diff --git a/.github/workflows/run-unit-tests.yml b/.github/workflows/run-unit-tests.yml index 9ebfa1f8..eff9898f 100644 --- a/.github/workflows/run-unit-tests.yml +++ b/.github/workflows/run-unit-tests.yml @@ -108,6 +108,8 @@ jobs: python3 -B test/test_reply_tx_integration.py python3 -B test/test_ota_speed.py -v python3 -B test/test_ota_apply_delivery.py -v + python3 -B test/test_ota_dev_staging.py -v + python3 -B test/test_ota_dev_reboot_guard.py -v - name: Verify dual-profile switching and chirp timing working-directory: test diff --git a/build.sh b/build.sh index de117150..62c7b91a 100755 --- a/build.sh +++ b/build.sh @@ -296,7 +296,11 @@ for section, options in data: full_wifi_ota = any("AsyncElegantOTA" in str(item) for item in values) continue if key == "build_src_filter": - ota_enabled = any("helpers/ota/" in str(item) for item in values) + # OtaTinf.c is shared by all Arduino builds. Only a recipe that + # includes the protocol implementation can receive the OTA overlay. + ota_enabled = any(re.search( + r"\+", str(item) + ) for item in values) continue if key != "build_flags": continue diff --git a/docs/ota_protocol.md b/docs/ota_protocol.md index bbc5993d..80297df0 100644 --- a/docs/ota_protocol.md +++ b/docs/ota_protocol.md @@ -753,6 +753,11 @@ fetches a `.mota` off the mesh and streams it onto the host as `.mota` via against firmware you don't have. Resume is bookkeeping-free: `BEGIN` 0xFF-fills the file and, on reconnect after a link drop (the fetch PAUSES, holding progress on the host - no RAM/flash fallback), `STAT`+`SREAD` let the fetcher recompute and refill only the missing blocks. +TCP disconnect preserves the selected MID, target, and destination across dynamic-context idle cleanup. +Metadata initialization or seed leaf-diff restarts from the manifest on reconnect; an initialized transfer +reopens and rehashes the same MID before requesting missing blocks. Host I/O failure during final verification +or publication pauses too; a genuine Merkle mismatch remains a failed transfer. Explicit cancel/detach still +releases the session, and a different host transport cannot take over a paused capture without cancellation. The phone-oriented BLE link is deliberately source-only and does not register a folder destination. diff --git a/docs/ota_user_guide.md b/docs/ota_user_guide.md index a88016e0..4dd074eb 100644 --- a/docs/ota_user_guide.md +++ b/docs/ota_user_guide.md @@ -254,7 +254,10 @@ Check progress with `ota status`. If a `folder` pull loses its link mid-transfer, `ota status` shows **paused** - the host keeps the partial and the pull resumes (filling only what's missing) the moment you reconnect motatool; it never -falls back to flash. To **stop** a download you no longer want: +falls back to flash. A TCP disconnect preserves the selected image even if it happens before its manifest +arrives or during the final save. Reconnect through the same transport; an interrupted manifest or seed +comparison restarts initialization, while a partial transfer rechecks its stored blocks. The paused capture +keeps its OTA workspace until reconnection or cancellation. To **stop** a download you no longer want: ``` ota cancel diff --git a/examples/companion_radio/DataStore.cpp b/examples/companion_radio/DataStore.cpp index e95ccc85..c0921a5f 100644 --- a/examples/companion_radio/DataStore.cpp +++ b/examples/companion_radio/DataStore.cpp @@ -3,16 +3,16 @@ #include "DataStore.h" #include #include -#if MESH_CONTACT_CACHE && defined(ESP32_PLATFORM) +#if defined(ESP32_PLATFORM) || defined(RP2040_PLATFORM) #include #endif #if COMPANION_FEATURE_JOHN #include #endif -#if defined(NRF52_PLATFORM) +#if defined(NRF52_PLATFORM) || defined(STM32_PLATFORM) #include -#if defined(EXTRAFS) && !defined(QSPIFLASH) +#if defined(NRF52_PLATFORM) && defined(EXTRAFS) && !defined(QSPIFLASH) #include #endif #endif @@ -1916,6 +1916,9 @@ bool DataStore::hasPendingContactWrites() const { } bool DataStore::hasIncompleteContactLoad() const { +#if !defined(NRF52_PLATFORM) + if (_channel_load_incomplete) return true; +#endif #if MESH_CONTACT_CACHE if (_cache_load_incomplete) return true; #endif @@ -1928,41 +1931,55 @@ bool DataStore::hasIncompleteContactLoad() const { void DataStore::loadChannels(DataStoreHost* host) { #if defined(NRF52_PLATFORM) - if (_contact_load_incomplete) { - MESH_DEBUG_PRINTLN( - "DataStore: channel load quarantined after storage I/O failure"); - return; - } - + bool& incomplete = _contact_load_incomplete; +#else + bool& incomplete = _channel_load_incomplete; +#endif + if (incomplete) return; FILESYSTEM* contacts_fs = _getContactsChannelsFS(); - bool channels_exist = false; - uint32_t channels_size = 0; - if (!contactPathPresence(contacts_fs, "/channels2", channels_exist, - &channels_size)) { - _contact_load_incomplete = true; - return; - } - if (!channels_exist) return; - - static const uint32_t CHANNEL_RECORD_SIZE = 4 + 32 + 32; - if ((channels_size % CHANNEL_RECORD_SIZE) != 0 - || channels_size / CHANNEL_RECORD_SIZE > MAX_GROUP_CHANNELS) { - MESH_DEBUG_PRINTLN( - "DataStore: invalid channels file size; storage quarantined"); - _contact_load_incomplete = true; +#if defined(ESP32_PLATFORM) || defined(RP2040_PLATFORM) + if (!mesh::ContactFileTransaction::recover(contacts_fs, "/channels2")) { + MESH_DEBUG_PRINTLN("DataStore: channel transaction recovery failed; storage quarantined"); + incomplete = true; return; } #endif - File file = openRead(_getContactsChannelsFS(), "/channels2"); + uint32_t channels_size = 0; #if defined(NRF52_PLATFORM) - if (!file || file.size() != channels_size) { - if (file) file.close(); - MESH_DEBUG_PRINTLN( - "DataStore: channels file could not be opened consistently; storage quarantined"); - _contact_load_incomplete = true; + bool channels_exist = false; + if (!contactPathPresence(contacts_fs, "/channels2", channels_exist, + &channels_size)) { + incomplete = true; + return; + } + if (!channels_exist) return; +#else + if (!contacts_fs->exists("/channels2")) return; +#endif + File file = openRead(contacts_fs, "/channels2"); + if (!file) { + MESH_DEBUG_PRINTLN("DataStore: channels file open failed; storage quarantined"); + incomplete = true; + return; + } +#if defined(NRF52_PLATFORM) + if (file.size() != channels_size) { + file.close(); + MESH_DEBUG_PRINTLN("DataStore: channels file changed while opening; storage quarantined"); + incomplete = true; + return; + } +#else + channels_size = file.size(); +#endif + static const uint32_t CHANNEL_RECORD_SIZE = 4 + 32 + 32; + if ((channels_size % CHANNEL_RECORD_SIZE) != 0 + || channels_size / CHANNEL_RECORD_SIZE > MAX_GROUP_CHANNELS) { + file.close(); + MESH_DEBUG_PRINTLN("DataStore: invalid channels file size; storage quarantined"); + incomplete = true; return; } - const uint8_t channel_count = static_cast(channels_size / CHANNEL_RECORD_SIZE); ChannelDetails* loaded = channel_count == 0 ? nullptr @@ -1972,7 +1989,7 @@ void DataStore::loadChannels(DataStoreHost* host) { file.close(); MESH_DEBUG_PRINTLN( "DataStore: no memory for channels snapshot; storage quarantined"); - _contact_load_incomplete = true; + incomplete = true; return; } bool success = true; @@ -1986,6 +2003,7 @@ void DataStore::loadChannels(DataStoreHost* host) { sizeof(loaded[channel_idx].channel.secret)) == sizeof(loaded[channel_idx].channel.secret); if (!success) break; + loaded[channel_idx].name[sizeof(loaded[channel_idx].name) - 1] = 0; loaded[channel_idx].channel.tx_radio = mesh::decodeRadioTxPolicy(unused[0]); } file.close(); @@ -1993,7 +2011,7 @@ void DataStore::loadChannels(DataStoreHost* host) { free(loaded); MESH_DEBUG_PRINTLN( "DataStore: channels file read failed; storage quarantined"); - _contact_load_incomplete = true; + incomplete = true; return; } for (uint8_t channel_idx = 0; channel_idx < channel_count; channel_idx++) { @@ -2001,43 +2019,24 @@ void DataStore::loadChannels(DataStoreHost* host) { free(loaded); MESH_DEBUG_PRINTLN( "DataStore: channel host refused durable record; storage quarantined"); - _contact_load_incomplete = true; + incomplete = true; return; } } free(loaded); -#else - if (file) { - bool full = false; - uint8_t channel_idx = 0; - while (!full) { - ChannelDetails ch; - uint8_t unused[4]; - - bool success = (file.read(unused, 4) == 4); - success = success && (file.read((uint8_t *)ch.name, 32) == 32); - success = success && (file.read((uint8_t *)ch.channel.secret, 32) == 32); - - if (!success) break; // EOF - - ch.channel.tx_radio = mesh::decodeRadioTxPolicy(unused[0]); - if (host->onChannelLoaded(channel_idx, ch)) { - channel_idx++; - } else { - full = true; - } - } - file.close(); - } -#endif } bool DataStore::saveChannels(DataStoreHost* host) { +#if !defined(NRF52_PLATFORM) + if (_channel_load_incomplete) return false; +#endif #if defined(NRF52_PLATFORM) if (_contact_load_incomplete) return false; #endif -#if defined(NRF52_PLATFORM) +#if defined(NRF52_PLATFORM) || defined(STM32_PLATFORM) mesh::AtomicFileWriter file(_getContactsChannelsFS(), "/channels2"); +#elif defined(ESP32_PLATFORM) || defined(RP2040_PLATFORM) + mesh::ContactFileTransaction file(_getContactsChannelsFS(), "/channels2"); #else File file = openWrite(_getContactsChannelsFS(), "/channels2"); #endif @@ -2057,7 +2056,7 @@ bool DataStore::saveChannels(DataStoreHost* host) { if (!success) break; // write failed channel_idx++; } -#if defined(NRF52_PLATFORM) +#if defined(NRF52_PLATFORM) || defined(STM32_PLATFORM) || defined(ESP32_PLATFORM) || defined(RP2040_PLATFORM) success = file.commit(success); if (!success) MESH_DEBUG_PRINTLN("DataStore: atomic channels write failed"); #else diff --git a/examples/companion_radio/DataStore.h b/examples/companion_radio/DataStore.h index dc3ab505..4edf9579 100644 --- a/examples/companion_radio/DataStore.h +++ b/examples/companion_radio/DataStore.h @@ -39,6 +39,9 @@ class DataStore FILESYSTEM* _configuredFsExtra; mesh::RTCClock* _clock; IdentityStore identity_store; +#if !defined(NRF52_PLATFORM) + bool _channel_load_incomplete = false; +#endif #if MESH_CONTACT_CACHE DataStoreHost* _cache_host = nullptr; bool _cache_load_incomplete = false; diff --git a/examples/companion_radio/main.cpp b/examples/companion_radio/main.cpp index aff450b6..dce517cb 100644 --- a/examples/companion_radio/main.cpp +++ b/examples/companion_radio/main.cpp @@ -2347,6 +2347,10 @@ bool handleCompanionBluetoothCommand(const char* command, char* reply, return true; } const bool force = action == CompanionBluetoothCommand::ForceOff; + if (!force && mesh::wireless::control().pending()) { + snprintf(reply, reply_size, "Error: wireless change pending; get 2.4ghz for status"); + return true; + } BaseSerialInterface* route = interface_manager.captureReplyRoute(); const bool non_bluetooth_requester = source == CompanionBluetoothCommandSource::Terminal || (source == CompanionBluetoothCommandSource::Framed @@ -2493,6 +2497,16 @@ static CompanionWirelessBackend companion_wireless; bool handleCompanionWirelessCommand(const char* command, char* reply, size_t size, CompanionWirelessSource source) { +#if defined(BLE_PIN_CODE) + const auto wireless_command = mesh::wireless::parse(command); + if (reply && size && companion_bluetooth_off_at != 0 + && (wireless_command.action == mesh::wireless::Action::Off + || (wireless_command.scope == mesh::wireless::All + && wireless_command.action == mesh::wireless::Action::On))) { + snprintf(reply, size, "Error: Bluetooth change pending; get bluetooth for status"); + return true; + } +#endif uint8_t requester = 0; if (source == CompanionWirelessSource::Usb) requester = mesh::wireless::Independent; else if (source == CompanionWirelessSource::Network) requester = mesh::wireless::WiFi; diff --git a/src/helpers/AtomicFileWriter.h b/src/helpers/AtomicFileWriter.h index 809b2281..93610472 100644 --- a/src/helpers/AtomicFileWriter.h +++ b/src/helpers/AtomicFileWriter.h @@ -1,6 +1,6 @@ #pragma once -#if defined(NRF52_PLATFORM) +#if defined(NRF52_PLATFORM) || defined(STM32_PLATFORM) #include #include @@ -9,7 +9,7 @@ namespace mesh { /** - * Power-loss-safe file replacement for the nRF52 LittleFS backend. + * Power-loss-safe file replacement for the Adafruit LittleFS backends. * * Data is written to a sibling .tmp file. commit() flushes and closes that * file, reads it back while checking its size and CRC32, and only then uses @@ -178,4 +178,4 @@ bool AtomicFileWriter::commit(bool content_valid) { #endif // ATOMIC_FILE_WRITER_IMPLEMENTATION -#endif // NRF52_PLATFORM +#endif // NRF52_PLATFORM || STM32_PLATFORM diff --git a/src/helpers/ContactFileTransaction.h b/src/helpers/ContactFileTransaction.h index 17a74e0e..c39e14ac 100644 --- a/src/helpers/ContactFileTransaction.h +++ b/src/helpers/ContactFileTransaction.h @@ -3,7 +3,7 @@ // SPIFFS cannot replace an existing name in one rename. Preserve the previous // file as .bak until the verified replacement has its final name. Recovery of // the rename gap happens before loading contacts on the next boot. -#if defined(ESP32_PLATFORM) +#if defined(ESP32_PLATFORM) || defined(RP2040_PLATFORM) #include "IdentityStore.h" #include "PersistentStoreFormat.h" #include @@ -31,7 +31,11 @@ public: snprintf(_backup, sizeof(_backup), "%s.bak", target); if (!recover(fs, target)) return; if (fs->exists(_temp) && !fs->remove(_temp)) return; +#if defined(RP2040_PLATFORM) + _file = fs->open(_temp, "w"); +#else _file = fs->open(_temp, "w", true); +#endif _ok = static_cast(_file); } ~ContactFileTransaction() { diff --git a/src/helpers/WirelessControl.h b/src/helpers/WirelessControl.h index ef032a50..e91adf81 100644 --- a/src/helpers/WirelessControl.h +++ b/src/helpers/WirelessControl.h @@ -57,7 +57,7 @@ class Control { Backend* backend_ = nullptr; std::atomic blocked_{0}; uint8_t scope_ = 0, target_ = 0, saved_ = 0; - bool pending_ = false, started_ = false, force_ = false, retained_requester_ = false; + bool pending_ = false, started_ = false, force_ = false, independent_requester_ = false; bool saved_valid_ = false, master_off_ = false; uint32_t due_ = 0; const char* error_ = nullptr; @@ -104,10 +104,16 @@ public: return true; } const uint8_t scope = command.scope & available; - const bool retained = (requester & ~scope) != 0; + const uint8_t target = !on ? 0 : command.scope == All && command.action != Action::OnAll + ? (saved_valid_ ? saved_ : current) & available : scope; + // Restoring a saved subset may also switch off services enabled since a + // failed restore. Protect the actual removals, not just commands named off. + const uint8_t disabling = scope & ~target; + const bool retained = (requester & ~disabling) != 0; const bool force = command.action == Action::ForceOff; - if (!on && (current & scope) && !force && !retained && !(backend_->clients() & ~scope)) { - snprintf(reply, size, "Error: no remaining management connection; use off force or USB/Ethernet"); + if ((current & disabling) && !force && !retained && !(backend_->clients() & ~disabling)) { + snprintf(reply, size, "Error: no remaining management connection; use %s or USB/Ethernet", + on ? "on all" : "off force"); return true; } if (command.scope == All && !on && !saved_valid_) { @@ -115,10 +121,12 @@ public: saved_valid_ = true; } scope_ = scope; - target_ = !on ? 0 : command.scope == All && command.action != Action::OnAll - ? (saved_valid_ ? saved_ : current) & available : scope; + target_ = target; force_ = force; - retained_requester_ = retained; + // An actual USB/UART command proves a host session even on adapters with + // no DTR reporting. Wireless requesters can disconnect during reply drain; + // only their live management sessions may authorize the delayed shutdown. + independent_requester_ = (requester & Independent) != 0; pending_ = true; started_ = false; error_ = nullptr; @@ -133,7 +141,8 @@ public: void service(uint32_t now) { if (!pending_ || !backend_ || int32_t(now - due_) < 0) return; if (!started_) { - if (!target_ && (enabled() & scope_) && !force_ && !retained_requester_ && !(backend_->clients() & ~scope_)) { + const uint8_t disabling = scope_ & ~target_; + if ((enabled() & disabling) && !force_ && !independent_requester_ && !(backend_->clients() & ~disabling)) { pending_ = false; if (master_off_) { master_off_ = false; saved_valid_ = false; } error_ = " (cancelled: connection lost)"; diff --git a/src/helpers/esp32/WiFiOtaSeeder.cpp b/src/helpers/esp32/WiFiOtaSeeder.cpp index a7758e8d..bbb10aa8 100644 --- a/src/helpers/esp32/WiFiOtaSeeder.cpp +++ b/src/helpers/esp32/WiFiOtaSeeder.cpp @@ -37,19 +37,22 @@ bool networkReady() { return static_cast(WiFi.softAPIP()) != 0; } -void detachTcpFolder() { +void detachTcpFolder(bool disconnected) { if (!tcp_folder_attached) return; OtaContext* context = ota_context_if_active(); if (context && context->folderLink() == OtaContext::FOLDER_LINK_TCP) { - context->detach_folder(); - context->clear_folder_dest(); + if (disconnected) context->disconnect_folder(); + else { + context->detach_folder(); + context->clear_folder_dest(); + } context->manager.announce(); } tcp_folder_attached = false; } -void stopClient() { - detachTcpFolder(); +void stopClient(bool disconnected = false) { + detachTcpFolder(disconnected); if (seeder_client) seeder_client.stop(); } @@ -66,7 +69,9 @@ void WiFiOtaSeeder::loop() { static_cast(OTA_SEEDER_TCP_PORT)); break; case ListenerAction::Stop: - stop(); + stopClient(true); + seeder_server.end(); + listener_active = false; return; case ListenerAction::Keep: break; @@ -87,7 +92,7 @@ void WiFiOtaSeeder::loop() { } if (seeder_client && seeder_client.connected()) return; - stopClient(); + stopClient(true); WiFiClient incoming = seeder_server.available(); if (!incoming) return; @@ -123,11 +128,6 @@ void WiFiOtaSeeder::loop() { snprintf(link_info, sizeof(link_info), "tcp %s", seeder_client.remoteIP().toString().c_str()); context.set_folder_dest(&folder_store, link_info); - if (context.manager.fetchState() == OtaManager::PAUSED) { - // This continues the active host-selected MID; it is not a boot-time - // automatic adoption governed by application autofetch policy. - context.manager.resumeStaged(context.manager.fetchManifestId()); - } context.manager.announce(); mesh::usbLoggingPort().printf( "OTA seeder client connected (%s)\n", link_info); diff --git a/src/helpers/ota/FolderMotaStore.h b/src/helpers/ota/FolderMotaStore.h index 993eea19..cf14656f 100644 --- a/src/helpers/ota/FolderMotaStore.h +++ b/src/helpers/ota/FolderMotaStore.h @@ -38,6 +38,7 @@ public: void clear() override { _total = 0; } bool finalize() override; bool reopen() override; // OP_STAT: adopt an existing file for resume + bool canReconnect() const override { return true; } // A host-backed store is fully random-access, so it needs no pinned-meta RAM page and no layout planning. bool set_meta_size(uint32_t) override { return true; } diff --git a/src/helpers/ota/OtaCli.cpp b/src/helpers/ota/OtaCli.cpp index e99443c5..16ec98fd 100644 --- a/src/helpers/ota/OtaCli.cpp +++ b/src/helpers/ota/OtaCli.cpp @@ -9,6 +9,7 @@ #include "OtaBlInfo.h" // installed bootloader application/update capability views #endif #include "Utils.h" +#include "helpers/CLICommandUtils.h" #include #include #include @@ -17,13 +18,6 @@ namespace mesh { namespace ota { -static uint32_t parse_u32(const char* s) { - uint32_t n = 0; - while (*s == ' ') s++; - while (*s >= '0' && *s <= '9') n = n * 10 + (uint32_t)(*s++ - '0'); - return n; -} - static bool parse_page(const char* s, uint16_t& page) { while (*s == ' ') s++; if (*s == 0) { page = 1; return true; } @@ -1066,23 +1060,36 @@ static bool handle_dev(const char* d, char* reply, OtaContext& c) { strcpy(reply, "ERR ota dev staging/apply is disabled on this seeder-only build"); #else if (strncmp(d, "stage ", 6) == 0) { - uint32_t sz = parse_u32(d + 6); - if (sz == 0 || sz > OTA_SERVE_BUF_SIZE) { sprintf(reply, "ERR size 1..%u", OTA_SERVE_BUF_SIZE); } + uint32_t sz; + if (!mesh::cli::parseUnsignedIntegerStrict(d + 6, sz) || sz == 0 || sz > OTA_SERVE_BUF_SIZE) { + sprintf(reply, "ERR size 1..%u", OTA_SERVE_BUF_SIZE); + } else if (!c.ensureServeBuffer()) { strcpy(reply, "ERR stage OOM"); } else { c.manager.clear_primary(); c.serving = false; memset(c.serve_buf, 0xFF, sz); c.serve_expected = sz; sprintf(reply, "OK stage %u bytes", (unsigned)sz); } } else if (strncmp(d, "recv ", 5) == 0) { - const char* p = d + 5; uint32_t off = parse_u32(p); - const char* hex = strchr(p, ' '); - if (!hex) { strcpy(reply, "ERR usage: ota dev recv "); return true; } - hex++; - int blen = (int)strlen(hex) / 2; uint8_t tmp[80]; + char arguments[sizeof(tmp) * 2 + 16]; + const char* fields[2]; + size_t count; + uint32_t off; + if (!mesh::cli::splitWhitespaceFieldsStrict(d + 5, arguments, sizeof(arguments), fields, 2, count) + || count != 2 || !mesh::cli::parseUnsignedIntegerStrict(fields[0], off)) { + strcpy(reply, "ERR usage: ota dev recv "); return true; + } + const char* hex = fields[1]; + int blen = (int)strlen(hex) / 2; if (blen <= 0 || blen > (int)sizeof(tmp) || !mesh::Utils::fromHex(tmp, blen, hex)) strcpy(reply, "ERR hex"); - else if (!c.serve_buf || off + blen > c.serve_expected) strcpy(reply, "ERR off>size (stage first)"); - else { memcpy(c.serve_buf + off, tmp, blen); sprintf(reply, "OK %d@%u", blen, (unsigned)off); } + else if (!c.serve_buf || off > c.serve_expected || (uint32_t)blen > c.serve_expected - off) { + strcpy(reply, "ERR off>size (stage first)"); + } else { + // A published view points into this buffer. Revoke it before changing + // bytes, including queued replies that captured its old manifest ID. + if (c.serving) { c.manager.clear_primary(); c.serving = false; } + memcpy(c.serve_buf + off, tmp, blen); sprintf(reply, "OK %d@%u", blen, (unsigned)off); + } } else if (strncmp(d, "serve self", 10) == 0) { // host our own running firmware, served from flash if (ota_serve_self(c, 0)) { diff --git a/src/helpers/ota/OtaContext.cpp b/src/helpers/ota/OtaContext.cpp index e2357777..3de636e0 100644 --- a/src/helpers/ota/OtaContext.cpp +++ b/src/helpers/ota/OtaContext.cpp @@ -111,7 +111,7 @@ uint8_t ota_hop_limit() { void ota_release_context_if_idle(bool temporary_radio_active) { if (!active_context) return; OtaContext& c = *active_context; - if (c.folder_active || c.folder_dest || c.apply_pending) return; + if (c.folder_active || c.folder_dest || c.apply_pending || c.folderCaptureWaiting()) return; #if defined(OTA_HEAP_CONTEXT) // Self-serving ends with the temporary radio window. It must not keep the // heap workspace forever after the first announcement. Manual staging is diff --git a/src/helpers/ota/OtaContext.h b/src/helpers/ota/OtaContext.h index d07db40c..9205beae 100644 --- a/src/helpers/ota/OtaContext.h +++ b/src/helpers/ota/OtaContext.h @@ -177,10 +177,18 @@ struct OtaContext { FolderMotaStore* folder_dest = nullptr; // non-null == a folder destination is currently connected char folder_dest_info[24] = {0}; // human id of the link (e.g. "tcp 192.168.4.5", "serial") bool fetch_to_folder = false; // COMPLETE belongs to a capture destination, never install it + FolderMotaStore* disconnected_folder_dest = nullptr; + uint8_t disconnected_folder_link = 0; + bool folderCaptureWaiting() const { + return disconnected_folder_dest && fetch_to_folder + && manager.fetchState() == OtaManager::PAUSED; + } void set_folder_dest(FolderMotaStore* fs, const char* info) { folder_dest = fs; strncpy(folder_dest_info, info ? info : "?", sizeof(folder_dest_info) - 1); folder_dest_info[sizeof(folder_dest_info) - 1] = 0; + if (folderCaptureWaiting() && fs == disconnected_folder_dest + && manager.resumeFetchAfterReconnect()) disconnected_folder_dest = nullptr; } void clear_folder_dest() { folder_dest = nullptr; folder_dest_info[0] = 0; } @@ -412,6 +420,11 @@ struct OtaContext { msg[cap - 1] = 0; return false; } + if (folderCaptureWaiting() && link != disconnected_folder_link) { + snprintf(msg, cap, "ERR paused capture belongs to %s; cancel it first", + folderLinkName(static_cast(disconnected_folder_link))); + return false; + } if (folder_active && _folder_link != link) { snprintf(msg, cap, "ERR folder already attached via %s", folderLinkName(_folder_link)); @@ -463,7 +476,14 @@ struct OtaContext { return attach_folder_source(&src, FOLDER_LINK_SERIAL, "serial", msg, cap); } #endif - void detach_folder() { + void detach_folder(bool preserve_capture = false) { + disconnected_folder_dest = nullptr; + if (!preserve_capture && fetch_to_folder) { + manager.reset_session(); + manager.set_fetch_store(&fetch_store); + fetch_to_folder = false; + } + clear_folder_dest(); if (_folder_source) manager.remove_source(_folder_source); folder_active = false; _folder_link = FOLDER_LINK_NONE; @@ -472,6 +492,19 @@ struct OtaContext { release_when_idle = true; #endif } + // Network loss differs from an operator detach: keep the chosen capture and + // its store until reconnect or an explicit reset/cancel, even outside TempRadio. + void disconnect_folder() { + FolderMotaStore* destination = folder_dest; + const uint8_t link = _folder_link; + const bool paused = destination && fetch_to_folder && manager.pauseFetchForDisconnect(); + detach_folder(true); + clear_folder_dest(); + if (paused) { + disconnected_folder_dest = destination; + disconnected_folder_link = link; + } + } #if defined(NRF52_PLATFORM) && defined(OTA_SD_STORE) // Initialize and attach the persistent source lazily. This lets Mesh::begin finish quickly when no card diff --git a/src/helpers/ota/OtaManager.cpp b/src/helpers/ota/OtaManager.cpp index 5a3c5d2e..691b25a9 100644 --- a/src/helpers/ota/OtaManager.cpp +++ b/src/helpers/ota/OtaManager.cpp @@ -82,6 +82,7 @@ uint8_t* OtaManager::ensureScratch() { void OtaManager::begin(uint32_t my_target_id, OtaSend send, void* ctx) { _target = my_target_id; _send = send; _ctx = ctx; _fstate = IDLE; _have = 0; _fbc = 0; + _paused_from = IDLE; _fetch_error = FETCH_ERROR_NONE; _archive_fetch = false; _validate = false; clearFetchIntent(); @@ -1257,8 +1258,11 @@ void OtaManager::clearFetchIntent() { } void OtaManager::failFetch(FetchError error) { + if (error == FETCH_ERROR_STORAGE && _fetch && _fetch->canReconnect() + && pauseFetchForDisconnect()) return; _fetch_error = error; _fstate = FAILED; + _paused_from = IDLE; clearReassembly(); freeLeaves(); _validate = false; @@ -1269,6 +1273,7 @@ void OtaManager::failFetch(FetchError error) { void OtaManager::completeFetch() { _fetch_error = FETCH_ERROR_NONE; _fstate = COMPLETE; + _paused_from = IDLE; _validate = false; _archive_fetch = false; clearFetchIntent(); @@ -1350,6 +1355,40 @@ bool OtaManager::resumeStagedExplicit(const uint8_t* want_mid, uint32_t expected return adopted; } +bool OtaManager::pauseFetchForDisconnect() { + if (!fetchActive()) return false; + if (_fstate != PAUSED) _paused_from = _fstate; + _fetch_error = FETCH_ERROR_STORAGE; + _fstate = PAUSED; + clearReassembly(); + return true; +} + +bool OtaManager::resumeFetchAfterReconnect() { + if (_fstate != PAUSED || !_fetch) return false; + if (_paused_from == WANT_MANIFEST || _paused_from == WANT_LEAVES) { + // begin/header/manifest/trailer may only have partly reached the host. + // Re-request the manifest and run the full initialization transaction. + // Leaf-diff also restarts initialization: the reconnected host may have + // replaced its seed file, so old metadata/leaf markers cannot be trusted. + freeLeaves(); + _mf_total = 0; _mf_mask = 0; _mf_len = 0; + _mf_retries = 0; _loop_last_mfmask = 0; + _fstate = WANT_MANIFEST; + GetManifestMsg gm; memcpy(gm.manifest_id, _fid, 4); gm.want_mask = 0xFFFF; + uint8_t wire[16]; + emit(wire, encode_get_manifest(wire, sizeof(wire), gm), false); + } else { + // Reopening is bound to the original MID/target and verifies payload bytes; + // a replacement host file must not silently become the selected capture. + if (!resumeStaged(_fid)) return false; + } + _paused_from = IDLE; + _fetch_error = FETCH_ERROR_NONE; + noteFetchActivity(); + return true; +} + void OtaManager::handleManifest(const uint8_t* m, uint16_t n) { ManifestMsg mm; if (!decode_manifest(m, n, mm) || !_fetch) return; @@ -1517,17 +1556,23 @@ void OtaManager::diffStep() { uint8_t blk[OTA_MAX_BLOCK]; for (uint32_t k = 0; k < OTA_DIFF_BATCH && _diff_idx < _fbc; k++, _diff_idx++) { uint32_t blen = blockLen(_diff_idx); - if (!_fetch->read(_fpoff + _diff_idx * _fbs, blk, blen)) continue; // read fail -> leave missing + if (!_fetch->read(_fpoff + _diff_idx * _fbs, blk, blen)) { + if (_fetch->canReconnect()) { pauseFetchForDisconnect(); return; } + continue; // unreadable local seed -> leave missing + } uint8_t leaf[4]; merkle_leaf(leaf, blk, blen); - if (memcmp(leaf, _leaves_buf + (size_t)_diff_idx * 4, 4) == 0 && // seed block == target block - _fetch->write(_floff + _diff_idx * 4, _leaves_buf + (size_t)_diff_idx * 4, 4)) - _have++; // mark present (payload already seeded) + if (memcmp(leaf, _leaves_buf + (size_t)_diff_idx * 4, 4) == 0) { // seed block == target block + if (_fetch->write(_floff + _diff_idx * 4, _leaves_buf + (size_t)_diff_idx * 4, 4)) + _have++; // mark present (payload already seeded) + else if (_fetch->canReconnect()) { pauseFetchForDisconnect(); return; } + } } if (_diff_idx < _fbc) return; // more to diff on the next tick OTA_DBG("OTA: leaf-diff %u/%u already valid; fetching the rest\n", (unsigned)_have, (unsigned)_fbc); freeLeaves(); // clears _diffing + frees the buffer if (_have >= _fbc) { // seed covered the whole image - if (!storedLeavesRootMatches()) failFetch(FETCH_ERROR_INTEGRITY); + FetchError error; + if (!storedLeavesRootMatches(error)) failFetch(error); else if (!_fetch->finalize()) failFetch(FETCH_ERROR_STORAGE); else completeFetch(); return; @@ -1606,13 +1651,17 @@ bool OtaManager::blockPresent(uint32_t i) const { return !(leaf[0]==0xFF && leaf[1]==0xFF && leaf[2]==0xFF && leaf[3]==0xFF); } -bool OtaManager::storedLeavesRootMatches() const { +bool OtaManager::storedLeavesRootMatches(FetchError& error) const { + error = FETCH_ERROR_INTEGRITY; if (!_fetch || _fbc == 0) return false; MerkleAccumulator accumulator; for (uint32_t i = 0; i < _fbc; ++i) { uint8_t leaf[4]; - if (!_fetch->read(_floff + i * 4, leaf, sizeof(leaf)) - || (leaf[0] == 0xFF && leaf[1] == 0xFF + if (!_fetch->read(_floff + i * 4, leaf, sizeof(leaf))) { + error = FETCH_ERROR_STORAGE; + return false; + } + if ((leaf[0] == 0xFF && leaf[1] == 0xFF && leaf[2] == 0xFF && leaf[3] == 0xFF) || !accumulator.add(leaf)) { return false; @@ -1834,8 +1883,8 @@ bool OtaManager::handleProof(const uint8_t* m, uint16_t n) { uint8_t leaf[4]; merkle_leaf(leaf, slot.buf, blen); if (!_fetch->write(_fpoff + block * _fbs, slot.buf, blen) || !_fetch->write(_floff + block * 4, leaf, 4)) { - _fetch_error = FETCH_ERROR_STORAGE; - _fstate = PAUSED; clearReassembly(); return true; + pauseFetchForDisconnect(); + return true; } _have++; OTA_DBG("OTA: block %u OK have=%u/%u\n", (unsigned)block, (unsigned)_have, (unsigned)_fbc); @@ -1850,9 +1899,10 @@ bool OtaManager::handleProof(const uint8_t* m, uint16_t n) { return true; } // Every leaf must be readable and collectively match the manifest root. - // A scratch allocation/read failure is an integrity failure, never success. + // Missing/corrupt leaves are integrity failures; a lost host read is resumable. clearReassembly(); - if (!storedLeavesRootMatches()) failFetch(FETCH_ERROR_INTEGRITY); + FetchError error; + if (!storedLeavesRootMatches(error)) failFetch(error); else if (!_fetch->finalize()) failFetch(FETCH_ERROR_STORAGE); else completeFetch(); OTA_DBG("OTA: transfer %s\n", _fstate == COMPLETE ? "COMPLETE" : "FAILED(integrity/storage)"); diff --git a/src/helpers/ota/OtaManager.h b/src/helpers/ota/OtaManager.h index ad8e7150..809e3c53 100644 --- a/src/helpers/ota/OtaManager.h +++ b/src/helpers/ota/OtaManager.h @@ -358,6 +358,11 @@ public: // this never applies boot-time autofetch/version policy. bool resumeStagedExplicit(const uint8_t* want_mid, uint32_t expected_target = 0); + // Hold the current selection across loss of an external destination. Metadata + // initialization restarts safely; committed transfers reverify their store. + bool pauseFetchForDisconnect(); + bool resumeFetchAfterReconnect(); + // Manual cross-target override (decision: deliberate role switch, e.g. companion -> repeater on the // same hardware). Normally a node only auto-fetches its OWN target_id; `want(T)` makes it accept an // ADV for target T instead (T=0 restores auto). The user takes responsibility for HW compatibility; @@ -484,6 +489,7 @@ public: // Drop the current fetch session back to IDLE so a fresh `ota pull` / advert starts a new one. void reset_session() { _fstate = IDLE; _have = 0; _fbc = 0; _ftotal = 0; _fflags = 0; + _paused_from = IDLE; _req_count = 0; _mf_retries = 0; _fetch_error = FETCH_ERROR_NONE; clearFetchIntent(); @@ -607,7 +613,7 @@ private: } void setDigest(uint8_t out[4]) const; // sha2-256:4 over our served mids bool blockPresent(uint32_t i) const; - bool storedLeavesRootMatches() const; + bool storedLeavesRootMatches(FetchError& error) const; void beginStagedVerification(); void verifyStagedStep(); bool requestSlot(uint8_t slot); // request DATA holes or the proof for one slot @@ -685,6 +691,7 @@ private: // fetch OtaStore* _fetch = nullptr; FetchState _fstate = IDLE; + FetchState _paused_from = IDLE; FetchError _fetch_error = FETCH_ERROR_NONE; uint8_t _fid[4] = {0}; uint8_t _froot[4] = {0}; diff --git a/src/helpers/ota/OtaStore.h b/src/helpers/ota/OtaStore.h index 37f0e756..285e6c4b 100644 --- a/src/helpers/ota/OtaStore.h +++ b/src/helpers/ota/OtaStore.h @@ -61,6 +61,10 @@ public: // OtaManager then reads + parses the stored manifest to recompute geometry and resume the fetch. virtual bool reopen() { return false; } + // External host stores can disappear temporarily while retaining their file. + // Preserve the selected fetch on an I/O failure until that link reconnects. + virtual bool canReconnect() const { return false; } + // Optional: declare the container's logical layout once the manifest is parsed, BEFORE begin(), so a // store backed by a single spare A/B partition (ESP32) can choose placement and reject an unfittable // fetch up front. A FULL image's payload IS the final firmware (no decode), so it can stream straight diff --git a/src/helpers/radiolib/RadioLibWrappers.cpp b/src/helpers/radiolib/RadioLibWrappers.cpp index b68e88a7..ce01e0ff 100644 --- a/src/helpers/radiolib/RadioLibWrappers.cpp +++ b/src/helpers/radiolib/RadioLibWrappers.cpp @@ -185,6 +185,13 @@ mesh::RadioParamApplyResult RadioLibWrapper::trySetParams(float freq, float bw, const uint8_t previous_sf = _cur_sf; const uint8_t previous_cr = _cur_cr; const uint16_t previous_preamble = _physical_preamble; + const uint8_t previous_profile = _active_profile; + const uint32_t previous_generation = _profile_generation; + const bool previous_refresh = _profile_refresh_required; + const bool previous_rxps = _rx_ps_enabled; + const bool previous_saved_rxps = _profile_saved_rxps; + const bool previous_fallback = _rx_ps_continuous_fallback; + const uint32_t previous_rx_us = _rx_ps_rx_us, previous_sleep_us = _rx_ps_sleep_us; mesh::RadioProfileParams requested = _profiles.primary; requested.freq = freq; requested.bw = bw; requested.sf = sf; requested.cr = cr; const auto previous_primary = _profiles.primary; @@ -194,10 +201,7 @@ mesh::RadioParamApplyResult RadioLibWrapper::trySetParams(float freq, float bw, bool success = applyParams(freq, bw, sf, cr); if (success) { cacheParams(freq, bw, sf, cr); - _profiles.primary = previous_primary; - _profiles.setPrimary(requested, _profiles.primary_temporary); _active_profile = 0; - _profile_generation = _profiles.generation[0]; if (rx_ps_timings != NULL) { if (_profile_rxps_suspended) _profile_saved_rxps = true; else _rx_ps_enabled = true; @@ -206,20 +210,43 @@ mesh::RadioParamApplyResult RadioLibWrapper::trySetParams(float freq, float bw, _rx_ps_continuous_fallback = rxPowerSavingUsesContinuousFallback( _rx_ps_rx_us, _rx_ps_sleep_us); } - } else { + endReconfigure(resume_rx); + success = !resume_rx || isInRecvMode(); + } + if (success) { _profiles.primary = previous_primary; + _profiles.setPrimary(requested, _profiles.primary_temporary); + _profile_generation = _profiles.generation[0]; + _profile_refresh_required = false; + } else { + // RX resume is part of a primary command just as it is for scan hops. + // Restore the active physical profile and power policy before recovery; + // neither a saved command nor a temporary lease may commit a deaf radio. + _profiles.primary = previous_primary; + cacheParams(previous_freq, previous_bw, previous_sf, previous_cr); + _params_valid = had_previous_params; + _active_profile = previous_profile; + _profile_generation = previous_generation; + _profile_refresh_required = previous_refresh; _physical_preamble = previous_preamble; + _rx_ps_enabled = previous_rxps; + _profile_saved_rxps = previous_saved_rxps; + _rx_ps_continuous_fallback = previous_fallback; + _rx_ps_rx_us = previous_rx_us; + _rx_ps_sleep_us = previous_sleep_us; bool restored = had_previous_params + && _radio->standby() == RADIOLIB_ERR_NONE && applyParams(previous_freq, previous_bw, previous_sf, previous_cr); if (!restored) restored = restoreAfterDeepInit(); + if (restored) endReconfigure(resume_rx); + if (!restored || (resume_rx && !isInRecvMode())) _profile_refresh_required = true; if (!restored) { MESH_DEBUG_PRINTLN("RadioLibWrapper: failed to restore radio parameters after apply failure"); } } - endReconfigure(resume_rx); _profile_visit_us = micros(); return success ? mesh::RadioParamApplyResult::APPLIED : mesh::RadioParamApplyResult::FAILED; } diff --git a/test/fixtures/radio_profiles/mocks/helpers/IdentityStore.h b/test/fixtures/radio_profiles/mocks/helpers/IdentityStore.h index 4df9a57d..2b4046cb 100644 --- a/test/fixtures/radio_profiles/mocks/helpers/IdentityStore.h +++ b/test/fixtures/radio_profiles/mocks/helpers/IdentityStore.h @@ -13,7 +13,9 @@ class File { size_t cursor_ = 0; public: File() = default; + explicit File(MemoryFS& fs) : fs_(&fs) {} File(MemoryFS* fs, const char* path) : fs_(fs), path_(path) {} + bool open(const char* path, uint8_t mode); operator bool() const { return fs_ != nullptr; } size_t size() const; size_t available() const { return size() - cursor_; } @@ -27,7 +29,10 @@ class MemoryFS { public: std::map> files; bool fail_write = false; + bool fail_read_open = false; + int fail_read_after = -1; bool fail_remove = false; + bool rename_replaces = false; int fail_rename = 0; std::vector fail_rename_from; bool mkdir(const char*) { return true; } @@ -36,18 +41,29 @@ class MemoryFS { bool rename(const char* from, const char* to) { if (fail_rename > 0 && --fail_rename == 0) return false; if (std::find(fail_rename_from.begin(), fail_rename_from.end(), from) != fail_rename_from.end()) return false; - if (!exists(from) || exists(to)) return false; + if (!exists(from) || (!rename_replaces && exists(to))) return false; files[to] = files[from]; files.erase(from); return true; } File open(const char* path, const char* mode = "r", bool = false) { + if (*mode == 'r' && fail_read_open) return {}; if (*mode == 'w') files[path].clear(); if (!exists(path)) return {}; return File(this, path); } }; +enum { FILE_O_READ = 0, FILE_O_WRITE = 1 }; +inline bool File::open(const char* path, uint8_t mode) { + if (!fs_) return false; + *this = fs_->open(path, mode == FILE_O_WRITE ? "w" : "r"); + return fs_ != nullptr; +} inline size_t File::size() const { return fs_ ? fs_->files[path_].size() : 0; } inline int File::read(uint8_t* data, size_t size) { if (!fs_) return -1; + if (fs_->fail_read_after >= 0) { + if (cursor_ >= static_cast(fs_->fail_read_after)) return -1; + size = std::min(size, static_cast(fs_->fail_read_after) - cursor_); + } auto& bytes = fs_->files[path_]; size = std::min(size, bytes.size() - cursor_); memcpy(data, bytes.data() + cursor_, size); cursor_ += size; return size; diff --git a/test/test_build_profiles.sh b/test/test_build_profiles.sh index f98beb86..95d04bd4 100644 --- a/test/test_build_profiles.sh +++ b/test/test_build_profiles.sh @@ -319,6 +319,35 @@ require(rak_usb, "build_flags", "FORCE_GPS_ALIVE") # replace each with that exact board's Full target. init_project_context >/dev/null +# The portable inflater is shared by every Arduino platform, but it does not +# supply the OTA manager or a supported bootloader/apply path. In particular, +# auto overlays for PicoW and RAK_3x72 must remain buildable without OTA. +for unsupported_ota_env in "${SUPPORTED_PIO_ENVS[@]}"; do + case "${PIO_ENV_PLATFORM_BY_NAME[$unsupported_ota_env]}" in + RP2040_PLATFORM|STM32_PLATFORM) ;; + *) continue ;; + esac + [ "${PIO_ENV_OTA_BY_NAME[$unsupported_ota_env]:-0}" = 0 ] \ + || fail "$unsupported_ota_env incorrectly classified its shared inflater as LoRa OTA" + for unsupported_ota_profile in auto standard; do + ( + BUILD_PROFILE_FOR_TARGET=$unsupported_ota_profile + if is_lora_ota_build "$unsupported_ota_env"; then + fail "$unsupported_ota_env $unsupported_ota_profile enables unsupported LoRa OTA" + fi + PLATFORMIO_BUILD_FLAGS="" + PLATFORMIO_BUILD_UNFLAGS="" + apply_lora_ota_override "$unsupported_ota_env" + [[ "$PLATFORMIO_BUILD_FLAGS" == *"-UENABLE_OTA"* ]] \ + || fail "$unsupported_ota_env $unsupported_ota_profile failed to disable unsupported LoRa OTA" + [[ "$PLATFORMIO_BUILD_FLAGS" != *"-DENABLE_OTA=1"* ]] \ + || fail "$unsupported_ota_env $unsupported_ota_profile emitted missing OTA implementation references" + [[ "$PLATFORMIO_BUILD_FLAGS" != *"-DCOMPANION_FEATURE_OTA_CLI=1"* ]] \ + || fail "$unsupported_ota_env $unsupported_ota_profile enabled unavailable OTA controls" + ) + done +done + # Auto means that an explicitly selected target keeps the capabilities in its # resolved PlatformIO recipe. T1000-E BLE/USB both inherit nRF52 LoRa OTA; the # release builder must not silently replace ENABLE_OTA with -UENABLE_OTA merely diff --git a/test/test_companion_settings_persistence_contract.py b/test/test_companion_settings_persistence_contract.py index 7ff8d25e..d7a3e905 100644 --- a/test/test_companion_settings_persistence_contract.py +++ b/test/test_companion_settings_persistence_contract.py @@ -182,12 +182,12 @@ class CompanionSettingsPersistenceContractTests(unittest.TestCase): presence_at = load.index( 'contactPathPresence(contacts_fs, "/channels2",' ) - open_at = load.index('openRead(_getContactsChannelsFS(), "/channels2")') + open_at = load.index('openRead(contacts_fs, "/channels2")') self.assertLess(presence_at, open_at) # Stat and open errors are distinct from a cleanly absent file, and # both must enter the same write-veto state used by saveChannels(). - self.assertGreaterEqual(load.count("_contact_load_incomplete = true;"), 2) + self.assertGreaterEqual(load.count("incomplete = true;"), 2) self.assertIn("if (!channels_exist) return;", load) def test_channel_file_requires_complete_fixed_size_records(self): @@ -206,7 +206,7 @@ class CompanionSettingsPersistenceContractTests(unittest.TestCase): self.assertRegex(load, r"file\.read\([^;]+\)\s*==") self.assertIn("if (!success)", load) self.assertRegex(load, r"if\s*\(!host->onChannelLoaded\(") - self.assertGreaterEqual(load.count("_contact_load_incomplete = true;"), 4) + self.assertGreaterEqual(load.count("incomplete = true;"), 4) save = function_body(self.store, "bool DataStore::saveChannels(") self.assertIn("if (_contact_load_incomplete) return false;", save) diff --git a/test/test_companion_tx_routing.py b/test/test_companion_tx_routing.py index 87218895..b3036143 100644 --- a/test/test_companion_tx_routing.py +++ b/test/test_companion_tx_routing.py @@ -16,6 +16,12 @@ HARNESS = r''' #include #include #include +#include "ContactFileTransaction.h" +#if defined(STM32_PLATFORM) +#define ATOMIC_FILE_WRITER_IMPLEMENTATION +#include +#endif +#define MESH_DEBUG_PRINTLN(...) ((void)0) #define MAX_GROUP_CHANNELS 4 #define MAX_ANON_CONTACTS 1 #define ADV_TYPE_NONE 0 @@ -37,6 +43,8 @@ struct DataStoreHost { struct Radio { mesh::RadioProfiles config; mesh::RadioProfiles* profiles() { return &config; } }; struct DataStore { FILESYSTEM fs; + bool _channel_load_incomplete=false; + bool hasIncompleteContactLoad() const; FILESYSTEM* _getContactsChannelsFS() { return &fs; } File openRead(FILESYSTEM* fs, const char* name) { return fs->open(name,"r"); } void loadChannels(DataStoreHost*); @@ -50,7 +58,7 @@ public: Radio radio; Radio* _radio=&radio; DataStore store; bool writable=true, writes_ok=true; unsigned writes=0; - bool canMutateContacts() const { return writable; } + bool canMutateContacts() const { return writable && !store.hasIncompleteContactLoad(); } int getTotalContactSlots() const { return contacts.size(); } ContactInfo* getContactPtrByIdx(int i) { return &contacts.at(i); } bool getChannel(int i, ChannelDetails& c) { if (i<0 || i>=MAX_GROUP_CHANNELS) return false; c=channels[i]; return true; } @@ -66,6 +74,9 @@ public: int main() { MyMesh node; +#if defined(STM32_PLATFORM) + node.store.fs.rename_replaces=true; +#endif strcpy(node.contacts[1].name,"Alice Jones"); strcpy(node.contacts[2].name,"Bob"); strcpy(node.contacts[3].name,"Bob"); for (int i=1;i<4;++i) { memset(node.contacts[i].id.pub_key,0x11,32); node.contacts[i].id.pub_key[31]=i; } @@ -111,6 +122,28 @@ int main() { assert(disk.size()==MAX_GROUP_CHANNELS*68); assert(disk[0]==mesh::encodeRadioTxPolicy(mesh::RADIO_TX_BOTH)); assert(disk[68]==mesh::encodeRadioTxPolicy(mesh::RADIO_TX_SECONDARY)); + // Loading is all-or-nothing. An unreadable/partial file must not become a + // default channel table that a later phone or CLI update can persist over it. + for(int fault : {0,1,2,3,4}) { + MyMesh boot; + boot.store.fs.files["/channels2"]=disk; + strcpy(boot.channels[0].name,"keep existing"); + if(fault==0) boot.store.fs.fail_read_open=true; + if(fault==1) boot.store.fs.fail_read_after=0; + if(fault==2) boot.store.fs.fail_read_after=68; // one complete record read + if(fault==3) boot.store.fs.files["/channels2"].pop_back(); + if(fault==4) boot.store.fs.files["/channels2"].resize((MAX_GROUP_CHANNELS+1)*68); + auto durable=boot.store.fs.files["/channels2"]; + boot.store.loadChannels(&boot); + assert(boot.store.hasIncompleteContactLoad()); + assert(!strcmp(boot.channels[0].name,"keep existing")); + assert(!boot.channels[1].name[0]); + boot.store.fs.fail_read_open=false;boot.store.fs.fail_read_after=-1; + assert(!boot.canMutateContacts()&&!boot.store.saveChannels(&boot)); + assert(boot.store.fs.files["/channels2"]==durable); + } + MyMesh fresh;fresh.store.loadChannels(&fresh); + assert(!fresh.store.hasIncompleteContactLoad()); // absent file is a fresh boot node.channels[0].channel.tx_radio=node.channels[1].channel.tx_radio=0; node.store.loadChannels(&node); assert(node.channels[0].channel.tx_radio==mesh::RADIO_TX_BOTH); @@ -118,6 +151,46 @@ int main() { node.radio.config.secondary.mode=mesh::RadioProfileMode::Rx; cmd("get tx.channel 1"); assert(strstr(reply,"active=off") && strstr(reply,"unavailable")); cmd("get tx.channel 0"); assert(strstr(reply,"active=radio ")); +#if defined(ESP32_PLATFORM) || defined(RP2040_PLATFORM) || defined(STM32_PLATFORM) + // A failed real filesystem write must preserve every durable channel, + // including its secret, rather than just rolling back the live TX policy. + node.store.fs.fail_write=true; + cmd("set tx.channel 0 off"); assert(strstr(reply,"not saved")); + assert(node.channels[0].channel.tx_radio==mesh::RADIO_TX_BOTH); + assert(node.store.fs.files["/channels2"]==disk); + node.store.fs.fail_write=false; +#if defined(STM32_PLATFORM) + for(int failed_rename : {1}) { +#else + for(int failed_rename : {1,2}) { +#endif + node.store.fs.fail_rename=failed_rename; + cmd("set tx.channel 0 off"); assert(strstr(reply,"not saved")); + assert(node.channels[0].channel.tx_radio==mesh::RADIO_TX_BOTH); + assert(node.store.fs.files["/channels2"]==disk); + } +#if !defined(STM32_PLATFORM) + // If both publish and rollback fail, the verified backup remains available + // on reboot. A failed boot recovery quarantines writes for that whole boot. + node.store.fs.fail_rename_from={"/channels2.tmp","/channels2.bak"}; + cmd("set tx.channel 0 off"); assert(strstr(reply,"not saved")); + assert(!node.store.fs.exists("/channels2")); + assert(node.store.fs.files["/channels2.bak"]==disk); + MyMesh failed_boot;failed_boot.store.fs=node.store.fs; + failed_boot.store.loadChannels(&failed_boot); + assert(failed_boot.store.hasIncompleteContactLoad()); + failed_boot.store.fs.fail_rename_from.clear(); + failed_boot.store.loadChannels(&failed_boot); + assert(failed_boot.store.hasIncompleteContactLoad()); + assert(!failed_boot.store.saveChannels(&failed_boot)); + assert(failed_boot.store.fs.files["/channels2.bak"]==disk); + node.store.fs.fail_rename_from.clear(); + node.store.loadChannels(&node); + assert(!node.store.hasIncompleteContactLoad()); + assert(node.store.fs.files["/channels2"]==disk); + assert(node.channels[0].channel.tx_radio==mesh::RADIO_TX_BOTH); +#endif +#endif node.writes_ok=false; cmd("set tx.channel 0 off"); assert(strstr(reply,"not saved")); assert(node.channels[0].channel.tx_radio==mesh::RADIO_TX_BOTH && node.store.fs.files["/channels2"]==disk); @@ -146,17 +219,26 @@ class CompanionTxRoutingTest(unittest.TestCase): methods = method(companion, 'bool MyMesh::handleTxRoutingCommand(') methods += '\n' + method(store, 'void DataStore::loadChannels(') methods += '\n' + method(store, 'bool DataStore::saveChannels(') + methods += '\n' + method(store, 'bool DataStore::hasIncompleteContactLoad(') with tempfile.TemporaryDirectory() as directory: work = Path(directory) + # Keep the real transaction implementation, substituting only its + # filesystem include with the fault-injectable test backend. + transaction = (ROOT / 'src/helpers/ContactFileTransaction.h').read_text() + (work / 'ContactFileTransaction.h').write_text(transaction.replace( + '#include "IdentityStore.h"', '#include ')) (work / 'test.cpp').write_text(HARNESS.replace('@METHODS@', methods)) - build = subprocess.run(['g++', '-std=c++17', '-Wall', '-Wextra', - '-fsanitize=address,undefined', '-fno-pie', '-no-pie', - '-I', str(ROOT / 'test/fixtures/radio_profiles/mocks'), - '-I', str(ROOT / 'test/mocks'), '-I', str(ROOT / 'src'), - str(work / 'test.cpp'), '-o', str(work / 'test')], capture_output=True, text=True) - self.assertEqual(build.returncode, 0, build.stdout + build.stderr) - run = subprocess.run([str(work / 'test')], capture_output=True, text=True) - self.assertEqual(run.returncode, 0, run.stdout + run.stderr) + for platform in ('ESP32_PLATFORM', 'RP2040_PLATFORM', 'STM32_PLATFORM'): + with self.subTest(platform=platform): + build = subprocess.run(['g++', '-std=c++17', '-Wall', '-Wextra', '-D'+platform+'=1', + '-fsanitize=address,undefined', '-fno-pie', '-no-pie', + '-I', str(ROOT / 'test/fixtures/radio_profiles/mocks'), + '-I', str(ROOT / 'test/mocks'), '-I', str(ROOT / 'src'), + '-I', str(ROOT / 'src/helpers'), + str(work / 'test.cpp'), '-o', str(work / 'test')], capture_output=True, text=True) + self.assertEqual(build.returncode, 0, build.stdout + build.stderr) + run = subprocess.run([str(work / 'test')], capture_output=True, text=True) + self.assertEqual(run.returncode, 0, run.stdout + run.stderr) def test_phone_protocol_updates_preserve_same_channel_policy(self): source = (ROOT / 'examples/companion_radio/MyMesh.cpp').read_text() diff --git a/test/test_ota_dev_staging.py b/test/test_ota_dev_staging.py new file mode 100644 index 00000000..327afa61 --- /dev/null +++ b/test/test_ota_dev_staging.py @@ -0,0 +1,104 @@ +"""Execute production OTA staging commands with bounds and lifetime checks.""" +from pathlib import Path +import os +import subprocess +import tempfile +import unittest + +from test_t096_full_memory import method + +ROOT = Path(__file__).resolve().parents[1] + + +class OtaDevStagingTest(unittest.TestCase): + def test_invalid_commands_are_atomic_and_writes_revoke_serving(self): + cli = (ROOT / "src/helpers/ota/OtaCli.cpp").read_text() + parser = (method(cli, "static uint32_t parse_u32(") + if "static uint32_t parse_u32(" in cli else "") + stage = method(cli, 'if (strncmp(d, "stage ", 6) == 0)') + receive = method(cli, 'else if (strncmp(d, "recv ", 5) == 0)') + with tempfile.TemporaryDirectory() as directory: + path = Path(directory) + (path / "commands.h").write_text( + parser + '\nstatic bool command(const char* d, char* reply, Context& c) {\n' + + stage + receive + '\nreturn true;\n}\n') + source = path / "test.cpp" + source.write_text(r''' +#include +#include +#include +#include +#include +#define OTA_SERVE_BUF_SIZE 256 +struct Context { + uint8_t storage[OTA_SERVE_BUF_SIZE]; + uint8_t* serve_buf = storage; + uint32_t serve_expected = 0; + bool serving = false; + struct Manager { + unsigned cleared = 0; + void clear_primary() { ++cleared; } + } manager; + bool ensureServeBuffer() { return true; } +}; +#include "commands.h" +int main(int argc, char** argv) { + assert(argc == 2); + Context context; + char reply[160]; + auto call = [&](const char* text) { command(text, reply, context); }; + call("stage 4"); + assert(!strncmp(reply, "OK", 2)); + auto reject = [&](const char* text) { + Context before = context; + call(text); + assert(!strncmp(reply, "ERR", 3)); + assert(context.serve_expected == before.serve_expected); + assert(context.serving == before.serving); + assert(context.manager.cleared == before.manager.cleared); + assert(!memcmp(context.storage, before.storage, sizeof(context.storage))); + }; + const std::string scenario(argv[1]); + if (scenario == "stage") { + for (const char* text : {"stage 4junk", "stage 4294967552", "stage -1", + "stage 0", "stage 257", "stage 4 8", "stage "}) reject(text); + } else if (scenario == "offset") { + for (const char* text : {"recv junk 00", "recv 1junk 00", "recv -1 00", + "recv 4294967296 00", "recv 0 001", "recv 0 0g", "recv 4 00", + "recv 3 0000", "recv 0", "recv 0 ", "recv 0 00 extra"}) reject(text); + } else if (scenario == "wrap") { + reject("recv 4294967295 0000"); + } else if (scenario == "live") { + context.serving = true; + unsigned prior = context.manager.cleared; + reject("recv 4 00"); + call("recv 0 aB"); + assert(!strncmp(reply, "OK", 2)); + assert(!context.serving && context.manager.cleared == prior + 1); + assert(context.storage[0] == 0xAB); + } else { + call("recv 0 001122"); + assert(!strncmp(reply, "OK", 2)); + call("recv 3 Ff"); + assert(!strncmp(reply, "OK", 2)); + assert(!memcmp(context.storage, "\x00\x11\x22\xFF", 4)); + reject("recv 4 aa"); + } +} +''') + binary = path / "staging" + flags = [] if os.name == "nt" else ["-fsanitize=address,undefined"] + result = subprocess.run([ + "c++", "-std=c++17", *flags, "-I", str(ROOT / "src"), + "-I", str(ROOT / "test/mocks"), str(source), + str(ROOT / "src/Utils.cpp"), "-o", str(binary), + ], text=True, capture_output=True) + self.assertEqual(result.returncode, 0, result.stderr) + for scenario in ("stage", "offset", "wrap", "live", "valid"): + with self.subTest(scenario=scenario): + result = subprocess.run([str(binary), scenario], text=True, capture_output=True) + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + + +if __name__ == "__main__": + unittest.main() diff --git a/test/test_ota_heap_context.py b/test/test_ota_heap_context.py index 8d27dcdf..e225ac83 100644 --- a/test/test_ota_heap_context.py +++ b/test/test_ota_heap_context.py @@ -119,6 +119,202 @@ int main() { ''') self.compile_and_run(path, source) + def test_disconnected_folder_capture_retains_selected_session(self): + with tempfile.TemporaryDirectory() as temp: + path = Path(temp) + source = path / "test.cpp" + (path / "vectors.h").write_text('#include "' + + (ROOT / "test/test_ota/mota_vectors.h").as_posix() + '"\n') + (path / "tcp_detach.h").write_text(method( + (ROOT / "src/helpers/esp32/WiFiOtaSeeder.cpp").read_text(), + "void detachTcpFolder(")) + source.write_text(r''' +#include +#include +#include +#include +#include "vectors.h" +using namespace mesh::ota; +namespace mesh { namespace ota { +bool ota_self_firmware(SelfFwInfo& info) { info = SelfFwInfo(); return false; } +} } +struct Message { OtaManager* dest; std::vector bytes; }; +static std::vector queue; +static bool send(void* ctx, const uint8_t* bytes, uint16_t len, bool) { + queue.push_back({*static_cast(ctx), {bytes, bytes + len}}); + return true; +} +struct EmptySource : MotaSource { + uint8_t count() override { return 0; } + bool describe(uint8_t, MotaDesc&) override { return false; } + bool read(uint8_t, uint32_t, uint8_t*, uint32_t) override { return false; } +}; +static Stream stream; +struct MemoryFolder : FolderMotaStore { + OtaStoreRam<4096> memory; + bool fail_begin = false, fail_finalize = false; + bool fail_root_read = false; + bool corrupt_root_read = false; + OtaManager* receiver = nullptr; + uint32_t fail_write_at = UINT32_MAX; + uint32_t fail_read_at = UINT32_MAX; + MemoryFolder() : FolderMotaStore(stream, MotaStreamWritePolicy::NoFlush, 20) {} + bool begin(uint32_t n) override { return !fail_begin && memory.begin(n); } + bool write(uint32_t off, const uint8_t* bytes, uint32_t n) override { + return off != fail_write_at && memory.write(off, bytes, n); + } + bool read(uint32_t off, uint8_t* bytes, uint32_t n) const override { + if (off == fail_read_at || (fail_root_read && receiver && receiver->blocksTotal() + && receiver->blocksHave() == receiver->blocksTotal())) return false; + if (!memory.read(off, bytes, n)) return false; + if (corrupt_root_read && receiver && receiver->blocksTotal() + && receiver->blocksHave() == receiver->blocksTotal() && n) bytes[0] ^= 1; + return true; + } + uint32_t staged_size() const override { return memory.staged_size(); } + bool reopen() override { return memory.reopen(); } + bool finalize() override { return !fail_finalize; } +}; +static bool tcp_folder_attached = true; +#include "tcp_detach.h" +int main(int argc, char** argv) { + const int scenario = argc > 1 ? atoi(argv[1]) : 0; + MemoryFolder store; + EmptySource source; + OtaManager server; + OtaManager* server_ptr = &server; + ota_begin_context(SIM_TARGET_ID, send, &server_ptr, "test", nullptr); + assert(ota_acquire_context(nullptr, 0)); + OtaContext* context = ota_context_if_active(); + OtaManager* client_ptr = &context->manager; + store.receiver = client_ptr; + server.begin(0, send, &client_ptr); + assert(server.serve(SIM_MOTA_1K, SIM_MOTA_1K_LEN)); + MotaManifest manifest; + assert(mota_parse(SIM_MOTA_1K, SIM_MOTA_1K_LEN, manifest)); + uint8_t mid[4]; memcpy(mid, manifest.merkle_root, sizeof(mid)); + char reply[160] = {}; + assert(context->attach_folder_source(&source, OtaContext::FOLDER_LINK_TCP, + "tcp", reply, sizeof(reply))); + context->manager.set_fetch_store(&store); + context->fetch_to_folder = true; + context->set_folder_dest(&store, "tcp"); + store.fail_begin = scenario == 2; + store.fail_finalize = scenario == 5; + store.fail_root_read = scenario == 10; + store.corrupt_root_read = scenario == 14; + if (scenario == 3) store.fail_write_at = 8; + if (scenario == 4) store.fail_write_at = manifest.payload - SIM_MOTA_1K; + if (scenario == 13) store.fail_read_at = manifest.payload - SIM_MOTA_1K; + assert(context->manager.pull_archive(mid, SIM_TARGET_ID, scenario == 9 || scenario == 12 || scenario == 13) + == OtaManager::PULL_STARTED); + uint32_t now = 0; + auto tick = [&]() { + if (!queue.empty()) { + Message msg = queue.front(); queue.erase(queue.begin()); + msg.dest->on_message(msg.bytes.data(), msg.bytes.size()); + } else { + now += 1000; + context->manager.set_clock(now); server.set_clock(now); + context->manager.loop(); server.loop(); + } + context->manager.serviceEgress(); server.serviceEgress(); + }; + if ((scenario >= 1 && scenario <= 6) || scenario >= 9) { + unsigned guard = 100000; + while (guard--) { + tick(); + if (scenario == 14) { + if (context->manager.fetchState() == OtaManager::FAILED) break; + } else if ((scenario >= 2 && scenario <= 5) || scenario == 10 || scenario == 13) { + if (context->manager.fetchState() == OtaManager::PAUSED) break; + assert(context->manager.fetchState() != OtaManager::FAILED); + } else if (scenario == 9 || scenario == 12) { + if (context->manager.fetchState() == OtaManager::WANT_LEAVES) break; + } else if (context->manager.blocksHave() >= 1) break; + } + assert(guard != UINT32_MAX); + } + if (scenario == 14) { + assert(context->manager.fetchError() == OtaManager::FETCH_ERROR_INTEGRITY); + detachTcpFolder(true); + ota_release_context_if_idle(false); + assert(!ota_context_if_active()); + return 0; + } + // Execute the real TCP loss cleanup, then the normal role lifetime service. + detachTcpFolder(true); + assert(context->manager.fetchState() == OtaManager::PAUSED); + ota_release_context_if_idle(false); + assert(ota_context_if_active() == context); + ota_release_context_if_idle(true); + assert(ota_context_if_active() == context); + assert(memcmp(context->manager.fetchManifestId(), mid, sizeof(mid)) == 0); + queue.clear(); + const auto sent = context->manager.packetsSent(); + for (unsigned i = 0; i < 100; ++i) context->manager.loop(); + assert(context->manager.packetsSent() == sent); + assert(!context->attach_folder_source(&source, OtaContext::FOLDER_LINK_BLE, + "ble", reply, sizeof(reply))); + if (scenario == 7 || scenario == 8) { + if (scenario == 7) context->manager.reset_session(); + else { + context->detach_folder(); + // Static-context builds also need an idle slot after explicit detach. + assert(context->manager.fetchState() == OtaManager::IDLE); + assert(!context->fetch_to_folder && !context->folder_dest); + } + ota_release_context_if_idle(false); + assert(!ota_context_if_active()); + return 0; + } + store.fail_begin = store.fail_finalize = false; + store.fail_root_read = false; + store.fail_write_at = UINT32_MAX; + store.fail_read_at = UINT32_MAX; + assert(context->attach_folder_source(&source, OtaContext::FOLDER_LINK_TCP, + "tcp", reply, sizeof(reply))); + if (scenario == 6) { + // A different file at the host cannot change the interrupted selection. + uint8_t wrong_mid[4] = {9, 8, 7, 6}; + assert(store.memory.write(8 + 20, wrong_mid, sizeof(wrong_mid))); + context->set_folder_dest(&store, "tcp"); + assert(context->manager.fetchState() == OtaManager::PAUSED); + assert(memcmp(context->manager.fetchManifestId(), mid, sizeof(mid)) == 0); + assert(store.memory.write(8 + 20, mid, sizeof(mid))); + } + if (scenario == 12) { + uint8_t wrong_mid[4] = {9, 8, 7, 6}; + assert(store.memory.write(8 + 20, wrong_mid, sizeof(wrong_mid))); + } + context->set_folder_dest(&store, "tcp"); + if (scenario == 11) { + store.fail_read_at = manifest.payload - SIM_MOTA_1K; + context->manager.loop(); + assert(context->manager.fetchState() == OtaManager::PAUSED); + context->disconnect_folder(); + ota_release_context_if_idle(false); + assert(ota_context_if_active() == context); + store.fail_read_at = UINT32_MAX; + assert(context->attach_folder_source(&source, OtaContext::FOLDER_LINK_TCP, + "tcp", reply, sizeof(reply))); + context->set_folder_dest(&store, "tcp"); + } + unsigned guard = 100000; + while (context->manager.fetchState() != OtaManager::COMPLETE && guard--) { + tick(); + assert(context->manager.fetchState() != OtaManager::FAILED); + } + assert(context->manager.fetchState() == OtaManager::COMPLETE); + assert(store.staged_size() == SIM_MOTA_1K_LEN); + assert(memcmp(store.memory.data(), SIM_MOTA_1K, SIM_MOTA_1K_LEN) == 0); + context->detach_folder(); context->clear_folder_dest(); + ota_release_context_if_idle(false); + assert(!ota_context_if_active()); +} +''') + self.compile_and_run(path, source, [(str(i),) for i in range(15)]) + def test_self_refresh_revokes_view_before_freeing_buffers(self): with tempfile.TemporaryDirectory() as temp: path = Path(temp) @@ -221,7 +417,7 @@ int main(int argc, char** argv) { tinf = path / "tinf.o" subprocess.run([shutil.which("cc") or "gcc", "-DENABLE_OTA=1", *flags, "-c", str(ROOT / "src/helpers/ota/OtaTinf.c"), "-o", str(tinf)], check=True) - sources = ["OtaContext.cpp", "OtaManager.cpp", "OtaProtocol.cpp", + sources = ["OtaContext.cpp", "OtaManager.cpp", "OtaProtocol.cpp", "FolderMotaStore.cpp", "MotaContainer.cpp", "MerkleTree.cpp", "OtaDeflate.cpp"] binary = path / "heap.exe" result = subprocess.run([ diff --git a/test/test_radio_profile_scan.py b/test/test_radio_profile_scan.py index 87f528dc..cb2063fd 100644 --- a/test/test_radio_profile_scan.py +++ b/test/test_radio_profile_scan.py @@ -119,6 +119,52 @@ int main() { assert(w._profile_refresh_required==(failures==2)); } } + { + // A primary command must not publish a new profile if restarting RX + // fails. Restore the active tuple (which may be radio2) and RXPS policy. + for (unsigned initial=0; initial<4; ++initial) { + const bool secondary=(initial & 1), powersaving=(initial & 2); + for (bool primary_api: {false,true}) { + for (unsigned failures: {1u,2u}) { + RadioLibWrapper w; + w._rx_ps_enabled=w._rx_ps_armed=powersaving; + if (secondary) { w.enable(); assert(w.tuneProfile(1)==Result::APPLIED); } + const auto primary=w._profiles.primary; + const auto generation=w._profiles.generation[0]; + const auto active=w._active_profile; + const auto physical_generation=w._profile_generation; + const auto freq=w._cur_freq, bw=w._cur_bw; + const auto preamble=w._physical_preamble; + const auto rxps=w._rx_ps_enabled, saved_rxps=w._profile_saved_rxps; + const auto rx_us=w._rx_ps_rx_us, sleep_us=w._rx_ps_sleep_us; + const auto fallback=w._rx_ps_continuous_fallback; + auto requested=primary; requested.freq=910.25; requested.preamble=64; + const uint32_t timings[]={100000,200000}; + w.failRxStarts=failures; + const auto result=primary_api ? w.trySetPrimaryParams(requested,true,timings) + : w.trySetParams(requested.freq,requested.bw,requested.sf,requested.cr,timings); + assert(result==Result::FAILED); + assert(w._profiles.primary==primary && !w._profiles.primary_temporary); + assert(w._profiles.generation[0]==generation && w._profile_generation==physical_generation); + assert(w._active_profile==active && w._cur_freq==freq && w._cur_bw==bw); + assert(w._physical_preamble==preamble); + assert(w._rx_ps_enabled==rxps && w._profile_saved_rxps==saved_rxps); + assert(w._rx_ps_rx_us==rx_us && w._rx_ps_sleep_us==sleep_us); + assert(w._rx_ps_continuous_fallback==fallback); + assert(w.isInRecvMode()==(failures==1)); + assert(w._profile_refresh_required==(failures==2)); + if (!w.isInRecvMode()) w.startRecv(); // normal receive recovery + assert(w.trySetPrimaryParams(requested,true,timings)==Result::APPLIED); + assert(w._profiles.primary==requested && w._profiles.primary_temporary); + assert(w._profiles.generation[0]==generation+1); + assert(w._profile_generation==generation+1 && w._active_profile==0); + assert(w.isInRecvMode() && !w._profile_refresh_required); + assert(w._rx_ps_rx_us==timings[0] && w._rx_ps_sleep_us==timings[1]); + assert(secondary ? w._profile_saved_rxps : w._rx_ps_enabled); + } + } + } + } { RadioLibWrapper w; w.enable(); assert(w._profile_rxps_suspended && !w._rx_ps_enabled && !w._rx_ps_armed); diff --git a/test/test_wireless_control.py b/test/test_wireless_control.py index 0b7f1b52..aa450db8 100644 --- a/test/test_wireless_control.py +++ b/test/test_wireless_control.py @@ -104,6 +104,24 @@ int main(int argc,char** argv) { assert(!f.c.pending()&&f.b.active==All&&f.b.calls.size()==1); f.run("get 2.4ghz");assert(strstr(f.reply,"failed")); f.b.wait=0;f.run("set 2.4ghz off");f.tick();assert(f.b.active==0); + } else if(test==8){ + Fixture f;f.b.active=WiFi|Bluetooth;f.b.sessions=WiFi|Bluetooth; + f.run("set wifi off",Bluetooth);assert(f.c.pending()); + f.b.active=WiFi;f.b.sessions=WiFi; // the retained requester disconnects + f.tick();assert(f.b.active==WiFi&&!f.c.pending()); + f.run("get wifi");assert(strstr(f.reply,"cancelled")); + // Explicit force still permits shutting down the only live connection. + f.run("set wifi off force",WiFi);f.tick();assert(f.b.active==0); + } else if(test==9){ + Fixture f;f.b.active=WiFi; + f.run("set 2.4ghz off");f.tick(); + f.b.fail=WiFi;f.run("set 2.4ghz on");f.tick();assert(!f.c.pending()); + f.b.fail=0;f.b.active=Bluetooth;f.b.sessions=Bluetooth; + // The failed restore retains a WiFi-only saved mask. Enabling BLE to + // recover access must not make the next ordinary restore cut that access. + f.run("set 2.4ghz on",Bluetooth); + assert(strstr(f.reply,"no remaining")&&!f.c.pending()&&f.b.active==Bluetooth); + f.run("set 2.4ghz on all",Bluetooth);f.tick();assert(f.b.active==All); } } ''' @@ -116,7 +134,7 @@ class WirelessControlTests(unittest.TestCase): binary = Path(tmp)/'wireless' subprocess.run([os.environ.get('CXX', 'g++'), '-std=c++11', '-Wall', '-Wextra', '-Werror', '-I', str(ROOT/'src'), str(source), '-o', str(binary)], check=True) - for scenario in range(8): + for scenario in range(10): with self.subTest(scenario=scenario): subprocess.run([str(binary), str(scenario)], check=True) @@ -129,6 +147,9 @@ class WirelessControlTests(unittest.TestCase): adapter = method(main, 'class CompanionWirelessBackend')+';\n' adapter += method(main, 'static void disableCompanionBluetoothForCli(')+'\n' adapter += method(main, 'bool handleCompanionWirelessCommand(') + adapter += '\n'+method(main, 'static bool hasCompanionNonBluetoothClient(') + adapter += '\n'+method(main, 'static void serviceCompanionBluetoothControl(') + adapter += '\n'+method(main, 'bool handleCompanionBluetoothCommand(') source_text = prefix+r''' #include "examples/companion_radio/CompanionWireless.h" bool companion_wifi_requested=true,companion_wifi_active=true; @@ -187,6 +208,55 @@ int main(){ handleCompanionWirelessCommand("set espnow off",reply,sizeof(reply),CompanionWirelessSource::Framed); now_ms+=250;mesh::wireless::control().service(now_ms); assert(!radio_driver.active&&companion_wifi_active&&bluetooth_interface.enabled); + // Each shutdown must not use the other pending shutdown's connection as + // its safety net. Bluetooth's force override remains available explicitly. + usb_serial_interface.connected=false; + bluetooth_interface.connected=true;bluetooth_interface.frame=true; + interface_manager.checkRecvFrame(frame); + handleCompanionWirelessCommand("set wifi off",reply,sizeof(reply),CompanionWirelessSource::Framed); + assert(mesh::wireless::control().pending()); + handleCompanionBluetoothCommand("set bluetooth off",reply,sizeof(reply),CompanionBluetoothCommandSource::Terminal); + assert(strstr(reply,"pending")&&bluetooth_interface.enabled); + handleCompanionBluetoothCommand("set bluetooth off force",reply,sizeof(reply),CompanionBluetoothCommandSource::Terminal); + assert(!bluetooth_interface.enabled); + now_ms+=250;mesh::wireless::control().service(now_ms); + assert(companion_wifi_active&&!mesh::wireless::control().pending()); + handleCompanionWirelessCommand("get wifi",reply,sizeof(reply),CompanionWirelessSource::Network); + assert(strstr(reply,"cancelled")); + // The opposite command order is protected too, including asynchronous + // WiFi teardown where its socket can stay visible during the first stop. + handleCompanionBluetoothCommand("set bluetooth on",reply,sizeof(reply),CompanionBluetoothCommandSource::Terminal); + bluetooth_interface.connected=true;bluetooth_interface.frame=true; + interface_manager.checkRecvFrame(frame); + handleCompanionBluetoothCommand("set bluetooth off",reply,sizeof(reply),CompanionBluetoothCommandSource::Framed); + assert(companion_bluetooth_off_at!=0); + handleCompanionWirelessCommand("set wifi off",reply,sizeof(reply),CompanionWirelessSource::Network); + assert(strstr(reply,"Bluetooth change pending")&&!mesh::wireless::control().pending()); + now_ms+=250;serviceCompanionBluetoothControl(); + assert(!bluetooth_interface.enabled&&companion_wifi_active); + // A restore can remove WiFi too. Keep a BLE-only saved snapshot after a + // failed restore, then simulate both management services recovering. + companion_wifi_requested=companion_wifi_active=false;wifi_interface.disable(); + interface_manager.enableBluetooth(); + handleCompanionWirelessCommand("set 2.4ghz off",reply,sizeof(reply),CompanionWirelessSource::Usb); + now_ms+=250;mesh::wireless::control().service(now_ms); + bluetooth_interface.enable_fails=true; + handleCompanionWirelessCommand("set 2.4ghz on",reply,sizeof(reply),CompanionWirelessSource::Usb); + now_ms+=250;mesh::wireless::control().service(now_ms); + assert(!mesh::wireless::control().pending()&&!bluetooth_interface.enabled); + bluetooth_interface.enable_fails=false;interface_manager.enableBluetooth(); + companion_wifi_requested=companion_wifi_active=true;wifi_interface.enable(); + wifi_interface.connected=true;bluetooth_interface.connected=true; + bluetooth_interface.frame=true;interface_manager.checkRecvFrame(frame); + handleCompanionBluetoothCommand("set bluetooth off",reply,sizeof(reply),CompanionBluetoothCommandSource::Framed); + assert(companion_bluetooth_off_at!=0); + handleCompanionWirelessCommand("set 2.4ghz on",reply,sizeof(reply),CompanionWirelessSource::Network); + assert(strstr(reply,"Bluetooth change pending")&&!mesh::wireless::control().pending()); + // Explicit all cannot remove a service and may replace the pending stop. + handleCompanionWirelessCommand("set 2.4ghz on all",reply,sizeof(reply),CompanionWirelessSource::Network); + assert(mesh::wireless::control().pending()); + now_ms+=250;mesh::wireless::control().service(now_ms); + assert(!companion_bluetooth_off_at&&bluetooth_interface.enabled&&companion_wifi_active); } ''' with tempfile.TemporaryDirectory() as tmp: