From 807343de8d77adc4f61dbb260bdb499c04fa0211 Mon Sep 17 00:00:00 2001 From: mikecarper Date: Fri, 23 Oct 2026 08:57:51 -0700 Subject: [PATCH] Share Companion hardware diagnostics and protect RXEN reconfiguration --- .github/workflows/run-unit-tests.yml | 3 + docs/cli_command_availability.md | 6 + docs/cli_commands.md | 13 +- docs/companion_power_diagnostics.md | 36 +++ docs/companion_radio_diagnostics.md | 23 ++ docs/companion_radio_full.md | 4 + .../companion_cli_parity_2026-10-23.md | 87 ++++++ examples/companion_radio/MyMesh.cpp | 34 ++- examples/companion_radio/MyMesh.h | 6 + examples/companion_radio/main.cpp | 15 - src/MeshCore.h | 7 + src/helpers/CommonCLI.cpp | 217 +------------- src/helpers/CommonCLI.h | 8 +- src/helpers/CompanionPowerDiagnostics.h | 69 +++++ src/helpers/CompanionRadioDiagnostics.h | 73 +++++ src/helpers/NRF52Board.cpp | 7 + src/helpers/NRF52Board.h | 2 + src/helpers/SdCardCLI.cpp | 271 ++++++++++++++++++ src/helpers/SdCardCLI.h | 30 ++ src/helpers/radiolib/CustomSX1262Wrapper.h | 19 +- .../companion_radio_diagnostics/test.cpp | 218 ++++++++++++++ test/fixtures/rak15002_sd_seeder/cli.cpp | 17 +- .../rak15002_sd_seeder/maintenance.cpp | 17 +- test/test_companion_board_power_commands.py | 194 +++++++++++++ test/test_companion_radio_diagnostics.py | 51 ++++ test/test_rak15002_sd_seeder.py | 9 +- test/test_rak_sd_context_handoff.py | 26 +- test/test_sdcard_cli_parity.py | 208 ++++++++++++++ 28 files changed, 1393 insertions(+), 277 deletions(-) create mode 100644 docs/companion_power_diagnostics.md create mode 100644 docs/companion_radio_diagnostics.md create mode 100644 docs/test-results/companion_cli_parity_2026-10-23.md create mode 100644 src/helpers/CompanionPowerDiagnostics.h create mode 100644 src/helpers/CompanionRadioDiagnostics.h create mode 100644 src/helpers/SdCardCLI.cpp create mode 100644 src/helpers/SdCardCLI.h create mode 100644 test/fixtures/companion_radio_diagnostics/test.cpp create mode 100644 test/test_companion_board_power_commands.py create mode 100644 test/test_companion_radio_diagnostics.py create mode 100644 test/test_sdcard_cli_parity.py diff --git a/.github/workflows/run-unit-tests.yml b/.github/workflows/run-unit-tests.yml index 0a8eed0f8..30435b496 100644 --- a/.github/workflows/run-unit-tests.yml +++ b/.github/workflows/run-unit-tests.yml @@ -828,6 +828,9 @@ jobs: python3 -B test/test_xiao_nrf52_buffered_spi.py -v python3 -B test/test_nrf52_hil_spim1.py -v python3 -B test/test_companion_hil_startup_frequency.py -v + python3 -B test/test_companion_board_power_commands.py -v + python3 -B test/test_companion_radio_diagnostics.py -v + python3 -B test/test_sdcard_cli_parity.py -v python3 -B test/test_esp32_buffered_radio_targets.py -v python3 -B test/test_xiao_battery_safety.py -v python3 -B test/test_peripheral_mode_prefs.py -v diff --git a/docs/cli_command_availability.md b/docs/cli_command_availability.md index 291b3c35c..c9d6a96b0 100644 --- a/docs/cli_command_availability.md +++ b/docs/cli_command_availability.md @@ -96,6 +96,12 @@ over the normal binary USB, BLE, or TCP connection: | Command | Availability | |---|---| | `stats-core`, `stats-radio`, `stats-radio-diag`, `stats-packets` | Local terminal and binary command `0x42` | +| [`clear stats`, `get rxps.wd`, `get cad`](companion_radio_diagnostics.md) | Companion terminal and command `0x42`; clearing statistics also clears sticky packet error flags | +| [`get/set radio.rxps.rfrx_disabled`](companion_radio_diagnostics.md) | Supported radio with an RXEN pin; applies for this boot and preserves radio ownership | +| [`power`, `get pwrmgt.support`, `get pwrmgt.source`, `get pwrmgt.bootmv`](companion_power_diagnostics.md) | Board power capabilities determine available readings | +| [`get system.watchdog`](companion_power_diagnostics.md) | Actual nRF52 hardware status; Companion does not provide a saved on/off setter | +| [`get bootloader.ver`](cli_commands.md#view-the-bootloader-version-nrf52-only) | nRF52 Companion terminal and command `0x42`; unidentifiable metadata returns `unknown` | +| [`get/set sdcard`](test-results/companion_cli_parity_2026-10-23.md#shared-sd-commands) | nRF52 builds with SD OTA storage; maintenance retains OTA and Ethernet ownership guards | | `erase` | Local terminal and binary command `0x42`; reboot after erasing stored settings | | `set freq ` | Local terminal, binary command `0x42`, and authorized LoRa CLI; reboot to apply | | [`get public.key`](cli_commands.md#view-this-nodes-public-key) | Companion text terminal and binary command `0x42`; read-only, no private-key export feature required | diff --git a/docs/cli_commands.md b/docs/cli_commands.md index 0d562f54c..e120f2ea5 100644 --- a/docs/cli_commands.md +++ b/docs/cli_commands.md @@ -2781,6 +2781,11 @@ before relying on automatic forwarding limits. - The nRF52 hardware cannot stop a watchdog after it has started. Disabling is persisted immediately, then the current firmware stops feeding it so the board performs one watchdog restart within 60 seconds. It remains off after that restart. - This setting is nRF52-only. The SoftDevice flash deadlock does not apply to ESP32, whose existing watchdog behavior is unchanged. +Companion supports the getter and reports the actual hardware running state. +Its application always feeds the watchdog and has no saved on/off policy; +`set system.watchdog ...` therefore reports that control is unavailable. The +persistent on/off behavior above applies to infrastructure firmware. + --- #### Estimate and correct infrastructure-node time after startup @@ -6605,10 +6610,16 @@ off at boot and is deliberately controlled only for the current boot: get eth set eth on set eth off -get sdcard [free] +get sdcard free ota cache [on|off] ``` +SD-capable nRF52 Companion builds also share the infrastructure SD commands: +`get sdcard ls [page]`, `get sdcard dir [page]`, maintenance history, and +`set sdcard format|erase [--force]`. Bare `get sdcard` reports maintenance +history; use `get sdcard free` for capacity. See the +[SD command details and ownership guards](test-results/companion_cli_parity_2026-10-23.md#shared-sd-commands). + Before `set eth on`, remove the SD card and finish/cancel any active OTA capture, transfer, serving, or host-folder work. The command rejects an inserted card or a busy archive instead of taking the shared bus. Turn diff --git a/docs/companion_power_diagnostics.md b/docs/companion_power_diagnostics.md new file mode 100644 index 000000000..05ffa3c4b --- /dev/null +++ b/docs/companion_power_diagnostics.md @@ -0,0 +1,36 @@ +# Companion power and system-watchdog diagnostics + +Companion text commands are available through its existing USB terminal and +framed CLI paths, with the same admission rules as other diagnostics: + +```text +power +get pwrmgt.support +get pwrmgt.source +get pwrmgt.bootmv +get system.watchdog +``` + +`power` reports the current battery millivolts, USB power, solar charging, +external power and charger state. A board without a dedicated USB or solar +detector reports `n/a` for that field. External or charging power does not prove +that USB data is connected. + +`get pwrmgt.support` reports whether the board includes nRF52 power management. +On supported builds, `get pwrmgt.source` reports `external` or `battery`, and +`get pwrmgt.bootmv` reports the boot-time voltage sample. The boot sample differs +from the live battery reading. Other builds return an explicit unsupported +reply for the source and boot-voltage queries. + +On nRF52, `get system.watchdog` reports the hardware watchdog's actual running +state as `on` or `off`. It does not start or feed the watchdog. This also works +when a build disables normal watchdog feeding or inherits a running watchdog +from its bootloader. Other platforms report unsupported; this command does not +describe an ESP32 task watchdog or the radio-recovery watchdogs. + +Companion normally starts and feeds its nRF52 watchdog in the application loop. +Its timeout is normally 60 seconds. Companion has no saved watchdog enable +preference, so `set system.watchdog` returns an explicit error. Infrastructure +roles retain their existing saved on/off policy. Implementing that control in +Companion would also require preference storage and feed-loop integration: +an already running nRF52 watchdog cannot be stopped directly. diff --git a/docs/companion_radio_diagnostics.md b/docs/companion_radio_diagnostics.md new file mode 100644 index 000000000..3e3638379 --- /dev/null +++ b/docs/companion_radio_diagnostics.md @@ -0,0 +1,23 @@ +# Companion radio diagnostics + +Companion exposes these commands through USB terminal, the app's framed CLI +(command `0x42`, including Bluetooth), and its existing admitted admin CLI. + +| Command | Result | +| --- | --- | +| `get rxps.wd` | RX power-saving recovery history: `soft=,hard=`. | +| `get radio.rxps.rfrx_disabled` | Whether duty-cycle receive suppresses the radio's external RXEN output. Unsupported radios report an error. | +| `set radio.rxps.rfrx_disabled on` / `off` | Changes that RXEN policy for this boot. The radio must have a supported RXEN pin and admit a safe reconfiguration. | +| `get cad` / `get radio.cad` | Existing CAD scan/retry/deadline information, followed by `busy=` for busy-channel observations. | +| `clear stats` | Resets the same statistics as infrastructure roles: radio packet/error counts, mesh flood/direct packet counts, sticky mesh error flags, and table duplicate counters. | + +RXEN changes preserve the configured receive power-saving policy and timings. +A carrier/Morse transmission, beacon, BUSY interval, transmission, or unread +packet can reject the change. If receive restoration fails, the old RXEN policy +is restored for the normal bounded receive-retry path and the command reports +an error. The setting is not saved across reboot. + +`clear stats` retains watchdog recovery counts, airtime totals, receive activity +timestamps, queued packets, duplicate-detection history, and all saved settings. +Record a failure's statistics before clearing them; the command deliberately +clears the sticky error flags shown by `stats-core`. diff --git a/docs/companion_radio_full.md b/docs/companion_radio_full.md index d812d2136..4160dd8ae 100644 --- a/docs/companion_radio_full.md +++ b/docs/companion_radio_full.md @@ -16,6 +16,10 @@ For runtime on/off commands and board exceptions, see For practical on/off commands and update directions, see [Full Companion feature switches](full_companion_features.md). +Shared hardware commands include [power and watchdog diagnostics](companion_power_diagnostics.md), +[radio recovery and RXEN diagnostics](companion_radio_diagnostics.md), and +[SD card management on supported builds](test-results/companion_cli_parity_2026-10-23.md#shared-sd-commands). + `companion_radio_full` combines every qualified Companion transport for its platform into one artifact and acts as a host-backed LoRa mOTA source for updating other nodes. Most targets can run those transports simultaneously; diff --git a/docs/test-results/companion_cli_parity_2026-10-23.md b/docs/test-results/companion_cli_parity_2026-10-23.md new file mode 100644 index 000000000..f8da86b42 --- /dev/null +++ b/docs/test-results/companion_cli_parity_2026-10-23.md @@ -0,0 +1,87 @@ +# Companion hardware command parity, 2026-10-23 + +The audit baseline was `04c4fdb240318d0151947b8c11ce09a3e2aec65c`. +That revision already closed `get bootloader.ver`: Companion calls the same +board reader and preserves the bootloader's full version suffix. The follow-up +changes share the missing hardware diagnostics and SD command implementation. + +This records source behavior and native regression tests, not hardware +qualification or a claim that every board or firmware type was built. Existing +local/remote authorization and platform/backend capability gates remain in use. + +## Commands added after the audit + +| Command | Behavior and limits | Implementation | +| --- | --- | --- | +| `get pwrmgt.support` | Reports actual board/build power-management support. | [Power diagnostics](../../src/helpers/CompanionPowerDiagnostics.h#L28) | +| `get pwrmgt.source` | Reports external/battery power when supported. External power does not establish that USB data is connected. | [Power diagnostics](../../src/helpers/CompanionPowerDiagnostics.h#L33) | +| `get pwrmgt.bootmv` | Reports the boot voltage sample, distinct from current battery voltage in `stats-core`. | [Power diagnostics](../../src/helpers/CompanionPowerDiagnostics.h#L34) | +| `get system.watchdog` | Reports actual running state where supported. Companion does not pretend to have Repeater's persisted watchdog switch; its setter explicitly reports unavailable. | [Watchdog diagnostics](../../src/helpers/CompanionPowerDiagnostics.h#L46) | +| `get rxps.wd` | Reports the radio driver's separate soft/hard recovery counts. These are not included in `stats-radio-diag`. | [Radio diagnostics](../../src/helpers/CompanionRadioDiagnostics.h#L17) | +| `get/set radio.rxps.rfrx_disabled` | Reads/changes the supported radio's RF RX policy for this boot. Retains driver ownership and receiver-restoration checks; does not persist a preference. | [Radio diagnostics](../../src/helpers/CompanionRadioDiagnostics.h#L31) | +| `power` | Reports current battery voltage and supported USB, solar, external-power and charger states. | [Power diagnostics](../../src/helpers/CompanionPowerDiagnostics.h#L15) | +| `clear stats` | Resets radio/mesh packet counters, sticky packet error flags and table duplicate counters. RXPS recovery history, queues and saved settings remain intact. | [Radio diagnostics](../../src/helpers/CompanionRadioDiagnostics.h#L23) | +| `get cad` / `get radio.cad` | Retains timing details and appends the channel-busy count. | [CAD diagnostics](../../src/helpers/CompanionRadioDiagnostics.h#L70) | + +The watchdog setter requires more than an alias: Companion would need a saved +policy and feed-loop integration. An already running nRF52 watchdog cannot be +stopped directly; the existing +[disable path deliberately resets](../../src/helpers/NRF52Board.cpp#L328). + +## Shared SD commands + +All nRF52 builds that already enable `OTA_SD_STORE` use the same +[SD handler](../../src/helpers/SdCardCLI.cpp#L60) through +[CommonCLI](../../src/helpers/CommonCLI.cpp#L4033) or the Companion command router. +This adds no SD capability to unsupported boards. + +| Command | Meaning | +| --- | --- | +| `get sdcard` / `get sdcard *` | Format/erase history for this boot. Bare `get sdcard` now follows CommonCLI's history reply; use `free` for space. | +| `get sdcard free` | Used and free space. | +| `get sdcard ls [page]` / `get sdcard dir [page]` | Paginated file listing; page must be 1-65535. | +| `get sdcard format` / `get sdcard erase` | Time since the corresponding successful maintenance operation, or `never`. These getters do not modify the card. | +| `set sdcard format [--force]` | Formats the card. | +| `set sdcard erase [--force]` | Erases and then formats the card. | + +The setters preserve the existing five-minute per-action cooldown; `--force` +bypasses only that cooldown. Pending apply and unrelated occupied internal/NOR +update slots still block maintenance. The handler closes/checkpoints shared +archive work before card-wide changes and rebuilds the source view afterward +([guards and reset sequence](../../src/helpers/SdCardCLI.cpp#L99)). +RAK Ethernet ownership blocks space/list/maintenance access before allocating +an OTA context or changing update state +([bus exclusion](../../src/helpers/SdCardCLI.cpp#L22)); the card backend retains +its own bus and firmware-authorization checks. Failed context allocation or +card operations return an error without bypassing these checks. + +The former Ethernet-only Companion SD query branch was removed. Ethernet's +own enable/disable admission and lifecycle callbacks remain separate. + +## Existing commands and intentional distinctions + +Companion already had `get pwrmgt.bootreason` / `powerlog`, USB debug/logging, +USB watchdog commands, sleep diagnostics, storage layout, ADC calibration, +regulator controls, and core/radio/packet statistics. Shared helper dispatch +must be checked before treating an absent literal command name as a gap. +ESP32 CPU frequency and power-test commands are also shared and remain +platform-specific. + +`get/set radio.watchdog` is the +[Observer/MQTT inactivity policy](../../src/helpers/CommonCLI_Observer.cpp#L928), +not the universal nRF52 hardware watchdog. Repeater `get/set rx.watchdog` is a +separate [receive-inactivity policy](../../examples/simple_repeater/MyMesh.cpp#L13034). +Neither is copied into Companion without its underlying policy. + +## SD validation + +The shared production `.cpp` is compiled and executed under five native SD +profiles with address/undefined-behavior sanitizers. Tests cover malformed +commands, allocation failure, Ethernet ownership, card failures, bounded +replies, cooldown/clock wrap and history. Existing RAK seeder and context-handoff +suites also pass; the occupied-store matrix retains its negative regression +that detects destructive loss of an unrelated update. + +Relevant suites: [SD CLI parity](../../test/test_sdcard_cli_parity.py), +[RAK seeder](../../test/test_rak15002_sd_seeder.py), and +[SD context handoff](../../test/test_rak_sd_context_handoff.py). diff --git a/examples/companion_radio/MyMesh.cpp b/examples/companion_radio/MyMesh.cpp index a28ceccd3..3223fc684 100644 --- a/examples/companion_radio/MyMesh.cpp +++ b/examples/companion_radio/MyMesh.cpp @@ -22,6 +22,8 @@ #include "NotificationSettingsFile.h" #include "MyMesh.h" #include +#include +#include #include #include #include @@ -3613,9 +3615,8 @@ bool MyMesh::handleCadCommand(const char* command, char* reply, } else { snprintf(maximum, sizeof(maximum), "%u", (unsigned)_prefs.cad_max_duration_ms); } - snprintf(reply, reply_size, - "radio.cad %s, scan=%s ms, retry=%s ms, max=%s ms", - _prefs.cad_enabled ? "on" : "off", scan, retry, maximum); + mesh::companion::formatCadDiagnostics(reply, reply_size, + _prefs.cad_enabled != 0, scan, retry, maximum, board.n_cad_busy); return true; } @@ -3894,12 +3895,16 @@ bool MyMesh::handleLocalControlCommand(const char* command, char* reply, } #endif 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::cpuclock::handleCommand(board, _prefs.cpu_freq_mhz, _prefs.powersaving_enabled != 0, command, reply, reply_size, [this]() { return savePrefs(); })) return true; if (mesh::esp32::handlePowerTestCommand(command, reply, reply_size, true)) return true; #endif +#if defined(NRF52_PLATFORM) && defined(OTA_SD_STORE) + if (mesh::cli::handleSdCardCommand(_sdcard_cli, board, command, reply, reply_size)) return true; +#endif #if defined(OTA_RAK15002_SD_STORE) && defined(ETHERNET_USE_RAK13800) if (handleRak4631PeripheralCommand(command, reply, reply_size)) return true; #endif @@ -3948,6 +3953,12 @@ bool MyMesh::handleLocalControlCommand(const char* command, char* reply, if (handleCompanionBluetoothCommand(command, reply, reply_size)) return true; if (handleCompanionWirelessCommand(command, reply, reply_size)) return true; if (handleRxPowerSavingGetCommand(command, reply, reply_size)) return true; + if (mesh::companion::handleRadioDiagnosticsCommand( + radio_driver, _radio_available, command, reply, reply_size, [this]() { + radio_driver.resetStats(); + this->resetStats(); + static_cast(getTables())->resetStats(); + })) return true; if (strcmp(command, "get powersaving") == 0 || strcmp(command, "powersaving") == 0) { snprintf(reply, reply_size, "> %s", _prefs.powersaving_enabled ? "on" : "off"); @@ -10448,9 +10459,18 @@ void MyMesh::handleTerminalCommand(char* command, bool reader_prompt, #endif #if defined(OTA_RAK15002_SD_STORE) && defined(ETHERNET_USE_RAK13800) output.print( - " get eth / set eth on|off (physical RAK13800; default off, this boot)\r\n" - " get sdcard [free] / ota cache [on|off] (RAK15002; mutually exclusive with Ethernet)\r\n"); + " get eth / set eth on|off (physical RAK13800; default off, this boot)\r\n"); #endif +#if defined(NRF52_PLATFORM) && defined(OTA_SD_STORE) + output.print( + " 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"); +#endif + output.print( + " power / get pwrmgt.support / get pwrmgt.source / get pwrmgt.bootmv\r\n" + " get system.watchdog (hardware status)\r\n" + " get bootloader.ver\r\n"); output.print( " get pwrmgt.bootreason\r\n" #if COMPANION_FEATURE_MEMORY_DIAGNOSTICS @@ -10548,6 +10568,10 @@ void MyMesh::handleTerminalCommand(char* command, bool reader_prompt, " get/set radio.cad \r\n" " set radio.cad timings \r\n" " get/set radio.rxgain \r\n"); + output.print( + " get rxps.wd\r\n" + " get/set radio.rxps.rfrx_disabled (this boot)\r\n" + " clear stats\r\n"); output.print( " get/set radio.fem.rxgain \r\n" " get/set radio.fem.txgain \r\n" diff --git a/examples/companion_radio/MyMesh.h b/examples/companion_radio/MyMesh.h index d4792b491..c325b1e4d 100644 --- a/examples/companion_radio/MyMesh.h +++ b/examples/companion_radio/MyMesh.h @@ -69,6 +69,9 @@ namespace mesh { class BeaconManager; } #include #include #include +#if defined(NRF52_PLATFORM) && defined(OTA_SD_STORE) +#include +#endif #if defined(OTA_SHARED_COMPANION_QUEUE) #include #include @@ -777,6 +780,9 @@ private: DataStore* _store; mesh::RadioProfileCLI _radio_profiles; +#if defined(NRF52_PLATFORM) && defined(OTA_SD_STORE) + mesh::cli::SdCardCLIState _sdcard_cli; +#endif mesh::MorseReplyHandoff _morse_reply; bool _morse_reply_admission = false; #if MESH_ENABLE_MESHTEXT_CHAT diff --git a/examples/companion_radio/main.cpp b/examples/companion_radio/main.cpp index ba30e8fce..72edc46a1 100644 --- a/examples/companion_radio/main.cpp +++ b/examples/companion_radio/main.cpp @@ -664,21 +664,6 @@ bool handleRak4631PeripheralCommand(const char* command, char* reply, size_t cap } return true; } - if (strcmp(command, "get sdcard") == 0 || strcmp(command, "get sdcard free") == 0) { - if (ethernet_interface.hardwareActive()) { - snprintf(reply, capacity, "Error: turn Ethernet off before accessing SD"); - } else if (mesh::ota::ota_acquire_context(reply, capacity)) { - uint64_t used = 0, free = 0; - auto& card = mesh::ota::ota_ctx().sdStagingStore(); - if (card.getSpace(board, used, free)) { - snprintf(reply, capacity, "> SD used=%llu free=%llu bytes; ota cache for archive status", - (unsigned long long)used, (unsigned long long)free); - } else { - snprintf(reply, capacity, "Error: %s", card.last_error()); - } - } - return true; - } return false; } #endif diff --git a/src/MeshCore.h b/src/MeshCore.h index 9c96eeed0..3ea29e65d 100644 --- a/src/MeshCore.h +++ b/src/MeshCore.h @@ -195,6 +195,12 @@ public: // internally bounded operations can use this while retaining their own // timeout. Boards without an explicit watchdog need no implementation. virtual void serviceWatchdog() { /* no op */ } + // Query the actual hardware state without starting, feeding or stopping a + // watchdog. False means this board has no shared runtime status reader. + virtual bool getSystemWatchdogRunning(bool& running) const { + (void)running; + return false; + } virtual void enterDeepSleep(uint32_t secs) { (void)secs; } // Called by example setup() functions to signal that boot is complete. // Boards may override to stop a boot-indicator LED sequence or similar. @@ -298,6 +304,7 @@ public: #endif // Power management interface (boards with power management override these) + virtual bool supportsPowerManagement() const { return false; } virtual bool isPwrMgtInitialised() const { return false; } virtual bool isExternalPowered() { return false; } virtual bool isUsbDataConnected() { return false; } diff --git a/src/helpers/CommonCLI.cpp b/src/helpers/CommonCLI.cpp index b8aae3878..f5cb18e7a 100644 --- a/src/helpers/CommonCLI.cpp +++ b/src/helpers/CommonCLI.cpp @@ -98,36 +98,6 @@ #define RECENT_REPEATER_PREFIX_MAX_BYTES 3 -#if defined(NRF52_PLATFORM) && defined(OTA_SD_STORE) -static const uint32_t SDCARD_ACTION_COOLDOWN_MILLIS = 5UL * 60UL * 1000UL; - -static void formatSdCardElapsed(char* out, size_t out_len, bool recorded, - uint32_t ran_at, uint32_t now) { - if (!recorded) { - snprintf(out, out_len, "never"); - return; - } - - const uint32_t elapsed_secs = (now - ran_at) / 1000UL; - if (elapsed_secs < 60UL) { - snprintf(out, out_len, "%lus ago", (unsigned long)elapsed_secs); - } else if (elapsed_secs < 60UL * 60UL) { - snprintf(out, out_len, "%lum %lus ago", - (unsigned long)(elapsed_secs / 60UL), - (unsigned long)(elapsed_secs % 60UL)); - } else if (elapsed_secs < 24UL * 60UL * 60UL) { - snprintf(out, out_len, "%luh %lum ago", - (unsigned long)(elapsed_secs / 3600UL), - (unsigned long)((elapsed_secs / 60UL) % 60UL)); - } else { - snprintf(out, out_len, "%lud %luh ago", - (unsigned long)(elapsed_secs / 86400UL), - (unsigned long)((elapsed_secs / 3600UL) % 24UL)); - } -} - -#endif - #include // Believe it or not, this std C function is busted on some platforms! @@ -4061,194 +4031,11 @@ void CommonCLI::handleCommand(uint32_t sender_timestamp, char* command, char* re #if defined(NRF52_PLATFORM) && defined(OTA_SD_STORE) bool CommonCLI::handleSdCardSetCmd(const char* config, char* reply) { - if (strncmp(config, "sdcard", 6) != 0 || - (config[6] != 0 && config[6] != ' ')) { - return false; - } - - const char* action_args = skipSpacesConst(config + 6); - bool is_format; - bool force = false; - if (strcmp(action_args, "format") == 0) { - is_format = true; - } else if (strcmp(action_args, "format --force") == 0) { - is_format = true; - force = true; - } else if (strcmp(action_args, "erase") == 0) { - is_format = false; - } else if (strcmp(action_args, "erase --force") == 0) { - is_format = false; - force = true; - } else { - mesh::cli::formatError(reply, 160, "use set sdcard format|erase [--force]"); - return true; - } - - const bool recorded = is_format ? _sdcard_format_recorded : _sdcard_erase_recorded; - const uint32_t ran_at = is_format ? _sdcard_format_at : _sdcard_erase_at; - const char* action = is_format ? "format" : "erase"; - const uint32_t now = millis(); - if (!force && recorded && now - ran_at < SDCARD_ACTION_COOLDOWN_MILLIS) { - char age[32]; - formatSdCardElapsed(age, sizeof(age), true, ran_at, now); - snprintf(reply, 160, - "Error: SD card %s ran %s; use set sdcard %s --force", - action, age, action); - return true; - } - - // Heap-backed source-only builds may not have an OTA workspace yet. Never - // dereference it (or start card maintenance) after an allocation failure. - if (!mesh::ota::ota_acquire_context(reply, 160)) return true; - mesh::ota::OtaContext& context = mesh::ota::ota_ctx(); - if (context.apply_pending) { - mesh::cli::formatError(reply, 160, "OTA apply is pending"); - return true; - } -#if defined(OTA_TOWER_AUTO_STORE) - // Card maintenance must never abandon a fallback update stored internally. - if (context.fetch_store.usesInternal() && - (context.manager.fetchState() != mesh::ota::OtaManager::IDLE || - context.fetch_store.staged_size() != 0)) { - mesh::cli::formatError(reply, 160, "internal OTA slot is occupied; use ota cancel first"); - return true; - } -#elif defined(OTA_RAK15002_SD_STORE) && !defined(OTA_SEEDER_ONLY) - // The optional card is independent of an internal/NOR application fetch - // or an internal bootloader package. Formatting it must not reset that - // unrelated receive session or strand its still-pinned staging bytes. - if (!context.fetch_store.usesSd() && - (context.manager.fetchState() != mesh::ota::OtaManager::IDLE || - context.fetch_store.staged_size() != 0)) { - mesh::cli::formatError(reply, 160, "non-SD OTA slot is occupied; use ota cancel first"); - return true; - } -#endif - - mesh::ota::OtaStoreSdNrf52& card = context.sdStagingStore(); - - // A card-wide destructive operation invalidates the staged SD fetch. First - // let an archive capture checkpoint and close its shared card handles. - context.prepareSdCardReset(); - context.manager.reset_session(); - context.manager.want(0); - context.manager.want_mid(nullptr); - context.session_started_ms = 0; - context.prev_fstate = mesh::ota::OtaManager::IDLE; - - if (is_format) { - if (!card.formatCard(*_board)) { - char error[80]; - strncpy(error, card.last_error(), sizeof(error) - 1); - error[sizeof(error) - 1] = 0; - context.finishSdCardReset(millis()); - snprintf(reply, 160, "Error: SD card format failed: %s", - error); - return true; - } - context.finishSdCardReset(millis()); - _sdcard_format_recorded = true; - _sdcard_format_at = millis(); - strcpy(reply, "OK - SD card format complete"); - return true; - } - - if (!card.eraseCard(*_board)) { - char error[80]; - strncpy(error, card.last_error(), sizeof(error) - 1); - error[sizeof(error) - 1] = 0; - context.finishSdCardReset(millis()); - snprintf(reply, 160, "Error: SD card erase failed: %s", - error); - return true; - } - _sdcard_erase_recorded = true; - _sdcard_erase_at = millis(); - - if (!card.formatCard(*_board)) { - char error[80]; - strncpy(error, card.last_error(), sizeof(error) - 1); - error[sizeof(error) - 1] = 0; - context.finishSdCardReset(millis()); - snprintf(reply, 160, "Error: SD card erased but format failed: %s", - error); - return true; - } - _sdcard_format_recorded = true; - _sdcard_format_at = millis(); - context.finishSdCardReset(millis()); - strcpy(reply, "OK - SD card erase and format complete"); - return true; + return mesh::cli::handleSdCardSet(_sdcard_cli, *_board, config, reply, 160); } bool CommonCLI::handleSdCardGetCmd(const char* config, char* reply) { - if (strncmp(config, "sdcard", 6) != 0 || - (config[6] != 0 && config[6] != ' ')) { - return false; - } - - const char* query = skipSpacesConst(config + 6); - const uint32_t now = millis(); - if (*query == 0 || strcmp(query, "*") == 0) { - char format_age[32]; - char erase_age[32]; - formatSdCardElapsed(format_age, sizeof(format_age), - _sdcard_format_recorded, _sdcard_format_at, now); - formatSdCardElapsed(erase_age, sizeof(erase_age), - _sdcard_erase_recorded, _sdcard_erase_at, now); - snprintf(reply, 160, "> format: %s, erase: %s", format_age, erase_age); - } else if (strcmp(query, "format") == 0) { - char age[32]; - formatSdCardElapsed(age, sizeof(age), _sdcard_format_recorded, - _sdcard_format_at, now); - snprintf(reply, 160, "> format: %s", age); - } else if (strcmp(query, "erase") == 0) { - char age[32]; - formatSdCardElapsed(age, sizeof(age), _sdcard_erase_recorded, - _sdcard_erase_at, now); - snprintf(reply, 160, "> erase: %s", age); - } else if (strcmp(query, "free") == 0) { - if (!mesh::ota::ota_acquire_context(reply, 160)) return true; - uint64_t used_bytes = 0; - uint64_t free_bytes = 0; - mesh::ota::OtaStoreSdNrf52& store = mesh::ota::ota_ctx().sdStagingStore(); - if (!store.getSpace(*_board, used_bytes, free_bytes)) { - snprintf(reply, 160, "Error: SD card space query failed: %s", - store.last_error()); - } else { - char used[24]; - char free[24]; - mesh::cli::formatStorageBytes(used, sizeof(used), used_bytes); - mesh::cli::formatStorageBytes(free, sizeof(free), free_bytes); - snprintf(reply, 160, "> used: %s, free: %s", used, free); - } - } else if (strncmp(query, "ls", 2) == 0 || strncmp(query, "dir", 3) == 0) { - const bool is_ls = strncmp(query, "ls", 2) == 0; - const size_t word_len = is_ls ? 2 : 3; - if (query[word_len] != 0 && query[word_len] != ' ') { - mesh::cli::formatError(reply, 160, "use get sdcard ls|dir [page]"); - return true; - } - const char* page_arg = skipSpacesConst(query + word_len); - uint32_t page = 1; - if (*page_arg) { - char* end = nullptr; - unsigned long parsed = strtoul(page_arg, &end, 10); - if (parsed == 0 || parsed > 65535 || !end || *skipSpacesConst(end) != 0) { - mesh::cli::formatError(reply, 160, "use get sdcard ls|dir [page]"); - return true; - } - page = parsed; - } - if (!mesh::ota::ota_acquire_context(reply, 160)) return true; - mesh::ota::OtaStoreSdNrf52& store = mesh::ota::ota_ctx().sdStagingStore(); - if (!store.listFiles(*_board, (uint16_t)page, reply, 160)) { - snprintf(reply, 160, "Error: SD card list failed: %s", store.last_error()); - } - } else { - mesh::cli::formatError(reply, 160, "use get sdcard [format|erase|free|ls|dir [page]|*]"); - } - return true; + return mesh::cli::handleSdCardGet(_sdcard_cli, *_board, config, reply, 160); } #endif diff --git a/src/helpers/CommonCLI.h b/src/helpers/CommonCLI.h index 9212c44ec..5082a63bd 100644 --- a/src/helpers/CommonCLI.h +++ b/src/helpers/CommonCLI.h @@ -18,6 +18,9 @@ #include #include #include +#if defined(NRF52_PLATFORM) && defined(OTA_SD_STORE) +#include +#endif #if defined(WITH_UDP_BRIDGE) #include #endif @@ -905,10 +908,7 @@ class CommonCLI { ClientACL* _acl; char tmp[COMMON_CLI_TMP_LEN]; #if defined(NRF52_PLATFORM) && defined(OTA_SD_STORE) - bool _sdcard_format_recorded = false; - bool _sdcard_erase_recorded = false; - uint32_t _sdcard_format_at = 0; - uint32_t _sdcard_erase_at = 0; + mesh::cli::SdCardCLIState _sdcard_cli; #endif #ifdef WITH_MQTT_BRIDGE MQTTPrefs _mqtt_prefs; diff --git a/src/helpers/CompanionPowerDiagnostics.h b/src/helpers/CompanionPowerDiagnostics.h new file mode 100644 index 000000000..e077ab082 --- /dev/null +++ b/src/helpers/CompanionPowerDiagnostics.h @@ -0,0 +1,69 @@ +#pragma once + +#include +#include +#include +#include + +namespace mesh { namespace companion { + +// Read-only board diagnostics shared by every Companion transport. A capability +// reply describes the board/build, independently of whether boot setup ran. +template +bool handlePowerDiagnosticsCommand(Board& board, const char* command, + char* reply, size_t capacity) { + if (!command || !reply || !capacity) return false; + if (strcmp(command, "power") == 0) { + const char* usb = board.hasUsbPowerDetect() + ? (board.isUsbPowered() ? "yes" : "no") : "n/a"; + const char* solar = board.hasSolarChargerDetect() + ? (board.isSolarChargerActive() ? "yes" : "no") : "n/a"; + snprintf(reply, capacity, + "batt:%u mV usb:%s solar_chg:%s ext:%s charger:%s", + (unsigned)board.getBattMilliVolts(), usb, solar, + board.isExternalPowered() ? "yes" : "no", + board.isChargerActive() ? "yes" : "no"); + return true; + } + if (strcmp(command, "get pwrmgt.support") == 0) { + snprintf(reply, capacity, "> %s", + board.supportsPowerManagement() ? "supported" : "unsupported"); + return true; + } + const bool source = strcmp(command, "get pwrmgt.source") == 0; + const bool boot_voltage = strcmp(command, "get pwrmgt.bootmv") == 0; + if (source || boot_voltage) { + if (!board.supportsPowerManagement()) { + snprintf(reply, capacity, "ERROR: Power management not supported"); + } else if (source) { + snprintf(reply, capacity, "> %s", + board.isExternalPowered() ? "external" : "battery"); + } else { + snprintf(reply, capacity, "> %u mV", (unsigned)board.getBootVoltage()); + } + return true; + } + if (strcmp(command, "get system.watchdog") == 0) { + bool running = false; + if (!board.getSystemWatchdogRunning(running)) { + cli::formatError(reply, capacity, "unsupported on this platform"); + } else { + snprintf(reply, capacity, "> %s", running ? "on" : "off"); + } + return true; + } + static const char setter[] = "set system.watchdog"; + if (strncmp(command, setter, sizeof(setter) - 1) == 0 + && (command[sizeof(setter) - 1] == 0 + || command[sizeof(setter) - 1] == ' ' + || command[sizeof(setter) - 1] == '\t')) { + // Companion has no persisted watchdog preference/feed-loop policy. In + // particular, claiming "off" without that policy would cause reset loops. + cli::formatError(reply, capacity, + "system watchdog control is unavailable in Companion"); + return true; + } + return false; +} + +} } // namespace mesh::companion diff --git a/src/helpers/CompanionRadioDiagnostics.h b/src/helpers/CompanionRadioDiagnostics.h new file mode 100644 index 000000000..87afac8fd --- /dev/null +++ b/src/helpers/CompanionRadioDiagnostics.h @@ -0,0 +1,73 @@ +#pragma once + +#include "CLICommandUtils.h" +#include +#include +#include + +namespace mesh { namespace companion { + +// The radio owns RXEN admission and receiver restoration. This command adapter +// does not stop RX, change preferences, or bypass an unread packet/other owner. +template +bool handleRadioDiagnosticsCommand(Driver& driver, bool radio_available, + const char* command, char* reply, + size_t capacity, ClearStats clear_stats) { + if (!command || !reply || capacity == 0) return false; + if (strcmp(command, "get rxps.wd") == 0) { + snprintf(reply, capacity, "> soft=%lu,hard=%lu", + (unsigned long)driver.getRxPsWatchdogSoftCount(), + (unsigned long)driver.getRxPsWatchdogHardCount()); + return true; + } + if (strcmp(command, "clear stats") == 0) { + // Match infrastructure: radio packet counts, Dispatcher packet counts and + // sticky error flags, and table duplicate counters. Watchdog history, + // receive timestamps, queues, and duplicate-detection entries are retained. + clear_stats(); + snprintf(reply, capacity, "(OK - stats reset)"); + return true; + } + if (strcmp(command, "get radio.rxps.rfrx_disabled") == 0) { + if (!driver.supportsRxPowerSavingRfRxDisable()) { + cli::formatError(reply, capacity, "unsupported"); + } else if (!radio_available) { + cli::formatError(reply, capacity, "radio unavailable"); + } else { + snprintf(reply, capacity, "> %s", + driver.isRxPowerSavingRfRxDisabled() ? "on" : "off"); + } + return true; + } + static const char set_key[] = "set radio.rxps.rfrx_disabled"; + if (strncmp(command, set_key, sizeof(set_key) - 1) != 0 + || (command[sizeof(set_key) - 1] != 0 + && command[sizeof(set_key) - 1] != ' ' + && command[sizeof(set_key) - 1] != '\t')) return false; + const char* value = command + sizeof(set_key) - 1; + if (*value == ' ') ++value; + if (strcmp(value, "on") != 0 && strcmp(value, "off") != 0) { + cli::formatError(reply, capacity, "use set radio.rxps.rfrx_disabled on|off"); + } else if (!driver.supportsRxPowerSavingRfRxDisable()) { + cli::formatError(reply, capacity, "unsupported"); + } else if (!radio_available) { + cli::formatError(reply, capacity, "radio unavailable"); + } else if (!driver.setRxPowerSavingRfRxDisabled(strcmp(value, "on") == 0)) { + cli::formatError(reply, capacity, "radio busy or RXEN change failed"); + } else { + snprintf(reply, capacity, "OK - radio.rxps.rfrx_disabled %s", value); + } + return true; +} + +inline void formatCadDiagnostics(char* reply, size_t capacity, bool enabled, + const char* scan, const char* retry, + const char* maximum, uint32_t busy_count) { + if (!reply || capacity == 0) return; + snprintf(reply, capacity, + "radio.cad %s, scan=%s ms, retry=%s ms, max=%s ms, busy=%lu", + enabled ? "on" : "off", scan, retry, maximum, + (unsigned long)busy_count); +} + +} } // namespace mesh::companion diff --git a/src/helpers/NRF52Board.cpp b/src/helpers/NRF52Board.cpp index e6192359b..b8a24bece 100644 --- a/src/helpers/NRF52Board.cpp +++ b/src/helpers/NRF52Board.cpp @@ -370,6 +370,13 @@ void NRF52Board::serviceWatchdog() { #endif } +bool NRF52Board::getSystemWatchdogRunning(bool& running) const { + // Read even if this build's feed policy is disabled: a bootloader can leave + // a watchdog running. The query must describe the hardware, not a preference. + running = NRF_WDT->RUNSTATUS != 0; + return true; +} + #ifdef NRF52_POWER_MANAGEMENT // Power Management global variables uint32_t g_nrf52_reset_reason = 0; // Reset/Startup reason diff --git a/src/helpers/NRF52Board.h b/src/helpers/NRF52Board.h index 1fecbdda1..68f4a2531 100644 --- a/src/helpers/NRF52Board.h +++ b/src/helpers/NRF52Board.h @@ -68,6 +68,7 @@ public: // and be reset automatically. void feedWatchdog(bool enabled = true); void serviceWatchdog() override; + bool getSystemWatchdogRunning(bool& running) const override; virtual uint8_t getStartupReason() const override { return startup_reason; } virtual float getMCUTemperature() override; void reboot() override; @@ -110,6 +111,7 @@ public: void attachDynamicPrefs(KeyValueStore* prefs) { (void)prefs; } // no-op #ifdef NRF52_POWER_MANAGEMENT + bool supportsPowerManagement() const override { return true; } uint16_t getBootVoltage() override { return boot_voltage_mv; } virtual uint32_t getResetReason() const override { return reset_reason; } uint8_t getShutdownReason() const override { return shutdown_reason; } diff --git a/src/helpers/SdCardCLI.cpp b/src/helpers/SdCardCLI.cpp new file mode 100644 index 000000000..743a60ee1 --- /dev/null +++ b/src/helpers/SdCardCLI.cpp @@ -0,0 +1,271 @@ +#include "SdCardCLI.h" + +#if defined(NRF52_PLATFORM) && defined(OTA_SD_STORE) +#include +#include +#include +#include +#include +#include +#include + +namespace mesh { +namespace cli { + +static const uint32_t SDCARD_ACTION_COOLDOWN_MILLIS = 5UL * 60UL * 1000UL; + +static const char* skipSpacesConst(const char* value) { + while (*value == ' ' || *value == '\t') ++value; + return value; +} + +static bool sdCardBusAvailable(char* reply, size_t capacity) { +#if defined(OTA_RAK15002_SD_STORE) + if (mesh::ota::rak4631_ethernet_owns_spi()) { + mesh::cli::formatError(reply, capacity, "turn Ethernet off before accessing SD"); + return false; + } +#else + (void)reply; + (void)capacity; +#endif + return true; +} + +static void formatSdCardElapsed(char* out, size_t out_len, bool recorded, + uint32_t ran_at, uint32_t now) { + if (!recorded) { + snprintf(out, out_len, "never"); + return; + } + + const uint32_t elapsed_secs = (now - ran_at) / 1000UL; + if (elapsed_secs < 60UL) { + snprintf(out, out_len, "%lus ago", (unsigned long)elapsed_secs); + } else if (elapsed_secs < 60UL * 60UL) { + snprintf(out, out_len, "%lum %lus ago", + (unsigned long)(elapsed_secs / 60UL), + (unsigned long)(elapsed_secs % 60UL)); + } else if (elapsed_secs < 24UL * 60UL * 60UL) { + snprintf(out, out_len, "%luh %lum ago", + (unsigned long)(elapsed_secs / 3600UL), + (unsigned long)((elapsed_secs / 60UL) % 60UL)); + } else { + snprintf(out, out_len, "%lud %luh ago", + (unsigned long)(elapsed_secs / 86400UL), + (unsigned long)((elapsed_secs / 3600UL) % 24UL)); + } +} + +bool handleSdCardSet(SdCardCLIState& state, MainBoard& board, + const char* config, char* reply, size_t capacity) { + if (!config || !reply || capacity == 0 + || strncmp(config, "sdcard", 6) != 0 || + (config[6] != 0 && config[6] != ' ')) { + return false; + } + + const char* action_args = skipSpacesConst(config + 6); + bool is_format; + bool force = false; + if (strcmp(action_args, "format") == 0) { + is_format = true; + } else if (strcmp(action_args, "format --force") == 0) { + is_format = true; + force = true; + } else if (strcmp(action_args, "erase") == 0) { + is_format = false; + } else if (strcmp(action_args, "erase --force") == 0) { + is_format = false; + force = true; + } else { + mesh::cli::formatError(reply, capacity, "use set sdcard format|erase [--force]"); + return true; + } + + const bool recorded = is_format ? state.format_recorded : state.erase_recorded; + const uint32_t ran_at = is_format ? state.format_at : state.erase_at; + const char* action = is_format ? "format" : "erase"; + const uint32_t now = millis(); + if (!force && recorded && now - ran_at < SDCARD_ACTION_COOLDOWN_MILLIS) { + char age[32]; + formatSdCardElapsed(age, sizeof(age), true, ran_at, now); + snprintf(reply, capacity, + "Error: SD card %s ran %s; use set sdcard %s --force", + action, age, action); + return true; + } + + if (!sdCardBusAvailable(reply, capacity)) return true; + + // Heap-backed source-only builds may not have an OTA workspace yet. Never + // dereference it (or start card maintenance) after an allocation failure. + if (!mesh::ota::ota_acquire_context(reply, capacity)) return true; + mesh::ota::OtaContext& context = mesh::ota::ota_ctx(); + if (context.apply_pending) { + mesh::cli::formatError(reply, capacity, "OTA apply is pending"); + return true; + } +#if defined(OTA_TOWER_AUTO_STORE) + // Card maintenance must never abandon a fallback update stored internally. + if (context.fetch_store.usesInternal() && + (context.manager.fetchState() != mesh::ota::OtaManager::IDLE || + context.fetch_store.staged_size() != 0)) { + mesh::cli::formatError(reply, capacity, "internal OTA slot is occupied; use ota cancel first"); + return true; + } +#elif defined(OTA_RAK15002_SD_STORE) && !defined(OTA_SEEDER_ONLY) + // The optional card is independent of an internal/NOR application fetch + // or an internal bootloader package. Formatting it must not reset that + // unrelated receive session or strand its still-pinned staging bytes. + if (!context.fetch_store.usesSd() && + (context.manager.fetchState() != mesh::ota::OtaManager::IDLE || + context.fetch_store.staged_size() != 0)) { + mesh::cli::formatError(reply, capacity, "non-SD OTA slot is occupied; use ota cancel first"); + return true; + } +#endif + + mesh::ota::OtaStoreSdNrf52& card = context.sdStagingStore(); + + // A card-wide destructive operation invalidates the staged SD fetch. First + // let an archive capture checkpoint and close its shared card handles. + context.prepareSdCardReset(); + context.manager.reset_session(); + context.manager.want(0); + context.manager.want_mid(nullptr); + context.session_started_ms = 0; + context.prev_fstate = mesh::ota::OtaManager::IDLE; + + if (is_format) { + if (!card.formatCard(board)) { + char error[80]; + strncpy(error, card.last_error(), sizeof(error) - 1); + error[sizeof(error) - 1] = 0; + context.finishSdCardReset(millis()); + snprintf(reply, capacity, "Error: SD card format failed: %s", + error); + return true; + } + context.finishSdCardReset(millis()); + state.format_recorded = true; + state.format_at = millis(); + snprintf(reply, capacity, "OK - SD card format complete"); + return true; + } + + if (!card.eraseCard(board)) { + char error[80]; + strncpy(error, card.last_error(), sizeof(error) - 1); + error[sizeof(error) - 1] = 0; + context.finishSdCardReset(millis()); + snprintf(reply, capacity, "Error: SD card erase failed: %s", + error); + return true; + } + state.erase_recorded = true; + state.erase_at = millis(); + + if (!card.formatCard(board)) { + char error[80]; + strncpy(error, card.last_error(), sizeof(error) - 1); + error[sizeof(error) - 1] = 0; + context.finishSdCardReset(millis()); + snprintf(reply, capacity, "Error: SD card erased but format failed: %s", + error); + return true; + } + state.format_recorded = true; + state.format_at = millis(); + context.finishSdCardReset(millis()); + snprintf(reply, capacity, "OK - SD card erase and format complete"); + return true; +} + +bool handleSdCardGet(SdCardCLIState& state, MainBoard& board, + const char* config, char* reply, size_t capacity) { + if (!config || !reply || capacity == 0 + || strncmp(config, "sdcard", 6) != 0 || + (config[6] != 0 && config[6] != ' ')) { + return false; + } + + const char* query = skipSpacesConst(config + 6); + const uint32_t now = millis(); + if (*query == 0 || strcmp(query, "*") == 0) { + char format_age[32]; + char erase_age[32]; + formatSdCardElapsed(format_age, sizeof(format_age), + state.format_recorded, state.format_at, now); + formatSdCardElapsed(erase_age, sizeof(erase_age), + state.erase_recorded, state.erase_at, now); + snprintf(reply, capacity, "> format: %s, erase: %s", format_age, erase_age); + } else if (strcmp(query, "format") == 0) { + char age[32]; + formatSdCardElapsed(age, sizeof(age), state.format_recorded, + state.format_at, now); + snprintf(reply, capacity, "> format: %s", age); + } else if (strcmp(query, "erase") == 0) { + char age[32]; + formatSdCardElapsed(age, sizeof(age), state.erase_recorded, + state.erase_at, now); + snprintf(reply, capacity, "> erase: %s", age); + } else if (strcmp(query, "free") == 0) { + if (!sdCardBusAvailable(reply, capacity)) return true; + if (!mesh::ota::ota_acquire_context(reply, capacity)) return true; + uint64_t used_bytes = 0; + uint64_t free_bytes = 0; + mesh::ota::OtaStoreSdNrf52& store = mesh::ota::ota_ctx().sdStagingStore(); + if (!store.getSpace(board, used_bytes, free_bytes)) { + snprintf(reply, capacity, "Error: SD card space query failed: %s", + store.last_error()); + } else { + char used[24]; + char free[24]; + mesh::cli::formatStorageBytes(used, sizeof(used), used_bytes); + mesh::cli::formatStorageBytes(free, sizeof(free), free_bytes); + snprintf(reply, capacity, "> used: %s, free: %s", used, free); + } + } else if (strncmp(query, "ls", 2) == 0 || strncmp(query, "dir", 3) == 0) { + const bool is_ls = strncmp(query, "ls", 2) == 0; + const size_t word_len = is_ls ? 2 : 3; + if (query[word_len] != 0 && query[word_len] != ' ') { + mesh::cli::formatError(reply, capacity, "use get sdcard ls|dir [page]"); + return true; + } + const char* page_arg = skipSpacesConst(query + word_len); + uint32_t page = 1; + if (*page_arg) { + char* end = nullptr; + unsigned long parsed = strtoul(page_arg, &end, 10); + if (parsed == 0 || parsed > 65535 || !end || *skipSpacesConst(end) != 0) { + mesh::cli::formatError(reply, capacity, "use get sdcard ls|dir [page]"); + return true; + } + page = parsed; + } + if (!sdCardBusAvailable(reply, capacity)) return true; + if (!mesh::ota::ota_acquire_context(reply, capacity)) return true; + mesh::ota::OtaStoreSdNrf52& store = mesh::ota::ota_ctx().sdStagingStore(); + if (!store.listFiles(board, (uint16_t)page, reply, capacity)) { + snprintf(reply, capacity, "Error: SD card list failed: %s", store.last_error()); + } + } else { + mesh::cli::formatError(reply, capacity, "use get sdcard [format|erase|free|ls|dir [page]|*]"); + } + return true; +} + +bool handleSdCardCommand(SdCardCLIState& state, MainBoard& board, + const char* command, char* reply, size_t capacity) { + if (!command) return false; + if (strncmp(command, "get ", 4) == 0) + return handleSdCardGet(state, board, command + 4, reply, capacity); + if (strncmp(command, "set ", 4) == 0) + return handleSdCardSet(state, board, command + 4, reply, capacity); + return false; +} + +} // namespace cli +} // namespace mesh +#endif diff --git a/src/helpers/SdCardCLI.h b/src/helpers/SdCardCLI.h new file mode 100644 index 000000000..d79d38b7b --- /dev/null +++ b/src/helpers/SdCardCLI.h @@ -0,0 +1,30 @@ +#pragma once + +#include +#include + +namespace mesh { +class MainBoard; +namespace cli { + +// Per-CLI, per-boot history. It is not stored in node preferences or on SD. +struct SdCardCLIState { + bool format_recorded = false; + bool erase_recorded = false; + uint32_t format_at = 0; + uint32_t erase_at = 0; +}; + +#if defined(NRF52_PLATFORM) && defined(OTA_SD_STORE) +// The role must perform its normal local/remote authorization before dispatch. +// config begins with "sdcard"; command begins with "get " or "set ". +bool handleSdCardSet(SdCardCLIState& state, MainBoard& board, + const char* config, char* reply, size_t capacity); +bool handleSdCardGet(SdCardCLIState& state, MainBoard& board, + const char* config, char* reply, size_t capacity); +bool handleSdCardCommand(SdCardCLIState& state, MainBoard& board, + const char* command, char* reply, size_t capacity); +#endif + +} // namespace cli +} // namespace mesh diff --git a/src/helpers/radiolib/CustomSX1262Wrapper.h b/src/helpers/radiolib/CustomSX1262Wrapper.h index fa2ecb1c5..9dba1d1b1 100644 --- a/src/helpers/radiolib/CustomSX1262Wrapper.h +++ b/src/helpers/radiolib/CustomSX1262Wrapper.h @@ -202,12 +202,21 @@ public: if (isCarrierWaveActive()) return false; if (!supportsRxPowerSavingRfRxDisable()) return false; - if (_rx_ps_armed) stopReceiveDutyCycle(); - ((CustomSX1262 *)_radio)->setRxPowerSavingRfRxDisabled(disabled); + // Do not stop duty-cycle RX or change its policy before admission. A BUSY + // sleep window, TX, or unread RX completion still owns the current mode. + const uint8_t resume_rx = beginReconfigure(); + if (resume_rx > 1) return false; - // Re-arm the configured receive mode immediately without disturbing an - // unread RX interrupt or an in-flight transmission. - return setRxPowerSaving(_rx_ps_enabled, _rx_ps_rx_us, _rx_ps_sleep_us); + const bool previous = isRxPowerSavingRfRxDisabled(); + ((CustomSX1262 *)_radio)->setRxPowerSavingRfRxDisabled(disabled); + endReconfigure(resume_rx); + if (resume_rx && !isInRecvMode()) { + // startRecv already retained its bounded retry/backoff. That retry must + // use the old RXEN policy; never report a failed RX restoration as OK. + ((CustomSX1262 *)_radio)->setRxPowerSavingRfRxDisabled(previous); + return false; + } + return true; } bool isRxPowerSavingRfRxDisabled() const override { diff --git a/test/fixtures/companion_radio_diagnostics/test.cpp b/test/fixtures/companion_radio_diagnostics/test.cpp new file mode 100644 index 000000000..c1cd80537 --- /dev/null +++ b/test/fixtures/companion_radio_diagnostics/test.cpp @@ -0,0 +1,218 @@ +#include +#include +#include +#include +#include +#include +#include +#include + +constexpr int RADIOLIB_NC=-1; +constexpr int RADIOLIB_ERR_NONE=0; +constexpr uint8_t STATE_IDLE=0,STATE_RX=1,STATE_TX_WAIT=3; +struct CustomSX1262 { + bool disabled=false; + bool pending=false; + bool irq_during_standby=false; + int standby_result=RADIOLIB_ERR_NONE; + unsigned standby_calls=0,flag_writes=0; + int standby() {++standby_calls;if(irq_during_standby)pending=true;return standby_result;} + void setRxPowerSavingRfRxDisabled(bool value) {++flag_writes;disabled=value;} + bool isRxPowerSavingRfRxDisabled() const {return disabled;} +}; +class RadioLibWrapper { +public: + CustomSX1262 physical; + CustomSX1262* _radio=&physical; + uint8_t state=STATE_RX; + bool _profile_repair_required=false,_profile_refresh_required=false; + bool _beacon_owned=false,_beacon_restoring=false,_cw_active=false; + bool _rx_hold_continuous=false,_rx_ps_armed=true; + bool chip_busy=false,receiving=false,rearm_fails=false,retry_pending=false; + bool _profile_rxps_suspended=false,_profile_saved_rxps=false; + bool _rx_ps_enabled=true; + uint32_t _rx_ps_rx_us=123456,_rx_ps_sleep_us=765432; + uint32_t n_recv=7,n_sent=9,n_recv_errors=3,n_wd_soft=11,n_wd_hard=13; + uint32_t last_rx=1234; + unsigned rearm_calls=0; + bool hasPendingRadioInterrupt() const {return physical.pending;} + void claimRadioInterrupt() {physical.pending=false;} + bool isChipBusy() const {return chip_busy;} + bool isReceivingPacket() const {return receiving;} + bool isCarrierWaveActive() const {return _cw_active;} + bool isBeaconRadioOwned() const {return _beacon_owned;} + void startRecv() { + ++rearm_calls; + state=rearm_fails?STATE_IDLE:STATE_RX; + retry_pending=rearm_fails; + _rx_ps_armed=!rearm_fails&&_rx_ps_enabled; + } + uint8_t beginReconfigure(); + void endReconfigure(bool); + bool isInRecvMode() const; + virtual bool supportsRxPowerSavingRfRxDisable() const {return false;} + virtual bool setRxPowerSavingRfRxDisabled(bool) {return false;} + virtual bool isRxPowerSavingRfRxDisabled() const {return false;} + uint32_t getRxPsWatchdogSoftCount() const {return n_wd_soft;} + uint32_t getRxPsWatchdogHardCount() const {return n_wd_hard;} +#include "production_radio_reset.inc" +}; +#include "production_admission.inc" +struct Driver:RadioLibWrapper { +#include "production_rxen.inc" +}; +struct Dispatcher { + uint32_t n_sent_flood=1,n_sent_direct=2,n_recv_flood=3,n_recv_direct=4; + uint16_t _err_flags=0x12; + uint32_t last_meshcore_rx=23456; + std::vector outbound{42}; +#include "production_dispatcher_reset.inc" +}; +struct Tables { + uint32_t _direct_dups=8,_flood_dups=10; + std::vector dedup_history{1,2,3}; +#include "production_table_reset.inc" +}; +struct Harness { + Driver driver;Dispatcher dispatcher;Tables tables; + unsigned clears=0; + bool command(const char* command,char* reply,size_t cap,bool available=true) { + return mesh::companion::handleRadioDiagnosticsCommand(driver,available,command,reply,cap,[this](){ + ++clears;driver.resetStats();dispatcher.resetStats();tables.resetStats(); + }); + } +}; +static void bounds(Harness& h,const char* command,const std::string& expected,bool available=true) { + for(size_t cap=1;cap<=176;++cap) { + std::vector bytes(cap+16,0xa5); + char* reply=reinterpret_cast(bytes.data()+8); + assert(h.command(command,reply,cap,available)); + assert(std::string(reply)==expected.substr(0,cap-1)); + for(size_t i=0;i<8;++i)assert(bytes[i]==0xa5); + for(size_t i=8+cap;i soft=11,hard=13"); +#if defined(SX126X_RXEN) && SX126X_RXEN != -1 + bounds(h,"get radio.rxps.rfrx_disabled","> off"); + bounds(h,"get radio.rxps.rfrx_disabled","Error: radio unavailable",false); + assert(h.command("set radio.rxps.rfrx_disabled on",reply,sizeof(reply))); + assert(!strcmp(reply,"OK - radio.rxps.rfrx_disabled on")&&h.driver.physical.disabled); + h.driver.chip_busy=true; + assert(h.command("set radio.rxps.rfrx_disabled off",reply,sizeof(reply))); + assert(!strcmp(reply,"Error: radio busy or RXEN change failed")&&h.driver.physical.disabled); + h.driver.chip_busy=false; + h.driver.rearm_fails=true; + assert(h.command("set radio.rxps.rfrx_disabled off",reply,sizeof(reply))); + assert(!strcmp(reply,"Error: radio busy or RXEN change failed")&&h.driver.physical.disabled); + h.driver.rearm_fails=false;h.driver.startRecv(); + assert(h.command("set radio.rxps.rfrx_disabled off",reply,sizeof(reply))); + assert(!h.driver.physical.disabled); +#else + bounds(h,"get radio.rxps.rfrx_disabled","Error: unsupported"); + bounds(h,"set radio.rxps.rfrx_disabled on","Error: unsupported"); + assert(h.driver.physical.flag_writes==0); +#endif + const unsigned writes=h.driver.physical.flag_writes; + for(const char* command:{"set radio.rxps.rfrx_disabled", "set radio.rxps.rfrx_disabled ", + "set radio.rxps.rfrx_disabled banana","set radio.rxps.rfrx_disabled on extra", + "set radio.rxps.rfrx_disabled\ton"}) { + assert(h.command(command,reply,sizeof(reply))); + assert(!strncmp(reply,"Error: use ",11)); + } + for(const char* command:{"get rxps.wdx","get rxps.wd extra","get radio.rxps.rfrx_disabledx", + "set radio.rxps.rfrx_disabledx on","clear stats extra","clear stats ","CLEAR stats"}) { + memset(reply,'X',sizeof(reply));assert(!h.command(command,reply,sizeof(reply))); + for(char c:reply)assert(c=='X'); + } + assert(h.driver.physical.flag_writes==writes); + assert(!h.command(nullptr,reply,sizeof(reply))); + assert(!h.command("clear stats",nullptr,sizeof(reply))); + assert(!h.command("clear stats",reply,0));assert(h.clears==0); + assert(h.command("clear stats",reply,sizeof(reply))); + assert(!strcmp(reply,"(OK - stats reset)")&&h.clears==1); + assert(h.driver.n_recv==0&&h.driver.n_sent==0&&h.driver.n_recv_errors==0); + assert(h.dispatcher.n_sent_flood==0&&h.dispatcher.n_sent_direct==0&&h.dispatcher.n_recv_flood==0&&h.dispatcher.n_recv_direct==0); + assert(h.dispatcher._err_flags==0&&h.tables._direct_dups==0&&h.tables._flood_dups==0); + assert(h.driver.n_wd_soft==11&&h.driver.n_wd_hard==13&&h.driver.last_rx==1234); + assert(h.dispatcher.last_meshcore_rx==23456&&h.dispatcher.outbound==std::vector{42}); + assert(h.tables.dedup_history==std::vector({1,2,3})); + const std::string cad="radio.cad on, scan=auto(3500) ms, retry=auto ms, max=60000 ms, busy=4294967295"; + for(size_t cap=1;cap<=176;++cap) { + std::vector bytes(cap+16,0xa5);char* out=reinterpret_cast(bytes.data()+8); + mesh::companion::formatCadDiagnostics(out,cap,true,"auto(3500)","auto","60000",UINT32_MAX); + assert(std::string(out)==cad.substr(0,cap-1)); + for(size_t i=0;i<8;++i)assert(bytes[i]==0xa5); + for(size_t i=8+cap;i #include #include #include @@ -8,8 +9,11 @@ static uint32_t clock_ms = 500000; static uint32_t millis() { return clock_ms; } static const char* skipSpacesConst(const char* in) { while (*in == ' ' || *in == '\t') ++in; return in; } static constexpr uint32_t SDCARD_ACTION_COOLDOWN_MILLIS = 300000; -struct Board {}; +namespace mesh { class MainBoard {}; } +using Board = mesh::MainBoard; namespace mesh { namespace ota { +[[maybe_unused]] static bool ethernet_owned = false; +[[maybe_unused]] static bool rak4631_ethernet_owns_spi() { return ethernet_owned; } struct OtaStoreSdNrf52 { int formats = 0, erases = 0, spaces = 0, lists = 0; uint16_t page = 0; @@ -53,10 +57,13 @@ static void formatStorageBytes(char* reply, size_t cap, uint64_t value) { snprin struct CommonCLI { Board board; Board* _board = &board; - bool _sdcard_format_recorded = false, _sdcard_erase_recorded = false; - uint32_t _sdcard_format_at = 0, _sdcard_erase_at = 0; - bool handleSdCardSetCmd(const char*, char*); - bool handleSdCardGetCmd(const char*, char*); + mesh::cli::SdCardCLIState state; + bool handleSdCardSetCmd(const char* config, char* reply) { + return mesh::cli::handleSdCardSet(state, board, config, reply, 160); + } + bool handleSdCardGetCmd(const char* config, char* reply) { + return mesh::cli::handleSdCardGet(state, board, config, reply, 160); + } }; @METHODS@ diff --git a/test/fixtures/rak15002_sd_seeder/maintenance.cpp b/test/fixtures/rak15002_sd_seeder/maintenance.cpp index fe4387eaa..3d63e7155 100644 --- a/test/fixtures/rak15002_sd_seeder/maintenance.cpp +++ b/test/fixtures/rak15002_sd_seeder/maintenance.cpp @@ -1,3 +1,4 @@ +#include // Compile the real card-maintenance CLI around its side-effect boundaries. // No filesystem, flash, bootloader, or radio hardware is accessed here. #include @@ -14,9 +15,12 @@ static const char* skipSpacesConst(const char* in) { return in; } static constexpr uint32_t SDCARD_ACTION_COOLDOWN_MILLIS = 300000; -struct Board {}; +namespace mesh { class MainBoard {}; } +using Board = mesh::MainBoard; namespace mesh { namespace ota { +[[maybe_unused]] static bool ethernet_owned = false; +[[maybe_unused]] static bool rak4631_ethernet_owns_spi() { return ethernet_owned; } struct OtaManager { enum State { IDLE, FETCHING, COMPLETE, PAUSED, FAILED }; State state = IDLE; @@ -66,9 +70,10 @@ static OtaContext& ota_ctx() { return context; } struct CommonCLI { Board board; Board* _board = &board; - bool _sdcard_format_recorded = false, _sdcard_erase_recorded = false; - uint32_t _sdcard_format_at = 0, _sdcard_erase_at = 0; - bool handleSdCardSetCmd(const char*, char*); + mesh::cli::SdCardCLIState state; + bool handleSdCardSetCmd(const char* config, char* reply) { + return mesh::cli::handleSdCardSet(state, board, config, reply, 160); + } }; @METHODS@ @@ -88,8 +93,8 @@ int main() { CommonCLI cli; // --force only bypasses the action cooldown, never store ownership. if (strstr(command, "--force")) { - cli._sdcard_format_recorded = cli._sdcard_erase_recorded = true; - cli._sdcard_format_at = cli._sdcard_erase_at = clock_ms; + cli.state.format_recorded = cli.state.erase_recorded = true; + cli.state.format_at = cli.state.erase_at = clock_ms; } struct Reply { char text[160]; char guard[8] = "guarded"; } reply{}; assert(cli.handleSdCardSetCmd(command, reply.text)); diff --git a/test/test_companion_board_power_commands.py b/test/test_companion_board_power_commands.py new file mode 100644 index 000000000..455446f4b --- /dev/null +++ b/test/test_companion_board_power_commands.py @@ -0,0 +1,194 @@ +"""Execute real Companion power formatting and the nRF52 hardware-state reader.""" +from pathlib import Path +import os +import re +import subprocess +import tempfile +import unittest +from cpp_source import body + +ROOT = Path(__file__).resolve().parents[1] + + +class CompanionPowerDiagnostics(unittest.TestCase): + def compile(self, source, flags=()): + with tempfile.TemporaryDirectory(prefix="companion-power-diagnostics-") as folder: + cpp, exe = Path(folder) / "test.cpp", Path(folder) / "test" + cpp.write_text(source, encoding="ascii") + result = subprocess.run([os.environ.get("CXX", "c++"), "-std=c++17", + "-Wall", "-Wextra", "-Werror", "-fsanitize=address,undefined", + "-fno-omit-frame-pointer", "-no-pie", "-I", str(ROOT / "src"), + *["-D" + flag for flag in flags], str(cpp), "-o", str(exe)], + capture_output=True, text=True, timeout=30) + self.assertEqual(result.returncode, 0, result.stderr) + run = subprocess.run([str(exe)], capture_output=True, text=True, timeout=10) + self.assertEqual(run.returncode, 0, run.stderr) + + def test_actual_common_power_format_and_capability_gates(self): + source = (ROOT / "src/helpers/CommonCLI.cpp").read_text() + common = body(source, '} else if (strcmp(command, "power") == 0)') + common = common[common.index("{") :] + fixture = r''' +#include +#include +struct Board { + bool pm=false, usb_cap=false, solar_cap=false; + bool usb=false, solar=false, external=false, charger=false; + bool watchdog_supported=true, watchdog_running=true; + unsigned batt_reads=0, boot_reads=0, source_reads=0, usb_reads=0, solar_reads=0; + bool supportsPowerManagement() const { return pm; } + bool hasUsbPowerDetect() const { return usb_cap; } + bool hasSolarChargerDetect() const { return solar_cap; } + bool isUsbPowered() { ++usb_reads; return usb; } + bool isSolarChargerActive() { ++solar_reads; return solar; } + bool isExternalPowered() { ++source_reads; return external; } + bool isChargerActive() { return charger; } + unsigned getBattMilliVolts() { ++batt_reads; return 4102; } + unsigned getBootVoltage() { ++boot_reads; return 3991; } + bool getSystemWatchdogRunning(bool& running) const { + running=watchdog_running; return watchdog_supported; + } +}; +void commonPower(Board& board, char* reply) { + auto* _board=&board; + @COMMON@ +} +void expect(Board& board,const char* command,const char* expected) { + char reply[160]; + assert(mesh::companion::handlePowerDiagnosticsCommand(board,command,reply,sizeof(reply))); + assert(strcmp(reply,expected)==0); +} +int main() { + Board board; + expect(board,"get pwrmgt.support","> unsupported"); + expect(board,"get pwrmgt.source","ERROR: Power management not supported"); + expect(board,"get pwrmgt.bootmv","ERROR: Power management not supported"); + assert(board.source_reads==0 && board.boot_reads==0); + board.pm=true; + expect(board,"get pwrmgt.support","> supported"); + expect(board,"get pwrmgt.source","> battery"); + board.external=true; + expect(board,"get pwrmgt.source","> external"); + expect(board,"get pwrmgt.bootmv","> 3991 mV"); + assert(board.boot_reads==1 && board.batt_reads==0); + for(unsigned bits=0;bits<64;++bits) { + board.usb_cap=bits&1;board.solar_cap=bits&2;board.usb=bits&4; + board.solar=bits&8;board.external=bits&16;board.charger=bits&32; + board.usb_reads=board.solar_reads=0; + char actual[160],expected[160]; + assert(mesh::companion::handlePowerDiagnosticsCommand(board,"power",actual,sizeof(actual))); + assert(board.usb_reads==unsigned(board.usb_cap)); + assert(board.solar_reads==unsigned(board.solar_cap)); + commonPower(board,expected); + assert(strcmp(actual,expected)==0); + } + board.watchdog_supported=true;board.watchdog_running=false; + expect(board,"get system.watchdog","> off"); + board.watchdog_running=true;expect(board,"get system.watchdog","> on"); + board.watchdog_supported=false; + expect(board,"get system.watchdog","Error: unsupported on this platform"); + for(const char* command:{"set system.watchdog on","set system.watchdog off","set system.watchdog","set system.watchdog invalid"}) { + expect(board,command,"Error: system watchdog control is unavailable in Companion"); + assert(board.watchdog_running); // A setter must never pretend to stop it. + } +} +'''.replace("@COMMON@", common) + fixture = "#include \n" + fixture + self.compile(fixture) + + def test_actual_nrf_reader_reports_hardware_even_with_feeding_disabled(self): + source = (ROOT / "src/helpers/NRF52Board.cpp").read_text() + reader = body(source, "bool NRF52Board::getSystemWatchdogRunning(bool& running) const") + self.assertNotRegex(reader, r"feedWatchdog|serviceWatchdog|TASKS_START|RR\[") + fixture = r''' +#include +#include +struct { volatile uint32_t RUNSTATUS=0; uint32_t untouched=0x1234; } registers; +#define NRF_WDT (®isters) +struct NRF52Board { bool getSystemWatchdogRunning(bool& running) const; }; +@READER@ +int main() { + const NRF52Board board; + bool running=true; + assert(board.getSystemWatchdogRunning(running) && !running); + registers.RUNSTATUS=1; + assert(board.getSystemWatchdogRunning(running) && running); + registers.RUNSTATUS=2; + assert(board.getSystemWatchdogRunning(running) && running); + assert(registers.RUNSTATUS==2 && registers.untouched==0x1234); +} +'''.replace("@READER@", reader) + self.compile(fixture, ["NRF52_WATCHDOG_TIMEOUT_SECONDS=0"]) + self.compile(fixture, ["NRF52_WATCHDOG_TIMEOUT_SECONDS=60"]) + + def test_real_base_defaults_and_nrf_support_scope(self): + source = (ROOT / "src/MeshCore.h").read_text() + default_reader = body(source, "virtual bool getSystemWatchdogRunning(bool& running) const") + default_power = body(source, "virtual bool supportsPowerManagement() const") + nrf = (ROOT / "src/helpers/NRF52Board.h").read_text() + nrf_power = body(nrf, "bool supportsPowerManagement() const override") + preceding = nrf[:nrf.index("bool supportsPowerManagement() const override")] + active = [] + for directive in re.findall(r"^\s*#\s*(if[^\n]*|endif)\s*$", preceding, re.MULTILINE): + if directive == "endif": active.pop() + else: active.append(directive) + self.assertIn("ifdef NRF52_POWER_MANAGEMENT", active) + fixture = r''' +#include +struct MainBoard { + @READER@ + @POWER@ +}; +struct ManagedBoard:MainBoard { + @NRF_POWER@ +}; +int main() { + MainBoard base;ManagedBoard managed; + bool running=true; + assert(!base.getSystemWatchdogRunning(running) && running); + assert(!base.supportsPowerManagement() && managed.supportsPowerManagement()); +} +'''.replace("@READER@", default_reader).replace("@POWER@", default_power).replace("@NRF_POWER@", nrf_power) + self.compile(fixture) + + def test_small_output_buffers_and_unknown_commands(self): + fixture = r''' +#include +#include +#include +struct Board { + bool supportsPowerManagement()const{return true;} + bool hasUsbPowerDetect()const{return true;} + bool hasSolarChargerDetect()const{return true;} + bool isUsbPowered(){return true;} + bool isSolarChargerActive(){return true;} + bool isExternalPowered(){return true;} + bool isChargerActive(){return true;} + unsigned getBattMilliVolts(){return 12345;} + unsigned getBootVoltage(){return 54321;} + bool getSystemWatchdogRunning(bool& v)const{v=true;return true;} +}; +int main() { + Board board; + for(const char* command:{"power","get pwrmgt.support","get pwrmgt.source","get pwrmgt.bootmv","get system.watchdog","set system.watchdog off"}) { + for(unsigned length=1;length<160;++length) { + char data[162];memset(data,'z',sizeof(data)); + assert(mesh::companion::handlePowerDiagnosticsCommand(board,command,data+1,length)); + assert(data[0]=='z' && data[length+1]=='z'); + assert(memchr(data+1,0,length)!=nullptr); + } + } + char untouched[8]="stable"; + for(const char* command:{"","get pwrmgt.support.extra","power extra","get system.watchdog off","set system.watchdog.extra off"}) { + assert(!mesh::companion::handlePowerDiagnosticsCommand(board,command,untouched,sizeof(untouched))); + assert(strcmp(untouched,"stable")==0); + } + assert(!mesh::companion::handlePowerDiagnosticsCommand(board,nullptr,untouched,sizeof(untouched))); + assert(!mesh::companion::handlePowerDiagnosticsCommand(board,"power",nullptr,sizeof(untouched))); + assert(!mesh::companion::handlePowerDiagnosticsCommand(board,"power",untouched,0)); +} +''' + self.compile(fixture) + + +if __name__ == "__main__":unittest.main() diff --git a/test/test_companion_radio_diagnostics.py b/test/test_companion_radio_diagnostics.py new file mode 100644 index 000000000..7c0488022 --- /dev/null +++ b/test/test_companion_radio_diagnostics.py @@ -0,0 +1,51 @@ +"""Real shared CLI plus RXEN admission/restoration and existing reset semantics.""" +from pathlib import Path +import os +import subprocess +import tempfile +import unittest +from cpp_source import body + +ROOT=Path(__file__).resolve().parents[1] + +class CompanionRadioDiagnosticsTests(unittest.TestCase): + def test_production_backend_admission_cli_bounds_and_reset_scope(self): + wrapper=(ROOT/'src/helpers/radiolib/CustomSX1262Wrapper.h').read_text() + radio=(ROOT/'src/helpers/radiolib/RadioLibWrappers.cpp').read_text() + header=(ROOT/'src/helpers/radiolib/RadioLibWrappers.h').read_text() + dispatcher=(ROOT/'src/Dispatcher.h').read_text() + tables=(ROOT/'src/helpers/SimpleMeshTables.h').read_text() + methods='\n'.join(body(wrapper,signature) for signature in ( + 'bool supportsRxPowerSavingRfRxDisable() const override', + 'bool setRxPowerSavingRfRxDisabled(bool disabled) override', + 'bool isRxPowerSavingRfRxDisabled() const override')) + admission='\n'.join(body(radio,signature) for signature in ( + 'uint8_t RadioLibWrapper::beginReconfigure()', + 'void RadioLibWrapper::endReconfigure(bool resume_rx)', + 'bool RadioLibWrapper::isInRecvMode() const')) + with tempfile.TemporaryDirectory(prefix='mesh-companion-radio-diagnostics-') as directory: + work=Path(directory) + for name,source in { + 'production_rxen.inc':methods, + 'production_admission.inc':admission, + 'production_radio_reset.inc':body(header,'void resetStats()'), + 'production_dispatcher_reset.inc':body(dispatcher,'void resetStats()'), + 'production_table_reset.inc':body(tables,'void resetStats()'), + }.items(): + (work/name).write_text(source) + for rxen,beacons in ((5,0),(5,1),(-1,1),(None,0)): + with self.subTest(rxen=rxen,beacons=beacons): + flags=['-DMESH_ENABLE_TX_BEACONS='+str(beacons)] + if rxen is not None:flags.append('-DSX126X_RXEN='+str(rxen)) + exe=work/('test-'+str(rxen)+'-'+str(beacons)) + command=[os.environ.get('CXX','g++'),'-std=c++17','-Wall','-Wextra','-Werror', + '-fsanitize=address,undefined','-fno-sanitize-recover=all','-fno-pie','-no-pie', + '-I'+str(ROOT/'src'),'-I'+str(work),*flags, + str(ROOT/'test/fixtures/companion_radio_diagnostics/test.cpp'),'-o',str(exe)] + result=subprocess.run(command,capture_output=True,text=True,timeout=60) + self.assertEqual(result.returncode,0,result.stdout+result.stderr) + result=subprocess.run([str(exe)],capture_output=True,text=True,timeout=20) + self.assertEqual(result.returncode,0,result.stdout+result.stderr) + self.assertIn('radio diagnostics cases passed',result.stdout) + +if __name__=='__main__':unittest.main() diff --git a/test/test_rak15002_sd_seeder.py b/test/test_rak15002_sd_seeder.py index fe079c8be..e60a53a56 100644 --- a/test/test_rak15002_sd_seeder.py +++ b/test/test_rak15002_sd_seeder.py @@ -200,10 +200,11 @@ int main() { EnvironmentSensorManager sensors; assert(sensors.detectWisBlockStor build(self, harness, defines) def test_sd_cli_cold_context_oom_validation_and_card_actions(self): - source = (ROOT / "src/helpers/CommonCLI.cpp").read_text() - methods = "\n".join(extract_braced(source, signature) for signature in ( - "static void formatSdCardElapsed(", "bool CommonCLI::handleSdCardSetCmd(", - "bool CommonCLI::handleSdCardGetCmd(")) + source = (ROOT / "src/helpers/SdCardCLI.cpp").read_text() + methods = "namespace mesh { namespace cli {\n" + "\n".join( + extract_braced(source, signature) for signature in ( + "static bool sdCardBusAvailable(", "static void formatSdCardElapsed(", + "bool handleSdCardSet(", "bool handleSdCardGet(")) + "\n} }\n" fixture = (FIXTURES / "cli.cpp").read_text() build(self, fixture.replace("@METHODS@", methods), ["NRF52_PLATFORM", "OTA_SD_STORE"]) diff --git a/test/test_rak_sd_context_handoff.py b/test/test_rak_sd_context_handoff.py index 3d523fd8e..acb8f7446 100644 --- a/test/test_rak_sd_context_handoff.py +++ b/test/test_rak_sd_context_handoff.py @@ -105,9 +105,11 @@ int main() { class RakSdContextHandoffTests(unittest.TestCase): def test_card_maintenance_preserves_non_sd_install_sessions(self): - source = (ROOT / 'src/helpers/CommonCLI.cpp').read_text() - methods = '\n'.join(extract_braced(source, signature) for signature in ( - 'static void formatSdCardElapsed(', 'bool CommonCLI::handleSdCardSetCmd(')) + source = (ROOT / 'src/helpers/SdCardCLI.cpp').read_text() + methods = 'namespace mesh { namespace cli {\n' + '\n'.join( + extract_braced(source, signature) for signature in ( + 'static bool sdCardBusAvailable(', 'static void formatSdCardElapsed(', + 'bool handleSdCardSet(')) + '\n} }\n' fixture = (ROOT / 'test/fixtures/rak15002_sd_seeder/maintenance.cpp').read_text() for profile in ( ['OTA_RAK15002_SD_STORE', 'OTA_RAK_AUTO_STORE'], @@ -137,7 +139,7 @@ class RakSdContextHandoffTests(unittest.TestCase): build(self, harness, ['OTA_RAK15002_SD_STORE', *(['OTA_SEEDER_ONLY'] if seeder else [])]) - def test_companion_physical_ethernet_and_sd_commands_keep_failed_admission_off(self): + def test_companion_physical_ethernet_commands_keep_failed_admission_off(self): source = (ROOT / 'examples/companion_radio/main.cpp').read_text() method = extract_braced(source, 'bool handleRak4631PeripheralCommand(') fixture = r''' @@ -190,25 +192,15 @@ int main() { ethernet_interface.state = "online"; assert(handleRak4631PeripheralCommand("get eth", reply, sizeof reply)); assert(strstr(reply, "eth=online")); - assert(handleRak4631PeripheralCommand("get sdcard free", reply, sizeof reply)); - assert(strstr(reply, "Ethernet off") && !mesh::ota::acquires); assert(handleRak4631PeripheralCommand("set eth off", reply, sizeof reply)); assert(strstr(reply, "eth=stopping") && ethernet_interface.active); - assert(handleRak4631PeripheralCommand("get sdcard", reply, sizeof reply)); - assert(strstr(reply, "Ethernet off") && !mesh::ota::acquires); ethernet_interface.active = false; ethernet_interface.state = "off"; assert(handleRak4631PeripheralCommand("get eth", reply, sizeof reply)); assert(strstr(reply, "eth=off")); assert(!ethernet_interface.active); - mesh::ota::admission = false; - assert(handleRak4631PeripheralCommand("get sdcard", reply, sizeof reply)); - assert(strstr(reply, "queue busy") && !mesh::ota::context.card.queries); - mesh::ota::admission = true; - assert(handleRak4631PeripheralCommand("get sdcard free", reply, sizeof reply)); - assert(strstr(reply, "used=42 free=4096 bytes") && mesh::ota::context.card.queries == 1); - mesh::ota::context.card.okay = false; - assert(handleRak4631PeripheralCommand("get sdcard", reply, sizeof reply)); - assert(strstr(reply, "no SD card")); + // SD command admission moved into the shared production handler, executed + // under all SD profiles by test_sdcard_cli_parity.py. + assert(!handleRak4631PeripheralCommand("get sdcard free", reply, sizeof reply)); } '''.replace('@METHOD@', method) build(self, fixture) diff --git a/test/test_sdcard_cli_parity.py b/test/test_sdcard_cli_parity.py new file mode 100644 index 000000000..4184b83d1 --- /dev/null +++ b/test/test_sdcard_cli_parity.py @@ -0,0 +1,208 @@ +#!/usr/bin/env python3 +"""Compile the shared production SD CLI against stateful storage boundaries.""" +from pathlib import Path +import os +import shutil +import subprocess +import sys +import tempfile +import unittest + +ROOT = Path(__file__).resolve().parents[1] + +ARDUINO = r''' +#pragma once +#include +extern uint32_t clock_ms; +inline uint32_t millis() { return clock_ms; } +''' +BOARD = '#pragma once\nnamespace mesh { class MainBoard {}; }\n' +CONTEXT = r''' +#pragma once +#include +#include +#include +namespace mesh { namespace ota { +inline bool ethernet_owned = false; +inline bool rak4631_ethernet_owns_spi() { return ethernet_owned; } +struct OtaManager { + enum State { IDLE, FETCHING, COMPLETE }; + State state = IDLE; + unsigned resets = 0; + State fetchState() const { return state; } + void reset_session() { ++resets; state = IDLE; } + void want(unsigned) {} + void want_mid(const uint8_t*) {} +}; +struct FetchStore { + bool internal = false, sd = true; + uint32_t staged = 0; + bool usesInternal() const { return internal; } + bool usesSd() const { return sd; } + uint32_t staged_size() const { return staged; } +}; +struct OtaStoreSdNrf52 { + unsigned formats = 0, erases = 0, spaces = 0, lists = 0; + uint16_t page = 0; + bool format_ok = true, erase_ok = true, read_ok = true; + bool formatCard(MainBoard&) { ++formats; return format_ok; } + bool eraseCard(MainBoard&) { ++erases; return erase_ok; } + bool getSpace(MainBoard&, uint64_t& used, uint64_t& free) { + ++spaces; used = 12; free = 34; return read_ok; + } + bool listFiles(MainBoard&, uint16_t p, char* reply, size_t cap) { + ++lists; page = p; snprintf(reply, cap, "> files page=%u", unsigned(p)); return read_ok; + } + const char* last_error() const { return "card unavailable"; } +}; +struct OtaContext { + OtaManager manager; + FetchStore fetch_store; + OtaStoreSdNrf52 card; + bool apply_pending = false; + uint32_t session_started_ms = 1; + OtaManager::State prev_fstate = OtaManager::FETCHING; + unsigned prepares = 0, finishes = 0; + OtaStoreSdNrf52& sdStagingStore() { return card; } + void prepareSdCardReset() { ++prepares; } + void finishSdCardReset(uint32_t) { ++finishes; } +}; +inline OtaContext context; +inline bool acquired = false, out_of_memory = false; +inline unsigned acquisitions = 0, accesses = 0; +inline bool ota_acquire_context(char* reply, size_t cap) { + ++acquisitions; + if (out_of_memory) { snprintf(reply, cap, "ERR mOTA out of memory"); return false; } + acquired = true; return true; +} +inline OtaContext& ota_ctx() { + if (!acquired) __builtin_trap(); + ++accesses; return context; +} +} } +''' +HARNESS = r''' +#include +#include +#include +#include +#include +uint32_t clock_ms = 500000; +int main() { + using namespace mesh::ota; + mesh::MainBoard board; + mesh::cli::SdCardCLIState state; + struct Reply { char text[160]; char sentinel[8] = "guarded"; } reply{}; + auto command = [&](const char* value) { + bool handled = mesh::cli::handleSdCardCommand(state, board, value, reply.text, sizeof(reply.text)); + assert(strcmp(reply.sentinel, "guarded") == 0); return handled; + }; + assert(!command("get sdcardoops")); + assert(!command("set unrelated")); + for (const char* value : {"get sdcard", "get sdcard *", "get sdcard format", "get sdcard erase"}) { + assert(command(value)); assert(strstr(reply.text, "never")); + } + for (const char* value : {"get sdcard invalid", "get sdcard ls 0", "get sdcard dir -1", + "get sdcard dir 65536", "get sdcard lsoops", "get sdcard dir 2 extra", + "set sdcard format unknown"}) { + assert(command(value)); assert(strncmp(reply.text, "Error:", 6) == 0); + } + assert(!acquisitions && !accesses); +#if defined(OTA_RAK15002_SD_STORE) + ethernet_owned = true; + for (const char* value : {"get sdcard free", "get sdcard ls", "set sdcard format --force", "set sdcard erase --force"}) { + assert(command(value)); assert(strstr(reply.text, "Ethernet off")); + assert(!acquisitions && !accesses && !context.prepares && !context.manager.resets); + } + ethernet_owned = false; +#endif + out_of_memory = true; + assert(command("get sdcard free")); assert(strstr(reply.text, "out of memory")); + assert(!accesses && !context.card.spaces); + out_of_memory = false; + assert(command("get sdcard free")); assert(strstr(reply.text, "12 B") && strstr(reply.text, "34 B")); + assert(command("get sdcard dir 65535")); assert(context.card.page == 65535); + context.card.read_ok = false; + assert(command("get sdcard free")); assert(strstr(reply.text, "card unavailable")); + assert(command("get sdcard ls")); assert(strstr(reply.text, "card unavailable")); + context.card.read_ok = true; + context.apply_pending = true; + assert(command("set sdcard format --force")); + assert(strstr(reply.text, "apply is pending") && !context.prepares && !context.card.formats); + context.apply_pending = false; + context.card.format_ok = false; + assert(command("set sdcard format")); + assert(strstr(reply.text, "format failed") && context.prepares == 1 && context.finishes == 1); + assert(!state.format_recorded); + context.card.format_ok = true; + assert(command("set sdcard format")); assert(state.format_recorded && state.format_at == clock_ms); + unsigned formats = context.card.formats; + assert(command("set sdcard format")); assert(strstr(reply.text, "--force") && context.card.formats == formats); + assert(command("set sdcard format --force")); assert(context.card.formats == formats + 1); + context.card.erase_ok = false; + assert(command("set sdcard erase")); assert(strstr(reply.text, "erase failed")); + assert(!state.erase_recorded && context.prepares == context.finishes); + context.card.erase_ok = true; context.card.format_ok = false; + assert(command("set sdcard erase")); assert(strstr(reply.text, "erased but format failed")); + assert(state.erase_recorded && context.prepares == context.finishes); + context.card.format_ok = true; + assert(command("set sdcard erase --force")); assert(strstr(reply.text, "erase and format complete")); + // uint32_t wrap must retain the same five-minute cooldown semantics. + state.format_recorded = true; state.format_at = 0xfffffff0u; clock_ms = 20; + formats = context.card.formats; + assert(command("set sdcard format")); assert(context.card.formats == formats); + clock_ms += 300000; + assert(command("set sdcard format")); assert(context.card.formats == formats + 1); + // Short framed replies are always bounded, including failed operations. + struct Tiny { char text[8]; char sentinel[8] = "guarded"; } tiny{}; + assert(mesh::cli::handleSdCardCommand(state, board, "get sdcard *", tiny.text, sizeof(tiny.text))); + assert(tiny.text[7] == 0 && strcmp(tiny.sentinel, "guarded") == 0); + assert(mesh::cli::handleSdCardCommand(state, board, "set sdcard format --force", tiny.text, sizeof(tiny.text))); + assert(tiny.text[7] == 0 && strcmp(tiny.sentinel, "guarded") == 0); + assert(mesh::cli::handleSdCardCommand(state, board, "set sdcard erase --force", tiny.text, sizeof(tiny.text))); + assert(tiny.text[7] == 0 && strcmp(tiny.sentinel, "guarded") == 0); + assert(!mesh::cli::handleSdCardCommand(state, board, nullptr, reply.text, sizeof(reply.text))); + assert(!mesh::cli::handleSdCardCommand(state, board, "get sdcard", nullptr, 0)); +} +''' + + +class SdCardCLIParityTests(unittest.TestCase): + def compile_and_run(self, defines): + compiler = os.environ.get('CXX') or shutil.which('g++') or shutil.which('clang++') + if not compiler: + self.skipTest('host C++17 compiler required') + with tempfile.TemporaryDirectory(prefix='sdcard-cli-') as folder: + work = Path(folder) + for name, text in {'Arduino.h': ARDUINO, 'MeshCore.h': BOARD, + 'helpers/ota/OtaContext.h': CONTEXT, + 'main.cpp': HARNESS}.items(): + path = work / name; path.parent.mkdir(parents=True, exist_ok=True); path.write_text(text) + sanitizer = ['-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', + *sanitizer, *['-D' + value for value in ['NRF52_PLATFORM', 'OTA_SD_STORE', *defines]], + '-I' + str(work), '-I' + str(ROOT / 'src'), str(ROOT / 'src/helpers/SdCardCLI.cpp'), + str(work / 'main.cpp'), '-o', str(work / 'test')], capture_output=True, text=True) + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + result = subprocess.run([str(work / 'test')], capture_output=True, text=True) + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + + def test_actual_shared_cpp_handles_legacy_sd_and_source_only_profiles(self): + for defines in ([], ['OTA_SEEDER_ONLY'], ['OTA_TOWER_AUTO_STORE'], + ['OTA_RAK15002_SD_STORE', 'OTA_RAK_AUTO_STORE'], + ['OTA_RAK15002_SD_STORE', 'OTA_SEEDER_ONLY']): + with self.subTest(defines=defines): + self.compile_and_run(defines) + + def test_common_cli_delegates_to_same_implementation(self): + source = (ROOT / 'src/helpers/CommonCLI.cpp').read_text() + self.assertIn('handleSdCardSet(_sdcard_cli, *_board, config, reply, 160)', source) + self.assertIn('handleSdCardGet(_sdcard_cli, *_board, config, reply, 160)', source) + self.assertNotIn('context.prepareSdCardReset()', source) + header = (ROOT / 'src/helpers/CommonCLI.h').read_text() + self.assertIn('mesh::cli::SdCardCLIState _sdcard_cli;', header) + + +if __name__ == '__main__': + unittest.main()