Harden radio recovery, OTA reconnects, and companion settings

Reject radio changes when receive mode cannot resume and restore the previous physical profile and power policy. Preserve selected folder OTA captures across disconnects and recoverable host I/O failures, while keeping corruption failures distinct. Validate OTA staging input before arithmetic or buffer writes and revoke published data before edits.

Commit companion channels through verified file replacement on ESP32, RP2040, and STM32, and quarantine incomplete startup reads. Recheck the last management connection during delayed wireless changes and prevent conflicting Bluetooth shutdowns. Detect complete OTA implementation sources rather than the shared inflater when configuring release builds.

Add fault-injection regressions and CI coverage for these paths, and document folder capture reconnect behavior.

Validation: 1,510 native tests; 55 host integration checks; build profile regressions; ESP32, nRF52, STM32, and RP2040 firmware builds.
This commit is contained in:
mikecarper committed 2026-09-15 10:02:23 -07:00
1 parent f844d30361
commit 39c3f7b25c
27 files changed
+852 -137

No files matched your search

+2
View File
@@ -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
+5 -1
View File
@@ -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"\+<helpers/ota/(?:\*\.cpp|OtaManager\.cpp)>", str(item)
) for item in values)
continue
if key != "build_flags":
continue
+5
View File
@@ -753,6 +753,11 @@ fetches a `.mota` off the mesh and streams it onto the host as `<mid>.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.
+4 -1
View File
@@ -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
+59 -60
View File
@@ -3,16 +3,16 @@
#include "DataStore.h"
#include <helpers/FileRead.h>
#include <helpers/AdvertDataHelpers.h>
#if MESH_CONTACT_CACHE && defined(ESP32_PLATFORM)
#if defined(ESP32_PLATFORM) || defined(RP2040_PLATFORM)
#include <helpers/ContactFileTransaction.h>
#endif
#if COMPANION_FEATURE_JOHN
#include <helpers/bible/JohnBookmarkFiles.h>
#endif
#if defined(NRF52_PLATFORM)
#if defined(NRF52_PLATFORM) || defined(STM32_PLATFORM)
#include <helpers/AtomicFileWriter.h>
#if defined(EXTRAFS) && !defined(QSPIFLASH)
#if defined(NRF52_PLATFORM) && defined(EXTRAFS) && !defined(QSPIFLASH)
#include <helpers/nrf52/InternalSecondaryFsRepair.h>
#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<uint8_t>(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
+3
View File
@@ -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;
+14
View File
@@ -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;
+3 -3
View File
@@ -1,6 +1,6 @@
#pragma once
#if defined(NRF52_PLATFORM)
#if defined(NRF52_PLATFORM) || defined(STM32_PLATFORM)
#include <stddef.h>
#include <stdint.h>
@@ -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
+5 -1
View File
@@ -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 <stdio.h>
@@ -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<bool>(_file);
}
~ContactFileTransaction() {
+17 -8
View File
@@ -57,7 +57,7 @@ class Control {
Backend* backend_ = nullptr;
std::atomic<uint8_t> 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)";
+12 -12
View File
@@ -37,19 +37,22 @@ bool networkReady() {
return static_cast<uint32_t>(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<unsigned>(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);
+1
View File
@@ -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; }
+23 -16
View File
@@ -9,6 +9,7 @@
#include "OtaBlInfo.h" // installed bootloader application/update capability views
#endif
#include "Utils.h"
#include "helpers/CLICommandUtils.h"
#include <stdio.h>
#include <string.h>
#include <stdlib.h>
@@ -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 <off> <hex>"); 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 <off> <hex>"); 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)) {
+1 -1
View File
@@ -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
+34 -1
View File
@@ -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<FolderLink>(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
+62 -12
View File
@@ -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)");
+8 -1
View File
@@ -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};
+4
View File
@@ -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
+32 -5
View File
@@ -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;
}
+17 -1
View File
@@ -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<std::string, std::vector<uint8_t>> 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<std::string> 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<size_t>(fs_->fail_read_after)) return -1;
size = std::min(size, static_cast<size_t>(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;
+29
View File
@@ -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
@@ -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)
+91 -9
View File
@@ -16,6 +16,12 @@ HARNESS = r'''
#include <helpers/CLICommandUtils.h>
#include <helpers/CompanionTxRoutingCLI.h>
#include <helpers/IdentityStore.h>
#include "ContactFileTransaction.h"
#if defined(STM32_PLATFORM)
#define ATOMIC_FILE_WRITER_IMPLEMENTATION
#include <helpers/AtomicFileWriter.h>
#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 <helpers/IdentityStore.h>'))
(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()
+104
View File
@@ -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 <helpers/CLICommandUtils.h>
#include <Utils.h>
#include <cassert>
#include <cstring>
#include <string>
#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()
+197 -1
View File
@@ -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 <helpers/ota/OtaContext.h>
#include <cassert>
#include <vector>
#include <helpers/ota/FolderMotaStore.h>
#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<uint8_t> bytes; };
static std::vector<Message> queue;
static bool send(void* ctx, const uint8_t* bytes, uint16_t len, bool) {
queue.push_back({*static_cast<OtaManager**>(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([
+46
View File
@@ -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);
+71 -1
View File
@@ -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: