mirror of
https://github.com/mikecarper/MeshCore.git
synced 2026-09-16 15:52:50 +00:00
Add corrected RAK3401 mOTA chain runner
This commit is contained in:
@@ -1,5 +1,10 @@
|
||||
# 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.
|
||||
|
||||
[`tools/lora_ota/lora_ota.sh`](../tools/lora_ota/lora_ota.sh) and
|
||||
[`tools/lora_ota/lora_ota.ps1`](../tools/lora_ota/lora_ota.ps1) automate a
|
||||
MeshCore LoRa firmware update from a release `.zip` or ready `.mota`. They
|
||||
@@ -365,6 +370,16 @@ Useful controls:
|
||||
- `--allow-non-upgrade` deliberately permits the same or an older version.
|
||||
- `--replace-active-download` deliberately discards a different update already
|
||||
downloading or staged on the target. Without it, that update is preserved.
|
||||
- `--source-shares-controller` is for a Full Companion whose USB Binary API is
|
||||
the controller while its TCP port `5001` is the source. It verifies that the
|
||||
source's port-`5000` public key equals the controller key. The Binary API
|
||||
first moves the shared physical radio, port `5002` then enables the local OTA
|
||||
egress gate, and cleanup sends `normalradio` before restoring the saved
|
||||
Binary radio tuple.
|
||||
- `--require-system-watchdog-off` checks `get system.watchdog` immediately
|
||||
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.
|
||||
- `--work-dir PATH` chooses a new, non-existent work directory.
|
||||
- `--meshcli PATH` and `--motatool PATH` select binaries not on `PATH`.
|
||||
|
||||
|
||||
@@ -0,0 +1,313 @@
|
||||
# RAK3401 1W repeater LoRa update chain
|
||||
|
||||
> Status: the corrected v1.17.01 26-package release candidate has passed
|
||||
> complete offline reconstruction, independent container verification, and
|
||||
> both stable OTAFIX 2.4 and Preview 6 C simulations. It still needs its first
|
||||
> complete physical RAK3401 run and is gated behind
|
||||
> `--accept-test-candidate`; do not use it in production yet.
|
||||
|
||||
This procedure records the exact 26-step update chain from the release
|
||||
[`rak3401-mota-v1.16.07-c1caa5ad-to-v1.17.01-c96bdd6e`](https://github.com/mikecarper/MeshCore/releases/tag/rak3401-mota-v1.16.07-c1caa5ad-to-v1.17.01-c96bdd6e).
|
||||
It is intentionally specific to the following destination firmware:
|
||||
|
||||
- target ID: `2FA509C1`
|
||||
- hardware: `RAK_3401`
|
||||
- role: `RAK_3401_repeater_lora_ota_no_external_sensors`
|
||||
- starting version: `1.16.7.0`, build `c1caa5ad`
|
||||
- starting EndF body hash: `71F4026CBE4B8B74`
|
||||
- destination key used for this deployment:
|
||||
`63d8df6387eaffd2e25db7d2a8ad967a65202182a48d681d7e7a9260f917280d`
|
||||
|
||||
Do not use this chain on another node, and do not start it on another RAK3401.
|
||||
The artifact checks do not replace the pending complete physical-board run.
|
||||
Use this candidate only for the next recoverable lab test.
|
||||
|
||||
## Corrected test-candidate bundle
|
||||
|
||||
The replacement rebuilds steps 6 through 14 so both `Utils::sha256` overloads
|
||||
use software SHA-256; hardware AES and HMAC stay enabled. Temporary steps 14
|
||||
through 25 have monotonic EndF versions `1.16.9.110` through `1.16.9.121`.
|
||||
The final endpoint is a real v1.17.01 build from `c96bdd6e`, with both its
|
||||
runtime string and EndF version set to `1.17.1.0`. No v1.17.02 endpoint is used.
|
||||
|
||||
```text
|
||||
/home/mesh/git/MeshCore/out-rak3401-mota-v1.17.01-corrected/RAK3401-update-chain-v1.16.7-c1caa5ad-to-v1.17.01-c96bdd6e.zip
|
||||
```
|
||||
|
||||
ZIP SHA-256:
|
||||
`693f08187e42cce72124f01328983965726bfbbb3fef80de503f06c4cbe9256a`.
|
||||
The largest package is 88,280 bytes, leaving 1,832 bytes below the staging
|
||||
limit. All 26 deltas reconstruct with both zero-filled and 0xFF-filled
|
||||
workspaces. The endpoint has whole-image SHA-256
|
||||
`e923f6209e0f071a5862ffbc7690e0b355c1e74829067e0bfdacd0de6c316065`
|
||||
and EndF body hash `AC267B02E055F42E`.
|
||||
|
||||
The candidate includes the exact v1.16.7 test-start UF2/Nordic DFU ZIP, a
|
||||
diagnostic corrected step-6 image, and the final recovery UF2/ZIP. Restore the
|
||||
lab node locally to the included test-start image before rerunning the chain.
|
||||
The bundle's `RUNBOOK.md` contains the direct and two-hop commands.
|
||||
|
||||
## Withdrawn v1.17.02-chain smoke-test result
|
||||
|
||||
The lab topology used a Heltec V4.3 OLED ESP32 Full Companion on
|
||||
`/dev/ttyACM0` and a direct LoRa path to `RAK3401 1W Repeater`. The remote
|
||||
admin password is the lowercase default `password`, not `Password`.
|
||||
|
||||
Steps 1 through 5 completed and passed an exact post-boot version and EndF
|
||||
body-hash check. Step 6 then did all of the following successfully:
|
||||
|
||||
- downloaded all 79 blocks of mOTA ID `7C8BC884`;
|
||||
- verified the watchdog was off immediately before install;
|
||||
- returned `verified (unsigned); applying`;
|
||||
- booted `v1.16.08.7-halo-keymind-cascade-b-0c4ed3ac`;
|
||||
- retained a soft-reset boot record consistent with OTAFIX apply success.
|
||||
|
||||
The resulting bridge returns `ERR no EndF (firmware lacks the trailer?)` from
|
||||
`ota self`. The release package is not corrupt: independently applying its
|
||||
delta reconstructs the expected 524,204-byte image with whole-image SHA-256
|
||||
`61ced8b63953c614748c2fa1b04c2c01e8eb6626a604f6ef95fd2594d6d8ce71`
|
||||
and valid EndF body hash `A0130F9BD6760D47`.
|
||||
|
||||
The regression is in the intermediate application. This is the first bridge
|
||||
in the chain built with the old CC310 hardware-crypto SHA path. That path
|
||||
passes the memory-mapped application body to CC310 and ignores a failed return
|
||||
code instead of falling back to software SHA. The preceding v1.16.08 image
|
||||
uses software SHA, while later code contains the required checked fallback.
|
||||
The normal nRF52 install gate correctly refuses step 7 when it cannot validate
|
||||
the running body. Forcing the bootloader trigger would bypass an application
|
||||
safety gate and is not an acceptable recovery.
|
||||
|
||||
The lab RAK remains alive on v1.16.08.7. Its normal radio is restored to
|
||||
`910.525,62.5,7,5`, and `system.watchdog` is restored and verified `on`.
|
||||
|
||||
## Recover the lab RAK
|
||||
|
||||
Recovery now requires a local data path to the RAK3401 itself. The Heltec
|
||||
controller cable cannot recover the remote RAK, and this host currently has no
|
||||
Bluetooth adapter.
|
||||
|
||||
1. Connect the RAK3401 itself to this host by USB.
|
||||
2. Enter its exact-board UF2 bootloader, using a local `uf2reset` command or
|
||||
the board's double-reset gesture.
|
||||
3. Flash the final RAK3401 UF2 from the corrected bundle's `recovery/final/`
|
||||
directory:
|
||||
`RAK_3401_repeater_lora_ota_no_external_sensors-ota-v1.17.01-halo-keymind-cascade-dev-c96bdd6e.uf2`.
|
||||
4. After it boots, require version `v1.17.1`, target `2FA509C1`, hardware
|
||||
`RAK_3401`, a valid `ota self` reply, normal radio settings, and watchdog
|
||||
`on`.
|
||||
|
||||
Do not send step 7, force an apply, or issue a remote recovery reboot. Once the
|
||||
RAK USB cable is attached, the port and UF2 mount can be detected and the
|
||||
recovery completed without guessing device paths.
|
||||
|
||||
## Intended runner safety behavior
|
||||
|
||||
Stable OTAFIX 2.4 cannot feed a system watchdog inherited by the bootloader.
|
||||
The dormant live path implements the release's watchdog sequence:
|
||||
|
||||
1. Persist `system.watchdog off` without issuing a normal reboot.
|
||||
2. Wait 90 seconds for the already-running watchdog to reset the node.
|
||||
3. Require the node to report `> off`.
|
||||
4. Prove another 90 seconds of stable uptime.
|
||||
5. Enforce the requested `ota config hops` reach before every chain step.
|
||||
6. Recheck `> off` immediately before every `ota install` command.
|
||||
7. Re-enable the watchdog only after the exact step 26 image boots and passes
|
||||
both version and EndF body-hash verification.
|
||||
|
||||
These controls are active for the corrected candidate. They cannot repair the
|
||||
old bridge whose own EndF check is broken. The candidate must pass a complete
|
||||
physical-board run before its explicit lab-only gate is removed.
|
||||
|
||||
An interrupted download is resumable. Avoid interrupting power or radio
|
||||
coverage while `ota install` is applying a package. Physical USB recovery is
|
||||
the fallback if an apply is interrupted at the wrong instant.
|
||||
|
||||
## Host and radio requirements
|
||||
|
||||
Install:
|
||||
|
||||
- Python 3.9 or newer
|
||||
- `meshcli` 1.6.0 or newer
|
||||
- a current `motatool`
|
||||
- a Full Companion or OTA-enabled repeater to seed the files
|
||||
|
||||
The ESP32 Full Companion serves mOTA data through its dedicated TCP port
|
||||
`5001`; its USB port remains the Binary API controller. Its local OTA and
|
||||
TempRadio console is TCP port `5002`. For a Heltec Full Companion at
|
||||
`192.168.1.51`, use these three connections together:
|
||||
|
||||
```text
|
||||
controller: /dev/ttyACM0
|
||||
mOTA source: 192.168.1.51:5001
|
||||
source CLI: 192.168.1.51:5002
|
||||
```
|
||||
|
||||
Before a deployment, verify that USB and TCP port `5000` report the same
|
||||
Companion public key:
|
||||
|
||||
```bash
|
||||
meshcli -s /dev/ttyACM0 -j -c off infos
|
||||
meshcli -t 192.168.1.51 -p 5000 -j -c off infos
|
||||
```
|
||||
|
||||
The tested Heltec key is
|
||||
`8ce031bc322f3cf77376eeacfe8800b30d8c5dfe927f3636d801f3455a2dd4cf`.
|
||||
Change the IP address for a different deployment.
|
||||
|
||||
The source, controller, destination, and every intermediate relay must support
|
||||
the temporary tuple `909.950,250,7,5`. Confirm that this frequency and
|
||||
bandwidth are legal at the deployment location. All nodes return to their
|
||||
ordinary radio after each step.
|
||||
|
||||
## Verify without touching a radio
|
||||
|
||||
The chain runner pins the release ZIP SHA-256 to
|
||||
`693f08187e42cce72124f01328983965726bfbbb3fef80de503f06c4cbe9256a`.
|
||||
It also pins and verifies the inner checksum list, checks complete checksum
|
||||
coverage, parses every manifest, verifies the chain continuity, and runs
|
||||
`motatool verify` on all 26 containers.
|
||||
|
||||
It downloads the release automatically when `--bundle` is omitted:
|
||||
|
||||
```bash
|
||||
python3 tools/lora_ota/rak3401_mota_chain.py \
|
||||
--work-dir ./rak3401-mota-chain-work \
|
||||
--motatool /path/to/motatool \
|
||||
--verify-only
|
||||
```
|
||||
|
||||
An already-downloaded asset can be supplied with `--bundle`. Only the pinned
|
||||
ZIP or its extracted root is accepted.
|
||||
|
||||
## Corrected-release live preflight
|
||||
|
||||
The remote-admin password for the tested RAK is the lowercase default
|
||||
`password`. Prefer the environment variable so it is not placed in the child
|
||||
process command line:
|
||||
|
||||
```bash
|
||||
export MESHCORE_ADMIN_PASSWORD='password'
|
||||
|
||||
python3 tools/lora_ota/rak3401_mota_chain.py \
|
||||
--work-dir ./rak3401-mota-chain-work \
|
||||
--controller-serial /dev/ttyACM0 \
|
||||
--source-tcp 192.168.1.51:5001 \
|
||||
--source-cli-tcp 192.168.1.51:5002 \
|
||||
--source-shares-controller \
|
||||
--ota-hops 3 \
|
||||
--accept-test-candidate \
|
||||
--motatool /path/to/motatool \
|
||||
--preflight-only
|
||||
```
|
||||
|
||||
This validates the corrected bundle, Full Companion identity, source, and
|
||||
destination without changing radio or watchdog settings. Omitting
|
||||
`--accept-test-candidate` exits before connecting to a device. The withdrawn
|
||||
v1.17.02 chain is also recognized and always blocked before device access.
|
||||
|
||||
## Direct lab template
|
||||
|
||||
Use only on a locally recoverable test RAK3401. Keep the work directory for
|
||||
resume, and retain the explicit `--accept-test-candidate` lab gate.
|
||||
|
||||
```bash
|
||||
export MESHCORE_ADMIN_PASSWORD='password'
|
||||
|
||||
python3 tools/lora_ota/rak3401_mota_chain.py \
|
||||
--work-dir ./rak3401-mota-chain-work \
|
||||
--controller-serial /dev/ttyACM0 \
|
||||
--source-tcp 192.168.1.51:5001 \
|
||||
--source-cli-tcp 192.168.1.51:5002 \
|
||||
--source-shares-controller \
|
||||
--ota-hops 3 \
|
||||
--accept-test-candidate \
|
||||
--motatool /path/to/motatool \
|
||||
--yes
|
||||
```
|
||||
|
||||
The intended runner logs each isolated step attempt below `steps/`, records every
|
||||
verified transition in `progress.jsonl`, and prints the source log path. Rerun
|
||||
the exact command after a host restart or recoverable RF failure. Its default
|
||||
60-second status interval deliberately leaves airtime for the low-priority OTA
|
||||
transfer; avoid shortening it on a busy or relayed mesh.
|
||||
|
||||
## Relayed lab template
|
||||
|
||||
Every intermediate relay must enter the same TempRadio tuple. Add one
|
||||
`--relay` argument for each intermediate repeater, ordered from the destination
|
||||
side back toward the controller. In other words, list the farthest relay first
|
||||
and the nearest relay last. A bare relay name uses
|
||||
`MESHCORE_ADMIN_PASSWORD`; append `=PASSWORD` only when that relay has a
|
||||
different password.
|
||||
|
||||
The word "hop" is sometimes counted two ways:
|
||||
|
||||
- `controller -> relay -> destination` is two RF links but has one
|
||||
intermediate relay, so pass one `--relay`.
|
||||
- `controller -> near relay -> far relay -> destination` has two intermediate
|
||||
relays, so pass two `--relay` values, `far` first and `near` second.
|
||||
|
||||
For one intermediate relay:
|
||||
|
||||
```bash
|
||||
python3 tools/lora_ota/rak3401_mota_chain.py \
|
||||
--work-dir ./rak3401-mota-chain-work \
|
||||
--controller-serial /dev/ttyACM0 \
|
||||
--source-tcp 192.168.1.51:5001 \
|
||||
--source-cli-tcp 192.168.1.51:5002 \
|
||||
--source-shares-controller \
|
||||
--ota-hops 3 \
|
||||
--relay 'Intermediate Repeater' \
|
||||
--accept-test-candidate \
|
||||
--motatool /path/to/motatool \
|
||||
--yes
|
||||
```
|
||||
|
||||
For two intermediate relays:
|
||||
|
||||
```bash
|
||||
python3 tools/lora_ota/rak3401_mota_chain.py \
|
||||
--work-dir ./rak3401-mota-chain-work \
|
||||
--controller-serial /dev/ttyACM0 \
|
||||
--source-tcp 192.168.1.51:5001 \
|
||||
--source-cli-tcp 192.168.1.51:5002 \
|
||||
--source-shares-controller \
|
||||
--ota-hops 3 \
|
||||
--relay 'Far Repeater' \
|
||||
--relay 'Near Repeater' \
|
||||
--accept-test-candidate \
|
||||
--motatool /path/to/motatool \
|
||||
--yes
|
||||
```
|
||||
|
||||
Do not begin a production deployment until the corrected bundle passes a
|
||||
direct physical smoke test from start to endpoint. Run `--preflight-only` with
|
||||
the same relay arguments first. The runner sends
|
||||
the destination to TempRadio, then the relays farthest-to-nearest while their
|
||||
remaining normal routes still exist. It checks that the 120-minute TempRadio
|
||||
window covers the selected relay count and all configured timeouts. The
|
||||
starting build may report `hops=0` after the watchdog-driven reset, so the
|
||||
runner re-enforces `--ota-hops 3` before every step. That setting accepts OTA
|
||||
packets received through up to three intermediate repeaters.
|
||||
|
||||
For a lossy multi-hop path, increase `--reply-timeout`,
|
||||
`--discovery-timeout`, or `--transfer-timeout-minutes` as needed. If their sum
|
||||
no longer fits the TempRadio window, also increase the minutes in
|
||||
`--temp-radio`; the runner rejects an unsafe combination before connecting.
|
||||
|
||||
## Completion checks
|
||||
|
||||
Success requires all of the following:
|
||||
|
||||
- version `v1.17.1.0`
|
||||
- target ID `2FA509C1`
|
||||
- hardware `RAK_3401`
|
||||
- final EndF body hash `AC267B02E055F42E`
|
||||
- `get system.watchdog` returning `> on`
|
||||
|
||||
Do not treat a successful transfer alone as completion. Each temporary bridge
|
||||
is a complete bootable image, but the RAK should not be left on a bridge
|
||||
version between planned maintenance sessions.
|
||||
|
||||
See [LoRa OTA automation](lora_ota_automation.md) for the generic single-step
|
||||
workflow and source topology details.
|
||||
+129
-6
@@ -1017,6 +1017,17 @@ class Controller:
|
||||
)
|
||||
raise OtaError("meshcli did not return the controller radio settings")
|
||||
|
||||
def get_public_key(self) -> str:
|
||||
objects = self._run(["infos"], "read controller identity")
|
||||
for value in reversed(objects):
|
||||
public_key = value.get("public_key")
|
||||
if (
|
||||
isinstance(public_key, str)
|
||||
and re.fullmatch(r"[0-9A-Fa-f]{64}", public_key)
|
||||
):
|
||||
return public_key.lower()
|
||||
raise OtaError("meshcli did not return the controller public key")
|
||||
|
||||
def set_radio(self, settings: RadioSettings, label: str) -> None:
|
||||
command_error: OtaError | None = None
|
||||
try:
|
||||
@@ -1358,6 +1369,39 @@ def preflight_source_cli(args: argparse.Namespace) -> None:
|
||||
)
|
||||
|
||||
|
||||
def verify_shared_source_identity(
|
||||
controller: Controller,
|
||||
args: argparse.Namespace,
|
||||
) -> None:
|
||||
"""Prove that a TCP source is the controller's own Full Companion."""
|
||||
if not getattr(args, "source_shares_controller", False):
|
||||
return
|
||||
source_host, _source_port = split_host_port(args.source_tcp, 5001)
|
||||
cli_host, _cli_port = split_host_port(args.source_cli_tcp, 5002)
|
||||
if source_host.lower() != cli_host.lower():
|
||||
raise OtaError(
|
||||
"shared source data and CLI endpoints must use the same host"
|
||||
)
|
||||
api_host = f"[{source_host}]:5000" if ":" in source_host else f"{source_host}:5000"
|
||||
api_args = argparse.Namespace(
|
||||
meshcli=args.meshcli,
|
||||
controller_serial=None,
|
||||
controller_tcp=api_host,
|
||||
controller_ble=None,
|
||||
controller_baud=args.controller_baud,
|
||||
reply_timeout=args.reply_timeout,
|
||||
)
|
||||
source_api = Controller(api_args, "")
|
||||
controller_key = controller.get_public_key()
|
||||
source_key = source_api.get_public_key()
|
||||
if source_key != controller_key:
|
||||
raise OtaError(
|
||||
"--source-shares-controller identity mismatch: controller is "
|
||||
f"{controller_key}, source host is {source_key}"
|
||||
)
|
||||
print(f"[source] verified shared Full Companion {source_key}")
|
||||
|
||||
|
||||
class SeederProcess:
|
||||
def __init__(self, args: argparse.Namespace, served_dir: Path, work_dir: Path):
|
||||
self.args = args
|
||||
@@ -1682,6 +1726,19 @@ def confirm_ready_to_install(
|
||||
return status
|
||||
|
||||
|
||||
def require_system_watchdog_off(
|
||||
controller: Controller,
|
||||
args: argparse.Namespace,
|
||||
) -> None:
|
||||
reply = controller.remote_command(args.target, "get system.watchdog")
|
||||
if re.fullmatch(r"\s*>\s*off\s*", reply, re.IGNORECASE) is None:
|
||||
raise OtaError(
|
||||
"destination system watchdog must report `> off` immediately "
|
||||
f"before installation; got: {reply}"
|
||||
)
|
||||
print(f"[install] {args.target} system watchdog is off")
|
||||
|
||||
|
||||
def request_install(
|
||||
controller: Controller,
|
||||
args: argparse.Namespace,
|
||||
@@ -1695,6 +1752,8 @@ def request_install(
|
||||
# If an install reply is lost and the target also stops replying, this
|
||||
# short window gets it back onto the normal channel for verification.
|
||||
arm_target_install_window(controller, args)
|
||||
if getattr(args, "require_system_watchdog_off", False):
|
||||
require_system_watchdog_off(controller, args)
|
||||
try:
|
||||
reply = controller.remote_command(args.target, "ota install", retry=False)
|
||||
except TransmissionError as exc:
|
||||
@@ -1780,6 +1839,18 @@ def shorten_source_temp_window(
|
||||
) -> bool:
|
||||
if args.source_already_temp:
|
||||
return True
|
||||
if getattr(args, "source_shares_controller", False):
|
||||
print("[source] ending shared Full Companion TempRadio before restore")
|
||||
output = source_cli_command(args, "normalradio", check=check)
|
||||
if not output and not check:
|
||||
print(
|
||||
"[warn] could not end the shared source TempRadio window; "
|
||||
"end it with `normalradio` before restoring the controller",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return False
|
||||
time.sleep(TEMP_RADIO_SWITCH_DELAY_SECONDS)
|
||||
return True
|
||||
command = temp_radio_command_for_minutes(args, TEMP_RADIO_RETURN_MINUTES)
|
||||
print(
|
||||
f"[source] scheduling return to the normal channel in "
|
||||
@@ -1823,13 +1894,29 @@ def verify_installed(
|
||||
"destination still reports its pre-install firmware hash"
|
||||
)
|
||||
|
||||
version_reply = controller.remote_command(args.target, "ver")
|
||||
installed_version = extract_reply_version(version_reply)
|
||||
installed_version = None
|
||||
version_source = "`ota stats`"
|
||||
try:
|
||||
stats_reply = controller.remote_command(args.target, "ota stats")
|
||||
version_match = re.search(
|
||||
r"\bfw (v\d+\.\d+\.\d+(?:\.\d+)?)\b", stats_reply
|
||||
)
|
||||
if version_match:
|
||||
installed_version = parse_version(version_match.group(1))
|
||||
except OtaError as exc:
|
||||
print(f"[warn] post-reboot `ota stats` version query failed: {exc}")
|
||||
if installed_version is None:
|
||||
raise OtaError(f"post-reboot `ver` returned no firmware version: {version_reply}")
|
||||
version_source = "`ver`"
|
||||
version_reply = controller.remote_command(args.target, "ver")
|
||||
installed_version = extract_reply_version(version_reply)
|
||||
if installed_version is None:
|
||||
raise OtaError(
|
||||
f"post-reboot `ver` returned no firmware version: {version_reply}"
|
||||
)
|
||||
if installed_version != package.fw_version:
|
||||
raise OtaError(
|
||||
f"destination reports {format_version(installed_version)} after reboot; "
|
||||
f"destination {version_source} reports "
|
||||
f"{format_version(installed_version)} after reboot; "
|
||||
f"expected {package.version}"
|
||||
)
|
||||
print(
|
||||
@@ -1907,6 +1994,14 @@ def build_parser() -> argparse.ArgumentParser:
|
||||
"--source-already-temp", action="store_true",
|
||||
help="do not configure a TCP source; assert it is already on --temp-radio",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--source-shares-controller",
|
||||
action="store_true",
|
||||
help=(
|
||||
"TCP source is the controller's own Full Companion; verify its "
|
||||
"port-5000 identity and let the controller radio switch move both"
|
||||
),
|
||||
)
|
||||
parser.add_argument(
|
||||
"--leave-controller-radio", action="store_true",
|
||||
help="leave the controller on --temp-radio instead of restoring it",
|
||||
@@ -1917,6 +2012,14 @@ def build_parser() -> argparse.ArgumentParser:
|
||||
help="discard a different update already staged on the destination",
|
||||
)
|
||||
parser.add_argument("--yes", action="store_true", help="skip the destructive-action confirmation")
|
||||
parser.add_argument(
|
||||
"--require-system-watchdog-off",
|
||||
action="store_true",
|
||||
help=(
|
||||
"refuse each ota install attempt unless the destination immediately "
|
||||
"reports that its system watchdog is off"
|
||||
),
|
||||
)
|
||||
parser.add_argument(
|
||||
"--prepare-only", action="store_true",
|
||||
help="only select/build/verify the mOTA; requires offline target metadata",
|
||||
@@ -1962,6 +2065,16 @@ def validate_args(args: argparse.Namespace, parser: argparse.ArgumentParser) ->
|
||||
parser.error(
|
||||
"--source-tcp also needs --source-cli-serial, --source-cli-tcp, or --source-already-temp"
|
||||
)
|
||||
if args.source_shares_controller and not (
|
||||
args.source_tcp and args.source_cli_tcp
|
||||
):
|
||||
parser.error(
|
||||
"--source-shares-controller requires --source-tcp and --source-cli-tcp"
|
||||
)
|
||||
if args.source_shares_controller and args.source_already_temp:
|
||||
parser.error(
|
||||
"--source-shares-controller and --source-already-temp are mutually exclusive"
|
||||
)
|
||||
if args.controller_serial and args.source_serial:
|
||||
if os.path.abspath(args.controller_serial) == os.path.abspath(args.source_serial):
|
||||
parser.error("controller and source must be separate nodes/serial ports")
|
||||
@@ -1985,7 +2098,9 @@ def validate_args(args: argparse.Namespace, parser: argparse.ArgumentParser) ->
|
||||
parser.error(f"--{name.replace('_', '-')} must be positive")
|
||||
if not args.prepare_only:
|
||||
remote_setup_seconds = (1 + len(args.relay)) * args.reply_timeout
|
||||
source_setup_seconds = 0 if args.source_already_temp else 30
|
||||
source_setup_seconds = (
|
||||
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)
|
||||
required_temp_seconds = (
|
||||
remote_setup_seconds
|
||||
@@ -2082,6 +2197,7 @@ def main(argv: list[str] | None = None) -> int:
|
||||
if not args.prepare_only:
|
||||
preflight_source_cli(args)
|
||||
controller = Controller(args, password)
|
||||
verify_shared_source_identity(controller, args)
|
||||
target = query_target(controller, args)
|
||||
original_radio = controller.get_radio()
|
||||
print(f"[controller] saved radio {original_radio.meshcli_value()}")
|
||||
@@ -2118,7 +2234,7 @@ def main(argv: list[str] | None = None) -> int:
|
||||
relay_name, temp_command, password=relay_password
|
||||
)
|
||||
require_temp_radio_reply(relay_name, temp_reply)
|
||||
if not args.source_already_temp:
|
||||
if not args.source_already_temp and not args.source_shares_controller:
|
||||
source_cli_command(args, temp_command)
|
||||
source_temp_owned = True
|
||||
|
||||
@@ -2127,6 +2243,13 @@ def main(argv: list[str] | None = None) -> int:
|
||||
)
|
||||
controller_changed = True
|
||||
controller.set_radio(temp_radio, "switch controller to TempRadio")
|
||||
if args.source_shares_controller:
|
||||
# The Full Companion OTA egress gate needs its local TempRadio
|
||||
# state even though the Binary API has already moved the same
|
||||
# physical radio. Enter it after persisting the temporary tuple;
|
||||
# cleanup runs `normalradio` before the Binary restore.
|
||||
source_cli_command(args, temp_command)
|
||||
source_temp_owned = True
|
||||
time.sleep(TEMP_RADIO_SWITCH_DELAY_SECONDS)
|
||||
|
||||
seeder = SeederProcess(args, package_path.parent, work_dir)
|
||||
|
||||
Executable
+1038
File diff suppressed because it is too large
Load Diff
@@ -18,6 +18,7 @@ from unittest import mock
|
||||
import zipfile
|
||||
|
||||
import lora_ota as ota
|
||||
import rak3401_mota_chain as rak_chain
|
||||
|
||||
|
||||
TARGET = 0x1234ABCD
|
||||
@@ -669,6 +670,72 @@ class ReliabilityTests(unittest.TestCase):
|
||||
],
|
||||
)
|
||||
|
||||
def test_install_watchdog_gate_runs_immediately_before_install(self) -> None:
|
||||
image = firmware(b"watchdog" * 900, VERSION_NEW)
|
||||
package = ota.parse_mota(mota_blob(image))
|
||||
|
||||
class Controller:
|
||||
def __init__(self) -> None:
|
||||
self.commands: list[str] = []
|
||||
self.replies = iter([
|
||||
f"OTA | download: ready to install 9/9 id={package.manifest_id} 2s",
|
||||
"OK - temp params for 3 mins",
|
||||
"> off",
|
||||
"OK | verified; applying",
|
||||
])
|
||||
|
||||
def remote_command(
|
||||
self, _target: str, command: str, **_kwargs: object
|
||||
) -> str:
|
||||
self.commands.append(command)
|
||||
return next(self.replies)
|
||||
|
||||
controller = Controller()
|
||||
args = argparse.Namespace(
|
||||
target="remote",
|
||||
temp_values=(909.95, 250.0, 7, 5, 120),
|
||||
require_system_watchdog_off=True,
|
||||
)
|
||||
self.assertTrue(ota.request_install(controller, args, package))
|
||||
self.assertEqual(
|
||||
controller.commands,
|
||||
[
|
||||
"ota status",
|
||||
"tempradio 909.95,250,7,5,3",
|
||||
"get system.watchdog",
|
||||
"ota install",
|
||||
],
|
||||
)
|
||||
|
||||
def test_install_watchdog_gate_rejects_enabled_watchdog(self) -> None:
|
||||
image = firmware(b"watchdog-on" * 900, VERSION_NEW)
|
||||
package = ota.parse_mota(mota_blob(image))
|
||||
|
||||
class Controller:
|
||||
def __init__(self) -> None:
|
||||
self.commands: list[str] = []
|
||||
self.replies = iter([
|
||||
f"OTA | download: ready to install 9/9 id={package.manifest_id} 2s",
|
||||
"OK - temp params for 3 mins",
|
||||
"> on",
|
||||
])
|
||||
|
||||
def remote_command(
|
||||
self, _target: str, command: str, **_kwargs: object
|
||||
) -> str:
|
||||
self.commands.append(command)
|
||||
return next(self.replies)
|
||||
|
||||
controller = Controller()
|
||||
args = argparse.Namespace(
|
||||
target="remote",
|
||||
temp_values=(909.95, 250.0, 7, 5, 120),
|
||||
require_system_watchdog_off=True,
|
||||
)
|
||||
with self.assertRaisesRegex(ota.OtaError, "must report `> off`"):
|
||||
ota.request_install(controller, args, package)
|
||||
self.assertNotIn("ota install", controller.commands)
|
||||
|
||||
def test_post_install_verification_requires_exact_version(self) -> None:
|
||||
image = firmware(b"verify" * 900, VERSION_NEW)
|
||||
package = ota.parse_mota(mota_blob(image))
|
||||
@@ -678,7 +745,7 @@ class ReliabilityTests(unittest.TestCase):
|
||||
def __init__(self, version: str) -> None:
|
||||
self.replies = iter([
|
||||
f"self body=1 image=2 base_hash={expected_hash.hex()}",
|
||||
version,
|
||||
f"OTA stats | fw {version}",
|
||||
])
|
||||
|
||||
def remote_command(self, *_args: object, **_kwargs: object) -> str:
|
||||
@@ -693,6 +760,26 @@ class ReliabilityTests(unittest.TestCase):
|
||||
Controller("v1.16.9 (Build: old)"), args, package, expected_hash
|
||||
)
|
||||
|
||||
def test_post_install_version_falls_back_to_runtime_ver(self) -> None:
|
||||
image = firmware(b"verify-fallback" * 700, VERSION_NEW)
|
||||
package = ota.parse_mota(mota_blob(image))
|
||||
expected_hash = ota.parse_endf(image).body_hash
|
||||
|
||||
class Controller:
|
||||
def __init__(self) -> None:
|
||||
self.replies = iter([
|
||||
f"self body=1 image=2 base_hash={expected_hash.hex()}",
|
||||
"ERR command not found",
|
||||
"v1.17.0 (Build: test)",
|
||||
])
|
||||
|
||||
def remote_command(self, *_args: object, **_kwargs: object) -> str:
|
||||
return next(self.replies)
|
||||
|
||||
ota.verify_installed(
|
||||
Controller(), argparse.Namespace(target="remote"), package, expected_hash
|
||||
)
|
||||
|
||||
def test_unknown_new_hash_must_differ_from_pre_install_hash(self) -> None:
|
||||
image = firmware(b"delta-result" * 700, VERSION_NEW)
|
||||
package = ota.parse_mota(mota_blob(
|
||||
@@ -707,7 +794,6 @@ class ReliabilityTests(unittest.TestCase):
|
||||
def __init__(self) -> None:
|
||||
self.replies = iter([
|
||||
f"self body=1 image=2 base_hash={old_hash.hex()}",
|
||||
"v1.17.0 (Build: test)",
|
||||
])
|
||||
|
||||
def remote_command(self, *_args: object, **_kwargs: object) -> str:
|
||||
@@ -771,6 +857,7 @@ class ReliabilityTests(unittest.TestCase):
|
||||
def test_source_cleanup_only_changes_a_script_owned_window(self) -> None:
|
||||
args = argparse.Namespace(
|
||||
source_already_temp=False,
|
||||
source_shares_controller=False,
|
||||
temp_values=(909.95, 250.0, 7, 5, 120),
|
||||
)
|
||||
with mock.patch.object(
|
||||
@@ -786,6 +873,64 @@ class ReliabilityTests(unittest.TestCase):
|
||||
self.assertTrue(ota.shorten_source_temp_window(args))
|
||||
source_command.assert_not_called()
|
||||
|
||||
def test_shared_source_cleanup_ends_temp_before_binary_restore(self) -> None:
|
||||
args = argparse.Namespace(
|
||||
source_already_temp=False,
|
||||
source_shares_controller=True,
|
||||
temp_values=(909.95, 250.0, 7, 5, 120),
|
||||
)
|
||||
with (
|
||||
mock.patch.object(
|
||||
ota,
|
||||
"source_cli_command",
|
||||
return_value="OK - normal radio restore scheduled",
|
||||
) as source_command,
|
||||
mock.patch.object(ota.time, "sleep") as sleep,
|
||||
):
|
||||
self.assertTrue(ota.shorten_source_temp_window(args))
|
||||
source_command.assert_called_once_with(args, "normalradio", check=True)
|
||||
sleep.assert_called_once_with(ota.TEMP_RADIO_SWITCH_DELAY_SECONDS)
|
||||
|
||||
|
||||
class Rak3401KnownUnsafeReleaseTests(unittest.TestCase):
|
||||
def test_live_chain_is_blocked_after_failed_physical_step_6(self) -> None:
|
||||
self.assertEqual(rak_chain.KNOWN_UNSAFE_STEP, 6)
|
||||
self.assertEqual(rak_chain.KNOWN_UNSAFE_VERSION, "1.16.8.7")
|
||||
steps = [mock.Mock(target_sha256="") for _ in range(6)]
|
||||
steps[5].target_sha256 = rak_chain.KNOWN_UNSAFE_IMAGE_SHA256
|
||||
with self.assertRaisesRegex(
|
||||
rak_chain.KnownUnsafeReleaseError,
|
||||
"physical RAK3401 test reached step 6",
|
||||
):
|
||||
rak_chain.require_live_release_safe(
|
||||
argparse.Namespace(accept_test_candidate=False), steps
|
||||
)
|
||||
|
||||
def test_corrected_chain_requires_explicit_lab_gate(self) -> None:
|
||||
steps = [mock.Mock(target_sha256="") for _ in range(6)]
|
||||
steps[5].target_sha256 = rak_chain.SAFE_CANDIDATE_STEP6_IMAGE_SHA256
|
||||
with self.assertRaisesRegex(
|
||||
rak_chain.KnownUnsafeReleaseError,
|
||||
"needs its first end-to-end board test",
|
||||
):
|
||||
rak_chain.require_live_release_safe(
|
||||
argparse.Namespace(accept_test_candidate=False), steps
|
||||
)
|
||||
rak_chain.require_live_release_safe(
|
||||
argparse.Namespace(accept_test_candidate=True), steps
|
||||
)
|
||||
|
||||
def test_unrecognized_step6_is_blocked(self) -> None:
|
||||
steps = [mock.Mock(target_sha256="") for _ in range(6)]
|
||||
steps[5].target_sha256 = "00" * 32
|
||||
with self.assertRaisesRegex(
|
||||
rak_chain.KnownUnsafeReleaseError,
|
||||
"not a recognized, audited RAK3401 bridge image",
|
||||
):
|
||||
rak_chain.require_live_release_safe(
|
||||
argparse.Namespace(accept_test_candidate=True), steps
|
||||
)
|
||||
|
||||
|
||||
class MotatoolIntegrationTests(unittest.TestCase):
|
||||
@classmethod
|
||||
|
||||
Reference in New Issue
Block a user