Support bootloader mOTA staging and guided host tool repair

This commit is contained in:
mikecarper
2026-09-08 12:36:06 -07:00
parent 966ab823f0
commit f2f93c4660
7 changed files with 1292 additions and 44 deletions
+78 -1
View File
@@ -77,6 +77,83 @@ mOTA ownership state as nRF52. The exact `ota folder on` line transfers the USB
port from startup ASCII or idle Binary Companion mode before binary mOTA frames
begin; neither console mode shares that port until the folder detaches.
## Bootloader packages and station identifiers
Both wrappers use the same package-aware runner. It identifies application
versus bootloader updates from container metadata, not filenames. A ZIP mixing
application inputs and bootloader mOTAs requires `--zip-member`; their versions
and target IDs are independent and must not be ranked together.
Signed format-3 nRF52 bootloader packages can be **staged with `--no-install`**.
Use the same controller/source/TempRadio arguments as an application transfer,
but provide the bootloader `.mota` (or its ZIP member) and `--no-install`.
Without that flag, even `--yes` stops before opening a radio. Bootloader staging
requires Python `cryptography` in the interpreter running the wrapper and a
current `motatool` supporting format 3. The runner reuses `tools/mota/motalib.py`
for strict geometry, embedded identity/CRC, capability, hash and signature
validation, and still runs `motatool verify` including any `--public-key` pin.
It probes actual `motatool inspect` support before radio access: older and newer
builds may advertise the same version. If the tool is missing or incompatible,
an interactive run offers an automatic repair:
```text
Install this bootloader-capable motatool and continue after verification? [y/N]
```
Only `y` or `yes` approves installation. **`--yes` does not approve host software
installation.** Noninteractive runs stop and print a copyable manual installation
command plus the `--motatool` argument to use afterward.
The repair builds the [mikecarper/motatool](https://github.com/mikecarper/motatool)
fork at pinned revision `8c38369e7d35ad50cf74261869676d52dd24adf7`, with Cargo's
`--locked` dependencies, in a separate per-user cache directory. It needs
Rust/Cargo and a native linker already installed; otherwise it points to
[Rust installation](https://rustup.rs/) and stops without installing anything.
Source/dependency downloads, build time and disk use are disclosed before
confirmation. Cargo progress is displayed, with a one-hour build timeout.
The radio admin-password environment variable is not passed to the build.
The installer does not overwrite the selected/system `motatool` or alter PATH.
It may replace only a previous copy in its displayed private install directory.
After installation it repeats the bootloader-package capability check and uses
the new binary for this run only if that check passes. A later run using the
default `motatool` selection can reuse the checked cache without downloading
again; switching from an explicit `--motatool` path still asks permission.
Refusal, a failed build, or a failed recheck stops before opening any radio.
Failed build files may remain in the displayed cache directory for diagnostics.
The install root is `meshcore-lora-ota/motatool/<revision>` under `%LOCALAPPDATA%`
on Windows, `~/Library/Caches` on macOS, or `$XDG_CACHE_HOME` (default
`~/.cache`) on Linux. Cargo uses an explicit
[`--root`, `--rev`, and `--locked`](https://doc.rust-lang.org/cargo/commands/cargo-install.html)
instead of replacing a global installation or following a moving branch.
Before transfer it queries the destination's `ota bootloader status`, checks its
bootloader-specific target/hardware identity, installed ABI/codecs and matching
internal/QSPI/SD storage profile. It does not compare an application version or
application target ID to a bootloader package. After transfer it checks the
destination's exact staged MID and image-hash confirmation, then prints the
manual command; it **never sends a bootloader install command**:
```text
ota bootloader status
ota bootloader install <MID8> <HASH16>
```
Review and send those commands on the **destination**, not the source. The
device independently enforces signer authorization, safe live storage,
continuity and upgrade-only policy at installation. `--base`,
`--allow-non-upgrade`, and `--prepare-only` remain application-only. See the
[bootloader prerequisites and recovery limits](ota_nrf52_bootloader_update.md).
`TARGET_NODE`, `--relay`, and `--source-contact` accept a contact name, full
public key, or unique hexadecimal key prefix. The runner reads the controller's
existing contact table and binds each selection to a full key before remote
commands, so emoji names need not be typed and later name changes cannot
redirect the run. Duplicate names, ambiguous prefixes, or the same radio under
different participant aliases stop with an actionable error. Missing contacts
must first be imported or discovered; a key alone does not create a contact.
## Destination requirements
| Destination | Package installed | One-time prerequisite | Raw ZIP handling |
@@ -559,7 +636,7 @@ Useful controls:
If the version gate required RXPS off, it stays off while that topology is
preserved; use `target-rxps-settings.json` to restore it only after sending
`normalradio`.
- `--allow-non-upgrade` deliberately permits the same or an older version.
- `--allow-non-upgrade` deliberately permits the same or an older application 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
+7
View File
@@ -197,6 +197,13 @@ closed instead of selecting smaller or overlapping geometry.
## Explicit install workflow
The `tools/lora_ota/lora_ota.sh` and `.ps1` runners can perform the discovery,
transfer, verification and radio cleanup for a bootloader mOTA with
`--no-install`. They detect format 3 automatically and check the destination's
bootloader-specific identity/capabilities. They do not install it: the final
MID/hash confirmation below remains an explicit operator action. A mixed
application/bootloader ZIP requires `--zip-member`.
Check the installed identity and capability marker:
```text
+531 -40
View File
@@ -17,6 +17,7 @@ import argparse
import atexit
import getpass
import hashlib
import importlib.util
import json
import math
import os
@@ -35,8 +36,10 @@ import sys
import tempfile
import threading
import time
from dataclasses import dataclass
from dataclasses import dataclass, replace
from datetime import datetime, timezone
from functools import lru_cache
from types import ModuleType
from typing import Callable, TypeVar
import zipfile
@@ -138,6 +141,11 @@ RELAY_TIMING_RECOVERY_FILE = "relay-timing-settings.json"
TARGET_RXPS_RECOVERY_FILE = "target-rxps-settings.json"
SOURCE_RXPS_RECOVERY_FILE = "source-rxps-settings.json"
MIN_MESHCLI_VERSION = (1, 6, 0)
# Tested format-3 inspect/verify/serve support. Keep automatic repairs pinned;
# neither a package nor a moving branch may choose code to install on the host.
MOTATOOL_REPAIR_REPOSITORY = "https://github.com/mikecarper/motatool.git"
MOTATOOL_REPAIR_REVISION = "8c38369e7d35ad50cf74261869676d52dd24adf7"
MOTATOOL_REPAIR_TIMEOUT_SECONDS = 60 * 60
# v1.17.1.5 is the first release-version contract in which every packet,
# including retries, uses the same tuple-selected physical preamble: normally
# 32 symbols at SF5-SF8, then 64 or 128 only where each shorter choice cannot
@@ -193,6 +201,7 @@ class MotaInfo:
hw_id: str
base_hash: bytes
payload_offset: int
bootloader_storage: int | None = None
@property
def is_full(self) -> bool:
@@ -200,8 +209,14 @@ class MotaInfo:
@property
def kind(self) -> str:
if self.is_bootloader:
return "bootloader full"
return "full" if self.is_full else "delta"
@property
def is_bootloader(self) -> bool:
return bool(self.flags & MOTA_FLAG_BOOTLOADER)
@property
def version(self) -> str:
return format_version(self.fw_version)
@@ -233,6 +248,9 @@ class TargetInfo:
nrf_qspi: bool = False
# Firmware predating the live maxblk marker had 1 KiB receive buffers.
max_block_size: int = LEGACY_TARGET_MAX_BLOCK_SIZE
boot_target_id: int | None = None
boot_hw_id: str | None = None
boot_storage: int | None = None
@property
def nrf_external(self) -> bool:
@@ -810,6 +828,52 @@ def merkle_root(leaves: list[bytes]) -> bytes:
return level[0]
@lru_cache(maxsize=1)
def bootloader_library() -> ModuleType:
"""Reuse the repository's strict v3 reference validator, not a second codec."""
path = Path(__file__).resolve().parents[1] / "mota" / "motalib.py"
name = "_meshcore_lora_ota_motalib"
spec = importlib.util.spec_from_file_location(name, path)
if spec is None or spec.loader is None:
raise OtaError(f"bootloader validator is unavailable: {path}")
module = importlib.util.module_from_spec(spec)
sys.modules[name] = module # dataclasses resolves annotations through this registry
try:
spec.loader.exec_module(module)
except (OSError, ImportError) as exc:
sys.modules.pop(name, None)
raise OtaError(f"cannot load bootloader validator: {exc}") from exc
return module
def parse_bootloader_mota(blob: bytes, path: Path | None) -> MotaInfo:
library = bootloader_library()
try:
parsed = library.parse_container(blob)
problems = library.verify(parsed)
if problems:
raise ValueError("; ".join(problems))
except ImportError as exc:
raise OtaError(
"bootloader signature verification requires Python cryptography; "
"install it in the Python environment running this script"
) from exc
except (ValueError, struct.error) as exc:
raise OtaError(f"invalid bootloader mOTA: {exc}") from exc
manifest = parsed.manifest
return MotaInfo(
path=path, blob=blob, flags=manifest.flags,
target_id=manifest.target_id, fw_version=manifest.fw_version,
image_size=manifest.image_size, payload_size=manifest.payload_size,
block_size=manifest.block_size, merkle_root=manifest.merkle_root,
image_hash=manifest.image_hash, codec_id=manifest.codec_id,
hw_id=manifest.hw_id.rstrip(b"\0").decode("ascii"),
base_hash=manifest.base_hash,
payload_offset=len(blob) - len(MOTA_TRAILER) - manifest.payload_size,
bootloader_storage=library.bootloader_caps_storage(parsed.payload),
)
def parse_mota(blob: bytes, path: Path | None = None) -> MotaInfo:
if len(blob) < 8 + MOTA_FIXED_MANIFEST_SIZE + len(MOTA_TRAILER):
raise OtaError("mOTA is truncated")
@@ -821,17 +885,14 @@ def parse_mota(blob: bytes, path: Path | None = None) -> MotaInfo:
f"mOTA size field is {declared_size}, but the file is {len(blob)} bytes"
)
flags = blob[9]
if blob[8] == MOTA_BOOT_FORMAT_VERSION and flags & MOTA_FLAG_BOOTLOADER:
raise OtaError(
"bootloader mOTA packages require the device's explicit `ota bootloader install` "
"workflow; this application-update runner deliberately refuses them"
)
if blob[10] != 0x12:
raise OtaError(f"unsupported mOTA hash algorithm 0x{blob[10]:02x}")
if blob[8] == MOTA_BOOT_FORMAT_VERSION:
return parse_bootloader_mota(blob, path)
if blob[8] != MOTA_FORMAT_VERSION:
raise OtaError(f"unsupported mOTA format version {blob[8]}")
if flags & MOTA_FLAG_BOOTLOADER or flags & ~MOTA_KNOWN_FLAGS:
raise OtaError("invalid flags in v2 application mOTA")
if blob[10] != 0x12:
raise OtaError(f"unsupported mOTA hash algorithm 0x{blob[10]:02x}")
target_id, fw_version, image_size, payload_size = struct.unpack_from("<IIII", blob, 11)
block_size_log2 = blob[27]
@@ -1001,6 +1062,27 @@ def read_zip_member(archive: zipfile.ZipFile, member: zipfile.ZipInfo) -> bytes:
def compatible_mota(info: MotaInfo, target: TargetInfo) -> tuple[bool, str]:
if info.is_bootloader:
if target.platform != "nrf52" or target.boot_target_id is None:
return False, "destination has no verified `ota bootloader status` capability"
if info.target_id != target.boot_target_id or info.hw_id != target.boot_hw_id:
return False, (
f"bootloader target {info.target_id:08X} hw={info.hw_id}, need "
f"{target.boot_target_id:08X} hw={target.boot_hw_id}"
)
if target.bootloader_abi is None or target.bootloader_abi < 3:
return False, "bootloader self-update requires installed ABI 3 or newer"
if target.bootloader_codecs != 0x5:
return False, "bootloader self-update requires FULL|INPLACE codecs 0x5"
expected_storage = 0x09 if target.nrf_sd else 0x0E if target.nrf_qspi else 0x0A
if (
target.boot_storage != expected_storage
or info.bootloader_storage != expected_storage
):
return False, "bootloader package, installed capabilities, and application storage differ"
if info.block_size > target.max_block_size:
return False, f"bootloader block exceeds destination maxblk:{target.max_block_size}"
return True, ""
if info.target_id != target.target_id:
return False, f"target {info.target_id:08X}, need {target.target_id:08X}"
if info.hw_id and target.hw_id and info.hw_id != target.hw_id:
@@ -1052,6 +1134,8 @@ def select_mota_from_zip(
archive: zipfile.ZipFile,
target: TargetInfo,
requested_member: str | None,
*,
package_kind: str = "application",
) -> tuple[MotaInfo, str] | None:
candidates: list[tuple[MotaInfo, str]] = []
rejected: list[str] = []
@@ -1062,6 +1146,9 @@ def select_mota_from_zip(
continue
try:
info = parse_mota(read_zip_member(archive, member))
if info.is_bootloader != (package_kind == "bootloader"):
rejected.append(f"{member.filename}: different update type; select it explicitly")
continue
good, reason = compatible_mota(info, target)
if good:
candidates.append((info, member.filename))
@@ -1070,7 +1157,9 @@ def select_mota_from_zip(
except (OtaError, zipfile.BadZipFile) as exc:
rejected.append(f"{member.filename}: {exc}")
if not candidates:
if requested_member and requested_member.lower().endswith(".mota"):
if package_kind == "bootloader" or (
requested_member and requested_member.lower().endswith(".mota")
):
details = "; ".join(rejected) or "member not found"
raise OtaError(f"requested ZIP mOTA is unusable: {details}")
return None
@@ -1144,8 +1233,8 @@ def load_base_image(path: Path, target: TargetInfo) -> EndFInfo:
info = parse_mota(
read_bounded_file(path, MAX_ARCHIVE_MEMBER_SIZE, "mOTA file"), path
)
if not info.is_full:
raise OtaError("--base mOTA must be a full-image container")
if info.is_bootloader or not info.is_full:
raise OtaError("--base mOTA must be a full application-image container")
identity = parse_endf(info.payload)
elif suffix == ".zip":
identities: list[tuple[EndFInfo, str]] = []
@@ -1156,7 +1245,7 @@ def load_base_image(path: Path, target: TargetInfo) -> EndFInfo:
try:
if suffix == ".mota":
candidate = parse_mota(read_zip_member(archive, member))
if not candidate.is_full:
if candidate.is_bootloader or not candidate.is_full:
continue
candidate_identity = parse_endf(candidate.payload)
elif suffix in (".bin", ".hex"):
@@ -1339,6 +1428,10 @@ def prepare_package(
source = args.package.resolve()
if not source.is_file():
raise OtaError(f"package does not exist: {source}")
package_kind = getattr(args, "package_kind", None) or inspect_package_kind(
source, args.zip_member
)
require_package_action(args, package_kind == "bootloader")
served_dir = work_dir / "served"
served_dir.mkdir(parents=True, exist_ok=False)
selected: MotaInfo | None = None
@@ -1354,7 +1447,8 @@ def prepare_package(
try:
with zipfile.ZipFile(source) as archive:
mota_member = select_mota_from_zip(
archive, target, args.zip_member
archive, target, args.zip_member,
package_kind=package_kind,
)
if mota_member is not None:
selected, member_name = mota_member
@@ -1371,6 +1465,9 @@ def prepare_package(
raise OtaError("PACKAGE must be a .mota or .zip file")
if selected is not None:
if selected.is_bootloader != (package_kind == "bootloader"):
raise OtaError("package type changed after preflight; restart with the intended input")
require_package_action(args, selected.is_bootloader)
good, reason = compatible_mota(selected, target)
if not good:
raise OtaError(f"package is not installable on {target.name}: {reason}")
@@ -1437,7 +1534,7 @@ def prepare_package(
verify_with_motatool(args.motatool, output, args.public_key)
expected_body_hash: bytes | None = None
if selected.is_full:
if selected.is_full and not selected.is_bootloader:
try:
expected_body_hash = parse_endf(selected.payload).body_hash
except OtaError:
@@ -1478,6 +1575,14 @@ def reply_matches_command(command_text: str, reply: str) -> bool:
needs_temp = lowered.startswith("lora ota needs temp radio")
if command == "ota status":
return text.startswith("OTA |") or "not included" in lowered or is_unknown
if command in ("ota bootloader", "ota bootloader status"):
return (
text.startswith("BL board=")
or lowered.startswith("bootloader update unavailable:")
or is_unknown
or needs_temp
or (is_error and "bootloader" in lowered)
)
if command == "ota self":
return (
lowered.startswith("self ")
@@ -2048,9 +2153,7 @@ class Controller:
contact_keys = [
value["public_key"].lower()
for value in json_objects(after)
if value.get("adv_name") == target
and isinstance(value.get("public_key"), str)
and re.fullmatch(r"[0-9A-Fa-f]{64}", value["public_key"])
if contact_matches_selector(value, target)
]
if contact_keys != [expected_public_key.lower()]:
raise OtaError(
@@ -2114,9 +2217,7 @@ class Controller:
contact_keys = [
value["public_key"].lower()
for value in post_objects
if value.get("adv_name") == target
and isinstance(value.get("public_key"), str)
and re.fullmatch(r"[0-9A-Fa-f]{64}", value["public_key"])
if contact_matches_selector(value, target)
]
if len(contact_keys) != 1:
if any(
@@ -2267,16 +2368,15 @@ class Controller:
for item in objects
):
raise OtaError(f"controller has no contact named {target!r}")
target_key = None
for item in objects:
if (
item.get("adv_name") == target
and isinstance(item.get("public_key"), str)
):
target_key = item["public_key"].lower()
break
if target_key is None:
target_keys = {
item["public_key"].lower() for item in objects
if contact_matches_selector(item, target)
}
if len(target_keys) > 1:
raise OtaError(f"ambiguous contact identity for {target}; use a full public key")
if not target_keys:
raise TransmissionError(f"meshcli did not return contact identity for {target}")
target_key = target_keys.pop()
messages = [
item for item in post_objects
@@ -2396,6 +2496,111 @@ def parse_target_max_block_size(status: str, self_status: str) -> int:
return value
def contact_matches_selector(contact: dict, selector: str) -> bool:
key = contact.get("public_key")
if not isinstance(key, str) or not re.fullmatch(r"[0-9A-Fa-f]{64}", key):
return False
name = contact.get("adv_name")
return (
isinstance(name, str) and name.casefold() == selector.casefold()
) or (
re.fullmatch(r"[0-9A-Fa-f]{1,64}", selector) is not None
and key.lower().startswith(selector.lower())
)
def bind_contact_selectors(controller: Controller, args: argparse.Namespace) -> None:
"""Resolve the local contact table once, before any remote command is sent.
meshcli already accepts keys. Pin unique selectors to complete keys so names
with emojis, renames, duplicate names, and prefix collisions cannot redirect
maintenance halfway through a run. Reading contacts sends no LoRa packets.
"""
objects = controller._run(["contacts"], "resolve OTA station identifiers")
contacts: dict[str, dict] = {}
for obj in objects:
for key, value in obj.items():
if (
isinstance(value, dict)
and isinstance(key, str)
and re.fullmatch(r"[0-9A-Fa-f]{64}", key)
and isinstance(value.get("public_key"), str)
and value["public_key"].lower() == key.lower()
):
contacts[key.lower()] = value
def resolve(selector: str) -> str:
matches = [
key for key, value in contacts.items()
if contact_matches_selector(value, selector)
]
if not matches:
raise OtaError(
f"controller has no contact matching {selector!r}; import or discover it first"
)
if len(matches) != 1:
raise OtaError(
f"ambiguous station {selector!r}: {len(matches)} contacts match; "
"use a full public key"
)
key = matches[0]
print(f"[contact] {selector} -> {contacts[key].get('adv_name', '?')} [{key}]")
return key
target = resolve(args.target)
relays = [(resolve(name), password) for name, password in args.relay_values]
source_selector = args.source_contact_value
if (
not source_selector and not args.source_shares_controller
and has_managed_source_cli(args)
):
source_selector = read_source_name_bounded(args)
source = resolve(source_selector) if source_selector else None
keys = [target, *(key for key, _password in relays)]
if source:
keys.append(source)
if len(keys) != len(set(keys)):
raise OtaError("destination, relays, and source contact must identify different radios")
args.target = target
args.relay_values = relays
args.source_contact_value = source
def query_bootloader_target(
controller: Controller, target: TargetInfo,
) -> TargetInfo:
reply = controller.remote_command(target.name, "ota bootloader status")
match = re.fullmatch(
r"BL board=([0-9A-Fa-f]{8}) target=([0-9A-Fa-f]{8}) name=(\S+) "
r"crc=([0-9A-Fa-f]{8}) abi=(\d+) caps=([0-9A-Fa-f]{2}) "
r"\| staged:(?:ready|none) mid=(?:-|[0-9A-Fa-f]{8}) hash=(?:-|[0-9A-Fa-f]{16})",
reply.strip(),
)
if match is None:
raise OtaError(
f"destination cannot stage a bootloader update: {reply}; "
"requires exact-board ABI-3 self-update bootloader and capable application"
)
board, target_id, name, _crc, abi, caps = match.groups()
library = bootloader_library()
try:
hw_id = (
library.bootloader_hw_id(int(board, 16), name)
.rstrip(b"\0").decode("ascii")
)
derived_target = library.bootloader_target_id(int(board, 16), name)
except (ValueError, UnicodeError) as exc:
raise OtaError(f"invalid destination bootloader identity: {exc}") from exc
if derived_target != int(target_id, 16):
raise OtaError("destination bootloader board/name does not match its target ID")
if int(abi) < 3 or int(abi) != target.bootloader_abi:
raise OtaError("destination reports inconsistent or unsupported bootloader ABI")
return replace(
target, boot_target_id=derived_target, boot_hw_id=hw_id,
boot_storage=int(caps, 16),
)
def query_target(
controller: Controller,
args: argparse.Namespace,
@@ -2502,7 +2707,7 @@ def query_target(
)
current_version = format_version(version_value)
current_version_source = "ver"
return TargetInfo(
target = TargetInfo(
name=args.target,
target_id=target_id,
base_hash=base_hash,
@@ -2519,6 +2724,9 @@ def query_target(
nrf_qspi=nrf_qspi,
max_block_size=max_block_size,
)
if getattr(args, "package_kind", "application") == "bootloader":
return query_bootloader_target(controller, target)
return target
def parse_temp_radio(value: str) -> tuple[float, float, int, int, int]:
@@ -3942,6 +4150,7 @@ def confirm_update(
target: TargetInfo,
package: MotaInfo,
) -> None:
require_package_action(args, package.is_bootloader)
print("\nValidated update plan:")
print(f" destination : {target.name} ({target.target_id:08X}, {target.platform})")
print(f" running base: {target.base_hash.hex().upper()}")
@@ -3956,6 +4165,9 @@ def confirm_update(
print(" bootloader : not required")
print(f" update : {package.version} {package.kind} hw={package.hw_id or '?'}")
print(f" mOTA id : {package.manifest_id}")
if package.is_bootloader:
print(f" boot target : {package.target_id:08X}; stage only, explicit install required")
print(" boot safety : destination rechecks trust, continuity and upgrade-only policy at manual install")
print(f" TempRadio : {args.temp_radio}")
saved_rxps = getattr(args, "target_rxps_saved", None)
rxps_profile = getattr(args, "target_rxps_profile", None)
@@ -3982,7 +4194,10 @@ def confirm_update(
current_version = (
parse_version(target.current_version) if target.current_version else None
)
if current_version is not None and current_version >= package.fw_version:
if (
not package.is_bootloader and current_version is not None
and current_version >= package.fw_version
):
print(
f" warning : destination reports {target.current_version}; "
f"package is {package.version}"
@@ -6189,6 +6404,11 @@ def request_install(
package: MotaInfo,
) -> bool:
"""Request install without ever blindly replaying an uncertain command."""
if package.is_bootloader:
raise OtaError(
"bootloader installation requires explicit "
"`ota bootloader install <MID8> <HASH16>`; this runner only stages it"
)
cycle_started = time.monotonic()
retries = 0
confirm_ready_to_install(controller, args, package)
@@ -6637,7 +6857,7 @@ def build_parser() -> argparse.ArgumentParser:
),
)
parser.add_argument("package", type=Path, metavar="PACKAGE", help=".mota or .zip")
parser.add_argument("target", metavar="TARGET_NODE", help="destination contact name")
parser.add_argument("target", metavar="TARGET_NODE", help="destination contact name, full public key, or unique key prefix")
controller = parser.add_mutually_exclusive_group()
controller.add_argument("--controller-serial", metavar="PORT")
controller.add_argument("--controller-tcp", metavar="HOST[:PORT]")
@@ -6661,14 +6881,14 @@ def build_parser() -> argparse.ArgumentParser:
help="destination admin password (prefer the MESHCORE_ADMIN_PASSWORD environment variable)",
)
parser.add_argument(
"--relay", action="append", default=[], metavar="NAME[=PASSWORD]",
help="optional relay, ordered farthest-to-nearest; repeat as needed",
"--relay", action="append", default=[], metavar="NAME_OR_KEY[=PASSWORD]",
help="optional relay name/key/unique key prefix, farthest-to-nearest; repeat as needed",
)
parser.add_argument(
"--source-contact",
metavar="NAME",
metavar="NAME_OR_KEY",
help=(
"controller contact for a separate OTA source, used for the "
"controller contact name/key/unique key prefix for a separate OTA source, used for the "
"three-minute on-air proof; defaults to the source's local name "
"(no remote-admin password is required)"
),
@@ -6711,7 +6931,13 @@ def build_parser() -> argparse.ArgumentParser:
help=argparse.SUPPRESS,
)
parser.add_argument("--meshcli", default="meshcli")
parser.add_argument("--motatool", default="motatool")
parser.add_argument(
"--motatool", default="motatool",
help=(
"host packaging tool; missing/incompatible bootloader support offers "
"a separately confirmed private install (--yes does not approve it)"
),
)
parser.add_argument(
"--package-build-timeout",
type=int,
@@ -6761,7 +6987,7 @@ def build_parser() -> argparse.ArgumentParser:
"--leave-controller-radio", action="store_true",
help="leave the controller on --temp-radio instead of restoring it",
)
parser.add_argument("--no-install", action="store_true", help="download and verify, but do not install")
parser.add_argument("--no-install", action="store_true", help="download and verify, but do not install (required for bootloader mOTA)")
parser.add_argument(
"--replace-active-download", action="store_true",
help="discard a different update already staged on the destination",
@@ -7015,11 +7241,272 @@ def require_meshcli_version(command: str) -> tuple[int, int, int]:
return version
def require_package_action(args: argparse.Namespace, bootloader: bool) -> None:
if not bootloader:
return
if not getattr(args, "no_install", False):
raise OtaError(
"detected a bootloader mOTA, not an application update; use --no-install "
"to stage it. Installation remains the device's explicit "
"`ota bootloader install <MID8> <HASH16>` workflow"
)
if getattr(args, "prepare_only", False):
raise OtaError(
"bootloader staging requires live destination capability checks; "
"--prepare-only is application-only"
)
if getattr(args, "base", None) or getattr(args, "allow_non_upgrade", False):
raise OtaError(
"--base and --allow-non-upgrade are application-only; "
"bootloader continuity and upgrade policy cannot be overridden"
)
def inspect_package_kind(source: Path, requested_member: str | None) -> str:
"""Choose the update family before opening radios; never rank app vs BL versions."""
if source.suffix.lower() == ".mota":
info = parse_mota(
read_bounded_file(source, MAX_ARCHIVE_MEMBER_SIZE, "mOTA file"), source
)
return "bootloader" if info.is_bootloader else "application"
kinds: set[str] = set()
found = False
try:
with zipfile.ZipFile(source) as archive:
for member in archive.infolist():
if member.is_dir() or (
requested_member and member.filename != requested_member
):
continue
if requested_member and found:
raise OtaError(f"ZIP member name is duplicated: {requested_member}")
found = True
suffix = Path(member.filename).suffix.lower()
if suffix == ".mota":
# Classification is deliberately cheap and conservative. Full
# validation (including signatures for BL) follows selection.
# A malformed boot declaration must never fall back to an app.
with archive.open(member) as stream:
header = stream.read(10)
is_boot = len(header) == 10 and header[:4] == MOTA_MAGIC and (
header[8] == MOTA_BOOT_FORMAT_VERSION
or header[9] & MOTA_FLAG_BOOTLOADER
)
kinds.add("bootloader" if is_boot else "application")
elif suffix in (".bin", ".hex"):
kinds.add("application")
except (OSError, RuntimeError, zipfile.BadZipFile) as exc:
raise OtaError(f"cannot inspect ZIP archive {source}: {exc}") from exc
if requested_member and not found:
raise OtaError(f"requested ZIP member not found: {requested_member}")
if len(kinds) > 1:
raise OtaError(
"ZIP contains both application and bootloader inputs; choose the intended "
"update with --zip-member (their versions and target IDs are unrelated)"
)
return next(iter(kinds), "application")
def report_staged_update(
controller: Controller, args: argparse.Namespace, package: MotaInfo,
) -> None:
if not package.is_bootloader:
print(f"{args.target} is ready to install; leaving the verified update staged.")
return
reply = controller.remote_command(args.target, "ota bootloader status")
match = re.search(
r"\btarget=([0-9A-Fa-f]{8}) .*\| staged:ready "
r"mid=([0-9A-Fa-f]{8}) hash=([0-9A-Fa-f]{16})$", reply.strip(),
)
if match is None or (
int(match[1], 16) != package.target_id
or match[2].upper() != package.manifest_id
or match[3].lower() != package.image_hash[:8].hex()
):
raise OtaError(f"destination did not confirm the exact staged bootloader MID/hash: {reply}")
print(f"[staged] bootloader {package.version} on {args.target}; NOT installed")
print("[manual] Review `ota bootloader status` on the destination, then explicitly send:")
print(f" ota bootloader install {package.manifest_id} {package.image_hash[:8].hex().upper()}")
print("[manual] Do not use `ota install`. The device still enforces signer trust, continuity and upgrade-only checks.")
def motatool_repair_root() -> Path:
if os.name == "nt":
cache = Path(os.environ.get("LOCALAPPDATA") or Path.home() / "AppData" / "Local")
elif sys.platform == "darwin":
cache = Path.home() / "Library" / "Caches"
else:
cache = Path(os.environ.get("XDG_CACHE_HOME") or Path.home() / ".cache")
if not cache.is_absolute():
raise OtaError("motatool repair needs an absolute user cache directory")
return cache / "meshcore-lora-ota" / "motatool" / MOTATOOL_REPAIR_REVISION
def motatool_repair_command(root: Path, cargo: str = "cargo") -> list[str]:
return [
cargo, "install", "--git", MOTATOOL_REPAIR_REPOSITORY,
"--rev", MOTATOOL_REPAIR_REVISION, "--locked", "--bin", "motatool",
"--root", str(root), "--target-dir", str(root / "build"), "--force",
]
def display_host_command(command: list[str]) -> str:
if os.name == "nt":
return "& " + " ".join("'" + word.replace("'", "''") + "'" for word in command)
return shlex.join(command)
def check_bootloader_tool(command: str, probe: Path) -> None:
run_checked(
[command, "inspect", str(probe)],
label="check motatool bootloader support", timeout=30,
)
def offer_motatool_repair(
args: argparse.Namespace, probe: Path, failure: OtaError,
) -> None:
"""Install only after separate consent; never replace the selected/PATH binary."""
root = motatool_repair_root()
binary = root / "bin" / ("motatool.exe" if os.name == "nt" else "motatool")
print(f"[host] {args.motatool} cannot inspect this format-3 bootloader package: {failure}")
cached = False
if binary.is_file():
try:
check_bootloader_tool(str(binary), probe)
cached = True
except OtaError as exc:
print(f"[host] cached motatool also failed its capability check: {exc}")
if cached and args.motatool == "motatool":
args.motatool = str(binary)
print(f"[host] using previously installed, rechecked motatool: {binary}")
return
cargo = shutil.which("cargo")
command = motatool_repair_command(root, cargo or "cargo")
manual = display_host_command(command)
selection = display_host_command(["--motatool", str(binary)])
# Display an argument, not a PowerShell call operator for an option.
if os.name == "nt":
selection = selection.removeprefix("& ")
print(f"[repair] source: {MOTATOOL_REPAIR_REPOSITORY}")
print(f"[repair] pinned revision: {MOTATOOL_REPAIR_REVISION}")
print(f"[repair] private install: {root}")
print("[repair] Existing motatool installations and PATH will not be changed.")
print(f"[repair] Manual install command: {manual}")
print(f"[repair] To select this build explicitly, append: {selection}")
if not cached and cargo is None:
raise OtaError(
"automatic motatool repair requires Rust/Cargo and a native linker; "
"install the toolchain from https://rustup.rs and rerun, or select a "
"bootloader-capable build with --motatool. No installation was attempted"
)
if not sys.stdin.isatty():
raise OtaError(
"motatool repair needs separate interactive approval (--yes does not approve "
"software installation); rerun in a terminal or use the printed manual "
"command and --motatool. No installation was attempted"
)
if cached:
question = "Use the checked cached motatool for this run instead? [y/N] "
else:
print(
"[repair] This downloads and compiles the pinned source and locked dependencies. "
"It can take several minutes and use substantial disk space; "
"only a previous copy in the private install folder may be replaced."
)
question = "Install this bootloader-capable motatool and continue after verification? [y/N] "
try:
approved = input(question).strip().lower() in ("y", "yes")
except EOFError:
approved = False
if not approved:
raise OtaError("motatool repair declined; no installation or radio changes were made")
if not cached:
# No cache directories, downloads or builds before affirmative consent.
root.mkdir(parents=True, exist_ok=True)
print("[repair] Building motatool (Cargo output follows)...", flush=True)
build_env = os.environ.copy()
build_env.pop("MESHCORE_ADMIN_PASSWORD", None)
try:
result = subprocess.run(
command, cwd=str(root), stdin=subprocess.DEVNULL,
timeout=MOTATOOL_REPAIR_TIMEOUT_SECONDS, check=False,
env=build_env,
)
except (OSError, subprocess.TimeoutExpired) as exc:
raise OtaError(
f"motatool repair failed: {exc}; existing installations are unchanged. "
f"Build files may remain in {root}; fix the toolchain/network and rerun"
) from exc
if result.returncode != 0:
raise OtaError(
f"motatool repair failed (Cargo exit {result.returncode}); see the build "
f"output above. Existing installations are unchanged; build files remain in {root}"
)
if not binary.is_file():
raise OtaError(f"motatool installer returned success but did not produce {binary}")
# Recheck even after Cargo success; do not silently fall back to the old binary.
try:
check_bootloader_tool(str(binary), probe)
except OtaError as exc:
raise OtaError(
f"repaired motatool still failed bootloader verification: {exc}; "
"stopping before radio access"
) from exc
args.motatool = str(binary)
print(f"[repair] bootloader support verified; continuing with {binary}")
def require_bootloader_tool_support(args: argparse.Namespace) -> None:
"""Probe actual format support: old and new motatool builds can share a version."""
if args.package.suffix.lower() == ".mota":
probe = args.package
temporary_blob = None
else:
temporary_blob = None
failures: list[str] = []
try:
with zipfile.ZipFile(args.package) as archive:
for member in archive.infolist():
if not member.filename.lower().endswith(".mota") or (
args.zip_member and member.filename != args.zip_member
):
continue
try:
info = parse_mota(read_zip_member(archive, member))
if info.is_bootloader:
temporary_blob = info.blob
break
except OtaError as exc:
failures.append(f"{member.filename}: {exc}")
except zipfile.BadZipFile as exc:
raise OtaError(f"invalid ZIP archive: {args.package}") from exc
if temporary_blob is None:
raise OtaError("ZIP contains no valid bootloader mOTA: " + "; ".join(failures))
# This is a local parser probe, never a serve/pull operation. Do not require
# every hardware variant in an archive to share the operator's signer pin;
# the selected package is independently verified with that pin later.
with tempfile.TemporaryDirectory(prefix="meshcore-boot-probe-") as directory:
if temporary_blob is not None:
probe = Path(directory) / "bootloader.mota"
probe.write_bytes(temporary_blob)
try:
check_bootloader_tool(args.motatool, probe)
except OtaError as exc:
offer_motatool_repair(args, probe, exc)
def preflight_inputs(args: argparse.Namespace) -> None:
if not args.package.is_file():
raise OtaError(f"package does not exist: {args.package.resolve()}")
if args.package.suffix.lower() not in (".zip", ".mota"):
raise OtaError("PACKAGE must be a .zip or .mota file")
args.package_kind = inspect_package_kind(args.package, args.zip_member)
print(f"[package] detected {args.package_kind} update from container metadata")
require_package_action(args, args.package_kind == "bootloader")
for label, path in (
("--base", args.base),
("--sign-key", args.sign_key),
@@ -7027,7 +7514,10 @@ def preflight_inputs(args: argparse.Namespace) -> None:
):
if path is not None and not path.is_file():
raise OtaError(f"{label} file does not exist: {path.resolve()}")
require_command(args.motatool, "motatool")
if args.package_kind == "bootloader":
require_bootloader_tool_support(args)
else:
require_command(args.motatool, "motatool")
if not args.prepare_only:
require_meshcli_version(args.meshcli)
@@ -7252,6 +7742,7 @@ def main(
)
if controller is None:
controller = Controller(args, password)
bind_contact_selectors(controller, args)
verify_shared_source_identity(controller, args)
# These gates are intentionally before query_target() or any other
# on-air remote operation. First prove that advancing the managed
@@ -7519,7 +8010,7 @@ def main(
monitor_download(controller, args, package, seeder)
if args.no_install:
print(f"{args.target} is ready to install; leaving the verified update staged.")
report_staged_update(controller, args, package)
restore_relay_timings(controller, relay_timing_settings)
relay_timing_settings.clear()
if args.leave_controller_radio:
+10 -2
View File
@@ -143,11 +143,11 @@ class FormatTests(unittest.TestCase):
with self.assertRaisesRegex(ota.OtaError, "block hashes"):
ota.parse_mota(bytes(blob))
def test_bootloader_packages_are_explicitly_refused(self) -> None:
def test_relabelling_application_does_not_make_a_bootloader(self) -> None:
blob = bytearray(mota_blob(firmware(b"B" * 5000, VERSION_NEW)))
blob[8] = ota.MOTA_BOOT_FORMAT_VERSION
blob[9] |= ota.MOTA_FLAG_SIGNED | ota.MOTA_FLAG_BOOTLOADER
with self.assertRaisesRegex(ota.OtaError, "bootloader mOTA packages"):
with self.assertRaisesRegex(ota.OtaError, "invalid bootloader mOTA"):
ota.parse_mota(bytes(blob))
blob[8] = ota.MOTA_FORMAT_VERSION
@@ -836,6 +836,13 @@ class DebugTests(unittest.TestCase):
class SourceCliTests(unittest.TestCase):
def setUp(self) -> None:
# These lifecycle tests stub the controller and source transports.
# Station resolution itself is covered by StationSelectionTests.
patcher = mock.patch.object(ota, "bind_contact_selectors")
patcher.start()
self.addCleanup(patcher.stop)
@staticmethod
def source_args() -> argparse.Namespace:
return argparse.Namespace(
@@ -4426,6 +4433,7 @@ class TempRadioPreflightTests(unittest.TestCase):
]
with (
mock.patch.object(ota, "preflight_inputs"),
mock.patch.object(ota, "bind_contact_selectors"),
mock.patch.object(ota, "preflight_source_cli"),
mock.patch.object(
ota,
+457
View File
@@ -0,0 +1,457 @@
"""Offline bootloader staging and station-selection regression tests. No radios."""
import argparse
import contextlib
from dataclasses import replace
import io
import os
from pathlib import Path
import shutil
import struct
import tempfile
import unittest
from unittest import mock
import warnings
import zipfile
import zlib
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
import lora_ota as ota
from test_lora_ota import firmware, mota_blob, prepare_args, target, VERSION_NEW
def boot_blob(*, storage=0x0A, version=0x0117010D):
"""Build a signed test-only candidate using the canonical reference builder."""
ml = ota.bootloader_library()
board, name, app_base = 0x239A0029, "3401_DFU", ml.NRF52_APP_BASE_S140_V6
if storage == 0x0E:
board, name, app_base = 0x28860044, "XIAO-SENSE-DFU", ml.NRF52_APP_BASE_S140_V7
name = ml.XIAO_BOOT_DEVICE_NAME.rstrip(b"\0").decode("ascii")
elif storage == 0x09:
board, name = 0x239A0071, "TOWER_V2_OTA"
image = bytearray(b"\xff" * ml.XIAO_BOOT_IMAGE_SIZE)
struct.pack_into("<II", image, 0, 0x20040000, ml.XIAO_BOOT_IMAGE_START + 0x101)
struct.pack_into("<8sHHB3x", image, 0x80, ml.XIAO_BOOT_CAPS_MAGIC, 3, 5, storage)
offset = ml.BOOT_CANDIDATE_MANIFEST_OFFSET
struct.pack_into(
"<8sHHIII16sI", image, offset, ml.XIAO_BOOT_MANIFEST_MAGIC,
ml.XIAO_BOOT_MANIFEST_VERSION, ml.XIAO_BOOT_MANIFEST_SIZE,
ml.XIAO_BOOT_IMAGE_START, len(image), board, name.encode("ascii"), 0,
)
struct.pack_into(
"<8sHHIHHIHHI", image, offset + ml.XIAO_BOOT_MANIFEST_SIZE,
ml.BOOT_CONTINUITY_MAGIC, ml.BOOT_CONTINUITY_VERSION, ml.BOOT_CONTINUITY_SIZE,
version, ml.BOOT_CONTINUITY_FAMILY_S140,
0x0123 if app_base == ml.NRF52_APP_BASE_S140_V7 else 0x00B6,
app_base, ml.BOOT_CONTINUITY_LAYOUT_ABI, 0, 0,
)
struct.pack_into("<I", image, offset + 40, zlib.crc32(image) & 0xFFFFFFFF)
image = bytes(image)
manifest = ml.build_manifest(
target_id=ml.bootloader_target_id(board, name), fw_version=version,
image_size=len(image), payload=image, block_size=1024,
image_hash=ml.mh32(image), codec_id=ml.CODEC_FULL, is_full=True,
sign_priv=Ed25519PrivateKey.from_private_bytes(b"\x01" * 32), bootloader=True,
)
return ml.build_container(manifest, image)
def boot_target(package):
return replace(
target(platform="nrf52", boot_codecs=5), bootloader_abi=3,
boot_target_id=package.target_id, boot_hw_id=package.hw_id,
boot_storage=package.bootloader_storage,
nrf_sd=package.bootloader_storage == 9,
nrf_qspi=package.bootloader_storage == 14,
)
class BootloaderStagingTests(unittest.TestCase):
@classmethod
def setUpClass(cls):
cls.blob = boot_blob()
cls.package = ota.parse_mota(cls.blob)
def test_signed_full_bootloader_is_identified(self):
package = self.package
self.assertTrue(package.is_bootloader)
self.assertEqual(package.kind, "bootloader full")
self.assertEqual(package.target_id, 0x23818A80)
self.assertEqual(package.block_size, 1024)
self.assertEqual(package.payload_size, 40960)
def test_all_supported_storage_profiles(self):
for storage in (10, 14, 9):
with self.subTest(storage=storage):
package = ota.parse_mota(boot_blob(storage=storage))
self.assertEqual(ota.compatible_mota(package, boot_target(package)), (True, ""))
def test_malformed_bootloaders_fail_closed(self):
for offset in (9, 10, 11, 27, 28, 105, 137, 201, 205, 600):
with self.subTest(offset=offset):
bad = bytearray(self.blob)
bad[offset] ^= 1
with self.assertRaises(ota.OtaError):
ota.parse_mota(bytes(bad))
def test_bootloader_is_not_an_application_full_exception(self):
application = ota.parse_mota(mota_blob(firmware(b"test" * 1000, VERSION_NEW)))
self.assertFalse(ota.compatible_mota(application, target(platform="nrf52"))[0])
def test_bootloader_compatibility_requires_exact_live_contract(self):
live = boot_target(self.package)
for change in (
dict(platform="esp32"), dict(boot_target_id=None),
dict(boot_target_id=self.package.target_id ^ 1), dict(boot_hw_id="wrong"),
dict(bootloader_abi=2), dict(bootloader_codecs=4),
dict(boot_storage=14), dict(nrf_qspi=True), dict(max_block_size=512),
):
with self.subTest(change=change):
self.assertFalse(ota.compatible_mota(self.package, replace(live, **change))[0])
def test_stage_only_is_required_even_with_yes(self):
args = argparse.Namespace(no_install=False, yes=True)
with self.assertRaisesRegex(ota.OtaError, "use --no-install"):
ota.require_package_action(args, True)
args.no_install = True
ota.require_package_action(args, True)
for key, value in (("prepare_only", True), ("allow_non_upgrade", True), ("base", Path("base.bin"))):
with self.subTest(option=key):
with self.assertRaises(ota.OtaError):
ota.require_package_action(argparse.Namespace(no_install=True, **{key: value}), True)
def test_default_run_rejects_boot_before_contacting_radios(self):
with tempfile.TemporaryDirectory() as directory:
path = Path(directory) / "not-an-app.mota"
path.write_bytes(self.blob)
with (
mock.patch.object(ota, "Controller") as controller,
mock.patch.object(ota, "source_cli_command") as source,
mock.patch.object(ota, "require_command") as require,
contextlib.redirect_stdout(io.StringIO()),
contextlib.redirect_stderr(io.StringIO()) as error,
):
result = ota.main([
str(path), "remote", "--controller-serial", "controller",
"--source-serial", "source", "--yes",
])
self.assertEqual(result, 2)
self.assertIn("use --no-install", error.getvalue())
controller.assert_not_called()
source.assert_not_called()
require.assert_not_called()
def test_prepare_boot_stage_uses_motatool_verification(self):
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
source = root / "boot.mota"
source.write_bytes(self.blob)
args = prepare_args(source, "motatool")
args.no_install = True
with mock.patch.object(ota, "verify_with_motatool") as verify:
path, package, body_hash = ota.prepare_package(args, boot_target(self.package), root)
self.assertTrue(package.is_bootloader)
self.assertIsNone(body_hash)
self.assertEqual(path.read_bytes(), self.blob)
verify.assert_called_once_with("motatool", path, None)
def test_mixed_archive_requires_explicit_choice(self):
with tempfile.TemporaryDirectory() as directory:
path = Path(directory) / "mixed.zip"
with zipfile.ZipFile(path, "w") as archive:
archive.writestr("renamed.mota", self.blob)
archive.writestr("firmware.bin", firmware(b"test" * 1000, VERSION_NEW))
with self.assertRaisesRegex(ota.OtaError, "both application and bootloader"):
ota.inspect_package_kind(path, None)
self.assertEqual(ota.inspect_package_kind(path, "renamed.mota"), "bootloader")
self.assertEqual(ota.inspect_package_kind(path, "firmware.bin"), "application")
with self.assertRaisesRegex(ota.OtaError, "not found"):
ota.inspect_package_kind(path, "missing.mota")
def test_boot_archive_selects_matching_newest_and_never_falls_back(self):
with tempfile.TemporaryDirectory() as directory:
path = Path(directory) / "boot.zip"
with zipfile.ZipFile(path, "w") as archive:
archive.writestr("old.mota", self.blob)
archive.writestr("new.mota", boot_blob(version=0x0117010E))
archive.writestr("xiao.mota", boot_blob(storage=14))
with zipfile.ZipFile(path) as archive:
selected = ota.select_mota_from_zip(archive, boot_target(self.package), None, package_kind="bootloader")
self.assertEqual(selected[1], "new.mota")
with self.assertRaisesRegex(ota.OtaError, "unusable"):
ota.select_mota_from_zip(archive, target(platform="esp32"), None, package_kind="bootloader")
def test_duplicate_zip_member_cannot_be_selected_by_name(self):
with tempfile.TemporaryDirectory() as directory:
path = Path(directory) / "duplicate.zip"
with warnings.catch_warnings(), zipfile.ZipFile(path, "w") as archive:
warnings.simplefilter("ignore", UserWarning)
archive.writestr("boot.mota", self.blob)
archive.writestr("boot.mota", self.blob)
with self.assertRaisesRegex(ota.OtaError, "duplicated"):
ota.inspect_package_kind(path, "boot.mota")
def test_query_uses_boot_identity_not_application_id(self):
controller = mock.Mock()
controller.remote_command.return_value = (
"BL board=239A0029 target=23818A80 name=3401_DFU "
"crc=12345678 abi=3 caps=0A | staged:none mid=- hash=-"
)
live = replace(target(platform="nrf52", boot_codecs=5), bootloader_abi=3)
result = ota.query_bootloader_target(controller, live)
self.assertEqual(result.target_id, live.target_id)
self.assertEqual(result.boot_target_id, self.package.target_id)
self.assertEqual(result.boot_hw_id, self.package.hw_id)
for reply in (
"ERR this build cannot update its bootloader over LoRa",
controller.remote_command.return_value.replace("23818A80", "00000000"),
controller.remote_command.return_value.replace("abi=3", "abi=2"),
):
controller.remote_command.return_value = reply
with self.assertRaises(ota.OtaError):
ota.query_bootloader_target(controller, live)
def test_staging_prints_confirmation_but_never_installs(self):
package = self.package
args = argparse.Namespace(target="remote")
controller = mock.Mock()
reply = (
"BL board=239A0029 target=23818A80 name=3401_DFU crc=12345678 abi=3 caps=0A "
f"| staged:ready mid={package.manifest_id} hash={package.image_hash[:8].hex()}"
)
controller.remote_command.return_value = reply
with contextlib.redirect_stdout(io.StringIO()) as output:
ota.report_staged_update(controller, args, package)
controller.remote_command.assert_called_once_with("remote", "ota bootloader status")
self.assertIn("NOT installed", output.getvalue())
self.assertIn(f"ota bootloader install {package.manifest_id}", output.getvalue())
for bad in (reply.replace("staged:ready", "staged:none"), reply.replace(package.manifest_id, "00000000"), reply.replace(package.image_hash[:8].hex(), "0000000000000000")):
controller.remote_command.return_value = bad
with self.assertRaisesRegex(ota.OtaError, "exact staged"):
ota.report_staged_update(controller, args, package)
controller.reset_mock()
with self.assertRaisesRegex(ota.OtaError, "explicit"):
ota.request_install(controller, args, package)
controller.remote_command.assert_not_called()
def test_target_query_only_adds_boot_probe_for_boot_packages(self):
replies = {
"ota status": "OTA | no download | target:1234ABCD hw=TestBoard",
"get bootloader.ver": "> 0.11.0-OTAFIX2.4.6",
"ota self": "self body=100 image=156 base_hash=0011223344556677 | bootloader: abi=3 codecs=0x5",
"ota stats": "OTA | fw v1.17.1.5",
"ota bootloader status": (
"BL board=239A0029 target=23818A80 name=3401_DFU "
"crc=12345678 abi=3 caps=0A | staged:none mid=- hash=-"
),
}
for kind in ("application", "bootloader"):
controller = mock.Mock()
controller.remote_command.side_effect = lambda _target, command, **_kwargs: replies[command]
with contextlib.redirect_stdout(io.StringIO()):
result = ota.query_target(controller, argparse.Namespace(target="remote", package_kind=kind))
if kind == "bootloader":
self.assertEqual(result.boot_target_id, self.package.target_id)
self.assertEqual(ota.compatible_mota(self.package, result), (True, ""))
else:
self.assertIsNone(result.boot_target_id)
self.assertNotIn(mock.call("remote", "ota bootloader status"), controller.remote_command.call_args_list)
def test_application_version_is_not_compared_to_bootloader_version(self):
args = ota.build_parser().parse_args(["boot.mota", "remote", "--no-install", "--yes"])
live = replace(boot_target(self.package), current_version="v99.0.0")
with contextlib.redirect_stdout(io.StringIO()):
ota.confirm_update(args, live, self.package)
def test_boot_status_reply_filter(self):
for reply in (
"BL board=239A0029 target=23818A80", "Bootloader update unavailable: bad CRC",
"ERR this build cannot update its bootloader over LoRa",
):
self.assertTrue(ota.reply_matches_command("ota bootloader status", reply))
self.assertFalse(ota.reply_matches_command("ota bootloader status", "OTA | no download | target:1234ABCD"))
@unittest.skipUnless(os.environ.get("MOTATOOL_TEST_BIN") or shutil.which("motatool"), "motatool is not installed")
def test_real_motatool_verifies_bootloader_and_rejects_wrong_signer_pin(self):
tool = os.environ.get("MOTATOOL_TEST_BIN") or "motatool"
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
package = root / "boot.mota"
package.write_bytes(self.blob)
pin = root / "signer.pub"
pin.write_text(self.blob[105:137].hex(), encoding="ascii")
ota.require_bootloader_tool_support(argparse.Namespace(package=package, motatool=tool))
archive_path = root / "boot.zip"
with zipfile.ZipFile(archive_path, "w") as archive:
archive.writestr("boot.mota", self.blob)
ota.require_bootloader_tool_support(argparse.Namespace(
package=archive_path, zip_member="boot.mota", motatool=tool,
))
ota.verify_with_motatool(tool, package, pin)
pin.write_text("02" * 32, encoding="ascii")
with self.assertRaises(ota.OtaError):
ota.verify_with_motatool(tool, package, pin)
def test_old_motatool_stops_before_any_radio_access(self):
with tempfile.TemporaryDirectory() as directory:
path = Path(directory) / "boot.mota"
path.write_bytes(self.blob)
args = ota.build_parser().parse_args([str(path), "remote", "--no-install"])
with (
mock.patch.object(ota, "require_command"),
mock.patch.object(ota, "require_meshcli_version") as meshcli,
mock.patch.object(ota, "run_checked", side_effect=ota.OtaError("unsupported format_ver 3")),
mock.patch.object(ota, "motatool_repair_root", return_value=Path(directory) / "cache"),
mock.patch.object(ota.shutil, "which", return_value="cargo"),
mock.patch.object(ota.sys.stdin, "isatty", return_value=False),
self.assertRaisesRegex(ota.OtaError, "separate interactive approval"),
):
ota.preflight_inputs(args)
meshcli.assert_not_called()
def test_simulated_main_stages_bootloader_and_restores_radios(self):
package = self.package
destination_key, source_key = "a1" * 32, "b2" * 32
controller = mock.Mock()
controller._run.return_value = [{
destination_key: {"adv_name": "remote", "public_key": destination_key},
source_key: {"adv_name": "source", "public_key": source_key},
}]
normal = ota.RadioSettings(910.525, 62.5, 7, 5, False)
controller.get_radio.return_value = normal
def remote_command(station, command, **_kwargs):
self.assertEqual(station, destination_key)
replies = {
"ota status": "OTA | no download | target:1234ABCD",
"ota ls": "Updates 1",
f"ota pull {package.manifest_id} flash": f"OK pulling mid={package.manifest_id} -> flash",
"ota bootloader status": (
"BL board=239A0029 target=23818A80 name=3401_DFU crc=12345678 abi=3 caps=0A "
f"| staged:ready mid={package.manifest_id} hash={package.image_hash[:8].hex()}"
),
}
if command not in replies:
self.fail(f"unexpected remote command: {command}")
return replies[command]
controller.remote_command.side_effect = remote_command
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
source = root / "boot.mota"
source.write_bytes(self.blob)
seeder = mock.Mock()
with contextlib.ExitStack() as stack:
for name in (
"require_command", "require_meshcli_version", "require_bootloader_tool_support", "preflight_source_cli",
"ensure_source_clock_gate_safe", "ensure_controller_clock_safe",
"verify_with_motatool", "arm_target_temp_radio",
"switch_controller_to_temp_radio", "source_cli_command",
):
stack.enter_context(mock.patch.object(ota, name))
stack.enter_context(mock.patch.object(ota, "read_source_name_bounded", return_value="source"))
stack.enter_context(mock.patch.object(ota, "read_source_rxps", return_value=ota.RxpsSettings(False, 0, 0)))
stack.enter_context(mock.patch.object(ota, "read_remote_rxps", return_value=ota.RxpsSettings(False, 0, 0)))
stack.enter_context(mock.patch.object(
ota, "query_target", return_value=replace(boot_target(package), name=destination_key),
))
stack.enter_context(mock.patch.object(ota, "read_lora_ota_participant_versions", return_value={}))
rehearsal = stack.enter_context(mock.patch.object(ota, "run_temp_radio_preflight"))
stack.enter_context(mock.patch.object(ota, "arm_source_temp_radio_once", return_value=True))
stack.enter_context(mock.patch.object(ota, "SeederProcess", return_value=seeder))
stack.enter_context(mock.patch.object(ota, "monitor_download"))
cleanup = stack.enter_context(mock.patch.object(ota, "shorten_target_temp_window"))
stack.enter_context(mock.patch.object(ota, "shorten_source_temp_window", return_value=True))
install = stack.enter_context(mock.patch.object(ota, "request_install"))
stack.enter_context(mock.patch.object(ota.time, "sleep"))
output = stack.enter_context(contextlib.redirect_stdout(io.StringIO()))
error = stack.enter_context(contextlib.redirect_stderr(io.StringIO()))
result = ota.main([
str(source), destination_key[:12], "--controller-serial", "controller",
"--source-serial", "source", "--password", "test-only",
"--no-install", "--yes", "--work-dir", str(root / "work"),
], controller_override=controller)
self.assertEqual(result, 0, error.getvalue())
self.assertIn("detected bootloader update", output.getvalue())
self.assertIn("NOT installed", output.getvalue())
rehearsal.assert_called_once()
seeder.start.assert_called_once()
cleanup.assert_called_once()
controller.set_radio.assert_called_once_with(normal, "restore controller radio after staging")
install.assert_not_called()
self.assertIn(
mock.call(destination_key, f"ota pull {package.manifest_id} flash", retry=False),
controller.remote_command.call_args_list,
)
class StationSelectionTests(unittest.TestCase):
key = "3ee21f453f8f" + "ab" * 26
name = "Ashport \U0001f4e1"
def args(self, selector):
return argparse.Namespace(
target=selector, relay_values=[], source_contact_value=None,
source_shares_controller=True,
)
def controller(self):
controller = mock.Mock()
controller._run.return_value = [{self.key: {"public_key": self.key, "adv_name": self.name}}]
return controller
def test_names_full_keys_and_prefixes_bind_to_one_full_key(self):
for selector in (self.name, self.name.upper(), self.key, self.key.upper(), self.key[:12]):
with self.subTest(selector=selector):
args = self.args(selector)
controller = self.controller()
with contextlib.redirect_stdout(io.StringIO()):
ota.bind_contact_selectors(controller, args)
self.assertEqual(args.target, self.key)
controller._run.assert_called_once_with(["contacts"], "resolve OTA station identifiers")
controller.remote_command.assert_not_called()
def test_missing_and_ambiguous_identifiers_are_refused(self):
controller = self.controller()
with self.assertRaisesRegex(ota.OtaError, "no contact"):
ota.bind_contact_selectors(controller, self.args("missing"))
second = self.key[:-2] + "cd"
controller._run.return_value[0][second] = {"public_key": second, "adv_name": self.name}
for selector in (self.name, self.key[:12]):
with self.assertRaisesRegex(ota.OtaError, "ambiguous"):
ota.bind_contact_selectors(controller, self.args(selector))
def test_same_radio_cannot_be_target_and_relay_under_aliases(self):
args = self.args(self.name)
args.relay_values = [(self.key[:12], "unused")]
with contextlib.redirect_stdout(io.StringIO()), self.assertRaisesRegex(ota.OtaError, "different radios"):
ota.bind_contact_selectors(self.controller(), args)
def test_remote_reply_accepts_key_selector_with_emoji_name(self):
for selector in (self.name, self.key, self.key[:12]):
controller = object.__new__(ota.Controller)
controller.reply_timeout = 5
controller._authenticated_targets = {selector}
controller._run_marked = mock.Mock(return_value=(
[{"adv_name": self.name, "public_key": self.key}],
[{"txt_type": 1, "text": "OTA | no download | target:1234ABCD", "pubkey_prefix": self.key[:12]}],
))
with contextlib.redirect_stdout(io.StringIO()):
self.assertIn("OTA", controller._remote_command_once(selector, "ota status", "unused"))
def test_clock_and_ack_accept_key_selector(self):
controller = object.__new__(ota.Controller)
controller._execute = mock.Mock(side_effect=lambda commands, _label: argparse.Namespace(stdout=(
commands[1] + '\n{"adv_name": "renamed", "public_key": "' + self.key + '"}\n1800000000\n'
)))
self.assertEqual(controller.get_contact_clock(self.key, self.key), 1800000000)
controller._run_marked = mock.Mock(return_value=([], [
{"adv_name": "renamed", "public_key": self.key},
{"expected_ack": "12345678"}, {"code": "12345678"},
]))
controller.prove_contact_ack(self.key, self.key, "proof")
if __name__ == "__main__":
unittest.main()
+206
View File
@@ -0,0 +1,206 @@
"""Automatic host-tool repair tests. All installer execution is mocked."""
import argparse
import contextlib
import io
import os
from pathlib import Path
import subprocess
import tempfile
import unittest
from unittest import mock
import lora_ota as ota
from test_lora_ota_bootloader import boot_blob
class MotatoolRepairTests(unittest.TestCase):
def setUp(self):
directory = tempfile.TemporaryDirectory()
self.addCleanup(directory.cleanup)
self.folder = Path(directory.name)
self.root = self.folder / "private-install"
self.binary = self.root / "bin" / ("motatool.exe" if os.name == "nt" else "motatool")
self.probe = self.folder / "boot.mota"
self.probe.write_bytes(boot_blob())
self.args = argparse.Namespace(motatool="old-motatool", yes=True, package=self.probe)
self.failure = ota.OtaError("unsupported format_ver 3")
stack = contextlib.ExitStack()
self.addCleanup(stack.close)
stack.enter_context(mock.patch.object(ota, "motatool_repair_root", return_value=self.root))
stack.enter_context(mock.patch.object(ota.shutil, "which", return_value="cargo"))
stack.enter_context(mock.patch.object(ota.sys.stdin, "isatty", return_value=True))
self.prompt = stack.enter_context(mock.patch("builtins.input", return_value="yes"))
self.check = stack.enter_context(mock.patch.object(ota, "check_bootloader_tool"))
self.install = stack.enter_context(mock.patch.object(ota.subprocess, "run", side_effect=self.fake_install))
self.output = stack.enter_context(contextlib.redirect_stdout(io.StringIO()))
def fake_install(self, command, **kwargs):
self.binary.parent.mkdir(parents=True, exist_ok=True)
self.binary.write_bytes(b"test-only installer output, not executable")
return subprocess.CompletedProcess(command, 0)
def repair(self):
ota.offer_motatool_repair(self.args, self.probe, self.failure)
def test_consent_installs_pinned_private_tool_rechecks_and_selects_it(self):
with mock.patch.dict(os.environ, {"MESHCORE_ADMIN_PASSWORD": "never-pass-to-build"}):
self.repair()
self.prompt.assert_called_once()
self.install.assert_called_once()
command = self.install.call_args.args[0]
self.assertEqual(command, ota.motatool_repair_command(self.root))
self.assertIn(ota.MOTATOOL_REPAIR_REPOSITORY, command)
self.assertIn(ota.MOTATOOL_REPAIR_REVISION, command)
self.assertIn("--locked", command)
self.assertNotIn("MESHCORE_ADMIN_PASSWORD", self.install.call_args.kwargs["env"])
self.assertEqual(self.install.call_args.kwargs["stdin"], subprocess.DEVNULL)
self.assertEqual(self.install.call_args.kwargs["timeout"], ota.MOTATOOL_REPAIR_TIMEOUT_SECONDS)
self.check.assert_called_once_with(str(self.binary), self.probe)
self.assertEqual(self.args.motatool, str(self.binary))
self.assertIn("Existing motatool installations and PATH will not be changed", self.output.getvalue())
def test_decline_or_blank_never_creates_install_directory(self):
for answer in ("", "no", "n", "anything else"):
with self.subTest(answer=answer):
self.prompt.return_value = answer
with self.assertRaisesRegex(ota.OtaError, "declined"):
self.repair()
self.assertFalse(self.root.exists())
self.install.assert_not_called()
self.assertEqual(self.args.motatool, "old-motatool")
def test_eof_does_not_approve_install(self):
self.prompt.side_effect = EOFError
with self.assertRaisesRegex(ota.OtaError, "declined"):
self.repair()
self.assertFalse(self.root.exists())
self.install.assert_not_called()
def test_yes_flag_never_approves_repair_in_noninteractive_run(self):
self.assertTrue(self.args.yes)
with mock.patch.object(ota.sys.stdin, "isatty", return_value=False):
with self.assertRaisesRegex(ota.OtaError, "--yes does not approve"):
self.repair()
self.prompt.assert_not_called()
self.install.assert_not_called()
self.assertIn("Manual install command:", self.output.getvalue())
self.assertIn("--motatool", self.output.getvalue())
self.assertFalse(self.root.exists())
def test_missing_cargo_gives_prerequisite_without_installing_anything(self):
with mock.patch.object(ota.shutil, "which", return_value=None):
with self.assertRaisesRegex(ota.OtaError, "https://rustup.rs"):
self.repair()
self.install.assert_not_called()
self.prompt.assert_not_called()
self.assertFalse(self.root.exists())
def test_cargo_failure_stops_and_preserves_original_selection(self):
self.install.side_effect = None
self.install.return_value = subprocess.CompletedProcess([], 101)
with self.assertRaisesRegex(ota.OtaError, "Cargo exit 101"):
self.repair()
self.check.assert_not_called()
self.assertEqual(self.args.motatool, "old-motatool")
def test_cargo_timeout_or_launch_failure_stops(self):
for error in (OSError("cannot start compiler"), subprocess.TimeoutExpired("cargo", 3600)):
with self.subTest(error=error):
self.install.side_effect = error
with self.assertRaisesRegex(ota.OtaError, "repair failed"):
self.repair()
self.check.assert_not_called()
self.assertEqual(self.args.motatool, "old-motatool")
def test_installer_success_without_binary_is_not_success(self):
self.install.side_effect = None
self.install.return_value = subprocess.CompletedProcess([], 0)
with self.assertRaisesRegex(ota.OtaError, "did not produce"):
self.repair()
self.check.assert_not_called()
self.assertEqual(self.args.motatool, "old-motatool")
def test_failed_postinstall_check_does_not_select_bad_tool(self):
self.check.side_effect = ota.OtaError("still unsupported")
with self.assertRaisesRegex(ota.OtaError, "still failed bootloader verification"):
self.repair()
self.assertEqual(self.args.motatool, "old-motatool")
def test_default_tool_reuses_rechecked_cache_without_download_or_prompt(self):
self.args.motatool = "motatool"
self.fake_install([])
with mock.patch.object(ota.sys.stdin, "isatty", return_value=False):
self.repair()
self.prompt.assert_not_called()
self.install.assert_not_called()
self.check.assert_called_once_with(str(self.binary), self.probe)
self.assertEqual(self.args.motatool, str(self.binary))
def test_custom_tool_is_not_silently_replaced_even_with_valid_cache(self):
self.fake_install([])
self.prompt.return_value = "no"
with self.assertRaisesRegex(ota.OtaError, "declined"):
self.repair()
self.assertIn("cached motatool", self.prompt.call_args.args[0])
self.assertEqual(self.args.motatool, "old-motatool")
self.install.assert_not_called()
def test_approved_cached_tool_needs_no_cargo(self):
self.fake_install([])
with mock.patch.object(ota.shutil, "which", return_value=None):
self.repair()
self.install.assert_not_called()
self.assertEqual(self.check.call_count, 2)
self.assertEqual(self.args.motatool, str(self.binary))
def test_bad_cache_requires_new_consent_and_recheck(self):
self.args.motatool = "motatool"
self.fake_install([])
self.check.side_effect = [ota.OtaError("old cached tool"), None]
self.repair()
self.prompt.assert_called_once()
self.install.assert_called_once()
self.assertEqual(self.check.call_count, 2)
def test_working_tool_does_not_offer_or_install(self):
with mock.patch.object(ota, "offer_motatool_repair") as offer:
ota.require_bootloader_tool_support(self.args)
offer.assert_not_called()
self.install.assert_not_called()
def test_missing_or_incompatible_tool_reaches_the_offer(self):
self.check.side_effect = self.failure
with mock.patch.object(ota, "offer_motatool_repair") as offer:
ota.require_bootloader_tool_support(self.args)
offer.assert_called_once_with(self.args, self.probe, self.failure)
def test_repair_failure_in_main_never_contacts_radios(self):
self.check.side_effect = self.failure
self.prompt.return_value = "no"
with (
mock.patch.object(ota, "Controller") as controller,
mock.patch.object(ota, "source_cli_command") as source,
contextlib.redirect_stderr(io.StringIO()),
):
status = ota.main([
str(self.probe), "remote", "--controller-serial", "controller",
"--source-serial", "source", "--no-install", "--yes",
])
self.assertEqual(status, 2)
self.prompt.assert_called_once()
self.install.assert_not_called()
controller.assert_not_called()
source.assert_not_called()
def test_repaired_binary_is_selected_before_meshcli_preflight(self):
self.check.side_effect = [self.failure, None]
args = ota.build_parser().parse_args([str(self.probe), "remote", "--no-install"])
with mock.patch.object(ota, "require_meshcli_version") as meshcli:
ota.preflight_inputs(args)
self.assertEqual(args.motatool, str(self.binary))
meshcli.assert_called_once()
if __name__ == "__main__":
unittest.main()
+3 -1
View File
@@ -82,4 +82,6 @@ from the generated application target table.
This library does not authorize a device update. A capable node will only arm
such a v3 package through the exact manual confirmation described in
[`docs/ota_nrf52_bootloader_update.md`](../../docs/ota_nrf52_bootloader_update.md).
The ordinary `tools/lora_ota` runner rejects bootloader packages.
The `tools/lora_ota` runner accepts verified bootloader packages only with
`--no-install`, checks the destination's bootloader-specific identity and
capabilities, and leaves installation to that explicit manual confirmation.