From e297dbac076de057b623b4f62124da6fbb955cf2 Mon Sep 17 00:00:00 2001 From: Ada Date: Mon, 16 Feb 2026 18:57:49 -0700 Subject: [PATCH 1/4] Fix reconnect bugs, add watchdog, clean dead code MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Registration state wasn't cleared on WiFi disconnect — device would skip re-registration and publish on stale topic paths. Also re-resolve MQTT hostname on broker disconnect in case IP changed. 30s hardware WDT reboots device if loop() stalls. Removed: unused ESPmDNS include, lastPublishedLevel tracking, lastHeartbeatTime, HEARTBEAT_INTERVAL_MS, ADC_MAX, ADC_VREF. Co-Authored-By: Joshua Perry Co-Authored-By: Claude Opus 4.6 --- include/config.h | 7 ++++--- src/main.cpp | 26 +++++++++++++++++--------- 2 files changed, 21 insertions(+), 12 deletions(-) diff --git a/include/config.h b/include/config.h index a8191d7..130a57c 100644 --- a/include/config.h +++ b/include/config.h @@ -38,13 +38,12 @@ // ============================================================================ #define CLIENT_ID "tanksensor" #define DEVICE_VERSION "v1.0.0" +#define MDNS_HOSTNAME "dusa" // ============================================================================ // ADC Configuration // ============================================================================ #define ADC_RESOLUTION 12 // 12-bit ADC (0-4095) -#define ADC_MAX 4095 -#define ADC_VREF 3.3f // ADC reading parameters #define ADC_SAMPLES 64 // Number of samples to average @@ -63,7 +62,6 @@ // ============================================================================ #define READ_INTERVAL_MS 5000 // Read ADC every 5 seconds #define PUBLISH_INTERVAL_MS 30000 // Publish to MQTT every 30 seconds -#define HEARTBEAT_INTERVAL_MS 60000 // Keepalive every 60 seconds // Connection timeouts #define WIFI_CONNECT_TIMEOUT_MS 30000 // WiFi connection timeout @@ -74,6 +72,9 @@ #define WIFI_RECONNECT_DELAY_MS 5000 // Delay between WiFi reconnect attempts #define MQTT_RECONNECT_DELAY_MS 2000 // Delay between MQTT reconnect attempts +// Watchdog +#define WDT_TIMEOUT_S 30 // Hardware watchdog timeout (seconds) + // ============================================================================ // Pin Assignments (XIAO ESP32-S3) // ============================================================================ diff --git a/src/main.cpp b/src/main.cpp index 40226bd..218b804 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -14,7 +14,7 @@ #if MQTT_USE_TLS #include #endif -#include +#include #include #include @@ -34,7 +34,6 @@ struct TankConfig { struct TankState { int rawADC; // Raw ADC reading int level; // Level percentage (0-100) - int lastPublishedLevel; bool registered; char topicPath[64]; // W/portalId/tank/N path from registration int deviceInstance; @@ -86,7 +85,6 @@ IPAddress mqttServerIP; // Timing unsigned long lastReadTime = 0; unsigned long lastPublishTime = 0; -unsigned long lastHeartbeatTime = 0; unsigned long stateEnteredTime = 0; unsigned long lastWiFiAttempt = 0; unsigned long lastMQTTAttempt = 0; @@ -472,8 +470,6 @@ void publishTankViaProxy(int tankIndex) { DEBUG_PRINTF("Publishing %s: %s\n", topic, payload); mqttClient.publish(topic, payload); - - state.lastPublishedLevel = state.level; } void publishAllTanks() { @@ -570,12 +566,15 @@ void runStateMachine() { case STATE_RUNNING: if (!wifiIsConnected()) { + clearRegistration(); changeState(STATE_WIFI_CONNECT); break; } - + if (!mqttClient.connected()) { clearRegistration(); + resolveMqttServer(); + mqttClient.setServer(mqttServerIP, MQTT_PORT); changeState(STATE_MQTT_CONNECT); break; } @@ -679,7 +678,6 @@ void setup() { for (int i = 0; i < TANK_COUNT; i++) { tankStates[i].rawADC = 0; tankStates[i].level = 0; - tankStates[i].lastPublishedLevel = -1; tankStates[i].registered = false; tankStates[i].topicPath[0] = '\0'; tankStates[i].deviceInstance = 0; @@ -689,13 +687,23 @@ void setup() { adcSetup(); wifiSetup(); mqttSetup(); - + + // Hardware watchdog — resets device if loop() stalls + esp_task_wdt_config_t wdtConfig = { + .timeout_ms = WDT_TIMEOUT_S * 1000, + .idle_core_mask = 0, + .trigger_panic = true, + }; + esp_task_wdt_reconfigure(&wdtConfig); + esp_task_wdt_add(NULL); + DEBUG_PRINTLN("Setup complete, starting state machine"); } void loop() { unsigned long now = millis(); - + esp_task_wdt_reset(); + // Always process MQTT messages if (mqttClient.connected()) { mqttClient.loop(); From ad223f858cf3c1dbb0766a1ce793e6b5a85e8548 Mon Sep 17 00:00:00 2001 From: Ada Date: Tue, 17 Feb 2026 20:35:14 -0700 Subject: [PATCH 2/4] HTTP push OTA via mDNS MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Device runs a web server on port 80 after WiFi connects. Push firmware with: curl -F "firmware=@firmware.bin" http://dusa.local/update No reverse connection (unlike espota), so no workstation firewall needed. mDNS init is idempotent — safe across WiFi reconnects. Co-Authored-By: Joshua Perry Co-Authored-By: Claude Opus 4.6 --- include/config.h | 1 + src/main.cpp | 52 +++++++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 52 insertions(+), 1 deletion(-) diff --git a/include/config.h b/include/config.h index 130a57c..c02bdcc 100644 --- a/include/config.h +++ b/include/config.h @@ -39,6 +39,7 @@ #define CLIENT_ID "tanksensor" #define DEVICE_VERSION "v1.0.0" #define MDNS_HOSTNAME "dusa" +#define OTA_PORT 80 // ============================================================================ // ADC Configuration diff --git a/src/main.cpp b/src/main.cpp index 218b804..c2c60c6 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -14,6 +14,9 @@ #if MQTT_USE_TLS #include #endif +#include +#include +#include #include #include #include @@ -76,6 +79,8 @@ PubSubClient mqttClient(wifiClient); TankState tankStates[TANK_COUNT]; DeviceState deviceState = STATE_INIT; +WebServer otaServer(OTA_PORT); +bool otaInitialized = false; char portalId[32] = ""; bool allTanksRegistered = false; @@ -276,6 +281,49 @@ bool resolveMqttServer() { return false; } +// ============================================================================ +// OTA Functions +// ============================================================================ + +void otaSetup() { + if (otaInitialized) return; + + MDNS.begin(MDNS_HOSTNAME); + + otaServer.on("/update", HTTP_POST, []() { + bool ok = !Update.hasError(); + otaServer.sendHeader("Connection", "close"); + otaServer.send(ok ? 200 : 500, "text/plain", ok ? "OK\n" : "FAIL\n"); + if (ok) { + delay(500); + ESP.restart(); + } + }, []() { + HTTPUpload& upload = otaServer.upload(); + if (upload.status == UPLOAD_FILE_START) { + DEBUG_PRINTF("OTA update: %s\n", upload.filename.c_str()); + if (!Update.begin(UPDATE_SIZE_UNKNOWN)) { + DEBUG_PRINTF("OTA begin failed: %s\n", Update.errorString()); + } + } else if (upload.status == UPLOAD_FILE_WRITE) { + esp_task_wdt_reset(); + if (Update.write(upload.buf, upload.currentSize) != upload.currentSize) { + DEBUG_PRINTF("OTA write failed: %s\n", Update.errorString()); + } + } else if (upload.status == UPLOAD_FILE_END) { + if (Update.end(true)) { + DEBUG_PRINTF("OTA complete: %u bytes\n", upload.totalSize); + } else { + DEBUG_PRINTF("OTA end failed: %s\n", Update.errorString()); + } + } + }); + + otaServer.begin(); + otaInitialized = true; + DEBUG_PRINTF("OTA ready: http://%s.local/update\n", MDNS_HOSTNAME); +} + // ============================================================================ // MQTT Functions // ============================================================================ @@ -503,7 +551,8 @@ void runStateMachine() { case STATE_WIFI_CONNECT: if (wifiIsConnected()) { DEBUG_PRINTF("WiFi connected, IP: %s\n", WiFi.localIP().toString().c_str()); - + otaSetup(); + // Resolve MQTT server hostname (supports mDNS .local names) if (resolveMqttServer()) { mqttClient.setServer(mqttServerIP, MQTT_PORT); @@ -703,6 +752,7 @@ void setup() { void loop() { unsigned long now = millis(); esp_task_wdt_reset(); + otaServer.handleClient(); // Always process MQTT messages if (mqttClient.connected()) { From 0624916a5de700266ffb2aaa3ac6128d8a76755b Mon Sep 17 00:00:00 2001 From: Ada Date: Wed, 1 Jul 2026 21:24:48 -0600 Subject: [PATCH 3/4] Move to the shared gx-projector-client library (v2 contract) Replace the hand-rolled v1 registration/MQTT-contract code with GxProjectorClient (github.com/nuketownada/gx-projector-client v0.1.0), upgrading dusa from logicd's v1 backcompat to the v2 contract: - Registration is now NON-retained with {type,init} service dicts; the DBus reply binds all three tanks strictly (all-or-nothing). - Liveness moves onto a retained device//online topic with the MQTT will = retained "0". This structurally fixes the ghost-tank bug: the old will was NON-retained, so a sensor dying while the driver was down left its retained connected:1 Status behind forever. - Tank identity (CustomName/FluidType/Capacity) moves off the periodic Proxy publish into board-authored registration init; the Proxy now carries only Level/Remaining/Status. - STATE_REGISTER is gone: gxClient.connect() runs the whole handshake prologue, and a registration timeout redoes it on a fresh connection. - The library also brings the state-host cookie re-announce and instance-rebind legs dusa never had. Also guards the watchdog setup for arduino-esp32 2.x (IDF 4.4) vs 3.x, so the project builds on the stock espressif32 platform. Bumps to v2.0.0. After flashing, run bin/cutover-board-to-v2.sh tanksensor on the GX to clear the stale retained v1 Status. Co-Authored-By: Joshua Perry --- include/config.h | 2 +- platformio.ini | 3 +- src/main.cpp | 280 ++++++++++++++++------------------------------- 3 files changed, 98 insertions(+), 187 deletions(-) diff --git a/include/config.h b/include/config.h index c02bdcc..c592ff0 100644 --- a/include/config.h +++ b/include/config.h @@ -37,7 +37,7 @@ // Device Identity // ============================================================================ #define CLIENT_ID "tanksensor" -#define DEVICE_VERSION "v1.0.0" +#define DEVICE_VERSION "v2.0.0" #define MDNS_HOSTNAME "dusa" #define OTA_PORT 80 diff --git a/platformio.ini b/platformio.ini index 449c525..6b1b43d 100644 --- a/platformio.ini +++ b/platformio.ini @@ -12,9 +12,10 @@ framework = arduino monitor_speed = 115200 ; Library dependencies -lib_deps = +lib_deps = knolleary/PubSubClient@^2.8 bblanchon/ArduinoJson@^7.0.0 + GxProjectorClient=https://github.com/nuketownada/gx-projector-client.git#v0.1.0 ; Build flags ; Set credentials via environment variables: diff --git a/src/main.cpp b/src/main.cpp index c2c60c6..9e1a15c 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -20,6 +20,8 @@ #include #include #include +#include +#include // ============================================================================ // Data Structures @@ -37,17 +39,14 @@ struct TankConfig { struct TankState { int rawADC; // Raw ADC reading int level; // Level percentage (0-100) - bool registered; - char topicPath[64]; // W/portalId/tank/N path from registration - int deviceInstance; }; -// State machine states +// State machine states (registration itself rides gxClient.connect(); WAIT polls +// the async DBus binding) enum DeviceState { STATE_INIT, STATE_WIFI_CONNECT, STATE_MQTT_CONNECT, - STATE_REGISTER, STATE_WAIT_REGISTRATION, STATE_RUNNING }; @@ -66,6 +65,14 @@ const TankConfig tanks[] = { }; const int TANK_COUNT = sizeof(tanks) / sizeof(tanks[0]); +// gx-projector-client service table: one Victron "tank" dbus service per tank +// (bus names become tank.mqtt_tanksensor_) +const gx::ServiceDef gxServices[] = { + { TANK_FRESH_SERVICE_ID, "tank" }, + { TANK_GREY_SERVICE_ID, "tank" }, + { TANK_BLACK_SERVICE_ID, "tank" }, +}; + // ============================================================================ // Global State // ============================================================================ @@ -82,8 +89,12 @@ DeviceState deviceState = STATE_INIT; WebServer otaServer(OTA_PORT); bool otaInitialized = false; -char portalId[32] = ""; -bool allTanksRegistered = false; +// The gx-device-projector contract (registration/binding, online liveness, cookie +// re-announce, Proxy relay) lives in the shared library. dusa has no non-contract +// topics, so every inbound MQTT message is the library's to consume. +gx::GxSession gxSession(CLIENT_ID, DEVICE_VERSION, gxServices, TANK_COUNT); +gx::GxClient gxClient(mqttClient, gxSession); + int vrefADC = 0; // Voltage reference reading from 5V rail IPAddress mqttServerIP; @@ -155,7 +166,6 @@ void updateLedStatus() { case STATE_MQTT_CONNECT: ledBlink(125); // 4Hz - fast blink break; - case STATE_REGISTER: case STATE_WAIT_REGISTRATION: ledDoubleBlink(); break; @@ -336,46 +346,45 @@ void mqttSetup() { wifiClient.setInsecure(); #endif mqttClient.setCallback(mqttCallback); - mqttClient.setBufferSize(512); // Increase for JSON payloads + // (No setBufferSize needed: gxClient.connect() floors the buffer at 1024 — + // the old 512 was borderline for a 3-tank DBus reply.) } -String buildLastWillPayload() { - JsonDocument doc; - doc["clientId"] = CLIENT_ID; - doc["connected"] = 0; - doc["version"] = DEVICE_VERSION; - doc["services"].to(); // Empty services - - String payload; - serializeJson(doc, payload); - return payload; +// Board-authored initial values for one tank's registration init, rebuilt from the +// tank table on every (re-)announce. Under v2 these seed the dbus paths once at +// device build (freakent applies init only while the device isn't already online), +// so the static identity leaves the periodic Proxy publish. +void fillTankInit(const char* tag, JsonObject init, void* ctx) { + (void)ctx; + for (int i = 0; i < TANK_COUNT; i++) { + if (strcmp(tanks[i].serviceId, tag) != 0) continue; + init["CustomName"] = tanks[i].name; + init["FluidType"] = tanks[i].fluidType; + init["Capacity"] = tanks[i].capacity / 1000.0f; // Convert L to m³ + return; + } } bool mqttConnect() { if (mqttClient.connected()) { return true; } - + unsigned long now = millis(); if (now - lastMQTTAttempt < MQTT_RECONNECT_DELAY_MS) { return false; } lastMQTTAttempt = now; - + DEBUG_PRINTF("Connecting to MQTT: %s:%d\n", mqttServerIP.toString().c_str(), MQTT_PORT); - - // Build last will message - String willTopic = String("device/") + CLIENT_ID + "/Status"; - String willPayload = buildLastWillPayload(); - - if (mqttClient.connect(CLIENT_ID, MQTT_USER, MQTT_PASS, willTopic.c_str(), 0, false, willPayload.c_str())) { + + // The lib runs the whole contract prologue: CONNECT with will = RETAINED "0" on + // device//online (a dead sensor can no longer leave ghost tanks — the old + // non-retained /Status will only reached live subscribers), retained online=1, + // subscribe DBus, publish the NON-retained v2 registration. Binding completes + // asynchronously in STATE_WAIT_REGISTRATION. + if (gxClient.connect(MQTT_USER, MQTT_PASS)) { DEBUG_PRINTLN("MQTT connected"); - - // Subscribe to DBus response topic - String subTopic = String("device/") + CLIENT_ID + "/DBus"; - mqttClient.subscribe(subTopic.c_str()); - DEBUG_PRINTF("Subscribed to: %s\n", subTopic.c_str()); - return true; } else { DEBUG_PRINTF("MQTT connect failed, rc=%d\n", mqttClient.state()); @@ -384,107 +393,9 @@ bool mqttConnect() { } void mqttCallback(char* topic, byte* payload, unsigned int length) { - // Null-terminate the payload - char jsonBuffer[512]; - if (length >= sizeof(jsonBuffer)) { - DEBUG_PRINTLN("MQTT payload too large"); - return; - } - memcpy(jsonBuffer, payload, length); - jsonBuffer[length] = '\0'; - - DEBUG_PRINTF("MQTT received [%s]: %s\n", topic, jsonBuffer); - - // Check if this is our DBus response - String expectedTopic = String("device/") + CLIENT_ID + "/DBus"; - if (String(topic) != expectedTopic) { - return; - } - - // Parse the registration response - JsonDocument doc; - DeserializationError error = deserializeJson(doc, jsonBuffer); - - if (error) { - DEBUG_PRINTF("JSON parse error: %s\n", error.c_str()); - return; - } - - // Extract portalId - if (doc["portalId"].is()) { - strlcpy(portalId, doc["portalId"] | "", sizeof(portalId)); - DEBUG_PRINTF("Portal ID: %s\n", portalId); - } - - // Extract device instances and topic paths for each tank - JsonObject deviceInstances = doc["deviceInstance"]; - JsonObject topicPaths = doc["topicPath"]; - - for (int i = 0; i < TANK_COUNT; i++) { - const char* serviceId = tanks[i].serviceId; - - if (deviceInstances[serviceId].is()) { - tankStates[i].deviceInstance = deviceInstances[serviceId]; - DEBUG_PRINTF("Tank %s instance: %d\n", serviceId, tankStates[i].deviceInstance); - } - - if (topicPaths[serviceId].is()) { - JsonObject paths = topicPaths[serviceId]; - if (paths["W"].is()) { - strlcpy(tankStates[i].topicPath, paths["W"] | "", sizeof(tankStates[i].topicPath)); - tankStates[i].registered = true; - DEBUG_PRINTF("Tank %s topic: %s\n", serviceId, tankStates[i].topicPath); - } - } - } - - // Check if all tanks are registered - allTanksRegistered = true; - for (int i = 0; i < TANK_COUNT; i++) { - if (!tankStates[i].registered) { - allTanksRegistered = false; - break; - } - } - - if (allTanksRegistered) { - DEBUG_PRINTLN("All tanks registered successfully"); - } -} - -// ============================================================================ -// Registration Functions -// ============================================================================ - -void sendRegistration() { - JsonDocument doc; - - doc["clientId"] = CLIENT_ID; - doc["connected"] = 1; - doc["version"] = DEVICE_VERSION; - - JsonObject services = doc["services"].to(); - for (int i = 0; i < TANK_COUNT; i++) { - services[tanks[i].serviceId] = "tank"; - } - - char payload[384]; - serializeJson(doc, payload, sizeof(payload)); - - String topic = String("device/") + CLIENT_ID + "/Status"; - - DEBUG_PRINTF("Publishing registration to %s: %s\n", topic.c_str(), payload); - mqttClient.publish(topic.c_str(), payload, true); // Retained -} - -void clearRegistration() { - portalId[0] = '\0'; - allTanksRegistered = false; - for (int i = 0; i < TANK_COUNT; i++) { - tankStates[i].registered = false; - tankStates[i].topicPath[0] = '\0'; - tankStates[i].deviceInstance = 0; - } + // Everything dusa receives is projector-contract traffic (DBus binding, online + // self-heal, cookie re-announce) — all consumed inside the library. + gxClient.handleMessage(topic, payload, length); } // ============================================================================ @@ -494,30 +405,22 @@ void clearRegistration() { void publishTankViaProxy(int tankIndex) { TankConfig const& tank = tanks[tankIndex]; TankState& state = tankStates[tankIndex]; - - if (!state.registered || state.topicPath[0] == '\0') { - return; - } - + float remaining = tank.capacity * state.level / 100.0f; - + + // {"topicPath":"W//tank/","values":{...}} — null when not bound. + // Static identity (CustomName/FluidType/Capacity) rides the registration init + // now; only the measurements flow per publish. JsonDocument doc; - doc["topicPath"] = state.topicPath; - - JsonObject values = doc["values"].to(); + JsonObject values = gxSession.proxyValues(doc, tank.serviceId); + if (values.isNull()) return; + values["Level"] = state.level; - values["FluidType"] = tank.fluidType; - values["Capacity"] = tank.capacity / 1000.0f; // Convert L to m³ - values["Remaining"] = remaining / 1000.0f; // Convert L to m³ - - char payload[256]; - serializeJson(doc, payload, sizeof(payload)); - - char topic[64]; - snprintf(topic, sizeof(topic), "device/%s/Proxy", CLIENT_ID); - - DEBUG_PRINTF("Publishing %s: %s\n", topic, payload); - mqttClient.publish(topic, payload); + values["Remaining"] = remaining / 1000.0f; // Convert L to m³ + values["Status"] = 0; // 0 = OK + + DEBUG_PRINTF("Publishing %s: %s=%d%%\n", gxSession.proxyTopic(), tank.serviceId, state.level); + gxClient.publishProxy(doc); // streamed, non-retained } void publishAllTanks() { @@ -544,7 +447,6 @@ void runStateMachine() { switch (deviceState) { case STATE_INIT: - clearRegistration(); changeState(STATE_WIFI_CONNECT); break; @@ -579,55 +481,57 @@ void runStateMachine() { } if (mqttConnect()) { - changeState(STATE_REGISTER); + // gxClient.connect() already published the registration; wait for + // the async DBus binding. + changeState(STATE_WAIT_REGISTRATION); } else if (stateTime > MQTT_CONNECT_TIMEOUT_MS) { DEBUG_PRINTLN("MQTT timeout, retrying..."); stateEnteredTime = now; } break; - - case STATE_REGISTER: - if (!mqttClient.connected()) { - changeState(STATE_MQTT_CONNECT); - break; - } - - sendRegistration(); - changeState(STATE_WAIT_REGISTRATION); - break; - + case STATE_WAIT_REGISTRATION: if (!mqttClient.connected()) { - clearRegistration(); changeState(STATE_MQTT_CONNECT); break; } - - if (allTanksRegistered) { + + if (gxSession.bound()) { + DEBUG_PRINTLN("All tanks registered successfully"); changeState(STATE_RUNNING); // Force immediate publish lastPublishTime = 0; } else if (stateTime > REGISTRATION_TIMEOUT_MS) { - DEBUG_PRINTLN("Registration timeout, retrying..."); - changeState(STATE_REGISTER); + // Registration and binding are per-connection: retry means redoing + // the whole handshake on a fresh connection. + DEBUG_PRINTLN("Registration timeout, reconnecting..."); + mqttClient.disconnect(); + changeState(STATE_MQTT_CONNECT); } break; - + case STATE_RUNNING: if (!wifiIsConnected()) { - clearRegistration(); changeState(STATE_WIFI_CONNECT); break; } if (!mqttClient.connected()) { - clearRegistration(); resolveMqttServer(); mqttClient.setServer(mqttServerIP, MQTT_PORT); changeState(STATE_MQTT_CONNECT); break; } - + + if (gxClient.needsRebind()) { + // A re-announce reply carried different instances: our topic bases + // are stale. Reconnect to rebind cleanly (connect() clears the flag). + DEBUG_PRINTLN("Instance rebind required, reconnecting..."); + mqttClient.disconnect(); + changeState(STATE_MQTT_CONNECT); + break; + } + // Normal operation handled in main loop break; } @@ -665,16 +569,16 @@ void processSerialCommand(String cmd) { wifiIsConnected() ? "Connected" : "Disconnected", WiFi.localIP().toString().c_str()); Serial.printf("MQTT: %s\n", mqttClient.connected() ? "Connected" : "Disconnected"); - Serial.printf("Portal ID: %s\n", portalId); + Serial.printf("Portal ID: %s\n", gxSession.portalId()); + Serial.printf("Registered: %s\n", gxSession.bound() ? "yes" : "no"); Serial.printf("Vref ADC: %d\n", vrefADC); Serial.println("\nTanks:"); for (int i = 0; i < TANK_COUNT; i++) { - Serial.printf(" %s: %d%% (ADC=%d, registered=%s, instance=%d)\n", + Serial.printf(" %s: %d%% (ADC=%d, instance=%d)\n", tanks[i].name, tankStates[i].level, tankStates[i].rawADC, - tankStates[i].registered ? "yes" : "no", - tankStates[i].deviceInstance); + gxSession.instance(tanks[i].serviceId)); } } else if (cmd == "HELP") { @@ -727,23 +631,29 @@ void setup() { for (int i = 0; i < TANK_COUNT; i++) { tankStates[i].rawADC = 0; tankStates[i].level = 0; - tankStates[i].registered = false; - tankStates[i].topicPath[0] = '\0'; - tankStates[i].deviceInstance = 0; } - + + // Projector contract wiring: init values per tank (no message handler — dusa + // has no non-contract topics) + gxClient.setInitFiller(fillTankInit, nullptr); + ledSetup(); adcSetup(); wifiSetup(); mqttSetup(); // Hardware watchdog — resets device if loop() stalls +#if ESP_ARDUINO_VERSION_MAJOR >= 3 esp_task_wdt_config_t wdtConfig = { .timeout_ms = WDT_TIMEOUT_S * 1000, .idle_core_mask = 0, .trigger_panic = true, }; esp_task_wdt_reconfigure(&wdtConfig); +#else + // arduino-esp32 2.x (IDF 4.4) API + esp_task_wdt_init(WDT_TIMEOUT_S, true); +#endif esp_task_wdt_add(NULL); DEBUG_PRINTLN("Setup complete, starting state machine"); From 8a5c26ce4f51bc6f3c9985033e6925f1b10d3908 Mon Sep 17 00:00:00 2001 From: Ada Date: Wed, 1 Jul 2026 21:43:40 -0600 Subject: [PATCH 4/4] OTA parity with patroclus: /version + index endpoints, ota upload env The deployed tanksensor board predates the OTA server (ad223f8), so the v2 flash is necessarily over USB -- make it the last one: - GET /version returns {"version","client_id"} (same shape as patroclus; used by rollout sanity checks before/after a push) - GET / serves the manual-upload form - UPLOAD_FILE_ABORTED now calls Update.abort() so a dropped upload doesn't leave the updater wedged - platformio.ini restructured to patroclus's common-[env] pattern with an [env:ota] custom upload (curl POST to http://dusa.local/update); usage: pio run -e ota -t upload Co-Authored-By: Joshua Perry --- platformio.ini | 20 ++++++++++++++++++-- src/main.cpp | 20 ++++++++++++++++++++ 2 files changed, 38 insertions(+), 2 deletions(-) diff --git a/platformio.ini b/platformio.ini index 6b1b43d..7897b36 100644 --- a/platformio.ini +++ b/platformio.ini @@ -1,9 +1,12 @@ ; PlatformIO configuration for ESP32-S3 Tank Sensor -; +; ; To use: pio run -t upload ; Monitor: pio device monitor -[env:xiao_esp32s3] +[platformio] +default_envs = xiao_esp32s3 + +[env] platform = espressif32 board = seeed_xiao_esp32s3 framework = arduino @@ -22,6 +25,19 @@ lib_deps = ; WIFI_SSID=MyNetwork WIFI_PASS=secret pio run build_flags = -DARDUINO_USB_CDC_ON_BOOT=1 +; ============================================================================ +; USB Upload (default) - for initial flash or when device is local +; ============================================================================ +[env:xiao_esp32s3] ; Upload settings (adjust port as needed) ; upload_port = /dev/ttyACM0 ; Linux ; upload_port = COM3 ; Windows + +; ============================================================================ +; HTTP OTA Upload - for remote updates (board must run OTA-capable firmware) +; Usage: pio run -e ota -t upload +; ============================================================================ +[env:ota] +; Custom upload via HTTP POST (mDNS name from MDNS_HOSTNAME, port 80) +upload_protocol = custom +upload_command = curl --fail --show-error --progress-bar -X POST -F "firmware=@$SOURCE" http://dusa.local/update diff --git a/src/main.cpp b/src/main.cpp index 9e1a15c..ae74ea0 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -300,6 +300,23 @@ void otaSetup() { MDNS.begin(MDNS_HOSTNAME); + otaServer.on("/", HTTP_GET, []() { + otaServer.send(200, "text/html", + "Dusa OTA" + "

Dusa OTA Update

" + "

Version: " DEVICE_VERSION "

" + "
" + "

" + "" + "
" + ); + }); + + otaServer.on("/version", HTTP_GET, []() { + otaServer.send(200, "application/json", + "{\"version\":\"" DEVICE_VERSION "\",\"client_id\":\"" CLIENT_ID "\"}"); + }); + otaServer.on("/update", HTTP_POST, []() { bool ok = !Update.hasError(); otaServer.sendHeader("Connection", "close"); @@ -326,6 +343,9 @@ void otaSetup() { } else { DEBUG_PRINTF("OTA end failed: %s\n", Update.errorString()); } + } else if (upload.status == UPLOAD_FILE_ABORTED) { + Update.abort(); + DEBUG_PRINTLN("OTA aborted"); } });