From 18f2cf0079388dc4afca40203458bf3360a40bb9 Mon Sep 17 00:00:00 2001 From: ODevStudio Date: Wed, 12 Aug 2026 01:09:19 +0200 Subject: [PATCH 1/4] fix(ota): defer remote actions during updates --- include/websocket.h | 5 ++-- tools/test_ota_reboot_routing_contract.py | 30 +++++++++++++++++++++++ 2 files changed, 33 insertions(+), 2 deletions(-) diff --git a/include/websocket.h b/include/websocket.h index c946254..0f38c66 100644 --- a/include/websocket.h +++ b/include/websocket.h @@ -141,10 +141,10 @@ inline void wsReplacePending(uint32_t setBits, uint32_t clearBits) { void processWsPendingCmds() { portENTER_CRITICAL(&wsPendingMux); - uint32_t mask = wsPendingMask; + uint32_t mask = b_ota ? (wsPendingMask & WSP_RESET) : wsPendingMask; uint8_t samplesInUse = pendingSamplesInUse; unsigned long resetAt = pendingResetAt; - wsPendingMask = 0; + wsPendingMask &= ~mask; if (mask & WSP_RESET) { pendingResetAt = 0; } @@ -207,6 +207,7 @@ void processWsPendingCmds() { wifiUpdate(); #endif } + if (b_ota) return; if (mask & WSP_POWER_OFF) { b_powerOff = true; } diff --git a/tools/test_ota_reboot_routing_contract.py b/tools/test_ota_reboot_routing_contract.py index 8642c35..3119561 100644 --- a/tools/test_ota_reboot_routing_contract.py +++ b/tools/test_ota_reboot_routing_contract.py @@ -7,6 +7,7 @@ ROLLBACK_HEADER = ROOT / "include" / "ota_rollback.h" WIFI_SETUP_SOURCE = ROOT / "src" / "wifi_setup.cpp" HDS_SOURCE = ROOT / "src" / "hds.ino" +WEBSOCKET_HEADER = ROOT / "include" / "websocket.h" def assert_contains(path, text): @@ -54,6 +55,16 @@ def assert_pending_sleep_ota_interleaving(): raise AssertionError("OTA wake leaves applied soft sleep or powered-off rails") +def assert_ordered(path, snippets): + contents = path.read_text(encoding="utf-8") + cursor = 0 + for snippet in snippets: + index = contents.find(snippet, cursor) + if index < 0: + raise AssertionError(f"{path.name} missing ordered snippet: {snippet}") + cursor = index + len(snippet) + + def main(): assert_pending_sleep_ota_interleaving() hds = HDS_SOURCE.read_text(encoding="utf-8") @@ -101,6 +112,25 @@ def main(): "WiFi.config(INADDR_NONE, INADDR_NONE, INADDR_NONE, INADDR_NONE);", ) + assert_ordered( + WEBSOCKET_HEADER, + [ + "uint32_t mask = b_ota ? (wsPendingMask & WSP_RESET) : wsPendingMask;", + "wsPendingMask &= ~mask;", + "if (mask & WSP_RESET)", + "if (mask & WSP_DISPLAY_ON)", + ], + ) + assert_ordered( + WEBSOCKET_HEADER, + [ + "if (mask & WSP_WIFI_UPDATE)", + "wifiUpdate();", + "if (b_ota) return;", + "if (mask & WSP_POWER_OFF)", + ], + ) + print("OTA reboot routing contract tests passed") From 512d267a4d9227a38c9fc7bdadd9754a2bac7816 Mon Sep 17 00:00:00 2001 From: ODevStudio Date: Wed, 12 Aug 2026 01:14:54 +0200 Subject: [PATCH 2/4] fix(ota): synchronize async update start --- include/wifi_ota.h | 2 ++ tools/test_ota_reboot_routing_contract.py | 9 +++++++++ 2 files changed, 11 insertions(+) diff --git a/include/wifi_ota.h b/include/wifi_ota.h index 348779d..be365ef 100644 --- a/include/wifi_ota.h +++ b/include/wifi_ota.h @@ -66,7 +66,9 @@ void processOtaDisplayUpdate() { void onOTAStart() { Serial.println("OTA update started!"); + portENTER_CRITICAL(&wsPendingMux); b_ota = true; + portEXIT_CRITICAL(&wsPendingMux); } void onOTAProgress(size_t current, size_t final) { diff --git a/tools/test_ota_reboot_routing_contract.py b/tools/test_ota_reboot_routing_contract.py index 3119561..bd2b69f 100644 --- a/tools/test_ota_reboot_routing_contract.py +++ b/tools/test_ota_reboot_routing_contract.py @@ -84,6 +84,15 @@ def main(): assert_contains(WIFI_OTA_HEADER, "ElegantOTA.setAutoReboot(false)") assert_not_contains(WIFI_OTA_HEADER, "ElegantOTA.setAutoReboot(true)") assert_contains(WIFI_OTA_HEADER, "remoteQueueResetAt(millis() + OTA_RESTART_DELAY_MS)") + assert_ordered( + WIFI_OTA_HEADER, + [ + "void onOTAStart()", + "portENTER_CRITICAL(&wsPendingMux);", + "b_ota = true;", + "portEXIT_CRITICAL(&wsPendingMux);", + ], + ) # Pull OTA's success paths must queue the restart rather than calling # ESP.restart() directly from the Pull OTA task or the setup() task. From 24e03cc62d4760af9d36fa4c7fd992adc6b000be Mon Sep 17 00:00:00 2001 From: ODevStudio Date: Thu, 13 Aug 2026 15:48:44 +0200 Subject: [PATCH 3/4] fix(ota): preserve deferred remote actions --- docs/AI_OTA_NOTES.md | 2 +- include/parameter.h | 9 +++ include/pull_ota.h | 4 +- include/websocket.h | 38 ++++++++++++- include/wifi_ota.h | 2 +- tools/test_ota_reboot_routing_contract.py | 68 ++++++++++++++++++----- 6 files changed, 101 insertions(+), 22 deletions(-) diff --git a/docs/AI_OTA_NOTES.md b/docs/AI_OTA_NOTES.md index f31b451..fa3f3df 100644 --- a/docs/AI_OTA_NOTES.md +++ b/docs/AI_OTA_NOTES.md @@ -75,7 +75,7 @@ The single LittleFS partition has no independent rollback slot. Do not weaken si ## Reboot Routing -ElegantOTA auto-reboot stays disabled with `ElegantOTA.setAutoReboot(false)`. Successful ElegantOTA and pull OTA paths call `remoteQueueResetAt()` so the main loop owns the reset and normal teardown instead of a task or callback calling `ESP.restart()` directly. +ElegantOTA auto-reboot stays disabled with `ElegantOTA.setAutoReboot(false)`. Successful ElegantOTA and pull OTA paths call `remoteQueueOtaResetAt()` so their reset remains distinguishable from ordinary remote resets during an active update. Other scheduled main-loop resets use `remoteQueueResetAt()`; neither path should call `ESP.restart()` directly. The rollback path withdraws WiFi and mDNS with `stopWifi()` before `esp_ota_mark_app_invalid_rollback_and_reboot()`. Do not bypass this routing: a direct reboot can leave the service advertised until resolver caches expire. diff --git a/include/parameter.h b/include/parameter.h index 7d8a748..4fb1144 100644 --- a/include/parameter.h +++ b/include/parameter.h @@ -66,10 +66,12 @@ const uint32_t WSP_SET_SAMPLES = 1u << 10; const uint32_t WSP_WIFI_UPDATE = 1u << 11; const uint32_t WSP_RESET = 1u << 12; const uint32_t WSP_BLE_GYRO = 1u << 13; +const uint32_t WSP_OTA_RESET = 1u << 14; portMUX_TYPE wsPendingMux = portMUX_INITIALIZER_UNLOCKED; volatile uint32_t wsPendingMask = 0; volatile uint8_t pendingSamplesInUse = 0; volatile unsigned long pendingResetAt = 0; +volatile unsigned long pendingOtaResetAt = 0; const uint8_t OTA_DISPLAY_NONE = 0; const uint8_t OTA_DISPLAY_PROGRESS = 1; @@ -86,6 +88,13 @@ inline void remoteQueueResetAt(unsigned long resetAt) { portEXIT_CRITICAL(&wsPendingMux); } +inline void remoteQueueOtaResetAt(unsigned long resetAt) { + portENTER_CRITICAL(&wsPendingMux); + pendingOtaResetAt = resetAt; + wsPendingMask |= WSP_OTA_RESET; + portEXIT_CRITICAL(&wsPendingMux); +} + int i_onWrite_counter = 0; volatile unsigned long t_heartBeat = 0; volatile unsigned long t_firstConnect = 0; diff --git a/include/pull_ota.h b/include/pull_ota.h index 5970a69..afe068d 100644 --- a/include/pull_ota.h +++ b/include/pull_ota.h @@ -1285,7 +1285,7 @@ bool pullOtaInstall( } pullOtaDraw("Firmware done", "Restarting", "Web UI next"); delay(1500); - remoteQueueResetAt(millis()); + remoteQueueOtaResetAt(millis()); return true; } @@ -1376,7 +1376,7 @@ bool pullOtaResumePendingLittleFs() { } pullOtaDraw("Update done", "Restarting"); delay(1500); - remoteQueueResetAt(millis()); + remoteQueueOtaResetAt(millis()); return true; } diff --git a/include/websocket.h b/include/websocket.h index 0f38c66..871a059 100644 --- a/include/websocket.h +++ b/include/websocket.h @@ -141,21 +141,54 @@ inline void wsReplacePending(uint32_t setBits, uint32_t clearBits) { void processWsPendingCmds() { portENTER_CRITICAL(&wsPendingMux); - uint32_t mask = b_ota ? (wsPendingMask & WSP_RESET) : wsPendingMask; + uint32_t mask = b_ota ? (wsPendingMask & WSP_OTA_RESET) : wsPendingMask; uint8_t samplesInUse = pendingSamplesInUse; unsigned long resetAt = pendingResetAt; + unsigned long otaResetAt = pendingOtaResetAt; wsPendingMask &= ~mask; if (mask & WSP_RESET) { pendingResetAt = 0; } + if (mask & WSP_OTA_RESET) { + pendingOtaResetAt = 0; + } + portEXIT_CRITICAL(&wsPendingMux); + if (mask == 0) return; + + portENTER_CRITICAL(&wsPendingMux); + if (b_ota) { + uint32_t deferredMask = mask & ~WSP_OTA_RESET; + if ((deferredMask & WSP_RESET) && !(wsPendingMask & WSP_RESET)) { + pendingResetAt = resetAt; + } + wsPendingMask |= deferredMask; + mask &= WSP_OTA_RESET; + } portEXIT_CRITICAL(&wsPendingMux); if (mask == 0) return; + if (mask & WSP_OTA_RESET) { + if (otaResetAt != 0 && (long)(millis() - otaResetAt) < 0) { + portENTER_CRITICAL(&wsPendingMux); + if (!(wsPendingMask & WSP_OTA_RESET)) { + pendingOtaResetAt = otaResetAt; + } + wsPendingMask |= WSP_OTA_RESET; + portEXIT_CRITICAL(&wsPendingMux); + mask &= ~WSP_OTA_RESET; + if (mask == 0) return; + } else { + reset(); + return; + } + } if (mask & WSP_RESET) { if (resetAt != 0 && (long)(millis() - resetAt) < 0) { portENTER_CRITICAL(&wsPendingMux); + if (!(wsPendingMask & WSP_RESET)) { + pendingResetAt = resetAt; + } wsPendingMask |= WSP_RESET; - pendingResetAt = resetAt; portEXIT_CRITICAL(&wsPendingMux); mask &= ~WSP_RESET; if (mask == 0) return; @@ -207,7 +240,6 @@ void processWsPendingCmds() { wifiUpdate(); #endif } - if (b_ota) return; if (mask & WSP_POWER_OFF) { b_powerOff = true; } diff --git a/include/wifi_ota.h b/include/wifi_ota.h index be365ef..e55f407 100644 --- a/include/wifi_ota.h +++ b/include/wifi_ota.h @@ -87,7 +87,7 @@ void onOTAEnd(bool success) { if (success) { Serial.println("OTA update finished successfully!"); queueOtaDisplay(OTA_DISPLAY_SUCCESS); - remoteQueueResetAt(millis() + OTA_RESTART_DELAY_MS); + remoteQueueOtaResetAt(millis() + OTA_RESTART_DELAY_MS); } else { Serial.println("There was an error during OTA update!"); queueOtaDisplay(OTA_DISPLAY_FAILURE); diff --git a/tools/test_ota_reboot_routing_contract.py b/tools/test_ota_reboot_routing_contract.py index bd2b69f..7608fbe 100644 --- a/tools/test_ota_reboot_routing_contract.py +++ b/tools/test_ota_reboot_routing_contract.py @@ -8,6 +8,15 @@ WIFI_SETUP_SOURCE = ROOT / "src" / "wifi_setup.cpp" HDS_SOURCE = ROOT / "src" / "hds.ino" WEBSOCKET_HEADER = ROOT / "include" / "websocket.h" +PARAMETER_HEADER = ROOT / "include" / "parameter.h" +BLE_HEADER = ROOT / "include" / "ble.h" + +WSP_DISPLAY_OFF = 1 << 1 +WSP_SLEEP_ON = 1 << 4 +WSP_SLEEP_OFF = 1 << 5 +WSP_TIMER_START = 1 << 7 +WSP_RESET = 1 << 12 +WSP_OTA_RESET = 1 << 14 def assert_contains(path, text): @@ -65,8 +74,30 @@ def assert_ordered(path, snippets): cursor = index + len(snippet) +def assert_interleavings(): + pending = WSP_RESET | WSP_TIMER_START + extracted = pending & WSP_OTA_RESET + pending &= ~extracted + if extracted != 0 or pending != WSP_RESET | WSP_TIMER_START: + raise AssertionError("ordinary remote reset escaped OTA deferral") + + extracted = WSP_DISPLAY_OFF | WSP_TIMER_START | WSP_OTA_RESET + pending = 0 + deferred = extracted & ~WSP_OTA_RESET + pending |= deferred + extracted &= WSP_OTA_RESET + if pending != WSP_DISPLAY_OFF | WSP_TIMER_START or extracted != WSP_OTA_RESET: + raise AssertionError("OTA start race did not restore extracted remote actions") + + pending = WSP_SLEEP_ON + pending = (pending & ~(WSP_SLEEP_ON | WSP_DISPLAY_OFF)) | WSP_SLEEP_OFF + if pending & WSP_SLEEP_ON or not pending & WSP_SLEEP_OFF: + raise AssertionError("rapid soft wake lost to pending sleep") + + def main(): assert_pending_sleep_ota_interleaving() + assert_interleavings() hds = HDS_SOURCE.read_text(encoding="utf-8") loop = hds[hds.index("void loop() {"):hds.index("void chargingOLED(")] if "if (b_ota || bleHasLiveClient()" not in loop: @@ -83,7 +114,8 @@ def main(): # restart must be queued through the main-loop reset mechanism instead. assert_contains(WIFI_OTA_HEADER, "ElegantOTA.setAutoReboot(false)") assert_not_contains(WIFI_OTA_HEADER, "ElegantOTA.setAutoReboot(true)") - assert_contains(WIFI_OTA_HEADER, "remoteQueueResetAt(millis() + OTA_RESTART_DELAY_MS)") + assert_contains(WIFI_OTA_HEADER, "remoteQueueOtaResetAt(millis() + OTA_RESTART_DELAY_MS)") + assert_not_contains(WIFI_OTA_HEADER, "remoteQueueResetAt(millis() + OTA_RESTART_DELAY_MS)") assert_ordered( WIFI_OTA_HEADER, [ @@ -96,13 +128,14 @@ def main(): # Pull OTA's success paths must queue the restart rather than calling # ESP.restart() directly from the Pull OTA task or the setup() task. - assert_contains(PULL_OTA_HEADER, "remoteQueueResetAt(millis())") + assert_contains(PULL_OTA_HEADER, "remoteQueueOtaResetAt(millis())") pull_ota_contents = PULL_OTA_HEADER.read_text(encoding="utf-8") - if pull_ota_contents.count("remoteQueueResetAt(millis())") != 2: + if pull_ota_contents.count("remoteQueueOtaResetAt(millis())") != 2: raise AssertionError( "pull_ota.h expected exactly two queued restarts " "(pullOtaInstall and pullOtaResumePendingLittleFs)" ) + assert_not_contains(PULL_OTA_HEADER, "remoteQueueResetAt(millis())") assert_not_contains(PULL_OTA_HEADER, "ESP.restart();\n return true;") # hdsOtaRollback() withdraws mDNS/WiFi before the rollback reboot, since @@ -121,24 +154,29 @@ def main(): "WiFi.config(INADDR_NONE, INADDR_NONE, INADDR_NONE, INADDR_NONE);", ) + assert_contains(PARAMETER_HEADER, "const uint32_t WSP_OTA_RESET = 1u << 14;") + assert_contains(PARAMETER_HEADER, "volatile unsigned long pendingOtaResetAt = 0;") + assert_contains(PARAMETER_HEADER, "inline void remoteQueueOtaResetAt(unsigned long resetAt)") + assert_contains(BLE_HEADER, "remoteQueuePending(WSP_RESET);") + + websocket = WEBSOCKET_HEADER.read_text(encoding="utf-8") + dispatcher_start = websocket.index("void processWsPendingCmds() {") + dispatcher = websocket[dispatcher_start:websocket.index("#if HDS_FEATURE_WEBSOCKET", dispatcher_start)] assert_ordered( WEBSOCKET_HEADER, [ - "uint32_t mask = b_ota ? (wsPendingMask & WSP_RESET) : wsPendingMask;", + "uint32_t mask = b_ota ? (wsPendingMask & WSP_OTA_RESET) : wsPendingMask;", "wsPendingMask &= ~mask;", - "if (mask & WSP_RESET)", - "if (mask & WSP_DISPLAY_ON)", - ], - ) - assert_ordered( - WEBSOCKET_HEADER, - [ - "if (mask & WSP_WIFI_UPDATE)", - "wifiUpdate();", - "if (b_ota) return;", - "if (mask & WSP_POWER_OFF)", + "if (mask & WSP_OTA_RESET)", ], ) + race_check = dispatcher.index("if (b_ota) {") + pre_race = dispatcher[dispatcher.index("portEXIT_CRITICAL(&wsPendingMux);"):race_check] + for action in ["reset();", "u8g2.", "wakeScaleFromSoftSleep", "stopWatch.", "wifiUpdate();", "b_powerOff = true;"]: + if action in pre_race: + raise AssertionError(f"pending action dispatches before OTA recheck: {action}") + if "uint32_t deferredMask = mask & ~WSP_OTA_RESET;" not in dispatcher: + raise AssertionError("OTA start race does not restore extracted remote actions") print("OTA reboot routing contract tests passed") From bff8810b52e347b03802b29401171a41df91cc2e Mon Sep 17 00:00:00 2001 From: ODevStudio Date: Thu, 13 Aug 2026 16:30:33 +0200 Subject: [PATCH 4/4] fix(ota): serialize remote action dispatch --- docs/AI_OTA_NOTES.md | 2 + include/parameter.h | 2 + include/websocket.h | 16 ++++++ include/wifi_ota.h | 1 + tools/test_ota_reboot_routing_contract.py | 65 +++++++++++++++++++++-- 5 files changed, 82 insertions(+), 4 deletions(-) diff --git a/docs/AI_OTA_NOTES.md b/docs/AI_OTA_NOTES.md index fa3f3df..5d0401a 100644 --- a/docs/AI_OTA_NOTES.md +++ b/docs/AI_OTA_NOTES.md @@ -77,6 +77,8 @@ The single LittleFS partition has no independent rollback slot. Do not weaken si ElegantOTA auto-reboot stays disabled with `ElegantOTA.setAutoReboot(false)`. Successful ElegantOTA and pull OTA paths call `remoteQueueOtaResetAt()` so their reset remains distinguishable from ordinary remote resets during an active update. Other scheduled main-loop resets use `remoteQueueResetAt()`; neither path should call `ESP.restart()` directly. +ElegantOTA start and main-loop remote action dispatch share `otaDispatchMutex`. Hold it across the complete extracted action batch so `onOTAStart()` cannot publish active OTA until in-flight hardware work finishes. If OTA starts first, restore extracted actions without overwriting newer pending members of the display, low-power, soft-sleep, or timer replacement groups. + The rollback path withdraws WiFi and mDNS with `stopWifi()` before `esp_ota_mark_app_invalid_rollback_and_reboot()`. Do not bypass this routing: a direct reboot can leave the service advertised until resolver caches expire. ## OTA Files And Tests diff --git a/include/parameter.h b/include/parameter.h index 4fb1144..a4774b4 100644 --- a/include/parameter.h +++ b/include/parameter.h @@ -4,6 +4,7 @@ #include #include #include +#include #include "calibration_validation.h" Preferences settingsPreferences; @@ -72,6 +73,7 @@ volatile uint32_t wsPendingMask = 0; volatile uint8_t pendingSamplesInUse = 0; volatile unsigned long pendingResetAt = 0; volatile unsigned long pendingOtaResetAt = 0; +std::mutex otaDispatchMutex; const uint8_t OTA_DISPLAY_NONE = 0; const uint8_t OTA_DISPLAY_PROGRESS = 1; diff --git a/include/websocket.h b/include/websocket.h index 871a059..d903a86 100644 --- a/include/websocket.h +++ b/include/websocket.h @@ -155,9 +155,21 @@ void processWsPendingCmds() { portEXIT_CRITICAL(&wsPendingMux); if (mask == 0) return; + std::lock_guard otaDispatchLock(otaDispatchMutex); portENTER_CRITICAL(&wsPendingMux); if (b_ota) { uint32_t deferredMask = mask & ~WSP_OTA_RESET; + const uint32_t replacementGroups[] = { + WSP_DISPLAY_ON | WSP_DISPLAY_OFF, + WSP_LOWPWR_ON | WSP_LOWPWR_OFF, + WSP_SLEEP_ON | WSP_SLEEP_OFF, + WSP_TIMER_START | WSP_TIMER_STOP | WSP_TIMER_ZERO, + }; + for (const uint32_t group : replacementGroups) { + if (wsPendingMask & group) { + deferredMask &= ~group; + } + } if ((deferredMask & WSP_RESET) && !(wsPendingMask & WSP_RESET)) { pendingResetAt = resetAt; } @@ -240,6 +252,10 @@ void processWsPendingCmds() { wifiUpdate(); #endif } + if (b_ota) { + remoteQueuePending(mask & WSP_POWER_OFF); + return; + } if (mask & WSP_POWER_OFF) { b_powerOff = true; } diff --git a/include/wifi_ota.h b/include/wifi_ota.h index e55f407..4262b96 100644 --- a/include/wifi_ota.h +++ b/include/wifi_ota.h @@ -66,6 +66,7 @@ void processOtaDisplayUpdate() { void onOTAStart() { Serial.println("OTA update started!"); + std::lock_guard otaDispatchLock(otaDispatchMutex); portENTER_CRITICAL(&wsPendingMux); b_ota = true; portEXIT_CRITICAL(&wsPendingMux); diff --git a/tools/test_ota_reboot_routing_contract.py b/tools/test_ota_reboot_routing_contract.py index 7608fbe..221955b 100644 --- a/tools/test_ota_reboot_routing_contract.py +++ b/tools/test_ota_reboot_routing_contract.py @@ -11,13 +11,26 @@ PARAMETER_HEADER = ROOT / "include" / "parameter.h" BLE_HEADER = ROOT / "include" / "ble.h" +WSP_DISPLAY_ON = 1 << 0 WSP_DISPLAY_OFF = 1 << 1 +WSP_LOWPWR_ON = 1 << 2 +WSP_LOWPWR_OFF = 1 << 3 WSP_SLEEP_ON = 1 << 4 WSP_SLEEP_OFF = 1 << 5 +WSP_POWER_OFF = 1 << 6 WSP_TIMER_START = 1 << 7 +WSP_TIMER_STOP = 1 << 8 +WSP_TIMER_ZERO = 1 << 9 WSP_RESET = 1 << 12 WSP_OTA_RESET = 1 << 14 +REPLACEMENT_GROUPS = ( + WSP_DISPLAY_ON | WSP_DISPLAY_OFF, + WSP_LOWPWR_ON | WSP_LOWPWR_OFF, + WSP_SLEEP_ON | WSP_SLEEP_OFF, + WSP_TIMER_START | WSP_TIMER_STOP | WSP_TIMER_ZERO, +) + def assert_contains(path, text): contents = path.read_text(encoding="utf-8") @@ -84,15 +97,37 @@ def assert_interleavings(): extracted = WSP_DISPLAY_OFF | WSP_TIMER_START | WSP_OTA_RESET pending = 0 deferred = extracted & ~WSP_OTA_RESET + for group in REPLACEMENT_GROUPS: + if pending & group: + deferred &= ~group pending |= deferred extracted &= WSP_OTA_RESET if pending != WSP_DISPLAY_OFF | WSP_TIMER_START or extracted != WSP_OTA_RESET: raise AssertionError("OTA start race did not restore extracted remote actions") - pending = WSP_SLEEP_ON - pending = (pending & ~(WSP_SLEEP_ON | WSP_DISPLAY_OFF)) | WSP_SLEEP_OFF - if pending & WSP_SLEEP_ON or not pending & WSP_SLEEP_OFF: - raise AssertionError("rapid soft wake lost to pending sleep") + conflicts = ( + (WSP_DISPLAY_OFF, WSP_DISPLAY_ON), + (WSP_LOWPWR_ON, WSP_LOWPWR_OFF), + (WSP_SLEEP_ON, WSP_SLEEP_OFF), + (WSP_TIMER_START, WSP_TIMER_ZERO), + ) + for older, newer in conflicts: + deferred = older + pending = newer + for group in REPLACEMENT_GROUPS: + if pending & group: + deferred &= ~group + pending |= deferred + if pending != newer: + raise AssertionError("restored command overrode newer replacement intent") + + extracted = WSP_POWER_OFF + pending = 0 + ota_active = True + if ota_active: + pending |= extracted & WSP_POWER_OFF + if pending != WSP_POWER_OFF: + raise AssertionError("pull OTA start lost an extracted power action") def main(): @@ -120,6 +155,7 @@ def main(): WIFI_OTA_HEADER, [ "void onOTAStart()", + "std::lock_guard otaDispatchLock(otaDispatchMutex);", "portENTER_CRITICAL(&wsPendingMux);", "b_ota = true;", "portEXIT_CRITICAL(&wsPendingMux);", @@ -157,6 +193,7 @@ def main(): assert_contains(PARAMETER_HEADER, "const uint32_t WSP_OTA_RESET = 1u << 14;") assert_contains(PARAMETER_HEADER, "volatile unsigned long pendingOtaResetAt = 0;") assert_contains(PARAMETER_HEADER, "inline void remoteQueueOtaResetAt(unsigned long resetAt)") + assert_contains(PARAMETER_HEADER, "std::mutex otaDispatchMutex;") assert_contains(BLE_HEADER, "remoteQueuePending(WSP_RESET);") websocket = WEBSOCKET_HEADER.read_text(encoding="utf-8") @@ -170,13 +207,33 @@ def main(): "if (mask & WSP_OTA_RESET)", ], ) + dispatch_lock = "std::lock_guard otaDispatchLock(otaDispatchMutex);" race_check = dispatcher.index("if (b_ota) {") + if dispatcher.index(dispatch_lock) > race_check: + raise AssertionError("OTA lifecycle lock starts after the OTA recheck") pre_race = dispatcher[dispatcher.index("portEXIT_CRITICAL(&wsPendingMux);"):race_check] for action in ["reset();", "u8g2.", "wakeScaleFromSoftSleep", "stopWatch.", "wifiUpdate();", "b_powerOff = true;"]: if action in pre_race: raise AssertionError(f"pending action dispatches before OTA recheck: {action}") if "uint32_t deferredMask = mask & ~WSP_OTA_RESET;" not in dispatcher: raise AssertionError("OTA start race does not restore extracted remote actions") + for group in [ + "WSP_DISPLAY_ON | WSP_DISPLAY_OFF", + "WSP_LOWPWR_ON | WSP_LOWPWR_OFF", + "WSP_SLEEP_ON | WSP_SLEEP_OFF", + "WSP_TIMER_START | WSP_TIMER_STOP | WSP_TIMER_ZERO", + ]: + if group not in dispatcher: + raise AssertionError(f"restoration does not preserve newer replacement group: {group}") + assert_ordered( + WEBSOCKET_HEADER, + [ + "wifiUpdate();", + "if (b_ota) {", + "remoteQueuePending(mask & WSP_POWER_OFF);", + "if (mask & WSP_POWER_OFF)", + ], + ) print("OTA reboot routing contract tests passed")