diff --git a/docs/rak3401_mota_chain.md b/docs/rak3401_mota_chain.md index 4ebaf104..a77bab6e 100644 --- a/docs/rak3401_mota_chain.md +++ b/docs/rak3401_mota_chain.md @@ -1,318 +1,216 @@ # RAK3401 1W repeater LoRa update chain -> Status: the second corrected v1.17.01 candidate is withdrawn. A physical -> RAK3401 run passed steps 1 through 15, then step 16 booted but could not -> validate its EndF. The runner blocks this exact bundle before device access. +> Status: physically passed. The exact 29-step chain completed from the +> c1caa5ad v1.16.7.0 test image through v1.17.01 on a RAK3401 1W. Every step +> passed its post-boot version, EndF, rescue-command, and watchdog checks. -This procedure records the exact 26-step update chain from the release +The release is [`rak3401-mota-v1.16.07-c1caa5ad-to-v1.17.01-c96bdd6e`](https://github.com/mikecarper/MeshCore/releases/tag/rak3401-mota-v1.16.07-c1caa5ad-to-v1.17.01-c96bdd6e). -It is intentionally specific to the following destination firmware: +Use this asset: + +```text +RAK3401-update-chain-v1.16.7-c1caa5ad-to-v1.17.01-cd824765.zip +``` + +- ZIP SHA-256: `eac67a0be12690b7e22c4d1f6a15bfdeb5bd627c4850b246b1be4220e5607b34` +- inner `SHA256SUMS.txt` SHA-256: `8097d75c5d11b9e32e3ebd4971068bf743eaa108054ab39eb0049016f89d185d` +- endpoint image SHA-256: `5c8d94bb23e87c23b0374ffb5a46e0c1205d6eebcb9d8bae3be6fda2613f9f79` +- endpoint EndF body hash: `DD45E7A23438B051` + +The older v1.17.01 candidates and the v1.17.02 chain are retained only for +diagnosis. The runner recognizes their hashes and refuses live installation +before opening a device or reading a password. + +## Exact destination + +This chain is intentionally specific to one firmware lineage and hardware +target: - target ID: `2FA509C1` - hardware: `RAK_3401` - role: `RAK_3401_repeater_lora_ota_no_external_sensors` -- starting version: `1.16.7.0`, build `c1caa5ad` -- starting EndF body hash: `71F4026CBE4B8B74` -- destination key used for this deployment: - `63d8df6387eaffd2e25db7d2a8ad967a65202182a48d681d7e7a9260f917280d` +- start: `v1.16.7.0-c1caa5ad`, EndF `71F4026CBE4B8B74` +- endpoint: `v1.17.01-halo-keymind-cascade-dev-cd824765`, EndF `DD45E7A23438B051` +- deployment target key: `63d8df6387eaffd2e25db7d2a8ad967a65202182a48d681d7e7a9260f917280d` -Do not use this chain on another node, and do not start it on another RAK3401. -The physical test has withdrawn this exact chain. Use it only for offline -diagnosis; the runner refuses live deployment even with -`--accept-test-candidate`. +Do not use the chain on another target ID, hardware family, starting image, or +firmware body hash. The runner checks all four. -## Withdrawn second v1.17.01 candidate +## What was tested -The replacement rebuilt steps 6 through 15 around the first observed SHA -failure. Step 15 used software SHA-256 and passed its physical EndF check. -Step 16 re-enabled CC310 SHA with checked return codes. It downloaded and -applied successfully, booted `v1.16.9.112-ea3843e0`, then returned `ERR no -EndF`. This proves that CC310 can report success yet produce a wrong digest -for the memory-mapped application image; return-code fallback is insufficient. +The direct physical run used: -```text -/home/mesh/git/MeshCore/out-rak3401-mota-v1.17.01-corrected/RAK3401-update-chain-v1.16.7-c1caa5ad-to-v1.17.01-c96bdd6e.zip -``` +- a Heltec V4.3 OLED 2 MB Full Companion on `/dev/ttyACM0`; +- the same Full Companion at `192.168.1.51`, with mOTA seeding on TCP `5001` + and its local OTA/TempRadio console on TCP `5002`; +- Full Companion key + `8ce031bc322f3cf77376eeacfe8800b30d8c5dfe927f3636d801f3455a2dd4cf`; +- one direct RF link to `RAK3401 1W Repeater`; +- temporary radio `909.950 MHz`, `500 kHz`, `SF5`, `CR5`; +- all 29 freshly rebuilt 1 KiB-block mOTA packages. -ZIP SHA-256: -`46c7480ed6bdc2aa01fb23a0f70e34c4012ffdd42b616d07bde66cf66d594630`. -The largest package is 88,280 bytes, leaving 1,832 bytes below the staging -limit. All 26 deltas reconstruct with both zero-filled and 0xFF-filled -workspaces. The endpoint has whole-image SHA-256 -`e923f6209e0f071a5862ffbc7690e0b355c1e74829067e0bfdacd0de6c316065` -and EndF body hash `AC267B02E055F42E`. +The first verified transition completed at `2026-08-14T01:50:24Z`; the final +endpoint completed at `2026-08-14T06:41:36Z`. The ZIP contains the complete +29-row physical record as `PHYSICAL-TEST.jsonl`. -The withdrawn bundle includes the exact v1.16.7 test-start UF2/Nordic DFU ZIP, a -diagnostic corrected step-6 image, and the final recovery UF2/ZIP. Restore the -lab node locally to the included test-start image before rerunning the chain. -The bundle's `RUNBOOK.md` is retained for diagnosis only. +The largest package is 89,844 bytes and leaves 268 bytes of staging margin. +Every delta was also reconstructed offline with both zero-filled and +`0xFF`-filled workspaces and verified independently with `motatool`. Step 25 is +an intentional extra bridge that keeps the compiler-layout transition small. -## Requirements for the next chain +Step 1 installs both the guarded rescue command and the accelerated four-block +primary requester. The old requester used for the first transfer is serial, so +step 1 is expected to be slower. Steps 2 through 29 use the accelerated +requester. Some historical bridge text still says `low priority`; those +images were built with `OTA_TX_PRIORITY=0`, so their transfer traffic is +actually primary. -The next chain must use software SHA-256 for every bridge. CC310 may remain -enabled for AES, HMAC, and entropy, but not for either `Utils::sha256` -overload. The guarded `ota rescue install ` command must be in -the first installed bridge (step 1) and every target after it. Offline -validation must reconstruct each target and prove that command is present; -the live runner also probes `ota help` after every successful transition and -refuses to expose a bridge without the rescue path to another update. - -## Withdrawn v1.17.02-chain smoke-test result - -The lab topology used a Heltec V4.3 OLED ESP32 Full Companion on -`/dev/ttyACM0` and a direct LoRa path to `RAK3401 1W Repeater`. The remote -admin password is the lowercase default `password`, not `Password`. - -Steps 1 through 5 completed and passed an exact post-boot version and EndF -body-hash check. Step 6 then did all of the following successfully: - -- downloaded all 79 blocks of mOTA ID `7C8BC884`; -- verified the watchdog was off immediately before install; -- returned `verified (unsigned); applying`; -- booted `v1.16.08.7-halo-keymind-cascade-b-0c4ed3ac`; -- retained a soft-reset boot record consistent with OTAFIX apply success. - -The resulting bridge returns `ERR no EndF (firmware lacks the trailer?)` from -`ota self`. The release package is not corrupt: independently applying its -delta reconstructs the expected 524,204-byte image with whole-image SHA-256 -`61ced8b63953c614748c2fa1b04c2c01e8eb6626a604f6ef95fd2594d6d8ce71` -and valid EndF body hash `A0130F9BD6760D47`. - -The regression is in the intermediate application. This is the first bridge -in the chain built with the old CC310 hardware-crypto SHA path. That path -passes the memory-mapped application body to CC310 and ignores a failed return -code instead of falling back to software SHA. The preceding v1.16.08 image -uses software SHA, while later code contains the required checked fallback. -The normal nRF52 install gate correctly refuses step 7 when it cannot validate -the running body. Forcing the bootloader trigger would bypass an application -safety gate and is not an acceptable recovery. - -The lab RAK remains alive on v1.16.08.7. Its normal radio is restored to -`910.525,62.5,7,5`, and `system.watchdog` is restored and verified `on`. - -## Recover the lab RAK - -The latest physical run stopped on `v1.16.9.112-ea3843e0`: normal radio is -restored to `910.525,62.5,7,5` and `system.watchdog` is verified `on`, but the -application reports `ERR no EndF`. That bridge predates the guarded rescue -command, so recovery requires a local data path to the RAK3401 itself. The -Heltec controller cable cannot recover the remote RAK. - -1. Connect the RAK3401 itself to this host by USB. -2. Enter its exact-board UF2 bootloader, using a local `uf2reset` command or - the board's double-reset gesture. -3. For another chain test, flash the exact c1caa5ad test-start UF2 from the - withdrawn bundle's `recovery/test-start/` directory. Do not use its final - recovery image as a production recovery target; it predates the new - software-only SHA rule. -4. After it boots, require the c1caa5ad start identity, target `2FA509C1`, - hardware `RAK_3401`, a valid `ota self` reply, normal radio settings, and - watchdog `on`. - -Do not force an apply or issue a remote recovery reboot. Once the -RAK USB cable is attached, the port and UF2 mount can be detected and the -recovery completed without guessing device paths. - -## Intended runner safety behavior - -Stable OTAFIX 2.4 cannot feed a system watchdog inherited by the bootloader. -The dormant live path implements the release's watchdog sequence: - -1. Persist `system.watchdog off` without issuing a normal reboot. -2. Wait 90 seconds for the already-running watchdog to reset the node. -3. Require the node to report `> off`. -4. Prove another 90 seconds of stable uptime. -5. Enforce the requested `ota config hops` reach before every chain step. -6. Recheck `> off` immediately before every `ota install` command. -7. Re-enable the watchdog only after the exact step 26 image boots and passes - both version and EndF body-hash verification. - -These controls remain requirements for the next candidate. They cannot repair -a running bridge that lacks both valid app-side EndF validation and the rescue -command. A replacement must pass a complete physical-board run before its -lab-only gate is removed. - -An interrupted download is resumable. Avoid interrupting power or radio -coverage while `ota install` is applying a package. Physical USB recovery is -the fallback if an apply is interrupted at the wrong instant. - -## Host and radio requirements +## Host requirements Install: -- Python 3.9 or newer -- `meshcli` 1.6.0 or newer -- a current `motatool` -- a Full Companion or OTA-enabled repeater to seed the files +- Python 3.9 or newer; +- `meshcli` 1.6.0 or newer; +- the current `motatool`; +- a Full Companion or OTA-enabled repeater that can seed mOTA files. -The ESP32 Full Companion serves mOTA data through its dedicated TCP port -`5001`; its USB port remains the Binary API controller. Its local OTA and -TempRadio console is TCP port `5002`. For a Heltec Full Companion at -`192.168.1.51`, use these three connections together: +The runner maintains one persistent controller connection. It logs into the +destination once, keeps that authenticated radio-node session across +application reboots, and does not treat a silent packet as lost +authentication. The source seeder reconnects for each isolated package but +does not need remote-admin login. -```text -controller: /dev/ttyACM0 -mOTA source: 192.168.1.51:5001 -source CLI: 192.168.1.51:5002 -``` +## Verify the release offline -Before a deployment, verify that USB and TCP port `5000` report the same -Companion public key: - -```bash -meshcli -s /dev/ttyACM0 -j -c off infos -meshcli -t 192.168.1.51 -p 5000 -j -c off infos -``` - -The tested Heltec key is -`8ce031bc322f3cf77376eeacfe8800b30d8c5dfe927f3636d801f3455a2dd4cf`. -Change the IP address for a different deployment. - -The source, controller, destination, and every intermediate relay must support -the temporary tuple `909.950,250,5,5`. Confirm that this frequency and -bandwidth are legal at the deployment location. All nodes return to their -ordinary radio after each step. - -## Verify without touching a radio - -The chain runner pins the release ZIP SHA-256 to -`46c7480ed6bdc2aa01fb23a0f70e34c4012ffdd42b616d07bde66cf66d594630`. -It also pins and verifies the inner checksum list, checks complete checksum -coverage, parses every manifest, verifies the chain continuity, and runs -`motatool verify` on all 26 containers. - -It downloads the release automatically when `--bundle` is omitted: +No password or device is needed: ```bash python3 tools/lora_ota/rak3401_mota_chain.py \ + --bundle /path/to/RAK3401-update-chain-v1.16.7-c1caa5ad-to-v1.17.01-cd824765.zip \ --work-dir ./rak3401-mota-chain-work \ --motatool /path/to/motatool \ --verify-only ``` -An already-downloaded asset can be supplied with `--bundle`. Only the pinned -ZIP or its extracted root is accepted. +When `--bundle` is omitted, the runner downloads the pinned release asset. It +checks the outer ZIP hash, complete inner checksum coverage, all 29 manifests, +chain continuity, audited image anchors, final recovery image, and every mOTA +container with `motatool`. -## Withdrawn-release verification +## Direct recoverable bench run: BW500/SF5 -No password or device connection is needed for offline verification: +Restore the test RAK locally with the ZIP's +`recovery/test-start/RAK3401-test-start-v1.16.7-c1caa5ad.uf2`. Before starting, +require start version `1.16.7.0`, body hash `71F4026CBE4B8B74`, target +`2FA509C1`, hardware `RAK_3401`, and OTAFIX mOTA ABI 2 with codec 2. -```bash -python3 tools/lora_ota/rak3401_mota_chain.py \ - --work-dir ./rak3401-mota-chain-work \ - --motatool /path/to/motatool \ - --verify-only -``` - -Use `--verify-only` for this withdrawn bundle. Every live invocation exits -before connecting to a device, including one with `--accept-test-candidate`. -The older failed v1.17.01 and v1.17.02 chains are also recognized and blocked. - -## Future direct lab template - -This template becomes usable only after a new ZIP and its hashes are pinned in -the runner. Use it only on a locally recoverable test RAK3401. Keep the work -directory for resume, and retain the explicit `--accept-test-candidate` gate. +For the tested direct topology: ```bash export MESHCORE_ADMIN_PASSWORD='password' python3 tools/lora_ota/rak3401_mota_chain.py \ + --bundle /path/to/RAK3401-update-chain-v1.16.7-c1caa5ad-to-v1.17.01-cd824765.zip \ --work-dir ./rak3401-mota-chain-work \ --controller-serial /dev/ttyACM0 \ --source-tcp 192.168.1.51:5001 \ --source-cli-tcp 192.168.1.51:5002 \ --source-shares-controller \ - --ota-hops 3 \ - --accept-test-candidate \ + --target-key 63d8df63 \ + --temp-radio 909.950,500,5,5,120 \ + --ota-hops 1 \ --motatool /path/to/motatool \ --yes ``` -The intended runner logs each isolated step attempt below `steps/`, records every -verified transition in `progress.jsonl`, and prints the source log path. Rerun -the exact command after a host restart or recoverable RF failure. Status polling -starts at 60 seconds, expands adaptively when replies are slow or the link is -contended, and contracts after quick replies. The transfer itself is primary -traffic, so reserve the TempRadio window for OTA on a busy or relayed mesh. +BW500 is for the controlled direct bench setup. Confirm that the selected +frequency and bandwidth are legal at the test location. -## Relayed lab template +Keep the work directory. Each package gets an isolated attempt directory, and +each exact transition is appended to `progress.jsonl`. After a host or RF +interruption, rerun the identical command with the same work directory. The +runner resumes only when the live body hash matches the recorded chain; never +manually skip to a package. -Every intermediate relay must enter the same TempRadio tuple. Add one -`--relay` argument for each intermediate repeater, ordered from the destination -side back toward the controller. In other words, list the farthest relay first -and the nearest relay last. A bare relay name uses -`MESHCORE_ADMIN_PASSWORD`; append `=PASSWORD` only when that relay has a -different password. +## Live deployment with two intermediate relays: BW250/SF5 -The word "hop" is sometimes counted two ways: +For the intended private TempRadio deployment, use BW250/SF5 and two +intermediate relays. List relays from the destination side toward the +controller: farthest first, nearest last. With two relays there are three RF +links, so use `--ota-hops 3`. -- `controller -> relay -> destination` is two RF links but has one - intermediate relay, so pass one `--relay`. -- `controller -> near relay -> far relay -> destination` has two intermediate - relays, so pass two `--relay` values, `far` first and `near` second. - -For one intermediate relay: +Run a non-mutating preflight first: ```bash +export MESHCORE_ADMIN_PASSWORD='your-destination-and-relay-password' + python3 tools/lora_ota/rak3401_mota_chain.py \ + --bundle /path/to/RAK3401-update-chain-v1.16.7-c1caa5ad-to-v1.17.01-cd824765.zip \ --work-dir ./rak3401-mota-chain-work \ --controller-serial /dev/ttyACM0 \ - --source-tcp 192.168.1.51:5001 \ - --source-cli-tcp 192.168.1.51:5002 \ + --source-tcp SOURCE_IP:5001 \ + --source-cli-tcp SOURCE_IP:5002 \ --source-shares-controller \ - --ota-hops 3 \ - --relay 'Intermediate Repeater' \ - --accept-test-candidate \ - --motatool /path/to/motatool \ - --yes -``` - -For two intermediate relays: - -```bash -python3 tools/lora_ota/rak3401_mota_chain.py \ - --work-dir ./rak3401-mota-chain-work \ - --controller-serial /dev/ttyACM0 \ - --source-tcp 192.168.1.51:5001 \ - --source-cli-tcp 192.168.1.51:5002 \ - --source-shares-controller \ - --ota-hops 3 \ + --target-key 63d8df63 \ --relay 'Far Repeater' \ --relay 'Near Repeater' \ - --accept-test-candidate \ + --temp-radio 909.950,250,5,5,120 \ + --ota-hops 3 \ --motatool /path/to/motatool \ - --yes + --preflight-only ``` -Do not begin a production deployment until a replacement bundle passes a -direct physical smoke test from start to endpoint. Run `--preflight-only` with -the same relay arguments first. The runner sends -the destination to TempRadio, then the relays farthest-to-nearest while their -remaining normal routes still exist. It checks that the 120-minute TempRadio -window covers the selected relay count and all configured timeouts. The -starting build may report `hops=0` after the watchdog-driven reset, so the -runner re-enforces `--ota-hops 3` before every step. That setting accepts OTA -packets received through up to three intermediate repeaters. +A bare relay name uses `MESHCORE_ADMIN_PASSWORD`. Append `=PASSWORD` only for +a relay with a different password. After preflight succeeds, rerun the same +command with `--yes` in place of `--preflight-only`. -For a lossy multi-hop path, increase `--reply-timeout`, -`--discovery-timeout`, or `--transfer-timeout-minutes` as needed. If their sum -no longer fits the TempRadio window, also increase the minutes in -`--temp-radio`; the runner rejects an unsafe combination before connecting. +Arm the relay chain farthest-to-nearest while all nodes are still reachable on +the ordinary mesh. The runner then moves the source and controller to the same +temporary tuple. Keep the private TempRadio network reserved for OTA during +the run; public-mesh flooding does not improve this transfer. A 120-minute +window is the minimum used here—raise it if the selected timeouts and relay +count require more time. -## Completion checks +## Guarded no-EndF rescue + +The rescue command is present from the first installed bridge onward. If a +bridge remains reachable but `ota self` reports no EndF, serve and fetch only +its exact successor package. Read that row's 16-hex `base_body_hash` from +`CHAIN.csv`, then issue: + +```text +ota rescue install +``` + +This is not a force command. It refuses a valid normal EndF, a base mismatch, +wrong target or hardware, or invalid payload. OTAFIX hashes the physical +running application before its first write. A missing/corrupt EndF can +therefore continue through its exact successor instead of becoming stuck, +provided the installed bridge already contains the rescue command. The +c1caa5ad starting image predates rescue, so a failure before step 1 completes +still requires local USB recovery. + +## Safety and completion + +The runner performs the following guarded sequence: + +1. Persist `system.watchdog off`, allow the already-running watchdog to reset + once, and prove stable uptime with the watchdog off. +2. Recheck destination identity, OTA reach, source identity, and exact package + base before every step. +3. Require `system.watchdog` to report `off` immediately before every install. +4. After every reboot, require the exact version and EndF body hash, then prove + the rescue command is present before exposing that bridge to another step. +5. Re-enable the watchdog only after the exact step-29 endpoint boots. Success requires all of the following: -- version `v1.17.1.0` -- target ID `2FA509C1` -- hardware `RAK_3401` -- final EndF body hash `AC267B02E055F42E` -- `get system.watchdog` returning `> on` +- version `v1.17.01-halo-keymind-cascade-dev-cd824765`; +- EndF body hash `DD45E7A23438B051`; +- target `2FA509C1`, hardware `RAK_3401`; +- normal radio `910.525 MHz`, `62.5 kHz`, `SF7`, `CR5`; +- `system.watchdog` verified `on`. -Do not treat a successful transfer alone as completion. Each temporary bridge -is a complete bootable image, but the RAK should not be left on a bridge -version between planned maintenance sessions. - -See [LoRa OTA automation](lora_ota_automation.md) for the generic single-step -workflow and source topology details. +A completed download or an accepted install reply is not success without the +post-reboot identity checks. The bundle includes exact test-start and final +local recovery UF2/ZIP files if USB recovery is ever needed. diff --git a/tools/lora_ota/lora_ota.py b/tools/lora_ota/lora_ota.py index 53c0fc21..cf2d55c5 100755 --- a/tools/lora_ota/lora_ota.py +++ b/tools/lora_ota/lora_ota.py @@ -1126,10 +1126,10 @@ class Controller: else: self.connection = ["-a", args.controller_ble] # Remote-admin authentication belongs to the radio node, not to each - # host command. Reuse it across application reboots; only repeated - # silence suggests that the session needs to be refreshed. + # host command. Reuse it across application reboots. A silent command + # is packet loss or an airtime-starved reply, not evidence that the + # authenticated session was lost. self._authenticated_targets: set[str] = set() - self._authenticated_target_failures: dict[str, int] = {} self._meshcli_session = ( PersistentMeshcliSession([ self.meshcli, "-j", "-c", "off", *self.connection, @@ -1148,9 +1148,6 @@ class Controller: authenticated = getattr(self, "_authenticated_targets", None) if authenticated is not None: authenticated.discard(target) - failures = getattr(self, "_authenticated_target_failures", None) - if failures is not None: - failures.pop(target, None) def _remote_auth_is_cached(self, target: str) -> bool: return target in getattr(self, "_authenticated_targets", set()) @@ -1158,25 +1155,7 @@ class Controller: def _cache_remote_auth(self, target: str) -> None: if not hasattr(self, "_authenticated_targets"): self._authenticated_targets = set() - if not hasattr(self, "_authenticated_target_failures"): - self._authenticated_target_failures = {} self._authenticated_targets.add(target) - self._authenticated_target_failures.pop(target, None) - - def _note_remote_silence(self, target: str) -> bool: - if not hasattr(self, "_authenticated_target_failures"): - self._authenticated_target_failures = {} - failures = self._authenticated_target_failures.get(target, 0) + 1 - self._authenticated_target_failures[target] = failures - if failures < 2: - return False - self.forget_remote_auth(target) - return True - - def _note_remote_reply(self, target: str) -> None: - failures = getattr(self, "_authenticated_target_failures", None) - if failures is not None: - failures.pop(target, None) def _execute( self, commands: list[str], label: str @@ -1369,17 +1348,12 @@ class Controller: and reply_matches_command(command_text, item["text"]) ] if not messages: - refresh = self._note_remote_silence(target) - refresh_note = ( - "; cached admin session will be refreshed on the next retry" - if refresh else "" - ) raise TransmissionError( f"no matching CLI reply from {target} for {command_text!r}; " - f"check its path and reply timeout{refresh_note}" + "check its path and reply timeout; the cached admin session " + "was retained" ) reply = messages[-1]["text"] - self._note_remote_reply(target) print(f"[{target}] {reply}") return reply diff --git a/tools/lora_ota/rak3401_mota_chain.py b/tools/lora_ota/rak3401_mota_chain.py index 37888f76..f454cea1 100755 --- a/tools/lora_ota/rak3401_mota_chain.py +++ b/tools/lora_ota/rak3401_mota_chain.py @@ -35,8 +35,8 @@ ASSET_URL = ( f"https://github.com/mikecarper/MeshCore/releases/download/{RELEASE_TAG}/" f"{ASSET_NAME}" ) -ASSET_SHA256 = "f843221aa9b33c452351b49e80c3cc9c8cc2ae9bfe80b68e0e83506583f5d12b" -CHECKSUM_LIST_SHA256 = "f30eb5169d21f09800eea89d30452c89118b62386e8cb0fb47c7682f06f37c2c" +ASSET_SHA256 = "eac67a0be12690b7e22c4d1f6a15bfdeb5bd627c4850b246b1be4220e5607b34" +CHECKSUM_LIST_SHA256 = "8097d75c5d11b9e32e3ebd4971068bf743eaa108054ab39eb0049016f89d185d" BUNDLE_ROOT_NAME = "RAK3401-update-chain-v1.16.7-c1caa5ad-to-v1.17.01-cd824765" # This 27-step candidate passed steps 1 and 2, then its test was deliberately @@ -105,8 +105,8 @@ WATCHDOG_STABILITY_WAIT_SECONDS = 90 # steps 1 through 14, but the first v1.17.01 candidate failed at step 15 because # that retained 386ae4a5 bridge still used unchecked CC310 SHA. Keep both # bundles useful for offline diagnosis, but fail closed before a live -# connection. The second corrected replacement remains behind an explicit -# lab-only gate until its end-to-end physical-board test is complete. +# connection. The exact 29-step replacement below passed its complete direct +# physical-board run on 14-Aug-2026. KNOWN_UNSAFE_STEP = 6 KNOWN_UNSAFE_VERSION = "1.16.8.7" KNOWN_UNSAFE_IMAGE_SHA256 = ( @@ -141,10 +141,10 @@ SUPERSEDED_27_FINAL_IMAGE_SHA256 = ( "e1376869da043c05792b3458e505e67e818f58d94d3fc050d22ab68578a2f2e8" ) -# Exact anchors for the fresh 29-step candidate. The pinned checksum list -# covers every byte; these anchors also make the live gate fail closed if its -# structural checks are ever reused independently. -CURRENT_CANDIDATE_ANCHORS = ( +# Exact anchors for the physically passed 29-step release. The pinned checksum +# list covers every byte; these anchors also make the live gate fail closed if +# its structural checks are ever reused independently. +PHYSICALLY_PASSED_ANCHORS = ( (1, "8364257a2b3a219905e870fad6fbb2040a96ca4b4bb7201b2867534cc2b45530"), (6, "4909b5cd50ca86e00b1583bf9ca50e0fc69808a4bebbec8b5e368304145e5d43"), (11, "28ba025251b9cf11376e09c2dc91619d9cc216de9f4998a5eeec7438b689d1c4"), @@ -183,7 +183,7 @@ SUPERSEDED_27_MESSAGE = ( "live installation of the superseded 27-step candidate is disabled: its " "physical test was stopped after step 2 so the adaptive primary requester " "could be moved into every historical bridge. Use --verify-only for that " - "bundle and use the fresh 29-step candidate for the lab rerun." + "bundle and use the physically passed 29-step release." ) @@ -196,18 +196,12 @@ def require_live_release_safe( steps: list[ChainStep], ) -> None: if len(steps) == EXPECTED_STEP_COUNT: - for number, expected_sha256 in CURRENT_CANDIDATE_ANCHORS: + for number, expected_sha256 in PHYSICALLY_PASSED_ANCHORS: if steps[number - 1].target_sha256 != expected_sha256: raise KnownUnsafeReleaseError( - "live installation is disabled: the 29-step candidate has " + "live installation is disabled: the 29-step release has " f"an unrecognized step-{number} image" ) - if not args.accept_test_candidate: - raise KnownUnsafeReleaseError( - "live installation of the fresh 29-step chain requires " - "--accept-test-candidate until its complete physical-board " - "test passes" - ) return step6_sha256 = steps[KNOWN_UNSAFE_STEP - 1].target_sha256 @@ -527,10 +521,10 @@ def parse_chain(bundle_root: Path) -> tuple[list[ChainStep], bytes]: if steps[0].from_version != EXPECTED_START_VERSION: raise ota.OtaError("chain has an unexpected starting version") if len(steps) == EXPECTED_STEP_COUNT: - for number, expected_sha256 in CURRENT_CANDIDATE_ANCHORS: + for number, expected_sha256 in PHYSICALLY_PASSED_ANCHORS: if steps[number - 1].target_sha256 != expected_sha256: raise ota.OtaError( - f"fresh candidate step {number} does not match its audited " + f"physically passed step {number} does not match its audited " "image pin" ) expected_final_version = EXPECTED_FINAL_VERSION @@ -995,8 +989,8 @@ def build_parser() -> argparse.ArgumentParser: "--accept-test-candidate", action="store_true", help=( - "allow the pinned fresh 29-step chain for its direct physical lab test; " - "it never overrides a bundle known to have failed physical testing" + "deprecated compatibility flag; the exact physically passed 29-step " + "release no longer requires it, and it never overrides a failed bundle" ), ) @@ -1186,8 +1180,8 @@ def main(argv: list[str] | None = None) -> int: print(f"WARNING: {SUPERSEDED_27_MESSAGE}", file=sys.stderr) else: print( - "Unfailed candidate verified offline; complete physical-board testing " - "is still required before production use." + "Physically passed release verified offline: all 29 direct " + "BW500/SF5 transitions passed on a RAK3401." ) return 0 diff --git a/tools/lora_ota/test_lora_ota.py b/tools/lora_ota/test_lora_ota.py index fd63a30b..f17c208d 100644 --- a/tools/lora_ota/test_lora_ota.py +++ b/tools/lora_ota/test_lora_ota.py @@ -684,7 +684,6 @@ class ReliabilityTests(unittest.TestCase): controller = object.__new__(ota.Controller) controller.reply_timeout = 20 controller._authenticated_targets = set() - controller._authenticated_target_failures = {} key = "A1" * 32 commands_seen: list[list[str]] = [] @@ -709,11 +708,10 @@ class ReliabilityTests(unittest.TestCase): controller._remote_command_once("remote", "ota status", "secret") self.assertIn("login", commands_seen[2]) - def test_two_silent_commands_refresh_cached_admin_session(self) -> None: + def test_silent_commands_retain_cached_admin_session(self) -> None: controller = object.__new__(ota.Controller) controller.reply_timeout = 20 controller._authenticated_targets = {"remote"} - controller._authenticated_target_failures = {} key = "A1" * 32 commands_seen: list[list[str]] = [] @@ -722,13 +720,13 @@ class ReliabilityTests(unittest.TestCase): objects = [{"adv_name": "remote", "public_key": key}] if "login" in commands: objects.append({"login_success": True}) + replies = [] + if len(commands_seen) == 3: replies = [{ "txt_type": 1, "text": "OTA | no download | target:1234ABCD", "pubkey_prefix": key[:12], }] - else: - replies = [] return objects, replies controller._run_marked = run_marked @@ -739,7 +737,7 @@ class ReliabilityTests(unittest.TestCase): controller._remote_command_once("remote", "ota status", "secret") self.assertNotIn("login", commands_seen[0]) self.assertNotIn("login", commands_seen[1]) - self.assertIn("login", commands_seen[2]) + self.assertNotIn("login", commands_seen[2]) def test_generic_retry_rejects_state_changing_ota_commands(self) -> None: controller = object.__new__(ota.Controller) @@ -1316,24 +1314,20 @@ class Rak3401KnownUnsafeReleaseTests(unittest.TestCase): argparse.Namespace(accept_test_candidate=True), steps ) - def test_fresh_29_step_candidate_requires_explicit_lab_gate(self) -> None: + def test_physically_passed_29_step_release_needs_no_lab_gate(self) -> None: steps = [mock.Mock(target_sha256="") for _ in range(29)] - for number, image_sha256 in rak_chain.CURRENT_CANDIDATE_ANCHORS: + for number, image_sha256 in rak_chain.PHYSICALLY_PASSED_ANCHORS: steps[number - 1].target_sha256 = image_sha256 - with self.assertRaisesRegex( - rak_chain.KnownUnsafeReleaseError, - "requires --accept-test-candidate", - ): - rak_chain.require_live_release_safe( - argparse.Namespace(accept_test_candidate=False), steps - ) + rak_chain.require_live_release_safe( + argparse.Namespace(accept_test_candidate=False), steps + ) rak_chain.require_live_release_safe( argparse.Namespace(accept_test_candidate=True), steps ) - def test_fresh_29_step_candidate_rejects_changed_anchor(self) -> None: + def test_physically_passed_29_step_release_rejects_changed_anchor(self) -> None: steps = [mock.Mock(target_sha256="") for _ in range(29)] - for number, image_sha256 in rak_chain.CURRENT_CANDIDATE_ANCHORS: + for number, image_sha256 in rak_chain.PHYSICALLY_PASSED_ANCHORS: steps[number - 1].target_sha256 = image_sha256 steps[10].target_sha256 = "00" * 32 with self.assertRaisesRegex(