Harden and qualify compact RAK3401 OTA updates
This commit is contained in:
1 parent
47993d178e
commit
dc4a8808c0
22 files changed
+2156
-277
No files matched your search
@@ -2388,10 +2388,10 @@ apply_companion_radio_full_profile() {
|
||||
return 0
|
||||
fi
|
||||
|
||||
# ESP32 inherits the WiFi companion recipe and uses its dedicated TCP folder
|
||||
# seeder instead of multiplexing mOTA data onto USB.
|
||||
append_platformio_build_unflags "-DOTA_FOLDER_SERIAL"
|
||||
export PLATFORMIO_BUILD_FLAGS="${PLATFORMIO_BUILD_FLAGS} -UOTA_FOLDER_SERIAL -DWIFI_OTA_SEEDER=1"
|
||||
# ESP32 keeps both source transports. TCP remains the normal unattended
|
||||
# path; a host may explicitly hold the native USB port in Companion terminal
|
||||
# mode when WiFi is unavailable and serve the same folder protocol there.
|
||||
export PLATFORMIO_BUILD_FLAGS="${PLATFORMIO_BUILD_FLAGS} -DWIFI_OTA_SEEDER=1"
|
||||
|
||||
# BLE + WiFi exhaust internal DRAM on these high-capacity ESP32 recipes. Use
|
||||
# measured-safe tables for FULL OTA without changing ordinary USB/BLE/WiFi
|
||||
|
||||
@@ -289,6 +289,17 @@ Return to Binary mode with:
|
||||
Closing the USB data connection also resets the port to Binary mode. A
|
||||
different baud rate, including 57600, does not select ASCII mode.
|
||||
|
||||
On an ESP32 Full Companion built with `OTA_FOLDER_SERIAL`, `motatool` can keep
|
||||
that terminal session open as an mOTA folder source when WiFi is unavailable:
|
||||
|
||||
```sh
|
||||
motatool serve --serial /dev/ttyACM0 --companion-terminal --dir ./motas -v
|
||||
```
|
||||
|
||||
The explicit flag is required because an ESP32 Companion starts the same USB
|
||||
port in binary Companion mode. TCP port 5001 remains the preferred unattended
|
||||
source transport.
|
||||
|
||||
## nRF52 USB mOTA mode
|
||||
|
||||
The nRF52 full target has a third, exclusive USB mode for the host folder.
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
# Scripted LoRa OTA from start to finish
|
||||
|
||||
The dedicated [RAK3401 chain report](rak3401_mota_chain.md) records the
|
||||
physical failure of the withdrawn 26-step migration and the corrected
|
||||
v1.17.01 test candidate. Its runner blocks the withdrawn chain and requires an
|
||||
explicit lab-only gate for the corrected chain until physical testing passes.
|
||||
physical failures of withdrawn migrations and the physically qualified compact
|
||||
nine-step replacement. Its runner blocks the withdrawn chains and pins every
|
||||
accepted bridge image by SHA-256.
|
||||
|
||||
[`tools/lora_ota/lora_ota.sh`](https://github.com/mikecarper/MeshCore/blob/keymindCascade/tools/lora_ota/lora_ota.sh) and
|
||||
[`tools/lora_ota/lora_ota.ps1`](https://github.com/mikecarper/MeshCore/blob/keymindCascade/tools/lora_ota/lora_ota.ps1) automate a
|
||||
@@ -51,7 +51,9 @@ The USB ASCII switch (`+++MESHCORE-TERM-START`) is the local control path, not
|
||||
the mOTA data framing. On an nRF52 full Companion, the script uses that mode
|
||||
briefly for `ota status` and TempRadio commands. It then closes the CLI and
|
||||
starts `motatool`, whose existing `ota folder on` preamble switches the same
|
||||
USB port into exclusive mOTA mode. BLE remains available during that mode.
|
||||
USB port into exclusive mOTA mode. On an ESP32 Full Companion with serial
|
||||
folder support, `motatool --companion-terminal` keeps the ASCII session open
|
||||
while the same link carries framed folder requests. BLE remains available.
|
||||
|
||||
## Destination requirements
|
||||
|
||||
@@ -383,6 +385,10 @@ Useful controls:
|
||||
before every `ota install` transmission and refuses installation unless the
|
||||
destination reports `> off`. Use it for nRF52 chains whose bootloader cannot
|
||||
service an application watchdog inherited across reset.
|
||||
- Managed `--relay` nodes have their original `rxdelay` and `txdelay` captured
|
||||
in the work directory. During transfer the runner verifies `rxdelay 0` and
|
||||
an airtime-scaled `txdelay 0.3` (override with `--relay-txdelay`), then
|
||||
restores both values before the relay leaves TempRadio.
|
||||
- `--work-dir PATH` chooses a new, non-existent work directory.
|
||||
- `--meshcli PATH` and `--motatool PATH` select binaries not on `PATH`.
|
||||
|
||||
|
||||
+17
-1
@@ -552,7 +552,8 @@ This keeps each active transfer as one transmitter and one receiver while still
|
||||
repeaters between them and persistent archive nodes to improve future availability.
|
||||
|
||||
TempRadio is treated as a private maintenance network. Active transfer packets use priority 0, bypass the
|
||||
public-flood receive holdoff and relay jitter, and do not schedule generic flood retries. The bounded serving
|
||||
public-flood receive holdoff, retain the relay role's airtime-scaled transmit collision window, and do not
|
||||
schedule generic flood retries. The bounded serving
|
||||
queue admits at most two DATA/PROOF packets ahead of the radio while preserving at least four free packet-pool
|
||||
entries. CAD remains enabled to arbitrate the half-duplex channel, but its busy retry is scaled to one-quarter
|
||||
of a packet airtime and clamped to 5-50 ms instead of the ordinary 120-360 ms cadence. Discovery traffic keeps
|
||||
@@ -662,6 +663,21 @@ MotaDesc wire (38 B): mid[4] target_id(4) fw_version(4) codec(1) flags(1)
|
||||
status: 0 = OK, non-zero = error (out of range / past EOF).
|
||||
```
|
||||
|
||||
`SerialMotaSource` splits logical reads into replies of at most 192 payload
|
||||
bytes. A manifest's leaf table can exceed 256 bytes and payload blocks are
|
||||
normally 1 KiB; requesting either in one transaction can overrun common USB
|
||||
CDC/UART receive rings even though the host successfully wrote the complete
|
||||
reply. Chunking is internal to the transport and does not change `OP_READ` or
|
||||
the `MotaSource` random-access contract.
|
||||
|
||||
Manifest fragments are retained as bounded response jobs and admitted one at
|
||||
a time. Their source-side gap follows the active maximum packet airtime and
|
||||
dispatcher duty spacing, clamped to 100-1000 ms. The 100 ms floor protects
|
||||
fast radios' TX-to-RX turnaround; the cap keeps the receiver's one-second
|
||||
manifest progress/retry observation responsive. Relay collision delay is a
|
||||
separate setting: active OTA floods honor the relay role's configured
|
||||
`txdelay`, and the deployment runner temporarily uses `0.3` on managed relays.
|
||||
|
||||
**What to plug into `--serial`.** Use the USB serial console of an OTA-enabled MeshCore node built with
|
||||
`OTA_FOLDER_SERIAL`. The node must have a working LoRa radio plus an
|
||||
`ota folder on` command; that command confirms it can host and advertise the folder. A **KISS modem will not
|
||||
|
||||
+253
-160
@@ -1,28 +1,24 @@
|
||||
# RAK3401 1W repeater LoRa update chain
|
||||
# RAK3401 1W repeater compact LoRa update chain
|
||||
|
||||
> Status: accelerated release candidate. All 30 exact transitions passed
|
||||
> independent offline reconstruction, container verification, and stable
|
||||
> bootloader simulation. The byte-identical first bridge and the superseded
|
||||
> 29-step baseline completed physical RAK3401 testing; the new complete 30-step
|
||||
> byte sequence still needs its final full-chain hardware qualification.
|
||||
> Status: physically qualified on 19-Aug-2026. All nine exact transitions
|
||||
> completed on a deployed RAK3401 1W with its existing bootloader, using a
|
||||
> Heltec V4 source at 909.950 MHz / 500 kHz / SF5 / CR5. Every post-boot EndF
|
||||
> hash matched. The same files also pass independent reconstruction, container
|
||||
> verification, and both deployed Preview 6 and current OTAFIX simulators.
|
||||
|
||||
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).
|
||||
Use this asset:
|
||||
|
||||
```text
|
||||
RAK3401-update-chain-v1.16.7-c1caa5ad-to-v1.17.01-8c5262c6.zip
|
||||
RAK3401-update-chain-v1.16.7-c1caa5ad-to-v1.17.1.02-e742333a.zip
|
||||
```
|
||||
|
||||
- ZIP SHA-256: `b2781e02460b200a7c37bfae352bad81618716e550d1d042dca8aa29bfc73c29`
|
||||
- inner `SHA256SUMS.txt` SHA-256: `1f7add658ae5771451cd66a0e5c58a5e461983c1b36029ef480093ae5d5f1020`
|
||||
- endpoint image SHA-256: `a08b5791419410c760f31c26bb45c77d776eb7bbf68fde656e19bcd616a6227d`
|
||||
- endpoint EndF body hash: `3DD88C3DD7B8A35B`
|
||||
- ZIP SHA-256: `9f80eef191b88833bf4d2e4fea559cf5233ca53f9266ba310d447f37fa445f3a`
|
||||
- inner `SHA256SUMS.txt` SHA-256: `73d96e23237896a3e342fe736be12d94087a813bf09ad609fb55330bbe586055`
|
||||
- endpoint image SHA-256: `2784e4b645bc3dc198de0b8b18d3d7369cd02eca61cd71c46a51b61854da5345`
|
||||
- endpoint EndF body hash: `4BB1526BF647547D`
|
||||
|
||||
The superseded 29-step v1.17.01 release, earlier v1.17.01 candidates, and the
|
||||
v1.17.02 chain remain useful only for provenance or diagnosis. The runner
|
||||
recognizes their hashes and refuses live installation before opening a device
|
||||
or reading a password.
|
||||
Until that asset is uploaded to the release tag pinned by the runner, pass its
|
||||
local path with `--bundle`.
|
||||
|
||||
## Exact destination
|
||||
|
||||
@@ -33,111 +29,88 @@ target:
|
||||
- hardware: `RAK_3401`
|
||||
- role: `RAK_3401_repeater_lora_ota_no_external_sensors`
|
||||
- start: `v1.16.7.0-c1caa5ad`, EndF `71F4026CBE4B8B74`
|
||||
- endpoint: `v1.17.01-halo-keymind-cascade-dev-8c5262c6`, EndF `3DD88C3DD7B8A35B`
|
||||
- endpoint source: MeshCore `e742333a0ee393b0d55d2414f25b28f2f67e1ea1`
|
||||
- endpoint label: `v1.17.1.02-halo-keymind-cascade-dev-e742333a`
|
||||
- endpoint packed version: `0x01110102` (EndF tools render this as `1.17.1.2`)
|
||||
- deployment target key: `63d8df6387eaffd2e25db7d2a8ad967a65202182a48d681d7e7a9260f917280d`
|
||||
|
||||
Do not use the chain on another target ID, hardware family, starting image, or
|
||||
firmware body hash. The runner checks all four.
|
||||
|
||||
## Validation and speed design
|
||||
## Why the old bootloader works
|
||||
|
||||
The direct physical baseline used:
|
||||
The deployed bootloader is not changed and no package uses the newer expanded
|
||||
`0xED000` ceiling. Each package remains bottom-aligned below the old
|
||||
bootloader's `0xD4000` scan ceiling. Its detools workspace is page-aligned and
|
||||
must satisfy:
|
||||
|
||||
- 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 packages in the now-superseded `cd824765` chain.
|
||||
```text
|
||||
0x26000 + inplace_memory <= staged_package_start < 0xD4000
|
||||
```
|
||||
|
||||
The baseline started at `2026-08-14T01:50:24Z` and reached its endpoint at
|
||||
`2026-08-14T06:41:36Z`. This ZIP preserves that 29-row record as
|
||||
`PHYSICAL-BASELINE-cd824765.jsonl`; it does not claim those rows describe the
|
||||
new bridge bytes.
|
||||
The old bootloader already reads and validates that encoded detools geometry
|
||||
before its first destructive write. The previous application receiver was the
|
||||
limiting part: it reserved a fixed `0x98000` workspace before accepting a
|
||||
download. Step 1 therefore remains byte-for-byte identical to the physically
|
||||
passed 89,844-byte package. Step 2 is a 46,363-byte fixed-workspace package
|
||||
that installs the compatibility receiver. Later receivers stage above the
|
||||
real EndF image extent and let the bootloader enforce each package's selected
|
||||
workspace.
|
||||
|
||||
All 30 new containers were freshly generated. Every delta was reconstructed
|
||||
with both zero-filled and `0xFF`-filled workspaces, verified independently by
|
||||
`motatool`, and exercised by the stable OTAFIX apply simulator. The largest
|
||||
package is the byte-identical, physically passed step 1 at 89,844 bytes, which
|
||||
leaves 268 bytes of the fixed 90,112-byte staging region. Step 2 is the earliest
|
||||
safe place for the terminal-consumption backport: adding it directly to step 1
|
||||
would exceed that staging limit. Step 26 remains an intentional compiler-layout
|
||||
bridge.
|
||||
An exhaustive search tested every page-aligned workspace relevant to a route
|
||||
shorter than nine packages: 9,430 candidate geometries across 272 possible
|
||||
layer-skipping edges. None fit. The 61 edges on the shortest-path graph were
|
||||
then swept across another 2,296 workspace choices; 840 nine-package routes
|
||||
were compared by total transfer size. The selected route is the byte-minimum
|
||||
shortest route:
|
||||
|
||||
The speedup keeps 1 KiB logical blocks. Firmware currently accepts at most 1
|
||||
KiB, and larger logical blocks would still fragment into the same roughly
|
||||
160-byte LoRa packets while increasing the amount at risk when a block must be
|
||||
retried. Instead, one request can open an adaptive flight of multiple 1 KiB
|
||||
blocks. It begins at one block, grows through two and three to four after clean
|
||||
flights, and reduces the width after missing data or proofs. Retry timing is
|
||||
derived from actual LoRa airtime and path length. Active transfer packets use
|
||||
primary priority; periodic discovery remains background traffic.
|
||||
| Step | From | To | Workspace | Package | Margin |
|
||||
|---:|---|---|---:|---:|---:|
|
||||
| 1 | 1.16.7.0 | 1.16.7.9 | `0x98000` | 89,844 | 0 |
|
||||
| 2 | 1.16.7.9 | 1.16.7.10 | `0x98000` | 46,363 | 40,960 |
|
||||
| 3 | 1.16.7.10 | 1.16.8.7 | `0x91000` | 106,030 | 12,288 |
|
||||
| 4 | 1.16.8.7 | 1.16.9.105 | `0x89000` | 149,927 | 0 |
|
||||
| 5 | 1.16.9.105 | 1.16.9.110 | `0x8E000` | 111,307 | 16,384 |
|
||||
| 6 | 1.16.9.110 | 1.16.9.111 | `0x8D000` | 70,679 | 61,440 |
|
||||
| 7 | 1.16.9.111 | 1.16.9.116 | `0x89000` | 143,441 | 4,096 |
|
||||
| 8 | 1.16.9.116 | 1.16.9.118 | `0x81000` | 98,188 | 86,016 |
|
||||
| 9 | 1.16.9.118 | 1.17.1.02 | `0x7D000` | 186,385 | 12,288 |
|
||||
|
||||
Step 1 installs the guarded rescue command, the four-block adaptive requester,
|
||||
and primary transfer priority. The starting firmware still requests step 1
|
||||
serially. Step 2 installs terminal OTA bulk-packet consumption in the target,
|
||||
so the echo/dispatch reduction takes effect for transfers 3 through 30. A
|
||||
direct endpoint comparison confirmed the intended behavior: the older target
|
||||
needed 147 host block reads for a 48-block package and reported ready after
|
||||
267 seconds; the current target needed 82 reads for a larger 68-block package
|
||||
and reported ready after 98 seconds. These differently sized transfers are a
|
||||
protocol comparison, not a formal throughput benchmark.
|
||||
Total mOTA transfer data is 1,002,164 bytes. `ROUTE.json`, `CHAIN.csv`, and
|
||||
`validation-results.json` in the bundle pin the exact geometry and image hash
|
||||
for every transition.
|
||||
|
||||
## Host requirements
|
||||
|
||||
Install:
|
||||
Install Python 3.9 or newer, `meshcli` 1.6.0 or newer, the current `motatool`,
|
||||
and an OTA-enabled Full Companion or repeater that can seed mOTA 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 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.
|
||||
|
||||
## Verify the release offline
|
||||
## Verify offline
|
||||
|
||||
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-8c5262c6.zip \
|
||||
--bundle /path/to/RAK3401-update-chain-v1.16.7-c1caa5ad-to-v1.17.1.02-e742333a.zip \
|
||||
--work-dir ./rak3401-mota-chain-work \
|
||||
--motatool /path/to/motatool \
|
||||
--verify-only
|
||||
```
|
||||
|
||||
When `--bundle` is omitted, the runner downloads the pinned release asset. It
|
||||
checks the outer ZIP hash, complete inner checksum coverage, all 30 manifests,
|
||||
chain continuity, audited image anchors, final recovery image, and every mOTA
|
||||
container with `motatool`.
|
||||
The runner checks the outer ZIP, complete inner checksum coverage, all nine
|
||||
manifests, continuity, image anchors, final recovery image, and every container
|
||||
with `motatool`.
|
||||
|
||||
Offline verification does not need `--accept-test-candidate`. Live preflight or
|
||||
installation does: the flag explicitly acknowledges that the exact 30-step
|
||||
sequence has exhaustive offline validation but not yet a complete physical run.
|
||||
## Direct recoverable bench run
|
||||
|
||||
## Direct recoverable bench run: BW500/SF5
|
||||
|
||||
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. The live
|
||||
runner queries `get bootloader.ver`, reports the installed version, and then
|
||||
uses `ota self` to verify those apply capabilities before changing any radio
|
||||
or watchdog setting.
|
||||
|
||||
For the tested direct topology:
|
||||
Restore the test start locally with
|
||||
`recovery/test-start/RAK3401-test-start-v1.16.7-c1caa5ad.uf2`, then run:
|
||||
|
||||
```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-8c5262c6.zip \
|
||||
--bundle /path/to/RAK3401-update-chain-v1.16.7-c1caa5ad-to-v1.17.1.02-e742333a.zip \
|
||||
--work-dir ./rak3401-mota-chain-work \
|
||||
--controller-serial /dev/ttyACM0 \
|
||||
--source-tcp 192.168.1.51:5001 \
|
||||
@@ -147,33 +120,179 @@ python3 tools/lora_ota/rak3401_mota_chain.py \
|
||||
--temp-radio 909.950,500,5,5,120 \
|
||||
--ota-hops 1 \
|
||||
--motatool /path/to/motatool \
|
||||
--accept-test-candidate \
|
||||
--yes
|
||||
```
|
||||
|
||||
BW500 is for the controlled direct bench setup. Confirm that the selected
|
||||
frequency and bandwidth are legal at the test location.
|
||||
Keep the work directory. Rerunning the same command resumes only when the live
|
||||
EndF body hash matches an exact chain node. Never manually skip a package.
|
||||
|
||||
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.
|
||||
## Manual operation
|
||||
|
||||
## Live deployment with two intermediate relays: BW250/SF5
|
||||
The pinned runner is the safer method. If it cannot be used, follow every
|
||||
check below and keep a written copy of each original setting. Do not skip a
|
||||
step even when a later package appears in `ota ls`.
|
||||
|
||||
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`.
|
||||
|
||||
Run a non-mutating preflight first:
|
||||
### 1. Verify and extract the asset
|
||||
|
||||
```bash
|
||||
export MESHCORE_ADMIN_PASSWORD='your-destination-and-relay-password'
|
||||
sha256sum RAK3401-update-chain-v1.16.7-c1caa5ad-to-v1.17.1.02-e742333a.zip
|
||||
unzip RAK3401-update-chain-v1.16.7-c1caa5ad-to-v1.17.1.02-e742333a.zip
|
||||
cd RAK3401-update-chain-v1.16.7-c1caa5ad-to-v1.17.1.02-e742333a
|
||||
sha256sum -c SHA256SUMS.txt
|
||||
for package in motas/*.mota; do motatool verify "$package" || exit 1; done
|
||||
```
|
||||
|
||||
The outer hash must be `9f80eef191b88833bf4d2e4fea559cf5233ca53f9266ba310d447f37fa445f3a`.
|
||||
Do not continue after any checksum or verification error.
|
||||
|
||||
### 2. Record and prepare the destination
|
||||
|
||||
Before changing anything, save the complete replies from:
|
||||
|
||||
```text
|
||||
ver
|
||||
ota self
|
||||
get bootloader.ver
|
||||
get system.watchdog
|
||||
get radio.rxps
|
||||
powersaving
|
||||
get rxdelay
|
||||
ota config
|
||||
```
|
||||
|
||||
The starting `ota self` hash must be `71F4026CBE4B8B74`, the target must be
|
||||
`2FA509C1`, and the hardware must be `RAK_3401`. Disable the nRF52 system
|
||||
watchdog before the chain:
|
||||
|
||||
```text
|
||||
set system.watchdog off
|
||||
```
|
||||
|
||||
The hardware watchdog cannot stop immediately. The RAK will reset once within
|
||||
about 60 seconds. Reconnect, wait at least 90 seconds from the command, and
|
||||
require `get system.watchdog` to report `> off` before continuing.
|
||||
|
||||
For a direct bench link, apply these temporary transfer guardrails to the RAK
|
||||
after every bridge reboot:
|
||||
|
||||
```text
|
||||
powersaving off
|
||||
set radio.rxps off
|
||||
set rxdelay 0
|
||||
ota config hops 1
|
||||
tempradio 909.950,500,5,5,120
|
||||
```
|
||||
|
||||
`powersaving off` is an isolation guardrail, not the fix for the observed
|
||||
failure. The actual source failure was an oversized USB CDC reply; current
|
||||
firmware splits those reads. Some historical bridge builds may report RXPS as
|
||||
unsupported while already operating continuously. Verify the reported state
|
||||
and do not substitute a normal reboot for the one-time watchdog reset.
|
||||
|
||||
Put the source on the identical TempRadio tuple. If it is a binary-mode Full
|
||||
Companion, the current `motatool` can switch modes for the serving session:
|
||||
|
||||
```bash
|
||||
motatool serve \
|
||||
--dir ./motas \
|
||||
--serial /dev/ttyACM1 \
|
||||
--baud 115200 \
|
||||
--companion-terminal \
|
||||
-v
|
||||
```
|
||||
|
||||
Omit `--companion-terminal` for an ordinary text-console OTA source. Restart
|
||||
`motatool` for every step so the source emits a fresh catalog advert. Leave it
|
||||
running during the download and stop it with Ctrl-C only after the destination
|
||||
reports `ready to install`.
|
||||
|
||||
### 3. Install all nine packages in order
|
||||
|
||||
| Step | Manifest ID | Before hash | Expected version | After hash |
|
||||
|---:|---|---|---|---|
|
||||
| 1 | `C147BCEF` | `71F4026CBE4B8B74` | `1.16.7.9` | `42BC53A64288E845` |
|
||||
| 2 | `C510B628` | `42BC53A64288E845` | `1.16.7.10` | `6F6E51D63BF3E93F` |
|
||||
| 3 | `8CAD7045` | `6F6E51D63BF3E93F` | `1.16.8.7` | `F04DA8DB515E5C6D` |
|
||||
| 4 | `8B1EEFF6` | `F04DA8DB515E5C6D` | `1.16.9.105` | `3894A4D7B11ED282` |
|
||||
| 5 | `687E3BEA` | `3894A4D7B11ED282` | `1.16.9.110` | `CDC5CA630881DA15` |
|
||||
| 6 | `0D59A34B` | `CDC5CA630881DA15` | `1.16.9.111` | `D0CC4A9E135B9A4D` |
|
||||
| 7 | `2C5C2082` | `D0CC4A9E135B9A4D` | `1.16.9.116` | `66272A0E7FEF2773` |
|
||||
| 8 | `994AB743` | `66272A0E7FEF2773` | `1.16.9.118` | `8E00019AA08E00BF` |
|
||||
| 9 | `FE986948` | `8E00019AA08E00BF` | `1.17.1.02` | `4BB1526BF647547D` |
|
||||
|
||||
For each row, first prove that `ota self` exactly matches its **Before hash**.
|
||||
Then run:
|
||||
|
||||
```text
|
||||
ota cancel
|
||||
ota ls
|
||||
ota pull MANIFEST_ID flash
|
||||
ota status
|
||||
```
|
||||
|
||||
Repeat `ota status` at a restrained interval until it says `ready to install`.
|
||||
If the manifest is initially absent, wait for a fresh source advert, run
|
||||
`ota ls` again, or restart `motatool`; do not pull a different ID. Stop the
|
||||
seeder and install:
|
||||
|
||||
```text
|
||||
get system.watchdog
|
||||
ota install
|
||||
```
|
||||
|
||||
The watchdog reply must still be `> off`. Allow up to 90 seconds for the USB
|
||||
port to disappear and return. Then run `ver`, `ota self`, and `ota help`.
|
||||
Require the row's exact **After hash**, require `bootloader: apply OK`, and
|
||||
require `rescue install <hash16>` in the help before exposing that bridge to
|
||||
the next package. Clear only the now-proven retained download with `ota cancel`,
|
||||
reapply the transfer guardrails and TempRadio tuple, restart `motatool`, and
|
||||
continue with the next row.
|
||||
|
||||
If the board boots but `ota self` says the EndF is invalid, stop. Only when
|
||||
`ota status` still identifies that row's complete staged package may the
|
||||
guarded `ota rescue install BEFORE_HASH` command be used. It is not a force
|
||||
option and must use that row's exact Before hash. If the board does not boot,
|
||||
recover the documented start/recovery UF2 locally over USB.
|
||||
|
||||
### 4. Intermediate relays and restoration
|
||||
|
||||
On every managed intermediate relay, save `get rxdelay` and `get txdelay`, then
|
||||
use the airtime-scaled collision window during the maintenance session:
|
||||
|
||||
```text
|
||||
set rxdelay 0
|
||||
set txdelay 0.3
|
||||
```
|
||||
|
||||
Current OTA transfer packets honor that configured relay `txdelay`; setting
|
||||
`0.3` gives competing relays a small randomized, airtime-scaled forwarding
|
||||
window. The source's manifest-fragment gap independently follows active packet
|
||||
airtime and duty spacing, clamped to 100-1000 ms. These two delays solve
|
||||
different problems.
|
||||
|
||||
After step 9 is proven, restore each saved destination and relay value exactly,
|
||||
then restore normal radio operation. Re-enable the RAK system watchdog last:
|
||||
|
||||
```text
|
||||
normalradio
|
||||
set system.watchdog on
|
||||
get system.watchdog
|
||||
```
|
||||
|
||||
Final success requires version `v1.17.1.02-halo-keymind-cascade-dev-e742333a`,
|
||||
body hash `4BB1526BF647547D`, target `2FA509C1`, hardware `RAK_3401`, and
|
||||
`get system.watchdog` reporting `> on`. If a relay cannot be restored before
|
||||
its TempRadio lease ends, wait for it to return to the normal channel and
|
||||
restore its saved `rxdelay` and `txdelay` there.
|
||||
|
||||
## Two-relay deployment
|
||||
|
||||
For two intermediate relays, list them farthest-to-nearest and use three OTA
|
||||
hops. Run a non-mutating preflight first:
|
||||
|
||||
```bash
|
||||
python3 tools/lora_ota/rak3401_mota_chain.py \
|
||||
--bundle /path/to/RAK3401-update-chain-v1.16.7-c1caa5ad-to-v1.17.01-8c5262c6.zip \
|
||||
--bundle /path/to/RAK3401-update-chain-v1.16.7-c1caa5ad-to-v1.17.1.02-e742333a.zip \
|
||||
--work-dir ./rak3401-mota-chain-work \
|
||||
--controller-serial /dev/ttyACM0 \
|
||||
--source-tcp SOURCE_IP:5001 \
|
||||
@@ -185,64 +304,38 @@ python3 tools/lora_ota/rak3401_mota_chain.py \
|
||||
--temp-radio 909.950,250,5,5,120 \
|
||||
--ota-hops 3 \
|
||||
--motatool /path/to/motatool \
|
||||
--accept-test-candidate \
|
||||
--preflight-only
|
||||
```
|
||||
|
||||
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`.
|
||||
After preflight succeeds, rerun with `--yes` in place of
|
||||
`--preflight-only`. Confirm that the selected frequency and bandwidth are
|
||||
legal at the deployment location.
|
||||
|
||||
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.
|
||||
## Rescue and completion
|
||||
|
||||
## Guarded no-EndF rescue
|
||||
The guarded `ota rescue install <base_body_hash>` command is present after
|
||||
step 1. It is not a force command: it refuses a valid normal EndF, a base
|
||||
mismatch, wrong target/hardware, or invalid payload. A failure before step 1
|
||||
completes still requires local USB recovery because the deployed start image
|
||||
predates the rescue command.
|
||||
|
||||
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:
|
||||
Before the first mutation, the runner saves the destination's RXPS periods,
|
||||
CPU power-saving state, and RX flood delay in the persistent work directory.
|
||||
It verifies `radio.rxps off`, `powersaving off`, and `rxdelay 0` after every
|
||||
bridge reboot, then restores the original values only after the exact endpoint
|
||||
is proven. An interrupted run deliberately leaves those transfer guardrails
|
||||
active; rerunning with the same work directory resumes and restores them.
|
||||
CPU power saving was not the cause of the observed folder-source failure: that
|
||||
was a USB CDC receive-ring overrun fixed by bounded serial reads. The runtime
|
||||
settings remain conservative isolation and fast-link reliability guardrails.
|
||||
|
||||
```text
|
||||
ota rescue install <base_body_hash>
|
||||
```
|
||||
For every named `--relay`, the per-step runner also captures `rxdelay` and
|
||||
`txdelay`, writes them to `relay-timing-settings.json`, applies `rxdelay 0` and
|
||||
`txdelay 0.3` during transfer, verifies the readback, and restores both before
|
||||
the relay leaves TempRadio.
|
||||
|
||||
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 install, probe `ota self` at 10 and 20 seconds rather than
|
||||
sleeping for 90 seconds. Require the exact version and EndF body hash, then
|
||||
prove the rescue command is present before exposing that bridge to another
|
||||
step. Relayed runs keep the same 10-second probe cadence until the relays'
|
||||
mandatory return-to-normal window has elapsed.
|
||||
5. Re-enable the watchdog only after the exact step-30 endpoint boots.
|
||||
|
||||
Success requires all of the following:
|
||||
|
||||
- version `v1.17.01-halo-keymind-cascade-dev-8c5262c6`;
|
||||
- EndF body hash `3DD88C3DD7B8A35B`;
|
||||
- target `2FA509C1`, hardware `RAK_3401`;
|
||||
- normal radio `910.525 MHz`, `62.5 kHz`, `SF7`, `CR5`;
|
||||
- `system.watchdog` verified `on`.
|
||||
|
||||
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.
|
||||
The runner also keeps the watchdog off across the chain, rechecks identity and
|
||||
OTA reach before every package, requires the exact post-boot EndF hash, and
|
||||
only re-enables the watchdog after step 9. Success requires endpoint body hash
|
||||
`4BB1526BF647547D`, target `2FA509C1`, hardware `RAK_3401`, normal radio
|
||||
`910.525 MHz / 62.5 kHz / SF7 / CR5`, and the watchdog verified on.
|
||||
@@ -274,6 +274,7 @@ build_src_filter =
|
||||
+<../src/helpers/ota/MerkleTree.cpp>
|
||||
+<../src/helpers/ota/MotaContainer.cpp>
|
||||
+<../src/helpers/ota/FirmwareInfo.cpp>
|
||||
+<../src/helpers/ota/MotaSourceSerial.cpp>
|
||||
+<../src/helpers/ota/OtaProtocol.cpp>
|
||||
+<../src/helpers/ota/OtaManager.cpp>
|
||||
+<../src/helpers/ota/detools/detools.c>
|
||||
|
||||
+6
-1
@@ -520,7 +520,12 @@ void Mesh::decayOtaRelayBackoff() {
|
||||
|
||||
uint32_t Mesh::getOtaRetransmitDelay(const mesh::Packet* packet) {
|
||||
if (packet == NULL) return 0;
|
||||
if (isPrimaryOtaTraffic(packet->payload, packet->payload_len)) return 0;
|
||||
// Active transfer packets use the role's configured flood collision window.
|
||||
// Deployment tooling can temporarily select txdelay 0.3 on managed relays;
|
||||
// the value is airtime-scaled by the role and therefore follows SF/BW.
|
||||
if (isPrimaryOtaTraffic(packet->payload, packet->payload_len)) {
|
||||
return getRetransmitDelay(packet);
|
||||
}
|
||||
decayOtaRelayBackoff();
|
||||
uint32_t airtime = _radio->getEstAirtimeFor(packet->getRawLength());
|
||||
if (airtime == 0) return 0;
|
||||
|
||||
+2
-2
@@ -227,8 +227,8 @@ protected:
|
||||
virtual uint32_t getRetransmitDelay(const Packet* packet);
|
||||
|
||||
/**
|
||||
* \returns TempRadio OTA relay delay. Primary transfer packets forward immediately on the private
|
||||
* network; discovery retains collision jitter.
|
||||
* \returns TempRadio OTA relay delay. Primary transfer packets use the role's configured flood delay;
|
||||
* discovery uses its adaptive request-pressure window.
|
||||
*/
|
||||
virtual uint32_t getOtaRetransmitDelay(const Packet* packet);
|
||||
|
||||
|
||||
@@ -57,6 +57,9 @@ static const uint8_t MS_STATUS_OK = 0x00;
|
||||
static const uint8_t MS_STATUS_ERR = 0x01;
|
||||
|
||||
static const uint16_t MOTA_DESC_WIRE = 38; // existing reserved bytes carry block geometry; wire size is unchanged
|
||||
// Keep host-to-device replies below common 256-byte USB CDC/UART receive rings. Manifest leaves can exceed
|
||||
// that ring, and payload reads are normally 1 KB; splitting them here prevents silent overrun/checksum loss.
|
||||
static const uint16_t MOTA_SEEDER_READ_MAX = 192;
|
||||
static const uint16_t MOTA_SEEDER_WRITE_MAX = 512; // max data bytes per OP_WRITE/OP_SREAD (bounds frames)
|
||||
|
||||
} // namespace ota
|
||||
|
||||
@@ -90,12 +90,20 @@ bool SerialMotaSource::describe(uint8_t idx, MotaDesc& out) {
|
||||
}
|
||||
|
||||
bool SerialMotaSource::read(uint8_t idx, uint32_t off, uint8_t* buf, uint32_t len) {
|
||||
if (len > 0xFFFF) return false; // single transaction caps at 64 KB (a block is <=1 KB)
|
||||
uint8_t args[7];
|
||||
args[0] = idx;
|
||||
wr_u32le(args + 1, off);
|
||||
args[5] = (uint8_t)(len & 0xFF); args[6] = (uint8_t)(len >> 8);
|
||||
return txn(MS_OP_READ, args, 7, buf, len);
|
||||
if (!buf && len != 0) return false;
|
||||
if (len > UINT32_MAX - off) return false;
|
||||
uint32_t done = 0;
|
||||
while (done < len) {
|
||||
uint16_t chunk = (uint16_t)(len - done > MOTA_SEEDER_READ_MAX
|
||||
? MOTA_SEEDER_READ_MAX : len - done);
|
||||
uint8_t args[7];
|
||||
args[0] = idx;
|
||||
wr_u32le(args + 1, off + done);
|
||||
args[5] = (uint8_t)(chunk & 0xFF); args[6] = (uint8_t)(chunk >> 8);
|
||||
if (!txn(MS_OP_READ, args, 7, buf + done, chunk)) return false;
|
||||
done += chunk;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
} // namespace ota
|
||||
|
||||
+109
-20
@@ -362,28 +362,24 @@ void OtaManager::handleQuery(const uint8_t* m, uint16_t n) {
|
||||
}
|
||||
}
|
||||
|
||||
void OtaManager::handleGetManifest(const uint8_t* m, uint16_t n) {
|
||||
bool OtaManager::handleGetManifest(const uint8_t* m, uint16_t n) {
|
||||
GetManifestMsg gm;
|
||||
if (!decode_get_manifest(m, n, gm)) return;
|
||||
if (!decode_get_manifest(m, n, gm)) return false;
|
||||
ServeView* v = resolve(gm.manifest_id);
|
||||
if (!v) return;
|
||||
// A signed v2 manifest (with hw_id[32]) exceeds one LoRa packet, so send it as fragments. Each carries
|
||||
// up to OTA_MF_FRAG manifest bytes; the client reassembles by frag_idx. Only the fragments requested in
|
||||
// want_mask are sent (0xFFFF = all), so a retry re-sends just the holes, not the whole manifest.
|
||||
uint32_t mfl = v->mfl;
|
||||
const uint8_t* src = v->m.manifest_start;
|
||||
uint8_t ftotal = (uint8_t)((mfl + OTA_MF_FRAG - 1) / OTA_MF_FRAG); if (ftotal == 0) ftotal = 1;
|
||||
for (uint8_t fi = 0; fi < ftotal; fi++) {
|
||||
if (!(gm.want_mask & (1u << fi))) continue; // fetcher didn't ask for this manifest fragment
|
||||
uint32_t off = (uint32_t)fi * OTA_MF_FRAG;
|
||||
uint32_t fl = mfl - off; if (fl > OTA_MF_FRAG) fl = OTA_MF_FRAG;
|
||||
ManifestMsg mm;
|
||||
memcpy(mm.manifest_id, v->m.merkle_root, 4);
|
||||
mm.frag_idx = fi; mm.frag_total = ftotal;
|
||||
mm.bytes = src + off; mm.len = (uint16_t)fl;
|
||||
uint8_t b[MAX_PACKET_PAYLOAD];
|
||||
emit(b, encode_manifest(b, sizeof(b), mm), false);
|
||||
if (!v) return false;
|
||||
OTA_DBG("OTA: GET_MANIFEST want=%04x pending=%u\n",
|
||||
(unsigned)gm.want_mask, (unsigned)_n_manifest_jobs);
|
||||
// Retain the request and let serviceEgress admit its fragments one at a time. In particular, a deployed
|
||||
// receiver with the legacy 32-second SF5 flood delay must receive both fragments from the first request;
|
||||
// serializing fragment 1 behind the receiver's next GET would exhaust its manifest retry window.
|
||||
const bool queued = queueManifestJob(v->m.merkle_root, gm.want_mask);
|
||||
if (!queued) {
|
||||
OTA_DBG("OTA: GET_MANIFEST response queue full\n");
|
||||
}
|
||||
// A node that successfully queued the requested MID is the terminal source. Do not also re-flood the
|
||||
// request; that echo competes with the manifest on the same temporary-radio channel. If the bounded queue
|
||||
// is full, return false so another source or a later retry can service it.
|
||||
return queued;
|
||||
}
|
||||
|
||||
// Serve the target's merkle leaves[] in fragments (for a motatool folder-capture warm-start). Only the
|
||||
@@ -434,12 +430,101 @@ bool OtaManager::queueServeJob(const uint8_t* mid, uint16_t block, uint16_t want
|
||||
return true;
|
||||
}
|
||||
|
||||
bool OtaManager::queueManifestJob(const uint8_t* mid, uint16_t want_mask) {
|
||||
for (uint8_t i = 0; i < _n_manifest_jobs; i++) {
|
||||
ManifestServeJob& job = _manifest_jobs[i];
|
||||
if (memcmp(job.mid, mid, 4) != 0) continue;
|
||||
job.pending_mask |= (uint16_t)(want_mask & ~job.emitted_mask);
|
||||
return true;
|
||||
}
|
||||
if (_n_manifest_jobs >= OTA_MANIFEST_SERVE_QUEUE) return false;
|
||||
ManifestServeJob& job = _manifest_jobs[_n_manifest_jobs++];
|
||||
memcpy(job.mid, mid, 4);
|
||||
job.pending_mask = want_mask;
|
||||
job.emitted_mask = 0;
|
||||
job.ready_at = _now_ms + manifestEgressGapMs();
|
||||
return true;
|
||||
}
|
||||
|
||||
uint32_t OtaManager::manifestEgressGapMs() const {
|
||||
if (_radio_packet_airtime_ms == 0) return OTA_MANIFEST_EGRESS_MIN_GAP_MS;
|
||||
uint64_t gap = (uint64_t)_radio_packet_airtime_ms * _tx_spacing_permille;
|
||||
gap = (gap + 999u) / 1000u;
|
||||
if (gap < OTA_MANIFEST_EGRESS_MIN_GAP_MS) gap = OTA_MANIFEST_EGRESS_MIN_GAP_MS;
|
||||
if (gap > OTA_MANIFEST_EGRESS_MAX_GAP_MS) gap = OTA_MANIFEST_EGRESS_MAX_GAP_MS;
|
||||
return (uint32_t)gap;
|
||||
}
|
||||
|
||||
void OtaManager::clearPendingEgress() {
|
||||
_n_manifest_jobs = 0;
|
||||
_n_serve_jobs = 0;
|
||||
_serve_block_len = 0;
|
||||
_serve_block_loaded = false;
|
||||
}
|
||||
|
||||
void OtaManager::popManifestJob() {
|
||||
if (_n_manifest_jobs == 0) return;
|
||||
for (uint8_t i = 1; i < _n_manifest_jobs; i++) _manifest_jobs[i - 1] = _manifest_jobs[i];
|
||||
_n_manifest_jobs--;
|
||||
}
|
||||
|
||||
bool OtaManager::serviceManifestEgress() {
|
||||
if (_n_manifest_jobs == 0) return false;
|
||||
ManifestServeJob& job = _manifest_jobs[0];
|
||||
if ((int32_t)(_now_ms - job.ready_at) < 0) {
|
||||
return true;
|
||||
}
|
||||
ServeView* v = resolve(job.mid);
|
||||
if (!v) {
|
||||
popManifestJob();
|
||||
return true;
|
||||
}
|
||||
|
||||
const uint32_t mfl = v->mfl;
|
||||
uint8_t ftotal = (uint8_t)((mfl + OTA_MF_FRAG - 1) / OTA_MF_FRAG);
|
||||
if (ftotal == 0) ftotal = 1;
|
||||
const uint16_t valid_mask = frag_full_mask(ftotal);
|
||||
job.pending_mask &= valid_mask;
|
||||
if (job.pending_mask == 0) {
|
||||
popManifestJob();
|
||||
return true;
|
||||
}
|
||||
|
||||
uint8_t fragment = 0;
|
||||
while (fragment < 16 && !(job.pending_mask & (1u << fragment))) fragment++;
|
||||
if (fragment >= ftotal) {
|
||||
popManifestJob();
|
||||
return true;
|
||||
}
|
||||
|
||||
const uint32_t off = (uint32_t)fragment * OTA_MF_FRAG;
|
||||
uint32_t len = mfl - off;
|
||||
if (len > OTA_MF_FRAG) len = OTA_MF_FRAG;
|
||||
ManifestMsg message;
|
||||
memcpy(message.manifest_id, v->m.merkle_root, 4);
|
||||
message.frag_idx = fragment;
|
||||
message.frag_total = ftotal;
|
||||
message.bytes = v->m.manifest_start + off;
|
||||
message.len = (uint16_t)len;
|
||||
uint8_t wire[MAX_PACKET_PAYLOAD];
|
||||
const uint16_t wire_len = encode_manifest(wire, sizeof(wire), message);
|
||||
if (emit(wire, wire_len, false)) {
|
||||
OTA_DBG("OTA: MANIFEST tx frag=%u/%u len=%u\n",
|
||||
(unsigned)fragment, (unsigned)ftotal, (unsigned)len);
|
||||
const uint16_t bit = (uint16_t)(1u << fragment);
|
||||
job.pending_mask &= (uint16_t)~bit;
|
||||
job.emitted_mask |= bit;
|
||||
if (job.pending_mask == 0) {
|
||||
popManifestJob();
|
||||
} else {
|
||||
// Follow the active packet airtime and dispatcher duty spacing while
|
||||
// retaining a floor for fast radios' TX-to-RX turnaround.
|
||||
job.ready_at = _now_ms + manifestEgressGapMs();
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
void OtaManager::popServeJob() {
|
||||
if (_n_serve_jobs == 0) return;
|
||||
for (uint8_t i = 1; i < _n_serve_jobs; i++) _serve_jobs[i - 1] = _serve_jobs[i];
|
||||
@@ -497,6 +582,10 @@ bool OtaManager::handleReqProof(const uint8_t* m, uint16_t n) {
|
||||
}
|
||||
|
||||
void OtaManager::serviceEgress() {
|
||||
// Metadata comes first so an older receiver gets every manifest fragment before its retry horizon. The
|
||||
// send callback supplies radio-queue backpressure, and a rejected fragment remains in this descriptor.
|
||||
if (serviceManifestEgress()) return;
|
||||
|
||||
// A proactive proof normally follows the final DATA fragment. If it was lost, or the source is older and
|
||||
// never sent one, issue the legacy proof request after a short grace. Stay RX-silent while another slot
|
||||
// still expects DATA, and restart the grace on every new packet, so this fallback cannot collide with a
|
||||
@@ -1586,7 +1675,7 @@ bool OtaManager::on_message(const uint8_t* msg, uint16_t len) {
|
||||
case OTA_ADV: handleAdv(msg, len); return false;
|
||||
case OTA_QUERY: handleQuery(msg, len); return false;
|
||||
case OTA_HAVE: handleHave(msg, len); return false;
|
||||
case OTA_GET_MANIFEST: handleGetManifest(msg, len); return false;
|
||||
case OTA_GET_MANIFEST: return handleGetManifest(msg, len);
|
||||
case OTA_MANIFEST: handleManifest(msg, len); return false;
|
||||
case OTA_GET_LEAVES: handleGetLeaves(msg, len); return false;
|
||||
case OTA_LEAVES: handleLeaves(msg, len); return false;
|
||||
|
||||
@@ -59,6 +59,15 @@ typedef bool (*ServeReadFn)(void* ctx, uint32_t off, uint8_t* buf, uint32_t len)
|
||||
#ifndef OTA_MANIFEST_MAX_RETRY
|
||||
#define OTA_MANIFEST_MAX_RETRY 20 // give up (FAILED) after this many GET_MANIFEST retries - frees the slot
|
||||
#endif
|
||||
#ifndef OTA_MANIFEST_SERVE_QUEUE
|
||||
#define OTA_MANIFEST_SERVE_QUEUE 4 // bounded manifest response descriptors (fragments are emitted one at a time)
|
||||
#endif
|
||||
#ifndef OTA_MANIFEST_EGRESS_MIN_GAP_MS
|
||||
#define OTA_MANIFEST_EGRESS_MIN_GAP_MS 100 // minimum fast-link TX-to-RX turnaround allowance
|
||||
#endif
|
||||
#ifndef OTA_MANIFEST_EGRESS_MAX_GAP_MS
|
||||
#define OTA_MANIFEST_EGRESS_MAX_GAP_MS 1000 // do not outrun the receiver's one-second retry observation
|
||||
#endif
|
||||
// Leaf-diff warm-start (motatool folder-capture only): bulk-fetch the target's leaves[], diff a seed build
|
||||
// locally, pull DATA only for mismatches. OTA_LEAVES is bitmap-fragmented like OTA_MANIFEST so a want_mask
|
||||
// retry never re-bursts (anti-deadlock). The mask is a fixed uint16, so leaves are capped at MAXFRAG frags.
|
||||
@@ -386,6 +395,7 @@ public:
|
||||
void serviceEgress();
|
||||
void clearPendingEgress();
|
||||
uint8_t pendingServeJobs() const { return _n_serve_jobs; }
|
||||
uint8_t pendingManifestJobs() const { return _n_manifest_jobs; }
|
||||
|
||||
// Drop the current fetch session back to IDLE so a fresh `ota pull` / advert starts a new one.
|
||||
void reset_session() {
|
||||
@@ -446,7 +456,7 @@ private:
|
||||
void handleAdv(const uint8_t* m, uint16_t n); // beacon -> sources table (+ query if interested)
|
||||
void handleQuery(const uint8_t* m, uint16_t n); // serve: reply OTA_HAVE catalog
|
||||
void handleHave(const uint8_t* m, uint16_t n); // peer: catalog rows (+ startFetch if a row matches)
|
||||
void handleGetManifest(const uint8_t* m, uint16_t n);
|
||||
bool handleGetManifest(const uint8_t* m, uint16_t n);
|
||||
void handleManifest(const uint8_t* m, uint16_t n);
|
||||
void handleGetLeaves(const uint8_t* m, uint16_t n); // serve: send the requested leaf fragments
|
||||
void handleLeaves(const uint8_t* m, uint16_t n); // fetcher (validate): reassemble the target leaves
|
||||
@@ -481,6 +491,10 @@ private:
|
||||
void registerSelfEntry(); // (re)build entry[0] from view0
|
||||
static bool srcReadTramp(void* c, uint32_t off, uint8_t* buf, uint32_t len); // source payload reader
|
||||
bool queueServeJob(const uint8_t* mid, uint16_t block, uint16_t want_mask);
|
||||
bool queueManifestJob(const uint8_t* mid, uint16_t want_mask);
|
||||
uint32_t manifestEgressGapMs() const;
|
||||
bool serviceManifestEgress();
|
||||
void popManifestJob();
|
||||
bool loadActiveServeBlock();
|
||||
void popServeJob();
|
||||
void sendQuery(const uint8_t* seeder, const uint8_t* digest, uint32_t filter_target,
|
||||
@@ -544,6 +558,14 @@ private:
|
||||
};
|
||||
ServeJob _serve_jobs[OTA_SERVE_QUEUE];
|
||||
uint8_t _n_serve_jobs = 0;
|
||||
struct ManifestServeJob {
|
||||
uint8_t mid[4];
|
||||
uint16_t pending_mask;
|
||||
uint16_t emitted_mask;
|
||||
uint32_t ready_at;
|
||||
};
|
||||
ManifestServeJob _manifest_jobs[OTA_MANIFEST_SERVE_QUEUE];
|
||||
uint8_t _n_manifest_jobs = 0;
|
||||
uint8_t _serve_block[OTA_MAX_BLOCK];
|
||||
uint16_t _serve_block_len = 0;
|
||||
bool _serve_block_loaded = false;
|
||||
|
||||
@@ -8,6 +8,8 @@
|
||||
#include "helpers/ota/BlockBitmap.h"
|
||||
#include "helpers/ota/Multihash.h"
|
||||
#include "helpers/ota/FirmwareInfo.h"
|
||||
#include "helpers/ota/MotaSeederProto.h"
|
||||
#include "helpers/ota/MotaSourceSerial.h"
|
||||
#include "helpers/ota/SignerAllowlist.h"
|
||||
#include "helpers/ota/OtaStore.h"
|
||||
#include "helpers/ota/OtaProtocol.h"
|
||||
@@ -21,6 +23,80 @@ extern "C" {
|
||||
|
||||
using namespace mesh::ota;
|
||||
|
||||
class FakeMotaSeederStream : public Stream {
|
||||
public:
|
||||
using Stream::write;
|
||||
|
||||
size_t write(const uint8_t* request, size_t len) override {
|
||||
request_valid = len == 11 && request[0] == MOTA_SEEDER_REQ_MAGIC0
|
||||
&& request[1] == MOTA_SEEDER_REQ_MAGIC1 && request[2] == MS_OP_READ;
|
||||
if (!request_valid) return len;
|
||||
|
||||
uint8_t checksum = request[2];
|
||||
for (size_t i = 3; i + 1 < len; i++) checksum ^= request[i];
|
||||
request_valid = checksum == request[len - 1];
|
||||
const uint32_t offset = rd_u32le(request + 4);
|
||||
const uint16_t read_len = rd_u16le(request + 8);
|
||||
offsets.push_back(offset);
|
||||
lengths.push_back(read_len);
|
||||
|
||||
response.clear();
|
||||
response_pos = 0;
|
||||
response.push_back(MOTA_SEEDER_RSP_MAGIC0);
|
||||
response.push_back(MOTA_SEEDER_RSP_MAGIC1);
|
||||
response.push_back(MS_OP_READ);
|
||||
response.push_back(MS_STATUS_OK);
|
||||
uint8_t response_checksum = MOTA_SEEDER_RSP_MAGIC0 ^ MOTA_SEEDER_RSP_MAGIC1
|
||||
^ MS_OP_READ ^ MS_STATUS_OK;
|
||||
for (uint16_t i = 0; i < read_len; i++) {
|
||||
const uint8_t value = (uint8_t)(offset + i);
|
||||
response.push_back(value);
|
||||
response_checksum ^= value;
|
||||
}
|
||||
response.push_back(response_checksum);
|
||||
return len;
|
||||
}
|
||||
|
||||
int read() override {
|
||||
return response_pos < response.size() ? response[response_pos++] : -1;
|
||||
}
|
||||
|
||||
std::vector<uint32_t> offsets;
|
||||
std::vector<uint16_t> lengths;
|
||||
bool request_valid = true;
|
||||
|
||||
private:
|
||||
std::vector<uint8_t> response;
|
||||
size_t response_pos = 0;
|
||||
};
|
||||
|
||||
TEST(MotaSourceSerial, SplitsOneKilobyteReadsBelowCdcReceiveRing) {
|
||||
FakeMotaSeederStream stream;
|
||||
SerialMotaSource source(stream);
|
||||
std::array<uint8_t, 1024> data{};
|
||||
|
||||
ASSERT_TRUE(source.read(0, 0x1000, data.data(), data.size()));
|
||||
EXPECT_TRUE(stream.request_valid);
|
||||
EXPECT_EQ(stream.offsets,
|
||||
(std::vector<uint32_t>{0x1000, 0x10C0, 0x1180, 0x1240, 0x1300, 0x13C0}));
|
||||
EXPECT_EQ(stream.lengths,
|
||||
(std::vector<uint16_t>{192, 192, 192, 192, 192, 64}));
|
||||
for (size_t i = 0; i < data.size(); i++) {
|
||||
EXPECT_EQ(data[i], (uint8_t)(0x1000 + i));
|
||||
}
|
||||
}
|
||||
|
||||
TEST(MotaSourceSerial, AcceptsEmptyReadAndRejectsInvalidRange) {
|
||||
FakeMotaSeederStream stream;
|
||||
SerialMotaSource source(stream);
|
||||
uint8_t byte = 0;
|
||||
|
||||
EXPECT_TRUE(source.read(0, 123, nullptr, 0));
|
||||
EXPECT_FALSE(source.read(0, 123, nullptr, 1));
|
||||
EXPECT_FALSE(source.read(0, UINT32_MAX, &byte, 2));
|
||||
EXPECT_TRUE(stream.offsets.empty());
|
||||
}
|
||||
|
||||
// Build a flashed-image layout (body || fixed 56-byte EndF) the way the host packager / build hook do:
|
||||
// marker(4) body_len(4) body_hash8(8) fw_version(4) target_id(4) hw_id(32). Identity is always present
|
||||
// (zero/"" = unknown).
|
||||
@@ -840,6 +916,61 @@ TEST(OtaTransfer, ServerPacesOneKilobyteBlockAndProactiveProofWithBackpressure)
|
||||
EXPECT_EQ(server.pendingServeJobs(), 0u);
|
||||
}
|
||||
|
||||
TEST(OtaTransfer, ServerPacesManifestFragmentsAndConsumesResolvedRequest) {
|
||||
MotaManifest manifest;
|
||||
ASSERT_TRUE(mota_parse(SIM_MOTA, SIM_MOTA_LEN, manifest));
|
||||
|
||||
OtaManager server;
|
||||
CapturedMessages sent;
|
||||
server.begin(0, capture_send, &sent);
|
||||
server.set_link_timing(80, 2000); // 160 ms at the active airtime/duty spacing
|
||||
ASSERT_TRUE(server.serve(SIM_MOTA, SIM_MOTA_LEN));
|
||||
|
||||
GetManifestMsg request;
|
||||
memcpy(request.manifest_id, manifest.merkle_root, 4);
|
||||
request.want_mask = 0xFFFF;
|
||||
uint8_t wire[MAX_PACKET_PAYLOAD];
|
||||
uint16_t wire_len = encode_get_manifest(wire, sizeof(wire), request);
|
||||
ASSERT_GT(wire_len, 0);
|
||||
|
||||
EXPECT_TRUE(server.on_message(wire, wire_len));
|
||||
EXPECT_TRUE(sent.items.empty());
|
||||
EXPECT_EQ(server.pendingManifestJobs(), 1u);
|
||||
|
||||
const uint32_t manifest_gap = 160;
|
||||
server.set_clock(manifest_gap - 1);
|
||||
server.serviceEgress();
|
||||
EXPECT_TRUE(sent.items.empty());
|
||||
|
||||
server.set_clock(manifest_gap);
|
||||
server.serviceEgress();
|
||||
ASSERT_EQ(sent.items.size(), 1u);
|
||||
ManifestMsg fragment;
|
||||
ASSERT_TRUE(decode_manifest(sent.items[0].data(),
|
||||
(uint16_t)sent.items[0].size(), fragment));
|
||||
EXPECT_EQ(fragment.frag_idx, 0u);
|
||||
EXPECT_EQ(fragment.frag_total, 2u);
|
||||
|
||||
server.set_clock(2 * manifest_gap - 1);
|
||||
server.serviceEgress();
|
||||
ASSERT_EQ(sent.items.size(), 1u);
|
||||
|
||||
server.set_clock(2 * manifest_gap);
|
||||
server.serviceEgress();
|
||||
ASSERT_EQ(sent.items.size(), 2u);
|
||||
ASSERT_TRUE(decode_manifest(sent.items[1].data(),
|
||||
(uint16_t)sent.items[1].size(), fragment));
|
||||
EXPECT_EQ(fragment.frag_idx, 1u);
|
||||
EXPECT_EQ(server.pendingManifestJobs(), 0u);
|
||||
|
||||
sent.items.clear();
|
||||
request.manifest_id[0] ^= 0xFF;
|
||||
wire_len = encode_get_manifest(wire, sizeof(wire), request);
|
||||
ASSERT_GT(wire_len, 0);
|
||||
EXPECT_FALSE(server.on_message(wire, wire_len));
|
||||
EXPECT_TRUE(sent.items.empty());
|
||||
}
|
||||
|
||||
TEST(OtaTransfer, ServerQueuesEveryBlockInOneRequestFlight) {
|
||||
MotaManifest manifest;
|
||||
ASSERT_TRUE(mota_parse(SIM_MOTA_1K, SIM_MOTA_1K_LEN, manifest));
|
||||
|
||||
@@ -271,7 +271,7 @@ TEST(RepeaterTransport, OtaDiscoveryRelayKeepsCollisionJitter) {
|
||||
EXPECT_EQ(node.otaRelayDelay(&packet), 5u); // 0.5 * 10 ms
|
||||
}
|
||||
|
||||
TEST(RepeaterTransport, OtaTransferRelayIsImmediateEvenUnderRequestPressure) {
|
||||
TEST(RepeaterTransport, OtaTransferRelayKeepsConfiguredCollisionJitter) {
|
||||
TraceTestClock clock;
|
||||
TraceTestRTC rtc;
|
||||
TraceTestRNG rng;
|
||||
@@ -291,18 +291,19 @@ TEST(RepeaterTransport, OtaTransferRelayIsImmediateEvenUnderRequestPressure) {
|
||||
|
||||
mesh::Packet request = make_request();
|
||||
node.receivePacket(&request); // first request establishes the key
|
||||
EXPECT_EQ(node.otaRelayDelay(&request), 0u);
|
||||
rng.value = 3;
|
||||
EXPECT_EQ(node.otaRelayDelay(&request), 15u);
|
||||
for (uint32_t retry = 1; retry <= 3; retry++) {
|
||||
clock.now = retry * 3000;
|
||||
request = make_request(); // a fresh origin copy has the same zero-hop request key
|
||||
node.receivePacket(&request); // frequent repeats raise one level each
|
||||
}
|
||||
rng.value = 20;
|
||||
EXPECT_EQ(node.otaRelayDelay(&request), 0u); // private transfer never inherits flood backoff
|
||||
rng.value = 3;
|
||||
EXPECT_EQ(node.otaRelayDelay(&request), 15u); // request pressure does not widen primary traffic
|
||||
|
||||
clock.now = 100000;
|
||||
rng.value = 2;
|
||||
EXPECT_EQ(node.otaRelayDelay(&request), 0u);
|
||||
rng.value = 1;
|
||||
EXPECT_EQ(node.otaRelayDelay(&request), 5u);
|
||||
}
|
||||
|
||||
TEST(RepeaterTransport, TempRadioOtaBypassesReceiveHoldoffAndUsesFastCadRetry) {
|
||||
|
||||
@@ -35,6 +35,7 @@ COMMON_PATCH = SCRIPT_DIR / "rak3401_terminal_ota_backport.patch"
|
||||
LEGACY_MESH_PATCH = SCRIPT_DIR / "rak3401_terminal_ota_mesh_legacy.patch"
|
||||
GUARDED_MESH_PATCH = SCRIPT_DIR / "rak3401_terminal_ota_mesh_guarded.patch"
|
||||
VERSION_PATCH = SCRIPT_DIR / "rak3401_four_component_endf_backport.patch"
|
||||
WORKSPACE_PATCH = SCRIPT_DIR / "rak3401_dynamic_workspace_backport.patch"
|
||||
SELECTIVE_OS_HOOK = SCRIPT_DIR / "rak3401_selective_os.py"
|
||||
|
||||
|
||||
@@ -184,6 +185,7 @@ def apply_backport(source: Path, commit: str) -> None:
|
||||
required = (
|
||||
COMMON_PATCH,
|
||||
LEGACY_MESH_PATCH if commit in LEGACY_MESH_COMMITS else GUARDED_MESH_PATCH,
|
||||
WORKSPACE_PATCH,
|
||||
)
|
||||
for patch in required:
|
||||
run(["git", "apply", "--check", str(patch)], f"check {patch.name}", source)
|
||||
@@ -213,7 +215,10 @@ def main() -> int:
|
||||
parser.add_argument("--keep-worktree", action="store_true")
|
||||
args = parser.parse_args()
|
||||
|
||||
for patch in (COMMON_PATCH, LEGACY_MESH_PATCH, GUARDED_MESH_PATCH, VERSION_PATCH):
|
||||
for patch in (
|
||||
COMMON_PATCH, LEGACY_MESH_PATCH, GUARDED_MESH_PATCH, VERSION_PATCH,
|
||||
WORKSPACE_PATCH,
|
||||
):
|
||||
if not patch.is_file():
|
||||
raise BuildError(f"missing backport patch: {patch}")
|
||||
if not SELECTIVE_OS_HOOK.is_file():
|
||||
@@ -243,7 +248,9 @@ def main() -> int:
|
||||
[
|
||||
"git", "restore", "--source=HEAD", "--staged", "--worktree", "--",
|
||||
"src/Mesh.cpp", "src/helpers/ota/OtaManager.cpp",
|
||||
"src/helpers/ota/OtaManager.h", "tools/mota/pio_endf.py",
|
||||
"src/helpers/ota/OtaManager.h",
|
||||
"src/helpers/ota/OtaFlashLayout_nrf52.h",
|
||||
"tools/mota/pio_endf.py",
|
||||
],
|
||||
"restore prior backport",
|
||||
source,
|
||||
@@ -353,6 +360,8 @@ def main() -> int:
|
||||
"guarded_mesh_patch_sha256": sha256_file(GUARDED_MESH_PATCH),
|
||||
"version_patch": VERSION_PATCH.name,
|
||||
"version_patch_sha256": sha256_file(VERSION_PATCH),
|
||||
"workspace_patch": WORKSPACE_PATCH.name,
|
||||
"workspace_patch_sha256": sha256_file(WORKSPACE_PATCH),
|
||||
"selective_os_hook": SELECTIVE_OS_HOOK.name,
|
||||
"selective_os_hook_sha256": sha256_file(SELECTIVE_OS_HOOK),
|
||||
"targets": records,
|
||||
|
||||
+678
@@ -0,0 +1,678 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Build the compact legacy-bootloader RAK3401 mOTA chain.
|
||||
|
||||
The first package is retained byte-for-byte from the pinned physical baseline.
|
||||
Later packages use page-aligned detools workspaces selected by the audited
|
||||
route plan. Every package must fit below the deployed bootloader's original
|
||||
0xD4000 scan ceiling; no expanded-ceiling bootloader feature is assumed.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import csv
|
||||
from datetime import datetime, timezone
|
||||
import json
|
||||
from pathlib import Path
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import zipfile
|
||||
|
||||
|
||||
SCRIPT_DIR = Path(__file__).resolve().parent
|
||||
REPO_ROOT = SCRIPT_DIR.parent.parent
|
||||
sys.path.insert(0, str(REPO_ROOT / "tools" / "mota"))
|
||||
|
||||
import motalib # noqa: E402
|
||||
import rebuild_rak3401_bundle as common # noqa: E402
|
||||
|
||||
|
||||
PREVIOUS_BUNDLE_SHA256 = (
|
||||
"b2781e02460b200a7c37bfae352bad81618716e550d1d042dca8aa29bfc73c29"
|
||||
)
|
||||
EXPECTED_TARGET_ID = 0x2FA509C1
|
||||
EXPECTED_HARDWARE = "RAK_3401"
|
||||
EXPECTED_ENDPOINT_VERSION = "1.17.1.02"
|
||||
EXPECTED_ENDPOINT_VERSION_PACKED = motalib.pack_version(EXPECTED_ENDPOINT_VERSION)
|
||||
EXPECTED_ROUTE_NODES = (0, 1, 2, 7, 13, 16, 17, 22, 24, 30)
|
||||
EXPECTED_STEPS = len(EXPECTED_ROUTE_NODES) - 1
|
||||
APP_BASE = 0x26000
|
||||
STAGE_CEILING = 0xD4000
|
||||
FIXED_WORKSPACE = 0x98000
|
||||
FLASH_PAGE = 4096
|
||||
SEGMENT_SIZE = 4096
|
||||
BLOCK_SIZE = 1024
|
||||
|
||||
|
||||
class CompactBuildError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
def run(command: list[str], label: str, cwd: Path | None = None, timeout: int = 900) -> str:
|
||||
try:
|
||||
result = subprocess.run(
|
||||
command,
|
||||
cwd=cwd,
|
||||
text=True,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.STDOUT,
|
||||
timeout=timeout,
|
||||
check=False,
|
||||
)
|
||||
except (OSError, subprocess.TimeoutExpired) as exc:
|
||||
raise CompactBuildError(f"{label} failed: {exc}") from exc
|
||||
if result.returncode != 0:
|
||||
raise CompactBuildError(
|
||||
f"{label} exited {result.returncode}:\n{result.stdout[-6000:]}"
|
||||
)
|
||||
return result.stdout
|
||||
|
||||
|
||||
def align_up(value: int, unit: int) -> int:
|
||||
return (value + unit - 1) // unit * unit
|
||||
|
||||
|
||||
def align_down(value: int, unit: int) -> int:
|
||||
return value // unit * unit
|
||||
|
||||
|
||||
def parse_int(value: object, label: str) -> int:
|
||||
if isinstance(value, int):
|
||||
result = value
|
||||
elif isinstance(value, str) and re.fullmatch(r"(?:0[xX][0-9a-fA-F]+|[0-9]+)", value):
|
||||
result = int(value, 0)
|
||||
else:
|
||||
raise CompactBuildError(f"{label} must be an integer or 0x-prefixed integer")
|
||||
if result < 0:
|
||||
raise CompactBuildError(f"{label} cannot be negative")
|
||||
return result
|
||||
|
||||
|
||||
def parse_simulators(values: list[str]) -> list[tuple[str, Path]]:
|
||||
simulators: list[tuple[str, Path]] = []
|
||||
for value in values:
|
||||
label, separator, raw_path = value.partition("=")
|
||||
if not separator or not re.fullmatch(r"[A-Za-z0-9_.-]+", label):
|
||||
raise CompactBuildError("--apply-sim must use LABEL=/path/to/apply_sim")
|
||||
path = Path(raw_path)
|
||||
if not path.is_file():
|
||||
raise CompactBuildError(f"apply simulator does not exist: {path}")
|
||||
simulators.append((label, path))
|
||||
return simulators
|
||||
|
||||
|
||||
def read_route(path: Path) -> tuple[list[dict[str, object]], dict[str, object]]:
|
||||
try:
|
||||
document = json.loads(path.read_text(encoding="ascii"))
|
||||
except (OSError, UnicodeError, json.JSONDecodeError) as exc:
|
||||
raise CompactBuildError(f"cannot read route plan {path}: {exc}") from exc
|
||||
if not isinstance(document, dict) or document.get("schema") != 1:
|
||||
raise CompactBuildError("route plan must use schema 1")
|
||||
if parse_int(document.get("app_base"), "route app_base") != APP_BASE:
|
||||
raise CompactBuildError("route plan has the wrong application base")
|
||||
if parse_int(document.get("stage_ceiling"), "route stage_ceiling") != STAGE_CEILING:
|
||||
raise CompactBuildError("route plan does not target the deployed 0xD4000 ceiling")
|
||||
if document.get("shortest_package_count") != EXPECTED_STEPS:
|
||||
raise CompactBuildError(
|
||||
f"route plan shortest_package_count must be {EXPECTED_STEPS}"
|
||||
)
|
||||
raw_steps = document.get("steps")
|
||||
if not isinstance(raw_steps, list) or len(raw_steps) != EXPECTED_STEPS:
|
||||
raise CompactBuildError(f"route plan must contain {EXPECTED_STEPS} steps")
|
||||
steps: list[dict[str, object]] = []
|
||||
nodes = []
|
||||
for number, raw in enumerate(raw_steps, 1):
|
||||
if not isinstance(raw, dict):
|
||||
raise CompactBuildError(f"route step {number} must be an object")
|
||||
source = parse_int(raw.get("source_node"), f"route step {number} source_node")
|
||||
target = parse_int(raw.get("target_node"), f"route step {number} target_node")
|
||||
memory = parse_int(raw.get("inplace_memory"), f"route step {number} inplace_memory")
|
||||
expected_size = parse_int(
|
||||
raw.get("expected_container_size"),
|
||||
f"route step {number} expected_container_size",
|
||||
)
|
||||
expected_margin = parse_int(
|
||||
raw.get("expected_staging_margin"),
|
||||
f"route step {number} expected_staging_margin",
|
||||
)
|
||||
expected_sha = raw.get("expected_target_sha256")
|
||||
expected_version = raw.get("expected_target_version")
|
||||
if not isinstance(expected_sha, str) or not re.fullmatch(r"[0-9a-f]{64}", expected_sha):
|
||||
raise CompactBuildError(f"route step {number} target SHA-256 is invalid")
|
||||
if not isinstance(expected_version, str):
|
||||
raise CompactBuildError(f"route step {number} target version is invalid")
|
||||
try:
|
||||
motalib.pack_version(expected_version)
|
||||
except (TypeError, ValueError):
|
||||
raise CompactBuildError(f"route step {number} target version is invalid") from None
|
||||
reuse = raw.get("reuse_baseline_package", False)
|
||||
if not isinstance(reuse, bool):
|
||||
raise CompactBuildError(f"route step {number} reuse flag must be boolean")
|
||||
if source >= target or memory % FLASH_PAGE:
|
||||
raise CompactBuildError(f"route step {number} has invalid nodes or workspace alignment")
|
||||
if memory < FLASH_PAGE or APP_BASE + memory >= STAGE_CEILING:
|
||||
raise CompactBuildError(f"route step {number} workspace is outside legacy flash")
|
||||
if number == 1:
|
||||
nodes.append(source)
|
||||
elif source != steps[-1]["target_node"]:
|
||||
raise CompactBuildError(f"route step {number} is discontinuous")
|
||||
nodes.append(target)
|
||||
steps.append({
|
||||
"source_node": source,
|
||||
"target_node": target,
|
||||
"inplace_memory": memory,
|
||||
"reuse_baseline_package": reuse,
|
||||
"expected_container_size": expected_size,
|
||||
"expected_staging_margin": expected_margin,
|
||||
"expected_target_sha256": expected_sha,
|
||||
"expected_target_version": expected_version,
|
||||
})
|
||||
if tuple(nodes) != EXPECTED_ROUTE_NODES:
|
||||
raise CompactBuildError(
|
||||
f"route nodes are {tuple(nodes)}, expected {EXPECTED_ROUTE_NODES}"
|
||||
)
|
||||
if not steps[0]["reuse_baseline_package"] or any(
|
||||
step["reuse_baseline_package"] for step in steps[1:]
|
||||
):
|
||||
raise CompactBuildError("only the first route step may reuse the baseline package")
|
||||
if steps[0]["inplace_memory"] != FIXED_WORKSPACE or steps[1]["inplace_memory"] != FIXED_WORKSPACE:
|
||||
raise CompactBuildError("the two fixed-receiver steps require a 0x98000 workspace")
|
||||
return steps, document
|
||||
|
||||
|
||||
def read_input_rows(root: Path) -> list[dict[str, str]]:
|
||||
try:
|
||||
with (root / "CHAIN.csv").open(newline="", encoding="ascii") as source:
|
||||
rows = list(csv.DictReader(source))
|
||||
except OSError as exc:
|
||||
raise CompactBuildError(f"cannot read baseline CHAIN.csv: {exc}") from exc
|
||||
if len(rows) != 30:
|
||||
raise CompactBuildError(f"baseline chain has {len(rows)} steps, expected 30")
|
||||
return rows
|
||||
|
||||
|
||||
def firmware_identity(image: bytes) -> tuple[int, str, str, int]:
|
||||
if not motalib.has_endf(image):
|
||||
raise CompactBuildError("firmware image has no valid EndF")
|
||||
ident = motalib.parse_endf_ident(image)
|
||||
assert ident is not None
|
||||
if ident.target_id != EXPECTED_TARGET_ID or ident.hw_id != EXPECTED_HARDWARE:
|
||||
raise CompactBuildError(
|
||||
f"firmware identity is target={ident.target_id:08X} hw={ident.hw_id!r}"
|
||||
)
|
||||
body, body_hash = motalib.parse_endf(image)
|
||||
return ident.fw_version, body_hash.hex(), motalib.unpack_version(ident.fw_version), len(body)
|
||||
|
||||
|
||||
def apply_in_place(
|
||||
detools: Path,
|
||||
base: bytes,
|
||||
payload: bytes,
|
||||
target_size: int,
|
||||
memory_size: int,
|
||||
work: Path,
|
||||
label: str,
|
||||
fill: int,
|
||||
) -> bytes:
|
||||
if len(base) > memory_size or target_size > memory_size:
|
||||
raise CompactBuildError(f"{label} image exceeds the selected workspace")
|
||||
memory_path = work / f"{label}-{fill:02x}.memory"
|
||||
payload_path = work / f"{label}.patch"
|
||||
memory = bytearray([fill]) * memory_size
|
||||
memory[:len(base)] = base
|
||||
memory_path.write_bytes(memory)
|
||||
if not payload_path.exists():
|
||||
payload_path.write_bytes(payload)
|
||||
run(
|
||||
[str(detools), "apply_patch_in_place", str(memory_path), str(payload_path)],
|
||||
f"apply {label} with fill 0x{fill:02x}",
|
||||
)
|
||||
result = memory_path.read_bytes()[:target_size]
|
||||
memory_path.unlink()
|
||||
return result
|
||||
|
||||
|
||||
def patch_geometry(detools: Path, payload_path: Path, label: str) -> tuple[int, int, int, int]:
|
||||
output = run(
|
||||
[str(detools), "patch_info", "--no-human", str(payload_path)],
|
||||
f"inspect {label}",
|
||||
)
|
||||
values: dict[str, int] = {}
|
||||
for key, output_key in (
|
||||
("Memory size", "memory"),
|
||||
("Segment size", "segment"),
|
||||
("From size", "from_size"),
|
||||
("To size", "to_size"),
|
||||
):
|
||||
match = re.search(rf"^{re.escape(key)}:\s+([0-9]+) bytes$", output, re.MULTILINE)
|
||||
if match is None:
|
||||
raise CompactBuildError(f"{label} patch_info is missing {key}")
|
||||
values[output_key] = int(match.group(1))
|
||||
return values["memory"], values["segment"], values["from_size"], values["to_size"]
|
||||
|
||||
|
||||
def write_chain(path: Path, rows: list[dict[str, object]]) -> None:
|
||||
fields = [
|
||||
"step", "from_version", "to_version", "mota_file", "mota_size",
|
||||
"inplace_memory", "stage_start", "workspace_end", "staging_margin",
|
||||
"target_image_size", "base_body_hash", "target_body_hash",
|
||||
"target_sha256", "source_commit",
|
||||
]
|
||||
with path.open("w", newline="", encoding="ascii") as output:
|
||||
writer = csv.DictWriter(output, fieldnames=fields, lineterminator="\n")
|
||||
writer.writeheader()
|
||||
writer.writerows(rows)
|
||||
|
||||
|
||||
def write_docs(
|
||||
root: Path,
|
||||
source_commit: str,
|
||||
endpoint: bytes,
|
||||
previous_sha: str,
|
||||
bridge_sha: str,
|
||||
route_sha: str,
|
||||
rows: list[dict[str, object]],
|
||||
) -> None:
|
||||
endpoint_sha = common.sha256_bytes(endpoint)
|
||||
_packed, endpoint_body, _version, _body_size = firmware_identity(endpoint)
|
||||
total_bytes = sum(int(row["mota_size"]) for row in rows)
|
||||
largest = max(int(row["mota_size"]) for row in rows)
|
||||
minimum_margin = min(int(row["staging_margin"]) for row in rows)
|
||||
(root / "README.md").write_text(
|
||||
f"""# RAK3401 compact mOTA update chain
|
||||
|
||||
This archive contains {EXPECTED_STEPS} mandatory mOTA packages from the deployed
|
||||
`1.16.7.0-c1caa5ad` image to
|
||||
`v{EXPECTED_ENDPOINT_VERSION}-halo-keymind-cascade-dev-{source_commit[:8]}`.
|
||||
|
||||
The deployed bootloader is unchanged. Every flash package is bottom-aligned
|
||||
below its original `0x{STAGE_CEILING:X}` scan ceiling, and every encoded detools
|
||||
workspace ends at or below that package. No byte from `0x{STAGE_CEILING:X}`
|
||||
through `0xED000` is used by this chain.
|
||||
|
||||
- Packages: {EXPECTED_STEPS} (the replaced chain used 30)
|
||||
- Total transferred bytes: {total_bytes:,}
|
||||
- Largest package: {largest:,} bytes
|
||||
- Smallest workspace-to-stage margin: {minimum_margin:,} bytes
|
||||
- Endpoint image SHA-256: `{endpoint_sha}`
|
||||
- Endpoint EndF body hash: `{endpoint_body.upper()}`
|
||||
|
||||
Step 1 is byte-for-byte identical to the physically passed baseline package.
|
||||
Step 2 installs the dynamic-workspace receiver while still obeying the old
|
||||
fixed `0x{FIXED_WORKSPACE:X}` receiver limit. Steps 3-{EXPECTED_STEPS} use the
|
||||
page-aligned workspace selected in `ROUTE.json`.
|
||||
|
||||
All {EXPECTED_STEPS} transitions passed container verification, zero-filled and
|
||||
erased-workspace reconstruction, the supplied legacy bootloader simulator, and
|
||||
the exact physical chain on a deployed RAK3401 1W on 19-Aug-2026. Use the pinned
|
||||
chain runner and do not install packages out of order.
|
||||
""",
|
||||
encoding="ascii",
|
||||
)
|
||||
(root / "PROVENANCE.md").write_text(
|
||||
f"""# Build and validation provenance
|
||||
|
||||
Endpoint source commit: `{source_commit}`
|
||||
Endpoint requested version: `{EXPECTED_ENDPOINT_VERSION}`
|
||||
Endpoint packed version: `0x{EXPECTED_ENDPOINT_VERSION_PACKED:08X}`
|
||||
Endpoint profile name: `halo-keymind-cascade-dev`
|
||||
Legacy stage ceiling: `0x{STAGE_CEILING:X}`
|
||||
Application base: `0x{APP_BASE:X}`
|
||||
Segment size: `{SEGMENT_SIZE}`
|
||||
Logical mOTA block size: `{BLOCK_SIZE}`
|
||||
|
||||
Pinned 30-step reconstruction input SHA-256: `{previous_sha}`
|
||||
Dynamic bridge-image manifest SHA-256: `{bridge_sha}`
|
||||
Compact route plan SHA-256: `{route_sha}`
|
||||
|
||||
The route plan records the shortest path found by sweeping every page-aligned
|
||||
workspace relevant to a path shorter than {EXPECTED_STEPS} packages. The
|
||||
bundle generator independently checks the selected geometry against the old
|
||||
bootloader ceiling before invoking either simulator.
|
||||
""",
|
||||
encoding="ascii",
|
||||
)
|
||||
(root / "RUNBOOK.md").write_text(
|
||||
"""# Runbook
|
||||
|
||||
Use `tools/lora_ota/rak3401_mota_chain.py` from the matching MeshCore commit.
|
||||
Run `--verify-only`, then `--preflight-only`, before a live attempt. Live use
|
||||
is enabled for this physically qualified exact chain. Keep the persistent work
|
||||
directory so an interrupted transfer resumes from the target's exact EndF body
|
||||
hash.
|
||||
|
||||
Do not use `--inplace-memory 0x98000` as a manual override for these files.
|
||||
Each package already carries its audited workspace in the detools payload.
|
||||
""",
|
||||
encoding="ascii",
|
||||
)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--previous-bundle", type=Path, required=True)
|
||||
parser.add_argument("--endpoint-zip", type=Path, required=True)
|
||||
parser.add_argument("--endpoint-uf2", type=Path, required=True)
|
||||
parser.add_argument("--accelerated-images", type=Path, required=True)
|
||||
parser.add_argument("--route-plan", type=Path, required=True)
|
||||
parser.add_argument("--work-dir", type=Path, required=True)
|
||||
parser.add_argument("--output-dir", type=Path, required=True)
|
||||
parser.add_argument("--motatool", type=Path, required=True)
|
||||
parser.add_argument("--detools", type=Path, required=True)
|
||||
parser.add_argument(
|
||||
"--apply-sim",
|
||||
action="append",
|
||||
default=[],
|
||||
metavar="LABEL=PATH",
|
||||
help="apply simulator and stable provenance label; repeat to validate multiple bootloaders",
|
||||
)
|
||||
parser.add_argument("--source-commit", required=True)
|
||||
args = parser.parse_args()
|
||||
|
||||
for path, label in (
|
||||
(args.previous_bundle, "previous bundle"),
|
||||
(args.endpoint_zip, "endpoint ZIP"),
|
||||
(args.endpoint_uf2, "endpoint UF2"),
|
||||
(args.accelerated_images, "accelerated image manifest"),
|
||||
(args.route_plan, "route plan"),
|
||||
(args.motatool, "motatool"),
|
||||
(args.detools, "detools"),
|
||||
):
|
||||
if not path.is_file():
|
||||
raise CompactBuildError(f"{label} does not exist: {path}")
|
||||
simulators = parse_simulators(args.apply_sim)
|
||||
if not re.fullmatch(r"[0-9a-f]{40}", args.source_commit):
|
||||
raise CompactBuildError("source commit must be a full lowercase 40-hex commit")
|
||||
resolved = run(
|
||||
["git", "-C", str(REPO_ROOT), "rev-parse", "--verify", f"{args.source_commit}^{{commit}}"],
|
||||
"resolve endpoint source commit",
|
||||
).strip()
|
||||
if resolved != args.source_commit:
|
||||
raise CompactBuildError(
|
||||
f"endpoint source resolves to {resolved}, expected {args.source_commit}"
|
||||
)
|
||||
if args.work_dir.exists():
|
||||
raise CompactBuildError(f"work directory already exists: {args.work_dir}")
|
||||
args.work_dir.mkdir(parents=True)
|
||||
|
||||
route, route_document = read_route(args.route_plan)
|
||||
if (
|
||||
route_document.get("endpoint_source_commit") != args.source_commit
|
||||
or route_document.get("endpoint_version") != EXPECTED_ENDPOINT_VERSION
|
||||
or route_document.get("endpoint_name") != "halo-keymind-cascade-dev"
|
||||
):
|
||||
raise CompactBuildError("route plan endpoint identity does not match this build")
|
||||
previous_sha = common.sha256_file(args.previous_bundle)
|
||||
if previous_sha != PREVIOUS_BUNDLE_SHA256:
|
||||
raise CompactBuildError(
|
||||
f"previous bundle SHA-256 is {previous_sha}, expected {PREVIOUS_BUNDLE_SHA256}"
|
||||
)
|
||||
old_root = common.extract_zip(args.previous_bundle, args.work_dir / "old")
|
||||
old_rows = read_input_rows(old_root)
|
||||
start_zip = old_root / "recovery/test-start/RAK3401-test-start-v1.16.7-c1caa5ad.zip"
|
||||
image0 = common.read_firmware_zip(start_zip)
|
||||
firmware_identity(image0)
|
||||
first_relative = common.safe_relative(old_rows[0]["mota_file"], "baseline step-1 mOTA")
|
||||
first_package_path = old_root.joinpath(*first_relative.parts)
|
||||
first_package = motalib.parse_container(first_package_path.read_bytes())
|
||||
reconstruction = args.work_dir / "reconstruction"
|
||||
reconstruction.mkdir()
|
||||
image1 = apply_in_place(
|
||||
args.detools, image0, first_package.payload,
|
||||
int(old_rows[0]["target_image_size"]), FIXED_WORKSPACE,
|
||||
reconstruction, "baseline-step-01", 0xFF,
|
||||
)
|
||||
image1_zero = apply_in_place(
|
||||
args.detools, image0, first_package.payload,
|
||||
int(old_rows[0]["target_image_size"]), FIXED_WORKSPACE,
|
||||
reconstruction, "baseline-step-01", 0x00,
|
||||
)
|
||||
if image1 != image1_zero or common.sha256_bytes(image1) != old_rows[0]["target_sha256"]:
|
||||
raise CompactBuildError("baseline step 1 does not reconstruct its pinned target")
|
||||
|
||||
accelerated, accelerated_sources = common.read_accelerated_images(args.accelerated_images)
|
||||
endpoint = common.read_firmware_zip(args.endpoint_zip)
|
||||
endpoint_packed, _body_hash, _version_text, _body_size = firmware_identity(endpoint)
|
||||
if endpoint_packed != EXPECTED_ENDPOINT_VERSION_PACKED:
|
||||
raise CompactBuildError(
|
||||
f"endpoint packed version is 0x{endpoint_packed:08X}, "
|
||||
f"expected 0x{EXPECTED_ENDPOINT_VERSION_PACKED:08X}"
|
||||
)
|
||||
expected_label = f"v{EXPECTED_ENDPOINT_VERSION}-halo-keymind-cascade-dev-{args.source_commit[:8]}"
|
||||
if expected_label.encode("ascii") not in endpoint:
|
||||
raise CompactBuildError(f"endpoint firmware does not contain {expected_label}")
|
||||
|
||||
images = {0: image0, 1: image1}
|
||||
sources = {1: old_rows[0]["source_commit"]}
|
||||
for node, (image, source_commit) in enumerate(zip(accelerated, accelerated_sources), 2):
|
||||
images[node] = image
|
||||
sources[node] = source_commit
|
||||
images[30] = endpoint
|
||||
sources[30] = args.source_commit
|
||||
expected_nodes = set(range(31))
|
||||
if set(images) != expected_nodes:
|
||||
# Do not silently accept a shortened or reordered bridge manifest.
|
||||
raise CompactBuildError(f"assembled image nodes are {sorted(images)}")
|
||||
|
||||
short_commit = args.source_commit[:8]
|
||||
root_name = (
|
||||
"RAK3401-update-chain-v1.16.7-c1caa5ad-to-"
|
||||
f"v{EXPECTED_ENDPOINT_VERSION}-{short_commit}"
|
||||
)
|
||||
root = args.work_dir / root_name
|
||||
motas = root / "motas"
|
||||
recovery_start = root / "recovery/test-start"
|
||||
recovery_final = root / "recovery/final"
|
||||
motas.mkdir(parents=True)
|
||||
recovery_start.mkdir(parents=True)
|
||||
recovery_final.mkdir(parents=True)
|
||||
validation_work = args.work_dir / "validation"
|
||||
validation_work.mkdir()
|
||||
|
||||
output_rows: list[dict[str, object]] = []
|
||||
validation_steps: list[dict[str, object]] = []
|
||||
for number, plan_step in enumerate(route, 1):
|
||||
source_node = int(plan_step["source_node"])
|
||||
target_node = int(plan_step["target_node"])
|
||||
memory_size = int(plan_step["inplace_memory"])
|
||||
base_image = images[source_node]
|
||||
target_image = images[target_node]
|
||||
base_packed, base_body, base_version, _base_body_size = firmware_identity(base_image)
|
||||
target_packed, target_body, target_version, _target_body_size = firmware_identity(target_image)
|
||||
from_version = base_version
|
||||
to_version = EXPECTED_ENDPOINT_VERSION if target_node == 30 else target_version
|
||||
filename = f"step-{number:02d}__v{from_version}-to-v{to_version}.mota"
|
||||
output_path = motas / filename
|
||||
base_path = validation_work / f"base-{number:02d}.bin"
|
||||
target_path = validation_work / f"target-{number:02d}.bin"
|
||||
payload_path = validation_work / f"payload-{number:02d}.patch"
|
||||
base_path.write_bytes(base_image)
|
||||
target_path.write_bytes(target_image)
|
||||
if bool(plan_step["reuse_baseline_package"]):
|
||||
shutil.copy2(first_package_path, output_path)
|
||||
build_kind = "byte-identical physical baseline"
|
||||
else:
|
||||
build_output = run(
|
||||
[
|
||||
str(args.motatool), "build", "--fw", str(target_path),
|
||||
"--base", str(base_path), "--patch-type", "in-place",
|
||||
"--inplace-memory", hex(memory_size),
|
||||
"--segment-size", str(SEGMENT_SIZE),
|
||||
"--block-size", str(BLOCK_SIZE), "--out", str(output_path),
|
||||
],
|
||||
f"build compact step {number}",
|
||||
)
|
||||
if not build_output.strip():
|
||||
raise CompactBuildError(f"motatool produced no output for step {number}")
|
||||
build_kind = "freshly generated"
|
||||
run([str(args.motatool), "verify", str(output_path)], f"verify compact step {number}")
|
||||
parsed = motalib.parse_container(output_path.read_bytes())
|
||||
payload_path.write_bytes(parsed.payload)
|
||||
patch_memory, patch_segment, patch_from, patch_to = patch_geometry(
|
||||
args.detools, payload_path, f"compact step {number}"
|
||||
)
|
||||
if (
|
||||
patch_memory != memory_size
|
||||
or patch_segment != SEGMENT_SIZE
|
||||
or patch_from != len(base_image)
|
||||
or patch_to != len(target_image)
|
||||
):
|
||||
raise CompactBuildError(
|
||||
f"step {number} detools geometry does not match its route plan/images"
|
||||
)
|
||||
if parsed.manifest.fw_version != target_packed:
|
||||
raise CompactBuildError(f"step {number} manifest version does not match target EndF")
|
||||
if parsed.manifest.base_hash.hex() != base_body:
|
||||
raise CompactBuildError(f"step {number} manifest base hash mismatch")
|
||||
target_sha = common.sha256_bytes(target_image)
|
||||
if parsed.manifest.image_hash.hex() != target_sha:
|
||||
raise CompactBuildError(f"step {number} manifest target hash mismatch")
|
||||
if target_sha != plan_step["expected_target_sha256"]:
|
||||
raise CompactBuildError(f"step {number} target does not match its route pin")
|
||||
if target_packed != motalib.pack_version(str(plan_step["expected_target_version"])):
|
||||
raise CompactBuildError(f"step {number} target version does not match its route pin")
|
||||
if parsed.manifest.target_id != EXPECTED_TARGET_ID:
|
||||
raise CompactBuildError(f"step {number} manifest target ID mismatch")
|
||||
if parsed.manifest.image_size != len(target_image):
|
||||
raise CompactBuildError(f"step {number} manifest target size mismatch")
|
||||
|
||||
package_size = output_path.stat().st_size
|
||||
if package_size != plan_step["expected_container_size"]:
|
||||
raise CompactBuildError(
|
||||
f"step {number} is {package_size} bytes, expected "
|
||||
f"{plan_step['expected_container_size']}"
|
||||
)
|
||||
stage_start = align_down(STAGE_CEILING - package_size, FLASH_PAGE)
|
||||
workspace_end = APP_BASE + memory_size
|
||||
staging_margin = stage_start - workspace_end
|
||||
if stage_start < APP_BASE + len(base_image):
|
||||
raise CompactBuildError(f"step {number} staged package overlaps its running image")
|
||||
if staging_margin < 0:
|
||||
raise CompactBuildError(
|
||||
f"step {number} needs {-staging_margin} bytes beyond the legacy ceiling"
|
||||
)
|
||||
if staging_margin != plan_step["expected_staging_margin"]:
|
||||
raise CompactBuildError(
|
||||
f"step {number} staging margin is {staging_margin}, expected "
|
||||
f"{plan_step['expected_staging_margin']}"
|
||||
)
|
||||
if memory_size < align_up(max(len(base_image) + 2 * SEGMENT_SIZE, len(target_image)), SEGMENT_SIZE):
|
||||
raise CompactBuildError(f"step {number} workspace is below detools' safe minimum")
|
||||
|
||||
zero = apply_in_place(
|
||||
args.detools, base_image, parsed.payload, len(target_image), memory_size,
|
||||
validation_work, f"compact-step-{number:02d}", 0x00,
|
||||
)
|
||||
erased = apply_in_place(
|
||||
args.detools, base_image, parsed.payload, len(target_image), memory_size,
|
||||
validation_work, f"compact-step-{number:02d}", 0xFF,
|
||||
)
|
||||
if zero != target_image or erased != target_image:
|
||||
raise CompactBuildError(f"step {number} reconstruction does not match target")
|
||||
simulator_results = []
|
||||
for simulator_label, simulator in simulators:
|
||||
run(
|
||||
[str(simulator), str(base_path), str(output_path), str(target_path)],
|
||||
f"{simulator_label} step {number}",
|
||||
)
|
||||
simulator_results.append({
|
||||
"label": simulator_label,
|
||||
"sha256": common.sha256_file(simulator),
|
||||
})
|
||||
|
||||
output_rows.append({
|
||||
"step": number,
|
||||
"from_version": from_version,
|
||||
"to_version": to_version,
|
||||
"mota_file": f"motas/{filename}",
|
||||
"mota_size": package_size,
|
||||
"inplace_memory": f"0x{memory_size:X}",
|
||||
"stage_start": f"0x{stage_start:X}",
|
||||
"workspace_end": f"0x{workspace_end:X}",
|
||||
"staging_margin": staging_margin,
|
||||
"target_image_size": len(target_image),
|
||||
"base_body_hash": base_body,
|
||||
"target_body_hash": target_body,
|
||||
"target_sha256": target_sha,
|
||||
"source_commit": sources[target_node],
|
||||
})
|
||||
validation_steps.append({
|
||||
"step": number,
|
||||
"source_node": source_node,
|
||||
"target_node": target_node,
|
||||
"build_kind": build_kind,
|
||||
"inplace_memory": memory_size,
|
||||
"stage_start": stage_start,
|
||||
"workspace_end": workspace_end,
|
||||
"staging_margin": staging_margin,
|
||||
"mota_sha256": common.sha256_file(output_path),
|
||||
"mota_size": package_size,
|
||||
"target_sha256": target_sha,
|
||||
"zero_fill_apply": "passed",
|
||||
"erased_fill_apply": "passed",
|
||||
"apply_simulators": simulator_results,
|
||||
})
|
||||
print(
|
||||
f"[compact] {number:02d}/{EXPECTED_STEPS} nodes={source_node}->{target_node} "
|
||||
f"memory=0x{memory_size:X} size={package_size} margin={staging_margin}",
|
||||
flush=True,
|
||||
)
|
||||
|
||||
write_chain(root / "CHAIN.csv", output_rows)
|
||||
shutil.copy2(start_zip, recovery_start)
|
||||
start_uf2 = old_root / "recovery/test-start/RAK3401-test-start-v1.16.7-c1caa5ad.uf2"
|
||||
shutil.copy2(start_uf2, recovery_start)
|
||||
shutil.copy2(args.endpoint_zip, recovery_final)
|
||||
shutil.copy2(args.endpoint_uf2, recovery_final)
|
||||
shutil.copy2(args.accelerated_images, root / "BRIDGE-IMAGES.json")
|
||||
shutil.copy2(args.route_plan, root / "ROUTE.json")
|
||||
baseline = old_root / "PHYSICAL-BASELINE-cd824765.jsonl"
|
||||
if baseline.is_file():
|
||||
shutil.copy2(baseline, root)
|
||||
|
||||
route_sha = common.sha256_file(args.route_plan)
|
||||
bridge_sha = common.sha256_file(args.accelerated_images)
|
||||
write_docs(
|
||||
root, args.source_commit, endpoint, previous_sha, bridge_sha,
|
||||
route_sha, output_rows,
|
||||
)
|
||||
validation = {
|
||||
"generated_at": datetime.now(timezone.utc).isoformat(),
|
||||
"generator": "tools/lora_ota/build_rak3401_compact_bundle.py",
|
||||
"source_commit": args.source_commit,
|
||||
"endpoint_requested_version": EXPECTED_ENDPOINT_VERSION,
|
||||
"endpoint_packed_version": EXPECTED_ENDPOINT_VERSION_PACKED,
|
||||
"endpoint_name": "halo-keymind-cascade-dev",
|
||||
"previous_bundle_sha256": previous_sha,
|
||||
"bridge_images_sha256": bridge_sha,
|
||||
"route_plan_sha256": route_sha,
|
||||
"route_search": route_document.get("search"),
|
||||
"app_base": APP_BASE,
|
||||
"stage_ceiling": STAGE_CEILING,
|
||||
"segment_size": SEGMENT_SIZE,
|
||||
"block_size": BLOCK_SIZE,
|
||||
"steps": validation_steps,
|
||||
}
|
||||
(root / "validation-results.json").write_text(
|
||||
json.dumps(validation, indent=2, sort_keys=True) + "\n",
|
||||
encoding="ascii",
|
||||
)
|
||||
checksum_sha = common.write_checksums(root)
|
||||
args.output_dir.mkdir(parents=True, exist_ok=True)
|
||||
output_path = args.output_dir / f"{root_name}.zip"
|
||||
common.make_zip(root, output_path)
|
||||
print(f"[bundle] {output_path}")
|
||||
print(f"[bundle] sha256={common.sha256_file(output_path)}")
|
||||
print(f"[bundle] inner_sha256={checksum_sha}")
|
||||
print(f"[bundle] endpoint_sha256={common.sha256_bytes(endpoint)}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
raise SystemExit(main())
|
||||
except (CompactBuildError, common.RebuildError) as exc:
|
||||
print(f"ERROR: {exc}", file=sys.stderr)
|
||||
raise SystemExit(2)
|
||||
+197
-6
@@ -70,6 +70,9 @@ COMPANION_TERMINAL_STOP = "+++MESHCORE-TERM-STOP"
|
||||
# Firmware may hold the apply reboot for up to 15 seconds while its reply
|
||||
# drains. Do not interpret "still ready" as a failed install before that cap.
|
||||
INSTALL_RECONCILE_WAIT_SECONDS = 20
|
||||
DEFAULT_RELAY_TX_DELAY = 0.3
|
||||
RELAY_TIMING_COMMANDS_PER_RELAY = 12
|
||||
RELAY_TIMING_RECOVERY_FILE = "relay-timing-settings.json"
|
||||
|
||||
T = TypeVar("T")
|
||||
|
||||
@@ -174,6 +177,14 @@ class RadioSettings:
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RelayTimingSettings:
|
||||
name: str
|
||||
password: str
|
||||
rxdelay: float
|
||||
txdelay: float
|
||||
|
||||
|
||||
def prompt_after_transmission_failure(
|
||||
label: str,
|
||||
error: TransmissionError,
|
||||
@@ -1838,6 +1849,8 @@ class SeederProcess:
|
||||
"--serial", args.source_serial,
|
||||
"--baud", str(args.source_baud),
|
||||
])
|
||||
if getattr(args, "source_companion_terminal", False):
|
||||
command.append("--companion-terminal")
|
||||
else:
|
||||
command.extend(["--tcp", args.source_tcp])
|
||||
self.command = command
|
||||
@@ -1938,6 +1951,130 @@ def parse_relay(value: str, default_password: str) -> tuple[str, str]:
|
||||
return value, default_password
|
||||
|
||||
|
||||
def parse_delay_reply(reply: str, label: str) -> float:
|
||||
match = re.fullmatch(r"\s*>\s*([0-9]+(?:\.[0-9]+)?)\s*", reply)
|
||||
if match is None:
|
||||
raise OtaError(f"could not read {label}: {reply}")
|
||||
return float(match.group(1))
|
||||
|
||||
|
||||
def read_relay_timing(
|
||||
controller: Controller,
|
||||
relay_name: str,
|
||||
relay_password: str,
|
||||
) -> RelayTimingSettings:
|
||||
rxdelay = parse_delay_reply(
|
||||
controller.remote_command(
|
||||
relay_name, "get rxdelay", password=relay_password
|
||||
),
|
||||
f"{relay_name} rxdelay",
|
||||
)
|
||||
txdelay = parse_delay_reply(
|
||||
controller.remote_command(
|
||||
relay_name, "get txdelay", password=relay_password
|
||||
),
|
||||
f"{relay_name} txdelay",
|
||||
)
|
||||
return RelayTimingSettings(
|
||||
name=relay_name,
|
||||
password=relay_password,
|
||||
rxdelay=rxdelay,
|
||||
txdelay=txdelay,
|
||||
)
|
||||
|
||||
|
||||
def write_relay_timing_recovery(
|
||||
work_dir: Path,
|
||||
settings: list[RelayTimingSettings],
|
||||
) -> Path:
|
||||
path = work_dir / RELAY_TIMING_RECOVERY_FILE
|
||||
payload = [
|
||||
{
|
||||
"name": item.name,
|
||||
"rxdelay": item.rxdelay,
|
||||
"txdelay": item.txdelay,
|
||||
}
|
||||
for item in settings
|
||||
]
|
||||
temporary = path.with_suffix(path.suffix + ".tmp")
|
||||
temporary.write_text(
|
||||
json.dumps(payload, indent=2, sort_keys=True) + "\n",
|
||||
encoding="ascii",
|
||||
)
|
||||
temporary.chmod(0o600)
|
||||
os.replace(temporary, path)
|
||||
return path
|
||||
|
||||
|
||||
def enforce_relay_timing(
|
||||
controller: Controller,
|
||||
saved: RelayTimingSettings,
|
||||
txdelay: float,
|
||||
) -> None:
|
||||
if abs(saved.rxdelay) > 0.0001:
|
||||
reply = controller.remote_command(
|
||||
saved.name, "set rxdelay 0", password=saved.password
|
||||
)
|
||||
if not reply.upper().startswith("OK"):
|
||||
raise OtaError(f"{saved.name} did not disable rxdelay: {reply}")
|
||||
if abs(saved.txdelay - txdelay) > 0.0001:
|
||||
reply = controller.remote_command(
|
||||
saved.name,
|
||||
f"set txdelay {format_decimal(txdelay)}",
|
||||
password=saved.password,
|
||||
)
|
||||
if not reply.upper().startswith("OK"):
|
||||
raise OtaError(f"{saved.name} did not accept OTA txdelay: {reply}")
|
||||
|
||||
verified = read_relay_timing(controller, saved.name, saved.password)
|
||||
if abs(verified.rxdelay) > 0.0001 or abs(verified.txdelay - txdelay) > 0.0001:
|
||||
raise OtaError(
|
||||
f"{saved.name} transfer timing did not read back as "
|
||||
f"rxdelay=0, txdelay={format_decimal(txdelay)}"
|
||||
)
|
||||
print(
|
||||
f"[relays] {saved.name} verified at rxdelay 0, "
|
||||
f"txdelay {format_decimal(txdelay)}"
|
||||
)
|
||||
|
||||
|
||||
def restore_relay_timings(
|
||||
controller: Controller,
|
||||
settings: list[RelayTimingSettings],
|
||||
) -> None:
|
||||
errors: list[str] = []
|
||||
for saved in reversed(settings):
|
||||
try:
|
||||
current = read_relay_timing(controller, saved.name, saved.password)
|
||||
if abs(current.rxdelay - saved.rxdelay) > 0.0001:
|
||||
reply = controller.remote_command(
|
||||
saved.name,
|
||||
f"set rxdelay {format_decimal(saved.rxdelay)}",
|
||||
password=saved.password,
|
||||
)
|
||||
if not reply.upper().startswith("OK"):
|
||||
raise OtaError(f"did not restore rxdelay: {reply}")
|
||||
if abs(current.txdelay - saved.txdelay) > 0.0001:
|
||||
reply = controller.remote_command(
|
||||
saved.name,
|
||||
f"set txdelay {format_decimal(saved.txdelay)}",
|
||||
password=saved.password,
|
||||
)
|
||||
if not reply.upper().startswith("OK"):
|
||||
raise OtaError(f"did not restore txdelay: {reply}")
|
||||
verified = read_relay_timing(controller, saved.name, saved.password)
|
||||
if (
|
||||
abs(verified.rxdelay - saved.rxdelay) > 0.0001
|
||||
or abs(verified.txdelay - saved.txdelay) > 0.0001
|
||||
):
|
||||
raise OtaError("relay timing did not restore exactly")
|
||||
print(f"[relays] {saved.name} original rxdelay/txdelay restored")
|
||||
except (OtaError, OSError) as exc:
|
||||
errors.append(f"{saved.name}: {exc}")
|
||||
if errors:
|
||||
raise OtaError("could not restore relay timings: " + "; ".join(errors))
|
||||
|
||||
|
||||
def confirm_update(
|
||||
args: argparse.Namespace,
|
||||
target: TargetInfo,
|
||||
@@ -1957,6 +2094,11 @@ def confirm_update(
|
||||
print(f" update : {package.version} {package.kind} hw={package.hw_id or '?'}")
|
||||
print(f" mOTA id : {package.manifest_id}")
|
||||
print(f" TempRadio : {args.temp_radio}")
|
||||
if args.relay:
|
||||
print(
|
||||
f" relay timing: rxdelay 0, "
|
||||
f"txdelay {format_decimal(args.relay_txdelay)} (saved/restored)"
|
||||
)
|
||||
print(f" action : {'stage only' if args.no_install else 'install and reboot'}")
|
||||
current_version = (
|
||||
parse_version(target.current_version) if target.current_version else None
|
||||
@@ -2738,6 +2880,15 @@ def build_parser() -> argparse.ArgumentParser:
|
||||
"--relay", action="append", default=[], metavar="NAME[=PASSWORD]",
|
||||
help="optional relay, ordered farthest-to-nearest; repeat as needed",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--relay-txdelay",
|
||||
type=float,
|
||||
default=DEFAULT_RELAY_TX_DELAY,
|
||||
help=(
|
||||
"temporary flood txdelay for managed relays; rxdelay is also "
|
||||
"temporarily set to 0 and both original values are restored"
|
||||
),
|
||||
)
|
||||
parser.add_argument(
|
||||
"--temp-radio", default="909.950,250,5,5,120",
|
||||
help="frequency,bw,sf,cr,minutes",
|
||||
@@ -2849,6 +3000,8 @@ def validate_args(args: argparse.Namespace, parser: argparse.ArgumentParser) ->
|
||||
args.temp_values = parse_temp_radio(args.temp_radio)
|
||||
except argparse.ArgumentTypeError as exc:
|
||||
parser.error(f"--temp-radio: {exc}")
|
||||
if not math.isfinite(args.relay_txdelay) or not 0.0 <= args.relay_txdelay <= 2.0:
|
||||
parser.error("--relay-txdelay must be between 0 and 2")
|
||||
clear_manifest = args.clear_completed_manifest
|
||||
clear_body_hash = args.clear_completed_on_body_hash
|
||||
if bool(clear_manifest) != bool(clear_body_hash):
|
||||
@@ -2944,6 +3097,11 @@ def validate_args(args: argparse.Namespace, parser: argparse.ArgumentParser) ->
|
||||
0 if args.source_already_temp or args.source_shares_controller else 30
|
||||
)
|
||||
final_reply_count = 1 if args.no_install else 4 + len(args.relay)
|
||||
relay_timing_seconds = (
|
||||
len(args.relay)
|
||||
* RELAY_TIMING_COMMANDS_PER_RELAY
|
||||
* args.reply_timeout
|
||||
)
|
||||
required_temp_seconds = (
|
||||
remote_setup_seconds
|
||||
+ source_setup_seconds
|
||||
@@ -2953,6 +3111,7 @@ def validate_args(args: argparse.Namespace, parser: argparse.ArgumentParser) ->
|
||||
+ args.transfer_timeout_minutes * 60
|
||||
+ adaptive_poll_ceiling(args.poll_seconds)
|
||||
+ args.reply_timeout * final_reply_count
|
||||
+ relay_timing_seconds
|
||||
)
|
||||
temp_seconds = args.temp_values[4] * 60
|
||||
if required_temp_seconds >= temp_seconds:
|
||||
@@ -3039,6 +3198,7 @@ def main(
|
||||
source_temp_owned = False
|
||||
target_temp_owned = False
|
||||
armed_relay_values: list[tuple[str, str]] = []
|
||||
relay_timing_settings: list[RelayTimingSettings] = []
|
||||
password = args.password or os.environ.get("MESHCORE_ADMIN_PASSWORD", "")
|
||||
try:
|
||||
preflight_inputs(args)
|
||||
@@ -3128,6 +3288,19 @@ def main(
|
||||
source_temp_owned = True
|
||||
time.sleep(TEMP_RADIO_SWITCH_DELAY_SECONDS)
|
||||
|
||||
for relay_name, relay_password in args.relay_values:
|
||||
saved_timing = read_relay_timing(
|
||||
controller, relay_name, relay_password
|
||||
)
|
||||
relay_timing_settings.append(saved_timing)
|
||||
recovery_path = write_relay_timing_recovery(
|
||||
work_dir, relay_timing_settings
|
||||
)
|
||||
print(f"[relays] timing recovery settings: {recovery_path}")
|
||||
enforce_relay_timing(
|
||||
controller, saved_timing, args.relay_txdelay
|
||||
)
|
||||
|
||||
seeder = SeederProcess(args, package_path.parent, work_dir)
|
||||
seeder_attempted = True
|
||||
seeder.start()
|
||||
@@ -3136,6 +3309,8 @@ def main(
|
||||
|
||||
if args.no_install:
|
||||
print(f"{args.target} is ready to install; leaving the verified update staged.")
|
||||
restore_relay_timings(controller, relay_timing_settings)
|
||||
relay_timing_settings.clear()
|
||||
if args.leave_controller_radio:
|
||||
print(
|
||||
"[cleanup] preserving TempRadio windows because "
|
||||
@@ -3168,6 +3343,8 @@ def main(
|
||||
# Once the target has accepted (or may have accepted) installation,
|
||||
# the relays are no longer needed on TempRadio. Give each a short,
|
||||
# bounded window, then verify through the restored normal route.
|
||||
restore_relay_timings(controller, relay_timing_settings)
|
||||
relay_timing_settings.clear()
|
||||
shorten_relay_temp_windows(controller, args)
|
||||
armed_relay_values.clear()
|
||||
|
||||
@@ -3221,10 +3398,13 @@ def main(
|
||||
# this is harmless on TCP and explicitly cleans the serial case.
|
||||
if seeder_attempted and args.source_serial:
|
||||
source_cli_command(args, "ota folder off", check=False)
|
||||
needs_remote_cleanup = target_temp_owned or bool(armed_relay_values)
|
||||
needs_remote_cleanup = (
|
||||
target_temp_owned
|
||||
or bool(armed_relay_values)
|
||||
or bool(relay_timing_settings)
|
||||
)
|
||||
if (
|
||||
needs_remote_cleanup
|
||||
and not args.leave_controller_radio
|
||||
and controller is not None
|
||||
and original_radio is not None
|
||||
and temp_radio is not None
|
||||
@@ -3239,12 +3419,23 @@ def main(
|
||||
time.sleep(TEMP_RADIO_SWITCH_DELAY_SECONDS)
|
||||
except (OtaError, OSError) as exc:
|
||||
print(
|
||||
"WARNING: could not reach TempRadio to shorten remote "
|
||||
f"leases; their configured windows remain bounded: {exc}",
|
||||
"WARNING: could not reach TempRadio to restore relay "
|
||||
"timing or shorten remote leases; TempRadio windows "
|
||||
f"remain bounded: {exc}",
|
||||
file=sys.stderr,
|
||||
)
|
||||
if controller_changed:
|
||||
if target_temp_owned:
|
||||
if relay_timing_settings:
|
||||
try:
|
||||
restore_relay_timings(controller, relay_timing_settings)
|
||||
relay_timing_settings.clear()
|
||||
except (OtaError, OSError) as exc:
|
||||
print(
|
||||
"CRITICAL: could not restore every relay's "
|
||||
f"rxdelay/txdelay; use {RELAY_TIMING_RECOVERY_FILE}: {exc}",
|
||||
file=sys.stderr,
|
||||
)
|
||||
if target_temp_owned and not args.leave_controller_radio:
|
||||
try:
|
||||
shorten_target_temp_window(controller, args)
|
||||
target_temp_owned = False
|
||||
@@ -3254,7 +3445,7 @@ def main(
|
||||
f"TempRadio lease: {exc}",
|
||||
file=sys.stderr,
|
||||
)
|
||||
if armed_relay_values:
|
||||
if armed_relay_values and not args.leave_controller_radio:
|
||||
try:
|
||||
shorten_relay_temp_windows(
|
||||
controller, args, armed_relay_values
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
{
|
||||
"app_base": "0x26000",
|
||||
"endpoint_name": "halo-keymind-cascade-dev",
|
||||
"endpoint_source_commit": "e742333a0ee393b0d55d2414f25b28f2f67e1ea1",
|
||||
"endpoint_version": "1.17.1.02",
|
||||
"schema": 1,
|
||||
"search": {
|
||||
"dynamic_workspace_candidates_on_shortest_dag": 2296,
|
||||
"fixed_source_0_reachable_nodes": [
|
||||
1
|
||||
],
|
||||
"fixed_source_1_reachable_nodes": [
|
||||
2,
|
||||
3,
|
||||
4,
|
||||
5,
|
||||
6
|
||||
],
|
||||
"objective": "minimum packages, then minimum total container bytes",
|
||||
"page_size": 4096,
|
||||
"selected_total_bytes": 1002164,
|
||||
"shorter_path_edges_unlocked": 0,
|
||||
"shorter_path_pairs": 272,
|
||||
"shorter_path_workspace_candidates": 9430,
|
||||
"shortest_dag_edges": 61,
|
||||
"shortest_package_count": 9,
|
||||
"shortest_route_count": 840,
|
||||
"workspace_max": "0xAD000",
|
||||
"workspace_minimum": "page_align(max(base_size + 8192, target_size))"
|
||||
},
|
||||
"shortest_package_count": 9,
|
||||
"stage_ceiling": "0xD4000",
|
||||
"steps": [
|
||||
{
|
||||
"expected_container_size": 89844,
|
||||
"expected_staging_margin": 0,
|
||||
"expected_target_sha256": "8364257a2b3a219905e870fad6fbb2040a96ca4b4bb7201b2867534cc2b45530",
|
||||
"expected_target_version": "1.16.7.9",
|
||||
"inplace_memory": "0x98000",
|
||||
"reuse_baseline_package": true,
|
||||
"source_node": 0,
|
||||
"target_node": 1
|
||||
},
|
||||
{
|
||||
"expected_container_size": 46363,
|
||||
"expected_staging_margin": 40960,
|
||||
"expected_target_sha256": "7031e4d7f883c5d6ba6637eab730d143bd7d0f3c755f09a6c52190f762d23e45",
|
||||
"expected_target_version": "1.16.7.10",
|
||||
"inplace_memory": "0x98000",
|
||||
"reuse_baseline_package": false,
|
||||
"source_node": 1,
|
||||
"target_node": 2
|
||||
},
|
||||
{
|
||||
"expected_container_size": 106030,
|
||||
"expected_staging_margin": 12288,
|
||||
"expected_target_sha256": "2a434bc74d4c5c282cf5709dff18c5cceb1d7d69321a2692944df0d8c14d43d1",
|
||||
"expected_target_version": "1.16.8.7",
|
||||
"inplace_memory": "0x91000",
|
||||
"reuse_baseline_package": false,
|
||||
"source_node": 2,
|
||||
"target_node": 7
|
||||
},
|
||||
{
|
||||
"expected_container_size": 149927,
|
||||
"expected_staging_margin": 0,
|
||||
"expected_target_sha256": "0a514424c121febb90c7951859f0f5ccf30ffc5392c3bc3185bc7141bde651ff",
|
||||
"expected_target_version": "1.16.9.105",
|
||||
"inplace_memory": "0x89000",
|
||||
"reuse_baseline_package": false,
|
||||
"source_node": 7,
|
||||
"target_node": 13
|
||||
},
|
||||
{
|
||||
"expected_container_size": 111307,
|
||||
"expected_staging_margin": 16384,
|
||||
"expected_target_sha256": "871039ef453faf98b694b14c666773c8c6d5ae159fc7a4573f57feb66bdb60be",
|
||||
"expected_target_version": "1.16.9.110",
|
||||
"inplace_memory": "0x8E000",
|
||||
"reuse_baseline_package": false,
|
||||
"source_node": 13,
|
||||
"target_node": 16
|
||||
},
|
||||
{
|
||||
"expected_container_size": 70679,
|
||||
"expected_staging_margin": 61440,
|
||||
"expected_target_sha256": "644a8c562080838acac32f47871774f04820511e5595c64d674210aebed6b419",
|
||||
"expected_target_version": "1.16.9.111",
|
||||
"inplace_memory": "0x8D000",
|
||||
"reuse_baseline_package": false,
|
||||
"source_node": 16,
|
||||
"target_node": 17
|
||||
},
|
||||
{
|
||||
"expected_container_size": 143441,
|
||||
"expected_staging_margin": 4096,
|
||||
"expected_target_sha256": "b81d219393897fb1453594d9ea6983b2695c1b2396b768836af7da58b8576e83",
|
||||
"expected_target_version": "1.16.9.116",
|
||||
"inplace_memory": "0x89000",
|
||||
"reuse_baseline_package": false,
|
||||
"source_node": 17,
|
||||
"target_node": 22
|
||||
},
|
||||
{
|
||||
"expected_container_size": 98188,
|
||||
"expected_staging_margin": 86016,
|
||||
"expected_target_sha256": "05ac521daf941b14426360e7ff81b0329f4788b84fe7db9d55f7da58ee336597",
|
||||
"expected_target_version": "1.16.9.118",
|
||||
"inplace_memory": "0x81000",
|
||||
"reuse_baseline_package": false,
|
||||
"source_node": 22,
|
||||
"target_node": 24
|
||||
},
|
||||
{
|
||||
"expected_container_size": 186385,
|
||||
"expected_staging_margin": 12288,
|
||||
"expected_target_sha256": "2784e4b645bc3dc198de0b8b18d3d7369cd02eca61cd71c46a51b61854da5345",
|
||||
"expected_target_version": "1.17.1.02",
|
||||
"inplace_memory": "0x7D000",
|
||||
"reuse_baseline_package": false,
|
||||
"source_node": 24,
|
||||
"target_node": 30
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
diff --git a/src/helpers/ota/OtaFlashLayout_nrf52.h b/src/helpers/ota/OtaFlashLayout_nrf52.h
|
||||
--- a/src/helpers/ota/OtaFlashLayout_nrf52.h
|
||||
+++ b/src/helpers/ota/OtaFlashLayout_nrf52.h
|
||||
@@ -63,4 +63,3 @@
|
||||
inline uint32_t mota_nrf52_stage_capacity(uint32_t app_base) {
|
||||
- return mota_nrf52_layout_valid(app_base)
|
||||
- ? MOTA_NRF52_FS_START - (app_base + MOTA_NRF52_INPLACE_MEMORY) : 0;
|
||||
+ return mota_nrf52_layout_valid(app_base) ? MOTA_NRF52_FS_START - app_base : 0;
|
||||
}
|
||||
@@ -70,18 +69,17 @@ inline uint32_t mota_nrf52_stage_capacity(uint32_t app_base) {
|
||||
-// ENTIRELY above both the running image and the in-place decoder workspace, and below the filesystem
|
||||
-// region (ExtraFS/InternalFS - where user prefs live, assumed immutable). Reserving the full workspace
|
||||
-// here prevents accepting a large container that the bootloader would later reject as overlapping it.
|
||||
+// ENTIRELY above the running image and below ExtraFS/InternalFS, where user preferences live.
|
||||
+// The exact package carries its own page-aligned detools memory size; the deployed OTAFIX bootloader
|
||||
+// independently bounds that workspace below the package, so the receiver can use the real EndF extent.
|
||||
// Returns false (and leaves out_start untouched) if it does not fit. This is the SINGLE place the FS
|
||||
// ceiling + app-collision bounds are enforced; begin()/reopen() both go through it. Pure - no flash I/O -
|
||||
// so it is unit-tested natively in test/test_ota/test_ota_flashplan.cpp.
|
||||
inline bool mota_nrf52_stage_plan(uint32_t total_size, uint32_t app_base, uint32_t app_end,
|
||||
uint32_t& out_start) {
|
||||
if (!mota_nrf52_layout_valid(app_base) || app_end < app_base ||
|
||||
- app_end > app_base + MOTA_NRF52_INPLACE_MEMORY) return false;
|
||||
- const uint32_t workspace_end = app_base + MOTA_NRF52_INPLACE_MEMORY;
|
||||
- const uint32_t capacity = MOTA_NRF52_FS_START - workspace_end;
|
||||
+ app_end > MOTA_NRF52_FS_START) return false;
|
||||
+ const uint32_t capacity = MOTA_NRF52_FS_START - app_base;
|
||||
if (total_size < 13 || total_size > capacity) return false; // 13 = header(8)+trailer(5); must fit below FS
|
||||
uint32_t start = (MOTA_NRF52_FS_START - total_size) & ~(MOTA_NRF52_FLASH_PAGE - 1); // bottom-align down
|
||||
- if (start < app_end || start < workspace_end) return false; // would overlap app / decoder workspace
|
||||
+ if (start < app_end) return false; // would overlap the running application
|
||||
out_start = start;
|
||||
return true;
|
||||
}
|
||||
@@ -10,6 +10,7 @@ from datetime import datetime, timezone
|
||||
import getpass
|
||||
import hashlib
|
||||
import json
|
||||
import math
|
||||
import os
|
||||
from pathlib import Path, PurePosixPath
|
||||
import re
|
||||
@@ -28,21 +29,34 @@ except ImportError:
|
||||
import lora_ota as ota
|
||||
|
||||
|
||||
RELEASE_TAG = "rak3401-mota-v1.16.07-c1caa5ad-to-v1.17.01-c96bdd6e"
|
||||
RELEASE_TAG = "rak3401-mota-v1.16.07-c1caa5ad-to-v1.17.1.02-e742333a"
|
||||
RELEASE_URL = f"https://github.com/mikecarper/MeshCore/releases/tag/{RELEASE_TAG}"
|
||||
ASSET_NAME = "RAK3401-update-chain-v1.16.7-c1caa5ad-to-v1.17.01-8c5262c6.zip"
|
||||
ASSET_NAME = "RAK3401-update-chain-v1.16.7-c1caa5ad-to-v1.17.1.02-e742333a.zip"
|
||||
ASSET_URL = (
|
||||
f"https://github.com/mikecarper/MeshCore/releases/download/{RELEASE_TAG}/"
|
||||
f"{ASSET_NAME}"
|
||||
)
|
||||
ASSET_SHA256 = "b2781e02460b200a7c37bfae352bad81618716e550d1d042dca8aa29bfc73c29"
|
||||
CHECKSUM_LIST_SHA256 = "1f7add658ae5771451cd66a0e5c58a5e461983c1b36029ef480093ae5d5f1020"
|
||||
BUNDLE_ROOT_NAME = "RAK3401-update-chain-v1.16.7-c1caa5ad-to-v1.17.01-8c5262c6"
|
||||
ASSET_SHA256 = "9f80eef191b88833bf4d2e4fea559cf5233ca53f9266ba310d447f37fa445f3a"
|
||||
CHECKSUM_LIST_SHA256 = "73d96e23237896a3e342fe736be12d94087a813bf09ad609fb55330bbe586055"
|
||||
BUNDLE_ROOT_NAME = "RAK3401-update-chain-v1.16.7-c1caa5ad-to-v1.17.1.02-e742333a"
|
||||
|
||||
# The accelerated 30-step release is the pinned reconstruction input for the
|
||||
# compact chain. Keep it available for offline provenance, but do not start a
|
||||
# new live run with it now that the legacy-ceiling route is nine packages.
|
||||
SUPERSEDED_30_ASSET_SHA256 = (
|
||||
"b2781e02460b200a7c37bfae352bad81618716e550d1d042dca8aa29bfc73c29"
|
||||
)
|
||||
SUPERSEDED_30_CHECKSUM_LIST_SHA256 = (
|
||||
"1f7add658ae5771451cd66a0e5c58a5e461983c1b36029ef480093ae5d5f1020"
|
||||
)
|
||||
SUPERSEDED_30_ROOT_NAME = (
|
||||
"RAK3401-update-chain-v1.16.7-c1caa5ad-to-v1.17.01-8c5262c6"
|
||||
)
|
||||
|
||||
# This exact 29-step release completed its full direct physical-board test. It
|
||||
# is now superseded because its historical targets echo terminal OTA bulk
|
||||
# packets back into mesh dispatch. Keep it available for offline provenance,
|
||||
# but require the rebuilt 30-step chain for any new live run.
|
||||
# but require the compact 9-step chain for any new live run.
|
||||
SUPERSEDED_29_ASSET_SHA256 = (
|
||||
"eac67a0be12690b7e22c4d1f6a15bfdeb5bd627c4850b246b1be4220e5607b34"
|
||||
)
|
||||
@@ -107,12 +121,14 @@ DEFAULT_TARGET_KEY = (
|
||||
)
|
||||
EXPECTED_TARGET_ID = 0x2FA509C1
|
||||
EXPECTED_HARDWARE = "RAK_3401"
|
||||
EXPECTED_STEP_COUNT = 30
|
||||
EXPECTED_STEP_COUNT = 9
|
||||
EXPECTED_START_VERSION = "1.16.7.0"
|
||||
EXPECTED_FINAL_VERSION = "1.17.1.0"
|
||||
EXPECTED_FINAL_VERSION = "1.17.1.02"
|
||||
SUPERSEDED_FINAL_VERSION = "1.17.1.0"
|
||||
MIN_MESHCLI_VERSION = (1, 6, 0)
|
||||
WATCHDOG_RESET_WAIT_SECONDS = 90
|
||||
WATCHDOG_STABILITY_WAIT_SECONDS = 90
|
||||
TRANSFER_SETTINGS_FILE = "target-transfer-settings.json"
|
||||
|
||||
# Physical RAK3401 testing proved two retired packages unsafe. The original
|
||||
# v1.17.02 chain failed at step 6. Its software-SHA replacement then passed
|
||||
@@ -120,9 +136,9 @@ WATCHDOG_STABILITY_WAIT_SECONDS = 90
|
||||
# that retained 386ae4a5 bridge still used unchecked CC310 SHA. Keep both
|
||||
# bundles useful for offline diagnosis, but fail closed before a live
|
||||
# connection. A prior exact 29-step replacement passed its complete direct
|
||||
# physical-board run on 14-Aug-2026. The pinned 30-step release is rebuilt and
|
||||
# exhaustively verified offline, but still requires an explicit candidate
|
||||
# acknowledgement until that exact byte sequence completes a physical run.
|
||||
# physical-board run on 14-Aug-2026. The compact 9-step release is exhaustively
|
||||
# verified offline against the deployed bootloader and completed its exact
|
||||
# nine-package physical RAK3401 run on 19-Aug-2026.
|
||||
KNOWN_UNSAFE_STEP = 6
|
||||
KNOWN_UNSAFE_VERSION = "1.16.8.7"
|
||||
KNOWN_UNSAFE_IMAGE_SHA256 = (
|
||||
@@ -171,6 +187,21 @@ SUPERSEDED_29_ANCHORS = (
|
||||
(29, "5c8d94bb23e87c23b0374ffb5a46e0c1205d6eebcb9d8bae3be6fda2613f9f79"),
|
||||
)
|
||||
|
||||
# Exact anchors for the compact nine-package release. The first target and
|
||||
# package are byte-identical to the physically passed baseline; every later
|
||||
# target is pinned here in addition to the outer and inner bundle checksums.
|
||||
COMPACT_RELEASE_ANCHORS = (
|
||||
(1, "8364257a2b3a219905e870fad6fbb2040a96ca4b4bb7201b2867534cc2b45530"),
|
||||
(2, "7031e4d7f883c5d6ba6637eab730d143bd7d0f3c755f09a6c52190f762d23e45"),
|
||||
(3, "2a434bc74d4c5c282cf5709dff18c5cceb1d7d69321a2692944df0d8c14d43d1"),
|
||||
(4, "0a514424c121febb90c7951859f0f5ccf30ffc5392c3bc3185bc7141bde651ff"),
|
||||
(5, "871039ef453faf98b694b14c666773c8c6d5ae159fc7a4573f57feb66bdb60be"),
|
||||
(6, "644a8c562080838acac32f47871774f04820511e5595c64d674210aebed6b419"),
|
||||
(7, "b81d219393897fb1453594d9ea6983b2695c1b2396b768836af7da58b8576e83"),
|
||||
(8, "05ac521daf941b14426360e7ff81b0329f4788b84fe7db9d55f7da58ee336597"),
|
||||
(9, "2784e4b645bc3dc198de0b8b18d3d7369cd02eca61cd71c46a51b61854da5345"),
|
||||
)
|
||||
|
||||
# Exact anchors for every image in the accelerated 30-step release. The outer
|
||||
# asset and inner checksum-list pins cover every byte as well; the complete
|
||||
# anchor table keeps structural validation fail-closed if those layers are
|
||||
@@ -234,22 +265,20 @@ 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 pinned 30-step release."
|
||||
"bundle and use the pinned compact release."
|
||||
)
|
||||
SUPERSEDED_29_MESSAGE = (
|
||||
"live installation of the superseded 29-step release is disabled: that "
|
||||
"exact chain passed its direct physical run, but its historical targets "
|
||||
"do not consume terminal OTA bulk packets before mesh dispatch. Use "
|
||||
"--verify-only for provenance and deploy the accelerated 30-step release."
|
||||
"--verify-only for provenance and deploy the compact release."
|
||||
)
|
||||
CANDIDATE_ACK_MESSAGE = (
|
||||
"live installation of the accelerated 30-step release requires "
|
||||
"--accept-test-candidate: all 30 transitions passed independent offline "
|
||||
"reconstruction and bootloader simulation, but this exact byte sequence "
|
||||
"has not yet completed a full physical-chain run"
|
||||
SUPERSEDED_30_MESSAGE = (
|
||||
"live installation of the superseded 30-step release is disabled: its "
|
||||
"images remain the pinned reconstruction input for the compact chain, "
|
||||
"which reaches the requested endpoint in nine packages without changing "
|
||||
"the deployed bootloader. Use --verify-only for the older bundle."
|
||||
)
|
||||
|
||||
|
||||
class KnownUnsafeReleaseError(ota.OtaError):
|
||||
"""The pinned artifacts are intact but their live transition is unsafe."""
|
||||
|
||||
@@ -259,16 +288,25 @@ def require_live_release_safe(
|
||||
steps: list[ChainStep],
|
||||
) -> None:
|
||||
if len(steps) == EXPECTED_STEP_COUNT:
|
||||
for number, expected_sha256 in PINNED_RELEASE_ANCHORS:
|
||||
for number, expected_sha256 in COMPACT_RELEASE_ANCHORS:
|
||||
if steps[number - 1].target_sha256 != expected_sha256:
|
||||
raise KnownUnsafeReleaseError(
|
||||
"live installation is disabled: the 30-step release has "
|
||||
"live installation is disabled: the compact release has "
|
||||
f"an unrecognized step-{number} image"
|
||||
)
|
||||
if not getattr(args, "accept_test_candidate", False):
|
||||
raise KnownUnsafeReleaseError(CANDIDATE_ACK_MESSAGE)
|
||||
return
|
||||
|
||||
if len(steps) == 30:
|
||||
if all(
|
||||
steps[number - 1].target_sha256 == expected_sha256
|
||||
for number, expected_sha256 in PINNED_RELEASE_ANCHORS
|
||||
):
|
||||
raise KnownUnsafeReleaseError(SUPERSEDED_30_MESSAGE)
|
||||
raise KnownUnsafeReleaseError(
|
||||
"live installation is disabled: this is an unrecognized variant "
|
||||
"of the superseded 30-step release"
|
||||
)
|
||||
|
||||
if len(steps) == 29:
|
||||
if all(
|
||||
steps[number - 1].target_sha256 == expected_sha256
|
||||
@@ -339,6 +377,15 @@ class ChainStep:
|
||||
package: ota.MotaInfo
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TargetTransferSettings:
|
||||
rxps_enabled: bool
|
||||
rxps_rx_us: int
|
||||
rxps_sleep_us: int
|
||||
powersaving_enabled: bool
|
||||
rxdelay: str
|
||||
|
||||
|
||||
def sha256_file(path: Path) -> str:
|
||||
digest = hashlib.sha256()
|
||||
with path.open("rb") as source:
|
||||
@@ -402,6 +449,7 @@ def download_release_asset(destination: Path) -> None:
|
||||
def extract_bundle(archive_path: Path, destination: Path) -> Path:
|
||||
root_names = (
|
||||
BUNDLE_ROOT_NAME,
|
||||
SUPERSEDED_30_ROOT_NAME,
|
||||
SUPERSEDED_29_ROOT_NAME,
|
||||
KNOWN_FAILED_V11701_ROOT_NAME,
|
||||
KNOWN_UNSAFE_ROOT_NAME,
|
||||
@@ -455,6 +503,7 @@ def locate_bundle(args: argparse.Namespace, work_dir: Path) -> Path:
|
||||
nested_roots = [
|
||||
supplied / name for name in (
|
||||
BUNDLE_ROOT_NAME,
|
||||
SUPERSEDED_30_ROOT_NAME,
|
||||
SUPERSEDED_29_ROOT_NAME,
|
||||
KNOWN_FAILED_V11701_ROOT_NAME,
|
||||
KNOWN_UNSAFE_ROOT_NAME,
|
||||
@@ -471,6 +520,7 @@ def locate_bundle(args: argparse.Namespace, work_dir: Path) -> Path:
|
||||
actual = sha256_file(supplied)
|
||||
if actual not in {
|
||||
ASSET_SHA256,
|
||||
SUPERSEDED_30_ASSET_SHA256,
|
||||
SUPERSEDED_29_ASSET_SHA256,
|
||||
SUPERSEDED_27_ASSET_SHA256,
|
||||
KNOWN_FAILED_V11701_ASSET_SHA256,
|
||||
@@ -490,6 +540,7 @@ def verify_checksum_list(bundle_root: Path) -> None:
|
||||
checksum_digest = sha256_file(checksum_path)
|
||||
expected_lists = {
|
||||
CHECKSUM_LIST_SHA256,
|
||||
SUPERSEDED_30_CHECKSUM_LIST_SHA256,
|
||||
SUPERSEDED_29_CHECKSUM_LIST_SHA256,
|
||||
SUPERSEDED_27_CHECKSUM_LIST_SHA256,
|
||||
KNOWN_FAILED_V11701_CHECKSUM_LIST_SHA256,
|
||||
@@ -547,10 +598,10 @@ def parse_chain(bundle_root: Path) -> tuple[list[ChainStep], bytes]:
|
||||
rows = list(csv.DictReader(source))
|
||||
except OSError as exc:
|
||||
raise ota.OtaError(f"cannot read {chain_path}: {exc}") from exc
|
||||
if len(rows) not in (26, 27, 29, EXPECTED_STEP_COUNT):
|
||||
if len(rows) not in (EXPECTED_STEP_COUNT, 26, 27, 29, 30):
|
||||
raise ota.OtaError(
|
||||
f"chain contains {len(rows)} steps, expected 26, 27, 29, or "
|
||||
f"{EXPECTED_STEP_COUNT}"
|
||||
f"chain contains {len(rows)} steps, expected {EXPECTED_STEP_COUNT}, "
|
||||
"26, 27, 29, or 30"
|
||||
)
|
||||
|
||||
steps: list[ChainStep] = []
|
||||
@@ -601,19 +652,26 @@ 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 COMPACT_RELEASE_ANCHORS:
|
||||
if steps[number - 1].target_sha256 != expected_sha256:
|
||||
raise ota.OtaError(
|
||||
f"compact release step {number} does not match its audited image pin"
|
||||
)
|
||||
expected_final_version = EXPECTED_FINAL_VERSION
|
||||
elif len(steps) == 30:
|
||||
for number, expected_sha256 in PINNED_RELEASE_ANCHORS:
|
||||
if steps[number - 1].target_sha256 != expected_sha256:
|
||||
raise ota.OtaError(
|
||||
f"pinned release step {number} does not match its audited image pin"
|
||||
f"superseded 30-step release step {number} does not match its image pin"
|
||||
)
|
||||
expected_final_version = EXPECTED_FINAL_VERSION
|
||||
expected_final_version = SUPERSEDED_FINAL_VERSION
|
||||
elif len(steps) == 29:
|
||||
for number, expected_sha256 in SUPERSEDED_29_ANCHORS:
|
||||
if steps[number - 1].target_sha256 != expected_sha256:
|
||||
raise ota.OtaError(
|
||||
f"superseded step {number} does not match its audited image pin"
|
||||
)
|
||||
expected_final_version = EXPECTED_FINAL_VERSION
|
||||
expected_final_version = SUPERSEDED_FINAL_VERSION
|
||||
else:
|
||||
step6 = steps[KNOWN_UNSAFE_STEP - 1]
|
||||
recognized_step6_hashes = {
|
||||
@@ -650,7 +708,7 @@ def parse_chain(bundle_root: Path) -> tuple[list[ChainStep], bytes]:
|
||||
expected_final_version = (
|
||||
"1.17.2.0"
|
||||
if step6.target_sha256 == KNOWN_UNSAFE_IMAGE_SHA256
|
||||
else EXPECTED_FINAL_VERSION
|
||||
else SUPERSEDED_FINAL_VERSION
|
||||
)
|
||||
if steps[-1].to_version != expected_final_version:
|
||||
raise ota.OtaError(
|
||||
@@ -837,6 +895,177 @@ def require_watchdog_state(
|
||||
return reply
|
||||
|
||||
|
||||
def read_target_transfer_settings(
|
||||
controller: ota.Controller,
|
||||
target_name: str,
|
||||
) -> TargetTransferSettings:
|
||||
rxps_reply = controller.remote_command(target_name, "get radio.rxps")
|
||||
rxps_match = re.fullmatch(
|
||||
r"\s*>\s*(on|off),(\d+),(\d+)\s*",
|
||||
rxps_reply,
|
||||
re.IGNORECASE,
|
||||
)
|
||||
if rxps_match is None:
|
||||
raise ota.OtaError(f"could not read destination RXPS state: {rxps_reply}")
|
||||
|
||||
powersaving_reply = controller.remote_command(target_name, "powersaving")
|
||||
powersaving_match = re.fullmatch(
|
||||
r"\s*>?\s*(on|off)\s*",
|
||||
powersaving_reply,
|
||||
re.IGNORECASE,
|
||||
)
|
||||
if powersaving_match is None:
|
||||
raise ota.OtaError(
|
||||
f"could not read destination CPU power-saving state: {powersaving_reply}"
|
||||
)
|
||||
|
||||
rxdelay_reply = controller.remote_command(target_name, "get rxdelay")
|
||||
rxdelay_match = re.fullmatch(
|
||||
r"\s*>\s*([0-9]+(?:\.[0-9]+)?)\s*",
|
||||
rxdelay_reply,
|
||||
)
|
||||
if rxdelay_match is None:
|
||||
raise ota.OtaError(f"could not read destination RX delay: {rxdelay_reply}")
|
||||
|
||||
return TargetTransferSettings(
|
||||
rxps_enabled=rxps_match.group(1).lower() == "on",
|
||||
rxps_rx_us=int(rxps_match.group(2)),
|
||||
rxps_sleep_us=int(rxps_match.group(3)),
|
||||
powersaving_enabled=powersaving_match.group(1).lower() == "on",
|
||||
rxdelay=rxdelay_match.group(1),
|
||||
)
|
||||
|
||||
|
||||
def load_or_capture_transfer_settings(
|
||||
controller: ota.Controller,
|
||||
target_name: str,
|
||||
target_key: str,
|
||||
work_dir: Path,
|
||||
) -> TargetTransferSettings:
|
||||
path = work_dir / TRANSFER_SETTINGS_FILE
|
||||
if path.exists():
|
||||
try:
|
||||
saved = json.loads(path.read_text(encoding="ascii"))
|
||||
if saved["target_key"].lower() != target_key.lower():
|
||||
raise ota.OtaError(
|
||||
f"{path} belongs to a different destination public key"
|
||||
)
|
||||
if not isinstance(saved["rxps_enabled"], bool) or not isinstance(
|
||||
saved["powersaving_enabled"], bool
|
||||
):
|
||||
raise TypeError("saved power states must be JSON booleans")
|
||||
settings = TargetTransferSettings(
|
||||
rxps_enabled=saved["rxps_enabled"],
|
||||
rxps_rx_us=int(saved["rxps_rx_us"]),
|
||||
rxps_sleep_us=int(saved["rxps_sleep_us"]),
|
||||
powersaving_enabled=saved["powersaving_enabled"],
|
||||
rxdelay=str(saved["rxdelay"]),
|
||||
)
|
||||
float(settings.rxdelay)
|
||||
except (KeyError, TypeError, ValueError, json.JSONDecodeError) as exc:
|
||||
raise ota.OtaError(f"invalid saved transfer settings in {path}") from exc
|
||||
print(f"[guardrail] loaded original destination settings from {path}")
|
||||
return settings
|
||||
|
||||
settings = read_target_transfer_settings(controller, target_name)
|
||||
payload = {
|
||||
"target_key": target_key.lower(),
|
||||
"rxps_enabled": settings.rxps_enabled,
|
||||
"rxps_rx_us": settings.rxps_rx_us,
|
||||
"rxps_sleep_us": settings.rxps_sleep_us,
|
||||
"powersaving_enabled": settings.powersaving_enabled,
|
||||
"rxdelay": settings.rxdelay,
|
||||
}
|
||||
temporary = path.with_suffix(path.suffix + ".tmp")
|
||||
temporary.write_text(json.dumps(payload, indent=2, sort_keys=True) + "\n", encoding="ascii")
|
||||
temporary.chmod(0o600)
|
||||
os.replace(temporary, path)
|
||||
print(f"[guardrail] saved original destination settings to {path}")
|
||||
return settings
|
||||
|
||||
|
||||
def enforce_transfer_guardrails(
|
||||
controller: ota.Controller,
|
||||
target_name: str,
|
||||
) -> None:
|
||||
current = read_target_transfer_settings(controller, target_name)
|
||||
if current.powersaving_enabled:
|
||||
reply = controller.remote_command(target_name, "powersaving off")
|
||||
if re.search(r"\boff\b", reply, re.IGNORECASE) is None:
|
||||
raise ota.OtaError(f"target did not disable CPU power saving: {reply}")
|
||||
if current.rxps_enabled:
|
||||
reply = controller.remote_command(target_name, "set radio.rxps off")
|
||||
if re.search(r"\boff\b", reply, re.IGNORECASE) is None:
|
||||
raise ota.OtaError(f"target did not disable RXPS: {reply}")
|
||||
if abs(float(current.rxdelay)) > 0.0001:
|
||||
reply = controller.remote_command(target_name, "set rxdelay 0")
|
||||
if not reply.upper().startswith("OK"):
|
||||
raise ota.OtaError(f"target did not disable RX flood delay: {reply}")
|
||||
|
||||
verified = read_target_transfer_settings(controller, target_name)
|
||||
if (
|
||||
verified.powersaving_enabled
|
||||
or verified.rxps_enabled
|
||||
or abs(float(verified.rxdelay)) > 0.0001
|
||||
):
|
||||
raise ota.OtaError(
|
||||
"destination transfer guardrails did not read back as "
|
||||
"powersaving=off, RXPS=off, rxdelay=0"
|
||||
)
|
||||
print("[guardrail] destination verified: RXPS off, rxdelay 0, CPU power saving off")
|
||||
|
||||
|
||||
def restore_transfer_settings(
|
||||
controller: ota.Controller,
|
||||
target_name: str,
|
||||
saved: TargetTransferSettings,
|
||||
) -> None:
|
||||
current = read_target_transfer_settings(controller, target_name)
|
||||
if abs(float(current.rxdelay) - float(saved.rxdelay)) > 0.0001:
|
||||
reply = controller.remote_command(target_name, f"set rxdelay {saved.rxdelay}")
|
||||
if not reply.upper().startswith("OK"):
|
||||
raise ota.OtaError(f"target did not restore RX flood delay: {reply}")
|
||||
|
||||
if saved.rxps_enabled:
|
||||
if (
|
||||
not current.rxps_enabled
|
||||
or current.rxps_rx_us != saved.rxps_rx_us
|
||||
or current.rxps_sleep_us != saved.rxps_sleep_us
|
||||
):
|
||||
reply = controller.remote_command(
|
||||
target_name,
|
||||
f"set radio.rxps {saved.rxps_rx_us} {saved.rxps_sleep_us}",
|
||||
)
|
||||
if re.search(r"\bon\b", reply, re.IGNORECASE) is None:
|
||||
raise ota.OtaError(f"target did not restore RXPS: {reply}")
|
||||
elif current.rxps_enabled:
|
||||
reply = controller.remote_command(target_name, "set radio.rxps off")
|
||||
if re.search(r"\boff\b", reply, re.IGNORECASE) is None:
|
||||
raise ota.OtaError(f"target did not restore RXPS-off state: {reply}")
|
||||
|
||||
verified = read_target_transfer_settings(controller, target_name)
|
||||
if (
|
||||
verified.rxps_enabled != saved.rxps_enabled
|
||||
or abs(float(verified.rxdelay) - float(saved.rxdelay)) > 0.0001
|
||||
or (
|
||||
saved.rxps_enabled
|
||||
and (
|
||||
verified.rxps_rx_us != saved.rxps_rx_us
|
||||
or verified.rxps_sleep_us != saved.rxps_sleep_us
|
||||
)
|
||||
)
|
||||
):
|
||||
raise ota.OtaError("destination radio transfer settings did not restore exactly")
|
||||
|
||||
if saved.powersaving_enabled:
|
||||
reply = controller.remote_command(target_name, "powersaving on")
|
||||
if re.search(r"\bon\b", reply, re.IGNORECASE) is None:
|
||||
raise ota.OtaError(f"target did not restore CPU power saving: {reply}")
|
||||
elif verified.powersaving_enabled:
|
||||
raise ota.OtaError("destination CPU power saving unexpectedly remained enabled")
|
||||
print("[guardrail] original destination transfer settings restored")
|
||||
|
||||
|
||||
def read_ota_hops(controller: ota.Controller, target_name: str) -> int:
|
||||
reply = controller.remote_command(target_name, "ota config")
|
||||
match = re.search(r"\bhops=(\d+)\b", reply)
|
||||
@@ -1021,6 +1250,7 @@ def run_step(
|
||||
*connection_arguments(args),
|
||||
"--controller-baud", str(args.controller_baud),
|
||||
"--source-baud", str(args.source_baud),
|
||||
"--relay-txdelay", str(args.relay_txdelay),
|
||||
"--temp-radio", args.temp_radio,
|
||||
"--meshcli", args.meshcli,
|
||||
"--motatool", args.motatool,
|
||||
@@ -1055,8 +1285,8 @@ def run_step(
|
||||
def build_parser() -> argparse.ArgumentParser:
|
||||
parser = argparse.ArgumentParser(
|
||||
description=(
|
||||
"Download, verify, resume, and install the exact accelerated "
|
||||
"30-step RAK3401 mOTA chain from c1caa5ad to 8c5262c6."
|
||||
"Download, verify, resume, and install the exact compact 9-step "
|
||||
"RAK3401 mOTA chain from c1caa5ad to e742333a."
|
||||
)
|
||||
)
|
||||
parser.add_argument(
|
||||
@@ -1074,11 +1304,7 @@ def build_parser() -> argparse.ArgumentParser:
|
||||
parser.add_argument(
|
||||
"--accept-test-candidate",
|
||||
action="store_true",
|
||||
help=(
|
||||
"acknowledge that the exact accelerated 30-step byte sequence is "
|
||||
"offline-validated but awaits its complete physical-chain run; this "
|
||||
"never overrides a failed or superseded bundle"
|
||||
),
|
||||
help=argparse.SUPPRESS,
|
||||
)
|
||||
|
||||
controller = parser.add_mutually_exclusive_group()
|
||||
@@ -1100,6 +1326,12 @@ def build_parser() -> argparse.ArgumentParser:
|
||||
metavar="NAME[=PASSWORD]",
|
||||
help="intermediate relay, farthest-to-nearest; repeat for each relay",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--relay-txdelay",
|
||||
type=float,
|
||||
default=ota.DEFAULT_RELAY_TX_DELAY,
|
||||
help="temporary flood txdelay for managed intermediate relays",
|
||||
)
|
||||
parser.add_argument("--temp-radio", default="909.950,250,5,5,120")
|
||||
parser.add_argument(
|
||||
"--ota-hops",
|
||||
@@ -1179,6 +1411,8 @@ def validate_args(args: argparse.Namespace, parser: argparse.ArgumentParser) ->
|
||||
):
|
||||
if getattr(args, name) <= 0:
|
||||
parser.error(f"--{name.replace('_', '-')} must be positive")
|
||||
if not math.isfinite(args.relay_txdelay) or not 0.0 <= args.relay_txdelay <= 2.0:
|
||||
parser.error("--relay-txdelay must be between 0 and 2")
|
||||
if not 0 <= args.ota_hops <= 8:
|
||||
parser.error("--ota-hops must be from 0 through 8")
|
||||
try:
|
||||
@@ -1195,6 +1429,7 @@ def validate_args(args: argparse.Namespace, parser: argparse.ArgumentParser) ->
|
||||
+ args.transfer_timeout_minutes * 60
|
||||
+ ota.adaptive_poll_ceiling(args.poll_seconds)
|
||||
+ args.reply_timeout * (4 + len(args.relay))
|
||||
+ len(args.relay) * ota.RELAY_TIMING_COMMANDS_PER_RELAY * args.reply_timeout
|
||||
)
|
||||
if not args.verify_only and temp_values[4] * 60 <= required_seconds:
|
||||
parser.error(
|
||||
@@ -1229,7 +1464,13 @@ def confirm_chain(
|
||||
)
|
||||
print(f" TempRadio : {args.temp_radio}")
|
||||
print(f" relays : {len(args.relay)}")
|
||||
if args.relay:
|
||||
print(
|
||||
f" relay timing: rxdelay 0, txdelay "
|
||||
f"{ota.format_decimal(args.relay_txdelay)} (saved/restored)"
|
||||
)
|
||||
print(f" OTA reach : enforce {args.ota_hops} hops before every step")
|
||||
print(" guardrails : save, disable RXPS/rxdelay/CPU sleep, restore at endpoint")
|
||||
print(" watchdog : disable, prove stable, gate every install, then re-enable")
|
||||
if args.preflight_only:
|
||||
print("Live preflight passed; no radio or watchdog settings were changed.")
|
||||
@@ -1257,7 +1498,16 @@ def main(argv: list[str] | None = None) -> int:
|
||||
verify_motatool(args, steps)
|
||||
if args.verify_only:
|
||||
print(f"Verified release bundle: {bundle_root}")
|
||||
if len(steps) == 29:
|
||||
if len(steps) == EXPECTED_STEP_COUNT:
|
||||
print(
|
||||
"Compact release verified: all 9 transitions passed its exact "
|
||||
"physical RAK3401 run, zero-filled and erased-workspace "
|
||||
"reconstruction, independent motatool verification, and "
|
||||
"deployed/current bootloader simulation."
|
||||
)
|
||||
elif len(steps) == 30:
|
||||
print(f"WARNING: {SUPERSEDED_30_MESSAGE}", file=sys.stderr)
|
||||
elif len(steps) == 29:
|
||||
print(f"WARNING: {SUPERSEDED_29_MESSAGE}", file=sys.stderr)
|
||||
elif steps[KNOWN_UNSAFE_STEP - 1].target_sha256 == KNOWN_UNSAFE_IMAGE_SHA256:
|
||||
print(f"WARNING: {KNOWN_UNSAFE_RELEASE_MESSAGE}", file=sys.stderr)
|
||||
@@ -1279,12 +1529,6 @@ def main(argv: list[str] | None = None) -> int:
|
||||
and steps[-1].target_sha256 == SUPERSEDED_27_FINAL_IMAGE_SHA256
|
||||
):
|
||||
print(f"WARNING: {SUPERSEDED_27_MESSAGE}", file=sys.stderr)
|
||||
else:
|
||||
print(
|
||||
"Accelerated release verified offline: all 30 transitions "
|
||||
"passed zero-filled and erased-workspace reconstruction, "
|
||||
"independent motatool verification, and bootloader simulation."
|
||||
)
|
||||
return 0
|
||||
|
||||
# This must precede meshcli, password handling, source preflight, and
|
||||
@@ -1314,6 +1558,14 @@ def main(argv: list[str] | None = None) -> int:
|
||||
|
||||
if first_index == len(steps):
|
||||
enforce_ota_hops(controller, target_name, args.ota_hops)
|
||||
transfer_path = work_dir / TRANSFER_SETTINGS_FILE
|
||||
if transfer_path.exists():
|
||||
transfer_settings = load_or_capture_transfer_settings(
|
||||
controller, target_name, full_key, work_dir
|
||||
)
|
||||
restore_transfer_settings(
|
||||
controller, target_name, transfer_settings
|
||||
)
|
||||
if not args.keep_watchdog_off:
|
||||
enabled = controller.remote_command(target_name, "set system.watchdog on")
|
||||
if not enabled.lower().startswith("ok - system watchdog enabled"):
|
||||
@@ -1322,7 +1574,11 @@ def main(argv: list[str] | None = None) -> int:
|
||||
print("RAK3401 already matches the verified final endpoint.")
|
||||
return 0
|
||||
|
||||
transfer_settings = load_or_capture_transfer_settings(
|
||||
controller, target_name, full_key, work_dir
|
||||
)
|
||||
prepare_watchdog(controller, target_name)
|
||||
enforce_transfer_guardrails(controller, target_name)
|
||||
enforce_ota_hops(controller, target_name, args.ota_hops)
|
||||
for index in range(first_index, len(steps)):
|
||||
step = steps[index]
|
||||
@@ -1339,6 +1595,7 @@ def main(argv: list[str] | None = None) -> int:
|
||||
f"live target is at chain index {current_index}, expected {index}"
|
||||
)
|
||||
require_watchdog_state(controller, target_name, "off")
|
||||
enforce_transfer_guardrails(controller, target_name)
|
||||
enforce_ota_hops(controller, target_name, args.ota_hops)
|
||||
print(
|
||||
f"\n[chain] step {step.number:02d}/{len(steps)}: "
|
||||
@@ -1385,6 +1642,7 @@ def main(argv: list[str] | None = None) -> int:
|
||||
final_target = query_live_target(controller, args, target_name)
|
||||
if find_resume_index(final_target, steps, final_body_hash) != len(steps):
|
||||
raise ota.OtaError("final target identity did not match the release endpoint")
|
||||
restore_transfer_settings(controller, target_name, transfer_settings)
|
||||
if not args.keep_watchdog_off:
|
||||
enabled = controller.remote_command(target_name, "set system.watchdog on")
|
||||
if not enabled.lower().startswith("ok - system watchdog enabled"):
|
||||
@@ -1403,15 +1661,16 @@ def main(argv: list[str] | None = None) -> int:
|
||||
except KeyboardInterrupt:
|
||||
print(
|
||||
"\nInterrupted. Any partial mOTA remains resumable. The target watchdog "
|
||||
"may intentionally remain off until the chain completes.",
|
||||
"and transfer guardrails may intentionally remain off until the chain completes.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return 130
|
||||
except (ota.OtaError, OSError, zipfile.BadZipFile) as exc:
|
||||
print(f"ERROR: {exc}", file=sys.stderr)
|
||||
print(
|
||||
"The target watchdog may intentionally remain off. Rerun the same command "
|
||||
"to resume from the live version; do not skip a chain step.",
|
||||
"The target watchdog and transfer guardrails may intentionally remain off. "
|
||||
"Rerun the same command and work directory to resume and restore the saved "
|
||||
"settings; do not skip a chain step.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return 2
|
||||
|
||||
+209
-14
@@ -7,6 +7,7 @@ import argparse
|
||||
import contextlib
|
||||
import hashlib
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import struct
|
||||
@@ -337,6 +338,24 @@ class SourceCliTests(unittest.TestCase):
|
||||
)
|
||||
self.assertIn("OK - temp params", output)
|
||||
|
||||
def test_serial_companion_seeder_enters_terminal_mode(self) -> None:
|
||||
args = argparse.Namespace(
|
||||
motatool="motatool",
|
||||
source_serial="/dev/source",
|
||||
source_tcp=None,
|
||||
source_baud=115200,
|
||||
source_companion_terminal=True,
|
||||
)
|
||||
seeder = ota.SeederProcess(args, Path("/served"), Path("/work"))
|
||||
self.assertEqual(
|
||||
seeder.command,
|
||||
[
|
||||
"motatool", "serve", "--dir", "/served", "-v",
|
||||
"--serial", "/dev/source", "--baud", "115200",
|
||||
"--companion-terminal",
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
class CompatibilityTests(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
@@ -1434,6 +1453,79 @@ class ReliabilityTests(unittest.TestCase):
|
||||
],
|
||||
)
|
||||
|
||||
def test_managed_relay_timing_is_saved_guarded_and_restored(self) -> None:
|
||||
class Controller:
|
||||
def __init__(self) -> None:
|
||||
self.rxdelay = 2.0
|
||||
self.txdelay = 0.5
|
||||
|
||||
def remote_command(
|
||||
self, _target: str, command: str, **_kwargs: object
|
||||
) -> str:
|
||||
if command == "get rxdelay":
|
||||
return f"> {self.rxdelay}"
|
||||
if command == "get txdelay":
|
||||
return f"> {self.txdelay}"
|
||||
if command.startswith("set rxdelay "):
|
||||
self.rxdelay = float(command.split()[-1])
|
||||
return "OK"
|
||||
if command.startswith("set txdelay "):
|
||||
self.txdelay = float(command.split()[-1])
|
||||
return "OK"
|
||||
raise AssertionError(command)
|
||||
|
||||
controller = Controller()
|
||||
saved = ota.read_relay_timing(controller, "relay", "secret")
|
||||
ota.enforce_relay_timing(controller, saved, 0.3)
|
||||
self.assertEqual(controller.rxdelay, 0.0)
|
||||
self.assertEqual(controller.txdelay, 0.3)
|
||||
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
path = ota.write_relay_timing_recovery(Path(directory), [saved])
|
||||
payload = json.loads(path.read_text(encoding="ascii"))
|
||||
self.assertEqual(path.stat().st_mode & 0o777, 0o600)
|
||||
self.assertEqual(
|
||||
payload,
|
||||
[{"name": "relay", "rxdelay": 2.0, "txdelay": 0.5}],
|
||||
)
|
||||
|
||||
ota.restore_relay_timings(controller, [saved])
|
||||
self.assertEqual(controller.rxdelay, 2.0)
|
||||
self.assertEqual(controller.txdelay, 0.5)
|
||||
|
||||
def test_unreachable_relay_does_not_block_other_relay_restoration(self) -> None:
|
||||
class Controller:
|
||||
def __init__(self) -> None:
|
||||
self.rxdelay = 0.0
|
||||
self.txdelay = 0.3
|
||||
|
||||
def remote_command(
|
||||
self, target: str, command: str, **_kwargs: object
|
||||
) -> str:
|
||||
if target == "offline":
|
||||
raise ota.OtaError("unreachable")
|
||||
if command == "get rxdelay":
|
||||
return f"> {self.rxdelay}"
|
||||
if command == "get txdelay":
|
||||
return f"> {self.txdelay}"
|
||||
if command.startswith("set rxdelay "):
|
||||
self.rxdelay = float(command.split()[-1])
|
||||
return "OK"
|
||||
if command.startswith("set txdelay "):
|
||||
self.txdelay = float(command.split()[-1])
|
||||
return "OK"
|
||||
raise AssertionError(command)
|
||||
|
||||
controller = Controller()
|
||||
settings = [
|
||||
ota.RelayTimingSettings("online", "secret", 2.0, 0.5),
|
||||
ota.RelayTimingSettings("offline", "secret", 1.0, 0.4),
|
||||
]
|
||||
with self.assertRaisesRegex(ota.OtaError, "offline: unreachable"):
|
||||
ota.restore_relay_timings(controller, settings)
|
||||
self.assertEqual(controller.rxdelay, 2.0)
|
||||
self.assertEqual(controller.txdelay, 0.5)
|
||||
|
||||
def test_source_cleanup_only_changes_a_script_owned_window(self) -> None:
|
||||
args = argparse.Namespace(
|
||||
source_already_temp=False,
|
||||
@@ -1472,6 +1564,104 @@ class ReliabilityTests(unittest.TestCase):
|
||||
sleep.assert_called_once_with(ota.TEMP_RADIO_SWITCH_DELAY_SECONDS)
|
||||
|
||||
|
||||
class Rak3401TransferGuardrailTests(unittest.TestCase):
|
||||
class Controller:
|
||||
def __init__(self) -> None:
|
||||
self.rxps_enabled = True
|
||||
self.rxps_rx_us = 65625
|
||||
self.rxps_sleep_us = 60000
|
||||
self.powersaving_enabled = True
|
||||
self.rxdelay = "2.0"
|
||||
self.commands: list[str] = []
|
||||
|
||||
def remote_command(self, _target: str, command: str) -> str:
|
||||
self.commands.append(command)
|
||||
if command == "get radio.rxps":
|
||||
state = "on" if self.rxps_enabled else "off"
|
||||
return f"> {state},{self.rxps_rx_us},{self.rxps_sleep_us}"
|
||||
if command == "set radio.rxps off":
|
||||
self.rxps_enabled = False
|
||||
return f"OK - off,{self.rxps_rx_us},{self.rxps_sleep_us}"
|
||||
if command.startswith("set radio.rxps "):
|
||||
_set, _key, rx_us, sleep_us = command.split()
|
||||
self.rxps_enabled = True
|
||||
self.rxps_rx_us = int(rx_us)
|
||||
self.rxps_sleep_us = int(sleep_us)
|
||||
return f"OK - on,{rx_us},{sleep_us}"
|
||||
if command == "powersaving":
|
||||
return "on" if self.powersaving_enabled else "off"
|
||||
if command == "powersaving off":
|
||||
self.powersaving_enabled = False
|
||||
return "off"
|
||||
if command == "powersaving on":
|
||||
self.powersaving_enabled = True
|
||||
return "on - Immediate effect"
|
||||
if command == "get rxdelay":
|
||||
return f"> {self.rxdelay}"
|
||||
if command.startswith("set rxdelay "):
|
||||
self.rxdelay = command.split(maxsplit=2)[2]
|
||||
return "OK"
|
||||
raise AssertionError(command)
|
||||
|
||||
def test_guardrails_disable_and_restore_all_original_settings(self) -> None:
|
||||
controller = self.Controller()
|
||||
saved = rak_chain.read_target_transfer_settings(controller, "remote")
|
||||
|
||||
rak_chain.enforce_transfer_guardrails(controller, "remote")
|
||||
self.assertFalse(controller.rxps_enabled)
|
||||
self.assertFalse(controller.powersaving_enabled)
|
||||
self.assertEqual(controller.rxdelay, "0")
|
||||
|
||||
rak_chain.restore_transfer_settings(controller, "remote", saved)
|
||||
self.assertTrue(controller.rxps_enabled)
|
||||
self.assertEqual(controller.rxps_rx_us, 65625)
|
||||
self.assertEqual(controller.rxps_sleep_us, 60000)
|
||||
self.assertTrue(controller.powersaving_enabled)
|
||||
self.assertEqual(controller.rxdelay, "2.0")
|
||||
self.assertEqual(controller.commands[-1], "powersaving on")
|
||||
|
||||
def test_original_settings_are_persisted_for_resume(self) -> None:
|
||||
controller = self.Controller()
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
work_dir = Path(directory)
|
||||
saved = rak_chain.load_or_capture_transfer_settings(
|
||||
controller, "remote", "AA" * 32, work_dir
|
||||
)
|
||||
command_count = len(controller.commands)
|
||||
controller.rxdelay = "0"
|
||||
loaded = rak_chain.load_or_capture_transfer_settings(
|
||||
controller, "remote", "AA" * 32, work_dir
|
||||
)
|
||||
mode = (work_dir / rak_chain.TRANSFER_SETTINGS_FILE).stat().st_mode
|
||||
|
||||
self.assertEqual(loaded, saved)
|
||||
self.assertEqual(len(controller.commands), command_count)
|
||||
self.assertEqual(mode & 0o777, 0o600)
|
||||
|
||||
def test_saved_settings_reject_string_booleans(self) -> None:
|
||||
controller = self.Controller()
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
work_dir = Path(directory)
|
||||
path = work_dir / rak_chain.TRANSFER_SETTINGS_FILE
|
||||
path.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"target_key": "aa" * 32,
|
||||
"rxps_enabled": "false",
|
||||
"rxps_rx_us": 65625,
|
||||
"rxps_sleep_us": 60000,
|
||||
"powersaving_enabled": False,
|
||||
"rxdelay": "2.0",
|
||||
}
|
||||
),
|
||||
encoding="ascii",
|
||||
)
|
||||
with self.assertRaisesRegex(ota.OtaError, "invalid saved transfer settings"):
|
||||
rak_chain.load_or_capture_transfer_settings(
|
||||
controller, "remote", "AA" * 32, work_dir
|
||||
)
|
||||
|
||||
|
||||
class Rak3401KnownUnsafeReleaseTests(unittest.TestCase):
|
||||
def test_chain_resume_uses_exact_body_hash_not_runtime_label(self) -> None:
|
||||
steps = [
|
||||
@@ -1618,29 +1808,34 @@ class Rak3401KnownUnsafeReleaseTests(unittest.TestCase):
|
||||
argparse.Namespace(accept_test_candidate=True), steps
|
||||
)
|
||||
|
||||
def test_accelerated_30_step_release_requires_candidate_ack(self) -> None:
|
||||
steps = [mock.Mock(target_sha256="") for _ in range(30)]
|
||||
for number, image_sha256 in rak_chain.PINNED_RELEASE_ANCHORS:
|
||||
def test_physically_passed_compact_9_step_release_needs_no_lab_ack(self) -> None:
|
||||
steps = [mock.Mock(target_sha256="") for _ in range(9)]
|
||||
for number, image_sha256 in rak_chain.COMPACT_RELEASE_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=True), steps
|
||||
argparse.Namespace(accept_test_candidate=False), steps
|
||||
)
|
||||
|
||||
def test_accelerated_30_step_release_rejects_changed_anchor(self) -> None:
|
||||
def test_compact_9_step_release_rejects_changed_anchor(self) -> None:
|
||||
steps = [mock.Mock(target_sha256="") for _ in range(9)]
|
||||
for number, image_sha256 in rak_chain.COMPACT_RELEASE_ANCHORS:
|
||||
steps[number - 1].target_sha256 = image_sha256
|
||||
steps[5].target_sha256 = "00" * 32
|
||||
with self.assertRaisesRegex(
|
||||
rak_chain.KnownUnsafeReleaseError,
|
||||
"unrecognized step-6 image",
|
||||
):
|
||||
rak_chain.require_live_release_safe(
|
||||
argparse.Namespace(accept_test_candidate=True), steps
|
||||
)
|
||||
|
||||
def test_superseded_30_step_release_is_blocked(self) -> None:
|
||||
steps = [mock.Mock(target_sha256="") for _ in range(30)]
|
||||
for number, image_sha256 in rak_chain.PINNED_RELEASE_ANCHORS:
|
||||
steps[number - 1].target_sha256 = image_sha256
|
||||
steps[10].target_sha256 = "00" * 32
|
||||
with self.assertRaisesRegex(
|
||||
rak_chain.KnownUnsafeReleaseError,
|
||||
"unrecognized step-11 image",
|
||||
"superseded 30-step release",
|
||||
):
|
||||
rak_chain.require_live_release_safe(
|
||||
argparse.Namespace(accept_test_candidate=True), steps
|
||||
|
||||
@@ -137,8 +137,10 @@ def _firmware_ident():
|
||||
ver_s = (_cppdef("FIRMWARE_VERSION") or "").replace("\\", "").strip().strip('"').strip("'")
|
||||
if not ver_s: # not a -D -> read the header MeshCore ships
|
||||
ver_s = _version_from_headers()
|
||||
m = re.search(r"(\d+)\.(\d+)(?:\.(\d+))?", ver_s)
|
||||
fw_version = ml.pack_version(f"{m.group(1)}.{m.group(2)}.{m.group(3) or 0}") if m else 0
|
||||
m = re.search(r"(\d+)\.(\d+)(?:\.(\d+))?(?:\.(\d+))?", ver_s)
|
||||
fw_version = ml.pack_version(
|
||||
f"{m.group(1)}.{m.group(2)}.{m.group(3) or 0}.{m.group(4) or 0}"
|
||||
) if m else 0
|
||||
return ml.FwIdent(fw_version=fw_version, target_id=target_id, hw_id=hw_id)
|
||||
|
||||
|
||||
|
||||
Reference in new issue
Block a user