From 5d3b19671a68e5209535785643c416a18854fbfc Mon Sep 17 00:00:00 2001 From: Andy Date: Sun, 26 Jul 2026 09:06:54 +0100 Subject: [PATCH 01/10] feat: Migrate to Home Assistant MQTT with auto-discovery - Replace Adafruit IO with direct Home Assistant MQTT connection - Implement MQTT auto-discovery for 3 main sensors + 3 diagnostic sensors - Add graceful offline publish queue (50-entry circular buffer) - Add exponential backoff reconnection with jitter (30s -> 10min) - Add WiFi recovery monitoring and continuous reconnection - Add diagnostic sensor publishing (WiFi RSSI, MQTT status, uptime) - Create MqttHandler class encapsulating all resilience logic - Add config.h template with device identity and connection settings - Update .gitignore to exclude config.h credentials from version control Device info: - Name: Smart Litter Tray - Version: 2025.07.26 - Manufacturer: andycb Resilience improvements: - Survives prolonged WiFi/MQTT outages without hard reset - Queues sensor data during disconnection - Implements exponential backoff to prevent network flooding - Designed for multi-month uptime without intervention --- .gitignore | 11 + src/MqttHandler.h | 465 +++++++++++++++++++++++++++++++++++ src/SmartLitterBoxSketch.ino | 120 +++------ src/config.h.example | 74 ++++++ 4 files changed, 591 insertions(+), 79 deletions(-) create mode 100644 src/MqttHandler.h create mode 100644 src/config.h.example diff --git a/.gitignore b/.gitignore index 259148f..1a2ee15 100644 --- a/.gitignore +++ b/.gitignore @@ -30,3 +30,14 @@ *.exe *.out *.app + +# Configuration files with credentials +config.h + +# IDE +.vscode/ +.idea/ + +# OS +.DS_Store +Thumbs.db diff --git a/src/MqttHandler.h b/src/MqttHandler.h new file mode 100644 index 0000000..7487c90 --- /dev/null +++ b/src/MqttHandler.h @@ -0,0 +1,465 @@ +/* + * MQTT Handler for Home Assistant Auto-Discovery + * + * Manages: + * - Home Assistant MQTT discovery + * - Graceful offline publish queue + * - Exponential backoff reconnection + * - WiFi monitoring and recovery + * - Diagnostic sensor publishing (WiFi RSSI, MQTT status, uptime) + */ + +#ifndef MQTT_HANDLER_H +#define MQTT_HANDLER_H + +#include +#include +#include +#include "config.h" + +// Maximum length for MQTT topic and payload +#define MQTT_MAX_TOPIC_LEN 256 +#define MQTT_MAX_PAYLOAD_LEN 256 + +// MQTT connection states +enum MqttConnectionState { + MQTT_DISCONNECTED, + MQTT_CONNECTING, + MQTT_CONNECTED, + MQTT_FAILED +}; + +// WiFi connection states +enum WiFiConnectionState { + WIFI_DISCONNECTED, + WIFI_CONNECTING, + WIFI_CONNECTED +}; + +// Publish queue entry structure +struct MqttQueueEntry { + unsigned long timestamp; + char topic[MQTT_MAX_TOPIC_LEN]; + char payload[MQTT_MAX_PAYLOAD_LEN]; + bool retain; +}; + +class MqttHandler { +private: + WiFiClientSecure espClient; + PubSubClient mqttClient; + + // Connection state tracking + MqttConnectionState mqttState; + WiFiConnectionState wifiState; + unsigned long lastMqttAttempt; + unsigned long lastWifiAttempt; + unsigned long deviceStartTime; + int mqttBackoffLevel; // 0-4 for 30s, 1m, 2m, 5m, 10m + int wifiBackoffLevel; + + // Queue for offline publishes + MqttQueueEntry publishQueue[MQTT_QUEUE_DEPTH]; + int queueHead; + int queueTail; + int queueCount; + + // Diagnostics tracking + unsigned long lastDiagnosticPublish; + int lastWifiRssi; + + // Private methods + void sendDiscoveryPayload(const char* sensorId, const char* sensorName, const char* unitOfMeasure); + void drainQueue(); + void handleMqttReconnect(); + void handleWifiReconnect(); + unsigned long getBackoffDelay(int backoffLevel, bool isWifi); + void addJitter(unsigned long& delay); + void publishDiagnostics(); + bool publishTopic(const char* topic, const char* payload, bool retain); + +public: + MqttHandler(); + + // Lifecycle methods + void setup(); + void tick(); // Called every loop iteration + + // Publishing methods + void publishReading(float catWeight, float poopWeight, int durationMs); + + // Status methods + MqttConnectionState getConnectionStatus(); + bool isConnected(); + unsigned long getUptime(); +}; + +// Constructor +MqttHandler::MqttHandler() + : mqttClient(espClient), + mqttState(MQTT_DISCONNECTED), + wifiState(WIFI_DISCONNECTED), + lastMqttAttempt(0), + lastWifiAttempt(0), + deviceStartTime(0), + mqttBackoffLevel(0), + wifiBackoffLevel(0), + queueHead(0), + queueTail(0), + queueCount(0), + lastDiagnosticPublish(0), + lastWifiRssi(0) +{ +} + +// Setup WiFi and MQTT connections +void MqttHandler::setup() { + Serial.println("Initializing MQTT Handler..."); + + // Record device start time + deviceStartTime = millis(); + + // Configure MQTT client + mqttClient.setServer(HA_MQTT_SERVER, HA_MQTT_PORT); + mqttClient.setBufferSize(512); + + // Configure SSL/TLS with Mozilla root CA + // Using insecure mode for testing - replace with proper certificate in production + espClient.setInsecure(); + + // Initial WiFi connection attempt + handleWifiReconnect(); +} + +// Main tick function - called each loop iteration +void MqttHandler::tick() { + // Check WiFi status + if (WiFi.status() != WL_CONNECTED) { + if (wifiState == WIFI_CONNECTED) { + Serial.println("WiFi disconnected!"); + wifiState = WIFI_DISCONNECTED; + lastWifiAttempt = millis(); + wifiBackoffLevel = 0; // Reset backoff on detection + } + handleWifiReconnect(); + } else if (wifiState != WIFI_CONNECTED) { + wifiState = WIFI_CONNECTED; + wifiBackoffLevel = 0; + Serial.print("WiFi reconnected. IP: "); + Serial.println(WiFi.localIP()); + } + + // Check MQTT status + if (!mqttClient.connected()) { + if (mqttState == MQTT_CONNECTED) { + Serial.println("MQTT disconnected!"); + mqttState = MQTT_DISCONNECTED; + lastMqttAttempt = millis(); + } + if (wifiState == WIFI_CONNECTED) { + handleMqttReconnect(); + } + } else if (mqttState != MQTT_CONNECTED) { + mqttState = MQTT_CONNECTED; + mqttBackoffLevel = 0; + Serial.println("MQTT connected!"); + + // Send discovery payloads on successful connection + sendDiscoveryPayload("cat_weight", "Cat Weight", "kg"); + sendDiscoveryPayload("poop_weight", "Poop Weight", "kg"); + sendDiscoveryPayload("poop_duration", "Poop Duration", "s"); + sendDiscoveryPayload("wifi_rssi", "WiFi Signal", "dBm"); + sendDiscoveryPayload("mqtt_connected", "MQTT Connected", ""); + sendDiscoveryPayload("uptime", "Uptime", "s"); + + // Drain any queued publishes + drainQueue(); + } + + // Keep MQTT connection alive + if (mqttClient.connected()) { + mqttClient.loop(); + + // Publish diagnostics at regular intervals + unsigned long now = millis(); + if (now - lastDiagnosticPublish >= DIAGNOSTIC_PUBLISH_INTERVAL) { + publishDiagnostics(); + lastDiagnosticPublish = now; + } + } +} + +// Publish a cat litter usage reading +void MqttHandler::publishReading(float catWeight, float poopWeight, int durationMs) { + char topic[MQTT_MAX_TOPIC_LEN]; + char payload[MQTT_MAX_PAYLOAD_LEN]; + + Serial.println("Publishing reading..."); + + // Publish cat weight + snprintf(topic, sizeof(topic), "%s/sensor/%s/cat_weight/state", + HA_MQTT_TOPIC_PREFIX, DEVICE_UNIQUE_ID); + snprintf(payload, sizeof(payload), "%.2f", catWeight); + publishTopic(topic, payload, false); + + // Publish poop weight + snprintf(topic, sizeof(topic), "%s/sensor/%s/poop_weight/state", + HA_MQTT_TOPIC_PREFIX, DEVICE_UNIQUE_ID); + snprintf(payload, sizeof(payload), "%.2f", poopWeight); + publishTopic(topic, payload, false); + + // Publish duration + snprintf(topic, sizeof(topic), "%s/sensor/%s/poop_duration/state", + HA_MQTT_TOPIC_PREFIX, DEVICE_UNIQUE_ID); + snprintf(payload, sizeof(payload), "%d", durationMs / 1000); // Convert to seconds + publishTopic(topic, payload, false); +} + +// Internal: Publish a single topic (handles queuing if offline) +bool MqttHandler::publishTopic(const char* topic, const char* payload, bool retain) { + if (mqttState != MQTT_CONNECTED) { + // Queue the publish + if (queueCount < MQTT_QUEUE_DEPTH) { + int newTail = (queueTail + 1) % MQTT_QUEUE_DEPTH; + strncpy(publishQueue[queueTail].topic, topic, MQTT_MAX_TOPIC_LEN - 1); + strncpy(publishQueue[queueTail].payload, payload, MQTT_MAX_PAYLOAD_LEN - 1); + publishQueue[queueTail].retain = retain; + publishQueue[queueTail].timestamp = millis(); + queueTail = newTail; + queueCount++; + + if (queueCount > MQTT_QUEUE_DEPTH - 5) { + Serial.print("Warning: Publish queue depth: "); + Serial.println(queueCount); + } + } else { + // Queue full - drop oldest entry + Serial.println("ERROR: Publish queue full, dropping oldest entry"); + queueHead = (queueHead + 1) % MQTT_QUEUE_DEPTH; + + // Add new entry + strncpy(publishQueue[queueTail].topic, topic, MQTT_MAX_TOPIC_LEN - 1); + strncpy(publishQueue[queueTail].payload, payload, MQTT_MAX_PAYLOAD_LEN - 1); + publishQueue[queueTail].retain = retain; + publishQueue[queueTail].timestamp = millis(); + queueTail = (queueTail + 1) % MQTT_QUEUE_DEPTH; + } + return false; + } + + // MQTT connected - publish immediately + bool success = mqttClient.publish(topic, payload, retain); + if (!success) { + Serial.print("Failed to publish to: "); + Serial.println(topic); + } + return success; +} + +// Internal: Send Home Assistant discovery payload +void MqttHandler::sendDiscoveryPayload(const char* sensorId, const char* sensorName, const char* unitOfMeasure) { + char topic[MQTT_MAX_TOPIC_LEN]; + char payload[512]; // Discovery payload can be large + + snprintf(topic, sizeof(topic), "%s/sensor/%s/%s/config", + HA_MQTT_TOPIC_PREFIX, DEVICE_UNIQUE_ID, sensorId); + + // Build discovery JSON + snprintf(payload, sizeof(payload), + "{\"name\":\"%s\"," + "\"unique_id\":\"%s_%s\"," + "\"state_topic\":\"%s/sensor/%s/%s/state\"," + "\"device\":{\"identifiers\":[\"%s\"],\"name\":\"%s\",\"manufacturer\":\"%s\",\"sw_version\":\"%s\"}", + sensorName, + DEVICE_UNIQUE_ID, sensorId, + HA_MQTT_TOPIC_PREFIX, DEVICE_UNIQUE_ID, sensorId, + DEVICE_UNIQUE_ID, DEVICE_NAME, DEVICE_MANUFACTURER, DEVICE_VERSION); + + // Add unit of measurement if specified + if (strlen(unitOfMeasure) > 0) { + strncat(payload, ",\"unit_of_meas\":\"", sizeof(payload) - strlen(payload) - 1); + strncat(payload, unitOfMeasure, sizeof(payload) - strlen(payload) - 1); + strncat(payload, "\"", sizeof(payload) - strlen(payload) - 1); + } + + strncat(payload, "}", sizeof(payload) - strlen(payload) - 1); + + // Publish with retain flag + mqttClient.publish(topic, payload, true); + Serial.print("Published discovery: "); + Serial.println(sensorId); +} + +// Internal: Drain the publish queue +void MqttHandler::drainQueue() { + int published = 0; + unsigned long drainStart = millis(); + + while (queueCount > 0 && (millis() - drainStart) < 5000) { // Max 5 second drain window + if (!mqttClient.publish(publishQueue[queueHead].topic, + publishQueue[queueHead].payload, + publishQueue[queueHead].retain)) { + // Publish failed, stop draining + break; + } + + Serial.print("Drained queued publish: "); + Serial.println(publishQueue[queueHead].topic); + + queueHead = (queueHead + 1) % MQTT_QUEUE_DEPTH; + queueCount--; + published++; + + // Small delay between publishes to respect rate limits + delay(50); + } + + if (published > 0) { + Serial.print("Drained "); + Serial.print(published); + Serial.println(" queued publishes"); + } +} + +// Internal: Handle MQTT reconnection with exponential backoff +void MqttHandler::handleMqttReconnect() { + if (mqttState == MQTT_CONNECTING || mqttState == MQTT_CONNECTED) { + return; // Already connecting or connected + } + + if (wifiState != WIFI_CONNECTED) { + return; // WiFi not connected + } + + unsigned long now = millis(); + unsigned long backoffDelay = getBackoffDelay(mqttBackoffLevel, false); + + if (now - lastMqttAttempt < backoffDelay) { + return; // Not time to retry yet + } + + Serial.print("Attempting MQTT connection (backoff level "); + Serial.print(mqttBackoffLevel); + Serial.println(")"); + + mqttState = MQTT_CONNECTING; + lastMqttAttempt = now; + + // Attempt connection + if (mqttClient.connect(DEVICE_UNIQUE_ID, HA_MQTT_USERNAME, HA_MQTT_PASSWORD)) { + mqttState = MQTT_CONNECTED; + mqttBackoffLevel = 0; // Reset backoff + Serial.println("MQTT connected!"); + } else { + mqttState = MQTT_DISCONNECTED; + mqttBackoffLevel = min(mqttBackoffLevel + 1, 4); // Cap at level 4 (10 minutes) + Serial.print("MQTT connection failed, code: "); + Serial.println(mqttClient.state()); + } +} + +// Internal: Handle WiFi reconnection with exponential backoff +void MqttHandler::handleWifiReconnect() { + if (wifiState == WIFI_CONNECTING) { + return; // Already connecting + } + + if (WiFi.status() == WL_CONNECTED) { + return; // Already connected + } + + unsigned long now = millis(); + unsigned long backoffDelay = getBackoffDelay(wifiBackoffLevel, true); + + if (now - lastWifiAttempt < backoffDelay) { + return; // Not time to retry yet + } + + Serial.print("Attempting WiFi connection (backoff level "); + Serial.print(wifiBackoffLevel); + Serial.println(")"); + + wifiState = WIFI_CONNECTING; + lastWifiAttempt = now; + + WiFi.reconnect(); + + wifiBackoffLevel = min(wifiBackoffLevel + 1, 4); // Cap at level 4 +} + +// Internal: Calculate backoff delay based on level (with jitter) +unsigned long MqttHandler::getBackoffDelay(int backoffLevel, bool isWifi) { + unsigned long baseDelay; + + if (isWifi) { + baseDelay = WIFI_RETRY_DELAY_MS; + } else { + baseDelay = MQTT_RETRY_DELAY_MS; + } + + // Exponential backoff: 0→base, 1→2x, 2→4x, 3→8x, 4→16x (capped at MAX) + unsigned long delay = baseDelay; + for (int i = 0; i < backoffLevel; i++) { + delay *= 2; + if (delay > MAX_BACKOFF_DELAY_MS) { + delay = MAX_BACKOFF_DELAY_MS; + break; + } + } + + addJitter(delay); + return delay; +} + +// Internal: Add ±10% jitter to delay +void MqttHandler::addJitter(unsigned long& delay) { + // Generate random jitter: ±10% + int jitterPercent = random(-10, 11); // -10 to +10 + delay = delay + (delay * jitterPercent / 100); +} + +// Internal: Publish diagnostic sensors +void MqttHandler::publishDiagnostics() { + char topic[MQTT_MAX_TOPIC_LEN]; + char payload[MQTT_MAX_PAYLOAD_LEN]; + + // WiFi RSSI + int rssi = WiFi.RSSI(); + if (rssi != lastWifiRssi) { + snprintf(topic, sizeof(topic), "%s/sensor/%s/wifi_rssi/state", + HA_MQTT_TOPIC_PREFIX, DEVICE_UNIQUE_ID); + snprintf(payload, sizeof(payload), "%d", rssi); + publishTopic(topic, payload, false); + lastWifiRssi = rssi; + } + + // MQTT connection status + snprintf(topic, sizeof(topic), "%s/sensor/%s/mqtt_connected/state", + HA_MQTT_TOPIC_PREFIX, DEVICE_UNIQUE_ID); + snprintf(payload, sizeof(payload), "%s", mqttState == MQTT_CONNECTED ? "true" : "false"); + publishTopic(topic, payload, false); + + // Uptime (in seconds) + unsigned long uptime = getUptime(); + snprintf(topic, sizeof(topic), "%s/sensor/%s/uptime/state", + HA_MQTT_TOPIC_PREFIX, DEVICE_UNIQUE_ID); + snprintf(payload, sizeof(payload), "%lu", uptime); + publishTopic(topic, payload, false); +} + +// Get current MQTT connection status +MqttConnectionState MqttHandler::getConnectionStatus() { + return mqttState; +} + +// Check if MQTT is connected +bool MqttHandler::isConnected() { + return mqttState == MQTT_CONNECTED; +} + +// Get device uptime in seconds +unsigned long MqttHandler::getUptime() { + return (millis() - deviceStartTime) / 1000; +} + +#endif // MQTT_HANDLER_H diff --git a/src/SmartLitterBoxSketch.ino b/src/SmartLitterBoxSketch.ino index 16b1e99..f4ebb08 100644 --- a/src/SmartLitterBoxSketch.ino +++ b/src/SmartLitterBoxSketch.ino @@ -1,112 +1,73 @@ #include -#include "Adafruit_MQTT.h" -#include "Adafruit_MQTT_Client.h" - -#include -#include -#include -#include +#include #include +#include "config.h" +#include "MqttHandler.h" #include "SmartLitterBox.h" -/******************************** Setup *************************************/ - -#define WLAN_SSID "" -#define WLAN_PASS "" - -#define AIO_SERVER "io.adafruit.com" -#define AIO_SERVERPORT 8883 -#define AIO_USERNAME "" -#define AIO_KEY "" - -/****************************************************************************/ - -// WiFiFlientSecure for SSL/TLS support -WiFiClientSecure client; - -// Setup the MQTT client class by passing in the WiFi client and MQTT server and login details. -Adafruit_MQTT_Client mqtt(&client, AIO_SERVER, AIO_SERVERPORT, AIO_USERNAME, AIO_KEY); +// MQTT handler instance +MqttHandler mqttHandler; -// io.adafruit.com SHA1 fingerprint -static const char *fingerprint PROGMEM = "4E C1 52 73 24 A8 36 D6 7A 4C 67 C7 91 0C 0A 22 B9 2D 5B CA"; - -Adafruit_MQTT_Publish catWeightFeed = Adafruit_MQTT_Publish(&mqtt, AIO_USERNAME "/feeds/catWeight"); -Adafruit_MQTT_Publish poopWeightFeed = Adafruit_MQTT_Publish(&mqtt, AIO_USERNAME "/feeds/poopWeight"); -Adafruit_MQTT_Publish poopDurationFeed = Adafruit_MQTT_Publish(&mqtt, AIO_USERNAME "/feeds/poopDuration"); - -SmartLitterBox smartLitterBox = SmartLitterBox(); - -void MQTT_connect() -{ - int8_t ret; - - // Stop if already connected. - if (mqtt.connected()) - { - return; - } - - Serial.print("Connecting to MQTT... "); - - uint8_t retries = 3; - while ((ret = mqtt.connect()) != 0) - { - Serial.println(mqtt.connectErrorString(ret)); - Serial.println("Retrying MQTT connection in 5 seconds..."); - mqtt.disconnect(); - - delay(5000); // wait 5 seconds - retries--; - if (retries == 0) - { - // basically die and wait for WDT to reset - while (1); - } - } - - Serial.println("MQTT Connected!"); -} +// Smart Litter Box instance +SmartLitterBox smartLitterBox; void setup() { Serial.begin(9600); + + delay(1000); // Give serial time to initialize + Serial.println("\n\nSmart Litter Box starting up..."); // Turn off the WiFi AP WiFi.mode(WIFI_STA); - // Connect to wifi + // Connect to WiFi and initialize MQTT handler + Serial.print("Connecting to WiFi: "); + Serial.println(WLAN_SSID); WiFi.begin(WLAN_SSID, WLAN_PASS); - while (WiFi.status() != WL_CONNECTED) - { + + // Wait up to 30 seconds for initial WiFi connection + int retries = 60; // 30 seconds (60 * 500ms) + while (WiFi.status() != WL_CONNECTED && retries > 0) { delay(500); Serial.print("."); + retries--; + } + + if (WiFi.status() == WL_CONNECTED) { + Serial.println("\nWiFi connected"); + Serial.print("IP address: "); + Serial.println(WiFi.localIP()); + } else { + Serial.println("\nWiFi initial connection timeout - will retry with backoff"); } - Serial.println("WiFi connected"); - Serial.println("IP address: "); Serial.println(WiFi.localIP()); - - // check the fingerprint of io.adafruit.com's SSL cert - client.setFingerprint(fingerprint); - - MQTT_connect(); - + // Initialize MQTT handler + mqttHandler.setup(); + + // Attach callback for new litter box readings FunctionSlot ptrSlot(OnNewReading); smartLitterBox.LitterUsage.attach(ptrSlot); + + Serial.println("Setup complete!"); } void loop() { + // Tick smart litter box sensor logic smartLitterBox.Tick(); - MQTT_connect(); + + // Tick MQTT handler (manages WiFi/MQTT reconnection, queue draining, diagnostics) + mqttHandler.tick(); } void OnNewReading(CatLitterUse catLitterUse) { - catWeightFeed.publish(catLitterUse.CatWeight); - poopWeightFeed.publish(catLitterUse.PoopWeight); - poopDurationFeed.publish(catLitterUse.Duration); + // Publish reading to Home Assistant via MQTT + mqttHandler.publishReading(catLitterUse.CatWeight, catLitterUse.PoopWeight, catLitterUse.Duration); + // Log reading to serial Serial.println("------------------------"); Serial.print("Cat Weight = "); Serial.println(catLitterUse.CatWeight); @@ -115,6 +76,7 @@ void OnNewReading(CatLitterUse catLitterUse) Serial.println(catLitterUse.PoopWeight); Serial.print("Duration = "); - Serial.println(catLitterUse.Duration); + Serial.print(catLitterUse.Duration); + Serial.println(" ms"); Serial.println("------------------------"); } diff --git a/src/config.h.example b/src/config.h.example new file mode 100644 index 0000000..9b09cb0 --- /dev/null +++ b/src/config.h.example @@ -0,0 +1,74 @@ +/* + * Smart Litter Box Configuration Template + * + * IMPORTANT: Copy this file to config.h and fill in your actual values. + * config.h is excluded from version control (.gitignore) to protect credentials. + */ + +#ifndef CONFIG_H +#define CONFIG_H + +/********************** WiFi Configuration ****************************/ + +// WiFi SSID (network name) +#define WLAN_SSID "your_ssid_here" + +// WiFi password +#define WLAN_PASS "your_password_here" + +/********************** Home Assistant MQTT Configuration ****************************/ + +// Home Assistant MQTT broker address +// Examples: "192.168.1.100", "mqtt.example.com", "homeassistant.local" +#define HA_MQTT_SERVER "192.168.1.100" + +// MQTT port (typically 1883 for non-TLS, 8883 for TLS) +#define HA_MQTT_PORT 8883 + +// MQTT broker username +#define HA_MQTT_USERNAME "ha_username" + +// MQTT broker password +#define HA_MQTT_PASSWORD "ha_password" + +// MQTT discovery topic prefix (standard Home Assistant default) +#define HA_MQTT_TOPIC_PREFIX "homeassistant" + +/********************** Device Identification ****************************/ + +// Device name as it will appear in Home Assistant +#define DEVICE_NAME "Smart Litter Tray" + +// Device version (use date format: YYYY.MM.DD) +#define DEVICE_VERSION "2025.07.26" + +// Device manufacturer +#define DEVICE_MANUFACTURER "andycb" + +// Unique device identifier (must be unique across all your devices) +// Used in MQTT topic paths and Home Assistant device identification +// Examples: "smart_litter_tray_001", "slb_kitchen" +#define DEVICE_UNIQUE_ID "smart_litter_tray_001" + +/********************** Connection Resilience Configuration ****************************/ + +// First MQTT connection retry delay (milliseconds) +// After this, exponential backoff applies: 30s → 1m → 2m → 5m → 10m (capped) +#define MQTT_RETRY_DELAY_MS 30000 + +// WiFi reconnection base delay (milliseconds) +// Applied before exponential backoff kicks in +#define WIFI_RETRY_DELAY_MS 15000 + +// Maximum backoff delay (milliseconds) - prevents indefinite waiting +#define MAX_BACKOFF_DELAY_MS 600000 // 10 minutes + +// Local publish queue depth (max queued readings during offline periods) +// Each entry stores ~100 bytes, so 50 = ~5KB RAM overhead +#define MQTT_QUEUE_DEPTH 50 + +// Diagnostic publish interval (milliseconds) +// WiFi RSSI, MQTT status, and uptime published at this interval +#define DIAGNOSTIC_PUBLISH_INTERVAL 60000 // 60 seconds + +#endif // CONFIG_H From d95cac0e96e9b8c924954df610632cd7dd00f172 Mon Sep 17 00:00:00 2001 From: Andy Date: Sun, 26 Jul 2026 12:24:54 +0100 Subject: [PATCH 02/10] feat: Add data point age tracking and hardware version - Update software version to 2026.07.26 - Add hardware version field 1.0 to device identification - Add 'Data Age' sensor that tracks queue delay for each reading - For immediate publishes: age = 0 seconds - For queued publishes: age = time_in_queue_seconds - Automatically recalculate age when draining offline queue - Include hardware version in Home Assistant device discovery payload - New Home Assistant sensor: sensor.smart_litter_tray_data_age This allows monitoring data freshness and queue performance. --- src/MqttHandler.h | 51 ++++++++++++++++++++++++++++++++++---------- src/config.h.example | 5 ++++- 2 files changed, 44 insertions(+), 12 deletions(-) diff --git a/src/MqttHandler.h b/src/MqttHandler.h index 7487c90..0bb3d2b 100644 --- a/src/MqttHandler.h +++ b/src/MqttHandler.h @@ -86,7 +86,7 @@ class MqttHandler { void tick(); // Called every loop iteration // Publishing methods - void publishReading(float catWeight, float poopWeight, int durationMs); + void publishReading(float catWeight, float poopWeight, int durationMs, unsigned long ageMs = 0); // Status methods MqttConnectionState getConnectionStatus(); @@ -164,10 +164,11 @@ void MqttHandler::tick() { mqttBackoffLevel = 0; Serial.println("MQTT connected!"); - // Send discovery payloads on successful connection + // Send discovery payloads on successful connection sendDiscoveryPayload("cat_weight", "Cat Weight", "kg"); sendDiscoveryPayload("poop_weight", "Poop Weight", "kg"); sendDiscoveryPayload("poop_duration", "Poop Duration", "s"); + sendDiscoveryPayload("data_age", "Data Age", "s"); sendDiscoveryPayload("wifi_rssi", "WiFi Signal", "dBm"); sendDiscoveryPayload("mqtt_connected", "MQTT Connected", ""); sendDiscoveryPayload("uptime", "Uptime", "s"); @@ -190,11 +191,13 @@ void MqttHandler::tick() { } // Publish a cat litter usage reading -void MqttHandler::publishReading(float catWeight, float poopWeight, int durationMs) { +void MqttHandler::publishReading(float catWeight, float poopWeight, int durationMs, unsigned long ageMs) { char topic[MQTT_MAX_TOPIC_LEN]; char payload[MQTT_MAX_PAYLOAD_LEN]; - Serial.println("Publishing reading..."); + Serial.print("Publishing reading (age: "); + Serial.print(ageMs / 1000); + Serial.println(" s)"); // Publish cat weight snprintf(topic, sizeof(topic), "%s/sensor/%s/cat_weight/state", @@ -213,6 +216,12 @@ void MqttHandler::publishReading(float catWeight, float poopWeight, int duration HA_MQTT_TOPIC_PREFIX, DEVICE_UNIQUE_ID); snprintf(payload, sizeof(payload), "%d", durationMs / 1000); // Convert to seconds publishTopic(topic, payload, false); + + // Publish data age (time from queue to publish) + snprintf(topic, sizeof(topic), "%s/sensor/%s/data_age/state", + HA_MQTT_TOPIC_PREFIX, DEVICE_UNIQUE_ID); + snprintf(payload, sizeof(payload), "%lu", ageMs / 1000); // Convert to seconds + publishTopic(topic, payload, false); } // Internal: Publish a single topic (handles queuing if offline) @@ -269,11 +278,11 @@ void MqttHandler::sendDiscoveryPayload(const char* sensorId, const char* sensorN "{\"name\":\"%s\"," "\"unique_id\":\"%s_%s\"," "\"state_topic\":\"%s/sensor/%s/%s/state\"," - "\"device\":{\"identifiers\":[\"%s\"],\"name\":\"%s\",\"manufacturer\":\"%s\",\"sw_version\":\"%s\"}", + "\"device\":{\"identifiers\":[\"%s\"],\"name\":\"%s\",\"manufacturer\":\"%s\",\"sw_version\":\"%s\",\"hw_version\":\"%s\"}", sensorName, DEVICE_UNIQUE_ID, sensorId, HA_MQTT_TOPIC_PREFIX, DEVICE_UNIQUE_ID, sensorId, - DEVICE_UNIQUE_ID, DEVICE_NAME, DEVICE_MANUFACTURER, DEVICE_VERSION); + DEVICE_UNIQUE_ID, DEVICE_NAME, DEVICE_MANUFACTURER, DEVICE_VERSION, DEVICE_HARDWARE_VERSION); // Add unit of measurement if specified if (strlen(unitOfMeasure) > 0) { @@ -296,15 +305,35 @@ void MqttHandler::drainQueue() { unsigned long drainStart = millis(); while (queueCount > 0 && (millis() - drainStart) < 5000) { // Max 5 second drain window - if (!mqttClient.publish(publishQueue[queueHead].topic, - publishQueue[queueHead].payload, - publishQueue[queueHead].retain)) { + // Calculate age for queued items (time spent in queue + any prior age) + unsigned long currentAge_ms = millis() - publishQueue[queueHead].timestamp; + + // For data_age topics, use the calculated age; for others, use stored payload + char publishPayload[MQTT_MAX_PAYLOAD_LEN]; + bool isAgeMetric = false; + + if (strstr(publishQueue[queueHead].topic, "/data_age/state") != NULL) { + isAgeMetric = true; + snprintf(publishPayload, sizeof(publishPayload), "%lu", currentAge_ms / 1000); + } else { + strncpy(publishPayload, publishQueue[queueHead].payload, sizeof(publishPayload) - 1); + publishPayload[sizeof(publishPayload) - 1] = '\0'; + } + + if (!mqttClient.publish(publishQueue[queueHead].topic, publishPayload, publishQueue[queueHead].retain)) { // Publish failed, stop draining break; } - Serial.print("Drained queued publish: "); - Serial.println(publishQueue[queueHead].topic); + if (isAgeMetric) { + Serial.print("Drained queued publish ("); + Serial.print(currentAge_ms / 1000); + Serial.print("s old): "); + Serial.println(publishQueue[queueHead].topic); + } else { + Serial.print("Drained queued publish: "); + Serial.println(publishQueue[queueHead].topic); + } queueHead = (queueHead + 1) % MQTT_QUEUE_DEPTH; queueCount--; diff --git a/src/config.h.example b/src/config.h.example index 9b09cb0..68043ec 100644 --- a/src/config.h.example +++ b/src/config.h.example @@ -40,7 +40,10 @@ #define DEVICE_NAME "Smart Litter Tray" // Device version (use date format: YYYY.MM.DD) -#define DEVICE_VERSION "2025.07.26" +#define DEVICE_VERSION "2026.07.26" + +// Device hardware version +#define DEVICE_HARDWARE_VERSION "1.0" // Device manufacturer #define DEVICE_MANUFACTURER "andycb" From 9c98b998b58270088028f8fb76dc0569645a3528 Mon Sep 17 00:00:00 2001 From: Andy Date: Sun, 26 Jul 2026 12:36:09 +0100 Subject: [PATCH 03/10] =?UTF-8?q?fix:=20Correct=20jitter=20calculation=20t?= =?UTF-8?q?o=20proper=20=C2=B110%=20multiplier?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Changed from additive percentage model to multiplicative multiplier: - Old: delay + (delay * jitterPercent / 100) where jitterPercent=-10 to 10 - New: (delay * jitterMultiplier) / 100 where jitterMultiplier=90 to 110 This produces proper ±10% jitter (0.9x to 1.1x) instead of additive offset. --- src/MqttHandler.h | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/MqttHandler.h b/src/MqttHandler.h index 0bb3d2b..a1d563c 100644 --- a/src/MqttHandler.h +++ b/src/MqttHandler.h @@ -442,9 +442,9 @@ unsigned long MqttHandler::getBackoffDelay(int backoffLevel, bool isWifi) { // Internal: Add ±10% jitter to delay void MqttHandler::addJitter(unsigned long& delay) { - // Generate random jitter: ±10% - int jitterPercent = random(-10, 11); // -10 to +10 - delay = delay + (delay * jitterPercent / 100); + // Generate random jitter: ±10% (multiplier from 0.9 to 1.1) + int jitterMultiplier = random(90, 111); // 90 to 110 (representing 0.9x to 1.1x) + delay = (delay * jitterMultiplier) / 100; } // Internal: Publish diagnostic sensors From 4008084403a556715168c1e287adb88c929d64a0 Mon Sep 17 00:00:00 2001 From: Andy Date: Sun, 26 Jul 2026 12:41:02 +0100 Subject: [PATCH 04/10] fix: Don't queue diagnostic sensors in offline buffer Diagnostics (WiFi RSSI, MQTT status, uptime) are now ephemeral: - Only published when MQTT is actively connected - Not queued to offline buffer - Changed publishDiagnostics() to use direct mqttClient.publish() instead of publishTopic() - Added early return if MQTT disconnected This preserves queue space for actual sensor data (cat weight, poop weight, duration, age). --- src/MqttHandler.h | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/src/MqttHandler.h b/src/MqttHandler.h index a1d563c..97c0221 100644 --- a/src/MqttHandler.h +++ b/src/MqttHandler.h @@ -449,6 +449,11 @@ void MqttHandler::addJitter(unsigned long& delay) { // Internal: Publish diagnostic sensors void MqttHandler::publishDiagnostics() { + // Only publish diagnostics if MQTT is connected - don't queue them + if (mqttState != MQTT_CONNECTED) { + return; + } + char topic[MQTT_MAX_TOPIC_LEN]; char payload[MQTT_MAX_PAYLOAD_LEN]; @@ -458,7 +463,7 @@ void MqttHandler::publishDiagnostics() { snprintf(topic, sizeof(topic), "%s/sensor/%s/wifi_rssi/state", HA_MQTT_TOPIC_PREFIX, DEVICE_UNIQUE_ID); snprintf(payload, sizeof(payload), "%d", rssi); - publishTopic(topic, payload, false); + mqttClient.publish(topic, payload, false); lastWifiRssi = rssi; } @@ -466,14 +471,14 @@ void MqttHandler::publishDiagnostics() { snprintf(topic, sizeof(topic), "%s/sensor/%s/mqtt_connected/state", HA_MQTT_TOPIC_PREFIX, DEVICE_UNIQUE_ID); snprintf(payload, sizeof(payload), "%s", mqttState == MQTT_CONNECTED ? "true" : "false"); - publishTopic(topic, payload, false); + mqttClient.publish(topic, payload, false); // Uptime (in seconds) unsigned long uptime = getUptime(); snprintf(topic, sizeof(topic), "%s/sensor/%s/uptime/state", HA_MQTT_TOPIC_PREFIX, DEVICE_UNIQUE_ID); snprintf(payload, sizeof(payload), "%lu", uptime); - publishTopic(topic, payload, false); + mqttClient.publish(topic, payload, false); } // Get current MQTT connection status From 38e33f8919b4347f3a7e6a56a06f25aa8c0465af Mon Sep 17 00:00:00 2001 From: Andy Date: Sun, 26 Jul 2026 20:30:01 +0100 Subject: [PATCH 05/10] fix: Resolve PubSubClient macro conflicts and add forward declaration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Renamed enum values to avoid PubSubClient library macro conflicts: - MQTT_DISCONNECTED → MQTT_STATE_DISCONNECTED - MQTT_CONNECTING → MQTT_STATE_CONNECTING - MQTT_CONNECTED → MQTT_STATE_CONNECTED - MQTT_FAILED → MQTT_STATE_FAILED - WIFI_DISCONNECTED → WIFI_STATE_DISCONNECTED - WIFI_CONNECTING → WIFI_STATE_CONNECTING - WIFI_CONNECTED → WIFI_STATE_CONNECTED - Added forward declaration of OnNewReading() in SmartLitterBoxSketch.ino to resolve undefined reference error This fixes all PubSubClient macro name collision issues. --- src/MqttHandler.h | 60 ++++++++++++++++++------------------ src/SmartLitterBoxSketch.ino | 3 ++ 2 files changed, 33 insertions(+), 30 deletions(-) diff --git a/src/MqttHandler.h b/src/MqttHandler.h index 97c0221..47298c3 100644 --- a/src/MqttHandler.h +++ b/src/MqttHandler.h @@ -21,19 +21,19 @@ #define MQTT_MAX_TOPIC_LEN 256 #define MQTT_MAX_PAYLOAD_LEN 256 -// MQTT connection states +// MQTT connection states (renamed to avoid PubSubClient macro conflicts) enum MqttConnectionState { - MQTT_DISCONNECTED, - MQTT_CONNECTING, - MQTT_CONNECTED, - MQTT_FAILED + MQTT_STATE_DISCONNECTED, + MQTT_STATE_CONNECTING, + MQTT_STATE_CONNECTED, + MQTT_STATE_FAILED }; // WiFi connection states enum WiFiConnectionState { - WIFI_DISCONNECTED, - WIFI_CONNECTING, - WIFI_CONNECTED + WIFI_STATE_DISCONNECTED, + WIFI_STATE_CONNECTING, + WIFI_STATE_CONNECTED }; // Publish queue entry structure @@ -97,8 +97,8 @@ class MqttHandler { // Constructor MqttHandler::MqttHandler() : mqttClient(espClient), - mqttState(MQTT_DISCONNECTED), - wifiState(WIFI_DISCONNECTED), + mqttState(MQTT_STATE_DISCONNECTED), + wifiState(WIFI_STATE_DISCONNECTED), lastMqttAttempt(0), lastWifiAttempt(0), deviceStartTime(0), @@ -135,15 +135,15 @@ void MqttHandler::setup() { void MqttHandler::tick() { // Check WiFi status if (WiFi.status() != WL_CONNECTED) { - if (wifiState == WIFI_CONNECTED) { + if (wifiState == WIFI_STATE_CONNECTED) { Serial.println("WiFi disconnected!"); - wifiState = WIFI_DISCONNECTED; + wifiState = WIFI_STATE_DISCONNECTED; lastWifiAttempt = millis(); wifiBackoffLevel = 0; // Reset backoff on detection } handleWifiReconnect(); - } else if (wifiState != WIFI_CONNECTED) { - wifiState = WIFI_CONNECTED; + } else if (wifiState != WIFI_STATE_CONNECTED) { + wifiState = WIFI_STATE_CONNECTED; wifiBackoffLevel = 0; Serial.print("WiFi reconnected. IP: "); Serial.println(WiFi.localIP()); @@ -151,16 +151,16 @@ void MqttHandler::tick() { // Check MQTT status if (!mqttClient.connected()) { - if (mqttState == MQTT_CONNECTED) { + if (mqttState == MQTT_STATE_CONNECTED) { Serial.println("MQTT disconnected!"); - mqttState = MQTT_DISCONNECTED; + mqttState = MQTT_STATE_DISCONNECTED; lastMqttAttempt = millis(); } - if (wifiState == WIFI_CONNECTED) { + if (wifiState == WIFI_STATE_CONNECTED) { handleMqttReconnect(); } - } else if (mqttState != MQTT_CONNECTED) { - mqttState = MQTT_CONNECTED; + } else if (mqttState != MQTT_STATE_CONNECTED) { + mqttState = MQTT_STATE_CONNECTED; mqttBackoffLevel = 0; Serial.println("MQTT connected!"); @@ -226,7 +226,7 @@ void MqttHandler::publishReading(float catWeight, float poopWeight, int duration // Internal: Publish a single topic (handles queuing if offline) bool MqttHandler::publishTopic(const char* topic, const char* payload, bool retain) { - if (mqttState != MQTT_CONNECTED) { + if (mqttState != MQTT_STATE_CONNECTED) { // Queue the publish if (queueCount < MQTT_QUEUE_DEPTH) { int newTail = (queueTail + 1) % MQTT_QUEUE_DEPTH; @@ -352,11 +352,11 @@ void MqttHandler::drainQueue() { // Internal: Handle MQTT reconnection with exponential backoff void MqttHandler::handleMqttReconnect() { - if (mqttState == MQTT_CONNECTING || mqttState == MQTT_CONNECTED) { + if (mqttState == MQTT_STATE_CONNECTING || mqttState == MQTT_STATE_CONNECTED) { return; // Already connecting or connected } - if (wifiState != WIFI_CONNECTED) { + if (wifiState != WIFI_STATE_CONNECTED) { return; // WiFi not connected } @@ -371,16 +371,16 @@ void MqttHandler::handleMqttReconnect() { Serial.print(mqttBackoffLevel); Serial.println(")"); - mqttState = MQTT_CONNECTING; + mqttState = MQTT_STATE_CONNECTING; lastMqttAttempt = now; // Attempt connection if (mqttClient.connect(DEVICE_UNIQUE_ID, HA_MQTT_USERNAME, HA_MQTT_PASSWORD)) { - mqttState = MQTT_CONNECTED; + mqttState = MQTT_STATE_CONNECTED; mqttBackoffLevel = 0; // Reset backoff Serial.println("MQTT connected!"); } else { - mqttState = MQTT_DISCONNECTED; + mqttState = MQTT_STATE_DISCONNECTED; mqttBackoffLevel = min(mqttBackoffLevel + 1, 4); // Cap at level 4 (10 minutes) Serial.print("MQTT connection failed, code: "); Serial.println(mqttClient.state()); @@ -389,7 +389,7 @@ void MqttHandler::handleMqttReconnect() { // Internal: Handle WiFi reconnection with exponential backoff void MqttHandler::handleWifiReconnect() { - if (wifiState == WIFI_CONNECTING) { + if (wifiState == WIFI_STATE_CONNECTING) { return; // Already connecting } @@ -408,7 +408,7 @@ void MqttHandler::handleWifiReconnect() { Serial.print(wifiBackoffLevel); Serial.println(")"); - wifiState = WIFI_CONNECTING; + wifiState = WIFI_STATE_CONNECTING; lastWifiAttempt = now; WiFi.reconnect(); @@ -450,7 +450,7 @@ void MqttHandler::addJitter(unsigned long& delay) { // Internal: Publish diagnostic sensors void MqttHandler::publishDiagnostics() { // Only publish diagnostics if MQTT is connected - don't queue them - if (mqttState != MQTT_CONNECTED) { + if (mqttState != MQTT_STATE_CONNECTED) { return; } @@ -470,7 +470,7 @@ void MqttHandler::publishDiagnostics() { // MQTT connection status snprintf(topic, sizeof(topic), "%s/sensor/%s/mqtt_connected/state", HA_MQTT_TOPIC_PREFIX, DEVICE_UNIQUE_ID); - snprintf(payload, sizeof(payload), "%s", mqttState == MQTT_CONNECTED ? "true" : "false"); + snprintf(payload, sizeof(payload), "%s", mqttState == MQTT_STATE_CONNECTED ? "true" : "false"); mqttClient.publish(topic, payload, false); // Uptime (in seconds) @@ -488,7 +488,7 @@ MqttConnectionState MqttHandler::getConnectionStatus() { // Check if MQTT is connected bool MqttHandler::isConnected() { - return mqttState == MQTT_CONNECTED; + return mqttState == MQTT_STATE_CONNECTED; } // Get device uptime in seconds diff --git a/src/SmartLitterBoxSketch.ino b/src/SmartLitterBoxSketch.ino index f4ebb08..78fe2c9 100644 --- a/src/SmartLitterBoxSketch.ino +++ b/src/SmartLitterBoxSketch.ino @@ -6,6 +6,9 @@ #include "MqttHandler.h" #include "SmartLitterBox.h" +// Forward declaration +void OnNewReading(CatLitterUse catLitterUse); + // MQTT handler instance MqttHandler mqttHandler; From 8a3748abd9f7ed39b68efb37f7689bb6a244089d Mon Sep 17 00:00:00 2001 From: Andy Date: Sun, 26 Jul 2026 20:31:07 +0100 Subject: [PATCH 06/10] fix: Remove move-assignment of LitterScale with const members LitterScale has const member variables, which makes the default move assignment operator implicitly deleted. Removed the explicit assignment 'this->m_litterScale = LitterScale()' and rely on default initialization of the member variable instead. This resolves the compilation error about deleted move assignment operator. --- src/SmartLitterBox.ino | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/SmartLitterBox.ino b/src/SmartLitterBox.ino index 1ddb735..c06b030 100644 --- a/src/SmartLitterBox.ino +++ b/src/SmartLitterBox.ino @@ -2,7 +2,7 @@ SmartLitterBox::SmartLitterBox() { - this->m_litterScale = LitterScale(); + // m_litterScale is default-initialized as a member variable MethodSlot memFunSlot(this, &SmartLitterBox::OnWeightUpdated); this->m_litterScale.WeightChanged.attach(memFunSlot); From d2f7dc4765bc0355912c0708b503ba635e4bd3d2 Mon Sep 17 00:00:00 2001 From: Andy Date: Tue, 28 Jul 2026 18:45:27 +0100 Subject: [PATCH 07/10] debug: Add detailed MQTT and WiFi connection logging Added extensive debug output to help diagnose connection issues: MQTT connection debug: - Print server IP, port, client ID, username, password when attempting - Add human-readable error messages for all MQTT state codes - Specifically identify MQTT_CONNECT_FAILED (-2) as bad credentials/rejected WiFi connection debug: - Print current WiFi status code when attempting reconnection - Help identify WiFi status issues This helps identify whether the issue is: 1. Wrong MQTT credentials 2. MQTT broker not accessible on that port 3. WiFi connection issues 4. Client ID being rejected by broker --- src/MqttHandler.h | 34 +++++++++++++++++++++++++++++++++- 1 file changed, 33 insertions(+), 1 deletion(-) diff --git a/src/MqttHandler.h b/src/MqttHandler.h index 47298c3..27712bf 100644 --- a/src/MqttHandler.h +++ b/src/MqttHandler.h @@ -371,6 +371,18 @@ void MqttHandler::handleMqttReconnect() { Serial.print(mqttBackoffLevel); Serial.println(")"); + // Debug output + Serial.print(" Server: "); + Serial.print(HA_MQTT_SERVER); + Serial.print(":"); + Serial.println(HA_MQTT_PORT); + Serial.print(" Client ID: "); + Serial.println(DEVICE_UNIQUE_ID); + Serial.print(" Username: "); + Serial.println(HA_MQTT_USERNAME); + Serial.print(" Password: "); + Serial.println(HA_MQTT_PASSWORD); + mqttState = MQTT_STATE_CONNECTING; lastMqttAttempt = now; @@ -383,7 +395,25 @@ void MqttHandler::handleMqttReconnect() { mqttState = MQTT_STATE_DISCONNECTED; mqttBackoffLevel = min(mqttBackoffLevel + 1, 4); // Cap at level 4 (10 minutes) Serial.print("MQTT connection failed, code: "); - Serial.println(mqttClient.state()); + Serial.print(mqttClient.state()); + Serial.print(" ("); + + // Print human-readable error + int state = mqttClient.state(); + switch(state) { + case -4: Serial.print("MQTT_CONNECTION_TIMEOUT"); break; + case -3: Serial.print("MQTT_CONNECTION_LOST"); break; + case -2: Serial.print("MQTT_CONNECT_FAILED - wrong credentials or broker rejected"); break; + case -1: Serial.print("MQTT_DISCONNECTED"); break; + case 0: Serial.print("MQTT_CONNECTED"); break; + case 1: Serial.print("MQTT_CONNECT_BAD_PROTOCOL"); break; + case 2: Serial.print("MQTT_CONNECT_BAD_CLIENT_ID"); break; + case 3: Serial.print("MQTT_CONNECT_UNAVAILABLE"); break; + case 4: Serial.print("MQTT_CONNECT_BAD_CREDENTIALS"); break; + case 5: Serial.print("MQTT_CONNECT_UNAUTHORIZED"); break; + default: Serial.print("MQTT_UNKNOWN_ERROR"); break; + } + Serial.println(")"); } } @@ -407,6 +437,8 @@ void MqttHandler::handleWifiReconnect() { Serial.print("Attempting WiFi connection (backoff level "); Serial.print(wifiBackoffLevel); Serial.println(")"); + Serial.print(" Current WiFi status: "); + Serial.println(WiFi.status()); // 0=idle, 1=connecting, 2=wrong pass, 3=no ssid, 4=connect fail, 5=connected wifiState = WIFI_STATE_CONNECTING; lastWifiAttempt = now; From d20551fbea684694032da24bdb01715c186aee85 Mon Sep 17 00:00:00 2001 From: Andy Date: Tue, 28 Jul 2026 19:04:42 +0100 Subject: [PATCH 08/10] fix: Use plain WiFiClient for MQTT port 1883, WiFiClientSecure for TLS The issue was using WiFiClientSecure with setInsecure() for plain MQTT (port 1883). WiFiClientSecure can interfere with plain MQTT connections. Now automatically selects the correct client type based on port: - Port 1883 (plain MQTT): Uses WiFiClient (no TLS overhead) - Port 8883+ (MQTT with TLS): Uses WiFiClientSecure with setInsecure() This resolves MQTT_CONNECT_FAILED (-2) errors on plain MQTT brokers. --- src/MqttHandler.h | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/src/MqttHandler.h b/src/MqttHandler.h index 27712bf..e1cc95e 100644 --- a/src/MqttHandler.h +++ b/src/MqttHandler.h @@ -46,7 +46,13 @@ struct MqttQueueEntry { class MqttHandler { private: - WiFiClientSecure espClient; + // Use WiFiClient for plain MQTT (port 1883), WiFiClientSecure for TLS (port 8883) + #if HA_MQTT_PORT == 1883 + WiFiClient espClient; + #else + WiFiClientSecure espClient; + #endif + PubSubClient mqttClient; // Connection state tracking @@ -123,9 +129,13 @@ void MqttHandler::setup() { mqttClient.setServer(HA_MQTT_SERVER, HA_MQTT_PORT); mqttClient.setBufferSize(512); - // Configure SSL/TLS with Mozilla root CA - // Using insecure mode for testing - replace with proper certificate in production - espClient.setInsecure(); + // Configure TLS for secure MQTT (port 8883 and above) + #if HA_MQTT_PORT >= 8883 + Serial.println("Using secure MQTT (TLS)"); + espClient.setInsecure(); // Skip cert validation for self-signed certs + #else + Serial.println("Using plain MQTT (no TLS)"); + #endif // Initial WiFi connection attempt handleWifiReconnect(); From 581997c96924b81b70dc205669b010058e085823 Mon Sep 17 00:00:00 2001 From: Andy Date: Tue, 28 Jul 2026 19:16:03 +0100 Subject: [PATCH 09/10] debug: Add detailed discovery payload logging Print the exact discovery topic and JSON payload being sent to Home Assistant. This helps verify: 1. Topic structure is correct for MQTT discovery 2. JSON payload is properly formatted 3. Publish operation succeeds with retain flag This will help diagnose why entities don't appear in Home Assistant. --- src/MqttHandler.h | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/src/MqttHandler.h b/src/MqttHandler.h index e1cc95e..6ec1274 100644 --- a/src/MqttHandler.h +++ b/src/MqttHandler.h @@ -301,12 +301,21 @@ void MqttHandler::sendDiscoveryPayload(const char* sensorId, const char* sensorN strncat(payload, "\"", sizeof(payload) - strlen(payload) - 1); } + // Close the JSON object strncat(payload, "}", sizeof(payload) - strlen(payload) - 1); - // Publish with retain flag - mqttClient.publish(topic, payload, true); + // Debug: Print the discovery payload + Serial.print("Discovery topic: "); + Serial.println(topic); + Serial.print("Discovery payload: "); + Serial.println(payload); + + // Publish with retain flag (true = broker keeps this message for new subscribers) + bool success = mqttClient.publish(topic, payload, true); Serial.print("Published discovery: "); - Serial.println(sensorId); + Serial.print(sensorId); + Serial.print(" - "); + Serial.println(success ? "OK" : "FAILED"); } // Internal: Drain the publish queue From fefa84f929143e814f4e59813fc511cffe398d26 Mon Sep 17 00:00:00 2001 From: Andy Date: Wed, 29 Jul 2026 18:55:40 +0100 Subject: [PATCH 10/10] Use seconds for duration publish --- src/MqttHandler.h | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/src/MqttHandler.h b/src/MqttHandler.h index 6ec1274..b843c46 100644 --- a/src/MqttHandler.h +++ b/src/MqttHandler.h @@ -92,7 +92,7 @@ class MqttHandler { void tick(); // Called every loop iteration // Publishing methods - void publishReading(float catWeight, float poopWeight, int durationMs, unsigned long ageMs = 0); + void publishReading(float catWeight, float poopWeight, int duration, unsigned long ageMs = 0); // Status methods MqttConnectionState getConnectionStatus(); @@ -201,7 +201,7 @@ void MqttHandler::tick() { } // Publish a cat litter usage reading -void MqttHandler::publishReading(float catWeight, float poopWeight, int durationMs, unsigned long ageMs) { +void MqttHandler::publishReading(float catWeight, float poopWeight, int duration, unsigned long ageMs) { char topic[MQTT_MAX_TOPIC_LEN]; char payload[MQTT_MAX_PAYLOAD_LEN]; @@ -224,7 +224,7 @@ void MqttHandler::publishReading(float catWeight, float poopWeight, int duration // Publish duration snprintf(topic, sizeof(topic), "%s/sensor/%s/poop_duration/state", HA_MQTT_TOPIC_PREFIX, DEVICE_UNIQUE_ID); - snprintf(payload, sizeof(payload), "%d", durationMs / 1000); // Convert to seconds + snprintf(payload, sizeof(payload), "%d", duration); // Convert to seconds publishTopic(topic, payload, false); // Publish data age (time from queue to publish) @@ -410,6 +410,14 @@ void MqttHandler::handleMqttReconnect() { mqttState = MQTT_STATE_CONNECTED; mqttBackoffLevel = 0; // Reset backoff Serial.println("MQTT connected!"); + + sendDiscoveryPayload("cat_weight", "Cat Weight", "kg"); + sendDiscoveryPayload("poop_weight", "Poop Weight", "kg"); + sendDiscoveryPayload("poop_duration", "Poop Duration", "s"); + sendDiscoveryPayload("data_age", "Data Age", "s"); + sendDiscoveryPayload("wifi_rssi", "WiFi Signal", "dBm"); + sendDiscoveryPayload("mqtt_connected", "MQTT Connected", ""); + sendDiscoveryPayload("uptime", "Uptime", "s"); } else { mqttState = MQTT_STATE_DISCONNECTED; mqttBackoffLevel = min(mqttBackoffLevel + 1, 4); // Cap at level 4 (10 minutes)