From d64a4ef2340229c0165e2f4a9cb898a32518f52a Mon Sep 17 00:00:00 2001 From: mikecarper Date: Mon, 26 Oct 2026 21:45:18 -0700 Subject: [PATCH] Preserve unread messages when the OTA wizard borrows queue space --- docs/ota_wizard.md | 5 ++- examples/companion_radio/MyMesh.cpp | 11 ++++-- .../test_shared_mota_queue.cpp | 34 +++++++++++++++++-- test/test_companion_ota_config.py | 15 ++++++++ test/test_shared_mota_queue.py | 4 +++ 5 files changed, 64 insertions(+), 5 deletions(-) diff --git a/docs/ota_wizard.md b/docs/ota_wizard.md index 307a4bb04..3056449bc 100644 --- a/docs/ota_wizard.md +++ b/docs/ota_wizard.md @@ -30,7 +30,10 @@ version 2 direct admin support. An older Full Companion may serve OTA files but provide this guarded admin connection; the wizard will ask for a source firmware upgrade instead of proceeding with that connection. Check `get ota.direct` from its USB console for `v=2`. Update replies use a -separate mailbox; ordinary queued messages and saved contact routes stay intact. +separate mailbox; the wizard does not consume ordinary queued messages or change +saved contact routes. Some Full Companion boards borrow queue space for OTA and +retain up to 128 unread messages during an update. If more are waiting, sync them +with an app first; the wizard refuses to discard them to make room. For this wizard, use an ESP32 source instead of RAK4631 Full. The RAK image's current flash limit prevents adding the new direct admin helper. RAK4631 diff --git a/examples/companion_radio/MyMesh.cpp b/examples/companion_radio/MyMesh.cpp index 95d10a6a3..68b626008 100644 --- a/examples/companion_radio/MyMesh.cpp +++ b/examples/companion_radio/MyMesh.cpp @@ -3738,7 +3738,11 @@ bool MyMesh::scheduleTempRadio(float freq, float bw, uint8_t sf, uint8_t cr, } #if defined(OTA_SHARED_COMPANION_QUEUE) uint16_t drained = 0; - if (!mesh::ota::ota_acquire_context(reply, reply_size, true, &drained)) return false; + bool drain_queue = true; +#if COMPANION_FEATURE_DIRECT_OTA_ADMIN + drain_queue = !_ota_direct_admin.active(_ms->getMillis()); +#endif + if (!mesh::ota::ota_acquire_context(reply, reply_size, drain_queue, &drained)) return false; mesh::ota::ota_ctx().release_when_idle = false; #endif _temp_radio_freq = freq; @@ -5185,7 +5189,7 @@ bool MyMesh::handleLocalControlCommand(const char* command, char* reply, while (*action == ' ') ++action; // Only an explicit transfer, catalog search, or source attach may evict // unread offline frames. Read-only OTA queries leave the queue intact. - bool const drain_queue = strcmp(action, "folder on") == 0 + bool drain_queue = strcmp(action, "folder on") == 0 || strcmp(action, "ls") == 0 || strncmp(action, "ls ", 3) == 0 || strncmp(action, "pull ", 5) == 0 || strncmp(action, "get ", 4) == 0 @@ -5195,6 +5199,9 @@ bool MyMesh::handleLocalControlCommand(const char* command, char* reply, || strcmp(action, "applydelta") == 0 || strncmp(action, "bootloader install ", 19) == 0 || strncmp(action, "rescue install ", 15) == 0; +#if COMPANION_FEATURE_DIRECT_OTA_ADMIN + if (_ota_direct_admin.active(_ms->getMillis())) drain_queue = false; +#endif uint16_t drained = 0; if (!mesh::ota::ota_acquire_context(ota_reply, sizeof(ota_reply), drain_queue, &drained)) { snprintf(reply, reply_size, "%s", ota_reply); diff --git a/test/fixtures/shared_mota_queue/test_shared_mota_queue.cpp b/test/fixtures/shared_mota_queue/test_shared_mota_queue.cpp index ef0370df0..9aae89283 100644 --- a/test/fixtures/shared_mota_queue/test_shared_mota_queue.cpp +++ b/test/fixtures/shared_mota_queue/test_shared_mota_queue.cpp @@ -1,6 +1,7 @@ #include #include #include +#include #include #include #include @@ -42,6 +43,21 @@ struct Queue { } }; +#define COMPANION_FEATURE_DIRECT_OTA_ADMIN 1 +struct MyMesh { + struct Guard { bool enabled = false; bool active(uint32_t) const { return enabled; } } _ota_direct_admin; + struct Timer { uint32_t getMillis() const { return 100; } } timer; + Timer* _ms = &timer; + bool _primary_radio_blocked = false; + float _temp_radio_freq = 0, _temp_radio_bw = 0; + uint8_t _temp_radio_sf = 0, _temp_radio_cr = 0, _temp_radio_failures = 0; + uint32_t _temp_radio_set_at = 0, _temp_radio_revert_at = 0, _temp_radio_retry_at = 0; + uint32_t futureMillis(uint32_t delay) const { return _ms->getMillis() + delay; } + void appendRxPowerSavingAdjustmentNote(char*, size_t, uint8_t, float) {} + bool scheduleTempRadio(float, float, uint8_t, uint8_t, uint32_t, char*, size_t); +}; +#include "temp_radio_under_test.h" + class Folder : public MotaSource { public: uint8_t count() override { return 1; } @@ -162,11 +178,25 @@ int main() { assert(strstr(reply, "sync unread messages")); assert(q.head == 191 && q.count == 129 && q.buffer.capacity() == 256); check_messages(); + MyMesh companion; + companion._ota_direct_admin.enabled = true; + assert(!companion.scheduleTempRadio(910.525f, 62.5f, 7, 5, 60, reply, sizeof reply)); + assert(strstr(reply, "sync unread messages")); + assert(q.head == 191 && q.count == 129 && q.buffer.capacity() == 256); + assert(!companion._temp_radio_set_at && !ota_context_if_active()); + check_messages(); + companion._ota_direct_admin.enabled = false; uint16_t drained = 0; - assert(ota_acquire_context(reply, sizeof reply, true, &drained)); - assert(drained == 1 && q.count == 128 && q.head == 0); + assert(companion.scheduleTempRadio(910.525f, 62.5f, 7, 5, 60, reply, sizeof reply)); + assert(strstr(reply, "OTA cleared 1 oldest queued messages")); + assert(q.count == 128 && q.head == 0); first_retained = 1; check_messages(); + companion._ota_direct_admin.enabled = true; + assert(companion.scheduleTempRadio(910.525f, 62.5f, 7, 5, 60, reply, sizeof reply)); + assert(!strstr(reply, "cleared") && q.count == 128); + check_messages(); + ota_ctx().release_when_idle = true; for (unsigned cycle = 0; cycle < 8; ++cycle) { assert(ota_acquire_context(reply, sizeof reply)); diff --git a/test/test_companion_ota_config.py b/test/test_companion_ota_config.py index 1886de863..0e73049d6 100644 --- a/test/test_companion_ota_config.py +++ b/test/test_companion_ota_config.py @@ -81,6 +81,11 @@ static bool handle_ota_command(const char* command, char* reply, int) { return config(rest, reply, context); } } } +#if COMPANION_FEATURE_DIRECT_OTA_ADMIN +static struct Guard { bool enabled=false; bool active(uint32_t) const { return enabled; } } _ota_direct_admin; +static struct Timer { uint32_t getMillis() const { return 100; } } timer; +static Timer* _ms=&timer; +#endif static bool companion(const char* command, char* reply, size_t reply_size) { int board=0; @WRAPPER@ @@ -124,6 +129,14 @@ int main() { reboot(fs); command("ota folder on", "OK folder attached"); assert(drain_requested); +#if COMPANION_FEATURE_DIRECT_OTA_ADMIN + _ota_direct_admin.enabled=true; + command("ota folder on", "OK folder attached"); + assert(!drain_requested); + _ota_direct_admin.enabled=false; + command("ota folder on", "OK folder attached"); + assert(drain_requested); +#endif command("ota config", "ota config: speed=1x packet=0.5x"); assert(!drain_requested); for (const auto* setting : {"hops", "checkpoint", "advert"}) { @@ -295,6 +308,8 @@ class CompanionOtaConfigTest(unittest.TestCase): for platform in ("ESP32_PLATFORM", "NRF52_PLATFORM", "STM32_PLATFORM") for seeder in ([], ["-DOTA_SEEDER_ONLY=1"])] variants.append(("ESP32_PLATFORM", [], "-DCOMPANION_FEATURE_OTA_CLI=1")) + variants.append(("ESP32_PLATFORM", ["-DCOMPANION_FEATURE_DIRECT_OTA_ADMIN=1"], + "-DCOMPANION_RADIO_FULL=1")) for platform, seeder, capability in variants: with self.subTest(platform=platform, seeder=seeder, capability=capability): binary = Path(directory) / "config" diff --git a/test/test_shared_mota_queue.py b/test/test_shared_mota_queue.py index b112c80b9..9be466d99 100644 --- a/test/test_shared_mota_queue.py +++ b/test/test_shared_mota_queue.py @@ -24,6 +24,9 @@ class SharedMotaQueueTest(unittest.TestCase): control = method((ROOT / "examples/companion_radio/main.cpp").read_text(), "class Nrf52BleMotaSourceControl") (Path(temp) / "ble_control_under_test.h").write_text(control + ";\n") + (Path(temp) / "temp_radio_under_test.h").write_text(method( + (ROOT / "examples/companion_radio/MyMesh.cpp").read_text(), + "bool MyMesh::scheduleTempRadio(")) subprocess.run([ "cc", "-DENABLE_OTA=1", "-fsanitize=address,undefined", "-g", "-c", str(ROOT / "src/helpers/ota/OtaTinf.c"), "-o", str(tinf), @@ -45,6 +48,7 @@ class SharedMotaQueueTest(unittest.TestCase): "c++", "-std=c++17", "-fsanitize=address,undefined", "-g", *flags, "-DOTA_SEEDER_ONLY=1", "-DCOMPANION_RADIO_FULL=1", "-DOTA_SHARED_COMPANION_QUEUE=1", + "-DMESH_ENABLE_HOST_KISS=0", "-DENABLE_OTA=1", "-I", str(ROOT / "src"), "-I", str(ROOT / "test/fixtures/shared_mota_queue/mocks"), "-I", str(ROOT / "test/mocks"), "-I", temp,