mirror of
https://github.com/mikecarper/MeshCore.git
synced 2026-09-16 10:02:38 +00:00
Finalize tested RAK3401 v1.17.01 mOTA release
This commit is contained in:
+148
-250
@@ -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 <base_hash16>` 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 <base_body_hash>
|
||||
```
|
||||
|
||||
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.
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user