Skip to content

Instantly share code, notes, and snippets.

@MDAR
Last active June 30, 2026 07:56
Show Gist options
  • Select an option

  • Save MDAR/a314aaca20aad4077954594185b08b97 to your computer and use it in GitHub Desktop.

Select an option

Save MDAR/a314aaca20aad4077954594185b08b97 to your computer and use it in GitHub Desktop.
MQTT IR RX TX MQTT ESP8266
Using a board like this
https://www.aliexpress.com/item/1005009427293908.html
I asked Claude Ai to create the Ardunio code to turn it into an MQTT device
The basic concept is
At first boot, without any WiFi details set, it starts a WiFi AP
Connect with to it with the password in the PDF user guide.
Set you SSID & MQTT broker details.
Reboot.
The device try the connect and subscribe to a topic formed from its MAC address, with TX & RX
More details in the PDF (download the raw text and save it with .pdf suffix then open it in your normal PDF viewer or a web browser.
Once it connects to your MQTT broker, you can harvest codes from your physical IR remote, then fire them back to the TX topic.
After that, you can do wonders with your own home automatio n or software logic.
If you want to take this project further, upload all files to a Claude AI session and ask it to adapt the code to your needs.
EG
Ask Claude to review all attached files and adapt the Arduino sketch to suit a different platform, like an ESP32
Likewise you can ask Claude to extend the functions.
If your Arduino / ESP device has buttons, you might want to fire out an IR blaster when a button is pressed
Long press the button to listen for a code
Short press to fire out that code
The options are endless.
Have fun with it.
// ============================================================
// IR MQTT Bridge — ESP8266MOD
// Board type NodeMCU 1.9 (ESP-12E) @ 115200 baud
// Receives IR signals → publishes to MQTT
// Subscribes to MQTT → transmits IR signals
//
// Topics (MAC = last 6 hex chars of ESP MAC address):
// ir/{MAC}/rx — ESP publishes received IR here
// ir/{MAC}/tx — ESP subscribes, send IR commands here
// ir/{MAC}/status — Online/offline LWT heartbeat
//
// AP Config Portal:
// Triggered after 5 consecutive boot failures
// Connect to SSID: IR-Config-{MAC} Password: irconfig
// Browse to: http://192.168.4.1
//
// Libraries required (install via Arduino Library Manager):
// - IRremoteESP8266 (by crankyoldgit)
// - PubSubClient (by Nick O'Leary)
// - ArduinoJson (by Benoit Blanchon) v6+
// LittleFS is built into ESP8266 Arduino core (no install needed)
// ============================================================
#include <ESP8266WiFi.h>
#include <ESP8266WebServer.h>
#include <PubSubClient.h>
#include <IRrecv.h>
#include <IRsend.h>
#include <IRutils.h>
#include <IRremoteESP8266.h>
#include <LittleFS.h>
#include <ArduinoJson.h>
// ------------------------------------------------------------
// PIN DEFINITIONS
// Adjust these if your board is wired differently
// ------------------------------------------------------------
#define IR_RECV_PIN 14 // GPIO14 = D5 — connect to TSOP OUT pin
#define IR_SEND_PIN 4 // GPIO4 = D2 — connect to NPN base (via 100Ω resistor)
#define CONFIG_LED 2 // GPIO2 = D4 — onboard LED, used to signal AP mode
// ------------------------------------------------------------
// IR SETTINGS
// ------------------------------------------------------------
#define IR_RECV_BUFSIZE 1024 // Buffer for raw IR timings
#define IR_RECV_TIMEOUT 15 // ms gap that signals end of IR packet
#define IR_SEND_KHZ 38 // Carrier frequency (38kHz is standard)
// ------------------------------------------------------------
// BOOT FAILURE COUNTER
// Stored in RTC memory — survives reboot, not power loss
// After BOOT_FAIL_THRESHOLD failures, AP mode is launched
// ------------------------------------------------------------
#define BOOT_FAIL_THRESHOLD 5
// RTC memory is 512 bytes; we use a small struct at slot 0
// The magic number confirms the memory contains valid data
struct RtcData {
uint32_t magic;
uint8_t failCount;
};
#define RTC_MAGIC 0xDEADBEEF
RtcData rtcData;
// ------------------------------------------------------------
// CONFIG — loaded from LittleFS /config.json
// ------------------------------------------------------------
struct Config {
char wifiSSID[64];
char wifiPass[64];
char brokerIP[40];
int brokerPort;
char brokerUser[32];
char brokerPass[32];
};
Config cfg;
// ------------------------------------------------------------
// GLOBALS
// ------------------------------------------------------------
WiFiClient wifiClient;
PubSubClient mqtt(wifiClient);
IRrecv irRecv(IR_RECV_PIN, IR_RECV_BUFSIZE, IR_RECV_TIMEOUT, true);
IRsend irSend(IR_SEND_PIN);
ESP8266WebServer apServer(80);
decode_results irResult;
String macSuffix; // Last 6 chars of MAC e.g. "A0B1C2"
String topicRX; // ir/{MAC}/rx
String topicTX; // ir/{MAC}/tx
String topicStatus; // ir/{MAC}/status
bool apMode = false;
// ------------------------------------------------------------
// FORWARD DECLARATIONS
// ------------------------------------------------------------
void loadConfig();
void saveConfig();
bool connectWiFi();
bool connectMQTT();
void launchAPMode();
void handleAPRoot();
void handleAPSave();
void mqttCallback(char* topic, byte* payload, unsigned int length);
void publishIR(decode_results* result);
void sendIRFromJSON(JsonDocument& doc);
void blinkLED(int times, int delayMs);
void readRTC();
void writeRTC();
// ============================================================
// SETUP
// ============================================================
void setup() {
Serial.begin(115200);
delay(200);
Serial.println("\n\n=== IR MQTT Bridge booting ===");
pinMode(CONFIG_LED, OUTPUT);
digitalWrite(CONFIG_LED, HIGH); // HIGH = LED off on most ESP8266 boards
// -- Derive MAC suffix --
uint8_t mac[6];
WiFi.macAddress(mac);
char macBuf[13];
snprintf(macBuf, sizeof(macBuf), "%02X%02X%02X%02X%02X%02X",
mac[0], mac[1], mac[2], mac[3], mac[4], mac[5]);
macSuffix = String(macBuf).substring(6); // Last 6 chars
topicRX = "ir/" + macSuffix + "/rx";
topicTX = "ir/" + macSuffix + "/tx";
topicStatus = "ir/" + macSuffix + "/status";
Serial.println("MAC suffix : " + macSuffix);
Serial.println("RX topic : " + topicRX);
Serial.println("TX topic : " + topicTX);
Serial.println("Status : " + topicStatus);
// -- LittleFS --
if (!LittleFS.begin()) {
Serial.println("[FS] LittleFS mount failed — formatting...");
LittleFS.format();
LittleFS.begin();
}
// -- Load saved config --
loadConfig();
// -- Read boot failure counter from RTC memory --
readRTC();
Serial.printf("[Boot] Fail count: %d / %d\n", rtcData.failCount, BOOT_FAIL_THRESHOLD);
// -- Check if we should enter AP mode --
// Go straight to AP if no SSID configured, or fail threshold reached
if (strlen(cfg.wifiSSID) == 0 || rtcData.failCount >= BOOT_FAIL_THRESHOLD) {
if (strlen(cfg.wifiSSID) == 0) {
Serial.println("[Boot] No SSID configured — entering AP config mode");
} else {
Serial.println("[Boot] Threshold reached — entering AP config mode");
}
launchAPMode();
return;
}
// -- Attempt WiFi --
bool wifiOK = connectWiFi();
if (!wifiOK) {
rtcData.failCount++;
writeRTC();
Serial.printf("[Boot] WiFi failed. Fail count now %d\n", rtcData.failCount);
delay(1000);
ESP.restart();
return;
}
// -- Attempt MQTT --
mqtt.setServer(cfg.brokerIP, cfg.brokerPort);
mqtt.setCallback(mqttCallback);
mqtt.setBufferSize(1024); // Increase for raw IR payloads
bool mqttOK = connectMQTT();
if (!mqttOK) {
rtcData.failCount++;
writeRTC();
Serial.printf("[Boot] MQTT failed. Fail count now %d\n", rtcData.failCount);
delay(1000);
ESP.restart();
return;
}
// -- All good — reset fail counter --
rtcData.failCount = 0;
writeRTC();
Serial.println("[Boot] Connected successfully. Fail counter reset.");
// -- Start IR --
irRecv.enableIRIn();
irSend.begin();
Serial.println("[IR] Receiver and sender initialised");
}
// ============================================================
// MAIN LOOP
// ============================================================
void loop() {
// -- AP mode: just serve the web portal --
if (apMode) {
apServer.handleClient();
// Blink LED slowly to indicate AP mode
static unsigned long lastBlink = 0;
if (millis() - lastBlink > 500) {
digitalWrite(CONFIG_LED, !digitalRead(CONFIG_LED));
lastBlink = millis();
}
return;
}
// -- Maintain MQTT connection --
if (!mqtt.connected()) {
Serial.println("[MQTT] Disconnected — attempting reconnect...");
if (!connectMQTT()) {
Serial.println("[MQTT] Reconnect failed — rebooting in 5s");
delay(5000);
// Increment fail counter before reboot
readRTC();
rtcData.failCount++;
writeRTC();
ESP.restart();
}
}
mqtt.loop();
// -- Check for received IR signal --
if (irRecv.decode(&irResult)) {
publishIR(&irResult);
irRecv.resume(); // Ready for next signal
}
// -- Periodic status heartbeat every 60 seconds --
static unsigned long lastHeartbeat = 0;
if (millis() - lastHeartbeat > 60000) {
String hb = "{\"status\":\"online\",\"mac\":\"" + macSuffix +
"\",\"ip\":\"" + WiFi.localIP().toString() + "\"}";
mqtt.publish(topicStatus.c_str(), hb.c_str(), true); // retained
lastHeartbeat = millis();
}
}
// ============================================================
// IR — PUBLISH RECEIVED SIGNAL TO MQTT
// ============================================================
void publishIR(decode_results* result) {
// Build JSON payload
StaticJsonDocument<2048> doc;
// -- Decoded fields --
doc["protocol"] = typeToString(result->decode_type);
doc["bits"] = result->bits;
char hexVal[20];
snprintf(hexVal, sizeof(hexVal), "0x%llX", result->value);
doc["value"] = hexVal;
char hexAddr[12];
snprintf(hexAddr, sizeof(hexAddr), "0x%X", result->address);
doc["address"] = hexAddr;
char hexCmd[12];
snprintf(hexCmd, sizeof(hexCmd), "0x%X", result->command);
doc["command"] = hexCmd;
doc["repeat"] = result->repeat;
// -- Raw timings array --
JsonArray raw = doc.createNestedArray("raw");
for (uint16_t i = 1; i < result->rawlen; i++) {
// rawbuf stores values in 2µs ticks — multiply to get microseconds
raw.add(result->rawbuf[i] * kRawTick);
}
// -- Serialise and publish --
String payload;
serializeJson(doc, payload);
Serial.println("[IR RX] " + payload);
mqtt.publish(topicRX.c_str(), payload.c_str());
}
// ============================================================
// IR — SEND SIGNAL FROM MQTT MESSAGE
//
// Expected TX payload (decoded):
// {
// "protocol": "NEC",
// "bits": 32,
// "value": "0x20DF10EF",
// "repeat": 0
// }
//
// Expected TX payload (raw fallback):
// {
// "protocol": "RAW",
// "raw": [9000, 4500, 560, ...],
// "khz": 38
// }
// ============================================================
void sendIRFromJSON(JsonDocument& doc) {
String protocol = doc["protocol"] | "UNKNOWN";
protocol.toUpperCase();
Serial.println("[IR TX] Sending protocol: " + protocol);
if (protocol == "RAW") {
// -- RAW mode --
JsonArray rawArr = doc["raw"].as<JsonArray>();
if (rawArr.isNull() || rawArr.size() == 0) {
Serial.println("[IR TX] RAW mode but no raw array found");
return;
}
uint16_t khz = doc["khz"] | 38;
uint16_t rawData[rawArr.size()];
int i = 0;
for (JsonVariant v : rawArr) {
rawData[i++] = v.as<uint16_t>();
}
irSend.sendRaw(rawData, rawArr.size(), khz);
Serial.printf("[IR TX] Sent RAW (%d timings @ %dkHz)\n", i, khz);
} else {
// -- Decoded protocol mode --
uint32_t bits = doc["bits"] | 32;
uint8_t repeat = doc["repeat"] | 0;
// Value can arrive as string ("0x20DF10EF") or number
uint64_t value = 0;
if (doc["value"].is<const char*>()) {
value = strtoull(doc["value"].as<const char*>(), nullptr, 16);
} else {
value = doc["value"].as<uint64_t>();
}
// Map protocol string to enum and send
decode_type_t protoEnum = strToDecodeType(protocol.c_str());
if (protoEnum == decode_type_t::UNKNOWN) {
Serial.println("[IR TX] Unknown protocol — cannot send");
return;
}
irSend.send(protoEnum, value, bits, repeat);
Serial.printf("[IR TX] Sent %s value=0x%llX bits=%d repeat=%d\n",
protocol.c_str(), value, bits, repeat);
}
}
// ============================================================
// MQTT CALLBACK — called when message arrives on subscribed topic
// ============================================================
void mqttCallback(char* topic, byte* payload, unsigned int length) {
Serial.printf("[MQTT] Message on topic: %s\n", topic);
// Parse JSON
StaticJsonDocument<1024> doc;
DeserializationError err = deserializeJson(doc, payload, length);
if (err) {
Serial.println("[MQTT] JSON parse error: " + String(err.c_str()));
return;
}
// Only act on TX topic
if (String(topic) == topicTX) {
sendIRFromJSON(doc);
}
}
// ============================================================
// WIFI — CONNECT
// ============================================================
bool connectWiFi() {
Serial.printf("[WiFi] Connecting to: %s\n", cfg.wifiSSID);
WiFi.mode(WIFI_STA);
WiFi.begin(cfg.wifiSSID, cfg.wifiPass);
unsigned long start = millis();
while (WiFi.status() != WL_CONNECTED) {
if (millis() - start > 10000) { // 10s timeout
Serial.println("[WiFi] Timeout");
return false;
}
delay(250);
Serial.print(".");
}
Serial.println("\n[WiFi] Connected. IP: " + WiFi.localIP().toString());
return true;
}
// ============================================================
// MQTT — CONNECT (with LWT)
// ============================================================
bool connectMQTT() {
String clientID = "IR-" + macSuffix;
// Build offline LWT payload
String lwtPayload = "{\"status\":\"offline\",\"mac\":\"" + macSuffix + "\"}";
Serial.printf("[MQTT] Connecting to %s:%d as %s\n",
cfg.brokerIP, cfg.brokerPort, clientID.c_str());
// LWT is passed directly into connect()
// connect(clientID, user, pass, willTopic, willQoS, willRetain, willMessage)
bool connected = false;
if (strlen(cfg.brokerUser) > 0) {
connected = mqtt.connect(
clientID.c_str(),
cfg.brokerUser, cfg.brokerPass,
topicStatus.c_str(), 1, true,
lwtPayload.c_str()
);
} else {
connected = mqtt.connect(
clientID.c_str(),
nullptr, nullptr,
topicStatus.c_str(), 1, true,
lwtPayload.c_str()
);
}
if (!connected) {
Serial.printf("[MQTT] Failed. State: %d\n", mqtt.state());
return false;
}
Serial.println("[MQTT] Connected");
// Publish online status (retained)
String onlinePayload = "{\"status\":\"online\",\"mac\":\"" + macSuffix +
"\",\"ip\":\"" + WiFi.localIP().toString() + "\"}";
mqtt.publish(topicStatus.c_str(), onlinePayload.c_str(), true);
// Subscribe to TX topic
mqtt.subscribe(topicTX.c_str());
Serial.println("[MQTT] Subscribed to: " + topicTX);
return true;
}
// ============================================================
// AP MODE — Launch config portal
// ============================================================
void launchAPMode() {
apMode = true;
String apSSID = "IR-Config-" + macSuffix;
WiFi.mode(WIFI_AP);
WiFi.softAP(apSSID.c_str(), "irconfig"); // Password: irconfig
Serial.println("[AP] Started SSID: " + apSSID);
Serial.println("[AP] IP: " + WiFi.softAPIP().toString());
Serial.println("[AP] Browse to: http://192.168.4.1");
// Register web routes
apServer.on("/", HTTP_GET, handleAPRoot);
apServer.on("/save", HTTP_POST, handleAPSave);
apServer.begin();
// Signal AP mode with LED
blinkLED(5, 100);
}
// ============================================================
// AP MODE — Serve config form
// ============================================================
void handleAPRoot() {
// Pre-fill form with existing saved values where available
String html = R"rawhtml(
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>IR Bridge Config</title>
<style>
body { font-family: sans-serif; max-width: 420px; margin: 40px auto; padding: 0 16px; background: #f4f4f4; }
h2 { color: #333; }
label { display:block; margin-top:14px; font-size:0.9em; color:#555; }
input { width:100%; padding:8px; margin-top:4px; box-sizing:border-box;
border:1px solid #ccc; border-radius:4px; font-size:1em; }
.opt { color:#888; font-size:0.8em; }
button { margin-top:24px; width:100%; padding:12px; background:#2a7ae2;
color:#fff; border:none; border-radius:4px; font-size:1em; cursor:pointer; }
button:hover { background:#1a5ab8; }
.mac { font-size:0.8em; color:#999; margin-top:8px; }
</style>
</head>
<body>
<h2>&#128268; IR Bridge Config</h2>
<form action="/save" method="POST">
<label>WiFi SSID</label>
<input type="text" name="wifiSSID" value=")rawhtml";
html += String(cfg.wifiSSID);
html += R"rawhtml(" required>
<label>WiFi Password</label>
<input type="password" name="wifiPass" value=")rawhtml";
html += String(cfg.wifiPass);
html += R"rawhtml(">
<label>MQTT Broker IP</label>
<input type="text" name="brokerIP" placeholder="192.168.1.x" value=")rawhtml";
html += String(cfg.brokerIP);
html += R"rawhtml(" required>
<label>MQTT Broker Port</label>
<input type="number" name="brokerPort" value=")rawhtml";
html += String(cfg.brokerPort > 0 ? cfg.brokerPort : 1883);
html += R"rawhtml(" required>
<label>Broker Username <span class="opt">(optional)</span></label>
<input type="text" name="brokerUser" value=")rawhtml";
html += String(cfg.brokerUser);
html += R"rawhtml(">
<label>Broker Password <span class="opt">(optional)</span></label>
<input type="password" name="brokerPass" value=")rawhtml";
html += String(cfg.brokerPass);
html += R"rawhtml(">
<button type="submit">Save &amp; Reboot</button>
</form>
<p class="mac">Device MAC: )rawhtml";
html += macSuffix;
html += R"rawhtml(</p>
</body>
</html>
)rawhtml";
apServer.send(200, "text/html", html);
}
// ============================================================
// AP MODE — Handle form submission
// ============================================================
void handleAPSave() {
// Read posted fields
if (apServer.hasArg("wifiSSID")) apServer.arg("wifiSSID").toCharArray(cfg.wifiSSID, sizeof(cfg.wifiSSID));
if (apServer.hasArg("wifiPass")) apServer.arg("wifiPass").toCharArray(cfg.wifiPass, sizeof(cfg.wifiPass));
if (apServer.hasArg("brokerIP")) apServer.arg("brokerIP").toCharArray(cfg.brokerIP, sizeof(cfg.brokerIP));
if (apServer.hasArg("brokerUser")) apServer.arg("brokerUser").toCharArray(cfg.brokerUser, sizeof(cfg.brokerUser));
if (apServer.hasArg("brokerPass")) apServer.arg("brokerPass").toCharArray(cfg.brokerPass, sizeof(cfg.brokerPass));
cfg.brokerPort = apServer.hasArg("brokerPort") ? apServer.arg("brokerPort").toInt() : 1883;
saveConfig();
// Reset boot fail counter
rtcData.failCount = 0;
writeRTC();
// Confirm page then reboot
String html = R"rawhtml(
<!DOCTYPE html><html><head><meta charset="UTF-8">
<meta http-equiv="refresh" content="5;url=/">
<style>body{font-family:sans-serif;text-align:center;margin-top:80px;}</style>
</head><body>
<h2>&#10003; Saved!</h2>
<p>Rebooting now — reconnect to your WiFi network.</p>
</body></html>
)rawhtml";
apServer.send(200, "text/html", html);
delay(2000);
ESP.restart();
}
// ============================================================
// CONFIG — Load from LittleFS /config.json
// ============================================================
void loadConfig() {
// Set safe defaults
memset(&cfg, 0, sizeof(cfg));
cfg.brokerPort = 1883;
File f = LittleFS.open("/config.json", "r");
if (!f) {
Serial.println("[Config] No config.json found — using defaults");
return;
}
StaticJsonDocument<512> doc;
DeserializationError err = deserializeJson(doc, f);
f.close();
if (err) {
Serial.println("[Config] JSON parse error: " + String(err.c_str()));
return;
}
strlcpy(cfg.wifiSSID, doc["wifiSSID"] | "", sizeof(cfg.wifiSSID));
strlcpy(cfg.wifiPass, doc["wifiPass"] | "", sizeof(cfg.wifiPass));
strlcpy(cfg.brokerIP, doc["brokerIP"] | "", sizeof(cfg.brokerIP));
strlcpy(cfg.brokerUser, doc["brokerUser"] | "", sizeof(cfg.brokerUser));
strlcpy(cfg.brokerPass, doc["brokerPass"] | "", sizeof(cfg.brokerPass));
cfg.brokerPort = doc["brokerPort"] | 1883;
Serial.println("[Config] Loaded from LittleFS");
}
// ============================================================
// CONFIG — Save to LittleFS /config.json
// ============================================================
void saveConfig() {
StaticJsonDocument<512> doc;
doc["wifiSSID"] = cfg.wifiSSID;
doc["wifiPass"] = cfg.wifiPass;
doc["brokerIP"] = cfg.brokerIP;
doc["brokerPort"] = cfg.brokerPort;
doc["brokerUser"] = cfg.brokerUser;
doc["brokerPass"] = cfg.brokerPass;
File f = LittleFS.open("/config.json", "w");
if (!f) {
Serial.println("[Config] Failed to open config.json for writing");
return;
}
serializeJson(doc, f);
f.close();
Serial.println("[Config] Saved to LittleFS");
}
// ============================================================
// RTC MEMORY — Read boot fail counter
// ============================================================
void readRTC() {
ESP.rtcUserMemoryRead(0, (uint32_t*)&rtcData, sizeof(rtcData));
if (rtcData.magic != RTC_MAGIC) {
// First ever boot — initialise
rtcData.magic = RTC_MAGIC;
rtcData.failCount = 0;
}
}
// ============================================================
// RTC MEMORY — Write boot fail counter
// ============================================================
void writeRTC() {
rtcData.magic = RTC_MAGIC;
ESP.rtcUserMemoryWrite(0, (uint32_t*)&rtcData, sizeof(rtcData));
}
// ============================================================
// UTILITY — Blink onboard LED
// ============================================================
void blinkLED(int times, int delayMs) {
for (int i = 0; i < times; i++) {
digitalWrite(CONFIG_LED, LOW); // LOW = on
delay(delayMs);
digitalWrite(CONFIG_LED, HIGH); // HIGH = off
delay(delayMs);
}
}
%PDF-1.4
%���� ReportLab Generated PDF document (opensource)
1 0 obj
<<
/F1 2 0 R /F2 3 0 R /F3 4 0 R /F4 6 0 R /F5 7 0 R
>>
endobj
2 0 obj
<<
/BaseFont /Helvetica /Encoding /WinAnsiEncoding /Name /F1 /Subtype /Type1 /Type /Font
>>
endobj
3 0 obj
<<
/BaseFont /Helvetica-Bold /Encoding /WinAnsiEncoding /Name /F2 /Subtype /Type1 /Type /Font
>>
endobj
4 0 obj
<<
/BaseFont /Symbol /Name /F3 /Subtype /Type1 /Type /Font
>>
endobj
5 0 obj
<<
/Contents 16 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 15 0 R /Resources <<
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
>> /Rotate 0 /Trans <<
>>
/Type /Page
>>
endobj
6 0 obj
<<
/BaseFont /Courier /Encoding /WinAnsiEncoding /Name /F4 /Subtype /Type1 /Type /Font
>>
endobj
7 0 obj
<<
/BaseFont /ZapfDingbats /Name /F5 /Subtype /Type1 /Type /Font
>>
endobj
8 0 obj
<<
/Contents 17 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 15 0 R /Resources <<
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
>> /Rotate 0 /Trans <<
>>
/Type /Page
>>
endobj
9 0 obj
<<
/Contents 18 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 15 0 R /Resources <<
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
>> /Rotate 0 /Trans <<
>>
/Type /Page
>>
endobj
10 0 obj
<<
/Contents 19 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 15 0 R /Resources <<
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
>> /Rotate 0 /Trans <<
>>
/Type /Page
>>
endobj
11 0 obj
<<
/Contents 20 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 15 0 R /Resources <<
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
>> /Rotate 0 /Trans <<
>>
/Type /Page
>>
endobj
12 0 obj
<<
/Contents 21 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 15 0 R /Resources <<
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
>> /Rotate 0 /Trans <<
>>
/Type /Page
>>
endobj
13 0 obj
<<
/PageMode /UseNone /Pages 15 0 R /Type /Catalog
>>
endobj
14 0 obj
<<
/Author (\(anonymous\)) /CreationDate (D:20260523113141+00'00') /Creator (\(unspecified\)) /Keywords () /ModDate (D:20260523113141+00'00') /Producer (ReportLab PDF Library - \(opensource\))
/Subject (\(unspecified\)) /Title (\(anonymous\)) /Trapped /False
>>
endobj
15 0 obj
<<
/Count 6 /Kids [ 5 0 R 8 0 R 9 0 R 10 0 R 11 0 R 12 0 R ] /Type /Pages
>>
endobj
16 0 obj
<<
/Filter [ /ASCII85Decode /FlateDecode ] /Length 1994
>>
stream
Gau`U>Bed\&:WeDbTl?qXs@5cm)u!_g@XG9BjU@>5_'bh;d[\hrq[YWfGKL&EO0;A2Q8haI<3l]-VAsJ2q:,_!PleW5$q-Z$jIB<Q=7Vf)U8SYT,kMk[$b?LbbO+KNo>$1`-%W+SI-,c'n1<78uQB$0Gm.Xr\eWQ\/*$3%YE,d*J4?/@)0,YlK:$$:(<Q#Ss#,-Hh@Y'%tanQ`,[sT7kj]=K26I!WJ+A;e/63<WaVe(K'hIML=+d1$kCh(d=ue9&eW#m-H?0ocndZMhn.+@DskmXZhE$;\RDZ.i35V?D]Q`Lr6OD7-e)o5Y\Z/Loium36bde364Fn4YKM`=o0V:r+`<DRC98/tV$9'.r"Q:H(sHO]p\Wh$=AC/%dF)k[PF"!N3lYE7.0,aV3*?$^J2E2%"T5J:C)BEh4GK3j;Pl5ro$c)1c,:I';IV?nneYo[&,#Peq:Wq'a=rUhLM(m[+j0p2#3!#XU5)D*NhS3AUD,YLi?UnR0ar3<)/tkX>bh\=79L&*-kP<LBb^?AN'^(lP#MR02'gi\m7H6:deT%O1.hEumD3Wr(<;L!B0D(1!hF"(5CCqfI6@':rbsJ4M'f4q45Wl-'?ZH&=)6Q"&O`SuZoX2A3@&V`qX_XM5;1eM>NY#sKZ2`5+f$:lP.tB:@8oO<Y[n6o8NINc/1Kmr$;3CO_]g&FAMSn7.OBI&'1r?_UEchcpb7_779o]:mAgMkh-rC8V%AG.nY\^Ud1BJcA#p8"e8Xh'.7BRZ:mF<fTH=$PnF^&#@Lflk/g6QY2BOl7,\I=Q.Aun;EGCgIIsM*_!FO57>oJd`B-skh)OO&8K=ZHb6AHD\N<^.k_@-0G"'&];KRq*:K3Qt[O3oMcAFc^0GS[-9%>Mo?f>`:)3K.0KZ&6,g\r*[l@;4\ETu2.Ea!(t:VE7jrm-O4Ii9tL+hm]!?gp^81A!Ie,L]'D9Th3TicX]lf?=b%G7_p8U7@;Y,!k##d&WH2!!8c^\8[cF74f$L^G'\"hI=5aPgY)#l%=E"!F%k7UhQ1WjT6g!cm3.$:$9'`)Wc_eb$09m&ld$SJP919?SQ\<9cK:jPO)2"\N9hUfrO[F*AYr]%##+dalT['=)VRu^fYHQ$A;Di:qrqf*/oT'^ATPM2)XMJ$CZdk+kk@5`gGn!/!BaCM_6ZZLXS@;H8E1g?&#`5[p?)g)F6W;]p"#.j.Q,hQI-TMDhj0s-@m=4T?Fn"@)Sg5.INeZWi4G\uab)(lqWuoq;[s<\g&PpTpB0(Q,PRu=I@"nN4UVJL$8E<BMN;79;:H`6is^c/&bEIpm[>^;lJjAngO3ajVerk,\/nUeQ*[5:Nj0<D\Hl^q_eBYS?4Yqn;FNr;fiot^4*MbuZ,\-C@^.:4lU!TOJ:E>]dYFb"M5l\?9N=U!lDTF7L4=\hgjokI01H-(#hF&t4fZ\%Gt0=eAE,PH-rs;^2H'1C,#XJu#U<.\iF==2Rj,EZ3?&TD/`7R5I#Z$%<j6$:ioO<>lcP""p4k]Ka#k=*4l0?-81Cn$;F.PggN!Qt+k+8W+<087o6.R5P*G+<@5fhe]F:=mpEmN[eK;kqDc2KN"4pC^XU$;"T>(8rZuA5q+s4YL0(6"3E!0Tm?B4;H3Ah8$gAHRW0="Cj5qO.qY!T*\56u1-FuX[1k\)iVqmjW4<i2UI-2Z+Rj%[DC"p2j+@mU$'\B*r\Hl9UU0Z_o]T7^eYM?#O]S*fEqMOP(pCD+'6#V1(LQhs5qCBJR.UK>EMP?X_^C:YEX&ba/:*<f=aAh(`"P7Y2h5i/#Ca^/F_`ja4lG@CJRNQS$qUaqCcON0n]+Okf0@sO,LR,?l/]#ce-aHj355p7;`1L@lh4Td9;jk/do\;*t]O]j%`X<FED/+096eW&&YY)3M\&',?&nmlHT<'77a@*"[VaHaDc.-.esM40_r*J`1.-dPe5qB;KpR0N;A%t"0tbn^cf#D$^.rs<oH&s"i*Vs`j:*3SBWp1*pQ7m#)n(O^-#dJ~>endstream
endobj
17 0 obj
<<
/Filter [ /ASCII85Decode /FlateDecode ] /Length 2352
>>
stream
Gb!#]D0+/c&H:NnZ(Gn8;d;t]h`>g#BPEhh/X.RjG;K3WfLfZ==M^;ShQO\ZhdRsQS=&0W;+b<$[&\V^g1OIud.)nPs8#B=M^b0U#nRHm3=r&BR,5X/o]5lU.7[Q@ZWuc.YUu^af5bjPMY15E4J&2M;CaV5\h"Q\\O'5o)dUL`oV)W@Y?W?C_iN/s[n2[8Z%2.a=7?Qohf,17T(bKEX-@F6KZOmoro3]uf<HE/!Jd.`Q?^OCnHs0IfR'^^LHI`Ga%]nkcp$L90&TE%1Fu-5E6SElXuVe:(li_'!T-[0U*Ikb1eMUsihp5L[_ig1Z'n1"@&BWcbV7ra:.dLi?_;!a,K.#UF%si_%/dK3akV3.DDl)]]psf5iD2JRC(?s"@>[q45.ssD'c>BdkQ4*C?GB]!AeZsh_-Q@HQ2B7F)2(j]a47q`[C;Q/LnrK:`=6J(EF-`??'_Q[au@Pp#I"N3@h(JhpBb16afd%8%^iBE#`%(%'/+f\(/aQ<b'quZI@g\CmkccXEKGbg#8i%V%tF'_Y4!MC+(Hrr:EeZ1#^BMj+0?/!KeK1-qtso2+7JH"2Oe6`b:)Fk\#0t*6O\>Nr?qC45UjD*q]`aDF,5t5cdO?<\J$Y5$*:F8'W-dBI`rC!ZWZQ@i=)@Q^dn-:j)2a\.==ZTgl(##r>.R3TnJ?FMuCU9)`;qg_<VBGM_'h0=Bp,CMNR[Bo'%BJIu]2;Jqm3h+pZ[hD1i0bT&e#2WuVTEJA9u:1^AoQp[:m7"+Jg+Y[$?uobbLj([4YFhKl:_"u_3X-rS::']EOJZ<@lV/RU/IX>3Q[/)N<4XhLu5;<AXU.rkMaaZk!S/+R<OD3J8k!N+'*!@eaebgqp%%'4)Xbf",`]XbFnQ&,ghGLgJ_B3qoL#VQKl_PP1\YU>F[l`eXR3fXO#pB%.@WPE<R9-.`b)<C@=g;>c>VXl[#[m+pd=26VMBTD+6@(7MH/$4dWU*PeF@]5Q_NEO(sPrBQW$C-!E,<5DW]_1oga#j\,f"38!<UXfoO@=P`^5#QWdaa8:8--2d?WuoSVO,=U5DnLB9,F]2POP38g=rZ>[hg!la-63&EdLY%C+E34B0l8]<ID)M^fl>1<k)Ck7!^/m<,^q=Y50?6LQAQ=W>#ZiY%qCZXAhAu41jK7fN:'eWNI<SC:`.*[78AJ/@/s4O=Fj!<>=\beVsEeMP%5[g%%/+bO?8[2kcHJ+&="Q@><H965\_pOUDY1Lt(gG0//KM/oAB/r_"RZN\1*i(k(Lsf'aMu*DNGdeX9naI]E[#D]`[`\P?kt3i3Rfr919g0p4@1LIm@K-%pq9i%j33/Fd8\LTo?&TiTcC$G%^Mau[YGcgDM+S)mdiiM?maYMksj#fobd4V2SsKp[I`\^]P@_Tu'OFAh\k8u`#J+W#3,T#)J&S`WMdRj4t6@89IJ,?#%G\XuGEe+1mKW4?l%?Deaef*IJE]hF;0K&G\?4Te_34A!A-JceY.hhQ"qYr1N_1PXkuQ2Gh4j%:n%PteAd"O(#<p&(t8@#%jfiu#Z.]&<)gBE5q6O_K8<6sT*Nc.+nkg+IhH+n8TA&3XfZE=a5-/I%AlJILGhf+Q];#P@bu(*1iTp@OtG"euTAD!deLE(dGr6ndB"pOJ"/.d2A_WLQMnlJ,jG@qs#0jb7N!?$LXH#fIlL//_@;P663[blKip;[gtr_[Zsa"V,gO^62rJ>%KeW=-4h>/q8c>l$P#;?/NCG:B-funip@#m9rs"i6U1Z,8K2E<LmG'[r3q)l^H/;?TWUf])NiP*USWb;S0'5)n5s6Q'\A(9!F:IQo%F"h;4M$3`i*6)fo(#oQ]tAKc/En=X&<laCpIc*`Z4SmFO\IebRl9E@EYuUG8l41bD^$+716;pWsb]?Xq(;@@WM`(6_CeGI],2^Xu0?F'](o#Z$7B=Upt%)6pb(n=R/)R!;0kJ&"3KXM/VNO,(P6@&&e7\h8PsI*HARd*n_`_1FK%?LY&AIC=8If@R2Na#/3XHp"K;Y6T7ClO?:Yh0]m52d6$c87.;A<\&*l>C!P4`E^`^XVnL7@B^Al26VJ-(>.<Z'A(U4ib!2>gbLDUn!7MO8o,L<.-U*Uf6?j\g"0Bq$5>DdO=U2-D8G2D`$KSeF8(q@2)X2XSM+I<Sq#)'K^UYYfGZXK`ljbH-m[]/TUh@m&3_UbmppJ;Xrk;"qZ#DUq@8ks.9=Zt&51k4O!+.^HWm%-N*$q,.nbkNS;,ZU#cBtGf*o:Vo3`,,?5.L0I<RpVrE&MFi.%Tk^^GaIV^[FI;2-oc&$A'1:t8Z9qfNkh]<,jifA<0)GQ7'?gO%-C@VeYm>O5%4AR7OL$Qop.!7&R6KA4$nLQd2gL?D#<~>endstream
endobj
18 0 obj
<<
/Filter [ /ASCII85Decode /FlateDecode ] /Length 2006
>>
stream
Gb!#\=\mdE'Ro4H*;(a[?tSqRl!'kCW`:u#=&jCR&&t$!Bl+B*+eYk8rLOnI90tO'/)@(>m$Y3)=hb7HJfeb-O&LSh+WR%NTF?QbTW[=ln,p<DmbR<Y)5U-!_"4L_&$A8e018"ZG!_NnhZG5??Joi$p6fkeaKP=njRWPbJigj2*"JBe>f-7^au#_.M'0u42TTH&jUu>ZOu13&p,MEd@6>*q[_L-T>Gh`DVL8cf8/N?0qM3;RMpBLo(k.8!9-a*<5Fl?-KL:Ec4"9qOMq*C]hf5XAG7sgCNh\'Q5ntdqi%((;^ralNq7D`6(8Z&XDg#"K8T/ds/oj0haTH'ONoUJq(`mda_c;,X>i&L2QN]cW3K[IDii#g;>N<D0,ZReX;k8uX8?>&9],eih(>$7?J)C!j!:%>^J*f69VRFPPUid&:huX_KJiE#_9=mp\f1ET5'[,a[*/!H7fGk-33%,7%&/`.TV%lN'>.`4#2_VN)Zc-0XNYeu4+2Yf*$p,K@IlW)+>(YLX=>1N?2h@[XKYrHj1smK)&8]\rrQ4FK$n(J#HFG4Be=gQ$AufmFZa%*EEZ6UZ/rJj&UK_KK0Z'*8o$JanXFP:''b_ufSO_9E/dEIt=XN_3ic<q-G9mr'I])Tfrjf\i]#6_P7W>-#Ql9sE((La1DT+DKnt8R5JYbAc+6#\3.P^rqQ(g+#5m4`i>&gQqWCdefF,1]G,gR@#lB-0hf4sVX%gSR0phKgb[jAh87u_F93/mYc7c3/GnH8M_`<:>&0H?t0^WaurJT0Q-fl2bF^JXOd?Jk#`6.")o]J^X1>+DeD1Sn*(?Ak"^,SrgMAa6^enF?/XL2/nPpD?AYC5I?iSj[i<UJD9E@rug$WM7cG9=cf#*_nCJ;Aj'5+U0A*(j2%d<"om6)+I$N/!PkK0Zjcn%S__GWcFi7dKV:#EOeN:6pMa/pnL`$To$s;9<^<RVT>b+q\Xp%#<XF',7g+l6T!b"Oq!"3T1M.38t-D%Fii*1PW($0Fts:\1KR?[QT51cs#msMi.Qp<:/o6$j87KjIZM?!<IU^$7=`gPeXSS_%KfSi7mF7TIN@PERpEO^Jn*OEB<@ZLmp.^-[7u'e#AT4&KpTnb!4BJg*+DdV_Y(1o9cFIYWiJN&'C=?bM2=>`#U&T?kcn]/aJGF9fH/n?2`;8k>c?s>9%MoNs#E@o2[<n1q(i$FoUm$sD2sI^6V&oYXoE2*j7#g=+8p1_k1I1B%)uN\2S5SGa8JFEQ.;?#bq'"0kW%/TA@!Z8$K`i^Y0,gf*fnQ)e7WaYK,Kk[%JUc4>Y>ZY(_$%ZGe)^X'8N[H$p-knqQMK-7N@$BX*tfm7]O7LQsj'$k,:.6faP2.A2UP0:iQrI42U+C\hD-`^A\o:OE<Ik'D<RBgkKL91A>Uh/Q/0'GH)MOlA1kqp8-GLM51`$3umpp6ukDc/\rN!dc^0JM.-cLq(:<g?'%)IL'Xi;Op6;*kLos<QIk2bShM--%Xc`jKi)?6E7Y/iRT.gVl9aV?q*4.<+V':+rDd<W_hDMP^P]%R_-UW:A%ij(^'2[=Icu$-=RAPD2uCpT_]H['"I/$QnKiKPFG-mgEq'(fo;AaOeeh"Jh[n-,$)/s<ir^2DlM!_7r72EoUD6lucKh/\_JTVq[L7[=he`RDCC^fdcCUr@YHKo,Hel5oc#eWM,9N;`fl`>]N/%smf:GX"mHpou[Fms\OS2r1!n$g^$V1A1F?Sml5o?8ur#$P=RkGc:,!it_gno&Ue&1+A29I7W4X-B!(H8lWFf^cCYNFG$]fgX\Y`e;Z0-^iES?4-QMip[bC*<80]K)hLmHGNXZcgX(?K/-HMKPBIh/is)Y_FZp2_lj!R)W!$a>H*-FFiDCO:ZFd\)`Fc(#^_q:bUTlJd_U%G5U&<c1!.n`*M%`.!JmIkLT'T`9(0g9>>R]ojZ:o2@-NC%D,5Fg=(L]NOAR2X@[5EAZ7<+%6%"(G.G@VI@?Y,+?Y$dJKi-lqW-`;;P)^\@IHF8Mnf;~>endstream
endobj
19 0 obj
<<
/Filter [ /ASCII85Decode /FlateDecode ] /Length 2251
>>
stream
Gatm<968iG&AJ$Cm.eq)3]7G/"@]184,l>h3&>iYj$V8t6pVG8MGsT&ZC8mbmfF1F;FJXS1eo6W])WWDm=)Z_$3YjWq`e?fgl:7SSc\Rn1^+eD+cIK#mHEQQ"l3o_^)&)E6=K'g4dnbFY!]I\YUGM5f`+pWM]?22)+N,6j!4ikK+]m:q;MK/_t3/Q@gE/R/V+-"ba\J@&o.Vi5ACBVqABM,$:b%P+.#bc/&gW9h3[3U-bnM:o(N(Rb.TjC;Mg[sAoB,4e\AYup^JnV_3t/5"%_'a5](0.8d(NpOn0u@*bG%_K9(BhX"r/f#+fC9ShAsm?s4s,o0lueF',EY'PW]oc`^ZI2C&W]Z]KuF*tg+cL;`"cY?Pe[pG)JgqfiMX^><)`=$d2'g3FF=rAQ:]HU4FfPR>/"]AJ^hr80Y)K(t+1JgEtnMN1IEIlcpWQ.A_'#VI5SG_,a%*IMqS.VY_AaPF\WRdF;rbltTcJ>k#a^_+gja<r]nV9k[W"Je/80n'feM/OaS'hl%:R@=9.[E':e&)J,tg24,HMu[b2i;VbgX3"@inNk<!VSo.4,4u?p^<$^G\['OAY$>NXG;o":3LFIJ"oaC7*qP/Lp"*UU7N7%7]=AY\GPZdM:*803qSo;]SJ43D+,11gK89rrd"Ul6K275k;Su<"^pS_`R:S&bg@1r[bmUVLXNq`U3,i"0DO'[-b)C(B@cEG)WTQSMK!5F0hs(`J](u3jp<C&cm0miNhhkhoc.l`<\BI$"CW&$dJ1el'<H9W)aQ6;n,W>ep[\TW0(Z""_[hg]jQ7re02h!cppe`-@lt/No[581m\RD+)MEJf2?g_NL/R7jg?iQGId5V&rrhWleld<ZFBG>gC$iS6hdC5DF?=r/<HGIHD[N.?p3%tBt$cRXe%`PPu:&IQL;Yo4)dHFDA@!R'C*.<:t3R/MC@Y#,'l,,t*nBr"DDIeXD%+B]@\#O@c9g$8u6Ui&t@8LON7-_/#F6"O)'i-rqn=,g'da+;fO;Gu>'/VF^#mbPp-8&_+R=hW+AhMp2\kibFmKuXGm9MPfeJqBOZ(:fm'5'P7Y&H8EANn#mb/G99fij1lek%>C7-1AkqP^FE/sX$4p&`qUC$`ei%XMhBM#BdDo<+G0%PVA8KbZNV@RNp*a>uZ6Mo+Bu:E(,Z6]_#YGJ$$`?5uDDI5H%ds.k4VNCYgXO<Kg@=Pp:[\cMGdOAZMGQ;3/u$^5n%+1'7mODIuq>>6'-n=\(+S]U,eC"1[Alt&7WN2HcYG'7I&2f;U[(;+B!rU1\En.YA&E=p]?OgKHF5&HT9bhLuVT\*7"L9^7J*;:Ql[M6F6V&$aWqGH6@(a&e(On\Eln"aibhR6>fIE]d;^:l)kh_k+O>I&QAY,EePD4VM0X>IdW5*:Q$kKbpC;&./^O7N-R!]$V6oX96J@Y/t[H)=AiE7X9_;hLELgM=CsJ-QFXiV#Dc/[8NGD[aLC[)f.cAt)EI29q1a]tV%V.kse$rFCTba*rF268NXjCu)m)K>hV[K:mN.*;'/rQuehY`I.i0oE`@N:M,S"bZn:gc6fb-rG?5#b?8Gi0@i$02eaW.GU'Is_O"/tMemHG_^(e2FO=4_8LTN0GZWtST[6_ECG?$9H)sl_md;(LIfB[Wc>j?9G(^a"brGladmVik?ud0^o1\q#4*eF]][S6GdA^d4PJE]6ZJLU?I#X\TZ3+(?2d^!gdn(B8A6FW/B.e%?^37V@%%?*ZB^D+BnS:3B0qBMd@;j`L1D*D<m6A(6jfIJm/osC_MpdBi^HsDM>08`aVS(,JB+:\-\KX",(2n"iJ5JG\;VOlI9?S'b!&D3WDCWgJ91iK4OK3YX]f!AR`U6n!?`N1m`j\66AB6fDEs/`+;1j38juW94KACiFM(^/1ERoVMK@O*Phc3U+AX9&I(9Fq%/Yfh%F:tG6BTVk:<&(JF6EXTSE>SAB_QFeXG&$M(c]DTiY)P$N3%nZe=66a1W6UX/6p<Eu@jU?/W30`%bj(He!JqnRoBefo*5/eq:oS%K*-Bn^4&VWpYlpsc=CZ(b-"f$-/FRJ,GGl%Y]0K:n`HAp7C@ofnYe.SUMH$pqSc/afk:IBZRc8]R/[N$nWWB7p<MS1u=[K)S*GT]q^jlAhGVN0I5Q5ib"gDlVQ"A^CgW'^d3>(K_aNeSL;=(7RCh+o_NlXCBU2X0JG!okB6s>]n<]VH%fSJaO)>:<NZ(4=b?:4["'iB-_o?c4[7tF%^MX0`=oRm$lMuNc\X7b'~>endstream
endobj
20 0 obj
<<
/Filter [ /ASCII85Decode /FlateDecode ] /Length 1724
>>
stream
Gb!<O>>s99'S,*:'kbs]C^\)M4hT/XB%Ugk5W\ej(YMXu\$kA?16C2Bs1V"/j4Hp3m^0S^,m2)JFEF[XN^COu(GUF"%,gL]:+lnb)eKI#m_jh4#Ip-AI6SqK9])H:;gV+Z#V>QWf,`ZgSD^p%K^VDE@ZdWc?t.lAlf!?6[t;s1\NI`'8Y\c]ZOCu\EHeO;D/0U#dg:CS'#'%>7'5BNbh:+o`VVl-p(_2C[=YlCFmqbrV*el?a4!*^:Wk[T>C&NW]]9^FRnY"SJANKFO!bMkfGnS(6q10#3%,qJ:b/R(;93YZ4L-fG<;^u&Rn[;!@_eRKQD^g7LnP5*<qo1efLGTi$;Fucas<Nn"/Ai6-tsj#OkJB7FU+%&k">o@TQ4@4i/.W3nHraT$J(pScEqa7N+h6<A("KFJr@T,-L7@b[:A-.AYVjKp``k,'#g5$;5gQ4I^*b%J?)HoIh'#NC,3U#JNF1Od-8ddht<HF3^\9RY^FZR!8h1=Gh)D'L'15*&<ir!SKS-m;2,!/`u*U:QmE8V#J(@E%q$PL"r'QWmgW*bfIbo?$J<r$RI_<VJIse#J<!=d0lFN<Pui=;:]cM0.Unf69L#`d-J*"/;kW2m-B6A?Sq<,al/]_*9URA(n2%loKh5bi<E;"t`Cr._j@ng.Wgs1%TMfB"?pHP3QaDB&p&*0\@.ukiMdm3#i;,DrS;,sK+;l_:)lYDRL>_mIO&"e,(T(IsCA/UR`g-#4=1n.WUops73b't.-ZS=5Zd'51nQ"K#>B/PGL1fS/7YhZK=!Cd[>`$`Nm6&'p;7Sf*;U>2kFb_]F]4US8A/Nk"g[;X.OsR#?Z*UUFl`.QkLUW``kBkqGPd>I&-<\1Dd_\<n5s$I8-7:/:n?>5iKDEpjEI-kLUXeE[DsF>#WBp$SD&$Y$&I;mhf>,Q\_Q"4V/2g0<XJH/f,$8u.+B>J!o'$u*rbCj9MQ3t4N"lM%i`H^jd)B.U.eJHDMf_+t,OX'ROKea5Fo?88rpuYeCJNaUFSuS4l+5?GDB/me3++\4g]c`-4YTp03\g0XL(Xf,U1n*4nbaEEF0S9-n0h9ZZu'!VI&_dFp9Gi:d!"tucaPj2o'(?`S<2'us7?6PqM,?E;d&H\fR[1#f;YG7!EJB/_84@8ZugdPV;=%:_KIY7R(S$W2O4c:F@`lKC/qDcg=FAaC&g7I,YkG?V#dIYC++g=ms5Y.9S6Y"3@&8br&g5a\7aS6K6;Tp`o#r1:)7OCgNG7!YHL(u>O),7L^pf:=8XS,e\R8KlY&>&_jEK[N/.;an!gUY&sZ+eFQ9O4/XZoSeT._L!$d'H%`i%0&Nk/^&4Wbj=ba>31^%$T.Si2pS%hJ('M?:f*M&1m0A_\K7o?"<d@"JdNuht<1AopRZE<kf`4EIs@EEOZe`b6RV\bZb]Sh:@e8)kKJ8YtMRumUn?CqJcG$prBrrq-REBCjU3_9*VH[\-9G$.hI3ol;%<$@i[\4Zc'W8=dAV(mVFZgB:1[_*0[,u*.]K>+""8@U,9HR9Ch>[OZ'+r(D=aA,fD+"asgSqOVuKV,0?gSUm_!#oSeE8TnJ?WK7qZr52CDXO?*0/hMi&\O9IZ,)eN?X?39;QNWX372SS`o7`p8Wb$_6V8S!Im!iia26G8B:oC7U!ucm6g81(A,XA>%n]o&2*H167#mF2;O#?cm,,=5Cir!\"4!pA;3WO`nLgrih7CFXSCP0U$XYmi(NnQfM?~>endstream
endobj
21 0 obj
<<
/Filter [ /ASCII85Decode /FlateDecode ] /Length 1687
>>
stream
Gb!kth/h:0&:`lHfZ=eD;hJq'9Q'8A?Za^fRO2J**^Up$Ohm#/EOHk([/&Tt-(>Fui/h:6V$@;nZD:e)G4jTq9PEJ@<1eUJE&)rEhZVD[5lbN;keQ*hc3K?D)eLhR[1'NWp$dn,)J7=_iAu@&oGlJ(D()m8UqsjaPo\I54Kmr,XuK#sGi-$IFXV4>1Hr*A?Asl.g?NmT-e2n2G;=_]5@_D53I;!`DpX0M@=1bVJYF#?;_#-d>O1fQbVG;%l@VdfCISu>5N3q+&)X>r%,S&bjqH,ic,_*gA?:dhWV,7m@MH0J=RB,A7gVh([^eU^mN,S<lTd\Q_23GPS_<,Po1n1'Fq5X/,beUfnoBGCR*:<0TWlD>)&'bMA3',U>(UW*]h,0D'"2B5Z7#DuoT/WCKLtJp)u6/!JG:ut%pIu'"A<0XUMYY;fZOJ:0>huQ]e/E/5d"au=au-^mTYqNdk)VD69sjk68oFIlXI&8"p8`#]!UA[Z7(um9s35896Cu+ek8@gM<I_B>RntF0eFCXNNC;Ph:j(2ETR%qAVK%#=cfCr'VSKOWblVBe-=ftpsIpY)fs+[7N3/N4YU^FP2+OO48I?QE\kaa[AFdp4"<Kh.4gkojS=RsAYm2JGXN5'!>N`[JjW4b5?05bO,I3HkNF#,>R8]%;@(sCc7",q&M5-e5b_ou5]JfH>(%;Q;73=Jj-[&*OBecL`&JgXe9DukVq3gHX6WC-[SO\Wb6^`>VVC@D>stcP?VE/D]T;^"JTr+JhQ=KqVQ!tp'PoqMJ\_ET8L!@gHk)KR_$`c5f<Sn).'X0p\k"1bWU)3*3_JJ<"D8KI6"/'&8+^U'Uu['$%2d?L!"nCT.7E-i`%t)J?J!7Vh+_#FNf'-6mYZmfN5d_Pnc",gUV>(mm,D(3(7q1YID3>mnus37f/1)l7V?f7<sfL#"l+$+,97PY*dPiF5k+8aqWq&,es%.X:1p&fCPlVuaBf^"'eZMA\R7q^'NcG+H2aqiWNX%&mTKoKjfk;?!a$&UIqN$3kU;q5!_bZ:F5:P4#pr-:^$ijCS\5;#^YI^F5t%%<hegP>'JeD%5p4q6NVHQ?WCWt7PJUURYE+C)H$PE_n&urVDJ(ichrbP'>Z-_=)9N8UA3s]#KrJ,i:<<nXF:*XLAT1:T&QucL]]D*l]"KDt(s#Z,PfZ?D?qqImcP&(eLG*>RO14:bW8B#TDm,\JoJDs/c7*"2=%_G][2F[4B_K0`7]E#5,Fr#]V+K,3J.ZIC)(g9#Y<6?A>)t,(?@=(Uk+Yh*Pff]!rj_6_jWnJnH#m0(LU+j%Dd1ijPg:,QlRQsh>ObfE@EQ2]Dn=rh[9B+6S0T[P($\VXqGV<2Op<9]96;_kbR,5%"*6"loKB6]o)7kKoEE^Ip!K9Bi_eo(egeFq.ODpQqV(Ds#kcfsBl@7ss1"_dAF`-_N#oheiI^qh3WrU7NC']hK9/Q)o1ACK'jgG>KjheF$q:r8U*%8ic/Qp00k<g<@Qd9uj&g!RL+>`0`dAa1=Gk5kFcSl:ZsCSh3c$1cRl8\l0`ftNJN;$Ogb99Kolp9>GtY;sOh#cuF1!0C]:BSZbmcNsc@8$b>bfoiJfdR0XFA=9D;e&k%9L9TPuc>i`@].V"AY,!4J@&e35>@U=g!*oI0PCpr$\'>5#Fc_,%qH2S44bSHk6>UW,#8g#E=35=Y_Rg~>endstream
endobj
xref
0 22
0000000000 65535 f
0000000061 00000 n
0000000132 00000 n
0000000239 00000 n
0000000351 00000 n
0000000428 00000 n
0000000633 00000 n
0000000738 00000 n
0000000821 00000 n
0000001026 00000 n
0000001231 00000 n
0000001437 00000 n
0000001643 00000 n
0000001849 00000 n
0000001919 00000 n
0000002200 00000 n
0000002293 00000 n
0000004379 00000 n
0000006823 00000 n
0000008921 00000 n
0000011264 00000 n
0000013080 00000 n
trailer
<<
/ID
[<57c82911784a4bfa49da6ee2c5adcc93><57c82911784a4bfa49da6ee2c5adcc93>]
% ReportLab generated PDF document -- digest (opensource)
/Info 14 0 R
/Root 13 0 R
/Size 22
>>
startxref
14859
%%EOF
================================================================
IR MQTT BRIDGE — CLAUDE SESSION CONTEXT FILE
Upload this file to a new Claude session to resume work
exactly where this conversation left off.
================================================================
PROJECT SUMMARY
---------------
We designed and built complete Arduino firmware for an ESP8266MOD
board that acts as a two-way IR / MQTT bridge. The firmware is
complete, compiled, flashed, and confirmed working.
The user has the following files already generated:
- ir_mqtt_esp8266.ino (complete Arduino sketch, working)
- IR_MQTT_Bridge_UserGuide.pdf (user guide including Node-RED section)
- IR_MQTT_Bridge_ClaudeContext.txt (this file)
================================================================
CONFIRMED HARDWARE
================================================================
Board: ESP_IR_TR_WIFI (303ESPIRTR3)
AliExpress ESP-12F based IR transceiver board
Chip marking reads ESP8266MOD on the module
USB-C port present but requires CH340 driver on Win11
NO physical buttons on this board
IR Receiver: Built into the board (38kHz demodulator)
IR LED: Built into the board (with transistor driver)
Status LED: GPIO2 (D4) onboard — blinks during AP mode
Board MAC suffix (permanent, confirmed): A7A272
RX topic : ir/A7A272/rx
TX topic : ir/A7A272/tx
Status : ir/A7A272/status
Pin Definitions (top of .ino file):
#define IR_RECV_PIN 14 // D5
#define IR_SEND_PIN 4 // D2
#define CONFIG_LED 2 // D4
================================================================
FLASHING / UPLOAD PROCEDURE
================================================================
Programmer: Prolific USB-to-TTL adapter
Windows port: COM12
Wiring: TX -> RX, RX -> TX, GND -> GND, 3.3V -> 3.3V
(DO NOT use 5V — ESP-12F is 3.3V only)
To enter flash/bootloader mode (no buttons on this board):
1. Locate the IO0 and GND pads on the board header
2. Bridge IO0 to GND with a jumper wire
3. Apply power (plug in TTL adapter)
4. Wait one second, remove the jumper
5. Click Upload in Arduino IDE
Arduino IDE settings (confirmed working):
Board : NodeMCU 1.0 (ESP-12E Module)
Port : COM12
Reset Method : nodemcu
Upload Speed : 115200
Flash Mode : DOUT
Flash Size : 4MB (FS:2MB OTA:~1019KB)
Memory usage at compile (confirmed):
RAM (globals) : 42% used — fine
Flash (code) : 36% used — fine
IRAM : 94% used — watch if adding large features (OTA etc)
================================================================
ARDUINO LIBRARIES REQUIRED
================================================================
Install all via Arduino IDE -> Tools -> Manage Libraries:
1. IRremoteESP8266 by crankyoldgit
Purpose: IR send and receive, protocol decode/encode
Supports NEC, Sony, Samsung, Panasonic, RC5, RC6, and 100+ more
2. PubSubClient by Nick O'Leary
Purpose: MQTT client for ESP8266/ESP32
3. ArduinoJson by Benoit Blanchon (VERSION 6 or later)
Purpose: JSON serialisation and deserialisation
Built-in (no install needed):
- LittleFS (part of ESP8266 Arduino core)
- ESP8266WiFi (part of ESP8266 Arduino core)
- ESP8266WebServer (part of ESP8266 Arduino core)
================================================================
BUGS FIXED FROM ORIGINAL SKETCH
================================================================
BUG 1: PubSubClient setWill() does not exist
Error: 'class PubSubClient' has no member named 'setWill'
Fix: LWT passed directly into mqtt.connect() instead.
Original broken code:
mqtt.setWill(topicStatus.c_str(), lwtPayload.c_str(), true, 1);
connected = mqtt.connect(clientID.c_str(), ...);
Fixed code (in connectMQTT()):
connected = mqtt.connect(
clientID.c_str(),
cfg.brokerUser, cfg.brokerPass, // or nullptr, nullptr
topicStatus.c_str(), 1, true,
lwtPayload.c_str()
);
BUG 2: AP mode required 5 boot failures even on fresh flash
Problem: RTC memory can reset between power cycles on this board,
making the fail counter unreliable. Fresh flash had no
config so it would loop forever rather than enter AP mode.
Fix: Added immediate AP mode trigger if wifiSSID is empty.
Added to setup() before the fail counter check:
if (strlen(cfg.wifiSSID) == 0 || rtcData.failCount >= BOOT_FAIL_THRESHOLD) {
if (strlen(cfg.wifiSSID) == 0) {
Serial.println("[Boot] No SSID configured — entering AP config mode");
} else {
Serial.println("[Boot] Threshold reached — entering AP config mode");
}
launchAPMode();
return;
}
================================================================
MQTT TOPIC DESIGN
================================================================
Topics use the last 6 hex chars of the board WiFi MAC address
as a unique suffix. Confirmed MAC suffix for this board: A7A272
ir/{MAC}/rx ESP publishes received IR signals here
ir/{MAC}/tx ESP subscribes — publish IR commands here
ir/{MAC}/status Online/offline heartbeat (retained, QoS 1)
The MAC suffix is printed to Serial on every boot:
MAC suffix : A7A272
================================================================
MQTT PAYLOAD FORMAT
================================================================
RX PAYLOAD (ESP to Broker, on ir/A7A272/rx):
{
"protocol": "NEC",
"bits": 32,
"value": "0x20DF10EF",
"address": "0x04",
"command": "0x08",
"repeat": false,
"raw": [9000, 4500, 560, 1690, 560, 560, ...]
}
TX PAYLOAD decoded protocol (Broker to ESP, on ir/A7A272/tx):
{
"protocol": "NEC",
"bits": 32,
"value": "0x20DF10EF",
"repeat": 0
}
TX PAYLOAD raw fallback:
{
"protocol": "RAW",
"raw": [9000, 4500, 560, 1690, 560, 560],
"khz": 38
}
STATUS / LWT PAYLOADS (ir/A7A272/status, retained):
Online: { "status": "online", "mac": "A7A272", "ip": "192.168.1.42" }
Offline: { "status": "offline", "mac": "A7A272" }
LWT is set with retain=true, QoS=1. Broker sends offline message
automatically if the board drops without a clean disconnect.
Heartbeat published every 60 seconds.
================================================================
CONFIGURATION / AP MODE DESIGN
================================================================
Config stored in LittleFS as /config.json:
{
"wifiSSID": "MyNetwork",
"wifiPass": "mypassword",
"brokerIP": "192.168.1.10",
"brokerPort": 1883,
"brokerUser": "",
"brokerPass": ""
}
AP MODE TRIGGERS (either condition launches AP mode):
1. No SSID stored (fresh flash / wiped config)
2. Boot fail counter >= 5 consecutive failures
Fail counter stored in RTC memory (survives reboot, not power loss)
Note: RTC memory can be unreliable on this specific board between
power cycles. The no-SSID check is the more reliable trigger.
AP MODE DETAILS:
SSID: IR-Config-A7A272
Password: irconfig
IP: 192.168.4.1
Portal: http://192.168.4.1 (lightweight custom HTML form)
On save: Writes config.json, resets fail counter, reboots
================================================================
BOOT SEQUENCE LOGIC
================================================================
Boot
|
|-- Read RTC fail counter
|-- Load config.json from LittleFS
|-- If no SSID stored OR counter >= 5 -> Launch AP mode (stops here)
|
|-- Attempt WiFi (10s timeout)
| |-- Fail -> increment counter, write RTC, restart
|
|-- Attempt MQTT connect (with LWT passed into connect())
| |-- Fail -> increment counter, write RTC, restart
|
|-- Success -> reset counter to 0, write RTC
|-- Publish online status (retained)
|-- Subscribe to ir/A7A272/tx
|-- Start IR receiver and sender
================================================================
MAIN LOOP BEHAVIOUR
================================================================
- If AP mode: serve web portal, blink LED at 500ms, nothing else
- If normal mode:
- Maintain MQTT connection (reconnect if dropped)
- If MQTT reconnect fails: increment fail counter, reboot
- Check IR receiver for incoming signals, publish JSON to rx topic
- Handle MQTT messages on tx topic, fire IR signal
- Publish heartbeat to status topic every 60 seconds
================================================================
NODE-RED INTEGRATION
================================================================
Catch all IR from all boards:
MQTT In node, Topic: ir/#, QoS: 1
Wire to Debug node
Catch only received IR from all boards:
MQTT In node, Topic: ir/+/rx, QoS: 1
Wire to JSON node then Debug node
Send to one specific board:
Inject node, Payload: {"protocol":"NEC","bits":32,"value":"0x20DF10EF","repeat":0}
MQTT Out node, Topic: ir/A7A272/tx, QoS: 1
Send to all boards (Function node approach):
var boards = ["A7A272", "B1C2D3"];
var msgs = boards.map(function(mac) {
return { topic: "ir/" + mac + "/tx", payload: msg.payload };
});
return msgs;
Wire each output to an MQTT Out node (topic set from msg.topic)
Learn a button (capture and replay):
1. Subscribe to ir/+/rx
2. Point remote at board, press button
3. Copy JSON from Debug panel
4. Paste as payload of Inject node wired to ir/A7A272/tx
================================================================
THINGS NOT YET DONE / POSSIBLE NEXT STEPS
================================================================
1. OTA (Over-the-Air) firmware updates
- Add ArduinoOTA library so you can reflash over WiFi
- Useful once the board is mounted/installed
- Would also reduce IRAM pressure concern
2. AC unit support
- IRremoteESP8266 has special classes for AC protocols
- e.g. IRDaikinESP, IRMitsubishiAC
- These use structured objects (temperature, mode, fan speed)
rather than simple hex values
3. Home Assistant MQTT Discovery
- Auto-register device in HA without manual config.yaml edits
- Publish to homeassistant/sensor/{MAC}/config on boot
4. Web UI for IR learning
- Add a /learn page served in normal operating mode
- Press a button on a remote, capture and display the code
- No need to use Serial Monitor or Node-RED to learn codes
5. Button-triggered AP mode
- This board has no buttons, but IO0 pad could be wired to one
- Hold IO0 to GND for 3-5 seconds to trigger AP mode
- Faster recovery than waiting for 5 boot failures
6. Multiple IR zones
- Multiple boards, each with unique MAC suffix
- Subscribe to ir/+/rx in Node-RED / HA to catch all of them
- Each board independently connected and identified
7. IR repeat / blaster mode
- Some devices need IR repeated N times to register
- Could be added as a "repeat_count" field in tx payload
================================================================
REFERENCE: KEY CODE SECTIONS IN ir_mqtt_esp8266.ino
================================================================
setup() -- Boot sequence, MAC derivation, WiFi, MQTT init
loop() -- AP portal handler, MQTT keepalive, IR receive loop
publishIR() -- Builds and publishes rx JSON payload
sendIRFromJSON() -- Parses tx JSON and fires IR signal
mqttCallback() -- Called by PubSubClient on incoming tx message
connectWiFi() -- WiFi connection with 10s timeout
connectMQTT() -- MQTT connect with LWT inside connect(), publishes online
launchAPMode() -- Starts AP, registers web routes
handleAPRoot() -- Serves HTML config form
handleAPSave() -- Processes form POST, saves config, reboots
loadConfig() -- Reads /config.json from LittleFS
saveConfig() -- Writes /config.json to LittleFS
readRTC() -- Reads boot fail counter from RTC memory
writeRTC() -- Writes boot fail counter to RTC memory
================================================================
HOW TO USE THIS FILE IN A NEW CLAUDE SESSION
================================================================
Upload ir_mqtt_esp8266.ino AND this context file to a new Claude
chat, then say something like:
"Here is the context file and sketch from a previous session.
I need help with [your question]."
Claude will have everything it needs to:
- Continue debugging the sketch
- Add new features (see next steps above)
- Modify pin assignments or topic names
- Help with Node-RED or Home Assistant integration
- Explain any part of the code
================================================================
END OF CONTEXT FILE
================================================================
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment