diff --git a/examples/simple_repeater/MyMesh.cpp b/examples/simple_repeater/MyMesh.cpp index cc45c2c4..72363588 100644 --- a/examples/simple_repeater/MyMesh.cpp +++ b/examples/simple_repeater/MyMesh.cpp @@ -1445,6 +1445,22 @@ void MyMesh::loop() { MESH_DEBUG_PRINTLN("Radio params restored"); } +#if defined(WITH_MQTT_BRIDGE) && defined(OTA_MANIFEST_URL) + if (_ota_update_at && millisHasNowPassed(_ota_update_at)) { // deferred `ota update` + _ota_update_at = 0; // clear timer + // The "Beginning update..." reply has now gone out. Free the bridge for heap + // headroom, then flash: otaFromManifest reboots into the new image on success + // (so this never returns); on any abort (already up to date, partition change, + // download error) it returns and we resume the bridge. + setBridgeState(false); + char ota_reply[160]; + if (!_cli.getBoard()->otaFromManifest(getFirmwareVer(), false, ota_reply)) { + MESH_DEBUG_PRINTLN("ota update aborted: %s", ota_reply); + setBridgeState(true); + } + } +#endif + // is pending dirty contacts write needed? if (dirty_contacts_expiry && millisHasNowPassed(dirty_contacts_expiry)) { acl.save(_fs); diff --git a/examples/simple_repeater/MyMesh.h b/examples/simple_repeater/MyMesh.h index 5ea4fcc3..09ee140e 100644 --- a/examples/simple_repeater/MyMesh.h +++ b/examples/simple_repeater/MyMesh.h @@ -116,6 +116,7 @@ class MyMesh : public mesh::Mesh, public CommonCLICallbacks { #endif CayenneLPP telemetry; unsigned long set_radio_at, revert_radio_at; + unsigned long _ota_update_at = 0; // deferred `ota update` fire time (0 = none scheduled) float pending_freq; float pending_bw; uint8_t pending_sf; @@ -306,6 +307,15 @@ public: bridge->setSlotPreset(slot, _prefs.mqtt_slot_preset[slot]); } + // Schedule the pull-OTA flash to run from loop() in ~2.5 s, leaving time for the + // "Beginning update..." CLI reply (CLI_REPLY_DELAY_MILLIS = 600 ms) to transmit + // before the flash blocks the loop and reboots. + bool beginDeferredOtaUpdate() override { + _ota_update_at = millis() + 2500; + if (_ota_update_at == 0) _ota_update_at = 1; // 0 means "none" + return true; + } + int getQueueSize() override { return bridge ? bridge->getQueueSize() : 0; } diff --git a/src/helpers/CommonCLI.cpp b/src/helpers/CommonCLI.cpp index c62e46b6..e44269c0 100644 --- a/src/helpers/CommonCLI.cpp +++ b/src/helpers/CommonCLI.cpp @@ -800,18 +800,26 @@ void CommonCLI::handleCommand(uint32_t sender_timestamp, char* command, char* re // ota check -> report available build, do not flash // ota update -> download and flash, then reboot #if defined(WITH_MQTT_BRIDGE) && defined(OTA_MANIFEST_URL) - bool dry = (memcmp(command, "ota check", 9) == 0); if (WiFi.status() != WL_CONNECTED) { strcpy(reply, "ERR: WiFi not connected"); + } else if (memcmp(command, "ota check", 9) == 0) { + // Check is synchronous so its result lands in this reply. Free the MQTT + // bridge first: on a no-PSRAM board only ~70 KB heap is free with the + // bridge up, and a third TLS connection (the manifest fetch) alongside the + // two live MQTT sessions drives free heap to a few hundred bytes, which + // truncates the read. The WiFi STA link survives end(); restore after. + _callbacks->setBridgeState(false); + _board->otaFromManifest(_callbacks->getFirmwareVer(), true, reply); + _callbacks->setBridgeState(true); } else { - // Free the MQTT bridge (TLS contexts + task) so the manifest parse and - // OTA download have heap headroom; the WiFi STA link survives end(). - if (!dry) _callbacks->setBridgeState(false); - bool ok = _board->otaFromManifest(_callbacks->getFirmwareVer(), dry, reply); - // On success the board reboots and never returns here; on any abort - // (already up to date, partition change, download error) bring the - // bridge back up so the node resumes uplinking. - if (!ok && !dry) _callbacks->setBridgeState(true); + // Update is DEFERRED: the flash blocks the loop and then reboots, so it + // must run only AFTER this reply has gone out over the mesh — otherwise + // the requester never gets a confirmation. The app loop runs it shortly. + if (_callbacks->beginDeferredOtaUpdate()) { + strcpy(reply, "Beginning update... (node will reboot if successful)"); + } else { + strcpy(reply, "ERR: online OTA not available"); + } } #else strcpy(reply, "ERR: online OTA not supported on this build"); diff --git a/src/helpers/CommonCLI.h b/src/helpers/CommonCLI.h index 9a6502d1..a158324c 100644 --- a/src/helpers/CommonCLI.h +++ b/src/helpers/CommonCLI.h @@ -288,6 +288,14 @@ public: restartBridge(); }; + // Schedule a pull-OTA firmware update to run shortly (from the app loop), after + // the "Beginning update..." CLI reply has been transmitted. Deferred because the + // flash blocks the loop and then reboots, so it can't run inline with the reply. + // Returns true if scheduled. Default: not supported. + virtual bool beginDeferredOtaUpdate() { + return false; + }; + virtual int getQueueSize() { return 0; // no op by default }; diff --git a/src/helpers/ESP32Board.cpp b/src/helpers/ESP32Board.cpp index 2306db15..139b5f78 100644 --- a/src/helpers/ESP32Board.cpp +++ b/src/helpers/ESP32Board.cpp @@ -71,6 +71,8 @@ bool ESP32Board::startOTAUpdate(const char* id, char reply[]) { #include #include #include +#include +#include // Embedded CA bundle (produced by board_build.embed_files). Weak so non-bundle // builds still link; we check for presence at runtime. @@ -93,7 +95,44 @@ static void ota_extractHash(const char* s, char* out, size_t out_sz) { out[n] = 0; } +// Parameters handed to the worker task; lives on otaFromManifest()'s stack, +// which stays valid because that function blocks until the worker signals done. +struct OtaTaskArgs { + ESP32Board* self; + const char* current_ver; + bool dry_run; + char* reply; + volatile bool result; + volatile bool done; +}; + +static void ota_task_entry(void* param) { + OtaTaskArgs* a = static_cast(param); + a->result = a->self->otaFromManifestImpl(a->current_ver, a->dry_run, a->reply); + a->done = true; // on a successful `ota update` we reboot before reaching here + vTaskDelete(nullptr); +} + bool ESP32Board::otaFromManifest(const char* current_ver, bool dry_run, char reply[]) { + // The TLS handshake (cert-bundle verify) + JSON parse / HTTPUpdate use far more + // stack than the ~8 KB loop task offers — especially when reached via the deep + // mesh-receive call chain (it overflows the loopTask canary). Run the work in a + // dedicated 24 KB-stack task and block here until it finishes. The big stack is + // freed when the task exits; on a successful update the chip reboots inside it. + OtaTaskArgs args = { this, current_ver, dry_run, reply, false, false }; + TaskHandle_t handle = nullptr; + BaseType_t ok = xTaskCreatePinnedToCore(ota_task_entry, "ota", 24576, &args, 5, &handle, 1); + if (ok != pdPASS) { + strcpy(reply, "ERR: OTA task spawn failed"); + return false; + } + while (!args.done) { + delay(50); // Arduino delay() yields to other tasks + } + return args.result; +} + +bool ESP32Board::otaFromManifestImpl(const char* current_ver, bool dry_run, char reply[]) { #if !defined(OTA_MANIFEST_URL) || !defined(OTA_VARIANT) strcpy(reply, "ERR: OTA not configured (build via build.sh)"); return false; diff --git a/src/helpers/ESP32Board.h b/src/helpers/ESP32Board.h index 93d1bdad..eae9ab78 100644 --- a/src/helpers/ESP32Board.h +++ b/src/helpers/ESP32Board.h @@ -152,6 +152,10 @@ public: bool startOTAUpdate(const char* id, char reply[]) override; bool otaFromManifest(const char* current_ver, bool dry_run, char reply[]) override; + // Heavy body (TLS + JSON / HTTPUpdate). Runs in a dedicated large-stack task + // spawned by otaFromManifest() — public only so that task entry point can call + // it; not meant to be invoked directly. + bool otaFromManifestImpl(const char* current_ver, bool dry_run, char reply[]); void setInhibitSleep(bool inhibit) { inhibit_sleep = inhibit; diff --git a/src/helpers/bridges/MQTTBridge.cpp b/src/helpers/bridges/MQTTBridge.cpp index 677e814b..0c1ff86b 100644 --- a/src/helpers/bridges/MQTTBridge.cpp +++ b/src/helpers/bridges/MQTTBridge.cpp @@ -754,27 +754,44 @@ void MQTTBridge::initializeWiFiInTask() { WiFi.setAutoReconnect(true); WiFi.setAutoConnect(true); - // Set up WiFi event handlers for better diagnostics and immediate disconnection detection - WiFi.onEvent([this](WiFiEvent_t event, WiFiEventInfo_t info) { - switch(event) { - case ARDUINO_EVENT_WIFI_STA_GOT_IP: - MQTT_DEBUG_PRINTLN("WiFi connected: %s", IPAddress(info.got_ip.ip_info.ip.addr).toString().c_str()); - // Set flag to trigger NTP sync from loop() instead of doing it here - if (!_ntp_synced && !_ntp_sync_pending) { - _ntp_sync_pending = true; - } - break; - case ARDUINO_EVENT_WIFI_STA_DISCONNECTED: - s_wifi_disconnect_reason = info.wifi_sta_disconnected.reason; - s_wifi_disconnect_time = millis(); - MQTT_DEBUG_PRINTLN("WiFi disconnected: reason %d", s_wifi_disconnect_reason); - break; - default: - break; - } - }); + // Set up WiFi event handlers for better diagnostics and immediate disconnection + // detection. Register ONCE — the bridge is reused across restarts (e.g. stopped + // for `ota check`/`ota update`, or `set mqtt…` reconfigure) and WiFi.onEvent() + // never removes prior callbacks, so re-registering leaks handlers and duplicates + // every log line. + if (!_wifi_event_registered) { + WiFi.onEvent([this](WiFiEvent_t event, WiFiEventInfo_t info) { + switch(event) { + case ARDUINO_EVENT_WIFI_STA_GOT_IP: + MQTT_DEBUG_PRINTLN("WiFi connected: %s", IPAddress(info.got_ip.ip_info.ip.addr).toString().c_str()); + // Set flag to trigger NTP sync from loop() instead of doing it here + if (!_ntp_synced && !_ntp_sync_pending) { + _ntp_sync_pending = true; + } + break; + case ARDUINO_EVENT_WIFI_STA_DISCONNECTED: + s_wifi_disconnect_reason = info.wifi_sta_disconnected.reason; + s_wifi_disconnect_time = millis(); + MQTT_DEBUG_PRINTLN("WiFi disconnected: reason %d", s_wifi_disconnect_reason); + break; + default: + break; + } + }); + _wifi_event_registered = true; + } - WiFi.begin(_prefs->wifi_ssid, _prefs->wifi_password); + // Only (re)start the WiFi association if it isn't already up. end() leaves the + // STA link connected, so on a restart (e.g. after `ota check`) calling + // WiFi.begin() again forces a needless disconnect/reconnect — which also races + // the MQTT task's first DNS lookup (getaddrinfo fails until WiFi/DNS recovers). + // When already connected, the deferred slot setup still fires in mqttTaskLoop() + // because _ntp_synced persists across end() (only _slots_setup_done is reset). + if (WiFi.status() != WL_CONNECTED) { + WiFi.begin(_prefs->wifi_ssid, _prefs->wifi_password); + } else if (!_ntp_synced && !_ntp_sync_pending) { + _ntp_sync_pending = true; // already connected but never synced — kick NTP now + } // NOTE: Slot setup is deferred until after NTP sync in mqttTaskLoop(). // JWT-auth slots need valid timestamps for token creation, and connecting diff --git a/src/helpers/bridges/MQTTBridge.h b/src/helpers/bridges/MQTTBridge.h index 9eaf66de..e19e0544 100644 --- a/src/helpers/bridges/MQTTBridge.h +++ b/src/helpers/bridges/MQTTBridge.h @@ -180,6 +180,11 @@ private: bool _ntp_synced; bool _ntp_sync_pending; // Flag to trigger NTP sync from loop() instead of event handler bool _slots_setup_done; // Deferred: slots set up after NTP sync + // WiFi.onEvent() handler registered once and never removed by end(); the bridge + // object is reused across restarts, so re-registering would leak handlers and + // duplicate every connect/disconnect log line. Inline-initialised so it survives + // construction and is NOT reset by end(). + bool _wifi_event_registered = false; int _max_active_slots; // Runtime limit: 5 with PSRAM, 2 without // Pending slot reconfigure: set from CLI (Core 1), processed by MQTT task (Core 0)