From 9a2211e85a7f2bde1ba577e0b8e7ffedd39190b6 Mon Sep 17 00:00:00 2001 From: mikecarper Date: Tue, 27 Oct 2026 22:54:19 -0700 Subject: [PATCH 01/61] Fix Cardputer delivery, live settings and power management Keep selected chats bound to full contact keys and route keyboard channel sends through the active transport. Deliver accepted plain, room and Meshtext messages to local history and notifications once; disarm rejected sends and retries. Use shared live radio settings, GPS acquisition and power-saving policy, screen timers and red night palette. Report save failures, preserve verified custom preferences, and guard display controls and shutdown. Add executable sanitizer regressions for routing, retries, selection, storage faults, UI lifecycle and GPS. Both Companion builds and the shared T-Deck regression pass; no physical Cardputer testing performed. --- .github/workflows/run-unit-tests.yml | 4 + examples/companion_radio/MyMesh.cpp | 29 +- examples/companion_radio/MyMesh.h | 12 + examples/companion_radio/main.cpp | 2 + src/helpers/ui/DisplayBuildFlags.h | 10 +- src/helpers/ui/DisplayPowerSettings.cpp | 5 + test/test_cardputer_delivery.py | 293 ++++++++++++++ test/test_cardputer_gps.py | 218 +++++++++++ test/test_cardputer_mesh_storage.py | 115 +++++- test/test_cardputer_messaging.py | 241 ++++++++++++ test/test_cardputer_ui.py | 69 +++- test/test_cardputer_ui_lifecycle.py | 362 ++++++++++++++++++ .../CardputerDataStore.cpp | 111 +++++- .../CardputerDataStore.h | 5 +- .../CardputerSensorManager.cpp | 170 +++++--- .../CardputerSensorManager.h | 17 + variants/m5stack_cardputer_adv/README.md | 29 ++ .../companion_radio/CardputerMesh.cpp | 106 ++--- .../companion_radio/CardputerMesh.h | 16 +- .../ui-cardputer/CardputerUITask.cpp | 212 ++++++---- .../ui-cardputer/CardputerUITask.h | 18 +- .../ui-cardputer/screen/SettingsScreen.cpp | 158 ++++---- .../ui-cardputer/screen/SettingsScreen.h | 4 +- .../ui-cardputer/screen/main/ChatPage.cpp | 50 ++- .../ui-cardputer/screen/main/ChatPage.h | 1 + .../ui-cardputer/screen/main/ContactsPage.cpp | 16 +- .../hw/CardputerDisplay.cpp | 23 +- 27 files changed, 1950 insertions(+), 346 deletions(-) create mode 100644 test/test_cardputer_delivery.py create mode 100644 test/test_cardputer_gps.py create mode 100644 test/test_cardputer_messaging.py create mode 100644 test/test_cardputer_ui_lifecycle.py diff --git a/.github/workflows/run-unit-tests.yml b/.github/workflows/run-unit-tests.yml index 863a73641..c3ece928f 100644 --- a/.github/workflows/run-unit-tests.yml +++ b/.github/workflows/run-unit-tests.yml @@ -400,7 +400,11 @@ jobs: python3 -B test/test_cardputer_board_policy.py -v python3 -B test/test_cardputer_layout.py -v python3 -B test/test_cardputer_ui.py -v + python3 -B test/test_cardputer_ui_lifecycle.py -v python3 -B test/test_cardputer_mesh_storage.py -v + python3 -B test/test_cardputer_messaging.py -v + python3 -B test/test_cardputer_delivery.py -v + python3 -B test/test_cardputer_gps.py -v python3 -B test/test_cardputer_runtime_memory.py -v python3 -B test/test_cardputer_license.py -v python3 -B test/test_protocol_parser_bounds.py -v diff --git a/examples/companion_radio/MyMesh.cpp b/examples/companion_radio/MyMesh.cpp index bbd3fab02..fc2c33de5 100644 --- a/examples/companion_radio/MyMesh.cpp +++ b/examples/companion_radio/MyMesh.cpp @@ -1717,9 +1717,12 @@ bool MyMesh::queueMessage(const ContactInfo &from, uint8_t txt_type, mesh::Packe const bool notification_managed = false; #endif -#ifdef DISPLAY_CLASS +#if defined(DISPLAY_CLASS) || CARDPUTER_ADV_UI // we only want to show text messages on display, not cli data bool should_display = txt_type == TXT_TYPE_PLAIN || txt_type == TXT_TYPE_SIGNED_PLAIN; +#if CARDPUTER_ADV_UI + if (should_display) onUiContactMessage(from, text); +#endif if (should_display && _ui) { _ui->newMsg(path_len, from.name, text, offline_queue_len, -1, nullptr, queued ? offline_queue_len - 1 : -1); @@ -2340,6 +2343,9 @@ bool MyMesh::queueChannelText(uint8_t channel_idx, const uint8_t secret[32], flo const bool queued = addToOfflineQueue(out_frame, i); if (!queued) return false; +#if CARDPUTER_ADV_UI + onUiChannelMessage(secret, text); +#endif #if COMPANION_FEATURE_NOTIFICATIONS const bool notification_managed = notify && _notifications.message(nullptr, secret, _serial && _serial->isConnected(), millis()); if (_ui) _ui->setMessageNotificationOverride(notification_managed, _notifications.overridesScreen()); @@ -2354,7 +2360,7 @@ bool MyMesh::queueChannelText(uint8_t channel_idx, const uint8_t secret[32], flo frame[0] = PUSH_CODE_MSG_WAITING; _serial->writeFrame(frame, 1); } else { -#ifdef DISPLAY_CLASS +#if defined(DISPLAY_CLASS) || CARDPUTER_ADV_UI if (notify && _ui && !notification_managed) _ui->notify(UIEventType::channelMessage); #endif } @@ -2369,7 +2375,7 @@ bool MyMesh::queueChannelText(uint8_t channel_idx, const uint8_t secret[32], flo path_len == 0xFF ? "DIRECT" : "FLOOD", text); } #endif -#ifdef DISPLAY_CLASS +#if defined(DISPLAY_CLASS) || CARDPUTER_ADV_UI // Get the channel name from the channel index const char *channel_name = "Unknown"; ChannelDetails channel_details; @@ -9544,6 +9550,23 @@ void MyMesh::appendRxPowerSavingAdjustmentNote(char* reply, size_t reply_size, (unsigned)_prefs.rx_ps_level); } +#if CARDPUTER_ADV_UI +bool MyMesh::sendUiChannelMessage(int channel_idx, uint32_t timestamp, + const char* text, size_t length) { + ChannelDetails channel; + if (!text || !length || length != strlen(text) + || !getChannel(channel_idx, channel) || !channel.name[0]) return false; +#if MESH_ENABLE_MESHTEXT_CHAT + if (isMeshtextChannel(channel)) return sendMeshtextText(channel, text, length); +#endif + if (isAliasRadio2Defined() + && parseCompanionChannelAlias(channel.name).kind == CompanionChannelKind::Meshtext) return false; + const size_t prefix_length = strlen(_prefs.node_name) + 2; + if (prefix_length >= MAX_TEXT_LEN || length > MAX_TEXT_LEN - prefix_length) return false; + return sendGroupMessage(timestamp, channel.channel, _prefs.node_name, text, length); +} +#endif + #if COMPANION_FEATURE_TEXT_TERMINAL Stream& MyMesh::terminalOutput() { if (isTerminalWaitingForInput()) return mesh::usbTerminalPort(false); diff --git a/examples/companion_radio/MyMesh.h b/examples/companion_radio/MyMesh.h index 8b315eac8..04f8ef6a9 100644 --- a/examples/companion_radio/MyMesh.h +++ b/examples/companion_radio/MyMesh.h @@ -389,6 +389,12 @@ public: #endif protected: +#if CARDPUTER_ADV_UI + // Run after the shared duplicate/retention checks, including room posts and + // Meshtext. Variant history must not bypass the Companion delivery policy. + virtual void onUiContactMessage(const ContactInfo&, const char*) {} + virtual void onUiChannelMessage(const uint8_t[32], const char*) {} +#endif #if COMPANION_FEATURE_TEMP_RADIO bool isTempRadioActive() const override { return _temp_radio_applied && _temp_radio_revert_at != 0 @@ -609,6 +615,12 @@ public: // Runtime copies use the wire PSK; storage/app keep the original alias PSK. bool getChannel(int channel_idx, ChannelDetails& channel); bool setChannel(int channel_idx, const ChannelDetails& channel); +#if CARDPUTER_ADV_UI + uint16_t getPrimaryRadioPreamble() const { return _radio_profiles.primaryPreamble(); } + bool queueUiContactWrite(const ContactInfo& contact) { return scheduleContactWrite(contact); } + bool sendUiChannelMessage(int channel_idx, uint32_t timestamp, + const char* text, size_t length); +#endif bool savePrefs() { const uint8_t previous_tail_flags[] = { _prefs.usb_debug_enabled, _prefs.usb_logging_always}; diff --git a/examples/companion_radio/main.cpp b/examples/companion_radio/main.cpp index 28be7269c..94483e6a4 100644 --- a/examples/companion_radio/main.cpp +++ b/examples/companion_radio/main.cpp @@ -3991,6 +3991,8 @@ void setup() { the_mesh.begin( #ifdef DISPLAY_CLASS disp != NULL + #elif CARDPUTER_ADV_UI + cardputer_disp != NULL #else false #endif diff --git a/src/helpers/ui/DisplayBuildFlags.h b/src/helpers/ui/DisplayBuildFlags.h index da28e78a1..129786e54 100644 --- a/src/helpers/ui/DisplayBuildFlags.h +++ b/src/helpers/ui/DisplayBuildFlags.h @@ -24,8 +24,9 @@ #define MESHCORE_DISPLAY_TOKEN_JOIN(prefix, name) \ MESHCORE_DISPLAY_TOKEN_JOIN_INNER(prefix, name) -#if defined(DISPLAY_CLASS) \ - && !MESHCORE_DISPLAY_TOKEN_JOIN(MESHCORE_NULL_DISPLAY_CLASS_, DISPLAY_CLASS) +#if (defined(DISPLAY_CLASS) \ + && !MESHCORE_DISPLAY_TOKEN_JOIN(MESHCORE_NULL_DISPLAY_CLASS_, DISPLAY_CLASS)) \ + || CARDPUTER_ADV_UI #define MESHCORE_HAS_REAL_DISPLAY 1 #endif @@ -35,8 +36,9 @@ #define MESHCORE_HAS_EINK_DISPLAY 1 #endif -#if defined(DISPLAY_CLASS) \ - && MESHCORE_DISPLAY_TOKEN_JOIN(MESHCORE_COLOR_DISPLAY_CLASS_, DISPLAY_CLASS) +#if (defined(DISPLAY_CLASS) \ + && MESHCORE_DISPLAY_TOKEN_JOIN(MESHCORE_COLOR_DISPLAY_CLASS_, DISPLAY_CLASS)) \ + || CARDPUTER_ADV_UI #define MESHCORE_HAS_COLOR_DISPLAY 1 #endif diff --git a/src/helpers/ui/DisplayPowerSettings.cpp b/src/helpers/ui/DisplayPowerSettings.cpp index e3efcbfbf..bbdac4a4f 100644 --- a/src/helpers/ui/DisplayPowerSettings.cpp +++ b/src/helpers/ui/DisplayPowerSettings.cpp @@ -106,6 +106,11 @@ bool loadDisplayPowerSettings(FILESYSTEM* fs, bool pairing_supported, bool wake_ DisplayPowerPrefs prefs; prefs.wake_on_boot = wake_on_boot; if (pairing_supported) prefs.battery.mode = prefs.usb.mode = DisplayMode::ButtonPairing; +#if CARDPUTER_ADV_UI + // Preserve the keyboard UI's timed message wake on a fresh installation. + // A saved profile below remains authoritative, including explicit off. + prefs.battery.mode = prefs.usb.mode = DisplayMode::Automatic; +#endif displayPowerPrefs() = prefs; #ifdef MESHCORE_HAS_REAL_DISPLAY if (!fs) return false; diff --git a/test/test_cardputer_delivery.py b/test/test_cardputer_delivery.py new file mode 100644 index 000000000..3e572d95d --- /dev/null +++ b/test/test_cardputer_delivery.py @@ -0,0 +1,293 @@ +"""Execute shared Companion delivery and keyboard transport routing.""" + +import os +from pathlib import Path +import re +import subprocess +import tempfile +import unittest + +from cpp_source import body + +ROOT = Path(__file__).resolve().parents[1] +SOURCE = ROOT / "examples/companion_radio/MyMesh.cpp" + +HARNESS = r''' +#include +#include +#include +#include +#include +#include +#include +#include +#include +using std::isfinite; +#define MESH_ENABLE_ONE_KEY_DM 1 +#define ONE_KEY_DM_SHARED_OFFLINE_QUEUE 0 +#define COMPANION_FEATURE_NOTIFICATIONS 0 +#define COMPANION_FEATURE_TEXT_TERMINAL 0 +#define MAX_FRAME_SIZE 176 +#define MAX_GROUP_CHANNELS 40 +#define MAX_TEXT_LEN 160 +#define PRV_KEY_SIZE 64 +#define PUB_KEY_SIZE 32 +#define ONE_KEY_DM_ID_SIZE 8 +#define TXT_TYPE_PLAIN 0 +#define TXT_TYPE_CLI_DATA 1 +#define TXT_TYPE_SIGNED_PLAIN 2 +#define RESP_CODE_CONTACT_MSG_RECV_V3 0x93 +#define RESP_CODE_CONTACT_MSG_RECV 0x82 +#define RESP_CODE_CHANNEL_MSG_RECV_V3 0x90 +#define RESP_CODE_CHANNEL_MSG_RECV 0x83 +#define PUSH_CODE_MSG_WAITING 0x80 +static unsigned long millis() { return 1234; } +enum class UIEventType { contactMessage, channelMessage }; +namespace mesh { +struct Packet { + uint8_t path_len = 0; + float getSNR() const { return 2.0f; } + bool isRouteFlood() const { return true; } +}; +struct GroupChannel { uint8_t secret[32]{}; uint8_t tx_radio = 0; }; +} +struct ContactInfo { struct { uint8_t pub_key[32]{}; } id; char name[32] = "Alice"; }; +struct ChannelDetails { char name[32]{}; mesh::GroupChannel channel; }; +struct Serial { + bool connected = false; + bool isConnected() const { return connected; } + bool isReplyRouteAvailable(Serial*) { return true; } + void writeFrame(const uint8_t*, int) {} + void writeFrameToRoute(Serial*, const uint8_t*, int) {} +}; +struct UI { + int received = 0, sounds = 0; + void setMessageNotificationOverride(bool, bool = false) {} + void newMsg(uint8_t, const char*, const char*, int, int, const char*, int) { ++received; } + void notify(UIEventType) { ++sounds; } +}; +struct MyMesh { + Serial serial; + UI ui; + Serial* _serial = &serial; + UI* _ui = &ui; + int app_target_ver = 3, offline_queue_len = 0; + uint8_t out_frame[MAX_FRAME_SIZE]{}; + bool accept = true, delivered = false; + int contact_history = 0, channel_history = 0; + Serial* private_key_backup_route = nullptr; + unsigned long private_key_backup_deadline = 0; + uint8_t private_key_backup_sender[6]{}; + char private_key_backup_nonce[17]{}; + struct Held { uint32_t timestamp; char text[160]; uint8_t id[8], sender_key[32]; } held_dms[1]{}; + uint8_t held_dm_count = 0; + struct { char node_name[32] = "Cardputer"; } _prefs; + ChannelDetails configured; + bool radio2 = true, mt_available = true, transport_accept = true; + int meshtext_sends = 0, meshcore_sends = 0; + uint8_t sent_key = 0, sent_radio = 0; + bool getChannel(int index, ChannelDetails& out) { + if (index != 9 || !configured.name[0]) return false; + out = configured; return true; + } + bool isAliasRadio2Defined() const { return radio2; } + bool isMeshtextChannel(const ChannelDetails& c) const { + return mt_available && parseCompanionChannelAlias(c.name).kind == CompanionChannelKind::Meshtext; + } + bool sendMeshtextText(const ChannelDetails& c, const char*, size_t) { + ++meshtext_sends; sent_key = c.channel.secret[0]; return transport_accept; + } + bool sendGroupMessage(uint32_t, mesh::GroupChannel& c, const char*, const char*, size_t) { + ++meshcore_sends; sent_key = c.secret[0]; sent_radio = c.tx_radio; return transport_accept; + } + bool millisHasNowPassed(unsigned long) const { return false; } + bool addToOfflineQueue(const uint8_t*, int) { if (accept) ++offline_queue_len; return accept; } + void onUiContactMessage(const ContactInfo&, const char*) { ++contact_history; } + void onUiChannelMessage(const uint8_t[32], const char*) { ++channel_history; } + void makeOneKeyDMId(uint8_t* out, uint32_t, const char*) { memset(out, 7, 8); } + bool wasDeliveredOneKeyDM(const uint8_t*, const uint8_t*) const { return delivered; } + void rememberDeliveredOneKeyDM(const uint8_t*, const uint8_t*) { delivered = true; } + int getOfflineQueueCapacity() const { return 256; } + void markConnectionActive(const ContactInfo&) {} + void scheduleContactWrite(const ContactInfo&) {} + void onMessageRecv(const ContactInfo&, mesh::Packet*, uint32_t, const char*); + bool queueMessage(const ContactInfo&, uint8_t, mesh::Packet*, uint32_t, + const uint8_t*, int, const char*, bool = false, uint32_t = 0); + bool queueChannelText(uint8_t, const uint8_t[32], float, uint8_t, uint32_t, + const char*, bool, bool = true); + bool sendUiChannelMessage(int, uint32_t, const char*, size_t); +}; +@METHODS@ +int main() { + MyMesh m; + ContactInfo from; + mesh::Packet packet; + m.onMessageRecv(from, &packet, 100, "hello"); + assert(m.offline_queue_len == 1); +#if CARDPUTER_ADV_UI + assert(m.contact_history == 1 && m.ui.received == 1 && m.ui.sounds == 1); +#else + assert(m.contact_history == 0 && m.ui.received == 0 && m.ui.sounds == 0); +#endif + // Intro/held-message duplicate suppression must happen before local history. + const int history_before_duplicate = m.contact_history; + const int ui_before_duplicate = m.ui.received; + m.delivered = true; + m.onMessageRecv(from, &packet, 100, "hello"); + assert(m.offline_queue_len == 1); + assert(m.contact_history == history_before_duplicate && m.ui.received == ui_before_duplicate); + m.delivered = false; + m.accept = false; + int prior = m.contact_history; + assert(!m.queueMessage(from, TXT_TYPE_SIGNED_PLAIN, &packet, 101, nullptr, 0, "room")); + assert(m.contact_history == prior); // Server must retry an unretained room post. + m.accept = true; + assert(m.queueMessage(from, TXT_TYPE_SIGNED_PLAIN, &packet, 101, nullptr, 0, "room")); +#if CARDPUTER_ADV_UI + assert(m.contact_history == prior + 1); +#endif + prior = m.contact_history; + assert(!m.queueMessage(from, TXT_TYPE_CLI_DATA, &packet, 101, nullptr, 0, "KEYBACKUP secret")); + assert(m.contact_history == prior); // No private export retained locally. + assert(m.queueMessage(from, TXT_TYPE_CLI_DATA, &packet, 101, nullptr, 0, "normal admin reply")); + assert(m.contact_history == prior); // Admin replies are not chat history. + uint8_t key[32] = {0x42}; + m.accept = false; + assert(!m.queueChannelText(9, key, 1, 0, 101, "channel", false)); + assert(m.channel_history == 0); + m.accept = true; + assert(m.queueChannelText(9, key, 1, 0, 101, "channel", false)); +#if CARDPUTER_ADV_UI + assert(m.channel_history == 1); + // Meshtext enters this same queue once after its transport-level dedup. + assert(m.queueChannelText(9, key, 1, 0, 101, "Meshtext", false)); + assert(m.channel_history == 2); + strcpy(m.configured.name, "#mt-longfast"); + m.configured.channel.secret[0] = 71; + m.configured.channel.tx_radio = 3; // Native MeshCore TX disabled for this alias. +#if MESH_ENABLE_MESHTEXT_CHAT + assert(m.sendUiChannelMessage(9, 101, "text", 4)); + assert(m.meshtext_sends == 1 && m.meshcore_sends == 0 && m.sent_key == 71); + m.transport_accept = false; + assert(!m.sendUiChannelMessage(9, 101, "text", 4)); + assert(m.meshcore_sends == 0); // Rejection never leaks onto the other protocol. + m.transport_accept = true; +#else + assert(!m.sendUiChannelMessage(9, 101, "text", 4)); + assert(m.meshcore_sends == 0); +#endif + m.mt_available = false; + assert(!m.sendUiChannelMessage(9, 101, "text", 4)); + strcpy(m.configured.name, "#r2-testing"); + m.configured.channel.secret[0] = 72; + m.configured.channel.tx_radio = 1; + assert(m.sendUiChannelMessage(9, 101, "text", 4)); + assert(m.sent_key == 72 && m.sent_radio == 1); + // Resolve the current slot every time; never transmit using a stale snapshot. + m.configured.channel.secret[0] = 73; + assert(m.sendUiChannelMessage(9, 101, "text", 4)); + assert(m.sent_key == 73); + int sends = m.meshcore_sends; + assert(!m.sendUiChannelMessage(1, 101, "text", 4)); + assert(!m.sendUiChannelMessage(9, 101, nullptr, 4)); + assert(!m.sendUiChannelMessage(9, 101, "", 0)); + assert(!m.sendUiChannelMessage(9, 101, "text", 3)); + std::string oversized(160, 'x'); + assert(!m.sendUiChannelMessage(9, 101, oversized.c_str(), oversized.size())); + assert(m.meshcore_sends == sends); +#else + assert(m.channel_history == 0 && m.ui.received == 0); +#endif +} +''' + + +class CardputerDeliveryTests(unittest.TestCase): + def run_cpp(self, cardputer=True, meshtext=True): + source = SOURCE.read_text() + signatures = [ + "void MyMesh::onMessageRecv(", + "bool MyMesh::queueMessage(", + "bool MyMesh::queueChannelText(", + ] + if cardputer: + signatures.append("bool MyMesh::sendUiChannelMessage(") + code = HARNESS.replace("@METHODS@", "\n".join(body(source, sig) for sig in signatures)) + with tempfile.TemporaryDirectory(prefix="meshcore-cardputer-delivery-") as temp: + binary = Path(temp) / "delivery" + built = subprocess.run([ + os.environ.get("CXX", "c++"), "-std=c++17", "-Wall", "-Wextra", "-Werror", + "-Wno-unused-parameter", "-Wno-unused-variable", "-Wno-unused-function", + "-fsanitize=address,undefined", "-fno-omit-frame-pointer", "-fno-pie", "-no-pie", + f"-DCARDPUTER_ADV_UI={int(cardputer)}", f"-DMESH_ENABLE_MESHTEXT_CHAT={int(meshtext)}", + "-I", str(ROOT / "src"), "-x", "c++", "-", "-o", str(binary), + ], input=code, text=True, capture_output=True) + self.assertEqual(built.returncode, 0, built.stderr) + ran = subprocess.run([str(binary)], text=True, capture_output=True) + self.assertEqual(ran.returncode, 0, ran.stdout + ran.stderr) + + def test_cardputer_shared_delivery_and_meshtext(self): + self.run_cpp() + + def test_missing_meshtext_transport_cannot_leak_native_packets(self): + self.run_cpp(meshtext=False) + + def test_headless_delivery_keeps_its_existing_behavior(self): + self.run_cpp(cardputer=False) + + def test_keyboard_transport_does_not_depend_on_text_terminal(self): + source = re.sub(r"^\s*#\s*include[^\n]*", "", SOURCE.read_text(), flags=re.MULTILINE) + result = subprocess.run([ + os.environ.get("CXX", "c++"), "-E", "-P", "-x", "c++", "-", + "-DCARDPUTER_ADV_UI=1", "-DCOMPANION_FEATURE_TEXT_TERMINAL=0", + ], input=source, text=True, capture_output=True) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertIn("bool MyMesh::sendUiChannelMessage(", result.stdout) + + def test_cardputer_color_traits_without_standard_ui(self): + for enabled in (0, 1): + code = '#include \n' + code += '#ifdef MESHCORE_HAS_COLOR_DISPLAY\nconstexpr bool color=true;\n#else\nconstexpr bool color=false;\n#endif\n' + code += '#ifdef MESHCORE_HAS_REAL_DISPLAY\nconstexpr bool real=true;\n#else\nconstexpr bool real=false;\n#endif\n' + code += f'static_assert(color == {enabled}); static_assert(real == {enabled});\nint main() {{}}\n' + with tempfile.TemporaryDirectory(prefix="meshcore-cardputer-traits-") as temp: + result = subprocess.run([ + os.environ.get("CXX", "c++"), "-std=c++17", f"-DCARDPUTER_ADV_UI={enabled}", + "-I", str(ROOT / "src"), "-x", "c++", "-", "-o", str(Path(temp) / "traits"), + ], input=code, text=True, capture_output=True) + self.assertEqual(result.returncode, 0, result.stderr) + + def test_fresh_message_wake_and_saved_display_choices(self): + from test_display_night_mode import compile_run + compile_run(r''' +#include +#include +#include +using namespace mesh::ui; +int main() { + FakeFS fs; + char reply[160]; + assert(!loadDisplayPowerSettings(&fs, true, true)); + assert(displayPowerPrefs().battery.mode == DisplayMode::Automatic); + assert(displayPowerPrefs().usb.mode == DisplayMode::Automatic); + assert(displayNightMode() == NightAuto && displayNightDelayMinutes() == 60); + assert(handleDisplayPowerCommand("set display.mode off", reply, sizeof(reply))); + assert(!strcmp(reply, "OK")); + assert(handleDisplayPowerCommand("set display.usb.mode button", reply, sizeof(reply))); + assert(!strcmp(reply, "OK")); + assert(handleDisplayPowerCommand("set display.timeout 47", reply, sizeof(reply))); + assert(!strcmp(reply, "OK")); + assert(handleDisplayPowerCommand("set display.night on", reply, sizeof(reply))); + assert(!strcmp(reply, "OK")); + assert(loadDisplayPowerSettings(&fs, true, true)); + assert(displayPowerPrefs().battery.mode == DisplayMode::Off); + assert(displayPowerPrefs().usb.mode == DisplayMode::Button); + assert(displayPowerPrefs().battery.seconds == 47); + assert(displayNightMode() == NightOn && displayNightActive()); +} +''', files=("src/helpers/ui/DisplayPowerSettings.cpp", "src/helpers/ui/DisplayNightMode.cpp"), + defines=("-DCARDPUTER_ADV_UI=1",)) + + +if __name__ == "__main__": + unittest.main() diff --git a/test/test_cardputer_gps.py b/test/test_cardputer_gps.py new file mode 100644 index 000000000..1eff7b9ef --- /dev/null +++ b/test/test_cardputer_gps.py @@ -0,0 +1,218 @@ +"""Execute Cardputer GPS policy with real SensorManager and NMEA provider code.""" +from pathlib import Path +import os +import shutil +import subprocess +import tempfile +import unittest + +from test_gps_upstream_adaptations import ARDUINO, MESH, RTC, CAYENNE + +ROOT = Path(__file__).resolve().parents[1] +VARIANT = ROOT / "variants/m5stack_cardputer_adv" +ARDUINO = ARDUINO.replace("#include ", "#include \n#include \n#include \n#include ") +ARDUINO = ARDUINO.replace(" virtual size_t write(uint8_t){return 1;}", r''' + virtual size_t write(uint8_t){return 1;} + size_t print(const char* text){size_t n=0;while(*text)n+=write(uint8_t(*text++));return n;} + size_t print(char c){return write(uint8_t(c));} +''') + r''' +class HardwareSerial:public Stream { +public: + std::deque incoming; + std::string output; + int rx=-1,tx=-1;unsigned baud=0,starts=0,ends=0; + void setPins(int input,int out){rx=input;tx=out;} + void begin(unsigned rate){baud=rate;++starts;} + void end(){++ends;} + int available()override{return int(incoming.size());} + int read()override{if(incoming.empty())return -1;char c=incoming.front();incoming.pop_front();return uint8_t(c);} + size_t write(uint8_t c)override{output+=char(c);return 1;} + void feed(const std::string& data){for(char c:data)incoming.push_back(c);} +}; +inline HardwareSerial Serial1; +''' +HARNESS = r''' +#include +#include +#include +#include +#include +#include "CardputerSensorManager.h" +#include +void LocationProvider::sendSentence(const char*){} +struct Provider:LocationProvider { + bool fix=false;long lat=47610000,lon=-122330000,alt=125000; + unsigned polls=0,starts=0,stops=0,clears=0; + long getLatitude()override{return lat;} + long getLongitude()override{return lon;} + long getAltitude()override{return alt;} + long satellitesCount()override{return fix?8:0;} + bool isValid()override{return fix&&lat>=-90000000&&lat<=90000000&&lon>=-180000000&&lon<=180000000;} + long getTimestamp()override{return 0;} + void reset()override{} + void begin()override{++starts;} + void stop()override{++stops;} + void loop()override{++polls;} + // This is what the real pinless MicroNMEA provider reports even in standby. + bool isEnabled()override{return true;} + bool clearPositionFix()override{++clears;fix=false;return true;} + void sendSentence(const char* sentence)override{MicroNMEA::sendSentence(Serial1,sentence);} + void synced(){markTimeSyncApplied();stopTimeSync();} +}; +static unsigned occurrences(const std::string& haystack,const char* needle) { + unsigned count=0;size_t at=0; + while((at=haystack.find(needle,at))!=std::string::npos){++count;at+=std::strlen(needle);} + return count; +} +static void reset(uint32_t time=100) {now_ms=time;delay_count=0;Serial1=HardwareSerial();} +static void setup(CardputerSensorManager& manager,Provider& provider) { + provider.setPowerSavingProfile(600,1800);assert(manager.begin()); + assert(Serial1.rx==15&&Serial1.tx==13&&Serial1.baud==115200); + assert(manager.isGPSDetected()&&manager.gpsUsesSerialUart(1)&&!manager.gpsUsesSerialUart(2)); + assert(!manager.isGpsPowered()&&provider.isEnabled()); + assert(Serial1.output=="$PCAS12,65535*1E\r\n"); + assert(!std::strcmp(manager.getSettingValue(0),"0")); + assert(manager.begin()&&Serial1.starts==1&&delay_count==0); +} +static void bootAndManualOff() { + reset();Serial1.feed("startup bytes");Provider provider;CardputerSensorManager manager(provider); + setup(manager,provider);manager.setPowerSavingEnabled(true); + for(unsigned tick=1;tick<=20;++tick) { + now_ms=100+tick*3600000U;provider.syncTime();manager.loop(); + assert(!manager.isGpsPowered()&&provider.polls==0); + } + assert(occurrences(Serial1.output,"$PCAS10")==0); + assert(occurrences(Serial1.output,"$PCAS12")>=20&&delay_count==0); +} +static void cycleAndWrap() { + for(uint32_t start:{100U,UINT32_MAX-599000U,UINT32_MAX-599999U}) { + reset(start);Provider p;CardputerSensorManager m(p);setup(m,p); + m.setPowerSavingEnabled(true);assert(m.setSettingValue("gps","1")); + uint32_t off=p.getNextGPSOff();assert(off==(uint32_t(start+600000U)?uint32_t(start+600000U):1U)); + assert(m.isGpsPowered()&&p.waitingTimeSync()&&!std::strcmp(m.getSettingValue(0),"1")); + now_ms=off-1;m.loop();assert(m.isGpsPowered()); + now_ms=off;m.loop();assert(!m.isGpsPowered()); + assert(!std::strcmp(m.getSettingValue(0),"1")); + uint32_t on=p.getNextGPSOn();assert(on==uint32_t(off+1800000U)); + now_ms=on-1;m.loop();assert(!m.isGpsPowered()); + now_ms=on;m.loop();assert(m.isGpsPowered()&&p.clears==2); + assert(uint32_t(p.getNextGPSOff())==uint32_t(now_ms+600000U)); + assert(delay_count==0&&occurrences(Serial1.output,"$PCAS10,0*1C\r\n")==2); + p.synced();m.loop();assert(!m.isGpsPowered()); // common early sleep after clock acquisition + } +} +static void toggles() { + reset();Provider p;CardputerSensorManager m(p);setup(m,p); + assert(m.setSettingValue("gps","1")&&m.isGpsPowered()); + m.setPowerSavingEnabled(true);const auto off=p.getNextGPSOff(); + ++now_ms;m.setPowerSavingEnabled(true);assert(p.getNextGPSOff()==off); + now_ms=uint32_t(off);m.loop();assert(!m.isGpsPowered()); + m.setPowerSavingEnabled(false);assert(m.isGpsPowered()&&!p.getGPSPowerSaving()); + assert(p.getNextGPSOn()==0&&p.getNextGPSOff()==0); + m.setPowerSavingEnabled(true);assert(p.getNextGPSOff()!=0); + assert(m.setSettingValue("gps","0")&&!m.isGpsPowered()); + assert(!p.getGPSPowerSaving()&&!p.waitingTimeSync()&&p.getNextGPSOn()==0&&p.getNextGPSOff()==0); + for(bool save:{false,true,false,true}) {m.setPowerSavingEnabled(save);m.loop();assert(!m.isGpsPowered());} + assert(m.setSettingValue("gps","1")&&m.isGpsPowered()); + m.shutdownGpsTransportForDeepSleep();assert(!m.isGpsPowered()&&!m.isGPSDetected()&&Serial1.ends==1); + m.loop();assert(!m.isGpsPowered()); +} +static void intervalAndValidity() { + reset();Provider p;CardputerSensorManager m(p);setup(m,p); + assert(m.setSettingValue("gps","1"));assert(m.setSettingValue("gps_interval","2")); + p.fix=true;m.loop();assert(m.node_lat==47.61&&m.node_lon==-122.33&&m.node_altitude==125); + p.lat=48000000;now_ms+=1999;m.loop();assert(m.node_lat==47.61); + ++now_ms;m.loop();assert(m.node_lat==48); + p.lat=91000000;now_ms+=2000;m.loop();assert(m.node_lat==48); + assert(m.setSettingValue("gps_interval","86400"));p.lat=49000000;m.loop();assert(m.node_lat==49); + assert(m.setSettingValue("gps_interval","1"));p.lat=50000000;m.loop();assert(m.node_lat==50); + for(const char* bad:{"","-1","86401","1x"})assert(!m.setSettingValue("gps_interval",bad)); + assert(!m.setSettingValue(nullptr,"1")&&!m.setSettingValue("gps",nullptr)); + assert(!m.setSettingValue("unknown","1")); +} +static void telemetry() { + reset();Provider p;CardputerSensorManager m(p);setup(m,p);CayenneLPP data(64); + assert(m.querySensors(TELEM_PERM_BASE,data)&&data.gps_count==0&&!m.isGpsPowered()); + const auto commands=Serial1.output;double lat=0,lon=0; + m.setTelemetryLocationAccessAvailable(false);m.loop();assert(Serial1.output==commands); + assert(!m.getCachedGpsPosition(lat,lon)); + assert(m.querySensors(TELEM_PERM_LOCATION,data)&&data.gps_count==0&&m.isGpsPowered()); + assert(!std::strcmp(m.getSettingValue(0),"0")); // telemetry did not change saved intent + p.fix=true;m.loop();now_ms+=31000;m.loop(); + assert(m.getCachedGpsPosition(lat,lon)); + assert(m.querySensors(TELEM_PERM_LOCATION,data)&&data.gps_count==1); + assert(m.setSettingValue("gps","0")&&!m.isGpsPowered()); + const auto polls=p.polls;now_ms+=1800000;m.loop();assert(!m.isGpsPowered()&&p.polls==polls); + data.reset();const auto before=Serial1.output; + assert(m.querySensors(TELEM_PERM_BASE,data)&&data.gps_count==1&&Serial1.output==before); + assert(m.querySensors(TELEM_PERM_LOCATION,data)&&data.gps_count==2); // cached, authorized, explicit hold + assert(m.isGpsPowered());m.setSettingValue("gps","0"); +} +static std::string sentence(const char* text) { + HardwareSerial output;MicroNMEA::sendSentence(output,text);return output.output; +} +static void actualNmeaAndClock() { + reset(10000);mesh::RTCClock clock;MicroNMEALocationProvider p(Serial1,&clock); + CardputerSensorManager m(p);p.setPowerSavingProfile(600,1800);assert(m.begin()); + assert(m.setSettingValue("gps","1")&&p.waitingTimeSync()); + for(unsigned second=0;second<4;++second) { + char rmc[160];std::snprintf(rmc,sizeof(rmc), + "$GPRMC,1200%02u.00,A,4736.600,N,12219.800,W,0.0,0.0,281026,,,A",second); + Serial1.feed(sentence("$GPGGA,120000.00,4736.600,N,12219.800,W,1,08,0.9,125.0,M,0.0,M,,")); + Serial1.feed(sentence(rmc));now_ms+=1100; + // A real foreground loop drains its bounded 96-byte NMEA batches often. + // Keep one timestamp per emitted second and never bypass that byte limit. + for(unsigned poll=0;poll<4&&Serial1.available();++poll)m.loop(); + assert(Serial1.available()==0); + } + assert(p.satellitesCount()==8&&p.isValid()); + assert(m.node_lat>47.60&&m.node_lat<47.62&&m.node_lon< -122.32&&m.node_lon> -122.34); + assert(p.getTimeSyncGeneration()>0&&p.getLastValidTimeSync()==clock.getCurrentTime()); + const double retained=m.node_lat; + Serial1.feed(sentence("$GPGGA,120005.00,,,,,0,00,99.9,,,,,,"));now_ms+=1100;m.loop(); + assert(!p.isValid()&&p.satellitesCount()==0&&m.node_lat==retained); + m.setSettingValue("gps","0");const auto generation=p.getTimeSyncGeneration(); + Serial1.feed(sentence("$GPRMC,120010.00,A,4736.600,N,12219.800,W,0.0,0.0,281026,,,A")); + now_ms+=1100;m.loop();assert(p.getTimeSyncGeneration()==generation&&!m.isGpsPowered()); +} +int main(int argc,char** argv) { + assert(argc==2);const std::string scenario=argv[1]; + if(scenario=="boot")bootAndManualOff();else if(scenario=="cycle")cycleAndWrap(); + else if(scenario=="toggles")toggles();else if(scenario=="interval")intervalAndValidity(); + else if(scenario=="telemetry")telemetry();else if(scenario=="nmea")actualNmeaAndClock();else assert(false); +} +''' + + +class CardputerGpsTests(unittest.TestCase): + def test_actual_manager_policy_cache_nmea_and_clock(self): + compiler = os.environ.get("CXX") or shutil.which("g++") or shutil.which("clang++") + self.assertIsNotNone(compiler, "a native C++ compiler is required") + nmea = next((ROOT / ".pio/libdeps").glob("*/MicroNMEA/src/MicroNMEA.cpp"), None) + self.assertIsNotNone(nmea, "pinned MicroNMEA installed by native tests is required") + with tempfile.TemporaryDirectory(prefix="cardputer-gps-") as directory: + work = Path(directory) + for name, text in {"Arduino.h": ARDUINO, "Mesh.h": MESH, "RTClib.h": RTC, + "CayenneLPP.h": CAYENNE, "Wire.h": "#pragma once\nstruct TwoWire {};\n", + "main.cpp": HARNESS}.items(): + (work / name).write_text(text, encoding="ascii") + binary = work / ("gps.exe" if os.name == "nt" else "gps") + sanitizer = [] if os.name == "nt" else ["-fsanitize=address,undefined", + "-fno-sanitize-recover=all", "-fno-pie", "-no-pie"] + built = subprocess.run([compiler, "-std=c++17", "-O1", "-g", "-Wall", "-Wextra", "-Werror", + "-Wno-unused-parameter", *sanitizer, "-DENV_INCLUDE_GPS=1", + "-DPIN_GPS_TX=15", "-DPIN_GPS_RX=13", "-DGPS_BAUD_RATE=115200", + "-I", str(work), "-I", str(ROOT / "src"), "-I", str(VARIANT), + "-I", str(nmea.parent), str(work / "main.cpp"), str(nmea), + str(ROOT / "src/helpers/SensorManager.cpp"), + str(VARIANT / "CardputerSensorManager.cpp"), "-o", str(binary)], + capture_output=True, text=True, timeout=60) + self.assertEqual(built.returncode, 0, built.stdout + built.stderr) + for scenario in ("boot", "cycle", "toggles", "interval", "telemetry", "nmea"): + with self.subTest(scenario=scenario): + run = subprocess.run([str(binary), scenario], capture_output=True, text=True, timeout=10) + self.assertEqual(run.returncode, 0, run.stdout + run.stderr) + + +if __name__ == "__main__": + unittest.main() diff --git a/test/test_cardputer_mesh_storage.py b/test/test_cardputer_mesh_storage.py index 59b7a7f8c..5dbc6daae 100644 --- a/test/test_cardputer_mesh_storage.py +++ b/test/test_cardputer_mesh_storage.py @@ -23,6 +23,8 @@ FILESYSTEM_STUB = r''' #include #include #include +#include +#define ESP32_PLATFORM 1 #define PUB_KEY_SIZE 32 #define FILE_READ "r" #define FILE_WRITE "w" @@ -31,24 +33,31 @@ namespace mesh { struct RTCClock {}; } struct Entry { std::string bytes; bool no_progress = false; + bool corrupt_read = false; unsigned reads = 0; }; struct File { std::shared_ptr entry; size_t position = 0; + size_t max_write = std::numeric_limits::max(); explicit operator bool() const { return bool(entry); } size_t size() const { return entry ? entry->bytes.size() : 0; } size_t available() const { return size() - position; } + bool isDirectory() const { return false; } + bool setBufferSize(size_t) { return true; } + void flush() {} size_t read(uint8_t* dest, size_t count) { if (!entry) return 0; if (++entry->reads > 100) throw std::runtime_error("read made no progress"); if (entry->no_progress) return 0; count = std::min(count, available()); memcpy(dest, entry->bytes.data() + position, count); + if (entry->corrupt_read && count) dest[0] ^= 1; position += count; return count; } size_t write(const uint8_t* src, size_t count) { + count = std::min(count, max_write); entry->bytes.append(reinterpret_cast(src), count); return count; } @@ -60,17 +69,34 @@ struct FILESYSTEM { std::map> files; unsigned opens = 0; bool fail_open = false; + bool fail_stat = false; + size_t max_write = std::numeric_limits::max(); + unsigned rename_calls = 0, fail_rename = 0; + bool corrupt_public_read = false; bool exists(const char* path) const { return files.count(path) != 0; } bool mkdir(const char*) { return true; } File open(const char* path, const char* mode, bool = false) { ++opens; if (fail_open) return {}; + if (*mode == 'r' && !files.count(path)) return {}; auto& entry = files[path]; if (!entry) entry = std::make_shared(); if (*mode == 'w') entry->bytes.clear(); - return File{entry}; + return File{entry, 0, max_write}; + } + bool remove(const char* path) { return files.erase(path) != 0; } + bool rename(const char* from, const char* to) { + if (++rename_calls == fail_rename || !files.count(from) || files.count(to)) return false; + files[to] = files.at(from); + files.erase(from); + if (corrupt_public_read && std::string(to) == "/meshcore_custom/prefs") { + files[to]->corrupt_read = true; + corrupt_public_read = false; + } + return true; } }; +static FILESYSTEM* stat_fs = nullptr; struct DataStore { DataStore(FILESYSTEM&, mesh::RTCClock&) {} }; ''' @@ -80,6 +106,7 @@ STORAGE_CHECKS = r''' int main() { mesh::RTCClock clock; FILESYSTEM primary, sd; + stat_fs = &primary; CardputerDataStore store(primary, clock); uint8_t key[PUB_KEY_SIZE] = {}; ChatHistory history; @@ -92,7 +119,7 @@ int main() { CustomNodePrefs prefs{1.1f, 127, 2}; store.beginCustom(&sd); - store.saveCustomPrefs(prefs); + assert(store.saveCustomPrefs(prefs)); assert(primary.files.count(CUSTOM_PREFS_FILE) == 1 && sd.files.empty()); CustomNodePrefs restored{}; store.loadCustomPrefs(restored); @@ -145,6 +172,7 @@ MESH_HARNESS = r''' #include #include #define MAX_HASH_SIZE 8 +#define MAX_MESSAGE_LENGTH 150 #define PAYLOAD_TYPE_GRP_TXT 5 namespace mesh { struct GroupChannel { uint8_t secret[32] = {}; }; @@ -154,6 +182,7 @@ struct Packet { void calculatePacketHash(uint8_t* hash) const { memset(hash, value, MAX_HASH_SIZE); } }; } +struct ChannelDetails { mesh::GroupChannel channel; char name[32] = "channel"; }; struct Store { unsigned writes = 0; void storeMessage(const uint8_t*, const char*, bool, bool) { ++writes; } @@ -166,7 +195,12 @@ struct MyMesh { if (!admit || !pkt) { delete pkt; return false; } return true; } - bool sendGroupMessage(uint32_t, mesh::GroupChannel&, const char*, const char*, int) { return admit; } + bool getChannel(int index, ChannelDetails& channel) { + assert(index == 7); + channel.channel.secret[31] = 99; + return true; + } + bool sendUiChannelMessage(int index, uint32_t, const char*, size_t) { assert(index == 7); return admit; } void loop() { ++base_loops; } bool millisHasNowPassed(unsigned long at) const { return int32_t(now - at) > 0; } }; @@ -178,7 +212,7 @@ struct CardputerMesh : MyMesh { unsigned long resend_timeout = 0; unsigned resends = 0; bool sendFloodScoped(const mesh::GroupChannel&, mesh::Packet*, uint32_t); - bool sendGroupMessage(uint32_t, mesh::GroupChannel&, const char*, const char*, int); + bool sendChannelMessage(int, uint32_t, const char*, size_t); void checkResend() { ++resends; } void loop(); }; @@ -189,7 +223,7 @@ int main() { node.admit = false; assert(!node.sendFloodScoped(channel, new mesh::Packet{PAYLOAD_TYPE_GRP_TXT, 42}, 0)); assert(node.last_message_hash[0] == 0 && node.last_message_heard_repeats == 9); - assert(!node.sendGroupMessage(0, channel, "name", "text", 4) && node.store.writes == 0); + assert(!node.sendChannelMessage(7, 0, "text", 4) && node.store.writes == 0); node.admit = true; auto* group = new mesh::Packet{PAYLOAD_TYPE_GRP_TXT, 77}; assert(node.sendFloodScoped(channel, group, 0)); @@ -201,7 +235,7 @@ int main() { assert(node.sendFloodScoped(channel, other, 0)); assert(node.last_message_hash[0] == 77 && node.last_message_heard_repeats == 3); delete other; - assert(node.sendGroupMessage(0, channel, "name", "text", 4) && node.store.writes == 1); + assert(node.sendChannelMessage(7, 0, "text", 4) && node.store.writes == 1); node.resend_timeout = 10; node.now = 9; node.loop(); @@ -309,6 +343,7 @@ class CardputerMeshStorageTests(unittest.TestCase): with tempfile.TemporaryDirectory() as temporary: work = Path(temporary) for name, content in (headers or {}).items(): + (work / name).parent.mkdir(parents=True, exist_ok=True) (work / name).write_text(content, encoding="ascii") program = work / "test.cpp" program.write_text(source, encoding="ascii") @@ -334,18 +369,82 @@ class CardputerMeshStorageTests(unittest.TestCase): def mesh_source(self): source = (VARIANT / "companion_radio/CardputerMesh.cpp").read_text() methods = "\n".join(body(source, signature) for signature in ( - "bool CardputerMesh::sendFloodScoped(", "bool CardputerMesh::sendGroupMessage(", + "bool CardputerMesh::sendFloodScoped(", "bool CardputerMesh::sendChannelMessage(", "void CardputerMesh::loop()")) return MESH_HARNESS.replace("@METHODS@", methods) def test_history_storage_and_faults(self): self.run_cpp(self.storage_source(), headers=self.storage_headers()) + def test_custom_preferences_verified_rollback_and_recovery(self): + checks = r''' +#include +#include +int main() { + mesh::RTCClock clock; + const CustomNodePrefs previous{1.0f, 128, 1}, candidate{1.2f, 240, 2}; + for (unsigned fault = 0; fault < 6; ++fault) { + FILESYSTEM fs; stat_fs = &fs; + CardputerDataStore store(fs, clock); + assert(store.saveCustomPrefs(previous)); + fs.rename_calls = 0; + if (fault == 0) fs.max_write = 3; + if (fault == 1) fs.fail_open = true; + if (fault == 2) fs.fail_rename = 1; + if (fault == 3) fs.fail_rename = 2; + if (fault == 4) fs.corrupt_public_read = true; + if (fault == 5) fs.fail_stat = true; + assert(!store.saveCustomPrefs(candidate)); + fs.max_write = 100; fs.fail_open = false; fs.fail_rename = 0; fs.fail_stat = false; + CustomNodePrefs restored{}; + CardputerDataStore rebooted(fs, clock); + assert(rebooted.loadCustomPrefs(restored)); + assert(!memcmp(&restored, &previous, sizeof(previous))); + assert(!fs.exists(CUSTOM_PREFS_FILE ".tmp")); + } + FILESYSTEM fs; stat_fs = &fs; + CardputerDataStore store(fs, clock); + assert(store.saveCustomPrefs(previous)); + assert(fs.rename(CUSTOM_PREFS_FILE, CUSTOM_PREFS_FILE ".bak")); + auto temp = fs.open(CUSTOM_PREFS_FILE ".tmp", FILE_WRITE); + temp.write(reinterpret_cast(&candidate), sizeof(candidate)); temp.close(); + CustomNodePrefs restored{}; + assert(store.loadCustomPrefs(restored)); + assert(!memcmp(&restored, &previous, sizeof(previous))); + assert(fs.exists(CUSTOM_PREFS_FILE) && !fs.exists(CUSTOM_PREFS_FILE ".bak")); + for (CustomNodePrefs invalid : {CustomNodePrefs{0.49f, 1, 1}, CustomNodePrefs{2.01f, 1, 1}, + CustomNodePrefs{1.0f, 1, 3}, CustomNodePrefs{std::numeric_limits::quiet_NaN(), 1, 1}}) { + assert(!store.saveCustomPrefs(invalid)); + } + fs.files.at(CUSTOM_PREFS_FILE)->bytes.resize(3); + restored = candidate; + assert(!store.loadCustomPrefs(restored)); + assert(!memcmp(&restored, &candidate, sizeof(candidate))); + assert(!store.saveCustomPrefs(previous)); +} +''' + self.run_cpp((VARIANT / "CardputerDataStore.cpp").read_text() + checks, + headers=self.storage_headers()) + def storage_headers(self): # Production includes SD.h for PlatformIO's variant-local dependency # discovery. The filesystem stub supplies every API this source uses; # no concrete SD driver is needed by the native ownership/fault tests. - return {"DataStore.h": FILESYSTEM_STUB, "SD.h": "#pragma once\n"} + return {"DataStore.h": FILESYSTEM_STUB, "SD.h": "#pragma once\n", + "helpers/ContactFileTransaction.h": (ROOT / "src/helpers/ContactFileTransaction.h").read_text(), + "helpers/IdentityStore.h": "#pragma once\n", + "helpers/PersistentStoreFormat.h": (ROOT / "src/helpers/PersistentStoreFormat.h").read_text(), + "sys/stat.h": r''' +#pragma once +#include +struct stat {}; +inline int stat(const char* path, struct stat*) { + if (!stat_fs || stat_fs->fail_stat) { errno = EIO; return -1; } + if (stat_fs->exists(path + 7)) return 0; + errno = ENOENT; + return -1; +} +'''} def test_history_source_bound_negative_control(self): source = self.storage_source().replace( diff --git a/test/test_cardputer_messaging.py b/test/test_cardputer_messaging.py new file mode 100644 index 000000000..36dddc20b --- /dev/null +++ b/test/test_cardputer_messaging.py @@ -0,0 +1,241 @@ +#!/usr/bin/env python3 +"""Execute Cardputer admission, retry, accepted delivery and selection methods.""" +from pathlib import Path +import unittest + +from cpp_source import body +import test_cardputer_mesh_storage as storage_tests + +ROOT = Path(__file__).resolve().parents[1] +VARIANT = ROOT / "variants/m5stack_cardputer_adv" + +HARNESS = r''' +#include +#include +#include +#include +#include +#include "globals.h" +#define PUB_KEY_SIZE 32 +#define OUT_PATH_UNKNOWN 255 +#define MSG_SEND_FAILED 0 +#define MESH_DEBUG_PRINTLN(...) ((void)0) +enum MessageSendState { MESSAGE_SENDING, MESSAGE_DELIVERED, MESSAGE_FAILED }; +struct ContactInfo { + struct { uint8_t pub_key[32] = {}; } id; + uint8_t out_path_len = 0; +}; +namespace mesh { struct GroupChannel { uint8_t secret[32] = {}; }; } +struct ChannelDetails { mesh::GroupChannel channel; char name[32] = "channel"; }; +struct CustomNodePrefs { float battery_correction; uint8_t brightness, text_level; }; +@LAST_MESSAGE@ +struct Store { + bool save_ok = true; + unsigned writes = 0; + uint8_t key[32] = {}; + std::string text; + void storeMessage(const uint8_t* pubkey, const char* value, bool, bool) { + ++writes; memcpy(key, pubkey, 32); text = value; + } + bool saveCustomPrefs(const CustomNodePrefs&) { return save_ok; } + void loadCustomPrefs(CustomNodePrefs&) {} +}; +struct UI { + std::vector events; + unsigned contacts = 0, channels = 0; + uint8_t key[32] = {}; + void onMessageSendAttempt(uint8_t, uint8_t, MessageSendState state) { events.push_back(state); } + void onContactMessageRecv(const ContactInfo& contact, const char*) { + ++contacts; memcpy(key, contact.id.pub_key, 32); + } + void onChannelMessageRecv(const mesh::GroupChannel& channel, const char*) { + ++channels; memcpy(key, channel.secret, 32); + } +}; +struct MyMesh { + bool admit = true, present = true, persist = true, standard_save = true; + uint32_t now = 100, timeout = 25000; + unsigned sends = 0, standard_saves = 0, path_saves = 0; + ContactInfo current; + uint8_t sent_path = 0, sent_attempt = 0; + int sendMessage(const ContactInfo& contact, uint32_t, uint8_t attempt, const char*, + uint32_t& ack, uint32_t& estimate) { + ++sends; + if (!admit) { ack = 0; estimate = 0; return MSG_SEND_FAILED; } + sent_path = contact.out_path_len; sent_attempt = attempt; + ack = 1000 + attempt; estimate = timeout; return 1; + } + ContactInfo* lookupContactByPubKey(const uint8_t* key, size_t count) { + assert(count == 32); + return present && !memcmp(key, current.id.pub_key, 32) ? ¤t : nullptr; + } + bool queueUiContactWrite(const ContactInfo&) { ++path_saves; return persist; } + unsigned long futureMillis(uint32_t delay) const { return now + delay; } + bool savePrefs() { ++standard_saves; return standard_save; } +}; +struct CardputerMesh : MyMesh { + Store store; + Store* _store = &store; + UI ui; + UI* _ui = &ui; + LastSentMessage last_msg; + unsigned long resend_timeout = 0; + CustomNodePrefs _custom_prefs{1.0f, 128, 1}, _saved_custom_prefs = _custom_prefs; + bool isMessageSending() const { return last_msg.state == MESSAGE_SENDING; } + int sendDirectMessage(const ContactInfo&, uint32_t, const char*); + void checkResend(); + void cancelResending(); + void onUiContactMessage(const ContactInfo&, const char*); + void onUiChannelMessage(const uint8_t[32], const char*); + bool savePrefs(); + bool saveCustomPrefs(); + void beginCustom(); +}; +@METHODS@ +int main() { + CardputerMesh rejected; + rejected.admit = false; + assert(rejected.sendDirectMessage(rejected.current, 4, "test") == MSG_SEND_FAILED); + assert(!rejected.isMessageSending() && rejected.resend_timeout == 0); + assert(rejected.store.writes == 0 && rejected.ui.events.empty()); + + CardputerMesh node; + node.current.id.pub_key[31] = 42; + node.current.out_path_len = 2; + assert(node.sendDirectMessage(node.current, 5, "message") != MSG_SEND_FAILED); + assert(node.resend_timeout == node.now + node.timeout && node.store.writes == 1); + assert(node.last_msg.expected_ack == 1000 && node.last_msg.attempt == 1); + assert(node.sendDirectMessage(node.current, 6, "other") == MSG_SEND_FAILED && node.sends == 1); + node.current.out_path_len = 3; + node.now = 30000; node.timeout = 32000; + node.checkResend(); + assert(node.sent_path == 3 && node.sent_attempt == 1 && node.last_msg.attempt == 2); + assert(node.resend_timeout == node.now + 32000 && node.store.writes == 1); + node.admit = false; + node.checkResend(); + assert(!node.isMessageSending() && node.resend_timeout == 0 && node.last_msg.expected_ack == 0); + assert(node.ui.events.back() == MESSAGE_FAILED && node.store.writes == 1); + + CardputerMesh removed; + assert(removed.sendDirectMessage(removed.current, 1, "test") != MSG_SEND_FAILED); + removed.present = false; removed.checkResend(); + assert(removed.sends == 1 && !removed.isMessageSending() && removed.resend_timeout == 0); + + CardputerMesh fallback; + fallback.current.out_path_len = 4; fallback.timeout = 10; + assert(fallback.sendDirectMessage(fallback.current, 1, "test") != MSG_SEND_FAILED); + assert(fallback.resend_timeout == fallback.now + DIRECT_SEND_ROUTE_RESEND_RELAY); + fallback.checkResend(); fallback.checkResend(); fallback.checkResend(); + assert(fallback.sent_path == OUT_PATH_UNKNOWN && fallback.path_saves == 1); + assert(fallback.resend_timeout == fallback.now + DIRECT_SEND_FLOOD_RESEND_RELAY); + fallback.checkResend(); fallback.checkResend(); fallback.checkResend(); + assert(!fallback.isMessageSending() && fallback.sends == 6); + + CardputerMesh blocked; + blocked.current.out_path_len = 4; + assert(blocked.sendDirectMessage(blocked.current, 1, "test") != MSG_SEND_FAILED); + blocked.last_msg.attempt = DIRECT_SEND_ROUTE_ATTEMPTS; + blocked.persist = false; blocked.checkResend(); + assert(blocked.current.out_path_len == 4 && !blocked.isMessageSending() && blocked.sends == 1); + + CardputerMesh custom; + custom._custom_prefs.brightness = 200; + assert(custom.saveCustomPrefs() && custom.standard_saves == 0); + custom._custom_prefs.brightness = 250; custom.store.save_ok = false; + assert(!custom.saveCustomPrefs() && custom._custom_prefs.brightness == 200); + assert(custom.savePrefs() && custom.standard_saves == 1 && custom._custom_prefs.brightness == 200); + + CardputerMesh accepted; + accepted.current.id.pub_key[31] = 77; + accepted.onUiContactMessage(accepted.current, "direct"); + assert(accepted.ui.contacts == 1 && accepted.store.writes == 1 && accepted.store.key[31] == 77); + uint8_t channel_key[32] = {}; channel_key[31] = 88; + accepted.onUiChannelMessage(channel_key, "group"); + assert(accepted.ui.channels == 1 && accepted.store.writes == 2 && accepted.store.key[31] == 88); + assert(accepted.ui.key[31] == 88); +} +''' + +SELECTION = r''' +#include +#include +#include +#include +#include +#define PUB_KEY_SIZE 32 +struct ContactInfo { struct { uint8_t pub_key[32] = {}; } id; int marker = 0; }; +struct ChannelDetails { struct { uint8_t secret[32] = {}; } channel; char name[32] = "channel"; }; +struct Mesh { + std::vector contacts; + ChannelDetails channel; + bool channel_present = true; + ContactInfo* lookupPersistentContactByPubKey(const uint8_t* key, size_t size) { + assert(size == 32); + for (auto& contact : contacts) if (!memcmp(key, contact.id.pub_key, 32)) return &contact; + return nullptr; + } + bool getChannel(int index, ChannelDetails& value) { + assert(index == 4); + if (!channel_present) return false; + value = channel; return true; + } +} the_mesh_cp; +struct History { unsigned count = 1; void clear() { count = 0; } }; +struct ChatPage { + int current_contact_idx = 2, current_channel_idx = -1; + ContactInfo current_contact; + ChannelDetails current_channel; + History chat_history; + int chat_history_offset = 2; + std::string last_sent_message = "previous"; + void refreshSelectedContact(); + bool refreshSelectedChannel(); +}; +@METHODS@ +int main() { + ContactInfo alice, bob, carol; + alice.id.pub_key[31] = 1; bob.id.pub_key[31] = 2; carol.id.pub_key[31] = 3; + bob.marker = 42; carol.marker = 99; + the_mesh_cp.contacts = {alice, bob, carol}; + ChatPage page; page.current_contact = bob; + the_mesh_cp.contacts.erase(the_mesh_cp.contacts.begin()); + page.refreshSelectedContact(); + assert(page.current_contact.id.pub_key[31] == 2 && page.current_contact.marker == 42); + the_mesh_cp.contacts.erase(the_mesh_cp.contacts.begin()); + page.refreshSelectedContact(); + assert(page.current_contact_idx == -1 && page.chat_history.count == 0 && page.last_sent_message.empty()); + page.current_channel_idx = 4; page.current_channel = the_mesh_cp.channel; + strcpy(the_mesh_cp.channel.name, "renamed"); + assert(page.refreshSelectedChannel() && !strcmp(page.current_channel.name, "renamed")); + the_mesh_cp.channel.channel.secret[31] = 1; + assert(!page.refreshSelectedChannel() && page.current_channel_idx == -1); + page.current_channel_idx = 4; page.current_channel = the_mesh_cp.channel; + the_mesh_cp.channel.name[0] = 0; + assert(!page.refreshSelectedChannel() && page.current_channel_idx == -1); +} +''' + + +class CardputerMessagingTests(unittest.TestCase): + def run_cpp(self, source): + storage_tests.CardputerMeshStorageTests.run_cpp(self, source) + + def test_admission_retry_accepted_delivery_and_preference_rollback(self): + source = (VARIANT / "companion_radio/CardputerMesh.cpp").read_text() + methods = "\n".join(body(source, signature) for signature in ( + "int CardputerMesh::sendDirectMessage(", "void CardputerMesh::checkResend()", + "void CardputerMesh::cancelResending()", "void CardputerMesh::onUiContactMessage(", + "void CardputerMesh::onUiChannelMessage(", "bool CardputerMesh::saveCustomPrefs()", + "bool CardputerMesh::savePrefs()", "void CardputerMesh::beginCustom()")) + last_message = body((VARIANT / "types.h").read_text(), "struct LastSentMessage {") + ";" + self.run_cpp(HARNESS.replace("@METHODS@", methods).replace("@LAST_MESSAGE@", last_message)) + + def test_selection_survives_contact_compaction_and_invalidates_replaced_keys(self): + source = (VARIANT / "companion_radio/ui-cardputer/screen/main/ChatPage.cpp").read_text() + methods = "\n".join(body(source, signature) for signature in ( + "void ChatPage::refreshSelectedContact()", "bool ChatPage::refreshSelectedChannel()")) + self.run_cpp(SELECTION.replace("@METHODS@", methods)) + + +if __name__ == "__main__": + unittest.main() diff --git a/test/test_cardputer_ui.py b/test/test_cardputer_ui.py index 3c3fab72a..1e3800a9d 100644 --- a/test/test_cardputer_ui.py +++ b/test/test_cardputer_ui.py @@ -87,6 +87,7 @@ struct Task { bool getGPSState() const { return true; } bool isBluetoothEnabled() const { return true; } bool powerSaveEnabled() const { return true; } + void showAlert(const char*,int) {} }; struct Prefs { char node_name[32]="node",default_scope_name[31]=""; @@ -98,9 +99,33 @@ struct Prefs { struct CustomPrefs { float battery_correction=1; }; struct ChannelDetails { char name[32]={}; }; struct Mesh { - unsigned saves=0; + unsigned saves=0,applies=0; + Prefs* prefs=nullptr; + CustomPrefs* custom=nullptr; + CustomPrefs committed; + bool fail=false; + std::string command; ChannelDetails channels[MAX_GROUP_CHANNELS]; - void savePrefs() { ++saves; } + bool savePrefs() { if(fail) return false; ++saves; return true; } + bool saveCustomPrefs() { + if(fail) { *custom=committed; return false; } + committed=*custom; ++saves; return true; + } + uint16_t getPrimaryRadioPreamble() const { return 96; } + bool handleLocalCommand(const char* text,char* reply) { + command=text; + if(fail) { strcpy(reply,"Error: save failed"); return true; } + if(command.rfind("set radio ",0)==0) { + float f,bw; unsigned sf,cr,pre; + assert(sscanf(text+10,"%f,%f,%u,%u,%u",&f,&bw,&sf,&cr,&pre)==5 && pre==96); + prefs->freq=f; prefs->bw=bw; prefs->sf=sf; prefs->cr=cr; ++applies; + } else if(command.rfind("set tx ",0)==0) { + prefs->tx_power_dbm=std::stoi(command.substr(7)); ++applies; + } else if(command.rfind("set name ",0)==0) { + snprintf(prefs->node_name,sizeof(prefs->node_name),"%s",text+9); + } else { assert(false); } + ++saves; strcpy(reply,"OK"); return true; + } bool getChannel(int i,ChannelDetails& result) { if(i<0 || i>=MAX_GROUP_CHANNELS) return false; result=channels[i]; return true; @@ -150,17 +175,22 @@ struct SettingsScreen { String edit_buffer; uint8_t edit_u8=0; int menu_index=RadioFreq; - bool restart_required=false,is_editing=true; + char edit_error[80]="Invalid value"; + bool is_editing=true; void renderItem(CardputerDisplay&,SettingsItem,int,int); bool commitItemEdit(SettingsItem); void handleEditInput(SettingsItem,Keyboard::Event&); bool inputParsePositiveFloat(float&); bool validRadioParams(float,float,uint8_t,uint8_t) const; + bool commitCommand(const char*); + bool commitRadio(float,float,uint8_t,uint8_t); int render(CardputerDisplay&); }; ''' for signature in ( "void SettingsScreen::renderItem(", + "bool SettingsScreen::commitCommand(", + "bool SettingsScreen::commitRadio(", "bool SettingsScreen::commitItemEdit(", "void SettingsScreen::handleEditInput(", "bool SettingsScreen::inputParsePositiveFloat(", @@ -172,6 +202,9 @@ struct SettingsScreen { struct CardputerUITask { CardputerDisplay* _display=nullptr; Board* _board; + bool allow_shutdown=true; + bool prepareForShutdown() { return allow_shutdown; } + void showAlert(const char*,int) {} void shutdown(bool restart=false); }; ''' @@ -199,6 +232,7 @@ struct ChannelsPage { int main() { Prefs prefs; CustomPrefs custom; Task task; CardputerDisplay display; SettingsScreen s{&prefs,&custom,&task}; + the_mesh_cp.prefs=&prefs; the_mesh_cp.custom=&custom; Keyboard::Event digit{Keyboard::KEY_1,"9"}; for(int i=0;i<4096;++i) s.handleEditInput(SettingsScreen::RadioFreq,digit); assert(s.edit_buffer.length()==s.MAX_NUMERIC_EDIT_BYTES); @@ -213,11 +247,11 @@ int main() { for(const char* bad:{"0","149.9","960.1","42949672960"}) { s.edit_buffer=bad; assert(!s.commitItemEdit(SettingsScreen::RadioFreq)); - assert(prefs.freq==910.525f && the_mesh_cp.saves==0 && !s.restart_required); + assert(prefs.freq==910.525f && the_mesh_cp.saves==0); } s.edit_buffer="915.25"; assert(s.commitItemEdit(SettingsScreen::RadioFreq)); - assert(prefs.freq==915.25f && the_mesh_cp.saves==1 && s.restart_required); + assert(prefs.freq==915.25f && the_mesh_cp.saves==1 && the_mesh_cp.applies==1); for(const char* bad:{"0","123.45","812.5"}) { s.edit_buffer=bad; assert(!s.commitItemEdit(SettingsScreen::RadioBw)); @@ -229,10 +263,35 @@ int main() { assert(!s.commitItemEdit(SettingsScreen::RadioCr)); s.edit_u8=23; assert(!s.commitItemEdit(SettingsScreen::RadioPwr)); assert(prefs.sf==8 && prefs.cr==5 && prefs.tx_power_dbm==22 && the_mesh_cp.saves==2); + // The UI must use the live shared command path and leave prefs untouched on failure. + assert(the_mesh_cp.command=="set radio 915.250000,125.000000,8,5,96"); + the_mesh_cp.fail=true; + s.edit_buffer="916"; assert(!s.commitItemEdit(SettingsScreen::RadioFreq)); + assert(prefs.freq==915.25f && strstr(s.edit_error,"save failed")); + s.edit_buffer="renamed"; assert(!s.commitItemEdit(SettingsScreen::PublicInfoName)); + assert(!strcmp(prefs.node_name,"node")); + strcpy(prefs.default_scope_name,"original"); memset(prefs.default_scope_key,0xA5,sizeof(prefs.default_scope_key)); + s.edit_buffer="replacement"; assert(!s.commitItemEdit(SettingsScreen::MeshDefaultScope)); + assert(!strcmp(prefs.default_scope_name,"original") && prefs.default_scope_key[0]==0xA5); + s.edit_u8=3; assert(!s.commitItemEdit(SettingsScreen::MeshPathSize)); + assert(prefs.path_hash_mode==1); + s.edit_buffer="4400"; assert(!s.commitItemEdit(SettingsScreen::DeviceBatteryCorrection)); + assert(custom.battery_correction==1.0f); + the_mesh_cp.fail=false; + s.edit_buffer="node renamed"; assert(s.commitItemEdit(SettingsScreen::PublicInfoName)); + assert(!strcmp(prefs.node_name,"node renamed")); + for(const char* bad:{"0","1","9999"}) { + s.edit_buffer=bad; assert(!s.commitItemEdit(SettingsScreen::DeviceBatteryCorrection)); + assert(custom.battery_correction==1.0f); + } + s.edit_buffer="4400"; assert(s.commitItemEdit(SettingsScreen::DeviceBatteryCorrection)); + assert(custom.battery_correction==1.1f); // A missing/failed display cannot turn a battery shutdown into a null dereference. CardputerUITask ui{nullptr,&task.board}; ui.shutdown(); assert(task.board.off==1 && radio_driver.off==1); ui.shutdown(true); assert(task.board.boots==1); + ui.allow_shutdown=false; ui.shutdown(); ui.shutdown(true); + assert(task.board.off==1 && task.board.boots==1 && radio_driver.off==1); // Channel list indices must map back to actual sparse channel slots. MainScreen main_screen; ChannelsPage list{&main_screen}; for(int i:{2,7,39}) strcpy(the_mesh_cp.channels[i].name,"channel"); diff --git a/test/test_cardputer_ui_lifecycle.py b/test/test_cardputer_ui_lifecycle.py new file mode 100644 index 000000000..2c7be7c9d --- /dev/null +++ b/test/test_cardputer_ui_lifecycle.py @@ -0,0 +1,362 @@ +#!/usr/bin/env python3 +"""Run actual Cardputer controls, drawing and shared display policy on the host.""" + +from pathlib import Path +import subprocess +import tempfile +import unittest + +from test_cardputer_ui import function + +ROOT = Path(__file__).resolve().parents[1] +VARIANT = ROOT / "variants/m5stack_cardputer_adv" + + +class CardputerLifecycleTests(unittest.TestCase): + def test_actual_controls_notifications_power_and_night_drawing(self): + task = (VARIANT / "companion_radio/ui-cardputer/CardputerUITask.cpp").read_text(encoding="ascii") + display = (VARIANT / "hw/CardputerDisplay.cpp").read_text(encoding="ascii") + abstract = (ROOT / "examples/companion_radio/AbstractUITask.h").read_text(encoding="ascii") + code = r''' +#include +#include +#include +#include +#include +#include +#include "helpers/ui/ColorTheme.h" +#include "helpers/CompanionNotificationPolicy.h" +#include "helpers/ui/BluetoothPairingUiPolicy.h" +uint32_t fake_now=0; +unsigned long millis() { return fake_now; } +bool night=false; +namespace mesh { namespace ui { bool displayNightActive() { return night; } } } +constexpr uint16_t TFT_BLACK=0,TFT_WHITE=0xFFFF,TFT_DARKGRAY=0x7BEF; +struct LCDMock { + std::vector colors; + std::vector text; + uint16_t current=0; + unsigned brightness=0,text_level=0,prints=0; + void setBaseColor(uint16_t c) { colors.push_back(c); } + bool begin() { return true; } + void setRotation(int) {} + void setColorDepth(int) {} + void setBrightness(uint8_t b) { brightness=b; } + void setTextColor(uint16_t c) { colors.push_back(c); } + void setTextColor(uint16_t c,uint16_t b) { colors.push_back(c); colors.push_back(b); } + template void setEmojiCallback(F) {} + void setColor(uint16_t c) { current=c; colors.push_back(c); } + void clear(uint16_t c) { colors.push_back(c); } + void startWrite() {} + void endWrite() {} + void wakeup() {} + void sleep() {} + int textWidth(const char* s) { return strlen(s)*8; } + int fontHeight() { return 16; } + void drawRect(int,int,int,int) { colors.push_back(current); } + void drawRect(int,int,int,int,uint16_t c) { colors.push_back(c); } + void fillRect(int,int,int,int) { colors.push_back(current); } + void drawBitmap(int,int,const uint8_t*,int,int,uint16_t c) { colors.push_back(c); } + void setCursor(int,int) {} + void print(const char* value) { ++prints; colors.push_back(current); text.emplace_back(value); } +}; +namespace lgfx { using LGFXBase=LCDMock; } +struct CardputerDisplay: DisplayDriver { + enum { TEXT_LEVEL_COUNT=3 }; + enum PaletteColor:uint16_t { P_BLACK=0,P_WHITE=0xFFFF,P_RED=0xF800,P_GREEN=0x07E0, + P_BLUE=0x001F,P_ORANGE=0xFD20,P_YELLOW=0xFFE0,P_GRAY=0x8410 }; + bool _isOn=false; + uint16_t _lastColor=0; + uint8_t _textLevel=1; + LCDMock LCD; + CardputerDisplay():DisplayDriver(240,135) {} + bool begin(); + bool isOn() override { return _isOn; } + void turnOn() override; + void turnOff() override; + void clear() override; + void startFrame(ColorVal bkg=UIColor::window_bkg) override; + void endFrame() override; + void setTextSize(int) override {} + void setColor(ColorVal) override; + void setCursor(int,int) override; + void print(const char*) override; + void fillRect(int,int,int,int) override; + void drawRect(int,int,int,int) override; + void drawXbm(int,int,const uint8_t*,int,int) override; + uint16_t getTextWidth(const char*) override; + void setTextLevel(uint8_t level) { _textLevel=level; LCD.text_level=level; } + uint8_t getTextLevel() const { return _textLevel; } + void setBrightness(uint8_t level) { LCD.setBrightness(level); } +}; +''' + color_start = display.index("ColorVal UIColor::window_bkg") + color_end = display.index("\n\nint32_t emoji_draw_callback", color_start) + code += display[color_start:color_end] + "\n" + code += function(display, "int32_t emoji_draw_callback(") + "\n" + for signature in ( + "bool CardputerDisplay::begin(", "void CardputerDisplay::turnOn(", + "void CardputerDisplay::turnOff(", "void CardputerDisplay::clear(", + "void CardputerDisplay::startFrame(", "void CardputerDisplay::endFrame(", + "void CardputerDisplay::setColor(", "void CardputerDisplay::setCursor(", + "void CardputerDisplay::print(", "void CardputerDisplay::fillRect(", + "void CardputerDisplay::drawRect(", "void CardputerDisplay::drawXbm(", + "uint16_t CardputerDisplay::getTextWidth(", + ): + code += function(display, signature) + "\n" + code += r''' +struct Speaker { + unsigned tones=0,sleeps=0; + void queueTone(int,int,float=0.5f) { ++tones; } + void sleep() { ++sleeps; } +}; +struct Board { + bool usb=false; + Speaker speaker; + bool isExternalPowered() { return usb; } + bool isUsbHostConnected() { return usb; } + Speaker* getSpeaker() { return &speaker; } +}; +struct Interfaces { + bool pending=false,enabled=true,ble=false; + bool takePairingRequest() { bool result=pending; pending=false; return result; } +}; +namespace Keyboard { +enum Key { KEY_ESC,KEY_RETURN,KEY_A }; +struct Event { Key key; }; +} +struct Prefs { uint8_t gps_enabled=1,buzzer_quiet=0,powersaving_enabled=0; }; +struct CustomPrefs { uint8_t text_level=1,brightness=96; }; +struct Mesh { + Prefs* prefs=nullptr; + CustomPrefs* custom=nullptr; + CustomPrefs committed; + bool fail=false; + unsigned saves=0; + uint32_t getBLEPin() const { return 246810; } + bool savePrefs() { if(fail) return false; ++saves; return true; } + bool saveCustomPrefs() { + if(fail) { *custom=committed; return false; } + committed=*custom; ++saves; return true; + } + bool setGpsEnabled(bool on) { + if(fail) return false; + prefs->gps_enabled=on; ++saves; return true; + } + bool handleLocalCommand(const char* command,char* reply) { + if(fail) { strcpy(reply,"Error: save failed"); return true; } + assert(!strcmp(command,"powersaving on") || !strcmp(command,"powersaving off")); + prefs->powersaving_enabled=!strcmp(command,"powersaving on"); ++saves; + strcpy(reply,"OK - powersaving"); return true; + } +} the_mesh_cp; +struct CardputerScreen {}; +struct MainScreen: CardputerScreen { + enum { FIRST,CHAT }; + int page=FIRST; + int getCurrentPage() { return page; } +}; +struct NewMessageScreen: CardputerScreen { + unsigned messages=0; + void newMessage(const char*,const char*) { ++messages; } +}; +enum class UIEventType { none,channelMessage,contactMessage,ack }; +enum class SoundType { Keyboard,NewMessage,DiscoveryResult,MessageAck }; +struct CardputerUITask { + CardputerDisplay* _display=nullptr; + Board* _board=nullptr; + Interfaces* _interfaceManager=nullptr; + Prefs* _node_prefs=nullptr; + CustomPrefs* _custom_prefs=nullptr; + CardputerScreen *main_screen=nullptr,*new_message_screen=nullptr,*current_screen=nullptr; + bool connected=false,_message_notification_override=false,_message_notification_screen_override=false; + uint8_t _notification_outputs=31; + int unsynced_msg_count=0; + char alert_text[80]={}; + unsigned long alert_expiry=0,next_refresh=0; + uint32_t pairing_prompt_until=0; + bool hasConnection() const { return connected || _interfaceManager->ble; } + bool hasBluetoothConnection() const { return _interfaceManager->ble; } + bool isBluetoothEnabled() const { return _interfaceManager->enabled; } + void scheduleRefresh(unsigned ms=0) { next_refresh=millis()+ms; } + void setCurrScreen(CardputerScreen* screen) { current_screen=screen; scheduleRefresh(); } + bool shouldWakeDisplayForMessage() const; + void showAlert(const char*,int); + void serviceDisplayPower(); + void servicePairingState(); + bool isPairingPromptActive() const; + void renderPairingPrompt(); + bool handlePairingInput(const Keyboard::Event&); + bool turnDisplayOn(); + bool getGPSState(); + bool toggleGPS(); + bool toggleBuzzer(); + bool togglePowerSave(); + void cycleBrightness(); + void cycleTextSize(); + void playSound(SoundType); + void notify(UIEventType); + uint8_t notificationCapabilities() const; + void notificationScreen(int8_t); + void newMsg(uint8_t,const char*,const char*,int,int=-1,const char*=nullptr,int=-1); +}; +'''.replace("const char*=", "const char* =") + code += "bool CardputerUITask::" + function(abstract, "bool shouldWakeDisplayForMessage() const").removeprefix("bool ") + "\n" + for signature in ( + "void CardputerUITask::showAlert(", "void CardputerUITask::serviceDisplayPower(", + "void CardputerUITask::servicePairingState(", "bool CardputerUITask::isPairingPromptActive(", + "void CardputerUITask::renderPairingPrompt(", "bool CardputerUITask::handlePairingInput(", + "bool CardputerUITask::turnDisplayOn(", "bool CardputerUITask::getGPSState(", + "bool CardputerUITask::toggleGPS(", "bool CardputerUITask::toggleBuzzer(", + "bool CardputerUITask::togglePowerSave(", "void CardputerUITask::cycleBrightness(", + "void CardputerUITask::cycleTextSize(", "void CardputerUITask::playSound(", + "void CardputerUITask::notify(", "uint8_t CardputerUITask::notificationCapabilities(", + "void CardputerUITask::notificationScreen(", "void CardputerUITask::newMsg(", + ): + code += function(task, signature) + "\n" + code += r''' +int main() { + CardputerDisplay display; + // Every drawing boundary, including implicit glyph and emoji colors, follows night mode. + night=true; assert(display.begin()); + display.startFrame(0x1234); + const uint8_t bitmap[]={0xFF}; + for(uint16_t color:{uint16_t(0),uint16_t(0xFFFF),uint16_t(0x07E0),uint16_t(0x1234)}) { + display.setColor(color); display.print("text"); display.fillRect(0,0,1,1); + display.drawRect(0,0,1,1); display.drawXbm(0,0,bitmap,1,1); + } + emoji_draw_callback(&display.LCD,0,0,0,16); display.clear(); display.endFrame(); + for(auto color:display.LCD.colors) assert(color==0 || color==0xF800); + night=false; display.setColor(CardputerDisplay::P_GREEN); + assert(display._lastColor==CardputerDisplay::P_GREEN); + display.setColor(CardputerDisplay::P_WHITE); assert(display._lastColor==0xFFFF); + + Board board; Interfaces interfaces; Prefs prefs; CustomPrefs custom; + MainScreen main; NewMessageScreen messages; + CardputerUITask ui; + ui._display=&display; ui._board=&board; ui._interfaceManager=&interfaces; + ui._node_prefs=&prefs; ui._custom_prefs=&custom; + ui.main_screen=&main; ui.current_screen=&main; ui.new_message_screen=&messages; + the_mesh_cp.prefs=&prefs; the_mesh_cp.custom=&custom; + auto& power=mesh::ui::displayPowerPrefs(); + power.battery.mode=mesh::ui::DisplayMode::Automatic; power.battery.seconds=27; + power.usb.mode=mesh::ui::DisplayMode::Off; + ui.serviceDisplayPower(); assert(display.isOn()); + fake_now=5000; ui.serviceDisplayPower(); assert(!display.isOn()); + ui.newMsg(0,"peer","message",1); assert(display.isOn() && messages.messages==1); + fake_now=31999; ui.serviceDisplayPower(); assert(display.isOn()); + fake_now=32000; ui.serviceDisplayPower(); assert(!display.isOn()); + // The first waking key is consumed; subsequent input extends the saved timeout. + assert(ui.turnDisplayOn()); assert(display.isOn()); assert(!ui.turnDisplayOn()); + fake_now=59000; ui.serviceDisplayPower(); assert(!display.isOn()); + ui.connected=true; ui.current_screen=&main; + ui.newMsg(0,"peer","online",2); assert(!display.isOn() && messages.messages==1 && ui.current_screen==&main); + ui.connected=false; + ui._notification_outputs=mesh::notify::Sound; + ui.newMsg(0,"peer","screen muted",3); assert(!display.isOn() && messages.messages==1); + ui._notification_outputs=31; ui._message_notification_screen_override=true; + ui.newMsg(0,"peer","managed screen",4); assert(!display.isOn() && messages.messages==1); + ui._message_notification_screen_override=false; + power.battery.mode=mesh::ui::DisplayMode::Button; + ui.serviceDisplayPower(); ui.newMsg(0,"peer","button mode",5); assert(!display.isOn()); + assert(ui.turnDisplayOn() && display.isOn()); + board.usb=true; ui.serviceDisplayPower(); assert(!display.isOn()); + ui.newMsg(0,"peer","USB off",6); assert(!display.isOn()); + ui.notificationScreen(1); assert(display.isOn()); + ui.notificationScreen(-1); ui.serviceDisplayPower(); assert(!display.isOn()); + assert(ui.notificationCapabilities()==mesh::notify::Screen); + unsigned tones=board.speaker.tones; + ui._notification_outputs=0; ui.notify(UIEventType::contactMessage); assert(board.speaker.tones==tones); + ui._notification_outputs=mesh::notify::Sound; ui._message_notification_override=true; + ui.notify(UIEventType::contactMessage); assert(board.speaker.tones==tones); + ui._message_notification_override=false; prefs.buzzer_quiet=1; + ui.notify(UIEventType::contactMessage); assert(board.speaker.tones==tones); + prefs.buzzer_quiet=0; ui.notify(UIEventType::contactMessage); assert(board.speaker.tones==tones+1); + + the_mesh_cp.fail=true; + assert(!ui.toggleGPS() && prefs.gps_enabled==1); + assert(!ui.toggleBuzzer() && prefs.buzzer_quiet==0); + assert(!ui.togglePowerSave() && prefs.powersaving_enabled==0); + display.setTextLevel(1); display.setBrightness(96); + ui.cycleTextSize(); assert(custom.text_level==1 && display.getTextLevel()==1); + ui.cycleBrightness(); assert(custom.brightness==96 && display.LCD.brightness==96); + the_mesh_cp.fail=false; + assert(ui.toggleGPS() && prefs.gps_enabled==0); + assert(ui.toggleBuzzer() && prefs.buzzer_quiet==1); + assert(ui.togglePowerSave() && prefs.powersaving_enabled==1); + ui.cycleTextSize(); assert(custom.text_level==2 && display.getTextLevel()==2); + ui.cycleBrightness(); assert(custom.brightness==160 && display.LCD.brightness==160); + // Real pairing events wake pairing-only policy, without replacing the chat/draft owner. + power.usb.mode=mesh::ui::DisplayMode::Pairing; + ui.current_screen=&main; + ui.servicePairingState(); assert(!ui.isPairingPromptActive() && !display.isOn()); + interfaces.pending=true; ui.servicePairingState(); + assert(!interfaces.pending && ui.isPairingPromptActive() && display.isOn()); + assert(ui.current_screen==&main && ui.pairing_prompt_until==fake_now+120000); + display.LCD.text.clear(); ui.renderPairingPrompt(); + unsigned pin_count=0; + for(const auto& text:display.LCD.text) if(text=="246810") ++pin_count; + assert(pin_count==1); + Keyboard::Event enter{Keyboard::KEY_RETURN}; + assert(ui.handlePairingInput(enter) && ui.current_screen==&main && ui.isPairingPromptActive()); + ui.newMsg(0,"peer","during pairing",8); assert(ui.current_screen==&main); + fake_now=ui.pairing_prompt_until-1; ui.servicePairingState(); assert(ui.isPairingPromptActive()); + ++fake_now; ui.servicePairingState(); assert(!ui.isPairingPromptActive() && !display.isOn()); + assert(ui.current_screen==&main); + + // A saved Off profile stays dark, even when the phone requests a PIN. + power.usb.mode=mesh::ui::DisplayMode::Off; + interfaces.pending=true; ui.servicePairingState(); + assert(ui.isPairingPromptActive() && !display.isOn()); + // BLE completion, unlike an unrelated USB client, ends the prompt immediately. + interfaces.ble=true; ui.servicePairingState(); + assert(!ui.isPairingPromptActive() && ui.pairing_prompt_until==0); + interfaces.ble=false; power.usb.mode=mesh::ui::DisplayMode::ButtonPairing; + interfaces.pending=true; ui.servicePairingState(); assert(display.isOn()); + interfaces.enabled=false; ui.servicePairingState(); + assert(!ui.isPairingPromptActive() && !display.isOn()); + interfaces.enabled=true; + // ESC dismisses the overlay and wakes the retained screen under button-pairing policy. + interfaces.pending=true; ui.servicePairingState(); + Keyboard::Event escape{Keyboard::KEY_ESC}; + assert(ui.handlePairingInput(escape) && !ui.isPairingPromptActive()); + assert(ui.current_screen==&main && display.isOn()); + assert(!ui.handlePairingInput(enter)); + // Expiry remains bounded across the 32-bit millis rollover. + fake_now=0xFFFF0000U; interfaces.pending=true; ui.servicePairingState(); + assert(ui.isPairingPromptActive()); + fake_now+=119999U; ui.servicePairingState(); assert(ui.isPairingPromptActive()); + ++fake_now; ui.servicePairingState(); assert(!ui.isPairingPromptActive()); + // Failed display initialization leaves every display control and notification safe. + ui._display=nullptr; + auto saved=the_mesh_cp.saves; + ui.cycleTextSize(); ui.cycleBrightness(); ui.serviceDisplayPower(); ui.notificationScreen(1); + assert(!ui.turnDisplayOn() && ui.notificationCapabilities()==0 && the_mesh_cp.saves==saved); + interfaces.pending=true; ui.servicePairingState(); + assert(!ui.isPairingPromptActive() && !interfaces.pending); + ui.newMsg(0,"peer","headless",7); + std::puts("PASS: Cardputer live controls, notifications, shared power and night drawing"); +} +''' + self.assertIn("event.changed && event.down && !handlePairingInput(event)", task) + self.assertIn("BUTTON_EVENT_CLICK && !isPairingPromptActive()", task) + self.assertIn("renderPairingPrompt();\n _display->endFrame();", task) + with tempfile.TemporaryDirectory(prefix="cardputer-lifecycle-") as tmp: + work = Path(tmp) + (work / "Arduino.h").write_text("#pragma once\nunsigned long millis();\n", encoding="ascii") + source, binary = work / "test.cpp", work / "test" + source.write_text(code, encoding="ascii") + compiled = subprocess.run([ + "g++", "-std=c++17", "-Wall", "-Wextra", "-Werror", + "-Wno-unused-parameter", "-Wno-misleading-indentation", + "-fsanitize=address,undefined", "-fno-omit-frame-pointer", "-no-pie", + "-DCARDPUTER_ADV_UI=1", "-I", str(work), "-I", str(ROOT / "src"), + str(source), str(ROOT / "src/helpers/ui/DisplayDriver.cpp"), "-o", str(binary), + ], capture_output=True, text=True) + self.assertEqual(compiled.returncode, 0, compiled.stderr) + executed = subprocess.run([str(binary)], capture_output=True, text=True) + self.assertEqual(executed.returncode, 0, executed.stdout + executed.stderr) + + +if __name__ == "__main__": + unittest.main() diff --git a/variants/m5stack_cardputer_adv/CardputerDataStore.cpp b/variants/m5stack_cardputer_adv/CardputerDataStore.cpp index 7b32714ff..d68c8e309 100644 --- a/variants/m5stack_cardputer_adv/CardputerDataStore.cpp +++ b/variants/m5stack_cardputer_adv/CardputerDataStore.cpp @@ -1,5 +1,9 @@ #include "CardputerDataStore.h" #include // Keep optional framework-SD discovery local to this variant. +#include +#include +#include +#include #include "globals.h" @@ -24,30 +28,97 @@ void CardputerDataStore::beginCustom(FILESYSTEM *history_fs) { _history_fs->mkdir(HISTORY_DIR_DIRECT); } -void CardputerDataStore::loadCustomPrefs(CustomNodePrefs &prefs) { - if (!_fs->exists(CUSTOM_PREFS_FILE)) { - return; - } - - File file = _fs->open(CUSTOM_PREFS_FILE, FILE_READ); - - // Read file only if it exactly matches struct size, otherwise keep default values - if (file.size() == sizeof(prefs)) { - file.read((uint8_t *)&prefs, sizeof(prefs)); - } - - file.close(); +static bool validCustomPrefs(const CustomNodePrefs &prefs) { + return isfinite(prefs.battery_correction) && prefs.battery_correction >= 0.5f + && prefs.battery_correction <= 2.0f && prefs.text_level < 3; } -void CardputerDataStore::saveCustomPrefs(const CustomNodePrefs &prefs) { - File file = _fs->open(CUSTOM_PREFS_FILE, FILE_WRITE); +static bool customPrefsPresence(FILESYSTEM *fs, const char *path, bool &present) { + (void)fs; // Custom preferences always use the default internal SPIFFS mount. + char vfs_path[96]; + const int length = snprintf(vfs_path, sizeof(vfs_path), "/spiffs%s", path); + if (length < 0 || static_cast(length) >= sizeof(vfs_path)) return false; + struct stat info; + const int result = ::stat(vfs_path, &info); + if (result != 0 && errno != ENOENT) return false; + present = result == 0; + return true; +} - if (!file) { - return; - } - - file.write((uint8_t *)&prefs, sizeof(prefs)); +static bool readCustomPrefs(FILESYSTEM *fs, const char *path, CustomNodePrefs &prefs) { + File file = fs->open(path, FILE_READ); + if (!file) return false; + CustomNodePrefs candidate; + const bool valid = !file.isDirectory() && file.size() == sizeof(candidate) + && file.read(reinterpret_cast(&candidate), sizeof(candidate)) == sizeof(candidate) + && validCustomPrefs(candidate); file.close(); + if (valid) prefs = candidate; + return valid; +} + +bool CardputerDataStore::loadCustomPrefs(CustomNodePrefs &prefs) { + _custom_prefs_write_blocked = false; + bool present = false, backup_present = false; + if (!customPrefsPresence(_fs, CUSTOM_PREFS_FILE, present) + || !customPrefsPresence(_fs, CUSTOM_PREFS_FILE ".bak", backup_present)) { + _custom_prefs_write_blocked = true; + return false; + } + if (present && readCustomPrefs(_fs, CUSTOM_PREFS_FILE, prefs)) return true; + CustomNodePrefs backup; + if (backup_present && readCustomPrefs(_fs, CUSTOM_PREFS_FILE ".bak", backup)) { + prefs = backup; + if (present && !_fs->remove(CUSTOM_PREFS_FILE)) { + _custom_prefs_write_blocked = true; + return true; + } + _fs->rename(CUSTOM_PREFS_FILE ".bak", CUSTOM_PREFS_FILE); + CustomNodePrefs verified; + _custom_prefs_write_blocked = !readCustomPrefs(_fs, CUSTOM_PREFS_FILE, verified) + || memcmp(&verified, &backup, sizeof(backup)) != 0; + return true; + } + // An unreadable existing image is not permission to replace its only copy. + _custom_prefs_write_blocked = present || backup_present; + return false; +} + +bool CardputerDataStore::saveCustomPrefs(const CustomNodePrefs &prefs) { + if (_custom_prefs_write_blocked || !validCustomPrefs(prefs)) return false; + mesh::ContactFileTransaction transaction(_fs, CUSTOM_PREFS_FILE, customPrefsPresence); + bool valid = transaction && transaction.write(reinterpret_cast(&prefs), sizeof(prefs)) == sizeof(prefs); + mesh::ContactFileTransaction::CommitProgress progress; + do { + progress = transaction.serviceCommit(valid, 1, true); + } while (progress == mesh::ContactFileTransaction::CommitProgress::Pending); + if (progress != mesh::ContactFileTransaction::CommitProgress::Succeeded) { + _custom_prefs_write_blocked = transaction.outcome() == mesh::ContactFileTransaction::Outcome::Indeterminate; + return false; + } + CustomNodePrefs verified; + if (readCustomPrefs(_fs, CUSTOM_PREFS_FILE, verified) && memcmp(&verified, &prefs, sizeof(prefs)) == 0) { + _fs->remove(CUSTOM_PREFS_FILE ".bak"); + return true; + } + // Keep the old verified image until the public candidate has been read back. + CustomNodePrefs backup; + bool backup_present = false; + if (!customPrefsPresence(_fs, CUSTOM_PREFS_FILE ".bak", backup_present) + || (backup_present && !readCustomPrefs(_fs, CUSTOM_PREFS_FILE ".bak", backup)) + || !_fs->remove(CUSTOM_PREFS_FILE)) { + _custom_prefs_write_blocked = true; + return false; + } + if (backup_present) { + _fs->rename(CUSTOM_PREFS_FILE ".bak", CUSTOM_PREFS_FILE); + _custom_prefs_write_blocked = !readCustomPrefs(_fs, CUSTOM_PREFS_FILE, verified) + || memcmp(&verified, &backup, sizeof(backup)) != 0; + } else { + bool present = false; + _custom_prefs_write_blocked = !customPrefsPresence(_fs, CUSTOM_PREFS_FILE, present) || present; + } + return false; } void CardputerDataStore::storeMessage(const uint8_t pkey[PUB_KEY_SIZE], const char *text, bool is_sent, diff --git a/variants/m5stack_cardputer_adv/CardputerDataStore.h b/variants/m5stack_cardputer_adv/CardputerDataStore.h index a747d5b85..b9f8552ea 100644 --- a/variants/m5stack_cardputer_adv/CardputerDataStore.h +++ b/variants/m5stack_cardputer_adv/CardputerDataStore.h @@ -26,6 +26,7 @@ class CardputerDataStore : public DataStore { private: FILESYSTEM *_fs; FILESYSTEM *_history_fs = nullptr; + bool _custom_prefs_write_blocked = false; public: CardputerDataStore(FILESYSTEM &fs, mesh::RTCClock &clock) : DataStore(fs, clock), _fs(&fs) {} @@ -33,8 +34,8 @@ public: // Call after the SD card is mounted (or not); nullptr disables history. void beginCustom(FILESYSTEM *history_fs); bool hasHistory() const { return _history_fs != nullptr; } - void loadCustomPrefs(CustomNodePrefs &prefs); - void saveCustomPrefs(const CustomNodePrefs &prefs); + bool loadCustomPrefs(CustomNodePrefs &prefs); + bool saveCustomPrefs(const CustomNodePrefs &prefs); void storeMessage(const uint8_t pkey[PUB_KEY_SIZE], const char *text, bool is_sent, bool is_channel); void loadMessages(const uint8_t pkey[PUB_KEY_SIZE], bool is_channel, ChatHistory &history); }; diff --git a/variants/m5stack_cardputer_adv/CardputerSensorManager.cpp b/variants/m5stack_cardputer_adv/CardputerSensorManager.cpp index 379079f54..4372c57aa 100644 --- a/variants/m5stack_cardputer_adv/CardputerSensorManager.cpp +++ b/variants/m5stack_cardputer_adv/CardputerSensorManager.cpp @@ -1,110 +1,156 @@ #include "CardputerSensorManager.h" +namespace { +const uint32_t STANDBY_RENEW_MS = 60UL * 60UL * 1000UL; +bool due(uint32_t now, uint32_t deadline) { + return static_cast(now - deadline) >= 0; +} +} + +void CardputerSensorManager::armGpsPowerSavingCycle() { + if (!powersaving_enabled || !_location->getGPSPowerSaving()) return; + _location->syncTimeForPowerSavingCycle(); + _location->setNextGPSOn(0); + _location->setNextSleep(); + // The provider uses zero as "no deadline", including at millis rollover. + if (static_cast(_location->getNextGPSOff()) == 0) _location->setNextGPSOff(1); +} + void CardputerSensorManager::start_gps() { - if (!gps_active) { - gps_standby(false); - delay(1000); - gps_active = true; - } + if (!gps_serial_open || gps_active) return; + gps_standby(false); + gps_active = true; + // Do not turn a retained pre-sleep fix into a new telemetry observation. + // Receiver startup is asynchronous; the normal bounded parser polls it. + _location->clearPositionFix(); _location->begin(); + next_gps_update = millis(); + if (!_location->getGPSPowerSaving()) _location->syncTimeForPowerSavingCycle(); + armGpsPowerSavingCycle(); } void CardputerSensorManager::stop_gps() { - if (gps_active) { - gps_standby(true); - gps_active = false; - } + if (!gps_active) return; + gps_standby(true); + gps_active = false; + _location->stopTimeSync(); + _location->setNextGPSOff(0); + if (powersaving_enabled && _location->getGPSPowerSaving() + && isGpsTelemetryUserEnabled()) _location->setNextWake(); + else _location->setNextGPSOn(0); _location->stop(); } void CardputerSensorManager::gps_standby(bool active) { - if (active) { - // Cap Lora-1262 does not have GPS switch pin - // Instead we send a command to the GPS module to put it into standby for maximum possible value - // (65535 seconds) - Serial1.print("$PCAS12,65535*1E\r\n"); - } else { - // Hot reboot to exit standby mode - Serial1.print("$PCAS10,0*1C\r\n"); - } + // CASIC protocol sections CAS10/CAS12: hot start and standby for at most + // 65535 seconds. The provider's MicroNMEA sender generates the checksum. + _location->sendSentence(active ? "$PCAS12,65535" : "$PCAS10,0"); + standby_renew_at = static_cast(millis()) + STANDBY_RENEW_MS; } bool CardputerSensorManager::begin() { + if (gps_serial_open) return true; + // Existing board macros name the cap's RX/TX nets; setPins takes MCU RX/TX. Serial1.setPins(PIN_GPS_TX, PIN_GPS_RX); Serial1.begin(GPS_BAUD_RATE); - - delay(1000); - - // Check data available to determine if GPS active - if (Serial1.available() > 0) { - start_gps(); - } - + gps_serial_open = true; + gps_active = false; + _location->setGPSPowerSaving(false); + _location->stopTimeSync(); + _location->stop(); + // A fixed cap powers GNSS at boot even when the saved preference is off. + // Silence it explicitly; UART traffic during startup is not a user choice. + gps_standby(true); return true; } bool CardputerSensorManager::querySensors(uint8_t requester_permissions, CayenneLPP &telemetry) { - if (requester_permissions & TELEM_PERM_LOCATION) { // does requester have permission? - telemetry.addGPS(TELEM_CHANNEL_SELF, node_lat, node_lon, node_altitude); - } + // Shared cache, permissions, bounded acquisition and manual-off cancellation. + queryGpsTelemetry(requester_permissions, telemetry); return true; } void CardputerSensorManager::loop() { - static long next_gps_update = 0; - static long next_gps_force_shutdown = 0; - - // periodically send standby command because standby has maximum of 18 hours - if (millis() > next_gps_force_shutdown) { - if (!gps_active) { - gps_standby(true); + if (!gps_serial_open) return; + const uint32_t now = millis(); + loopGpsTelemetry(now); + if (powersaving_enabled && isGpsTelemetryUserEnabled() + && !isTrackerGpsModeEnabled() && _location->getGPSPowerSaving()) { + const uint32_t next_off = _location->getNextGPSOff(); + const uint32_t next_on = _location->getNextGPSOn(); + if (gps_active && !gpsTelemetryReceiverRequired(now) + && ((next_off != 0 && due(now, next_off)) || !_location->waitingTimeSync())) { + stop_gps(); + } else if (!gps_active && ((next_on != 0 && due(now, next_on)) + || _location->waitingTimeSync())) { + start_gps(); } - next_gps_force_shutdown = millis() + 1000 * 60 * 60; // every hour } - if (!gps_active) { + // CAS12 eventually wakes on its own. Renew off/standby without changing + // saved intent or creating a GPS telemetry/clock acquisition. + if (due(now, standby_renew_at)) gps_standby(true); return; } - _location->loop(); - - if (millis() > next_gps_update) { + if (due(now, next_gps_update)) { if (_location->isValid()) { - node_lat = ((double)_location->getLatitude()) / 1000000.; - node_lon = ((double)_location->getLongitude()) / 1000000.; - node_altitude = ((double)_location->getAltitude()) / 1000.0; - MESH_DEBUG_PRINTLN("VALID location: lat %f lon %f", node_lat, node_lon); - } else { - MESH_DEBUG_PRINTLN("INVALID location, waiting for fix"); + node_lat = static_cast(_location->getLatitude()) / 1000000.0; + node_lon = static_cast(_location->getLongitude()) / 1000000.0; + node_altitude = static_cast(_location->getAltitude()) / 1000.0; + processGpsTelemetryFix(node_lat, node_lon, node_altitude, now); } - MESH_DEBUG_PRINTLN("GPS satellites: %d", _location->satellitesCount()); - next_gps_update = millis() + 1000; + next_gps_update = now + getGpsUpdateIntervalMillis(); } } -int CardputerSensorManager::getNumSettings() const { - return 1; -} +int CardputerSensorManager::getNumSettings() const { return 1; } const char *CardputerSensorManager::getSettingName(int i) const { - return i == 0 ? "gps" : NULL; + return i == 0 ? "gps" : nullptr; } const char *CardputerSensorManager::getSettingValue(int i) const { - if (i == 0) { - return gps_active ? "1" : "0"; - } - return NULL; + return i == 0 ? (isGpsTelemetryUserEnabled() ? "1" : "0") : nullptr; } bool CardputerSensorManager::setSettingValue(const char *name, const char *value) { + if (!name || !value) return false; if (strcmp(name, "gps") == 0) { - if (strcmp(value, "0") == 0) { - stop_gps(); - } else { - start_gps(); + const bool enabled = strcmp(value, "0") != 0; + const bool was_active = gps_active; + _location->setGPSPowerSaving(enabled && powersaving_enabled); + setGpsTelemetryUserEnabled(enabled); + if (enabled && powersaving_enabled && was_active) armGpsPowerSavingCycle(); + if (!enabled) { + _location->stopTimeSync(); + _location->setNextGPSOn(0); + _location->setNextGPSOff(0); } return true; } - return false; // not supported + const bool accepted = SensorManager::setSettingValue(name, value); + if (accepted && strcmp(name, "gps_interval") == 0) next_gps_update = millis(); + return accepted; +} + +void CardputerSensorManager::setPowerSavingEnabled(bool enabled) { + if (powersaving_enabled == enabled) return; + powersaving_enabled = enabled; + if (isTrackerGpsModeEnabled()) { + _location->setGPSPowerSaving(false); + return; + } + const bool user_enabled = isGpsTelemetryUserEnabled(); + _location->setGPSPowerSaving(enabled && user_enabled); + if (!user_enabled) return; + if (!gps_active) start_gps(); + else if (enabled) armGpsPowerSavingCycle(); +} + +void CardputerSensorManager::shutdownGpsTransportForDeepSleep() { + if (!gps_serial_open) return; + stop_gps(); + Serial1.end(); + gps_serial_open = false; } diff --git a/variants/m5stack_cardputer_adv/CardputerSensorManager.h b/variants/m5stack_cardputer_adv/CardputerSensorManager.h index a47d4f0d4..37f821296 100644 --- a/variants/m5stack_cardputer_adv/CardputerSensorManager.h +++ b/variants/m5stack_cardputer_adv/CardputerSensorManager.h @@ -6,7 +6,17 @@ class CardputerSensorManager : public SensorManager { private: bool gps_active = false; + bool gps_serial_open = false; + uint32_t next_gps_update = 0; + uint32_t standby_renew_at = 0; LocationProvider *_location; + void armGpsPowerSavingCycle(); + +protected: + bool telemetryGpsDetected() const override { return gps_serial_open; } + bool telemetryGpsActive() const override { return gps_active; } + void telemetryGpsStart() override { start_gps(); } + void telemetryGpsStop() override { stop_gps(); } public: CardputerSensorManager(LocationProvider &location) : _location(&location) {} @@ -17,6 +27,13 @@ public: const char *getSettingName(int i) const override; const char *getSettingValue(int i) const override; bool setSettingValue(const char *name, const char *value) override; + void setPowerSavingEnabled(bool enabled) override; + bool isGPSDetected() const override { return gps_serial_open; } + // The cap has command-controlled standby, not a GPIO power switch. A + // pinless LocationProvider::isEnabled() cannot report its logical state. + bool isGpsPowered() const override { return gps_active; } + bool gpsUsesSerialUart(uint8_t uart) const override { return uart == 1 && gps_serial_open; } + void shutdownGpsTransportForDeepSleep() override; LocationProvider *getLocationProvider() override { return _location; } void start_gps(); void stop_gps(); diff --git a/variants/m5stack_cardputer_adv/README.md b/variants/m5stack_cardputer_adv/README.md index e33ee8853..c00e10bcf 100644 --- a/variants/m5stack_cardputer_adv/README.md +++ b/variants/m5stack_cardputer_adv/README.md @@ -69,6 +69,31 @@ Settings -> Text size cycles Normal (12 px), Large (16 px, default) and Extra large (19 px). The larger fonts are generated from DejaVu Sans with `tools/gen_vlw_font.py`. +Radio settings use the same live apply and save path as the USB terminal; +a reboot is not required. A busy radio applies the saved setting once idle. +Rejected settings or storage failures show an error +and retain the previous saved value. Display brightness, text size and battery +correction are saved separately from the standard node preferences. + +Screen wake and timeout follow the shared `display.mode` and `display.timeout` +settings for battery and USB power. Fresh installations use `automatic` mode +with a 15-second timeout; saved modes and timeouts are retained. +A key press wakes the screen for the selected timeout; the wake-up +press is consumed. The `automatic` mode also permits incoming messages to wake +the screen while no client is connected. Notification screen overrides and +muted notification outputs are respected. `set display.night auto|on|off` uses +the shared red-on-black palette without changing the saved daytime colors. +Automatic night mode uses GPS or recent direct-neighbor locations, starting +one hour after sunset by default. `set display.night.delay ` changes +that delay. + +Bluetooth pairing requests show the PIN for up to two minutes when the selected +screen mode permits it. Esc dismisses the prompt; the current chat and draft +remain selected. An explicit screen-off setting keeps the screen dark. + +The speaker plays fixed UI tones and honors notification sound output and mute +settings. Custom RTTTL notification melodies are unsupported on this board. + ## Third-party notices The keyboard/display UI is adapted from MultiMote's MIT-licensed project; @@ -83,6 +108,10 @@ neither dependency is bundled here. - Power save uses the companion's own light-sleep loop. It only sleeps while the screen is off and no USB or BLE client is connected. +- With GPS and power save enabled, the receiver sleeps after clock acquisition + or at the end of its 10-minute acquisition window, then wakes after 30 minutes. + Location telemetry can temporarily hold it awake under the common GPS policy. + Turning GPS off cancels current acquisitions; saved GPS intervals apply. - Below 3.4 V the UI shows "Battery low" and powers off (no LoRa wake). ## Building on Windows diff --git a/variants/m5stack_cardputer_adv/companion_radio/CardputerMesh.cpp b/variants/m5stack_cardputer_adv/companion_radio/CardputerMesh.cpp index 7bff4c4c3..d4b263c07 100644 --- a/variants/m5stack_cardputer_adv/companion_radio/CardputerMesh.cpp +++ b/variants/m5stack_cardputer_adv/companion_radio/CardputerMesh.cpp @@ -59,17 +59,14 @@ mesh::DispatcherAction CardputerMesh::onRecvPacket(mesh::Packet *pkt) { return action; } -void CardputerMesh::onChannelMessageRecv(const mesh::GroupChannel &channel, mesh::Packet *pkt, - uint32_t timestamp, const char *text) { - MyMesh::onChannelMessageRecv(channel, pkt, timestamp, text); - _store->storeMessage(channel.secret, text, false, true); +void CardputerMesh::onUiChannelMessage(const uint8_t secret[32], const char *text) { + mesh::GroupChannel channel{}; + memcpy(channel.secret, secret, sizeof(channel.secret)); + _store->storeMessage(secret, text, false, true); _ui->onChannelMessageRecv(channel, text); } -void CardputerMesh::onMessageRecv(const ContactInfo &from, mesh::Packet *pkt, uint32_t sender_timestamp, - const char *text) { - MyMesh::onMessageRecv(from, pkt, sender_timestamp, text); - +void CardputerMesh::onUiContactMessage(const ContactInfo &from, const char *text) { _store->storeMessage(from.id.pub_key, text, false, false); _ui->onContactMessageRecv(from, text); } @@ -92,19 +89,27 @@ bool CardputerMesh::sendFloodScoped(const mesh::GroupChannel &channel, mesh::Pac return sent; } -bool CardputerMesh::sendGroupMessage(uint32_t timestamp, mesh::GroupChannel &channel, const char *sender_name, - const char *text, int text_len) { - bool ok = MyMesh::sendGroupMessage(timestamp, channel, sender_name, text, text_len); - if (ok) { - _store->storeMessage(channel.secret, text, true, true); - } - return ok; +bool CardputerMesh::sendChannelMessage(int channel_idx, uint32_t timestamp, const char *text, size_t length) { + ChannelDetails channel; + if (!getChannel(channel_idx, channel) || !channel.name[0] + || !sendUiChannelMessage(channel_idx, timestamp, text, length)) return false; + char stored_text[MAX_MESSAGE_LENGTH + 1]; + const size_t stored_length = length < MAX_MESSAGE_LENGTH ? length : MAX_MESSAGE_LENGTH; + memcpy(stored_text, text, stored_length); + stored_text[stored_length] = 0; + _store->storeMessage(channel.channel.secret, stored_text, true, true); + return true; } int CardputerMesh::sendDirectMessage(const ContactInfo &recipient, uint32_t timestamp, const char *text) { - uint32_t est_timeout; + if (isMessageSending() || !text || !text[0] + || strnlen(text, MAX_MESSAGE_LENGTH + 1) > MAX_MESSAGE_LENGTH) return MSG_SEND_FAILED; + uint32_t est_timeout = 0, expected_ack = 0; + const int rc = MyMesh::sendMessage(recipient, timestamp, 0, text, expected_ack, est_timeout); + if (rc == MSG_SEND_FAILED) return rc; bool flood = (recipient.out_path_len == OUT_PATH_UNKNOWN); last_msg.timestamp = timestamp; + last_msg.expected_ack = expected_ack; last_msg.recipient = recipient; strncpy(last_msg.text, text, sizeof(last_msg.text)); last_msg.text[sizeof(last_msg.text) - 1] = 0; @@ -113,15 +118,9 @@ int CardputerMesh::sendDirectMessage(const ContactInfo &recipient, uint32_t time last_msg.attempt = 1; last_msg.total_attempts = DIRECT_SEND_FLOOD_ATTEMPTS + (!flood ? DIRECT_SEND_ROUTE_ATTEMPTS : 0); + const uint32_t minimum_timeout = flood ? DIRECT_SEND_FLOOD_RESEND_RELAY : DIRECT_SEND_ROUTE_RESEND_RELAY; + resend_timeout = futureMillis(est_timeout > minimum_timeout ? est_timeout : minimum_timeout); _ui->onMessageSendAttempt(last_msg.attempt, last_msg.total_attempts, last_msg.state); - - if (recipient.out_path_len == OUT_PATH_UNKNOWN) { - resend_timeout = futureMillis(DIRECT_SEND_FLOOD_RESEND_RELAY); - } else { - resend_timeout = futureMillis(DIRECT_SEND_ROUTE_RESEND_RELAY); - } - - int rc = MyMesh::sendMessage(recipient, timestamp, 0, text, last_msg.expected_ack, est_timeout); _store->storeMessage(recipient.id.pub_key, text, true, false); return rc; } @@ -152,17 +151,20 @@ bool CardputerMesh::processAck(const uint8_t *data, ContactInfo *&peer) { void CardputerMesh::beginCustom() { _store->loadCustomPrefs(_custom_prefs); + _saved_custom_prefs = _custom_prefs; } -void CardputerMesh::savePrefs() { - MyMesh::savePrefs(); - _store->saveCustomPrefs(_custom_prefs); +bool CardputerMesh::savePrefs() { + return MyMesh::savePrefs(); } -void CardputerMesh::setPowerSaving(bool enabled) { - getNodePrefs()->powersaving_enabled = enabled ? 1 : 0; - savePrefs(); - sensors.setPowerSavingEnabled(enabled); +bool CardputerMesh::saveCustomPrefs() { + if (!_store->saveCustomPrefs(_custom_prefs)) { + _custom_prefs = _saved_custom_prefs; + return false; + } + _saved_custom_prefs = _custom_prefs; + return true; } bool CardputerMesh::sendPing(ContactInfo &contact) { @@ -241,33 +243,44 @@ void CardputerMesh::checkResend() { return; } + ContactInfo *ref = lookupContactByPubKey(last_msg.recipient.id.pub_key, PUB_KEY_SIZE); + if (!ref || last_msg.attempt >= last_msg.total_attempts) { + cancelResending(); + return; + } + if (last_msg.need_direct && last_msg.attempt >= DIRECT_SEND_ROUTE_ATTEMPTS) { last_msg.need_direct = false; MESH_DEBUG_PRINTLN("Resetting path"); - ContactInfo *ref = the_mesh_cp.lookupContactByPubKey(last_msg.recipient.id.pub_key, CONTACT_LOOKUP_BYTES); - if (ref) { - ref->out_path_len = OUT_PATH_UNKNOWN; + const uint8_t previous_path_len = ref->out_path_len; + ref->out_path_len = OUT_PATH_UNKNOWN; + if (!queueUiContactWrite(*ref)) { + ref->out_path_len = previous_path_len; + cancelResending(); + return; } - last_msg.recipient.out_path_len = OUT_PATH_UNKNOWN; } + last_msg.recipient = *ref; + if (!last_msg.need_direct) last_msg.recipient.out_path_len = OUT_PATH_UNKNOWN; if (last_msg.attempt < last_msg.total_attempts) { - uint32_t est_timeout; - - _ui->onMessageSendAttempt(last_msg.attempt + 1, last_msg.total_attempts, last_msg.state); - - if (last_msg.recipient.out_path_len == OUT_PATH_UNKNOWN) { - resend_timeout = futureMillis(DIRECT_SEND_FLOOD_RESEND_RELAY); - } else { - resend_timeout = futureMillis(DIRECT_SEND_ROUTE_RESEND_RELAY); - } + uint32_t est_timeout = 0, expected_ack = 0; // expected_ack is derived from the attempt number, so it must be refreshed on every resend - MyMesh::sendMessage(last_msg.recipient, last_msg.timestamp, last_msg.attempt, last_msg.text, - last_msg.expected_ack, est_timeout); + const int rc = MyMesh::sendMessage(last_msg.recipient, last_msg.timestamp, last_msg.attempt, last_msg.text, + expected_ack, est_timeout); + if (rc == MSG_SEND_FAILED) { + cancelResending(); + return; + } + last_msg.expected_ack = expected_ack; + const uint32_t minimum_timeout = last_msg.recipient.out_path_len == OUT_PATH_UNKNOWN + ? DIRECT_SEND_FLOOD_RESEND_RELAY : DIRECT_SEND_ROUTE_RESEND_RELAY; + resend_timeout = futureMillis(est_timeout > minimum_timeout ? est_timeout : minimum_timeout); last_msg.attempt++; + _ui->onMessageSendAttempt(last_msg.attempt, last_msg.total_attempts, last_msg.state); } else { last_msg.state = MESSAGE_FAILED; _ui->onMessageSendAttempt(last_msg.attempt + 1, last_msg.total_attempts, last_msg.state); @@ -286,6 +299,7 @@ void CardputerMesh::loop() { void CardputerMesh::cancelResending() { resend_timeout = 0; + last_msg.expected_ack = 0; if (last_msg.state == MESSAGE_SENDING) { last_msg.state = MESSAGE_FAILED; _ui->onMessageSendAttempt(last_msg.total_attempts, last_msg.total_attempts, last_msg.state); diff --git a/variants/m5stack_cardputer_adv/companion_radio/CardputerMesh.h b/variants/m5stack_cardputer_adv/companion_radio/CardputerMesh.h index cc4608ee2..68230788e 100644 --- a/variants/m5stack_cardputer_adv/companion_radio/CardputerMesh.h +++ b/variants/m5stack_cardputer_adv/companion_radio/CardputerMesh.h @@ -18,6 +18,7 @@ class CardputerMesh : public MyMesh { uint8_t last_message_hash[MAX_HASH_SIZE] = { 0 }; uint16_t last_message_heard_repeats = 0; CustomNodePrefs _custom_prefs{ .battery_correction = 1.0f, .brightness = 128, .text_level = 1 }; + CustomNodePrefs _saved_custom_prefs = _custom_prefs; LastSentMessage last_msg; unsigned long resend_timeout = 0; @@ -30,16 +31,12 @@ public: const uint8_t *path_snrs, const uint8_t *path_hashes, uint8_t path_len) override; void onControlDataRecv(mesh::Packet *packet) override; mesh::DispatcherAction onRecvPacket(mesh::Packet *pkt) override; - void onChannelMessageRecv(const mesh::GroupChannel &channel, mesh::Packet *pkt, uint32_t timestamp, - const char *text) override; - void onMessageRecv(const ContactInfo &from, mesh::Packet *pkt, uint32_t sender_timestamp, - const char *text) override; + void onUiChannelMessage(const uint8_t secret[32], const char *text) override; + void onUiContactMessage(const ContactInfo &from, const char *text) override; bool sendFloodScoped(const mesh::GroupChannel &channel, mesh::Packet *pkt, uint32_t delay_millis = 0) override; using MyMesh::sendFloodScoped; - // not virtual; using function hiding - bool sendGroupMessage(uint32_t timestamp, mesh::GroupChannel &channel, const char *sender_name, - const char *text, int text_len); + bool sendChannelMessage(int channel_idx, uint32_t timestamp, const char *text, size_t length); int sendDirectMessage(const ContactInfo &recipient, uint32_t timestamp, const char *text); @@ -53,9 +50,8 @@ public: // Loads Cardputer UI prefs; call after MyMesh::begin(). void beginCustom(); - void savePrefs(); - // Same effect as the CLI's "powersaving on|off". - void setPowerSaving(bool enabled); + bool savePrefs(); + bool saveCustomPrefs(); bool sendPing(ContactInfo &contact); bool sendAdvert(bool flood); bool sendRepeatersDiscover(); diff --git a/variants/m5stack_cardputer_adv/companion_radio/ui-cardputer/CardputerUITask.cpp b/variants/m5stack_cardputer_adv/companion_radio/ui-cardputer/CardputerUITask.cpp index eb7d50592..2fcbea56b 100644 --- a/variants/m5stack_cardputer_adv/companion_radio/ui-cardputer/CardputerUITask.cpp +++ b/variants/m5stack_cardputer_adv/companion_radio/ui-cardputer/CardputerUITask.cpp @@ -8,12 +8,13 @@ #include "target.h" #include "helpers.h" #include "CompanionBluetooth.h" +#include +#include void CardputerUITask::begin(DisplayDriver *display, SensorManager *sensors, CompanionNodePrefs *node_prefs, CustomNodePrefs *custom_prefs) { _display = static_cast(display); _sensors = sensors; - extendAutoOff(); _node_prefs = node_prefs; _custom_prefs = custom_prefs; @@ -21,7 +22,7 @@ void CardputerUITask::begin(DisplayDriver *display, SensorManager *sensors, Comp if (_display != NULL) { _display->setBrightness(_custom_prefs->brightness); _display->setTextLevel(_custom_prefs->text_level); - _display->turnOn(); + serviceDisplayPower(); } _top_btn.begin(); @@ -51,7 +52,8 @@ bool CardputerUITask::isAlertActive() { } void CardputerUITask::notify(UIEventType t) { - if (t == UIEventType::channelMessage || t == UIEventType::contactMessage) { + if ((_notification_outputs & mesh::notify::Sound) && !_message_notification_override + && (t == UIEventType::channelMessage || t == UIEventType::contactMessage)) { playSound(SoundType::NewMessage); } } @@ -85,24 +87,89 @@ void CardputerUITask::msgRead(int msgcount) { void CardputerUITask::newMsg(uint8_t path_len, const char *from_name, const char *text, int msgcount, int channel_idx, const char *channel_name, int queue_index) { unsynced_msg_count = msgcount; + const bool on_first_page = current_screen == main_screen + && static_cast(main_screen)->getCurrentPage() == MainScreen::FIRST; + const bool display_off = _display && !_display->isOn(); - bool onFirstPage = - (current_screen == main_screen && ((MainScreen *)main_screen)->getCurrentPage() == MainScreen::FIRST); - - bool display_off = (_display != NULL && !_display->isOn()); - - if (display_off || current_screen == new_message_screen || onFirstPage) { - ((NewMessageScreen *)new_message_screen)->newMessage(from_name, text); + // Keep a connected client's screen stable; managed notifications own wake. + const bool show_preview = (_notification_outputs & mesh::notify::Screen) + && !_message_notification_screen_override && !hasConnection() && !isPairingPromptActive(); + if (show_preview && (display_off || current_screen == new_message_screen || on_first_page)) { + static_cast(new_message_screen)->newMessage(from_name, text); setCurrScreen(new_message_screen); } + if (_display && shouldWakeDisplayForMessage()) { + _display->wake(mesh::ui::DisplayWake::Message); + } + scheduleRefresh(); +} - if (_display != NULL) { - if (!_display->isOn() && !hasConnection()) { - _display->turnOn(); - } - extendAutoOff(); +uint8_t CardputerUITask::notificationCapabilities() const { + // The codec's fixed tones do not implement custom RTTTL notification rules. + return _display ? mesh::notify::Screen : 0; +} + +void CardputerUITask::notificationScreen(int8_t mode) { + if (!_display) return; + _display->notificationPower(mode); + scheduleRefresh(); +} + +void CardputerUITask::serviceDisplayPower() { + if (!_display) return; + if (_display->servicePower(_board->isExternalPowered() || _board->isUsbHostConnected(), + hasConnection(), isPairingPromptActive())) { + scheduleRefresh(); + if (!_display->isOn()) _board->getSpeaker()->sleep(); + } +} + +bool CardputerUITask::isPairingPromptActive() const { + return _display && mesh::ui::shouldDisplayBluetoothPairingPin( + isBluetoothEnabled(), hasBluetoothConnection(), the_mesh_cp.getBLEPin()) + && mesh::ui::isBluetoothPairingPromptActive(isBluetoothEnabled(), hasBluetoothConnection(), + pairing_prompt_until, static_cast(millis())); +} + +void CardputerUITask::servicePairingState() { + const uint32_t now = static_cast(millis()); + if (_interfaceManager->takePairingRequest() && _display + && mesh::ui::shouldDisplayBluetoothPairingPin( + isBluetoothEnabled(), hasBluetoothConnection(), the_mesh_cp.getBLEPin())) { + pairing_prompt_until = now + 120000U; + if (pairing_prompt_until == 0) pairing_prompt_until = 1; scheduleRefresh(); } + if (pairing_prompt_until != 0 && !isPairingPromptActive()) { + pairing_prompt_until = 0; + scheduleRefresh(); + } + serviceDisplayPower(); +} + +bool CardputerUITask::handlePairingInput(const Keyboard::Event &event) { + if (!isPairingPromptActive()) return false; + if (event.key == Keyboard::KEY_ESC) { + pairing_prompt_until = 0; + serviceDisplayPower(); + turnDisplayOn(); + } + return true; +} + +void CardputerUITask::renderPairingPrompt() { + if (!_display || !_display->isOn() || !isPairingPromptActive()) return; + char pin[16]; + snprintf(pin, sizeof(pin), "%06u", static_cast(the_mesh_cp.getBLEPin())); + _display->setColor(CardputerDisplay::P_BLACK); + _display->fillRect(0, 0, _display->width(), _display->height()); + _display->setColor(CardputerDisplay::P_WHITE); + _display->setTextSize(1); + _display->drawTextCentered(_display->width() / 2, 22, "Bluetooth pairing PIN"); + _display->setTextSize(2); + _display->drawTextCentered(_display->width() / 2, 52, pin); + _display->setTextSize(1); + _display->drawTextCentered(_display->width() / 2, 94, "Enter PIN on phone"); } void CardputerUITask::onChannelMessageRecv(const mesh::GroupChannel &channel, const char *text) { @@ -114,8 +181,6 @@ void CardputerUITask::onContactMessageRecv(const ContactInfo &contact, const cha } void CardputerUITask::onMessageSendAttempt(uint8_t attempt, uint8_t total, MessageSendState state) { - extendAutoOff(); - if (current_screen == main_screen) { static_cast(main_screen)->onMessageSendAttempt(attempt, total, state); } @@ -148,6 +213,10 @@ void CardputerUITask::setCurrScreen(CardputerScreen *c) { hardware-agnostic pre-shutdown activity should be done here */ void CardputerUITask::shutdown(bool restart) { + if (!prepareForShutdown()) { + showAlert("Shutdown blocked: update in progress", 2000); + return; + } if (restart) { _board->reboot(); } else { @@ -158,13 +227,14 @@ void CardputerUITask::shutdown(bool restart) { } void CardputerUITask::loop() { + servicePairingState(); _board->getSpeaker()->processQueue(); CardputerKeyboard *kb = _board->getKeyboard(); const Keyboard::Modifiers latched = kb->getModifiers(); auto event = kb->poll(); - if (event.changed && event.down) { + if (event.changed && event.down && !handlePairingInput(event)) { if (turnDisplayOn()) { kb->setModifiers(latched); // a wake-up press must not flip Shift/Fn } else { @@ -175,7 +245,7 @@ void CardputerUITask::loop() { } } - if (_top_btn.check() == BUTTON_EVENT_CLICK) { + if (_top_btn.check() == BUTTON_EVENT_CLICK && !isPairingPromptActive()) { turnDisplayOn(); sendFloodAdvert(); } @@ -197,14 +267,9 @@ void CardputerUITask::loop() { } else { scheduleRefresh(delay_millis); } + renderPairingPrompt(); _display->endFrame(); } -#if AUTO_OFF_MILLIS > 0 - if (millis() > auto_off_time) { - _display->turnOff(); - _board->getSpeaker()->sleep(); - } -#endif } #ifdef AUTO_SHUTDOWN_MILLIVOLTS @@ -228,53 +293,35 @@ void CardputerUITask::loop() { } bool CardputerUITask::turnDisplayOn() { - if (_display != NULL) { - extendAutoOff(); - scheduleRefresh(); - - if (!_display->isOn()) { - _display->turnOn(); - return true; - } - } - return false; + if (!_display) return false; + const bool was_off = !_display->isOn(); + _display->wake(mesh::ui::DisplayWake::Button); + scheduleRefresh(); + return was_off; } bool CardputerUITask::getGPSState() { - if (_sensors != NULL) { - int num = _sensors->getNumSettings(); - for (int i = 0; i < num; i++) { - if (strcmp(_sensors->getSettingName(i), "gps") == 0) { - return !strcmp(_sensors->getSettingValue(i), "1"); - } - } - } - return false; + return _node_prefs && _node_prefs->gps_enabled != 0; } -void CardputerUITask::toggleGPS() { - if (_sensors != NULL) { - int num = _sensors->getNumSettings(); - for (int i = 0; i < num; i++) { - if (strcmp(_sensors->getSettingName(i), "gps") == 0) { - if (strcmp(_sensors->getSettingValue(i), "1") == 0) { - _sensors->setSettingValue("gps", "0"); - _node_prefs->gps_enabled = 0; - } else { - _sensors->setSettingValue("gps", "1"); - _node_prefs->gps_enabled = 1; - } - the_mesh_cp.savePrefs(); - scheduleRefresh(); - break; - } - } +bool CardputerUITask::toggleGPS() { + if (!the_mesh_cp.setGpsEnabled(!getGPSState())) { + showAlert("GPS setting could not be saved", 2000); + return false; } + scheduleRefresh(); + return true; } -void CardputerUITask::toggleBuzzer() { - _node_prefs->buzzer_quiet = !_node_prefs->buzzer_quiet; - the_mesh_cp.savePrefs(); +bool CardputerUITask::toggleBuzzer() { + const uint8_t previous = _node_prefs->buzzer_quiet; + _node_prefs->buzzer_quiet = !previous; + if (!the_mesh_cp.savePrefs()) { + _node_prefs->buzzer_quiet = previous; + showAlert("Sound setting could not be saved", 2000); + return false; + } + return true; } void CardputerUITask::renderAlert() { @@ -344,10 +391,14 @@ void CardputerUITask::toggleBluetooth() { } void CardputerUITask::cycleTextSize() { - uint8_t next = (_display->getTextLevel() + 1) % CardputerDisplay::TEXT_LEVEL_COUNT; - _display->setTextLevel(next); - _custom_prefs->text_level = next; - the_mesh_cp.savePrefs(); + if (!_display) return; + _custom_prefs->text_level = (_display->getTextLevel() + 1) % CardputerDisplay::TEXT_LEVEL_COUNT; + if (!the_mesh_cp.saveCustomPrefs()) { + showAlert("Text size could not be saved", 2000); + return; + } + _display->setTextLevel(_custom_prefs->text_level); + scheduleRefresh(); } void CardputerUITask::sendFloodAdvert() { @@ -355,6 +406,7 @@ void CardputerUITask::sendFloodAdvert() { } void CardputerUITask::cycleBrightness() { + if (!_display) return; static const uint8_t levels[] = { 32, 96, 160, 255 }; uint8_t next = levels[0]; for (uint8_t level : levels) { @@ -364,8 +416,11 @@ void CardputerUITask::cycleBrightness() { } } _custom_prefs->brightness = next; + if (!the_mesh_cp.saveCustomPrefs()) { + showAlert("Brightness could not be saved", 2000); + return; + } _display->setBrightness(next); - the_mesh_cp.savePrefs(); char buf[24]; snprintf(buf, sizeof(buf), "Brightness %d%%", next * 100 / 255); showAlert(buf, 1500); @@ -384,19 +439,16 @@ bool CardputerUITask::handleShortcut(const Keyboard::Event &e) { sendFloodAdvert(); return true; case Keyboard::KEY_G: // GPS - toggleGPS(); - showAlert(getGPSState() ? "GPS ON" : "GPS OFF", 1500); + if (toggleGPS()) showAlert(getGPSState() ? "GPS ON" : "GPS OFF", 1500); return true; case Keyboard::KEY_B: // Bluetooth toggleBluetooth(); return true; case Keyboard::KEY_M: // Mute - toggleBuzzer(); - showAlert(isBuzzerQuiet() ? "Sound OFF" : "Sound ON", 1500); + if (toggleBuzzer()) showAlert(isBuzzerQuiet() ? "Sound OFF" : "Sound ON", 1500); return true; case Keyboard::KEY_P: // Power save - togglePowerSave(); - showAlert(powerSaveEnabled() ? "Power save ON" : "Power save OFF", 1500); + if (togglePowerSave()) showAlert(powerSaveEnabled() ? "Power save ON" : "Power save OFF", 1500); return true; case Keyboard::KEY_L: // Light cycleBrightness(); @@ -443,6 +495,12 @@ bool CardputerUITask::handleShortcut(const Keyboard::Event &e) { } } -void CardputerUITask::togglePowerSave() { - the_mesh_cp.setPowerSaving(!_node_prefs->powersaving_enabled); +bool CardputerUITask::togglePowerSave() { + char reply[160] = {}; + const char *command = _node_prefs->powersaving_enabled ? "powersaving off" : "powersaving on"; + if (!the_mesh_cp.handleLocalCommand(command, reply) || strncmp(reply, "OK", 2) != 0) { + showAlert(reply[0] ? reply : "Power save setting failed", 2000); + return false; + } + return true; } diff --git a/variants/m5stack_cardputer_adv/companion_radio/ui-cardputer/CardputerUITask.h b/variants/m5stack_cardputer_adv/companion_radio/ui-cardputer/CardputerUITask.h index b6c04f503..711a762c4 100644 --- a/variants/m5stack_cardputer_adv/companion_radio/ui-cardputer/CardputerUITask.h +++ b/variants/m5stack_cardputer_adv/companion_radio/ui-cardputer/CardputerUITask.h @@ -23,13 +23,12 @@ class CardputerUITask : public AbstractUITask { CompanionNodePrefs *_node_prefs; CustomNodePrefs *_custom_prefs; unsigned long next_refresh = 0; - unsigned long auto_off_time; - bool sleep_enabled = false; char alert_text[80]; unsigned long alert_expiry = 0; int unsynced_msg_count = 0; unsigned long ui_started_at = 0; unsigned long next_batt_chck = 0; + uint32_t pairing_prompt_until = 0; CardputerScreen *splash_screen; CardputerScreen *main_screen; @@ -46,6 +45,9 @@ class CardputerUITask : public AbstractUITask { void renderAlert(); void sendFloodAdvert(); void cycleBrightness(); + void serviceDisplayPower(); + void renderPairingPrompt(); + bool handlePairingInput(const Keyboard::Event &event); // Returns false if display is already on bool turnDisplayOn(); @@ -72,13 +74,12 @@ public: inline bool isBuzzerQuiet() { return _node_prefs->buzzer_quiet; } inline const char* getNodeName() { return _node_prefs->node_name; } inline uint16_t getBattMilliVoltsCorrected() { return (float)getBattMilliVolts() * _custom_prefs->battery_correction; } - void toggleBuzzer(); + bool toggleBuzzer(); void toggleBluetooth(); void cycleTextSize(); - void togglePowerSave(); + bool togglePowerSave(); bool getGPSState(); - void toggleGPS(); - inline unsigned long getAutoOffTime() const { return auto_off_time; } + bool toggleGPS(); inline bool powerSaveEnabled() const { return _node_prefs->powersaving_enabled; } // Light sleep stops the backlight PWM and I2S audio; keep the CPU awake // while the screen is on or a tone is queued. @@ -95,10 +96,13 @@ public: void discoverRecv(const mesh::Identity &id, float snr); void messageRepeatsRecv(uint16_t count); void notify(UIEventType t = UIEventType::none) override; + uint8_t notificationCapabilities() const override; + void notificationScreen(int8_t mode) override; + void servicePairingState() override; + bool isPairingPromptActive() const override; void playSound(SoundType t); void loop() override; void shutdown(bool restart = false); CardputerAdvBoard *getBoard() { return _board; } - inline void extendAutoOff() { auto_off_time = millis() + AUTO_OFF_MILLIS; } inline void scheduleRefresh(unsigned long ms = 0) { next_refresh = millis() + ms; } }; diff --git a/variants/m5stack_cardputer_adv/companion_radio/ui-cardputer/screen/SettingsScreen.cpp b/variants/m5stack_cardputer_adv/companion_radio/ui-cardputer/screen/SettingsScreen.cpp index 39111e75b..17246b783 100644 --- a/variants/m5stack_cardputer_adv/companion_radio/ui-cardputer/screen/SettingsScreen.cpp +++ b/variants/m5stack_cardputer_adv/companion_radio/ui-cardputer/screen/SettingsScreen.cpp @@ -179,103 +179,102 @@ void SettingsScreen::cancelItemEdit(SettingsItem item) { is_editing = false; } +bool SettingsScreen::commitCommand(const char *command) { + char reply[160] = {}; + if (the_mesh_cp.handleLocalCommand(command, reply) && strncmp(reply, "OK", 2) == 0) { + if (strstr(reply, "pending")) _task->showAlert(reply, 2000); + return true; + } + snprintf(edit_error, sizeof(edit_error), "%.*s", static_cast(sizeof(edit_error) - 1), + reply[0] ? reply : "Setting could not be saved"); + return false; +} + +bool SettingsScreen::commitRadio(float freq, float bw, uint8_t sf, uint8_t cr) { + if (!validRadioParams(freq, bw, sf, cr)) return false; + char command[112]; + snprintf(command, sizeof(command), "set radio %.6f,%.6f,%u,%u,%u", freq, bw, sf, cr, + the_mesh_cp.getPrimaryRadioPreamble()); + if (!commitCommand(command)) return false; + _task->showAlert("Radio saved; applies when idle", 2000); + return true; +} + bool SettingsScreen::commitItemEdit(SettingsItem item) { - + snprintf(edit_error, sizeof(edit_error), "Invalid value"); switch (item) { - case SettingsItem::PublicInfoName: - strncpy(_node_prefs->node_name, edit_buffer.c_str(), sizeof(_node_prefs->node_name) - 1); - _node_prefs->node_name[sizeof(_node_prefs->node_name) - 1] = '\0'; - the_mesh_cp.savePrefs(); - return true; + case SettingsItem::PublicInfoName: { + char command[sizeof(_node_prefs->node_name) + 10]; + if (edit_buffer.length() >= sizeof(_node_prefs->node_name)) return false; + snprintf(command, sizeof(command), "set name %s", edit_buffer.c_str()); + return commitCommand(command); + } - case SettingsItem::MeshDefaultScope: + case SettingsItem::MeshDefaultScope: { + char previous_name[sizeof(_node_prefs->default_scope_name)]; + uint8_t previous_key[sizeof(_node_prefs->default_scope_key)]; + memcpy(previous_name, _node_prefs->default_scope_name, sizeof(previous_name)); + memcpy(previous_key, _node_prefs->default_scope_key, sizeof(previous_key)); memset(_node_prefs->default_scope_name, 0, sizeof(_node_prefs->default_scope_name)); memset(_node_prefs->default_scope_key, 0, sizeof(_node_prefs->default_scope_key)); - if (edit_buffer.length() > 0) { - strncpy(_node_prefs->default_scope_name, edit_buffer.c_str(), - sizeof(_node_prefs->default_scope_name) - 1); - _node_prefs->default_scope_name[sizeof(_node_prefs->default_scope_name) - 1] = '\0'; - + strncpy(_node_prefs->default_scope_name, edit_buffer.c_str(), sizeof(_node_prefs->default_scope_name) - 1); const uint8_t prefix[] = "#"; - mesh::Utils::sha256(_node_prefs->default_scope_key, sizeof(_node_prefs->default_scope_key), prefix, 1, reinterpret_cast(_node_prefs->default_scope_name), - edit_buffer.length()); + strlen(_node_prefs->default_scope_name)); } - - the_mesh_cp.savePrefs(); - return true; + if (the_mesh_cp.savePrefs()) return true; + memcpy(_node_prefs->default_scope_name, previous_name, sizeof(previous_name)); + memcpy(_node_prefs->default_scope_key, previous_key, sizeof(previous_key)); + snprintf(edit_error, sizeof(edit_error), "Scope could not be saved"); + return false; + } case SettingsItem::RadioSf: - if (!validRadioParams(_node_prefs->freq, _node_prefs->bw, edit_u8, _node_prefs->cr)) return false; - _node_prefs->sf = edit_u8; - the_mesh_cp.savePrefs(); - restart_required = true; - return true; - + return commitRadio(_node_prefs->freq, _node_prefs->bw, edit_u8, _node_prefs->cr); case SettingsItem::RadioCr: - if (!validRadioParams(_node_prefs->freq, _node_prefs->bw, _node_prefs->sf, edit_u8)) return false; - _node_prefs->cr = edit_u8; - the_mesh_cp.savePrefs(); - restart_required = true; - return true; - - case SettingsItem::RadioPwr: + return commitRadio(_node_prefs->freq, _node_prefs->bw, _node_prefs->sf, edit_u8); + case SettingsItem::RadioPwr: { if (!radio_driver.isTxPowerValid(static_cast(edit_u8))) return false; - _node_prefs->tx_power_dbm = edit_u8; - the_mesh_cp.savePrefs(); - restart_required = true; - return true; - - case SettingsItem::MeshPathSize: + char command[24]; + snprintf(command, sizeof(command), "set tx %d", static_cast(edit_u8)); + return commitCommand(command); + } + case SettingsItem::MeshPathSize: { + if (edit_u8 < 1 || edit_u8 > 3) return false; + const uint8_t previous = _node_prefs->path_hash_mode; _node_prefs->path_hash_mode = edit_u8 - 1; - the_mesh_cp.savePrefs(); - return true; - + if (the_mesh_cp.savePrefs()) return true; + _node_prefs->path_hash_mode = previous; + snprintf(edit_error, sizeof(edit_error), "Path size could not be saved"); + return false; + } case SettingsItem::RadioFreq: { float value; - if (inputParsePositiveFloat(value) - && validRadioParams(value, _node_prefs->bw, _node_prefs->sf, _node_prefs->cr)) { - _node_prefs->freq = value; - the_mesh_cp.savePrefs(); - restart_required = true; - return true; - } - break; + return inputParsePositiveFloat(value) + && commitRadio(value, _node_prefs->bw, _node_prefs->sf, _node_prefs->cr); } - case SettingsItem::RadioBw: { float value; - if (inputParsePositiveFloat(value) - && validRadioParams(_node_prefs->freq, value, _node_prefs->sf, _node_prefs->cr)) { - _node_prefs->bw = value; - the_mesh_cp.savePrefs(); - restart_required = true; - return true; - } - break; + return inputParsePositiveFloat(value) + && commitRadio(_node_prefs->freq, value, _node_prefs->sf, _node_prefs->cr); + } + case SettingsItem::DeviceBatteryCorrection: { + if (!edit_buffer.length()) return false; + const uint16_t real = edit_buffer.toInt(); + const uint16_t adc = _task->getBoard()->getBattMilliVolts(); + if (!real || !adc) return false; + const float correction = static_cast(real) / adc; + if (!isfinite(correction) || correction < 0.5f || correction > 2.0f) return false; + _custom_prefs->battery_correction = correction; + if (the_mesh_cp.saveCustomPrefs()) return true; + snprintf(edit_error, sizeof(edit_error), "Battery correction could not be saved"); + return false; } - - case SettingsItem::DeviceBatteryCorrection: - - if (edit_buffer.length() > 0) { - uint16_t real = edit_buffer.toInt(); - uint16_t adc = _task->getBoard()->getBattMilliVolts(); - if (adc != 0) { - _custom_prefs->battery_correction = (float)real / adc; - the_mesh_cp.savePrefs(); - return true; - } - } - - break; - default: - break; + return false; } - - return false; } void SettingsScreen::handleEditInput(SettingsItem item, Keyboard::Event &e) { @@ -388,13 +387,8 @@ int SettingsScreen::render(CardputerDisplay &display) { const int item_x = 5 + display.getTextWidth(">") + 6; display.setTextSize(1); - if (restart_required) { - display.setColor(CardputerDisplay::P_RED); - display.drawTextCentered(display.width() / 2, title_y, "Settings | Restart"); - } else { - display.setColor(CardputerDisplay::P_GREEN); - display.drawTextCentered(display.width() / 2, title_y, "Settings"); - } + display.setColor(CardputerDisplay::P_GREEN); + display.drawTextCentered(display.width() / 2, title_y, "Settings"); int real_idx = 0; int list_page = menu_index / rows; @@ -471,7 +465,7 @@ bool SettingsScreen::handleInput(Keyboard::Event &e) { if (is_editing) { if (e.key == Keyboard::KEY_RETURN) { if (!commitItemEdit(static_cast(menu_index))) { - _task->showAlert("Invalid value", 1000); + _task->showAlert(edit_error, 2000); } else { is_editing = false; } diff --git a/variants/m5stack_cardputer_adv/companion_radio/ui-cardputer/screen/SettingsScreen.h b/variants/m5stack_cardputer_adv/companion_radio/ui-cardputer/screen/SettingsScreen.h index bffd5cf42..4b41b7e8a 100644 --- a/variants/m5stack_cardputer_adv/companion_radio/ui-cardputer/screen/SettingsScreen.h +++ b/variants/m5stack_cardputer_adv/companion_radio/ui-cardputer/screen/SettingsScreen.h @@ -32,7 +32,7 @@ class SettingsScreen : public CardputerScreen { int menu_index = 0; bool is_editing = false; - bool restart_required = false; + char edit_error[80] = "Invalid value"; String edit_buffer; uint8_t edit_u8; static constexpr size_t MAX_NUMERIC_EDIT_BYTES = 31; @@ -44,6 +44,8 @@ class SettingsScreen : public CardputerScreen { void handleEditInput(SettingsItem item, Keyboard::Event &e); bool inputParsePositiveFloat(float &val); bool validRadioParams(float freq, float bw, uint8_t sf, uint8_t cr) const; + bool commitCommand(const char *command); + bool commitRadio(float freq, float bw, uint8_t sf, uint8_t cr); public: SettingsScreen(CardputerUITask *task, mesh::RTCClock *rtc, CompanionNodePrefs *node_prefs, diff --git a/variants/m5stack_cardputer_adv/companion_radio/ui-cardputer/screen/main/ChatPage.cpp b/variants/m5stack_cardputer_adv/companion_radio/ui-cardputer/screen/main/ChatPage.cpp index bc9d34247..e4c9ea46e 100644 --- a/variants/m5stack_cardputer_adv/companion_radio/ui-cardputer/screen/main/ChatPage.cpp +++ b/variants/m5stack_cardputer_adv/companion_radio/ui-cardputer/screen/main/ChatPage.cpp @@ -3,6 +3,8 @@ #include "helpers.h" int ChatPage::render(CardputerDisplay &lcd) { + refreshSelectedContact(); + refreshSelectedChannel(); char buf[64]; const int row = display.getRowHeight(); const int line_h = display.getFontLineHeight(); // advance used when print() wraps @@ -101,7 +103,9 @@ void ChatPage::sendChatMessage() { return; } - uint32_t ts = the_mesh_cp.getRTCClock()->getCurrentTime(); + refreshSelectedContact(); + refreshSelectedChannel(); + uint32_t ts = the_mesh_cp.getRTCClock()->getCurrentTimeUnique(); if (current_contact_idx != -1 && current_contact.type == ADV_TYPE_CHAT) { // direct msg int result = the_mesh_cp.sendDirectMessage(current_contact, ts, chat_text_box.c_str()); @@ -114,14 +118,16 @@ void ChatPage::sendChatMessage() { last_sent_message = chat_text_box; chat_text_box.clear(); chat_history_offset = 0; + } else { + _p->getUiTask()->showAlert("Unable to send message", 2000); } return; } if (current_channel_idx != -1) { // channel msg - bool ok = the_mesh_cp.sendGroupMessage(ts, current_channel.channel, _p->getUiTask()->getNodeName(), - chat_text_box.c_str(), chat_text_box.length()); + bool ok = the_mesh_cp.sendChannelMessage(current_channel_idx, ts, + chat_text_box.c_str(), chat_text_box.length()); if (ok) { HistoryMessage *msg = chat_history.push_ref(); msg->out = true; @@ -132,6 +138,8 @@ void ChatPage::sendChatMessage() { chat_text_box.clear(); chat_history_offset = 0; _p->getUiTask()->showAlert("Waiting for repeats...", 2000); + } else { + _p->getUiTask()->showAlert("Unable to send message", 2000); } return; } @@ -181,7 +189,7 @@ bool ChatPage::handleInput(Keyboard::Event &e) { } if (e.key == Keyboard::ARROW_UP) { - if (chat_history_offset < chat_history.count() - 1) { + if (chat_history.count() > 0 && chat_history_offset < (int)chat_history.count() - 1) { chat_history_offset++; } return true; @@ -212,6 +220,7 @@ void ChatPage::selectContact(const ContactInfo &contact, int idx) { current_channel_idx = -1; the_mesh_cp.loadMessageHistory(contact.id.pub_key, false, chat_history); chat_history_offset = 0; + last_sent_message = ""; const HistoryMessage *msg; for (size_t i = chat_history.count(); i > 0; i--) { @@ -228,6 +237,7 @@ void ChatPage::selectChannel(const ChannelDetails &channel, int idx) { current_contact_idx = -1; the_mesh_cp.loadMessageHistory(channel.channel.secret, true, chat_history); chat_history_offset = 0; + last_sent_message = ""; const HistoryMessage *msg; for (size_t i = chat_history.count(); i > 0; i--) { @@ -241,7 +251,7 @@ void ChatPage::selectChannel(const ChannelDetails &channel, int idx) { bool ChatPage::onChannelMessageRecv(const mesh::GroupChannel &channel, const char *text) { if (current_channel_idx != -1 && - memcmp(channel.secret, current_channel.channel.secret, CONTACT_LOOKUP_BYTES) == 0) { + memcmp(channel.secret, current_channel.channel.secret, PUB_KEY_SIZE) == 0) { HistoryMessage *msg = chat_history.push_ref(); msg->out = false; snprintf(msg->text, MAX_MESSAGE_LENGTH, "%s", text); @@ -253,7 +263,7 @@ bool ChatPage::onChannelMessageRecv(const mesh::GroupChannel &channel, const cha bool ChatPage::onContactMessageRecv(const ContactInfo &contact, const char *text) { if (current_contact_idx != -1 && - memcmp(contact.id.pub_key, current_contact.id.pub_key, CONTACT_LOOKUP_BYTES) == 0) { + memcmp(contact.id.pub_key, current_contact.id.pub_key, PUB_KEY_SIZE) == 0) { HistoryMessage *msg = chat_history.push_ref(); msg->out = false; snprintf(msg->text, MAX_MESSAGE_LENGTH, "%s", text); @@ -265,9 +275,31 @@ bool ChatPage::onContactMessageRecv(const ContactInfo &contact, const char *text void ChatPage::refreshSelectedContact() { if (current_contact_idx != -1) { - ContactInfo c; - if (the_mesh_cp.getContactByIdx(current_contact_idx, c)) { - current_contact = c; + ContactInfo *contact = the_mesh_cp.lookupPersistentContactByPubKey(current_contact.id.pub_key, PUB_KEY_SIZE); + if (contact) { + current_contact = *contact; + } else { + current_contact_idx = -1; + current_contact = ContactInfo{}; + chat_history.clear(); + chat_history_offset = 0; + last_sent_message = ""; } } } + +bool ChatPage::refreshSelectedChannel() { + if (current_channel_idx == -1) return false; + ChannelDetails channel; + if (!the_mesh_cp.getChannel(current_channel_idx, channel) || !channel.name[0] + || memcmp(channel.channel.secret, current_channel.channel.secret, PUB_KEY_SIZE) != 0) { + current_channel_idx = -1; + current_channel = ChannelDetails{}; + chat_history.clear(); + chat_history_offset = 0; + last_sent_message = ""; + return false; + } + current_channel = channel; + return true; +} diff --git a/variants/m5stack_cardputer_adv/companion_radio/ui-cardputer/screen/main/ChatPage.h b/variants/m5stack_cardputer_adv/companion_radio/ui-cardputer/screen/main/ChatPage.h index a6d42765a..431561bf5 100644 --- a/variants/m5stack_cardputer_adv/companion_radio/ui-cardputer/screen/main/ChatPage.h +++ b/variants/m5stack_cardputer_adv/companion_radio/ui-cardputer/screen/main/ChatPage.h @@ -20,6 +20,7 @@ private: int chat_history_offset = 0; // from bottom void sendChatMessage(); + bool refreshSelectedChannel(); public: ChatPage(MainScreen *parent) : _p(parent) { chat_text_box.reserve(MAX_MESSAGE_LENGTH); }; diff --git a/variants/m5stack_cardputer_adv/companion_radio/ui-cardputer/screen/main/ContactsPage.cpp b/variants/m5stack_cardputer_adv/companion_radio/ui-cardputer/screen/main/ContactsPage.cpp index 57c3b19d5..0f04a55ad 100644 --- a/variants/m5stack_cardputer_adv/companion_radio/ui-cardputer/screen/main/ContactsPage.cpp +++ b/variants/m5stack_cardputer_adv/companion_radio/ui-cardputer/screen/main/ContactsPage.cpp @@ -167,10 +167,18 @@ bool ContactsPage::handleInput(Keyboard::Event &e) { if (the_mesh_cp.getContactByIdx(real_idx, c)) { if (c.type == ADV_TYPE_CHAT) { // getContactByIdx does not return a reference - ContactInfo *ref = the_mesh_cp.lookupContactByPubKey(c.id.pub_key, CONTACT_LOOKUP_BYTES); - ref->out_path_len = OUT_PATH_UNKNOWN; - _p->getUiTask()->showAlert("Path cleared", 1000); - _p->refreshSelectedContact(); + ContactInfo *ref = the_mesh_cp.lookupPersistentContactByPubKey(c.id.pub_key, PUB_KEY_SIZE); + if (ref) { + const uint8_t previous_path_len = ref->out_path_len; + ref->out_path_len = OUT_PATH_UNKNOWN; + if (the_mesh_cp.queueUiContactWrite(*ref)) { + _p->getUiTask()->showAlert("Path cleared", 1000); + _p->refreshSelectedContact(); + } else { + ref->out_path_len = previous_path_len; + _p->getUiTask()->showAlert("Unable to save path", 2000); + } + } } } } diff --git a/variants/m5stack_cardputer_adv/hw/CardputerDisplay.cpp b/variants/m5stack_cardputer_adv/hw/CardputerDisplay.cpp index 1fb92b145..4d5a7ac68 100644 --- a/variants/m5stack_cardputer_adv/hw/CardputerDisplay.cpp +++ b/variants/m5stack_cardputer_adv/hw/CardputerDisplay.cpp @@ -6,13 +6,22 @@ #include "globals.h" #include +#include -ColorVal UIColor::window_bkg = CardputerDisplay::PaletteColor::P_BLACK; +ColorVal UIColor::window_bkg = CardputerDisplay::P_BLACK; +ColorVal UIColor::title_bkg = CardputerDisplay::P_BLACK; +ColorVal UIColor::title_txt = CardputerDisplay::P_WHITE; +ColorVal UIColor::primary_txt = CardputerDisplay::P_WHITE; +ColorVal UIColor::secondary_txt = CardputerDisplay::P_GRAY; +ColorVal UIColor::warning_txt = CardputerDisplay::P_ORANGE; +ColorVal UIColor::popup_bkg = CardputerDisplay::P_BLACK; +ColorVal UIColor::popup_txt = CardputerDisplay::P_WHITE; +ColorVal UIColor::corp_blue = CardputerDisplay::P_BLUE; int32_t emoji_draw_callback(lgfx::LGFXBase *gfx, int32_t x, int32_t y, uint32_t code, int32_t font_height) { int w = gfx->textWidth("O"); int h = gfx->fontHeight(); - gfx->drawRect(x, y + (h - w - w / 2), w, w, TFT_DARKGRAY); + gfx->drawRect(x, y + (h - w - w / 2), w, w, mesh::ui::color_theme::render(TFT_DARKGRAY)); return w; } @@ -99,7 +108,8 @@ bool CardputerDisplay::begin() { LCD.setColorDepth(16); // lower SPI bandwidth LCD.setBrightness(128); - LCD.setTextColor(TFT_WHITE, TFT_BLACK); + LCD.setTextColor(mesh::ui::color_theme::render(P_WHITE), + mesh::ui::color_theme::background(P_BLACK)); LCD.setEmojiCallback(emoji_draw_callback); setTextLevel(_textLevel); return true; @@ -116,12 +126,13 @@ void CardputerDisplay::turnOff() { } void CardputerDisplay::clear() { - LCD.clear(); + LCD.clear(mesh::ui::color_theme::background(P_BLACK)); } void CardputerDisplay::startFrame(ColorVal bkg) { LCD.startWrite(); - LCD.clear(P_BLACK); + LCD.clear(mesh::ui::color_theme::background(bkg)); + setColor(P_WHITE); } void CardputerDisplay::endFrame() { @@ -180,7 +191,7 @@ void CardputerDisplay::setTextSize(int sz) { } void CardputerDisplay::setColor(ColorVal c) { - _lastColor = c; + _lastColor = mesh::ui::color_theme::render(c); LCD.setColor(_lastColor); LCD.setTextColor(_lastColor); } From 1e22f6326d196f761fc6919c10c8c70eb854e30b Mon Sep 17 00:00:00 2001 From: mikecarper Date: Tue, 27 Oct 2026 23:45:06 -0700 Subject: [PATCH 02/61] Fix Cardputer history, notifications and hardware lifecycle --- .github/workflows/run-unit-tests.yml | 2 + examples/companion_radio/MyMesh.cpp | 82 ++++- examples/companion_radio/MyMesh.h | 15 +- test/test_cardputer_channel_identity.py | 185 +++++++++++ test/test_cardputer_delivery.py | 167 +++++++++- test/test_cardputer_gps.py | 2 +- test/test_cardputer_hardware.py | 299 ++++++++++++++++++ test/test_cardputer_mesh_storage.py | 39 ++- test/test_cardputer_messaging.py | 41 ++- test/test_cardputer_ui.py | 120 ++++++- test/test_cardputer_ui_lifecycle.py | 153 ++++++++- .../CardputerDataStore.cpp | 17 +- .../CardputerDataStore.h | 4 + .../CardputerSensorManager.cpp | 69 +++- .../CardputerSensorManager.h | 41 ++- variants/m5stack_cardputer_adv/README.md | 27 +- .../m5stack_cardputer_adv/UnreadCounter.h | 16 +- .../companion_radio/CardputerMesh.cpp | 57 +++- .../companion_radio/CardputerMesh.h | 6 +- .../ui-cardputer/CardputerUITask.cpp | 67 +++- .../ui-cardputer/CardputerUITask.h | 8 +- .../ui-cardputer/screen/MainScreen.cpp | 19 +- .../ui-cardputer/screen/MainScreen.h | 4 +- .../ui-cardputer/screen/SettingsScreen.cpp | 23 +- .../ui-cardputer/screen/SettingsScreen.h | 1 + .../ui-cardputer/screen/ToolsScreen.cpp | 41 ++- .../ui-cardputer/screen/ToolsScreen.h | 7 + .../ui-cardputer/screen/main/ChannelsPage.cpp | 4 +- .../ui-cardputer/screen/main/ChatPage.cpp | 50 ++- .../ui-cardputer/screen/main/ChatPage.h | 6 +- .../ui-cardputer/screen/main/ContactsPage.cpp | 2 +- .../hw/CardputerSpeaker.cpp | 151 +++++---- .../hw/CardputerSpeaker.h | 34 +- variants/m5stack_cardputer_adv/platformio.ini | 1 + variants/m5stack_cardputer_adv/target.cpp | 8 +- variants/m5stack_cardputer_adv/target.h | 8 +- 36 files changed, 1558 insertions(+), 218 deletions(-) create mode 100644 test/test_cardputer_channel_identity.py create mode 100644 test/test_cardputer_hardware.py diff --git a/.github/workflows/run-unit-tests.yml b/.github/workflows/run-unit-tests.yml index c3ece928f..052aeb281 100644 --- a/.github/workflows/run-unit-tests.yml +++ b/.github/workflows/run-unit-tests.yml @@ -403,8 +403,10 @@ jobs: python3 -B test/test_cardputer_ui_lifecycle.py -v python3 -B test/test_cardputer_mesh_storage.py -v python3 -B test/test_cardputer_messaging.py -v + python3 -B test/test_cardputer_channel_identity.py -v python3 -B test/test_cardputer_delivery.py -v python3 -B test/test_cardputer_gps.py -v + python3 -B test/test_cardputer_hardware.py -v python3 -B test/test_cardputer_runtime_memory.py -v python3 -B test/test_cardputer_license.py -v python3 -B test/test_protocol_parser_bounds.py -v diff --git a/examples/companion_radio/MyMesh.cpp b/examples/companion_radio/MyMesh.cpp index fc2c33de5..7f81df74d 100644 --- a/examples/companion_radio/MyMesh.cpp +++ b/examples/companion_radio/MyMesh.cpp @@ -2344,7 +2344,7 @@ bool MyMesh::queueChannelText(uint8_t channel_idx, const uint8_t secret[32], flo if (!queued) return false; #if CARDPUTER_ADV_UI - onUiChannelMessage(secret, text); + onUiChannelMessage(channel_idx, text); #endif #if COMPANION_FEATURE_NOTIFICATIONS const bool notification_managed = notify && _notifications.message(nullptr, secret, _serial && _serial->isConnected(), millis()); @@ -6703,6 +6703,9 @@ void MyMesh::handleCmdFrame(size_t len) { #if MESH_ENABLE_MESHTEXT_CHAT if (success && isMeshtextChannel(channel)) { if (sendMeshtextText(channel, text, len - i)) { +#if CARDPUTER_ADV_UI + onUiChannelMessageSent(channel_idx, text, len - i); +#endif #if UI_POCKET_GUI if (_ui) _ui->channelMessageSent(channel_idx, channel.name, _prefs.node_name, text, len - i); #endif @@ -6715,6 +6718,13 @@ void MyMesh::handleCmdFrame(size_t len) { && parseCompanionChannelAlias(channel.name).kind == CompanionChannelKind::Meshtext) { writeErrFrame(ERR_CODE_UNSUPPORTED_CMD); } else if (success && sendGroupMessage(msg_timestamp, channel.channel, _prefs.node_name, text, len - i)) { +#if CARDPUTER_ADV_UI + // Native group packets include ": "; BaseChatMesh limits + // the text to the remaining payload. History must match that packet. + const size_t available = MAX_TEXT_LEN - (strlen(_prefs.node_name) + 2); + const size_t sent_length = len - i < available ? len - i : available; + onUiChannelMessageSent(channel_idx, text, sent_length); +#endif #if UI_POCKET_GUI if (_ui) _ui->channelMessageSent(channel_idx, channel.name, _prefs.node_name, text, len - i); #endif @@ -9551,19 +9561,45 @@ void MyMesh::appendRxPowerSavingAdjustmentNote(char* reply, size_t reply_size, } #if CARDPUTER_ADV_UI +int MyMesh::sendMessage(const ContactInfo& recipient, uint32_t timestamp, uint8_t attempt, + const char* text, uint32_t& expected_ack, uint32_t& est_timeout, + uint8_t* packet_hash, const uint8_t* replace_retry_key, + const uint8_t* message_retry_key, uint32_t delay_millis) { + const int result = BaseChatMesh::sendMessage(recipient, timestamp, attempt, text, + expected_ack, est_timeout, packet_hash, replace_retry_key, message_retry_key, delay_millis); + if (result != MSG_SEND_FAILED && attempt == 0) onUiContactMessageSent(recipient, text); + return result; +} + +void MyMesh::syncUiNotificationSettings() { + if (!_ui) return; + _ui->setHasConnection(_serial && _serial->isConnected()); +#if COMPANION_FEATURE_NOTIFICATIONS + const auto& notifications = _notifications.settings(); + _ui->setNotificationOutputs(notifications.enabled ? notifications.outputs : 31); + _ui->setNotificationLedOwnership(_notifications.ownsLed()); +#endif +} + bool MyMesh::sendUiChannelMessage(int channel_idx, uint32_t timestamp, const char* text, size_t length) { ChannelDetails channel; if (!text || !length || length != strlen(text) || !getChannel(channel_idx, channel) || !channel.name[0]) return false; #if MESH_ENABLE_MESHTEXT_CHAT - if (isMeshtextChannel(channel)) return sendMeshtextText(channel, text, length); + if (isMeshtextChannel(channel)) { + if (!sendMeshtextText(channel, text, length)) return false; + onUiChannelMessageSent(channel_idx, text, length); + return true; + } #endif if (isAliasRadio2Defined() && parseCompanionChannelAlias(channel.name).kind == CompanionChannelKind::Meshtext) return false; const size_t prefix_length = strlen(_prefs.node_name) + 2; if (prefix_length >= MAX_TEXT_LEN || length > MAX_TEXT_LEN - prefix_length) return false; - return sendGroupMessage(timestamp, channel.channel, _prefs.node_name, text, length); + if (!sendGroupMessage(timestamp, channel.channel, _prefs.node_name, text, length)) return false; + onUiChannelMessageSent(channel_idx, text, length); + return true; } #endif @@ -9940,10 +9976,17 @@ void MyMesh::handleTerminalDisplayCommand(const char* arguments) { } void MyMesh::sendTerminalChannelMessage(ChannelDetails& channel, - const char* text) { + const char* text +#if CARDPUTER_ADV_UI + , uint8_t channel_idx +#endif + ) { #if MESH_ENABLE_MESHTEXT_CHAT if (isMeshtextChannel(channel)) { if (text && sendMeshtextText(channel, text, strlen(text))) { +#if CARDPUTER_ADV_UI + onUiChannelMessageSent(channel_idx, text, strlen(text)); +#endif terminalOutput().printf(" Queued to %s (Meshtext).\r\n", channel.name); } else { terminalOutput().printf(" ERROR: %s; use get mt.status\r\n", _mt_status); @@ -9968,6 +10011,9 @@ void MyMesh::sendTerminalChannelMessage(ChannelDetails& channel, } else if (sendGroupMessage(getRTCClock()->getCurrentTimeUnique(), channel.channel, _prefs.node_name, text, text_len)) { +#if CARDPUTER_ADV_UI + onUiChannelMessageSent(channel_idx, text, text_len); +#endif terminalOutput().printf(" Sent to %s.\r\n", channel.name); } else { terminalOutput().print(" ERROR: unable to send\r\n"); @@ -10432,16 +10478,25 @@ void MyMesh::handleTerminalCommand(char* command, bool reader_prompt, ChannelDetails channel; bool found = false; size_t requested_index = 0; +#if CARDPUTER_ADV_UI + uint8_t selected_channel_idx = 0; +#endif if (mesh::cli::parseTerminalChannelIndex( channel_message, MAX_GROUP_CHANNELS, requested_index)) { found = getChannel((int)requested_index, channel) && channel.name[0] != 0; +#if CARDPUTER_ADV_UI + selected_channel_idx = static_cast(requested_index); +#endif } else { for (int i = 0; i < MAX_GROUP_CHANNELS; i++) { if (getChannel(i, channel) && channel.name[0] != 0 && mesh::cli::terminalChannelNameMatches(channel_message, channel.name)) { found = true; +#if CARDPUTER_ADV_UI + selected_channel_idx = static_cast(i); +#endif break; } } @@ -10450,7 +10505,11 @@ void MyMesh::handleTerminalCommand(char* command, bool reader_prompt, if (!found) { terminalOutput().print(" ERROR: channel not found (use 'channels')\r\n"); } else { - sendTerminalChannelMessage(channel, channel_message.text); + sendTerminalChannelMessage(channel, channel_message.text +#if CARDPUTER_ADV_UI + , selected_channel_idx +#endif + ); } } else if (strncmp(command, "send ", 5) == 0) { ContactInfo* recipient = getTerminalRecipient(); @@ -10488,7 +10547,11 @@ void MyMesh::handleTerminalCommand(char* command, bool reader_prompt, if (!getChannel(0, channel) || channel.name[0] == 0) { terminalOutput().print(" ERROR: Public channel is unavailable\r\n"); } else { - sendTerminalChannelMessage(channel, text); + sendTerminalChannelMessage(channel, text +#if CARDPUTER_ADV_UI + , 0 +#endif + ); } } else if (strcmp(command, "list") == 0 || strncmp(command, "list ", 5) == 0) { int count = command[4] == ' ' ? atoi(command + 5) : 0; @@ -11752,6 +11815,11 @@ void MyMesh::checkSerialInterface() { } void MyMesh::loop() { +#if CARDPUTER_ADV_UI + // Apply saved output masks before the first received packet, not only at + // the end of the loop or after a later settings change. + syncUiNotificationSettings(); +#endif #if COMPANION_FEATURE_DIRECT_OTA_ADMIN if (_ota_direct_contact.type && !_ota_direct_admin.active(_ms->getMillis())) closeDirectOtaAdmin(); #endif @@ -11890,7 +11958,7 @@ void MyMesh::loop() { } #endif -#ifdef DISPLAY_CLASS +#if defined(DISPLAY_CLASS) || CARDPUTER_ADV_UI if (_ui) { _ui->setHasConnection(_serial->isConnected()); #if COMPANION_FEATURE_NOTIFICATIONS diff --git a/examples/companion_radio/MyMesh.h b/examples/companion_radio/MyMesh.h index 04f8ef6a9..1d0b85818 100644 --- a/examples/companion_radio/MyMesh.h +++ b/examples/companion_radio/MyMesh.h @@ -393,7 +393,10 @@ protected: // Run after the shared duplicate/retention checks, including room posts and // Meshtext. Variant history must not bypass the Companion delivery policy. virtual void onUiContactMessage(const ContactInfo&, const char*) {} - virtual void onUiChannelMessage(const uint8_t[32], const char*) {} + virtual void onUiChannelMessage(uint8_t, const char*) {} + virtual void onUiContactMessageSent(const ContactInfo&, const char*) {} + virtual void onUiChannelMessageSent(uint8_t, const char*, size_t) {} + void syncUiNotificationSettings(); #endif #if COMPANION_FEATURE_TEMP_RADIO bool isTempRadioActive() const override { @@ -618,6 +621,10 @@ public: #if CARDPUTER_ADV_UI uint16_t getPrimaryRadioPreamble() const { return _radio_profiles.primaryPreamble(); } bool queueUiContactWrite(const ContactInfo& contact) { return scheduleContactWrite(contact); } + int sendMessage(const ContactInfo& recipient, uint32_t timestamp, uint8_t attempt, + const char* text, uint32_t& expected_ack, uint32_t& est_timeout, + uint8_t* packet_hash = nullptr, const uint8_t* replace_retry_key = nullptr, + const uint8_t* message_retry_key = nullptr, uint32_t delay_millis = 0); bool sendUiChannelMessage(int channel_idx, uint32_t timestamp, const char* text, size_t length); #endif @@ -769,7 +776,11 @@ private: void handleTerminalPath(ContactInfo& recipient, const char* path_spec); void importTerminalCard(char* command); void listTerminalChannels(); - void sendTerminalChannelMessage(ChannelDetails& channel, const char* text); + void sendTerminalChannelMessage(ChannelDetails& channel, const char* text +#if CARDPUTER_ADV_UI + , uint8_t channel_idx +#endif + ); void handleTerminalDisplayCommand(const char* arguments); void printTerminalSendStatus(const char* operation, const ContactInfo& recipient, int result, diff --git a/test/test_cardputer_channel_identity.py b/test/test_cardputer_channel_identity.py new file mode 100644 index 000000000..cd05f16e7 --- /dev/null +++ b/test/test_cardputer_channel_identity.py @@ -0,0 +1,185 @@ +#!/usr/bin/env python3 +"""Execute channel IDs, accepted sent history, and visible-chat unread policy.""" +from pathlib import Path +import unittest + +from cpp_source import body +import test_cardputer_mesh_storage as storage_tests + +ROOT = Path(__file__).resolve().parents[1] +VARIANT = ROOT / "variants/m5stack_cardputer_adv" + +HARNESS = r''' +#include +#include +#include +#include +#include +#include +#include +#include "RingBuffer.h" +#include "UnreadCounter.h" +#define PUB_KEY_SIZE 32 +namespace mesh { +struct Utils { + static void sha256(uint8_t* out, size_t count, const uint8_t* data, int length) { + assert(count == 32); SHA256(data, length, out); + } +}; +} +struct ContactInfo { struct { uint8_t pub_key[32] = {}; } id; }; +struct ChannelDetails { struct { uint8_t secret[32] = {}; } channel; char name[32] = {}; }; +struct HistoryMessage { char text[MAX_MESSAGE_LENGTH] = {}; bool out = false; }; +using ChatHistory = RingBuffer; +struct MainScreen; +struct UI { + MainScreen* screen; + bool visible = false; + bool isChatVisible() const { return visible; } + void onChannelMessageRecv(uint8_t, const uint8_t[32], const char*); + void onChannelMessageSent(uint8_t, const uint8_t[32], const char*); + void onContactMessageRecv(const ContactInfo&, const char*); + void onContactMessageSent(const ContactInfo&, const char*); +}; +struct ChatPage { + MainScreen* _p; + int current_channel_idx = -1, current_contact_idx = -1; + uint8_t current_channel_history_id[32] = {}; + ContactInfo current_contact; + ChatHistory chat_history; + int chat_history_offset = 0; + std::string last_sent_message; + bool onChannelMessageRecv(uint8_t, const uint8_t[32], const char*); + bool onContactMessageRecv(const ContactInfo&, const char*); + void onChannelMessageSent(uint8_t, const uint8_t[32], const char*); + void onContactMessageSent(const ContactInfo&, const char*); + void markRead(UnreadCounter&) const; +}; +struct MainScreen { + UI* _task; + ChatPage* chat_page; + UnreadCounter unread; + UI* getUiTask() { return _task; } + void onChannelMessageRecv(uint8_t, const uint8_t[32], const char*); + void onContactMessageRecv(const ContactInfo&, const char*); + void onChannelMessageSent(uint8_t, const uint8_t[32], const char*); + void onContactMessageSent(const ContactInfo&, const char*); +}; +void UI::onChannelMessageRecv(uint8_t index, const uint8_t* id, const char* text) { screen->onChannelMessageRecv(index,id,text); } +void UI::onChannelMessageSent(uint8_t index, const uint8_t* id, const char* text) { screen->onChannelMessageSent(index,id,text); } +void UI::onContactMessageRecv(const ContactInfo& contact, const char* text) { screen->onContactMessageRecv(contact,text); } +void UI::onContactMessageSent(const ContactInfo& contact, const char* text) { screen->onContactMessageSent(contact,text); } +struct Stored { std::vector id; std::string text; bool out, channel; }; +struct Store { + std::vector messages; + void storeMessage(const uint8_t* id, const char* text, bool out, bool channel) { + messages.push_back({std::vector(id,id+32), text, out, channel}); + } +}; +struct CardputerMesh { + Store* _store; + UI* _ui; + ChannelDetails channels[4]; + bool getChannel(int index, ChannelDetails& channel) { + if (index < 0 || index >= 4) return false; + channel = channels[index]; return true; + } + static bool channelHistoryId(const ChannelDetails&, uint8_t[32]); + bool getChannelHistoryId(int, uint8_t[32]); + void onUiChannelMessage(uint8_t, const char*); + void onUiContactMessage(const ContactInfo&, const char*); + void onUiChannelMessageSent(uint8_t, const char*, size_t); + void onUiContactMessageSent(const ContactInfo&, const char*); +}; +@METHODS@ +int main() { + UI ui{}; ChatPage page{}; MainScreen main{&ui,&page,{}}; + ui.screen = &main; page._p = &main; + Store store; CardputerMesh node{&store,&ui,{}}; + const char* labels[] = {"alpha", "r2-alpha", "mt-alpha", "mt-LongFast"}; + uint8_t ids[4][32]; + for (unsigned i=0; i<4; ++i) { + strcpy(node.channels[i].name, labels[i]); + memset(node.channels[i].channel.secret, 17, 16); + assert(node.getChannelHistoryId(i, ids[i])); + for (unsigned j=0; j(ids[2], ids[2]+32)); + node.onUiChannelMessage(3, "hidden selected chat"); + assert(page.chat_history.count() == 1 && main.unread.countChats() == 2); + ui.visible = true; + page.markRead(main.unread); + assert(main.unread.countChats() == 1); + node.onUiChannelMessage(3, "visible selected chat"); + assert(page.chat_history.count() == 2 && main.unread.countMessages() == 1); + + page.current_channel_idx = -1; page.current_contact_idx = 1; + page.current_contact.id.pub_key[31] = 1; + ContactInfo other = page.current_contact; other.id.pub_key[31] = 2; + node.onUiContactMessage(other, "same six-byte prefix"); + assert(main.unread.countChats() == 2); + ui.visible = false; + node.onUiContactMessage(page.current_contact, "settings overlay"); + assert(main.unread.countChats() == 3); + ui.visible = true; page.markRead(main.unread); + assert(main.unread.countChats() == 2); + + page.chat_history.clear(); store.messages.clear(); + for (const char* origin : {"app", "USB terminal", "keyboard"}) { + node.onUiContactMessageSent(page.current_contact, origin); + } + assert(page.chat_history.count() == 3 && store.messages.size() == 3); + for (const auto& message : store.messages) assert(message.out && !message.channel); + page.current_contact_idx = -1; page.current_channel_idx = 3; + page.chat_history.clear(); store.messages.clear(); + for (const char* origin : {"app", "USB terminal", "keyboard"}) { + node.onUiChannelMessageSent(3, origin, strlen(origin)); + } + assert(page.chat_history.count() == 3 && store.messages.size() == 3); + for (const auto& message : store.messages) { + assert(message.out && message.channel && message.id == std::vector(ids[3],ids[3]+32)); + } + assert(main.unread.countChats() == 2); + char bounded[MAX_MESSAGE_LENGTH + 20]; memset(bounded, 'x', sizeof(bounded)); + node.onUiChannelMessageSent(3, bounded, sizeof(bounded)); + assert(store.messages.back().text.size() == MAX_MESSAGE_LENGTH); +} +''' + + +class CardputerChannelIdentityTests(unittest.TestCase): + def test_identity_delivery_sent_history_and_visible_unread(self): + mesh = (VARIANT / "companion_radio/CardputerMesh.cpp").read_text() + main = (VARIANT / "companion_radio/ui-cardputer/screen/MainScreen.cpp").read_text() + chat = (VARIANT / "companion_radio/ui-cardputer/screen/main/ChatPage.cpp").read_text() + methods = [] + for source, signatures in ( + (mesh, ("bool CardputerMesh::channelHistoryId(", "bool CardputerMesh::getChannelHistoryId(", + "void CardputerMesh::onUiChannelMessage(", "void CardputerMesh::onUiContactMessage(", + "void CardputerMesh::onUiChannelMessageSent(", "void CardputerMesh::onUiContactMessageSent(")), + (main, ("void MainScreen::onChannelMessageRecv(", "void MainScreen::onContactMessageRecv(", + "void MainScreen::onChannelMessageSent(", "void MainScreen::onContactMessageSent(")), + (chat, ("bool ChatPage::onChannelMessageRecv(", "bool ChatPage::onContactMessageRecv(", + "void ChatPage::onChannelMessageSent(", "void ChatPage::onContactMessageSent(", + "void ChatPage::markRead("))): + methods.extend(body(source, signature) for signature in signatures) + storage_tests.CardputerMeshStorageTests.run_cpp( + self, HARNESS.replace("@METHODS@", "\n".join(methods)), + flags=("-Wl,--no-as-needed", "-lcrypto")) + + +if __name__ == "__main__": + unittest.main() diff --git a/test/test_cardputer_delivery.py b/test/test_cardputer_delivery.py index 3e572d95d..4f2bff8e9 100644 --- a/test/test_cardputer_delivery.py +++ b/test/test_cardputer_delivery.py @@ -41,6 +41,7 @@ using std::isfinite; #define RESP_CODE_CHANNEL_MSG_RECV_V3 0x90 #define RESP_CODE_CHANNEL_MSG_RECV 0x83 #define PUSH_CODE_MSG_WAITING 0x80 +#define MSG_SEND_FAILED 0 static unsigned long millis() { return 1234; } enum class UIEventType { contactMessage, channelMessage }; namespace mesh { @@ -62,6 +63,11 @@ struct Serial { }; struct UI { int received = 0, sounds = 0; + uint8_t outputs = 31; + bool connected = false, led_owned = false; + void setHasConnection(bool value) { connected = value; } + void setNotificationOutputs(uint8_t value) { outputs = value; } + void setNotificationLedOwnership(bool value) { led_owned = value; } void setMessageNotificationOverride(bool, bool = false) {} void newMsg(uint8_t, const char*, const char*, int, int, const char*, int) { ++received; } void notify(UIEventType) { ++sounds; } @@ -103,7 +109,12 @@ struct MyMesh { bool millisHasNowPassed(unsigned long) const { return false; } bool addToOfflineQueue(const uint8_t*, int) { if (accept) ++offline_queue_len; return accept; } void onUiContactMessage(const ContactInfo&, const char*) { ++contact_history; } - void onUiChannelMessage(const uint8_t[32], const char*) { ++channel_history; } + void onUiChannelMessage(uint8_t slot, const char*) { assert(slot == 9); ++channel_history; } + int channel_sent_history = 0; + size_t last_history_length = 0; + void onUiChannelMessageSent(uint8_t slot, const char*, size_t length) { + assert(slot == 9); ++channel_sent_history; last_history_length = length; + } void makeOneKeyDMId(uint8_t* out, uint32_t, const char*) { memset(out, 7, 8); } bool wasDeliveredOneKeyDM(const uint8_t*, const uint8_t*) const { return delivered; } void rememberDeliveredOneKeyDM(const uint8_t*, const uint8_t*) { delivered = true; } @@ -168,9 +179,11 @@ int main() { #if MESH_ENABLE_MESHTEXT_CHAT assert(m.sendUiChannelMessage(9, 101, "text", 4)); assert(m.meshtext_sends == 1 && m.meshcore_sends == 0 && m.sent_key == 71); + assert(m.channel_sent_history == 1); m.transport_accept = false; assert(!m.sendUiChannelMessage(9, 101, "text", 4)); assert(m.meshcore_sends == 0); // Rejection never leaks onto the other protocol. + assert(m.channel_sent_history == 1); m.transport_accept = true; #else assert(!m.sendUiChannelMessage(9, 101, "text", 4)); @@ -188,6 +201,7 @@ int main() { assert(m.sendUiChannelMessage(9, 101, "text", 4)); assert(m.sent_key == 73); int sends = m.meshcore_sends; + const int sent_history = m.channel_sent_history; assert(!m.sendUiChannelMessage(1, 101, "text", 4)); assert(!m.sendUiChannelMessage(9, 101, nullptr, 4)); assert(!m.sendUiChannelMessage(9, 101, "", 0)); @@ -195,6 +209,7 @@ int main() { std::string oversized(160, 'x'); assert(!m.sendUiChannelMessage(9, 101, oversized.c_str(), oversized.size())); assert(m.meshcore_sends == sends); + assert(m.channel_sent_history == sent_history); #else assert(m.channel_history == 0 && m.ui.received == 0); #endif @@ -213,13 +228,17 @@ class CardputerDeliveryTests(unittest.TestCase): if cardputer: signatures.append("bool MyMesh::sendUiChannelMessage(") code = HARNESS.replace("@METHODS@", "\n".join(body(source, sig) for sig in signatures)) + self.run_program(code, f"-DCARDPUTER_ADV_UI={int(cardputer)}", + f"-DMESH_ENABLE_MESHTEXT_CHAT={int(meshtext)}") + + def run_program(self, code, *defines): with tempfile.TemporaryDirectory(prefix="meshcore-cardputer-delivery-") as temp: binary = Path(temp) / "delivery" built = subprocess.run([ os.environ.get("CXX", "c++"), "-std=c++17", "-Wall", "-Wextra", "-Werror", "-Wno-unused-parameter", "-Wno-unused-variable", "-Wno-unused-function", "-fsanitize=address,undefined", "-fno-omit-frame-pointer", "-fno-pie", "-no-pie", - f"-DCARDPUTER_ADV_UI={int(cardputer)}", f"-DMESH_ENABLE_MESHTEXT_CHAT={int(meshtext)}", + *defines, "-I", str(ROOT / "src"), "-x", "c++", "-", "-o", str(binary), ], input=code, text=True, capture_output=True) self.assertEqual(built.returncode, 0, built.stderr) @@ -235,6 +254,150 @@ class CardputerDeliveryTests(unittest.TestCase): def test_headless_delivery_keeps_its_existing_behavior(self): self.run_cpp(cardputer=False) + def test_contact_origin_hooks_and_saved_notification_masks(self): + source = SOURCE.read_text() + code = r''' +#include +#include +#include +#include +constexpr int MSG_SEND_FAILED=0; +struct ContactInfo { uint8_t key[32]{}; }; +struct BaseChatMesh { + int result=1, sends=0; + uint8_t last_attempt=0; + int sendMessage(const ContactInfo&, uint32_t stamp, uint8_t attempt, const char* text, + uint32_t& ack, uint32_t& timeout, uint8_t* hash, const uint8_t* replace, + const uint8_t* semantic, uint32_t delay) { + assert(stamp==123 && !strcmp(text,"outgoing") && hash && replace && semantic && delay==42); + ++sends; last_attempt=attempt; ack=0x12345678; timeout=5000; return result; + } +}; +struct UI { + uint8_t mask=31; bool connected=false, led=false; + void setHasConnection(bool value) { connected=value; } + void setNotificationOutputs(uint8_t value) { mask=value; } + void setNotificationLedOwnership(bool value) { led=value; } +}; +struct Serial { bool connected=false; bool isConnected() const { return connected; } }; +struct Engine { + struct Settings { bool enabled=true; uint8_t outputs=0; } saved; + const Settings& settings() const { return saved; } + bool ownsLed() const { return true; } +}; +struct MyMesh : BaseChatMesh { + UI ui; Serial serial; UI* _ui=&ui; Serial* _serial=&serial; Engine _notifications; + int stored=0; uint8_t stored_key=0; + void onUiContactMessageSent(const ContactInfo& c,const char* text) { + assert(!strcmp(text,"outgoing")); ++stored; stored_key=c.key[31]; + } + int sendMessage(const ContactInfo&,uint32_t,uint8_t,const char*,uint32_t&,uint32_t&, + uint8_t*,const uint8_t*,const uint8_t*,uint32_t); + void syncUiNotificationSettings(); +}; +@METHODS@ +int main() { + MyMesh m; ContactInfo recipient; recipient.key[31]=77; + uint32_t ack=0,timeout=0; uint8_t hash[8]{},replace[8]{},semantic[8]{}; + assert(m.sendMessage(recipient,123,0,"outgoing",ack,timeout,hash,replace,semantic,42)==1); + assert(m.stored==1 && m.stored_key==77 && ack==0x12345678 && timeout==5000); + for(unsigned attempt=1;attempt<6;++attempt) + assert(m.sendMessage(recipient,123,attempt,"outgoing",ack,timeout,hash,replace,semantic,42)==1); + assert(m.stored==1 && m.sends==6); // Radio retries never duplicate local history. + m.result=MSG_SEND_FAILED; + assert(m.sendMessage(recipient,123,0,"outgoing",ack,timeout,hash,replace,semantic,42)==MSG_SEND_FAILED); + assert(m.stored==1); + for(unsigned mask=0;mask<32;++mask) { + m._notifications.saved.outputs=mask; + m.serial.connected=(mask&1); + m.syncUiNotificationSettings(); + assert(m.ui.mask==mask && m.ui.connected==m.serial.connected && m.ui.led); + } + m._notifications.saved.enabled=false; m.syncUiNotificationSettings(); + assert(m.ui.mask==31); // Disabled custom controller restores ordinary UI defaults. + m._serial=nullptr; m.syncUiNotificationSettings(); assert(!m.ui.connected); + m._ui=nullptr; m.syncUiNotificationSettings(); +} +''' + code = code.replace("@METHODS@", "\n".join(body(source, sig) for sig in ( + "int MyMesh::sendMessage(", "void MyMesh::syncUiNotificationSettings("))) + self.run_program(code, "-DCARDPUTER_ADV_UI=1", "-DCOMPANION_FEATURE_NOTIFICATIONS=1") + loop = body(source, "void MyMesh::loop()") + self.assertLess(loop.index("syncUiNotificationSettings();"), loop.index("BaseChatMesh::loop();")) + + def test_actual_app_and_terminal_channel_admission_hooks(self): + source = SOURCE.read_text() + prefix = HARNESS.split("@METHODS@")[0] + prefix = prefix.replace("struct MyMesh {", r''' +struct Output { template void printf(const char*,Args...) {} void print(const char*) {} }; +struct Clock { uint32_t getCurrentTimeUnique() { return 123; } }; +#define TXT_TYPE_PLAIN 0 +#define ERR_CODE_ILLEGAL_ARG 1 +#define ERR_CODE_UNSUPPORTED_CMD 2 +#define ERR_CODE_BAD_STATE 3 +#define ERR_CODE_NOT_FOUND 4 +struct MyMesh {''') + prefix = prefix.replace(" bool sendUiChannelMessage(int, uint32_t, const char*, size_t);", r''' + bool sendUiChannelMessage(int, uint32_t, const char*, size_t); + uint8_t cmd_frame[176]{}; int ok=0,errors=0; + const char* _mt_status="test"; Output output; Clock clock; + Output& terminalOutput() { return output; } + Clock* getRTCClock() { return &clock; } + void writeOKFrame() { ++ok; } + void writeErrFrame(int) { ++errors; } + void appChannel(size_t len); + void sendTerminalChannelMessage(ChannelDetails&,const char*,uint8_t); +''') + app = body(source, "} else if (cmd_frame[0] == CMD_SEND_CHANNEL_TXT_MSG)") + app = "void MyMesh::appChannel(size_t len) " + app[app.index("{"):] + methods = app + "\n" + body(source, "void MyMesh::sendTerminalChannelMessage(") + exercise = r''' +int main() { + MyMesh m; strcpy(m.configured.name,"#mt-longfast"); + m.cmd_frame[1]=TXT_TYPE_PLAIN; m.cmd_frame[2]=9; + uint32_t stamp=123; memcpy(m.cmd_frame+3,&stamp,4); memcpy(m.cmd_frame+7,"text",4); + m.appChannel(11); + assert(m.ok==1 && m.channel_sent_history==1 && m.meshtext_sends==1 && m.meshcore_sends==0); + m.transport_accept=false; m.appChannel(11); + assert(m.errors==1 && m.channel_sent_history==1); + m.transport_accept=true; strcpy(m.configured.name,"#r2-testing"); + m.appChannel(11); + assert(m.ok==2 && m.channel_sent_history==2 && m.meshcore_sends==1); + assert(m.last_history_length==4); + m.transport_accept=false; m.appChannel(11); + assert(m.errors==2 && m.channel_sent_history==2); + m.transport_accept=true; + m.sendTerminalChannelMessage(m.configured,"text",9); + assert(m.channel_sent_history==3); + strcpy(m.configured.name,"#mt-longfast"); + m.sendTerminalChannelMessage(m.configured,"text",9); + assert(m.channel_sent_history==4); + m.transport_accept=false; + m.sendTerminalChannelMessage(m.configured,"text",9); + assert(m.channel_sent_history==4); + + // Native packets reserve room for the sender name, while Meshtext sends + // have their own framing and must retain the complete admitted text. + MyMesh long_message; + strcpy(long_message.configured.name,"#r2-testing"); + memset(long_message._prefs.node_name,'N',31); + long_message._prefs.node_name[31]=0; + long_message.cmd_frame[1]=TXT_TYPE_PLAIN; + long_message.cmd_frame[2]=9; + memcpy(long_message.cmd_frame+3,&stamp,4); + memset(long_message.cmd_frame+7,'x',150); + long_message.appChannel(157); + assert(long_message.ok==1 && long_message.channel_sent_history==1); + assert(long_message.last_history_length==127); + strcpy(long_message.configured.name,"#mt-longfast"); + long_message.appChannel(157); + assert(long_message.ok==2 && long_message.channel_sent_history==2); + assert(long_message.last_history_length==150); +} +''' + self.run_program(prefix + methods + exercise, + "-DCARDPUTER_ADV_UI=1", "-DMESH_ENABLE_MESHTEXT_CHAT=1") + def test_keyboard_transport_does_not_depend_on_text_terminal(self): source = re.sub(r"^\s*#\s*include[^\n]*", "", SOURCE.read_text(), flags=re.MULTILINE) result = subprocess.run([ diff --git a/test/test_cardputer_gps.py b/test/test_cardputer_gps.py index 1eff7b9ef..93141ecae 100644 --- a/test/test_cardputer_gps.py +++ b/test/test_cardputer_gps.py @@ -200,7 +200,7 @@ class CardputerGpsTests(unittest.TestCase): sanitizer = [] if os.name == "nt" else ["-fsanitize=address,undefined", "-fno-sanitize-recover=all", "-fno-pie", "-no-pie"] built = subprocess.run([compiler, "-std=c++17", "-O1", "-g", "-Wall", "-Wextra", "-Werror", - "-Wno-unused-parameter", *sanitizer, "-DENV_INCLUDE_GPS=1", + "-Wno-unused-parameter", *sanitizer, "-DENV_INCLUDE_GPS=1", "-DCARDPUTER_ADV_UI=1", "-DPIN_GPS_TX=15", "-DPIN_GPS_RX=13", "-DGPS_BAUD_RATE=115200", "-I", str(work), "-I", str(ROOT / "src"), "-I", str(VARIANT), "-I", str(nmea.parent), str(work / "main.cpp"), str(nmea), diff --git a/test/test_cardputer_hardware.py b/test/test_cardputer_hardware.py new file mode 100644 index 000000000..650e7cea6 --- /dev/null +++ b/test/test_cardputer_hardware.py @@ -0,0 +1,299 @@ +"""Execute bounded Cardputer audio and headless GNSS/environment ownership.""" +from pathlib import Path +import os +import shutil +import subprocess +import tempfile +import unittest + +from test_cardputer_gps import ARDUINO, MESH, RTC, CAYENNE + +ROOT = Path(__file__).resolve().parents[1] +VARIANT = ROOT / "variants/m5stack_cardputer_adv" + +I2S = r''' +#pragma once +#include +#include +#include +#include +#include +using esp_err_t=int; +using i2s_mode_t=int; +constexpr int ESP_OK=0,ESP_ERR_TIMEOUT=1,ESP_FAIL=2; +constexpr int I2S_NUM_0=0,I2S_MODE_MASTER=1,I2S_MODE_TX=2,I2S_BITS_PER_SAMPLE_16BIT=16; +constexpr int I2S_CHANNEL_FMT_ONLY_LEFT=0,I2S_COMM_FORMAT_STAND_I2S=0,ESP_INTR_FLAG_LEVEL1=1; +constexpr int I2S_PIN_NO_CHANGE=-1; +struct i2s_config_t { + int mode,sample_rate,bits_per_sample,channel_format,communication_format,intr_alloc_flags; + int dma_buf_count,dma_buf_len;bool use_apll,tx_desc_auto_clear;int fixed_mclk; +}; +struct i2s_pin_config_t {int bck_io_num,ws_io_num,data_out_num,data_in_num;}; +struct WriteReply {int result;size_t limit;}; +inline std::deque replies; +inline std::vector audio; +inline unsigned installs=0,pins=0,uninstalls=0,writes=0; +inline int install_result=ESP_OK,pin_result=ESP_OK; +inline esp_err_t i2s_driver_install(int,const i2s_config_t* cfg,int,void*) { + ++installs;assert(cfg->dma_buf_count==4&&cfg->dma_buf_len==256);return install_result; +} +inline esp_err_t i2s_set_pin(int,const i2s_pin_config_t* cfg) { + ++pins;assert(cfg->bck_io_num==41&&cfg->ws_io_num==43&&cfg->data_out_num==42);return pin_result; +} +inline esp_err_t i2s_driver_uninstall(int){++uninstalls;return ESP_OK;} +inline esp_err_t i2s_write(int,const void* src,size_t size,size_t* written,unsigned wait) { + ++writes;assert(wait==0&&size>0&&size<=512); + WriteReply reply{ESP_OK,size};if(!replies.empty()){reply=replies.front();replies.pop_front();} + *written=std::min(size,reply.limit); + const auto* bytes=static_cast(src);audio.insert(audio.end(),bytes,bytes+*written); + return reply.result; +} +''' +WIRE = r''' +#pragma once +#include +#include +struct TwoWire { + unsigned transactions=0,fail_at=0;uint8_t address=0; + void beginTransmission(uint8_t addr){address=addr;++transactions;} + size_t write(uint8_t){return 1;} + uint8_t endTransmission(bool=true){return fail_at==transactions?2:0;} + void begin(int=0,int=0,unsigned=100000){} + void setPins(uint8_t,uint8_t){} + void setClock(unsigned){} + void end(){} +}; +inline TwoWire Wire,Wire1; +''' +SPEAKER = r''' +#include +#include +#include +#include +#include +#include +#include "hw/CardputerSpeaker.h" +static void reset(uint32_t now=100) { + now_ms=now;delay_count=0;replies.clear();audio.clear();installs=pins=uninstalls=writes=0; + install_result=pin_result=ESP_OK;Wire1=TwoWire(); +} +static void poll(CardputerSpeaker& speaker) { + const auto before=writes;speaker.processQueue();assert(writes-before<=1&&delay_count==0); +} +static void finish(CardputerSpeaker& speaker) { + for(unsigned i=0;i<100&&speaker.isBusy();++i){poll(speaker);++now_ms;} + assert(!speaker.isBusy()); +} +static void normalAndPartial() { + reset();CardputerSpeaker speaker;speaker.queueTone(1000,100,0.5f); + assert(speaker.isBusy()&&writes==0&&installs==1&&pins==1); + now_ms+=1099;poll(speaker);assert(writes==0); + ++now_ms;replies={{ESP_OK,1},{ESP_ERR_TIMEOUT,3},{ESP_OK,0},{ESP_OK,17},{ESP_OK,511}}; + finish(speaker);assert(audio.size()==3200); + for(size_t i=0;i7000); // new tone has no unsent old suffix + speaker.queueTone(3000,100);speaker.sleep();const auto before=writes;poll(speaker); + assert(!speaker.isBusy()&&writes==before&&uninstalls==1); + speaker.queueTone(3000,1);assert(installs==2);now_ms+=1100;finish(speaker); + for(float volume:{0.0f,-1.0f,std::numeric_limits::quiet_NaN()}) { + speaker.queueTone(4000,20,volume);assert(!speaker.isBusy()); + } + speaker.queueTone(0,20);assert(!speaker.isBusy()); + speaker.queueTone(9000,20);assert(!speaker.isBusy()); + speaker.queueTone(1000,0);assert(!speaker.isBusy()); +} +static void failures() { + for(int fault=0;fault<4;++fault) { + reset();CardputerSpeaker speaker; + if(fault==0)install_result=ESP_FAIL; + if(fault==1)pin_result=ESP_FAIL; + if(fault==2)Wire1.fail_at=4; + speaker.queueTone(1000,100); + if(fault<3) { + assert(!speaker.isBusy()&&writes==0&&uninstalls==(fault==0?0U:1U)); + } else { + now_ms+=1100;replies={{ESP_FAIL,0}};poll(speaker); + assert(!speaker.isBusy()&&uninstalls==1); + } + install_result=pin_result=ESP_OK;Wire1.fail_at=0; + speaker.queueTone(1000,1);assert(speaker.isBusy());now_ms+=1100;finish(speaker); + } + reset();CardputerSpeaker stalled;stalled.queueTone(1000,100);now_ms+=1100; + replies={{ESP_OK,0}};poll(stalled);assert(stalled.isBusy()); + now_ms+=1000;replies={{ESP_ERR_TIMEOUT,0}};poll(stalled); + assert(!stalled.isBusy()&&uninstalls==1); +} +static void rollover() { + reset(UINT32_MAX-500);CardputerSpeaker speaker;speaker.queueTone(1000,1); + now_ms=598;poll(speaker);assert(writes==0); + now_ms=599;poll(speaker);assert(writes==1&&speaker.isBusy()); + now_ms=662;poll(speaker);assert(speaker.isBusy()); + now_ms=663;poll(speaker);assert(!speaker.isBusy()); +} +int main(){normalAndPartial();replacementAndCancellation();failures();rollover();} +''' +INA260 = r''' +#pragma once +#include +class Adafruit_INA260 { +public: + inline static unsigned begins=0,reads=0; + bool begin(uint8_t addr=0x41,TwoWire* wire=&Wire){++begins;return addr==0x41&&wire==&Wire;} + float readBusVoltage(){++reads;return 6500.0f;} + float readCurrent(){return 250.0f;} + float readPower(){return 1600.0f;} +}; +''' +HEADLESS = r''' +#include +#include +#include +#include "CardputerSensorManager.h" +#if ENV_INCLUDE_GPS +struct Provider:LocationProvider { + unsigned starts=0,stops=0,polls=0,resets=0;bool valid=false; + void begin()override{++starts;} + void stop()override{++stops;} + void reset()override{++resets;} + void loop()override{++polls;} + bool clearPositionFix()override{valid=false;return true;} + long getLatitude()override{return 47610000;} + long getLongitude()override{return -122330000;} + long getAltitude()override{return 125000;} + bool isValid()override{return valid;} + bool isEnabled()override{return true;} + long satellitesCount()override{return 8;} + long getTimestamp()override{return 0;} + void sendSentence(const char* sentence)override { + Serial1.print(!std::strcmp(sentence,"$PCAS12,65535")?"$PCAS12,65535*1E\r\n":"$PCAS10,0*1C\r\n"); + } +}; +#endif +void LocationProvider::sendSentence(const char*){} +static void checkExternal(CardputerSensorManager& manager) { + uint8_t channels[2]={};assert(manager.getVoltageSensorChannels(channels,2)==1&&channels[0]==2); + SensorManager::VoltageSensorReading readings[2];assert(manager.queryVoltageSensors(readings,2)==1); + assert(readings[0].valid&&readings[0].voltage==6.5f); + CayenneLPP telemetry(64);const auto before=Adafruit_INA260::reads; + assert(manager.querySensors(TELEM_PERM_ENVIRONMENT,telemetry)&&Adafruit_INA260::reads==before+1); + assert(telemetry.getSize()>0&&telemetry.gps_count==0); + assert(manager.querySensors(TELEM_PERM_BASE,telemetry)&&Adafruit_INA260::reads==before+1); +} +int main() { + now_ms=100; +#if ENV_INCLUDE_GPS + Provider provider;CardputerSensorManager manager(provider); +#else + CardputerSensorManager manager; +#endif + assert(manager.begin()&&Serial1.starts==1&&Adafruit_INA260::begins==1); + assert(Serial1.output=="$PCAS12,65535*1E\r\n"&&!manager.isGpsPowered()); + assert(Serial1.rx==15&&Serial1.tx==13&&Serial1.baud==115200); + assert(manager.gpsUsesSerialUart(1)&&manager.gpsSerialTransportMayConflict(1)); + assert(!manager.gpsSerialTransportCanYield(1)&&!manager.setGpsSerialTransportBlocked(1,true)); + checkExternal(manager); +#if ENV_INCLUDE_GPS + assert(manager.isGPSDetected()&&manager.getNumSettings()==1); + assert(!std::strcmp(manager.getSettingName(0),"gps")&&manager.getSettingName(1)==nullptr); + assert(!std::strcmp(manager.getSettingValue(0),"0")); + assert(provider.starts==0&&provider.resets==0&&provider.polls==0); + manager.setPowerSavingEnabled(true); + for(unsigned i=0;i<4;++i){now_ms+=3600000;provider.syncTime();manager.loop();} + assert(provider.starts==0&&provider.resets==0&&provider.polls==0&&Serial1.starts==1); + assert(manager.setSettingValue("gps","1")&&manager.isGpsPowered()); + assert(provider.starts==1&&provider.resets==0); + assert(manager.setSettingValue("gps_interval","2"));provider.valid=true;manager.loop(); + CayenneLPP telemetry(64);assert(manager.querySensors(TELEM_PERM_LOCATION,telemetry)); + assert(telemetry.gps_count==1); // one cache, one GPS field, generic query delegated exactly once + manager.setSettingValue("gps","0");assert(!manager.isGpsPowered()); + const auto polls=provider.polls;now_ms+=3600000;manager.loop();assert(provider.polls==polls); +#else + assert(!manager.isGPSDetected()&&manager.getNumSettings()==0&&manager.getLocationProvider()==nullptr); + assert(manager.getSettingName(0)==nullptr&&manager.getSettingValue(0)==nullptr); + assert(!manager.setSettingValue("gps","1")&&!manager.setSettingValue("gps_interval","2")); + manager.setPowerSavingEnabled(true);now_ms+=3600000;manager.loop(); + assert(!manager.isGpsPowered()&&Serial1.output.find("$PCAS10")==std::string::npos); +#endif + assert(Serial1.output.find("$PCAS12",1)!=std::string::npos); + manager.shutdownGpsTransportForDeepSleep();assert(Serial1.ends==1&&!manager.isGpsPowered()); +} +''' + + +class CardputerHardwareTests(unittest.TestCase): + def compile_run(self, code, *, gps=None): + compiler = os.environ.get("CXX") or shutil.which("g++") or shutil.which("clang++") + self.assertIsNotNone(compiler) + arduino = ARDUINO.replace("inline uint32_t delay_count=0;", "inline uint32_t delay_count=0;\n#define MESH_DEBUG_PRINTLN(...)\n#define POWERSAVING_DEBUG_PRINTLN(...)") + arduino = arduino.replace(" void end(){++ends;}", " explicit operator bool()const{return true;}\n void end(){++ends;}") + lpp = CAYENNE.replace(" void addTemperature", " void addCurrent(uint8_t,float){}\n void addPower(uint8_t,float){}\n void addTemperature") + with tempfile.TemporaryDirectory(prefix="cardputer-hardware-") as directory: + work = Path(directory) + (work / "driver").mkdir() + headers = {"Arduino.h": arduino, "Mesh.h": MESH, "RTClib.h": RTC, "Wire.h": WIRE, + "CayenneLPP.h": lpp, "driver/i2s.h": I2S, + "Adafruit_INA260.h": INA260, "main.cpp": code} + for name, content in headers.items(): + (work / name).write_text(content, encoding="ascii") + definitions, sources = [], [] + if gps is None: + sources = [VARIANT / "hw/CardputerSpeaker.cpp"] + else: + definitions = [f"-DENV_INCLUDE_GPS={int(gps)}", "-DCARDPUTER_ADV_UI=0", + "-DESP32_PLATFORM", "-DENV_INCLUDE_INA260=1", "-DPIN_GPS_TX=15", + "-DPIN_GPS_RX=13", "-DGPS_BAUD_RATE=115200"] + if not gps: + # The unchanged generic no-GPS settings methods retain a + # local index even when every conditional setting is absent. + definitions += ["-Wno-unused-variable"] + sources = [ROOT / "src/helpers/SensorManager.cpp", + ROOT / "src/helpers/sensors/EnvironmentSensorManager.cpp", + VARIANT / "CardputerSensorManager.cpp"] + binary = work / ("hardware.exe" if os.name == "nt" else "hardware") + sanitizer = [] if os.name == "nt" else ["-fsanitize=address,undefined", + "-fno-sanitize-recover=all", "-fno-pie", "-no-pie"] + built = subprocess.run([compiler, "-std=c++17", "-O1", "-g", "-Wall", "-Wextra", "-Werror", + "-Wno-unused-parameter", *sanitizer, *definitions, + "-I", str(work), "-I", str(ROOT / "src"), "-I", str(VARIANT), + str(work / "main.cpp"), *map(str, sources), "-o", str(binary)], + capture_output=True, text=True, timeout=60) + self.assertEqual(built.returncode, 0, built.stdout + built.stderr) + run = subprocess.run([str(binary)], capture_output=True, text=True, timeout=10) + self.assertEqual(run.returncode, 0, run.stdout + run.stderr) + + def test_speaker_nonblocking_partial_replacement_failure_and_wrap(self): + self.compile_run(SPEAKER) + + def test_headless_without_gps_provider_preserves_environment_and_standby(self): + self.compile_run(HEADLESS, gps=False) + + def test_headless_with_gps_has_one_owner_cache_and_external_sensors(self): + self.compile_run(HEADLESS, gps=True) + + def test_all_roles_compile_the_fixed_manager_without_forcing_gps(self): + config = (VARIANT / "platformio.ini").read_text(encoding="ascii") + base = config.split("[M5Stack_Cardputer_Adv]", 1)[1].split("[M5Stack_Cardputer_Adv_ui]", 1)[0] + self.assertIn("+", base) + self.assertIn("+<../variants/m5stack_cardputer_adv/CardputerSensorManager.cpp>", base) + self.assertNotIn("-D ENV_INCLUDE_GPS=1", base) + target = (VARIANT / "target.cpp").read_text(encoding="ascii") + self.assertIn("#if ENV_INCLUDE_GPS", target) + self.assertIn("CardputerSensorManager sensors(nmea);", target) + self.assertIn("CardputerSensorManager sensors;", target) + self.assertNotIn("EnvironmentSensorManager sensors", target) + + +if __name__ == "__main__": + unittest.main() diff --git a/test/test_cardputer_mesh_storage.py b/test/test_cardputer_mesh_storage.py index 5dbc6daae..0b32da424 100644 --- a/test/test_cardputer_mesh_storage.py +++ b/test/test_cardputer_mesh_storage.py @@ -130,7 +130,7 @@ int main() { memset(bounded, 'x', MAX_MESSAGE_LENGTH); store.storeMessage(key, bounded, false, true); delete[] bounded; - const std::string channel_path = std::string(HISTORY_DIR_CHANNEL) + "/" + std::string(32, '0'); + const std::string channel_path = std::string(CHANNEL_HISTORY_ID_DIR) + "/" + std::string(64, '0'); assert(sd.files.at(channel_path)->bytes == "<< " + std::string(MAX_MESSAGE_LENGTH, 'x') + "\n"); store.storeMessage(key, "line1\r\nline2", true, true); assert(sd.files.at(channel_path)->bytes.find(">> line1 line2\n") != std::string::npos); @@ -235,7 +235,7 @@ int main() { assert(node.sendFloodScoped(channel, other, 0)); assert(node.last_message_hash[0] == 77 && node.last_message_heard_repeats == 3); delete other; - assert(node.sendChannelMessage(7, 0, "text", 4) && node.store.writes == 1); + assert(node.sendChannelMessage(7, 0, "text", 4) && node.store.writes == 0); node.resend_timeout = 10; node.now = 9; node.loop(); @@ -422,6 +422,41 @@ int main() { assert(!memcmp(&restored, &candidate, sizeof(candidate))); assert(!store.saveCustomPrefs(previous)); } +''' + self.run_cpp((VARIANT / "CardputerDataStore.cpp").read_text() + checks, + headers=self.storage_headers()) + + def test_channel_identity_storage_preserves_ambiguous_legacy_logs(self): + checks = r''' +#include +int main() { + mesh::RTCClock clock; + FILESYSTEM primary, sd; stat_fs = &primary; + CardputerDataStore store(primary, clock); store.beginCustom(&sd); + const std::string legacy_path = std::string(HISTORY_DIR_CHANNEL) + "/" + std::string(32,'0'); + auto legacy = sd.open(legacy_path.c_str(), FILE_WRITE); + legacy.println("<< ambiguous old conversation"); legacy.close(); + const std::string previous = sd.files.at(legacy_path)->bytes; + uint8_t first[32] = {}, second[32] = {}; first[31] = 1; second[31] = 2; + ChatHistory history; + store.loadMessages(first, true, history); + assert(history.count() == 0); + store.storeMessage(first, "first identity", false, true); + store.storeMessage(second, "second identity", true, true); + store.loadMessages(first, true, history); + const HistoryMessage* message = nullptr; + assert(history.count() == 1 && history.get(0,message)); + assert(!strcmp(message->text,"first identity") && !message->out); + store.loadMessages(second, true, history); + assert(history.count() == 1 && history.get(0,message)); + assert(!strcmp(message->text,"second identity") && message->out); + assert(sd.files.size() == 3 && sd.files.at(legacy_path)->bytes == previous); + for (const auto& entry : sd.files) { + if (entry.first == legacy_path) continue; + assert(entry.first.find(std::string(HISTORY_DIR) + "/chan2/") == 0); + assert(entry.first.substr(entry.first.find_last_of('/')+1).size() == 64); + } +} ''' self.run_cpp((VARIANT / "CardputerDataStore.cpp").read_text() + checks, headers=self.storage_headers()) diff --git a/test/test_cardputer_messaging.py b/test/test_cardputer_messaging.py index 36dddc20b..48d5372e1 100644 --- a/test/test_cardputer_messaging.py +++ b/test/test_cardputer_messaging.py @@ -42,15 +42,17 @@ struct Store { }; struct UI { std::vector events; - unsigned contacts = 0, channels = 0; + unsigned contacts = 0, channels = 0, sent_contacts = 0, sent_channels = 0; uint8_t key[32] = {}; void onMessageSendAttempt(uint8_t, uint8_t, MessageSendState state) { events.push_back(state); } void onContactMessageRecv(const ContactInfo& contact, const char*) { ++contacts; memcpy(key, contact.id.pub_key, 32); } - void onChannelMessageRecv(const mesh::GroupChannel& channel, const char*) { - ++channels; memcpy(key, channel.secret, 32); + void onChannelMessageRecv(uint8_t, const uint8_t* id, const char*) { + ++channels; memcpy(key, id, 32); } + void onContactMessageSent(const ContactInfo&, const char*) { ++sent_contacts; } + void onChannelMessageSent(uint8_t, const uint8_t*, const char*) { ++sent_channels; } }; struct MyMesh { bool admit = true, present = true, persist = true, standard_save = true; @@ -58,12 +60,15 @@ struct MyMesh { unsigned sends = 0, standard_saves = 0, path_saves = 0; ContactInfo current; uint8_t sent_path = 0, sent_attempt = 0; - int sendMessage(const ContactInfo& contact, uint32_t, uint8_t attempt, const char*, + virtual void onUiContactMessageSent(const ContactInfo&, const char*) {} + int sendMessage(const ContactInfo& contact, uint32_t, uint8_t attempt, const char* text, uint32_t& ack, uint32_t& estimate) { ++sends; if (!admit) { ack = 0; estimate = 0; return MSG_SEND_FAILED; } sent_path = contact.out_path_len; sent_attempt = attempt; - ack = 1000 + attempt; estimate = timeout; return 1; + ack = 1000 + attempt; estimate = timeout; + if (attempt == 0) onUiContactMessageSent(contact, text); + return 1; } ContactInfo* lookupContactByPubKey(const uint8_t* key, size_t count) { assert(count == 32); @@ -86,7 +91,11 @@ struct CardputerMesh : MyMesh { void checkResend(); void cancelResending(); void onUiContactMessage(const ContactInfo&, const char*); - void onUiChannelMessage(const uint8_t[32], const char*); + void onUiChannelMessage(uint8_t, const char*); + void onUiContactMessageSent(const ContactInfo&, const char*) override; + bool getChannelHistoryId(int index, uint8_t* id) { + assert(index == 7); memset(id, 0, 32); id[31] = 88; return true; + } bool savePrefs(); bool saveCustomPrefs(); void beginCustom(); @@ -104,6 +113,7 @@ int main() { node.current.out_path_len = 2; assert(node.sendDirectMessage(node.current, 5, "message") != MSG_SEND_FAILED); assert(node.resend_timeout == node.now + node.timeout && node.store.writes == 1); + assert(node.ui.sent_contacts == 1); assert(node.last_msg.expected_ack == 1000 && node.last_msg.attempt == 1); assert(node.sendDirectMessage(node.current, 6, "other") == MSG_SEND_FAILED && node.sends == 1); node.current.out_path_len = 3; @@ -111,6 +121,7 @@ int main() { node.checkResend(); assert(node.sent_path == 3 && node.sent_attempt == 1 && node.last_msg.attempt == 2); assert(node.resend_timeout == node.now + 32000 && node.store.writes == 1); + assert(node.ui.sent_contacts == 1); node.admit = false; node.checkResend(); assert(!node.isMessageSending() && node.resend_timeout == 0 && node.last_msg.expected_ack == 0); @@ -149,8 +160,7 @@ int main() { accepted.current.id.pub_key[31] = 77; accepted.onUiContactMessage(accepted.current, "direct"); assert(accepted.ui.contacts == 1 && accepted.store.writes == 1 && accepted.store.key[31] == 77); - uint8_t channel_key[32] = {}; channel_key[31] = 88; - accepted.onUiChannelMessage(channel_key, "group"); + accepted.onUiChannelMessage(7, "group"); assert(accepted.ui.channels == 1 && accepted.store.writes == 2 && accepted.store.key[31] == 88); assert(accepted.ui.key[31] == 88); } @@ -165,6 +175,14 @@ SELECTION = r''' #define PUB_KEY_SIZE 32 struct ContactInfo { struct { uint8_t pub_key[32] = {}; } id; int marker = 0; }; struct ChannelDetails { struct { uint8_t secret[32] = {}; } channel; char name[32] = "channel"; }; +struct CardputerMesh { + static bool channelHistoryId(const ChannelDetails& channel, uint8_t* id) { + memset(id, 0, 32); + for (size_t i=0; i contacts; ChannelDetails channel; @@ -185,6 +203,7 @@ struct ChatPage { int current_contact_idx = 2, current_channel_idx = -1; ContactInfo current_contact; ChannelDetails current_channel; + uint8_t current_channel_history_id[32] = {}; History chat_history; int chat_history_offset = 2; std::string last_sent_message = "previous"; @@ -205,8 +224,11 @@ int main() { page.refreshSelectedContact(); assert(page.current_contact_idx == -1 && page.chat_history.count == 0 && page.last_sent_message.empty()); page.current_channel_idx = 4; page.current_channel = the_mesh_cp.channel; + CardputerMesh::channelHistoryId(page.current_channel, page.current_channel_history_id); strcpy(the_mesh_cp.channel.name, "renamed"); - assert(page.refreshSelectedChannel() && !strcmp(page.current_channel.name, "renamed")); + assert(!page.refreshSelectedChannel() && page.current_channel_idx == -1); + page.current_channel_idx = 4; page.current_channel = the_mesh_cp.channel; + CardputerMesh::channelHistoryId(page.current_channel, page.current_channel_history_id); the_mesh_cp.channel.channel.secret[31] = 1; assert(!page.refreshSelectedChannel() && page.current_channel_idx == -1); page.current_channel_idx = 4; page.current_channel = the_mesh_cp.channel; @@ -225,6 +247,7 @@ class CardputerMessagingTests(unittest.TestCase): methods = "\n".join(body(source, signature) for signature in ( "int CardputerMesh::sendDirectMessage(", "void CardputerMesh::checkResend()", "void CardputerMesh::cancelResending()", "void CardputerMesh::onUiContactMessage(", + "void CardputerMesh::onUiContactMessageSent(", "void CardputerMesh::onUiChannelMessage(", "bool CardputerMesh::saveCustomPrefs()", "bool CardputerMesh::savePrefs()", "void CardputerMesh::beginCustom()")) last_message = body((VARIANT / "types.h").read_text(), "struct LastSentMessage {") + ";" diff --git a/test/test_cardputer_ui.py b/test/test_cardputer_ui.py index 1e3800a9d..d550dbd22 100644 --- a/test/test_cardputer_ui.py +++ b/test/test_cardputer_ui.py @@ -158,7 +158,7 @@ struct CardputerDisplay { static const char* textLevelName(int) { return "Large"; } void setColor(PaletteColor) {} void setTextSize(int) {} - void drawTextCentered(int,int,const char*) {} + void drawTextCentered(int,int,const char* text) { last=text; } void drawTextLeftAlign(int,int,const char*) {} void drawTextEllipsized(int,int,int,const char*) {} void drawTextLeftAlignWithScroll(int,int,int,const char* s) { last=s; } @@ -174,6 +174,7 @@ struct SettingsScreen { Task* _task; String edit_buffer; uint8_t edit_u8=0; + int32_t edit_tx_power=0; int menu_index=RadioFreq; char edit_error[80]="Invalid value"; bool is_editing=true; @@ -261,7 +262,7 @@ int main() { assert(prefs.bw==125 && the_mesh_cp.saves==2); s.edit_u8=4; assert(!s.commitItemEdit(SettingsScreen::RadioSf)); assert(!s.commitItemEdit(SettingsScreen::RadioCr)); - s.edit_u8=23; assert(!s.commitItemEdit(SettingsScreen::RadioPwr)); + s.edit_tx_power=23; assert(!s.commitItemEdit(SettingsScreen::RadioPwr)); assert(prefs.sf==8 && prefs.cr==5 && prefs.tx_power_dbm==22 && the_mesh_cp.saves==2); // The UI must use the live shared command path and leave prefs untouched on failure. assert(the_mesh_cp.command=="set radio 915.250000,125.000000,8,5,96"); @@ -286,6 +287,19 @@ int main() { } s.edit_buffer="4400"; assert(s.commitItemEdit(SettingsScreen::DeviceBatteryCorrection)); assert(custom.battery_correction==1.1f); + // Signed TX values remain visible and editable through zero and both driver limits. + s.menu_index=SettingsScreen::RadioPwr; s.edit_tx_power=-9; + s.render(display); assert(display.last=="- -9 +"); + Keyboard::Event left{Keyboard::ARROW_LEFT,""},right{Keyboard::ARROW_RIGHT,""}; + s.handleEditInput(SettingsScreen::RadioPwr,left); assert(s.edit_tx_power==-9); + s.handleEditInput(SettingsScreen::RadioPwr,right); assert(s.edit_tx_power==-8); + assert(s.commitItemEdit(SettingsScreen::RadioPwr) && prefs.tx_power_dbm==-8); + s.edit_tx_power=-1; s.handleEditInput(SettingsScreen::RadioPwr,right); assert(s.edit_tx_power==0); + assert(s.commitItemEdit(SettingsScreen::RadioPwr) && prefs.tx_power_dbm==0); + s.edit_tx_power=22; s.handleEditInput(SettingsScreen::RadioPwr,right); assert(s.edit_tx_power==22); + for(int32_t invalid:{-129,-10,23,128}) { + s.edit_tx_power=invalid; assert(!s.commitItemEdit(SettingsScreen::RadioPwr)); + } // A missing/failed display cannot turn a battery shutdown into a null dereference. CardputerUITask ui{nullptr,&task.board}; ui.shutdown(); assert(task.board.off==1 && radio_driver.off==1); @@ -330,6 +344,108 @@ int main() { executed = subprocess.run([str(binary)], capture_output=True, text=True) self.assertEqual(executed.returncode, 0, executed.stdout + executed.stderr) + def test_actual_tools_shutdown_release_timeout_and_guard(self): + tools = (UI / "screen/ToolsScreen.cpp").read_text(encoding="ascii") + header = (UI / "screen/ToolsScreen.h").read_text(encoding="ascii") + task = (UI / "CardputerUITask.cpp").read_text(encoding="ascii") + enums = re.search(r"enum ToolsPage.*?enum ToolsMenuItem.*?};", header, re.S).group() + timeout = re.search(r"static constexpr uint32_t POWER_OFF_RELEASE_TIMEOUT_MS = \d+;", header).group() + code = r''' +#include +#include +#include +#include +uint32_t fake_now=0; +unsigned long millis() { return fake_now; } +namespace Keyboard { +enum Key { KEY_RETURN,KEY_ESC,KEY_A }; +struct Event { Key key; bool changed,down; }; +} +struct Board { + unsigned boots=0,offs=0; + void reboot() { ++boots; } + void powerOff() { ++offs; } +}; +struct Display { void turnOff() {} }; +struct Radio { unsigned offs=0; void powerOff() { ++offs; } } radio_driver; +struct CardputerUITask { + Board* _board; + Display* _display=nullptr; + bool allow_shutdown=true; + std::string alert; + bool prepareForShutdown() { return allow_shutdown; } + void showAlert(const char* text,int) { alert=text; } + void dismissAlert() { alert.clear(); } + void shutdown(bool restart=false); +}; +struct Mesh { + bool sendAdvert(bool) { return true; } + bool sendRepeatersDiscover() { return true; } +} the_mesh_cp; +struct ToolsScreen { +''' + code += enums + "\n" + timeout + r''' + CardputerUITask* _task; + ToolsPage page=MenuPage; + uint16_t discovered_repeaters_count=0; + bool power_off_pending=false,power_off_released=false; + uint32_t power_off_deadline=0; + void menuItemEnter(ToolsMenuItem); + bool handlePowerOffInput(const Keyboard::Event&); + void poll(); +}; +''' + code += function(task, "void CardputerUITask::shutdown(") + "\n" + for signature in ("void ToolsScreen::menuItemEnter(", "bool ToolsScreen::handlePowerOffInput(", "void ToolsScreen::poll("): + code += function(tools, signature) + "\n" + code += r''' +int main() { + Board board; CardputerUITask task{&board}; ToolsScreen tools{&task}; + Keyboard::Event held{Keyboard::KEY_RETURN,true,true},released{Keyboard::KEY_RETURN,true,false}; + Keyboard::Event idle{Keyboard::KEY_A,false,false},escape{Keyboard::KEY_ESC,true,true}; + // Menu activation returns immediately while the main loop can continue servicing work. + tools.menuItemEnter(ToolsScreen::PowerOff); + assert(tools.power_off_pending && board.offs==0); + for(unsigned tick=0; ticknotificationPower(mode); } +}; +struct NotificationStore: mesh::notify::Store { + bool save(const mesh::notify::Settings&) override { return true; } +}; struct Mesh { + mesh::notify::Controller* notifications=nullptr; + unsigned button_calls=0; + bool notificationButton() { ++button_calls; return notifications && notifications->button(); } Prefs* prefs=nullptr; CustomPrefs* custom=nullptr; CustomPrefs committed; @@ -181,6 +197,9 @@ struct CardputerUITask { void scheduleRefresh(unsigned ms=0) { next_refresh=millis()+ms; } void setCurrScreen(CardputerScreen* screen) { current_screen=screen; scheduleRefresh(); } bool shouldWakeDisplayForMessage() const; + bool isAlertActive() const; + bool isChatVisible() const; + bool handleNotificationInput(); void showAlert(const char*,int); void serviceDisplayPower(); void servicePairingState(); @@ -204,6 +223,8 @@ struct CardputerUITask { code += "bool CardputerUITask::" + function(abstract, "bool shouldWakeDisplayForMessage() const").removeprefix("bool ") + "\n" for signature in ( "void CardputerUITask::showAlert(", "void CardputerUITask::serviceDisplayPower(", + "bool CardputerUITask::isAlertActive(", "bool CardputerUITask::isChatVisible(", + "bool CardputerUITask::handleNotificationInput(", "void CardputerUITask::servicePairingState(", "bool CardputerUITask::isPairingPromptActive(", "void CardputerUITask::renderPairingPrompt(", "bool CardputerUITask::handlePairingInput(", "bool CardputerUITask::turnDisplayOn(", "bool CardputerUITask::getGPSState(", @@ -327,6 +348,34 @@ int main() { assert(ui.isPairingPromptActive()); fake_now+=119999U; ui.servicePairingState(); assert(ui.isPairingPromptActive()); ++fake_now; ui.servicePairingState(); assert(!ui.isPairingPromptActive()); + // Visibility means the matching conversation is actually readable, including overlays. + ui.current_screen=&main; main.page=MainScreen::CHAT; ui.alert_expiry=0; + board.usb=false; power.battery.mode=mesh::ui::DisplayMode::On; ui.serviceDisplayPower(); + assert(ui.isChatVisible()); + ui.showAlert("overlay",1000); assert(!ui.isChatVisible()); + ui.alert_expiry=0; ui.current_screen=&messages; assert(!ui.isChatVisible()); + ui.current_screen=&main; display.turnOff(); assert(!ui.isChatVisible()); + display.turnOn(); main.page=MainScreen::FIRST; assert(!ui.isChatVisible()); + main.page=MainScreen::CHAT; + interfaces.pending=true; ui.servicePairingState(); assert(!ui.isChatVisible()); + ui.handlePairingInput(escape); assert(ui.isChatVisible()); + // Actual shared controller stops infinite screen overrides on a physical-input action. + NotificationSink sink(&display); NotificationStore store; + mesh::notify::Controller notifications(sink,store); the_mesh_cp.notifications=¬ifications; + auto& rules=notifications.settings(); rules.enabled=1; + auto& rule=rules.rules[0]; rule.used=1; rule.fields=mesh::notify::FScreen; + rule.screen=1; rule.repeat=0; rule.stop=mesh::notify::Button; + assert(notifications.message(nullptr,nullptr,false,fake_now)); + assert(notifications.active()); + unsigned button_calls=the_mesh_cp.button_calls; + assert(ui.handleNotificationInput()); + assert(!notifications.active() && the_mesh_cp.button_calls==button_calls+1); + assert(!ui.handleNotificationInput()); + // Dismissing a notification during a pairing prompt leaves that modal prompt intact. + assert(notifications.message(nullptr,nullptr,false,fake_now)); + interfaces.pending=true; ui.servicePairingState(); + assert(ui.handleNotificationInput() && ui.isPairingPromptActive()); + ui.handlePairingInput(escape); // Failed display initialization leaves every display control and notification safe. ui._display=nullptr; auto saved=the_mesh_cp.saves; @@ -338,8 +387,11 @@ int main() { std::puts("PASS: Cardputer live controls, notifications, shared power and night drawing"); } ''' - self.assertIn("event.changed && event.down && !handlePairingInput(event)", task) - self.assertIn("BUTTON_EVENT_CLICK && !isPairingPromptActive()", task) + self.assertIn("if (handleNotificationInput())", task) + self.assertIn("else if (!power_off_input && !handlePairingInput(event))", task) + self.assertIn("if (!handleNotificationInput() && !isPairingPromptActive()", task) + self.assertIn("tools->handlePowerOffInput(event)", task) + self.assertIn("current_screen != tools_screen) tools->poll()", task) self.assertIn("renderPairingPrompt();\n _display->endFrame();", task) with tempfile.TemporaryDirectory(prefix="cardputer-lifecycle-") as tmp: work = Path(tmp) @@ -357,6 +409,103 @@ int main() { executed = subprocess.run([str(binary)], capture_output=True, text=True) self.assertEqual(executed.returncode, 0, executed.stdout + executed.stderr) + def test_actual_audio_loop_cleanup_waits_for_drain_and_keeps_visible_codec_warm(self): + task = (VARIANT / "companion_radio/ui-cardputer/CardputerUITask.cpp").read_text(encoding="ascii") + speaker = (VARIANT / "hw/CardputerSpeaker.h").read_text(encoding="ascii") + audio_prefix = function(task, "void CardputerUITask::loop(").split(" CardputerKeyboard *kb", 1)[0] + code = r''' +#include +#include +struct Speaker { + bool tone_pending=false,draining=false,finish_drain=false,initialized=true; + unsigned polls=0,sleeps=0,shutdowns=0; + void processQueue() { + ++polls; + if(finish_drain) { draining=false; finish_drain=false; } + } + void sleep() { + ++sleeps; + if(initialized) { initialized=false; ++shutdowns; } + tone_pending=draining=false; + } +''' + code += function(speaker, "bool isBusy() const") + r''' +}; +struct Board { + Speaker speaker; + Speaker* getSpeaker() { return &speaker; } + bool isExternalPowered() const { return false; } + bool isUsbHostConnected() const { return false; } +}; +struct Display { + bool on=false,desired=false; + bool isOn() const { return on; } + bool servicePower(bool,bool,bool) { bool changed=on!=desired; on=desired; return changed; } +}; +struct CardputerUITask { + Board* _board; + Display* _display; + unsigned refreshes=0; + bool hasConnection() const { return false; } + bool isPairingPromptActive() const { return false; } + void scheduleRefresh() { ++refreshes; } + void serviceDisplayPower(); + void servicePairingState() { serviceDisplayPower(); } + void loop(); +}; +''' + code += function(task, "void CardputerUITask::serviceDisplayPower(") + "\n" + code += audio_prefix + "}\n" + code += r''' +int main() { + Board board; Display display; + CardputerUITask ui{&board,&display}; + auto& audio=board.speaker; + // A sound-only notification plays while the screen is already dark. + audio.tone_pending=true; + ui.loop(); assert(audio.polls==1 && audio.sleeps==0 && audio.initialized); // warmup + ui.loop(); assert(audio.polls==2 && audio.sleeps==0 && audio.initialized); // transmitting + audio.tone_pending=false; audio.draining=true; + ui.loop(); assert(audio.polls==3 && audio.sleeps==0 && audio.initialized); // DMA still draining + audio.finish_drain=true; + ui.loop(); assert(audio.polls==4 && audio.sleeps==1 && !audio.initialized && audio.shutdowns==1); + ui.loop(); assert(audio.shutdowns==1); // repeated idle cleanup performs no further hardware shutdown + + // A display timeout must not cancel audio before the post-processQueue idle check. + display.on=true; display.desired=false; + audio.initialized=true; audio.tone_pending=true; + unsigned sleeps=audio.sleeps; + ui.loop(); assert(!display.on && audio.tone_pending && audio.sleeps==sleeps && audio.initialized); + audio.tone_pending=false; audio.draining=true; + ui.loop(); assert(audio.draining && audio.sleeps==sleeps); + audio.finish_drain=true; + ui.loop(); assert(!audio.initialized && audio.sleeps==sleeps+1); + + // Headless sound uses the same complete-playback-then-cleanup lifecycle. + ui._display=nullptr; audio.initialized=true; audio.tone_pending=true; + sleeps=audio.sleeps; ui.loop(); assert(audio.sleeps==sleeps && audio.tone_pending); + audio.tone_pending=false; audio.draining=true; + ui.loop(); assert(audio.sleeps==sleeps && audio.draining); + audio.finish_drain=true; ui.loop(); assert(audio.sleeps==sleeps+1 && !audio.initialized); + + // Visible idle operation retains the warmed codec for keyboard tones. + ui._display=&display; display.on=display.desired=true; audio.initialized=true; + sleeps=audio.sleeps; ui.loop(); assert(audio.sleeps==sleeps && audio.initialized); + std::puts("PASS: actual UI audio loop waits for DMA drain, powers down dark/headless idle, and stays warm while visible"); +} +''' + with tempfile.TemporaryDirectory(prefix="cardputer-audio-lifecycle-") as tmp: + source, binary = Path(tmp) / "test.cpp", Path(tmp) / "test" + source.write_text(code, encoding="ascii") + compiled = subprocess.run([ + "g++", "-std=c++17", "-Wall", "-Wextra", "-Werror", + "-fsanitize=address,undefined", "-fno-omit-frame-pointer", "-no-pie", + str(source), "-o", str(binary), + ], capture_output=True, text=True) + self.assertEqual(compiled.returncode, 0, compiled.stderr) + executed = subprocess.run([str(binary)], capture_output=True, text=True) + self.assertEqual(executed.returncode, 0, executed.stdout + executed.stderr) + if __name__ == "__main__": unittest.main() diff --git a/variants/m5stack_cardputer_adv/CardputerDataStore.cpp b/variants/m5stack_cardputer_adv/CardputerDataStore.cpp index d68c8e309..4c4e2f905 100644 --- a/variants/m5stack_cardputer_adv/CardputerDataStore.cpp +++ b/variants/m5stack_cardputer_adv/CardputerDataStore.cpp @@ -7,6 +7,8 @@ #include "globals.h" +static const char CHANNEL_HISTORY_ID_DIR[] = HISTORY_DIR "/chan2"; + static void buf_to_hex_fast(const unsigned char *src, size_t src_len, char *dst) { static const char hex_digits[] = "0123456789abcdef"; @@ -24,7 +26,8 @@ void CardputerDataStore::beginCustom(FILESYSTEM *history_fs) { } _history_fs->mkdir(CUSTOM_DATA_DIR); _history_fs->mkdir(HISTORY_DIR); - _history_fs->mkdir(HISTORY_DIR_CHANNEL); + // Leave ambiguous legacy PSK-only /chan histories intact for recovery. + _history_fs->mkdir(CHANNEL_HISTORY_ID_DIR); _history_fs->mkdir(HISTORY_DIR_DIRECT); } @@ -129,9 +132,7 @@ void CardputerDataStore::storeMessage(const uint8_t pkey[PUB_KEY_SIZE], const ch char path[128]; char msg_copy[MAX_MESSAGE_LENGTH + 1] = { 0 }; char pkey_hex[PUB_KEY_SIZE * 2 + 1]; - uint8_t key_len = is_channel ? (PUB_KEY_SIZE / 2) : PUB_KEY_SIZE; - - buf_to_hex_fast(pkey, key_len, pkey_hex); + buf_to_hex_fast(pkey, PUB_KEY_SIZE, pkey_hex); int msg_len; for (msg_len = 0; msg_len < MAX_MESSAGE_LENGTH && text[msg_len] != '\0'; msg_len++) { @@ -144,7 +145,7 @@ void CardputerDataStore::storeMessage(const uint8_t pkey[PUB_KEY_SIZE], const ch msg_copy[msg_len] = '\0'; - snprintf(path, sizeof(path), "%s/%s", is_channel ? HISTORY_DIR_CHANNEL : HISTORY_DIR_DIRECT, pkey_hex); + snprintf(path, sizeof(path), "%s/%s", is_channel ? CHANNEL_HISTORY_ID_DIR : HISTORY_DIR_DIRECT, pkey_hex); File file = _history_fs->open(path, FILE_APPEND, true); @@ -183,11 +184,9 @@ void CardputerDataStore::loadMessages(const uint8_t pkey[PUB_KEY_SIZE], bool is_ char line_buf[256] = { 0 }; uint8_t file_read_buf[1024]; - uint8_t key_len = is_channel ? (PUB_KEY_SIZE / 2) : PUB_KEY_SIZE; + buf_to_hex_fast(pkey, PUB_KEY_SIZE, pkey_hex); - buf_to_hex_fast(pkey, key_len, pkey_hex); - - snprintf(path, sizeof(path), "%s/%s", is_channel ? HISTORY_DIR_CHANNEL : HISTORY_DIR_DIRECT, pkey_hex); + snprintf(path, sizeof(path), "%s/%s", is_channel ? CHANNEL_HISTORY_ID_DIR : HISTORY_DIR_DIRECT, pkey_hex); if (!_history_fs->exists(path)) { return; diff --git a/variants/m5stack_cardputer_adv/CardputerDataStore.h b/variants/m5stack_cardputer_adv/CardputerDataStore.h index b9f8552ea..111e3c856 100644 --- a/variants/m5stack_cardputer_adv/CardputerDataStore.h +++ b/variants/m5stack_cardputer_adv/CardputerDataStore.h @@ -22,6 +22,9 @@ using ChatHistory = RingBuffer; // Contacts, channels and node prefs stay on the primary (internal) FS managed // by DataStore. Cardputer UI prefs live there too; per-chat history is kept // only when a microSD card is mounted. +// Channel identities include the complete name/protocol alias and 32-byte key. +// Legacy /history/chan PSK-only logs remain on SD for manual recovery; they +// cannot be assigned safely to one channel and are never silently imported. class CardputerDataStore : public DataStore { private: FILESYSTEM *_fs; @@ -36,6 +39,7 @@ public: bool hasHistory() const { return _history_fs != nullptr; } bool loadCustomPrefs(CustomNodePrefs &prefs); bool saveCustomPrefs(const CustomNodePrefs &prefs); + // For channels, pkey is the 32-byte channelHistoryId, not the wire PSK. void storeMessage(const uint8_t pkey[PUB_KEY_SIZE], const char *text, bool is_sent, bool is_channel); void loadMessages(const uint8_t pkey[PUB_KEY_SIZE], bool is_channel, ChatHistory &history); }; diff --git a/variants/m5stack_cardputer_adv/CardputerSensorManager.cpp b/variants/m5stack_cardputer_adv/CardputerSensorManager.cpp index 4372c57aa..202f6124a 100644 --- a/variants/m5stack_cardputer_adv/CardputerSensorManager.cpp +++ b/variants/m5stack_cardputer_adv/CardputerSensorManager.cpp @@ -7,6 +7,7 @@ bool due(uint32_t now, uint32_t deadline) { } } +#if ENV_INCLUDE_GPS void CardputerSensorManager::armGpsPowerSavingCycle() { if (!powersaving_enabled || !_location->getGPSPowerSaving()) return; _location->syncTimeForPowerSavingCycle(); @@ -40,11 +41,18 @@ void CardputerSensorManager::stop_gps() { else _location->setNextGPSOn(0); _location->stop(); } +#endif void CardputerSensorManager::gps_standby(bool active) { // CASIC protocol sections CAS10/CAS12: hot start and standby for at most // 65535 seconds. The provider's MicroNMEA sender generates the checksum. +#if ENV_INCLUDE_GPS _location->sendSentence(active ? "$PCAS12,65535" : "$PCAS10,0"); +#else + // GPS-free roles still have a powered physical receiver. Keep it in standby + // without linking a parser/provider or advertising a GPS firmware feature. + Serial1.print(active ? "$PCAS12,65535*1E\r\n" : "$PCAS10,0*1C\r\n"); +#endif standby_renew_at = static_cast(millis()) + STANDBY_RENEW_MS; } @@ -55,25 +63,49 @@ bool CardputerSensorManager::begin() { Serial1.begin(GPS_BAUD_RATE); gps_serial_open = true; gps_active = false; +#if ENV_INCLUDE_GPS _location->setGPSPowerSaving(false); _location->stopTimeSync(); _location->stop(); +#endif // A fixed cap powers GNSS at boot even when the saved preference is off. // Silence it explicitly; UART traffic during startup is not a user choice. gps_standby(true); +#if !CARDPUTER_ADV_UI +#if ENV_INCLUDE_GPS + // The fixed cap uses command-based standby. Keep the generic manager's GPS + // discovery/transport permanently blocked: it must not reset or reopen this + // UART independently. Its environmental discovery and telemetry stay intact. + CardputerSensorBase::gps_serial_transport_blocked = true; +#endif + return CardputerSensorBase::begin(); +#else return true; +#endif } bool CardputerSensorManager::querySensors(uint8_t requester_permissions, CayenneLPP &telemetry) { // Shared cache, permissions, bounded acquisition and manual-off cancellation. +#if !CARDPUTER_ADV_UI + return CardputerSensorBase::querySensors(requester_permissions, telemetry); +#elif ENV_INCLUDE_GPS queryGpsTelemetry(requester_permissions, telemetry); +#endif return true; } void CardputerSensorManager::loop() { if (!gps_serial_open) return; +#if !CARDPUTER_ADV_UI && (ENV_INCLUDE_GPS || defined(ENV_INCLUDE_BME680_BSEC)) + // Generic GPS discovery is blocked and its gps_detected/active remain false. + // This services external BSEC sensors and the one shared GPS telemetry cache. + CardputerSensorBase::loop(); +#endif const uint32_t now = millis(); +#if ENV_INCLUDE_GPS +#if CARDPUTER_ADV_UI loopGpsTelemetry(now); +#endif if (powersaving_enabled && isGpsTelemetryUserEnabled() && !isTrackerGpsModeEnabled() && _location->getGPSPowerSaving()) { const uint32_t next_off = _location->getNextGPSOff(); @@ -86,12 +118,14 @@ void CardputerSensorManager::loop() { start_gps(); } } +#endif if (!gps_active) { // CAS12 eventually wakes on its own. Renew off/standby without changing // saved intent or creating a GPS telemetry/clock acquisition. if (due(now, standby_renew_at)) gps_standby(true); return; } +#if ENV_INCLUDE_GPS _location->loop(); if (due(now, next_gps_update)) { if (_location->isValid()) { @@ -102,20 +136,36 @@ void CardputerSensorManager::loop() { } next_gps_update = now + getGpsUpdateIntervalMillis(); } +#endif } -int CardputerSensorManager::getNumSettings() const { return 1; } +int CardputerSensorManager::getNumSettings() const { + return CardputerSensorBase::getNumSettings() +#if ENV_INCLUDE_GPS + + 1 +#endif + ; +} const char *CardputerSensorManager::getSettingName(int i) const { - return i == 0 ? "gps" : nullptr; +#if ENV_INCLUDE_GPS + if (i == 0) return "gps"; + --i; +#endif + return CardputerSensorBase::getSettingName(i); } const char *CardputerSensorManager::getSettingValue(int i) const { - return i == 0 ? (isGpsTelemetryUserEnabled() ? "1" : "0") : nullptr; +#if ENV_INCLUDE_GPS + if (i == 0) return isGpsTelemetryUserEnabled() ? "1" : "0"; + --i; +#endif + return CardputerSensorBase::getSettingValue(i); } bool CardputerSensorManager::setSettingValue(const char *name, const char *value) { if (!name || !value) return false; +#if ENV_INCLUDE_GPS if (strcmp(name, "gps") == 0) { const bool enabled = strcmp(value, "0") != 0; const bool was_active = gps_active; @@ -129,12 +179,16 @@ bool CardputerSensorManager::setSettingValue(const char *name, const char *value } return true; } - const bool accepted = SensorManager::setSettingValue(name, value); +#endif + const bool accepted = CardputerSensorBase::setSettingValue(name, value); +#if ENV_INCLUDE_GPS if (accepted && strcmp(name, "gps_interval") == 0) next_gps_update = millis(); +#endif return accepted; } void CardputerSensorManager::setPowerSavingEnabled(bool enabled) { +#if ENV_INCLUDE_GPS if (powersaving_enabled == enabled) return; powersaving_enabled = enabled; if (isTrackerGpsModeEnabled()) { @@ -146,11 +200,18 @@ void CardputerSensorManager::setPowerSavingEnabled(bool enabled) { if (!user_enabled) return; if (!gps_active) start_gps(); else if (enabled) armGpsPowerSavingCycle(); +#else + CardputerSensorBase::setPowerSavingEnabled(enabled); +#endif } void CardputerSensorManager::shutdownGpsTransportForDeepSleep() { if (!gps_serial_open) return; +#if ENV_INCLUDE_GPS stop_gps(); +#else + gps_standby(true); +#endif Serial1.end(); gps_serial_open = false; } diff --git a/variants/m5stack_cardputer_adv/CardputerSensorManager.h b/variants/m5stack_cardputer_adv/CardputerSensorManager.h index 37f821296..c8d27f48c 100644 --- a/variants/m5stack_cardputer_adv/CardputerSensorManager.h +++ b/variants/m5stack_cardputer_adv/CardputerSensorManager.h @@ -2,13 +2,20 @@ #include #include +#if CARDPUTER_ADV_UI +using CardputerSensorBase = SensorManager; +#else +#include +using CardputerSensorBase = EnvironmentSensorManager; +#endif -class CardputerSensorManager : public SensorManager { +class CardputerSensorManager : public CardputerSensorBase { private: bool gps_active = false; bool gps_serial_open = false; uint32_t next_gps_update = 0; uint32_t standby_renew_at = 0; +#if ENV_INCLUDE_GPS LocationProvider *_location; void armGpsPowerSavingCycle(); @@ -17,9 +24,18 @@ protected: bool telemetryGpsActive() const override { return gps_active; } void telemetryGpsStart() override { start_gps(); } void telemetryGpsStop() override { stop_gps(); } +#endif public: +#if ENV_INCLUDE_GPS +#if CARDPUTER_ADV_UI CardputerSensorManager(LocationProvider &location) : _location(&location) {} +#else + CardputerSensorManager(LocationProvider &location) : CardputerSensorBase(location), _location(&location) {} +#endif +#else + CardputerSensorManager() = default; +#endif bool begin() override; bool querySensors(uint8_t requester_permissions, CayenneLPP &telemetry) override; void loop() override; @@ -28,14 +44,33 @@ public: const char *getSettingValue(int i) const override; bool setSettingValue(const char *name, const char *value) override; void setPowerSavingEnabled(bool enabled) override; - bool isGPSDetected() const override { return gps_serial_open; } + bool isGPSDetected() const override { +#if ENV_INCLUDE_GPS + return gps_serial_open; +#else + return false; +#endif + } // The cap has command-controlled standby, not a GPIO power switch. A // pinless LocationProvider::isEnabled() cannot report its logical state. bool isGpsPowered() const override { return gps_active; } bool gpsUsesSerialUart(uint8_t uart) const override { return uart == 1 && gps_serial_open; } + // The cap cannot prove electrical silence without an enable pin. Do not + // let the environmental base or a serial bridge release this fixed UART. + bool gpsSerialTransportMayConflict(uint8_t uart) const override { return gpsUsesSerialUart(uart); } + bool gpsSerialTransportCanYield(uint8_t) const override { return false; } + bool setGpsSerialTransportBlocked(uint8_t, bool) override { return false; } void shutdownGpsTransportForDeepSleep() override; - LocationProvider *getLocationProvider() override { return _location; } + LocationProvider *getLocationProvider() override { +#if ENV_INCLUDE_GPS + return _location; +#else + return nullptr; +#endif + } +#if ENV_INCLUDE_GPS void start_gps(); void stop_gps(); +#endif void gps_standby(bool active); }; diff --git a/variants/m5stack_cardputer_adv/README.md b/variants/m5stack_cardputer_adv/README.md index c00e10bcf..7ff22915b 100644 --- a/variants/m5stack_cardputer_adv/README.md +++ b/variants/m5stack_cardputer_adv/README.md @@ -37,6 +37,17 @@ history and keyboard layouts. Companion builds retain all 350 contacts and use the existing bounded contact path cache to leave RAM available for Bluetooth, the display and keyboard layouts. +Accepted incoming messages and successful sends from the keyboard, phone app +or USB terminal share the same local history. Retries do not add another copy. +Only the conversation actually visible on the screen is marked read. + +Channel history identifies each channel by its full name (including protocol +prefix) and full key, so channels sharing a key keep separate conversations. +New channel logs use `/meshcore_custom/history/chan2` on the SD card. Older +`/meshcore_custom/history/chan` logs remain on the card for manual retrieval; +they are not imported because their channel identity can be ambiguous. Direct +message logs keep their existing paths. + When the device is managed by bmorcelli's Launcher, install updates by writing the app image to the MeshCore app slot over USB rather than reinstalling from the SD card: Launcher can repartition the flash to fit the image and drop the @@ -72,7 +83,8 @@ Extra large (19 px). The larger fonts are generated from DejaVu Sans with Radio settings use the same live apply and save path as the USB terminal; a reboot is not required. A busy radio applies the saved setting once idle. Rejected settings or storage failures show an error -and retain the previous saved value. Display brightness, text size and battery +and retain the previous saved value. TX power supports the radio's signed range, +including negative values. Display brightness, text size and battery correction are saved separately from the standard node preferences. Screen wake and timeout follow the shared `display.mode` and `display.timeout` @@ -91,8 +103,14 @@ Bluetooth pairing requests show the PIN for up to two minutes when the selected screen mode permits it. Esc dismisses the prompt; the current chat and draft remain selected. An explicit screen-off setting keeps the screen dark. -The speaker plays fixed UI tones and honors notification sound output and mute -settings. Custom RTTTL notification melodies are unsupported on this board. +The speaker plays fixed UI tones without blocking the mesh loop and honors +the saved notification outputs and mute settings. A key or top-button press +dismisses notifications configured to stop on button input. Custom RTTTL +notification melodies are unsupported on this board. + +Tools -> Restart and Power off flush pending contact writes before shutdown. +Power off waits for Enter release without blocking radio processing; holding +the key for two seconds or pressing Esc cancels it. ## Third-party notices @@ -112,6 +130,9 @@ neither dependency is bundled here. or at the end of its 10-minute acquisition window, then wakes after 30 minutes. Location telemetry can temporarily hold it awake under the common GPS policy. Turning GPS off cancels current acquisitions; saved GPS intervals apply. +- Repeater and room-server builds also put the Cap's fixed GNSS receiver into + standby when unused, while retaining external sensor support. GPS-free builds + send the standby command without adding a GPS parser or GPS settings. - Below 3.4 V the UI shows "Battery low" and powers off (no LoRa wake). ## Building on Windows diff --git a/variants/m5stack_cardputer_adv/UnreadCounter.h b/variants/m5stack_cardputer_adv/UnreadCounter.h index 933c9378f..25def1034 100644 --- a/variants/m5stack_cardputer_adv/UnreadCounter.h +++ b/variants/m5stack_cardputer_adv/UnreadCounter.h @@ -6,7 +6,7 @@ struct UnreadCounterItem { bool is_channel; - uint8_t pkey[CONTACT_LOOKUP_BYTES]; // todo: maybe replace to index, but what should we do if it gets deleted? + uint8_t pkey[32]; // Full contact public key or domain-separated channel ID. int count; }; @@ -15,7 +15,7 @@ class UnreadCounter { private: UnreadCounterItem _items[UNREAD_COUNTER_MAX_ITEMS]; uint16_t _count = 0; - using LookupPkey = uint8_t[CONTACT_LOOKUP_BYTES]; + using LookupPkey = uint8_t[32]; public: UnreadCounter() {} @@ -27,8 +27,8 @@ public: return &_items[index]; } - inline const uint16_t countChats() const { return _count; } - const uint16_t countMessages() const { + inline uint16_t countChats() const { return _count; } + uint16_t countMessages() const { uint16_t total = 0; for (uint16_t i = 0; i < _count; i++) { total += _items[i].count; @@ -38,7 +38,7 @@ public: bool add(bool is_channel, const LookupPkey pkey, int count) { for (uint16_t i = 0; i < _count; i++) { - if (_items[i].is_channel == is_channel && memcmp(_items[i].pkey, pkey, CONTACT_LOOKUP_BYTES) == 0) { + if (_items[i].is_channel == is_channel && memcmp(_items[i].pkey, pkey, sizeof(_items[i].pkey)) == 0) { _items[i].count += count; return true; } @@ -47,7 +47,7 @@ public: if (_count < UNREAD_COUNTER_MAX_ITEMS) { _items[_count].is_channel = is_channel; _items[_count].count = count; - memcpy(_items[_count].pkey, pkey, CONTACT_LOOKUP_BYTES); + memcpy(_items[_count].pkey, pkey, sizeof(_items[_count].pkey)); _count++; return true; } @@ -56,14 +56,14 @@ public: void reset(bool is_channel, const LookupPkey pkey) { for (uint16_t i = 0; i < _count; i++) { - if (_items[i].is_channel == is_channel && memcmp(_items[i].pkey, pkey, CONTACT_LOOKUP_BYTES) == 0) { + if (_items[i].is_channel == is_channel && memcmp(_items[i].pkey, pkey, sizeof(_items[i].pkey)) == 0) { // Swap the target item with the last active item if (i < _count - 1) { _items[i] = _items[_count - 1]; } _count--; - _items[_count] = { false, 0, 0 }; + _items[_count] = {}; return; } } diff --git a/variants/m5stack_cardputer_adv/companion_radio/CardputerMesh.cpp b/variants/m5stack_cardputer_adv/companion_radio/CardputerMesh.cpp index d4b263c07..72a326b29 100644 --- a/variants/m5stack_cardputer_adv/companion_radio/CardputerMesh.cpp +++ b/variants/m5stack_cardputer_adv/companion_radio/CardputerMesh.cpp @@ -59,11 +59,31 @@ mesh::DispatcherAction CardputerMesh::onRecvPacket(mesh::Packet *pkt) { return action; } -void CardputerMesh::onUiChannelMessage(const uint8_t secret[32], const char *text) { - mesh::GroupChannel channel{}; - memcpy(channel.secret, secret, sizeof(channel.secret)); - _store->storeMessage(secret, text, false, true); - _ui->onChannelMessageRecv(channel, text); +bool CardputerMesh::channelHistoryId(const ChannelDetails &channel, uint8_t id[PUB_KEY_SIZE]) { + static const uint8_t domain[] = "Cardputer channel history v2"; + const size_t name_length = strnlen(channel.name, sizeof(channel.name)); + if (!name_length || name_length == sizeof(channel.name)) return false; + uint8_t identity[sizeof(domain) + 1 + sizeof(channel.name) + sizeof(channel.channel.secret)]; + size_t used = 0; + memcpy(identity, domain, sizeof(domain)); used += sizeof(domain); + identity[used++] = static_cast(name_length); + memcpy(identity + used, channel.name, name_length); used += name_length; + memcpy(identity + used, channel.channel.secret, sizeof(channel.channel.secret)); + used += sizeof(channel.channel.secret); + mesh::Utils::sha256(id, PUB_KEY_SIZE, identity, used); + return true; +} + +bool CardputerMesh::getChannelHistoryId(int channel_idx, uint8_t id[PUB_KEY_SIZE]) { + ChannelDetails channel; + return getChannel(channel_idx, channel) && channelHistoryId(channel, id); +} + +void CardputerMesh::onUiChannelMessage(uint8_t channel_idx, const char *text) { + uint8_t id[PUB_KEY_SIZE]; + if (!getChannelHistoryId(channel_idx, id)) return; + _store->storeMessage(id, text, false, true); + _ui->onChannelMessageRecv(channel_idx, id, text); } void CardputerMesh::onUiContactMessage(const ContactInfo &from, const char *text) { @@ -71,6 +91,22 @@ void CardputerMesh::onUiContactMessage(const ContactInfo &from, const char *text _ui->onContactMessageRecv(from, text); } +void CardputerMesh::onUiContactMessageSent(const ContactInfo &recipient, const char *text) { + _store->storeMessage(recipient.id.pub_key, text, true, false); + _ui->onContactMessageSent(recipient, text); +} + +void CardputerMesh::onUiChannelMessageSent(uint8_t channel_idx, const char *text, size_t length) { + uint8_t id[PUB_KEY_SIZE]; + if (!getChannelHistoryId(channel_idx, id)) return; + char stored_text[MAX_MESSAGE_LENGTH + 1]; + const size_t stored_length = length < MAX_MESSAGE_LENGTH ? length : MAX_MESSAGE_LENGTH; + memcpy(stored_text, text, stored_length); + stored_text[stored_length] = 0; + _store->storeMessage(id, stored_text, true, true); + _ui->onChannelMessageSent(channel_idx, id, stored_text); +} + bool CardputerMesh::sendFloodScoped(const mesh::GroupChannel &channel, mesh::Packet *pkt, uint32_t delay_millis) { const bool is_group_text = pkt && pkt->getPayloadType() == PAYLOAD_TYPE_GRP_TXT; @@ -90,15 +126,7 @@ bool CardputerMesh::sendFloodScoped(const mesh::GroupChannel &channel, mesh::Pac } bool CardputerMesh::sendChannelMessage(int channel_idx, uint32_t timestamp, const char *text, size_t length) { - ChannelDetails channel; - if (!getChannel(channel_idx, channel) || !channel.name[0] - || !sendUiChannelMessage(channel_idx, timestamp, text, length)) return false; - char stored_text[MAX_MESSAGE_LENGTH + 1]; - const size_t stored_length = length < MAX_MESSAGE_LENGTH ? length : MAX_MESSAGE_LENGTH; - memcpy(stored_text, text, stored_length); - stored_text[stored_length] = 0; - _store->storeMessage(channel.channel.secret, stored_text, true, true); - return true; + return sendUiChannelMessage(channel_idx, timestamp, text, length); } int CardputerMesh::sendDirectMessage(const ContactInfo &recipient, uint32_t timestamp, const char *text) { @@ -121,7 +149,6 @@ int CardputerMesh::sendDirectMessage(const ContactInfo &recipient, uint32_t time const uint32_t minimum_timeout = flood ? DIRECT_SEND_FLOOD_RESEND_RELAY : DIRECT_SEND_ROUTE_RESEND_RELAY; resend_timeout = futureMillis(est_timeout > minimum_timeout ? est_timeout : minimum_timeout); _ui->onMessageSendAttempt(last_msg.attempt, last_msg.total_attempts, last_msg.state); - _store->storeMessage(recipient.id.pub_key, text, true, false); return rc; } diff --git a/variants/m5stack_cardputer_adv/companion_radio/CardputerMesh.h b/variants/m5stack_cardputer_adv/companion_radio/CardputerMesh.h index 68230788e..4a5d7e3d2 100644 --- a/variants/m5stack_cardputer_adv/companion_radio/CardputerMesh.h +++ b/variants/m5stack_cardputer_adv/companion_radio/CardputerMesh.h @@ -31,12 +31,16 @@ public: const uint8_t *path_snrs, const uint8_t *path_hashes, uint8_t path_len) override; void onControlDataRecv(mesh::Packet *packet) override; mesh::DispatcherAction onRecvPacket(mesh::Packet *pkt) override; - void onUiChannelMessage(const uint8_t secret[32], const char *text) override; + void onUiChannelMessage(uint8_t channel_idx, const char *text) override; void onUiContactMessage(const ContactInfo &from, const char *text) override; + void onUiContactMessageSent(const ContactInfo &recipient, const char *text) override; + void onUiChannelMessageSent(uint8_t channel_idx, const char *text, size_t length) override; bool sendFloodScoped(const mesh::GroupChannel &channel, mesh::Packet *pkt, uint32_t delay_millis = 0) override; using MyMesh::sendFloodScoped; bool sendChannelMessage(int channel_idx, uint32_t timestamp, const char *text, size_t length); + static bool channelHistoryId(const ChannelDetails &channel, uint8_t id[PUB_KEY_SIZE]); + bool getChannelHistoryId(int channel_idx, uint8_t id[PUB_KEY_SIZE]); int sendDirectMessage(const ContactInfo &recipient, uint32_t timestamp, const char *text); diff --git a/variants/m5stack_cardputer_adv/companion_radio/ui-cardputer/CardputerUITask.cpp b/variants/m5stack_cardputer_adv/companion_radio/ui-cardputer/CardputerUITask.cpp index 2fcbea56b..7255ff8d9 100644 --- a/variants/m5stack_cardputer_adv/companion_radio/ui-cardputer/CardputerUITask.cpp +++ b/variants/m5stack_cardputer_adv/companion_radio/ui-cardputer/CardputerUITask.cpp @@ -47,10 +47,16 @@ void CardputerUITask::dismissAlert() { scheduleRefresh(); } -bool CardputerUITask::isAlertActive() { +bool CardputerUITask::isAlertActive() const { return millis() < alert_expiry; } +bool CardputerUITask::isChatVisible() const { + return _display && _display->isOn() && current_screen == main_screen + && static_cast(main_screen)->getCurrentPage() == MainScreen::CHAT + && !isPairingPromptActive() && !isAlertActive(); +} + void CardputerUITask::notify(UIEventType t) { if ((_notification_outputs & mesh::notify::Sound) && !_message_notification_override && (t == UIEventType::channelMessage || t == UIEventType::contactMessage)) { @@ -120,7 +126,7 @@ void CardputerUITask::serviceDisplayPower() { if (_display->servicePower(_board->isExternalPowered() || _board->isUsbHostConnected(), hasConnection(), isPairingPromptActive())) { scheduleRefresh(); - if (!_display->isOn()) _board->getSpeaker()->sleep(); + if (!_display->isOn() && !_board->getSpeaker()->isBusy()) _board->getSpeaker()->sleep(); } } @@ -157,6 +163,13 @@ bool CardputerUITask::handlePairingInput(const Keyboard::Event &event) { return true; } +bool CardputerUITask::handleNotificationInput() { + if (!the_mesh_cp.notificationButton()) return false; + serviceDisplayPower(); + scheduleRefresh(); + return true; +} + void CardputerUITask::renderPairingPrompt() { if (!_display || !_display->isOn() || !isPairingPromptActive()) return; char pin[16]; @@ -172,8 +185,18 @@ void CardputerUITask::renderPairingPrompt() { _display->drawTextCentered(_display->width() / 2, 94, "Enter PIN on phone"); } -void CardputerUITask::onChannelMessageRecv(const mesh::GroupChannel &channel, const char *text) { - ((MainScreen *)main_screen)->onChannelMessageRecv(channel, text); +void CardputerUITask::onChannelMessageRecv(uint8_t channel_idx, const uint8_t history_id[32], const char *text) { + static_cast(main_screen)->onChannelMessageRecv(channel_idx, history_id, text); +} + +void CardputerUITask::onChannelMessageSent(uint8_t channel_idx, const uint8_t history_id[32], const char *text) { + static_cast(main_screen)->onChannelMessageSent(channel_idx, history_id, text); + scheduleRefresh(); +} + +void CardputerUITask::onContactMessageSent(const ContactInfo &contact, const char *text) { + static_cast(main_screen)->onContactMessageSent(contact, text); + scheduleRefresh(); } void CardputerUITask::onContactMessageRecv(const ContactInfo &contact, const char *text) { @@ -214,7 +237,7 @@ void CardputerUITask::setCurrScreen(CardputerScreen *c) { */ void CardputerUITask::shutdown(bool restart) { if (!prepareForShutdown()) { - showAlert("Shutdown blocked: update in progress", 2000); + showAlert("Shutdown blocked: storage not ready", 2000); return; } if (restart) { @@ -229,26 +252,40 @@ void CardputerUITask::shutdown(bool restart) { void CardputerUITask::loop() { servicePairingState(); _board->getSpeaker()->processQueue(); + if ((!_display || !_display->isOn()) && !_board->getSpeaker()->isBusy()) { + _board->getSpeaker()->sleep(); + } CardputerKeyboard *kb = _board->getKeyboard(); const Keyboard::Modifiers latched = kb->getModifiers(); auto event = kb->poll(); - if (event.changed && event.down && !handlePairingInput(event)) { - if (turnDisplayOn()) { - kb->setModifiers(latched); // a wake-up press must not flip Shift/Fn - } else { - playSound(SoundType::Keyboard); - if (!handleShortcut(event) && current_screen) { - current_screen->handleInput(event); + ToolsScreen *tools = static_cast(tools_screen); + const bool power_off_input = tools && tools->handlePowerOffInput(event); + if (event.changed && event.down) { + if (handleNotificationInput()) { + kb->setModifiers(latched); // dismissal must not also arm a shortcut + } else if (!power_off_input && !handlePairingInput(event)) { + if (turnDisplayOn()) { + kb->setModifiers(latched); // a wake-up press must not flip Shift/Fn + } else { + playSound(SoundType::Keyboard); + if (!handleShortcut(event) && current_screen) { + current_screen->handleInput(event); + } } } } - if (_top_btn.check() == BUTTON_EVENT_CLICK && !isPairingPromptActive()) { - turnDisplayOn(); - sendFloodAdvert(); + if (_top_btn.check() == BUTTON_EVENT_CLICK) { + if (!handleNotificationInput() && !isPairingPromptActive() + && !(tools && tools->isPowerOffPending())) { + turnDisplayOn(); + sendFloodAdvert(); + } } + // A received-message preview must not suspend an armed release/timeout. + if (tools && current_screen != tools_screen) tools->poll(); if (current_screen) { current_screen->poll(); diff --git a/variants/m5stack_cardputer_adv/companion_radio/ui-cardputer/CardputerUITask.h b/variants/m5stack_cardputer_adv/companion_radio/ui-cardputer/CardputerUITask.h index 711a762c4..f3cc54342 100644 --- a/variants/m5stack_cardputer_adv/companion_radio/ui-cardputer/CardputerUITask.h +++ b/variants/m5stack_cardputer_adv/companion_radio/ui-cardputer/CardputerUITask.h @@ -48,6 +48,7 @@ class CardputerUITask : public AbstractUITask { void serviceDisplayPower(); void renderPairingPrompt(); bool handlePairingInput(const Keyboard::Event &event); + bool handleNotificationInput(); // Returns false if display is already on bool turnDisplayOn(); @@ -68,7 +69,8 @@ public: int drawModifierBadges(CardputerDisplay &lcd, int x, int y); void showAlert(const char *text, int duration_millis); void dismissAlert(); - bool isAlertActive(); + bool isAlertActive() const; + bool isChatVisible() const; inline int getMsgCount() const { return unsynced_msg_count; } inline bool hasDisplay() const { return _display != NULL; } inline bool isBuzzerQuiet() { return _node_prefs->buzzer_quiet; } @@ -88,7 +90,9 @@ public: void newMsg(uint8_t path_len, const char *from_name, const char *text, int msgcount, int channel_idx = -1, const char *channel_name = nullptr, int queue_index = -1) override; - void onChannelMessageRecv(const mesh::GroupChannel &channel, const char *text); + void onChannelMessageRecv(uint8_t channel_idx, const uint8_t history_id[32], const char *text); + void onChannelMessageSent(uint8_t channel_idx, const uint8_t history_id[32], const char *text); + void onContactMessageSent(const ContactInfo &contact, const char *text); void onContactMessageRecv(const ContactInfo &contact, const char *text); void onMessageSendAttempt(uint8_t attempt, uint8_t total, MessageSendState state); diff --git a/variants/m5stack_cardputer_adv/companion_radio/ui-cardputer/screen/MainScreen.cpp b/variants/m5stack_cardputer_adv/companion_radio/ui-cardputer/screen/MainScreen.cpp index d0857d438..c227a6f1d 100644 --- a/variants/m5stack_cardputer_adv/companion_radio/ui-cardputer/screen/MainScreen.cpp +++ b/variants/m5stack_cardputer_adv/companion_radio/ui-cardputer/screen/MainScreen.cpp @@ -92,6 +92,7 @@ void MainScreen::renderStatusIcons(CardputerDisplay &lcd) { } int MainScreen::render(CardputerDisplay &lcd) { + if (_task->isChatVisible()) static_cast(chat_page)->markRead(unread); lcd.setTextSize(1); renderStatusIcons(lcd); @@ -139,14 +140,22 @@ void MainScreen::messageRepeatsRecv(uint16_t count) { } } -void MainScreen::onChannelMessageRecv(const mesh::GroupChannel &channel, const char *text) { - bool to_selected = static_cast(chat_page)->onChannelMessageRecv(channel, text); +void MainScreen::onChannelMessageRecv(uint8_t channel_idx, const uint8_t history_id[32], const char *text) { + bool to_selected = static_cast(chat_page)->onChannelMessageRecv(channel_idx, history_id, text); if (!to_selected) { - unread.addChannel(channel.secret, 1); + unread.addChannel(history_id, 1); } } +void MainScreen::onChannelMessageSent(uint8_t channel_idx, const uint8_t history_id[32], const char *text) { + static_cast(chat_page)->onChannelMessageSent(channel_idx, history_id, text); +} + +void MainScreen::onContactMessageSent(const ContactInfo &contact, const char *text) { + static_cast(chat_page)->onContactMessageSent(contact, text); +} + void MainScreen::onContactMessageRecv(const ContactInfo &contact, const char *text) { bool to_selected = static_cast(chat_page)->onContactMessageRecv(contact, text); @@ -244,9 +253,11 @@ void MainScreen::selectContact(ContactInfo &contact, int idx) { } void MainScreen::selectChannel(const ChannelDetails &channel, int idx) { + uint8_t history_id[PUB_KEY_SIZE]; + if (!CardputerMesh::channelHistoryId(channel, history_id)) return; setCurrentPage(MainScreenPage::CHAT); page_to_return = MainScreenPage::CHANNELS; - unread.resetChannel(channel.channel.secret); + unread.resetChannel(history_id); static_cast(chat_page)->selectChannel(channel, idx); } diff --git a/variants/m5stack_cardputer_adv/companion_radio/ui-cardputer/screen/MainScreen.h b/variants/m5stack_cardputer_adv/companion_radio/ui-cardputer/screen/MainScreen.h index 5bb181a18..58b35c568 100644 --- a/variants/m5stack_cardputer_adv/companion_radio/ui-cardputer/screen/MainScreen.h +++ b/variants/m5stack_cardputer_adv/companion_radio/ui-cardputer/screen/MainScreen.h @@ -48,8 +48,10 @@ public: MainScreen(CardputerUITask *task, mesh::RTCClock *rtc, SensorManager *sensors, CompanionNodePrefs *node_prefs, CustomNodePrefs *custom_prefs); void messageRepeatsRecv(uint16_t count); - void onChannelMessageRecv(const mesh::GroupChannel &channel, const char *text); + void onChannelMessageRecv(uint8_t channel_idx, const uint8_t history_id[32], const char *text); void onContactMessageRecv(const ContactInfo &contact, const char *text); + void onChannelMessageSent(uint8_t channel_idx, const uint8_t history_id[32], const char *text); + void onContactMessageSent(const ContactInfo &contact, const char *text); void onMessageSendAttempt(uint8_t attempt, uint8_t total, MessageSendState state); void poll() override; diff --git a/variants/m5stack_cardputer_adv/companion_radio/ui-cardputer/screen/SettingsScreen.cpp b/variants/m5stack_cardputer_adv/companion_radio/ui-cardputer/screen/SettingsScreen.cpp index 17246b783..a689c830b 100644 --- a/variants/m5stack_cardputer_adv/companion_radio/ui-cardputer/screen/SettingsScreen.cpp +++ b/variants/m5stack_cardputer_adv/companion_radio/ui-cardputer/screen/SettingsScreen.cpp @@ -139,7 +139,7 @@ bool SettingsScreen::enterItemEdit(SettingsItem item) { return true; case SettingsItem::RadioPwr: - edit_u8 = _node_prefs->tx_power_dbm; + edit_tx_power = _node_prefs->tx_power_dbm; is_editing = true; return true; @@ -236,9 +236,10 @@ bool SettingsScreen::commitItemEdit(SettingsItem item) { case SettingsItem::RadioCr: return commitRadio(_node_prefs->freq, _node_prefs->bw, _node_prefs->sf, edit_u8); case SettingsItem::RadioPwr: { - if (!radio_driver.isTxPowerValid(static_cast(edit_u8))) return false; + if (edit_tx_power < INT8_MIN || edit_tx_power > INT8_MAX + || !radio_driver.isTxPowerValid(static_cast(edit_tx_power))) return false; char command[24]; - snprintf(command, sizeof(command), "set tx %d", static_cast(edit_u8)); + snprintf(command, sizeof(command), "set tx %d", static_cast(edit_tx_power)); return commitCommand(command); } case SettingsItem::MeshPathSize: { @@ -295,10 +296,12 @@ void SettingsScreen::handleEditInput(SettingsItem item, Keyboard::Event &e) { break; case SettingsItem::RadioPwr: - if (e.key == Keyboard::ARROW_LEFT && edit_u8 > 1) { - edit_u8--; - } else if (e.key == Keyboard::ARROW_RIGHT && edit_u8 < LORA_TX_POWER) { - edit_u8++; + if (e.key == Keyboard::ARROW_LEFT && edit_tx_power > INT8_MIN + && radio_driver.isTxPowerValid(static_cast(edit_tx_power - 1))) { + --edit_tx_power; + } else if (e.key == Keyboard::ARROW_RIGHT && edit_tx_power < INT8_MAX + && radio_driver.isTxPowerValid(static_cast(edit_tx_power + 1))) { + ++edit_tx_power; } break; case SettingsItem::MeshPathSize: @@ -436,12 +439,16 @@ int SettingsScreen::render(CardputerDisplay &display) { switch (menu_index) { case SettingsItem::RadioSf: case SettingsItem::RadioCr: - case SettingsItem::RadioPwr: case SettingsItem::MeshPathSize: display.setTextSize(2); snprintf(buf, sizeof(buf), "- %d +", edit_u8); display.drawTextCentered(display.width() / 2, value_y, buf); break; + case SettingsItem::RadioPwr: + display.setTextSize(2); + snprintf(buf, sizeof(buf), "- %d +", static_cast(edit_tx_power)); + display.drawTextCentered(display.width() / 2, value_y, buf); + break; case SettingsItem::PublicInfoName: case SettingsItem::RadioFreq: case SettingsItem::RadioBw: diff --git a/variants/m5stack_cardputer_adv/companion_radio/ui-cardputer/screen/SettingsScreen.h b/variants/m5stack_cardputer_adv/companion_radio/ui-cardputer/screen/SettingsScreen.h index 4b41b7e8a..f81ce828c 100644 --- a/variants/m5stack_cardputer_adv/companion_radio/ui-cardputer/screen/SettingsScreen.h +++ b/variants/m5stack_cardputer_adv/companion_radio/ui-cardputer/screen/SettingsScreen.h @@ -35,6 +35,7 @@ class SettingsScreen : public CardputerScreen { char edit_error[80] = "Invalid value"; String edit_buffer; uint8_t edit_u8; + int32_t edit_tx_power = 0; static constexpr size_t MAX_NUMERIC_EDIT_BYTES = 31; void renderItem(CardputerDisplay &display, SettingsItem item, int x, int y); diff --git a/variants/m5stack_cardputer_adv/companion_radio/ui-cardputer/screen/ToolsScreen.cpp b/variants/m5stack_cardputer_adv/companion_radio/ui-cardputer/screen/ToolsScreen.cpp index 65ca33cb3..c48f32659 100644 --- a/variants/m5stack_cardputer_adv/companion_radio/ui-cardputer/screen/ToolsScreen.cpp +++ b/variants/m5stack_cardputer_adv/companion_radio/ui-cardputer/screen/ToolsScreen.cpp @@ -84,19 +84,15 @@ void ToolsScreen::menuItemEnter(ToolsMenuItem item) { break; - case ToolsMenuItem::PowerOff: { - bool done = false; - // Wait for key release to prevent firing interrupt - do { - Keyboard::Event e = _task->getBoard()->getKeyboard()->poll(); - done = (e.changed && e.key == Keyboard::KEY_RETURN && e.down == false); - delay(10); - } while (!done); - _task->shutdown(); - } break; + case ToolsMenuItem::PowerOff: + power_off_pending = true; + power_off_released = false; + power_off_deadline = static_cast(millis()) + POWER_OFF_RELEASE_TIMEOUT_MS; + _task->showAlert("Release Enter to power off", POWER_OFF_RELEASE_TIMEOUT_MS); + break; case ToolsMenuItem::Restart: - ESP.restart(); + _task->shutdown(true); break; default: @@ -104,6 +100,29 @@ void ToolsScreen::menuItemEnter(ToolsMenuItem item) { } } +bool ToolsScreen::handlePowerOffInput(const Keyboard::Event &e) { + if (!power_off_pending) return false; + if (e.changed && e.key == Keyboard::KEY_RETURN && !e.down) { + power_off_released = true; + } else if (e.changed && e.key == Keyboard::KEY_ESC && e.down) { + power_off_pending = false; + _task->dismissAlert(); + } + return true; +} + +void ToolsScreen::poll() { + if (!power_off_pending) return; + if (power_off_released) { + power_off_pending = false; + _task->dismissAlert(); + _task->shutdown(); + } else if (static_cast(static_cast(millis()) - power_off_deadline) >= 0) { + power_off_pending = false; + _task->showAlert("Power off cancelled: release Enter", 2000); + } +} + bool ToolsScreen::handleInput(Keyboard::Event &e) { if (_task->isAlertActive()) { return false; diff --git a/variants/m5stack_cardputer_adv/companion_radio/ui-cardputer/screen/ToolsScreen.h b/variants/m5stack_cardputer_adv/companion_radio/ui-cardputer/screen/ToolsScreen.h index d395308d2..cc345c1a2 100644 --- a/variants/m5stack_cardputer_adv/companion_radio/ui-cardputer/screen/ToolsScreen.h +++ b/variants/m5stack_cardputer_adv/companion_radio/ui-cardputer/screen/ToolsScreen.h @@ -37,6 +37,10 @@ class ToolsScreen : public CardputerScreen { ToolsPage page = ToolsPage::MenuPage; int menu_index = 0; + bool power_off_pending = false; + bool power_off_released = false; + uint32_t power_off_deadline = 0; + static constexpr uint32_t POWER_OFF_RELEASE_TIMEOUT_MS = 2000; DiscoveredRepeater discovered_repeaters[MAX_DISCOVERED_REPEATERS]; uint16_t discovered_repeaters_count = 0; @@ -46,6 +50,9 @@ public: int render(CardputerDisplay &lcd) override; void menuItemEnter(ToolsMenuItem item); bool handleInput(Keyboard::Event &e) override; + bool handlePowerOffInput(const Keyboard::Event &e); + bool isPowerOffPending() const { return power_off_pending; } + void poll() override; void discoverRecv(const mesh::Identity &id, float snr); void startDiscover() { menuItemEnter(ToolsMenuItem::DiscoverRepeaters); } diff --git a/variants/m5stack_cardputer_adv/companion_radio/ui-cardputer/screen/main/ChannelsPage.cpp b/variants/m5stack_cardputer_adv/companion_radio/ui-cardputer/screen/main/ChannelsPage.cpp index 363e495c5..fd33b5644 100644 --- a/variants/m5stack_cardputer_adv/companion_radio/ui-cardputer/screen/main/ChannelsPage.cpp +++ b/variants/m5stack_cardputer_adv/companion_radio/ui-cardputer/screen/main/ChannelsPage.cpp @@ -51,10 +51,12 @@ int ChannelsPage::render(CardputerDisplay &lcd) { } int unread_count = 0; + uint8_t history_id[PUB_KEY_SIZE]; + if (!CardputerMesh::channelHistoryId(channel, history_id)) continue; for (int j = 0; j < _p->getUnread()->countChats(); j++) { const UnreadCounterItem *item = _p->getUnread()->get(j); - if (item && item->is_channel && memcmp(item->pkey, channel.channel.secret, CONTACT_LOOKUP_BYTES) == 0) { + if (item && item->is_channel && memcmp(item->pkey, history_id, sizeof(history_id)) == 0) { unread_count = item->count; break; } diff --git a/variants/m5stack_cardputer_adv/companion_radio/ui-cardputer/screen/main/ChatPage.cpp b/variants/m5stack_cardputer_adv/companion_radio/ui-cardputer/screen/main/ChatPage.cpp index e4c9ea46e..ef16ecfb9 100644 --- a/variants/m5stack_cardputer_adv/companion_radio/ui-cardputer/screen/main/ChatPage.cpp +++ b/variants/m5stack_cardputer_adv/companion_radio/ui-cardputer/screen/main/ChatPage.cpp @@ -111,10 +111,6 @@ void ChatPage::sendChatMessage() { int result = the_mesh_cp.sendDirectMessage(current_contact, ts, chat_text_box.c_str()); if (result != MSG_SEND_FAILED) { - HistoryMessage *msg = chat_history.push_ref(); - msg->out = true; - snprintf(msg->text, MAX_MESSAGE_LENGTH, "%s", chat_text_box.c_str()); - last_sent_message = chat_text_box; chat_text_box.clear(); chat_history_offset = 0; @@ -129,11 +125,6 @@ void ChatPage::sendChatMessage() { bool ok = the_mesh_cp.sendChannelMessage(current_channel_idx, ts, chat_text_box.c_str(), chat_text_box.length()); if (ok) { - HistoryMessage *msg = chat_history.push_ref(); - msg->out = true; - - snprintf(msg->text, MAX_MESSAGE_LENGTH, "%s", chat_text_box.c_str()); - last_sent_message = chat_text_box; chat_text_box.clear(); chat_history_offset = 0; @@ -232,10 +223,11 @@ void ChatPage::selectContact(const ContactInfo &contact, int idx) { } void ChatPage::selectChannel(const ChannelDetails &channel, int idx) { + if (!CardputerMesh::channelHistoryId(channel, current_channel_history_id)) return; current_channel = channel; current_channel_idx = idx; current_contact_idx = -1; - the_mesh_cp.loadMessageHistory(channel.channel.secret, true, chat_history); + the_mesh_cp.loadMessageHistory(current_channel_history_id, true, chat_history); chat_history_offset = 0; last_sent_message = ""; @@ -248,15 +240,15 @@ void ChatPage::selectChannel(const ChannelDetails &channel, int idx) { } } -bool ChatPage::onChannelMessageRecv(const mesh::GroupChannel &channel, const char *text) { - +bool ChatPage::onChannelMessageRecv(uint8_t channel_idx, const uint8_t history_id[32], const char *text) { + (void)channel_idx; // Identical logical channels can occupy more than one slot. if (current_channel_idx != -1 && - memcmp(channel.secret, current_channel.channel.secret, PUB_KEY_SIZE) == 0) { + memcmp(history_id, current_channel_history_id, PUB_KEY_SIZE) == 0) { HistoryMessage *msg = chat_history.push_ref(); msg->out = false; snprintf(msg->text, MAX_MESSAGE_LENGTH, "%s", text); chat_history_offset = 0; - return true; + return _p->getUiTask()->isChatVisible(); } return false; } @@ -268,11 +260,35 @@ bool ChatPage::onContactMessageRecv(const ContactInfo &contact, const char *text msg->out = false; snprintf(msg->text, MAX_MESSAGE_LENGTH, "%s", text); chat_history_offset = 0; - return true; + return _p->getUiTask()->isChatVisible(); } return false; } +void ChatPage::onChannelMessageSent(uint8_t channel_idx, const uint8_t history_id[32], const char *text) { + (void)channel_idx; + if (current_channel_idx == -1 || memcmp(history_id, current_channel_history_id, PUB_KEY_SIZE) != 0) return; + HistoryMessage *msg = chat_history.push_ref(); + msg->out = true; + snprintf(msg->text, sizeof(msg->text), "%s", text); + last_sent_message = text; + chat_history_offset = 0; +} + +void ChatPage::onContactMessageSent(const ContactInfo &contact, const char *text) { + if (current_contact_idx == -1 || memcmp(contact.id.pub_key, current_contact.id.pub_key, PUB_KEY_SIZE) != 0) return; + HistoryMessage *msg = chat_history.push_ref(); + msg->out = true; + snprintf(msg->text, sizeof(msg->text), "%s", text); + last_sent_message = text; + chat_history_offset = 0; +} + +void ChatPage::markRead(UnreadCounter &unread) const { + if (current_contact_idx != -1) unread.resetContact(current_contact.id.pub_key); + if (current_channel_idx != -1) unread.resetChannel(current_channel_history_id); +} + void ChatPage::refreshSelectedContact() { if (current_contact_idx != -1) { ContactInfo *contact = the_mesh_cp.lookupPersistentContactByPubKey(current_contact.id.pub_key, PUB_KEY_SIZE); @@ -291,8 +307,10 @@ void ChatPage::refreshSelectedContact() { bool ChatPage::refreshSelectedChannel() { if (current_channel_idx == -1) return false; ChannelDetails channel; + uint8_t history_id[PUB_KEY_SIZE]; if (!the_mesh_cp.getChannel(current_channel_idx, channel) || !channel.name[0] - || memcmp(channel.channel.secret, current_channel.channel.secret, PUB_KEY_SIZE) != 0) { + || !CardputerMesh::channelHistoryId(channel, history_id) + || memcmp(history_id, current_channel_history_id, PUB_KEY_SIZE) != 0) { current_channel_idx = -1; current_channel = ChannelDetails{}; chat_history.clear(); diff --git a/variants/m5stack_cardputer_adv/companion_radio/ui-cardputer/screen/main/ChatPage.h b/variants/m5stack_cardputer_adv/companion_radio/ui-cardputer/screen/main/ChatPage.h index 431561bf5..c87c8a8e5 100644 --- a/variants/m5stack_cardputer_adv/companion_radio/ui-cardputer/screen/main/ChatPage.h +++ b/variants/m5stack_cardputer_adv/companion_radio/ui-cardputer/screen/main/ChatPage.h @@ -11,6 +11,7 @@ private: String last_sent_message; ChannelDetails current_channel; + uint8_t current_channel_history_id[PUB_KEY_SIZE] = {}; int current_channel_idx = -1; ContactInfo current_contact; @@ -29,8 +30,11 @@ public: void selectContact(const ContactInfo &contact, int idx); void selectChannel(const ChannelDetails &channel, int idx); - bool onChannelMessageRecv(const mesh::GroupChannel &channel, const char *text); + bool onChannelMessageRecv(uint8_t channel_idx, const uint8_t history_id[32], const char *text); bool onContactMessageRecv(const ContactInfo &contact, const char *text); + void onChannelMessageSent(uint8_t channel_idx, const uint8_t history_id[32], const char *text); + void onContactMessageSent(const ContactInfo &contact, const char *text); + void markRead(UnreadCounter &unread) const; void refreshSelectedContact(); }; diff --git a/variants/m5stack_cardputer_adv/companion_radio/ui-cardputer/screen/main/ContactsPage.cpp b/variants/m5stack_cardputer_adv/companion_radio/ui-cardputer/screen/main/ContactsPage.cpp index 0f04a55ad..0edee2a43 100644 --- a/variants/m5stack_cardputer_adv/companion_radio/ui-cardputer/screen/main/ContactsPage.cpp +++ b/variants/m5stack_cardputer_adv/companion_radio/ui-cardputer/screen/main/ContactsPage.cpp @@ -75,7 +75,7 @@ int ContactsPage::render(CardputerDisplay &lcd) { for (int j = 0; j < _p->getUnread()->countChats(); j++) { const UnreadCounterItem *item = _p->getUnread()->get(j); - if (item && !item->is_channel && memcmp(item->pkey, contact.id.pub_key, CONTACT_LOOKUP_BYTES) == 0) { + if (item && !item->is_channel && memcmp(item->pkey, contact.id.pub_key, PUB_KEY_SIZE) == 0) { unread_count = item->count; break; } diff --git a/variants/m5stack_cardputer_adv/hw/CardputerSpeaker.cpp b/variants/m5stack_cardputer_adv/hw/CardputerSpeaker.cpp index b49cbd14e..ebe72c440 100644 --- a/variants/m5stack_cardputer_adv/hw/CardputerSpeaker.cpp +++ b/variants/m5stack_cardputer_adv/hw/CardputerSpeaker.cpp @@ -4,24 +4,36 @@ #include #include // because i2s_std.h not exists for some reason +#include -void CardputerSpeaker::initI2C() { +namespace { +bool due(uint32_t now, uint32_t deadline) { + return static_cast(now - deadline) >= 0; +} +const uint32_t AUDIO_STALL_MS = 1000; +const uint32_t AUDIO_DMA_DRAIN_MS = + (4UL * AUDIO_BUFFER_SIZE * 1000UL + AUDIO_SAMPLE_RATE - 1) / AUDIO_SAMPLE_RATE; +} + +bool CardputerSpeaker::initI2C() { // From M5Unified - writeReg(0x00, 0x80); // 0x00 RESET/ CSM POWER ON - writeReg(0x01, 0xB5); // 0x01 CLOCK_MANAGER/ MCLK=BCLK - writeReg(0x02, 0x18); // 0x02 CLOCK_MANAGER/ MULT_PRE=3 - writeReg(0x0D, 0x01); // 0x0D SYSTEM/ Power up analog circuitry - writeReg(0x12, 0x00); // 0x12 SYSTEM/ power-up DAC - NOT default - writeReg(0x13, 0x10); // 0x13 SYSTEM/ Enable output to HP drive - NOT default - writeReg(0x32, 0xBF); // 0x32 DAC/ DAC volume (0xBF == +/-0 dB) - writeReg(0x37, 0x08); // 0x37 DAC/ Bypass DAC equalizer - NOT default + // Mark partial initialization too, so ready() can power it down on failure. + i2c_initialized = true; + if (!writeReg(0x00, 0x80) // RESET / CSM POWER ON + || !writeReg(0x01, 0xB5) // CLOCK_MANAGER / MCLK=BCLK + || !writeReg(0x02, 0x18) // CLOCK_MANAGER / MULT_PRE=3 + || !writeReg(0x0D, 0x01) // Power up analog circuitry + || !writeReg(0x12, 0x00) // Power up DAC + || !writeReg(0x13, 0x10) // Enable output to HP drive + || !writeReg(0x32, 0xBF) // DAC volume (+/-0 dB) + || !writeReg(0x37, 0x08)) return false; // Bypass DAC equalizer // Codec is not ready immediately, it need some time to stabilize busy_time = millis() + AUDIO_WARMUP_MS; - i2c_initialized = true; + return true; } -void CardputerSpeaker::initI2S() { +bool CardputerSpeaker::initI2S() { i2s_config_t i2s_config = { .mode = (i2s_mode_t)(I2S_MODE_MASTER | I2S_MODE_TX), .sample_rate = AUDIO_SAMPLE_RATE, .bits_per_sample = I2S_BITS_PER_SAMPLE_16BIT, @@ -41,10 +53,9 @@ void CardputerSpeaker::initI2S() { .data_in_num = I2S_PIN_NO_CHANGE // Mic is not needed }; - i2s_driver_install(I2S_NUM_0, &i2s_config, 0, NULL); - i2s_set_pin(I2S_NUM_0, &pin_config); - + if (i2s_driver_install(I2S_NUM_0, &i2s_config, 0, NULL) != ESP_OK) return false; i2s_initialized = true; + return i2s_set_pin(I2S_NUM_0, &pin_config) == ESP_OK; } bool CardputerSpeaker::writeReg(uint8_t reg, uint8_t val) { @@ -54,51 +65,23 @@ bool CardputerSpeaker::writeReg(uint8_t reg, uint8_t val) { return Wire1.endTransmission() == 0; } -void CardputerSpeaker::begin() { - if (!i2s_initialized) { - initI2S(); - } - - if (!i2c_initialized) { - initI2C(); +bool CardputerSpeaker::ready() { + if ((!i2s_initialized && !initI2S()) || (!i2c_initialized && !initI2C())) { + sleep(); + return false; } + return true; } -void CardputerSpeaker::playTone(int32_t frequency_hz, uint32_t duration_ms, float volume) { - begin(); +void CardputerSpeaker::begin() { (void)ready(); } - static double current_phase = 0.0; - - const double phase_increment = 2.0 * M_PI * frequency_hz / AUDIO_SAMPLE_RATE; - const int16_t target_amplitude = static_cast(volume * AUDIO_MAX_SAFE_VOLUME); - - uint64_t total_samples_to_play = (uint64_t)AUDIO_SAMPLE_RATE * duration_ms / 1000ULL; - uint64_t samples_played = 0; - - int16_t audio_buffer[AUDIO_BUFFER_SIZE]; - size_t bytes_written; - - while (samples_played < total_samples_to_play) { - uint64_t remaining_samples = total_samples_to_play - samples_played; - int samples_in_chunk = - (int)(remaining_samples < AUDIO_BUFFER_SIZE ? remaining_samples : AUDIO_BUFFER_SIZE); - - for (int i = 0; i < samples_in_chunk; ++i) { - audio_buffer[i] = static_cast(sin(current_phase) * target_amplitude); - - current_phase += phase_increment; - if (current_phase >= 2.0 * M_PI) { - current_phase -= 2.0 * M_PI; - } - } - - i2s_write(I2S_NUM_0, audio_buffer, samples_in_chunk * sizeof(int16_t), &bytes_written, portMAX_DELAY); - - samples_played += samples_in_chunk; - } +void CardputerSpeaker::playTone(int32_t hz, uint32_t duration_ms, float level) { + queueTone(hz, duration_ms, level); } void CardputerSpeaker::sleep() { + tone_pending = draining = false; + total_bytes = bytes_sent = 0; if (i2c_initialized) { writeReg(0x0E, 0xFF); // SYSTEM writeReg(0x12, 0x02); // SYSTEM @@ -112,19 +95,63 @@ void CardputerSpeaker::sleep() { } } -void CardputerSpeaker::queueTone(int32_t frequency_hz, uint32_t duration_ms, float volume) { - begin(); - queue_tone.frequency_hz = frequency_hz; - queue_tone.duration_ms = duration_ms; - queue_tone.volume = volume; - queue_tone.done = false; +void CardputerSpeaker::queueTone(int32_t hz, uint32_t duration_ms, float level) { + if (hz <= 0 || hz > AUDIO_SAMPLE_RATE / 2 || !std::isfinite(level) + || level <= 0 || duration_ms == 0) { + sleep(); + return; + } + if (!ready()) return; + frequency_hz = hz; + volume = level > 1.0f ? 1.0f : level; + bytes_sent = 0; + total_bytes = (uint64_t(AUDIO_SAMPLE_RATE) * duration_ms / 1000ULL) * sizeof(int16_t); + tone_pending = total_bytes != 0; + draining = false; + // A replacement discards all unsent old samples. Already submitted DMA is + // bounded to four chunks and can finish without stalling the mesh loop. + last_progress = millis(); } void CardputerSpeaker::processQueue() { - if (!queue_tone.done) { - if (millis() > busy_time) { - queue_tone.done = true; - playTone(queue_tone.frequency_hz, queue_tone.duration_ms, queue_tone.volume); + const uint32_t now = millis(); + if (draining && due(now, drain_until)) draining = false; + if (!tone_pending || !due(now, busy_time)) return; + + // Regenerate the unsubmitted suffix from its exact byte offset. This also + // handles an odd-byte partial write without retaining a heap/instance buffer. + const uint64_t first_sample = bytes_sent / sizeof(int16_t); + const size_t byte_offset = bytes_sent % sizeof(int16_t); + const uint64_t remaining_samples = (total_bytes / sizeof(int16_t)) - first_sample; + const size_t count = remaining_samples < AUDIO_BUFFER_SIZE + ? size_t(remaining_samples) : AUDIO_BUFFER_SIZE; + int16_t audio_buffer[AUDIO_BUFFER_SIZE]; + const double increment = 2.0 * M_PI * frequency_hz / AUDIO_SAMPLE_RATE; + double phase = std::fmod(double(first_sample) * increment, 2.0 * M_PI); + const int16_t amplitude = static_cast(volume * AUDIO_MAX_SAFE_VOLUME); + for (size_t i = 0; i < count; ++i) { + audio_buffer[i] = static_cast(std::sin(phase) * amplitude); + phase += increment; + if (phase >= 2.0 * M_PI) phase -= 2.0 * M_PI; + } + const size_t requested = count * sizeof(int16_t) - byte_offset; + size_t written = 0; + const esp_err_t result = i2s_write(I2S_NUM_0, + reinterpret_cast(audio_buffer) + byte_offset, requested, &written, 0); + if ((result != ESP_OK && result != ESP_ERR_TIMEOUT) || written > requested) { + sleep(); + return; + } + if (written != 0) { + bytes_sent += written; + last_progress = now; + if (bytes_sent == total_bytes) { + tone_pending = false; + draining = true; + drain_until = now + AUDIO_DMA_DRAIN_MS; } + } else if (static_cast(now - last_progress) >= AUDIO_STALL_MS + AUDIO_WARMUP_MS) { + // A wedged driver must not hold the node awake indefinitely. + sleep(); } } diff --git a/variants/m5stack_cardputer_adv/hw/CardputerSpeaker.h b/variants/m5stack_cardputer_adv/hw/CardputerSpeaker.h index 241ff86c0..ca620b028 100644 --- a/variants/m5stack_cardputer_adv/hw/CardputerSpeaker.h +++ b/variants/m5stack_cardputer_adv/hw/CardputerSpeaker.h @@ -2,36 +2,36 @@ #include -/** Basic ES8311 blocking driver */ +/** ES8311 tone driver serviced in bounded foreground chunks. */ class CardputerSpeaker { private: - struct queue_tone_t { - int32_t frequency_hz = 0; - uint32_t duration_ms = 0; - float volume = 0; - bool done = true; - }; - bool i2c_initialized = false; bool i2s_initialized = false; - + bool tone_pending = false; + bool draining = false; uint32_t busy_time = 0; - queue_tone_t queue_tone; + uint32_t last_progress = 0; + uint32_t drain_until = 0; + uint64_t bytes_sent = 0; + uint64_t total_bytes = 0; + int32_t frequency_hz = 0; + float volume = 0; - void initI2C(); - void initI2S(); + bool initI2C(); + bool initI2S(); + bool ready(); bool writeReg(uint8_t reg, uint8_t val); public: CardputerSpeaker() {} void begin(); + // Both entry points queue playback; neither waits for audio to finish. void playTone(int32_t frequency_hz, uint32_t duration_ms, float volume = 0.5f); - void sleep(); - - /** Queue a tone. Will be played when processQueue() is called and the codec read. */ void queueTone(int32_t frequency_hz, uint32_t duration_ms, float volume = 0.5f); + // Cancel pending playback, stop DMA and power down the codec. + void sleep(); + // At most one AUDIO_BUFFER_SIZE chunk and one zero-timeout write per call. void processQueue(); - /** True while a queued tone has not been played yet. */ - bool isBusy() const { return !queue_tone.done; } + bool isBusy() const { return tone_pending || draining; } }; diff --git a/variants/m5stack_cardputer_adv/platformio.ini b/variants/m5stack_cardputer_adv/platformio.ini index 333747b63..b754d674a 100644 --- a/variants/m5stack_cardputer_adv/platformio.ini +++ b/variants/m5stack_cardputer_adv/platformio.ini @@ -47,6 +47,7 @@ build_flags = ${esp32_base.build_flags} -D LORA_TX_POWER=22 build_src_filter = ${esp32_base.build_src_filter} + + +<../variants/m5stack_cardputer_adv/CardputerSensorManager.cpp> +<../variants/m5stack_cardputer_adv/target.cpp> lib_deps = ${esp32_base.lib_deps} diff --git a/variants/m5stack_cardputer_adv/target.cpp b/variants/m5stack_cardputer_adv/target.cpp index f607d168a..1f2e2b585 100644 --- a/variants/m5stack_cardputer_adv/target.cpp +++ b/variants/m5stack_cardputer_adv/target.cpp @@ -14,13 +14,15 @@ WRAPPER_CLASS radio_driver(radio, board); ESP32RTCClock fallback_clock; AutoDiscoverRTCClock rtc_clock(fallback_clock); -#if CARDPUTER_ADV_UI +#if ENV_INCLUDE_GPS #include static MicroNMEALocationProvider nmea(Serial1, &rtc_clock); CardputerSensorManager sensors(nmea); - CardputerDisplay display; #else - EnvironmentSensorManager sensors; + CardputerSensorManager sensors; +#endif +#if CARDPUTER_ADV_UI + CardputerDisplay display; #endif bool radio_init() { diff --git a/variants/m5stack_cardputer_adv/target.h b/variants/m5stack_cardputer_adv/target.h index b390ff942..fc4207b7e 100644 --- a/variants/m5stack_cardputer_adv/target.h +++ b/variants/m5stack_cardputer_adv/target.h @@ -8,13 +8,11 @@ #include #include #include "CardputerAdvBoard.h" +#include "CardputerSensorManager.h" #if CARDPUTER_ADV_UI #include - #include "CardputerSensorManager.h" #include "hw/CardputerDisplay.h" -#else - #include #endif extern CardputerAdvBoard board; @@ -23,11 +21,9 @@ extern AutoDiscoverRTCClock rtc_clock; // The microSD slot shares SCK/MISO/MOSI with the Cap LoRa-1262. extern SPIClass lora_spi; -#if CARDPUTER_ADV_UI extern CardputerSensorManager sensors; +#if CARDPUTER_ADV_UI extern CardputerDisplay display; -#else -extern EnvironmentSensorManager sensors; #endif bool radio_init(); From 744692fb93d8c5b86b5873f5c659312c10b861f0 Mon Sep 17 00:00:00 2001 From: mikecarper Date: Wed, 28 Oct 2026 00:05:10 -0700 Subject: [PATCH 03/61] Configure MediaMesh radio2 presets when joining Companion channels --- docs/radio_profiles.md | 13 ++ examples/companion_radio/MyMesh.cpp | 36 +++++ src/helpers/CompanionChannelAlias.h | 9 ++ src/helpers/RadioProfileCLI.cpp | 23 +++ src/helpers/RadioProfileCLI.h | 7 +- test/fixtures/companion_meshtext.cpp | 95 ++++++++++++ test/fixtures/radio_profiles/cli_test.cpp | 163 +++++++++++++++++++++ test/test_companion_channel_alias.py | 114 +++++++++++++- test/test_companion_channel_publication.py | 7 + test/test_companion_meshtext.py | 3 + test/test_radio_profile_cli.py | 19 ++- 11 files changed, 483 insertions(+), 6 deletions(-) diff --git a/docs/radio_profiles.md b/docs/radio_profiles.md index 36f8e6c4d..d1ce79a95 100644 --- a/docs/radio_profiles.md +++ b/docs/radio_profiles.md @@ -59,6 +59,19 @@ For the same profile at **125 kHz**, use: set radio2 mediamesh-range ``` +On Companions, adding `#r2-mediamesh` in the app also saves the `mediamesh` +profile; `#r2-mediamesh-range` selects `mediamesh-range`. Renaming another +channel to either alias does the same. Names without `#` also work. No extra +CLI command or forwarding-region setup is needed on a Companion. + +Re-saving the same preset alias, editing its key, or rebooting preserves any +later manual radio2 changes, including `set radio2 off`. Removing the channel +does not switch radio2 off. All `r2-` channels share one secondary profile, so +the most recently added or renamed MediaMesh preset chooses its RF settings. +Meshtext channels and an active foreign-protocol radio2 session cannot share +this setup. The supplied channel name and key retain the normal alias rules +[below](#companion-channel-name-routing-aliases). + The RF settings match the presets documented by [MeshCore Open TAK](https://github.com/jade-on-mesh/meshcore-open-tak/blob/main/documentation/media-mesh.md) and [Wadamesh TAK](https://github.com/jade-on-mesh/wadamesh-tak/blob/main/MEDIAMESH.md). diff --git a/examples/companion_radio/MyMesh.cpp b/examples/companion_radio/MyMesh.cpp index 7f81df74d..2ff0c856e 100644 --- a/examples/companion_radio/MyMesh.cpp +++ b/examples/companion_radio/MyMesh.cpp @@ -7491,10 +7491,45 @@ void MyMesh::handleCmdFrame(size_t len) { && !memcmp(wire_channel.channel.secret, previous_wire_channel.channel.secret, sizeof(channel.channel.secret))) { channel.channel.tx_radio = previous.channel.tx_radio; } + const char* preset = companionChannelRadio2Preset(channel.name); + const char* previous_preset = companionChannelRadio2Preset(previous.name); + // A join or rename selects the saved preset, just like set radio2. App + // replays/key edits must not undo a later manual Off or custom RF setting. + const bool setup_preset = have_previous && preset + && (!previous_preset || strcmp(preset, previous_preset)); + if (setup_preset) { +#if !MESH_ENABLE_MESHTEXT_CHAT + // Even without the foreign-protocol backend, do not turn old fallback + // mt- channels into inactive protocol requests as a hidden side effect. + for (int i = 0; i < MAX_GROUP_CHANNELS; ++i) { + ChannelDetails other; + if (i != channel_idx && BaseChatMesh::getChannel(i, other) + && parseCompanionChannelAlias(other.name).kind == CompanionChannelKind::Meshtext) { + writeErrFrame(ERR_CODE_BAD_STATE); + return; + } + } +#endif + if (!_radio_profiles.stageChannelPreset(preset)) { + writeErrFrame(ERR_CODE_BAD_STATE); + return; + } + } if (!have_previous || !setChannel(channel_idx, channel)) { + if (setup_preset) _radio_profiles.finishChannelPreset(false); writeErrFrame(ERR_CODE_NOT_FOUND); // bad channel_idx } else if (saveChannels()) { + if (setup_preset && !_radio_profiles.finishChannelPreset(true)) { + // Never report a complete setup when the RF image did not commit. + // Restore the channel when storage permits; report failure even if + // an indeterminate channels write has quarantined further saves. + setChannel(channel_idx, previous); + saveChannels(); + _radio_profiles.finishChannelPreset(false); + writeErrFrame(ERR_CODE_FILE_IO_ERROR); + return; + } #if MESH_ENABLE_MESHTEXT_CHAT _mt_seen.clear(); _mt_profile_dirty = true; @@ -7509,6 +7544,7 @@ void MyMesh::handleCmdFrame(size_t len) { // commit this update, and never acknowledge a change that will disappear // after reboot. setChannel(channel_idx, previous); + if (setup_preset) _radio_profiles.finishChannelPreset(false); writeErrFrame(ERR_CODE_FILE_IO_ERROR); } } else if (cmd_frame[0] == CMD_SIGN_START) { diff --git a/src/helpers/CompanionChannelAlias.h b/src/helpers/CompanionChannelAlias.h index af0f9846c..7c434b595 100644 --- a/src/helpers/CompanionChannelAlias.h +++ b/src/helpers/CompanionChannelAlias.h @@ -3,6 +3,7 @@ #include #include #include +#include // Local labels, not part of a hashtag PSK. Keep the original app label/key. enum class CompanionChannelKind : uint8_t { MeshCore, Radio2, Meshtext, Invalid }; @@ -30,6 +31,14 @@ inline CompanionChannelAlias parseCompanionChannelAlias(const char* name) { return {kind, hashtag, tail}; } +// Only exact named radio2 aliases request RF setup. Ordinary channels and +// arbitrary r2 labels keep the operator's existing profile. +inline const char* companionChannelRadio2Preset(const char* name) { + const auto alias = parseCompanionChannelAlias(name); + return alias.kind == CompanionChannelKind::Radio2 + && mesh::cli::radio2PresetTuple(alias.tail) ? alias.tail : nullptr; +} + inline bool companionChannelCanonicalName(const char* name, char* out, size_t size) { if (out == nullptr) return false; const auto alias = parseCompanionChannelAlias(name); diff --git a/src/helpers/RadioProfileCLI.cpp b/src/helpers/RadioProfileCLI.cpp index 4b790912b..60facf08a 100644 --- a/src/helpers/RadioProfileCLI.cpp +++ b/src/helpers/RadioProfileCLI.cpp @@ -746,6 +746,29 @@ bool RadioProfileCLI::finishReplyMutation(bool delivered) { || !fs_ || !fs_->exists(ReplyPath) || fs_->remove(ReplyPath); } +bool RadioProfileCLI::stageChannelPreset(const char* name) { + if (!name || !cli::radio2PresetTuple(name) || remote_command_ + || !secondaryRuntimeAvailable()) return false; + const auto& secondary = radio_->profiles()->secondary; + if (secondary.mode != RadioProfileMode::Off + && secondary.params.protocol != RadioProfileProtocol::MeshCore) return false; + char command[48], reply[160]; + snprintf(command, sizeof(command), "set radio2 %s", name); + beginReplyCommand(); + const bool handled = handle(command, reply, sizeof(reply)); + endReplyCommand(); + return handled && !strncmp(reply, "OK", 2) + && remote_mutation_ == RemoteMutation::Saved; +} + +bool RadioProfileCLI::finishChannelPreset(bool commit) { + if (remote_command_ || remote_mutation_ != RemoteMutation::Saved) return false; + if (!commit) return finishReplyMutation(false); + if (!applyReplyMutation()) return false; + publish_after_ms_ = millis() + 2000; + return true; +} + #if MESH_ENABLE_FLEET_CONTROL bool RadioProfileCLI::stageRemoteSchedule(unsigned slot, const Schedule& schedule) { if (slot >= 4 || !stageRemoteMutation(schedule.temporary diff --git a/src/helpers/RadioProfileCLI.h b/src/helpers/RadioProfileCLI.h index 5fe0bae40..2273d2701 100644 --- a/src/helpers/RadioProfileCLI.h +++ b/src/helpers/RadioProfileCLI.h @@ -163,7 +163,12 @@ class RadioProfileCLI { bool hasReplyMutation() const { return remote_mutation_ != RemoteMutation::None; } uint32_t replyMutationGeneration() const { return remote_generation_; } bool finishReplyMutation(bool delivered); - // Channel-derived RF settings are RAM-only and never replace explicit saved + // A channel join can stage a named preset before its channel save. Commit + // synchronously only after that save succeeds; cancellation leaves saved + // RF intent and the running modem unchanged. + bool stageChannelPreset(const char* name); + bool finishChannelPreset(bool commit); + // Passive RF borrowing is RAM-only and never replaces explicit saved // settings (including Off). Repeated identical requests do not retune. bool requestAutoSecondary(const RadioProfileConfig& config); void clearAutoSecondary(); diff --git a/test/fixtures/companion_meshtext.cpp b/test/fixtures/companion_meshtext.cpp index 2a14e8408..70103d83b 100644 --- a/test/fixtures/companion_meshtext.cpp +++ b/test/fixtures/companion_meshtext.cpp @@ -2,6 +2,7 @@ // production method bodies; all boundary substitutes are visible below. @FIXTURE@ #include +#include #include #include #include @@ -497,6 +498,99 @@ static void commands() { applySet(node,0,{}); assert(node.ok && !node._mt_active && !node.radio.p.enabled()); assertUntouched(node); puts("PASS commands"); } +static void assertMediaMeshConfig(const mesh::RadioProfileConfig& config,float bandwidth) { + assert(config.mode==mesh::RadioProfileMode::RxTx); + assert(std::fabs(config.params.freq-911.150f)<0.0001f && config.params.bw==bandwidth); + assert(config.params.sf==7 && config.params.cr==5 && !config.params.preamble); + assert(config.params.protocol==mesh::RadioProfileProtocol::MeshCore); +} +static void channelPresets() { + // These use the actual binary command branch and RadioProfileCLI image + // writer. Channel persistence itself remains this fixture's save boundary. + for(bool range:{false,true}) { + Preferences::reset(); MyMesh node; node.beginMeshtext(); + const auto primary=node.radio.p.primary; + const auto requested=channel(range?"r2-mediamesh-range":"#r2-mediamesh"); + applySet(node,0,requested); + assert(node.ok && node.error==-1 && !node.radio.p.enabled()); + assertMediaMeshConfig(node._radio_profiles.savedSecondaryConfig(),range?125:250); + assert(node._radio_profiles.manualSecondaryConfigured() && node.fs.files.count("/radio_profiles")); + assert(!node._radio_profiles.hasReplyMutation() && !node.fs.files.count("/radio_profiles.reply")); + ChannelDetails raw; assert(node.getChannelForSave(0,raw)); + assert(!strcmp(raw.name,requested.name) && !memcmp(raw.channel.secret,requested.channel.secret,32)); + node.advance(1999); assert(!node.radio.p.enabled()); + node.advance(1); assertMediaMeshConfig(node.radio.p.secondary,range?125:250); + assert(node.radio.p.primary==primary && node.radio.p.cross==mesh::RadioCrossMode::Off); + assert(!node.radio.p.secondary_temporary && !node._mt_active); + MyMesh reboot; reboot.fs=node.fs; reboot.stored[0]=raw; + reboot._radio_profiles.begin(&reboot.fs,&reboot.radio,&reboot.clock); reboot.beginMeshtext(); + assertMediaMeshConfig(reboot.radio.p.secondary,range?125:250); + assert(reboot._radio_profiles.manualSecondaryConfigured() && !reboot._mt_active); + assertUntouched(node); assertUntouched(reboot); + } + + Preferences::reset(); MyMesh node; node.beginMeshtext(); + applySet(node,0,channel("#r2-mediamesh")); assert(node.ok); node.advance(2000); + // The newest join/rename chooses the tuple, even with another named alias + // present. Names do not continuously force RF over later operator choices. + applySet(node,1,channel("#r2-mediamesh-range")); assert(node.ok); + assertMediaMeshConfig(node._radio_profiles.savedSecondaryConfig(),125); + assertMediaMeshConfig(node.radio.p.secondary,250); + node.advance(2000); assertMediaMeshConfig(node.radio.p.secondary,125); + auto renamed=channel("r2-mediamesh",16); + applySet(node,1,renamed); assert(node.ok); + assertMediaMeshConfig(node._radio_profiles.savedSecondaryConfig(),250); + assertMediaMeshConfig(node.radio.p.secondary,125); + node.advance(2000); assertMediaMeshConfig(node.radio.p.secondary,250); + for(const char* command:{"set radio2 off","set radio2 912.5,250,8,5,rx,80"}) { + node.cli(command); node.advance(2000); + const auto saved=node._radio_profiles.savedSecondaryConfig(); + const auto running=node.radio.p.secondary; + const auto disk=node.fs.files; + for(bool edit_key:{false,true}) { + if(edit_key) renamed.channel.secret[0]^=0x80; + applySet(node,1,renamed); assert(node.ok); + assert(node._radio_profiles.savedSecondaryConfig().mode==saved.mode + && node._radio_profiles.savedSecondaryConfig().params==saved.params); + node.advance(2000); + assert(node.radio.p.secondary.mode==running.mode && node.radio.p.secondary.params==running.params); + assert(node.fs.files==disk && !node._radio_profiles.hasReplyMutation()); + } + } + assertUntouched(node); + + for(bool fail_rf_commit:{false,true}) { + Preferences::reset(); MyMesh failed; failed.beginMeshtext(); + failed.cli("set radio2 912.5,250,8,5,rx,80"); failed.advance(2000); + failed.commit(0,channel("ordinary",16)); + const auto old_channel=failed.stored[0]; + const auto old_profile=failed.radio.p.secondary; + const auto old_disk=failed.fs.files; + if(fail_rf_commit) failed.fs.fail_rename_from={"/radio_profiles.reply"}; + else failed.save_result=false; + applySet(failed,0,channel("#r2-mediamesh")); + assert(!failed.ok && failed.error==ERR_CODE_FILE_IO_ERROR); + assert(!strcmp(failed.stored[0].name,old_channel.name) + && !memcmp(failed.stored[0].channel.secret,old_channel.channel.secret,32) + && failed.stored[0].channel.tx_radio==old_channel.channel.tx_radio); + assert(failed.fs.files==old_disk && !failed._radio_profiles.hasReplyMutation()); + assert(failed._radio_profiles.savedSecondaryConfig().mode==old_profile.mode + && failed._radio_profiles.savedSecondaryConfig().params==old_profile.params); + failed.advance(2000); + assert(failed.radio.p.secondary.mode==old_profile.mode && failed.radio.p.secondary.params==old_profile.params); + assertUntouched(failed); + } + Preferences::reset(); MyMesh conflicting; active(conflicting); + const auto old_profile=conflicting.radio.p.secondary; + const auto old_disk=conflicting.fs.files; + applySet(conflicting,1,channel("#r2-mediamesh")); + assert(!conflicting.ok && conflicting.error==ERR_CODE_BAD_STATE && !conflicting.stored[1].name[0]); + assert(conflicting.radio.p.secondary.mode==old_profile.mode + && conflicting.radio.p.secondary.params==old_profile.params); + assert(conflicting.fs.files==old_disk && conflicting._mt_active); + assert(!conflicting._radio_profiles.hasReplyMutation()); assertUntouched(conflicting); + puts("PASS channel-presets"); +} static void privateCLI() { Preferences::reset(); MyMesh node; node.beginMeshtext(); char reply[256]; const char* key128="0102030405060708090a0b0c0d0e0f10"; @@ -1646,6 +1740,7 @@ int main(int argc,char** argv) { else if(!strcmp(argv[1],"receive"))receive(); else if(!strcmp(argv[1],"transmit"))transmit(); else if(!strcmp(argv[1],"commands"))commands(); + else if(!strcmp(argv[1],"channel-presets"))channelPresets(); else if(!strcmp(argv[1],"cli"))privateCLI(); else if(!strcmp(argv[1],"hops"))hops(); else if(!strcmp(argv[1],"shortname"))shortname(); diff --git a/test/fixtures/radio_profiles/cli_test.cpp b/test/fixtures/radio_profiles/cli_test.cpp index 2d847332d..953cf35b5 100644 --- a/test/fixtures/radio_profiles/cli_test.cpp +++ b/test/fixtures/radio_profiles/cli_test.cpp @@ -548,6 +548,168 @@ static void mediaMeshPresets() { assert(!invalid.radio.p.enabled() && invalid.fs.files.empty()); } +static void channelPresetTransaction() { + for (bool range : {false, true}) { + Fixture f; + assert(f.cli.savePrimaryPreamble(48)); + f.cli.stagePrimary(48, false); + f.cmd("set radio2.cross on"); + f.cmd("set radio2 910.5,500,8,5,rxtx,80"); f.advance(2000); + const auto primary = f.radio.p.primary; + const auto previous = f.radio.p.secondary; + const auto previous_image = f.fs.files["/radio_profiles"]; + const uint32_t intent = f.cli.secondaryIntentGeneration(); + assert(f.cli.stageChannelPreset(range ? "mediamesh-range" : "mediamesh")); + assert(f.cli.hasReplyMutation() && f.fs.exists("/radio_profiles.reply")); + assert(f.fs.files["/radio_profiles"] == previous_image); + assert(f.cli.savedSecondaryConfig().params == previous.params); + assert(f.radio.p.secondary.params == previous.params); + f.advance(100); // A staged channel save has no permission to publish yet. + assert(f.radio.p.secondary.params == previous.params); + assert(f.cli.secondaryIntentGeneration() == intent); + assert(f.cli.finishChannelPreset(true)); + assert(!f.cli.hasReplyMutation() && !f.fs.exists("/radio_profiles.reply")); + assert(f.cli.secondaryIntentGeneration() == intent + 1); + const auto saved = f.cli.savedSecondaryConfig(); + assert(saved.mode == mesh::RadioProfileMode::RxTx); + assert(fabsf(saved.params.freq - 911.150f) < 0.0001f); + assert(saved.params.bw == (range ? 125 : 250)); + assert(saved.params.sf == 7 && saved.params.cr == 5 && !saved.params.preamble); + assert(saved.params.protocol == mesh::RadioProfileProtocol::MeshCore); + // The pair is durable before acknowledging the channel, while the radio + // retains the ordinary saved setter's two-second application delay. + Radio reboot; mesh::RadioProfileCLI restored; + restored.begin(&f.fs, &reboot, &f.clock); + assert(reboot.p.secondary.params == saved.params); + assert(reboot.p.secondary.mode == saved.mode); + assert(reboot.p.primary == primary && reboot.p.cross == mesh::RadioCrossMode::On); + f.advance(1999); + assert(f.radio.p.secondary.params == previous.params); + f.advance(1); + assert(f.radio.p.secondary.params == saved.params && f.radio.p.secondary.mode == saved.mode); + assert(f.radio.p.primary == primary && f.radio.p.cross == mesh::RadioCrossMode::On); + assert(!f.cli.finishChannelPreset(true) && !f.cli.finishChannelPreset(false)); + } + { + Fixture f; + f.cmd("set radio2 off"); f.advance(2000); + const auto before = f.fs.files; + const uint32_t intent = f.cli.secondaryIntentGeneration(); + assert(f.cli.stageChannelPreset("mediamesh")); + assert(f.cli.finishChannelPreset(false)); // The channel save failed. + assert(!f.cli.hasReplyMutation() && f.fs.files == before); + assert(f.cli.secondaryIntentGeneration() == intent); + f.advance(2000); + assert(!f.radio.p.enabled() && f.cli.savedSecondaryConfig().mode == mesh::RadioProfileMode::Off); + Radio reboot; mesh::RadioProfileCLI restored; + restored.begin(&f.fs, &reboot, &f.clock); + assert(!reboot.p.enabled()); + } + for (int fail_rename : {1, 2}) { + Fixture f; + f.cmd("set radio2 910.5,500,8,5,rx,80"); f.advance(2000); + const auto before = f.fs.files; + const auto previous = f.radio.p.secondary; + const uint32_t intent = f.cli.secondaryIntentGeneration(); + assert(f.cli.stageChannelPreset("mediamesh-range")); + f.fs.fail_rename = fail_rename; + assert(!f.cli.finishChannelPreset(true)); + assert(f.cli.hasReplyMutation()); + assert(f.radio.p.secondary.params == previous.params); + assert(f.cli.savedSecondaryConfig().params == previous.params); + assert(f.cli.secondaryIntentGeneration() == intent); + assert(f.cli.finishChannelPreset(false)); + assert(f.fs.files == before && !f.cli.hasReplyMutation()); + f.advance(2000); + assert(f.radio.p.secondary.params == previous.params); + Radio reboot; mesh::RadioProfileCLI restored; + restored.begin(&f.fs, &reboot, &f.clock); + assert(reboot.p.secondary.params == previous.params && reboot.p.secondary.mode == previous.mode); + } + { + Fixture f; + const char* invalid_names[] = {nullptr, "", "MediaMesh", "mediamesh-extra", + "mediamesh,32", "911.150,250,7,5,rxtx"}; + for (const char* name : invalid_names) { + assert(!f.cli.stageChannelPreset(name)); + assert(!f.cli.hasReplyMutation() && f.fs.files.empty()); + } + f.radio.minimum_frequency = 912; + assert(!f.cli.stageChannelPreset("mediamesh")); + assert(!f.cli.hasReplyMutation() && f.fs.files.empty()); + f.radio.minimum_frequency = 137; + f.fs.fail_write = true; + assert(!f.cli.stageChannelPreset("mediamesh")); + assert(!f.cli.hasReplyMutation() && !f.radio.p.enabled()); + assert(!f.fs.exists("/radio_profiles")); + } + { + Fixture f; + mesh::RadioProfileCLI unsupported; + unsupported.begin(&f.fs, nullptr, &f.clock); + assert(!unsupported.stageChannelPreset("mediamesh")); + assert(!unsupported.finishChannelPreset(true) && f.fs.files.empty()); + } + for (auto protocol : {mesh::RadioProfileProtocol::Meshtext, + mesh::RadioProfileProtocol::Kiss, mesh::RadioProfileProtocol::Ethernet}) { + Fixture f; + mesh::RadioProfileConfig foreign; + foreign.mode = mesh::RadioProfileMode::RxTx; + foreign.params.freq = 911.5; foreign.params.bw = 250; + foreign.params.sf = 7; foreign.params.cr = 5; foreign.params.protocol = protocol; + f.radio.p.setSecondary(foreign, false); + assert(!f.cli.stageChannelPreset("mediamesh")); + assert(!f.cli.hasReplyMutation() && f.fs.files.empty()); + assert(f.radio.p.secondary.params == foreign.params); + } +#if MESH_ENABLE_MESHTEXT_CHAT || MESH_ENABLE_ETHERNET_TUNNEL || MESH_ENABLE_HOST_KISS + { + Fixture f; + mesh::RadioProfileConfig foreign; + foreign.mode = mesh::RadioProfileMode::RxTx; + foreign.params.freq = 911.5; foreign.params.bw = 250; + foreign.params.sf = 7; foreign.params.cr = 5; + foreign.params.protocol = mesh::RadioProfileProtocol::Meshtext; + assert(f.cli.requestAutoSecondary(foreign)); + assert(!f.cli.stageChannelPreset("mediamesh")); + assert(f.cli.autoSecondaryActive() && f.fs.files.empty()); + // A separately leased MeshCore tuple must also remain protected after + // an automatic owner observes an external generation change. + foreign.params.protocol = mesh::RadioProfileProtocol::MeshCore; + f.radio.p.setSecondary(foreign, false); + assert(!f.cli.stageChannelPreset("mediamesh")); + assert(!f.cli.hasReplyMutation() && f.fs.files.empty()); + assert(f.radio.p.secondary.params == foreign.params); + } +#endif + for (unsigned busy = 0; busy < 5; ++busy) { + Fixture f; + if (busy == 0) f.radio.p.primary_temporary = true; + else if (busy == 1) f.cmd("set tempradio2 910.5,500,8,5,rx,1,80"); + else if (busy == 2) { f.cmd("set tempradio2 910.5,500,8,5,rx,1,80"); f.advance(2000); } + else if (busy == 3) f.cmd("set radioat2 910.5,500,8,5,rx,+1,80"); + else f.cmd("set radio2 910.5,500,8,5,rx,80"); + const auto before = f.fs.files; + assert(!f.cli.stageChannelPreset("mediamesh")); + assert(!f.cli.hasReplyMutation() && f.fs.files == before); + } + { + Fixture f; + f.cli.beginReplyCommand(); + assert(!f.cli.stageChannelPreset("mediamesh")); + f.cmd("set radio2 mediamesh-range"); + f.cli.endReplyCommand(); + const auto before = f.fs.files; + const uint32_t generation = f.cli.replyMutationGeneration(); + assert(!f.cli.stageChannelPreset("mediamesh")); + assert(!f.cli.stageChannelPreset("invalid")); + assert(f.cli.hasReplyMutation() && f.cli.replyMutationGeneration() == generation); + assert(f.fs.files == before); // Failed staging cannot cancel the remote ACK owner. + assert(f.cli.finishReplyMutation(true)); f.advance(1); + assert(!f.cli.hasReplyMutation() && f.radio.p.secondary.params.bw == 125); + } +} + struct MediaScope { bool available = true, fail_save = false, enabled = false, durable = false; unsigned checks = 0, commits = 0; @@ -731,6 +893,7 @@ int main(int argc, char** argv) { else if (!strcmp(argv[1], "relative_schedule")) relativeSchedules(); else if (!strcmp(argv[1], "spectrum_overlap")) spectrumOverlap(); else if (!strcmp(argv[1], "mediamesh")) mediaMeshPresets(); + else if (!strcmp(argv[1], "channel_preset")) channelPresetTransaction(); else if (!strcmp(argv[1], "media_scope")) mediaMeshScopeLifecycle(); else return 2; return 0; diff --git a/test/test_companion_channel_alias.py b/test/test_companion_channel_alias.py index dcfafe675..39acaf50c 100644 --- a/test/test_companion_channel_alias.py +++ b/test/test_companion_channel_alias.py @@ -17,6 +17,8 @@ HASH_NAMES = ( "#emergency", "#r2-emergency", "#mt-emergency", "#custom", "#r2-custom", "#mt-custom", "#EMERGENCY", "#Emergency", "emergency", "Emergency", "#emergency-test", + "#mediamesh", "#r2-mediamesh", "r2-mediamesh", + "#mediamesh-range", "#r2-mediamesh-range", "r2-mediamesh-range", ) HARNESS = r''' @@ -80,6 +82,18 @@ static void grammar() { "#r2-r2-room", "#r2-mt-room", "#mt-r2-room", "#mt-mt-room"}) { assert(parseCompanionChannelAlias(name).kind == CompanionChannelKind::Invalid); } + for (const char* name : {"#r2-mediamesh", "r2-mediamesh", + "#r2-mediamesh-range", "r2-mediamesh-range"}) { + const auto alias = parseCompanionChannelAlias(name); + assert(companionChannelRadio2Preset(name)); + assert(!strcmp(companionChannelRadio2Preset(name), alias.tail)); + } + for (const char* name : {"#mediamesh", "mediamesh", "#mt-mediamesh", + "#r2-MediaMesh", "#r2-mediamesh-extra", "#r2-mediamesh,32", + "#r2-mt-mediamesh", "#r2-", ""}) { + assert(!companionChannelRadio2Preset(name)); + } + assert(!companionChannelRadio2Preset(nullptr)); puts("PASS grammar"); } static void canonical() { @@ -391,6 +405,22 @@ struct Serial { COMMAND_MEMBERS = r''' Serial serial; Serial* _serial = &serial; + struct ProfileSetup { + bool stage_ok = true, commit_ok = true, pending = false; + unsigned stages = 0, commits = 0, cancels = 0; + char requested[32] = {}; + bool stageChannelPreset(const char* name) { + ++stages; + if (!stage_ok) return false; + strcpy(requested, name); pending = true; return true; + } + bool finishChannelPreset(bool commit) { + assert(pending); + if (commit) { ++commits; if (!commit_ok) return false; } + else ++cancels; + pending = false; return true; + } + } _radio_profiles; bool save_result = true, ok = false; int error = -1; unsigned save_calls = 0; bool saveChannels() { ++save_calls; return save_result; } void writeErrFrame(uint8_t code) { error = code; } @@ -985,7 +1015,7 @@ class CompanionChannelAliasTest(unittest.TestCase): "@KEY_VECTORS@", key_vectors).replace("@METHODS@", methods).replace("@SAVE_GETTER@", save_getter) self.run_native_program(source_text, "runtime") - def test_production_binary_get_set_raw_keys_and_rollback(self): + def binary_channel_program(self, cases=None): companion = (ROOT / "examples/companion_radio/MyMesh.cpp").read_text(encoding="utf-8") header = (ROOT / "examples/companion_radio/MyMesh.h").read_text(encoding="utf-8") signatures = ("bool MyMesh::isAliasRadio2Defined(", "bool MyMesh::resolveChannelAlias(", @@ -1000,7 +1030,7 @@ class CompanionChannelAliasTest(unittest.TestCase): branches = " else ".join(body(companion, signature) for signature in command_signatures) macros = ("CMD_GET_CHANNEL", "CMD_SET_CHANNEL", "RESP_CODE_CHANNEL_INFO", "ERR_CODE_UNSUPPORTED_CMD", "ERR_CODE_NOT_FOUND", "ERR_CODE_FILE_IO_ERROR", - "ERR_CODE_ILLEGAL_ARG") + "ERR_CODE_ILLEGAL_ARG", "ERR_CODE_BAD_STATE") constants = "\n".join(re.search(r"^#define\s+" + name + r"\s+[^\r\n]+", companion, flags=re.MULTILINE).group(0) for name in macros) shared = HARNESS[:HARNESS.index("static void assertAlias(")] @@ -1008,10 +1038,86 @@ class CompanionChannelAliasTest(unittest.TestCase): fixture = fixture.replace("class MyMesh :", COMMAND_STUBS + "\nclass MyMesh :") fixture = fixture.replace("@SAVE_GETTER@", save_getter + "\n" + COMMAND_MEMBERS) vectors, key_vectors = oracle_vectors() - source_text = (shared + constants + "\n" + fixture + COMMAND_HARNESS).replace( + commands = COMMAND_HARNESS if cases is None else ( + COMMAND_HARNESS[:COMMAND_HARNESS.index("\nint main() {")] + cases) + source_text = (shared + constants + "\n" + fixture + commands).replace( "@VECTORS@", vectors).replace("@KEY_VECTORS@", key_vectors).replace( "@METHODS@", methods).replace("@COMMAND_BRANCHES@", branches) - self.run_native_program(source_text, "command") + return source_text + + def test_production_binary_get_set_raw_keys_and_rollback(self): + self.run_native_program(self.binary_channel_program(), "command") + + def test_mediamesh_joins_stage_setup_and_ack_only_after_both_saves(self): + cases = r''' +int main() { + for (const char* name : {"#r2-mediamesh", "r2-mediamesh", + "#r2-mediamesh-range", "r2-mediamesh-range"}) { + MyMesh node; + auto joined = channel(name); + applySet(node,0,joined); + assert(node.ok && node.error==-1 && node.save_calls==1); + expectRaw(node,0,joined); + expectGet(node,0,joined); + const char* preset=companionChannelRadio2Preset(name); + assert(node._radio_profiles.stages==1 && node._radio_profiles.commits==1); + assert(!node._radio_profiles.pending && !strcmp(node._radio_profiles.requested,preset)); + // App replay, key edit and hashtag-only rename are not new preset choices. + applySet(node,0,joined); + for(unsigned i=0;i<16;++i)joined.channel.secret[i]=uint8_t(i+1); + applySet(node,0,joined); + strcpy(joined.name,name[0]=='#' ? name+1 : (strstr(name,"-range") ? "#r2-mediamesh-range":"#r2-mediamesh")); + applySet(node,0,joined); + assert(node.ok && node._radio_profiles.stages==1); + // A different newly joined preset selects radio2 even if another named + // channel remains in its slot; there is only one shared radio2 profile. + auto other=channel(strstr(name,"-range") ? "#r2-mediamesh":"#r2-mediamesh-range"); + applySet(node,1,other); + assert(node.ok && node._radio_profiles.stages==2 && node._radio_profiles.commits==2); + assert(!strcmp(node._radio_profiles.requested,companionChannelRadio2Preset(other.name))); + ChannelDetails empty{};applySet(node,1,empty); + assert(node.ok && node._radio_profiles.stages==2); // removal is not radio2 Off + applySet(node,1,other); + assert(node.ok && node._radio_profiles.stages==3); + } + for(bool stage_fail:{false,true}) { + MyMesh node;const auto before=channel("#custom");assert(node.setChannel(0,before)); + node._radio_profiles.stage_ok=!stage_fail;node.save_result=stage_fail; + applySet(node,0,channel("#r2-mediamesh")); + assert(!node.ok && node.error==(stage_fail ? ERR_CODE_BAD_STATE:ERR_CODE_FILE_IO_ERROR)); + assert(node.save_calls==(stage_fail ? 0u:1u));expectRaw(node,0,before); + assert(!node._radio_profiles.pending && node._radio_profiles.commits==0); + assert(node._radio_profiles.cancels==(stage_fail ? 0u:1u)); + } + { + MyMesh node;const auto before=channel("#custom");assert(node.setChannel(0,before)); + node._radio_profiles.commit_ok=false; + applySet(node,0,channel("#r2-mediamesh-range")); + assert(!node.ok && node.error==ERR_CODE_FILE_IO_ERROR && node.save_calls==2); + expectRaw(node,0,before);assert(!node._radio_profiles.pending && node._radio_profiles.cancels==1); + } + { + // An mt- fallback label on a build without Meshtext must not be made + // unusable by silently enabling radio2 with another channel's join. + MyMesh node;const auto mt=channel("#mt-emergency");assert(node.setChannel(1,mt)); + applySet(node,0,channel("#r2-mediamesh")); + assert(!node.ok && node.error==ERR_CODE_BAD_STATE); + assert(!node._radio_profiles.stages && !node.save_calls);expectRaw(node,1,mt); + } + { + MyMesh node;applySet(node,MAX_GROUP_CHANNELS,channel("#r2-mediamesh")); + assert(!node.ok && node.error==ERR_CODE_NOT_FOUND && !node._radio_profiles.stages); + for(const char* name:{"#mediamesh","#r2-custom","#r2-emergency"}) { + applySet(node,0,channel(name));assert(node.ok && !node._radio_profiles.stages); + } + // Disk loading has no RF side effect. An operator's later Off remains Off. + assert(node.setChannel(1,channel("#r2-mediamesh"))); + assert(!node._radio_profiles.stages); + } + puts("PASS mediamesh-join"); +} +''' + self.run_native_program(self.binary_channel_program(cases), "mediamesh-join") def test_production_binary_get_zero_pads_dirty_fixed_width_name(self): companion = (ROOT / "examples/companion_radio/MyMesh.cpp").read_text(encoding="utf-8") diff --git a/test/test_companion_channel_publication.py b/test/test_companion_channel_publication.py index a536b5bb2..993be28cc 100644 --- a/test/test_companion_channel_publication.py +++ b/test/test_companion_channel_publication.py @@ -115,6 +115,13 @@ struct StrHelper { #endif """, 1) source = source.replace(" bool handleTxRoutingCommand(const char*,char*,size_t);", """ + using BaseChatMesh = MyMesh; + // This publication fixture only mutates ordinary channels. RF preset + // behavior is exercised with the real CLI by the Meshtext fixture. + struct { + bool stageChannelPreset(const char*) { assert(false); return false; } + bool finishChannelPreset(bool) { assert(false); return false; } + } _radio_profiles; bool handleTxRoutingCommand(const char*,char*,size_t); int last_error = 0; bool binary_ack = false; diff --git a/test/test_companion_meshtext.py b/test/test_companion_meshtext.py index 7980f8a89..f07311aa2 100644 --- a/test/test_companion_meshtext.py +++ b/test/test_companion_meshtext.py @@ -213,6 +213,9 @@ class CompanionMeshtextTest(unittest.TestCase): def test_binary_get_set_raw_psk_and_save_failure_rollback(self): self.run_case("commands") + def test_binary_mediamesh_presets_commit_delay_replay_and_failure_rollback(self): + self.run_case("channel-presets") + def test_private_cli_provisioning_metadata_masking_and_transactional_delete(self): self.run_case("cli") diff --git a/test/test_radio_profile_cli.py b/test/test_radio_profile_cli.py index 4166d7512..2b5000893 100644 --- a/test/test_radio_profile_cli.py +++ b/test/test_radio_profile_cli.py @@ -9,6 +9,22 @@ import unittest ROOT = Path(__file__).resolve().parents[1] class RadioProfileCLITest(unittest.TestCase): + def test_channel_preset_preserves_secondary_owners(self): + compiler = shutil.which('g++') or shutil.which('clang++') + self.assertIsNotNone(compiler) + with tempfile.TemporaryDirectory(prefix='profile-channel-owner-') as work: + binary = Path(work) / 'channel-owner' + compiled = subprocess.run([ + compiler, '-std=c++17', '-Wall', '-Wextra', + '-DMESH_ENABLE_MESHTEXT_CHAT=1', + '-I', str(ROOT/'test/fixtures/radio_profiles/mocks'), + '-I', str(ROOT/'test/mocks'), '-I', str(ROOT/'src'), + str(ROOT/'src/helpers/RadioProfileCLI.cpp'), + str(ROOT/'test/fixtures/radio_profiles/cli_test.cpp'), + '-o', str(binary)], capture_output=True, text=True, timeout=60) + self.assertEqual(compiled.returncode, 0, compiled.stderr) + subprocess.run([str(binary), 'channel_preset'], check=True, timeout=10) + def test_all_companions_keep_rf_presets_without_unused_region_code(self): compiler = shutil.which('g++') or shutil.which('clang++') self.assertIsNotNone(compiler) @@ -80,7 +96,8 @@ int main() { '-o', str(exe)], check=True, capture_output=True, text=True) for scenario in (None, 'permanent_order', 'temporary_boundary', 'lease_creation', 'save_failure', 'schedule_permutations', 'storage_isolation', - 'relative_schedule', 'spectrum_overlap', 'mediamesh', 'media_scope'): + 'relative_schedule', 'spectrum_overlap', 'mediamesh', 'channel_preset', + 'media_scope'): with self.subTest(fleet=fleet_enabled, carrier=carrier_enabled, scenario=scenario or 'commands_persistence_and_timers'): subprocess.run([str(exe)] + ([scenario] if scenario else []), check=True) From 0c7dec9f1989aa7c511ba62aa18b08bd19e30806 Mon Sep 17 00:00:00 2001 From: mikecarper Date: Wed, 28 Oct 2026 02:06:53 -0700 Subject: [PATCH 04/61] Add an hourly emergency battery sleep schedule for infrastructure nodes --- .github/workflows/run-unit-tests.yml | 4 + docs/cli_commands.md | 63 ++++- docs/solarxiao_power.md | 21 ++ examples/simple_repeater/MyMesh.h | 16 ++ examples/simple_repeater/main.cpp | 20 ++ examples/simple_room_server/MyMesh.h | 16 ++ examples/simple_room_server/main.cpp | 30 +- examples/simple_sensor/SensorMesh.h | 16 ++ examples/simple_sensor/main.cpp | 15 + scripts/client_acl_role.py | 28 ++ src/MeshCore.h | 10 + src/helpers/BatteryForwarding.h | 13 +- src/helpers/CommonCLI.cpp | 9 + src/helpers/CommonCLI.h | 24 +- src/helpers/CommonCLI_Emergency.cpp | 146 ++++++++++ src/helpers/ESP32Board.cpp | 31 +++ src/helpers/ESP32Board.h | 4 + src/helpers/EmergencyOtaWork.h | 38 +++ src/helpers/EmergencyPowerConfig.h | 11 + src/helpers/EmergencyPowerPolicy.h | 122 ++++++++ src/helpers/EmergencyPowerRuntime.h | 59 ++++ src/helpers/NRF52Board.cpp | 28 +- src/helpers/NRF52Board.h | 4 + src/helpers/WirelessControl.h | 51 ++++ test/test_battery_forwarding.py | 84 +++++- test/test_client_acl_role.py | 21 ++ test/test_emergency_power.py | 386 ++++++++++++++++++++++++++ test/test_emergency_power_ota.py | 155 +++++++++++ test/test_emergency_power_runtime.py | 360 ++++++++++++++++++++++++ test/test_emergency_sleep_backends.py | 268 ++++++++++++++++++ 30 files changed, 2020 insertions(+), 33 deletions(-) create mode 100644 src/helpers/CommonCLI_Emergency.cpp create mode 100644 src/helpers/EmergencyOtaWork.h create mode 100644 src/helpers/EmergencyPowerConfig.h create mode 100644 src/helpers/EmergencyPowerPolicy.h create mode 100644 src/helpers/EmergencyPowerRuntime.h create mode 100644 test/test_emergency_power.py create mode 100644 test/test_emergency_power_ota.py create mode 100644 test/test_emergency_power_runtime.py create mode 100644 test/test_emergency_sleep_backends.py diff --git a/.github/workflows/run-unit-tests.yml b/.github/workflows/run-unit-tests.yml index 052aeb281..6bbe16c88 100644 --- a/.github/workflows/run-unit-tests.yml +++ b/.github/workflows/run-unit-tests.yml @@ -595,6 +595,10 @@ jobs: python3 -B test/test_regulator_recovery.py -v python3 -B test/test_regulator_settings.py -v python3 -B test/test_battery_forwarding.py -v + python3 -B test/test_emergency_power.py -v + python3 -B test/test_emergency_power_runtime.py -v + python3 -B test/test_emergency_power_ota.py -v + python3 -B test/test_emergency_sleep_backends.py -v python3 -B test/test_battery_calibration.py -v python3 -B test/test_battery_calibration_store.py -v python3 -B test/test_power_saving_diagnostics.py -v diff --git a/docs/cli_commands.md b/docs/cli_commands.md index 6094581f3..567edd71c 100644 --- a/docs/cli_commands.md +++ b/docs/cli_commands.md @@ -3024,8 +3024,8 @@ send text.flood checking ridge link - `set battery.alert.critical <0-99>` **Defaults:** -- `battery.alert.low`: `20` -- `battery.alert.critical`: `10` +- `battery.alert.low`: `25` +- `battery.alert.critical`: `15` **Note:** The low threshold must be greater than the critical threshold. Alerts at or below the critical threshold use `CRITICAL BATTERY` in the message; both severities use the same 12-hour resend cooldown. @@ -3052,12 +3052,17 @@ readings remain available. - `set flood.battery ` - `get battery.forward` -`set repeat.battery on` enables the saved repeat threshold, initially **10%**. -`set flood.battery on` enables the saved flood threshold, initially **20%**. +`set repeat.battery on` enables the saved repeat threshold, initially **15%**. +`set flood.battery on` enables the saved flood threshold, initially **25%**. A number both enables the control and sets its threshold. `off` disables it while retaining that threshold for the next `on`. Changes are saved and take effect without a reboot. +An older forwarding record with exactly the former 10% repeat / 20% flood +defaults adopts 15% / 25% on upgrade. Its enabled controls and voltage endpoints +stay unchanged. Other saved thresholds are retained; newly selected custom +10% / 20% settings also remain available. + Below the repeat threshold, the node stops LoRa retransmissions of received traffic. Below the flood threshold, it stops LoRa retransmissions of received flood traffic but may still @@ -3071,8 +3076,8 @@ limits. Exporting radio reception to WiFi, ESP-NOW, or RS232 is unchanged. Forwarding resumes automatically after the measured percentage reaches two percentage points above the applicable threshold, capped at 100%. With the -initial thresholds, flood forwarding pauses below 20% and resumes at 22%; all -relay forwarding pauses below 10% and resumes at 12%. This recovery margin +initial thresholds, flood forwarding pauses below 25% and resumes at 27%; all +relay forwarding pauses below 15% and resumes at 17%. This recovery margin avoids repeatedly switching forwarding on and off near a threshold. Battery voltage is sampled every 30 seconds while either control is enabled, @@ -3093,12 +3098,54 @@ get battery.forward To choose different thresholds and later disable the feature: ```text -set repeat.battery 15 -set flood.battery 25 +set repeat.battery 12 +set flood.battery 22 set repeat.battery off set flood.battery off ``` +#### Emergency battery schedule + +Supported ESP32 and nRF52 Repeater, Room Server and Sensor images enter the +final battery-saving stage at **5% or less**, independently of the optional +forwarding gates. Automatic mode returns to normal at **7% or more**. An +invalid battery reading cannot activate it, or falsely clear an existing low +battery state. Percentage uses the same configured voltage endpoints as +`battery.forward`; it does not prove that a physical battery is connected. + +In emergency mode, normal work runs during the first **15 minutes of every +UTC hour** (`:00:00` through `:14:59`). For the remaining 45 minutes the LoRa +radio sleeps and normal mesh, sensor and screen work is suspended. The CPU +uses timed sleep with brief watchdog, clock, voltage-protection and command +maintenance wakes. Active firmware updates and unfinished critical operations +defer sleep. Existing hard low-voltage protection remains in force. + +```text +get power.emergency +set power.emergency on +set power.awake 30 +set power.awake 0 +set power.emergency auto +``` + +`on` saves manual emergency mode, including across reboots. `auto` restores +battery-controlled entry and is the default. The initial transition allows +five seconds for its reply. `power.awake` keeps the node up for the next given +number of minutes (0 cancels; maximum 10080), even outside the hourly window. +That override is held in RAM and ends on reboot; it never disables hard +low-voltage shutdown. Use it before maintenance that needs continuous uptime. + +`get power.emergency` reports the mode, battery estimate, desired `window`, +actual radio ownership, seconds until the next awake window, and remaining +hold. With an unset clock it reports `clock=uptime` and follows the same +15/45-minute cycle from boot until a valid UTC clock is available. Queued +future traffic does not keep a critically low node awake indefinitely. + +This schedule uses resumable timed sleep, preserving identity and settings. +WiFi/ESP-NOW services are temporarily stopped and their previous live subset +is restored when the node wakes. Companion builds and flash-constrained +profiles that omit battery-forwarding controls do not add this feature. + #### Set the battery percentage voltage range The forwarding thresholds use a voltage-derived percentage. On infrastructure diff --git a/docs/solarxiao_power.md b/docs/solarxiao_power.md index 0543e4a34..a663c0255 100644 --- a/docs/solarxiao_power.md +++ b/docs/solarxiao_power.md @@ -246,6 +246,27 @@ measurement or entry into ship mode. Read the corresponding status afterward. See the [bench acceptance report](test-results/solarxiao_power_2026-10-21.md) for exact firmware identity, measured readouts and test limits. +## Emergency battery schedule + +The [emergency schedule](cli_commands.md#emergency-battery-schedule) keeps the +radio and normal work awake for the first 15 minutes of each UTC hour, with +timed sleep for the remaining 45 minutes. It starts automatically at 5% and +recovers at 7%. Forwarding thresholds now default to 25% for floods and 15% +for all relayed traffic; those forwarding gates remain opt-in. + +```text +get power.emergency +set power.emergency on +set power.awake 30 +set power.emergency auto +``` + +The percentage follows the configured self battery voltage endpoints used by +`battery.forward`. On MPPT carriers this is the XIAO ADC, not an estimate of +the separate carrier battery's charge from INA260/LTC4162. Check the actual +voltage source before relying on automatic entry. Manual mode and timed wake +work independently of the charger. Hard low-voltage protection still applies. + ## Conversion defaults and carrier revisions The supplied [Standard schematic](https://github.com/wehooper4/Meshtastic-Hardware/blob/main/XaioSeries/SolarXaio/MPPT/Standard/Schematic_SolarXaio_MPPT_2025-10-23.pdf) diff --git a/examples/simple_repeater/MyMesh.h b/examples/simple_repeater/MyMesh.h index 8448b7e62..f1edc4d0b 100644 --- a/examples/simple_repeater/MyMesh.h +++ b/examples/simple_repeater/MyMesh.h @@ -5,6 +5,7 @@ #include #include #include +#include #include #include #include @@ -1277,6 +1278,21 @@ public: NodePrefs* getNodePrefs() { return &_prefs; } +#if MESH_ENABLE_EMERGENCY_POWER + mesh::power::EmergencyOtaWork _emergency_ota_work; + CommonCLI& getPowerCLI() { return _cli; } + bool emergencyPowerBusy() const { +#if defined(ENABLE_OTA) + if (_emergency_ota_work.busy(mesh::ota::ota_context_if_active(), millis())) return true; +#endif +#if MESH_ENABLE_DIRECT_OTA_ADMIN + if (direct_ota_admin.awaitingPeer()) return true; +#endif + return hasPendingOtaApply() || _cli.hasPendingRadioSettings() + || _cli.hasActiveUserGpioTimer() || dirty_contacts_expiry != 0 + || (saved_radio_apply_pending && !temp_radio_applied) || _ota_update_at != 0; + } +#endif bool isHiddenNode() const { return _prefs.advert_interval == 0 && _prefs.flood_advert_interval == 0; } diff --git a/examples/simple_repeater/main.cpp b/examples/simple_repeater/main.cpp index 45ec9755a..93a945a79 100644 --- a/examples/simple_repeater/main.cpp +++ b/examples/simple_repeater/main.cpp @@ -20,6 +20,7 @@ #include #include #include +#include #include #if MESH_PACKET_LOGGING #include @@ -727,6 +728,23 @@ void loop() { #endif serviceCommandInterfaces(); +#if MESH_ENABLE_EMERGENCY_POWER + static mesh::power::EmergencyPowerRuntime emergency_power; +#if defined(ESP32_PLATFORM) + board.servicePowerTestControls(); +#endif + rtc_clock.tick(); +#ifdef HAS_EXTERNAL_WATCHDOG + external_watchdog.loop(); +#endif + bool emergency_busy = command[0]; +#if MESH_USB_CONSOLE_COOPERATIVE + emergency_busy = emergency_busy || the_mesh.hasPendingSerialOutput(); +#endif + if (emergency_power.service(the_mesh, radio_driver, board, + the_mesh.getPowerCLI(), emergency_busy, the_mesh.emergencyPowerBusy())) return; +#endif + #if defined(PIN_USER_BTN) && defined(_SEEED_SENSECAP_SOLAR_H_) && !defined(DISPLAY_CLASS) // Hold the user button to power off the SenseCAP Solar repeater. int btnState = digitalRead(PIN_USER_BTN); @@ -778,7 +796,9 @@ void loop() { // Report the guard that really stopped this iteration, without probing // later guards or changing their short-circuit hardware access. #if defined(ESP32_PLATFORM) +#if !MESH_ENABLE_EMERGENCY_POWER board.servicePowerTestControls(); +#endif mesh::esp32::serviceCpuFrequencyPolicy(board, the_mesh.getNodePrefs()->cpu_freq_mhz, the_mesh.getNodePrefs()->powersaving_enabled != 0, millis(), false, the_mesh.isDualRadioActive()); #endif diff --git a/examples/simple_room_server/MyMesh.h b/examples/simple_room_server/MyMesh.h index bf81bfff0..3ad07fcec 100644 --- a/examples/simple_room_server/MyMesh.h +++ b/examples/simple_room_server/MyMesh.h @@ -3,6 +3,10 @@ #include // needed for PlatformIO #include #include +#include +#if MESH_ENABLE_EMERGENCY_POWER && defined(ENABLE_OTA) +#include +#endif #include "RoomServerFeatures.h" #if defined(NRF52_PLATFORM) @@ -553,6 +557,18 @@ public: NodePrefs* getNodePrefs() { return &_prefs; } +#if MESH_ENABLE_EMERGENCY_POWER + mesh::power::EmergencyOtaWork _emergency_ota_work; + CommonCLI& getPowerCLI() { return _cli; } + bool emergencyPowerBusy() const { +#if defined(ENABLE_OTA) + if (_emergency_ota_work.busy(mesh::ota::ota_context_if_active(), millis())) return true; +#endif + return hasPendingOtaApply() || _cli.hasPendingRadioSettings() + || _cli.hasActiveUserGpioTimer() || dirty_contacts_expiry != 0 + || (saved_radio_apply_pending && !temp_radio_applied) || _ota_update_at != 0; + } +#endif #ifdef DISPLAY_ACTIVITY_DASHBOARD RadioActivityWindow* getActivityWindow() { return &_activity; } diff --git a/examples/simple_room_server/main.cpp b/examples/simple_room_server/main.cpp index 10d89c391..f5cc3339f 100644 --- a/examples/simple_room_server/main.cpp +++ b/examples/simple_room_server/main.cpp @@ -20,6 +20,7 @@ #include #include #include +#include #if MESH_PACKET_LOGGING #include #endif @@ -452,6 +453,28 @@ void loop() { } #endif + mesh::power::serviceRegulatorSettings(); +#if defined(MESH_SOLARXIAO_POWER) && MESH_SOLARXIAO_POWER + mesh::solar::service(); +#endif + board.loop(); + +#if MESH_ENABLE_EMERGENCY_POWER + static mesh::power::EmergencyPowerRuntime emergency_power; +#if defined(ESP32_PLATFORM) + board.servicePowerTestControls(); +#endif + rtc_clock.tick(); +#ifdef HAS_EXTERNAL_WATCHDOG + external_watchdog.loop(); +#endif + bool emergency_busy = command[0]; +#if MESH_USB_CONSOLE_COOPERATIVE + emergency_busy = emergency_busy || the_mesh.hasPendingSerialOutput(); +#endif + if (emergency_power.service(the_mesh, radio_driver, board, + the_mesh.getPowerCLI(), emergency_busy, the_mesh.emergencyPowerBusy())) return; +#endif the_mesh.loop(); const bool morse_timing = mesh::morseTimingActive(&radio_driver); #if defined(USE_LR2021) @@ -466,11 +489,6 @@ void loop() { #endif #endif rtc_clock.tick(); - mesh::power::serviceRegulatorSettings(); -#if defined(MESH_SOLARXIAO_POWER) && MESH_SOLARXIAO_POWER - mesh::solar::service(); -#endif - board.loop(); if (mesh::serviceUsbLoggingWatchdog(usbLoggingRecoverySafe)) board.reboot(); #if MESH_USB_CONSOLE_COOPERATIVE #if MESH_ENABLE_HOST_KISS @@ -489,7 +507,9 @@ void loop() { && !morse_timing) board.prepareProfileIdleCycle(); #endif #if defined(ESP32_PLATFORM) +#if !MESH_ENABLE_EMERGENCY_POWER board.servicePowerTestControls(); +#endif mesh::esp32::serviceCpuFrequencyPolicy(board, the_mesh.getNodePrefs()->cpu_freq_mhz, the_mesh.getNodePrefs()->powersaving_enabled != 0, millis(), false, the_mesh.isDualRadioActive()); #endif diff --git a/examples/simple_sensor/SensorMesh.h b/examples/simple_sensor/SensorMesh.h index 78688ff4f..0fe2cded5 100644 --- a/examples/simple_sensor/SensorMesh.h +++ b/examples/simple_sensor/SensorMesh.h @@ -3,6 +3,10 @@ #include // needed for PlatformIO #include #include +#include +#if MESH_ENABLE_EMERGENCY_POWER && defined(ENABLE_OTA) +#include +#endif #include "TimeSeriesData.h" @@ -86,6 +90,18 @@ public: const char* getRole() override { return FIRMWARE_ROLE; } const char* getNodeName() { return _prefs.node_name; } NodePrefs* getNodePrefs() { return &_prefs; } +#if MESH_ENABLE_EMERGENCY_POWER + mesh::power::EmergencyOtaWork _emergency_ota_work; + CommonCLI& getPowerCLI() { return _cli; } + bool emergencyPowerBusy() const { +#if defined(ENABLE_OTA) + if (_emergency_ota_work.busy(mesh::ota::ota_context_if_active(), millis())) return true; +#endif + return hasPendingOtaApply() || _cli.hasPendingRadioSettings() + || _cli.hasActiveUserGpioTimer() || dirty_contacts_expiry != 0 + || (saved_radio_apply_pending && !temp_radio_applied); + } +#endif uint32_t getPowerSaveSleepSeconds(uint32_t max_secs) const; uint32_t getPowerSaveSleepMilliseconds(uint32_t max_ms) const; bool isTrackerModeEnabled() const { diff --git a/examples/simple_sensor/main.cpp b/examples/simple_sensor/main.cpp index 17a806895..caf200835 100644 --- a/examples/simple_sensor/main.cpp +++ b/examples/simple_sensor/main.cpp @@ -24,6 +24,7 @@ #include #include #include +#include #if defined(NRF52_PLATFORM) && MESH_ENABLE_SENSOR_TRACKER #include static SoftwareTimer tracker_wake_timer; @@ -412,6 +413,18 @@ void loop() { mesh::power::serviceRegulatorSettings(); board.loop(); // let the board feed its watchdog, run periodic housekeeping +#if MESH_ENABLE_EMERGENCY_POWER + static mesh::power::EmergencyPowerRuntime emergency_power; +#if defined(ESP32_PLATFORM) + board.servicePowerTestControls(); +#endif + rtc_clock.tick(); +#ifdef HAS_EXTERNAL_WATCHDOG + external_watchdog.loop(); +#endif + if (emergency_power.service(the_mesh, radio_driver, board, + the_mesh.getPowerCLI(), command[0], the_mesh.emergencyPowerBusy())) return; +#endif the_mesh.loop(); const bool morse_timing = mesh::morseTimingActive(&radio_driver); #if defined(USE_LR2021) @@ -442,7 +455,9 @@ void loop() { #endif #if defined(ESP32_PLATFORM) +#if !MESH_ENABLE_EMERGENCY_POWER board.servicePowerTestControls(); +#endif mesh::esp32::serviceCpuFrequencyPolicy(board, the_mesh.getNodePrefs()->cpu_freq_mhz, the_mesh.getNodePrefs()->powersaving_enabled != 0, millis(), false, the_mesh.isDualRadioActive()); #endif diff --git a/scripts/client_acl_role.py b/scripts/client_acl_role.py index 995884297..c638d6407 100644 --- a/scripts/client_acl_role.py +++ b/scripts/client_acl_role.py @@ -22,6 +22,32 @@ OTHER_ROLES = ("simple_room_server", "simple_sensor", "companion_radio", "simple "kiss_modem", "partition_expander", "esp32_partition_migrator", "esp32_partition_legacy_seed") COMPANION_DEFINITION = "MESH_ROLE_COMPANION" COMPANION_SOURCES = ("../examples/companion_radio/main.cpp", "../examples/companion_radio/MyMesh.cpp") +EMERGENCY_DEFINITION = "MESH_ENABLE_EMERGENCY_POWER" + + +def emergency_power_policy(src_filter, flags=(), cppdefines=()): + """Enable scheduled battery sleep only on complete supported infrastructure roles.""" + values = SOURCE_POLICY.flag_defines(flags, cppdefines) + if EMERGENCY_DEFINITION in values: + value = str(values[EMERGENCY_DEFINITION]).strip("\"'() ") + if value not in ("0", "1"): + raise ValueError(EMERGENCY_DEFINITION + " override must be 0 or 1") + return {} + if not any(name in values for name in ("ESP32_PLATFORM", "NRF52_PLATFORM")): + return {} + if str(values.get("MESH_ENABLE_BATTERY_FORWARDING", "1")).strip("\"'() ") == "0": + return {} + selected = SOURCE_POLICY.source_selected + text = src_filter if isinstance(src_filter, str) else " ".join(map(str, src_filter)) + includes = re.findall(r"\+<\.\./examples/([^>]+)>", text.replace("\\", "/")) + for role, mesh_source in (("simple_repeater", "MyMesh.cpp"), + ("simple_room_server", "MyMesh.cpp"), + ("simple_sensor", "SensorMesh.cpp")): + if includes and all(path == role or path.startswith(role + "/") for path in includes) \ + and all(selected(src_filter, "../examples/" + role + "/" + source) + for source in ("main.cpp", mesh_source)): + return {EMERGENCY_DEFINITION: 1} + return {} def companion_role_policy(src_filter, flags=(), cppdefines=()): @@ -83,6 +109,8 @@ def install(build_env): build_env.get("CPPDEFINES", ())) definitions.update(companion_role_policy(src_filter, build_env.get("BUILD_FLAGS", ()), build_env.get("CPPDEFINES", ()))) + definitions.update(emergency_power_policy(src_filter, build_env.get("BUILD_FLAGS", ()), + build_env.get("CPPDEFINES", ()))) if definitions: build_env.AppendUnique(CPPDEFINES=list(definitions.items())) diff --git a/src/MeshCore.h b/src/MeshCore.h index 3ea29e65d..423d370b4 100644 --- a/src/MeshCore.h +++ b/src/MeshCore.h @@ -6,6 +6,7 @@ #include "helpers/UsbLogging.h" #include "helpers/BatteryChargeSupport.h" #include "helpers/BatteryCalibration.h" +#include "helpers/EmergencyPowerConfig.h" #include "helpers/RegulatorMode.h" #include "helpers/PowerSavingDiagnostics.h" #include "helpers/LedPolicy.h" @@ -202,6 +203,15 @@ public: return false; } virtual void enterDeepSleep(uint32_t secs) { (void)secs; } +#if MESH_ENABLE_EMERGENCY_POWER + // Resumable CPU sleep after the caller has suspended RF and drained owners. + // Capability describes the backend, not whether this attempt can sleep. + virtual bool supportsEmergencySleep() const { return false; } + virtual bool emergencySleepMilliseconds(uint32_t millisecs) { + (void)millisecs; + return false; + } +#endif // Called by example setup() functions to signal that boot is complete. // Boards may override to stop a boot-indicator LED sequence or similar. // Default no-op: boards that don't care need not implement anything. diff --git a/src/helpers/BatteryForwarding.h b/src/helpers/BatteryForwarding.h index 1c5aadc31..1745e5823 100644 --- a/src/helpers/BatteryForwarding.h +++ b/src/helpers/BatteryForwarding.h @@ -24,8 +24,8 @@ static constexpr size_t RecordSize = 16; struct Settings { uint8_t enabled = 0; - uint8_t repeatBelow = 10; - uint8_t floodBelow = 20; + uint8_t repeatBelow = 15; + uint8_t floodBelow = 25; // Zero keeps the board's compiled endpoints. nRF52 uses its voltage policy. uint16_t emptyMv = 0; uint16_t fullMv = 0; @@ -54,7 +54,7 @@ inline uint16_t checksum(const uint8_t* data, size_t size) { inline void encode(uint8_t* out, const Settings& s) { memset(out, 0, RecordSize); - out[0] = 'B'; out[1] = 'F'; out[2] = 'R'; out[3] = 1; + out[0] = 'B'; out[1] = 'F'; out[2] = 'R'; out[3] = 2; out[4] = s.enabled; out[5] = s.repeatBelow; out[6] = s.floodBelow; out[8] = uint8_t(s.emptyMv); out[9] = uint8_t(s.emptyMv >> 8); out[10] = uint8_t(s.fullMv); out[11] = uint8_t(s.fullMv >> 8); @@ -65,13 +65,18 @@ inline void encode(uint8_t* out, const Settings& s) { inline bool decode(const uint8_t* in, size_t size, Settings& s) { s = Settings{}; if (!in || size != RecordSize || in[0] != 'B' || in[1] != 'F' - || in[2] != 'R' || in[3] != 1 || in[7] || in[12] || in[13] + || in[2] != 'R' || (in[3] != 1 && in[3] != 2) || in[7] || in[12] || in[13] || checksum(in, 14) != uint16_t(in[14] | (uint16_t(in[15]) << 8))) return false; Settings candidate; candidate.enabled = in[4]; candidate.repeatBelow = in[5]; candidate.floodBelow = in[6]; candidate.emptyMv = uint16_t(in[8] | (uint16_t(in[9]) << 8)); candidate.fullMv = uint16_t(in[10] | (uint16_t(in[11]) << 8)); if (!validSettings(candidate)) return false; + // Upgrade the old default pair once; v2 preserves an explicit 10/20 choice. + if (in[3] == 1 && candidate.repeatBelow == 10 && candidate.floodBelow == 20) { + candidate.repeatBelow = 15; + candidate.floodBelow = 25; + } s = candidate; return true; } diff --git a/src/helpers/CommonCLI.cpp b/src/helpers/CommonCLI.cpp index cb84f8e54..7377de554 100644 --- a/src/helpers/CommonCLI.cpp +++ b/src/helpers/CommonCLI.cpp @@ -222,6 +222,9 @@ void CommonCLI::loop() { #if MESH_ENABLE_BATTERY_FORWARDING loopBatteryForwarding(); #endif +#if MESH_ENABLE_EMERGENCY_POWER + loopEmergencyPower(); +#endif #if MESH_ENABLE_MORSE_RX if (_morse_reply.pending()) { const ClientInfo* actor = _acl->getClient(_morse_reply.actor(), PUB_KEY_SIZE); @@ -1012,6 +1015,9 @@ static void formatSnrDbX4Short(char* dest, size_t dest_len, int16_t snr_x4) { void CommonCLI::loadPrefs(FILESYSTEM* fs) { #if MESH_ENABLE_BATTERY_FORWARDING loadBatteryForwarding(fs); +#endif +#if MESH_ENABLE_EMERGENCY_POWER + loadEmergencyPower(fs); #endif mesh::hilStartupTrace("prefs_profiles_begin"); _radio_profiles.begin(fs, _callbacks->getProfileRadio(), _rtc, true); @@ -3563,6 +3569,9 @@ void CommonCLI::handleCommand(uint32_t sender_timestamp, char* command, char* re #if MESH_ENABLE_BATTERY_FORWARDING if (handleBatteryForwardingCommand(command, reply)) return; #endif +#if MESH_ENABLE_EMERGENCY_POWER + if (handleEmergencyPowerCommand(command, reply)) return; +#endif #if defined(WITH_UDP_BRIDGE) if (handleUDPBridgeCommand(sender_timestamp, command, reply)) return; #endif diff --git a/src/helpers/CommonCLI.h b/src/helpers/CommonCLI.h index e9b73c346..8fcbdc448 100644 --- a/src/helpers/CommonCLI.h +++ b/src/helpers/CommonCLI.h @@ -19,6 +19,8 @@ #include #include #include +#include +#include #if defined(NRF52_PLATFORM) && defined(OTA_SD_STORE) #include #endif @@ -91,8 +93,8 @@ static constexpr uint8_t DEFAULT_ADVERT_LOC_POLICY = ADVERT_LOC_PREFS; #define FLOOD_RETRY_ADVERT_DEFAULT 0 #define FLOOD_RETRY_GROUP_MAX_PATH_DEFAULT 1 -#define BATTERY_ALERT_LOW_PERCENT_DEFAULT 20 -#define BATTERY_ALERT_CRITICAL_PERCENT_DEFAULT 10 +#define BATTERY_ALERT_LOW_PERCENT_DEFAULT 25 +#define BATTERY_ALERT_CRITICAL_PERCENT_DEFAULT 15 #ifndef FLOOD_RETRY_PREFIX_SLOTS #define FLOOD_RETRY_PREFIX_SLOTS 8 @@ -938,6 +940,15 @@ class CommonCLI { void loadBatteryForwarding(FILESYSTEM*) {} void loopBatteryForwarding(bool = false) const {} bool handleBatteryForwardingCommand(const char*, char*) { return false; } +#endif +#if MESH_ENABLE_EMERGENCY_POWER + FILESYSTEM* _emergency_power_fs = nullptr; + mesh::emergencypower::State _emergency_power; + bool _emergency_radio_sleeping = false; + void loadEmergencyPower(FILESYSTEM* fs); + bool saveEmergencyPower(const mesh::emergencypower::Settings& settings); + void loopEmergencyPower(); + bool handleEmergencyPowerCommand(const char* command, char* reply); #endif uint16_t batteryEmptyMv() const; uint16_t batteryFullMv() const; @@ -1110,6 +1121,15 @@ public: #else bool batteryAllowsForward(bool) const { return true; } bool batteryAllowsTransmit(const mesh::Packet*) const { return true; } +#endif +#if MESH_ENABLE_EMERGENCY_POWER + uint32_t emergencySleepMilliseconds(); + bool emergencyActive() const { return _emergency_power.active(); } + void setEmergencyRadioSleeping(bool sleeping) { _emergency_radio_sleeping = sleeping; } +#else + uint32_t emergencySleepMilliseconds() { return 0; } + bool emergencyActive() const { return false; } + void setEmergencyRadioSleeping(bool) {} #endif uint8_t batteryPercent(uint16_t mv) const; bool hasActiveUserGpioTimer() const; diff --git a/src/helpers/CommonCLI_Emergency.cpp b/src/helpers/CommonCLI_Emergency.cpp new file mode 100644 index 000000000..74b1bd407 --- /dev/null +++ b/src/helpers/CommonCLI_Emergency.cpp @@ -0,0 +1,146 @@ +#include "CommonCLI.h" + +#if MESH_ENABLE_EMERGENCY_POWER +#include "CheckedSettingsFile.h" +#if defined(NRF52_PLATFORM) || defined(STM32_PLATFORM) +#include "AtomicFileWriter.h" +#else +#include "ContactFileTransaction.h" +#endif + +namespace { +const char* const EMERGENCY_POWER_PATH = "/power_emergency"; + +bool readEmergencyPower(FILESYSTEM* fs, const char* path, + mesh::emergencypower::Settings& settings) { + uint8_t bytes[mesh::emergencypower::RecordSize]; + return mesh::storage::readFixedSettingsFile(fs, path, bytes, sizeof(bytes)) + && mesh::emergencypower::decode(bytes, sizeof(bytes), settings); +} + +bool emergencyCommand(const char* command, const char* prefix, const char*& value) { + const size_t length = strlen(prefix); + if (strncmp(command, prefix, length) != 0 + || (command[length] && command[length] != ' ' && command[length] != '\t')) return false; + value = command + length; + while (*value == ' ' || *value == '\t') ++value; + return true; +} + +bool emergencyValue(const char* value, const char* expected) { + const size_t length = strlen(expected); + if (strncmp(value, expected, length) != 0) return false; + value += length; + while (*value == ' ' || *value == '\t') ++value; + return !*value; +} +} + +void CommonCLI::loadEmergencyPower(FILESYSTEM* fs) { + _emergency_power_fs = fs; + mesh::emergencypower::Settings loaded; +#if defined(ESP32_PLATFORM) || defined(RP2040_PLATFORM) + if (!mesh::ContactFileTransaction::recover(fs, EMERGENCY_POWER_PATH)) + _emergency_power_fs = nullptr; + else +#endif + if (!readEmergencyPower(fs, EMERGENCY_POWER_PATH, loaded)) + readEmergencyPower(fs, "/power_emergency.bak", loaded); + _emergency_power.begin(loaded, millis()); + _emergency_radio_sleeping = false; +} + +bool CommonCLI::saveEmergencyPower(const mesh::emergencypower::Settings& settings) { + if (!_emergency_power_fs) return false; + uint8_t bytes[mesh::emergencypower::RecordSize]; + mesh::emergencypower::encode(bytes, settings); +#if defined(NRF52_PLATFORM) + return mesh::storage::writeCheckedSettingsFile(_emergency_power_fs, EMERGENCY_POWER_PATH, + bytes, sizeof(bytes)) == mesh::AtomicFileWriter::Outcome::Committed; +#else +#if defined(STM32_PLATFORM) + mesh::AtomicFileWriter writer(_emergency_power_fs, EMERGENCY_POWER_PATH); +#else + mesh::ContactFileTransaction writer(_emergency_power_fs, EMERGENCY_POWER_PATH); +#endif + if (!writer || writer.write(bytes, sizeof(bytes)) != sizeof(bytes) || !writer.commit()) return false; + uint8_t verified[sizeof(bytes)]; + return mesh::storage::readFixedSettingsFile(_emergency_power_fs, EMERGENCY_POWER_PATH, + verified, sizeof(verified)) && memcmp(bytes, verified, sizeof(bytes)) == 0; +#endif +} + +void CommonCLI::loopEmergencyPower() { + const uint32_t now = millis(); + if (!_emergency_power.due(now)) return; + const uint16_t mv = _board->getBattMilliVolts(); + _emergency_power.sample(now, mv, batteryPercent(mv), + mesh::batteryforward::validEndpoints(batteryEmptyMv(), batteryFullMv())); +} + +uint32_t CommonCLI::emergencySleepMilliseconds() { + loopEmergencyPower(); + return _emergency_power.sleepMilliseconds(millis(), _rtc->getCurrentTime()); +} + +bool CommonCLI::handleEmergencyPowerCommand(const char* command, char* reply) { + if (!command || !reply) return false; + const char* value; + if (emergencyCommand(command, "get power.emergency", value)) { + if (*value) snprintf(reply, 157, "Error: use get power.emergency"); + else { + const uint32_t sleep_ms = emergencySleepMilliseconds(); + char battery[12] = "unknown"; + if (_emergency_power.valid) + snprintf(battery, sizeof(battery), "%u%%", unsigned(_emergency_power.percentage)); + snprintf(reply, 157, "> mode=%s active=%s battery=%s clock=%s window=%s radio=%s wake=%lus hold=%lus", + _emergency_power.settings.manual ? "manual" : "auto", emergencyActive() ? "on" : "off", + battery, mesh::emergencypower::State::usesRtc(_rtc->getCurrentTime()) ? "rtc" : "uptime", + sleep_ms ? "sleep" : "awake", _emergency_radio_sleeping ? "sleep" : "awake", + static_cast((sleep_ms + 999UL) / 1000UL), + static_cast((_emergency_power.holdRemaining(millis()) + 999UL) / 1000UL)); + } + return true; + } + if (emergencyCommand(command, "set power.emergency", value)) { + const bool manual = emergencyValue(value, "on"); + if (!manual && !emergencyValue(value, "auto")) { + snprintf(reply, 157, "Error: use set power.emergency on|auto"); + return true; + } + if (manual && !_board->supportsEmergencySleep()) { + snprintf(reply, 157, "Error: emergency sleep unsupported on this board"); + return true; + } + mesh::emergencypower::Settings candidate; + candidate.manual = manual; + if (candidate.manual != _emergency_power.settings.manual && !saveEmergencyPower(candidate)) + snprintf(reply, 157, "Error: emergency settings save unconfirmed; active policy unchanged"); + else { + _emergency_power.setManual(manual, millis()); + snprintf(reply, 157, "OK - emergency %s", manual ? "on" : "auto"); + } + return true; + } + if (!emergencyCommand(command, "set power.awake", value)) return false; + uint16_t minutes = 0; + const char* p = value; + bool digits = false; + while (*p >= '0' && *p <= '9') { + digits = true; + const unsigned number = unsigned(minutes) * 10U + unsigned(*p++ - '0'); + if (number > mesh::emergencypower::MaximumAwakeMinutes) { + snprintf(reply, 157, "Error: use set power.awake 0-10080 (minutes)"); + return true; + } + minutes = uint16_t(number); + } + while (*p == ' ' || *p == '\t') ++p; + if (!digits || *p) snprintf(reply, 157, "Error: use set power.awake 0-10080 (minutes)"); + else { + _emergency_power.stayAwake(minutes, millis()); + snprintf(reply, 157, "OK - awake for %u minutes", unsigned(minutes)); + } + return true; +} +#endif // MESH_ENABLE_EMERGENCY_POWER diff --git a/src/helpers/ESP32Board.cpp b/src/helpers/ESP32Board.cpp index fd51a2cf1..b5591fc66 100644 --- a/src/helpers/ESP32Board.cpp +++ b/src/helpers/ESP32Board.cpp @@ -8,6 +8,9 @@ #include "FileRead.h" #include "UserGpioPinPolicy.h" #include +#if MESH_ENABLE_EMERGENCY_POWER +#include +#endif namespace mesh { namespace esp32_clock { @@ -1408,6 +1411,34 @@ void ESP32Board::powerOff() { enterDeepSleep(0); // Do not wakeup } +#if MESH_ENABLE_EMERGENCY_POWER +bool ESP32Board::emergencySleepMilliseconds(uint32_t millisecs) { + // The caller owns radio suspension and reversible wireless shutdown. Never + // force a live WiFi/Bluetooth stack through explicit light sleep. + if (millisecs == 0 || isRadioTestActive() || xPortInIsrContext() + || xTaskGetSchedulerState() != taskSCHEDULER_RUNNING + || !mesh::esp32::manualLightSleepReady()) return false; + if (millisecs > 10000UL) millisecs = 10000UL; + const esp_err_t watchdog_status = esp_task_wdt_status(nullptr); + const bool watched = watchdog_status == ESP_OK; + if (!watched && watchdog_status != ESP_ERR_NOT_FOUND + && watchdog_status != ESP_ERR_INVALID_STATE) return false; +#if defined(CONFIG_ESP_TASK_WDT_TIMEOUT_S) && CONFIG_ESP_TASK_WDT_TIMEOUT_S > 0 + if (watched && millisecs > uint32_t(CONFIG_ESP_TASK_WDT_TIMEOUT_S) * 500UL) + millisecs = uint32_t(CONFIG_ESP_TASK_WDT_TIMEOUT_S) * 500UL; +#endif + if (watched && esp_task_wdt_reset() != ESP_OK) return false; + if (esp_sleep_enable_timer_wakeup(uint64_t(millisecs) * 1000ULL) != ESP_OK) + return false; + // Add no radio/GPIO wake. Existing button sources remain untouched. The + // normal sleep path installs its own next timer; never leave ours armed. + const esp_err_t slept = esp_light_sleep_start(); + const esp_err_t cleared = esp_sleep_disable_wakeup_source(ESP_SLEEP_WAKEUP_TIMER); + const bool watchdog_ok = !watched || esp_task_wdt_reset() == ESP_OK; + return slept == ESP_OK && cleared == ESP_OK && watchdog_ok; +} +#endif + void ESP32Board::enterDeepSleep(uint32_t secs) { shutdownPeripherals(); diff --git a/src/helpers/ESP32Board.h b/src/helpers/ESP32Board.h index a29e79ed1..f9d9f1854 100644 --- a/src/helpers/ESP32Board.h +++ b/src/helpers/ESP32Board.h @@ -349,6 +349,10 @@ public: virtual void shutdownPeripherals(); virtual void powerOff() override; virtual void enterDeepSleep(uint32_t secs) override; +#if MESH_ENABLE_EMERGENCY_POWER + bool supportsEmergencySleep() const override { return true; } + bool emergencySleepMilliseconds(uint32_t millisecs) override; +#endif uint32_t getIRQGpio() override { #ifdef P_LORA_DIO_1 diff --git a/src/helpers/EmergencyOtaWork.h b/src/helpers/EmergencyOtaWork.h new file mode 100644 index 000000000..9ab84191c --- /dev/null +++ b/src/helpers/EmergencyOtaWork.h @@ -0,0 +1,38 @@ +#pragma once + +#include "EmergencyPowerConfig.h" +#include + +#if MESH_ENABLE_EMERGENCY_POWER +namespace mesh { namespace power { + +class EmergencyOtaWork { + mutable uint32_t served_at_ = 0, serve_hold_ms_ = 0; +public: + template + bool busy(const Context* context, uint32_t now) const { + bool receiving = false, cleanup = false; + if (context) { + const auto& manager = context->manager; + using Manager = decltype(context->manager); + const auto state = manager.fetchState(); + receiving = state == Manager::WANT_MANIFEST || state == Manager::WANT_LEAVES + || state == Manager::VERIFYING_STAGED || state == Manager::FETCHING; + if (manager.pendingServeJobs() || manager.pendingManifestJobs()) { + // Keep listening between a proof and the peer's next block request. + // Idle self-serving/catalog adverts never renew this bounded hold. + const uint32_t retry = manager.fetchRetryTimeoutMs(); + serve_hold_ms_ = retry < 30000UL ? 30000UL : retry; + served_at_ = now; + } +#if defined(OTA_SD_STORE) + cleanup = context->sdCacheFetching(); +#endif + } + if (serve_hold_ms_ && uint32_t(now - served_at_) >= serve_hold_ms_) serve_hold_ms_ = 0; + return receiving || cleanup || serve_hold_ms_ != 0; + } +}; + +}} // namespace mesh::power +#endif diff --git a/src/helpers/EmergencyPowerConfig.h b/src/helpers/EmergencyPowerConfig.h new file mode 100644 index 000000000..d20366e6d --- /dev/null +++ b/src/helpers/EmergencyPowerConfig.h @@ -0,0 +1,11 @@ +#pragma once + +// The source-role build policy enables this for supported infrastructure +// images. Companion and constrained profiles retain their existing footprint. +#ifndef MESH_ENABLE_EMERGENCY_POWER +#define MESH_ENABLE_EMERGENCY_POWER 0 +#endif + +#if MESH_ENABLE_EMERGENCY_POWER != 0 && MESH_ENABLE_EMERGENCY_POWER != 1 +#error "MESH_ENABLE_EMERGENCY_POWER must be 0 or 1" +#endif diff --git a/src/helpers/EmergencyPowerPolicy.h b/src/helpers/EmergencyPowerPolicy.h new file mode 100644 index 000000000..58bdbce70 --- /dev/null +++ b/src/helpers/EmergencyPowerPolicy.h @@ -0,0 +1,122 @@ +#pragma once + +#include +#include + +namespace mesh { namespace emergencypower { + +static constexpr uint32_t SampleIntervalMs = 30000; +static constexpr uint32_t HourMs = 3600000; +static constexpr uint32_t AwakeWindowMs = 900000; +static constexpr uint32_t ReplyHoldMs = 5000; +static constexpr uint32_t PlausibleEpoch = 1577836800; // 2020-01-01 UTC +static constexpr uint16_t MaximumAwakeMinutes = 10080; +static constexpr size_t RecordSize = 8; + +struct Settings { + bool manual = false; +}; + +inline uint16_t checksum(const uint8_t* data, size_t size) { + uint16_t crc = 0xffff; + while (size--) { + crc ^= uint16_t(*data++) << 8; + for (unsigned bit = 0; bit < 8; ++bit) + crc = (crc & 0x8000) ? uint16_t((crc << 1) ^ 0x1021) : uint16_t(crc << 1); + } + return crc; +} + +inline void encode(uint8_t* out, const Settings& settings) { + out[0] = 'P'; out[1] = 'E'; out[2] = 'M'; out[3] = 1; + out[4] = settings.manual ? 1 : 0; out[5] = 0; + const uint16_t crc = checksum(out, 6); + out[6] = uint8_t(crc); out[7] = uint8_t(crc >> 8); +} + +inline bool decode(const uint8_t* in, size_t size, Settings& settings) { + settings = Settings{}; + if (!in || size != RecordSize || in[0] != 'P' || in[1] != 'E' + || in[2] != 'M' || in[3] != 1 || in[4] > 1 || in[5] + || checksum(in, 6) != uint16_t(in[6] | uint16_t(in[7]) << 8)) return false; + settings.manual = in[4] != 0; + return true; +} + +struct State { + Settings settings; + uint32_t sampledAt = 0; + uint16_t millivolts = 0; + uint8_t percentage = 0; + bool sampled = false; + bool valid = false; + bool lowBattery = false; + uint32_t phaseMs = 0; + uint32_t lastTick = 0; + uint32_t activationStarted = 0; + uint32_t awakeStarted = 0; + uint32_t awakeDuration = 0; + bool activationHold = false; + + void begin(const Settings& loaded, uint32_t now) { + *this = State{}; + settings = loaded; + phaseMs = now % HourMs; + lastTick = now; + if (active()) holdReplies(now); + } + bool active() const { return settings.manual || lowBattery; } + bool due(uint32_t now) const { + return !sampled || uint32_t(now - sampledAt) >= SampleIntervalMs; + } + void holdReplies(uint32_t now) { + activationStarted = now; + activationHold = true; + } + void setManual(bool manual, uint32_t now) { + if (settings.manual == manual) return; + settings.manual = manual; + if (active()) holdReplies(now); + } + void sample(uint32_t now, uint16_t mv, uint8_t pct, bool known) { + const bool was_active = active(); + sampled = true; sampledAt = now; millivolts = mv; percentage = pct; + valid = known && pct <= 100 && mv >= 2000 && mv <= 20000; + // A failed reading cannot wake a node already protecting its battery. + if (valid) lowBattery = lowBattery ? pct < 7 : pct <= 5; + if (!was_active && active()) holdReplies(now); + } + bool stayAwake(uint16_t minutes, uint32_t now) { + if (minutes > MaximumAwakeMinutes) return false; + awakeStarted = now; + awakeDuration = uint32_t(minutes) * 60000UL; + return true; + } + uint32_t holdRemaining(uint32_t now) { + uint32_t remaining = 0; + if (activationHold) { + const uint32_t elapsed = uint32_t(now - activationStarted); + if (elapsed >= ReplyHoldMs) activationHold = false; + else remaining = ReplyHoldMs - elapsed; + } + if (awakeDuration) { + const uint32_t elapsed = uint32_t(now - awakeStarted); + if (elapsed >= awakeDuration) awakeDuration = 0; + else if (awakeDuration - elapsed > remaining) remaining = awakeDuration - elapsed; + } + return remaining; + } + static bool usesRtc(uint32_t epoch) { return epoch >= PlausibleEpoch; } + uint32_t sleepMilliseconds(uint32_t now, uint32_t epoch) { + // Accumulate the boot-relative phase across millis() wrap. RTC corrections + // change only the hourly window, never the duration of an awake lease. + phaseMs = (phaseMs + uint32_t(now - lastTick) % HourMs) % HourMs; + lastTick = now; + const uint32_t hold = holdRemaining(now); + if (!active() || hold) return 0; + const uint32_t phase = usesRtc(epoch) ? (epoch % 3600UL) * 1000UL : phaseMs; + return phase < AwakeWindowMs ? 0 : HourMs - phase; + } +}; + +}} // namespace mesh::emergencypower diff --git a/src/helpers/EmergencyPowerRuntime.h b/src/helpers/EmergencyPowerRuntime.h new file mode 100644 index 000000000..3a79508af --- /dev/null +++ b/src/helpers/EmergencyPowerRuntime.h @@ -0,0 +1,59 @@ +#pragma once + +#include "EmergencyPowerConfig.h" + +#if MESH_ENABLE_EMERGENCY_POWER +#include +#include "WirelessControl.h" + +namespace mesh { namespace power { + +class EmergencyPowerRuntime { + bool radio_asleep_ = false; + bool radio_was_available_ = false; +public: + template + bool service(Mesh& mesh, Radio& radio, Board& board, CLI& cli, + bool entry_busy, bool work_required = false) { + const uint32_t budget = cli.emergencySleepMilliseconds(); + if (!board.supportsEmergencySleep()) return false; + const bool active_operation = work_required || board.isOTAUpdateRunning() + || board.isRadioTestActive() || radio.isMorseTxActive() || radio.isMorseRxActive(); + if (budget == 0 || active_operation) { + if (radio_asleep_) { + if (radio_was_available_) { + if (!radio.setTrackerSleep(false)) radio.recoverRadio(true); + mesh.setRadioAvailable(true); + } + radio_asleep_ = false; + cli.setEmergencyRadioSleeping(false); + } + wireless::control().resumeAfterEmergency(); + return false; + } + if (!radio_asleep_) { + // Complete live operations and command replies before taking ownership. + // Queued future traffic cannot keep a critically low node awake forever. + if (entry_busy || mesh.hasOutbound()) return false; + if (!wireless::control().suspendForEmergency()) return false; + radio_was_available_ = mesh.isRadioAvailable(); + if (radio_was_available_) { + if (!radio.setTrackerSleep(true)) return false; + mesh.setRadioAvailable(false); + } + radio_asleep_ = true; + cli.setEmergencyRadioSleeping(true); + } + // Brief maintenance wakes retain voltage protection and watchdogs. Normal + // radio/mesh/sensor/UI work remains suspended throughout the off window. + if (!board.emergencySleepMilliseconds(budget > 10000 ? 10000 : budget)) { + // A transient SDK/driver refusal must not turn battery protection into + // a busy loop. Keep RF ownership and retry after yielding the CPU. + delay(10); + } + return true; + } +}; + +}} // namespace mesh::power +#endif diff --git a/src/helpers/NRF52Board.cpp b/src/helpers/NRF52Board.cpp index b8a24bece..ca2bb253e 100644 --- a/src/helpers/NRF52Board.cpp +++ b/src/helpers/NRF52Board.cpp @@ -312,7 +312,7 @@ float NRF52Board::getAdcMultiplier() const { return mesh::MainBoard::getAdcMultiplier(); } -#if NRF52_WATCHDOG_TIMEOUT_SECONDS > 0 +#if NRF52_WATCHDOG_TIMEOUT_SECONDS > 0 || MESH_ENABLE_EMERGENCY_POWER static void reloadWatchdogChannels() { // Ordinarily only RR0 is enabled. If a bootloader left the watchdog running // with a different reload channel, service every enabled channel. @@ -927,6 +927,32 @@ void NRF52Board::powerOff() { } } +#if MESH_ENABLE_EMERGENCY_POWER +bool NRF52Board::emergencySleepMilliseconds(uint32_t millisecs) { + // Blocking the sketch lets the existing RTOS idle/RTC machinery sleep the + // CPU. SYSTEMOFF cannot provide a timed wake on every supported nRF52 board. + if (millisecs == 0 || watchdog_disable_reset_pending || isRadioTestActive() + || __get_IPSR() != 0 || __get_PRIMASK() != 0 || __get_BASEPRI() != 0 + || __get_FAULTMASK() != 0 || nrf_nvic_state.__cr_flag != 0 + || xTaskGetSchedulerState() != taskSCHEDULER_RUNNING) return false; + if (millisecs > 10000UL) millisecs = 10000UL; + if (NRF_WDT->RUNSTATUS != 0) { + // A bootloader may have left a shorter watchdog than this build's default. + // Half its actual timeout leaves room for scheduling and wake processing. + const uint32_t watchdog_budget_ms = (NRF_WDT->CRV / 32768UL) * 500UL; + if (watchdog_budget_ms == 0) return false; + if (millisecs > watchdog_budget_ms) millisecs = watchdog_budget_ms; + } + const TickType_t ticks = pdMS_TO_TICKS(millisecs); + if (ticks == 0) return false; + // Service inherited watchdogs even when this build does not start one. + if (NRF_WDT->RUNSTATUS != 0) reloadWatchdogChannels(); + vTaskDelay(ticks); + if (NRF_WDT->RUNSTATUS != 0) reloadWatchdogChannels(); + return true; +} +#endif + void NRF52Board::enterDeepSleep(uint32_t secs) { if (secs == 0) { powerOff(); // Not wake up diff --git a/src/helpers/NRF52Board.h b/src/helpers/NRF52Board.h index 68f4a2531..f066f152f 100644 --- a/src/helpers/NRF52Board.h +++ b/src/helpers/NRF52Board.h @@ -80,6 +80,10 @@ public: virtual void shutdownPeripherals(); virtual void powerOff() override; virtual void enterDeepSleep(uint32_t secs) override; +#if MESH_ENABLE_EMERGENCY_POWER + bool supportsEmergencySleep() const override { return true; } + bool emergencySleepMilliseconds(uint32_t millisecs) override; +#endif virtual bool getBootloaderVersion(char* version, size_t max_len) override; virtual bool startOTAUpdate(const char *id, char reply[], bool force_ap = false) override; virtual bool stopOTAUpdate(char reply[]) override; diff --git a/src/helpers/WirelessControl.h b/src/helpers/WirelessControl.h index 8f8e1520e..7da2abca5 100644 --- a/src/helpers/WirelessControl.h +++ b/src/helpers/WirelessControl.h @@ -4,6 +4,7 @@ #include #include #include +#include "EmergencyPowerConfig.h" namespace mesh { namespace wireless { @@ -59,6 +60,10 @@ class Control { uint8_t scope_ = 0, target_ = 0, saved_ = 0; bool pending_ = false, started_ = false, force_ = false, independent_requester_ = false; bool saved_valid_ = false, master_off_ = false; +#if MESH_ENABLE_EMERGENCY_POWER + bool emergency_suspended_ = false; + uint8_t emergency_enabled_ = 0, emergency_blocked_ = 0; +#endif uint32_t due_ = 0; const char* error_ = nullptr; @@ -73,6 +78,9 @@ public: } bool masterOff() const { return master_off_; } bool allowService(uint8_t service) { +#if MESH_ENABLE_EMERGENCY_POWER + if (emergency_suspended_) return false; +#endif if (master_off_ || pending_) return false; blocked_.store(blocked_.load() & ~service); return true; @@ -81,6 +89,9 @@ public: // disabled 2.4 GHz for this boot. Leave the other services disabled. Do not // race a pending shutdown, which could otherwise turn OTA off afterwards. bool allowWiFiForOTA() { +#if MESH_ENABLE_EMERGENCY_POWER + if (emergency_suspended_) return false; +#endif if (pending_) return false; blocked_.store(blocked_.load() & ~WiFi); master_off_ = false; @@ -90,6 +101,40 @@ public: } uint8_t enabled() const { return backend_ ? backend_->enabled() : 0; } +#if MESH_ENABLE_EMERGENCY_POWER + // A scheduled battery sleep borrows the live services without changing the + // user's saved wireless policy or the ordinary off/on restore snapshot. + bool suspendForEmergency() { + if (!backend_) return true; + if (pending_) return false; + if (!emergency_suspended_) { + emergency_enabled_ = enabled(); + emergency_blocked_ = blocked_.load(); + emergency_suspended_ = true; + blocked_.store(emergency_blocked_ | All); + } + const uint8_t order[] = {WiFi, EspNow, Bluetooth}; + for (uint8_t service : order) { + if ((emergency_enabled_ & service) && backend_->set(service, false) != Result::Done) + return false; + } + return enabled() == 0; + } + + bool resumeAfterEmergency() { + if (!emergency_suspended_) return true; + const uint8_t order[] = {EspNow, WiFi, Bluetooth}; + for (uint8_t service : order) { + if (!(emergency_enabled_ & service)) continue; + blocked_.store(blocked_.load() & ~service); + if (backend_->set(service, true) != Result::Done) return false; + } + blocked_.store(emergency_blocked_); + emergency_suspended_ = false; + return true; + } +#endif + bool handle(const char* text, char* reply, size_t size, uint32_t now, uint8_t requester) { const auto command = parse(text); if (command.action == Action::None || !reply || !size) return false; @@ -106,6 +151,12 @@ public: else snprintf(reply, size, "wifi=%s, bluetooth=%s, espnow=%s%s%s", state(WiFi), state(Bluetooth), state(EspNow), pending_ ? " (pending)" : "", error_ ? error_ : ""); return true; } +#if MESH_ENABLE_EMERGENCY_POWER + if (emergency_suspended_) { + snprintf(reply, size, "Error: emergency sleep; use set power.awake first"); + return true; + } +#endif if (!(available & command.scope)) { snprintf(reply, size, "Error: requested wireless service unavailable in this build/boot"); return true; diff --git a/test/test_battery_forwarding.py b/test/test_battery_forwarding.py index 0b72138b1..83d266a15 100644 --- a/test/test_battery_forwarding.py +++ b/test/test_battery_forwarding.py @@ -565,17 +565,17 @@ static bool handleVoltagePolicyCommand(const char*, char* reply, size_t capacity self.exercise(r''' CLI cli; assert(validSettings(cli.settings)); - assert(cli.settings.enabled == 0 && cli.settings.repeatBelow == 10 - && cli.settings.floodBelow == 20 && cli.settings.emptyMv == 0 && cli.settings.fullMv == 0); + assert(cli.settings.enabled == 0 && cli.settings.repeatBelow == 15 + && cli.settings.floodBelow == 25 && cli.settings.emptyMv == 0 && cli.settings.fullMv == 0); cli.state.update(1, 3000, 0, true, cli.settings); assert(cli.state.allows(false) && cli.state.allows(true)); - assert(cli.run("get repeat.battery") && strcmp(cli.reply, "> off below=10%") == 0); - assert(cli.run("get flood.battery") && strcmp(cli.reply, "> off below=20%") == 0); + assert(cli.run("get repeat.battery") && strcmp(cli.reply, "> off below=15%") == 0); + assert(cli.run("get flood.battery") && strcmp(cli.reply, "> off below=25%") == 0); assert(cli.saveCalls == 0); for (unsigned threshold = 1; threshold <= 100; ++threshold) { const std::string command = "set repeat.battery " + std::to_string(threshold); cli.ok(command.c_str()); - assert(cli.settings.repeatBelow == threshold && cli.settings.floodBelow == 20); + assert(cli.settings.repeatBelow == threshold && cli.settings.floodBelow == 25); assert(cli.settings.enabled == Repeat); } cli.ok("set repeat.battery 13"); @@ -683,21 +683,21 @@ static bool handleVoltagePolicyCommand(const char*, char* reply, size_t capacity Settings settings; settings.enabled = Repeat | Flood; State state; - state.update(1, 3240, 20, true, settings); + state.update(1, 3300, 25, true, settings); assert(state.allows(false) && state.allows(true)); - state.update(2, 3228, 19, true, settings); + state.update(2, 3288, 24, true, settings); assert(state.allows(false) && !state.allows(true)); - state.update(3, 3120, 10, true, settings); + state.update(3, 3180, 15, true, settings); assert(state.allows(false) && !state.allows(true)); - state.update(4, 3108, 9, true, settings); + state.update(4, 3168, 14, true, settings); assert(!state.allows(false) && !state.allows(true)); - state.update(5, 3132, 11, true, settings); + state.update(5, 3192, 16, true, settings); assert(!state.allows(false) && !state.allows(true)); - state.update(6, 3144, 12, true, settings); + state.update(6, 3204, 17, true, settings); assert(state.allows(false) && !state.allows(true)); - state.update(7, 3252, 21, true, settings); + state.update(7, 3312, 26, true, settings); assert(state.allows(false) && !state.allows(true)); - state.update(8, 3264, 22, true, settings); + state.update(8, 3324, 27, true, settings); assert(state.allows(false) && state.allows(true)); ''') @@ -838,6 +838,64 @@ static bool handleVoltagePolicyCommand(const char*, char* reply, size_t capacity assert(decode(bytes, RecordSize, actual) && same(actual, Settings{})); ''') + def test_v1_default_pair_migrates_once_and_v2_custom_legacy_pair_is_preserved(self): + self.exercise(r''' + auto encodeVersion = [&](uint8_t* bytes, const Settings& settings, uint8_t version) { + encode(bytes, settings); + bytes[3] = version; + const uint16_t crc = checksum(bytes, 14); + bytes[14] = uint8_t(crc); bytes[15] = uint8_t(crc >> 8); + }; + for (uint8_t enabled = 0; enabled <= (Repeat | Flood); ++enabled) { + Settings saved; + saved.enabled = enabled; + saved.emptyMv = 6000; saved.fullMv = 8400; + saved.repeatBelow = 10; saved.floodBelow = 20; + uint8_t bytes[RecordSize]; encodeVersion(bytes, saved, 1); + Settings loaded; + assert(decode(bytes, sizeof(bytes), loaded)); + assert(loaded.repeatBelow == 15 && loaded.floodBelow == 25); + assert(loaded.enabled == enabled && loaded.emptyMv == 6000 && loaded.fullMv == 8400); + encode(bytes, saved); + assert(bytes[3] == 2 && decode(bytes, sizeof(bytes), loaded) && same(saved, loaded)); + encodeVersion(bytes, saved, 1); + bytes[3] = 2; // Version is covered by the record checksum. + assert(!decode(bytes, sizeof(bytes), loaded)); + for (uint8_t version : {uint8_t(0), uint8_t(3), uint8_t(255)}) { + encodeVersion(bytes, saved, version); + assert(!decode(bytes, sizeof(bytes), loaded) && same(loaded, Settings{})); + } + } + for (unsigned repeat = 1; repeat <= 100; ++repeat) { + for (unsigned flood = 1; flood <= 100; ++flood) { + Settings saved; + saved.enabled = Repeat | Flood; + saved.repeatBelow = uint8_t(repeat); saved.floodBelow = uint8_t(flood); + uint8_t bytes[RecordSize]; encodeVersion(bytes, saved, 1); + Settings loaded; + assert(decode(bytes, sizeof(bytes), loaded)); + if (repeat == 10 && flood == 20) { + assert(loaded.repeatBelow == 15 && loaded.floodBelow == 25); + } else assert(same(saved, loaded)); + encode(bytes, saved); + assert(decode(bytes, sizeof(bytes), loaded) && same(saved, loaded)); + } + } + Settings custom; + custom.enabled = Repeat | Flood; + custom.repeatBelow = 10; custom.floodBelow = 20; + uint8_t bytes[RecordSize]; encode(bytes, custom); + Settings loaded; + assert(decode(bytes, sizeof(bytes), loaded) && same(custom, loaded)); + State state; + state.update(0, 3240, 20, true, loaded); + assert(state.allows(false) && state.allows(true)); + state.update(1, 3120, 10, true, loaded); + assert(state.allows(false) && !state.allows(true)); + state.update(2, 3108, 9, true, loaded); + assert(!state.allows(false) && !state.allows(true)); +''') + if __name__ == "__main__": unittest.main() diff --git a/test/test_client_acl_role.py b/test/test_client_acl_role.py index 46be48bdc..e660a4a7c 100644 --- a/test/test_client_acl_role.py +++ b/test/test_client_acl_role.py @@ -19,6 +19,27 @@ FIXTURE = ROOT / "test/fixtures/client_acl_spiffs" class ClientAclRoleTests(unittest.TestCase): + def test_emergency_sleep_is_scoped_to_supported_infrastructure(self): + definition = POLICY.EMERGENCY_DEFINITION + for role in ("simple_repeater", "simple_room_server", "simple_sensor"): + source = "+ +<../examples/" + role + "/*.cpp>" + for platform in ("NRF52_PLATFORM", "ESP32_PLATFORM"): + flags = "-D" + platform + self.assertEqual(POLICY.emergency_power_policy(source, flags), {definition: 1}) + self.assertEqual(POLICY.emergency_power_policy( + source, flags + " -DMESH_ENABLE_BATTERY_FORWARDING=0"), {}) + self.assertEqual(POLICY.emergency_power_policy( + source, flags + " -D" + definition + "=0"), {}) + self.assertEqual(POLICY.emergency_power_policy(source, "-DSTM32_PLATFORM"), {}) + self.assertEqual(POLICY.emergency_power_policy( + source + " +<../examples/companion_radio/*.cpp>", "-DNRF52_PLATFORM"), {}) + for source in ("", "+<*>", "+<../examples/custom_role/*.cpp>", + "+<../examples/companion_radio/*.cpp>", + "+<../examples/simple_repeater/main.cpp>"): + self.assertEqual(POLICY.emergency_power_policy(source, "-DNRF52_PLATFORM"), {}) + with self.assertRaises(ValueError): + POLICY.emergency_power_policy("", "-D" + definition + "=2") + def test_complete_exclusive_companion_role_uses_one_global_definition(self): definition = POLICY.COMPANION_DEFINITION for role in ("+<../examples/companion_radio>", diff --git a/test/test_emergency_power.py b/test/test_emergency_power.py new file mode 100644 index 000000000..bf74e3d12 --- /dev/null +++ b/test/test_emergency_power.py @@ -0,0 +1,386 @@ +"""Exercise the emergency scheduler and actual CommonCLI boundary without hardware.""" +from pathlib import Path +import os +import re +import subprocess +import tempfile +import unittest + +from test_replay_reset_integration import extract_braced + + +ROOT = Path(__file__).resolve().parents[1] +POLICY_PREFIX = r''' +#include +#include +#include +#include +#include +using namespace mesh::emergencypower; +static constexpr uint32_t epoch = PlausibleEpoch; +''' + + +class EmergencyPowerTests(unittest.TestCase): + def exercise(self, body, prefix=POLICY_PREFIX, flags=()): + with tempfile.TemporaryDirectory(prefix="emergency-power-") as folder: + work = Path(folder) + source, binary = work / "main.cpp", work / "check" + source.write_text(prefix + "\nint main() {\n" + body + "\n}\n", encoding="ascii") + command = [os.environ.get("CXX", "g++"), "-std=c++17", "-O1", "-g", + "-Wall", "-Wextra", "-Werror", "-Wno-unused-function", + "-fsanitize=address,undefined", "-fno-sanitize-recover=all", + "-fno-omit-frame-pointer", "-fno-pie", "-no-pie", *flags, + "-I", str(ROOT / "src"), str(source), "-o", str(binary)] + built = subprocess.run(command, capture_output=True, text=True, timeout=60) + self.assertEqual(built.returncode, 0, built.stdout + built.stderr) + result = subprocess.run([str(binary)], capture_output=True, text=True, timeout=30) + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + + def test_exact_low_boundary_hysteresis_invalid_readings_and_reply_hold(self): + self.exercise(r''' + for (unsigned pct = 0; pct <= 100; ++pct) { + State state; + state.begin(Settings{}, 100); + assert(!state.active() && state.due(100)); + state.sample(100, 3000, uint8_t(pct), true); + assert(state.active() == (pct <= 5)); + assert(!state.due(30099) && state.due(30100)); + } + State state; + state.begin(Settings{}, 0); + state.sample(0, 3060, 5, true); + assert(state.active() && state.holdRemaining(0) == 5000); + assert(state.sleepMilliseconds(4999, epoch + 1800) == 0); + assert(state.sleepMilliseconds(5000, epoch + 1800) == 1800000); + state.sample(5001, 3072, 6, true); + assert(state.active() && state.sleepMilliseconds(5001, epoch + 1800) == 1800000); + for (uint16_t mv : {uint16_t(0), uint16_t(1999), uint16_t(20001), uint16_t(65535)}) { + state.sample(5002, mv, 100, true); + assert(!state.valid && state.active()); + } + state.sample(5003, 4200, 255, true); + assert(!state.valid && state.active()); + state.sample(5004, 4200, 100, false); + assert(!state.valid && state.active()); + state.sample(5005, 3084, 7, true); + assert(!state.active() && state.sleepMilliseconds(5005, epoch + 1800) == 0); + state.sample(5006, 0, 0, true); + assert(!state.active()); + state.sample(5007, 3000, 0, false); + assert(!state.active()); + state.sample(5008, 3060, 5, true); + assert(state.active() && state.holdRemaining(5008) == 5000); + state.sample(5009, 3060, 5, true); + assert(state.holdRemaining(5009) == 4999); // Sampling never renews an existing activation. +''') + + def test_hour_boundaries_clock_corrections_and_boot_relative_wrap(self): + self.exercise(r''' + Settings manual; manual.manual = true; + State state; + state.begin(manual, 0); + assert(state.sleepMilliseconds(5000, epoch + 899) == 0); + assert(state.sleepMilliseconds(5001, epoch + 900) == 2700000); + assert(state.sleepMilliseconds(5002, epoch + 3599) == 1000); + assert(state.sleepMilliseconds(5003, epoch + 3600) == 0); + assert(state.sleepMilliseconds(5004, epoch + 7200 + 1800) == 1800000); + assert(state.sleepMilliseconds(5005, epoch + 600) == 0); // RTC rollback selects the real hour. + assert(state.sleepMilliseconds(900000, 0) == 2700000); + assert(state.sleepMilliseconds(3599999, PlausibleEpoch - 1) == 1); + assert(state.sleepMilliseconds(3600000, 0) == 0); + assert(State::usesRtc(epoch) && !State::usesRtc(epoch - 1)); + + const uint32_t boot = 0xfffff000U; + state.begin(manual, boot); + uint32_t now = boot; + uint32_t expected = boot % HourMs; + for (uint32_t delta : {uint32_t(5000), uint32_t(899999), uint32_t(3600000), + uint32_t(0xf0000000), uint32_t(0x10000100)}) { + now += delta; + expected = (expected + delta % HourMs) % HourMs; + const uint32_t want = expected < AwakeWindowMs ? 0 : HourMs - expected; + assert(state.sleepMilliseconds(now, 0) == want); + assert(state.phaseMs == expected); + } +''') + + def test_manual_transitions_awake_lease_renew_cancel_limits_and_wrap(self): + self.exercise(r''' + State state; + state.begin(Settings{}, 0); + state.setManual(true, 100); + assert(state.active() && state.holdRemaining(100) == 5000); + state.setManual(true, 101); + assert(state.holdRemaining(101) == 4999); + assert(state.sleepMilliseconds(5100, epoch + 1800) == 1800000); + assert(state.stayAwake(1, 5101)); + assert(state.sleepMilliseconds(65100, epoch + 3599) == 0); + assert(state.sleepMilliseconds(65101, epoch + 1800) == 1800000); + assert(!state.awakeDuration); + assert(state.sleepMilliseconds(5101, epoch + 1800) == 1800000); // Expired lease stays expired. + assert(state.stayAwake(10080, 0xfffffff0U)); + assert(state.holdRemaining(0xfffffff0U) == 604800000); + assert(!state.stayAwake(10081, 100) && state.awakeDuration == 604800000); + assert(state.sleepMilliseconds(uint32_t(0xfffffff0U + 604799999U), epoch + 1800) == 0); + assert(state.sleepMilliseconds(uint32_t(0xfffffff0U + 604800000U), epoch + 1800) == 1800000); + assert(state.stayAwake(2, 200)); + assert(state.stayAwake(1, 300)); + assert(state.holdRemaining(400) == 59900); + assert(state.stayAwake(0, 400) && state.holdRemaining(400) == 0); + state.sample(401, 3060, 5, true); + state.setManual(false, 402); // Auto still protects the low battery, with time to send this reply. + assert(state.active() && state.holdRemaining(402) == 5000); + state.sample(403, 3084, 7, true); + assert(!state.active()); + state.setManual(true, 404); + state.sample(405, 4200, 100, true); + assert(state.active()); + state.setManual(false, 406); + assert(!state.active()); +''') + + def test_checked_record_roundtrip_all_bit_flips_lengths_and_semantics(self): + self.exercise(r''' + for (bool manual : {false, true}) { + Settings expected; expected.manual = manual; + uint8_t bytes[RecordSize]; encode(bytes, expected); + Settings actual; + assert(decode(bytes, sizeof(bytes), actual) && actual.manual == manual); + for (size_t n = 0; n < RecordSize; ++n) { + for (unsigned bit = 0; bit < 8; ++bit) { + uint8_t corrupted[RecordSize]; memcpy(corrupted, bytes, sizeof(bytes)); + corrupted[n] ^= uint8_t(1U << bit); + actual.manual = true; + assert(!decode(corrupted, sizeof(corrupted), actual) && !actual.manual); + } + assert(!decode(bytes, n, actual) && !actual.manual); + } + std::vector oversized(bytes, bytes + RecordSize); oversized.push_back(0); + assert(!decode(oversized.data(), oversized.size(), actual)); + assert(!decode(nullptr, RecordSize, actual)); + for (size_t n : {size_t(3), size_t(4), size_t(5)}) { + uint8_t invalid[RecordSize]; memcpy(invalid, bytes, sizeof(bytes)); invalid[n] = 2; + const uint16_t crc = checksum(invalid, 6); + invalid[6] = uint8_t(crc); invalid[7] = uint8_t(crc >> 8); + assert(!decode(invalid, sizeof(invalid), actual) && !actual.manual); + } + } +''') + + def runtime_prefix(self): + header = (ROOT / "src/helpers/CommonCLI.h").read_text(encoding="ascii") + def api_block(marker): + start = header.index(marker) + return header[start:header.index("#endif", start) + len("#endif")] + private = api_block("#if MESH_ENABLE_EMERGENCY_POWER\n FILESYSTEM* _emergency_power_fs") + public = api_block("#if MESH_ENABLE_EMERGENCY_POWER\n uint32_t emergencySleepMilliseconds") + implementation = (ROOT / "src/helpers/CommonCLI_Emergency.cpp").read_text(encoding="ascii") + implementation = re.sub(r"^#include[^\n]*\n", "", implementation, flags=re.MULTILINE) + battery = (ROOT / "src/helpers/CommonCLI_Battery.cpp").read_text(encoding="ascii") + battery_methods = "\n".join(extract_braced(battery, signature) for signature in ( + "uint16_t CommonCLI::batteryEmptyMv(", "uint16_t CommonCLI::batteryFullMv(", + "uint8_t CommonCLI::batteryPercent(")) + checked = (ROOT / "src/helpers/CheckedSettingsFile.h").read_text(encoding="ascii") + checked_write = extract_braced(checked, "MESH_SETTINGS_FILE_SHARED inline AtomicFileWriter::Outcome writeCheckedSettingsFile(") + prefix = POLICY_PREFIX + r''' +#include +#include +#include +#include +static uint32_t nowMs; +static uint32_t millis() { return nowMs; } +struct FILESYSTEM { + std::map> files; + bool shortWrite = false, failCommit = false, badRead = false, corruptCommit = false; + bool recoverOk = true; + unsigned commits = 0, writes = 0; +}; +namespace mesh { +class AtomicFileWriter { + FILESYSTEM* fs; + std::string path; + std::vector bytes; +public: + enum class Outcome { NotCommitted, Committed, Indeterminate }; + AtomicFileWriter(FILESYSTEM* f, const char* p) : fs(f), path(p) {} + explicit operator bool() const { return fs != nullptr; } + size_t write(const uint8_t* p, size_t n) { + ++fs->writes; + const size_t written = fs->shortWrite ? n - 1 : n; + bytes.assign(p, p + written); return written; + } + bool commit() { + ++fs->commits; + if (fs->failCommit) return false; + fs->files[path] = bytes; + if (fs->corruptCommit) fs->files[path][0] ^= 1; + return true; + } + Outcome outcome() const { return Outcome::NotCommitted; } +}; +class ContactFileTransaction : public AtomicFileWriter { +public: + using AtomicFileWriter::AtomicFileWriter; + static bool recover(FILESYSTEM* fs, const char*) { return fs && fs->recoverOk; } +}; +namespace storage { +static bool readFixedSettingsFile(FILESYSTEM* fs, const char* path, uint8_t* bytes, size_t n) { + if (!fs || fs->badRead) return false; + auto found = fs->files.find(path); + if (found == fs->files.end() || found->second.size() != n) return false; + memcpy(bytes, found->second.data(), n); return true; +} +#define MESH_SETTINGS_FILE_SHARED +''' + checked_write + r''' +#undef MESH_SETTINGS_FILE_SHARED +}} +struct Board { + uint16_t mv = 4200; + unsigned reads = 0; + bool emergencySupported = true; + uint16_t getBattMilliVolts() { ++reads; return mv; } + bool supportsEmergencySleep() const { return emergencySupported; } +}; +struct Clock { + uint32_t time = epoch + 1800; + uint32_t getCurrentTime() { return time; } +}; +class CommonCLI { +public: + Board* _board; + Clock* _rtc; + mesh::batteryforward::Settings _battery_forward_settings; +''' + private + "\n" + public + r''' + uint16_t batteryEmptyMv() const; + uint16_t batteryFullMv() const; + uint8_t batteryPercent(uint16_t) const; + CommonCLI(Board& board, Clock& rtc) : _board(&board), _rtc(&rtc) {} +}; +''' + battery_methods + "\n" + implementation + return prefix + + def test_actual_cli_sampling_independent_status_and_runtime_lease(self): + self.exercise(r''' + Board board; Clock clock; FILESYSTEM fs; + CommonCLI cli(board, clock); + nowMs = 0; board.mv = 3060; + cli.loadEmergencyPower(&fs); + assert(cli._battery_forward_settings.enabled == 0); + assert(cli.emergencySleepMilliseconds() == 0 && cli.emergencyActive() && board.reads == 1); + nowMs = 5000; + assert(cli.emergencySleepMilliseconds() == 1800000 && board.reads == 1); + char reply[160] = {}; + cli.setEmergencyRadioSleeping(false); + assert(cli.handleEmergencyPowerCommand("get power.emergency", reply)); + assert(strstr(reply, "mode=auto active=on battery=5% clock=rtc window=sleep radio=awake")); + assert(strstr(reply, "wake=1800s hold=0s")); + cli.setEmergencyRadioSleeping(true); + assert(cli.handleEmergencyPowerCommand("get power.emergency \t", reply)); + assert(strstr(reply, "radio=sleep")); + nowMs = 5001; + assert(cli.handleEmergencyPowerCommand("get power.emergency", reply)); + assert(cli._emergency_power.holdRemaining(nowMs) == 0); // Polls do not postpone sleep. + assert(cli.handleEmergencyPowerCommand("set power.awake 1 \t", reply)); + assert(!strcmp(reply, "OK - awake for 1 minutes") && fs.writes == 0); + assert(cli.emergencySleepMilliseconds() == 0); + nowMs = 65000; + assert(cli.emergencySleepMilliseconds() == 0); + nowMs = 65001; + assert(cli.emergencySleepMilliseconds() == 1800000); + board.mv = 0; nowMs = 95001; + assert(cli.emergencySleepMilliseconds() == 1800000 && cli.emergencyActive()); + clock.time = 0; + assert(cli.handleEmergencyPowerCommand("get power.emergency", reply)); + assert(strstr(reply, "battery=unknown clock=uptime")); + board.mv = 3084; nowMs += 30000; + assert(cli.emergencySleepMilliseconds() == 0 && !cli.emergencyActive()); + cli._battery_forward_settings.emptyMv = 6000; + cli._battery_forward_settings.fullMv = 8400; + board.mv = 6120; nowMs += 30000; + assert(cli.emergencySleepMilliseconds() == 0 && cli.emergencyActive()); + assert(cli._emergency_power.percentage == 5); + assert(cli.handleEmergencyPowerCommand("set power.awake 10080", reply)); + assert(cli._emergency_power.awakeDuration == 604800000); + assert(cli.handleEmergencyPowerCommand("set power.awake 0", reply)); + assert(!cli._emergency_power.awakeDuration); +''', self.runtime_prefix(), flags=("-DESP32_PLATFORM", "-DMESH_ENABLE_EMERGENCY_POWER=1")) + + def test_actual_cli_checked_save_reboot_fallback_and_failure_keeps_candidate_inactive(self): + body = r''' + Board board; Clock clock; FILESYSTEM fs; + nowMs = 100; + CommonCLI cli(board, clock); cli.loadEmergencyPower(&fs); + char reply[160]; + assert(cli.handleEmergencyPowerCommand("set power.emergency on", reply)); + assert(!strcmp(reply, "OK - emergency on") && cli.emergencyActive()); + assert(fs.commits == 1 && cli._emergency_power.settings.manual); + nowMs += 5000; + assert(cli.emergencySleepMilliseconds() == 1800000); + assert(cli.handleEmergencyPowerCommand("set power.emergency on", reply)); + assert(fs.commits == 1 && cli._emergency_power.holdRemaining(nowMs) == 0); + CommonCLI reboot(board, clock); reboot.loadEmergencyPower(&fs); + assert(reboot.emergencyActive() && reboot.emergencySleepMilliseconds() == 0); + assert(!reboot._emergency_power.awakeDuration); // Awake lease is deliberately runtime-only. + fs.files["/power_emergency.bak"] = fs.files["/power_emergency"]; + fs.files["/power_emergency"][0] ^= 1; + CommonCLI backup(board, clock); backup.loadEmergencyPower(&fs); + assert(backup.emergencyActive()); + fs.files["/power_emergency.bak"][0] ^= 1; + CommonCLI corrupt(board, clock); corrupt.loadEmergencyPower(&fs); + assert(!corrupt.emergencyActive()); + fs.files.clear(); + for (unsigned failure = 0; failure < 4; ++failure) { + fs.shortWrite = failure == 0; fs.failCommit = failure == 1; + fs.badRead = failure == 2; fs.corruptCommit = failure == 3; + assert(cli.handleEmergencyPowerCommand("set power.emergency auto", reply)); + assert(strstr(reply, "save unconfirmed") && cli._emergency_power.settings.manual); + assert(cli._emergency_power.holdRemaining(nowMs) == 0); + } + fs.shortWrite = fs.failCommit = fs.badRead = fs.corruptCommit = false; + assert(cli.handleEmergencyPowerCommand("set power.emergency auto\t", reply)); + assert(!strcmp(reply, "OK - emergency auto") && !cli.emergencyActive()); + CommonCLI fresh(board, clock); fresh.loadEmergencyPower(&fs); + assert(!fresh._emergency_power.settings.manual); + unsigned writes = fs.writes; + board.emergencySupported = false; + assert(fresh.handleEmergencyPowerCommand("set power.emergency on", reply)); + assert(strstr(reply, "unsupported") && !fresh.emergencyActive() && fs.writes == writes); + board.emergencySupported = true; + for (const char* command : {"set power.emergency off", "set power.emergency", "set power.emergency on extra", + "get power.emergency x", "set power.awake", "set power.awake -1", "set power.awake +1", + "set power.awake 10081", "set power.awake 99999999999999", "set power.awake 1.0", + "set power.awake 1 2", "set power.awake 1min", "set power.awake \n"}) { + assert(fresh.handleEmergencyPowerCommand(command, reply)); + assert(!strncmp(reply, "Error:", 6) && fs.writes == writes && !fresh.emergencyActive()); + } + for (const char* command : {"get power.emergencyx", "set power.emergencyx on", "set power.awakex 1", + "get power.awake", "set unrelated on", ""}) { + strcpy(reply, "unchanged"); + assert(!fresh.handleEmergencyPowerCommand(command, reply) && !strcmp(reply, "unchanged")); + } + assert(!fresh.handleEmergencyPowerCommand(nullptr, reply)); + assert(!fresh.handleEmergencyPowerCommand("get power.emergency", nullptr)); +''' + for platform in ("ESP32_PLATFORM", "NRF52_PLATFORM", "STM32_PLATFORM"): + with self.subTest(platform=platform): + self.exercise(body, self.runtime_prefix(), flags=("-D" + platform, + "-DMESH_ENABLE_EMERGENCY_POWER=1")) + + def test_disabled_actual_declarations_and_source_retain_no_state_or_io(self): + prefix = self.runtime_prefix() + # A missing implementation at link time would reveal a retained runtime + # call; the public disabled API is entirely inline and stateless. + self.exercise(r''' + Board board; Clock clock; FILESYSTEM fs; + CommonCLI cli(board, clock); + cli.setEmergencyRadioSleeping(true); + assert(!cli.emergencyActive() && cli.emergencySleepMilliseconds() == 0); + assert(board.reads == 0 && fs.writes == 0); + static_assert(sizeof(CommonCLI) == sizeof(Board*) + sizeof(Clock*) + 8, + "disabled emergency policy must not retain its scheduler or filesystem"); +''', prefix, flags=("-DSTM32_PLATFORM", "-DMESH_ENABLE_EMERGENCY_POWER=0")) + + +if __name__ == "__main__": + unittest.main() diff --git a/test/test_emergency_power_ota.py b/test/test_emergency_power_ota.py new file mode 100644 index 000000000..a2f5f0bdb --- /dev/null +++ b/test/test_emergency_power_ota.py @@ -0,0 +1,155 @@ +"""Exercise the real role guards and OTA work hold without allocating OTA storage.""" +from pathlib import Path +import os +import subprocess +import tempfile +import unittest + +from test_replay_reset_integration import extract_braced + + +ROOT = Path(__file__).resolve().parents[1] + + +class EmergencyOtaTests(unittest.TestCase): + def test_actual_three_role_guards_active_transfers_and_bounded_server_hold(self): + enum_source = (ROOT / "src/helpers/ota/OtaManager.h").read_text(encoding="ascii") + enum = extract_braced(enum_source, "enum FetchState : uint8_t") + ";" + prefix = r''' +#include +#include +#include +#include +static uint32_t nowMs; +static uint32_t millis() { return nowMs; } +namespace mesh { namespace ota { +struct Manager { + @ENUM@ + FetchState state = IDLE; + uint8_t serveJobs = 0, manifestJobs = 0; + uint32_t retryMs = 5000; + FetchState fetchState() const { return state; } + uint8_t pendingServeJobs() const { return serveJobs; } + uint8_t pendingManifestJobs() const { return manifestJobs; } + uint32_t fetchRetryTimeoutMs() const { return retryMs; } +}; +struct Context { + Manager manager; + bool sdFetching = false; + bool serving = true; + uint32_t stagedSize = 100000; + bool sdCacheFetching() const { return sdFetching; } +}; +static Context* active = nullptr; +static Context* ota_context_if_active() { return active; } +Context& ota_ctx() = delete; // Merely checking power must never allocate a dynamic context. +}} +struct CLI { + bool radio = false, gpio = false; + bool hasPendingRadioSettings() const { return radio; } + bool hasActiveUserGpioTimer() const { return gpio; } +}; +struct Peer { + bool waiting = false; + bool awaitingPeer() const { return waiting; } +}; +'''.replace("@ENUM@", enum) + for index, path in enumerate(("examples/simple_repeater/MyMesh.h", + "examples/simple_room_server/MyMesh.h", + "examples/simple_sensor/SensorMesh.h")): + method = extract_braced((ROOT / path).read_text(encoding="ascii"), + "bool emergencyPowerBusy() const") + prefix += r''' +struct Role@INDEX@ { + mesh::power::EmergencyOtaWork _emergency_ota_work; + CLI _cli; + Peer direct_ota_admin; + bool apply = false, saved_radio_apply_pending = false, temp_radio_applied = false; + uint32_t dirty_contacts_expiry = 0, _ota_update_at = 0; + bool hasPendingOtaApply() const { return apply; } + @METHOD@ +}; +'''.replace("@INDEX@", str(index)).replace("@METHOD@", method) + prefix += r''' +template void exercise() { + using namespace mesh::ota; + Role role; + active = nullptr; nowMs = 0; + assert(!role.emergencyPowerBusy()); + role.apply = true; assert(role.emergencyPowerBusy()); role.apply = false; + role._cli.radio = true; assert(role.emergencyPowerBusy()); role._cli.radio = false; + role._cli.gpio = true; assert(role.emergencyPowerBusy()); role._cli.gpio = false; + role.dirty_contacts_expiry = 1; assert(role.emergencyPowerBusy()); role.dirty_contacts_expiry = 0; + role.saved_radio_apply_pending = true; + assert(role.emergencyPowerBusy()); + role.temp_radio_applied = true; assert(!role.emergencyPowerBusy()); + role.saved_radio_apply_pending = role.temp_radio_applied = false; +#ifdef ENABLE_OTA + Context context; active = &context; + for (auto state : {Manager::IDLE, Manager::COMPLETE, Manager::FAILED, Manager::PAUSED}) { + context.manager.state = state; + assert(!role.emergencyPowerBusy()); // Existing images, idle serving and paused sessions are not activity. + } + for (auto state : {Manager::WANT_MANIFEST, Manager::WANT_LEAVES, + Manager::VERIFYING_STAGED, Manager::FETCHING}) { + context.manager.state = state; + assert(role.emergencyPowerBusy()); + context.manager.state = Manager::COMPLETE; + assert(!role.emergencyPowerBusy()); + } +#ifdef OTA_SD_STORE + context.sdFetching = true; + assert(role.emergencyPowerBusy()); // Let serviceSdCache finalize/reset its completed receive owner. + context.sdFetching = false; assert(!role.emergencyPowerBusy()); +#endif + context.manager.serveJobs = 1; + assert(role.emergencyPowerBusy()); // Activity at millis()==0 must arm a real hold. + context.manager.serveJobs = 0; nowMs = 29999; + assert(role.emergencyPowerBusy()); + nowMs = 30000; assert(!role.emergencyPowerBusy()); + nowMs = 0; assert(!role.emergencyPowerBusy()); // Retired hold cannot resurrect after wrap. + context.manager.manifestJobs = 1; context.manager.retryMs = 60000; + nowMs = 0xfffffff0U; + assert(role.emergencyPowerBusy()); + nowMs += 100; + assert(role.emergencyPowerBusy()); // Actual queued manifest work renews the hold. + context.manager.manifestJobs = 0; + nowMs += 59999; assert(role.emergencyPowerBusy()); + ++nowMs; assert(!role.emergencyPowerBusy()); + context.manager.serveJobs = 1; + assert(role.emergencyPowerBusy()); + context.manager.serveJobs = 0; active = nullptr; + nowMs += 60000; assert(!role.emergencyPowerBusy()); // Released dynamic context is never reacquired. +#endif +} +int main() { + exercise(); exercise(); exercise(); + mesh::ota::active = nullptr; + Role0 repeater; Role1 room; + repeater._ota_update_at = 1; room._ota_update_at = 1; + assert(repeater.emergencyPowerBusy() && room.emergencyPowerBusy()); + repeater._ota_update_at = 0; +#if MESH_ENABLE_DIRECT_OTA_ADMIN + repeater.direct_ota_admin.waiting = true; assert(repeater.emergencyPowerBusy()); +#endif +} +''' + for flags in (("-DENABLE_OTA", "-DOTA_SD_STORE", "-DMESH_ENABLE_DIRECT_OTA_ADMIN=1"), + ("-DENABLE_OTA", "-DMESH_ENABLE_DIRECT_OTA_ADMIN=0"), + ("-DMESH_ENABLE_DIRECT_OTA_ADMIN=0",)): + with self.subTest(flags=flags), tempfile.TemporaryDirectory(prefix="emergency-ota-") as folder: + source, binary = Path(folder) / "main.cpp", Path(folder) / "check" + source.write_text(prefix, encoding="ascii") + result = subprocess.run([ + os.environ.get("CXX", "g++"), "-std=c++17", "-O1", "-g", "-Wall", "-Wextra", "-Werror", + "-Wno-unused-function", "-fsanitize=address,undefined", "-fno-sanitize-recover=all", + "-fno-omit-frame-pointer", "-fno-pie", "-no-pie", "-DMESH_ENABLE_EMERGENCY_POWER=1", + *flags, "-I", str(ROOT / "src"), str(source), "-o", str(binary)], + capture_output=True, text=True, timeout=60) + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + result = subprocess.run([str(binary)], capture_output=True, text=True, timeout=30) + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + + +if __name__ == "__main__": + unittest.main() diff --git a/test/test_emergency_power_runtime.py b/test/test_emergency_power_runtime.py new file mode 100644 index 000000000..81fe9f27b --- /dev/null +++ b/test/test_emergency_power_runtime.py @@ -0,0 +1,360 @@ +"""Run actual emergency RF/wireless ownership and restoration with fake hardware.""" +from pathlib import Path +import os +import subprocess +import tempfile +import unittest + + +ROOT = Path(__file__).resolve().parents[1] +HARNESS = r''' +#include +#include +#include +#include +#include +#include +using namespace mesh::wireless; +static std::vector events; +void delay(uint32_t milliseconds) { + events.push_back("backoff:" + std::to_string(milliseconds)); +} +static std::string serviceName(uint8_t bit) { + return bit == WiFi ? "wifi" : bit == EspNow ? "espnow" : "ble"; +} +struct Wireless : Backend { + uint8_t active = All, supported = All, sessions = Independent; + uint8_t pendingOn = 0, pendingOff = 0, failedOn = 0, failedOff = 0; + uint8_t available() const override { return supported; } + uint8_t enabled() const override { return active; } + uint8_t clients() const override { return sessions; } + Result set(uint8_t bit, bool on) override { + events.push_back(serviceName(bit) + (on ? ":on" : ":off")); + if ((on ? pendingOn : pendingOff) & bit) return Result::Pending; + if ((on ? failedOn : failedOff) & bit) return Result::Failed; + if (on) active |= bit; else active &= ~bit; + return Result::Done; + } +}; +struct Mesh { + bool available = true, inFlight = false; + unsigned queuedFuturePackets = 20; + bool hasOutbound() const { return inFlight; } + bool isRadioAvailable() const { return available; } + void setRadioAvailable(bool value) { + events.push_back(value ? "mesh:on" : "mesh:off"); available = value; + } +}; +struct Radio { + bool morseTx = false, morseRx = false, failSleep = false, failWake = false; + bool recoveryOk = true; + bool isMorseTxActive() const { return morseTx; } + bool isMorseRxActive() const { return morseRx; } + bool setTrackerSleep(bool sleeping) { + events.push_back(sleeping ? "radio:sleep" : "radio:wake"); + return !(sleeping ? failSleep : failWake); + } + bool recoverRadio(bool hard) { + assert(hard); events.push_back("radio:recover"); return recoveryOk; + } +}; +struct Board { + bool supported = true, ota = false, radioTest = false, sleepOk = true; + unsigned sleeps = 0; + uint32_t lastBudget = 0; + bool supportsEmergencySleep() const { return supported; } + bool isOTAUpdateRunning() const { return ota; } + bool isRadioTestActive() const { return radioTest; } + bool emergencySleepMilliseconds(uint32_t budget) { + events.push_back("cpu:" + std::to_string(budget)); + ++sleeps; lastBudget = budget; return sleepOk; + } +}; +struct CLI { + uint32_t budget = 2700000; + bool radioSleeping = false; + uint32_t emergencySleepMilliseconds() const { return budget; } + void setEmergencyRadioSleeping(bool value) { + events.push_back(value ? "status:sleep" : "status:awake"); radioSleeping = value; + } +}; +struct Fixture { + Wireless wireless; + Mesh mesh; + Radio radio; + Board board; + CLI cli; + mesh::power::EmergencyPowerRuntime runtime; + Fixture() { control().begin(wireless); } + bool service(bool entryBusy = false, bool workRequired = false) { + return runtime.service(mesh, radio, board, cli, entryBusy, workRequired); + } +}; +static void command(Control& c, const char* text, uint32_t now = 100) { + char reply[160] = {}; + assert(c.handle(text, reply, sizeof(reply), now, Independent)); + assert(strncmp(reply, "OK", 2) == 0); + c.service(now + 250); + assert(!c.pending()); +} +static void refused(Control& c, const char* text) { + char reply[160] = {}; + assert(c.handle(text, reply, sizeof(reply), 1000, Independent)); + assert(strstr(reply, "emergency sleep") && !c.pending()); +} +int main(int argc, char** argv) { + assert(argc == 2); + const int scenario = std::atoi(argv[1]); + if (scenario == 0) { + Fixture f; + assert(f.service()); + assert((events == std::vector{"wifi:off", "espnow:off", "ble:off", + "radio:sleep", "mesh:off", "status:sleep", "cpu:10000"})); + assert(!f.mesh.available && f.cli.radioSleeping && f.wireless.active == 0); + events.clear(); f.cli.budget = 417; + assert(f.service(true)); // Reply-drain/partial-input guard does not steal already-suspended RF. + assert((events == std::vector{"cpu:417"})); + events.clear(); f.cli.budget = 0; + assert(!f.service()); + assert((events == std::vector{"radio:wake", "mesh:on", "status:awake", + "espnow:on", "wifi:on", "ble:on"})); + assert(f.mesh.available && !f.cli.radioSleeping && f.wireless.active == All); + events.clear(); assert(!f.service() && events.empty()); + } else if (scenario == 1) { + Fixture f; + f.board.supported = false; assert(!f.service()); + f.board.supported = true; + assert(!f.service(true)); + assert(!f.service(false, true)); + f.board.ota = true; assert(!f.service()); f.board.ota = false; + f.board.radioTest = true; assert(!f.service()); f.board.radioTest = false; + f.mesh.inFlight = true; assert(!f.service()); f.mesh.inFlight = false; + f.radio.morseTx = true; assert(!f.service()); f.radio.morseTx = false; + f.radio.morseRx = true; assert(!f.service()); f.radio.morseRx = false; + assert(events.empty() && f.mesh.available && !f.cli.radioSleeping && !f.board.sleeps); + // Queued future packets are intentionally not consulted: normal mesh work + // stays suspended, so an off-hour backlog cannot prevent battery protection. + assert(f.mesh.queuedFuturePackets == 20 && f.service()); + assert(f.board.sleeps == 1 && !f.mesh.available); + } else if (scenario == 2) { + Fixture f; + f.radio.failSleep = true; + assert(!f.service() && f.mesh.available && !f.cli.radioSleeping && !f.board.sleeps); + assert(f.wireless.active == 0); + f.radio.failSleep = false; events.clear(); + assert(f.service() && !f.mesh.available && f.board.sleeps == 1); + f.radio.failWake = true; f.radio.recoveryOk = false; + f.cli.budget = 0; events.clear(); + assert(!f.service()); + assert((events == std::vector{"radio:wake", "radio:recover", "mesh:on", + "status:awake", "espnow:on", "wifi:on", "ble:on"})); + // Return ownership to Dispatcher even after hardware recovery reports an + // error, so its ordinary watchdog is not permanently disabled. + assert(f.mesh.available && !f.cli.radioSleeping); + } else if (scenario == 3) { + Fixture f; + for (unsigned cause = 0; cause != 5; ++cause) { + assert(f.service() && !f.mesh.available); + const unsigned sleeps = f.board.sleeps; + f.board.ota = cause == 0; + f.board.radioTest = cause == 1; + f.radio.morseTx = cause == 2; + f.radio.morseRx = cause == 3; + const bool work = cause == 4; + assert(!f.service(false, work)); + assert(f.mesh.available && !f.cli.radioSleeping && f.wireless.active == All); + assert(f.board.sleeps == sleeps); + assert(!f.service(false, work)); + assert(f.board.sleeps == sleeps); // Active work also prevents immediate re-entry. + f.board.ota = f.board.radioTest = f.radio.morseTx = f.radio.morseRx = false; + } + } else if (scenario == 4) { + Wireless b; Control c; c.begin(b); + assert(c.suspendForEmergency()); + assert(b.active == 0 && c.blocked(All)); + assert(!c.allowService(Bluetooth)); + assert(!c.allowService(WiFi | EspNow)); + assert(!c.allowWiFiForOTA()); + assert(c.blocked(All) && !c.masterOff()); + for (const char* text : {"set wifi on", "set espnow on", "set 2.4ghz on all", + "set 2.4ghz off force"}) refused(c, text); + char reply[160]; + assert(c.handle("get 2.4ghz", reply, sizeof(reply), 1000, Independent)); + assert(strstr(reply, "wifi=off") && strstr(reply, "bluetooth=off")); + assert(c.resumeAfterEmergency() && b.active == All && !c.blocked(All)); + assert(c.allowService(Bluetooth) && c.allowWiFiForOTA()); + } else if (scenario == 5) { + for (uint8_t mask = 0; mask <= All; ++mask) { + Wireless b; b.active = mask; + Control c; c.begin(b); + command(c, "set 2.4ghz off"); command(c, "set 2.4ghz on", 500); + assert(b.active == mask && !c.masterOff()); + for (uint8_t bit : {uint8_t(WiFi), uint8_t(Bluetooth), uint8_t(EspNow)}) + assert(c.blocked(bit) == !(mask & bit)); + assert(c.suspendForEmergency() && b.active == 0 && c.blocked(All)); + assert(c.resumeAfterEmergency() && b.active == mask && !c.masterOff()); + for (uint8_t bit : {uint8_t(WiFi), uint8_t(Bluetooth), uint8_t(EspNow)}) + assert(c.blocked(bit) == !(mask & bit)); + const size_t calls = events.size(); + assert(c.resumeAfterEmergency() && events.size() == calls); + } + Wireless b; b.active = WiFi | Bluetooth; + Control c; c.begin(b); + command(c, "set 2.4ghz off"); + assert(c.masterOff() && b.active == 0); + const size_t calls = events.size(); + assert(c.suspendForEmergency() && c.resumeAfterEmergency()); + assert(c.masterOff() && c.blocked(All) && b.active == 0 && events.size() == calls); + char reply[160]; + assert(c.handle("set wifi on", reply, sizeof(reply), 500, Independent)); + assert(strstr(reply, "2.4ghz is off")); + command(c, "set 2.4ghz on", 1000); + assert(b.active == (WiFi | Bluetooth)); // Original ordinary restore snapshot survived. + } else if (scenario == 6) { + Fixture f; + f.wireless.pendingOff = WiFi; + assert(!f.service() && f.wireless.active == All && !f.board.sleeps && f.mesh.available); + refused(control(), "set wifi on"); + assert(!control().allowService(WiFi) && !control().allowWiFiForOTA()); + f.wireless.pendingOff = 0; + f.wireless.failedOff = EspNow; + assert(!f.service() && f.wireless.active == (EspNow | Bluetooth)); + assert(f.mesh.available && !f.cli.radioSleeping && !f.board.sleeps); + // The hour/lease can change before RF ever went to sleep. Restore services + // already stopped by the incomplete suspend, then allow a later retry. + f.wireless.failedOff = 0; f.cli.budget = 0; + assert(!f.service() && f.wireless.active == All); + f.cli.budget = 2700000; assert(f.service()); + f.cli.budget = 0; f.wireless.pendingOn = EspNow; + assert(!f.service() && f.mesh.available && f.wireless.active == 0); + refused(control(), "set 2.4ghz on all"); + assert(!control().allowService(Bluetooth)); + f.wireless.pendingOn = 0; f.wireless.failedOn = WiFi; + assert(!f.service() && f.wireless.active == EspNow); + refused(control(), "set wifi on"); + f.wireless.failedOn = 0; + assert(!f.service() && f.wireless.active == All && !control().blocked(All)); + } else if (scenario == 7) { + Fixture f; + char reply[160]; + assert(control().handle("set wifi off", reply, sizeof(reply), 100, Independent)); + assert(control().pending()); + assert(!f.service() && events.empty() && f.mesh.available); + control().service(350); + assert(f.wireless.active == (Bluetooth | EspNow) && !control().pending()); + assert(f.service() && f.wireless.active == 0); + f.cli.budget = 0; + assert(!f.service() && f.wireless.active == (Bluetooth | EspNow)); + assert(control().blocked(WiFi) && !control().blocked(Bluetooth | EspNow)); + } else if (scenario == 8) { + Fixture f; + f.mesh.available = false; // Sensor Tracker already owns this radio's sleep. + assert(f.service() && f.cli.radioSleeping && f.board.sleeps == 1); + for (const auto& event : events) + assert(event != "radio:sleep" && event != "mesh:off"); + events.clear(); f.cli.budget = 0; + assert(!f.service() && !f.mesh.available); + for (const auto& event : events) + assert(event != "radio:wake" && event != "radio:recover" && event != "mesh:on"); + assert(f.wireless.active == All); + } else if (scenario == 9) { + Fixture f; + f.board.sleepOk = false; + assert(f.service() && !f.mesh.available && f.cli.radioSleeping); + assert(events.back() == "backoff:10"); + events.clear(); + assert(f.service() && !f.mesh.available && f.cli.radioSleeping); + assert((events == std::vector{"cpu:10000", "backoff:10"})); + // A later successful attempt keeps the existing suspension and skips backoff. + events.clear(); f.board.sleepOk = true; + assert(f.service()); + assert((events == std::vector{"cpu:10000"})); + // An awake lease still wakes RF after a failed CPU wait. + events.clear(); f.cli.budget = 0; + assert(!f.service() && f.mesh.available && !f.cli.radioSleeping); + assert(f.wireless.active == All); + } else assert(false); +} +''' + + +class EmergencyPowerRuntimeTests(unittest.TestCase): + @classmethod + def setUpClass(cls): + cls.folder = tempfile.TemporaryDirectory(prefix="emergency-runtime-") + work = Path(cls.folder.name) + source, cls.binary = work / "main.cpp", work / "check" + (work / "Arduino.h").write_text( + "#pragma once\n#include \nvoid delay(uint32_t);\n", encoding="ascii") + source.write_text(HARNESS, encoding="ascii") + result = subprocess.run([ + os.environ.get("CXX", "g++"), "-std=c++17", "-O1", "-g", "-Wall", "-Wextra", "-Werror", + "-fsanitize=address,undefined", "-fno-sanitize-recover=all", "-fno-omit-frame-pointer", + "-fno-pie", "-no-pie", "-DMESH_ENABLE_EMERGENCY_POWER=1", "-I", str(work), + "-I", str(ROOT / "src"), + str(source), "-o", str(cls.binary)], capture_output=True, text=True, timeout=60) + if result.returncode: + cls.folder.cleanup() + raise AssertionError(result.stdout + result.stderr) + + @classmethod + def tearDownClass(cls): + cls.folder.cleanup() + + def scenario(self, number): + result = subprocess.run([str(self.binary), str(number)], capture_output=True, + text=True, timeout=30) + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + + def test_successful_sleep_wake_order_repeat_ownership_and_ten_second_budget(self): + self.scenario(0) + + def test_live_work_prevents_entry_but_future_queue_does_not(self): + self.scenario(1) + + def test_radio_refusal_and_failed_wake_return_dispatcher_watchdog_ownership(self): + self.scenario(2) + + def test_critical_work_started_while_suspended_wakes_before_further_cpu_sleep(self): + self.scenario(3) + + def test_all_wireless_start_paths_are_refused_during_emergency_ownership(self): + self.scenario(4) + + def test_wireless_subset_blocked_mask_master_off_and_saved_snapshot_survive(self): + self.scenario(5) + + def test_async_pending_errors_and_cancelled_partial_suspend_restore_correctly(self): + self.scenario(6) + + def test_existing_wireless_request_finishes_before_emergency_ownership(self): + self.scenario(7) + + def test_sensor_tracker_radio_ownership_is_not_stolen(self): + self.scenario(8) + + def test_cpu_sleep_refusal_yields_without_resuming_normal_rf_work(self): + self.scenario(9) + + def test_room_maintenance_runs_before_emergency_early_return(self): + source = (ROOT / "examples/simple_room_server/main.cpp").read_text() + loop = source[source.index("void loop() {"):] + gate = loop.index("emergency_power.service(") + for maintenance in ("mesh::power::serviceRegulatorSettings();", + "mesh::solar::service();", "board.loop();"): + self.assertEqual(loop.count(maintenance), 1, maintenance) + self.assertLess(loop.index(maintenance), gate, maintenance) + + def test_esp_uart_console_restore_stays_live_during_emergency_sleep(self): + for role in ("simple_repeater", "simple_room_server", "simple_sensor"): + source = (ROOT / f"examples/{role}/main.cpp").read_text() + loop = source[source.index("void loop() {"):] + gate = loop.index("emergency_power.service(") + service = "board.servicePowerTestControls();" + self.assertLess(loop.index(service), gate, role) + self.assertIn("#if !MESH_ENABLE_EMERGENCY_POWER\n " + service + "\n#endif", + loop[gate:], role) + + +if __name__ == "__main__": + unittest.main() diff --git a/test/test_emergency_sleep_backends.py b/test/test_emergency_sleep_backends.py new file mode 100644 index 000000000..3d065f62e --- /dev/null +++ b/test/test_emergency_sleep_backends.py @@ -0,0 +1,268 @@ +"""Execute resumable emergency CPU sleeps with native RTOS/SDK boundaries.""" + +from pathlib import Path +import shutil +import subprocess +import sys +import tempfile +import unittest + +from test_replay_reset_integration import extract_braced + + +ROOT = Path(__file__).resolve().parents[1] +SANITIZERS = (["-fsanitize=address,undefined", "-fno-sanitize-recover=all", + "-fno-pie", "-no-pie"] if sys.platform.startswith("linux") else []) + +HARNESS = r''' +#include +#include +#include +#include +#include +static std::vector events; +static bool radio_test = false, interrupt_context = false, wireless_ready = true; +static unsigned primask = 0, basepri = 0, faultmask = 0; +static unsigned __get_IPSR() { return interrupt_context; } +static unsigned __get_PRIMASK() { return primask; } +static unsigned __get_BASEPRI() { return basepri; } +static unsigned __get_FAULTMASK() { return faultmask; } +static bool xPortInIsrContext() { return interrupt_context; } +static constexpr int taskSCHEDULER_RUNNING = 2; +static int scheduler = taskSCHEDULER_RUNNING; +static int xTaskGetSchedulerState() { return scheduler; } +static struct { unsigned __cr_flag = 0; } nrf_nvic_state; +using TickType_t = uint32_t; +static uint32_t tick_ms = 1, delayed_ticks = 0; +#define pdMS_TO_TICKS(ms) ((ms) / tick_ms) +static void vTaskDelay(TickType_t ticks) { + assert(ticks != 0); + delayed_ticks = ticks; + events.push_back("delay"); +} +static unsigned nrf_feeds = 0; +static constexpr uint32_t WDT_RR_RR_Reload = 0x6e524635; +struct ReloadRegister { + uint32_t value = 0; + void operator=(uint32_t reload) { + assert(reload == WDT_RR_RR_Reload); + value = reload; ++nrf_feeds; events.push_back("nrf_feed"); + } +}; +static struct { + uint32_t RUNSTATUS = 0, CRV = 60 * 32768UL, RREN = 1; + ReloadRegister RR[8]{}; +} nrf_watchdog; +#define NRF_WDT (&nrf_watchdog) +@NRF_RELOAD@ + +using esp_err_t = int; +static constexpr int ESP_OK = 0, ESP_ERR_NOT_FOUND = 1, ESP_ERR_INVALID_STATE = 2; +static constexpr int ESP_SLEEP_WAKEUP_TIMER = 3; +static esp_err_t task_watchdog_status = ESP_ERR_INVALID_STATE; +static unsigned task_watchdog_feeds = 0, fail_watchdog_feed = 0; +static esp_err_t esp_task_wdt_status(void*) { return task_watchdog_status; } +static esp_err_t esp_task_wdt_reset() { + events.push_back("task_feed"); + return ++task_watchdog_feeds == fail_watchdog_feed ? 9 : ESP_OK; +} +static uint64_t timer_us = 0, last_armed_us = 0; +static unsigned timer_calls = 0, sleep_calls = 0, clear_calls = 0; +static uint32_t other_wake_sources = 0x71; +static int timer_error = 0, sleep_error = 0, clear_error = 0; +static esp_err_t esp_sleep_enable_timer_wakeup(uint64_t value) { + events.push_back("timer"); ++timer_calls; + if (timer_error) return timer_error; + timer_us = last_armed_us = value; + return ESP_OK; +} +static esp_err_t esp_light_sleep_start() { + events.push_back("sleep"); ++sleep_calls; + return sleep_error; +} +static esp_err_t esp_sleep_disable_wakeup_source(int source) { + assert(source == ESP_SLEEP_WAKEUP_TIMER); // Never disable GPIO/ALL sources. + events.push_back("clear_timer"); ++clear_calls; + if (clear_error) return clear_error; + timer_us = 0; + return ESP_OK; +} +namespace mesh { namespace esp32 { +static bool manualLightSleepReady() { return wireless_ready; } +} } +struct Base { + @DEFAULT_SUPPORT@ + @DEFAULT_SLEEP@ +}; +struct NRF52Board : Base { + bool watchdog_disable_reset_pending = false; + bool isRadioTestActive() const { return radio_test; } + @NRF_SUPPORT@ + bool emergencySleepMilliseconds(uint32_t millisecs) override; +}; +struct ESP32Board : Base { + bool isRadioTestActive() const { return radio_test; } + @ESP_SUPPORT@ + bool emergencySleepMilliseconds(uint32_t millisecs) override; +}; +@NRF_SLEEP@ +@ESP_SLEEP@ +static void reset() { + events.clear(); radio_test = interrupt_context = false; wireless_ready = true; + primask = basepri = faultmask = 0; nrf_nvic_state.__cr_flag = 0; + scheduler = taskSCHEDULER_RUNNING; + tick_ms = 1; delayed_ticks = nrf_feeds = 0; + nrf_watchdog = {0, 60 * 32768UL}; + task_watchdog_status = ESP_ERR_INVALID_STATE; + task_watchdog_feeds = fail_watchdog_feed = 0; + timer_us = last_armed_us = 0; timer_calls = sleep_calls = clear_calls = 0; + timer_error = sleep_error = clear_error = 0; other_wake_sources = 0x71; +} +int main() { + Base unsupported; + assert(!unsupported.supportsEmergencySleep()); + assert(!unsupported.emergencySleepMilliseconds(1000)); + NRF52Board nrf; ESP32Board esp; + Base* supported[] = {&nrf, &esp}; + for (Base* board : supported) assert(board->supportsEmergencySleep()); + for (Base* board : supported) { + reset(); assert(!board->emergencySleepMilliseconds(0)); assert(events.empty()); + radio_test = true; + assert(!board->emergencySleepMilliseconds(100)); assert(events.empty()); + reset(); interrupt_context = true; + assert(!board->emergencySleepMilliseconds(100)); assert(events.empty()); + reset(); scheduler = 0; + assert(!board->emergencySleepMilliseconds(100)); assert(events.empty()); + } + for (unsigned* masked : {&primask, &basepri, &faultmask, &nrf_nvic_state.__cr_flag}) { + reset(); *masked = 1; + assert(!nrf.emergencySleepMilliseconds(100)); assert(events.empty()); + } + reset(); nrf.watchdog_disable_reset_pending = true; + assert(!nrf.emergencySleepMilliseconds(100)); assert(events.empty()); + nrf.watchdog_disable_reset_pending = false; + reset(); tick_ms = 100; + assert(!nrf.emergencySleepMilliseconds(1)); assert(events.empty()); + reset(); assert(nrf.emergencySleepMilliseconds(123)); + assert(delayed_ticks == 123 && !nrf_watchdog.RUNSTATUS && !nrf_feeds); + assert(events == std::vector{"delay"}); + reset(); assert(nrf.emergencySleepMilliseconds(UINT32_MAX)); + assert(delayed_ticks == 10000); // Bound the maintenance absence. + reset(); nrf_watchdog.RUNSTATUS = 1; + assert(nrf.emergencySleepMilliseconds(10000)); + assert(delayed_ticks == 10000 && nrf_feeds == 2); + assert((events == std::vector{"nrf_feed", "delay", "nrf_feed"})); + reset(); nrf_watchdog.RUNSTATUS = 1; nrf_watchdog.RREN = 0x82; + assert(nrf.emergencySleepMilliseconds(100)); assert(nrf_feeds == 4); + assert(nrf_watchdog.RR[1].value == WDT_RR_RR_Reload); + assert(nrf_watchdog.RR[7].value == WDT_RR_RR_Reload); + assert(nrf_watchdog.RR[0].value == 0 && nrf_watchdog.RR[6].value == 0); + reset(); nrf_watchdog.RUNSTATUS = 1; nrf_watchdog.CRV = 2 * 32768; + assert(nrf.emergencySleepMilliseconds(10000)); + assert(delayed_ticks == 1000 && nrf_feeds == 2); // Short inherited watchdog. + reset(); nrf_watchdog.RUNSTATUS = 1; nrf_watchdog.CRV = 32767; + assert(!nrf.emergencySleepMilliseconds(10000)); assert(events.empty()); + + reset(); wireless_ready = false; + assert(!esp.emergencySleepMilliseconds(100)); assert(events.empty()); + reset(); task_watchdog_status = 99; + assert(!esp.emergencySleepMilliseconds(100)); assert(events.empty()); + reset(); assert(esp.emergencySleepMilliseconds(UINT32_MAX)); + assert(sleep_calls == 1 && timer_calls == 1 && clear_calls == 1); + assert(last_armed_us == 10000000ULL); + assert(!timer_us && !task_watchdog_feeds && other_wake_sources == 0x71); + assert((events == std::vector{"timer", "sleep", "clear_timer"})); + reset(); task_watchdog_status = ESP_ERR_NOT_FOUND; + assert(esp.emergencySleepMilliseconds(250)); assert(!task_watchdog_feeds); + reset(); task_watchdog_status = ESP_OK; + assert(esp.emergencySleepMilliseconds(10000)); + assert(last_armed_us == 2000000ULL); + assert(task_watchdog_feeds == 2 && other_wake_sources == 0x71); + assert((events == std::vector{"task_feed", "timer", "sleep", "clear_timer", "task_feed"})); + for (int* failed : {&sleep_error, &clear_error}) { + reset(); *failed = 9; + assert(!esp.emergencySleepMilliseconds(100)); + assert(sleep_calls == 1 && clear_calls == 1 && other_wake_sources == 0x71); + } + reset(); timer_us = 555; timer_error = 9; + assert(!esp.emergencySleepMilliseconds(100)); + assert(timer_us == 555 && sleep_calls == 0 && clear_calls == 0); + reset(); task_watchdog_status = ESP_OK; fail_watchdog_feed = 1; + assert(!esp.emergencySleepMilliseconds(100)); + assert(timer_calls == 0 && sleep_calls == 0 && clear_calls == 0); + reset(); task_watchdog_status = ESP_OK; fail_watchdog_feed = 2; + assert(!esp.emergencySleepMilliseconds(100)); assert(clear_calls == 1 && !timer_us); +} +''' + + +class EmergencySleepBackendTests(unittest.TestCase): + def test_actual_platform_waits_and_cleanup(self): + compiler = shutil.which("g++") or shutil.which("clang++") + self.assertIsNotNone(compiler) + sources = {name: (ROOT / path).read_text() for name, path in ( + ("core", "src/MeshCore.h"), + ("nrf", "src/helpers/NRF52Board.cpp"), + ("nrf_header", "src/helpers/NRF52Board.h"), + ("esp", "src/helpers/ESP32Board.cpp"), + ("esp_header", "src/helpers/ESP32Board.h"))} + code = HARNESS + for marker, source, signature in ( + ("DEFAULT_SUPPORT", "core", "virtual bool supportsEmergencySleep() const"), + ("DEFAULT_SLEEP", "core", "virtual bool emergencySleepMilliseconds("), + ("NRF_RELOAD", "nrf", "static void reloadWatchdogChannels("), + ("NRF_SUPPORT", "nrf_header", "bool supportsEmergencySleep() const override"), + ("ESP_SUPPORT", "esp_header", "bool supportsEmergencySleep() const override"), + ("NRF_SLEEP", "nrf", "bool NRF52Board::emergencySleepMilliseconds("), + ("ESP_SLEEP", "esp", "bool ESP32Board::emergencySleepMilliseconds(")): + code = code.replace("@" + marker + "@", extract_braced(sources[source], signature)) + with tempfile.TemporaryDirectory(prefix="emergency-sleep-backends-") as directory: + work = Path(directory) + cpp, binary = work / "test.cpp", work / "test" + cpp.write_text(code) + subprocess.run([compiler, "-std=c++17", "-Wall", "-Wextra", "-Werror", + "-DCONFIG_ESP_TASK_WDT_TIMEOUT_S=4", *SANITIZERS, + str(cpp), "-o", str(binary)], check=True, + capture_output=True, text=True, timeout=60) + subprocess.run([str(binary)], check=True, capture_output=True, + text=True, timeout=20) + + def test_disabled_feature_has_no_new_base_virtuals(self): + compiler = shutil.which("g++") or shutil.which("clang++") + self.assertIsNotNone(compiler) + core = (ROOT / "src/MeshCore.h").read_text() + start = core.index("#if MESH_ENABLE_EMERGENCY_POWER\n", core.index("class MainBoard")) + block = core[start:core.index("#endif", start) + len("#endif")] + code = r''' +#include +#include +#include +#include +struct Board { +@API@ +}; +template struct HasApi : std::false_type {}; +template struct HasApi().supportsEmergencySleep())>> : std::true_type {}; +static_assert(HasApi::value == bool(MESH_ENABLE_EMERGENCY_POWER)); +int main() { +#if MESH_ENABLE_EMERGENCY_POWER + Board b; assert(!b.supportsEmergencySleep() && !b.emergencySleepMilliseconds(1)); +#endif +} +'''.replace("@API@", block) + with tempfile.TemporaryDirectory(prefix="emergency-sleep-feature-") as directory: + work = Path(directory) + cpp, binary = work / "test.cpp", work / "test" + cpp.write_text(code) + for enabled in (0, 1): + subprocess.run([compiler, "-std=c++17", "-Wall", "-Wextra", "-Werror", + f"-DMESH_ENABLE_EMERGENCY_POWER={enabled}", str(cpp), + "-o", str(binary)], check=True, + capture_output=True, text=True, timeout=60) + subprocess.run([str(binary)], check=True, capture_output=True, + text=True, timeout=20) + + +if __name__ == "__main__": + unittest.main() From f4f9453248d351bd91be269ca0a58a6edb51882e Mon Sep 17 00:00:00 2001 From: mikecarper Date: Wed, 28 Oct 2026 02:12:04 -0700 Subject: [PATCH 05/61] Suspend the RAK3401 amplifier during emergency sleep --- docs/cli_commands.md | 2 + src/Dispatcher.h | 7 +++ src/MeshCore.h | 3 + src/helpers/EmergencyPowerRuntime.h | 2 + test/test_emergency_power_runtime.py | 88 +++++++++++++++++++++++++++- variants/rak3401/RAK3401Board.cpp | 17 ++++++ variants/rak3401/RAK3401Board.h | 7 +++ 7 files changed, 125 insertions(+), 1 deletion(-) diff --git a/docs/cli_commands.md b/docs/cli_commands.md index 567edd71c..3412c1528 100644 --- a/docs/cli_commands.md +++ b/docs/cli_commands.md @@ -3134,6 +3134,8 @@ five seconds for its reply. `power.awake` keeps the node up for the next given number of minutes (0 cancels; maximum 10080), even outside the hourly window. That override is held in RAM and ends on reboot; it never disables hard low-voltage shutdown. Use it before maintenance that needs continuous uptime. +LoRa commands must arrive during an awake window. Local USB commands can wait +up to one ten-second maintenance interval while the node is sleeping. `get power.emergency` reports the mode, battery estimate, desired `window`, actual radio ownership, seconds until the next awake window, and remaining diff --git a/src/Dispatcher.h b/src/Dispatcher.h index a145719c3..1c1784089 100644 --- a/src/Dispatcher.h +++ b/src/Dispatcher.h @@ -20,6 +20,10 @@ namespace mesh { +#if MESH_ENABLE_EMERGENCY_POWER +namespace power { class EmergencyPowerRuntime; } +#endif + enum class RadioParamApplyResult : uint8_t { APPLIED, BUSY, @@ -421,6 +425,9 @@ typedef uint32_t DispatcherAction; * and scheduling of outbound Packets. */ class Dispatcher { +#if MESH_ENABLE_EMERGENCY_POWER + friend class power::EmergencyPowerRuntime; +#endif friend class FleetChannel; // bounded deferred receiver and exact reply retirement enum class OutboundCancellation : uint8_t { None, Cancelled, Delivered }; Packet* outbound; // current outbound packet diff --git a/src/MeshCore.h b/src/MeshCore.h index 423d370b4..532b47cf7 100644 --- a/src/MeshCore.h +++ b/src/MeshCore.h @@ -211,6 +211,9 @@ public: (void)millisecs; return false; } + // Quiesce only board-specific RF controls after owned warm radio sleep; + // restore their prior state before the caller wakes the radio. + virtual void setEmergencyRadioSleeping(bool sleeping) { (void)sleeping; } #endif // Called by example setup() functions to signal that boot is complete. // Boards may override to stop a boot-indicator LED sequence or similar. diff --git a/src/helpers/EmergencyPowerRuntime.h b/src/helpers/EmergencyPowerRuntime.h index 3a79508af..cbf853889 100644 --- a/src/helpers/EmergencyPowerRuntime.h +++ b/src/helpers/EmergencyPowerRuntime.h @@ -22,6 +22,7 @@ public: if (budget == 0 || active_operation) { if (radio_asleep_) { if (radio_was_available_) { + board.setEmergencyRadioSleeping(false); if (!radio.setTrackerSleep(false)) radio.recoverRadio(true); mesh.setRadioAvailable(true); } @@ -40,6 +41,7 @@ public: if (radio_was_available_) { if (!radio.setTrackerSleep(true)) return false; mesh.setRadioAvailable(false); + board.setEmergencyRadioSleeping(true); } radio_asleep_ = true; cli.setEmergencyRadioSleeping(true); diff --git a/test/test_emergency_power_runtime.py b/test/test_emergency_power_runtime.py index 81fe9f27b..131d882ae 100644 --- a/test/test_emergency_power_runtime.py +++ b/test/test_emergency_power_runtime.py @@ -1,10 +1,13 @@ """Run actual emergency RF/wireless ownership and restoration with fake hardware.""" from pathlib import Path import os +import re import subprocess import tempfile import unittest +from test_replay_reset_integration import extract_braced + ROOT = Path(__file__).resolve().parents[1] HARNESS = r''' @@ -15,6 +18,7 @@ HARNESS = r''' #include #include using namespace mesh::wireless; +namespace power = mesh::power; static std::vector events; void delay(uint32_t milliseconds) { events.push_back("backoff:" + std::to_string(milliseconds)); @@ -40,6 +44,8 @@ struct Mesh { bool available = true, inFlight = false; unsigned queuedFuturePackets = 20; bool hasOutbound() const { return inFlight; } +protected: + @RUNTIME_FRIEND@ bool isRadioAvailable() const { return available; } void setRadioAvailable(bool value) { events.push_back(value ? "mesh:on" : "mesh:off"); available = value; @@ -65,11 +71,30 @@ struct Board { bool supportsEmergencySleep() const { return supported; } bool isOTAUpdateRunning() const { return ota; } bool isRadioTestActive() const { return radioTest; } + virtual void setEmergencyRadioSleeping(bool) {} bool emergencySleepMilliseconds(uint32_t budget) { events.push_back("cpu:" + std::to_string(budget)); ++sleeps; lastBudget = budget; return sleepOk; } }; +static constexpr int HIGH = 1, LOW = 0; +static constexpr unsigned SX126X_POWER_EN = @FEM_PIN@, PIN_3V3_EN = @RAIL_PIN@; +static int levels[64] = {}; +static int digitalRead(unsigned pin) { + assert(pin == SX126X_POWER_EN); + events.push_back("fem:read"); + return levels[pin]; +} +static void digitalWrite(unsigned pin, int value) { + assert(pin == SX126X_POWER_EN); // Shared IO2/storage rail must never change. + levels[pin] = value; + events.push_back(value ? "fem:on" : "fem:off"); +} +struct RAK3401Board : Board { + @RAK_FIELDS@ + void setEmergencyRadioSleeping(bool sleeping) override; +}; +@RAK_FEM_METHOD@ struct CLI { uint32_t budget = 2700000; bool radioSleeping = false; @@ -273,6 +298,49 @@ int main(int argc, char** argv) { events.clear(); f.cli.budget = 0; assert(!f.service() && f.mesh.available && !f.cli.radioSleeping); assert(f.wireless.active == All); + } else if (scenario == 10) { + for (int starting_level : {LOW, HIGH}) { + Wireless wireless; control().begin(wireless); + Mesh mesh; Radio radio; RAK3401Board board; CLI cli; + mesh::power::EmergencyPowerRuntime runtime; + levels[SX126X_POWER_EN] = starting_level; levels[PIN_3V3_EN] = HIGH; + events.clear(); + // Failed warm sleep never captures/disables the FEM. + radio.failSleep = true; + assert(!runtime.service(mesh, radio, board, cli, false)); + assert(levels[SX126X_POWER_EN] == starting_level); + assert(events.back() == "radio:sleep"); + radio.failSleep = false; events.clear(); + assert(runtime.service(mesh, radio, board, cli, false)); + assert((events == std::vector{"wifi:off", "espnow:off", "ble:off", + "radio:sleep", "mesh:off", "fem:read", "fem:off", "status:sleep", "cpu:10000"})); + assert(levels[SX126X_POWER_EN] == LOW && levels[PIN_3V3_EN] == HIGH); + events.clear(); + // Maintenance and duplicate suspension must not overwrite the snapshot. + board.setEmergencyRadioSleeping(true); + assert(runtime.service(mesh, radio, board, cli, false)); + assert((events == std::vector{"cpu:10000"})); + cli.budget = 0; events.clear(); + radio.failWake = true; + assert(!runtime.service(mesh, radio, board, cli, false)); + std::vector expected = {starting_level ? "fem:on" : "fem:off"}; + if (starting_level) expected.push_back("backoff:1"); + const std::vector after = {"radio:wake", "radio:recover", "mesh:on", + "status:awake", "espnow:on", "wifi:on", "ble:on"}; + expected.insert(expected.end(), after.begin(), after.end()); + assert(events == expected); + assert(levels[SX126X_POWER_EN] == starting_level && levels[PIN_3V3_EN] == HIGH); + events.clear(); board.setEmergencyRadioSleeping(false); assert(events.empty()); + } + Wireless wireless; control().begin(wireless); + Mesh mesh; mesh.available = false; // Tracker owns RF; its FEM is untouched. + Radio radio; RAK3401Board board; CLI cli; + mesh::power::EmergencyPowerRuntime runtime; + levels[SX126X_POWER_EN] = HIGH; events.clear(); + assert(runtime.service(mesh, radio, board, cli, false)); + cli.budget = 0; assert(!runtime.service(mesh, radio, board, cli, false)); + for (const auto& event : events) assert(event.find("fem:") == std::string::npos); + assert(levels[SX126X_POWER_EN] == HIGH); } else assert(false); } ''' @@ -286,7 +354,22 @@ class EmergencyPowerRuntimeTests(unittest.TestCase): source, cls.binary = work / "main.cpp", work / "check" (work / "Arduino.h").write_text( "#pragma once\n#include \nvoid delay(uint32_t);\n", encoding="ascii") - source.write_text(HARNESS, encoding="ascii") + board_header = (ROOT / "variants/rak3401/RAK3401Board.h").read_text() + board_source = (ROOT / "variants/rak3401/RAK3401Board.cpp").read_text() + dispatcher = (ROOT / "src/Dispatcher.h").read_text() + variant = (ROOT / "variants/rak3401/variant.h").read_text() + fields = re.search(r"class RAK3401Board[^\n]*\{\n#if MESH_ENABLE_EMERGENCY_POWER\n(.*?)#endif", + board_header, re.S).group(1) + code = HARNESS.replace("@RAK_FIELDS@", fields) + code = code.replace("@RAK_FEM_METHOD@", extract_braced( + board_source, "void RAK3401Board::setEmergencyRadioSleeping(")) + code = code.replace("@RUNTIME_FRIEND@", re.search( + r"friend class power::EmergencyPowerRuntime;", dispatcher).group(0)) + for marker, name, body in (("FEM_PIN", "SX126X_POWER_EN", variant), + ("RAIL_PIN", "PIN_3V3_EN", board_header)): + pin = re.search(rf"#define\s+{name}\s+\(?\s*(\d+)", body).group(1) + code = code.replace("@" + marker + "@", pin) + source.write_text(code, encoding="ascii") result = subprocess.run([ os.environ.get("CXX", "g++"), "-std=c++17", "-O1", "-g", "-Wall", "-Wextra", "-Werror", "-fsanitize=address,undefined", "-fno-sanitize-recover=all", "-fno-omit-frame-pointer", @@ -336,6 +419,9 @@ class EmergencyPowerRuntimeTests(unittest.TestCase): def test_cpu_sleep_refusal_yields_without_resuming_normal_rf_work(self): self.scenario(9) + def test_rak3401_actual_fem_pin_restores_before_radio_wake_without_cutting_shared_rail(self): + self.scenario(10) + def test_room_maintenance_runs_before_emergency_early_return(self): source = (ROOT / "examples/simple_room_server/main.cpp").read_text() loop = source[source.index("void loop() {"):] diff --git a/variants/rak3401/RAK3401Board.cpp b/variants/rak3401/RAK3401Board.cpp index 434aaf559..a8d8c255f 100644 --- a/variants/rak3401/RAK3401Board.cpp +++ b/variants/rak3401/RAK3401Board.cpp @@ -153,3 +153,20 @@ void RAK3401Board::enableRadioFrontend() { digitalWrite(SX126X_POWER_EN, HIGH); delay(1); } + +#if MESH_ENABLE_EMERGENCY_POWER +void RAK3401Board::setEmergencyRadioSleeping(bool sleeping) { + if (sleeping) { + if (emergency_fem_suspended) return; + emergency_fem_was_enabled = digitalRead(SX126X_POWER_EN) == HIGH; + // IO3 controls only the SKY66122 CSD+CPS. Keep the shared IO2 rail live + // for the radio core, storage and any fitted sensors. + digitalWrite(SX126X_POWER_EN, LOW); + emergency_fem_suspended = true; + } else if (emergency_fem_suspended) { + digitalWrite(SX126X_POWER_EN, emergency_fem_was_enabled ? HIGH : LOW); + if (emergency_fem_was_enabled) delay(1); + emergency_fem_suspended = false; + } +} +#endif diff --git a/variants/rak3401/RAK3401Board.h b/variants/rak3401/RAK3401Board.h index 74764501a..592118664 100644 --- a/variants/rak3401/RAK3401Board.h +++ b/variants/rak3401/RAK3401Board.h @@ -12,6 +12,10 @@ #define WB_IO2 PIN_3V3_EN class RAK3401Board : public NRF52BoardDCDC { +#if MESH_ENABLE_EMERGENCY_POWER + bool emergency_fem_suspended = false; + bool emergency_fem_was_enabled = false; +#endif protected: #ifdef NRF52_POWER_MANAGEMENT void initiateShutdown(uint8_t reason) override; @@ -22,6 +26,9 @@ public: void shutdownPeripherals() override; bool recoverRadio(); void enableRadioFrontend(); +#if MESH_ENABLE_EMERGENCY_POWER + void setEmergencyRadioSleeping(bool sleeping) override; +#endif bool supportsRadioHardReset() const override { return true; } bool resetRadio() override { return recoverRadio(); } bool finishRadioHardReset() override { From b18ba8288a484589667422af04eb554bb52ebe74 Mon Sep 17 00:00:00 2001 From: mikecarper Date: Wed, 28 Oct 2026 02:14:11 -0700 Subject: [PATCH 06/61] Report the running firmware USB mode without disturbing logging --- docs/cli_commands.md | 22 +++ examples/companion_radio/MyMesh.cpp | 3 + src/helpers/CommonCLI.cpp | 2 + src/helpers/UsbLogging.cpp | 3 +- src/helpers/UsbLoggingClientActivity.h | 16 +++ src/helpers/UsbMode.h | 46 +++++++ test/test_usb_mode.py | 181 +++++++++++++++++++++++++ test/test_usb_packet_logging_quiet.py | 8 +- 8 files changed, 278 insertions(+), 3 deletions(-) create mode 100644 src/helpers/UsbMode.h create mode 100644 test/test_usb_mode.py diff --git a/docs/cli_commands.md b/docs/cli_commands.md index 6094581f3..f48c2d6c3 100644 --- a/docs/cli_commands.md +++ b/docs/cli_commands.md @@ -1631,6 +1631,28 @@ For other roles, use the separately named `-ota-` artifact when required. A LoRa OTA, and the expanded FULL feature set. A `-full-logging-ota-` artifact is emitted only when that hardware/role has no matching WiFi MQTT environment. +### Report the USB connection mode + +`get usb.mode` reports the primary serial backend compiled into the running +application, checking its actual primary `Serial` type as well as the USB +build flags. It is available on Repeaters, Room Servers, Sensors and Full +Companions, including the Companion protocol-14 CLI command. It is read-only: +no reset, settings write, logging toggle or packet-capture pause is performed. + +The exact reply is one of: + +- `usb.mode hardware-cdc-jtag`: ESP32 hardware USB Serial/JTAG, with CDC on boot. +- `usb.mode tinyusb`: ESP32 TinyUSB CDC on boot, or nRF52 using TinyUSB. +- `usb.mode uart`: ESP32 primary `Serial` is UART, not native USB CDC. +- `usb.mode unknown`: this build does not provide a proven backend mapping. + +The result describes the application, not a connected adapter, host COM-port +name, optional second logging interface or current ROM/DFU bootloader. There +is no `set usb.mode`; changing the backend requires another firmware build. +Older firmware may reject this command. A flasher should retain its manual +connection option when the report is absent, unknown or conflicts with the +selected USB device; it must not guess from the board name or shared USB ID. + ### Control live USB logging **Search terms:** serial logging, USB debug log, enable logging, disable logging, debug output. diff --git a/examples/companion_radio/MyMesh.cpp b/examples/companion_radio/MyMesh.cpp index 2ff0c856e..6c6449eda 100644 --- a/examples/companion_radio/MyMesh.cpp +++ b/examples/companion_radio/MyMesh.cpp @@ -69,6 +69,7 @@ #include #include #include +#include #if defined(ESP32_PLATFORM) && defined(WIFI_SSID) #include #include @@ -11132,6 +11133,8 @@ bool MyMesh::handleCommand(const char* command, uint32_t sender_timestamp, while (*command == ' ' || *command == '\t') command++; } + if (mesh::handleUsbModeCommand(command, reply, reply_capacity, Serial)) return true; + #if defined(ESP32_PLATFORM) // Catch remote requests before the direct-transport router below. That // router is also used by USB/BLE/browser commands without a timestamp. diff --git a/src/helpers/CommonCLI.cpp b/src/helpers/CommonCLI.cpp index cb84f8e54..d989c8da3 100644 --- a/src/helpers/CommonCLI.cpp +++ b/src/helpers/CommonCLI.cpp @@ -5,6 +5,7 @@ #include #include #include +#include "UsbMode.h" #include "CarrierTools.h" #include "PacketFileLoggingConfig.h" #include "CommonCLI.h" @@ -3553,6 +3554,7 @@ bool CommonCLI::handleUDPBridgeCommand(uint32_t sender_timestamp, void CommonCLI::handleCommand(uint32_t sender_timestamp, char* command, char* reply) { PrefsSaveReplyGuard save_reply(_prefs_save_failures, reply); mesh::cli::normalizeCommandVerb(command); + if (mesh::handleUsbModeCommand(command, reply, 160, Serial)) return; #if MESH_ENABLE_HOST_KISS if (mesh::hostkiss::handleCommand(command, reply, 157, sender_timestamp == 0)) return; #endif diff --git a/src/helpers/UsbLogging.cpp b/src/helpers/UsbLogging.cpp index f03c2486b..6d7e15c90 100644 --- a/src/helpers/UsbLogging.cpp +++ b/src/helpers/UsbLogging.cpp @@ -1303,7 +1303,8 @@ void noteUsbConsoleCommand(const char* command) { if (command == nullptr || isUsbPacketLoggingAlways() || hasDedicatedUsbLoggingPort() || isUsbLoggingPacketStream()) return; while (*command == ' ' || *command == '\t' || *command == '\r' || *command == '\n') ++command; - if (*command == 0 || isUsbLoggingStatsCommand(command)) return; + if (*command == 0 || isUsbLoggingStatsCommand(command) + || isUsbModeDetectionCommand(command)) return; for (const unsigned char* p = reinterpret_cast(command); *p; ++p) { if ((*p < 32 && *p != '\t') || *p >= 127) return; } diff --git a/src/helpers/UsbLoggingClientActivity.h b/src/helpers/UsbLoggingClientActivity.h index bc03f838c..2fe6d2fb1 100644 --- a/src/helpers/UsbLoggingClientActivity.h +++ b/src/helpers/UsbLoggingClientActivity.h @@ -11,6 +11,22 @@ namespace mesh { // not identify a particular application or prove MQTT/disk health. constexpr uint32_t USB_LOGGING_CLIENT_LEASE_MS = 15UL * 60UL * 1000UL; +// Firmware detection is passive even before the role strips an optional +// two-byte Companion request prefix or normalizes an uppercase GET verb. +// Unlike stats polling, this query must not establish a logging-reader lease. +inline bool isUsbModeDetectionCommand(const char* command) { + if (!command) return false; + while (*command == ' ' || *command == '\t') ++command; + if (command[0] && command[1] && command[2] == '|' && command[3]) { + command += 3; + while (*command == ' ' || *command == '\t') ++command; + } + return (command[0] == 'g' || command[0] == 'G') + && (command[1] == 'e' || command[1] == 'E') + && (command[2] == 't' || command[2] == 'T') + && strcmp(command + 3, " usb.mode") == 0; +} + inline bool isUsbLoggingStatsCommand(const char* command) { if (!command) return false; while (*command == ' ' || *command == '\t') ++command; diff --git a/src/helpers/UsbMode.h b/src/helpers/UsbMode.h new file mode 100644 index 000000000..2a5c2fe31 --- /dev/null +++ b/src/helpers/UsbMode.h @@ -0,0 +1,46 @@ +#pragma once + +#include +#include +#include +#include + +namespace mesh { + +// Describe the primary Serial backend selected by this application build, +// not its board name, host port name, USB VID/PID or optional logging CDC. +// Include after Arduino.h. The caller supplies decltype(Serial), after any +// board-specific alias is visible, so a custom primary UART cannot inherit a +// native-USB claim merely because the unused USB peripheral has those flags. +template +inline const char* usbModeName() { +#if defined(ESP32) || defined(ESP32_PLATFORM) + if (std::is_same::value) return "uart"; + #if defined(ARDUINO_USB_CDC_ON_BOOT) && ARDUINO_USB_CDC_ON_BOOT + #if defined(ARDUINO_USB_MODE) && ARDUINO_USB_MODE == 1 + if (std::is_same::value) return "hardware-cdc-jtag"; + #elif defined(ARDUINO_USB_MODE) && ARDUINO_USB_MODE == 0 + if (std::is_same::value) return "tinyusb"; + #endif + #endif +#elif defined(NRF52_PLATFORM) && defined(USE_TINYUSB) + if (std::is_same::value) return "tinyusb"; +#endif + return "unknown"; +} + +inline bool isUsbModeCommand(const char* command) { + return command != nullptr && strcmp(command, "get usb.mode") == 0; +} + +// Read-only, bounded and independent of diagnostic/logging build flags. +// No setter: changing a USB backend requires installing another application. +template +inline bool handleUsbModeCommand(const char* command, char* reply, + size_t capacity, const SerialType&) { + if (!isUsbModeCommand(command) || reply == nullptr || capacity == 0) return false; + snprintf(reply, capacity, "usb.mode %s", usbModeName()); + return true; +} + +} // namespace mesh diff --git a/test/test_usb_mode.py b/test/test_usb_mode.py new file mode 100644 index 000000000..60ce40840 --- /dev/null +++ b/test/test_usb_mode.py @@ -0,0 +1,181 @@ +"""Execute the read-only USB backend report with actual compiled flag branches.""" +from pathlib import Path +import os +import shutil +import subprocess +import tempfile +import unittest + +from test_radio_receive_contract import method + +ROOT = Path(__file__).resolve().parents[1] + +HARNESS = r''' +// Minimal concrete types stand in for each SDK's actual primary Serial type. +class HardwareSerial {}; +class HWCDC {}; +class USBCDC {}; +class Adafruit_USBD_CDC {}; +class CustomSerial {}; +PRIMARY_SERIAL Serial; +#include +#include +#include +#include +#include +#include + +@DISPATCH@ + +int main() { + assert(!strcmp(mesh::usbModeName(), EXPECTED_MODE)); + const char expected[] = "usb.mode " EXPECTED_MODE; + char reply[160] = {}; + assert(mesh::handleUsbModeCommand("get usb.mode", reply, sizeof(reply), Serial)); + assert(!strcmp(reply, expected)); + assert(strlen(reply) < 32); + // This detection query neither pauses capture nor grants a logging-client + // watchdog lease. Stats polling remains a separate explicit mechanism. + assert(!mesh::isUsbLoggingStatsCommand("get usb.mode")); + for (const char* raw : {"get usb.mode", "GET usb.mode", "GeT usb.mode", + "xy|get usb.mode", "xy| \tget usb.mode", " \txy|GET usb.mode"}) { + assert(mesh::isUsbModeDetectionCommand(raw)); + assert(!mesh::isUsbLoggingStatsCommand(raw)); + } + for (const char* raw : std::initializer_list{nullptr, "", "x", "xy", "xy|", + "x|get usb.mode", "xyz|get usb.mode", "get usb.mode ", "get usb.mode extra", + "xy|set usb.mode tinyusb", "xy|get usb.modex", "get USB.MODE"}) + assert(!mesh::isUsbModeDetectionCommand(raw)); + for (const char* invalid : std::initializer_list{nullptr, "", "get usb.modex", "get usb.mode extra", + "get usb.mode\n", "get usb.mode\t", "get USB.MODE", "usb.mode", + "set usb.mode", "set usb.mode tinyusb", "set usb.mode hardware-cdc-jtag"}) { + strcpy(reply, "unchanged"); + assert(!mesh::handleUsbModeCommand(invalid, reply, sizeof(reply), Serial)); + assert(!strcmp(reply, "unchanged")); + } + assert(!mesh::handleUsbModeCommand("get usb.mode", nullptr, sizeof(reply), Serial)); + strcpy(reply, "unchanged"); + assert(!mesh::handleUsbModeCommand("get usb.mode", reply, 0, Serial)); + assert(!strcmp(reply, "unchanged")); + for (size_t capacity = 1; capacity <= sizeof(expected); ++capacity) { + unsigned char guarded[160]; + memset(guarded, 0xA5, sizeof(guarded)); + assert(mesh::handleUsbModeCommand("get usb.mode", + reinterpret_cast(guarded + 1), capacity, Serial)); + assert(guarded[0] == 0xA5 && guarded[capacity + 1] == 0xA5); + assert(guarded[capacity] == 0); + assert(!strncmp(reinterpret_cast(guarded + 1), expected, capacity - 1)); + } + @DISPATCH_CASES@ +} +''' + + +class UsbModeTests(unittest.TestCase): + def compile_and_run(self, directory, name, flags, expected, dispatch="", cases="", primary=None): + compiler = os.environ.get("CXX") or shutil.which("g++") or shutil.which("clang++") + if not compiler: + self.skipTest("a host C++17 compiler is required") + source = Path(directory) / (name + ".cpp") + source.write_text(HARNESS.replace("@DISPATCH@", dispatch) + .replace("@DISPATCH_CASES@", cases), encoding="ascii") + binary = source.with_suffix(".exe") + if primary is None: + primary = {"hardware-cdc-jtag": "HWCDC", "tinyusb": "USBCDC", + "uart": "HardwareSerial"}.get(expected, "CustomSerial") + if "NRF52_PLATFORM" in flags and expected == "tinyusb": + primary = "Adafruit_USBD_CDC" + built = subprocess.run([compiler, "-std=c++17", "-Wall", "-Wextra", "-Werror", + *([] if os.name == "nt" else ["-fsanitize=address,undefined", + "-fno-sanitize-recover=all", "-fno-pie", "-no-pie"]), + *["-D" + flag for flag in flags], '-DEXPECTED_MODE="' + expected + '"', + "-DPRIMARY_SERIAL=" + primary, + "-I" + str(ROOT / "src"), str(source), "-o", str(binary)], + capture_output=True, text=True, timeout=60) + self.assertEqual(built.returncode, 0, built.stdout + built.stderr) + ran = subprocess.run([str(binary)], capture_output=True, text=True, timeout=10) + self.assertEqual(ran.returncode, 0, ran.stdout + ran.stderr) + + def test_actual_backend_flags_and_bounded_read_only_command(self): + cases = ( + (["ESP32", "ARDUINO_USB_MODE=1", "ARDUINO_USB_CDC_ON_BOOT=1"], "hardware-cdc-jtag"), + (["ESP32", "ARDUINO_USB_MODE=0", "ARDUINO_USB_CDC_ON_BOOT=1"], "tinyusb"), + (["ESP32", "ARDUINO_USB_MODE=1", "ARDUINO_USB_CDC_ON_BOOT=0"], "uart"), + (["ESP32", "ARDUINO_USB_MODE=0", "ARDUINO_USB_CDC_ON_BOOT=0"], "uart"), + (["ESP32", "ARDUINO_USB_MODE=1"], "uart"), + (["ESP32"], "uart"), + (["ESP32", "ARDUINO_USB_CDC_ON_BOOT=1"], "unknown"), + (["ESP32", "ARDUINO_USB_MODE=7", "ARDUINO_USB_CDC_ON_BOOT=1"], "unknown"), + (["ESP32_PLATFORM", "ARDUINO_USB_MODE=1", "ARDUINO_USB_CDC_ON_BOOT=1"], "hardware-cdc-jtag"), + (["NRF52_PLATFORM", "USE_TINYUSB", "MESH_DUAL_CDC_LOGGING"], "tinyusb"), + (["NRF52_PLATFORM"], "unknown"), + (["RP2040_PLATFORM"], "unknown"), + ([], "unknown"), + ) + with tempfile.TemporaryDirectory(prefix="meshcore-usb-mode-") as directory: + for index, (flags, expected) in enumerate(cases): + with self.subTest(flags=flags): + self.compile_and_run(directory, "mode" + str(index), flags, expected) + # Exact flags alone are insufficient when a board redirects Serial. + for index, (flags, primary, expected) in enumerate(( + (["ESP32", "ARDUINO_USB_MODE=1", "ARDUINO_USB_CDC_ON_BOOT=1"], "HardwareSerial", "uart"), + (["ESP32", "ARDUINO_USB_MODE=1", "ARDUINO_USB_CDC_ON_BOOT=1"], "CustomSerial", "unknown"), + (["ESP32", "ARDUINO_USB_MODE=0", "ARDUINO_USB_CDC_ON_BOOT=1"], "HWCDC", "unknown"), + (["NRF52_PLATFORM", "USE_TINYUSB"], "CustomSerial", "unknown"), + )): + with self.subTest(primary_override=primary, flags=flags): + self.compile_and_run(directory, "alias" + str(index), flags, expected, primary=primary) + + def test_actual_common_and_companion_dispatch_including_v14_request_prefix(self): + common = method((ROOT / "src/helpers/CommonCLI.cpp").read_text(), + "void CommonCLI::handleCommand(") + companion = method((ROOT / "examples/companion_radio/MyMesh.cpp").read_text(), + "bool MyMesh::handleCommand(") + call = "if (mesh::handleUsbModeCommand(command, reply, reply_capacity, Serial)) return true;" + self.assertIn(call, companion) + # Execute the real early dispatch bodies, before unrelated board/radio + # services. The framed V14 command path invokes this same handleCommand. + common = common.split("#if MESH_ENABLE_HOST_KISS", 1)[0] + "\n}" + companion = companion[:companion.index(call) + len(call)] + "\nreturn false;\n}" + # The omitted unrelated dispatch branches normally use this argument. + common = common.replace("{", "{ (void)sender_timestamp;", 1) + companion = companion.replace("{", "{ (void)sender_timestamp;", 1) + dispatch = r''' +struct PrefsSaveReplyGuard { + PrefsSaveReplyGuard(unsigned&, char*) {} +}; +namespace mesh { namespace cli { void normalizeCommandVerb(char*) {} } } +struct CommonCLI { + unsigned _prefs_save_failures = 0; + void handleCommand(uint32_t, char*, char*); +}; +struct MyMesh { bool handleCommand(const char*, uint32_t, char*); }; +''' + cases = r''' + CommonCLI common; + MyMesh companion; + char command[] = "get usb.mode"; + common.handleCommand(0, command, reply); + assert(!strcmp(reply, expected) && common._prefs_save_failures == 0); + assert(companion.handleCommand(command, 0, reply)); + assert(!strcmp(reply, expected)); + assert(companion.handleCommand("xy|get usb.mode", 0, reply)); + assert(!strncmp(reply, "xy|", 3) && !strcmp(reply + 3, expected)); + assert(!companion.handleCommand("set usb.mode tinyusb", 0, reply)); +''' + with tempfile.TemporaryDirectory(prefix="meshcore-usb-mode-dispatch-") as directory: + self.compile_and_run(directory, "dispatch", ["ESP32", "ARDUINO_USB_MODE=1", + "ARDUINO_USB_CDC_ON_BOOT=1"], "hardware-cdc-jtag", dispatch + common + companion, cases) + full = (ROOT / "examples/companion_radio/MyMesh.cpp").read_text() + frame = full[full.index("mesh::companion::isRunCliFrame"):] + self.assertIn("handleCommand(text, 0, reply_buf)", frame) + for role, implementation in (("simple_repeater", "MyMesh.cpp"), + ("simple_room_server", "MyMesh.cpp"), + ("simple_sensor", "SensorMesh.cpp")): + source = (ROOT / "examples" / role / implementation).read_text() + self.assertTrue("_cli.handleCommand(sender_timestamp, command, reply)" in source, + role + " must route to the shared CommonCLI") + + +if __name__ == "__main__": + unittest.main() diff --git a/test/test_usb_packet_logging_quiet.py b/test/test_usb_packet_logging_quiet.py index 9b60fb494..b6e8fc3df 100644 --- a/test/test_usb_packet_logging_quiet.py +++ b/test/test_usb_packet_logging_quiet.py @@ -35,16 +35,20 @@ static void timerCases() { mesh::configureUsbLoggingPacketStream(false);g_mock_millis=0; assert(!mesh::isUsbPacketLoggingAlways()); for(const char* ignored:std::initializer_list{nullptr,""," \t\r\n","stats-core"," \tstats-radio\t ","stats-packets", + "get usb.mode", "GET usb.mode", "GeT usb.mode", "xy|get usb.mode", "xy|\t get usb.mode", " \txy|GeT usb.mode", "\x01" "get name","get\x7f","\xff" "get name","get name\n","stats-core\r","get\rname"}) { mesh::noteUsbConsoleCommand(ignored);assert(mesh::isUsbPacketLoggingAllowed()); } - for(const char* human:{"get name","stats-core extra","stats-radiox","stats-packets 1"}) { + for(const char* human:{"get name","stats-core extra","stats-radiox","stats-packets 1", + "get usb.mode ", "get usb.modex", "get usb.mode extra", "xy|get usb.mode extra", + "x|get usb.mode", "xyz|get usb.mode", "xy|set usb.mode tinyusb", "get USB.MODE"}) { mesh::noteUsbConsoleCommand(human);assert(!mesh::isUsbPacketLoggingAllowed()); mesh::resumeUsbPacketLogging(); } mesh::noteUsbConsoleCommand("get name"); assert(mesh::isUsbLoggingEnabled()&&!mesh::isUsbPacketLoggingAllowed()); - g_mock_millis=299999;assert(!mesh::isUsbPacketLoggingAllowed()); + g_mock_millis=299999;mesh::noteUsbConsoleCommand("xy|GET usb.mode"); + assert(!mesh::isUsbPacketLoggingAllowed()); ++g_mock_millis;assert(mesh::isUsbPacketLoggingAllowed()); g_mock_millis+=0x80000000U;assert(mesh::isUsbPacketLoggingAllowed()); // Expiry latches. g_mock_millis=100;mesh::noteUsbConsoleCommand("get radio"); From 7390e2908611822372c15de7f0a2c7b302b0cca6 Mon Sep 17 00:00:00 2001 From: mikecarper Date: Wed, 28 Oct 2026 02:41:30 -0700 Subject: [PATCH 07/61] Keep USB mode reporting out of large command dispatchers --- src/helpers/UsbMode.h | 28 +++++++++++++++++++--------- 1 file changed, 19 insertions(+), 9 deletions(-) diff --git a/src/helpers/UsbMode.h b/src/helpers/UsbMode.h index 2a5c2fe31..a81db1eb5 100644 --- a/src/helpers/UsbMode.h +++ b/src/helpers/UsbMode.h @@ -1,7 +1,6 @@ #pragma once #include -#include #include #include @@ -12,21 +11,25 @@ namespace mesh { // Include after Arduino.h. The caller supplies decltype(Serial), after any // board-specific alias is visible, so a custom primary UART cannot inherit a // native-USB claim merely because the unused USB peripheral has those flags. -template +template inline const char* usbModeName() { #if defined(ESP32) || defined(ESP32_PLATFORM) - if (std::is_same::value) return "uart"; + if (std::is_same::value) + return WithPrefix ? "usb.mode uart" : "uart"; #if defined(ARDUINO_USB_CDC_ON_BOOT) && ARDUINO_USB_CDC_ON_BOOT #if defined(ARDUINO_USB_MODE) && ARDUINO_USB_MODE == 1 - if (std::is_same::value) return "hardware-cdc-jtag"; + if (std::is_same::value) + return WithPrefix ? "usb.mode hardware-cdc-jtag" : "hardware-cdc-jtag"; #elif defined(ARDUINO_USB_MODE) && ARDUINO_USB_MODE == 0 - if (std::is_same::value) return "tinyusb"; + if (std::is_same::value) + return WithPrefix ? "usb.mode tinyusb" : "tinyusb"; #endif #endif #elif defined(NRF52_PLATFORM) && defined(USE_TINYUSB) - if (std::is_same::value) return "tinyusb"; + if (std::is_same::value) + return WithPrefix ? "usb.mode tinyusb" : "tinyusb"; #endif - return "unknown"; + return WithPrefix ? "usb.mode unknown" : "unknown"; } inline bool isUsbModeCommand(const char* command) { @@ -36,10 +39,17 @@ inline bool isUsbModeCommand(const char* command) { // Read-only, bounded and independent of diagnostic/logging build flags. // No setter: changing a USB backend requires installing another application. template -inline bool handleUsbModeCommand(const char* command, char* reply, +#if defined(__GNUC__) +__attribute__((noinline)) +#endif +bool handleUsbModeCommand(const char* command, char* reply, size_t capacity, const SerialType&) { if (!isUsbModeCommand(command) || reply == nullptr || capacity == 0) return false; - snprintf(reply, capacity, "usb.mode %s", usbModeName()); + const char* response = usbModeName(); + size_t length = strlen(response); + if (length >= capacity) length = capacity - 1; + memcpy(reply, response, length); + reply[length] = 0; return true; } From 4261889095af82a9fc6b4b17870056aef10c43fd Mon Sep 17 00:00:00 2001 From: mikecarper Date: Wed, 28 Oct 2026 02:51:56 -0700 Subject: [PATCH 08/61] Recover constrained firmware flash and update USB dispatch fixtures --- examples/companion_radio/MyMesh.cpp | 195 +++++++++--------- examples/companion_radio/MyMesh.h | 1 + scripts/stm32_mini_size.py | 12 ++ test/fixtures/companion_meshtext.cpp | 1 + test/fixtures/companion_rxps_setters.cpp | 7 +- .../test_companion.cpp | 10 +- test/fixtures/usb_mode_console.h | 23 +++ test/test_companion_channel_alias.py | 3 + test/test_companion_channel_publication.py | 2 + test/test_companion_fleet_control.py | 6 + test/test_companion_meshtext.py | 2 +- ...est_companion_primary_radio_persistence.py | 1 + test/test_companion_reader.py | 1 + test/test_companion_rxps_getters.py | 1 + test/test_stm32_mini_repeater_profile.py | 32 +++ 15 files changed, 195 insertions(+), 102 deletions(-) create mode 100644 test/fixtures/usb_mode_console.h diff --git a/examples/companion_radio/MyMesh.cpp b/examples/companion_radio/MyMesh.cpp index 6c6449eda..1462194ca 100644 --- a/examples/companion_radio/MyMesh.cpp +++ b/examples/companion_radio/MyMesh.cpp @@ -6426,6 +6426,105 @@ bool MyMesh::handleDirectOtaCommand(const char* command, uint32_t timestamp, } #endif +__attribute__((noinline)) +void MyMesh::handleSetChannelFrame(const uint8_t* cmd_frame) { + uint8_t channel_idx = cmd_frame[1]; + ChannelDetails previous{}; + ChannelDetails channel; + StrHelper::strncpy(channel.name, (char *)&cmd_frame[2], 32); + memset(channel.channel.secret, 0, sizeof(channel.channel.secret)); + memcpy(channel.channel.secret, &cmd_frame[2 + 32], 16); // NOTE: only 128-bit supported + const auto alias = parseCompanionChannelAlias(channel.name); + if (alias.kind == CompanionChannelKind::Invalid) { + writeErrFrame(ERR_CODE_ILLEGAL_ARG); + return; + } +#if MESH_ENABLE_MESHTEXT_CHAT + // Legacy apps fetch only 16 bytes. A label-only edit must not silently + // truncate a provisioned MT AES-256 key to AES-128. An explicit 32-byte SET + // (zero upper half for AES-128) is the unambiguous way to replace that key. + if (alias.kind == CompanionChannelKind::Meshtext + && getChannelForSave(channel_idx, previous) && isMeshtextChannel(previous) + && !memcmp(channel.channel.secret, previous.channel.secret, 16)) { + memcpy(channel.channel.secret + 16, previous.channel.secret + 16, 16); + } + if (!validateMeshtextChannelChange(channel_idx, channel)) { + writeErrFrame(ERR_CODE_BAD_STATE); // Conflicting preset, manual RF, region or busy owner. + return; + } +#else + if (alias.kind == CompanionChannelKind::Meshtext && isAliasRadio2Defined()) { + writeErrFrame(ERR_CODE_UNSUPPORTED_CMD); // Meshtext transport not implemented yet. + return; + } +#endif + const bool have_previous = getChannelForSave(channel_idx, previous); + ChannelDetails wire_channel = channel; + ChannelDetails previous_wire_channel = previous; + resolveChannelAlias(wire_channel); + if (have_previous) resolveChannelAlias(previous_wire_channel); + if (have_previous && channel.name[0] != 0 && previous.name[0] != 0 + && !memcmp(wire_channel.channel.secret, previous_wire_channel.channel.secret, sizeof(channel.channel.secret))) { + channel.channel.tx_radio = previous.channel.tx_radio; + } + const char* preset = companionChannelRadio2Preset(channel.name); + const char* previous_preset = companionChannelRadio2Preset(previous.name); + // A join or rename selects the saved preset, just like set radio2. App + // replays/key edits must not undo a later manual Off or custom RF setting. + const bool setup_preset = have_previous && preset + && (!previous_preset || strcmp(preset, previous_preset)); + if (setup_preset) { +#if !MESH_ENABLE_MESHTEXT_CHAT + // Even without the foreign-protocol backend, do not turn old fallback + // mt- channels into inactive protocol requests as a hidden side effect. + for (int i = 0; i < MAX_GROUP_CHANNELS; ++i) { + ChannelDetails other; + if (i != channel_idx && BaseChatMesh::getChannel(i, other) + && parseCompanionChannelAlias(other.name).kind == CompanionChannelKind::Meshtext) { + writeErrFrame(ERR_CODE_BAD_STATE); + return; + } + } +#endif + if (!_radio_profiles.stageChannelPreset(preset)) { + writeErrFrame(ERR_CODE_BAD_STATE); + return; + } + } + if (!have_previous + || !setChannel(channel_idx, channel)) { + if (setup_preset) _radio_profiles.finishChannelPreset(false); + writeErrFrame(ERR_CODE_NOT_FOUND); // bad channel_idx + } else if (saveChannels()) { + if (setup_preset && !_radio_profiles.finishChannelPreset(true)) { + // Never report a complete setup when the RF image did not commit. + // Restore the channel when storage permits; report failure even if + // an indeterminate channels write has quarantined further saves. + setChannel(channel_idx, previous); + saveChannels(); + _radio_profiles.finishChannelPreset(false); + writeErrFrame(ERR_CODE_FILE_IO_ERROR); + return; + } +#if MESH_ENABLE_MESHTEXT_CHAT + _mt_seen.clear(); + _mt_profile_dirty = true; + refreshMeshtextProfile(); +#endif + writeOKFrame(); + // Local history seed only after the durable save and its acknowledgement. + seedEmergencyChannelNotice(channel_idx, previous); + } else { + // AtomicFileWriter preserves the last complete channels file. Keep the + // live table consistent with it when a full or failing filesystem cannot + // commit this update, and never acknowledge a change that will disappear + // after reboot. + setChannel(channel_idx, previous); + if (setup_preset) _radio_profiles.finishChannelPreset(false); + writeErrFrame(ERR_CODE_FILE_IO_ERROR); + } +} + void MyMesh::handleCmdFrame(size_t len) { if (len == 0) { writeErrFrame(ERR_CODE_ILLEGAL_ARG); @@ -7453,101 +7552,7 @@ void MyMesh::handleCmdFrame(size_t len) { writeErrFrame(ERR_CODE_UNSUPPORTED_CMD); #endif } else if (cmd_frame[0] == CMD_SET_CHANNEL && len >= 2 + 32 + 16) { - uint8_t channel_idx = cmd_frame[1]; - ChannelDetails previous{}; - ChannelDetails channel; - StrHelper::strncpy(channel.name, (char *)&cmd_frame[2], 32); - memset(channel.channel.secret, 0, sizeof(channel.channel.secret)); - memcpy(channel.channel.secret, &cmd_frame[2 + 32], 16); // NOTE: only 128-bit supported - const auto alias = parseCompanionChannelAlias(channel.name); - if (alias.kind == CompanionChannelKind::Invalid) { - writeErrFrame(ERR_CODE_ILLEGAL_ARG); - return; - } -#if MESH_ENABLE_MESHTEXT_CHAT - // Legacy apps fetch only 16 bytes. A label-only edit must not silently - // truncate a provisioned MT AES-256 key to AES-128. An explicit 32-byte SET - // (zero upper half for AES-128) is the unambiguous way to replace that key. - if (alias.kind == CompanionChannelKind::Meshtext - && getChannelForSave(channel_idx, previous) && isMeshtextChannel(previous) - && !memcmp(channel.channel.secret, previous.channel.secret, 16)) { - memcpy(channel.channel.secret + 16, previous.channel.secret + 16, 16); - } - if (!validateMeshtextChannelChange(channel_idx, channel)) { - writeErrFrame(ERR_CODE_BAD_STATE); // Conflicting preset, manual RF, region or busy owner. - return; - } -#else - if (alias.kind == CompanionChannelKind::Meshtext && isAliasRadio2Defined()) { - writeErrFrame(ERR_CODE_UNSUPPORTED_CMD); // Meshtext transport not implemented yet. - return; - } -#endif - const bool have_previous = getChannelForSave(channel_idx, previous); - ChannelDetails wire_channel = channel; - ChannelDetails previous_wire_channel = previous; - resolveChannelAlias(wire_channel); - if (have_previous) resolveChannelAlias(previous_wire_channel); - if (have_previous && channel.name[0] != 0 && previous.name[0] != 0 - && !memcmp(wire_channel.channel.secret, previous_wire_channel.channel.secret, sizeof(channel.channel.secret))) { - channel.channel.tx_radio = previous.channel.tx_radio; - } - const char* preset = companionChannelRadio2Preset(channel.name); - const char* previous_preset = companionChannelRadio2Preset(previous.name); - // A join or rename selects the saved preset, just like set radio2. App - // replays/key edits must not undo a later manual Off or custom RF setting. - const bool setup_preset = have_previous && preset - && (!previous_preset || strcmp(preset, previous_preset)); - if (setup_preset) { -#if !MESH_ENABLE_MESHTEXT_CHAT - // Even without the foreign-protocol backend, do not turn old fallback - // mt- channels into inactive protocol requests as a hidden side effect. - for (int i = 0; i < MAX_GROUP_CHANNELS; ++i) { - ChannelDetails other; - if (i != channel_idx && BaseChatMesh::getChannel(i, other) - && parseCompanionChannelAlias(other.name).kind == CompanionChannelKind::Meshtext) { - writeErrFrame(ERR_CODE_BAD_STATE); - return; - } - } -#endif - if (!_radio_profiles.stageChannelPreset(preset)) { - writeErrFrame(ERR_CODE_BAD_STATE); - return; - } - } - if (!have_previous - || !setChannel(channel_idx, channel)) { - if (setup_preset) _radio_profiles.finishChannelPreset(false); - writeErrFrame(ERR_CODE_NOT_FOUND); // bad channel_idx - } else if (saveChannels()) { - if (setup_preset && !_radio_profiles.finishChannelPreset(true)) { - // Never report a complete setup when the RF image did not commit. - // Restore the channel when storage permits; report failure even if - // an indeterminate channels write has quarantined further saves. - setChannel(channel_idx, previous); - saveChannels(); - _radio_profiles.finishChannelPreset(false); - writeErrFrame(ERR_CODE_FILE_IO_ERROR); - return; - } -#if MESH_ENABLE_MESHTEXT_CHAT - _mt_seen.clear(); - _mt_profile_dirty = true; - refreshMeshtextProfile(); -#endif - writeOKFrame(); - // Local history seed only after the durable save and its acknowledgement. - seedEmergencyChannelNotice(channel_idx, previous); - } else { - // AtomicFileWriter preserves the last complete channels file. Keep the - // live table consistent with it when a full or failing filesystem cannot - // commit this update, and never acknowledge a change that will disappear - // after reboot. - setChannel(channel_idx, previous); - if (setup_preset) _radio_profiles.finishChannelPreset(false); - writeErrFrame(ERR_CODE_FILE_IO_ERROR); - } + handleSetChannelFrame(cmd_frame); } else if (cmd_frame[0] == CMD_SIGN_START) { BaseSerialInterface* signing_route = _serial->captureReplyRoute(); if (signing_route == NULL) { diff --git a/examples/companion_radio/MyMesh.h b/examples/companion_radio/MyMesh.h index 1d0b85818..e8ac56423 100644 --- a/examples/companion_radio/MyMesh.h +++ b/examples/companion_radio/MyMesh.h @@ -332,6 +332,7 @@ public: // constrained STM32 targets; merging their branches increases code size. __attribute__((noinline)) #endif + void handleSetChannelFrame(const uint8_t* cmd_frame); void handleCmdFrame(size_t len); bool advert(); void enterCLIRescue(); diff --git a/scripts/stm32_mini_size.py b/scripts/stm32_mini_size.py index 82f2087cd..79cb33378 100644 --- a/scripts/stm32_mini_size.py +++ b/scripts/stm32_mini_size.py @@ -6,6 +6,18 @@ Import("env") # to the LTO driver automatically. Keep this list matched by the profile test. # No compiler/ABI, flash boundary or capacity check is overridden here. if env["PIOENV"] == "wio-e5-mini_repeater": + import importlib.util + from pathlib import Path + + # Reuse the qualified RAK CLI's equivalent libc reply copies. Restrict + # this flag to CommonCLI.cpp; radio, framework and other objects retain + # the existing optimizer recipe. + source = Path(__file__).resolve().with_name("nrf52_flash_trim.py") + spec = importlib.util.spec_from_file_location("mesh_mini_cli_replies", source) + replies = importlib.util.module_from_spec(spec) + spec.loader.exec_module(replies) + env.AddBuildMiddleware(replies.compile_shared_cli_replies, "*CommonCLI.cpp") + env.Append(LINKFLAGS=[ "-fno-caller-saves", "-fno-tree-dominator-opts", diff --git a/test/fixtures/companion_meshtext.cpp b/test/fixtures/companion_meshtext.cpp index 70103d83b..76a9b8a62 100644 --- a/test/fixtures/companion_meshtext.cpp +++ b/test/fixtures/companion_meshtext.cpp @@ -206,6 +206,7 @@ public: void writeErrFrame(uint8_t value) { error=value; } void writeOKFrame() { ok=true; } bool sendGroupMessage(uint32_t,mesh::GroupChannel&,const char*,const char*,int) { ++meshcore_sends; return true; } + void handleSetChannelFrame(const uint8_t*); void command(uint8_t* cmd_frame,int len); void commit(int index,const ChannelDetails& value) { assert(setChannel(index,value)); _mt_profile_dirty=true; refreshMeshtextProfile(); diff --git a/test/fixtures/companion_rxps_setters.cpp b/test/fixtures/companion_rxps_setters.cpp index 970fd247b..0df9d5810 100644 --- a/test/fixtures/companion_rxps_setters.cpp +++ b/test/fixtures/companion_rxps_setters.cpp @@ -9,6 +9,7 @@ #include #include #include +#include "usb_mode_console.h" namespace mesh {enum class RadioParamApplyResult {APPLIED,BUSY,FAILED};} struct Prefs { @@ -40,7 +41,7 @@ struct Driver { } } radio_driver; struct BaseSerialInterface {}; -struct Serial { +struct SerialTransport { BaseSerialInterface requester, other; BaseSerialInterface* current = &requester; BaseSerialInterface* reply_route = nullptr; @@ -54,8 +55,8 @@ struct Serial { }; struct MyMesh { Prefs _prefs, durable; - Serial serial; - Serial* _serial = &serial; + SerialTransport serial; + SerialTransport* _serial = &serial; bool _radio_available = true, storage_accepts = true, saved_radio_apply_pending = false; bool _primary_radio_blocked=false; unsigned saves = 0; diff --git a/test/fixtures/nrf52_bootloader_version/test_companion.cpp b/test/fixtures/nrf52_bootloader_version/test_companion.cpp index 65f1ef5f7..a04cb2220 100644 --- a/test/fixtures/nrf52_bootloader_version/test_companion.cpp +++ b/test/fixtures/nrf52_bootloader_version/test_companion.cpp @@ -9,6 +9,7 @@ #include #include #include +#include "../usb_mode_console.h" struct Board { #if defined(NRF52_PLATFORM) @@ -31,7 +32,7 @@ struct Board { #endif }; struct BaseSerialInterface { unsigned id; }; -struct Serial { +struct SerialTransport { BaseSerialInterface usb{1}, ble{2}; BaseSerialInterface* current = &usb; BaseSerialInterface* captured = nullptr; @@ -62,8 +63,8 @@ struct Output { }; struct MyMesh { Board board; - Serial serial; - Serial* _serial = &serial; + SerialTransport serial; + SerialTransport* _serial = &serial; Output output; char reply_buf[160] = {}; uint8_t out_frame[MAX_FRAME_SIZE] = {}; @@ -142,6 +143,9 @@ static void dispatch(MyMesh& mesh, BaseSerialInterface* requester, bool nul) { } int main() { MyMesh mesh; + char usb_reply[160] = {}; + assert(mesh.handleCommand("AB|get usb.mode", 0, usb_reply)); + assert(std::string(usb_reply) == std::string("AB|usb.mode ") + mesh::usbModeName()); bounds(mesh); #if defined(NRF52_PLATFORM) assert(mesh.board.reader_capacity == 128); diff --git a/test/fixtures/usb_mode_console.h b/test/fixtures/usb_mode_console.h new file mode 100644 index 000000000..1c2e6a2a9 --- /dev/null +++ b/test/fixtures/usb_mode_console.h @@ -0,0 +1,23 @@ +#pragma once + +// Dispatch fixtures have no Arduino console, but must retain the real USB-mode +// handler. Model its backend types separately from each fixture's framed reply +// transport; no console reads, writes or USB peripherals are needed here. +struct HardwareSerial {}; +struct HWCDC {}; +struct USBCDC {}; +struct Adafruit_USBD_CDC {}; +#if (defined(ESP32) || defined(ESP32_PLATFORM)) \ + && defined(ARDUINO_USB_CDC_ON_BOOT) && ARDUINO_USB_CDC_ON_BOOT + #if defined(ARDUINO_USB_MODE) && ARDUINO_USB_MODE == 1 +inline HWCDC Serial; + #else +inline USBCDC Serial; + #endif +#elif defined(NRF52_PLATFORM) && defined(USE_TINYUSB) +inline Adafruit_USBD_CDC Serial; +#else +inline HardwareSerial Serial; +#endif + +#include diff --git a/test/test_companion_channel_alias.py b/test/test_companion_channel_alias.py index 39acaf50c..f70154efd 100644 --- a/test/test_companion_channel_alias.py +++ b/test/test_companion_channel_alias.py @@ -404,6 +404,7 @@ struct Serial { ''' COMMAND_MEMBERS = r''' + void handleSetChannelFrame(const uint8_t*); Serial serial; Serial* _serial = &serial; struct ProfileSetup { bool stage_ok = true, commit_ok = true, pending = false; @@ -1027,6 +1028,7 @@ class CompanionChannelAliasTest(unittest.TestCase): "if (cmd_frame[0] == CMD_SET_CHANNEL && len >= 2 + 32 + 32)", "if (cmd_frame[0] == CMD_SET_CHANNEL && len >= 2 + 32 + 16)", ) + methods += "\n" + body(companion, "void MyMesh::handleSetChannelFrame(") branches = " else ".join(body(companion, signature) for signature in command_signatures) macros = ("CMD_GET_CHANNEL", "CMD_SET_CHANNEL", "RESP_CODE_CHANNEL_INFO", "ERR_CODE_UNSUPPORTED_CMD", "ERR_CODE_NOT_FOUND", "ERR_CODE_FILE_IO_ERROR", @@ -1166,6 +1168,7 @@ int main() { "MyMesh::Frame& MyMesh::heldDMFrameAt(", "void MyMesh::removeHeldOneKeyDM(", )) + "\n#endif\n" + methods += "\n" + body(companion, "void MyMesh::handleSetChannelFrame(") branches = " else ".join(body(companion, signature) for signature in ( "if (cmd_frame[0] == CMD_GET_CHANNEL && len >= 2)", "if (cmd_frame[0] == CMD_SET_CHANNEL && len >= 2 + 32 + 32)", diff --git a/test/test_companion_channel_publication.py b/test/test_companion_channel_publication.py index 993be28cc..1c60dc04d 100644 --- a/test/test_companion_channel_publication.py +++ b/test/test_companion_channel_publication.py @@ -88,6 +88,7 @@ def publication_fixture(work, missing_latch, skip_noop_save=False): methods = recovery + "\n" + route methods += "\n" + method(store, "void DataStore::loadChannels(") methods += "\n" + save + "\n" + method(store, "bool DataStore::hasIncompleteContactLoad(") + methods += "\n" + method(mesh, "void MyMesh::handleSetChannelFrame(") binary_start = mesh.index(" } else if (cmd_frame[0] == CMD_SET_CHANNEL && len >= 2 + 32 + 16) {") binary_end = mesh.index(" } else if (cmd_frame[0] == CMD_SIGN_START)", binary_start) methods += ("\nvoid MyMesh::applyChannelFrame(const uint8_t* cmd_frame, size_t len) {\n" @@ -129,6 +130,7 @@ struct StrHelper { void writeOKFrame() { binary_ack = true; } void seedEmergencyChannelNotice(uint8_t, const ChannelDetails&) {} bool resolveChannelAlias(ChannelDetails&) const { return true; } + void handleSetChannelFrame(const uint8_t*); void applyChannelFrame(const uint8_t*, size_t); """, 1) source = source.replace("@RECOVERY_METHOD@", method(header, " bool contactRecoveryPending() const")) diff --git a/test/test_companion_fleet_control.py b/test/test_companion_fleet_control.py index c41dd5f64..be9748435 100644 --- a/test/test_companion_fleet_control.py +++ b/test/test_companion_fleet_control.py @@ -29,6 +29,7 @@ HARNESS = r''' #include #include #include "regulator_cli_stub.h" +#include "usb_mode_console.h" #include #include #include @@ -364,6 +365,10 @@ static void compactBroadcast(MyMesh& node, const char* command) { int main(int argc, char** argv) { assert(argc == 2); + MyMesh usb_probe; + char usb_reply[160] = {}; + assert(usb_probe.handleCommand("AB|get usb.mode", 0, usb_reply)); + assert(std::string(usb_reply) == "AB|usb.mode unknown" && usb_probe.queued == 0); const std::string scenario = argv[1]; MyMesh node; char reply[160] = {}; if (scenario == "local_boundary") { @@ -1513,6 +1518,7 @@ class CompanionFleetControlTests(unittest.TestCase): #include #include #include "regulator_cli_stub.h" +#include "usb_mode_console.h" static_assert(MESH_ENABLE_FLEET_CONTROL == 0, "compact profile must disable fleet"); // No channels, clock, signing identity, codec definitions or sendGroupData. class MyMesh { diff --git a/test/test_companion_meshtext.py b/test/test_companion_meshtext.py index f07311aa2..605083754 100644 --- a/test/test_companion_meshtext.py +++ b/test/test_companion_meshtext.py @@ -60,7 +60,7 @@ class CompanionMeshtextTest(unittest.TestCase): "static bool isEmergencyNoticeChannelName(", "void MyMesh::seedEmergencyChannelNotice(", "void MyMesh::servicePendingChannelNotices(", "bool MyMesh::queueChannelText(", - "bool MyMesh::saveAdvertName(") + "bool MyMesh::saveAdvertName(", "void MyMesh::handleSetChannelFrame(") methods = "\n".join(body(source, signature) for signature in signatures) utils_source = (ROOT / "src/Utils.cpp").read_text(encoding="utf-8") # CLI keys use the production strict hexadecimal parser as well. diff --git a/test/test_companion_primary_radio_persistence.py b/test/test_companion_primary_radio_persistence.py index c431e77cd..7746c6b38 100644 --- a/test/test_companion_primary_radio_persistence.py +++ b/test/test_companion_primary_radio_persistence.py @@ -22,6 +22,7 @@ HARNESS = r''' #include #include "companion_fleet_dispatch.h" #include "battery_calibration_cli_stub.h" +#include "usb_mode_console.h" #include #include #define constrain(value,low,high) ((value)<(low)?(low):((value)>(high)?(high):(value))) diff --git a/test/test_companion_reader.py b/test/test_companion_reader.py index 4373d2f9c..0fb1eeeec 100644 --- a/test/test_companion_reader.py +++ b/test/test_companion_reader.py @@ -392,6 +392,7 @@ int main() { #include #include #include "battery_calibration_cli_stub.h" +#include "usb_mode_console.h" struct BatteryBoard {} board; struct Radio { bool active = false; }; namespace mesh { diff --git a/test/test_companion_rxps_getters.py b/test/test_companion_rxps_getters.py index 81bcc95cd..9607b19b3 100644 --- a/test/test_companion_rxps_getters.py +++ b/test/test_companion_rxps_getters.py @@ -282,6 +282,7 @@ int main() { built = subprocess.run([ compiler, "-std=c++17", "-Wall", "-Wextra", "-Werror", *sanitizers, "-DCOMPANION_FEATURE_TEMP_RADIO=0", "-I", str(ROOT / "src"), + "-I", str(ROOT / "test/fixtures"), "-isystem", str(ROOT / "test/mocks"), str(cpp), str(ROOT / "src/helpers/radiolib/RXPowerSaving.cpp"), "-o", str(binary), ], capture_output=True, text=True, timeout=60) diff --git a/test/test_stm32_mini_repeater_profile.py b/test/test_stm32_mini_repeater_profile.py index a691d7c56..91ca5ef6f 100644 --- a/test/test_stm32_mini_repeater_profile.py +++ b/test/test_stm32_mini_repeater_profile.py @@ -39,6 +39,15 @@ def read_config(path): class FakeEnvironment(dict): + def AddBuildMiddleware(self, callback, pattern): + self.middleware = getattr(self, "middleware", []) + [(callback, pattern)] + + def subst(self, value): + return value.replace("$PROJECT_DIR", str(ROOT)) + + def Object(self, node, **flags): + return node, flags + def Append(self, **values): for name, items in values.items(): self.setdefault(name, []).extend(items) @@ -126,6 +135,29 @@ class MiniRepeaterProfileTest(unittest.TestCase): expected = ["keep-existing-flag", *(LINK_SIZE_FLAGS if target == TARGET else ())] self.assertEqual(environment["LINKFLAGS"], expected) self.assertEqual(set(environment), {"PIOENV", "LINKFLAGS"}) + self.assertEqual(len(getattr(environment, "middleware", [])), + int(target == TARGET)) + + def test_shared_reply_copy_policy_changes_only_the_exact_mini_cli_object(self): + class Node: + def __init__(self, path): self.path = path + def srcnode(self): return self + def get_abspath(self): return str(self.path) + + environment = FakeEnvironment(PIOENV=TARGET, CCFLAGS=["-Oz", "-flto"]) + runpy.run_path(str(ROOT / "scripts/stm32_mini_size.py"), init_globals={ + "env": environment, "Import": lambda name: self.assertEqual(name, "env")}) + callback, pattern = environment.middleware[0] + self.assertEqual(pattern, "*CommonCLI.cpp") + cli = Node(ROOT / "src/helpers/CommonCLI.cpp") + same, flags = callback(environment, cli) + self.assertIs(same, cli) + self.assertEqual(flags["CCFLAGS"], ["-Oz", "-flto", "-fno-builtin-strcpy"]) + self.assertEqual(environment["CCFLAGS"], ["-Oz", "-flto"]) + for path in ("test/fixtures/CommonCLI.cpp", "src/helpers/RadioProfileCLI.cpp", + "src/helpers/UsbLogging.cpp"): + other = Node(ROOT / path) + self.assertIs(callback(environment, other), other) def test_capability_contract_declares_only_exact_mini_repeater_omissions(self): command = r''' From b7c048f3f7ccf8ecf3eee63c35b417f03a136e53 Mon Sep 17 00:00:00 2001 From: mikecarper Date: Wed, 28 Oct 2026 02:53:27 -0700 Subject: [PATCH 09/61] Resolve Mini build helpers from the PlatformIO project directory --- scripts/stm32_mini_size.py | 2 +- test/test_stm32_mini_repeater_profile.py | 11 +++++++++++ 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/scripts/stm32_mini_size.py b/scripts/stm32_mini_size.py index 79cb33378..55718c987 100644 --- a/scripts/stm32_mini_size.py +++ b/scripts/stm32_mini_size.py @@ -12,7 +12,7 @@ if env["PIOENV"] == "wio-e5-mini_repeater": # Reuse the qualified RAK CLI's equivalent libc reply copies. Restrict # this flag to CommonCLI.cpp; radio, framework and other objects retain # the existing optimizer recipe. - source = Path(__file__).resolve().with_name("nrf52_flash_trim.py") + source = Path(env.subst("$PROJECT_DIR/scripts/nrf52_flash_trim.py")).resolve() spec = importlib.util.spec_from_file_location("mesh_mini_cli_replies", source) replies = importlib.util.module_from_spec(spec) spec.loader.exec_module(replies) diff --git a/test/test_stm32_mini_repeater_profile.py b/test/test_stm32_mini_repeater_profile.py index 91ca5ef6f..3e2e28242 100644 --- a/test/test_stm32_mini_repeater_profile.py +++ b/test/test_stm32_mini_repeater_profile.py @@ -159,6 +159,17 @@ class MiniRepeaterProfileTest(unittest.TestCase): other = Node(ROOT / path) self.assertIs(callback(environment, other), other) + def test_scons_script_context_without_file_still_installs_exact_cli_policy(self): + script = ROOT / "scripts/stm32_mini_size.py" + environment = FakeEnvironment(PIOENV=TARGET) + namespace = {"env": environment, + "Import": lambda name: self.assertEqual(name, "env")} + exec(compile(script.read_text(), str(script), "exec"), namespace) + self.assertNotIn("__file__", namespace) + self.assertEqual(environment["LINKFLAGS"], list(LINK_SIZE_FLAGS)) + self.assertEqual(len(environment.middleware), 1) + self.assertEqual(environment.middleware[0][1], "*CommonCLI.cpp") + def test_capability_contract_declares_only_exact_mini_repeater_omissions(self): command = r''' set -euo pipefail From ce79ea8ee27dc76446e8eaa4f2eb3f70084fdd05 Mon Sep 17 00:00:00 2001 From: mikecarper Date: Wed, 28 Oct 2026 02:56:31 -0700 Subject: [PATCH 10/61] Share literal reply copies in the constrained RAK Full Companion --- scripts/nrf52_flash_trim.py | 28 +++++++++++++++++++++++++--- test/test_nrf52_flash_trim.py | 27 +++++++++++++++++++++++++++ 2 files changed, 52 insertions(+), 3 deletions(-) diff --git a/scripts/nrf52_flash_trim.py b/scripts/nrf52_flash_trim.py index 9efa478bd..104243055 100644 --- a/scripts/nrf52_flash_trim.py +++ b/scripts/nrf52_flash_trim.py @@ -259,10 +259,9 @@ def compile_packed_help(build_env, node): return build_env.Object(node, **flags) -def compile_shared_cli_replies(build_env, node): - """Keep constant reply copies out of line in the qualified CLI object only.""" +def _compile_shared_reply_copies(build_env, node, relative_path): source = Path(node.srcnode().get_abspath()).resolve() - expected = Path(build_env.subst("$PROJECT_DIR/src/helpers/CommonCLI.cpp")).resolve() + expected = Path(build_env.subst("$PROJECT_DIR/" + relative_path)).resolve() if source != expected: return node # GCC otherwise expands many constant strcpy replies into repeated stores. @@ -273,6 +272,25 @@ def compile_shared_cli_replies(build_env, node): return build_env.Object(node, CCFLAGS=flags) +def compile_shared_cli_replies(build_env, node): + """Keep constant reply copies out of line in the qualified CLI object only.""" + return _compile_shared_reply_copies(build_env, node, "src/helpers/CommonCLI.cpp") + + +def compile_shared_companion_replies(build_env, node): + return _compile_shared_reply_copies(build_env, node, "examples/companion_radio/MyMesh.cpp") + + +def companion_shared_replies_policy(platform, mcu, env_name, src_filter, + flags=(), cppdefines=()): + if env_name != "RAK_4631_companion_radio_full" or str(mcu).lower() != "nrf52840": + return False + values = flag_defines(flags, cppdefines) + return (bool(companion_trim_policy(platform, mcu, src_filter, flags, cppdefines)) + and ("MESH_CLI_SHARE_REPLIES" not in values + or _enabled(values["MESH_CLI_SHARE_REPLIES"]))) + + def install(build_env): platform = build_env.PioPlatform() mcu = build_env.BoardConfig().get("build.mcu", "") @@ -304,6 +322,10 @@ def install(build_env): if rak_repeater and _enabled(values.get("MESH_CLI_SHARE_REPLIES", definitions.get("MESH_CLI_SHARE_REPLIES"))): build_env.AddBuildMiddleware(compile_shared_cli_replies, "*CommonCLI.cpp") + if not infrastructure and companion_shared_replies_policy( + platform.name, mcu, build_env["PIOENV"], build_env.get("SRC_FILTER", ()), + flags, cppdefines): + build_env.AddBuildMiddleware(compile_shared_companion_replies, "*MyMesh.cpp") if infrastructure: names = ("local OTA target name only" if definitions.get("OTA_TARGET_NAME_TABLE") == 0 or flag_defines(flags, cppdefines).get("OTA_TARGET_NAME_TABLE") == "0" diff --git a/test/test_nrf52_flash_trim.py b/test/test_nrf52_flash_trim.py index 9b4788840..74dad3fbc 100644 --- a/test/test_nrf52_flash_trim.py +++ b/test/test_nrf52_flash_trim.py @@ -124,6 +124,33 @@ class Nrf52FlashTrimTest(unittest.TestCase): other = Node(ROOT / "test/fixtures/CommonCLI.cpp") self.assertIs(TRIM.compile_shared_cli_replies(env, other), other) + companion = Node(ROOT / "examples/companion_radio/MyMesh.cpp") + result, flags = TRIM.compile_shared_companion_replies(env, companion) + self.assertIs(result, companion) + self.assertEqual(flags["CCFLAGS"], ["-Os", "-flto", "-fno-builtin-strcpy"]) + self.assertEqual(env["CCFLAGS"], ["-Os", "-flto"]) + for path in ("examples/simple_repeater/MyMesh.cpp", "test/fixtures/MyMesh.cpp", + "src/helpers/CommonCLI.cpp", "src/helpers/RadioProfileCLI.cpp"): + other = Node(ROOT / path) + self.assertIs(TRIM.compile_shared_companion_replies(env, other), other) + + def test_companion_shared_replies_are_limited_to_the_qualified_rak_full_image(self): + defaults = dict(platform="nordicnrf52", mcu="nrf52840", + env_name="RAK_4631_companion_radio_full", + src_filter="+<../examples/companion_radio/*.cpp>") + self.assertTrue(TRIM.companion_shared_replies_policy(**defaults)) + for changes in (dict(env_name="RAK_3401_companion_radio_full"), + dict(env_name="RAK_4631_companion_radio_usb"), + dict(env_name="custom_companion_radio_full"), + dict(mcu="nrf52832"), dict(platform="ststm32"), + dict(src_filter="+<../examples/simple_repeater>"), + dict(src_filter="+<../examples/companion_radio> +<../examples/simple_sensor>"), + dict(flags="-DMESH_CLI_SHARE_REPLIES=0"), + dict(flags="-UMESH_CLI_SHARE_REPLIES"), + dict(flags="-DMESH_NRF52_COMPANION_TRIM=0")): + with self.subTest(changes=changes): + self.assertFalse(TRIM.companion_shared_replies_policy(**(defaults | changes))) + def test_local_names_do_not_change_companion_or_unqualified_roles(self): for options in (dict(name="RAK_4631_companion_radio_full"), dict(role="custom"), dict(platform="espressif32", mcu="esp32")): From eb7c307583080ea042b3544613d99920a521b991 Mon Sep 17 00:00:00 2001 From: mikecarper Date: Wed, 28 Oct 2026 02:57:28 -0700 Subject: [PATCH 11/61] Register Mini literal reply sharing before source compilation --- scripts/stm32_mini_cli_replies.py | 13 ++++++++ scripts/stm32_mini_size.py | 12 -------- test/test_stm32_mini_repeater_profile.py | 38 ++++++++++++++++++++---- variants/wio-e5-mini/platformio.ini | 1 + 4 files changed, 47 insertions(+), 17 deletions(-) create mode 100644 scripts/stm32_mini_cli_replies.py diff --git a/scripts/stm32_mini_cli_replies.py b/scripts/stm32_mini_cli_replies.py new file mode 100644 index 000000000..5b7bc4aa6 --- /dev/null +++ b/scripts/stm32_mini_cli_replies.py @@ -0,0 +1,13 @@ +"""Register equivalent Mini CLI reply copies before objects are constructed.""" + +Import("env") + +if env["PIOENV"] == "wio-e5-mini_repeater": + import importlib.util + from pathlib import Path + + source = Path(env.subst("$PROJECT_DIR/scripts/nrf52_flash_trim.py")).resolve() + spec = importlib.util.spec_from_file_location("mesh_mini_cli_replies", source) + replies = importlib.util.module_from_spec(spec) + spec.loader.exec_module(replies) + env.AddBuildMiddleware(replies.compile_shared_cli_replies, "*CommonCLI.cpp") diff --git a/scripts/stm32_mini_size.py b/scripts/stm32_mini_size.py index 55718c987..82f2087cd 100644 --- a/scripts/stm32_mini_size.py +++ b/scripts/stm32_mini_size.py @@ -6,18 +6,6 @@ Import("env") # to the LTO driver automatically. Keep this list matched by the profile test. # No compiler/ABI, flash boundary or capacity check is overridden here. if env["PIOENV"] == "wio-e5-mini_repeater": - import importlib.util - from pathlib import Path - - # Reuse the qualified RAK CLI's equivalent libc reply copies. Restrict - # this flag to CommonCLI.cpp; radio, framework and other objects retain - # the existing optimizer recipe. - source = Path(env.subst("$PROJECT_DIR/scripts/nrf52_flash_trim.py")).resolve() - spec = importlib.util.spec_from_file_location("mesh_mini_cli_replies", source) - replies = importlib.util.module_from_spec(spec) - spec.loader.exec_module(replies) - env.AddBuildMiddleware(replies.compile_shared_cli_replies, "*CommonCLI.cpp") - env.Append(LINKFLAGS=[ "-fno-caller-saves", "-fno-tree-dominator-opts", diff --git a/test/test_stm32_mini_repeater_profile.py b/test/test_stm32_mini_repeater_profile.py index 3e2e28242..12de6af14 100644 --- a/test/test_stm32_mini_repeater_profile.py +++ b/test/test_stm32_mini_repeater_profile.py @@ -135,8 +135,7 @@ class MiniRepeaterProfileTest(unittest.TestCase): expected = ["keep-existing-flag", *(LINK_SIZE_FLAGS if target == TARGET else ())] self.assertEqual(environment["LINKFLAGS"], expected) self.assertEqual(set(environment), {"PIOENV", "LINKFLAGS"}) - self.assertEqual(len(getattr(environment, "middleware", [])), - int(target == TARGET)) + self.assertFalse(getattr(environment, "middleware", [])) def test_shared_reply_copy_policy_changes_only_the_exact_mini_cli_object(self): class Node: @@ -145,7 +144,7 @@ class MiniRepeaterProfileTest(unittest.TestCase): def get_abspath(self): return str(self.path) environment = FakeEnvironment(PIOENV=TARGET, CCFLAGS=["-Oz", "-flto"]) - runpy.run_path(str(ROOT / "scripts/stm32_mini_size.py"), init_globals={ + runpy.run_path(str(ROOT / "scripts/stm32_mini_cli_replies.py"), init_globals={ "env": environment, "Import": lambda name: self.assertEqual(name, "env")}) callback, pattern = environment.middleware[0] self.assertEqual(pattern, "*CommonCLI.cpp") @@ -160,16 +159,45 @@ class MiniRepeaterProfileTest(unittest.TestCase): self.assertIs(callback(environment, other), other) def test_scons_script_context_without_file_still_installs_exact_cli_policy(self): - script = ROOT / "scripts/stm32_mini_size.py" + script = ROOT / "scripts/stm32_mini_cli_replies.py" environment = FakeEnvironment(PIOENV=TARGET) namespace = {"env": environment, "Import": lambda name: self.assertEqual(name, "env")} exec(compile(script.read_text(), str(script), "exec"), namespace) self.assertNotIn("__file__", namespace) - self.assertEqual(environment["LINKFLAGS"], list(LINK_SIZE_FLAGS)) + self.assertNotIn("LINKFLAGS", environment) self.assertEqual(len(environment.middleware), 1) self.assertEqual(environment.middleware[0][1], "*CommonCLI.cpp") + def test_middleware_is_registered_before_object_flags_are_captured(self): + environment = FakeEnvironment(PIOENV=TARGET, CCFLAGS=["-Oz", "-flto"]) + scripts = self.repeater["extra_scripts"].split() + pre = "pre:scripts/stm32_mini_cli_replies.py" + post = "post:scripts/stm32_mini_size.py" + self.assertIn(pre, scripts) + self.assertIn(post, scripts) + self.assertNotIn(pre.replace("pre:", "post:"), scripts) + + def run_script(entry): + runpy.run_path(str(ROOT / entry.split(":", 1)[1]), init_globals={ + "env": environment, "Import": lambda name: self.assertEqual(name, "env")}) + + # PlatformIO constructs source objects before executing post scripts. + run_script(pre) + callback, _ = environment.middleware[0] + class Node: + def srcnode(self): return self + def get_abspath(self): return str(ROOT / "src/helpers/CommonCLI.cpp") + _, captured = callback(environment, Node()) + run_script(post) + self.assertEqual(captured["CCFLAGS"], ["-Oz", "-flto", "-fno-builtin-strcpy"]) + self.assertEqual(environment["CCFLAGS"], ["-Oz", "-flto"]) + self.assertEqual(environment["LINKFLAGS"], list(LINK_SIZE_FLAGS)) + for target in ("wio-e5-mini_sensor", "wio-e5-mini_companion_radio_usb", "wio-e5_repeater"): + environment = FakeEnvironment(PIOENV=target) + run_script(pre) + self.assertFalse(getattr(environment, "middleware", [])) + def test_capability_contract_declares_only_exact_mini_repeater_omissions(self): command = r''' set -euo pipefail diff --git a/variants/wio-e5-mini/platformio.ini b/variants/wio-e5-mini/platformio.ini index b3052c347..60c5a6d51 100644 --- a/variants/wio-e5-mini/platformio.ini +++ b/variants/wio-e5-mini/platformio.ini @@ -26,6 +26,7 @@ platform_packages = platformio/toolchain-gccarmnoneeabi@^1.140201.0 build_unflags = -Os extra_scripts = ${stm32_base.extra_scripts} + pre:scripts/stm32_mini_cli_replies.py post:scripts/stm32_mini_size.py build_flags = ${lora_e5_mini.build_flags} ; Equivalent compact arithmetic and unused timer wrappers leave room for the From 3385f2acaa7e8a48e24fd6dd323458e7d46454bd Mon Sep 17 00:00:00 2001 From: mikecarper Date: Wed, 28 Oct 2026 03:03:48 -0700 Subject: [PATCH 12/61] Avoid repeated channel alias parsing in constrained companions --- examples/companion_radio/MyMesh.cpp | 2 +- scripts/nrf52_flash_trim.py | 10 ++++++---- src/helpers/CompanionChannelAlias.h | 9 ++++++--- test/test_companion_channel_alias.py | 10 ++++++++++ test/test_nrf52_flash_trim.py | 3 ++- 5 files changed, 25 insertions(+), 9 deletions(-) diff --git a/examples/companion_radio/MyMesh.cpp b/examples/companion_radio/MyMesh.cpp index 1462194ca..6f34b2d85 100644 --- a/examples/companion_radio/MyMesh.cpp +++ b/examples/companion_radio/MyMesh.cpp @@ -1785,7 +1785,7 @@ bool MyMesh::resolveChannelAlias(ChannelDetails& channel) const { })) return false; const auto kind = parseCompanionChannelAlias(channel.name).kind; #if MESH_ENABLE_MESHTEXT_CHAT - if (isMeshtextChannel(channel)) channel.channel.tx_radio = mesh::RADIO_TX_OFF; + if (kind == CompanionChannelKind::Meshtext) channel.channel.tx_radio = mesh::RADIO_TX_OFF; #endif if (isAliasRadio2Defined() && (kind == CompanionChannelKind::Radio2 || kind == CompanionChannelKind::Meshtext)) { diff --git a/scripts/nrf52_flash_trim.py b/scripts/nrf52_flash_trim.py index 104243055..0433ccd20 100644 --- a/scripts/nrf52_flash_trim.py +++ b/scripts/nrf52_flash_trim.py @@ -259,7 +259,7 @@ def compile_packed_help(build_env, node): return build_env.Object(node, **flags) -def _compile_shared_reply_copies(build_env, node, relative_path): +def _compile_shared_reply_copies(build_env, node, relative_path, extra_flags=()): source = Path(node.srcnode().get_abspath()).resolve() expected = Path(build_env.subst("$PROJECT_DIR/" + relative_path)).resolve() if source != expected: @@ -267,8 +267,9 @@ def _compile_shared_reply_copies(build_env, node, relative_path): # GCC otherwise expands many constant strcpy replies into repeated stores. # Use the same libc function and strings, with no parser or output changes. flags = list(build_env.get("CCFLAGS", ())) - if "-fno-builtin-strcpy" not in flags: - flags.append("-fno-builtin-strcpy") + for flag in ("-fno-builtin-strcpy", *extra_flags): + if flag not in flags: + flags.append(flag) return build_env.Object(node, CCFLAGS=flags) @@ -278,7 +279,8 @@ def compile_shared_cli_replies(build_env, node): def compile_shared_companion_replies(build_env, node): - return _compile_shared_reply_copies(build_env, node, "examples/companion_radio/MyMesh.cpp") + return _compile_shared_reply_copies(build_env, node, "examples/companion_radio/MyMesh.cpp", + ("-fno-builtin-strncpy",)) def companion_shared_replies_policy(platform, mcu, env_name, src_filter, diff --git a/src/helpers/CompanionChannelAlias.h b/src/helpers/CompanionChannelAlias.h index 7c434b595..1c741bcc3 100644 --- a/src/helpers/CompanionChannelAlias.h +++ b/src/helpers/CompanionChannelAlias.h @@ -34,9 +34,12 @@ inline CompanionChannelAlias parseCompanionChannelAlias(const char* name) { // Only exact named radio2 aliases request RF setup. Ordinary channels and // arbitrary r2 labels keep the operator's existing profile. inline const char* companionChannelRadio2Preset(const char* name) { - const auto alias = parseCompanionChannelAlias(name); - return alias.kind == CompanionChannelKind::Radio2 - && mesh::cli::radio2PresetTuple(alias.tail) ? alias.tail : nullptr; + if (!name) return nullptr; + if (*name == '#') ++name; + if (strncmp(name, "r2-", 3)) return nullptr; + name += 3; + // An exact preset tail also excludes every invalid/nested alias form. + return mesh::cli::radio2PresetTuple(name) ? name : nullptr; } inline bool companionChannelCanonicalName(const char* name, char* out, size_t size) { diff --git a/test/test_companion_channel_alias.py b/test/test_companion_channel_alias.py index f70154efd..bf18b9e7f 100644 --- a/test/test_companion_channel_alias.py +++ b/test/test_companion_channel_alias.py @@ -94,6 +94,16 @@ static void grammar() { assert(!companionChannelRadio2Preset(name)); } assert(!companionChannelRadio2Preset(nullptr)); + for (const char* prefix : {"", "#", "r2-", "#r2-", "mt-", "#mt-", "r2-r2-", "#r2-mt-"}) { + for (const char* tail : {"", "r", "r2", "#", "mediamesh", "mediamesh-range", + "MediaMesh", "mediamesh-extra", "mediamesh,32"}) { + char name[64]; snprintf(name, sizeof(name), "%s%s", prefix, tail); + const auto alias = parseCompanionChannelAlias(name); + const char* expected = alias.kind == CompanionChannelKind::Radio2 + && mesh::cli::radio2PresetTuple(alias.tail) ? alias.tail : nullptr; + assert(companionChannelRadio2Preset(name) == expected); + } + } puts("PASS grammar"); } static void canonical() { diff --git a/test/test_nrf52_flash_trim.py b/test/test_nrf52_flash_trim.py index 74dad3fbc..55a3ebf89 100644 --- a/test/test_nrf52_flash_trim.py +++ b/test/test_nrf52_flash_trim.py @@ -127,7 +127,8 @@ class Nrf52FlashTrimTest(unittest.TestCase): companion = Node(ROOT / "examples/companion_radio/MyMesh.cpp") result, flags = TRIM.compile_shared_companion_replies(env, companion) self.assertIs(result, companion) - self.assertEqual(flags["CCFLAGS"], ["-Os", "-flto", "-fno-builtin-strcpy"]) + self.assertEqual(flags["CCFLAGS"], ["-Os", "-flto", "-fno-builtin-strcpy", + "-fno-builtin-strncpy"]) self.assertEqual(env["CCFLAGS"], ["-Os", "-flto"]) for path in ("examples/simple_repeater/MyMesh.cpp", "test/fixtures/MyMesh.cpp", "src/helpers/CommonCLI.cpp", "src/helpers/RadioProfileCLI.cpp"): From 5cbff19463cab254f83a2aedafe58de3c686b016 Mon Sep 17 00:00:00 2001 From: mikecarper Date: Wed, 28 Oct 2026 03:05:05 -0700 Subject: [PATCH 13/61] Keep native USB responsive during ESP32 emergency radio sleep --- src/helpers/ESP32Board.cpp | 19 +++++++++++++++---- test/test_emergency_sleep_backends.py | 26 ++++++++++++++++++++++++++ 2 files changed, 41 insertions(+), 4 deletions(-) diff --git a/src/helpers/ESP32Board.cpp b/src/helpers/ESP32Board.cpp index b5591fc66..b7d4834df 100644 --- a/src/helpers/ESP32Board.cpp +++ b/src/helpers/ESP32Board.cpp @@ -1413,12 +1413,15 @@ void ESP32Board::powerOff() { #if MESH_ENABLE_EMERGENCY_POWER bool ESP32Board::emergencySleepMilliseconds(uint32_t millisecs) { - // The caller owns radio suspension and reversible wireless shutdown. Never - // force a live WiFi/Bluetooth stack through explicit light sleep. if (millisecs == 0 || isRadioTestActive() || xPortInIsrContext() - || xTaskGetSchedulerState() != taskSCHEDULER_RUNNING - || !mesh::esp32::manualLightSleepReady()) return false; + || xTaskGetSchedulerState() != taskSCHEDULER_RUNNING) return false; + // Native USB loses its connection in light sleep, even without an open CDC + // reader. Retain the ordinary physical-host/loss-grace policy here too. + // RF remains suspended, but an attached host gets frequent command service. + const bool usb_held = isUsbSleepHeld(); + if (!usb_held && !mesh::esp32::manualLightSleepReady()) return false; if (millisecs > 10000UL) millisecs = 10000UL; + if (usb_held && millisecs > 100UL) millisecs = 100UL; const esp_err_t watchdog_status = esp_task_wdt_status(nullptr); const bool watched = watchdog_status == ESP_OK; if (!watched && watchdog_status != ESP_ERR_NOT_FOUND @@ -1427,7 +1430,15 @@ bool ESP32Board::emergencySleepMilliseconds(uint32_t millisecs) { if (watched && millisecs > uint32_t(CONFIG_ESP_TASK_WDT_TIMEOUT_S) * 500UL) millisecs = uint32_t(CONFIG_ESP_TASK_WDT_TIMEOUT_S) * 500UL; #endif + const TickType_t usb_ticks = usb_held ? pdMS_TO_TICKS(millisecs) : 0; + if (usb_held && usb_ticks == 0) return false; if (watched && esp_task_wdt_reset() != ESP_OK) return false; + if (usb_held) { + // This also remains safe during asynchronous wireless teardown. Do not + // arm or clear wake sources while yielding with the USB peripheral live. + vTaskDelay(usb_ticks); + return !watched || esp_task_wdt_reset() == ESP_OK; + } if (esp_sleep_enable_timer_wakeup(uint64_t(millisecs) * 1000ULL) != ESP_OK) return false; // Add no radio/GPIO wake. Existing button sources remain untouched. The diff --git a/test/test_emergency_sleep_backends.py b/test/test_emergency_sleep_backends.py index 3d065f62e..0f4c18e19 100644 --- a/test/test_emergency_sleep_backends.py +++ b/test/test_emergency_sleep_backends.py @@ -22,6 +22,7 @@ HARNESS = r''' #include static std::vector events; static bool radio_test = false, interrupt_context = false, wireless_ready = true; +static bool usb_held = false; static unsigned primask = 0, basepri = 0, faultmask = 0; static unsigned __get_IPSR() { return interrupt_context; } static unsigned __get_PRIMASK() { return primask; } @@ -102,6 +103,7 @@ struct NRF52Board : Base { }; struct ESP32Board : Base { bool isRadioTestActive() const { return radio_test; } + bool isUsbSleepHeld() const { return usb_held; } @ESP_SUPPORT@ bool emergencySleepMilliseconds(uint32_t millisecs) override; }; @@ -109,6 +111,7 @@ struct ESP32Board : Base { @ESP_SLEEP@ static void reset() { events.clear(); radio_test = interrupt_context = false; wireless_ready = true; + usb_held = false; primask = basepri = faultmask = 0; nrf_nvic_state.__cr_flag = 0; scheduler = taskSCHEDULER_RUNNING; tick_ms = 1; delayed_ticks = nrf_feeds = 0; @@ -165,6 +168,29 @@ int main() { reset(); wireless_ready = false; assert(!esp.emergencySleepMilliseconds(100)); assert(events.empty()); + reset(); usb_held = true; wireless_ready = false; timer_us = 555; + assert(esp.emergencySleepMilliseconds(UINT32_MAX)); + assert(delayed_ticks == 100 && events == std::vector{"delay"}); + assert(!timer_calls && !sleep_calls && !clear_calls && timer_us == 555); + assert(other_wake_sources == 0x71); // USB, RF and existing wake ownership are untouched. + usb_held = false; events.clear(); + assert(!esp.emergencySleepMilliseconds(100) && events.empty()); // Teardown is still live. + wireless_ready = true; + assert(esp.emergencySleepMilliseconds(100)); // Recheck the host on every call; detach permits light sleep. + assert(last_armed_us == 100000 && sleep_calls == 1 && !timer_us); + reset(); usb_held = true; task_watchdog_status = ESP_OK; + assert(esp.emergencySleepMilliseconds(250)); + assert(delayed_ticks == 100 && task_watchdog_feeds == 2); + assert((events == std::vector{"task_feed", "delay", "task_feed"})); + assert(!timer_calls && !sleep_calls && !clear_calls); + reset(); usb_held = true; tick_ms = 1000; + assert(!esp.emergencySleepMilliseconds(100) && events.empty()); + for (unsigned failure : {1U, 2U}) { + reset(); usb_held = true; task_watchdog_status = ESP_OK; fail_watchdog_feed = failure; + assert(!esp.emergencySleepMilliseconds(100)); + assert(delayed_ticks == (failure == 1 ? 0U : 100U)); + assert(!timer_calls && !sleep_calls && !clear_calls); + } reset(); task_watchdog_status = 99; assert(!esp.emergencySleepMilliseconds(100)); assert(events.empty()); reset(); assert(esp.emergencySleepMilliseconds(UINT32_MAX)); From be6d636c79413fbadf7f23a1fc97cd9f92b76ba6 Mon Sep 17 00:00:00 2001 From: mikecarper Date: Wed, 28 Oct 2026 03:10:32 -0700 Subject: [PATCH 14/61] Give native USB time to recover after scheduled light sleep --- src/helpers/ESP32Board.cpp | 18 ++++++++-- test/test_emergency_sleep_backends.py | 52 ++++++++++++++++++++++++--- test/test_esp32_cpu_clock_board.py | 8 +++-- 3 files changed, 70 insertions(+), 8 deletions(-) diff --git a/src/helpers/ESP32Board.cpp b/src/helpers/ESP32Board.cpp index b7d4834df..37cba1c49 100644 --- a/src/helpers/ESP32Board.cpp +++ b/src/helpers/ESP32Board.cpp @@ -1432,11 +1432,23 @@ bool ESP32Board::emergencySleepMilliseconds(uint32_t millisecs) { #endif const TickType_t usb_ticks = usb_held ? pdMS_TO_TICKS(millisecs) : 0; if (usb_held && usb_ticks == 0) return false; + TickType_t usb_service_ticks = 0; +#if MESH_ESP32_USB_CONSOLE_COOPERATIVE + if (!usb_held) { + // SOF/connection state is sampled by RTOS hooks. Reserve awake service + // time after a timer wake so a newly attached host can become visible + // before the next sleep attempt. Keep the whole wait within this budget. + const uint32_t service_ms = millisecs > 100UL ? 100UL : millisecs; + usb_service_ticks = pdMS_TO_TICKS(service_ms); + if (usb_service_ticks == 0) return false; + millisecs -= service_ms; + } +#endif if (watched && esp_task_wdt_reset() != ESP_OK) return false; - if (usb_held) { + if (usb_held || millisecs == 0) { // This also remains safe during asynchronous wireless teardown. Do not // arm or clear wake sources while yielding with the USB peripheral live. - vTaskDelay(usb_ticks); + vTaskDelay(usb_held ? usb_ticks : usb_service_ticks); return !watched || esp_task_wdt_reset() == ESP_OK; } if (esp_sleep_enable_timer_wakeup(uint64_t(millisecs) * 1000ULL) != ESP_OK) @@ -1445,6 +1457,8 @@ bool ESP32Board::emergencySleepMilliseconds(uint32_t millisecs) { // normal sleep path installs its own next timer; never leave ours armed. const esp_err_t slept = esp_light_sleep_start(); const esp_err_t cleared = esp_sleep_disable_wakeup_source(ESP_SLEEP_WAKEUP_TIMER); + if (slept == ESP_OK && cleared == ESP_OK && usb_service_ticks != 0) + vTaskDelay(usb_service_ticks); const bool watchdog_ok = !watched || esp_task_wdt_reset() == ESP_OK; return slept == ESP_OK && cleared == ESP_OK && watchdog_ok; } diff --git a/test/test_emergency_sleep_backends.py b/test/test_emergency_sleep_backends.py index 0f4c18e19..973261138 100644 --- a/test/test_emergency_sleep_backends.py +++ b/test/test_emergency_sleep_backends.py @@ -223,9 +223,7 @@ int main() { class EmergencySleepBackendTests(unittest.TestCase): - def test_actual_platform_waits_and_cleanup(self): - compiler = shutil.which("g++") or shutil.which("clang++") - self.assertIsNotNone(compiler) + def platform_code(self): sources = {name: (ROOT / path).read_text() for name, path in ( ("core", "src/MeshCore.h"), ("nrf", "src/helpers/NRF52Board.cpp"), @@ -242,17 +240,63 @@ class EmergencySleepBackendTests(unittest.TestCase): ("NRF_SLEEP", "nrf", "bool NRF52Board::emergencySleepMilliseconds("), ("ESP_SLEEP", "esp", "bool ESP32Board::emergencySleepMilliseconds(")): code = code.replace("@" + marker + "@", extract_braced(sources[source], signature)) + return code + + def run_code(self, code, flags=()): + compiler = shutil.which("g++") or shutil.which("clang++") + self.assertIsNotNone(compiler) with tempfile.TemporaryDirectory(prefix="emergency-sleep-backends-") as directory: work = Path(directory) cpp, binary = work / "test.cpp", work / "test" cpp.write_text(code) subprocess.run([compiler, "-std=c++17", "-Wall", "-Wextra", "-Werror", - "-DCONFIG_ESP_TASK_WDT_TIMEOUT_S=4", *SANITIZERS, + "-DCONFIG_ESP_TASK_WDT_TIMEOUT_S=4", *flags, *SANITIZERS, str(cpp), "-o", str(binary)], check=True, capture_output=True, text=True, timeout=60) subprocess.run([str(binary)], check=True, capture_output=True, text=True, timeout=20) + def test_actual_platform_waits_and_cleanup(self): + self.run_code(self.platform_code()) + + def test_native_usb_timer_wake_reserves_enumeration_service_without_extending_budget(self): + code = self.platform_code().split("int main() {", 1)[0] + r''' +int main() { + ESP32Board esp; + reset(); assert(esp.emergencySleepMilliseconds(10000)); + assert(last_armed_us == 9900000ULL && delayed_ticks == 100); + assert((events == std::vector{"timer", "sleep", "clear_timer", "delay"})); + assert(timer_us == 0 && other_wake_sources == 0x71); + reset(); task_watchdog_status = ESP_OK; + assert(esp.emergencySleepMilliseconds(10000)); + assert(last_armed_us == 1900000ULL && delayed_ticks == 100 && task_watchdog_feeds == 2); + assert((events == std::vector{"task_feed", "timer", "sleep", "clear_timer", "delay", "task_feed"})); + reset(); assert(esp.emergencySleepMilliseconds(125)); + assert(last_armed_us == 25000 && delayed_ticks == 100); + reset(); assert(esp.emergencySleepMilliseconds(30)); + assert(delayed_ticks == 30 && events == std::vector{"delay"}); + assert(!timer_calls && !sleep_calls && !clear_calls); + reset(); tick_ms = 10; + assert(esp.emergencySleepMilliseconds(129)); + assert(last_armed_us == 29000 && delayed_ticks == 10); // Timer + floored RTOS wait <=129ms. + reset(); tick_ms = 1000; + assert(!esp.emergencySleepMilliseconds(10000) && events.empty()); + for (int* failed : {&sleep_error, &clear_error}) { + reset(); *failed = 9; + assert(!esp.emergencySleepMilliseconds(10000)); + assert(!delayed_ticks && clear_calls == 1); + assert((events == std::vector{"timer", "sleep", "clear_timer"})); + } + reset(); timer_error = 9; + assert(!esp.emergencySleepMilliseconds(10000)); + assert(!delayed_ticks && !sleep_calls && !clear_calls); + reset(); usb_held = true; wireless_ready = false; + assert(esp.emergencySleepMilliseconds(10000)); + assert(delayed_ticks == 100 && !sleep_calls); // Existing host still bypasses physical USB sleep. +} +''' + self.run_code(code, ("-DMESH_ESP32_USB_CONSOLE_COOPERATIVE=1",)) + def test_disabled_feature_has_no_new_base_virtuals(self): compiler = shutil.which("g++") or shutil.which("clang++") self.assertIsNotNone(compiler) diff --git a/test/test_esp32_cpu_clock_board.py b/test/test_esp32_cpu_clock_board.py index 58096c34e..1318f675b 100644 --- a/test/test_esp32_cpu_clock_board.py +++ b/test/test_esp32_cpu_clock_board.py @@ -182,8 +182,12 @@ int main(){ def test_actual_infrastructure_loop_services_saved_choice_and_quiet_deadline(self): for role in ("simple_repeater","simple_room_server","simple_sensor"): source=(ROOT/"examples"/role/"main.cpp").read_text() - start=source.index(" board.servicePowerTestControls();",source.index("void loop()")) - end=source.index("#endif",start) + loop_start=source.index("void loop()") + clock_service=source.index("mesh::esp32::serviceCpuFrequencyPolicy(",loop_start) + # Emergency maintenance also services UART restoration before its + # early return. Keep this fixture anchored to the clock policy. + start=source.rindex(" board.servicePowerTestControls();",loop_start,clock_service) + end=source.index("#endif",clock_service) service=source[start:end] tests="void role_service(){\n"+service+"}\n"+r''' int main(){ From 411144f69cef6a54a9fe2b6bee492e5a0d10de1b Mon Sep 17 00:00:00 2001 From: mikecarper Date: Wed, 28 Oct 2026 03:15:49 -0700 Subject: [PATCH 15/61] Keep local Companion control parsing in a cold function --- examples/companion_radio/MyMesh.cpp | 1 + scripts/nrf52_flash_trim.py | 10 ++++------ test/test_nrf52_flash_trim.py | 3 +-- 3 files changed, 6 insertions(+), 8 deletions(-) diff --git a/examples/companion_radio/MyMesh.cpp b/examples/companion_radio/MyMesh.cpp index 6f34b2d85..e2334cff7 100644 --- a/examples/companion_radio/MyMesh.cpp +++ b/examples/companion_radio/MyMesh.cpp @@ -4078,6 +4078,7 @@ bool MyMesh::handleTxRoutingCommand(const char* command, char* reply, size_t rep return true; } +__attribute__((noinline, cold)) bool MyMesh::handleLocalControlCommand(const char* command, char* reply, size_t reply_size) { if (!command || !reply || reply_size == 0) return false; diff --git a/scripts/nrf52_flash_trim.py b/scripts/nrf52_flash_trim.py index 0433ccd20..104243055 100644 --- a/scripts/nrf52_flash_trim.py +++ b/scripts/nrf52_flash_trim.py @@ -259,7 +259,7 @@ def compile_packed_help(build_env, node): return build_env.Object(node, **flags) -def _compile_shared_reply_copies(build_env, node, relative_path, extra_flags=()): +def _compile_shared_reply_copies(build_env, node, relative_path): source = Path(node.srcnode().get_abspath()).resolve() expected = Path(build_env.subst("$PROJECT_DIR/" + relative_path)).resolve() if source != expected: @@ -267,9 +267,8 @@ def _compile_shared_reply_copies(build_env, node, relative_path, extra_flags=()) # GCC otherwise expands many constant strcpy replies into repeated stores. # Use the same libc function and strings, with no parser or output changes. flags = list(build_env.get("CCFLAGS", ())) - for flag in ("-fno-builtin-strcpy", *extra_flags): - if flag not in flags: - flags.append(flag) + if "-fno-builtin-strcpy" not in flags: + flags.append("-fno-builtin-strcpy") return build_env.Object(node, CCFLAGS=flags) @@ -279,8 +278,7 @@ def compile_shared_cli_replies(build_env, node): def compile_shared_companion_replies(build_env, node): - return _compile_shared_reply_copies(build_env, node, "examples/companion_radio/MyMesh.cpp", - ("-fno-builtin-strncpy",)) + return _compile_shared_reply_copies(build_env, node, "examples/companion_radio/MyMesh.cpp") def companion_shared_replies_policy(platform, mcu, env_name, src_filter, diff --git a/test/test_nrf52_flash_trim.py b/test/test_nrf52_flash_trim.py index 55a3ebf89..74dad3fbc 100644 --- a/test/test_nrf52_flash_trim.py +++ b/test/test_nrf52_flash_trim.py @@ -127,8 +127,7 @@ class Nrf52FlashTrimTest(unittest.TestCase): companion = Node(ROOT / "examples/companion_radio/MyMesh.cpp") result, flags = TRIM.compile_shared_companion_replies(env, companion) self.assertIs(result, companion) - self.assertEqual(flags["CCFLAGS"], ["-Os", "-flto", "-fno-builtin-strcpy", - "-fno-builtin-strncpy"]) + self.assertEqual(flags["CCFLAGS"], ["-Os", "-flto", "-fno-builtin-strcpy"]) self.assertEqual(env["CCFLAGS"], ["-Os", "-flto"]) for path in ("examples/simple_repeater/MyMesh.cpp", "test/fixtures/MyMesh.cpp", "src/helpers/CommonCLI.cpp", "src/helpers/RadioProfileCLI.cpp"): From 749ee1e2e5672f9965a138ae948b62c255b32d65 Mon Sep 17 00:00:00 2001 From: mikecarper Date: Wed, 28 Oct 2026 03:18:45 -0700 Subject: [PATCH 16/61] Keep unused Companion preset staging out of repeater compilation --- src/helpers/RadioProfileCLI.cpp | 3 +++ test/fixtures/radio_profiles/cli_test.cpp | 5 +++++ test/test_radio_profile_cli.py | 26 +++++++++++++++++++++++ 3 files changed, 34 insertions(+) diff --git a/src/helpers/RadioProfileCLI.cpp b/src/helpers/RadioProfileCLI.cpp index 60facf08a..04c171d30 100644 --- a/src/helpers/RadioProfileCLI.cpp +++ b/src/helpers/RadioProfileCLI.cpp @@ -746,6 +746,8 @@ bool RadioProfileCLI::finishReplyMutation(bool delivered) { || !fs_ || !fs_->exists(ReplyPath) || fs_->remove(ReplyPath); } +#if !MESH_CLIENT_REPEATER_ONLY +// Companion channel joins use this transaction; repeaters use the CLI below. bool RadioProfileCLI::stageChannelPreset(const char* name) { if (!name || !cli::radio2PresetTuple(name) || remote_command_ || !secondaryRuntimeAvailable()) return false; @@ -768,6 +770,7 @@ bool RadioProfileCLI::finishChannelPreset(bool commit) { publish_after_ms_ = millis() + 2000; return true; } +#endif #if MESH_ENABLE_FLEET_CONTROL bool RadioProfileCLI::stageRemoteSchedule(unsigned slot, const Schedule& schedule) { diff --git a/test/fixtures/radio_profiles/cli_test.cpp b/test/fixtures/radio_profiles/cli_test.cpp index 953cf35b5..f6a836f3e 100644 --- a/test/fixtures/radio_profiles/cli_test.cpp +++ b/test/fixtures/radio_profiles/cli_test.cpp @@ -548,6 +548,7 @@ static void mediaMeshPresets() { assert(!invalid.radio.p.enabled() && invalid.fs.files.empty()); } +#if !MESH_CLIENT_REPEATER_ONLY static void channelPresetTransaction() { for (bool range : {false, true}) { Fixture f; @@ -710,6 +711,8 @@ static void channelPresetTransaction() { } } +#endif + struct MediaScope { bool available = true, fail_save = false, enabled = false, durable = false; unsigned checks = 0, commits = 0; @@ -893,7 +896,9 @@ int main(int argc, char** argv) { else if (!strcmp(argv[1], "relative_schedule")) relativeSchedules(); else if (!strcmp(argv[1], "spectrum_overlap")) spectrumOverlap(); else if (!strcmp(argv[1], "mediamesh")) mediaMeshPresets(); +#if !MESH_CLIENT_REPEATER_ONLY else if (!strcmp(argv[1], "channel_preset")) channelPresetTransaction(); +#endif else if (!strcmp(argv[1], "media_scope")) mediaMeshScopeLifecycle(); else return 2; return 0; diff --git a/test/test_radio_profile_cli.py b/test/test_radio_profile_cli.py index 2b5000893..be966e186 100644 --- a/test/test_radio_profile_cli.py +++ b/test/test_radio_profile_cli.py @@ -9,6 +9,32 @@ import unittest ROOT = Path(__file__).resolve().parents[1] class RadioProfileCLITest(unittest.TestCase): + def test_repeater_retains_radio2_presets_schedules_and_transactions(self): + compiler = shutil.which('g++') or shutil.which('clang++') + self.assertIsNotNone(compiler) + with tempfile.TemporaryDirectory(prefix='profile-repeater-') as work: + binary = Path(work) / 'repeater' + compiled = subprocess.run([ + compiler, '-std=c++17', '-Os', '-Wall', '-Wextra', + '-DMESH_CLIENT_REPEATER_ONLY=1', '-DMESH_ENABLE_FLEET_CONTROL=0', + '-DMESH_ENABLE_CARRIER_TOOLS=0', + '-I', str(ROOT/'test/fixtures/radio_profiles/mocks'), + '-I', str(ROOT/'test/mocks'), '-I', str(ROOT/'src'), + str(ROOT/'src/helpers/RadioProfileCLI.cpp'), + str(ROOT/'test/fixtures/radio_profiles/cli_test.cpp'), + '-o', str(binary)], capture_output=True, text=True, timeout=60) + self.assertEqual(compiled.returncode, 0, compiled.stderr) + for scenario in (None, 'permanent_order', 'temporary_boundary', 'lease_creation', + 'save_failure', 'schedule_permutations', 'storage_isolation', + 'relative_schedule', 'spectrum_overlap', 'mediamesh', 'media_scope'): + with self.subTest(scenario=scenario): + ran = subprocess.run([str(binary)] + ([scenario] if scenario else []), + capture_output=True, text=True, timeout=10) + self.assertEqual(ran.returncode, 0, ran.stdout + ran.stderr) + symbols = subprocess.check_output(['nm', '-C', str(binary)], text=True) + self.assertNotIn('RadioProfileCLI::stageChannelPreset(', symbols) + self.assertNotIn('RadioProfileCLI::finishChannelPreset(', symbols) + def test_channel_preset_preserves_secondary_owners(self): compiler = shutil.which('g++') or shutil.which('clang++') self.assertIsNotNone(compiler) From f0bcb10ff0f59d2396835a93a4cac741f4c5546c Mon Sep 17 00:00:00 2001 From: mikecarper Date: Wed, 28 Oct 2026 03:21:36 -0700 Subject: [PATCH 17/61] Handle USB mode queries in the existing getter paths --- examples/companion_radio/MyMesh.cpp | 6 ++-- src/helpers/CommonCLI.cpp | 2 +- src/helpers/CompanionChannelAlias.h | 9 ++--- test/fixtures/companion_rxps_getters.cpp | 1 + test/test_usb_mode.py | 45 ++++++++++++++++-------- 5 files changed, 38 insertions(+), 25 deletions(-) diff --git a/examples/companion_radio/MyMesh.cpp b/examples/companion_radio/MyMesh.cpp index e2334cff7..ed55f7aa4 100644 --- a/examples/companion_radio/MyMesh.cpp +++ b/examples/companion_radio/MyMesh.cpp @@ -1785,7 +1785,7 @@ bool MyMesh::resolveChannelAlias(ChannelDetails& channel) const { })) return false; const auto kind = parseCompanionChannelAlias(channel.name).kind; #if MESH_ENABLE_MESHTEXT_CHAT - if (kind == CompanionChannelKind::Meshtext) channel.channel.tx_radio = mesh::RADIO_TX_OFF; + if (isMeshtextChannel(channel)) channel.channel.tx_radio = mesh::RADIO_TX_OFF; #endif if (isAliasRadio2Defined() && (kind == CompanionChannelKind::Radio2 || kind == CompanionChannelKind::Meshtext)) { @@ -4078,11 +4078,11 @@ bool MyMesh::handleTxRoutingCommand(const char* command, char* reply, size_t rep return true; } -__attribute__((noinline, cold)) bool MyMesh::handleLocalControlCommand(const char* command, char* reply, size_t reply_size) { if (!command || !reply || reply_size == 0) return false; while (*command == ' ') command++; + if (mesh::handleUsbModeCommand(command, reply, reply_size, Serial)) return true; #if defined(ESP32_PLATFORM) && defined(WIFI_SSID) if (strncmp(command, "password", 8) == 0 && (command[8] == 0 || command[8] == ' ' || command[8] == '\t')) { @@ -11139,8 +11139,6 @@ bool MyMesh::handleCommand(const char* command, uint32_t sender_timestamp, while (*command == ' ' || *command == '\t') command++; } - if (mesh::handleUsbModeCommand(command, reply, reply_capacity, Serial)) return true; - #if defined(ESP32_PLATFORM) // Catch remote requests before the direct-transport router below. That // router is also used by USB/BLE/browser commands without a timestamp. diff --git a/src/helpers/CommonCLI.cpp b/src/helpers/CommonCLI.cpp index 24682ed61..dce9ba037 100644 --- a/src/helpers/CommonCLI.cpp +++ b/src/helpers/CommonCLI.cpp @@ -3560,7 +3560,6 @@ bool CommonCLI::handleUDPBridgeCommand(uint32_t sender_timestamp, void CommonCLI::handleCommand(uint32_t sender_timestamp, char* command, char* reply) { PrefsSaveReplyGuard save_reply(_prefs_save_failures, reply); mesh::cli::normalizeCommandVerb(command); - if (mesh::handleUsbModeCommand(command, reply, 160, Serial)) return; #if MESH_ENABLE_HOST_KISS if (mesh::hostkiss::handleCommand(command, reply, 157, sender_timestamp == 0)) return; #endif @@ -5992,6 +5991,7 @@ void CommonCLI::handleSetCmd(uint32_t sender_timestamp, char* command, char* rep } void CommonCLI::handleGetCmd(uint32_t sender_timestamp, char* command, char* reply) { + if (mesh::handleUsbModeCommand(command, reply, 160, Serial)) return; const char* config = &command[4]; #if defined(RAK_4631) if (mesh::peripherals::handleCommand(_prefs->peripherals_shared, command, diff --git a/src/helpers/CompanionChannelAlias.h b/src/helpers/CompanionChannelAlias.h index 1c741bcc3..7c434b595 100644 --- a/src/helpers/CompanionChannelAlias.h +++ b/src/helpers/CompanionChannelAlias.h @@ -34,12 +34,9 @@ inline CompanionChannelAlias parseCompanionChannelAlias(const char* name) { // Only exact named radio2 aliases request RF setup. Ordinary channels and // arbitrary r2 labels keep the operator's existing profile. inline const char* companionChannelRadio2Preset(const char* name) { - if (!name) return nullptr; - if (*name == '#') ++name; - if (strncmp(name, "r2-", 3)) return nullptr; - name += 3; - // An exact preset tail also excludes every invalid/nested alias form. - return mesh::cli::radio2PresetTuple(name) ? name : nullptr; + const auto alias = parseCompanionChannelAlias(name); + return alias.kind == CompanionChannelKind::Radio2 + && mesh::cli::radio2PresetTuple(alias.tail) ? alias.tail : nullptr; } inline bool companionChannelCanonicalName(const char* name, char* out, size_t size) { diff --git a/test/fixtures/companion_rxps_getters.cpp b/test/fixtures/companion_rxps_getters.cpp index e2d4cf373..71bdd4a49 100644 --- a/test/fixtures/companion_rxps_getters.cpp +++ b/test/fixtures/companion_rxps_getters.cpp @@ -6,6 +6,7 @@ #include #include #include "helpers/radiolib/RXPowerSaving.h" +#include "usb_mode_console.h" struct Radio { bool supported = true; diff --git a/test/test_usb_mode.py b/test/test_usb_mode.py index 60ce40840..843abbe84 100644 --- a/test/test_usb_mode.py +++ b/test/test_usb_mode.py @@ -127,18 +127,26 @@ class UsbModeTests(unittest.TestCase): self.compile_and_run(directory, "alias" + str(index), flags, expected, primary=primary) def test_actual_common_and_companion_dispatch_including_v14_request_prefix(self): - common = method((ROOT / "src/helpers/CommonCLI.cpp").read_text(), - "void CommonCLI::handleCommand(") - companion = method((ROOT / "examples/companion_radio/MyMesh.cpp").read_text(), - "bool MyMesh::handleCommand(") - call = "if (mesh::handleUsbModeCommand(command, reply, reply_capacity, Serial)) return true;" - self.assertIn(call, companion) - # Execute the real early dispatch bodies, before unrelated board/radio - # services. The framed V14 command path invokes this same handleCommand. - common = common.split("#if MESH_ENABLE_HOST_KISS", 1)[0] + "\n}" - companion = companion[:companion.index(call) + len(call)] + "\nreturn false;\n}" - # The omitted unrelated dispatch branches normally use this argument. + common_source = (ROOT / "src/helpers/CommonCLI.cpp").read_text() + companion_source = (ROOT / "examples/companion_radio/MyMesh.cpp").read_text() + common = method(common_source, "void CommonCLI::handleCommand(") + getter = method(common_source, "void CommonCLI::handleGetCmd(") + companion = method(companion_source, "bool MyMesh::handleCommand(") + control = method(companion_source, "bool MyMesh::handleLocalControlCommand(") + self.assertNotIn("handleUsbModeCommand", common) + self.assertNotIn("handleUsbModeCommand", companion) + self.assertIn("handleUsbModeCommand", getter) + self.assertIn("handleUsbModeCommand", control) + # Execute each actual entry and getter registration, retaining the V14 + # prefix/capacity handling while omitting unrelated hardware branches. + common = common.split("#if MESH_ENABLE_HOST_KISS", 1)[0] + method( + common, 'if (memcmp(command, "get ", 4) == 0)') + "\n}" + getter = getter[:getter.index(" const char* config")] + "\n}" + control = control[:control.index("#if defined(ESP32_PLATFORM)")] + "return false;\n}" + companion = companion[:companion.index("#if defined(ESP32_PLATFORM)")] + companion += " return handleLocalControlCommand(command, reply, reply_capacity);\n}" common = common.replace("{", "{ (void)sender_timestamp;", 1) + getter = getter.replace("{", "{ (void)sender_timestamp;", 1) companion = companion.replace("{", "{ (void)sender_timestamp;", 1) dispatch = r''' struct PrefsSaveReplyGuard { @@ -148,8 +156,12 @@ namespace mesh { namespace cli { void normalizeCommandVerb(char*) {} } } struct CommonCLI { unsigned _prefs_save_failures = 0; void handleCommand(uint32_t, char*, char*); + void handleGetCmd(uint32_t, char*, char*); +}; +struct MyMesh { + bool handleCommand(const char*, uint32_t, char*); + bool handleLocalControlCommand(const char*, char*, size_t); }; -struct MyMesh { bool handleCommand(const char*, uint32_t, char*); }; ''' cases = r''' CommonCLI common; @@ -164,8 +176,13 @@ struct MyMesh { bool handleCommand(const char*, uint32_t, char*); }; assert(!companion.handleCommand("set usb.mode tinyusb", 0, reply)); ''' with tempfile.TemporaryDirectory(prefix="meshcore-usb-mode-dispatch-") as directory: - self.compile_and_run(directory, "dispatch", ["ESP32", "ARDUINO_USB_MODE=1", - "ARDUINO_USB_CDC_ON_BOOT=1"], "hardware-cdc-jtag", dispatch + common + companion, cases) + for index, (flags, expected) in enumerate(( + (["ESP32", "ARDUINO_USB_MODE=1", "ARDUINO_USB_CDC_ON_BOOT=1"], "hardware-cdc-jtag"), + (["ESP32", "ARDUINO_USB_MODE=0", "ARDUINO_USB_CDC_ON_BOOT=1"], "tinyusb"), + (["NRF52_PLATFORM", "USE_TINYUSB", "MESH_DUAL_CDC_LOGGING"], "tinyusb"), + (["STM32_PLATFORM"], "unknown"))): + self.compile_and_run(directory, "dispatch" + str(index), flags, expected, + dispatch + getter + control + common + companion, cases) full = (ROOT / "examples/companion_radio/MyMesh.cpp").read_text() frame = full[full.index("mesh::companion::isRunCliFrame"):] self.assertIn("handleCommand(text, 0, reply_buf)", frame) From 8fdad47b27528940212ae1f083900fadf440e603 Mon Sep 17 00:00:00 2001 From: mikecarper Date: Wed, 28 Oct 2026 03:26:36 -0700 Subject: [PATCH 18/61] Keep Mini single-use helpers out of line and align native fixtures --- scripts/stm32_mini_size.py | 1 + test/fixtures/rs232_runtime/json.cpp | 2 +- test/test_companion_identity_cli.py | 2 ++ test/test_companion_wifi_status.py | 4 +++- test/test_management_password_commands.py | 3 +++ test/test_stm32_mini_repeater_profile.py | 1 + test/test_storage_layout_cli.py | 4 +++- variants/wio-e5-mini/platformio.ini | 1 + 8 files changed, 15 insertions(+), 3 deletions(-) diff --git a/scripts/stm32_mini_size.py b/scripts/stm32_mini_size.py index 82f2087cd..6dae59350 100644 --- a/scripts/stm32_mini_size.py +++ b/scripts/stm32_mini_size.py @@ -12,4 +12,5 @@ if env["PIOENV"] == "wio-e5-mini_repeater": "-fno-reorder-blocks", "-fno-reorder-functions", "-fno-tree-switch-conversion", + "-fno-inline-functions-called-once", ]) diff --git a/test/fixtures/rs232_runtime/json.cpp b/test/fixtures/rs232_runtime/json.cpp index b52083cd8..f730a6839 100644 --- a/test/fixtures/rs232_runtime/json.cpp +++ b/test/fixtures/rs232_runtime/json.cpp @@ -11,7 +11,7 @@ struct NodePrefs : ConfigSerializer { uint16_t bridge_delay = 500; uint8_t bridge_pkt_src = 1, bridge_channel = 1, bridge_format = 0; uint32_t bridge_baud = 115200; - uint8_t usb_logging_enabled = 0, usb_debug_enabled = 0; + uint8_t usb_logging_enabled = 0, usb_debug_enabled = 0, usb_logging_always = 0; uint8_t rs232_autosleep = 1; uint8_t rs232_heartbeat = 1; uint16_t rs232_idle_seconds = 300; diff --git a/test/test_companion_identity_cli.py b/test/test_companion_identity_cli.py index 928193cfc..08340814d 100644 --- a/test/test_companion_identity_cli.py +++ b/test/test_companion_identity_cli.py @@ -20,6 +20,7 @@ HARNESS = r''' #include #include #include +#include "usb_mode_console.h" // Keep the production diagnostics delegate in the command path. Identity // requests must not consult board power state or trigger a hardware query. @@ -190,6 +191,7 @@ class CompanionIdentityCliTests(unittest.TestCase): '-fsanitize=address,undefined', '-fno-sanitize-recover=all', '-fno-pie', '-no-pie', *['-D' + flag for flag in flags], '-I', str(ROOT / 'src'), '-I', str(ROOT / 'test/mocks'), + '-I', str(ROOT / 'test/fixtures'), str(cpp), '-o', str(binary)], capture_output=True, text=True) self.assertEqual(built.returncode, 0, built.stdout + built.stderr) ran = subprocess.run([str(binary)], capture_output=True, text=True) diff --git a/test/test_companion_wifi_status.py b/test/test_companion_wifi_status.py index ca7655d25..c7e4324fc 100644 --- a/test/test_companion_wifi_status.py +++ b/test/test_companion_wifi_status.py @@ -17,7 +17,9 @@ class CompanionWiFiStatusTest(unittest.TestCase): self.assertIn('strcmp(command, "get wifi.status") == 0', main) self.assertNotIn('"get wifi.status detail"', main) self.assertNotIn('"get wifi.compat"', main) - self.assertIn('get wifi.status (connection, saved/boot/live settings)', mesh) + self.assertIn('#include "CompanionHelp.inc"', mesh) + self.assertIn('get wifi.status (connection, saved/boot/live settings)', + (ROOT / "examples/companion_radio/CompanionHelp.inc").read_text()) self.assertIn('formatCompanionWiFiStatusSnapshot(reply, reply_size, snapshot);', main) local = mesh[mesh.index("bool MyMesh::handleLocalControlCommand("):] self.assertLess(local.index("handleCompanionWiFiCompatibilityCommand("), diff --git a/test/test_management_password_commands.py b/test/test_management_password_commands.py index f8e1be900..49a2b690d 100644 --- a/test/test_management_password_commands.py +++ b/test/test_management_password_commands.py @@ -26,6 +26,9 @@ class ManagementPasswordCommandsTest(unittest.TestCase): #include #include #include +struct HardwareSerial {}; +static HardwareSerial Serial; +#include using namespace mesh::esp32::managementPassword; bool save_ok = true; int saves = 0; bool durableSave() { diff --git a/test/test_stm32_mini_repeater_profile.py b/test/test_stm32_mini_repeater_profile.py index 12de6af14..c671297e7 100644 --- a/test/test_stm32_mini_repeater_profile.py +++ b/test/test_stm32_mini_repeater_profile.py @@ -22,6 +22,7 @@ LINK_SIZE_FLAGS = ( "-fno-reorder-blocks", "-fno-reorder-functions", "-fno-tree-switch-conversion", + "-fno-inline-functions-called-once", ) OPTIONAL_CUTS = ( "MESH_ENABLE_CARRIER_TOOLS", diff --git a/test/test_storage_layout_cli.py b/test/test_storage_layout_cli.py index 046aa583b..85652836f 100644 --- a/test/test_storage_layout_cli.py +++ b/test/test_storage_layout_cli.py @@ -147,7 +147,9 @@ int main() { help_body = companion[help_start:help_end] self.assertRegex(help_body, r"auto& \w+ = terminalOutput\(\);") # This line may share a bounded help chunk with adjacent commands. - self.assertIn('" get storage.layout\\r\\n"', help_body) + self.assertIn('#include "CompanionHelp.inc"', help_body) + self.assertIn('" get storage.layout\\r\\n"', + (ROOT / "examples/companion_radio/CompanionHelp.inc").read_text()) # ESP-IDF discovery and every platform-specific result live only in the # shared formatter; neither role owns a second copy. diff --git a/variants/wio-e5-mini/platformio.ini b/variants/wio-e5-mini/platformio.ini index 60c5a6d51..16a2a0e11 100644 --- a/variants/wio-e5-mini/platformio.ini +++ b/variants/wio-e5-mini/platformio.ini @@ -44,6 +44,7 @@ build_flags = ${lora_e5_mini.build_flags} -fno-reorder-functions -fno-tree-switch-conversion -fno-inline-small-functions ; reduce flash without moving the deployed filesystem boundary + -fno-inline-functions-called-once -fipa-pta -fmerge-all-constants -fno-ipa-cp-clone From c2ae465ece44e64b7c223bbc92e9c03010a0ebe9 Mon Sep 17 00:00:00 2001 From: mikecarper Date: Wed, 28 Oct 2026 03:35:52 -0700 Subject: [PATCH 19/61] Use one LTO partition for the constrained Mini image --- scripts/stm32_mini_size.py | 2 +- test/test_stm32_mini_repeater_profile.py | 9 ++++++--- variants/wio-e5-mini/platformio.ini | 1 - 3 files changed, 7 insertions(+), 5 deletions(-) diff --git a/scripts/stm32_mini_size.py b/scripts/stm32_mini_size.py index 6dae59350..e6807e4d8 100644 --- a/scripts/stm32_mini_size.py +++ b/scripts/stm32_mini_size.py @@ -12,5 +12,5 @@ if env["PIOENV"] == "wio-e5-mini_repeater": "-fno-reorder-blocks", "-fno-reorder-functions", "-fno-tree-switch-conversion", - "-fno-inline-functions-called-once", + "-flto-partition=one", ]) diff --git a/test/test_stm32_mini_repeater_profile.py b/test/test_stm32_mini_repeater_profile.py index c671297e7..66e293c48 100644 --- a/test/test_stm32_mini_repeater_profile.py +++ b/test/test_stm32_mini_repeater_profile.py @@ -22,8 +22,8 @@ LINK_SIZE_FLAGS = ( "-fno-reorder-blocks", "-fno-reorder-functions", "-fno-tree-switch-conversion", - "-fno-inline-functions-called-once", ) +LINK_ONLY_SIZE_FLAGS = ("-flto-partition=one",) OPTIONAL_CUTS = ( "MESH_ENABLE_CARRIER_TOOLS", "MESH_ENABLE_MANAGEMENT_REPORTING", @@ -121,6 +121,8 @@ class MiniRepeaterProfileTest(unittest.TestCase): tokens = shlex.split(self.flags) for flag in ("-Oz", "-flto", *LINK_SIZE_FLAGS): self.assertIn(flag, tokens) + for flag in LINK_ONLY_SIZE_FLAGS: + self.assertNotIn(flag, tokens) scripts = self.repeater["extra_scripts"].split() self.assertIn("${stm32_base.extra_scripts}", scripts) self.assertIn("post:scripts/stm32_mini_size.py", scripts) @@ -133,7 +135,8 @@ class MiniRepeaterProfileTest(unittest.TestCase): "env": environment, "Import": lambda name: self.assertEqual(name, "env"), }) - expected = ["keep-existing-flag", *(LINK_SIZE_FLAGS if target == TARGET else ())] + expected = ["keep-existing-flag", + *((LINK_SIZE_FLAGS + LINK_ONLY_SIZE_FLAGS) if target == TARGET else ())] self.assertEqual(environment["LINKFLAGS"], expected) self.assertEqual(set(environment), {"PIOENV", "LINKFLAGS"}) self.assertFalse(getattr(environment, "middleware", [])) @@ -193,7 +196,7 @@ class MiniRepeaterProfileTest(unittest.TestCase): run_script(post) self.assertEqual(captured["CCFLAGS"], ["-Oz", "-flto", "-fno-builtin-strcpy"]) self.assertEqual(environment["CCFLAGS"], ["-Oz", "-flto"]) - self.assertEqual(environment["LINKFLAGS"], list(LINK_SIZE_FLAGS)) + self.assertEqual(environment["LINKFLAGS"], list(LINK_SIZE_FLAGS + LINK_ONLY_SIZE_FLAGS)) for target in ("wio-e5-mini_sensor", "wio-e5-mini_companion_radio_usb", "wio-e5_repeater"): environment = FakeEnvironment(PIOENV=target) run_script(pre) diff --git a/variants/wio-e5-mini/platformio.ini b/variants/wio-e5-mini/platformio.ini index 16a2a0e11..60c5a6d51 100644 --- a/variants/wio-e5-mini/platformio.ini +++ b/variants/wio-e5-mini/platformio.ini @@ -44,7 +44,6 @@ build_flags = ${lora_e5_mini.build_flags} -fno-reorder-functions -fno-tree-switch-conversion -fno-inline-small-functions ; reduce flash without moving the deployed filesystem boundary - -fno-inline-functions-called-once -fipa-pta -fmerge-all-constants -fno-ipa-cp-clone From e86710eda2842ef5c9e520631a2b3a95e66cd10e Mon Sep 17 00:00:00 2001 From: mikecarper Date: Wed, 28 Oct 2026 03:40:25 -0700 Subject: [PATCH 20/61] Document emergency power controls and record three-board wake tests --- .github/workflows/run-unit-tests.yml | 1 + docs/cli_commands.md | 8 +- .../emergency_power_2026-10-28.md | 116 ++++ .../emergency_power_2026-10-28.safe.json | 513 ++++++++++++++++ docs/test-results/index.md | 1 + test/test_emergency_power_bench.py | 248 ++++++++ test/test_emergency_sleep_backends.py | 9 + test/test_esp32_cpu_clock_board.py | 8 +- tools/hil/emergency_power_bench.py | 550 ++++++++++++++++++ 9 files changed, 1449 insertions(+), 5 deletions(-) create mode 100644 docs/test-results/emergency_power_2026-10-28.md create mode 100644 docs/test-results/emergency_power_2026-10-28.safe.json create mode 100644 test/test_emergency_power_bench.py create mode 100644 tools/hil/emergency_power_bench.py diff --git a/.github/workflows/run-unit-tests.yml b/.github/workflows/run-unit-tests.yml index 6bbe16c88..adc53f27a 100644 --- a/.github/workflows/run-unit-tests.yml +++ b/.github/workflows/run-unit-tests.yml @@ -598,6 +598,7 @@ jobs: python3 -B test/test_emergency_power.py -v python3 -B test/test_emergency_power_runtime.py -v python3 -B test/test_emergency_power_ota.py -v + python3 -B test/test_emergency_power_bench.py -v python3 -B test/test_emergency_sleep_backends.py -v python3 -B test/test_battery_calibration.py -v python3 -B test/test_battery_calibration_store.py -v diff --git a/docs/cli_commands.md b/docs/cli_commands.md index 5cf9218d1..b01f84a72 100644 --- a/docs/cli_commands.md +++ b/docs/cli_commands.md @@ -3141,6 +3141,9 @@ radio sleeps and normal mesh, sensor and screen work is suspended. The CPU uses timed sleep with brief watchdog, clock, voltage-protection and command maintenance wakes. Active firmware updates and unfinished critical operations defer sleep. Existing hard low-voltage protection remains in force. +On ESP32, an active native USB connection uses short CPU idle waits to keep +the console usable while the radio sleeps. Timed light sleep resumes after +USB detaches and its disconnect grace expires. ```text get power.emergency @@ -3161,8 +3164,9 @@ up to one ten-second maintenance interval while the node is sleeping. `get power.emergency` reports the mode, battery estimate, desired `window`, actual radio ownership, seconds until the next awake window, and remaining -hold. With an unset clock it reports `clock=uptime` and follows the same -15/45-minute cycle from boot until a valid UTC clock is available. Queued +hold. With an implausible clock timestamp it reports `clock=uptime` and follows +the same 15/45-minute cycle from boot. Keep the clock set to UTC: a plausible +timestamp alone does not prove it is synchronized. Queued future traffic does not keep a critically low node awake indefinitely. This schedule uses resumable timed sleep, preserving identity and settings. diff --git a/docs/test-results/emergency_power_2026-10-28.md b/docs/test-results/emergency_power_2026-10-28.md new file mode 100644 index 000000000..b45c06a2f --- /dev/null +++ b/docs/test-results/emergency_power_2026-10-28.md @@ -0,0 +1,116 @@ + + + + +# Emergency battery schedule, 28 October 2026 + +Native checks and **nine packaged firmware builds pass**, using +[`f4f945324`](https://github.com/mikecarper/MeshCore/commit/f4f9453248d351bd91be269ca0a58a6edb51882e), +plus the ESP32 USB recovery fix in +[`be6d636c7`](https://github.com/mikecarper/MeshCore/commit/be6d636c7). +SolarXiao 33S, RAK3401 and Heltec V4.3 passed physical sleep, override, automatic +wake and reboot-persistence trials. Current savings and operation with a real +battery at 5% were not measured. + +## Behavior + +Supported infrastructure images enter automatic emergency mode at **5% or +less**, and recover at **7% or more**, using a voltage-derived battery estimate +sampled every 30 seconds. Invalid readings neither activate the mode nor clear +an existing low-battery state. Existing hard low-voltage shutdown still applies. + +Normal work runs during the first **15 minutes of each UTC hour**. Outside that +window, LoRa sleeps and normal forwarding, sensor and display work pauses. +With an implausible clock timestamp, the same cycle follows monotonic uptime +from boot. A plausible timestamp still needs synchronization to UTC. +Brief maintenance wakes, at intervals of **10 seconds or less**, service commands, +clock and watchdogs. Active updates and unfinished critical work defer sleep. + +```text +get power.emergency +set power.emergency on +set power.awake 30 +set power.awake 0 +set power.emergency auto +``` + +`on` persists manual emergency mode; `auto` restores the default automatic +policy. Entry allows five seconds for the reply. `power.awake` grants a runtime +override of 0–10080 minutes; zero cancels it and reboot clears it. LoRa commands +require an awake window. Local USB can wait for the next maintenance wake. +Status separates the desired `window` from actual radio ownership. + +nRF52 retains state through timed RTOS delay and idle sleep; ESP32 uses +timer-woken light sleep. Neither path uses a rebooting deep-sleep cycle. +ESP32 keeps an attached native USB console usable with short CPU idle waits +instead of peripheral-stopping light sleep; detached operation uses timed light sleep. +Wireless services restore their previous live subset and preserve manual +wireless-off settings. RAK3401 places its amplifier in standby and restores it +before radio wake, without cutting the shared supply rail. + +Forwarding defaults move from 10/20% to **15/25%**. The exact old pair migrates +from a v1 record; other pairs, enabled controls and voltage endpoints survive. +A v2 record preserves an explicitly selected 10/20 pair. Companion and profiles +that omit battery-forwarding controls retain their existing feature scope. + +## Checks + +**85 native tests across eleven suites pass**: policy/CLI 7, runtime 13, platform +backends 3, OTA work 1, battery forwarding 20, build-role policy 8, wireless 4, +logging/sleep 3, sleep deadlines 3, CPU clock 9, and bench harness 14. The OTA test compiles +three configurations and executes the actual guards for all three roles. +Coverage includes rollover, failed persistence, reply holds, active transfers, +bounded serving holds, wireless restoration failures and prior Tracker ownership. + +| Packaged firmware | Capability checks | Available / required RAM, bytes | +| --- | ---: | ---: | +| SolarXiao 33S Repeater Full | 11/11 | 151,348 / 82,784 | +| SolarXiao 33S Repeater Reduced | 9/9 | 152,528 / 80,736 | +| RAK3401 Repeater Full | 14/14 | 80,124 / 75,920 | +| RAK3401 Repeater Reduced | 12/12 | 82,648 / 73,872 | +| Heltec V4 Full MQTT Repeater | 16/16 | 240,112 / 238,732 | +| Heltec V4 Full MQTT Repeater, USB fix | 16/16 | 240,112 / 238,732 | +| SolarXiao 33S Room Server Full | 7/7 | 157,108 / 67,328 | +| SolarXiao 33S Room Server Reduced | 5/5 | 158,264 / 65,280 | +| Heltec V4 Sensor Full | 9/9 | 271,872 / 150,592 | + +Memory admission and packaged update-path checks pass. These are linked capacity +estimates, not measurements of peak runtime heap. PlatformIO ran sequentially. + +The [safe machine-readable report](emergency_power_2026-10-28.safe.json) records +ELF, package and manifest hashes. Package hashes were independently compared +with every corresponding memory-manifest entry. + +## Physical trials + +Each board entered manual radio sleep, woke for a real one-minute override, +returned to sleep when it expired, and retained manual mode after reboot. +Two accelerated hourly boundaries then produced queued adverts on an +independent RAW receiver before any further USB write to the target. + +| Target | Independent receiver | Passive capture intervals | +| --- | --- | --- | +| SolarXiao 33S | RAK3401 | 31.586 / 31.587 s | +| RAK3401 | SolarXiao 33S | 31.586 / 31.583 s | +| Heltec V4.3 OLED | RAK3401 | 21.730 / 21.677 s | + +The clock was set near the hour boundary; these intervals are evidence of +autonomous RF resumption, not a measurement of the CPU's exact wake latency. +The V4's initial build lost its USB console during off-window light sleep. +The corrected build kept USB responsive while RF slept and passed the whole +trial. WiFi/NTP was temporarily stopped so it could not reset the test clock. + +All identities, existing settings and present clocks were checked after +cleanup. SolarXiao retains the tested Full Repeater in automatic mode with no +awake hold. RAK3401 has its original unified Repeater; V4 has its original Full +Companion and verified original NVS/filesystem. The Pi collector is active +with its original configuration and serial ownership. + +## Limits + +Trials used manual entry and USB power. They do not qualify real 5% battery +entry, current draw, or a continuous 45-minute sleep interval. V4 exercised +the USB-held CPU idle path; detached light-sleep current remains unmeasured. +Solar LTC4162 telemetry was unavailable, and the self ADC is not carrier state +of charge. Room and Sensor hooks have native/build coverage, not physical +trials. Sustained RF delivery and maximum runtime load need separate evidence. diff --git a/docs/test-results/emergency_power_2026-10-28.safe.json b/docs/test-results/emergency_power_2026-10-28.safe.json new file mode 100644 index 000000000..2a1a8e86c --- /dev/null +++ b/docs/test-results/emergency_power_2026-10-28.safe.json @@ -0,0 +1,513 @@ +{ + "schema_version": 1, + "date": "2026-10-28", + "source_commit": "f4f9453248d351bd91be269ca0a58a6edb51882e", + "implementation_commits": [ + "0c7dec9f1", + "f4f945324", + "5cbff1946", + "be6d636c7" + ], + "scope": "Native policy/runtime checks, selected production packaging, and three restored hardware trials; current draw and real low-battery entry remain unmeasured.", + "policy": { + "automatic_enter_percent_max": 5, + "automatic_release_percent_min": 7, + "sample_interval_ms": 30000, + "utc_awake_window_seconds": [ + 0, + 900 + ], + "hour_seconds": 3600, + "fallback_clock": "boot-relative monotonic uptime", + "reply_hold_ms": 5000, + "maximum_cpu_sleep_slice_ms": 10000, + "maximum_awake_override_minutes": 10080, + "default_mode": "auto", + "manual_mode_persisted": true, + "awake_override_persisted": false, + "hard_low_voltage_protection_preserved": true, + "platform_sleep": { + "nrf52": "Retained RTOS timed delay with idle sleep and watchdog service; no SYSTEMOFF/reboot.", + "esp32": "Timed light sleep when detached; native USB uses bounded RTOS idle waits and a reserved post-wake USB service interval." + }, + "forwarding_defaults": { + "repeat_below_percent": 15, + "flood_below_percent": 25, + "v1_migration": "Only the exact legacy 10/20 pair migrates to 15/25; enabled bits/endpoints and other pairs are preserved.", + "v2_custom_10_20_preserved": true + }, + "commands": [ + "get power.emergency", + "set power.emergency on", + "set power.emergency auto", + "set power.awake <0..10080 minutes>" + ] + }, + "native_tests": { + "status": "passed", + "total_test_methods": 85, + "suites": { + "test_emergency_power.py": 7, + "test_emergency_power_runtime.py": 13, + "test_emergency_sleep_backends.py": 3, + "test_emergency_power_ota.py": 1, + "test_battery_forwarding.py": 20, + "test_client_acl_role.py": 8, + "test_wireless_control.py": 4, + "test_logging_sleep_contract.py": 3, + "test_power_saving_deadlines.py": 3, + "test_emergency_power_bench.py": 14, + "test_esp32_cpu_clock_board.py": 9 + }, + "ota_role_test_compile_configurations": 3, + "qualification": "Native fakes validate state and call ordering; they do not measure physical current or battery state." + }, + "builds": [ + { + "artifact_target": "solarxiao_33S_repeater-full-ota", + "pio_environment": "solarxiao_33S_repeater", + "platform": "NRF52_PLATFORM", + "mcu": "nrf52840", + "passed": true, + "capability_checks": { + "passed": 11, + "total": 11 + }, + "ota_update_verified": true, + "memory": { + "passed": true, + "elf_sha256": "e70e0dc40d7b57b026ac79dc2bbaf5ceb1517e8ec2f92a0ae65acb71125a1634", + "available_internal_bytes": 151348, + "largest_internal_region_bytes": 151348, + "required_heap_bytes": 82784, + "required_contiguous_bytes": 19168 + }, + "memory_manifest_sha256": "5c9b97e0f5643fe4c48e446d373d78278fb3ce1446522f3166c1eb352a189bf4", + "capabilities_manifest_sha256": "f487331b66faaff19771d713468ff9b02f77a60cadf18d104527d3fa32332ab3", + "artifact_files": { + "solarxiao_33S_repeater-full-ota-v1.18.0.2-halo-keymind-cascade-dev-f4f94532.uf2": { + "sha256": "8baab7d62feed91eb7382f2e38304bcedabff15f92dfca889cde1d33c879359e", + "size_bytes": 1255424 + }, + "solarxiao_33S_repeater-full-ota-v1.18.0.2-halo-keymind-cascade-dev-f4f94532.zip": { + "sha256": "da62778bc5add51749deb9e36e5fbe781c9e277d404a3117ca655b0ab57cb6a0", + "size_bytes": 628363 + } + } + }, + { + "artifact_target": "solarxiao_33S_repeater-reduced-ota", + "pio_environment": "solarxiao_33S_repeater", + "platform": "NRF52_PLATFORM", + "mcu": "nrf52840", + "passed": true, + "capability_checks": { + "passed": 9, + "total": 9 + }, + "ota_update_verified": true, + "memory": { + "passed": true, + "elf_sha256": "b3568203b8550be591a0937355c4764bd3de5cdc7007be27cd2bf33a33ef8e83", + "available_internal_bytes": 152528, + "largest_internal_region_bytes": 152528, + "required_heap_bytes": 80736, + "required_contiguous_bytes": 19168 + }, + "memory_manifest_sha256": "b5cf910705fff6ad5333f65a9c7e282f9757ce53a1ad5d26385c70a9ff525ba5", + "capabilities_manifest_sha256": "f1148c7fbc1448bde7a6b244ac179f34c0cb47d366736eed7a1f94e3a1dbf07b", + "artifact_files": { + "solarxiao_33S_repeater-reduced-ota-v1.18.0.2-halo-keymind-cascade-dev-f4f94532.uf2": { + "sha256": "1f7dc7d181704b975f4ac598551ea8c8b6cebf89b301826bfb551821e33f37bd", + "size_bytes": 1181696 + }, + "solarxiao_33S_repeater-reduced-ota-v1.18.0.2-halo-keymind-cascade-dev-f4f94532.zip": { + "sha256": "fc8345308fee06c0059b8f0791ded9c04a8d3a5fdb9212fde64961fa32bb4e0d", + "size_bytes": 591611 + } + } + }, + { + "artifact_target": "RAK_3401_repeater_lora_ota_no_external_sensors-full-ota", + "pio_environment": "RAK_3401_repeater", + "platform": "NRF52_PLATFORM", + "mcu": "nrf52840", + "passed": true, + "capability_checks": { + "passed": 14, + "total": 14 + }, + "ota_update_verified": true, + "memory": { + "passed": true, + "elf_sha256": "8e4d72940ae57e8c4aaf4c1e14efdd42a26c9f1c717bef2dfae19ddc2217507b", + "available_internal_bytes": 80124, + "largest_internal_region_bytes": 80124, + "required_heap_bytes": 75920, + "required_contiguous_bytes": 19168 + }, + "memory_manifest_sha256": "fdc17070df586a0b8a5341c4934bb6697b9dfad5d12943383166211b0e442f23", + "capabilities_manifest_sha256": "fb661625a4851dfb53b889e524aec4ee4c604a4eff675f9513a47c65c32ea768", + "artifact_files": { + "RAK_3401_repeater_lora_ota_no_external_sensors-full-ota-v1.18.0.2-halo-keymind-cascade-dev-f4f94532.uf2": { + "sha256": "498fbfb12cb2abd0969456752bf39adbaa82134c53e63be3d6fa594be04cb593", + "size_bytes": 1255936 + }, + "RAK_3401_repeater_lora_ota_no_external_sensors-full-ota-v1.18.0.2-halo-keymind-cascade-dev-f4f94532.zip": { + "sha256": "3d979e38b38d1b4af77d68b21ca67b7a2d9cf3f2cf3ed4e35051b354dafb4807", + "size_bytes": 628603 + } + } + }, + { + "artifact_target": "RAK_3401_repeater_lora_ota_no_external_sensors-reduced-ota", + "pio_environment": "RAK_3401_repeater", + "platform": "NRF52_PLATFORM", + "mcu": "nrf52840", + "passed": true, + "capability_checks": { + "passed": 12, + "total": 12 + }, + "ota_update_verified": true, + "memory": { + "passed": true, + "elf_sha256": "d2862c5939afcd4401bbe8fdb31f980ee9a48e92f89b45e6e840bb3a990a5213", + "available_internal_bytes": 82648, + "largest_internal_region_bytes": 82648, + "required_heap_bytes": 73872, + "required_contiguous_bytes": 19168 + }, + "memory_manifest_sha256": "923ec558f2a9e4c236f543c0c31121bd601ffbaa753268d20ab52725daea37b1", + "capabilities_manifest_sha256": "4e4a91cba3cecab699ddcdb215d2b020f766fd1843d45e19244cd6dfa432ca1f", + "artifact_files": { + "RAK_3401_repeater_lora_ota_no_external_sensors-reduced-ota-v1.18.0.2-halo-keymind-cascade-dev-f4f94532.uf2": { + "sha256": "eac7a79110c130dfde00b771cd58dd2fd791c559b8b96df2f0369c99d8a6d742", + "size_bytes": 1137152 + }, + "RAK_3401_repeater_lora_ota_no_external_sensors-reduced-ota-v1.18.0.2-halo-keymind-cascade-dev-f4f94532.zip": { + "sha256": "81f5c2285a6407346cdcdcbf33c090ece3590ee1f497817aea07f5b1da098b1c", + "size_bytes": 569367 + } + } + }, + { + "artifact_target": "heltec_v4_repeater_observer_mqtt", + "pio_environment": "heltec_v4_repeater_observer_mqtt", + "platform": "ESP32_PLATFORM", + "mcu": "esp32s3", + "passed": true, + "capability_checks": { + "passed": 16, + "total": 16 + }, + "ota_update_verified": true, + "memory": { + "passed": true, + "elf_sha256": "fe7c84f61b24ed87b3451b9d7d62e5df38dd2382a7456f3b3b838901d064b13d", + "available_internal_bytes": 240112, + "largest_internal_region_bytes": 207344, + "required_heap_bytes": 238732, + "required_contiguous_bytes": 19168 + }, + "memory_manifest_sha256": "d8a38eceb3563314ac8659466d57e0ba225173fcbb89ed469d26f025c0e4a4eb", + "capabilities_manifest_sha256": "26c650a6e3086486a35fc7e6404f31d74e56930ab2497d36de5dff3b126f6956", + "artifact_files": { + "heltec_v4_repeater_observer_mqtt-full-usb-wifi-ota-v1.18.0.2-halo-keymind-cascade-dev-f4f94532.bin": { + "sha256": "e4814e3a0304be78ec26f3ec54514dced662c6a851b49508e37c2096f33b6e88", + "size_bytes": 2176680 + }, + "heltec_v4_repeater_observer_mqtt-full-usb-wifi-ota-v1.18.0.2-halo-keymind-cascade-dev-f4f94532-merged.bin": { + "sha256": "958f0aae6cb8d87b3fcd9fe04616988b5e48b7a6ec2a521252b06333ffce8cad", + "size_bytes": 2242216 + } + } + }, + { + "artifact_target": "solarxiao_33S_room_server_lora_ota-full-ota", + "pio_environment": "solarxiao_33S_room_server_lora_ota", + "platform": "NRF52_PLATFORM", + "mcu": "nrf52840", + "passed": true, + "capability_checks": { + "passed": 7, + "total": 7 + }, + "ota_update_verified": true, + "memory": { + "passed": true, + "elf_sha256": "e6d9ae0909d1bb9d34cd434e0fe50ce7752672cb5b9e4651e86580c6938a831f", + "available_internal_bytes": 157108, + "largest_internal_region_bytes": 157108, + "required_heap_bytes": 67328, + "required_contiguous_bytes": 10256 + }, + "memory_manifest_sha256": "398652d19d46ea3011404a91cd7cd9b4d5916ae26732f239f5054fee362d9b47", + "capabilities_manifest_sha256": "e6e68d818c297f2c3dd3732c94c92314ade500f10f4384fb13ffac132ebd4398", + "artifact_files": { + "solarxiao_33S_room_server_lora_ota-full-ota-v1.18.0.2-halo-keymind-cascade-dev-f4f94532.uf2": { + "sha256": "dda7c6d03e4a7a507ea174cde1a6e7ef478e783637b2cd5144d63c670c439780", + "size_bytes": 1095680 + }, + "solarxiao_33S_room_server_lora_ota-full-ota-v1.18.0.2-halo-keymind-cascade-dev-f4f94532.zip": { + "sha256": "f12af1af797d502963d23c34f44c3934d3791dee2c5f2e6ac910fa6ec102063a", + "size_bytes": 548491 + } + } + }, + { + "artifact_target": "solarxiao_33S_room_server_lora_ota-reduced-ota", + "pio_environment": "solarxiao_33S_room_server_lora_ota", + "platform": "NRF52_PLATFORM", + "mcu": "nrf52840", + "passed": true, + "capability_checks": { + "passed": 5, + "total": 5 + }, + "ota_update_verified": true, + "memory": { + "passed": true, + "elf_sha256": "05d808a0d74a76bc0e3b14d4d54b75e059e36193f366601605b3c78bece1cbc4", + "available_internal_bytes": 158264, + "largest_internal_region_bytes": 158264, + "required_heap_bytes": 65280, + "required_contiguous_bytes": 10256 + }, + "memory_manifest_sha256": "1b27404c085b6dc2b6363b4e6a47ef2cfc6afc11ee32c5f1a9bf6bf22a2bf884", + "capabilities_manifest_sha256": "b64e5229c28253c2df916dbe2ac1eba65076594ca640d0b51c10d58385920412", + "artifact_files": { + "solarxiao_33S_room_server_lora_ota-reduced-ota-v1.18.0.2-halo-keymind-cascade-dev-f4f94532.uf2": { + "sha256": "a56a7cfae82bb28f0579206a0c19d1f75e04ff366510a01c7fb978af0a045e11", + "size_bytes": 1022976 + }, + "solarxiao_33S_room_server_lora_ota-reduced-ota-v1.18.0.2-halo-keymind-cascade-dev-f4f94532.zip": { + "sha256": "b93ba11be67c9e74fb0dab4b21310fed65f47d856652a7b037b139f01cf22867", + "size_bytes": 512115 + } + } + }, + { + "artifact_target": "heltec_v4_sensor", + "pio_environment": "heltec_v4_sensor", + "platform": "ESP32_PLATFORM", + "mcu": "esp32s3", + "passed": true, + "capability_checks": { + "passed": 9, + "total": 9 + }, + "ota_update_verified": true, + "memory": { + "passed": true, + "elf_sha256": "f8956d176b5a15df8818f0955779934340d735b7f2f8a30044dd1ddb0d574ab5", + "available_internal_bytes": 271872, + "largest_internal_region_bytes": 239104, + "required_heap_bytes": 150592, + "required_contiguous_bytes": 18464 + }, + "memory_manifest_sha256": "fa55caceb0882d2db0c8fca5bf12ffa5b84289d6347da99521e132646b063069", + "capabilities_manifest_sha256": "1b713a6e1073731e76cc360563087cea10f9385d69aec3992bb166aaf1498f5c", + "artifact_files": { + "heltec_v4_sensor-full-ota-v1.18.0.2-halo-keymind-cascade-dev-f4f94532.bin": { + "sha256": "b16e69620277fa98079bcd9516a6f9d4254a04a07914dc8951d293c10dab3fb8", + "size_bytes": 1541288 + }, + "heltec_v4_sensor-full-ota-v1.18.0.2-halo-keymind-cascade-dev-f4f94532-merged.bin": { + "sha256": "3fe8fbe319cf6d2ba389f9fd088fdbee04d7195fafa3220d4c14a7bb87ab8a1e", + "size_bytes": 1606824 + } + } + } + ], + "additional_builds": { + "heltec_v4_native_usb_fix": { + "source_commit": "be6d636c79413fbadf7f23a1fc97cd9f92b76ba6", + "artifact_target": "heltec_v4_repeater_observer_mqtt", + "pio_environment": "heltec_v4_repeater_observer_mqtt", + "platform": "ESP32_PLATFORM", + "mcu": "esp32s3", + "passed": true, + "capability_checks": { + "passed": 16, + "total": 16 + }, + "memory": { + "passed": true, + "elf_sha256": "4c74d111ae0c535a69657ba5807f5dcc2b2e5b4b9aabdaa371f6d3a3ddb33d42", + "available_internal_bytes": 240112, + "largest_internal_region_bytes": 207344, + "required_heap_bytes": 238732, + "required_contiguous_bytes": 19168 + }, + "memory_manifest_sha256": "d2663a83298dbd4daa12e675a8737e8948fc16b5cdda71a9ded57f3c528e9820", + "capabilities_manifest_sha256": "afb6fc8e8f883f2b03e436461aef3c4acedbeaddef7ba55b0b42215439566c62", + "artifact_files": { + "heltec_v4_repeater_observer_mqtt-full-usb-wifi-ota-v1.18.0.2-halo-keymind-cascade-dev-be6d636c.bin": { + "sha256": "145afa4085b37f9c2eec0e5ba39c1aad045c46dfd0c476bc2ed1977d1dd0213a", + "size_bytes": 2177160 + }, + "heltec_v4_repeater_observer_mqtt-full-usb-wifi-ota-v1.18.0.2-halo-keymind-cascade-dev-be6d636c-merged.bin": { + "sha256": "a78920426a9f7edff7620425cca813e76df8dde228022573c744823bbbbee065", + "size_bytes": 2242696 + } + } + } + }, + "physical_validation": { + "status": "passed_with_documented_limits", + "schema_version": 1, + "bench_profile": { + "frequency_mhz": 909.5, + "bandwidth_khz": 500, + "spreading_factor": 5, + "coding_rate": 5, + "configured_tx_dbm": -9 + }, + "boards": [ + { + "board": "SolarXiao 33S", + "tested_version": "v1.18.0.2-halo-keymind-cascade-dev-f4f94532 (Build: 28-Oct-2026)", + "installed_version": "v1.18.0.2-halo-keymind-cascade-dev-f4f94532 (Build: 28-Oct-2026)", + "trial_passed": true, + "manual_radio_sleep_verified": true, + "independent_witness_board": "RAK3401", + "baseline_rf_advert_received": true, + "awake_lease": { + "requested_seconds": 60, + "real_time_expiry_verified": true, + "clock_not_accelerated_during_lease": true + }, + "accelerated_hourly_wake_cycles": [ + { + "no_target_usb_writes_seconds": 31.586, + "independent_raw_capture_before_status_query": true, + "expected_hour_boundary_timestamp": true, + "radio_awake_after_capture": true + }, + { + "no_target_usb_writes_seconds": 31.587, + "independent_raw_capture_before_status_query": true, + "expected_hour_boundary_timestamp": true, + "radio_awake_after_capture": true + } + ], + "manual_mode_survived_reboot": true, + "cleanup_passed": true, + "final_state": { + "role": "Repeater", + "identity_equal": true, + "existing_settings_equal": true, + "present_clock_confirmed": true, + "emergency_cli_supported": true, + "emergency_mode": "auto", + "active": "off", + "radio": "awake", + "awake_hold_seconds": 0 + } + }, + { + "board": "RAK3401", + "tested_version": "v1.18.0.2-halo-keymind-cascade-dev-f4f94532 (Build: 28-Oct-2026)", + "installed_version": "v1.18.0.1-halo-keymind-cascade-dev-4c0f2acf (Build: 23-Oct-2026)", + "trial_passed": true, + "manual_radio_sleep_verified": true, + "independent_witness_board": "SolarXiao 33S", + "baseline_rf_advert_received": true, + "awake_lease": { + "requested_seconds": 60, + "real_time_expiry_verified": true, + "clock_not_accelerated_during_lease": true + }, + "accelerated_hourly_wake_cycles": [ + { + "no_target_usb_writes_seconds": 31.586, + "independent_raw_capture_before_status_query": true, + "expected_hour_boundary_timestamp": true, + "radio_awake_after_capture": true + }, + { + "no_target_usb_writes_seconds": 31.583, + "independent_raw_capture_before_status_query": true, + "expected_hour_boundary_timestamp": true, + "radio_awake_after_capture": true + } + ], + "manual_mode_survived_reboot": true, + "cleanup_passed": true, + "final_state": { + "role": "Repeater", + "identity_equal": true, + "existing_settings_equal": true, + "present_clock_confirmed": true, + "emergency_cli_supported": false, + "auto_and_zero_awake_hold_confirmed_before_original_application_restore": true + }, + "original_unified_application_restored": true + }, + { + "board": "Heltec V4.3 OLED", + "tested_version": "v1.18.0.2-halo-keymind-cascade-dev-observer-be6d636c (Build: 28-Oct-2026)", + "installed_version": "Companion v1.18.0.2-kiss-test-ec4aba8a (protocol 14, build 27-Oct-2026)", + "trial_passed": true, + "manual_radio_sleep_verified": true, + "independent_witness_board": "RAK3401", + "baseline_rf_advert_received": true, + "awake_lease": { + "requested_seconds": 60, + "real_time_expiry_verified": true, + "clock_not_accelerated_during_lease": true + }, + "accelerated_hourly_wake_cycles": [ + { + "no_target_usb_writes_seconds": 21.73, + "independent_raw_capture_before_status_query": true, + "expected_hour_boundary_timestamp": true, + "radio_awake_after_capture": true + }, + { + "no_target_usb_writes_seconds": 21.677, + "independent_raw_capture_before_status_query": true, + "expected_hour_boundary_timestamp": true, + "radio_awake_after_capture": true + } + ], + "manual_mode_survived_reboot": true, + "cleanup_passed": true, + "final_state": { + "role": "Full Companion", + "identity_equal": true, + "existing_settings_equal": true, + "present_clock_confirmed": true, + "emergency_cli_supported": false, + "auto_and_zero_awake_hold_confirmed_before_original_application_restore": true + }, + "usb_console_usable_while_radio_asleep": true, + "usb_held_cpu_idle_fallback": true, + "wireless_runtime_subset_restored": true, + "original_application_and_data_readbacks_verified": true, + "earlier_usb_sleep_regression_reproduced_and_fixed": true + } + ], + "collector": { + "active": true, + "unit_configuration_equal": true, + "serial_owned_by_collector": true + }, + "bench_native_regressions": { + "tests": 14, + "passed": true + }, + "measurement_limits": [ + "All physical emergency tests used manual entry while USB powered; the actual 5% battery trigger and charging hysteresis were not physically exercised.", + "No current draw or power-saving percentage was measured.", + "Hourly boundaries used accelerated isolated RTC values; this was not a real 45-minute sleep-duration test.", + "V4 used the USB-held CPU idle fallback while RF/wireless slept; detached USB light-sleep current was not measured.", + "Physical tests used Repeater firmware; Room and Sensor hooks were validated separately by native tests and builds." + ], + "current_draw_measured": false, + "real_five_percent_battery_qualified": false, + "continuous_45_minute_sleep_qualified": false, + "bench_source_sha256": "2c4799cb239f9d092d5cf46c0366408aa95f766f786f56af4b913e07b636418f" + } +} diff --git a/docs/test-results/index.md b/docs/test-results/index.md index 2159a352e..c06696ea8 100644 --- a/docs/test-results/index.md +++ b/docs/test-results/index.md @@ -77,6 +77,7 @@ Completed validation reports, experiments, and test guides are collected here. O ## Build, storage, memory, and checklists +- [Emergency battery schedule: native and firmware build checks, 2026-10-28](emergency_power_2026-10-28.md) - [RAK4631 Full/Reduced RAM repair and RAK3401 Room Server radio/sleep qualification, 2026-10-22](rak_release_fixes_2026-10-22.md) - [Shared battery ADC calibration validation, 2026-10-22](battery_calibration_2026-10-22.md) - [Heltec V2 BLE RAM reserve repair, 2026-10-22](heltec_v2_ble_memory_2026-10-22.md) diff --git a/test/test_emergency_power_bench.py b/test/test_emergency_power_bench.py new file mode 100644 index 000000000..bd30d5c89 --- /dev/null +++ b/test/test_emergency_power_bench.py @@ -0,0 +1,248 @@ +"""Host harness guards; these do not claim hardware or battery qualification.""" +from contextlib import contextmanager +import importlib.util +import json +from pathlib import Path +import tempfile +import threading +import time +import types +import unittest +from unittest import mock + + +PATH = Path(__file__).resolve().parents[1] / "tools/hil/emergency_power_bench.py" +SPEC = importlib.util.spec_from_file_location("emergency_power_bench", PATH) +bench = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(bench) + + +def reply(command, value): + return command + "\n -> > " + value + "\n" + + +class EmergencyBench(unittest.TestCase): + def test_status_requires_every_field_and_seconds(self): + text = "mode=manual active=on battery=5% clock=rtc window=sleep radio=sleep wake=2699s hold=0s" + state = bench.status(text) + self.assertEqual(state["wake"], 2699) + self.assertEqual(state["hold"], 0) + self.assertEqual(state["mode"], "manual") + for bad in (text.replace("2699s", "2699m"), text.replace(" radio=sleep", ""), + text.replace("mode=manual", "mode=unknown")): + with self.assertRaises(RuntimeError): + bench.status(bad) + + def test_raw_advert_uses_actual_route_path_identity_and_timestamp(self): + identity = bytes(range(32)) + epoch = 1793000000 + payload = identity + epoch.to_bytes(4, "little") + bytes(64) + b"node" + for route in range(4): + for width in (1, 2, 3): + raw = bytes((16 | route,)) + (bytes(4) if route in (0, 3) else b"") + raw += bytes((((width - 1) << 6) | 2,)) + b"x" * (width * 2) + payload + self.assertEqual(bench.advert_timestamp("2026 RAW: " + raw.hex(), identity), epoch) + self.assertEqual(bench.advert_timestamp("R" + raw.hex(), identity), epoch) + self.assertIsNone(bench.advert_timestamp("TX RAW? " + raw.hex(), identity)) + self.assertIsNone(bench.advert_timestamp("R" + raw.hex(), bytes(32))) + self.assertIsNone(bench.advert_timestamp("R" + raw[:10].hex(), identity)) + for header, path in ((16, 192), (16, 127), (20, 0), (80, 0)): + self.assertIsNone(bench.advert_timestamp("R" + (bytes((header, path)) + payload).hex(), identity)) + + def test_reply_and_identity_require_complete_exact_key(self): + self.assertEqual(bench.public_key(reply("get public.key", "12" * 32)), b"\x12" * 32) + for value in ("12" * 31, "12" * 33, "x" * 64, "pending"): + with self.assertRaises(RuntimeError): + bench.public_key(reply("get public.key", value)) + with self.assertRaises(RuntimeError): + bench.reply_value("get radio\n> partial") + + def test_rxps_restore_preserves_level_or_manual_timings(self): + self.assertEqual(bench.rxps_restore("off,level=8,preamble=16,rx=39657,sleep=15975"), "set radio.rxps off") + self.assertEqual(bench.rxps_restore("on,level=8,preamble=16,rx=39657,sleep=15975"), "set radio.rxps level 8 preamble 16") + self.assertEqual(bench.rxps_restore("on,level=0,preamble=0,rx=65625,sleep=60000"), "set radio.rxps 65625 60000") + for bad in ("unknown setting", "on", "on,level=8,preamble=16,rx=1"): + with self.assertRaises(RuntimeError): + bench.rxps_restore(bad) + + def test_clock_uses_real_minute_precision_cli(self): + epoch = bench.clock_epoch(reply("clock", "12:34 - 28/10/2026 UTC")) + self.assertEqual(epoch % 3600, 34 * 60) + with self.assertRaises(RuntimeError): + bench.clock_epoch(reply("clock", "OK")) + + def test_passive_capture_matches_key_and_minimum_epoch_without_command(self): + identity = bytes(range(32)) + witness = types.SimpleNamespace(condition=threading.Condition(), failed=False) + raw = bytes((17, 0)) + identity + (200).to_bytes(4, "little") + bytes(64) + witness.lines = [(8, "RAW: " + raw.hex()), (12, "RAW: " + raw.hex())] + observed = bench.await_advert(witness, identity, after=10, minimum_epoch=200, timeout=.1) + self.assertEqual(observed, {"received_monotonic": 12, "advert_epoch": 200}) + with self.assertRaises(RuntimeError): + bench.await_advert(witness, identity, after=10, minimum_epoch=201, timeout=.01) + with self.assertRaises(RuntimeError): + bench.await_advert(witness, identity, after=10, minimum_epoch=100, + maximum_epoch=150, timeout=.01) + + def test_preexisting_manual_mode_is_not_changed_during_failed_preflight(self): + calls = [] + collector_events = [] + + class FakeConsole: + def __init__(self, serial_number, path): + self.serial_number = serial_number + + def command(self, command, **kwargs): + calls.append(command) + values = {"get public.key": "12" * 32, "board": "SolarXiao 33S" if self.serial_number.startswith("9E") else "RAK 3401", + "ver": "verified build", "get power.emergency": "mode=manual active=on battery=unknown clock=rtc window=sleep radio=sleep wake=200s hold=0s"} + return reply(command, values[command]) + + def close(self): + pass + + @contextmanager + def collector(): + collector_events.append("pause") + try: + yield + finally: + collector_events.append("restore") + + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + backup = root / "baseline.json" + backup.write_text(json.dumps({"usb_serial": bench.TARGETS["solarxiao33s"][0], + "queries": {"get public.key": reply("get public.key", "12" * 32)}})) + args = types.SimpleNamespace(run_dir=root / "run", identity_backup=backup, + target="solarxiao33s", witness=None, expected_version="verified build") + with mock.patch.object(bench, "Console", FakeConsole), mock.patch.object(bench, "paused_collector", collector), \ + mock.patch.object(bench, "snapshot", return_value={}), mock.patch("builtins.print"): + with self.assertRaisesRegex(RuntimeError, "automatic mode"): + bench.run_trial(args) + self.assertFalse(any(command.startswith(("set ", "time ", "clkreboot")) for command in calls)) + self.assertEqual(collector_events, ["pause", "restore"]) + report = json.loads((args.run_dir / "report.json").read_text()) + self.assertFalse(report["passed"]) + self.assertEqual(report["cleanup"], "complete") + + def test_default_plan_opens_no_port_or_service(self): + arguments = ["bench", "--target", "rak3401", "--expected-version", "verified", "--identity-backup", "unused.json", "--run-dir", "unused"] + with mock.patch("sys.argv", arguments), mock.patch.object(bench, "run_trial") as run, mock.patch("builtins.print"): + bench.main() + run.assert_not_called() + + def test_console_initialization_delimits_stale_input_without_reset_pulse(self): + writes = [] + + class Port: + def __init__(self, **kwargs): + self.dtr = self.rts = None + + def open(self): + self_values = (self.dtr, self.rts, self.port) + if self_values != (True, False, "/dev/ttyACM10"): + raise AssertionError(self_values) + + def write(self, value): + writes.append(value) + + def flush(self): + pass + + def close(self): + pass + + serial_module = types.SimpleNamespace(Serial=Port) + ports = types.SimpleNamespace(comports=lambda: [types.SimpleNamespace( + serial_number="9EBCE8853076EE86", interface="TinyUSB Serial", device="/dev/ttyACM10")]) + with tempfile.TemporaryDirectory() as directory, \ + mock.patch.dict("sys.modules", {"serial": serial_module, "serial.tools": types.SimpleNamespace(list_ports=ports)}), \ + mock.patch.object(bench.subprocess, "run", return_value=types.SimpleNamespace(returncode=1, stdout=b"", stderr=b"")), \ + mock.patch.object(bench.threading, "Thread"), mock.patch.object(bench.time, "sleep"): + console = bench.Console("9EBCE8853076EE86", Path(directory) / "private.log") + console.close() + self.assertEqual(writes, [b"\r"]) + + def test_snapshot_preserves_disabled_radio2_ownership_annotation(self): + values = {"radio": "910.5250244,62.5,7,5", "tx": "-6", "repeat": "on", "tempradio": "off", + "radio.rxps.config": "off,level=0,preamble=0,rx=65625,sleep=60000", + "usb.logging": "on", "usb.logging.always": "off", "radioat": "-none-", + "tempradioat": "-none-", "radioat2": "slots:", "tempradioat2": "slots:"} + console = types.SimpleNamespace(command=lambda command, **kwargs: reply(command, values[command[4:]])) + for disabled in ("off", "off (manual)", "off (automatic allowed)"): + values["radio2"] = disabled + self.assertEqual(bench.snapshot(console)["radio2"], disabled) + values["radio2"] = "911.150,250,7,5,rxtx,32" + with self.assertRaisesRegex(RuntimeError, "inactive"): + bench.snapshot(console) + values["radio2"] = "off" + for key in ("radioat", "tempradioat", "radioat2", "tempradioat2"): + previous = values[key] + values[key] = "1:911.5,125,7,5@1900000000" + with self.assertRaisesRegex(RuntimeError, "empty future"): + bench.snapshot(console) + values[key] = previous + + def test_radio_admission_never_confuses_reboot_required_with_live_retune(self): + calls = [] + def command(text): + calls.append(text) + return reply(text, "OK - reboot to apply") + console = types.SimpleNamespace(command=command) + with mock.patch.object(bench.time, "sleep") as sleep: + with self.assertRaisesRegex(RuntimeError, "saved getter"): + bench.apply_radio(console, bench.BENCH_RADIO) + self.assertEqual(calls, ["set radio " + bench.BENCH_RADIO]) + sleep.assert_not_called() + + def test_rf_witness_cannot_share_an_original_main_frequency(self): + target = {"radio": "910.5250244,62.5,7,5"} + bench.require_distinct_original_frequencies(target, {"radio": "917,62.5,7,5"}) + with self.assertRaisesRegex(RuntimeError, "different original"): + bench.require_distinct_original_frequencies(target, {"radio": "910.525,125,8,5"}) + + def test_wireless_snapshot_distinguishes_pending_and_failure(self): + console = types.SimpleNamespace(command=lambda command: reply(command, + "wifi=on, bluetooth=unavailable, espnow=off (pending)")) + self.assertEqual(bench.wireless_snapshot(console), + {"wifi": "on", "bluetooth": "unavailable", "espnow": "off", "pending": True}) + console.command = lambda command: reply(command, + "wifi=off, bluetooth=unavailable, espnow=off (failed: retry command)") + with self.assertRaisesRegex(RuntimeError, "failed transition"): + bench.wireless_snapshot(console) + + def test_network_clock_hold_restores_only_original_runtime_subset(self): + calls = [] + live = {"wifi": "on", "bluetooth": "unavailable", "espnow": "off"} + refused = [False] + def command(text, **kwargs): + calls.append(text) + if text == "get 2.4ghz": + return reply(text, ", ".join(key + "=" + value for key, value in live.items())) + if text == "get power.emergency": + return reply(text, "mode=manual active=on battery=100% clock=rtc window=awake radio=awake wake=0s hold=60s") + if text == "set wifi off force": + if not refused[0]: + refused[0] = True + return reply(text, "Error: emergency sleep; use set power.awake first") + live["wifi"] = "off" + elif text == "set wifi on": + live["wifi"] = "on" + elif text not in ("set power.awake 0", "set power.awake 1"): + raise AssertionError(text) + return reply(text, "OK") + console = types.SimpleNamespace(command=command) + original = bench.wireless_snapshot(console) + with mock.patch.object(bench.time, "sleep"): + bench.hold_network_clock(console) + self.assertEqual(live["wifi"], "off") + bench.restore_wireless(console, original) + self.assertEqual(bench.wireless_snapshot(console), original) + self.assertEqual(calls.count("set wifi off force"), 2) + self.assertEqual(calls.count("set wifi on"), 1) + self.assertFalse(any("ssid" in value or "pwd" in value or "password" in value for value in calls)) + + +if __name__ == "__main__": + unittest.main() diff --git a/test/test_emergency_sleep_backends.py b/test/test_emergency_sleep_backends.py index 973261138..5729bb08a 100644 --- a/test/test_emergency_sleep_backends.py +++ b/test/test_emergency_sleep_backends.py @@ -23,6 +23,7 @@ HARNESS = r''' static std::vector events; static bool radio_test = false, interrupt_context = false, wireless_ready = true; static bool usb_held = false; +static bool attach_on_delay = false; static unsigned primask = 0, basepri = 0, faultmask = 0; static unsigned __get_IPSR() { return interrupt_context; } static unsigned __get_PRIMASK() { return primask; } @@ -40,6 +41,7 @@ static void vTaskDelay(TickType_t ticks) { assert(ticks != 0); delayed_ticks = ticks; events.push_back("delay"); + if (attach_on_delay) usb_held = true; // Model the USB SOF hook observing a new host. } static unsigned nrf_feeds = 0; static constexpr uint32_t WDT_RR_RR_Reload = 0x6e524635; @@ -112,6 +114,7 @@ struct ESP32Board : Base { static void reset() { events.clear(); radio_test = interrupt_context = false; wireless_ready = true; usb_held = false; + attach_on_delay = false; primask = basepri = faultmask = 0; nrf_nvic_state.__cr_flag = 0; scheduler = taskSCHEDULER_RUNNING; tick_ms = 1; delayed_ticks = nrf_feeds = 0; @@ -293,6 +296,12 @@ int main() { reset(); usb_held = true; wireless_ready = false; assert(esp.emergencySleepMilliseconds(10000)); assert(delayed_ticks == 100 && !sleep_calls); // Existing host still bypasses physical USB sleep. + reset(); attach_on_delay = true; + assert(esp.emergencySleepMilliseconds(10000) && usb_held); + assert(sleep_calls == 1 && clear_calls == 1); + events.clear(); + assert(esp.emergencySleepMilliseconds(10000)); + assert(events == std::vector{"delay"} && sleep_calls == 1); } ''' self.run_code(code, ("-DMESH_ESP32_USB_CONSOLE_COOPERATIVE=1",)) diff --git a/test/test_esp32_cpu_clock_board.py b/test/test_esp32_cpu_clock_board.py index 1318f675b..f5f9fb3bd 100644 --- a/test/test_esp32_cpu_clock_board.py +++ b/test/test_esp32_cpu_clock_board.py @@ -186,9 +186,11 @@ int main(){ clock_service=source.index("mesh::esp32::serviceCpuFrequencyPolicy(",loop_start) # Emergency maintenance also services UART restoration before its # early return. Keep this fixture anchored to the clock policy. - start=source.rindex(" board.servicePowerTestControls();",loop_start,clock_service) - end=source.index("#endif",clock_service) - service=source[start:end] + quiet_service=source.rindex(" board.servicePowerTestControls();",loop_start,clock_service) + # Execute the two real calls without importing unrelated enclosing + # feature guards (early emergency placement is tested separately). + service=source[quiet_service:source.index(";",quiet_service)+1]+"\n" + service+=source[clock_service:source.index(";",clock_service)+1]+"\n" tests="void role_service(){\n"+service+"}\n"+r''' int main(){ prefs.cpu_freq_mhz=160;role_service();assert(actual_mhz==160&&board.quiet_services==1); diff --git a/tools/hil/emergency_power_bench.py b/tools/hil/emergency_power_bench.py new file mode 100644 index 000000000..6dbaebe37 --- /dev/null +++ b/tools/hil/emergency_power_bench.py @@ -0,0 +1,550 @@ +#!/usr/bin/env python3 +"""Guarded USB emergency-power trials with an independent RAW radio witness. + +Run on the Mercerwood Pi after separately flashing a verified application. +The default is a plan only. --execute permits CLI changes and temporarily +stops only mctomqtt.service, restoring it in finally. No flashing, erasing, +power cycling, credential changes, or firmware downloads occur here. +Private transcripts remain in a newly created0700 run directory. +""" +import argparse +from contextlib import contextmanager +from datetime import datetime, timezone +import hashlib +import json +import os +from pathlib import Path +import re +import subprocess +import threading +import time + + +TARGETS = { + "solarxiao33s": ("9EBCE8853076EE86", "SolarXiao 33S"), + "rak4631": ("9AB3B64C641BA927", "RAK4631"), + "heltec_v43": ("44:1B:F6:69:C9:C0", "Heltec V4"), + "rak3401": ("0B81C9C68D8D01B4", "RAK3401"), +} +WITNESSES = {"rak3401": "0B81C9C68D8D01B4", "rak4631": "9AB3B64C641BA927", + "solarxiao33s": "9EBCE8853076EE86"} +BENCH_RADIO = "909.5,500,5,5" +STATUS_RE = re.compile( + r"mode=(manual|auto) active=(on|off) battery=(unknown|\d+%) " + r"clock=(rtc|uptime) window=(awake|sleep) radio=(awake|sleep) " + r"wake=(\d+)s hold=(\d+)s\b") + + +def private_json(path, value): + with os.fdopen(os.open(path, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600), "w") as stream: + json.dump(value, stream, indent=2) + stream.write("\n") + + +def status(reply): + match = STATUS_RE.search(reply) + if not match: + raise RuntimeError("Incomplete emergency-power status") + return dict(zip(("mode", "active", "battery", "clock", "window", "radio", "wake", "hold"), + (*match.groups()[:6], int(match[7]), int(match[8])))) + + +def reply_value(reply): + match = re.search(r"(?m)^ -> (.*)\r?$", reply) + if not match: + raise RuntimeError("Missing completed infrastructure CLI reply") + return match[1].removeprefix("> ").strip() + + +def public_key(reply): + value = reply_value(reply) + if not re.fullmatch(r"[0-9A-Fa-f]{64}", value): + raise RuntimeError("Cannot verify the complete public identity") + return bytes.fromhex(value) + + +def advert_timestamp(line, identity): + """Parse the actual Packet wire layout; never match TX/detail log lines.""" + match = re.search(r"(?:^|\s)RAW: ([0-9A-Fa-f]+)\s*$", line) + if not match: + match = re.fullmatch(r"R([0-9A-Fa-f]+)", line.strip()) + if not match or len(match[1]) % 2: + return None + raw = bytes.fromhex(match[1]) + if len(raw) < 2 or raw[0] >> 6 or (raw[0] >> 2) & 15 != 4: + return None + index = 5 if (raw[0] & 3) in (0, 3) else 1 + if len(raw) <= index: + return None + count, width = raw[index] & 63, (raw[index] >> 6) + 1 + if width == 4 or count * width > 64: + return None + index += 1 + count * width + if len(raw) < index + 100 or raw[index:index + 32] != identity: + return None + return int.from_bytes(raw[index + 32:index + 36], "little") + + +def rxps_restore(value): + match = re.fullmatch(r"(on|off),level=(\d+),preamble=(\d+),rx=(\d+),sleep=(\d+)", value) + if not match: + raise RuntimeError("Cannot safely preserve RX power-saving configuration") + enabled, level, preamble, rx, sleep = match.groups() + if enabled == "off": + return "set radio.rxps off" + if int(level): + return f"set radio.rxps level {level} preamble {preamble}" + return f"set radio.rxps {rx} {sleep}" + + +def clock_epoch(reply): + value = reply_value(reply) + try: + return int(datetime.strptime(value, "%H:%M - %d/%m/%Y UTC").replace(tzinfo=timezone.utc).timestamp()) + except ValueError: + raise RuntimeError("Unrecognized current clock response") from None + + +class Console: + """Single reader records RAW traffic even while no USB writes are made.""" + def __init__(self, serial_number, transcript): + import serial + from serial.tools import list_ports + matches = [p for p in list_ports.comports() + if (p.serial_number or "").upper() == serial_number.upper() + and p.interface != "MeshCore Logging"] + if len(matches) != 1: + raise RuntimeError("Missing or ambiguous exact application USB identity") + guard = subprocess.run(["sudo", "-n", "fuser", matches[0].device], + capture_output=True, timeout=5) + if guard.returncode != 1 or guard.stdout or guard.stderr: + raise RuntimeError("Serial ownership could not be proved idle") + self.serial_number = serial_number + self.port = serial.Serial(port=None, baudrate=115200, timeout=.03, exclusive=True, dsrdtr=True) + self.port.dtr, self.port.rts = True, False + self.port.port = matches[0].device + self.port.open() + self.port.rts, self.port.dtr = False, True + self.log = os.fdopen(os.open(transcript, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600), "wb", buffering=0) + self.condition = threading.Condition() + self.lines = [] + self.running = True + self.failed = False + self.last_write = 0 + self.thread = threading.Thread(target=self._read, daemon=True) + self.thread.start() + # Boot banners and a prior host's partial input can share a line. + # End that input before the first exact-echo transaction; never replay + # a timed-out command or infer a reply from an unframed startup tail. + self.port.write(b"\r") + self.port.flush() + time.sleep(.3) + + def _read(self): + pending = bytearray() + try: + while self.running: + chunk = self.port.read(min(self.port.in_waiting or 1, 4096)) + if not chunk: + continue + self.log.write(chunk) + pending.extend(chunk) + if len(pending) > 65536: + raise RuntimeError("Unterminated serial line exceeds capture bound") + while b"\n" in pending: + line, _, pending = pending.partition(b"\n") + with self.condition: + self.lines.append((time.monotonic(), line.decode(errors="replace").rstrip("\r"))) + self.condition.notify_all() + except Exception: + with self.condition: + self.failed = True + self.condition.notify_all() + + def command(self, command, timeout=25, allow_error=False): + if not command or any(c in command for c in "\r\n\0") or self.failed: + raise RuntimeError("Unsafe command or desynchronized CLI session") + with self.condition: + first = len(self.lines) + self.port.write((command + "\r").encode("ascii")) + self.port.flush() + self.last_write = time.monotonic() + deadline = self.last_write + timeout + while time.monotonic() < deadline and not self.failed: + text = "\n".join(line for _, line in self.lines[first:]) + "\n" + echo = re.search(r"(?:^|\n)(?:> )?" + re.escape(command) + r"\n", text) + if echo: + done = re.search(r"(?m)^ -> .*\n", text[echo.end():]) + if done: + reply = text[echo.start():echo.end() + done.end()] + if not allow_error and re.search(r"\b(?:Error|ERROR|ERR|Unknown)\b|(?m:^ -> \?\?:)", reply): + raise RuntimeError(f"CLI rejected {command.split()[0]} request; see private transcript") + return reply + self.condition.wait(min(.1, max(0, deadline - time.monotonic()))) + self.failed = True + raise RuntimeError("CLI response incomplete; session will not be reused") + + def reboot(self, command, transcript, expected_key, after_identity=None): + if command not in ("reboot", "clkreboot") or self.failed: + raise RuntimeError("Unverified reboot request") + self.port.write((command + "\r").encode("ascii")) + self.port.flush() + self.close() + time.sleep(3) + deadline = time.monotonic() + 40 + while True: + try: + candidate = Console(self.serial_number, transcript) + break + except (RuntimeError, OSError): + if time.monotonic() > deadline: + raise + time.sleep(1) + try: + if public_key(candidate.command("get public.key")) != expected_key: + raise RuntimeError("Identity changed after reboot") + if after_identity: + after_identity(candidate) + except BaseException: + candidate.close() + raise + return candidate + + def close(self): + self.running = False + self.thread.join(timeout=2) + self.port.close() + self.log.close() + + +@contextmanager +def paused_collector(): + """Restore the existing unit state without reading its credentials.""" + unit = "/etc/systemd/system/mctomqtt.service" + digest = hashlib.sha256(Path(unit).read_bytes()).digest() + active = subprocess.run(["systemctl", "is-active", "--quiet", "mctomqtt.service"]).returncode == 0 + try: + if active: + subprocess.run(["sudo", "-n", "systemctl", "stop", "mctomqtt.service"], check=True, timeout=30) + yield + finally: + if hashlib.sha256(Path(unit).read_bytes()).digest() != digest: + raise RuntimeError("Collector unit changed during bench run; inspect before restoring") + if active: + subprocess.run(["sudo", "-n", "systemctl", "start", "mctomqtt.service"], check=True, timeout=30) + subprocess.run(["systemctl", "is-active", "--quiet", "mctomqtt.service"], check=True, timeout=15) + + +def snapshot(console): + keys = ("radio", "tx", "repeat", "radio2", "tempradio", "radio.rxps.config", + "usb.logging", "usb.logging.always", "radioat", "tempradioat", "radioat2", "tempradioat2") + values = {key: reply_value(console.command("get " + key, allow_error=True)) for key in keys} + for key in ("radioat", "tempradioat", "radioat2", "tempradioat2"): + if values[key] != ("-none-" if key in ("radioat", "tempradioat") else "slots:"): + raise RuntimeError("Bench requires confirmed empty future radio schedules") + if not re.fullmatch(r"\d+(?:\.\d+)?,\d+(?:\.\d+)?,\d+,\d+", values["radio"]): + raise RuntimeError("Unrecognized primary radio configuration") + if values["tempradio"] != "off" or values["radio2"] not in ("off", "off (manual)", "off (automatic allowed)"): + raise RuntimeError("Bench requires inactive temporary and secondary radio profiles") + if values["repeat"] not in ("on", "off") or not re.fullmatch(r"-?\d+", values["tx"]): + raise RuntimeError("Cannot safely restore TX/repeater configuration") + values["rxps_restore"] = rxps_restore(values["radio.rxps.config"]) + return values + + +def require_distinct_original_frequencies(target, witness): + # A baseline RF success must not be two failed retunes remaining together + # on a shared original channel. These guarded bench pairs start apart. + if abs(float(target["radio"].split(",")[0]) - float(witness["radio"].split(",")[0])) < .001: + raise RuntimeError("Independent witness must start on a different original main frequency") + + +def apply_radio(console, value): + admission = reply_value(console.command("set radio " + value)) + if "reboot" in admission.lower() or "applying in 2s" not in admission: + raise RuntimeError("Live radio application not confirmed; a saved getter cannot prove an RF retune") + time.sleep(4) # CLI admits primary radio changes with a2-second reply grace. + actual = tuple(map(float, reply_value(console.command("get radio")).split(","))) + wanted = tuple(map(float, value.split(","))) + if any(abs(a - b) > .001 for a, b in zip(actual, wanted)): + raise RuntimeError("Saved primary radio differs after applying the bench profile") + + +def isolate(console, saved, witness=False): + console.command("set radio.rxps off") + console.command("set repeat off") + console.command("set tx -9") + apply_radio(console, BENCH_RADIO) + if witness: + console.command("set usb.logging on") + if saved["usb.logging.always"] in ("on", "off"): + console.command("set usb.logging.always on") + + +def restore(console, saved): + apply_radio(console, saved["radio"]) + console.command("set tx " + saved["tx"]) + console.command(saved["rxps_restore"]) + console.command("set repeat " + saved["repeat"]) + for key in ("usb.logging.always", "usb.logging"): + if saved[key] in ("on", "off"): + console.command("set " + key + " " + saved[key]) + current = snapshot(console) + for key in ("radio", "tx", "repeat", "radio2", "radio.rxps.config", "usb.logging", "usb.logging.always"): + if key == "radio": + if any(abs(float(a) - float(b)) > .001 for a, b in zip(current[key].split(","), saved[key].split(","))): + raise RuntimeError("Original radio was not restored") + elif current[key] != saved[key]: + raise RuntimeError("Original " + key + " was not restored") + + +def await_status(console, expected, timeout=30): + deadline = time.monotonic() + timeout + while True: + observed = status(console.command("get power.emergency")) + if all(observed[key] == value for key, value in expected.items()): + return observed + if time.monotonic() > deadline: + raise RuntimeError("Emergency status did not reach " + repr(expected)) + time.sleep(.5) + + +def wireless_snapshot(console): + value = reply_value(console.command("get 2.4ghz")) + match = re.fullmatch(r"wifi=(on|off|unavailable), bluetooth=(on|off|unavailable), " + r"espnow=(on|off|unavailable)( \(pending\))?", value) + if not match: + raise RuntimeError("Wireless runtime state is unavailable or reports a failed transition") + return dict(zip(("wifi", "bluetooth", "espnow", "pending"), + (*match.groups()[:3], bool(match[4])))) + + +def hold_network_clock(console): + """Keep NTP from replacing accelerated RTC values; credentials stay saved.""" + console.command("set power.awake 1") + await_status(console, {"radio": "awake", "window": "awake"}) + deadline = time.monotonic() + 35 + while True: + value = reply_value(console.command("set wifi off force", allow_error=True)) + if value.startswith("OK"): + break + if not value.startswith("Error: emergency sleep") or time.monotonic() > deadline: + raise RuntimeError("WiFi runtime clock isolation was rejected") + time.sleep(.1) # Wait for the completed awake request to resume its backend. + while True: + live = wireless_snapshot(console) + if live["wifi"] == "off" and not live["pending"]: + break + if time.monotonic() > deadline: + raise RuntimeError("WiFi/NTP did not stop for accelerated clock testing") + time.sleep(.1) + console.command("set power.awake 0") + + +def restore_wireless(console, original): + for service in ("espnow", "wifi"): + live = wireless_snapshot(console) + if original[service] != live[service]: + if original[service] == "unavailable": + raise RuntimeError("Wireless availability changed during the test") + console.command("set " + service + " " + original[service] + + (" force" if original[service] == "off" else "")) + deadline = time.monotonic() + 35 + while True: + live = wireless_snapshot(console) + if live[service] == original[service] and not live["pending"]: + break + if time.monotonic() > deadline: + raise RuntimeError("Original wireless runtime subset was not restored") + time.sleep(.1) + if wireless_snapshot(console) != original: + raise RuntimeError("Original wireless runtime subset differs after restoration") + + +def await_advert(witness, identity, after, minimum_epoch, timeout=20, maximum_epoch=None): + deadline = time.monotonic() + timeout + with witness.condition: + while True: + for stamp, line in witness.lines: + epoch = advert_timestamp(line, identity) + if (stamp >= after and epoch is not None and epoch >= minimum_epoch + and (maximum_epoch is None or epoch < maximum_epoch)): + return {"received_monotonic": stamp, "advert_epoch": epoch} + if witness.failed or time.monotonic() > deadline: + raise RuntimeError("Independent witness did not receive the target advertisement") + witness.condition.wait(.1) + + +def run_trial(args): + args.run_dir.mkdir(mode=0o700, parents=False, exist_ok=False) + original = json.loads(args.identity_backup.read_text()) + serial_number, board_label = TARGETS[args.target] + if original.get("usb_serial", "").upper() != serial_number.upper(): + raise RuntimeError("Private identity backup is for a different USB board") + original_key = public_key(original["queries"]["get public.key"]) + report = {"board": board_label, "usb_serial": serial_number, + "real_minute_lease": False, "accelerated_hourly_cycles": [], + "manual_mode_survived_reboot": False, "cleanup": "not needed", + "limitations": ["USB-powered manual entry; battery5% threshold/current savings not measured", + "Hourly boundaries accelerated with RTC; no real45-minute sleep duration claim", + "Application restoration is a separate guarded flashing step"]} + target = witness = None + target_saved = witness_saved = None + wireless_before = None + clock_guard = hold_network_clock if args.target == "heltec_v43" else None + if clock_guard: + report["limitations"].append("USB-held CPU idle fallback; detached USB light-sleep current not measured") + target_changed = witness_changed = False + changed_clock = False + try: + with paused_collector(): + try: + target = Console(serial_number, args.run_dir / "target.private.log") + witness_name = args.witness or ("solarxiao33s" if args.target == "rak3401" else "rak3401") + if WITNESSES[witness_name] == serial_number: + raise RuntimeError("Target and independent witness must be different boards") + witness = Console(WITNESSES[witness_name], args.run_dir / "witness.private.log") + if public_key(target.command("get public.key")) != original_key: + raise RuntimeError("Target public identity differs from its private baseline backup") + actual_board = reply_value(target.command("board")) + if re.sub(r"[^a-z0-9]", "", board_label.lower()) not in re.sub(r"[^a-z0-9]", "", actual_board.lower()): + raise RuntimeError("Unexpected target board label") + version = reply_value(target.command("ver")) + if version != args.expected_version: + raise RuntimeError("Running firmware differs from the explicitly selected verified build") + witness_board = re.sub(r"[^a-z0-9]", "", reply_value(witness.command("board")).lower()) + if re.sub(r"[^a-z0-9]", "", TARGETS[witness_name][1].lower()) not in witness_board: + raise RuntimeError("Wrong independent witness board") + report["firmware"] = version + target_saved, witness_saved = snapshot(target), snapshot(witness) + if clock_guard: + wireless_before = wireless_snapshot(target) + if wireless_before["pending"] or wireless_before["wifi"] == "unavailable": + raise RuntimeError("WiFi must expose a settled reversible runtime state") + private_json(args.run_dir / "settings.private.json", {"target": target_saved, "witness": witness_saved, + "wireless": wireless_before}) + initial = status(target.command("get power.emergency")) + if initial["mode"] != "auto" or initial["hold"]: + raise RuntimeError("Target must start with automatic mode and no awake lease") + if witness_saved["usb.logging.always"] not in ("on", "off"): + raise RuntimeError("Witness needs the current logging override and live radio CLI") + require_distinct_original_frequencies(target_saved, witness_saved) + witness_changed = True + isolate(witness, witness_saved, witness=True) + target_changed = True + isolate(target, target_saved) + report["cleanup"] = "required" + target.command("advert.zerohop") + report["baseline_advert"] = await_advert(witness, original_key, target.last_write, 0) + target.command("set power.awake 0") + target.command("set power.emergency auto") + if clock_guard: + clock_guard(target) + # Past test clocks avoid future advert timestamps in the witness's + # neighbour cache. RF stays isolated until present time is proven. + future = ((int(time.time()) // 3600) - 48) * 3600 + changed_clock = True + target = target.reboot("clkreboot", args.run_dir / "target.bench-clock.private.log", original_key, clock_guard) + target.command(f"time {future + 1800}") + target.command("set power.emergency on") + report["manual_entry"] = await_status(target, {"mode": "manual", "active": "on", "radio": "sleep", "window": "sleep", "hold": 0}) + started = time.monotonic() + target.command("set power.awake 1") + awake = await_status(target, {"radio": "awake", "window": "awake"}) + if not 30 <= awake["hold"] <= 60: + raise RuntimeError("The one-minute awake lease was not observed") + target.command("advert.zerohop") + report["lease_advert"] = await_advert(witness, original_key, target.last_write, future, + maximum_epoch=future + 3600) + time.sleep(max(0, started + 65 - time.monotonic())) + await_status(target, {"radio": "sleep", "window": "sleep", "hold": 0}) + report["real_minute_lease"] = True + for cycle in range(2): + cycle_hour = future + cycle * 3600 + if cycle: + target.command(f"time {cycle_hour + 1800}") + await_status(target, {"radio": "sleep", "window": "sleep"}) + # Defer signing/dispatch until the mesh loop runs at timer wake. + target.command("advert.zerohop") + target.command(f"time {cycle_hour + 3580}") + last_write = target.last_write + received = await_advert(witness, original_key, last_write, cycle_hour + 3600, timeout=45, + maximum_epoch=cycle_hour + 3660) + if target.last_write != last_write: + raise RuntimeError("Target USB write occurred during the passive timer-wake interval") + received["no_usb_write_seconds"] = time.monotonic() - last_write + received["status_after_passive_capture"] = await_status(target, {"radio": "awake", "window": "awake"}) + report["accelerated_hourly_cycles"].append(received) + target.command(f"time {future + 2 * 3600 + 1800}") + await_status(target, {"mode": "manual", "radio": "sleep"}) + target = target.reboot("reboot", args.run_dir / "target.after-reboot.private.log", original_key, clock_guard) + report["after_reboot"] = await_status(target, {"mode": "manual", "active": "on", "hold": 0}) + target.command(f"time {future + 3 * 3600 + 1800}") + report["after_reboot_sleep"] = await_status(target, {"mode": "manual", "radio": "sleep", "window": "sleep"}) + report["manual_mode_survived_reboot"] = True + report["passed"] = True + finally: + cleanup_errors = [] + if target and target_changed: + try: + target.command("set power.awake 0") + target.command("set power.emergency auto") + await_status(target, {"mode": "auto", "radio": "awake"}) + if changed_clock: + target = target.reboot("clkreboot", args.run_dir / "target.clock-restored.private.log", original_key, clock_guard) + target.command(f"time {int(time.time())}") + current_time = clock_epoch(target.command("clock")) + if abs(current_time - time.time()) > 90: + raise RuntimeError("Present clock not confirmed; retaining isolated RF") + restore(target, target_saved) + report["final_power"] = status(target.command("get power.emergency")) + if wireless_before: + restore_wireless(target, wireless_before) + report["wireless_runtime_restored"] = True + except Exception as error: + cleanup_errors.append("Target cleanup: " + str(error)) + if witness and witness_changed: + try: + witness_key = public_key(witness.command("get public.key")) + if abs(clock_epoch(witness.command("clock")) - time.time()) > 90: + witness = witness.reboot("clkreboot", args.run_dir / "witness.clock-restored.private.log", witness_key) + witness.command(f"time {int(time.time())}") + if abs(clock_epoch(witness.command("clock")) - time.time()) > 90: + raise RuntimeError("Witness present clock not confirmed; retaining isolated RF") + restore(witness, witness_saved) + except Exception as error: + cleanup_errors.append("Witness cleanup: " + str(error)) + for console in (target, witness): + if console: + console.close() + report["cleanup"] = "complete" if not cleanup_errors else "NEEDS MANUAL RECOVERY" + if cleanup_errors: + report["cleanup_errors"] = cleanup_errors + raise RuntimeError("Bench cleanup incomplete; inspect the private report before further tests") + except BaseException as error: + report["passed"] = False + report["error"] = str(error) + raise + finally: + report["finished_utc"] = datetime.now(timezone.utc).isoformat() + private_json(args.run_dir / "report.json", report) + print(json.dumps({key: report.get(key) for key in ("board", "passed", "cleanup", "real_minute_lease", "manual_mode_survived_reboot")})) + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--target", choices=TARGETS, required=True) + parser.add_argument("--witness", choices=WITNESSES, help="Default RAK3401, or qualified SolarXiao when testing the3401") + parser.add_argument("--expected-version", required=True, help="Exact ver response from the verified new build") + parser.add_argument("--identity-backup", type=Path, required=True, help="Private preflash JSON for the selected USB identity") + parser.add_argument("--run-dir", type=Path, required=True, help="New private run directory (must not already exist)") + parser.add_argument("--execute", action="store_true", help="Permit the documented CLI changes and collector pause") + args = parser.parse_args() + if not args.execute: + print("Plan only: isolated909.5/BW500/SF5/CR5, manual sleep, real60s lease, two accelerated hourly timer wakes, reboot persistence, finally restore clock/RF/collector. No hardware touched.") + return + run_trial(args) + + +if __name__ == "__main__": + main() From c778abceab214ec6f1e502c39960c8b095eee74e Mon Sep 17 00:00:00 2001 From: mikecarper Date: Wed, 28 Oct 2026 03:52:08 -0700 Subject: [PATCH 21/61] Shorten Companion help descriptions while retaining every command --- examples/companion_radio/CompanionHelp.inc | 57 ++++---- .../companion_help_output.sha256.json | 128 +++++++++--------- test/test_companion_help_writer.py | 59 +++++++- test/test_companion_reader.py | 2 +- test/test_companion_terminal_profile.py | 8 +- test/test_companion_usb_logging.py | 2 +- test/test_nrf52_extrafs_contract.py | 4 +- 7 files changed, 152 insertions(+), 108 deletions(-) diff --git a/examples/companion_radio/CompanionHelp.inc b/examples/companion_radio/CompanionHelp.inc index f8ee8f179..2ef6685ef 100644 --- a/examples/companion_radio/CompanionHelp.inc +++ b/examples/companion_radio/CompanionHelp.inc @@ -7,16 +7,16 @@ MESH_COMPANION_HELP_BEGIN "Commands:\r\n" " stats-core / stats-radio / stats-radio-diag / stats-packets\r\n" - " get prv.key (if export enabled)\r\n" + " get prv.key\r\n" #if defined(ESP32_PLATFORM) && defined(WIFI_SSID) " password (shared setup WiFi, WebConfig and WiFi OTA)\r\n" " get password (management password status)\r\n" #else - " get password (no admin password)\r\n" + " get password\r\n" #endif MESH_COMPANION_HELP_END MESH_COMPANION_HELP_BEGIN - " erase (settings and identity)\r\n" + " erase (settings+identity)\r\n" " board\r\n" " version\r\n" " get storage.layout\r\n" @@ -31,24 +31,23 @@ MESH_COMPANION_HELP_END MESH_COMPANION_HELP_BEGIN " get mt.status / get mt.region / get mt.slot / get mt.hops\r\n" " get mt.node / get mt.shortname\r\n" - " get mt.rebroadcast / set mt.rebroadcast (saved; default off)\r\n" - " Relay decoded channel text only; adds 5 packet airtimes before TX\r\n" + " get mt.rebroadcast / set mt.rebroadcast (default off)\r\n" MESH_COMPANION_HELP_END MESH_COMPANION_HELP_BEGIN " set mt.region \r\n" " set mt.slot <0|1-based frequency slot>\r\n" - " set mt.hops <0..7> (saved; default 7)\r\n" - " set mt.node (saved; use a unique ID)\r\n" + " set mt.hops <0..7> (default 7)\r\n" + " set mt.node \r\n" MESH_COMPANION_HELP_END MESH_COMPANION_HELP_BEGIN - " set mt.shortname <1..4 UTF-8 characters|default> (saved; NodeInfo uses 4 bytes)\r\n" - " send mt.nodeinfo (GPS-free; also automatic after first chat)\r\n" - " set mt.preset (saved manual radio2)\r\n" + " set mt.shortname <1..4 UTF-8 characters|default>\r\n" + " send mt.nodeinfo (auto after first chat)\r\n" + " set mt.preset \r\n" MESH_COMPANION_HELP_END MESH_COMPANION_HELP_BEGIN " set mt.channel <32/64 hex digits|AQ==>\r\n" " get mt.channel / delete mt.channel \r\n" - " Join #mt-longfast or #mt-shortturbo in the app for automatic radio2\r\n" + " App #mt-longfast/#mt-shortturbo: radio2\r\n" MESH_COMPANION_HELP_END #endif #if defined(NRF52_PLATFORM) && defined(EXTRAFS) && !defined(QSPIFLASH) @@ -58,12 +57,12 @@ MESH_COMPANION_HELP_END #endif #if COMPANION_FEATURE_READER MESH_COMPANION_HELP_BEGIN - " get reader [:[-]] [page] (World English Bible, offline)\r\n" + " get reader [:[-]] [page]\r\n" MESH_COMPANION_HELP_END #endif MESH_COMPANION_HELP_BEGIN - " get/set tx.media auto|radio|radio2|both|off (binary media)\r\n" - " get/set tx.ssh auto|radio|radio2|both|off (tagged tunnel data)\r\n" + " get/set tx.media auto|radio|radio2|both|off\r\n" + " get/set tx.ssh auto|radio|radio2|both|off\r\n" MESH_COMPANION_HELP_END #if MESH_ENABLE_ETHERNET_TUNNEL MESH_COMPANION_HELP_BEGIN @@ -74,19 +73,19 @@ MESH_COMPANION_HELP_END #endif #if defined(OTA_RAK15002_SD_STORE) && defined(ETHERNET_USE_RAK13800) MESH_COMPANION_HELP_BEGIN - " get eth / set eth on|off (physical RAK13800; default off, this boot)\r\n" + " get eth / set eth on|off (default off; boot)\r\n" MESH_COMPANION_HELP_END #endif #if defined(NRF52_PLATFORM) && defined(OTA_SD_STORE) MESH_COMPANION_HELP_BEGIN " get sdcard [free|ls|dir [page]|format|erase|*]\r\n" " set sdcard format|erase [--force]\r\n" - " ota cache [on|off] (SD archive)\r\n" + " ota cache [on|off]\r\n" MESH_COMPANION_HELP_END #endif MESH_COMPANION_HELP_BEGIN " power / get pwrmgt.support / get pwrmgt.source / get pwrmgt.bootmv\r\n" - " get system.watchdog (hardware status)\r\n" + " get system.watchdog\r\n" " get bootloader.ver\r\n" MESH_COMPANION_HELP_END MESH_COMPANION_HELP_BEGIN @@ -100,7 +99,7 @@ MESH_COMPANION_HELP_BEGIN " get/set display.night.delay <-180..180> (minutes after sunset; default 60)\r\n" #endif #if defined(OLED_AUTO_DETECT) && OLED_AUTO_DETECT && defined(DISPLAY_CLASS) - " get/set display.controller (saved; restart to apply)\r\n" + " get/set display.controller (restart)\r\n" #endif #ifdef PIN_BUZZER " get/set buzzer on|off\r\n" @@ -116,26 +115,26 @@ MESH_COMPANION_HELP_BEGIN " set notify. \r\n" MESH_COMPANION_HELP_END MESH_COMPANION_HELP_BEGIN - " set notify.remote on|off (!notify, max 15s, no GPIO)\r\n" + " set notify.remote on|off (!notify, <=15s, no GPIO)\r\n" " notify.test / notify.stop / notify.delete \r\n" MESH_COMPANION_HELP_END #endif MESH_COMPANION_HELP_BEGIN " get/set display.inbox \r\n" - " get/set display.touch (this boot)\r\n" + " get/set display.touch (boot)\r\n" " get public.key\r\n" " set {name|lat|lon|freq|tx|af} {value}\r\n" " get wifi|espnow|2.4ghz\r\n" - " set wifi|espnow on|off [force] (force only with off)\r\n" + " set wifi|espnow on|off [force] (force: off)\r\n" MESH_COMPANION_HELP_END MESH_COMPANION_HELP_BEGIN - " set 2.4ghz on [all|force]|off [force] (this boot)\r\n" + " set 2.4ghz on [all|force]|off [force] (boot)\r\n" " get/set bluetooth.name \r\n" MESH_COMPANION_HELP_END #if defined(BLE_PIN_CODE) MESH_COMPANION_HELP_BEGIN - " get bluetooth (alias: get ble)\r\n" - " set bluetooth [force] (saved; force only with off)\r\n" + " get bluetooth / get ble\r\n" + " set bluetooth [force] (force: off)\r\n" " get bluetooth.mac\r\n" " set bluetooth.mac \r\n" " get/set bluetooth.stealth on|off\r\n" @@ -152,7 +151,7 @@ MESH_COMPANION_HELP_BEGIN " get/set usb.debug \r\n" #if MESH_USB_CONSOLE_COOPERATIVE " get usb.watchdog.last\r\n" - " get/set usb.watchdog (saved; default auto)\r\n" + " get/set usb.watchdog (default auto)\r\n" #endif #endif MESH_COMPANION_HELP_END @@ -236,7 +235,7 @@ MESH_COMPANION_HELP_END // Keep native USB producer writes within 256 bytes. #if !defined(MESH_RADIO_SWITCH_TUNING) || MESH_RADIO_SWITCH_TUNING MESH_COMPANION_HELP_BEGIN - " get radio.fast / get radio.fast.caps (supported saved controls)\r\n" + " get radio.fast / get radio.fast.caps\r\n" " set radio.fast / set radio.fast.spi <0|2|4|8|9|16>\r\n" MESH_COMPANION_HELP_END #endif @@ -249,7 +248,7 @@ MESH_COMPANION_HELP_BEGIN MESH_COMPANION_HELP_END MESH_COMPANION_HELP_BEGIN " get rxps.wd\r\n" - " get/set radio.rxps.rfrx_disabled (this boot)\r\n" + " get/set radio.rxps.rfrx_disabled (boot)\r\n" " clear stats\r\n" MESH_COMPANION_HELP_END MESH_COMPANION_HELP_BEGIN @@ -286,10 +285,10 @@ MESH_COMPANION_HELP_BEGIN MESH_COMPANION_HELP_END #if defined(NRF52_PLATFORM) && defined(EXTRAFS) && !defined(QSPIFLASH) MESH_COMPANION_HELP_BEGIN - " repair extrafs (erases/rebuilds internal ExtraFS)\r\n" + " repair extrafs (erases ExtraFS)\r\n" MESH_COMPANION_HELP_END MESH_COMPANION_HELP_BEGIN - " scan extrafs erase (USB only; reboot for destructive page test)\r\n" + " scan extrafs erase (USB; reboot; erases pages)\r\n" MESH_COMPANION_HELP_END #endif #if COMPANION_FEATURE_TEMP_RADIO || COMPANION_FEATURE_OTA_CLI diff --git a/test/fixtures/companion_help_output.sha256.json b/test/fixtures/companion_help_output.sha256.json index 96551a395..20234e4b2 100644 --- a/test/fixtures/companion_help_output.sha256.json +++ b/test/fixtures/companion_help_output.sha256.json @@ -30,12 +30,12 @@ "MESH_ENABLE_LOST_REPLY=0" ], "output_bytes": [ - 4946, - 4938 + 4491, + 4483 ], "output_sha256": [ - "bb14e81de08d1e49d30fd23aac0b32bbbe89e233021c7772ed00ff439fa4e530", - "abb5cd540b09c9b8c7451e0a2c1ec4afbd06a3c51203e0c747f32aa8f9c2bdab" + "90da76bd3a33bb8ca4e3212475850f090bf7bc8372e5d2838603cca904fef03b", + "d1ef09f3f08a1f588e751531c1423456813ccfb9cbb723a5ca5171a73b062318" ] }, "all-options-native0-lost1": { @@ -68,12 +68,12 @@ "MESH_ENABLE_LOST_REPLY=1" ], "output_bytes": [ - 4981, - 4973 + 4526, + 4518 ], "output_sha256": [ - "ff5e938adf128262f499720545073f775293cf867d7e78be46256ab3326e0ab7", - "93f43a333536b43ae308ab8dedcd929eec11b109901818aecfa66abad204b3cc" + "29dbb3af9b59fa6d207d296ea0bb3da839b5655417fb7c74d00ea6ad9bea90d9", + "7a578f8a4c43c962bcbfadaadd452b5ab8ff1c2e8db86ad403b5242773a8e4d2" ] }, "all-options-native1-lost0": { @@ -106,12 +106,12 @@ "MESH_ENABLE_LOST_REPLY=0" ], "output_bytes": [ - 5031, - 5023 + 4569, + 4561 ], "output_sha256": [ - "65b1c10004b7f43587eba91698e6f0d281e811c22114b77401fc34248364d603", - "14d510af8083cbf03b7ac77af8bb51fca947a050a4953132e6239a3a5bf17ca7" + "9264906abd107d191dc3448be96ae74e8508bf662f28279f38a838049d2b8922", + "ec30fe0bcd47547aa7115ea0f111873c366a541f4c892cee2584331ca5c6c4bd" ] }, "all-options-native1-lost1": { @@ -144,12 +144,12 @@ "MESH_ENABLE_LOST_REPLY=1" ], "output_bytes": [ - 5066, - 5058 + 4604, + 4596 ], "output_sha256": [ - "693e01f6ee384f9d3c96ef1e43148fdc3528a30dafd137515a3e77d1c900bf48", - "4437daf200af4bd6bb8ab651c0315ddd8743c7a880c07b8d9c82b3d5a9774dcb" + "752cdd07d45a7e02580794b6d1c6267b711622f366dbbbeee649a2e29d9ff614", + "59b7d18da893b30d2c60f6a7bc59679a6222d672cb58aa5b6d71bfcc23e0e7f3" ] }, "full-native0-lost0": { @@ -173,12 +173,12 @@ "MESH_ENABLE_LOST_REPLY=0" ], "output_bytes": [ - 3603, - 3595 + 3363, + 3355 ], "output_sha256": [ - "8bfd3694e0bc33aa808895f6e4a76b082bb159c882b8d3937c14e51880dca78d", - "786c12671e62289d8468c43fdc3776f6e3299aa6e8d0006fd8ad5613840bd78c" + "4f8d3a26b611f3bb6f12f93ec3c81f728701a96762f9febdd60ac030ca8388e5", + "6214ce35ca15bcd6e11c1ba83ddb58f1ce42d0ad88eb047c655d639a4f9bc4f6" ] }, "full-native0-lost1": { @@ -202,12 +202,12 @@ "MESH_ENABLE_LOST_REPLY=1" ], "output_bytes": [ - 3638, - 3630 + 3398, + 3390 ], "output_sha256": [ - "ae3b7cf29e9892a554d04ba5558ad89aa3ff94478a1bd434ed498b1435f8b0d0", - "17528343802f445c17dda60c803cc469aa0d8b9ce4c492fb64fd45cfabd5f56a" + "7d070794c5fd04f2e7cd366d1d2a871e199384fcaef93908c6aacf15cdbf4dea", + "0ac6e2df584259d55e3f27e80a16da04ae26bdf2f0480098139921dbe2745352" ] }, "full-native1-lost0": { @@ -231,12 +231,12 @@ "MESH_ENABLE_LOST_REPLY=0" ], "output_bytes": [ - 3688, - 3680 + 3441, + 3433 ], "output_sha256": [ - "83b832f5de59fbfe04a49fb550245b8256a9dd924b50215f72e64b76b0e59cdf", - "52c5b5821c64d4100462d4ef08b8cef85b350150372e74abd3c2047836cb1954" + "3ff627f73f95d1ecbcae35057bde3c311075888a812f1f134d40a5338ba34a14", + "e40417cdf8b76e942d75649396cb805e16e7013a7043f446caa07ba5bbcf5b6f" ] }, "full-native1-lost1": { @@ -260,12 +260,12 @@ "MESH_ENABLE_LOST_REPLY=1" ], "output_bytes": [ - 3723, - 3715 + 3476, + 3468 ], "output_sha256": [ - "525c50beeb01595634f469b38c265000f6ad6d6ad5443b4dad6d3e36db0b5a8e", - "dd4d7390ba42b655c8cc78b9da25048328c921c14eb8d2b4c3eccf343142d8ac" + "9fc5c27d49b1b71d6d93a28f83a4dab5806e4037bfe5eaa5901fee31f224386c", + "c0776838ce268bb5f0a0bf4f597e4668bff4540768a683c77abe80a0e7624d3b" ] }, "minimal-native0-lost0": { @@ -275,12 +275,12 @@ "MESH_ENABLE_LOST_REPLY=0" ], "output_bytes": [ - 2046, - 2038 + 1897, + 1889 ], "output_sha256": [ - "5571a06d789dff50aebd53777095cc8f0f74e5fd4226ed399bd25050e9e5a02e", - "9328e546b561bc1d965f8036d73c862cfed9d5077111c0b5c8744822235740bb" + "fda1d107a7bba40a0211e22e04ea0102a5deb46d27fef9fae15d3b3302fe092e", + "b345253e4a2713f6a5f8afbaf29d8d3db79e374176cce53cb1335d3593a7a75b" ] }, "minimal-native0-lost1": { @@ -290,12 +290,12 @@ "MESH_ENABLE_LOST_REPLY=1" ], "output_bytes": [ - 2081, - 2073 + 1932, + 1924 ], "output_sha256": [ - "9ee93b7a588ce48c647b9871dc7bec5c3f307c06c29a65cb6ff66a55060247b6", - "449353daa515ede8c6528782406b3f4a101d60bee46412aa934ea94e922ed5a5" + "d27fa5b890700841f77a815fdeabe6850cd6cc4bf6ea4066f71bd0bf59245c74", + "02d853b9b49358dadf412c165654accd9b7dfd5cdd37d469fea71db85fc5ad92" ] }, "minimal-native1-lost0": { @@ -305,12 +305,12 @@ "MESH_ENABLE_LOST_REPLY=0" ], "output_bytes": [ - 2046, - 2038 + 1897, + 1889 ], "output_sha256": [ - "5571a06d789dff50aebd53777095cc8f0f74e5fd4226ed399bd25050e9e5a02e", - "9328e546b561bc1d965f8036d73c862cfed9d5077111c0b5c8744822235740bb" + "fda1d107a7bba40a0211e22e04ea0102a5deb46d27fef9fae15d3b3302fe092e", + "b345253e4a2713f6a5f8afbaf29d8d3db79e374176cce53cb1335d3593a7a75b" ] }, "minimal-native1-lost1": { @@ -320,12 +320,12 @@ "MESH_ENABLE_LOST_REPLY=1" ], "output_bytes": [ - 2081, - 2073 + 1932, + 1924 ], "output_sha256": [ - "9ee93b7a588ce48c647b9871dc7bec5c3f307c06c29a65cb6ff66a55060247b6", - "449353daa515ede8c6528782406b3f4a101d60bee46412aa934ea94e922ed5a5" + "d27fa5b890700841f77a815fdeabe6850cd6cc4bf6ea4066f71bd0bf59245c74", + "02d853b9b49358dadf412c165654accd9b7dfd5cdd37d469fea71db85fc5ad92" ] }, "qspi-native0-lost0": { @@ -359,12 +359,12 @@ "MESH_ENABLE_LOST_REPLY=0" ], "output_bytes": [ - 4806, - 4798 + 4386, + 4378 ], "output_sha256": [ - "4cc6eed0758a5f99006b17d3d6b4564665d86e4cd65f3b0365926a78f562356e", - "9d6cc39d8b397abd81543346fd88786517fb385a44abb317ac2cf1e7b2cbd8a9" + "21bd11864860334b87c27d24b36d513ae858dd1da4f1df6b8c5e25f822cc23c9", + "3086b361ed519ec91fb9f31ecb6d22f71180d8fb064bb2f6f51c151c1b4c4c35" ] }, "qspi-native0-lost1": { @@ -398,12 +398,12 @@ "MESH_ENABLE_LOST_REPLY=1" ], "output_bytes": [ - 4841, - 4833 + 4421, + 4413 ], "output_sha256": [ - "3bfd0dc32ba035ea4254e72fe21fde8e18a0b26d6621eec6df4e077ddc1069b9", - "ff22590ebdb6ed569fff20b9680050b7ac746d76ccc87cac85034b8f49adc0c4" + "10fcc7eba146e50131f34487a8d118befc3b4f32024a8762adbe6772e04f9294", + "5529438f4b22e556d1d00bc8c4633923b089d49211e5fee0b9b2b533d5fb88ba" ] }, "qspi-native1-lost0": { @@ -437,12 +437,12 @@ "MESH_ENABLE_LOST_REPLY=0" ], "output_bytes": [ - 4891, - 4883 + 4464, + 4456 ], "output_sha256": [ - "6dc1e3ab146948fa9c59c8eca47cd0ca3171281c161dc7fd66a3e17eb5d966b4", - "ddec9acf0156c143bbf40d8372002c6eadcdac355ebc567dbb18ec627bbc992c" + "1b2d35692f9e8bfaee60a1f60620d116860d605e339bdd7a58f183b6cd7503db", + "cc056e777ea8bd7ae0d4581595467b1835d9a5e91f3b341c588fa86245591459" ] }, "qspi-native1-lost1": { @@ -476,12 +476,12 @@ "MESH_ENABLE_LOST_REPLY=1" ], "output_bytes": [ - 4926, - 4918 + 4499, + 4491 ], "output_sha256": [ - "5cb4b4679630e65a8a6739158fc31c8830b393cdf18a69a259f3e31f6e4c0a74", - "9883ae8a436098ac6c6d2edf03164d19a0f07624ddcec74b598e79243387060e" + "378af0b3efc64bbd65585772928d9e1a031afa10cedcf2409dcda40a48ac7e32", + "5653195e250d7588b5df16c99a795fcf78ca6999ef4b4cf8f88821db5cdd6b44" ] } }, diff --git a/test/test_companion_help_writer.py b/test/test_companion_help_writer.py index 03a80bf8d..85b101637 100644 --- a/test/test_companion_help_writer.py +++ b/test/test_companion_help_writer.py @@ -1,9 +1,10 @@ #!/usr/bin/env python3 -"""Execute actual help branches; preserve807 UI and bounded partial writes.""" +"""Execute actual help branches; preserve commands and bounded partial writes.""" from pathlib import Path import hashlib import json import os +import re import shutil import subprocess import sys @@ -34,15 +35,40 @@ PROFILES = { 'MESH_PRIMARY_ESPNOW=1', 'BLE_PIN_CODE=123456', 'PIN_BUZZER', 'PIN_VIBRATION', *FEATURES], } -# Generated from807's original literal groups, including its dynamic TERM-STOP -# suffix. Non-RAK call hashes bind print/println order as well as all text bytes. -GOLDEN = {'rak_full': {'text': '9a68ce5a10905a201c3bddeb19f4819ce03c6dcaff248cff9c140803b70b14a7', 'calls': '56ed09dee7d53881b2c9fcf3f4ec9b6223e1562ce63e110a065e46706702b53b'}, 'nrf_full': {'text': 'b114ac12ae2ea01b7c47609b72f7eafb616c1dbeb7fb796a95b1377465fd9105', 'calls': '4973a7f21887d07b129cce4ae4cc8145f3f35e664bae09be658957210440d52e'}, 'nrf_minimal': {'text': '15fa8f290cd8c75cfde2fd2ac1cb5b5151a6755c68448fb2793dde3f27669033', 'calls': '6063fd1a699f9c0b588252bd8ce5922e0ff31cec6bbd24522789284ab2a37644'}, 'esp_full': {'text': '1e3b6781c81b5603f7092108e5e309543c60cef8ff4d7cb41a891f67396f716f', 'calls': 'b6335df2bd05b9cab44a7933469b76c5b2611c4b81057436a959feaf3bfbaf60'}} +# Complete text and print/println order, including the dynamic TERM-STOP suffix. +GOLDEN = { + 'rak_full': {'text': '482e6d1a78e676fe295c066e6dea9d33d16cf3d63788c4f225ca235451b9c5d0'}, + 'nrf_full': {'text': '97520f83770adbcd8bfe6f3f7c9b7b5f204b62d112aeeb9d9692ee35ba6c98a4', + 'calls': 'ddc85fafb3f655e589c045e2ee91fac95b3926624c4ac91b657a7de346e27098'}, + 'nrf_minimal': {'text': '633d0e5e67abed482f64ca2563011ba263f169e039e1d89190de06668f975395', + 'calls': 'd550ea873fb4408c2796b439736b205fea1ab985c3da58a06fb69cc557347ae5'}, + 'esp_full': {'text': 'ccfc453a97260b5efba38b3a01b1c738ec03dcd90e72d24ecd80bc2592154757', + 'calls': 'c46a0cd8d83af284eba9243806349938577d03a20ca56474a4abc5ab3e8d57f7'}, + 'stm32_wl_usb': {'text': 'e23a0514d2d52091196cd689b88c984db52642df8e9fd67a878c74a6312c520d'}, +} +# Snapshots taken before shortening the English descriptions. These bind every +# command, alias, argument and option independently of the wording around them. +COMMAND_INVENTORY = { + 'rak_full': '42159b2857691728524e247f8590cbcb3972724aedffeddc5e50bd8b105e64cb', + 'nrf_full': '90321d86ecd2a04efff4c95aee0578935c84439786a9725383a21287808ec8f0', + 'nrf_minimal': '420617a933aec1f81c90c9b78b0457e3509b6da62b649969b4ffa9b8418538c5', + 'stm32_wl_usb': '36e6972aefe8fc2355d937c06ecae1656224618529c2f7a18f459008856476e7', + 'esp_full': '8c952ccf44824bc2e473e1a560714cd27dc91365abb484d9411e779720df3350', +} -GOLDEN['stm32_wl_usb'] = {'text': '9e587ac9f287c8f8fe64fe5c899fe635b6de5d6cf436c4c43a27ffd4add42474'} - -FAST_HELP = (b' get radio.fast / get radio.fast.caps (supported saved controls)\r\n' +FAST_HELP = (b' get radio.fast / get radio.fast.caps\r\n' b' set radio.fast / set radio.fast.spi <0|2|4|8|9|16>\r\n') + +def command_inventory(data): + commands = [] + for line in data.decode().splitlines(): + if not line.startswith(' ') or line.startswith((' ', ' Join ', ' App ')): + continue + line = line.replace(' (alias: get ble)', ' / get ble') + commands.append(re.sub(r' \([^)]*\)', '', line)) + return '\n'.join(commands).encode() + HARNESS = r''' #include "CompanionHelpWriter.h" #include @@ -222,6 +248,25 @@ class CompanionHelpWriterTests(unittest.TestCase): self.assertEqual(result.returncode,0,result.stdout+result.stderr) result = subprocess.run([str(work/'test')],capture_output=True) self.assertEqual(result.returncode,0,result.stderr.decode(errors='replace')) + self.assertEqual(hashlib.sha256(command_inventory(result.stdout)).hexdigest(), + COMMAND_INVENTORY[name]) + for command, detail in ( + (b'set beacon.interval', b'(default 24h)'), + (b'set mt.rebroadcast', b'(default off)'), + (b'set mt.hops', b'(default 7)'), + (b'get eth / set eth', b'(default off; boot)'), + (b'get/set usb.watchdog', b'(default auto)'), + (b'get/set display.controller', b'(restart)'), + (b'get/set display.touch', b'(boot)'), + (b'set 2.4ghz', b'(boot)'), + (b'radio.rxps.rfrx_disabled', b'(boot)'), + (b'set wifi|espnow', b'(force: off)'), + (b'set bluetooth <', b'(force: off)'), + (b'repair extrafs', b'(erases ExtraFS)'), + (b'scan extrafs erase', b'(USB; reboot; erases pages)')): + for line in result.stdout.splitlines(): + if command in line: + self.assertIn(detail, line) self.assertEqual(result.stdout.count(FAST_HELP), 0 if name == 'stm32_wl_usb' else 1) previous_help = result.stdout.replace(FAST_HELP, b'') self.assertEqual(hashlib.sha256(previous_help).hexdigest(),GOLDEN[name]['text']) diff --git a/test/test_companion_reader.py b/test/test_companion_reader.py index 0fb1eeeec..45b0a6291 100644 --- a/test/test_companion_reader.py +++ b/test/test_companion_reader.py @@ -342,7 +342,7 @@ int main() { self.assertLess(terminal.index("mesh::handleReaderCommand("), terminal.index("char local_reply[160]")) help_text = (ROOT / "examples/companion_radio/CompanionHelp.inc").read_text() - self.assertIn("get reader [:[-]] [page] (World English Bible, offline)", help_text) + self.assertIn("get reader [:[-]] [page]", help_text) self.assertIn('#include "CompanionHelp.inc"', terminal) self.assertIn("mesh::companion::writePackedCompanionHelp(output)", terminal) packed_help = (ROOT / "examples/companion_radio/CompanionHelpPacked.cpp").read_text() diff --git a/test/test_companion_terminal_profile.py b/test/test_companion_terminal_profile.py index e04cbeddf..dc72b5276 100644 --- a/test/test_companion_terminal_profile.py +++ b/test/test_companion_terminal_profile.py @@ -94,11 +94,11 @@ assert '#include "CompanionHelp.inc"' in packed_help help_fragments = (root / "examples/companion_radio/CompanionHelp.inc").read_text() for help_line in (' board\\r\\n', ' version\\r\\n', ' get pwrmgt.bootreason\\r\\n', ' set pin <0-999999>\\r\\n', - ' set mt.hops <0..7> (saved; default 7)\\r\\n', + ' set mt.hops <0..7> (default 7)\\r\\n', ' get mt.node / get mt.shortname\\r\\n', - ' set mt.node (saved; use a unique ID)\\r\\n', - ' set mt.shortname <1..4 UTF-8 characters|default> (saved; NodeInfo uses 4 bytes)\\r\\n', - ' send mt.nodeinfo (GPS-free; also automatic after first chat)\\r\\n'): + ' set mt.node \\r\\n', + ' set mt.shortname <1..4 UTF-8 characters|default>\\r\\n', + ' send mt.nodeinfo (auto after first chat)\\r\\n'): assert help_line in help_fragments # Explicit NodeInfo remains available, but first successful chat also arms # separate GPS-free metadata. Do not restore the obsolete manual-only policy. diff --git a/test/test_companion_usb_logging.py b/test/test_companion_usb_logging.py index 51545fd8e..8942939b5 100644 --- a/test/test_companion_usb_logging.py +++ b/test/test_companion_usb_logging.py @@ -504,7 +504,7 @@ int main(){ assert(text.find("usb.debug")==std::string::npos); #endif #if MESH_USB_LOGGING_AVAILABLE && MESH_USB_CONSOLE_COOPERATIVE - assert(text.find(" get usb.watchdog.last\r\n get/set usb.watchdog (saved; default auto)\r\n")!=std::string::npos); + assert(text.find(" get usb.watchdog.last\r\n get/set usb.watchdog (default auto)\r\n")!=std::string::npos); #else assert(text.find("usb.watchdog")==std::string::npos); #endif diff --git a/test/test_nrf52_extrafs_contract.py b/test/test_nrf52_extrafs_contract.py index bd6a87198..47a56258d 100644 --- a/test/test_nrf52_extrafs_contract.py +++ b/test/test_nrf52_extrafs_contract.py @@ -1037,12 +1037,12 @@ class Nrf52ExtraFsContractTest(unittest.TestCase): self.assertIn("mesh::companion::writePackedCompanionHelp(output);", terminal) help_fragments = (MESH.parent / "CompanionHelp.inc").read_text(encoding="ascii") self.assertIn( - "repair extrafs (erases/rebuilds internal ExtraFS)", help_fragments + "repair extrafs (erases ExtraFS)", help_fragments ) self.assertRegex(help_fragments, r"#if defined\(NRF52_PLATFORM\) && defined\(EXTRAFS\) && !defined\(QSPIFLASH\)" r"\s+MESH_COMPANION_HELP_BEGIN\s+" - r'" repair extrafs \(erases/rebuilds internal ExtraFS\)\\r\\n"') + r'" repair extrafs \(erases ExtraFS\)\\r\\n"') packed_help = (MESH.parent / "CompanionHelpPacked.cpp").read_text(encoding="ascii") self.assertIn('#include "CompanionHelp.inc"', packed_help) From cea654cb47a5b8fbf690a0d3e9ce8f3ebaf83b3e Mon Sep 17 00:00:00 2001 From: mikecarper Date: Wed, 28 Oct 2026 04:15:04 -0700 Subject: [PATCH 22/61] Use shorter CLI validation replies on the constrained Mini --- examples/simple_repeater/MyMesh.cpp | 129 ++-- src/helpers/CLICommandUtils.h | 1 + src/helpers/CLIReplyText.h | 14 + src/helpers/CommonCLI.cpp | 141 +++-- src/helpers/FloodAdvertCLI.h | 10 +- src/helpers/LedSettings.cpp | 5 +- src/helpers/PowerSavingDiagnostics.h | 10 +- src/helpers/RadioProfileCLI.cpp | 23 +- src/helpers/StorageLayout.cpp | 3 +- src/helpers/WirelessControl.h | 8 +- test/fixtures/cli_reply_text.json | 598 ++++++++++++++++++ .../common_cli_argument_bounds/main.cpp | 4 +- test/test_cli_reply_text.py | 110 ++++ test/test_common_cli_argument_bounds.py | 7 +- variants/wio-e5-mini/platformio.ini | 1 + 15 files changed, 901 insertions(+), 163 deletions(-) create mode 100644 src/helpers/CLIReplyText.h create mode 100644 test/fixtures/cli_reply_text.json create mode 100644 test/test_cli_reply_text.py diff --git a/examples/simple_repeater/MyMesh.cpp b/examples/simple_repeater/MyMesh.cpp index 681e431f3..ed9117b7a 100644 --- a/examples/simple_repeater/MyMesh.cpp +++ b/examples/simple_repeater/MyMesh.cpp @@ -1,3 +1,4 @@ +#include #include "MyMesh.h" #include @@ -234,7 +235,7 @@ static const char FLOOD_PACKET_FILTER_USAGE[] = #if MESH_ENABLE_FLOOD_RULE_ENGINE "Err - use: set flood.rule[.n] type= [hops=] [...]"; #else - "Err - use: set flood.filter[.n] [hops] [path=blacklist] [scope=] [require=region] [tx=slow] [suspend=tempradio]"; + MESH_CLI_REPLY_TEXT("Err - use: set flood.filter[.n] [hops] [path=blacklist] [scope=] [require=region] [tx=slow] [suspend=tempradio]", "Err - set flood.filter[.n] [hops] [path=blacklist] [scope=] [require=region] [tx=slow] [suspend=tempradio]"); #endif static const char FLOOD_PACKET_FILTER_DUPLICATE[] = "Err - duplicate filter option"; @@ -3173,8 +3174,8 @@ void MyMesh::onPeerDataRecv(mesh::Packet *packet, uint8_t type, int sender_idx, request_id, packet->radio_profile, packet->radio_generation)) { const char* error = deferred_cli_command.pending - ? "Err - another remote command is still running" - : "Err - remote command is too long"; + ? MESH_CLI_REPLY_TEXT("Err - another remote command is still running", "Err - remote command busy") + : MESH_CLI_REPLY_TEXT("Err - remote command is too long", "Err - remote command too long"); remote_cli_reply_cache.remember(client->id.pub_key, request_id, command_fingerprint, error); sendRemoteCliReply(client, secret, packet->getPathHashSize(), @@ -4926,15 +4927,15 @@ void MyMesh::addScheduledRadioParams(bool temporary, float freq, float bw, uint8 return; } if (start_time <= now) { - strcpy(reply, "Error: start is in the past"); + strcpy(reply, MESH_CLI_REPLY_TEXT("Error: start is in the past", "Error: start in past")); return; } if (temporary && end_time <= now) { - strcpy(reply, "Error: end is in the past"); + strcpy(reply, MESH_CLI_REPLY_TEXT("Error: end is in the past", "Error: end in past")); return; } if (temporary && end_time <= start_time) { - strcpy(reply, "Error: end must be after start"); + strcpy(reply, MESH_CLI_REPLY_TEXT("Error: end must be after start", "Error: end must follow start")); return; } if (countScheduledRadioSettings(temporary) >= MAX_SCHEDULED_RADIO_SETTINGS_PER_TYPE) { @@ -5054,7 +5055,7 @@ void MyMesh::formatScheduledRadioParams(bool temporary, const char* selector, ch int wanted = 0; if (!parsePositiveSelector(selector, wanted)) { - strcpy(reply, temporary ? "Error, use: get tempradioat [n]" : "Error, use: get radioat [n]"); + strcpy(reply, temporary ? MESH_CLI_REPLY_TEXT("Error, use: get tempradioat [n]", "Error, get tempradioat [n]") : MESH_CLI_REPLY_TEXT("Error, use: get radioat [n]", "Error, get radioat [n]")); return; } @@ -5077,7 +5078,7 @@ void MyMesh::deleteScheduledRadioParams(bool temporary, const char* selector, ch if (!all) { int wanted = 0; if (!parsePositiveSelector(selector, wanted)) { - strcpy(reply, temporary ? "Error, use: del tempradioat [n]" : "Error, use: del radioat [n]"); return; + strcpy(reply, temporary ? MESH_CLI_REPLY_TEXT("Error, use: del tempradioat [n]", "Error, del tempradioat [n]") : MESH_CLI_REPLY_TEXT("Error, use: del radioat [n]", "Error, del radioat [n]")); return; } idx = findScheduledRadioSettingByIndex(temporary, wanted); if (idx < 0) { strcpy(reply, "Error: not found"); return; } @@ -5122,7 +5123,7 @@ void MyMesh::deleteScheduledRadioParams(bool temporary, const char* selector, ch int wanted = 0; if (!parsePositiveSelector(selector, wanted)) { - strcpy(reply, temporary ? "Error, use: del tempradioat [n]" : "Error, use: del radioat [n]"); + strcpy(reply, temporary ? MESH_CLI_REPLY_TEXT("Error, use: del tempradioat [n]", "Error, del tempradioat [n]") : MESH_CLI_REPLY_TEXT("Error, use: del radioat [n]", "Error, del radioat [n]")); return; } @@ -5696,7 +5697,7 @@ bool MyMesh::handleGuestCommand(const char* command, char* reply, size_t capacit const char* value = command + 9; while (*value == ' ' || *value == '\t') ++value; if ((get && *value) || (set && strcmp(value, "on") && strcmp(value, "off"))) { - snprintf(reply, capacity, "Err - use get guest or set guest on|off"); + snprintf(reply, capacity, MESH_CLI_REPLY_TEXT("Err - use get guest or set guest on|off", "Err - get guest / set guest on|off")); } else if (get) { snprintf(reply, capacity, "> %s%s", guest_login.enabled() ? "on" : "off", guest_login.healthy() ? "" : " (settings unavailable)"); @@ -6391,7 +6392,7 @@ void MyMesh::formatFloodPacketFilterBlacklist(const char* args, char* reply) con uint8_t slot = 0; if (!parseFloodFilterUnsigned(selector, FLOOD_PACKET_FILTER_BLACKLIST_MAX, slot) || slot == 0 || slot > flood_packet_filter_blacklist_count) { - snprintf(reply, 160, "Err - blacklist slot must be 1-%u", + snprintf(reply, 160, MESH_CLI_REPLY_TEXT("Err - blacklist slot must be 1-%u", "Err - blacklist slot: 1-%u"), (uint32_t)flood_packet_filter_blacklist_count); return; } @@ -6437,7 +6438,7 @@ void MyMesh::setFloodPacketFilterBlacklist(const char* args, char* reply) { size_t slot_len = (size_t)(cursor - slot_start); char slot_text[8]; if (slot_len == 0 || slot_len >= sizeof(slot_text)) { - snprintf(reply, 160, "Err - blacklist slot must be 1-%u", + snprintf(reply, 160, MESH_CLI_REPLY_TEXT("Err - blacklist slot must be 1-%u", "Err - blacklist slot: 1-%u"), (uint32_t)FLOOD_PACKET_FILTER_BLACKLIST_MAX); return; } @@ -6446,13 +6447,13 @@ void MyMesh::setFloodPacketFilterBlacklist(const char* args, char* reply) { uint8_t slot = 0; if (!parseFloodFilterUnsigned(slot_text, FLOOD_PACKET_FILTER_BLACKLIST_MAX, slot) || slot == 0) { - snprintf(reply, 160, "Err - blacklist slot must be 1-%u", + snprintf(reply, 160, MESH_CLI_REPLY_TEXT("Err - blacklist slot must be 1-%u", "Err - blacklist slot: 1-%u"), (uint32_t)FLOOD_PACKET_FILTER_BLACKLIST_MAX); return; } requested_slot = slot - 1; if (*cursor != ' ') { - strcpy(reply, "Err - expected six-digit repeater ID"); + strcpy(reply, MESH_CLI_REPLY_TEXT("Err - expected six-digit repeater ID", "Err - repeater ID: six hex digits")); return; } } @@ -6463,10 +6464,10 @@ void MyMesh::setFloodPacketFilterBlacklist(const char* args, char* reply) { uint8_t count = 0; if (!parseFloodPacketFilterBlacklist(cursor, parsed, count)) { if (requested_slot >= 0) { - snprintf(reply, 160, "Err - use 1-%d unique six-digit hex IDs separated by commas", + snprintf(reply, 160, MESH_CLI_REPLY_TEXT("Err - use 1-%d unique six-digit hex IDs separated by commas", "Err - use 1-%d unique six-digit hex IDs, comma-separated"), FLOOD_PACKET_FILTER_BLACKLIST_REPLACE_MAX); } else { - snprintf(reply, 160, "Err - use 1-%d unique six-digit hex IDs separated by commas", + snprintf(reply, 160, MESH_CLI_REPLY_TEXT("Err - use 1-%d unique six-digit hex IDs separated by commas", "Err - use 1-%d unique six-digit hex IDs, comma-separated"), FLOOD_PACKET_FILTER_BLACKLIST_REPLACE_MAX); } return; @@ -6478,12 +6479,12 @@ void MyMesh::setFloodPacketFilterBlacklist(const char* args, char* reply) { if (requested_slot >= 0) { if (requested_slot > flood_packet_filter_blacklist_count) { - snprintf(reply, 160, "Err - next blacklist slot is %u", + snprintf(reply, 160, MESH_CLI_REPLY_TEXT("Err - next blacklist slot is %u", "Err - next blacklist slot: %u"), (uint32_t)flood_packet_filter_blacklist_count + 1U); return; } if ((uint16_t)requested_slot + count > FLOOD_PACKET_FILTER_BLACKLIST_MAX) { - snprintf(reply, 160, "Err - blacklist range exceeds slot %u", + snprintf(reply, 160, MESH_CLI_REPLY_TEXT("Err - blacklist range exceeds slot %u", "Err - blacklist range past slot %u"), (uint32_t)FLOOD_PACKET_FILTER_BLACKLIST_MAX); return; } @@ -6513,7 +6514,7 @@ void MyMesh::setFloodPacketFilterBlacklist(const char* args, char* reply) { if (!saveFloodPacketFilterBlacklist()) { flood_packet_filter_blacklist_count = previous_count; memcpy(flood_packet_filter_blacklist, previous, sizeof(previous)); - strcpy(reply, "Err - unable to save flood filter blacklist"); + strcpy(reply, MESH_CLI_REPLY_TEXT("Err - unable to save flood filter blacklist", "Err - flood filter blacklist not saved")); return; } if (requested_slot >= 0) { @@ -6538,7 +6539,7 @@ void MyMesh::deleteFloodPacketFilterBlacklist(const char* args, char* reply) { if (!delete_all && (!parseFloodFilterUnsigned(selector, FLOOD_PACKET_FILTER_BLACKLIST_MAX, slot) || slot == 0 || slot > flood_packet_filter_blacklist_count)) { - snprintf(reply, 160, "Err - use: del flood.filter.blacklist[.<1-%u>| all]", + snprintf(reply, 160, MESH_CLI_REPLY_TEXT("Err - use: del flood.filter.blacklist[.<1-%u>| all]", "Err - del flood.filter.blacklist[.<1-%u>| all]"), (uint32_t)FLOOD_PACKET_FILTER_BLACKLIST_MAX); return; } @@ -6564,7 +6565,7 @@ void MyMesh::deleteFloodPacketFilterBlacklist(const char* args, char* reply) { if (!saveFloodPacketFilterBlacklist()) { flood_packet_filter_blacklist_count = previous_count; memcpy(flood_packet_filter_blacklist, previous, sizeof(previous)); - strcpy(reply, "Err - unable to save flood filter blacklist"); + strcpy(reply, MESH_CLI_REPLY_TEXT("Err - unable to save flood filter blacklist", "Err - flood filter blacklist not saved")); return; } if (delete_all) { @@ -8330,7 +8331,7 @@ void MyMesh::formatFloodPacketFilters(const char* args, char* reply, bool compac } uint8_t slot; if (!parseFloodFilterUnsigned(selector, flood_packet_filter_slots, slot) || slot == 0) { - snprintf(reply, 160, "Err - filter slot must be 1-%d", flood_packet_filter_slots); + snprintf(reply, 160, MESH_CLI_REPLY_TEXT("Err - filter slot must be 1-%d", "Err - filter slot: 1-%d"), flood_packet_filter_slots); return; } formatFloodPacketFilterDetail(slot - 1, reply, 160, compact); @@ -8402,14 +8403,14 @@ void MyMesh::setFloodPacketFilter(const char* args, char* reply, size_t slot_len = (size_t)(cursor - slot_start); char slot_text[8]; if (slot_len == 0 || slot_len >= sizeof(slot_text)) { - snprintf(reply, 160, "Err - filter slot must be 1-%d", flood_packet_filter_slots); + snprintf(reply, 160, MESH_CLI_REPLY_TEXT("Err - filter slot must be 1-%d", "Err - filter slot: 1-%d"), flood_packet_filter_slots); return; } memcpy(slot_text, slot_start, slot_len); slot_text[slot_len] = 0; uint8_t slot; if (!parseFloodFilterUnsigned(slot_text, flood_packet_filter_slots, slot) || slot == 0) { - snprintf(reply, 160, "Err - filter slot must be 1-%d", flood_packet_filter_slots); + snprintf(reply, 160, MESH_CLI_REPLY_TEXT("Err - filter slot must be 1-%d", "Err - filter slot: 1-%d"), flood_packet_filter_slots); return; } requested_slot = slot - 1; @@ -8464,7 +8465,7 @@ void MyMesh::setFloodPacketFilter(const char* args, char* reply, ? tokens[0] + strlen("type=") : floodFilterAsciiStartsWith(tokens[0], "t=") ? tokens[0] + 2 : tokens[0]; if (!parseFloodFilterPayloadType(type_text, payload_type)) { - strcpy(reply, "Err - packet type must be name, any, 0-15, or 0x00-0x0F"); + strcpy(reply, MESH_CLI_REPLY_TEXT("Err - packet type must be name, any, 0-15, or 0x00-0x0F", "Err - type: name|any|0-15|0x00-0x0F")); return; } @@ -8673,7 +8674,7 @@ void MyMesh::setFloodPacketFilter(const char* args, char* reply, if (floodFilterAsciiEqual(tokens[i], "tx=slow")) { scope_uses_slow_timing = true; } else if (!floodFilterAsciiEqual(tokens[i], "tx=fast")) { - strcpy(reply, "Err - tx timing must be fast or slow"); + strcpy(reply, MESH_CLI_REPLY_TEXT("Err - tx timing must be fast or slow", "Err - tx: fast|slow")); return; } scope_timing_set = true; @@ -8685,7 +8686,7 @@ void MyMesh::setFloodPacketFilter(const char* args, char* reply, } if (!normalizeFloodFilterScopeName(strchr(tokens[i], '=') + 1, scope_name, sizeof(scope_name))) { - strcpy(reply, "Err - scope must be a public name of at most 30 characters"); + strcpy(reply, MESH_CLI_REPLY_TEXT("Err - scope must be a public name of at most 30 characters", "Err - scope: public name, max 30 chars")); return; } target_set = true; @@ -8953,7 +8954,7 @@ void MyMesh::setFloodPacketFilter(const char* args, char* reply, if (!saveFloodPacketFilters()) { flood_packet_filters[slot] = previous; flood_channel_data_rule_slot = previous_channel_data_slot; - strcpy(reply, "Err - unable to save flood filter"); + strcpy(reply, MESH_CLI_REPLY_TEXT("Err - unable to save flood filter", "Err - flood filter not saved")); return; } @@ -9003,7 +9004,7 @@ void MyMesh::formatFloodPacketFilters(const char* args, char* reply, bool compac if (*selector != 0) { uint8_t slot; if (!parseFloodFilterUnsigned(selector, flood_packet_filter_slots, slot) || slot == 0) { - snprintf(reply, 160, "Err - filter slot must be 1-%d", flood_packet_filter_slots); + snprintf(reply, 160, MESH_CLI_REPLY_TEXT("Err - filter slot must be 1-%d", "Err - filter slot: 1-%d"), flood_packet_filter_slots); return; } formatFloodPacketFilterDetail(slot - 1, reply, 160); @@ -9055,19 +9056,19 @@ void MyMesh::setFloodPacketFilter(const char* args, char* reply, size_t slot_len = (size_t)(cursor - slot_start); char slot_text[8]; if (slot_len == 0 || slot_len >= sizeof(slot_text)) { - snprintf(reply, 160, "Err - filter slot must be 1-%d", flood_packet_filter_slots); + snprintf(reply, 160, MESH_CLI_REPLY_TEXT("Err - filter slot must be 1-%d", "Err - filter slot: 1-%d"), flood_packet_filter_slots); return; } memcpy(slot_text, slot_start, slot_len); slot_text[slot_len] = 0; uint8_t slot; if (!parseFloodFilterUnsigned(slot_text, flood_packet_filter_slots, slot) || slot == 0) { - snprintf(reply, 160, "Err - filter slot must be 1-%d", flood_packet_filter_slots); + snprintf(reply, 160, MESH_CLI_REPLY_TEXT("Err - filter slot must be 1-%d", "Err - filter slot: 1-%d"), flood_packet_filter_slots); return; } requested_slot = slot - 1; if (*cursor != ' ') { - strcpy(reply, "Err - packet type must be name, any, 0-15, or 0x00-0x0F"); + strcpy(reply, MESH_CLI_REPLY_TEXT("Err - packet type must be name, any, 0-15, or 0x00-0x0F", "Err - type: name|any|0-15|0x00-0x0F")); return; } } @@ -9078,7 +9079,7 @@ void MyMesh::setFloodPacketFilter(const char* args, char* reply, char params[140]; #endif if (strlen(cursor) >= sizeof(params)) { - strcpy(reply, "Err - filter parameters too long"); + strcpy(reply, MESH_CLI_REPLY_TEXT("Err - filter parameters too long", "Err - filter params too long")); return; } @@ -9088,7 +9089,7 @@ void MyMesh::setFloodPacketFilter(const char* args, char* reply, char* token = params; while (*token != 0) { if (token_count >= 7) { - strcpy(reply, "Err - too many flood filter parameters"); + strcpy(reply, MESH_CLI_REPLY_TEXT("Err - too many flood filter parameters", "Err - excess flood filter parameters")); return; } tokens[token_count++] = token; @@ -9105,7 +9106,7 @@ void MyMesh::setFloodPacketFilter(const char* args, char* reply, uint8_t payload_type; if (!parseFloodFilterPayloadType(tokens[0], payload_type)) { - strcpy(reply, "Err - packet type must be name, any, 0-15, or 0x00-0x0F"); + strcpy(reply, MESH_CLI_REPLY_TEXT("Err - packet type must be name, any, 0-15, or 0x00-0x0F", "Err - type: name|any|0-15|0x00-0x0F")); return; } @@ -9146,7 +9147,7 @@ void MyMesh::setFloodPacketFilter(const char* args, char* reply, if (floodFilterAsciiEqual(tokens[i], "tx=slow")) { scope_uses_slow_timing = true; } else if (!floodFilterAsciiEqual(tokens[i], "tx=fast")) { - strcpy(reply, "Err - tx timing must be fast or slow"); + strcpy(reply, MESH_CLI_REPLY_TEXT("Err - tx timing must be fast or slow", "Err - tx: fast|slow")); return; } scope_timing_set = true; @@ -9157,7 +9158,7 @@ void MyMesh::setFloodPacketFilter(const char* args, char* reply, } if (!normalizeFloodFilterScopeName(tokens[i] + strlen("scope="), scope_name, sizeof(scope_name))) { - strcpy(reply, "Err - scope must be a public name of at most 30 characters"); + strcpy(reply, MESH_CLI_REPLY_TEXT("Err - scope must be a public name of at most 30 characters", "Err - scope: public name, max 30 chars")); return; } } else if (!hops_set && parseFloodFilterHopSpec(tokens[i], min_hops, max_hops)) { @@ -9168,11 +9169,11 @@ void MyMesh::setFloodPacketFilter(const char* args, char* reply, } } if (scope_requires_region_match && scope_name[0] == 0) { - strcpy(reply, "Err - require=region requires scope="); + strcpy(reply, MESH_CLI_REPLY_TEXT("Err - require=region requires scope=", "Err - require=region needs scope=")); return; } if (scope_timing_set && scope_name[0] == 0) { - strcpy(reply, "Err - tx timing requires scope="); + strcpy(reply, MESH_CLI_REPLY_TEXT("Err - tx timing requires scope=", "Err - tx needs scope=")); return; } @@ -9217,7 +9218,7 @@ void MyMesh::setFloodPacketFilter(const char* args, char* reply, StrHelper::strzcpy(entry.scopeName(), scope_name, sizeof(entry.scopeName())); if (!saveFloodPacketFilters()) { entry = previous; - strcpy(reply, "Err - unable to save flood filter"); + strcpy(reply, MESH_CLI_REPLY_TEXT("Err - unable to save flood filter", "Err - flood filter not saved")); return; } @@ -9232,20 +9233,20 @@ void MyMesh::deleteFloodPacketFilter(const char* args, char* reply) { if (*selector == '.') selector = skipFloodFilterSpaces(selector + 1); if (floodFilterAsciiEqual(selector, "all")) { if (!saveFloodPacketFilters(false, true)) { - strcpy(reply, "Err - unable to save flood filter"); + strcpy(reply, MESH_CLI_REPLY_TEXT("Err - unable to save flood filter", "Err - flood filter not saved")); } else { if (flood_packet_filters) memset(flood_packet_filters, 0, sizeof(FloodPacketFilterEntry) * flood_packet_filter_slots); #if MESH_ENABLE_FLOOD_RULE_ENGINE flood_channel_data_rule_slot = 0xFF; #endif - strcpy(reply, "OK - all flood filters removed"); + strcpy(reply, MESH_CLI_REPLY_TEXT("OK - all flood filters removed", "OK - flood filters cleared")); } return; } uint8_t slot; if (!parseFloodFilterUnsigned(selector, flood_packet_filter_slots, slot) || slot == 0) { - snprintf(reply, 160, "Err - use: del flood.filter.<1-%d>|all", flood_packet_filter_slots); + snprintf(reply, 160, MESH_CLI_REPLY_TEXT("Err - use: del flood.filter.<1-%d>|all", "Err - del flood.filter.<1-%d>|all"), flood_packet_filter_slots); return; } int index = slot - 1; @@ -9266,7 +9267,7 @@ void MyMesh::deleteFloodPacketFilter(const char* args, char* reply) { #if MESH_ENABLE_FLOOD_RULE_ENGINE flood_channel_data_rule_slot = previous_channel_data_slot; #endif - strcpy(reply, "Err - unable to save flood filter"); + strcpy(reply, MESH_CLI_REPLY_TEXT("Err - unable to save flood filter", "Err - flood filter not saved")); } else { strcpy(reply, "OK"); } @@ -9821,7 +9822,7 @@ static bool parseFloodChannelScopeIndex(const char*& cursor, int& index) { void MyMesh::formatFloodChannelScopeDetail(int index, char* reply, size_t reply_len) { if (index < 0 || index >= FLOOD_CHANNEL_SCOPE_SLOTS) { - snprintf(reply, reply_len, "Err - scope slot must be 1-%d", FLOOD_CHANNEL_SCOPE_SLOTS); + snprintf(reply, reply_len, MESH_CLI_REPLY_TEXT("Err - scope slot must be 1-%d", "Err - scope slot: 1-%d"), FLOOD_CHANNEL_SCOPE_SLOTS); return; } const auto& entry = floodChannelScopes()[index]; @@ -9879,7 +9880,7 @@ void MyMesh::formatFloodChannelScopes(const char* args, char* reply) { const char* cursor = args; int index = -1; if (!parseFloodChannelScopeIndex(cursor, index)) { - snprintf(reply, 160, "Err - scope slot must be 1-%d", FLOOD_CHANNEL_SCOPE_SLOTS); + snprintf(reply, 160, MESH_CLI_REPLY_TEXT("Err - scope slot must be 1-%d", "Err - scope slot: 1-%d"), FLOOD_CHANNEL_SCOPE_SLOTS); return; } if (*cursor != 0) { @@ -9956,7 +9957,7 @@ void MyMesh::setFloodChannelScope(const char* args, char* reply) { option + (option[0] == 'p' && option[1] == '=' ? 3 : 12), FLOOD_RETRY_BRIDGE_BUCKETS, bucket) || bucket == 0) { - strcpy(reply, "Err - path bucket must be 1-6"); + strcpy(reply, MESH_CLI_REPLY_TEXT("Err - path bucket must be 1-6", "Err - path bucket: 1-6")); return; } path_set = true; @@ -9981,7 +9982,7 @@ void MyMesh::setFloodChannelScope(const char* args, char* reply) { target_text + strlen("scope="), direct_scope_name, sizeof(direct_scope_name))) { strcpy(reply, - "Err - scope must be a public name of at most 30 characters"); + MESH_CLI_REPLY_TEXT("Err - scope must be a public name of at most 30 characters", "Err - scope: public name, max 30 chars")); return; } } else { @@ -10151,7 +10152,7 @@ void MyMesh::deleteFloodChannelScope(const char* args, char* reply) { void MyMesh::formatFloodChannelScopeRequirementDetail( int index, char* reply, size_t reply_len) const { if (index < 0 || index >= FLOOD_CHANNEL_SCOPE_REQUIRE_SLOTS) { - snprintf(reply, reply_len, "Err - require slot must be 1-%d", + snprintf(reply, reply_len, MESH_CLI_REPLY_TEXT("Err - require slot must be 1-%d", "Err - require slot: 1-%d"), FLOOD_CHANNEL_SCOPE_REQUIRE_SLOTS); return; } @@ -10174,7 +10175,7 @@ void MyMesh::formatFloodChannelScopeRequirements(const char* args, const char* cursor = args; int index = -1; if (!parseFloodChannelScopeIndex(cursor, index)) { - snprintf(reply, 160, "Err - require slot must be 1-%d", + snprintf(reply, 160, MESH_CLI_REPLY_TEXT("Err - require slot must be 1-%d", "Err - require slot: 1-%d"), FLOOD_CHANNEL_SCOPE_REQUIRE_SLOTS); return; } @@ -10219,7 +10220,7 @@ void MyMesh::setFloodChannelScopeRequirement(const char* args, char* reply) { char ignored_name[FLOOD_GROUP_MODERATION_NAME_LEN]; if (!parseFloodModerationChannel(channel_text, secret, key_len, hash_prefix, ignored_name, sizeof(ignored_name))) { - strcpy(reply, "Err - channel must be public, #name, or 128/256-bit key"); + strcpy(reply, MESH_CLI_REPLY_TEXT("Err - channel must be public, #name, or 128/256-bit key", "Err - channel: public|#name|128/256-bit key")); return; } @@ -12474,7 +12475,7 @@ void MyMesh::handleCommand(uint32_t sender_timestamp, ClientInfo* sender, char * } if (region_load_active && sender && !sender->isAdmin() && !sender->isRegionMgr()) { - strcpy(reply, "Err - region load owned by admin/region manager"); + strcpy(reply, MESH_CLI_REPLY_TEXT("Err - region load owned by admin/region manager", "Err - region load: admin/region manager only")); return; } @@ -13194,7 +13195,7 @@ void MyMesh::handleCommand(uint32_t sender_timestamp, ClientInfo* sender, char * && mesh::Utils::fromHex(pubkey, (int)(hex_len / 2), hex)) { uint32_t perms; if (!mesh::cli::parseUnsignedIntegerStrict(sp, perms) || perms > UINT8_MAX) { - strcpy(reply, "Err - permissions must be 0-255"); + strcpy(reply, MESH_CLI_REPLY_TEXT("Err - permissions must be 0-255", "Err - permissions: 0-255")); } else if (acl.applyPermissions(self_id, pubkey, (int)(hex_len / 2), static_cast(perms))) { mesh::scheduleLazyPersistenceMutation( dirty_contacts_expiry, contacts_save_failures, @@ -13240,7 +13241,7 @@ void MyMesh::handleCommand(uint32_t sender_timestamp, ClientInfo* sender, char * } else if (strncmp(command, "send text.flood ", 16) == 0) { char* text = trimSpaces(command + 16); if (*text == 0) { - strcpy(reply, "Err - usage: send text.flood "); + strcpy(reply, MESH_CLI_REPLY_TEXT("Err - usage: send text.flood ", "Err - send text.flood ")); } else if (sendRepeatersFloodText(text)) { strcpy(reply, "OK"); } else { @@ -13275,7 +13276,7 @@ void MyMesh::handleCommand(uint32_t sender_timestamp, ClientInfo* sender, char * snprintf(reply, 160, "OK - saved off; rx.watchdog=%s", radio_timing.watchdogLabel(_prefs.rx_watchdog_enabled)); } else { - strcpy(reply, "Err - usage: set rx.watchdog "); + strcpy(reply, MESH_CLI_REPLY_TEXT("Err - usage: set rx.watchdog ", "Err - set rx.watchdog ")); } } else if (strncmp(command, "set battery.alert ", 18) == 0) { const char* value = command + 18; @@ -13286,22 +13287,22 @@ void MyMesh::handleCommand(uint32_t sender_timestamp, ClientInfo* sender, char * if (*region_name) { if (strchr(region_name, ' ') != NULL) { - strcpy(reply, "Err - region names cannot contain spaces"); + strcpy(reply, MESH_CLI_REPLY_TEXT("Err - region names cannot contain spaces", "Err - region name: no spaces")); return; } region = region_map.findByName(region_name); if (region == NULL || region->isWildcard()) { - strcpy(reply, "Err - unknown or invalid alert region"); + strcpy(reply, MESH_CLI_REPLY_TEXT("Err - unknown or invalid alert region", "Err - unknown/invalid alert region")); return; } } else { region = findNarrowestBatteryAlertRegion(ambiguous); if (region == NULL) { - strcpy(reply, "Err - define a usable region before enabling battery alerts"); + strcpy(reply, MESH_CLI_REPLY_TEXT("Err - define a usable region before enabling battery alerts", "Err - battery alerts need a usable region first")); return; } if (ambiguous) { - strcpy(reply, "Err - multiple narrowest regions; specify one"); + strcpy(reply, MESH_CLI_REPLY_TEXT("Err - multiple narrowest regions; specify one", "Err - specify one of the narrowest regions")); return; } } @@ -13324,14 +13325,14 @@ void MyMesh::handleCommand(uint32_t sender_timestamp, ClientInfo* sender, char * savePrefs(); strcpy(reply, "OK"); } else { - strcpy(reply, "Err - usage: set battery.alert "); + strcpy(reply, MESH_CLI_REPLY_TEXT("Err - usage: set battery.alert ", "Err - set battery.alert ")); } } else if (strncmp(command, "set battery.alert.low ", 22) == 0) { uint8_t percent; if (!parseBatteryAlertPercent(command + 22, 1, 100, percent)) { - strcpy(reply, "Err - usage: set battery.alert.low <1-100>"); + strcpy(reply, MESH_CLI_REPLY_TEXT("Err - usage: set battery.alert.low <1-100>", "Err - set battery.alert.low <1-100>")); } else if (percent <= _prefs.battery_alert_critical_percent) { - strcpy(reply, "Err - low must be greater than critical"); + strcpy(reply, MESH_CLI_REPLY_TEXT("Err - low must be greater than critical", "Err - low must exceed critical")); } else { _prefs.battery_alert_low_percent = percent; next_battery_alert_check = 0; @@ -13341,9 +13342,9 @@ void MyMesh::handleCommand(uint32_t sender_timestamp, ClientInfo* sender, char * } else if (strncmp(command, "set battery.alert.critical ", 27) == 0) { uint8_t percent; if (!parseBatteryAlertPercent(command + 27, 0, 99, percent)) { - strcpy(reply, "Err - usage: set battery.alert.critical <0-99>"); + strcpy(reply, MESH_CLI_REPLY_TEXT("Err - usage: set battery.alert.critical <0-99>", "Err - set battery.alert.critical <0-99>")); } else if (percent >= _prefs.battery_alert_low_percent) { - strcpy(reply, "Err - critical must be less than low"); + strcpy(reply, MESH_CLI_REPLY_TEXT("Err - critical must be less than low", "Err - critical must be below low")); } else { _prefs.battery_alert_critical_percent = percent; next_battery_alert_check = 0; diff --git a/src/helpers/CLICommandUtils.h b/src/helpers/CLICommandUtils.h index d036424cf..eab2744f5 100644 --- a/src/helpers/CLICommandUtils.h +++ b/src/helpers/CLICommandUtils.h @@ -1,5 +1,6 @@ #pragma once +#include "CLIReplyText.h" #include #include #include diff --git a/src/helpers/CLIReplyText.h b/src/helpers/CLIReplyText.h new file mode 100644 index 000000000..315de80cf --- /dev/null +++ b/src/helpers/CLIReplyText.h @@ -0,0 +1,14 @@ +#pragma once + +// Constrained profiles may shorten human CLI prose. Command syntax, structured +// replies, protocol acknowledgements, destructive and rollback warnings keep +// their exact text. +#ifndef MESH_CLI_COMPACT_REPLIES +#define MESH_CLI_COMPACT_REPLIES 0 +#endif + +#if MESH_CLI_COMPACT_REPLIES +#define MESH_CLI_REPLY_TEXT(verbose, concise) concise +#else +#define MESH_CLI_REPLY_TEXT(verbose, concise) verbose +#endif diff --git a/src/helpers/CommonCLI.cpp b/src/helpers/CommonCLI.cpp index dce9ba037..8d227e6da 100644 --- a/src/helpers/CommonCLI.cpp +++ b/src/helpers/CommonCLI.cpp @@ -1,3 +1,4 @@ +#include "CLIReplyText.h" #include #if defined(USE_LR2021) #include @@ -262,7 +263,7 @@ bool CommonCLI::applyAndSavePrimaryRadioParams(float freq, float bw, uint8_t sf, uint8_t cr, uint16_t preamble, char* reply) { const int8_t previous_power = _prefs->tx_power_dbm; if (!savePrimaryRadioParams(freq, bw, sf, cr, preamble)) { - mesh::cli::formatError(reply, 160, "radio settings could not be saved"); + mesh::cli::formatError(reply, 160, MESH_CLI_REPLY_TEXT("radio settings could not be saved", "radio settings not saved")); return false; } // Arm the role's reply handoff now, while the originating CLI command is @@ -3691,7 +3692,7 @@ void CommonCLI::handleCommand(uint32_t sender_timestamp, char* command, char* re _callbacks->onManualClockSet(); uint32_t now = getRTCClock()->getCurrentTime(); DateTime dt = DateTime(now); - sprintf(reply, "OK - clock set: %02d:%02d - %d/%d/%d UTC", dt.hour(), dt.minute(), dt.day(), dt.month(), dt.year()); + sprintf(reply, MESH_CLI_REPLY_TEXT("OK - clock set: %02d:%02d - %d/%d/%d UTC", "OK - clock: %02d:%02d - %d/%d/%d UTC"), dt.hour(), dt.minute(), dt.day(), dt.month(), dt.year()); } else { strcpy(reply, "ERR: clock cannot go backwards"); } @@ -3866,7 +3867,7 @@ void CommonCLI::handleCommand(uint32_t sender_timestamp, char* command, char* re _callbacks->onManualClockSet(); uint32_t now = getRTCClock()->getCurrentTime(); DateTime dt = DateTime(now); - sprintf(reply, "OK - clock set: %02d:%02d - %d/%d/%d UTC", dt.hour(), dt.minute(), dt.day(), dt.month(), dt.year()); + sprintf(reply, MESH_CLI_REPLY_TEXT("OK - clock set: %02d:%02d - %d/%d/%d UTC", "OK - clock: %02d:%02d - %d/%d/%d UTC"), dt.hour(), dt.minute(), dt.day(), dt.month(), dt.year()); } else { strcpy(reply, "(ERR: clock cannot go backwards)"); } @@ -3900,7 +3901,7 @@ void CommonCLI::handleCommand(uint32_t sender_timestamp, char* command, char* re } else if (memcmp(command, "tempradio ", 10) == 0) { uint16_t preamble = 0; if (!mesh::RadioProfileCLI::parseSuffix(command + 10, 5, tmp, sizeof(tmp), preamble)) { - strcpy(reply, "Error, use: tempradio f,bw,sf,cr,minutes[,preamble]"); return; + strcpy(reply, MESH_CLI_REPLY_TEXT("Error, use: tempradio f,bw,sf,cr,minutes[,preamble]", "Error, tempradio f,bw,sf,cr,minutes[,preamble]")); return; } const char *parts[5]; int num = mesh::Utils::parseTextParts(tmp, parts, 5); @@ -3965,7 +3966,7 @@ void CommonCLI::handleCommand(uint32_t sender_timestamp, char* command, char* re _callbacks->clearRecentRepeaters(); strcpy(reply, "OK"); } else { - strcpy(reply, "Error, unsupported on this role"); + strcpy(reply, MESH_CLI_REPLY_TEXT("Error, unsupported on this role", "Error, unsupported role")); } } else if (memcmp(command, "get ", 4) == 0) { handleGetCmd(sender_timestamp, command, reply); @@ -4047,7 +4048,7 @@ void CommonCLI::handleCommand(uint32_t sender_timestamp, char* command, char* re } } else if (memcmp(command, "sensor set ", 11) == 0) { if (!copyCLIArgument(tmp, sizeof(tmp), &command[11])) { - strcpy(reply, "Error, argument too long"); + strcpy(reply, MESH_CLI_REPLY_TEXT("Error, argument too long", "Error, arg too long")); return; } const char *parts[2]; @@ -4391,7 +4392,7 @@ void CommonCLI::handleSetCmd(uint32_t sender_timestamp, char* command, char* rep savePrefs(); strcpy(reply, "OK"); } else { - strcpy(reply, "Error, must be 0,1, or 2"); + strcpy(reply, MESH_CLI_REPLY_TEXT("Error, must be 0,1, or 2", "Error, 0|1|2")); } return; } @@ -4781,18 +4782,18 @@ void CommonCLI::handleSetCmd(uint32_t sender_timestamp, char* command, char* rep } #endif if (isAdvancedRetryConfig(config) && !_callbacks->supportsAdvancedRetryConfig()) { - strcpy(reply, "Error, unsupported on this role"); + strcpy(reply, MESH_CLI_REPLY_TEXT("Error, unsupported on this role", "Error, unsupported role")); return; } if (isBasicRetryConfig(config) && !_callbacks->supportsBasicRetryConfig()) { - strcpy(reply, "Error, retry configuration unsupported on this role"); + strcpy(reply, MESH_CLI_REPLY_TEXT("Error, retry configuration unsupported on this role", "Error, retry unsupported for role")); return; } if (memcmp(config, "dutycycle ", 10) == 0) { float dc = 0.0f; if (!mesh::cli::parseDecimalStrict(&config[10], dc) || dc < 1.0f || dc > 100.0f) { - strcpy(reply, "ERROR: dutycycle must be 1-100"); + strcpy(reply, MESH_CLI_REPLY_TEXT("ERROR: dutycycle must be 1-100", "ERROR: dutycycle 1-100")); } else { _prefs->airtime_factor = (100.0f / dc) - 1.0f; savePrefs(); @@ -4816,7 +4817,7 @@ void CommonCLI::handleSetCmd(uint32_t sender_timestamp, char* command, char* rep mesh::cli::formatError(reply, 160, "use set system.watchdog on|off"); } #else - mesh::cli::formatError(reply, 160, "unsupported on this platform"); + mesh::cli::formatError(reply, 160, MESH_CLI_REPLY_TEXT("unsupported on this platform", "platform unsupported")); #endif } else if (memcmp(config, "af ", 3) == 0) { float factor = 0.0f; @@ -4825,7 +4826,7 @@ void CommonCLI::handleSetCmd(uint32_t sender_timestamp, char* command, char* rep savePrefs(); strcpy(reply, "OK"); } else { - strcpy(reply, "Error, invalid airtime factor"); + strcpy(reply, MESH_CLI_REPLY_TEXT("Error, invalid airtime factor", "Error, bad airtime factor")); } } else if (memcmp(config, "int.thresh ", 11) == 0) { _prefs->interference_threshold = atoi(&config[11]); @@ -4847,7 +4848,7 @@ void CommonCLI::handleSetCmd(uint32_t sender_timestamp, char* command, char* rep } else { _prefs->agc_reset_interval = seconds / 4; savePrefs(); - sprintf(reply, "OK - interval rounded to %d", ((uint32_t) _prefs->agc_reset_interval) * 4); + sprintf(reply, MESH_CLI_REPLY_TEXT("OK - interval rounded to %d", "OK - rounded interval %d"), ((uint32_t) _prefs->agc_reset_interval) * 4); } } else if (memcmp(config, "multi.acks ", 11) == 0) { _prefs->multi_acks = atoi(&config[11]); @@ -4867,7 +4868,7 @@ void CommonCLI::handleSetCmd(uint32_t sender_timestamp, char* command, char* rep savePrefs(); strcpy(reply, "OK"); } else { - strcpy(reply, "ERROR: telemetry.access must be all or acl"); + strcpy(reply, MESH_CLI_REPLY_TEXT("ERROR: telemetry.access must be all or acl", "ERROR: telemetry.access all|acl")); } } else if (memcmp(config, "flood.advert.interval ", 22) == 0) { uint32_t hours = 0; @@ -4889,7 +4890,7 @@ void CommonCLI::handleSetCmd(uint32_t sender_timestamp, char* command, char* rep uint32_t mins = 0; if (!parseUint32Strict(&config[16], mins) || (mins != 0 && (mins < MIN_LOCAL_ADVERT_INTERVAL || mins > 240))) { - sprintf(reply, "Error: interval must be 0 (off) or %d-240 minutes", MIN_LOCAL_ADVERT_INTERVAL); + sprintf(reply, MESH_CLI_REPLY_TEXT("Error: interval must be 0 (off) or %d-240 minutes", "Error: interval 0 (off) or %d-240 minutes"), MIN_LOCAL_ADVERT_INTERVAL); } else { const uint8_t previous = _prefs->advert_interval; _prefs->advert_interval = (uint8_t)(mins / 2); @@ -4940,9 +4941,9 @@ void CommonCLI::handleSetCmd(uint32_t sender_timestamp, char* command, char* rep _prefs->disable_fwd = strcmp(&config[7], "off") == 0; savePrefs(); _callbacks->onRetryConfigChanged(); - strcpy(reply, _prefs->disable_fwd ? "OK - repeat is now OFF" : "OK - repeat is now ON"); + strcpy(reply, _prefs->disable_fwd ? MESH_CLI_REPLY_TEXT("OK - repeat is now OFF", "OK - repeat OFF") : MESH_CLI_REPLY_TEXT("OK - repeat is now ON", "OK - repeat ON")); } else { - strcpy(reply, "Error, must be on or off"); + strcpy(reply, MESH_CLI_REPLY_TEXT("Error, must be on or off", "Error, on|off")); } } else if (memcmp(config, "radio.rxgain ", 13) == 0) { const char* value = &config[13]; @@ -4984,7 +4985,7 @@ void CommonCLI::handleSetCmd(uint32_t sender_timestamp, char* command, char* rep if (changed) _callbacks->recalibrateNoiseFloor(); _prefs->radio_fem_rxgain = 1; savePrefs(); - strcpy(reply, "OK - LoRa FEM RX gain on"); + strcpy(reply, MESH_CLI_REPLY_TEXT("OK - LoRa FEM RX gain on", "OK - FEM RX gain on")); } else { mesh::cli::formatError(reply, 160, "failed to apply LoRa FEM RX gain"); } @@ -4994,7 +4995,7 @@ void CommonCLI::handleSetCmd(uint32_t sender_timestamp, char* command, char* rep if (changed) _callbacks->recalibrateNoiseFloor(); _prefs->radio_fem_rxgain = 0; savePrefs(); - strcpy(reply, "OK - LoRa FEM RX gain off"); + strcpy(reply, MESH_CLI_REPLY_TEXT("OK - LoRa FEM RX gain off", "OK - FEM RX gain off")); } else { mesh::cli::formatError(reply, 160, "failed to apply LoRa FEM RX gain"); } @@ -5013,7 +5014,7 @@ void CommonCLI::handleSetCmd(uint32_t sender_timestamp, char* command, char* rep if (_board->setLoRaFemPaGainEnabled(true)) { _prefs->radio_fem_txgain = 1; savePrefs(); - strcpy(reply, "OK - LoRa FEM TX gain on"); + strcpy(reply, MESH_CLI_REPLY_TEXT("OK - LoRa FEM TX gain on", "OK - FEM TX gain on")); } else { mesh::cli::formatError(reply, 160, "failed to apply LoRa FEM TX gain"); } @@ -5021,7 +5022,7 @@ void CommonCLI::handleSetCmd(uint32_t sender_timestamp, char* command, char* rep if (_board->setLoRaFemPaGainEnabled(false)) { _prefs->radio_fem_txgain = 0; savePrefs(); - strcpy(reply, "OK - LoRa FEM TX gain off"); + strcpy(reply, MESH_CLI_REPLY_TEXT("OK - LoRa FEM TX gain off", "OK - FEM TX gain off")); } else { mesh::cli::formatError(reply, 160, "failed to apply LoRa FEM TX gain"); } @@ -5144,14 +5145,14 @@ void CommonCLI::handleSetCmd(uint32_t sender_timestamp, char* command, char* rep if (manual_timings_requested && (rx_us < RX_POWERSAVING_MIN_MANUAL_PERIOD_US || sleep_us < RX_POWERSAVING_MIN_MANUAL_PERIOD_US)) { - sprintf(reply, "ERROR: range is %lu-%lu us", + sprintf(reply, MESH_CLI_REPLY_TEXT("ERROR: range is %lu-%lu us", "ERROR: range %lu-%lu us"), (unsigned long)RX_POWERSAVING_MIN_MANUAL_PERIOD_US, (unsigned long)RX_POWERSAVING_MAX_PERIOD_US); return; } if (!isValidRxPowerSavingPeriod(rx_us) || !isValidRxPowerSavingPeriod(sleep_us)) { - sprintf(reply, "ERROR: range is %lu-%lu us", + sprintf(reply, MESH_CLI_REPLY_TEXT("ERROR: range is %lu-%lu us", "ERROR: range %lu-%lu us"), (unsigned long)RX_POWERSAVING_MIN_MANUAL_PERIOD_US, (unsigned long)RX_POWERSAVING_MAX_PERIOD_US); return; @@ -5223,7 +5224,7 @@ void CommonCLI::handleSetCmd(uint32_t sender_timestamp, char* command, char* rep appendRxPowerSavingAdjustmentNote(reply, _prefs, sf, bw); _radio_profiles.appendPrimaryChirpWarning(reply, 160, sf, bw, preamble); } else { - strcpy(reply, "Error, invalid radio params"); + strcpy(reply, MESH_CLI_REPLY_TEXT("Error, invalid radio params", "Error, bad radio params")); } } else if (memcmp(config, "radioat ", 8) == 0) { float freq, bw; @@ -5233,9 +5234,9 @@ void CommonCLI::handleSetCmd(uint32_t sender_timestamp, char* command, char* rep char legacy[120]; if (!mesh::RadioProfileCLI::parseSuffix(&config[8], 5, legacy, sizeof(legacy), preamble) || !parseScheduledRadioArgs(legacy, false, freq, bw, sf, cr, start_time, end_time, _rtc->getCurrentTime())) { - strcpy(reply, "Error, use: set radioat f,bw,sf,cr,start (UTC or +minutes)"); + strcpy(reply, MESH_CLI_REPLY_TEXT("Error, use: set radioat f,bw,sf,cr,start (UTC or +minutes)", "Error, set radioat f,bw,sf,cr,start (UTC or +minutes)")); } else if (!acceptsPrimaryRadioParams(freq, bw, sf, cr, preamble)) { - strcpy(reply, "Error, invalid radio params"); + strcpy(reply, MESH_CLI_REPLY_TEXT("Error, invalid radio params", "Error, bad radio params")); } else { _callbacks->addScheduledRadioParams(false, freq, bw, sf, cr, start_time, end_time, reply, preamble); if (strncmp(reply, "OK", 2) == 0) { @@ -5251,9 +5252,9 @@ void CommonCLI::handleSetCmd(uint32_t sender_timestamp, char* command, char* rep char legacy[120]; if (!mesh::RadioProfileCLI::parseSuffix(&config[12], 6, legacy, sizeof(legacy), preamble) || !parseScheduledRadioArgs(legacy, true, freq, bw, sf, cr, start_time, end_time, _rtc->getCurrentTime())) { - strcpy(reply, "Error, use: set tempradioat f,bw,sf,cr,start,end (UTC or +minutes)"); + strcpy(reply, MESH_CLI_REPLY_TEXT("Error, use: set tempradioat f,bw,sf,cr,start,end (UTC or +minutes)", "Error, set tempradioat f,bw,sf,cr,start,end (UTC or +minutes)")); } else if (!acceptsPrimaryRadioParams(freq, bw, sf, cr, preamble)) { - strcpy(reply, "Error, invalid radio params"); + strcpy(reply, MESH_CLI_REPLY_TEXT("Error, invalid radio params", "Error, bad radio params")); } else { _callbacks->addScheduledRadioParams(true, freq, bw, sf, cr, start_time, end_time, reply, preamble); if (strncmp(reply, "OK", 2) == 0) { @@ -5268,7 +5269,7 @@ void CommonCLI::handleSetCmd(uint32_t sender_timestamp, char* command, char* rep savePrefs(); strcpy(reply, "OK"); } else { - strcpy(reply, "Error, invalid latitude"); + strcpy(reply, MESH_CLI_REPLY_TEXT("Error, invalid latitude", "Error, bad latitude")); } } else if (memcmp(config, "lon ", 4) == 0) { float longitude = 0.0f; @@ -5277,7 +5278,7 @@ void CommonCLI::handleSetCmd(uint32_t sender_timestamp, char* command, char* rep savePrefs(); strcpy(reply, "OK"); } else { - strcpy(reply, "Error, invalid longitude"); + strcpy(reply, MESH_CLI_REPLY_TEXT("Error, invalid longitude", "Error, bad longitude")); } } else if (memcmp(config, "rxdelay ", 8) == 0) { float db = 0.0f; @@ -5287,7 +5288,7 @@ void CommonCLI::handleSetCmd(uint32_t sender_timestamp, char* command, char* rep savePrefs(); strcpy(reply, "OK"); } else { - strcpy(reply, "Error, must be 0-20"); + strcpy(reply, MESH_CLI_REPLY_TEXT("Error, must be 0-20", "Error, 0-20")); } } else if (memcmp(config, "txdelay ", 8) == 0) { float f = 0.0f; @@ -5297,7 +5298,7 @@ void CommonCLI::handleSetCmd(uint32_t sender_timestamp, char* command, char* rep savePrefs(); strcpy(reply, "OK"); } else { - strcpy(reply, "Error, must be 0-2"); + strcpy(reply, MESH_CLI_REPLY_TEXT("Error, must be 0-2", "Error, 0-2")); } } else if (memcmp(config, "flood.max.unscoped ", 19) == 0) { uint32_t m; @@ -5334,7 +5335,7 @@ void CommonCLI::handleSetCmd(uint32_t sender_timestamp, char* command, char* rep savePrefs(); strcpy(reply, "OK"); } else { - strcpy(reply, "Error, must be 0-2"); + strcpy(reply, MESH_CLI_REPLY_TEXT("Error, must be 0-2", "Error, 0-2")); } } else if (memcmp(config, "retry.preset ", 13) == 0) { uint8_t preset; @@ -5343,7 +5344,7 @@ void CommonCLI::handleSetCmd(uint32_t sender_timestamp, char* command, char* rep savePrefs(); sprintf(reply, "OK - %s", retryPresetName(_prefs->retry_preset)); } else { - strcpy(reply, "Error, must be infra, rooftop, or mobile"); + strcpy(reply, MESH_CLI_REPLY_TEXT("Error, must be infra, rooftop, or mobile", "Error, infra|rooftop|mobile")); } } else if (memcmp(config, "direct.retry ", 13) == 0) { if (strcmp(&config[13], "on") == 0) { @@ -5357,7 +5358,7 @@ void CommonCLI::handleSetCmd(uint32_t sender_timestamp, char* command, char* rep _callbacks->onRetryConfigChanged(); strcpy(reply, "OK"); } else { - strcpy(reply, "Error, must be on or off"); + strcpy(reply, MESH_CLI_REPLY_TEXT("Error, must be on or off", "Error, on|off")); } } else if (memcmp(config, "direct.retry.heard ", 19) == 0) { if (strcmp(&config[19], "on") == 0) { @@ -5369,11 +5370,11 @@ void CommonCLI::handleSetCmd(uint32_t sender_timestamp, char* command, char* rep savePrefs(); strcpy(reply, "OK"); } else { - strcpy(reply, "Error, must be on or off"); + strcpy(reply, MESH_CLI_REPLY_TEXT("Error, must be on or off", "Error, on|off")); } } else if (memcmp(config, "direct.retry.margin ", 20) == 0) { if (!looksNumeric(&config[20])) { - strcpy(reply, "Error, must be 0-40 dB"); + strcpy(reply, MESH_CLI_REPLY_TEXT("Error, must be 0-40 dB", "Error, 0-40 dB")); } else { int16_t margin_x4 = parseSnrDbX4(&config[20]); if (margin_x4 >= 0 && margin_x4 <= 160) { @@ -5382,7 +5383,7 @@ void CommonCLI::handleSetCmd(uint32_t sender_timestamp, char* command, char* rep savePrefs(); strcpy(reply, "OK"); } else { - strcpy(reply, "Error, must be 0-40 dB"); + strcpy(reply, MESH_CLI_REPLY_TEXT("Error, must be 0-40 dB", "Error, 0-40 dB")); } } } else if (memcmp(config, "direct.retry.count ", 19) == 0) { @@ -5393,7 +5394,7 @@ void CommonCLI::handleSetCmd(uint32_t sender_timestamp, char* command, char* rep savePrefs(); strcpy(reply, "OK"); } else { - strcpy(reply, "Error, must be 1-15"); + strcpy(reply, MESH_CLI_REPLY_TEXT("Error, must be 1-15", "Error, 1-15")); } } else if (memcmp(config, "direct.retry.base ", 18) == 0) { int base_ms = looksUnsignedInteger(&config[18]) ? _atoi(&config[18]) : -1; @@ -5403,7 +5404,7 @@ void CommonCLI::handleSetCmd(uint32_t sender_timestamp, char* command, char* rep savePrefs(); strcpy(reply, "OK"); } else { - strcpy(reply, "Error, must be 10-5000 ms"); + strcpy(reply, MESH_CLI_REPLY_TEXT("Error, must be 10-5000 ms", "Error, 10-5000 ms")); } } else if (memcmp(config, "direct.retry.step ", 18) == 0) { int step_ms = looksUnsignedInteger(&config[18]) ? _atoi(&config[18]) : -1; @@ -5413,7 +5414,7 @@ void CommonCLI::handleSetCmd(uint32_t sender_timestamp, char* command, char* rep savePrefs(); strcpy(reply, "OK"); } else { - strcpy(reply, "Error, must be 0-5000 ms"); + strcpy(reply, MESH_CLI_REPLY_TEXT("Error, must be 0-5000 ms", "Error, 0-5000 ms")); } } else if (memcmp(config, "flood.channel.data.hops ", 24) == 0) { uint8_t max_hops; @@ -5422,7 +5423,7 @@ void CommonCLI::handleSetCmd(uint32_t sender_timestamp, char* command, char* rep savePrefs(); strcpy(reply, "OK"); } else { - strcpy(reply, "Error, must be all or 1-7"); + strcpy(reply, MESH_CLI_REPLY_TEXT("Error, must be all or 1-7", "Error, all|1-7")); } } else if (memcmp(config, "flood.channel.data ", 19) == 0) { if (strcmp(&config[19], "on") == 0) { @@ -5434,7 +5435,7 @@ void CommonCLI::handleSetCmd(uint32_t sender_timestamp, char* command, char* rep savePrefs(); strcpy(reply, "OK"); } else { - strcpy(reply, "Error, must be on or off"); + strcpy(reply, MESH_CLI_REPLY_TEXT("Error, must be on or off", "Error, on|off")); } } else if (memcmp(config, "flood.retry.count ", 18) == 0) { int attempts = looksUnsignedInteger(&config[18]) ? _atoi(&config[18]) : -1; @@ -5445,7 +5446,7 @@ void CommonCLI::handleSetCmd(uint32_t sender_timestamp, char* command, char* rep _callbacks->onRetryConfigChanged(); strcpy(reply, "OK"); } else { - strcpy(reply, "Error, must be 0-15"); + strcpy(reply, MESH_CLI_REPLY_TEXT("Error, must be 0-15", "Error, 0-15")); } } else if (memcmp(config, "flood.retry.path ", 17) == 0) { uint8_t path_gate; @@ -5458,7 +5459,7 @@ void CommonCLI::handleSetCmd(uint32_t sender_timestamp, char* command, char* rep savePrefs(); strcpy(reply, "OK"); } else { - strcpy(reply, "Error, must be 0-63 or off"); + strcpy(reply, MESH_CLI_REPLY_TEXT("Error, must be 0-63 or off", "Error, 0-63|off")); } } else if (memcmp(config, "flood.retry.group.path ", 23) == 0) { uint8_t path_gate; @@ -5470,7 +5471,7 @@ void CommonCLI::handleSetCmd(uint32_t sender_timestamp, char* command, char* rep savePrefs(); strcpy(reply, "OK"); } else { - strcpy(reply, "Error, must be 0-63 or off"); + strcpy(reply, MESH_CLI_REPLY_TEXT("Error, must be 0-63 or off", "Error, 0-63|off")); } } else if (memcmp(config, "flood.retry.prefixes ", 21) == 0) { if (parseFloodRetryPrefixList(_prefs->flood_retry_prefixes, FLOOD_RETRY_PREFIX_SLOTS, &config[21])) { @@ -5479,7 +5480,7 @@ void CommonCLI::handleSetCmd(uint32_t sender_timestamp, char* command, char* rep strcpy(reply, "OK"); } } else { - sprintf(reply, "Error, use up to %u comma-separated 3-byte hex prefixes", + sprintf(reply, MESH_CLI_REPLY_TEXT("Error, use up to %u comma-separated 3-byte hex prefixes", "Error, max %u comma-separated 3-byte hex prefixes"), (unsigned int)FLOOD_RETRY_PREFIX_SLOTS); } } else if (memcmp(config, "flood.retry.ignore ", 19) == 0) { @@ -5488,7 +5489,7 @@ void CommonCLI::handleSetCmd(uint32_t sender_timestamp, char* command, char* rep savePrefs(); strcpy(reply, "OK"); } else { - sprintf(reply, "Error, use up to %u comma-separated 3-byte hex prefixes", + sprintf(reply, MESH_CLI_REPLY_TEXT("Error, use up to %u comma-separated 3-byte hex prefixes", "Error, max %u comma-separated 3-byte hex prefixes"), (unsigned int)FLOOD_RETRY_IGNORE_PREFIXES); } } else if (memcmp(config, "flood.retry.advert ", 19) == 0) { @@ -5501,7 +5502,7 @@ void CommonCLI::handleSetCmd(uint32_t sender_timestamp, char* command, char* rep savePrefs(); strcpy(reply, "OK"); } else { - strcpy(reply, "Error, must be on or off"); + strcpy(reply, MESH_CLI_REPLY_TEXT("Error, must be on or off", "Error, on|off")); } } else if (memcmp(config, "flood.retry.bridge ", 19) == 0) { if (strcmp(&config[19], "on") == 0) { @@ -5519,7 +5520,7 @@ void CommonCLI::handleSetCmd(uint32_t sender_timestamp, char* command, char* rep savePrefs(); strcpy(reply, "OK"); } else { - strcpy(reply, "Error, must be on or off"); + strcpy(reply, MESH_CLI_REPLY_TEXT("Error, must be on or off", "Error, on|off")); } } else if (memcmp(config, "flood.retry.bucket ", 19) == 0) { const char* params = &config[19]; @@ -5536,12 +5537,12 @@ void CommonCLI::handleSetCmd(uint32_t sender_timestamp, char* command, char* rep strcpy(reply, "OK"); } } else { - sprintf(reply, "Error, use up to %u comma-separated 3-byte hex prefixes", + sprintf(reply, MESH_CLI_REPLY_TEXT("Error, use up to %u comma-separated 3-byte hex prefixes", "Error, max %u comma-separated 3-byte hex prefixes"), (unsigned int)FLOOD_RETRY_BUCKET_PREFIXES); } } else if (memcmp(config, "direct.retry.cr ", 16) == 0) { if (!copyCLIArgument(tmp, sizeof(tmp), &config[16])) { - strcpy(reply, "Error, argument too long"); + strcpy(reply, MESH_CLI_REPLY_TEXT("Error, argument too long", "Error, arg too long")); return; } if (strcmp(&config[16], "off") == 0) { @@ -5554,7 +5555,7 @@ void CommonCLI::handleSetCmd(uint32_t sender_timestamp, char* command, char* rep savePrefs(); strcpy(reply, "OK"); } else { - strcpy(reply, "Error, use on or off on this role"); + strcpy(reply, MESH_CLI_REPLY_TEXT("Error, use on or off on this role", "Error, on|off")); } } else { const char *parts[4]; @@ -5573,15 +5574,15 @@ void CommonCLI::handleSetCmd(uint32_t sender_timestamp, char* command, char* rep savePrefs(); strcpy(reply, "OK"); } else { - strcpy(reply, "Error, SNR must fit -32.00..31.75 dB"); + strcpy(reply, MESH_CLI_REPLY_TEXT("Error, SNR must fit -32.00..31.75 dB", "Error, SNR -32.00..31.75 dB")); } } else { - strcpy(reply, "Error, use CR4,CR5,CR7,CR8 SNRs or off"); + strcpy(reply, MESH_CLI_REPLY_TEXT("Error, use CR4,CR5,CR7,CR8 SNRs or off", "Error, CR4,CR5,CR7,CR8 SNRs|off")); } } } else if (memcmp(config, "recent.repeater ", 16) == 0) { if (!copyCLIArgument(tmp, sizeof(tmp), &config[16])) { - strcpy(reply, "Error, argument too long"); + strcpy(reply, MESH_CLI_REPLY_TEXT("Error, argument too long", "Error, arg too long")); return; } const char *parts[2]; @@ -5590,15 +5591,15 @@ void CommonCLI::handleSetCmd(uint32_t sender_timestamp, char* command, char* rep uint8_t prefix_len = 0; int16_t snr_x4 = 12; // default to +3.0 dB when omitted or invalid if (num < 1 || !parseHashPrefix(parts[0], prefix, prefix_len)) { - strcpy(reply, "Error, use: set recent.repeater [snr_db]"); + strcpy(reply, MESH_CLI_REPLY_TEXT("Error, use: set recent.repeater [snr_db]", "Error, set recent.repeater [snr_db]")); } else if (num > 1 && !looksNumeric(parts[1])) { - strcpy(reply, "Error, SNR must be numeric"); + strcpy(reply, MESH_CLI_REPLY_TEXT("Error, SNR must be numeric", "Error, numeric SNR needed")); } else { if (num > 1) { snr_x4 = parseSnrDbX4(parts[1]); } if (snr_x4 < -128 || snr_x4 > 127) { - strcpy(reply, "Error, SNR must fit -32.00..31.75 dB"); + strcpy(reply, MESH_CLI_REPLY_TEXT("Error, SNR must fit -32.00..31.75 dB", "Error, SNR -32.00..31.75 dB")); } else if (_callbacks->setRecentRepeater(prefix, prefix_len, (int8_t)snr_x4)) { char prefix_hex[RECENT_REPEATER_PREFIX_MAX_BYTES * 2 + 1]; char snr[12]; @@ -5607,7 +5608,7 @@ void CommonCLI::handleSetCmd(uint32_t sender_timestamp, char* command, char* rep formatSnrDbX4Short(snr, sizeof(snr), snr_x4); sprintf(reply, "OK - set %s at %s SNR", prefix_hex, snr); } else { - strcpy(reply, "Error, table rejected prefix"); + strcpy(reply, MESH_CLI_REPLY_TEXT("Error, table rejected prefix", "Error, prefix rejected")); } } } else if (memcmp(config, "owner.info ", 11) == 0) { @@ -5633,7 +5634,7 @@ void CommonCLI::handleSetCmd(uint32_t sender_timestamp, char* command, char* rep mode = LOOP_DETECT_STRICT; } else { mode = 0xFF; - strcpy(reply, "Error, must be: off, minimal, moderate, or strict"); + strcpy(reply, MESH_CLI_REPLY_TEXT("Error, must be: off, minimal, moderate, or strict", "Error, off|minimal|moderate|strict")); } if (mode != 0xFF) { _prefs->loop_detect = mode; @@ -5647,7 +5648,7 @@ void CommonCLI::handleSetCmd(uint32_t sender_timestamp, char* command, char* rep if (!mesh::cli::parseIntegerStrict(&config[3], power)) { mesh::cli::formatError(reply, 160, "invalid TX power"); } else if (!mesh::isLoRaTxPowerValid(power, _prefs->freq)) { - sprintf(reply, "Error: TX power range is %d to %d dBm", + sprintf(reply, MESH_CLI_REPLY_TEXT("Error: TX power range is %d to %d dBm", "Error: TX power %d..%d dBm"), (int)minimum, (int)maximum); } else { applyAndSaveTxPower(static_cast(power), reply); @@ -5659,7 +5660,7 @@ void CommonCLI::handleSetCmd(uint32_t sender_timestamp, char* command, char* rep applyAndSavePrimaryRadioParams(freq, _prefs->bw, _prefs->sf, _prefs->cr, _prefs->primary_radio_preamble, reply); } else { - strcpy(reply, "Error, invalid frequency"); + strcpy(reply, MESH_CLI_REPLY_TEXT("Error, invalid frequency", "Error, bad frequency")); } #ifdef WITH_BRIDGE } else if (memcmp(config, "bridge.enabled ", 15) == 0) { @@ -6167,11 +6168,11 @@ void CommonCLI::handleGetCmd(uint32_t sender_timestamp, char* command, char* rep } #endif if (isAdvancedRetryConfig(config) && !_callbacks->supportsAdvancedRetryConfig()) { - strcpy(reply, "Error, unsupported on this role"); + strcpy(reply, MESH_CLI_REPLY_TEXT("Error, unsupported on this role", "Error, unsupported role")); return; } if (isBasicRetryConfig(config) && !_callbacks->supportsBasicRetryConfig()) { - strcpy(reply, "Error, retry configuration unsupported on this role"); + strcpy(reply, MESH_CLI_REPLY_TEXT("Error, retry configuration unsupported on this role", "Error, retry unsupported for role")); return; } mesh::cli::RecentRepeaterGetQuery recent_query; @@ -6186,7 +6187,7 @@ void CommonCLI::handleGetCmd(uint32_t sender_timestamp, char* command, char* rep #if defined(NRF52_PLATFORM) sprintf(reply, "> %s", _prefs->system_watchdog_enabled ? "on" : "off"); #else - mesh::cli::formatError(reply, 160, "unsupported on this platform"); + mesh::cli::formatError(reply, 160, MESH_CLI_REPLY_TEXT("unsupported on this platform", "platform unsupported")); #endif } else if (configKeyEquals(config, "af")) { sprintf(reply, "> %s", StrHelper::ftoa(_prefs->airtime_factor)); @@ -6357,7 +6358,7 @@ void CommonCLI::handleGetCmd(uint32_t sender_timestamp, char* command, char* rep recent_query.search_prefix_len == 0 ? NULL : recent_query.search_prefix, recent_query.search_prefix_len); } else if (recent_query_match == mesh::cli::RecentRepeaterGetMatch::Invalid) { - strcpy(reply, "Error, use: get recent.repeaters [page] | search <2|4|6 hex> [page]"); + strcpy(reply, MESH_CLI_REPLY_TEXT("Error, use: get recent.repeaters [page] | search <2|4|6 hex> [page]", "Error, get recent.repeaters [page] | search <2|4|6 hex> [page]")); } else if (configKeyEquals(config, "owner.info")) { *reply++ = '>'; *reply++ = ' '; @@ -6513,7 +6514,7 @@ void CommonCLI::handleGetCmd(uint32_t sender_timestamp, char* command, char* rep #ifdef NRF52_POWER_MANAGEMENT strcpy(reply, _board->isExternalPowered() ? "> external" : "> battery"); #else - strcpy(reply, "ERROR: Power management not supported"); + strcpy(reply, MESH_CLI_REPLY_TEXT("ERROR: Power management not supported", "ERROR: Power management unsupported")); #endif } else if (configKeyEquals(config, "pwrmgt.bootreason")) { sprintf(reply, "> Reset: %s; Shutdown: %s", @@ -6523,7 +6524,7 @@ void CommonCLI::handleGetCmd(uint32_t sender_timestamp, char* command, char* rep #ifdef NRF52_POWER_MANAGEMENT sprintf(reply, "> %u mV", _board->getBootVoltage()); #else - strcpy(reply, "ERROR: Power management not supported"); + strcpy(reply, MESH_CLI_REPLY_TEXT("ERROR: Power management not supported", "ERROR: Power management unsupported")); #endif } else if (configKeyEquals(config, "reboot.interval")) { if (_prefs->reboot_interval == 0) { @@ -6608,7 +6609,7 @@ static bool processRegionDefSegment(RegionMap* map, char* tok, RegionEntry** cur if (jump) { RegionEntry* j = map->findByNamePrefix(jump); - if (j == NULL) { snprintf(reply, 160, "Err - unknown or ambiguous jump: %s", jump); return false; } + if (j == NULL) { snprintf(reply, 160, MESH_CLI_REPLY_TEXT("Err - unknown or ambiguous jump: %s", "Err - unknown/ambiguous jump: %s"), jump); return false; } *cursor = j; } else { *cursor = r; diff --git a/src/helpers/FloodAdvertCLI.h b/src/helpers/FloodAdvertCLI.h index 5bfaf4f41..3bdb1cb96 100644 --- a/src/helpers/FloodAdvertCLI.h +++ b/src/helpers/FloodAdvertCLI.h @@ -1,5 +1,7 @@ #pragma once +#include "CLIReplyText.h" + #include "FloodAdvertLimiter.h" #include "FloodCommandAliases.h" #include @@ -43,7 +45,7 @@ inline bool handleFloodAdvertGet(FloodAdvertLimiter* limiter, const char* comman return true; } if (!limiter) { - strcpy(reply, "ERR: advert limiter unavailable on this role"); + strcpy(reply, MESH_CLI_REPLY_TEXT("ERR: advert limiter unavailable on this role", "ERR: advert limiter unavailable for role")); return true; } @@ -54,7 +56,7 @@ inline bool handleFloodAdvertGet(FloodAdvertLimiter* limiter, const char* comman if (total == 0 && !detail && number == 1) { strcpy(reply, "> no rate-limited adverts"); } else if (offset >= total) { - strcpy(reply, "ERR: advert list index out of range"); + strcpy(reply, MESH_CLI_REPLY_TEXT("ERR: advert list index out of range", "ERR: advert index out of range")); } else if (detail) { const auto& row = rows[0]; char key[PUB_KEY_SIZE * 2 + 1]; @@ -100,11 +102,11 @@ inline bool handleFloodAdvertClear(FloodAdvertLimiter* limiter, const char* comm if (*end || !valid) { strcpy(reply, "ERR: clear flood.advert all|<64-hex-key>"); } else if (!limiter) { - strcpy(reply, "ERR: advert limiter unavailable on this role"); + strcpy(reply, MESH_CLI_REPLY_TEXT("ERR: advert limiter unavailable on this role", "ERR: advert limiter unavailable for role")); } else { if (all) limiter->reset(); else limiter->clear(key); - strcpy(reply, all ? "OK - all advert limit history cleared" : "OK - key advert limit history cleared"); + strcpy(reply, all ? MESH_CLI_REPLY_TEXT("OK - all advert limit history cleared", "OK - all advert history cleared") : MESH_CLI_REPLY_TEXT("OK - key advert limit history cleared", "OK - key advert history cleared")); } return true; } diff --git a/src/helpers/LedSettings.cpp b/src/helpers/LedSettings.cpp index 01abe5c54..94ebc35f7 100644 --- a/src/helpers/LedSettings.cpp +++ b/src/helpers/LedSettings.cpp @@ -1,3 +1,4 @@ +#include "CLIReplyText.h" #include "LedSettings.h" #include "CheckedSettingsFile.h" #include @@ -80,12 +81,12 @@ bool handleCommand(const char* command, char* reply, size_t capacity) { if (*value && *value != ' ' && *value != '\t') return false; const char* message; if (get) { - message = *value ? "Error: usage get leds" : enabled() ? "> on" : "> off"; + message = *value ? MESH_CLI_REPLY_TEXT("Error: usage get leds", "Error: get leds") : enabled() ? "> on" : "> off"; } else { while (*value == ' ' || *value == '\t') ++value; const bool on = strcmp(value, "on") == 0; if (!on && strcmp(value, "off") != 0) { - message = "Error: usage set leds on|off"; + message = MESH_CLI_REPLY_TEXT("Error: usage set leds on|off", "Error: set leds on|off"); } else if (!main_board || !saveSetting(on)) { message = "Error: LED setting save unconfirmed; reboot may differ"; } else { diff --git a/src/helpers/PowerSavingDiagnostics.h b/src/helpers/PowerSavingDiagnostics.h index 3f72a9dd8..d407ca29c 100644 --- a/src/helpers/PowerSavingDiagnostics.h +++ b/src/helpers/PowerSavingDiagnostics.h @@ -1,5 +1,7 @@ #pragma once +#include "CLIReplyText.h" + #include #include #include @@ -315,17 +317,17 @@ inline bool handleCommand(const char* command, char* reply, size_t capacity, if (argument(tail, "on") || argument(tail, "off")) { const bool on = argument(tail, "on"); setEnabled(on, now_ms); - snprintf(reply, capacity, "OK - power diagnostics %s (RAM only; sleep policy unchanged)", + snprintf(reply, capacity, MESH_CLI_REPLY_TEXT("OK - power diagnostics %s (RAM only; sleep policy unchanged)", "OK - power diag %s (RAM only; sleep policy unchanged)"), on ? "on" : "off"); - } else snprintf(reply, capacity, "Error: use set powersaving.diag on|off"); + } else snprintf(reply, capacity, MESH_CLI_REPLY_TEXT("Error: use set powersaving.diag on|off", "Error: set powersaving.diag on|off")); return true; } tail = suffix(command, "clear powersaving.diag"); if (tail) { if (!*tail) { reset(now_ms); - snprintf(reply, capacity, "OK - power diagnostics cleared; sleep policy unchanged"); - } else snprintf(reply, capacity, "Error: use clear powersaving.diag"); + snprintf(reply, capacity, MESH_CLI_REPLY_TEXT("OK - power diagnostics cleared; sleep policy unchanged", "OK - power diag cleared; sleep policy unchanged")); + } else snprintf(reply, capacity, MESH_CLI_REPLY_TEXT("Error: use clear powersaving.diag", "Error: clear powersaving.diag")); return true; } tail = suffix(command, "get powersaving.diag"); diff --git a/src/helpers/RadioProfileCLI.cpp b/src/helpers/RadioProfileCLI.cpp index 04c171d30..d6dbbd5c1 100644 --- a/src/helpers/RadioProfileCLI.cpp +++ b/src/helpers/RadioProfileCLI.cpp @@ -1,3 +1,4 @@ +#include "CLIReplyText.h" #include "RadioProfileCLI.h" #include "CarrierTools.h" #if MESH_ENABLE_CARRIER_TOOLS @@ -1144,7 +1145,7 @@ bool RadioProfileCLI::handle(const char* command, char* reply, size_t capacity, reply_setting_ = 0xb0 | policy | (force ? 8 : 0); if (!save(saved_, primary_preamble_, cross_)) { reply_setting_ = previous; - cli::formatError(reply, capacity, "settings could not be saved"); + cli::formatError(reply, capacity, MESH_CLI_REPLY_TEXT("settings could not be saved", "settings not saved")); } else { publish(); snprintf(reply, capacity, "OK - tx.reply=%s%s", radioTxPolicyName(policy), force ? " force" : ""); @@ -1182,7 +1183,7 @@ bool RadioProfileCLI::handle(const char* command, char* reply, size_t capacity, } else if (verb == Set && (!strcmp(args, "auto") || !strcmp(args, "on") || !strcmp(args, "off"))) { auto mode = !strcmp(args, "auto") ? RadioCrossMode::Auto : !strcmp(args, "on") ? RadioCrossMode::On : RadioCrossMode::Off; if (save(saved_, primary_preamble_, mode)) { cross_ = mode; publish(); snprintf(reply, capacity, "OK"); } - else cli::formatError(reply, capacity, "settings could not be saved"); + else cli::formatError(reply, capacity, MESH_CLI_REPLY_TEXT("settings could not be saved", "settings not saved")); } else cli::formatError(reply, capacity, "use get/set radio2.cross [auto|on|off]"); return true; } @@ -1249,7 +1250,7 @@ bool RadioProfileCLI::handle(const char* command, char* reply, size_t capacity, return true; } if (!save({}, primary_preamble_, cross_, 0)) { - cli::formatError(reply, capacity, "settings could not be saved"); return true; + cli::formatError(reply, capacity, MESH_CLI_REPLY_TEXT("settings could not be saved", "settings not saved")); return true; } noteSecondaryManualChange(); saved_ = {}; manual_secondary_ = false; @@ -1264,7 +1265,7 @@ bool RadioProfileCLI::handle(const char* command, char* reply, size_t capacity, if ((base || temporary) && verb == Set && !strcmp(args, "off")) { if (base && !(remote_command_ ? prepareSavedImage(ReplyPath, {}, primary_preamble_, cross_, true) : save({}, primary_preamble_, cross_, true))) { - cli::formatError(reply, capacity, "settings could not be saved"); return true; + cli::formatError(reply, capacity, MESH_CLI_REPLY_TEXT("settings could not be saved", "settings not saved")); return true; } if (remote_command_) { stageRemoteMutation(base ? RemoteMutation::Off : RemoteMutation::TempOff); @@ -1287,7 +1288,7 @@ bool RadioProfileCLI::handle(const char* command, char* reply, size_t capacity, if (scheduled && (verb == Get || verb == Delete)) { uint32_t index = 0; if (*args && strcmp(args, "all") && (!cli::parseUnsignedIntegerStrict(args, index) || index < 1 || index > 4)) { - cli::formatError(reply, capacity, "slot must be 1-4 or all"); return true; + cli::formatError(reply, capacity, MESH_CLI_REPLY_TEXT("slot must be 1-4 or all", "slot: 1-4|all")); return true; } const unsigned first = scheduled_temp ? 4 : 0; if (verb == Delete) { @@ -1326,7 +1327,7 @@ bool RadioProfileCLI::handle(const char* command, char* reply, size_t capacity, } return true; } - if (verb != Set) { cli::formatError(reply, capacity, "invalid radio2 command"); return true; } + if (verb != Set) { cli::formatError(reply, capacity, MESH_CLI_REPLY_TEXT("invalid radio2 command", "bad radio2 command")); return true; } if (base) { const char* preset = cli::radio2PresetTuple(args); if (preset) args = preset; @@ -1354,15 +1355,15 @@ bool RadioProfileCLI::handle(const char* command, char* reply, size_t capacity, if (valid) valid = radio_->validateProfile(config.params); RadioProfiles preview = *radio_->profiles(); preview.setSecondary(config, false); if (valid) valid = preview.automaticPreambleFits(); - if (!valid) { snprintf(reply, capacity, "Error: use %s f,bw,sf,cr,rx|rxtx%s[,preamble|auto]", key, + if (!valid) { snprintf(reply, capacity, MESH_CLI_REPLY_TEXT("Error: use %s f,bw,sf,cr,rx|rxtx%s[,preamble|auto]", "Error: %s f,bw,sf,cr,rx|rxtx%s[,preamble|auto]"), key, scheduled_temp ? ",start,end (UTC or +minutes)" : scheduled ? ",start (UTC or +minutes)" : temporary ? ",minutes" : ""); return true; } if (base && !mediaMeshScopeReady(config)) { - cli::formatError(reply, capacity, "MediaMesh region unavailable or full"); return true; + cli::formatError(reply, capacity, MESH_CLI_REPLY_TEXT("MediaMesh region unavailable or full", "MediaMesh region unavailable/full")); return true; } if (scheduled) { if (!epoch || start <= epoch || (uint64_t)(start-epoch) * 1000 > 0x7fffffffULL || (scheduled_temp && (end <= start || (uint64_t)(end-epoch) * 1000 > 0x7fffffffULL))) { - cli::formatError(reply, capacity, "future UTC start/end required, within 24 days"); return true; + cli::formatError(reply, capacity, MESH_CLI_REPLY_TEXT("future UTC start/end required, within 24 days", "future UTC start/end within 24 days")); return true; } if (scheduled_temp) { const uint64_t pending_left = temp_pending_ @@ -1397,7 +1398,7 @@ bool RadioProfileCLI::handle(const char* command, char* reply, size_t capacity, snprintf(reply, capacity, "OK - %s %u queued, preamble=%u", key, i+1, preview.preamble(1, 32)); appendSecondaryWarnings(reply, capacity, preview); return true; } - cli::formatError(reply, capacity, "all four schedule slots occupied"); return true; + cli::formatError(reply, capacity, MESH_CLI_REPLY_TEXT("all four schedule slots occupied", "all four slots full")); return true; } if (temporary) { for (const auto& s : schedules_) if (s.active && s.temporary) { @@ -1429,7 +1430,7 @@ bool RadioProfileCLI::handle(const char* command, char* reply, size_t capacity, } snprintf(reply, capacity, "OK - radio2 %s%s; preamble=%u", modeName(config.mode), remote_command_ ? " pending after reply" : "", preview.preamble(1, 32)); - } else cli::formatError(reply, capacity, "settings could not be saved"); + } else cli::formatError(reply, capacity, MESH_CLI_REPLY_TEXT("settings could not be saved", "settings not saved")); if (!strncmp(reply, "OK", 2)) appendSecondaryWarnings(reply, capacity, preview); return true; } diff --git a/src/helpers/StorageLayout.cpp b/src/helpers/StorageLayout.cpp index c6fff525f..16a9fb783 100644 --- a/src/helpers/StorageLayout.cpp +++ b/src/helpers/StorageLayout.cpp @@ -1,3 +1,4 @@ +#include "CLIReplyText.h" #include "StorageLayout.h" #if defined(ESP32_PLATFORM) @@ -185,7 +186,7 @@ bool handleStorageLayoutGet(const char* config, MainBoard& board, char* reply, if (match == StorageLayoutGetMatch::NotMatched) return false; if (reply == nullptr || reply_size == 0) return true; if (match == StorageLayoutGetMatch::InvalidArguments) { - snprintf(reply, reply_size, "Error: use get storage.layout"); + snprintf(reply, reply_size, MESH_CLI_REPLY_TEXT("Error: use get storage.layout", "Error: get storage.layout")); } else { formatStorageLayout(board, reply, reply_size); } diff --git a/src/helpers/WirelessControl.h b/src/helpers/WirelessControl.h index 7da2abca5..8a04a638f 100644 --- a/src/helpers/WirelessControl.h +++ b/src/helpers/WirelessControl.h @@ -1,5 +1,7 @@ #pragma once +#include "CLIReplyText.h" + #include #include #include @@ -141,7 +143,7 @@ public: const uint8_t available = backend_ ? backend_->available() : 0; const char* name = command.scope == All ? "2.4ghz" : command.scope == EspNow ? "espnow" : "wifi"; if (command.action == Action::Invalid) { - snprintf(reply, size, "Error: use set wifi/espnow on|off [force]; set 2.4ghz on [all|force]|off [force]"); + snprintf(reply, size, MESH_CLI_REPLY_TEXT("Error: use set wifi/espnow on|off [force]; set 2.4ghz on [all|force]|off [force]", "Error: set wifi/espnow on|off [force]; set 2.4ghz on [all|force]|off [force]")); return true; } const uint8_t current = enabled(); @@ -158,7 +160,7 @@ public: } #endif if (!(available & command.scope)) { - snprintf(reply, size, "Error: requested wireless service unavailable in this build/boot"); + snprintf(reply, size, MESH_CLI_REPLY_TEXT("Error: requested wireless service unavailable in this build/boot", "Error: wireless service unavailable this build/boot")); return true; } const bool on = command.action == Action::On || command.action == Action::OnAll; @@ -167,7 +169,7 @@ public: return true; } if (command.scope != All && on && master_off_) { - snprintf(reply, size, "Error: 2.4ghz is off; use set 2.4ghz on first"); + snprintf(reply, size, MESH_CLI_REPLY_TEXT("Error: 2.4ghz is off; use set 2.4ghz on first", "Error: 2.4ghz off; set 2.4ghz on first")); return true; } const uint8_t scope = command.scope & available; diff --git a/test/fixtures/cli_reply_text.json b/test/fixtures/cli_reply_text.json new file mode 100644 index 000000000..933d18401 --- /dev/null +++ b/test/fixtures/cli_reply_text.json @@ -0,0 +1,598 @@ +{ + "schema": 1, + "profiles": [ + "wio-e5-mini_repeater" + ], + "messages": [ + { + "file": "src/helpers/CommonCLI.cpp", + "verbose": "Error, unsupported on this role", + "concise": "Error, unsupported role" + }, + { + "file": "src/helpers/CommonCLI.cpp", + "verbose": "Error, retry configuration unsupported on this role", + "concise": "Error, retry unsupported for role" + }, + { + "file": "src/helpers/CommonCLI.cpp", + "verbose": "ERROR: dutycycle must be 1-100", + "concise": "ERROR: dutycycle 1-100" + }, + { + "file": "src/helpers/CommonCLI.cpp", + "verbose": "Error, invalid airtime factor", + "concise": "Error, bad airtime factor" + }, + { + "file": "src/helpers/CommonCLI.cpp", + "verbose": "ERROR: telemetry.access must be all or acl", + "concise": "ERROR: telemetry.access all|acl" + }, + { + "file": "src/helpers/CommonCLI.cpp", + "verbose": "Error: interval must be 0 (off) or %d-240 minutes", + "concise": "Error: interval 0 (off) or %d-240 minutes" + }, + { + "file": "src/helpers/CommonCLI.cpp", + "verbose": "Error, must be 0,1, or 2", + "concise": "Error, 0|1|2" + }, + { + "file": "src/helpers/CommonCLI.cpp", + "verbose": "Error, must be on or off", + "concise": "Error, on|off" + }, + { + "file": "src/helpers/CommonCLI.cpp", + "verbose": "Error, must be 0-20", + "concise": "Error, 0-20" + }, + { + "file": "src/helpers/CommonCLI.cpp", + "verbose": "Error, must be 0-2", + "concise": "Error, 0-2" + }, + { + "file": "src/helpers/CommonCLI.cpp", + "verbose": "Error, must be 0-40 dB", + "concise": "Error, 0-40 dB" + }, + { + "file": "src/helpers/CommonCLI.cpp", + "verbose": "Error, must be 1-15", + "concise": "Error, 1-15" + }, + { + "file": "src/helpers/CommonCLI.cpp", + "verbose": "Error, must be 10-5000 ms", + "concise": "Error, 10-5000 ms" + }, + { + "file": "src/helpers/CommonCLI.cpp", + "verbose": "Error, must be 0-5000 ms", + "concise": "Error, 0-5000 ms" + }, + { + "file": "src/helpers/CommonCLI.cpp", + "verbose": "Error, must be all or 1-7", + "concise": "Error, all|1-7" + }, + { + "file": "src/helpers/CommonCLI.cpp", + "verbose": "Error, must be 0-15", + "concise": "Error, 0-15" + }, + { + "file": "src/helpers/CommonCLI.cpp", + "verbose": "Error, must be 0-63 or off", + "concise": "Error, 0-63|off" + }, + { + "file": "src/helpers/CommonCLI.cpp", + "verbose": "Error, must be infra, rooftop, or mobile", + "concise": "Error, infra|rooftop|mobile" + }, + { + "file": "src/helpers/CommonCLI.cpp", + "verbose": "Error, use up to %u comma-separated 3-byte hex prefixes", + "concise": "Error, max %u comma-separated 3-byte hex prefixes" + }, + { + "file": "src/helpers/CommonCLI.cpp", + "verbose": "Error, use on or off on this role", + "concise": "Error, on|off" + }, + { + "file": "src/helpers/CommonCLI.cpp", + "verbose": "Error, SNR must fit -32.00..31.75 dB", + "concise": "Error, SNR -32.00..31.75 dB" + }, + { + "file": "src/helpers/CommonCLI.cpp", + "verbose": "Error, use CR4,CR5,CR7,CR8 SNRs or off", + "concise": "Error, CR4,CR5,CR7,CR8 SNRs|off" + }, + { + "file": "src/helpers/CommonCLI.cpp", + "verbose": "Error, must be: off, minimal, moderate, or strict", + "concise": "Error, off|minimal|moderate|strict" + }, + { + "file": "src/helpers/CommonCLI.cpp", + "verbose": "Error: TX power range is %d to %d dBm", + "concise": "Error: TX power %d..%d dBm" + }, + { + "file": "src/helpers/CommonCLI.cpp", + "verbose": "Error, invalid radio params", + "concise": "Error, bad radio params" + }, + { + "file": "src/helpers/CommonCLI.cpp", + "verbose": "Error, invalid latitude", + "concise": "Error, bad latitude" + }, + { + "file": "src/helpers/CommonCLI.cpp", + "verbose": "Error, invalid longitude", + "concise": "Error, bad longitude" + }, + { + "file": "src/helpers/CommonCLI.cpp", + "verbose": "Error, table rejected prefix", + "concise": "Error, prefix rejected" + }, + { + "file": "src/helpers/CommonCLI.cpp", + "verbose": "Err - unknown or ambiguous jump: %s", + "concise": "Err - unknown/ambiguous jump: %s" + }, + { + "file": "src/helpers/CommonCLI.cpp", + "verbose": "ERROR: Power management not supported", + "concise": "ERROR: Power management unsupported" + }, + { + "file": "src/helpers/CommonCLI.cpp", + "verbose": "OK - repeat is now OFF", + "concise": "OK - repeat OFF" + }, + { + "file": "src/helpers/CommonCLI.cpp", + "verbose": "OK - repeat is now ON", + "concise": "OK - repeat ON" + }, + { + "file": "src/helpers/CommonCLI.cpp", + "verbose": "OK - clock set: %02d:%02d - %d/%d/%d UTC", + "concise": "OK - clock: %02d:%02d - %d/%d/%d UTC" + }, + { + "file": "src/helpers/CommonCLI.cpp", + "verbose": "OK - interval rounded to %d", + "concise": "OK - rounded interval %d" + }, + { + "file": "src/helpers/CommonCLI.cpp", + "verbose": "radio settings could not be saved", + "concise": "radio settings not saved" + }, + { + "file": "src/helpers/CommonCLI.cpp", + "verbose": "unsupported on this platform", + "concise": "platform unsupported" + }, + { + "file": "src/helpers/CommonCLI.cpp", + "verbose": "OK - LoRa FEM RX gain on", + "concise": "OK - FEM RX gain on" + }, + { + "file": "src/helpers/CommonCLI.cpp", + "verbose": "OK - LoRa FEM RX gain off", + "concise": "OK - FEM RX gain off" + }, + { + "file": "src/helpers/CommonCLI.cpp", + "verbose": "OK - LoRa FEM TX gain on", + "concise": "OK - FEM TX gain on" + }, + { + "file": "src/helpers/CommonCLI.cpp", + "verbose": "OK - LoRa FEM TX gain off", + "concise": "OK - FEM TX gain off" + }, + { + "file": "src/helpers/CommonCLI.cpp", + "verbose": "ERROR: range is %lu-%lu us", + "concise": "ERROR: range %lu-%lu us" + }, + { + "file": "src/helpers/CommonCLI.cpp", + "verbose": "Error, argument too long", + "concise": "Error, arg too long" + }, + { + "file": "src/helpers/CommonCLI.cpp", + "verbose": "Error, SNR must be numeric", + "concise": "Error, numeric SNR needed" + }, + { + "file": "src/helpers/CommonCLI.cpp", + "verbose": "Error, invalid frequency", + "concise": "Error, bad frequency" + }, + { + "file": "src/helpers/CommonCLI.cpp", + "verbose": "Error, use: tempradio f,bw,sf,cr,minutes[,preamble]", + "concise": "Error, tempradio f,bw,sf,cr,minutes[,preamble]" + }, + { + "file": "src/helpers/CommonCLI.cpp", + "verbose": "Error, use: set radioat f,bw,sf,cr,start (UTC or +minutes)", + "concise": "Error, set radioat f,bw,sf,cr,start (UTC or +minutes)" + }, + { + "file": "src/helpers/CommonCLI.cpp", + "verbose": "Error, use: set tempradioat f,bw,sf,cr,start,end (UTC or +minutes)", + "concise": "Error, set tempradioat f,bw,sf,cr,start,end (UTC or +minutes)" + }, + { + "file": "src/helpers/CommonCLI.cpp", + "verbose": "Error, use: set recent.repeater [snr_db]", + "concise": "Error, set recent.repeater [snr_db]" + }, + { + "file": "src/helpers/CommonCLI.cpp", + "verbose": "Error, use: get recent.repeaters [page] | search <2|4|6 hex> [page]", + "concise": "Error, get recent.repeaters [page] | search <2|4|6 hex> [page]" + }, + { + "file": "examples/simple_repeater/MyMesh.cpp", + "verbose": "Err - blacklist slot must be 1-%u", + "concise": "Err - blacklist slot: 1-%u" + }, + { + "file": "examples/simple_repeater/MyMesh.cpp", + "verbose": "Err - expected six-digit repeater ID", + "concise": "Err - repeater ID: six hex digits" + }, + { + "file": "examples/simple_repeater/MyMesh.cpp", + "verbose": "Err - use 1-%d unique six-digit hex IDs separated by commas", + "concise": "Err - use 1-%d unique six-digit hex IDs, comma-separated" + }, + { + "file": "examples/simple_repeater/MyMesh.cpp", + "verbose": "Err - next blacklist slot is %u", + "concise": "Err - next blacklist slot: %u" + }, + { + "file": "examples/simple_repeater/MyMesh.cpp", + "verbose": "Err - blacklist range exceeds slot %u", + "concise": "Err - blacklist range past slot %u" + }, + { + "file": "examples/simple_repeater/MyMesh.cpp", + "verbose": "Err - unable to save flood filter blacklist", + "concise": "Err - flood filter blacklist not saved" + }, + { + "file": "examples/simple_repeater/MyMesh.cpp", + "verbose": "Err - filter slot must be 1-%d", + "concise": "Err - filter slot: 1-%d" + }, + { + "file": "examples/simple_repeater/MyMesh.cpp", + "verbose": "Err - packet type must be name, any, 0-15, or 0x00-0x0F", + "concise": "Err - type: name|any|0-15|0x00-0x0F" + }, + { + "file": "examples/simple_repeater/MyMesh.cpp", + "verbose": "Err - tx timing must be fast or slow", + "concise": "Err - tx: fast|slow" + }, + { + "file": "examples/simple_repeater/MyMesh.cpp", + "verbose": "Err - scope must be a public name of at most 30 characters", + "concise": "Err - scope: public name, max 30 chars" + }, + { + "file": "examples/simple_repeater/MyMesh.cpp", + "verbose": "Err - unable to save flood filter", + "concise": "Err - flood filter not saved" + }, + { + "file": "examples/simple_repeater/MyMesh.cpp", + "verbose": "Err - too many flood filter parameters", + "concise": "Err - excess flood filter parameters" + }, + { + "file": "examples/simple_repeater/MyMesh.cpp", + "verbose": "Err - require=region requires scope=", + "concise": "Err - require=region needs scope=" + }, + { + "file": "examples/simple_repeater/MyMesh.cpp", + "verbose": "Err - tx timing requires scope=", + "concise": "Err - tx needs scope=" + }, + { + "file": "examples/simple_repeater/MyMesh.cpp", + "verbose": "Err - scope slot must be 1-%d", + "concise": "Err - scope slot: 1-%d" + }, + { + "file": "examples/simple_repeater/MyMesh.cpp", + "verbose": "Err - path bucket must be 1-6", + "concise": "Err - path bucket: 1-6" + }, + { + "file": "examples/simple_repeater/MyMesh.cpp", + "verbose": "Err - require slot must be 1-%d", + "concise": "Err - require slot: 1-%d" + }, + { + "file": "examples/simple_repeater/MyMesh.cpp", + "verbose": "Err - channel must be public, #name, or 128/256-bit key", + "concise": "Err - channel: public|#name|128/256-bit key" + }, + { + "file": "examples/simple_repeater/MyMesh.cpp", + "verbose": "Err - permissions must be 0-255", + "concise": "Err - permissions: 0-255" + }, + { + "file": "examples/simple_repeater/MyMesh.cpp", + "verbose": "Err - region names cannot contain spaces", + "concise": "Err - region name: no spaces" + }, + { + "file": "examples/simple_repeater/MyMesh.cpp", + "verbose": "Err - unknown or invalid alert region", + "concise": "Err - unknown/invalid alert region" + }, + { + "file": "examples/simple_repeater/MyMesh.cpp", + "verbose": "Err - define a usable region before enabling battery alerts", + "concise": "Err - battery alerts need a usable region first" + }, + { + "file": "examples/simple_repeater/MyMesh.cpp", + "verbose": "Err - multiple narrowest regions; specify one", + "concise": "Err - specify one of the narrowest regions" + }, + { + "file": "examples/simple_repeater/MyMesh.cpp", + "verbose": "Err - low must be greater than critical", + "concise": "Err - low must exceed critical" + }, + { + "file": "examples/simple_repeater/MyMesh.cpp", + "verbose": "Err - critical must be less than low", + "concise": "Err - critical must be below low" + }, + { + "file": "examples/simple_repeater/MyMesh.cpp", + "verbose": "OK - all flood filters removed", + "concise": "OK - flood filters cleared" + }, + { + "file": "examples/simple_repeater/MyMesh.cpp", + "verbose": "Err - use get guest or set guest on|off", + "concise": "Err - get guest / set guest on|off" + }, + { + "file": "examples/simple_repeater/MyMesh.cpp", + "verbose": "Err - region load owned by admin/region manager", + "concise": "Err - region load: admin/region manager only" + }, + { + "file": "examples/simple_repeater/MyMesh.cpp", + "verbose": "Error: start is in the past", + "concise": "Error: start in past" + }, + { + "file": "examples/simple_repeater/MyMesh.cpp", + "verbose": "Error: end is in the past", + "concise": "Error: end in past" + }, + { + "file": "examples/simple_repeater/MyMesh.cpp", + "verbose": "Error: end must be after start", + "concise": "Error: end must follow start" + }, + { + "file": "examples/simple_repeater/MyMesh.cpp", + "verbose": "Err - another remote command is still running", + "concise": "Err - remote command busy" + }, + { + "file": "examples/simple_repeater/MyMesh.cpp", + "verbose": "Err - remote command is too long", + "concise": "Err - remote command too long" + }, + { + "file": "examples/simple_repeater/MyMesh.cpp", + "verbose": "Err - filter parameters too long", + "concise": "Err - filter params too long" + }, + { + "file": "examples/simple_repeater/MyMesh.cpp", + "verbose": "Err - use: set flood.filter[.n] [hops] [path=blacklist] [scope=] [require=region] [tx=slow] [suspend=tempradio]", + "concise": "Err - set flood.filter[.n] [hops] [path=blacklist] [scope=] [require=region] [tx=slow] [suspend=tempradio]" + }, + { + "file": "examples/simple_repeater/MyMesh.cpp", + "verbose": "Error, use: get tempradioat [n]", + "concise": "Error, get tempradioat [n]" + }, + { + "file": "examples/simple_repeater/MyMesh.cpp", + "verbose": "Error, use: get radioat [n]", + "concise": "Error, get radioat [n]" + }, + { + "file": "examples/simple_repeater/MyMesh.cpp", + "verbose": "Error, use: del tempradioat [n]", + "concise": "Error, del tempradioat [n]" + }, + { + "file": "examples/simple_repeater/MyMesh.cpp", + "verbose": "Error, use: del radioat [n]", + "concise": "Error, del radioat [n]" + }, + { + "file": "examples/simple_repeater/MyMesh.cpp", + "verbose": "Err - use: del flood.filter.blacklist[.<1-%u>| all]", + "concise": "Err - del flood.filter.blacklist[.<1-%u>| all]" + }, + { + "file": "examples/simple_repeater/MyMesh.cpp", + "verbose": "Err - use: del flood.filter.<1-%d>|all", + "concise": "Err - del flood.filter.<1-%d>|all" + }, + { + "file": "examples/simple_repeater/MyMesh.cpp", + "verbose": "Err - usage: send text.flood ", + "concise": "Err - send text.flood " + }, + { + "file": "examples/simple_repeater/MyMesh.cpp", + "verbose": "Err - usage: set rx.watchdog ", + "concise": "Err - set rx.watchdog " + }, + { + "file": "examples/simple_repeater/MyMesh.cpp", + "verbose": "Err - usage: set battery.alert ", + "concise": "Err - set battery.alert " + }, + { + "file": "examples/simple_repeater/MyMesh.cpp", + "verbose": "Err - usage: set battery.alert.low <1-100>", + "concise": "Err - set battery.alert.low <1-100>" + }, + { + "file": "examples/simple_repeater/MyMesh.cpp", + "verbose": "Err - usage: set battery.alert.critical <0-99>", + "concise": "Err - set battery.alert.critical <0-99>" + }, + { + "file": "src/helpers/RadioProfileCLI.cpp", + "verbose": "slot must be 1-4 or all", + "concise": "slot: 1-4|all" + }, + { + "file": "src/helpers/RadioProfileCLI.cpp", + "verbose": "future UTC start/end required, within 24 days", + "concise": "future UTC start/end within 24 days" + }, + { + "file": "src/helpers/RadioProfileCLI.cpp", + "verbose": "all four schedule slots occupied", + "concise": "all four slots full" + }, + { + "file": "src/helpers/RadioProfileCLI.cpp", + "verbose": "settings could not be saved", + "concise": "settings not saved" + }, + { + "file": "src/helpers/RadioProfileCLI.cpp", + "verbose": "invalid radio2 command", + "concise": "bad radio2 command" + }, + { + "file": "src/helpers/RadioProfileCLI.cpp", + "verbose": "MediaMesh region unavailable or full", + "concise": "MediaMesh region unavailable/full" + }, + { + "file": "src/helpers/RadioProfileCLI.cpp", + "verbose": "Error: use %s f,bw,sf,cr,rx|rxtx%s[,preamble|auto]", + "concise": "Error: %s f,bw,sf,cr,rx|rxtx%s[,preamble|auto]" + }, + { + "file": "src/helpers/WirelessControl.h", + "verbose": "Error: requested wireless service unavailable in this build/boot", + "concise": "Error: wireless service unavailable this build/boot" + }, + { + "file": "src/helpers/WirelessControl.h", + "verbose": "Error: 2.4ghz is off; use set 2.4ghz on first", + "concise": "Error: 2.4ghz off; set 2.4ghz on first" + }, + { + "file": "src/helpers/WirelessControl.h", + "verbose": "Error: use set wifi/espnow on|off [force]; set 2.4ghz on [all|force]|off [force]", + "concise": "Error: set wifi/espnow on|off [force]; set 2.4ghz on [all|force]|off [force]" + }, + { + "file": "src/helpers/PowerSavingDiagnostics.h", + "verbose": "OK - power diagnostics %s (RAM only; sleep policy unchanged)", + "concise": "OK - power diag %s (RAM only; sleep policy unchanged)" + }, + { + "file": "src/helpers/PowerSavingDiagnostics.h", + "verbose": "OK - power diagnostics cleared; sleep policy unchanged", + "concise": "OK - power diag cleared; sleep policy unchanged" + }, + { + "file": "src/helpers/PowerSavingDiagnostics.h", + "verbose": "Error: use set powersaving.diag on|off", + "concise": "Error: set powersaving.diag on|off" + }, + { + "file": "src/helpers/PowerSavingDiagnostics.h", + "verbose": "Error: use clear powersaving.diag", + "concise": "Error: clear powersaving.diag" + }, + { + "file": "src/helpers/FloodAdvertCLI.h", + "verbose": "ERR: advert limiter unavailable on this role", + "concise": "ERR: advert limiter unavailable for role" + }, + { + "file": "src/helpers/FloodAdvertCLI.h", + "verbose": "ERR: advert list index out of range", + "concise": "ERR: advert index out of range" + }, + { + "file": "src/helpers/FloodAdvertCLI.h", + "verbose": "OK - all advert limit history cleared", + "concise": "OK - all advert history cleared" + }, + { + "file": "src/helpers/FloodAdvertCLI.h", + "verbose": "OK - key advert limit history cleared", + "concise": "OK - key advert history cleared" + }, + { + "file": "src/helpers/LedSettings.cpp", + "verbose": "Error: usage get leds", + "concise": "Error: get leds" + }, + { + "file": "src/helpers/LedSettings.cpp", + "verbose": "Error: usage set leds on|off", + "concise": "Error: set leds on|off" + }, + { + "file": "src/helpers/StorageLayout.cpp", + "verbose": "Error: use get storage.layout", + "concise": "Error: get storage.layout" + } + ], + "noncompact_tokens_sha256": { + "src/helpers/CommonCLI.cpp": "0a3d2e2eabd3e161e352176c4a6a4c4baa184944c08c93b522d4f1ddb5a24edd", + "examples/simple_repeater/MyMesh.cpp": "8567eeef2f327100e397c40b0aaf5b675fcdfea254987437d1ca8d005ad3a6f0", + "src/helpers/RadioProfileCLI.cpp": "d641d16e872eba3e73b9b5a50f9718c10b0b7da72782f793706ac36b3724a624", + "src/helpers/WirelessControl.h": "bbde183f091a8afa7fd65c7b379783f8be2ac4e74ae6037a99a71fde3fb89f1e", + "src/helpers/PowerSavingDiagnostics.h": "18b78f5780046ea8c772c4ce6c337741ae0c027039d9399c94c5f4205d114000", + "src/helpers/FloodAdvertCLI.h": "0f378e4d0f6c3b9b79991068a4b4208139484d2144ee1d7c5094d39043463be4", + "src/helpers/LedSettings.cpp": "df0681234ca1337148ad59caa935857bc692f98bf18603ca6d099c0382028400", + "src/helpers/StorageLayout.cpp": "5dd1c8bcbe2902e1f22d14f9ab425465bdc38926fae23989987d850b484cbb18" + } +} diff --git a/test/fixtures/common_cli_argument_bounds/main.cpp b/test/fixtures/common_cli_argument_bounds/main.cpp index 44a0a4c11..1926a5dec 100644 --- a/test/fixtures/common_cli_argument_bounds/main.cpp +++ b/test/fixtures/common_cli_argument_bounds/main.cpp @@ -106,7 +106,7 @@ static void exercise(const std::string& kind, size_t size, bool advanced, bool a const auto input = command(kind, args); cli.run(input.c_str(), reply.data()); if (size >= COMMON_CLI_TMP_LEN) { - assert(strcmp(reply.data(), "Error, argument too long") == 0); + assert(strcmp(reply.data(), MESH_CLI_REPLY_TEXT("Error, argument too long", "Error, arg too long")) == 0); untouched(cli); for (char value : cli.tmp) assert(value == char(0x55)); } else if (kind == "sensor" || kind == "gps") { @@ -117,7 +117,7 @@ static void exercise(const std::string& kind, size_t size, bool advanced, bool a } else if (kind == "retry") { assert(cli.callbacks.support_calls == 1); assert(cli.saves == (advanced ? 1U : 0U)); - assert(strcmp(reply.data(), advanced ? "OK" : "Error, use on or off on this role") == 0); + assert(strcmp(reply.data(), advanced ? "OK" : MESH_CLI_REPLY_TEXT("Error, use on or off on this role", "Error, on|off")) == 0); if (advanced) { assert(cli.prefs.direct_retry_cr_enabled == 1); assert(cli.prefs.direct_retry_cr4_snr_x4 == 0 && cli.prefs.direct_retry_cr5_snr_x4 == 0); diff --git a/test/test_cli_reply_text.py b/test/test_cli_reply_text.py new file mode 100644 index 000000000..d099ed98d --- /dev/null +++ b/test/test_cli_reply_text.py @@ -0,0 +1,110 @@ +"""Keep constrained CLI prose separate from commands and protocol replies.""" +from pathlib import Path +import configparser +import hashlib +import json +import os +import re +import shutil +import subprocess +import tempfile +import unittest + +ROOT = Path(__file__).resolve().parents[1] +FIXTURE = ROOT / 'test/fixtures/cli_reply_text.json' +SELECTION = re.compile( + r'MESH_CLI_REPLY_TEXT\(("(?:\\.|[^"\\])*"),\s*("(?:\\.|[^"\\])*")\)') +TOKENS = re.compile(r'"(?:\\.|[^"\\])*"|\'(?:\\.|[^\'\\])*\'|' + r'//[^\n]*|/\*.*?\*/|[A-Za-z_][A-Za-z_0-9]*|[^\s]', re.S) + + +def original_tokens(source): + source = re.sub(r'^#include [<"](?:helpers/)?CLIReplyText\.h[>"]\s*\n', + '', source, flags=re.M) + source = SELECTION.sub(lambda match: match.group(1), source) + return '\n'.join(token.group() for token in TOKENS.finditer(source) + if not token.group().startswith(('//', '/*'))).encode() + + +class CliReplyTextTests(unittest.TestCase): + def test_only_reviewed_human_literals_change_and_control_flow_stays_exact(self): + fixture = json.loads(FIXTURE.read_text()) + self.assertEqual(fixture['schema'], 1) + expected = {} + for row in fixture['messages']: + expected.setdefault(row['file'], set()).add((row['verbose'], row['concise'])) + old, new = row['verbose'], row['concise'] + self.assertLess(len(new), len(old)) + formats = r'%(?:[-+ #0]*[0-9.*]*[a-zA-Z]+|%)' + self.assertEqual(re.findall(formats, old), re.findall(formats, new)) + category = r'^(Error|ERROR|ERR|Err|OK)(?=[: ,\-])' + self.assertEqual(re.findall(category, old), re.findall(category, new)) + self.assertEqual(re.findall(r'\d+(?:\.\d+)?', old), + re.findall(r'\d+(?:\.\d+)?', new)) + self.assertFalse(old.startswith(('> ', '{', 'KEYBACKUP ', 'ACK'))) + self.assertNotRegex(old.lower(), + r'host|replay|password|login|save unconfirmed|reboot|' + r'retained|default|warning|applying in|restore scheduled') + if 'unchanged' in old: + self.assertIn('sleep policy unchanged', old) + self.assertIn('sleep policy unchanged', new) + for file, messages in expected.items(): + source = (ROOT / file).read_text() + actual = {(json.loads(match.group(1)), json.loads(match.group(2))) + for match in SELECTION.finditer(source)} + self.assertEqual(actual, messages, file) + # Substituting each original literal recovers every original C++ + # token, including command tests, branches, calls and arguments. + self.assertEqual(hashlib.sha256(original_tokens(source)).hexdigest(), + fixture['noncompact_tokens_sha256'][file], file) + + def test_profile_activation_is_explicit_and_default_is_off(self): + fixture = json.loads(FIXTURE.read_text()) + enabled = [] + for path in [ROOT / 'platformio.ini', *sorted((ROOT / 'variants').rglob('*.ini'))]: + config = configparser.ConfigParser(interpolation=None, strict=False) + config.read(path) + for section in config.sections(): + if re.search(r'-D\s*MESH_CLI_COMPACT_REPLIES=1\b', + config[section].get('build_flags', '')): + self.assertTrue(section.startswith('env:'), str(path)) + enabled.append(section[4:]) + self.assertEqual(sorted(enabled), sorted(fixture['profiles'])) + header = (ROOT / 'src/helpers/CLIReplyText.h').read_text() + self.assertIn('#ifndef MESH_CLI_COMPACT_REPLIES\n' + '#define MESH_CLI_COMPACT_REPLIES 0', header) + + def test_actual_header_selects_exact_text_without_a_runtime_lookup(self): + compiler = os.environ.get('CXX') or shutil.which('g++') or shutil.which('clang++') + if not compiler: + self.skipTest('host C++ compiler required') + messages = json.loads(FIXTURE.read_text())['messages'] + selections = ',\n'.join('MESH_CLI_REPLY_TEXT(%s, %s)' % + (json.dumps(row['verbose']), json.dumps(row['concise'])) + for row in messages) + source = ('#include \n#include \n' + 'int main(){const char* text[]={'+selections+'};' + 'for(const char* line:text)puts(line);}\n') + with tempfile.TemporaryDirectory(prefix='cli-reply-text-') as folder: + work = Path(folder) + (work / 'test.cpp').write_text(source) + for flag, key in ((None, 'verbose'), (0, 'verbose'), (1, 'concise')): + flags = [] if flag is None else ['-DMESH_CLI_COMPACT_REPLIES='+str(flag)] + built = subprocess.run([compiler, '-std=c++11', '-Os', '-Wall', '-Wextra', + '-Werror', *flags, '-I'+str(ROOT / 'src/helpers'), + str(work / 'test.cpp'), '-o', str(work / 'test')], capture_output=True) + self.assertEqual(built.returncode, 0, built.stderr.decode(errors='replace')) + result = subprocess.run([str(work / 'test')], capture_output=True) + self.assertEqual(result.returncode, 0, result.stderr.decode(errors='replace')) + self.assertEqual(result.stdout.decode().splitlines(), + [row[key] for row in messages]) + if flag == 1: + image = (work / 'test').read_bytes() + selected = {row['concise'] for row in messages} + for row in messages: + if row['verbose'] not in selected: + self.assertNotIn(row['verbose'].encode()+b'\0', image) + + +if __name__ == '__main__': + unittest.main() diff --git a/test/test_common_cli_argument_bounds.py b/test/test_common_cli_argument_bounds.py index d0d762a1f..8f56e9af9 100644 --- a/test/test_common_cli_argument_bounds.py +++ b/test/test_common_cli_argument_bounds.py @@ -54,12 +54,15 @@ class CommonCLIArgumentBoundsTests(unittest.TestCase): cls.work = tempfile.TemporaryDirectory(prefix="meshcore-cli-argument-bounds-") cls.binaries = {} for name, defines in (("default", []), ("larger", ["-DCOMMON_CLI_TMP_LEN=192"]), + ("compact", ["-DMESH_CLI_COMPACT_REPLIES=1"]), ("legacy", [])): generated = source if name == "legacy": # Restore all three audited unchecked copies as a negative control. + error = (r'(?:"Error, argument too long"|MESH_CLI_REPLY_TEXT\(' + r'"Error, argument too long", "Error, arg too long"\))') pattern = (r'if \(!copyCLIArgument\(tmp, sizeof\(tmp\), (&(?:command|config)\[\d+\])\)\) ' - r'\{\s*strcpy\(reply, "Error, argument too long"\);\s*return;\s*\}') + r'\{\s*strcpy\(reply, ' + error + r'\);\s*return;\s*\}') generated, count = re.subn(pattern, r"strcpy(tmp, \1);", generated) assert count == 3, "review the negative controls after branch changes" directory = Path(cls.work.name) @@ -86,7 +89,7 @@ class CommonCLIArgumentBoundsTests(unittest.TestCase): env=dict(os.environ, ASAN_OPTIONS="detect_leaks=0:abort_on_error=0")) def test_production_boundaries_and_rejection_without_effects(self): - for variant in ("default", "larger"): + for variant in ("default", "larger", "compact"): with self.subTest(variant=variant): result = self.run_case(variant, "production") self.assertEqual(result.returncode, 0, result.stdout + result.stderr) diff --git a/variants/wio-e5-mini/platformio.ini b/variants/wio-e5-mini/platformio.ini index 60c5a6d51..49d6a84b2 100644 --- a/variants/wio-e5-mini/platformio.ini +++ b/variants/wio-e5-mini/platformio.ini @@ -60,6 +60,7 @@ build_flags = ${lora_e5_mini.build_flags} -D MESH_ENABLE_CLOCK_SYNC=0 ; preserve the 16 KiB filesystem on this 256 KiB part -D MESH_ENABLE_FLOOD_GROUP_MODERATION=0 ; keep core routing and radio scheduling in flash -D MESH_PACKET_LOGGING_COMPACT=1 ; retain complete RX/TX bytes without the duplicate parsed RX line + -D MESH_CLI_COMPACT_REPLIES=1 ; concise human prose; command/protocol replies stay intact ; User-approved Mini Repeater cuts only; other boards keep their defaults. -D MESH_ENABLE_CARRIER_TOOLS=0 -D MESH_ENABLE_MANAGEMENT_REPORTING=0 From 8a51a54fd7a14ff3c108a9d38c1685f861da9827 Mon Sep 17 00:00:00 2001 From: mikecarper Date: Wed, 28 Oct 2026 04:22:07 -0700 Subject: [PATCH 23/61] Use concise validation replies in constrained Companion builds --- .github/workflows/run-unit-tests.yml | 1 + examples/companion_radio/MyMesh.cpp | 121 ++++++------ src/helpers/CLIReplyText.h | 4 + test/fixtures/cli_reply_text.json | 275 ++++++++++++++++++++++++++- test/test_cli_reply_text.py | 25 ++- variants/rak3x72/platformio.ini | 1 + variants/rak4631/platformio.ini | 1 + variants/tiny_relay/platformio.ini | 1 + variants/wio-e5-dev/platformio.ini | 1 + variants/wio-e5-mini/platformio.ini | 3 +- 10 files changed, 362 insertions(+), 71 deletions(-) diff --git a/.github/workflows/run-unit-tests.yml b/.github/workflows/run-unit-tests.yml index adc53f27a..00e52660d 100644 --- a/.github/workflows/run-unit-tests.yml +++ b/.github/workflows/run-unit-tests.yml @@ -970,6 +970,7 @@ jobs: python3 -B test/test_companion_board_power_commands.py -v python3 -B test/test_companion_radio_diagnostics.py -v python3 -B test/test_companion_help_writer.py -v + python3 -B test/test_cli_reply_text.py -v python3 -B test/test_companion_help_build_policy.py -v python3 -B test/test_sdcard_cli_parity.py -v python3 -B test/test_esp32_buffered_radio_targets.py -v diff --git a/examples/companion_radio/MyMesh.cpp b/examples/companion_radio/MyMesh.cpp index ed55f7aa4..4d752e184 100644 --- a/examples/companion_radio/MyMesh.cpp +++ b/examples/companion_radio/MyMesh.cpp @@ -1,3 +1,4 @@ +#include #include #if defined(USE_LR2021) #include @@ -3663,7 +3664,7 @@ MyMesh::RadioSettingResult MyMesh::applyAndSaveRadioParams( bool MyMesh::applyRxPowerSavingRfRxDisabled(const char* value, char* reply, size_t capacity) { if (strcmp(value, "on") && strcmp(value, "off")) { - mesh::cli::formatError(reply, capacity, "use set radio.rxps.rfrx_disabled on|off"); + mesh::cli::formatError(reply, capacity, MESH_CLI_REPLY_TEXT("use set radio.rxps.rfrx_disabled on|off", "set radio.rxps.rfrx_disabled on|off")); } else if (!radio_driver.supportsRxPowerSavingRfRxDisable()) { mesh::cli::formatError(reply, capacity, "unsupported"); } else { @@ -3861,7 +3862,7 @@ bool MyMesh::handleCadCommand(const char* command, char* reply, if (!savePrefs()) { _prefs.cad_enabled = previous; if (_radio_available) _radio->setCADEnabled(previous != 0); - mesh::cli::formatError(reply, reply_size, "CAD changed but save failed"); + mesh::cli::formatError(reply, reply_size, MESH_CLI_REPLY_TEXT("CAD changed but save failed", "CAD changed; save failed")); } else { snprintf(reply, reply_size, "OK - radio.cad %s", value); } @@ -3887,7 +3888,7 @@ bool MyMesh::handleCadCommand(const char* command, char* reply, mesh::CAD_SCAN_MAX_TIMEOUT_MS, scan_ms) || !parseCadTimingMillis(tokens[1], 1, 60000, retry_ms) || !parseCadTimingMillis(tokens[2], 1, 60000, max_ms)) { - mesh::cli::formatError(reply, reply_size, "use set radio.cad timings "); + mesh::cli::formatError(reply, reply_size, MESH_CLI_REPLY_TEXT("use set radio.cad timings ", "set radio.cad timings ")); return true; } @@ -3895,7 +3896,7 @@ bool MyMesh::handleCadCommand(const char* command, char* reply, const uint16_t previous_retry = _prefs.cad_retry_delay_ms; const uint16_t previous_max = _prefs.cad_max_duration_ms; if (_radio_available && !radio_driver.setCADScanTimeoutMillis(scan_ms)) { - mesh::cli::formatError(reply, reply_size, "CAD scan timing is unsupported"); + mesh::cli::formatError(reply, reply_size, MESH_CLI_REPLY_TEXT("CAD scan timing is unsupported", "CAD scan timing unsupported")); return true; } _prefs.cad_scan_timeout_ms = scan_ms; @@ -3906,14 +3907,14 @@ bool MyMesh::handleCadCommand(const char* command, char* reply, _prefs.cad_retry_delay_ms = previous_retry; _prefs.cad_max_duration_ms = previous_max; if (_radio_available) radio_driver.setCADScanTimeoutMillis(previous_scan); - mesh::cli::formatError(reply, reply_size, "CAD timings changed but save failed"); + mesh::cli::formatError(reply, reply_size, MESH_CLI_REPLY_TEXT("CAD timings changed but save failed", "CAD timings changed; save failed")); } else { snprintf(reply, reply_size, "OK - radio.cad timings saved"); } return true; } - mesh::cli::formatError(reply, reply_size, "use set radio.cad or set radio.cad timings "); + mesh::cli::formatError(reply, reply_size, MESH_CLI_REPLY_TEXT("use set radio.cad or set radio.cad timings ", "set radio.cad ; set radio.cad timings ")); return true; } @@ -3923,7 +3924,7 @@ bool MyMesh::handleTrafficRoutingCommand(const char* command, char* reply, size_ if (!parsed.matched) return false; const char* kind = parsed.kind == mesh::companion::TrafficKind::Media ? "media" : "ssh"; if (!parsed.valid) { - snprintf(reply, reply_size, "Error: use get tx.%s or set tx.%s auto|radio|radio2|both|off", kind, kind); + snprintf(reply, reply_size, MESH_CLI_REPLY_TEXT("Error: use get tx.%s or set tx.%s auto|radio|radio2|both|off", "Error: get tx.%s; set tx.%s auto|radio|radio2|both|off"), kind, kind); return true; } uint8_t& policy = parsed.kind == mesh::companion::TrafficKind::Media ? _prefs.tx_media : _prefs.tx_ssh; @@ -3933,7 +3934,7 @@ bool MyMesh::handleTrafficRoutingCommand(const char* command, char* reply, size_ // Even an unchanged value must have durable storage before we say saved. if (!savePrefs()) { policy = previous; - snprintf(reply, reply_size, "Error: tx.%s was not saved", kind); + snprintf(reply, reply_size, MESH_CLI_REPLY_TEXT("Error: tx.%s was not saved", "Error: tx.%s not saved"), kind); return true; } } @@ -3976,13 +3977,13 @@ bool MyMesh::handleTxRoutingCommand(const char* command, char* reply, size_t rep if (!parsed.matched) return false; const char* kind = parsed.channel ? "channel" : "user"; if (!parsed.valid) { - snprintf(reply, reply_size, "Error: use get/set tx.%s [auto|radio|radio2|both|off]", + snprintf(reply, reply_size, MESH_CLI_REPLY_TEXT("Error: use get/set tx.%s [auto|radio|radio2|both|off]", "Error: get/set tx.%s [auto|radio|radio2|both|off]"), kind, parsed.channel ? "index" : "key:hex"); return true; } if (parsed.set && (!canMutateContacts() || (parsed.channel && _store->hasIncompleteChannelLoad()))) { - mesh::cli::formatError(reply, reply_size, "storage unavailable; TX routing not saved"); + mesh::cli::formatError(reply, reply_size, MESH_CLI_REPLY_TEXT("storage unavailable; TX routing not saved", "storage unavailable; TX route not saved")); return true; } @@ -4005,7 +4006,7 @@ bool MyMesh::handleTxRoutingCommand(const char* command, char* reply, size_t rep uint8_t key[32]; size_t key_bytes = 0; const bool by_key = !parsed.quoted && !strncmp(parsed.target, "key:", 4); if (by_key && !mesh::companion::parseTxRouteKey(parsed.target + 4, key, key_bytes)) { - mesh::cli::formatError(reply, reply_size, "key: needs 12-64 hexadecimal digits (whole bytes)"); + mesh::cli::formatError(reply, reply_size, MESH_CLI_REPLY_TEXT("key: needs 12-64 hexadecimal digits (whole bytes)", "key: 12-64 hex digits (whole bytes)")); return true; } for (int i = MAX_ANON_CONTACTS; i < getTotalContactSlots(); ++i) { @@ -4038,7 +4039,7 @@ bool MyMesh::handleTxRoutingCommand(const char* command, char* reply, size_t rep const auto alias = parseCompanionChannelAlias(channel.name); if (isAliasRadio2Defined() && (alias.kind == CompanionChannelKind::Radio2 || alias.kind == CompanionChannelKind::Meshtext)) { - mesh::cli::formatError(reply, reply_size, "prefix controls routing; rename channel"); + mesh::cli::formatError(reply, reply_size, MESH_CLI_REPLY_TEXT("prefix controls routing; rename channel", "prefix sets routing; rename channel")); return true; } // Persist the app PSK, not the resolved wire-key copy used for reporting. @@ -4056,7 +4057,7 @@ bool MyMesh::handleTxRoutingCommand(const char* command, char* reply, size_t rep } } if (!saved) { - mesh::cli::formatError(reply, reply_size, "storage unavailable; TX routing not saved"); + mesh::cli::formatError(reply, reply_size, MESH_CLI_REPLY_TEXT("storage unavailable; TX routing not saved", "storage unavailable; TX route not saved")); return true; } policy = parsed.policy; @@ -4233,11 +4234,11 @@ bool MyMesh::handleLocalControlCommand(const char* command, char* reply, if (gps_sync_value) { uint16_t hours = 0; if (!mesh::gps::parseSyncIntervalHours(gps_sync_value, hours)) { - mesh::cli::formatError(reply, reply_size, "GPS sync interval must be 1..336 hours"); + mesh::cli::formatError(reply, reply_size, MESH_CLI_REPLY_TEXT("GPS sync interval must be 1..336 hours", "GPS sync interval 1..336 hours")); } else if (sensors.getLocationProvider() == nullptr) { mesh::cli::formatError(reply, reply_size, "GPS unavailable"); } else if (!savePreference(_prefs.gps_sync_interval_hours, hours)) { - mesh::cli::formatError(reply, reply_size, "GPS sync interval could not be saved"); + mesh::cli::formatError(reply, reply_size, MESH_CLI_REPLY_TEXT("GPS sync interval could not be saved", "GPS sync interval not saved")); } else { sensors.applyGpsTimeSyncInterval(hours); snprintf(reply, reply_size, "OK - GPS sync interval %u hours (saved)", (unsigned)hours); @@ -4254,7 +4255,7 @@ bool MyMesh::handleLocalControlCommand(const char* command, char* reply, if (strncmp(command, "set gps ", 8) == 0) gps_value = command + 8; if (gps_value) { if (strcmp(gps_value, "on") != 0 && strcmp(gps_value, "off") != 0) { - mesh::cli::formatError(reply, reply_size, "use set gps on|off"); + mesh::cli::formatError(reply, reply_size, MESH_CLI_REPLY_TEXT("use set gps on|off", "set gps on|off")); return true; } const bool enabled = strcmp(gps_value, "on") == 0; @@ -4264,7 +4265,7 @@ bool MyMesh::handleLocalControlCommand(const char* command, char* reply, if (setGpsEnabled(enabled)) { snprintf(reply, reply_size, "OK - GPS %s (saved)", enabled ? "on" : "off"); } else { - mesh::cli::formatError(reply, reply_size, "GPS could not be saved"); + mesh::cli::formatError(reply, reply_size, MESH_CLI_REPLY_TEXT("GPS could not be saved", "GPS not saved")); } } return true; @@ -4362,11 +4363,11 @@ bool MyMesh::handleLocalControlCommand(const char* command, char* reply, const char* value = command + 13; while (*value == ' ' || *value == '\t') value++; if (strcmp(value, "on") != 0 && strcmp(value, "off") != 0) { - mesh::cli::formatError(reply, reply_size, "use set usb.debug "); + mesh::cli::formatError(reply, reply_size, MESH_CLI_REPLY_TEXT("use set usb.debug ", "set usb.debug ")); } else { const uint8_t enabled = strcmp(value, "on") == 0 ? 1 : 0; if (!savePreference(_prefs.usb_debug_enabled, enabled)) { - mesh::cli::formatError(reply, reply_size, "USB debug setting could not be saved"); + mesh::cli::formatError(reply, reply_size, MESH_CLI_REPLY_TEXT("USB debug setting could not be saved", "USB debug setting not saved")); } else { snprintf(reply, reply_size, "OK - USB debug %s (saved)%s", enabled ? "on" : "off", @@ -4406,12 +4407,12 @@ bool MyMesh::handleLocalControlCommand(const char* command, char* reply, } if (!packet_stream && !mesh::cli::parseLoggingToggle(value, enabled, reboot_if_needed)) { - mesh::cli::formatError(reply, reply_size, "use set usb.logging [reboot]"); + mesh::cli::formatError(reply, reply_size, MESH_CLI_REPLY_TEXT("use set usb.logging [reboot]", "set usb.logging [reboot]")); } else { _prefs.usb_logging_enabled = enabled ? (packet_stream ? 2 : 1) : 0; applyUsbLoggingState(enabled); if (!savePrefs()) { - mesh::cli::formatError(reply, reply_size, "USB logging changed for this boot but save failed"); + mesh::cli::formatError(reply, reply_size, MESH_CLI_REPLY_TEXT("USB logging changed for this boot but save failed", "USB logging changed for this boot; save failed")); } else if (!mesh::saveUsbLoggingBootPreference(enabled)) { mesh::cli::formatError(reply, reply_size, "setting saved, but next-boot USB interface state could not be saved"); } else { @@ -4554,7 +4555,7 @@ bool MyMesh::handleLocalControlCommand(const char* command, char* reply, if (strncmp(command, "set radio.rxgain ", 17) == 0) { const char* value = command + 17; if (strcmp(value, "on") != 0 && strcmp(value, "off") != 0) { - mesh::cli::formatError(reply, reply_size, "use set radio.rxgain on|off"); + mesh::cli::formatError(reply, reply_size, MESH_CLI_REPLY_TEXT("use set radio.rxgain on|off", "set radio.rxgain on|off")); } else if (!radio_driver.supportsRxBoostedGainMode()) { mesh::cli::formatError(reply, reply_size, "unsupported"); } else if (!applyAndSaveRxBoostedGain(strcmp(value, "on") == 0)) { @@ -4578,7 +4579,7 @@ bool MyMesh::handleLocalControlCommand(const char* command, char* reply, if (strncmp(command, "set radio.fem.rxgain ", 21) == 0) { const char* value = command + 21; if (strcmp(value, "on") != 0 && strcmp(value, "off") != 0) { - mesh::cli::formatError(reply, reply_size, "use set radio.fem.rxgain on|off"); + mesh::cli::formatError(reply, reply_size, MESH_CLI_REPLY_TEXT("use set radio.fem.rxgain on|off", "set radio.fem.rxgain on|off")); } else if (_radio && (_radio->isMorseTxActive() || _radio->isMorseRxActive())) { mesh::cli::formatError(reply, reply_size, "stop Morse before changing LoRa FEM gain"); } else if (!board.canControlLoRaFemLna()) { @@ -4608,7 +4609,7 @@ bool MyMesh::handleLocalControlCommand(const char* command, char* reply, if (strncmp(command, "set radio.fem.txgain ", 21) == 0) { const char* value = command + 21; if (strcmp(value, "on") != 0 && strcmp(value, "off") != 0) { - mesh::cli::formatError(reply, reply_size, "use set radio.fem.txgain on|off"); + mesh::cli::formatError(reply, reply_size, MESH_CLI_REPLY_TEXT("use set radio.fem.txgain on|off", "set radio.fem.txgain on|off")); } else if (_radio && (_radio->isMorseTxActive() || _radio->isMorseRxActive())) { mesh::cli::formatError(reply, reply_size, "stop Morse before changing LoRa FEM gain"); } else if (!board.canControlLoRaFemPaGain()) { @@ -4641,7 +4642,7 @@ bool MyMesh::handleLocalControlCommand(const char* command, char* reply, if (strcmp(command, "get display.touch") == 0) { if (_ui == NULL || !_ui->supportsTouchDebug()) { - mesh::cli::formatError(reply, reply_size, "touchscreen diagnostics unsupported"); + mesh::cli::formatError(reply, reply_size, MESH_CLI_REPLY_TEXT("touchscreen diagnostics unsupported", "touch diagnostics unsupported")); } else { snprintf(reply, reply_size, "display.touch %s (off after reboot)", _ui->isTouchDebugEnabled() ? "on" : "off"); @@ -4653,11 +4654,11 @@ bool MyMesh::handleLocalControlCommand(const char* command, char* reply, const char* value = command + 17; while (*value == ' ' || *value == '\t') value++; if (strcmp(value, "on") != 0 && strcmp(value, "off") != 0) { - mesh::cli::formatError(reply, reply_size, "use set display.touch on|off"); + mesh::cli::formatError(reply, reply_size, MESH_CLI_REPLY_TEXT("use set display.touch on|off", "set display.touch on|off")); } else if (_ui == NULL || !_ui->supportsTouchDebug()) { - mesh::cli::formatError(reply, reply_size, "touchscreen diagnostics unsupported"); + mesh::cli::formatError(reply, reply_size, MESH_CLI_REPLY_TEXT("touchscreen diagnostics unsupported", "touch diagnostics unsupported")); } else if (!_ui->setTouchDebugEnabled(strcmp(value, "on") == 0)) { - mesh::cli::formatError(reply, reply_size, "touchscreen diagnostics unavailable"); + mesh::cli::formatError(reply, reply_size, MESH_CLI_REPLY_TEXT("touchscreen diagnostics unavailable", "touch diagnostics unavailable")); } else { snprintf(reply, reply_size, "OK - display.touch %s (off after reboot)", value); } @@ -4672,7 +4673,7 @@ bool MyMesh::handleLocalControlCommand(const char* command, char* reply, if (strcmp(command, "get display.rotation") == 0) { if (_ui == NULL || !_ui->supportsDisplayRotation()) { - mesh::cli::formatError(reply, reply_size, "display rotation is unsupported"); + mesh::cli::formatError(reply, reply_size, MESH_CLI_REPLY_TEXT("display rotation is unsupported", "display rotation unsupported")); } else if (_prefs.display_rotation_degrees == 0) { snprintf(reply, reply_size, "display.rotation default"); } else { @@ -4694,9 +4695,9 @@ bool MyMesh::handleLocalControlCommand(const char* command, char* reply, && (degrees == 0 || degrees == 90 || degrees == 180 || degrees == 270); if (!valid) { - mesh::cli::formatError(reply, reply_size, "use set display.rotation <0|90|180|270>"); + mesh::cli::formatError(reply, reply_size, MESH_CLI_REPLY_TEXT("use set display.rotation <0|90|180|270>", "set display.rotation <0|90|180|270>")); } else if (_ui == NULL || !_ui->supportsDisplayRotation()) { - mesh::cli::formatError(reply, reply_size, "display rotation is unsupported"); + mesh::cli::formatError(reply, reply_size, MESH_CLI_REPLY_TEXT("display rotation is unsupported", "display rotation unsupported")); } else { const uint16_t previous = _prefs.display_rotation_degrees; if (!_ui->setDisplayRotationDegrees((uint16_t)degrees)) { @@ -4706,7 +4707,7 @@ bool MyMesh::handleLocalControlCommand(const char* command, char* reply, if (!savePrefs()) { _prefs.display_rotation_degrees = previous; _ui->setDisplayRotationDegrees(previous); - mesh::cli::formatError(reply, reply_size, "display rotation changed but save failed"); + mesh::cli::formatError(reply, reply_size, MESH_CLI_REPLY_TEXT("display rotation changed but save failed", "display rotation changed; save failed")); } else if (degrees == 0) { snprintf(reply, reply_size, "OK - display rotation reset to board default"); @@ -4740,7 +4741,7 @@ bool MyMesh::handleLocalControlCommand(const char* command, char* reply, bluetooth_name_value++; } if (bluetooth_name_value[0] == 0) { - mesh::cli::formatError(reply, reply_size, "use set bluetooth.name "); + mesh::cli::formatError(reply, reply_size, MESH_CLI_REPLY_TEXT("use set bluetooth.name ", "set bluetooth.name ")); } else { const bool clear_override = strcmp(bluetooth_name_value, "default") == 0 || strcmp(bluetooth_name_value, "clear") == 0; @@ -4796,7 +4797,7 @@ bool MyMesh::handleLocalControlCommand(const char* command, char* reply, bluetooth_mac_value++; } if (bluetooth_mac_value[0] == 0) { - mesh::cli::formatError(reply, reply_size, "use set bluetooth.mac "); + mesh::cli::formatError(reply, reply_size, MESH_CLI_REPLY_TEXT("use set bluetooth.mac ", "set bluetooth.mac ")); } else { applyAndSaveBluetoothMac(bluetooth_mac_value, reply, reply_size); } @@ -5189,7 +5190,7 @@ bool MyMesh::handleLocalControlCommand(const char* command, char* reply, char legacy[120]; uint16_t preamble = 0; if (!mesh::RadioProfileCLI::parseSuffix(command + 10, 5, legacy, sizeof(legacy), preamble)) { - mesh::cli::formatError(reply, reply_size, "invalid preamble"); return true; + mesh::cli::formatError(reply, reply_size, MESH_CLI_REPLY_TEXT("invalid preamble", "bad preamble")); return true; } float freq = 0.0f, bw = 0.0f; uint8_t sf = 0, cr = 0; @@ -5795,7 +5796,7 @@ void MyMesh::execCommand(char* cmd, char* reply) { if (!value[0] || !wcCopyValue(name, sizeof(name), value)) { mesh::cli::formatError(reply, 160, "name must be 1-31 characters"); } else { - strcpy(reply, saveAdvertName(name) ? "OK" : "Error: name could not be saved"); + strcpy(reply, saveAdvertName(name) ? "OK" : MESH_CLI_REPLY_TEXT("Error: name could not be saved", "Error: name not saved")); } return; } @@ -5871,7 +5872,7 @@ void MyMesh::execCommand(char* cmd, char* reply) { } else { strcpy(reply, savePreference(is_af ? _prefs.airtime_factor : _prefs.rx_delay_base, static_cast(parsed)) - ? "OK" : "Error: setting could not be saved"); + ? "OK" : MESH_CLI_REPLY_TEXT("Error: setting could not be saved", "Error: setting not saved")); } return; } @@ -8908,7 +8909,7 @@ bool MyMesh::applyAndSaveBluetoothName(const char* value, char* reply, const bool use_default = value == NULL || value[0] == 0; if (!use_default && !mesh::companion::isValidBluetoothName(value)) { snprintf(reply, reply_size, - "Error: Bluetooth name must be 1-%u valid UTF-8 bytes without control characters", + MESH_CLI_REPLY_TEXT("Error: Bluetooth name must be 1-%u valid UTF-8 bytes without control characters", "Error: Bluetooth name: 1-%u valid UTF-8 bytes, no control chars"), (unsigned)mesh::companion::BLUETOOTH_NAME_MAX_BYTES); return false; } @@ -9102,7 +9103,7 @@ bool MyMesh::applyAndSaveBluetoothMac(const char* value, char* reply, size_t reply_size) { if (reply == NULL || reply_size == 0) return false; if (value == NULL) { - mesh::cli::formatError(reply, reply_size, "use set bluetooth.mac "); + mesh::cli::formatError(reply, reply_size, MESH_CLI_REPLY_TEXT("use set bluetooth.mac ", "set bluetooth.mac ")); return false; } @@ -9138,7 +9139,7 @@ bool MyMesh::applyAndSaveBluetoothMac(const char* value, char* reply, while (*custom_value == ' ' || *custom_value == '\t') custom_value++; } if (!mesh::companion::parseBluetoothMac(custom_value, address)) { - mesh::cli::formatError(reply, reply_size, "use a random-static address (first byte C0-FF), random, random-every-boot, random-after-connect, or default"); + mesh::cli::formatError(reply, reply_size, MESH_CLI_REPLY_TEXT("use a random-static address (first byte C0-FF), random, random-every-boot, random-after-connect, or default", "random-static address (first byte C0-FF)|random|random-every-boot|random-after-connect|default")); return false; } mode = mesh::companion::BLUETOOTH_MAC_CUSTOM; @@ -9218,7 +9219,7 @@ bool MyMesh::applyAndSaveBluetoothStealth(const char* value, char* reply, if (reply == NULL || reply_size == 0) return false; if (value == NULL || (strcmp(value, "on") != 0 && strcmp(value, "off") != 0)) { - mesh::cli::formatError(reply, reply_size, "use set bluetooth.stealth on|off"); + mesh::cli::formatError(reply, reply_size, MESH_CLI_REPLY_TEXT("use set bluetooth.stealth on|off", "set bluetooth.stealth on|off")); return false; } @@ -9312,12 +9313,12 @@ bool MyMesh::applyAndSavePowerSaving(const char* value, char* reply) { } else if (strcmp(value, "off") == 0) { enabled = false; } else { - mesh::cli::formatError(reply, 160, "use set powersaving on|off"); + mesh::cli::formatError(reply, 160, MESH_CLI_REPLY_TEXT("use set powersaving on|off", "set powersaving on|off")); return false; } if (!savePreference(_prefs.powersaving_enabled, static_cast(enabled))) { - mesh::cli::formatError(reply, 160, "power saving could not be saved"); + mesh::cli::formatError(reply, 160, MESH_CLI_REPLY_TEXT("power saving could not be saved", "power saving not saved")); return false; } sensors.setPowerSavingEnabled(enabled); @@ -9457,7 +9458,7 @@ bool MyMesh::applyAndSaveRxPowerSaving(const char* value, char* reply) { mesh::cli::RxPowerSavingArguments parsed = {}; if (!mesh::cli::parseRxPowerSavingArgumentsStrict(value, parsed)) { - mesh::cli::formatError(reply, 160, "use off, level 1-10, or RX_US SLEEP_US"); + mesh::cli::formatError(reply, 160, MESH_CLI_REPLY_TEXT("use off, level 1-10, or RX_US SLEEP_US", "off | level 1-10 | RX_US SLEEP_US")); return false; } @@ -9477,7 +9478,7 @@ bool MyMesh::applyAndSaveRxPowerSaving(const char* value, char* reply) { } else if (parsed.mode == mesh::cli::RxPowerSavingArgumentMode::Level) { if (parsed.level < 1 || parsed.level > 10) { strcpy(reply, parsed.preamble == 0 - ? "Error: level must be 1-10" + ? MESH_CLI_REPLY_TEXT("Error: level must be 1-10", "Error: level 1-10") : "Error: level 1-10; preamble 16 or 32"); return false; } @@ -10997,7 +10998,7 @@ bool MyMesh::handleDirectCommand(const char* command, char* reply, size_t reply_ _prefs.one_key_dm_enabled = strcmp(value, "on") == 0 ? 1 : 0; if (!savePrefs()) { _prefs.one_key_dm_enabled = old_value; - mesh::cli::formatError(reply, reply_size, "could not save DM setting"); + mesh::cli::formatError(reply, reply_size, MESH_CLI_REPLY_TEXT("could not save DM setting", "DM setting not saved")); } else { snprintf(reply, reply_size, "> dm.one_key %s", value); } @@ -11193,7 +11194,7 @@ bool MyMesh::handleCommand(const char* command, uint32_t sender_timestamp, while (*text == ' ' || *text == '\t') ++text; if (strncmp(text, "send", 4) || (text[4] != ' ' && text[4] != '\t')) { - mesh::cli::formatError(reply, reply_capacity, "use fleet send [all|key|region:name|home:name|gps:lat,lon:km[;target...]] "); + mesh::cli::formatError(reply, reply_capacity, MESH_CLI_REPLY_TEXT("use fleet send [all|key|region:name|home:name|gps:lat,lon:km[;target...]] ", "fleet send [all|key|region:name|home:name|gps:lat,lon:km[;target...]] ")); return true; } text += 4; @@ -11204,14 +11205,14 @@ bool MyMesh::handleCommand(const char* command, uint32_t sender_timestamp, have_index = true; const uint8_t digit = *text++ - '0'; if (channel_index > (0xFFFFFFFFUL - digit) / 10UL) { - mesh::cli::formatError(reply, reply_capacity, "invalid fleet channel index"); + mesh::cli::formatError(reply, reply_capacity, MESH_CLI_REPLY_TEXT("invalid fleet channel index", "bad fleet channel index")); return true; } channel_index = channel_index * 10UL + digit; } if (!have_index || channel_index >= MAX_GROUP_CHANNELS || (*text != ' ' && *text != '\t')) { - mesh::cli::formatError(reply, reply_capacity, "invalid fleet channel index"); + mesh::cli::formatError(reply, reply_capacity, MESH_CLI_REPLY_TEXT("invalid fleet channel index", "bad fleet channel index")); return true; } while (*text == ' ' || *text == '\t') ++text; @@ -11223,7 +11224,7 @@ bool MyMesh::handleCommand(const char* command, uint32_t sender_timestamp, // A target list is one signed request; never admit a partial list. if (text[target_length] == 0 || !mesh::FleetCommand::parseTargets(text, target_length, targets)) { - mesh::cli::formatError(reply, reply_capacity, "targets: all, 8/12/64-hex keys, region names, home:name or gps:lat,lon:km; semicolon-separated"); + mesh::cli::formatError(reply, reply_capacity, MESH_CLI_REPLY_TEXT("targets: all, 8/12/64-hex keys, region names, home:name or gps:lat,lon:km; semicolon-separated", "targets: all|8/12/64-hex keys|region names|home:name|gps:lat,lon:km; semicolon-separated")); return true; } text += target_length; @@ -11235,7 +11236,7 @@ bool MyMesh::handleCommand(const char* command, uint32_t sender_timestamp, } ChannelDetails channel; if (!getChannel((int)channel_index, channel) || !channel.name[0]) { - mesh::cli::formatError(reply, reply_capacity, "fleet channel is not configured"); + mesh::cli::formatError(reply, reply_capacity, MESH_CLI_REPLY_TEXT("fleet channel is not configured", "fleet channel not configured")); return true; } bool padded_key = true; @@ -11250,11 +11251,11 @@ bool MyMesh::handleCommand(const char* command, uint32_t sender_timestamp, static uint32_t last_fleet_sequence = 0; if (sequence < mesh::FleetCommand::MinEpoch || sequence > 0xFFFFFFFFUL - mesh::FleetCommand::MaxLifetime) { - mesh::cli::formatError(reply, reply_capacity, "set the Companion clock before fleet send"); + mesh::cli::formatError(reply, reply_capacity, MESH_CLI_REPLY_TEXT("set the Companion clock before fleet send", "set Companion clock before fleet send")); return true; } if (sequence <= last_fleet_sequence) { - mesh::cli::formatError(reply, reply_capacity, "wait for the next clock second before fleet send"); + mesh::cli::formatError(reply, reply_capacity, MESH_CLI_REPLY_TEXT("wait for the next clock second before fleet send", "wait for next clock second before fleet send")); return true; } uint8_t payload[mesh::FleetCommand::MaxEnvelopeLength]; @@ -11263,9 +11264,9 @@ bool MyMesh::handleCommand(const char* command, uint32_t sender_timestamp, sequence + mesh::FleetCommand::MaxLifetime, targets, text, payload, sizeof(payload)); if (!length) { - mesh::cli::formatError(reply, reply_capacity, "fleet targets and command exceed two packets; use fewer targets"); + mesh::cli::formatError(reply, reply_capacity, MESH_CLI_REPLY_TEXT("fleet targets and command exceed two packets; use fewer targets", "fleet targets+command exceed two packets; use fewer targets")); } else if (!sendFleetCommandData(channel.channel, payload, length)) { - mesh::cli::formatError(reply, reply_capacity, "could not queue fleet command"); + mesh::cli::formatError(reply, reply_capacity, MESH_CLI_REPLY_TEXT("could not queue fleet command", "fleet command not queued")); } else { last_fleet_sequence = sequence; snprintf(reply, reply_capacity, "OK - fleet command queued; seq=%lu", @@ -11288,7 +11289,7 @@ bool MyMesh::handleCommand(const char* command, uint32_t sender_timestamp, float f, bw; uint8_t sf, cr; if (!mesh::RadioProfileCLI::parseSuffix(command + 10, 4, legacy, sizeof(legacy), preamble) || !mesh::cli::parseRadioTupleStrict(legacy, f, bw, sf, cr)) { - mesh::cli::formatError(reply, reply_capacity, "invalid radio params"); return true; + mesh::cli::formatError(reply, reply_capacity, MESH_CLI_REPLY_TEXT("invalid radio params", "bad radio params")); return true; } if (!_radio_profiles.acceptsPrimary(f, bw, sf, cr, preamble)) { mesh::cli::formatError(reply, reply_capacity, "radio params unsupported"); return true; @@ -11330,7 +11331,7 @@ bool MyMesh::handleCommand(const char* command, uint32_t sender_timestamp, float frequency; if (!mesh::cli::parseDecimalStrict(command + 9, frequency) || frequency < 150.0f || frequency > 2500.0f) { - mesh::cli::formatError(reply, reply_capacity, "frequency must be 150-2500 MHz"); + mesh::cli::formatError(reply, reply_capacity, MESH_CLI_REPLY_TEXT("frequency must be 150-2500 MHz", "frequency 150-2500 MHz")); } else { const auto result = applyAndSaveRadioParams(frequency, _prefs.bw, _prefs.sf, _prefs.cr, _prefs.client_repeat, _radio_profiles.primaryPreamble(), sender_timestamp != 0); @@ -11435,7 +11436,7 @@ bool MyMesh::handleCommand(const char* command, uint32_t sender_timestamp, if (!AdvertDataParser::isValidName(name)) { strcpy(reply, "Error, bad chars"); } else { - strcpy(reply, saveAdvertName(name) ? "OK" : "Error: name could not be saved"); + strcpy(reply, saveAdvertName(name) ? "OK" : MESH_CLI_REPLY_TEXT("Error: name could not be saved", "Error: name not saved")); } return true; } @@ -11510,10 +11511,10 @@ bool MyMesh::handleCommand(const char* command, uint32_t sender_timestamp, float tz; if (!mesh::cli::parseDecimalStrict(command + 14, tz) || !mesh::companion::timezone::valid(tz)) { - strcpy(reply, "Error, must be from -12 to +14"); + strcpy(reply, MESH_CLI_REPLY_TEXT("Error, must be from -12 to +14", "Error, -12 to +14")); } else { strcpy(reply, savePreference(_prefs.tz_offset, tz) - ? "OK" : "Error: setting could not be saved"); + ? "OK" : MESH_CLI_REPLY_TEXT("Error: setting could not be saved", "Error: setting not saved")); } return true; } diff --git a/src/helpers/CLIReplyText.h b/src/helpers/CLIReplyText.h index 315de80cf..a4050ca2e 100644 --- a/src/helpers/CLIReplyText.h +++ b/src/helpers/CLIReplyText.h @@ -4,8 +4,12 @@ // replies, protocol acknowledgements, destructive and rollback warnings keep // their exact text. #ifndef MESH_CLI_COMPACT_REPLIES +#if defined(MESH_CLI_COMPACT_REPLIES_DEFAULT) +#define MESH_CLI_COMPACT_REPLIES MESH_CLI_COMPACT_REPLIES_DEFAULT +#else #define MESH_CLI_COMPACT_REPLIES 0 #endif +#endif #if MESH_CLI_COMPACT_REPLIES #define MESH_CLI_REPLY_TEXT(verbose, concise) concise diff --git a/test/fixtures/cli_reply_text.json b/test/fixtures/cli_reply_text.json index 933d18401..c476044c5 100644 --- a/test/fixtures/cli_reply_text.json +++ b/test/fixtures/cli_reply_text.json @@ -1,7 +1,12 @@ { "schema": 1, "profiles": [ - "wio-e5-mini_repeater" + "wio-e5-mini_repeater", + "RAK_4631_companion_radio_full", + "RAK_3x72_companion_radio_usb", + "wio-e5-mini_companion_radio_usb", + "wio-e5_companion_radio_usb", + "Tiny_Relay_companion_radio_usb" ], "messages": [ { @@ -583,6 +588,271 @@ "file": "src/helpers/StorageLayout.cpp", "verbose": "Error: use get storage.layout", "concise": "Error: get storage.layout" + }, + { + "file": "examples/companion_radio/MyMesh.cpp", + "verbose": "use set radio.rxps.rfrx_disabled on|off", + "concise": "set radio.rxps.rfrx_disabled on|off" + }, + { + "file": "examples/companion_radio/MyMesh.cpp", + "verbose": "CAD changed but save failed", + "concise": "CAD changed; save failed" + }, + { + "file": "examples/companion_radio/MyMesh.cpp", + "verbose": "use set radio.cad timings ", + "concise": "set radio.cad timings " + }, + { + "file": "examples/companion_radio/MyMesh.cpp", + "verbose": "CAD scan timing is unsupported", + "concise": "CAD scan timing unsupported" + }, + { + "file": "examples/companion_radio/MyMesh.cpp", + "verbose": "CAD timings changed but save failed", + "concise": "CAD timings changed; save failed" + }, + { + "file": "examples/companion_radio/MyMesh.cpp", + "verbose": "use set radio.cad or set radio.cad timings ", + "concise": "set radio.cad ; set radio.cad timings " + }, + { + "file": "examples/companion_radio/MyMesh.cpp", + "verbose": "Error: use get tx.%s or set tx.%s auto|radio|radio2|both|off", + "concise": "Error: get tx.%s; set tx.%s auto|radio|radio2|both|off" + }, + { + "file": "examples/companion_radio/MyMesh.cpp", + "verbose": "Error: tx.%s was not saved", + "concise": "Error: tx.%s not saved" + }, + { + "file": "examples/companion_radio/MyMesh.cpp", + "verbose": "Error: use get/set tx.%s [auto|radio|radio2|both|off]", + "concise": "Error: get/set tx.%s [auto|radio|radio2|both|off]" + }, + { + "file": "examples/companion_radio/MyMesh.cpp", + "verbose": "storage unavailable; TX routing not saved", + "concise": "storage unavailable; TX route not saved" + }, + { + "file": "examples/companion_radio/MyMesh.cpp", + "verbose": "key: needs 12-64 hexadecimal digits (whole bytes)", + "concise": "key: 12-64 hex digits (whole bytes)" + }, + { + "file": "examples/companion_radio/MyMesh.cpp", + "verbose": "prefix controls routing; rename channel", + "concise": "prefix sets routing; rename channel" + }, + { + "file": "examples/companion_radio/MyMesh.cpp", + "verbose": "GPS sync interval must be 1..336 hours", + "concise": "GPS sync interval 1..336 hours" + }, + { + "file": "examples/companion_radio/MyMesh.cpp", + "verbose": "GPS sync interval could not be saved", + "concise": "GPS sync interval not saved" + }, + { + "file": "examples/companion_radio/MyMesh.cpp", + "verbose": "use set gps on|off", + "concise": "set gps on|off" + }, + { + "file": "examples/companion_radio/MyMesh.cpp", + "verbose": "GPS could not be saved", + "concise": "GPS not saved" + }, + { + "file": "examples/companion_radio/MyMesh.cpp", + "verbose": "use set usb.debug ", + "concise": "set usb.debug " + }, + { + "file": "examples/companion_radio/MyMesh.cpp", + "verbose": "USB debug setting could not be saved", + "concise": "USB debug setting not saved" + }, + { + "file": "examples/companion_radio/MyMesh.cpp", + "verbose": "use set usb.logging [reboot]", + "concise": "set usb.logging [reboot]" + }, + { + "file": "examples/companion_radio/MyMesh.cpp", + "verbose": "USB logging changed for this boot but save failed", + "concise": "USB logging changed for this boot; save failed" + }, + { + "file": "examples/companion_radio/MyMesh.cpp", + "verbose": "use set radio.rxgain on|off", + "concise": "set radio.rxgain on|off" + }, + { + "file": "examples/companion_radio/MyMesh.cpp", + "verbose": "use set radio.fem.rxgain on|off", + "concise": "set radio.fem.rxgain on|off" + }, + { + "file": "examples/companion_radio/MyMesh.cpp", + "verbose": "use set radio.fem.txgain on|off", + "concise": "set radio.fem.txgain on|off" + }, + { + "file": "examples/companion_radio/MyMesh.cpp", + "verbose": "touchscreen diagnostics unsupported", + "concise": "touch diagnostics unsupported" + }, + { + "file": "examples/companion_radio/MyMesh.cpp", + "verbose": "use set display.touch on|off", + "concise": "set display.touch on|off" + }, + { + "file": "examples/companion_radio/MyMesh.cpp", + "verbose": "touchscreen diagnostics unavailable", + "concise": "touch diagnostics unavailable" + }, + { + "file": "examples/companion_radio/MyMesh.cpp", + "verbose": "display rotation is unsupported", + "concise": "display rotation unsupported" + }, + { + "file": "examples/companion_radio/MyMesh.cpp", + "verbose": "use set display.rotation <0|90|180|270>", + "concise": "set display.rotation <0|90|180|270>" + }, + { + "file": "examples/companion_radio/MyMesh.cpp", + "verbose": "display rotation changed but save failed", + "concise": "display rotation changed; save failed" + }, + { + "file": "examples/companion_radio/MyMesh.cpp", + "verbose": "use set bluetooth.name ", + "concise": "set bluetooth.name " + }, + { + "file": "examples/companion_radio/MyMesh.cpp", + "verbose": "use set bluetooth.mac ", + "concise": "set bluetooth.mac " + }, + { + "file": "examples/companion_radio/MyMesh.cpp", + "verbose": "invalid preamble", + "concise": "bad preamble" + }, + { + "file": "examples/companion_radio/MyMesh.cpp", + "verbose": "Error: name could not be saved", + "concise": "Error: name not saved" + }, + { + "file": "examples/companion_radio/MyMesh.cpp", + "verbose": "Error: setting could not be saved", + "concise": "Error: setting not saved" + }, + { + "file": "examples/companion_radio/MyMesh.cpp", + "verbose": "Error: Bluetooth name must be 1-%u valid UTF-8 bytes without control characters", + "concise": "Error: Bluetooth name: 1-%u valid UTF-8 bytes, no control chars" + }, + { + "file": "examples/companion_radio/MyMesh.cpp", + "verbose": "use a random-static address (first byte C0-FF), random, random-every-boot, random-after-connect, or default", + "concise": "random-static address (first byte C0-FF)|random|random-every-boot|random-after-connect|default" + }, + { + "file": "examples/companion_radio/MyMesh.cpp", + "verbose": "use set bluetooth.stealth on|off", + "concise": "set bluetooth.stealth on|off" + }, + { + "file": "examples/companion_radio/MyMesh.cpp", + "verbose": "use set powersaving on|off", + "concise": "set powersaving on|off" + }, + { + "file": "examples/companion_radio/MyMesh.cpp", + "verbose": "power saving could not be saved", + "concise": "power saving not saved" + }, + { + "file": "examples/companion_radio/MyMesh.cpp", + "verbose": "use off, level 1-10, or RX_US SLEEP_US", + "concise": "off | level 1-10 | RX_US SLEEP_US" + }, + { + "file": "examples/companion_radio/MyMesh.cpp", + "verbose": "Error: level must be 1-10", + "concise": "Error: level 1-10" + }, + { + "file": "examples/companion_radio/MyMesh.cpp", + "verbose": "could not save DM setting", + "concise": "DM setting not saved" + }, + { + "file": "examples/companion_radio/MyMesh.cpp", + "verbose": "use fleet send [all|key|region:name|home:name|gps:lat,lon:km[;target...]] ", + "concise": "fleet send [all|key|region:name|home:name|gps:lat,lon:km[;target...]] " + }, + { + "file": "examples/companion_radio/MyMesh.cpp", + "verbose": "invalid fleet channel index", + "concise": "bad fleet channel index" + }, + { + "file": "examples/companion_radio/MyMesh.cpp", + "verbose": "targets: all, 8/12/64-hex keys, region names, home:name or gps:lat,lon:km; semicolon-separated", + "concise": "targets: all|8/12/64-hex keys|region names|home:name|gps:lat,lon:km; semicolon-separated" + }, + { + "file": "examples/companion_radio/MyMesh.cpp", + "verbose": "fleet channel is not configured", + "concise": "fleet channel not configured" + }, + { + "file": "examples/companion_radio/MyMesh.cpp", + "verbose": "set the Companion clock before fleet send", + "concise": "set Companion clock before fleet send" + }, + { + "file": "examples/companion_radio/MyMesh.cpp", + "verbose": "wait for the next clock second before fleet send", + "concise": "wait for next clock second before fleet send" + }, + { + "file": "examples/companion_radio/MyMesh.cpp", + "verbose": "fleet targets and command exceed two packets; use fewer targets", + "concise": "fleet targets+command exceed two packets; use fewer targets" + }, + { + "file": "examples/companion_radio/MyMesh.cpp", + "verbose": "could not queue fleet command", + "concise": "fleet command not queued" + }, + { + "file": "examples/companion_radio/MyMesh.cpp", + "verbose": "invalid radio params", + "concise": "bad radio params" + }, + { + "file": "examples/companion_radio/MyMesh.cpp", + "verbose": "frequency must be 150-2500 MHz", + "concise": "frequency 150-2500 MHz" + }, + { + "file": "examples/companion_radio/MyMesh.cpp", + "verbose": "Error, must be from -12 to +14", + "concise": "Error, -12 to +14" } ], "noncompact_tokens_sha256": { @@ -593,6 +863,7 @@ "src/helpers/PowerSavingDiagnostics.h": "18b78f5780046ea8c772c4ce6c337741ae0c027039d9399c94c5f4205d114000", "src/helpers/FloodAdvertCLI.h": "0f378e4d0f6c3b9b79991068a4b4208139484d2144ee1d7c5094d39043463be4", "src/helpers/LedSettings.cpp": "df0681234ca1337148ad59caa935857bc692f98bf18603ca6d099c0382028400", - "src/helpers/StorageLayout.cpp": "5dd1c8bcbe2902e1f22d14f9ab425465bdc38926fae23989987d850b484cbb18" + "src/helpers/StorageLayout.cpp": "5dd1c8bcbe2902e1f22d14f9ab425465bdc38926fae23989987d850b484cbb18", + "examples/companion_radio/MyMesh.cpp": "4ed72908fa1b986edb27c7a74acaccd5fda212c68f2f41537692739c040bb392" } } diff --git a/test/test_cli_reply_text.py b/test/test_cli_reply_text.py index d099ed98d..19608df3c 100644 --- a/test/test_cli_reply_text.py +++ b/test/test_cli_reply_text.py @@ -42,9 +42,12 @@ class CliReplyTextTests(unittest.TestCase): self.assertEqual(re.findall(r'\d+(?:\.\d+)?', old), re.findall(r'\d+(?:\.\d+)?', new)) self.assertFalse(old.startswith(('> ', '{', 'KEYBACKUP ', 'ACK'))) - self.assertNotRegex(old.lower(), + self.assertNotRegex(old.lower().replace('[reboot]', ''), r'host|replay|password|login|save unconfirmed|reboot|' - r'retained|default|warning|applying in|restore scheduled') + r'retained|warning|applying in|restore scheduled') + for protected in ('[reboot]', 'default'): + if protected in old: + self.assertIn(protected, new) if 'unchanged' in old: self.assertIn('sleep policy unchanged', old) self.assertIn('sleep policy unchanged', new) @@ -65,14 +68,14 @@ class CliReplyTextTests(unittest.TestCase): config = configparser.ConfigParser(interpolation=None, strict=False) config.read(path) for section in config.sections(): - if re.search(r'-D\s*MESH_CLI_COMPACT_REPLIES=1\b', + if re.search(r'-D\s*MESH_CLI_COMPACT_REPLIES_DEFAULT=1\b', config[section].get('build_flags', '')): self.assertTrue(section.startswith('env:'), str(path)) enabled.append(section[4:]) self.assertEqual(sorted(enabled), sorted(fixture['profiles'])) header = (ROOT / 'src/helpers/CLIReplyText.h').read_text() - self.assertIn('#ifndef MESH_CLI_COMPACT_REPLIES\n' - '#define MESH_CLI_COMPACT_REPLIES 0', header) + self.assertIn('#ifndef MESH_CLI_COMPACT_REPLIES\n', header) + self.assertIn('#define MESH_CLI_COMPACT_REPLIES 0', header) def test_actual_header_selects_exact_text_without_a_runtime_lookup(self): compiler = os.environ.get('CXX') or shutil.which('g++') or shutil.which('clang++') @@ -88,8 +91,14 @@ class CliReplyTextTests(unittest.TestCase): with tempfile.TemporaryDirectory(prefix='cli-reply-text-') as folder: work = Path(folder) (work / 'test.cpp').write_text(source) - for flag, key in ((None, 'verbose'), (0, 'verbose'), (1, 'concise')): - flags = [] if flag is None else ['-DMESH_CLI_COMPACT_REPLIES='+str(flag)] + for flags, key in (([], 'verbose'), + (['-DMESH_CLI_COMPACT_REPLIES=0'], 'verbose'), + (['-DMESH_CLI_COMPACT_REPLIES=1'], 'concise'), + (['-DMESH_CLI_COMPACT_REPLIES_DEFAULT=1'], 'concise'), + (['-DMESH_CLI_COMPACT_REPLIES_DEFAULT=1', + '-DMESH_CLI_COMPACT_REPLIES=0'], 'verbose'), + (['-DMESH_CLI_COMPACT_REPLIES_DEFAULT=0', + '-DMESH_CLI_COMPACT_REPLIES=1'], 'concise')): built = subprocess.run([compiler, '-std=c++11', '-Os', '-Wall', '-Wextra', '-Werror', *flags, '-I'+str(ROOT / 'src/helpers'), str(work / 'test.cpp'), '-o', str(work / 'test')], capture_output=True) @@ -98,7 +107,7 @@ class CliReplyTextTests(unittest.TestCase): self.assertEqual(result.returncode, 0, result.stderr.decode(errors='replace')) self.assertEqual(result.stdout.decode().splitlines(), [row[key] for row in messages]) - if flag == 1: + if key == 'concise': image = (work / 'test').read_bytes() selected = {row['concise'] for row in messages} for row in messages: diff --git a/variants/rak3x72/platformio.ini b/variants/rak3x72/platformio.ini index e7302adc9..50191ce06 100644 --- a/variants/rak3x72/platformio.ini +++ b/variants/rak3x72/platformio.ini @@ -66,6 +66,7 @@ extends = rak3x72 platform_packages = platformio/toolchain-gccarmnoneeabi@^1.140201.0 build_unflags = -Os build_flags = ${rak3x72.build_flags} + -D MESH_CLI_COMPACT_REPLIES_DEFAULT=1 ; This headless UART Companion uses GPIO, ADC and SysTick, not PWM/tone. ; Drop unused Arduino timer wrappers, retaining the low-level STM32 TIM HAL. -D RADIOLIB_TONE_UNSUPPORTED diff --git a/variants/rak4631/platformio.ini b/variants/rak4631/platformio.ini index bf6221781..4fa245732 100644 --- a/variants/rak4631/platformio.ini +++ b/variants/rak4631/platformio.ini @@ -486,6 +486,7 @@ build_flags = -D ETHERNET_CLASS=RAK13800EthernetInterface -D MESHCORE_REQUIRES_COMPANION_RADIO_FULL=1 -D COMPANION_READER_COMPACT_STREAM=1 + -D MESH_CLI_COMPACT_REPLIES_DEFAULT=1 build_src_filter = ${env:RAK_4631_companion_radio_usb.build_src_filter} + + diff --git a/variants/tiny_relay/platformio.ini b/variants/tiny_relay/platformio.ini index f13376dae..ca469dc1e 100644 --- a/variants/tiny_relay/platformio.ini +++ b/variants/tiny_relay/platformio.ini @@ -72,6 +72,7 @@ extends = Tiny_Relay platform_packages = platformio/toolchain-gccarmnoneeabi@^1.140201.0 build_unflags = -Os build_flags = ${Tiny_Relay.build_flags} + -D MESH_CLI_COMPACT_REPLIES_DEFAULT=1 ; GPIO relay controls and ADC monitoring do not use PWM/tone timers. -D RADIOLIB_TONE_UNSUPPORTED -D HAL_TIM_MODULE_ONLY diff --git a/variants/wio-e5-dev/platformio.ini b/variants/wio-e5-dev/platformio.ini index 7f1d7cbed..b5aa3b476 100644 --- a/variants/wio-e5-dev/platformio.ini +++ b/variants/wio-e5-dev/platformio.ini @@ -90,6 +90,7 @@ extends = lora_e5 platform_packages = platformio/toolchain-gccarmnoneeabi@^1.140201.0 build_unflags = -Os build_flags = ${lora_e5.build_flags} + -D MESH_CLI_COMPACT_REPLIES_DEFAULT=1 ; This headless UART Companion uses ADC, GPIO and SysTick, not PWM/tone. ; Retain the low-level TIM HAL without unused Arduino timer wrappers. -D RADIOLIB_TONE_UNSUPPORTED diff --git a/variants/wio-e5-mini/platformio.ini b/variants/wio-e5-mini/platformio.ini index 49d6a84b2..43b51ffa5 100644 --- a/variants/wio-e5-mini/platformio.ini +++ b/variants/wio-e5-mini/platformio.ini @@ -60,7 +60,7 @@ build_flags = ${lora_e5_mini.build_flags} -D MESH_ENABLE_CLOCK_SYNC=0 ; preserve the 16 KiB filesystem on this 256 KiB part -D MESH_ENABLE_FLOOD_GROUP_MODERATION=0 ; keep core routing and radio scheduling in flash -D MESH_PACKET_LOGGING_COMPACT=1 ; retain complete RX/TX bytes without the duplicate parsed RX line - -D MESH_CLI_COMPACT_REPLIES=1 ; concise human prose; command/protocol replies stay intact + -D MESH_CLI_COMPACT_REPLIES_DEFAULT=1 ; explicit MESH_CLI_COMPACT_REPLIES overrides this default ; User-approved Mini Repeater cuts only; other boards keep their defaults. -D MESH_ENABLE_CARRIER_TOOLS=0 -D MESH_ENABLE_MANAGEMENT_REPORTING=0 @@ -101,6 +101,7 @@ platform_packages = platformio/toolchain-gccarmnoneeabi@^1.140201.0 build_unflags = -Os build_flags = ${lora_e5_mini.build_flags} + -D MESH_CLI_COMPACT_REPLIES_DEFAULT=1 ; Headless button/LED controls use GPIO and millis, not PWM/tone timers. ; Keep the low-level TIM HAL while omitting unused Arduino timer wrappers. -D RADIOLIB_TONE_UNSUPPORTED From f973e4a952e85cfd614013ab60f4cb3f1e8f129b Mon Sep 17 00:00:00 2001 From: mikecarper Date: Wed, 28 Oct 2026 04:26:03 -0700 Subject: [PATCH 24/61] Keep native Companion fixtures aligned with shared control dispatch --- test/test_companion_fleet_control.py | 18 +++++++++++++++--- test/test_companion_tx_raw_alias.py | 3 +++ 2 files changed, 18 insertions(+), 3 deletions(-) diff --git a/test/test_companion_fleet_control.py b/test/test_companion_fleet_control.py index be9748435..e83a02fea 100644 --- a/test/test_companion_fleet_control.py +++ b/test/test_companion_fleet_control.py @@ -27,6 +27,7 @@ HARNESS = r''' #include #include #include +#include #include #include "regulator_cli_stub.h" #include "usb_mode_console.h" @@ -230,6 +231,7 @@ class MyMesh : public mesh::Mesh { bool sendFleetCommandData(GroupChannel&, const uint8_t*, size_t); void onRadioProfileCopyQueued(mesh::Packet*, const mesh::Packet*, uint8_t); bool handleCommand(const char* command, uint32_t sender_timestamp, char* reply); + bool handleLocalControlCommand(const char*, char*, size_t); void checkCLIRescueCmd(); void enterCLIRescue(); void resetUsbHostSessionInput(); @@ -1299,7 +1301,16 @@ class CompanionFleetControlTests(unittest.TestCase): fleet_end = function.index(" if (mesh::handleMorseReplyCommand", fleet_start) # Keep the actual compile guard as well as its complete branch so # compact profiles prove that none of the fleet dependencies survive. - cls.production = prefix + function[fleet_start:fleet_end] + "\n return false;\n}\n" + # The local-control route follows the fleet/radio branches. Retain + # its real dispatcher and USB-mode prefix without the other local + # command families' unrelated board/filesystem dependencies. + local_dispatch = " if (handleLocalControlCommand(command, reply, reply_capacity)) return true;" + assert function.count(local_dispatch) == 1 + local = body(source, "bool MyMesh::handleLocalControlCommand(") + cls.local_control = local[:local.index("#if defined(ESP32_PLATFORM) && defined(WIFI_SSID)")] + cls.local_control += "\n return false;\n}\n" + cls.production = (prefix + function[fleet_start:fleet_end] + local_dispatch + + "\n return false;\n}\n") cls.work = tempfile.TemporaryDirectory(prefix="meshcore-fleet-companion-") work = Path(cls.work.name) cls.binary = work / "companion-fleet" @@ -1334,7 +1345,7 @@ class CompanionFleetControlTests(unittest.TestCase): assert enter.count(banner) == 1 enter = enter.replace(banner, 'mesh::usbTerminalPort().print("========= CLI Rescue =========\\n");') cls.fixture = fixture - fixture.write_text(HARNESS.replace("@METHOD@", cls.production) + fixture.write_text(HARNESS.replace("@METHOD@", cls.production + cls.local_control) .replace("@USB_INPUT_RESERVED@", body( (ROOT / "src/helpers/HostKiss.cpp").read_text(), "bool usbInputReserved(")) @@ -1524,6 +1535,7 @@ static_assert(MESH_ENABLE_FLEET_CONTROL == 0, "compact profile must disable flee class MyMesh { public: bool handleCommand(const char*, uint32_t, char*); + bool handleLocalControlCommand(const char*, char*, size_t); }; @METHOD@ int main() { @@ -1536,7 +1548,7 @@ int main() { ''' work = Path(self.work.name) source = work / "disabled.cpp" - source.write_text(harness.replace("@METHOD@", self.production)) + source.write_text(harness.replace("@METHOD@", self.production + self.local_control)) for profile, flags in (("stm32-default", ["-DSTM32_PLATFORM=1"]), ("explicit-disable", ["-DMESH_ENABLE_FLEET_CONTROL=0"])): with self.subTest(profile=profile): diff --git a/test/test_companion_tx_raw_alias.py b/test/test_companion_tx_raw_alias.py index b1342b6be..3753fed41 100644 --- a/test/test_companion_tx_raw_alias.py +++ b/test/test_companion_tx_raw_alias.py @@ -63,6 +63,9 @@ public: bool setChannel(int, const ChannelDetails&); }; class MyMesh : public BaseChatMesh, public DataStoreHost {""") + # The publication-only fixture aliases its simplified channel owner. This + # fixture supplies the real base accessors and must retain that base name. + code = replace_once(code, " using BaseChatMesh = MyMesh;\n", "") code = replace_once(code, " bool writable=true, writes_ok=true; unsigned writes=0;", """ struct { bool enabled = false; } _ethernet_settings; bool writable=true, writes_ok=true; unsigned writes=0;""") From 8f20fe5d8346c6f9e50f62fcd898dfc388047055 Mon Sep 17 00:00:00 2001 From: mikecarper Date: Wed, 28 Oct 2026 04:44:06 -0700 Subject: [PATCH 25/61] Trim remaining validation wording in small Companion profiles --- examples/companion_radio/CompanionRetry.h | 4 +- examples/companion_radio/MyMesh.cpp | 6 +- examples/companion_radio/Notifications.cpp | 3 +- src/helpers/CommonRadioPrefs.cpp | 15 ++-- src/helpers/RadioProfileCLI.cpp | 12 +-- test/fixtures/cli_reply_text.json | 90 +++++++++++++++++++++- 6 files changed, 111 insertions(+), 19 deletions(-) diff --git a/examples/companion_radio/CompanionRetry.h b/examples/companion_radio/CompanionRetry.h index 168fe3d4c..b96431fc7 100644 --- a/examples/companion_radio/CompanionRetry.h +++ b/examples/companion_radio/CompanionRetry.h @@ -1,5 +1,7 @@ #pragma once +#include + #include #include #include @@ -87,7 +89,7 @@ bool handleRetryCommand(CompanionNodePrefs& prefs, const char* command, prefs.flood_retry_max_path = old_path; prefs.flood_retry_group_max_path = old_group_path; prefs.flood_retry_advert_enabled = old_advert; - snprintf(reply, reply_size, "Error: retry setting could not be saved"); + snprintf(reply, reply_size, MESH_CLI_REPLY_TEXT("Error: retry setting could not be saved", "Error: retry setting not saved")); return true; } if (old_count != 0 && prefs.flood_retry_attempts == 0) cancel_flood_retries(); diff --git a/examples/companion_radio/MyMesh.cpp b/examples/companion_radio/MyMesh.cpp index 4d752e184..56a4482a6 100644 --- a/examples/companion_radio/MyMesh.cpp +++ b/examples/companion_radio/MyMesh.cpp @@ -9398,7 +9398,7 @@ bool MyMesh::handleRxPowerSavingGetCommand(const char* command, char* reply, const bool config = strcmp(command, "get radio.rxps.config") == 0; if (!config && strcmp(command, "get radio.rxps") != 0) return false; if (!radio_driver.supportsRxPowerSaving()) { - mesh::cli::formatError(reply, reply_size, "RX power saving is unsupported on this radio"); + mesh::cli::formatError(reply, reply_size, MESH_CLI_REPLY_TEXT("RX power saving is unsupported on this radio", "RX power saving unsupported on radio")); return true; } @@ -11007,7 +11007,7 @@ bool MyMesh::handleDirectCommand(const char* command, char* reply, size_t reply_ #else if (strcmp(command, "get dm.one_key") == 0 || strcmp(command, "get dm.held") == 0 || strncmp(command, "set dm.one_key ", 15) == 0) { - mesh::cli::formatError(reply, reply_size, "one-key DMs unsupported on this build"); + mesh::cli::formatError(reply, reply_size, MESH_CLI_REPLY_TEXT("one-key DMs unsupported on this build", "one-key DMs unsupported in build")); return true; } #endif @@ -11359,7 +11359,7 @@ bool MyMesh::handleCommand(const char* command, uint32_t sender_timestamp, if (strncmp(command, "set tx ", 7) == 0) { int32_t parsed = 0; if (!mesh::cli::parseIntegerStrict(command + 7, parsed)) { - mesh::cli::formatError(reply, reply_capacity, "invalid TX power"); + mesh::cli::formatError(reply, reply_capacity, MESH_CLI_REPLY_TEXT("invalid TX power", "bad TX power")); } else if (parsed < -9 || parsed > MAX_LORA_TX_POWER) { snprintf(reply, reply_capacity, "Error: TX -9..%d dBm", MAX_LORA_TX_POWER); diff --git a/examples/companion_radio/Notifications.cpp b/examples/companion_radio/Notifications.cpp index 9dc50ce98..b025b7a6d 100644 --- a/examples/companion_radio/Notifications.cpp +++ b/examples/companion_radio/Notifications.cpp @@ -1,3 +1,4 @@ +#include #include "MyMesh.h" #include "target.h" #include "NotificationSettingsFile.h" @@ -160,7 +161,7 @@ bool MyMesh::handleNotificationCommand(const char* command,char* reply,size_t si bool MyMesh::handleNotificationCommand(const char* command,char* reply,size_t size) { if(strncmp(command,"set notify.",11)&&strncmp(command,"get notify",10) &&strncmp(command,"notify.",7))return false; - snprintf(reply,size,"Error: programmable alerts unavailable on this compact STM32 build"); + snprintf(reply,size,MESH_CLI_REPLY_TEXT("Error: programmable alerts unavailable on this compact STM32 build", "Error: programmable alerts unavailable on compact STM32")); return true; } #endif diff --git a/src/helpers/CommonRadioPrefs.cpp b/src/helpers/CommonRadioPrefs.cpp index 42481b3f1..94ada63c6 100644 --- a/src/helpers/CommonRadioPrefs.cpp +++ b/src/helpers/CommonRadioPrefs.cpp @@ -1,3 +1,4 @@ +#include "CLIReplyText.h" #include "CommonRadioPrefs.h" #include "TxtDataHelpers.h" #include "target.h" @@ -284,7 +285,7 @@ bool CommonRadioPrefs::handleCommand(const char* command, uint32_t sender_timest if (strcmp(command, "get dutycycle") == 0) { const float factor = getAirtimeFactor(); if (!isfinite(factor) || factor < 0.0f) { - strcpy(reply, "Error: invalid airtime factor"); + strcpy(reply, MESH_CLI_REPLY_TEXT("Error: invalid airtime factor", "Error: bad airtime factor")); } else { const int tenths = static_cast(1000.0f / (factor + 1.0f) + 0.5f); sprintf(reply, "> %d.%d%%", tenths / 10, tenths % 10); @@ -315,7 +316,7 @@ bool CommonRadioPrefs::handleCommand(const char* command, uint32_t sender_timest setIntThresh(static_cast(threshold)); strcpy(reply, "OK"); } else { - strcpy(reply, "Error, must be 0-255"); + strcpy(reply, MESH_CLI_REPLY_TEXT("Error, must be 0-255", "Error, 0-255")); } return true; } @@ -330,7 +331,7 @@ bool CommonRadioPrefs::handleCommand(const char* command, uint32_t sender_timest setCadEnabled(strcmp(value, "on") == 0); strcpy(reply, "OK"); } else { - strcpy(reply, "Error: use set cad on|off"); + strcpy(reply, MESH_CLI_REPLY_TEXT("Error: use set cad on|off", "Error: set cad on|off")); } return true; } @@ -342,7 +343,7 @@ bool CommonRadioPrefs::handleCommand(const char* command, uint32_t sender_timest if (startsWith(command, "set radio.rxgain ")) { const char* value = command + strlen("set radio.rxgain "); if (strcmp(value, "on") != 0 && strcmp(value, "off") != 0) { - strcpy(reply, "Error: use set radio.rxgain on|off"); + strcpy(reply, MESH_CLI_REPLY_TEXT("Error: use set radio.rxgain on|off", "Error: set radio.rxgain on|off")); return true; } @@ -363,7 +364,7 @@ bool CommonRadioPrefs::handleCommand(const char* command, uint32_t sender_timest if (startsWith(command, "set tx ")) { int32_t dbm = 0; if (!parseIntStrict(command + strlen("set tx "), -128, 127, dbm)) { - strcpy(reply, "Error: invalid TX power"); + strcpy(reply, MESH_CLI_REPLY_TEXT("Error: invalid TX power", "Error: bad TX power")); } else if (!radio_driver.setTxPower(static_cast(dbm))) { strcpy(reply, "Error: TX power rejected"); } else { @@ -399,7 +400,7 @@ bool CommonRadioPrefs::handleCommand(const char* command, uint32_t sender_timest setAgcResetInt(static_cast(seconds)); sprintf(reply, "OK - interval rounded to %d", (uint32_t) getAgcResetInt()); } else { - strcpy(reply, "Error, must be 0-255"); + strcpy(reply, MESH_CLI_REPLY_TEXT("Error, must be 0-255", "Error, 0-255")); } return true; } @@ -429,7 +430,7 @@ bool CommonRadioPrefs::handleCommand(const char* command, uint32_t sender_timest setMultiAcks(static_cast(enabled)); strcpy(reply, "OK"); } else { - strcpy(reply, "Error, must be 0 or 1"); + strcpy(reply, MESH_CLI_REPLY_TEXT("Error, must be 0 or 1", "Error, 0|1")); } return true; } diff --git a/src/helpers/RadioProfileCLI.cpp b/src/helpers/RadioProfileCLI.cpp index d6dbbd5c1..66e75d4c0 100644 --- a/src/helpers/RadioProfileCLI.cpp +++ b/src/helpers/RadioProfileCLI.cpp @@ -1106,7 +1106,7 @@ bool RadioProfileCLI::handle(const char* command, char* reply, size_t capacity, const bool status = kind == Status; const bool scan = kind == Scan; const bool timing = kind == Timing || kind == TimingAlias; - if (!radio_ || !radio_->profiles()) { cli::formatError(reply, capacity, "radio profiles unsupported"); return true; } + if (!radio_ || !radio_->profiles()) { cli::formatError(reply, capacity, MESH_CLI_REPLY_TEXT("radio profiles unsupported", "profiles unsupported")); return true; } if (command == text && *args && verb == Get && (temporary || base)) verb = Set; if (remote_origin && !remote_command_ && (((base || temporary) && verb == Set) || (scheduled_temp && verb == Delete))) { @@ -1138,7 +1138,7 @@ bool RadioProfileCLI::handle(const char* command, char* reply, size_t capacity, if (verb != Set || length >= sizeof(mode) || !parseRadioTxPolicy(mode, policy) || (*suffix && !force) || (force && policy != RADIO_TX_BOTH && policy != RADIO_TX_SECONDARY)) { - cli::formatError(reply, capacity, "use get/set tx.reply auto|radio|radio2|both|off [force for radio2/both]"); + cli::formatError(reply, capacity, MESH_CLI_REPLY_TEXT("use get/set tx.reply auto|radio|radio2|both|off [force for radio2/both]", "get/set tx.reply auto|radio|radio2|both|off [force for radio2/both]")); return true; } const uint8_t previous = reply_setting_; @@ -1184,7 +1184,7 @@ bool RadioProfileCLI::handle(const char* command, char* reply, size_t capacity, auto mode = !strcmp(args, "auto") ? RadioCrossMode::Auto : !strcmp(args, "on") ? RadioCrossMode::On : RadioCrossMode::Off; if (save(saved_, primary_preamble_, mode)) { cross_ = mode; publish(); snprintf(reply, capacity, "OK"); } else cli::formatError(reply, capacity, MESH_CLI_REPLY_TEXT("settings could not be saved", "settings not saved")); - } else cli::formatError(reply, capacity, "use get/set radio2.cross [auto|on|off]"); + } else cli::formatError(reply, capacity, MESH_CLI_REPLY_TEXT("use get/set radio2.cross [auto|on|off]", "get/set radio2.cross [auto|on|off]")); return true; } if (scan) { @@ -1371,10 +1371,10 @@ bool RadioProfileCLI::handle(const char* command, char* reply, size_t capacity, : 0; const uint64_t session_left = pending_left > temp_remaining_ms_ ? pending_left : temp_remaining_ms_; if ((temp_active_ || temp_pending_) && (uint64_t)(start-epoch)*1000 < session_left) { - cli::formatError(reply, capacity, "temporary schedule overlaps active session"); return true; + cli::formatError(reply, capacity, MESH_CLI_REPLY_TEXT("temporary schedule overlaps active session", "temp schedule overlaps active session")); return true; } for (const auto& s : schedules_) if (s.active && s.temporary && start < s.end && end > s.start) { - cli::formatError(reply, capacity, "temporary schedules overlap"); return true; + cli::formatError(reply, capacity, MESH_CLI_REPLY_TEXT("temporary schedules overlap", "temp schedules overlap")); return true; } } const unsigned first = scheduled_temp ? 4 : 0; @@ -1402,7 +1402,7 @@ bool RadioProfileCLI::handle(const char* command, char* reply, size_t capacity, } if (temporary) { for (const auto& s : schedules_) if (s.active && s.temporary) { - cli::formatError(reply, capacity, "clear tempradioat2 schedules first"); return true; + cli::formatError(reply, capacity, MESH_CLI_REPLY_TEXT("clear tempradioat2 schedules first", "clear tempradioat2 first")); return true; } if (remote_command_) stageRemoteMutation(RemoteMutation::Temporary, config, minutes * 60000UL); else { diff --git a/test/fixtures/cli_reply_text.json b/test/fixtures/cli_reply_text.json index c476044c5..0f444e7da 100644 --- a/test/fixtures/cli_reply_text.json +++ b/test/fixtures/cli_reply_text.json @@ -853,6 +853,91 @@ "file": "examples/companion_radio/MyMesh.cpp", "verbose": "Error, must be from -12 to +14", "concise": "Error, -12 to +14" + }, + { + "file": "examples/companion_radio/MyMesh.cpp", + "verbose": "RX power saving is unsupported on this radio", + "concise": "RX power saving unsupported on radio" + }, + { + "file": "examples/companion_radio/MyMesh.cpp", + "verbose": "one-key DMs unsupported on this build", + "concise": "one-key DMs unsupported in build" + }, + { + "file": "examples/companion_radio/MyMesh.cpp", + "verbose": "invalid TX power", + "concise": "bad TX power" + }, + { + "file": "src/helpers/RadioProfileCLI.cpp", + "verbose": "radio profiles unsupported", + "concise": "profiles unsupported" + }, + { + "file": "src/helpers/RadioProfileCLI.cpp", + "verbose": "use get/set tx.reply auto|radio|radio2|both|off [force for radio2/both]", + "concise": "get/set tx.reply auto|radio|radio2|both|off [force for radio2/both]" + }, + { + "file": "src/helpers/RadioProfileCLI.cpp", + "verbose": "use get/set radio2.cross [auto|on|off]", + "concise": "get/set radio2.cross [auto|on|off]" + }, + { + "file": "src/helpers/RadioProfileCLI.cpp", + "verbose": "clear tempradioat2 schedules first", + "concise": "clear tempradioat2 first" + }, + { + "file": "src/helpers/RadioProfileCLI.cpp", + "verbose": "temporary schedule overlaps active session", + "concise": "temp schedule overlaps active session" + }, + { + "file": "src/helpers/RadioProfileCLI.cpp", + "verbose": "temporary schedules overlap", + "concise": "temp schedules overlap" + }, + { + "file": "src/helpers/CommonRadioPrefs.cpp", + "verbose": "Error: invalid airtime factor", + "concise": "Error: bad airtime factor" + }, + { + "file": "src/helpers/CommonRadioPrefs.cpp", + "verbose": "Error, must be 0-255", + "concise": "Error, 0-255" + }, + { + "file": "src/helpers/CommonRadioPrefs.cpp", + "verbose": "Error: use set cad on|off", + "concise": "Error: set cad on|off" + }, + { + "file": "src/helpers/CommonRadioPrefs.cpp", + "verbose": "Error: use set radio.rxgain on|off", + "concise": "Error: set radio.rxgain on|off" + }, + { + "file": "src/helpers/CommonRadioPrefs.cpp", + "verbose": "Error: invalid TX power", + "concise": "Error: bad TX power" + }, + { + "file": "src/helpers/CommonRadioPrefs.cpp", + "verbose": "Error, must be 0 or 1", + "concise": "Error, 0|1" + }, + { + "file": "examples/companion_radio/CompanionRetry.h", + "verbose": "Error: retry setting could not be saved", + "concise": "Error: retry setting not saved" + }, + { + "file": "examples/companion_radio/Notifications.cpp", + "verbose": "Error: programmable alerts unavailable on this compact STM32 build", + "concise": "Error: programmable alerts unavailable on compact STM32" } ], "noncompact_tokens_sha256": { @@ -864,6 +949,9 @@ "src/helpers/FloodAdvertCLI.h": "0f378e4d0f6c3b9b79991068a4b4208139484d2144ee1d7c5094d39043463be4", "src/helpers/LedSettings.cpp": "df0681234ca1337148ad59caa935857bc692f98bf18603ca6d099c0382028400", "src/helpers/StorageLayout.cpp": "5dd1c8bcbe2902e1f22d14f9ab425465bdc38926fae23989987d850b484cbb18", - "examples/companion_radio/MyMesh.cpp": "4ed72908fa1b986edb27c7a74acaccd5fda212c68f2f41537692739c040bb392" + "examples/companion_radio/MyMesh.cpp": "4ed72908fa1b986edb27c7a74acaccd5fda212c68f2f41537692739c040bb392", + "src/helpers/CommonRadioPrefs.cpp": "16b1fb499426abde83566b6481df89b80302c72e00f0f4aa3843b6c9b9de4218", + "examples/companion_radio/CompanionRetry.h": "e0ae10c7b2f778346f4645c4e147d87e1fbc7f8b99484752a8eed914bb21380f", + "examples/companion_radio/Notifications.cpp": "173fb0d452fdd383585dd88ab35fbb079a90e068adca2db9bc44d604557d3873" } } From acacf548ef1715d3b7b77e698c075077faaac3a2 Mon Sep 17 00:00:00 2001 From: mikecarper Date: Wed, 28 Oct 2026 04:57:03 -0700 Subject: [PATCH 26/61] Group runtime timers and flags to recover nRF52 repeater RAM --- examples/simple_repeater/MyMesh.h | 52 +++++++++++++++++------------- src/helpers/CommonCLI.h | 9 ++++-- src/helpers/EmergencyPowerPolicy.h | 15 +++++---- test/test_emergency_power.py | 1 + 4 files changed, 45 insertions(+), 32 deletions(-) diff --git a/examples/simple_repeater/MyMesh.h b/examples/simple_repeater/MyMesh.h index f1edc4d0b..ddd0155cd 100644 --- a/examples/simple_repeater/MyMesh.h +++ b/examples/simple_repeater/MyMesh.h @@ -355,53 +355,59 @@ class MyMesh : public mesh::Mesh, public CommonCLICallbacks size_t serial_log_remaining = 0; size_t serial_log_pending_size = 0; char serial_log_pending[640]; + int serial_acl_next = -1; + int serial_acl_count = 0; + int serial_recent_next = -1; + int serial_recent_count = 0; + SimpleMeshTables::RecentRepeaterInfo serial_recent_cursor; + int serial_recent_cursor_index = -1; bool serial_log_active = false; bool serial_log_eof_pending = false; bool serial_log_skip_line = false; - int serial_acl_next = -1; - int serial_acl_count = 0; bool serial_acl_header = false; - int serial_recent_next = -1; - int serial_recent_count = 0; bool serial_recent_header = false; bool serial_recent_has_cursor = false; - SimpleMeshTables::RecentRepeaterInfo serial_recent_cursor; - int serial_recent_cursor_index = -1; +#endif + // Runtime only: group scalar timers and flags to avoid gaps before aligned + // members. No persisted preferences or queue capacities depend on this order. + uint64_t uptime_millis; + uint64_t last_battery_alert_sent; +#if MESH_ENABLE_HOST_CLI + uint64_t host_cli_nonce; + uint64_t host_cli_claim_challenge; #endif uint32_t last_millis; - uint64_t uptime_millis; unsigned long next_local_advert, next_flood_advert; + uint32_t primary_radio_mutation_generation = 0; + uint32_t radio_reply_deadline = 0; +#if MESH_ENABLE_HOST_CLI + unsigned long host_cli_deadline; + unsigned long host_cli_claim_emit_at; +#endif + uint32_t pending_self_advert_delay; + unsigned long next_battery_alert_check; + unsigned long next_recent_repeater_sweep; mesh::DeferredCliCommand deferred_cli_command; mesh::RemoteCliReplyCache remote_cli_reply_cache; mesh::ReplayResetNonce replay_reset_nonce; - bool replay_clock_set = false; mesh::TempRadioReplyBarrier temp_radio_reply_barrier; - uint32_t primary_radio_mutation_generation = 0; - bool primary_radio_mutation_starts_temp = false; - uint32_t radio_reply_deadline = 0; - bool radio_reply_secondary = false; void finishRadioReply(bool delivered); void serviceRadioReplyDeadline(); TransportKey deferred_cli_reply_scope; + mesh::RxInactivityWatchdog rx_inactivity_watchdog; + mesh::RepeaterRadioTiming radio_timing; + mesh::Packet* pending_battery_alert_packet; + bool replay_clock_set = false; + bool primary_radio_mutation_starts_temp = false; + bool radio_reply_secondary = false; bool deferred_cli_reply_scoped; #if MESH_ENABLE_HOST_CLI bool host_cli_waiting; bool host_cli_claimed; bool host_cli_claim_emit; - unsigned long host_cli_deadline; - unsigned long host_cli_claim_emit_at; - uint64_t host_cli_nonce; - uint64_t host_cli_claim_challenge; #endif - uint32_t pending_self_advert_delay; bool pending_self_advert; bool pending_self_advert_flood; - unsigned long next_battery_alert_check; - mesh::RxInactivityWatchdog rx_inactivity_watchdog; - mesh::RepeaterRadioTiming radio_timing; - unsigned long next_recent_repeater_sweep; - uint64_t last_battery_alert_sent; - mesh::Packet* pending_battery_alert_packet; bool battery_alert_sent; bool _logging; #ifdef DISPLAY_ACTIVITY_DASHBOARD diff --git a/src/helpers/CommonCLI.h b/src/helpers/CommonCLI.h index 8fcbdc448..c46f4c52d 100644 --- a/src/helpers/CommonCLI.h +++ b/src/helpers/CommonCLI.h @@ -944,7 +944,6 @@ class CommonCLI { #if MESH_ENABLE_EMERGENCY_POWER FILESYSTEM* _emergency_power_fs = nullptr; mesh::emergencypower::State _emergency_power; - bool _emergency_radio_sleeping = false; void loadEmergencyPower(FILESYSTEM* fs); bool saveEmergencyPower(const mesh::emergencypower::Settings& settings); void loopEmergencyPower(); @@ -968,7 +967,6 @@ class CommonCLI { SensorManager* _sensors; RegionMap* _region_map; uint32_t _media_regions_retry_ms = 0; - bool _media_regions_ready = false; static bool mediaMeshRegionStoreMissing(FILESYSTEM* fs); void recoverMediaMeshRegions(); static bool updateMediaMeshScope(void* context, bool enable, bool commit); @@ -989,6 +987,12 @@ class CommonCLI { bool _observer_save_result_known = false; bool _observer_save_succeeded = false; #endif + // Runtime flags share padding; persisted NodePrefs retains its own layout. + bool _media_regions_ready = false; +#if MESH_ENABLE_EMERGENCY_POWER + bool _emergency_radio_sleeping = false; +#endif + uint8_t _rx_settings_pending = 0; bool _com_prefs_needs_upgrade = false; // old-format legacy prefs detected; rewrite once after load bool _common_save_result_known = false; bool _common_save_succeeded = false; @@ -1004,7 +1008,6 @@ class CommonCLI { RxGain = 1, RxPowerSaving = 2, RxSideDetectors = 4, RxRfRx = 8, RxPendingMask = 15, RxRfRxDisabled = 128 }; - uint8_t _rx_settings_pending = 0; uint32_t _rx_settings_retry_at = 0; uint32_t _prefs_save_failures = 0; mesh::RadioProfileCLI _radio_profiles; diff --git a/src/helpers/EmergencyPowerPolicy.h b/src/helpers/EmergencyPowerPolicy.h index 58bdbce70..7af46aa66 100644 --- a/src/helpers/EmergencyPowerPolicy.h +++ b/src/helpers/EmergencyPowerPolicy.h @@ -44,18 +44,19 @@ inline bool decode(const uint8_t* in, size_t size, Settings& settings) { } struct State { - Settings settings; + // Runtime only: keep aligned timers together without packing accesses. uint32_t sampledAt = 0; - uint16_t millivolts = 0; - uint8_t percentage = 0; - bool sampled = false; - bool valid = false; - bool lowBattery = false; uint32_t phaseMs = 0; uint32_t lastTick = 0; uint32_t activationStarted = 0; uint32_t awakeStarted = 0; uint32_t awakeDuration = 0; + uint16_t millivolts = 0; + uint8_t percentage = 0; + Settings settings; + bool sampled = false; + bool valid = false; + bool lowBattery = false; bool activationHold = false; void begin(const Settings& loaded, uint32_t now) { @@ -118,5 +119,7 @@ struct State { return phase < AwakeWindowMs ? 0 : HourMs - phase; } }; +static_assert(sizeof(State) <= 32, "Emergency timers must retain their compact RAM layout"); +static_assert(alignof(State) >= alignof(uint32_t), "Emergency timers must remain aligned"); }} // namespace mesh::emergencypower diff --git a/test/test_emergency_power.py b/test/test_emergency_power.py index bf74e3d12..da6f84029 100644 --- a/test/test_emergency_power.py +++ b/test/test_emergency_power.py @@ -174,6 +174,7 @@ class EmergencyPowerTests(unittest.TestCase): start = header.index(marker) return header[start:header.index("#endif", start) + len("#endif")] private = api_block("#if MESH_ENABLE_EMERGENCY_POWER\n FILESYSTEM* _emergency_power_fs") + private += "\n" + api_block("#if MESH_ENABLE_EMERGENCY_POWER\n bool _emergency_radio_sleeping") public = api_block("#if MESH_ENABLE_EMERGENCY_POWER\n uint32_t emergencySleepMilliseconds") implementation = (ROOT / "src/helpers/CommonCLI_Emergency.cpp").read_text(encoding="ascii") implementation = re.sub(r"^#include[^\n]*\n", "", implementation, flags=re.MULTILINE) From 8c2ddc9ac7e96fa332a0a2757e0534dc5b4ea2c6 Mon Sep 17 00:00:00 2001 From: mikecarper Date: Wed, 28 Oct 2026 05:03:29 -0700 Subject: [PATCH 27/61] Coalesce repeater clock and radio schedule runtime fields --- examples/simple_repeater/MyMesh.h | 30 +++++++++++++++--------------- 1 file changed, 15 insertions(+), 15 deletions(-) diff --git a/examples/simple_repeater/MyMesh.h b/examples/simple_repeater/MyMesh.h index ddd0155cd..9f93578bb 100644 --- a/examples/simple_repeater/MyMesh.h +++ b/examples/simple_repeater/MyMesh.h @@ -559,10 +559,10 @@ class MyMesh : public mesh::Mesh, public CommonCLICallbacks // is 0 when the allocation failed: every loop below is bounded by it, so a // zero-capacity node simply forwards without rule filtering. FloodPacketFilterEntry* flood_packet_filters; - uint8_t flood_packet_filter_slots; #if MESH_ENABLE_FLOOD_RULE_ENGINE uint32_t flood_filter_probability_seed; #endif + uint8_t flood_packet_filter_slots; uint8_t flood_packet_filter_blacklist_count; uint8_t flood_packet_filter_blacklist[FLOOD_PACKET_FILTER_BLACKLIST_MAX] [FLOOD_PACKET_FILTER_PATH_ID_SIZE]; @@ -651,6 +651,14 @@ class MyMesh : public mesh::Mesh, public CommonCLICallbacks uint8_t flood_channel_data_rule_max_hops; #endif ClockSyncSample clock_sync_samples[CLOCK_SYNC_SAMPLE_SLOTS]; + uint64_t clock_sync_next_attempt_uptime; + uint32_t clock_sync_drift_seconds; + uint32_t clock_sync_last_estimate; + uint32_t clock_sync_last_abs_drift; + uint32_t clock_sync_internet_requested_millis; + uint32_t pending_discover_tag; + unsigned long pending_discover_until; + unsigned long dirty_contacts_expiry; bool clock_sync_mesh_enabled; bool clock_sync_mesh_edge_enabled; bool clock_sync_internet_enabled; @@ -664,15 +672,7 @@ class MyMesh : public mesh::Mesh, public CommonCLICallbacks uint8_t clock_sync_last_fresh_count; uint8_t clock_sync_last_required_count; uint8_t clock_sync_required_samples; - uint32_t clock_sync_drift_seconds; - uint32_t clock_sync_last_estimate; - uint32_t clock_sync_last_abs_drift; - uint32_t clock_sync_internet_requested_millis; - uint64_t clock_sync_next_attempt_uptime; - uint32_t pending_discover_tag; - unsigned long pending_discover_until; bool region_load_active; - unsigned long dirty_contacts_expiry; uint8_t contacts_save_failures; #if MAX_NEIGHBOURS NeighbourInfo neighbours[MAX_NEIGHBOURS]; @@ -697,19 +697,19 @@ class MyMesh : public mesh::Mesh, public CommonCLICallbacks #endif unsigned long _ota_update_at = 0; // deferred `ota update` fire time (0 = none scheduled) float active_bw; // live BW, including temporary radio overrides + uint32_t next_scheduled_radio_time; + unsigned long next_scheduled_radio_check_at; + uint32_t scheduled_temp_radio_end_time; + unsigned long scheduled_temp_radio_end_check_at; + unsigned long scheduled_radio_retry_at; + unsigned long scheduled_radio_save_retry_at; uint8_t active_sf; // live SF, including temporary radio overrides uint8_t active_cr; // live CR, including temporary radio overrides bool saved_radio_apply_pending; bool temp_radio_handoff_pending; bool temp_radio_applied; bool scheduled_temp_radio_started; - uint32_t next_scheduled_radio_time; - unsigned long next_scheduled_radio_check_at; - uint32_t scheduled_temp_radio_end_time; - unsigned long scheduled_temp_radio_end_check_at; bool scheduled_temp_radio_end_check_final; - unsigned long scheduled_radio_retry_at; - unsigned long scheduled_radio_save_retry_at; uint8_t scheduled_radio_retry_failures; ScheduledRadioSetting scheduled_radio_settings[MAX_SCHEDULED_RADIO_SETTINGS]; #if MESH_ENABLE_FLEET_CONTROL From c9acec474bb9617d4f92dbdc0401765701989288 Mon Sep 17 00:00:00 2001 From: mikecarper Date: Wed, 28 Oct 2026 05:07:58 -0700 Subject: [PATCH 28/61] Record final emergency power and constrained-profile build checks --- docs/cli_commands.md | 5 + .../emergency_power_2026-10-28.md | 28 ++ .../emergency_power_2026-10-28.safe.json | 276 ++++++++++++++++++ 3 files changed, 309 insertions(+) diff --git a/docs/cli_commands.md b/docs/cli_commands.md index b01f84a72..ba2821332 100644 --- a/docs/cli_commands.md +++ b/docs/cli_commands.md @@ -69,6 +69,11 @@ See [CLI Availability by Firmware Build](cli_build_matrix.md) for the role and profile matrix. Commands depend on compiled features; some portable builds omit WebConfig while retaining the complete role CLI and compact WiFi updater. +Small-flash Mini and STM32 Companion profiles, plus RAK4631 Full Companion, +use shorter human-readable validation replies. Command syntax, returned +values, protocol acknowledgements and destructive-operation warnings stay +the same. + See [CLI Command Availability Matrix](cli_command_availability.md) for the command-by-command nRF52 and ESP32 build tables. diff --git a/docs/test-results/emergency_power_2026-10-28.md b/docs/test-results/emergency_power_2026-10-28.md index b45c06a2f..79deed605 100644 --- a/docs/test-results/emergency_power_2026-10-28.md +++ b/docs/test-results/emergency_power_2026-10-28.md @@ -81,6 +81,34 @@ The [safe machine-readable report](emergency_power_2026-10-28.safe.json) records ELF, package and manifest hashes. Package hashes were independently compared with every corresponding memory-manifest entry. +### Integration checks + +Eight further normal builds at +[`8c2ddc9ac`](https://github.com/mikecarper/MeshCore/commit/8c2ddc9ac) +pass after the concurrent USB-mode and MediaMesh changes. Constrained profiles +use shorter human validation replies and help descriptions; commands, returned +values, protocol acknowledgements and destructive-operation warnings remain. +Runtime timers and flags are grouped to remove alignment padding. Saved-record +layouts, features and filesystem capacity are preserved. + +| Firmware | Linked flash / limit, bytes | Available / required RAM, bytes | +| --- | ---: | ---: | +| RAK4631 Repeater Full | 687,512 / 815,104 | 83,156 / 83,136 | +| RAK4631 Repeater Reduced | 620,020 / 815,104 | 85,696 / 81,088 | +| Wio E5 Mini Repeater | 245,260 / 245,760 | 43,880 / 32,288 | +| RAK4631 Full Companion | 712,092 / 712,704 | 81,284 / 74,576 | +| RAK3x72 USB Companion | 228,924 / 229,376 | 24,448 / 17,408 | +| Wio E5 Mini USB Companion | 228,816 / 229,376 | 24,424 / 17,408 | +| Wio E5 USB Companion | 228,892 / 229,376 | 24,456 / 17,408 | +| TinyRelay USB Companion | 228,992 / 229,376 | 24,448 / 17,408 | + +Linked flash counts exclude packaging and alignment overhead; normal linker +and package checks also pass. The Mini retains its 16 KB filesystem. Native +checks compare the reviewed reply substitutions and unchanged C++ control +flow. The final RAK Companion ELF passes both RFC8032 vectors with a 1,904-byte +signing frame under the 2,048-byte limit, plus four OTA workspace lifetime checks. +These Companions do not gain the infrastructure emergency-sleep feature. + ## Physical trials Each board entered manual radio sleep, woke for a real one-minute override, diff --git a/docs/test-results/emergency_power_2026-10-28.safe.json b/docs/test-results/emergency_power_2026-10-28.safe.json index 2a1a8e86c..167502a97 100644 --- a/docs/test-results/emergency_power_2026-10-28.safe.json +++ b/docs/test-results/emergency_power_2026-10-28.safe.json @@ -509,5 +509,281 @@ "real_five_percent_battery_qualified": false, "continuous_45_minute_sleep_qualified": false, "bench_source_sha256": "2c4799cb239f9d092d5cf46c0366408aa95f766f786f56af4b913e07b636418f" + }, + "integration_builds": [ + { + "artifact_target": "RAK_4631_repeater_lora_ota_no_external_sensors-full-ota", + "pio_environment": "RAK_4631_repeater", + "source_commit": "8c2ddc9ac", + "passed": true, + "linked_flash_bytes": 687512, + "flash_limit_bytes": 815104, + "capability_checks": { + "passed": 16, + "total": 16 + }, + "ota_update_verified": true, + "memory": { + "passed": true, + "elf_sha256": "a5a590f54660ca2b0c57bcc8f5400659fe46b4f3dd379ad0b2ab924c63519512", + "available_internal_bytes": 83156, + "largest_internal_region_bytes": 83156, + "required_heap_bytes": 83136, + "required_contiguous_bytes": 19168 + }, + "memory_manifest_sha256": "a9f6ab28eeed7138b9f38f483c69db031491cfd91859ae05374acdff46bf1a45", + "capabilities_manifest_sha256": "46615a33db71a170d2be3a5c52274f7e1e2c5d256f681ca2d324d96a643b2e34", + "artifact_files": { + "RAK_4631_repeater_lora_ota_no_external_sensors-full-ota-v1.18.0.1-halo-keymind-cascade-dev-8c2ddc9a.uf2": { + "sha256": "81c4d857839f7b94ffa472a3a2ad6eee213e2cf45831bbd24fdb5e33c8afe16a", + "size_bytes": 1375232 + }, + "RAK_4631_repeater_lora_ota_no_external_sensors-full-ota-v1.18.0.1-halo-keymind-cascade-dev-8c2ddc9a.zip": { + "sha256": "8b1a3ba59a5ff684439f376e8169746e3aff19c081dfec763a20038dfc8a6241", + "size_bytes": 688395 + } + } + }, + { + "artifact_target": "RAK_4631_repeater_lora_ota_no_external_sensors-reduced-ota", + "pio_environment": "RAK_4631_repeater", + "source_commit": "8c2ddc9ac", + "passed": true, + "linked_flash_bytes": 620020, + "flash_limit_bytes": 815104, + "capability_checks": { + "passed": 14, + "total": 14 + }, + "ota_update_verified": true, + "memory": { + "passed": true, + "elf_sha256": "59c70ac3c4f22650d2c5a0739624e5e29a3a61b7abfda3614e4171a476c9f5c7", + "available_internal_bytes": 85696, + "largest_internal_region_bytes": 85696, + "required_heap_bytes": 81088, + "required_contiguous_bytes": 19168 + }, + "memory_manifest_sha256": "fa7b6eb7d08aa0a74641d3e691c407d7699c68689b40fe84a80bc78be579709e", + "capabilities_manifest_sha256": "c3804a710f03f5e7b3ac3404830de418e370ce813bd5279a9eb3350b056052f4", + "artifact_files": { + "RAK_4631_repeater_lora_ota_no_external_sensors-reduced-ota-v1.18.0.1-halo-keymind-cascade-dev-8c2ddc9a.uf2": { + "sha256": "dcfad8950921952ee9360e255f62762c79ecb536750eca8d297f7409af07895e", + "size_bytes": 1240576 + }, + "RAK_4631_repeater_lora_ota_no_external_sensors-reduced-ota-v1.18.0.1-halo-keymind-cascade-dev-8c2ddc9a.zip": { + "sha256": "0a3842418293cae5ea1281fb30a76c376737a258521ed9081d7229c6a5acaa32", + "size_bytes": 620903 + } + } + }, + { + "artifact_target": "wio-e5-mini_repeater", + "pio_environment": "wio-e5-mini_repeater", + "source_commit": "8c2ddc9ac", + "passed": true, + "linked_flash_bytes": 245260, + "flash_limit_bytes": 245760, + "capability_checks": { + "passed": 1, + "total": 1 + }, + "ota_update_verified": false, + "memory": { + "passed": true, + "elf_sha256": "bf433b151be10a89ca6a04bea98ba7692999743eee0406bc95a55436db120dd2", + "available_internal_bytes": 43880, + "largest_internal_region_bytes": 43880, + "required_heap_bytes": 32288, + "required_contiguous_bytes": 10240 + }, + "memory_manifest_sha256": "f4f244b033e1538445aee5c32de9381e393e63dd4f4c137a701bf17fcc15b8c2", + "capabilities_manifest_sha256": "8f65ac10ba9398904097eb525d179be5a8b9884fe140479eeba2e0e93f886044", + "artifact_files": { + "wio-e5-mini_repeater-v1.18.0.1-halo-keymind-cascade-dev-8c2ddc9a.bin": { + "sha256": "da39a4dd6028faa014541d8baae8b1079c9ff16384aeb8cb04b54ed375f494f1", + "size_bytes": 245608 + }, + "wio-e5-mini_repeater-v1.18.0.1-halo-keymind-cascade-dev-8c2ddc9a.hex": { + "sha256": "d6787f7c1435cb7f0d6a21acc5c115f135af534466745d4c4c16ae3d3dd5e457", + "size_bytes": 690891 + } + } + }, + { + "artifact_target": "RAK_4631_companion_radio_full", + "pio_environment": "RAK_4631_companion_radio_full", + "source_commit": "8c2ddc9ac", + "passed": true, + "linked_flash_bytes": 712092, + "flash_limit_bytes": 712704, + "capability_checks": { + "passed": 15, + "total": 15 + }, + "ota_update_verified": true, + "memory": { + "passed": true, + "elf_sha256": "a931669843dce4536a067f28705149da5190d086470c09e195c5576fa985afef", + "available_internal_bytes": 81284, + "largest_internal_region_bytes": 81284, + "required_heap_bytes": 74576, + "required_contiguous_bytes": 11008 + }, + "memory_manifest_sha256": "120a9b53156b581edd67ddc049c774e15a33e751c50990281ec3b8df425da6ab", + "capabilities_manifest_sha256": "520af68ff60e2e1c8dbfe6da1313f5e067094958fb966d4dd66d764615fb2b1d", + "artifact_files": { + "RAK_4631_companion_radio_full-v1.18.0.1-halo-keymind-cascade-dev-8c2ddc9a.uf2": { + "sha256": "4666c141fed3fa20ad92e57ab39fa839b34bb5db05385202e564220fe8c56bbd", + "size_bytes": 1424384 + }, + "RAK_4631_companion_radio_full-v1.18.0.1-halo-keymind-cascade-dev-8c2ddc9a.zip": { + "sha256": "951bb37464998e455c0a6032a3779fe072268babfc161fd04aee8fbcae39aa7e", + "size_bytes": 712975 + } + } + }, + { + "artifact_target": "RAK_3x72_companion_radio_usb", + "pio_environment": "RAK_3x72_companion_radio_usb", + "source_commit": "8c2ddc9ac", + "passed": true, + "linked_flash_bytes": 228924, + "flash_limit_bytes": 229376, + "capability_checks": { + "passed": 0, + "total": 0 + }, + "ota_update_verified": false, + "memory": { + "passed": true, + "elf_sha256": "6ced0f235f78bc90f178aabd2c31241d64df4bd86f131b6c6d37a4e213205ba1", + "available_internal_bytes": 24448, + "largest_internal_region_bytes": 24448, + "required_heap_bytes": 17408, + "required_contiguous_bytes": 5120 + }, + "memory_manifest_sha256": "7200af66aed34e13a123fc4a683a95f3bb74de40622c90c524af998a9a0135d9", + "capabilities_manifest_sha256": "8c602dc990fbfcea540a0a05d44747aa701bd61d08f92d0b12045b7f883ceff1", + "artifact_files": { + "RAK_3x72_companion_radio_usb-v1.18.0.1-halo-keymind-cascade-dev-8c2ddc9a.bin": { + "sha256": "411324849a06570bed4bc500c6b3c0ec977ef00870a02124194d1f79522b8565", + "size_bytes": 229276 + }, + "RAK_3x72_companion_radio_usb-v1.18.0.1-halo-keymind-cascade-dev-8c2ddc9a.hex": { + "sha256": "a9afd0bae4923669596cc58b21dce8d42687bd42c287482d7e1339e60a659308", + "size_bytes": 644946 + } + } + }, + { + "artifact_target": "wio-e5-mini_companion_radio_usb", + "pio_environment": "wio-e5-mini_companion_radio_usb", + "source_commit": "8c2ddc9ac", + "passed": true, + "linked_flash_bytes": 228816, + "flash_limit_bytes": 229376, + "capability_checks": { + "passed": 0, + "total": 0 + }, + "ota_update_verified": false, + "memory": { + "passed": true, + "elf_sha256": "2ddb4009fb776c09ce69da048655964b3a6e54e18f2f630f5af5f6d113b11b3e", + "available_internal_bytes": 24424, + "largest_internal_region_bytes": 24424, + "required_heap_bytes": 17408, + "required_contiguous_bytes": 5120 + }, + "memory_manifest_sha256": "0fecade0c4edc49f0263ec72c383ee7de0ca4a4e08950b2300e4b679776f1520", + "capabilities_manifest_sha256": "c993c9a05190aad3dfefdf5e6071d73b8bd804ff6d1aba6636008c6bcd4ad9dd", + "artifact_files": { + "wio-e5-mini_companion_radio_usb-v1.18.0.1-halo-keymind-cascade-dev-8c2ddc9a.bin": { + "sha256": "a7ab04710b8aa979fab7b61ab3e0fb2a6dc7d205189907c9f3609da412bf5f79", + "size_bytes": 229168 + }, + "wio-e5-mini_companion_radio_usb-v1.18.0.1-halo-keymind-cascade-dev-8c2ddc9a.hex": { + "sha256": "c0308378d6f69ae8de24176d3730b0465243f23a92d1ffca634e9503cb3d2b49", + "size_bytes": 644639 + } + } + }, + { + "artifact_target": "wio-e5_companion_radio_usb", + "pio_environment": "wio-e5_companion_radio_usb", + "source_commit": "8c2ddc9ac", + "passed": true, + "linked_flash_bytes": 228892, + "flash_limit_bytes": 229376, + "capability_checks": { + "passed": 0, + "total": 0 + }, + "ota_update_verified": false, + "memory": { + "passed": true, + "elf_sha256": "e0de0678e0d0f19207d56a617b60c89e036a9b0fac5c4927c1308869ab806646", + "available_internal_bytes": 24456, + "largest_internal_region_bytes": 24456, + "required_heap_bytes": 17408, + "required_contiguous_bytes": 5120 + }, + "memory_manifest_sha256": "9ee86224a19ff34c4cf93c1e9b69d93dd46f7787d67dbd8bc22d930a58e48961", + "capabilities_manifest_sha256": "0c546a1a946a3c331ddb32ee3df304a161fa51cdcb37e63b19184de8d38c4924", + "artifact_files": { + "wio-e5_companion_radio_usb-v1.18.0.1-halo-keymind-cascade-dev-8c2ddc9a.bin": { + "sha256": "cb984309506ec4ecb3a892208720c116679a9f3f5d8c72b662be78f52cf060cd", + "size_bytes": 229244 + }, + "wio-e5_companion_radio_usb-v1.18.0.1-halo-keymind-cascade-dev-8c2ddc9a.hex": { + "sha256": "19de565271061002d74651cb8de4d8d440ec54eb64aa3ccf127c60b5f8dec459", + "size_bytes": 644869 + } + } + }, + { + "artifact_target": "Tiny_Relay_companion_radio_usb", + "pio_environment": "Tiny_Relay_companion_radio_usb", + "source_commit": "8c2ddc9ac", + "passed": true, + "linked_flash_bytes": 228992, + "flash_limit_bytes": 229376, + "capability_checks": { + "passed": 0, + "total": 0 + }, + "ota_update_verified": false, + "memory": { + "passed": true, + "elf_sha256": "4289c0f88b4da7bc218288b4761006829b52babfaa80b740fe5e50f170e561c7", + "available_internal_bytes": 24448, + "largest_internal_region_bytes": 24448, + "required_heap_bytes": 17408, + "required_contiguous_bytes": 5120 + }, + "memory_manifest_sha256": "1bc96e6770725262a2db53de4963de6b3d308e552c2cb57cc37ba5fcec462fe1", + "capabilities_manifest_sha256": "9fa181658288ea207c14fdccd059c1020c0778518deafe664ead19eab8062de2", + "artifact_files": { + "Tiny_Relay_companion_radio_usb-v1.18.0.1-halo-keymind-cascade-dev-8c2ddc9a.bin": { + "sha256": "53d2b0ffe4f1ed89bb04e3dd35ca617d9a428f81465970076d06c9d00f8dceab", + "size_bytes": 229344 + }, + "Tiny_Relay_companion_radio_usb-v1.18.0.1-halo-keymind-cascade-dev-8c2ddc9a.hex": { + "sha256": "505741dec9122439f72a27ca1f6937ed1d2e6b653fb3eb364a2b8cb3f673f032", + "size_bytes": 645134 + } + } + } + ], + "integration_scope": "Eight normal capacity-checked builds following concurrent USB-mode and MediaMesh changes. Only human CLI prose is shortened in constrained profiles; feature and filesystem scope, command syntax, structured values, protocol acknowledgements and destructive warnings remain. RAM-only timer/flag grouping removes padding without changing serialized layouts. Linked flash counts exclude packaging and alignment overhead; normal link/package checks passed.", + "additional_arm_checks": { + "source_commit": "8c2ddc9ac", + "elf_sha256": "a931669843dce4536a067f28705149da5190d086470c09e195c5576fa985afef", + "crypto_rfc8032_vectors_passed": 2, + "crypto_sign_frame_bytes": 1904, + "crypto_frame_limit_bytes": 2048, + "ota_workspace_lifetimes_passed": 4, + "scope": "Linked ARM execution, not physical timing or load qualification." } } From ba3fcc2cabb6554fd0973a14ea99bbf72e294641 Mon Sep 17 00:00:00 2001 From: mikecarper Date: Wed, 28 Oct 2026 05:34:58 -0700 Subject: [PATCH 29/61] Align clock and console fixtures with current CLI helpers --- test/fixtures/morse_main_loop.cpp | 1 + test/fixtures/official_web_console/firmware.cpp | 1 + test/test_fleet_clock_control.py | 1 + test/test_host_kiss_infrastructure.py | 1 + test/test_official_config_gui.py | 14 ++++++++++---- 5 files changed, 14 insertions(+), 4 deletions(-) diff --git a/test/fixtures/morse_main_loop.cpp b/test/fixtures/morse_main_loop.cpp index da6f1fb93..1435c2ec0 100644 --- a/test/fixtures/morse_main_loop.cpp +++ b/test/fixtures/morse_main_loop.cpp @@ -34,6 +34,7 @@ bool isUsbLoggingWatchdogArmed() { return usb_watchdog_armed; } bool serviceUsbLoggingWatchdog(bool(*)()) { ++usb_services; return false; } Stream& usbConsolePort() { return console; } void noteUsbLoggingStatsCommand(const char*) { ++usb_services; } +void noteUsbConsoleCommand(const char*) { ++usb_services; } namespace wireless { struct Control { void service(uint32_t) { ++transport_services; } bool pending() { return wireless_pending; } } controller; Control& control() { return controller; } diff --git a/test/fixtures/official_web_console/firmware.cpp b/test/fixtures/official_web_console/firmware.cpp index c755caab8..d092cee97 100644 --- a/test/fixtures/official_web_console/firmware.cpp +++ b/test/fixtures/official_web_console/firmware.cpp @@ -34,6 +34,7 @@ bool takeUsbTerminalSessionReset() { return false; } bool tryCompleteUsbTerminalSessionReset() { return true; } bool canAcceptUsbConsoleCommand() { return true; } Stream& usbConsolePort() { return console; } +void noteUsbConsoleCommand(const char*) {} void noteUsbLoggingStatsCommand(const char*) {} bool isUsbLoggingWatchdogArmed() { return false; } namespace wireless { diff --git a/test/test_fleet_clock_control.py b/test/test_fleet_clock_control.py index 6a2d6c992..3967ad8eb 100644 --- a/test/test_fleet_clock_control.py +++ b/test/test_fleet_clock_control.py @@ -22,6 +22,7 @@ ROOT = Path(__file__).resolve().parents[1] HARNESS = r''' #include +#include // Date formatting is a host peripheral adapter; it never mutates the RTC. class DateTime { diff --git a/test/test_host_kiss_infrastructure.py b/test/test_host_kiss_infrastructure.py index 75a11428d..1b99a7e19 100644 --- a/test/test_host_kiss_infrastructure.py +++ b/test/test_host_kiss_infrastructure.py @@ -50,6 +50,7 @@ bool tryCompleteUsbTerminalSessionReset() { return true; } bool canAcceptUsbConsoleCommand() { return true; } Stream& usbConsolePort() { return console; } void noteUsbLoggingStatsCommand(const char*) {} +void noteUsbConsoleCommand(const char*) {} namespace power { void serviceRegulatorSettings() {} } #if MESH_ENABLE_HOST_KISS namespace hostkiss { diff --git a/test/test_official_config_gui.py b/test/test_official_config_gui.py index 65c318e94..3a95ff5f0 100644 --- a/test/test_official_config_gui.py +++ b/test/test_official_config_gui.py @@ -191,6 +191,7 @@ def cli_source(*, lf_reply=False, reject_cr=False, platform="ESP32_PLATFORM", common = (ROOT / "src/helpers/CommonCLI.cpp").read_text() time_handler = body(common, 'if (memcmp(command, "time ", 5) == 0)') support = r''' +#include struct RTC { uint32_t utc=1715770351; uint32_t getCurrentTime(){return utc;} void setCurrentTime(uint32_t value){utc=value;} } rtc; struct ClockCallbacks { void onManualClockSet(){} } clock_callbacks; @@ -225,7 +226,8 @@ class OfficialConfigGuiTests(unittest.TestCase): def run_contract(self, *, blocking=False, lf_reply=False, reject_cr=False, extra_boot_ms=0, platform="ESP32_PLATFORM", raw_uart=False, - preconnect_idle_ms=0, lose_raw_uart_guard=False): + preconnect_idle_ms=0, lose_raw_uart_guard=False, + compact_replies=False): with tempfile.TemporaryDirectory(prefix="official-config-gui-") as directory: work = Path(directory) gui = self.sources["src/gui.js"].read_text() @@ -281,7 +283,8 @@ class OfficialConfigGuiTests(unittest.TestCase): "-DCONFIG_TINYUSB_ENABLED=0"] cli_sources = [] command = [self.compiler, "-std=c++17", "-Wall", "-Wextra", - *cli_flags, *sanitize, "-I", str(ROOT / "src"), + *cli_flags, f"-DMESH_CLI_COMPACT_REPLIES={int(compact_replies)}", + *sanitize, "-I", str(ROOT / "src"), str(cli_cpp), *cli_sources, "-o", str(cli)] built = subprocess.run(command, text=True, capture_output=True, timeout=60) self.assertEqual(built.returncode, 0, built.stdout + built.stderr) @@ -351,8 +354,11 @@ class OfficialConfigGuiTests(unittest.TestCase): # Use nRF52's compiled GPS guards, actual USB transport/callbacks and # production repeater CR command pump/time reply with DTR low. Add # setup's one-second settle; filesystem/radio timing is not invented. - result = self.run_contract(platform="NRF52_PLATFORM", extra_boot_ms=1000) - self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + for compact in (False, True): + with self.subTest(compact_replies=compact): + result = self.run_contract(platform="NRF52_PLATFORM", extra_boot_ms=1000, + compact_replies=compact) + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) def test_nrf52_old_blocking_gps_breaks_stock_gui_initial_time(self): # Reinsert the former five-second GPS wait without changing the From 70c7d5b478b19f16d232389779ca455e73be7eba Mon Sep 17 00:00:00 2001 From: mikecarper Date: Wed, 28 Oct 2026 06:27:06 -0700 Subject: [PATCH 30/61] Keep nRF USB commands responsive during emergency sleep --- src/helpers/NRF52Board.cpp | 4 ++ test/test_emergency_sleep_backends.py | 98 +++++++++++++++++++++++++++ 2 files changed, 102 insertions(+) diff --git a/src/helpers/NRF52Board.cpp b/src/helpers/NRF52Board.cpp index ca2bb253e..eeb5d772e 100644 --- a/src/helpers/NRF52Board.cpp +++ b/src/helpers/NRF52Board.cpp @@ -936,6 +936,10 @@ bool NRF52Board::emergencySleepMilliseconds(uint32_t millisecs) { || __get_FAULTMASK() != 0 || nrf_nvic_state.__cr_flag != 0 || xTaskGetSchedulerState() != taskSCHEDULER_RUNNING) return false; if (millisecs > 10000UL) millisecs = 10000UL; + // An enumerated host needs several sketch passes for cooperative CDC + // session cleanup and reply draining. Keep those passes prompt while RF + // stays asleep; detached operation retains the longer maintenance slice. + if (isUsbHostConnected() && millisecs > 100UL) millisecs = 100UL; if (NRF_WDT->RUNSTATUS != 0) { // A bootloader may have left a shorter watchdog than this build's default. // Half its actual timeout leaves room for scheduling and wake processing. diff --git a/test/test_emergency_sleep_backends.py b/test/test_emergency_sleep_backends.py index 5729bb08a..c5318708a 100644 --- a/test/test_emergency_sleep_backends.py +++ b/test/test_emergency_sleep_backends.py @@ -23,6 +23,8 @@ HARNESS = r''' static std::vector events; static bool radio_test = false, interrupt_context = false, wireless_ready = true; static bool usb_held = false; +static unsigned usb_mount_checks = 0; +[[maybe_unused]] static bool tud_mounted() { ++usb_mount_checks; return usb_held; } static bool attach_on_delay = false; static unsigned primask = 0, basepri = 0, faultmask = 0; static unsigned __get_IPSR() { return interrupt_context; } @@ -100,6 +102,7 @@ struct Base { struct NRF52Board : Base { bool watchdog_disable_reset_pending = false; bool isRadioTestActive() const { return radio_test; } + bool isUsbHostConnected(); @NRF_SUPPORT@ bool emergencySleepMilliseconds(uint32_t millisecs) override; }; @@ -109,11 +112,13 @@ struct ESP32Board : Base { @ESP_SUPPORT@ bool emergencySleepMilliseconds(uint32_t millisecs) override; }; +@NRF_USB@ @NRF_SLEEP@ @ESP_SLEEP@ static void reset() { events.clear(); radio_test = interrupt_context = false; wireless_ready = true; usb_held = false; + usb_mount_checks = 0; attach_on_delay = false; primask = basepri = faultmask = 0; nrf_nvic_state.__cr_flag = 0; scheduler = taskSCHEDULER_RUNNING; @@ -239,6 +244,7 @@ class EmergencySleepBackendTests(unittest.TestCase): ("DEFAULT_SLEEP", "core", "virtual bool emergencySleepMilliseconds("), ("NRF_RELOAD", "nrf", "static void reloadWatchdogChannels("), ("NRF_SUPPORT", "nrf_header", "bool supportsEmergencySleep() const override"), + ("NRF_USB", "nrf", "bool NRF52Board::isUsbHostConnected("), ("ESP_SUPPORT", "esp_header", "bool supportsEmergencySleep() const override"), ("NRF_SLEEP", "nrf", "bool NRF52Board::emergencySleepMilliseconds("), ("ESP_SLEEP", "esp", "bool ESP32Board::emergencySleepMilliseconds(")): @@ -262,6 +268,98 @@ class EmergencySleepBackendTests(unittest.TestCase): def test_actual_platform_waits_and_cleanup(self): self.run_code(self.platform_code()) + def test_nrf_native_usb_keeps_cooperative_maintenance_prompt_without_waking_rf(self): + runtime = extract_braced( + (ROOT / "src/helpers/EmergencyPowerRuntime.h").read_text(), + "class EmergencyPowerRuntime") + code = self.platform_code().split("int main() {", 1)[0] + r''' +static void delay(uint32_t budget) { events.push_back("backoff:" + std::to_string(budget)); } +namespace mesh { namespace wireless { +struct Control { + unsigned suspends = 0, resumes = 0; + bool suspendForEmergency() { ++suspends; return true; } + void resumeAfterEmergency() { ++resumes; } +}; +static Control transport; +static Control& control() { return transport; } +} namespace power { +@RUNTIME@; +} } +struct RuntimeBoard : NRF52Board { + bool fem_sleeping = false; + bool isOTAUpdateRunning() const { return false; } + void setEmergencyRadioSleeping(bool sleeping) { fem_sleeping = sleeping; } +}; +struct RuntimeMesh { + bool available = true; + bool hasOutbound() const { return false; } + bool isRadioAvailable() const { return available; } + void setRadioAvailable(bool value) { available = value; } +}; +struct RuntimeRadio { + unsigned sleeps = 0, wakes = 0; + bool isMorseTxActive() const { return false; } + bool isMorseRxActive() const { return false; } + bool setTrackerSleep(bool sleeping) { sleeping ? ++sleeps : ++wakes; return true; } + bool recoverRadio(bool) { assert(false); return false; } +}; +struct RuntimeCLI { + uint32_t budget = 2700000; + bool sleeping = false; + uint32_t emergencySleepMilliseconds() const { return budget; } + void setEmergencyRadioSleeping(bool value) { sleeping = value; } +}; +int main() { + reset(); usb_held = true; + RuntimeBoard board; RuntimeMesh mesh; RuntimeRadio radio; RuntimeCLI cli; + mesh::power::EmergencyPowerRuntime runtime; + // Session reset, input, reply construction and queued-output service can + // require separate sketch passes. Bound the combined CPU absence across + // those passes with the actual backend and actual RF ownership policy. + uint32_t elapsed = 0; + for (unsigned pass = 0; pass < 4; ++pass) { + assert(runtime.service(mesh, radio, board, cli, false)); + elapsed += delayed_ticks * tick_ms; + assert(delayed_ticks == 100 && elapsed <= (pass + 1) * 100); + assert(!mesh.available && cli.sleeping && board.fem_sleeping); + assert(radio.sleeps == 1 && radio.wakes == 0); + assert(cli.budget == 2700000); // No awake lease or policy-window change. + assert(mesh::wireless::transport.resumes == 0); + } + assert(usb_mount_checks == 4 && elapsed == 400); + usb_held = false; + assert(runtime.service(mesh, radio, board, cli, false)); + assert(delayed_ticks == 10000); // Recheck detach every call. + assert(!mesh.available && radio.sleeps == 1 && radio.wakes == 0); + usb_held = true; + assert(runtime.service(mesh, radio, board, cli, false)); + assert(delayed_ticks == 100 && radio.wakes == 0); + assert(mesh::wireless::transport.suspends == 1); + + reset(); usb_held = true; + assert(board.emergencySleepMilliseconds(37) && delayed_ticks == 37); + reset(); usb_held = true; nrf_watchdog.RUNSTATUS = 1; nrf_watchdog.RREN = 0x82; + assert(board.emergencySleepMilliseconds(UINT32_MAX)); + assert(delayed_ticks == 100 && nrf_feeds == 4); + reset(); usb_held = true; nrf_watchdog.RUNSTATUS = 1; nrf_watchdog.CRV = 32767; + assert(!board.emergencySleepMilliseconds(10000) && events.empty()); + reset(); usb_held = true; tick_ms = 1000; + assert(!board.emergencySleepMilliseconds(10000) && events.empty()); +} +'''.replace("@RUNTIME@", runtime) + self.run_code(code, ("-DUSE_TINYUSB=1",)) + + def test_nrf_without_native_usb_retains_long_sleep_slice(self): + code = self.platform_code().split("int main() {", 1)[0] + r''' +int main() { + reset(); usb_held = true; + NRF52Board board; + assert(board.emergencySleepMilliseconds(10000)); + assert(delayed_ticks == 10000 && usb_mount_checks == 0); +} +''' + self.run_code(code) + def test_native_usb_timer_wake_reserves_enumeration_service_without_extending_budget(self): code = self.platform_code().split("int main() {", 1)[0] + r''' int main() { From 69c612bf354d64f107d4526f1c0e59b328226657 Mon Sep 17 00:00:00 2001 From: mikecarper Date: Wed, 28 Oct 2026 06:59:53 -0700 Subject: [PATCH 31/61] Include CLI reply helpers in moderation and retry fixtures --- test/test_flood_moderation.py | 1 + test/test_retry_cr_presets.py | 2 ++ 2 files changed, 3 insertions(+) diff --git a/test/test_flood_moderation.py b/test/test_flood_moderation.py index 44645b20b..21b41c5bf 100644 --- a/test/test_flood_moderation.py +++ b/test/test_flood_moderation.py @@ -13,6 +13,7 @@ from test_serial_wifi_sessions import SANITIZER_FLAGS ROOT = Path(__file__).resolve().parents[1] HARNESS = r''' +#include #include #include #include diff --git a/test/test_retry_cr_presets.py b/test/test_retry_cr_presets.py index fe10fd635..5b34d6e7f 100644 --- a/test/test_retry_cr_presets.py +++ b/test/test_retry_cr_presets.py @@ -34,6 +34,7 @@ class RetryCrPresetsTest(unittest.TestCase): #include #include #include +#include "src/helpers/CLIReplyText.h" #define constrain(x, low, high) ((x) < (low) ? (low) : (x) > (high) ? (high) : (x)) struct NodePrefs { uint8_t retry_preset, direct_retry_attempts, direct_retry_enabled; @@ -150,6 +151,7 @@ int main() { source.write_text(generated, encoding="ascii") binary = work / "retry-cr" result = subprocess.run([compiler, "-std=c++17", "-Wall", "-Wextra", + "-I", str(ROOT), str(source), "-o", str(binary)], capture_output=True, text=True, timeout=60) self.assertEqual(result.returncode, 0, result.stdout + result.stderr) From 939ebc3e956ac4265fb39c4ece3c7e705455ce22 Mon Sep 17 00:00:00 2001 From: mikecarper Date: Wed, 28 Oct 2026 07:29:44 -0700 Subject: [PATCH 32/61] Refresh extracted CLI and channel rollback fixtures --- test/fixtures/webconfig_password_command.cpp | 3 +++ test/test_companion_traffic_routing.py | 1 + test/test_nrf52_extrafs_contract.py | 25 +++++++++++++------- test/test_repeater_guest_access.py | 1 + 4 files changed, 22 insertions(+), 8 deletions(-) diff --git a/test/fixtures/webconfig_password_command.cpp b/test/fixtures/webconfig_password_command.cpp index 45a43cd44..0f1f911ad 100644 --- a/test/fixtures/webconfig_password_command.cpp +++ b/test/fixtures/webconfig_password_command.cpp @@ -10,6 +10,9 @@ #define ESP32_PLATFORM #define ESP_PLATFORM #define WIFI_SSID "" +struct HardwareSerial {}; +HardwareSerial Serial; +#include #include #include #include diff --git a/test/test_companion_traffic_routing.py b/test/test_companion_traffic_routing.py index 6a38d46cf..5a41975e2 100644 --- a/test/test_companion_traffic_routing.py +++ b/test/test_companion_traffic_routing.py @@ -142,6 +142,7 @@ int main() { METHOD_HARNESS = r''' +#include #include #include #include diff --git a/test/test_nrf52_extrafs_contract.py b/test/test_nrf52_extrafs_contract.py index 47a56258d..bf574a532 100644 --- a/test/test_nrf52_extrafs_contract.py +++ b/test/test_nrf52_extrafs_contract.py @@ -1113,27 +1113,36 @@ class Nrf52ExtraFsContractTest(unittest.TestCase): with self.subTest(key_bytes=key_bytes): channel_update = function_body(mesh, f"if (cmd_frame[0] == CMD_SET_CHANNEL && len >= 2 + 32 + {key_bytes})") + if key_bytes == 16: + self.assertEqual(channel_update.strip(), + "handleSetChannelFrame(cmd_frame);") + channel_update = function_body(mesh, + "void MyMesh::handleSetChannelFrame(") snapshot_at = channel_update.index( - "getChannelForSave(channel_idx, previous)" + "const bool have_previous = getChannelForSave(channel_idx, previous)" + if key_bytes == 16 else "getChannelForSave(channel_idx, previous)" ) self.assertNotIn("getChannel(channel_idx, previous)", channel_update) mutate_at = channel_update.index( "setChannel(channel_idx, channel)" ) save_at = channel_update.index("saveChannels()") - rollback_at = channel_update.index( - "setChannel(channel_idx, previous)", save_at - ) - error_at = channel_update.index( - "writeErrFrame(ERR_CODE_FILE_IO_ERROR)", rollback_at - ) + # The legacy handler also compensates a failed radio-preset + # commit. Inspect the channel-save failure branch itself so + # that separate rollback cannot mask a missing ordinary one. + save_failure = function_body(channel_update, + "} else {" if key_bytes == 16 + else "else if (!saveChannels())") + rollback_at = save_failure.index("setChannel(channel_idx, previous)") + error_at = save_failure.index( + "writeErrFrame(ERR_CODE_FILE_IO_ERROR)") ok_at = channel_update.index("writeOKFrame()") self.assertLess(snapshot_at, mutate_at) self.assertLess(mutate_at, save_at) self.assertLess(save_at, ok_at) - self.assertLess(save_at, rollback_at) self.assertLess(rollback_at, error_at) + self.assertNotIn("writeOKFrame()", save_failure) self.assertEqual(channel_update.count("writeOKFrame()"), 1) def test_absent_advert_delete_avoids_bucket_rewrite_without_legacy_cache(self): diff --git a/test/test_repeater_guest_access.py b/test/test_repeater_guest_access.py index bc3680263..8221398f7 100644 --- a/test/test_repeater_guest_access.py +++ b/test/test_repeater_guest_access.py @@ -15,6 +15,7 @@ ROOT = Path(__file__).resolve().parents[1] FIXTURE = ROOT / "test/fixtures/guest_login" HARNESS = r''' +#include #include #include #include From 226ccc332d29e78c3dc69cdb70a228baca403711 Mon Sep 17 00:00:00 2001 From: mikecarper Date: Wed, 28 Oct 2026 09:04:49 -0700 Subject: [PATCH 33/61] Recover stale nRF Bluetooth callbacks across USB handover --- .github/workflows/run-unit-tests.yml | 1 + src/helpers/nrf52/BleRxFrameQueue.h | 8 ++ src/helpers/nrf52/SerialBLEInterface.cpp | 57 +++++++-- test/fixtures/nrf52_ble_usb_lifecycle.cpp | 144 ++++++++++++++++++++++ test/test_nrf52_ble_reconnect_watchdog.py | 8 ++ test/test_nrf52_ble_usb_lifecycle.py | 37 ++++++ 6 files changed, 247 insertions(+), 8 deletions(-) create mode 100644 test/fixtures/nrf52_ble_usb_lifecycle.cpp create mode 100644 test/test_nrf52_ble_usb_lifecycle.py diff --git a/.github/workflows/run-unit-tests.yml b/.github/workflows/run-unit-tests.yml index 00e52660d..696354f75 100644 --- a/.github/workflows/run-unit-tests.yml +++ b/.github/workflows/run-unit-tests.yml @@ -1038,6 +1038,7 @@ jobs: python3 -B test/test_nrf52_svc_compat.py -v python3 -B test/test_nrf52_ble_cccd_fix.py -v python3 -B test/test_nrf52_ble_disconnect.py -v + python3 -B test/test_nrf52_ble_usb_lifecycle.py -v pip install unicorn==2.1.4 python3 -B test/test_nrf52_ble_dfu_fix.py -v diff --git a/src/helpers/nrf52/BleRxFrameQueue.h b/src/helpers/nrf52/BleRxFrameQueue.h index e5b6a63e2..a23e94161 100644 --- a/src/helpers/nrf52/BleRxFrameQueue.h +++ b/src/helpers/nrf52/BleRxFrameQueue.h @@ -58,6 +58,14 @@ public: } } + // Physical CONNECT is observed by the BLE worker before deferred lifecycle + // callbacks. A disable/re-enable invalidates that observation even if the + // SoftDevice still has the same numeric handle. + bool hasConnection(uint16_t handle) const { + Guard guard; + return _enabled && handle != INVALID_HANDLE && _handle == handle; + } + void disconnect(uint16_t handle) { Guard guard; if (_handle != handle) return; diff --git a/src/helpers/nrf52/SerialBLEInterface.cpp b/src/helpers/nrf52/SerialBLEInterface.cpp index da6d95f0d..4e271ed28 100644 --- a/src/helpers/nrf52/SerialBLEInterface.cpp +++ b/src/helpers/nrf52/SerialBLEInterface.cpp @@ -314,15 +314,29 @@ void SerialBLEInterface::onConnect(uint16_t connection_handle) { #endif BLE_DEBUG_PRINTLN("SerialBLEInterface: connected handle=0x%04X", connection_handle); if (instance) { + // A queued connect callback can arrive after disable() stopped advertising + // and sampled an invalid handle, including after it was enabled again. + // Do not admit a link whose physical RX observation was invalidated. + // In the pinned Bluefruit core the BLE worker (priority 3) reaches our raw + // observer before the deferred callback task (priority 2) can run: the + // intervening peripheral CONNECT handlers do not block or yield. + if (!instance->_isEnabled + || !instance->recv_queue.hasConnection(connection_handle)) { + if (instance->_conn_handle == BLE_CONN_HANDLE_INVALID + || instance->_conn_handle == connection_handle) { + instance->_conn_handle = connection_handle; + instance->_isDeviceConnected = false; + instance->_security_timer.cancel(); + instance->_companion_start_timer.cancel(); + instance->disconnect(); + } else { + sd_ble_gap_disconnect(connection_handle, BLE_HCI_REMOTE_USER_TERMINATED_CONNECTION); + } + return; + } instance->_pairingRequestPending.store(false, std::memory_order_release); instance->_conn_handle = connection_handle; instance->_isDeviceConnected = false; - // A queued connect callback can arrive after disable() stopped advertising - // and sampled an invalid handle. Close that late link as well. - if (!instance->_isEnabled) { - instance->disconnect(); - return; - } instance->_security_timer.start(millis()); instance->_companion_start_timer.cancel(); instance->_companionDataSeen.store(false, std::memory_order_release); @@ -342,7 +356,8 @@ void SerialBLEInterface::onDisconnect(uint16_t connection_handle, uint8_t reason #endif BLE_DEBUG_PRINTLN("SerialBLEInterface: disconnected handle=0x%04X reason=%u", connection_handle, reason); if (instance) { - if (instance->_conn_handle == connection_handle) { + if (instance->_conn_handle == connection_handle + && !instance->recv_queue.hasConnection(connection_handle)) { instance->_pairingRequestPending.store(false, std::memory_order_release); instance->_conn_handle = BLE_CONN_HANDLE_INVALID; instance->_isDeviceConnected = false; @@ -396,6 +411,14 @@ void SerialBLEInterface::onSecured(uint16_t connection_handle) { meshBleTrace(0xf003, connection_handle); BLE_DEBUG_PRINTLN("SerialBLEInterface: onSecured handle=0x%04X", connection_handle); if (instance) { + if (instance->_conn_handle == connection_handle + && !instance->recv_queue.hasConnection(connection_handle)) { + instance->_isDeviceConnected = false; + instance->_security_timer.cancel(); + instance->_companion_start_timer.cancel(); + instance->disconnect(); + return; + } if (instance->isValidConnection(connection_handle, true)) { BLEConnection* conn = Bluefruit.Connection(connection_handle); if (conn == nullptr || !conn->secured()) { @@ -1279,7 +1302,7 @@ void SerialBLEInterface::recoverStalledTx(const char* cause, } bool SerialBLEInterface::isValidConnection(uint16_t handle, bool requireWaitingForSecurity) const { - if (!_isEnabled || _conn_handle != handle) { + if (!_isEnabled || _conn_handle != handle || !recv_queue.hasConnection(handle)) { return false; } BLEConnection* conn = Bluefruit.Connection(handle); @@ -1355,6 +1378,24 @@ void SerialBLEInterface::loop() { if (!_isEnabled) return; if (_conn_handle != BLE_CONN_HANDLE_INVALID) { + if (!recv_queue.hasConnection(_conn_handle)) { + // An enable transition can invalidate RX while the asynchronous + // disconnect is still pending. This link cannot provide a secured RX + // snapshot, so enter the existing bounded disconnect recovery directly. + { + RxQueueGuard guard; + if (_conn_handle == BLE_CONN_HANDLE_INVALID + || recv_queue.hasConnection(_conn_handle)) return; + _isDeviceConnected = false; + _security_timer.cancel(); + _companion_start_timer.cancel(); + _tx_recovery_handle = _conn_handle; + _tx_recovery_generation = recv_queue.generation(); + _tx_disconnect_recovery.begin(); + } + serviceTxRecovery(now); + return; + } if (_security_timer.expired(now)) { // Keep the two-minute PIN-entry window, but do not let an unfinished // security exchange occupy the only BLE connection indefinitely. diff --git a/test/fixtures/nrf52_ble_usb_lifecycle.cpp b/test/fixtures/nrf52_ble_usb_lifecycle.cpp new file mode 100644 index 000000000..371a3ee1b --- /dev/null +++ b/test/fixtures/nrf52_ble_usb_lifecycle.cpp @@ -0,0 +1,144 @@ + +#include +#include +#include +#include +#include +#include +#include +#define BLE_CONN_HANDLE_INVALID 0xffff +#define BLE_HCI_REMOTE_USER_TERMINATED_CONNECTION 0x13 +#define BLE_HEALTH_CHECK_INTERVAL 10000 +#define BLE_COMPANION_START_TIMEOUT_MS 15000 +#define BLE_MIN_CONN_INTERVAL 12 +#define BLE_MAX_CONN_INTERVAL 24 +#define BLE_SLAVE_LATENCY 4 +#define BLE_CONN_SUP_TIMEOUT 200 +#define NRF_SUCCESS 0 +#define NRF_ERROR_INVALID_STATE 8 +#define COMPANION_FEATURE_BLE_MOTA_SOURCE 0 +#define BLE_DEBUG_PRINTLN(...) do {} while(0) +#define meshBleTrace(...) do {} while(0) +static uint32_t clock_ms=100; +static bool physical_link=false; +static unsigned disconnect_requests=0; +static uint16_t last_disconnect_handle=0xffff; +uint32_t millis(){return clock_ms;} +struct ble_gap_addr_t {}; +struct ble_gap_conn_params_t {uint16_t min_conn_interval,max_conn_interval,slave_latency,conn_sup_timeout;}; +struct BLEConnection { + bool connected()const{return physical_link;} + bool secured()const{return true;} + bool bonded()const{return true;} + ble_gap_addr_t getPeerAddr()const{return {};} +}; +struct { + BLEConnection connection; + struct { + bool running=false,auto_restart=false; + void restartOnDisconnect(bool enabled){auto_restart=enabled;} + bool stop(){running=false;return true;} + bool start(unsigned){running=!physical_link;return running;} + bool isRunning()const{return running;} + } Advertising; + BLEConnection* Connection(uint16_t handle){return physical_link&&handle==7?&connection:nullptr;} + unsigned connected()const{return physical_link?1:0;} +} Bluefruit; +uint32_t sd_ble_gap_disconnect(uint16_t handle,uint8_t){++disconnect_requests;last_disconnect_handle=handle;return physical_link?NRF_SUCCESS:NRF_ERROR_INVALID_STATE;} +uint32_t sd_ble_gap_conn_param_update(uint16_t,const ble_gap_conn_params_t*){return NRF_SUCCESS;} +struct SerialBLEInterface { + struct Guard {}; + struct Frame {uint8_t len=0;uint8_t buf[176]={};}; + using RxQueueGuard=Guard; + mesh_nrf52::BleRxFrameQueue recv_queue; + struct {void flush(){}} bleuart; + bool _isEnabled=false,_isDeviceConnected=false,_bonded_only=false,_peer_address_valid=false; + uint16_t _conn_handle=BLE_CONN_HANDLE_INVALID; + uint32_t _last_health_check=0,_last_retry_attempt=0; + std::atomic _tx_reset_pending{false},_pairingRequestPending{false},_companionDataSeen{false}; + std::atomic _advertisingSuppressed{false},_bondedOnlyRecoveryPending{false}; + uint8_t send_queue_len=0; + uint16_t _tx_recovery_handle=BLE_CONN_HANDLE_INVALID; + uint32_t _tx_recovery_generation=0; + mesh::BleTxStallWatchdog _tx_stall_watchdog; + mesh::BleDisconnectRecovery _tx_disconnect_recovery; + SecuritySessionTimer _security_timer,_companion_start_timer; + unsigned recovery_services=0; + void clearBuffers();void resetTxState();bool servicePendingTxReset(); + void enable();void disable();void disconnect();void loop(); + bool advertisingAllowed()const;bool isAdvertising()const; + bool startAdvertising(const char*);bool isConnected()const; + bool isValidConnection(uint16_t,bool waiting=false)const; + void recoverStalledTx(const char*,uint16_t handle=BLE_CONN_HANDLE_INVALID,uint32_t generation=0); + void serviceTxRecovery(uint32_t); + void serviceBondedOnlyTransition(){} + bool removeStoredBondForPeer(const char*){return false;} + void requestBondedOnlyRecovery(const char*){} + void noteSuccessfulConnection(const ble_gap_addr_t&){} + static void onConnect(uint16_t);static void onSecured(uint16_t);static void onDisconnect(uint16_t,uint8_t); +}; +static SerialBLEInterface* instance=nullptr; +@METHODS@ + + +static void normal_session() { + physical_link=false;disconnect_requests=0;clock_ms=100; + SerialBLEInterface link;instance=&link;link.enable(); + physical_link=true;Bluefruit.Advertising.running=false;link.recv_queue.connect(7); + SerialBLEInterface::onConnect(7);SerialBLEInterface::onSecured(7); + assert(link.isConnected()); + clock_ms=15100;link.loop(); + assert(link._tx_disconnect_recovery.pending()&&disconnect_requests==1); +} +static void finish_disconnect(SerialBLEInterface& link) { + physical_link=false;link.recv_queue.disconnect(7); + SerialBLEInterface::onDisconnect(7,BLE_HCI_REMOTE_USER_TERMINATED_CONNECTION); + clock_ms+=11000;link.loop(); + assert(link._conn_handle==BLE_CONN_HANDLE_INVALID&&!link.isConnected()); + assert(Bluefruit.Advertising.running); +} +static void callback_crosses_disable_enable(bool delayed_security_only) { + physical_link=false;disconnect_requests=0;clock_ms=100; + SerialBLEInterface link;instance=&link;link.enable(); + physical_link=true;Bluefruit.Advertising.running=false;link.recv_queue.connect(7); + if(delayed_security_only)SerialBLEInterface::onConnect(7); + link.disable();clock_ms+=501;link.enable(); + if(!delayed_security_only)SerialBLEInterface::onConnect(7); + SerialBLEInterface::onSecured(7); + uint32_t generation=0; + assert(link._isEnabled&&!link._isDeviceConnected&&physical_link); + assert(!link.recv_queue.snapshot(7,generation)&&!link.isConnected()); + assert(disconnect_requests>=1&&last_disconnect_handle==7); + link.loop();assert(link._tx_disconnect_recovery.pending()); + unsigned before=disconnect_requests;clock_ms+=999;link.loop();assert(disconnect_requests==before); + clock_ms+=1;link.loop();assert(disconnect_requests==before+1); + finish_disconnect(link); +} +static void reused_handle_and_late_disconnect() { + physical_link=false;disconnect_requests=0;clock_ms=100; + SerialBLEInterface link;instance=&link;link.enable(); + physical_link=true;link.recv_queue.connect(7);SerialBLEInterface::onConnect(7);SerialBLEInterface::onSecured(7); + link.recv_queue.disconnect(7); // Raw disconnect invalidates the old epoch. + link.recv_queue.connect(7); // A replacement physical connection reuses7. + SerialBLEInterface::onConnect(7);SerialBLEInterface::onSecured(7); + uint32_t generation;assert(link.recv_queue.snapshot(7,generation)); + SerialBLEInterface::Frame frame;frame.len=1;frame.buf[0]=0x42; + assert(link.recv_queue.push(7,generation,frame)==decltype(link.recv_queue)::PushResult::Accepted); + SerialBLEInterface::onDisconnect(7,BLE_HCI_REMOTE_USER_TERMINATED_CONNECTION); + assert(link._conn_handle==7&&link.isConnected()&&link.recv_queue.size()==1); + SerialBLEInterface::onConnect(8); // A stale different handle cannot replace7. + assert(last_disconnect_handle==8&&link._conn_handle==7&&link.isConnected()); +} +static void closed_physical_link_cannot_block_advertising() { + physical_link=false;disconnect_requests=0;clock_ms=100; + SerialBLEInterface link;instance=&link;link.enable();link._conn_handle=7; + Bluefruit.Advertising.running=false;link.loop(); + assert(link._conn_handle==BLE_CONN_HANDLE_INVALID&&Bluefruit.Advertising.running); +} +static void repeated_idle_usb_cycles_restore_advertising() { + physical_link=false;clock_ms=100;SerialBLEInterface link;instance=&link; + for(unsigned n=0;n<5;++n){link.enable();assert(Bluefruit.Advertising.running);link.disable();assert(!Bluefruit.Advertising.running);clock_ms+=501;} + link.enable();assert(Bluefruit.Advertising.running); +} +int main(){normal_session();callback_crosses_disable_enable(false);callback_crosses_disable_enable(true); + reused_handle_and_late_disconnect();closed_physical_link_cannot_block_advertising();repeated_idle_usb_cycles_restore_advertising();} diff --git a/test/test_nrf52_ble_reconnect_watchdog.py b/test/test_nrf52_ble_reconnect_watchdog.py index 89fd20d8e..7274fa90d 100644 --- a/test/test_nrf52_ble_reconnect_watchdog.py +++ b/test/test_nrf52_ble_reconnect_watchdog.py @@ -30,6 +30,7 @@ uint32_t millis() { return current_millis; } struct Recovery { bool active = false; bool pending() const { return active; } + void begin() { active = true; } }; struct SerialBLEInterface { @@ -37,6 +38,13 @@ struct SerialBLEInterface { bool _isDeviceConnected = false; uint16_t _conn_handle = BLE_CONN_HANDLE_INVALID; uint32_t _last_health_check = 0; + struct RxQueueGuard {}; + struct { + bool hasConnection(uint16_t) const { return true; } + uint32_t generation() const { return 0; } + } recv_queue; + uint16_t _tx_recovery_handle = BLE_CONN_HANDLE_INVALID; + uint32_t _tx_recovery_generation = 0; bool allow_advertising = true; bool advertising = false; std::atomic _companionDataSeen{false}; diff --git a/test/test_nrf52_ble_usb_lifecycle.py b/test/test_nrf52_ble_usb_lifecycle.py new file mode 100644 index 000000000..60c94bfb5 --- /dev/null +++ b/test/test_nrf52_ble_usb_lifecycle.py @@ -0,0 +1,37 @@ +"""Run production nRF BLE lifecycle methods across delayed callbacks and USB.""" +import os +from pathlib import Path +import subprocess +import tempfile +import unittest + +from test_t096_full_memory import method + +ROOT=Path(__file__).resolve().parents[1] + + +class NrfBleUsbLifecycleTests(unittest.TestCase): + def test_actual_lifecycle_rejects_invalid_epochs_and_recovers_without_new_state(self): + source=(ROOT/'src/helpers/nrf52/SerialBLEInterface.cpp').read_text() + signatures=('void SerialBLEInterface::clearBuffers()', 'void SerialBLEInterface::resetTxState()', + 'bool SerialBLEInterface::servicePendingTxReset()', 'void SerialBLEInterface::enable()', + 'void SerialBLEInterface::disable()', 'void SerialBLEInterface::disconnect()', + 'void SerialBLEInterface::onConnect(', 'void SerialBLEInterface::onSecured(', + 'void SerialBLEInterface::onDisconnect(', 'bool SerialBLEInterface::isValidConnection(', + 'bool SerialBLEInterface::isConnected()', 'void SerialBLEInterface::recoverStalledTx(', + 'void SerialBLEInterface::serviceTxRecovery(', 'void SerialBLEInterface::loop()', + 'bool SerialBLEInterface::advertisingAllowed()', 'bool SerialBLEInterface::startAdvertising(', + 'bool SerialBLEInterface::isAdvertising()') + actual='\n'.join(method(source,signature) for signature in signatures) + harness=(ROOT/'test/fixtures/nrf52_ble_usb_lifecycle.cpp').read_text().replace('@METHODS@',actual) + flags=[] if os.name=='nt' else ['-fsanitize=address,undefined','-fno-sanitize-recover=all','-fno-pie','-no-pie'] + with tempfile.TemporaryDirectory(prefix='mesh-ble-usb-lifecycle-') as temp: + cpp=Path(temp)/'model.cpp';binary=Path(temp)/'model';cpp.write_text(harness) + built=subprocess.run(['c++','-std=c++17',str(cpp),'-I'+str(ROOT/'src'),*flags,'-o',str(binary)], + capture_output=True,text=True,timeout=30) + self.assertEqual(built.returncode,0,built.stderr) + ran=subprocess.run([str(binary)],capture_output=True,text=True,timeout=10) + self.assertEqual(ran.returncode,0,ran.stdout+ran.stderr) + + +if __name__=='__main__':unittest.main() From d863f9636c90b5d992fda8708292d6f92f618cc1 Mon Sep 17 00:00:00 2001 From: mikecarper Date: Wed, 28 Oct 2026 09:09:44 -0700 Subject: [PATCH 34/61] Account for logging preference in frozen value fixtures --- test/test_companion_prefs_values.py | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/test/test_companion_prefs_values.py b/test/test_companion_prefs_values.py index 0227e5f5b..c2aa56204 100644 --- a/test/test_companion_prefs_values.py +++ b/test/test_companion_prefs_values.py @@ -39,9 +39,9 @@ static size_t adapterOffset(const CompanionNodePrefs& prefs) { // The value helper does not copy the adapters or their inter-field padding. // Use the complete object's byte representation for bounded observation. const uintptr_t first = reinterpret_cast(&prefs); - const uintptr_t last = reinterpret_cast(&prefs.tx_ssh); + const uintptr_t last = reinterpret_cast(&prefs.usb_logging_always); assert(last >= first && last - first < sizeof(prefs)); - return static_cast(last - first) + sizeof(prefs.tx_ssh); + return static_cast(last - first) + sizeof(prefs.usb_logging_always); } static std::vector adapterBytes(const CompanionNodePrefs& prefs) { @@ -95,9 +95,12 @@ int main() { CompanionNodePrefs saved; seedValues(saved, random); saved.bluetooth_enabled = static_cast(trial); + saved.usb_logging_always = trial & 1; DataStore image_store; assert(image_store.savePrefs(saved, 47.125, -122.25)); auto image = image_store.fs.files["/new_prefs"]; + assert(image[36] == (saved.usb_logging_always ? 0xa1 : 0)); + assert(image[37] == 0 && image[38] == 0 && image[39] == 0); #if defined(WIO_E5_MINI_HEADLESS_COMPANION_UI) assert(image[79] == saved.leds_quiet); #endif @@ -202,6 +205,13 @@ class CompanionPrefsValuesTests(unittest.TestCase): '#if defined(WIO_E5_MINI_HEADLESS_COMPANION_UI)\n' ' readField(&loaded_prefs.leds_quiet, 1);\n' '#else\n' + reserved_byte + '\n#endif') + # The always-log opt-in uses the first byte of the existing reserved + # word at 36. Keep its one historical four-byte read and independent + # literal marker decoding, including all failed-read boundaries. + logging_word = ' readField(pad, 4);' + self.assertEqual(legacy.count(logging_word), 1) + legacy = legacy.replace(logging_word, logging_word + '\n' + ' loaded_prefs.usb_logging_always = success && pad[0] == 0xa1;') # Keep the frozen scalar prefix independent of the new GNU table. # The RAK-only checked tail was published later; add it explicitly # to this test-local reference without rewriting the frozen fixture. @@ -236,7 +246,7 @@ class CompanionPrefsValuesTests(unittest.TestCase): r'^\s*(?:float|char|u?int(?:8|16|32)_t)\s+([A-Za-z_][A-Za-z_0-9]*)', prefix, re.M) self.assertEqual(declarations[0], 'airtime_factor') - self.assertEqual(declarations[-2:], ['tx_media', 'tx_ssh']) + self.assertEqual(declarations[-3:], ['tx_media', 'tx_ssh', 'usb_logging_always']) optional = { 'fan_mode': 'TBEAM_1W', 'fan_lo': 'TBEAM_1W', 'fan_hi': 'TBEAM_1W', 'wifi_ssid': 'defined(RP2040_PLATFORM) && defined(ENABLE_WIFI_INTERFACE)', From 463031e38c44997aa00d8db07cfed09849dbae78 Mon Sep 17 00:00:00 2001 From: mikecarper Date: Wed, 28 Oct 2026 09:15:27 -0700 Subject: [PATCH 35/61] Update preference snapshot contract for logging policy --- test/test_companion_settings_persistence_contract.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/test/test_companion_settings_persistence_contract.py b/test/test_companion_settings_persistence_contract.py index 0702ae214..4776117f6 100644 --- a/test/test_companion_settings_persistence_contract.py +++ b/test/test_companion_settings_persistence_contract.py @@ -196,13 +196,13 @@ class CompanionSettingsPersistenceContractTests(unittest.TestCase): "bool copyPersistedValuesFrom(const CompanionNodePrefs& source)", ) self.assertIn("reinterpret_cast(&airtime_factor)", copy) - # Both common traffic preferences follow the optional RAK policy. - # The final value may never include either runtime adapter. - self.assertIn("const auto& last_value = tx_ssh;", copy) + # The always-log value follows both common traffic preferences. Include + # that final scalar while keeping both runtime adapters outside the copy. + self.assertIn("const auto& last_value = usb_logging_always;", copy) self.assertIn("reinterpret_cast(&last_value)", copy) - self.assertIn("&values.tx_ssh", copy) - self.assertIn("+ sizeof(tx_ssh)", copy) - self.assertIn("end - begin == size - sizeof(values.tx_ssh)", copy) + self.assertIn("&values.usb_logging_always", copy) + self.assertIn("+ sizeof(usb_logging_always)", copy) + self.assertIn("end - begin == size - sizeof(values.usb_logging_always)", copy) self.assertIn("reinterpret_cast(&values.radio)", copy) self.assertIn("reinterpret_cast(&values.custom)", copy) self.assertIn("size <= radio_begin - begin", copy) From 5a72a991227a8c3062a8291091862289e4cc6807 Mon Sep 17 00:00:00 2001 From: mikecarper Date: Wed, 28 Oct 2026 09:33:33 -0700 Subject: [PATCH 36/61] Outline the RAK BLE guard to reduce flash use --- src/helpers/nrf52/BleRxFrameQueue.h | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/helpers/nrf52/BleRxFrameQueue.h b/src/helpers/nrf52/BleRxFrameQueue.h index a23e94161..27cda9073 100644 --- a/src/helpers/nrf52/BleRxFrameQueue.h +++ b/src/helpers/nrf52/BleRxFrameQueue.h @@ -61,6 +61,10 @@ public: // Physical CONNECT is observed by the BLE worker before deferred lifecycle // callbacks. A disable/re-enable invalidates that observation even if the // SoftDevice still has the same numeric handle. +#if defined(RAK_4631) && defined(__GNUC__) + // Share the guarded query across callbacks in the constrained RAK image. + __attribute__((noinline)) +#endif bool hasConnection(uint16_t handle) const { Guard guard; return _enabled && handle != INVALID_HANDLE && _handle == handle; From f6034e1f029b9ce09a3149db6dfc48ef4ef51b7e Mon Sep 17 00:00:00 2001 From: mikecarper Date: Wed, 28 Oct 2026 10:00:54 -0700 Subject: [PATCH 37/61] Shorten RAK Bluetooth startup diagnostics --- examples/companion_radio/main.cpp | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/examples/companion_radio/main.cpp b/examples/companion_radio/main.cpp index 94483e6a4..01c6545b7 100644 --- a/examples/companion_radio/main.cpp +++ b/examples/companion_radio/main.cpp @@ -3099,7 +3099,12 @@ void halt() { companion_bluetooth_start_failure[ sizeof(companion_bluetooth_start_failure) - 1] = 0; mesh::usbLoggingPort().println( - "Companion: no interface slot available for Bluetooth; retrying"); +#if defined(RAK_4631) + "Bluetooth: no slot; retrying" +#else + "Companion: no interface slot available for Bluetooth; retrying" +#endif + ); scheduleCompanionBluetoothRetry(); return; } @@ -3145,7 +3150,12 @@ void halt() { // Leave the interface unregistered so the UI cannot advertise a PIN // for it, and avoid consuming more heap every five seconds. mesh::usbLoggingPort().println( - "Companion: Bluetooth initialization failed; reboot required"); +#if defined(RAK_4631) + "Bluetooth init failed; reboot" +#else + "Companion: Bluetooth initialization failed; reboot required" +#endif + ); #else mesh::usbLoggingPort().println( "Companion: Bluetooth initialization failed; retrying in 5 seconds"); From 0779b2af04f1dfb7c85f0ef0b001616d82d02d52 Mon Sep 17 00:00:00 2001 From: mikecarper Date: Wed, 28 Oct 2026 10:23:45 -0700 Subject: [PATCH 38/61] Include CLI reply definitions in scheduler fixture --- test/test_repeater_radio_timing_integration.py | 1 + 1 file changed, 1 insertion(+) diff --git a/test/test_repeater_radio_timing_integration.py b/test/test_repeater_radio_timing_integration.py index 617000081..938be1251 100644 --- a/test/test_repeater_radio_timing_integration.py +++ b/test/test_repeater_radio_timing_integration.py @@ -29,6 +29,7 @@ HARNESS = r''' #include #include #include +#include #include #include #define MESH_DEBUG_PRINTLN(...) ((void)0) From 37b294dfd1c0112960aa84a856362271ee8db87f Mon Sep 17 00:00:00 2001 From: mikecarper Date: Wed, 28 Oct 2026 10:23:45 -0700 Subject: [PATCH 39/61] Record 1.18.0.2 Mercerwood radio checks --- docs/cli_commands.md | 8 +- .../emergency_power_2026-10-28.md | 5 + ...lidation_mercerwood_1_18_0_2_2026-10-28.md | 234 + ...n_mercerwood_1_18_0_2_2026-10-28.safe.json | 9260 +++++++++++++++++ docs/test-results/index.md | 1 + 5 files changed, 9506 insertions(+), 2 deletions(-) create mode 100644 docs/test-results/hardware_validation_mercerwood_1_18_0_2_2026-10-28.md create mode 100644 docs/test-results/hardware_validation_mercerwood_1_18_0_2_2026-10-28.safe.json diff --git a/docs/cli_commands.md b/docs/cli_commands.md index ba2821332..d345445bf 100644 --- a/docs/cli_commands.md +++ b/docs/cli_commands.md @@ -3149,6 +3149,10 @@ defer sleep. Existing hard low-voltage protection remains in force. On ESP32, an active native USB connection uses short CPU idle waits to keep the console usable while the radio sleeps. Timed light sleep resumes after USB detaches and its disconnect grace expires. +On nRF52, an enumerated USB host limits each CPU idle slice to 100 ms so +reconnects and replies can progress while LoRa remains asleep. Detached +maintenance intervals remain up to ten seconds; command completion can need +several passes through the main loop. ```text get power.emergency @@ -3164,8 +3168,8 @@ five seconds for its reply. `power.awake` keeps the node up for the next given number of minutes (0 cancels; maximum 10080), even outside the hourly window. That override is held in RAM and ends on reboot; it never disables hard low-voltage shutdown. Use it before maintenance that needs continuous uptime. -LoRa commands must arrive during an awake window. Local USB commands can wait -up to one ten-second maintenance interval while the node is sleeping. +LoRa commands must arrive during an awake window. Local serial commands remain +available through maintenance wakes while the radio sleeps. `get power.emergency` reports the mode, battery estimate, desired `window`, actual radio ownership, seconds until the next awake window, and remaining diff --git a/docs/test-results/emergency_power_2026-10-28.md b/docs/test-results/emergency_power_2026-10-28.md index 79deed605..8f75ea7f9 100644 --- a/docs/test-results/emergency_power_2026-10-28.md +++ b/docs/test-results/emergency_power_2026-10-28.md @@ -12,6 +12,11 @@ SolarXiao 33S, RAK3401 and Heltec V4.3 passed physical sleep, override, automati wake and reboot-persistence trials. Current savings and operation with a real battery at 5% were not measured. +The later [1.18.0.2 bench campaign](hardware_validation_mercerwood_1_18_0_2_2026-10-28.md) +found and fixed nRF52 USB reconnect starvation during automatic emergency +sleep. Attached USB hosts now use CPU idle slices of at most 100 ms while +LoRa stays asleep. The measurements below retain their original source. + ## Behavior Supported infrastructure images enter automatic emergency mode at **5% or diff --git a/docs/test-results/hardware_validation_mercerwood_1_18_0_2_2026-10-28.md b/docs/test-results/hardware_validation_mercerwood_1_18_0_2_2026-10-28.md new file mode 100644 index 000000000..d3a2a8d83 --- /dev/null +++ b/docs/test-results/hardware_validation_mercerwood_1_18_0_2_2026-10-28.md @@ -0,0 +1,234 @@ + + + + +# Mercerwood 1.18.0.2 candidate checks, 28 October 2026 + +Status: **COMPLETED WITH UNRESOLVED RESULTS**. This finite campaign covers **15 radios**; the +Indicator's separate RP2040 display coprocessor is excluded from radio flashing. +S3 remains unqualified; T1000-E/T096 BLE and Tower RF misses remain unresolved. +This is not release approval. Results retain the source +actually measured; a later build does not inherit an earlier physical pass. +The [machine-readable evidence summary](hardware_validation_mercerwood_1_18_0_2_2026-10-28.safe.json) +records the source and artifact hashes. + +| Board | Role | Measured source | USB / warm reboot | BLE | Zero-hop LoRa | +| --- | --- | --- | --- | --- | --- | +| Seeed T1000-E | Full Companion | 226 | PASS | FAIL, first USB-release phase; second not attempted | PASS, three rounds each way | +| RAK WisMesh Tag | Full Companion | 226 | PASS | PASS, three clean sessions | PASS as observer for all three pairs | +| Heltec T096, FEM on | Full Companion | 226 | PASS | Three payload sessions; strict FAIL (`0x3e`) | PASS, three rounds each way | +| Heltec MeshPocket | Full Companion | 226 | PASS | PASS, three clean sessions | PASS, three rounds each way | +| Heltec V4.3, FEM off | Full Companion | C9 | PASS | PASS with restored original PIN, after host bond repair | PASS, separate cleanup-comparison review | +| Heltec V3 | Repeater | C9 | PASS | Not applicable | PASS with discovery temporarily enabled | +| LilyGo T-Deck | Repeater | C9 | PASS | Not applicable | PASS with discovery temporarily enabled | +| SenseCAP Indicator | Repeater | C9 | PASS, after 29.3-second startup | Not applicable | PASS with discovery temporarily enabled | +| Heltec V4.2 | Repeater | C9 | PASS | Not applicable | PASS, three discovery responses | +| Heltec MeshTower V2 SD | Repeater | 70C7 | PASS, including automatic emergency radio sleep | Not applicable | Unresolved missing responses; RXPS-off trial also incomplete | +| SolarXiao MPPT 33S | Repeater | 70C7 | PASS | Not applicable | PASS, three discovery responses | +| RAK3401 | Repeater / MQTT collector | 70C7 | PASS | Not applicable | PASS after warm preparation, with discovery temporarily enabled | +| Seeed XIAO nRF52840 | Repeater | 70C7 | PASS | Not applicable | Three responses pass; restoration confirmed separately | +| RAK4631 | Repeater | 70C7 | PASS; legacy getter limits below | Not applicable | PASS after warm preparation, three discovery responses | +| Seeed XIAO ESP32-S3 WIO | Observer Repeater target | Original retained | Unqualified; no programmer response | Not applicable | Unqualified | + +C9 is [`c9acec474`](https://github.com/mikecarper/MeshCore/commit/c9acec474bb9617d4f92dbdc0401765701989288). +70C7 is [`70c7d5b47`](https://github.com/mikecarper/MeshCore/commit/70c7d5b478b19f16d232389779ca455e73be7eba). +226 is [`226ccc332`](https://github.com/mikecarper/MeshCore/commit/226ccc332d29e78c3dc69cdb70a228baca403711). +70C7 changes the nRF52 emergency CPU idle backend; 226 changes the nRF52 +Companion BLE lifecycle. V3 and V4.2 report the observer suffix in their runtime +version. The RAK4631 hardware result is for its 70C7 Repeater image; the separate +Full Companion capacity build and uninstalled S3 Observer build are described below. + +## Measurements + +USB qualification requires **100 query cycles, ten fresh reopens and one +verified warm reboot**, with zero core flags. Companion checks cover owned +contact creation/persistence/removal, natural expiry of a one-minute RAM-only +radio2 lease, and restoration of the original secondary profile/automatic owner. +Transmit-power and saved Bluetooth PIN setters require acknowledgement, +readback, reopen and restoration. Actual RF power and an active PIN changing +before restart were not measured. + +BLE requires three authenticated thirty-second sessions, the USB-bound public +identity/device information and a clean HCI lifecycle. Payload delivery and +strict HCI acceptance are separate results. T1000-E uses target-only USB host +deauthorization; USB power remains uncommanded, so this is not a battery-only +or physical cable-unplug test. + +On 226, Tag and MeshPocket pass both payload and strict HCI checks. T096 passes +all three payload sessions and encryption succeeds three times, but its trace +has an extra connection ending with `0x3e`; strict acceptance therefore fails. +No missing-key marker or host Bluetooth-service crash was observed in that +trial, and its cause is unassigned. + +T1000-E's 226 trial actually released USB host ownership and waited ten seconds, +leaving host WiFi unchanged. Its first phase completed no payload session: +GATT reported `Unlikely Error (0x0e)`, with HCI disconnect reasons `0x3e` and +`0x13`. Encryption succeeded once; no missing-key marker or host daemon crash +was observed. The second phase was not attempted after that failure. USB +enumeration, identity, preferences, contacts, channels and uptime continuity +were verified afterward, with zero core flags and queue length. No further +BLE/RF retries were made. + +On C9, the T1000-E completed three payload sessions in its normal-host-WiFi trial, +but the controller recorded an extra connection and disconnect reasons +`0x3e` / `0x22`. A controlled WiFi-quiet trial completed no payload sessions, +with `0x3e` / `0x08`; host WiFi and USB authorization were restored. Quieting +WiFi did not establish a remedy. A separate radio2-off diagnostic found no +unique matching advertisement within its bounded scan, so it attempted no +connection and captured no authentication failure under that condition. + +The V4.3 initially had a host/device bond mismatch. Target-only host bond repair +later yielded three clean thirty-second sessions with the original saved PIN +restored. A temporary-PIN phase failed while BlueZ 5.66 crashed during +notification setup; that failure remains recorded. Identity, settings, +contacts, channels and unrelated host bonds were checked afterward. This is +a conditional C9 pass; no firmware remedy for the host crash is claimed. + +RF trials use shipping firmware, zero-hop controls, fresh nonces, full peer +identity and physical TX/RX counters. Live control pushes leave the offline +message queue untouched. Bench RF/TX settings and owned awake holds are +restored. Repeater discovery checks the radio2 extension within the existing +four-per-two-minute limiter. + +The fresh 226 Tag star completed **all 18 legs**: three exchanges in each +direction with T1000-E, T096 and MeshPocket. Every leg matched the identity, +nonce and physical packet counters, with zero receive errors; the slowest took +**0.524 seconds**. Original radio/TX settings, binary preferences, contacts and +channels were restored, with no offline-queue reads or RF retries. Earlier C9 +exchanges remain separate history. +V3, T-Deck, Indicator and RAK3401 had discovery hidden by their saved advert +settings. Their conditional trials temporarily enabled discovery on the +isolated bench profile and restored the original values. A missing response +while discovery was disabled is not a failed response from an eligible node. + +The Tower returned two of three baseline requests, one of two quiet, paced +requests, and one of two requests with RX power saving disabled. Missing +responses remain unresolved; disabling RXPS did not establish a remedy. +Original settings and its automatic emergency mode with no awake hold were +restored after each measured trial. + +Separate reviews accepted the original V4.3 six exchanges and XIAO nRF52840 +three responses: their harness failures compared changing timing/countdown or +live battery fields as saved settings. Read-only checks confirmed restoration; +no RF was repeated. Original failures remain recorded. Preflight formatter/type +errors sent no requests. A manual V3 diagnostic exposed an unsafe host transport +fallback; recovery completed and the harness now requires an explicit reviewed +transport before opening a port. + +## Production fixes + +The Tower reported 0% battery and entered automatic emergency radio sleep. +The first candidate's ten-second CPU idle slices starved USB cleanup/reply +draining: queries and three reopens passed, but the fourth received no reply. +Its original application and queried configuration were restored before the fix. + +70C7 caps CPU idle slices at **100 ms while a USB host is enumerated**, retaining +the ten-second detached limit. It does not wake LoRa or change the battery +policy. On the Tower, automatic mode, reported 0%, sleeping radio and no awake +override were verified before and after USB stress, and again after warm reboot. +All **213 completed commands** responded, with a maximum measured latency of +**1.0952 seconds**. The backend's five tests and runtime's thirteen tests pass +with address and undefined-behavior sanitizers. Actual battery state of charge +and current draw were not measured. + +Earlier [emergency-power checks](emergency_power_2026-10-28.md) retain their +original source; this campaign adds the automatic-entry USB regression/retest. + +A host regression reproduced a BLE connection stuck after USB disabled/re-enabled +Bluetooth while a callback was delayed. 226 rejects stale physical sessions, +uses bounded disconnect recovery and preserves a replacement connection when +an old disconnect arrives. **25 focused native tests passed**, covering delayed +callbacks, normal sessions, retry timing and advertising recovery. Four Full +nRF52 Companion packages pass capability checks with unchanged linked RAM +admission. All four pass their new 100-query, ten-reopen and warm-reboot checks; +all 18 new RF legs also pass. Tag/Pocket BLE passes and T096's strict lifecycle +exception are reported separately above. The earlier T1000-E failures remain +causally unassigned. + +## Emergency scheduling and collector + +On 70C7, SolarXiao MPPT 33S and RAK3401 completed a real one-minute awake lease, +two accelerated past-RTC hourly wake boundaries and manual-mode persistence +through a verified warm reboot. Independent advertisements proved timer wake +after approximately **22 seconds without a target USB write** at each boundary. +The CPU remained connected to an enumerated USB host; these are RF scheduling +measurements, not detached-host or current-consumption measurements. + +Solar's first manual-reboot check closed CDC before the command was consumed. +Its successful lease/timer-wake observations remain separate from that failed +axis. The corrected single-write driver holds CDC until reset or 1.5 seconds; +a focused follow-up proved reduced uptime and manual persistence without +repeating the wake trials. RAK3401 completed the corrected full trial. + +Present UTC was restored before original RF settings, and owned awake holds +were revoked. A separate full-body read-only comparison verified **two Solar +ACL entries and one RAK3401 ACL entry**, available saved settings and trusted +OTA signer lists. Earlier first-line-only ACL counts are superseded by that +complete comparison. Solar's observed CAD flag and the quiet watchdog flags +on some boards remain in the preparation history; warm preparation is not +counted as a clean long-duration run. + +The collector's five configuration/unit digests, original active state and +serial ownership were restored. One owned RAW packet was observed in its +journal with receive metadata and a successful local MQTT publish call. Receipt +at an external broker was not measured. + +## Builds and limits + +The Indicator's first candidate readiness check received no reply within six +seconds. Its original application and data were restored and verified before +one controlled trial of the same candidate. That trial received the exact +version and original identity after **29.3055 seconds**, then passed the unchanged +100-query / ten-reopen / warm-reboot qualification with zero core flags. The +startup wait is preparation, not extra accepted query cycles; the original +failure and each unsuccessful readiness probe remain recorded. + +The initial **15 board plans / 20 packaged profiles** pass RAM/capability checks, +including Full/Reduced images for five nRF52 Repeaters. Four replacement Full +Companion packages were checked for 226. The provisional XIAO ESP32-S3 Companion +target is superseded by an uninstalled Observer Repeater build. PlatformIO +ran serially. Full application installs omit bootloader, SoftDevice, filesystem +and partition-table images. ESP32 recovery restored original applications and +NVS/SPIFFS snapshots with readback verification. + +An additional RAK4631 **Full Companion** build at `f6034e1f`, using the CI library +versions and maximum development-version length, passes capacity checks with +a **712,652-byte packaged application in 712,704 bytes: 52 bytes free**. Its final +change shortens Bluetooth startup diagnostics. This is a build-only result; +that Companion image was not installed on the tested Repeater. The S3 Observer +Repeater package at `5a72a991` also passes build checks and remains uninstalled. + +The user explicitly made original-image backups optional for these test radios. +RAK4631 therefore received its verified 70C7 application through working CDC +DFU without an original rollback image. It passed 100 queries, ten reopens, +warm reboot, exact candidate image/identity checks and current-RTC readiness; +its final core flags and queue were zero. Seven available original settings +and the OTA signer list matched. Its older firmware returned `??:` for password, +ACL and several newer getters, so their preservation cannot be established +from those queries. The original comparison failure remains recorded; a narrow +legacy-error classification review and fresh readiness check accepted the +completed measurements without reflashing or repeating them. Bootloader +version text changed from its numeric fallback to `OTAFIX2.4.12`; no bootloader +image was written, and a before/after CRC-equality claim is not made. + +RAK4631's later RF preflight refused a latched idle watchdog flag `8` before +sending any request. One documented warm reboot reduced uptime from 2,545 to +4 seconds; the unchanged 70C7 image then returned all three exact discovery +responses to the 226 Tag observer, including the matching radio2 extension. +Fifteen available current settings, one current ACL entry and OTA signers +matched across that warm preparation. This does not establish the unavailable pre-upgrade password/ACL +state or long-duration idle reliability. + +XIAO ESP32-S3 did not answer application probes or the programmer after two +physical ROM-entry attempts. No firmware was written. A MAC-bound DHCP/neighbour +lookup found no address, so no HTTP identification probe was made. It remains +unqualified; an enumerated USB device alone does not prove a working application. + +The remaining release gates are the Tower's missing discovery responses, +T096's extra BLE connection, T1000-E's failed BLE phase and the unqualified +S3. Successful checks on other interfaces do not clear those results. + +Read-only OTA readiness is distinct from an actual LoRa firmware transfer and +installation. These checks do not establish range, calibrated transmit power, +display pixel quality, cold-power recovery, phone interoperability, current +savings, long-duration reliability or every firmware feature. diff --git a/docs/test-results/hardware_validation_mercerwood_1_18_0_2_2026-10-28.safe.json b/docs/test-results/hardware_validation_mercerwood_1_18_0_2_2026-10-28.safe.json new file mode 100644 index 000000000..cccab969e --- /dev/null +++ b/docs/test-results/hardware_validation_mercerwood_1_18_0_2_2026-10-28.safe.json @@ -0,0 +1,9260 @@ +{ + "schema": 2, + "updated_utc": "2026-10-28T17:19:25.167443+00:00", + "status": "COMPLETE WITH UNRESOLVED AXES", + "campaign": "Mercerwood 1.18.0.2 candidate qualification", + "all_boards_fully_qualified": false, + "markdown_sha256_lf_utf8": "c21519be2e123a981b34134578b74e62bbc9d04f42d6dcdd1a71db35f0cf3b3e", + "unresolved_axes": [ + { + "board": "Heltec MeshTower V2 SD", + "axis": "RF discovery", + "finding": "Missing replies in baseline, paced and continuous-RX trials; original settings restored." + }, + { + "board": "Heltec T096", + "axis": "strict BLE lifecycle", + "finding": "Three payload sessions passed, but an extra connection ended with 0x3e." + }, + { + "board": "Seeed T1000-E", + "axis": "BLE after actual USB host release", + "finding": "Corrected 226 first phase failed with zero payload sessions; phase 2 not attempted. USB and settings restored." + }, + { + "board": "Seeed XIAO ESP32-S3 WIO", + "axis": "firmware installation and runtime qualification", + "finding": "No application or programmer response; no flash write performed." + } + ], + "inventory": { + "radios": 15, + "observed_companions": 5, + "observed_repeaters": 9, + "unresponsive_historical_repeater": 1, + "excluded_display_coprocessors": 1 + }, + "candidate_usb_passes": 14, + "sources": { + "c9": "c9acec474bb9617d4f92dbdc0401765701989288", + "70c7": "70c7d5b478b19f16d232389779ca455e73be7eba", + "226": "226ccc332d29e78c3dc69cdb70a228baca403711", + "5a72": "5a72a991227a8c3062a8291091862289e4cc6807", + "f603": "f6034e1f029b9ce09a3149db6dfc48ef4ef51b7e" + }, + "initial_builds": { + "schema": 1, + "scope": "Build capacity and packaged capability checks only; physical results are separate.", + "boards": [ + { + "id": "t1000", + "board": "Seeed T1000-E", + "source_commit": "c9acec474bb9617d4f92dbdc0401765701989288", + "role": "companion", + "target": "t1000e_companion_radio_full", + "provisional_role_only": false, + "profiles": [ + { + "artifact_target": "t1000e_companion_radio_full", + "passed": true, + "platformio_environment": "t1000e_companion_radio_usb", + "capability_checks": 14, + "available_internal_bytes": 88572, + "required_heap_bytes": 46912, + "required_contiguous_bytes": 6176, + "elf_sha256": "28a71e452f7edaa58492bfe141be524d55655a65f105a2d1453c13adcbc41988", + "packages": { + "t1000e_companion_radio_full-v1.18.0.2-halo-keymind-cascade-dev-c9acec47.uf2": { + "sha256": "dece109b7045c533eed8352cdeab8a6ae1a45a840033d7fc69ef74cb32c9c325", + "size_bytes": 1226752 + }, + "t1000e_companion_radio_full-v1.18.0.2-halo-keymind-cascade-dev-c9acec47.zip": { + "sha256": "824b6bc39853fddeeba553bf91db79a098afccfc7886546750d3a7afdd73f03e", + "size_bytes": 614043 + } + } + } + ] + }, + { + "id": "tag", + "board": "RAK WisMesh Tag", + "source_commit": "c9acec474bb9617d4f92dbdc0401765701989288", + "role": "companion", + "target": "RAK_WisMesh_Tag_companion_radio_full", + "provisional_role_only": false, + "profiles": [ + { + "artifact_target": "RAK_WisMesh_Tag_companion_radio_full", + "passed": true, + "platformio_environment": "RAK_WisMesh_Tag_companion_radio_usb", + "capability_checks": 15, + "available_internal_bytes": 84292, + "required_heap_bytes": 46912, + "required_contiguous_bytes": 6176, + "elf_sha256": "ff1f664487685cfca24a066d0216e1b0a9c7f3934915721994b963edb08bdf70", + "packages": { + "RAK_WisMesh_Tag_companion_radio_full-v1.18.0.2-halo-keymind-cascade-dev-c9acec47.uf2": { + "sha256": "fae8f53858da5ca30e5725721f4003cb211db6036c1629b723d394b93983574e", + "size_bytes": 1308160 + }, + "RAK_WisMesh_Tag_companion_radio_full-v1.18.0.2-halo-keymind-cascade-dev-c9acec47.zip": { + "sha256": "60d0a86fac55cd8639257268da13d89a51e31e1b478ad1c451f47119690ee23f", + "size_bytes": 654703 + } + } + } + ] + }, + { + "id": "t096", + "board": "Heltec T096, FEM on", + "source_commit": "c9acec474bb9617d4f92dbdc0401765701989288", + "role": "companion", + "target": "Heltec_t096_companion_radio_full_femon", + "provisional_role_only": false, + "profiles": [ + { + "artifact_target": "Heltec_t096_companion_radio_full_femon", + "passed": true, + "platformio_environment": "Heltec_t096_companion_radio_usb_femon", + "capability_checks": 15, + "available_internal_bytes": 84212, + "required_heap_bytes": 73728, + "required_contiguous_bytes": 13825, + "elf_sha256": "967224e02efe8115dd9d715ac74e49fec7a3a4c9c99f0281b6c746c8b459f470", + "packages": { + "Heltec_t096_companion_radio_full_femon-v1.18.0.2-halo-keymind-cascade-dev-c9acec47.uf2": { + "sha256": "21e300720bb9ccd8492f96d990f41169c10173701a56378757ccafa5678f0c06", + "size_bytes": 1330176 + }, + "Heltec_t096_companion_radio_full_femon-v1.18.0.2-halo-keymind-cascade-dev-c9acec47.zip": { + "sha256": "e8230b79d6140d45e25a62cd6946fff844f05c9c583325afce39e809b0f369e8", + "size_bytes": 665767 + } + } + } + ] + }, + { + "id": "pocket", + "board": "Heltec MeshPocket", + "source_commit": "c9acec474bb9617d4f92dbdc0401765701989288", + "role": "companion", + "target": "Mesh_pocket_companion_radio_full", + "provisional_role_only": false, + "profiles": [ + { + "artifact_target": "Mesh_pocket_companion_radio_full", + "passed": true, + "platformio_environment": "Mesh_pocket_companion_radio_usb", + "capability_checks": 15, + "available_internal_bytes": 82604, + "required_heap_bytes": 59200, + "required_contiguous_bytes": 12288, + "elf_sha256": "6230a1171e0bb2b1ac5b3f75e431f0a129b8e41bf5b198682d2732c3758b62b5", + "packages": { + "Mesh_pocket_companion_radio_full-v1.18.0.2-halo-keymind-cascade-dev-c9acec47.uf2": { + "sha256": "55ec932d3c4c446611c53556bcf3518fb9c20f8eda8416ec5d227c9265b56c4f", + "size_bytes": 1406464 + }, + "Mesh_pocket_companion_radio_full-v1.18.0.2-halo-keymind-cascade-dev-c9acec47.zip": { + "sha256": "7f0195128d1c8c86fd15aed6b27e237d507adffc03ae17bb9efd3a04cd67ca17", + "size_bytes": 703847 + } + } + } + ] + }, + { + "id": "solar33s", + "board": "SolarXiao 33S", + "source_commit": "70c7d5b478b19f16d232389779ca455e73be7eba", + "role": "repeater", + "target": "solarxiao_33S_repeater", + "provisional_role_only": false, + "profiles": [ + { + "artifact_target": "solarxiao_33S_repeater-full-ota", + "passed": true, + "platformio_environment": "solarxiao_33S_repeater", + "capability_checks": 11, + "available_internal_bytes": 151404, + "required_heap_bytes": 82784, + "required_contiguous_bytes": 19168, + "elf_sha256": "0de1309a0d5be1f30c0ce3dc53788eab637ce1dbaa385b7bc9e3a5287eb2ed70", + "packages": { + "solarxiao_33S_repeater-full-ota-v1.18.0.2-halo-keymind-cascade-dev-70c7d5b4.uf2": { + "sha256": "ce88768adcf798d622f0a8cee0481e98970a7da4f9e9174983fe54476b767519", + "size_bytes": 1255424 + }, + "solarxiao_33S_repeater-full-ota-v1.18.0.2-halo-keymind-cascade-dev-70c7d5b4.zip": { + "sha256": "61543eb675c2ed3eb8d55d03cd78b5ce4806817178a020f62d7d209353085f50", + "size_bytes": 628354 + } + } + }, + { + "artifact_target": "solarxiao_33S_repeater-reduced-ota", + "passed": true, + "platformio_environment": "solarxiao_33S_repeater", + "capability_checks": 9, + "available_internal_bytes": 152584, + "required_heap_bytes": 80736, + "required_contiguous_bytes": 19168, + "elf_sha256": "08c7104cf036cfa7c8221a227817b9d3093b173ba12f9ccb083a19d1db392539", + "packages": { + "solarxiao_33S_repeater-reduced-ota-v1.18.0.2-halo-keymind-cascade-dev-70c7d5b4.uf2": { + "sha256": "17b136eacf195d2a02ec5946bd87479df701a7bb6836f09a48209a06dbc6b949", + "size_bytes": 1182208 + }, + "solarxiao_33S_repeater-reduced-ota-v1.18.0.2-halo-keymind-cascade-dev-70c7d5b4.zip": { + "sha256": "922796550f8ba4488526ec815cb84ef6d2167dbf0a23c430b8e49f47460dc809", + "size_bytes": 591671 + } + } + } + ] + }, + { + "id": "v43", + "board": "Heltec V4.3, FEM off", + "source_commit": "c9acec474bb9617d4f92dbdc0401765701989288", + "role": "companion", + "target": "heltec_v4_3_companion_radio_full_femoff", + "provisional_role_only": false, + "profiles": [ + { + "artifact_target": "heltec_v4_3_companion_radio_full_femoff", + "passed": true, + "platformio_environment": "heltec_v4_3_companion_radio_full_femoff", + "capability_checks": 28, + "available_internal_bytes": 223648, + "required_heap_bytes": 200480, + "required_contiguous_bytes": 18464, + "elf_sha256": "5e66f41a45500b459eafe829809a2c39e0ad4028eddb0ca7faade5a6121912b3", + "packages": { + "heltec_v4_3_companion_radio_full_femoff-v1.18.0.2-halo-keymind-cascade-dev-c9acec47-merged.bin": { + "sha256": "d0f520d4444fe12d0e3a5d9a069b3c667b1b63252f77cb935a84fe1db82b4d7c", + "size_bytes": 2628424 + }, + "heltec_v4_3_companion_radio_full_femoff-v1.18.0.2-halo-keymind-cascade-dev-c9acec47.bin": { + "sha256": "fb320cf886e56b577e07f792a2d505213f0faf63e51305f5fab5ca120010f8d2", + "size_bytes": 2562888 + } + } + } + ] + }, + { + "id": "tower", + "board": "Heltec MeshTower V2 SD", + "source_commit": "70c7d5b478b19f16d232389779ca455e73be7eba", + "role": "repeater", + "target": "Heltec_tower_v2_sdcard_repeater_lora_ota_no_external_sensors", + "provisional_role_only": false, + "profiles": [ + { + "artifact_target": "Heltec_tower_v2_sdcard_repeater_lora_ota_no_external_sensors-full-ota", + "passed": true, + "platformio_environment": "Heltec_tower_v2_sdcard_repeater_lora_ota_no_external_sensors", + "capability_checks": 9, + "available_internal_bytes": 127252, + "required_heap_bytes": 76992, + "required_contiguous_bytes": 19168, + "elf_sha256": "788b2200b66cf2c64af2773e3a540ec4f302455fc11609a54fbcddf7fd641940", + "packages": { + "Heltec_tower_v2_sdcard_repeater_lora_ota_no_external_sensors-full-ota-v1.18.0.2-halo-keymind-cascade-dev-70c7d5b4.uf2": { + "sha256": "c7e29369427b51fb63ea1c1612549f1e1c205da7bc18ca0929d58cff6b869da0", + "size_bytes": 1177600 + }, + "Heltec_tower_v2_sdcard_repeater_lora_ota_no_external_sensors-full-ota-v1.18.0.2-halo-keymind-cascade-dev-70c7d5b4.zip": { + "sha256": "3aabda910a3b582c6c339bdc09612ccc9b1caa9ef5d9275439929a8acd8acfe9", + "size_bytes": 589591 + } + } + }, + { + "artifact_target": "Heltec_tower_v2_sdcard_repeater_lora_ota_no_external_sensors-reduced-ota", + "passed": true, + "platformio_environment": "Heltec_tower_v2_sdcard_repeater_lora_ota_no_external_sensors", + "capability_checks": 7, + "available_internal_bytes": 127440, + "required_heap_bytes": 74944, + "required_contiguous_bytes": 19168, + "elf_sha256": "220b96fe2c84515cd02326ce0fe703152c4772a43cd0f4e057cf86e4854159c9", + "packages": { + "Heltec_tower_v2_sdcard_repeater_lora_ota_no_external_sensors-reduced-ota-v1.18.0.2-halo-keymind-cascade-dev-70c7d5b4.uf2": { + "sha256": "52be7a9f9cdcb21e0cd4346e5366848fd240ff6b99bd430ab3e4e921e01b6664", + "size_bytes": 1163776 + }, + "Heltec_tower_v2_sdcard_repeater_lora_ota_no_external_sensors-reduced-ota-v1.18.0.2-halo-keymind-cascade-dev-70c7d5b4.zip": { + "sha256": "096a9c11f2a59e250ce15f6086958b99aa68311d22382b80f6eba2ddbcd849d8", + "size_bytes": 582487 + } + } + } + ] + }, + { + "id": "rak3401", + "board": "RAK 3401 MQTT gateway", + "source_commit": "70c7d5b478b19f16d232389779ca455e73be7eba", + "role": "repeater", + "target": "RAK_3401_repeater_lora_ota_no_external_sensors", + "provisional_role_only": false, + "profiles": [ + { + "artifact_target": "RAK_3401_repeater_lora_ota_no_external_sensors-full-ota", + "passed": true, + "platformio_environment": "RAK_3401_repeater", + "capability_checks": 14, + "available_internal_bytes": 80180, + "required_heap_bytes": 75920, + "required_contiguous_bytes": 19168, + "elf_sha256": "a3dc20dae8139eaad36692eaeb865db1f469ce318da1540f9c8b3cc9e714ea8b", + "packages": { + "RAK_3401_repeater_lora_ota_no_external_sensors-full-ota-v1.18.0.2-halo-keymind-cascade-dev-70c7d5b4.uf2": { + "sha256": "dc984124d3ab9a1e2eb1164363dd1bad217e2ac33dfce6d072f144c0e21d9f39", + "size_bytes": 1255936 + }, + "RAK_3401_repeater_lora_ota_no_external_sensors-full-ota-v1.18.0.2-halo-keymind-cascade-dev-70c7d5b4.zip": { + "sha256": "13ddc99a9fea2650880303e01ad658e0974129ca145ad0d11d2efceb288de045", + "size_bytes": 628614 + } + } + }, + { + "artifact_target": "RAK_3401_repeater_lora_ota_no_external_sensors-reduced-ota", + "passed": true, + "platformio_environment": "RAK_3401_repeater", + "capability_checks": 12, + "available_internal_bytes": 82704, + "required_heap_bytes": 73872, + "required_contiguous_bytes": 19168, + "elf_sha256": "387e2321b9a6b0b66ff4dfd9abbb437baa6a0752fb39ec94154a553b663fa8e1", + "packages": { + "RAK_3401_repeater_lora_ota_no_external_sensors-reduced-ota-v1.18.0.2-halo-keymind-cascade-dev-70c7d5b4.uf2": { + "sha256": "02aca2ecf2b7465ac0a6e7758447b6fc5d97bc979ec615a63a13a239011597c7", + "size_bytes": 1137664 + }, + "RAK_3401_repeater_lora_ota_no_external_sensors-reduced-ota-v1.18.0.2-halo-keymind-cascade-dev-70c7d5b4.zip": { + "sha256": "c16c281b6afc085a42982dd7ec5e7b6490ac2a496070636e121fbae0f7fc8d5b", + "size_bytes": 569479 + } + } + } + ] + }, + { + "id": "rak-repeater", + "board": "RAK 4631", + "source_commit": "70c7d5b478b19f16d232389779ca455e73be7eba", + "role": "repeater", + "target": "RAK_4631_repeater", + "provisional_role_only": false, + "profiles": [ + { + "artifact_target": "RAK_4631_repeater_lora_ota_no_external_sensors-full-ota", + "passed": true, + "platformio_environment": "RAK_4631_repeater", + "capability_checks": 16, + "available_internal_bytes": 83156, + "required_heap_bytes": 83136, + "required_contiguous_bytes": 19168, + "elf_sha256": "c5c4da20454452fecd06edfe735116cacafedfb04ddde5adbefca5df72ab74de", + "packages": { + "RAK_4631_repeater_lora_ota_no_external_sensors-full-ota-v1.18.0.2-halo-keymind-cascade-dev-70c7d5b4.uf2": { + "sha256": "1480d35f4e4d5baf90306c813986de3ebb8ef3fa4a568886ef35e4f70c068fe0", + "size_bytes": 1375232 + }, + "RAK_4631_repeater_lora_ota_no_external_sensors-full-ota-v1.18.0.2-halo-keymind-cascade-dev-70c7d5b4.zip": { + "sha256": "915ced25f967ec302eacd218fdc10ee776eca579e71f61bdb89a3bc705f189e6", + "size_bytes": 688411 + } + } + }, + { + "artifact_target": "RAK_4631_repeater_lora_ota_no_external_sensors-reduced-ota", + "passed": true, + "platformio_environment": "RAK_4631_repeater", + "capability_checks": 14, + "available_internal_bytes": 85696, + "required_heap_bytes": 81088, + "required_contiguous_bytes": 19168, + "elf_sha256": "7d71696ca63e1443d56f87cf66fa45b096a2e59b34e094ae9844a1b1b1595334", + "packages": { + "RAK_4631_repeater_lora_ota_no_external_sensors-reduced-ota-v1.18.0.2-halo-keymind-cascade-dev-70c7d5b4.uf2": { + "sha256": "189e01d215f5cfa644b1f06cf4edfe58eadc69cecb87e5f666d8a6009cb99b36", + "size_bytes": 1240576 + }, + "RAK_4631_repeater_lora_ota_no_external_sensors-reduced-ota-v1.18.0.2-halo-keymind-cascade-dev-70c7d5b4.zip": { + "sha256": "142f4ef30011671cf1f51cafddaa55eb51bfd5873b3b59daccf4440fe9b795ea", + "size_bytes": 620916 + } + } + } + ] + }, + { + "id": "xiao_nrf-repeater", + "board": "Seeed XIAO nRF52", + "source_commit": "70c7d5b478b19f16d232389779ca455e73be7eba", + "role": "repeater", + "target": "Xiao_nrf52_repeater", + "provisional_role_only": false, + "profiles": [ + { + "artifact_target": "Xiao_nrf52_repeater-full-ota", + "passed": true, + "platformio_environment": "Xiao_nrf52_repeater", + "capability_checks": 11, + "available_internal_bytes": 151636, + "required_heap_bytes": 76992, + "required_contiguous_bytes": 19168, + "elf_sha256": "5aa28ef7b564bb1556b6aea63eee7ce2bb16869241a8a65c883cc66ca4391247", + "packages": { + "Xiao_nrf52_repeater-full-ota-v1.18.0.2-halo-keymind-cascade-dev-70c7d5b4.uf2": { + "sha256": "41e1bb51ade1e585392e49d35e9582f1627c9318dd51e8af5d9f9042ac747f0f", + "size_bytes": 1208832 + }, + "Xiao_nrf52_repeater-full-ota-v1.18.0.2-halo-keymind-cascade-dev-70c7d5b4.zip": { + "sha256": "48c81181a776519da471557c3fcb330f4427254c2adebd9752bca2dc3c28bc50", + "size_bytes": 605087 + } + } + }, + { + "artifact_target": "Xiao_nrf52_repeater-reduced-ota", + "passed": true, + "platformio_environment": "Xiao_nrf52_repeater", + "capability_checks": 9, + "available_internal_bytes": 152824, + "required_heap_bytes": 74944, + "required_contiguous_bytes": 19168, + "elf_sha256": "471d983d14203f02ede5d1c95ab3d560c67026bbb9660c690263e8752668d623", + "packages": { + "Xiao_nrf52_repeater-reduced-ota-v1.18.0.2-halo-keymind-cascade-dev-70c7d5b4.uf2": { + "sha256": "047a165c4d38c4759102847bc107ec2627dd87f1a2cb74d9b04539aa827a7584", + "size_bytes": 1136128 + }, + "Xiao_nrf52_repeater-reduced-ota-v1.18.0.2-halo-keymind-cascade-dev-70c7d5b4.zip": { + "sha256": "a55ac23f3fbc181e92cac4377017aede7c608322597ff17dc67bf7f731be7700", + "size_bytes": 568731 + } + } + } + ] + }, + { + "id": "v42", + "board": "Heltec V4.2", + "source_commit": "c9acec474bb9617d4f92dbdc0401765701989288", + "role": "repeater", + "target": "heltec_v4_repeater_observer_mqtt", + "provisional_role_only": false, + "profiles": [ + { + "artifact_target": "heltec_v4_repeater_observer_mqtt", + "passed": true, + "platformio_environment": "heltec_v4_repeater_observer_mqtt", + "capability_checks": 16, + "available_internal_bytes": 240144, + "required_heap_bytes": 238732, + "required_contiguous_bytes": 19168, + "elf_sha256": "9be28505365f4e3dd8700d3ca3b1cd707022f034594ecdbba301147a5ba07700", + "packages": { + "heltec_v4_repeater_observer_mqtt-full-usb-wifi-ota-v1.18.0.2-halo-keymind-cascade-dev-c9acec47-merged.bin": { + "sha256": "8f5ad7e2184a9a3053925eafe57b98448a0dd5ac59c56c9b3ecaaa93c1377f7b", + "size_bytes": 2242728 + }, + "heltec_v4_repeater_observer_mqtt-full-usb-wifi-ota-v1.18.0.2-halo-keymind-cascade-dev-c9acec47.bin": { + "sha256": "ba151fab2c6e6eca6b3025bb2df394fd250372faee0b4c5966291c72ebc3ab29", + "size_bytes": 2177192 + } + } + } + ] + }, + { + "id": "v3-repeater", + "board": "Heltec V3", + "source_commit": "c9acec474bb9617d4f92dbdc0401765701989288", + "role": "repeater", + "target": "Heltec_v3_repeater", + "provisional_role_only": false, + "profiles": [ + { + "artifact_target": "Heltec_v3_repeater_observer_mqtt", + "passed": true, + "platformio_environment": "Heltec_v3_repeater_observer_mqtt", + "capability_checks": 17, + "available_internal_bytes": 211536, + "required_heap_bytes": 209072, + "required_contiguous_bytes": 19168, + "elf_sha256": "2a92176aae57b59e5529ed99f55167797ab301f838bf4f95ee9234e23b22ca53", + "packages": { + "Heltec_v3_repeater_observer_mqtt-full-usb-wifi-ota-v1.18.0.2-halo-keymind-cascade-dev-c9acec47-merged.bin": { + "sha256": "bb53308a65a5873c8efe3a0f7ef285d7e951e86d2251243512b13e9705876bef", + "size_bytes": 2188136 + }, + "Heltec_v3_repeater_observer_mqtt-full-usb-wifi-ota-v1.18.0.2-halo-keymind-cascade-dev-c9acec47.bin": { + "sha256": "d983f8d27adb4a7d5fa9e9bd4ea6fc39925e7a54f75ae4a90f8dbb5ea68423ee", + "size_bytes": 2122600 + } + } + } + ] + }, + { + "id": "tdeck-repeater", + "board": "LilyGo T-Deck", + "source_commit": "c9acec474bb9617d4f92dbdc0401765701989288", + "role": "repeater", + "target": "LilyGo_TDeck_repeater", + "provisional_role_only": false, + "profiles": [ + { + "artifact_target": "LilyGo_TDeck_repeater", + "passed": true, + "platformio_environment": "LilyGo_TDeck_repeater", + "capability_checks": 14, + "available_internal_bytes": 260712, + "required_heap_bytes": 208012, + "required_contiguous_bytes": 19168, + "elf_sha256": "b5e5f6b31124fb4c2f41920cd7c460fac560502f7da55e073aeb246e24142f13", + "packages": { + "LilyGo_TDeck_repeater-full-logging-ota-v1.18.0.2-halo-keymind-cascade-dev-c9acec47-merged.bin": { + "sha256": "d47648cb484144df9b0d0ce14277255b4ff94356dbe3d483a448b35d7b9c4a36", + "size_bytes": 1833368 + }, + "LilyGo_TDeck_repeater-full-logging-ota-v1.18.0.2-halo-keymind-cascade-dev-c9acec47.bin": { + "sha256": "83a73ddd7add2a98fef58b2a7c1335157e7ee4633d959923d78f876ec2f6b9d2", + "size_bytes": 1767832 + } + } + } + ] + }, + { + "id": "indicator-repeater", + "board": "SenseCAP Indicator", + "source_commit": "c9acec474bb9617d4f92dbdc0401765701989288", + "role": "repeater", + "target": "SenseCapIndicator-LoRa_repeater", + "provisional_role_only": false, + "profiles": [ + { + "artifact_target": "SenseCapIndicator-LoRa_repeater", + "passed": true, + "platformio_environment": "SenseCapIndicator-LoRa_repeater", + "capability_checks": 14, + "available_internal_bytes": 261224, + "required_heap_bytes": 260236, + "required_contiguous_bytes": 52224, + "elf_sha256": "2781d4d0ced339f734cc897e4a49a610931ca82b1426768cfebf2e91e9a8576e", + "packages": { + "SenseCapIndicator-LoRa_repeater-full-logging-ota-v1.18.0.2-halo-keymind-cascade-dev-c9acec47-merged.bin": { + "sha256": "e412d6486c5d21d1dfe960c5af6584ae593dcdb9e3f79e2900275b36abd0f73e", + "size_bytes": 1878952 + }, + "SenseCapIndicator-LoRa_repeater-full-logging-ota-v1.18.0.2-halo-keymind-cascade-dev-c9acec47.bin": { + "sha256": "78c494a116f4a233a9d8c72be2a80fbf0bf71506028723cec18b358dac1fd2ab", + "size_bytes": 1813416 + } + } + } + ] + }, + { + "id": "xiao", + "board": "Seeed XIAO ESP32-S3", + "source_commit": "c9acec474bb9617d4f92dbdc0401765701989288", + "role": "companion", + "target": "Xiao_S3_WIO_companion_radio_full", + "provisional_role_only": true, + "profiles": [ + { + "artifact_target": "Xiao_S3_WIO_companion_radio_full", + "passed": true, + "platformio_environment": "Xiao_S3_WIO_companion_radio_full", + "capability_checks": 26, + "available_internal_bytes": 226280, + "required_heap_bytes": 171808, + "required_contiguous_bytes": 18464, + "elf_sha256": "db3a11b23b03f1a0e0753d56e2b058b2ca319f14a97b9f8c7087b7bc18d20c90", + "packages": { + "Xiao_S3_WIO_companion_radio_full-v1.18.0.2-halo-keymind-cascade-dev-c9acec47-merged.bin": { + "sha256": "ee65b21fbc0ab3e67740c11a37563103fad1d5353e3db0609d1ce26b6bafa0f9", + "size_bytes": 2336168 + }, + "Xiao_S3_WIO_companion_radio_full-v1.18.0.2-halo-keymind-cascade-dev-c9acec47.bin": { + "sha256": "fe7195113335b2f3abfacab004654b25c80cc1f50edf5364cc25c8fb35d3f524", + "size_bytes": 2270632 + } + } + } + ] + } + ], + "manifest_hashes": { + "build_status.json": "904904b4e23d0b7c75ff549b375401d59efec441951f83834e1a01fffd04e8e1", + "fixed-70c7d5b4/build_status.json": "11b2ca7f73edf789c17ce5b3696b42c9c883d11d879294238e95f2a5a743f7e2" + } + }, + "corrected_companion_builds": { + "schema": 1, + "source_commit": "226ccc332d29e78c3dc69cdb70a228baca403711", + "firmware_version": "v1.18.0.2-halo-keymind-cascade-dev", + "boards": [ + { + "id": "t1000", + "board": "Seeed T1000-E", + "target": "t1000e_companion_radio_full", + "passed": true, + "source_commit": "226ccc332d29e78c3dc69cdb70a228baca403711", + "full_version": "v1.18.0.2-halo-keymind-cascade-dev-226ccc33", + "platformio_environment": "t1000e_companion_radio_usb", + "capability_checks": 14, + "available_internal_bytes": 88572, + "required_heap_bytes": 46912, + "required_contiguous_bytes": 6176, + "linked_ram_admission_unchanged": true, + "elf_sha256": "09cd8b9df308ac6b4a93ece0e1f2e4558030796d31fb50b215bfa37a099978ce", + "packages": { + "t1000e_companion_radio_full-v1.18.0.2-halo-keymind-cascade-dev-226ccc33.uf2": { + "sha256": "7635e59c99066be2a9fd3e5a99e593dec89b2810ccf601766759993a97544dfd", + "size_bytes": 1227264 + }, + "t1000e_companion_radio_full-v1.18.0.2-halo-keymind-cascade-dev-226ccc33.zip": { + "sha256": "65c8d9c7f0927890cf25d54322d499dd7a6b5d4d6ce6e6e2a677f973716db4eb", + "size_bytes": 614315 + } + } + }, + { + "id": "tag", + "board": "RAK WisMesh Tag", + "target": "RAK_WisMesh_Tag_companion_radio_full", + "passed": true, + "source_commit": "226ccc332d29e78c3dc69cdb70a228baca403711", + "full_version": "v1.18.0.2-halo-keymind-cascade-dev-226ccc33", + "platformio_environment": "RAK_WisMesh_Tag_companion_radio_usb", + "capability_checks": 15, + "available_internal_bytes": 84292, + "required_heap_bytes": 46912, + "required_contiguous_bytes": 6176, + "linked_ram_admission_unchanged": true, + "elf_sha256": "a7bb90a8be79aa9ecb181ce6e51a299a650ddc71151ab8cacff57c12fc5faad9", + "packages": { + "RAK_WisMesh_Tag_companion_radio_full-v1.18.0.2-halo-keymind-cascade-dev-226ccc33.uf2": { + "sha256": "2d4d03db6401b6adc93c70f8ae7eb5dfe9e1b23029e1415d1c63961355468676", + "size_bytes": 1308672 + }, + "RAK_WisMesh_Tag_companion_radio_full-v1.18.0.2-halo-keymind-cascade-dev-226ccc33.zip": { + "sha256": "c9da30b5d898c61587982c58b19e2f3593246a21a75f16599948dc965a2741db", + "size_bytes": 655023 + } + } + }, + { + "id": "t096", + "board": "Heltec T096, FEM on", + "target": "Heltec_t096_companion_radio_full_femon", + "passed": true, + "source_commit": "226ccc332d29e78c3dc69cdb70a228baca403711", + "full_version": "v1.18.0.2-halo-keymind-cascade-dev-226ccc33", + "platformio_environment": "Heltec_t096_companion_radio_usb_femon", + "capability_checks": 15, + "available_internal_bytes": 84212, + "required_heap_bytes": 73728, + "required_contiguous_bytes": 13825, + "linked_ram_admission_unchanged": true, + "elf_sha256": "01c80477bfd45d5034821ad3a166092467b7f202882cbeab785f5252ad00f55a", + "packages": { + "Heltec_t096_companion_radio_full_femon-v1.18.0.2-halo-keymind-cascade-dev-226ccc33.uf2": { + "sha256": "98b89675313591099fe0d2951c3064c3f492fb87d16578c2914580b675a72954", + "size_bytes": 1331200 + }, + "Heltec_t096_companion_radio_full_femon-v1.18.0.2-halo-keymind-cascade-dev-226ccc33.zip": { + "sha256": "3e0ad88ca18a1f85811f0dbfc440466a11735c8c6e9db951265a8815c020d9d4", + "size_bytes": 666167 + } + } + }, + { + "id": "pocket", + "board": "Heltec MeshPocket", + "target": "Mesh_pocket_companion_radio_full", + "passed": true, + "source_commit": "226ccc332d29e78c3dc69cdb70a228baca403711", + "full_version": "v1.18.0.2-halo-keymind-cascade-dev-226ccc33", + "platformio_environment": "Mesh_pocket_companion_radio_usb", + "capability_checks": 15, + "available_internal_bytes": 82604, + "required_heap_bytes": 59200, + "required_contiguous_bytes": 12288, + "linked_ram_admission_unchanged": true, + "elf_sha256": "1953d90b9bfd53bf78f754c2e527ab0586b10086f2bc265a02c3f2e04773f298", + "packages": { + "Mesh_pocket_companion_radio_full-v1.18.0.2-halo-keymind-cascade-dev-226ccc33.uf2": { + "sha256": "1787ae7b7bc88f87300183f0a1e20cf0ee34397ced3504584eb7eb3a5f4b1d79", + "size_bytes": 1406976 + }, + "Mesh_pocket_companion_radio_full-v1.18.0.2-halo-keymind-cascade-dev-226ccc33.zip": { + "sha256": "4f85629da02a3826b8548a106fa199262408237c9f44aa78e950f5ab7ff1a994", + "size_bytes": 704199 + } + } + } + ], + "scope": "Four corrected Full nRF52 Companions; build admission only, physical retests separate." + }, + "additional_builds_only": { + "source_commit": "5a72a991227a8c3062a8291091862289e4cc6807", + "firmware_version": "v1.18.0.2-halo-keymind-cascade-dev", + "boards": [ + { + "id": "xiao-s3-repeater", + "target": "Xiao_S3_WIO_repeater_observer_mqtt", + "status": "passed", + "exit_code": 0, + "elapsed_seconds": 159.303, + "artifacts": { + "Xiao_S3_WIO_repeater_observer_mqtt-full-usb-wifi-ota-v1.18.0.2-halo-keymind-cascade-dev-5a72a991-merged.bin": { + "sha256": "1ff1aafa6ee21313bda1339f7d07cc1cdc01b3822d3f46d51ca25fc39aff3883", + "size_bytes": 2132168 + }, + "Xiao_S3_WIO_repeater_observer_mqtt-full-usb-wifi-ota-v1.18.0.2-halo-keymind-cascade-dev-5a72a991.bin": { + "sha256": "525ac663e9b08fc29e2c5b55e3c04ac6b111345c74dff63f558096c8dfc142a4", + "size_bytes": 2066632 + }, + "Xiao_S3_WIO_repeater_observer_mqtt-full-usb-wifi-ota-v1.18.0.2-halo-keymind-cascade-dev-5a72a991.capabilities.json": { + "sha256": "4db9b64086c1de1eb9a7474a63747a50f56bdea7057992a74d35e9917d7a74ad", + "size_bytes": 3903 + }, + "Xiao_S3_WIO_repeater_observer_mqtt-full-usb-wifi-ota-v1.18.0.2-halo-keymind-cascade-dev-5a72a991.memory.json": { + "sha256": "285dcc62a45e03d30c5cb72d2d10f36f1df00b4b2b213bf30ac387a591a8956d", + "size_bytes": 4289 + }, + "Xiao_S3_WIO_repeater_observer_mqtt-full-usb-wifi-ota-v1.18.0.2-halo-keymind-cascade-dev-5a72a991.meshtext-license.txt": { + "sha256": "c37d0a453a2491514bac33ed835c1877f207390f134ae439a97b06b004e236d2", + "size_bytes": 1945 + }, + "Xiao_S3_WIO_repeater_observer_mqtt-full.partsig": { + "sha256": "a743a4253cddb196524eca11ec4ca63235f8116ed4e5db0363a75842bd7a41b7", + "size_bytes": 101 + } + }, + "selected_memory_manifest": "Xiao_S3_WIO_repeater_observer_mqtt-full-usb-wifi-ota-v1.18.0.2-halo-keymind-cascade-dev-5a72a991.memory.json" + }, + { + "id": "rak-full-companion", + "target": "RAK_4631_companion_radio_full", + "status": "passed", + "exit_code": 0, + "elapsed_seconds": 32.675, + "artifacts": { + "RAK_4631_companion_radio_full-v1.18.0.2-halo-keymind-cascade-dev-5a72a991.capabilities.json": { + "sha256": "2bd800847e7413b09904973b9fb4d12bd4cca85f5b6d4bd8e5ff66068f4318c8", + "size_bytes": 4336 + }, + "RAK_4631_companion_radio_full-v1.18.0.2-halo-keymind-cascade-dev-5a72a991.memory.json": { + "sha256": "327c18cd698ffd5b70e4ba47bd2b36e140494ff556d6c8b019b48152844dd35d", + "size_bytes": 1908 + }, + "RAK_4631_companion_radio_full-v1.18.0.2-halo-keymind-cascade-dev-5a72a991.meshtext-license.txt": { + "sha256": "c37d0a453a2491514bac33ed835c1877f207390f134ae439a97b06b004e236d2", + "size_bytes": 1945 + }, + "RAK_4631_companion_radio_full-v1.18.0.2-halo-keymind-cascade-dev-5a72a991.uf2": { + "sha256": "81e15c63eb5a6e78636ec6dda53d9e0c0e693f5cb187a44a10499fd33df1a0c0", + "size_bytes": 1425408 + }, + "RAK_4631_companion_radio_full-v1.18.0.2-halo-keymind-cascade-dev-5a72a991.zip": { + "sha256": "451ca856bf21b294318769665a26b03e9cf68858655b9b58acdfd036ef12b248", + "size_bytes": 713423 + } + }, + "selected_memory_manifest": "RAK_4631_companion_radio_full-v1.18.0.2-halo-keymind-cascade-dev-5a72a991.memory.json" + } + ] + }, + "rak_ci_dependency_matched_build_only": { + "source_commit": "f6034e1f029b9ce09a3149db6dfc48ef4ef51b7e", + "exit_code": 0, + "scope": "RAK Full Companion with CI library versions and maximum dev version length", + "artifacts": { + "RAK_4631_companion_radio_full-v1.18.0.1-halo-keymind-cascade-dev-f6034e1f.capabilities.json": { + "sha256": "cff05e4631188a195d707fcdfdf2660866af44e38fbb730669e5c8f68bba4ead", + "size_bytes": 4336 + }, + "RAK_4631_companion_radio_full-v1.18.0.1-halo-keymind-cascade-dev-f6034e1f.memory.json": { + "sha256": "549e40a92a56d744a4571543827ecf9667af99501d0797c5e0748758c7016ff7", + "size_bytes": 1908 + }, + "RAK_4631_companion_radio_full-v1.18.0.1-halo-keymind-cascade-dev-f6034e1f.meshtext-license.txt": { + "sha256": "c37d0a453a2491514bac33ed835c1877f207390f134ae439a97b06b004e236d2", + "size_bytes": 1945 + }, + "RAK_4631_companion_radio_full-v1.18.0.1-halo-keymind-cascade-dev-f6034e1f.uf2": { + "sha256": "cf959e0a10e9bcd7acda3989e9aa51463c632568868ea9fc81bf84d34282bf6e", + "size_bytes": 1425408 + }, + "RAK_4631_companion_radio_full-v1.18.0.1-halo-keymind-cascade-dev-f6034e1f.zip": { + "sha256": "20e1cb88283c3c600902f1895678b664fd07fd2029df7b303df589750e99e3bd", + "size_bytes": 713455 + } + } + }, + "build_only_limitations": [ + "Initial XIAO ESP32-S3 Companion packages were provisional and never installed; its later observer Repeater build is also not installed.", + "RAK Full Companion builds are capacity checks, not the installed RAK Repeater application." + ], + "companions": [ + { + "board": "Heltec V4.3 OLED FEM off", + "role": "Full Companion", + "installed_source_commit": "c9acec474bb9617d4f92dbdc0401765701989288", + "installed_full_version": "v1.18.0.2-halo-keymind-cascade-dev-c9acec47 (Build: 28-Oct-2026)", + "installed_artifact_sha256": "fb320cf886e56b577e07f792a2d505213f0faf63e51305f5fab5ca120010f8d2", + "installed_artifact_kind": "active-slot application BIN", + "application_install": { + "passed": true, + "app_only": true, + "bootloader_written": false, + "partition_written": false, + "erase_requested": false, + "runtime_version_verified": true, + "identity_preserved": true, + "logical_settings_preserved": true, + "contacts_preserved": true, + "channels_preserved": true + }, + "radio2": { + "passed": true, + "lease_minutes": 1, + "mode": "rxtx", + "natural_expiry_verified": true, + "expiry_polls": 11, + "original_secondary_restored": true, + "automatic_owner_before": true, + "original_auto_owner_restored": true, + "persistent_radio_or_channel_setters_sent": false, + "intentional_rf_transmissions": 0, + "overlap_cases_completed": 0, + "overlap_cases_passed": 0 + }, + "binary_settings": { + "passed": true, + "binary_tx_setter_acknowledged": true, + "pending_saved_pin_readback_matches": true, + "pending_pin_readback_survives_usb_reopen": true, + "original_settings_restored": true, + "physical_tx_power_measured": false, + "hardware_application_verified": false, + "pin_cold_persistence_tested": false, + "rf_send_commands": 0, + "reboots_requested": 0 + }, + "ble_trials": [ + { + "attempt": "20261028T141400.938310Z", + "strict_passed": false, + "condition": "normal mounted USB; normal host WiFi", + "host_wifi_condition": "normal host WiFi; no WiFi change requested during this trial", + "measurement_started_utc": "2026-10-28T14:14:00.939214+00:00", + "measurement_finished_utc": "2026-10-28T14:14:34.351967+00:00", + "source_summary_sha256": "b94fa13e1c055c87a2dcc2b82ff2b3cf8c1aa33070654cbc995617e58ac44bef", + "target_bond_removal_requested": false, + "hci": { + "clean": false, + "target_successful_connection_events": 1, + "target_failed_establishments": 0, + "target_command_failures": 0, + "target_disconnect_reasons": [ + "0x16" + ], + "target_disconnect_statuses": [ + "0x00" + ], + "unattributed_controller_events": 0, + "lifecycle_parse_errors": 0, + "target_open_handles_at_end": 0, + "target_pending_attempts_at_end": 0, + "capture_ended_before_timeout": true, + "source_summary_sha256": "d4ac639283571ff60fe41e12bab3ea4e3159b91c2218b0208da4770cb24b45f3" + }, + "qualification_payload_worker_ran": true, + "passed": false, + "sessions_requested": 3, + "sessions_completed": 0, + "hold_seconds_requested": 30.0, + "adapter_reset": false, + "broad_bond_changes": false, + "session_hold_seconds": [], + "subject_provenance": { + "board": "Heltec V4.3 OLED FEM off", + "target": "heltec_v4_3_companion_radio_full_femoff", + "source_commit": "c9acec474bb9617d4f92dbdc0401765701989288", + "artifact_kind": "application BIN", + "artifact_sha256": "fb320cf886e56b577e07f792a2d505213f0faf63e51305f5fab5ca120010f8d2" + } + } + ], + "rf_trials": [ + { + "attempt": "rf-tag-v43-run1", + "harness_passed": false, + "rounds_completed": 3, + "offline_queue_reads": 0, + "rf_send_attempts": 6, + "rf_retries": 0, + "cleanup": { + "v43": { + "passed": false, + "failure_type": "RuntimeError" + }, + "tag": { + "saved_getters_equal": true, + "channel_names_and_keys_equal": true, + "contact_static_records_equal": true, + "contact_live_timestamp_changes": 0, + "binary_preferences_equal": true, + "offline_queue_reads": 0 + } + }, + "legs": [ + { + "passed": true, + "elapsed_seconds": 0.406, + "path_bytes": 0, + "snr_db": 12.0, + "rssi_dbm": -51, + "full_usb_bound_peer_ids_and_nonce_verified": true, + "sender_packet_delta": { + "received": 0, + "sent": 1, + "sent_flood": 0, + "sent_direct": 1, + "received_flood": 0, + "received_direct": 0, + "receive_errors": 0 + }, + "receiver_packet_delta": { + "received": 1, + "sent": 0, + "sent_flood": 0, + "sent_direct": 0, + "received_flood": 0, + "received_direct": 1, + "receive_errors": 0 + }, + "offline_queue_reads": 0, + "round": 1, + "direction": "hub_to_peer" + }, + { + "passed": true, + "elapsed_seconds": 0.414, + "path_bytes": 0, + "snr_db": 12.5, + "rssi_dbm": -35, + "full_usb_bound_peer_ids_and_nonce_verified": true, + "sender_packet_delta": { + "received": 0, + "sent": 1, + "sent_flood": 0, + "sent_direct": 1, + "received_flood": 0, + "received_direct": 0, + "receive_errors": 0 + }, + "receiver_packet_delta": { + "received": 1, + "sent": 0, + "sent_flood": 0, + "sent_direct": 0, + "received_flood": 0, + "received_direct": 1, + "receive_errors": 0 + }, + "offline_queue_reads": 0, + "round": 1, + "direction": "peer_to_hub" + }, + { + "passed": true, + "elapsed_seconds": 0.413, + "path_bytes": 0, + "snr_db": 11.0, + "rssi_dbm": -51, + "full_usb_bound_peer_ids_and_nonce_verified": true, + "sender_packet_delta": { + "received": 0, + "sent": 1, + "sent_flood": 0, + "sent_direct": 1, + "received_flood": 0, + "received_direct": 0, + "receive_errors": 0 + }, + "receiver_packet_delta": { + "received": 1, + "sent": 0, + "sent_flood": 0, + "sent_direct": 0, + "received_flood": 0, + "received_direct": 1, + "receive_errors": 0 + }, + "offline_queue_reads": 0, + "round": 2, + "direction": "hub_to_peer" + }, + { + "passed": true, + "elapsed_seconds": 0.414, + "path_bytes": 0, + "snr_db": 12.5, + "rssi_dbm": -34, + "full_usb_bound_peer_ids_and_nonce_verified": true, + "sender_packet_delta": { + "received": 0, + "sent": 1, + "sent_flood": 0, + "sent_direct": 1, + "received_flood": 0, + "received_direct": 0, + "receive_errors": 0 + }, + "receiver_packet_delta": { + "received": 1, + "sent": 0, + "sent_flood": 0, + "sent_direct": 0, + "received_flood": 0, + "received_direct": 1, + "receive_errors": 0 + }, + "offline_queue_reads": 0, + "round": 2, + "direction": "peer_to_hub" + }, + { + "passed": true, + "elapsed_seconds": 0.412, + "path_bytes": 0, + "snr_db": 11.0, + "rssi_dbm": -51, + "full_usb_bound_peer_ids_and_nonce_verified": true, + "sender_packet_delta": { + "received": 0, + "sent": 1, + "sent_flood": 0, + "sent_direct": 1, + "received_flood": 0, + "received_direct": 0, + "receive_errors": 0 + }, + "receiver_packet_delta": { + "received": 1, + "sent": 0, + "sent_flood": 0, + "sent_direct": 0, + "received_flood": 0, + "received_direct": 1, + "receive_errors": 0 + }, + "offline_queue_reads": 0, + "round": 3, + "direction": "hub_to_peer" + }, + { + "passed": true, + "elapsed_seconds": 0.417, + "path_bytes": 0, + "snr_db": 12.75, + "rssi_dbm": -34, + "full_usb_bound_peer_ids_and_nonce_verified": true, + "sender_packet_delta": { + "received": 0, + "sent": 1, + "sent_flood": 0, + "sent_direct": 1, + "received_flood": 0, + "received_direct": 0, + "receive_errors": 0 + }, + "receiver_packet_delta": { + "received": 1, + "sent": 0, + "sent_flood": 0, + "sent_direct": 0, + "received_flood": 0, + "received_direct": 1, + "receive_errors": 0 + }, + "offline_queue_reads": 0, + "round": 3, + "direction": "peer_to_hub" + } + ], + "offline_comparison_followup": { + "passed": true, + "method": "offline corrected static radio2 comparison only", + "new_hardware_access": false, + "new_rf_send_commands": 0, + "original_failed_summary_preserved": true, + "original_summary_sha256": "024759290a84c836196453089785c5351866df55894e539840b1f7e46b590b61", + "six_source_identity_nonce_counter_bound_zero_hop_legs_verified": true, + "both_owned_radio_tx_restorations_verified": true, + "source_summary_sha256": "749d1210c2d3a2141290b97c1f349b6e08d3f3f95b4849090cde1296c0387adc" + }, + "subject_provenance": { + "board": "Heltec V4.3 OLED FEM off", + "target": "heltec_v4_3_companion_radio_full_femoff", + "source_commit": "c9acec474bb9617d4f92dbdc0401765701989288", + "artifact_kind": "application BIN", + "artifact_sha256": "fb320cf886e56b577e07f792a2d505213f0faf63e51305f5fab5ca120010f8d2" + } + } + ], + "retained_attempt_notes": [ + "First RF trial completed all6physical legs and restored binary prefs/contacts/channels; original comparison refused only changing radio2 timing diagnostic suffix." + ], + "host_bond_followup_attempts": [ + { + "passed": false, + "source_commit": "c9acec474bb9617d4f92dbdc0401765701989288", + "expected_version": "v1.18.0.2-halo-keymind-cascade-dev-c9acec47", + "firmware_fix_claimed": false, + "diagnostic_changed_host_bond_setup": true, + "firmware_writes": 0, + "broad_unpair": false, + "adapter_reset": false, + "pi_wifi_changed": false, + "device_bond_clear_requested": false, + "failure_type": "FileNotFoundError", + "finished_utc": "2026-10-28T15:00:52.366773+00:00", + "attempt": "20261028T150046.093606Z", + "source_summary_sha256": "ae4023b70c8d6d894d5b7bf60f83fcb66ec91bc5a75be15450c1450f40c9dd22", + "host_removal_reached": false, + "pairing_measurement_reached": false, + "ble_phases": {}, + "read_only_cleanup_verifications": [] + }, + { + "passed": false, + "source_commit": "c9acec474bb9617d4f92dbdc0401765701989288", + "expected_version": "v1.18.0.2-halo-keymind-cascade-dev-c9acec47", + "firmware_fix_claimed": false, + "diagnostic_changed_host_bond_setup": true, + "firmware_writes": 0, + "broad_unpair": false, + "adapter_reset": false, + "pi_wifi_changed": false, + "device_bond_clear_requested": false, + "original_host_entry_privately_backed_up": true, + "unrelated_host_entries_byte_hash_equal": true, + "failure_type": "RuntimeError", + "cleanup_failure_type": "RuntimeError", + "finished_utc": "2026-10-28T15:08:46.150740+00:00", + "attempt": "20261028T150601.852558Z", + "source_summary_sha256": "6787fdeb49242ca0c2cccbd193083808fa9bc353aba6b6a080176ba8d1db916a", + "host_removal_reached": false, + "pairing_measurement_reached": false, + "ble_phases": {}, + "read_only_cleanup_verifications": [ + { + "passed": false, + "read_only_continuation": true, + "old_failure_retained": true, + "pin_setters_sent": 0, + "reboots_sent": 0, + "host_removals": 0, + "pairing_requests": 0, + "failure_type": "RuntimeError", + "source_summary_sha256": "5b4198b7a8d7a412de9b91e97c77f6d6da0f5c32b5a5a86cd7399bda05870dce" + }, + { + "passed": true, + "read_only_continuation": true, + "old_failure_retained": true, + "pin_setters_sent": 0, + "reboots_sent": 0, + "host_removals": 0, + "pairing_requests": 0, + "source_summary_sha256": "1a3857082962d231bb1ebf9870ec469554da1ae694b1f0f25b4f7707539328ab" + } + ] + }, + { + "passed": false, + "source_commit": "c9acec474bb9617d4f92dbdc0401765701989288", + "expected_version": "v1.18.0.2-halo-keymind-cascade-dev-c9acec47", + "firmware_fix_claimed": false, + "diagnostic_changed_host_bond_setup": true, + "firmware_writes": 0, + "broad_unpair": false, + "adapter_reset": false, + "pi_wifi_changed": false, + "device_bond_clear_requested": false, + "original_host_entry_privately_backed_up": true, + "original_saved_pin0_restored": true, + "original_identity_settings_contacts_channels_verified": true, + "unrelated_host_entries_byte_hash_equal": true, + "finished_utc": "2026-10-28T15:36:40.654958+00:00", + "attempt": "20261028T153246.428996Z", + "source_summary_sha256": "9be9f2605b6fa64b8eb4acdd029d0965ff828cd7993ee8b11b3739f46e5ea5ea", + "host_removal_reached": true, + "pairing_measurement_reached": true, + "ble_phases": { + "temporary-pin": { + "passed": false, + "sessions_completed": 0, + "trace_sha256": "5ca23a3bee050b895f01f130c186d0486c09534603eb0ee8658d90076526c142", + "ble_report_sha256": "bb25d8a7190f004f4c675397b98b4fd46389fbf623377611590528bc5a7ca23d", + "hci": { + "clean": false, + "target_successful_connection_events": 1, + "target_failed_establishments": 0, + "target_command_failures": 0, + "target_disconnect_reasons": [ + "0x16" + ], + "target_disconnect_statuses": [ + "0x00" + ], + "unattributed_controller_events": 0, + "lifecycle_parse_errors": 0, + "target_open_handles_at_end": 0, + "target_pending_attempts_at_end": 0, + "capture_ended_before_timeout": true + }, + "started_at": "2026-10-28T15:33:16.180827+00:00", + "finished_at": "2026-10-28T15:33:49.457109+00:00", + "hold_seconds_requested": 30.0, + "session_hold_seconds": [] + }, + "restored-settings": { + "passed": true, + "sessions_completed": 3, + "trace_sha256": "3d9537ac8947a542c024f75e7aa2950426d7a5eb5d0c5eb9318505b7ecf2540c", + "ble_report_sha256": "b394a01d4f9ca1fc7fc99cbdd4e6c386f95660db3b8b6ea5f14dc9f9df1a903d", + "hci": { + "clean": true, + "target_successful_connection_events": 3, + "target_failed_establishments": 0, + "target_command_failures": 0, + "target_disconnect_reasons": [ + "0x16", + "0x16", + "0x16" + ], + "target_disconnect_statuses": [ + "0x00", + "0x00", + "0x00" + ], + "unattributed_controller_events": 0, + "lifecycle_parse_errors": 0, + "target_open_handles_at_end": 0, + "target_pending_attempts_at_end": 0, + "capture_ended_before_timeout": true + }, + "started_at": "2026-10-28T15:34:18.704010+00:00", + "finished_at": "2026-10-28T15:36:33.670702+00:00", + "hold_seconds_requested": 30.0, + "session_hold_seconds": [ + 30.274, + 30.273, + 30.322 + ] + } + }, + "temporary_pair_host_daemon_crash": { + "sigsegv_events": 1, + "daemon_version_observed": "5.66", + "operation": "start_notify", + "source_journal_sha256": "f9bd461bc8b07090a651160bef8c9b1098bcbb9199b57fdb92d5c3c24746bed7" + }, + "read_only_cleanup_verifications": [] + } + ], + "usb": { + "passed": true, + "queries_requested": 100, + "persistent_cycles_completed": 100, + "reopens_requested": 10, + "reopens_completed": 10, + "channel_state_unchanged": true, + "cold_boot_tested": false, + "initial_core_stats": { + "battery_mv": 4209, + "error_flags": 0, + "outbound_queue_length": 0, + "uptime_seconds": 2025 + }, + "final_core_stats": { + "battery_mv": 4209, + "error_flags": 0, + "outbound_queue_length": 0, + "uptime_seconds": 23 + }, + "transport": { + "discarded_prefix_bytes": 0, + "ignored_push_frames": 0, + "invalid_length_candidates": 0, + "ports_closed": 31, + "ports_opened": 31, + "request_bytes_sent": 6189, + "request_frames_sent": 713, + "response_frames_received": 715, + "response_payload_bytes": 28166 + }, + "owned_contact_and_warm_reboot": { + "passed": true, + "crud_requested": true, + "create_update_readback": true, + "reopen_readback": true, + "owned_record_survives_warm_reboot": true, + "owned_contact_removed_and_reopen_verified": true, + "warm_reboot_verified": true, + "reboot_requests_sent": 1, + "uptime_before_reboot": 2109, + "uptime_after_reboot": 13 + }, + "stable_binary_tail_summary": { + "sessions_checked": 30, + "all_passed": true, + "maximum_buffered_bytes": 0, + "maximum_input_bytes": 0, + "source_summary_sha256": "53e4e11ec8f1166955649359075a4c920cf29d5e275203c531b43bd24b1d7177" + } + }, + "installed_artifact_target": "heltec_v4_3_companion_radio_full_femoff", + "ble_acceptance": { + "conditional_restored_settings_strict_passed": true, + "overall_followup_passed": false, + "condition": "Target-only host bond repair; original saved settings restored; temporary-PIN host daemon crash retained" + } + }, + { + "board": "Seeed Tracker T1000-E", + "installed_source_commit": "226ccc332d29e78c3dc69cdb70a228baca403711", + "installed_full_version": "v1.18.0.2-halo-keymind-cascade-dev-226ccc33 (Build: 28-Oct-2026)", + "installation": { + "passed": true, + "app_only": true, + "erase_requested": false, + "bootloader_written": false, + "softdevice_written": false, + "identity_preserved": true, + "contacts_preserved": true, + "channels_preserved": true, + "original_core_flags_recorded": 8 + }, + "installation_report_sha256": "2be8b729170fa5b3f03de203e713a6d90b713d5b4038ac04ef5368f0c49e9e42", + "usb": { + "passed": true, + "persistent_cycles_completed": 100, + "reopens_completed": 10, + "started_at": "2026-10-28T16:50:57.350596+00:00", + "finished_at": "2026-10-28T16:52:34.369288+00:00", + "channel_state_unchanged": true, + "contact_changes": { + "added": 0, + "other_live_record_changes": 0, + "removed": 0, + "timestamp_only_changed": 0, + "timestamp_regressions": 0 + }, + "uptime_segments_monotonic": true, + "final_core_stats": { + "battery_mv": 4196, + "error_flags": 0, + "outbound_queue_length": 0, + "uptime_seconds": 12 + }, + "transport": { + "discarded_prefix_bytes": 0, + "ignored_push_frames": 0, + "invalid_length_candidates": 0, + "ports_closed": 31, + "ports_opened": 31, + "request_bytes_sent": 6189, + "request_frames_sent": 713, + "response_frames_received": 718, + "response_payload_bytes": 28544 + }, + "report_sha256": "f76c600d261091be70e770d59b50d4bd66d28bf01cef82e5621544d2a294ce0c" + }, + "crud_warm_reboot": { + "passed": true, + "create_update_readback": true, + "reopen_readback": true, + "owned_record_survives_warm_reboot": true, + "owned_contact_removed_and_reopen_verified": true, + "reboot_requests_sent": 1, + "warm_reboot_verified": true, + "uptime_before_reboot": 409, + "uptime_after_reboot": 3 + }, + "stable_binary_tails": { + "count": 30, + "all_passed": true, + "max_elapsed_seconds": 0.050318, + "input_bytes": 0, + "buffered_bytes": 0 + }, + "radio2": { + "passed": true, + "automatic_owner_before": true, + "lease_minutes": 1, + "natural_expiry_verified": true, + "expiry_polls": 11, + "original_secondary_restored": true, + "original_auto_owner_restored": true, + "intentional_rf_transmissions": 0, + "overlap_cases": 0, + "overlap_cases_passed": true + }, + "ble_trials": [ + { + "condition": "normal host WiFi; exact target USB host-deauthorized; USB power present; 10s settle", + "started_at": "2026-10-28T17:13:38.937030+00:00", + "finished_at": "2026-10-28T17:14:34.359343+00:00", + "report_sha256": "64193cb6d01e14aa812c126c201dfe6c6be8873fa2538d625381c2f55d68c5c2", + "requested_checks_passed": false, + "ble": { + "status": "failed", + "passed": false, + "sessions_completed": 0, + "payload_sessions_passed": false, + "timed_out": false, + "trace_sha256": "9e883fab08db120d472a6ce8258a9a52042d88533f90ca963462f80534e25080", + "report_sha256": "a75331b65d12898ae2e98da83ae8a39e366d8e2d0a128b5ebc70affddfdbb948" + }, + "hci": { + "target_successful_connection_events": 2, + "target_failed_establishments": 0, + "target_command_failures": 0, + "target_disconnect_reasons": [ + "0x3e", + "0x13" + ], + "target_disconnect_statuses": [ + "0x00", + "0x00" + ], + "unattributed_controller_events": 0, + "lifecycle_parse_errors": 0, + "target_open_handles_at_end": 0, + "target_pending_attempts_at_end": 0, + "target_establishment_0x3e_disconnects": 1, + "target_timeout_0x08_disconnects": 0, + "target_ll_response_timeout_0x22_disconnects": 0, + "classification_complete": true, + "clean": false + }, + "phase_markers": { + "trace_sha256": "9e883fab08db120d472a6ce8258a9a52042d88533f90ca963462f80534e25080", + "bluetooth_service_crashes": 0, + "journal_sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "encryption_change_successes": 1, + "pin_or_key_missing_markers": 0, + "cause_assigned": false, + "failure_kind": "BleakGATTProtocolError", + "gatt_error_code": 14, + "gatt_error_name": "Unlikely Error", + "failure_operation": "write_gatt_char" + }, + "subject_provenance": { + "board": "Seeed Tracker T1000-E", + "target": "t1000e_companion_radio_full", + "source_commit": "226ccc332d29e78c3dc69cdb70a228baca403711", + "artifact_kind": "DFU ZIP", + "artifact_sha256": "65c8d9c7f0927890cf25d54322d499dd7a6b5d4d6ce6e6e2a677f973716db4eb" + } + } + ], + "rf": { + "status": "passed", + "trials": [ + { + "peer_board": "Seeed Tracker T1000-E", + "passed": true, + "started_utc": "2026-10-28T17:06:07.190513+00:00", + "finished_utc": "2026-10-28T17:06:27.387132+00:00", + "rounds": 3, + "legs": 6, + "all_legs_passed": true, + "all_zero_hop_identity_nonce_bound": true, + "max_elapsed_seconds": 0.522, + "packet_deltas": [ + { + "sender_packet_delta": { + "received": 0, + "sent": 1, + "sent_flood": 0, + "sent_direct": 1, + "received_flood": 0, + "received_direct": 0, + "receive_errors": 0 + }, + "receiver_packet_delta": { + "received": 1, + "sent": 0, + "sent_flood": 0, + "sent_direct": 0, + "received_flood": 0, + "received_direct": 1, + "receive_errors": 0 + } + }, + { + "sender_packet_delta": { + "received": 0, + "sent": 1, + "sent_flood": 0, + "sent_direct": 1, + "received_flood": 0, + "received_direct": 0, + "receive_errors": 0 + }, + "receiver_packet_delta": { + "received": 1, + "sent": 0, + "sent_flood": 0, + "sent_direct": 0, + "received_flood": 0, + "received_direct": 1, + "receive_errors": 0 + } + }, + { + "sender_packet_delta": { + "received": 0, + "sent": 1, + "sent_flood": 0, + "sent_direct": 1, + "received_flood": 0, + "received_direct": 0, + "receive_errors": 0 + }, + "receiver_packet_delta": { + "received": 1, + "sent": 0, + "sent_flood": 0, + "sent_direct": 0, + "received_flood": 0, + "received_direct": 1, + "receive_errors": 0 + } + }, + { + "sender_packet_delta": { + "received": 0, + "sent": 1, + "sent_flood": 0, + "sent_direct": 1, + "received_flood": 0, + "received_direct": 0, + "receive_errors": 0 + }, + "receiver_packet_delta": { + "received": 1, + "sent": 0, + "sent_flood": 0, + "sent_direct": 0, + "received_flood": 0, + "received_direct": 1, + "receive_errors": 0 + } + }, + { + "sender_packet_delta": { + "received": 0, + "sent": 1, + "sent_flood": 0, + "sent_direct": 1, + "received_flood": 0, + "received_direct": 0, + "receive_errors": 0 + }, + "receiver_packet_delta": { + "received": 1, + "sent": 0, + "sent_flood": 0, + "sent_direct": 0, + "received_flood": 0, + "received_direct": 1, + "receive_errors": 0 + } + }, + { + "sender_packet_delta": { + "received": 0, + "sent": 1, + "sent_flood": 0, + "sent_direct": 1, + "received_flood": 0, + "received_direct": 0, + "receive_errors": 0 + }, + "receiver_packet_delta": { + "received": 1, + "sent": 0, + "sent_flood": 0, + "sent_direct": 0, + "received_flood": 0, + "received_direct": 1, + "receive_errors": 0 + } + } + ], + "rf_retries": 0, + "offline_queue_reads": 0, + "channel_setters": 0, + "ota_workspace_acquired": false, + "cleanup": { + "t1000": { + "saved_getters_equal": true, + "channel_names_and_keys_equal": true, + "contact_static_records_equal": true, + "contact_live_timestamp_changes": 0, + "binary_preferences_equal": true, + "offline_queue_reads": 0 + }, + "tag": { + "saved_getters_equal": true, + "channel_names_and_keys_equal": true, + "contact_static_records_equal": true, + "contact_live_timestamp_changes": 0, + "binary_preferences_equal": true, + "offline_queue_reads": 0 + } + }, + "report_sha256": "37857800ab9560015192edb42e8b20033db7aaaeaa604d9fe243e29646a6606b", + "subject_provenance": { + "board": "Seeed Tracker T1000-E", + "target": "t1000e_companion_radio_full", + "source_commit": "226ccc332d29e78c3dc69cdb70a228baca403711", + "artifact_kind": "DFU ZIP", + "artifact_sha256": "65c8d9c7f0927890cf25d54322d499dd7a6b5d4d6ce6e6e2a677f973716db4eb" + } + } + ] + }, + "usb_host_release_phases": [ + { + "passed": false, + "condition": "real target-only USB host release; normal connected host WiFi; USB power remains present", + "started_at": "2026-10-28T17:13:25.826304+00:00", + "finished_at": "2026-10-28T17:14:37.891933+00:00", + "host_unmount_settle_seconds": 10, + "battery_or_cold_boot_qualified": false, + "firmware_writes": 0, + "intentional_rf_transmissions": 0, + "bond_changes_requested": false, + "adapter_changes_requested": false, + "host_wifi_changes_requested": false, + "report_sha256": "bbe9cff76179ad52e511fe7444388cfa98441ca9040dbd9c1683a2f7e26bff75", + "phases": [ + { + "phase": 1, + "passed": false, + "release_requests": 1, + "restore_requests": 1, + "ble_attempts": 1, + "started_at": "2026-10-28T17:13:27.069298+00:00", + "finished_at": "2026-10-28T17:14:37.883789+00:00", + "real_host_unmount_verified": true, + "ble_exit_code": 1, + "ble_report_sha256": "64193cb6d01e14aa812c126c201dfe6c6be8873fa2538d625381c2f55d68c5c2", + "ble_sessions_completed": 0, + "hci_clean": false, + "after_core": { + "battery_mv": 4198, + "uptime_seconds": 1335, + "error_flags": 0, + "outbound_queue_length": 0 + }, + "port_unowned_after_resume": true, + "ble_passed": false, + "usb_restored": true + } + ] + } + ], + "role": "Full Companion", + "installed_artifact_sha256": "65c8d9c7f0927890cf25d54322d499dd7a6b5d4d6ce6e6e2a677f973716db4eb", + "installed_artifact_kind": "application-only DFU ZIP", + "installed_artifact_target": "t1000e_companion_radio_full" + }, + { + "board": "RAK WisMesh Tag", + "installed_source_commit": "226ccc332d29e78c3dc69cdb70a228baca403711", + "installed_full_version": "v1.18.0.2-halo-keymind-cascade-dev-226ccc33 (Build: 28-Oct-2026)", + "installation": { + "passed": true, + "app_only": true, + "erase_requested": false, + "bootloader_written": false, + "softdevice_written": false, + "identity_preserved": true, + "contacts_preserved": true, + "channels_preserved": true, + "original_core_flags_recorded": 8 + }, + "installation_report_sha256": "c8a32e2ef4f1581abb23593c091fe8643cf284290ee3a102c864c011fd95fb02", + "usb": { + "passed": true, + "persistent_cycles_completed": 100, + "reopens_completed": 10, + "started_at": "2026-10-28T16:52:35.111363+00:00", + "finished_at": "2026-10-28T16:54:21.671316+00:00", + "channel_state_unchanged": true, + "contact_changes": { + "added": 0, + "other_live_record_changes": 0, + "removed": 0, + "timestamp_only_changed": 0, + "timestamp_regressions": 0 + }, + "uptime_segments_monotonic": true, + "final_core_stats": { + "battery_mv": 4072, + "error_flags": 0, + "outbound_queue_length": 0, + "uptime_seconds": 12 + }, + "transport": { + "discarded_prefix_bytes": 0, + "ignored_push_frames": 0, + "invalid_length_candidates": 0, + "ports_closed": 32, + "ports_opened": 32, + "request_bytes_sent": 6841, + "request_frames_sent": 743, + "response_frames_received": 760, + "response_payload_bytes": 31562 + }, + "report_sha256": "3abf2df87e388273bc5fd6dcd6fa6928c0470d6391f6e1fdc7402767e27e1fa5" + }, + "crud_warm_reboot": { + "passed": true, + "create_update_readback": true, + "reopen_readback": true, + "owned_record_survives_warm_reboot": true, + "owned_contact_removed_and_reopen_verified": true, + "reboot_requests_sent": 1, + "warm_reboot_verified": true, + "uptime_before_reboot": 444, + "uptime_after_reboot": 3 + }, + "stable_binary_tails": { + "count": 31, + "all_passed": true, + "max_elapsed_seconds": 0.050368, + "input_bytes": 0, + "buffered_bytes": 0 + }, + "radio2": { + "passed": true, + "automatic_owner_before": false, + "lease_minutes": 1, + "natural_expiry_verified": true, + "expiry_polls": 11, + "original_secondary_restored": true, + "original_auto_owner_restored": false, + "intentional_rf_transmissions": 0, + "overlap_cases": 2, + "overlap_cases_passed": true + }, + "ble_trials": [ + { + "condition": "normal host WiFi; USB enumerated", + "started_at": "2026-10-28T16:59:54.986329+00:00", + "finished_at": "2026-10-28T17:02:12.249957+00:00", + "report_sha256": "e059c9629fc9325128b4ca5b7970d588f21b86772a3eeb8df6b01048303cb08f", + "requested_checks_passed": true, + "ble": { + "status": "passed", + "passed": true, + "sessions_completed": 3, + "payload_sessions_passed": true, + "timed_out": false, + "trace_sha256": "fe7484eeba5197e27305adb295a00ea923014d5f8c58d7174887312495888033", + "report_sha256": "2e07ccaeb787d82c704c1b88793ae28c36078eba8806d482b4b042693c067862" + }, + "hci": { + "target_successful_connection_events": 3, + "target_failed_establishments": 0, + "target_command_failures": 0, + "target_disconnect_reasons": [ + "0x16", + "0x16", + "0x16" + ], + "target_disconnect_statuses": [ + "0x00", + "0x00", + "0x00" + ], + "unattributed_controller_events": 0, + "lifecycle_parse_errors": 0, + "target_open_handles_at_end": 0, + "target_pending_attempts_at_end": 0, + "target_establishment_0x3e_disconnects": 0, + "target_timeout_0x08_disconnects": 0, + "target_ll_response_timeout_0x22_disconnects": 0, + "classification_complete": true, + "clean": true + }, + "subject_provenance": { + "board": "RAK WisMesh Tag", + "target": "RAK_WisMesh_Tag_companion_radio_full", + "source_commit": "226ccc332d29e78c3dc69cdb70a228baca403711", + "artifact_kind": "DFU ZIP", + "artifact_sha256": "c9da30b5d898c61587982c58b19e2f3593246a21a75f16599948dc965a2741db" + } + } + ], + "rf": { + "status": "passed", + "trials": [ + { + "peer_board": "Seeed Tracker T1000-E", + "passed": true, + "started_utc": "2026-10-28T17:06:07.190513+00:00", + "finished_utc": "2026-10-28T17:06:27.387132+00:00", + "rounds": 3, + "legs": 6, + "all_legs_passed": true, + "all_zero_hop_identity_nonce_bound": true, + "max_elapsed_seconds": 0.522, + "packet_deltas": [ + { + "sender_packet_delta": { + "received": 0, + "sent": 1, + "sent_flood": 0, + "sent_direct": 1, + "received_flood": 0, + "received_direct": 0, + "receive_errors": 0 + }, + "receiver_packet_delta": { + "received": 1, + "sent": 0, + "sent_flood": 0, + "sent_direct": 0, + "received_flood": 0, + "received_direct": 1, + "receive_errors": 0 + } + }, + { + "sender_packet_delta": { + "received": 0, + "sent": 1, + "sent_flood": 0, + "sent_direct": 1, + "received_flood": 0, + "received_direct": 0, + "receive_errors": 0 + }, + "receiver_packet_delta": { + "received": 1, + "sent": 0, + "sent_flood": 0, + "sent_direct": 0, + "received_flood": 0, + "received_direct": 1, + "receive_errors": 0 + } + }, + { + "sender_packet_delta": { + "received": 0, + "sent": 1, + "sent_flood": 0, + "sent_direct": 1, + "received_flood": 0, + "received_direct": 0, + "receive_errors": 0 + }, + "receiver_packet_delta": { + "received": 1, + "sent": 0, + "sent_flood": 0, + "sent_direct": 0, + "received_flood": 0, + "received_direct": 1, + "receive_errors": 0 + } + }, + { + "sender_packet_delta": { + "received": 0, + "sent": 1, + "sent_flood": 0, + "sent_direct": 1, + "received_flood": 0, + "received_direct": 0, + "receive_errors": 0 + }, + "receiver_packet_delta": { + "received": 1, + "sent": 0, + "sent_flood": 0, + "sent_direct": 0, + "received_flood": 0, + "received_direct": 1, + "receive_errors": 0 + } + }, + { + "sender_packet_delta": { + "received": 0, + "sent": 1, + "sent_flood": 0, + "sent_direct": 1, + "received_flood": 0, + "received_direct": 0, + "receive_errors": 0 + }, + "receiver_packet_delta": { + "received": 1, + "sent": 0, + "sent_flood": 0, + "sent_direct": 0, + "received_flood": 0, + "received_direct": 1, + "receive_errors": 0 + } + }, + { + "sender_packet_delta": { + "received": 0, + "sent": 1, + "sent_flood": 0, + "sent_direct": 1, + "received_flood": 0, + "received_direct": 0, + "receive_errors": 0 + }, + "receiver_packet_delta": { + "received": 1, + "sent": 0, + "sent_flood": 0, + "sent_direct": 0, + "received_flood": 0, + "received_direct": 1, + "receive_errors": 0 + } + } + ], + "rf_retries": 0, + "offline_queue_reads": 0, + "channel_setters": 0, + "ota_workspace_acquired": false, + "cleanup": { + "t1000": { + "saved_getters_equal": true, + "channel_names_and_keys_equal": true, + "contact_static_records_equal": true, + "contact_live_timestamp_changes": 0, + "binary_preferences_equal": true, + "offline_queue_reads": 0 + }, + "tag": { + "saved_getters_equal": true, + "channel_names_and_keys_equal": true, + "contact_static_records_equal": true, + "contact_live_timestamp_changes": 0, + "binary_preferences_equal": true, + "offline_queue_reads": 0 + } + }, + "report_sha256": "37857800ab9560015192edb42e8b20033db7aaaeaa604d9fe243e29646a6606b", + "subject_provenance": { + "board": "RAK WisMesh Tag", + "target": "RAK_WisMesh_Tag_companion_radio_full", + "source_commit": "226ccc332d29e78c3dc69cdb70a228baca403711", + "artifact_kind": "DFU ZIP", + "artifact_sha256": "c9da30b5d898c61587982c58b19e2f3593246a21a75f16599948dc965a2741db" + } + }, + { + "peer_board": "Heltec T096", + "passed": true, + "started_utc": "2026-10-28T17:05:04.447063+00:00", + "finished_utc": "2026-10-28T17:05:24.848572+00:00", + "rounds": 3, + "legs": 6, + "all_legs_passed": true, + "all_zero_hop_identity_nonce_bound": true, + "max_elapsed_seconds": 0.413, + "packet_deltas": [ + { + "sender_packet_delta": { + "received": 0, + "sent": 1, + "sent_flood": 0, + "sent_direct": 1, + "received_flood": 0, + "received_direct": 0, + "receive_errors": 0 + }, + "receiver_packet_delta": { + "received": 1, + "sent": 0, + "sent_flood": 0, + "sent_direct": 0, + "received_flood": 0, + "received_direct": 1, + "receive_errors": 0 + } + }, + { + "sender_packet_delta": { + "received": 0, + "sent": 1, + "sent_flood": 0, + "sent_direct": 1, + "received_flood": 0, + "received_direct": 0, + "receive_errors": 0 + }, + "receiver_packet_delta": { + "received": 1, + "sent": 0, + "sent_flood": 0, + "sent_direct": 0, + "received_flood": 0, + "received_direct": 1, + "receive_errors": 0 + } + }, + { + "sender_packet_delta": { + "received": 0, + "sent": 1, + "sent_flood": 0, + "sent_direct": 1, + "received_flood": 0, + "received_direct": 0, + "receive_errors": 0 + }, + "receiver_packet_delta": { + "received": 1, + "sent": 0, + "sent_flood": 0, + "sent_direct": 0, + "received_flood": 0, + "received_direct": 1, + "receive_errors": 0 + } + }, + { + "sender_packet_delta": { + "received": 0, + "sent": 1, + "sent_flood": 0, + "sent_direct": 1, + "received_flood": 0, + "received_direct": 0, + "receive_errors": 0 + }, + "receiver_packet_delta": { + "received": 1, + "sent": 0, + "sent_flood": 0, + "sent_direct": 0, + "received_flood": 0, + "received_direct": 1, + "receive_errors": 0 + } + }, + { + "sender_packet_delta": { + "received": 0, + "sent": 1, + "sent_flood": 0, + "sent_direct": 1, + "received_flood": 0, + "received_direct": 0, + "receive_errors": 0 + }, + "receiver_packet_delta": { + "received": 1, + "sent": 0, + "sent_flood": 0, + "sent_direct": 0, + "received_flood": 0, + "received_direct": 1, + "receive_errors": 0 + } + }, + { + "sender_packet_delta": { + "received": 0, + "sent": 1, + "sent_flood": 0, + "sent_direct": 1, + "received_flood": 0, + "received_direct": 0, + "receive_errors": 0 + }, + "receiver_packet_delta": { + "received": 1, + "sent": 0, + "sent_flood": 0, + "sent_direct": 0, + "received_flood": 0, + "received_direct": 1, + "receive_errors": 0 + } + } + ], + "rf_retries": 0, + "offline_queue_reads": 0, + "channel_setters": 0, + "ota_workspace_acquired": false, + "cleanup": { + "t096": { + "saved_getters_equal": true, + "channel_names_and_keys_equal": true, + "contact_static_records_equal": true, + "contact_live_timestamp_changes": 0, + "binary_preferences_equal": true, + "offline_queue_reads": 0 + }, + "tag": { + "saved_getters_equal": true, + "channel_names_and_keys_equal": true, + "contact_static_records_equal": true, + "contact_live_timestamp_changes": 0, + "binary_preferences_equal": true, + "offline_queue_reads": 0 + } + }, + "report_sha256": "8f2a793f984e5b45d2c1b8b60dd5e811bf215f669f8e3ab16505df60c8217729", + "subject_provenance": { + "board": "RAK WisMesh Tag", + "target": "RAK_WisMesh_Tag_companion_radio_full", + "source_commit": "226ccc332d29e78c3dc69cdb70a228baca403711", + "artifact_kind": "DFU ZIP", + "artifact_sha256": "c9da30b5d898c61587982c58b19e2f3593246a21a75f16599948dc965a2741db" + } + }, + { + "peer_board": "MeshPocket", + "passed": true, + "started_utc": "2026-10-28T17:05:46.259183+00:00", + "finished_utc": "2026-10-28T17:06:06.270631+00:00", + "rounds": 3, + "legs": 6, + "all_legs_passed": true, + "all_zero_hop_identity_nonce_bound": true, + "max_elapsed_seconds": 0.524, + "packet_deltas": [ + { + "sender_packet_delta": { + "received": 0, + "sent": 1, + "sent_flood": 0, + "sent_direct": 1, + "received_flood": 0, + "received_direct": 0, + "receive_errors": 0 + }, + "receiver_packet_delta": { + "received": 1, + "sent": 0, + "sent_flood": 0, + "sent_direct": 0, + "received_flood": 0, + "received_direct": 1, + "receive_errors": 0 + } + }, + { + "sender_packet_delta": { + "received": 0, + "sent": 1, + "sent_flood": 0, + "sent_direct": 1, + "received_flood": 0, + "received_direct": 0, + "receive_errors": 0 + }, + "receiver_packet_delta": { + "received": 1, + "sent": 0, + "sent_flood": 0, + "sent_direct": 0, + "received_flood": 0, + "received_direct": 1, + "receive_errors": 0 + } + }, + { + "sender_packet_delta": { + "received": 0, + "sent": 1, + "sent_flood": 0, + "sent_direct": 1, + "received_flood": 0, + "received_direct": 0, + "receive_errors": 0 + }, + "receiver_packet_delta": { + "received": 1, + "sent": 0, + "sent_flood": 0, + "sent_direct": 0, + "received_flood": 0, + "received_direct": 1, + "receive_errors": 0 + } + }, + { + "sender_packet_delta": { + "received": 0, + "sent": 1, + "sent_flood": 0, + "sent_direct": 1, + "received_flood": 0, + "received_direct": 0, + "receive_errors": 0 + }, + "receiver_packet_delta": { + "received": 1, + "sent": 0, + "sent_flood": 0, + "sent_direct": 0, + "received_flood": 0, + "received_direct": 1, + "receive_errors": 0 + } + }, + { + "sender_packet_delta": { + "received": 0, + "sent": 1, + "sent_flood": 0, + "sent_direct": 1, + "received_flood": 0, + "received_direct": 0, + "receive_errors": 0 + }, + "receiver_packet_delta": { + "received": 1, + "sent": 0, + "sent_flood": 0, + "sent_direct": 0, + "received_flood": 0, + "received_direct": 1, + "receive_errors": 0 + } + }, + { + "sender_packet_delta": { + "received": 0, + "sent": 1, + "sent_flood": 0, + "sent_direct": 1, + "received_flood": 0, + "received_direct": 0, + "receive_errors": 0 + }, + "receiver_packet_delta": { + "received": 1, + "sent": 0, + "sent_flood": 0, + "sent_direct": 0, + "received_flood": 0, + "received_direct": 1, + "receive_errors": 0 + } + } + ], + "rf_retries": 0, + "offline_queue_reads": 0, + "channel_setters": 0, + "ota_workspace_acquired": false, + "cleanup": { + "pocket": { + "saved_getters_equal": true, + "channel_names_and_keys_equal": true, + "contact_static_records_equal": true, + "contact_live_timestamp_changes": 0, + "binary_preferences_equal": true, + "offline_queue_reads": 0 + }, + "tag": { + "saved_getters_equal": true, + "channel_names_and_keys_equal": true, + "contact_static_records_equal": true, + "contact_live_timestamp_changes": 0, + "binary_preferences_equal": true, + "offline_queue_reads": 0 + } + }, + "report_sha256": "c6615cf9e548fe9f7ebbc50b2ef7e37c5c57b7b478f860aee94720b88bfd20c9", + "subject_provenance": { + "board": "RAK WisMesh Tag", + "target": "RAK_WisMesh_Tag_companion_radio_full", + "source_commit": "226ccc332d29e78c3dc69cdb70a228baca403711", + "artifact_kind": "DFU ZIP", + "artifact_sha256": "c9da30b5d898c61587982c58b19e2f3593246a21a75f16599948dc965a2741db" + } + } + ] + }, + "role": "Full Companion", + "installed_artifact_sha256": "c9da30b5d898c61587982c58b19e2f3593246a21a75f16599948dc965a2741db", + "installed_artifact_kind": "application-only DFU ZIP", + "installed_artifact_target": "RAK_WisMesh_Tag_companion_radio_full" + }, + { + "board": "Heltec T096", + "installed_source_commit": "226ccc332d29e78c3dc69cdb70a228baca403711", + "installed_full_version": "v1.18.0.2-halo-keymind-cascade-dev-226ccc33 (Build: 28-Oct-2026)", + "installation": { + "passed": true, + "app_only": true, + "erase_requested": false, + "bootloader_written": false, + "softdevice_written": false + }, + "installation_report_sha256": "3ea7b193a283290f44a5cba3e22495c7b5b589f074ea8e980fc1cd201777b477", + "usb": { + "passed": true, + "persistent_cycles_completed": 100, + "reopens_completed": 10, + "started_at": "2026-10-28T16:34:09.513824+00:00", + "finished_at": "2026-10-28T16:35:58.040510+00:00", + "channel_state_unchanged": true, + "contact_changes": { + "added": 0, + "other_live_record_changes": 0, + "removed": 0, + "timestamp_only_changed": 0, + "timestamp_regressions": 0 + }, + "uptime_segments_monotonic": true, + "final_core_stats": { + "battery_mv": 4155, + "error_flags": 0, + "outbound_queue_length": 0, + "uptime_seconds": 12 + }, + "transport": { + "discarded_prefix_bytes": 0, + "ignored_push_frames": 0, + "invalid_length_candidates": 0, + "ports_closed": 32, + "ports_opened": 32, + "request_bytes_sent": 6841, + "request_frames_sent": 743, + "response_frames_received": 745, + "response_payload_bytes": 29342 + }, + "report_sha256": "ae4192a28ce295a1225ba50bb5226b033e08543de89ef2ef328614c5b919b5fe" + }, + "crud_warm_reboot": { + "passed": true, + "create_update_readback": true, + "reopen_readback": true, + "owned_record_survives_warm_reboot": true, + "owned_contact_removed_and_reopen_verified": true, + "reboot_requests_sent": 1, + "warm_reboot_verified": true, + "uptime_before_reboot": 314, + "uptime_after_reboot": 3 + }, + "stable_binary_tails": { + "count": 31, + "all_passed": true, + "max_elapsed_seconds": 0.050443, + "input_bytes": 0, + "buffered_bytes": 0 + }, + "radio2": { + "passed": true, + "automatic_owner_before": false, + "lease_minutes": 1, + "natural_expiry_verified": true, + "expiry_polls": 11, + "original_secondary_restored": true, + "original_auto_owner_restored": false, + "intentional_rf_transmissions": 0, + "overlap_cases": 2, + "overlap_cases_passed": true + }, + "ble_trials": [ + { + "condition": "normal host WiFi; USB enumerated", + "started_at": "2026-10-28T16:40:42.601955+00:00", + "finished_at": "2026-10-28T16:43:01.222203+00:00", + "report_sha256": "d1614ef0d7027caa56941d4968563fcb7a4ba07372c1614f26e4e240a46a858e", + "requested_checks_passed": false, + "ble": { + "status": "failed", + "passed": false, + "sessions_completed": 3, + "payload_sessions_passed": true, + "timed_out": false, + "trace_sha256": "f11a1d0df191743200c37eb9b4e514e9a820eea109dd802c98bdf83e2356c979", + "report_sha256": "ee80ad766d0ad83c2c677997dab031a679614a2ebc522ad74bc965a6263ed2c6" + }, + "hci": { + "target_successful_connection_events": 4, + "target_failed_establishments": 0, + "target_command_failures": 0, + "target_disconnect_reasons": [ + "0x16", + "0x16", + "0x3e", + "0x16" + ], + "target_disconnect_statuses": [ + "0x00", + "0x00", + "0x00", + "0x00" + ], + "unattributed_controller_events": 0, + "lifecycle_parse_errors": 0, + "target_open_handles_at_end": 0, + "target_pending_attempts_at_end": 0, + "target_establishment_0x3e_disconnects": 1, + "target_timeout_0x08_disconnects": 0, + "target_ll_response_timeout_0x22_disconnects": 0, + "classification_complete": true, + "clean": false + }, + "phase_markers": { + "trace_sha256": "f11a1d0df191743200c37eb9b4e514e9a820eea109dd802c98bdf83e2356c979", + "bluetooth_service_crashes": 0, + "journal_sha256": "4150ecea50b582ecf0bc07cd5fe238dbca2a5e4f973b1a24ae28c7c918f0be7a", + "encryption_change_successes": 3, + "pin_or_key_missing_markers": 0, + "cause_assigned": false + }, + "subject_provenance": { + "board": "Heltec T096", + "target": "Heltec_t096_companion_radio_full_femon", + "source_commit": "226ccc332d29e78c3dc69cdb70a228baca403711", + "artifact_kind": "DFU ZIP", + "artifact_sha256": "3e0ad88ca18a1f85811f0dbfc440466a11735c8c6e9db951265a8815c020d9d4" + } + } + ], + "rf": { + "status": "passed", + "trials": [ + { + "peer_board": "Heltec T096", + "passed": true, + "started_utc": "2026-10-28T17:05:04.447063+00:00", + "finished_utc": "2026-10-28T17:05:24.848572+00:00", + "rounds": 3, + "legs": 6, + "all_legs_passed": true, + "all_zero_hop_identity_nonce_bound": true, + "max_elapsed_seconds": 0.413, + "packet_deltas": [ + { + "sender_packet_delta": { + "received": 0, + "sent": 1, + "sent_flood": 0, + "sent_direct": 1, + "received_flood": 0, + "received_direct": 0, + "receive_errors": 0 + }, + "receiver_packet_delta": { + "received": 1, + "sent": 0, + "sent_flood": 0, + "sent_direct": 0, + "received_flood": 0, + "received_direct": 1, + "receive_errors": 0 + } + }, + { + "sender_packet_delta": { + "received": 0, + "sent": 1, + "sent_flood": 0, + "sent_direct": 1, + "received_flood": 0, + "received_direct": 0, + "receive_errors": 0 + }, + "receiver_packet_delta": { + "received": 1, + "sent": 0, + "sent_flood": 0, + "sent_direct": 0, + "received_flood": 0, + "received_direct": 1, + "receive_errors": 0 + } + }, + { + "sender_packet_delta": { + "received": 0, + "sent": 1, + "sent_flood": 0, + "sent_direct": 1, + "received_flood": 0, + "received_direct": 0, + "receive_errors": 0 + }, + "receiver_packet_delta": { + "received": 1, + "sent": 0, + "sent_flood": 0, + "sent_direct": 0, + "received_flood": 0, + "received_direct": 1, + "receive_errors": 0 + } + }, + { + "sender_packet_delta": { + "received": 0, + "sent": 1, + "sent_flood": 0, + "sent_direct": 1, + "received_flood": 0, + "received_direct": 0, + "receive_errors": 0 + }, + "receiver_packet_delta": { + "received": 1, + "sent": 0, + "sent_flood": 0, + "sent_direct": 0, + "received_flood": 0, + "received_direct": 1, + "receive_errors": 0 + } + }, + { + "sender_packet_delta": { + "received": 0, + "sent": 1, + "sent_flood": 0, + "sent_direct": 1, + "received_flood": 0, + "received_direct": 0, + "receive_errors": 0 + }, + "receiver_packet_delta": { + "received": 1, + "sent": 0, + "sent_flood": 0, + "sent_direct": 0, + "received_flood": 0, + "received_direct": 1, + "receive_errors": 0 + } + }, + { + "sender_packet_delta": { + "received": 0, + "sent": 1, + "sent_flood": 0, + "sent_direct": 1, + "received_flood": 0, + "received_direct": 0, + "receive_errors": 0 + }, + "receiver_packet_delta": { + "received": 1, + "sent": 0, + "sent_flood": 0, + "sent_direct": 0, + "received_flood": 0, + "received_direct": 1, + "receive_errors": 0 + } + } + ], + "rf_retries": 0, + "offline_queue_reads": 0, + "channel_setters": 0, + "ota_workspace_acquired": false, + "cleanup": { + "t096": { + "saved_getters_equal": true, + "channel_names_and_keys_equal": true, + "contact_static_records_equal": true, + "contact_live_timestamp_changes": 0, + "binary_preferences_equal": true, + "offline_queue_reads": 0 + }, + "tag": { + "saved_getters_equal": true, + "channel_names_and_keys_equal": true, + "contact_static_records_equal": true, + "contact_live_timestamp_changes": 0, + "binary_preferences_equal": true, + "offline_queue_reads": 0 + } + }, + "report_sha256": "8f2a793f984e5b45d2c1b8b60dd5e811bf215f669f8e3ab16505df60c8217729", + "subject_provenance": { + "board": "Heltec T096", + "target": "Heltec_t096_companion_radio_full_femon", + "source_commit": "226ccc332d29e78c3dc69cdb70a228baca403711", + "artifact_kind": "DFU ZIP", + "artifact_sha256": "3e0ad88ca18a1f85811f0dbfc440466a11735c8c6e9db951265a8815c020d9d4" + } + } + ] + }, + "role": "Full Companion", + "installed_artifact_sha256": "3e0ad88ca18a1f85811f0dbfc440466a11735c8c6e9db951265a8815c020d9d4", + "installed_artifact_kind": "application-only DFU ZIP", + "installed_artifact_target": "Heltec_t096_companion_radio_full_femon" + }, + { + "board": "MeshPocket", + "installed_source_commit": "226ccc332d29e78c3dc69cdb70a228baca403711", + "installed_full_version": "v1.18.0.2-halo-keymind-cascade-dev-226ccc33 (Build: 28-Oct-2026)", + "installation": { + "passed": true, + "app_only": true, + "erase_requested": false, + "bootloader_written": false, + "softdevice_written": false + }, + "installation_report_sha256": "6bf160539e1dd0e5d3e6f88d0fe2d189552db23ae19b4d109204de74bb1dc7d0", + "usb": { + "passed": true, + "persistent_cycles_completed": 100, + "reopens_completed": 10, + "started_at": "2026-10-28T16:35:58.791863+00:00", + "finished_at": "2026-10-28T16:38:06.540533+00:00", + "channel_state_unchanged": true, + "contact_changes": { + "added": 0, + "other_live_record_changes": 0, + "removed": 0, + "timestamp_only_changed": 0, + "timestamp_regressions": 0 + }, + "uptime_segments_monotonic": true, + "final_core_stats": { + "battery_mv": 4084, + "error_flags": 0, + "outbound_queue_length": 0, + "uptime_seconds": 27 + }, + "transport": { + "discarded_prefix_bytes": 0, + "ignored_push_frames": 0, + "invalid_length_candidates": 0, + "ports_closed": 31, + "ports_opened": 31, + "request_bytes_sent": 6755, + "request_frames_sent": 738, + "response_frames_received": 743, + "response_payload_bytes": 30108 + }, + "report_sha256": "f799169003b55d7fd57571cac3bcdd08a1649be3fd25b205f04d2c9d88f38f85" + }, + "crud_warm_reboot": { + "passed": true, + "create_update_readback": true, + "reopen_readback": true, + "owned_record_survives_warm_reboot": true, + "owned_contact_removed_and_reopen_verified": true, + "reboot_requests_sent": 1, + "warm_reboot_verified": true, + "uptime_before_reboot": 351, + "uptime_after_reboot": 17 + }, + "stable_binary_tails": { + "count": 30, + "all_passed": true, + "max_elapsed_seconds": 0.050602, + "input_bytes": 0, + "buffered_bytes": 0 + }, + "radio2": { + "passed": true, + "automatic_owner_before": false, + "lease_minutes": 1, + "natural_expiry_verified": true, + "expiry_polls": 10, + "original_secondary_restored": true, + "original_auto_owner_restored": false, + "intentional_rf_transmissions": 0, + "overlap_cases": 2, + "overlap_cases_passed": true + }, + "ble_trials": [ + { + "condition": "normal host WiFi; USB enumerated", + "started_at": "2026-10-28T16:43:01.606868+00:00", + "finished_at": "2026-10-28T16:45:21.179069+00:00", + "report_sha256": "e485736a7b0cc0ea8b2bbf18c4ad74970dc3a9cbf22bd5eaf7fbade39a59496d", + "requested_checks_passed": true, + "ble": { + "status": "passed", + "passed": true, + "sessions_completed": 3, + "payload_sessions_passed": true, + "timed_out": false, + "trace_sha256": "b38dde3a6c3eb5f1db0bce62e4a8588045f947a96026f6aeb4189e27b891718f", + "report_sha256": "81c43de646d5d86e65e4ae0591240f26ddfd1aa92df5f1739554def2e8817166" + }, + "hci": { + "target_successful_connection_events": 3, + "target_failed_establishments": 0, + "target_command_failures": 0, + "target_disconnect_reasons": [ + "0x16", + "0x16", + "0x16" + ], + "target_disconnect_statuses": [ + "0x00", + "0x00", + "0x00" + ], + "unattributed_controller_events": 0, + "lifecycle_parse_errors": 0, + "target_open_handles_at_end": 0, + "target_pending_attempts_at_end": 0, + "target_establishment_0x3e_disconnects": 0, + "target_timeout_0x08_disconnects": 0, + "target_ll_response_timeout_0x22_disconnects": 0, + "classification_complete": true, + "clean": true + }, + "subject_provenance": { + "board": "MeshPocket", + "target": "Mesh_pocket_companion_radio_full", + "source_commit": "226ccc332d29e78c3dc69cdb70a228baca403711", + "artifact_kind": "DFU ZIP", + "artifact_sha256": "4f85629da02a3826b8548a106fa199262408237c9f44aa78e950f5ab7ff1a994" + } + } + ], + "rf": { + "status": "passed", + "trials": [ + { + "peer_board": "MeshPocket", + "passed": true, + "started_utc": "2026-10-28T17:05:46.259183+00:00", + "finished_utc": "2026-10-28T17:06:06.270631+00:00", + "rounds": 3, + "legs": 6, + "all_legs_passed": true, + "all_zero_hop_identity_nonce_bound": true, + "max_elapsed_seconds": 0.524, + "packet_deltas": [ + { + "sender_packet_delta": { + "received": 0, + "sent": 1, + "sent_flood": 0, + "sent_direct": 1, + "received_flood": 0, + "received_direct": 0, + "receive_errors": 0 + }, + "receiver_packet_delta": { + "received": 1, + "sent": 0, + "sent_flood": 0, + "sent_direct": 0, + "received_flood": 0, + "received_direct": 1, + "receive_errors": 0 + } + }, + { + "sender_packet_delta": { + "received": 0, + "sent": 1, + "sent_flood": 0, + "sent_direct": 1, + "received_flood": 0, + "received_direct": 0, + "receive_errors": 0 + }, + "receiver_packet_delta": { + "received": 1, + "sent": 0, + "sent_flood": 0, + "sent_direct": 0, + "received_flood": 0, + "received_direct": 1, + "receive_errors": 0 + } + }, + { + "sender_packet_delta": { + "received": 0, + "sent": 1, + "sent_flood": 0, + "sent_direct": 1, + "received_flood": 0, + "received_direct": 0, + "receive_errors": 0 + }, + "receiver_packet_delta": { + "received": 1, + "sent": 0, + "sent_flood": 0, + "sent_direct": 0, + "received_flood": 0, + "received_direct": 1, + "receive_errors": 0 + } + }, + { + "sender_packet_delta": { + "received": 0, + "sent": 1, + "sent_flood": 0, + "sent_direct": 1, + "received_flood": 0, + "received_direct": 0, + "receive_errors": 0 + }, + "receiver_packet_delta": { + "received": 1, + "sent": 0, + "sent_flood": 0, + "sent_direct": 0, + "received_flood": 0, + "received_direct": 1, + "receive_errors": 0 + } + }, + { + "sender_packet_delta": { + "received": 0, + "sent": 1, + "sent_flood": 0, + "sent_direct": 1, + "received_flood": 0, + "received_direct": 0, + "receive_errors": 0 + }, + "receiver_packet_delta": { + "received": 1, + "sent": 0, + "sent_flood": 0, + "sent_direct": 0, + "received_flood": 0, + "received_direct": 1, + "receive_errors": 0 + } + }, + { + "sender_packet_delta": { + "received": 0, + "sent": 1, + "sent_flood": 0, + "sent_direct": 1, + "received_flood": 0, + "received_direct": 0, + "receive_errors": 0 + }, + "receiver_packet_delta": { + "received": 1, + "sent": 0, + "sent_flood": 0, + "sent_direct": 0, + "received_flood": 0, + "received_direct": 1, + "receive_errors": 0 + } + } + ], + "rf_retries": 0, + "offline_queue_reads": 0, + "channel_setters": 0, + "ota_workspace_acquired": false, + "cleanup": { + "pocket": { + "saved_getters_equal": true, + "channel_names_and_keys_equal": true, + "contact_static_records_equal": true, + "contact_live_timestamp_changes": 0, + "binary_preferences_equal": true, + "offline_queue_reads": 0 + }, + "tag": { + "saved_getters_equal": true, + "channel_names_and_keys_equal": true, + "contact_static_records_equal": true, + "contact_live_timestamp_changes": 0, + "binary_preferences_equal": true, + "offline_queue_reads": 0 + } + }, + "report_sha256": "c6615cf9e548fe9f7ebbc50b2ef7e37c5c57b7b478f860aee94720b88bfd20c9", + "subject_provenance": { + "board": "MeshPocket", + "target": "Mesh_pocket_companion_radio_full", + "source_commit": "226ccc332d29e78c3dc69cdb70a228baca403711", + "artifact_kind": "DFU ZIP", + "artifact_sha256": "4f85629da02a3826b8548a106fa199262408237c9f44aa78e950f5ab7ff1a994" + } + } + ] + }, + "role": "Full Companion", + "installed_artifact_sha256": "4f85629da02a3826b8548a106fa199262408237c9f44aa78e950f5ab7ff1a994", + "installed_artifact_kind": "application-only DFU ZIP", + "installed_artifact_target": "Mesh_pocket_companion_radio_full" + } + ], + "historical_c9_companions": [ + { + "board": "Seeed T1000-E", + "role": "Full Companion", + "installed_source_commit": "c9acec474bb9617d4f92dbdc0401765701989288", + "installed_full_version": "v1.18.0.2-halo-keymind-cascade-dev-c9acec47 (Build: 28-Oct-2026)", + "installed_artifact_sha256": "824b6bc39853fddeeba553bf91db79a098afccfc7886546750d3a7afdd73f03e", + "installed_artifact_kind": "application-only DFU ZIP", + "application_install": { + "passed": true, + "app_only": true, + "bootloader_written": false, + "softdevice_written": false, + "erase_requested": false, + "runtime_version_verified": true, + "identity_preserved": true, + "logical_settings_preserved": true, + "contacts_preserved": true, + "channels_preserved": true + }, + "radio2": { + "passed": true, + "lease_minutes": 1, + "mode": "rxtx", + "natural_expiry_verified": true, + "expiry_polls": 11, + "original_auto_owner_restored": true, + "persistent_radio_or_channel_setters_sent": false, + "intentional_rf_transmissions": 0, + "overlap_cases_completed": 0, + "overlap_cases_passed": 0 + }, + "binary_settings": { + "passed": true, + "binary_tx_setter_acknowledged": true, + "pending_saved_pin_readback_matches": true, + "pending_pin_readback_survives_usb_reopen": true, + "original_settings_restored": true, + "physical_tx_power_measured": false, + "hardware_application_verified": false, + "pin_cold_persistence_tested": false, + "rf_send_commands": 0, + "reboots_requested": 0 + }, + "ble_trials": [ + { + "attempt": "20261028T132437.657199Z", + "strict_passed": false, + "condition": "target host-deauthorized; USB power not commanded off", + "host_wifi_condition": "normal host WiFi; wlan0 remained associated", + "measurement_started_utc": "2026-10-28T13:24:37.669577+00:00", + "measurement_finished_utc": "2026-10-28T13:24:51.844789+00:00", + "source_summary_sha256": "097da121761c672abd47f6465ceef15d4b1f02702ff18dcacd59dea91e9f7780", + "target_bond_removal_requested": false, + "usb_unmount_settle_seconds": 3, + "usb_restored": true, + "qualification_payload_worker_ran": true, + "passed": false, + "sessions_requested": 3, + "sessions_completed": 0, + "hold_seconds_requested": 30.0, + "adapter_reset": false, + "broad_bond_changes": false, + "session_hold_seconds": [], + "authentication_markers": { + "trace_sha256": "ebfb99197460b52526a8a145eed0caa9dc38d53a3f70cd3a2a6e622dbc95586e", + "markers": [], + "key_missing_status_observed": false + }, + "host_daemon_check": { + "interval_started_utc": "2026-10-28T13:24:37.669577+00:00", + "interval_finished_utc": "2026-10-28T13:24:51.844789+00:00", + "journal_window_padding_seconds": 1, + "sigsegv_events": 0, + "daemon_start_events": 0, + "journal_records": 0, + "source_journal_sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + } + }, + { + "attempt": "20261028T133005.382208Z", + "strict_passed": false, + "condition": "target host-deauthorized; USB power not commanded off", + "host_wifi_condition": "normal host WiFi; wlan0 remained associated", + "measurement_started_utc": "2026-10-28T13:30:05.385949+00:00", + "measurement_finished_utc": "2026-10-28T13:33:12.001969+00:00", + "source_summary_sha256": "e5d92082aaf706c86c5733472dee3aa124b2869b2e9e0ab421590f7950826666", + "target_bond_removal_requested": false, + "usb_unmount_settle_seconds": 3, + "usb_restored": true, + "hci": { + "clean": false, + "target_successful_connection_events": 4, + "target_failed_establishments": 0, + "target_command_failures": 0, + "target_disconnect_reasons": [ + "0x3e", + "0x16", + "0x22", + "0x16" + ], + "target_disconnect_statuses": [ + "0x00", + "0x00", + "0x00", + "0x00" + ], + "unattributed_controller_events": 0, + "lifecycle_parse_errors": 0, + "target_open_handles_at_end": 0, + "target_pending_attempts_at_end": 0, + "capture_ended_before_timeout": true, + "source_summary_sha256": "5f62813bbe3105cee40ad52bf2185dfe34fd047efa4b485e3e042460560fa70d" + }, + "qualification_payload_worker_ran": true, + "passed": true, + "sessions_requested": 3, + "sessions_completed": 3, + "hold_seconds_requested": 30.0, + "adapter_reset": false, + "broad_bond_changes": false, + "full_version_matches": true, + "usb_public_key_matches": true, + "final_target_disconnected": true, + "session_hold_seconds": [ + 32.307, + 32.426, + 31.02 + ], + "authentication_markers": { + "trace_sha256": "c57fb44352f22ddf857f28e7a65f778e6f8b518c90ac6fe9749112db6f75dba0", + "markers": [ + { + "phase": "start_encryption" + }, + { + "phase": "encryption_change", + "status": { + "name": "Success", + "code": "0x00" + } + }, + { + "phase": "start_encryption" + }, + { + "phase": "encryption_change", + "status": { + "name": "Success", + "code": "0x00" + } + }, + { + "phase": "start_encryption" + }, + { + "phase": "encryption_change", + "status": { + "name": "Success", + "code": "0x00" + } + } + ], + "key_missing_status_observed": false + }, + "host_daemon_check": { + "interval_started_utc": "2026-10-28T13:30:05.385949+00:00", + "interval_finished_utc": "2026-10-28T13:33:12.001969+00:00", + "journal_window_padding_seconds": 1, + "sigsegv_events": 0, + "daemon_start_events": 0, + "journal_records": 0, + "source_journal_sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + } + }, + { + "attempt": "20261028T140554.518846Z", + "strict_passed": false, + "condition": "target host-deauthorized; USB power not commanded off", + "host_wifi_condition": "wlan0 disconnected; eth0 management; exact WiFi connection restored by outer driver", + "measurement_started_utc": "2026-10-28T14:05:54.520551+00:00", + "measurement_finished_utc": "2026-10-28T14:07:02.973533+00:00", + "source_summary_sha256": "445da9ad051de2a7b297195859203f2dc9dc39cc3a763ffd3a5c6e556ed89d8d", + "target_bond_removal_requested": false, + "usb_unmount_settle_seconds": 10, + "usb_restored": true, + "hci": { + "clean": false, + "target_successful_connection_events": 2, + "target_failed_establishments": 0, + "target_command_failures": 0, + "target_disconnect_reasons": [ + "0x3e", + "0x08" + ], + "target_disconnect_statuses": [ + "0x00", + "0x00" + ], + "unattributed_controller_events": 0, + "lifecycle_parse_errors": 0, + "target_open_handles_at_end": 0, + "target_pending_attempts_at_end": 0, + "capture_ended_before_timeout": true, + "source_summary_sha256": "64a9cb1d005928ba80552b3dddf1f78b89b6e404e862260ede71e0aa9b98da80" + }, + "qualification_payload_worker_ran": true, + "passed": false, + "sessions_requested": 3, + "sessions_completed": 0, + "hold_seconds_requested": 30.0, + "adapter_reset": false, + "broad_bond_changes": false, + "session_hold_seconds": [], + "authentication_markers": { + "trace_sha256": "19cb070fa08cc9334c92be8d0bd25de2f64c7e65cbe16bf2018fedfc798fa077", + "markers": [ + { + "phase": "start_encryption" + }, + { + "phase": "encryption_change", + "status": { + "name": "Success", + "code": "0x00" + } + } + ], + "key_missing_status_observed": false + }, + "host_daemon_check": { + "interval_started_utc": "2026-10-28T14:05:54.520551+00:00", + "interval_finished_utc": "2026-10-28T14:07:02.973533+00:00", + "journal_window_padding_seconds": 1, + "sigsegv_events": 0, + "daemon_start_events": 0, + "journal_records": 0, + "source_journal_sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + } + }, + { + "attempt": "20261028T142607.276080Z", + "strict_passed": false, + "condition": "radio2 temporarily saved Off; exact-target host deauthorization; normal host WiFi", + "host_wifi_condition": "normal host WiFi; wlan0 remained associated", + "measurement_started_utc": "2026-10-28T14:26:07.276665+00:00", + "measurement_finished_utc": "2026-10-28T14:26:40.028759+00:00", + "source_summary_sha256": "cef00534ed299f9b446ff6b6d1c8bbfe47573b98b0fa818a1ee27788c68901cf", + "target_bond_removal_requested": false, + "usb_unmount_settle_seconds": 10, + "usb_restored": true, + "changed_condition_only": true, + "original_auto_owner_restored": true, + "qualification_payload_worker_ran": true, + "passed": false, + "sessions_requested": 3, + "sessions_completed": 0, + "hold_seconds_requested": 30.0, + "adapter_reset": false, + "broad_bond_changes": false, + "session_hold_seconds": [], + "authentication_markers": { + "trace_sha256": "c0fca19c26c94521eb255cd19f85246fc65224ba85023e9411b28e75ca0db6dd", + "markers": [], + "key_missing_status_observed": false + }, + "host_daemon_check": { + "interval_started_utc": "2026-10-28T14:26:07.276665+00:00", + "interval_finished_utc": "2026-10-28T14:26:40.028759+00:00", + "journal_window_padding_seconds": 1, + "sigsegv_events": 0, + "daemon_start_events": 0, + "journal_records": 0, + "source_journal_sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + } + } + ], + "rf_trials": [ + { + "attempt": "rf-tag-t1000-run1", + "harness_passed": true, + "rounds_completed": 3, + "offline_queue_reads": 0, + "rf_send_attempts": 6, + "rf_retries": 0, + "cleanup": { + "t1000": { + "saved_getters_equal": true, + "channel_names_and_keys_equal": true, + "contact_static_records_equal": true, + "contact_live_timestamp_changes": 0, + "binary_preferences_equal": true, + "offline_queue_reads": 0 + }, + "tag": { + "saved_getters_equal": true, + "channel_names_and_keys_equal": true, + "contact_static_records_equal": true, + "contact_live_timestamp_changes": 0, + "binary_preferences_equal": true, + "offline_queue_reads": 0 + } + }, + "legs": [ + { + "passed": true, + "elapsed_seconds": 0.641, + "path_bytes": 0, + "snr_db": 13.0, + "rssi_dbm": -40, + "full_usb_bound_peer_ids_and_nonce_verified": true, + "sender_packet_delta": { + "received": 0, + "sent": 1, + "sent_flood": 0, + "sent_direct": 1, + "received_flood": 0, + "received_direct": 0, + "receive_errors": 0 + }, + "receiver_packet_delta": { + "received": 1, + "sent": 0, + "sent_flood": 0, + "sent_direct": 0, + "received_flood": 0, + "received_direct": 1, + "receive_errors": 0 + }, + "offline_queue_reads": 0, + "round": 1, + "direction": "hub_to_peer" + }, + { + "passed": true, + "elapsed_seconds": 0.408, + "path_bytes": 0, + "snr_db": 13.0, + "rssi_dbm": -32, + "full_usb_bound_peer_ids_and_nonce_verified": true, + "sender_packet_delta": { + "received": 0, + "sent": 1, + "sent_flood": 0, + "sent_direct": 1, + "received_flood": 0, + "received_direct": 0, + "receive_errors": 0 + }, + "receiver_packet_delta": { + "received": 1, + "sent": 0, + "sent_flood": 0, + "sent_direct": 0, + "received_flood": 0, + "received_direct": 1, + "receive_errors": 0 + }, + "offline_queue_reads": 0, + "round": 1, + "direction": "peer_to_hub" + }, + { + "passed": true, + "elapsed_seconds": 0.406, + "path_bytes": 0, + "snr_db": 13.25, + "rssi_dbm": -40, + "full_usb_bound_peer_ids_and_nonce_verified": true, + "sender_packet_delta": { + "received": 0, + "sent": 1, + "sent_flood": 0, + "sent_direct": 1, + "received_flood": 0, + "received_direct": 0, + "receive_errors": 0 + }, + "receiver_packet_delta": { + "received": 1, + "sent": 0, + "sent_flood": 0, + "sent_direct": 0, + "received_flood": 0, + "received_direct": 1, + "receive_errors": 0 + }, + "offline_queue_reads": 0, + "round": 2, + "direction": "hub_to_peer" + }, + { + "passed": true, + "elapsed_seconds": 0.405, + "path_bytes": 0, + "snr_db": 13.5, + "rssi_dbm": -32, + "full_usb_bound_peer_ids_and_nonce_verified": true, + "sender_packet_delta": { + "received": 0, + "sent": 1, + "sent_flood": 0, + "sent_direct": 1, + "received_flood": 0, + "received_direct": 0, + "receive_errors": 0 + }, + "receiver_packet_delta": { + "received": 1, + "sent": 0, + "sent_flood": 0, + "sent_direct": 0, + "received_flood": 0, + "received_direct": 1, + "receive_errors": 0 + }, + "offline_queue_reads": 0, + "round": 2, + "direction": "peer_to_hub" + }, + { + "passed": true, + "elapsed_seconds": 0.404, + "path_bytes": 0, + "snr_db": 12.5, + "rssi_dbm": -40, + "full_usb_bound_peer_ids_and_nonce_verified": true, + "sender_packet_delta": { + "received": 0, + "sent": 1, + "sent_flood": 0, + "sent_direct": 1, + "received_flood": 0, + "received_direct": 0, + "receive_errors": 0 + }, + "receiver_packet_delta": { + "received": 1, + "sent": 0, + "sent_flood": 0, + "sent_direct": 0, + "received_flood": 0, + "received_direct": 1, + "receive_errors": 0 + }, + "offline_queue_reads": 0, + "round": 3, + "direction": "hub_to_peer" + }, + { + "passed": true, + "elapsed_seconds": 0.406, + "path_bytes": 0, + "snr_db": 13.25, + "rssi_dbm": -32, + "full_usb_bound_peer_ids_and_nonce_verified": true, + "sender_packet_delta": { + "received": 0, + "sent": 1, + "sent_flood": 0, + "sent_direct": 1, + "received_flood": 0, + "received_direct": 0, + "receive_errors": 0 + }, + "receiver_packet_delta": { + "received": 1, + "sent": 0, + "sent_flood": 0, + "sent_direct": 0, + "received_flood": 0, + "received_direct": 1, + "receive_errors": 0 + }, + "offline_queue_reads": 0, + "round": 3, + "direction": "peer_to_hub" + } + ] + } + ], + "retained_attempt_notes": [ + "Initial app install was refused before touching hardware by owner-inspection mode; scoped helper retry passed.", + "Legacy USB radio2 test refused existing automatic owner after100query/10reopen pass; RAM-only natural-expiry replacement passed.", + "First host-release bookkeeping failure and second missing-BLE-library failure restored USB; neither is BLE qualification.", + "Normal-WiFi BLE completed3payloadsessions but HCI contained0x3e/0x22 disconnects.", + "WiFi-quiet BLE completed0sessions; HCI0x3e/0x08. Exact WiFi connection and USB were restored.", + "Approved radio2-Off changed-condition diagnostic found no unique current USB-bound BLE advertisement in25seconds, before any connection; original auto owner and USB/settings restored." + ], + "usb": { + "passed": true, + "queries_requested": 100, + "persistent_cycles_completed": 100, + "reopens_requested": 10, + "reopens_completed": 10, + "channel_state_unchanged": true, + "cold_boot_tested": false, + "initial_core_stats": { + "battery_mv": 4182, + "error_flags": 0, + "outbound_queue_length": 0, + "uptime_seconds": 786 + }, + "final_core_stats": { + "battery_mv": 4196, + "error_flags": 0, + "outbound_queue_length": 0, + "uptime_seconds": 12 + }, + "transport": { + "discarded_prefix_bytes": 0, + "ignored_push_frames": 0, + "invalid_length_candidates": 0, + "ports_closed": 31, + "ports_opened": 31, + "request_bytes_sent": 6189, + "request_frames_sent": 713, + "response_frames_received": 718, + "response_payload_bytes": 28545 + }, + "owned_contact_and_warm_reboot": { + "passed": true, + "crud_requested": true, + "create_update_readback": true, + "reopen_readback": true, + "owned_record_survives_warm_reboot": true, + "owned_contact_removed_and_reopen_verified": true, + "warm_reboot_verified": true, + "reboot_requests_sent": 1, + "uptime_before_reboot": 869, + "uptime_after_reboot": 3 + }, + "stable_binary_tail_summary": { + "sessions_checked": 30, + "all_passed": true, + "maximum_buffered_bytes": 0, + "maximum_input_bytes": 0, + "source_summary_sha256": "53e4e11ec8f1166955649359075a4c920cf29d5e275203c531b43bd24b1d7177" + } + } + }, + { + "board": "RAK WisMesh Tag", + "role": "Full Companion", + "installed_source_commit": "c9acec474bb9617d4f92dbdc0401765701989288", + "installed_full_version": "v1.18.0.2-halo-keymind-cascade-dev-c9acec47 (Build: 28-Oct-2026)", + "installed_artifact_sha256": "60d0a86fac55cd8639257268da13d89a51e31e1b478ad1c451f47119690ee23f", + "installed_artifact_kind": "application-only DFU ZIP", + "application_install": { + "passed": true, + "app_only": true, + "bootloader_written": false, + "softdevice_written": false, + "erase_requested": false, + "runtime_version_verified": true, + "identity_preserved": true, + "logical_settings_preserved": true, + "contacts_preserved": true, + "channels_preserved": true + }, + "radio2": { + "passed": true, + "lease_minutes": 1, + "mode": "rxtx", + "natural_expiry_verified": true, + "expiry_polls": 10, + "original_secondary_restored": true, + "automatic_owner_before": false, + "original_auto_owner_restored": false, + "persistent_radio_or_channel_setters_sent": false, + "intentional_rf_transmissions": 0, + "overlap_cases_completed": 2, + "overlap_cases_passed": 2 + }, + "binary_settings": { + "passed": true, + "binary_tx_setter_acknowledged": true, + "pending_saved_pin_readback_matches": true, + "pending_pin_readback_survives_usb_reopen": true, + "original_settings_restored": true, + "physical_tx_power_measured": false, + "hardware_application_verified": false, + "pin_cold_persistence_tested": false, + "rf_send_commands": 0, + "reboots_requested": 0 + }, + "ble_trials": [ + { + "attempt": "20261028T135428.807280Z", + "strict_passed": false, + "condition": "normal mounted USB; normal host WiFi", + "host_wifi_condition": "normal host WiFi; no WiFi change requested during this trial", + "measurement_started_utc": "2026-10-28T13:54:28.809021+00:00", + "measurement_finished_utc": "2026-10-28T13:54:28.845708+00:00", + "source_summary_sha256": "31cb7cb4e9012be53eae14b516f8e7bd52d9ed5d5141e56d3bf32bd1acec0afb", + "target_bond_removal_requested": false, + "qualification_payload_worker_ran": false, + "payload_sessions_completed": 0 + }, + { + "attempt": "20261028T140158.046202Z", + "strict_passed": true, + "condition": "normal mounted USB; normal host WiFi", + "host_wifi_condition": "normal host WiFi; no WiFi change requested during this trial", + "measurement_started_utc": "2026-10-28T14:01:58.046875+00:00", + "measurement_finished_utc": "2026-10-28T14:04:16.302509+00:00", + "source_summary_sha256": "164f2fe47441daf85e79c8aaeeab46c9d88e8ce700fb073cbe77aa6a367a3543", + "target_bond_removal_requested": false, + "hci": { + "clean": true, + "target_successful_connection_events": 3, + "target_failed_establishments": 0, + "target_command_failures": 0, + "target_disconnect_reasons": [ + "0x16", + "0x16", + "0x16" + ], + "target_disconnect_statuses": [ + "0x00", + "0x00", + "0x00" + ], + "unattributed_controller_events": 0, + "lifecycle_parse_errors": 0, + "target_open_handles_at_end": 0, + "target_pending_attempts_at_end": 0, + "capture_ended_before_timeout": true, + "source_summary_sha256": "ea2949936d770646480a5cc7e976e009826617bbe9505c466e1e78d14487ccd5" + }, + "qualification_payload_worker_ran": true, + "passed": true, + "sessions_requested": 3, + "sessions_completed": 3, + "hold_seconds_requested": 30.0, + "adapter_reset": false, + "broad_bond_changes": false, + "full_version_matches": true, + "usb_public_key_matches": true, + "final_target_disconnected": true, + "session_hold_seconds": [ + 30.269, + 30.3, + 30.329 + ] + } + ], + "rf_trials": [ + { + "attempt": "rf-tag-pocket-run1", + "harness_passed": true, + "rounds_completed": 3, + "offline_queue_reads": 0, + "rf_send_attempts": 6, + "rf_retries": 0, + "cleanup": { + "pocket": { + "saved_getters_equal": true, + "channel_names_and_keys_equal": true, + "contact_static_records_equal": true, + "contact_live_timestamp_changes": 0, + "binary_preferences_equal": true, + "offline_queue_reads": 0 + }, + "tag": { + "saved_getters_equal": true, + "channel_names_and_keys_equal": true, + "contact_static_records_equal": true, + "contact_live_timestamp_changes": 0, + "binary_preferences_equal": true, + "offline_queue_reads": 0 + } + }, + "legs": [ + { + "passed": true, + "elapsed_seconds": 0.405, + "path_bytes": 0, + "snr_db": 11.5, + "rssi_dbm": -52, + "full_usb_bound_peer_ids_and_nonce_verified": true, + "sender_packet_delta": { + "received": 0, + "sent": 1, + "sent_flood": 0, + "sent_direct": 1, + "received_flood": 0, + "received_direct": 0, + "receive_errors": 0 + }, + "receiver_packet_delta": { + "received": 1, + "sent": 0, + "sent_flood": 0, + "sent_direct": 0, + "received_flood": 0, + "received_direct": 1, + "receive_errors": 0 + }, + "offline_queue_reads": 0, + "round": 1, + "direction": "hub_to_peer" + }, + { + "passed": true, + "elapsed_seconds": 0.416, + "path_bytes": 0, + "snr_db": 11.75, + "rssi_dbm": -41, + "full_usb_bound_peer_ids_and_nonce_verified": true, + "sender_packet_delta": { + "received": 0, + "sent": 1, + "sent_flood": 0, + "sent_direct": 1, + "received_flood": 0, + "received_direct": 0, + "receive_errors": 0 + }, + "receiver_packet_delta": { + "received": 1, + "sent": 0, + "sent_flood": 0, + "sent_direct": 0, + "received_flood": 0, + "received_direct": 1, + "receive_errors": 0 + }, + "offline_queue_reads": 0, + "round": 1, + "direction": "peer_to_hub" + }, + { + "passed": true, + "elapsed_seconds": 0.409, + "path_bytes": 0, + "snr_db": 11.75, + "rssi_dbm": -51, + "full_usb_bound_peer_ids_and_nonce_verified": true, + "sender_packet_delta": { + "received": 0, + "sent": 1, + "sent_flood": 0, + "sent_direct": 1, + "received_flood": 0, + "received_direct": 0, + "receive_errors": 0 + }, + "receiver_packet_delta": { + "received": 1, + "sent": 0, + "sent_flood": 0, + "sent_direct": 0, + "received_flood": 0, + "received_direct": 1, + "receive_errors": 0 + }, + "offline_queue_reads": 0, + "round": 2, + "direction": "hub_to_peer" + }, + { + "passed": true, + "elapsed_seconds": 0.423, + "path_bytes": 0, + "snr_db": 11.25, + "rssi_dbm": -41, + "full_usb_bound_peer_ids_and_nonce_verified": true, + "sender_packet_delta": { + "received": 0, + "sent": 1, + "sent_flood": 0, + "sent_direct": 1, + "received_flood": 0, + "received_direct": 0, + "receive_errors": 0 + }, + "receiver_packet_delta": { + "received": 1, + "sent": 0, + "sent_flood": 0, + "sent_direct": 0, + "received_flood": 0, + "received_direct": 1, + "receive_errors": 0 + }, + "offline_queue_reads": 0, + "round": 2, + "direction": "peer_to_hub" + }, + { + "passed": true, + "elapsed_seconds": 0.413, + "path_bytes": 0, + "snr_db": 11.75, + "rssi_dbm": -52, + "full_usb_bound_peer_ids_and_nonce_verified": true, + "sender_packet_delta": { + "received": 0, + "sent": 1, + "sent_flood": 0, + "sent_direct": 1, + "received_flood": 0, + "received_direct": 0, + "receive_errors": 0 + }, + "receiver_packet_delta": { + "received": 1, + "sent": 0, + "sent_flood": 0, + "sent_direct": 0, + "received_flood": 0, + "received_direct": 1, + "receive_errors": 0 + }, + "offline_queue_reads": 0, + "round": 3, + "direction": "hub_to_peer" + }, + { + "passed": true, + "elapsed_seconds": 0.424, + "path_bytes": 0, + "snr_db": 11.25, + "rssi_dbm": -41, + "full_usb_bound_peer_ids_and_nonce_verified": true, + "sender_packet_delta": { + "received": 0, + "sent": 1, + "sent_flood": 0, + "sent_direct": 1, + "received_flood": 0, + "received_direct": 0, + "receive_errors": 0 + }, + "receiver_packet_delta": { + "received": 1, + "sent": 0, + "sent_flood": 0, + "sent_direct": 0, + "received_flood": 0, + "received_direct": 1, + "receive_errors": 0 + }, + "offline_queue_reads": 0, + "round": 3, + "direction": "peer_to_hub" + } + ] + }, + { + "attempt": "rf-tag-t096-run1", + "harness_passed": true, + "rounds_completed": 3, + "offline_queue_reads": 0, + "rf_send_attempts": 6, + "rf_retries": 0, + "cleanup": { + "t096": { + "saved_getters_equal": true, + "channel_names_and_keys_equal": true, + "contact_static_records_equal": true, + "contact_live_timestamp_changes": 0, + "binary_preferences_equal": true, + "offline_queue_reads": 0 + }, + "tag": { + "saved_getters_equal": true, + "channel_names_and_keys_equal": true, + "contact_static_records_equal": true, + "contact_live_timestamp_changes": 0, + "binary_preferences_equal": true, + "offline_queue_reads": 0 + } + }, + "legs": [ + { + "passed": true, + "elapsed_seconds": 0.407, + "path_bytes": 0, + "snr_db": 11.75, + "rssi_dbm": -25, + "full_usb_bound_peer_ids_and_nonce_verified": true, + "sender_packet_delta": { + "received": 0, + "sent": 1, + "sent_flood": 0, + "sent_direct": 1, + "received_flood": 0, + "received_direct": 0, + "receive_errors": 0 + }, + "receiver_packet_delta": { + "received": 1, + "sent": 0, + "sent_flood": 0, + "sent_direct": 0, + "received_flood": 0, + "received_direct": 1, + "receive_errors": 0 + }, + "offline_queue_reads": 0, + "round": 1, + "direction": "hub_to_peer" + }, + { + "passed": true, + "elapsed_seconds": 0.406, + "path_bytes": 0, + "snr_db": 11.75, + "rssi_dbm": -30, + "full_usb_bound_peer_ids_and_nonce_verified": true, + "sender_packet_delta": { + "received": 0, + "sent": 1, + "sent_flood": 0, + "sent_direct": 1, + "received_flood": 0, + "received_direct": 0, + "receive_errors": 0 + }, + "receiver_packet_delta": { + "received": 1, + "sent": 0, + "sent_flood": 0, + "sent_direct": 0, + "received_flood": 0, + "received_direct": 1, + "receive_errors": 0 + }, + "offline_queue_reads": 0, + "round": 1, + "direction": "peer_to_hub" + }, + { + "passed": true, + "elapsed_seconds": 0.407, + "path_bytes": 0, + "snr_db": 11.75, + "rssi_dbm": -23, + "full_usb_bound_peer_ids_and_nonce_verified": true, + "sender_packet_delta": { + "received": 0, + "sent": 1, + "sent_flood": 0, + "sent_direct": 1, + "received_flood": 0, + "received_direct": 0, + "receive_errors": 0 + }, + "receiver_packet_delta": { + "received": 1, + "sent": 0, + "sent_flood": 0, + "sent_direct": 0, + "received_flood": 0, + "received_direct": 1, + "receive_errors": 0 + }, + "offline_queue_reads": 0, + "round": 2, + "direction": "hub_to_peer" + }, + { + "passed": true, + "elapsed_seconds": 0.41, + "path_bytes": 0, + "snr_db": 12.0, + "rssi_dbm": -30, + "full_usb_bound_peer_ids_and_nonce_verified": true, + "sender_packet_delta": { + "received": 0, + "sent": 1, + "sent_flood": 0, + "sent_direct": 1, + "received_flood": 0, + "received_direct": 0, + "receive_errors": 0 + }, + "receiver_packet_delta": { + "received": 1, + "sent": 0, + "sent_flood": 0, + "sent_direct": 0, + "received_flood": 0, + "received_direct": 1, + "receive_errors": 0 + }, + "offline_queue_reads": 0, + "round": 2, + "direction": "peer_to_hub" + }, + { + "passed": true, + "elapsed_seconds": 0.412, + "path_bytes": 0, + "snr_db": 12.0, + "rssi_dbm": -23, + "full_usb_bound_peer_ids_and_nonce_verified": true, + "sender_packet_delta": { + "received": 0, + "sent": 1, + "sent_flood": 0, + "sent_direct": 1, + "received_flood": 0, + "received_direct": 0, + "receive_errors": 0 + }, + "receiver_packet_delta": { + "received": 1, + "sent": 0, + "sent_flood": 0, + "sent_direct": 0, + "received_flood": 0, + "received_direct": 1, + "receive_errors": 0 + }, + "offline_queue_reads": 0, + "round": 3, + "direction": "hub_to_peer" + }, + { + "passed": true, + "elapsed_seconds": 0.419, + "path_bytes": 0, + "snr_db": 12.0, + "rssi_dbm": -30, + "full_usb_bound_peer_ids_and_nonce_verified": true, + "sender_packet_delta": { + "received": 0, + "sent": 1, + "sent_flood": 0, + "sent_direct": 1, + "received_flood": 0, + "received_direct": 0, + "receive_errors": 0 + }, + "receiver_packet_delta": { + "received": 1, + "sent": 0, + "sent_flood": 0, + "sent_direct": 0, + "received_flood": 0, + "received_direct": 1, + "receive_errors": 0 + }, + "offline_queue_reads": 0, + "round": 3, + "direction": "peer_to_hub" + } + ] + }, + { + "attempt": "rf-tag-t1000-run1", + "harness_passed": true, + "rounds_completed": 3, + "offline_queue_reads": 0, + "rf_send_attempts": 6, + "rf_retries": 0, + "cleanup": { + "t1000": { + "saved_getters_equal": true, + "channel_names_and_keys_equal": true, + "contact_static_records_equal": true, + "contact_live_timestamp_changes": 0, + "binary_preferences_equal": true, + "offline_queue_reads": 0 + }, + "tag": { + "saved_getters_equal": true, + "channel_names_and_keys_equal": true, + "contact_static_records_equal": true, + "contact_live_timestamp_changes": 0, + "binary_preferences_equal": true, + "offline_queue_reads": 0 + } + }, + "legs": [ + { + "passed": true, + "elapsed_seconds": 0.641, + "path_bytes": 0, + "snr_db": 13.0, + "rssi_dbm": -40, + "full_usb_bound_peer_ids_and_nonce_verified": true, + "sender_packet_delta": { + "received": 0, + "sent": 1, + "sent_flood": 0, + "sent_direct": 1, + "received_flood": 0, + "received_direct": 0, + "receive_errors": 0 + }, + "receiver_packet_delta": { + "received": 1, + "sent": 0, + "sent_flood": 0, + "sent_direct": 0, + "received_flood": 0, + "received_direct": 1, + "receive_errors": 0 + }, + "offline_queue_reads": 0, + "round": 1, + "direction": "hub_to_peer" + }, + { + "passed": true, + "elapsed_seconds": 0.408, + "path_bytes": 0, + "snr_db": 13.0, + "rssi_dbm": -32, + "full_usb_bound_peer_ids_and_nonce_verified": true, + "sender_packet_delta": { + "received": 0, + "sent": 1, + "sent_flood": 0, + "sent_direct": 1, + "received_flood": 0, + "received_direct": 0, + "receive_errors": 0 + }, + "receiver_packet_delta": { + "received": 1, + "sent": 0, + "sent_flood": 0, + "sent_direct": 0, + "received_flood": 0, + "received_direct": 1, + "receive_errors": 0 + }, + "offline_queue_reads": 0, + "round": 1, + "direction": "peer_to_hub" + }, + { + "passed": true, + "elapsed_seconds": 0.406, + "path_bytes": 0, + "snr_db": 13.25, + "rssi_dbm": -40, + "full_usb_bound_peer_ids_and_nonce_verified": true, + "sender_packet_delta": { + "received": 0, + "sent": 1, + "sent_flood": 0, + "sent_direct": 1, + "received_flood": 0, + "received_direct": 0, + "receive_errors": 0 + }, + "receiver_packet_delta": { + "received": 1, + "sent": 0, + "sent_flood": 0, + "sent_direct": 0, + "received_flood": 0, + "received_direct": 1, + "receive_errors": 0 + }, + "offline_queue_reads": 0, + "round": 2, + "direction": "hub_to_peer" + }, + { + "passed": true, + "elapsed_seconds": 0.405, + "path_bytes": 0, + "snr_db": 13.5, + "rssi_dbm": -32, + "full_usb_bound_peer_ids_and_nonce_verified": true, + "sender_packet_delta": { + "received": 0, + "sent": 1, + "sent_flood": 0, + "sent_direct": 1, + "received_flood": 0, + "received_direct": 0, + "receive_errors": 0 + }, + "receiver_packet_delta": { + "received": 1, + "sent": 0, + "sent_flood": 0, + "sent_direct": 0, + "received_flood": 0, + "received_direct": 1, + "receive_errors": 0 + }, + "offline_queue_reads": 0, + "round": 2, + "direction": "peer_to_hub" + }, + { + "passed": true, + "elapsed_seconds": 0.404, + "path_bytes": 0, + "snr_db": 12.5, + "rssi_dbm": -40, + "full_usb_bound_peer_ids_and_nonce_verified": true, + "sender_packet_delta": { + "received": 0, + "sent": 1, + "sent_flood": 0, + "sent_direct": 1, + "received_flood": 0, + "received_direct": 0, + "receive_errors": 0 + }, + "receiver_packet_delta": { + "received": 1, + "sent": 0, + "sent_flood": 0, + "sent_direct": 0, + "received_flood": 0, + "received_direct": 1, + "receive_errors": 0 + }, + "offline_queue_reads": 0, + "round": 3, + "direction": "hub_to_peer" + }, + { + "passed": true, + "elapsed_seconds": 0.406, + "path_bytes": 0, + "snr_db": 13.25, + "rssi_dbm": -32, + "full_usb_bound_peer_ids_and_nonce_verified": true, + "sender_packet_delta": { + "received": 0, + "sent": 1, + "sent_flood": 0, + "sent_direct": 1, + "received_flood": 0, + "received_direct": 0, + "receive_errors": 0 + }, + "receiver_packet_delta": { + "received": 1, + "sent": 0, + "sent_flood": 0, + "sent_direct": 0, + "received_flood": 0, + "received_direct": 1, + "receive_errors": 0 + }, + "offline_queue_reads": 0, + "round": 3, + "direction": "peer_to_hub" + } + ] + }, + { + "attempt": "rf-tag-v43-run1", + "harness_passed": false, + "rounds_completed": 3, + "offline_queue_reads": 0, + "rf_send_attempts": 6, + "rf_retries": 0, + "cleanup": { + "v43": { + "passed": false, + "failure_type": "RuntimeError" + }, + "tag": { + "saved_getters_equal": true, + "channel_names_and_keys_equal": true, + "contact_static_records_equal": true, + "contact_live_timestamp_changes": 0, + "binary_preferences_equal": true, + "offline_queue_reads": 0 + } + }, + "legs": [ + { + "passed": true, + "elapsed_seconds": 0.406, + "path_bytes": 0, + "snr_db": 12.0, + "rssi_dbm": -51, + "full_usb_bound_peer_ids_and_nonce_verified": true, + "sender_packet_delta": { + "received": 0, + "sent": 1, + "sent_flood": 0, + "sent_direct": 1, + "received_flood": 0, + "received_direct": 0, + "receive_errors": 0 + }, + "receiver_packet_delta": { + "received": 1, + "sent": 0, + "sent_flood": 0, + "sent_direct": 0, + "received_flood": 0, + "received_direct": 1, + "receive_errors": 0 + }, + "offline_queue_reads": 0, + "round": 1, + "direction": "hub_to_peer" + }, + { + "passed": true, + "elapsed_seconds": 0.414, + "path_bytes": 0, + "snr_db": 12.5, + "rssi_dbm": -35, + "full_usb_bound_peer_ids_and_nonce_verified": true, + "sender_packet_delta": { + "received": 0, + "sent": 1, + "sent_flood": 0, + "sent_direct": 1, + "received_flood": 0, + "received_direct": 0, + "receive_errors": 0 + }, + "receiver_packet_delta": { + "received": 1, + "sent": 0, + "sent_flood": 0, + "sent_direct": 0, + "received_flood": 0, + "received_direct": 1, + "receive_errors": 0 + }, + "offline_queue_reads": 0, + "round": 1, + "direction": "peer_to_hub" + }, + { + "passed": true, + "elapsed_seconds": 0.413, + "path_bytes": 0, + "snr_db": 11.0, + "rssi_dbm": -51, + "full_usb_bound_peer_ids_and_nonce_verified": true, + "sender_packet_delta": { + "received": 0, + "sent": 1, + "sent_flood": 0, + "sent_direct": 1, + "received_flood": 0, + "received_direct": 0, + "receive_errors": 0 + }, + "receiver_packet_delta": { + "received": 1, + "sent": 0, + "sent_flood": 0, + "sent_direct": 0, + "received_flood": 0, + "received_direct": 1, + "receive_errors": 0 + }, + "offline_queue_reads": 0, + "round": 2, + "direction": "hub_to_peer" + }, + { + "passed": true, + "elapsed_seconds": 0.414, + "path_bytes": 0, + "snr_db": 12.5, + "rssi_dbm": -34, + "full_usb_bound_peer_ids_and_nonce_verified": true, + "sender_packet_delta": { + "received": 0, + "sent": 1, + "sent_flood": 0, + "sent_direct": 1, + "received_flood": 0, + "received_direct": 0, + "receive_errors": 0 + }, + "receiver_packet_delta": { + "received": 1, + "sent": 0, + "sent_flood": 0, + "sent_direct": 0, + "received_flood": 0, + "received_direct": 1, + "receive_errors": 0 + }, + "offline_queue_reads": 0, + "round": 2, + "direction": "peer_to_hub" + }, + { + "passed": true, + "elapsed_seconds": 0.412, + "path_bytes": 0, + "snr_db": 11.0, + "rssi_dbm": -51, + "full_usb_bound_peer_ids_and_nonce_verified": true, + "sender_packet_delta": { + "received": 0, + "sent": 1, + "sent_flood": 0, + "sent_direct": 1, + "received_flood": 0, + "received_direct": 0, + "receive_errors": 0 + }, + "receiver_packet_delta": { + "received": 1, + "sent": 0, + "sent_flood": 0, + "sent_direct": 0, + "received_flood": 0, + "received_direct": 1, + "receive_errors": 0 + }, + "offline_queue_reads": 0, + "round": 3, + "direction": "hub_to_peer" + }, + { + "passed": true, + "elapsed_seconds": 0.417, + "path_bytes": 0, + "snr_db": 12.75, + "rssi_dbm": -34, + "full_usb_bound_peer_ids_and_nonce_verified": true, + "sender_packet_delta": { + "received": 0, + "sent": 1, + "sent_flood": 0, + "sent_direct": 1, + "received_flood": 0, + "received_direct": 0, + "receive_errors": 0 + }, + "receiver_packet_delta": { + "received": 1, + "sent": 0, + "sent_flood": 0, + "sent_direct": 0, + "received_flood": 0, + "received_direct": 1, + "receive_errors": 0 + }, + "offline_queue_reads": 0, + "round": 3, + "direction": "peer_to_hub" + } + ], + "offline_comparison_followup": { + "passed": true, + "method": "offline corrected static radio2 comparison only", + "new_hardware_access": false, + "new_rf_send_commands": 0, + "original_failed_summary_preserved": true, + "original_summary_sha256": "024759290a84c836196453089785c5351866df55894e539840b1f7e46b590b61", + "six_source_identity_nonce_counter_bound_zero_hop_legs_verified": true, + "both_owned_radio_tx_restorations_verified": true, + "source_summary_sha256": "749d1210c2d3a2141290b97c1f349b6e08d3f3f95b4849090cde1296c0387adc" + } + } + ], + "retained_attempt_notes": [ + "Initial BLE preflight refused missing capability manifest before BLE; correct manifest staged, subsequent strict BLE passed." + ], + "usb": { + "passed": true, + "queries_requested": 100, + "persistent_cycles_completed": 100, + "reopens_requested": 10, + "reopens_completed": 10, + "channel_state_unchanged": true, + "cold_boot_tested": false, + "initial_core_stats": { + "battery_mv": 4121, + "error_flags": 0, + "outbound_queue_length": 0, + "uptime_seconds": 1087 + }, + "final_core_stats": { + "battery_mv": 4118, + "error_flags": 0, + "outbound_queue_length": 0, + "uptime_seconds": 14 + }, + "transport": { + "discarded_prefix_bytes": 0, + "ignored_push_frames": 0, + "invalid_length_candidates": 0, + "ports_closed": 31, + "ports_opened": 31, + "request_bytes_sent": 6755, + "request_frames_sent": 738, + "response_frames_received": 755, + "response_payload_bytes": 31230 + }, + "owned_contact_and_warm_reboot": { + "passed": true, + "crud_requested": true, + "create_update_readback": true, + "reopen_readback": true, + "owned_record_survives_warm_reboot": true, + "owned_contact_removed_and_reopen_verified": true, + "warm_reboot_verified": true, + "reboot_requests_sent": 1, + "uptime_before_reboot": 1185, + "uptime_after_reboot": 4 + }, + "stable_binary_tail_summary": { + "sessions_checked": 30, + "all_passed": true, + "maximum_buffered_bytes": 0, + "maximum_input_bytes": 0, + "source_summary_sha256": "53e4e11ec8f1166955649359075a4c920cf29d5e275203c531b43bd24b1d7177" + } + } + }, + { + "board": "Heltec T096 FEM on", + "role": "Full Companion", + "installed_source_commit": "c9acec474bb9617d4f92dbdc0401765701989288", + "installed_full_version": "v1.18.0.2-halo-keymind-cascade-dev-c9acec47 (Build: 28-Oct-2026)", + "installed_artifact_sha256": "e8230b79d6140d45e25a62cd6946fff844f05c9c583325afce39e809b0f369e8", + "installed_artifact_kind": "application-only DFU ZIP", + "application_install": { + "passed": true, + "app_only": true, + "bootloader_written": false, + "softdevice_written": false, + "erase_requested": false, + "runtime_version_verified": true, + "identity_preserved": true, + "logical_settings_preserved": true, + "contacts_preserved": true, + "channels_preserved": true + }, + "radio2": { + "passed": true, + "lease_minutes": 1, + "mode": "rxtx", + "natural_expiry_verified": true, + "expiry_polls": 10, + "original_secondary_restored": true, + "automatic_owner_before": false, + "original_auto_owner_restored": false, + "persistent_radio_or_channel_setters_sent": false, + "intentional_rf_transmissions": 0, + "overlap_cases_completed": 2, + "overlap_cases_passed": 2 + }, + "binary_settings": { + "passed": true, + "binary_tx_setter_acknowledged": true, + "pending_saved_pin_readback_matches": true, + "pending_pin_readback_survives_usb_reopen": true, + "original_settings_restored": true, + "physical_tx_power_measured": false, + "hardware_application_verified": false, + "pin_cold_persistence_tested": false, + "rf_send_commands": 0, + "reboots_requested": 0 + }, + "ble_trials": [ + { + "attempt": "20261028T140808.471165Z", + "strict_passed": true, + "condition": "normal mounted USB; normal host WiFi", + "host_wifi_condition": "normal host WiFi; no WiFi change requested during this trial", + "measurement_started_utc": "2026-10-28T14:08:08.472124+00:00", + "measurement_finished_utc": "2026-10-28T14:10:26.199848+00:00", + "source_summary_sha256": "c321a87796170a745d8bd8b2d7f60c111f8bf851262e757083ebd916f790f2c9", + "target_bond_removal_requested": false, + "hci": { + "clean": true, + "target_successful_connection_events": 3, + "target_failed_establishments": 0, + "target_command_failures": 0, + "target_disconnect_reasons": [ + "0x16", + "0x16", + "0x16" + ], + "target_disconnect_statuses": [ + "0x00", + "0x00", + "0x00" + ], + "unattributed_controller_events": 0, + "lifecycle_parse_errors": 0, + "target_open_handles_at_end": 0, + "target_pending_attempts_at_end": 0, + "capture_ended_before_timeout": true, + "source_summary_sha256": "7ba6d0dd1745ca75f33614f4d5422d0082bb1afe3b61047434ba20dda5c52ae8" + }, + "qualification_payload_worker_ran": true, + "passed": true, + "sessions_requested": 3, + "sessions_completed": 3, + "hold_seconds_requested": 30.0, + "adapter_reset": false, + "broad_bond_changes": false, + "full_version_matches": true, + "usb_public_key_matches": true, + "final_target_disconnected": true, + "session_hold_seconds": [ + 30.211, + 30.24, + 30.24 + ] + } + ], + "rf_trials": [ + { + "attempt": "rf-tag-t096-run1", + "harness_passed": true, + "rounds_completed": 3, + "offline_queue_reads": 0, + "rf_send_attempts": 6, + "rf_retries": 0, + "cleanup": { + "t096": { + "saved_getters_equal": true, + "channel_names_and_keys_equal": true, + "contact_static_records_equal": true, + "contact_live_timestamp_changes": 0, + "binary_preferences_equal": true, + "offline_queue_reads": 0 + }, + "tag": { + "saved_getters_equal": true, + "channel_names_and_keys_equal": true, + "contact_static_records_equal": true, + "contact_live_timestamp_changes": 0, + "binary_preferences_equal": true, + "offline_queue_reads": 0 + } + }, + "legs": [ + { + "passed": true, + "elapsed_seconds": 0.407, + "path_bytes": 0, + "snr_db": 11.75, + "rssi_dbm": -25, + "full_usb_bound_peer_ids_and_nonce_verified": true, + "sender_packet_delta": { + "received": 0, + "sent": 1, + "sent_flood": 0, + "sent_direct": 1, + "received_flood": 0, + "received_direct": 0, + "receive_errors": 0 + }, + "receiver_packet_delta": { + "received": 1, + "sent": 0, + "sent_flood": 0, + "sent_direct": 0, + "received_flood": 0, + "received_direct": 1, + "receive_errors": 0 + }, + "offline_queue_reads": 0, + "round": 1, + "direction": "hub_to_peer" + }, + { + "passed": true, + "elapsed_seconds": 0.406, + "path_bytes": 0, + "snr_db": 11.75, + "rssi_dbm": -30, + "full_usb_bound_peer_ids_and_nonce_verified": true, + "sender_packet_delta": { + "received": 0, + "sent": 1, + "sent_flood": 0, + "sent_direct": 1, + "received_flood": 0, + "received_direct": 0, + "receive_errors": 0 + }, + "receiver_packet_delta": { + "received": 1, + "sent": 0, + "sent_flood": 0, + "sent_direct": 0, + "received_flood": 0, + "received_direct": 1, + "receive_errors": 0 + }, + "offline_queue_reads": 0, + "round": 1, + "direction": "peer_to_hub" + }, + { + "passed": true, + "elapsed_seconds": 0.407, + "path_bytes": 0, + "snr_db": 11.75, + "rssi_dbm": -23, + "full_usb_bound_peer_ids_and_nonce_verified": true, + "sender_packet_delta": { + "received": 0, + "sent": 1, + "sent_flood": 0, + "sent_direct": 1, + "received_flood": 0, + "received_direct": 0, + "receive_errors": 0 + }, + "receiver_packet_delta": { + "received": 1, + "sent": 0, + "sent_flood": 0, + "sent_direct": 0, + "received_flood": 0, + "received_direct": 1, + "receive_errors": 0 + }, + "offline_queue_reads": 0, + "round": 2, + "direction": "hub_to_peer" + }, + { + "passed": true, + "elapsed_seconds": 0.41, + "path_bytes": 0, + "snr_db": 12.0, + "rssi_dbm": -30, + "full_usb_bound_peer_ids_and_nonce_verified": true, + "sender_packet_delta": { + "received": 0, + "sent": 1, + "sent_flood": 0, + "sent_direct": 1, + "received_flood": 0, + "received_direct": 0, + "receive_errors": 0 + }, + "receiver_packet_delta": { + "received": 1, + "sent": 0, + "sent_flood": 0, + "sent_direct": 0, + "received_flood": 0, + "received_direct": 1, + "receive_errors": 0 + }, + "offline_queue_reads": 0, + "round": 2, + "direction": "peer_to_hub" + }, + { + "passed": true, + "elapsed_seconds": 0.412, + "path_bytes": 0, + "snr_db": 12.0, + "rssi_dbm": -23, + "full_usb_bound_peer_ids_and_nonce_verified": true, + "sender_packet_delta": { + "received": 0, + "sent": 1, + "sent_flood": 0, + "sent_direct": 1, + "received_flood": 0, + "received_direct": 0, + "receive_errors": 0 + }, + "receiver_packet_delta": { + "received": 1, + "sent": 0, + "sent_flood": 0, + "sent_direct": 0, + "received_flood": 0, + "received_direct": 1, + "receive_errors": 0 + }, + "offline_queue_reads": 0, + "round": 3, + "direction": "hub_to_peer" + }, + { + "passed": true, + "elapsed_seconds": 0.419, + "path_bytes": 0, + "snr_db": 12.0, + "rssi_dbm": -30, + "full_usb_bound_peer_ids_and_nonce_verified": true, + "sender_packet_delta": { + "received": 0, + "sent": 1, + "sent_flood": 0, + "sent_direct": 1, + "received_flood": 0, + "received_direct": 0, + "receive_errors": 0 + }, + "receiver_packet_delta": { + "received": 1, + "sent": 0, + "sent_flood": 0, + "sent_direct": 0, + "received_flood": 0, + "received_direct": 1, + "receive_errors": 0 + }, + "offline_queue_reads": 0, + "round": 3, + "direction": "peer_to_hub" + } + ] + } + ], + "retained_attempt_notes": [], + "usb": { + "passed": true, + "queries_requested": 100, + "persistent_cycles_completed": 100, + "reopens_requested": 10, + "reopens_completed": 10, + "channel_state_unchanged": true, + "cold_boot_tested": false, + "initial_core_stats": { + "battery_mv": 4195, + "error_flags": 0, + "outbound_queue_length": 0, + "uptime_seconds": 1807 + }, + "final_core_stats": { + "battery_mv": 4173, + "error_flags": 0, + "outbound_queue_length": 0, + "uptime_seconds": 14 + }, + "transport": { + "discarded_prefix_bytes": 0, + "ignored_push_frames": 0, + "invalid_length_candidates": 0, + "ports_closed": 31, + "ports_opened": 31, + "request_bytes_sent": 6755, + "request_frames_sent": 738, + "response_frames_received": 740, + "response_payload_bytes": 29010 + }, + "owned_contact_and_warm_reboot": { + "passed": true, + "crud_requested": true, + "create_update_readback": true, + "reopen_readback": true, + "owned_record_survives_warm_reboot": true, + "owned_contact_removed_and_reopen_verified": true, + "warm_reboot_verified": true, + "reboot_requests_sent": 1, + "uptime_before_reboot": 1902, + "uptime_after_reboot": 4 + }, + "stable_binary_tail_summary": { + "sessions_checked": 30, + "all_passed": true, + "maximum_buffered_bytes": 0, + "maximum_input_bytes": 0, + "source_summary_sha256": "53e4e11ec8f1166955649359075a4c920cf29d5e275203c531b43bd24b1d7177" + } + } + }, + { + "board": "Heltec MeshPocket", + "role": "Full Companion", + "installed_source_commit": "c9acec474bb9617d4f92dbdc0401765701989288", + "installed_full_version": "v1.18.0.2-halo-keymind-cascade-dev-c9acec47 (Build: 28-Oct-2026)", + "installed_artifact_sha256": "7f0195128d1c8c86fd15aed6b27e237d507adffc03ae17bb9efd3a04cd67ca17", + "installed_artifact_kind": "application-only DFU ZIP", + "application_install": { + "passed": true, + "app_only": true, + "bootloader_written": false, + "softdevice_written": false, + "erase_requested": false, + "runtime_version_verified": true, + "identity_preserved": true, + "logical_settings_preserved": true, + "contacts_preserved": true, + "channels_preserved": true + }, + "radio2": { + "passed": true, + "lease_minutes": 1, + "mode": "rxtx", + "natural_expiry_verified": true, + "expiry_polls": 10, + "original_secondary_restored": true, + "automatic_owner_before": false, + "original_auto_owner_restored": false, + "persistent_radio_or_channel_setters_sent": false, + "intentional_rf_transmissions": 0, + "overlap_cases_completed": 2, + "overlap_cases_passed": 2 + }, + "binary_settings": { + "passed": true, + "binary_tx_setter_acknowledged": true, + "pending_saved_pin_readback_matches": true, + "pending_pin_readback_survives_usb_reopen": true, + "original_settings_restored": true, + "physical_tx_power_measured": false, + "hardware_application_verified": false, + "pin_cold_persistence_tested": false, + "rf_send_commands": 0, + "reboots_requested": 0 + }, + "ble_trials": [ + { + "attempt": "20261028T141104.432712Z", + "strict_passed": true, + "condition": "normal mounted USB; normal host WiFi", + "host_wifi_condition": "normal host WiFi; no WiFi change requested during this trial", + "measurement_started_utc": "2026-10-28T14:11:04.433684+00:00", + "measurement_finished_utc": "2026-10-28T14:13:22.148324+00:00", + "source_summary_sha256": "50c47b7caebe234387a047e11980b6a8b10d03aa41c4aff143ae828a348bc815", + "target_bond_removal_requested": false, + "hci": { + "clean": true, + "target_successful_connection_events": 3, + "target_failed_establishments": 0, + "target_command_failures": 0, + "target_disconnect_reasons": [ + "0x16", + "0x16", + "0x16" + ], + "target_disconnect_statuses": [ + "0x00", + "0x00", + "0x00" + ], + "unattributed_controller_events": 0, + "lifecycle_parse_errors": 0, + "target_open_handles_at_end": 0, + "target_pending_attempts_at_end": 0, + "capture_ended_before_timeout": true, + "source_summary_sha256": "5766448ff1de1a4ccf68289bffae6de5823994239e282d36d2309b7bc05dcae9" + }, + "qualification_payload_worker_ran": true, + "passed": true, + "sessions_requested": 3, + "sessions_completed": 3, + "hold_seconds_requested": 30.0, + "adapter_reset": false, + "broad_bond_changes": false, + "full_version_matches": true, + "usb_public_key_matches": true, + "final_target_disconnected": true, + "session_hold_seconds": [ + 30.3, + 30.269, + 30.301 + ] + } + ], + "rf_trials": [ + { + "attempt": "rf-tag-pocket-run1", + "harness_passed": true, + "rounds_completed": 3, + "offline_queue_reads": 0, + "rf_send_attempts": 6, + "rf_retries": 0, + "cleanup": { + "pocket": { + "saved_getters_equal": true, + "channel_names_and_keys_equal": true, + "contact_static_records_equal": true, + "contact_live_timestamp_changes": 0, + "binary_preferences_equal": true, + "offline_queue_reads": 0 + }, + "tag": { + "saved_getters_equal": true, + "channel_names_and_keys_equal": true, + "contact_static_records_equal": true, + "contact_live_timestamp_changes": 0, + "binary_preferences_equal": true, + "offline_queue_reads": 0 + } + }, + "legs": [ + { + "passed": true, + "elapsed_seconds": 0.405, + "path_bytes": 0, + "snr_db": 11.5, + "rssi_dbm": -52, + "full_usb_bound_peer_ids_and_nonce_verified": true, + "sender_packet_delta": { + "received": 0, + "sent": 1, + "sent_flood": 0, + "sent_direct": 1, + "received_flood": 0, + "received_direct": 0, + "receive_errors": 0 + }, + "receiver_packet_delta": { + "received": 1, + "sent": 0, + "sent_flood": 0, + "sent_direct": 0, + "received_flood": 0, + "received_direct": 1, + "receive_errors": 0 + }, + "offline_queue_reads": 0, + "round": 1, + "direction": "hub_to_peer" + }, + { + "passed": true, + "elapsed_seconds": 0.416, + "path_bytes": 0, + "snr_db": 11.75, + "rssi_dbm": -41, + "full_usb_bound_peer_ids_and_nonce_verified": true, + "sender_packet_delta": { + "received": 0, + "sent": 1, + "sent_flood": 0, + "sent_direct": 1, + "received_flood": 0, + "received_direct": 0, + "receive_errors": 0 + }, + "receiver_packet_delta": { + "received": 1, + "sent": 0, + "sent_flood": 0, + "sent_direct": 0, + "received_flood": 0, + "received_direct": 1, + "receive_errors": 0 + }, + "offline_queue_reads": 0, + "round": 1, + "direction": "peer_to_hub" + }, + { + "passed": true, + "elapsed_seconds": 0.409, + "path_bytes": 0, + "snr_db": 11.75, + "rssi_dbm": -51, + "full_usb_bound_peer_ids_and_nonce_verified": true, + "sender_packet_delta": { + "received": 0, + "sent": 1, + "sent_flood": 0, + "sent_direct": 1, + "received_flood": 0, + "received_direct": 0, + "receive_errors": 0 + }, + "receiver_packet_delta": { + "received": 1, + "sent": 0, + "sent_flood": 0, + "sent_direct": 0, + "received_flood": 0, + "received_direct": 1, + "receive_errors": 0 + }, + "offline_queue_reads": 0, + "round": 2, + "direction": "hub_to_peer" + }, + { + "passed": true, + "elapsed_seconds": 0.423, + "path_bytes": 0, + "snr_db": 11.25, + "rssi_dbm": -41, + "full_usb_bound_peer_ids_and_nonce_verified": true, + "sender_packet_delta": { + "received": 0, + "sent": 1, + "sent_flood": 0, + "sent_direct": 1, + "received_flood": 0, + "received_direct": 0, + "receive_errors": 0 + }, + "receiver_packet_delta": { + "received": 1, + "sent": 0, + "sent_flood": 0, + "sent_direct": 0, + "received_flood": 0, + "received_direct": 1, + "receive_errors": 0 + }, + "offline_queue_reads": 0, + "round": 2, + "direction": "peer_to_hub" + }, + { + "passed": true, + "elapsed_seconds": 0.413, + "path_bytes": 0, + "snr_db": 11.75, + "rssi_dbm": -52, + "full_usb_bound_peer_ids_and_nonce_verified": true, + "sender_packet_delta": { + "received": 0, + "sent": 1, + "sent_flood": 0, + "sent_direct": 1, + "received_flood": 0, + "received_direct": 0, + "receive_errors": 0 + }, + "receiver_packet_delta": { + "received": 1, + "sent": 0, + "sent_flood": 0, + "sent_direct": 0, + "received_flood": 0, + "received_direct": 1, + "receive_errors": 0 + }, + "offline_queue_reads": 0, + "round": 3, + "direction": "hub_to_peer" + }, + { + "passed": true, + "elapsed_seconds": 0.424, + "path_bytes": 0, + "snr_db": 11.25, + "rssi_dbm": -41, + "full_usb_bound_peer_ids_and_nonce_verified": true, + "sender_packet_delta": { + "received": 0, + "sent": 1, + "sent_flood": 0, + "sent_direct": 1, + "received_flood": 0, + "received_direct": 0, + "receive_errors": 0 + }, + "receiver_packet_delta": { + "received": 1, + "sent": 0, + "sent_flood": 0, + "sent_direct": 0, + "received_flood": 0, + "received_direct": 1, + "receive_errors": 0 + }, + "offline_queue_reads": 0, + "round": 3, + "direction": "peer_to_hub" + } + ] + } + ], + "retained_attempt_notes": [ + "Original preflash core already contained watchdog flag8. After retaining candidate idle flag8 diagnostic, one identity-bound warm preparation returned0; full USB test then passed." + ], + "usb": { + "passed": true, + "queries_requested": 100, + "persistent_cycles_completed": 100, + "reopens_requested": 10, + "reopens_completed": 10, + "channel_state_unchanged": true, + "cold_boot_tested": false, + "initial_core_stats": { + "battery_mv": 4120, + "error_flags": 0, + "outbound_queue_length": 0, + "uptime_seconds": 123 + }, + "final_core_stats": { + "battery_mv": 4112, + "error_flags": 0, + "outbound_queue_length": 0, + "uptime_seconds": 27 + }, + "transport": { + "discarded_prefix_bytes": 0, + "ignored_push_frames": 0, + "invalid_length_candidates": 0, + "ports_closed": 31, + "ports_opened": 31, + "request_bytes_sent": 6755, + "request_frames_sent": 738, + "response_frames_received": 743, + "response_payload_bytes": 30106 + }, + "owned_contact_and_warm_reboot": { + "passed": true, + "crud_requested": true, + "create_update_readback": true, + "reopen_readback": true, + "owned_record_survives_warm_reboot": true, + "owned_contact_removed_and_reopen_verified": true, + "warm_reboot_verified": true, + "reboot_requests_sent": 1, + "uptime_before_reboot": 223, + "uptime_after_reboot": 16 + }, + "stable_binary_tail_summary": { + "sessions_checked": 30, + "all_passed": true, + "maximum_buffered_bytes": 0, + "maximum_input_bytes": 0, + "source_summary_sha256": "53e4e11ec8f1166955649359075a4c920cf29d5e275203c531b43bd24b1d7177" + } + } + } + ], + "nrf_repeaters": [ + { + "board": "Heltec MeshTower V2 SD", + "source_commit": "70c7d5b478b19f16d232389779ca455e73be7eba", + "application_sha256": "3aabda910a3b582c6c339bdc09612ccc9b1caa9ef5d9275439929a8acd8acfe9", + "qualification": "PASS: app-only USB update and readiness", + "installed_candidate_left": true, + "persistent_queries_completed": 100, + "fresh_reopens_completed": 10, + "warm_reboot_requests": 1, + "core_errors": 0, + "settings_preservation": { + "saved_getters_checked": 14, + "saved_getters_originally_unavailable": [ + "get usb.logging.always", + "get telemetry.mode.base", + "get telemetry.mode.loc", + "get telemetry.mode.env", + "get max.neighbours", + "get rx.delay", + "get tx.delay" + ], + "acl_comparison_available": true, + "acl_entries_preserved": 5, + "trusted_signer_list_preserved": true + }, + "identity_preserved": true, + "candidate_endf_verified": true, + "bootloader_version_unchanged": true, + "rtc_current": true, + "max_completed_command_seconds": 1.0952, + "usb_objects_closed": true, + "automatic_sleep_proofs": { + "automatic_sleep_before_usb": { + "uptime_secs": 11, + "status": "mode=auto active=on battery=0% clock=rtc window=sleep radio=sleep wake=610s hold=0s", + "no_awake_override": true + }, + "automatic_sleep_after_usb": { + "uptime_secs": 42, + "status": "mode=auto active=on battery=0% clock=rtc window=sleep radio=sleep wake=579s hold=0s", + "no_awake_override": true + }, + "automatic_sleep_after_warm_reboot": { + "uptime_secs": 11, + "status": "mode=auto active=on battery=0% clock=rtc window=sleep radio=sleep wake=567s hold=0s", + "no_awake_override": true + } + }, + "collector_originally_active": null, + "collector_unit_config_equal": null, + "collector_serial_owner_restored": null, + "installed_artifact_target": "Heltec_tower_v2_sdcard_repeater_lora_ota_no_external_sensors-full-ota" + }, + { + "board": "SolarXiao 33S", + "source_commit": "70c7d5b478b19f16d232389779ca455e73be7eba", + "application_sha256": "61543eb675c2ed3eb8d55d03cd78b5ce4806817178a020f62d7d209353085f50", + "qualification": "PASS: app-only USB update and readiness", + "installed_candidate_left": true, + "persistent_queries_completed": 100, + "fresh_reopens_completed": 10, + "warm_reboot_requests": 1, + "core_errors": 0, + "settings_preservation": { + "saved_getters_checked": 15, + "saved_getters_originally_unavailable": [ + "get telemetry.mode.base", + "get telemetry.mode.loc", + "get telemetry.mode.env", + "get max.neighbours", + "get rx.delay", + "get tx.delay" + ], + "acl_comparison_available": true, + "acl_entries_preserved": 2, + "trusted_signer_list_preserved": true + }, + "identity_preserved": true, + "candidate_endf_verified": true, + "bootloader_version_unchanged": true, + "rtc_current": true, + "max_completed_command_seconds": 4.0798, + "usb_objects_closed": true, + "automatic_sleep_proofs": {}, + "collector_originally_active": null, + "collector_unit_config_equal": null, + "collector_serial_owner_restored": null, + "installed_artifact_target": "solarxiao_33S_repeater-full-ota" + }, + { + "board": "RAK3401", + "source_commit": "70c7d5b478b19f16d232389779ca455e73be7eba", + "application_sha256": "13ddc99a9fea2650880303e01ad658e0974129ca145ad0d11d2efceb288de045", + "qualification": "PASS: app-only USB update and readiness", + "installed_candidate_left": true, + "persistent_queries_completed": 100, + "fresh_reopens_completed": 10, + "warm_reboot_requests": 1, + "core_errors": 0, + "settings_preservation": { + "saved_getters_checked": 14, + "saved_getters_originally_unavailable": [ + "get usb.logging.always", + "get telemetry.mode.base", + "get telemetry.mode.loc", + "get telemetry.mode.env", + "get max.neighbours", + "get rx.delay", + "get tx.delay" + ], + "acl_comparison_available": true, + "acl_entries_preserved": 1, + "trusted_signer_list_preserved": true + }, + "identity_preserved": true, + "candidate_endf_verified": true, + "bootloader_version_unchanged": true, + "rtc_current": true, + "max_completed_command_seconds": 0.9998, + "usb_objects_closed": true, + "automatic_sleep_proofs": {}, + "collector_originally_active": true, + "collector_unit_config_equal": true, + "collector_serial_owner_restored": true, + "installed_artifact_target": "RAK_3401_repeater_lora_ota_no_external_sensors-full-ota" + }, + { + "board": "Seeed XIAO nRF52840", + "source_commit": "70c7d5b478b19f16d232389779ca455e73be7eba", + "application_sha256": "48c81181a776519da471557c3fcb330f4427254c2adebd9752bca2dc3c28bc50", + "qualification": "PASS: app-only USB update and readiness", + "installed_candidate_left": true, + "persistent_queries_completed": 100, + "fresh_reopens_completed": 10, + "warm_reboot_requests": 1, + "core_errors": 0, + "settings_preservation": { + "saved_getters_checked": 15, + "saved_getters_originally_unavailable": [ + "get telemetry.mode.base", + "get telemetry.mode.loc", + "get telemetry.mode.env", + "get max.neighbours", + "get rx.delay", + "get tx.delay" + ], + "acl_comparison_available": true, + "acl_entries_preserved": 1, + "trusted_signer_list_preserved": true + }, + "identity_preserved": true, + "candidate_endf_verified": true, + "bootloader_version_unchanged": true, + "rtc_current": true, + "max_completed_command_seconds": 3.9601, + "usb_objects_closed": true, + "automatic_sleep_proofs": {}, + "collector_originally_active": null, + "collector_unit_config_equal": null, + "collector_serial_owner_restored": null, + "installed_artifact_target": "Xiao_nrf52_repeater-full-ota" + }, + { + "board": "RAK4631", + "source_commit": "70c7d5b478b19f16d232389779ca455e73be7eba", + "qualification": { + "persistent_queries_completed": 100, + "reopens_completed": 10, + "warm_reboot_requests": 1, + "uptime_before_warm_reboot": 10, + "uptime_after_warm_reboot": 3, + "source_bound_first_attempt_measurements_reused": true, + "new_queries_or_reboots_substituted": false, + "settings_preservation": { + "saved_getters_checked": 7, + "saved_getters_originally_unavailable": [ + "get password", + "get guest", + "get radio2", + "get radio2.cross", + "get radio.rxps.config", + "get powersaving", + "get usb.logging", + "get usb.logging.always", + "get telemetry.mode.base", + "get telemetry.mode.loc", + "get telemetry.mode.env", + "get max.neighbours", + "get rx.delay", + "get tx.delay" + ], + "acl_comparison_available": false, + "acl_entries_preserved": null, + "trusted_signer_list_preserved": true + }, + "password_and_acl_originally_unavailable": true, + "password_or_acl_preserved_claimed": false, + "clock_within_90_seconds": true, + "final_core_stats": { + "battery_mv": 4223, + "uptime_secs": 282, + "errors": 0, + "queue_len": 0 + }, + "usb_completed_commands_measured": 250, + "usb_max_completed_command_seconds": 1.0997, + "final_usb_session_closed": true + }, + "started_utc": "2026-10-28T16:33:51.523830+00:00", + "finished_utc": "2026-10-28T16:33:53.532673+00:00", + "initial_failed_comparison_retained": true, + "original_summary_sha256": "e67d82c08327298732048ba3d2f276a4056bfb07e3729a1a485d5f89460bfb83", + "legacy_unknown_classifier_only": true, + "user_test_radio_no_backup_prerequisite_override": true, + "original_rollback_image_available": false, + "bootloader_numeric_version_before": "0x02040CFF", + "bootloader_version_after": "OTAFIX2.4.12", + "bootloader_crc_equal_claimed": false, + "bootloader_version_format_changed": true, + "clock_synchronized_forward": true, + "application_sha256": "915ced25f967ec302eacd218fdc10ee776eca579e71f61bdb89a3bc705f189e6", + "installed_candidate_left": true, + "identity_preserved": true, + "candidate_endf_verified": true, + "installed_artifact_target": "RAK_4631_repeater_lora_ota_no_external_sensors-full-ota" + } + ], + "esp_repeaters": [ + { + "board": "Heltec V3", + "source_commit": "c9acec474bb9617d4f92dbdc0401765701989288", + "candidate_sha256": "d983f8d27adb4a7d5fa9e9bd4ea6fc39925e7a54f75ae4a90f8dbb5ea68423ee", + "expected_runtime_version": "v1.18.0.2-halo-keymind-cascade-dev-observer-c9acec47", + "candidate_version": "v1.18.0.2-halo-keymind-cascade-dev-observer-c9acec47", + "flash_bytes": 8388608, + "backup_complete": true, + "backup_sha256": "872f321c5be4abd78f5d05d1a15fa8afc0dd1bd8c68a07cf59ae1ae044a7f13b", + "identity_probe_passed": true, + "bootloader_and_partition_preserved": true, + "usb_passed": true, + "usb_query_cycles": 100, + "usb_reopens": 10, + "usb_opens": 11, + "usb_core_errors_max": 0, + "packet_receive_error_delta": 0, + "warm_passed": true, + "warm_requests_sent": 1, + "warm_lower_uptime_proved": true, + "warm_wait_rejections": 0, + "candidate_core_errors": 0, + "saved_getters_preserved": true, + "identity_preserved": true, + "candidate_usb_qualified": true, + "legacy_radio2_off_annotation": true, + "final_state": "candidate installed with original identity and queried saved settings", + "controlled_retests": null, + "prewrite_guard_refusals": null, + "candidate_failure": null, + "restoration_failure": null, + "attempts": [ + { + "stage": "backup", + "started_utc": "2026-10-28T12:56:04.312630+00:00", + "finished_utc": "2026-10-28T13:12:26.051140+00:00", + "passed": true, + "error_type": null, + "reason": null, + "worker_sha256": "aa859e891d5b9aea1a2d2768f7d68fced3422ab48d76c69adf5d3ea5e2ed939d" + }, + { + "stage": "install", + "started_utc": "2026-10-28T13:19:49.092296+00:00", + "finished_utc": "2026-10-28T13:29:43.606637+00:00", + "passed": true, + "error_type": null, + "reason": null, + "worker_sha256": "75742aac4b751d8af430d91ed1d9e3ec4494cf908aa2590b33fce680808354ce" + } + ], + "status_sha256": "3b533ad5bbb59f689ef15833845039e0e89632ca91cb1cf0f3d42659fe33adcd", + "worker_alive": false, + "serial_unowned": true, + "worker_lock_free": true, + "installed_artifact_target": "Heltec_v3_repeater_observer_mqtt" + }, + { + "board": "LilyGo T-Deck", + "source_commit": "c9acec474bb9617d4f92dbdc0401765701989288", + "candidate_sha256": "83a73ddd7add2a98fef58b2a7c1335157e7ee4633d959923d78f876ec2f6b9d2", + "expected_runtime_version": "v1.18.0.2-halo-keymind-cascade-dev-c9acec47", + "candidate_version": "v1.18.0.2-halo-keymind-cascade-dev-c9acec47", + "flash_bytes": 16777216, + "backup_complete": true, + "backup_sha256": "00cc847d6c1929ae9206f5473d74df6e52136fc2b67b7a0eea0c082502a72ec7", + "identity_probe_passed": true, + "bootloader_and_partition_preserved": true, + "usb_passed": true, + "usb_query_cycles": 100, + "usb_reopens": 10, + "usb_opens": 11, + "usb_core_errors_max": 0, + "packet_receive_error_delta": 0, + "warm_passed": true, + "warm_requests_sent": 1, + "warm_lower_uptime_proved": true, + "warm_wait_rejections": 0, + "candidate_core_errors": 0, + "saved_getters_preserved": true, + "identity_preserved": true, + "candidate_usb_qualified": true, + "legacy_radio2_off_annotation": true, + "final_state": "candidate installed with original identity and queried saved settings", + "controlled_retests": 1, + "prewrite_guard_refusals": 1, + "candidate_failure": null, + "restoration_failure": null, + "attempts": [ + { + "stage": "backup", + "started_utc": "2026-10-28T12:56:04.571400+00:00", + "finished_utc": "2026-10-28T13:06:00.841849+00:00", + "passed": true, + "error_type": null, + "reason": null, + "worker_sha256": "aa859e891d5b9aea1a2d2768f7d68fced3422ab48d76c69adf5d3ea5e2ed939d" + }, + { + "stage": "install", + "started_utc": "2026-10-28T13:07:51.906423+00:00", + "finished_utc": "2026-10-28T13:18:36.820139+00:00", + "passed": false, + "error_type": "RuntimeError", + "reason": "Original saved getter differs: get radio2", + "worker_sha256": "c61a053b0c3a83ace4f8e72150d89221045466b3941aa538e68fa612c67688fb" + }, + { + "stage": "install", + "started_utc": "2026-10-28T13:21:26.531161+00:00", + "finished_utc": "2026-10-28T13:21:35.104344+00:00", + "passed": false, + "error_type": "RuntimeError", + "reason": "Stage capture exists; review old attempt before retry", + "worker_sha256": "75742aac4b751d8af430d91ed1d9e3ec4494cf908aa2590b33fce680808354ce" + }, + { + "stage": "install", + "started_utc": "2026-10-28T13:24:32.447584+00:00", + "finished_utc": "2026-10-28T13:29:13.875374+00:00", + "passed": true, + "error_type": null, + "reason": null, + "worker_sha256": "4319129d542c4fd6ed50fb1b8f491a94e336e1f2fa7db388a1ed0a529057376e" + } + ], + "status_sha256": "fb0e528b033b4a680445354ae4047c535aa6a0b168f6fc0c3c0fbcfa6ea4aa7b", + "worker_alive": false, + "serial_unowned": true, + "worker_lock_free": true, + "installed_artifact_target": "LilyGo_TDeck_repeater" + }, + { + "board": "SenseCAP Indicator", + "source_commit": "c9acec474bb9617d4f92dbdc0401765701989288", + "candidate_sha256": "78c494a116f4a233a9d8c72be2a80fbf0bf71506028723cec18b358dac1fd2ab", + "expected_runtime_version": "v1.18.0.2-halo-keymind-cascade-dev-c9acec47", + "candidate_version": "v1.18.0.2-halo-keymind-cascade-dev-c9acec47", + "flash_bytes": 8388608, + "backup_complete": true, + "backup_sha256": "94043073e5a37ea6f84798fb2bea2592ca55793ccf432cce9c24c339a1b41e18", + "identity_probe_passed": true, + "bootloader_and_partition_preserved": true, + "usb_passed": true, + "usb_query_cycles": 100, + "usb_reopens": 10, + "usb_opens": 11, + "usb_core_errors_max": 0, + "packet_receive_error_delta": 0, + "warm_passed": true, + "warm_requests_sent": 1, + "warm_lower_uptime_proved": true, + "warm_wait_rejections": 3, + "candidate_core_errors": 0, + "saved_getters_preserved": true, + "identity_preserved": true, + "candidate_usb_qualified": true, + "legacy_radio2_off_annotation": true, + "final_state": "candidate installed with original identity and queried saved settings", + "controlled_retests": 1, + "prewrite_guard_refusals": null, + "candidate_failure": null, + "restoration_failure": null, + "application_readiness": { + "started_utc": "2026-10-28T14:04:18.135667+00:00", + "time_origin": "after guarded bootloader-readback runtime handoff", + "budget_seconds": 180, + "passed": true, + "observations": [ + { + "index": 1, + "started_elapsed_seconds": 0.0029, + "error_type": "RuntimeError", + "reason": "Exact echoed query did not complete" + }, + { + "index": 2, + "started_elapsed_seconds": 8.1667, + "error_type": "RuntimeError", + "reason": "Exact echoed query did not complete" + }, + { + "index": 3, + "started_elapsed_seconds": 16.3587, + "error_type": "RuntimeError", + "reason": "Exact echoed query did not complete" + }, + { + "index": 4, + "started_elapsed_seconds": 24.5301, + "valid_full_version_elapsed_seconds": 29.3055, + "version": "v1.18.0.2-halo-keymind-cascade-dev-c9acec47 (Build: 28-Oct-2026)", + "bound_identity_role_and_version_verified": true + } + ], + "valid_full_version_elapsed_seconds": 29.3055 + }, + "controlled_indicator_readiness_trial": true, + "restoration_pinned_data_validator_passed": true, + "prior_candidate_readiness_refusals": [ + { + "failure": { + "at_utc": "2026-10-28T13:28:03.365737+00:00", + "error_type": "RuntimeError", + "reason": "Exact echoed query did not complete" + }, + "original_application_and_data_restored": true, + "final_read_only_completion_verified": true, + "firmware_defect_established": false + } + ], + "attempts": [ + { + "stage": "backup", + "started_utc": "2026-10-28T12:56:04.651215+00:00", + "finished_utc": "2026-10-28T13:12:31.010660+00:00", + "passed": true, + "error_type": null, + "reason": null, + "worker_sha256": "aa859e891d5b9aea1a2d2768f7d68fced3422ab48d76c69adf5d3ea5e2ed939d" + }, + { + "stage": "install", + "started_utc": "2026-10-28T13:19:49.092307+00:00", + "finished_utc": "2026-10-28T13:45:16.140166+00:00", + "passed": false, + "error_type": "RuntimeError", + "reason": "Exact echoed query did not complete", + "worker_sha256": "75742aac4b751d8af430d91ed1d9e3ec4494cf908aa2590b33fce680808354ce" + }, + { + "stage": "read-only restore verification completion", + "started_utc": "2026-10-28T13:49:18.540122+00:00", + "finished_utc": "2026-10-28T13:53:35.144233+00:00", + "passed": true, + "error_type": null, + "reason": null, + "worker_sha256": "ae31c39f0b80742d367a43b4e0c0f382735660a0d68cdccf9a3bef86c85559e6" + }, + { + "stage": "install", + "started_utc": "2026-10-28T13:57:00.436306+00:00", + "finished_utc": "2026-10-28T14:06:05.542609+00:00", + "passed": true, + "error_type": null, + "reason": null, + "worker_sha256": "c16e5794413c39f817ad350442ff6446eca63d65617f917e918d7c36613b36fd" + } + ], + "status_sha256": "e80f2d5ad6601f618a125696b53755ec89d564f264b8238f95384c1d4ce416e7", + "worker_alive": false, + "serial_unowned": true, + "worker_lock_free": true, + "installed_artifact_target": "SenseCapIndicator-LoRa_repeater" + }, + { + "board": "Heltec V4.2", + "source_commit": "c9acec474bb9617d4f92dbdc0401765701989288", + "candidate_sha256": "ba151fab2c6e6eca6b3025bb2df394fd250372faee0b4c5966291c72ebc3ab29", + "expected_runtime_version": "v1.18.0.2-halo-keymind-cascade-dev-observer-c9acec47", + "candidate_version": "v1.18.0.2-halo-keymind-cascade-dev-observer-c9acec47", + "flash_bytes": 16777216, + "backup_complete": true, + "backup_sha256": "00e32d55ce768d73915f2be329b3a59feadae1fe8a5dc5e2f9a3fbbf126e8914", + "identity_probe_passed": true, + "bootloader_and_partition_preserved": true, + "usb_passed": true, + "usb_query_cycles": 100, + "usb_reopens": 10, + "usb_opens": 11, + "usb_core_errors_max": 0, + "packet_receive_error_delta": 0, + "warm_passed": true, + "warm_requests_sent": 1, + "warm_lower_uptime_proved": true, + "warm_wait_rejections": 1, + "candidate_core_errors": 0, + "saved_getters_preserved": true, + "identity_preserved": true, + "candidate_usb_qualified": true, + "legacy_radio2_off_annotation": null, + "final_state": "candidate installed with original identity and queried saved settings", + "controlled_retests": 1, + "prewrite_guard_refusals": 1, + "candidate_failure": null, + "restoration_failure": null, + "attempts": [ + { + "stage": "backup", + "started_utc": "2026-10-28T12:56:04.409835+00:00", + "finished_utc": "2026-10-28T13:05:59.306600+00:00", + "passed": true, + "error_type": null, + "reason": null, + "worker_sha256": "aa859e891d5b9aea1a2d2768f7d68fced3422ab48d76c69adf5d3ea5e2ed939d" + }, + { + "stage": "install", + "started_utc": "2026-10-28T13:07:52.006241+00:00", + "finished_utc": "2026-10-28T13:19:19.071074+00:00", + "passed": false, + "error_type": "RuntimeError", + "reason": "Installed version does not match exact candidate", + "worker_sha256": "c61a053b0c3a83ace4f8e72150d89221045466b3941aa538e68fa612c67688fb" + }, + { + "stage": "install", + "started_utc": "2026-10-28T13:21:26.531161+00:00", + "finished_utc": "2026-10-28T13:21:35.075911+00:00", + "passed": false, + "error_type": "RuntimeError", + "reason": "Stage capture exists; review old attempt before retry", + "worker_sha256": "75742aac4b751d8af430d91ed1d9e3ec4494cf908aa2590b33fce680808354ce" + }, + { + "stage": "install", + "started_utc": "2026-10-28T13:24:32.385661+00:00", + "finished_utc": "2026-10-28T13:29:38.378669+00:00", + "passed": true, + "error_type": null, + "reason": null, + "worker_sha256": "4319129d542c4fd6ed50fb1b8f491a94e336e1f2fa7db388a1ed0a529057376e" + } + ], + "status_sha256": "77f4ee51ab3222cf518c8356813f13630414ca250d3575c4846226a33a525759", + "worker_alive": false, + "serial_unowned": true, + "worker_lock_free": true, + "installed_artifact_target": "heltec_v4_repeater_observer_mqtt" + } + ], + "infrastructure_rf": { + "schema": 1, + "updated_utc": "2026-10-28T17:14:37.488055+00:00", + "scope": "Eight installed Full Repeater candidates; finite board-only RF measurements", + "private_identifiers_excluded": true, + "boards": [ + { + "board": "Heltec MeshTower V2 SD", + "role": "Full Repeater", + "installed_source_commit": "70c7d5b478b19f16d232389779ca455e73be7eba", + "installed_full_version": "v1.18.0.2-halo-keymind-cascade-dev-70c7d5b4", + "artifact_sha256": "3aabda910a3b582c6c339bdc09612ccc9b1caa9ef5d9275439929a8acd8acfe9", + "artifact_kind": "application-only DFU ZIP", + "usb_qualified": true, + "rf_trials": [ + { + "attempt": "rf-tag-tower-run1", + "harness_passed": false, + "measurement_started_utc": "2026-10-28T14:16:20.934978+00:00", + "measurement_finished_utc": "2026-10-28T14:17:04.204730+00:00", + "source_summary_sha256": "9f62d0115b709ac5da2554881fbac9bfae4c9a3fe50361d4c3ea6afef9d8a849", + "requests_attempted": 3, + "exact_responses_completed": 2, + "rf_retries": 0, + "offline_queue_reads": 0, + "channel_setters": 0, + "ota_workspace_acquired": false, + "cleanup": { + "tower": { + "saved_settings_equal": true, + "primary_ram_lease_cleared": true, + "owned_emergency_hold_revoked": true, + "offline_queue_reads": 0 + }, + "tag": { + "saved_getters_equal": true, + "channel_names_and_keys_equal": true, + "contact_static_records_equal": true, + "contact_live_timestamp_changes": 0, + "binary_preferences_equal": true, + "offline_queue_reads": 0 + } + }, + "failure_type": "TimeoutError", + "rounds": [ + { + "round": 1, + "passed": true, + "elapsed_seconds": 1.547, + "full_target_identity_and_tag_verified": true, + "radio2_profile": { + "flags": 0 + }, + "radio2_extension_matches_cli_profile": true, + "request_snr_db": 11.75, + "response_snr_db": -2.0, + "hub_packet_delta": { + "received": 3, + "sent": 1, + "sent_flood": 0, + "sent_direct": 1, + "received_flood": 2, + "received_direct": 1, + "receive_errors": 0 + }, + "target_packet_delta": { + "recv": 1, + "sent": 3, + "flood_tx": 2, + "direct_tx": 1, + "flood_rx": 0, + "direct_rx": 1, + "recv_errors": 0 + }, + "offline_queue_reads": 0 + }, + { + "round": 2, + "passed": true, + "elapsed_seconds": 2.724, + "full_target_identity_and_tag_verified": true, + "radio2_profile": { + "flags": 0 + }, + "radio2_extension_matches_cli_profile": true, + "request_snr_db": 12.5, + "response_snr_db": -2.25, + "hub_packet_delta": { + "received": 1, + "sent": 1, + "sent_flood": 0, + "sent_direct": 1, + "received_flood": 0, + "received_direct": 1, + "receive_errors": 0 + }, + "target_packet_delta": { + "recv": 1, + "sent": 1, + "flood_tx": 0, + "direct_tx": 1, + "flood_rx": 0, + "direct_rx": 1, + "recv_errors": 0 + }, + "offline_queue_reads": 0 + } + ], + "measured_core_stats": { + "initial": { + "battery_mv": 2884, + "uptime_secs": 2473, + "errors": 0, + "queue_len": 0 + }, + "final": { + "battery_mv": 2876, + "uptime_secs": 2506, + "errors": 0, + "queue_len": 0 + } + }, + "peer_board": "RAK WisMesh Tag", + "peer_source_commit": "c9acec474bb9617d4f92dbdc0401765701989288", + "peer_expected_version": "v1.18.0.2-halo-keymind-cascade-dev-c9acec47" + }, + { + "attempt": "rf-tag-tower-run2-paced", + "harness_passed": false, + "measurement_started_utc": "2026-10-28T14:32:11.590246+00:00", + "measurement_finished_utc": "2026-10-28T14:32:20.893285+00:00", + "source_summary_sha256": "af4c9290c94ef9c03d12369a134b5d2962afbf126b8ad105030d1cbb99be3133", + "requests_attempted": 0, + "exact_responses_completed": 0, + "rf_retries": 0, + "offline_queue_reads": 0, + "channel_setters": 0, + "ota_workspace_acquired": false, + "cleanup": { + "tag": { + "saved_getters_equal": true, + "channel_names_and_keys_equal": true, + "contact_static_records_equal": true, + "contact_live_timestamp_changes": 0, + "binary_preferences_equal": true, + "offline_queue_reads": 0 + } + }, + "failure_type": "TypeError", + "rounds": [], + "changed_condition": { + "schema": 1, + "changed_condition": true, + "automatic_retries": 0, + "discovery_requests_maximum": 3, + "initial_and_between_requests_stable_tx_observations": [], + "hidden_eligibility_temporarily_enabled": false, + "advert_intervals_restored": false, + "followup_entrypoint_sha256": "c9af8d8a2abadf9e3dc08f309c9e652f97acb344bf00451b865955e92c53702f", + "source_summary_sha256": "749d4d98c036f5f03fc794a96959bac3c92035f8e383a237510cdd08f2af96ba" + }, + "measured_core_stats": {}, + "peer_board": "RAK WisMesh Tag", + "peer_source_commit": "c9acec474bb9617d4f92dbdc0401765701989288", + "peer_expected_version": "v1.18.0.2-halo-keymind-cascade-dev-c9acec47" + }, + { + "attempt": "rf-tag-tower-run3-paced", + "harness_passed": false, + "measurement_started_utc": "2026-10-28T14:33:31.635298+00:00", + "measurement_finished_utc": "2026-10-28T14:34:22.966328+00:00", + "source_summary_sha256": "0dae6ab7a068106d28d8c729bdacde15e7968af57d2733bf1cfeb5122e14ed19", + "requests_attempted": 2, + "exact_responses_completed": 1, + "rf_retries": 0, + "offline_queue_reads": 0, + "channel_setters": 0, + "ota_workspace_acquired": false, + "cleanup": { + "tower": { + "saved_settings_equal": true, + "primary_ram_lease_cleared": true, + "owned_emergency_hold_revoked": true, + "offline_queue_reads": 0 + }, + "tag": { + "saved_getters_equal": true, + "channel_names_and_keys_equal": true, + "contact_static_records_equal": true, + "contact_live_timestamp_changes": 0, + "binary_preferences_equal": true, + "offline_queue_reads": 0 + } + }, + "failure_type": "TimeoutError", + "rounds": [ + { + "round": 1, + "passed": true, + "elapsed_seconds": 0.822, + "full_target_identity_and_tag_verified": true, + "radio2_profile": { + "flags": 0 + }, + "radio2_extension_matches_cli_profile": true, + "request_snr_db": 11.5, + "response_snr_db": -3.75, + "hub_packet_delta": { + "received": 1, + "sent": 1, + "sent_flood": 0, + "sent_direct": 1, + "received_flood": 0, + "received_direct": 1, + "receive_errors": 0 + }, + "target_packet_delta": { + "recv": 1, + "sent": 1, + "flood_tx": 0, + "direct_tx": 1, + "flood_rx": 0, + "direct_rx": 1, + "recv_errors": 0 + }, + "offline_queue_reads": 0 + } + ], + "changed_condition": { + "schema": 1, + "changed_condition": true, + "automatic_retries": 0, + "discovery_requests_maximum": 3, + "initial_and_between_requests_stable_tx_observations": [ + { + "round": 1, + "elapsed_seconds": 6.066, + "observations": [ + { + "queue_len": 0, + "sent": 7 + }, + { + "queue_len": 0, + "sent": 7 + }, + { + "queue_len": 0, + "sent": 7 + }, + { + "queue_len": 0, + "sent": 7 + } + ] + }, + { + "round": 2, + "elapsed_seconds": 6.063, + "observations": [ + { + "queue_len": 0, + "sent": 8 + }, + { + "queue_len": 0, + "sent": 8 + }, + { + "queue_len": 0, + "sent": 8 + }, + { + "queue_len": 0, + "sent": 8 + } + ] + } + ], + "hidden_eligibility_temporarily_enabled": false, + "advert_intervals_restored": true, + "original_local_advert_minutes": 0, + "original_flood_advert_hours": 83, + "original_hidden": false, + "followup_entrypoint_sha256": "a80312256a8c92749465af6e68c6cf53f41aebc04c3bbee5686157ffea89bdbe", + "source_summary_sha256": "b88b4b54f9d8216e251eaf18a703608c0d581b73e67d4ee4b826cffdc8994bab" + }, + "measured_core_stats": { + "initial": { + "battery_mv": 2905, + "uptime_secs": 3504, + "errors": 0, + "queue_len": 0 + }, + "final": { + "battery_mv": 2866, + "uptime_secs": 3545, + "errors": 0, + "queue_len": 0 + } + }, + "peer_board": "RAK WisMesh Tag", + "peer_source_commit": "c9acec474bb9617d4f92dbdc0401765701989288", + "peer_expected_version": "v1.18.0.2-halo-keymind-cascade-dev-c9acec47" + }, + { + "attempt": "rf-tag-tower-run4-rxps-off", + "harness_passed": false, + "measurement_started_utc": "2026-10-28T15:04:57.278891+00:00", + "measurement_finished_utc": "2026-10-28T15:05:25.816052+00:00", + "source_summary_sha256": "2c37d0ad78142531cf8da1941e75e29e2d7d60d459d8478371de0ec60bc0e6b1", + "requests_attempted": 0, + "exact_responses_completed": 0, + "rf_retries": 0, + "offline_queue_reads": 0, + "channel_setters": 0, + "ota_workspace_acquired": false, + "cleanup": { + "tower": { + "saved_settings_equal": true, + "primary_ram_lease_cleared": true, + "owned_emergency_hold_revoked": true, + "offline_queue_reads": 0 + }, + "tag": { + "saved_getters_equal": true, + "channel_names_and_keys_equal": true, + "contact_static_records_equal": true, + "contact_live_timestamp_changes": 0, + "binary_preferences_equal": true, + "offline_queue_reads": 0 + } + }, + "failure_type": "RuntimeError", + "rounds": [], + "changed_condition": { + "schema": 1, + "changed_condition": true, + "automatic_retries": 0, + "discovery_requests_maximum": 3, + "initial_and_between_requests_stable_tx_observations": [], + "hidden_eligibility_temporarily_enabled": false, + "advert_intervals_restored": true, + "original_local_advert_minutes": 0, + "original_flood_advert_hours": 83, + "original_hidden": false, + "original_rxps_configuration_restored_exactly": true, + "followup_entrypoint_sha256": "01a463c29a8ca74be8904f532c7a57a46241235f0c39e7ec47c099059ca5ba78", + "source_summary_sha256": "c3d7c1c03801079dedb4d4b57ee9c694a2298ce5f79724c3543440d15b5c9567" + }, + "measured_core_stats": { + "initial": { + "battery_mv": 2876, + "uptime_secs": 5389, + "errors": 0, + "queue_len": 0 + }, + "final": { + "battery_mv": 2855, + "uptime_secs": 5408, + "errors": 0, + "queue_len": 0 + } + }, + "peer_board": "RAK WisMesh Tag", + "peer_source_commit": "c9acec474bb9617d4f92dbdc0401765701989288", + "peer_expected_version": "v1.18.0.2-halo-keymind-cascade-dev-c9acec47" + }, + { + "attempt": "rf-tag-tower-run5-rxps-off", + "harness_passed": false, + "measurement_started_utc": "2026-10-28T15:07:07.086893+00:00", + "measurement_finished_utc": "2026-10-28T15:07:56.482606+00:00", + "source_summary_sha256": "c871ecd7596ccc176d977bb4591ceaa8c0d6fa67e5df7286885cb01e1a1bfe0a", + "requests_attempted": 2, + "exact_responses_completed": 1, + "rf_retries": 0, + "offline_queue_reads": 0, + "channel_setters": 0, + "ota_workspace_acquired": false, + "cleanup": { + "tower": { + "saved_settings_equal": true, + "primary_ram_lease_cleared": true, + "owned_emergency_hold_revoked": true, + "offline_queue_reads": 0 + }, + "tag": { + "saved_getters_equal": true, + "channel_names_and_keys_equal": true, + "contact_static_records_equal": true, + "contact_live_timestamp_changes": 0, + "binary_preferences_equal": true, + "offline_queue_reads": 0 + } + }, + "failure_type": "TimeoutError", + "rounds": [ + { + "round": 1, + "passed": true, + "elapsed_seconds": 0.554, + "full_target_identity_and_tag_verified": true, + "radio2_profile": { + "flags": 0 + }, + "radio2_extension_matches_cli_profile": true, + "request_snr_db": -7.75, + "response_snr_db": -2.25, + "hub_packet_delta": { + "received": 1, + "sent": 1, + "sent_flood": 0, + "sent_direct": 1, + "received_flood": 0, + "received_direct": 1, + "receive_errors": 0 + }, + "target_packet_delta": { + "recv": 1, + "sent": 1, + "flood_tx": 0, + "direct_tx": 1, + "flood_rx": 0, + "direct_rx": 1, + "recv_errors": 0 + }, + "offline_queue_reads": 0 + } + ], + "changed_condition": { + "schema": 1, + "changed_condition": true, + "automatic_retries": 0, + "discovery_requests_maximum": 3, + "initial_and_between_requests_stable_tx_observations": [ + { + "round": 1, + "elapsed_seconds": 6.061, + "observations": [ + { + "queue_len": 0, + "sent": 11 + }, + { + "queue_len": 0, + "sent": 11 + }, + { + "queue_len": 0, + "sent": 11 + }, + { + "queue_len": 0, + "sent": 11 + } + ] + }, + { + "round": 2, + "elapsed_seconds": 6.061, + "observations": [ + { + "queue_len": 0, + "sent": 12 + }, + { + "queue_len": 0, + "sent": 12 + }, + { + "queue_len": 0, + "sent": 12 + }, + { + "queue_len": 0, + "sent": 12 + } + ] + } + ], + "hidden_eligibility_temporarily_enabled": false, + "advert_intervals_restored": true, + "original_local_advert_minutes": 0, + "original_flood_advert_hours": 83, + "original_hidden": false, + "continued_after_zero_rf_full_tuple_readback_guard_refusal": true, + "prior_failed_summary_sha256": "2c37d0ad78142531cf8da1941e75e29e2d7d60d459d8478371de0ec60bc0e6b1", + "continuous_rx_requested_and_readback_verified": true, + "original_rxps_configuration_restored_exactly": true, + "followup_entrypoint_sha256": "97ffcee3a733e03f4f82d60c627bcd9ba5ae0c9ce76fb4a4ed55f73f600d316a", + "source_summary_sha256": "e9a92d9a0fa6448ae54b7719ded2892341d766576f0ee77ed6da8d8993c7961b" + }, + "measured_core_stats": { + "initial": { + "battery_mv": 2869, + "uptime_secs": 5519, + "errors": 0, + "queue_len": 0 + }, + "final": { + "battery_mv": 2862, + "uptime_secs": 5558, + "errors": 0, + "queue_len": 0 + } + }, + "peer_board": "RAK WisMesh Tag", + "peer_source_commit": "c9acec474bb9617d4f92dbdc0401765701989288", + "peer_expected_version": "v1.18.0.2-halo-keymind-cascade-dev-c9acec47" + } + ], + "warm_preparations": [], + "emergency_trials": [], + "retained_notes": [ + "Baseline2of3 replies; paced quiet-queue follow-up1of2 replies; continuous-RX conditional diagnostic1of2 replies. All missing responses retained, no further RF retry. RXPS-off preflight full-tuple formatter refusal sent0requests and restored exact settings before its measured continuation. Original auto emergency at actual0% restored with hold0." + ] + }, + { + "board": "Seeed XIAO nRF52840", + "role": "Full Repeater", + "installed_source_commit": "70c7d5b478b19f16d232389779ca455e73be7eba", + "installed_full_version": "v1.18.0.2-halo-keymind-cascade-dev-70c7d5b4", + "artifact_sha256": "48c81181a776519da471557c3fcb330f4427254c2adebd9752bca2dc3c28bc50", + "artifact_kind": "application-only DFU ZIP", + "usb_qualified": true, + "rf_trials": [ + { + "attempt": "rf-tag-xiao_nrf-run1", + "harness_passed": false, + "measurement_started_utc": "2026-10-28T14:18:53.496778+00:00", + "measurement_finished_utc": "2026-10-28T14:19:17.459379+00:00", + "source_summary_sha256": "1de4079e52b283690ad790be0cc4049e63742f00226c916232fed2398165da00", + "requests_attempted": 3, + "exact_responses_completed": 3, + "rf_retries": 0, + "offline_queue_reads": 0, + "channel_setters": 0, + "ota_workspace_acquired": false, + "cleanup": { + "xiao_nrf": { + "passed": false, + "failure_type": "RuntimeError" + }, + "tag": { + "saved_getters_equal": true, + "channel_names_and_keys_equal": true, + "contact_static_records_equal": true, + "contact_live_timestamp_changes": 0, + "binary_preferences_equal": true, + "offline_queue_reads": 0 + } + }, + "failure_type": "RuntimeError", + "rounds": [ + { + "round": 1, + "passed": true, + "elapsed_seconds": 1.477, + "full_target_identity_and_tag_verified": true, + "radio2_profile": { + "flags": 0 + }, + "radio2_extension_matches_cli_profile": true, + "request_snr_db": 12.25, + "response_snr_db": 11.75, + "hub_packet_delta": { + "received": 2, + "sent": 1, + "sent_flood": 0, + "sent_direct": 1, + "received_flood": 0, + "received_direct": 2, + "receive_errors": 0 + }, + "target_packet_delta": { + "recv": 1, + "sent": 2, + "flood_tx": 0, + "direct_tx": 2, + "flood_rx": 0, + "direct_rx": 1, + "recv_errors": 0 + }, + "offline_queue_reads": 0 + }, + { + "round": 2, + "passed": true, + "elapsed_seconds": 1.549, + "full_target_identity_and_tag_verified": true, + "radio2_profile": { + "flags": 0 + }, + "radio2_extension_matches_cli_profile": true, + "request_snr_db": 11.75, + "response_snr_db": 11.75, + "hub_packet_delta": { + "received": 1, + "sent": 1, + "sent_flood": 0, + "sent_direct": 1, + "received_flood": 0, + "received_direct": 1, + "receive_errors": 0 + }, + "target_packet_delta": { + "recv": 1, + "sent": 1, + "flood_tx": 0, + "direct_tx": 1, + "flood_rx": 0, + "direct_rx": 1, + "recv_errors": 0 + }, + "offline_queue_reads": 0 + }, + { + "round": 3, + "passed": true, + "elapsed_seconds": 2.489, + "full_target_identity_and_tag_verified": true, + "radio2_profile": { + "flags": 0 + }, + "radio2_extension_matches_cli_profile": true, + "request_snr_db": 11.25, + "response_snr_db": 11.5, + "hub_packet_delta": { + "received": 1, + "sent": 1, + "sent_flood": 0, + "sent_direct": 1, + "received_flood": 0, + "received_direct": 1, + "receive_errors": 0 + }, + "target_packet_delta": { + "recv": 1, + "sent": 1, + "flood_tx": 0, + "direct_tx": 1, + "flood_rx": 0, + "direct_rx": 1, + "recv_errors": 0 + }, + "offline_queue_reads": 0 + } + ], + "comparison-followup": { + "passed": true, + "method": "offline corrected battery diagnostic comparison only", + "new_hardware_access": false, + "new_rf_send_commands": 0, + "original_failed_summary_preserved": true, + "original_summary_sha256": "1de4079e52b283690ad790be0cc4049e63742f00226c916232fed2398165da00", + "source_summary_sha256": "524e7de46d056324f205ffdc86d6d66bde8d724daff8fed5aa5f50e19c0178db" + }, + "cleanup-readonly-followup": { + "passed": true, + "method": "Fresh exact-identity/source-bound read-only cleanup follow-up", + "new_rf_send_commands": 0, + "setters_sent": 0, + "saved_getters_and_forwarding_gates_equal": true, + "primary_temporary_off": true, + "primary_schedule_empty": true, + "core_stats": { + "battery_mv": 4122, + "uptime_secs": 2616, + "errors": 0, + "queue_len": 0 + }, + "exact_identity_version_verified": true, + "source_summary_sha256": "0764757d92bdf09bcae1974fa5803714ead90d0ad713718eca7aef031b4fd72b" + }, + "measured_core_stats": { + "initial": { + "battery_mv": 3783, + "uptime_secs": 2283, + "errors": 0, + "queue_len": 0 + }, + "final": { + "battery_mv": 3761, + "uptime_secs": 2295, + "errors": 0, + "queue_len": 0 + } + }, + "peer_board": "RAK WisMesh Tag", + "peer_source_commit": "c9acec474bb9617d4f92dbdc0401765701989288", + "peer_expected_version": "v1.18.0.2-halo-keymind-cascade-dev-c9acec47" + } + ], + "warm_preparations": [], + "emergency_trials": [], + "retained_notes": [ + "All3physical discovery rounds passed. Initial cleanup comparison rejected only changing live battery ADC fields; narrow offline comparison and fresh read-only schedule/core check separately prove final restoration." + ] + }, + { + "board": "SolarXiao MPPT 33S", + "role": "Full Repeater", + "installed_source_commit": "70c7d5b478b19f16d232389779ca455e73be7eba", + "installed_full_version": "v1.18.0.2-halo-keymind-cascade-dev-70c7d5b4", + "artifact_sha256": "61543eb675c2ed3eb8d55d03cd78b5ce4806817178a020f62d7d209353085f50", + "artifact_kind": "application-only DFU ZIP", + "usb_qualified": true, + "rf_trials": [ + { + "attempt": "rf-tag-solarxiao33s-run1", + "harness_passed": true, + "measurement_started_utc": "2026-10-28T14:23:05.198135+00:00", + "measurement_finished_utc": "2026-10-28T14:23:28.522519+00:00", + "source_summary_sha256": "295cb54a3dbc865e99bd4d8cbbf05961bce4abc8623fe21a0483886b941392ad", + "requests_attempted": 3, + "exact_responses_completed": 3, + "rf_retries": 0, + "offline_queue_reads": 0, + "channel_setters": 0, + "ota_workspace_acquired": false, + "cleanup": { + "solarxiao33s": { + "saved_settings_equal": true, + "primary_ram_lease_cleared": true, + "owned_emergency_hold_revoked": false, + "offline_queue_reads": 0 + }, + "tag": { + "saved_getters_equal": true, + "channel_names_and_keys_equal": true, + "contact_static_records_equal": true, + "contact_live_timestamp_changes": 0, + "binary_preferences_equal": true, + "offline_queue_reads": 0 + } + }, + "failure_type": null, + "rounds": [ + { + "round": 1, + "passed": true, + "elapsed_seconds": 0.885, + "full_target_identity_and_tag_verified": true, + "radio2_profile": { + "flags": 0 + }, + "radio2_extension_matches_cli_profile": true, + "request_snr_db": 11.25, + "response_snr_db": 12.0, + "hub_packet_delta": { + "received": 2, + "sent": 1, + "sent_flood": 0, + "sent_direct": 1, + "received_flood": 0, + "received_direct": 2, + "receive_errors": 0 + }, + "target_packet_delta": { + "recv": 1, + "sent": 2, + "flood_tx": 0, + "direct_tx": 2, + "flood_rx": 0, + "direct_rx": 1, + "recv_errors": 0 + }, + "offline_queue_reads": 0 + }, + { + "round": 2, + "passed": true, + "elapsed_seconds": 1.46, + "full_target_identity_and_tag_verified": true, + "radio2_profile": { + "flags": 0 + }, + "radio2_extension_matches_cli_profile": true, + "request_snr_db": 12.0, + "response_snr_db": 12.25, + "hub_packet_delta": { + "received": 1, + "sent": 1, + "sent_flood": 0, + "sent_direct": 1, + "received_flood": 0, + "received_direct": 1, + "receive_errors": 0 + }, + "target_packet_delta": { + "recv": 1, + "sent": 1, + "flood_tx": 0, + "direct_tx": 1, + "flood_rx": 0, + "direct_rx": 1, + "recv_errors": 0 + }, + "offline_queue_reads": 0 + }, + { + "round": 3, + "passed": true, + "elapsed_seconds": 0.837, + "full_target_identity_and_tag_verified": true, + "radio2_profile": { + "flags": 0 + }, + "radio2_extension_matches_cli_profile": true, + "request_snr_db": 12.0, + "response_snr_db": 12.5, + "hub_packet_delta": { + "received": 1, + "sent": 1, + "sent_flood": 0, + "sent_direct": 1, + "received_flood": 0, + "received_direct": 1, + "receive_errors": 0 + }, + "target_packet_delta": { + "recv": 1, + "sent": 1, + "flood_tx": 0, + "direct_tx": 1, + "flood_rx": 0, + "direct_rx": 1, + "recv_errors": 0 + }, + "offline_queue_reads": 0 + } + ], + "measured_core_stats": { + "initial": { + "battery_mv": 3785, + "uptime_secs": 2742, + "errors": 0, + "queue_len": 0 + }, + "final": { + "battery_mv": 4141, + "uptime_secs": 2756, + "errors": 0, + "queue_len": 0 + } + }, + "peer_board": "RAK WisMesh Tag", + "peer_source_commit": "c9acec474bb9617d4f92dbdc0401765701989288", + "peer_expected_version": "v1.18.0.2-halo-keymind-cascade-dev-c9acec47" + } + ], + "warm_preparations": [ + { + "passed": false, + "scope": "single warm preparation, not RF qualification", + "reboot_requests": 0, + "rf_send_commands": 0, + "clear_stats_commands": 0, + "before_core": { + "battery_mv": 3772, + "uptime_secs": 4408, + "errors": 2, + "queue_len": 0 + }, + "failure_type": "RuntimeError", + "attempt": "collector-owned-rf-emergency-run2", + "source_summary_sha256": "be8c6b5f4d1586075666148d68c04d426d862468e7efdfe3654255f0c77ba668" + }, + { + "passed": true, + "scope": "single warm preparation, not RF qualification", + "reboot_requests": 1, + "rf_send_commands": 0, + "clear_stats_commands": 0, + "before_core": { + "battery_mv": 3785, + "uptime_secs": 4780, + "errors": 2, + "queue_len": 0 + }, + "after_core": { + "battery_mv": 3768, + "uptime_secs": 7, + "errors": 0, + "queue_len": 1 + }, + "preservation": { + "saved_getters_checked": 15, + "saved_getters_originally_unavailable": [ + "get telemetry.mode.base", + "get telemetry.mode.loc", + "get telemetry.mode.env", + "get max.neighbours", + "get rx.delay", + "get tx.delay" + ], + "acl_comparison_available": true, + "acl_entries_preserved": 2, + "trusted_signer_list_preserved": true + }, + "cad_recovery_admission": { + "live_rx_previously_proved": true, + "queue_and_sent_counter_idle": true, + "observations": [ + { + "queue_len": 0, + "sent": 514 + }, + { + "queue_len": 0, + "sent": 514 + }, + { + "queue_len": 0, + "sent": 514 + } + ], + "automatic_retries": 0 + }, + "attempt": "collector-owned-rf-emergency-run3", + "source_summary_sha256": "8c2852a5e545c332eefbb57a63f031dea7a994d3b2be6c2f504e023e433134e5" + } + ], + "emergency_trials": [ + { + "passed": true, + "source_commit": "70c7d5b478b19f16d232389779ca455e73be7eba", + "expected_version": "v1.18.0.2-halo-keymind-cascade-dev-70c7d5b4", + "driver_sha256": "d8da090510a74bb86be129562432b6a3c433247838f42b3ccf57de27c20786dc", + "reviewed_method_sha256": "2c4799cb239f9d092d5cf46c0366408aa95f766f786f56af4b913e07b636418f", + "cpu_condition": "native USB host enumerated; retained idle slices capped100ms", + "limitations": [ + "Manual emergency entry at actual reported battery; automatic5% threshold not exercised", + "USB-held CPU/RF scheduling only; current consumption and detached USB sleep not measured", + "Past RTC boundaries accelerated; no real45-minute duration or MPPT charging claim" + ], + "boot_observations": [ + { + "command": "clkreboot", + "before_uptime_secs": 911, + "after_uptime_secs": 7, + "uptime_decreased": true, + "core_errors_after": 0 + }, + { + "command": "reboot", + "before_uptime_secs": 15, + "after_uptime_secs": 7, + "uptime_decreased": true, + "core_errors_after": 0 + }, + { + "command": "clkreboot", + "before_uptime_secs": 15, + "after_uptime_secs": 7, + "uptime_decreased": true, + "core_errors_after": 0 + } + ], + "real_minute_lease": false, + "accelerated_hourly_cycles": [], + "manual_mode_survived_reboot": true, + "original_settings_and_present_rtc_restored": true, + "final_power": { + "mode": "auto", + "active": "off", + "battery": "66%", + "clock": "rtc", + "window": "awake", + "radio": "awake", + "wake": 0, + "hold": 0 + }, + "finished_utc": "2026-10-28T15:17:02.757321+00:00", + "prior_evidence_sha256": { + "intent.private.json": "a240447d38de0e9dabfa86e24e55a049f41ac340d05ba2e3cecbd940c8ecc4b4", + "summary.safe.json": "16b89af942c652cd960c2d29eb4d3c2f9b589c5acab97c7942b3e3606d9b3a38", + "trial/report.json": "b1e5ff41da3d6d95fb5556ecb1650620543e0f2fc8a9911d989c946a9ebd8a00" + }, + "prior_axes_repeated": false, + "test_scope": "manual-persistence-only", + "one_shot_reboot_delivery_hold_ms": 1500, + "attempt": "emergency-solarxiao33s-70-followup1", + "source_summary_sha256": "43b47592c54d7c8be47eba754aaff703aebd56ab19e9d8116b5b067d4037fcc4", + "underlying_submeasurements": { + "passed": true, + "cleanup": "complete", + "real_minute_lease": false, + "accelerated_hourly_cycles": [], + "manual_mode_survived_reboot": true, + "final_power": { + "mode": "auto", + "active": "off", + "battery": "66%", + "clock": "rtc", + "window": "awake", + "radio": "awake", + "wake": 0, + "hold": 0 + }, + "source_report_sha256": "274494cb695a6779994b67c040d6377ae6d42ed01abfb98ab598df85221b4f9a" + } + }, + { + "passed": false, + "source_commit": "70c7d5b478b19f16d232389779ca455e73be7eba", + "expected_version": "v1.18.0.2-halo-keymind-cascade-dev-70c7d5b4", + "driver_sha256": "764414a81317a5f4f28806ab968b25fb487fbdb447c9641d24ff97abbb3ba59e", + "reviewed_method_sha256": "2c4799cb239f9d092d5cf46c0366408aa95f766f786f56af4b913e07b636418f", + "cpu_condition": "native USB host enumerated; retained idle slices capped100ms", + "limitations": [ + "Manual emergency entry at actual reported battery; automatic5% threshold not exercised", + "USB-held CPU/RF scheduling only; current consumption and detached USB sleep not measured", + "Past RTC boundaries accelerated; no real45-minute duration or MPPT charging claim" + ], + "boot_observations": [], + "finished_utc": "2026-10-28T14:45:27.856371+00:00", + "failure_type": "RuntimeError", + "attempt": "emergency-solarxiao33s-70-run1", + "source_summary_sha256": "9b956e4ff22a0129e817016bbb3433ebd1a9a6f25d02d0e4e433a94f44e439a0", + "underlying_submeasurements": { + "passed": false, + "cleanup": "complete", + "real_minute_lease": false, + "accelerated_hourly_cycles": [], + "manual_mode_survived_reboot": false, + "source_report_sha256": "950f9430ddf1f1992b7b889eba0c2b6cbae971eff8f55b178b4bd9310be8563e" + } + }, + { + "passed": false, + "source_commit": "70c7d5b478b19f16d232389779ca455e73be7eba", + "expected_version": "v1.18.0.2-halo-keymind-cascade-dev-70c7d5b4", + "driver_sha256": "764414a81317a5f4f28806ab968b25fb487fbdb447c9641d24ff97abbb3ba59e", + "reviewed_method_sha256": "2c4799cb239f9d092d5cf46c0366408aa95f766f786f56af4b913e07b636418f", + "cpu_condition": "native USB host enumerated; retained idle slices capped100ms", + "limitations": [ + "Manual emergency entry at actual reported battery; automatic5% threshold not exercised", + "USB-held CPU/RF scheduling only; current consumption and detached USB sleep not measured", + "Past RTC boundaries accelerated; no real45-minute duration or MPPT charging claim" + ], + "boot_observations": [ + { + "command": "clkreboot", + "before_uptime_secs": 82, + "after_uptime_secs": 7, + "uptime_decreased": true, + "core_errors_after": 0 + }, + { + "command": "reboot", + "before_uptime_secs": 123, + "after_uptime_secs": 128, + "uptime_decreased": false, + "core_errors_after": 0 + }, + { + "command": "clkreboot", + "before_uptime_secs": 129, + "after_uptime_secs": 7, + "uptime_decreased": true, + "core_errors_after": 0 + } + ], + "finished_utc": "2026-10-28T15:01:09.994480+00:00", + "failure_type": "RuntimeError", + "attempt": "emergency-solarxiao33s-70-run3", + "source_summary_sha256": "16b89af942c652cd960c2d29eb4d3c2f9b589c5acab97c7942b3e3606d9b3a38", + "underlying_submeasurements": { + "passed": true, + "cleanup": "complete", + "real_minute_lease": true, + "accelerated_hourly_cycles": [ + { + "received_monotonic": 26297.607508727, + "advert_epoch": 1793026800, + "no_usb_write_seconds": 22.155895531999704, + "status_after_passive_capture": { + "mode": "manual", + "active": "on", + "battery": "98%", + "clock": "rtc", + "window": "awake", + "radio": "awake", + "wake": 0, + "hold": 0 + } + }, + { + "received_monotonic": 26320.097473607, + "advert_epoch": 1793030400, + "no_usb_write_seconds": 22.145149753003352, + "status_after_passive_capture": { + "mode": "manual", + "active": "on", + "battery": "98%", + "clock": "rtc", + "window": "awake", + "radio": "awake", + "wake": 0, + "hold": 0 + } + } + ], + "manual_mode_survived_reboot": true, + "final_power": { + "mode": "auto", + "active": "off", + "battery": "97%", + "clock": "rtc", + "window": "awake", + "radio": "awake", + "wake": 0, + "hold": 0 + }, + "source_report_sha256": "b1e5ff41da3d6d95fb5556ecb1650620543e0f2fc8a9911d989c946a9ebd8a00" + } + } + ], + "full_body_preservation_followup": { + "passed": true, + "setters_sent": 0, + "reboot_requests": 0, + "rf_send_commands": 0, + "preservation": { + "saved_getters_checked": 15, + "saved_getters_originally_unavailable": [ + "get telemetry.mode.base", + "get telemetry.mode.loc", + "get telemetry.mode.env", + "get max.neighbours", + "get rx.delay", + "get tx.delay" + ], + "acl_comparison_available": true, + "acl_entries_preserved": 2, + "trusted_signer_list_preserved": true + }, + "core": { + "battery_mv": 3805, + "uptime_secs": 841, + "errors": 0, + "queue_len": 0 + }, + "source_summary_sha256": "22c8d64cae0a324156b67f80b5afe572e2cc5e705d328b3f8fa6992caad0fdcb", + "measurement_finished_utc": "2026-10-28T15:30:38.266158+00:00" + }, + "retained_notes": [ + "Later live-channel core flag2 is CAD_TIMEOUT, distinct from watchdog8. A narrow watchdog-only warm preparer refused2 without reboot; exact-source live receive/queue diagnostics and any explicitly admitted single CAD recovery are separate evidence." + ] + }, + { + "board": "RAK3401 gateway", + "role": "Full Repeater", + "installed_source_commit": "70c7d5b478b19f16d232389779ca455e73be7eba", + "installed_full_version": "v1.18.0.2-halo-keymind-cascade-dev-70c7d5b4", + "artifact_sha256": "13ddc99a9fea2650880303e01ad658e0974129ca145ad0d11d2efceb288de045", + "artifact_kind": "application-only DFU ZIP", + "usb_qualified": true, + "rf_trials": [ + { + "attempt": "rf-tag-rak3401-run1", + "harness_passed": false, + "measurement_started_utc": "2026-10-28T14:45:16.350117+00:00", + "measurement_finished_utc": "2026-10-28T14:45:26.013118+00:00", + "source_summary_sha256": "5180b6ff3f16ea7d7e72c598dac796213efa55978d01749bc97cb657a9b4d452", + "requests_attempted": 0, + "exact_responses_completed": 0, + "rf_retries": 0, + "offline_queue_reads": 0, + "channel_setters": 0, + "ota_workspace_acquired": false, + "cleanup": { + "tag": { + "saved_getters_equal": true, + "channel_names_and_keys_equal": true, + "contact_static_records_equal": true, + "contact_live_timestamp_changes": 0, + "binary_preferences_equal": true, + "offline_queue_reads": 0 + } + }, + "failure_type": "RuntimeError", + "rounds": [], + "changed_condition": { + "schema": 1, + "changed_condition": true, + "automatic_retries": 0, + "discovery_requests_maximum": 3, + "initial_and_between_requests_stable_tx_observations": [], + "hidden_eligibility_temporarily_enabled": false, + "advert_intervals_restored": false, + "followup_entrypoint_sha256": "c00607ba128dd428a813a75cdaab6291de485a7b62328d0e6fbbc19777f03c0c", + "source_summary_sha256": "39e059f2748900d3afdccfa0bbfeb85253eb34af737299a828593a67b2bfb69c" + }, + "measured_core_stats": { + "initial": { + "battery_mv": 4203, + "uptime_secs": 4000, + "errors": 8, + "queue_len": 0 + } + }, + "peer_board": "RAK WisMesh Tag", + "peer_source_commit": "c9acec474bb9617d4f92dbdc0401765701989288", + "peer_expected_version": "v1.18.0.2-halo-keymind-cascade-dev-c9acec47" + }, + { + "attempt": "rf-tag-rak3401-run3-warm", + "harness_passed": true, + "measurement_started_utc": "2026-10-28T14:57:31.910266+00:00", + "measurement_finished_utc": "2026-10-28T14:58:13.050136+00:00", + "source_summary_sha256": "ce5da07700f4e8fea412c21ea3de48ba3ca2078330d970bac7285e838f7e15eb", + "requests_attempted": 3, + "exact_responses_completed": 3, + "rf_retries": 0, + "offline_queue_reads": 0, + "channel_setters": 0, + "ota_workspace_acquired": false, + "cleanup": { + "rak3401": { + "saved_settings_equal": true, + "primary_ram_lease_cleared": true, + "owned_emergency_hold_revoked": false, + "offline_queue_reads": 0 + }, + "tag": { + "saved_getters_equal": true, + "channel_names_and_keys_equal": true, + "contact_static_records_equal": true, + "contact_live_timestamp_changes": 0, + "binary_preferences_equal": true, + "offline_queue_reads": 0 + } + }, + "failure_type": null, + "rounds": [ + { + "round": 1, + "passed": true, + "elapsed_seconds": 2.777, + "full_target_identity_and_tag_verified": true, + "radio2_profile": { + "flags": 0 + }, + "radio2_extension_matches_cli_profile": true, + "request_snr_db": 11.5, + "response_snr_db": 12.0, + "hub_packet_delta": { + "received": 1, + "sent": 1, + "sent_flood": 0, + "sent_direct": 1, + "received_flood": 0, + "received_direct": 1, + "receive_errors": 0 + }, + "target_packet_delta": { + "recv": 1, + "sent": 1, + "flood_tx": 0, + "direct_tx": 1, + "flood_rx": 0, + "direct_rx": 1, + "recv_errors": 0 + }, + "offline_queue_reads": 0 + }, + { + "round": 2, + "passed": true, + "elapsed_seconds": 0.594, + "full_target_identity_and_tag_verified": true, + "radio2_profile": { + "flags": 0 + }, + "radio2_extension_matches_cli_profile": true, + "request_snr_db": 11.25, + "response_snr_db": 12.0, + "hub_packet_delta": { + "received": 1, + "sent": 1, + "sent_flood": 0, + "sent_direct": 1, + "received_flood": 0, + "received_direct": 1, + "receive_errors": 0 + }, + "target_packet_delta": { + "recv": 1, + "sent": 1, + "flood_tx": 0, + "direct_tx": 1, + "flood_rx": 0, + "direct_rx": 1, + "recv_errors": 0 + }, + "offline_queue_reads": 0 + }, + { + "round": 3, + "passed": true, + "elapsed_seconds": 2.67, + "full_target_identity_and_tag_verified": true, + "radio2_profile": { + "flags": 0 + }, + "radio2_extension_matches_cli_profile": true, + "request_snr_db": 12.25, + "response_snr_db": 12.75, + "hub_packet_delta": { + "received": 1, + "sent": 1, + "sent_flood": 0, + "sent_direct": 1, + "received_flood": 0, + "received_direct": 1, + "receive_errors": 0 + }, + "target_packet_delta": { + "recv": 1, + "sent": 1, + "flood_tx": 0, + "direct_tx": 1, + "flood_rx": 0, + "direct_rx": 1, + "recv_errors": 0 + }, + "offline_queue_reads": 0 + } + ], + "changed_condition": { + "schema": 1, + "changed_condition": true, + "automatic_retries": 0, + "discovery_requests_maximum": 3, + "initial_and_between_requests_stable_tx_observations": [ + { + "round": 1, + "elapsed_seconds": 6.027, + "observations": [ + { + "queue_len": 0, + "sent": 0 + }, + { + "queue_len": 0, + "sent": 0 + }, + { + "queue_len": 0, + "sent": 0 + }, + { + "queue_len": 0, + "sent": 0 + } + ] + }, + { + "round": 2, + "elapsed_seconds": 6.025, + "observations": [ + { + "queue_len": 0, + "sent": 1 + }, + { + "queue_len": 0, + "sent": 1 + }, + { + "queue_len": 0, + "sent": 1 + }, + { + "queue_len": 0, + "sent": 1 + } + ] + }, + { + "round": 3, + "elapsed_seconds": 6.026, + "observations": [ + { + "queue_len": 0, + "sent": 2 + }, + { + "queue_len": 0, + "sent": 2 + }, + { + "queue_len": 0, + "sent": 2 + }, + { + "queue_len": 0, + "sent": 2 + } + ] + } + ], + "hidden_eligibility_temporarily_enabled": true, + "advert_intervals_restored": true, + "original_local_advert_minutes": 0, + "original_flood_advert_hours": 0, + "original_hidden": true, + "followup_entrypoint_sha256": "4c853ae958964fb70ac74a1fe9df2d7847ac4d1656cc0a9a87565775a1f7138d", + "source_summary_sha256": "f3dda64fa76ef4492f435206ebbee30156581e6355265bbf14551ba04af450a5" + }, + "measured_core_stats": { + "initial": { + "battery_mv": 4235, + "uptime_secs": 13, + "errors": 0, + "queue_len": 1 + }, + "final": { + "battery_mv": 4199, + "uptime_secs": 44, + "errors": 0, + "queue_len": 0 + } + }, + "peer_board": "RAK WisMesh Tag", + "peer_source_commit": "c9acec474bb9617d4f92dbdc0401765701989288", + "peer_expected_version": "v1.18.0.2-halo-keymind-cascade-dev-c9acec47" + } + ], + "warm_preparations": [ + { + "passed": true, + "scope": "single warm preparation, not RF qualification", + "reboot_requests": 1, + "rf_send_commands": 0, + "clear_stats_commands": 0, + "before_core": { + "battery_mv": 4181, + "uptime_secs": 4721, + "errors": 8, + "queue_len": 0 + }, + "after_core": { + "battery_mv": 4248, + "uptime_secs": 4, + "errors": 0, + "queue_len": 1 + }, + "preservation": { + "saved_getters_checked": 15, + "saved_getters_originally_unavailable": [ + "get telemetry.mode.base", + "get telemetry.mode.loc", + "get telemetry.mode.env", + "get max.neighbours", + "get rx.delay", + "get tx.delay" + ], + "acl_comparison_available": true, + "acl_entries_preserved": 1, + "trusted_signer_list_preserved": true + }, + "attempt": "collector-owned-rf-emergency-run3", + "source_summary_sha256": "033b417ec59f67972f28f493946477d73942dbda72040ab9e1b39aec7bac8ac3" + } + ], + "emergency_trials": [ + { + "passed": true, + "source_commit": "70c7d5b478b19f16d232389779ca455e73be7eba", + "expected_version": "v1.18.0.2-halo-keymind-cascade-dev-70c7d5b4", + "driver_sha256": "d8da090510a74bb86be129562432b6a3c433247838f42b3ccf57de27c20786dc", + "reviewed_method_sha256": "2c4799cb239f9d092d5cf46c0366408aa95f766f786f56af4b913e07b636418f", + "cpu_condition": "native USB host enumerated; retained idle slices capped100ms", + "limitations": [ + "Manual emergency entry at actual reported battery; automatic5% threshold not exercised", + "USB-held CPU/RF scheduling only; current consumption and detached USB sleep not measured", + "Past RTC boundaries accelerated; no real45-minute duration or MPPT charging claim" + ], + "boot_observations": [ + { + "command": "clkreboot", + "before_uptime_secs": 1200, + "after_uptime_secs": 4, + "uptime_decreased": true, + "core_errors_after": 0 + }, + { + "command": "reboot", + "before_uptime_secs": 120, + "after_uptime_secs": 4, + "uptime_decreased": true, + "core_errors_after": 0 + }, + { + "command": "clkreboot", + "before_uptime_secs": 15, + "after_uptime_secs": 4, + "uptime_decreased": true, + "core_errors_after": 0 + } + ], + "real_minute_lease": true, + "accelerated_hourly_cycles": [ + { + "received_monotonic": 27424.446708501, + "advert_epoch": 1793030400, + "no_usb_write_seconds": 22.147087785997428, + "status_after_passive_capture": { + "mode": "manual", + "active": "on", + "battery": "100%", + "clock": "rtc", + "window": "awake", + "radio": "awake", + "wake": 0, + "hold": 0 + } + }, + { + "received_monotonic": 27446.931863828, + "advert_epoch": 1793034000, + "no_usb_write_seconds": 22.137041695001244, + "status_after_passive_capture": { + "mode": "manual", + "active": "on", + "battery": "100%", + "clock": "rtc", + "window": "awake", + "radio": "awake", + "wake": 0, + "hold": 0 + } + } + ], + "manual_mode_survived_reboot": true, + "original_settings_and_present_rtc_restored": true, + "final_power": { + "mode": "auto", + "active": "off", + "battery": "100%", + "clock": "rtc", + "window": "awake", + "radio": "awake", + "wake": 0, + "hold": 0 + }, + "finished_utc": "2026-10-28T15:20:04.154150+00:00", + "test_scope": "full emergency scheduling", + "one_shot_reboot_delivery_hold_ms": 1500, + "attempt": "emergency-rak3401-70-followup1", + "source_summary_sha256": "53dbd02d5f78dca7e4adb3b8fc4bb306a1098eee872b3caefec625143fa7c71f", + "underlying_submeasurements": { + "passed": true, + "cleanup": "complete", + "real_minute_lease": true, + "accelerated_hourly_cycles": [ + { + "received_monotonic": 27424.446708501, + "advert_epoch": 1793030400, + "no_usb_write_seconds": 22.147087785997428, + "status_after_passive_capture": { + "mode": "manual", + "active": "on", + "battery": "100%", + "clock": "rtc", + "window": "awake", + "radio": "awake", + "wake": 0, + "hold": 0 + } + }, + { + "received_monotonic": 27446.931863828, + "advert_epoch": 1793034000, + "no_usb_write_seconds": 22.137041695001244, + "status_after_passive_capture": { + "mode": "manual", + "active": "on", + "battery": "100%", + "clock": "rtc", + "window": "awake", + "radio": "awake", + "wake": 0, + "hold": 0 + } + } + ], + "manual_mode_survived_reboot": true, + "final_power": { + "mode": "auto", + "active": "off", + "battery": "100%", + "clock": "rtc", + "window": "awake", + "radio": "awake", + "wake": 0, + "hold": 0 + }, + "source_report_sha256": "3c74aca40e1559caa744a218c86d46e8f5c2f0e9021b2fa6b4ecef9834f50bae" + } + } + ], + "full_body_preservation_followup": { + "passed": true, + "setters_sent": 0, + "reboot_requests": 0, + "rf_send_commands": 0, + "preservation": { + "saved_getters_checked": 15, + "saved_getters_originally_unavailable": [ + "get telemetry.mode.base", + "get telemetry.mode.loc", + "get telemetry.mode.env", + "get max.neighbours", + "get rx.delay", + "get tx.delay" + ], + "acl_comparison_available": true, + "acl_entries_preserved": 1, + "trusted_signer_list_preserved": true + }, + "core": { + "battery_mv": 4242, + "uptime_secs": 651, + "errors": 0, + "queue_len": 0 + }, + "source_summary_sha256": "22c8d64cae0a324156b67f80b5afe572e2cc5e705d328b3f8fa6992caad0fdcb", + "measurement_finished_utc": "2026-10-28T15:30:38.266158+00:00" + }, + "retained_notes": [ + "Later idle core flag8 is RADIO_WATCHDOG. Any single identity/settings/ACL/signer-preserving warm preparation is separate from RF/emergency qualification. Collector unit/config digests and active serial ownership are restored by an external finally guard." + ] + }, + { + "board": "Heltec V3", + "role": "Full Repeater", + "installed_source_commit": "c9acec474bb9617d4f92dbdc0401765701989288", + "installed_full_version": "v1.18.0.2-halo-keymind-cascade-dev-observer-c9acec47", + "artifact_sha256": "d983f8d27adb4a7d5fa9e9bd4ea6fc39925e7a54f75ae4a90f8dbb5ea68423ee", + "artifact_kind": "active application BIN", + "usb_qualified": true, + "rf_trials": [ + { + "attempt": "rf-tag-v3-run1", + "harness_passed": false, + "measurement_started_utc": "2026-10-28T14:23:29.338249+00:00", + "measurement_finished_utc": "2026-10-28T14:23:39.358170+00:00", + "source_summary_sha256": "16db31fe3e933a1497404db87dfd060e8b1167bc9748a690684e3ea2c94e7f27", + "requests_attempted": 0, + "exact_responses_completed": 0, + "rf_retries": 0, + "offline_queue_reads": 0, + "channel_setters": 0, + "ota_workspace_acquired": false, + "cleanup": { + "tag": { + "saved_getters_equal": true, + "channel_names_and_keys_equal": true, + "contact_static_records_equal": true, + "contact_live_timestamp_changes": 0, + "binary_preferences_equal": true, + "offline_queue_reads": 0 + } + }, + "failure_type": "RuntimeError", + "rounds": [], + "measured_core_stats": { + "initial": { + "battery_mv": 3964, + "uptime_secs": 3236, + "errors": 8, + "queue_len": 0 + } + }, + "peer_board": "RAK WisMesh Tag", + "peer_source_commit": "c9acec474bb9617d4f92dbdc0401765701989288", + "peer_expected_version": "v1.18.0.2-halo-keymind-cascade-dev-c9acec47" + }, + { + "attempt": "rf-tag-v3-run2-hidden", + "harness_passed": true, + "measurement_started_utc": "2026-10-28T15:09:50.983925+00:00", + "measurement_finished_utc": "2026-10-28T15:10:42.070740+00:00", + "source_summary_sha256": "13e9fd71acff3b8a727d8f19edb2083b744a9272169c1f986d00f2b395c1978b", + "requests_attempted": 3, + "exact_responses_completed": 3, + "rf_retries": 0, + "offline_queue_reads": 0, + "channel_setters": 0, + "ota_workspace_acquired": false, + "cleanup": { + "v3": { + "saved_settings_equal": true, + "primary_ram_lease_cleared": true, + "owned_emergency_hold_revoked": false, + "offline_queue_reads": 0 + }, + "tag": { + "saved_getters_equal": true, + "channel_names_and_keys_equal": true, + "contact_static_records_equal": true, + "contact_live_timestamp_changes": 0, + "binary_preferences_equal": true, + "offline_queue_reads": 0 + } + }, + "failure_type": null, + "rounds": [ + { + "round": 1, + "passed": true, + "elapsed_seconds": 2.553, + "full_target_identity_and_tag_verified": true, + "radio2_profile": { + "flags": 0 + }, + "radio2_extension_matches_cli_profile": true, + "request_snr_db": 12.25, + "response_snr_db": 11.75, + "hub_packet_delta": { + "received": 1, + "sent": 1, + "sent_flood": 0, + "sent_direct": 1, + "received_flood": 0, + "received_direct": 1, + "receive_errors": 0 + }, + "target_packet_delta": { + "recv": 1, + "sent": 1, + "flood_tx": 0, + "direct_tx": 1, + "flood_rx": 0, + "direct_rx": 1, + "recv_errors": 0 + }, + "offline_queue_reads": 0 + }, + { + "round": 2, + "passed": true, + "elapsed_seconds": 0.64, + "full_target_identity_and_tag_verified": true, + "radio2_profile": { + "flags": 0 + }, + "radio2_extension_matches_cli_profile": true, + "request_snr_db": 11.25, + "response_snr_db": 11.75, + "hub_packet_delta": { + "received": 1, + "sent": 1, + "sent_flood": 0, + "sent_direct": 1, + "received_flood": 0, + "received_direct": 1, + "receive_errors": 0 + }, + "target_packet_delta": { + "recv": 1, + "sent": 1, + "flood_tx": 0, + "direct_tx": 1, + "flood_rx": 0, + "direct_rx": 1, + "recv_errors": 0 + }, + "offline_queue_reads": 0 + }, + { + "round": 3, + "passed": true, + "elapsed_seconds": 1.969, + "full_target_identity_and_tag_verified": true, + "radio2_profile": { + "flags": 0 + }, + "radio2_extension_matches_cli_profile": true, + "request_snr_db": 11.75, + "response_snr_db": 11.75, + "hub_packet_delta": { + "received": 1, + "sent": 1, + "sent_flood": 0, + "sent_direct": 1, + "received_flood": 0, + "received_direct": 1, + "receive_errors": 0 + }, + "target_packet_delta": { + "recv": 1, + "sent": 1, + "flood_tx": 0, + "direct_tx": 1, + "flood_rx": 0, + "direct_rx": 1, + "recv_errors": 0 + }, + "offline_queue_reads": 0 + } + ], + "changed_condition": { + "schema": 1, + "changed_condition": true, + "automatic_retries": 0, + "discovery_requests_maximum": 3, + "initial_and_between_requests_stable_tx_observations": [ + { + "round": 1, + "elapsed_seconds": 6.117, + "observations": [ + { + "queue_len": 0, + "sent": 1 + }, + { + "queue_len": 0, + "sent": 1 + }, + { + "queue_len": 0, + "sent": 1 + }, + { + "queue_len": 0, + "sent": 1 + } + ] + }, + { + "round": 2, + "elapsed_seconds": 6.118, + "observations": [ + { + "queue_len": 0, + "sent": 2 + }, + { + "queue_len": 0, + "sent": 2 + }, + { + "queue_len": 0, + "sent": 2 + }, + { + "queue_len": 0, + "sent": 2 + } + ] + }, + { + "round": 3, + "elapsed_seconds": 10.178, + "observations": [ + { + "queue_len": 0, + "sent": 3 + }, + { + "queue_len": 0, + "sent": 3 + }, + { + "queue_len": 0, + "sent": 4 + }, + { + "queue_len": 0, + "sent": 4 + }, + { + "queue_len": 0, + "sent": 4 + }, + { + "queue_len": 0, + "sent": 4 + } + ] + } + ], + "hidden_eligibility_temporarily_enabled": true, + "advert_intervals_restored": true, + "original_local_advert_minutes": 0, + "original_flood_advert_hours": 0, + "original_hidden": true, + "followup_entrypoint_sha256": "97ffcee3a733e03f4f82d60c627bcd9ba5ae0c9ce76fb4a4ed55f73f600d316a", + "source_summary_sha256": "81562cd3d2ba72717e2eca98fe2afc5ce7d5b4ff28eee591e76f4b30a5e0008b" + }, + "measured_core_stats": { + "initial": { + "battery_mv": 3999, + "uptime_secs": 11, + "errors": 0, + "queue_len": 1 + }, + "final": { + "battery_mv": 4122, + "uptime_secs": 52, + "errors": 0, + "queue_len": 0 + } + }, + "peer_board": "RAK WisMesh Tag", + "peer_source_commit": "c9acec474bb9617d4f92dbdc0401765701989288", + "peer_expected_version": "v1.18.0.2-halo-keymind-cascade-dev-c9acec47" + } + ], + "warm_preparations": [ + { + "passed": true, + "scope": "single warm preparation, not RF qualification", + "reboot_requests": 1, + "rf_send_commands": 0, + "clear_stats_commands": 0, + "before_core": { + "battery_mv": 4034, + "uptime_secs": 1310, + "errors": 8, + "queue_len": 0 + }, + "after_core": { + "battery_mv": 3999, + "uptime_secs": 5, + "errors": 0, + "queue_len": 1 + }, + "preservation": { + "saved_getters_checked": 15, + "saved_getters_originally_unavailable": [ + "get telemetry.mode.base", + "get telemetry.mode.loc", + "get telemetry.mode.env", + "get max.neighbours", + "get rx.delay", + "get tx.delay" + ], + "acl_comparison_available": true, + "acl_entries_preserved": 0, + "trusted_signer_list_preserved": true + }, + "source_summary_sha256": "75584a1ebb58b12ac0f935e9b2f3a30c20fc949a80196f9e9881381e660874af" + } + ], + "emergency_trials": [], + "retained_notes": [ + "Initial RF preflight refused pre-existing quiet watchdog flag8 before RF. Separate read-only diagnostic used an unintended host-test transport fallback; guarded recovery and further qualification are reported separately." + ] + }, + { + "board": "LilyGo T-Deck", + "role": "Full Repeater", + "installed_source_commit": "c9acec474bb9617d4f92dbdc0401765701989288", + "installed_full_version": "v1.18.0.2-halo-keymind-cascade-dev-c9acec47", + "artifact_sha256": "83a73ddd7add2a98fef58b2a7c1335157e7ee4633d959923d78f876ec2f6b9d2", + "artifact_kind": "active application BIN", + "usb_qualified": true, + "rf_trials": [ + { + "attempt": "rf-tag-tdeck-run1", + "harness_passed": false, + "measurement_started_utc": "2026-10-28T14:23:40.166004+00:00", + "measurement_finished_utc": "2026-10-28T14:24:14.978321+00:00", + "source_summary_sha256": "60116b567de022444d3055c5e497980245143a8a768af89e727283e132ef7454", + "requests_attempted": 1, + "exact_responses_completed": 0, + "rf_retries": 0, + "offline_queue_reads": 0, + "channel_setters": 0, + "ota_workspace_acquired": false, + "cleanup": { + "tdeck": { + "saved_settings_equal": true, + "primary_ram_lease_cleared": true, + "owned_emergency_hold_revoked": false, + "offline_queue_reads": 0 + }, + "tag": { + "saved_getters_equal": true, + "channel_names_and_keys_equal": true, + "contact_static_records_equal": true, + "contact_live_timestamp_changes": 0, + "binary_preferences_equal": true, + "offline_queue_reads": 0 + } + }, + "failure_type": "TimeoutError", + "rounds": [], + "measured_core_stats": { + "initial": { + "battery_mv": 4389, + "uptime_secs": 3277, + "errors": 0, + "queue_len": 0 + }, + "final": { + "battery_mv": 4394, + "uptime_secs": 3302, + "errors": 0, + "queue_len": 0 + } + }, + "peer_board": "RAK WisMesh Tag", + "peer_source_commit": "c9acec474bb9617d4f92dbdc0401765701989288", + "peer_expected_version": "v1.18.0.2-halo-keymind-cascade-dev-c9acec47" + }, + { + "attempt": "rf-tag-tdeck-run2-paced", + "harness_passed": false, + "measurement_started_utc": "2026-10-28T14:32:21.686369+00:00", + "measurement_finished_utc": "2026-10-28T14:32:30.988868+00:00", + "source_summary_sha256": "c798dfe40648beb0300ace5eb45619e804bc6347c6ace425a576ac07930b54e0", + "requests_attempted": 0, + "exact_responses_completed": 0, + "rf_retries": 0, + "offline_queue_reads": 0, + "channel_setters": 0, + "ota_workspace_acquired": false, + "cleanup": { + "tag": { + "saved_getters_equal": true, + "channel_names_and_keys_equal": true, + "contact_static_records_equal": true, + "contact_live_timestamp_changes": 0, + "binary_preferences_equal": true, + "offline_queue_reads": 0 + } + }, + "failure_type": "TypeError", + "rounds": [], + "changed_condition": { + "schema": 1, + "changed_condition": true, + "automatic_retries": 0, + "discovery_requests_maximum": 3, + "initial_and_between_requests_stable_tx_observations": [], + "hidden_eligibility_temporarily_enabled": false, + "advert_intervals_restored": false, + "followup_entrypoint_sha256": "c9af8d8a2abadf9e3dc08f309c9e652f97acb344bf00451b865955e92c53702f", + "source_summary_sha256": "749d4d98c036f5f03fc794a96959bac3c92035f8e383a237510cdd08f2af96ba" + }, + "measured_core_stats": {}, + "peer_board": "RAK WisMesh Tag", + "peer_source_commit": "c9acec474bb9617d4f92dbdc0401765701989288", + "peer_expected_version": "v1.18.0.2-halo-keymind-cascade-dev-c9acec47" + }, + { + "attempt": "rf-tag-tdeck-run3-paced", + "harness_passed": true, + "measurement_started_utc": "2026-10-28T14:34:23.759280+00:00", + "measurement_finished_utc": "2026-10-28T14:35:12.786591+00:00", + "source_summary_sha256": "3d8f27122f5024745784c418ba3c4e33b3915f8105103d4c75387deb09290c0e", + "requests_attempted": 3, + "exact_responses_completed": 3, + "rf_retries": 0, + "offline_queue_reads": 0, + "channel_setters": 0, + "ota_workspace_acquired": false, + "cleanup": { + "tdeck": { + "saved_settings_equal": true, + "primary_ram_lease_cleared": true, + "owned_emergency_hold_revoked": false, + "offline_queue_reads": 0 + }, + "tag": { + "saved_getters_equal": true, + "channel_names_and_keys_equal": true, + "contact_static_records_equal": true, + "contact_live_timestamp_changes": 0, + "binary_preferences_equal": true, + "offline_queue_reads": 0 + } + }, + "failure_type": null, + "rounds": [ + { + "round": 1, + "passed": true, + "elapsed_seconds": 1.94, + "full_target_identity_and_tag_verified": true, + "radio2_profile": { + "flags": 0 + }, + "radio2_extension_matches_cli_profile": true, + "request_snr_db": 9.75, + "response_snr_db": 12.25, + "hub_packet_delta": { + "received": 1, + "sent": 1, + "sent_flood": 0, + "sent_direct": 1, + "received_flood": 0, + "received_direct": 1, + "receive_errors": 0 + }, + "target_packet_delta": { + "recv": 1, + "sent": 1, + "flood_tx": 0, + "direct_tx": 1, + "flood_rx": 0, + "direct_rx": 1, + "recv_errors": 0 + }, + "offline_queue_reads": 0 + }, + { + "round": 2, + "passed": true, + "elapsed_seconds": 2.268, + "full_target_identity_and_tag_verified": true, + "radio2_profile": { + "flags": 0 + }, + "radio2_extension_matches_cli_profile": true, + "request_snr_db": 12.5, + "response_snr_db": 12.25, + "hub_packet_delta": { + "received": 1, + "sent": 1, + "sent_flood": 0, + "sent_direct": 1, + "received_flood": 0, + "received_direct": 1, + "receive_errors": 0 + }, + "target_packet_delta": { + "recv": 1, + "sent": 1, + "flood_tx": 0, + "direct_tx": 1, + "flood_rx": 0, + "direct_rx": 1, + "recv_errors": 0 + }, + "offline_queue_reads": 0 + }, + { + "round": 3, + "passed": true, + "elapsed_seconds": 2.999, + "full_target_identity_and_tag_verified": true, + "radio2_profile": { + "flags": 0 + }, + "radio2_extension_matches_cli_profile": true, + "request_snr_db": 10.25, + "response_snr_db": 2.0, + "hub_packet_delta": { + "received": 1, + "sent": 1, + "sent_flood": 0, + "sent_direct": 1, + "received_flood": 0, + "received_direct": 1, + "receive_errors": 0 + }, + "target_packet_delta": { + "recv": 1, + "sent": 1, + "flood_tx": 0, + "direct_tx": 1, + "flood_rx": 0, + "direct_rx": 1, + "recv_errors": 0 + }, + "offline_queue_reads": 0 + } + ], + "changed_condition": { + "schema": 1, + "changed_condition": true, + "automatic_retries": 0, + "discovery_requests_maximum": 3, + "initial_and_between_requests_stable_tx_observations": [ + { + "round": 1, + "elapsed_seconds": 6.037, + "observations": [ + { + "queue_len": 0, + "sent": 3 + }, + { + "queue_len": 0, + "sent": 3 + }, + { + "queue_len": 0, + "sent": 3 + }, + { + "queue_len": 0, + "sent": 3 + } + ] + }, + { + "round": 2, + "elapsed_seconds": 6.038, + "observations": [ + { + "queue_len": 0, + "sent": 4 + }, + { + "queue_len": 0, + "sent": 4 + }, + { + "queue_len": 0, + "sent": 4 + }, + { + "queue_len": 0, + "sent": 4 + } + ] + }, + { + "round": 3, + "elapsed_seconds": 8.047, + "observations": [ + { + "queue_len": 0, + "sent": 5 + }, + { + "queue_len": 0, + "sent": 6 + }, + { + "queue_len": 0, + "sent": 6 + }, + { + "queue_len": 0, + "sent": 6 + }, + { + "queue_len": 0, + "sent": 6 + } + ] + } + ], + "hidden_eligibility_temporarily_enabled": true, + "advert_intervals_restored": true, + "original_local_advert_minutes": 0, + "original_flood_advert_hours": 0, + "original_hidden": true, + "followup_entrypoint_sha256": "a80312256a8c92749465af6e68c6cf53f41aebc04c3bbee5686157ffea89bdbe", + "source_summary_sha256": "37211d9e8373cea98d2b8f16a39fd1ca64acc328a88966d77c2122718f2db2fa" + }, + "measured_core_stats": { + "initial": { + "battery_mv": 4394, + "uptime_secs": 3921, + "errors": 0, + "queue_len": 0 + }, + "final": { + "battery_mv": 4395, + "uptime_secs": 3960, + "errors": 0, + "queue_len": 0 + } + }, + "peer_board": "RAK WisMesh Tag", + "peer_source_commit": "c9acec474bb9617d4f92dbdc0401765701989288", + "peer_expected_version": "v1.18.0.2-halo-keymind-cascade-dev-c9acec47" + } + ], + "warm_preparations": [], + "emergency_trials": [], + "retained_notes": [ + "Original saved local and flood advert intervals0 make the node hidden; first received request correctly had no discovery reply. Conditional isolated trial temporarily enabled local advert interval120 and restored0." + ] + }, + { + "board": "SenseCAP Indicator", + "role": "Full Repeater", + "installed_source_commit": "c9acec474bb9617d4f92dbdc0401765701989288", + "installed_full_version": "v1.18.0.2-halo-keymind-cascade-dev-c9acec47", + "artifact_sha256": "78c494a116f4a233a9d8c72be2a80fbf0bf71506028723cec18b358dac1fd2ab", + "artifact_kind": "active application BIN", + "usb_qualified": true, + "rf_trials": [ + { + "attempt": "rf-tag-indicator-run1", + "harness_passed": false, + "measurement_started_utc": "2026-10-28T14:24:15.772182+00:00", + "measurement_finished_utc": "2026-10-28T14:24:48.664488+00:00", + "source_summary_sha256": "2461fed4601067f31b85ed3af12ad51e5174bd037d80bab1419716e2ae85a77e", + "requests_attempted": 1, + "exact_responses_completed": 0, + "rf_retries": 0, + "offline_queue_reads": 0, + "channel_setters": 0, + "ota_workspace_acquired": false, + "cleanup": { + "indicator": { + "saved_settings_equal": true, + "primary_ram_lease_cleared": true, + "owned_emergency_hold_revoked": false, + "offline_queue_reads": 0 + }, + "tag": { + "saved_getters_equal": true, + "channel_names_and_keys_equal": true, + "contact_static_records_equal": true, + "contact_live_timestamp_changes": 0, + "binary_preferences_equal": true, + "offline_queue_reads": 0 + } + }, + "failure_type": "TimeoutError", + "rounds": [], + "measured_core_stats": { + "initial": { + "battery_mv": 0, + "uptime_secs": 1126, + "errors": 0, + "queue_len": 0 + }, + "final": { + "battery_mv": 0, + "uptime_secs": 1149, + "errors": 0, + "queue_len": 0 + } + }, + "peer_board": "RAK WisMesh Tag", + "peer_source_commit": "c9acec474bb9617d4f92dbdc0401765701989288", + "peer_expected_version": "v1.18.0.2-halo-keymind-cascade-dev-c9acec47" + }, + { + "attempt": "rf-tag-indicator-run2-paced", + "harness_passed": false, + "measurement_started_utc": "2026-10-28T14:32:31.782058+00:00", + "measurement_finished_utc": "2026-10-28T14:32:40.943567+00:00", + "source_summary_sha256": "da598d9ed59044a3f76c64715deeed440b2b9690ba36d60449fc5dfbb905306b", + "requests_attempted": 0, + "exact_responses_completed": 0, + "rf_retries": 0, + "offline_queue_reads": 0, + "channel_setters": 0, + "ota_workspace_acquired": false, + "cleanup": { + "tag": { + "saved_getters_equal": true, + "channel_names_and_keys_equal": true, + "contact_static_records_equal": true, + "contact_live_timestamp_changes": 0, + "binary_preferences_equal": true, + "offline_queue_reads": 0 + } + }, + "failure_type": "TypeError", + "rounds": [], + "changed_condition": { + "schema": 1, + "changed_condition": true, + "automatic_retries": 0, + "discovery_requests_maximum": 3, + "initial_and_between_requests_stable_tx_observations": [], + "hidden_eligibility_temporarily_enabled": false, + "advert_intervals_restored": false, + "followup_entrypoint_sha256": "c9af8d8a2abadf9e3dc08f309c9e652f97acb344bf00451b865955e92c53702f", + "source_summary_sha256": "749d4d98c036f5f03fc794a96959bac3c92035f8e383a237510cdd08f2af96ba" + }, + "measured_core_stats": {}, + "peer_board": "RAK WisMesh Tag", + "peer_source_commit": "c9acec474bb9617d4f92dbdc0401765701989288", + "peer_expected_version": "v1.18.0.2-halo-keymind-cascade-dev-c9acec47" + }, + { + "attempt": "rf-tag-indicator-run3-paced", + "harness_passed": true, + "measurement_started_utc": "2026-10-28T14:35:13.581533+00:00", + "measurement_finished_utc": "2026-10-28T14:35:59.066652+00:00", + "source_summary_sha256": "db660034f4b760d5ea217d74e9c0655b800ddb94915372dd10ec7c12c92eeff3", + "requests_attempted": 3, + "exact_responses_completed": 3, + "rf_retries": 0, + "offline_queue_reads": 0, + "channel_setters": 0, + "ota_workspace_acquired": false, + "cleanup": { + "indicator": { + "saved_settings_equal": true, + "primary_ram_lease_cleared": true, + "owned_emergency_hold_revoked": false, + "offline_queue_reads": 0 + }, + "tag": { + "saved_getters_equal": true, + "channel_names_and_keys_equal": true, + "contact_static_records_equal": true, + "contact_live_timestamp_changes": 0, + "binary_preferences_equal": true, + "offline_queue_reads": 0 + } + }, + "failure_type": null, + "rounds": [ + { + "round": 1, + "passed": true, + "elapsed_seconds": 1.61, + "full_target_identity_and_tag_verified": true, + "radio2_profile": { + "flags": 0 + }, + "radio2_extension_matches_cli_profile": true, + "request_snr_db": 11.75, + "response_snr_db": 11.5, + "hub_packet_delta": { + "received": 1, + "sent": 1, + "sent_flood": 0, + "sent_direct": 1, + "received_flood": 0, + "received_direct": 1, + "receive_errors": 0 + }, + "target_packet_delta": { + "recv": 1, + "sent": 1, + "flood_tx": 0, + "direct_tx": 1, + "flood_rx": 0, + "direct_rx": 1, + "recv_errors": 0 + }, + "offline_queue_reads": 0 + }, + { + "round": 2, + "passed": true, + "elapsed_seconds": 0.547, + "full_target_identity_and_tag_verified": true, + "radio2_profile": { + "flags": 0 + }, + "radio2_extension_matches_cli_profile": true, + "request_snr_db": 13.0, + "response_snr_db": 11.75, + "hub_packet_delta": { + "received": 1, + "sent": 1, + "sent_flood": 0, + "sent_direct": 1, + "received_flood": 0, + "received_direct": 1, + "receive_errors": 0 + }, + "target_packet_delta": { + "recv": 1, + "sent": 1, + "flood_tx": 0, + "direct_tx": 1, + "flood_rx": 0, + "direct_rx": 1, + "recv_errors": 0 + }, + "offline_queue_reads": 0 + }, + { + "round": 3, + "passed": true, + "elapsed_seconds": 1.625, + "full_target_identity_and_tag_verified": true, + "radio2_profile": { + "flags": 0 + }, + "radio2_extension_matches_cli_profile": true, + "request_snr_db": 11.5, + "response_snr_db": 11.5, + "hub_packet_delta": { + "received": 1, + "sent": 1, + "sent_flood": 0, + "sent_direct": 1, + "received_flood": 0, + "received_direct": 1, + "receive_errors": 0 + }, + "target_packet_delta": { + "recv": 1, + "sent": 1, + "flood_tx": 0, + "direct_tx": 1, + "flood_rx": 0, + "direct_rx": 1, + "recv_errors": 0 + }, + "offline_queue_reads": 0 + } + ], + "changed_condition": { + "schema": 1, + "changed_condition": true, + "automatic_retries": 0, + "discovery_requests_maximum": 3, + "initial_and_between_requests_stable_tx_observations": [ + { + "round": 1, + "elapsed_seconds": 6.121, + "observations": [ + { + "queue_len": 0, + "sent": 3 + }, + { + "queue_len": 0, + "sent": 3 + }, + { + "queue_len": 0, + "sent": 3 + }, + { + "queue_len": 0, + "sent": 3 + } + ] + }, + { + "round": 2, + "elapsed_seconds": 6.188, + "observations": [ + { + "queue_len": 0, + "sent": 4 + }, + { + "queue_len": 0, + "sent": 4 + }, + { + "queue_len": 0, + "sent": 4 + }, + { + "queue_len": 0, + "sent": 4 + } + ] + }, + { + "round": 3, + "elapsed_seconds": 12.257, + "observations": [ + { + "queue_len": 0, + "sent": 5 + }, + { + "queue_len": 0, + "sent": 5 + }, + { + "queue_len": 0, + "sent": 5 + }, + { + "queue_len": 0, + "sent": 6 + }, + { + "queue_len": 0, + "sent": 6 + }, + { + "queue_len": 0, + "sent": 6 + }, + { + "queue_len": 0, + "sent": 6 + } + ] + } + ], + "hidden_eligibility_temporarily_enabled": true, + "advert_intervals_restored": true, + "original_local_advert_minutes": 0, + "original_flood_advert_hours": 0, + "original_hidden": true, + "followup_entrypoint_sha256": "a80312256a8c92749465af6e68c6cf53f41aebc04c3bbee5686157ffea89bdbe", + "source_summary_sha256": "9e1c79da865a316cdb83b4ed35a1311b67b19d4c0a49500a1c5b2cb2a30677e7" + }, + "measured_core_stats": { + "initial": { + "battery_mv": 0, + "uptime_secs": 1783, + "errors": 0, + "queue_len": 0 + }, + "final": { + "battery_mv": 0, + "uptime_secs": 1819, + "errors": 0, + "queue_len": 0 + } + }, + "peer_board": "RAK WisMesh Tag", + "peer_source_commit": "c9acec474bb9617d4f92dbdc0401765701989288", + "peer_expected_version": "v1.18.0.2-halo-keymind-cascade-dev-c9acec47" + } + ], + "warm_preparations": [], + "emergency_trials": [], + "retained_notes": [ + "Original saved local and flood advert intervals0 make the node hidden; first received request correctly had no discovery reply. Conditional isolated trial temporarily enabled local advert interval120 and restored0." + ] + }, + { + "board": "Heltec V4.2", + "role": "Full Repeater", + "installed_source_commit": "c9acec474bb9617d4f92dbdc0401765701989288", + "installed_full_version": "v1.18.0.2-halo-keymind-cascade-dev-observer-c9acec47", + "artifact_sha256": "ba151fab2c6e6eca6b3025bb2df394fd250372faee0b4c5966291c72ebc3ab29", + "artifact_kind": "active application BIN", + "usb_qualified": true, + "rf_trials": [ + { + "attempt": "rf-tag-v42-run1", + "harness_passed": true, + "measurement_started_utc": "2026-10-28T14:24:49.477630+00:00", + "measurement_finished_utc": "2026-10-28T14:25:19.778985+00:00", + "source_summary_sha256": "cc40df11ced21f45d6956f5a166773cd2e7c9e24aa9638a2b9bc799aeca719fb", + "requests_attempted": 3, + "exact_responses_completed": 3, + "rf_retries": 0, + "offline_queue_reads": 0, + "channel_setters": 0, + "ota_workspace_acquired": false, + "cleanup": { + "v42": { + "saved_settings_equal": true, + "primary_ram_lease_cleared": true, + "owned_emergency_hold_revoked": false, + "offline_queue_reads": 0 + }, + "tag": { + "saved_getters_equal": true, + "channel_names_and_keys_equal": true, + "contact_static_records_equal": true, + "contact_live_timestamp_changes": 0, + "binary_preferences_equal": true, + "offline_queue_reads": 0 + } + }, + "failure_type": null, + "rounds": [ + { + "round": 1, + "passed": true, + "elapsed_seconds": 1.143, + "full_target_identity_and_tag_verified": true, + "radio2_profile": { + "flags": 0 + }, + "radio2_extension_matches_cli_profile": true, + "request_snr_db": 11.5, + "response_snr_db": 11.5, + "hub_packet_delta": { + "received": 1, + "sent": 1, + "sent_flood": 0, + "sent_direct": 1, + "received_flood": 0, + "received_direct": 1, + "receive_errors": 0 + }, + "target_packet_delta": { + "recv": 1, + "sent": 1, + "flood_tx": 0, + "direct_tx": 1, + "flood_rx": 0, + "direct_rx": 1, + "recv_errors": 0 + }, + "offline_queue_reads": 0 + }, + { + "round": 2, + "passed": true, + "elapsed_seconds": 1.889, + "full_target_identity_and_tag_verified": true, + "radio2_profile": { + "flags": 0 + }, + "radio2_extension_matches_cli_profile": true, + "request_snr_db": 11.5, + "response_snr_db": 11.25, + "hub_packet_delta": { + "received": 1, + "sent": 1, + "sent_flood": 0, + "sent_direct": 1, + "received_flood": 0, + "received_direct": 1, + "receive_errors": 0 + }, + "target_packet_delta": { + "recv": 1, + "sent": 1, + "flood_tx": 0, + "direct_tx": 1, + "flood_rx": 0, + "direct_rx": 1, + "recv_errors": 0 + }, + "offline_queue_reads": 0 + }, + { + "round": 3, + "passed": true, + "elapsed_seconds": 2.26, + "full_target_identity_and_tag_verified": true, + "radio2_profile": { + "flags": 0 + }, + "radio2_extension_matches_cli_profile": true, + "request_snr_db": 12.25, + "response_snr_db": 11.25, + "hub_packet_delta": { + "received": 1, + "sent": 1, + "sent_flood": 0, + "sent_direct": 1, + "received_flood": 0, + "received_direct": 1, + "receive_errors": 0 + }, + "target_packet_delta": { + "recv": 1, + "sent": 1, + "flood_tx": 0, + "direct_tx": 1, + "flood_rx": 0, + "direct_rx": 1, + "recv_errors": 0 + }, + "offline_queue_reads": 0 + } + ], + "measured_core_stats": { + "initial": { + "battery_mv": 4174, + "uptime_secs": 3329, + "errors": 0, + "queue_len": 0 + }, + "final": { + "battery_mv": 4174, + "uptime_secs": 3350, + "errors": 0, + "queue_len": 1 + } + }, + "peer_board": "RAK WisMesh Tag", + "peer_source_commit": "c9acec474bb9617d4f92dbdc0401765701989288", + "peer_expected_version": "v1.18.0.2-halo-keymind-cascade-dev-c9acec47" + } + ], + "warm_preparations": [], + "emergency_trials": [], + "retained_notes": [] + }, + { + "board": "RAK 4631", + "role": "repeater", + "installed_source_commit": "70c7d5b478b19f16d232389779ca455e73be7eba", + "installed_full_version": "v1.18.0.2-halo-keymind-cascade-dev-70c7d5b4 (Build: 28-Oct-2026)", + "artifact_sha256": "915ced25f967ec302eacd218fdc10ee776eca579e71f61bdb89a3bc705f189e6", + "artifact_kind": "application-only Full DFU ZIP", + "usb_qualified": true, + "usb": { + "persistent_queries_completed": 100, + "reopens_completed": 10, + "warm_reboot_requests": 1, + "uptime_before_warm_reboot": 10, + "uptime_after_warm_reboot": 3, + "source_bound_first_attempt_measurements_reused": true, + "new_queries_or_reboots_substituted": false, + "settings_preservation": { + "saved_getters_checked": 7, + "saved_getters_originally_unavailable": [ + "get password", + "get guest", + "get radio2", + "get radio2.cross", + "get radio.rxps.config", + "get powersaving", + "get usb.logging", + "get usb.logging.always", + "get telemetry.mode.base", + "get telemetry.mode.loc", + "get telemetry.mode.env", + "get max.neighbours", + "get rx.delay", + "get tx.delay" + ], + "acl_comparison_available": false, + "acl_entries_preserved": null, + "trusted_signer_list_preserved": true + }, + "password_and_acl_originally_unavailable": true, + "password_or_acl_preserved_claimed": false, + "clock_within_90_seconds": true, + "final_core_stats": { + "battery_mv": 4223, + "uptime_secs": 282, + "errors": 0, + "queue_len": 0 + }, + "usb_completed_commands_measured": 250, + "usb_max_completed_command_seconds": 1.0997, + "final_usb_session_closed": true + }, + "usb_followup_sha256": "2adfff79b8d22ea14c5d1d6d85f07b4ff54a034f2a03d3e4c647d5df096f3e33", + "initial_install_report_sha256": "e67d82c08327298732048ba3d2f276a4056bfb07e3729a1a485d5f89460bfb83", + "application_install": { + "user_test_radio_no_backup_prerequisite_override": true, + "rollback_image_available": false, + "original_application_backup_obtained": false, + "bootloader_written": false, + "softdevice_written": false, + "filesystem_image_written": false, + "partition_table_written": false, + "erase_requested": false, + "automatic_flash_retries": 0, + "fresh_preflash_identity_role_version_hash_verified": true + }, + "rf_trials": [ + { + "attempt": "rf226-tag-rak70-run1", + "harness_passed": false, + "measurement_started_utc": "2026-10-28T17:06:34.969638+00:00", + "measurement_finished_utc": "2026-10-28T17:06:44.640110+00:00", + "source_summary_sha256": "33461cf045e980c118e83d4f91601d1dbcb4c7b63dd990bd9556143e07fc5761", + "requests_attempted": 0, + "exact_responses_completed": 0, + "peer_board": "RAK WisMesh Tag", + "peer_source_commit": "226ccc332d29e78c3dc69cdb70a228baca403711", + "peer_expected_version": "v1.18.0.2-halo-keymind-cascade-dev-226ccc33", + "target_source_commit": "70c7d5b478b19f16d232389779ca455e73be7eba", + "rf_retries": 0, + "offline_queue_reads": 0, + "channel_setters": 0, + "ota_workspace_acquired": false, + "cleanup": { + "tag": { + "saved_getters_equal": true, + "channel_names_and_keys_equal": true, + "contact_static_records_equal": true, + "contact_live_timestamp_changes": 0, + "binary_preferences_equal": true, + "offline_queue_reads": 0 + } + }, + "failure_type": "RuntimeError", + "rounds": [], + "condition": { + "hidden_eligibility_temporarily_enabled": false, + "advert_intervals_restored": false, + "quiet_admission": [] + } + }, + { + "attempt": "rf226-tag-rak70-after-warm-run1", + "harness_passed": true, + "measurement_started_utc": "2026-10-28T17:12:19.957026+00:00", + "measurement_finished_utc": "2026-10-28T17:13:08.178488+00:00", + "source_summary_sha256": "4cb7b41b64bb61386e920d3dd98ca1ba2d90cf829b967c75ef449d4e33669ff9", + "requests_attempted": 3, + "exact_responses_completed": 3, + "peer_board": "RAK WisMesh Tag", + "peer_source_commit": "226ccc332d29e78c3dc69cdb70a228baca403711", + "peer_expected_version": "v1.18.0.2-halo-keymind-cascade-dev-226ccc33", + "target_source_commit": "70c7d5b478b19f16d232389779ca455e73be7eba", + "rf_retries": 0, + "offline_queue_reads": 0, + "channel_setters": 0, + "ota_workspace_acquired": false, + "cleanup": { + "rak": { + "saved_settings_equal": true, + "primary_ram_lease_cleared": true, + "owned_emergency_hold_revoked": false, + "offline_queue_reads": 0 + }, + "tag": { + "saved_getters_equal": true, + "channel_names_and_keys_equal": true, + "contact_static_records_equal": true, + "contact_live_timestamp_changes": 0, + "binary_preferences_equal": true, + "offline_queue_reads": 0 + } + }, + "failure_type": null, + "rounds": [ + { + "full_target_identity_and_tag_verified": true, + "radio2_profile": { + "flags": 0 + }, + "response_sha256": "64e60a56288787d17207fe49d2291a6b2c5430f6711dae7401f660b3d1655bd5", + "passed": true, + "round": 1, + "elapsed_seconds": 0.942, + "hub_packet_delta": { + "received": 1, + "sent": 1, + "sent_flood": 0, + "sent_direct": 1, + "received_flood": 0, + "received_direct": 1, + "receive_errors": 0 + }, + "target_packet_delta": { + "recv": 1, + "sent": 1, + "flood_tx": 0, + "direct_tx": 1, + "flood_rx": 0, + "direct_rx": 1, + "recv_errors": 0 + }, + "offline_queue_reads": 0, + "radio2_extension_matches_cli_profile": true + }, + { + "full_target_identity_and_tag_verified": true, + "radio2_profile": { + "flags": 0 + }, + "response_sha256": "dee63106f130c4be4ac95d445614a51d64c96514b621e84632770ac639e15f62", + "passed": true, + "round": 2, + "elapsed_seconds": 0.536, + "hub_packet_delta": { + "received": 1, + "sent": 1, + "sent_flood": 0, + "sent_direct": 1, + "received_flood": 0, + "received_direct": 1, + "receive_errors": 0 + }, + "target_packet_delta": { + "recv": 1, + "sent": 1, + "flood_tx": 0, + "direct_tx": 1, + "flood_rx": 0, + "direct_rx": 1, + "recv_errors": 0 + }, + "offline_queue_reads": 0, + "radio2_extension_matches_cli_profile": true + }, + { + "full_target_identity_and_tag_verified": true, + "radio2_profile": { + "flags": 0 + }, + "response_sha256": "42062110641036c998221e5464a38c03aec6c1ef350f95bcf8b302ea838e4cd4", + "passed": true, + "round": 3, + "elapsed_seconds": 1.505, + "hub_packet_delta": { + "received": 1, + "sent": 1, + "sent_flood": 0, + "sent_direct": 1, + "received_flood": 0, + "received_direct": 1, + "receive_errors": 0 + }, + "target_packet_delta": { + "recv": 1, + "sent": 1, + "flood_tx": 0, + "direct_tx": 1, + "flood_rx": 0, + "direct_rx": 1, + "recv_errors": 0 + }, + "offline_queue_reads": 0, + "radio2_extension_matches_cli_profile": true + } + ], + "condition": { + "original_local_advert_minutes": 0, + "original_flood_advert_hours": 47, + "original_hidden": false, + "hidden_eligibility_temporarily_enabled": false, + "advert_intervals_restored": true, + "quiet_admission": [ + { + "round": 1, + "observations": [ + { + "queue_len": 0, + "sent": 1 + }, + { + "queue_len": 0, + "sent": 2 + }, + { + "queue_len": 0, + "sent": 2 + }, + { + "queue_len": 0, + "sent": 2 + }, + { + "queue_len": 0, + "sent": 2 + } + ] + }, + { + "round": 2, + "observations": [ + { + "queue_len": 0, + "sent": 3 + }, + { + "queue_len": 0, + "sent": 3 + }, + { + "queue_len": 0, + "sent": 3 + }, + { + "queue_len": 0, + "sent": 3 + } + ] + }, + { + "round": 3, + "observations": [ + { + "queue_len": 0, + "sent": 4 + }, + { + "queue_len": 0, + "sent": 4 + }, + { + "queue_len": 0, + "sent": 4 + }, + { + "queue_len": 0, + "sent": 5 + }, + { + "queue_len": 0, + "sent": 5 + }, + { + "queue_len": 0, + "sent": 5 + }, + { + "queue_len": 0, + "sent": 5 + } + ] + } + ] + } + } + ], + "warm_preparations": [ + { + "schema": 1, + "board": "RAK 4631", + "source_commit": "70c7d5b478b19f16d232389779ca455e73be7eba", + "scope": "single documented warm preparation, not RF qualification", + "passed": true, + "reboot_requests": 1, + "rf_sends": 0, + "clearstats": 0, + "before_core": { + "battery_mv": 4254, + "uptime_secs": 2545, + "errors": 8, + "queue_len": 0 + }, + "after_core": { + "battery_mv": 4280, + "uptime_secs": 4, + "errors": 0, + "queue_len": 1 + }, + "preservation": { + "saved_getters_checked": 15, + "saved_getters_originally_unavailable": [ + "get telemetry.mode.base", + "get telemetry.mode.loc", + "get telemetry.mode.env", + "get max.neighbours", + "get rx.delay", + "get tx.delay" + ], + "acl_comparison_available": true, + "acl_entries_preserved": 1, + "trusted_signer_list_preserved": true + }, + "source_summary_sha256": "f3f0dbb415541f058f76f17604b0f91d6aceccad9533a93c604a2358c634b25d" + } + ], + "emergency_trials": [], + "retained_notes": [ + "Legacy password and ACL were unavailable before the upgrade; preservation is not claimed.", + "First RF admission saw idle flag8 and sent zero RF requests. One documented warm preparation preceded the finite three-request RF pass.", + "Current before/after warm settings and one current ACL entry were preserved; this does not recover the unavailable legacy comparison." + ] + } + ], + "collector_live_flow": { + "schema": 1, + "passed": true, + "rf_send_attempts": 1, + "automatic_retries": 0, + "receiver_usb_opened": false, + "logging_settings_changed": false, + "offline_queue_reads": 0, + "channel_setters": 0, + "frequency_setters": 0, + "broker_external_receipt_proved": false, + "started_utc": "2026-10-28T15:25:58.659076+00:00", + "sink_source_commit": "70c7d5b478b19f16d232389779ca455e73be7eba", + "sender_source_commit": "c9acec474bb9617d4f92dbdc0401765701989288", + "collector_ownership_and_config_verified": true, + "packet_payload_sha256": "682707a94c76404cdfbed77f803e23df689e623ec8174196942e80623b4caee1", + "sender_packet_delta": { + "received": 0, + "sent": 1, + "sent_flood": 0, + "sent_direct": 1, + "received_flood": 0, + "received_direct": 0, + "receive_errors": 0 + }, + "exact_owned_raw_payload_seen": true, + "following_rx_metadata_seen": true, + "existing_packet_publish_call_succeeded": true, + "raw_to_publish_journal_records": 3, + "journal_evidence_sha256": "be7d5a47a3bfb79922720f6164beaad16d749d607e4b8fcae385a33a2249f7c9", + "collector_still_original_owner": true, + "original_sender_tx_restored": true, + "finished_utc": "2026-10-28T15:26:07.913874+00:00" + }, + "collector_restorations": [ + { + "passed": true, + "started_utc": "2026-10-28T15:15:29.760380+00:00", + "finished_utc": "2026-10-28T15:20:07.186026+00:00", + "collector_originally_active": true, + "collector_config_files_equal": true, + "collector_serial_owned_after_restore": true, + "collector_restored": true, + "results": [ + { + "board_key": "solarxiao33s", + "passed": true, + "exit_code": 0, + "source_summary_sha256": "43b47592c54d7c8be47eba754aaff703aebd56ab19e9d8116b5b067d4037fcc4" + }, + { + "board_key": "rak3401", + "passed": true, + "exit_code": 0, + "source_summary_sha256": "53dbd02d5f78dca7e4adb3b8fc4bb306a1098eee872b3caefec625143fa7c71f" + } + ], + "attempt": "collector-owned-emergency-followup-run1", + "source_summary_sha256": "e5d2ab916675da829f4d77b3857ead47c415bbeefb68a321cf880ef3d9d0cbdb" + }, + { + "passed": false, + "started_utc": "2026-10-28T14:45:07.191818+00:00", + "finished_utc": "2026-10-28T14:45:30.917270+00:00", + "collector_originally_active": true, + "collector_config_files_equal": true, + "collector_serial_owned_after_restore": true, + "collector_restored": true, + "results": [ + { + "phase": "RAK3401RF", + "passed": false, + "rounds_completed": 0, + "requests_attempted": 0, + "exit_code": 1 + }, + { + "phase": "solarxiao33sEmergency", + "passed": false, + "exit_code": 1 + } + ], + "failure_type": "RuntimeError", + "attempt": "collector-owned-rf-emergency-run1", + "source_summary_sha256": "8c1dad31da912d3d187f31873f2da1a724afd3570485a7d8a93168a86b373dab" + }, + { + "passed": false, + "started_utc": "2026-10-28T14:50:48.270065+00:00", + "finished_utc": "2026-10-28T14:50:58.855600+00:00", + "collector_originally_active": true, + "collector_config_files_equal": true, + "collector_serial_owned_after_restore": true, + "collector_restored": true, + "results": [ + { + "phase": "solarxiao33sWarmPreparation", + "passed": false, + "reboot_requests": 0 + } + ], + "failure_type": "RuntimeError", + "attempt": "collector-owned-rf-emergency-run2", + "source_summary_sha256": "30d74e0af1c72f1b230bcfc06159ac22044f917386a31f3d513acce0b51f4c0e" + }, + { + "passed": false, + "started_utc": "2026-10-28T14:57:00.023532+00:00", + "finished_utc": "2026-10-28T15:01:13.035455+00:00", + "collector_originally_active": true, + "collector_config_files_equal": true, + "collector_serial_owned_after_restore": true, + "collector_restored": true, + "results": [ + { + "phase": "solarxiao33sWarmPreparation", + "passed": true, + "reboot_requests": 1 + }, + { + "phase": "rak3401WarmPreparation", + "passed": true, + "reboot_requests": 1 + }, + { + "phase": "RAK3401RF", + "passed": true, + "rounds_completed": 3, + "requests_attempted": 3, + "exit_code": 0 + }, + { + "phase": "solarxiao33sEmergency", + "passed": false, + "exit_code": 1 + } + ], + "failure_type": "RuntimeError", + "attempt": "collector-owned-rf-emergency-run3", + "source_summary_sha256": "ab84816b627a87abd922016898489e8893744b2b7eb3731bab2573824c725f97" + } + ], + "previous_export_sha256": "488e3d5c781bdb2483e778893e74b84c7a570564ba759ef52d7c38d0c0f495cf" + }, + "final_ownership": { + "schema": 1, + "checked_utc": "2026-10-28T17:18:03.976137+00:00", + "hardware_ports_opened": 0, + "intentional_rf_transmissions": 0, + "ble_connections_requested": 0, + "collector_active": true, + "collector_file_digests_checked": 5, + "collector_all_original_digests_equal": true, + "collector_exact_port_owners_in_original_service_cgroup": true, + "collector_owner_processes": 1, + "t1000_usb_authorized": true, + "active_bench_processes": 0, + "all_owned_serial_workers_finished": true + }, + "separate_comparison_followups": { + "v43": { + "schema": 1, + "passed": true, + "method": "offline corrected static radio2 comparison only", + "new_hardware_access": false, + "new_rf_send_commands": 0, + "original_failed_summary_preserved": true, + "original_summary_sha256": "024759290a84c836196453089785c5351866df55894e539840b1f7e46b590b61", + "original_failure_type": "RuntimeError", + "comparison_driver_sha256": "72f6dbb4a8b57de377f0e0a7c56af8c782cea5815025183df068c37d37ac3ff0", + "followup_driver_sha256": "032f6f70241d8035a38adbf741e65d6f97e68a185ab6e25d1c68ff3ef05b7665", + "six_source_identity_nonce_counter_bound_zero_hop_legs_verified": true, + "both_owned_radio_tx_restorations_verified": true, + "comparisons": { + "tag": { + "saved_getters_equal": true, + "channel_names_and_keys_equal": true, + "contact_static_records_equal": true, + "contact_live_timestamp_changes": 0, + "binary_preferences_equal": true, + "offline_queue_reads": 0 + }, + "v43": { + "saved_getters_equal": true, + "channel_names_and_keys_equal": true, + "contact_static_records_equal": true, + "contact_live_timestamp_changes": 0, + "binary_preferences_equal": true, + "offline_queue_reads": 0 + } + }, + "evidence": { + "tag": { + "before_sha256": "a9f4e8fb8703d90b2949e9261fa4d418378e988a6a0f0a6e4c9cc88aac9c8c09", + "after_sha256": "a9f4e8fb8703d90b2949e9261fa4d418378e988a6a0f0a6e4c9cc88aac9c8c09" + }, + "v43": { + "before_sha256": "3607af154db1a09c3426321ace1c74ef85ed4529eff0c0879d7855e608d70e22", + "after_sha256": "9f298bf5a5b14c8ba5dff989f96831cb9c81adb77653bd0b3254b0545f6ec071" + } + } + }, + "xiao_nrf": { + "schema": 1, + "passed": true, + "method": "offline corrected battery diagnostic comparison only", + "new_hardware_access": false, + "new_rf_send_commands": 0, + "original_failed_summary_preserved": true, + "original_recovery_flag_value": false, + "original_recovery_record_unchanged": true, + "original_recovery_record_sha256": "86c4fe6d5fa2471071f5d52e54ae5254807c1a23f6ed37f56833734c382d16a8", + "original_summary_sha256": "1de4079e52b283690ad790be0cc4049e63742f00226c916232fed2398165da00", + "original_campaign_driver_sha256": "72f6dbb4a8b57de377f0e0a7c56af8c782cea5815025183df068c37d37ac3ff0", + "comparison_driver_sha256": "c1968736f088a76c740dd20428a4d1ab65e9bb5b39a3f883d199d3fc273547d4", + "followup_driver_sha256": "68ad9d8485c03b141717315d6700daef56a4d86bd8eb3e5548c9248a00c28d11", + "three_source_identity_tag_counter_bound_zero_hop_discovery_rounds_verified": true, + "radio2_extensions_match_current_cli": true, + "saved_settings_and_forwarding_gates_equal": true, + "normal_radio_restore_acknowledged": true, + "temporary_profile_observed_off": true, + "schedule_cancellation_inferred_from_reviewed_normalradio_handler": true, + "explicit_post_cleanup_empty_schedule_getter_observed": false, + "live_battery_measurement_changed": true, + "hub_cleanup": { + "saved_getters_equal": true, + "channel_names_and_keys_equal": true, + "contact_static_records_equal": true, + "contact_live_timestamp_changes": 0, + "binary_preferences_equal": true, + "offline_queue_reads": 0 + }, + "cleanup_transcript_sha256": "5e807f6cee6a9e2ac731615206a6333f6eca37e497230407f3677796f55448d5" + } + }, + "historical_initial_build_audit": { + "schema": 1, + "audit": "independent source/build/public-whitelist check", + "passed": true, + "board_plans": 15, + "packaged_profiles": 20, + "hashed_packages": 40, + "both_manifest_hashes_match": true, + "four_nrf_installed_zip_hashes_match_verified_packages": true, + "public_json_build_section_exact": true, + "public_json_private_path_username_mac_scan_clean": true, + "hardware_accessed": false + }, + "source_report_sha256": { + "verified-builds.safe.json": "af63233ec159510509f33d77bb351245679be6f2c511d5c7b7e585659516c452", + "companion-summary.safe.json": "53e4e11ec8f1166955649359075a4c920cf29d5e275203c531b43bd24b1d7177", + "nrf-infra-summary.safe.json": "0a954bc83a25d186701d8808a8a0cffd0a556933dfe3976bcc06f77f965b300a", + "esp-usb-results.safe.json": "8f86160c40eaf87057e5c1d7ea2866025476318541596c7fb461d0144764327b", + "companion-226-summary.safe.json": "c5d0e0edea12b8b95cbd06aa9e988c096e74865f0a067a56564268a773eb9389", + "ble-handover-builds.safe.json": "23b6d3b1c1216595f05b86af474f89ace57279bf20c134f4d5479ce932450334", + "rak4631-candidate70-followup.safe.json": "2adfff79b8d22ea14c5d1d6d85f07b4ff54a034f2a03d3e4c647d5df096f3e33", + "infrastructure-final-summary.safe.json": "a50e66f8811b2c7a6de7195bbc3aed786ad0a94b6af410b7ce22c848285f4dba", + "extra-5a72a991/build_status.json": "f4fb2af886881808a373fda5d116bb1a5487e2047157dcb8cf5da062d806f5fa", + "rak-ci-final.safe.json": "ec3a7887e4639858821c454046bcd69462efa6af4a2af62c3dcc455abdcc5409", + "campaign-final-ownership.safe.json": "8cb3521a9c0850eb364b961030e7d65a26bd7a7c97e328929b4e35b84821a6de" + }, + "unqualified_boards": [ + { + "board": "Seeed XIAO ESP32-S3 WIO", + "candidate_installed": false, + "historical_role": "observer Repeater", + "historical_source_short": "36d9735e5", + "application_probe_responded": false, + "rom_probe_identity_not_established": true, + "physical_boot_reset_attempted": true, + "physical_boot_held_powerup_attempted": true, + "application_usb_descriptor_returned_after_both_attempts": true, + "mac_bound_ip_not_found": true, + "http_probe_sent": false, + "flash_reads_completed": 0, + "flash_writes_completed": 0 + } + ], + "limitations": [ + "Finite shipping-firmware bench checks; not every firmware feature.", + "Build capacity and read-only OTA readiness do not prove an actual LoRa update installation.", + "No RF range, calibrated TX power, current, battery state-of-charge, MPPT charging, display pixel or cold-power qualification.", + "T1000 USB host deauthorization is not a physical battery-only cable-unplug trial.", + "Earlier failed attempts and harness refusals remain separate from later measurements.", + "Installed source commits differ by board and test axis; historical C9 results do not qualify the corrected226 image.", + "All14 installations pass finite USB checks; this does not imply all BLE/RF axes pass." + ], + "corrected_companion_unique_rf_trials": { + "source_commit": "226ccc332d29e78c3dc69cdb70a228baca403711", + "pairs": 3, + "legs": 18, + "maximum_elapsed_seconds": 0.524, + "all_passed": true, + "report_sha256": [ + "37857800ab9560015192edb42e8b20033db7aaaeaa604d9fe243e29646a6606b", + "8f2a793f984e5b45d2c1b8b60dd5e811bf215f669f8e3ab16505df60c8217729", + "c6615cf9e548fe9f7ebbc50b2ef7e37c5c57b7b478f860aee94720b88bfd20c9" + ], + "note": "The same pair is referenced by both boards; these counts deduplicate report digests." + }, + "installed_package_provenance": [ + { + "board": "Heltec V4.3 OLED FEM off", + "target": "heltec_v4_3_companion_radio_full_femoff", + "source_commit": "c9acec474bb9617d4f92dbdc0401765701989288", + "artifact_kind": "application BIN", + "artifact_sha256": "fb320cf886e56b577e07f792a2d505213f0faf63e51305f5fab5ca120010f8d2" + }, + { + "board": "Seeed Tracker T1000-E", + "target": "t1000e_companion_radio_full", + "source_commit": "226ccc332d29e78c3dc69cdb70a228baca403711", + "artifact_kind": "DFU ZIP", + "artifact_sha256": "65c8d9c7f0927890cf25d54322d499dd7a6b5d4d6ce6e6e2a677f973716db4eb" + }, + { + "board": "RAK WisMesh Tag", + "target": "RAK_WisMesh_Tag_companion_radio_full", + "source_commit": "226ccc332d29e78c3dc69cdb70a228baca403711", + "artifact_kind": "DFU ZIP", + "artifact_sha256": "c9da30b5d898c61587982c58b19e2f3593246a21a75f16599948dc965a2741db" + }, + { + "board": "Heltec T096", + "target": "Heltec_t096_companion_radio_full_femon", + "source_commit": "226ccc332d29e78c3dc69cdb70a228baca403711", + "artifact_kind": "DFU ZIP", + "artifact_sha256": "3e0ad88ca18a1f85811f0dbfc440466a11735c8c6e9db951265a8815c020d9d4" + }, + { + "board": "MeshPocket", + "target": "Mesh_pocket_companion_radio_full", + "source_commit": "226ccc332d29e78c3dc69cdb70a228baca403711", + "artifact_kind": "DFU ZIP", + "artifact_sha256": "4f85629da02a3826b8548a106fa199262408237c9f44aa78e950f5ab7ff1a994" + }, + { + "board": "Heltec MeshTower V2 SD", + "target": "Heltec_tower_v2_sdcard_repeater_lora_ota_no_external_sensors-full-ota", + "source_commit": "70c7d5b478b19f16d232389779ca455e73be7eba", + "artifact_kind": "DFU ZIP", + "artifact_sha256": "3aabda910a3b582c6c339bdc09612ccc9b1caa9ef5d9275439929a8acd8acfe9" + }, + { + "board": "SolarXiao 33S", + "target": "solarxiao_33S_repeater-full-ota", + "source_commit": "70c7d5b478b19f16d232389779ca455e73be7eba", + "artifact_kind": "DFU ZIP", + "artifact_sha256": "61543eb675c2ed3eb8d55d03cd78b5ce4806817178a020f62d7d209353085f50" + }, + { + "board": "RAK3401", + "target": "RAK_3401_repeater_lora_ota_no_external_sensors-full-ota", + "source_commit": "70c7d5b478b19f16d232389779ca455e73be7eba", + "artifact_kind": "DFU ZIP", + "artifact_sha256": "13ddc99a9fea2650880303e01ad658e0974129ca145ad0d11d2efceb288de045" + }, + { + "board": "Seeed XIAO nRF52840", + "target": "Xiao_nrf52_repeater-full-ota", + "source_commit": "70c7d5b478b19f16d232389779ca455e73be7eba", + "artifact_kind": "DFU ZIP", + "artifact_sha256": "48c81181a776519da471557c3fcb330f4427254c2adebd9752bca2dc3c28bc50" + }, + { + "board": "RAK4631", + "target": "RAK_4631_repeater_lora_ota_no_external_sensors-full-ota", + "source_commit": "70c7d5b478b19f16d232389779ca455e73be7eba", + "artifact_kind": "DFU ZIP", + "artifact_sha256": "915ced25f967ec302eacd218fdc10ee776eca579e71f61bdb89a3bc705f189e6" + }, + { + "board": "Heltec V3", + "target": "Heltec_v3_repeater_observer_mqtt", + "source_commit": "c9acec474bb9617d4f92dbdc0401765701989288", + "artifact_kind": "application BIN", + "artifact_sha256": "d983f8d27adb4a7d5fa9e9bd4ea6fc39925e7a54f75ae4a90f8dbb5ea68423ee" + }, + { + "board": "LilyGo T-Deck", + "target": "LilyGo_TDeck_repeater", + "source_commit": "c9acec474bb9617d4f92dbdc0401765701989288", + "artifact_kind": "application BIN", + "artifact_sha256": "83a73ddd7add2a98fef58b2a7c1335157e7ee4633d959923d78f876ec2f6b9d2" + }, + { + "board": "SenseCAP Indicator", + "target": "SenseCapIndicator-LoRa_repeater", + "source_commit": "c9acec474bb9617d4f92dbdc0401765701989288", + "artifact_kind": "application BIN", + "artifact_sha256": "78c494a116f4a233a9d8c72be2a80fbf0bf71506028723cec18b358dac1fd2ab" + }, + { + "board": "Heltec V4.2", + "target": "heltec_v4_repeater_observer_mqtt", + "source_commit": "c9acec474bb9617d4f92dbdc0401765701989288", + "artifact_kind": "application BIN", + "artifact_sha256": "ba151fab2c6e6eca6b3025bb2df394fd250372faee0b4c5966291c72ebc3ab29" + } + ], + "installed_board_source_kind_hash_tuples_match_verified_full_builds": true +} diff --git a/docs/test-results/index.md b/docs/test-results/index.md index c06696ea8..35e7d1fd1 100644 --- a/docs/test-results/index.md +++ b/docs/test-results/index.md @@ -58,6 +58,7 @@ Completed validation reports, experiments, and test guides are collected here. O ## Companion, USB, BLE, and display +- [Mercerwood 1.18.0.2 candidate checks, 2026-10-28](hardware_validation_mercerwood_1_18_0_2_2026-10-28.md) - [Mercerwood qualification: 12 active radios, 2026-10-20](hardware_validation_mercerwood_1_18_0_1_2026-10-20.md) - [Mercerwood Companion qualification follow-up, 2026-10-19](companion_qualification_followup_2026-10-19.md) - [ESP32 management password hardware check, 2026-10-19](esp32_management_password_2026-10-19.md) From 5e65c521703de5d2b6c66dd947f2c3062c40083f Mon Sep 17 00:00:00 2001 From: mikecarper Date: Wed, 28 Oct 2026 10:53:11 -0700 Subject: [PATCH 40/61] Cover Cardputer in release recipe tests --- test/test_build_profiles.sh | 34 ++++++++++++++++++++++++++++---- test/test_esp32_hwcdc_recipes.py | 25 ++++++++++++++++++++++- 2 files changed, 54 insertions(+), 5 deletions(-) diff --git a/test/test_build_profiles.sh b/test/test_build_profiles.sh index 05fd4c398..63e164aec 100644 --- a/test/test_build_profiles.sh +++ b/test/test_build_profiles.sh @@ -9,6 +9,16 @@ fail() { exit 1 } +# These are the only current ESP32 Companion recipes without a qualified Full +# replacement. Keep inventory assertions consistent without exempting boards. +is_existing_cardputer_companion_recipe() { + case "$1" in + M5Stack_Cardputer_Adv_companion_radio_usb|M5Stack_Cardputer_Adv_companion_radio_ble) + return 0 ;; + esac + return 1 +} + # Optional debug settings must remain usable by strict shell callers before # any PlatformIO flags have been initialized. ( @@ -529,9 +539,21 @@ init_project_context >/dev/null || fail "nRF52 menu depends on alias input order" ) -# Expanded partitions make Full the ordinary ESP32 node release. Legacy lean -# names still address installed firmware and must remain directly buildable, -# but cannot reappear in either the all-role or a role-specific release list. +# Cardputer's existing UI recipes have not qualified a combined Full profile. +# Keep both exact transport recipes available in its ordinary release lists. +for cardputer_env in M5Stack_Cardputer_Adv_companion_radio_usb \ + M5Stack_Cardputer_Adv_companion_radio_ble; do + [ "$(get_pio_build_env "$cardputer_env")" = "$cardputer_env" ] \ + || fail "$cardputer_env lost its exact board recipe" + for resolver in resolve_all_firmwares resolve_companion_firmwares; do + grep -Fxq "$cardputer_env" <<<"$("$resolver")" \ + || fail "$resolver omitted supported Cardputer recipe $cardputer_env" + done +done + +# Expanded partitions make Full the ordinary qualified ESP32 node release. +# Legacy lean names remain directly buildable but must not reappear beside +# their qualified Full replacement in all-role or role-specific release lists. for resolver in resolve_all_firmwares resolve_repeater_firmwares \ resolve_room_server_firmwares resolve_sensor_firmwares; do while IFS= read -r release_env; do @@ -541,6 +563,9 @@ for resolver in resolve_all_firmwares resolve_repeater_firmwares \ fi is_redundant_bulk_build_target "$release_env" \ && fail "$resolver republishes utility or legacy alias $release_env" + if is_existing_cardputer_companion_recipe "$release_env"; then + continue + fi if is_kiss_modem_target "$release_env"; then if is_esp32_full_release_build_target "$release_env"; then fail "$release_env host modem was promoted into the node Full profile" @@ -1296,7 +1321,8 @@ MQTT_BRIDGE_OVERRIDE="" while IFS= read -r env_name; do if [ "${PIO_ENV_PLATFORM_BY_NAME[$env_name]:-}" = ESP32_PLATFORM ] \ - && ! is_companion_radio_full_target "$env_name"; then + && ! is_companion_radio_full_target "$env_name" \ + && ! is_existing_cardputer_companion_recipe "$env_name"; then fail "canonical ESP32 Companion inventory still contains $env_name" fi done < <(resolve_companion_firmwares) diff --git a/test/test_esp32_hwcdc_recipes.py b/test/test_esp32_hwcdc_recipes.py index 8a68d01a3..48b2ebca0 100644 --- a/test/test_esp32_hwcdc_recipes.py +++ b/test/test_esp32_hwcdc_recipes.py @@ -86,6 +86,7 @@ UNCHANGED_COUNTS = { ("heltec_v4_r8", "hwcdc"): 25, ("heltec_wifi_lora_32_V2", "classic_uart"): 9, ("meshnology_w12", "hwcdc"): 12, + ("m5stack-stamps3", "hwcdc"): 4, ("seeed_xiao_esp32c3", "hwcdc"): 7, ("seeed_xiao_esp32s3", "hwcdc"): 26, ("seeed_xiao_esp32s3_migrator", "hwcdc"): 1, @@ -113,6 +114,7 @@ EXTERNAL_BOARD_DEFAULTS = { "esp32doit-devkit-v1": ("esp32", []), "esp32s3box": ("esp32s3", ["-DARDUINO_USB_MODE=1", "-DARDUINO_USB_CDC_ON_BOOT=1"]), "heltec_wifi_lora_32_V2": ("esp32", []), + "m5stack-stamps3": ("esp32s3", ["-DARDUINO_USB_MODE=1"]), "seeed_xiao_esp32c3": ("esp32c3", ["-DARDUINO_USB_MODE=1", "-DARDUINO_USB_CDC_ON_BOOT=1"]), "seeed_xiao_esp32s3": ("esp32s3", ["-DARDUINO_USB_MODE=1", "-DARDUINO_USB_CDC_ON_BOOT=1"]), "ttgo-lora32-v1": ("esp32", []), @@ -285,7 +287,7 @@ class Esp32HwcdcRecipeTests(unittest.TestCase): self.assertEqual(self.sdk["hwcdc_primary_enabled"](row["env"]), row["kind"] == "hwcdc") self.assertFalse(self.sdk["native_primary_enabled"](row["env"])) - expected = Counter({"hwcdc": 338, "uart": 122, "classic_uart": 61}) + expected = Counter({"hwcdc": 342, "uart": 122, "classic_uart": 61}) expected.update(kind for _, _, kind in USB_PARTITION_BRIDGES.values()) self.assertEqual(counts, expected) @@ -358,6 +360,27 @@ class Esp32HwcdcRecipeTests(unittest.TestCase): self.assertEqual(usb_values(env), {USB_MODE: "0", USB_CDC: "0"}) self.assertFalse(self.sdk["hwcdc_primary_enabled"](env)) + def test_cardputer_roles_keep_native_usb_including_the_ble_companion(self): + names = {name for name in self.rows if name.startswith("M5Stack_Cardputer_Adv_")} + self.assertEqual(names, { + "M5Stack_Cardputer_Adv_repeater", "M5Stack_Cardputer_Adv_room_server", + "M5Stack_Cardputer_Adv_companion_radio_usb", + "M5Stack_Cardputer_Adv_companion_radio_ble", + }) + for name in names: + with self.subTest(environment=name): + row = self.rows[name] + self.assertEqual((row["board"], row["mcu"], row["kind"]), + ("m5stack-stamps3", "esp32s3", "hwcdc")) + self.assertEqual(row["values"], {USB_MODE: "1", USB_CDC: "1"}) + self.assertTrue(self.sdk["hwcdc_primary_enabled"](row["env"])) + self.assertFalse(self.sdk["native_primary_enabled"](row["env"])) + env = flag_environment(EXTERNAL_BOARD_DEFAULTS["m5stack-stamps3"][1], + self.config.get(f"env:{name}", "build_flags"), + ["-DARDUINO_USB_CDC_ON_BOOT=1"]) + self.assertEqual(usb_values(env), {USB_MODE: "1", USB_CDC: "0"}) + self.assertFalse(self.sdk["hwcdc_primary_enabled"](env)) + def test_uart_existing_hwcdc_and_newer_mcu_recipes_match_reviewed_transports(self): counts = Counter() for name, row in self.rows.items(): From 49f0b4a20e00bf0813a1e602935f3bb47ec8e215 Mon Sep 17 00:00:00 2001 From: mikecarper Date: Wed, 28 Oct 2026 10:53:11 -0700 Subject: [PATCH 41/61] Update touch help fixture for compact wording --- test/test_touch_debug_cli.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/test_touch_debug_cli.py b/test/test_touch_debug_cli.py index e04b6d3f3..72a1f4e1d 100644 --- a/test/test_touch_debug_cli.py +++ b/test/test_touch_debug_cli.py @@ -164,7 +164,7 @@ int main() { self.assertIn('#include "CompanionHelp.inc"', help_body) self.assertIn('mesh::companion::writePackedCompanionHelp(output);', help_body) help_fragments = (ROOT / 'examples/companion_radio/CompanionHelp.inc').read_text() - self.assertIn('get/set display.touch (this boot)\\r\\n', help_fragments) + self.assertIn('get/set display.touch (boot)\\r\\n', help_fragments) packed_help = (ROOT / 'examples/companion_radio/CompanionHelpPacked.cpp').read_text() self.assertIn('#include "CompanionHelp.inc"', packed_help) # No persistent preference schema or write is involved. From 4518f3db81fbaf8cb06b574a7f87e3a7328b7f46 Mon Sep 17 00:00:00 2001 From: mikecarper Date: Wed, 28 Oct 2026 12:09:40 -0700 Subject: [PATCH 42/61] Add daylight and daily operating hours to power scheduling --- .github/workflows/run-unit-tests.yml | 2 + docs/cli_commands.md | 65 ++++- src/helpers/CommonCLI.h | 9 + src/helpers/CommonCLI_Emergency.cpp | 197 +++++++++++++-- src/helpers/OperatingSchedule.h | 127 ++++++++++ src/helpers/SolarDaylight.h | 147 +++++++++++ src/helpers/WirelessControl.h | 2 +- test/test_emergency_power.py | 26 +- test/test_emergency_power_runtime.py | 71 +++++- test/test_power_schedule.py | 357 +++++++++++++++++++++++++++ test/test_solar_daylight.py | 159 ++++++++++++ tools/hil/emergency_power_bench.py | 2 +- 12 files changed, 1142 insertions(+), 22 deletions(-) create mode 100644 src/helpers/OperatingSchedule.h create mode 100644 src/helpers/SolarDaylight.h create mode 100644 test/test_power_schedule.py create mode 100644 test/test_solar_daylight.py diff --git a/.github/workflows/run-unit-tests.yml b/.github/workflows/run-unit-tests.yml index 696354f75..ba4eea7d8 100644 --- a/.github/workflows/run-unit-tests.yml +++ b/.github/workflows/run-unit-tests.yml @@ -596,6 +596,8 @@ jobs: python3 -B test/test_regulator_settings.py -v python3 -B test/test_battery_forwarding.py -v python3 -B test/test_emergency_power.py -v + python3 -B test/test_solar_daylight.py -v + python3 -B test/test_power_schedule.py -v python3 -B test/test_emergency_power_runtime.py -v python3 -B test/test_emergency_power_ota.py -v python3 -B test/test_emergency_power_bench.py -v diff --git a/docs/cli_commands.md b/docs/cli_commands.md index d345445bf..42e08cc80 100644 --- a/docs/cli_commands.md +++ b/docs/cli_commands.md @@ -3172,8 +3172,9 @@ LoRa commands must arrive during an awake window. Local serial commands remain available through maintenance wakes while the radio sleeps. `get power.emergency` reports the mode, battery estimate, desired `window`, -actual radio ownership, seconds until the next awake window, and remaining -hold. With an implausible clock timestamp it reports `clock=uptime` and follows +actual radio ownership, scheduled wake/check delay (`wake`), and remaining +hold. Its window includes any enabled operating-hours or daylight filters. +With an implausible clock timestamp it reports `clock=uptime` and follows the same 15/45-minute cycle from boot. Keep the clock set to UTC: a plausible timestamp alone does not prove it is synchronized. Queued future traffic does not keep a critically low node awake indefinitely. @@ -3183,6 +3184,66 @@ WiFi/ESP-NOW services are temporarily stopped and their previous live subset is restored when the node wakes. Companion builds and flash-constrained profiles that omit battery-forwarding controls do not add this feature. +#### Operating hours and daylight + +The same supported infrastructure images can sleep outside daylight or a +daily time window, even when emergency mode is inactive. Both filters default +to **off** and are saved across reboots. If both are enabled, the node must be +inside the time window **and** daylight. Active emergency mode further limits +operation to the first 15 minutes of each UTC hour. + +For daylight-only operation, starting from the defaults: + +```text +set power.daylight on +get power.schedule +``` + +Daylight is calculated from the UTC clock and the node's saved `lat`/`lon`, +falling back to a fresh cached GPS fix when the saved position is unset. For +a fixed installation, saved coordinates keep the schedule independent of GPS +freshness. This does not start GPS. Sunrise and sunset are approximate; cloud +cover, panel output and charging status do not determine this schedule. Set +the node's position and synchronize its UTC clock before relying on daylight +control. + +For a daily operating window: + +```text +set power.hours 08:00-18:00 +``` + +The start is included and the end excluded. Overnight windows such as +`22:00-06:00` work, and an end of `24:00` is accepted. Equal start/end times +are rejected; use `set power.hours off` for unrestricted hours. +Times use UTC by default. `set power.hours.offset -420`, for example, makes +the window use UTC-7. The offset is minutes east of UTC, from -720 to +840; +it is fixed and does not adjust for daylight saving time. It affects only the +hour window, not the UTC clock, solar calculation or emergency hourly cycle. + +To combine daylight with the emergency hourly duty cycle: + +```text +set power.daylight on +set power.emergency on +``` + +`get power.schedule` shows the combined policy. Its `check` value is the next +candidate schedule decision; another filter may keep the node asleep afterward. +The individual settings also have `get power.daylight`, `get power.hours` and +`get power.hours.offset` readbacks. `set power.schedule off` disables both +operating filters; it does not disable manual emergency mode or automatic +low-battery protection. + +An unknown clock leaves the time-based filters open. A missing valid position +leaves the daylight filter open; an independently configured hours filter +still applies. Status reports the unavailable input rather than guessing. +The existing emergency fallback to a boot-relative cycle remains in force. +`set power.awake ` overrides all these schedules temporarily; +hard low-voltage protection and existing active-operation sleep deferrals +remain in force. These commands use the same USB/admin CLI and authorized +management-command paths as `power.emergency`. + #### Set the battery percentage voltage range The forwarding thresholds use a voltage-derived percentage. On infrastructure diff --git a/src/helpers/CommonCLI.h b/src/helpers/CommonCLI.h index c46f4c52d..29c40d03a 100644 --- a/src/helpers/CommonCLI.h +++ b/src/helpers/CommonCLI.h @@ -21,6 +21,9 @@ #include #include #include +#if MESH_ENABLE_EMERGENCY_POWER +#include +#endif #if defined(NRF52_PLATFORM) && defined(OTA_SD_STORE) #include #endif @@ -948,6 +951,12 @@ class CommonCLI { bool saveEmergencyPower(const mesh::emergencypower::Settings& settings); void loopEmergencyPower(); bool handleEmergencyPowerCommand(const char* command, char* reply); + FILESYSTEM* _power_schedule_fs = nullptr; + mesh::powerschedule::State _power_schedule; + void loadPowerSchedule(FILESYSTEM* fs); + bool savePowerSchedule(const mesh::powerschedule::Settings& settings); + bool handlePowerScheduleCommand(const char* command, char* reply); + uint32_t powerScheduleSleepMilliseconds(uint32_t epoch); #endif uint16_t batteryEmptyMv() const; uint16_t batteryFullMv() const; diff --git a/src/helpers/CommonCLI_Emergency.cpp b/src/helpers/CommonCLI_Emergency.cpp index 74b1bd407..c4bf68a21 100644 --- a/src/helpers/CommonCLI_Emergency.cpp +++ b/src/helpers/CommonCLI_Emergency.cpp @@ -2,6 +2,7 @@ #if MESH_ENABLE_EMERGENCY_POWER #include "CheckedSettingsFile.h" +#include "FleetLocation.h" #if defined(NRF52_PLATFORM) || defined(STM32_PLATFORM) #include "AtomicFileWriter.h" #else @@ -10,6 +11,26 @@ namespace { const char* const EMERGENCY_POWER_PATH = "/power_emergency"; +const char* const POWER_SCHEDULE_PATH = "/power_schedule"; + +bool writePowerSettings(FILESYSTEM* fs, const char* path, const uint8_t* bytes, size_t size) { + if (!fs) return false; +#if defined(NRF52_PLATFORM) + return mesh::storage::writeCheckedSettingsFile(fs, path, bytes, size) + == mesh::AtomicFileWriter::Outcome::Committed; +#else +#if defined(STM32_PLATFORM) + mesh::AtomicFileWriter writer(fs, path); +#else + mesh::ContactFileTransaction writer(fs, path); +#endif + if (!writer || writer.write(bytes, size) != size || !writer.commit()) return false; + uint8_t verified[mesh::powerschedule::RecordSize]; + return size <= sizeof(verified) + && mesh::storage::readFixedSettingsFile(fs, path, verified, size) + && memcmp(bytes, verified, size) == 0; +#endif +} bool readEmergencyPower(FILESYSTEM* fs, const char* path, mesh::emergencypower::Settings& settings) { @@ -34,6 +55,53 @@ bool emergencyValue(const char* value, const char* expected) { while (*value == ' ' || *value == '\t') ++value; return !*value; } + +bool operatingHours(const char* value, mesh::powerschedule::Settings& settings) { + size_t length = strlen(value); + while (length && (value[length - 1] == ' ' || value[length - 1] == '\t')) --length; + if (length != 11 || value[2] != ':' || value[5] != '-' || value[8] != ':') return false; + uint16_t minute[2]; + for (unsigned i = 0; i < 2; ++i) { + const char* p = value + i * 6; + if (p[0] < '0' || p[0] > '9' || p[1] < '0' || p[1] > '9' + || p[3] < '0' || p[3] > '9' || p[4] < '0' || p[4] > '9') return false; + const unsigned hour = unsigned(p[0] - '0') * 10 + unsigned(p[1] - '0'); + const unsigned min = unsigned(p[3] - '0') * 10 + unsigned(p[4] - '0'); + if (min >= 60 || hour > (i ? 24U : 23U) || (hour == 24 && min)) return false; + minute[i] = uint16_t(hour * 60 + min); + } + if (minute[0] == minute[1]) return false; + settings.startMinute = minute[0]; settings.endMinute = minute[1]; settings.hours = true; + return true; +} + +bool operatingOffset(const char* value, int16_t& offset) { + const bool negative = *value == '-'; + if (negative || *value == '+') ++value; + const char* first = value; + unsigned number = 0; + while (*value >= '0' && *value <= '9') { + number = number * 10 + unsigned(*value++ - '0'); + if (number > (negative ? 720U : 840U)) return false; + } + if (value == first) return false; + while (*value == ' ' || *value == '\t') ++value; + if (*value) return false; + offset = negative ? -int16_t(number) : int16_t(number); + return true; +} + +const char* daylightLabel(mesh::solar::State state) { + return state == mesh::solar::State::Day ? "day" + : state == mesh::solar::State::Night ? "night" : "unknown"; +} + +void operatingHoursText(const mesh::powerschedule::Settings& settings, char* text, size_t size) { + if (!settings.hours) snprintf(text, size, "off"); + else snprintf(text, size, "%02u:%02u-%02u:%02u", unsigned(settings.startMinute / 60), + unsigned(settings.startMinute % 60), unsigned(settings.endMinute / 60), + unsigned(settings.endMinute % 60)); +} } void CommonCLI::loadEmergencyPower(FILESYSTEM* fs) { @@ -48,26 +116,120 @@ void CommonCLI::loadEmergencyPower(FILESYSTEM* fs) { readEmergencyPower(fs, "/power_emergency.bak", loaded); _emergency_power.begin(loaded, millis()); _emergency_radio_sleeping = false; + loadPowerSchedule(fs); } bool CommonCLI::saveEmergencyPower(const mesh::emergencypower::Settings& settings) { - if (!_emergency_power_fs) return false; uint8_t bytes[mesh::emergencypower::RecordSize]; mesh::emergencypower::encode(bytes, settings); -#if defined(NRF52_PLATFORM) - return mesh::storage::writeCheckedSettingsFile(_emergency_power_fs, EMERGENCY_POWER_PATH, - bytes, sizeof(bytes)) == mesh::AtomicFileWriter::Outcome::Committed; -#else -#if defined(STM32_PLATFORM) - mesh::AtomicFileWriter writer(_emergency_power_fs, EMERGENCY_POWER_PATH); -#else - mesh::ContactFileTransaction writer(_emergency_power_fs, EMERGENCY_POWER_PATH); -#endif - if (!writer || writer.write(bytes, sizeof(bytes)) != sizeof(bytes) || !writer.commit()) return false; - uint8_t verified[sizeof(bytes)]; - return mesh::storage::readFixedSettingsFile(_emergency_power_fs, EMERGENCY_POWER_PATH, - verified, sizeof(verified)) && memcmp(bytes, verified, sizeof(bytes)) == 0; + return writePowerSettings(_emergency_power_fs, EMERGENCY_POWER_PATH, bytes, sizeof(bytes)); +} + +void CommonCLI::loadPowerSchedule(FILESYSTEM* fs) { + _power_schedule_fs = fs; + mesh::powerschedule::Settings loaded; +#if defined(ESP32_PLATFORM) || defined(RP2040_PLATFORM) + if (!mesh::ContactFileTransaction::recover(fs, POWER_SCHEDULE_PATH)) + _power_schedule_fs = nullptr; #endif + if (_power_schedule_fs) { + uint8_t bytes[mesh::powerschedule::RecordSize]; + if (!(mesh::storage::readFixedSettingsFile(fs, POWER_SCHEDULE_PATH, bytes, sizeof(bytes)) + && mesh::powerschedule::decode(bytes, sizeof(bytes), loaded))) { + if (mesh::storage::readFixedSettingsFile(fs, "/power_schedule.bak", bytes, sizeof(bytes))) + mesh::powerschedule::decode(bytes, sizeof(bytes), loaded); + } + } + _power_schedule.begin(loaded); + if (loaded.daylight || loaded.hours) _emergency_power.holdReplies(millis()); +} + +bool CommonCLI::savePowerSchedule(const mesh::powerschedule::Settings& settings) { + uint8_t bytes[mesh::powerschedule::RecordSize]; + mesh::powerschedule::encode(bytes, settings); + return writePowerSettings(_power_schedule_fs, POWER_SCHEDULE_PATH, bytes, sizeof(bytes)); +} + +uint32_t CommonCLI::powerScheduleSleepMilliseconds(uint32_t epoch) { + int32_t latitude = 0, longitude = 0; + const bool located = _power_schedule.settings.daylight + && mesh::readFleetLocation(*_prefs, *_sensors, latitude, longitude); + return _power_schedule.sleepMilliseconds(epoch, located, latitude, longitude); +} + +bool CommonCLI::handlePowerScheduleCommand(const char* command, char* reply) { + const char* value; + if (emergencyCommand(command, "get power.daylight", value)) { + if (*value) snprintf(reply, 157, "Error: use get power.daylight"); + else { + powerScheduleSleepMilliseconds(_rtc->getCurrentTime()); + if (!_power_schedule.settings.daylight) snprintf(reply, 157, "> off"); + else snprintf(reply, 157, "> on state=%s", daylightLabel(_power_schedule.daylightState)); + } + return true; + } + if (emergencyCommand(command, "get power.hours", value)) { + if (*value) snprintf(reply, 157, "Error: use get power.hours"); + else { + char hours[12]; operatingHoursText(_power_schedule.settings, hours, sizeof(hours)); + snprintf(reply, 157, "> %s", hours); + } + return true; + } + if (emergencyCommand(command, "get power.hours.offset", value)) { + if (*value) snprintf(reply, 157, "Error: use get power.hours.offset"); + else snprintf(reply, 157, "> %d", int(_power_schedule.settings.offsetMinutes)); + return true; + } + if (emergencyCommand(command, "get power.schedule", value)) { + if (*value) snprintf(reply, 157, "Error: use get power.schedule"); + else { + const uint32_t budget = emergencySleepMilliseconds(); + char hours[12]; operatingHoursText(_power_schedule.settings, hours, sizeof(hours)); + snprintf(reply, 157, "> daylight=%s state=%s hours=%s offset=%d clock=%s emergency=%s window=%s radio=%s check=%lus hold=%lus", + _power_schedule.settings.daylight ? "on" : "off", daylightLabel(_power_schedule.daylightState), + hours, int(_power_schedule.settings.offsetMinutes), + mesh::emergencypower::State::usesRtc(_rtc->getCurrentTime()) ? "rtc" : "unknown", + emergencyActive() ? "on" : "off", budget ? "sleep" : "awake", + _emergency_radio_sleeping ? "sleep" : "awake", + static_cast((budget + 999UL) / 1000UL), + static_cast((_emergency_power.holdRemaining(millis()) + 999UL) / 1000UL)); + } + return true; + } + mesh::powerschedule::Settings candidate = _power_schedule.settings; + if (emergencyCommand(command, "set power.daylight", value)) { + if (!emergencyValue(value, "on") && !emergencyValue(value, "off")) { + snprintf(reply, 157, "Error: use set power.daylight on|off"); return true; + } + candidate.daylight = emergencyValue(value, "on"); + } else if (emergencyCommand(command, "set power.hours", value)) { + if (emergencyValue(value, "off")) candidate.hours = false; + else if (!operatingHours(value, candidate)) { + snprintf(reply, 157, "Error: use set power.hours HH:MM-HH:MM|off"); return true; + } + } else if (emergencyCommand(command, "set power.hours.offset", value)) { + if (!operatingOffset(value, candidate.offsetMinutes)) { + snprintf(reply, 157, "Error: offset must be -720..840 minutes"); return true; + } + } else if (emergencyCommand(command, "set power.schedule", value)) { + if (!emergencyValue(value, "off")) { + snprintf(reply, 157, "Error: use set power.schedule off"); return true; + } + candidate.daylight = candidate.hours = false; + } else return false; + if ((candidate.daylight || candidate.hours) && !_board->supportsEmergencySleep()) { + snprintf(reply, 157, "Error: scheduled sleep unsupported on this board"); return true; + } + if (!mesh::powerschedule::equal(candidate, _power_schedule.settings)) { + if (!savePowerSchedule(candidate)) { + snprintf(reply, 157, "Error: schedule save unconfirmed; active policy unchanged"); return true; + } + _power_schedule.begin(candidate); + _emergency_power.holdReplies(millis()); + } + snprintf(reply, 157, "OK"); + return true; } void CommonCLI::loopEmergencyPower() { @@ -80,11 +242,16 @@ void CommonCLI::loopEmergencyPower() { uint32_t CommonCLI::emergencySleepMilliseconds() { loopEmergencyPower(); - return _emergency_power.sleepMilliseconds(millis(), _rtc->getCurrentTime()); + const uint32_t now = millis(), epoch = _rtc->getCurrentTime(); + const uint32_t emergency = _emergency_power.sleepMilliseconds(now, epoch); + const uint32_t schedule = powerScheduleSleepMilliseconds(epoch); + if (_emergency_power.holdRemaining(now)) return 0; + return emergency > schedule ? emergency : schedule; } bool CommonCLI::handleEmergencyPowerCommand(const char* command, char* reply) { if (!command || !reply) return false; + if (handlePowerScheduleCommand(command, reply)) return true; const char* value; if (emergencyCommand(command, "get power.emergency", value)) { if (*value) snprintf(reply, 157, "Error: use get power.emergency"); diff --git a/src/helpers/OperatingSchedule.h b/src/helpers/OperatingSchedule.h new file mode 100644 index 000000000..d555c3a57 --- /dev/null +++ b/src/helpers/OperatingSchedule.h @@ -0,0 +1,127 @@ +#pragma once + +#include "EmergencyPowerPolicy.h" +#include "SolarDaylight.h" + +namespace mesh { namespace powerschedule { + +static constexpr size_t RecordSize = 14; +static constexpr uint32_t DaySeconds = 86400; + +struct Settings { + bool daylight = false; + bool hours = false; + uint16_t startMinute = 0; + uint16_t endMinute = 0; + int16_t offsetMinutes = 0; +}; + +inline bool valid(const Settings& settings) { + return settings.startMinute < 1440 && settings.endMinute <= 1440 + && (!settings.hours || settings.startMinute != settings.endMinute) + && settings.offsetMinutes >= -720 && settings.offsetMinutes <= 840; +} + +inline bool equal(const Settings& a, const Settings& b) { + return a.daylight == b.daylight && a.hours == b.hours + && a.startMinute == b.startMinute && a.endMinute == b.endMinute + && a.offsetMinutes == b.offsetMinutes; +} + +inline void encode(uint8_t* out, const Settings& settings) { + out[0] = 'P'; out[1] = 'S'; out[2] = 'C'; out[3] = 1; + out[4] = (settings.daylight ? 1 : 0) | (settings.hours ? 2 : 0); + out[5] = 0; + const uint16_t values[] = {settings.startMinute, settings.endMinute, + uint16_t(settings.offsetMinutes + 720)}; + for (unsigned i = 0; i < 3; ++i) { + out[6 + i * 2] = uint8_t(values[i]); + out[7 + i * 2] = uint8_t(values[i] >> 8); + } + const uint16_t crc = emergencypower::checksum(out, 12); + out[12] = uint8_t(crc); out[13] = uint8_t(crc >> 8); +} + +inline bool decode(const uint8_t* in, size_t size, Settings& settings) { + settings = Settings{}; + if (!in || size != RecordSize || in[0] != 'P' || in[1] != 'S' + || in[2] != 'C' || in[3] != 1 || (in[4] & ~3) || in[5] + || emergencypower::checksum(in, 12) != uint16_t(in[12] | uint16_t(in[13]) << 8)) + return false; + Settings loaded; + loaded.daylight = (in[4] & 1) != 0; + loaded.hours = (in[4] & 2) != 0; + loaded.startMinute = uint16_t(in[6] | uint16_t(in[7]) << 8); + loaded.endMinute = uint16_t(in[8] | uint16_t(in[9]) << 8); + const uint16_t offset = uint16_t(in[10] | uint16_t(in[11]) << 8); + if (offset > 1560) return false; + loaded.offsetMinutes = int16_t(offset) - 720; + if (!valid(loaded)) return false; + settings = loaded; + return true; +} + +struct State { + Settings settings; + uint32_t evaluatedUtc = UINT32_MAX; + uint32_t nextChangeUtc = 0; + int32_t latitude = 0, longitude = 0; + solar::State daylightState = solar::State::Unknown; + bool hasLocation = false; + + void begin(const Settings& loaded) { + *this = State{}; + settings = loaded; + } + void invalidate() { + evaluatedUtc = UINT32_MAX; + nextChangeUtc = 0; + daylightState = solar::State::Unknown; + } + uint32_t sleepMilliseconds(uint32_t epoch, bool located, + int32_t latitude_e6, int32_t longitude_e6) { + if (!emergencypower::State::usesRtc(epoch)) { + invalidate(); + return 0; // Never strand a node whose clock has not been set. + } + uint32_t budget = 0; + if (settings.hours) { + int32_t local = int32_t(epoch % DaySeconds) + int32_t(settings.offsetMinutes) * 60; + if (local < 0) local += DaySeconds; + else if (local >= int32_t(DaySeconds)) local -= DaySeconds; + const int32_t start = int32_t(settings.startMinute) * 60; + const int32_t end = int32_t(settings.endMinute) * 60; + const bool inside = start < end ? local >= start && local < end + : local >= start || local < end; + if (!inside) budget = uint32_t((start - local + DaySeconds) % DaySeconds) * 1000UL; + } + if (settings.daylight) { + // Clock corrections and a changed location invalidate the minute cache. + // A transition within this minute is evaluated again at its boundary. + if (evaluatedUtc / 60 != epoch / 60 || epoch < evaluatedUtc || located != hasLocation + || latitude != latitude_e6 || longitude != longitude_e6 + || (nextChangeUtc && epoch >= nextChangeUtc)) { + const solar::Window window = located ? solar::evaluate(epoch, latitude_e6, longitude_e6) + : solar::Window{solar::State::Unknown, 0}; + evaluatedUtc = epoch; + hasLocation = located; + latitude = latitude_e6; longitude = longitude_e6; + daylightState = window.state; + nextChangeUtc = window.nextChangeUtc; + } + if (daylightState == solar::State::Night) { + uint32_t seconds = nextChangeUtc > epoch ? nextChangeUtc - epoch : 60; + if (seconds > DaySeconds) seconds = DaySeconds; + const uint32_t night = seconds * 1000UL; + if (night > budget) budget = night; + } + } else { + daylightState = solar::State::Unknown; + } + return budget; + } +}; + +static_assert(sizeof(State) <= 32, "Operating schedule must keep its bounded RAM footprint"); + +}} // namespace mesh::powerschedule diff --git a/src/helpers/SolarDaylight.h b/src/helpers/SolarDaylight.h new file mode 100644 index 000000000..7867dff73 --- /dev/null +++ b/src/helpers/SolarDaylight.h @@ -0,0 +1,147 @@ +#pragma once + +#include + +namespace mesh { namespace solar { + +enum class State : uint8_t { Unknown, Day, Night }; + +struct Window { + State state; + // Next sunrise/sunset, or a six-hour reevaluation during polar day/night. + // Saturates at the end of the RTC range instead of wrapping into 1970. + uint32_t nextChangeUtc; +}; + +namespace daylight_detail { + +constexpr int32_t Scale = 32768; +constexpr uint32_t PlausibleEpoch = 1577836800UL; +constexpr uint32_t PolarRecheckSeconds = 6UL * 3600UL; + +// Five-degree samples and linear interpolation keep the daily NOAA Fourier +// calculation integer-only. Angles are millidegrees, results are Q15. +inline int32_t sine(int32_t angle) { + static constexpr uint16_t quarter[] = { + 0, 2856, 5690, 8481, 11207, 13848, 16384, 18795, 21063, 23170, + 25102, 26842, 28378, 29698, 30792, 31651, 32270, 32643, 32768}; + angle %= 360000; + if (angle < 0) angle += 360000; + const bool negative = angle > 180000; + if (negative) angle -= 180000; + if (angle > 90000) angle = 180000 - angle; + const unsigned index = unsigned(angle / 5000); + int32_t value = quarter[index]; + if (index < 18) + value += ((int32_t(quarter[index + 1]) - value) * (angle % 5000) + 2500) / 5000; + return negative ? -value : value; +} + +inline int32_t roundedDivide(int64_t value, int32_t denominator) { + return int32_t((value + (value < 0 ? -denominator / 2 : denominator / 2)) / denominator); +} + +inline bool leapYear(unsigned year) { + return year % 4 == 0 && (year % 100 != 0 || year % 400 == 0); +} + +inline uint32_t clampUtc(int64_t value) { + if (value <= 0) return 0; + if (value >= int64_t(UINT32_MAX)) return UINT32_MAX; + return uint32_t(value); +} + +struct Events { + int64_t sunrise; + int64_t sunset; + State polar; + bool crosses; +}; + +inline Events events(int64_t unix_day, int32_t latitude_e6, int32_t longitude_e6) { + // The caller only supplies dates close to a plausible uint32 RTC epoch. + uint32_t day = uint32_t(unix_day - 10957); // days since 2000-01-01 + unsigned year = 2000; + for (;;) { + const unsigned length = 365 + unsigned(leapYear(year)); + if (day < length) break; + day -= length; + ++year; + } + const unsigned year_days = 365 + unsigned(leapYear(year)); + const int32_t gamma = int32_t((day * 360000UL + year_days / 2) / year_days); + const int32_t s1 = sine(gamma), c1 = sine(gamma + 90000); + const int32_t s2 = sine(gamma * 2), c2 = sine(gamma * 2 + 90000); + const int32_t s3 = sine(gamma * 3), c3 = sine(gamma * 3 + 90000); + + // NOAA fractional-year coefficients, scaled to millidegrees and seconds. + const int32_t declination = 396 + roundedDivide( + -int64_t(22913) * c1 + int64_t(4025) * s1 - int64_t(387) * c2 + + int64_t(52) * s2 - int64_t(155) * c3 + int64_t(85) * s3, Scale); + const int32_t equation_seconds = 1 + roundedDivide( + int64_t(26) * c1 - int64_t(441) * s1 - int64_t(201) * c2 + - int64_t(562) * s2, Scale); + const int32_t latitude = roundedDivide(latitude_e6, 1000); + const int32_t sin_latitude = sine(latitude), cos_latitude = sine(latitude + 90000); + const int32_t sin_declination = sine(declination), cos_declination = sine(declination + 90000); + // Conventional sunrise is solar altitude -0.833 degrees (refraction/limb). + const int64_t numerator = -int64_t(476) * Scale - int64_t(sin_latitude) * sin_declination; + const int64_t denominator = int64_t(cos_latitude) * cos_declination; + if (numerator <= -denominator) return {0, 0, State::Day, false}; + if (numerator >= denominator) return {0, 0, State::Night, false}; + + int32_t low = 0, high = 180000; + while (low < high) { + const int32_t middle = (low + high) / 2; + if (int64_t(sine(middle + 90000)) * denominator > numerator * Scale) + low = middle + 1; + else + high = middle; + } + const int32_t half_day_seconds = roundedDivide(int64_t(low) * 240, 1000); + const int32_t longitude_seconds = roundedDivide(int64_t(longitude_e6) * 240, 1000000); + const int64_t noon = unix_day * 86400 + 43200 - longitude_seconds - equation_seconds; + return {noon - half_day_seconds, noon + half_day_seconds, State::Unknown, true}; +} + +} // namespace daylight_detail + +// The caller supplies the node's configured/cached location and can cache this +// result by UTC minute. No GPS reads, timezone, daylight-saving rules or floats. +inline Window evaluate(uint32_t utc, int32_t latitude_e6, int32_t longitude_e6) { + using namespace daylight_detail; + if (utc < PlausibleEpoch || latitude_e6 < -90000000 || latitude_e6 > 90000000 + || longitude_e6 < -180000000 || longitude_e6 > 180000000) + return {State::Unknown, 0}; + + const int32_t longitude_seconds = roundedDivide(int64_t(longitude_e6) * 240, 1000000); + const int64_t solar_day = (int64_t(utc) + longitude_seconds) / 86400; + Events days[3]; + days[1] = events(solar_day, latitude_e6, longitude_e6); + if (!days[1].crosses) + return {days[1].polar, clampUtc(int64_t(utc) + PolarRecheckSeconds)}; + days[0] = events(solar_day - 1, latitude_e6, longitude_e6); + days[2] = events(solar_day + 1, latitude_e6, longitude_e6); + + int64_t daylight_end = 0; + int64_t next_sunrise = -1; + for (const Events& day : days) { + if (!day.crosses) continue; + if (int64_t(utc) >= day.sunrise && int64_t(utc) < day.sunset + && day.sunset > daylight_end) daylight_end = day.sunset; + if (day.sunrise > int64_t(utc) && (next_sunrise < 0 || day.sunrise < next_sunrise)) + next_sunrise = day.sunrise; + } + if (daylight_end) { + // Adjacent very long days can overlap across apparent solar midnight. + for (unsigned pass = 0; pass < 2; ++pass) + for (const Events& day : days) + if (day.crosses && day.sunrise <= daylight_end && day.sunset > daylight_end) + daylight_end = day.sunset; + return {State::Day, clampUtc(daylight_end)}; + } + return {State::Night, clampUtc(next_sunrise < 0 + ? int64_t(utc) + PolarRecheckSeconds : next_sunrise)}; +} + +}} // namespace mesh::solar diff --git a/src/helpers/WirelessControl.h b/src/helpers/WirelessControl.h index 8a04a638f..29d4a483c 100644 --- a/src/helpers/WirelessControl.h +++ b/src/helpers/WirelessControl.h @@ -155,7 +155,7 @@ public: } #if MESH_ENABLE_EMERGENCY_POWER if (emergency_suspended_) { - snprintf(reply, size, "Error: emergency sleep; use set power.awake first"); + snprintf(reply, size, "Error: scheduled sleep; use set power.awake first"); return true; } #endif diff --git a/test/test_emergency_power.py b/test/test_emergency_power.py index da6f84029..ac0e8c08f 100644 --- a/test/test_emergency_power.py +++ b/test/test_emergency_power.py @@ -186,6 +186,8 @@ class EmergencyPowerTests(unittest.TestCase): checked_write = extract_braced(checked, "MESH_SETTINGS_FILE_SHARED inline AtomicFileWriter::Outcome writeCheckedSettingsFile(") prefix = POLICY_PREFIX + r''' #include +#include +#include #include #include #include @@ -247,16 +249,35 @@ struct Clock { uint32_t time = epoch + 1800; uint32_t getCurrentTime() { return time; } }; +struct Prefs { + double node_lat = 0, node_lon = 0; +}; +struct Sensors { + bool cached = false; + double latitude = 0, longitude = 0; + mutable unsigned reads = 0; + bool getCachedGpsPosition(double& lat, double& lon) const { + ++reads; + lat = latitude; lon = longitude; + return cached; + } +}; +static Prefs defaultPrefs; +static Sensors defaultSensors; class CommonCLI { public: Board* _board; Clock* _rtc; + Prefs* _prefs; + Sensors* _sensors; mesh::batteryforward::Settings _battery_forward_settings; ''' + private + "\n" + public + r''' uint16_t batteryEmptyMv() const; uint16_t batteryFullMv() const; uint8_t batteryPercent(uint16_t) const; - CommonCLI(Board& board, Clock& rtc) : _board(&board), _rtc(&rtc) {} + CommonCLI(Board& board, Clock& rtc, Prefs& prefs = defaultPrefs, + Sensors& sensors = defaultSensors) + : _board(&board), _rtc(&rtc), _prefs(&prefs), _sensors(&sensors) {} }; ''' + battery_methods + "\n" + implementation return prefix @@ -378,7 +399,8 @@ public: cli.setEmergencyRadioSleeping(true); assert(!cli.emergencyActive() && cli.emergencySleepMilliseconds() == 0); assert(board.reads == 0 && fs.writes == 0); - static_assert(sizeof(CommonCLI) == sizeof(Board*) + sizeof(Clock*) + 8, + static_assert(sizeof(CommonCLI) == sizeof(Board*) + sizeof(Clock*) + + sizeof(Prefs*) + sizeof(Sensors*) + 8, "disabled emergency policy must not retain its scheduler or filesystem"); ''', prefix, flags=("-DSTM32_PLATFORM", "-DMESH_ENABLE_EMERGENCY_POWER=0")) diff --git a/test/test_emergency_power_runtime.py b/test/test_emergency_power_runtime.py index 131d882ae..107f961f7 100644 --- a/test/test_emergency_power_runtime.py +++ b/test/test_emergency_power_runtime.py @@ -12,6 +12,7 @@ from test_replay_reset_integration import extract_braced ROOT = Path(__file__).resolve().parents[1] HARNESS = r''' #include +#include #include #include #include @@ -103,6 +104,21 @@ struct CLI { events.push_back(value ? "status:sleep" : "status:awake"); radioSleeping = value; } }; +struct ScheduleCLI { + mesh::powerschedule::State schedule; + uint32_t utc = 1577836800; + bool location = false; + int32_t latitude = 0, longitude = 0; + bool radioSleeping = false; + // Standalone scheduling is deliberately not an emergency activation. + bool emergencyActive() const { return false; } + uint32_t emergencySleepMilliseconds() { + return schedule.sleepMilliseconds(utc, location, latitude, longitude); + } + void setEmergencyRadioSleeping(bool value) { + events.push_back(value ? "status:sleep" : "status:awake"); radioSleeping = value; + } +}; struct Fixture { Wireless wireless; Mesh mesh; @@ -125,7 +141,7 @@ static void command(Control& c, const char* text, uint32_t now = 100) { static void refused(Control& c, const char* text) { char reply[160] = {}; assert(c.handle(text, reply, sizeof(reply), 1000, Independent)); - assert(strstr(reply, "emergency sleep") && !c.pending()); + assert(strstr(reply, "scheduled sleep") && !c.pending()); } int main(int argc, char** argv) { assert(argc == 2); @@ -341,6 +357,53 @@ int main(int argc, char** argv) { cli.budget = 0; assert(!runtime.service(mesh, radio, board, cli, false)); for (const auto& event : events) assert(event.find("fem:") == std::string::npos); assert(levels[SX126X_POWER_EN] == HIGH); + } else if (scenario == 11) { + Wireless wireless; control().begin(wireless); + Mesh mesh; Radio radio; Board board; ScheduleCLI cli; + mesh::power::EmergencyPowerRuntime runtime; + mesh::powerschedule::Settings settings; + settings.hours = true; settings.startMinute = 8 * 60; settings.endMinute = 18 * 60; + cli.schedule.begin(settings); + cli.utc += 7 * 3600; + assert(!cli.emergencyActive()); + assert(runtime.service(mesh, radio, board, cli, false)); + assert(!mesh.available && cli.radioSleeping && wireless.active == 0); + assert(board.lastBudget == 10000); + events.clear(); cli.utc += 3600; + assert(!runtime.service(mesh, radio, board, cli, false)); + assert(mesh.available && !cli.radioSleeping && wireless.active == All); + assert((events == std::vector{"radio:wake", "mesh:on", "status:awake", + "espnow:on", "wifi:on", "ble:on"})); + cli.utc += 10 * 3600; + events.clear(); + assert(!runtime.service(mesh, radio, board, cli, true)); // Live reply/TX gets to finish. + assert(mesh.available && events.empty()); + assert(runtime.service(mesh, radio, board, cli, false)); + assert(!mesh.available && wireless.active == 0); + board.ota = true; + assert(!runtime.service(mesh, radio, board, cli, false)); + assert(mesh.available && wireless.active == All); + } else if (scenario == 12) { + Wireless wireless; control().begin(wireless); + Mesh mesh; Radio radio; Board board; ScheduleCLI cli; + mesh::power::EmergencyPowerRuntime runtime; + mesh::powerschedule::Settings settings; settings.daylight = true; + cli.schedule.begin(settings); + cli.location = true; cli.latitude = 47600000; cli.longitude = -122300000; + cli.utc = 1782000000 + 8 * 3600; + assert(!cli.emergencyActive()); + assert(runtime.service(mesh, radio, board, cli, false)); + assert(!mesh.available && cli.radioSleeping && wireless.active == 0); + events.clear(); cli.utc = 1782000000 + 20 * 3600; + assert(!runtime.service(mesh, radio, board, cli, false)); + assert(mesh.available && !cli.radioSleeping && wireless.active == All); + assert((events == std::vector{"radio:wake", "mesh:on", "status:awake", + "espnow:on", "wifi:on", "ble:on"})); + cli.utc = 1782000000 + 8 * 3600; + assert(runtime.service(mesh, radio, board, cli, false)); + cli.location = false; + assert(!runtime.service(mesh, radio, board, cli, false)); // Unknown position leaves access available. + assert(mesh.available && wireless.active == All); } else assert(false); } ''' @@ -422,6 +485,12 @@ class EmergencyPowerRuntimeTests(unittest.TestCase): def test_rak3401_actual_fem_pin_restores_before_radio_wake_without_cutting_shared_rail(self): self.scenario(10) + def test_standalone_daily_hours_sleep_and_restore_rf_wireless_without_emergency_mode(self): + self.scenario(11) + + def test_standalone_daylight_sleep_and_position_loss_restore_rf_wireless(self): + self.scenario(12) + def test_room_maintenance_runs_before_emergency_early_return(self): source = (ROOT / "examples/simple_room_server/main.cpp").read_text() loop = source[source.index("void loop() {"):] diff --git a/test/test_power_schedule.py b/test/test_power_schedule.py new file mode 100644 index 000000000..b9faf87d1 --- /dev/null +++ b/test/test_power_schedule.py @@ -0,0 +1,357 @@ +"""Run the saved operating schedule and actual CLI against clock/location changes.""" +from pathlib import Path +import unittest + +import test_emergency_power as emergency + + +ROOT = Path(__file__).resolve().parents[1] +POLICY_PREFIX = r''' +#include +#include +#include +#include +#include +using namespace mesh::powerschedule; +static constexpr uint32_t epoch = 1577836800; // 2020-01-01, midnight UTC +''' + + +class PowerScheduleTests(unittest.TestCase): + def exercise(self, body, prefix=POLICY_PREFIX, flags=()): + emergency.EmergencyPowerTests().exercise(body, prefix, flags) + + def cli(self, body, platform="ESP32_PLATFORM"): + self.exercise(body, emergency.EmergencyPowerTests().runtime_prefix(), + flags=("-D" + platform, "-DMESH_ENABLE_EMERGENCY_POWER=1")) + + def test_daily_window_exact_minute_and_second_boundaries(self): + self.exercise(r''' + Settings settings; + State state; state.begin(settings); + for (unsigned hour = 0; hour < 24; ++hour) + assert(state.sleepMilliseconds(epoch + hour * 3600, false, 0, 0) == 0); + settings.hours = true; settings.startMinute = 8 * 60; settings.endMinute = 18 * 60; + state.begin(settings); + assert(state.sleepMilliseconds(epoch + 8 * 3600 - 1, false, 0, 0) == 1000); + assert(state.sleepMilliseconds(epoch + 8 * 3600, false, 0, 0) == 0); + assert(state.sleepMilliseconds(epoch + 18 * 3600 - 1, false, 0, 0) == 0); + assert(state.sleepMilliseconds(epoch + 18 * 3600, false, 0, 0) == 14UL * 3600000); + for (unsigned minute = 0; minute < 1440; ++minute) { + const bool expected_awake = minute >= 480 && minute < 1080; + assert((state.sleepMilliseconds(epoch + minute * 60, false, 0, 0) == 0) + == expected_awake); + } + // Explicit 24:00 permits the whole day without a same-start/end ambiguity. + settings.startMinute = 0; settings.endMinute = 1440; state.begin(settings); + assert(state.sleepMilliseconds(epoch, false, 0, 0) == 0); + assert(state.sleepMilliseconds(epoch + 86399, false, 0, 0) == 0); +''') + + def test_overnight_windows_fixed_offsets_and_clock_corrections(self): + self.exercise(r''' + Settings settings; settings.hours = true; + settings.startMinute = 22 * 60; settings.endMinute = 6 * 60; + State state; state.begin(settings); + for (unsigned minute = 0; minute < 1440; ++minute) { + const bool expected_awake = minute < 360 || minute >= 1320; + assert((state.sleepMilliseconds(epoch + minute * 60, false, 0, 0) == 0) + == expected_awake); + } + assert(state.sleepMilliseconds(epoch + 6 * 3600, false, 0, 0) == 16UL * 3600000); + settings.startMinute = 8 * 60; settings.endMinute = 18 * 60; + for (int offset : {-720, -420, 330, 840}) { + settings.offsetMinutes = int16_t(offset); state.begin(settings); + for (unsigned minute = 0; minute < 1440; ++minute) { + const int local = (int(minute) + offset + 1440) % 1440; + const bool expected_awake = local >= 480 && local < 1080; + assert((state.sleepMilliseconds(epoch + minute * 60, false, 0, 0) == 0) + == expected_awake); + } + } + settings.offsetMinutes = 0; state.begin(settings); + assert(state.sleepMilliseconds(epoch + 20 * 3600, false, 0, 0) > 0); + assert(state.sleepMilliseconds(epoch + 10 * 3600, false, 0, 0) == 0); // RTC corrected backward. + assert(state.sleepMilliseconds(epoch + 7 * 3600, false, 0, 0) > 0); + assert(state.sleepMilliseconds(epoch + 9 * 3600, false, 0, 0) == 0); + assert(state.sleepMilliseconds(0, false, 0, 0) == 0); + assert(state.sleepMilliseconds(epoch - 1, false, 0, 0) == 0); + assert(state.sleepMilliseconds(UINT32_MAX, false, 0, 0) <= 86400000UL); +''') + + def test_daylight_and_hour_limits_intersect_and_missing_inputs_fail_open(self): + self.exercise(r''' + const uint32_t june = 1782000000; // 2026-06-21, midnight UTC + Settings settings; settings.daylight = true; + State state; state.begin(settings); + assert(state.sleepMilliseconds(june + 20 * 3600, true, 47600000, -122300000) == 0); + assert(state.sleepMilliseconds(june + 8 * 3600, true, 47600000, -122300000) > 0); + // Changing location or its availability inside one UTC minute invalidates the cache. + assert(state.sleepMilliseconds(june, true, 85000000, 0) == 0); + assert(state.sleepMilliseconds(june + 1, true, -85000000, 0) > 0); + assert(state.sleepMilliseconds(june + 2, false, 0, 0) == 0); + assert(state.sleepMilliseconds(june + 3, true, 91000000, 0) == 0); + assert(state.sleepMilliseconds(0, true, -85000000, 0) == 0); + + settings.hours = true; settings.startMinute = 0; settings.endMinute = 12 * 60; + state.begin(settings); + assert(state.sleepMilliseconds(june + 8 * 3600, true, 47600000, -122300000) > 0); // Night. + assert(state.sleepMilliseconds(june + 20 * 3600, true, 47600000, -122300000) > 0); // Hours closed. + settings.startMinute = 19 * 60; settings.endMinute = 22 * 60; state.begin(settings); + assert(state.sleepMilliseconds(june + 20 * 3600, true, 47600000, -122300000) == 0); + assert(state.sleepMilliseconds(june + 8 * 3600, false, 0, 0) > 0); // Missing GPS does not bypass hours. + assert(state.sleepMilliseconds(june + 20 * 3600, false, 0, 0) == 0); + assert(state.sleepMilliseconds(0, false, 0, 0) == 0); // No civil-hour guesses from uptime. +''') + + def test_checked_record_roundtrip_corruption_reserved_values_and_old_record_independence(self): + self.exercise(r''' + static_assert(RecordSize == 14, "Schedule stays independent of the legacy emergency record"); + static_assert(mesh::emergencypower::RecordSize == 8, "Existing emergency images remain readable"); + for (bool daylight : {false, true}) for (bool hours : {false, true}) { + Settings expected; + expected.daylight = daylight; expected.hours = hours; + expected.startMinute = 22 * 60; expected.endMinute = 6 * 60; + expected.offsetMinutes = -420; + uint8_t bytes[RecordSize]; encode(bytes, expected); + Settings actual; + assert(decode(bytes, sizeof(bytes), actual)); + assert(actual.daylight == daylight && actual.hours == hours); + assert(actual.startMinute == 1320 && actual.endMinute == 360 && actual.offsetMinutes == -420); + for (size_t byte = 0; byte < RecordSize; ++byte) { + for (unsigned bit = 0; bit < 8; ++bit) { + uint8_t corrupted[RecordSize]; memcpy(corrupted, bytes, sizeof(bytes)); + corrupted[byte] ^= uint8_t(1U << bit); + assert(!decode(corrupted, sizeof(corrupted), actual)); + assert(!actual.daylight && !actual.hours); + } + assert(!decode(bytes, byte, actual)); + } + std::vector oversized(bytes, bytes + RecordSize); oversized.push_back(0); + assert(!decode(oversized.data(), oversized.size(), actual)); + assert(!decode(nullptr, RecordSize, actual)); + } + for (unsigned bad = 0; bad < 5; ++bad) { + Settings invalid; invalid.hours = true; invalid.startMinute = 480; invalid.endMinute = 1080; + if (bad == 0) invalid.startMinute = 1440; + if (bad == 1) invalid.endMinute = 1441; + if (bad == 2) invalid.endMinute = invalid.startMinute; + if (bad == 3) invalid.offsetMinutes = -721; + if (bad == 4) invalid.offsetMinutes = 841; + uint8_t bytes[RecordSize]; encode(bytes, invalid); + Settings actual; assert(!decode(bytes, sizeof(bytes), actual)); + } + for (unsigned field : {0U, 3U, 4U, 5U}) { + Settings good; + uint8_t bytes[RecordSize]; encode(bytes, good); + bytes[field] = field == 0 ? 'X' : field == 4 ? 4 : 2; + const uint16_t crc = mesh::emergencypower::checksum(bytes, 12); + bytes[12] = uint8_t(crc); bytes[13] = uint8_t(crc >> 8); + Settings actual; + assert(!decode(bytes, sizeof(bytes), actual) && !actual.daylight && !actual.hours); + } + for (int offset : {-720, 0, 330, 840}) { + Settings expected; expected.offsetMinutes = int16_t(offset); + uint8_t bytes[RecordSize]; encode(bytes, expected); + Settings actual; + assert(decode(bytes, sizeof(bytes), actual) && actual.offsetMinutes == offset); + } +''') + + def test_clock_rollback_across_dawn_inside_one_cached_minute(self): + self.exercise(r''' + const uint32_t night = 1782000000 + 8 * 3600; + Settings settings; settings.daylight = true; + State state; + bool exercised = false; + for (int32_t longitude : {-122300000, -122250000, -122200000}) { + const auto solar = mesh::solar::evaluate(night, 47600000, longitude); + assert(solar.state == mesh::solar::State::Night && solar.nextChangeUtc > night); + const uint32_t dawn = solar.nextChangeUtc; + if ((dawn - 1) / 60 != (dawn + 1) / 60) continue; + state.begin(settings); + assert(state.sleepMilliseconds(dawn + 1, true, 47600000, longitude) == 0); + assert(state.sleepMilliseconds(dawn - 1, true, 47600000, longitude) > 0); + exercised = true; + } + assert(exercised); +''') + + def test_actual_cli_standalone_hour_schedule_reply_grace_awake_override_and_persistence(self): + body = r''' + Board board; Clock clock; FILESYSTEM fs; Prefs prefs; Sensors sensors; + CommonCLI cli(board, clock, prefs, sensors); + nowMs = 100; clock.time = epoch + 20 * 3600; + cli.loadEmergencyPower(&fs); + assert(!cli.emergencyActive() && cli.emergencySleepMilliseconds() == 0); + char reply[160] = {}; + assert(cli.handleEmergencyPowerCommand("set power.hours 08:00-18:00", reply)); + assert(!strncmp(reply, "OK", 2)); + assert(fs.files["/power_schedule"].size() == 14); + assert(fs.files.count("/power_emergency") == 0); + assert(!cli.emergencyActive() && cli.emergencySleepMilliseconds() == 0); // Reply grace. + nowMs += 5000; + assert(!cli.emergencyActive() && cli.emergencySleepMilliseconds() > 0); + assert(cli.handleEmergencyPowerCommand("get power.hours", reply)); + assert(strstr(reply, "08:00-18:00")); + assert(cli.handleEmergencyPowerCommand("get power.schedule", reply)); + assert(strstr(reply, "sleep") && strlen(reply) < 157); + const unsigned writes = fs.writes; + assert(cli.handleEmergencyPowerCommand("set power.hours 08:00-18:00", reply)); + assert(fs.writes == writes && cli.emergencySleepMilliseconds() > 0); // No poll/idempotent lease. + assert(cli.handleEmergencyPowerCommand("set power.awake 1", reply)); + assert(cli.emergencySleepMilliseconds() == 0); + nowMs += 60000; + assert(cli.emergencySleepMilliseconds() > 0); + assert(cli.handleEmergencyPowerCommand("set power.hours.offset -420", reply)); + assert(!strncmp(reply, "OK", 2)); + nowMs += 5000; + assert(cli.emergencySleepMilliseconds() == 0); // 20:00 UTC is 13:00 at this fixed offset. + CommonCLI reboot(board, clock, prefs, sensors); reboot.loadEmergencyPower(&fs); + nowMs += 5000; + assert(!reboot.emergencyActive() && reboot.emergencySleepMilliseconds() == 0); + assert(reboot.handleEmergencyPowerCommand("get power.hours.offset", reply)); + assert(strstr(reply, "-420")); + clock.time = epoch + 3 * 3600; + assert(reboot.emergencySleepMilliseconds() > 0); + assert(reboot.handleEmergencyPowerCommand("set power.schedule off", reply)); + assert(!strncmp(reply, "OK", 2) && reboot.emergencySleepMilliseconds() == 0); +''' + for platform in ("ESP32_PLATFORM", "NRF52_PLATFORM", "STM32_PLATFORM"): + with self.subTest(platform=platform): + self.cli(body, platform) + + def test_actual_cli_daylight_emergency_intersection_and_location_priority(self): + self.cli(r''' + Board board; Clock clock; FILESYSTEM fs; Prefs prefs; Sensors sensors; + prefs.node_lat = 47.6; prefs.node_lon = -122.3; + sensors.cached = true; sensors.latitude = -85; sensors.longitude = 0; + CommonCLI cli(board, clock, prefs, sensors); + nowMs = 0; clock.time = 1782000000 + 20 * 3600; + cli.loadEmergencyPower(&fs); + char reply[160]; + assert(cli.handleEmergencyPowerCommand("set power.daylight on", reply)); + assert(!strncmp(reply, "OK", 2)); + nowMs += 5000; + assert(!cli.emergencyActive() && cli.emergencySleepMilliseconds() == 0); + assert(sensors.reads == 0); // Fixed location takes priority; no GPS activation/read needed. + assert(cli.handleEmergencyPowerCommand("set power.emergency on", reply)); + nowMs += 5000; + clock.time += 10 * 60; + assert(cli.emergencyActive() && cli.emergencySleepMilliseconds() == 0); + clock.time += 5 * 60; + assert(cli.emergencySleepMilliseconds() > 0); // First15min restriction still applies in daytime. + clock.time = 1782000000 + 8 * 3600 + 10 * 60; + assert(cli.emergencySleepMilliseconds() > 0); // Night suppresses even an emergency awake slot. + assert(cli.handleEmergencyPowerCommand("set power.awake 1", reply)); + assert(cli.emergencySleepMilliseconds() == 0); + assert(cli.handleEmergencyPowerCommand("set power.awake 0", reply)); + assert(cli.emergencySleepMilliseconds() > 0); + assert(cli.handleEmergencyPowerCommand("set power.emergency auto", reply)); + nowMs += 5000; + assert(!cli.emergencyActive() && cli.emergencySleepMilliseconds() > 0); // Independent setting survives. + assert(cli.handleEmergencyPowerCommand("get power.daylight", reply)); + assert(strstr(reply, "on")); + prefs.node_lat = prefs.node_lon = 0; + sensors.latitude = 85; sensors.longitude = 0; + assert(cli.emergencySleepMilliseconds() == 0); // Cached polar-day GPS, without starting receiver. + assert(sensors.reads > 0); + sensors.cached = false; + assert(cli.emergencySleepMilliseconds() == 0); + assert(cli.handleEmergencyPowerCommand("get power.schedule", reply)); + assert(strstr(reply, "unknown")); + assert(cli.handleEmergencyPowerCommand("set power.hours 19:00-22:00", reply)); + nowMs += 5000; + assert(cli.emergencySleepMilliseconds() > 0); // Missing GPS cannot open a closed civil-hour gate. + clock.time = 0; + assert(cli.emergencySleepMilliseconds() == 0); // Missing time fails open for standalone schedules. +''') + + def test_actual_cli_preserves_legacy_manual_state_and_atomic_failure_leaves_schedule_inactive(self): + self.cli(r''' + Board board; Clock clock; FILESYSTEM fs; + mesh::emergencypower::Settings legacy; legacy.manual = true; + uint8_t bytes[mesh::emergencypower::RecordSize]; mesh::emergencypower::encode(bytes, legacy); + const std::vector original(bytes, bytes + sizeof(bytes)); + fs.files["/power_emergency"] = original; + CommonCLI cli(board, clock); nowMs = 100; cli.loadEmergencyPower(&fs); + char reply[160]; nowMs += 5000; + assert(cli.emergencyActive()); + for (unsigned failure = 0; failure < 4; ++failure) { + fs.shortWrite = failure == 0; fs.failCommit = failure == 1; + fs.badRead = failure == 2; fs.corruptCommit = failure == 3; + assert(cli.handleEmergencyPowerCommand("set power.daylight on", reply)); + assert(strstr(reply, "Error:") && cli.emergencyActive()); + assert(cli.handleEmergencyPowerCommand("get power.daylight", reply)); + assert(strstr(reply, "off")); + assert(fs.files["/power_emergency"] == original); + } + fs.shortWrite = fs.failCommit = fs.badRead = fs.corruptCommit = false; + assert(cli.handleEmergencyPowerCommand("set power.hours 22:00-06:00", reply)); + assert(!strncmp(reply, "OK", 2)); + assert(fs.files["/power_emergency"] == original); + fs.files["/power_schedule.bak"] = fs.files["/power_schedule"]; + fs.files["/power_schedule"][0] ^= 1; + CommonCLI backup(board, clock); backup.loadEmergencyPower(&fs); + assert(backup.handleEmergencyPowerCommand("get power.hours", reply)); + assert(strstr(reply, "22:00-06:00")); + fs.files["/power_schedule.bak"][0] ^= 1; + CommonCLI corrupt(board, clock); corrupt.loadEmergencyPower(&fs); + assert(corrupt.handleEmergencyPowerCommand("get power.hours", reply)); + assert(strstr(reply, "off") && corrupt.emergencyActive()); +''') + + def test_actual_cli_strict_parsing_unsupported_board_and_no_accidental_prefix_match(self): + self.cli(r''' + Board board; Clock clock; FILESYSTEM fs; + CommonCLI cli(board, clock); nowMs = 0; cli.loadEmergencyPower(&fs); + char reply[160]; + for (const char* command : {"set power.daylight", "set power.daylight AUTO", "set power.daylight on extra", + "get power.daylight x", "set power.hours", "set power.hours 8:00-18:00", "set power.hours 08:00", + "set power.hours 24:00-06:00", "set power.hours 08:60-18:00", "set power.hours 08:00-24:01", + "set power.hours 08:00-08:00", "set power.hours 08:00-18:00 extra", "get power.hours x", + "set power.hours.offset", "set power.hours.offset -721", "set power.hours.offset 841", + "set power.hours.offset 3.5", "set power.hours.offset 60x", "set power.hours.offset --1", + "get power.hours.offset x", "set power.schedule on", "get power.schedule x"}) { + const unsigned writes = fs.writes; + assert(cli.handleEmergencyPowerCommand(command, reply)); + assert(!strncmp(reply, "Error:", 6) && fs.writes == writes); + } + for (const char* command : {"get power.daylightx", "set power.hoursx off", "set power.hours.offsetx 0", + "get power.schedulex", "get power.hour", "set unrelated on"}) { + strcpy(reply, "unchanged"); + assert(!cli.handleEmergencyPowerCommand(command, reply) && !strcmp(reply, "unchanged")); + } + board.emergencySupported = false; + for (const char* command : {"set power.daylight on", "set power.hours 08:00-18:00"}) { + const unsigned writes = fs.writes; + assert(cli.handleEmergencyPowerCommand(command, reply)); + assert(strstr(reply, "unsupported") && fs.writes == writes); + } +''') + + def test_actual_cli_boundary_values_whitespace_and_complete_bounded_status(self): + self.cli(r''' + Board board; Clock clock; FILESYSTEM fs; + CommonCLI cli(board, clock); nowMs = 0; cli.loadEmergencyPower(&fs); + char reply[160]; + for (const char* command : {"set power.daylight on \t", "set power.hours 00:00-24:00 \t", + "set power.hours.offset +330\t", "set power.hours.offset -720 \t", + "set power.awake 10080"}) { + memset(reply, 'Z', sizeof(reply)); + assert(cli.handleEmergencyPowerCommand(command, reply) && !strncmp(reply, "OK", 2)); + assert(reply[157] == 'Z' && reply[158] == 'Z' && reply[159] == 'Z'); + } + clock.time = 0; + memset(reply, 'Z', sizeof(reply)); + assert(cli.handleEmergencyPowerCommand("get power.schedule \t", reply)); + for (const char* token : {"daylight=on", "state=unknown", "hours=00:00-24:00", "offset=-720", + "clock=unknown", "emergency=off", "window=awake", "radio=awake", "hold=604800s"}) + assert(strstr(reply, token)); + assert(strlen(reply) < 157 && reply[157] == 'Z' && reply[158] == 'Z' && reply[159] == 'Z'); + assert(!cli.emergencyActive() && cli.emergencySleepMilliseconds() == 0); +''') + + +if __name__ == "__main__": + unittest.main() diff --git a/test/test_solar_daylight.py b/test/test_solar_daylight.py new file mode 100644 index 000000000..f779bb530 --- /dev/null +++ b/test/test_solar_daylight.py @@ -0,0 +1,159 @@ +"""Compare the production integer daylight schedule to a floating NOAA oracle.""" +from datetime import date, datetime, timedelta, timezone +import calendar +import math +import os +from pathlib import Path +import shutil +import subprocess +import tempfile +import unittest + + +ROOT = Path(__file__).resolve().parents[1] +DAY, NIGHT, UNKNOWN = 1, 2, 0 +UINT32_MAX = (1 << 32) - 1 + + +def reference_events(day, latitude, longitude): + """NOAA general solar equations, evaluated at the local date's noon.""" + gamma = 2 * math.pi / (366 if calendar.isleap(day.year) else 365) * (day.timetuple().tm_yday - 1) + equation = 229.18 * (0.000075 + 0.001868 * math.cos(gamma) + - 0.032077 * math.sin(gamma) - 0.014615 * math.cos(2 * gamma) + - 0.040849 * math.sin(2 * gamma)) + declination = (0.006918 - 0.399912 * math.cos(gamma) + 0.070257 * math.sin(gamma) + - 0.006758 * math.cos(2 * gamma) + 0.000907 * math.sin(2 * gamma) + - 0.002697 * math.cos(3 * gamma) + 0.001480 * math.sin(3 * gamma)) + latitude = math.radians(latitude) + cosine = ((math.cos(math.radians(90.833)) - math.sin(latitude) * math.sin(declination)) + / (math.cos(latitude) * math.cos(declination))) + if not -1 < cosine < 1: + return None + half_day = 4 * math.degrees(math.acos(cosine)) + midnight = datetime.combine(day, datetime.min.time(), timezone.utc).timestamp() + noon = 720 - 4 * longitude - equation + return midnight + (noon - half_day) * 60, midnight + (noon + half_day) * 60 + + +class SolarDaylightTests(unittest.TestCase): + @classmethod + def setUpClass(cls): + cls.work = tempfile.TemporaryDirectory(prefix="mesh-solar-daylight-") + root = Path(cls.work.name) + source = root / "solar.cpp" + source.write_text(r''' +#include +#include +#include +static_assert(sizeof(mesh::solar::Window) == 8, "Bounded daylight cache"); +static_assert(std::is_same::type, uint8_t>::value, + "Compact daylight state"); +int main() { + unsigned utc; int latitude, longitude; + while (std::scanf("%u %d %d", &utc, &latitude, &longitude) == 3) { + const auto result = mesh::solar::evaluate(utc, latitude, longitude); + std::printf("%u %u\n", unsigned(result.state), unsigned(result.nextChangeUtc)); + } +} +''', encoding="utf-8") + cls.binary = root / ("solar.exe" if os.name == "nt" else "solar") + compiler = os.environ.get("CXX") or shutil.which("g++") or shutil.which("c++") + if not compiler: + raise RuntimeError("A C++ compiler is required for daylight tests") + command = [compiler, "-std=c++17", "-Wall", "-Wextra", "-Werror", "-pedantic", + "-I", str(ROOT / "src"), str(source), "-o", str(cls.binary)] + if os.name != "nt": + command += ["-fsanitize=address,undefined", "-fno-omit-frame-pointer", "-fno-pie", "-no-pie"] + result = subprocess.run(command, capture_output=True, text=True, timeout=60) + if result.returncode: + raise AssertionError(result.stdout + result.stderr) + + @classmethod + def tearDownClass(cls): + cls.work.cleanup() + + def evaluate(self, queries): + result = subprocess.run([str(self.binary)], input="".join( + f"{utc} {latitude} {longitude}\n" for utc, latitude, longitude in queries), + capture_output=True, text=True, timeout=30) + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + output = [tuple(map(int, line.split())) for line in result.stdout.splitlines()] + self.assertEqual(len(output), len(queries)) + return output + + def test_independent_noaa_boundaries_and_next_transition(self): + # NOAA: https://gml.noaa.gov/grad/solcalc/solareqns.PDF . The production + # implementation uses integer Fourier coefficients/table interpolation; + # this oracle independently uses full precision trigonometric functions. + places = ((47.6, -122.3), (0, 0), (51.5, 0), (-33.9, 151.2), + (60.2, 24.9), (61.2, -149.9), (-34.9, -56.2), + (45, 179.9), (45, -179.9), (65.5, -150), (-65.5, 20)) + dates = [date(2026, month, 21) for month in range(1, 13)] + dates += [date(2024, 2, 29), date(2024, 12, 31), date(2025, 1, 1), + date(2100, 2, 28), date(2100, 3, 1)] + queries, expected = [], [] + for latitude, longitude in places: + for day in dates: + dawn, dusk = reference_events(day, latitude, longitude) + next_dawn, _ = reference_events(day + timedelta(days=1), latitude, longitude) + for utc, state, change in ((dawn - 600, NIGHT, dawn), (dawn + 600, DAY, dusk), + (dusk - 600, DAY, dusk), (dusk + 600, NIGHT, next_dawn)): + queries.append((int(utc), round(latitude * 1e6), round(longitude * 1e6))) + expected.append((state, change, day, latitude, longitude)) + for query, output, target in zip(queries, self.evaluate(queries), expected): + state, transition = output + wanted_state, wanted_transition, *_ = target + self.assertEqual(state, wanted_state, (query, output, target)) + self.assertLessEqual(abs(transition - wanted_transition), 600, (query, output, target)) + self.assertGreater(transition, query[0]) + + def test_reported_normal_transition_changes_state_at_that_second(self): + queries = [] + for day, latitude, longitude in ((date(2024, 2, 29), 47.6, -122.3), + (date(2026, 12, 31), 45, 179.9), (date(2027, 1, 1), 45, -179.9), + (date(2026, 6, 21), 61.2, -149.9)): + dawn, _ = reference_events(day, latitude, longitude) + queries.append((int(dawn - 600), round(latitude * 1e6), round(longitude * 1e6))) + sunrises = self.evaluate(queries) + edges = [] + for (_, latitude, longitude), (state, sunrise) in zip(queries, sunrises): + self.assertEqual(state, NIGHT) + edges.extend(((sunrise - 1, latitude, longitude), (sunrise, latitude, longitude))) + sunrise_edges = self.evaluate(edges) + self.assertEqual([state for state, _ in sunrise_edges], [NIGHT, DAY] * len(queries)) + sunset_queries = [] + for i, (_, latitude, longitude) in enumerate(queries): + sunset = sunrise_edges[2 * i + 1][1] + sunset_queries.extend(((sunset - 1, latitude, longitude), (sunset, latitude, longitude))) + self.assertEqual([state for state, _ in self.evaluate(sunset_queries)], [DAY, NIGHT] * len(queries)) + + def test_polar_states_recheck_and_southern_seasons(self): + queries, wanted = [], [] + for month, north, south in ((6, DAY, NIGHT), (12, NIGHT, DAY)): + utc = int(datetime(2026, month, 21, tzinfo=timezone.utc).timestamp()) + for longitude in (-180000000, 0, 180000000): + queries.extend(((utc, 85000000, longitude), (utc, -85000000, longitude), + (utc, 90000000, longitude), (utc, -90000000, longitude))) + wanted.extend((north, south, north, south)) + for query, (state, change), expected in zip(queries, self.evaluate(queries), wanted): + self.assertEqual(state, expected) + self.assertEqual(change, query[0] + 6 * 3600) + + def test_invalid_clock_coordinates_and_rtc_saturation(self): + plausible = 1577836800 + invalid = ((0, 0, 0), (plausible - 1, 47000000, -122000000), + (plausible, -90000001, 0), (plausible, 90000001, 0), + (plausible, 0, -180000001), (plausible, 0, 180000001)) + self.assertEqual(self.evaluate(invalid), [(UNKNOWN, 0)] * len(invalid)) + valid = [(utc, latitude, longitude) + for utc in (plausible, UINT32_MAX - 1, UINT32_MAX) + for latitude in (-90000000, 0, 47600000, 90000000) + for longitude in (-180000000, 0, 180000000)] + for query, (state, change) in zip(valid, self.evaluate(valid)): + self.assertIn(state, (DAY, NIGHT)) + self.assertGreaterEqual(change, query[0]) + self.assertLessEqual(change, UINT32_MAX) + + +if __name__ == "__main__": + unittest.main() diff --git a/tools/hil/emergency_power_bench.py b/tools/hil/emergency_power_bench.py index 6dbaebe37..f1a3c92f5 100644 --- a/tools/hil/emergency_power_bench.py +++ b/tools/hil/emergency_power_bench.py @@ -328,7 +328,7 @@ def hold_network_clock(console): value = reply_value(console.command("set wifi off force", allow_error=True)) if value.startswith("OK"): break - if not value.startswith("Error: emergency sleep") or time.monotonic() > deadline: + if not value.startswith(("Error: emergency sleep", "Error: scheduled sleep")) or time.monotonic() > deadline: raise RuntimeError("WiFi runtime clock isolation was rejected") time.sleep(.1) # Wait for the completed awake request to resume its backend. while True: From 15faf26d4d6901bd36e9135f7932fb535da822eb Mon Sep 17 00:00:00 2001 From: mikecarper Date: Wed, 28 Oct 2026 12:27:25 -0700 Subject: [PATCH 43/61] Keep operating schedule within nRF52 RAM reserves --- src/helpers/CommonCLI.h | 3 +- src/helpers/CommonCLI_Emergency.cpp | 17 ++++++---- src/helpers/OperatingSchedule.h | 36 ++++++++++---------- test/test_emergency_power.py | 5 ++- test/test_power_schedule.py | 52 +++++++++++++++++++++++++++-- 5 files changed, 86 insertions(+), 27 deletions(-) diff --git a/src/helpers/CommonCLI.h b/src/helpers/CommonCLI.h index 29c40d03a..f032cfeb2 100644 --- a/src/helpers/CommonCLI.h +++ b/src/helpers/CommonCLI.h @@ -951,7 +951,6 @@ class CommonCLI { bool saveEmergencyPower(const mesh::emergencypower::Settings& settings); void loopEmergencyPower(); bool handleEmergencyPowerCommand(const char* command, char* reply); - FILESYSTEM* _power_schedule_fs = nullptr; mesh::powerschedule::State _power_schedule; void loadPowerSchedule(FILESYSTEM* fs); bool savePowerSchedule(const mesh::powerschedule::Settings& settings); @@ -1000,6 +999,8 @@ class CommonCLI { bool _media_regions_ready = false; #if MESH_ENABLE_EMERGENCY_POWER bool _emergency_radio_sleeping = false; + // Share the filesystem, but a failed recovery blocks only that record. + uint8_t _power_settings_blocked = 0; #endif uint8_t _rx_settings_pending = 0; bool _com_prefs_needs_upgrade = false; // old-format legacy prefs detected; rewrite once after load diff --git a/src/helpers/CommonCLI_Emergency.cpp b/src/helpers/CommonCLI_Emergency.cpp index c4bf68a21..c4b4e3def 100644 --- a/src/helpers/CommonCLI_Emergency.cpp +++ b/src/helpers/CommonCLI_Emergency.cpp @@ -106,10 +106,11 @@ void operatingHoursText(const mesh::powerschedule::Settings& settings, char* tex void CommonCLI::loadEmergencyPower(FILESYSTEM* fs) { _emergency_power_fs = fs; + _power_settings_blocked = 0; mesh::emergencypower::Settings loaded; #if defined(ESP32_PLATFORM) || defined(RP2040_PLATFORM) if (!mesh::ContactFileTransaction::recover(fs, EMERGENCY_POWER_PATH)) - _emergency_power_fs = nullptr; + _power_settings_blocked |= 1; else #endif if (!readEmergencyPower(fs, EMERGENCY_POWER_PATH, loaded)) @@ -120,19 +121,20 @@ void CommonCLI::loadEmergencyPower(FILESYSTEM* fs) { } bool CommonCLI::saveEmergencyPower(const mesh::emergencypower::Settings& settings) { + if (_power_settings_blocked & 1) return false; uint8_t bytes[mesh::emergencypower::RecordSize]; mesh::emergencypower::encode(bytes, settings); return writePowerSettings(_emergency_power_fs, EMERGENCY_POWER_PATH, bytes, sizeof(bytes)); } void CommonCLI::loadPowerSchedule(FILESYSTEM* fs) { - _power_schedule_fs = fs; + _power_settings_blocked &= uint8_t(~2U); mesh::powerschedule::Settings loaded; #if defined(ESP32_PLATFORM) || defined(RP2040_PLATFORM) if (!mesh::ContactFileTransaction::recover(fs, POWER_SCHEDULE_PATH)) - _power_schedule_fs = nullptr; + _power_settings_blocked |= 2; #endif - if (_power_schedule_fs) { + if (fs && !(_power_settings_blocked & 2)) { uint8_t bytes[mesh::powerschedule::RecordSize]; if (!(mesh::storage::readFixedSettingsFile(fs, POWER_SCHEDULE_PATH, bytes, sizeof(bytes)) && mesh::powerschedule::decode(bytes, sizeof(bytes), loaded))) { @@ -145,9 +147,10 @@ void CommonCLI::loadPowerSchedule(FILESYSTEM* fs) { } bool CommonCLI::savePowerSchedule(const mesh::powerschedule::Settings& settings) { + if (_power_settings_blocked & 2) return false; uint8_t bytes[mesh::powerschedule::RecordSize]; mesh::powerschedule::encode(bytes, settings); - return writePowerSettings(_power_schedule_fs, POWER_SCHEDULE_PATH, bytes, sizeof(bytes)); + return writePowerSettings(_emergency_power_fs, POWER_SCHEDULE_PATH, bytes, sizeof(bytes)); } uint32_t CommonCLI::powerScheduleSleepMilliseconds(uint32_t epoch) { @@ -209,9 +212,11 @@ bool CommonCLI::handlePowerScheduleCommand(const char* command, char* reply) { snprintf(reply, 157, "Error: use set power.hours HH:MM-HH:MM|off"); return true; } } else if (emergencyCommand(command, "set power.hours.offset", value)) { - if (!operatingOffset(value, candidate.offsetMinutes)) { + int16_t offset; + if (!operatingOffset(value, offset)) { snprintf(reply, 157, "Error: offset must be -720..840 minutes"); return true; } + candidate.offsetMinutes = offset; } else if (emergencyCommand(command, "set power.schedule", value)) { if (!emergencyValue(value, "off")) { snprintf(reply, 157, "Error: use set power.schedule off"); return true; diff --git a/src/helpers/OperatingSchedule.h b/src/helpers/OperatingSchedule.h index d555c3a57..259ba7ecc 100644 --- a/src/helpers/OperatingSchedule.h +++ b/src/helpers/OperatingSchedule.h @@ -9,12 +9,15 @@ static constexpr size_t RecordSize = 14; static constexpr uint32_t DaySeconds = 86400; struct Settings { - bool daylight = false; - bool hours = false; - uint16_t startMinute = 0; - uint16_t endMinute = 0; - int16_t offsetMinutes = 0; + // Natural 16-bit allocation units; only explicit encode/decode is persisted. + uint16_t startMinute : 11; + bool daylight : 1; + bool hours : 1; + uint16_t endMinute : 11; + int16_t offsetMinutes : 11; + Settings() : startMinute(0), daylight(false), hours(false), endMinute(0), offsetMinutes(0) {} }; +static_assert(sizeof(Settings) <= 6, "Operating settings must retain their compact layout"); inline bool valid(const Settings& settings) { return settings.startMinute < 1440 && settings.endMinute <= 1440 @@ -51,10 +54,12 @@ inline bool decode(const uint8_t* in, size_t size, Settings& settings) { Settings loaded; loaded.daylight = (in[4] & 1) != 0; loaded.hours = (in[4] & 2) != 0; - loaded.startMinute = uint16_t(in[6] | uint16_t(in[7]) << 8); - loaded.endMinute = uint16_t(in[8] | uint16_t(in[9]) << 8); + const uint16_t start = uint16_t(in[6] | uint16_t(in[7]) << 8); + const uint16_t end = uint16_t(in[8] | uint16_t(in[9]) << 8); const uint16_t offset = uint16_t(in[10] | uint16_t(in[11]) << 8); - if (offset > 1560) return false; + if (start >= 1440 || end > 1440 || offset > 1560) return false; + loaded.startMinute = start; + loaded.endMinute = end; loaded.offsetMinutes = int16_t(offset) - 720; if (!valid(loaded)) return false; settings = loaded; @@ -62,10 +67,9 @@ inline bool decode(const uint8_t* in, size_t size, Settings& settings) { } struct State { - Settings settings; uint32_t evaluatedUtc = UINT32_MAX; uint32_t nextChangeUtc = 0; - int32_t latitude = 0, longitude = 0; + Settings settings; solar::State daylightState = solar::State::Unknown; bool hasLocation = false; @@ -96,16 +100,14 @@ struct State { if (!inside) budget = uint32_t((start - local + DaySeconds) % DaySeconds) * 1000UL; } if (settings.daylight) { - // Clock corrections and a changed location invalidate the minute cache. - // A transition within this minute is evaluated again at its boundary. - if (evaluatedUtc / 60 != epoch / 60 || epoch < evaluatedUtc || located != hasLocation - || latitude != latitude_e6 || longitude != longitude_e6 - || (nextChangeUtc && epoch >= nextChangeUtc)) { + // Bound calculations to once per UTC second. Location availability is + // immediate; a changed known position is observed within one second. + // The explicit sentinel check also handles clock recovery at UINT32_MAX. + if (evaluatedUtc == UINT32_MAX || evaluatedUtc != epoch || located != hasLocation) { const solar::Window window = located ? solar::evaluate(epoch, latitude_e6, longitude_e6) : solar::Window{solar::State::Unknown, 0}; evaluatedUtc = epoch; hasLocation = located; - latitude = latitude_e6; longitude = longitude_e6; daylightState = window.state; nextChangeUtc = window.nextChangeUtc; } @@ -122,6 +124,6 @@ struct State { } }; -static_assert(sizeof(State) <= 32, "Operating schedule must keep its bounded RAM footprint"); +static_assert(sizeof(State) <= 16, "Operating schedule must keep its bounded RAM footprint"); }} // namespace mesh::powerschedule diff --git a/test/test_emergency_power.py b/test/test_emergency_power.py index ac0e8c08f..ff297f0a4 100644 --- a/test/test_emergency_power.py +++ b/test/test_emergency_power.py @@ -197,6 +197,7 @@ struct FILESYSTEM { std::map> files; bool shortWrite = false, failCommit = false, badRead = false, corruptCommit = false; bool recoverOk = true; + std::string blockedPath; unsigned commits = 0, writes = 0; }; namespace mesh { @@ -225,7 +226,9 @@ public: class ContactFileTransaction : public AtomicFileWriter { public: using AtomicFileWriter::AtomicFileWriter; - static bool recover(FILESYSTEM* fs, const char*) { return fs && fs->recoverOk; } + static bool recover(FILESYSTEM* fs, const char* path) { + return fs && fs->recoverOk && fs->blockedPath != path; + } }; namespace storage { static bool readFixedSettingsFile(FILESYSTEM* fs, const char* path, uint8_t* bytes, size_t n) { diff --git a/test/test_power_schedule.py b/test/test_power_schedule.py index b9faf87d1..6b47b4615 100644 --- a/test/test_power_schedule.py +++ b/test/test_power_schedule.py @@ -86,10 +86,11 @@ class PowerScheduleTests(unittest.TestCase): State state; state.begin(settings); assert(state.sleepMilliseconds(june + 20 * 3600, true, 47600000, -122300000) == 0); assert(state.sleepMilliseconds(june + 8 * 3600, true, 47600000, -122300000) > 0); - // Changing location or its availability inside one UTC minute invalidates the cache. + // Known coordinates are cached for at most one UTC second, preserving scarce node RAM. assert(state.sleepMilliseconds(june, true, 85000000, 0) == 0); + assert(state.sleepMilliseconds(june, true, -85000000, 0) == 0); assert(state.sleepMilliseconds(june + 1, true, -85000000, 0) > 0); - assert(state.sleepMilliseconds(june + 2, false, 0, 0) == 0); + assert(state.sleepMilliseconds(june + 1, false, 0, 0) == 0); // Validity loss is immediate. assert(state.sleepMilliseconds(june + 3, true, 91000000, 0) == 0); assert(state.sleepMilliseconds(0, true, -85000000, 0) == 0); @@ -156,6 +157,16 @@ class PowerScheduleTests(unittest.TestCase): Settings actual; assert(decode(bytes, sizeof(bytes), actual) && actual.offsetMinutes == offset); } + // Validate raw wire ranges before assigning compact11-bit in-memory fields. + for (unsigned field : {6U, 8U}) for (uint16_t bad : {uint16_t(2048), uint16_t(65535)}) { + Settings good; good.hours = true; good.startMinute = 480; good.endMinute = 1080; + uint8_t bytes[RecordSize]; encode(bytes, good); + bytes[field] = uint8_t(bad); bytes[field + 1] = uint8_t(bad >> 8); + const uint16_t crc = mesh::emergencypower::checksum(bytes, 12); + bytes[12] = uint8_t(crc); bytes[13] = uint8_t(crc >> 8); + Settings actual; + assert(!decode(bytes, sizeof(bytes), actual) && !actual.daylight && !actual.hours); + } ''') def test_clock_rollback_across_dawn_inside_one_cached_minute(self): @@ -177,6 +188,17 @@ class PowerScheduleTests(unittest.TestCase): assert(exercised); ''') + def test_invalid_clock_then_uint32_ceiling_recomputes_daylight(self): + self.exercise(r''' + Settings settings; settings.daylight = true; + State state; state.begin(settings); + assert(mesh::solar::evaluate(UINT32_MAX, 47600000, -122300000).state + == mesh::solar::State::Night); + assert(state.sleepMilliseconds(UINT32_MAX, true, 47600000, -122300000) > 0); + assert(state.sleepMilliseconds(0, true, 47600000, -122300000) == 0); + assert(state.sleepMilliseconds(UINT32_MAX, true, 47600000, -122300000) > 0); +''') + def test_actual_cli_standalone_hour_schedule_reply_grace_awake_override_and_persistence(self): body = r''' Board board; Clock clock; FILESYSTEM fs; Prefs prefs; Sensors sensors; @@ -254,6 +276,7 @@ class PowerScheduleTests(unittest.TestCase): assert(strstr(reply, "on")); prefs.node_lat = prefs.node_lon = 0; sensors.latitude = 85; sensors.longitude = 0; + ++clock.time; // New known coordinates take effect by the next UTC second. assert(cli.emergencySleepMilliseconds() == 0); // Cached polar-day GPS, without starting receiver. assert(sensors.reads > 0); sensors.cached = false; @@ -267,6 +290,31 @@ class PowerScheduleTests(unittest.TestCase): assert(cli.emergencySleepMilliseconds() == 0); // Missing time fails open for standalone schedules. ''') + def test_actual_cli_recovery_failure_is_isolated_to_its_settings_path(self): + self.cli(r''' + for (bool bad_emergency : {false, true}) { + Board board; Clock clock; FILESYSTEM fs; + fs.blockedPath = bad_emergency ? "/power_emergency" : "/power_schedule"; + CommonCLI cli(board, clock); nowMs = 0; cli.loadEmergencyPower(&fs); + char reply[160]; + const char* blocked = bad_emergency ? "set power.emergency on" : "set power.daylight on"; + assert(cli.handleEmergencyPowerCommand(blocked, reply)); + assert(strstr(reply, "save unconfirmed") && fs.writes == 0); + const char* allowed = bad_emergency ? "set power.hours 08:00-18:00" : "set power.emergency on"; + assert(cli.handleEmergencyPowerCommand(allowed, reply)); + assert(!strncmp(reply, "OK", 2) && fs.commits == 1); + assert(cli.emergencyActive() != bad_emergency); + assert(fs.files.count(bad_emergency ? "/power_schedule" : "/power_emergency") == 1); + assert(fs.files.count(bad_emergency ? "/power_emergency" : "/power_schedule") == 0); + CommonCLI reboot(board, clock); reboot.loadEmergencyPower(&fs); + assert(reboot.emergencyActive() != bad_emergency); + assert(reboot.handleEmergencyPowerCommand("get power.hours", reply)); + assert(strstr(reply, bad_emergency ? "08:00-18:00" : "off")); + assert(reboot.handleEmergencyPowerCommand("get power.daylight", reply)); + assert(strstr(reply, "off")); + } +''') + def test_actual_cli_preserves_legacy_manual_state_and_atomic_failure_leaves_schedule_inactive(self): self.cli(r''' Board board; Clock clock; FILESYSTEM fs; From 1d09ea4bdefc3d3712002daaa39ab00123619498 Mon Sep 17 00:00:00 2001 From: mikecarper Date: Wed, 28 Oct 2026 12:52:48 -0700 Subject: [PATCH 44/61] Expose board-aware setting capabilities and saved primary preamble --- docs/cli_commands.md | 64 +++++- examples/companion_radio/MyMesh.cpp | 27 +++ src/helpers/CommonCLI.cpp | 26 +++ src/helpers/ConfigCapabilities.h | 120 ++++++++++++ test/test_config_capabilities.py | 291 ++++++++++++++++++++++++++++ 5 files changed, 527 insertions(+), 1 deletion(-) create mode 100644 src/helpers/ConfigCapabilities.h create mode 100644 test/test_config_capabilities.py diff --git a/docs/cli_commands.md b/docs/cli_commands.md index 42e08cc80..c1d48e36f 100644 --- a/docs/cli_commands.md +++ b/docs/cli_commands.md @@ -1658,6 +1658,61 @@ Older firmware may reject this command. A flasher should retain its manual connection option when the report is absent, unknown or conflicts with the selected USB device; it must not guess from the board name or shared USB ID. +### Report optional settings support + +`get config.cap` is a read-only, bounded settings-capability query shared by +the infrastructure CLI and Companion CLI. It reports no setting values or +secrets, saves nothing, and does not change radio or Bluetooth state. One +reply lets backup tools distinguish an unavailable setting from a real +getter failure without issuing unsupported requests for every board. + +The exact v1 reply format is: + +```text +config.cap v1 get=XXXXXXXX set=XXXXXXXX fixed=XXXXXXXX na=XXXXXXXX +``` + +Each field is eight hexadecimal digits, using these stable bit assignments: + +| Bit | Setting | +| --- | --- | +| 0 | `system.watchdog` | +| 1 | `radio.cad` scan/retry/max timings | +| 2 | `radio.fem.rxgain` | +| 3 | `radio.fem.txgain` | +| 4 | `radio.rxps.rfrx_disabled` | +| 5 | `bluetooth` | +| 6 | `bluetooth.name` | +| 7 | `display.rotation` | +| 8 | `usb.logging.always` | +| 9 | `usb.logging` | +| 10 | `usb.debug` | +| 11 | `radio.preamble` saved primary preference | + +`get` means a genuine reader exists; `set` means an existing setting command +can restore the value. For `radio.preamble`, restoration uses the fifth +`set radio` field, not a separate `set radio.preamble` command. `fixed` +means there is no user-configurable preference for that setting; a fixed +diagnostic may also have a `get` bit. `na` means the setting does not apply +to this board/build/role. `na` never overlaps another field, and `fixed` +never overlaps `set`. All four bits clear means unknown, not a successful +backup. Bits 12-31 are reserved and zero in v1. This limited manifest does +not claim that all stored preferences are exportable. + +Infrastructure firmware has no mutable CAD scan/retry/max preferences, so +bit 1 is `fixed` there; its existing `get cad` enable/busy diagnostic remains +unchanged. Companion does store and export those timings through +`get radio.cad`. Companion watchdog status is diagnostic only where the +board can report it; it has no persisted Companion watchdog-control policy. +Front-end gain, RX power-saving RF-switch, display rotation and Bluetooth +support follow the actual board and role rather than the firmware filename. +The always-on USB logging preference is not applicable to a separate +dual-CDC logging interface. + +Older firmware may not implement this query. Clients must keep legacy +per-setting reads and report unconfirmed/failed reads honestly instead of +treating an absent or malformed capability response as not applicable. + ### Control live USB logging **Search terms:** serial logging, USB debug log, enable logging, disable logging, debug output. @@ -1999,7 +2054,8 @@ node after the save succeeds. See [regulator recovery and hardware checks](power #### View or change this node's radio parameters **Usage:** - `get radio` -- `set radio ,,,` +- `get radio.preamble` +- `set radio ,,,[,]` **Parameters:** - `freq`: Frequency in MHz @@ -2015,6 +2071,12 @@ node after the save succeeds. See [regulator recovery and hardware checks](power existing Companion apps. On dual-radio builds, use `get radio2` and `get radio.timing` for profile and timing diagnostics. +`get radio.preamble` reads the **saved primary** preamble preference: `auto` +or an explicit symbol count from `8` through `65528`. It does not report a +temporary profile or replace `auto` with a computed recommendation. Backup +tools can restore it using the optional fifth `set radio` field. Omitting +that field retains the existing setter behavior: select `auto`. + **Note:** Companion USB, BLE and WebConfig changes apply automatically when the radio is free. A busy radio keeps the saved target across disconnects. Infrastructure saves immediately, waits two seconds, then applies when the radio diff --git a/examples/companion_radio/MyMesh.cpp b/examples/companion_radio/MyMesh.cpp index 56a4482a6..fe7925a75 100644 --- a/examples/companion_radio/MyMesh.cpp +++ b/examples/companion_radio/MyMesh.cpp @@ -1,4 +1,5 @@ #include +#include #include #if defined(USE_LR2021) #include @@ -4116,6 +4117,32 @@ bool MyMesh::handleLocalControlCommand(const char* command, char* reply, return true; } #endif + if (mesh::configcap::isCommand(command)) { + mesh::configcap::Features features; + bool watchdog_running = false; + features.watchdog_read = board.getSystemWatchdogRunning(watchdog_running); + features.cad_timings = true; + features.fem_rxgain = board.canControlLoRaFemLna(); + features.fem_txgain = board.canControlLoRaFemPaGain(); + features.fem_txgain_fixed = board.isLoRaFemPaGainEnabled(); + features.rxps_rfrx_disabled = radio_driver.supportsRxPowerSavingRfRxDisable(); +#if defined(BLE_PIN_CODE) + features.bluetooth = true; + features.bluetooth_name = true; +#endif + features.display_rotation = _ui != nullptr && _ui->supportsDisplayRotation(); +#if MESH_USB_LOGGING_AVAILABLE + features.usb_logging = true; +#if !defined(MESH_DUAL_CDC_LOGGING) + features.usb_logging_always = true; +#endif +#endif + return mesh::configcap::handleCommand(command, reply, reply_size, features); + } + if (strcmp(command, "get radio.preamble") == 0) { + mesh::configcap::formatPrimaryPreamble(_radio_profiles.primaryPreamble(), reply, reply_size); + return true; + } if (mesh::powerdiag::handleCommand(command, reply, reply_size, millis())) return true; if (mesh::companion::handlePowerDiagnosticsCommand(board, command, reply, reply_size)) return true; #if defined(ESP32_PLATFORM) diff --git a/src/helpers/CommonCLI.cpp b/src/helpers/CommonCLI.cpp index 8d227e6da..c86d96578 100644 --- a/src/helpers/CommonCLI.cpp +++ b/src/helpers/CommonCLI.cpp @@ -1,4 +1,5 @@ #include "CLIReplyText.h" +#include "ConfigCapabilities.h" #include #if defined(USE_LR2021) #include @@ -5994,6 +5995,31 @@ void CommonCLI::handleSetCmd(uint32_t sender_timestamp, char* command, char* rep void CommonCLI::handleGetCmd(uint32_t sender_timestamp, char* command, char* reply) { if (mesh::handleUsbModeCommand(command, reply, 160, Serial)) return; const char* config = &command[4]; + if (mesh::configcap::isCommand(command)) { + mesh::configcap::Features features; +#if defined(NRF52_PLATFORM) + features.watchdog_read = features.watchdog_write = true; +#endif + features.fem_rxgain = _board->canControlLoRaFemLna(); + features.fem_txgain = _board->canControlLoRaFemPaGain(); + features.rxps_rfrx_disabled = _callbacks->supportsRxPowerSavingRfRxDisable(); +#if defined(HELTEC_V4_R8_TFT) && defined(HELTEC_TFT_RUNTIME_ROTATION) \ + && HELTEC_TFT_RUNTIME_ROTATION == 1 + features.display_rotation = true; +#endif +#if MESH_USB_LOGGING_AVAILABLE + features.usb_logging = true; +#if !defined(MESH_DUAL_CDC_LOGGING) + features.usb_logging_always = true; +#endif +#endif + mesh::configcap::handleCommand(command, reply, 160, features); + return; + } + if (strcmp(config, "radio.preamble") == 0) { + mesh::configcap::formatPrimaryPreamble(_prefs->primary_radio_preamble, reply, 160); + return; + } #if defined(RAK_4631) if (mesh::peripherals::handleCommand(_prefs->peripherals_shared, command, reply, 160, _sensors->supportsSharedPeripheralMode(), diff --git a/src/helpers/ConfigCapabilities.h b/src/helpers/ConfigCapabilities.h new file mode 100644 index 000000000..ea75983c3 --- /dev/null +++ b/src/helpers/ConfigCapabilities.h @@ -0,0 +1,120 @@ +#pragma once + +#include +#include +#include +#include + +namespace mesh { namespace configcap { + +// config.cap v1 bit assignments are a wire contract. Do not reorder or reuse +// bits; unknown/reserved bits stay zero. This is a bounded subset, not a claim +// that every stored preference can be exported or restored. +enum Key : uint8_t { + SystemWatchdog = 0, // system.watchdog + RadioCad = 1, // radio.cad (scan/retry/max timing preferences) + RadioFemRxGain = 2, // radio.fem.rxgain + RadioFemTxGain = 3, // radio.fem.txgain + RadioRxpsRfRxDisabled = 4, // radio.rxps.rfrx_disabled + Bluetooth = 5, // bluetooth + BluetoothName = 6, // bluetooth.name + DisplayRotation = 7, // display.rotation + UsbLoggingAlways = 8, // usb.logging.always + UsbLogging = 9, // usb.logging + UsbDebug = 10, // usb.debug + RadioPreamble = 11, // radio.preamble (saved primary-radio preference) + KeyCount = 12 +}; + +struct Manifest { + uint32_t get = 0; + uint32_t set = 0; + uint32_t fixed = 0; + uint32_t na = 0; + + void readWrite(Key key) { + get |= uint32_t(1) << key; + set |= uint32_t(1) << key; + } + void notApplicable(Key key) { na |= uint32_t(1) << key; } + void fixedValue(Key key, bool readable = false) { + fixed |= uint32_t(1) << key; + if (readable) get |= uint32_t(1) << key; + } + void configurable(Key key, bool supported) { + if (supported) readWrite(key); + else notApplicable(key); + } +}; + +// Inputs describe actual parser/board support, not whether a setting happens +// to be enabled, a management client is connected, or a runtime radio is busy. +struct Features { + bool watchdog_read = false; + bool watchdog_write = false; + bool cad_timings = false; + bool fem_rxgain = false; + bool fem_txgain = false; + bool fem_txgain_fixed = false; + bool rxps_rfrx_disabled = false; + bool bluetooth = false; + bool bluetooth_name = false; + bool display_rotation = false; + bool usb_logging = false; + bool usb_logging_always = false; +}; + +inline Manifest describe(const Features& features) { + Manifest result; + if (features.watchdog_read) { + if (features.watchdog_write) result.readWrite(SystemWatchdog); + else result.fixedValue(SystemWatchdog, true); + } else { + result.notApplicable(SystemWatchdog); + } + // Infrastructure CAD has an enable gate, but no saved scan/retry/max + // timing preferences. Do not fabricate restoreable timing values for it. + if (features.cad_timings) result.readWrite(RadioCad); + else result.fixedValue(RadioCad); + result.configurable(RadioFemRxGain, features.fem_rxgain); + if (features.fem_txgain) result.readWrite(RadioFemTxGain); + else if (features.fem_txgain_fixed) result.fixedValue(RadioFemTxGain, true); + else result.notApplicable(RadioFemTxGain); + result.configurable(RadioRxpsRfRxDisabled, features.rxps_rfrx_disabled); + result.configurable(Bluetooth, features.bluetooth); + result.configurable(BluetoothName, features.bluetooth_name); + result.configurable(DisplayRotation, features.display_rotation); + result.configurable(UsbLoggingAlways, + features.usb_logging && features.usb_logging_always); + result.configurable(UsbLogging, features.usb_logging); + result.configurable(UsbDebug, features.usb_logging); + // Both CLI roles restore this using the existing optional fifth field of + // set radio ,,,,. + result.readWrite(RadioPreamble); + return result; +} + +inline bool isCommand(const char* command) { + return command != nullptr && strcmp(command, "get config.cap") == 0; +} + +inline bool handleCommand(const char* command, char* reply, size_t capacity, + const Features& features) { + if (!isCommand(command) || reply == nullptr || capacity == 0) return false; + const Manifest result = describe(features); + snprintf(reply, capacity, + "config.cap v1 get=%08lX set=%08lX fixed=%08lX na=%08lX", + (unsigned long)result.get, (unsigned long)result.set, + (unsigned long)result.fixed, (unsigned long)result.na); + return true; +} + +// Read saved intent, not a temporary profile or a computed dwell recommendation. +inline void formatPrimaryPreamble(uint16_t preamble, char* reply, + size_t capacity) { + if (reply == nullptr || capacity == 0) return; + if (preamble == 0) snprintf(reply, capacity, "> auto"); + else snprintf(reply, capacity, "> %u", (unsigned)preamble); +} + +} } // namespace mesh::configcap diff --git a/test/test_config_capabilities.py b/test/test_config_capabilities.py new file mode 100644 index 000000000..a910275dc --- /dev/null +++ b/test/test_config_capabilities.py @@ -0,0 +1,291 @@ +"""Execute the bounded settings manifest and the actual role-specific readers.""" +from pathlib import Path +import os +import shutil +import subprocess +import tempfile +import unittest + +from cpp_source import body + +ROOT = Path(__file__).resolve().parents[1] + +HARNESS = r''' +#include +#include +#include +#include +#include +using namespace mesh::configcap; +static uint32_t bit(Key key) { return uint32_t(1) << key; } +static void valid(const Manifest& cap) { + const uint32_t known = (uint32_t(1) << KeyCount) - 1; + assert(!((cap.get | cap.set | cap.fixed | cap.na) & ~known)); + assert(!(cap.na & (cap.get | cap.set | cap.fixed))); + assert(!(cap.fixed & cap.set)); +} +static Manifest decode(const char* reply) { + unsigned long get, set, fixed, na; + int consumed = 0; + assert(sscanf(reply, "config.cap v1 get=%8lX set=%8lX fixed=%8lX na=%8lX%n", + &get, &set, &fixed, &na, &consumed) == 4); + assert(reply[consumed] == 0); + Manifest cap; + cap.get=get; cap.set=set; cap.fixed=fixed; cap.na=na; + valid(cap); + return cap; +} +struct Board { + bool watchdog = false, fem_rx = false, fem_tx = false, fixed_pa = false; + bool canControlLoRaFemLna() const { return fem_rx; } + bool canControlLoRaFemPaGain() const { return fem_tx; } + bool isLoRaFemPaGainEnabled() const { return fixed_pa; } + bool getSystemWatchdogRunning(bool& running) const { + running = true; + return watchdog; + } +}; +struct Radio { + bool rf_rx = false; + bool supportsRxPowerSavingRfRxDisable() const { return rf_rx; } +}; +struct UI { + bool rotation = false; + bool supportsDisplayRotation() const { return rotation; } +}; +struct Profiles { + uint16_t saved = 0; + uint16_t primaryPreamble() const { return saved; } +}; +struct Prefs { uint16_t primary_radio_preamble = 0; }; +struct CommonCLI { + Board board; + Board* _board = &board; + Radio radio; + Radio* _callbacks = &radio; + Prefs prefs; + Prefs* _prefs = &prefs; + void handleGetCmd(char* command, char* reply) { + const char* config = command + 4; + @COMMON@ + @COMMON_PREAMBLE@ + strcpy(reply, "unsupported"); + } +}; +struct MyMesh { + Board board; + Radio radio_driver; + UI ui; + UI* _ui = nullptr; + Profiles _radio_profiles; + bool handleCommand(const char* command, uint32_t sender_timestamp, char* reply); + bool handleLocalControlCommand(const char* command, char* reply, size_t reply_size) { + @COMPANION@ + @COMPANION_PREAMBLE@ + return false; + } +}; +@COMPANION_ENTRY@ +static void check(const Manifest& cap, Key key, bool get, bool set, + bool fixed, bool na) { + assert(bool(cap.get & bit(key)) == get); + assert(bool(cap.set & bit(key)) == set); + assert(bool(cap.fixed & bit(key)) == fixed); + assert(bool(cap.na & bit(key)) == na); +} +int main() { + static_assert(SystemWatchdog == 0 && RadioCad == 1 && RadioFemRxGain == 2 + && RadioFemTxGain == 3 && RadioRxpsRfRxDisabled == 4 && Bluetooth == 5 + && BluetoothName == 6 && DisplayRotation == 7 && UsbLoggingAlways == 8 + && UsbLogging == 9 && UsbDebug == 10 && RadioPreamble == 11 + && KeyCount == 12, "v1 wire bits changed"); + Features features; + char reply[160] = {}; + assert(handleCommand("get config.cap", reply, sizeof(reply), features)); + assert(!strcmp(reply, "config.cap v1 get=00000800 set=00000800 fixed=00000002 na=000007FD")); + const char expected[] = "config.cap v1 get=00000800 set=00000800 fixed=00000002 na=000007FD"; + assert(sizeof(expected) < 160); + for (const char* command : std::initializer_list{nullptr, "", "get config.cap extra", + "get config.cap ", "get config.cap\n", "get CONFIG.CAP", "set config.cap"}) { + strcpy(reply, "unchanged"); + assert(!handleCommand(command, reply, sizeof(reply), features)); + assert(!strcmp(reply, "unchanged")); + } + assert(!handleCommand("get config.cap", nullptr, sizeof(reply), features)); + assert(!handleCommand("get config.cap", reply, 0, features)); + assert(!strcmp(reply, "unchanged")); + for (size_t capacity = 1; capacity <= sizeof(expected); ++capacity) { + unsigned char guarded[160]; + memset(guarded, 0xA5, sizeof(guarded)); + assert(handleCommand("get config.cap", reinterpret_cast(guarded + 1), capacity, features)); + assert(guarded[0] == 0xA5 && guarded[capacity + 1] == 0xA5); + assert(guarded[capacity] == 0); + assert(!strncmp(reinterpret_cast(guarded + 1), expected, capacity - 1)); + } + // Exercise every combination, including contradictory SDK inputs: the wire + // result must never classify a setting as both mutable and not applicable. + for (unsigned flags = 0; flags < (1u << 12); ++flags) { + features.watchdog_read = flags & 1; + features.watchdog_write = flags & 2; + features.cad_timings = flags & 4; + features.fem_rxgain = flags & 8; + features.fem_txgain = flags & 16; + features.fem_txgain_fixed = flags & 32; + features.rxps_rfrx_disabled = flags & 64; + features.bluetooth = flags & 128; + features.bluetooth_name = flags & 256; + features.display_rotation = flags & 512; + features.usb_logging = flags & 1024; + features.usb_logging_always = flags & 2048; + valid(describe(features)); + } + CommonCLI common; + MyMesh companion; + char cap_command[] = "get config.cap"; + assert(companion.handleCommand("xy| \tget config.cap", 0, reply)); + assert(!strncmp(reply, "xy|config.cap v1 ", 17)); + assert(companion.handleCommand("xy|get radio.preamble", 0, reply)); + assert(!strcmp(reply, "xy|> auto")); + for (unsigned flags = 0; flags < 64; ++flags) { + common.board.fem_rx = companion.board.fem_rx = flags & 1; + common.board.fem_tx = companion.board.fem_tx = flags & 2; + common.radio.rf_rx = companion.radio_driver.rf_rx = flags & 4; + companion.board.fixed_pa = flags & 8; + companion.board.watchdog = flags & 16; + companion.ui.rotation = flags & 32; + companion._ui = &companion.ui; + common.handleGetCmd(cap_command, reply); + Manifest cap = decode(reply); +#if defined(NRF52_PLATFORM) + check(cap, SystemWatchdog, true, true, false, false); +#else + check(cap, SystemWatchdog, false, false, false, true); +#endif + check(cap, RadioCad, false, false, true, false); + check(cap, RadioFemRxGain, flags & 1, flags & 1, false, !(flags & 1)); + check(cap, RadioFemTxGain, flags & 2, flags & 2, false, !(flags & 2)); + check(cap, RadioRxpsRfRxDisabled, flags & 4, flags & 4, false, !(flags & 4)); + check(cap, Bluetooth, false, false, false, true); + check(cap, BluetoothName, false, false, false, true); +#if defined(HELTEC_V4_R8_TFT) && defined(HELTEC_TFT_RUNTIME_ROTATION) && HELTEC_TFT_RUNTIME_ROTATION == 1 + check(cap, DisplayRotation, true, true, false, false); +#else + check(cap, DisplayRotation, false, false, false, true); +#endif + assert(companion.handleLocalControlCommand(cap_command, reply, sizeof(reply))); + cap = decode(reply); + check(cap, SystemWatchdog, flags & 16, false, flags & 16, !(flags & 16)); + check(cap, RadioCad, true, true, false, false); + check(cap, RadioFemRxGain, flags & 1, flags & 1, false, !(flags & 1)); + const bool pa_fixed = !(flags & 2) && (flags & 8); + check(cap, RadioFemTxGain, (flags & 2) || pa_fixed, flags & 2, pa_fixed, + !(flags & 2) && !pa_fixed); + check(cap, RadioRxpsRfRxDisabled, flags & 4, flags & 4, false, !(flags & 4)); +#if defined(BLE_PIN_CODE) + check(cap, Bluetooth, true, true, false, false); + check(cap, BluetoothName, true, true, false, false); +#else + check(cap, Bluetooth, false, false, false, true); + check(cap, BluetoothName, false, false, false, true); +#endif + check(cap, DisplayRotation, flags & 32, flags & 32, false, !(flags & 32)); + for (bool is_common : {true, false}) { + if (is_common) common.handleGetCmd(cap_command, reply); + else assert(companion.handleLocalControlCommand(cap_command, reply, sizeof(reply))); + cap = decode(reply); +#if MESH_USB_LOGGING_AVAILABLE + check(cap, UsbLogging, true, true, false, false); + check(cap, UsbDebug, true, true, false, false); +#if !defined(MESH_DUAL_CDC_LOGGING) + check(cap, UsbLoggingAlways, true, true, false, false); +#else + check(cap, UsbLoggingAlways, false, false, false, true); +#endif +#else + check(cap, UsbLogging, false, false, false, true); + check(cap, UsbDebug, false, false, false, true); + check(cap, UsbLoggingAlways, false, false, false, true); +#endif + check(cap, RadioPreamble, true, true, false, false); + } + } + companion._ui = nullptr; + assert(companion.handleLocalControlCommand(cap_command, reply, sizeof(reply))); + check(decode(reply), DisplayRotation, false, false, false, true); + char preamble_command[] = "get radio.preamble"; + for (uint16_t preamble : {0, 8, 32, 88, 65528}) { + common.prefs.primary_radio_preamble = companion._radio_profiles.saved = preamble; + common.handleGetCmd(preamble_command, reply); + char actual[160]; + assert(companion.handleLocalControlCommand(preamble_command, actual, sizeof(actual))); + assert(!strcmp(actual, reply)); + if (!preamble) assert(!strcmp(actual, "> auto")); + else { char expected_preamble[16]; snprintf(expected_preamble, sizeof(expected_preamble), "> %u", (unsigned)preamble); + assert(!strcmp(actual, expected_preamble)); } + assert(common.prefs.primary_radio_preamble == preamble); + assert(companion._radio_profiles.saved == preamble); + uint16_t parsed = 123; + assert(mesh::cli::parseRadioPreamble(actual + 2, parsed)); + assert(parsed == preamble); + } + for (const char* invalid : {"1", "4", "7", "65529", "65535", "65536", "-1"}) { + uint16_t parsed = 123; + assert(!mesh::cli::parseRadioPreamble(invalid, parsed)); + } + formatPrimaryPreamble(32, nullptr, sizeof(reply)); + strcpy(reply, "unchanged"); + formatPrimaryPreamble(32, reply, 0); + assert(!strcmp(reply, "unchanged")); +} +''' + + +class ConfigCapabilitiesTests(unittest.TestCase): + def test_compiled_role_capabilities_and_saved_preamble(self): + compiler = os.environ.get("CXX") or shutil.which("g++") or shutil.which("clang++") + if not compiler: + self.skipTest("a host C++11 compiler is required") + common = (ROOT / "src/helpers/CommonCLI.cpp").read_text() + companion = (ROOT / "examples/companion_radio/MyMesh.cpp").read_text() + common_get = body(common, "void CommonCLI::handleGetCmd(") + companion_control = body(companion, "bool MyMesh::handleLocalControlCommand(") + source = HARNESS.replace("@COMMON@", body(common_get, "if (mesh::configcap::isCommand(command))")) + source = source.replace("@COMMON_PREAMBLE@", body(common_get, 'if (strcmp(config, "radio.preamble") == 0)')) + source = source.replace("@COMPANION@", body(companion_control, "if (mesh::configcap::isCommand(command))")) + source = source.replace("@COMPANION_PREAMBLE@", body(companion_control, 'if (strcmp(command, "get radio.preamble") == 0)')) + # Retain the production protocol-14 request prefix and capacity logic; + # omit unrelated radio handlers after its first ESP32-specific block. + companion_entry = body(companion, "bool MyMesh::handleCommand(") + companion_entry = companion_entry[:companion_entry.index("#if defined(ESP32_PLATFORM)")] + companion_entry = companion_entry.replace("{", "{ (void)sender_timestamp;", 1) + companion_entry += "return handleLocalControlCommand(command, reply, reply_capacity);\n}" + source = source.replace("@COMPANION_ENTRY@", companion_entry) + self.assertIn("handleLocalControlCommand(command, reply, reply_capacity)", + body(companion, "bool MyMesh::handleCommand(")) + self.assertIn('memcmp(command, "get ", 4)', body(common, "void CommonCLI::handleCommand(")) + configurations = ( + [], + ["NRF52_PLATFORM", "MESH_USB_LOGGING_AVAILABLE=1", "BLE_PIN_CODE=123456"], + ["NRF52_PLATFORM", "MESH_USB_LOGGING_AVAILABLE=1", "MESH_DUAL_CDC_LOGGING", "BLE_PIN_CODE=123456"], + ["ESP32_PLATFORM", "MESH_USB_LOGGING_AVAILABLE=1", "BLE_PIN_CODE=123456"], + ["ESP32_PLATFORM", "MESH_USB_LOGGING_AVAILABLE=1", "HELTEC_V4_R8_TFT", "HELTEC_TFT_RUNTIME_ROTATION=1"], + ["ESP32_PLATFORM", "MESH_USB_LOGGING_AVAILABLE=0", "HELTEC_V4_R8_TFT", "HELTEC_TFT_RUNTIME_ROTATION=0"], + ) + with tempfile.TemporaryDirectory(prefix="meshcore-config-cap-") as directory: + path = Path(directory) / "config_cap.cpp" + path.write_text(source, encoding="ascii") + for language in ("c++11", "c++17"): + for index, flags in enumerate(configurations): + with self.subTest(language=language, flags=flags): + binary = Path(directory) / ("config_cap_" + language + "_" + str(index) + ".exe") + built = subprocess.run([compiler, "-std=" + language, "-Wall", "-Wextra", "-Werror", + *([] if os.name == "nt" else ["-fsanitize=address,undefined", "-fno-sanitize-recover=all", "-fno-pie", "-no-pie"]), + *["-D" + flag for flag in flags], "-I" + str(ROOT / "src"), + str(path), "-o", str(binary)], capture_output=True, text=True, timeout=60) + self.assertEqual(built.returncode, 0, built.stdout + built.stderr) + ran = subprocess.run([str(binary)], capture_output=True, text=True, timeout=15) + self.assertEqual(ran.returncode, 0, ran.stdout + ran.stderr) + + +if __name__ == "__main__": + unittest.main() From 67009e0d1e434023b47b809bb09d25a7bdcffe41 Mon Sep 17 00:00:00 2001 From: mikecarper Date: Wed, 28 Oct 2026 14:03:31 -0700 Subject: [PATCH 45/61] Add ESP32 app slot selection to the CLI --- .github/workflows/run-unit-tests.yml | 1 + docs/cli_command_availability.md | 2 + docs/cli_commands.md | 30 +++ docs/esp32_partition_catalog.md | 7 + examples/companion_radio/MyMesh.cpp | 2 + examples/simple_repeater/MyMesh.h | 4 + examples/simple_room_server/MyMesh.h | 4 + src/helpers/BootSlot.cpp | 124 ++++++++++ src/helpers/BootSlot.h | 16 ++ src/helpers/CommonCLI.cpp | 3 + src/helpers/CommonCLI.h | 3 + test/fixtures/cli_reply_text.json | 6 +- test/test_boot_slot_cli.py | 345 +++++++++++++++++++++++++++ 13 files changed, 544 insertions(+), 3 deletions(-) create mode 100644 src/helpers/BootSlot.cpp create mode 100644 src/helpers/BootSlot.h create mode 100644 test/test_boot_slot_cli.py diff --git a/.github/workflows/run-unit-tests.yml b/.github/workflows/run-unit-tests.yml index ba4eea7d8..cffc53a52 100644 --- a/.github/workflows/run-unit-tests.yml +++ b/.github/workflows/run-unit-tests.yml @@ -612,6 +612,7 @@ jobs: python3 -B test/test_companion_rtc_terminal_input.py -v python3 -B test/test_esp32_cpu_clock.py -v python3 -B test/test_esp32_cpu_clock_board.py -v + python3 -B test/test_boot_slot_cli.py -v python3 -B test/test_esp32_power_test_controls.py -v python3 -B test/test_esp32_uart_quiet_session.py -v python3 -B test/test_companion_rxps_getters.py -v diff --git a/docs/cli_command_availability.md b/docs/cli_command_availability.md index 1333d4d9f..7a071820b 100644 --- a/docs/cli_command_availability.md +++ b/docs/cli_command_availability.md @@ -115,6 +115,7 @@ over the normal binary USB, BLE, or TCP connection: | [`board`](cli_commands.md#show-the-hardware-name) | Every Companion text terminal and command `0x42` | | [`version`](cli_commands.md#get-the-version) | Every Companion text terminal and protocol-v14 command `0x42`; returns the untruncated build identity | | [`get storage.layout`](cli_commands.md#show-the-storage-layout) | Every Companion text terminal and command `0x42`; reports the running platform's storage layout | +| [`get/set boot.slot`](cli_commands.md#select-the-next-application-slot-esp32) | ESP32 Companion terminal, command `0x42`, and authorized LoRa CLI; select an installed `app0` or `app1` image for the next reboot | | [`memory`](#memory) | ESP32 Full Companion text terminal and command `0x42` | | [`get/set radio.rxgain`](cli_commands.md#view-or-change-this-nodes-rx-boosted-gain-mode-sx12xx-and-lr1110-v1141) | Companion with a supported radio | | [`get/set radio.fem.rxgain`](cli_commands.md#view-or-change-the-lora-fem-receive-path-gain-state-on-supported-boards) | Companion on a board with controllable LoRa FEM LNA | @@ -323,6 +324,7 @@ should use command `0x42`. See [Companion radio binary protocol](companion_proto | Information | [`ver`](cli_commands.md#get-the-version) | All text CLI roles | Yes | Yes | Yes | Yes | Yes | | Information | [`board`](cli_commands.md#show-the-hardware-name) | All text CLI roles | Yes | Yes | Yes | Yes | Yes | | Diagnostics | [`get storage.layout`](cli_commands.md#show-the-storage-layout) | Detected flash size and live partition table | Yes | Yes | Yes | Yes | Yes | +| Operational | [`get/set boot.slot`](cli_commands.md#select-the-next-application-slot-esp32) | Installed ESP32 `app0`/`app1`; selected image starts after `reboot` | Yes | Yes | Yes | Yes | Yes | | Diagnostics | [`memory`](#memory) | ESP32 heap and PSRAM summary | Yes | Yes | Yes | Yes | Yes | | Diagnostics | [`sensor`](#sensor-hardware-summary) | Hardware wiring summary | Yes | Yes | Yes | Yes | Yes | | Diagnostics | [`powerlog`](#powerlog) | Reset-reason summary | Yes | Yes | Yes | Yes | Yes | diff --git a/docs/cli_commands.md b/docs/cli_commands.md index c1d48e36f..97447173a 100644 --- a/docs/cli_commands.md +++ b/docs/cli_commands.md @@ -145,6 +145,36 @@ command aliases are listed there explicitly. --- +### Select the next application slot (ESP32) + +**Usage:** + +```text +get boot.slot +set boot.slot app1 +get boot.slot +reboot +``` + +Use `app0` or `app1` to select an installed application for the next reboot. +This works through the ESP32 Repeater, Room Server, Sensor and Companion CLI +with the usual administration permissions. Selection sends a reply and does +not reboot automatically. + +`get boot.slot` reports the running application, the next boot selection and +each slot's capacity, or `missing`. A capacity does not mean the slot contains +a valid image. Selection verifies the complete image through ESP-IDF and +rejects missing, corrupt, rejected or unconfirmed applications. It does not +create a second slot or install firmware. + +Finish or stop an active/staged OTA update before changing slots; stop the +WiFi OTA server if it is running. Use firmware for the exact board and installed +partition layout. Both applications share NVS and filesystem settings, and a +later OTA update can overwrite the inactive application or change the next +boot selection. Automatic rollback depends on the installed bootloader. + +--- + ### Enter the UF2 bootloader (nRF52 only) **Search terms:** bootloader mode, USB firmware update, UF2 mode. diff --git a/docs/esp32_partition_catalog.md b/docs/esp32_partition_catalog.md index 66354df43..d33e07006 100644 --- a/docs/esp32_partition_catalog.md +++ b/docs/esp32_partition_catalog.md @@ -18,6 +18,13 @@ Mixed layouts means some releases have dual-slot OTA and others do not. These are historical factory-image minimums, not measurements of your device; use `get storage.layout` to check its actual installed layout. +Use `get boot.slot` to see the running application, next boot selection and +installed `app0`/`app1` capacities. Select an existing application with +`set boot.slot app0` or `set boot.slot app1`, then run `reboot` to start it. +The command validates the image and refuses a missing slot; it does not change +the partition table. See [application slot selection](cli_commands.md#select-the-next-application-slot-esp32) +for update conflicts and shared settings. +
diff --git a/examples/companion_radio/MyMesh.cpp b/examples/companion_radio/MyMesh.cpp index fe7925a75..9512df67f 100644 --- a/examples/companion_radio/MyMesh.cpp +++ b/examples/companion_radio/MyMesh.cpp @@ -5,6 +5,7 @@ #include #endif #if defined(ESP32_PLATFORM) +#include #include #include #endif @@ -4146,6 +4147,7 @@ bool MyMesh::handleLocalControlCommand(const char* command, char* reply, if (mesh::powerdiag::handleCommand(command, reply, reply_size, millis())) return true; if (mesh::companion::handlePowerDiagnosticsCommand(board, command, reply, reply_size)) return true; #if defined(ESP32_PLATFORM) + if (mesh::esp32::handleBootSlotCommand(command, board, reply, reply_size)) return true; if (mesh::esp32::cpuclock::handleCommand(board, _prefs.cpu_freq_mhz, _prefs.powersaving_enabled != 0, command, reply, reply_size, [this]() { return savePrefs(); })) return true; diff --git a/examples/simple_repeater/MyMesh.h b/examples/simple_repeater/MyMesh.h index 9f93578bb..3709f71aa 100644 --- a/examples/simple_repeater/MyMesh.h +++ b/examples/simple_repeater/MyMesh.h @@ -2052,6 +2052,10 @@ public: return true; } +#if defined(ESP32_PLATFORM) + bool isDeferredOtaUpdatePending() const override { return _ota_update_at != 0; } +#endif + int getQueueSize() override { #ifdef WITH_MQTT_BRIDGE return mqtt_bridge ? mqtt_bridge->getQueueSize() : 0; diff --git a/examples/simple_room_server/MyMesh.h b/examples/simple_room_server/MyMesh.h index 3ad07fcec..1ae5fd6d2 100644 --- a/examples/simple_room_server/MyMesh.h +++ b/examples/simple_room_server/MyMesh.h @@ -1049,6 +1049,10 @@ public: return true; } +#if defined(ESP32_PLATFORM) + bool isDeferredOtaUpdatePending() const override { return _ota_update_at != 0; } +#endif + int getQueueSize() override { return bridge ? bridge->getQueueSize() : 0; } diff --git a/src/helpers/BootSlot.cpp b/src/helpers/BootSlot.cpp new file mode 100644 index 000000000..b0e739971 --- /dev/null +++ b/src/helpers/BootSlot.cpp @@ -0,0 +1,124 @@ +#include "BootSlot.h" + +#if defined(ESP32_PLATFORM) +#include +#include +#include +#include +#include +#include +#include "esp32/ManagementPassword.h" +#if defined(ENABLE_OTA) +#include "ota/OtaContext.h" +#endif + +namespace mesh { +namespace esp32 { +namespace { + +const esp_partition_t* appSlot(unsigned index) { + const auto subtype = static_cast( + ESP_PARTITION_SUBTYPE_APP_OTA_0 + index); + const char* label = index == 0 ? "app0" : "app1"; + return esp_partition_find_first(ESP_PARTITION_TYPE_APP, subtype, label); +} + +bool otaBusy() { + if (Update.isRunning()) return true; +#if defined(ENABLE_OTA) + // Status/selection must not allocate an OTA workspace or reopen flash staging. + const auto* context = mesh::ota::ota_context_if_active(); + if (context) { + const auto state = context->manager.fetchState(); + if ((state != mesh::ota::OtaManager::IDLE && + state != mesh::ota::OtaManager::COMPLETE && + state != mesh::ota::OtaManager::FAILED) || + context->apply_pending || context->bootloader_apply_pending || + context->fetch_store.staged_size() != 0 || context->folder_active || + context->serve_expected != 0 || context->manager.pendingServeJobs() || + context->manager.pendingManifestJobs()) return true; + } +#endif + return false; +} + +} // namespace + +bool handleBootSlotCommand(const char* command, MainBoard& board, char* reply, + size_t capacity, bool update_pending) { + if (!command) return false; + const bool get = strncmp(command, "get boot.slot", 13) == 0 && + (command[13] == 0 || command[13] == ' ' || command[13] == '\t'); + const bool set = strncmp(command, "set boot.slot", 13) == 0 && + (command[13] == 0 || command[13] == ' ' || command[13] == '\t'); + if (!get && !set) return false; + if (!reply || !capacity) return true; + + if (get) { + if (command[13] != 0) { + snprintf(reply, capacity, "Error: use get boot.slot"); + return true; + } + const auto* running = esp_ota_get_running_partition(); + const auto* boot = esp_ota_get_boot_partition(); + char sizes[2][24]; + for (unsigned index = 0; index < 2; ++index) { + const auto* partition = appSlot(index); + if (partition) snprintf(sizes[index], sizeof(sizes[index]), "%luK", + static_cast(partition->size / 1024)); + else snprintf(sizes[index], sizeof(sizes[index]), "missing"); + } + snprintf(reply, capacity, "> running=%s next=%s app0=%s app1=%s", + running ? running->label : "unknown", boot ? boot->label : "unknown", + sizes[0], sizes[1]); + return true; + } + + const char* value = command + 13; + while (*value == ' ' || *value == '\t') ++value; + if (strcmp(value, "app0") != 0 && strcmp(value, "app1") != 0) { + snprintf(reply, capacity, "Error: use set boot.slot app0|app1"); + return true; + } + + // HTTP upload tasks use the matching UploadGuard. Keep them excluded until + // the SDK has verified the image and committed the next-boot metadata. + managementPassword::ChangeGuard guard; + if (!guard.acquired() || update_pending || board.isOTAUpdateRunning() || otaBusy()) { + snprintf(reply, capacity, "Error: OTA busy/staged; stop uploads or use ota install / ota cancel first"); + return true; + } + const auto* partition = appSlot(value[3] - '0'); + if (!partition) { + snprintf(reply, capacity, "Error: %s missing from installed partition table", value); + return true; + } + esp_ota_img_states_t state; + const esp_err_t state_error = esp_ota_get_state_partition(partition, &state); + if (state_error != ESP_OK && state_error != ESP_ERR_NOT_FOUND) { + snprintf(reply, capacity, "Error: cannot read %s boot state (%ld)", value, + static_cast(state_error)); + return true; + } + if (state_error == ESP_OK && (state == ESP_OTA_IMG_INVALID || + state == ESP_OTA_IMG_ABORTED || + state == ESP_OTA_IMG_PENDING_VERIFY)) { + snprintf(reply, capacity, "Error: %s is rejected or unconfirmed", value); + return true; + } + + // This performs the complete SDK image/signature check before otadata is + // changed. A header or app-description check alone would accept corruption. + const esp_err_t result = esp_ota_set_boot_partition(partition); + if (result != ESP_OK) { + snprintf(reply, capacity, "Error: cannot select %s; invalid image or boot metadata (%ld)", + value, static_cast(result)); + return true; + } + snprintf(reply, capacity, "OK - boot.slot %s; reboot to apply", value); + return true; +} + +} // namespace esp32 +} // namespace mesh +#endif diff --git a/src/helpers/BootSlot.h b/src/helpers/BootSlot.h new file mode 100644 index 000000000..5c6bfb056 --- /dev/null +++ b/src/helpers/BootSlot.h @@ -0,0 +1,16 @@ +#pragma once + +#if defined(ESP32_PLATFORM) +#include + +namespace mesh { +class MainBoard; +namespace esp32 { + +// Select an installed application for the next boot; never reboot inline. +bool handleBootSlotCommand(const char* command, MainBoard& board, char* reply, + size_t capacity, bool update_pending = false); + +} // namespace esp32 +} // namespace mesh +#endif diff --git a/src/helpers/CommonCLI.cpp b/src/helpers/CommonCLI.cpp index c86d96578..f37aa68b5 100644 --- a/src/helpers/CommonCLI.cpp +++ b/src/helpers/CommonCLI.cpp @@ -21,6 +21,7 @@ #include "HilStartupTrace.h" #include "FilePresence.h" #if defined(ESP32_PLATFORM) +#include "BootSlot.h" #include #include #endif @@ -3598,6 +3599,8 @@ void CommonCLI::handleCommand(uint32_t sender_timestamp, char* command, char* re if (mesh::handleUsbLoggingWatchdogCommand(command, reply, 160)) return; if (mesh::powerdiag::handleCommand(command, reply, 157, millis())) return; #if defined(ESP32_PLATFORM) + if (mesh::esp32::handleBootSlotCommand(command, *_board, reply, 157, + _callbacks->isDeferredOtaUpdatePending())) return; if (mesh::esp32::cpuclock::handleCommand(*_board, _prefs->cpu_freq_mhz, _prefs->powersaving_enabled != 0, command, reply, 157, [this]() { return saveCommonPrefs(); })) return; diff --git a/src/helpers/CommonCLI.h b/src/helpers/CommonCLI.h index f032cfeb2..de57c465c 100644 --- a/src/helpers/CommonCLI.h +++ b/src/helpers/CommonCLI.h @@ -743,6 +743,9 @@ public: virtual bool beginDeferredOtaUpdate() { return false; }; +#if defined(ESP32_PLATFORM) + virtual bool isDeferredOtaUpdatePending() const { return false; } +#endif // Browser-based configuration portal. ESP32 infrastructure roles override // these; force_ap=true asks for the captive SoftAP even when WiFi is set. diff --git a/test/fixtures/cli_reply_text.json b/test/fixtures/cli_reply_text.json index 0f444e7da..b7fc31910 100644 --- a/test/fixtures/cli_reply_text.json +++ b/test/fixtures/cli_reply_text.json @@ -941,15 +941,15 @@ } ], "noncompact_tokens_sha256": { - "src/helpers/CommonCLI.cpp": "0a3d2e2eabd3e161e352176c4a6a4c4baa184944c08c93b522d4f1ddb5a24edd", + "src/helpers/CommonCLI.cpp": "ffde525e649544c1e7821f45810860f555bb4b2bfb9484923682c55830eee9ad", "examples/simple_repeater/MyMesh.cpp": "8567eeef2f327100e397c40b0aaf5b675fcdfea254987437d1ca8d005ad3a6f0", "src/helpers/RadioProfileCLI.cpp": "d641d16e872eba3e73b9b5a50f9718c10b0b7da72782f793706ac36b3724a624", - "src/helpers/WirelessControl.h": "bbde183f091a8afa7fd65c7b379783f8be2ac4e74ae6037a99a71fde3fb89f1e", + "src/helpers/WirelessControl.h": "f19bb602999118cc423ec74c7de7100ed91cb2c553295038077e52e321bb4e73", "src/helpers/PowerSavingDiagnostics.h": "18b78f5780046ea8c772c4ce6c337741ae0c027039d9399c94c5f4205d114000", "src/helpers/FloodAdvertCLI.h": "0f378e4d0f6c3b9b79991068a4b4208139484d2144ee1d7c5094d39043463be4", "src/helpers/LedSettings.cpp": "df0681234ca1337148ad59caa935857bc692f98bf18603ca6d099c0382028400", "src/helpers/StorageLayout.cpp": "5dd1c8bcbe2902e1f22d14f9ab425465bdc38926fae23989987d850b484cbb18", - "examples/companion_radio/MyMesh.cpp": "4ed72908fa1b986edb27c7a74acaccd5fda212c68f2f41537692739c040bb392", + "examples/companion_radio/MyMesh.cpp": "73a0ed65b2c37ea05c0d824e3f048bc0bb956c4f4d5944e43e01b91be93f0764", "src/helpers/CommonRadioPrefs.cpp": "16b1fb499426abde83566b6481df89b80302c72e00f0f4aa3843b6c9b9de4218", "examples/companion_radio/CompanionRetry.h": "e0ae10c7b2f778346f4645c4e147d87e1fbc7f8b99484752a8eed914bb21380f", "examples/companion_radio/Notifications.cpp": "173fb0d452fdd383585dd88ab35fbb079a90e068adca2db9bc44d604557d3873" diff --git a/test/test_boot_slot_cli.py b/test/test_boot_slot_cli.py new file mode 100644 index 000000000..7aa8bfbf2 --- /dev/null +++ b/test/test_boot_slot_cli.py @@ -0,0 +1,345 @@ +"""Execute ESP32 boot selection against mocked IDF flash and OTA services.""" +import os +from pathlib import Path +import subprocess +import tempfile +import unittest + + +ROOT = Path(__file__).resolve().parents[1] + +HEADERS = { + "MeshCore.h": r''' +#pragma once +namespace mesh { +class MainBoard { +public: + bool ota_running = false; + bool isOTAUpdateRunning() const { return ota_running; } +}; +} +''', + "esp_partition.h": r''' +#pragma once +#include +#include +using esp_err_t = int; +constexpr esp_err_t ESP_OK = 0; +constexpr esp_err_t ESP_ERR_NOT_FOUND = 0x105; +constexpr esp_err_t ESP_ERR_INVALID_ARG = 0x102; +constexpr esp_err_t ESP_ERR_OTA_VALIDATE_FAILED = 0x1503; +constexpr esp_err_t ESP_FAIL = -1; +constexpr unsigned ESP_PARTITION_TYPE_APP = 0; +constexpr unsigned ESP_PARTITION_TYPE_DATA = 1; +constexpr unsigned ESP_PARTITION_SUBTYPE_APP_OTA_0 = 0x10; +constexpr unsigned ESP_PARTITION_SUBTYPE_APP_OTA_1 = 0x11; +using esp_partition_subtype_t = unsigned; +struct esp_partition_t { + unsigned type, subtype; + uint32_t address, size; + char label[17]; +}; +const esp_partition_t* esp_partition_find_first(unsigned, unsigned, const char*); +''', + "esp_err.h": '#pragma once\n#include "esp_partition.h"\n', + "esp_ota_ops.h": r''' +#pragma once +#include "esp_partition.h" +const esp_partition_t* esp_ota_get_running_partition(); +const esp_partition_t* esp_ota_get_boot_partition(); +esp_err_t esp_ota_set_boot_partition(const esp_partition_t*); +enum esp_ota_img_states_t { + ESP_OTA_IMG_NEW, ESP_OTA_IMG_PENDING_VERIFY, ESP_OTA_IMG_VALID, + ESP_OTA_IMG_INVALID, ESP_OTA_IMG_ABORTED, ESP_OTA_IMG_UNDEFINED +}; +esp_err_t esp_ota_get_state_partition(const esp_partition_t*, esp_ota_img_states_t*); +''', + "Update.h": r''' +#pragma once +struct UpdateClass { + bool running = false; + bool isRunning() const { return running; } +}; +extern UpdateClass Update; +''', + "ota/OtaContext.h": r''' +#pragma once +#include +namespace mesh { namespace ota { +struct OtaManager { + enum FetchState : uint8_t { + IDLE, WANT_MANIFEST, WANT_LEAVES, VERIFYING_STAGED, FETCHING, + COMPLETE, FAILED, PAUSED + }; + FetchState state = IDLE; + uint8_t serve_jobs = 0, manifest_jobs = 0; + FetchState fetchState() const { return state; } + uint8_t pendingServeJobs() const { return serve_jobs; } + uint8_t pendingManifestJobs() const { return manifest_jobs; } +}; +struct Store { + uint32_t staged = 0; + uint32_t staged_size() const { return staged; } +}; +struct OtaContext { + OtaManager manager; + Store fetch_store; + bool apply_pending = false, bootloader_apply_pending = false; + bool folder_active = false; + unsigned serve_expected = 0; +}; +extern OtaContext* active_context; +inline OtaContext* ota_context_if_active() { return active_context; } +}} +''', +} + +PRELUDE = r''' +#include "MeshCore.h" +#include "esp_partition.h" +#include "esp_ota_ops.h" +#include "Update.h" +#include "BootSlot.h" +#include "esp32/ManagementPassword.h" +#if ENABLE_OTA +#include "ota/OtaContext.h" +namespace mesh { namespace ota { OtaContext* active_context = nullptr; }} +#endif +#include +#include +#include + +UpdateClass Update; +static esp_partition_t app0{ESP_PARTITION_TYPE_APP, ESP_PARTITION_SUBTYPE_APP_OTA_0, + 0x10000, 0x1f0000, "app0"}; +static esp_partition_t app1{ESP_PARTITION_TYPE_APP, ESP_PARTITION_SUBTYPE_APP_OTA_1, + 0x200000, 0x1f0000, "app1"}; +static const esp_partition_t* running = &app0; +static const esp_partition_t* selected = &app0; +static bool have0 = true, have1 = true, valid0 = true, valid1 = true; +static bool fail_select = false; +static esp_err_t state_error = ESP_ERR_NOT_FOUND; +static esp_ota_img_states_t image_state = ESP_OTA_IMG_UNDEFINED; +static unsigned selections = 0; +static bool upload_blocked_during_verification = false; + +const esp_partition_t* esp_partition_find_first(unsigned type, unsigned subtype, const char* label) { + for (const auto* p : {have0 ? &app0 : nullptr, have1 ? &app1 : nullptr}) { + if (p && p->type == type && p->subtype == subtype && (!label || !strcmp(label, p->label))) return p; + } + return nullptr; +} +const esp_partition_t* esp_ota_get_running_partition() { return running; } +const esp_partition_t* esp_ota_get_boot_partition() { return selected; } +esp_err_t esp_ota_get_state_partition(const esp_partition_t*, esp_ota_img_states_t* out) { + *out = image_state; + return state_error; +} +esp_err_t esp_ota_set_boot_partition(const esp_partition_t* p) { + ++selections; + using namespace mesh::esp32::managementPassword; + Snapshot snapshot_value; + assert(snapshot(snapshot_value)); + upload_blocked_during_verification = !beginUpload(snapshot_value.generation); + if (!upload_blocked_during_verification) endUpload(); + if (fail_select || !(p == &app0 ? valid0 : valid1)) return ESP_ERR_OTA_VALIDATE_FAILED; + selected = p; + return ESP_OK; +} + +static mesh::MainBoard board; +static char reply[160]; +static bool command(const char* text) { + memset(reply, 0, sizeof(reply)); + return mesh::esp32::handleBootSlotCommand(text, board, reply, sizeof(reply)); +} +static void succeeds(const char* text) { + assert(command(text)); + assert(!strncmp(reply, "OK", 2)); +} +static void rejects(const char* text) { + const auto before = selected; + assert(command(text)); + assert(!strncmp(reply, "Error", 5) || !strncmp(reply, "ERR", 3)); + assert(selected == before); +} +static void initialize() { + assert(mesh::esp32::managementPassword::initialize("test-password")); +} +''' + + +class BootSlotCliTest(unittest.TestCase): + def run_fixture(self, source, ota=False): + with tempfile.TemporaryDirectory(prefix="mesh-boot-slot-") as temporary: + folder = Path(temporary) + for name, contents in HEADERS.items(): + path = folder / name + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(contents) + # Keep production code unchanged. The temporary include directory + # substitutes IDF services and OTA storage, not the CLI behavior. + production = folder / "BootSlot.cpp" + production.write_bytes((ROOT / "src/helpers/BootSlot.cpp").read_bytes()) + main = folder / "test.cpp" + main.write_text(PRELUDE + source) + executable = folder / "test" + flags = ["-DESP32_PLATFORM=1", "-DMESH_MANAGEMENT_PASSWORD_HOST_TEST=1"] + if ota: + flags += ["-DENABLE_OTA=1", "-DOTA_FLASH_STORE=1"] + result = subprocess.run( + [os.environ.get("CXX", "c++"), "-std=c++17", "-Wall", "-Wextra", + "-pthread", "-fsanitize=address,undefined", "-fno-sanitize-recover=all", + "-fno-pie", "-no-pie", *flags, "-I", str(folder), + "-I", str(ROOT / "src/helpers"), "-I", str(ROOT / "src"), + str(production), str(main), "-o", str(executable)], + text=True, capture_output=True, timeout=45, + ) + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + result = subprocess.run([str(executable)], text=True, capture_output=True, timeout=15) + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + + def test_readback_selection_and_no_restart_or_unrelated_flash_writes(self): + self.run_fixture(r''' +int main() { + initialize(); + assert(command("get boot.slot")); + assert(strstr(reply, "running=app0") && strstr(reply, "next=app0")); + assert(strstr(reply, "app0=") && strstr(reply, "app1=")); + assert(selections == 0); + succeeds("set boot.slot app1"); + assert(selected == &app1 && running == &app0); + assert(upload_blocked_during_verification); + assert(command("get boot.slot") && strstr(reply, "next=app1")); + succeeds("set boot.slot app0"); + assert(selected == &app0 && running == &app0); + have1 = false; + assert(command("get boot.slot") && strstr(reply, "app1=missing")); + rejects("set boot.slot app1"); + // A factory-only app remains identifiable without inventing OTA slots. + esp_partition_t factory{ESP_PARTITION_TYPE_APP, 0, 0x10000, 0x300000, "factory"}; + running = selected = &factory; have0 = false; + assert(command("get boot.slot") && strstr(reply, "running=factory")); + assert(strstr(reply, "app0=missing") && strstr(reply, "app1=missing")); + rejects("set boot.slot app0"); + running = selected = nullptr; + assert(command("get boot.slot") && strstr(reply, "running=unknown") && strstr(reply, "next=unknown")); +} +''') + + def test_parser_boundaries_bad_images_and_idf_failure_leave_selection_alone(self): + self.run_fixture(r''' +int main() { + initialize(); + for (const char* text : {"get boot.slot.extra", "set boot.slotx app1", "get radio"}) + assert(!command(text)); + for (const char* text : {"set boot.slot", "set boot.slot app2", "set boot.slot nvs", + "set boot.slot app1 extra", "set boot.slot app10", "set boot.slot APP1", + "get boot.slot app1"}) rejects(text); + assert(selections == 0); + valid1 = false; rejects("set boot.slot app1"); + valid1 = true; fail_select = true; rejects("set boot.slot app1"); + fail_select = false; + // Labels alone cannot turn a data partition or wrong OTA subtype into an app. + app1.type = ESP_PARTITION_TYPE_DATA; rejects("set boot.slot app1"); + app1.type = ESP_PARTITION_TYPE_APP; + app1.subtype = ESP_PARTITION_SUBTYPE_APP_OTA_0; rejects("set boot.slot app1"); + app1.subtype = ESP_PARTITION_SUBTYPE_APP_OTA_1; + strcpy(app1.label, "renamed"); rejects("set boot.slot app1"); + strcpy(app1.label, "app1"); succeeds("set boot.slot app1"); + // Null and zero-length outputs must not arm a slot, and tiny replies stay bounded. + selected = &app0; + mesh::esp32::handleBootSlotCommand("set boot.slot app1", board, nullptr, 0); + assert(selected == &app0); + char bounded[5] = {'x','x','x','x','!'}; + mesh::esp32::handleBootSlotCommand("get boot.slot", board, bounded, 4); + assert(bounded[3] == 0 && bounded[4] == '!'); +} +''') + + def test_wifi_update_and_cross_task_upload_lease_prevent_boot_mutation(self): + self.run_fixture(r''' +int main() { + initialize(); + board.ota_running = true; rejects("set boot.slot app1"); + assert(command("get boot.slot")); board.ota_running = false; + Update.running = true; rejects("set boot.slot app1"); Update.running = false; + assert(mesh::esp32::handleBootSlotCommand("set boot.slot app1", board, reply, sizeof(reply), true)); + assert(selected == &app0 && !strncmp(reply, "Error", 5)); + namespace password = mesh::esp32::managementPassword; + password::Snapshot credentials; assert(password::snapshot(credentials)); + { + password::UploadGuard upload(credentials.generation); assert(upload.acquired()); + rejects("set boot.slot app1"); + } + { + password::ChangeGuard change; assert(change.acquired()); + rejects("set boot.slot app1"); + } + assert(selections == 0); + succeeds("set boot.slot app1"); assert(upload_blocked_during_verification); + assert(password::beginUpload(credentials.generation)); password::endUpload(); +} +''') + + def test_lora_fetch_staging_apply_and_pending_jobs_prevent_boot_mutation(self): + self.run_fixture(r''' +int main() { + initialize(); + using namespace mesh::ota; + OtaContext context; active_context = &context; + for (auto state : {OtaManager::WANT_MANIFEST, OtaManager::WANT_LEAVES, + OtaManager::VERIFYING_STAGED, OtaManager::FETCHING, OtaManager::PAUSED}) { + context.manager.state = state; rejects("set boot.slot app1"); + } + context.manager.state = OtaManager::IDLE; + context.apply_pending = true; rejects("set boot.slot app1"); context.apply_pending = false; + context.bootloader_apply_pending = true; rejects("set boot.slot app1"); context.bootloader_apply_pending = false; + context.manager.serve_jobs = 1; rejects("set boot.slot app1"); context.manager.serve_jobs = 0; + context.manager.manifest_jobs = 1; rejects("set boot.slot app1"); context.manager.manifest_jobs = 0; + context.folder_active = true; rejects("set boot.slot app1"); context.folder_active = false; + context.serve_expected = 123; rejects("set boot.slot app1"); context.serve_expected = 0; + context.fetch_store.staged = 500; rejects("set boot.slot app1"); context.fetch_store.staged = 0; + assert(selections == 0); + for (auto state : {OtaManager::IDLE, OtaManager::COMPLETE, OtaManager::FAILED}) { + context.manager.state = state; + succeeds("set boot.slot app1"); succeeds("set boot.slot app0"); + } + active_context = nullptr; succeeds("set boot.slot app1"); +} +''', ota=True) + + def test_bootloader_rejected_and_unconfirmed_slots_cannot_be_rearmed(self): + self.run_fixture(r''' +int main() { + initialize(); + state_error = ESP_OK; + for (auto state : {ESP_OTA_IMG_INVALID, ESP_OTA_IMG_ABORTED, ESP_OTA_IMG_PENDING_VERIFY}) { + image_state = state; rejects("set boot.slot app1"); + } + state_error = ESP_FAIL; rejects("set boot.slot app1"); + assert(selections == 0); + state_error = ESP_OK; + for (auto state : {ESP_OTA_IMG_NEW, ESP_OTA_IMG_VALID, ESP_OTA_IMG_UNDEFINED}) { + image_state = state; succeeds("set boot.slot app1"); succeeds("set boot.slot app0"); + } + state_error = ESP_ERR_NOT_FOUND; succeeds("set boot.slot app1"); +} +''') + + def test_other_platforms_do_not_include_sdk_or_add_a_boot_selector(self): + with tempfile.TemporaryDirectory(prefix="mesh-boot-slot-excluded-") as temporary: + folder = Path(temporary) + result = subprocess.run( + [os.environ.get("CXX", "c++"), "-std=c++11", "-DNRF52_PLATFORM=1", + "-c", str(ROOT / "src/helpers/BootSlot.cpp"), "-o", str(folder / "slot.o")], + capture_output=True, text=True, timeout=30, + ) + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + symbols = subprocess.run(["nm", str(folder / "slot.o")], capture_output=True, text=True, timeout=10) + self.assertEqual(symbols.returncode, 0, symbols.stderr) + self.assertNotIn("handleBootSlotCommand", symbols.stdout) + + +if __name__ == "__main__": + unittest.main() From 2fa1b63e1434bb97e2b01c9fe4cf81a7dd579b0e Mon Sep 17 00:00:00 2001 From: mikecarper Date: Wed, 28 Oct 2026 15:10:17 -0700 Subject: [PATCH 46/61] Allow bridge ingress to target radio2 without primary fallback --- .github/workflows/run-unit-tests.yml | 1 + docs/cli_command_availability.md | 2 + docs/cli_commands.md | 34 +++ docs/espnow_bridge_setup.md | 41 ++++ docs/radio_profiles.md | 6 + docs/rs232_bridge_setup.md | 7 + docs/udp_bridge_setup.md | 8 + examples/simple_room_server/MyMesh.h | 1 + src/Mesh.cpp | 3 + src/helpers/AbstractBridge.h | 24 +- src/helpers/BridgeIngressProfile.h | 26 +++ src/helpers/BridgeIngressSettings.cpp | 148 ++++++++++++ src/helpers/BridgeIngressSettings.h | 17 ++ src/helpers/CommonCLI.cpp | 7 + src/helpers/bridges/BridgeBase.h | 2 +- test/fixtures/cli_reply_text.json | 2 +- test/test_bridge_ingress.py | 319 +++++++++++++++++++++++++- test/test_bridge_ingress_settings.py | 236 +++++++++++++++++++ test/test_transport_filter_wiring.py | 7 + 19 files changed, 876 insertions(+), 15 deletions(-) create mode 100644 src/helpers/BridgeIngressProfile.h create mode 100644 src/helpers/BridgeIngressSettings.cpp create mode 100644 src/helpers/BridgeIngressSettings.h create mode 100644 test/test_bridge_ingress_settings.py diff --git a/.github/workflows/run-unit-tests.yml b/.github/workflows/run-unit-tests.yml index cffc53a52..466c7bf4a 100644 --- a/.github/workflows/run-unit-tests.yml +++ b/.github/workflows/run-unit-tests.yml @@ -474,6 +474,7 @@ jobs: python3 -B test/test_clock_transit_profile_timing.py -v python3 -B test/test_bridge_recovery.py -v python3 -B test/test_bridge_ingress.py -v + python3 -B test/test_bridge_ingress_settings.py -v - name: Verify signed private fleet management run: | diff --git a/docs/cli_command_availability.md b/docs/cli_command_availability.md index 7a071820b..ded327ece 100644 --- a/docs/cli_command_availability.md +++ b/docs/cli_command_availability.md @@ -252,6 +252,7 @@ should use command `0x42`. See [Companion radio binary protocol](companion_proto | Bridge | [`get/set bridge.enabled`; `get bridge.running`](cli_commands.md#view-or-change-the-bridge-enabled-flag) | Compiled bridge | Feature | Feature | Feature | | Bridge | [`get/set bridge.delay`](cli_commands.md#add-a-delay-to-packets-routed-through-this-bridge) | Compiled bridge | Feature | Feature | Feature | | Bridge | [`get/set bridge.source`](cli_commands.md#view-or-change-the-source-of-packets-bridged-to-the-external-interface) | Compiled bridge | Feature | Feature | Feature | +| Bridge | [`get/set bridge.ingress`](cli_commands.md#select-the-radio-for-incoming-bridge-packets) | Compiled incoming packet bridge; saved primary or radio2 selection | Feature | Feature | Feature | | Bridge | [`get/set bridge.baud`](cli_commands.md#view-or-change-the-speed-of-the-bridge-rs-232-only) | RS-232 bridge | Feature | Feature | Feature | | Bridge | [`get/set bridge.uart`](cli_commands.md#view-or-change-the-uart-used-by-the-bridge-rs-232-only) | RS-232 bridge | Feature | Feature | Feature | | Bridge | [`get/set bridge.channel`](cli_commands.md#view-or-change-the-channel-used-for-bridging-espnow-only) | ESP-NOW is ESP32 only | No | No | No | @@ -431,6 +432,7 @@ should use command `0x42`. See [Companion radio binary protocol](companion_proto | Bridge | [`get/set bridge.enabled`; `get bridge.running`](cli_commands.md#view-or-change-the-bridge-enabled-flag) | Compiled bridge | Feature | Feature | Feature | Yes | Feature | | Bridge | [`get/set bridge.delay`](cli_commands.md#add-a-delay-to-packets-routed-through-this-bridge) | Compiled bridge | Feature | Feature | Feature | Yes | Feature | | Bridge | [`get/set bridge.source`](cli_commands.md#view-or-change-the-source-of-packets-bridged-to-the-external-interface) | Compiled bridge | Feature | Feature | Feature | Yes | Feature | +| Bridge | [`get/set bridge.ingress`](cli_commands.md#select-the-radio-for-incoming-bridge-packets) | Compiled incoming packet bridge; saved primary or radio2 selection | Feature | Feature | Feature | Feature | Feature | | Bridge | [`get/set bridge.baud`](cli_commands.md#view-or-change-the-speed-of-the-bridge-rs-232-only) | RS-232 bridge | Feature | Feature | Feature | No | Feature | | Bridge | [`get/set bridge.uart`](cli_commands.md#view-or-change-the-uart-used-by-the-bridge-rs-232-only) | RS-232 bridge | Feature | Feature | Feature | No | Feature | | Bridge | [`get/set bridge.channel`](cli_commands.md#view-or-change-the-channel-used-for-bridging-espnow-only) | ESP-NOW bridge | No | No | No | Feature | Feature | diff --git a/docs/cli_commands.md b/docs/cli_commands.md index 97447173a..082349562 100644 --- a/docs/cli_commands.md +++ b/docs/cli_commands.md @@ -6793,6 +6793,40 @@ updated active peer can still answer a valid incoming ping in passive mode. --- +#### Select the radio for incoming bridge packets + +```text +get bridge.ingress +set bridge.ingress radio2 +set bridge.ingress radio +``` + +This receiver-side setting applies to incoming mesh packets from ESP-NOW, UDP +and RS-232 bridges. It is saved immediately and needs no restart. Packets +already queued retain their original route. `radio` is +the default, preserving the existing primary-radio behavior and crossing rules. +Existing senders and bridge wire formats remain compatible. +An unreadable or corrupt saved selection blocks bridge input rather than +silently restoring the primary; `get bridge.ingress` reports the fault. + +`radio2` assigns incoming packets to the active MeshCore second profile and +keeps their forwarding on that profile, even with `radio2.cross on`. If radio2 +is off, RX-only, owned by another protocol, or changed while a packet waits, +the packet is dropped; it never falls back to the primary radio. Normal repeat, +scope, duplicate and filter checks still apply. This setting selects every +incoming packet on these bridges, not individual senders or payload types. +The selection follows forwarded packets, retries and relayed ACKs. Locally +generated adverts and admin replies keep their existing routing, including +`tx.reply`; this is not a global transmit override. + +For a MediaMesh receiver feeding a repeater whose primary is USA 910.525 MHz, +see the [MediaMesh bridge example](espnow_bridge_setup.md#send-bridge-traffic-only-on-mediamesh). +`bridge.source` controls traffic leaving for the bridge and is independent of +`bridge.ingress`. This does not add MQTT packet injection or change MQTT +publication settings. + +--- + #### View or change the MQTT master switch **Search terms:** enable MQTT, disable MQTT, MQTT on, MQTT off, WiFi logging. diff --git a/docs/espnow_bridge_setup.md b/docs/espnow_bridge_setup.md index 761c9fb6a..714a05e8d 100644 --- a/docs/espnow_bridge_setup.md +++ b/docs/espnow_bridge_setup.md @@ -94,6 +94,7 @@ Stopping it first lets you finish configuring both sides before connecting them. | `bridge.format` | `wrapped` is the default for this two-repeater setup and uses the shared secret. `raw` connects to primary-ESP-NOW nodes such as `Generic_ESPNOW` or `SenseCapIndicator-ESPNow`; it ignores the secret. Both peers must use compatible formats. | | `bridge.secret` | A matching, case-sensitive value of 1-15 characters for `wrapped` mode. It separates bridge groups; it is not strong encryption. MeshCore's own message encryption remains in place. | | `bridge.source` | `tx` crosses packets this repeater transmits on LoRa, after its forwarding decisions; use this for the setup above. `rx` crosses received packets before those decisions and can pass traffic the local repeater would not forward. | +| `bridge.ingress` | Selects the local radio for incoming bridge packets: `radio` preserves the default primary-radio behavior; `radio2` forwards only on the active MeshCore second profile, with no primary fallback. | | `bridge.delay` | Wait before processing a packet received from the other bridge, in milliseconds. Range: 0-10000. Start with the default `500`. | | `repeat` | `on` enables normal LoRa forwarding; `off` disables forwarding. Keep it `on` on both bridge repeaters. Normal routing, scope, and packet-filter rules still apply. | | `bridge.type` | Read-only firmware capability, including combined transports. | @@ -105,6 +106,46 @@ use `logTx` and `logRx`; those are not the command values used here. For a LoRa gateway connected to primary-ESP-NOW Companions instead of another LoRa repeater, see [raw ESP-NOW compatibility](WiFi.md#wifi-companion-setup). +### Send bridge traffic only on MediaMesh + +Keep the receiving repeater's primary USA profile at 910.525 MHz while sending +packets arriving over the bridge only on MediaMesh: + +```text +MediaMesh 911.150 RX --> Repeater A --> ESP-NOW --> Repeater B --> radio2 911.150 TX +``` + +Configure the matching ESP-NOW channel, format and secret as above. On the +source repeater already receiving MediaMesh, export receptions without local +relay forwarding: + +```text +set bridge.source rx +set repeat off +``` + +`repeat off` still allows this source's own adverts and replies. For an RX-only +MediaMesh profile, use `set radio2 911.150,250,7,5,rx` and keep crossing off; +the bridge also exports receptions from any other active profile. This setting +does not filter bridge output by the source frequency. + +On the destination repeater, leave its existing primary USA settings and use: + +```text +set radio2 mediamesh +set radio2.cross off +set bridge.ingress radio2 +set repeat on +get bridge.ingress +``` + +The incoming bridge packets are marked as radio2 traffic locally; the sender +does not need updated firmware or an extra wire-format field. Radio2 must be +available in `rxtx` MeshCore mode. Otherwise packets are dropped rather than +sent on the primary. This saved setting covers all incoming ESP-NOW, UDP and +RS-232 mesh traffic on the destination; `set bridge.ingress radio` restores +the original behavior. Normal scope and filtering rules still apply. + ## 4. Check the link Run on both boards: diff --git a/docs/radio_profiles.md b/docs/radio_profiles.md index d1ce79a95..f42cc8795 100644 --- a/docs/radio_profiles.md +++ b/docs/radio_profiles.md @@ -102,6 +102,12 @@ If the second profile cannot transmit MeshCore, media uses the normal channel/global route; `set tx.media radio2` instead requires radio2 with no primary fallback. See [application TX routing](#companion-media-and-ssh-tx-routing). +Infrastructure repeaters can separately mark packets arriving over ESP-NOW, +UDP or RS-232 with `set bridge.ingress radio2`. Those packets forward only on +the second MeshCore profile; they are dropped if it cannot transmit, without +primary fallback. The default `bridge.ingress radio` preserves existing bridge +behavior. See the [MediaMesh bridge example](espnow_bridge_setup.md#send-bridge-traffic-only-on-mediamesh). + ### Discover the local radio2 profile Clients can request a repeater's current MeshCore radio2 profile through public, diff --git a/docs/rs232_bridge_setup.md b/docs/rs232_bridge_setup.md index 818031533..87119b60e 100644 --- a/docs/rs232_bridge_setup.md +++ b/docs/rs232_bridge_setup.md @@ -55,6 +55,13 @@ bridges received packets before those decisions. Normal scope, filters, duplicate suppression and repeat settings still affect mesh delivery. Configure each side's LoRa network separately; see the [bridge CLI reference](cli_commands.md#bridge-when-bridge-support-is-compiled-in). +Incoming mesh packets use the local primary profile by default. Save +`set bridge.ingress radio2` on the receiver to mark them for forwarding only on +its active MeshCore radio2; an unavailable or RX-only radio2 drops them rather +than falling back to the primary. The serial format and older peer remain +compatible. See [bridge ingress](cli_commands.md#select-the-radio-for-incoming-bridge-packets) +for the shared setting and `set bridge.ingress radio` to restore the default. + ## Distinguish power state from transport activity `get rs232.status` retains the existing heartbeat and automatic-standby report. diff --git a/docs/udp_bridge_setup.md b/docs/udp_bridge_setup.md index 34e2440fc..926a52533 100644 --- a/docs/udp_bridge_setup.md +++ b/docs/udp_bridge_setup.md @@ -163,6 +163,14 @@ actual firmware. If sessions/probes work but mesh traffic does not, check `bridge.source rx` is valid; `tx` instead selects packets after LoRa forwarding decisions. These source/filter settings are shared with other compiled bridges. +Incoming UDP mesh packets use `bridge.ingress radio` by default. To forward +them only on an already configured MeshCore radio2, save +`set bridge.ingress radio2` on the receiving board. Radio2 must permit TX; +unavailable profiles cause drops, never a fallback to the primary. The sender, +UDP framing, peer sessions and authentication stay unchanged. See the +[MediaMesh example](espnow_bridge_setup.md#send-bridge-traffic-only-on-mediamesh) +and [bridge-ingress reference](cli_commands.md#select-the-radio-for-incoming-bridge-packets). + The peer list contains direct destinations for locally received/transmitted LoRa traffic. An incoming UDP packet enters the local LoRa mesh; it is not relayed to other UDP peers, including through its later local LoRa TX callback. diff --git a/examples/simple_room_server/MyMesh.h b/examples/simple_room_server/MyMesh.h index 1ae5fd6d2..2093e6f79 100644 --- a/examples/simple_room_server/MyMesh.h +++ b/examples/simple_room_server/MyMesh.h @@ -892,6 +892,7 @@ public: void configureBridgeFilter(AbstractBridge* active_bridge) { + active_bridge->setIngressProfiles(_radio->profiles()); #if MESH_ENABLE_ROOM_FLOOD_RULE_ENGINE active_bridge->setPacketFilter([](void* context, const mesh::Packet* packet) { return static_cast(context)->allowTransportPacket(packet, 2); // RULE_MODE_BRIDGE diff --git a/src/Mesh.cpp b/src/Mesh.cpp index 2dd0f6a79..f5c878815 100644 --- a/src/Mesh.cpp +++ b/src/Mesh.cpp @@ -1607,6 +1607,7 @@ DispatcherAction Mesh::forwardMultipartDirect(Packet* pkt, uint32_t delay_millis _tables->markSeen(pkt); Packet tmp; tmp.header = pkt->header; + tmp.tx_radio = pkt->tx_radio; tmp.path_len = Packet::copyPath(tmp.path, pkt->path, pkt->path_len); tmp.payload_len = pkt->payload_len - 1; memcpy(tmp.payload, &pkt->payload[1], tmp.payload_len); @@ -1625,6 +1626,7 @@ void Mesh::routeDirectRecvAcks(Packet* packet, uint32_t delay_millis) { if (a1) { a1->radio_reply = false; // relayed ACK, not a local response a1->forwarded = true; + a1->tx_radio = packet->tx_radio; a1->path_len = Packet::copyPath(a1->path, packet->path, packet->path_len); a1->header &= ~PH_ROUTE_MASK; a1->header |= ROUTE_TYPE_DIRECT; @@ -1639,6 +1641,7 @@ void Mesh::routeDirectRecvAcks(Packet* packet, uint32_t delay_millis) { if (a2) { a2->radio_reply = false; // relayed ACK, not a local response a2->forwarded = true; + a2->tx_radio = packet->tx_radio; a2->path_len = Packet::copyPath(a2->path, packet->path, packet->path_len); a2->header &= ~PH_ROUTE_MASK; a2->header |= ROUTE_TYPE_DIRECT; diff --git a/src/helpers/AbstractBridge.h b/src/helpers/AbstractBridge.h index 39304168a..0e1f89f55 100644 --- a/src/helpers/AbstractBridge.h +++ b/src/helpers/AbstractBridge.h @@ -1,6 +1,7 @@ #pragma once #include +#include class AbstractBridge { using PacketFilter = bool (*)(void*, const mesh::Packet*); @@ -9,18 +10,25 @@ class AbstractBridge { const mesh::RadioProfiles* _ingress_profiles = nullptr; protected: - // Bridges enter the local primary MeshCore network, not whichever tuple - // the physical scanner currently visits. Snapshot its generation at queue - // admission so a delayed frame cannot acquire a newer temporary lease. + // The receiver selects a local MeshCore profile, independent of the wire + // format and the scanner's current tuple. Legacy bridges default to primary. + // Snapshot the generation so delayed frames cannot acquire a newer lease. bool preparePacketIngress(mesh::Packet* packet) const { - if (!packet || (_ingress_profiles - && _ingress_profiles->params(0).protocol != mesh::RadioProfileProtocol::MeshCore)) return false; - packet->radio_profile = packet->radio_origin = 0; + const auto target = mesh::bridge::ingressProfile(); + if (!packet || target == mesh::bridge::IngressProfile::Blocked) return false; + const uint8_t profile = target == mesh::bridge::IngressProfile::Radio2 ? 1 : 0; + if ((profile == 1 && (!_ingress_profiles || !_ingress_profiles->enabled() + || !_ingress_profiles->canTransmit(1))) + || (_ingress_profiles && _ingress_profiles->params(profile).protocol + != mesh::RadioProfileProtocol::MeshCore)) return false; + packet->radio_profile = packet->radio_origin = profile; packet->radio_generation = packet->radio_origin_generation = - _ingress_profiles ? _ingress_profiles->generation[0] : 0; + _ingress_profiles ? _ingress_profiles->generation[profile] : 0; packet->radio_bound = false; packet->radio_local = false; - packet->tx_radio = mesh::RADIO_TX_AUTO; + // Explicit secondary ingress stays secondary even if crossing is enabled. + // The default primary selector retains its historical crossing behavior. + packet->tx_radio = profile == 1 ? mesh::RADIO_TX_SECONDARY : mesh::RADIO_TX_AUTO; packet->radio_reply = packet->radio_reply_force = false; packet->forwarded = true; return true; diff --git a/src/helpers/BridgeIngressProfile.h b/src/helpers/BridgeIngressProfile.h new file mode 100644 index 000000000..b756d4b75 --- /dev/null +++ b/src/helpers/BridgeIngressProfile.h @@ -0,0 +1,26 @@ +#pragma once + +#include + +// WITH_BRIDGE is also synthesized by CommonCLI.h. This feature must not +// depend on that header having been included first in a translation unit. +#ifndef MESH_ENABLE_BRIDGE_INGRESS +#if defined(WITH_BRIDGE) || defined(WITH_ESPNOW_BRIDGE) || \ + defined(WITH_RS232_BRIDGE) || defined(WITH_MQTT_BRIDGE) || defined(WITH_UDP_BRIDGE) +#define MESH_ENABLE_BRIDGE_INGRESS 1 +#else +#define MESH_ENABLE_BRIDGE_INGRESS 0 +#endif +#endif + +namespace mesh { namespace bridge { + +enum class IngressProfile : uint8_t { Radio = 0, Radio2 = 1, Blocked = 2 }; + +#if MESH_ENABLE_BRIDGE_INGRESS +IngressProfile ingressProfile(); +#else +inline IngressProfile ingressProfile() { return IngressProfile::Radio; } +#endif + +}} // namespace mesh::bridge diff --git a/src/helpers/BridgeIngressSettings.cpp b/src/helpers/BridgeIngressSettings.cpp new file mode 100644 index 000000000..cf6a3deb4 --- /dev/null +++ b/src/helpers/BridgeIngressSettings.cpp @@ -0,0 +1,148 @@ +#include "BridgeIngressSettings.h" + +#if MESH_ENABLE_BRIDGE_INGRESS +#include "CheckedSettingsFile.h" +#include "FilePresence.h" +#if defined(NRF52_PLATFORM) || defined(STM32_PLATFORM) +#include "AtomicFileWriter.h" +#else +#include "ContactFileTransaction.h" +#endif +#include +#include + +namespace mesh { namespace bridge { +namespace { +constexpr char Path[] = "/bridge_ingress"; +constexpr size_t ImageSize = 10; +FILESYSTEM* settings_fs = nullptr; +IngressProfile selected = IngressProfile::Blocked; +bool writable = false; + +uint32_t checksum(const uint8_t* data, size_t size) { + uint32_t crc = 0xffffffffU; + while (size--) { + crc ^= *data++; + for (uint8_t bit = 0; bit < 8; ++bit) + crc = (crc >> 1) ^ ((crc & 1) ? 0xedb88320U : 0); + } + return ~crc; +} + +void encode(uint8_t image[ImageSize], IngressProfile profile) { + memcpy(image, "BIN\1", 4); + image[4] = static_cast(profile); + image[5] = image[4] ^ 0xff; + const uint32_t crc = checksum(image, 6); + for (uint8_t i = 0; i < 4; ++i) image[6 + i] = crc >> (i * 8); +} + +bool decode(const uint8_t image[ImageSize], IngressProfile& profile) { + if (memcmp(image, "BIN\1", 4) != 0 || image[4] > 1 || + image[5] != (image[4] ^ 0xff)) return false; + const uint32_t saved_crc = uint32_t(image[6]) | (uint32_t(image[7]) << 8) | + (uint32_t(image[8]) << 16) | (uint32_t(image[9]) << 24); + if (saved_crc != checksum(image, 6)) return false; + profile = static_cast(image[4]); + return true; +} + +void quarantine() { + selected = IngressProfile::Blocked; + writable = false; +} + +bool saveSetting(IngressProfile profile) { + if (!settings_fs || !writable) return false; + uint8_t image[ImageSize]; + encode(image, profile); +#if defined(NRF52_PLATFORM) || defined(STM32_PLATFORM) + AtomicFileWriter file(settings_fs, Path); +#else + ContactFileTransaction file(settings_fs, Path, filePresence); +#endif + using Outcome = decltype(file.outcome()); + if (!file || file.write(image, sizeof(image)) != sizeof(image)) { + if (file.outcome() == Outcome::Indeterminate) quarantine(); + return false; + } + file.commit(); + if (file.outcome() == Outcome::NotCommitted) return false; + if (file.outcome() != Outcome::Committed) { + quarantine(); + return false; + } + uint8_t verified[ImageSize]; + if (!storage::readFixedSettingsFile(settings_fs, Path, verified, sizeof(verified)) || + memcmp(verified, image, sizeof(image)) != 0) { + quarantine(); + return false; + } + selected = profile; + return true; +} +} // namespace + +IngressProfile ingressProfile() { return selected; } + +void loadIngressSettings(FILESYSTEM* fs) { + settings_fs = fs; + quarantine(); + if (!fs) return; +#if defined(ESP32_PLATFORM) || defined(RP2040_PLATFORM) + if (!ContactFileTransaction::recover(fs, Path, filePresence)) return; +#endif + bool present = false; + if (!filePresence(fs, Path, present)) return; + if (!present) { + selected = IngressProfile::Radio; + writable = true; + return; + } + uint8_t image[ImageSize]; + const auto read = storage::readFixedSettingsFileResult(fs, Path, image, sizeof(image)); + if (read == storage::FixedSettingsRead::Unreadable) return; + // A known malformed record can be explicitly repaired. Until then it must + // not silently turn a saved radio2-only ingress into primary-radio traffic. + writable = true; + if (read == storage::FixedSettingsRead::Complete) decode(image, selected); +} + +bool handleIngressCommand(const char* command, char* reply, size_t capacity) { + if (!command || !reply || !capacity) return false; + const bool get = strncmp(command, "get bridge.ingress", 18) == 0 && + (command[18] == 0 || command[18] == ' ' || command[18] == '\t'); + const bool set = strncmp(command, "set bridge.ingress", 18) == 0 && + (command[18] == 0 || command[18] == ' ' || command[18] == '\t'); + if (!get && !set) return false; + if (get) { + if (command[18] != 0) + snprintf(reply, capacity, "Error: use get bridge.ingress"); + else if (selected == IngressProfile::Blocked) + snprintf(reply, capacity, "Error: bridge ingress blocked; settings invalid or unreadable"); + else + snprintf(reply, capacity, "> %s", selected == IngressProfile::Radio2 ? "radio2" : "radio"); + return true; + } + const char* value = command + 18; + while (*value == ' ' || *value == '\t') ++value; + IngressProfile profile; + if (strcmp(value, "radio") == 0) profile = IngressProfile::Radio; + else if (strcmp(value, "radio2") == 0) profile = IngressProfile::Radio2; + else { + snprintf(reply, capacity, "Error: use set bridge.ingress radio|radio2"); + return true; + } + if (!writable) { + snprintf(reply, capacity, "Error: bridge ingress storage unavailable; reboot first"); + } else if (!saveSetting(profile)) { + snprintf(reply, capacity, "Error: bridge ingress not saved%s", + writable ? "" : "; storage uncertain, ingress blocked until reload"); + } else { + snprintf(reply, capacity, "OK - bridge.ingress %s", value); + } + return true; +} + +}} // namespace mesh::bridge +#endif diff --git a/src/helpers/BridgeIngressSettings.h b/src/helpers/BridgeIngressSettings.h new file mode 100644 index 000000000..bc8cb0385 --- /dev/null +++ b/src/helpers/BridgeIngressSettings.h @@ -0,0 +1,17 @@ +#pragma once + +#include "BridgeIngressProfile.h" +#include + +#if MESH_ENABLE_BRIDGE_INGRESS +#include "IdentityStore.h" + +namespace mesh { namespace bridge { + +// Separate from /com_prefs so old firmware and existing bridge frames keep +// their original layouts. Missing settings retain primary-radio ingress. +void loadIngressSettings(FILESYSTEM* fs); +bool handleIngressCommand(const char* command, char* reply, size_t capacity); + +}} // namespace mesh::bridge +#endif diff --git a/src/helpers/CommonCLI.cpp b/src/helpers/CommonCLI.cpp index f37aa68b5..5f299c331 100644 --- a/src/helpers/CommonCLI.cpp +++ b/src/helpers/CommonCLI.cpp @@ -20,6 +20,7 @@ #include "PowerSavingDiagnostics.h" #include "HilStartupTrace.h" #include "FilePresence.h" +#include "BridgeIngressSettings.h" #if defined(ESP32_PLATFORM) #include "BootSlot.h" #include @@ -1017,6 +1018,9 @@ static void formatSnrDbX4Short(char* dest, size_t dest_len, int16_t snr_x4) { } void CommonCLI::loadPrefs(FILESYSTEM* fs) { +#if MESH_ENABLE_BRIDGE_INGRESS + mesh::bridge::loadIngressSettings(fs); +#endif #if MESH_ENABLE_BATTERY_FORWARDING loadBatteryForwarding(fs); #endif @@ -3563,6 +3567,9 @@ bool CommonCLI::handleUDPBridgeCommand(uint32_t sender_timestamp, void CommonCLI::handleCommand(uint32_t sender_timestamp, char* command, char* reply) { PrefsSaveReplyGuard save_reply(_prefs_save_failures, reply); mesh::cli::normalizeCommandVerb(command); +#if MESH_ENABLE_BRIDGE_INGRESS + if (mesh::bridge::handleIngressCommand(command, reply, 157)) return; +#endif #if MESH_ENABLE_HOST_KISS if (mesh::hostkiss::handleCommand(command, reply, 157, sender_timestamp == 0)) return; #endif diff --git a/src/helpers/bridges/BridgeBase.h b/src/helpers/bridges/BridgeBase.h index b239e6279..54e471bc2 100644 --- a/src/helpers/bridges/BridgeBase.h +++ b/src/helpers/bridges/BridgeBase.h @@ -116,7 +116,7 @@ protected: * @brief Common packet handling for received packets * * Implements the standard pattern used by all bridges: - * - Bind ingress to the current local primary-profile generation + * - Bind ingress to the selected local radio-profile generation * - Apply transport policy before queueing or marking a packet * - Deduplicate ordinary packets using _seen_packets.wasSeen() * - Leave resumable OTA retries to the mesh's timed retry/echo policy diff --git a/test/fixtures/cli_reply_text.json b/test/fixtures/cli_reply_text.json index b7fc31910..51b4db218 100644 --- a/test/fixtures/cli_reply_text.json +++ b/test/fixtures/cli_reply_text.json @@ -941,7 +941,7 @@ } ], "noncompact_tokens_sha256": { - "src/helpers/CommonCLI.cpp": "ffde525e649544c1e7821f45810860f555bb4b2bfb9484923682c55830eee9ad", + "src/helpers/CommonCLI.cpp": "cd2bcd278e1b01e0e7195306aa67d0a00070183543c8d61a9724958058ead6b7", "examples/simple_repeater/MyMesh.cpp": "8567eeef2f327100e397c40b0aaf5b675fcdfea254987437d1ca8d005ad3a6f0", "src/helpers/RadioProfileCLI.cpp": "d641d16e872eba3e73b9b5a50f9718c10b0b7da72782f793706ac36b3724a624", "src/helpers/WirelessControl.h": "f19bb602999118cc423ec74c7de7100ed91cb2c553295038077e52e321bb4e73", diff --git a/test/test_bridge_ingress.py b/test/test_bridge_ingress.py index af62aa7b6..0d72b29b2 100644 --- a/test/test_bridge_ingress.py +++ b/test/test_bridge_ingress.py @@ -12,6 +12,312 @@ ROOT = Path(__file__).resolve().parents[1] class BridgeIngressTests(unittest.TestCase): + def test_radio2_selection_is_local_explicit_and_never_falls_back(self): + base = (ROOT / "src/helpers/bridges/BridgeBase.cpp").read_text() + dispatcher = (ROOT / "src/Dispatcher.cpp").read_text() + receive = method(dispatcher, "void Dispatcher::processRecvPacket(") + guard = receive[receive.index(" const auto* profiles ="):receive.index(" const uint8_t previous_profile")] + code = '#include \n' + HARNESS[:HARNESS.index("@METHODS@")] + code += method(base, "void BridgeBase::handleReceivedPacket(") + code += r''' +#define MESH_DEBUG_PRINTLN(...) ((void)0) +#define MAX_PACKET_PAYLOAD 64 +using mesh::Packet; +using mesh::RadioProfileProtocol; +using mesh::RADIO_TX_AUTO; +using mesh::explicitRadioTxMask; +struct Radio { + mesh::RadioProfiles* state; + mesh::RadioProfiles* profiles() const {return state;} +}; +struct Dispatcher { + Radio* _radio; + bool radio_available=true; + unsigned accepted=0,released=0,failed=0,copies=0; + std::vector outbound; + mesh::Packet copy_pool[4]; unsigned allocated=0; + explicit Dispatcher(Radio* radio):_radio(radio) {} + void releasePacket(mesh::Packet*) {++released;} + void receive(mesh::Packet* pkt) { +@DISPATCH_GUARD@ + ++accepted; + } + bool queueTransmitCandidate(mesh::Packet* packet,uint8_t,uint32_t) { + outbound.push_back(*packet);return true; + } + unsigned futureMillis(unsigned delay) {return delay;} + bool allowRadioProfileCross(const mesh::Packet*) const {return true;} + void onSendFail(mesh::Packet*) {++failed;} + void onRadioProfileCopyQueued(mesh::Packet*,mesh::Packet*,uint8_t) {++copies;} + void onTracePacketQueuedForSend(mesh::Packet*) {} + mesh::Packet* obtainNewPacket() {assert(allocated<4);return ©_pool[allocated++];} + bool queueOutboundPacket(mesh::Packet*,uint8_t,uint32_t); + uint8_t getTransmitProfileMask(const mesh::Packet*) const; + bool isPacketRadioCurrent(const mesh::Packet*) const; +}; +@OUTBOUND@ +@MASK@ +@CURRENT@ +int main() { + using mesh::RadioCrossMode; + using mesh::RadioProfileMode; + using mesh::bridge::IngressProfile; + auto& selection=mesh::bridge::requested_ingress; + mesh::RadioProfiles profiles; + profiles.generation[0]=12;profiles.generation[1]=93; + profiles.secondary.mode=RadioProfileMode::RxTx; + profiles.cross=RadioCrossMode::Off; + BridgeBase bridge;bridge.setIngressProfiles(&profiles); + Radio radio{&profiles};Dispatcher dispatch{&radio}; + + // A missing setting means primary/AUTO exactly as before, including the + // operator's existing crossover policy. Merely selecting another RX tuple + // does not make a bridge follow the scanner. + assert(selection==IngressProfile::Radio); + mesh::Packet legacy; + legacy.radio_profile=legacy.radio_origin=1; + legacy.radio_bound=legacy.radio_local=legacy.radio_reply=legacy.radio_reply_force=true; + legacy.tx_radio=mesh::RADIO_TX_BOTH; + bridge.handleReceivedPacket(&legacy); + assert(legacy.radio_profile==0&&legacy.radio_origin==0&&legacy.radio_generation==12); + assert(legacy.tx_radio==mesh::RADIO_TX_AUTO&&!legacy.radio_local&&!legacy.radio_bound); + assert(!legacy.radio_reply&&!legacy.radio_reply_force&&legacy.forwarded); + profiles.cross=RadioCrossMode::On; + assert(dispatch.queueOutboundPacket(&legacy,0,0)); + assert(dispatch.outbound.size()==2&&dispatch.outbound[0].radio_profile==0&&dispatch.outbound[1].radio_profile==1); + dispatch.outbound.clear(); + + // The destination setting applies before admission filters/deduplication. + // The source wire packet remains ordinary MeshCore bytes, so old bridge + // senders do not need new headers or firmware to use the selected target. + selection=IngressProfile::Radio2; + unsigned filter_calls=0; + bridge.setPacketFilter([](void* context,const mesh::Packet* packet) { + ++*static_cast(context); + assert(packet->radio_profile==1&&packet->radio_origin==1); + assert(packet->radio_generation==93&&packet->radio_origin_generation==93); + assert(packet->tx_radio==mesh::RADIO_TX_SECONDARY&&packet->forwarded); + assert(!packet->radio_local&&!packet->radio_bound&&!packet->radio_reply&&!packet->radio_reply_force); + return true; + },&filter_calls); + mesh::Packet media; + media.radio_reply=media.radio_reply_force=media.radio_local=media.radio_bound=true; + media.tx_radio=mesh::RADIO_TX_BOTH; + uint8_t before[32],after[32];media.writeTo(before); + bridge.handleReceivedPacket(&media); + media.writeTo(after);assert(memcmp(before,after,sizeof(before))==0); + assert(filter_calls==1&&media.radio_profile==1&&media.tx_radio==mesh::RADIO_TX_SECONDARY); + assert(dispatch.getTransmitProfileMask(&media)==2); + assert(dispatch.queueOutboundPacket(&media,0,0)); + assert(dispatch.outbound.size()==1&&dispatch.outbound[0].radio_profile==1); + assert(dispatch.isPacketRadioCurrent(&media)); + // Changing the main frequency cannot invalidate or redirect a queued + // secondary packet. Retuning/disabling that secondary must invalidate it. + ++profiles.generation[0];assert(dispatch.isPacketRadioCurrent(&media)); + ++profiles.generation[1];assert(!dispatch.isPacketRadioCurrent(&media)); + const auto sent=dispatch.outbound.size(); + assert(!dispatch.queueOutboundPacket(&media,0,0)&&dispatch.outbound.size()==sent); + profiles.generation[1]=93; + profiles.secondary.mode=RadioProfileMode::Rx; + assert(!dispatch.isPacketRadioCurrent(&media)); + profiles.secondary.mode=RadioProfileMode::RxTx; + profiles.secondary.params.protocol=RadioProfileProtocol::Meshtext; + assert(!dispatch.isPacketRadioCurrent(&media)); + profiles.secondary.params.protocol=RadioProfileProtocol::MeshCore; + + // Bridge-delay work holds the generation selected on receipt, not the + // generation or target selected when it is processed later. + mesh::Packet delayed;bridge.handleReceivedPacket(&delayed); + dispatch.receive(&delayed);assert(dispatch.accepted==1&&dispatch.released==0); + ++profiles.generation[1]; + dispatch.receive(&delayed);assert(dispatch.accepted==1&&dispatch.released==1); + assert(!dispatch.queueOutboundPacket(&delayed,0,0)); + profiles.generation[1]=93; + + // Invalid selected targets are rejected before filters, dedup marking and + // queueing. There is no primary fallback even with crossover enabled. + const auto queued=bridge.manager.queued,checks=bridge._seen_packets.checks,marks=bridge._seen_packets.marks; + const auto filters=filter_calls; + unsigned drops=bridge.manager.freed; + for(auto mode:{RadioProfileMode::Off,RadioProfileMode::Rx}) { + profiles.secondary.mode=mode; + mesh::Packet p;bridge.handleReceivedPacket(&p);assert(bridge.manager.freed==++drops); + } + profiles.secondary.mode=RadioProfileMode::RxTx; + for(auto protocol:{RadioProfileProtocol::Meshtext,RadioProfileProtocol::Ethernet,RadioProfileProtocol::Kiss}) { + profiles.secondary.params.protocol=protocol; + mesh::Packet p;bridge.handleReceivedPacket(&p);assert(bridge.manager.freed==++drops); + } + profiles.secondary.params.protocol=RadioProfileProtocol::MeshCore; + bridge.setIngressProfiles(nullptr); + mesh::Packet missing;bridge.handleReceivedPacket(&missing);assert(bridge.manager.freed==++drops); + bridge.setIngressProfiles(&profiles); + selection=IngressProfile::Blocked; + mesh::Packet blocked;bridge.handleReceivedPacket(&blocked);assert(bridge.manager.freed==++drops); + assert(bridge.manager.queued==queued&&bridge._seen_packets.checks==checks&&bridge._seen_packets.marks==marks); + assert(filter_calls==filters&&dispatch.outbound.size()==sent); + + // Selecting radio2 for incoming bridge traffic does not change the route + // of unrelated local/primary traffic or serialize a profile hint onto RF. + selection=IngressProfile::Radio2; + profiles.cross=RadioCrossMode::Off; + mesh::Packet normal;normal.radio_generation=profiles.generation[0]; + assert(dispatch.getTransmitProfileMask(&normal)==1); + assert(dispatch.queueOutboundPacket(&normal,0,0)); + assert(dispatch.outbound.back().radio_profile==0); +} +''' + code = code.replace("@DISPATCH_GUARD@", guard) + for marker, signature in (("@OUTBOUND@", "bool Dispatcher::queueOutboundPacket("), + ("@MASK@", "uint8_t Dispatcher::getTransmitProfileMask("), + ("@CURRENT@", "bool Dispatcher::isPacketRadioCurrent(")): + code = code.replace(marker, method(dispatcher, signature)) + packet = PACKET.replace(" uint8_t payload[64]", " uint8_t path_len=0;\n static bool isValidPathLen(uint8_t n) {return n<64;}\n uint8_t payload[64]") + selector = r''' +#pragma once +#include +namespace mesh { namespace bridge { +enum class IngressProfile : uint8_t {Radio=0,Radio2=1,Blocked=2}; +static IngressProfile requested_ingress=IngressProfile::Radio; +inline IngressProfile ingressProfile() {return requested_ingress;} +}} +''' + with tempfile.TemporaryDirectory(prefix="meshcore-bridge-radio2-") as directory: + work = Path(directory) + (work / "Mesh.h").write_text(packet) + (work / "helpers").mkdir() + (work / "helpers/AbstractBridge.h").write_bytes( + (ROOT / "src/helpers/AbstractBridge.h").read_bytes()) + (work / "helpers/BridgeIngressProfile.h").write_text(selector) + source, binary = work / "main.cpp", work / "test" + source.write_text(code) + built = subprocess.run([ + os.environ.get("CXX", "g++"), "-std=c++17", "-O1", "-Wall", "-Wextra", "-Werror", + "-DWITH_BRIDGE=1", "-fsanitize=address,undefined", "-fno-sanitize-recover=all", "-fno-pie", "-no-pie", + "-I", str(work), "-I", str(ROOT / "src"), str(source), "-o", str(binary), + ], capture_output=True, text=True, timeout=60) + self.assertEqual(built.returncode, 0, built.stdout + built.stderr) + checked = subprocess.run([str(binary)], capture_output=True, text=True, timeout=15) + self.assertEqual(checked.returncode, 0, checked.stdout + checked.stderr) + + def test_regenerated_direct_and_multipart_acks_retain_ingress_tx_policy(self): + production = (ROOT / "src/Mesh.cpp").read_text() + code = r''' +#include +#include +#include +#include +#include +#undef MESH_DEBUG_PRINTLN +#define MESH_DEBUG_PRINTLN(...) ((void)0) +using mesh::Packet; +using DispatcherAction=unsigned; +constexpr DispatcherAction ACTION_RELEASE=0; +struct Seen { + bool seen=false; + unsigned marked=0; + bool wasSeen(Packet*) {return seen;} + void markSeen(Packet*) {seen=true;++marked;} +}; +struct Mesh { + Packet pool[12];unsigned used=0,retries=0; + Seen tables;Seen* _tables=&tables; + uint8_t expected_policy=mesh::RADIO_TX_AUTO; + std::vector sent; + std::vector delays; + Packet* obtainNewPacket() { + assert(used<12);auto* p=&pool[used++]; + // The dispatcher allocator inherits the radio receive context. The + // regenerated ACK must independently retain the explicit TX selection. + p->radio_profile=p->radio_origin=1; + p->radio_generation=p->radio_origin_generation=93; + p->radio_local=false; + return p; + } + uint8_t getExtraAckTransmitCount() const {return 2;} + void maybeScheduleDirectRetry(Packet* p,uint8_t) { + assert(p->tx_radio==expected_policy);++retries; + } + void sendPacket(Packet* p,uint8_t,uint32_t delay) {sent.push_back(*p);delays.push_back(delay);} + Packet* createAck(const uint8_t*,uint8_t); + Packet* createMultiAck(const uint8_t*,uint8_t,uint8_t); + void removePathPrefix(Packet*,uint8_t); + void routeDirectRecvAcks(Packet*,uint32_t); + DispatcherAction forwardMultipartDirect(Packet*,uint32_t); +}; +@METHODS@ +int main() { + for(uint8_t policy:{mesh::RADIO_TX_AUTO,mesh::RADIO_TX_PRIMARY, + mesh::RADIO_TX_SECONDARY,mesh::RADIO_TX_BOTH}) { + Packet source; + source.header=(PAYLOAD_TYPE_ACK<setIngressProfiles(_radio->profiles());", configure) - self.assertLess(configure.index("setIngressProfiles"), configure.index("#if MESH_ENABLE_FLOOD_RULE_ENGINE")) + def test_every_role_bridge_configuration_binds_current_profiles(self): + for role in ("simple_repeater", "simple_room_server"): + with self.subTest(role=role): + header = (ROOT / f"examples/{role}/MyMesh.h").read_text() + configure = method(header, "void configureBridgeFilter(") + self.assertIn("active_bridge->setIngressProfiles(_radio->profiles());", configure) + if role == "simple_repeater": + self.assertLess(configure.index("setIngressProfiles"), configure.index("#if MESH_ENABLE_FLOOD_RULE_ENGINE")) if __name__ == "__main__": diff --git a/test/test_bridge_ingress_settings.py b/test/test_bridge_ingress_settings.py new file mode 100644 index 000000000..72718695f --- /dev/null +++ b/test/test_bridge_ingress_settings.py @@ -0,0 +1,236 @@ +"""Exercise saved bridge ingress and storage failures using the real writers.""" +import os +from pathlib import Path +import subprocess +import tempfile +import unittest + +ROOT = Path(__file__).resolve().parents[1] + +HARNESS = r''' +#include +#include +#include +#include "BridgeIngressSettings.h" +#if !MESH_ENABLE_BRIDGE_INGRESS +#error "Raw bridge transport flags must activate settings before CommonCLI.h" +#endif +#if defined(NRF52_PLATFORM) || defined(STM32_PLATFORM) +#define ATOMIC_FILE_WRITER_IMPLEMENTATION +#include "AtomicFileWriter.h" +#endif +#include "settings_under_test.cpp" + +int main() { + using namespace mesh::bridge; + MemoryFS fs; +#if defined(NRF52_PLATFORM) || defined(STM32_PLATFORM) + fs.rename_replaces = true; +#endif + const char* path = "/bridge_ingress"; + const std::vector legacy = {1, 8, 4, 7, 9}; + fs.files["/com_prefs"] = legacy; + assert(ingressProfile() == IngressProfile::Blocked); + loadIngressSettings(&fs); + assert(ingressProfile() == IngressProfile::Radio); + assert(fs.files.size() == 1 && fs.files.at("/com_prefs") == legacy); + char reply[160]; + auto run = [&](const char* command) { + strcpy(reply, "untouched"); + return handleIngressCommand(command, reply, sizeof(reply)); + }; + assert(run("get bridge.ingress") && !strcmp(reply, "> radio")); + assert(!run("get bridge.ingressXYZ") && !strcmp(reply, "untouched")); + assert(run("set bridge.ingress radio2") && !strncmp(reply, "OK", 2)); + assert(ingressProfile() == IngressProfile::Radio2); + const auto good = fs.files.at(path); + assert(good.size() == 10 && !memcmp(good.data(), "BIN\1", 4)); + assert(good[4] == 1 && good[5] == 0xfe); + loadIngressSettings(&fs); + assert(ingressProfile() == IngressProfile::Radio2); + assert(run("get bridge.ingress") && !strcmp(reply, "> radio2")); + + assert(run("set bridge.ingress radio") && !strncmp(reply, "OK", 2)); + loadIngressSettings(&fs); + assert(ingressProfile() == IngressProfile::Radio); + assert(run("set bridge.ingress\tradio2") && !strncmp(reply, "OK", 2)); + assert(fs.files.at(path) == good); + for (const char* invalid : {"set bridge.ingress", "set bridge.ingress both", + "set bridge.ingress Radio2", "set bridge.ingress radio2junk", + "set bridge.ingress radio trailing", "get bridge.ingress extra"}) { + assert(run(invalid) && !strncmp(reply, "Error:", 6)); + assert(ingressProfile() == IngressProfile::Radio2); + assert(fs.files.at(path) == good); + } + assert(!run("set bridge.ingress2 radio")); + assert(!handleIngressCommand(nullptr, reply, sizeof(reply))); + assert(!handleIngressCommand("set bridge.ingress radio", nullptr, sizeof(reply))); + assert(!handleIngressCommand("set bridge.ingress radio", reply, 0)); + char bounded[] = {'x', 'x', 'x', 'z'}; + assert(handleIngressCommand("get bridge.ingress", bounded, 3)); + assert(bounded[2] == 0 && bounded[3] == 'z'); + + // Failed staging leaves the public preference and live target unchanged. + fs.fail_write = true; + assert(run("set bridge.ingress radio") && !strncmp(reply, "Error:", 6)); + assert(ingressProfile() == IngressProfile::Radio2 && fs.files.at(path) == good); + fs.fail_write = false; + fs.fail_write_after = 4; + assert(run("set bridge.ingress radio") && !strncmp(reply, "Error:", 6)); + assert(ingressProfile() == IngressProfile::Radio2 && fs.files.at(path) == good); + fs.fail_write_after = -1; + assert(run("set bridge.ingress radio2") && !strncmp(reply, "OK", 2)); + + // Publication may happen before readback fails. Quarantine until a reload; + // neither ingress nor another setter may silently fall back to primary. + fs.fail_after_publication = true; + assert(run("set bridge.ingress radio") && !strncmp(reply, "Error:", 6)); + assert(ingressProfile() == IngressProfile::Blocked); + const auto published = fs.files.at(path); + assert(published != good); + fs.fail_after_publication = false; + fs.fail_read_open = false; + assert(run("set bridge.ingress radio2") && !strncmp(reply, "Error:", 6)); + assert(ingressProfile() == IngressProfile::Blocked && fs.files.at(path) == published); + loadIngressSettings(&fs); + assert(ingressProfile() == IngressProfile::Radio); + assert(run("set bridge.ingress radio2") && !strncmp(reply, "OK", 2)); + + // Complete malformed records remain blocked but permit explicit repair. + for (size_t bit = 0; bit < good.size() * 8; ++bit) { + fs.files[path] = good; + fs.files[path][bit / 8] ^= uint8_t(1U << (bit % 8)); + loadIngressSettings(&fs); + assert(ingressProfile() == IngressProfile::Blocked); + assert(run("get bridge.ingress") && strstr(reply, "blocked")); + } + assert(run("set bridge.ingress radio2") && !strncmp(reply, "OK", 2)); + for (size_t size = 0; size < good.size(); ++size) { + fs.files[path] = std::vector(good.begin(), good.begin() + size); + loadIngressSettings(&fs); + assert(ingressProfile() == IngressProfile::Blocked); + } + assert(run("set bridge.ingress radio2") && !strncmp(reply, "OK", 2)); + fs.files[path].push_back(0); + loadIngressSettings(&fs); + assert(ingressProfile() == IngressProfile::Blocked); + assert(run("set bridge.ingress radio2") && !strncmp(reply, "OK", 2)); + + // Read/metadata uncertainty requires reclassification before another write. + fs.fail_read_open = true; + loadIngressSettings(&fs); + assert(ingressProfile() == IngressProfile::Blocked); + fs.fail_read_open = false; + assert(run("set bridge.ingress radio") && !strncmp(reply, "Error:", 6)); + assert(fs.files.at(path) == good); + loadIngressSettings(&fs); + assert(ingressProfile() == IngressProfile::Radio2); + fs.fail_read_after = 4; + loadIngressSettings(&fs); + assert(ingressProfile() == IngressProfile::Blocked); + fs.fail_read_after = -1; + assert(run("set bridge.ingress radio") && !strncmp(reply, "Error:", 6)); + loadIngressSettings(&fs); + fs.fail_metadata = true; + loadIngressSettings(&fs); + assert(ingressProfile() == IngressProfile::Blocked); + fs.fail_metadata = false; + assert(run("set bridge.ingress radio") && !strncmp(reply, "Error:", 6)); + loadIngressSettings(&fs); + assert(ingressProfile() == IngressProfile::Radio2); + +#if defined(ESP32_PLATFORM) || defined(RP2040_PLATFORM) + // Recover only the old complete record across the SPIFFS rename gap. + fs.files["/bridge_ingress.bak"] = good; + fs.files.erase(path); + loadIngressSettings(&fs); + assert(ingressProfile() == IngressProfile::Radio2 && fs.files.at(path) == good); + fs.files["/bridge_ingress.bak"] = good; + fs.files.erase(path); + fs.fail_rename = 1; + loadIngressSettings(&fs); + assert(ingressProfile() == IngressProfile::Blocked); + assert(fs.files.at("/bridge_ingress.bak") == good); + assert(run("set bridge.ingress radio") && !strncmp(reply, "Error:", 6)); + loadIngressSettings(&fs); + assert(ingressProfile() == IngressProfile::Radio2); +#endif + loadIngressSettings(nullptr); + assert(ingressProfile() == IngressProfile::Blocked); + assert(run("set bridge.ingress radio") && !strncmp(reply, "Error:", 6)); + assert(fs.files.at("/com_prefs") == legacy); +} +''' + + +class BridgeIngressSettingsTests(unittest.TestCase): + def test_real_settings_and_transactions_across_filesystems(self): + with tempfile.TemporaryDirectory(prefix="bridge-ingress-settings-") as temporary: + work = Path(temporary) + (work / "helpers").mkdir() + mock = (ROOT / "test/fixtures/radio_profiles/mocks/helpers/IdentityStore.h").read_text() + mock = mock.replace(" bool fail_read_open = false;", + " bool fail_read_open = false;\n" + " bool fail_after_publication = false;\n bool fail_metadata = false;") + mock = mock.replace(" files[to] = files[from]; files.erase(from); return true;", + " files[to] = files[from]; files.erase(from);\n" + " if (fail_after_publication && !strcmp(to, \"/bridge_ingress\")) fail_read_open = true;\n" + " return true;") + (work / "IdentityStore.h").write_text(mock) + (work / "helpers/IdentityStore.h").write_text('#include "../IdentityStore.h"\n') + (work / "helpers/AtomicFileWriter.h").write_text('#include "../AtomicFileWriter.h"\n') + # Replace only the hardware metadata adapter; both production + # publication algorithms and all setting/parser bytes are unchanged. + (work / "FilePresence.h").write_text(r''' +#pragma once +#include "IdentityStore.h" +namespace mesh { +template bool filePresence(FS* fs, const char* path, bool& present) { + if (!fs || !path || fs->fail_metadata) return false; + present = fs->exists(path); + return true; +} +} +''') + for name in ("BridgeIngressSettings.h", "BridgeIngressProfile.h", + "CheckedSettingsFile.h", "ContactFileTransaction.h", "AtomicFileWriter.h"): + (work / name).write_bytes((ROOT / "src/helpers" / name).read_bytes()) + (work / "settings_under_test.cpp").write_bytes( + (ROOT / "src/helpers/BridgeIngressSettings.cpp").read_bytes()) + (work / "test.cpp").write_text(HARNESS) + # CommonCLI.h synthesizes WITH_BRIDGE for these raw transport + # flags. Standalone settings/transport translation units have not + # included it yet, so each flag must activate the shared selector. + cases = [(platform, "WITH_BRIDGE") for platform in + ("ESP32_PLATFORM", "RP2040_PLATFORM", "NRF52_PLATFORM", "STM32_PLATFORM")] + cases += [("ESP32_PLATFORM", transport) for transport in + ("WITH_ESPNOW_BRIDGE", "WITH_RS232_BRIDGE", "WITH_MQTT_BRIDGE", "WITH_UDP_BRIDGE")] + for platform, transport in cases: + with self.subTest(platform=platform, transport=transport): + executable = work / (platform + "_" + transport) + built = subprocess.run([ + os.environ.get("CXX", "c++"), "-std=c++17", "-O1", "-Wall", "-Wextra", "-Werror", + "-Wno-sign-compare", "-fsanitize=address,undefined", "-fno-sanitize-recover=all", + "-fno-pie", "-no-pie", "-D" + platform, "-D" + transport + "=1", + "-I" + str(work), "-I" + str(ROOT / "src"), "-I" + str(ROOT / "src/helpers"), + str(work / "test.cpp"), "-o", str(executable)], + capture_output=True, text=True, timeout=60) + self.assertEqual(built.returncode, 0, built.stdout + built.stderr) + tested = subprocess.run([str(executable)], capture_output=True, text=True, timeout=20) + self.assertEqual(tested.returncode, 0, tested.stdout + tested.stderr) + + def test_bridge_free_build_has_no_storage_or_runtime_selector(self): + with tempfile.TemporaryDirectory(prefix="no-bridge-ingress-") as temporary: + work = Path(temporary) + obj = work / "empty.o" + built = subprocess.run([os.environ.get("CXX", "c++"), "-std=c++17", + "-DNRF52_PLATFORM", "-c", str(ROOT / "src/helpers/BridgeIngressSettings.cpp"), + "-o", str(obj)], capture_output=True, text=True, timeout=30) + self.assertEqual(built.returncode, 0, built.stdout + built.stderr) + symbols = subprocess.check_output(["nm", "-C", str(obj)], text=True) + for name in ("settings_fs", "selected", "writable", "loadIngressSettings", "handleIngressCommand"): + self.assertNotIn(name, symbols) + + +if __name__ == "__main__": + unittest.main() diff --git a/test/test_transport_filter_wiring.py b/test/test_transport_filter_wiring.py index 477c43c51..7b6177290 100644 --- a/test/test_transport_filter_wiring.py +++ b/test/test_transport_filter_wiring.py @@ -409,6 +409,13 @@ int main() { work = Path(directory) (work/'Mesh.h').write_text(PACKET) code = '#include \n' + HARNESS.replace('@METHODS@', '\n'.join(methods)) + code += ''' +#if MESH_ENABLE_BRIDGE_INGRESS +namespace mesh { namespace bridge { +IngressProfile ingressProfile() { return IngressProfile::Radio; } +}} +#endif +''' (work/'test.cpp').write_text(code) for macros in [[], ['-DWITH_ESPNOW_BRIDGE=1'], ['-DWITH_UDP_BRIDGE=1']]: with self.subTest(macros=macros): From d270cd611a13430db57055fcb10e0022a954aed3 Mon Sep 17 00:00:00 2001 From: mikecarper Date: Wed, 28 Oct 2026 15:34:42 -0700 Subject: [PATCH 47/61] Add split RX and TX repeater modes with source-based bridge routing --- .github/workflows/run-unit-tests.yml | 4 + docs/cli_command_availability.md | 6 +- docs/cli_commands.md | 59 +++- docs/espnow_bridge_setup.md | 3 + docs/split_radio_repeater.md | 164 +++++++++ docs/udp_bridge_setup.md | 3 + examples/simple_repeater/main.cpp | 7 + examples/simple_room_server/main.cpp | 6 + mkdocs.yml | 1 + src/Dispatcher.cpp | 52 ++- src/Dispatcher.h | 1 + src/DispatcherBeacon.cpp | 6 + src/helpers/AbstractBridge.h | 23 +- src/helpers/BridgeIngressProfile.h | 39 ++- src/helpers/BridgeIngressSettings.cpp | 144 +++++++- src/helpers/BridgeSourceRoutes.h | 155 +++++++++ src/helpers/CommonCLI.cpp | 12 +- src/helpers/RadioDirectionPolicy.h | 33 ++ src/helpers/RadioDirectionSettings.cpp | 154 +++++++++ src/helpers/RadioDirectionSettings.h | 16 + src/helpers/bridges/ESPNowBridge.cpp | 8 + src/helpers/bridges/UDPBridge.cpp | 4 + src/helpers/bridges/UDPBridge.h | 1 + src/helpers/radiolib/RadioLibWrappers.cpp | 159 ++++++++- src/helpers/radiolib/RadioLibWrappers.h | 5 + test/fixtures/cli_reply_text.json | 2 +- .../espnow_bridge_diagnostic_runtime.cpp | 3 +- .../udp_bridge/helpers/bridges/BridgeBase.h | 11 + test/fixtures/udp_bridge_runtime.cpp | 8 + test/test_bridge_ingress.py | 35 +- test/test_bridge_ingress_settings.py | 2 +- test/test_bridge_recovery.py | 22 +- test/test_bridge_source_routes.py | 162 +++++++++ test/test_radio_direction_dispatcher.py | 150 +++++++++ test/test_radio_direction_driver.py | 314 ++++++++++++++++++ test/test_radio_direction_settings.py | 216 ++++++++++++ test/test_transport_filter_wiring.py | 6 + 37 files changed, 1949 insertions(+), 47 deletions(-) create mode 100644 docs/split_radio_repeater.md create mode 100644 src/helpers/BridgeSourceRoutes.h create mode 100644 src/helpers/RadioDirectionPolicy.h create mode 100644 src/helpers/RadioDirectionSettings.cpp create mode 100644 src/helpers/RadioDirectionSettings.h create mode 100644 test/test_bridge_source_routes.py create mode 100644 test/test_radio_direction_dispatcher.py create mode 100644 test/test_radio_direction_driver.py create mode 100644 test/test_radio_direction_settings.py diff --git a/.github/workflows/run-unit-tests.yml b/.github/workflows/run-unit-tests.yml index 466c7bf4a..6075053d8 100644 --- a/.github/workflows/run-unit-tests.yml +++ b/.github/workflows/run-unit-tests.yml @@ -475,6 +475,8 @@ jobs: python3 -B test/test_bridge_recovery.py -v python3 -B test/test_bridge_ingress.py -v python3 -B test/test_bridge_ingress_settings.py -v + python3 -B test/test_bridge_source_routes.py -v + python3 -B test/test_radio_direction_settings.py -v - name: Verify signed private fleet management run: | @@ -614,6 +616,8 @@ jobs: python3 -B test/test_esp32_cpu_clock.py -v python3 -B test/test_esp32_cpu_clock_board.py -v python3 -B test/test_boot_slot_cli.py -v + python3 -B test/test_radio_direction_driver.py -v + python3 -B test/test_radio_direction_dispatcher.py -v python3 -B test/test_esp32_power_test_controls.py -v python3 -B test/test_esp32_uart_quiet_session.py -v python3 -B test/test_companion_rxps_getters.py -v diff --git a/docs/cli_command_availability.md b/docs/cli_command_availability.md index ded327ece..ed31c22b8 100644 --- a/docs/cli_command_availability.md +++ b/docs/cli_command_availability.md @@ -252,7 +252,7 @@ should use command `0x42`. See [Companion radio binary protocol](companion_proto | Bridge | [`get/set bridge.enabled`; `get bridge.running`](cli_commands.md#view-or-change-the-bridge-enabled-flag) | Compiled bridge | Feature | Feature | Feature | | Bridge | [`get/set bridge.delay`](cli_commands.md#add-a-delay-to-packets-routed-through-this-bridge) | Compiled bridge | Feature | Feature | Feature | | Bridge | [`get/set bridge.source`](cli_commands.md#view-or-change-the-source-of-packets-bridged-to-the-external-interface) | Compiled bridge | Feature | Feature | Feature | -| Bridge | [`get/set bridge.ingress`](cli_commands.md#select-the-radio-for-incoming-bridge-packets) | Compiled incoming packet bridge; saved primary or radio2 selection | Feature | Feature | Feature | +| Bridge | [`get/set bridge.ingress`](cli_commands.md#select-the-radio-for-incoming-bridge-packets) | Compiled incoming packet bridge; saved primary/radio2 target or off | Feature | Feature | Feature | | Bridge | [`get/set bridge.baud`](cli_commands.md#view-or-change-the-speed-of-the-bridge-rs-232-only) | RS-232 bridge | Feature | Feature | Feature | | Bridge | [`get/set bridge.uart`](cli_commands.md#view-or-change-the-uart-used-by-the-bridge-rs-232-only) | RS-232 bridge | Feature | Feature | Feature | | Bridge | [`get/set bridge.channel`](cli_commands.md#view-or-change-the-channel-used-for-bridging-espnow-only) | ESP-NOW is ESP32 only | No | No | No | @@ -348,6 +348,7 @@ should use command `0x42`. See [Companion radio binary protocol](companion_proto | Logging | [`get/set usb.watchdog off\|on\|auto`](usb_logging_watchdog.md) | Current source; recovery requires native USB, live logging, and durable storage; Auto qualifies from USB stats polling | Yes | Yes | Feature | Yes | Yes | | Logging | [`get usb.watchdog.last`](usb_logging_watchdog.md#last-event-review) | Current source; latest USB watchdog event with advisory timestamp, uptime, action, reasons, and durability; native USB required | Yes | Yes | Feature | Yes | Yes | | Radio | [`get radio`; `set radio ...`](cli_commands.md#view-or-change-this-nodes-radio-parameters) | All text CLI roles | Yes | Yes | Yes | Yes | Yes | +| Radio | [`get/set radio.mode`](cli_commands.md#select-lora-receive-and-transmit-directions) | ESP32 infrastructure LoRa direction: both, rx, tx or off | Yes | Yes | Yes | Yes | Yes | | Radio | [`get tx`; `set tx `](cli_commands.md#view-or-change-this-nodes-transmit-power) | Board TX-power limits apply; busy changes apply automatically | Yes | Yes | Yes | Yes | Yes | | Radio | [`tempradio ...`; `normalradio`](cli_commands.md#change-the-radio-parameters-for-a-set-duration) | Full parser | Yes | Yes | Yes | Yes | Yes | | Radio | [`get/set/del radioat`; `get/set/del tempradioat`](cli_commands.md#schedule-radio-parameter-changes) | Full parser | Yes | Yes | Yes | Yes | Yes | @@ -432,7 +433,8 @@ should use command `0x42`. See [Companion radio binary protocol](companion_proto | Bridge | [`get/set bridge.enabled`; `get bridge.running`](cli_commands.md#view-or-change-the-bridge-enabled-flag) | Compiled bridge | Feature | Feature | Feature | Yes | Feature | | Bridge | [`get/set bridge.delay`](cli_commands.md#add-a-delay-to-packets-routed-through-this-bridge) | Compiled bridge | Feature | Feature | Feature | Yes | Feature | | Bridge | [`get/set bridge.source`](cli_commands.md#view-or-change-the-source-of-packets-bridged-to-the-external-interface) | Compiled bridge | Feature | Feature | Feature | Yes | Feature | -| Bridge | [`get/set bridge.ingress`](cli_commands.md#select-the-radio-for-incoming-bridge-packets) | Compiled incoming packet bridge; saved primary or radio2 selection | Feature | Feature | Feature | Feature | Feature | +| Bridge | [`get/set bridge.ingress`](cli_commands.md#select-the-radio-for-incoming-bridge-packets) | Compiled incoming packet bridge; saved primary/radio2 target or off | Feature | Feature | Feature | Feature | Feature | +| Bridge | [`get/set/del bridge.route.1..8`; `clear bridge.routes`](cli_commands.md#route-incoming-bridge-packets-by-sender) | Exact ESP-NOW station MAC or UDP endpoint routing; compiled bridge required | Feature | Feature | Feature | Feature | Feature | | Bridge | [`get/set bridge.baud`](cli_commands.md#view-or-change-the-speed-of-the-bridge-rs-232-only) | RS-232 bridge | Feature | Feature | Feature | No | Feature | | Bridge | [`get/set bridge.uart`](cli_commands.md#view-or-change-the-uart-used-by-the-bridge-rs-232-only) | RS-232 bridge | Feature | Feature | Feature | No | Feature | | Bridge | [`get/set bridge.channel`](cli_commands.md#view-or-change-the-channel-used-for-bridging-espnow-only) | ESP-NOW bridge | No | No | No | Feature | Feature | diff --git a/docs/cli_commands.md b/docs/cli_commands.md index 082349562..7916f943a 100644 --- a/docs/cli_commands.md +++ b/docs/cli_commands.md @@ -2009,6 +2009,35 @@ capacity. Remote use follows the existing administrator-command permissions. ### Radio +#### Select LoRa receive and transmit directions + +```text +get radio.mode +set radio.mode both +set radio.mode rx +set radio.mode tx +set radio.mode off +``` + +Supported ESP32 infrastructure images save this setting across reboot. The +default is `both`. `rx` permits LoRa reception and blocks all LoRa transmission, +including forwarding, adverts and replies. `tx` accepts no LoRa packets while +allowing bridge-fed and locally generated transmissions. Queued packets require a clear +CAD result on the selected profile before sending and keep backing off while +busy, without a timeout that bypasses CAD. `get cad` reports the effective +mandatory check in TX-only mode, even if its saved preference is off. A clear +check cannot guarantee collision-free delivery. `off` disables both packet directions. These +choices apply to the primary and second LoRa profiles; they do not change the +WiFi, ESP-NOW or serial bridge directions, or make an RX-only radio2 profile +transmit-capable. + +Use USB or a direct WiFi CLI when changing direction. A receive-only node +cannot send its LoRa command reply, and a transmit-only node cannot receive a +LoRa command. Invalid or unreadable saved settings leave LoRa off rather than +silently enabling transmission. A known malformed record can be replaced +explicitly; uncertain storage requires a reboot before another save. +See [two RX-only V4s feeding one TX-only V4](split_radio_repeater.md). + #### Tune and save radio switching acceleration Use local USB or BLE to change these settings on supported radio HALs. @@ -6799,6 +6828,7 @@ updated active peer can still answer a valid incoming ping in passive mode. get bridge.ingress set bridge.ingress radio2 set bridge.ingress radio +set bridge.ingress off ``` This receiver-side setting applies to incoming mesh packets from ESP-NOW, UDP @@ -6813,8 +6843,8 @@ silently restoring the primary; `get bridge.ingress` reports the fault. keeps their forwarding on that profile, even with `radio2.cross on`. If radio2 is off, RX-only, owned by another protocol, or changed while a packet waits, the packet is dropped; it never falls back to the primary radio. Normal repeat, -scope, duplicate and filter checks still apply. This setting selects every -incoming packet on these bridges, not individual senders or payload types. +scope, duplicate and filter checks still apply. A matching source route below +overrides this default. `off` drops incoming packets without a matching route. The selection follows forwarded packets, retries and relayed ACKs. Locally generated adverts and admin replies keep their existing routing, including `tx.reply`; this is not a global transmit override. @@ -6825,6 +6855,31 @@ see the [MediaMesh bridge example](espnow_bridge_setup.md#send-bridge-traffic-on `bridge.ingress`. This does not add MQTT packet injection or change MQTT publication settings. +#### Route incoming bridge packets by sender + +```text +get bridge.route +get bridge.route.1 +set bridge.route.1 mac:AA:BB:CC:DD:EE:10 radio2 +set bridge.route.2 udp:192.168.1.51:4404 radio +del bridge.route.1 +clear bridge.routes +``` + +Slots `1..8` match an exact ESP-NOW station MAC (`mac:`) or UDP sender address +and listening port (`udp:`). Each source can appear once. A matching rule +selects only `radio`, only `radio2`, or drops that source with `off`, regardless +of crossing settings. Unmatched packets use `bridge.ingress`; set that to +`off` to accept only listed senders. `get bridge.route` reports the count and +default; query each numbered slot for its saved rule. `get espnow.stats mac` +reads the local station MAC once the ESP-NOW/WiFi driver is active. + +Rules are saved separately from the default and from legacy node preferences. +Missing routes preserve existing behavior and bridge frames remain unchanged. +New admissions use the saved route; queued packets retain their original +profile and generation. Unavailable, RX-only or foreign-protocol targets drop +the packet rather than falling back. See the [three-V4 example](split_radio_repeater.md). + --- #### View or change the MQTT master switch diff --git a/docs/espnow_bridge_setup.md b/docs/espnow_bridge_setup.md index 714a05e8d..7c03a3a86 100644 --- a/docs/espnow_bridge_setup.md +++ b/docs/espnow_bridge_setup.md @@ -4,6 +4,9 @@ # ESP-NOW bridge: simple setup +For two dedicated receivers feeding one dual-profile transmitter, see +[the three-V4 split repeater setup](split_radio_repeater.md). + Use two ESP32 LoRa boards to join two MeshCore networks with different radio settings. Each board repeats on its own LoRa network, and the boards exchange packets directly over 2.4 GHz ESP-NOW. No router, internet connection, or manual diff --git a/docs/split_radio_repeater.md b/docs/split_radio_repeater.md new file mode 100644 index 000000000..dbfacaf69 --- /dev/null +++ b/docs/split_radio_repeater.md @@ -0,0 +1,164 @@ + + + + +# Two receive-only V4s feeding one transmit-only V4 + +Use the exact board's updated **Full Repeater** image on all three V4s. The +left V4 receives MediaMesh, the right V4 receives USA, and both feed the center +over ESP-NOW or WiFi UDP: + +```text +MediaMesh 911.150 --> left V4 (LoRa RX only) --+ + +--> center V4 (LoRa TX only) +USA 910.525 --> right V4 (LoRa RX only) --+ radio: USA 910.525 + radio2: MediaMesh 911.150 +``` + +The center routes by each bridge sender's identity. It never needs to infer +the source frequency from encrypted packet contents. Its single LoRa chip +transmits the two profiles sequentially; they cannot transmit simultaneously. + +Configure over USB or a direct WiFi CLI. `radio.mode rx` also prevents LoRa +admin replies, and `tx` prevents receiving LoRa commands. These modes apply to +both LoRa profiles and persist across reboot. They leave the 2.4 GHz bridge +able to transmit and receive. TX-only requires a clear-channel CAD check on +the selected LoRa profile before forwarding a packet. If busy, the center keeps +backing off; it does not bypass CAD after a timeout. A clear check cannot +guarantee collision-free delivery. The center does not accept LoRa packets. + +## ESP-NOW setup + +On **all three V4s**, prepare the same bridge group. Replace `YourSharedKey` +with the same 1–15 character secret, and use one common 2.4 GHz channel: + +```text +set espnow.enabled off +set bridge.channel 6 +set bridge.format wrapped +set bridge.secret YourSharedKey +set bridge.source rx +set bridge.delay 500 +clear bridge.routes +set bridge.ingress off +``` + +`clear bridge.routes` replaces any existing source mappings. With ingress +`off`, a sender without an explicit rule cannot enter the local LoRa mesh. +Using source `rx` on the center also prevents its LoRa transmissions being +exported back to the receivers. + +On the **left V4**: + +```text +set radio.mode rx +set radio 911.150,250,7,5 +set radio2 off +set radio2.cross off +set repeat off +set espnow.enabled on +get espnow.stats mac +``` + +On the **right V4**: + +```text +set radio.mode rx +set radio 910.525,62.5,7,5 +set radio2 off +set radio2.cross off +set repeat off +set espnow.enabled on +get espnow.stats mac +``` + +Record each returned `mac:AA:BB:CC:DD:EE:FF` value. This is the WiFi station +MAC used by ESP-NOW, not the node's MeshCore public key or Bluetooth address. + +On the **center V4**, substitute those actual MAC addresses: + +```text +set radio.mode tx +set radio 910.525,62.5,7,5 +set radio2 mediamesh +set radio2.cross off +set bridge.route.1 mac:AA:BB:CC:DD:EE:10 radio2 +set bridge.route.2 mac:AA:BB:CC:DD:EE:20 radio +set repeat on +set espnow.enabled on +``` + +The first example MAC represents the left V4; the second represents the right. +Forwarded left traffic stays on MediaMesh, and forwarded right traffic on USA. +If a selected profile is unavailable, incoming packets are dropped rather +than moved to the other channel. Normal duplicate, scope, filter and airtime +checks still apply. The `mediamesh` preset also enables the normal MediaMesh +forwarding region. The center's own adverts and replies retain their existing +routing policies, including `tx.reply`; source rules select forwarded traffic. + +## WiFi UDP alternative + +Keep the LoRa settings and direction modes above. Disable ESP-NOW on all +three, connect them to an existing WiFi network using the [WiFi settings](WiFi.md), +and reserve their DHCP addresses. These examples use left `192.168.1.50`, +right `192.168.1.51`, center `192.168.1.52`, all on UDP port `4404`. +Keep the same `bridge.secret` on all three. + +On **each receiver**, configure the center as its only UDP destination: + +```text +set espnow.enabled off +set udp.enabled off +set udp.port 4404 +set udp.peer.1 192.168.1.52:4404 +set udp.enabled on +get udp.status +``` + +Remove any other saved `udp.peer.2` through `.8` destinations if present. On +the **center**, replace the MAC rules with endpoint rules and allow both peers: + +```text +set espnow.enabled off +set udp.enabled off +set udp.port 4404 +set udp.peer.1 192.168.1.50:4404 +set udp.peer.2 192.168.1.51:4404 +clear bridge.routes +set bridge.ingress off +set bridge.route.1 udp:192.168.1.50:4404 radio2 +set bridge.route.2 udp:192.168.1.51:4404 radio +set udp.enabled on +get udp.status +``` + +The route matches the sender's IP address **and UDP listening port**. Both +ends must allow each other's endpoint for the authenticated bridge session. +See [UDP bridge setup](udp_bridge_setup.md) for station readiness, router +isolation and transport probes. + +## Check the result + +On each receiver, `get radio.mode` should report `rx`. On the center: + +```text +get radio.mode +get cad +get radio +get radio2 +get bridge.ingress +get bridge.route.1 +get bridge.route.2 +get radio2.status +``` + +Expect center mode `tx`, effective CAD on, default ingress `off`, the left rule targeting +`radio2`, and the right rule targeting `radio`. Send a packet on each LoRa +network and check receipt on that same network beyond the center. Bridge +counters show transport activity but do not by themselves prove LoRa delivery. + +To return a V4 to normal LoRa operation, use `set radio.mode both`. Existing +installations default to `both`, with no source routes and primary-radio +bridge ingress. Existing bridge wire formats are unchanged. Strict direction +modes require updated firmware on each configured V4; old bridge senders can +still feed an updated center using the source mappings. diff --git a/docs/udp_bridge_setup.md b/docs/udp_bridge_setup.md index 926a52533..0358beec7 100644 --- a/docs/udp_bridge_setup.md +++ b/docs/udp_bridge_setup.md @@ -4,6 +4,9 @@ # WiFi UDP bridge: up to eight IP peers +For two receive-only V4s feeding one transmit-only V4, see +[the split repeater setup](split_radio_repeater.md#wifi-udp-alternative). + Supported ESP32 repeater images can bridge MeshCore packets directly over an existing WiFi network, without an MQTT broker. Each board has one UDP listening port and up to eight configured IPv4 address/port destinations. Eight is the diff --git a/examples/simple_repeater/main.cpp b/examples/simple_repeater/main.cpp index 93a945a79..9da6ba5be 100644 --- a/examples/simple_repeater/main.cpp +++ b/examples/simple_repeater/main.cpp @@ -5,6 +5,9 @@ #include #include #include // needed for PlatformIO +#if defined(ESP32_PLATFORM) +#include +#endif #include #include #include @@ -223,6 +226,10 @@ void setup() { store.begin(); #else #error "need to define filesystem" +#endif +#if defined(ESP32_PLATFORM) + // Load direction before initializing the radio, including its first RX arm. + mesh::radioDirection::loadDirectionSettings(fs); #endif FILESYSTEM* regulator_prefs_fs = fs; #if defined(NRF52_PLATFORM) diff --git a/examples/simple_room_server/main.cpp b/examples/simple_room_server/main.cpp index f5cc3339f..0e5ad2081 100644 --- a/examples/simple_room_server/main.cpp +++ b/examples/simple_room_server/main.cpp @@ -5,6 +5,9 @@ #include #include #include // needed for PlatformIO +#if defined(ESP32_PLATFORM) +#include +#endif #include #include #include @@ -154,6 +157,9 @@ void setup() { IdentityStore store(SPIFFS, "/identity"); #else #error "need to define filesystem" +#endif +#if defined(ESP32_PLATFORM) + mesh::radioDirection::loadDirectionSettings(fs); #endif FILESYSTEM* regulator_prefs_fs = fs; #if defined(NRF52_PLATFORM) diff --git a/mkdocs.yml b/mkdocs.yml index 6303c01b1..e6f693fde 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -81,6 +81,7 @@ nav: - room_services.md - carrier_wave.md - espnow_bridge_setup.md + - split_radio_repeater.md - rs232_bridge_setup.md - udp_bridge_setup.md - WiFi.md diff --git a/src/Dispatcher.cpp b/src/Dispatcher.cpp index a283b334a..875044f35 100644 --- a/src/Dispatcher.cpp +++ b/src/Dispatcher.cpp @@ -1,6 +1,7 @@ #include "Dispatcher.h" #include "helpers/HostKissHooks.h" #include "helpers/ota/OtaTiming.h" +#include "helpers/RadioDirectionPolicy.h" #if MESH_ENABLE_OTA_KISS_DIRECT #include "helpers/ota/OtaDirectWire.h" #endif @@ -184,6 +185,7 @@ bool Dispatcher::restoreOutboundTxOverrides() { } bool Dispatcher::startOutboundTransmit() { + if (!radioDirection::radioTransmitAllowed()) return false; if (outbound == NULL) return false; if (!restoreOutboundTxOverrides()) return false; if (!isPacketRadioCurrent(outbound) @@ -299,6 +301,7 @@ bool Dispatcher::supportsForeignRadioProtocol(RadioProfileProtocol protocol) con } bool Dispatcher::isForeignRadioCurrent() const { + if (!radioDirection::radioTransmitAllowed()) return false; const auto* profiles = _radio->profiles(); #if MESH_ENABLE_OTA_KISS_DIRECT if (foreign_radio_protocol == RadioProfileProtocol::MeshCore) { @@ -321,6 +324,7 @@ bool Dispatcher::isForeignRadioCurrent() const { #if MESH_ENABLE_OTA_KISS_DIRECT bool Dispatcher::queueDirectOtaRadioFrame(const uint8_t* message, uint16_t length, uint8_t profile, uint32_t generation) { + if (!radioDirection::radioTransmitAllowed()) return false; #if MESH_ENABLE_TX_BEACONS if (hasBeaconRadioFrame()) return false; #endif @@ -355,6 +359,7 @@ __attribute__((noinline)) bool Dispatcher::queueForeignRadioFrame(const uint8_t* bytes, size_t length, uint8_t profile, uint32_t generation, RadioProfileProtocol protocol) { + if (!radioDirection::radioTransmitAllowed()) return false; #if MESH_ENABLE_TX_BEACONS if (hasBeaconRadioFrame()) return false; #endif @@ -504,7 +509,8 @@ bool Dispatcher::checkForeignRadioSend() { uint32_t& busy_since = profile_cad_busy[foreign_radio_profile]; if (_radio->isReceiving()) { if (!busy_since) busy_since = now ? now : 1; - if ((uint32_t)(now - busy_since) <= getCADFailMaxDuration()) { + if (radioDirection::mode() == radioDirection::Mode::Tx || + (uint32_t)(now - busy_since) <= getCADFailMaxDuration()) { foreign_radio_retry_at = now + getCADFailRetryDelay(); return false; } @@ -567,12 +573,12 @@ void Dispatcher::logPacketLine(const char* direction, const Packet* packet, #endif bool Dispatcher::getNextNoiseFloorWakeDelay(uint32_t& delay_millis) const { - return radio_available + return radioDirection::radioReceiveAllowed() && radio_available && _radio->getNoiseFloorWakeDelay(_ms->getMillis(), delay_millis); } bool Dispatcher::getNextQueueWakeDelay(uint32_t& delay_millis) const { - const bool dual_profile = isDualRadioActive(); + const bool dual_profile = radioDirection::radioReceiveAllowed() && isDualRadioActive(); uint32_t profile_delay = 0; if (dual_profile && (!radio_available || !_radio->getProfileIdleDelay(profile_delay) || profile_delay == 0)) { @@ -600,7 +606,7 @@ bool Dispatcher::getNextQueueWakeDelay(uint32_t& delay_millis) const { #endif // Timed MCU sleep must not skip the adaptive maintenance/retry deadline. // Preserve the legacy queue-only query when power saving is off. - if (radio_available && useAdaptiveAGC()) { + if (radioDirection::radioReceiveAllowed() && radio_available && useAdaptiveAGC()) { uint32_t agc_delay; const bool agc_found = agc_reset.nextDelay(now, agc_delay); if (agc_found && (!found || agc_delay < shortest_delay)) shortest_delay = agc_delay; @@ -614,7 +620,7 @@ bool Dispatcher::getNextQueueWakeDelay(uint32_t& delay_millis) const { } uint32_t receive_retry_delay = 0; - const bool receive_retry = radio_available + const bool receive_retry = radioDirection::radioReceiveAllowed() && radio_available && _radio->getReceiveRetryDelay(now, receive_retry_delay); if (outbound != NULL) { if (outbound_radio_retry_pending) { @@ -700,11 +706,14 @@ void Dispatcher::loop() { // Restore before any calibration/scan/watchdog can observe a foreign tuple. if (serviceBeaconRadioFrame()) return; #endif - if (millisHasNowPassed(next_floor_calib_time)) { + if (radioDirection::radioReceiveAllowed() && millisHasNowPassed(next_floor_calib_time)) { _radio->triggerNoiseFloorCalibrate(getInterferenceThreshold()); _radio->setCADEnabled(getCADEnabled()); next_floor_calib_time = futureMillis(NOISE_FLOOR_CALIB_INTERVAL); } + // TX-only skips receive calibration, but keeps its saved CAD policy. + if (!radioDirection::radioReceiveAllowed()) _radio->setCADEnabled( + radioDirection::mode() == radioDirection::Mode::Tx || getCADEnabled()); _radio->loop(); // A maintenance carrier has no TxDone packet. Keep its deadline serviced, // but leave queued packets and RX/AGC watchdogs alone until normal RX returns. @@ -716,8 +725,9 @@ void Dispatcher::loop() { #endif const unsigned long now = _ms->getMillis(); - const int agc_interval = getAGCResetInterval(); - const bool adaptive = useAdaptiveAGC(); + const bool receive_allowed = radioDirection::radioReceiveAllowed(); + const int agc_interval = receive_allowed ? getAGCResetInterval() : 0; + const bool adaptive = receive_allowed && useAdaptiveAGC(); agc_reset.configure(now, agc_interval > 0 ? agc_interval : 0, adaptive, adaptive ? _radio->getRecoveryCount() : 0); if (!radio_nonrx_timer_armed) { @@ -741,7 +751,17 @@ void Dispatcher::loop() { #endif } bool recovered_this_loop = false; - if (!is_recv && outbound == NULL && now - radio_nonrx_start > 8000) { + if (!receive_allowed) { + // Intentional TX-only/disabled operation is not a deaf receiver. Give RX + // a fresh watchdog allowance when it is enabled again. + radio_nonrx_start = now; +#ifdef RADIO_LIVENESS_SOFT_ONLY + rx_watchdog_window_start = now; +#else + radio_liveness.begin(now); +#endif + } + if (receive_allowed && !is_recv && outbound == NULL && now - radio_nonrx_start > 8000) { _err_flags |= ERR_EVENT_STARTRX_TIMEOUT; #ifdef RADIO_LIVENESS_SOFT_ONLY MESH_DEBUG_PRINTLN("Radio watchdog: radio outside RX for %lu ms; soft recovery", @@ -938,7 +958,7 @@ void Dispatcher::loop() { // a packet arriving at the deadline prevents unnecessary recovery, and a // continuous TX queue cannot hide a deaf receiver. The driver defers any // recovery that would interrupt a pending packet or an active reception. - if (_radio->isInRecvMode() && outbound == NULL && !recovered_this_loop) { + if (receive_allowed && _radio->isInRecvMode() && outbound == NULL && !recovered_this_loop) { const unsigned long now = _ms->getMillis(); uint32_t soft_liveness_ms = RADIO_LIVENESS_SOFT_MS; #ifdef WITH_MQTT_BRIDGE @@ -1061,6 +1081,7 @@ bool Dispatcher::tryParsePacket(Packet* pkt, const uint8_t* raw, int len) { } void Dispatcher::checkRecv() { + if (!radioDirection::radioReceiveAllowed()) return; if (_radio->isCarrierWaveActive()) return; Packet* pkt; float score; @@ -1193,6 +1214,12 @@ void Dispatcher::checkRecv() { } void Dispatcher::processRecvPacket(Packet* pkt) { + // Bridge admissions are marked forwarded before they enter this queue. + // Retire RF receptions delayed before a switch to TX-only/off. + if (!radioDirection::radioReceiveAllowed() && !pkt->forwarded) { + releasePacket(pkt); + return; + } const auto* profiles = _radio->profiles(); if (profiles && (pkt->radio_profile > 1 || (pkt->radio_profile == 1 && !profiles->enabled()) @@ -1247,7 +1274,7 @@ bool Dispatcher::isTransmitChannelReady(const Packet* packet, uint32_t& retry_de } const uint32_t maximum = scaleCADDelayForQueue( getCADFailMaxDuration(), ready_count, MIN_CAD_FAIL_MAX_DURATION_MS); - if (now - cad_busy_start <= maximum) { + if (radioDirection::mode() == radioDirection::Mode::Tx || now - cad_busy_start <= maximum) { retry_delay = scaleCADDelayForQueue( getCADFailRetryDelay(), ready_count, MIN_CAD_FAIL_RETRY_DELAY_MS); return false; @@ -1434,6 +1461,7 @@ bool Dispatcher::queueTransmitCandidate(Packet* packet, uint8_t priority, uint32 } bool Dispatcher::queueOutboundPacket(Packet* packet, uint8_t priority, uint32_t delay_millis) { + if (!radioDirection::radioTransmitAllowed()) return false; if (packet->violatesRoutePolicy()) { return false; } @@ -1509,6 +1537,7 @@ bool Dispatcher::queueOutboundPacket(Packet* packet, uint8_t priority, uint32_t } uint8_t Dispatcher::getTransmitProfileMask(const Packet* packet) const { + if (!radioDirection::radioTransmitAllowed()) return 0; const auto* profiles = _radio->profiles(); if (!profiles) return packet->tx_radio == RADIO_TX_AUTO ? 1 : explicitRadioTxMask(packet->tx_radio, false); if (packet->radio_profile > 1) return 0; @@ -1544,6 +1573,7 @@ uint32_t Dispatcher::getTransmitAirtime(const Packet* packet, int wire_length) c } bool Dispatcher::isPacketRadioCurrent(const Packet* packet) const { + if (!radioDirection::radioTransmitAllowed()) return false; const auto* p = _radio->profiles(); if (!p) return true; const uint8_t target = packet->radio_profile; diff --git a/src/Dispatcher.h b/src/Dispatcher.h index 1c1784089..811b44b69 100644 --- a/src/Dispatcher.h +++ b/src/Dispatcher.h @@ -13,6 +13,7 @@ #include #include #include +#include #ifndef RADIO_LIVENESS_SOFT_ONLY #include #endif diff --git a/src/DispatcherBeacon.cpp b/src/DispatcherBeacon.cpp index a595048df..c684bca26 100644 --- a/src/DispatcherBeacon.cpp +++ b/src/DispatcherBeacon.cpp @@ -1,4 +1,5 @@ #include "Dispatcher.h" +#include "helpers/RadioDirectionPolicy.h" #if MESH_ENABLE_TX_BEACONS #include @@ -18,6 +19,7 @@ Dispatcher::~Dispatcher() { delete beacon_tx; } bool Dispatcher::queueBeaconRadioFrame(const uint8_t* bytes, size_t length, const RadioProfileParams& params, int8_t power) { + if (!radioDirection::radioTransmitAllowed()) return false; if (!bytes || !length || length > MAX_TRANS_UNIT || beacon_tx || outbound || !radio_available || _radio->isCarrierWaveActive() || _radio->isBeaconRadioOwned() @@ -93,6 +95,10 @@ bool Dispatcher::serviceBeaconRadioFrame() { } bool Dispatcher::checkBeaconRadioSend() { + if (!radioDirection::radioTransmitAllowed()) { + if (beacon_tx) cancelBeaconRadioFrame(); + return beacon_tx != nullptr; + } if (!beacon_tx || outbound || beacon_tx->phase != BeaconTransmit::Pending) return false; const uint32_t now = _ms->getMillis(); if (int32_t(now - beacon_tx->prepare_expiry) >= 0) { diff --git a/src/helpers/AbstractBridge.h b/src/helpers/AbstractBridge.h index 0e1f89f55..89b6287ad 100644 --- a/src/helpers/AbstractBridge.h +++ b/src/helpers/AbstractBridge.h @@ -8,14 +8,30 @@ class AbstractBridge { PacketFilter _packet_filter = nullptr; void* _packet_filter_context = nullptr; const mesh::RadioProfiles* _ingress_profiles = nullptr; + const mesh::bridge::IngressSource* _ingress_source = nullptr; protected: + // Source identity is borrowed only while the main loop admits one frame. + // The queued Packet retains the selected profile, not this stack pointer. + class IngressSourceScope { + AbstractBridge& owner; + const mesh::bridge::IngressSource* previous; + public: + IngressSourceScope(AbstractBridge& bridge, const mesh::bridge::IngressSource& source) + : owner(bridge), previous(bridge._ingress_source) { owner._ingress_source = &source; } + ~IngressSourceScope() { owner._ingress_source = previous; } + IngressSourceScope(const IngressSourceScope&) = delete; + IngressSourceScope& operator=(const IngressSourceScope&) = delete; + }; + // The receiver selects a local MeshCore profile, independent of the wire // format and the scanner's current tuple. Legacy bridges default to primary. // Snapshot the generation so delayed frames cannot acquire a newer lease. bool preparePacketIngress(mesh::Packet* packet) const { - const auto target = mesh::bridge::ingressProfile(); - if (!packet || target == mesh::bridge::IngressProfile::Blocked) return false; + const auto route = mesh::bridge::ingressRoute(_ingress_source); + const auto target = route.profile; + if (!packet || target == mesh::bridge::IngressProfile::Blocked + || target == mesh::bridge::IngressProfile::Off) return false; const uint8_t profile = target == mesh::bridge::IngressProfile::Radio2 ? 1 : 0; if ((profile == 1 && (!_ingress_profiles || !_ingress_profiles->enabled() || !_ingress_profiles->canTransmit(1))) @@ -28,7 +44,8 @@ protected: packet->radio_local = false; // Explicit secondary ingress stays secondary even if crossing is enabled. // The default primary selector retains its historical crossing behavior. - packet->tx_radio = profile == 1 ? mesh::RADIO_TX_SECONDARY : mesh::RADIO_TX_AUTO; + packet->tx_radio = profile == 1 ? mesh::RADIO_TX_SECONDARY + : route.explicit_profile ? mesh::RADIO_TX_PRIMARY : mesh::RADIO_TX_AUTO; packet->radio_reply = packet->radio_reply_force = false; packet->forwarded = true; return true; diff --git a/src/helpers/BridgeIngressProfile.h b/src/helpers/BridgeIngressProfile.h index b756d4b75..c59695f7f 100644 --- a/src/helpers/BridgeIngressProfile.h +++ b/src/helpers/BridgeIngressProfile.h @@ -1,4 +1,6 @@ #pragma once +#ifndef MESH_BRIDGE_INGRESS_PROFILE_H +#define MESH_BRIDGE_INGRESS_PROFILE_H #include @@ -15,12 +17,47 @@ namespace mesh { namespace bridge { -enum class IngressProfile : uint8_t { Radio = 0, Radio2 = 1, Blocked = 2 }; +enum class IngressProfile : uint8_t { Radio = 0, Radio2 = 1, Blocked = 2, Off = 3 }; + +struct IngressSource { + enum class Kind : uint8_t { None = 0, Mac = 1, Udp = 2 }; + Kind kind = Kind::None; + uint8_t bytes[6] = {}; + + static IngressSource mac(const uint8_t* address) { + IngressSource source; + if (address) { + source.kind = Kind::Mac; + for (unsigned i = 0; i < 6; ++i) source.bytes[i] = address[i]; + } + return source; + } + static IngressSource udp(const uint8_t* address, uint16_t port) { + IngressSource source; + if (address && port) { + source.kind = Kind::Udp; + for (unsigned i = 0; i < 4; ++i) source.bytes[i] = address[i]; + source.bytes[4] = port >> 8; + source.bytes[5] = port; + } + return source; + } +}; + +struct IngressRoute { + IngressProfile profile; + bool explicit_profile; +}; #if MESH_ENABLE_BRIDGE_INGRESS IngressProfile ingressProfile(); +IngressRoute ingressRoute(const IngressSource* source = nullptr); #else inline IngressProfile ingressProfile() { return IngressProfile::Radio; } +inline IngressRoute ingressRoute(const IngressSource* = nullptr) { + return {IngressProfile::Radio, false}; +} #endif }} // namespace mesh::bridge +#endif diff --git a/src/helpers/BridgeIngressSettings.cpp b/src/helpers/BridgeIngressSettings.cpp index cf6a3deb4..7e133a07d 100644 --- a/src/helpers/BridgeIngressSettings.cpp +++ b/src/helpers/BridgeIngressSettings.cpp @@ -3,6 +3,7 @@ #if MESH_ENABLE_BRIDGE_INGRESS #include "CheckedSettingsFile.h" #include "FilePresence.h" +#include "BridgeSourceRoutes.h" #if defined(NRF52_PLATFORM) || defined(STM32_PLATFORM) #include "AtomicFileWriter.h" #else @@ -18,6 +19,10 @@ constexpr size_t ImageSize = 10; FILESYSTEM* settings_fs = nullptr; IngressProfile selected = IngressProfile::Blocked; bool writable = false; +constexpr char RoutesPath[] = "/bridge_routes"; +IngressRule routes[MaxIngressRoutes]; +bool routes_healthy = false; +bool routes_writable = false; uint32_t checksum(const uint8_t* data, size_t size) { uint32_t crc = 0xffffffffU; @@ -30,7 +35,7 @@ uint32_t checksum(const uint8_t* data, size_t size) { } void encode(uint8_t image[ImageSize], IngressProfile profile) { - memcpy(image, "BIN\1", 4); + memcpy(image, profile == IngressProfile::Off ? "BIN\2" : "BIN\1", 4); image[4] = static_cast(profile); image[5] = image[4] ^ 0xff; const uint32_t crc = checksum(image, 6); @@ -38,7 +43,9 @@ void encode(uint8_t image[ImageSize], IngressProfile profile) { } bool decode(const uint8_t image[ImageSize], IngressProfile& profile) { - if (memcmp(image, "BIN\1", 4) != 0 || image[4] > 1 || + const bool v1 = memcmp(image, "BIN\1", 4) == 0; + const bool v2 = memcmp(image, "BIN\2", 4) == 0; + if ((!v1 && !v2) || (image[4] > 1 && !(v2 && image[4] == 3)) || image[5] != (image[4] ^ 0xff)) return false; const uint32_t saved_crc = uint32_t(image[6]) | (uint32_t(image[7]) << 8) | (uint32_t(image[8]) << 16) | (uint32_t(image[9]) << 24); @@ -81,13 +88,133 @@ bool saveSetting(IngressProfile profile) { selected = profile; return true; } + +void loadRoutes(FILESYSTEM* fs) { + for (auto& rule : routes) rule = IngressRule(); + routes_healthy = routes_writable = false; + if (!fs) return; +#if defined(ESP32_PLATFORM) || defined(RP2040_PLATFORM) + if (!ContactFileTransaction::recover(fs, RoutesPath, filePresence)) return; +#endif + bool present = false; + if (!filePresence(fs, RoutesPath, present)) return; + if (!present) { routes_healthy = routes_writable = true; return; } + uint8_t image[RoutesImageSize]; + const auto read = storage::readFixedSettingsFileResult(fs, RoutesPath, image, sizeof(image)); + if (read == storage::FixedSettingsRead::Unreadable) return; + routes_writable = true; + routes_healthy = read == storage::FixedSettingsRead::Complete && decodeIngressRules(image, routes); +} + +bool saveRoutes(const IngressRule* candidate) { + if (!settings_fs || !routes_writable || !validIngressRules(candidate)) return false; + uint8_t image[RoutesImageSize]; + encodeIngressRules(image, candidate); +#if defined(NRF52_PLATFORM) || defined(STM32_PLATFORM) + AtomicFileWriter file(settings_fs, RoutesPath); +#else + ContactFileTransaction file(settings_fs, RoutesPath, filePresence); +#endif + using Outcome = decltype(file.outcome()); + if (!file || file.write(image, sizeof(image)) != sizeof(image)) { + if (file.outcome() == Outcome::Indeterminate) routes_healthy = routes_writable = false; + return false; + } + file.commit(); + if (file.outcome() == Outcome::NotCommitted) return false; + uint8_t verified[RoutesImageSize]; + if (file.outcome() != Outcome::Committed || + !storage::readFixedSettingsFile(settings_fs, RoutesPath, verified, sizeof(verified)) || + memcmp(verified, image, sizeof(image)) != 0) { + routes_healthy = routes_writable = false; + return false; + } + memcpy(routes, candidate, sizeof(routes)); + routes_healthy = true; + return true; +} + +bool handleRouteCommand(const char* command, char* reply, size_t capacity) { + const bool get = !strncmp(command, "get bridge.route", 16); + const bool set = !strncmp(command, "set bridge.route", 16); + const bool del = !strncmp(command, "del bridge.route", 16); + const bool clear = !strcmp(command, "clear bridge.routes"); + if (!clear && ((!get && !set && !del) || + (command[16] && command[16] != '.' && command[16] != ' ' && command[16] != '\t'))) return false; + if (!routes_writable) { + snprintf(reply, capacity, "Error: bridge route storage unavailable; reboot first"); + return true; + } + IngressRule candidate[MaxIngressRoutes]; + memcpy(candidate, routes, sizeof(candidate)); + if (clear) { + for (auto& rule : candidate) rule = IngressRule(); + } else { + if (!routes_healthy) { + snprintf(reply, capacity, "Error: bridge routes invalid; use clear bridge.routes"); + return true; + } + if (get && !command[16]) { + unsigned count = 0; + for (const auto& rule : routes) if (rule.source.kind != IngressSource::Kind::None) ++count; + snprintf(reply, capacity, "> %u/8 routes; get bridge.route.1..8; default=%s", count, + ingressProfileName(selected)); + return true; + } + const char* cursor = command + 16; + if (*cursor != '.' || cursor[1] < '1' || cursor[1] > '8' || + (cursor[2] && cursor[2] != ' ' && cursor[2] != '\t')) { + snprintf(reply, capacity, "Error: use get/set/del bridge.route.1..8"); + return true; + } + const unsigned index = cursor[1] - '1'; + cursor += 2; + if (get && !*cursor) { formatIngressRule(index + 1, routes[index], reply, capacity); return true; } + if (del && !*cursor) candidate[index] = IngressRule(); + else if (set && (*cursor == ' ' || *cursor == '\t')) { + while (*cursor == ' ' || *cursor == '\t') ++cursor; + char source_text[40]; + size_t length = 0; + while (*cursor && *cursor != ' ' && *cursor != '\t' && length + 1 < sizeof(source_text)) + source_text[length++] = *cursor++; + source_text[length] = 0; + while (*cursor == ' ' || *cursor == '\t') ++cursor; + IngressRule rule; + if (!parseIngressSource(source_text, rule.source) || !parseIngressProfile(cursor, rule.profile)) { + snprintf(reply, capacity, "Error: use set bridge.route.N mac:AA:BB:CC:DD:EE:FF|udp:IPv4:port radio|radio2|off"); + return true; + } + candidate[index] = rule; + if (!validIngressRules(candidate)) { + snprintf(reply, capacity, "Error: duplicate bridge route source"); + return true; + } + } else { + snprintf(reply, capacity, "Error: use get/set/del bridge.route.1..8"); + return true; + } + } + if (!saveRoutes(candidate)) + snprintf(reply, capacity, "Error: bridge routes not saved%s", routes_writable ? "" : "; ingress blocked until reboot"); + else snprintf(reply, capacity, clear ? "OK - bridge routes cleared" : "OK - bridge route saved"); + return true; +} } // namespace -IngressProfile ingressProfile() { return selected; } +IngressProfile ingressProfile() { return routes_healthy ? selected : IngressProfile::Blocked; } + +IngressRoute ingressRoute(const IngressSource* source) { + if (!routes_healthy || selected == IngressProfile::Blocked) return {IngressProfile::Blocked, false}; + if (source && source->kind != IngressSource::Kind::None) + for (const auto& rule : routes) + if (sameSource(*source, rule.source)) return {rule.profile, true}; + return {selected, false}; +} void loadIngressSettings(FILESYSTEM* fs) { settings_fs = fs; quarantine(); + loadRoutes(fs); if (!fs) return; #if defined(ESP32_PLATFORM) || defined(RP2040_PLATFORM) if (!ContactFileTransaction::recover(fs, Path, filePresence)) return; @@ -110,6 +237,7 @@ void loadIngressSettings(FILESYSTEM* fs) { bool handleIngressCommand(const char* command, char* reply, size_t capacity) { if (!command || !reply || !capacity) return false; + if (handleRouteCommand(command, reply, capacity)) return true; const bool get = strncmp(command, "get bridge.ingress", 18) == 0 && (command[18] == 0 || command[18] == ' ' || command[18] == '\t'); const bool set = strncmp(command, "set bridge.ingress", 18) == 0 && @@ -118,19 +246,17 @@ bool handleIngressCommand(const char* command, char* reply, size_t capacity) { if (get) { if (command[18] != 0) snprintf(reply, capacity, "Error: use get bridge.ingress"); - else if (selected == IngressProfile::Blocked) + else if (ingressProfile() == IngressProfile::Blocked) snprintf(reply, capacity, "Error: bridge ingress blocked; settings invalid or unreadable"); else - snprintf(reply, capacity, "> %s", selected == IngressProfile::Radio2 ? "radio2" : "radio"); + snprintf(reply, capacity, "> %s", ingressProfileName(selected)); return true; } const char* value = command + 18; while (*value == ' ' || *value == '\t') ++value; IngressProfile profile; - if (strcmp(value, "radio") == 0) profile = IngressProfile::Radio; - else if (strcmp(value, "radio2") == 0) profile = IngressProfile::Radio2; - else { - snprintf(reply, capacity, "Error: use set bridge.ingress radio|radio2"); + if (!parseIngressProfile(value, profile)) { + snprintf(reply, capacity, "Error: use set bridge.ingress radio|radio2|off"); return true; } if (!writable) { diff --git a/src/helpers/BridgeSourceRoutes.h b/src/helpers/BridgeSourceRoutes.h new file mode 100644 index 000000000..2c5ee4a6a --- /dev/null +++ b/src/helpers/BridgeSourceRoutes.h @@ -0,0 +1,155 @@ +#pragma once + +#include "BridgeIngressProfile.h" +#include +#include +#include + +namespace mesh { namespace bridge { + +constexpr size_t MaxIngressRoutes = 8; +constexpr size_t RoutesImageSize = 4 + MaxIngressRoutes * 8 + 4; + +struct IngressRule { + IngressSource source; + IngressProfile profile = IngressProfile::Radio; +}; + +inline bool sameSource(const IngressSource& a, const IngressSource& b) { + return a.kind == b.kind && memcmp(a.bytes, b.bytes, sizeof(a.bytes)) == 0; +} + +inline bool validSource(const IngressSource& source) { + const auto* b = source.bytes; + if (source.kind == IngressSource::Kind::Mac) { + uint8_t any = 0; + for (uint8_t value : source.bytes) any |= value; + return any && !(b[0] & 1); // a sender must have an individual MAC address + } + return source.kind == IngressSource::Kind::Udp && b[0] && b[0] != 127 + && b[0] < 224 && (b[4] || b[5]); +} + +inline bool parseIngressProfile(const char* text, IngressProfile& profile) { + if (!text) return false; + if (!strcmp(text, "radio")) profile = IngressProfile::Radio; + else if (!strcmp(text, "radio2")) profile = IngressProfile::Radio2; + else if (!strcmp(text, "off")) profile = IngressProfile::Off; + else return false; + return true; +} + +inline const char* ingressProfileName(IngressProfile profile) { + return profile == IngressProfile::Radio ? "radio" + : profile == IngressProfile::Radio2 ? "radio2" + : profile == IngressProfile::Off ? "off" : "blocked"; +} + +inline int ingressHex(char c) { + if (c >= '0' && c <= '9') return c - '0'; + if (c >= 'a' && c <= 'f') return c - 'a' + 10; + if (c >= 'A' && c <= 'F') return c - 'A' + 10; + return -1; +} + +inline bool parseIngressSource(const char* text, IngressSource& source) { + if (!text) return false; + IngressSource candidate; + if (!strncmp(text, "mac:", 4)) { + if (strlen(text + 4) != 17) return false; + candidate.kind = IngressSource::Kind::Mac; + for (unsigned i = 0; i < 6; ++i) { + const int hi = ingressHex(text[4 + i * 3]), lo = ingressHex(text[5 + i * 3]); + if (hi < 0 || lo < 0 || (i < 5 && text[6 + i * 3] != ':')) return false; + candidate.bytes[i] = (hi << 4) | lo; + } + } else if (!strncmp(text, "udp:", 4)) { + candidate.kind = IngressSource::Kind::Udp; + const char* cursor = text + 4; + for (unsigned i = 0; i < 4; ++i) { + unsigned value = 0, digits = 0; + while (*cursor >= '0' && *cursor <= '9') { + value = value * 10 + static_cast(*cursor++ - '0'); + if (++digits > 3 || value > 255) return false; + } + if (!digits || *cursor++ != (i == 3 ? ':' : '.')) return false; + candidate.bytes[i] = value; + } + unsigned port = 0, digits = 0; + while (*cursor >= '0' && *cursor <= '9') { + port = port * 10 + static_cast(*cursor++ - '0'); + if (++digits > 5 || port > 65535) return false; + } + if (!digits || *cursor || !port) return false; + candidate.bytes[4] = port >> 8; + candidate.bytes[5] = port; + } else return false; + if (!validSource(candidate)) return false; + source = candidate; + return true; +} + +inline bool validIngressRules(const IngressRule* rules) { + for (unsigned i = 0; i < MaxIngressRoutes; ++i) { + if (rules[i].source.kind == IngressSource::Kind::None) { + if (rules[i].profile != IngressProfile::Radio) return false; + for (uint8_t value : rules[i].source.bytes) if (value) return false; + continue; + } + if (!validSource(rules[i].source) || + (rules[i].profile != IngressProfile::Radio && rules[i].profile != IngressProfile::Radio2 + && rules[i].profile != IngressProfile::Off)) return false; + for (unsigned j = 0; j < i; ++j) + if (sameSource(rules[i].source, rules[j].source)) return false; + } + return true; +} + +inline uint32_t ingressRoutesChecksum(const uint8_t* data, size_t size) { + uint32_t crc = 0xffffffffU; + while (size--) { + crc ^= *data++; + for (uint8_t bit = 0; bit < 8; ++bit) + crc = (crc >> 1) ^ ((crc & 1) ? 0xedb88320U : 0); + } + return ~crc; +} + +inline void encodeIngressRules(uint8_t image[RoutesImageSize], const IngressRule* rules) { + memcpy(image, "BRT\1", 4); + for (unsigned i = 0; i < MaxIngressRoutes; ++i) { + image[4 + i * 8] = static_cast(rules[i].source.kind); + memcpy(image + 5 + i * 8, rules[i].source.bytes, 6); + image[11 + i * 8] = static_cast(rules[i].profile); + } + const uint32_t crc = ingressRoutesChecksum(image, RoutesImageSize - 4); + for (unsigned i = 0; i < 4; ++i) image[RoutesImageSize - 4 + i] = crc >> (i * 8); +} + +inline bool decodeIngressRules(const uint8_t image[RoutesImageSize], IngressRule* rules) { + if (memcmp(image, "BRT\1", 4)) return false; + uint32_t crc = 0; + for (unsigned i = 0; i < 4; ++i) crc |= uint32_t(image[RoutesImageSize - 4 + i]) << (i * 8); + if (crc != ingressRoutesChecksum(image, RoutesImageSize - 4)) return false; + IngressRule candidate[MaxIngressRoutes]; + for (unsigned i = 0; i < MaxIngressRoutes; ++i) { + candidate[i].source.kind = static_cast(image[4 + i * 8]); + memcpy(candidate[i].source.bytes, image + 5 + i * 8, 6); + candidate[i].profile = static_cast(image[11 + i * 8]); + } + if (!validIngressRules(candidate)) return false; + memcpy(rules, candidate, sizeof(candidate)); + return true; +} + +inline void formatIngressRule(unsigned number, const IngressRule& rule, char* reply, size_t capacity) { + const auto* b = rule.source.bytes; + if (rule.source.kind == IngressSource::Kind::None) snprintf(reply, capacity, "> %u off", number); + else if (rule.source.kind == IngressSource::Kind::Mac) + snprintf(reply, capacity, "> %u mac:%02X:%02X:%02X:%02X:%02X:%02X %s", number, + b[0], b[1], b[2], b[3], b[4], b[5], ingressProfileName(rule.profile)); + else snprintf(reply, capacity, "> %u udp:%u.%u.%u.%u:%u %s", number, + b[0], b[1], b[2], b[3], unsigned((b[4] << 8) | b[5]), ingressProfileName(rule.profile)); +} + +}} // namespace mesh::bridge diff --git a/src/helpers/CommonCLI.cpp b/src/helpers/CommonCLI.cpp index 5f299c331..6832a9b0b 100644 --- a/src/helpers/CommonCLI.cpp +++ b/src/helpers/CommonCLI.cpp @@ -21,6 +21,7 @@ #include "HilStartupTrace.h" #include "FilePresence.h" #include "BridgeIngressSettings.h" +#include "RadioDirectionSettings.h" #if defined(ESP32_PLATFORM) #include "BootSlot.h" #include @@ -1018,6 +1019,9 @@ static void formatSnrDbX4Short(char* dest, size_t dest_len, int16_t snr_x4) { } void CommonCLI::loadPrefs(FILESYSTEM* fs) { +#if MESH_ENABLE_RADIO_DIRECTION + mesh::radioDirection::loadDirectionSettings(fs); +#endif #if MESH_ENABLE_BRIDGE_INGRESS mesh::bridge::loadIngressSettings(fs); #endif @@ -3567,6 +3571,9 @@ bool CommonCLI::handleUDPBridgeCommand(uint32_t sender_timestamp, void CommonCLI::handleCommand(uint32_t sender_timestamp, char* command, char* reply) { PrefsSaveReplyGuard save_reply(_prefs_save_failures, reply); mesh::cli::normalizeCommandVerb(command); +#if MESH_ENABLE_RADIO_DIRECTION + if (mesh::radioDirection::handleDirectionCommand(command, reply, 157)) return; +#endif #if MESH_ENABLE_BRIDGE_INGRESS if (mesh::bridge::handleIngressCommand(command, reply, 157)) return; #endif @@ -6230,7 +6237,10 @@ void CommonCLI::handleGetCmd(uint32_t sender_timestamp, char* command, char* rep } else if (configKeyEquals(config, "int.thresh")) { sprintf(reply, "> %d", (uint32_t) _prefs->interference_threshold); } else if (configKeyEquals(config, "cad")) { - sprintf(reply, "> %s. # channel busy: %u", _prefs->cad_enabled ? "on" : "off", _board->n_cad_busy); + const bool required = mesh::radioDirection::mode() == mesh::radioDirection::Mode::Tx; + snprintf(reply, 160, "> %s%s. # channel busy: %u", + (_prefs->cad_enabled || required) ? "on" : "off", + required ? " (required by radio.mode tx)" : "", _board->n_cad_busy); } else if (configKeyEquals(config, "agc.reset.interval")) { sprintf(reply, "> %d", ((uint32_t) _prefs->agc_reset_interval) * 4); } else if (configKeyEquals(config, "multi.acks")) { diff --git a/src/helpers/RadioDirectionPolicy.h b/src/helpers/RadioDirectionPolicy.h new file mode 100644 index 000000000..473d6af7d --- /dev/null +++ b/src/helpers/RadioDirectionPolicy.h @@ -0,0 +1,33 @@ +#pragma once + +#include + +#ifndef MESH_ENABLE_RADIO_DIRECTION +#if defined(ESP32_PLATFORM) +#define MESH_ENABLE_RADIO_DIRECTION 1 +#else +#define MESH_ENABLE_RADIO_DIRECTION 0 +#endif +#endif + +namespace mesh { namespace radioDirection { + +enum class Mode : uint8_t { Both = 0, Rx = 1, Tx = 2, Off = 3 }; + +#if MESH_ENABLE_RADIO_DIRECTION +Mode mode(); +#else +inline Mode mode() { return Mode::Both; } +#endif + +inline bool radioReceiveAllowed() { + const Mode value = mode(); + return value == Mode::Both || value == Mode::Rx; +} + +inline bool radioTransmitAllowed() { + const Mode value = mode(); + return value == Mode::Both || value == Mode::Tx; +} + +}} // namespace mesh::radioDirection diff --git a/src/helpers/RadioDirectionSettings.cpp b/src/helpers/RadioDirectionSettings.cpp new file mode 100644 index 000000000..97b4b1480 --- /dev/null +++ b/src/helpers/RadioDirectionSettings.cpp @@ -0,0 +1,154 @@ +#include "RadioDirectionSettings.h" + +#if MESH_ENABLE_RADIO_DIRECTION +#include "CheckedSettingsFile.h" +#include "ContactFileTransaction.h" +#include "FilePresence.h" +#include +#include + +namespace mesh { namespace radioDirection { +namespace { +constexpr char Path[] = "/radio_mode"; +constexpr size_t ImageSize = 10; +FILESYSTEM* settings_fs = nullptr; +// Companions do not load this infrastructure setting. Preserve their existing +// behavior until an infrastructure role explicitly initializes the record. +Mode selected = Mode::Both; +bool valid = true; +bool writable = false; + +uint32_t checksum(const uint8_t* data, size_t size) { + uint32_t crc = 0xffffffffU; + while (size--) { + crc ^= *data++; + for (uint8_t bit = 0; bit < 8; ++bit) + crc = (crc >> 1) ^ ((crc & 1) ? 0xedb88320U : 0); + } + return ~crc; +} + +void encode(uint8_t image[ImageSize], Mode value) { + memcpy(image, "RDM\1", 4); + image[4] = static_cast(value); + image[5] = image[4] ^ 0xff; + const uint32_t crc = checksum(image, 6); + for (uint8_t i = 0; i < 4; ++i) image[6 + i] = crc >> (i * 8); +} + +bool decode(const uint8_t image[ImageSize], Mode& value) { + if (memcmp(image, "RDM\1", 4) != 0 || image[4] > 3 || + image[5] != (image[4] ^ 0xff)) return false; + const uint32_t saved_crc = uint32_t(image[6]) | (uint32_t(image[7]) << 8) | + (uint32_t(image[8]) << 16) | (uint32_t(image[9]) << 24); + if (saved_crc != checksum(image, 6)) return false; + value = static_cast(image[4]); + return true; +} + +void quarantine() { + selected = Mode::Off; + valid = false; + writable = false; +} + +bool saveSetting(Mode value) { + if (!settings_fs || !writable) return false; + uint8_t image[ImageSize]; + encode(image, value); + ContactFileTransaction file(settings_fs, Path, filePresence); + using Outcome = ContactFileTransaction::Outcome; + if (!file || file.write(image, sizeof(image)) != sizeof(image)) { + if (file.outcome() == Outcome::Indeterminate) quarantine(); + return false; + } + file.commit(); + if (file.outcome() == Outcome::NotCommitted) return false; + if (file.outcome() != Outcome::Committed) { + quarantine(); + return false; + } + uint8_t verified[ImageSize]; + if (!storage::readFixedSettingsFile(settings_fs, Path, verified, sizeof(verified)) || + memcmp(verified, image, sizeof(image)) != 0) { + quarantine(); + return false; + } + selected = value; + valid = true; + return true; +} + +const char* modeName(Mode value) { + switch (value) { + case Mode::Both: return "both"; + case Mode::Rx: return "rx"; + case Mode::Tx: return "tx"; + case Mode::Off: return "off"; + } + return "off"; +} +} // namespace + +Mode mode() { return selected; } + +void loadDirectionSettings(FILESYSTEM* fs) { + settings_fs = fs; + quarantine(); + if (!fs || !ContactFileTransaction::recover(fs, Path, filePresence)) return; + bool present = false; + if (!filePresence(fs, Path, present)) return; + if (!present) { + selected = Mode::Both; + valid = writable = true; + return; + } + uint8_t image[ImageSize]; + const auto read = storage::readFixedSettingsFileResult(fs, Path, image, sizeof(image)); + if (read == storage::FixedSettingsRead::Unreadable) return; + // A known malformed record permits explicit repair, while uncertainty must + // be reclassified before another write can replace the saved authority. + writable = true; + if (read == storage::FixedSettingsRead::Complete) valid = decode(image, selected); +} + +bool handleDirectionCommand(const char* command, char* reply, size_t capacity) { + if (!command || !reply || !capacity) return false; + const bool get = strncmp(command, "get radio.mode", 14) == 0 && + (command[14] == 0 || command[14] == ' ' || command[14] == '\t'); + const bool set = strncmp(command, "set radio.mode", 14) == 0 && + (command[14] == 0 || command[14] == ' ' || command[14] == '\t'); + if (!get && !set) return false; + if (get) { + if (command[14] != 0) + snprintf(reply, capacity, "Error: use get radio.mode"); + else if (!valid) + snprintf(reply, capacity, "Error: radio off; mode settings invalid or unreadable"); + else + snprintf(reply, capacity, "> %s", modeName(selected)); + return true; + } + const char* value = command + 14; + while (*value == ' ' || *value == '\t') ++value; + Mode candidate; + if (strcmp(value, "both") == 0) candidate = Mode::Both; + else if (strcmp(value, "rx") == 0) candidate = Mode::Rx; + else if (strcmp(value, "tx") == 0) candidate = Mode::Tx; + else if (strcmp(value, "off") == 0) candidate = Mode::Off; + else { + snprintf(reply, capacity, "Error: use set radio.mode both|rx|tx|off"); + return true; + } + if (!writable) { + snprintf(reply, capacity, "Error: radio mode storage unavailable; reboot first"); + } else if (!saveSetting(candidate)) { + snprintf(reply, capacity, "Error: radio mode not saved%s", + writable ? "" : "; storage uncertain, radio off until reload"); + } else { + snprintf(reply, capacity, "OK - radio.mode %s", value); + } + return true; +} + +}} // namespace mesh::radioDirection +#endif diff --git a/src/helpers/RadioDirectionSettings.h b/src/helpers/RadioDirectionSettings.h new file mode 100644 index 000000000..c3de6f8bb --- /dev/null +++ b/src/helpers/RadioDirectionSettings.h @@ -0,0 +1,16 @@ +#pragma once + +#include "RadioDirectionPolicy.h" +#include + +#if MESH_ENABLE_RADIO_DIRECTION +#include "IdentityStore.h" + +namespace mesh { namespace radioDirection { + +// Separate from common preferences; older installations retain both directions. +void loadDirectionSettings(FILESYSTEM* fs); +bool handleDirectionCommand(const char* command, char* reply, size_t capacity); + +}} // namespace mesh::radioDirection +#endif diff --git a/src/helpers/bridges/ESPNowBridge.cpp b/src/helpers/bridges/ESPNowBridge.cpp index 3a51c6838..d7b866c00 100644 --- a/src/helpers/bridges/ESPNowBridge.cpp +++ b/src/helpers/bridges/ESPNowBridge.cpp @@ -628,6 +628,8 @@ void ESPNowBridge::queueReceivedFrame(const uint8_t *mac, const uint8_t *data, bool ESPNowBridge::processReceivedFrame(mesh::Packet* packet_slot, const uint8_t *mac, const uint8_t *data, size_t len) { + const auto ingress_source = mesh::bridge::IngressSource::mac(mac); + IngressSourceScope ingress_scope(*this, ingress_source); if (data == nullptr || len == 0 || len > MAX_ESPNOW_PACKET_SIZE) { noteRx(&mesh::bridge::diag::RxStats::badframe); return false; @@ -920,6 +922,12 @@ void ESPNowBridge::formatStats(const char* page, char* reply, size_t reply_size) snprintf(reply, reply_size, "> rx=%lu mesh=%lu tx=%lu sent=%lu; pages: rx tx drops test", (unsigned long)s.rx.frames, (unsigned long)s.rx.delivered, (unsigned long)s.tx.queued, (unsigned long)s.tx.callback_ok); + } else if (strcmp(page, "mac") == 0) { + uint8_t mac[6]; + if (esp_wifi_get_mac(WIFI_IF_STA, mac) != ESP_OK) + snprintf(reply, reply_size, "Error: ESP-NOW station MAC unavailable; start the bridge first"); + else snprintf(reply, reply_size, "> mac:%02X:%02X:%02X:%02X:%02X:%02X", + mac[0], mac[1], mac[2], mac[3], mac[4], mac[5]); } else if (strcmp(page, "rx") == 0) { snprintf(reply, reply_size, "> frames=%lu decoded=%lu mesh=%lu crc=%lu bad=%lu parse=%lu", (unsigned long)s.rx.frames, (unsigned long)s.rx.packet_ok, diff --git a/src/helpers/bridges/UDPBridge.cpp b/src/helpers/bridges/UDPBridge.cpp index 4c46f0088..08499c9cd 100644 --- a/src/helpers/bridges/UDPBridge.cpp +++ b/src/helpers/bridges/UDPBridge.cpp @@ -201,6 +201,7 @@ void UDPBridge::receiveFrame(uint8_t peer, const uint8_t* data, size_t length) { // Copy verified bytes into one bounded holding slot before allocating from // the shared mesh pool. A full slot stops socket reads, preserving its owner. memcpy(_rx_payload, frame.payload, frame.length); _rx_length = frame.length; + _rx_peer = peer; } void UDPBridge::pumpReceive() { for (uint8_t count = 0; count < 2 && !_rx_length && _initialized; ++count) { @@ -228,6 +229,9 @@ void UDPBridge::drainReceive() { const bool parsed = packet->readFrom(_rx_payload, static_cast(_rx_length)); _rx_length = 0; if (!parsed) { _mgr->free(packet); note(_stats.parse); return; } + const auto ingress_source = mesh::bridge::IngressSource::udp( + _active.peers[_rx_peer].ip, _active.peers[_rx_peer].port); + IngressSourceScope ingress_scope(*this, ingress_source); packet->udp_bridge_ingress = true; onPacketReceived(packet); } diff --git a/src/helpers/bridges/UDPBridge.h b/src/helpers/bridges/UDPBridge.h index f11a7850d..22184d64f 100644 --- a/src/helpers/bridges/UDPBridge.h +++ b/src/helpers/bridges/UDPBridge.h @@ -44,6 +44,7 @@ private: // stops until it drains. No packet allocation for probes or invalid frames. uint8_t _rx_payload[mesh::bridge::udp::MAX_PAYLOAD]{}; uint16_t _rx_length = 0; + uint8_t _rx_peer = 0; struct Stats { uint32_t frames = 0, authenticated = 0, delivered = 0; uint32_t malformed = 0, authentication = 0, foreign = 0, replay = 0; diff --git a/src/helpers/radiolib/RadioLibWrappers.cpp b/src/helpers/radiolib/RadioLibWrappers.cpp index bbc20ef2b..093aaf546 100644 --- a/src/helpers/radiolib/RadioLibWrappers.cpp +++ b/src/helpers/radiolib/RadioLibWrappers.cpp @@ -237,6 +237,9 @@ bool RadioLibWrapper::claimRadioInterrupt() { } void RadioLibWrapper::begin() { +#if MESH_ENABLE_RADIO_DIRECTION + _direction_receive_disabled = false; +#endif #if MESH_RADIO_TRACE_ENABLED mesh::radiotrace::trace().context(_radio, _active_profile, _profile_generation); #endif @@ -365,6 +368,10 @@ mesh::RadioParamApplyResult RadioLibWrapper::trySetTxPower(int8_t dbm) { ) && _cw_active) return Result::BUSY; #if MESH_ENABLE_CARRIER_TOOLS if (_cw_active) { + if (!mesh::radioDirection::radioTransmitAllowed()) { + stopCarrierWave(); + return Result::FAILED; + } if (_cw_stopping) return Result::BUSY; #if defined(MESH_HIL_TX_FREQ_MHZ) if (!hilPacketTransmitAllowed() || _cw_profile != _active_profile @@ -467,7 +474,13 @@ uint8_t RadioLibWrapper::beginReconfigure() { _rx_ps_armed = false; claimRadioInterrupt(); state = STATE_IDLE; - return 1; // A repair must verify RX re-entry even if software was idle. + // A receive-capable repair verifies re-entry; TX-only repairs stop after + // restoring the modem, without opening an unsolicited receive window. +#if MESH_ENABLE_RADIO_DIRECTION + return mesh::radioDirection::radioReceiveAllowed() ? 1 : 0; +#else + return 1; +#endif } // On SX126x/LR11xx duty-cycle RX, BUSY may remain asserted during the sleep // side of the cycle. Do not issue an IRQ/preamble query over SPI then; due @@ -493,8 +506,13 @@ uint8_t RadioLibWrapper::beginReconfigure() { #if MESH_HIL_PROFILE_TIMING profile_timing.admissionComplete(); #endif - const bool resume_rx = base_state == STATE_RX; - if (_rx_ps_armed || resume_rx) { + const bool was_rx = base_state == STATE_RX; + const bool resume_rx = was_rx +#if MESH_ENABLE_RADIO_DIRECTION + && mesh::radioDirection::radioReceiveAllowed() +#endif + ; + if (_rx_ps_armed || was_rx) { const int16_t standby_status = #if MESH_HIL_PROFILE_TIMING profile_timing.standbyResult(_radio->standby()); @@ -597,6 +615,9 @@ bool RadioLibWrapper::configureBootSwitchTuning(const mesh::RadioSwitchTuning& v #endif bool RadioLibWrapper::restoreAfterDeepInit() { +#if MESH_ENABLE_RADIO_DIRECTION + _direction_receive_disabled = false; +#endif #if MESH_ENABLE_TX_BEACONS if (_beacon_owned) cacheBeaconRestoreParams(); #endif @@ -1030,6 +1051,9 @@ void RadioLibWrapper::cacheBeaconRestoreParams() { mesh::RadioParamApplyResult RadioLibWrapper::prepareBeaconTransmit( const mesh::RadioProfileParams& p, int8_t power) { using Result = mesh::RadioParamApplyResult; +#if MESH_ENABLE_RADIO_DIRECTION + if (!mesh::radioDirection::radioTransmitAllowed()) return Result::FAILED; +#endif if (_beacon_owned || _cw_active || _profile_repair_required) return Result::BUSY; #if defined(MESH_HIL_TX_FREQ_MHZ) _hil_beacon_tx_frequency_mhz = 0; @@ -1118,11 +1142,19 @@ mesh::RadioParamApplyResult RadioLibWrapper::restoreBeaconReceive() { _profile_repair_required = false; // Full normal tuple/protocol/power/IRQ restore succeeded. state = STATE_IDLE; startRecv(); - restored = isInRecvMode(); + restored = isInRecvMode() +#if MESH_ENABLE_RADIO_DIRECTION + || !mesh::radioDirection::radioReceiveAllowed() +#endif + ; if (!restored && restoreAfterDeepInit()) { state = STATE_IDLE; startRecv(); - restored = isInRecvMode(); + restored = isInRecvMode() +#if MESH_ENABLE_RADIO_DIRECTION + || !mesh::radioDirection::radioReceiveAllowed() +#endif + ; } } _beacon_restoring = false; @@ -1143,6 +1175,9 @@ mesh::RadioParamApplyResult RadioLibWrapper::prepareTransmitProfile(uint8_t prof } mesh::RadioParamApplyResult RadioLibWrapper::prepareTransmitProfile(uint8_t profile, bool reply_rx_override) { +#if MESH_ENABLE_RADIO_DIRECTION + if (!mesh::radioDirection::radioTransmitAllowed()) return mesh::RadioParamApplyResult::FAILED; +#endif #if defined(MESH_HIL_TX_FREQ_MHZ) if (profile > 1 || !mesh::hilTransmitFrequencyAllowed(_profiles.params(profile).freq)) return mesh::RadioParamApplyResult::FAILED; @@ -1168,6 +1203,9 @@ mesh::RadioParamApplyResult RadioLibWrapper::tryRestoreCodingRate(uint8_t cr) { } void RadioLibWrapper::serviceProfileScan() { +#if MESH_ENABLE_RADIO_DIRECTION + if (!mesh::radioDirection::radioReceiveAllowed()) return; +#endif #if MESH_RADIO_TRACE_ENABLED if (mesh::radiotrace::trace().pending()) { mesh::radiotrace::trace().context(_radio, _active_profile, _profile_generation); @@ -1549,6 +1587,9 @@ bool RadioLibWrapper::setTrackerSleep(bool sleeping) { void RadioLibWrapper::triggerNoiseFloorCalibrate(int threshold) { _threshold = threshold; +#if MESH_ENABLE_RADIO_DIRECTION + if (!mesh::radioDirection::radioReceiveAllowed()) return; +#endif // The Dispatcher calls this every two seconds. Repeatedly recalibrating a // dual-profile scanner would repeatedly lengthen its visits. Once both // baselines are valid, retain them until a real RF-path change—except for a @@ -1894,6 +1935,9 @@ uint32_t RadioLibWrapper::carrierWaveRemainingMillis() const { mesh::RadioParamApplyResult RadioLibWrapper::setCarrierWave(uint8_t profile, uint32_t duration_ms) { using Result = mesh::RadioParamApplyResult; +#if MESH_ENABLE_RADIO_DIRECTION + if (duration_ms && !mesh::radioDirection::radioTransmitAllowed()) return Result::FAILED; +#endif #if MESH_ENABLE_CARRIER_TOOLS // The shared emergency stop also covers chips exposing Morse without the // older profile-CW CLI capability. @@ -1974,6 +2018,9 @@ bool RadioLibWrapper::supportsMorseTx() const { mesh::MorseTxResult RadioLibWrapper::startMorseTx(float frequency_mhz, uint8_t wpm, const char* text) { using Result = mesh::MorseTxResult; +#if MESH_ENABLE_RADIO_DIRECTION + if (!mesh::radioDirection::radioTransmitAllowed()) return Result::Failed; +#endif if (!supportsMorseTx()) return Result::Unsupported; if (_cw_active) return Result::Busy; const Result valid_text = mesh::MorseTx::validate(text, wpm); @@ -2126,6 +2173,9 @@ bool RadioLibWrapper::canAllocateMorseReceiver() const { mesh::MorseRxResult RadioLibWrapper::startMorseRx(float frequency_mhz, uint8_t wpm, uint32_t duration_ms) { using Result = mesh::MorseRxResult; +#if MESH_ENABLE_RADIO_DIRECTION + if (!mesh::radioDirection::radioReceiveAllowed()) return Result::Failed; +#endif if (!supportsMorseRx()) return Result::Unsupported; if (_cw_active) return Result::Busy; const Result valid = mesh::MorseRx::validate(wpm, duration_ms); @@ -2316,8 +2366,16 @@ bool RadioLibWrapper::stopCarrierWave() { const bool tuned = tuneProfile(restore) == mesh::RadioParamApplyResult::APPLIED; recalibrateNoiseFloor(); startRecv(); - if (!isInRecvMode() && restoreAfterDeepInit()) startRecv(); - if (!tuned || !isInRecvMode()) { + bool receive_ready = isInRecvMode() +#if MESH_ENABLE_RADIO_DIRECTION + || !mesh::radioDirection::radioReceiveAllowed() +#endif + ; + if (!receive_ready && restoreAfterDeepInit()) { + startRecv(); + receive_ready = isInRecvMode(); + } + if (!tuned || !receive_ready) { // A stopped carrier is not sufficient to hand ownership back if the // configured packet modem/profile could not be restored. Keep the guard // and watchdog wake veto until the bounded retry repairs reception. @@ -2362,7 +2420,58 @@ bool RadioLibWrapper::serviceCarrierWave() { } #endif +void RadioLibWrapper::serviceDirectionPolicy() { +#if MESH_ENABLE_RADIO_DIRECTION + const bool receive = mesh::radioDirection::radioReceiveAllowed(); +#if MESH_ENABLE_CARRIER_TOOLS || MESH_ENABLE_MORSE_RX + if (_cw_active) { + bool receiver = false; +#if MESH_ENABLE_MORSE_RX + receiver = _morse_rx_owned; +#endif + if ((receiver && !receive) + || (!receiver && !mesh::radioDirection::radioTransmitAllowed())) stopCarrierWave(); + if (_cw_active) return; + } +#endif + if (receive) { + _direction_receive_disabled = false; + return; + } + if (state == STATE_TX_WAIT) return; +#if MESH_ENABLE_TX_BEACONS + if (_beacon_owned) return; // Its owner restores the saved tuple first. +#endif + // A direction change deliberately retires any unread receive. No RX retry, + // scanner or calibration may reopen it. Ordinary TX/CAD stays available. + if (!_direction_receive_disabled || state != STATE_IDLE || _rx_ps_armed + || hasPendingRadioInterrupt()) { + if (_rx_ps_armed) stopReceiveDutyCycle(); + if (_radio->standby() != RADIOLIB_ERR_NONE + || _radio->clearIrqFlags(UINT32_MAX) != RADIOLIB_ERR_NONE) return; + claimRadioInterrupt(); + _irq_probe_failures = 0; + state = STATE_IDLE; + _direction_receive_disabled = true; + } + _rx_ps_armed = _rx_hold_continuous = false; + _rx_retry_pending = false; + _rx_retry_delay_ms = 0; + _nf_calib_active = _nf_refresh_requested = _nf_window_armed = false; + _wd_observe_until = 0; +#endif +} + void RadioLibWrapper::loop() { +#if MESH_ENABLE_RADIO_DIRECTION + serviceDirectionPolicy(); + if (!mesh::radioDirection::radioReceiveAllowed()) { +#if MESH_ENABLE_CARRIER_TOOLS || MESH_ENABLE_MORSE_RX + serviceCarrierWave(); +#endif + return; + } +#endif #if MESH_RADIO_TRACE_ENABLED if (mesh::radiotrace::trace().pending()) { mesh::radiotrace::trace().context(_radio, _active_profile, _profile_generation); @@ -2539,6 +2648,9 @@ void RadioLibWrapper::loop() { } void RadioLibWrapper::startRecv() { +#if MESH_ENABLE_RADIO_DIRECTION + if (!mesh::radioDirection::radioReceiveAllowed()) return; +#endif #if MESH_ENABLE_TX_BEACONS if (_beacon_owned && !_beacon_restoring) return; #endif @@ -2678,6 +2790,9 @@ bool RadioLibWrapper::isInRecvMode() const { } bool RadioLibWrapper::getReceiveRetryDelay(uint32_t now, uint32_t& delay_ms) const { +#if MESH_ENABLE_RADIO_DIRECTION + if (!mesh::radioDirection::radioReceiveAllowed()) return false; +#endif #if MESH_ENABLE_TX_BEACONS if (_beacon_owned) return false; #endif @@ -2695,6 +2810,9 @@ bool RadioLibWrapper::getReceiveRetryDelay(uint32_t now, uint32_t& delay_ms) con } bool RadioLibWrapper::getNoiseFloorWakeDelay(uint32_t now, uint32_t& delay_ms) const { +#if MESH_ENABLE_RADIO_DIRECTION + if (!mesh::radioDirection::radioReceiveAllowed()) return false; +#endif #if MESH_ENABLE_TX_BEACONS if (_beacon_owned) return false; #endif @@ -2773,6 +2891,9 @@ RxPowerSavingStatus RadioLibWrapper::getRxPowerSavingStatus() const { } int RadioLibWrapper::recvRaw(uint8_t* bytes, int sz) { +#if MESH_ENABLE_RADIO_DIRECTION + if (!mesh::radioDirection::radioReceiveAllowed()) return 0; +#endif #if MESH_RADIO_TRACE_ENABLED auto& radio_trace = mesh::radiotrace::trace(); uint16_t trace_rx = mesh::radiotrace::NoIndex; @@ -2967,6 +3088,9 @@ bool RadioLibWrapper::hilPacketTransmitAllowed() const { #endif bool RadioLibWrapper::startSendRaw(const uint8_t* bytes, int len) { +#if MESH_ENABLE_RADIO_DIRECTION + if (!mesh::radioDirection::radioTransmitAllowed()) return false; +#endif #if defined(MESH_HIL_TX_FREQ_MHZ) if (!hilPacketTransmitAllowed()) { MESH_DEBUG_PRINTLN("HIL: packet TX blocked outside the proven allowed frequency"); @@ -3079,6 +3203,22 @@ int16_t RadioLibWrapper::performChannelScanWithTimeout(unsigned long timeout_ms) } bool RadioLibWrapper::isChannelActive() { +#if MESH_ENABLE_RADIO_DIRECTION + if (!mesh::radioDirection::radioReceiveAllowed()) { + if (_cw_active || state == STATE_TX_WAIT || isChipBusy()) return true; + if (!_cad_enabled) return false; + const int16_t result = performChannelScan(); + const bool stopped = _radio->standby() == RADIOLIB_ERR_NONE; + const bool cleared = _radio->clearIrqFlags(UINT32_MAX) == RADIOLIB_ERR_NONE; + claimRadioInterrupt(); + state = STATE_IDLE; + if (result != RADIOLIB_CHANNEL_FREE || !stopped || !cleared) { + _board->n_cad_busy++; + return true; + } + return false; + } +#endif if (_cw_active) return true; if (isPacketPendingOrReceiving() || state == STATE_TX_WAIT) return true; // int.thresh: RSSI-based interference detection (relative to noise floor). @@ -3111,6 +3251,11 @@ bool RadioLibWrapper::isChannelActive() { } bool RadioLibWrapper::isReceivingPassive(int interference_margin_db) { +#if MESH_ENABLE_RADIO_DIRECTION + // A TX-only node has no continuous-RX noise baseline or forwarding echoes. + // Use bounded CAD instead of waiting forever for either of those RX states. + if (!mesh::radioDirection::radioReceiveAllowed()) return isChannelActive(); +#endif if (_cw_active) return true; // RX duty-cycle radios hold BUSY high while asleep. Do not attempt an SPI // IRQ/RSSI read until the next listen window. Treat the unknown channel as diff --git a/src/helpers/radiolib/RadioLibWrappers.h b/src/helpers/radiolib/RadioLibWrappers.h index 3ea00756d..c8cda76c3 100644 --- a/src/helpers/radiolib/RadioLibWrappers.h +++ b/src/helpers/radiolib/RadioLibWrappers.h @@ -5,6 +5,7 @@ #include "NoiseFloorEstimator.h" #include "../TelemetryHistoryConfig.h" #include "../CarrierTools.h" +#include "../RadioDirectionPolicy.h" #include #include @@ -47,6 +48,9 @@ protected: uint8_t state = 0; uint8_t _interrupt_slot = 0xff; bool _loop_event_pending = false; +#if MESH_ENABLE_RADIO_DIRECTION + bool _direction_receive_disabled = false; +#endif uint8_t _irq_probe_failures = 0; bool registerInterruptAction(); void unregisterInterruptAction(); @@ -311,6 +315,7 @@ protected: void idle() override; void startRecv() override; + void serviceDirectionPolicy(); void rxPsWatchdogCheck(); void requestPeriodicNoiseFloorRefresh(); void requestNoiseFloorRefresh(); diff --git a/test/fixtures/cli_reply_text.json b/test/fixtures/cli_reply_text.json index 51b4db218..9601a51b6 100644 --- a/test/fixtures/cli_reply_text.json +++ b/test/fixtures/cli_reply_text.json @@ -941,7 +941,7 @@ } ], "noncompact_tokens_sha256": { - "src/helpers/CommonCLI.cpp": "cd2bcd278e1b01e0e7195306aa67d0a00070183543c8d61a9724958058ead6b7", + "src/helpers/CommonCLI.cpp": "17b89894dbcb4998f430d8bd6f3478b903f30bcfa633a7750c7771534a546548", "examples/simple_repeater/MyMesh.cpp": "8567eeef2f327100e397c40b0aaf5b675fcdfea254987437d1ca8d005ad3a6f0", "src/helpers/RadioProfileCLI.cpp": "d641d16e872eba3e73b9b5a50f9718c10b0b7da72782f793706ac36b3724a624", "src/helpers/WirelessControl.h": "f19bb602999118cc423ec74c7de7100ed91cb2c553295038077e52e321bb4e73", diff --git a/test/fixtures/espnow_bridge_diagnostic_runtime.cpp b/test/fixtures/espnow_bridge_diagnostic_runtime.cpp index 4a213d3bf..6d3b5c972 100644 --- a/test/fixtures/espnow_bridge_diagnostic_runtime.cpp +++ b/test/fixtures/espnow_bridge_diagnostic_runtime.cpp @@ -274,13 +274,14 @@ static void boundedReplies() { memset(&bridge._stats.tx, 0xFF, sizeof(bridge._stats.tx)); memset(&bridge._stats.probe, 0xFF, sizeof(bridge._stats.probe)); bridge._stats.error = {diag::Stage::ChannelConflict, INT32_MIN, UINT32_MAX}; - for (const char* page : {"rx", "tx", "drops", "test"}) { + for (const char* page : {"rx", "tx", "drops", "test", "mac"}) { char reply[160]; memset(reply, 0xA5, sizeof(reply)); bridge.formatStats(page, reply, 157); assert(memchr(reply, 0, 157) != nullptr); assert(static_cast(reply[157]) == 0xA5); assert(strlen(reply) < 157); + if (!strcmp(page, "mac")) assert(!strcmp(reply, "> mac:02:10:20:30:40:50")); printf("%s: %s\n", page, reply); } char status[160]; diff --git a/test/fixtures/udp_bridge/helpers/bridges/BridgeBase.h b/test/fixtures/udp_bridge/helpers/bridges/BridgeBase.h index f5f3df0e2..d2099bafa 100644 --- a/test/fixtures/udp_bridge/helpers/bridges/BridgeBase.h +++ b/test/fixtures/udp_bridge/helpers/bridges/BridgeBase.h @@ -2,6 +2,7 @@ #include #include #include +#include #if defined(ARDUINO) #include #include @@ -63,6 +64,7 @@ struct PacketManager { bool starved = false; unsigned allocations = 0, frees = 0; std::vector> received; + std::vector received_sources; Packet* allocNew() { ++allocations; return starved ? nullptr : new Packet; @@ -93,6 +95,14 @@ public: bool isRunning() const { return _initialized; } size_t seenCount() const { return _seen_packets.seen.size(); } protected: + const mesh::bridge::IngressSource* ingress_source = nullptr; + struct IngressSourceScope { + BridgeBase& owner; + const mesh::bridge::IngressSource* previous; + IngressSourceScope(BridgeBase& bridge, const mesh::bridge::IngressSource& source) + : owner(bridge), previous(bridge.ingress_source) { owner.ingress_source = &source; } + ~IngressSourceScope() { owner.ingress_source = previous; } + }; enum class IngressResult : uint8_t { Stopped, Filtered, Duplicate, Queued }; virtual void onIngressResult(IngressResult) {} bool _initialized = false; @@ -120,6 +130,7 @@ protected: } if (packet->getPayloadType() != PAYLOAD_TYPE_OTA) _seen_packets.markSeen(packet); onIngressResult(IngressResult::Queued); + _mgr->received_sources.push_back(ingress_source ? *ingress_source : mesh::bridge::IngressSource()); _mgr->received.push_back(packet->payload); _mgr->free(packet); } }; diff --git a/test/fixtures/udp_bridge_runtime.cpp b/test/fixtures/udp_bridge_runtime.cpp index 54dff0866..b6c00cd36 100644 --- a/test/fixtures/udp_bridge_runtime.cpp +++ b/test/fixtures/udp_bridge_runtime.cpp @@ -133,6 +133,12 @@ static void queues() { mesh::Packet packet; packet.payload = {1,0,3,4}; a.bridge.sendPacket(&packet); a.bridge.sendPacket(&packet); run(a,b,5); assert(b.manager.received.size() == 1); + assert(b.manager.received_sources.size() == 1); + const auto first_source = b.manager.received_sources.front(); + assert(first_source.kind == mesh::bridge::IngressSource::Kind::Udp); + assert(first_source.bytes[0] == 10 && first_source.bytes[1] == 11 && + first_source.bytes[2] == 12 && first_source.bytes[3] == 1); + assert(first_source.bytes[4] == (4404 >> 8) && first_source.bytes[5] == (4404 & 255)); assert(a.stats("tx").find("queued=1") != std::string::npos); // Same payload received through LoRa is deduplicated; received UDP packets // are marked locally and cannot re-export even for OTA. @@ -145,6 +151,8 @@ static void queues() { const int reads = udp_test::receives; b.bridge.loop(); assert(udp_test::receives == reads); // Held authenticated bytes own the RX slot. b.manager.starved = false; b.bridge.loop(); assert(b.manager.received.size() == 2); + assert(b.manager.received_sources.size() == 2); + assert(!memcmp(b.manager.received_sources.back().bytes, first_source.bytes, 6)); // Failed UDP send is final, never automatically retried. packet.payload[3]++; a.bridge.sendPacket(&packet); udp_test::fail_send = true; a.bridge.loop(); const int sends = udp_test::sends; run(a,b,2); diff --git a/test/test_bridge_ingress.py b/test/test_bridge_ingress.py index 0d72b29b2..05913a09a 100644 --- a/test/test_bridge_ingress.py +++ b/test/test_bridge_ingress.py @@ -152,8 +152,10 @@ int main() { bridge.setIngressProfiles(nullptr); mesh::Packet missing;bridge.handleReceivedPacket(&missing);assert(bridge.manager.freed==++drops); bridge.setIngressProfiles(&profiles); - selection=IngressProfile::Blocked; - mesh::Packet blocked;bridge.handleReceivedPacket(&blocked);assert(bridge.manager.freed==++drops); + for (auto target : {IngressProfile::Blocked, IngressProfile::Off}) { + selection=target; + mesh::Packet blocked;bridge.handleReceivedPacket(&blocked);assert(bridge.manager.freed==++drops); + } assert(bridge.manager.queued==queued&&bridge._seen_packets.checks==checks&&bridge._seen_packets.marks==marks); assert(filter_calls==filters&&dispatch.outbound.size()==sent); @@ -165,6 +167,24 @@ int main() { assert(dispatch.getTransmitProfileMask(&normal)==1); assert(dispatch.queueOutboundPacket(&normal,0,0)); assert(dispatch.outbound.back().radio_profile==0); + + // Explicit source mappings can also pin USA to primary while crossing is + // enabled. The source identity is visible only during this admission. + bridge.setPacketFilter(nullptr,nullptr); + selection=IngressProfile::Radio; + mesh::bridge::requested_explicit=true; + profiles.cross=RadioCrossMode::On; + const uint8_t address[]={2,16,32,48,64,81}; + const auto source=mesh::bridge::IngressSource::mac(address); + mesh::Packet usa;bridge.receiveFrom(&usa,source); + assert(mesh::bridge::observed_source.kind==mesh::bridge::IngressSource::Kind::Mac); + assert(memcmp(mesh::bridge::observed_source.bytes,address,6)==0); + assert(usa.radio_profile==0&&usa.radio_generation==profiles.generation[0]&&usa.tx_radio==mesh::RADIO_TX_PRIMARY); + assert(dispatch.getTransmitProfileMask(&usa)==1); + mesh::bridge::requested_explicit=false; + mesh::Packet next;bridge.handleReceivedPacket(&next); + assert(mesh::bridge::observed_source.kind==mesh::bridge::IngressSource::Kind::None); + assert(next.tx_radio==mesh::RADIO_TX_AUTO&&dispatch.getTransmitProfileMask(&next)==3); } ''' code = code.replace("@DISPATCH_GUARD@", guard) @@ -173,13 +193,16 @@ int main() { ("@CURRENT@", "bool Dispatcher::isPacketRadioCurrent(")): code = code.replace(marker, method(dispatcher, signature)) packet = PACKET.replace(" uint8_t payload[64]", " uint8_t path_len=0;\n static bool isValidPathLen(uint8_t n) {return n<64;}\n uint8_t payload[64]") - selector = r''' -#pragma once -#include + selector = (ROOT / "src/helpers/BridgeIngressProfile.h").read_text() + r''' namespace mesh { namespace bridge { -enum class IngressProfile : uint8_t {Radio=0,Radio2=1,Blocked=2}; static IngressProfile requested_ingress=IngressProfile::Radio; +static bool requested_explicit=false; +static IngressSource observed_source; inline IngressProfile ingressProfile() {return requested_ingress;} +inline IngressRoute ingressRoute(const IngressSource* source) { + observed_source=source?*source:IngressSource(); + return {requested_ingress, requested_explicit}; +} }} ''' with tempfile.TemporaryDirectory(prefix="meshcore-bridge-radio2-") as directory: diff --git a/test/test_bridge_ingress_settings.py b/test/test_bridge_ingress_settings.py index 72718695f..d378ce4f8 100644 --- a/test/test_bridge_ingress_settings.py +++ b/test/test_bridge_ingress_settings.py @@ -174,7 +174,7 @@ class BridgeIngressSettingsTests(unittest.TestCase): " bool fail_after_publication = false;\n bool fail_metadata = false;") mock = mock.replace(" files[to] = files[from]; files.erase(from); return true;", " files[to] = files[from]; files.erase(from);\n" - " if (fail_after_publication && !strcmp(to, \"/bridge_ingress\")) fail_read_open = true;\n" + " if (fail_after_publication && (!strcmp(to, \"/bridge_ingress\") || !strcmp(to, \"/bridge_routes\"))) fail_read_open = true;\n" " return true;") (work / "IdentityStore.h").write_text(mock) (work / "helpers/IdentityStore.h").write_text('#include "../IdentityStore.h"\n') diff --git a/test/test_bridge_recovery.py b/test/test_bridge_recovery.py index 8952aed2a..93e3f5f5b 100644 --- a/test/test_bridge_recovery.py +++ b/test/test_bridge_recovery.py @@ -20,6 +20,7 @@ MOCKS = r''' #include #include #include +#include #define MAX_TRANS_UNIT 255 #define PAYLOAD_TYPE_OTA 0x0c using portMUX_TYPE=int; @@ -32,6 +33,9 @@ constexpr int ESP_ERR_TIMEOUT=-80; enum wifi_ps_type_t {WIFI_PS_NONE=0,WIFI_PS_MIN_MODEM=1}; int esp_wifi_get_ps(wifi_ps_type_t* value){*value=WIFI_PS_NONE;return sdk_initialized?ESP_OK:-1;} int esp_wifi_get_protocol(int,uint8_t* value){*value=15;return sdk_initialized?ESP_OK:-1;} +int esp_wifi_get_mac(int,uint8_t* value){ + const uint8_t mac[]={2,16,32,48,64,80};memcpy(value,mac,sizeof(mac));return sdk_initialized?ESP_OK:-1; +} uint32_t now_ms=100; uint32_t millis(){return now_ms;} int send_result=0,send_calls=0; @@ -61,6 +65,7 @@ size_t nrf52DebugPrintf(const char* format,...){ } struct RTCClock {}; struct Packet { + mesh::bridge::IngressSource ingress_source; uint8_t data[MAX_TRANS_UNIT]={};uint16_t length=4; bool readFrom(const uint8_t* bytes,size_t len){ if(len==0||len>sizeof(data)||bytes[0]==0xee)return false; @@ -75,13 +80,14 @@ struct PacketManager { std::array pool;std::array used{}; bool exhausted=false;unsigned frees=0,allocs=0; std::vector received; + std::vector received_sources; Packet* allocNew(){ if(exhausted)return nullptr; for(size_t i=0;idata[0]);free(p);} + void queueInbound(Packet* p,uint32_t){received.push_back(p->data[0]);received_sources.push_back(p->ingress_source);free(p);} }; } struct NodePrefs { @@ -112,7 +118,16 @@ public: virtual void begin()=0;virtual void end()=0;virtual void loop()=0; virtual void sendPacket(mesh::Packet*)=0;virtual void onPacketReceived(mesh::Packet*)=0; bool allowsPacket(const mesh::Packet*)const{return allowed;} - bool preparePacketIngress(mesh::Packet*)const{return true;} + bool preparePacketIngress(mesh::Packet* packet)const{ + packet->ingress_source=ingress_source?*ingress_source:mesh::bridge::IngressSource();return true; + } + const mesh::bridge::IngressSource* ingress_source=nullptr; + struct IngressSourceScope { + BridgeBase& owner;const mesh::bridge::IngressSource* previous; + IngressSourceScope(BridgeBase& bridge,const mesh::bridge::IngressSource& source) + :owner(bridge),previous(bridge.ingress_source){owner.ingress_source=&source;} + ~IngressSourceScope(){owner.ingress_source=previous;} + }; static uint16_t fletcher16(const uint8_t*,size_t); bool validateChecksum(const uint8_t*,size_t,uint16_t); void handleReceivedPacket(mesh::Packet*); @@ -350,12 +365,15 @@ int main(){ bridge.queueReceivedFrame(mac,p.data,p.length);mgr.exhausted=true;bridge.loop(); assert(bridge._rx_count==1&&mgr.received.empty()); mgr.exhausted=false;bridge.loop();assert((mgr.received==std::vector{1})); + assert(mgr.received_sources.size()==1&&mgr.received_sources.back().kind==mesh::bridge::IngressSource::Kind::Mac); + assert(memcmp(mgr.received_sources.back().bytes,mac,6)==0&&bridge.ingress_source==nullptr); mesh::espnow::ESPNowRawFrames fragments;auto full=packet(2,255); assert(mesh::espnow::encodeEspNowRawFrames(full.data,full.length,fragments)); bridge.queueReceivedFrame(mac,fragments.data[0],fragments.lengths[0]);bridge.loop(); bridge.queueReceivedFrame(mac,fragments.data[1],fragments.lengths[1]);mgr.exhausted=true; bridge.loop();assert(bridge._rx_count==1&&mgr.received.size()==1); mgr.exhausted=false;bridge.loop();assert((mgr.received==std::vector{1,2})); + assert(mgr.received_sources.size()==2&&memcmp(mgr.received_sources.back().bytes,mac,6)==0); auto blocked=packet(3);bridge.allowed=false;bridge.queueReceivedFrame(mac,blocked.data,blocked.length); bridge.loop();assert(mgr.received.size()==2);bridge.allowed=true; auto bad=packet(0xee);bridge.queueReceivedFrame(mac,bad.data,bad.length);bridge.loop(); diff --git a/test/test_bridge_source_routes.py b/test/test_bridge_source_routes.py new file mode 100644 index 000000000..23ea1bda9 --- /dev/null +++ b/test/test_bridge_source_routes.py @@ -0,0 +1,162 @@ +"""Exercise per-sender bridge routing with the real parser and storage writers.""" +import unittest +from unittest.mock import patch + +import test_bridge_ingress_settings as settings + + +HARNESS = r''' +#include +#include +#include +#include "BridgeIngressSettings.h" +#if !MESH_ENABLE_BRIDGE_INGRESS +#error "Raw bridge transport flags must activate settings before CommonCLI.h" +#endif +#if defined(NRF52_PLATFORM) || defined(STM32_PLATFORM) +#define ATOMIC_FILE_WRITER_IMPLEMENTATION +#include "AtomicFileWriter.h" +#endif +#include "settings_under_test.cpp" + +int main() { + using namespace mesh::bridge; + MemoryFS fs; +#if defined(NRF52_PLATFORM) || defined(STM32_PLATFORM) + fs.rename_replaces = true; +#endif + const char* path = "/bridge_routes"; + const std::vector legacy = {9, 8, 7, 6, 5}; + fs.files["/com_prefs"] = legacy; + loadIngressSettings(&fs); + assert(fs.files.size() == 1); + auto route = ingressRoute(); + assert(route.profile == IngressProfile::Radio && !route.explicit_profile); + char reply[160]; + auto run = [&](const char* command) { + strcpy(reply, "untouched"); + return handleIngressCommand(command, reply, sizeof(reply)); + }; + auto okay = [&](const char* command) { + assert(run(command) && !strncmp(reply, "OK", 2)); + }; + auto rejects = [&](const char* command) { + assert(run(command) && !strncmp(reply, "Error:", 6)); + }; + okay("set bridge.ingress off"); + assert(fs.files.at("/bridge_ingress")[3] == 2); // legacy v1 remains readable + assert(ingressRoute().profile == IngressProfile::Off); + assert(run("get bridge.ingress") && !strcmp(reply, "> off")); + + IngressSource media_mac, usa_mac, media_udp, usa_udp, wrong_port; + assert(parseIngressSource("mac:02:10:20:30:40:50", media_mac)); + assert(parseIngressSource("mac:02:10:20:30:40:51", usa_mac)); + assert(parseIngressSource("udp:192.168.1.50:4404", media_udp)); + assert(parseIngressSource("udp:192.168.1.51:4404", usa_udp)); + assert(parseIngressSource("udp:192.168.1.50:4405", wrong_port)); + okay("set bridge.route.1 mac:02:10:20:30:40:50 radio2"); + okay("set bridge.route.2 mac:02:10:20:30:40:51 radio"); + okay("set bridge.route.3 udp:192.168.1.50:4404 radio2"); + okay("set bridge.route.4 udp:192.168.1.51:4404 radio"); + for (const auto* source : {&media_mac, &media_udp}) { + route = ingressRoute(source); + assert(route.profile == IngressProfile::Radio2 && route.explicit_profile); + } + for (const auto* source : {&usa_mac, &usa_udp}) { + route = ingressRoute(source); + assert(route.profile == IngressProfile::Radio && route.explicit_profile); + } + route = ingressRoute(&wrong_port); + assert(route.profile == IngressProfile::Off && !route.explicit_profile); + assert(run("get bridge.route") && strstr(reply, "4/8")); + assert(run("get bridge.route.1") && !strcmp(reply, "> 1 mac:02:10:20:30:40:50 radio2")); + assert(run("get bridge.route.3") && !strcmp(reply, "> 3 udp:192.168.1.50:4404 radio2")); + const auto good = fs.files.at(path); + assert(good.size() == RoutesImageSize); + loadIngressSettings(&fs); + assert(ingressRoute(&media_mac).profile == IngressProfile::Radio2); + assert(ingressRoute(&usa_udp).profile == IngressProfile::Radio); + assert(fs.files.at("/com_prefs") == legacy); + + for (const char* bad : { + "set bridge.route.0 mac:02:10:20:30:40:50 radio", "set bridge.route.9 mac:02:10:20:30:40:50 radio", + "set bridge.route.1 mac:02:10:20:30:40:50 both", "set bridge.route.1 mac:02:10:20:30:40:50 radio junk", + "set bridge.route.5 mac:02:10:20:30:40:50 radio", "set bridge.route.1 mac:FF:FF:FF:FF:FF:FF radio", + "set bridge.route.1 mac:00:00:00:00:00:00 radio", "set bridge.route.1 udp:0.0.0.0:4404 radio", + "set bridge.route.1 udp:224.1.2.3:4404 radio", "set bridge.route.1 udp:192.168.1.50:0 radio", + "set bridge.route.1 udp:192.168.1.50:65536 radio", "set bridge.route.1 udp:192.168.1.500:4404 radio", + "get bridge.route.1 extra", "del bridge.route.1 extra", "set bridge.route.1"}) { + rejects(bad); + assert(fs.files.at(path) == good && ingressRoute(&media_mac).profile == IngressProfile::Radio2); + } + assert(!run("get bridge.router") && !strcmp(reply, "untouched")); + assert(!run("set bridge.routes.1 mac:02:10:20:30:40:50 radio")); + char bounded[] = {'x', 'x', 'x', 'z'}; + assert(handleIngressCommand("get bridge.route.1", bounded, 3)); + assert(bounded[2] == 0 && bounded[3] == 'z'); + + fs.fail_write = true; + rejects("set bridge.route.1 mac:02:10:20:30:40:50 radio"); + assert(fs.files.at(path) == good && ingressRoute(&media_mac).profile == IngressProfile::Radio2); + fs.fail_write = false; + fs.fail_write_after = 7; + rejects("del bridge.route.1"); + assert(fs.files.at(path) == good && ingressRoute(&media_mac).profile == IngressProfile::Radio2); + fs.fail_write_after = -1; + + // Every corrupt or incomplete routes image blocks matched AND unmatched inputs. + for (size_t bit = 0; bit < good.size() * 8; ++bit) { + fs.files[path] = good; + fs.files[path][bit / 8] ^= uint8_t(1U << (bit % 8)); + loadIngressSettings(&fs); + assert(ingressRoute(&media_mac).profile == IngressProfile::Blocked); + assert(ingressRoute(&wrong_port).profile == IngressProfile::Blocked); + } + rejects("set bridge.route.1 mac:02:10:20:30:40:50 radio2"); + okay("clear bridge.routes"); + assert(ingressRoute(&media_mac).profile == IngressProfile::Off); + for (size_t size = 0; size < good.size(); ++size) { + fs.files[path] = std::vector(good.begin(), good.begin() + size); + loadIngressSettings(&fs); + assert(ingressRoute().profile == IngressProfile::Blocked); + } + okay("clear bridge.routes"); + fs.files[path] = good; + loadIngressSettings(&fs); + + // A write can publish before its readback fails. Block until storage reloads. + fs.fail_after_publication = true; + rejects("set bridge.route.1 mac:02:10:20:30:40:50 off"); + assert(ingressRoute(&media_mac).profile == IngressProfile::Blocked); + fs.fail_after_publication = false; + fs.fail_read_open = false; + rejects("clear bridge.routes"); + loadIngressSettings(&fs); + route = ingressRoute(&media_mac); + assert(route.profile == IngressProfile::Off && route.explicit_profile); + okay("del bridge.route.1"); + route = ingressRoute(&media_mac); + assert(route.profile == IngressProfile::Off && !route.explicit_profile); + okay("clear bridge.routes"); + okay("set bridge.ingress radio2"); + assert(fs.files.at("/bridge_ingress")[3] == 1); + assert(ingressRoute().profile == IngressProfile::Radio2); + okay("set bridge.ingress radio"); + fs.files.erase(path); + loadIngressSettings(&fs); + assert(ingressRoute().profile == IngressProfile::Radio && !ingressRoute().explicit_profile); + assert(fs.files.at("/com_prefs") == legacy); +} +''' + + +class BridgeSourceRouteTests(unittest.TestCase): + def test_saved_source_routes_and_faults_across_filesystems(self): + # Reuse the real filesystem/writer fixture and its eight transport / + # platform builds. Only the test main changes; production code is copied. + with patch.object(settings, "HARNESS", HARNESS): + settings.BridgeIngressSettingsTests().test_real_settings_and_transactions_across_filesystems() + + +if __name__ == "__main__": + unittest.main() diff --git a/test/test_radio_direction_dispatcher.py b/test/test_radio_direction_dispatcher.py new file mode 100644 index 000000000..86f74a82f --- /dev/null +++ b/test/test_radio_direction_dispatcher.py @@ -0,0 +1,150 @@ +"""Exercise the actual dispatcher with split receivers and a bridge-fed transmitter.""" +from pathlib import Path +import os +import subprocess +import tempfile +import unittest + +ROOT = Path(__file__).resolve().parents[1] + +SOURCE = r''' +#include +#include +#include +#include +#include +namespace mesh { namespace radioDirection { +Mode current = Mode::Both; +Mode mode() { return current; } +}} +namespace mesh { namespace bridge { +IngressRoute current{IngressProfile::Radio, true}; +IngressProfile ingressProfile() { return current.profile; } +IngressRoute ingressRoute(const IngressSource*) { return current; } +}} +struct Clock : mesh::MillisecondClock { + uint32_t now=100; + unsigned long getMillis() override { return now; } +}; +struct Radio : mesh::Radio { + mesh::RadioProfiles p; + unsigned rx=0,tx=0,calibrations=0,recoveries=0,loops=0; + uint8_t prepared=0; + bool cad=false; + unsigned busy_mask=0; + std::vector sent; + mesh::RadioProfiles* profiles() override { return &p; } + const mesh::RadioProfiles* profiles() const override { return &p; } + int recvRaw(uint8_t*,int) override { ++rx; return 0; } + uint32_t getEstAirtimeFor(int) override { return 10; } + float packetScore(float,int) override { return 0; } + bool startSendRaw(const uint8_t*,int) override { ++tx; sent.push_back(prepared); return true; } + bool isSendComplete() override { return true; } + void onSendFinished() override {} + bool isInRecvMode() const override { return mesh::radioDirection::radioReceiveAllowed(); } + void loop() override { ++loops; } + void setCADEnabled(bool value) override { cad=value; } + bool isReceiving() override { return busy_mask & (1U << prepared); } + void triggerNoiseFloorCalibrate(int) override { ++calibrations; } + bool recoverRadio(bool) override { ++recoveries; return true; } + mesh::RadioParamApplyResult prepareTransmitProfile(uint8_t value) override { + prepared=value; return mesh::RadioParamApplyResult::APPLIED; + } +}; +struct Dispatcher : mesh::Dispatcher { + unsigned inbound=0; + Dispatcher(Radio&r,Clock&c,StaticPoolPacketManager&m):mesh::Dispatcher(r,c,m){} + mesh::DispatcherAction onRecvPacket(mesh::Packet*) override { + ++inbound; return ACTION_RETRANSMIT_DELAYED(0,0); + } + using mesh::Dispatcher::getTransmitProfileMask; + using mesh::Dispatcher::getNextQueueWakeDelay; + bool getCADEnabled() const override { return true; } +}; +struct Bridge : AbstractBridge { + StaticPoolPacketManager& m; + explicit Bridge(StaticPoolPacketManager& pool):m(pool){} + bool receive(mesh::Packet* p) { if(!preparePacketIngress(p))return false; m.queueInbound(p,0);return true; } + void begin() override {} void end() override {} void loop() override {} + bool isRunning() const override { return true; } + void sendPacket(mesh::Packet*) override {} void onPacketReceived(mesh::Packet*) override {} +}; +int main() { + using namespace mesh::radioDirection; + Radio r; Clock c; StaticPoolPacketManager pool(12); Dispatcher d(r,c,pool); + r.p.secondary.mode=mesh::RadioProfileMode::RxTx; + r.p.cross=mesh::RadioCrossMode::On; + r.p.generation[0]=11;r.p.generation[1]=33; + current=Mode::Tx; d.begin(); Bridge b(pool);b.setIngressProfiles(&r.p); + for(unsigned profile:{0U,1U}) { + mesh::bridge::current={profile?mesh::bridge::IngressProfile::Radio2:mesh::bridge::IngressProfile::Radio,true}; + auto* p=d.obtainNewPacket();assert(p);p->header=ROUTE_TYPE_FLOOD|(PAYLOAD_TYPE_TXT_MSG<payload_len=1;p->payload[0]=profile; + assert(b.receive(p)); + } + for(unsigned i=0;i<100;++i){c.now+=10;d.loop();} + assert(d.inbound==2&&r.tx==2&&r.sent[0]==0&&r.sent[1]==1); + assert(r.rx==0&&r.calibrations==0&&r.recoveries==0&&r.loops>0&&r.cad); + c.now+=24UL*60*60*1000; d.loop();assert(r.recoveries==0&&r.rx==0); + uint32_t delay=0;assert(!d.getNextNoiseFloorWakeDelay(delay)); + assert(!d.getNextQueueWakeDelay(delay)); + // A busy MediaMesh channel must neither time out into a forced send nor + // starve a ready USA packet. Then send the retained frame when CAD clears. + const auto initial=r.tx; + r.busy_mask=2; + for(unsigned profile:{1U,0U}) { + mesh::bridge::current={profile?mesh::bridge::IngressProfile::Radio2:mesh::bridge::IngressProfile::Radio,true}; + auto* p=d.obtainNewPacket();assert(p);p->header=ROUTE_TYPE_FLOOD; + p->payload_len=1;assert(b.receive(p)); + } + for(unsigned i=0;i<1000;++i){c.now+=50;d.loop();} + assert(r.tx==initial+1&&r.sent.back()==0); + r.busy_mask=0; + for(unsigned i=0;i<100;++i){c.now+=50;d.loop();} + assert(r.tx==initial+2&&r.sent.back()==1); + // RF already delayed before a mode change cannot be mistaken for bridge + // input after RX is disabled. + const auto received=d.inbound; + auto* stale=d.obtainNewPacket();assert(stale); + stale->header=ROUTE_TYPE_FLOOD;stale->forwarded=false; + stale->radio_profile=0;stale->radio_generation=r.p.generation[0]; + pool.queueInbound(stale,0);c.now+=10;d.loop();assert(d.inbound==received); + current=Mode::Rx; + auto* blocked=d.obtainNewPacket();assert(blocked);blocked->header=ROUTE_TYPE_FLOOD; + assert(d.getTransmitProfileMask(blocked)==0); + assert(!d.sendPacket(blocked,0,0)); + const auto before=r.tx; c.now+=10;d.loop();assert(r.tx==before&&r.rx>0); + current=Mode::Off;const auto reads=r.rx; + for(unsigned i=0;i<10;++i){c.now+=10000;d.loop();} + assert(r.tx==before&&r.rx==reads&&r.recoveries==0); + current=Mode::Both;c.now+=10;d.loop();assert(r.rx>reads); +} +''' + + +class RadioDirectionDispatcherTests(unittest.TestCase): + def test_split_bridge_forwarding_direction_and_watchdog_lifecycle(self): + for soft in (False, True): + with self.subTest(soft=soft), tempfile.TemporaryDirectory() as temporary: + work = Path(temporary) + source = work / 'main.cpp' + source.write_text(SOURCE) + (work/'Mesh.h').write_text('#pragma once\n#include \n') + executable = work / 'test' + flags = ['-DMESH_ENABLE_RADIO_DIRECTION=1', '-DMESH_ENABLE_BRIDGE_INGRESS=1'] + if soft: + flags += ['-DRADIO_LIVENESS_SOFT_ONLY=1'] + built = subprocess.run([os.environ.get('CXX', 'c++'), '-std=c++17', '-O1', + '-fsanitize=address,undefined', '-fno-sanitize-recover=all', '-fno-pie', '-no-pie', + *flags, '-I', str(work), '-I', str(ROOT/'test/mocks'), '-I', str(ROOT/'src'), str(source), + str(ROOT/'src/Dispatcher.cpp'), str(ROOT/'src/Packet.cpp'), + str(ROOT/'src/helpers/StaticPoolPacketManager.cpp'), '-o', str(executable)], + capture_output=True, text=True, timeout=60) + self.assertEqual(built.returncode, 0, built.stdout+built.stderr) + tested = subprocess.run([str(executable)], capture_output=True, text=True, + timeout=20, env=dict(os.environ, ASAN_OPTIONS='detect_leaks=0')) + self.assertEqual(tested.returncode, 0, tested.stdout+tested.stderr) + + +if __name__ == '__main__': + unittest.main() diff --git a/test/test_radio_direction_driver.py b/test/test_radio_direction_driver.py new file mode 100644 index 000000000..bc7b2f802 --- /dev/null +++ b/test/test_radio_direction_driver.py @@ -0,0 +1,314 @@ +"""Exercise real radio direction, receive cleanup and CAD paths without RF.""" +from pathlib import Path +import os +import subprocess +import tempfile +import unittest + +from test_radio_receive_contract import ROOT, method, transition_harness +from test_carrier_wave import production_harness +import test_beacon_radio + + +class RadioDirectionDriverTests(unittest.TestCase): + def test_beacon_direction_gate_and_standby_restoration(self): + code = test_beacon_radio.BeaconRadioTest().source().replace( + "int main()", "int legacy_main()", 1) + end = code.rfind("}") + code = code[:end] + "return 0;\n" + code[end:] + code = r''' +#define MESH_ENABLE_RADIO_DIRECTION 1 +#include +namespace mesh { namespace radioDirection { +static Mode current = Mode::Both; +Mode mode() { return current; } +}} +''' + code + code = code.replace(" uint8_t state = STATE_RX, _active_profile = 0;", + " bool _direction_receive_disabled = false;\n" + " uint8_t state = STATE_RX, _active_profile = 0;") + code += r''' +int main() { + using namespace mesh::radioDirection; + assert(legacy_main() == 0); + current = Mode::Rx; + { + RadioLibWrapper w; + assert(w.prepareBeaconTransmit(beacon(), 10) == Result::FAILED); + assert(!w._beacon_owned && !w.applications && !w.power_sets && !w.board.before); + } + current = Mode::Tx; + { + RadioLibWrapper w; + assert(w.prepareBeaconTransmit(beacon(), 10) == Result::APPLIED); + assert(w._beacon_owned && w.state == STATE_IDLE && w.arms == 0); + assert(!w.isBeaconChannelBusy() && w.arms == 0); + assert(w.startSendRaw(nullptr, 0) && w.state == STATE_TX_WAIT); + w.onSendFinished(); + assert(w.restoreBeaconReceive() == Result::APPLIED); + assert(!w._beacon_owned && w.state == STATE_IDLE && w.arms == 0); + assert(w.modem.freq == 906 && w.modem.power == 20 && w.isTxPowerApplied(20)); + } + current = Mode::Both; + { + RadioLibWrapper w; + assert(w.prepareBeaconTransmit(beacon(), 10) == Result::APPLIED); + current = Mode::Off; + assert(!w.startSendRaw(nullptr, 0)); + assert(w.restoreBeaconReceive() == Result::APPLIED); + assert(!w._beacon_owned && w.state == STATE_IDLE && w.arms == 0); + } +} +''' + test_beacon_radio.BeaconRadioTest().run_harness(code) + + def test_carrier_and_morse_direction_gates_and_owned_cleanup(self): + source = (ROOT / "src/helpers/radiolib/RadioLibWrappers.cpp").read_text() + code = production_harness().replace("int main()", "int legacy_main()", 1) + end = code.rfind("}") + code = code[:end] + "return 0;\n" + code[end:] + code = r''' +#define MESH_ENABLE_RADIO_DIRECTION 1 +#define MESH_ENABLE_TX_BEACONS 0 +#include +namespace mesh { namespace radioDirection { +static Mode current = Mode::Both; +Mode mode() { return current; } +}} +''' + code + code = code.replace(" uint8_t state = STATE_RX;", r''' + uint8_t state = STATE_RX; + bool _direction_receive_disabled = false, _rx_retry_pending = false; + bool _nf_refresh_requested = false, _nf_window_armed = false; + uint8_t _irq_probe_failures = 0; + uint16_t _rx_retry_delay_ms = 0; + void serviceDirectionPolicy(); +''') + code = code.replace(" void startRecv() override {", r''' + void startRecv() override { + if (!mesh::radioDirection::radioReceiveAllowed()) return; +''') + code += method(source, "void RadioLibWrapper::serviceDirectionPolicy(") + code += r''' +int main() { + using namespace mesh::radioDirection; + using Result = mesh::RadioParamApplyResult; + assert(legacy_main() == 0); + current = Mode::Rx; + { + RadioLibWrapper w; + assert(w.setCarrierWave(0, 1000) == Result::FAILED); + assert(w.startMorseTx(902.1, 12, "E") == mesh::MorseTxResult::Failed); + assert(w.chip.starts == 0 && w.board.before == 0 && !w._cw_active); + } + current = Mode::Tx; + { + RadioLibWrapper w; + assert(w.startMorseRx(902.1, 25, 1000) == mesh::MorseRxResult::Failed); + assert(w.chip.raw_rx_starts == 0 && !w._cw_active); + assert(w.setCarrierWave(0, 1000) == Result::APPLIED); + assert(w.chip.carrier); + const auto receivers = w.rx_starts; + assert(w.setCarrierWave(0, 0) == Result::APPLIED); + assert(!w.chip.carrier && !w._cw_active && w.state == STATE_IDLE); + assert(w.rx_starts == receivers); + } + current = Mode::Both; + { + RadioLibWrapper w; + assert(w.setCarrierWave(0, 1000) == Result::APPLIED); + current = Mode::Rx; + w.serviceDirectionPolicy(); + assert(!w.chip.carrier && !w._cw_active && w.state == STATE_RX); + assert(!w.board.awake && w.board.after == 1); + } + current = Mode::Both; + { + RadioLibWrapper w; + assert(w.startMorseTx(902.1, 12, "E") == mesh::MorseTxResult::Started); + current = Mode::Rx; + w.serviceDirectionPolicy(); + assert(!w.chip.carrier && !w._cw_active && !w._morse_owned); + assert(w.state == STATE_RX && !w.board.awake); + } + current = Mode::Both; + { + RadioLibWrapper w; + assert(w.startMorseRx(902.1, 25, 1000) == mesh::MorseRxResult::Started); + current = Mode::Tx; + const auto receivers = w.rx_starts; + w.serviceDirectionPolicy(); + assert(!w._cw_active && !w._morse_rx_owned && w.state == STATE_IDLE); + assert(w.rx_starts == receivers && !w.board.awake); + } + current = Mode::Both; + { + RadioLibWrapper w; + assert(w.setCarrierWave(0, 1000) == Result::APPLIED); + current = Mode::Off; + w.serviceDirectionPolicy(); + assert(!w.chip.carrier && !w._cw_active && w.state == STATE_IDLE); + assert(w._direction_receive_disabled); + } +} +''' + with tempfile.TemporaryDirectory(prefix="radio-direction-carrier-") as temporary: + work = Path(temporary) + cpp, executable = work / "test.cpp", work / "test" + cpp.write_text(code) + built = subprocess.run([os.environ.get("CXX", "c++"), "-std=c++17", "-O1", + "-Wall", "-Wextra", "-fsanitize=address,undefined", + "-fno-sanitize-recover=all", "-fno-pie", "-no-pie", "-I", str(ROOT / "test/mocks"), + "-I", str(ROOT / "src"), str(cpp), "-o", str(executable)], + capture_output=True, text=True, timeout=60) + self.assertEqual(built.returncode, 0, built.stdout + built.stderr) + ran = subprocess.run([str(executable)], capture_output=True, text=True, timeout=30) + self.assertEqual(ran.returncode, 0, ran.stdout + ran.stderr) + + def test_direction_cleanup_cad_and_packet_last_gate(self): + source = (ROOT / "src/helpers/radiolib/RadioLibWrappers.cpp").read_text() + code = transition_harness(source).replace("int main()", "int legacy_main()", 1) + end = code.rfind("}") + code = code[:end] + "return 0;\n" + code[end:] + code = r''' +#define MESH_ENABLE_RADIO_DIRECTION 1 +#define MESH_ENABLE_CARRIER_TOOLS 0 +#define MESH_ENABLE_MORSE_RX 0 +#define MESH_ENABLE_TX_BEACONS 0 +#define RADIOLIB_ERR_SPI_CMD_TIMEOUT -705 +#include +namespace mesh { namespace radioDirection { +static Mode current = Mode::Both; +Mode mode() { return current; } +}} +''' + code + code = code.replace(" unsigned scans = 0;", r''' + unsigned scans = 0, standbys = 0, clears = 0, transmissions = 0; + int standby_error = 0, clear_error = 0, transmit_error = 0; + int standby() { ++standbys; return standby_error; } + int clearIrqFlags(uint32_t) { ++clears; return clear_error; } + int startTransmit(uint8_t*, int) { ++transmissions; return transmit_error; } +''') + code = code.replace(" unsigned n_cad_busy = 0, services = 0;", r''' + unsigned n_cad_busy = 0, services = 0, before_tx = 0, after_tx = 0; + void onBeforeTransmit() { ++before_tx; } + void onAfterTransmit() { ++after_tx; } +''') + code = code.replace(" uint8_t state = STATE_RX;", r''' + uint8_t state = STATE_RX; + bool _direction_receive_disabled = false; + uint8_t _irq_probe_failures = 0; + unsigned long _wd_observe_until = 0; + void serviceDirectionPolicy(); + bool startSendRaw(const uint8_t*, int); + bool getReceiveRetryDelay(uint32_t, uint32_t&) const; + void idle() { _radio->standby(); state = STATE_IDLE; } +''') + for signature in ("void RadioLibWrapper::serviceDirectionPolicy(", + "bool RadioLibWrapper::startSendRaw(", + "bool RadioLibWrapper::getReceiveRetryDelay("): + code += "\n" + method(source, signature) + code += r''' +int main() { + using namespace mesh::radioDirection; + assert(legacy_main() == 0); // Both preserves normal RX/CAD/calibration. + const uint8_t message[] = {1, 2, 3}; + current = Mode::Rx; + { + RadioLibWrapper w; + assert(!w.startSendRaw(message, sizeof(message))); + assert(w.radio.transmissions == 0 && w.board.before_tx == 0); + const auto old_arms = w.arms; + w.startRecv(); + assert(w.arms == old_arms + 1 && w.state == STATE_RX); + } + current = Mode::Tx; + { + RadioLibWrapper w; + w._rx_ps_enabled = w._rx_ps_armed = true; + w._rx_retry_pending = w._rx_hold_continuous = true; + w._nf_calib_active = w._nf_refresh_requested = w._nf_window_armed = true; + w._wd_observe_until = 1000; + w.events.record(); + w.loop(); + assert(w.radio.standbys == 1 && w.radio.clears == 1 && w.stops == 1); + assert(w.state == STATE_IDLE && !w.hasPendingRadioInterrupt()); + assert(w._direction_receive_disabled && !w._rx_ps_armed && !w._rx_retry_pending); + assert(!w._rx_hold_continuous && !w._nf_calib_active && !w._nf_refresh_requested); + assert(!w._nf_window_armed && !w._wd_observe_until); + for (unsigned i = 0; i < 1000; ++i) { ++now_ms; w.loop(); w.startRecv(); } + assert(w.radio.standbys == 1 && w.radio.clears == 1 && w.arms == 0 && w.reads == 0); + uint32_t delay = 123; + assert(!w.getNoiseFloorWakeDelay(now_ms, delay)); + assert(!w.getReceiveRetryDelay(now_ms, delay)); + w.triggerNoiseFloorCalibrate(7); + assert(!w._nf_refresh_requested && w._threshold == 7); + // Free CAD must not require RX re-entry, even with no RSSI baseline. + w._noise_floor_valid = false; + assert(!w.isChannelActive()); + assert(w.radio.scans == 1 && w.arms == 0 && w.state == STATE_IDLE); + assert(!w.isReceivingPassive(7)); + assert(w.radio.scans == 2 && w.reads == 0 && w.arms == 0); + w.radio.scan_result = -9; + assert(w.isChannelActive() && w.board.n_cad_busy == 1); + w.radio.scan_result = RADIOLIB_CHANNEL_FREE; + w.radio.clear_error = -8; + assert(w.isChannelActive() && w.board.n_cad_busy == 2); + w.radio.clear_error = 0; + assert(w.startSendRaw(message, sizeof(message))); + assert(w.state == STATE_TX_WAIT && w.radio.transmissions == 1); + const auto standbys = w.radio.standbys; + w.loop(); + assert(w.state == STATE_TX_WAIT && w.radio.standbys == standbys); + current = Mode::Rx; + w.serviceDirectionPolicy(); + assert(w.state == STATE_TX_WAIT && w.radio.standbys == standbys); + assert(!w._direction_receive_disabled); + w.state = STATE_IDLE; // Existing TxDone owner retires the on-air frame. + w.startRecv(); + assert(w.state == STATE_RX && w.arms == 1); + } + current = Mode::Tx; + { + RadioLibWrapper w; + w.state = STATE_IDLE; // Software idle may still conceal hardware RX. + w.radio.standby_error = -7; + w.serviceDirectionPolicy(); + assert(!w._direction_receive_disabled && w.radio.standbys == 1); + w.radio.standby_error = 0; + w.radio.clear_error = -8; + w.serviceDirectionPolicy(); + assert(!w._direction_receive_disabled && w.radio.standbys == 2); + w.radio.clear_error = 0; + w.serviceDirectionPolicy(); + assert(w._direction_receive_disabled && w.radio.standbys == 3); + current = Mode::Both; + w.serviceDirectionPolicy(); + assert(!w._direction_receive_disabled); + w.startRecv(); + assert(w.state == STATE_RX && w.arms == 1); + } + current = Mode::Off; + { + RadioLibWrapper w; + w.loop(); w.startRecv(); + assert(w.state == STATE_IDLE && w.arms == 0 && w._direction_receive_disabled); + assert(!w.startSendRaw(message, sizeof(message)) && w.radio.transmissions == 0); + } +} +''' + with tempfile.TemporaryDirectory(prefix="radio-direction-driver-") as temporary: + work = Path(temporary) + cpp, executable = work / "test.cpp", work / "test" + cpp.write_text(code) + built = subprocess.run([os.environ.get("CXX", "c++"), "-std=c++17", "-O1", + "-Wall", "-Wextra", "-Werror", "-DUSE_SX1262=1", "-fsanitize=address,undefined", + "-fno-sanitize-recover=all", "-fno-pie", "-no-pie", "-I", str(ROOT / "src"), + str(cpp), "-o", str(executable)], capture_output=True, text=True, timeout=60) + self.assertEqual(built.returncode, 0, built.stdout + built.stderr) + ran = subprocess.run([str(executable)], capture_output=True, text=True, timeout=30) + self.assertEqual(ran.returncode, 0, ran.stdout + ran.stderr) + + +if __name__ == "__main__": + unittest.main() diff --git a/test/test_radio_direction_settings.py b/test/test_radio_direction_settings.py new file mode 100644 index 000000000..3958e7860 --- /dev/null +++ b/test/test_radio_direction_settings.py @@ -0,0 +1,216 @@ +"""Exercise the saved LoRa direction gate and real publication failures.""" +import os +from pathlib import Path +import subprocess +import tempfile +import unittest + +ROOT = Path(__file__).resolve().parents[1] + +HARNESS = r''' +#include +#include +#include +#include "RadioDirectionSettings.h" +#include "settings_under_test.cpp" + +int main() { + using namespace mesh::radioDirection; + // Uninitialized users such as Companion retain their existing direction. + assert(mode() == Mode::Both && radioReceiveAllowed() && radioTransmitAllowed()); + MemoryFS fs; + const char* path = "/radio_mode"; + const std::vector legacy = {9, 7, 5, 3}; + fs.files["/com_prefs"] = legacy; + loadDirectionSettings(&fs); + assert(mode() == Mode::Both && fs.files.size() == 1); + char reply[160]; + auto run = [&](const char* command) { + strcpy(reply, "untouched"); + return handleDirectionCommand(command, reply, sizeof(reply)); + }; + assert(run("get radio.mode") && !strcmp(reply, "> both")); + assert(!run("get radio.model") && !strcmp(reply, "untouched")); + + struct Choice { const char* text; Mode value; bool rx, tx; }; + for (const Choice& choice : {Choice{"rx", Mode::Rx, true, false}, + Choice{"tx", Mode::Tx, false, true}, Choice{"off", Mode::Off, false, false}, + Choice{"both", Mode::Both, true, true}}) { + char command[32]; + snprintf(command, sizeof(command), "set radio.mode %s", choice.text); + assert(run(command) && !strncmp(reply, "OK", 2)); + assert(mode() == choice.value); + assert(radioReceiveAllowed() == choice.rx && radioTransmitAllowed() == choice.tx); + loadDirectionSettings(&fs); + assert(mode() == choice.value); + assert(radioReceiveAllowed() == choice.rx && radioTransmitAllowed() == choice.tx); + char expected[16]; snprintf(expected, sizeof(expected), "> %s", choice.text); + assert(run("get radio.mode") && !strcmp(reply, expected)); + } + assert(run("set radio.mode\trx") && !strncmp(reply, "OK", 2)); + const auto good = fs.files.at(path); + assert(good.size() == 10 && !memcmp(good.data(), "RDM\1", 4)); + assert(good[4] == 1 && good[5] == 0xfe); + for (const char* invalid : {"set radio.mode", "set radio.mode receive", + "set radio.mode Rx", "set radio.mode rxtx", "set radio.mode txjunk", + "set radio.mode rx trailing", "get radio.mode extra"}) { + assert(run(invalid) && !strncmp(reply, "Error:", 6)); + assert(mode() == Mode::Rx && fs.files.at(path) == good); + } + assert(!run("set radio.mode2 off")); + assert(!handleDirectionCommand(nullptr, reply, sizeof(reply))); + assert(!handleDirectionCommand("set radio.mode off", nullptr, sizeof(reply))); + assert(!handleDirectionCommand("set radio.mode off", reply, 0)); + char bounded[] = {'x', 'x', 'x', 'z'}; + assert(handleDirectionCommand("get radio.mode", bounded, 3)); + assert(bounded[2] == 0 && bounded[3] == 'z'); + + // A known failed save must keep both persisted and effective directions. + fs.fail_write = true; + assert(run("set radio.mode tx") && !strncmp(reply, "Error:", 6)); + assert(mode() == Mode::Rx && fs.files.at(path) == good); + fs.fail_write = false; + fs.fail_write_after = 4; + assert(run("set radio.mode off") && !strncmp(reply, "Error:", 6)); + assert(mode() == Mode::Rx && fs.files.at(path) == good); + fs.fail_write_after = -1; + assert(run("set radio.mode rx") && !strncmp(reply, "OK", 2)); + + // Publication uncertainty cannot enable LoRa TX or silently restore both. + fs.fail_after_publication = true; + assert(run("set radio.mode tx") && !strncmp(reply, "Error:", 6)); + assert(mode() == Mode::Off && !radioReceiveAllowed() && !radioTransmitAllowed()); + const auto published = fs.files.at(path); + assert(published != good); + fs.fail_after_publication = false; fs.fail_read_open = false; + assert(run("set radio.mode both") && !strncmp(reply, "Error:", 6)); + assert(mode() == Mode::Off && fs.files.at(path) == published); + loadDirectionSettings(&fs); + assert(mode() == Mode::Tx && !radioReceiveAllowed() && radioTransmitAllowed()); + assert(run("set radio.mode rx") && !strncmp(reply, "OK", 2)); + + // Corruption is distinguishable from deliberately saved off, and explicit + // repair is allowed only when the record's existence/read was determinate. + for (size_t bit = 0; bit < good.size() * 8; ++bit) { + fs.files[path] = good; + fs.files[path][bit / 8] ^= uint8_t(1U << (bit % 8)); + loadDirectionSettings(&fs); + assert(mode() == Mode::Off && !radioReceiveAllowed() && !radioTransmitAllowed()); + assert(run("get radio.mode") && strstr(reply, "invalid or unreadable")); + } + assert(run("set radio.mode rx") && !strncmp(reply, "OK", 2)); + for (size_t size = 0; size < good.size(); ++size) { + fs.files[path] = std::vector(good.begin(), good.begin() + size); + loadDirectionSettings(&fs); + assert(mode() == Mode::Off); + } + assert(run("set radio.mode rx") && !strncmp(reply, "OK", 2)); + fs.files[path].push_back(0); + loadDirectionSettings(&fs); assert(mode() == Mode::Off); + assert(run("set radio.mode off") && !strncmp(reply, "OK", 2)); + assert(run("get radio.mode") && !strcmp(reply, "> off")); + assert(run("set radio.mode rx") && !strncmp(reply, "OK", 2)); + + fs.fail_read_open = true; + loadDirectionSettings(&fs); assert(mode() == Mode::Off); + fs.fail_read_open = false; + assert(run("set radio.mode both") && !strncmp(reply, "Error:", 6)); + assert(fs.files.at(path) == good); + loadDirectionSettings(&fs); assert(mode() == Mode::Rx); + fs.fail_read_after = 4; + loadDirectionSettings(&fs); assert(mode() == Mode::Off); + fs.fail_read_after = -1; + assert(run("set radio.mode both") && !strncmp(reply, "Error:", 6)); + loadDirectionSettings(&fs); assert(mode() == Mode::Rx); + fs.fail_metadata = true; + loadDirectionSettings(&fs); assert(mode() == Mode::Off); + fs.fail_metadata = false; + assert(run("set radio.mode both") && !strncmp(reply, "Error:", 6)); + loadDirectionSettings(&fs); assert(mode() == Mode::Rx); + + fs.files["/radio_mode.bak"] = good; fs.files.erase(path); + loadDirectionSettings(&fs); + assert(mode() == Mode::Rx && fs.files.at(path) == good); + fs.files["/radio_mode.bak"] = good; fs.files.erase(path); + fs.fail_rename = 1; + loadDirectionSettings(&fs); assert(mode() == Mode::Off); + assert(fs.files.at("/radio_mode.bak") == good); + assert(run("set radio.mode tx") && !strncmp(reply, "Error:", 6)); + loadDirectionSettings(&fs); assert(mode() == Mode::Rx); + loadDirectionSettings(nullptr); + assert(mode() == Mode::Off && !radioReceiveAllowed() && !radioTransmitAllowed()); + assert(run("set radio.mode both") && !strncmp(reply, "Error:", 6)); + assert(fs.files.at("/com_prefs") == legacy); +} +''' + + +class RadioDirectionSettingsTests(unittest.TestCase): + def test_saved_modes_and_real_storage_faults(self): + with tempfile.TemporaryDirectory(prefix="radio-direction-settings-") as temporary: + work = Path(temporary) + (work / "helpers").mkdir() + mock = (ROOT / "test/fixtures/radio_profiles/mocks/helpers/IdentityStore.h").read_text() + mock = mock.replace(" bool fail_read_open = false;", + " bool fail_read_open = false;\n" + " bool fail_after_publication = false;\n bool fail_metadata = false;") + mock = mock.replace(" files[to] = files[from]; files.erase(from); return true;", + " files[to] = files[from]; files.erase(from);\n" + " if (fail_after_publication && !strcmp(to, \"/radio_mode\")) fail_read_open = true;\n" + " return true;") + (work / "IdentityStore.h").write_text(mock) + (work / "helpers/IdentityStore.h").write_text('#include "../IdentityStore.h"\n') + (work / "FilePresence.h").write_text(r''' +#pragma once +#include "IdentityStore.h" +namespace mesh { +template bool filePresence(FS* fs, const char* path, bool& present) { + if (!fs || !path || fs->fail_metadata) return false; + present = fs->exists(path); return true; +} +} +''') + for name in ("RadioDirectionSettings.h", "RadioDirectionPolicy.h", + "CheckedSettingsFile.h", "ContactFileTransaction.h"): + (work / name).write_bytes((ROOT / "src/helpers" / name).read_bytes()) + (work / "settings_under_test.cpp").write_bytes( + (ROOT / "src/helpers/RadioDirectionSettings.cpp").read_bytes()) + source, executable = work / "test.cpp", work / "test" + source.write_text(HARNESS) + built = subprocess.run([os.environ.get("CXX", "c++"), "-std=c++17", "-O1", + "-Wall", "-Wextra", "-Werror", "-Wno-sign-compare", + "-fsanitize=address,undefined", "-fno-sanitize-recover=all", "-fno-pie", "-no-pie", + "-DESP32_PLATFORM=1", "-I" + str(work), "-I" + str(ROOT / "src"), + "-I" + str(ROOT / "src/helpers"), str(source), "-o", str(executable)], + capture_output=True, text=True, timeout=60) + self.assertEqual(built.returncode, 0, built.stdout + built.stderr) + checked = subprocess.run([str(executable)], capture_output=True, text=True, timeout=20) + self.assertEqual(checked.returncode, 0, checked.stdout + checked.stderr) + + def test_other_platforms_have_both_directions_without_storage(self): + with tempfile.TemporaryDirectory(prefix="radio-direction-default-") as temporary: + work = Path(temporary) + source, executable, obj = work / "test.cpp", work / "test", work / "settings.o" + source.write_text(''' +#include +#include "RadioDirectionPolicy.h" +int main() { + using namespace mesh::radioDirection; + assert(mode() == Mode::Both && radioReceiveAllowed() && radioTransmitAllowed()); +} +''') + for platform in ("NRF52_PLATFORM", "STM32_PLATFORM", "RP2040_PLATFORM"): + flags = ["-std=c++17", "-D" + platform, "-I" + str(ROOT / "src/helpers")] + subprocess.run([os.environ.get("CXX", "c++"), *flags, + str(source), "-o", str(executable)], check=True, capture_output=True) + subprocess.run([str(executable)], check=True, capture_output=True) + subprocess.run([os.environ.get("CXX", "c++"), *flags, "-c", + str(ROOT / "src/helpers/RadioDirectionSettings.cpp"), "-o", str(obj)], + check=True, capture_output=True) + symbols = subprocess.check_output(["nm", "-C", str(obj)], text=True) + for name in ("settings_fs", "selected", "writable", "valid", "loadDirectionSettings", "handleDirectionCommand"): + self.assertNotIn(name, symbols) + + +if __name__ == "__main__": + unittest.main() diff --git a/test/test_transport_filter_wiring.py b/test/test_transport_filter_wiring.py index 7b6177290..816de1313 100644 --- a/test/test_transport_filter_wiring.py +++ b/test/test_transport_filter_wiring.py @@ -36,6 +36,8 @@ struct Packet { HARNESS = r''' #include +#include +namespace radioDirection = mesh::radioDirection; #include #include #include @@ -88,6 +90,9 @@ struct BridgeBase : AbstractBridge { bool isRunning() const override {return _initialized;} void sendPacket(mesh::Packet*) override {} void onPacketReceived(mesh::Packet* p) override {handleReceivedPacket(p);} + void receiveFrom(mesh::Packet* p, const mesh::bridge::IngressSource& source) { + IngressSourceScope scope(*this, source); handleReceivedPacket(p); + } void handleReceivedPacket(mesh::Packet*); uint16_t fletcher16(const uint8_t*,size_t) {return 0;} }; @@ -413,6 +418,7 @@ int main() { #if MESH_ENABLE_BRIDGE_INGRESS namespace mesh { namespace bridge { IngressProfile ingressProfile() { return IngressProfile::Radio; } +IngressRoute ingressRoute(const IngressSource*) { return {IngressProfile::Radio, false}; } }} #endif ''' From a925bdef545ca8da2fbb6a4774aafd588f1acbd4 Mon Sep 17 00:00:00 2001 From: mikecarper Date: Wed, 28 Oct 2026 15:37:43 -0700 Subject: [PATCH 48/61] Use shared radio direction policy in idle dispatcher tests --- test/test_radio_profile_idle_dispatcher.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/test/test_radio_profile_idle_dispatcher.py b/test/test_radio_profile_idle_dispatcher.py index 23beeaedc..6e559cc70 100644 --- a/test/test_radio_profile_idle_dispatcher.py +++ b/test/test_radio_profile_idle_dispatcher.py @@ -17,6 +17,8 @@ HARNESS = r''' #include #include #include +#include +namespace radioDirection = mesh::radioDirection; #define MESH_ENABLE_FOREIGN_RADIO 1 #define MESH_ENABLE_TX_BEACONS 1 struct Profiles { bool active = false; bool enabled() const { return active; } }; From dba5bdba2cac8bd2f37a433df03a0dffc5d27de2 Mon Sep 17 00:00:00 2001 From: mikecarper Date: Wed, 28 Oct 2026 16:36:07 -0700 Subject: [PATCH 49/61] Allow ESP-NOW station MAC diagnostics through the CLI --- src/helpers/CommonCLI.cpp | 4 ++-- src/helpers/bridges/ESPNowBridge.cpp | 4 ++-- test/test_espnow_diagnostic_cli.py | 6 +++++- 3 files changed, 9 insertions(+), 5 deletions(-) diff --git a/src/helpers/CommonCLI.cpp b/src/helpers/CommonCLI.cpp index 6832a9b0b..9cc0b09fe 100644 --- a/src/helpers/CommonCLI.cpp +++ b/src/helpers/CommonCLI.cpp @@ -3451,11 +3451,11 @@ bool CommonCLI::handleEspNowDiagnosticCommand(uint32_t sender_timestamp, const char* page = command + 16; if (*page == 0) { _callbacks->formatEspNowStats("", reply, capacity); - } else if (strcmp(page, " rx") == 0 || strcmp(page, " tx") == 0 + } else if (strcmp(page, " mac") == 0 || strcmp(page, " rx") == 0 || strcmp(page, " tx") == 0 || strcmp(page, " drops") == 0 || strcmp(page, " test") == 0) { _callbacks->formatEspNowStats(page + 1, reply, capacity); } else { - mesh::cli::formatError(reply, capacity, "use get espnow.stats [rx|tx|drops|test]"); + mesh::cli::formatError(reply, capacity, "use get espnow.stats [mac|rx|tx|drops|test]"); } return true; } diff --git a/src/helpers/bridges/ESPNowBridge.cpp b/src/helpers/bridges/ESPNowBridge.cpp index d7b866c00..1a87af096 100644 --- a/src/helpers/bridges/ESPNowBridge.cpp +++ b/src/helpers/bridges/ESPNowBridge.cpp @@ -919,7 +919,7 @@ void ESPNowBridge::formatStatus(char* reply, size_t reply_size) { void ESPNowBridge::formatStats(const char* page, char* reply, size_t reply_size) { const auto s = snapshotStats(); if (page == nullptr || page[0] == 0) { - snprintf(reply, reply_size, "> rx=%lu mesh=%lu tx=%lu sent=%lu; pages: rx tx drops test", + snprintf(reply, reply_size, "> rx=%lu mesh=%lu tx=%lu sent=%lu; pages: mac rx tx drops test", (unsigned long)s.rx.frames, (unsigned long)s.rx.delivered, (unsigned long)s.tx.queued, (unsigned long)s.tx.callback_ok); } else if (strcmp(page, "mac") == 0) { @@ -953,7 +953,7 @@ void ESPNowBridge::formatStats(const char* page, char* reply, size_t reply_size) (unsigned long)s.probe.rx_bad, (unsigned long)s.probe.last_tx_nonce, (unsigned long)s.probe.last_rx_nonce); } else { - snprintf(reply, reply_size, "Error: use get espnow.stats [rx|tx|drops|test]"); + snprintf(reply, reply_size, "Error: use get espnow.stats [mac|rx|tx|drops|test]"); } } diff --git a/test/test_espnow_diagnostic_cli.py b/test/test_espnow_diagnostic_cli.py index 4ed5ddf45..6956e18a6 100644 --- a/test/test_espnow_diagnostic_cli.py +++ b/test/test_espnow_diagnostic_cli.py @@ -87,7 +87,7 @@ int main() { }; assert(command("get espnow.status", 77)); assert(callbacks.statuses == 1 && callbacks.capacity == 157); - for (const char* page : {"", "rx", "tx", "drops", "test"}) { + for (const char* page : {"", "mac", "rx", "tx", "drops", "test"}) { const std::string text = std::string("get espnow.stats") + (*page ? " " : "") + page; assert(command(text.c_str(), 77)); assert(callbacks.page == page && callbacks.capacity == 157); @@ -101,6 +101,8 @@ int main() { for (const char* text : { "get espnow.status extra", "get espnow.statusx", "get espnow.status ", "get espnow.statsrx", "get espnow.stats x", "get espnow.stats rx junk", + "get espnow.statsmac", "get espnow.stats macx", "get espnow.stats mac junk", + "get espnow.stats mac ", "get espnow.stats\tmac", "get espnow.stats mac", "get espnow.stats ", "get espnow.stats\trx", "get espnow.stats rx", "espnow", "espnow testx", "espnow test ", "espnow test 1234567", "espnow test 123456789", "espnow test 0x123456", "espnow test +1234567", @@ -110,6 +112,8 @@ int main() { assert(command(text)); assert(strncmp(reply, "Error:", 6) == 0 && callbacks.count() == before); } + assert(command("get espnow.stats unknown")); + assert(strstr(reply, "[mac|rx|tx|drops|test]")); for (const char* text : {"espnowtest", "espnowx test", "get espnow.enabled", "get repeat"}) { const int before = callbacks.count(); assert(!command(text)); From 0cff0bf19c89e1b5f80e63816ba65458e9d53693 Mon Sep 17 00:00:00 2001 From: mikecarper Date: Wed, 28 Oct 2026 17:24:35 -0700 Subject: [PATCH 50/61] Share nRF52 crypto scratch buffers under the existing lock --- src/Identity.cpp | 6 +- src/Utils.cpp | 22 +-- src/helpers/NRF52Crypto.cpp | 37 +++- src/helpers/NRF52CryptoScratch.h | 22 +++ test/test_cc310_shared_scratch.py | 277 ++++++++++++++++++++++++++++++ 5 files changed, 349 insertions(+), 15 deletions(-) create mode 100644 src/helpers/NRF52CryptoScratch.h create mode 100644 test/test_cc310_shared_scratch.py diff --git a/src/Identity.cpp b/src/Identity.cpp index f2e9da031..c1762b555 100644 --- a/src/Identity.cpp +++ b/src/Identity.cpp @@ -5,7 +5,7 @@ #include #ifdef USE_CC310_HW_CRYPTO -#include "helpers/NRF52Crypto.h" +#include "helpers/NRF52CryptoScratch.h" #include "nrf_cc310/include/crys_ec_edw_api.h" #include "nrf_cc310/include/crys_ec_mont_edw_error.h" #endif @@ -32,11 +32,11 @@ bool Identity::verify(const uint8_t* sig, const uint8_t* message, int msg_len) c // workspace and the CC310 driver are not reentrant. CC310CryptoSession session; if (session) { - static CRYS_ECEDW_TempBuff_t cc310_tmp; + CRYS_ECEDW_TempBuff_t* cc310_tmp = cc310VerificationScratch(session); const CRYSError_t rc = CRYS_ECEDW_Verify( const_cast(sig), CRYS_ECEDW_SIGNATURE_BYTES, const_cast(pub_key), CRYS_ECEDW_MOD_SIZE_IN_BYTES, - const_cast(message), static_cast(msg_len), &cc310_tmp); + const_cast(message), static_cast(msg_len), cc310_tmp); if (rc == CRYS_OK) return true; // A normal signature mismatch is a conclusive hardware result. Other // CC310 errors fall through to the correctness-preserving software path. diff --git a/src/Utils.cpp b/src/Utils.cpp index 2b1fb88a8..aeed679e3 100644 --- a/src/Utils.cpp +++ b/src/Utils.cpp @@ -3,7 +3,7 @@ #include #ifdef USE_CC310_HW_CRYPTO -#include "helpers/NRF52Crypto.h" +#include "helpers/NRF52CryptoScratch.h" #include "nrf_cc310/include/crys_hash.h" #include "nrf_cc310/include/crys_hmac.h" #include "nrf_cc310/include/ssi_aes.h" @@ -92,23 +92,23 @@ int aesHardware(bool encrypting, const uint8_t* shared_secret, uint8_t* dest, mesh::CC310CryptoSession session; if (!session) return -1; - static SaSiAesUserContext_t ctx; + SaSiAesUserContext_t* ctx = mesh::cc310AesScratch(session); SaSiAesUserKeyData_t key_data = { const_cast(shared_secret), CIPHER_KEY_SIZE }; SaSiError_t rc = SaSi_AesInit( - &ctx, encrypting ? SASI_AES_ENCRYPT : SASI_AES_DECRYPT, + ctx, encrypting ? SASI_AES_ENCRYPT : SASI_AES_DECRYPT, SASI_AES_MODE_ECB, SASI_AES_PADDING_NONE); const bool initialized = rc == SASI_OK; if (rc == SASI_OK) { - rc = SaSi_AesSetKey(&ctx, SASI_AES_USER_KEY, &key_data, sizeof(key_data)); + rc = SaSi_AesSetKey(ctx, SASI_AES_USER_KEY, &key_data, sizeof(key_data)); } uint8_t* dp = dest; const uint8_t* sp = src; int remaining = src_len; while (rc == SASI_OK && remaining >= CIPHER_BLOCK_SIZE) { - rc = SaSi_AesBlock(&ctx, const_cast(sp), CIPHER_BLOCK_SIZE, dp); + rc = SaSi_AesBlock(ctx, const_cast(sp), CIPHER_BLOCK_SIZE, dp); if (rc == SASI_OK) { dp += CIPHER_BLOCK_SIZE; sp += CIPHER_BLOCK_SIZE; @@ -118,15 +118,15 @@ int aesHardware(bool encrypting, const uint8_t* shared_secret, uint8_t* dest, if (rc == SASI_OK && encrypting && remaining > 0) { uint8_t padded[CIPHER_BLOCK_SIZE] = {}; memcpy(padded, sp, remaining); - rc = SaSi_AesBlock(&ctx, padded, CIPHER_BLOCK_SIZE, dp); + rc = SaSi_AesBlock(ctx, padded, CIPHER_BLOCK_SIZE, dp); if (rc == SASI_OK) dp += CIPHER_BLOCK_SIZE; } size_t final_size = 0; if (rc == SASI_OK) { - rc = SaSi_AesFinish(&ctx, 0, NULL, 0, NULL, &final_size); + rc = SaSi_AesFinish(ctx, 0, NULL, 0, NULL, &final_size); } - const SaSiError_t free_rc = initialized ? SaSi_AesFree(&ctx) : SASI_OK; + const SaSiError_t free_rc = initialized ? SaSi_AesFree(ctx) : SASI_OK; if (rc != SASI_OK || free_rc != SASI_OK) return -1; return static_cast(dp - dest); } @@ -136,12 +136,12 @@ bool hmacHardware(const uint8_t* shared_secret, uint8_t* dest, mesh::CC310CryptoSession session; if (!session) return false; - static CRYS_HASH_Result_t result; + CRYS_HASH_Result_t* result = mesh::cc310HmacScratch(session); const CRYSError_t rc = CRYS_HMAC( CRYS_HASH_SHA256_mode, const_cast(shared_secret), PUB_KEY_SIZE, - const_cast(src), static_cast(src_len), result); + const_cast(src), static_cast(src_len), *result); if (rc != CRYS_OK) return false; - memcpy(dest, result, CIPHER_MAC_SIZE); + memcpy(dest, *result, CIPHER_MAC_SIZE); return true; } diff --git a/src/helpers/NRF52Crypto.cpp b/src/helpers/NRF52Crypto.cpp index c9e689435..3838ebb31 100644 --- a/src/helpers/NRF52Crypto.cpp +++ b/src/helpers/NRF52Crypto.cpp @@ -1,10 +1,12 @@ -#include "NRF52Crypto.h" +#include "NRF52CryptoScratch.h" #if defined(USE_CC310_HW_CRYPTO) #include #include +#include #include +#include namespace { @@ -18,6 +20,23 @@ StaticSemaphore_t cc310_mutex_storage; SemaphoreHandle_t cc310_mutex = NULL; CC310State cc310_state = CC310_UNINITIALIZED; +struct HmacScratch { + CRYS_HASH_Result_t result; +}; + +// The session mutex already excludes simultaneous SDK operations. Share their +// scratch storage without adding to the loop stack or allocating from the heap. +union CryptoScratch { + CRYS_ECEDW_TempBuff_t verification; + SaSiAesUserContext_t aes; + HmacScratch hmac; +}; +static_assert(std::is_trivially_destructible::value, + "CC310 scratch members must not require destruction"); +static_assert(sizeof(CryptoScratch) == sizeof(CRYS_ECEDW_TempBuff_t), + "Review CC310 scratch layout when SDK workspace sizes change"); +CryptoScratch crypto_scratch; + SemaphoreHandle_t getCC310Mutex() { if (cc310_mutex == NULL) { // nRF52840 is single-core. The critical section makes the lazy static @@ -57,6 +76,22 @@ CC310CryptoSession::~CC310CryptoSession() { if (_locked) xSemaphoreGive(cc310_mutex); } +CRYS_ECEDW_TempBuff_t* cc310VerificationScratch(const CC310CryptoSession& session) { + if (!session) return nullptr; + return ::new (static_cast(&crypto_scratch.verification)) CRYS_ECEDW_TempBuff_t{}; +} + +SaSiAesUserContext_t* cc310AesScratch(const CC310CryptoSession& session) { + if (!session) return nullptr; + return ::new (static_cast(&crypto_scratch.aes)) SaSiAesUserContext_t{}; +} + +CRYS_HASH_Result_t* cc310HmacScratch(const CC310CryptoSession& session) { + if (!session) return nullptr; + // Construct a single wrapper object, avoiding placement array-new cookies. + return &(::new (static_cast(&crypto_scratch.hmac)) HmacScratch{})->result; +} + bool initializeCC310Crypto() { CC310CryptoSession session; return static_cast(session); diff --git a/src/helpers/NRF52CryptoScratch.h b/src/helpers/NRF52CryptoScratch.h new file mode 100644 index 000000000..a743d6dfc --- /dev/null +++ b/src/helpers/NRF52CryptoScratch.h @@ -0,0 +1,22 @@ +#pragma once + +#include "NRF52Crypto.h" + +#if defined(USE_CC310_HW_CRYPTO) + +#include "nrf_cc310/include/crys_ec_edw_api.h" +#include "nrf_cc310/include/crys_hash.h" +#include "nrf_cc310/include/ssi_aes.h" + +namespace mesh { + +// Select one word-aligned workspace while the session owns the CC310. +// Selecting another kind invalidates the previous one. Each selection +// initializes its complete SDK type; unavailable sessions return nullptr. +CRYS_ECEDW_TempBuff_t* cc310VerificationScratch(const CC310CryptoSession& session); +SaSiAesUserContext_t* cc310AesScratch(const CC310CryptoSession& session); +CRYS_HASH_Result_t* cc310HmacScratch(const CC310CryptoSession& session); + +} // namespace mesh + +#endif diff --git a/test/test_cc310_shared_scratch.py b/test/test_cc310_shared_scratch.py new file mode 100644 index 000000000..e10202888 --- /dev/null +++ b/test/test_cc310_shared_scratch.py @@ -0,0 +1,277 @@ +"""Run the production CC310 session and callers with typed SDK/RTOS fakes. + +The fake algorithms test storage, serialization and existing fallback behavior; +they are not a substitute for CryptoCell hardware known-vector qualification. +""" +from pathlib import Path +import shutil +import subprocess +import sys +import tempfile +import unittest + +from cpp_source import body + +ROOT = Path(__file__).resolve().parents[1] + +ARDUINO = r''' +#pragma once +#include +#include +#include +#include +#include +struct StaticSemaphore_t { std::mutex mutex; }; +using SemaphoreHandle_t = StaticSemaphore_t*; +extern thread_local bool mock_isr; +extern std::mutex creation_mutex; +extern std::atomic creates, takes, gives, lock_depth; +inline bool isInISR() { return mock_isr; } +inline SemaphoreHandle_t xSemaphoreCreateMutexStatic(StaticSemaphore_t* value) { + ++creates; return value; +} +inline int xSemaphoreTake(SemaphoreHandle_t value, unsigned) { + value->mutex.lock(); ++takes; assert(++lock_depth == 1); return 1; +} +inline void xSemaphoreGive(SemaphoreHandle_t value) { + assert(--lock_depth == 0); ++gives; value->mutex.unlock(); +} +#define pdTRUE 1 +#define portMAX_DELAY 0xffffffffU +#define taskENTER_CRITICAL() creation_mutex.lock() +#define taskEXIT_CRITICAL() creation_mutex.unlock() +''' + +SDK = r''' +#pragma once +#include +#include +struct CRYS_ECEDW_TempBuff_t { uint32_t buff[140]; }; +struct SaSiAesUserContext_t { uint32_t buff[19]; }; +using CRYS_HASH_Result_t = uint32_t[16]; +using CRYSError_t = int; +using SaSiError_t = int; +struct SaSiAesUserKeyData_t { uint8_t* key; unsigned size; }; +constexpr int CRYS_OK=0, SASI_OK=0, CRYS_ECEDW_SIGN_VERIFY_FAILED_ERROR=2; +constexpr unsigned CRYS_ECEDW_SIGNATURE_BYTES=64, CRYS_ECEDW_MOD_SIZE_IN_BYTES=32; +constexpr int SASI_AES_ENCRYPT=1, SASI_AES_DECRYPT=0, SASI_AES_MODE_ECB=0; +constexpr int SASI_AES_PADDING_NONE=0, SASI_AES_USER_KEY=0, CRYS_HASH_SHA256_mode=0; +''' + +CRYPTO = r''' +#pragma once +#include +struct MockRandom { bool generate(uint8_t*, uint16_t); }; +struct MockCrypto { bool begin(); MockRandom Random; }; +extern MockCrypto nRFCrypto; +''' + +HARNESS = r''' +#include +#include +#include +#include +#include +#include +#include +thread_local bool mock_isr=false; +std::mutex creation_mutex; +std::atomic creates{0}, takes{0}, gives{0}, lock_depth{0}; +std::atomic begins{0}, sdk_calls{0}, software_verifies{0}; +bool begin_ok=true, random_ok=true; +int verify_rc=CRYS_OK, aes_rc=SASI_OK, hmac_rc=CRYS_OK; +MockCrypto nRFCrypto; +bool MockCrypto::begin() { ++begins; return begin_ok; } +bool MockRandom::generate(uint8_t* out, uint16_t size) { + if (!random_ok) return false; + memset(out, 0x5a, size); return true; +} +template bool zero(const T& value) { + const auto* bytes=reinterpret_cast(&value); + for(size_t i=0;ibuff[0]=key->key[0]; return SASI_OK; +} +int SaSi_AesBlock(SaSiAesUserContext_t* ctx, uint8_t* src, unsigned size, uint8_t* dest) { + for(unsigned i=0;ibuff[0]); + std::this_thread::yield(); return SASI_OK; +} +int SaSi_AesFinish(SaSiAesUserContext_t*, int, void*, int, void*, size_t*) { return SASI_OK; } +int SaSi_AesFree(SaSiAesUserContext_t* ctx) { memset(ctx,0,sizeof(*ctx)); return SASI_OK; } +int CRYS_HMAC(int, uint8_t*, unsigned, uint8_t*, size_t, uint32_t* result) { + assert(lock_depth==1); + for(unsigned i=0;i<16;i++) assert(result[i]==0); + ++sdk_calls; + if(hmac_rc) return hmac_rc; + memset(result,0xa5,sizeof(CRYS_HASH_Result_t)); return CRYS_OK; +} +int CRYS_ECEDW_Verify(uint8_t*, unsigned, uint8_t*, unsigned, uint8_t*, size_t, + CRYS_ECEDW_TempBuff_t* scratch) { + assert(lock_depth==1 && zero(*scratch)); ++sdk_calls; + memset(scratch,0xc3,sizeof(*scratch)); return verify_rc; +} +struct Ed25519 { + static bool verify(const uint8_t*, const uint8_t*, const uint8_t*, int) { + ++software_verifies; return true; + } +}; +namespace mesh { struct Identity { + uint8_t pub_key[32]{}; + bool verify(const uint8_t*,const uint8_t*,int) const; +}; } +@CALLERS@ + +static void no_scratch(mesh::CC310CryptoSession& session) { + assert(!session && !mesh::cc310VerificationScratch(session) && + !mesh::cc310AesScratch(session) && !mesh::cc310HmacScratch(session)); +} +static void operation_round() { + std::array key{}; key.fill(0x17); + std::array plain{}, encrypted{}, decoded{}; + for(unsigned i=0;i mac{}; + assert(hmacHardware(key.data(),mac.data(),plain.data(),plain.size())); + assert(mac[0]==0xa5 && mac[1]==0xa5); + mesh::Identity identity; + std::array signature{}; + assert(identity.verify(signature.data(),plain.data(),plain.size())); +} +int main(int argc,char**argv) { + assert(argc==2); + const std::string scenario=argv[1]; + if(scenario=="alternating") { + static_assert(sizeof(CRYS_ECEDW_TempBuff_t)==560); + static_assert(sizeof(SaSiAesUserContext_t)==76); + static_assert(sizeof(CRYS_HASH_Result_t)==64); + for(unsigned n=0;n<1000;n++) { + mesh::CC310CryptoSession session; + assert(session && lock_depth==1); + auto* v=mesh::cc310VerificationScratch(session); assert(zero(*v)); + assert(reinterpret_cast(v)%alignof(CRYS_ECEDW_TempBuff_t)==0); + memset(v,0xff,sizeof(*v)); + auto* a=mesh::cc310AesScratch(session); assert(zero(*a)); + assert(reinterpret_cast(a)==reinterpret_cast(v)); + assert(reinterpret_cast(a)%alignof(SaSiAesUserContext_t)==0); + memset(a,0xff,sizeof(*a)); + auto* h=mesh::cc310HmacScratch(session); assert(zero(*h)); + assert(reinterpret_cast(h)==reinterpret_cast(v)); + assert(reinterpret_cast(h)%alignof(CRYS_HASH_Result_t)==0); + memset(h,0xff,sizeof(*h)); + } + assert(begins==1 && creates==1 && takes==gives); + } else if(scenario=="callers") { + for(unsigned n=0;n<1000;n++) operation_round(); + assert(begins==1 && software_verifies==0 && takes==gives && sdk_calls==4000); + std::array signature{}; std::array message{}; + mesh::Identity identity; + verify_rc=CRYS_ECEDW_SIGN_VERIFY_FAILED_ERROR; + assert(!identity.verify(signature.data(),message.data(),message.size()) && software_verifies==0); + verify_rc=7; + assert(identity.verify(signature.data(),message.data(),message.size()) && software_verifies==1); + std::array key{}, destination{}; destination.fill(0x79); + aes_rc=7; hmac_rc=7; + assert(aesHardware(true,key.data(),destination.data(),message.data(),32)==-1); + for(auto byte:destination) assert(byte==0x79); + assert(!hmacHardware(key.data(),destination.data(),message.data(),32)); + for(auto byte:destination) assert(byte==0x79); + } else if(scenario=="threads") { + assert(mesh::initializeCC310Crypto()); + std::vector workers; + for(unsigned n=0;n<4;n++) workers.emplace_back([]{for(unsigned i=0;i<250;i++) operation_round();}); + for(auto& worker:workers) worker.join(); + assert(lock_depth==0 && takes==gives && begins==1 && creates==1); + } else if(scenario=="isr" || scenario=="init-failure") { + mock_isr=scenario=="isr"; begin_ok=scenario!="init-failure"; + { mesh::CC310CryptoSession session; no_scratch(session); } + std::array key{}, input{}, output{}; output.fill(0x68); + assert(aesHardware(true,key.data(),output.data(),input.data(),32)==-1); + assert(!hmacHardware(key.data(),output.data(),input.data(),32)); + mesh::mixCC310Random(output.data(),output.size()); + for(auto byte:output) assert(byte==0x68); + mesh::Identity identity; std::array signature{}; + assert(identity.verify(signature.data(),input.data(),input.size())); + assert(software_verifies==1 && sdk_calls==0 && takes==gives); + if(mock_isr) assert(creates==0 && begins==0 && takes==0); + else assert(creates==1 && begins==1); + } else if(scenario=="random") { + std::array data{}; data.fill(0xa5); + mesh::mixCC310Random(data.data(),data.size()); + for(auto byte:data) assert(byte==0xff); + random_ok=false; + mesh::mixCC310Random(data.data(),data.size()); + for(auto byte:data) assert(byte==0xff); + assert(begins==1 && takes==gives); + } else assert(false); +} +''' + + +class CC310SharedScratchTests(unittest.TestCase): + @classmethod + def setUpClass(cls): + compiler = shutil.which("g++") or shutil.which("clang++") + if not compiler: + raise RuntimeError("A native C++17 compiler is required") + cls.temporary = tempfile.TemporaryDirectory(prefix="cc310-shared-scratch-") + cls.addClassCleanup(cls.temporary.cleanup) + work = Path(cls.temporary.name) + (work / "nrf_cc310/include").mkdir(parents=True) + (work / "Arduino.h").write_text(ARDUINO) + (work / "Adafruit_nRFCrypto.h").write_text(CRYPTO) + (work / "sdk_types.h").write_text(SDK) + for name in ("crys_ec_edw_api.h", "crys_hash.h", "ssi_aes.h"): + (work / "nrf_cc310/include" / name).write_text('#include "sdk_types.h"\n') + utils = (ROOT / "src/Utils.cpp").read_text() + identity = (ROOT / "src/Identity.cpp").read_text() + callers = "\n".join((body(utils, "int aesHardware("), body(utils, "bool hmacHardware("), + body(identity, "bool Identity::verify("))) + callers = callers.replace("bool Identity::verify(", "bool mesh::Identity::verify(") + source = work / "main.cpp" + source.write_text(HARNESS.replace("@CALLERS@", callers)) + cls.binary = work / "check" + flags = (["-fsanitize=address,undefined", "-fno-sanitize-recover=all", "-fno-pie", "-no-pie"] + if sys.platform.startswith("linux") else []) + result = subprocess.run([compiler, "-std=c++17", "-Wall", "-Wextra", "-Werror", "-pthread", + *flags, "-DUSE_CC310_HW_CRYPTO=1", "-I" + str(work), + "-I" + str(ROOT / "src"), str(source), + str(ROOT / "src/helpers/NRF52Crypto.cpp"), "-o", str(cls.binary)], + capture_output=True, text=True, timeout=60) + if result.returncode: + raise AssertionError(result.stdout + result.stderr) + + def run_case(self, scenario): + result = subprocess.run([str(self.binary), scenario], capture_output=True, text=True, timeout=20) + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + + def test_alternating_types_have_shared_aligned_zero_initialized_storage(self): + self.run_case("alternating") + + def test_production_callers_repeat_roundtrips_and_preserve_error_fallbacks(self): + self.run_case("callers") + + def test_sessions_serialize_shared_scratch_between_threads(self): + self.run_case("threads") + + def test_isr_rejects_workspaces_and_hardware_without_mutating_output(self): + self.run_case("isr") + + def test_cached_initialization_failure_preserves_output_and_software_verify(self): + self.run_case("init-failure") + + def test_entropy_mixing_and_failed_random_output_are_unchanged(self): + self.run_case("random") + + +if __name__ == "__main__": + unittest.main() From c95d227c83f97fe2cf555e0944b1a0732db7e0b4 Mon Sep 17 00:00:00 2001 From: mikecarper Date: Wed, 28 Oct 2026 18:30:58 -0700 Subject: [PATCH 51/61] Keep displays awake for two minutes on first installation --- docs/cli_commands.md | 12 +- examples/companion_radio/MyMesh.cpp | 10 ++ examples/simple_repeater/main.cpp | 7 ++ examples/simple_room_server/main.cpp | 7 ++ examples/simple_sensor/main.cpp | 7 ++ src/helpers/ui/DisplayPowerPolicy.h | 29 +++-- src/helpers/ui/DisplayPowerSettings.h | 2 + test/test_companion_identity_startup.py | 66 +++++++++-- test/test_display_power.py | 150 +++++++++++++++++++++++- test/test_startup_screen.py | 63 ++++++++++ 10 files changed, 335 insertions(+), 18 deletions(-) diff --git a/docs/cli_commands.md b/docs/cli_commands.md index 7916f943a..541e2939f 100644 --- a/docs/cli_commands.md +++ b/docs/cli_commands.md @@ -1455,7 +1455,17 @@ position, automatic mode keeps normal colors. The small integer approximation uses UTC and longitude, so it needs neither a time zone nor daylight-saving configuration. Screen brightness and screen-off timers still work normally. -All display-equipped Companion builds show the screen at boot in the default +On a new installation, display-equipped Companions, repeaters, room servers +and sensors keep the normal UI visible for two minutes after it is ready. +This applies only when a missing identity was created and saved to persistent +storage. Firmware updates and later boots with an existing identity use the +normal screen timings below, even if display settings were never saved. +The setup window does not change saved timeouts; an app connection or USB +power change does not restart or shorten it. Explicit `off` and manual screen +dismissal still apply. Button input can extend the screen using its normal +timeout. + +On later boots, all display-equipped Companion builds show the screen in the default `button` or `button-pairing` mode, on battery or USB power. It stays on for the selected profile's timeout (15 minutes on e-paper, 15 seconds elsewhere). Button input restarts that timer. This also applies to previously saved button modes. Saved `off` and diff --git a/examples/companion_radio/MyMesh.cpp b/examples/companion_radio/MyMesh.cpp index 9512df67f..541a8e8e6 100644 --- a/examples/companion_radio/MyMesh.cpp +++ b/examples/companion_radio/MyMesh.cpp @@ -3174,6 +3174,16 @@ void MyMesh::begin(bool has_display, bool radio_available, #endif true // All Companion transports show startup unless the saved mode forbids it. ); +#ifdef MESHCORE_HAS_REAL_DISPLAY + // A saved identity makes later boots ordinary even if display preferences + // were never changed. RAM-only recovery must not look like a new install. +#if defined(NRF52_PLATFORM) + mesh::ui::displayPowerPrefs().first_install_boot = + !identity_loaded && !_store->isVolatilePrimaryFS(); +#else + mesh::ui::displayPowerPrefs().first_install_boot = !identity_loaded; +#endif +#endif #if COMPANION_FEATURE_NOTIFICATIONS auto& notification_settings = _notifications.settings(); diff --git a/examples/simple_repeater/main.cpp b/examples/simple_repeater/main.cpp index 9da6ba5be..5a4645070 100644 --- a/examples/simple_repeater/main.cpp +++ b/examples/simple_repeater/main.cpp @@ -429,6 +429,13 @@ void setup() { #endif #ifdef DISPLAY_CLASS +#if defined(NRF52_PLATFORM) + mesh::ui::displayPowerPrefs().first_install_boot = + identity_load == IdentityLoadResult::Missing && !volatile_primary_fs; +#else + mesh::ui::displayPowerPrefs().first_install_boot = + identity_load == IdentityLoadResult::Missing; +#endif #if defined(HELTEC_V4_DISPLAY_UNIFIED) && HELTEC_V4_DISPLAY_UNIFIED // Initialize the UI dependency even when auto detection found no screen. // A later saved display.type change can enable it without restarting. diff --git a/examples/simple_room_server/main.cpp b/examples/simple_room_server/main.cpp index 0e5ad2081..2eefb5708 100644 --- a/examples/simple_room_server/main.cpp +++ b/examples/simple_room_server/main.cpp @@ -299,6 +299,13 @@ void setup() { #endif #ifdef DISPLAY_CLASS +#if defined(NRF52_PLATFORM) + mesh::ui::displayPowerPrefs().first_install_boot = + identity_load == IdentityLoadResult::Missing && !volatile_primary_fs; +#else + mesh::ui::displayPowerPrefs().first_install_boot = + identity_load == IdentityLoadResult::Missing; +#endif #if defined(HELTEC_V4_DISPLAY_UNIFIED) && HELTEC_V4_DISPLAY_UNIFIED // Initialize the UI dependency even when auto detection found no screen. // A later saved display.type change can enable it without restarting. diff --git a/examples/simple_sensor/main.cpp b/examples/simple_sensor/main.cpp index caf200835..2e9fc8ca5 100644 --- a/examples/simple_sensor/main.cpp +++ b/examples/simple_sensor/main.cpp @@ -308,6 +308,13 @@ void setup() { #endif #ifdef DISPLAY_CLASS +#if defined(NRF52_PLATFORM) + mesh::ui::displayPowerPrefs().first_install_boot = + identity_load == IdentityLoadResult::Missing && !volatile_primary_fs; +#else + mesh::ui::displayPowerPrefs().first_install_boot = + identity_load == IdentityLoadResult::Missing; +#endif #if defined(HELTEC_V4_DISPLAY_UNIFIED) && HELTEC_V4_DISPLAY_UNIFIED // Initialize the UI dependency even when auto detection found no screen. // A later saved display.type change can enable it without restarting. diff --git a/src/helpers/ui/DisplayPowerPolicy.h b/src/helpers/ui/DisplayPowerPolicy.h index 768ce6835..70b0a05e9 100644 --- a/src/helpers/ui/DisplayPowerPolicy.h +++ b/src/helpers/ui/DisplayPowerPolicy.h @@ -32,6 +32,7 @@ struct DisplayPowerPrefs { DisplayInboxMode inbox = DisplayInboxMode::History; // Runtime role policy, not serialized with the user's display settings. bool wake_on_boot = false; + bool first_install_boot = false; }; inline DisplayPowerPrefs& displayPowerPrefs() { @@ -72,7 +73,9 @@ class DisplayPowerPolicy { DisplayPowerProfile _profile; bool _initialized = false, _usb = false, _connected = false, _pairing = false; bool _timed = false; + bool _first_install_active = false; uint32_t _deadline = 0; + uint32_t _first_install_deadline = 0; public: void update(const DisplayPowerPrefs& prefs, bool usb, bool connected, bool pairing, uint32_t now) { @@ -88,19 +91,28 @@ public: if (reset) { _timed = false; _initialized = true; - // Companions show startup for the normal button timeout. Only the first - // initialization counts; changing profiles must not synthesize input. - if (startup && prefs.wake_on_boot && (_profile.mode == DisplayMode::Button - || _profile.mode == DisplayMode::ButtonPairing)) { - wake(DisplayWake::Button, now); - } else { - wake(DisplayWake::Boot, now); + if (startup && prefs.first_install_boot && _profile.mode != DisplayMode::Off) { + // Give a new installation time for setup after the normal UI is ready. + // This is independent of saved timeouts, app connections and USB power. + _first_install_active = true; + _first_install_deadline = now + 120000UL; + } else if (!_first_install_active) { + // Companions show startup for the normal button timeout. Only the first + // initialization counts; changing profiles must not synthesize input. + if (startup && prefs.wake_on_boot && (_profile.mode == DisplayMode::Button + || _profile.mode == DisplayMode::ButtonPairing)) { + wake(DisplayWake::Button, now); + } else { + wake(DisplayWake::Boot, now); + } } } else if (timeout_changed && _timed) { _deadline = now + uint32_t(_profile.seconds) * 1000; } if (just_connected && _profile.mode == DisplayMode::Automatic) _timed = false; if (_timed && int32_t(now - _deadline) >= 0) _timed = false; + if (_first_install_active && (_profile.mode == DisplayMode::Off + || int32_t(now - _first_install_deadline) >= 0)) _first_install_active = false; } bool wake(DisplayWake reason, uint32_t now) { if (!_initialized) return false; @@ -119,10 +131,11 @@ public: _deadline = now + duration; return true; } - void dismiss() { _timed = false; } + void dismiss() { _timed = _first_install_active = false; } bool on() const { if (!_initialized || _profile.mode == DisplayMode::Off) return false; if (_profile.mode == DisplayMode::On) return true; + if (_first_install_active) return true; if (_pairing && (_profile.mode == DisplayMode::Automatic || displayModeSupportsPairing(_profile.mode))) return true; return _profile.mode != DisplayMode::Pairing && _timed; diff --git a/src/helpers/ui/DisplayPowerSettings.h b/src/helpers/ui/DisplayPowerSettings.h index a44f8bab9..c87198853 100644 --- a/src/helpers/ui/DisplayPowerSettings.h +++ b/src/helpers/ui/DisplayPowerSettings.h @@ -9,6 +9,8 @@ namespace mesh { namespace ui { // Separate, versioned file: existing node and MQTT preference layouts stay // readable by older firmware. Returns true when a saved profile was loaded. // Companion startup enables a timed boot screen without changing saved modes. +// Roles arm first_install_boot after a missing identity is saved successfully; +// the initial setup window is runtime-only and is never written to this file. // Headless builds initialize runtime defaults and never access this file. bool loadDisplayPowerSettings(FILESYSTEM* fs, bool pairing_supported, bool wake_on_boot = false); diff --git a/test/test_companion_identity_startup.py b/test/test_companion_identity_startup.py index 15aeb22cc..bce99220a 100644 --- a/test/test_companion_identity_startup.py +++ b/test/test_companion_identity_startup.py @@ -23,7 +23,15 @@ HARNESS = r''' #include #define DISPLAY_CLASS TestDisplay #define MESH_DEBUG_PRINTLN(...) do {} while (0) +#include +unsigned display_loads = 0; namespace mesh { namespace ui { +bool loadDisplayPowerSettings(void*, bool, bool wake_on_boot) { + ++display_loads; + displayPowerPrefs() = DisplayPowerPrefs(); // No /display_prefs on either boot. + displayPowerPrefs().wake_on_boot = wake_on_boot; + return false; +} struct StartupScreen { std::string status; std::vector phases; @@ -42,6 +50,9 @@ struct Board { } board; struct Store { bool loaded = true, allowed = true, save_ok = true; +#if defined(NRF52_PLATFORM) + bool volatile_fs = false; +#endif unsigned saves = 0; uint8_t prefix = 0x5a; bool loadMainIdentity(mesh::LocalIdentity& identity) { @@ -50,6 +61,10 @@ struct Store { return loaded; } bool canCreateMainIdentity() const { return allowed; } + void* getPrimaryFS() const { return nullptr; } +#if defined(NRF52_PLATFORM) + bool isVolatilePrimaryFS() const { return volatile_fs; } +#endif bool saveMainIdentity(const mesh::LocalIdentity&) { assert(screen.status == "Saving identity"); assert(mesh::identityGenerationProgress().callback == nullptr); @@ -71,14 +86,17 @@ static void bootIdentity() { Store* _store = &store; mesh::ui::StartupScreen* startup_screen = &screen; @IDENTITY_BRANCH@ + @DISPLAY_STARTUP@ } static void reset() { screen = {}; store = {}; board = {}; generated = 0; + display_loads = 0; valid_generated_key = true; self_id = {}; + mesh::ui::displayPowerPrefs() = mesh::ui::DisplayPowerPrefs(); } int main() { reset(); @@ -87,6 +105,7 @@ int main() { assert(generated == 0 && store.saves == 0 && board.reboots == 0); assert(self_id.pub_key[0] == 0x5a); assert((screen.phases == std::vector{"Loading identity", "Starting..."})); + assert(display_loads == 1 && !mesh::ui::displayPowerPrefs().first_install_boot); reset(); store.loaded = false; @@ -94,6 +113,18 @@ int main() { assert(generated == 1 && store.saves == 1 && board.reboots == 0); assert((screen.phases == std::vector{ "Loading identity", "Generating key", "Saving identity", "Starting..."})); + assert(display_loads == 1 && mesh::ui::displayPowerPrefs().first_install_boot); + assert(mesh::ui::displayPowerPrefs().battery.seconds == 15); + assert(mesh::ui::displayPowerPrefs().usb.seconds == 15); + + // The newly saved identity retires the exception on the next boot, even + // when no display preferences have ever been saved. + store.loaded = true; + store.prefix = self_id.pub_key[0]; + screen = {}; + bootIdentity(); + assert(generated == 1 && store.saves == 1 && board.reboots == 0); + assert(display_loads == 2 && !mesh::ui::displayPowerPrefs().first_install_boot); reset(); store.loaded = false; @@ -101,11 +132,22 @@ int main() { bootIdentity(); assert(generated == 0 && store.saves == 0 && board.reboots == 1); assert((screen.phases == std::vector{"Loading identity"})); + assert(display_loads == 0 && !mesh::ui::displayPowerPrefs().first_install_boot); reset(); store.prefix = 0xff; // Existing reserved-prefix policy is unchanged. bootIdentity(); assert(generated == 1 && store.saves == 1 && self_id.pub_key[0] == 0x42); + assert(display_loads == 1 && !mesh::ui::displayPowerPrefs().first_install_boot); + +#if defined(NRF52_PLATFORM) + reset(); + store.loaded = false; + store.volatile_fs = true; // Recovery RAM cannot make startup once-only. + bootIdentity(); + assert(generated == 1 && store.saves == 1 && board.reboots == 0); + assert(display_loads == 1 && !mesh::ui::displayPowerPrefs().first_install_boot); +#endif reset(); store.loaded = false; @@ -113,6 +155,7 @@ int main() { bootIdentity(); assert(generated == 1 && store.saves == 1 && board.reboots == 1); assert(screen.status == "Starting..."); + assert(display_loads == 0 && !mesh::ui::displayPowerPrefs().first_install_boot); reset(); store.loaded = false; @@ -121,6 +164,7 @@ int main() { assert(generated == mesh::MAX_LOCAL_IDENTITY_GENERATION_ATTEMPTS); assert(store.saves == 0 && board.reboots == 1 && screen.status == "Starting..."); assert(mesh::identityGenerationProgress().callback == nullptr); + assert(display_loads == 0 && !mesh::ui::displayPowerPrefs().first_install_boot); } ''' @@ -132,18 +176,24 @@ class CompanionIdentityStartupTests(unittest.TestCase): start = source.index("#ifdef DISPLAY_CLASS\n if (startup_screen != nullptr) startup_screen->loadingIdentity();", begin) end = source.index("#if defined(ENABLE_OTA)\n mesh::ota::ota_refresh_seeder_identity", start) program = HARNESS.replace("@IDENTITY_BRANCH@", source[start:end]) + display_start = source.index(" mesh::ui::loadDisplayPowerSettings(", end) + display_end = source.index("\n#if COMPANION_FEATURE_NOTIFICATIONS", display_start) + program = program.replace("@DISPLAY_STARTUP@", source[display_start:display_end]) with tempfile.TemporaryDirectory(prefix="mesh-identity-startup-") as temp: cpp, binary = Path(temp) / "test.cpp", Path(temp) / "test" (Path(temp) / "Identity.h").write_text(IDENTITY_STUB) cpp.write_text(program) - result = subprocess.run([ - "c++", "-std=c++17", "-Wall", "-Wextra", "-Werror", - "-fsanitize=address,undefined", "-fno-pie", "-no-pie", - "-I", temp, "-I", str(ROOT / "src"), str(cpp), "-o", str(binary), - ], text=True, capture_output=True) - self.assertEqual(result.returncode, 0, result.stderr) - result = subprocess.run([str(binary)], text=True, capture_output=True) - self.assertEqual(result.returncode, 0, result.stderr) + for definitions in ([], ["-DNRF52_PLATFORM"]): + with self.subTest(definitions=definitions): + result = subprocess.run([ + "c++", "-std=c++17", "-Wall", "-Wextra", "-Werror", + "-fsanitize=address,undefined", "-fno-pie", "-no-pie", + *definitions, + "-I", temp, "-I", str(ROOT / "src"), str(cpp), "-o", str(binary), + ], text=True, capture_output=True, timeout=60) + self.assertEqual(result.returncode, 0, result.stderr) + result = subprocess.run([str(binary)], text=True, capture_output=True, timeout=10) + self.assertEqual(result.returncode, 0, result.stderr) if __name__ == "__main__": diff --git a/test/test_display_power.py b/test/test_display_power.py index c493e8678..ebc49fccc 100644 --- a/test/test_display_power.py +++ b/test/test_display_power.py @@ -231,6 +231,137 @@ void companionBootTests() { assert(!rollover.on()); } } +void firstInstallBootTests() { + // The first-install window begins when the UI takes ownership, even after + // long radio/key-generation startup, and has exact wrap-safe boundaries. + for (bool companion : {false, true}) for (bool usb : {false, true}) { + for (bool connected : {false, true}) for (unsigned mode = 0; mode <= 5; ++mode) { + for (uint32_t start : {0U, 800000U, UINT32_MAX - 500U}) { + DisplayPowerPrefs prefs; + prefs.wake_on_boot = companion; + prefs.first_install_boot = true; + prefs.battery = prefs.usb = {DisplayMode(mode), 900}; + DisplayPowerPolicy policy; + policy.update(prefs, usb, connected, false, start); + assert(policy.on() == (mode != 0)); + policy.update(prefs, usb, connected, false, start + 119999U); + assert(policy.on() == (mode != 0)); + policy.update(prefs, usb, connected, false, start + 120000U); + assert(policy.on() == (mode == 1)); + policy.update(prefs, usb, connected, false, start + 900000U); + assert(policy.on() == (mode == 1)); // Refreshes cannot restart it. + assert(prefs.battery.seconds == 900 && prefs.usb.seconds == 900); + } + } + } + + DisplayPowerPrefs prefs; + prefs.wake_on_boot = true; + prefs.first_install_boot = true; + prefs.battery = {DisplayMode::Automatic, 27}; + prefs.usb = {DisplayMode::ButtonPairing, 123}; + const uint32_t start = 700000U; + DisplayPowerPolicy connected; + connected.update(prefs, false, false, false, start); + connected.update(prefs, false, true, false, start + 1000U); + assert(connected.on()); // An app cannot dismiss the first-install window. + connected.update(prefs, true, true, false, start + 30000U); + assert(connected.on()); + connected.update(prefs, false, true, false, start + 119500U); + connected.update(prefs, false, true, false, start + 119999U); + assert(connected.on()); + connected.update(prefs, false, true, false, start + 120000U); + assert(!connected.on()); // USB changes do not restart the window. + connected.update(prefs, false, false, false, start + 120001U); + assert(!connected.on()); + + // Ordinary input still uses the selected timeout. It cannot shorten the + // first window, and input near its end can keep the screen awake longer. + for (DisplayWake reason : {DisplayWake::Button, DisplayWake::Message}) { + DisplayPowerPolicy input; + input.update(prefs, false, false, false, start); + assert(input.wake(reason, start + 10000U)); + input.update(prefs, false, false, false, start + 37000U); + assert(input.on()); + assert(input.wake(reason, start + 110000U)); + input.update(prefs, false, false, false, start + 120000U); + assert(input.on()); + input.update(prefs, false, false, false, start + 136999U); + assert(input.on()); + input.update(prefs, false, false, false, start + 137000U); + assert(!input.on()); + } + + DisplayPowerPolicy dismissed; + dismissed.update(prefs, false, false, false, start); + dismissed.dismiss(); + dismissed.update(prefs, false, false, false, start + 1U); + assert(!dismissed.on()); + dismissed.update(prefs, true, false, false, start + 2U); + assert(!dismissed.on()); + + DisplayPowerPolicy turned_off; + turned_off.update(prefs, false, false, false, start); + prefs.usb.mode = DisplayMode::Off; + turned_off.update(prefs, true, false, true, start + 1U); + assert(!turned_off.on()); + prefs.battery.mode = DisplayMode::Button; + turned_off.update(prefs, false, false, false, start + 2U); + assert(!turned_off.on()); // Off cancels the first window permanently. + assert(turned_off.wake(DisplayWake::Button, start + 3U)); + turned_off.update(prefs, false, false, false, start + 27003U); + assert(!turned_off.on()); + + DisplayPowerPolicy initialized; + prefs.first_install_boot = false; + initialized.update(prefs, false, false, false, start); + prefs.first_install_boot = true; + initialized.update(prefs, false, false, false, start + 27000U); + assert(!initialized.on()); // Only the first policy update can arm startup. +} +void firstInstallSettingsTests() { + for (bool ble : {false, true}) for (bool companion : {false, true}) { + FakeFS fs; + assert(!loadDisplayPowerSettings(&fs, ble, companion)); + assert(!displayPowerPrefs().first_install_boot); + displayPowerPrefs().first_install_boot = true; + assert(displayPowerPrefs().battery.seconds == 15 && displayPowerPrefs().usb.seconds == 15); + DisplayPowerPolicy first; + first.update(displayPowerPrefs(), false, true, false, 500000U); + first.update(displayPowerPrefs(), false, true, false, 619999U); + assert(first.on()); + first.update(displayPowerPrefs(), false, true, false, 620000U); + assert(!first.on()); + assert(fs.files.empty()); // First-install display time writes no settings. + + // A later boot is ordinary even while /display_prefs is still absent. + assert(!loadDisplayPowerSettings(&fs, ble, companion)); + assert(!displayPowerPrefs().first_install_boot); + DisplayPowerPolicy next; + next.update(displayPowerPrefs(), false, false, false, 0); + assert(next.on() == companion); + next.update(displayPowerPrefs(), false, false, false, 15000U); + assert(!next.on()); + + displayPowerPrefs().first_install_boot = true; + char reply[160]; + assert(handleDisplayPowerCommand("set display.timeout 27", reply, sizeof(reply))); + assert(std::string(reply) == "OK"); + assert(displayPowerPrefs().first_install_boot); + const auto saved = fs.files.at("/display_prefs"); + assert(saved.size() == 12 && saved[2] == 2 && saved[10] == 0); + assert(saved[4] == 27 && saved[5] == 0 && saved[7] == 15 && saved[8] == 0); + assert(loadDisplayPowerSettings(&fs, ble, companion)); + assert(!displayPowerPrefs().first_install_boot); // Never serialized by CLI saves. + assert(displayPowerPrefs().battery.seconds == 27 && displayPowerPrefs().usb.seconds == 15); + assert(fs.files.at("/display_prefs") == saved); + DisplayPowerPolicy saved_boot; + saved_boot.update(displayPowerPrefs(), false, false, false, 0); + assert(saved_boot.on() == companion); + saved_boot.update(displayPowerPrefs(), false, false, false, 27000U); + assert(!saved_boot.on()); + } +} void companionSettingsTests() { for (bool ble : {false, true}) { FakeFS fs; @@ -368,7 +499,8 @@ void commandTests() { run("get display.usb.timeout", "> 42"); } int main() { - policyTests(); companionBootTests(); companionSettingsTests(); commandTests(); + policyTests(); companionBootTests(); firstInstallBootTests(); + firstInstallSettingsTests(); companionSettingsTests(); commandTests(); } ''' @@ -445,12 +577,14 @@ int main() { displayPowerPrefs().battery = {DisplayMode::Off, 123}; displayPowerPrefs().usb = {DisplayMode::On, 456}; displayPowerPrefs().inbox = DisplayInboxMode::Unread; + displayPowerPrefs().first_install_boot = true; assert(!loadDisplayPowerSettings(&fs, pairing, wake)); const auto& prefs = displayPowerPrefs(); const auto mode = pairing ? DisplayMode::ButtonPairing : DisplayMode::Button; assert(prefs.battery.mode == mode && prefs.usb.mode == mode); assert(prefs.battery.seconds == 15 && prefs.usb.seconds == 15); assert(prefs.inbox == DisplayInboxMode::History && prefs.wake_on_boot == wake); + assert(!prefs.first_install_boot); assert(displayPairingSupported() == pairing); for (const char* key : {"display.mode", "display.timeout", "display.usb.mode", "display.usb.timeout", "display.inbox"}) { @@ -496,6 +630,20 @@ int main() { assert(!loadDisplayPowerSettings(&fs, ble, companion)); const auto& prefs = displayPowerPrefs(); assert(prefs.battery.seconds == 900 && prefs.usb.seconds == 900); + displayPowerPrefs().first_install_boot = true; + DisplayPowerPolicy first; + first.update(prefs, false, true, false, 500000U); + first.update(prefs, false, true, false, 619999U); + assert(first.on()); + first.update(prefs, false, true, false, 620000U); + assert(!first.on()); // E-paper gets the same two-minute first startup. + assert(prefs.battery.seconds == 900 && prefs.usb.seconds == 900); + assert(first.wake(DisplayWake::Button, 620001U)); + first.update(prefs, false, true, false, 1520000U); + assert(first.on()); + first.update(prefs, false, true, false, 1520001U); + assert(!first.on()); // Later button wakes retain the 15-minute default. + displayPowerPrefs().first_install_boot = false; for (bool usb : {false, true}) { DisplayPowerPolicy policy; const uint32_t start = UINT32_MAX - 1000; diff --git a/test/test_startup_screen.py b/test/test_startup_screen.py index 58994cf2f..255b196c7 100644 --- a/test/test_startup_screen.py +++ b/test/test_startup_screen.py @@ -13,6 +13,69 @@ ROOT = Path(__file__).resolve().parents[1] class StartupScreenTest(unittest.TestCase): + def test_infrastructure_first_install_window_follows_durable_identity_and_final_prefs(self): + flags = [] + for role in ("simple_repeater", "simple_room_server", "simple_sensor"): + source = (ROOT / "examples" / role / "main.cpp").read_text() + setup = extract_braced(source, "void setup()") + assignment = setup.index("displayPowerPrefs().first_install_boot") + start = setup.rfind("#if defined(NRF52_PLATFORM)", 0, assignment) + end = setup.index("\n#endif", assignment) + len("\n#endif") + with self.subTest(role=role): + self.assertLess(setup.index("store.saveWithRetry("), assignment) + self.assertLess(setup.index("if (!identity_ready)"), assignment) + self.assertLess(setup.index("the_mesh.begin(fs)"), assignment) + self.assertLess(assignment, setup.index("ui_task.begin(")) + self.assertTrue(setup[:start].endswith("#ifdef DISPLAY_CLASS\n")) + # Execute the actual conditional assignment, including the real + # volatile-filesystem guard, for each infrastructure role. + flags.append("void arm_" + role + r'''(IdentityLoadResult identity_load, + bool volatile_primary_fs) { + (void)identity_load; + (void)volatile_primary_fs; +#ifdef DISPLAY_CLASS +''' + setup[start:end] + "\n#endif\n}\n") + program = r''' +#include +#include +#include +enum class IdentityLoadResult { Missing, Loaded, Unreadable }; +@FLAGS@ +int main() { + for (auto arm : {arm_simple_repeater, arm_simple_room_server, arm_simple_sensor}) { + for (auto identity : {IdentityLoadResult::Missing, IdentityLoadResult::Loaded, + IdentityLoadResult::Unreadable}) { + for (bool volatile_fs : {false, true}) { + mesh::ui::displayPowerPrefs() = mesh::ui::DisplayPowerPrefs(); + arm(identity, volatile_fs); + bool expected = false; +#ifdef DISPLAY_CLASS + expected = identity == IdentityLoadResult::Missing; +#if defined(NRF52_PLATFORM) + expected = expected && !volatile_fs; +#endif +#endif + assert(mesh::ui::displayPowerPrefs().first_install_boot == expected); + } + } + } +} +'''.replace("@FLAGS@", "\n".join(flags)) + with tempfile.TemporaryDirectory(prefix="mesh-first-install-flags-") as directory: + cpp, exe = Path(directory) / "test.cpp", Path(directory) / "test" + cpp.write_text(program, encoding="ascii") + for definitions in ([], ["NRF52_PLATFORM"], ["DISPLAY_CLASS=TestDisplay"], + ["NRF52_PLATFORM", "DISPLAY_CLASS=TestDisplay"]): + with self.subTest(definitions=definitions): + built = subprocess.run([ + "c++", "-std=c++17", "-Wall", "-Wextra", "-Werror", + *["-D" + flag for flag in definitions], + "-I", str(ROOT / "src"), str(cpp), "-o", str(exe)], + capture_output=True, text=True, timeout=60) + self.assertEqual(built.returncode, 0, built.stderr) + checked = subprocess.run([str(exe)], capture_output=True, text=True, timeout=10) + self.assertEqual(checked.returncode, 0, checked.stderr) + def test_screen_precedes_radio_keygen_and_settle_delays(self): for role in ("companion_radio", "simple_repeater", "simple_room_server", "simple_sensor"): with self.subTest(role=role): From 5d45d8a270f3f6bd31be055e83a91787ce2d8363 Mon Sep 17 00:00:00 2001 From: mikecarper Date: Wed, 28 Oct 2026 19:13:44 -0700 Subject: [PATCH 52/61] Fix CI radio harnesses and constrained firmware builds --- .github/workflows/run-unit-tests.yml | 7 + examples/companion_radio/MyMesh.cpp | 106 ++--- examples/simple_repeater/MyMesh.cpp | 48 +-- lib/ed25519/fe.c | 4 + lib/ed25519/fe_compact.h | 48 +++ scripts/nrf52_flash_trim.py | 18 +- src/helpers/CLIReplyCopy.cpp | 11 + src/helpers/CLIReplyCopy.h | 14 + src/helpers/CommonCLI.cpp | 81 ++-- test/fixtures/cli_reply_text.json | 469 ++++++++++++++++++++- test/fixtures/trace_profile_timing.cpp | 1 + test/test_admin_cli_reply_scope.py | 1 + test/test_cli_reply_text.py | 7 +- test/test_ed25519_compact_field.py | 138 ++++++ test/test_mini_repeater_logging.py | 1 + test/test_nrf52_flash_trim.py | 95 ++++- test/test_ota_zero_hop_route.py | 1 + test/test_power_saving_role_diagnostics.py | 21 +- test/test_radio_tx_power_transaction.py | 1 + test/test_repeater_discovery.py | 1 + test/test_reply_tx_integration.py | 1 + 21 files changed, 932 insertions(+), 142 deletions(-) create mode 100644 src/helpers/CLIReplyCopy.cpp create mode 100644 src/helpers/CLIReplyCopy.h diff --git a/.github/workflows/run-unit-tests.yml b/.github/workflows/run-unit-tests.yml index 6075053d8..067c33574 100644 --- a/.github/workflows/run-unit-tests.yml +++ b/.github/workflows/run-unit-tests.yml @@ -25,6 +25,7 @@ jobs: - name: Verify linked Companion crypto, retained Reader and ARM stack use run: | python3 -m pip install unicorn==2.1.4 + python3 -B test/test_cc310_shared_scratch.py -v python3 -B scripts/check_nrf52_companion_crypto.py .pio/build/RAK_4631_companion_radio_full/firmware.elf python3 -B scripts/check_nrf52_companion_reader.py .pio/build/RAK_4631_companion_radio_full/firmware.elf python3 -B scripts/check_nrf52_companion_ota_workspace.py .pio/build/RAK_4631_companion_radio_full/firmware.elf @@ -252,6 +253,7 @@ jobs: - name: Verify compact STM32 signature arithmetic run: | python3 -B test/test_stm32_companion_profile.py -v + python3 -B test/test_cli_reply_text.py -v python3 -B test/test_ed25519_compact_scalar.py -v python3 -B test/test_ed25519_compact_field.py -v python3 -B test/test_ed25519_compact.py -v @@ -269,6 +271,11 @@ jobs: --radio-preset usa-cascade-fixed --profile cascade --require-ota done + - name: Verify compact field arithmetic and stack on ARM + run: | + python3 -m pip install unicorn==2.1.4 + python3 -B test/test_ed25519_compact_field.py Ed25519CompactFieldTest.test_arm_shared_powers_match_oracle_aliases_and_stay_within_stack_budget -v + stm32-mini-repeater-smoke: runs-on: ubuntu-latest steps: diff --git a/examples/companion_radio/MyMesh.cpp b/examples/companion_radio/MyMesh.cpp index 541a8e8e6..54b45fddd 100644 --- a/examples/companion_radio/MyMesh.cpp +++ b/examples/companion_radio/MyMesh.cpp @@ -162,6 +162,8 @@ static bool isCompanionReaderRequest(const char* command) { #include #endif +#include + static uint32_t nextRadioApplyRetryDelay(uint8_t& failure_count) { uint8_t shift = failure_count < 5 ? failure_count : 5; if (failure_count < 6) failure_count++; @@ -4620,7 +4622,7 @@ bool MyMesh::handleLocalControlCommand(const char* command, char* reply, if (strcmp(value, "on") != 0 && strcmp(value, "off") != 0) { mesh::cli::formatError(reply, reply_size, MESH_CLI_REPLY_TEXT("use set radio.fem.rxgain on|off", "set radio.fem.rxgain on|off")); } else if (_radio && (_radio->isMorseTxActive() || _radio->isMorseRxActive())) { - mesh::cli::formatError(reply, reply_size, "stop Morse before changing LoRa FEM gain"); + mesh::cli::formatError(reply, reply_size, MESH_CLI_REPLY_TEXT("stop Morse before changing LoRa FEM gain", "stop Morse before FEM gain")); } else if (!board.canControlLoRaFemLna()) { mesh::cli::formatError(reply, reply_size, "unsupported"); } else if (!applyAndSaveFemRxGain(strcmp(value, "on") == 0)) { @@ -4650,7 +4652,7 @@ bool MyMesh::handleLocalControlCommand(const char* command, char* reply, if (strcmp(value, "on") != 0 && strcmp(value, "off") != 0) { mesh::cli::formatError(reply, reply_size, MESH_CLI_REPLY_TEXT("use set radio.fem.txgain on|off", "set radio.fem.txgain on|off")); } else if (_radio && (_radio->isMorseTxActive() || _radio->isMorseRxActive())) { - mesh::cli::formatError(reply, reply_size, "stop Morse before changing LoRa FEM gain"); + mesh::cli::formatError(reply, reply_size, MESH_CLI_REPLY_TEXT("stop Morse before changing LoRa FEM gain", "stop Morse before FEM gain")); } else if (!board.canControlLoRaFemPaGain()) { snprintf(reply, reply_size, "%s", board.isLoRaFemPaGainEnabled() ? "Error: TX PA is fixed on; use set tx to adjust power" @@ -5958,11 +5960,11 @@ void MyMesh::execCommand(char* cmd, char* reply) { if (!wcParseBool(value, enabled)) { mesh::cli::formatError(reply, 160, "must be on or off"); } else if (_radio && (_radio->isMorseTxActive() || _radio->isMorseRxActive())) { - mesh::cli::formatError(reply, 160, "stop Morse before changing LoRa FEM gain"); + mesh::cli::formatError(reply, 160, MESH_CLI_REPLY_TEXT("stop Morse before changing LoRa FEM gain", "stop Morse before FEM gain")); } else if (!board.canControlLoRaFemLna()) { mesh::cli::formatError(reply, 160, "unsupported"); } else if (!applyAndSaveFemRxGain(enabled)) { - mesh::cli::formatError(reply, 160, "failed to apply FEM RX gain"); + mesh::cli::formatError(reply, 160, MESH_CLI_REPLY_TEXT("failed to apply FEM RX gain", "FEM RX gain failed")); } else { strcpy(reply, "OK"); } @@ -5973,11 +5975,11 @@ void MyMesh::execCommand(char* cmd, char* reply) { if (!wcParseBool(value, enabled)) { mesh::cli::formatError(reply, 160, "must be on or off"); } else if (_radio && (_radio->isMorseTxActive() || _radio->isMorseRxActive())) { - mesh::cli::formatError(reply, 160, "stop Morse before changing LoRa FEM gain"); + mesh::cli::formatError(reply, 160, MESH_CLI_REPLY_TEXT("stop Morse before changing LoRa FEM gain", "stop Morse before FEM gain")); } else if (!board.canControlLoRaFemPaGain()) { mesh::cli::formatError(reply, 160, "unsupported"); } else if (!applyAndSaveFemTxGain(enabled)) { - mesh::cli::formatError(reply, 160, "failed to apply FEM TX gain"); + mesh::cli::formatError(reply, 160, MESH_CLI_REPLY_TEXT("failed to apply FEM TX gain", "FEM TX gain failed")); } else { strcpy(reply, "OK"); } @@ -9912,19 +9914,19 @@ void MyMesh::handleTerminalPath(ContactInfo& recipient, case mesh::cli::TerminalPathParseResult::Valid: break; case mesh::cli::TerminalPathParseResult::Missing: - terminalOutput().print(" ERROR: use path \r\n"); + terminalOutput().print(MESH_CLI_REPLY_TEXT(" ERROR: use path \r\n", " ERROR: path \r\n")); return; case mesh::cli::TerminalPathParseResult::InvalidPrefix: - terminalOutput().print(" ERROR: each path hop must be 2, 4, or 6 hex digits\r\n"); + terminalOutput().print(MESH_CLI_REPLY_TEXT(" ERROR: each path hop must be 2, 4, or 6 hex digits\r\n", " ERROR: path hops need 2, 4, or 6 hex digits\r\n")); return; case mesh::cli::TerminalPathParseResult::MixedPrefixSize: - terminalOutput().print(" ERROR: all path hops must use the same width\r\n"); + terminalOutput().print(MESH_CLI_REPLY_TEXT(" ERROR: all path hops must use the same width\r\n", " ERROR: path hops need equal widths\r\n")); return; case mesh::cli::TerminalPathParseResult::InvalidSeparator: - terminalOutput().print(" ERROR: separate path hops with spaces or commas\r\n"); + terminalOutput().print(MESH_CLI_REPLY_TEXT(" ERROR: separate path hops with spaces or commas\r\n", " ERROR: separate hops with spaces/commas\r\n")); return; case mesh::cli::TerminalPathParseResult::TooManyHops: - terminalOutput().print(" ERROR: path must contain at most 63 hops\r\n"); + terminalOutput().print(MESH_CLI_REPLY_TEXT(" ERROR: path must contain at most 63 hops\r\n", " ERROR: path max 63 hops\r\n")); return; case mesh::cli::TerminalPathParseResult::RouteTooLong: terminalOutput().print(" ERROR: path is too long\r\n"); @@ -10079,7 +10081,7 @@ void MyMesh::sendTerminalChannelMessage(ChannelDetails& channel, #endif if (isAliasRadio2Defined() && parseCompanionChannelAlias(channel.name).kind == CompanionChannelKind::Meshtext) { - terminalOutput().print(" ERROR: Meshtext channel transport is not available\r\n"); + terminalOutput().print(MESH_CLI_REPLY_TEXT(" ERROR: Meshtext channel transport is not available\r\n", " ERROR: Meshtext channel unavailable\r\n")); return; } const size_t prefix_len = strlen(_prefs.node_name) + 2; // "name: " @@ -10089,7 +10091,7 @@ void MyMesh::sendTerminalChannelMessage(ChannelDetails& channel, if (text_len == 0) { terminalOutput().print(" ERROR: message is empty\r\n"); } else if (text_len > max_text_len) { - terminalOutput().printf(" ERROR: message must be 1-%u UTF-8 bytes for this node name\r\n", + terminalOutput().printf(MESH_CLI_REPLY_TEXT(" ERROR: message must be 1-%u UTF-8 bytes for this node name\r\n", " ERROR: message 1-%u UTF-8 bytes for node name\r\n"), (unsigned)max_text_len); } else if (sendGroupMessage(getRTCClock()->getCurrentTimeUnique(), channel.channel, _prefs.node_name, text, @@ -10174,7 +10176,7 @@ void MyMesh::sendTerminalLogin(ContactInfo& recipient, } if (hasPendingReqs()) { terminalOutput().print( - " ERROR: another Companion request is still pending\r\n"); + MESH_CLI_REPLY_TEXT(" ERROR: another Companion request is still pending\r\n", " ERROR: Companion request pending\r\n")); return; } @@ -10237,7 +10239,7 @@ void MyMesh::sendTerminalCommand(ContactInfo& recipient, serviceTerminalCommand(); if (_terminal_command.isPending()) { - terminalOutput().printf(" ERROR: command to %s is still pending\r\n", + terminalOutput().printf(MESH_CLI_REPLY_TEXT(" ERROR: command to %s is still pending\r\n", " ERROR: command to %s pending\r\n"), _terminal_command_target); return; } @@ -10255,7 +10257,7 @@ void MyMesh::sendTerminalCommand(ContactInfo& recipient, const size_t command_len = on_air_command == NULL ? 0 : strlen(on_air_command); if (command_len == 0 || command_len > MAX_CORRELATED_CLI_TEXT_LEN) { - terminalOutput().printf(" ERROR: remote command must be 1-%u UTF-8 bytes\r\n", + terminalOutput().printf(MESH_CLI_REPLY_TEXT(" ERROR: remote command must be 1-%u UTF-8 bytes\r\n", " ERROR: remote command 1-%u UTF-8 bytes\r\n"), (unsigned)MAX_CORRELATED_CLI_TEXT_LEN); return; } @@ -10277,7 +10279,7 @@ void MyMesh::sendTerminalCommand(ContactInfo& recipient, txt_type, on_air_command, est_timeout, logical_request_id); if (result == MSG_SEND_FAILED) { - terminalOutput().print(" ERROR: unable to send remote command\r\n"); + terminalOutput().print(MESH_CLI_REPLY_TEXT(" ERROR: unable to send remote command\r\n", " ERROR: remote command send failed\r\n")); return; } @@ -10329,12 +10331,12 @@ void MyMesh::sendTerminalTraceRoute(const uint8_t* route, uint8_t hash_size, uint8_t hop_count, const char* target) { serviceTerminalTrace(); if (_terminal_trace_pending) { - terminalOutput().printf(" ERROR: trace to %s is still pending\r\n", + terminalOutput().printf(MESH_CLI_REPLY_TEXT(" ERROR: trace to %s is still pending\r\n", " ERROR: trace to %s pending\r\n"), _terminal_trace_target); return; } if (route == NULL || hop_count == 0 || hop_count >= MAX_PATH_SIZE) { - terminalOutput().print(" ERROR: trace path must contain 1-63 prefixes\r\n"); + terminalOutput().print(MESH_CLI_REPLY_TEXT(" ERROR: trace path must contain 1-63 prefixes\r\n", " ERROR: trace path 1-63 prefixes\r\n")); return; } @@ -10346,20 +10348,20 @@ void MyMesh::sendTerminalTraceRoute(const uint8_t* route, uint8_t hash_size, const uint8_t flags = mesh::traceFlagsForHashSize(hash_size); if (flags == 0xFF) { - terminalOutput().print(" ERROR: trace hash size must be 1, 2, or 4 bytes\r\n"); + terminalOutput().print(MESH_CLI_REPLY_TEXT(" ERROR: trace hash size must be 1, 2, or 4 bytes\r\n", " ERROR: trace hashes need 1, 2, or 4 bytes\r\n")); return; } mesh::Packet* packet = createTrace(0, 0, flags); if (packet == NULL) { - terminalOutput().print(" ERROR: unable to allocate trace packet\r\n"); + terminalOutput().print(MESH_CLI_REPLY_TEXT(" ERROR: unable to allocate trace packet\r\n", " ERROR: trace allocation failed\r\n")); return; } const uint32_t airtime = getTransmitAirtime(packet, 2 + packet->payload_len + route_byte_len); if (airtime == 0) { releasePacket(packet); - terminalOutput().print(" ERROR: unable to estimate trace timeout\r\n"); + terminalOutput().print(MESH_CLI_REPLY_TEXT(" ERROR: unable to estimate trace timeout\r\n", " ERROR: trace timeout estimate failed\r\n")); return; } const uint32_t base_timeout = @@ -10370,7 +10372,7 @@ void MyMesh::sendTerminalTraceRoute(const uint8_t* route, uint8_t hash_size, || trace_timeout > 0x7FFFFFFFU - mesh::CompanionDelayedReplies::DELIVERY_GRACE_MS) { releasePacket(packet); - terminalOutput().print(" ERROR: trace timeout is out of range\r\n"); + terminalOutput().print(MESH_CLI_REPLY_TEXT(" ERROR: trace timeout is out of range\r\n", " ERROR: trace timeout invalid\r\n")); return; } @@ -10383,7 +10385,7 @@ void MyMesh::sendTerminalTraceRoute(const uint8_t* route, uint8_t hash_size, const uint8_t history = _delayed_replies.reserveTrace(tag, auth, _ms->getMillis()); if (history == mesh::CompanionDelayedReplies::NO_HISTORY) { releasePacket(packet); - terminalOutput().print(" ERROR: trace tag is quarantined or reply history is full\r\n"); + terminalOutput().print(MESH_CLI_REPLY_TEXT(" ERROR: trace tag is quarantined or reply history is full\r\n", " ERROR: trace quarantined or reply history full\r\n")); return; } if (!sendDirect(packet, route, static_cast(route_byte_len))) { @@ -10421,7 +10423,7 @@ void MyMesh::sendTerminalTrace(ContactInfo& recipient) { path_bytes, recipient.out_path_len, recipient.id.pub_key, include_endpoint, _terminal_tmp_buf, MAX_PACKET_PAYLOAD - 9, route)) { - terminalOutput().print(" ERROR: recipient has no traceable round-trip path\r\n"); + terminalOutput().print(MESH_CLI_REPLY_TEXT(" ERROR: recipient has no traceable round-trip path\r\n", " ERROR: recipient has no trace return path\r\n")); return; } if (route.hop_count >= MAX_PATH_SIZE) { @@ -10446,14 +10448,14 @@ void MyMesh::sendTerminalRawTrace(const char* arguments) { terminalOutput().print(" ERROR: use trace path <1|2|4> \r\n"); return; case mesh::RawTracePathParseResult::InvalidHashSize: - terminalOutput().print(" ERROR: trace hash size must be 1, 2, or 4 bytes\r\n"); + terminalOutput().print(MESH_CLI_REPLY_TEXT(" ERROR: trace hash size must be 1, 2, or 4 bytes\r\n", " ERROR: trace hashes need 1, 2, or 4 bytes\r\n")); return; case mesh::RawTracePathParseResult::InvalidPrefix: - terminalOutput().printf(" ERROR: every prefix must be exactly %u hex digits\r\n", + terminalOutput().printf(MESH_CLI_REPLY_TEXT(" ERROR: every prefix must be exactly %u hex digits\r\n", " ERROR: each prefix needs %u hex digits\r\n"), (unsigned)route.hash_size * 2); return; case mesh::RawTracePathParseResult::TooManyHops: - terminalOutput().print(" ERROR: trace path must contain at most 63 prefixes\r\n"); + terminalOutput().print(MESH_CLI_REPLY_TEXT(" ERROR: trace path must contain at most 63 prefixes\r\n", " ERROR: trace path max 63 prefixes\r\n")); return; case mesh::RawTracePathParseResult::RouteTooLong: terminalOutput().print(" ERROR: trace path is too long\r\n"); @@ -10482,7 +10484,7 @@ void MyMesh::handleTerminalCommand(char* command, bool reader_prompt, #if COMPANION_FEATURE_READER if (mesh::morseTimingActive(_radio) && isCompanionReaderRequest(command)) { mesh::cancelReaderCommand(); - terminalOutput().print(" ERROR: Reader unavailable during Morse\r\n"); + terminalOutput().print(MESH_CLI_REPLY_TEXT(" ERROR: Reader unavailable during Morse\r\n", " ERROR: Reader blocked by Morse\r\n")); return; } if (mesh::handleReaderCommand(command, terminalOutput(), reader_prompt, @@ -10598,9 +10600,9 @@ void MyMesh::handleTerminalCommand(char* command, bool reader_prompt, ContactInfo* recipient = getTerminalRecipient(); const char* text = command + 5; if (recipient == NULL) { - terminalOutput().print(" ERROR: no recipient selected (use 'to' first)\r\n"); + terminalOutput().print(MESH_CLI_REPLY_TEXT(" ERROR: no recipient selected (use 'to' first)\r\n", " ERROR: choose recipient with 'to'\r\n")); } else if (*text == 0 || strlen(text) > MAX_TEXT_LEN) { - terminalOutput().printf(" ERROR: message must be 1-%u characters\r\n", (unsigned)MAX_TEXT_LEN); + terminalOutput().printf(MESH_CLI_REPLY_TEXT(" ERROR: message must be 1-%u characters\r\n", " ERROR: message 1-%u characters\r\n"), (unsigned)MAX_TEXT_LEN); } else { uint32_t expected_ack = 0; uint32_t est_timeout = 0; @@ -10680,7 +10682,7 @@ void MyMesh::handleTerminalCommand(char* command, bool reader_prompt, != mesh::cli::TerminalArgumentCommandMatch::NoMatch) { ContactInfo* recipient = getTerminalRecipient(); if (recipient == NULL) { - terminalOutput().print(" ERROR: no recipient selected (use 'to' first)\r\n"); + terminalOutput().print(MESH_CLI_REPLY_TEXT(" ERROR: no recipient selected (use 'to' first)\r\n", " ERROR: choose recipient with 'to'\r\n")); } else { handleTerminalPath( *recipient, @@ -10694,7 +10696,7 @@ void MyMesh::handleTerminalCommand(char* command, bool reader_prompt, != mesh::cli::TerminalArgumentCommandMatch::Valid) { terminalOutput().print(" ERROR: use login \r\n"); } else if (recipient == NULL) { - terminalOutput().print(" ERROR: no recipient selected (use 'to' first)\r\n"); + terminalOutput().print(MESH_CLI_REPLY_TEXT(" ERROR: no recipient selected (use 'to' first)\r\n", " ERROR: choose recipient with 'to'\r\n")); } else { sendTerminalLogin(*recipient, login_password); } @@ -10705,7 +10707,7 @@ void MyMesh::handleTerminalCommand(char* command, bool reader_prompt, != mesh::cli::TerminalArgumentCommandMatch::Valid) { terminalOutput().print(" ERROR: use cmd \r\n"); } else if (recipient == NULL) { - terminalOutput().print(" ERROR: no recipient selected (use 'to' first)\r\n"); + terminalOutput().print(MESH_CLI_REPLY_TEXT(" ERROR: no recipient selected (use 'to' first)\r\n", " ERROR: choose recipient with 'to'\r\n")); } else { sendTerminalCommand(*recipient, remote_command); } @@ -10729,7 +10731,7 @@ void MyMesh::handleTerminalCommand(char* command, bool reader_prompt, } else { recipient = getTerminalRecipient(); if (recipient == NULL) { - terminalOutput().print(" ERROR: no recipient selected (use 'to' first)\r\n"); + terminalOutput().print(MESH_CLI_REPLY_TEXT(" ERROR: no recipient selected (use 'to' first)\r\n", " ERROR: choose recipient with 'to'\r\n")); return; } } @@ -10791,7 +10793,7 @@ void MyMesh::handleTerminalCommand(char* command, bool reader_prompt, } } else if (strcmp(command, "get radio.fem.rxgain") == 0) { if (!board.canControlLoRaFemLna()) { - terminalOutput().print(" ERROR: FEM RX gain control is unsupported on this board\r\n"); + terminalOutput().print(MESH_CLI_REPLY_TEXT(" ERROR: FEM RX gain control is unsupported on this board\r\n", " ERROR: FEM RX gain unsupported\r\n")); } else { terminalOutput().printf(" FEM RX gain: %s\r\n", board.isLoRaFemLnaEnabled() ? "on" : "off"); @@ -10801,7 +10803,7 @@ void MyMesh::handleTerminalCommand(char* command, bool reader_prompt, if (board.isLoRaFemPaGainEnabled()) { terminalOutput().print(" FEM TX gain: on (fixed TX PA)\r\n"); } else { - terminalOutput().print(" ERROR: FEM TX gain control is unsupported on this board\r\n"); + terminalOutput().print(MESH_CLI_REPLY_TEXT(" ERROR: FEM TX gain control is unsupported on this board\r\n", " ERROR: FEM TX gain unsupported\r\n")); } } else { terminalOutput().printf(" FEM TX gain: %s\r\n", @@ -10841,31 +10843,31 @@ void MyMesh::handleTerminalCommand(char* command, bool reader_prompt, float parsed = 0.0f; if (!mesh::cli::parseDecimalStrict(config + 3, parsed) || parsed < 0.0f || parsed > 9.0f) { - terminalOutput().print(" ERROR: airtime factor must be 0-9\r\n"); + terminalOutput().print(MESH_CLI_REPLY_TEXT(" ERROR: airtime factor must be 0-9\r\n", " ERROR: airtime factor 0-9\r\n")); } else { terminalOutput().print(savePreference(_prefs.airtime_factor, parsed) - ? " OK\r\n" : " ERROR: airtime factor could not be saved\r\n"); + ? " OK\r\n" : MESH_CLI_REPLY_TEXT(" ERROR: airtime factor could not be saved\r\n", " ERROR: airtime factor not saved\r\n")); } } else if (strncmp(config, "name ", 5) == 0 && config[5] != 0) { terminalOutput().print(saveAdvertName(config + 5) - ? " OK\r\n" : " ERROR: name could not be saved\r\n"); + ? " OK\r\n" : MESH_CLI_REPLY_TEXT(" ERROR: name could not be saved\r\n", " ERROR: name not saved\r\n")); } else if (strncmp(config, "lat ", 4) == 0) { float parsed = 0.0f; if (!mesh::cli::parseDecimalStrict(config + 4, parsed) || parsed < -90.0f || parsed > 90.0f) { - terminalOutput().print(" ERROR: latitude must be -90 to 90\r\n"); + terminalOutput().print(MESH_CLI_REPLY_TEXT(" ERROR: latitude must be -90 to 90\r\n", " ERROR: latitude -90 to 90\r\n")); } else { terminalOutput().print(saveAdvertLocation(parsed, sensors.node_lon) - ? " OK\r\n" : " ERROR: latitude could not be saved\r\n"); + ? " OK\r\n" : MESH_CLI_REPLY_TEXT(" ERROR: latitude could not be saved\r\n", " ERROR: latitude not saved\r\n")); } } else if (strncmp(config, "lon ", 4) == 0) { float parsed = 0.0f; if (!mesh::cli::parseDecimalStrict(config + 4, parsed) || parsed < -180.0f || parsed > 180.0f) { - terminalOutput().print(" ERROR: longitude must be -180 to 180\r\n"); + terminalOutput().print(MESH_CLI_REPLY_TEXT(" ERROR: longitude must be -180 to 180\r\n", " ERROR: longitude -180 to 180\r\n")); } else { terminalOutput().print(saveAdvertLocation(sensors.node_lat, parsed) - ? " OK\r\n" : " ERROR: longitude could not be saved\r\n"); + ? " OK\r\n" : MESH_CLI_REPLY_TEXT(" ERROR: longitude could not be saved\r\n", " ERROR: longitude not saved\r\n")); } } else if (strncmp(config, "tx ", 3) == 0) { handleCommand(command, 0, local_reply); @@ -10881,11 +10883,11 @@ void MyMesh::handleTerminalCommand(char* command, bool reader_prompt, if (strcmp(value, "on") != 0 && strcmp(value, "off") != 0) { terminalOutput().print(" ERROR: use set radio.fem.rxgain \r\n"); } else if (_radio && (_radio->isMorseTxActive() || _radio->isMorseRxActive())) { - terminalOutput().print(" ERROR: stop Morse before changing LoRa FEM gain\r\n"); + terminalOutput().print(MESH_CLI_REPLY_TEXT(" ERROR: stop Morse before changing LoRa FEM gain\r\n", " ERROR: stop Morse before FEM gain\r\n")); } else if (!board.canControlLoRaFemLna()) { - terminalOutput().print(" ERROR: FEM RX gain control is unsupported on this board\r\n"); + terminalOutput().print(MESH_CLI_REPLY_TEXT(" ERROR: FEM RX gain control is unsupported on this board\r\n", " ERROR: FEM RX gain unsupported\r\n")); } else if (!applyAndSaveFemRxGain(strcmp(value, "on") == 0)) { - terminalOutput().print(" ERROR: failed to apply FEM RX gain\r\n"); + terminalOutput().print(MESH_CLI_REPLY_TEXT(" ERROR: failed to apply FEM RX gain\r\n", " ERROR: FEM RX gain failed\r\n")); } else { terminalOutput().printf(" OK - FEM RX gain %s\r\n", value); } @@ -10897,13 +10899,13 @@ void MyMesh::handleTerminalCommand(char* command, bool reader_prompt, if (strcmp(value, "on") != 0 && strcmp(value, "off") != 0) { terminalOutput().print(" ERROR: use set radio.fem.txgain \r\n"); } else if (_radio && (_radio->isMorseTxActive() || _radio->isMorseRxActive())) { - terminalOutput().print(" ERROR: stop Morse before changing LoRa FEM gain\r\n"); + terminalOutput().print(MESH_CLI_REPLY_TEXT(" ERROR: stop Morse before changing LoRa FEM gain\r\n", " ERROR: stop Morse before FEM gain\r\n")); } else if (!board.canControlLoRaFemPaGain()) { terminalOutput().print(board.isLoRaFemPaGainEnabled() ? " ERROR: TX PA is fixed on; use set tx to adjust power\r\n" - : " ERROR: FEM TX gain control is unsupported on this board\r\n"); + : MESH_CLI_REPLY_TEXT(" ERROR: FEM TX gain control is unsupported on this board\r\n", " ERROR: FEM TX gain unsupported\r\n")); } else if (!applyAndSaveFemTxGain(strcmp(value, "on") == 0)) { - terminalOutput().print(" ERROR: failed to apply FEM TX gain\r\n"); + terminalOutput().print(MESH_CLI_REPLY_TEXT(" ERROR: failed to apply FEM TX gain\r\n", " ERROR: FEM TX gain failed\r\n")); } else { terminalOutput().printf(" OK - FEM TX gain %s\r\n", value); } @@ -11201,7 +11203,7 @@ bool MyMesh::handleCommand(const char* command, uint32_t sender_timestamp, #if COMPANION_FEATURE_READER if (sender_timestamp == 0 && mesh::morseTimingActive(_radio) && isCompanionReaderRequest(command)) { - mesh::cli::formatError(reply, reply_capacity, "Reader unavailable during Morse"); + mesh::cli::formatError(reply, reply_capacity, MESH_CLI_REPLY_TEXT("Reader unavailable during Morse", "Reader blocked by Morse")); return true; } if (sender_timestamp == 0 @@ -11607,7 +11609,7 @@ complete_line: #if COMPANION_FEATURE_READER if (mesh::morseTimingActive(_radio) && isCompanionReaderRequest(cli_command)) { mesh::cancelReaderCommand(); - output.print(" ERROR: Reader unavailable during Morse\r\n"); + output.print(MESH_CLI_REPLY_TEXT(" ERROR: Reader unavailable during Morse\r\n", " ERROR: Reader blocked by Morse\r\n")); } else if (mesh::handleReaderCommand(cli_command, output)) { // USB rescue streams chapters/ranges cooperatively like the terminal. } else diff --git a/examples/simple_repeater/MyMesh.cpp b/examples/simple_repeater/MyMesh.cpp index ed9117b7a..4177b1483 100644 --- a/examples/simple_repeater/MyMesh.cpp +++ b/examples/simple_repeater/MyMesh.cpp @@ -8319,13 +8319,13 @@ void MyMesh::formatFloodPacketFilters(const char* args, char* reply, bool compac field = skipFloodFilterSpaces(field); if (!parseFloodFilterUnsigned(slot_text, flood_packet_filter_slots, slot) || !slot || !flood_packet_filters[slot - 1].active) { - strcpy(reply, "Err - rule slot is empty"); + strcpy(reply, MESH_CLI_REPLY_TEXT("Err - rule slot is empty", "Err - empty rule slot")); } else if (floodFilterAsciiEqual(field, "n") || floodFilterAsciiEqual(field, "sender")) { snprintf(reply, 160, "> %s", flood_packet_filters[slot - 1].extra.sender); } else if (floodFilterAsciiEqual(field, "x") || floodFilterAsciiEqual(field, "text")) { snprintf(reply, 160, "> %s", flood_packet_filters[slot - 1].extra.text); } else { - strcpy(reply, "Err - field is sender/n or text/x"); + strcpy(reply, MESH_CLI_REPLY_TEXT("Err - field is sender/n or text/x", "Err - field: sender/n|text/x")); } return; } @@ -8427,7 +8427,7 @@ void MyMesh::setFloodPacketFilter(const char* args, char* reply, if (state != cursor || !strcmp(state, "on") || !strcmp(state, "off")) { uint8_t disabled; if (!FloodRuleExtensions::enabledValue(state, disabled)) { - strcpy(reply, "Err - enabled is on or off"); return; + strcpy(reply, MESH_CLI_REPLY_TEXT("Err - enabled is on or off", "Err - enabled: on|off")); return; } auto& entry = flood_packet_filters[requested_slot]; if (!entry.active) { strcpy(reply, "Err - empty filter slot"); return; } @@ -8448,7 +8448,7 @@ void MyMesh::setFloodPacketFilter(const char* args, char* reply, } char params[256]; if (strlen(cursor) >= sizeof(params)) { - strcpy(reply, "Err - rule parameters too long"); + strcpy(reply, MESH_CLI_REPLY_TEXT("Err - rule parameters too long", "Err - rule params too long")); return; } @@ -8523,7 +8523,7 @@ void MyMesh::setFloodPacketFilter(const char* args, char* reply, } const char* value = strchr(tokens[i], '=') + 1; if (!FloodFilterPolicy::parseRuleModes(value, transport_modes)) { - strcpy(reply, "Err - mode is radio, bridge, cross, or bridge,cross"); + strcpy(reply, MESH_CLI_REPLY_TEXT("Err - mode is radio, bridge, cross, or bridge,cross", "Err - mode: radio|bridge|cross|bridge,cross")); return; } mode_set = true; @@ -8536,7 +8536,7 @@ void MyMesh::setFloodPacketFilter(const char* args, char* reply, } else if (floodFilterAsciiStartsWith(tokens[i], "f=")) { const char* flags = tokens[i] + 2; if (*flags == 0) { - strcpy(reply, "Err - compact flags are s, t, and/or r"); + strcpy(reply, MESH_CLI_REPLY_TEXT("Err - compact flags are s, t, and/or r", "Err - flags: s|t|r (combinable)")); return; } while (*flags != 0) { @@ -8560,7 +8560,7 @@ void MyMesh::setFloodPacketFilter(const char* args, char* reply, } retry_on_match = true; } else { - strcpy(reply, "Err - compact flags are s, t, and/or r"); + strcpy(reply, MESH_CLI_REPLY_TEXT("Err - compact flags are s, t, and/or r", "Err - flags: s|t|r (combinable)")); return; } flags++; @@ -8772,7 +8772,7 @@ void MyMesh::setFloodPacketFilter(const char* args, char* reply, if (!FloodRuleCLI::copyChannelReference(value, flood_packet_filters, flood_packet_filter_slots, channel_key_len, channel_hash, channel_secret, channel_name, sizeof(channel_name))) { - strcpy(reply, "Err - unknown or ambiguous channel key reference; use the original key"); + strcpy(reply, MESH_CLI_REPLY_TEXT("Err - unknown or ambiguous channel key reference; use the original key", "Err - ambiguous/unknown channel key reference; use original key")); return; } } else if (!floodFilterAsciiEqual(value, "*")) { @@ -8785,7 +8785,7 @@ void MyMesh::setFloodPacketFilter(const char* args, char* reply, if (!parseFloodRuleChannel( value, channel_secret, channel_key_len, hash_prefix, channel_name, sizeof(channel_name))) { - strcpy(reply, "Err - channel must be *, public, #channel, hash:XX, or a key"); + strcpy(reply, MESH_CLI_REPLY_TEXT("Err - channel must be *, public, #channel, hash:XX, or a key", "Err - channel: *|public|#channel|hash:XX|key")); return; } channel_hash = hash_prefix[0]; @@ -8798,7 +8798,7 @@ void MyMesh::setFloodPacketFilter(const char* args, char* reply, } if (!FloodFilterPolicy::parsePathHashBytes( strchr(tokens[i], '=') + 1, hashbytes)) { - strcpy(reply, "Err - hashbytes must be any, 1, 2, 2+, or 3"); + strcpy(reply, MESH_CLI_REPLY_TEXT("Err - hashbytes must be any, 1, 2, 2+, or 3", "Err - hashbytes: any|1|2|2+|3")); return; } hashbytes_set = true; @@ -8816,7 +8816,7 @@ void MyMesh::setFloodPacketFilter(const char* args, char* reply, || floodFilterAsciiStartsWith(tokens[i], "p="); if (!FloodRuleExtensions::parsePath(value, path_hash_size, path_hops, path, extra, legacy_prefix)) { - strcpy(reply, "Err - path is [^]ID>...[$], up to four 1/2/3-byte IDs"); + strcpy(reply, MESH_CLI_REPLY_TEXT("Err - path is [^]ID>...[$], up to four 1/2/3-byte IDs", "Err - path: [^]ID>...[$], max four 1/2/3-byte IDs")); return; } path_set = true; @@ -8824,7 +8824,7 @@ void MyMesh::setFloodPacketFilter(const char* args, char* reply, || floodFilterAsciiStartsWith(tokens[i], "h=")) { if (hops_set || !parseFloodFilterHopSpec( strchr(tokens[i], '=') + 1, min_hops, max_hops)) { - strcpy(reply, "Err - hops must be all, N, N+, or N-M (0-63)"); + strcpy(reply, MESH_CLI_REPLY_TEXT("Err - hops must be all, N, N+, or N-M (0-63)", "Err - hops: all|N|N+|N-M (0-63)")); return; } hops_set = true; @@ -8846,33 +8846,33 @@ void MyMesh::setFloodPacketFilter(const char* args, char* reply, if (path_hash_size == 0) path_hash_size = hashbytes; } if (scope_timing_set && !target_set) { - strcpy(reply, "Err - tx timing requires scope= or region="); + strcpy(reply, MESH_CLI_REPLY_TEXT("Err - tx timing requires scope= or region=", "Err - tx needs scope=|region=")); return; } if (drop_on_match && rate_limit_enabled) { - strcpy(reply, "Err - drop cannot be combined with rate"); + strcpy(reply, MESH_CLI_REPLY_TEXT("Err - drop cannot be combined with rate", "Err - drop conflicts with rate")); return; } if (drop_on_match && retry_on_match) { - strcpy(reply, "Err - drop cannot be combined with retry"); + strcpy(reply, MESH_CLI_REPLY_TEXT("Err - drop cannot be combined with retry", "Err - drop conflicts with retry")); return; } if (channel_key_len != 0 && payload_type != FLOOD_PACKET_FILTER_ANY_TYPE && payload_type != PAYLOAD_TYPE_GRP_TXT && payload_type != PAYLOAD_TYPE_GRP_DATA) { - strcpy(reply, "Err - channel matcher requires type=any|grp_txt|grp_data"); + strcpy(reply, MESH_CLI_REPLY_TEXT("Err - channel matcher requires type=any|grp_txt|grp_data", "Err - channel needs type=any|grp_txt|grp_data")); return; } if (!FloodFilterPolicy::transportActionsSupported( transport_modes, target_set, retry_on_match, scope_uses_slow_timing)) { - strcpy(reply, "Err - bridge/cross supports drop, rate=, priority=, stop only"); + strcpy(reply, MESH_CLI_REPLY_TEXT("Err - bridge/cross supports drop, rate=, priority=, stop only", "Err - bridge/cross: drop|rate=|priority=|stop only")); return; } bool action_set = drop_set || target_set || rate_limit_enabled || stop_on_match || retry_on_match; if (require_explicit_action && !action_set) { strcpy(reply, - "Err - flood.rule requires drop, scope=, region=, rate=, retry, or stop"); + MESH_CLI_REPLY_TEXT("Err - flood.rule requires drop, scope=, region=, rate=, retry, or stop", "Err - flood.rule: drop|scope=|region=|rate=|retry|stop")); return; } // Preserve the positional flood.filter behavior. The flood.rule alias is @@ -8913,7 +8913,7 @@ void MyMesh::setFloodPacketFilter(const char* args, char* reply, candidate.transport_modes = transport_modes; candidate.extra = extra; if (!FloodRuleExtensions::validRuleFields(candidate)) { - strcpy(reply, "Err - content needs a keyed group-text channel; scope/retry needs flood route"); + strcpy(reply, MESH_CLI_REPLY_TEXT("Err - content needs a keyed group-text channel; scope/retry needs flood route", "Err - content needs keyed group-text channel; flood scope/retry only")); return; } @@ -9884,7 +9884,7 @@ void MyMesh::formatFloodChannelScopes(const char* args, char* reply) { return; } if (*cursor != 0) { - strcpy(reply, "Err - use get flood.channel.scope[.n]"); + strcpy(reply, MESH_CLI_REPLY_TEXT("Err - use get flood.channel.scope[.n]", "Err - get flood.channel.scope[.n]")); return; } if (index >= 0) { @@ -10132,7 +10132,7 @@ void MyMesh::deleteFloodChannelScope(const char* args, char* reply) { int index = -1; if (!parseFloodChannelScopeIndex(cursor, index)) index = -1; if (index < 0 || *cursor != 0) { - strcpy(reply, "Err - use: del flood.channel.scope.|all"); + strcpy(reply, MESH_CLI_REPLY_TEXT("Err - use: del flood.channel.scope.|all", "Err - del flood.channel.scope.|all")); return; } if (floodChannelScopes()[index].target_id == 0) { @@ -10180,7 +10180,7 @@ void MyMesh::formatFloodChannelScopeRequirements(const char* args, return; } if (*cursor != 0) { - strcpy(reply, "Err - use get flood.channel.scope.require[.n]"); + strcpy(reply, MESH_CLI_REPLY_TEXT("Err - use get flood.channel.scope.require[.n]", "Err - get flood.channel.scope.require[.n]")); return; } if (index >= 0) { @@ -10210,7 +10210,7 @@ void MyMesh::setFloodChannelScopeRequirement(const char* args, char* reply) { if (takeFloodModerationToken(cursor, channel_text, sizeof(channel_text)) != 1 || takeFloodModerationToken(cursor, extra, sizeof(extra)) != 0) { strcpy(reply, - "Err - use: set flood.channel.scope.require[.n] "); + MESH_CLI_REPLY_TEXT("Err - use: set flood.channel.scope.require[.n] ", "Err - set flood.channel.scope.require[.n] ")); return; } @@ -10281,7 +10281,7 @@ void MyMesh::deleteFloodChannelScopeRequirement(const char* args, int index = -1; if (!parseFloodChannelScopeIndex(cursor, index)) index = -1; if (index < 0 || *cursor != 0) { - strcpy(reply, "Err - use: del flood.channel.scope.require.|all"); + strcpy(reply, MESH_CLI_REPLY_TEXT("Err - use: del flood.channel.scope.require.|all", "Err - del flood.channel.scope.require.|all")); return; } if (floodChannelScopeRequirements()[index].key_len == 0) { diff --git a/lib/ed25519/fe.c b/lib/ed25519/fe.c index 6ea6102f2..5d326515a 100644 --- a/lib/ed25519/fe.c +++ b/lib/ed25519/fe.c @@ -373,6 +373,7 @@ void fe_frombytes(fe h, const unsigned char *s) { #endif +#if !defined(ED25519_COMPACT_FIELD) || !ED25519_COMPACT_FIELD void fe_invert(fe out, const fe z) { fe t0; fe t1; @@ -458,6 +459,7 @@ void fe_invert(fe out, const fe z) { fe_mul(out, t1, t0); } +#endif @@ -897,6 +899,7 @@ void fe_neg(fe h, const fe f) { #endif +#if !defined(ED25519_COMPACT_FIELD) || !ED25519_COMPACT_FIELD void fe_pow22523(fe out, const fe z) { fe t0; fe t1; @@ -981,6 +984,7 @@ void fe_pow22523(fe out, const fe z) { fe_mul(out, t0, z); return; } +#endif /* diff --git a/lib/ed25519/fe_compact.h b/lib/ed25519/fe_compact.h index a21ba771f..e53ce9723 100644 --- a/lib/ed25519/fe_compact.h +++ b/lib/ed25519/fe_compact.h @@ -158,3 +158,51 @@ void fe_sq(fe h, const fe f) { void fe_sq2(fe h, const fe f) { fe_compact_product(h, f, f, 1); } + +static void fe_compact_square_n(fe out, const fe in, int count) { + int i; + fe_sq(out, in); + for (i = 1; i < count; ++i) fe_sq(out, out); +} + +/* Both ref10 exponentiation chains share z^(2^250-1). Keep the same field + * operations and carry order, while storing their common code only once. + * Every squaring count is public and fixed. z11 is retained for inversion. + */ +static void fe_compact_pow250(fe out, fe z11, const fe z) { + fe t0, t1, t2; + fe_sq(t0, z); + fe_compact_square_n(t1, t0, 2); + fe_mul(t1, z, t1); + fe_mul(z11, t0, t1); + fe_sq(t0, z11); + fe_mul(t0, t1, t0); /* 2^5 - 1 */ + fe_compact_square_n(t1, t0, 5); + fe_mul(t0, t1, t0); /* 2^10 - 1 */ + fe_compact_square_n(t1, t0, 10); + fe_mul(t1, t1, t0); /* 2^20 - 1 */ + fe_compact_square_n(t2, t1, 20); + fe_mul(t1, t2, t1); /* 2^40 - 1 */ + fe_compact_square_n(t1, t1, 10); + fe_mul(t0, t1, t0); /* 2^50 - 1 */ + fe_compact_square_n(t1, t0, 50); + fe_mul(t1, t1, t0); /* 2^100 - 1 */ + fe_compact_square_n(t2, t1, 100); + fe_mul(t1, t2, t1); /* 2^200 - 1 */ + fe_compact_square_n(t1, t1, 50); + fe_mul(out, t1, t0); /* 2^250 - 1 */ +} + +void fe_invert(fe out, const fe z) { + fe power, z11; + fe_compact_pow250(power, z11, z); + fe_compact_square_n(power, power, 5); + fe_mul(out, power, z11); /* 2^255 - 21 */ +} + +void fe_pow22523(fe out, const fe z) { + fe power, z11; + fe_compact_pow250(power, z11, z); + fe_compact_square_n(power, power, 2); + fe_mul(out, power, z); /* 2^252 - 3 */ +} diff --git a/scripts/nrf52_flash_trim.py b/scripts/nrf52_flash_trim.py index 104243055..197c524f7 100644 --- a/scripts/nrf52_flash_trim.py +++ b/scripts/nrf52_flash_trim.py @@ -277,18 +277,17 @@ def compile_shared_cli_replies(build_env, node): return _compile_shared_reply_copies(build_env, node, "src/helpers/CommonCLI.cpp") -def compile_shared_companion_replies(build_env, node): - return _compile_shared_reply_copies(build_env, node, "examples/companion_radio/MyMesh.cpp") - - def companion_shared_replies_policy(platform, mcu, env_name, src_filter, flags=(), cppdefines=()): - if env_name != "RAK_4631_companion_radio_full" or str(mcu).lower() != "nrf52840": + if (platform != "nordicnrf52" or env_name != "RAK_4631_companion_radio_full" + or str(mcu).lower() != "nrf52840" + or not source_selected(src_filter, COMPANION_SOURCES[0]) + or any(source_selected(src_filter, source) for source in ROLE_SOURCES)): return False values = flag_defines(flags, cppdefines) - return (bool(companion_trim_policy(platform, mcu, src_filter, flags, cppdefines)) - and ("MESH_CLI_SHARE_REPLIES" not in values - or _enabled(values["MESH_CLI_SHARE_REPLIES"]))) + return all(name not in values or _enabled(values[name]) for name in ( + "MESH_NRF52_FLASH_TRIM", "MESH_NRF52_COMPANION_TRIM", "MESH_CLI_SHARE_REPLIES", + "MESH_CLI_SHARE_REPLY_COPIES")) def install(build_env): @@ -325,7 +324,8 @@ def install(build_env): if not infrastructure and companion_shared_replies_policy( platform.name, mcu, build_env["PIOENV"], build_env.get("SRC_FILTER", ()), flags, cppdefines): - build_env.AddBuildMiddleware(compile_shared_companion_replies, "*MyMesh.cpp") + if "MESH_CLI_SHARE_REPLY_COPIES" not in flag_defines(flags, cppdefines): + build_env.AppendUnique(CPPDEFINES=[("MESH_CLI_SHARE_REPLY_COPIES", 1)]) if infrastructure: names = ("local OTA target name only" if definitions.get("OTA_TARGET_NAME_TABLE") == 0 or flag_defines(flags, cppdefines).get("OTA_TARGET_NAME_TABLE") == "0" diff --git a/src/helpers/CLIReplyCopy.cpp b/src/helpers/CLIReplyCopy.cpp new file mode 100644 index 000000000..1fcc98090 --- /dev/null +++ b/src/helpers/CLIReplyCopy.cpp @@ -0,0 +1,11 @@ +#include "CLIReplyCopy.h" + +#if defined(MESH_CLI_SHARE_REPLY_COPIES) && MESH_CLI_SHARE_REPLY_COPIES +#undef strcpy + +namespace mesh { namespace cli { +char* copyReply(char* destination, const char* source) { + return strcpy(destination, source); +} +}} +#endif diff --git a/src/helpers/CLIReplyCopy.h b/src/helpers/CLIReplyCopy.h new file mode 100644 index 000000000..adab0105e --- /dev/null +++ b/src/helpers/CLIReplyCopy.h @@ -0,0 +1,14 @@ +#pragma once + +#if defined(MESH_CLI_SHARE_REPLY_COPIES) && MESH_CLI_SHARE_REPLY_COPIES +#include + +namespace mesh { namespace cli { +// Keep the same libc copy and return value, but prevent LTO from expanding +// constant human CLI replies into repeated stores in the two command parsers. +__attribute__((noipa)) char* copyReply(char* destination, const char* source); +}} + +// Include this source-local remap only after every ordinary header. +#define strcpy ::mesh::cli::copyReply +#endif diff --git a/src/helpers/CommonCLI.cpp b/src/helpers/CommonCLI.cpp index 9cc0b09fe..1e231c3bf 100644 --- a/src/helpers/CommonCLI.cpp +++ b/src/helpers/CommonCLI.cpp @@ -116,6 +116,7 @@ #define RECENT_REPEATER_PREFIX_MAX_BYTES 3 #include +#include "CLIReplyCopy.h" // Believe it or not, this std C function is busted on some platforms! static uint32_t _atoi(const char* sp) { @@ -3726,11 +3727,11 @@ void CommonCLI::handleCommand(uint32_t sender_timestamp, char* command, char* re if (command[15] == ' ' && !force_ap) { strcpy(reply, "ERR: usage start webconfig [ap]"); } else if (!_callbacks->startWebConfig(force_ap, reply)) { - strcpy(reply, "ERR: webconfig not supported on this build"); + strcpy(reply, MESH_CLI_REPLY_TEXT("ERR: webconfig not supported on this build", "ERR: webconfig unavailable")); } } else if (strcmp(command, "stop webconfig") == 0) { if (!_callbacks->stopWebConfig(reply)) { - strcpy(reply, "ERR: webconfig not supported on this build"); + strcpy(reply, MESH_CLI_REPLY_TEXT("ERR: webconfig not supported on this build", "ERR: webconfig unavailable")); } #endif } else if (memcmp(command, "memory", 6) == 0) { @@ -4006,7 +4007,7 @@ void CommonCLI::handleCommand(uint32_t sender_timestamp, char* command, char* re } else if (memcmp(command, "ota", 3) == 0 && (command[3] == 0 || command[3] == ' ')) { if (!_callbacks->isTempRadioActive() && !_radio_profiles.secondaryTemporary() && otaCommandNeedsTempRadio(command)) { - strcpy(reply, "LoRa OTA needs temp radio on every node. Run: tempradio 909.950,250,5,5,120"); + strcpy(reply, MESH_CLI_REPLY_TEXT("LoRa OTA needs temp radio on every node. Run: tempradio 909.950,250,5,5,120", "LoRa OTA: tempradio 909.950,250,5,5,120 on every node")); } else { if (!mesh::ota::ota_acquire_context(reply, 160)) return; const auto previous = mesh::ota::OtaConfigState::capture(mesh::ota::ota_ctx()); @@ -4038,9 +4039,9 @@ void CommonCLI::handleCommand(uint32_t sender_timestamp, char* command, char* re #else } else if (memcmp(command, "ota", 3) == 0 && (command[3] == 0 || command[3] == ' ')) { #if defined(STM32_PLATFORM) && defined(WIO_E5_MINI_NO_EXTERNAL_SENSORS) - strcpy(reply, "LoRa OTA absent; tempradio cannot enable it. Install OTA repeater firmware"); + strcpy(reply, MESH_CLI_REPLY_TEXT("LoRa OTA absent; tempradio cannot enable it. Install OTA repeater firmware", "LoRa OTA absent; install OTA repeater; tempradio cannot enable it")); #else - strcpy(reply, "LoRa OTA is not included in this build; tempradio cannot enable it. Use an OTA-enabled repeater firmware"); + strcpy(reply, MESH_CLI_REPLY_TEXT("LoRa OTA is not included in this build; tempradio cannot enable it. Use an OTA-enabled repeater firmware", "LoRa OTA absent; install OTA repeater; tempradio cannot enable it")); #endif #endif } else if (strcmp(command, "power") == 0) { @@ -4083,7 +4084,7 @@ void CommonCLI::handleCommand(uint32_t sender_timestamp, char* command, char* re || strcmp(value, "0") == 0 || strcmp(value, "1") == 0; if (!valid_gps_interval) { - strcpy(reply, "gps_interval must be 0..86400 seconds"); + strcpy(reply, MESH_CLI_REPLY_TEXT("gps_interval must be 0..86400 seconds", "gps_interval 0..86400 seconds")); } else if (!valid_gps_toggle) { strcpy(reply, "gps must be 0 or 1"); } @@ -4367,7 +4368,7 @@ void CommonCLI::handleSetCmd(uint32_t sender_timestamp, char* command, char* rep if (strncmp(config, "repeat.trace", 12) == 0 && (config[12] == 0 || config[12] == ' ' || config[12] == '\t')) { if (strcmp(_callbacks->getRole(), "repeater") != 0) { - mesh::cli::formatError(reply, 160, "repeat.trace is only supported by repeaters"); + mesh::cli::formatError(reply, 160, MESH_CLI_REPLY_TEXT("repeat.trace is only supported by repeaters", "repeat.trace needs a repeater")); return; } const char* value = config + 12; @@ -4421,7 +4422,7 @@ void CommonCLI::handleSetCmd(uint32_t sender_timestamp, char* command, char* rep while (*value == ' ' || *value == '\t') ++value; uint16_t hours = 0; if (!mesh::gps::parseSyncIntervalHours(value, hours)) { - mesh::cli::formatError(reply, 160, "GPS sync interval must be 1..336 hours"); + mesh::cli::formatError(reply, 160, MESH_CLI_REPLY_TEXT("GPS sync interval must be 1..336 hours", "GPS sync interval 1..336 hours")); } else if (_sensors->getLocationProvider() == nullptr) { mesh::cli::formatError(reply, 160, "GPS unavailable"); } else { @@ -4455,7 +4456,7 @@ void CommonCLI::handleSetCmd(uint32_t sender_timestamp, char* command, char* rep while (*value == ' ' || *value == '\t') value++; uint8_t channel = 0; if (!mesh::wifi::parseEspNowChannel(value, channel)) { - mesh::cli::formatError(reply, 160, "ESP-NOW channel must be 1-13"); + mesh::cli::formatError(reply, 160, MESH_CLI_REPLY_TEXT("ESP-NOW channel must be 1-13", "ESP-NOW channel 1-13")); } else { // Snapshot the boot channel before writing NVS. The saved setting must // never become the active value in this boot merely because this is the @@ -4603,7 +4604,7 @@ void CommonCLI::handleSetCmd(uint32_t sender_timestamp, char* command, char* rep return; } if (!_callbacks->setWebUIEnabled(enabled, reply)) { - mesh::cli::formatError(reply, 160, "webui not supported on this build"); + mesh::cli::formatError(reply, 160, MESH_CLI_REPLY_TEXT("webui not supported on this build", "webui unavailable")); } return; } @@ -4613,7 +4614,7 @@ void CommonCLI::handleSetCmd(uint32_t sender_timestamp, char* command, char* rep if (mesh::cli::classifyStandaloneWiFiSet(config, &cli_value) == mesh::cli::StandaloneWiFiKey::CLI) { if (!_callbacks->setWiFiCLI(cli_value, reply)) { - mesh::cli::formatError(reply, 160, "WiFi CLI unavailable on this build"); + mesh::cli::formatError(reply, 160, MESH_CLI_REPLY_TEXT("WiFi CLI unavailable on this build", "WiFi CLI unavailable")); } return; } @@ -4794,7 +4795,7 @@ void CommonCLI::handleSetCmd(uint32_t sender_timestamp, char* command, char* rep } if (wifi_key != mesh::cli::StandaloneWiFiKey::None) { if (!wifi_supported) { - mesh::cli::formatError(reply, 160, "standalone WiFi settings unavailable on this build"); + mesh::cli::formatError(reply, 160, MESH_CLI_REPLY_TEXT("standalone WiFi settings unavailable on this build", "WiFi settings unavailable")); } return; } @@ -4862,7 +4863,7 @@ void CommonCLI::handleSetCmd(uint32_t sender_timestamp, char* command, char* rep } else if (memcmp(config, "agc.reset.interval ", 19) == 0) { uint32_t seconds; if (!mesh::cli::parseUnsignedIntegerStrict(&config[19], seconds) || seconds > 1020) { - mesh::cli::formatError(reply, 160, "AGC interval must be 0-1020 seconds"); + mesh::cli::formatError(reply, 160, MESH_CLI_REPLY_TEXT("AGC interval must be 0-1020 seconds", "AGC interval 0-1020 seconds")); } else { _prefs->agc_reset_interval = seconds / 4; savePrefs(); @@ -4892,7 +4893,7 @@ void CommonCLI::handleSetCmd(uint32_t sender_timestamp, char* command, char* rep uint32_t hours = 0; if (!parseUint32Strict(&config[22], hours) || (hours != 0 && (hours < 3 || hours > 168))) { - mesh::cli::formatError(reply, 160, "interval must be 0 (off) or 3-168 hours"); + mesh::cli::formatError(reply, 160, MESH_CLI_REPLY_TEXT("interval must be 0 (off) or 3-168 hours", "interval 0 (off)|3-168 hours")); } else { const uint8_t previous = _prefs->flood_advert_interval; _prefs->flood_advert_interval = (uint8_t)(hours); @@ -4992,7 +4993,7 @@ void CommonCLI::handleSetCmd(uint32_t sender_timestamp, char* command, char* rep } else if (memcmp(config, "radio.fem.rxgain ", 17) == 0) { const auto* radio = _callbacks->getProfileRadio(); if (radio && (radio->isMorseTxActive() || radio->isMorseRxActive())) { - mesh::cli::formatError(reply, 160, "stop Morse before changing LoRa FEM gain"); + mesh::cli::formatError(reply, 160, MESH_CLI_REPLY_TEXT("stop Morse before changing LoRa FEM gain", "stop Morse before setting FEM gain")); return; } if (!_board->canControlLoRaFemLna()) { @@ -5005,7 +5006,7 @@ void CommonCLI::handleSetCmd(uint32_t sender_timestamp, char* command, char* rep savePrefs(); strcpy(reply, MESH_CLI_REPLY_TEXT("OK - LoRa FEM RX gain on", "OK - FEM RX gain on")); } else { - mesh::cli::formatError(reply, 160, "failed to apply LoRa FEM RX gain"); + mesh::cli::formatError(reply, 160, MESH_CLI_REPLY_TEXT("failed to apply LoRa FEM RX gain", "LoRa FEM RX gain failed")); } } else if (memcmp(&config[17], "off", 3) == 0) { bool changed = _board->isLoRaFemLnaEnabled(); @@ -5015,7 +5016,7 @@ void CommonCLI::handleSetCmd(uint32_t sender_timestamp, char* command, char* rep savePrefs(); strcpy(reply, MESH_CLI_REPLY_TEXT("OK - LoRa FEM RX gain off", "OK - FEM RX gain off")); } else { - mesh::cli::formatError(reply, 160, "failed to apply LoRa FEM RX gain"); + mesh::cli::formatError(reply, 160, MESH_CLI_REPLY_TEXT("failed to apply LoRa FEM RX gain", "LoRa FEM RX gain failed")); } } else { mesh::cli::formatError(reply, 160, "state must be on or off"); @@ -5023,7 +5024,7 @@ void CommonCLI::handleSetCmd(uint32_t sender_timestamp, char* command, char* rep } else if (memcmp(config, "radio.fem.txgain ", 17) == 0) { const auto* radio = _callbacks->getProfileRadio(); if (radio && (radio->isMorseTxActive() || radio->isMorseRxActive())) { - mesh::cli::formatError(reply, 160, "stop Morse before changing LoRa FEM gain"); + mesh::cli::formatError(reply, 160, MESH_CLI_REPLY_TEXT("stop Morse before changing LoRa FEM gain", "stop Morse before setting FEM gain")); return; } if (!_board->canControlLoRaFemPaGain()) { @@ -5034,7 +5035,7 @@ void CommonCLI::handleSetCmd(uint32_t sender_timestamp, char* command, char* rep savePrefs(); strcpy(reply, MESH_CLI_REPLY_TEXT("OK - LoRa FEM TX gain on", "OK - FEM TX gain on")); } else { - mesh::cli::formatError(reply, 160, "failed to apply LoRa FEM TX gain"); + mesh::cli::formatError(reply, 160, MESH_CLI_REPLY_TEXT("failed to apply LoRa FEM TX gain", "LoRa FEM TX gain failed")); } } else if (memcmp(&config[17], "off", 3) == 0) { if (_board->setLoRaFemPaGainEnabled(false)) { @@ -5042,7 +5043,7 @@ void CommonCLI::handleSetCmd(uint32_t sender_timestamp, char* command, char* rep savePrefs(); strcpy(reply, MESH_CLI_REPLY_TEXT("OK - LoRa FEM TX gain off", "OK - FEM TX gain off")); } else { - mesh::cli::formatError(reply, 160, "failed to apply LoRa FEM TX gain"); + mesh::cli::formatError(reply, 160, MESH_CLI_REPLY_TEXT("failed to apply LoRa FEM TX gain", "LoRa FEM TX gain failed")); } } else { mesh::cli::formatError(reply, 160, "state must be on or off"); @@ -5118,7 +5119,7 @@ void CommonCLI::handleSetCmd(uint32_t sender_timestamp, char* command, char* rep const bool powersaving_supported = _callbacks->supportsRxPowerSaving(); #endif if (!powersaving_supported && parsed.mode != RxPowerSavingArgumentMode::Off) { - strcpy(reply, "ERROR: RX powersaving unsupported"); + strcpy(reply, MESH_CLI_REPLY_TEXT("ERROR: RX powersaving unsupported", "ERROR: RXPS unsupported")); return; } if (parsed.mode == RxPowerSavingArgumentMode::Off) { @@ -5139,7 +5140,7 @@ void CommonCLI::handleSetCmd(uint32_t sender_timestamp, char* command, char* rep } if (requested_level < 1 || requested_level > 10 || (requested_preamble != 0 && requested_preamble != 16 && requested_preamble != 32)) { - strcpy(reply, "ERROR: level range is 1-10; preamble is 16 or 32"); + strcpy(reply, MESH_CLI_REPLY_TEXT("ERROR: level range is 1-10; preamble is 16 or 32", "ERROR: level 1-10; preamble 16|32")); return; } level = requested_level; @@ -5151,12 +5152,12 @@ void CommonCLI::handleSetCmd(uint32_t sender_timestamp, char* command, char* rep if (level_requested && preamble_overridden && preamble != 16 && preamble != 32) { - strcpy(reply, "ERROR: level range is 1-10; preamble is 16 or 32"); + strcpy(reply, MESH_CLI_REPLY_TEXT("ERROR: level range is 1-10; preamble is 16 or 32", "ERROR: level 1-10; preamble 16|32")); return; } if (level_requested && !calculateRxPowerSavingLevel(level, _prefs->sf, _prefs->bw, preamble, &rx_us, &sleep_us)) { - strcpy(reply, "ERROR: level range is 1-10; preamble is 16 or 32"); + strcpy(reply, MESH_CLI_REPLY_TEXT("ERROR: level range is 1-10; preamble is 16 or 32", "ERROR: level 1-10; preamble 16|32")); return; } @@ -5757,7 +5758,7 @@ void CommonCLI::handleSetCmd(uint32_t sender_timestamp, char* command, char* rep savePrefs(); strcpy(reply, "OK"); } else { - mesh::cli::formatError(reply, 160, "delay must be between 0-10000 ms"); + mesh::cli::formatError(reply, 160, MESH_CLI_REPLY_TEXT("delay must be between 0-10000 ms", "delay 0-10000 ms")); } } else if (memcmp(config, "bridge.source ", 14) == 0) { const char* source = &config[14]; @@ -5813,7 +5814,7 @@ void CommonCLI::handleSetCmd(uint32_t sender_timestamp, char* command, char* rep : "Error: bridge failed to restart; previous baud could not be restored")); } } else { - sprintf(reply, "Error: baud rate must be between 9600-%d",BRIDGE_MAX_BAUD); + sprintf(reply, MESH_CLI_REPLY_TEXT("Error: baud rate must be between 9600-%d", "Error: baud 9600-%d"),BRIDGE_MAX_BAUD); } } else if (memcmp(config, "bridge.uart ", 12) == 0) { int32_t uart = 0; @@ -5825,10 +5826,10 @@ void CommonCLI::handleSetCmd(uint32_t sender_timestamp, char* command, char* rep ; if (!parsed || !supported) { #ifdef WITH_RS232_BRIDGE_ALT - sprintf(reply, "Error: UART must be %d or %d", + sprintf(reply, MESH_CLI_REPLY_TEXT("Error: UART must be %d or %d", "Error: UART %d|%d"), WITH_RS232_BRIDGE_UART, WITH_RS232_BRIDGE_ALT_UART); #else - sprintf(reply, "Error: UART is fixed at %d", WITH_RS232_BRIDGE_UART); + sprintf(reply, MESH_CLI_REPLY_TEXT("Error: UART is fixed at %d", "Error: UART fixed at %d"), WITH_RS232_BRIDGE_UART); #endif } else { @@ -5899,14 +5900,14 @@ void CommonCLI::handleSetCmd(uint32_t sender_timestamp, char* command, char* rep strcpy(reply, applied ? "OK" : "Error: setting saved; bridge failed to restart"); } else { - sprintf(reply, "Error: channel must be an integer between 1-%u for %s format", + sprintf(reply, MESH_CLI_REPLY_TEXT("Error: channel must be an integer between 1-%u for %s format", "Error: integer channel 1-%u for %s"), (unsigned)max_channel, mesh::bridge::espNowFormatName(_prefs->bridge_format)); } } else if (memcmp(config, "bridge.secret ", 14) == 0) { const char* secret = &config[14]; if (secret[0] == 0 || strlen(secret) >= sizeof(_prefs->bridge_secret)) { - sprintf(reply, "Error: secret must be 1-%u characters", (unsigned)(sizeof(_prefs->bridge_secret) - 1)); + sprintf(reply, MESH_CLI_REPLY_TEXT("Error: secret must be 1-%u characters", "Error: secret 1-%u characters"), (unsigned)(sizeof(_prefs->bridge_secret) - 1)); } else { StrHelper::strncpy(_prefs->bridge_secret, secret, sizeof(_prefs->bridge_secret)); const bool applied = @@ -5922,10 +5923,10 @@ void CommonCLI::handleSetCmd(uint32_t sender_timestamp, char* command, char* rep } else if (memcmp(config, "bridge.format ", 14) == 0) { uint8_t format = mesh::bridge::ESPNOW_FORMAT_WRAPPED; if (!mesh::bridge::parseEspNowFormat(&config[14], format)) { - mesh::cli::formatError(reply, 160, "format must be wrapped or raw"); + mesh::cli::formatError(reply, 160, MESH_CLI_REPLY_TEXT("format must be wrapped or raw", "format wrapped|raw")); } else if (!mesh::bridge::isValidEspNowBridgeChannel( _prefs->bridge_channel, format)) { - mesh::cli::formatError(reply, 160, "set bridge.channel to 1-13 before changing format"); + mesh::cli::formatError(reply, 160, MESH_CLI_REPLY_TEXT("set bridge.channel to 1-13 before changing format", "set bridge.channel 1-13 before format")); } else { _prefs->bridge_format = format; const bool applied = @@ -5958,7 +5959,7 @@ void CommonCLI::handleSetCmd(uint32_t sender_timestamp, char* command, char* rep savePrefs(); strcpy(reply, "reboot.interval disabled"); } else if (hours < 1 || 255 < hours) { - mesh::cli::formatError(reply, 160, "interval range is 1-255 hours"); + mesh::cli::formatError(reply, 160, MESH_CLI_REPLY_TEXT("interval range is 1-255 hours", "interval 1-255 hours")); } else { _prefs->reboot_interval = hours; savePrefs(); @@ -5969,7 +5970,7 @@ void CommonCLI::handleSetCmd(uint32_t sender_timestamp, char* command, char* rep const auto* radio = _callbacks->getProfileRadio(); const auto* profiles = radio ? radio->profiles() : nullptr; if (profiles && profiles->enabled()) { - mesh::cli::formatError(reply, 160, "extra SFs unavailable with dual radio"); + mesh::cli::formatError(reply, 160, MESH_CLI_REPLY_TEXT("extra SFs unavailable with dual radio", "extra SFs unavailable on dual radio")); return; } uint8_t sideDetSFs[mesh::lr2021::STORED_SIDE_DETECTOR_BYTES] = {}; @@ -5997,7 +5998,7 @@ void CommonCLI::handleSetCmd(uint32_t sender_timestamp, char* command, char* rep } } #else - mesh::cli::formatError(reply, 160, "extra.sf requires an LR2021 radio"); + mesh::cli::formatError(reply, 160, MESH_CLI_REPLY_TEXT("extra.sf requires an LR2021 radio", "extra.sf needs LR2021")); #endif #if defined(STATION_G3_ESP32) } else if (_board->handleCommand(command, sender_timestamp, reply)) { @@ -6050,7 +6051,7 @@ void CommonCLI::handleGetCmd(uint32_t sender_timestamp, char* command, char* rep if (strncmp(config, "repeat.trace", 12) == 0 && (config[12] == 0 || config[12] == ' ' || config[12] == '\t')) { if (strcmp(_callbacks->getRole(), "repeater") != 0) { - mesh::cli::formatError(reply, 160, "repeat.trace is only supported by repeaters"); + mesh::cli::formatError(reply, 160, MESH_CLI_REPLY_TEXT("repeat.trace is only supported by repeaters", "repeat.trace needs a repeater")); } else if (strcmp(config, "repeat.trace") != 0) { mesh::cli::formatError(reply, 160, "usage get repeat.trace"); } else { @@ -6159,7 +6160,7 @@ void CommonCLI::handleGetCmd(uint32_t sender_timestamp, char* command, char* rep #if defined(ESP_PLATFORM) && defined(ADMIN_PASSWORD) && !defined(WEBCONFIG_DISABLED) if (strcmp(config, "webui") == 0) { if (!_callbacks->getWebUIStatus(reply)) { - mesh::cli::formatError(reply, 160, "webui not supported on this build"); + mesh::cli::formatError(reply, 160, MESH_CLI_REPLY_TEXT("webui not supported on this build", "webui unavailable")); } return; } @@ -6168,7 +6169,7 @@ void CommonCLI::handleGetCmd(uint32_t sender_timestamp, char* command, char* rep if (mesh::cli::classifyStandaloneWiFiGet(config) == mesh::cli::StandaloneWiFiKey::CLI) { if (!_callbacks->getWiFiCLI(reply)) { - mesh::cli::formatError(reply, 160, "WiFi CLI unavailable on this build"); + mesh::cli::formatError(reply, 160, MESH_CLI_REPLY_TEXT("WiFi CLI unavailable on this build", "WiFi CLI unavailable")); } return; } @@ -6205,7 +6206,7 @@ void CommonCLI::handleGetCmd(uint32_t sender_timestamp, char* command, char* rep } if (wifi_key != mesh::cli::StandaloneWiFiKey::None) { if (!wifi_supported) { - mesh::cli::formatError(reply, 160, "WiFi information unavailable on this build"); + mesh::cli::formatError(reply, 160, MESH_CLI_REPLY_TEXT("WiFi information unavailable on this build", "WiFi info unavailable")); } return; } @@ -6588,7 +6589,7 @@ void CommonCLI::handleGetCmd(uint32_t sender_timestamp, char* command, char* rep strcpy(reply, "No extra SF configured"); } #else - mesh::cli::formatError(reply, 160, "extra.sf requires an LR2021 radio"); + mesh::cli::formatError(reply, 160, MESH_CLI_REPLY_TEXT("extra.sf requires an LR2021 radio", "extra.sf needs LR2021")); #endif #if defined(STATION_G3_ESP32) } else if (_board->handleCommand(command, sender_timestamp, reply)) { diff --git a/test/fixtures/cli_reply_text.json b/test/fixtures/cli_reply_text.json index 9601a51b6..c6f44147c 100644 --- a/test/fixtures/cli_reply_text.json +++ b/test/fixtures/cli_reply_text.json @@ -938,10 +938,475 @@ "file": "examples/companion_radio/Notifications.cpp", "verbose": "Error: programmable alerts unavailable on this compact STM32 build", "concise": "Error: programmable alerts unavailable on compact STM32" + }, + { + "file": "examples/companion_radio/MyMesh.cpp", + "verbose": " ERROR: use path \r\n", + "concise": " ERROR: path \r\n" + }, + { + "file": "examples/companion_radio/MyMesh.cpp", + "verbose": " ERROR: each path hop must be 2, 4, or 6 hex digits\r\n", + "concise": " ERROR: path hops need 2, 4, or 6 hex digits\r\n" + }, + { + "file": "examples/companion_radio/MyMesh.cpp", + "verbose": " ERROR: all path hops must use the same width\r\n", + "concise": " ERROR: path hops need equal widths\r\n" + }, + { + "file": "examples/companion_radio/MyMesh.cpp", + "verbose": " ERROR: separate path hops with spaces or commas\r\n", + "concise": " ERROR: separate hops with spaces/commas\r\n" + }, + { + "file": "examples/companion_radio/MyMesh.cpp", + "verbose": " ERROR: path must contain at most 63 hops\r\n", + "concise": " ERROR: path max 63 hops\r\n" + }, + { + "file": "examples/companion_radio/MyMesh.cpp", + "verbose": " ERROR: Meshtext channel transport is not available\r\n", + "concise": " ERROR: Meshtext channel unavailable\r\n" + }, + { + "file": "examples/companion_radio/MyMesh.cpp", + "verbose": " ERROR: message must be 1-%u UTF-8 bytes for this node name\r\n", + "concise": " ERROR: message 1-%u UTF-8 bytes for node name\r\n" + }, + { + "file": "examples/companion_radio/MyMesh.cpp", + "verbose": " ERROR: another Companion request is still pending\r\n", + "concise": " ERROR: Companion request pending\r\n" + }, + { + "file": "examples/companion_radio/MyMesh.cpp", + "verbose": " ERROR: remote command must be 1-%u UTF-8 bytes\r\n", + "concise": " ERROR: remote command 1-%u UTF-8 bytes\r\n" + }, + { + "file": "examples/companion_radio/MyMesh.cpp", + "verbose": " ERROR: unable to send remote command\r\n", + "concise": " ERROR: remote command send failed\r\n" + }, + { + "file": "examples/companion_radio/MyMesh.cpp", + "verbose": " ERROR: trace path must contain 1-63 prefixes\r\n", + "concise": " ERROR: trace path 1-63 prefixes\r\n" + }, + { + "file": "examples/companion_radio/MyMesh.cpp", + "verbose": " ERROR: trace hash size must be 1, 2, or 4 bytes\r\n", + "concise": " ERROR: trace hashes need 1, 2, or 4 bytes\r\n" + }, + { + "file": "examples/companion_radio/MyMesh.cpp", + "verbose": " ERROR: unable to estimate trace timeout\r\n", + "concise": " ERROR: trace timeout estimate failed\r\n" + }, + { + "file": "examples/companion_radio/MyMesh.cpp", + "verbose": " ERROR: trace tag is quarantined or reply history is full\r\n", + "concise": " ERROR: trace quarantined or reply history full\r\n" + }, + { + "file": "examples/companion_radio/MyMesh.cpp", + "verbose": " ERROR: recipient has no traceable round-trip path\r\n", + "concise": " ERROR: recipient has no trace return path\r\n" + }, + { + "file": "examples/companion_radio/MyMesh.cpp", + "verbose": " ERROR: every prefix must be exactly %u hex digits\r\n", + "concise": " ERROR: each prefix needs %u hex digits\r\n" + }, + { + "file": "examples/companion_radio/MyMesh.cpp", + "verbose": " ERROR: trace path must contain at most 63 prefixes\r\n", + "concise": " ERROR: trace path max 63 prefixes\r\n" + }, + { + "file": "examples/companion_radio/MyMesh.cpp", + "verbose": " ERROR: no recipient selected (use 'to' first)\r\n", + "concise": " ERROR: choose recipient with 'to'\r\n" + }, + { + "file": "examples/companion_radio/MyMesh.cpp", + "verbose": " ERROR: message must be 1-%u characters\r\n", + "concise": " ERROR: message 1-%u characters\r\n" + }, + { + "file": "examples/companion_radio/MyMesh.cpp", + "verbose": " ERROR: FEM RX gain control is unsupported on this board\r\n", + "concise": " ERROR: FEM RX gain unsupported\r\n" + }, + { + "file": "examples/companion_radio/MyMesh.cpp", + "verbose": " ERROR: FEM TX gain control is unsupported on this board\r\n", + "concise": " ERROR: FEM TX gain unsupported\r\n" + }, + { + "file": "examples/companion_radio/MyMesh.cpp", + "verbose": " ERROR: airtime factor must be 0-9\r\n", + "concise": " ERROR: airtime factor 0-9\r\n" + }, + { + "file": "examples/companion_radio/MyMesh.cpp", + "verbose": " ERROR: airtime factor could not be saved\r\n", + "concise": " ERROR: airtime factor not saved\r\n" + }, + { + "file": "examples/companion_radio/MyMesh.cpp", + "verbose": " ERROR: name could not be saved\r\n", + "concise": " ERROR: name not saved\r\n" + }, + { + "file": "examples/companion_radio/MyMesh.cpp", + "verbose": " ERROR: latitude must be -90 to 90\r\n", + "concise": " ERROR: latitude -90 to 90\r\n" + }, + { + "file": "examples/companion_radio/MyMesh.cpp", + "verbose": " ERROR: longitude must be -180 to 180\r\n", + "concise": " ERROR: longitude -180 to 180\r\n" + }, + { + "file": "examples/companion_radio/MyMesh.cpp", + "verbose": " ERROR: latitude could not be saved\r\n", + "concise": " ERROR: latitude not saved\r\n" + }, + { + "file": "examples/companion_radio/MyMesh.cpp", + "verbose": " ERROR: longitude could not be saved\r\n", + "concise": " ERROR: longitude not saved\r\n" + }, + { + "file": "src/helpers/CommonCLI.cpp", + "verbose": "ERR: webconfig not supported on this build", + "concise": "ERR: webconfig unavailable" + }, + { + "file": "src/helpers/CommonCLI.cpp", + "verbose": "LoRa OTA needs temp radio on every node. Run: tempradio 909.950,250,5,5,120", + "concise": "LoRa OTA: tempradio 909.950,250,5,5,120 on every node" + }, + { + "file": "src/helpers/CommonCLI.cpp", + "verbose": "LoRa OTA absent; tempradio cannot enable it. Install OTA repeater firmware", + "concise": "LoRa OTA absent; install OTA repeater; tempradio cannot enable it" + }, + { + "file": "src/helpers/CommonCLI.cpp", + "verbose": "LoRa OTA is not included in this build; tempradio cannot enable it. Use an OTA-enabled repeater firmware", + "concise": "LoRa OTA absent; install OTA repeater; tempradio cannot enable it" + }, + { + "file": "src/helpers/CommonCLI.cpp", + "verbose": "gps_interval must be 0..86400 seconds", + "concise": "gps_interval 0..86400 seconds" + }, + { + "file": "src/helpers/CommonCLI.cpp", + "verbose": "repeat.trace is only supported by repeaters", + "concise": "repeat.trace needs a repeater" + }, + { + "file": "src/helpers/CommonCLI.cpp", + "verbose": "GPS sync interval must be 1..336 hours", + "concise": "GPS sync interval 1..336 hours" + }, + { + "file": "src/helpers/CommonCLI.cpp", + "verbose": "ESP-NOW channel must be 1-13", + "concise": "ESP-NOW channel 1-13" + }, + { + "file": "src/helpers/CommonCLI.cpp", + "verbose": "webui not supported on this build", + "concise": "webui unavailable" + }, + { + "file": "src/helpers/CommonCLI.cpp", + "verbose": "WiFi CLI unavailable on this build", + "concise": "WiFi CLI unavailable" + }, + { + "file": "src/helpers/CommonCLI.cpp", + "verbose": "standalone WiFi settings unavailable on this build", + "concise": "WiFi settings unavailable" + }, + { + "file": "src/helpers/CommonCLI.cpp", + "verbose": "AGC interval must be 0-1020 seconds", + "concise": "AGC interval 0-1020 seconds" + }, + { + "file": "src/helpers/CommonCLI.cpp", + "verbose": "interval must be 0 (off) or 3-168 hours", + "concise": "interval 0 (off)|3-168 hours" + }, + { + "file": "src/helpers/CommonCLI.cpp", + "verbose": "stop Morse before changing LoRa FEM gain", + "concise": "stop Morse before setting FEM gain" + }, + { + "file": "src/helpers/CommonCLI.cpp", + "verbose": "failed to apply LoRa FEM RX gain", + "concise": "LoRa FEM RX gain failed" + }, + { + "file": "src/helpers/CommonCLI.cpp", + "verbose": "failed to apply LoRa FEM TX gain", + "concise": "LoRa FEM TX gain failed" + }, + { + "file": "src/helpers/CommonCLI.cpp", + "verbose": "ERROR: RX powersaving unsupported", + "concise": "ERROR: RXPS unsupported" + }, + { + "file": "src/helpers/CommonCLI.cpp", + "verbose": "ERROR: level range is 1-10; preamble is 16 or 32", + "concise": "ERROR: level 1-10; preamble 16|32" + }, + { + "file": "src/helpers/CommonCLI.cpp", + "verbose": "delay must be between 0-10000 ms", + "concise": "delay 0-10000 ms" + }, + { + "file": "src/helpers/CommonCLI.cpp", + "verbose": "Error: baud rate must be between 9600-%d", + "concise": "Error: baud 9600-%d" + }, + { + "file": "src/helpers/CommonCLI.cpp", + "verbose": "Error: UART must be %d or %d", + "concise": "Error: UART %d|%d" + }, + { + "file": "src/helpers/CommonCLI.cpp", + "verbose": "Error: UART is fixed at %d", + "concise": "Error: UART fixed at %d" + }, + { + "file": "src/helpers/CommonCLI.cpp", + "verbose": "Error: channel must be an integer between 1-%u for %s format", + "concise": "Error: integer channel 1-%u for %s" + }, + { + "file": "src/helpers/CommonCLI.cpp", + "verbose": "Error: secret must be 1-%u characters", + "concise": "Error: secret 1-%u characters" + }, + { + "file": "src/helpers/CommonCLI.cpp", + "verbose": "format must be wrapped or raw", + "concise": "format wrapped|raw" + }, + { + "file": "src/helpers/CommonCLI.cpp", + "verbose": "set bridge.channel to 1-13 before changing format", + "concise": "set bridge.channel 1-13 before format" + }, + { + "file": "src/helpers/CommonCLI.cpp", + "verbose": "interval range is 1-255 hours", + "concise": "interval 1-255 hours" + }, + { + "file": "src/helpers/CommonCLI.cpp", + "verbose": "extra SFs unavailable with dual radio", + "concise": "extra SFs unavailable on dual radio" + }, + { + "file": "src/helpers/CommonCLI.cpp", + "verbose": "extra.sf requires an LR2021 radio", + "concise": "extra.sf needs LR2021" + }, + { + "file": "src/helpers/CommonCLI.cpp", + "verbose": "WiFi information unavailable on this build", + "concise": "WiFi info unavailable" + }, + { + "file": "examples/simple_repeater/MyMesh.cpp", + "verbose": "Err - field is sender/n or text/x", + "concise": "Err - field: sender/n|text/x" + }, + { + "file": "examples/simple_repeater/MyMesh.cpp", + "verbose": "Err - rule slot is empty", + "concise": "Err - empty rule slot" + }, + { + "file": "examples/simple_repeater/MyMesh.cpp", + "verbose": "Err - enabled is on or off", + "concise": "Err - enabled: on|off" + }, + { + "file": "examples/simple_repeater/MyMesh.cpp", + "verbose": "Err - rule parameters too long", + "concise": "Err - rule params too long" + }, + { + "file": "examples/simple_repeater/MyMesh.cpp", + "verbose": "Err - mode is radio, bridge, cross, or bridge,cross", + "concise": "Err - mode: radio|bridge|cross|bridge,cross" + }, + { + "file": "examples/simple_repeater/MyMesh.cpp", + "verbose": "Err - compact flags are s, t, and/or r", + "concise": "Err - flags: s|t|r (combinable)" + }, + { + "file": "examples/simple_repeater/MyMesh.cpp", + "verbose": "Err - unknown or ambiguous channel key reference; use the original key", + "concise": "Err - ambiguous/unknown channel key reference; use original key" + }, + { + "file": "examples/simple_repeater/MyMesh.cpp", + "verbose": "Err - channel must be *, public, #channel, hash:XX, or a key", + "concise": "Err - channel: *|public|#channel|hash:XX|key" + }, + { + "file": "examples/simple_repeater/MyMesh.cpp", + "verbose": "Err - hashbytes must be any, 1, 2, 2+, or 3", + "concise": "Err - hashbytes: any|1|2|2+|3" + }, + { + "file": "examples/simple_repeater/MyMesh.cpp", + "verbose": "Err - path is [^]ID>...[$], up to four 1/2/3-byte IDs", + "concise": "Err - path: [^]ID>...[$], max four 1/2/3-byte IDs" + }, + { + "file": "examples/simple_repeater/MyMesh.cpp", + "verbose": "Err - hops must be all, N, N+, or N-M (0-63)", + "concise": "Err - hops: all|N|N+|N-M (0-63)" + }, + { + "file": "examples/simple_repeater/MyMesh.cpp", + "verbose": "Err - tx timing requires scope= or region=", + "concise": "Err - tx needs scope=|region=" + }, + { + "file": "examples/simple_repeater/MyMesh.cpp", + "verbose": "Err - drop cannot be combined with rate", + "concise": "Err - drop conflicts with rate" + }, + { + "file": "examples/simple_repeater/MyMesh.cpp", + "verbose": "Err - drop cannot be combined with retry", + "concise": "Err - drop conflicts with retry" + }, + { + "file": "examples/simple_repeater/MyMesh.cpp", + "verbose": "Err - channel matcher requires type=any|grp_txt|grp_data", + "concise": "Err - channel needs type=any|grp_txt|grp_data" + }, + { + "file": "examples/simple_repeater/MyMesh.cpp", + "verbose": "Err - bridge/cross supports drop, rate=, priority=, stop only", + "concise": "Err - bridge/cross: drop|rate=|priority=|stop only" + }, + { + "file": "examples/simple_repeater/MyMesh.cpp", + "verbose": "Err - flood.rule requires drop, scope=, region=, rate=, retry, or stop", + "concise": "Err - flood.rule: drop|scope=|region=|rate=|retry|stop" + }, + { + "file": "examples/simple_repeater/MyMesh.cpp", + "verbose": "Err - content needs a keyed group-text channel; scope/retry needs flood route", + "concise": "Err - content needs keyed group-text channel; flood scope/retry only" + }, + { + "file": "examples/simple_repeater/MyMesh.cpp", + "verbose": "Err - use get flood.channel.scope[.n]", + "concise": "Err - get flood.channel.scope[.n]" + }, + { + "file": "examples/simple_repeater/MyMesh.cpp", + "verbose": "Err - use: del flood.channel.scope.|all", + "concise": "Err - del flood.channel.scope.|all" + }, + { + "file": "examples/simple_repeater/MyMesh.cpp", + "verbose": "Err - use get flood.channel.scope.require[.n]", + "concise": "Err - get flood.channel.scope.require[.n]" + }, + { + "file": "examples/simple_repeater/MyMesh.cpp", + "verbose": "Err - use: set flood.channel.scope.require[.n] ", + "concise": "Err - set flood.channel.scope.require[.n] " + }, + { + "file": "examples/simple_repeater/MyMesh.cpp", + "verbose": "Err - use: del flood.channel.scope.require.|all", + "concise": "Err - del flood.channel.scope.require.|all" + }, + { + "file": "examples/companion_radio/MyMesh.cpp", + "verbose": "stop Morse before changing LoRa FEM gain", + "concise": "stop Morse before FEM gain" + }, + { + "file": "examples/companion_radio/MyMesh.cpp", + "verbose": "failed to apply FEM RX gain", + "concise": "FEM RX gain failed" + }, + { + "file": "examples/companion_radio/MyMesh.cpp", + "verbose": "failed to apply FEM TX gain", + "concise": "FEM TX gain failed" + }, + { + "file": "examples/companion_radio/MyMesh.cpp", + "verbose": "Reader unavailable during Morse", + "concise": "Reader blocked by Morse" + }, + { + "file": "examples/companion_radio/MyMesh.cpp", + "verbose": " ERROR: stop Morse before changing LoRa FEM gain\r\n", + "concise": " ERROR: stop Morse before FEM gain\r\n" + }, + { + "file": "examples/companion_radio/MyMesh.cpp", + "verbose": " ERROR: failed to apply FEM RX gain\r\n", + "concise": " ERROR: FEM RX gain failed\r\n" + }, + { + "file": "examples/companion_radio/MyMesh.cpp", + "verbose": " ERROR: failed to apply FEM TX gain\r\n", + "concise": " ERROR: FEM TX gain failed\r\n" + }, + { + "file": "examples/companion_radio/MyMesh.cpp", + "verbose": " ERROR: Reader unavailable during Morse\r\n", + "concise": " ERROR: Reader blocked by Morse\r\n" + }, + { + "file": "examples/companion_radio/MyMesh.cpp", + "verbose": " ERROR: unable to allocate trace packet\r\n", + "concise": " ERROR: trace allocation failed\r\n" + }, + { + "file": "examples/companion_radio/MyMesh.cpp", + "verbose": " ERROR: trace timeout is out of range\r\n", + "concise": " ERROR: trace timeout invalid\r\n" + }, + { + "file": "examples/companion_radio/MyMesh.cpp", + "verbose": " ERROR: command to %s is still pending\r\n", + "concise": " ERROR: command to %s pending\r\n" + }, + { + "file": "examples/companion_radio/MyMesh.cpp", + "verbose": " ERROR: trace to %s is still pending\r\n", + "concise": " ERROR: trace to %s pending\r\n" } ], "noncompact_tokens_sha256": { - "src/helpers/CommonCLI.cpp": "17b89894dbcb4998f430d8bd6f3478b903f30bcfa633a7750c7771534a546548", + "src/helpers/CommonCLI.cpp": "42669159fbdc7361dac4c4d0b2654bfbd84118b6223c1f44ecf83b452b8224a7", "examples/simple_repeater/MyMesh.cpp": "8567eeef2f327100e397c40b0aaf5b675fcdfea254987437d1ca8d005ad3a6f0", "src/helpers/RadioProfileCLI.cpp": "d641d16e872eba3e73b9b5a50f9718c10b0b7da72782f793706ac36b3724a624", "src/helpers/WirelessControl.h": "f19bb602999118cc423ec74c7de7100ed91cb2c553295038077e52e321bb4e73", @@ -949,7 +1414,7 @@ "src/helpers/FloodAdvertCLI.h": "0f378e4d0f6c3b9b79991068a4b4208139484d2144ee1d7c5094d39043463be4", "src/helpers/LedSettings.cpp": "df0681234ca1337148ad59caa935857bc692f98bf18603ca6d099c0382028400", "src/helpers/StorageLayout.cpp": "5dd1c8bcbe2902e1f22d14f9ab425465bdc38926fae23989987d850b484cbb18", - "examples/companion_radio/MyMesh.cpp": "73a0ed65b2c37ea05c0d824e3f048bc0bb956c4f4d5944e43e01b91be93f0764", + "examples/companion_radio/MyMesh.cpp": "ba3a84680c0adad11b4397ed2772d43c18c7189e577f480084c94cda6932a3ca", "src/helpers/CommonRadioPrefs.cpp": "16b1fb499426abde83566b6481df89b80302c72e00f0f4aa3843b6c9b9de4218", "examples/companion_radio/CompanionRetry.h": "e0ae10c7b2f778346f4645c4e147d87e1fbc7f8b99484752a8eed914bb21380f", "examples/companion_radio/Notifications.cpp": "173fb0d452fdd383585dd88ab35fbb079a90e068adca2db9bc44d604557d3873" diff --git a/test/fixtures/trace_profile_timing.cpp b/test/fixtures/trace_profile_timing.cpp index c663a46ee..b66927c29 100644 --- a/test/fixtures/trace_profile_timing.cpp +++ b/test/fixtures/trace_profile_timing.cpp @@ -7,6 +7,7 @@ #include #include #include +#include #include #include diff --git a/test/test_admin_cli_reply_scope.py b/test/test_admin_cli_reply_scope.py index a87fedf0e..8b26b8f16 100644 --- a/test/test_admin_cli_reply_scope.py +++ b/test/test_admin_cli_reply_scope.py @@ -21,6 +21,7 @@ HARNESS = r''' #include #include #include +#include #include #include #include diff --git a/test/test_cli_reply_text.py b/test/test_cli_reply_text.py index 19608df3c..2533f5c88 100644 --- a/test/test_cli_reply_text.py +++ b/test/test_cli_reply_text.py @@ -37,8 +37,9 @@ class CliReplyTextTests(unittest.TestCase): self.assertLess(len(new), len(old)) formats = r'%(?:[-+ #0]*[0-9.*]*[a-zA-Z]+|%)' self.assertEqual(re.findall(formats, old), re.findall(formats, new)) - category = r'^(Error|ERROR|ERR|Err|OK)(?=[: ,\-])' + category = r'^[ \t]*(Error|ERROR|ERR|Err|OK)(?=[: ,\-])' self.assertEqual(re.findall(category, old), re.findall(category, new)) + self.assertEqual(re.findall(r'[\r\n]', old), re.findall(r'[\r\n]', new)) self.assertEqual(re.findall(r'\d+(?:\.\d+)?', old), re.findall(r'\d+(?:\.\d+)?', new)) self.assertFalse(old.startswith(('> ', '{', 'KEYBACKUP ', 'ACK'))) @@ -105,8 +106,8 @@ class CliReplyTextTests(unittest.TestCase): self.assertEqual(built.returncode, 0, built.stderr.decode(errors='replace')) result = subprocess.run([str(work / 'test')], capture_output=True) self.assertEqual(result.returncode, 0, result.stderr.decode(errors='replace')) - self.assertEqual(result.stdout.decode().splitlines(), - [row[key] for row in messages]) + self.assertEqual(result.stdout, + ''.join(row[key] + '\n' for row in messages).encode()) if key == 'concise': image = (work / 'test').read_bytes() selected = {row['concise'] for row in messages} diff --git a/test/test_ed25519_compact_field.py b/test/test_ed25519_compact_field.py index 2593850aa..a70ca7202 100644 --- a/test/test_ed25519_compact_field.py +++ b/test/test_ed25519_compact_field.py @@ -3,7 +3,10 @@ import ctypes from pathlib import Path import random +import shutil +import struct import subprocess +import sys import tempfile import unittest @@ -69,6 +72,9 @@ class Ed25519CompactFieldTest(unittest.TestCase): library.fe_tobytes.restype = None library.fe_isnonzero.argtypes = [ctypes.POINTER(ctypes.c_int32)] library.fe_isnonzero.restype = ctypes.c_int + for name in ("fe_invert", "fe_pow22523"): + getattr(library, name).argtypes = [ctypes.POINTER(ctypes.c_int32)] * 2 + getattr(library, name).restype = None cls.libraries.append(library) @classmethod @@ -271,6 +277,138 @@ int main(void) { str(harness), str(ED25519 / "fe.c"), "-o", str(executable)], check=True) subprocess.run([str(executable)], check=True) + def test_shared_powers_match_original_limbs_integer_oracle_and_aliases(self): + values = vectors(0x250255, 1500) + bounds = [110 * (1 << (26 - (i & 1))) // 100 for i in range(10)] + for index, value in enumerate(values): + value = [min(bound, max(-bound, limb)) for limb, bound in zip(value, bounds)] + for name, exponent in (("fe_invert", PRIME - 2), ("fe_pow22523", 2**252 - 3)): + reference = Field() + getattr(self.libraries[0], name)(reference, Field(*value)) + expected = pow(integer(value), exponent, PRIME) + for alias in (False, True): + data = Field(*value) + output = data if alias else Field() + getattr(self.libraries[1], name)(output, data) + self.assertEqual(list(output), list(reference), (index, name, alias)) + self.assertEqual(integer(output), expected, (index, name, alias)) + self.check_bounds(output) + + def test_shared_powers_under_address_and_undefined_sanitizers(self): + rows = [] + for value in vectors(0xFA250, 32)[::29]: + bounds = [110 * (1 << (26 - (i & 1))) // 100 for i in range(10)] + value = [min(bound, max(-bound, limb)) for limb, bound in zip(value, bounds)] + outputs = [] + for name in ("fe_invert", "fe_pow22523"): + output = Field() + getattr(self.libraries[0], name)(output, Field(*value)) + outputs.append(list(output)) + rows.append("{" + ",".join("{" + ",".join(map(str, item)) + "}" + for item in (value, *outputs)) + "}") + source = r''' +#include +#include +#include "fe.h" +struct Vector { fe input, inverse, power; }; +static const struct Vector vectors[] = { @VECTORS@ }; +int main(void) { + for (unsigned int i = 0; i < sizeof(vectors) / sizeof(vectors[0]); ++i) { + const struct Vector *v = &vectors[i]; + fe output; + fe_invert(output, v->input); + assert(memcmp(output, v->inverse, sizeof(fe)) == 0); + memcpy(output, v->input, sizeof(fe)); + fe_invert(output, output); + assert(memcmp(output, v->inverse, sizeof(fe)) == 0); + fe_pow22523(output, v->input); + assert(memcmp(output, v->power, sizeof(fe)) == 0); + memcpy(output, v->input, sizeof(fe)); + fe_pow22523(output, output); + assert(memcmp(output, v->power, sizeof(fe)) == 0); + } +} +'''.replace("@VECTORS@", ",\n".join(rows)) + with tempfile.TemporaryDirectory() as directory: + harness = Path(directory) / "powers.c" + harness.write_text(source) + executable = Path(directory) / "powers" + subprocess.run(["cc", "-std=c99", "-O1", "-g", "-Wall", "-Wextra", "-Werror", + "-fsanitize=address,undefined", "-fno-sanitize-recover=all", "-no-pie", + "-DED25519_COMPACT_FIELD=1", "-I", str(ED25519), + str(harness), str(ED25519 / "fe.c"), "-o", str(executable)], check=True) + subprocess.run([str(executable)], check=True) + + def test_arm_shared_powers_match_oracle_aliases_and_stay_within_stack_budget(self): + compiler = shutil.which("arm-none-eabi-gcc") + if not compiler: + candidate = Path.home() / ".platformio/packages/toolchain-gccarmnoneeabi/bin/arm-none-eabi-gcc" + if candidate.exists(): + compiler = str(candidate) + try: + from unicorn import Uc, UC_ARCH_ARM, UC_MODE_THUMB, UC_MODE_MCLASS, UC_HOOK_CODE + from unicorn import arm_const as arm + except ImportError: + self.skipTest("Unicorn is not installed in this host job") + if not compiler: + self.skipTest("ARM compiler is not installed in this host job") + sys.path.insert(0, str(ROOT / "scripts")) + from firmware_elf import FirmwareElf + + with tempfile.TemporaryDirectory() as directory: + support = Path(directory) / "memory.c" + support.write_text('''#include +void *memset(void *dest, int value, size_t count) { + volatile unsigned char *p = dest; + for (size_t i = 0; i < count; ++i) p[i] = (unsigned char)value; + return dest; +} +''') + executable = Path(directory) / "powers.elf" + subprocess.run([compiler, "-std=c99", "-mcpu=cortex-m4", "-mthumb", "-Oz", + "-fno-inline-small-functions", "-nostdlib", "-fno-builtin", + "-DED25519_COMPACT_FIELD=1", "-I", str(ED25519), + str(ED25519 / "fe.c"), str(support), "-lgcc", + "-Wl,-Ttext=0x08000000,--entry=fe_invert", "-o", str(executable)], check=True) + elf = FirmwareElf(executable) + machine = Uc(UC_ARCH_ARM, UC_MODE_THUMB | UC_MODE_MCLASS) + machine.mem_map(0x08000000, 0x10000) + machine.mem_map(0x20000000, 0x10000) + for section in elf.sections: + if section[1] != 8 and section[2] & 2 and section[5]: + machine.mem_write(section[3], elf.section_bytes(section)) + stack_top, stop = 0x2000F000, 0x0800F000 + machine.mem_write(stop, b"\x00\xbe") + state = {"min_sp": stack_top} + + def instruction(current, _address, _size, _context): + state["min_sp"] = min(state["min_sp"], current.reg_read(arm.UC_ARM_REG_SP)) + + machine.hook_add(UC_HOOK_CODE, instruction) + for value in ([0] * 10, [1] + [0] * 9, [127, -19] * 5): + for name, exponent in (("fe_invert", PRIME - 2), ("fe_pow22523", 2**252 - 3)): + reference = Field() + getattr(self.libraries[0], name)(reference, Field(*value)) + for alias in (False, True): + source, output = 0x20001000, 0x20001100 + if alias: + output = source + machine.mem_write(source, struct.pack("<10i", *value)) + machine.reg_write(arm.UC_ARM_REG_R0, output) + machine.reg_write(arm.UC_ARM_REG_R1, source) + machine.reg_write(arm.UC_ARM_REG_SP, stack_top) + machine.reg_write(arm.UC_ARM_REG_LR, stop | 1) + state["min_sp"] = stack_top + machine.emu_start(elf.address(name) | 1, stop, timeout=10000000, count=10000000) + self.assertEqual(machine.reg_read(arm.UC_ARM_REG_PC), stop) + self.assertEqual(machine.reg_read(arm.UC_ARM_REG_SP), stack_top) + actual = struct.unpack("<10i", machine.mem_read(output, 40)) + self.assertEqual(list(actual), list(reference), (name, alias)) + self.assertEqual(integer(actual), pow(integer(value), exponent, PRIME)) + # Function frames only, excluding firmware callers and + # interrupts. The production 4 KiB stack gate remains. + self.assertLessEqual(stack_top - state["min_sp"], 512, (name, alias)) + def test_compact_products_under_address_and_undefined_sanitizers(self): values = vectors(0xC0FE, 128) # Keep every edge/carry-boundary vector and a bounded random selection. diff --git a/test/test_mini_repeater_logging.py b/test/test_mini_repeater_logging.py index ea31f2d2f..9fd65c93c 100644 --- a/test/test_mini_repeater_logging.py +++ b/test/test_mini_repeater_logging.py @@ -29,6 +29,7 @@ inline uint32_t micros() { return millis() * 1000U; } #include #include #include +#include #include #include "@PRODUCTION_UTILS@" #define MAX_ROUTE_HASH_BYTES @ROUTE_HASH_LIMIT@ diff --git a/test/test_nrf52_flash_trim.py b/test/test_nrf52_flash_trim.py index 74dad3fbc..eef8a07a9 100644 --- a/test/test_nrf52_flash_trim.py +++ b/test/test_nrf52_flash_trim.py @@ -124,16 +124,6 @@ class Nrf52FlashTrimTest(unittest.TestCase): other = Node(ROOT / "test/fixtures/CommonCLI.cpp") self.assertIs(TRIM.compile_shared_cli_replies(env, other), other) - companion = Node(ROOT / "examples/companion_radio/MyMesh.cpp") - result, flags = TRIM.compile_shared_companion_replies(env, companion) - self.assertIs(result, companion) - self.assertEqual(flags["CCFLAGS"], ["-Os", "-flto", "-fno-builtin-strcpy"]) - self.assertEqual(env["CCFLAGS"], ["-Os", "-flto"]) - for path in ("examples/simple_repeater/MyMesh.cpp", "test/fixtures/MyMesh.cpp", - "src/helpers/CommonCLI.cpp", "src/helpers/RadioProfileCLI.cpp"): - other = Node(ROOT / path) - self.assertIs(TRIM.compile_shared_companion_replies(env, other), other) - def test_companion_shared_replies_are_limited_to_the_qualified_rak_full_image(self): defaults = dict(platform="nordicnrf52", mcu="nrf52840", env_name="RAK_4631_companion_radio_full", @@ -147,10 +137,67 @@ class Nrf52FlashTrimTest(unittest.TestCase): dict(src_filter="+<../examples/companion_radio> +<../examples/simple_sensor>"), dict(flags="-DMESH_CLI_SHARE_REPLIES=0"), dict(flags="-UMESH_CLI_SHARE_REPLIES"), + dict(flags="-DMESH_CLI_SHARE_REPLY_COPIES=0"), + dict(flags="-UMESH_CLI_SHARE_REPLY_COPIES"), dict(flags="-DMESH_NRF52_COMPANION_TRIM=0")): with self.subTest(changes=changes): self.assertFalse(TRIM.companion_shared_replies_policy(**(defaults | changes))) + @unittest.skipUnless(shutil.which("g++") and shutil.which("nm") and shutil.which("objdump"), + "native GNU C++ and binutils required") + def test_shared_reply_copy_keeps_libc_bytes_return_and_one_out_of_line_lto_copy(self): + first = r''' +#include +#include +#include +extern char* other(char*, const char*); +int main() { + const char* values[] = {"", "OK", "Error: saved; pending", "UTF8: \xc3\xa9"}; + for (const char* value : values) { + char buffer[164]; + memset(buffer, 0x5a, sizeof(buffer)); + if (strcpy(buffer + 3, value) != buffer + 3) return 1; + if (strcmp(buffer + 3, value) || buffer[2] != 0x5a || + buffer[4 + strlen(value)] != 0x5a) return 2; + memset(buffer, 0x5a, sizeof(buffer)); + if (other(buffer + 3, value) != buffer + 3) return 3; + if (strcmp(buffer + 3, value) || buffer[2] != 0x5a || + buffer[4 + strlen(value)] != 0x5a) return 4; + } + return 0; +} +''' + second = r''' +#include +#include +char* other(char* destination, const char* source) { return strcpy(destination, source); } +''' + with tempfile.TemporaryDirectory() as folder: + folder = Path(folder) + (folder / "first.cpp").write_text(first) + (folder / "second.cpp").write_text(second) + for enabled in (0, 1): + binary = folder / ("copies-" + str(enabled)) + subprocess.run(["g++", "-std=c++11", "-Os", "-flto", "-Wall", "-Wextra", "-Werror", + "-DMESH_CLI_SHARE_REPLY_COPIES=" + str(enabled), "-I" + str(ROOT / "src"), + str(folder / "first.cpp"), str(folder / "second.cpp"), + str(ROOT / "src/helpers/CLIReplyCopy.cpp"), "-o", str(binary)], check=True) + subprocess.run([str(binary)], check=True) + symbols = subprocess.check_output(["nm", "-C", "--defined-only", str(binary)], text=True) + copies = [line for line in symbols.splitlines() if "mesh::cli::copyReply(" in line] + self.assertEqual(len(copies), enabled) + if enabled: + self.assertNotIn("clone", copies[0]) + assembly = subprocess.check_output(["objdump", "-d", "-C", str(binary)], text=True) + self.assertRegex(assembly, r'(?:call|jmp)[^\n]*mesh::cli::copyReply\(') + + for name in ("src/helpers/CommonCLI.cpp", "examples/companion_radio/MyMesh.cpp"): + text = (ROOT / name).read_text() + following = text.split("CLIReplyCopy.h", 1)[1] + self.assertNotRegex(following, r'\bstd::strcpy\b|(?") self.assertEqual(active["MIDDLEWARE"][0][1], "*Adafruit_VL53L0X*/*.cpp") + companion = BuildEnvironment(BUILD_FLAGS=list(base["BUILD_FLAGS"]), + SRC_FILTER=["+<../examples/companion_radio/*.cpp>"], CPPDEFINES=[], + PIOENV="RAK_4631_companion_radio_full") + TRIM.install(companion) + self.assertIn(("MESH_CLI_SHARE_REPLY_COPIES", 1), companion["CPPDEFINES"]) + self.assertEqual(companion["BUILD_FLAGS"], base["BUILD_FLAGS"]) + + for name, flag in (("RAK_4631_companion_radio_usb", ""), + ("Xiao_nrf52_companion_radio_full", ""), + ("RAK_4631_companion_radio_full", "-DMESH_CLI_SHARE_REPLIES=0"), + ("RAK_4631_companion_radio_full", "-DMESH_CLI_SHARE_REPLY_COPIES=0"), + ("RAK_4631_companion_radio_full", "-UMESH_CLI_SHARE_REPLY_COPIES")): + with self.subTest(name=name, flag=flag): + unchanged = BuildEnvironment(BUILD_FLAGS=[flag] if flag else [], + SRC_FILTER=["+<../examples/companion_radio/*.cpp>"], CPPDEFINES=[], + PIOENV=name) + TRIM.install(unchanged) + callbacks = [callback for callback, _ in unchanged.get("MIDDLEWARE", [])] + self.assertNotIn(TRIM.compile_shared_cli_replies, callbacks) + self.assertNotIn(("MESH_CLI_SHARE_REPLY_COPIES", 1), unchanged["CPPDEFINES"]) + + explicit = BuildEnvironment(BUILD_FLAGS=["-DMESH_CLI_SHARE_REPLY_COPIES=1"], + SRC_FILTER=["+<../examples/companion_radio/*.cpp>"], CPPDEFINES=[], + PIOENV="RAK_4631_companion_radio_full") + TRIM.install(explicit) + self.assertNotIn(("MESH_CLI_SHARE_REPLY_COPIES", 1), explicit["CPPDEFINES"]) + self.assertEqual(explicit["BUILD_FLAGS"], ["-DMESH_CLI_SHARE_REPLY_COPIES=1"]) + @unittest.skipUnless(shutil.which("cc"), "a native C compiler is required") def test_wrapped_domain_preserves_p256_and_rejects_unused_curves(self): fixture = ROOT / "test/fixtures/nrf52_flash_trim" diff --git a/test/test_ota_zero_hop_route.py b/test/test_ota_zero_hop_route.py index 5706e0dcf..0cb95ee9a 100644 --- a/test/test_ota_zero_hop_route.py +++ b/test/test_ota_zero_hop_route.py @@ -19,6 +19,7 @@ HARNESS = r''' #include #include #include +#include #include @CONSTANTS@ namespace mesh { diff --git a/test/test_power_saving_role_diagnostics.py b/test/test_power_saving_role_diagnostics.py index 12285fa6c..154cf8025 100644 --- a/test/test_power_saving_role_diagnostics.py +++ b/test/test_power_saving_role_diagnostics.py @@ -36,6 +36,13 @@ class PowerSavingRoleDiagnosticsTests(unittest.TestCase): self.execute(r''' #include #include +#include +#include +namespace mesh { namespace radioDirection { +Mode current = Mode::Both; +Mode mode() { return current; } +}} +namespace radioDirection = mesh::radioDirection; struct Radio { bool calibrating = false; bool isCalibratingNoiseFloor() const { return calibrating; } @@ -70,12 +77,22 @@ int main() { SpacedRadio spaced; node._radio = &spaced; assert(node.getNextNoiseFloorWakeDelay(delay) && delay == 50 && spaced.reads == 1); + for (auto mode : {radioDirection::Mode::Tx, radioDirection::Mode::Off}) { + radioDirection::current = mode; + const auto clock_reads = clock.reads, radio_reads = spaced.reads; + delay = 73; + assert(!node.getNextNoiseFloorWakeDelay(delay) && delay == 73); + assert(clock.reads == clock_reads && spaced.reads == radio_reads); + } + radioDirection::current = radioDirection::Mode::Rx; + assert(node.getNextNoiseFloorWakeDelay(delay) && delay == 50 && spaced.reads == 2); node.radio_available = false; const auto before = clock.reads; assert(!node.getNextNoiseFloorWakeDelay(delay)); - assert(clock.reads == before && spaced.reads == 1); + assert(clock.reads == before && spaced.reads == 2); } -'''.replace('@DEFAULT@', conservative).replace('@FORWARDER@', forwarder)) +'''.replace('@DEFAULT@', conservative).replace('@FORWARDER@', forwarder), + defines=('MESH_ENABLE_RADIO_DIRECTION=1',)) def test_actual_role_guards_preserve_short_circuit_and_ignore_charger_only(self): for role in ("simple_repeater", "simple_room_server", "simple_sensor"): diff --git a/test/test_radio_tx_power_transaction.py b/test/test_radio_tx_power_transaction.py index e5780364f..9558d31f3 100644 --- a/test/test_radio_tx_power_transaction.py +++ b/test/test_radio_tx_power_transaction.py @@ -11,6 +11,7 @@ ROOT = Path(__file__).resolve().parents[1] HARNESS = r''' #include #include +#include #include #define MESH_DEBUG_PRINTLN(...) ((void)0) #define RADIOLIB_ERR_NONE 0 diff --git a/test/test_repeater_discovery.py b/test/test_repeater_discovery.py index 60e710db4..f20bd7b87 100644 --- a/test/test_repeater_discovery.py +++ b/test/test_repeater_discovery.py @@ -24,6 +24,7 @@ HARNESS = r''' #include #include "examples/simple_repeater/RateLimiter.h" #include "src/RadioProfiles.h" +#include "src/helpers/RadioDirectionPolicy.h" #include "src/helpers/DiscoveryResponsePrefs.h" @CONSTANTS@ #define MESH_DEBUG_PRINTLN(...) ((void)0) diff --git a/test/test_reply_tx_integration.py b/test/test_reply_tx_integration.py index f043776c8..dc464ee6d 100644 --- a/test/test_reply_tx_integration.py +++ b/test/test_reply_tx_integration.py @@ -20,6 +20,7 @@ class ReplyTxIntegrationTest(unittest.TestCase): #include #include #include +#include #include #include #include "mota_vectors.h" From 0b5e0116b57eafd659f5ba67d3b8d003816760a5 Mon Sep 17 00:00:00 2001 From: mikecarper Date: Wed, 28 Oct 2026 19:34:11 -0700 Subject: [PATCH 53/61] Include direct dependencies in crypto and WebConfig harnesses --- test/test_cc310_shared_scratch.py | 1 + test/test_sensor_webconfig_capabilities.py | 1 + 2 files changed, 2 insertions(+) diff --git a/test/test_cc310_shared_scratch.py b/test/test_cc310_shared_scratch.py index e10202888..9695910fb 100644 --- a/test/test_cc310_shared_scratch.py +++ b/test/test_cc310_shared_scratch.py @@ -72,6 +72,7 @@ HARNESS = r''' #include #include #include +#include #include #include thread_local bool mock_isr=false; diff --git a/test/test_sensor_webconfig_capabilities.py b/test/test_sensor_webconfig_capabilities.py index e8b533ee0..70b5627be 100644 --- a/test/test_sensor_webconfig_capabilities.py +++ b/test/test_sensor_webconfig_capabilities.py @@ -132,6 +132,7 @@ for value in "${BUILD_APPLICATION_EXPECTATIONS[@]}"; do printf 'A:%s\0' "$value" fixture = r''' #define MESH_MANAGEMENT_PASSWORD_HOST_TEST 1 #include +#include #include #include #include From 0ba56acead9d76da1f2c01b7833dc202ecdc1f3c Mon Sep 17 00:00:00 2001 From: mikecarper Date: Wed, 28 Oct 2026 19:41:56 -0700 Subject: [PATCH 54/61] Restore production header dependencies in terminal test harnesses --- test/test_cardputer_delivery.py | 1 + test/test_carrier_wave.py | 1 + test/test_companion_reader.py | 1 + test/test_morse_tx.py | 8 ++++++-- 4 files changed, 9 insertions(+), 2 deletions(-) diff --git a/test/test_cardputer_delivery.py b/test/test_cardputer_delivery.py index 4f2bff8e9..9a948430b 100644 --- a/test/test_cardputer_delivery.py +++ b/test/test_cardputer_delivery.py @@ -22,6 +22,7 @@ HARNESS = r''' #include #include #include +#include using std::isfinite; #define MESH_ENABLE_ONE_KEY_DM 1 #define ONE_KEY_DM_SHARED_OFFLINE_QUEUE 0 diff --git a/test/test_carrier_wave.py b/test/test_carrier_wave.py index 6da7c13a0..253e60f5d 100644 --- a/test/test_carrier_wave.py +++ b/test/test_carrier_wave.py @@ -10,6 +10,7 @@ ROOT = Path(__file__).resolve().parents[1] HARNESS = r''' #include #include +#include #include #include #include diff --git a/test/test_companion_reader.py b/test/test_companion_reader.py index 45b0a6291..7f162175e 100644 --- a/test/test_companion_reader.py +++ b/test/test_companion_reader.py @@ -502,6 +502,7 @@ int main() { harness = r''' #include #include +#include #include #include #include diff --git a/test/test_morse_tx.py b/test/test_morse_tx.py index 9ae453d66..bdfa94346 100644 --- a/test/test_morse_tx.py +++ b/test/test_morse_tx.py @@ -15,9 +15,13 @@ SANITIZERS = ["-fsanitize=address,undefined", "-fno-sanitize-recover=all", "-fno def portable_radio_header(): """Use the exact Radio interface without unrelated packet/crypto mocks.""" dispatcher = (ROOT / "src/Dispatcher.h").read_text(encoding="utf-8") - return ("#pragma once\n#include \n#include \n#include \n#include \n#include \n#include \nnamespace mesh {\n" + return ("#pragma once\n#include \n#include \n#include \n#include \n#include \n#include \n#include \nnamespace mesh {\n" + method(dispatcher, "enum class RadioParamApplyResult") + ";\n" - + method(dispatcher, "class Radio {") + ";\n}\n") + + method(dispatcher, "class Radio {") + ";\n}\n" + # This portable interface has no settings backend. Model its + # ordinary default while retaining the real direction predicates. + + "#if MESH_ENABLE_RADIO_DIRECTION\nnamespace mesh { namespace radioDirection {\n" + "inline Mode mode() { return Mode::Both; }\n}}\n#endif\n") def compile_run(testcase, code, includes=(), flags=(), headers=None): From 5e117cb89f5cbc91d6ec2bcd0b532fb208b6939e Mon Sep 17 00:00:00 2001 From: mikecarper Date: Wed, 28 Oct 2026 19:53:50 -0700 Subject: [PATCH 55/61] Update capability fixtures and cover primary preamble settings --- test/fixtures/cli_settings/main.cpp | 20 +++++++++++++++++++- test/fixtures/companion_rxps_getters.cpp | 18 ++++++++++++++++++ test/fixtures/webconfig_password_command.cpp | 20 ++++++++++++++++++++ test/test_cli_settings_contract.py | 3 +++ test/test_companion_identity_cli.py | 15 +++++++++++++++ test/test_management_password_commands.py | 19 +++++++++++++++++++ 6 files changed, 94 insertions(+), 1 deletion(-) diff --git a/test/fixtures/cli_settings/main.cpp b/test/fixtures/cli_settings/main.cpp index 2969bce82..cde53e40e 100644 --- a/test/fixtures/cli_settings/main.cpp +++ b/test/fixtures/cli_settings/main.cpp @@ -5,6 +5,8 @@ #include #include #include +#include +#include #include // Persistence has its own production-parser tests with an in-memory FS. // This fixture isolates the actual CommonCLI setting branches and callbacks. @@ -123,8 +125,9 @@ public: mesh::RadioParamApplyResult applyRxSetting(uint8_t); bool saveRxSetting(uint8_t, mesh::RadioParamApplyResult, char*); void restoreRxSetting(uint8_t, mesh::RadioParamApplyResult, uint8_t, uint32_t); - bool savePrimaryRadioParams(float freq, float bw, uint8_t sf, uint8_t cr, uint16_t) { + bool savePrimaryRadioParams(float freq, float bw, uint8_t sf, uint8_t cr, uint16_t preamble) { prefs.freq=freq; prefs.bw=bw; prefs.sf=sf; prefs.cr=cr; + prefs.primary_radio_preamble=preamble; prefs.tx_power_dbm=mesh::clampLoRaTxPower(prefs.tx_power_dbm,freq); callbacks.savePrefs(); return true; } @@ -233,6 +236,21 @@ int main() { cli.call(sender,"set radio 916,125,13,6",reply); assert(!strncmp(reply,"Error",5) && !strstr(reply,"WARN")); assert(cli._radio_profiles.warning_calls==2); + // radio.preamble reads the saved preference; the writer remains the + // optional fifth set radio field. Exercise that actual parser route. + struct Preamble { const char* input; const char* reply; uint16_t value; }; + for (const Preamble& row : {Preamble{"auto", "> auto", 0}, + {"8", "> 8", 8}, {"32", "> 32", 32}, {"88", "> 88", 88}, + {"65528", "> 65528", 65528}}) { + saves=cli.callbacks.saves; + snprintf(text,sizeof(text),"set radio 916,125,8,6,%s",row.input); + cli.call(sender,text,reply); + assert(!strncmp(reply,"OK",2) && cli.callbacks.saves==saves+1); + assert(cli.prefs.primary_radio_preamble==row.value); + cli.call(sender,"get radio.preamble",reply); + assert(!strcmp(reply,row.reply)); + assert(cli.callbacks.saves==saves+1); + } saves=cli.callbacks.saves; cli.call(sender,"set extra.sf 9,10",reply); #if defined(USE_LR2021) diff --git a/test/fixtures/companion_rxps_getters.cpp b/test/fixtures/companion_rxps_getters.cpp index 71bdd4a49..58c7676e3 100644 --- a/test/fixtures/companion_rxps_getters.cpp +++ b/test/fixtures/companion_rxps_getters.cpp @@ -1,5 +1,6 @@ #include #include +#include #include #include #include @@ -8,11 +9,26 @@ #include "helpers/radiolib/RXPowerSaving.h" #include "usb_mode_console.h" +// RXPS queries must not probe unrelated capability providers. Keep these +// boundaries present so the entire preceding capability branch compiles. +struct Board { + bool getSystemWatchdogRunning(bool&) { assert(false); return false; } + bool canControlLoRaFemLna() { assert(false); return false; } + bool canControlLoRaFemPaGain() { assert(false); return false; } + bool isLoRaFemPaGainEnabled() { assert(false); return false; } +} board; +struct UI { + bool supportsDisplayRotation() { assert(false); return false; } +}; +struct Profiles { + uint16_t primaryPreamble() const { assert(false); return 0; } +}; struct Radio { bool supported = true; bool fallback = false; unsigned fallback_queries = 0; bool supportsRxPowerSaving() const { return supported; } + bool supportsRxPowerSavingRfRxDisable() const { assert(false); return false; } bool isRxPowerSavingContinuousFallback() { ++fallback_queries; return fallback; } // No mutating radio APIs exist in this fixture: a write cannot compile. } radio_driver; @@ -42,6 +58,8 @@ struct Output { struct MyMesh { Prefs _prefs; + Profiles _radio_profiles; + UI* _ui = nullptr; bool temporary = false; uint8_t _temp_radio_sf = 5; float _temp_radio_bw = 500; diff --git a/test/fixtures/webconfig_password_command.cpp b/test/fixtures/webconfig_password_command.cpp index 0f1f911ad..f80e8cf02 100644 --- a/test/fixtures/webconfig_password_command.cpp +++ b/test/fixtures/webconfig_password_command.cpp @@ -15,6 +15,7 @@ HardwareSerial Serial; #include #include #include +#include #include #include namespace password = mesh::esp32::managementPassword; @@ -35,6 +36,23 @@ Debug& usbDebugPort() { static Debug value; return value; } struct StrHelper { static void strncpy(char* out, const char* in, size_t size) { ::strncpy(out, in, size); out[size - 1] = 0; }}; +// Preserve the full Companion command prefix without allowing password +// handling to consult unrelated hardware capability or profile providers. +struct Board { + bool getSystemWatchdogRunning(bool&) { assert(false); return false; } + bool canControlLoRaFemLna() { assert(false); return false; } + bool canControlLoRaFemPaGain() { assert(false); return false; } + bool isLoRaFemPaGainEnabled() { assert(false); return false; } +} board; +struct Radio { + bool supportsRxPowerSavingRfRxDisable() const { assert(false); return false; } +} radio_driver; +struct UI { + bool supportsDisplayRotation() { assert(false); return false; } +}; +struct Profiles { + uint16_t primaryPreamble() const { assert(false); return 0; } +}; struct CommonCLI { struct Prefs { char password[16] = "original"; } prefs; Prefs* _prefs = &prefs; @@ -44,6 +62,8 @@ struct CommonCLI { } }; struct MyMesh { + Profiles _radio_profiles; + UI* _ui = nullptr; char _management_password[16] = "original"; bool _management_password_ready = true; bool handleLocalControlCommand(const char*, char*, size_t); diff --git a/test/test_cli_settings_contract.py b/test/test_cli_settings_contract.py index d660eed2c..6726e3d50 100644 --- a/test/test_cli_settings_contract.py +++ b/test/test_cli_settings_contract.py @@ -59,6 +59,7 @@ COMMON_QUERY_EXCEPTIONS = { 'pwrmgt.bootreason': 'Boot/reset cause', 'pwrmgt.source': 'Measured power source', 'pwrmgt.support': 'Hardware power-management capabilities', + 'radio.preamble': 'Saved primary preference; written as the fifth set radio field', 'radio.rxps.config': 'Detailed view of set radio.rxps', 'radio.rxps.status': 'Live radio status for set radio.rxps', 'role': 'Build-selected firmware role', @@ -274,6 +275,7 @@ namespace mesh { struct Utils { 'public.key', # Read-only public identity 'pwrmgt.bootreason', 'role', # Boot/build facts 'radio.apply', # Pending/applied radio diagnostics + 'radio.preamble', # Written through the fifth set radio field 'radio.rxps.config', # Detail view of radio.rxps 'tx.status', # HIL-only applied-power verification 'wifi.status', 'wifi.ip', # Live WiFi link state @@ -319,6 +321,7 @@ namespace mesh { struct Utils { # omitted; board delegation has its own real G3 suite. set_blocks = [extract_braced(setter, 'if (strncmp(config, "path.hash.mode", 14)')] get_blocks = [extract_braced(getter, 'if (configKeyEquals(config, "path.hash.mode"))')] + get_blocks.append(extract_braced(getter, 'if (strcmp(config, "radio.preamble") == 0)')) set_blocks.append(extract_braced(setter, 'if (strcmp(config, "extra.sf") == 0 ||')) get_blocks.append(extract_braced(getter, 'if (strcmp(config, "extra.sf") == 0)')) for key in keys: diff --git a/test/test_companion_identity_cli.py b/test/test_companion_identity_cli.py index 08340814d..bb2d57d98 100644 --- a/test/test_companion_identity_cli.py +++ b/test/test_companion_identity_cli.py @@ -20,6 +20,7 @@ HARNESS = r''' #include #include #include +#include #include "usb_mode_console.h" // Keep the production diagnostics delegate in the command path. Identity @@ -35,7 +36,19 @@ struct IdentityBoard { bool supportsPowerManagement(){assert(false);return false;} uint16_t getBootVoltage(){assert(false);return 0;} bool getSystemWatchdogRunning(bool&){assert(false);return false;} + bool canControlLoRaFemLna(){assert(false);return false;} + bool canControlLoRaFemPaGain(){assert(false);return false;} + bool isLoRaFemPaGainEnabled(){assert(false);return false;} } board; +struct IdentityRadio { + bool supportsRxPowerSavingRfRxDisable(){assert(false);return false;} +} radio_driver; +struct IdentityUi { + bool supportsDisplayRotation(){assert(false);return false;} +}; +struct IdentityProfiles { + uint16_t primaryPreamble() const {assert(false);return 0;} +}; namespace mesh { @HEX_CHARS@ @@ -52,6 +65,8 @@ public: }; struct MyMesh { mesh::LocalIdentity self_id; + IdentityProfiles _radio_profiles; + IdentityUi* _ui = nullptr; unsigned fallback_calls=0; // Routing commands have their own production-method tests. This identity // harness exercises fall-through to the shared read-only key query. diff --git a/test/test_management_password_commands.py b/test/test_management_password_commands.py index 49a2b690d..53a08324e 100644 --- a/test/test_management_password_commands.py +++ b/test/test_management_password_commands.py @@ -22,6 +22,7 @@ class ManagementPasswordCommandsTest(unittest.TestCase): source = r''' #include #include +#include #include #include #include @@ -43,6 +44,22 @@ struct StrHelper { static void strncpy(char* out, const char* in, size_t size) { ::strncpy(out, in, size); out[size - 1] = 0; } }; struct NodePrefs { char password[16] = "password"; }; +// Password commands return before unrelated capability or profile queries. +struct Board { + bool getSystemWatchdogRunning(bool&) { assert(false); return false; } + bool canControlLoRaFemLna() { assert(false); return false; } + bool canControlLoRaFemPaGain() { assert(false); return false; } + bool isLoRaFemPaGainEnabled() { assert(false); return false; } +} board; +struct Radio { + bool supportsRxPowerSavingRfRxDisable() const { assert(false); return false; } +} radio_driver; +struct UI { + bool supportsDisplayRotation() { assert(false); return false; } +}; +struct Profiles { + uint16_t primaryPreamble() const { assert(false); return 0; } +}; class CommonCLI { public: NodePrefs prefs; NodePrefs* _prefs = &prefs; @@ -53,6 +70,8 @@ class CommonCLI { }; class MyMesh { public: + Profiles _radio_profiles; + UI* _ui = nullptr; char _management_password[16] = "password"; bool _management_password_ready = true; bool handleLocalControlCommand(const char*, char*, size_t); From 3bcd5ae68576d7390b2386ecf7f6a36c6ab0a767 Mon Sep 17 00:00:00 2001 From: mikecarper Date: Wed, 28 Oct 2026 22:34:12 -0700 Subject: [PATCH 56/61] Add acknowledged ESP-NOW peer delivery and physical split-radio checks --- .github/workflows/run-unit-tests.yml | 3 + docs/cli_commands.md | 7 + docs/espnow_bridge_setup.md | 17 + docs/split_radio_repeater.md | 20 + src/helpers/CommonCLI.cpp | 47 + src/helpers/CommonCLI.h | 1 + src/helpers/ESPNowPeerSettings.cpp | 142 +++ src/helpers/ESPNowPeerSettings.h | 20 + src/helpers/bridges/ESPNowBridge.cpp | 26 +- src/helpers/bridges/ESPNowBridge.h | 1 + src/helpers/bridges/ESPNowBridgeDiagnostics.h | 4 +- test/fixtures/cli_reply_text.json | 2 +- test/fixtures/espnow_bridge_diagnostics.cpp | 2 +- test/fixtures/espnow_wifi_handoff.cpp | 29 +- test/fixtures/wifi_ap_shared_lr_sdk.h | 5 + test/test_bridge_recovery.py | 7 +- test/test_espnow_peer.py | 273 +++++ test/test_espnow_radio_lifecycle.py | 6 + test/test_wifi_ap_lr_safety.py | 1 + tools/hil/split_radio_hardware_test.py | 1046 +++++++++++++++++ tools/hil/split_radio_probe.py | 153 +++ tools/hil/test_split_radio_hardware_test.py | 634 ++++++++++ tools/hil/test_split_radio_probe.py | 73 ++ 23 files changed, 2501 insertions(+), 18 deletions(-) create mode 100644 src/helpers/ESPNowPeerSettings.cpp create mode 100644 src/helpers/ESPNowPeerSettings.h create mode 100644 test/test_espnow_peer.py create mode 100644 tools/hil/split_radio_hardware_test.py create mode 100644 tools/hil/split_radio_probe.py create mode 100644 tools/hil/test_split_radio_hardware_test.py create mode 100644 tools/hil/test_split_radio_probe.py diff --git a/.github/workflows/run-unit-tests.yml b/.github/workflows/run-unit-tests.yml index 067c33574..a36c44740 100644 --- a/.github/workflows/run-unit-tests.yml +++ b/.github/workflows/run-unit-tests.yml @@ -1072,6 +1072,9 @@ jobs: python3 -B test/test_espnow_runtime.py -v python3 -B test/test_espnow_bridge_diagnostics.py -v python3 -B test/test_espnow_diagnostic_cli.py -v + python3 -B test/test_espnow_peer.py -v + python3 -B tools/hil/test_split_radio_probe.py -v + python3 -B tools/hil/test_split_radio_hardware_test.py -v python3 -B test/test_wifi_ota_lifecycle.py -v - name: Verify message reader buttons, touch targets, and footer layouts diff --git a/docs/cli_commands.md b/docs/cli_commands.md index 541e2939f..0744d5983 100644 --- a/docs/cli_commands.md +++ b/docs/cli_commands.md @@ -6545,8 +6545,15 @@ get espnow.stats rx get espnow.stats tx get espnow.stats drops get espnow.stats test +get espnow.peer ``` +`set espnow.peer AA:BB:CC:DD:EE:FF` saves a fixed unicast destination and +restarts an enabled bridge. Unicast uses MAC acknowledgements and up to three +retries after an explicit failed send, with unchanged bridge packet bytes. +`set espnow.peer broadcast` restores the default broadcast destination. +Successful MAC delivery does not confirm subsequent LoRa forwarding. + Status reports local state (`off`, `teardown-error`, `driver-error`, `channel-mismatch` or `ready`), then these compact fields: diff --git a/docs/espnow_bridge_setup.md b/docs/espnow_bridge_setup.md index 7c03a3a86..b85683f54 100644 --- a/docs/espnow_bridge_setup.md +++ b/docs/espnow_bridge_setup.md @@ -87,11 +87,28 @@ These settings are saved across reboot. Bridge changes apply immediately; changing an enabled bridge's channel, format, or secret restarts the bridge. Stopping it first lets you finish configuring both sides before connecting them. +For a fixed pair, use acknowledged unicast. Run `get espnow.stats mac` on each +board, then set each board's destination to the other board's station MAC: + +```text +set espnow.peer AA:BB:CC:DD:EE:FF +get espnow.peer +``` + +Use the actual peer MAC in place of the example. The packet bytes stay +compatible with older receivers. A failed unicast send is retried up to three +times; MAC success confirms radio reception, not subsequent LoRa forwarding. +The setting persists across reboot. `set espnow.peer broadcast` restores the +default, which lets multiple bridges receive each packet without acknowledgements. +Dedicated ESP-NOW operation disables WiFi modem sleep; an existing shared WiFi +connection keeps its power policy. + ## What each bridge option does | Option | Choices and purpose | | --- | --- | | `espnow.enabled` | `on` starts ESP-NOW; `off` stops it and keeps its saved settings. Independent of MQTT or RS-232. | +| `espnow.peer` | `broadcast` by default, or a single destination station MAC for acknowledged unicast. Changing it restarts a running bridge. | | `rs232.enabled` | On combined UART images, starts or stops RS-232 independently of MQTT and ESP-NOW. Use `get rs232.running` to check the live UART. | | `bridge.channel` | The 2.4 GHz channel, 1-13. Match it on both boards. This is separate from the LoRa frequency in `set radio`. | | `bridge.format` | `wrapped` is the default for this two-repeater setup and uses the shared secret. `raw` connects to primary-ESP-NOW nodes such as `Generic_ESPNOW` or `SenseCapIndicator-ESPNow`; it ignores the secret. Both peers must use compatible formats. | diff --git a/docs/split_radio_repeater.md b/docs/split_radio_repeater.md index dbfacaf69..73bffcee0 100644 --- a/docs/split_radio_repeater.md +++ b/docs/split_radio_repeater.md @@ -86,6 +86,7 @@ set bridge.route.1 mac:AA:BB:CC:DD:EE:10 radio2 set bridge.route.2 mac:AA:BB:CC:DD:EE:20 radio set repeat on set espnow.enabled on +get espnow.stats mac ``` The first example MAC represents the left V4; the second represents the right. @@ -96,6 +97,25 @@ checks still apply. The `mediamesh` preset also enables the normal MediaMesh forwarding region. The center's own adverts and replies retain their existing routing policies, including `tx.reply`; source rules select forwarded traffic. +Use the center's returned station MAC as the destination on **both receivers**: + +```text +set espnow.peer AA:BB:CC:DD:EE:30 +get espnow.peer +``` + +Replace the example with the center's actual MAC. Unicast enables MAC +acknowledgements and up to three retries after an explicit failed send. The +packet format is unchanged, so the receiving bridge can run older firmware. +A MAC acknowledgement confirms reception by the peer radio; check the output +LoRa network to verify forwarding. The setting persists and restarts an +already running bridge. `set espnow.peer broadcast` restores the default +broadcast behavior, which has no receiver acknowledgement. + +A dedicated ESP-NOW bridge keeps WiFi modem sleep disabled for prompt packet +reception. A bridge sharing an active WiFi connection retains that connection's +power policy and must use its channel. + ## WiFi UDP alternative Keep the LoRa settings and direction modes above. Disable ESP-NOW on all diff --git a/src/helpers/CommonCLI.cpp b/src/helpers/CommonCLI.cpp index 1e231c3bf..0fafd6ffb 100644 --- a/src/helpers/CommonCLI.cpp +++ b/src/helpers/CommonCLI.cpp @@ -21,6 +21,9 @@ #include "HilStartupTrace.h" #include "FilePresence.h" #include "BridgeIngressSettings.h" +#if defined(WITH_ESPNOW_BRIDGE) +#include "ESPNowPeerSettings.h" +#endif #include "RadioDirectionSettings.h" #if defined(ESP32_PLATFORM) #include "BootSlot.h" @@ -1020,6 +1023,9 @@ static void formatSnrDbX4Short(char* dest, size_t dest_len, int16_t snr_x4) { } void CommonCLI::loadPrefs(FILESYSTEM* fs) { +#if defined(WITH_ESPNOW_BRIDGE) + mesh::bridge::loadEspNowPeerSettings(fs); +#endif #if MESH_ENABLE_RADIO_DIRECTION mesh::radioDirection::loadDirectionSettings(fs); #endif @@ -3437,6 +3443,46 @@ bool CommonCLI::handleRs232DiagnosticCommand(uint32_t sender_timestamp, #endif #if defined(WITH_ESPNOW_BRIDGE) +bool CommonCLI::handleEspNowPeerCommand(const char* command, char* reply) { + constexpr size_t capacity = 157; + const bool get = !strncmp(command, "get espnow.peer", 15) && + (!command[15] || command[15] == ' ' || command[15] == '\t'); + const bool set = !strncmp(command, "set espnow.peer", 15) && + (!command[15] || command[15] == ' ' || command[15] == '\t'); + if (!get && !set) return false; + if (get) { + if (command[15]) mesh::cli::formatError(reply, capacity, "use get espnow.peer"); + else mesh::bridge::formatEspNowPeer(reply, capacity); + return true; + } + const char* value = command + 15; + while (*value == ' ' || *value == '\t') ++value; + uint8_t destination[6], previous[6]; + if (!mesh::bridge::parseEspNowPeer(value, destination)) { + mesh::cli::formatError(reply, capacity, "use set espnow.peer AA:BB:CC:DD:EE:FF|broadcast"); + return true; + } + const bool unchanged = mesh::bridge::copyEspNowPeer(previous) && + !memcmp(previous, destination, sizeof(previous)); + if (!mesh::bridge::saveEspNowPeer(destination)) { + if (!mesh::bridge::espNowPeerSettingsHealthy()) _callbacks->setEspNowBridgeState(false); + mesh::cli::formatError(reply, capacity, "ESP-NOW peer not saved%s", + mesh::bridge::espNowPeerSettingsHealthy() ? "" : "; bridge blocked until reload"); + return true; + } + const bool enabled = +#if defined(WITH_MQTT_BRIDGE) || defined(WITH_RS232_BRIDGE) + _prefs->espnow_bridge_enabled; +#else + _prefs->bridge_enabled; +#endif + if (!unchanged && enabled && !_callbacks->restartEspNowBridge()) { + _callbacks->setEspNowBridgeState(false); + mesh::cli::formatError(reply, capacity, "peer saved; ESP-NOW bridge failed to restart"); + } else snprintf(reply, capacity, unchanged ? "OK - espnow.peer unchanged" : "OK - espnow.peer saved"); + return true; +} + bool CommonCLI::handleEspNowDiagnosticCommand(uint32_t sender_timestamp, const char* command, char* reply) { constexpr size_t capacity = 157; // Leave room for an infrastructure reply prefix. @@ -3598,6 +3644,7 @@ void CommonCLI::handleCommand(uint32_t sender_timestamp, char* command, char* re if (handleRs232DiagnosticCommand(sender_timestamp, command, reply)) return; #endif #if defined(WITH_ESPNOW_BRIDGE) + if (handleEspNowPeerCommand(command, reply)) return; if (handleEspNowDiagnosticCommand(sender_timestamp, command, reply)) return; #endif #if MESH_ENABLE_FLEET_CONTROL diff --git a/src/helpers/CommonCLI.h b/src/helpers/CommonCLI.h index de57c465c..b02a3d816 100644 --- a/src/helpers/CommonCLI.h +++ b/src/helpers/CommonCLI.h @@ -1068,6 +1068,7 @@ class CommonCLI { #endif #if defined(WITH_ESPNOW_BRIDGE) bool handleEspNowDiagnosticCommand(uint32_t sender_timestamp, const char* command, char* reply); + bool handleEspNowPeerCommand(const char* command, char* reply); #endif #if defined(WITH_UDP_BRIDGE) bool handleUDPBridgeCommand(uint32_t sender_timestamp, const char* command, char* reply); diff --git a/src/helpers/ESPNowPeerSettings.cpp b/src/helpers/ESPNowPeerSettings.cpp new file mode 100644 index 000000000..3a62bb307 --- /dev/null +++ b/src/helpers/ESPNowPeerSettings.cpp @@ -0,0 +1,142 @@ +#include "ESPNowPeerSettings.h" + +#if defined(WITH_ESPNOW_BRIDGE) +#include "CheckedSettingsFile.h" +#include "ContactFileTransaction.h" +#include "FilePresence.h" +#include +#include + +namespace mesh { namespace bridge { +namespace { +constexpr char Path[] = "/espnow_peer"; +constexpr size_t ImageSize = 14; +FILESYSTEM* settings_fs = nullptr; +uint8_t selected[6] = {255, 255, 255, 255, 255, 255}; +bool healthy = false, writable = false; + +bool broadcast(const uint8_t* address) { + for (unsigned i = 0; i < 6; ++i) if (address[i] != 255) return false; + return true; +} + +bool valid(const uint8_t* address) { + if (!address) return false; + if (broadcast(address)) return true; + uint8_t any = 0; + for (unsigned i = 0; i < 6; ++i) any |= address[i]; + return any && !(address[0] & 1); +} + +uint32_t checksum(const uint8_t* bytes, size_t size) { + uint32_t crc = 0xffffffffU; + while (size--) { + crc ^= *bytes++; + for (unsigned bit = 0; bit < 8; ++bit) + crc = (crc >> 1) ^ ((crc & 1) ? 0xedb88320U : 0); + } + return ~crc; +} + +void encode(uint8_t image[ImageSize], const uint8_t* address) { + memcpy(image, "ENP\1", 4); + memcpy(image + 4, address, 6); + const uint32_t crc = checksum(image, 10); + for (unsigned i = 0; i < 4; ++i) image[10 + i] = crc >> (8 * i); +} + +bool decode(const uint8_t image[ImageSize], uint8_t* address) { + if (memcmp(image, "ENP\1", 4) || !valid(image + 4)) return false; + uint32_t saved = 0; + for (unsigned i = 0; i < 4; ++i) saved |= uint32_t(image[10 + i]) << (8 * i); + if (saved != checksum(image, 10)) return false; + memcpy(address, image + 4, 6); + return true; +} + +int hex(char c) { + if (c >= '0' && c <= '9') return c - '0'; + if (c >= 'A' && c <= 'F') return c - 'A' + 10; + if (c >= 'a' && c <= 'f') return c - 'a' + 10; + return -1; +} +} // namespace + +void loadEspNowPeerSettings(FILESYSTEM* fs) { + settings_fs = fs; + healthy = writable = false; + if (!fs || !ContactFileTransaction::recover(fs, Path, filePresence)) return; + bool present = false; + if (!filePresence(fs, Path, present)) return; + if (!present) { + memset(selected, 255, sizeof(selected)); + healthy = writable = true; + return; + } + uint8_t image[ImageSize]; + const auto read = storage::readFixedSettingsFileResult(fs, Path, image, sizeof(image)); + if (read == storage::FixedSettingsRead::Unreadable) return; + writable = true; // A classified malformed record permits an explicit repair. + healthy = read == storage::FixedSettingsRead::Complete && decode(image, selected); +} + +bool espNowPeerSettingsHealthy() { return healthy; } + +bool copyEspNowPeer(uint8_t destination[6]) { + if (!destination || !healthy) return false; + memcpy(destination, selected, 6); + return true; +} + +bool parseEspNowPeer(const char* text, uint8_t destination[6]) { + if (!text || !destination) return false; + uint8_t candidate[6]; + if (!strcmp(text, "broadcast")) memset(candidate, 255, sizeof(candidate)); + else { + if (strlen(text) != 17) return false; + for (unsigned i = 0; i < 6; ++i) { + const int hi = hex(text[3 * i]), lo = hex(text[3 * i + 1]); + if (hi < 0 || lo < 0 || (i < 5 && text[3 * i + 2] != ':')) return false; + candidate[i] = (hi << 4) | lo; + } + if (!valid(candidate) || broadcast(candidate)) return false; + } + memcpy(destination, candidate, sizeof(candidate)); + return true; +} + +bool saveEspNowPeer(const uint8_t destination[6]) { + if (!settings_fs || !writable || !valid(destination)) return false; + if (healthy && !memcmp(selected, destination, 6)) return true; + uint8_t image[ImageSize]; + encode(image, destination); + ContactFileTransaction file(settings_fs, Path, filePresence); + using Outcome = ContactFileTransaction::Outcome; + if (!file || file.write(image, sizeof(image)) != sizeof(image)) { + if (file.outcome() == Outcome::Indeterminate) healthy = writable = false; + return false; + } + file.commit(); + if (file.outcome() == Outcome::NotCommitted) return false; + uint8_t verified[ImageSize]; + if (file.outcome() != Outcome::Committed || + !storage::readFixedSettingsFile(settings_fs, Path, verified, sizeof(verified)) || + memcmp(verified, image, sizeof(image))) { + healthy = writable = false; + return false; + } + memcpy(selected, destination, sizeof(selected)); + healthy = true; + return true; +} + +void formatEspNowPeer(char* reply, size_t capacity) { + if (!reply || !capacity) return; + if (!healthy) snprintf(reply, capacity, "Error: ESP-NOW peer settings unavailable; bridge blocked"); + else if (broadcast(selected)) snprintf(reply, capacity, "> broadcast"); + else snprintf(reply, capacity, "> %02X:%02X:%02X:%02X:%02X:%02X", selected[0], selected[1], + selected[2], selected[3], selected[4], selected[5]); +} + +}} // namespace mesh::bridge +#endif diff --git a/src/helpers/ESPNowPeerSettings.h b/src/helpers/ESPNowPeerSettings.h new file mode 100644 index 000000000..9cf571936 --- /dev/null +++ b/src/helpers/ESPNowPeerSettings.h @@ -0,0 +1,20 @@ +#pragma once + +#if defined(WITH_ESPNOW_BRIDGE) +#include "IdentityStore.h" +#include +#include + +namespace mesh { namespace bridge { + +// Separate from NodePrefs and the bridge wire format. Missing settings keep +// legacy broadcast delivery; an unreadable saved destination blocks startup. +void loadEspNowPeerSettings(FILESYSTEM* fs); +bool espNowPeerSettingsHealthy(); +bool copyEspNowPeer(uint8_t destination[6]); +bool parseEspNowPeer(const char* text, uint8_t destination[6]); +bool saveEspNowPeer(const uint8_t destination[6]); +void formatEspNowPeer(char* reply, size_t capacity); + +}} // namespace mesh::bridge +#endif diff --git a/src/helpers/bridges/ESPNowBridge.cpp b/src/helpers/bridges/ESPNowBridge.cpp index 1a87af096..415a0a248 100644 --- a/src/helpers/bridges/ESPNowBridge.cpp +++ b/src/helpers/bridges/ESPNowBridge.cpp @@ -3,6 +3,7 @@ #include #include +#include #include #ifdef WITH_ESPNOW_BRIDGE @@ -87,6 +88,11 @@ void ESPNowBridge::begin() { end(); if (_sdk_teardown_failed) return; } + if (!mesh::bridge::copyEspNowPeer(_active_peer)) { + recordError(Stage::PeerConfig, ESP_ERR_INVALID_STATE); + emitDebug(); + return; + } _active_format = mesh::bridge::isValidEspNowFormat(_prefs->bridge_format) ? _prefs->bridge_format : mesh::bridge::ESPNOW_FORMAT_WRAPPED; @@ -153,7 +159,10 @@ void ESPNowBridge::begin() { if (failed(esp_wifi_init(&wifi_config), Stage::WiFiInit) || failed(esp_wifi_set_storage(WIFI_STORAGE_RAM), Stage::WiFiStorage) || failed(esp_wifi_set_mode(WIFI_MODE_STA), Stage::WiFiMode) - || failed(esp_wifi_start(), Stage::WiFiStart)) return; + || failed(esp_wifi_start(), Stage::WiFiStart) + // A dedicated ESP-NOW receiver must listen continuously. Associated + // infrastructure WiFi retains its own saved modem-sleep policy. + || failed(esp_wifi_set_ps(WIFI_PS_NONE), Stage::WiFiPowerSave)) return; } if (raw_format) { @@ -208,10 +217,11 @@ void ESPNowBridge::begin() { return; } - // Add broadcast peer + // A missing peer setting keeps legacy broadcast. Configured unicast uses + // the same bytes and gains MAC acknowledgement plus bounded callback retry. esp_now_peer_info_t peerInfo = {}; memset(&peerInfo, 0, sizeof(peerInfo)); - memset(peerInfo.peer_addr, 0xFF, ESP_NOW_ETH_ALEN); // Broadcast address + memcpy(peerInfo.peer_addr, _active_peer, sizeof(_active_peer)); peerInfo.channel = _prefs->bridge_channel; peerInfo.ifidx = WIFI_IF_STA; peerInfo.encrypt = false; @@ -266,12 +276,10 @@ void ESPNowBridge::end() { if (!_initialized && !_sdk_teardown_failed) return; BRIDGE_DEBUG_PRINTLN("Stopping...\n"); - // Remove broadcast peer - uint8_t broadcastAddress[] = { 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF }; - const esp_err_t remove_result = esp_now_del_peer(broadcastAddress); + const esp_err_t remove_result = esp_now_del_peer(_active_peer); if (remove_result != ESP_OK) { recordError(mesh::bridge::diag::Stage::PeerRemove, remove_result); - BRIDGE_DEBUG_PRINTLN("Error removing broadcast peer\n"); + BRIDGE_DEBUG_PRINTLN("Error removing ESP-NOW peer\n"); } // Unregister callbacks @@ -514,10 +522,8 @@ void ESPNowBridge::pumpTransmitQueue() { } if (frame_len == 0) return; - static const uint8_t broadcast_address[] = { - 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF}; const esp_err_t result = esp_now_send( - broadcast_address, frame, frame_len); + _active_peer, frame, frame_len); if (result != ESP_OK) { recordError(mesh::bridge::diag::Stage::TxStart, result); portENTER_CRITICAL(&_tx_mux); diff --git a/src/helpers/bridges/ESPNowBridge.h b/src/helpers/bridges/ESPNowBridge.h index 38e55ceb5..3d069516c 100644 --- a/src/helpers/bridges/ESPNowBridge.h +++ b/src/helpers/bridges/ESPNowBridge.h @@ -135,6 +135,7 @@ private: // Snapshotted by begin() and immutable until end(), so callbacks and sends // never observe a partially applied preference change. uint8_t _active_format; + uint8_t _active_peer[6] = {255, 255, 255, 255, 255, 255}; mesh::bridge::diag::Stats _stats; std::atomic _debug_dirty{true}; uint32_t _debug_last_at = 0; diff --git a/src/helpers/bridges/ESPNowBridgeDiagnostics.h b/src/helpers/bridges/ESPNowBridgeDiagnostics.h index 8a133bcf8..be4f38062 100644 --- a/src/helpers/bridges/ESPNowBridgeDiagnostics.h +++ b/src/helpers/bridges/ESPNowBridgeDiagnostics.h @@ -33,7 +33,7 @@ enum class Stage : uint8_t { None, Policy, Channel, ChannelConflict, WiFiInit, WiFiStorage, WiFiMode, WiFiStart, WiFiProtocol, WiFiChannel, EspNowInit, RecvCallback, SendCallback, PeerAdd, PeerRate, PeerRemove, EspNowDeinit, TxStart, TxCallback, TxTimeout, - Secret, TestQueue + Secret, TestQueue, PeerConfig, WiFiPowerSave }; inline const char* stageName(Stage stage) { @@ -60,6 +60,8 @@ inline const char* stageName(Stage stage) { case Stage::TxTimeout: return "tx.timeout"; case Stage::Secret: return "secret"; case Stage::TestQueue: return "test.queue"; + case Stage::PeerConfig: return "peer.config"; + case Stage::WiFiPowerSave: return "wifi.ps"; } return "unknown"; } diff --git a/test/fixtures/cli_reply_text.json b/test/fixtures/cli_reply_text.json index c6f44147c..3435c629a 100644 --- a/test/fixtures/cli_reply_text.json +++ b/test/fixtures/cli_reply_text.json @@ -1406,7 +1406,7 @@ } ], "noncompact_tokens_sha256": { - "src/helpers/CommonCLI.cpp": "42669159fbdc7361dac4c4d0b2654bfbd84118b6223c1f44ecf83b452b8224a7", + "src/helpers/CommonCLI.cpp": "0618237d2b12b67b31b930242acefe52a63c9d37c59c7fdfe75f1a8ccbec0741", "examples/simple_repeater/MyMesh.cpp": "8567eeef2f327100e397c40b0aaf5b675fcdfea254987437d1ca8d005ad3a6f0", "src/helpers/RadioProfileCLI.cpp": "d641d16e872eba3e73b9b5a50f9718c10b0b7da72782f793706ac36b3724a624", "src/helpers/WirelessControl.h": "f19bb602999118cc423ec74c7de7100ed91cb2c553295038077e52e321bb4e73", diff --git a/test/fixtures/espnow_bridge_diagnostics.cpp b/test/fixtures/espnow_bridge_diagnostics.cpp index 07d05b041..85debda34 100644 --- a/test/fixtures/espnow_bridge_diagnostics.cpp +++ b/test/fixtures/espnow_bridge_diagnostics.cpp @@ -219,7 +219,7 @@ static void counters() { count = 0; diag::increment(count); assert(count == 1); - for (uint8_t stage = 0; stage <= static_cast(diag::Stage::TestQueue); + for (uint8_t stage = 0; stage <= static_cast(diag::Stage::WiFiPowerSave); ++stage) { assert(strcmp(diag::stageName(static_cast(stage)), "unknown") != 0); } diff --git a/test/fixtures/espnow_wifi_handoff.cpp b/test/fixtures/espnow_wifi_handoff.cpp index fc34b873b..714dfebd7 100644 --- a/test/fixtures/espnow_wifi_handoff.cpp +++ b/test/fixtures/espnow_wifi_handoff.cpp @@ -4,6 +4,8 @@ #include #include #include +#include +#include #include #include #include @@ -24,6 +26,7 @@ constexpr int ESP_OK=0, WIFI_STORAGE_RAM=0, WIFI_IF_STA=0, WIFI_SECOND_CHAN_NONE constexpr int WIFI_PROTOCOL_11B=1, WIFI_PROTOCOL_11G=2, WIFI_PROTOCOL_11N=4, WIFI_PROTOCOL_LR=8; constexpr int WIFI_PHY_MODE_LR=1, WIFI_PHY_RATE_LORA_250K=2; enum esp_now_send_status_t { ESP_NOW_SEND_SUCCESS=0, ESP_NOW_SEND_FAIL=1 }; +enum wifi_ps_type_t { WIFI_PS_NONE=0, WIFI_PS_MIN_MODEM=1 }; struct esp_now_peer_info_t { uint8_t peer_addr[6]; int channel, ifidx; bool encrypt; }; struct esp_now_rate_config_t { int phymode, rate; bool ersu, dcm; }; @@ -31,6 +34,22 @@ int failure=0, wifi_starts=0, wifi_stops=0, wifi_deinits=0, facade_initializatio bool sdk_initialized=false, sdk_started=false, espnow_active=false; uint8_t wifi_channel=6; wifi_mode_t sdk_mode=WIFI_OFF; +wifi_ps_type_t wifi_ps=WIFI_PS_MIN_MODEM; +unsigned ps_writes=0; +int esp_wifi_set_ps(wifi_ps_type_t value) { + ++ps_writes; + if (!sdk_started || failure==19) return -1; + wifi_ps=value; return ESP_OK; +} +namespace mesh { namespace bridge { +bool peer_settings_ready=true; +uint8_t configured_peer[6]={255,255,255,255,255,255}; +bool copyEspNowPeer(uint8_t* out) { + if (!peer_settings_ready) return false; + memcpy(out,configured_peer,6); return true; +} +}} +std::vector> added_peers,removed_peers; int esp_wifi_init(wifi_init_config_t*) { if (sdk_initialized) return ESP_OK; // esp-idf init is idempotent. if (failure==11) return -1; @@ -91,9 +110,14 @@ template int esp_now_register_send_cb(T) { return failure==7 ? -1 : template int esp_now_register_recv_cb(T) { return failure==8 ? -1 : ESP_OK; } int esp_now_add_peer(esp_now_peer_info_t* peer) { assert(peer->channel==6); + std::array address;memcpy(address.data(),peer->peer_addr,6); + added_peers.push_back(address); return failure==9 ? -1 : ESP_OK; } -int esp_now_del_peer(const uint8_t*) { return ESP_OK; } +int esp_now_del_peer(const uint8_t* peer) { + std::array address;memcpy(address.data(),peer,6); + removed_peers.push_back(address);return ESP_OK; +} int esp_now_set_peer_rate_config(const uint8_t*, esp_now_rate_config_t*) { return failure==10 ? -1 : ESP_OK; } int esp_wifi_config_espnow_rate(int, int) { return failure==10 ? -1 : ESP_OK; } @@ -165,6 +189,7 @@ public: Prefs* _prefs=&prefs; bool _initialized=false; int _active_format=0, _rx_mux=0, _rx_head=0, _rx_tail=0, _rx_count=0, _rx_dropped=0, _rx_dropped_reported=0; + uint8_t _active_peer[6]={255,255,255,255,255,255}; int _tx_mux=0, _tx_head=0, _tx_tail=0, _tx_count=0, _tx_waiting=0, _tx_callback_done=0; int _tx_callback_status=0, _tx_dropped=0, _tx_dropped_reported=0; mesh::espnow::ESPNowRawReassembler _raw_reassembler; @@ -207,7 +232,7 @@ int main() { assert(facade_initializations==0); } // Every driver startup failure releases pure-IDF resources without a facade. - for (int stage : {11,12,13,14,15,2,4,7,8,9,10}) { + for (int stage : {11,12,13,14,15,19,2,4,7,8,9,10}) { failure=stage; bridge.begin(); assert(!bridge._initialized); diff --git a/test/fixtures/wifi_ap_shared_lr_sdk.h b/test/fixtures/wifi_ap_shared_lr_sdk.h index a1470dbfe..2e25e4c25 100644 --- a/test/fixtures/wifi_ap_shared_lr_sdk.h +++ b/test/fixtures/wifi_ap_shared_lr_sdk.h @@ -29,6 +29,11 @@ constexpr int WIFI_PROTOCOL_11B=1,WIFI_PROTOCOL_11G=2,WIFI_PROTOCOL_11N=4,WIFI_P constexpr int WIFI_PHY_MODE_LR=1,WIFI_PHY_RATE_LORA_250K=2,WIFI_SCAN_RUNNING=-1; enum wl_status_t {WL_IDLE_STATUS,WL_CONNECTED,WL_NO_SSID_AVAIL,WL_CONNECT_FAILED,WL_DISCONNECTED}; enum esp_now_send_status_t {ESP_NOW_SEND_SUCCESS=0,ESP_NOW_SEND_FAIL=1}; +enum wifi_ps_type_t {WIFI_PS_NONE=0,WIFI_PS_MIN_MODEM=1}; +int esp_wifi_set_ps(wifi_ps_type_t){return ESP_OK;} +namespace mesh {namespace bridge { +bool copyEspNowPeer(uint8_t* address){memset(address,255,6);return true;} +}} struct esp_now_send_info_t {}; struct esp_now_recv_info_t {const uint8_t* src_addr;}; struct esp_now_peer_info_t {uint8_t peer_addr[6];int channel,ifidx;bool encrypt;}; diff --git a/test/test_bridge_recovery.py b/test/test_bridge_recovery.py index 93e3f5f5b..e53b17717 100644 --- a/test/test_bridge_recovery.py +++ b/test/test_bridge_recovery.py @@ -30,8 +30,7 @@ struct esp_now_send_info_t {const uint8_t* des_addr;}; constexpr int ESP_ERR_ESPNOW_NO_MEM=-77; constexpr int ESP_ERR_INVALID_STATE=-78,ESP_ERR_INVALID_ARG=-79; constexpr int ESP_ERR_TIMEOUT=-80; -enum wifi_ps_type_t {WIFI_PS_NONE=0,WIFI_PS_MIN_MODEM=1}; -int esp_wifi_get_ps(wifi_ps_type_t* value){*value=WIFI_PS_NONE;return sdk_initialized?ESP_OK:-1;} +int esp_wifi_get_ps(wifi_ps_type_t* value){*value=wifi_ps;return sdk_initialized?ESP_OK:-1;} int esp_wifi_get_protocol(int,uint8_t* value){*value=15;return sdk_initialized?ESP_OK:-1;} int esp_wifi_get_mac(int,uint8_t* value){ const uint8_t mac[]={2,16,32,48,64,80};memcpy(value,mac,sizeof(mac));return sdk_initialized?ESP_OK:-1; @@ -40,8 +39,10 @@ uint32_t now_ms=100; uint32_t millis(){return now_ms;} int send_result=0,send_calls=0; std::vector> sent; -int esp_now_send(const uint8_t*,const uint8_t* bytes,size_t len){ +std::vector> sent_peers; +int esp_now_send(const uint8_t* destination,const uint8_t* bytes,size_t len){ assert(espnow_active);++send_calls; + std::array address;memcpy(address.data(),destination,6);sent_peers.push_back(address); if(send_result!=ESP_OK)return send_result; sent.emplace_back(bytes,bytes+len);return ESP_OK; } diff --git a/test/test_espnow_peer.py b/test/test_espnow_peer.py new file mode 100644 index 000000000..e5b2b0612 --- /dev/null +++ b/test/test_espnow_peer.py @@ -0,0 +1,273 @@ +"""Exercise saved ESP-NOW destinations, live CLI application and SDK sends.""" +from pathlib import Path +import os +import re +import shutil +import subprocess +import sys +import tempfile +import unittest + +ROOT = Path(__file__).resolve().parents[1] + +STORAGE_CHECKS = r''' +#include +#include +#include +#include +#include +#include "ESPNowPeerSettings.h" +#include "settings_under_test.cpp" + +namespace mesh {namespace cli { +void formatError(char* out,size_t capacity,const char* format,...) { + snprintf(out,capacity,"Error: "); + const size_t used=strlen(out);va_list args;va_start(args,format); + vsnprintf(out+used,capacity-used,format,args);va_end(args); +} +}} +struct NodePrefs {bool bridge_enabled=true,espnow_bridge_enabled=true;}; +struct Callbacks { + unsigned restarts=0,stops=0;bool restart_ok=true; + bool restartEspNowBridge(){++restarts;return restart_ok;} + bool setEspNowBridgeState(bool enable){assert(!enable);++stops;return true;} +}; +struct CommonCLI { + NodePrefs prefs;Callbacks callbacks; + NodePrefs* _prefs=&prefs;Callbacks* _callbacks=&callbacks; + bool handleEspNowPeerCommand(const char*,char*); +}; +@CLI_METHOD@ + +static const uint8_t Peer[]={2,16,32,48,64,80}; +static const uint8_t Other[]={4,16,32,48,64,80}; +static const uint8_t Broadcast[]={255,255,255,255,255,255}; +static void expect(const uint8_t* address) { + uint8_t actual[6];assert(mesh::bridge::copyEspNowPeer(actual)); + assert(!memcmp(actual,address,6)); +} + +int main(int argc,char** argv) { + assert(argc==2);using namespace mesh::bridge; + MemoryFS fs;const char* path="/espnow_peer"; + const std::vector legacy={1,8,4,7,9};fs.files["/com_prefs"]=legacy; + uint8_t output[6]={9,9,9,9,9,9};char reply[160]; + assert(!copyEspNowPeer(output)&&output[0]==9); + loadEspNowPeerSettings(&fs);expect(Broadcast); + assert(fs.files.size()==1); + if(!strcmp(argv[1],"storage")) { + assert(parseEspNowPeer("02:10:20:30:40:50",output)&&!memcmp(output,Peer,6)); + assert(parseEspNowPeer("aa:bb:cc:dd:ee:fe",output)&&output[0]==0xaa); + for(const char* invalid:{"","broadcast ","Broadcast","00:00:00:00:00:00", + "03:10:20:30:40:50","FF:FF:FF:FF:FF:FF","02-10-20-30-40-50", + "02:10:20:30:40:5Z","02:10:20:30:40:50 trailing","02:10:20:30:40"}) { + memset(output,9,6);assert(!parseEspNowPeer(invalid,output)); + for(uint8_t value:output)assert(value==9); + } + assert(!parseEspNowPeer(nullptr,output)&&!parseEspNowPeer("broadcast",nullptr)); + assert(parseEspNowPeer("broadcast",output)&&!memcmp(output,Broadcast,6)); + assert(saveEspNowPeer(Peer));expect(Peer);const auto good=fs.files.at(path); + assert(good.size()==14&&!memcmp(good.data(),"ENP\1",4)); + loadEspNowPeerSettings(&fs);expect(Peer); + formatEspNowPeer(reply,sizeof(reply));assert(!strcmp(reply,"> 02:10:20:30:40:50")); + char bounded[]={'x','x','x','z'};formatEspNowPeer(bounded,3); + assert(!bounded[2]&&bounded[3]=='z'); + fs.fail_write=true;assert(!saveEspNowPeer(Other));expect(Peer); + assert(fs.files.at(path)==good);fs.fail_write=false; + fs.fail_write_after=4;assert(!saveEspNowPeer(Other));expect(Peer); + assert(fs.files.at(path)==good);fs.fail_write_after=-1; + fs.fail_after_publication=true;assert(!saveEspNowPeer(Other)); + assert(!espNowPeerSettingsHealthy()&&!copyEspNowPeer(output)); + const auto published=fs.files.at(path);assert(published!=good); + fs.fail_after_publication=false;fs.fail_read_open=false; + assert(!saveEspNowPeer(Peer)&&fs.files.at(path)==published); + loadEspNowPeerSettings(&fs);expect(Other);assert(saveEspNowPeer(Peer)); + for(size_t bit=0;bit broadcast")); + for(const char* command:{"set espnow.peer","set espnow.peer invalid","get espnow.peer extra"}) + assert(run(command)&&!strncmp(reply,"Error:",6)); + assert(!run("get espnow.peerXYZ")&&!strcmp(reply,"untouched")); + assert(run("set espnow.peer 02:10:20:30:40:50")&&!strncmp(reply,"OK",2)); + expect(Peer);assert(cli.callbacks.restarts==1); + assert(run("set espnow.peer 02:10:20:30:40:50")&&strstr(reply,"unchanged")); + assert(cli.callbacks.restarts==1); + const auto good=fs.files.at(path); + fs.fail_write=true;assert(run("set espnow.peer broadcast")&&!strncmp(reply,"Error:",6)); + expect(Peer);assert(fs.files.at(path)==good&&cli.callbacks.restarts==1&&cli.callbacks.stops==0); + fs.fail_write=false;cli.callbacks.restart_ok=false; + assert(run("set espnow.peer 04:10:20:30:40:50")&&strstr(reply,"failed to restart")); + expect(Other);assert(cli.callbacks.restarts==2&&cli.callbacks.stops==1); + cli.callbacks.restart_ok=true;cli.prefs.bridge_enabled=cli.prefs.espnow_bridge_enabled=false; + assert(run("set espnow.peer\tbroadcast")&&!strncmp(reply,"OK",2)); + expect(Broadcast);assert(cli.callbacks.restarts==2); + fs.fail_after_publication=true; + assert(run("set espnow.peer 02:10:20:30:40:50")&&strstr(reply,"blocked")); + assert(!espNowPeerSettingsHealthy()&&cli.callbacks.stops==2); + assert(run("get espnow.peer")&&strstr(reply,"blocked")); + } + assert(fs.files.at("/com_prefs")==legacy); +} +''' + +SDK_CHECKS = r''' +int main(){ + Backend backend;mesh::wireless::control().begin(backend); + NodePrefs prefs;prefs.bridge_format=mesh::bridge::ESPNOW_FORMAT_WRAPPED; + mesh::PacketManager mgr;mesh::RTCClock rtc; + auto p=packet(0x40); + std::vector legacy; + { ESPNowBridge bridge(&prefs,&mgr,&rtc);bridge.begin(); + assert(bridge._initialized&&ps_writes==1&&wifi_ps==WIFI_PS_NONE); + assert(added_peers.back()[0]==255); + bridge.sendPacket(&p);bridge.loop();complete(bridge); + assert(sent.size()==1&&sent_peers.back()[0]==255);legacy=sent.back();bridge.end(); } + const uint8_t destination[]={2,16,32,48,64,80}; + memcpy(mesh::bridge::configured_peer,destination,6); + { ESPNowBridge bridge(&prefs,&mgr,&rtc);bridge.begin(); + assert(bridge._initialized&&!memcmp(added_peers.back().data(),destination,6)); + bridge.sendPacket(&p);bridge.loop(); + assert(sent.back()==legacy&&!memcmp(sent_peers.back().data(),destination,6)); + const unsigned initial=send_calls; + for(unsigned attempt=0;attempt<3;++attempt){ + complete(bridge,ESP_NOW_SEND_FAIL);assert(unsigned(send_calls)==initial+attempt); + now_ms+=19;bridge.loop();assert(unsigned(send_calls)==initial+attempt); + ++now_ms;bridge.loop();assert(unsigned(send_calls)==initial+attempt+1); + } + complete(bridge,ESP_NOW_SEND_FAIL);assert(!bridge._tx_count); + assert(bridge._stats.tx.callback_fail==4&&bridge._stats.tx.drops==1); + bridge.end();assert(!memcmp(removed_peers.back().data(),destination,6)); } + // A failed first raw fragment retries before its sibling, to the same peer. + prefs.bridge_format=mesh::bridge::ESPNOW_FORMAT_RAW; + { ESPNowBridge bridge(&prefs,&mgr,&rtc);bridge.begin();auto large=packet(0x44,255); + const size_t first=sent.size();bridge.sendPacket(&large);bridge.loop(); + assert(sent.size()==first+1);const auto first_frame=sent.back(); + complete(bridge,ESP_NOW_SEND_FAIL);assert(sent.size()==first+1); + now_ms+=20;bridge.loop();assert(sent.size()==first+2&&sent.back()==first_frame); + complete(bridge);assert(sent.size()==first+3&&sent.back()!=first_frame); + complete(bridge);assert(!bridge._tx_count); + for(size_t i=first;i bool filePresence(FS* fs,const char* path,bool& present) { + if(!fs||!path||fs->fail_metadata)return false;present=fs->exists(path);return true; +}} +''') + for name in ("ESPNowPeerSettings.h", "ContactFileTransaction.h", "CheckedSettingsFile.h"): + (folder / name).write_bytes((ROOT / "src/helpers" / name).read_bytes()) + (folder / "settings_under_test.cpp").write_bytes((ROOT / "src/helpers/ESPNowPeerSettings.cpp").read_bytes()) + cli = method((ROOT / "src/helpers/CommonCLI.cpp").read_text(), "bool CommonCLI::handleEspNowPeerCommand(") + source = folder / "test.cpp" + source.write_text(STORAGE_CHECKS.replace("@CLI_METHOD@", cli)) + for combined in (False, True): + binary = folder / ("combined" if combined else "standalone") + defines = ["ESP32_PLATFORM=1", "WITH_ESPNOW_BRIDGE=1"] + if combined: + defines.append("WITH_RS232_BRIDGE=1") + self.compile(source, binary, [folder, ROOT / "src", ROOT / "src/helpers"], defines) + for scenario in ("storage", "cli"): + result = subprocess.run([str(binary), scenario], capture_output=True, text=True, timeout=20) + self.assertEqual(result.returncode, 0, result.stdout+result.stderr) + + def test_actual_sdk_peer_send_retry_and_power_policy_across_versions(self): + from test_bridge_recovery import code, COMMON + with tempfile.TemporaryDirectory(prefix="espnow-unicast-sdk-") as tmp: + folder = Path(tmp) + source = folder / "test.cpp" + source.write_text(code()+COMMON+SDK_CHECKS) + for version in (40400, 50200, 50500): + binary = folder / ("sdk-"+str(version)) + self.compile(source, binary, [ROOT / "src"], ["ESP32=1", "WITH_ESPNOW_BRIDGE=1", + "WITH_RS232_BRIDGE=1", "ESP_IDF_VERSION="+str(version), + "ESP_IDF_VERSION_MAJOR="+str(version//10000)]) + result = subprocess.run([str(binary)], capture_output=True, text=True, timeout=20) + self.assertEqual(result.returncode, 0, result.stdout+result.stderr) + + def test_non_espnow_build_has_no_storage_or_peer_symbols(self): + with tempfile.TemporaryDirectory(prefix="no-espnow-peer-") as tmp: + obj = Path(tmp) / "peer.o" + result = subprocess.run([self.compiler, "-std=c++17", "-DNRF52_PLATFORM=1", "-c", + str(ROOT / "src/helpers/ESPNowPeerSettings.cpp"), "-o", str(obj)], + capture_output=True, text=True, timeout=30) + self.assertEqual(result.returncode, 0, result.stderr) + symbols = subprocess.check_output(["nm", "-C", str(obj)], text=True) + self.assertNotRegex(symbols, r"EspNowPeer|settings_fs|selected|healthy") + + +if __name__ == "__main__": + unittest.main() diff --git a/test/test_espnow_radio_lifecycle.py b/test/test_espnow_radio_lifecycle.py index 1d3e8d89b..a374d4741 100644 --- a/test/test_espnow_radio_lifecycle.py +++ b/test/test_espnow_radio_lifecycle.py @@ -24,6 +24,11 @@ using portMUX_TYPE=int; using esp_err_t=int; constexpr int ESP_OK=0,WIFI_IF_STA=0,WIFI_PROTOCOL_LR=8,WIFI_PHY_MODE_LR=1,WIFI_PHY_RATE_LORA_250K=2; enum wifi_mode_t {WIFI_OFF=0,WIFI_STA=1,WIFI_AP=2,WIFI_AP_STA=3}; +enum wifi_ps_type_t {WIFI_PS_NONE=0,WIFI_PS_MIN_MODEM=1}; +int esp_wifi_set_ps(wifi_ps_type_t){return 0;} +namespace mesh {namespace bridge { +bool copyEspNowPeer(uint8_t* address){memset(address,255,6);return true;} +}} enum esp_now_send_status_t {ESP_NOW_SEND_SUCCESS=0,ESP_NOW_SEND_FAIL=1}; struct esp_now_send_info_t {}; struct esp_now_recv_info_t {const uint8_t* src_addr;}; @@ -232,6 +237,7 @@ public: struct Prefs {int bridge_format=mesh::bridge::ESPNOW_FORMAT_RAW;uint8_t bridge_channel=6;} prefs; Prefs* _prefs=&prefs;bool _initialized=false; int _active_format=0,_rx_mux=0,_rx_head=0,_rx_tail=0,_rx_count=0,_rx_dropped=0,_rx_dropped_reported=0; + uint8_t _active_peer[6]={255,255,255,255,255,255}; int _tx_mux=0,_tx_head=0,_tx_tail=0,_tx_count=0,_tx_waiting=0,_tx_callback_done=0; int _tx_callback_status=0,_tx_dropped=0,_tx_dropped_reported=0; uint32_t _tx_started_at=0,_tx_retry_at=0;bool _sdk_teardown_failed=false; diff --git a/test/test_wifi_ap_lr_safety.py b/test/test_wifi_ap_lr_safety.py index 9b6b31486..9dbc5f7ee 100644 --- a/test/test_wifi_ap_lr_safety.py +++ b/test/test_wifi_ap_lr_safety.py @@ -14,6 +14,7 @@ public: struct Prefs {int bridge_format=mesh::bridge::ESPNOW_FORMAT_RAW;uint8_t bridge_channel=1;} prefs; Prefs* _prefs=&prefs;bool _initialized=false; int _active_format=0,_rx_mux=0,_rx_head=0,_rx_tail=0,_rx_count=0,_rx_dropped=0,_rx_dropped_reported=0; + uint8_t _active_peer[6]={255,255,255,255,255,255}; int _tx_mux=0,_tx_head=0,_tx_tail=0,_tx_count=0,_tx_waiting=0,_tx_callback_done=0; int _tx_callback_status=0,_tx_dropped=0,_tx_dropped_reported=0; uint32_t _tx_started_at=0,_tx_retry_at=0; diff --git a/tools/hil/split_radio_hardware_test.py b/tools/hil/split_radio_hardware_test.py new file mode 100644 index 000000000..02d3ee35a --- /dev/null +++ b/tools/hil/split_radio_hardware_test.py @@ -0,0 +1,1046 @@ +#!/usr/bin/env python3 +"""Finite, identity-bound split-repeater HIL campaign. This script transmits RF. + +No flashing, backups, offline-message reads or remote execution. The config must +identify every physical USB device. --companions-only configures five fixtures +without sending probe packets, while the DUTs are being prepared separately. +""" +from __future__ import annotations + +import argparse +from collections import deque +from contextlib import contextmanager, nullcontext +from datetime import datetime, timezone +import json +import os +from pathlib import Path +import re +import secrets +import struct +import threading +import time + +from companion_serial import Companion, check, cli_command_label +from esp32_companion_serial_stress import (DeviceFrameReader, FrameTimeout, + TransportCounters, make_app_start_spec, validate_app_start_response) +from serial_session import assert_port_idle, open_configured_session +from split_radio_probe import (companion_send_command, inspect_push, make_probe, + packet_stats, parse_probe, profile_counters) + +DUTS = ('left', 'right', 'center') +FIXTURES = ('feed_left', 'feed_right', 'observe_media', 'observe_usa', 'observe_inputs') +RF = {'input_media': (902.750, 250, 7, 5), 'input_usa': (904.525, 62.5, 7, 5), + 'output_media': (908.150, 250, 7, 5), 'output_usa': (906.525, 62.5, 7, 5)} + + +def normalized(text): + return re.sub('[^a-z0-9]', '', text.lower()) + + +def verify_usb(spec): + from serial.tools import list_ports + path, expected = spec['port'], spec.get('usb_serial', spec.get('serial')) + check(expected and path.startswith('/dev/serial/by-id/'), 'stable port and USB serial required') + resolved = os.path.realpath(path) + matches = [p for p in list_ports.comports() if os.path.realpath(p.device) == resolved] + check(len(matches) == 1 and (matches[0].serial_number or '').upper() == expected.upper(), + 'selected USB identity changed or is ambiguous') + assert_port_idle(path) + return matches[0] + + +def rf_text(rf): + return f'{rf[0]:.3f},{rf[1]:g},{rf[2]},{rf[3]}' + + +def parse_radio_diagnostic(reply): + """Read physical driver state and counters, never USB packet timestamps.""" + match = re.fullmatch(r'(?:>\s*)?state=(\d+)\([^)]*\), recv=(\d+), sent=(\d+), ' + r'errors=(\d+), err_flags=(\d+), outbound=(yes|no), last_rx=\d+s ago', reply.strip()) + check(match is not None, 'missing or ambiguous physical radio diagnostics') + state, recv, sent, errors, flags = map(int, match.groups()[:5]) + check(0 <= state <= 255 and all(0 <= value <= 0xffffffff for value in + (recv, sent, errors, flags)), 'invalid physical radio diagnostic counters') + return {'state': state, 'tx_wait': (state & 15) == 3, 'recv': recv, + 'sent': sent, 'errors': errors, 'err_flags': flags, + 'outbound': match[6] == 'yes'} + + +def assess_cad_samples(samples, blocker_sent, center_sent, blocker_minimum_end=None): + """Conservative single-packet ordering proof from synchronous readbacks. + + `sent` advances only after the driver's TX_DONE IRQ is accepted. A center + TX_WAIT is cached until TX_DONE is serviced. Bracketed TX_WAIT therefore + proves driver-state overlap only. It proves RF overlap only inside the + verified blocker's minimum-preamble window, whose earliest possible start + is the host's command write. For a pass, observe blocker TX_DONE, then center idle with its old + counter, then center TX_DONE. Missing that idle gap is inconclusive: packet + USB notification order alone cannot establish either radio's TX interval. + """ + def delta(sample, initial): + return (sample['sent']-initial) & 0xffffffff + + overlap, driver_overlap, ordered = False, False, False + blocker_done = center_idle_after_blocker = blocker_started = False + latest_blocker = None + pending_center_tx = None + block_deltas, center_deltas = [], [] + for sample in samples: + if sample['node'] == 'blocker': + count = delta(sample, blocker_sent) + block_deltas.append(count) + blocker_started |= sample['tx_wait'] and count == 0 + if pending_center_tx is not None and latest_blocker is not None: + bracketed = latest_blocker['tx_wait'] and sample['tx_wait'] and \ + delta(latest_blocker, blocker_sent) == count == 0 + driver_overlap |= bracketed + overlap |= bracketed and blocker_minimum_end is not None and \ + sample.get('reply_completed_monotonic', float('inf')) < blocker_minimum_end + pending_center_tx = None + latest_blocker = sample + blocker_done |= count == 1 and not sample['tx_wait'] + elif sample['node'] == 'center': + count = delta(sample, center_sent) + center_deltas.append(count) + if sample['tx_wait'] and count == 0: + pending_center_tx = sample + if blocker_done and count == 0 and not sample['tx_wait']: + center_idle_after_blocker = True + if center_idle_after_blocker and count == 1: + ordered = True + one_blocker = bool(block_deltas) and max(block_deltas) == 1 + one_center = bool(center_deltas) and max(center_deltas) == 1 + verdict = ('failed' if overlap else 'passed' if blocker_started and one_blocker and + one_center and ordered else 'inconclusive') + return {'verdict': verdict, 'blocker_tx_wait_observed': blocker_started, + 'blocker_completed_once': one_blocker, 'center_completed_once': one_center, + 'driver_tx_wait_overlap': driver_overlap, 'simultaneous_tx_proven': overlap, + 'transmit_order_proven': ordered, + 'blocker_minimum_end_monotonic': blocker_minimum_end} + + +class Evidence: + def __init__(self, folder): + self.folder = Path(folder) + self.folder.mkdir(parents=True, exist_ok=True) + os.chmod(self.folder, 0o700) + self.file = (self.folder/'events.jsonl').open('x', encoding='utf-8', buffering=1) + self.lock = threading.RLock() + self.condition = threading.Condition(self.lock) + self.observations = [] + self.run = secrets.token_bytes(8) + self.report = {'schema': 2, 'run_id': self.run.hex(), + 'started_utc': datetime.now(timezone.utc).isoformat(), + 'source_commit': None, 'rf': RF, 'nodes': {}, 'trials': [], + 'hardware_cad_busy_test': 'not performed: no controlled continuous LoRa blocker', 'status': 'running'} + self.save() + + def event(self, kind, **fields): + record = {'monotonic': time.monotonic(), 'kind': kind, **fields} + with self.condition: + self.file.write(json.dumps(record, sort_keys=True)+'\n') + if kind == 'probe_rx': + self.observations.append(record) + self.condition.notify_all() + return record + + def receive(self, label, frame): + # A scanning witness has no single configured receive profile. Only + # an actual push84 A0/A1 tag proves the profile of that observation. + configured = None if label == 'observe_inputs' else 0 + record = inspect_push(frame, expected_run=self.run, observer=label, + configured_profile=configured) + if record is not None: + self.event('probe_rx', **record) + + def save(self): + with self.lock: + temporary = self.folder/'report.safe.json.tmp' + temporary.write_text(json.dumps(self.report, indent=2, sort_keys=True)+'\n') + os.replace(temporary, self.folder/'report.safe.json') + + def records(self, source, sequence): + with self.lock: + return [x.copy() for x in self.observations + if x['source'] == source and x['sequence'] == sequence] + + def close(self): + self.file.close() + + +class CaptureCompanion(Companion): + """One reader owns USB throughout setup, commands and asynchronous RX.""" + def __init__(self, label, spec, evidence): + self.label, self.spec, self.evidence = label, spec, evidence + self.usb = verify_usb(spec) + super().__init__(spec['port']) + self.responses, self.condition = deque(), threading.Condition() + self.request_lock = threading.Lock() + self.running, self.failure, self.thread = False, None, None + + def _read(self): + try: + while self.running: + try: + frame = self.reader.read_frame(self.port, 0.25) + except FrameTimeout: + continue + if not frame: + continue + if frame[0] >= 0x80: + self.evidence.receive(self.label, frame) + else: + with self.condition: + check(len(self.responses) < 64, 'unbounded Companion response queue') + self.responses.append(frame) + self.condition.notify_all() + except BaseException as error: + with self.condition: + self.failure = type(error).__name__ + self.condition.notify_all() + + def start(self): + # Equivalent to the shared helper's negotiation, respecting JTAG lines. + self.port.rts = False + self.port.dtr = self.spec.get('dtr', (self.usb.vid, self.usb.pid) != (0x303a, 0x1001)) + self.port.write(b'+++MESHCORE-TERM-STOP\r') + self.port.flush() + time.sleep(.3) + deadline = time.monotonic()+.5 + count = 0 + while time.monotonic() < deadline: + chunk = self.port.read(min(self.port.in_waiting or 1, 4096)) + count += len(chunk) + check(count <= 65536, 'terminal negotiation exceeded its byte bound') + self.reader = DeviceFrameReader(TransportCounters()) + self.running = True + self.thread = threading.Thread(target=self._read, name=self.label, daemon=True) + self.thread.start() + info = self.request(bytes((22, 14)), (13,)) + check(len(info) >= 80, 'truncated Companion info') + self.max_channels = info[3] + identity = self.request(make_app_start_spec('Split-Radio-HIL').payload, (5,)) + return {'protocol': info[1], 'model': info[20:60].split(b'\0')[0].decode(errors='replace'), + 'firmware_short': info[60:80].split(b'\0')[0].decode(errors='replace'), + 'primary': validate_app_start_response(identity)} + + def request(self, payload, expected, timeout=8): + from esp32_companion_serial_stress import encode_host_frame + with self.request_lock: + with self.condition: + check(not self.responses and not self.failure, 'Companion session is not synchronized') + frame = encode_host_frame(payload) + check(self.port.write(frame) == len(frame), 'short Companion write') + self.port.flush() + deadline = time.monotonic()+timeout + while time.monotonic() < deadline and not self.failure: + if self.responses: + response = self.responses.popleft() + check(response[0] != 1, 'Companion rejected command '+str(payload[0])) + check(response[0] in expected, 'unexpected Companion response') + return response + self.condition.wait(min(.1, max(0, deadline-time.monotonic()))) + raise RuntimeError('Companion transaction failed or timed out: '+self.label) + + def stats(self): + return packet_stats(self.request(bytes((56, 2)), (24,))) + + def close(self): + self.running = False + if self.thread: + self.thread.join(2) + super().close() + + +class Infrastructure: + """Keep the ASCII device closed between complete CLI transactions.""" + def __init__(self, label, spec, evidence): + self.label, self.spec, self.evidence = label, spec, evidence + verify_usb(spec) + check(not spec.get('dtr', False), 'infrastructure DTR must stay low') + self.port, self.failure, self._batch_active = None, False, False + + def _stage(self, command, stage): + operation = {'node': self.label, 'command': cli_command_label(command), 'stage': stage} + self.evidence.report['last_serial_operation'] = operation + self.evidence.save() + self.evidence.event('serial_session', **operation) + + @contextmanager + def _session(self, command): + if self._batch_active: + check(self.port is not None, 'configuration batch lost its serial session') + self._stage(command, 'batch_transaction') + yield self.port + return + import serial + self._stage(command, 'identity_check') + verify_usb(self.spec) + port = serial.Serial(port=None, baudrate=115200, timeout=.05, + write_timeout=2, exclusive=True) + self.port = port + try: + port.port = self.spec['port'] + port.dtr, port.rts = False, False + self._stage(command, 'opening') + open_configured_session(port) + self._stage(command, 'open') + # End an unfinished local input line without changing settings. + port.write(b'\r') + port.flush() + time.sleep(.05) + yield port + finally: + try: + port.close() + finally: + self.port = None + self._stage(command, 'closed') + + @contextmanager + def batch(self): + """Hold one identity-bound port for a finite synchronous command group.""" + check(not self.failure and self.port is None and not self._batch_active, + 'cannot start a nested or desynchronized configuration batch') + with self._session('configuration batch'): + self._batch_active = True + try: + yield self + finally: + self._batch_active = False + + def close(self): + if self.port is not None: + self.port.close() + self.port = None + + def cli(self, command, timeout=15, allow_error=False): + check(not self.failure and command and not any(c in command for c in '\r\n\0'), + 'invalid or desynchronized CLI operation') + # Exact echo then a completed arrow reply, never a silence/prompt guess. + echo = re.compile(r'(?:^|\r?\n)(?:> ?)?'+re.escape(command)+r'\r?\n') + completed = False + try: + with self._session(command) as port: + data, deadline = bytearray(), time.monotonic()+timeout + port.write((command+'\r').encode('ascii')) + port.flush() + while time.monotonic() < deadline: + data.extend(port.read(min(port.in_waiting or 1, 4096))) + check(len(data) <= 65536, 'CLI transaction exceeded capture bound') + text = data.decode('utf-8', errors='replace') + found = echo.search(text) + if found: + response = re.search(r'(?:^|\r?\n) -> ([^\r\n]*)\r?\n', text[found.end():]) + if response: + completed = True + reply = response[1].removeprefix('> ').strip() + if not allow_error: + check(not re.search(r'^(?:Error|ERROR|ERR|Err|Unknown|\?\?:)', reply), + self.label+' rejected '+cli_command_label(command)) + # Values and raw echoes are deliberately not persisted. + self.evidence.event('cli', node=self.label, command=cli_command_label(command), + reply='' if 'secret' in command or 'password' in command else reply) + return reply + raise TimeoutError(self.label+' CLI did not complete '+cli_command_label(command)) + except BaseException: + self.failure = not completed + raise + + def packet_stats(self): + return json.loads(self.cli('stats-packets')) + + def reboot(self): + check(not self.failure and not self._batch_active, + 'cannot reboot a desynchronized or batched infrastructure session') + with self._session('reboot') as port: + port.write(b'reboot\r') + port.flush() + time.sleep(2) + deadline = time.monotonic()+50 + while True: + try: + self.failure = False + # A live exact reply proves application readiness; merely + # finding its persistent USB bridge is insufficient. + self.cli('ver', timeout=min(5, max(.1, deadline-time.monotonic()))) + return + except (OSError, RuntimeError): + if time.monotonic() >= deadline: + raise + time.sleep(1) + + +class Campaign: + def __init__(self, config, evidence, right_only=False): + self.config, self.evidence = config, evidence + self.nodes, self.sequence, self.center_key = {}, 0, None + self.secret = secrets.token_hex(8)[:15] + self.pause = float(config.get('probe_pause', 2.5)) + self.fixture_preamble = config.get('fixture_preamble', 96) + check(type(self.fixture_preamble) is int and 16 <= self.fixture_preamble <= 65528, + 'fixture_preamble must be an integer from 16 to 65528') + self.right_only = right_only + self.duts = ('right', 'center') if right_only else DUTS + self.sources = ('R',) if right_only else ('L', 'R') + evidence.report['coverage'] = 'right_center_only' if right_only else 'full_three_dut_topology' + evidence.report['not_tested'] = ['left DUT reception/forwarding', 'left to center MediaMesh output'] if right_only else [] + check(1 <= self.pause <= 10, 'probe_pause must be 1..10 seconds') + + def open(self, companions_only=False): + labels = FIXTURES if companions_only else self.duts+FIXTURES + for label in labels: + self.evidence.report['opening_node'] = label + self.evidence.save() + spec = self.config['nodes'][label] + if label in FIXTURES: + node = CaptureCompanion(label, spec, self.evidence) + self.nodes[label] = node + info = node.start() + info['firmware'] = node.cli('ver') + model = info['model'] + else: + node = Infrastructure(label, spec, self.evidence) + self.nodes[label] = node + model, version = node.cli('board'), node.cli('ver') + public = node.cli('get public.key') + check(re.fullmatch('[0-9a-fA-F]{64}', public), 'invalid DUT public identity') + info = {'model': model, 'firmware': version, 'public_key': public.upper()} + expected = spec.get('firmware_contains', self.config.get('expected_firmware', 'a925bdef')) + check(expected and expected in version, 'DUT firmware source does not match expected build: '+label) + if label == 'center': + self.center_key = bytes.fromhex(public) + expected_model = spec.get('board_contains', spec.get('board', '')) + check(expected_model and normalized(expected_model) in normalized(model), + 'USB board model does not match configuration: '+label) + self.evidence.report['nodes'][label] = info + self.evidence.report['opening_node'] = None + self.evidence.save() + + def configure_companions(self): + settings = {'feed_left': RF['input_media'], 'feed_right': RF['input_usa'], + 'observe_media': RF['output_media'], 'observe_usa': RF['output_usa'], + 'observe_inputs': RF['input_usa']} + for label, rf in settings.items(): + node = self.nodes[label] + node.cli('set radio2 off') + node.cli('set radio2.cross off') + node.cli('set flood.retry.count 0') + node.cli('set radio '+rf_text(rf)+','+str(self.fixture_preamble)) + node.request(bytes((11,))+struct.pack('\s*)?-5', node.cli('get tx').strip()), 'fixture TX power readback mismatch') + if label == 'observe_inputs': + node.cli('set radio2 '+rf_text(RF['input_media'])+',rx,'+str(self.fixture_preamble)) + self.evidence.report['nodes'][label]['configured'] = { + 'radio': node.cli('get radio'), 'radio2': node.cli('get radio2'), + 'radio2_status': node.cli('get radio2.status'), 'packets': node.stats()} + self.evidence.save() + time.sleep(3) + + def configure_duts(self): + for label in self.duts: + node = self.nodes[label] + scope = node.batch() if self.config.get('batch_dut_configuration', False) else nullcontext(node) + with scope: + self._configure_dut(label) + self.restore_routes() + self.evidence.save() + time.sleep(3) + + def _configure_dut(self, label): + node = self.nodes[label] + commands = ['set radio.mode off', 'set powersaving off', 'set radio.rxps off', + 'set advert.interval 0', 'set flood.advert.interval 0', 'set flood.retry.count 0', + 'set repeat off', 'set espnow.enabled off', 'set udp.enabled off', + 'set mqtt.enabled off', 'stop webconfig', 'clear bridge.routes', + 'set bridge.ingress off', 'set bridge.source rx', 'set bridge.format wrapped', + 'set bridge.channel 6', 'set bridge.delay 500', 'set bridge.secret '+self.secret, + 'set radio2 off', 'set radio2.cross off', 'set tx 2'] + for command in commands: + node.cli(command, allow_error=command in ('stop webconfig', 'set mqtt.enabled off')) + rf = RF['input_media'] if label == 'left' else RF['input_usa'] if label == 'right' else RF['output_usa'] + node.cli('set radio '+rf_text(rf)+',96') + if label == 'center': + node.cli('set radio2 '+rf_text(RF['output_media'])+',rxtx,auto') + node.cli('set repeat on') + else: + node.cli('set espnow.enabled on') + check(node.cli('get espnow.running') == 'on', 'source ESP-NOW did not start') + mac = re.search(r'mac:([0-9A-Fa-f:]{17})', node.cli('get espnow.stats mac')) + check(mac is not None, 'missing source station MAC') + self.evidence.report['nodes'][label]['bridge_mac'] = mac[1].upper() + node.cli('set radio.mode '+('tx' if label == 'center' else 'rx')) + + def restore_routes(self): + center = self.nodes['center'] + for slot, label, profile in ((1, 'left', 'radio2'), (2, 'right', 'radio')): + if label not in self.duts: + continue + mac = self.evidence.report['nodes'][label]['bridge_mac'] + center.cli(f'set bridge.route.{slot} mac:{mac} {profile}') + + def wait_radio2_mode(self, mode, timeout=10): + """Confirm the runtime profile, not the immediately saved preference.""" + check(mode in ('off', 'rx', 'rxtx'), 'invalid radio2 barrier mode') + center = self.nodes['center'] + started, deadline = time.monotonic(), time.monotonic()+timeout + while time.monotonic() < deadline: + status = center.cli('get radio2.status', timeout=min(3, max(.1, deadline-time.monotonic()))) + if status.removeprefix('> ').startswith(mode+';'): + scan = center.cli('get radio2.scan', timeout=min(3, max(.1, deadline-time.monotonic()))) + enabled = scan.removeprefix('> ').strip() != 'off' + if enabled == (mode != 'off'): + record = self.evidence.event('radio2_runtime_confirmed', mode=mode, + status=status, scan=scan, elapsed_seconds=round(time.monotonic()-started, 3)) + self.evidence.report.setdefault('profile_barriers', []).append(record) + self.evidence.save() + return + time.sleep(.2) + raise TimeoutError('center radio2 runtime did not become '+mode) + + def snapshot(self): + result = {} + for label, node in self.nodes.items(): + if label in DUTS: + result[label] = {'mode': node.cli('get radio.mode'), 'radio': node.cli('get radio'), + 'radio2': node.cli('get radio2'), 'profiles': profile_counters(node.cli('get radio2.status')), + 'packets': node.packet_stats(), 'core': json.loads(node.cli('stats-core'))} + else: + result[label] = {'packets': node.stats(), + 'profiles': profile_counters(node.cli('get radio2.status'))} + return result + + def hop_diagnostics(self, source, sequence, stage): + """Optional intrusive diagnostics, outside the probe's timing window. + + Each command is retained with its collection interval. Compare a run + with this option off before attributing a timing change to firmware; + repeated CLI handling itself competes with the radio/bridge loop. + """ + if not self.config.get('per_probe_diagnostics', False): + return None + started = time.monotonic() + result = {'source': source, 'sequence': sequence, 'stage': stage, + 'collection_started_monotonic': started, 'nodes': {}} + for label in ('left' if source == 'L' else 'right', 'center'): + if label not in self.nodes: + continue + node = self.nodes[label] + scope = node.batch() if hasattr(node, 'batch') else nullcontext(node) + with scope: + values = {'packets': node.packet_stats(), + 'radio': parse_radio_diagnostic(node.cli('stats-radio-diag')), + 'profile_status': node.cli('get radio2.status'), + 'espnow_rx': node.cli('get espnow.stats rx'), + 'espnow_tx': node.cli('get espnow.stats tx'), + 'espnow_drops': node.cli('get espnow.stats drops')} + if label == 'center': + values['cad'] = node.cli('get cad') + result['nodes'][label] = values + result['collection_completed_monotonic'] = time.monotonic() + result['collection_seconds'] = round(time.monotonic()-started, 6) + self.evidence.event('hop_diagnostics', **result) + return result + + def probe(self, source, *, expected_forward=True, phase='positive', direct=False, + expected_output=None): + sequence = self.sequence + self.sequence += 1 + wire = make_probe(self.evidence.run, source, sequence, direct=direct) + feeder = self.nodes['feed_left' if source == 'L' else 'feed_right'] + diagnostics_before = self.hop_diagnostics(source, sequence, 'before') + started = time.monotonic() + self.evidence.event('probe_tx_request', source=source, sequence=sequence, phase=phase, + direct=direct, wire_hex=wire.hex()) + feeder.request(companion_send_command(wire), (0,)) + correct = expected_output or ('observe_media' if source == 'L' else 'observe_usa') + check(correct in ('observe_media', 'observe_usa'), 'invalid expected output witness') + wrong = 'observe_usa' if correct == 'observe_media' else 'observe_media' + deadline = started+float(self.config.get('probe_timeout', 12)) + while time.monotonic() < deadline: + records = self.evidence.records(source, sequence) + input_ok = any(x['observer'] == 'observe_inputs' and x['wire_hex'] == wire.hex() for x in records) + good = any(self.center_key is not None and x['observer'] == correct and x['wire_hex'] and + parse_probe(bytes.fromhex(x['wire_hex'])).forwarded_by(self.center_key) for x in records) + # An absence check needs its full observation window. Seeing the + # input packet is not proof that no delayed output will follow. + if expected_forward and not direct and input_ok and good: + break + with self.evidence.condition: + self.evidence.condition.wait(.1) + time.sleep(self.pause) + records = self.evidence.records(source, sequence) + input_ok = any(x['observer'] == 'observe_inputs' and x['wire_hex'] == wire.hex() for x in records) + output = [x for x in records if self.center_key is not None and x['observer'] == correct and x['wire_hex'] and + parse_probe(bytes.fromhex(x['wire_hex'])).forwarded_by(self.center_key)] + leaked = [x for x in records if x['observer'] == wrong] + any_output = [x for x in records if x['observer'] in ('observe_media', 'observe_usa')] + input_times = [x['monotonic'] for x in records if x['observer'] == 'observe_inputs' + and x['wire_hex'] == wire.hex() and 'monotonic' in x] + output_times = [x['monotonic'] for x in output if 'monotonic' in x] + within_deadline = not output_times or min(output_times) <= deadline + passed = input_ok and (bool(output) and within_deadline if expected_forward and not direct + else not any_output) and not leaked + item = {'phase': phase, 'source': source, 'sequence': sequence, 'direct': direct, + 'wire_hex': wire.hex(), 'mesh_hash': parse_probe(wire).mesh_hash, + 'expected_output': correct, + 'expected_forward': expected_forward, 'input_original_seen': input_ok, + 'correct_center_outputs': len(output), 'wrong_channel_observations': len(leaked), + 'output_observations': len(any_output), 'passed': passed, + 'elapsed_seconds': round(time.monotonic()-started, 3), + 'request_monotonic': started, 'observation_deadline_monotonic': deadline, + 'timing_basis': 'host USB notifications; not RF start/end timestamps', + 'input_notification_seconds': min(input_times)-started if input_times else None, + 'output_notification_seconds': min(output_times)-started if output_times else None, + 'input_to_output_notification_seconds': min(output_times)-min(input_times) + if input_times and output_times else None, + 'output_within_deadline': within_deadline if output else None, + 'observations': records} + item['delivery_result'] = ('wrong_channel' if leaked else + 'input_not_witnessed' if not input_ok else + 'forbidden_output' if (direct or not expected_forward) and any_output else + 'blocked_as_expected' if direct or not expected_forward else + 'missing_output' if not output else + 'delivered' if within_deadline else 'late_output') + if diagnostics_before is not None: + item['hop_diagnostics_before'] = diagnostics_before + item['hop_diagnostics_after'] = self.hop_diagnostics(source, sequence, 'after') + if direct and source == 'L': + item['radio2_tag_seen'] = any(x['observer'] == 'observe_inputs' and + x['push'] == 0x84 and x['radio_profile'] == 1 for x in records) + if self.config.get('require_radio2_tag', True): + item['passed'] &= item['radio2_tag_seen'] + self.evidence.report['trials'].append(item) + self.evidence.save() + print(json.dumps(item), flush=True) + return item + + def set_fixture_rf(self, label, rf, preamble=None): + node = self.nodes[label] + preamble = self.fixture_preamble if preamble is None else preamble + check(type(preamble) is int and 16 <= preamble <= 65528, 'invalid test preamble') + node.cli('set radio '+rf_text(rf)+','+str(preamble)) + node.request(bytes((11,))+struct.pack(' ').strip() + values = reply.split(',') + check(len(values) >= 4 and abs(float(values[0])-rf[0]) < .0001 and + abs(float(values[1])-rf[1]) < .01 and tuple(map(int, values[2:4])) == rf[2:4], + 'fixture RF readback mismatch: '+label) + self.evidence.event('fixture_rf_readback', node=label, radio=reply, preamble=preamble) + + def fixture_primary_preamble(self, label): + # Older Full Companions expose the effective pair through status but + # do not implement the newer saved-preference radio.preamble getter. + status = self.nodes[label].cli('get radio2.status') + matches = re.findall(r'\bpreamble=(\d+),(\d+)(?=\s|;|$)', status) + check(len(matches) == 1, 'missing or ambiguous fixture preamble readback') + primary, secondary = map(int, matches[0]) + self.evidence.event('fixture_preamble_readback', node=label, primary=primary, secondary=secondary) + return primary + + def source_rx_challenges(self): + # Repeat is enabled during this challenge: the direction gate, rather + # than repeat=off, must keep the source's physical transmitter silent. + for source in self.sources: + label = 'left' if source == 'L' else 'right' + node = self.nodes[label] + before = node.packet_stats() + node.cli('set repeat on') + try: + trial = self.probe(source, phase='rx_mode_repeat_enabled') + after = node.packet_stats() + key = bytes.fromhex(self.evidence.report['nodes'][label]['public_key']) + forwarded = [x for x in self.evidence.records(source, trial['sequence']) + if x['observer'] == 'observe_inputs' and x['wire_hex'] and + parse_probe(bytes.fromhex(x['wire_hex'])).forwarded_by(key)] + trial['source_tx_unchanged'] = after['sent'] == before['sent'] + trial['source_forward_observations'] = len(forwarded) + trial['passed'] &= trial['source_tx_unchanged'] and not forwarded + self.evidence.save() + finally: + node.cli('set repeat off') + + def center_rx_challenges(self): + # Inject on the center's actual tunings. An output witness must see + # each original over RF while the TX-only center's RX stays unchanged. + center = self.nodes['center'] + try: + for source, profile, observer in (('L', 'output_media', 'observe_media'), + ('R', 'output_usa', 'observe_usa')): + feeder = 'feed_left' if source == 'L' else 'feed_right' + self.set_fixture_rf(feeder, RF[profile]) + time.sleep(2) + before = center.packet_stats() + sequence = self.sequence + self.sequence += 1 + wire = make_probe(self.evidence.run, source, sequence) + started = time.monotonic() + self.evidence.event('probe_tx_request', source=source, sequence=sequence, + phase='tx_mode_rf_reception_challenge', profile=profile, wire_hex=wire.hex()) + self.nodes[feeder].request(companion_send_command(wire), (0,)) + time.sleep(float(self.config.get('probe_timeout', 12))+self.pause) + records = self.evidence.records(source, sequence) + witness = any(x['observer'] == observer and x['wire_hex'] == wire.hex() for x in records) + forwarded = [x for x in records if x['wire_hex'] and + parse_probe(bytes.fromhex(x['wire_hex'])).forwarded_by(self.center_key)] + wrong = [x for x in records if x['observer'] in ('observe_media', 'observe_usa') + and x['observer'] != observer] + after = center.packet_stats() + item = {'phase': 'tx_mode_rf_reception_challenge', 'source': source, + 'sequence': sequence, 'direct': False, 'expected_forward': False, + 'expected_output': observer, 'expected_original_on_output': True, + 'original_rf_witness_seen': witness, 'center_rx_unchanged': after['recv'] == before['recv'], + 'center_forward_observations': len(forwarded), 'wrong_channel_observations': len(wrong), + 'passed': witness and after['recv'] == before['recv'] and not forwarded and not wrong, + 'elapsed_seconds': round(time.monotonic()-started, 3)} + self.evidence.report['trials'].append(item) + self.evidence.save() + print(json.dumps(item), flush=True) + finally: + self.set_fixture_rf('feed_left', RF['input_media']) + self.set_fixture_rf('feed_right', RF['input_usa']) + time.sleep(2) + for source in self.sources: + self.probe(source, phase='restored_input_rf_control') + + def cad_busy_challenge(self): + """Finite LoRa blocker with physical TX-state/counter ordering proof.""" + center = self.nodes['center'] + feeder = self.nodes['feed_left'] + poll_interval = float(self.config.get('cad_poll_interval', .25)) + check(.1 <= poll_interval <= 1, 'cad_poll_interval must be 0.1..1 seconds') + samples = [] + + def sample(node, role): + began = time.monotonic() + value = parse_radio_diagnostic(node.cli('stats-radio-diag')) + value.update(node=role, request_started_monotonic=began, + reply_completed_monotonic=time.monotonic()) + samples.append(value) + self.evidence.event('physical_radio_sample', **value) + return value + + def busy_count(timeout=15): + reply = center.cli('get cad', timeout=timeout) + match = re.search(r'# channel busy:\s*(\d+)', reply) + check(reply.startswith('on (required') and match is not None, + 'mandatory CAD state/counter unavailable') + return int(match[1]) + + before = center.packet_stats() + busy_before = busy_count() + block_sequence, request_sequence = self.sequence, self.sequence+1 + self.sequence += 2 + blocker = make_probe(self.evidence.run, 'L', block_sequence, direct=True) + request = make_probe(self.evidence.run, 'R', request_sequence) + started, busy_peak = time.monotonic(), busy_before + blocker_requested_at = None + result = {'status': 'running', 'blocker_sequence': block_sequence, + 'request_sequence': request_sequence, 'preamble_symbols': 8192, + 'nominal_preamble_seconds': 8192*128/62500, + 'busy_before': busy_before, 'poll_interval_seconds': poll_interval, + 'timing_basis': 'synchronous physical driver TX_WAIT and TX_DONE counters', + 'usb_witness_order_is_rf_timing_proof': False} + self.evidence.report['hardware_cad_busy_test'] = result + self.evidence.save() + try: + self.set_fixture_rf('feed_left', RF['output_usa'], preamble=8192) + check(self.fixture_primary_preamble('feed_left') == 8192, + 'long blocker preamble readback mismatch') + blocker_baseline = sample(feeder, 'blocker') + center_baseline = sample(center, 'center') + check(not blocker_baseline['tx_wait'] and not blocker_baseline['outbound'] and + not center_baseline['tx_wait'] and not center_baseline['outbound'], + 'CAD trial must start with both transmit queues and radios idle') + self.evidence.event('probe_tx_request', source='L', sequence=block_sequence, + phase='cad_busy_blocker', direct=True, wire_hex=blocker.hex()) + blocker_request_started_at = time.monotonic() + feeder.request(companion_send_command(blocker), (0,)) + blocker_requested_at = time.monotonic() + start_deadline = time.monotonic()+8 + while True: + live = sample(feeder, 'blocker') + check(live['sent'] == blocker_baseline['sent'], + 'blocker completed before its physical TX_WAIT was observed') + if live['tx_wait']: + break + check(time.monotonic() < start_deadline, 'blocker never entered physical TX_WAIT') + time.sleep(poll_interval) + self.evidence.event('probe_tx_request', source='R', sequence=request_sequence, + phase='cad_busy_forward_request', direct=False, wire_hex=request.hex()) + self.nodes['feed_right'].request(companion_send_command(request), (0,)) + deadline = time.monotonic()+30 + while time.monotonic() < deadline: + # A center TX_WAIT surrounded by blocker TX_WAIT readbacks + # brackets the cached TX states. RF overlap additionally + # requires this to occur before the conservative earliest + # end of the blocker's verified long preamble. + sample(feeder, 'blocker') + sample(center, 'center') + sample(feeder, 'blocker') + busy_peak = max(busy_peak, busy_count(timeout=min(3, max(.1, deadline-time.monotonic())))) + time.sleep(poll_interval) + blocks = self.evidence.records('L', block_sequence) + requests = self.evidence.records('R', request_sequence) + block_times = [x['monotonic'] for x in blocks if x['observer'] == 'observe_usa' + and x['wire_hex'] == blocker.hex()] + input_times = [x['monotonic'] for x in requests if x['observer'] == 'observe_inputs' + and x['wire_hex'] == request.hex()] + forward_times = [x['monotonic'] for x in requests if x['observer'] == 'observe_usa' + and x['wire_hex'] and parse_probe(bytes.fromhex(x['wire_hex'])).forwarded_by(self.center_key)] + media = [x for x in blocks+requests if x['observer'] == 'observe_media'] + after = center.packet_stats() + delay = min(forward_times)-min(input_times) if forward_times and input_times else None + timing = assess_cad_samples(samples, blocker_baseline['sent'], center_baseline['sent'], + blocker_request_started_at+result['nominal_preamble_seconds']) + invariants = {'blocker_rf_witness_seen': bool(block_times), + 'request_input_rf_witness_seen': bool(input_times), + 'center_forward_rf_witness_seen': bool(forward_times), + 'cad_busy_increased': busy_peak > busy_before, + 'blocker_physical_tx_wait_seen': timing['blocker_tx_wait_observed'], + 'blocker_completed_one_physical_tx': timing['blocker_completed_once'], + 'center_completed_one_physical_tx': timing['center_completed_once'], + 'physical_simultaneous_tx_absent': not timing['simultaneous_tx_proven'], + 'center_rf_rx_unchanged': after['recv'] == before['recv'], + 'media_output_absent': not media} + status = ('failed' if not all(invariants.values()) else timing['verdict']) + result.update(status=status, invariants=invariants, physical_timing=timing, + physical_samples=samples, busy_peak=busy_peak, + input_to_output_notification_seconds=delay, + blocker_to_forward_notification_seconds=min(forward_times)-min(block_times) + if forward_times and block_times else None, + blocker_witness_monotonic=min(block_times) if block_times else None, + forward_witness_monotonic=min(forward_times) if forward_times else None, + observations=blocks+requests, + elapsed_seconds=round(time.monotonic()-started, 3)) + self.evidence.save() + print(json.dumps({'phase': 'cad_busy', **{key: value for key, value in result.items() + if key not in ('physical_samples', 'observations')}, + 'physical_sample_count': len(samples)}), flush=True) + except BaseException as error: + result.update(status='execution_failed', error=type(error).__name__) + self.evidence.save() + raise + finally: + # The blocker may still be on-air if an earlier CLI failed. Wait + # past its finite preamble before changing its tuning. + if blocker_requested_at is not None and time.monotonic()-blocker_requested_at < 19: + time.sleep(19-(time.monotonic()-blocker_requested_at)) + self.set_fixture_rf('feed_left', RF['input_media']) + check(self.fixture_primary_preamble('feed_left') == self.fixture_preamble, + 'normal feeder preamble restoration failed') + self.probe('R', phase='cad_busy_restored_control') + + def fixture_test(self): + self.evidence.report['coverage'] = 'fixtures_input_rf_only' + self.evidence.report['not_tested'] = ['DUT input reception and bridge forwarding', + 'center RF output', 'DUT direction enforcement', 'DUT saved-setting reboot'] + self.evidence.report['baseline'] = self.snapshot() + for _ in range(3): + for source in ('L', 'R'): + self.probe(source, expected_forward=False, phase='fixture_input_rf') + self.probe('L', expected_forward=False, phase='fixture_radio2_tag', direct=True) + time.sleep(float(self.config.get('final_drain', 3))) + final = self.snapshot() + self.evidence.report['final'] = final + baseline = self.evidence.report['baseline'] + invariants = { + 'left_feeder_transmitted': final['feed_left']['packets']['sent'] > baseline['feed_left']['packets']['sent'], + 'right_feeder_transmitted': final['feed_right']['packets']['sent'] > baseline['feed_right']['packets']['sent'], + 'input_radio2_received': final['observe_inputs']['profiles']['rx'][1] > baseline['observe_inputs']['profiles']['rx'][1], + 'input_primary_received': final['observe_inputs']['profiles']['rx'][0] > baseline['observe_inputs']['profiles']['rx'][0], + 'observers_did_not_transmit': all(final[label]['packets']['sent'] == baseline[label]['packets']['sent'] + for label in ('observe_media', 'observe_usa', 'observe_inputs')), + 'output_channels_quiet': not any(x['observer'] in ('observe_media', 'observe_usa') + for x in self.evidence.observations)} + self.evidence.report['invariants'] = invariants + passed = all(invariants.values()) and all(x['passed'] for x in self.evidence.report['trials']) + self.evidence.report['status'] = 'fixtures_input_rf_passed' if passed else 'failed' + self.evidence.save() + + def run(self): + center = self.nodes['center'] + self.evidence.report['baseline'] = self.snapshot() + for source in self.sources: + self.probe(source, expected_forward=False, phase='bridge_off') + center.cli('set espnow.enabled on') + check(center.cli('get espnow.running') == 'on', 'center ESP-NOW did not start') + for _ in range(int(self.config.get('positive_per_source', 5))): + for source in self.sources: + self.probe(source) + self.source_rx_challenges() + self.center_rx_challenges() + if self.config.get('cad_busy_test', False): + self.cad_busy_challenge() + if self.right_only: + mac = self.evidence.report['nodes']['right']['bridge_mac'] + center.cli(f'set bridge.route.2 mac:{mac} radio2') + try: + self.probe('R', phase='right_source_radio2_route_substitute', expected_output='observe_media') + finally: + self.restore_routes() + self.probe('L', expected_forward=False, phase='radio2_direct_witness', direct=True) + center.cli('set radio2.cross on') + for source in self.sources: + self.probe(source, phase='cross_on_isolation') + center.cli('set radio2.cross off') + center.cli('set radio2 off') + self.wait_radio2_mode('off') + if not self.right_only: + self.probe('L', expected_forward=False, phase='radio2_unavailable') + self.probe('R', phase='radio2_unavailable_usa_control') + center.cli('set radio2 '+rf_text(RF['output_media'])+',rxtx,auto') + self.wait_radio2_mode('rxtx') + if not self.right_only: + # Exercise the first real bridge packet after the runtime enable + # barrier, then a steady control. Do not warm the output with a + # different probe first or silently increase its delivery deadline. + self.probe('L', phase='radio2_reenabled_first_packet') + self.probe('L', phase='radio2_reenabled_steady_control') + center.cli('del bridge.route.'+('2' if self.right_only else '1')) + self.probe('R' if self.right_only else 'L', expected_forward=False, phase='unmapped_sender') + if not self.right_only: + self.probe('R', phase='unmapped_sender_usa_control') + self.restore_routes() + self.evidence.report['before_reboot'] = self.snapshot() + if self.config.get('reboot', True): + for label in self.duts: + self.nodes[label].reboot() + public = self.nodes[label].cli('get public.key') + check(public.upper() == self.evidence.report['nodes'][label]['public_key'], + 'DUT identity changed on warm reboot') + check(all(self.nodes[label].cli('get radio.mode') == 'rx' for label in self.duts if label != 'center') and + center.cli('get radio.mode') == 'tx', 'direction did not survive reboot') + self.evidence.report['after_reboot_before_probes'] = self.snapshot() + for source in self.sources: + self.probe(source, phase='warm_reboot') + time.sleep(float(self.config.get('final_drain', 3))) + self.evidence.report['final'] = self.snapshot() + baseline, before = self.evidence.report['baseline'], self.evidence.report['before_reboot'] + invariants = { + 'right_lora_tx_unchanged': before['right']['packets']['sent'] == baseline['right']['packets']['sent'], + 'center_rf_rx_unchanged': before['center']['packets']['recv'] == baseline['center']['packets']['recv'], + 'input_observer_radio2_rx_increased': before['observe_inputs']['profiles']['rx'][1] > + baseline['observe_inputs']['profiles']['rx'][1], + 'observers_did_not_transmit': all(before[label]['packets']['sent'] == baseline[label]['packets']['sent'] + for label in ('observe_media', 'observe_usa', 'observe_inputs'))} + if not self.right_only: + invariants['left_lora_tx_unchanged'] = before['left']['packets']['sent'] == baseline['left']['packets']['sent'] + if self.config.get('reboot', True): + final = self.evidence.report['final'] + invariants['post_reboot_rx_sources_silent'] = all(final[label]['packets']['sent'] == 0 + for label in self.duts if label != 'center') + invariants['post_reboot_center_no_rf_receive'] = final['center']['packets']['recv'] == 0 + self.evidence.report['invariants'] = invariants + self.evidence.report['cad'] = center.cli('get cad') + invariants['center_cad_required'] = self.evidence.report['cad'].startswith('on (required') + if self.config.get('cad_busy_test', False): + invariants['hardware_cad_busy_passed'] = self.evidence.report['hardware_cad_busy_test']['status'] == 'passed' + # Recheck all captured observations after the entire run, including + # outputs which arrived after an earlier trial's local deadline. + for trial in self.evidence.report['trials']: + records = self.evidence.records(trial['source'], trial['sequence']) + trial['observations'] = records + correct = trial.get('expected_output', 'observe_media' if trial['source'] == 'L' else 'observe_usa') + wrong = 'observe_usa' if correct == 'observe_media' else 'observe_media' + forbidden = [x for x in records if x['observer'] == wrong or ( + trial.get('expected_original_on_output') and x['wire_hex'] and + parse_probe(bytes.fromhex(x['wire_hex'])).forwarded_by(self.center_key)) or ( + not trial.get('expected_original_on_output') and + (trial['direct'] or not trial['expected_forward']) and + x['observer'] in ('observe_media', 'observe_usa'))] + trial['late_or_wrong_output_observations'] = len(forbidden) + trial['passed'] = trial['passed'] and not forbidden + if 'request_monotonic' in trial: + delivered = [x for x in records if x['observer'] == correct and x['wire_hex'] and + parse_probe(bytes.fromhex(x['wire_hex'])).forwarded_by(self.center_key)] + times = [x['monotonic'] for x in delivered] + if times: + trial['output_notification_seconds'] = min(times)-trial['request_monotonic'] + trial['output_within_deadline'] = min(times) <= trial['observation_deadline_monotonic'] + trial['delivery_result'] = ('forbidden_output' if forbidden else + 'input_not_witnessed' if not trial['input_original_seen'] else + 'blocked_as_expected' if not trial['expected_forward'] or trial['direct'] else + 'missing_output' if not times else + 'delivered' if trial['output_within_deadline'] else 'late_output') + passed = all(invariants.values()) and all(x['passed'] for x in self.evidence.report['trials']) + self.evidence.report['status'] = ('partial_passed' if self.right_only else 'passed') if passed else 'failed' + self.evidence.save() + + def quiesce(self): + result = {} + # The transmitting center is stopped first. Reopen a desynchronized + # session once and verify its public identity before changing it. + for label in ('center', 'left', 'right'): + if label not in self.nodes: + continue + node = self.nodes[label] + try: + if node.failure: + node.close() + node = Infrastructure(label, self.config['nodes'][label], self.evidence) + self.nodes[label] = node + public = node.cli('get public.key', timeout=8) + check(public.upper() == self.evidence.report['nodes'][label]['public_key'], + 'quiescence identity mismatch') + node.cli('set radio.mode off', timeout=8) + check(node.cli('get radio.mode', timeout=8) == 'off', 'radio off not confirmed') + result[label] = 'LoRa off confirmed' + except BaseException as error: + result[label] = 'unconfirmed: '+type(error).__name__ + self.evidence.report['failure_quiescence'] = result + self.evidence.save() + + def close(self): + for node in reversed(list(self.nodes.values())): + try: + node.close() + except Exception: + pass + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--config', required=True, type=Path) + parser.add_argument('--output', required=True, type=Path) + parser.add_argument('--companions-only', action='store_true') + parser.add_argument('--fixtures-test', action='store_true', help='Test five fixture radios without opening DUT ports') + parser.add_argument('--right-only', action='store_true', help='Test right/center while left DUT is unavailable') + args = parser.parse_args() + parser.error('--companions-only and --fixtures-test are mutually exclusive') if args.companions_only and args.fixtures_test else None + config = json.loads(args.config.read_text()) + evidence = Evidence(args.output) + evidence.report['source_commit'] = config.get('source_commit') + campaign = Campaign(config, evidence, args.right_only) + try: + campaign.open(args.companions_only or args.fixtures_test) + campaign.configure_companions() + if args.companions_only: + evidence.report['status'] = 'fixtures_configured_no_probe_rf' + evidence.save() + elif args.fixtures_test: + campaign.fixture_test() + else: + campaign.configure_duts() + campaign.run() + if evidence.report['status'] == 'failed': + campaign.quiesce() + except BaseException as error: + evidence.report['status'] = 'execution_failed' + evidence.report['failure'] = {'type': type(error).__name__, 'message': str(error)[:250], + 'opening_node': evidence.report.get('opening_node'), + 'serial_operation': evidence.report.get('last_serial_operation')} + evidence.save() + campaign.quiesce() + raise + finally: + campaign.close() + evidence.close() + return 0 if evidence.report['status'] in ('passed', 'partial_passed', 'fixtures_configured_no_probe_rf', + 'fixtures_input_rf_passed') else 1 + + +if __name__ == '__main__': + raise SystemExit(main()) diff --git a/tools/hil/split_radio_probe.py b/tools/hil/split_radio_probe.py new file mode 100644 index 000000000..13c0cb81e --- /dev/null +++ b/tools/hil/split_radio_probe.py @@ -0,0 +1,153 @@ +"""Pure probe codec for split RX/bridge/TX radio tests; never opens hardware.""" +from dataclasses import dataclass +import hashlib +import re +import struct + +MAGIC = b"SPLIT1" + + +@dataclass(frozen=True) +class Probe: + run: bytes + source: str + sequence: int + payload: bytes + wire: bytes | None = None + route: int | None = None + path_width: int | None = None + path: bytes | None = None + + @property + def key(self): + return self.run.hex(), self.source, self.sequence + + @property + def mesh_hash(self): + # Packet::calculatePacketHash intentionally excludes the route/path. + return hashlib.sha256(bytes((15,)) + self.payload).digest()[:8].hex() + + @property + def original(self): + return self.wire is not None and self.route in (0, 1) and self.path == b"" + + def forwarded_by(self, public_key): + if len(public_key) != 32: + raise ValueError("Need the full independently verified center public key") + return self.wire is not None and self.route in (0, 1) and \ + self.path_width in (1, 2, 3) and self.path == public_key[:self.path_width] + + def record(self): + return {"run_id": self.run.hex(), "source": self.source, "sequence": self.sequence, + "mesh_hash": self.mesh_hash, "payload_hex": self.payload.hex(), + "wire_hex": None if self.wire is None else self.wire.hex(), + "route": self.route, "path_width": self.path_width, + "path_hex": None if self.path is None else self.path.hex()} + + +def make_probe(run, source, sequence, *, payload_size=40, direct=False): + """Return a V1 RAW_CUSTOM packet, zero-hop flood by default.""" + if len(run) != 8 or source not in ("L", "R") or not 0 <= sequence <= 0xffffffff: + raise ValueError("Need run[8], source L/R and uint32 sequence") + fixed = MAGIC + run + source.encode("ascii") + struct.pack("> 6 or (wire[0] >> 2) & 15 != 15: + return None + route = wire[0] & 3 + path_offset = 5 if route in (0, 3) else 1 + if path_offset >= len(wire): + return None + encoded = wire[path_offset] + width, count = (encoded >> 6)+1, encoded & 63 + if width > 3 or width * count > 64: + return None + end = path_offset + 1 + count * width + if end > len(wire): + return None + payload = _payload_probe(wire[end:], expected_run) + if payload is None: + return None + return Probe(payload.run, payload.source, payload.sequence, payload.payload, + wire, route, width, wire[path_offset+1:end]) + + +def companion_send_command(wire, priority=0): + """CMD_SEND_RAW_PACKET (65), excluding the outer USB framing.""" + if not 0 <= priority <= 255 or not 2 <= len(wire) <= 174: + raise ValueError("Probe must fit the conservative Companion host frame") + return bytes((65, priority)) + bytes(wire) + + +def inspect_push(frame, *, expected_run=None, observer=None, + configured_profile=None, frequency_mhz=None): + """Decode push 88 or 84. Configuration labels are never treated as RF proof. + + Push 88 has full wire bytes but no receive-profile field. Push 84 reports + direct RAW_CUSTOM payload and may carry A0/A1, but has no path/wire bytes. + """ + if configured_profile not in (None, 0, 1): + raise ValueError("Configured profile must be primary 0 or radio2 1") + frame = bytes(frame) + if len(frame) < 4 or frame[0] not in (0x88, 0x84): + return None + if frame[0] == 0x88: + probe = parse_probe(frame[3:], expected_run=expected_run) + profile = None + else: + probe = _payload_probe(frame[4:], expected_run) + profile = {0xA0: 0, 0xA1: 1}.get(frame[3]) + if probe is None: + return None + snr, rssi = struct.unpack("bb", frame[1:3]) + return {**probe.record(), "push": frame[0], "snr_db": snr/4, "rssi_dbm": rssi, + "radio_profile": profile, "observer": observer, + "configured_profile": configured_profile, "frequency_mhz": frequency_mhz} + + +def packet_stats(frame): + """Exact CMD_GET_STATS/2 reply: two-byte tag then seven LE uint32 counters.""" + if len(frame) != 30 or frame[:2] != bytes((24, 2)): + raise ValueError("Expected exact Companion packet-stats response") + return dict(zip(("recv", "sent", "flood_tx", "direct_tx", "flood_rx", "direct_rx", "recv_errors"), + struct.unpack_from("<7I", frame, 2))) + + +def profile_counters(reply): + """Read the complete per-profile counter pair from get radio2.status.""" + matches = re.findall(r"\bRX=(\d+),(\d+)\s+TX=(\d+),(\d+)(?=\s|;|$)", reply) + if len(matches) != 1: + raise ValueError("Missing or ambiguous per-profile RX/TX counters") + values = tuple(map(int, matches[0])) + return {"rx": list(values[:2]), "tx": list(values[2:])} + + +def counter_delta(before, after): + if before.keys() != after.keys() or any(type(x) is not int or not 0 <= x <= 0xffffffff + for record in (before, after) for x in record.values()): + raise ValueError("Need matching unsigned 32-bit counter sets") + return {name: (after[name]-value) & 0xffffffff for name, value in before.items()} + + +def kiss_frame(command, payload=b""): + """Generic KISS encoder, including DATA port 0/1 for dedicated modem HIL.""" + if not 0 <= command <= 255 or len(payload) > 511: + raise ValueError("Invalid KISS command/frame size") + body = bytes((command,)) + bytes(payload) + return b"\xc0" + body.replace(b"\xdb", b"\xdb\xdd").replace(b"\xc0", b"\xdb\xdc") + b"\xc0" diff --git a/tools/hil/test_split_radio_hardware_test.py b/tools/hil/test_split_radio_hardware_test.py new file mode 100644 index 000000000..440d3babd --- /dev/null +++ b/tools/hil/test_split_radio_hardware_test.py @@ -0,0 +1,634 @@ +"""No-hardware checks for split-repeater runner framing and RF verdicts.""" +import tempfile +from pathlib import Path +import unittest +from unittest.mock import Mock, patch + +import split_radio_hardware_test as runner +from split_radio_probe import make_probe + + +class Clock: + def __init__(self): + self.now = 0.0 + + def monotonic(self): + return self.now + + def sleep(self, seconds): + self.now += seconds + + +class Condition: + def __init__(self, clock): + self.clock = clock + + def __enter__(self): + return self + + def __exit__(self, *args): + return False + + def wait(self, seconds): + self.clock.sleep(seconds) + + +class SplitRadioHardwareRunnerTests(unittest.TestCase): + def test_physical_radio_parser_accepts_irq_tx_state_and_rejects_partial_reply(self): + parsed = runner.parse_radio_diagnostic( + 'state=19(INT_READY), recv=12, sent=4294967295, errors=0, err_flags=0, ' + 'outbound=yes, last_rx=0s ago') + self.assertTrue(parsed['tx_wait']) + self.assertEqual(parsed['sent'], 0xffffffff) + self.assertTrue(parsed['outbound']) + for reply in ('state=3(TX_WAIT), sent=0', + 'state=3(TX_WAIT), recv=0, sent=-1, errors=0, err_flags=0, outbound=no, last_rx=0s ago'): + with self.subTest(reply=reply), self.assertRaises(RuntimeError): + runner.parse_radio_diagnostic(reply) + + def test_cad_order_requires_physical_idle_gap_after_blocker_tx_done(self): + def sample(node, sent, tx=False): + return {'node': node, 'sent': sent, 'tx_wait': tx, 'reply_completed_monotonic': 4} + + blocked = sample('blocker', 10, True) + completed = sample('blocker', 11) + center_idle, center_tx, center_done = (sample('center', 20), sample('center', 20, True), + sample('center', 21)) + for samples, expected in ( + ([blocked, center_idle, blocked, completed, center_idle, center_tx, center_done], 'passed'), + ([blocked, center_tx, blocked, completed, center_done], 'failed'), + # TX_DONE notification order cannot tell whether TX began before + # the blocker completed. An observer's later USB report cannot + # supply the missing physical idle gap. + ([blocked, completed, center_done], 'inconclusive'), + ([blocked, completed, center_idle, center_done, sample('center', 22)], 'inconclusive')): + with self.subTest(expected=expected): + result = runner.assess_cad_samples(samples, 10, 20, blocker_minimum_end=17) + self.assertEqual(result['verdict'], expected) + self.assertEqual(result['simultaneous_tx_proven'], expected == 'failed') + uncertain = runner.assess_cad_samples([blocked, center_tx, blocked, completed, center_done], 10, 20) + self.assertEqual(uncertain['verdict'], 'inconclusive') + self.assertTrue(uncertain['driver_tx_wait_overlap']) + self.assertFalse(uncertain['simultaneous_tx_proven']) + + def test_cad_order_handles_wrapped_physical_counters_and_rejects_reboots(self): + samples = [{'node': 'blocker', 'sent': 0xffffffff, 'tx_wait': True}, + {'node': 'blocker', 'sent': 0, 'tx_wait': False}, + {'node': 'center', 'sent': 0xffffffff, 'tx_wait': False}, + {'node': 'center', 'sent': 0, 'tx_wait': False}] + self.assertEqual(runner.assess_cad_samples(samples, 0xffffffff, 0xffffffff)['verdict'], 'passed') + self.assertEqual(runner.assess_cad_samples(samples, 12, 13)['verdict'], 'inconclusive') + + def test_configured_fixture_preamble_applies_to_primary_radio2_and_restoration(self): + class Evidence: + report = {'nodes': {label: {} for label in runner.FIXTURES}} + + def event(self, *args, **kwargs): + pass + + def save(self): + pass + + class Fixture: + def __init__(self): + self.commands = [] + self.radio = '' + + def cli(self, command): + self.commands.append(command) + if command.startswith('set radio '): + self.radio = ','.join(command[10:].split(',')[:4]) + if command == 'get radio': + return self.radio + if command == 'get tx': + return '-5' + return 'off' + + def request(self, *args): + return b'\0' + + def stats(self): + return {} + + campaign = runner.Campaign({'fixture_preamble': 256}, Evidence()) + campaign.nodes = {label: Fixture() for label in runner.FIXTURES} + with patch.object(runner.time, 'sleep'): + campaign.configure_companions() + for node in campaign.nodes.values(): + self.assertTrue(any(command.startswith('set radio ') and command.endswith(',256') + for command in node.commands)) + self.assertIn('set radio2 902.750,250,7,5,rx,256', campaign.nodes['observe_inputs'].commands) + campaign.set_fixture_rf('feed_left', runner.RF['output_usa'], preamble=8192) + campaign.set_fixture_rf('feed_left', runner.RF['input_media']) + self.assertIn('set radio 906.525,62.5,7,5,8192', campaign.nodes['feed_left'].commands) + self.assertEqual(campaign.nodes['feed_left'].commands[-2], 'set radio 902.750,250,7,5,256') + + def test_radio2_barrier_waits_for_runtime_disable_not_saved_preference(self): + clock = Clock() + + class Evidence: + report = {} + + def event(self, kind, **kwargs): + return {'kind': kind, **kwargs} + + def save(self): + pass + + class Center: + commands = [] + + def cli(self, command, timeout=15): + self.commands.append(command) + if command == 'get radio2': + return 'off (manual)' # Already saved, but not yet live. + if command == 'get radio2.status': + return ('rxtx' if clock.now < 2 else 'off')+'; RX=0,0 TX=0,0' + if command == 'get radio2.scan': + return 'off' if clock.now >= 2 else 'slow=radio' + raise AssertionError(command) + + campaign = runner.Campaign({}, Evidence()) + campaign.nodes['center'] = Center() + with patch.object(runner.time, 'monotonic', clock.monotonic), \ + patch.object(runner.time, 'sleep', clock.sleep): + campaign.wait_radio2_mode('off') + self.assertGreaterEqual(clock.now, 2) + self.assertNotIn('get radio2', campaign.nodes['center'].commands) + self.assertEqual(campaign.evidence.report['profile_barriers'][0]['mode'], 'off') + + def test_infrastructure_batch_keeps_only_one_port_and_closes_on_error(self): + clock = Clock() + port = Mock() + port.in_waiting = 128 + port.read.side_effect = [b'ver\r\n -> test firmware\r\n', + b'ver\r\n -> test firmware\r\n', + b'bad\r\n -> Err - invalid\r\n'] + with tempfile.TemporaryDirectory() as temporary: + evidence = runner.Evidence(Path(temporary)/'run') + try: + with patch.object(runner, 'verify_usb') as identity, \ + patch('serial.Serial', return_value=port) as create, \ + patch.object(runner, 'open_configured_session') as opened, \ + patch.object(runner.time, 'monotonic', clock.monotonic), \ + patch.object(runner.time, 'sleep', clock.sleep): + node = runner.Infrastructure('center', {'port': '/test'}, evidence) + with node.batch(): + self.assertEqual(node.cli('ver'), 'test firmware') + self.assertEqual(node.cli('ver'), 'test firmware') + port.close.assert_not_called() + self.assertIs(node.port, port) + with self.assertRaises(RuntimeError): + with node.batch(): + self.fail('nested batch was allowed') + self.assertIsNone(node.port) + self.assertFalse(node._batch_active) + self.assertEqual(create.call_count, 1) + self.assertEqual(opened.call_count, 1) + port.close.assert_called_once() + self.assertEqual(identity.call_count, 2) + with self.assertRaises(RuntimeError): + with node.batch(): + node.cli('bad') + self.assertIsNone(node.port) + self.assertFalse(node._batch_active) + self.assertEqual(port.close.call_count, 2) + self.assertFalse(node.failure) # Complete error reply, not a lost transaction. + finally: + evidence.close() + + def test_infrastructure_opens_only_for_one_transaction_and_closes_on_timeout(self): + clock = Clock() + ports = [] + + class Port: + def __init__(self, **kwargs): + self.dtr = self.rts = None + self.response = b'' + self.closed = False + self.writes = [] + ports.append(self) + + @property + def in_waiting(self): + return len(self.response) + + def write(self, data): + self.writes.append(data) + if data == b'ver\r': + self.response = b'ver\r\n -> test firmware\r\n' + return len(data) + + def flush(self): + pass + + def read(self, count): + clock.sleep(.1) + data, self.response = self.response[:count], self.response[count:] + return data + + def close(self): + self.closed = True + + def safe_open(port): + self.assertFalse(port.dtr) + self.assertFalse(port.rts) + + with tempfile.TemporaryDirectory() as temporary: + evidence = runner.Evidence(Path(temporary)/'run') + try: + with patch.object(runner, 'verify_usb') as identity, \ + patch('serial.Serial', Port), patch.object(runner, 'open_configured_session', safe_open), \ + patch.object(runner.time, 'monotonic', clock.monotonic), \ + patch.object(runner.time, 'sleep', clock.sleep): + node = runner.Infrastructure('right', {'port': '/test'}, evidence) + self.assertEqual(ports, []) + self.assertIsNone(node.port) + self.assertEqual(node.cli('ver'), 'test firmware') + self.assertTrue(ports[0].closed) + self.assertIsNone(node.port) + with self.assertRaises(TimeoutError): + node.cli('get radio', timeout=.5) + self.assertTrue(ports[1].closed) + self.assertIsNone(node.port) + self.assertTrue(node.failure) + self.assertEqual(identity.call_count, 3) + self.assertEqual(evidence.report['last_serial_operation']['node'], 'right') + self.assertEqual(ports[0].writes, [b'\r', b'ver\r']) + finally: + evidence.close() + + def test_infrastructure_reboot_opens_its_own_session_and_checks_application_reply(self): + clock = Clock() + ports = [] + + class Port: + def __init__(self, **kwargs): + self.response, self.writes, self.closed = b'', [], False + ports.append(self) + + @property + def in_waiting(self): + return len(self.response) + + def write(self, data): + self.writes.append(data) + if data == b'ver\r': + self.response = b'ver\r\n -> test firmware\r\n' + return len(data) + + def flush(self): + pass + + def read(self, count): + clock.sleep(.1) + data, self.response = self.response[:count], self.response[count:] + return data + + def close(self): + self.closed = True + + with tempfile.TemporaryDirectory() as temporary: + evidence = runner.Evidence(Path(temporary)/'run') + try: + with patch.object(runner, 'verify_usb'), patch('serial.Serial', Port), \ + patch.object(runner, 'open_configured_session'), \ + patch.object(runner.time, 'monotonic', clock.monotonic), \ + patch.object(runner.time, 'sleep', clock.sleep): + node = runner.Infrastructure('center', {'port': '/test'}, evidence) + node.reboot() + self.assertEqual(len(ports), 2) + self.assertIn(b'reboot\r', ports[0].writes) + self.assertIn(b'ver\r', ports[1].writes) + self.assertTrue(all(port.closed for port in ports)) + self.assertIsNone(node.port) + self.assertFalse(node.failure) + finally: + evidence.close() + + def test_dual_input_witness_does_not_invent_a_configured_profile(self): + with tempfile.TemporaryDirectory() as temporary: + evidence = runner.Evidence(Path(temporary)/'run') + try: + wire = make_probe(evidence.run, 'R', 1) + evidence.receive('observe_inputs', b'\x88\x00\x9c'+wire) + record = evidence.observations[0] + self.assertIsNone(record['configured_profile']) + self.assertIsNone(record['radio_profile']) + self.assertEqual(record['wire_hex'], wire.hex()) + finally: + evidence.close() + + def test_negative_trial_observes_full_horizon_and_catches_late_output(self): + clock = Clock() + + class Evidence: + run = bytes(range(8)) + condition = Condition(clock) + report = {'trials': []} + + def event(self, *args, **kwargs): + pass + + def records(self, source, sequence): + wire = make_probe(self.run, source, sequence) + records = [] + if clock.now >= .3: + records.append({'observer': 'observe_inputs', 'wire_hex': wire.hex()}) + if clock.now >= 5: + records.append({'observer': 'observe_usa', 'wire_hex': wire.hex()}) + return records + + def save(self): + pass + + class Feeder: + def request(self, command, expected): + self.command = command + return b'\0' + + campaign = runner.Campaign({'probe_pause': 1, 'probe_timeout': 6}, Evidence()) + campaign.nodes = {'feed_left': Feeder()} + campaign.center_key = bytes(range(32)) + with patch.object(runner.time, 'monotonic', clock.monotonic), \ + patch.object(runner.time, 'sleep', clock.sleep): + result = campaign.probe('L', expected_forward=False) + self.assertGreaterEqual(result['elapsed_seconds'], 6) + self.assertFalse(result['passed']) + self.assertEqual(result['wrong_channel_observations'], 1) + + def test_positive_trial_does_not_pass_late_delivery_during_drain(self): + clock = Clock() + center_key = bytes(range(32)) + + class Evidence: + run = bytes(range(8)) + condition = Condition(clock) + report = {'trials': []} + + def event(self, *args, **kwargs): + pass + + def records(self, source, sequence): + original = make_probe(self.run, source, sequence) + forward = original[:1]+b'\x01'+center_key[:1]+original[2:] + records = [{'observer': 'observe_inputs', 'wire_hex': original.hex(), 'monotonic': .2}] + if clock.now >= 6.5: + records.append({'observer': 'observe_media', 'wire_hex': forward.hex(), 'monotonic': 6.5}) + return records + + def save(self): + pass + + campaign = runner.Campaign({'probe_pause': 1, 'probe_timeout': 6}, Evidence()) + campaign.nodes = {'feed_left': Mock()} + campaign.center_key = center_key + with patch.object(runner.time, 'monotonic', clock.monotonic), \ + patch.object(runner.time, 'sleep', clock.sleep): + result = campaign.probe('L') + self.assertFalse(result['passed']) + self.assertEqual(result['delivery_result'], 'late_output') + self.assertEqual(result['output_notification_seconds'], 6.5) + self.assertEqual(result['input_to_output_notification_seconds'], 6.3) + self.assertEqual(result['correct_center_outputs'], 1) + self.assertEqual(len(result['observations']), 2) + + def test_optional_hop_diagnostics_keep_per_hop_counters_and_collection_interval(self): + clock = Clock() + + class Evidence: + report = {} + events = [] + + def event(self, kind, **fields): + self.events.append({'kind': kind, **fields}) + + class Node: + commands = None + + def __init__(self): + self.commands = [] + + def packet_stats(self): + clock.sleep(.1) + return {'recv': 7, 'sent': 3} + + def cli(self, command): + self.commands.append(command) + clock.sleep(.1) + if command == 'stats-radio-diag': + return ('state=0(IDLE), recv=7, sent=3, errors=0, err_flags=0, ' + 'outbound=no, last_rx=0s ago') + return command+' counters=1' + + campaign = runner.Campaign({'per_probe_diagnostics': True}, Evidence()) + campaign.nodes = {'left': Node(), 'right': Node(), 'center': Node()} + with patch.object(runner.time, 'monotonic', clock.monotonic): + result = campaign.hop_diagnostics('L', 3, 'before') + self.assertEqual(set(result['nodes']), {'left', 'center'}) + self.assertEqual(result['nodes']['left']['packets'], {'recv': 7, 'sent': 3}) + self.assertIn('espnow_rx', result['nodes']['center']) + self.assertIn('espnow_tx', result['nodes']['left']) + self.assertIn('espnow_drops', result['nodes']['left']) + self.assertIn('cad', result['nodes']['center']) + self.assertGreater(result['collection_seconds'], 1) + self.assertEqual(campaign.nodes['right'].commands, []) + self.assertEqual(len(campaign.evidence.events), 1) + campaign.config['per_probe_diagnostics'] = False + self.assertIsNone(campaign.hop_diagnostics('L', 4, 'before')) + self.assertEqual(len(campaign.evidence.events), 1) + + def test_fixture_only_open_never_touches_a_dut(self): + class Evidence: + report = {'nodes': {}} + + def save(self): + pass + + class Companion: + def __init__(self, label, spec, evidence): + self.label = label + + def start(self): + return {'model': 'test fixture'} + + def cli(self, command): + return 'test firmware' + + def close(self): + pass + + config = {'nodes': {label: {'board_contains': 'test fixture'} for label in runner.FIXTURES}} + campaign = runner.Campaign(config, Evidence()) + with patch.object(runner, 'CaptureCompanion', Companion), \ + patch.object(runner, 'Infrastructure', side_effect=AssertionError('DUT opened')): + campaign.open(companions_only=True) + self.assertEqual(set(campaign.nodes), set(runner.FIXTURES)) + campaign.close() + + def test_route_override_requires_the_selected_rf_witness(self): + clock = Clock() + center_key = bytes(range(32)) + + class Evidence: + run = bytes(range(8)) + condition = Condition(clock) + report = {'trials': []} + + def event(self, *args, **kwargs): + pass + + def records(self, source, sequence): + original = make_probe(self.run, source, sequence) + forwarded = original[:1]+b'\x01'+center_key[:1]+original[2:] + return [{'observer': 'observe_inputs', 'wire_hex': original.hex()}, + {'observer': 'observe_media', 'wire_hex': forwarded.hex()}] + + def save(self): + pass + + class Feeder: + def request(self, command, expected): + return b'\0' + + campaign = runner.Campaign({'probe_pause': 1, 'probe_timeout': 6}, Evidence(), right_only=True) + campaign.nodes = {'feed_right': Feeder()} + campaign.center_key = center_key + with patch.object(runner.time, 'monotonic', clock.monotonic), \ + patch.object(runner.time, 'sleep', clock.sleep): + result = campaign.probe('R', expected_output='observe_media') + self.assertTrue(result['passed']) + self.assertEqual(result['correct_center_outputs'], 1) + self.assertEqual(result['wrong_channel_observations'], 0) + + def test_center_rf_challenge_requires_independent_witness_and_restores_input(self): + clock = Clock() + + class Evidence: + run = bytes(range(8)) + report = {'trials': []} + + def event(self, *args, **kwargs): + pass + + def records(self, source, sequence): + wire = make_probe(self.run, source, sequence) + return [{'observer': 'observe_media' if source == 'L' else 'observe_usa', + 'wire_hex': wire.hex()}] + + def save(self): + pass + + class Center: + def packet_stats(self): + return {'recv': 9} + + class Feeder: + def request(self, command, expected): + return b'\0' + + campaign = runner.Campaign({'probe_pause': 1, 'probe_timeout': 6}, Evidence(), right_only=True) + campaign.nodes = {'center': Center(), 'feed_left': Feeder(), 'feed_right': Feeder()} + campaign.center_key = bytes(range(32)) + with patch.object(runner.time, 'monotonic', clock.monotonic), \ + patch.object(runner.time, 'sleep', clock.sleep), \ + patch.object(campaign, 'set_fixture_rf') as tune, patch.object(campaign, 'probe') as control: + campaign.center_rx_challenges() + self.assertEqual(tune.call_args_list[-2].args, ('feed_left', runner.RF['input_media'])) + self.assertEqual(tune.call_args_list[-1].args, ('feed_right', runner.RF['input_usa'])) + control.assert_called_once_with('R', phase='restored_input_rf_control') + self.assertTrue(all(trial['passed'] for trial in campaign.evidence.report['trials'])) + self.assertEqual(len(campaign.evidence.report['trials']), 2) + + def test_cad_busy_uses_physical_order_not_usb_order_and_restores_feeder(self): + for forward_time, blocker_notification, forward_notification, expected in ( + (18.0, 25.0, 18.5, 'passed'), + (4.0, 17.0, 25.0, 'failed'), + (17.0, 17.0, 17.5, 'inconclusive')): + with self.subTest(forward_time=forward_time, expected=expected): + clock = Clock() + center_key = bytes(range(32)) + + class Evidence: + run = bytes(range(8)) + report = {'trials': []} + + def event(self, *args, **kwargs): + pass + + def records(self, source, sequence): + wire = make_probe(self.run, source, sequence, direct=source == 'L') + if source == 'L': + return [{'observer': 'observe_usa', 'wire_hex': wire.hex(), + 'monotonic': blocker_notification}] + forward = wire[:1]+b'\x01'+center_key[:1]+wire[2:] + return [{'observer': 'observe_inputs', 'wire_hex': wire.hex(), 'monotonic': 2.0}, + {'observer': 'observe_usa', 'wire_hex': forward.hex(), + 'monotonic': forward_notification}] + + def save(self): + pass + + class Center: + cad_requests = 0 + + def packet_stats(self): + return {'recv': 0} + + def cli(self, command, timeout=15): + if command == 'stats-radio-diag': + transmitting = forward_time <= clock.now < forward_time+.5 + sent = int(clock.now >= forward_time+.5) + return (f'state={3 if transmitting else 0}(test), recv=0, sent={sent}, ' + 'errors=0, err_flags=0, outbound=no, last_rx=0s ago') + if command != 'get cad': + raise AssertionError(command) + value = self.cad_requests + self.cad_requests += 1 + return f'on (required by radio.mode tx). # channel busy: {value}' + + class Feeder: + preamble = 96 + started = False + + def request(self, command, expected): + self.started = True + return b'\0' + + def cli(self, command): + if command == 'stats-radio-diag': + transmitting = self.started and clock.now < 17 + sent = int(self.started and clock.now >= 17) + return (f'state={3 if transmitting else 0}(test), recv=0, sent={sent}, ' + 'errors=0, err_flags=0, outbound=no, last_rx=0s ago') + # Simulate the older Full fixture's command surface. + if command == 'get radio.preamble': + return 'Unknown command' + self_command = 'get radio2.status' + if command != self_command: + raise AssertionError(command) + return f'off; preamble={self.preamble},32 RX=0,0 TX=0,0' + + campaign = runner.Campaign({'probe_pause': 1}, Evidence()) + campaign.center_key = center_key + campaign.nodes = {'center': Center(), 'feed_left': Feeder(), 'feed_right': Feeder()} + + def tune(label, rf, preamble=96): + campaign.nodes[label].preamble = preamble + + with patch.object(runner.time, 'monotonic', clock.monotonic), \ + patch.object(runner.time, 'sleep', clock.sleep), \ + patch.object(campaign, 'set_fixture_rf', side_effect=tune) as restore, \ + patch.object(campaign, 'probe'): + campaign.cad_busy_challenge() + result = campaign.evidence.report['hardware_cad_busy_test'] + self.assertEqual(result['status'], expected) + self.assertEqual(result['physical_timing']['transmit_order_proven'], expected == 'passed') + self.assertEqual(result['physical_timing']['simultaneous_tx_proven'], expected == 'failed') + self.assertFalse(result['usb_witness_order_is_rf_timing_proof']) + self.assertEqual(campaign.nodes['feed_left'].preamble, 96) + self.assertEqual(restore.call_args.args, ('feed_left', runner.RF['input_media'])) + + +if __name__ == '__main__': + unittest.main() diff --git a/tools/hil/test_split_radio_probe.py b/tools/hil/test_split_radio_probe.py new file mode 100644 index 000000000..e879baa0f --- /dev/null +++ b/tools/hil/test_split_radio_probe.py @@ -0,0 +1,73 @@ +import struct +import unittest + +from split_radio_probe import (companion_send_command, counter_delta, inspect_push, + kiss_frame, make_probe, packet_stats, parse_probe, + profile_counters) + + +class SplitRadioProbeTests(unittest.TestCase): + nonce = bytes.fromhex("1020304050607080") + center = bytes(range(32)) + + def test_original_and_center_path_remain_separate_from_duplicate_hash(self): + for source in ("L", "R"): + original = make_probe(self.nonce, source, 123) + first = parse_probe(original, expected_run=self.nonce) + self.assertTrue(first.original) + self.assertFalse(first.forwarded_by(self.center)) + for width in (1, 2, 3): + forwarded = original[:1] + bytes((((width-1)<<6)|1,)) + \ + self.center[:width] + original[2:] + final = parse_probe(forwarded, expected_run=self.nonce) + self.assertTrue(final.forwarded_by(self.center)) + self.assertEqual(first.mesh_hash, final.mesh_hash) + self.assertEqual(first.key, final.key) + self.assertNotEqual(first.record()["wire_hex"], final.record()["wire_hex"]) + wrong = original[:1]+b"\x01\xff"+original[2:] + self.assertFalse(parse_probe(wrong).forwarded_by(self.center)) + self.assertNotEqual(parse_probe(make_probe(self.nonce, "L", 123)).key, + parse_probe(make_probe(self.nonce, "R", 123)).key) + + def test_pushes_do_not_invent_missing_radio_profile_or_path(self): + wire = make_probe(self.nonce, "L", 3) + full = inspect_push(b"\x88\x18\x9c"+wire, expected_run=self.nonce, + configured_profile=1, frequency_mhz=906.15, observer="R2") + self.assertEqual((full["snr_db"], full["rssi_dbm"]), (6.0, -100)) + self.assertIsNone(full["radio_profile"]) + self.assertEqual(full["configured_profile"], 1) + self.assertEqual(full["path_hex"], "") + for tag, profile in ((0xA0, 0), (0xA1, 1), (0xFF, None)): + payload = inspect_push(bytes((0x84, 0, 0x9c, tag))+wire[2:]) + self.assertEqual(payload["radio_profile"], profile) + self.assertIsNone(payload["wire_hex"]) + self.assertIsNone(payload["path_hex"]) + + def test_controls_counters_wire_limits_and_rejections(self): + wire = make_probe(self.nonce, "R", 7) + self.assertEqual(companion_send_command(wire), b"\x41\0"+wire) + direct = parse_probe(make_probe(self.nonce, "R", 7, direct=True)) + self.assertEqual(direct.route, 2) + self.assertFalse(direct.original) + transported = b"\x3c\x01\x02\x03\x04"+wire[1:] + self.assertEqual(parse_probe(transported).key, parse_probe(wire).key) + self.assertEqual(packet_stats(bytes((24, 2))+struct.pack("<7I", *range(7)))["recv_errors"], 6) + self.assertEqual(profile_counters("> rx; RX=0,12 TX=1,0 switches=5"), + {"rx": [0, 12], "tx": [1, 0]}) + self.assertEqual(counter_delta({"tx": 0xffffffff}, {"tx": 0}), {"tx": 1}) + self.assertEqual(kiss_frame(0x10, b"\xc0\xdb"), b"\xc0\x10\xdb\xdc\xdb\xdd\xc0") + for malformed in (b"", wire[:1], b"\xfd"+wire[1:], wire[:1]+b"\xc1"+wire[2:], + wire[:1]+b"\x7f"+wire[2:], wire[:-39]): + self.assertIsNone(parse_probe(malformed)) + self.assertIsNone(parse_probe(wire, expected_run=bytes(8))) + self.assertIsNone(parse_probe(make_probe(self.nonce, "L", 1, payload_size=184)+b"x")) + with self.assertRaises(ValueError): + profile_counters("RX=1,2 TX=1,2 RX=1,2 TX=1,2") + with self.assertRaises(ValueError): + packet_stats(b"\x18\2") + with self.assertRaises(ValueError): + make_probe(self.nonce, "X", 0) + + +if __name__ == "__main__": + unittest.main() From 27d9aaf7de1beb3ea1afbbe068fc1d49542ce24a Mon Sep 17 00:00:00 2001 From: mikecarper Date: Wed, 28 Oct 2026 22:38:07 -0700 Subject: [PATCH 57/61] Use the real CLI error helper in ESP-NOW peer handling --- src/helpers/CommonCLI.cpp | 4 ++-- test/fixtures/cli_reply_text.json | 2 +- test/test_espnow_peer.py | 10 +--------- 3 files changed, 4 insertions(+), 12 deletions(-) diff --git a/src/helpers/CommonCLI.cpp b/src/helpers/CommonCLI.cpp index 0fafd6ffb..1fad91104 100644 --- a/src/helpers/CommonCLI.cpp +++ b/src/helpers/CommonCLI.cpp @@ -3466,8 +3466,8 @@ bool CommonCLI::handleEspNowPeerCommand(const char* command, char* reply) { !memcmp(previous, destination, sizeof(previous)); if (!mesh::bridge::saveEspNowPeer(destination)) { if (!mesh::bridge::espNowPeerSettingsHealthy()) _callbacks->setEspNowBridgeState(false); - mesh::cli::formatError(reply, capacity, "ESP-NOW peer not saved%s", - mesh::bridge::espNowPeerSettingsHealthy() ? "" : "; bridge blocked until reload"); + mesh::cli::formatError(reply, capacity, mesh::bridge::espNowPeerSettingsHealthy() + ? "ESP-NOW peer not saved" : "ESP-NOW peer not saved; bridge blocked until reload"); return true; } const bool enabled = diff --git a/test/fixtures/cli_reply_text.json b/test/fixtures/cli_reply_text.json index 3435c629a..5e2e8ca2c 100644 --- a/test/fixtures/cli_reply_text.json +++ b/test/fixtures/cli_reply_text.json @@ -1406,7 +1406,7 @@ } ], "noncompact_tokens_sha256": { - "src/helpers/CommonCLI.cpp": "0618237d2b12b67b31b930242acefe52a63c9d37c59c7fdfe75f1a8ccbec0741", + "src/helpers/CommonCLI.cpp": "fd13053f606a8d008c10a690cbc7729c2701f44f84e433bb3cd2c776b9e61b0c", "examples/simple_repeater/MyMesh.cpp": "8567eeef2f327100e397c40b0aaf5b675fcdfea254987437d1ca8d005ad3a6f0", "src/helpers/RadioProfileCLI.cpp": "d641d16e872eba3e73b9b5a50f9718c10b0b7da72782f793706ac36b3724a624", "src/helpers/WirelessControl.h": "f19bb602999118cc423ec74c7de7100ed91cb2c553295038077e52e321bb4e73", diff --git a/test/test_espnow_peer.py b/test/test_espnow_peer.py index e5b2b0612..0c2454d8e 100644 --- a/test/test_espnow_peer.py +++ b/test/test_espnow_peer.py @@ -12,20 +12,12 @@ ROOT = Path(__file__).resolve().parents[1] STORAGE_CHECKS = r''' #include -#include #include #include #include +#include #include "ESPNowPeerSettings.h" #include "settings_under_test.cpp" - -namespace mesh {namespace cli { -void formatError(char* out,size_t capacity,const char* format,...) { - snprintf(out,capacity,"Error: "); - const size_t used=strlen(out);va_list args;va_start(args,format); - vsnprintf(out+used,capacity-used,format,args);va_end(args); -} -}} struct NodePrefs {bool bridge_enabled=true,espnow_bridge_enabled=true;}; struct Callbacks { unsigned restarts=0,stops=0;bool restart_ok=true; From f38083943353c33b07c9fd52f362dbf6cd59e2e2 Mon Sep 17 00:00:00 2001 From: mikecarper Date: Wed, 28 Oct 2026 23:45:09 -0700 Subject: [PATCH 58/61] Support opt-in case-insensitive flood text patterns --- docs/cli_commands.md | 11 ++++++---- docs/filter_tool.md | 8 ++++++++ docs/flood_filtering.md | 11 +++++++--- src/helpers/FloodTextPattern.h | 19 +++++++++++++++-- test/test_flood_rule_cli.py | 18 +++++++++++++++- test/test_flood_rule_extensions.py | 31 +++++++++++++++++++++++++++- test/test_flood_text_pattern.py | 33 +++++++++++++++++++++++++++--- 7 files changed, 117 insertions(+), 14 deletions(-) diff --git a/docs/cli_commands.md b/docs/cli_commands.md index 541e2939f..20b63f253 100644 --- a/docs/cli_commands.md +++ b/docs/cli_commands.md @@ -4683,15 +4683,17 @@ compile this table. recent; both require exactly the chain. Commas may replace `>`. Each ID must use the packet's complete hash width. Direct paths and TRACE SNR bytes never match. `path=blacklist` remains the separate unordered table. -- `sender=` (`n=`), `text=` (`x=`): Case-sensitive patterns - on the decrypted sender name and message body. Require an explicit +- `sender=` (`n=`), `text=` (`x=`): Patterns on the decrypted + sender name and message body. Matching is case-sensitive by default; a + leading `(?i)` ignores ASCII letter case in literals and character classes. + Non-ASCII bytes remain unchanged. Require an explicit authenticated channel (`public`, `#name`, or key); `channel=*` and raw hashes are insufficient. These match only plain group text. Patterns search anywhere unless anchored with `^`/`$`, and support `.`, classes such as `[A-Z]` or `[^0-9]`, `*`, `+`, `?`, groups, `|`, and escaped literals. Use `[0-9]` for digits and quote values containing spaces. The maximum is - 63 bytes per pattern. Matching uses complete fields, removes AES zero - padding, and shares a 131,072-operation work budget across one rule + 63 bytes per pattern, including `(?i)`. Matching uses complete fields, removes + AES zero padding, and shares a 131,072-operation work budget across one rule evaluation. Exhaustion rejects the packet; it cannot grant an allow, rewrite, or retry action. A message without `:` has no sender field but can match a text rule. @@ -4954,6 +4956,7 @@ Content, exceptions, and state changes use that same setter: ```text set fr grp_txt c=#local x="^BEACON" d +set fr grp_txt c=public n="(?i)^(Alice|Bob)$" d set fr grp_txt c=#local n="^Alice$" pri=200 a set fr grp_txt c=#local d set fr.4 e=off diff --git a/docs/filter_tool.md b/docs/filter_tool.md index 16808ecd6..1d9db1825 100644 --- a/docs/filter_tool.md +++ b/docs/filter_tool.md @@ -266,6 +266,14 @@ Four-byte paths remain unsupported.
+

+ The preview's sender condition matches an exact name, ignoring case. + Firmware n= and x= support patterns with + | alternatives and leading (?i) for ASCII + case-insensitive matching. See + content patterns. +

+

Common actions

Choosing a phase-specific action automatically selects a compatible diff --git a/docs/flood_filtering.md b/docs/flood_filtering.md index e862f9ce6..44fb4c543 100644 --- a/docs/flood_filtering.md +++ b/docs/flood_filtering.md @@ -595,11 +595,13 @@ Actions: ### Content patterns -Sender/text patterns are case-sensitive byte matches. They search anywhere +Sender/text patterns are case-sensitive byte matches by default. Add `(?i)` +at the start for ASCII case-insensitive matching. They search anywhere unless anchored with `^` and `$`. Supported forms include `.`, `[A-Z]`, `[^0-9]`, `*`, `+`, `?`, grouping, and `|` alternatives. Escape punctuation with `\` to match it literally; use `[0-9]` for digits. Each pattern is at -most 63 bytes. Quote values containing spaces. +most 63 bytes, including `(?i)`. Quote values containing spaces. Case folding +applies to literals and character classes; non-ASCII bytes remain unchanged. For `Alice: BEACON 123`, `n=^Alice$` matches the display name and `x=^BEACON` matches the body. They can be combined in one row. The parser @@ -615,6 +617,9 @@ short and specific. # Block BEACON messages on this channel. set fr grp_txt c=#local x="^BEACON" d +# Block either complete sender name, ignoring ASCII letter case. +set fr grp_txt c=public n="(?i)^(Alice|Bob)$" d + # Exempt Alice from lower-order general rules, then block everyone else. set fr grp_txt c=#local n="^Alice$" pri=200 a set fr grp_txt c=#local d @@ -778,7 +783,7 @@ set fr.3 any c=#wardriving m=bc d | `channel=...` | `c=...` | Same authenticated name/key or explicit `hash:XX` matcher. | | `prefix=...` | `p=...` | Ordered path prefix. | | `path=[^]ID>ID...[$]` | `pa=...` | Ordered chain anywhere, or anchored at either end. | -| `sender=...`, `text=...` | `n=...`, `x=...` | Case-sensitive patterns on an authenticated group-text channel. | +| `sender=...`, `text=...` | `n=...`, `x=...` | Patterns on an authenticated group-text channel; leading `(?i)` ignores ASCII letter case. | | `len=N`, `N+`, or `N-M` | `l=...` | Payload-byte range. | | `route=flood`, `direct`, `any` | `rt=f`, `rt=d`, `rt=a` | Route class. | | `prob=N`, `enabled=on/off` | `pr=N`, `e=1/0` | Sample matches or retain a disabled row. | diff --git a/src/helpers/FloodTextPattern.h b/src/helpers/FloodTextPattern.h index 5b432895e..679d2f889 100644 --- a/src/helpers/FloodTextPattern.h +++ b/src/helpers/FloodTextPattern.h @@ -9,8 +9,8 @@ namespace flood { // Byte patterns: substring search by default, ^/$ anchors, ., escaped literals, // classes/ranges/[^...], groups, alternation, and *, +, ? repetition. Matching is -// case-sensitive unless ASCII case folding is explicitly requested. No heap or -// backtracking is used; one budget can be shared across every rule for a packet. +// case-sensitive unless a leading (?i) or the caller requests ASCII folding. +// No heap or backtracking; one budget can cover every rule for a packet. static constexpr size_t MAX_TEXT_PATTERN_LENGTH = 63; static constexpr uint32_t TEXT_PATTERN_WORK_BUDGET = 32768; @@ -236,6 +236,18 @@ inline uint8_t fold(uint8_t byte) { return byte >= 'A' && byte <= 'Z' ? uint8_t(byte + ('a' - 'A')) : byte; } +// The modifier stays in the saved pattern, so no rule-layout change is needed. +// Count it toward the existing limit before removing it for the compiler. +inline bool preparePattern(const char*& pattern, size_t& length, bool& ignoreCase) { + if (!pattern || length > MAX_TEXT_PATTERN_LENGTH) return false; + if (length >= 4 && memcmp(pattern, "(?i)", 4) == 0) { + pattern += 4; + length -= 4; + ignoreCase = true; + } + return true; +} + inline uint8_t classChar(const char* pattern, uint8_t& cursor) { uint8_t byte = uint8_t(pattern[cursor++]); if (byte == '\\') byte = uint8_t(pattern[cursor++]); @@ -272,6 +284,8 @@ inline int classMatches(const char* pattern, uint8_t cursor, uint8_t byte, bool } // namespace pattern_detail inline bool textPatternValid(const char* pattern, size_t patternLength) { + bool ignoreCase = false; + if (!pattern_detail::preparePattern(pattern, patternLength, ignoreCase)) return false; pattern_detail::State states[pattern_detail::MAX_STATES]; uint8_t start; return pattern_detail::Compiler(pattern, patternLength, states).compile(start); @@ -282,6 +296,7 @@ inline PatternMatchResult matchTextPattern(const char* pattern, size_t patternLe bool ignoreCase = false, uint32_t* remainingWork = nullptr) { using namespace pattern_detail; if (!text && textLength) return PatternMatchResult::Invalid; + if (!preparePattern(pattern, patternLength, ignoreCase)) return PatternMatchResult::Invalid; State states[MAX_STATES]; uint8_t start; if (!Compiler(pattern, patternLength, states).compile(start)) return PatternMatchResult::Invalid; diff --git a/test/test_flood_rule_cli.py b/test/test_flood_rule_cli.py index 4f7c14875..ba1853dfd 100644 --- a/test/test_flood_rule_cli.py +++ b/test/test_flood_rule_cli.py @@ -162,7 +162,23 @@ static void budgetExhaustion(){ // than accidentally permitting a packet after a partial rule evaluation. assert(mesh.evaluateFloodPacketFilterMatches(&packet,true,nullptr,0)==(uint64_t(1)<<63)); } -int main(){parserAndRollback();allowAndDirect();budgetExhaustion();} +static void caseInsensitiveSenders(){ + MyMesh mesh; + set(mesh,R"(.1 grp_txt c=public n="(?i)^(Jeffery Epstein|Ghislaine Maxwell)$" d)"); + assert(mesh.rows[0].drop_on_match); + mesh::Packet packet;packet.header=ROUTE_TYPE_FLOOD|(PAYLOAD_TYPE_GRP_TXT<(sample), strlen(sample)) == mesh::flood::PatternMatchResult::Match); } int main() { parsing(); envelopesAndProbability(); paths(); content(); storage(); } ''' diff --git a/test/test_flood_text_pattern.py b/test/test_flood_text_pattern.py index 8ba704634..70ed7c0dd 100644 --- a/test/test_flood_text_pattern.py +++ b/test/test_flood_text_pattern.py @@ -44,16 +44,32 @@ static void checks() { assert(match("^Alice$", "Alice") == R::Match); assert(match("^Alice$", "alice") == R::NoMatch); assert(match("^Alice$", "alice", true) == R::Match); + assert(match("(?i)^Alice$", "aLiCe") == R::Match); + assert(match("(?i)^Alice$", "aLiCe", true) == R::Match); + assert(match("(?i)", "") == R::Match); + assert(match("(?i)^(Jeffery Epstein|Ghislaine Maxwell)$", "jEfFeRy ePsTeIn") == R::Match); + assert(match("(?i)^(Jeffery Epstein|Ghislaine Maxwell)$", "GHISLAINE MAXWELL") == R::Match); + assert(match("(?i)^(Jeffery Epstein|Ghislaine Maxwell)$", "Jeffery Epstein Jr") == R::NoMatch); + assert(match("(?i)^(Jeffery Epstein|Ghislaine Maxwell)$", "XGhislaine Maxwell") == R::NoMatch); + assert(match("(?i)^(Jeffery Epstein|Ghislaine Maxwell)$", "someone else") == R::NoMatch); + assert(match("^(Jeffery Epstein|Ghislaine Maxwell)$", "GHISLAINE MAXWELL") == R::NoMatch); assert(match("^Alice$", "Alice2") == R::NoMatch); assert(match("^Alice$", "2Alice") == R::NoMatch); assert(match("Alice$", "Alice\n") == R::NoMatch); assert(match("A.*e", "A\ne") == R::Match); assert(match("^a\\.b\\*\\[\\]\\(\\)\\|\\+\\?\\^\\$\\\\$", "a.b*[]()|+?^$\\") == R::Match); assert(match("^[A-F0-9]+$", "aBcF09", true) == R::Match); + assert(match("(?i)^[A-F0-9]+$", "aBcF09") == R::Match); + assert(match("(?i)^[A-F0-9]+$", "g") == R::NoMatch); assert(match("^[A-F0-9]+$", "aBcF09") == R::NoMatch); assert(match("^[^A-F]+$", "xyz", true) == R::Match); assert(match("^[^A-F]+$", "xyzA", true) == R::NoMatch); + assert(match("(?i)^[^A-F]+$", "xyz") == R::Match); + assert(match("(?i)^[^A-F]+$", "xyza") == R::NoMatch); assert(match("^[A-z]+$", "[_Zaz", true) == R::Match); + assert(match("(?i)^[A-z]+$", "[_Zaz") == R::Match); + assert(match("(?i)^\xC0$", "\xE0") == R::NoMatch); // ASCII folding only + assert(match("(?i)^\xC0$", "\xC0") == R::Match); assert(match("^[\\]\\-\\[]+$", "]-[") == R::Match); assert(match("^[a-]+$", "a--") == R::Match); assert(match("^[-a]+$", "a--") == R::Match); @@ -66,7 +82,8 @@ static void checks() { assert(match("^(()*)*$", "") == R::Match); // epsilon cycles terminate assert(match("^(a|)*$", "aaaa") == R::Match); for (const char* invalid : {"[", "[]", "[^]", "[z-a]", "[a-\\]]", "\\", "(", ")", "a)", - "a**", "a+?", "a??", "*a", "^*", "$+", "a]"}) { + "a**", "a+?", "a??", "*a", "^*", "$+", "a]", + "a(?i)b", "^(?i)Alice$", "(?i)(?i)Alice", "(?I)Alice", "(?i:Alice)"}) { assert(!textPatternValid(invalid, strlen(invalid))); assert(match(invalid, "anything") == R::Invalid); } @@ -81,6 +98,11 @@ static void checks() { const std::string maximum(MAX_TEXT_PATTERN_LENGTH, 'a'); assert(textPatternValid(maximum.data(), maximum.size())); assert(!textPatternValid(maximum.data(), maximum.size() + 1)); + const std::string foldedMaximum = "(?i)" + std::string(MAX_TEXT_PATTERN_LENGTH - 4, 'a'); + assert(textPatternValid(foldedMaximum.data(), foldedMaximum.size())); + assert(match(foldedMaximum.c_str(), std::string(MAX_TEXT_PATTERN_LENGTH - 4, 'A').c_str()) == R::Match); + const std::string foldedOversized = foldedMaximum + "a"; + assert(!textPatternValid(foldedOversized.data(), foldedOversized.size())); assert(!textPatternValid("(((((((((a)))))))))", 19)); assert(textPatternValid("((((((((a))))))))", 17)); const std::string tooManyStates(MAX_TEXT_PATTERN_LENGTH, '|'); @@ -146,7 +168,8 @@ extern "C" __attribute__((noinline)) mesh::flood::PatternMatchResult packet_matc class FloodTextPatternTests(unittest.TestCase): def test_packet_patterns_bounds_and_reference_agreement(self): rng = random.Random(436) - patterns = ["", "a", "^a$", "a|b", "^(ab|a)*b$", "a?b+c*", "[ab]+", "[^a]*b$", "[A-z]+"] + patterns = ["", "a", "^a$", "a|b", "^(ab|a)*b$", "a?b+c*", "[ab]+", "[^a]*b$", "[A-z]+", + "(?i)", "(?i)^a$", "(?i)^(ab|a)*b$", "(?i)^[A-C]+$", "(?i)^[^A-C]+$", "(?i)[A-z]+"] atoms = ["a", "b", "c", ".", "[ab]", "[^c]", "[a-c]", "[0-2]", r"\.", "(ab|c)", "(a|)"] for _ in range(300): pattern = "^" if rng.randrange(2) else "" @@ -154,13 +177,15 @@ class FloodTextPatternTests(unittest.TestCase): pattern += rng.choice(atoms) + rng.choice(["", "", "?", "*", "+"]) if rng.randrange(2): pattern += "$" + if rng.randrange(2): + pattern = "(?i)" + pattern patterns.append(pattern) cases = [] for pattern in patterns: for _ in range(15): text = "".join(rng.choice("abcABC012.") for _ in range(rng.randrange(21))) fold = bool(rng.randrange(2)) - expected = bool(re.search(pattern, text, re.DOTALL | (re.IGNORECASE | re.ASCII if fold else 0))) + expected = bool(re.search(pattern, text, re.DOTALL | re.ASCII | (re.IGNORECASE if fold else 0))) cases.append((pattern, text, fold, expected)) encoded = "".join(f"{pattern.encode().hex() or '-'} {text.encode().hex() or '-'} {int(fold)}\n" for pattern, text, fold, _ in cases) @@ -168,6 +193,8 @@ class FloodTextPatternTests(unittest.TestCase): # list bounds without assuming Python supports exactly our byte syntax. fuzz = ["".join(rng.choice("abc^$.*+?()[\\]|-") for _ in range(rng.randrange(1, 64))) for _ in range(4000)] + fuzz += ["(?i)" + pattern for pattern in fuzz[:250]] + fuzz += ["(", "(?", "(?i", "(?i)"] encoded += "".join(f"{pattern.encode().hex()} 616263 0\n" for pattern in fuzz) with tempfile.TemporaryDirectory() as folder: folder = Path(folder) From 92626fe2392dce79e852d539dd63699f01247682 Mon Sep 17 00:00:00 2001 From: mikecarper Date: Thu, 29 Oct 2026 00:41:23 -0700 Subject: [PATCH 59/61] Keep dedicated ESP-NOW awake across delayed WiFi startup --- docs/espnow_bridge_setup.md | 5 +- src/helpers/bridges/ESPNowBridge.cpp | 76 +++++++++++++------ src/helpers/bridges/ESPNowBridge.h | 2 + test/fixtures/espnow_wifi_handoff.cpp | 54 +++++++++++++- test/fixtures/wifi_ap_shared_lr_sdk.h | 5 +- test/test_espnow_peer.py | 102 ++++++++++++++++++++++++++ test/test_espnow_radio_lifecycle.py | 13 +++- test/test_wifi_ap_lr_safety.py | 3 +- test/test_wifi_ota_lifecycle.py | 6 +- 9 files changed, 232 insertions(+), 34 deletions(-) diff --git a/docs/espnow_bridge_setup.md b/docs/espnow_bridge_setup.md index b85683f54..ede5f5a0f 100644 --- a/docs/espnow_bridge_setup.md +++ b/docs/espnow_bridge_setup.md @@ -100,8 +100,9 @@ compatible with older receivers. A failed unicast send is retried up to three times; MAC success confirms radio reception, not subsequent LoRa forwarding. The setting persists across reboot. `set espnow.peer broadcast` restores the default, which lets multiple bridges receive each packet without acknowledgements. -Dedicated ESP-NOW operation disables WiFi modem sleep; an existing shared WiFi -connection keeps its power policy. +Dedicated ESP-NOW keeps the WiFi receiver awake to avoid missing bridge packets, +using more power than modem sleep. When a WiFi service shares the driver, that +service retains control of its sleep policy. ## What each bridge option does diff --git a/src/helpers/bridges/ESPNowBridge.cpp b/src/helpers/bridges/ESPNowBridge.cpp index 415a0a248..c266473c7 100644 --- a/src/helpers/bridges/ESPNowBridge.cpp +++ b/src/helpers/bridges/ESPNowBridge.cpp @@ -9,7 +9,7 @@ #ifdef WITH_ESPNOW_BRIDGE #include -static void stopBridgeWiFiIfUnused() { +static bool stopBridgeWiFiIfUnused() { if (!(mesh::wireless::control().enabled() & mesh::wireless::WiFi)) { // WebConfig/OTA may have adopted an initially IDF-only bridge driver. // Shut down through Arduino first so its private initialized/started @@ -19,17 +19,32 @@ static void stopBridgeWiFiIfUnused() { // report OFF despite retaining initialized state. Complete that start // before stopping it, without allocating a facade for a pure IDF owner. if (WiFi.getMode() == WIFI_OFF && WiFi.channel() > 0) { - if (!WiFi.mode(WIFI_STA)) return; + if (!WiFi.mode(WIFI_STA)) return false; } - if (!WiFi.mode(WIFI_OFF)) return; + if (!WiFi.mode(WIFI_OFF)) return false; // Arduino OFF is a no-op when the bridge alone initialized the SDK. // Probe the SDK separately rather than trusting the facade's mode/cache. wifi_mode_t mode = WIFI_MODE_NULL; - if (esp_wifi_get_mode(&mode) == ESP_OK) { - esp_wifi_stop(); - esp_wifi_deinit(); - } + const esp_err_t state = esp_wifi_get_mode(&mode); + if (state == ESP_OK) { + if (esp_wifi_stop() != ESP_OK || esp_wifi_deinit() != ESP_OK) return false; + } else if (state != ESP_ERR_WIFI_NOT_INIT) return false; + } + return true; +} + +static void stopBridgeWiFiAndReleaseSleepPreference(bool& owned, uint8_t previous, + bool teardown_uncertain) { + // Keep both driver and cache intact while an old ESP-NOW session or a failed + // WiFi teardown may still be alive. A later stop retries before restoration. + if (teardown_uncertain || !stopBridgeWiFiIfUnused() || !owned) return; + owned = false; + // A new owner may use its own cached preference or a direct SDK policy. + // Never overwrite either while it owns WiFi, or a newer cached preference. + if (!(mesh::wireless::control().enabled() & mesh::wireless::WiFi) + && WiFi.getSleep() == WIFI_PS_NONE) { + (void)WiFi.setSleep(static_cast(previous)); } } @@ -84,9 +99,9 @@ void ESPNowBridge::begin() { BRIDGE_DEBUG_PRINTLN("Initializing...\n"); if (_initialized) return; - if (_sdk_teardown_failed) { + if (_sdk_teardown_failed || _wifi_sleep_preference_owned) { end(); - if (_sdk_teardown_failed) return; + if (_sdk_teardown_failed || _wifi_sleep_preference_owned) return; } if (!mesh::bridge::copyEspNowPeer(_active_peer)) { recordError(Stage::PeerConfig, ESP_ERR_INVALID_STATE); @@ -151,11 +166,18 @@ void ESPNowBridge::begin() { auto failed = [this](esp_err_t result, Stage stage) { if (result == ESP_OK) return false; recordError(stage, result); - stopBridgeWiFiIfUnused(); + stopBridgeWiFiAndReleaseSleepPreference(_wifi_sleep_preference_owned, + _previous_wifi_sleep, _sdk_teardown_failed); emitDebug(); return true; }; if (!shared_wifi) { + _previous_wifi_sleep = static_cast(WiFi.getSleep()); + _wifi_sleep_preference_owned = true; + // Arduino applies this cache asynchronously on STA_START, including after + // raw IDF startup. Prepare it first so a late event cannot undo SDK NONE. + // setSleep returns false when the cache is already correct. + (void)WiFi.setSleep(WIFI_PS_NONE); if (failed(esp_wifi_init(&wifi_config), Stage::WiFiInit) || failed(esp_wifi_set_storage(WIFI_STORAGE_RAM), Stage::WiFiStorage) || failed(esp_wifi_set_mode(WIFI_MODE_STA), Stage::WiFiMode) @@ -175,7 +197,8 @@ void ESPNowBridge::begin() { recordError(Stage::WiFiProtocol, result); emitDebug(); BRIDGE_DEBUG_PRINTLN("Error enabling ESP-NOW LR protocol\n"); - stopBridgeWiFiIfUnused(); + stopBridgeWiFiAndReleaseSleepPreference(_wifi_sleep_preference_owned, + _previous_wifi_sleep, _sdk_teardown_failed); return; } } @@ -187,7 +210,8 @@ void ESPNowBridge::begin() { recordError(Stage::WiFiChannel, channel_result); emitDebug(); BRIDGE_DEBUG_PRINTLN("Error setting WIFI channel to %d\n", _prefs->bridge_channel); - stopBridgeWiFiIfUnused(); + stopBridgeWiFiAndReleaseSleepPreference(_wifi_sleep_preference_owned, + _previous_wifi_sleep, _sdk_teardown_failed); return; } @@ -197,7 +221,8 @@ void ESPNowBridge::begin() { recordError(Stage::EspNowInit, init_result); emitDebug(); BRIDGE_DEBUG_PRINTLN("Error initializing ESP-NOW\n"); - stopBridgeWiFiIfUnused(); + stopBridgeWiFiAndReleaseSleepPreference(_wifi_sleep_preference_owned, + _previous_wifi_sleep, _sdk_teardown_failed); return; } @@ -212,8 +237,9 @@ void ESPNowBridge::begin() { BRIDGE_DEBUG_PRINTLN("Error registering ESP-NOW callbacks\n"); esp_now_register_recv_cb(nullptr); esp_now_register_send_cb(nullptr); - esp_now_deinit(); - stopBridgeWiFiIfUnused(); + _sdk_teardown_failed = esp_now_deinit() != ESP_OK; + stopBridgeWiFiAndReleaseSleepPreference(_wifi_sleep_preference_owned, + _previous_wifi_sleep, _sdk_teardown_failed); return; } @@ -233,8 +259,9 @@ void ESPNowBridge::begin() { BRIDGE_DEBUG_PRINTLN("Failed to add broadcast peer\n"); esp_now_register_recv_cb(nullptr); esp_now_register_send_cb(nullptr); - esp_now_deinit(); - stopBridgeWiFiIfUnused(); + _sdk_teardown_failed = esp_now_deinit() != ESP_OK; + stopBridgeWiFiAndReleaseSleepPreference(_wifi_sleep_preference_owned, + _previous_wifi_sleep, _sdk_teardown_failed); return; } @@ -258,8 +285,9 @@ void ESPNowBridge::begin() { esp_now_del_peer(peerInfo.peer_addr); esp_now_register_recv_cb(nullptr); esp_now_register_send_cb(nullptr); - esp_now_deinit(); - stopBridgeWiFiIfUnused(); + _sdk_teardown_failed = esp_now_deinit() != ESP_OK; + stopBridgeWiFiAndReleaseSleepPreference(_wifi_sleep_preference_owned, + _previous_wifi_sleep, _sdk_teardown_failed); return; } } @@ -273,7 +301,12 @@ void ESPNowBridge::begin() { } void ESPNowBridge::end() { - if (!_initialized && !_sdk_teardown_failed) return; + if (!_initialized && !_sdk_teardown_failed) { + if (_wifi_sleep_preference_owned) + stopBridgeWiFiAndReleaseSleepPreference(_wifi_sleep_preference_owned, + _previous_wifi_sleep, false); + return; + } BRIDGE_DEBUG_PRINTLN("Stopping...\n"); const esp_err_t remove_result = esp_now_del_peer(_active_peer); @@ -297,7 +330,8 @@ void ESPNowBridge::end() { // Infrastructure WiFi may still own this shared radio. mesh::wifi::bridgeEspNowChannel().store(0); mesh::wifi::setLongRangeOwner(mesh::wifi::kLongRangeBridgeOwner, false); - stopBridgeWiFiIfUnused(); + stopBridgeWiFiAndReleaseSleepPreference(_wifi_sleep_preference_owned, + _previous_wifi_sleep, _sdk_teardown_failed); portENTER_CRITICAL(&_rx_mux); _rx_head = 0; diff --git a/src/helpers/bridges/ESPNowBridge.h b/src/helpers/bridges/ESPNowBridge.h index 3d069516c..11c84fb5b 100644 --- a/src/helpers/bridges/ESPNowBridge.h +++ b/src/helpers/bridges/ESPNowBridge.h @@ -125,6 +125,8 @@ private: uint32_t _tx_dropped_reported; uint32_t _tx_started_at = 0, _tx_retry_at = 0; bool _sdk_teardown_failed = false; + uint8_t _previous_wifi_sleep = 0; + bool _wifi_sleep_preference_owned = false; static constexpr uint32_t TX_CALLBACK_TIMEOUT_MS = 1000; static constexpr uint32_t TX_RETRY_DELAY_MS = 20; static constexpr uint8_t TX_MAX_START_RETRIES = 3; diff --git a/test/fixtures/espnow_wifi_handoff.cpp b/test/fixtures/espnow_wifi_handoff.cpp index 714dfebd7..b1f9976ef 100644 --- a/test/fixtures/espnow_wifi_handoff.cpp +++ b/test/fixtures/espnow_wifi_handoff.cpp @@ -13,6 +13,7 @@ #define ESP_IDF_VERSION_VAL(a,b,c) ((a)*10000+(b)*100+(c)) #define BRIDGE_DEBUG_PRINTLN(...) ((void)0) +#define log_e(...) ((void)0) #define portENTER_CRITICAL(x) ((void)0) #define portEXIT_CRITICAL(x) ((void)0) #define ESP_NOW_ETH_ALEN 6 @@ -22,11 +23,12 @@ using wifi_second_chan_t = int; using esp_err_t = int; enum wifi_mode_t { WIFI_OFF=0, WIFI_STA=1, WIFI_AP=2, WIFI_AP_STA=3 }; constexpr wifi_mode_t WIFI_MODE_NULL = WIFI_OFF, WIFI_MODE_STA = WIFI_STA; +constexpr int ESP_ERR_WIFI_NOT_INIT=-91; constexpr int ESP_OK=0, WIFI_STORAGE_RAM=0, WIFI_IF_STA=0, WIFI_SECOND_CHAN_NONE=0; constexpr int WIFI_PROTOCOL_11B=1, WIFI_PROTOCOL_11G=2, WIFI_PROTOCOL_11N=4, WIFI_PROTOCOL_LR=8; constexpr int WIFI_PHY_MODE_LR=1, WIFI_PHY_RATE_LORA_250K=2; enum esp_now_send_status_t { ESP_NOW_SEND_SUCCESS=0, ESP_NOW_SEND_FAIL=1 }; -enum wifi_ps_type_t { WIFI_PS_NONE=0, WIFI_PS_MIN_MODEM=1 }; +enum wifi_ps_type_t { WIFI_PS_NONE=0, WIFI_PS_MIN_MODEM=1, WIFI_PS_MAX_MODEM=2 }; struct esp_now_peer_info_t { uint8_t peer_addr[6]; int channel, ifidx; bool encrypt; }; struct esp_now_rate_config_t { int phymode, rate; bool ersu, dcm; }; @@ -35,7 +37,9 @@ bool sdk_initialized=false, sdk_started=false, espnow_active=false; uint8_t wifi_channel=6; wifi_mode_t sdk_mode=WIFI_OFF; wifi_ps_type_t wifi_ps=WIFI_PS_MIN_MODEM; -unsigned ps_writes=0; +unsigned ps_writes=0,pending_sta_events=0; +bool deliver_sta_during_start=false; +void deliverArduinoStaStart(); int esp_wifi_set_ps(wifi_ps_type_t value) { ++ps_writes; if (!sdk_started || failure==19) return -1; @@ -63,7 +67,7 @@ int esp_wifi_set_mode(wifi_mode_t mode) { return ESP_OK; } int esp_wifi_get_mode(wifi_mode_t* mode) { - if (!sdk_initialized) return -1; + if (!sdk_initialized) return ESP_ERR_WIFI_NOT_INIT; *mode=sdk_mode; return ESP_OK; } @@ -71,6 +75,8 @@ int esp_wifi_start() { if (!sdk_initialized || failure==14) return -1; sdk_started=true; ++wifi_starts; + ++pending_sta_events; + if(deliver_sta_during_start)deliverArduinoStaStart(); return ESP_OK; } int esp_wifi_stop() { @@ -121,8 +127,16 @@ int esp_now_del_peer(const uint8_t* peer) { int esp_now_set_peer_rate_config(const uint8_t*, esp_now_rate_config_t*) { return failure==10 ? -1 : ESP_OK; } int esp_wifi_config_espnow_rate(int, int) { return failure==10 ? -1 : ESP_OK; } +constexpr int WL_DISCONNECTED=6,STA_STARTED_BIT=8,ARDUINO_EVENT_WIFI_STA_START=42; +struct WiFiSTAClass {static void _setStatus(int){}}; +struct arduino_event_t {int event_id;}; struct Facade { bool initialized=false, started=false; + static wifi_ps_type_t _sleepEnabled; + bool setSleep(wifi_ps_type_t sleepType); + wifi_ps_type_t getSleep(); + void setStatusBits(int) {} + void deliverStart(); wifi_mode_t getMode() const { // Pinned Arduino 2.x and 3.x both gate this getter on both caches. if (!initialized || !started) return WIFI_OFF; @@ -165,6 +179,39 @@ struct Facade { } } WiFi; + +// Arduino-ESP32 2.0.17 WiFiGeneric.cpp: actual cached sleep methods and STA_START branch. +// Source SHA256 e37ce5edb3e0ab2f9d6a2879b2c32162c78c7b76249bc0ff114b3283bcaa2e51. +wifi_ps_type_t Facade::_sleepEnabled=WIFI_PS_MIN_MODEM; +bool Facade::setSleep(wifi_ps_type_t sleepType) +{ + if(sleepType != _sleepEnabled){ + _sleepEnabled = sleepType; + if((getMode() & WIFI_MODE_STA) != 0){ + if(esp_wifi_set_ps(_sleepEnabled) != ESP_OK){ + log_e("esp_wifi_set_ps failed!"); + return false; + } + } + return true; + } + return false; +} +wifi_ps_type_t Facade::getSleep() +{ + return _sleepEnabled; +} +void Facade::deliverStart() {arduino_event_t actual{ARDUINO_EVENT_WIFI_STA_START};auto* event=&actual; +if(event->event_id == ARDUINO_EVENT_WIFI_STA_START) { + WiFiSTAClass::_setStatus(WL_DISCONNECTED); + setStatusBits(STA_STARTED_BIT); + if(esp_wifi_set_ps(_sleepEnabled) != ESP_OK){ + log_e("esp_wifi_set_ps failed"); + } + } +} +void deliverArduinoStaStart(){assert(pending_sta_events);--pending_sta_events;WiFi.deliverStart();} + namespace mesh { namespace wifi { constexpr uint8_t kLongRangeBridgeOwner=2; void setLongRangeOwner(uint8_t,bool){} @@ -201,6 +248,7 @@ public: static void send_cb() {} uint32_t _tx_started_at = 0, _tx_retry_at = 0; bool _sdk_teardown_failed = false; + uint8_t _previous_wifi_sleep=0;bool _wifi_sleep_preference_owned=false; void begin(); void end(); }; diff --git a/test/fixtures/wifi_ap_shared_lr_sdk.h b/test/fixtures/wifi_ap_shared_lr_sdk.h index 2e25e4c25..24f4be519 100644 --- a/test/fixtures/wifi_ap_shared_lr_sdk.h +++ b/test/fixtures/wifi_ap_shared_lr_sdk.h @@ -29,7 +29,7 @@ constexpr int WIFI_PROTOCOL_11B=1,WIFI_PROTOCOL_11G=2,WIFI_PROTOCOL_11N=4,WIFI_P constexpr int WIFI_PHY_MODE_LR=1,WIFI_PHY_RATE_LORA_250K=2,WIFI_SCAN_RUNNING=-1; enum wl_status_t {WL_IDLE_STATUS,WL_CONNECTED,WL_NO_SSID_AVAIL,WL_CONNECT_FAILED,WL_DISCONNECTED}; enum esp_now_send_status_t {ESP_NOW_SEND_SUCCESS=0,ESP_NOW_SEND_FAIL=1}; -enum wifi_ps_type_t {WIFI_PS_NONE=0,WIFI_PS_MIN_MODEM=1}; +enum wifi_ps_type_t {WIFI_PS_NONE=0,WIFI_PS_MIN_MODEM=1,WIFI_PS_MAX_MODEM=2}; int esp_wifi_set_ps(wifi_ps_type_t){return ESP_OK;} namespace mesh {namespace bridge { bool copyEspNowPeer(uint8_t* address){memset(address,255,6);return true;} @@ -79,6 +79,9 @@ int esp_wifi_stop(){return ESP_OK;} int esp_wifi_deinit(){driver_initialized=false;return ESP_OK;} struct FakeWiFi { bool facade_started=true,autoreconnect=true; + wifi_ps_type_t cached_sleep=WIFI_PS_MIN_MODEM; + wifi_ps_type_t getSleep(){return cached_sleep;} + bool setSleep(wifi_ps_type_t value){if(value==cached_sleep)return false;cached_sleep=value;return true;} wl_status_t state=WL_DISCONNECTED;int join_channel=0,scan_channel=0; void persistent(bool){} // A late Arduino preference does not switch SDK storage. wifi_mode_t getMode(){return facade_started?sdk_mode:WIFI_OFF;} diff --git a/test/test_espnow_peer.py b/test/test_espnow_peer.py index 0c2454d8e..a38e9804f 100644 --- a/test/test_espnow_peer.py +++ b/test/test_espnow_peer.py @@ -186,6 +186,84 @@ int main(){ ''' +ASYNC_SLEEP_CHECKS = r''' +int main(){ + Backend backend;mesh::wireless::control().begin(backend); + NodePrefs prefs;prefs.bridge_format=mesh::bridge::ESPNOW_FORMAT_WRAPPED; + mesh::PacketManager mgr;mesh::RTCClock rtc; + // Actual Arduino2.0.17 event behavior demonstrates why SDK-only NONE loses. + wifi_init_config_t cfg=WIFI_INIT_CONFIG_DEFAULT(); + assert(esp_wifi_init(&cfg)==ESP_OK&&esp_wifi_set_mode(WIFI_MODE_STA)==ESP_OK&&esp_wifi_start()==ESP_OK); + assert(esp_wifi_set_ps(WIFI_PS_NONE)==ESP_OK&&wifi_ps==WIFI_PS_NONE); + deliverArduinoStaStart();assert(wifi_ps==WIFI_PS_MIN_MODEM); + assert(esp_wifi_stop()==ESP_OK&&esp_wifi_deinit()==ESP_OK); + for(unsigned cycle=0;cycle<3;++cycle){ + ESPNowBridge bridge(&prefs,&mgr,&rtc);bridge.begin(); + assert(bridge._initialized&&wifi_ps==WIFI_PS_NONE); + deliverArduinoStaStart();assert(wifi_ps==WIFI_PS_NONE&&WiFi.getSleep()==WIFI_PS_NONE); + bridge.begin();assert(bridge._previous_wifi_sleep==WIFI_PS_MIN_MODEM); + bridge.end();assert(!bridge._initialized&&!bridge._wifi_sleep_preference_owned&&WiFi.getSleep()==WIFI_PS_MIN_MODEM); + } + // An event delivered during SDK start also sees the prepared cache. + deliver_sta_during_start=true; + {ESPNowBridge bridge(&prefs,&mgr,&rtc);bridge.begin();assert(bridge._initialized&&wifi_ps==WIFI_PS_NONE);bridge.end();} + deliver_sta_during_start=false; + // setSleep returnsfalse for an already-correct preference; startup still works. + (void)WiFi.setSleep(WIFI_PS_NONE);assert(!WiFi.setSleep(WIFI_PS_NONE)); + {ESPNowBridge bridge(&prefs,&mgr,&rtc);bridge.begin();assert(bridge._initialized);deliverArduinoStaStart(); + assert(wifi_ps==WIFI_PS_NONE);bridge.end();assert(WiFi.getSleep()==WIFI_PS_NONE);} + (void)WiFi.setSleep(WIFI_PS_MIN_MODEM); + prefs.bridge_format=mesh::bridge::ESPNOW_FORMAT_RAW; + for(int stage:{11,12,13,14,15,19,2,4,7,8,9,10}){ + failure=stage;ESPNowBridge bridge(&prefs,&mgr,&rtc);bridge.begin(); + assert(!bridge._initialized&&!bridge._wifi_sleep_preference_owned&&WiFi.getSleep()==WIFI_PS_MIN_MODEM); + assert(!sdk_initialized&&!espnow_active);pending_sta_events=0; + } + failure=0;prefs.bridge_format=mesh::bridge::ESPNOW_FORMAT_WRAPPED; + // Shared startup never changes the owner's cache or actual modem policy. + assert(WiFi.mode(WIFI_STA));(void)WiFi.setSleep(WIFI_PS_MAX_MODEM);deliverArduinoStaStart();backend.wifi=true; + {ESPNowBridge bridge(&prefs,&mgr,&rtc);const auto writes=ps_writes;bridge.begin(); + assert(bridge._initialized&&!bridge._wifi_sleep_preference_owned&&ps_writes==writes&&WiFi.getSleep()==WIFI_PS_MAX_MODEM); + bridge.end();assert(WiFi.getSleep()==WIFI_PS_MAX_MODEM&&wifi_ps==WIFI_PS_MAX_MODEM&&sdk_started);} + backend.wifi=false;assert(WiFi.mode(WIFI_OFF));(void)WiFi.setSleep(WIFI_PS_MIN_MODEM); + // An owner adopting a dedicated driver keeps its newer cached preference. + {ESPNowBridge bridge(&prefs,&mgr,&rtc);bridge.begin();assert(WiFi.mode(WIFI_STA));backend.wifi=true; + (void)WiFi.setSleep(WIFI_PS_MAX_MODEM);bridge.end(); + assert(WiFi.getSleep()==WIFI_PS_MAX_MODEM&&wifi_ps==WIFI_PS_MAX_MODEM&&sdk_started&&!bridge._wifi_sleep_preference_owned); + deliverArduinoStaStart();assert(wifi_ps==WIFI_PS_MAX_MODEM);} + backend.wifi=false;assert(WiFi.mode(WIFI_OFF));(void)WiFi.setSleep(WIFI_PS_MIN_MODEM); + // Direct SDK writers do not alter the Arduino cache. Do not override an + // adopted owner's live setting at stop; its later event policy is its own. + {ESPNowBridge bridge(&prefs,&mgr,&rtc);bridge.begin();backend.wifi=true; + assert(esp_wifi_set_ps(WIFI_PS_MAX_MODEM)==ESP_OK);const auto writes=ps_writes;bridge.end(); + assert(ps_writes==writes&&wifi_ps==WIFI_PS_MAX_MODEM&&WiFi.getSleep()==WIFI_PS_NONE);} + backend.wifi=false;stopBridgeWiFiIfUnused();(void)WiFi.setSleep(WIFI_PS_MIN_MODEM); + // A changed cached preference is retained even if its owner already left. + {ESPNowBridge bridge(&prefs,&mgr,&rtc);bridge.begin();(void)WiFi.setSleep(WIFI_PS_MAX_MODEM);bridge.end(); + assert(WiFi.getSleep()==WIFI_PS_MAX_MODEM);} + (void)WiFi.setSleep(WIFI_PS_MIN_MODEM); + // WiFi facade/SDK stop or deinit failure cannot restore sleep prematurely. + for(int stage:{16,17}) { + ESPNowBridge bridge(&prefs,&mgr,&rtc);bridge.begin();assert(WiFi.mode(WIFI_STA)); + failure=stage;bridge.end(); + assert(!bridge._initialized&&bridge._wifi_sleep_preference_owned&&WiFi.getSleep()==WIFI_PS_NONE); + failure=0;bridge.end(); + assert(!bridge._wifi_sleep_preference_owned&&WiFi.getSleep()==WIFI_PS_MIN_MODEM&&!sdk_initialized); + } + // A new begin retries failed cleanup without replacing the saved MIN intent. + {ESPNowBridge bridge(&prefs,&mgr,&rtc);bridge.begin();assert(WiFi.mode(WIFI_STA)); + failure=17;bridge.end();assert(bridge._wifi_sleep_preference_owned); + failure=0;bridge.begin();assert(bridge._initialized&&bridge._previous_wifi_sleep==WIFI_PS_MIN_MODEM); + bridge.end();assert(WiFi.getSleep()==WIFI_PS_MIN_MODEM);} + // Failed ESP-NOW teardown keeps NONE until the uncertain SDK session ends. + {ESPNowBridge bridge(&prefs,&mgr,&rtc);bridge.begin();backend.wifi=true;failure=18;bridge.end(); + assert(bridge._sdk_teardown_failed&&bridge._wifi_sleep_preference_owned&&WiFi.getSleep()==WIFI_PS_NONE); + failure=0;backend.wifi=false;bridge.end(); + assert(!bridge._sdk_teardown_failed&&!bridge._wifi_sleep_preference_owned&&WiFi.getSleep()==WIFI_PS_MIN_MODEM&&!sdk_initialized);} +} +''' + + class EspNowPeerTests(unittest.TestCase): @classmethod def setUpClass(cls): @@ -250,6 +328,30 @@ namespace mesh {template bool filePresence(FS* fs,const char* path,boo result = subprocess.run([str(binary)], capture_output=True, text=True, timeout=20) self.assertEqual(result.returncode, 0, result.stdout+result.stderr) + def test_actual_arduino_delayed_start_sleep_cache_and_ownership(self): + from test_bridge_recovery import code, COMMON + with tempfile.TemporaryDirectory(prefix="espnow-async-sleep-") as tmp: + folder = Path(tmp) + source = folder / "test.cpp" + production = code() + original = production+COMMON+ASYNC_SLEEP_CHECKS + defines = ["ESP32=1", "WITH_ESPNOW_BRIDGE=1", "WITH_RS232_BRIDGE=1", + "ESP_IDF_VERSION=40400", "ESP_IDF_VERSION_MAJOR=4"] + source.write_text(original) + binary = folder / "fixed" + self.compile(source, binary, [ROOT / "src"], defines) + result = subprocess.run([str(binary)], capture_output=True, text=True, timeout=20) + self.assertEqual(result.returncode, 0, result.stdout+result.stderr) + # Mutation proves that a checked one-off SDK write still loses + # when the queued real Arduino STA_START branch runs afterward. + preparation = "(void)WiFi.setSleep(WIFI_PS_NONE);" + self.assertEqual(production.count(preparation), 1) + source.write_text(production.replace(preparation, "(void)0;")+COMMON+ASYNC_SLEEP_CHECKS) + mutant = folder / "sdk-only" + self.compile(source, mutant, [ROOT / "src"], defines) + result = subprocess.run([str(mutant)], capture_output=True, text=True, timeout=20) + self.assertNotEqual(result.returncode, 0, "SDK-only mutation unexpectedly survived late STA_START") + def test_non_espnow_build_has_no_storage_or_peer_symbols(self): with tempfile.TemporaryDirectory(prefix="no-espnow-peer-") as tmp: obj = Path(tmp) / "peer.o" diff --git a/test/test_espnow_radio_lifecycle.py b/test/test_espnow_radio_lifecycle.py index a374d4741..da860545b 100644 --- a/test/test_espnow_radio_lifecycle.py +++ b/test/test_espnow_radio_lifecycle.py @@ -22,9 +22,10 @@ STUBS = r''' #define portMUX_INITIALIZER_UNLOCKED 0 using portMUX_TYPE=int; using esp_err_t=int; +constexpr int ESP_ERR_WIFI_NOT_INIT=-91; constexpr int ESP_OK=0,WIFI_IF_STA=0,WIFI_PROTOCOL_LR=8,WIFI_PHY_MODE_LR=1,WIFI_PHY_RATE_LORA_250K=2; enum wifi_mode_t {WIFI_OFF=0,WIFI_STA=1,WIFI_AP=2,WIFI_AP_STA=3}; -enum wifi_ps_type_t {WIFI_PS_NONE=0,WIFI_PS_MIN_MODEM=1}; +enum wifi_ps_type_t {WIFI_PS_NONE=0,WIFI_PS_MIN_MODEM=1,WIFI_PS_MAX_MODEM=2}; int esp_wifi_set_ps(wifi_ps_type_t){return 0;} namespace mesh {namespace bridge { bool copyEspNowPeer(uint8_t* address){memset(address,255,6);return true;} @@ -38,6 +39,9 @@ int failure=0,init_calls=0,deinit_calls=0,wake_refs=0,send_calls=0,power=0; bool sdk_active=false;uint32_t millis(){return 1234;} struct FakeWiFi { wifi_mode_t current=WIFI_OFF;bool autoreconnect=true; + wifi_ps_type_t cached_sleep=WIFI_PS_MIN_MODEM; + wifi_ps_type_t getSleep(){return cached_sleep;} + bool setSleep(wifi_ps_type_t value){if(value==cached_sleep)return false;cached_sleep=value;return true;} void persistent(bool){} wifi_mode_t getMode(){return current;} int channel(){return current==WIFI_OFF?0:6;} @@ -193,7 +197,7 @@ class EspNowLifecycleTests(unittest.TestCase): def test_bridge_preserves_infrastructure_wifi(self): source=(ROOT/'src/helpers/bridges/ESPNowBridge.cpp').read_text() - methods=method(source,'static void stopBridgeWiFiIfUnused(')+'\n' + methods=method(source,'static bool stopBridgeWiFiIfUnused(')+'\n'+method(source,'static void stopBridgeWiFiAndReleaseSleepPreference(')+'\n' methods+='\n'.join(method(source,f'void ESPNowBridge::{name}(').replace('ESPNowBridge::','Bridge::') for name in ('begin','end')) harness=STUBS.replace('assert(peer->channel==0)', 'assert(peer->channel==6)')+r''' @@ -214,7 +218,7 @@ int esp_wifi_set_mode(int){return 0;} int esp_wifi_start(){++wifi_starts;return 0;} int esp_wifi_stop(){++wifi_stops;return 0;} int esp_wifi_deinit(){wifi_initialized=false;return 0;} -int esp_wifi_get_mode(wifi_mode_t* mode){*mode=WIFI_STA;return wifi_initialized?0:-1;} +int esp_wifi_get_mode(wifi_mode_t* mode){*mode=WIFI_STA;return wifi_initialized?0:ESP_ERR_WIFI_NOT_INIT;} int esp_wifi_get_channel(uint8_t* channel,wifi_second_chan_t*){*channel=wifi_channel;return 0;} int esp_wifi_set_channel(int channel,int){wifi_channel=channel;return 0;} int esp_now_del_peer(const uint8_t*){return 0;} @@ -241,6 +245,7 @@ public: int _tx_mux=0,_tx_head=0,_tx_tail=0,_tx_count=0,_tx_waiting=0,_tx_callback_done=0; int _tx_callback_status=0,_tx_dropped=0,_tx_dropped_reported=0; uint32_t _tx_started_at=0,_tx_retry_at=0;bool _sdk_teardown_failed=false; + uint8_t _previous_wifi_sleep=0;bool _wifi_sleep_preference_owned=false; mesh::espnow::ESPNowRawReassembler _raw_reassembler; struct QueuedTransmit {bool started=false,diagnostic=false;int seen_key=0;}; static constexpr int TX_QUEUE_DEPTH=2; @@ -346,7 +351,7 @@ struct { def test_bridge_arduino_sdk_handoffs_and_partial_startup(self): source = (ROOT/'src/helpers/bridges/ESPNowBridge.cpp').read_text() - methods = method(source, 'static void stopBridgeWiFiIfUnused(') + '\n' + methods = method(source, 'static bool stopBridgeWiFiIfUnused(') + '\n' + method(source, 'static void stopBridgeWiFiAndReleaseSleepPreference(') + '\n' methods += '\n'.join( method(source, f'void ESPNowBridge::{name}(').replace('ESPNowBridge::', 'Bridge::') for name in ('begin', 'end') diff --git a/test/test_wifi_ap_lr_safety.py b/test/test_wifi_ap_lr_safety.py index 9dbc5f7ee..49c48d8e1 100644 --- a/test/test_wifi_ap_lr_safety.py +++ b/test/test_wifi_ap_lr_safety.py @@ -19,6 +19,7 @@ public: int _tx_callback_status=0,_tx_dropped=0,_tx_dropped_reported=0; uint32_t _tx_started_at=0,_tx_retry_at=0; bool _sdk_teardown_failed=false; + uint8_t _previous_wifi_sleep=0;bool _wifi_sleep_preference_owned=false; mesh::bridge::diag::Stats _stats; std::atomic _debug_dirty{false}; mesh::espnow::ESPNowRawReassembler _raw_reassembler; @@ -178,7 +179,7 @@ def build_and_run(scenario, *, mutation=None, full=True): radio = (ROOT / 'src/helpers/esp32/ESPNOWRadio.cpp').read_text() radio = re.sub(r'^\s*#include.*$', '', radio, flags=re.MULTILINE) bridge_source = (ROOT / 'src/helpers/bridges/ESPNowBridge.cpp').read_text() - bridge = method(bridge_source, 'static void stopBridgeWiFiIfUnused(') + '\n' + bridge = method(bridge_source, 'static bool stopBridgeWiFiIfUnused(') + '\n' + method(bridge_source, 'static void stopBridgeWiFiAndReleaseSleepPreference(') + '\n' bridge += '\n'.join(method(bridge_source, f'void ESPNowBridge::{name}(') .replace('ESPNowBridge::', 'Bridge::') for name in ('begin', 'end', 'recordError', 'emitDebug')) diff --git a/test/test_wifi_ota_lifecycle.py b/test/test_wifi_ota_lifecycle.py index ff7503b21..87555c5f8 100644 --- a/test/test_wifi_ota_lifecycle.py +++ b/test/test_wifi_ota_lifecycle.py @@ -58,6 +58,8 @@ unsigned millis() { return clock_ms; } void delay(unsigned amount) { clock_ms += amount; } int esp_wifi_stop(); int esp_wifi_deinit(); +using esp_err_t=int; +constexpr int ESP_ERR_WIFI_NOT_INIT=-91; constexpr int ESP_OK = 0; constexpr int LISTEN = 10; @@ -154,7 +156,7 @@ int esp_wifi_stop() { } int esp_wifi_deinit() { ++wifi_deinit_calls; sdk_initialized = false; return 0; } int esp_wifi_get_mode(wifi_mode_t* mode) { - if (!sdk_initialized) return -1; + if (!sdk_initialized) return ESP_ERR_WIFI_NOT_INIT; *mode = sdk_ap_active ? WIFI_AP : WIFI_STA; return ESP_OK; } @@ -316,7 +318,7 @@ def production_harness(): "#elif defined(ADMIN_PASSWORD) && !defined(DISABLE_WIFI_OTA)") start_ota = method(board_source[elegant:], "bool ESP32Board::startOTAUpdate(") enabled = method(infrastructure, "uint8_t enabled() const override") - cleanup = method(bridge, "static void stopBridgeWiFiIfUnused(") + cleanup = method(bridge, "static bool stopBridgeWiFiIfUnused(") ota_running = method(board_header, "bool isOTAUpdateRunning() const override") ap_policy = (ROOT / "src/helpers/esp32/WiFiAccessPointPolicy.h").read_text() return (STUBS.replace("@ENABLED@", enabled).replace("@CLEANUP@", cleanup) From cfc9d44bc94ff039007b78ac3df51f4a455e735b Mon Sep 17 00:00:00 2001 From: mikecarper Date: Thu, 29 Oct 2026 01:19:12 -0700 Subject: [PATCH 60/61] Open Linux serial consoles without pulsing ESP32 BOOT --- test/test_ethernet_hil_serial_open.py | 38 +++++-- tools/hil/serial_session.py | 41 +++++++ tools/hil/test_serial_session.py | 149 ++++++++++++++++++++++++++ 3 files changed, 222 insertions(+), 6 deletions(-) diff --git a/test/test_ethernet_hil_serial_open.py b/test/test_ethernet_hil_serial_open.py index 43f344894..ad422a125 100644 --- a/test/test_ethernet_hil_serial_open.py +++ b/test/test_ethernet_hil_serial_open.py @@ -6,6 +6,7 @@ import io import os from pathlib import Path import subprocess +import struct import sys from types import ModuleType, SimpleNamespace import unittest @@ -22,6 +23,7 @@ import serial_session as session class FakeSerial: def __init__(self, **kwargs): self.dsrdtr = False + self.rtscts = False self._dtr = True self._rts = True self.driver_rts = True @@ -40,7 +42,7 @@ class FakeSerial: def dtr(self, value): self._dtr = value if self.is_open: - self._apply_dtr(value) + self._update_dtr_state() @property def rts(self): @@ -50,8 +52,14 @@ class FakeSerial: def rts(self, value): self._rts = value if self.is_open: - self.driver_rts = value - self.trace.append(("rts", value)) + self._update_rts_state() + + def _update_dtr_state(self): + self._apply_dtr(self._dtr) + + def _update_rts_state(self): + self.driver_rts = self._rts + self.trace.append(("rts", self._rts)) def _apply_dtr(self, value): if not value and self.driver_rts: @@ -62,10 +70,12 @@ class FakeSerial: def open(self): self.opens += 1 self.is_open = True + FakeSerial.live = self # POSIX pySerial's real open order: DTR before RTS unless deferred. if not self.dsrdtr: - self._apply_dtr(self._dtr) - self.rts = self._rts + self._update_dtr_state() + if not self.rtscts: + self._update_rts_state() def close(self): self.is_open = False @@ -97,6 +107,22 @@ def serial_modules(port): @unittest.skipUnless(os.name == "posix", "POSIX CH340 pin-order behavior") class EthernetHilSerialTests(unittest.TestCase): def setUp(self): + import fcntl + import termios + + def clear_together(fd, request, mask): + self.assertEqual(fd, 77) + self.assertEqual(request, termios.TIOCMBIC) + self.assertEqual(struct.unpack("I", mask)[0], termios.TIOCM_DTR | termios.TIOCM_RTS) + port = FakeSerial.live + port.driver_dtr = port.driver_rts = False + port.trace.append(("modem", False, False)) + + for inspector in (patch.object(fcntl, "ioctl", side_effect=clear_together), + patch.object(termios, "tcgetattr", return_value=[0, 0, termios.HUPCL, 0, 0, 0, []]), + patch.object(termios, "tcsetattr")): + inspector.start() + self.addCleanup(inspector.stop) # Exercise the real Linux ownership guard without consulting host # fuser or a nonexistent synthetic port. A quiet rc1 is the exact # successful no-owner contract; diagnostic output must still refuse. @@ -112,7 +138,7 @@ class EthernetHilSerialTests(unittest.TestCase): def assert_safe(self, port): self.assertEqual(port.opens, 1) self.assertEqual(port.resets, 0) - self.assertEqual(port.trace[0], ("rts", False)) + self.assertEqual(port.trace, [("modem", False, False)]) self.assertFalse(port.dsrdtr) self.assertFalse(port.driver_rts) self.assertFalse(port.driver_dtr) diff --git a/tools/hil/serial_session.py b/tools/hil/serial_session.py index a1fc3196e..6ed0e032e 100644 --- a/tools/hil/serial_session.py +++ b/tools/hil/serial_session.py @@ -81,7 +81,48 @@ def assert_port_idle(path): raise RuntimeError("Cannot safely inspect serial-port ownership (fuser error)") +def _open_linux_inactive_modem_session(port): + # pySerial updates DTR and RTS separately during open. Releasing either + # first can drive EN or BOOT on an ESP32 auto-reset circuit. Keep both + # kernel-open levels together until one joint modem-line update can clear + # them. Do not enable flow control to suppress the individual updates. + import fcntl + import struct + import termios + + missing = object() + saved = {} + for name in ("_update_dtr_state", "_update_rts_state"): + if not callable(getattr(port, name, None)): + raise RuntimeError("Linux serial backend cannot defer modem-line updates") + saved[name] = port.__dict__.get(name, missing) + try: + for name in saved: + setattr(port, name, lambda: None) + port.open() + descriptor = port.fileno() + attributes = termios.tcgetattr(descriptor) + attributes[2] &= ~termios.HUPCL + termios.tcsetattr(descriptor, termios.TCSANOW, attributes) + fcntl.ioctl(descriptor, termios.TIOCMBIC, + struct.pack("I", termios.TIOCM_DTR | termios.TIOCM_RTS)) + except BaseException: + port.close() + raise + finally: + for name, previous in saved.items(): + if previous is missing: + delattr(port, name) + else: + setattr(port, name, previous) + + def open_configured_session(port): + if (sys.platform.startswith("linux") and os.name == "posix" + and not port.dtr and not port.rts + and not port.dsrdtr and not port.rtscts): + _open_linux_inactive_modem_session(port) + return # POSIX pySerial normally applies DTR before RTS when opening. Some # bridge drivers initially assert both lines; clearing DTR first pulses # ESP32 EN. Defer DTR until RTS is released. Windows configures both in diff --git a/tools/hil/test_serial_session.py b/tools/hil/test_serial_session.py index 8f3084266..810661a26 100644 --- a/tools/hil/test_serial_session.py +++ b/tools/hil/test_serial_session.py @@ -3,6 +3,8 @@ from pathlib import Path import ast import hashlib import importlib.util +import os +import struct import subprocess import sys from tempfile import TemporaryDirectory @@ -47,6 +49,153 @@ class SerialPortOwnershipTests(unittest.TestCase): self.inspect(subprocess.CompletedProcess([], 0, stdout="1234\n", stderr="/dev/ttyACM7:")) +@unittest.skipUnless(os.name == "posix", "POSIX modem-line control") +class AtomicModemOpenTests(unittest.TestCase): + class Port: + dtr = rts = dsrdtr = rtscts = False + + def __init__(self): + self.is_open = False + self.events = [] + self.driver_dtr = self.driver_rts = True + + def _update_dtr_state(self): + self.events.append("individual_dtr") + self.driver_dtr = self.dtr + + def _update_rts_state(self): + self.events.append("individual_rts") + self.driver_rts = self.rts + + def open(self): + self.events.append("open") + self.is_open = True + if not self.dsrdtr: + self._update_dtr_state() + if not self.rtscts: + self._update_rts_state() + self.events.append(("opened_levels", self.driver_dtr, self.driver_rts)) + + def close(self): + self.events.append("close") + self.is_open = False + + def fileno(self): + return 77 + + def setUp(self): + spec = importlib.util.spec_from_file_location( + "atomic_serial_session", Path(__file__).with_name("serial_session.py")) + self.session = importlib.util.module_from_spec(spec) + spec.loader.exec_module(self.session) + + def exercise(self, port, fail=None): + import fcntl + import termios + + def get_attributes(fd): + self.assertEqual(fd, 77) + port.events.append("attributes") + if fail == "attributes": + raise OSError("attributes failed") + return [1, 2, termios.HUPCL | 0x4000, 4, 5, 6, [7]] + + def set_attributes(fd, when, attributes): + self.assertEqual((fd, when), (77, termios.TCSANOW)) + self.assertEqual(attributes, [1, 2, 0x4000, 4, 5, 6, [7]]) + port.events.append("clear_hupcl") + if fail == "set_attributes": + raise OSError("set_attributes failed") + + def clear_lines(fd, request, packed): + self.assertEqual((fd, request), (77, termios.TIOCMBIC)) + self.assertEqual(struct.unpack("I", packed)[0], termios.TIOCM_DTR | termios.TIOCM_RTS) + port.events.append("joint_clear") + if fail == "ioctl": + raise OSError("ioctl failed") + port.driver_dtr = port.driver_rts = False + + with patch.object(self.session.sys, "platform", "linux"), \ + patch.object(termios, "tcgetattr", side_effect=get_attributes), \ + patch.object(termios, "tcsetattr", side_effect=set_attributes), \ + patch.object(fcntl, "ioctl", side_effect=clear_lines) as modem: + self.session.open_configured_session(port) + return modem + + def test_joint_clear_has_no_intermediate_reset_or_boot_levels(self): + port = self.Port() + modem = self.exercise(port) + self.assertEqual(port.events, ["open", ("opened_levels", True, True), + "attributes", "clear_hupcl", "joint_clear"]) + modem.assert_called_once() + self.assertFalse(port.driver_dtr or port.driver_rts) + self.assertFalse(port.dsrdtr or port.rtscts) + self.assertNotIn("_update_dtr_state", port.__dict__) + self.assertNotIn("_update_rts_state", port.__dict__) + port._update_dtr_state() + port._update_rts_state() + self.assertEqual(port.events[-2:], ["individual_dtr", "individual_rts"]) + + def test_prior_dtr_only_deferral_has_an_intermediate_boot_level(self): + port = self.Port() + port.dsrdtr = True + port.open() + self.assertEqual(port.events[-1], ("opened_levels", True, False)) + + def test_all_failure_stages_close_and_restore_instance_overrides(self): + for fail in ("open", "attributes", "set_attributes", "ioctl"): + with self.subTest(stage=fail): + port = self.Port() + original_dtr = lambda: port.events.append("original_dtr") + original_rts = lambda: port.events.append("original_rts") + port._update_dtr_state = original_dtr + port._update_rts_state = original_rts + if fail == "open": + def failed_open(): + port.is_open = True + raise OSError("open failed") + port.open = failed_open + with self.assertRaisesRegex(OSError, fail + " failed"): + self.exercise(port, fail) + self.assertFalse(port.is_open) + self.assertEqual(port.events[-1], "close") + self.assertIs(port._update_dtr_state, original_dtr) + self.assertIs(port._update_rts_state, original_rts) + + def test_true_dtr_and_other_platforms_retain_their_existing_opener(self): + for platform, dtr in (("linux", True), ("win32", False), ("darwin", False)): + with self.subTest(platform=platform, dtr=dtr): + port = self.Port() + port.dtr = dtr + with patch.object(self.session.sys, "platform", platform), \ + patch.object(self.session, "_open_linux_inactive_modem_session") as atomic: + self.session.open_configured_session(port) + atomic.assert_not_called() + self.assertTrue(port.is_open) + self.assertEqual(port.dtr, dtr) + + def test_installed_pyserial_open_uses_the_deferred_methods(self): + try: + import serial.serialposix as backend + except ImportError: + self.skipTest("installed pySerial backend required") + port = self.Port() + port._port = port.portstr = "/dev/test-only" + port._dsrdtr = port._rtscts = False + port._reconfigure_port = lambda **kwargs: port.events.append("configure") + port._reset_input_buffer = lambda: port.events.append("reset_input") + port.open = lambda: backend.Serial.open(port) + with patch.object(backend.os, "open", return_value=77), \ + patch.object(backend.os, "pipe", side_effect=[(80, 81), (82, 83)]), \ + patch.object(backend.fcntl, "fcntl"): + self.exercise(port) + self.assertNotIn("individual_dtr", port.events) + self.assertNotIn("individual_rts", port.events) + self.assertTrue(port.is_open) + self.assertEqual(port.events, ["configure", "reset_input", "attributes", + "clear_hupcl", "joint_clear"]) + + class SerialSessionImportTests(unittest.TestCase): def test_immutable_collectors_require_only_their_shipped_dependencies(self): base = Path(__file__).parent From de2401c5464341f2127c3f6ca976c3fcd795c353 Mon Sep 17 00:00:00 2001 From: mikecarper Date: Thu, 29 Oct 2026 02:33:12 -0700 Subject: [PATCH 61/61] Update serial fixtures for atomic Linux console opens --- test/test_companion_serial_hil.py | 64 ++++++++++++-- tools/hil/test_contact_cache_serial_stress.py | 1 + .../hil/test_esp32_companion_serial_stress.py | 30 ++++--- tools/hil/test_fem_noise_floor_test.py | 3 +- tools/hil/test_profile_switch_host.py | 88 +++++++++++++++++++ 5 files changed, 169 insertions(+), 17 deletions(-) diff --git a/test/test_companion_serial_hil.py b/test/test_companion_serial_hil.py index 4d286a1dd..b05ab50ec 100644 --- a/test/test_companion_serial_hil.py +++ b/test/test_companion_serial_hil.py @@ -1,6 +1,7 @@ """Exercise the shared local USB transport without opening a real device.""" from collections import deque +from contextlib import ExitStack from pathlib import Path import os import struct @@ -41,7 +42,8 @@ class FakeSerial: def __init__(self, **options): self.options = options self.events = [] - self._dtr, self._rts, self._dsrdtr = True, True, False + self._dtr, self._rts, self._dsrdtr, self._rtscts = True, True, False, False + self.driver_dtr, self.driver_rts = True, True self.port = None self.is_open = False self.chunks = deque() @@ -57,11 +59,22 @@ class FakeSerial: def set(self, value): setattr(self, "_" + name, value) self.events.append((name, value)) + if self.is_open and name in ("dtr", "rts"): + getattr(self, "_update_" + name + "_state")() return property(get, set) dtr = _line("dtr") rts = _line("rts") dsrdtr = _line("dsrdtr") + rtscts = _line("rtscts") + + def _update_dtr_state(self): + self.driver_dtr = self.dtr + self.events.append(("driver_dtr", self.driver_dtr)) + + def _update_rts_state(self): + self.driver_rts = self.rts + self.events.append(("driver_rts", self.driver_rts)) @property def in_waiting(self): @@ -72,6 +85,16 @@ class FakeSerial: if self.fail_open: raise OSError("fixture open failure") self.is_open = True + # Match pySerial's independent open-time modem hooks. The Linux + # atomic opener must suppress both until the joint ioctl below. + if not self.dsrdtr: + self._update_dtr_state() + if not self.rtscts: + self._update_rts_state() + self.events.append(("opened_levels", self.driver_dtr, self.driver_rts)) + + def fileno(self): + return 77 def close(self): self.events.append(("close",)) @@ -105,7 +128,30 @@ class CompanionSerialTests(unittest.TestCase): port = port or FakeSerial() module = ModuleType("serial") module.Serial = mock.Mock(return_value=port) - with mock.patch.dict(sys.modules, {"serial": module}): + with ExitStack() as stack: + stack.enter_context(mock.patch.dict(sys.modules, {"serial": module})) + if sys.platform.startswith("linux") and os.name == "posix": + import fcntl + import termios + + def attributes(fd): + self.assertEqual(fd, 77) + return [0, 0, termios.HUPCL | 0x4000, 0, 0, 0, []] + + def clear_hupcl(fd, when, values): + self.assertEqual((fd, when), (77, termios.TCSANOW)) + self.assertEqual(values, [0, 0, 0x4000, 0, 0, 0, []]) + port.events.append(("clear_hupcl",)) + + def clear_lines(fd, request, packed): + self.assertEqual((fd, request), (77, termios.TIOCMBIC)) + self.assertEqual(struct.unpack("I", packed)[0], termios.TIOCM_DTR | termios.TIOCM_RTS) + port.driver_dtr = port.driver_rts = False + port.events.append(("joint_clear",)) + + stack.enter_context(mock.patch.object(termios, "tcgetattr", side_effect=attributes)) + stack.enter_context(mock.patch.object(termios, "tcsetattr", side_effect=clear_hupcl)) + stack.enter_context(mock.patch.object(fcntl, "ioctl", side_effect=clear_lines)) node = HIL.Companion("/dev/fake-local") self.assertEqual(module.Serial.call_args.kwargs, {"port": None, "baudrate": 115200, "timeout": 0.05, @@ -127,9 +173,16 @@ class CompanionSerialTests(unittest.TestCase): self.assertEqual(port.events[:2], [("dtr", False), ("rts", False)]) opened = next(event for event in port.events if event[0] == "open") self.assertEqual(opened[1:3], (False, False)) - if os.name == "posix": + if sys.platform.startswith("linux") and os.name == "posix": + self.assertFalse(opened[3]) + self.assertIn(("opened_levels", True, True), port.events) + self.assertEqual(port.events[-2:], [("clear_hupcl",), ("joint_clear",)]) + self.assertFalse(any(event[0] in ("driver_dtr", "driver_rts") for event in port.events)) + self.assertNotIn("_update_dtr_state", port.__dict__) + self.assertNotIn("_update_rts_state", port.__dict__) + elif os.name == "posix": self.assertTrue(opened[3]) - self.assertEqual(port.events[-3:], [("rts", False), ("dtr", False), ("dsrdtr", False)]) + self.assertFalse(port.driver_dtr or port.driver_rts) self.assertFalse(port.dsrdtr) self.assertFalse(any(event in (("dtr", True), ("rts", True)) for event in port.events)) node.close() @@ -166,7 +219,8 @@ class CompanionSerialTests(unittest.TestCase): HIL.encode_host_frame(bytes((22, 14))), HIL.encode_host_frame(HIL.make_app_start_spec("Meshtext-Auto-HIL").payload)]) stop = port.events.index(("write", writes[0])) - self.assertEqual(port.events[stop - 2:stop], [("rts", False), ("dtr", True)]) + lines_before_stop = [event for event in port.events[:stop] if event[0] in ("dtr", "rts")] + self.assertEqual(lines_before_stop[-2:], [("rts", False), ("dtr", True)]) def test_start_rejects_logging_owner_before_any_framed_request(self): node, port = self.connect() diff --git a/tools/hil/test_contact_cache_serial_stress.py b/tools/hil/test_contact_cache_serial_stress.py index 2e4500b88..385a9b0a6 100644 --- a/tools/hil/test_contact_cache_serial_stress.py +++ b/tools/hil/test_contact_cache_serial_stress.py @@ -51,6 +51,7 @@ class ContactCacheOpenTests(unittest.TestCase): port = FailingSessionPort(phase) with mock.patch.object(hil.serial, "Serial", return_value=port), mock.patch( "serial_session.os.name", "posix"), mock.patch( + "serial_session.sys.platform", "darwin"), mock.patch( "profile_switch.list_ports.comports", return_value=[]): with self.assertRaisesRegex(RuntimeError, phase + " failed"): hil.run(SimpleNamespace(port="test")) diff --git a/tools/hil/test_esp32_companion_serial_stress.py b/tools/hil/test_esp32_companion_serial_stress.py index 9236055fd..a829f6264 100644 --- a/tools/hil/test_esp32_companion_serial_stress.py +++ b/tools/hil/test_esp32_companion_serial_stress.py @@ -237,11 +237,12 @@ class StressModesTest(unittest.TestCase): os.close(slave) def test_selected_control_lines_are_set_while_port_is_closed(self) -> None: - class Serial: + from test_profile_switch_host import SessionPort + + class Serial(SessionPort): def __init__(self): - self.is_open = False - self.dsrdtr = False self.events = [] + super().__init__('test') def __setattr__(self, name, value): if name in ("dtr", "rts"): @@ -251,19 +252,25 @@ class StressModesTest(unittest.TestCase): def open(self): assert self.rts is False self.events.append(("open", self.dtr)) - self.is_open = True + super().open() with mock.patch.dict(sys.modules, {"serial": types.SimpleNamespace(Serial=Serial)}): factory = hil._make_pyserial_factory() for dtr in (False, True): with self.subTest(dtr=dtr): port = factory(fast_config(dtr=dtr)) - self.assertIs(port.dtr, dtr) - self.assertTrue(port.is_open) - opened = port.events.index(("open", dtr)) - self.assertLess(port.events.index(("dtr", dtr)), opened) - self.assertLess(port.events.index(("rts", False)), opened) - self.assertFalse(port.dsrdtr) + try: + self.assertIs(port.dtr, dtr) + self.assertTrue(port.is_open) + opened = port.events.index(("open", dtr)) + self.assertLess(port.events.index(("dtr", dtr)), opened) + self.assertLess(port.events.index(("rts", False)), opened) + self.assertFalse(port.dsrdtr) + if sys.platform.startswith('linux') and not dtr: + self.assertEqual(port.modem_edges, [ + ('joint_clear', False, {'dtr': False, 'rts': False})]) + finally: + port.close() def test_other_backend_keeps_manual_companion_lines_without_dsr_handoff(self) -> None: from test_profile_switch_host import SessionPort @@ -291,7 +298,8 @@ class StressModesTest(unittest.TestCase): with self.subTest(phase=phase): port = FailingSessionPort(phase) with mock.patch.dict(sys.modules, {"serial": types.SimpleNamespace(Serial=lambda: port)}), mock.patch( - "serial_session.os.name", "posix"): + "serial_session.os.name", "posix"), mock.patch( + "serial_session.sys.platform", "darwin"): with self.assertRaisesRegex(RuntimeError, phase + " failed"): hil._make_pyserial_factory()(fast_config()) self.assertFalse(port.is_open) diff --git a/tools/hil/test_fem_noise_floor_test.py b/tools/hil/test_fem_noise_floor_test.py index 169aef202..43814ce24 100644 --- a/tools/hil/test_fem_noise_floor_test.py +++ b/tools/hil/test_fem_noise_floor_test.py @@ -107,7 +107,8 @@ class PayloadValidationTest(unittest.TestCase): port = FailingSessionPort(phase) transport = transport_type(fast_config(dtr="off", open_delay=0)) with mock.patch.object(serial, "Serial", return_value=port), mock.patch( - "serial_session.os.name", "posix"): + "serial_session.os.name", "posix"), mock.patch( + "serial_session.sys.platform", "darwin"): with self.assertRaisesRegex(RuntimeError, phase + " failed"): transport.open() self.assertIsNone(transport.port) diff --git a/tools/hil/test_profile_switch_host.py b/tools/hil/test_profile_switch_host.py index 68138b6e9..8eb0c7d3d 100644 --- a/tools/hil/test_profile_switch_host.py +++ b/tools/hil/test_profile_switch_host.py @@ -1,12 +1,50 @@ """Regression tests for the serial HIL result reader.""" import unittest import os +import sys +from contextlib import ExitStack from pathlib import Path from tempfile import TemporaryDirectory from unittest.mock import patch from types import SimpleNamespace from profile_switch import read_response, exchange_ready, configure_session, exchange, open_session + +def simulated_native_modem(port, descriptor, *, synthetic_attributes=False): + """Model only unsupported PTY/synthetic-fd modem ioctls, not the opener.""" + import fcntl + import struct + import termios + + stack = ExitStack() + actual_ioctl = fcntl.ioctl + + def ioctl(fd, request, value, *args, **kwargs): + if fd != descriptor or request != termios.TIOCMBIC: + return actual_ioctl(fd, request, value, *args, **kwargs) + mask = struct.unpack('I', value)[0] + if mask != termios.TIOCM_DTR | termios.TIOCM_RTS: + raise AssertionError('modem lines were not cleared together') + port.modem_lines.update(dtr=False, rts=False) + port.modem_edges.append(('joint_clear', False, port.modem_lines.copy())) + return 0 + + stack.enter_context(patch.object(fcntl, 'ioctl', side_effect=ioctl)) + if synthetic_attributes: + def attributes(fd): + if fd != descriptor: + raise AssertionError('unexpected synthetic descriptor') + return [0, 0, termios.HUPCL | 0x4000, 0, 0, 0, []] + + def set_attributes(fd, when, values): + if fd != descriptor or when != termios.TCSANOW or values[2] & termios.HUPCL: + raise AssertionError('HUPCL was not cleared on the selected descriptor') + + stack.enter_context(patch.object(termios, 'tcgetattr', side_effect=attributes)) + stack.enter_context(patch.object(termios, 'tcsetattr', side_effect=set_attributes)) + return stack + + if os.name == "posix": import serial.serialposix @@ -20,8 +58,24 @@ if os.name == "posix": self.modem_lines = {"dtr": True, "rts": True} self.modem_edges = [] self.flow_at_reconfigure = [] + self._native_fixture = None super().__init__(*args, **kwargs) + def open(self): + super().open() + # Real pySerial/termios still configure a real PTY. Only the + # modem ioctl that PTYs cannot implement is simulated afterward. + self._native_fixture = simulated_native_modem(self, self.fileno()) + + def close(self): + try: + super().close() + finally: + fixture = getattr(self, '_native_fixture', None) + if fixture is not None: + fixture.close() + self._native_fixture = None + def _reconfigure_port(self, force_update=False): self.flow_at_reconfigure.append(self.dsrdtr) super()._reconfigure_port(force_update) @@ -62,16 +116,44 @@ class SessionPort: self.dtr = True self.rts = True self.dsrdtr = False + self.rtscts = False self.opened_control_lines = None self.close_count = 0 + self.modem_lines = {'dtr': True, 'rts': True} + self.modem_edges = [] + self._native_fixture = None + + def _update_dtr_state(self): + self.modem_lines['dtr'] = self.dtr + self.modem_edges.append(('dtr', self.dtr, self.modem_lines.copy())) + + def _update_rts_state(self): + self.modem_lines['rts'] = self.rts + self.modem_edges.append(('rts', self.rts, self.modem_lines.copy())) + + def fileno(self): + return 77 def open(self): + # Only the atomic opener requests native descriptor operations after + # open. Direct fake opens need no global native-function patch at all. + deferred = all(name in self.__dict__ for name in + ('_update_dtr_state', '_update_rts_state')) + if sys.platform.startswith('linux') and os.name == 'posix' and deferred: + self._native_fixture = simulated_native_modem(self, self.fileno(), synthetic_attributes=True) self.opened_control_lines = (self.dtr, self.rts) self.is_open = True + if not self.dsrdtr: + self._update_dtr_state() + if not self.rtscts: + self._update_rts_state() def close(self): self.close_count += 1 self.is_open = False + if self._native_fixture is not None: + self._native_fixture.close() + self._native_fixture = None class FailingSessionPort(SessionPort): @@ -111,6 +193,11 @@ class ResultReaderTests(unittest.TestCase): self.assertFalse(port.has_reset_transition(), port.modem_edges) self.assertEqual(port.modem_lines, {"dtr": expected, "rts": False}) self.assertFalse(port.dsrdtr) + if sys.platform.startswith('linux') and not expected: + self.assertEqual(port.modem_edges, [ + ('joint_clear', False, {'dtr': False, 'rts': False})]) + self.assertNotIn(True, port.flow_at_reconfigure, + 'Atomic Linux open enabled flow control') if expected: self.assertNotIn(True, port.flow_at_reconfigure, "Native USB entered the bridge handoff") @@ -155,6 +242,7 @@ class ResultReaderTests(unittest.TestCase): with self.subTest(phase=phase): port = FailingSessionPort(phase) with patch("profile_switch.os.name", "posix"), patch( + "serial_session.sys.platform", "darwin"), patch( "profile_switch.list_ports.comports", return_value=[]): with self.assertRaisesRegex(RuntimeError, phase + " failed"): open_session(port)