diff --git a/.github/workflows/run-unit-tests.yml b/.github/workflows/run-unit-tests.yml index 826c4cb3..680a158d 100644 --- a/.github/workflows/run-unit-tests.yml +++ b/.github/workflows/run-unit-tests.yml @@ -27,6 +27,9 @@ jobs: - name: Run Unit Tests run: pio test -e native -e native_kiss_modem -vv + - name: Verify nRF52 UF2-reset CLI coverage + run: python3 -B test/test_nrf52_uf2reset_cli.py + - name: Upload Test Results # Upload test results even if the test step failed. if: always() diff --git a/docs/cli_command_availability.md b/docs/cli_command_availability.md index 79f587e9..efd0a5a2 100644 --- a/docs/cli_command_availability.md +++ b/docs/cli_command_availability.md @@ -68,6 +68,7 @@ over the normal binary USB, BLE, or TCP connection: | [`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 | | [`get/set wifi.powersave`](cli_commands.md#browser-configuration-portal-esp32-repeater-and-room-server) | ESP32 WiFi Companion; active transport constraints still apply | | [`get/set bluetooth.name`](cli_commands.md#view-or-change-the-independent-bluetooth-name-companion) | Companion firmware | +| [`uf2reset`](cli_commands.md#enter-the-uf2-bootloader-nrf52-only) | Every nRF52 Companion text terminal and local command `0x42`; local only | Deprecated binary aliases remain receive-only for older clients; new clients should use command `0x42`. See [Companion radio binary protocol](companion_protocol.md). diff --git a/docs/cli_commands.md b/docs/cli_commands.md index 73ce4206..6a361c5d 100644 --- a/docs/cli_commands.md +++ b/docs/cli_commands.md @@ -63,6 +63,8 @@ arguments such as node names, passwords, and keys is left unchanged. **Serial Only:** Yes **Note:** Reboots directly into the UF2 bootloader on supported nRF52 boards. +This includes the Repeater, Room Server, Sensor, Companion, and Terminal Chat +local serial command surfaces. It is never accepted as a remote mesh command. --- diff --git a/docs/hardware_validation_checklist.md b/docs/hardware_validation_checklist.md index bf26be8f..27f7c336 100644 --- a/docs/hardware_validation_checklist.md +++ b/docs/hardware_validation_checklist.md @@ -5,15 +5,15 @@ when its log identifies the physical device, firmware artifact, artifact hash, command result, and cold/warm boot outcome. Do not infer success from a tool's exit code when the tool has a documented false-success mode. -## Current marathon ledger (through 2026-09-01) +## Current marathon ledger (through 2026-09-04) | Hardware | Stable identity | Current state | Next blocking check | | --- | --- | --- | --- | -| Seeed XIAO nRF52840 | `B35E71C1C3726CE7` | A stalled Companion transport survived the stop token and exact USB reset, then recovered after a 36-second exact application DFU. Current Full Companion USB, bonded BLE, clock sync, terminal/Binary switching, serial-mOTA folder attach, and reversible dual-CDC logging all pass | Prove current-build cold persistence; separately reproduce the stall before using a power cut to test whether power alone clears it; then transfer an actual image through BLE-controlled LoRa OTA | -| Seeed Tracker T1000-E | `34A9141999729D5D` | A temporary Full Companion and one of two same-visible-version OTAFIX candidates are running; the exact candidate bytes are not yet proven. The bonded warm baseline passed at 3.34 kB/s. Two lab-only pre-START 15 ms/latency-0 DFUs with `0/0` no-preference event-length hints passed at 6.23 and 6.52 kB/s; a 10 ms event-length control timed out before START | Prove installed bootloader bytes by readback, exercise a physical cold 20-to-244-byte readiness transition, restore the protected Repeater identity, cold boot, then force LR1110 reset | +| Seeed XIAO nRF52840 | `B35E71C1C3726CE7` | OTAFIX candidate `0x02040405` passed exact serial install and corrected Legacy DIS model gates; the patched Bluetooth repeater-updater application was restored over BLE and returned as exact `2886:8044` USB | Use the updater for an identity-gated Bluetooth transfer to another repeater, then prove current-build cold persistence | +| Seeed Tracker T1000-E | `34A9141999729D5D` | OTAFIX candidate `0x02040405` and the `uf2reset`-fixed Full Companion are installed. The real text command returned exact `2886:0057` boot USB, the same fixed app was restored, and refreshed bonded BLE services pass as `MeshCore-09848C15` | Restore the protected Repeater identity when Companion qualification is complete; cold boot, then force LR1110 reset | | RAK3401 | `0B81C9C68D8D01B4`; FICR `8D8D01B4 0B81C9C6` | OTAFIX test version `0x02040403` passes bidirectional application UF2, exact SWD application/bootloader readback, unchanged UICR, and a 74-block direct V4 LoRa apply from exact `3caf9dcf` to current HEAD `9fd580c8`. Post-apply USB and LoRa identity/hash checks pass and the temporary 0 dBm bench setting is restored to 22 dBm | Add exact RF packet counters, then repeat the supported-bandwidth and controlled/passive/mixed routed-hop matrix | -| Heltec T096 | `651F8E496197F882` | OTAFIX HIL candidate `0x02040401`, serial DFU, signed LoRa bootloader OTA, warm-watchdog recovery, physical power-removal cold persistence, bonded Legacy BLE application DFU, serial mOTA ownership, button/display wake, and Full Companion dual-CDC reconnect checks pass on `keymindCascade` `fcd1f8cc`. The final T096-only no-footer Full Companion image is installed | Perform a privileged or physical host-driven USB bus reset without resetting the MCU; complete the deferred multi-click/long-press physical matrix | -| Heltec MeshTower V2 with SD | `9352162A72082314` | Exact SD LoRa-OTA Repeater `e26d48e4` is running after identity-gated BLE recovery. Card format/cooldown/forced-format/raw-erase/remount pass; live bootloader contract reports ABI 3, FULL+INPLACE, and SD apply | Signed interrupted-download resume, corruption/signature rejection, then full/delta apply | +| Heltec T096 | `651F8E496197F882` | OTAFIX candidate `0x02040405` and the `uf2reset`-fixed no-footer Full Companion are installed. The real text command returned exact boot USB, and final USB terminal/Binary mode, dual CDC, version, help, and BLE identity pass | Perform a privileged or physical host-driven USB bus reset without resetting the MCU; complete the deferred multi-click/long-press physical matrix | +| Heltec MeshTower V2 with SD | `9352162A72082314` | OTAFIX candidate `0x02040405` passed serial combined install, the historical cached DIS handles, BLE application restore, and exact application USB return. Card format/cooldown/forced-format/raw-erase/remount and the live SD apply contract passed earlier | Signed interrupted-download resume, corruption/signature rejection, then full/delta apply | | Heltec V4 | USB MAC `44:1B:F6:6A:E8:44`; BLE `44:1B:F6:6A:E8:45` | USB/BLE/Wi-Fi and all three TCP services pass on hardware. The final fresh-NTP-gated Full Companion image builds with 4.31 MB app space free | Flash the exact final NTP build, prove NTP-before-TLS success/failure ordering, then OTA seeding | | SenseCAP Indicator LoRa | CH340 plus USB MAC `D8:3B:DA:75:23:AC`; BLE `D8:3B:DA:75:23:AD` | The current dark-layout application is SHA-256 `81bebdc07b6a8349c1c975cb5a0e30c5f6019d35bcac641e5f3d4df951f7406b`; identity-gated flash, USB ASCII/Binary switching, runtime logging, and separate configured-Wi-Fi, BLE, and TCP checks pass. Fresh-NTP HTTPS recovery, strict Range resume, STAGEV2 install, and RP USB readback passed on earlier exact artifacts; the final coordinator source still needs its blocked-UDP/123 hardware gate | Physical display-wrap and four-mode render/heap checks, exact-final blocked-NTP recovery gate, LoRa/TempRadio, and non-empty OTA seeding | @@ -644,6 +644,26 @@ network, and reboot record in that manifest. ## Seeed Tracker T1000-E +- [x] The cross-role `uf2reset` repair was hardware-qualified with Full + Companion ZIP SHA-256 + `16e470c0d66bec14e1e9f2d4ebe04d83ffb30efa606465f885cbe9fc3d004a14`. + It was built from `f2c15225` with GCC 14.2.1, the USA Cascadia/Cascade + profile, and embedded version + `v1.17.1-uf2reset-f2c15225-f2c15225`. Serial DFU took 32.196 seconds and + stable application USB returned in 35.888 seconds. Terminal entry, + version, help, the exact no-argument command, application disconnect, and + stable `2886:0057` boot USB all passed in one 8.898-second script. The same + image was restored in 32.204 seconds and stable `239a:8029` USB returned + in 36.313 seconds; a final terminal check returned Binary mode cleanly. +- [x] The fixed application advertises live as `MeshCore-09848C15` at + `D1:A0:86:CD:BB:C6`. The obsolete Pi bond for the earlier temporary + identity was removed, and the exact address was paired again with the + qualification PIN. Paired, bonded, and trusted state, an encrypted BLEDfu + revision read, and the Nordic UART service all passed. The pre-run old + application enumerated but produced neither a terminal response nor a + live advertisement, so this run does not claim continuity of that + temporary identity or of application-owned settings across a combined + SoftDevice/bootloader recovery install. - [x] Stable application identity `34A9141999729D5D` is recorded and is not confused with the RAK3401 even though both currently enumerate with USB product ID `239a:8029`. Application is `239a:8029`; the exercised @@ -946,6 +966,16 @@ network, and reboot record in that manifest. ## Heltec T096 +- [x] The cross-role `uf2reset` repair was hardware-qualified on the exact + no-footer Full Companion image. ZIP SHA-256 is + `0585bee1c0b278a68788f78af384bcbdb5633d57f9ea301fc14f1c80c4275489`; + embedded version is `v1.17.1-uf2reset-f2c15225-f2c15225`. Terminal entry + and help passed, the exact command disconnected application USB in + 1.031 seconds, and stable boot USB returned in 2.026 seconds. The final + Full image installed over serial in 38.900 seconds and stable application + USB returned in 41.672 seconds. Final primary terminal/Binary switching, + dedicated logging interface, version, help entry, and live + `MeshCore-8E229D0A` advertisement at `CF:A8:18:36:DE:8B` all passed. - [x] Stable USB serial `651F8E496197F882` distinguishes the application (`239A:8029`) from the OTAFIX serial-DFU interface. Every flash followed that identity across the host-assigned `COM9 -> COM6 -> COM9` transition; diff --git a/examples/companion_radio/MyMesh.cpp b/examples/companion_radio/MyMesh.cpp index 2e27a199..0c428c93 100644 --- a/examples/companion_radio/MyMesh.cpp +++ b/examples/companion_radio/MyMesh.cpp @@ -7214,6 +7214,9 @@ void MyMesh::handleTerminalCommand(char* command) { #endif terminalOutput().print(" reboot\r\n"); terminalOutput().print(" ver\r\n"); +#if defined(NRF52_PLATFORM) + terminalOutput().println(" uf2reset"); +#endif if (_terminal_mode) { terminalOutput().print(" +++MESHCORE-TERM-STOP\r\n"); } else { @@ -7281,6 +7284,13 @@ bool MyMesh::handleCommand(const char* command, uint32_t sender_timestamp, } #endif + if (sender_timestamp == 0 && mesh::cli::isUf2ResetCommand(command)) { + if (!board.rebootToUf2Bootloader()) { + snprintf(reply, reply_capacity, "ERR: unsupported"); + } + return true; + } + if (handleLocalControlCommand(command, reply, reply_capacity)) return true; if (strncmp(command, "set tx ", 7) == 0) { diff --git a/examples/simple_secure_chat/main.cpp b/examples/simple_secure_chat/main.cpp index 9c26517a..c665e8e2 100644 --- a/examples/simple_secure_chat/main.cpp +++ b/examples/simple_secure_chat/main.cpp @@ -13,6 +13,7 @@ #endif #include +#include #include #include #include @@ -595,6 +596,10 @@ public: } else { Serial.printf(" ERROR: unknown config: %s\n", config); } + } else if (mesh::cli::isUf2ResetCommand(command)) { + if (!board.rebootToUf2Bootloader()) { + Serial.println(" ERROR: UF2 bootloader is unavailable"); + } } else if (memcmp(command, "ver", 3) == 0) { Serial.println(FIRMWARE_VER_TEXT); } else if (memcmp(command, "help", 4) == 0) { @@ -617,6 +622,9 @@ public: Serial.println(" advert"); Serial.println(" reset path"); Serial.println(" public "); +#if defined(NRF52_PLATFORM) + Serial.println(" uf2reset"); +#endif } else { Serial.print(" ERROR: unknown command: "); Serial.println(command); } diff --git a/src/MeshCore.h b/src/MeshCore.h index 3279b2db..79819e6d 100644 --- a/src/MeshCore.h +++ b/src/MeshCore.h @@ -137,6 +137,10 @@ public: virtual void onBeforeTransmit() { } virtual void onAfterTransmit() { } virtual void reboot() = 0; + // Reboot into a UF2-capable bootloader when the platform supports the + // retained reset request. Returns false only when unsupported or rejected; + // a successful implementation resets and does not return. + virtual bool rebootToUf2Bootloader() { return false; } virtual void powerOff() { /* no op */ } // Reload an already-running system watchdog without enabling one. Long, // internally bounded operations can use this while retaining their own diff --git a/src/helpers/CLICommandUtils.h b/src/helpers/CLICommandUtils.h index 3f64f878..c72fb22a 100644 --- a/src/helpers/CLICommandUtils.h +++ b/src/helpers/CLICommandUtils.h @@ -517,6 +517,10 @@ inline NoArgCommandMatch matchNoArgCommand(const char* command, : NoArgCommandMatch::HasArguments; } +inline bool isUf2ResetCommand(const char* command) { + return matchNoArgCommand(command, "uf2reset") == NoArgCommandMatch::Exact; +} + inline void formatUnknownSetting(char* reply, size_t capacity, const char* setting) { if (reply == nullptr || capacity == 0) return; diff --git a/src/helpers/CommonCLI.cpp b/src/helpers/CommonCLI.cpp index d5e78c36..e98db71f 100644 --- a/src/helpers/CommonCLI.cpp +++ b/src/helpers/CommonCLI.cpp @@ -18,29 +18,6 @@ #include #include -#if defined(NRF52_PLATFORM) -#include -#include - -#ifndef DFU_MAGIC_UF2_RESET -#define DFU_MAGIC_UF2_RESET 0x57 -#endif - -static void resetToUf2Bootloader() { - uint8_t sd_enabled = 0; - sd_softdevice_is_enabled(&sd_enabled); - - if (sd_enabled) { - sd_power_gpregret_clr(0, 0xFF); - sd_power_gpregret_set(0, DFU_MAGIC_UF2_RESET); - } else { - NRF_POWER->GPREGRET = DFU_MAGIC_UF2_RESET; - } - - NVIC_SystemReset(); -} -#endif - #define STR_HELPER(x) #x #define STR(x) STR_HELPER(x) #if defined(ENABLE_OTA) @@ -2486,12 +2463,11 @@ void CommonCLI::handleCommand(uint32_t sender_timestamp, char* command, char* re _board->powerOff(); // doesn't return } else if (memcmp(command, "reboot", 6) == 0) { _board->reboot(); // doesn't return - } else if (sender_timestamp == 0 && memcmp(command, "uf2reset", 8) == 0 && (command[8] == 0 || command[8] == ' ')) { -#if defined(NRF52_PLATFORM) - resetToUf2Bootloader(); // doesn't return -#else - strcpy(reply, "ERR: unsupported"); -#endif + } else if (sender_timestamp == 0 + && mesh::cli::isUf2ResetCommand(command)) { + if (!_board->rebootToUf2Bootloader()) { + strcpy(reply, "ERR: unsupported"); + } } else if (memcmp(command, "clkreboot", 9) == 0) { // Reset clock getRTCClock()->setCurrentTime(1715770351); // 15 May 2024, 8:50pm diff --git a/src/helpers/NRF52Board.cpp b/src/helpers/NRF52Board.cpp index 72c2f036..0f30a08c 100644 --- a/src/helpers/NRF52Board.cpp +++ b/src/helpers/NRF52Board.cpp @@ -33,6 +33,10 @@ static bool ota_ble_started = false; #define NRF52_WATCHDOG_TIMEOUT_SECONDS 60UL #endif +#ifndef DFU_MAGIC_UF2_RESET +#define DFU_MAGIC_UF2_RESET 0x57 +#endif + #if NRF52_WATCHDOG_TIMEOUT_SECONDS > 131071UL #error "NRF52_WATCHDOG_TIMEOUT_SECONDS exceeds the nRF52 WDT counter range" #endif @@ -96,6 +100,23 @@ bool NRF52Board::isUserGpioAvailable(uint8_t pin) const { #endif } +bool NRF52Board::rebootToUf2Bootloader() { + uint8_t sd_enabled = 0; + if (sd_softdevice_is_enabled(&sd_enabled) != NRF_SUCCESS) return false; + + if (sd_enabled) { + if (sd_power_gpregret_clr(0, 0xFF) != NRF_SUCCESS + || sd_power_gpregret_set(0, DFU_MAGIC_UF2_RESET) != NRF_SUCCESS) { + return false; + } + } else { + NRF_POWER->GPREGRET = DFU_MAGIC_UF2_RESET; + } + + NVIC_SystemReset(); + return true; +} + static void connect_callback(uint16_t conn_handle) { ota_conn_handle = conn_handle; MESH_DEBUG_PRINTLN("BLE client connected"); diff --git a/src/helpers/NRF52Board.h b/src/helpers/NRF52Board.h index 00dcdd38..1ed83dd1 100644 --- a/src/helpers/NRF52Board.h +++ b/src/helpers/NRF52Board.h @@ -63,6 +63,7 @@ public: virtual uint8_t getStartupReason() const override { return startup_reason; } virtual float getMCUTemperature() override; virtual void reboot() override { NVIC_SystemReset(); } + bool rebootToUf2Bootloader() override; virtual void shutdownPeripherals(); virtual void powerOff() override; virtual bool getBootloaderVersion(char* version, size_t max_len) override; diff --git a/test/README.md b/test/README.md index c61f55e9..e2b6336c 100644 --- a/test/README.md +++ b/test/README.md @@ -20,6 +20,7 @@ python3 test/test_companion_transport_selector.py # Indicator transport-selecto python3 test/test_color_theme.py # shared color-display dark-palette contract python3 test/test_indicator_font_recovery.py # Indicator TLS/SD font recovery contract python3 test/test_companion_terminal_profile.py # Companion CLI capability gates +python3 test/test_nrf52_uf2reset_cli.py # all nRF52 text-CLI reset dispatchers python3 test/test_client_login_profile_contract.py # ACL login ordering/role contract python3 test/test_client_acl_spiffs.py # Actual ACL: first login, replay/reboot, failed storage python3 test/test_replay_reset_command.py # Strict full keys and one-use recovery confirmations diff --git a/test/test_cli_command_utils/test_cli_command_utils.cpp b/test/test_cli_command_utils/test_cli_command_utils.cpp index d7b81f5f..9d754440 100644 --- a/test/test_cli_command_utils/test_cli_command_utils.cpp +++ b/test/test_cli_command_utils/test_cli_command_utils.cpp @@ -719,6 +719,15 @@ TEST(CLICommandUtils, MatchesDiscoverNeighborsWithoutPrefixCollisions) { "discover.neighbors.extra", "discover.neighbors")); } +TEST(CLICommandUtils, MatchesOnlyACompleteUf2ResetCommand) { + EXPECT_TRUE(mesh::cli::isUf2ResetCommand("uf2reset")); + EXPECT_TRUE(mesh::cli::isUf2ResetCommand("uf2reset ")); + EXPECT_FALSE(mesh::cli::isUf2ResetCommand("uf2reset now")); + EXPECT_FALSE(mesh::cli::isUf2ResetCommand("uf2reset.extra")); + EXPECT_FALSE(mesh::cli::isUf2ResetCommand("uf2")); + EXPECT_FALSE(mesh::cli::isUf2ResetCommand(nullptr)); +} + TEST(CLICommandUtils, ParsesRecentRepeaterListAndPageQueries) { using mesh::cli::RecentRepeaterGetMatch; mesh::cli::RecentRepeaterGetQuery query; diff --git a/test/test_nrf52_uf2reset_cli.py b/test/test_nrf52_uf2reset_cli.py new file mode 100644 index 00000000..f54ca1e3 --- /dev/null +++ b/test/test_nrf52_uf2reset_cli.py @@ -0,0 +1,50 @@ +#!/usr/bin/env python3 +"""Pin local UF2 reset support across every nRF52 text CLI family.""" + +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[1] + +main_board = (ROOT / "src/MeshCore.h").read_text() +nrf_board_h = (ROOT / "src/helpers/NRF52Board.h").read_text() +nrf_board_cpp = (ROOT / "src/helpers/NRF52Board.cpp").read_text() +common = (ROOT / "src/helpers/CommonCLI.cpp").read_text() +companion = (ROOT / "examples/companion_radio/MyMesh.cpp").read_text() +chat = (ROOT / "examples/simple_secure_chat/main.cpp").read_text() + +# One board capability owns the retained-register transaction. This reaches +# every NRF52Board subclass, including variants which override handleCommand(). +assert "virtual bool rebootToUf2Bootloader() { return false; }" in main_board +assert "bool rebootToUf2Bootloader() override;" in nrf_board_h +assert "bool NRF52Board::rebootToUf2Bootloader()" in nrf_board_cpp +assert "sd_softdevice_is_enabled(&sd_enabled) != NRF_SUCCESS" in nrf_board_cpp +clear = nrf_board_cpp.index("sd_power_gpregret_clr(0, 0xFF)") +set_magic = nrf_board_cpp.index( + "sd_power_gpregret_set(0, DFU_MAGIC_UF2_RESET)" +) +reset = nrf_board_cpp.index("NVIC_SystemReset();", set_magic) +assert clear < set_magic < reset +assert "NRF_POWER->GPREGRET = DFU_MAGIC_UF2_RESET" in nrf_board_cpp + +# CommonCLI covers repeater, room-server, and sensor roles. Companion and +# Terminal Chat have independent parsers, so each must delegate explicitly. +assert "mesh::cli::isUf2ResetCommand(command)" in common +assert "_board->rebootToUf2Bootloader()" in common +assert "sender_timestamp == 0" in common + +assert "sender_timestamp == 0 && mesh::cli::isUf2ResetCommand(command)" in companion +assert "board.rebootToUf2Bootloader()" in companion +assert 'terminalOutput().println(" uf2reset");' in companion + +assert "mesh::cli::isUf2ResetCommand(command)" in chat +assert "board.rebootToUf2Bootloader()" in chat +assert 'Serial.println(" uf2reset");' in chat + +# Platform register manipulation must not be copied into role-specific CLI +# files; otherwise one role can drift again. +for role_source in (common, companion, chat): + assert "DFU_MAGIC_UF2_RESET" not in role_source + assert "sd_power_gpregret_set" not in role_source + +print("test_nrf52_uf2reset_cli: PASS")