Add shared-slot nRF52 bootloader updates

This commit is contained in:
mikecarper committed 2026-08-21 00:55:01 -07:00
1 parent 75a2b8f002
commit 177eb06745
43 files changed
+1647 -211

No files matched your search

+3
View File
@@ -8,6 +8,9 @@
"extra_flags": "-DNRF52840_XXAA",
"f_cpu": "64000000L",
"hwids": [
["0x239A", "0x8071"],
["0x239A", "0x0071"],
["0x239A", "0x8072"],
["0x239A", "0x4405"],
["0x239A", "0x0029"],
["0x239A", "0x002A"]
+21
View File
@@ -2394,6 +2394,26 @@ apply_nrf52_lora_ota_build_recipe() {
if ! pio_env_option_contains "$pio_env_name" extra_scripts "tools/mota/pio_endf.py"; then
append_platformio_extra_script "post:tools/mota/pio_endf.py"
fi
if supports_nrf52_internal_bootloader_update "$env_name"; then
export MESHCORE_NRF52_INTERNAL_BOOTLOADER_UPDATE=1
else
unset MESHCORE_NRF52_INTERNAL_BOOTLOADER_UPDATE
fi
}
# Exact-board OTAFIX manifests currently emitted for shared internal,
# app-preserving bootloader staging. Keep this list narrower than generic nRF52
# OTA support: external QSPI/SD, ExtraFS Companions, Ethernet, and
# unqualified/full-sensor roles must not advertise this privileged path. The
# same inventory is consumed by the nrf52_base pre-script so direct explicit-env
# and build.sh release builds cannot silently differ.
supports_nrf52_internal_bootloader_update() {
[ "${PIO_ENV_PLATFORM_BY_NAME[$1]:-}" = "NRF52_PLATFORM" ] || return 1
[ "${PIO_ENV_QSPI_OTA_BY_NAME[$1]:-0}" = "0" ] || return 1
[ "${PIO_ENV_SD_OTA_BY_NAME[$1]:-0}" = "0" ] || return 1
is_lora_ota_build "$1" || return 1
grep -Fqx -- "$1" tools/mota/nrf52_internal_bootloader_targets.txt
}
apply_lora_ota_override() {
@@ -2890,6 +2910,7 @@ build_firmware() {
restore_platformio_build_flags "$had_platformio_build_flags" "$original_platformio_build_flags"
unset MESHCORE_ESP32_FULL_BUILD
unset MESHCORE_COMPANION_RADIO_FULL
unset MESHCORE_NRF52_INTERNAL_BOOTLOADER_UPDATE
if [ "$had_platformio_build_unflags" -eq 1 ]; then
export PLATFORMIO_BUILD_UNFLAGS="$original_platformio_build_unflags"
else
+4 -4
View File
@@ -165,8 +165,8 @@ fix, no WiFi connection, an inactive bridge, or an nRF52 bootloader without
| LoRa OTA | [`ota ls`; `ota neighbors`; `ota nbrs`; `ota updates`; `ota n`](ota_protocol.md#11-cli-surface-otaclicpp) | LoRa OTA build | No | No | Yes |
| LoRa OTA | [`ota get`; `ota pull`; `ota download`](ota_protocol.md#11-cli-surface-otaclicpp) | LoRa OTA build; internal-store nRF52 installs in-place deltas, while matched SD/QSPI nRF52 also accepts full images | No | No | Yes |
| LoRa OTA | [`ota install`; `ota apply`; `ota applydelta`](ota_protocol.md#11-cli-surface-otaclicpp) | Compatible bootloader and completed update | No | No | Yes |
| LoRa OTA | [`ota rescue install <base_hash16>`](ota_protocol.md#12-apply-bootloader-contract) | Internal-flash nRF52 LoRa OTA build with failed app-side EndF validation | No | No | Feature |
| LoRa OTA | [`ota bootloader`; `ota bootloader install <MID8> <HASH16>`](ota_nrf52_qspi.md#explicit-xiao-bootloader-updates-over-lora) | Boot-update-capable XIAO-module QSPI repeater with ABI-3 exact-board OTAFIX | No | No | Feature |
| LoRa OTA | [`ota rescue install <base_hash16>`](ota_protocol.md#12-apply-bootloader-contract) | Legacy internal-flash nRF52 LoRa OTA build with failed app-side EndF validation; shared bootloader-update builds require valid EndF | No | No | Feature |
| LoRa OTA | [`ota bootloader`; `ota bootloader install <MID8> <HASH16>`](ota_nrf52_bootloader_update.md) | Allowlisted internal-flash or XIAO-QSPI nRF52 repeater with ABI-3 exact-board OTAFIX | No | No | Feature |
| LoRa OTA | [`ota cancel`; `ota drop`; `ota stop`](ota_protocol.md#11-cli-surface-otaclicpp) | LoRa OTA build | No | No | Yes |
| LoRa OTA | [`ota announce`; `ota adv`](ota_protocol.md#11-cli-surface-otaclicpp) | LoRa OTA build | No | No | Yes |
| LoRa OTA | [`ota self`; `ota id`](ota_protocol.md#11-cli-surface-otaclicpp) | Firmware with EndF trailer | No | No | Yes |
@@ -325,8 +325,8 @@ fix, no WiFi connection, an inactive bridge, or an nRF52 bootloader without
| LoRa OTA | [`ota ls`; `ota neighbors`; `ota nbrs`; `ota updates`; `ota n`](ota_protocol.md#11-cli-surface-otaclicpp) | LoRa OTA build | No | No | Yes | Yes | Yes |
| LoRa OTA | [`ota get`; `ota pull`; `ota download`](ota_protocol.md#11-cli-surface-otaclicpp) | LoRa OTA build | No | No | Yes | Yes | Yes |
| LoRa OTA | [`ota install`; `ota apply`; `ota applydelta`](ota_protocol.md#11-cli-surface-otaclicpp) | Compatible completed update | No | No | Yes | Yes | Yes |
| LoRa OTA | [`ota rescue install <base_hash16>`](ota_protocol.md#12-apply-bootloader-contract) | Internal-flash nRF52 LoRa OTA build with failed app-side EndF validation | No | No | Feature | No | No |
| LoRa OTA | [`ota bootloader`; `ota bootloader install <MID8> <HASH16>`](ota_nrf52_qspi.md#explicit-xiao-bootloader-updates-over-lora) | nRF52 XIAO QSPI only | No | No | No | No | No |
| LoRa OTA | [`ota rescue install <base_hash16>`](ota_protocol.md#12-apply-bootloader-contract) | Legacy internal-flash nRF52 LoRa OTA build with failed app-side EndF validation; shared bootloader-update builds require valid EndF | No | No | Feature | No | No |
| LoRa OTA | [`ota bootloader`; `ota bootloader install <MID8> <HASH16>`](ota_nrf52_bootloader_update.md) | Allowlisted nRF52 internal/QSPI repeater feature | No | No | No | No | No |
| LoRa OTA | [`ota cancel`; `ota drop`; `ota stop`](ota_protocol.md#11-cli-surface-otaclicpp) | LoRa OTA build | No | No | Yes | Yes | Yes |
| LoRa OTA | [`ota announce`; `ota adv`](ota_protocol.md#11-cli-surface-otaclicpp) | LoRa OTA build | No | No | Yes | Yes | Yes |
| LoRa OTA | [`ota self`; `ota id`](ota_protocol.md#11-cli-surface-otaclicpp) | Firmware with EndF trailer | No | No | Yes | Yes | Yes |
+1
View File
@@ -12,6 +12,7 @@ Below are a few quick start guides.
- [Easy LoRa OTA: ESP32 and nRF52 firmware updates](./ota_easy.md)
- [Scripted LoRa OTA: Bash and PowerShell](./lora_ota_automation.md)
- [nRF52 repeater OTA with external QSPI](./ota_nrf52_qspi.md)
- [nRF52 bootloader updates over LoRa](./ota_nrf52_bootloader_update.md)
- [MeshTower V2 microSD self-updates](./ota_meshtower_v2_sdcard.md)
- [GPS Tracking](./gps_tracking.md)
- [Companion Protocol](./companion_protocol.md)
+4 -4
View File
@@ -16,10 +16,10 @@ controller, and check the rebooted node.
The script cannot install the destination's first OTA-capable firmware. It is
also deliberately application-only: it rejects both v3 bootloader containers
and a v2 container carrying the bootloader flag, and it never sends the
privileged install command. Do the first nRF52 bootloader installation over USB;
an already-capable XIAO QSPI repeater may later use the separate, manual
[`ota bootloader install`](ota_nrf52_qspi.md#explicit-xiao-bootloader-updates-over-lora)
workflow.
privileged install command. Do the first nRF52 bootloader installation over USB.
An already-capable, explicitly supported internal-flash or XIAO QSPI repeater
may later use the separate manual
[`ota bootloader install`](ota_nrf52_bootloader_update.md) workflow.
## Required topology
+8 -4
View File
@@ -334,18 +334,22 @@ shows an older row. Select `[same target]`: it can say `full` for ESP32 and exte
or `delta` for any supported nRF52 target. Do not select `[unsupported]` (for example, a source's self-served
full image on an internal-staging nRF52).
Rows labelled `bootloader` are also outside this ordinary application flow:
they are never automatic and `ota install` refuses them. Only an already-capable
XIAO QSPI repeater can use the separate
[explicit bootloader workflow](ota_nrf52_qspi.md#explicit-xiao-bootloader-updates-over-lora).
they are never automatic and `ota install` refuses them. Only an
already-capable, allowlisted internal-flash or XIAO-QSPI nRF52 target can use
the separate [explicit bootloader workflow](ota_nrf52_bootloader_update.md).
Use the row's stable eight-hex manifest ID rather than its changing list position:
```text
ota pull 838B8169 flash
```
If an internal-flash nRF52 reports `no EndF`, only a row marked `[rescue]` is eligible. Current rescue-capable
If a legacy internal-flash nRF52 reports `no EndF`, only a row marked `[rescue]` is eligible. Current rescue-capable
firmware requires `ota pull <mid8> flash rescue`, followed after completion by
`ota rescue install <base_hash16>`. Older running firmware without those commands must be recovered over USB.
Shared-internal bootloader-update builds are stricter: without a valid live
`EndF`, every internal pull is refused before erase because the normal
application may extend through `0xED000`. Recover those builds over USB/BLE DFU
or SWD instead of relying on the older 608 KiB rescue estimate.
Monitor the transfer:
+150
View File
@@ -0,0 +1,150 @@
# nRF52 bootloader updates over LoRa
Selected nRF52840 repeater LoRa-OTA builds can replace their matching OTAFIX
bootloader without replacing the running application. This is a privileged
maintenance path, not a normal firmware update. It cannot bootstrap a stock or
older bootloader: install the exact ABI-3 self-update-capable OTAFIX build once
over USB/BLE DFU or SWD.
## Storage layouts
| Layout | Application limit | Staged boot package | Work area | Handoff / capability |
| --- | ---: | ---: | ---: | --- |
| XIAO-module raw QSPI | below `0xE0000` | external QSPI offset 0 | dedicated internal `0xE0000..0xEA000` scratch | source `0x51`, flags `0x0E` |
| Qualified internal-flash target | normal `0xED000` limit | shared internal slot, exact start `0xE2000` | the same eleven-page slot; no second reservation | source `0xED`, flags `0x0A` |
The internal path does not change the application linker or permanently set
aside separate app-OTA, boot-package, and scratch regions. The ordinary
bottom-aligned internal store holds one container at a time: either an
application delta or a bootloader package.
The exact bootloader container is 41,330 bytes: 365 bytes of signed mOTA
metadata, a 40 KiB payload, and the five-byte trailer. Below the normal
`0xED000` store ceiling it bottom-aligns at `0xE2000`. Admission requires a
hash-valid live `EndF` proving the current application, including its trailer,
ends at or before `0xE2000`. OTAFIX then reads each source window before
erasing and compacts the payload forward in place to the page-aligned raw range
`0xE2000..0xEC000`; it verifies every page and the whole image before asking the
MBR to copy that image over `0xF4000..0xFE000`.
An ordinary application delta can be smaller or larger than this eleven-page
shape. It bottom-aligns dynamically below `0xED000` and may begin below
`0xE2000`; its detools workspace must stop at its actual container start, while
the reconstructed application must stop below `0xED000`. The two package kinds
are mutually exclusive because they use the same store.
For internal-self-update builds, an absent or corrupt live `EndF` disables
**all** internal staging before the first erase. The older 608 KiB rescue
estimate is unsafe when a normally linked application may extend to
`0xED000`. Builds without this feature retain the legacy rescue behavior.
## Internal-flash target inventory
The release builder consumes the allowlist in
`tools/mota/nrf52_internal_bootloader_targets.txt`. Ten names are also literal
PlatformIO environments and are enabled automatically when built directly.
The other eleven are release aliases assembled from a base environment plus
the lean OTA overlay; build them through `build.sh`, which passes the same
allowlist decision to the common pre-build guard. These lean repeater/bridge
targets have no OTA-owned SD/QSPI store and have an exact curated OTAFIX
manifest identity:
| Build target(s) | Installed OTAFIX identity | Boot target ID |
| --- | --- | ---: |
| `Heltec_tower_v2_repeater_lora_ota_no_external_sensors` | `239A0071 / TOWER_V2_OTA` | `1150F50E` |
| T096 lean repeater and RS232 bridge | `239A0071 / T096_DFU` | `42354C85` |
| `Heltec_t1_repeater_lora_ota_no_external_sensors` | `239A0071 / T1_DFU` | `FC556FFC` |
| T114 display and without-display lean repeaters | `239A0071 / T114_DFU` | `0C3F2902` |
| `Mesh_pocket_repeater_lora_ota_no_external_sensors` | `239A0071 / MESH_POCKET_OTA` | `059277F4` |
| `KeepteenLT1_repeater_lora_ota_no_external_sensors` | `239A00B3 / KeepteenLT1_OTA` | `DB2E7B51` |
| `Minewsemi_me25ls01_repeater_lora_ota_no_external_sensors` | `239A0029 / MX25_DFU` | `026AA982` |
| `ProMicro_repeater_lora_ota_no_external_sensors` | `239A00B3 / PROM_DFU` | `AF79E8CC` |
| `t1000e_repeater_lora_ota_no_external_sensors` | `28860057 / T1KE_DFU` | `E6F5F03F` |
| `ThinkNode_M3_repeater_lora_ota_no_external_sensors` | `239A00DA / TNM3_DFU` | `0CA41DB2` |
| `RAK_3401_repeater_lora_ota_no_external_sensors` | `239A0029 / 3401_DFU` | `23818A80` |
| RAK4631 lean repeater and both lean RS232 bridges | `239A0029 / 4631_DFU` | `2D0DF000` |
| GAT562 30S/Tracker Pro/EVB Pro and R1Neo lean carrier aliases | `239A0029 / 4631_DFU` | `2D0DF000` |
| `RAK_WisMesh_Tag_repeater_lora_ota_no_external_sensors` | `239A0029 / RTAG_DFU` | `C72E9C9C` |
Board IDs are not globally unique. For generic targets, the signed hardware ID
is the exact NUL-padded 32-byte value
`NRF_BL_<BOARD_ID>_<DEVICE_NAME>`. The wire target is the little-endian first
four SHA-256 bytes of all 32 padded bytes. The installed and candidate embedded
manifest pairs must match exactly. XIAO retains its deployed
`XIAO_BL_28860044` / `XIAO_BL_28860045` identity and raw board-ID target.
The Python reference builder and release tooling audit these boot targets for
duplicates and collisions with application target IDs. Generic image parsing
can inspect a future canonical identity, but signing/building a package fails
until that exact identity is in the qualified inventory.
The `no_external_sensors` profiles omit optional/add-on sensor and GPS packages
to preserve flash headroom. Board-integrated GPS can be retained by a board's
recipe. The RAK3401 lean target specifically omits RAK12501/add-on GPS; use the
ordinary full-sensor target when that module is required.
Boards with onboard external flash are not silently redirected to internal
staging. Mesh Solar, Nano G2 Ultra, T-Impulse Plus, ThinkNode M8, T-Echo
Lite/Card, MeshTracker X1, and Wio WM1110 have board-specific QSPI hardware and
need a separately matched QSPI path where available. Full Companions,
SD/QSPI/ExtraFS roles, Ethernet roles, source-only roles, and unqualified
full-sensor roles are excluded.
This internal layout is limited to nRF52840 devices with 1 MiB internal flash,
the exact S140 v6/v7 map, and the 40 KiB boot region at
`0xF4000..0xFE000`. nRF52833 and smaller nRF52 parts cannot provide that map
plus a non-overlapping 41,330-byte live staging slot, so configuration fails
closed instead of selecting smaller or overlapping geometry.
## Explicit install workflow
Check the installed identity and capability marker:
```text
ota bootloader
```
The response must show a CRC-valid exact identity, ABI 3 or newer, full codec,
and exact flags `0x0A` for internal shared storage or `0x0E` for XIAO QSPI. A
bootloader row is visible in `ota ls`, but it is never autofetched or
autoinstalled. Fetch its exact MID, then explicitly arm it:
```text
ota pull <MID8> flash
# wait for ota status to report the download ready
ota bootloader
ota bootloader install <MID8> <HASH16>
```
Copy both confirmation values from the second `ota bootloader` response.
Ordinary `ota install` rejects a bootloader package; the bootloader command
rejects an application package. The FULL-codec exception exists only for that
manual bootloader MID. Ordinary application FULL remains disabled on an
internal single-slot node, bootloader autofetch remains off, and a partial
bootloader package is not automatically resumed after an application reboot.
Before writing `APRV`, the application authenticates and authorizes the
package: exact v3 geometry, trusted Ed25519 signer, signed/embedded identity,
one unambiguous capability marker, embedded CRC, sane vectors, complete
Merkle/payload/image hashes, safe live placement, and the typed MID/hash
confirmation. OTAFIX consumes that application-written authorization and then
independently rechecks the safety/integrity subset: strict v3 structure,
canonical identity/capabilities, vectors, full payload SHA, embedded manifest
CRC, live `EndF` no-overlap geometry, page readback, and final copy hash. It
does not re-run Ed25519, the signer allowlist, Merkle leaves/root, or the typed
operator confirmation. Success is reported as `blup:C8`.
## Failure behavior
The feature fails closed when the MCU/map is wrong, current valid `EndF`
headroom is insufficient, external/ExtraFS storage owns the target, the exact
installed capability marker is absent or ambiguous, identity cannot be derived
unambiguously, or any package check fails. Before compaction, the application
and bootloader are unchanged. OTAFIX consumes the trigger/approval before the
first in-place compaction erase, so an interrupted conversion cannot retry a
partly converted package; it boots the untouched application and old
bootloader instead. Use USB/BLE DFU or SWD for initial provisioning and local
recovery.
For XIAO and ordinary external-QSPI details, see
[nRF52 repeater LoRa OTA with external QSPI](ota_nrf52_qspi.md).
+9 -2
View File
@@ -176,6 +176,11 @@ enforces the package's hardware identity before approval.
## Explicit XIAO bootloader updates over LoRa
This section documents the deployed XIAO raw-QSPI layout. Curated nRF52840
targets without external staging use a separate internal-flash layout with the
same explicit operator safety model; see
[nRF52 bootloader updates over LoRa](ota_nrf52_bootloader_update.md).
Bootloader delivery is available only when all of these are already true:
- the application is an `OTA_QSPI_BOOTLOADER_UPDATE` XIAO-module repeater build
@@ -212,8 +217,10 @@ continuity. A signed older bootloader that would remove remote update support
is refused.
After the reply drains, GPREGRET `0x6B` and GPREGRET2 `0x51` enter the special
OTAFIX path. OTAFIX independently rechecks the package before using the
reserved scratch bank to replace its own `0xF4000..0xFE000` region. The running
OTAFIX path. `APRV` carries the app's signature/allowlist and explicit operator
authorization decision. OTAFIX independently rechecks the strict structure,
identity/capabilities, vectors, payload SHA, embedded CRC, and copy hashes
before using the reserved scratch bank to replace its own `0xF4000..0xFE000` region. The running
application remains preserved; a rejected candidate returns to it unchanged.
Post-reboot `ota status` reports bootloader-update diagnostics as `blup:C0` to
`blup:CF` (`blup:C8` is success), separately from ordinary application `blrc`.
+35 -11
View File
@@ -62,7 +62,7 @@ the authoritative reference for byte-level details.
| `EndF` marker | `45 6E 64 46` | `EndF` |
| `hash_algo` (sha2-256) | `0x12` | multihash code |
| application `format_ver` | `0x02` | ordinary full/delta application package |
| bootloader `format_ver` | `0x03` | privileged XIAO bootloader package only |
| bootloader `format_ver` | `0x03` | privileged exact-identity nRF52 bootloader package only |
| `approval` = not approved | `FF FF FF FF` | erased NOR word |
| `approval` = approved | `41 50 52 56` | `APRV` |
| `MFLAG_FULL` | `0x01` | flags bit0 |
@@ -260,17 +260,19 @@ cover `approval` or `leaves[]`:
- Bound to this image (lives in this `.mota`'s manifest, re-erased when a new `.mota` is staged).
- A **consent** marker, not a security primitive. Authenticity = `signature` + `image_hash` + `hw_id`.
### 4.3 Privileged XIAO bootloader package profile
### 4.3 Privileged nRF52 bootloader package profile
A v3 bootloader package has a deliberately narrow, non-extensible profile:
- flags exactly `FULL|SIGNED|BOOTLOADER`, `CODEC_FULL`, nonzero `fw_version`, zero `base_hash`;
- a raw payload and `image_size` of exactly `0xA000` (40 KiB), split into exactly forty 1024-byte blocks;
- `target_id` equal to the installed OTAFIX embedded board ID (`0x28860044` for XIAO nRF52840 or
`0x28860045` for XIAO nRF52840 Sense);
- signed `hw_id` exactly `XIAO_BL_28860044` or `XIAO_BL_28860045`, derived from that board ID;
- a sane nRF52840 vector table, a CRC-valid embedded manifest v1 with the exact padded device name
`XIAO_DFU`, and a `MOTABLDR` marker advertising ABI >= 3, full codec, QSPI, and boot-update continuity.
- a target derived from the installed CRC-valid embedded manifest identity. Deployed XIAO identities keep
raw board IDs `0x28860044`/`0x28860045`; generic identities use LE32(SHA-256(canonical padded hw32));
- signed `hw_id` exactly `XIAO_BL_28860044`/`XIAO_BL_28860045` for deployed XIAO, or the zero-padded
32-byte `NRF_BL_<BOARD_ID>_<DEVICE_NAME>` for a generic target;
- a sane nRF52840 vector table, exactly one CRC-valid embedded manifest v1 with the exact board/name pair,
and exactly one `MOTABLDR` marker advertising ABI >= 3, full codec, boot-update continuity, and the exact
storage flags for the application layout (`0x0E` XIAO QSPI or `0x0A` shared internal staging).
The incoming embedded identity must exactly match the installed CRC-valid bootloader identity. Both scans
consider every aligned structurally valid candidate so magic bytes in a literal pool cannot shadow the real
@@ -770,7 +772,7 @@ ota get | pull | download <mid8|#index> flash [rescue] | folder [validate]
fetch by stable mid8 (preferred) or current page index
ota install | apply | applydelta verify + approve + (ESP32) apply / (nRF52) reboot-to-bootloader
ota rescue install <base_hash16> internal-flash nRF52 only: recover from failed app-side EndF validation
ota bootloader [status] capable XIAO QSPI repeater: installed BL identity/caps + staged confirmation
ota bootloader [status] capable allowlisted nRF52 repeater: installed BL identity/caps + staged confirmation
ota bootloader install <MID8> <HASH16>
explicitly verify/arm one complete trusted v3 package; never automatic
ota cancel | drop | stop drop the current fetch session (frees the slot)
@@ -805,7 +807,9 @@ ota dev ... bring-up helpers (stage/recv/serve/verify)
- **nRF52 internal staging ceiling:** an internal-store application derives the ceiling from facts available
in every build, not a board-name list. A companion that actually links the internal ExtraFS datastore stays
below `0xD4000`; a default linker region or a role that does not mount ExtraFS can reclaim the unused
100 KiB through `0xED000`. The application uses the larger window only when the
100 KiB through `0xED000`. An internal bootloader-self-update target keeps that normal linker and ceiling;
it does not reserve a second boot-package or scratch region. The application uses a larger-than-legacy
window only when the
installed bootloader advertises the GPREGRET2 ceiling-handoff capability. The bootloader treats every
unknown/legacy handoff value as
`0xD4000`, and accepts a container only at the bottom-aligned position for the selected ceiling.
@@ -832,6 +836,9 @@ ota dev ... bring-up helpers (stage/recv/serve/verify)
fails, so a rescue-capable bridge can fetch its exact successor before invoking the guarded command. Such
a node must acknowledge the condition up front with `ota pull <mid8> flash rescue`; an ordinary flash pull
refuses before altering staged data. Firmware that predates both rescue commands still requires USB recovery.
Internal-bootloader-self-update builds are a stricter exception: because their ordinary linker may extend
through `0xED000`, an absent/corrupt live `EndF` disables every internal staging pull before erase instead
of trusting the legacy 608 KiB estimate.
- **MeshTower V2 SD nRF52:** the application stores a contiguous `/meshcore-ota.mota` on microSD and
publishes its raw sector range in a checksummed handoff record outside the MBR partition. The matching
bootloader reads the card without mounting FAT, supports either a full image or an in-place delta,
@@ -852,10 +859,27 @@ ota dev ... bring-up helpers (stage/recv/serve/verify)
`ota bootloader install <MID8> <HASH16>`. The app repeats the strict v3 geometry, installed/candidate
identity, vectors, embedded CRC/capabilities, complete payload/image hashes, signature, and trusted-key
gates before writing `APRV`. GPREGRET `0x6B` plus GPREGRET2 `0x51` hands the QSPI package to OTAFIX.
OTAFIX independently repeats all privileged checks, uses the scratch bank to preserve the running
application while replacing `0xF4000..0xFE000`, and reports boot-update results in GPREGRET2 `0xC0..0xCF`
`APRV` carries the app's authenticated and explicitly confirmed authorization decision; OTAFIX does not
repeat Ed25519/allowlist, Merkle, or typed operator confirmation. It independently rechecks the strict v3
structure, canonical identity/capabilities, vectors, payload SHA, embedded CRC, and scratch/copy hashes,
uses the scratch bank to preserve the running application while replacing `0xF4000..0xFE000`, and reports
boot-update results in GPREGRET2 `0xC0..0xCF`
(`0xC8` success). This mechanism cannot bootstrap a stock/older bootloader; install an ABI-3,
boot-update-capable exact-board OTAFIX over USB/BLE DFU or SWD once first.
- **Internal-flash bootloader self-update (explicit only):** curated nRF52840 lean repeater/bridge targets
without an OTA-owned SD/QSPI store share the normal bottom-aligned internal store below `0xED000`.
It holds either an ordinary app delta or the exact 41,330-byte v3 container, never both. The boot package
bottom-aligns at `0xE2000`; a hash-valid live `EndF` must prove the complete running image ends at or below
that address before the first erase. OTAFIX reads each source window before erasing and compacts the
payload forward in the same eleven pages to raw `0xE2000..0xEC000`; no separate scratch bank or special
application linker exists. GPREGRET `0x6B` plus GPREGRET2 `0xED` selects boot update, while ordinary app
apply uses GPREGRET `0x6A` plus the same storage source. Exact installed/candidate capability flags are
`0x0A` (`STAGE_CEILING|BOOT_UPDATE`). Ordinary deltas remain dynamically sized, may start below
`0xE2000`, and reconstruct only below the normal `0xED000` app ceiling. The same signature, explicit
confirmation, exact identity, vector, CRC, and single-marker rules as the XIAO path apply. Bootloader FULL
admission is isolated from ordinary application FULL policy, and privileged partials are never resumed
automatically after an application reboot. See
[the nRF52 bootloader-update guide](ota_nrf52_bootloader_update.md) for the exact target inventory.
A signature, when present, proves author authenticity and must pass the device allowlist. Unsigned v2
application packages remain installable when local policy permits them. A v3 bootloader package is always
+30 -19
View File
@@ -216,21 +216,22 @@ rejected; trusted signed images can auto-install only when that policy is enable
After it reboots, run `ota status` to confirm the new version.
### Updating a XIAO bootloader (advanced, explicit only)
### Updating an nRF52 bootloader (advanced, explicit only)
This is available only on the specially marked XIAO nRF52840/Sense QSPI
repeater builds after a one-time exact-board ABI-3 OTAFIX installation over
USB/BLE DFU or SWD. It is not the normal firmware update path. Check support
first:
This is available on specially marked no-external-flash nRF52840 lean
repeater/bridge builds and on the legacy XIAO nRF52840/Sense raw-QSPI builds,
after a one-time exact-board ABI-3 OTAFIX installation over USB/BLE DFU or
SWD. It is not the normal firmware update path. Check support first:
```text
ota bootloader
```
The reply must show a valid `XIAO_DFU` board identity plus ABI 3 and the QSPI /
boot-update capability bits. A bootloader package appears as `bootloader` in
`ota ls`. It is never downloaded or installed automatically, even if both OTA
automation settings are enabled. Use its stable ID explicitly:
The reply must show a CRC-valid installed identity plus ABI 3 and the exact
storage/boot-update capability bits for that build (`0x0A` for the shared
internal store or `0x0E` for XIAO raw QSPI). A bootloader package appears as
`bootloader` in `ota ls`. It is never downloaded or installed automatically,
even if both OTA automation settings are enabled. Use its stable ID explicitly:
```text
ota pull <MID8> flash
@@ -241,19 +242,25 @@ ota bootloader install <MID8> <HASH16>
Copy both confirmation values exactly from the second `ota bootloader` reply.
Ordinary `ota install` deliberately refuses this package. The privileged
command requires an exact 40 KiB candidate for the installed XIAO board, a
valid embedded identity/CRC and vector table, continued boot-update support,
and a valid signature from a key already in `ota key`'s trusted allowlist. It
preserves the running application while OTAFIX replaces itself; `blup:C8` in
post-reboot `ota status` means success. Any node lacking this command or those
capabilities must update its bootloader locally instead. See
[the nRF52 QSPI guide](ota_nrf52_qspi.md#explicit-xiao-bootloader-updates-over-lora)
for the complete safety contract.
command requires an exact 40 KiB candidate payload in the fixed 41,330-byte
container, a valid exact embedded identity/CRC and vector table, continued
boot-update support, and a valid signature from a key already in `ota key`'s
trusted allowlist. It preserves the running application while OTAFIX replaces
itself; `blup:C8` in post-reboot `ota status` means success. Any node lacking
this command or those capabilities must update its bootloader locally instead.
On an internal-flash target, the package shares the ordinary store below
`0xED000` and bottom-aligns at `0xE2000`; there is no separate reserved scratch
bank. A valid live `EndF` must prove the current image ends at or below that
address before any page is erased. If `EndF` is missing/corrupt or the app is
too large, the pull is refused and local DFU/SWD is required.
See [the nRF52 bootloader-update guide](ota_nrf52_bootloader_update.md) for the
complete target inventory, storage layouts, and safety contract.
### 5. If something goes wrong
- A download that stalls or gets interrupted just **resumes** later, or you can `ota cancel` and try again.
- An internal-flash **nRF52** that still runs but reports `no EndF` can use the pre-provisioned rescue path
- A legacy app-only internal-flash **nRF52** that still runs but reports `no EndF` can use the pre-provisioned rescue path
if its physical EndF is intact and only app-side validation is failing. Fetch the exact `[rescue]`
in-place delta with an explicit acknowledgement, obtain its 16-hex-digit `base_hash` from the package
metadata, then run:
@@ -269,6 +276,10 @@ for the complete safety contract.
the running app and rejects a wrong base before writing the app. If the physical EndF is absent or the
rescue commands were not already in the running firmware, recover over USB.
Release chains should put this command in their first bridge and keep it in every bridge after that.
The shared-internal bootloader-update builds are intentionally excluded:
without valid live `EndF`, they refuse every internal pull before erase
because their normal application can extend through `0xED000`. Recover one
of those builds over USB/BLE DFU or SWD.
- If an **install** fails, the node won't boot a broken image - it lands in **recovery mode**:
- **nRF52:** it appears as a USB drive; drag a known-good firmware `.uf2` for that exact board onto it
to recover.
@@ -398,7 +409,7 @@ that only contains what changed). You get them by:
| Download a listed update for installation | `ota get <mid8> flash` |
| Cancel a download | `ota cancel` |
| Install a finished download | `ota install` |
| Recover app-side `no EndF` on internal nRF52 | `ota rescue install <base_hash16>` |
| Recover app-side `no EndF` on a legacy app-only internal nRF52 | `ota rescue install <base_hash16>` |
| Turn on auto-download | `ota config autofetch any` |
| Turn on auto-install (trusted only) | `ota config autoinstall trusted` |
| Trust a signer | `ota key add <hex>` |
+1
View File
@@ -116,6 +116,7 @@ platform_packages =
framework-arduinoadafruitnrf52 @ https://github.com/meshcore-dev/Adafruit_nRF52_Arduino#d541301
platformio/toolchain-gccarmnoneeabi@^1.140201.0
extra_scripts = create-uf2.py
pre:scripts/nrf52_internal_bootloader_link.py
build_flags = ${arduino_base.build_flags}
-D NRF52_PLATFORM
-D LFS_NO_ASSERT=1
+56
View File
@@ -0,0 +1,56 @@
"""Enable shared-slot internal bootloader update support on qualified nRF52840 targets.
The bootloader package uses the ordinary bottom-aligned internal OTA store
below 0xED000; no second linker reservation exists. This pre-script only binds
the feature macro to the exact release inventory and fails closed if a target
does not use the normal S140 v6/v7 1 MiB application layout.
"""
Import("env")
import os
from pathlib import Path
inventory_path = Path(env["PROJECT_DIR"]) / "tools/mota/nrf52_internal_bootloader_targets.txt"
try:
direct_targets = {
line.strip()
for line in inventory_path.read_text(encoding="utf-8").splitlines()
if line.strip() and not line.lstrip().startswith("#")
}
except OSError as exc:
raise RuntimeError(f"cannot read nRF52 internal-bootloader inventory: {exc}")
requested = (
os.environ.get("MESHCORE_NRF52_INTERNAL_BOOTLOADER_UPDATE") == "1"
or env["PIOENV"] in direct_targets
)
if requested:
# The macro is injected here so direct PIO and build.sh synthetic aliases
# cannot silently differ. Package admission is runtime-capacity based.
env.AppendUnique(CPPDEFINES=["OTA_INTERNAL_BOOTLOADER_UPDATE"])
board = env.BoardConfig()
# Most variants inherit the framework's top-level ``build.ldscript``.
# A few otherwise ordinary Adafruit nRF52840 boards (notably Keepteen LT1)
# declare it under ``build.arduino.ldscript`` instead. Resolve both forms
# before making the safety decision; an unknown map still fails closed.
current = str(
board.get("build.ldscript", "")
or board.get("build.arduino.ldscript", "")
)
mcu = str(board.get("build.mcu", "")).lower()
if mcu != "nrf52840" or not (
current.endswith("nrf52840_s140_v6.ld")
or current.endswith("nrf52840_s140_v7.ld")
):
raise RuntimeError(
"shared-slot bootloader update requires an nRF52840 with the normal "
f"S140 v6/v7 non-ExtraFS linker; found mcu={mcu or 'none'} "
f"linker={current or 'none'}"
)
print(
"nRF52 shared-slot bootloader update: normal linker %s; runtime EndF headroom required"
% current
)
+1 -1
View File
@@ -40,7 +40,7 @@ public:
// A host-backed store is fully random-access, so it needs no pinned-meta RAM page and no layout planning.
bool set_meta_size(uint32_t) override { return true; }
bool plan_layout(bool, uint32_t, uint32_t, uint32_t) override { return true; }
bool plan_layout(bool, uint32_t, uint32_t, uint32_t, bool) override { return true; }
private:
// One request/response transaction over the seeder link (mirrors SerialMotaSource). `arglen` is uint16 so
+113 -15
View File
@@ -32,6 +32,9 @@
#if defined(OTA_QSPI_STORE)
#include "OtaStoreQspiNrf52.h"
#endif
#if defined(OTA_FLASH_STORE)
#include "OtaStoreFlashNrf52.h"
#endif
#endif
namespace mesh {
@@ -462,8 +465,7 @@ void ota_reboot_to_apply() { // public: set the apply magic +
#if defined(OTA_QSPI_STORE)
stage_handoff = GPREGRET2_OTA_STAGE_QSPI;
#elif defined(OTA_FLASH_STORE)
if (ota_nrf52_effective_stage_ceiling() == MOTA_NRF52_STAGE_CEILING_EXPANDED)
stage_handoff = GPREGRET2_OTA_STAGE_EXPANDED;
stage_handoff = mota_nrf52_flash_stage_handoff(ota_nrf52_effective_stage_ceiling());
#endif
uint8_t sd_en = 0;
sd_softdevice_is_enabled(&sd_en);
@@ -480,15 +482,19 @@ void ota_reboot_to_apply() { // public: set the apply magic +
}
void ota_reboot_to_bootloader_update() {
uint8_t source = GPREGRET2_OTA_STAGE_QSPI;
#if defined(OTA_INTERNAL_BOOTLOADER_UPDATE)
source = GPREGRET2_OTA_STAGE_EXPANDED;
#endif
uint8_t sd_en = 0;
sd_softdevice_is_enabled(&sd_en);
if (sd_en) {
sd_power_gpregret_clr(1, 0xFFFFFFFF);
sd_power_gpregret_set(1, GPREGRET2_OTA_STAGE_QSPI);
sd_power_gpregret_set(1, source);
sd_power_gpregret_clr(0, 0xFFFFFFFF);
sd_power_gpregret_set(0, GPREGRET_OTA_BOOTLOADER_UPDATE);
} else {
NRF_POWER->GPREGRET2 = GPREGRET2_OTA_STAGE_QSPI;
NRF_POWER->GPREGRET2 = source;
NRF_POWER->GPREGRET = GPREGRET_OTA_BOOTLOADER_UPDATE;
}
NVIC_SystemReset();
@@ -584,10 +590,16 @@ static bool ota_apply_mota_nrf52_impl(const uint8_t* buf, uint32_t len,
}
#if defined(OTA_FLASH_STORE)
const uint32_t app_base = mota_nrf52_app_base();
const uint32_t app_ceiling = mota_nrf52_application_ceiling();
const uint32_t mota_start = (uint32_t)(uintptr_t)buf;
const uint32_t stage_ceiling = ota_nrf52_effective_stage_ceiling();
if (mota_start < app_base || mota_start >= stage_ceiling ||
d.memory > mota_start - app_base) {
if (!mota_nrf52_target_image_fits(app_base, m.image_size, app_ceiling)) {
strcpy(msg, "target image exceeds linked application region");
return false;
}
if (mota_start >= stage_ceiling ||
!mota_nrf52_internal_patch_workspace_valid(
d.memory, app_base, mota_start, m.image_size, app_ceiling)) {
snprintf(msg, 159, "patch memory 0x%x exceeds staging at 0x%x",
(unsigned)d.memory, (unsigned)mota_start);
return false;
@@ -779,6 +791,7 @@ static bool qspi_bootloader_image_metadata(OtaStoreQspiNrf52& store, uint32_t pa
static const uint32_t STEP = 512;
uint8_t buf[STEP + OTA_BOOT_MANIFEST_SIZE - 1];
uint8_t valid_identities = 0;
uint8_t valid_caps = 0;
for (uint32_t base = 0; base < OTA_BOOT_IMAGE_SIZE; base += STEP) {
uint32_t len = OTA_BOOT_IMAGE_SIZE - base;
if (len > sizeof(buf)) len = sizeof(buf);
@@ -787,14 +800,14 @@ static bool qspi_bootloader_image_metadata(OtaStoreQspiNrf52& store, uint32_t pa
? OTA_BOOT_IMAGE_SIZE - base : STEP;
for (uint32_t local = 0; local < starts; local++) {
const uint32_t absolute = base + local;
if (!caps.present && (absolute & 3u) == 0 && local + 16u <= len) {
if ((absolute & 3u) == 0 && local + 16u <= len) {
OtaBootloaderCapsMarker parsed;
if (ota_bootloader_caps_marker_parse(buf + local, parsed) &&
parsed.apply_abi >= MOTA_BOOT_FORMAT_VER &&
(parsed.codec_mask & (1u << CODEC_FULL)) != 0 &&
(parsed.storage_flags & (OTA_BL_STORAGE_QSPI | OTA_BL_STORAGE_BOOT_UPDATE)) ==
(OTA_BL_STORAGE_QSPI | OTA_BL_STORAGE_BOOT_UPDATE)) {
caps = parsed;
(parsed.storage_flags & OTA_BL_STORAGE_BOOT_UPDATE) != 0) {
if (++valid_caps != 1u) return false;
if (parsed.storage_flags == ota_bootloader_update_storage_flags()) caps = parsed;
}
}
if ((absolute & 3u) != 0 || local + OTA_BOOT_MANIFEST_SIZE > len) continue;
@@ -808,7 +821,8 @@ static bool qspi_bootloader_image_metadata(OtaStoreQspiNrf52& store, uint32_t pa
}
}
}
return valid_identities == 1u && candidate.present && candidate.crc_ok && caps.present;
return valid_identities == 1u && valid_caps == 1u &&
candidate.present && candidate.crc_ok && caps.present;
}
bool ota_prepare_bootloader_update_nrf52(OtaStoreQspiNrf52& store,
@@ -844,10 +858,7 @@ bool ota_prepare_bootloader_update_nrf52(OtaStoreQspiNrf52& store,
}
const OtaBlCaps current_caps = ota_bootloader_caps();
if (!current_caps.present || current_caps.apply_abi < MOTA_BOOT_FORMAT_VER ||
(current_caps.codec_mask & (1u << CODEC_FULL)) == 0 ||
(current_caps.storage_flags & (OTA_BL_STORAGE_QSPI | OTA_BL_STORAGE_BOOT_UPDATE)) !=
(OTA_BL_STORAGE_QSPI | OTA_BL_STORAGE_BOOT_UPDATE)) {
if (!ota_bootloader_self_update_caps_valid(current_caps)) {
strcpy(msg, "installed bootloader cannot safely self-update from QSPI"); return false;
}
@@ -893,6 +904,93 @@ bool ota_prepare_bootloader_update_nrf52(OtaStoreQspiNrf52& store,
#endif
#endif
#if defined(OTA_INTERNAL_BOOTLOADER_UPDATE) && defined(OTA_FLASH_STORE)
bool ota_prepare_bootloader_update_nrf52(OtaStoreFlashNrf52& store,
const SignerAllowlist& allow,
const OtaBootloaderIdentity& installed,
const uint8_t actual_mid[4],
const uint8_t operator_mid[4],
const uint8_t operator_hash8[8],
ApplyState& st, char* msg) {
static const size_t CAP = 96;
st = ApplyState();
const uint32_t total = store.staged_size();
const uint8_t* container = store.data();
MotaManifest m;
if (!container || total < 8u + MOTA_MFL + 5u || !mota_parse(container, total, m)) {
strcpy(msg, "internal bootloader container parse failed"); return false;
}
switch (ota_bootloader_confirmation_gate(m, installed, actual_mid, operator_mid,
operator_hash8)) {
case OTA_BOOT_CONFIRM_OK: break;
case OTA_BOOT_CONFIRM_NOT_BOOT_PACKAGE: strcpy(msg, "not a bootloader package"); return false;
case OTA_BOOT_CONFIRM_GEOMETRY: strcpy(msg, "bootloader package geometry mismatch"); return false;
case OTA_BOOT_CONFIRM_LOCAL_IDENTITY: strcpy(msg, "installed bootloader identity is invalid"); return false;
case OTA_BOOT_CONFIRM_TARGET: strcpy(msg, "bootloader target identity mismatch"); return false;
case OTA_BOOT_CONFIRM_HW_ID: strcpy(msg, "bootloader signed hw_id mismatch"); return false;
case OTA_BOOT_CONFIRM_MID: strcpy(msg, "bootloader MID confirmation mismatch"); return false;
case OTA_BOOT_CONFIRM_IMAGE_HASH: strcpy(msg, "bootloader image-hash confirmation mismatch"); return false;
}
const OtaBlCaps current_caps = ota_bootloader_caps();
if (!ota_bootloader_self_update_caps_valid(current_caps) ||
current_caps.storage_flags != (OTA_BL_STORAGE_STAGE_CEILING |
OTA_BL_STORAGE_BOOT_UPDATE)) {
strcpy(msg, "installed bootloader cannot safely self-update from internal flash"); return false;
}
const uint64_t payload_off64 = 8u + MOTA_MFL + (uint64_t)m.block_count * 4u;
SelfFwInfo fi;
uint32_t expected_start;
const uint32_t app_base = mota_nrf52_app_base();
if (payload_off64 > UINT32_MAX || payload_off64 + OTA_BOOT_IMAGE_SIZE + 5u != total ||
!ota_self_firmware(fi) || !fi.valid || fi.image_len > UINT32_MAX - app_base ||
!mota_nrf52_shared_boot_stage_plan(
total, app_base, true, app_base + fi.image_len, expected_start) ||
store.write_start() != expected_start) {
strcpy(msg, "internal bootloader container placement mismatch"); return false;
}
const uint32_t payload_off = (uint32_t)payload_off64;
VerifyResult vr = ota_verify(container, total, allow);
st.manifest_ok = vr.parsed;
st.sig_ok = vr.sig_ok;
st.trusted = vr.trusted;
st.image_size = m.image_size;
memcpy(st.image_hash, m.image_hash, sizeof(st.image_hash));
if (!vr.auto_appliable()) {
if (!vr.root_ok || !vr.payload_ok || !vr.image_ok)
strcpy(msg, "bootloader payload hash mismatch");
else if (!vr.sig_ok)
strcpy(msg, "bootloader package signature invalid");
else
strcpy(msg, "bootloader signer is not in the trusted allowlist");
return false;
}
const uint8_t* image = container + payload_off;
if (!ota_bootloader_vector_sane(image)) {
strcpy(msg, "bootloader vector table is invalid"); return false;
}
OtaBootloaderIdentity candidate;
OtaBootloaderCapsMarker candidate_caps;
if (!ota_bootloader_identity_from_image(image, OTA_BOOT_IMAGE_SIZE, candidate) ||
!ota_bootloader_identity_matches(installed, candidate) ||
!ota_bootloader_caps_from_image(
image, OTA_BOOT_IMAGE_SIZE,
OTA_BL_STORAGE_STAGE_CEILING | OTA_BL_STORAGE_BOOT_UPDATE,
candidate_caps)) {
strcpy(msg, "candidate bootloader identity/capability/CRC mismatch"); return false;
}
st.slot_ok = true;
if (!store.approve_for_bootloader()) {
snprintf(msg, CAP, "internal bootloader handoff failed: %s", store.last_error()); return false;
}
strcpy(msg, "trusted bootloader verified in internal flash; rebooting after this reply");
return true;
}
#endif
#else // native / other platforms
bool ota_apply_slot_info(uint32_t*, uint32_t*) { return false; }
+11
View File
@@ -101,6 +101,17 @@ bool ota_prepare_bootloader_update_nrf52(OtaStoreQspiNrf52& store,
ApplyState& st, char* msg);
#endif
#endif
#if defined(NRF52_PLATFORM) && defined(OTA_FLASH_STORE) && \
defined(OTA_INTERNAL_BOOTLOADER_UPDATE)
class OtaStoreFlashNrf52;
bool ota_prepare_bootloader_update_nrf52(OtaStoreFlashNrf52& store,
const SignerAllowlist& allow,
const OtaBootloaderIdentity& installed,
const uint8_t actual_mid[4],
const uint8_t operator_mid[4],
const uint8_t operator_hash8[8],
ApplyState& st, char* msg);
#endif
// Commit the (already approved/armed) update and reboot into it - does NOT return. Call this only after
// a successful ota_apply_* AND after the confirmation reply has been delivered, so the operator knows
+49 -4
View File
@@ -33,6 +33,27 @@ static const uint8_t OTA_BL_STORAGE_SD = 0x01;
static const uint8_t OTA_BL_STORAGE_STAGE_CEILING = 0x02;
static const uint8_t OTA_BL_STORAGE_QSPI = 0x04;
static const uint8_t OTA_BL_STORAGE_BOOT_UPDATE = 0x08;
static const uint8_t OTA_BL_STORAGE_KNOWN = 0x0F;
inline uint8_t ota_bootloader_update_storage_flags() {
#if defined(OTA_INTERNAL_BOOTLOADER_UPDATE)
// No external-storage bit means the ordinary internal flash store. The
// same store holds either an app delta or a boot package; BOOT_UPDATE marks
// only the privileged package capability, not a second storage backend.
return OTA_BL_STORAGE_STAGE_CEILING | OTA_BL_STORAGE_BOOT_UPDATE;
#elif defined(OTA_QSPI_BOOTLOADER_UPDATE)
return OTA_BL_STORAGE_STAGE_CEILING | OTA_BL_STORAGE_QSPI |
OTA_BL_STORAGE_BOOT_UPDATE;
#else
return 0;
#endif
}
inline bool ota_bootloader_self_update_caps_valid(const OtaBlCaps& c) {
const uint8_t required = ota_bootloader_update_storage_flags();
return required != 0 && c.present && c.apply_abi >= 3u &&
(c.codec_mask & 1u) != 0 && c.storage_flags == required;
}
// Prefer a continuity-capable marker over a numerically newer legacy-looking candidate. Bootloader
// images contain the marker magic in code/literal data as well as in the real structure, so the scan
@@ -53,17 +74,33 @@ inline bool ota_bl_caps_prefer(const OtaBlCaps& current, uint16_t candidate_abi,
// uint16_t fields and OTAFIX emits it aligned(4); accepting a byte-shifted magic
// occurrence would let instruction/literal bytes masquerade as capabilities.
inline OtaBlCaps ota_bl_caps_scan_aligned(const uint8_t* bytes, size_t len,
bool require_boot_update) {
bool require_boot_update,
uint8_t exact_storage_flags = 0) {
OtaBlCaps c;
if (!bytes) return c;
uint8_t privileged_count = 0;
for (size_t off = 0; off + 16u <= len; off += 4u) {
const uint8_t* p = bytes + off;
if (p[0] != OTA_BL_MAGIC[0] || memcmp(p, OTA_BL_MAGIC, 8) != 0) continue;
const uint16_t abi = (uint16_t)(p[8] | ((uint16_t)p[9] << 8));
const uint16_t codecs = (uint16_t)(p[10] | ((uint16_t)p[11] << 8));
const uint8_t storage = p[12];
if (abi == 0 || abi == 0xFFFFu || codecs == 0 || (storage & ~0x0Fu) != 0 ||
if (abi == 0 || abi == 0xFFFFu || codecs == 0 || (storage & ~OTA_BL_STORAGE_KNOWN) != 0 ||
p[13] != 0 || p[14] != 0 || p[15] != 0) continue;
const bool exact_profile = exact_storage_flags == 0 || storage == exact_storage_flags;
if (require_boot_update && exact_profile && abi >= 3u && (codecs & 1u) != 0 &&
(storage & OTA_BL_STORAGE_BOOT_UPDATE) != 0) {
// A self-update build must have one unambiguous privileged marker. Do
// not silently choose between two otherwise valid structures (including
// two copies of the expected exact profile). Malformed magic/literal
// decoys were filtered above and do not count.
if (++privileged_count != 1u) return OtaBlCaps();
c.present = true;
c.apply_abi = abi;
c.codec_mask = codecs;
c.storage_flags = storage;
continue;
}
if (ota_bl_caps_prefer(c, abi, storage, require_boot_update)) {
c.present = true;
c.apply_abi = abi;
@@ -71,6 +108,7 @@ inline OtaBlCaps ota_bl_caps_scan_aligned(const uint8_t* bytes, size_t len,
c.storage_flags = storage;
}
}
if (require_boot_update && privileged_count != 1u) return OtaBlCaps();
return c;
}
@@ -80,8 +118,8 @@ inline OtaBlCaps ota_bootloader_caps() {
const uint8_t* lo = (const uint8_t*)(uintptr_t)MOTA_NRF52_BL_START;
const uint8_t* hi = (const uint8_t*)(uintptr_t)MOTA_NRF52_BL_END;
return ota_bl_caps_scan_aligned(lo, (size_t)(hi - lo),
#if defined(OTA_QSPI_BOOTLOADER_UPDATE)
true
#if defined(OTA_QSPI_BOOTLOADER_UPDATE) || defined(OTA_INTERNAL_BOOTLOADER_UPDATE)
true, ota_bootloader_update_storage_flags()
#else
false
#endif
@@ -103,11 +141,18 @@ inline bool ota_bootloader_can_apply(uint8_t format_ver, uint8_t codec_id) {
// the legacy 0xD4000 ceiling. Packages sized for the expanded window still require the newer bootloader.
inline uint32_t ota_nrf52_effective_stage_ceiling(const OtaBlCaps& c) {
const uint32_t desired = mota_nrf52_layout_stage_ceiling();
#if defined(OTA_INTERNAL_BOOTLOADER_UPDATE)
if (desired != MOTA_NRF52_STAGE_CEILING_EXPANDED ||
!ota_bootloader_self_update_caps_valid(c))
return MOTA_NRF52_STAGE_CEILING_LEGACY;
return desired;
#else
if (desired == MOTA_NRF52_STAGE_CEILING_EXPANDED) {
if (!c.present || !(c.storage_flags & OTA_BL_STORAGE_STAGE_CEILING))
return MOTA_NRF52_STAGE_CEILING_LEGACY;
}
return desired;
#endif
}
inline uint32_t ota_nrf52_effective_stage_ceiling() {
+107 -8
View File
@@ -2,9 +2,13 @@
#include <stddef.h>
#include <stdint.h>
#include <stdio.h>
#include <string.h>
#include "MotaContainer.h"
#include "Multihash.h"
#include "OtaBlInfo.h"
#include "OtaByteIO.h"
#if defined(OTA_QSPI_BOOTLOADER_UPDATE)
#if !defined(NRF52_PLATFORM) || !defined(OTA_QSPI_STORE)
@@ -18,6 +22,15 @@
#endif
#endif
#if defined(OTA_INTERNAL_BOOTLOADER_UPDATE)
#if !defined(NRF52_PLATFORM) || !defined(OTA_FLASH_STORE)
#error "OTA_INTERNAL_BOOTLOADER_UPDATE requires nRF52 internal-flash staging"
#endif
#if defined(OTA_QSPI_STORE) || defined(OTA_SD_STORE) || defined(QSPIFLASH)
#error "internal bootloader staging cannot share an external OTA/filesystem store"
#endif
#endif
namespace mesh {
namespace ota {
@@ -83,8 +96,30 @@ inline bool ota_xiao_bootloader_board_id(uint32_t board_id) {
return board_id == OTA_XIAO_BOARD_ID_BASE || board_id == OTA_XIAO_BOARD_ID_SENSE;
}
// Canonical signed .mota hw_id. It is intentionally distinct from application hw_id values and is
// derived solely from the exact embedded board ID.
inline bool ota_bootloader_board_id_valid(uint32_t board_id) {
return board_id != 0u && board_id != UINT32_MAX;
}
// New exact-board manifests use one to fifteen printable ASCII bytes followed by zero padding. Requiring
// a terminator keeps the canonical signed hw_id at 31 bytes or less. The two already-deployed XIAO
// identities retain their exact legacy name and signed hardware IDs.
inline bool ota_bootloader_device_name_valid(uint32_t board_id, const uint8_t name[16]) {
if (!name) return false;
if (ota_xiao_bootloader_board_id(board_id))
return memcmp(name, OTA_XIAO_BOOT_DEVICE_NAME, OTA_BOOT_DEVICE_NAME_SIZE) == 0;
size_t end = 0;
while (end < OTA_BOOT_DEVICE_NAME_SIZE && name[end] != 0) {
if (name[end] < 0x21u || name[end] > 0x7Eu) return false;
end++;
}
if (end == 0 || end == OTA_BOOT_DEVICE_NAME_SIZE) return false;
for (size_t i = end; i < OTA_BOOT_DEVICE_NAME_SIZE; i++)
if (name[i] != 0) return false;
return true;
}
// Legacy XIAO signed .mota hw_id. It is intentionally distinct from application hw_id values and is
// derived from the exact embedded board ID for compatibility with already-deployed packages.
inline bool ota_xiao_bootloader_hw_id(uint32_t board_id, uint8_t out[32]) {
if (!out || !ota_xiao_bootloader_board_id(board_id)) return false;
memset(out, 0, 32);
@@ -94,6 +129,37 @@ inline bool ota_xiao_bootloader_hw_id(uint32_t board_id, uint8_t out[32]) {
return true;
}
// Canonical generic bootloader identity. The board ID alone is not unique (several nRF52 families use
// the same USB VID/PID-derived ID), so the signed identity includes the complete embedded device name.
// Hashing all 32 padded bytes produces the bootloader package's collision-checked wire target ID.
inline bool ota_bootloader_hw_id(uint32_t board_id, const uint8_t device_name[16],
uint8_t out[32]) {
if (!out || !ota_bootloader_board_id_valid(board_id) ||
!ota_bootloader_device_name_valid(board_id, device_name)) return false;
if (ota_xiao_bootloader_board_id(board_id)) return ota_xiao_bootloader_hw_id(board_id, out);
static const char hex[] = "0123456789ABCDEF";
memset(out, 0, 32);
memcpy(out, "NRF_BL_", 7);
for (uint8_t i = 0; i < 8; i++)
out[7 + i] = (uint8_t)hex[(board_id >> (28u - 4u * i)) & 0x0Fu];
out[15] = '_';
size_t n = 0;
while (n < 15 && device_name[n]) n++;
memcpy(out + 16, device_name, n);
return true;
}
inline bool ota_bootloader_hw_id(const OtaBootloaderIdentity& identity, uint8_t out[32]);
inline uint32_t ota_bootloader_target_id(uint32_t board_id, const uint8_t device_name[16]) {
if (ota_xiao_bootloader_board_id(board_id)) return board_id;
uint8_t hw_id[32], digest[4];
if (!ota_bootloader_hw_id(board_id, device_name, hw_id)) return 0;
mh4(digest, hw_id, sizeof(hw_id));
return rd_u32le(digest);
}
inline uint32_t ota_boot_crc32_update(uint32_t crc, uint8_t value) {
crc ^= value;
for (uint8_t bit = 0; bit < 8; bit++)
@@ -122,8 +188,8 @@ inline bool ota_bootloader_manifest_parse(const uint8_t* raw, uint32_t raw_offse
ota_boot_rd16(raw + 10) != OTA_BOOT_MANIFEST_SIZE ||
ota_boot_rd32(raw + 12) != OTA_BOOT_IMAGE_START ||
ota_boot_rd32(raw + 16) != OTA_BOOT_IMAGE_SIZE ||
!ota_xiao_bootloader_board_id(ota_boot_rd32(raw + 20)) ||
memcmp(raw + 24, OTA_XIAO_BOOT_DEVICE_NAME, OTA_BOOT_DEVICE_NAME_SIZE) != 0) return false;
!ota_bootloader_board_id_valid(ota_boot_rd32(raw + 20)) ||
!ota_bootloader_device_name_valid(ota_boot_rd32(raw + 20), raw + 24)) return false;
out = OtaBootloaderIdentity();
out.present = true;
@@ -137,6 +203,18 @@ inline bool ota_bootloader_manifest_parse(const uint8_t* raw, uint32_t raw_offse
return true;
}
inline bool ota_bootloader_hw_id(const OtaBootloaderIdentity& identity, uint8_t out[32]) {
return identity.present && identity.crc_ok &&
ota_bootloader_hw_id(identity.board_id,
reinterpret_cast<const uint8_t*>(identity.device_name), out);
}
inline uint32_t ota_bootloader_target_id(const OtaBootloaderIdentity& identity) {
return identity.present && identity.crc_ok
? ota_bootloader_target_id(identity.board_id,
reinterpret_cast<const uint8_t*>(identity.device_name)) : 0;
}
// Validate an installed memory-mapped bootloader and recover its exact identity. Scan on word
// boundaries to mirror OTAFIX's self-update validator.
inline bool ota_bootloader_identity_from_image(const uint8_t* image, size_t image_size,
@@ -187,7 +265,7 @@ inline bool ota_bootloader_caps_marker_parse(const uint8_t raw[16],
static const uint8_t magic[8] = {'M','O','T','A','B','L','D','R'};
if (!raw || memcmp(raw, magic, sizeof(magic)) != 0 ||
raw[8] == 0 || (raw[8] == 0xFF && raw[9] == 0xFF) ||
(raw[10] == 0 && raw[11] == 0) || (raw[12] & ~0x0Fu) != 0 ||
(raw[10] == 0 && raw[11] == 0) || (raw[12] & ~OTA_BL_STORAGE_KNOWN) != 0 ||
raw[13] != 0 || raw[14] != 0 || raw[15] != 0) return false;
out.present = true;
out.apply_abi = ota_boot_rd16(raw + 8);
@@ -196,6 +274,25 @@ inline bool ota_bootloader_caps_marker_parse(const uint8_t raw[16],
return true;
}
inline bool ota_bootloader_caps_from_image(const uint8_t* image, size_t image_size,
uint8_t exact_storage_flags,
OtaBootloaderCapsMarker& out) {
out = OtaBootloaderCapsMarker();
if (!image || exact_storage_flags == 0) return false;
uint8_t valid_count = 0;
for (size_t off = 0; off + 16u <= image_size; off += 4u) {
OtaBootloaderCapsMarker parsed;
if (!ota_bootloader_caps_marker_parse(image + off, parsed) ||
parsed.apply_abi < MOTA_BOOT_FORMAT_VER ||
(parsed.codec_mask & (1u << CODEC_FULL)) == 0 ||
(parsed.storage_flags & OTA_BL_STORAGE_BOOT_UPDATE) == 0) continue;
if (++valid_count != 1u) return false; // privileged marker identity must be unambiguous
if (parsed.storage_flags != exact_storage_flags) continue;
out = parsed;
}
return valid_count == 1u && out.present;
}
enum OtaBootloaderConfirmGate : uint8_t {
OTA_BOOT_CONFIRM_OK = 0,
OTA_BOOT_CONFIRM_NOT_BOOT_PACKAGE,
@@ -223,11 +320,13 @@ inline OtaBootloaderConfirmGate ota_bootloader_confirmation_gate(
memcmp(m.base_hash, zero_base, sizeof(zero_base)) != 0)
return OTA_BOOT_CONFIRM_GEOMETRY;
if (!installed.present || !installed.crc_ok ||
!ota_xiao_bootloader_board_id(installed.board_id))
!ota_bootloader_board_id_valid(installed.board_id) ||
!ota_bootloader_device_name_valid(
installed.board_id, reinterpret_cast<const uint8_t*>(installed.device_name)))
return OTA_BOOT_CONFIRM_LOCAL_IDENTITY;
if (m.target_id != installed.board_id) return OTA_BOOT_CONFIRM_TARGET;
if (m.target_id != ota_bootloader_target_id(installed)) return OTA_BOOT_CONFIRM_TARGET;
uint8_t expected_hw[32];
if (!ota_xiao_bootloader_hw_id(installed.board_id, expected_hw) || !m.hw_id ||
if (!ota_bootloader_hw_id(installed, expected_hw) || !m.hw_id ||
memcmp(m.hw_id, expected_hw, sizeof(expected_hw)) != 0)
return OTA_BOOT_CONFIRM_HW_ID;
if (!actual_mid || !operator_mid || memcmp(actual_mid, operator_mid, 4) != 0 ||
+1 -1
View File
@@ -55,7 +55,7 @@ public:
bool finalize() override;
void checkpoint() override;
bool reopen() override;
bool plan_layout(bool, uint32_t, uint32_t, uint32_t) override { return true; }
bool plan_layout(bool, uint32_t, uint32_t, uint32_t, bool) override { return true; }
// MotaSource: completed files are advertised and read directly from SD on demand.
uint8_t count() override { return _initialized ? _count : 0; }
+66 -32
View File
@@ -170,9 +170,15 @@ bool handle_ota_command(const char* command, char* reply, mesh::MainBoard& board
"announce | folder | config. LoRa install is disabled.");
#elif defined(NRF52_PLATFORM) && defined(OTA_FLASH_STORE) && !defined(OTA_SD_STORE) && \
!defined(OTA_QSPI_STORE)
#if defined(OTA_INTERNAL_BOOTLOADER_UPDATE)
strcpy(reply,
"OTA: status | stats | ls | get <id> flash | install | "
"bootloader | cancel | announce | self | folder | config | key");
#else
strcpy(reply,
"OTA: status | stats | ls | get <id> flash [rescue] | install | rescue install <hash16> | "
"cancel | announce | self | folder | config | key");
#endif
#elif defined(NRF52_PLATFORM) && defined(OTA_QSPI_STORE)
#if defined(OTA_QSPI_BOOTLOADER_UPDATE)
strcpy(reply,
@@ -220,10 +226,6 @@ bool handle_ota_command(const char* command, char* reply, mesh::MainBoard& board
}
const char* hw = (c.hw_id[0]) ? c.hw_id : "?";
const char* tenv = ota_target_env_name(c.manager.target()); // env name, or "?" if not in the table
int n = snprintf(reply, 160, "OTA | this fw %s (%uK) hw=%s | %s | serving:%s (%u) | keys:%u | target:%08X (%s)",
selfhx, (unsigned)((s ? fi.image_len : 0) / 1024), hw, dl,
c.serving ? "on" : "off", (unsigned)c.manager.servedCount(),
(unsigned)c.allow.count(), (unsigned)c.manager.target(), tenv ? tenv : "?");
#if defined(NRF52_PLATFORM)
// nRF52 applies via the bootloader - show (cached) whether it can, so `ota get`/`install` won't surprise.
// blrc = the bootloader's last apply code (diagnostic; 0xB8=success, see ota_delta.c).
@@ -238,12 +240,23 @@ bool handle_ota_command(const char* command, char* reply, mesh::MainBoard& board
const char* bl_state = bl.present ? "apply" : "NONE";
#endif
const uint8_t last_rc = ota_bootloader_last_rc();
if (n < 146) {
if (ota_nrf52_boot_update_result(last_rc))
n += snprintf(reply + n, 160 - n, " | bl:%s blup:%02X", bl_state, last_rc);
else
n += snprintf(reply + n, 160 - n, " | bl:%s blrc:%02X", bl_state, last_rc);
}
const char* rc_name = ota_nrf52_boot_update_result(last_rc) ? "blup" : "blrc";
// Keep the fixed-width parser/diagnostic fields ahead of the variable download text and optional
// human target name. hw is bounded to 32 bytes, so target + the complete blup/blrc token always fit
// in the 160-byte reply; snprintf may truncate only the descriptive tail.
snprintf(reply, 160,
"OTA | this fw %s (%uK) hw=%s | target:%08X | bl:%s %s:%02X | %s | "
"serving:%s (%u) | keys:%u | env:%s",
selfhx, (unsigned)((s ? fi.image_len : 0) / 1024), hw,
(unsigned)c.manager.target(), bl_state, rc_name, last_rc, dl,
c.serving ? "on" : "off", (unsigned)c.manager.servedCount(),
(unsigned)c.allow.count(), tenv ? tenv : "?");
#else
snprintf(reply, 160,
"OTA | this fw %s (%uK) hw=%s | %s | serving:%s (%u) | keys:%u | target:%08X (%s)",
selfhx, (unsigned)((s ? fi.image_len : 0) / 1024), hw, dl,
c.serving ? "on" : "off", (unsigned)c.manager.servedCount(),
(unsigned)c.allow.count(), (unsigned)c.manager.target(), tenv ? tenv : "?");
#endif
#endif
@@ -334,21 +347,20 @@ bool handle_ota_command(const char* command, char* reply, mesh::MainBoard& board
uint32_t age = (now - h->last_ms) / 1000; if (age > 99999) age = 99999;
char ver[20]; ver_str(ver, sizeof ver, h->fw_version);
// Target equality alone is not an install-safety claim. Surface local codec/bootloader limitations,
// and flag the explicit rescue path when this internal-flash nRF52 has no valid running EndF.
// and flag the explicit legacy rescue path when an app-only internal-flash nRF52 has no valid EndF.
char hwbuf[16];
const char* fit;
const char* env = ota_target_env_name(h->target_id);
const bool boot_package = (h->flags & MFLAG_BOOTLOADER) != 0;
if (boot_package) {
bool installable = false;
#if defined(NRF52_PLATFORM) && defined(OTA_QSPI_STORE) && defined(OTA_QSPI_BOOTLOADER_UPDATE)
#if defined(NRF52_PLATFORM) && \
(defined(OTA_QSPI_BOOTLOADER_UPDATE) || defined(OTA_INTERNAL_BOOTLOADER_UPDATE))
const OtaBootloaderIdentity& bid = c.bootloaderIdentity();
installable = h->flags == (MFLAG_FULL | MFLAG_SIGNED | MFLAG_BOOTLOADER) &&
h->codec == CODEC_FULL && bid.present && bid.crc_ok &&
h->target_id == bid.board_id && list_bl.present &&
list_bl.apply_abi >= MOTA_BOOT_FORMAT_VER &&
(list_bl.storage_flags & (OTA_BL_STORAGE_QSPI | OTA_BL_STORAGE_BOOT_UPDATE)) ==
(OTA_BL_STORAGE_QSPI | OTA_BL_STORAGE_BOOT_UPDATE);
h->target_id == ota_bootloader_target_id(bid) &&
ota_bootloader_self_update_caps_valid(list_bl);
#endif
fit = installable ? "yours" : "bootloader unsupported";
} else if (myt && h->target_id == myt) {
@@ -363,7 +375,9 @@ bool handle_ota_command(const char* command, char* reply, mesh::MainBoard& board
#endif
#endif
if (!installable) fit = "unsupported";
#if defined(NRF52_PLATFORM) && defined(OTA_FLASH_STORE) && !defined(OTA_SD_STORE) && \
#if defined(NRF52_PLATFORM) && defined(OTA_INTERNAL_BOOTLOADER_UPDATE)
else if (!list_has_endf) fit = "no EndF; local recovery";
#elif defined(NRF52_PLATFORM) && defined(OTA_FLASH_STORE) && !defined(OTA_SD_STORE) && \
!defined(OTA_QSPI_STORE)
else if (!list_has_endf) fit = "rescue";
#endif
@@ -381,6 +395,12 @@ bool handle_ota_command(const char* command, char* reply, mesh::MainBoard& board
// ---- start fetching a specific catalogued mOTA (by list index or manifest_id) ----
} else if (is_cmd(a, "pull|get|download", &rest)) {
#if defined(NRF52_PLATFORM) && defined(OTA_FLASH_STORE) && !defined(OTA_SD_STORE) && \
!defined(OTA_QSPI_STORE) && !defined(OTA_INTERNAL_BOOTLOADER_UPDATE)
const char* pull_usage = "usage: ota pull <id> flash [rescue] | folder [validate] (see `ota ls`)";
#else
const char* pull_usage = "usage: ota pull <id> flash | folder [validate] (see `ota ls`)";
#endif
const char* p = rest; while (*p == ' ') p++;
// split into "<selector> [destination]": selector = #N / N (catalogue index) or mid hex; destination =
// flash | folder (MANDATORY - `folder` captures the .mota onto the connected motatool folder as <mid>.mota).
@@ -388,7 +408,7 @@ bool handle_ota_command(const char* command, char* reply, mesh::MainBoard& board
while (p[i] && p[i] != ' ' && i < (int)sizeof(selstr) - 1) { selstr[i] = p[i]; i++; }
selstr[i] = 0;
const char* dst = p + i; while (*dst == ' ') dst++;
if (selstr[0] == 0) { strcpy(reply, "usage: ota pull <id> flash [rescue] | folder [validate] (see `ota ls`)"); return true; }
if (selstr[0] == 0) { strcpy(reply, pull_usage); return true; }
// resolve the catalogue row (index or explicit manifest_id)
const OtaManager::CatRow* sel = nullptr; uint8_t mid[4];
// An eight-digit all-numeric manifest ID is still an ID, not a huge list index. Bare short decimal
@@ -435,7 +455,7 @@ bool handle_ota_command(const char* command, char* reply, mesh::MainBoard& board
char destination[8] = {0}, option[10] = {0}, extra[2] = {0};
int parts = sscanf(dst, "%7s %9s %1s", destination, option, extra);
if (parts < 1 || parts > 2) {
strcpy(reply, "ERR usage: ota pull <id> flash [rescue] | folder [validate]");
snprintf(reply, 160, "ERR %s", pull_usage);
return true;
}
bool to_flash = strcmp(destination, "flash") == 0;
@@ -443,7 +463,7 @@ bool handle_ota_command(const char* command, char* reply, mesh::MainBoard& board
bool validate = to_folder && parts == 2 && strcmp(option, "validate") == 0;
bool rescue = to_flash && parts == 2 && strcmp(option, "rescue") == 0;
if ((!to_flash && !to_folder) || (parts == 2 && !validate && !rescue)) {
strcpy(reply, "ERR usage: ota pull <id> flash [rescue] | folder [validate]");
snprintf(reply, 160, "ERR %s", pull_usage);
return true;
}
@@ -468,7 +488,8 @@ bool handle_ota_command(const char* command, char* reply, mesh::MainBoard& board
return true;
#else
if (selboot) {
#if defined(NRF52_PLATFORM) && defined(OTA_QSPI_STORE) && defined(OTA_QSPI_BOOTLOADER_UPDATE)
#if defined(NRF52_PLATFORM) && \
(defined(OTA_QSPI_BOOTLOADER_UPDATE) || defined(OTA_INTERNAL_BOOTLOADER_UPDATE))
const OtaBootloaderIdentity& bid = c.bootloaderIdentity();
const OtaBlCaps& bl = c.bootloaderCaps();
if (selflags != (MFLAG_FULL | MFLAG_SIGNED | MFLAG_BOOTLOADER) ||
@@ -476,13 +497,11 @@ bool handle_ota_command(const char* command, char* reply, mesh::MainBoard& board
strcpy(reply, "ERR malformed bootloader catalog row; capture it to folder for inspection");
return true;
}
if (!bid.present || !bid.crc_ok || seltgt != bid.board_id) {
strcpy(reply, "ERR bootloader package does not match this installed XIAO bootloader");
if (!bid.present || !bid.crc_ok || seltgt != ota_bootloader_target_id(bid)) {
strcpy(reply, "ERR bootloader package does not match this installed bootloader identity");
return true;
}
if (!bl.present || bl.apply_abi < MOTA_BOOT_FORMAT_VER ||
(bl.storage_flags & (OTA_BL_STORAGE_QSPI | OTA_BL_STORAGE_BOOT_UPDATE)) !=
(OTA_BL_STORAGE_QSPI | OTA_BL_STORAGE_BOOT_UPDATE)) {
if (!ota_bootloader_self_update_caps_valid(bl)) {
strcpy(reply, "ERR installed bootloader lacks safe LoRa bootloader-update support");
return true;
}
@@ -524,6 +543,16 @@ bool handle_ota_command(const char* command, char* reply, mesh::MainBoard& board
!defined(OTA_QSPI_STORE)
SelfFwInfo self;
bool has_endf = ota_self_firmware(self) && self.valid;
#if defined(OTA_INTERNAL_BOOTLOADER_UPDATE)
if (!has_endf) {
strcpy(reply, "ERR no EndF; shared-slot builds refuse internal pulls; recover via USB/BLE DFU or SWD");
return true;
}
if (rescue) {
strcpy(reply, "ERR rescue is disabled on shared-slot bootloader-update builds; use `flash`");
return true;
}
#else
if (!has_endf && !rescue) {
snprintf(reply, 160,
"ERR no EndF; retry `ota pull %s flash rescue`, then use `ota rescue install <hash16>`",
@@ -538,6 +567,7 @@ bool handle_ota_command(const char* command, char* reply, mesh::MainBoard& board
strcpy(reply, "ERR rescue is only for a running firmware with no valid EndF; use `flash`");
return true;
}
#endif
#else
if (rescue) {
strcpy(reply, "ERR rescue is available only on internal-flash nRF52 builds");
@@ -656,7 +686,8 @@ bool handle_ota_command(const char* command, char* reply, mesh::MainBoard& board
// the exact 8-byte base hash carried by the already-fetched package. The bootloader independently
// hashes the running app and refuses a mismatch before writing any application flash.
#if defined(NRF52_PLATFORM) && defined(OTA_FLASH_STORE) && !defined(OTA_SD_STORE) && \
!defined(OTA_QSPI_STORE) && !defined(OTA_SEEDER_ONLY)
!defined(OTA_QSPI_STORE) && !defined(OTA_SEEDER_ONLY) && \
!defined(OTA_INTERNAL_BOOTLOADER_UPDATE)
if (c.fetch_to_folder) {
strcpy(reply, "ERR the complete update was captured to a folder, not staged for install; use `ota cancel`");
return true;
@@ -677,13 +708,15 @@ bool handle_ota_command(const char* command, char* reply, mesh::MainBoard& board
char m2[100];
bool ok = c.apply_fetched_rescue(operator_base_hash, m2);
sprintf(reply, "%s | %s", ok ? "OK" : "ERR", m2);
#elif defined(NRF52_PLATFORM) && defined(OTA_INTERNAL_BOOTLOADER_UPDATE)
strcpy(reply, "ERR rescue is disabled on shared-slot bootloader-update builds; use local DFU/SWD if EndF is invalid");
#else
strcpy(reply, "ERR rescue requires an internal-flash nRF52 LoRa OTA build");
#endif
} else if (is_cmd(a, "bootloader|blupdate", &rest)) {
#if defined(NRF52_PLATFORM) && defined(OTA_QSPI_STORE) && \
defined(OTA_QSPI_BOOTLOADER_UPDATE) && !defined(OTA_SEEDER_ONLY)
#if defined(NRF52_PLATFORM) && !defined(OTA_SEEDER_ONLY) && \
(defined(OTA_QSPI_BOOTLOADER_UPDATE) || defined(OTA_INTERNAL_BOOTLOADER_UPDATE))
const OtaBootloaderIdentity& bid = c.bootloaderIdentity();
const OtaBlCaps& bl = c.bootloaderCaps();
if (*rest == 0 || strcmp(rest, "status") == 0) {
@@ -699,8 +732,9 @@ bool handle_ota_command(const char* command, char* reply, mesh::MainBoard& board
mesh::Utils::toHex(midhx, c.manager.fetchManifestId(), 4);
mesh::Utils::toHex(hashhx, staged.image_hash, 8);
}
snprintf(reply, 160, "BL board=%08X name=%s crc=%08X abi=%u caps=%02X | staged:%s mid=%s hash=%s",
(unsigned)bid.board_id, bid.device_name, (unsigned)bid.crc32,
snprintf(reply, 160, "BL board=%08X target=%08X name=%s crc=%08X abi=%u caps=%02X | staged:%s mid=%s hash=%s",
(unsigned)bid.board_id, (unsigned)ota_bootloader_target_id(bid),
bid.device_name, (unsigned)bid.crc32,
bl.apply_abi, bl.storage_flags, ready ? "ready" : "none", midhx, hashhx);
return true;
}
@@ -720,7 +754,7 @@ bool handle_ota_command(const char* command, char* reply, mesh::MainBoard& board
if (c.apply_pending) { strcpy(reply, "ERR another update is already armed"); return true; }
if (c.fetch_to_folder || c.manager.fetchState() != OtaManager::COMPLETE ||
c.fetch_store.staged_size() == 0 || !c.manager.fetched_is_bootloader()) {
strcpy(reply, "ERR no complete bootloader package in local QSPI storage"); return true;
strcpy(reply, "ERR no complete bootloader package in local install storage"); return true;
}
char m2[100];
const bool ok = c.apply_fetched_bootloader(operator_mid, operator_hash8, m2);
+19 -4
View File
@@ -151,7 +151,14 @@ struct OtaContext {
// Explicit recovery entry point for an internal-flash nRF52 whose normal EndF validation fails. This
// is never called by auto-install; the CLI requires the package's exact 8-byte base hash.
bool apply_fetched_rescue(const uint8_t operator_base_hash[8], char* msg) {
#if defined(OTA_INTERNAL_BOOTLOADER_UPDATE)
(void)operator_base_hash;
strncpy(msg, "rescue is disabled on shared-slot bootloader-update builds", 96);
msg[95] = 0;
return false;
#else
return apply_fetched_impl(operator_base_hash, msg);
#endif
}
bool apply_fetched_impl(const uint8_t* rescue_base_hash, char* msg) {
@@ -172,6 +179,13 @@ struct OtaContext {
return false;
}
#endif
#if defined(OTA_INTERNAL_BOOTLOADER_UPDATE)
if (rescue_base_hash) {
strncpy(msg, "rescue is disabled on shared-slot bootloader-update builds", 96);
msg[95] = 0;
return false;
}
#endif
#if defined(NRF52_PLATFORM) && defined(OTA_SD_STORE)
if (sdCacheFetching()) {
strncpy(msg, "refused: SD archive capture owns the OTA receive slot", 96);
@@ -223,10 +237,10 @@ struct OtaContext {
bool apply_fetched_bootloader(const uint8_t operator_mid[4],
const uint8_t operator_hash8[8], char* msg) {
#if defined(NRF52_PLATFORM) && defined(OTA_QSPI_STORE) && \
defined(OTA_QSPI_BOOTLOADER_UPDATE) && !defined(OTA_SEEDER_ONLY)
#if defined(NRF52_PLATFORM) && !defined(OTA_SEEDER_ONLY) && \
(defined(OTA_QSPI_BOOTLOADER_UPDATE) || defined(OTA_INTERNAL_BOOTLOADER_UPDATE))
if (fetch_to_folder) {
strncpy(msg, "refused: bootloader package is not in local QSPI storage", 96);
strncpy(msg, "refused: bootloader package is not in local install storage", 96);
msg[95] = 0; return false;
}
if (manager.fetchState() != OtaManager::COMPLETE || fetch_store.staged_size() == 0) {
@@ -503,7 +517,8 @@ struct OtaContext {
manager.set_apply_codec(CODEC_DETOOLS_SEQUENTIAL); // preferred (streams straight to the slot)
manager.set_apply_codec2(CODEC_DETOOLS_INPLACE); // also accepted -> a single in-place .mota fits both
#endif
#if defined(NRF52_PLATFORM) && defined(OTA_QSPI_STORE) && defined(OTA_QSPI_BOOTLOADER_UPDATE)
#if defined(NRF52_PLATFORM) && \
(defined(OTA_QSPI_BOOTLOADER_UPDATE) || defined(OTA_INTERNAL_BOOTLOADER_UPDATE))
manager.set_accept_bootloader(true);
#else
manager.set_accept_bootloader(false);
+95
View File
@@ -33,6 +33,14 @@ static const uint32_t MOTA_NRF52_EXTRAFS_START = 0x000D4000u;
static const uint32_t MOTA_NRF52_BOOT_SCRATCH_START = 0x000E0000u;
static const uint32_t MOTA_NRF52_BOOT_SCRATCH_END = 0x000EA000u;
static const uint32_t MOTA_NRF52_APP_END = 0x000ED000u;
// Internal-only targets do not reserve a second scratch bank. Their exact
// 41,330-byte v3 package shares the normal bottom-aligned internal OTA store.
// Under the 0xED000 filesystem ceiling it occupies eleven pages beginning at
// 0xE2000; OTAFIX compacts the payload forward within this same slot before
// handing that page-aligned source to the MBR. Admission requires a trustworthy
// live EndF proving the running application ends at or below this address.
static const uint32_t MOTA_NRF52_BOOT_CONTAINER_SIZE = 41330u;
static const uint32_t MOTA_NRF52_SHARED_BOOT_STAGE_START = 0x000E2000u;
static const uint32_t MOTA_NRF52_STAGE_CEILING_LEGACY = MOTA_NRF52_EXTRAFS_START;
static const uint32_t MOTA_NRF52_STAGE_CEILING_EXPANDED = MOTA_NRF52_APP_END;
// Compatibility name for code that refers to the fixed lower boundary. New staging code must use an
@@ -45,6 +53,12 @@ static const uint8_t GPREGRET2_OTA_STAGE_LEGACY = 0xD4u;
static const uint8_t GPREGRET2_OTA_STAGE_EXPANDED = 0xEDu;
static const uint8_t GPREGRET2_OTA_STAGE_QSPI = 0x51u;
inline uint8_t mota_nrf52_flash_stage_handoff(uint32_t effective_stage_ceiling) {
if (effective_stage_ceiling == MOTA_NRF52_STAGE_CEILING_EXPANDED)
return GPREGRET2_OTA_STAGE_EXPANDED;
return GPREGRET2_OTA_STAGE_LEGACY;
}
// Firmware without a valid EndF and older host tooling fall back to this conservative apply workspace.
// New motatool builds read the firmware's appended layout record and derive memory_size from the actual
// staged-container start; the app and bootloader both validate that per-patch value before writing.
@@ -72,6 +86,13 @@ static_assert((MOTA_NRF52_STAGE_CEILING_LEGACY % MOTA_NRF52_FLASH_PAGE) == 0,
"legacy staging ceiling must be page-aligned");
static_assert((MOTA_NRF52_STAGE_CEILING_EXPANDED % MOTA_NRF52_FLASH_PAGE) == 0,
"expanded staging ceiling must be page-aligned");
static_assert((MOTA_NRF52_SHARED_BOOT_STAGE_START % MOTA_NRF52_FLASH_PAGE) == 0,
"shared bootloader stage start must be page-aligned");
static_assert(((MOTA_NRF52_APP_END - MOTA_NRF52_BOOT_CONTAINER_SIZE) &
~(MOTA_NRF52_FLASH_PAGE - 1u)) == MOTA_NRF52_SHARED_BOOT_STAGE_START,
"exact bootloader container must bottom-align at E2000");
static_assert(MOTA_NRF52_APP_END - MOTA_NRF52_SHARED_BOOT_STAGE_START == 0xB000u,
"shared bootloader slot must be exactly eleven pages");
static_assert(MOTA_NRF52_STAGE_CEILING_LEGACY < MOTA_NRF52_STAGE_CEILING_EXPANDED,
"legacy staging ceiling must precede expanded ceiling");
static_assert(MOTA_NRF52_BOOT_SCRATCH_END - MOTA_NRF52_BOOT_SCRATCH_START == 0xA000u,
@@ -150,6 +171,61 @@ inline uint32_t mota_nrf52_stage_capacity(uint32_t app_base, uint32_t app_end,
return first_free_page <= stage_ceiling ? stage_ceiling - first_free_page : 0;
}
// Choose the erase-protection floor used while receiving/reopening an internal
// flash container. A valid EndF provides the exact live-image extent. Legacy
// app-only builds may opt into the conservative rescue fallback; a shared
// internal bootloader-update build passes refuse_without_endf=true for every
// package kind because its normal app linker may extend through 0xED000.
inline bool mota_nrf52_protected_app_end(uint32_t app_base,
uint32_t stage_ceiling,
bool firmware_info_valid,
uint32_t firmware_image_size,
bool refuse_without_endf,
uint32_t& out_app_end) {
if (!mota_nrf52_layout_valid(app_base, stage_ceiling)) return false;
if (firmware_info_valid) {
if (firmware_image_size > UINT32_MAX - app_base) return false;
out_app_end = app_base + firmware_image_size;
} else {
if (refuse_without_endf) return false;
if (MOTA_NRF52_FALLBACK_INPLACE_MEMORY > UINT32_MAX - app_base) return false;
out_app_end = app_base + MOTA_NRF52_FALLBACK_INPLACE_MEMORY;
}
return out_app_end >= app_base && out_app_end <= stage_ceiling;
}
// Bound a staged header's untrusted total before any caller uses that value as
// a memory-mapped parse length. Subtraction is safe only after start<=ceiling.
inline bool mota_nrf52_container_span_valid(uint32_t start,
uint32_t stage_ceiling,
uint32_t total_size,
uint32_t minimum_size) {
return start <= stage_ceiling && total_size >= minimum_size &&
total_size <= stage_ceiling - start;
}
inline bool mota_nrf52_target_image_fits(uint32_t app_base, uint32_t image_size,
uint32_t application_ceiling) {
return image_size != 0 && app_base < application_ceiling &&
image_size <= application_ceiling - app_base;
}
// Internal in-place patches use whichever address the bottom-aligned container
// actually starts at as their destructive workspace ceiling. A normal delta
// may be larger than the boot package's eleven-page shared slot and start
// below E2000; that remains safe when both the encoded detools memory and the
// reconstructed application stop below their independent limits.
inline bool mota_nrf52_internal_patch_workspace_valid(uint32_t memory_size,
uint32_t app_base,
uint32_t mota_start,
uint32_t target_image_size,
uint32_t application_ceiling) {
return memory_size != 0 && mota_start >= app_base &&
memory_size <= mota_start - app_base &&
mota_nrf52_target_image_fits(app_base, target_image_size,
application_ceiling);
}
// Plan where to stage a received `.mota` of `total_size` bytes. It is placed bottom-aligned within the
// highest flash page below stage_ceiling (the bootloader scans downward from there), and it must sit
// ENTIRELY above the running image and below the filesystem region (ExtraFS/InternalFS - where user prefs
@@ -170,6 +246,25 @@ inline bool mota_nrf52_stage_plan(uint32_t total_size, uint32_t app_base, uint32
return true;
}
// Privileged shared-slot admission. Unlike ordinary rescue staging, this
// requires the caller to have a trustworthy live EndF (`app_end` is exact),
// accepts only the fixed v3 package size, and proves the dynamic slot is wholly
// above the running image before any erase/write begins.
inline bool mota_nrf52_shared_boot_stage_plan(uint32_t total_size,
uint32_t app_base,
bool firmware_info_valid,
uint32_t app_end,
uint32_t& out_start) {
uint32_t start;
if (!firmware_info_valid || total_size != MOTA_NRF52_BOOT_CONTAINER_SIZE ||
!mota_nrf52_stage_plan(total_size, app_base, app_end,
MOTA_NRF52_APP_END, start) ||
start != MOTA_NRF52_SHARED_BOOT_STAGE_START)
return false;
out_start = start;
return true;
}
inline bool mota_nrf52_stage_plan(uint32_t total_size, uint32_t app_base, uint32_t app_end,
uint32_t& out_start) {
return mota_nrf52_stage_plan(total_size, app_base, app_end,
+10 -9
View File
@@ -923,9 +923,8 @@ bool OtaManager::wantRow(const uint8_t* mid, uint32_t target, uint8_t codec, uin
if (_fstate == COMPLETE && memcmp(mid, _fid, 4) == 0) return false; // already have it
if (flags & ~MFLAG_KNOWN) return false;
const bool bootloader = (flags & MFLAG_BOOTLOADER) != 0;
if (!_archive_fetch && bootloader &&
(!_accept_bootloader || !_have_desired_mid || codec != CODEC_FULL)) return false;
if (!_archive_fetch && !codecOk(codec)) return false; // can't apply this codec
if (!_archive_fetch && !fetchCodecOk(codec, bootloader, _have_desired_mid))
return false; // can't or must not apply this class
if (_have_desired_mid) // manual pull of a specific mid
return memcmp(mid, _desired_mid, 4) == 0 && (_desired_target == 0 || target == _desired_target);
if (_desired_target) return target == _desired_target; // cross-target want (role switch)
@@ -1176,11 +1175,10 @@ void OtaManager::handleManifest(const uint8_t* m, uint16_t n) {
MotaManifest parsed;
if (!mota_parse_manifest(mf, mfl, parsed)) { failFetch(FETCH_ERROR_MANIFEST); return; }
if (parsed.hash_algo != HASH_ALGO_SHA256) { failFetch(FETCH_ERROR_HASH_ALGO); return; }
if (!_archive_fetch && parsed.is_bootloader() &&
(!_accept_bootloader || !_have_desired_mid)) {
if (!_archive_fetch &&
!fetchCodecOk(parsed.codec_id, parsed.is_bootloader(), _have_desired_mid)) {
failFetch(FETCH_ERROR_CODEC); return; // bootloader packages are manual-pull only
}
if (!_archive_fetch && !codecOk(parsed.codec_id)) { failFetch(FETCH_ERROR_CODEC); return; }
uint32_t payload_size = parsed.payload_size;
uint8_t bsl = parsed.block_size_log2;
if (bsl >= 32) { failFetch(FETCH_ERROR_GEOMETRY); return; }
@@ -1204,7 +1202,8 @@ void OtaManager::handleManifest(const uint8_t* m, uint16_t n) {
// placement and refuse an unfittable fetch up front: a FULL payload streams to the inactive slot,
// a delta's whole container is staged together. (image_size at mf+11, is_full from flags at mf+1.)
bool is_full = parsed.is_full();
if (!_fetch->plan_layout(is_full, parsed.image_size, payload_off, payload_size)) {
if (!_fetch->plan_layout(is_full, parsed.image_size, payload_off, payload_size,
parsed.is_bootloader())) {
failFetch(FETCH_ERROR_STORAGE); return;
}
if (!_fetch->begin(total)) { failFetch(FETCH_ERROR_STORAGE); return; }
@@ -1326,8 +1325,10 @@ bool OtaManager::resumeStaged(const uint8_t* want_mid) {
if (!_fetch->read(8, mbuf, mread) || !mota_parse_manifest(mbuf, mread, m)) return false;
if (want_mid && memcmp(m.merkle_root, want_mid, 4) != 0) return false; // a different fw is staged
if (m.hash_algo != HASH_ALGO_SHA256) return false;
if (!_archive_fetch && m.is_bootloader() && !_accept_bootloader) return false;
if (!_archive_fetch && !codecOk(m.codec_id)) return false;
// A bootloader partial is resumed only as part of the same explicit MID pull.
// Boot-time resumeStaged(nullptr) must not silently re-adopt privileged data.
const bool manual = want_mid != nullptr && _have_desired_mid;
if (!_archive_fetch && !fetchCodecOk(m.codec_id, m.is_bootloader(), manual)) return false;
uint32_t mfl = (uint32_t)(m.approval - m.manifest_start) + 4; // manifest-minus-leaves length
uint32_t bs = m.block_size();
if (bs == 0 || bs > OTA_MAX_BLOCK) return false;
+7
View File
@@ -364,6 +364,13 @@ public:
bool codecOk(uint8_t c) const {
return (c == CODEC_FULL && _accept_full) || c == _apply_codec || c == _apply_codec2;
}
// Bootloader FULL images are a separate, privileged install class. They are
// admitted only for an explicit MID pull on a self-update-capable build;
// accepting one must never turn ordinary application FULL images on for an
// nRF52 single-slot target.
bool fetchCodecOk(uint8_t c, bool bootloader, bool manual) const {
return bootloader ? (_accept_bootloader && manual && c == CODEC_FULL) : codecOk(c);
}
// Auto-fetch policy (manual `ota pull` always works regardless): 0=off (discover only), 1=any
// compatible own-target advert, 2=only signed adverts. Conservative default = off.
+4 -2
View File
@@ -57,8 +57,10 @@ public:
// whole container is staged together (the decoder reads the patch from it at apply). image_size is the
// reconstructed image; [payload_off, payload_off+payload_size) is the payload region in the container.
// Return false if it cannot fit the backing store (the transfer is then refused before any block).
virtual bool plan_layout(bool is_full, uint32_t image_size, uint32_t payload_off, uint32_t payload_size) {
(void)is_full; (void)image_size; (void)payload_off; (void)payload_size; return true;
virtual bool plan_layout(bool is_full, uint32_t image_size, uint32_t payload_off,
uint32_t payload_size, bool is_bootloader) {
(void)is_full; (void)image_size; (void)payload_off; (void)payload_size;
(void)is_bootloader; return true;
}
};
+4 -1
View File
@@ -54,7 +54,10 @@ bool OtaStoreFlashEsp32::layout() {
// Choose placement from the parsed manifest and refuse anything that won't fit, BEFORE any block is
// staged. (See the header for the delta-vs-full layout rationale.)
bool OtaStoreFlashEsp32::plan_layout(bool is_full, uint32_t image_size, uint32_t payload_off, uint32_t payload_size) {
bool OtaStoreFlashEsp32::plan_layout(bool is_full, uint32_t image_size,
uint32_t payload_off, uint32_t payload_size,
bool is_bootloader) {
if (is_bootloader) return false;
if (!acquire()) return false;
_full = is_full; _image_size = image_size; _meta_bytes = payload_off; _pay_size = payload_size;
bool ok = layout();
+2 -1
View File
@@ -84,7 +84,8 @@ class OtaStoreFlashEsp32 : public OtaStore {
public:
~OtaStoreFlashEsp32() override;
bool plan_layout(bool is_full, uint32_t image_size, uint32_t payload_off, uint32_t payload_size) override;
bool plan_layout(bool is_full, uint32_t image_size, uint32_t payload_off,
uint32_t payload_size, bool is_bootloader) override;
bool begin(uint32_t total_size) override;
bool write(uint32_t offset, const uint8_t* data, uint32_t len) override;
bool read(uint32_t offset, uint8_t* buf, uint32_t len) const override;
+115 -8
View File
@@ -4,6 +4,7 @@
#include "OtaSelf.h"
#include "OtaBlInfo.h"
#include "MotaContainer.h"
#include "OtaDebug.h"
#include "OtaByteIO.h" // align_down / rd_u32le (flash-page geometry + header read)
#include <string.h>
@@ -12,14 +13,55 @@
namespace mesh {
namespace ota {
// A valid EndF gives the exact live-image extent. Rescue/bring-up builds without one retain the old
// 608 KiB protection floor so staging cannot begin in the middle of a normally sized application.
// A valid EndF gives the exact live-image extent. Legacy app-only builds keep
// the conservative rescue fallback. Shared internal bootloader-update builds
// reject every package kind without EndF because their normal linker may place
// live application bytes anywhere through the 0xED000 stage ceiling.
static bool protected_app_end(uint32_t app_base, uint32_t stage_ceiling, uint32_t& app_end) {
if (!mota_nrf52_layout_valid(app_base, stage_ceiling)) return false;
app_end = app_base + MOTA_NRF52_FALLBACK_INPLACE_MEMORY;
SelfFwInfo fi;
if (ota_self_firmware(fi) && fi.valid) app_end = app_base + fi.image_len;
return app_end >= app_base && app_end <= stage_ceiling;
const bool valid = ota_self_firmware(fi) && fi.valid;
#if defined(OTA_INTERNAL_BOOTLOADER_UPDATE)
const bool refuse_without_endf = true;
#else
const bool refuse_without_endf = false;
#endif
return mota_nrf52_protected_app_end(
app_base, stage_ceiling, valid, valid ? fi.image_len : 0u,
refuse_without_endf, app_end);
}
bool OtaStoreFlashNrf52::plan_layout(bool is_full, uint32_t image_size,
uint32_t payload_off, uint32_t payload_size,
bool is_bootloader) {
_planned_bootloader = false;
_planned_total = 0;
_planned_start = 0;
if (!is_bootloader) return true;
#if defined(OTA_INTERNAL_BOOTLOADER_UPDATE)
const uint64_t total64 = (uint64_t)payload_off + payload_size + 5u;
if (!is_full || image_size != 40u * 1024u || payload_size != 40u * 1024u ||
payload_off != 365u || total64 != MOTA_NRF52_BOOT_CONTAINER_SIZE ||
!ota_bootloader_self_update_caps_valid(ota_bootloader_caps()))
return false;
// This is the release-blocking no-EndF gate: package kind is known before
// begin(), and no page may be erased until the exact live app extent proves
// the shared E2000..ED000 slot is free.
SelfFwInfo fi;
uint32_t start;
const uint32_t app_base = mota_nrf52_app_base();
if (!ota_self_firmware(fi) || !fi.valid || fi.image_len > UINT32_MAX - app_base ||
!mota_nrf52_shared_boot_stage_plan(
(uint32_t)total64, app_base, true, app_base + fi.image_len, start))
return false;
_planned_bootloader = true;
_planned_total = (uint32_t)total64;
_planned_start = start;
return true;
#else
(void)is_full; (void)image_size; (void)payload_off; (void)payload_size;
return false;
#endif
}
// Write one whole 4 KB page from `buf` to flash (erase + program, ~85 ms). `buf` is PG bytes, 0xFF-padded
@@ -88,18 +130,38 @@ uint8_t* OtaStoreFlashNrf52::write_slot(uint32_t pos) {
}
bool OtaStoreFlashNrf52::begin(uint32_t total_size) {
const bool planned_bootloader = _planned_bootloader;
const uint32_t planned_total = _planned_total;
const uint32_t planned_start = _planned_start;
clear();
// never collide with the running application image (its extent comes from its EndF trailer)
const uint32_t app_base = mota_nrf52_app_base();
const uint32_t stage_ceiling = ota_nrf52_effective_stage_ceiling();
uint32_t app_end;
if (!protected_app_end(app_base, stage_ceiling, app_end)) return false;
if (planned_bootloader) {
#if defined(OTA_INTERNAL_BOOTLOADER_UPDATE)
SelfFwInfo fi;
uint32_t checked_start;
if (stage_ceiling != MOTA_NRF52_APP_END || total_size != planned_total ||
!ota_self_firmware(fi) || !fi.valid || fi.image_len > UINT32_MAX - app_base ||
!mota_nrf52_shared_boot_stage_plan(
total_size, app_base, true, app_base + fi.image_len, checked_start) ||
checked_start != planned_start)
return false;
app_end = app_base + fi.image_len;
#else
return false;
#endif
} else if (!protected_app_end(app_base, stage_ceiling, app_end)) {
return false;
}
// Bottom-align below the selected ceiling and reject unless it sits above the running image. The
// approval path later verifies the patch's detools workspace ends at/below this exact start.
uint32_t start;
if (!mota_nrf52_stage_plan(total_size, app_base, app_end, stage_ceiling, start)) return false;
if (planned_bootloader && start != planned_start) return false;
_write_start = start;
_stage_ceiling = stage_ceiling;
@@ -109,6 +171,9 @@ bool OtaStoreFlashNrf52::begin(uint32_t total_size) {
_pay_idx = 0;
_flushed = false;
_io_ok = true;
_planned_bootloader = planned_bootloader;
_planned_total = planned_total;
_planned_start = planned_start;
OTA_DBG("OTA flash: begin total=%u start=%08x app_end=%08x ceiling=%08x\n",
(unsigned)total_size, (unsigned)start, (unsigned)app_end, (unsigned)stage_ceiling);
return true; // no pre-erase: each page is erased by its own (single) flush
@@ -160,6 +225,26 @@ bool OtaStoreFlashNrf52::finalize() {
return true;
}
#if defined(OTA_INTERNAL_BOOTLOADER_UPDATE)
bool OtaStoreFlashNrf52::approve_for_bootloader() {
if (!finalize() || _total < 8u + MOTA_MFL + 5u) return false;
const uint32_t offset = 8u + MOTA_OFF_APPROVAL;
if (offset + sizeof(APPROVAL_YES) > _total) return false;
const uint32_t address = _write_start + offset;
if (flash_nrf5x_write(address, APPROVAL_YES, sizeof(APPROVAL_YES)) < 0) {
_io_ok = false;
return false;
}
flash_nrf5x_flush();
if (!flash_matches(address, APPROVAL_YES, sizeof(APPROVAL_YES))) {
_io_ok = false;
return false;
}
memcpy(_meta_page + offset, APPROVAL_YES, sizeof(APPROVAL_YES));
return true;
}
#endif
// Persist mid-transfer progress so a reboot can resume. Order matters for consistency: flush the open
// payload page FIRST, then page 0 (the leaf-progress markers) -- so every block whose leaf is now in flash
// also has its payload in flash. Infrequent (every OTA_CHECKPOINT_BLOCKS blocks), so the 2 extra page
@@ -184,8 +269,27 @@ bool OtaStoreFlashNrf52::reopen() {
const uint8_t* p = (const uint8_t*)(uintptr_t)start;
if (memcmp(p, MOTA_MAGIC, 4) != 0) continue;
uint32_t total = rd_u32le(p + 4);
if (!mota_nrf52_container_span_valid(
start, stage_ceiling, total, 8u + MOTA_MFL + 5u))
continue;
uint32_t want; // must be valid + placed exactly where begin() would have staged it (same bounds fn)
if (!mota_nrf52_stage_plan(total, app_base, app_end, stage_ceiling, want) || want != start) continue;
MotaManifest manifest;
const bool manifest_ok = mota_parse_manifest(p + 8u, MOTA_MFL, manifest);
const bool bootloader = manifest_ok && manifest.is_bootloader();
if (bootloader) {
#if defined(OTA_INTERNAL_BOOTLOADER_UPDATE)
SelfFwInfo fi;
if (!ota_self_firmware(fi) || !fi.valid || fi.image_len > UINT32_MAX - app_base ||
!mota_nrf52_shared_boot_stage_plan(
total, app_base, true, app_base + fi.image_len, want) || want != start)
continue;
#else
continue;
#endif
} else if (!mota_nrf52_stage_plan(
total, app_base, app_end, stage_ceiling, want) || want != start) {
continue;
}
_write_start = start;
_stage_ceiling = stage_ceiling;
_total = total;
@@ -194,6 +298,9 @@ bool OtaStoreFlashNrf52::reopen() {
_pay_idx = 0;
_flushed = false;
_io_ok = true;
_planned_bootloader = bootloader;
_planned_total = bootloader ? total : 0u;
_planned_start = bootloader ? start : 0u;
OTA_DBG("OTA flash: reopen total=%u start=%08x ceiling=%08x\n",
(unsigned)total, (unsigned)start, (unsigned)stage_ceiling);
return true;
+15
View File
@@ -40,6 +40,9 @@ class OtaStoreFlashNrf52 : public OtaStore {
uint32_t _total = 0; // staged container size (0 = none)
bool _flushed = false; // finalize() committed everything to flash
bool _io_ok = true; // cleared on a failed/out-of-bounds flash write or readback mismatch
bool _planned_bootloader = false; // manifest-kind decision made before begin()/any erase
uint32_t _planned_total = 0;
uint32_t _planned_start = 0;
uint8_t _meta_page[PG]; // pinned flash page 0 (header + manifest + leaves + 1st payload)
uint8_t _pay_page[PG]; // sliding buffer for one payload page (index _pay_idx)
@@ -56,6 +59,8 @@ class OtaStoreFlashNrf52 : public OtaStore {
bool flush_page(uint32_t page_idx, const uint8_t* buf); // write + verify a full page
public:
bool plan_layout(bool is_full, uint32_t image_size, uint32_t payload_off,
uint32_t payload_size, bool is_bootloader) override;
bool begin(uint32_t total_size) override;
bool write(uint32_t offset, const uint8_t* data, uint32_t len) override;
bool read(uint32_t offset, uint8_t* buf, uint32_t len) const override;
@@ -64,12 +69,22 @@ public:
void clear() override {
_stage_ceiling = MOTA_NRF52_STAGE_CEILING_LEGACY;
_total = 0; _pay_idx = 0; _flushed = false; _io_ok = true;
_planned_bootloader = false; _planned_total = 0; _planned_start = 0;
}
bool set_meta_size(uint32_t meta_bytes) override { return meta_bytes <= PG; } // leaves must fit page 0
bool finalize() override;
void checkpoint() override; // persist page 0 (leaves) + the open payload page so a reboot can resume
bool reopen() override; // re-attach to a container already staged in flash (scan for it)
#if defined(OTA_INTERNAL_BOOTLOADER_UPDATE)
// Clear the manifest approval word only after the privileged signed-image gates pass. OTAFIX scans
// this page-aligned shared container after the ED handoff and repeats the structural, identity,
// payload-integrity, in-place compaction, and readback checks before self-write. APRV carries the app's completed
// signature/allowlist and explicit-operator authorization decision across the reboot.
bool approve_for_bootloader();
const char* last_error() const { return _io_ok ? "" : "internal flash write/verify failed"; }
#endif
// Contiguous view (flash is memory-mapped). VALID ONLY AFTER finalize() - before that, page 0 and the
// tail are still in RAM. OtaManager/OtaCli/verify use this only once the transfer is COMPLETE.
const uint8_t* data() const { return (_flushed && _io_ok) ? (const uint8_t*)(uintptr_t)_write_start : nullptr; }
+2 -1
View File
@@ -676,7 +676,8 @@ uint32_t OtaStoreQspiNrf52::capacity() const {
return result;
}
bool OtaStoreQspiNrf52::plan_layout(bool, uint32_t image_size, uint32_t, uint32_t payload_size) {
bool OtaStoreQspiNrf52::plan_layout(bool, uint32_t image_size, uint32_t,
uint32_t payload_size, bool) {
// This is the start of a new manifest admission attempt. Clear diagnostics
// left by the normal empty-store reopen probe, then latch the first error
// from this attempt until the operator reads it or starts another attempt.
+2 -1
View File
@@ -159,7 +159,8 @@ public:
bool finalize() override;
void checkpoint() override;
bool reopen() override;
bool plan_layout(bool is_full, uint32_t image_size, uint32_t payload_off, uint32_t payload_size) override;
bool plan_layout(bool is_full, uint32_t image_size, uint32_t payload_off,
uint32_t payload_size, bool is_bootloader) override;
// Sets APRV only after all app-side verification gates have passed.
bool approve_for_bootloader();
+3 -1
View File
@@ -368,7 +368,9 @@ bool OtaStoreSdNrf52::locate_file() {
}
bool OtaStoreSdNrf52::plan_layout(bool, uint32_t image_size,
uint32_t, uint32_t payload_size) {
uint32_t, uint32_t payload_size,
bool is_bootloader) {
if (is_bootloader) return false;
const uint32_t app_base = mota_nrf52_app_base();
if (image_size == 0 || payload_size == 0 || app_base >= MOTA_NRF52_APP_END ||
image_size > MOTA_NRF52_APP_END - app_base) {
+2 -1
View File
@@ -33,7 +33,8 @@ public:
void checkpoint() override;
bool reopen() override;
bool plan_layout(bool is_full, uint32_t image_size,
uint32_t payload_off, uint32_t payload_size) override;
uint32_t payload_off, uint32_t payload_size,
bool is_bootloader) override;
// Called only after the app has verified payload, base, signature and trust.
// Writes APRV into the file, then publishes the raw-sector handoff record.
+12 -1
View File
@@ -2,7 +2,7 @@
#include <stdint.h>
// AUTO-GENERATED by tools/mota/gen_targets.py - do not edit by hand.
// 566 OTA-capable PlatformIO envs. Maps target_id (= sha2-256:4 of the env name, LE uint32)
// 577 OTA-capable build targets. Maps target_id (= sha2-256:4 of the target name, LE uint32)
// to the human-readable env name, so a node/tool can name a target seen over the air WITHOUT
// transmitting the string in the .mota / LoRa protocol. Regenerate when the OTA env set changes.
// Size-constrained receivers can set OTA_TARGET_NAME_TABLE=0. They still match targets by ID;
@@ -31,6 +31,7 @@ inline const char* ota_target_env_name(uint32_t target_id) {
{ 0xf9986af5, "GAT562_30S_Mesh_Kit_room_server" },
{ 0x98519a1d, "GAT562_Mesh_EVB_Pro_kiss_modem" },
{ 0x06b71719, "GAT562_Mesh_EVB_Pro_repeater" },
{ 0x6384719d, "GAT562_Mesh_EVB_Pro_repeater_lora_ota_no_external_sensors" },
{ 0x4c0461c8, "GAT562_Mesh_EVB_Pro_room_server" },
{ 0x4fa88992, "GAT562_Mesh_Tracker_Pro_companion_radio_ble" },
{ 0xcd6604d0, "GAT562_Mesh_Tracker_Pro_companion_radio_usb" },
@@ -101,6 +102,7 @@ inline const char* ota_target_env_name(uint32_t target_id) {
{ 0x2f9f8e7d, "Heltec_t114_without_display_companion_radio_usb" },
{ 0xc9dc5d8c, "Heltec_t114_without_display_repeater" },
{ 0x34b0e5f3, "Heltec_t114_without_display_repeater_bridge_rs232" },
{ 0xb4d0ad97, "Heltec_t114_without_display_repeater_lora_ota_no_external_sensors" },
{ 0xcfa0e233, "Heltec_t114_without_display_room_server" },
{ 0x17d5e1ff, "Heltec_T190_companion_radio_ble_" },
{ 0xc99313c6, "Heltec_T190_companion_radio_usb_" },
@@ -112,6 +114,7 @@ inline const char* ota_target_env_name(uint32_t target_id) {
{ 0x57172fcc, "Heltec_T190_room_server_observer_mqtt" },
{ 0xa529b4f5, "Heltec_t1_repeater_lora_ota_no_external_sensors" },
{ 0x85a8c944, "Heltec_tower_v2_repeater" },
{ 0x3c60586d, "Heltec_tower_v2_repeater_lora_ota_no_external_sensors" },
{ 0x0a9dbbf0, "Heltec_tower_v2_sdcard_repeater_lora_ota_no_external_sensors" },
{ 0xb519a89f, "heltec_tracker_v1_1_repeater_observer_mqtt" },
{ 0xa3e8d8ce, "heltec_tracker_v1_1_room_server_observer_mqtt" },
@@ -271,6 +274,7 @@ inline const char* ota_target_env_name(uint32_t target_id) {
{ 0xe5331f02, "ikoka_stick_nrf_33dbm_repeater" },
{ 0xfba7177b, "ikoka_stick_nrf_33dbm_room_server" },
{ 0xde7dc472, "KeepteenLT1_repeater" },
{ 0x3625bb80, "KeepteenLT1_repeater_lora_ota_no_external_sensors" },
{ 0x69df7765, "LilyGo_T-Echo-Lite_companion_radio_ble" },
{ 0xe9fbc9be, "LilyGo_T-Echo-Lite_kiss_modem" },
{ 0x40818863, "LilyGo_T-Echo-Lite_non_shell_companion_radio_ble" },
@@ -344,6 +348,7 @@ inline const char* ota_target_env_name(uint32_t target_id) {
{ 0xe6bff8f9, "M5Stack_Unit_C6L_repeater" },
{ 0xb9cd26cb, "M5Stack_Unit_C6L_room_server" },
{ 0x9bc34bd5, "Mesh_pocket_repeater" },
{ 0x144504b0, "Mesh_pocket_repeater_lora_ota_no_external_sensors" },
{ 0xdbfe2675, "Meshadventurer_sx1262_companion_radio_ble" },
{ 0xb00241d0, "Meshadventurer_sx1262_companion_radio_usb" },
{ 0x88e77fee, "Meshadventurer_sx1262_kiss_modem" },
@@ -368,6 +373,7 @@ inline const char* ota_target_env_name(uint32_t target_id) {
{ 0x6ebf28cb, "meshnology_w12_sensor" },
{ 0x2d589527, "meshnology_w12_terminal_chat" },
{ 0x77d0810c, "Minewsemi_me25ls01_repeater" },
{ 0x11b131e7, "Minewsemi_me25ls01_repeater_lora_ota_no_external_sensors" },
{ 0xf8259880, "Nano_G2_Ultra_repeater" },
{ 0x063f1f00, "nibble_screen_connect_companion_radio_ble_" },
{ 0xa8a79da5, "nibble_screen_connect_companion_radio_usb_" },
@@ -389,6 +395,7 @@ inline const char* ota_target_env_name(uint32_t target_id) {
{ 0xbd6a1c97, "ProMicro_kiss_modem" },
{ 0xf4d98c12, "ProMicro_repeater" },
{ 0xccac4283, "ProMicro_repeater_bridge_rs232_serial1" },
{ 0xa68c09e9, "ProMicro_repeater_lora_ota_no_external_sensors" },
{ 0x5f606274, "ProMicro_room_server" },
{ 0x84923735, "ProMicro_sensor" },
{ 0x2c0b8b05, "ProMicro_terminal_chat" },
@@ -396,6 +403,7 @@ inline const char* ota_target_env_name(uint32_t target_id) {
{ 0xcb6555ee, "R1Neo_companion_radio_usb" },
{ 0xd60daf25, "R1Neo_kiss_modem" },
{ 0x410bd800, "R1Neo_repeater" },
{ 0x9aca380e, "R1Neo_repeater_lora_ota_no_external_sensors" },
{ 0xd8641710, "R1Neo_room_server" },
{ 0x4d0b1601, "R1Neo_sensor" },
{ 0xab2ac09e, "R1Neo_terminal_chat" },
@@ -425,6 +433,7 @@ inline const char* ota_target_env_name(uint32_t target_id) {
{ 0x60683191, "RAK_WisMesh_Tag_companion_radio_usb" },
{ 0x8a7cb79b, "RAK_WisMesh_Tag_kiss_modem" },
{ 0x4fa4ae55, "RAK_WisMesh_Tag_repeater" },
{ 0xd1d61a8b, "RAK_WisMesh_Tag_repeater_lora_ota_no_external_sensors" },
{ 0x9cb0b232, "RAK_WisMesh_Tag_room_server" },
{ 0x51fa1a49, "RAK_WisMesh_Tag_sensor" },
{ 0xdb73638b, "SenseCap_Solar_repeater" },
@@ -458,6 +467,7 @@ inline const char* ota_target_env_name(uint32_t target_id) {
{ 0xff4102d6, "t1000e_companion_radio_usb" },
{ 0x2f9a9185, "t1000e_kiss_modem" },
{ 0x000fc519, "t1000e_repeater" },
{ 0x7b071fa0, "t1000e_repeater_lora_ota_no_external_sensors" },
{ 0x3e55e34d, "t1000e_room_server" },
{ 0x16482630, "T_Beam_S3_Supreme_SX1262_companion_radio_ble" },
{ 0xce0f07ad, "T_Beam_S3_Supreme_SX1262_companion_radio_ble_ps" },
@@ -506,6 +516,7 @@ inline const char* ota_target_env_name(uint32_t target_id) {
{ 0x37928233, "ThinkNode_M3_companion_radio_usb" },
{ 0x88ac45da, "ThinkNode_M3_kiss_modem" },
{ 0x087f99c0, "ThinkNode_M3_repeater" },
{ 0xdf41affe, "ThinkNode_M3_repeater_lora_ota_no_external_sensors" },
{ 0x2afe6c63, "ThinkNode_M3_room_server" },
{ 0xf2d61a1c, "ThinkNode_M5_companion_radio_ble" },
{ 0x14b1b097, "ThinkNode_M5_companion_radio_ble_ps" },
+2 -2
View File
@@ -70,8 +70,8 @@ does not reflect the GoogleTest count -- run the built binary directly
| `test_user_gpio` | `src/helpers/UserGpio.cpp`, `UserGpioReplyTracker.h` | board-approved pins, get/set/reset, timed nonblocking transitions, duplicate suppression, rollover, and completion-reply routing |
| `test_utf8_helpers` | `src/helpers/UTF8Helpers.h` | byte-limit truncation at complete code-point boundaries and rejection of malformed or truncated UTF-8 |
| `test_wifi_ota_seeder_policy` | `src/helpers/WiFiOtaSeederPolicy.h`, `WiFiOtaSeederStatus.h` | listener state versus network availability, serial/TCP folder ownership, detach detection, and bounded status formatting |
| `test_ota` | `src/helpers/ota/` | v2 application/v3 bootloader parser separation; XIAO identity, vector, capability, explicit-confirmation and no-autofetch gates; container and EndF integrity; protocol codecs; transfer, resume, and layered apply safety; adaptive 2-to-4 block-request window growth and stall contraction; active-transfer priority classification |
| `test_trace_retry` | `src/Mesh.cpp` retry and relay policy | opaque OTA relay behavior during TempRadio; background discovery priority; immediate primary transfer relay, receive-delay bypass, fast CAD retry, and no generic flood retry; trace and non-OTA flood retry timing |
| `test_ota` | `src/helpers/ota/` | v2 application/v3 bootloader parser separation; legacy XIAO and generic embedded identity, vector, capability, explicit-confirmation, codec-isolation, shared-slot placement/no-EndF erase gates, and no-autofetch gates; container and EndF integrity; protocol codecs; transfer, resume, and layered apply safety; adaptive 2-to-4 block-request window growth and stall contraction; active-transfer priority classification |
| `test_trace_retry` | `src/Mesh.cpp` retry and relay policy | app-v2 and boot-v3 traffic sharing `PAYLOAD_TYPE_OTA=0x0C` and the TempRadio suspend policy; opaque OTA relay behavior; background discovery priority; immediate primary transfer relay, receive-delay bypass, fast CAD retry, and no generic flood retry; trace and non-OTA flood retry timing |
| `test_utils` | `src/Utils.cpp` | `Utils::toHex` (upstream) |
## Conventions (and how to add a suite)
+124 -6
View File
@@ -24,6 +24,8 @@ extern "C" {
using namespace mesh::ota;
static std::vector<uint8_t> boot_manifest_bytes();
TEST(OtaBootResult, AcceptsOnlyOtafixApplyDiagnostics) {
EXPECT_EQ(ota_nrf52_boot_result_or_zero(0x90), 0x90);
EXPECT_EQ(ota_nrf52_boot_result_or_zero(0x9F), 0x9F);
@@ -41,9 +43,77 @@ TEST(OtaBootResult, AcceptsOnlyOtafixApplyDiagnostics) {
EXPECT_EQ(ota_nrf52_boot_result_or_zero(0x51), 0x00); // QSPI handoff
EXPECT_EQ(ota_nrf52_boot_result_or_zero(0xD4), 0x00); // legacy handoff
EXPECT_EQ(ota_nrf52_boot_result_or_zero(0xED), 0x00); // expanded handoff
EXPECT_EQ(ota_nrf52_boot_result_or_zero(0xE0), 0x00); // retired handoff remains non-result
EXPECT_EQ(ota_nrf52_boot_result_or_zero(0xBD), 0x00);
}
TEST(OtaBootPackage, GenericIdentityUsesBoardAndNameForCollisionSafeTarget) {
const uint32_t shared_board_id = 0x239A0029u;
const uint8_t rak3401_name[16] = {'3','4','0','1','_','D','F','U',0,0,0,0,0,0,0,0};
const uint8_t rak4631_name[16] = {'4','6','3','1','_','D','F','U',0,0,0,0,0,0,0,0};
uint8_t hw3401[32], hw4631[32];
ASSERT_TRUE(ota_bootloader_hw_id(shared_board_id, rak3401_name, hw3401));
ASSERT_TRUE(ota_bootloader_hw_id(shared_board_id, rak4631_name, hw4631));
EXPECT_EQ(0, memcmp(hw3401, "NRF_BL_239A0029_3401_DFU", 24));
EXPECT_EQ(0, memcmp(hw4631, "NRF_BL_239A0029_4631_DFU", 24));
EXPECT_NE(0, memcmp(hw3401, hw4631, sizeof(hw3401)));
EXPECT_EQ(ota_bootloader_target_id(shared_board_id, rak3401_name), 0x23818A80u);
EXPECT_EQ(ota_bootloader_target_id(shared_board_id, rak4631_name), 0x2D0DF000u);
uint8_t invalid[16] = {'B','A','D',0,'T','A','I','L',0,0,0,0,0,0,0,0};
EXPECT_FALSE(ota_bootloader_device_name_valid(shared_board_id, invalid));
memset(invalid, 'A', sizeof(invalid));
EXPECT_FALSE(ota_bootloader_device_name_valid(shared_board_id, invalid));
EXPECT_FALSE(ota_bootloader_board_id_valid(0));
EXPECT_FALSE(ota_bootloader_board_id_valid(UINT32_MAX));
auto raw = boot_manifest_bytes();
wr_u32le(raw.data() + 3, ota_bootloader_target_id(shared_board_id, rak3401_name));
memcpy(raw.data() + 57, hw3401, sizeof(hw3401));
MotaManifest manifest;
ASSERT_TRUE(mota_parse_manifest(raw.data(), raw.size(), manifest));
OtaBootloaderIdentity installed;
installed.present = installed.crc_ok = true;
installed.image_start = OTA_BOOT_IMAGE_START;
installed.image_size = OTA_BOOT_IMAGE_SIZE;
installed.board_id = shared_board_id;
memcpy(installed.device_name, rak3401_name, sizeof(rak3401_name));
uint8_t mid[4] = {0x11,0x22,0x33,0x44};
uint8_t hash8[8]; memcpy(hash8, manifest.image_hash, sizeof(hash8));
EXPECT_EQ(ota_bootloader_confirmation_gate(manifest, installed, mid, mid, hash8),
OTA_BOOT_CONFIRM_OK);
wr_u32le(raw.data() + 3, shared_board_id); // raw USB ID is not the generic wire target
ASSERT_TRUE(mota_parse_manifest(raw.data(), raw.size(), manifest));
EXPECT_EQ(ota_bootloader_confirmation_gate(manifest, installed, mid, mid, hash8),
OTA_BOOT_CONFIRM_TARGET);
struct KnownIdentity { uint32_t board_id; const char* name; uint32_t target_id; };
const KnownIdentity known[] = {
{0x239A0071u, "TOWER_V2_OTA", 0x1150F50Eu},
{0x239A0071u, "T096_DFU", 0x42354C85u},
{0x239A0071u, "T1_DFU", 0xFC556FFCu},
{0x239A0071u, "T114_DFU", 0x0C3F2902u},
{0x239A0071u, "MESH_POCKET_OTA",0x059277F4u},
{0x239A00B3u, "KeepteenLT1_OTA",0xDB2E7B51u},
{0x239A0029u, "MX25_DFU", 0x026AA982u},
{0x239A00B3u, "PROM_DFU", 0xAF79E8CCu},
{0x28860057u, "T1KE_DFU", 0xE6F5F03Fu},
{0x239A00DAu, "TNM3_DFU", 0x0CA41DB2u},
{0x239A0029u, "3401_DFU", 0x23818A80u},
{0x239A0029u, "4631_DFU", 0x2D0DF000u},
{0x239A0029u, "RTAG_DFU", 0xC72E9C9Cu},
};
uint32_t targets[sizeof(known) / sizeof(known[0])] = {0};
for (size_t i = 0; i < sizeof(known) / sizeof(known[0]); i++) {
uint8_t name[16] = {0};
ASSERT_LT(strlen(known[i].name), sizeof(name));
memcpy(name, known[i].name, strlen(known[i].name));
targets[i] = ota_bootloader_target_id(known[i].board_id, name);
EXPECT_EQ(targets[i], known[i].target_id);
for (size_t j = 0; j < i; j++) EXPECT_NE(targets[i], targets[j]);
}
}
static std::vector<uint8_t> boot_manifest_bytes() {
std::vector<uint8_t> m(MOTA_MFL, 0);
m[0] = MOTA_BOOT_FORMAT_VER;
@@ -146,6 +216,27 @@ TEST(OtaBootPackage, EmbeddedIdentityVectorCapsAndExplicitConfirmationGate) {
marker[15] = 1;
EXPECT_FALSE(ota_bootloader_caps_marker_parse(marker, caps));
std::vector<uint8_t> internal_caps_image(64, 0xFF);
const uint8_t internal_marker[16] = {
'M','O','T','A','B','L','D','R', 3,0, 1,0,
(uint8_t)(OTA_BL_STORAGE_STAGE_CEILING | OTA_BL_STORAGE_BOOT_UPDATE), 0,0,0};
memcpy(internal_caps_image.data() + 4, internal_marker, sizeof(internal_marker));
EXPECT_TRUE(ota_bootloader_caps_from_image(
internal_caps_image.data(), internal_caps_image.size(),
OTA_BL_STORAGE_STAGE_CEILING | OTA_BL_STORAGE_BOOT_UPDATE,
caps));
EXPECT_FALSE(ota_bootloader_caps_from_image(
internal_caps_image.data(), internal_caps_image.size(),
OTA_BL_STORAGE_STAGE_CEILING | OTA_BL_STORAGE_QSPI | OTA_BL_STORAGE_BOOT_UPDATE,
caps));
memcpy(internal_caps_image.data() + 24, internal_marker, sizeof(internal_marker));
internal_caps_image[24 + 12] =
OTA_BL_STORAGE_STAGE_CEILING | OTA_BL_STORAGE_QSPI | OTA_BL_STORAGE_BOOT_UPDATE;
EXPECT_FALSE(ota_bootloader_caps_from_image(
internal_caps_image.data(), internal_caps_image.size(),
OTA_BL_STORAGE_STAGE_CEILING | OTA_BL_STORAGE_BOOT_UPDATE,
caps));
OtaBlCaps selected;
selected.present = true;
selected.apply_abi = 3;
@@ -176,20 +267,38 @@ TEST(OtaBootPackage, EmbeddedIdentityVectorCapsAndExplicitConfirmationGate) {
}
TEST(OtaBootPackage, CapabilityScannerRejectsAnOtherwiseValidUnalignedMarker) {
const uint8_t qspi_profile =
OTA_BL_STORAGE_STAGE_CEILING | OTA_BL_STORAGE_QSPI |
OTA_BL_STORAGE_BOOT_UPDATE;
const uint8_t marker[16] = {'M','O','T','A','B','L','D','R', 3,0, 1,0,
(uint8_t)(OTA_BL_STORAGE_QSPI | OTA_BL_STORAGE_BOOT_UPDATE), 0,0,0};
qspi_profile, 0,0,0};
uint8_t image[64];
memset(image, 0xFF, sizeof(image));
memcpy(image + 1, marker, sizeof(marker));
EXPECT_FALSE(ota_bl_caps_scan_aligned(image, sizeof(image), true).present);
EXPECT_FALSE(ota_bl_caps_scan_aligned(image, sizeof(image), true, qspi_profile).present);
memset(image, 0xFF, sizeof(image));
memcpy(image + 4, marker, sizeof(marker));
const OtaBlCaps caps = ota_bl_caps_scan_aligned(image, sizeof(image), true);
const OtaBlCaps caps = ota_bl_caps_scan_aligned(image, sizeof(image), true, qspi_profile);
ASSERT_TRUE(caps.present);
EXPECT_EQ(caps.apply_abi, 3u);
EXPECT_EQ(caps.storage_flags,
(uint8_t)(OTA_BL_STORAGE_QSPI | OTA_BL_STORAGE_BOOT_UPDATE));
EXPECT_EQ(caps.storage_flags, qspi_profile);
// A malformed aligned magic decoy is ignored, but a second fully valid
// privileged marker makes the installed capability identity ambiguous.
memcpy(image + 24, marker, sizeof(marker));
EXPECT_FALSE(ota_bl_caps_scan_aligned(image, sizeof(image), true, qspi_profile).present);
memset(image + 24, 0xFF, sizeof(marker));
memcpy(image + 24, marker, sizeof(marker));
image[24 + 13] = 1;
EXPECT_TRUE(ota_bl_caps_scan_aligned(image, sizeof(image), true, qspi_profile).present);
// A structurally valid marker for the other storage profile is not an
// installed-profile candidate and cannot shadow the exact marker.
memcpy(image + 24, marker, sizeof(marker));
image[24 + 12] = OTA_BL_STORAGE_STAGE_CEILING |
OTA_BL_STORAGE_BOOT_UPDATE;
EXPECT_TRUE(ota_bl_caps_scan_aligned(image, sizeof(image), true, qspi_profile).present);
}
class FakeMotaSeederStream : public Stream {
@@ -1979,7 +2088,7 @@ TEST(OtaTransfer, BootloaderPackageRequiresCapableExplicitMidPull) {
OtaStoreRam<45000> capable_store;
capable.begin(OTA_XIAO_BOARD_ID_BASE, nullptr, nullptr);
capable.set_fetch_store(&capable_store);
capable.set_accept_full(true);
capable.set_accept_full(false); // internal nRF52 app FULL remains forbidden
capable.set_accept_bootloader(true);
capable.set_autofetch(OtaManager::AUTOFETCH_ANY);
capable.on_message(
@@ -1988,6 +2097,15 @@ TEST(OtaTransfer, BootloaderPackageRequiresCapableExplicitMidPull) {
EXPECT_EQ(capable.fetchState(), OtaManager::IDLE);
EXPECT_EQ(capable_store.staged_size(), 0u);
// Enabling the privileged bootloader path must not broaden the ordinary
// single-slot application codec policy or turn autofetch on for FULL apps.
const uint8_t app_mid[4] = {0x21, 0x22, 0x23, 0x24};
capable.on_message(
wire, make_have1(wire, sizeof(wire), app_mid, OTA_XIAO_BOARD_ID_BASE, 2,
CODEC_FULL, MFLAG_FULL));
EXPECT_EQ(capable.fetchState(), OtaManager::IDLE);
EXPECT_EQ(capable_store.staged_size(), 0u);
// An exact explicit pull admits the strict v3 manifest and opens the store for the 40 KiB payload.
EXPECT_EQ(capable.pull(mid, OTA_XIAO_BOARD_ID_BASE), OtaManager::PULL_STARTED);
deliver_manifest_fragment(capable, mid, 0, manifest.data(), OTA_MF_FRAG);
+126 -1
View File
@@ -18,6 +18,7 @@ static constexpr uint32_t APP_V6 = MOTA_NRF52_APP_BASE_S140_V6;
static constexpr uint32_t APP_V7 = MOTA_NRF52_APP_BASE_S140_V7;
static constexpr uint32_t LEGACY = MOTA_NRF52_STAGE_CEILING_LEGACY;
static constexpr uint32_t EXPANDED = MOTA_NRF52_STAGE_CEILING_EXPANDED;
static constexpr uint32_t SHARED_BOOT_START = MOTA_NRF52_SHARED_BOOT_STAGE_START;
static constexpr uint32_t APP_IMAGE_SIZE = 520u * 1024u;
static constexpr uint32_t APP_END_V6 = APP_V6 + APP_IMAGE_SIZE;
static constexpr uint32_t APP_END_V7 = APP_V7 + APP_IMAGE_SIZE;
@@ -190,10 +191,134 @@ TEST(OtaFlashPlan, RejectsAppOutsideSelectedRegion) {
TEST(OtaFlashPlan, RejectsUnknownCeiling) {
uint32_t start = 0x1234ABCD;
EXPECT_FALSE(mota_nrf52_stage_plan(4096, APP_V6, APP_V6, 0xE0000u, start));
EXPECT_FALSE(mota_nrf52_stage_plan(4096, APP_V6, APP_V6, 0xE1000u, start));
EXPECT_EQ(start, 0x1234ABCDu);
}
TEST(OtaFlashPlan, BootPackageUsesOneDynamicSharedInternalSlot) {
// v3 = header + fixed manifest + forty leaves + 40 KiB image + trailer.
const uint32_t total = 8u + 197u + 40u * 4u + 40u * 1024u + 5u;
ASSERT_EQ(total, MOTA_NRF52_BOOT_CONTAINER_SIZE);
uint32_t start = 0;
ASSERT_TRUE(mota_nrf52_shared_boot_stage_plan(
total, APP_V6, true, SHARED_BOOT_START, start));
EXPECT_EQ(start, SHARED_BOOT_START);
EXPECT_EQ(start, (EXPANDED - total) & ~(MOTA_NRF52_FLASH_PAGE - 1u));
// OTAFIX compacts payload offset +365 forward inside these same eleven
// pages; no independent scratch bank participates in the internal path.
const uint32_t payload_offset = 8u + 197u + 40u * 4u;
EXPECT_EQ(payload_offset, 365u);
EXPECT_LE(start + payload_offset + 40u * 1024u, EXPANDED);
EXPECT_LE(start + 40u * 1024u, EXPANDED);
// Runtime headroom, not a special linker, is authoritative.
EXPECT_FALSE(mota_nrf52_shared_boot_stage_plan(
total, APP_V6, true, SHARED_BOOT_START + 1u, start));
EXPECT_FALSE(mota_nrf52_shared_boot_stage_plan(
total - 1u, APP_V6, true, APP_V6, start));
EXPECT_FALSE(mota_nrf52_shared_boot_stage_plan(
total + 1u, APP_V6, true, APP_V6, start));
}
TEST(OtaFlashPlan, SharedInternalStoreKeepsNormalApplicationCeilingAndHandoff) {
EXPECT_TRUE(mota_nrf52_target_image_fits(
APP_V6, EXPANDED - APP_V6, EXPANDED));
EXPECT_FALSE(mota_nrf52_target_image_fits(
APP_V6, EXPANDED - APP_V6 + 1u, EXPANDED));
EXPECT_EQ(mota_nrf52_flash_stage_handoff(EXPANDED),
GPREGRET2_OTA_STAGE_EXPANDED);
EXPECT_EQ(mota_nrf52_flash_stage_handoff(LEGACY),
GPREGRET2_OTA_STAGE_LEGACY);
}
TEST(OtaFlashPlan, InternalOrdinaryDeltaUsesTheSameEd000Store) {
const uint32_t running_end = APP_V6 + 500u * 1024u;
const uint32_t delta_container = 28u * 1024u;
uint32_t staged = 0;
ASSERT_TRUE(mota_nrf52_stage_plan(delta_container, APP_V6, running_end,
EXPANDED, staged));
EXPECT_GE(staged, running_end);
EXPECT_LE(staged + delta_container, EXPANDED);
EXPECT_EQ(mota_nrf52_flash_stage_handoff(EXPANDED), 0xEDu);
}
TEST(OtaFlashPlan, LargeInternalOrdinaryDeltaMayStageBelowSharedBootStart) {
// An ordinary delta container can exceed the boot package's eleven pages.
// It bottom-aligns below E2000 and remains valid when detools'
// encoded workspace ends before the actual container start.
const uint32_t running_end = APP_V6 + 600u * 1024u;
const uint32_t delta_container = 80u * 1024u;
uint32_t staged = 0;
ASSERT_TRUE(mota_nrf52_stage_plan(delta_container, APP_V6, running_end,
EXPANDED, staged));
EXPECT_LT(staged, SHARED_BOOT_START);
EXPECT_GE(staged, running_end);
const uint32_t workspace = staged - APP_V6;
const uint32_t target_size = workspace;
EXPECT_TRUE(mota_nrf52_internal_patch_workspace_valid(
workspace, APP_V6, staged, target_size, EXPANDED));
EXPECT_FALSE(mota_nrf52_internal_patch_workspace_valid(
workspace + 1u, APP_V6, staged, target_size, EXPANDED));
EXPECT_FALSE(mota_nrf52_internal_patch_workspace_valid(
workspace, APP_V6, staged, EXPANDED - APP_V6 + 1u, EXPANDED));
}
TEST(OtaFlashPlan, MissingEndfRejectsEverySharedInternalPackageBeforeErase) {
uint32_t protected_end = 0;
ASSERT_TRUE(mota_nrf52_protected_app_end(
APP_V6, EXPANDED, false, 0, false, protected_end));
EXPECT_EQ(protected_end, APP_V6 + MOTA_NRF52_FALLBACK_INPLACE_MEMORY);
// A shared-internal-update build is linked through ED000, so the old 608 KiB
// estimate is not a safe erase floor for either package kind. Missing EndF
// disables all internal staging before begin()/reopen can erase a page.
EXPECT_FALSE(mota_nrf52_protected_app_end(
APP_V6, EXPANDED, false, 0, true, protected_end));
// The old rescue estimate would also admit this ordinary 80 KiB delta at
// D9000 even though a legal ED000-linked image can have live bytes there.
// A privileged shared-internal build therefore never calls stage_plan with
// that estimate: protected_app_end() above returns false/capacity zero.
const uint32_t ordinary_container = 80u * 1024u;
uint32_t unsafe_start = 0;
const uint32_t legacy_estimate =
APP_V6 + MOTA_NRF52_FALLBACK_INPLACE_MEMORY;
ASSERT_TRUE(mota_nrf52_stage_plan(
ordinary_container, APP_V6, legacy_estimate, EXPANDED, unsafe_start));
const uint32_t legal_live_tail = unsafe_start + MOTA_NRF52_FLASH_PAGE;
ASSERT_GT(legal_live_tail, legacy_estimate);
ASSERT_LT(legal_live_tail, EXPANDED);
EXPECT_FALSE(mota_nrf52_stage_plan(
ordinary_container, APP_V6, legal_live_tail, EXPANDED, protected_end));
// Model a legal linked image whose tail extends beyond the old 608 KiB
// estimate. The privileged planner rejects missing EndF even if the generic
// rescue fallback would appear to leave enough room.
const uint32_t real_tail = APP_V6 + MOTA_NRF52_FALLBACK_INPLACE_MEMORY + 0x8000u;
ASSERT_LT(real_tail, SHARED_BOOT_START);
uint32_t staged = 0;
EXPECT_FALSE(mota_nrf52_shared_boot_stage_plan(
MOTA_NRF52_BOOT_CONTAINER_SIZE, APP_V6, false, protected_end, staged));
ASSERT_TRUE(mota_nrf52_shared_boot_stage_plan(
MOTA_NRF52_BOOT_CONTAINER_SIZE, APP_V6, true, real_tail, staged));
EXPECT_EQ(staged, SHARED_BOOT_START);
}
TEST(OtaFlashPlan, ReopenBoundsUntrustedTotalBeforeManifestRead) {
const uint32_t start = SHARED_BOOT_START;
EXPECT_TRUE(mota_nrf52_container_span_valid(
start, EXPANDED, MOTA_NRF52_BOOT_CONTAINER_SIZE, 8u + 197u + 5u));
EXPECT_FALSE(mota_nrf52_container_span_valid(
start, EXPANDED, UINT32_MAX, 8u + 197u + 5u));
EXPECT_FALSE(mota_nrf52_container_span_valid(
start, EXPANDED, 8u + 197u + 4u, 8u + 197u + 5u));
EXPECT_FALSE(mota_nrf52_container_span_valid(
EXPANDED + 1u, EXPANDED, MOTA_NRF52_BOOT_CONTAINER_SIZE,
8u + 197u + 5u));
}
// out_start is only written on success - a rejected plan must not clobber the caller's variable.
TEST(OtaFlashPlan, LeavesOutputUntouchedOnReject) {
uint32_t start = 0x1234ABCD;
@@ -164,6 +164,21 @@ static mesh::Packet makeFloodPacket(uint8_t payload_type) {
return packet;
}
static mesh::Packet makeOtaManifestFragment(uint8_t format_version) {
mesh::Packet packet = makeFloodPacket(PAYLOAD_TYPE_OTA);
packet.payload_len = 12;
packet.payload[0] = mesh::ota::OTA_MANIFEST;
packet.payload[1] = 0x11;
packet.payload[2] = 0x22;
packet.payload[3] = 0x33;
packet.payload[4] = 0x44;
packet.payload[5] = 0; // fragment index
packet.payload[6] = 2; // fragment count
memcpy(packet.payload + 7, mesh::ota::MOTA_MAGIC, sizeof(mesh::ota::MOTA_MAGIC));
packet.payload[11] = format_version;
return packet;
}
TEST(RepeaterTransport, UnknownFloodPayloadIsRelayedWhenForwardingAllowsIt) {
TraceTestClock clock;
TraceTestRTC rtc;
@@ -233,6 +248,41 @@ TEST(RepeaterTransport, OtaDiscoveryRelaysInBackgroundOnlyDuringTempRadio) {
EXPECT_FALSE(node.canTransmit(&packet)); // queued-near-expiry packets cannot leak onto the normal channel
}
TEST(RepeaterTransport, AppV2AndBootV3ShareOta0cTempRadioRelayPolicy) {
// The package format is metadata inside an OTA_MANIFEST fragment. It must
// never select a second mesh payload type or bypass the repeater default
// `flood.filter.1 0x0C all suspend=tempradio` policy.
ASSERT_EQ(0x0C, PAYLOAD_TYPE_OTA);
const uint8_t formats[] = {
mesh::ota::MOTA_APP_FORMAT_VER,
mesh::ota::MOTA_BOOT_FORMAT_VER,
};
for (uint8_t format : formats) {
TraceTestClock clock;
TraceTestRTC rtc;
TraceTestRNG rng;
TraceTestRadio radio;
ForwardingTestTables tables;
StaticPoolPacketManager manager(12);
TraceTestMesh node(radio, clock, rng, rtc, manager, tables);
node.forwardFloods = true;
mesh::Packet packet = makeOtaManifestFragment(format);
ASSERT_EQ(PAYLOAD_TYPE_OTA, packet.getPayloadType());
ASSERT_EQ(format, packet.payload[11]);
EXPECT_EQ(ACTION_RELEASE, node.receivePacket(&packet));
EXPECT_EQ(0, tables.mark_seen_calls);
node.tempRadioActive = true;
mesh::DispatcherAction action = node.receivePacket(&packet);
EXPECT_NE(ACTION_RELEASE, action);
EXPECT_EQ(OTA_TRANSFER_TX_PRIORITY, (action >> 24) - 1);
EXPECT_EQ(1, tables.mark_seen_calls);
EXPECT_EQ(1, packet.getPathHashCount());
}
}
TEST(RepeaterTransport, OtaTransferRelaysAsPrimaryTrafficWithoutOtaManager) {
TraceTestClock clock;
TraceTestRTC rtc;
+17 -11
View File
@@ -25,13 +25,13 @@ Uses the repo's Python venv (`meshcore/`). Dependencies: `detools` (delta), `cry
| `motalib.py` | Core logic: multihash, EndF, merkle tree+proofs, manifest/container build/parse/verify. The **reference implementation** of the spec and the unit-test oracle. Imported by everything below. |
| `pio_endf.py` | **PlatformIO post-build hook** (wired in `platformio.ini`) that injects the `EndF` self-identity trailer into the flashed firmware (`-D ENABLE_OTA`). |
| `gen_vectors.py` | Generates `test/test_ota/mota_vectors.h` - the cross-check vectors the native C++ tests run against. |
| `gen_targets.py` | Generates `src/helpers/ota/OtaTargets.h` - the `target_id -> env-name` table (every `ENABLE_OTA` env, resolved from `pio project config`). Shared by the firmware and `motatool` so a node can name a target seen over the air without sending the string. Regenerate when the OTA env set changes. |
| `gen_targets.py` | Generates `src/helpers/ota/OtaTargets.h` - the `target_id -> env-name` table (every resolved `ENABLE_OTA` env plus qualified build.sh-only release aliases). Shared by the firmware and `motatool` so a node can name a target seen over the air without sending the string. Regenerate when the OTA env or release-alias set changes. |
| `test_mota.py` | Unit tests for `motalib` (run directly or via pytest). |
## Tests
```bash
./meshcore/bin/python tools/mota/test_mota.py # EndF, merkle+proofs, v2 apps/v3 XIAO bootloader,
./meshcore/bin/python tools/mota/test_mota.py # EndF, merkle+proofs, v2 apps/v3 nRF52 bootloader,
# signing, tamper detection, approval enforcement
./meshcore/bin/python tools/mota/gen_vectors.py # regenerate the native-test cross-check vectors
./meshcore/bin/python tools/mota/gen_targets.py # regenerate src/helpers/ota/OtaTargets.h (needs `pio`)
@@ -52,19 +52,25 @@ firmware and matches a delta's `base_hash` against its own `EndF`. Wiring (handl
family derived from the environment name, and `fw_version` = parsed from `FIRMWARE_VERSION`. A node (and
`motatool`, reading the firmware's `EndF`) therefore auto-discovers identity without relying on filenames.
## Privileged XIAO bootloader reference builder
## Privileged nRF52 bootloader reference builder
Ordinary calls to `build_manifest()` continue to emit v2 application packages.
Passing `bootloader=True` is a deliberately narrow reference-only path: it
requires a nonzero version, a signed exact 40 KiB XIAO OTAFIX region, 1024-byte
blocks, full codec, zero base hash, a sane vector table, exact `XIAO_DFU`
embedded manifest/CRC/board identity, and an ABI-3 marker retaining QSPI plus
boot-update capabilities. It derives the signed `XIAO_BL_28860044` or
`XIAO_BL_28860045` hardware ID from the embedded board ID. Parsing and
`verify()` repeat those gates; magic literals that are not complete valid
structures are skipped.
requires a nonzero version, a signed exact 40 KiB OTAFIX region, 1024-byte
blocks, full codec, zero base hash, a sane vector table, an exact embedded
manifest/CRC identity, and an ABI-3 marker retaining the selected storage plus
boot-update capabilities. Deployed XIAO packages retain their
`XIAO_BL_28860044`/`XIAO_BL_28860045` IDs. Generic packages derive the padded
`NRF_BL_<BOARD_ID>_<DEVICE_NAME>` ID and collision-checked wire target from the
full embedded manifest pair. Parsing and `verify()` repeat those gates; magic
literals that are not complete valid structures are skipped.
Generic parsing remains available for candidate inspection, but the signing
builder accepts only identities in the qualified internal inventory plus the
two deployed XIAO identities. The test suite verifies those boot target IDs are
unique and disjoint 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_qspi.md`](../../docs/ota_nrf52_qspi.md#explicit-xiao-bootloader-updates-over-lora).
[`docs/ota_nrf52_bootloader_update.md`](../../docs/ota_nrf52_bootloader_update.md).
The ordinary `tools/lora_ota` runner rejects bootloader packages.
+22 -8
View File
@@ -1,10 +1,12 @@
#!/usr/bin/env python3
"""Generate src/helpers/ota/OtaTargets.h - the target_id -> env-name table for OTA-capable envs.
"""Generate src/helpers/ota/OtaTargets.h - the target_id -> env-name table for OTA-capable builds.
A target_id is sha2-256:4(pio_env_name); it travels in beacons / the .mota manifest as 4 bytes. This
table lets a node (and motatool) print the human-readable env name for a target seen over the air, WITHOUT
ever transmitting the string. The set of envs is resolved from PlatformIO (every env whose build_flags
include ENABLE_OTA and do not include DISABLE_LORA_OTA), so explicit size/safety exclusions stay out.
ever transmitting the string. The set is the union of PlatformIO environments whose resolved build flags
enable LoRa OTA and qualified release-only aliases composed by build.sh. Keeping those aliases in the
table prevents a valid release target from being displayed as unknown merely because it is not a literal
``[env:...]`` section.
Run: ./meshcore/bin/python tools/mota/gen_targets.py # resolves via `pio project config`
./meshcore/bin/python tools/mota/gen_targets.py cfg.json # or reuse a cached config dump (faster)
@@ -21,6 +23,7 @@ import motalib as ml
ROOT = Path(__file__).resolve().parents[2]
OUT = ROOT / "src" / "helpers" / "ota" / "OtaTargets.h"
INTERNAL_BOOTLOADER_TARGETS = ROOT / "tools" / "mota" / "nrf52_internal_bootloader_targets.txt"
def resolved_config():
@@ -46,14 +49,25 @@ def ota_envs(cfg):
return sorted(set(envs))
def release_aliases():
"""Return qualified build.sh aliases that carry their own OTA target ID."""
return sorted({
line.strip()
for line in INTERNAL_BOOTLOADER_TARGETS.read_text(encoding="utf-8").splitlines()
if line.strip() and not line.lstrip().startswith("#")
})
def main():
envs = ota_envs(resolved_config())
envs = sorted(set(ota_envs(resolved_config())) | set(release_aliases()))
rows = [(ml.target_id_for_env(e), e) for e in envs]
seen = {}
for tid, e in rows: # sha2-256:4 over ~300 names - verify no collisions
for tid, e in rows: # sha2-256:4 over all names - verify no collisions
if tid in seen and seen[tid] != e:
print(f"WARNING: target_id collision 0x{tid:08x}: {seen[tid]!r} vs {e!r}", file=sys.stderr)
raise RuntimeError(
f"target_id collision 0x{tid:08x}: {seen[tid]!r} vs {e!r}"
)
seen[tid] = e
rows.sort(key=lambda r: r[1].lower()) # by env name (readable, deterministic)
@@ -62,7 +76,7 @@ def main():
"#include <stdint.h>",
"",
"// AUTO-GENERATED by tools/mota/gen_targets.py - do not edit by hand.",
f"// {len(rows)} OTA-capable PlatformIO envs. Maps target_id (= sha2-256:4 of the env name, LE uint32)",
f"// {len(rows)} OTA-capable build targets. Maps target_id (= sha2-256:4 of the target name, LE uint32)",
"// to the human-readable env name, so a node/tool can name a target seen over the air WITHOUT",
"// transmitting the string in the .mota / LoRa protocol. Regenerate when the OTA env set changes.",
"// Size-constrained receivers can set OTA_TARGET_NAME_TABLE=0. They still match targets by ID;",
@@ -96,7 +110,7 @@ def main():
]
OUT.parent.mkdir(parents=True, exist_ok=True)
OUT.write_text("\n".join(out) + "\n")
print(f"wrote {OUT} ({len(rows)} OTA envs)")
print(f"wrote {OUT} ({len(rows)} OTA targets)")
if __name__ == "__main__":
+169 -42
View File
@@ -68,6 +68,8 @@ NRF52_EXTRAFS_START = 0x000D4000
NRF52_BOOT_SCRATCH_START = 0x000E0000
NRF52_BOOT_SCRATCH_END = 0x000EA000
NRF52_APP_END = 0x000ED000
NRF52_BOOT_CONTAINER_SIZE = 41330
NRF52_SHARED_BOOT_STAGE_START = 0x000E2000
NRF52_FLASH_PAGE = 4096
# A validated nRF52 firmware carries this record immediately before EndF. It lets an offline packager
@@ -90,8 +92,34 @@ XIAO_BOOT_MANIFEST_VERSION = 1
XIAO_BOOT_MANIFEST_SIZE = 44
XIAO_BOOT_DEVICE_NAME = b"XIAO_DFU".ljust(16, b"\0")
XIAO_BOOT_CAPS_MAGIC = b"MOTABLDR"
BOOT_STORAGE_STAGE_CEILING = 0x02
XIAO_BOOT_STORAGE_QSPI = 0x04
XIAO_BOOT_STORAGE_UPDATE = 0x08
BOOT_STORAGE_KNOWN = 0x0F
BOOT_STORAGE_QSPI_UPDATE = (BOOT_STORAGE_STAGE_CEILING | XIAO_BOOT_STORAGE_QSPI |
XIAO_BOOT_STORAGE_UPDATE)
BOOT_STORAGE_INTERNAL_UPDATE = BOOT_STORAGE_STAGE_CEILING | XIAO_BOOT_STORAGE_UPDATE
# Exact embedded manifests for the qualified shared-internal nRF52840
# bootloaders. Parsing/inspection remains generic, but package creation is
# deliberately limited to this collision-audited release inventory. Carrier
# aliases use the same installed bootloader identity and therefore do not add
# rows here.
INTERNAL_BOOTLOADER_IDENTITIES = (
(0x239A0071, "TOWER_V2_OTA"),
(0x239A0071, "T096_DFU"),
(0x239A0071, "T1_DFU"),
(0x239A0071, "T114_DFU"),
(0x239A0071, "MESH_POCKET_OTA"),
(0x239A00B3, "KeepteenLT1_OTA"),
(0x239A0029, "MX25_DFU"),
(0x239A00B3, "PROM_DFU"),
(0x28860057, "T1KE_DFU"),
(0x239A00DA, "TNM3_DFU"),
(0x239A0029, "3401_DFU"),
(0x239A0029, "4631_DFU"),
(0x239A0029, "RTAG_DFU"),
)
# MeshTower V2's SD-backed OTA target keeps the staged .mota off-chip, so the application may use the
# complete S140 v6 application region up to InternalFS instead of leaving room for internal staging.
@@ -222,7 +250,6 @@ class Nrf52Layout:
def bootloader_scratch(self) -> bool:
return bool(self.flags & NRF52_LAYOUT_FLAG_BOOTLOADER_SCRATCH)
def nrf52_stage_ceiling_for_layout(linked_app_end: int, uses_internal_extrafs: bool) -> int:
"""Select a safe staging ceiling from linker geometry and actual secondary-storage type."""
if uses_internal_extrafs:
@@ -235,7 +262,8 @@ def nrf52_stage_ceiling_for_layout(linked_app_end: int, uses_internal_extrafs: b
def build_nrf52_layout(layout: Nrf52Layout) -> bytes:
if layout.app_base not in (NRF52_APP_BASE_S140_V6, NRF52_APP_BASE_S140_V7):
raise ValueError(f"unsupported nRF52 app base 0x{layout.app_base:X}")
if layout.linked_app_end not in (NRF52_EXTRAFS_START, NRF52_BOOT_SCRATCH_START, NRF52_APP_END):
if layout.linked_app_end not in (NRF52_EXTRAFS_START,
NRF52_BOOT_SCRATCH_START, NRF52_APP_END):
raise ValueError(f"unsupported nRF52 linked app end 0x{layout.linked_app_end:X}")
if layout.stage_ceiling not in (NRF52_EXTRAFS_START, NRF52_APP_END):
raise ValueError(f"unsupported nRF52 staging ceiling 0x{layout.stage_ceiling:X}")
@@ -250,10 +278,12 @@ def build_nrf52_layout(layout: Nrf52Layout) -> bytes:
if layout.external_backed and layout.flags & NRF52_LAYOUT_FLAG_INTERNAL_EXTRAFS:
raise ValueError("nRF52 external staging cannot also reserve internal ExtraFS")
if layout.bootloader_scratch:
if not layout.qspi_backed or layout.sd_backed or layout.linked_app_end != NRF52_BOOT_SCRATCH_START:
raise ValueError("nRF52 bootloader scratch requires QSPI and linked app end 0xE0000")
qspi_shape = (layout.qspi_backed and not layout.sd_backed and
layout.linked_app_end == NRF52_BOOT_SCRATCH_START)
if not qspi_shape:
raise ValueError("nRF52 bootloader scratch requires the XIAO QSPI layout")
elif layout.linked_app_end == NRF52_BOOT_SCRATCH_START:
raise ValueError("nRF52 linked app end 0xE0000 requires the bootloader-scratch flag")
raise ValueError("reserved bootloader geometry requires the bootloader-scratch flag")
expected_ceiling = (NRF52_APP_END if layout.external_backed else
nrf52_stage_ceiling_for_layout(
layout.linked_app_end,
@@ -501,7 +531,7 @@ def xiao_bootloader_hw_id(board_id: int) -> str:
@dataclass(frozen=True)
class XiaoBootloaderIdentity:
class BootloaderIdentity:
manifest_offset: int
image_start: int
image_size: int
@@ -510,7 +540,73 @@ class XiaoBootloaderIdentity:
crc32: int
def _xiao_bootloader_vector_sane(image: bytes) -> bool:
XiaoBootloaderIdentity = BootloaderIdentity # compatibility for callers of the original reference API
def _bootloader_device_name(board_id: int, name_raw: bytes) -> Optional[str]:
if len(name_raw) != 16 or board_id in (0, 0xFFFFFFFF):
return None
if board_id in (XIAO_BOOT_BOARD_ID_BASE, XIAO_BOOT_BOARD_ID_SENSE):
return "XIAO_DFU" if name_raw == XIAO_BOOT_DEVICE_NAME else None
try:
end = name_raw.index(0)
except ValueError:
return None
if end == 0 or end > 15 or any(name_raw[end:]) or any(b < 0x21 or b > 0x7E for b in name_raw[:end]):
return None
return name_raw[:end].decode("ascii", "strict")
def bootloader_hw_id(board_id: int, device_name: str) -> bytes:
name_raw = device_name.encode("ascii", "strict")
if len(name_raw) > 15 or _bootloader_device_name(board_id, name_raw.ljust(16, b"\0")) is None:
raise ValueError("invalid embedded bootloader board/name identity")
if board_id in (XIAO_BOOT_BOARD_ID_BASE, XIAO_BOOT_BOARD_ID_SENSE):
return hw_id_bytes(xiao_bootloader_hw_id(board_id))
return hw_id_bytes(f"NRF_BL_{board_id:08X}_{device_name}")
def bootloader_target_id(board_id: int, device_name: str) -> int:
if board_id in (XIAO_BOOT_BOARD_ID_BASE, XIAO_BOOT_BOARD_ID_SENSE):
return board_id
return int.from_bytes(sha256(bootloader_hw_id(board_id, device_name))[:4], "little")
def audit_bootloader_target_inventory(application_target_ids=()) -> dict:
"""Return target->identity after rejecting boot/app target collisions.
The generic image parser intentionally accepts future canonical embedded
identities for diagnostics. Only the curated identities below are allowed
to enter a newly built signed package, and release tests pass the generated
application target table here to keep the two wire namespaces disjoint.
"""
identities = (
(XIAO_BOOT_BOARD_ID_BASE, "XIAO_DFU"),
(XIAO_BOOT_BOARD_ID_SENSE, "XIAO_DFU"),
) + INTERNAL_BOOTLOADER_IDENTITIES
app_ids = set(application_target_ids)
targets = {}
for identity in identities:
target = bootloader_target_id(*identity)
if target in targets:
raise ValueError(
f"bootloader target collision 0x{target:08X}: "
f"{targets[target]!r} vs {identity!r}")
if target in app_ids:
raise ValueError(
f"bootloader target 0x{target:08X} collides with an application target")
targets[target] = identity
return targets
def bootloader_identity_is_buildable(board_id: int, device_name: str) -> bool:
return ((board_id, device_name) in INTERNAL_BOOTLOADER_IDENTITIES or
(board_id, device_name) in (
(XIAO_BOOT_BOARD_ID_BASE, "XIAO_DFU"),
(XIAO_BOOT_BOARD_ID_SENSE, "XIAO_DFU")))
def _bootloader_vector_sane(image: bytes) -> bool:
if len(image) < 8:
return False
sp, reset = struct.unpack_from("<II", image)
@@ -519,7 +615,7 @@ def _xiao_bootloader_vector_sane(image: bytes) -> bool:
and XIAO_BOOT_IMAGE_START <= entry < XIAO_BOOT_IMAGE_START + XIAO_BOOT_IMAGE_SIZE
def parse_xiao_bootloader_identity(image: bytes) -> Optional[XiaoBootloaderIdentity]:
def parse_bootloader_identity(image: bytes) -> Optional[BootloaderIdentity]:
"""Find exactly one CRC-valid embedded v1 manifest, continuing past invalid decoys."""
if len(image) != XIAO_BOOT_IMAGE_SIZE:
return None
@@ -531,56 +627,82 @@ def parse_xiao_bootloader_identity(image: bytes) -> Optional[XiaoBootloaderIdent
start, image_size, board_id = struct.unpack_from("<III", image, off + 12)
name_raw = image[off + 24:off + 40]
stored_crc = struct.unpack_from("<I", image, off + 40)[0]
device_name = _bootloader_device_name(board_id, name_raw)
if (version != XIAO_BOOT_MANIFEST_VERSION or size != XIAO_BOOT_MANIFEST_SIZE or
start != XIAO_BOOT_IMAGE_START or image_size != XIAO_BOOT_IMAGE_SIZE or
board_id not in (XIAO_BOOT_BOARD_ID_BASE, XIAO_BOOT_BOARD_ID_SENSE) or
name_raw != XIAO_BOOT_DEVICE_NAME):
device_name is None):
continue
crc_image = bytearray(image)
crc_image[off + 40:off + 44] = b"\0" * 4
import zlib
if zlib.crc32(crc_image) & 0xFFFFFFFF != stored_crc:
continue
candidate = XiaoBootloaderIdentity(
off, start, image_size, board_id,
name_raw.split(b"\0", 1)[0].decode("ascii", "strict"), stored_crc)
candidate = BootloaderIdentity(off, start, image_size, board_id, device_name, stored_crc)
if found is not None:
return None
found = candidate
return found
def xiao_bootloader_caps_ok(image: bytes) -> bool:
"""Find a real capability marker; a bare MOTABLDR literal is skipped."""
# The structure is emitted with uint16_t fields, so only aligned candidates
# are valid. This also avoids accepting an incidental magic string embedded
# in instruction/literal data as the privileged continuity marker.
def parse_xiao_bootloader_identity(image: bytes) -> Optional[XiaoBootloaderIdentity]:
"""Compatibility wrapper restricted to the two deployed XIAO identities."""
identity = parse_bootloader_identity(image)
return identity if identity and identity.board_id in (
XIAO_BOOT_BOARD_ID_BASE, XIAO_BOOT_BOARD_ID_SENSE) else None
def bootloader_caps_storage(image: bytes) -> Optional[int]:
"""Return the one exact supported self-update storage marker, or None."""
found = None
for off in range(0, len(image) - 16 + 1, 4):
if image[off:off + 8] != XIAO_BOOT_CAPS_MAGIC:
continue
abi, codecs = struct.unpack_from("<HH", image, off + 8)
storage = image[off + 12]
if (abi >= BOOT_FORMAT_VER and abi != 0xFFFF and
codecs & (1 << CODEC_FULL) and (storage & ~0x0F) == 0 and
storage & (XIAO_BOOT_STORAGE_QSPI | XIAO_BOOT_STORAGE_UPDATE) ==
(XIAO_BOOT_STORAGE_QSPI | XIAO_BOOT_STORAGE_UPDATE) and
image[off + 13:off + 16] == b"\0\0\0"):
return True
return False
if (abi < BOOT_FORMAT_VER or abi == 0xFFFF or not codecs & (1 << CODEC_FULL) or
storage & ~BOOT_STORAGE_KNOWN or image[off + 13:off + 16] != b"\0\0\0" or
storage not in (BOOT_STORAGE_QSPI_UPDATE, BOOT_STORAGE_INTERNAL_UPDATE)):
continue
if found is not None:
return None
found = storage
return found
def xiao_bootloader_caps_ok(image: bytes) -> bool:
return bootloader_caps_storage(image) == BOOT_STORAGE_QSPI_UPDATE
def validate_bootloader_image(image: bytes, target_id: Optional[int] = None,
signed_hw_id: Optional[bytes] = None) -> BootloaderIdentity:
if len(image) != XIAO_BOOT_IMAGE_SIZE:
raise ValueError(f"bootloader image must be exactly {XIAO_BOOT_IMAGE_SIZE} bytes")
if not _bootloader_vector_sane(image):
raise ValueError("bootloader vector table is invalid")
identity = parse_bootloader_identity(image)
if identity is None:
raise ValueError("bootloader embedded manifest/CRC is invalid or ambiguous")
expected_target = bootloader_target_id(identity.board_id, identity.device_name)
if target_id is not None and target_id != expected_target:
raise ValueError("bootloader target ID does not match embedded identity")
expected_hw = bootloader_hw_id(identity.board_id, identity.device_name)
if signed_hw_id is not None and bytes(signed_hw_id) != expected_hw:
raise ValueError("bootloader signed hw_id does not match embedded identity")
expected_storage = (BOOT_STORAGE_QSPI_UPDATE
if identity.board_id in (XIAO_BOOT_BOARD_ID_BASE,
XIAO_BOOT_BOARD_ID_SENSE)
else BOOT_STORAGE_INTERNAL_UPDATE)
if bootloader_caps_storage(image) != expected_storage:
raise ValueError(f"bootloader lacks exact ABI 3 self-update capabilities 0x{expected_storage:02X}")
return identity
def validate_xiao_bootloader_image(image: bytes, board_id: Optional[int] = None) -> XiaoBootloaderIdentity:
if len(image) != XIAO_BOOT_IMAGE_SIZE:
raise ValueError(f"bootloader image must be exactly {XIAO_BOOT_IMAGE_SIZE} bytes")
if not _xiao_bootloader_vector_sane(image):
raise ValueError("bootloader vector table is invalid")
identity = parse_xiao_bootloader_identity(image)
if identity is None:
raise ValueError("bootloader embedded manifest/CRC is invalid")
identity = validate_bootloader_image(image)
if identity.board_id not in (XIAO_BOOT_BOARD_ID_BASE, XIAO_BOOT_BOARD_ID_SENSE):
raise ValueError("bootloader is not a deployed XIAO identity")
if board_id is not None and identity.board_id != board_id:
raise ValueError("bootloader embedded board ID does not match package target")
if not xiao_bootloader_caps_ok(image):
raise ValueError("bootloader lacks ABI 3 QSPI boot-update continuity capabilities")
return identity
@@ -601,16 +723,19 @@ def build_manifest(*, target_id: int, fw_version: int, image_size: int, payload:
assert (block_size & (block_size - 1)) == 0, "block_size must be a power of two"
leaves = leaf_hashes(payload, block_size)
if bootloader:
identity = validate_xiao_bootloader_image(payload, target_id)
identity = validate_bootloader_image(payload, target_id)
audit_bootloader_target_inventory()
if not bootloader_identity_is_buildable(identity.board_id, identity.device_name):
raise ValueError("bootloader embedded identity is not in the qualified build inventory")
if (fw_version == 0 or not is_full or codec_id != CODEC_FULL or
image_size != XIAO_BOOT_IMAGE_SIZE or
block_size != DEFAULT_BLOCK_SIZE or base_hash not in (None, b"\0" * 8)):
raise ValueError("bootloader package needs a nonzero version and a 40 KiB CODEC_FULL image with 1 KiB blocks")
if sign_priv is None:
raise ValueError("bootloader package must be signed")
expected_hw = xiao_bootloader_hw_id(identity.board_id)
if hw_id is not None and hw_id_bytes(hw_id) != hw_id_bytes(expected_hw):
raise ValueError("bootloader package hw_id must be canonical for its embedded board ID")
expected_hw = bootloader_hw_id(identity.board_id, identity.device_name)
if hw_id is not None and hw_id_bytes(hw_id) != expected_hw:
raise ValueError("bootloader package hw_id must be canonical for its embedded identity")
hw_id = expected_hw
m = Manifest(
format_ver=BOOT_FORMAT_VER if bootloader else APP_FORMAT_VER,
@@ -707,10 +832,12 @@ def parse_container(blob: bytes) -> Parsed:
if (m.fw_version == 0 or m.codec_id != CODEC_FULL or
m.image_size != XIAO_BOOT_IMAGE_SIZE or
m.payload_size != XIAO_BOOT_IMAGE_SIZE or
m.block_size_log2 != 10 or m.block_count != 40 or m.base_hash != b"\0" * 8 or
m.target_id not in (XIAO_BOOT_BOARD_ID_BASE, XIAO_BOOT_BOARD_ID_SENSE) or
m.hw_id != hw_id_bytes(xiao_bootloader_hw_id(m.target_id))):
m.block_size_log2 != 10 or m.block_count != 40 or m.base_hash != b"\0" * 8):
raise ValueError("v3 bootloader manifest geometry/identity is invalid")
try:
validate_bootloader_image(payload, m.target_id, m.hw_id)
except (UnicodeDecodeError, ValueError) as exc:
raise ValueError(f"v3 bootloader image identity/capability is invalid: {exc}") from exc
return Parsed(manifest=m, payload=payload, total_size=total)
@@ -726,7 +853,7 @@ def verify(parsed: Parsed, *, expect_pub: Optional[bytes] = None,
if m.is_bootloader:
try:
validate_xiao_bootloader_image(payload, m.target_id)
validate_bootloader_image(payload, m.target_id, m.hw_id)
except (UnicodeDecodeError, ValueError) as exc:
problems.append(f"bootloader image contract: {exc}")
@@ -0,0 +1,27 @@
# Install-capable, internal-flash nRF52840 LoRa OTA targets whose exact OTAFIX
# manifest identity and shared ED000/E2000 runtime geometry are qualified. This file is the
# shared allowlist for build.sh release aliases and the subset that are also
# literal PlatformIO envs. Synthetic aliases are activated by build.sh.
# External QSPI/SD, internal ExtraFS, seeder-only, Ethernet, and full-sensor
# roles are intentionally absent.
Heltec_tower_v2_repeater_lora_ota_no_external_sensors
Heltec_t096_repeater_lora_ota_no_external_sensors
Heltec_t096_repeater_bridge_rs232_lora_ota_no_external_sensors
Heltec_t1_repeater_lora_ota_no_external_sensors
GAT562_30S_Mesh_Kit_repeater_lora_ota_no_external_sensors
Heltec_t114_without_display_repeater_lora_ota_no_external_sensors
Heltec_t114_repeater_lora_ota_no_external_sensors
Mesh_pocket_repeater_lora_ota_no_external_sensors
KeepteenLT1_repeater_lora_ota_no_external_sensors
Minewsemi_me25ls01_repeater_lora_ota_no_external_sensors
ProMicro_repeater_lora_ota_no_external_sensors
t1000e_repeater_lora_ota_no_external_sensors
ThinkNode_M3_repeater_lora_ota_no_external_sensors
RAK_3401_repeater_lora_ota_no_external_sensors
RAK_4631_repeater_lora_ota_no_external_sensors
RAK_4631_repeater_bridge_rs232_serial1_lora_ota_no_external_sensors
RAK_4631_repeater_bridge_rs232_serial2_lora_ota_no_external_sensors
GAT562_Mesh_Tracker_Pro_repeater_lora_ota_no_external_sensors
R1Neo_repeater_lora_ota_no_external_sensors
GAT562_Mesh_EVB_Pro_repeater_lora_ota_no_external_sensors
RAK_WisMesh_Tag_repeater_lora_ota_no_external_sensors
+9 -3
View File
@@ -179,23 +179,29 @@ def _append_endf_hex(source, target, env): # Intel-HEX path (nRF52: app f
and _builds_companion_radio())
sd_backed = _cppdef("OTA_SD_STORE") is not None
qspi_backed = _cppdef("OTA_QSPI_STORE") is not None
bootloader_update = _cppdef("OTA_QSPI_BOOTLOADER_UPDATE") is not None
qspi_bootloader_update = _cppdef("OTA_QSPI_BOOTLOADER_UPDATE") is not None
internal_bootloader_update = _cppdef("OTA_INTERNAL_BOOTLOADER_UPDATE") is not None
if sd_backed and qspi_backed:
raise RuntimeError("nRF52 build cannot enable both SD and QSPI OTA stores")
if qspi_backed and _cppdef("QSPIFLASH") is not None:
raise RuntimeError("raw QSPI OTA staging cannot share a chip with QSPIFLASH")
if bootloader_update:
if qspi_bootloader_update:
xiao_module = _cppdef("XIAO_NRF52") is not None or _cppdef("IKOKA_NRF52") is not None
if not qspi_backed or sd_backed or not xiao_module:
raise RuntimeError("bootloader update requires a XIAO nRF52840 raw-QSPI OTA build")
if linked_app_end != ml.NRF52_BOOT_SCRATCH_START:
raise RuntimeError("bootloader-update build must link exactly below scratch at 0xE0000")
if internal_bootloader_update:
if sd_backed or qspi_backed or _cppdef("QSPIFLASH") is not None or internal_extrafs:
raise RuntimeError("internal bootloader update cannot use SD/QSPI/ExtraFS")
if linked_app_end != ml.NRF52_APP_END:
raise RuntimeError("shared-slot bootloader update requires the normal 0xED000 linker ceiling")
stage_ceiling = (ml.NRF52_APP_END if (sd_backed or qspi_backed) else
ml.nrf52_stage_ceiling_for_layout(linked_app_end, internal_extrafs))
layout_flags = ((ml.NRF52_LAYOUT_FLAG_SD if sd_backed else 0) |
(ml.NRF52_LAYOUT_FLAG_QSPI if qspi_backed else 0) |
(ml.NRF52_LAYOUT_FLAG_INTERNAL_EXTRAFS if internal_extrafs else 0) |
(ml.NRF52_LAYOUT_FLAG_BOOTLOADER_SCRATCH if bootloader_update else 0))
(ml.NRF52_LAYOUT_FLAG_BOOTLOADER_SCRATCH if qspi_bootloader_update else 0))
layout = ml.Nrf52Layout(app_start, linked_app_end, stage_ceiling, layout_flags)
body = ml.ensure_nrf52_layout(raw_body, layout)
ident = _firmware_ident()
+139 -3
View File
@@ -13,6 +13,7 @@ import io
import os
import random
import struct
from pathlib import Path
import motalib as ml
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
@@ -40,8 +41,34 @@ def test_target_id_for_env():
assert ml.target_id_for_env("RAK_4631_repeater") != ml.target_id_for_env("RAK_4631_companion_radio_usb")
def test_internal_bootloader_target_wiring_is_central_and_not_duplicated():
root = Path(__file__).resolve().parents[2]
inventory = [
line.strip()
for line in (root / "tools/mota/nrf52_internal_bootloader_targets.txt")
.read_text(encoding="utf-8").splitlines()
if line.strip() and not line.lstrip().startswith("#")
]
assert len(inventory) == len(set(inventory))
assert len(inventory) == 21
target_header = (root / "src/helpers/ota/OtaTargets.h").read_text(encoding="utf-8")
for target in inventory:
assert f'"{target}"' in target_header
pio = (root / "platformio.ini").read_text(encoding="utf-8")
nrf52_base = pio.split("[nrf52_base]", 1)[1].split("\n[", 1)[0]
assert nrf52_base.count("pre:scripts/nrf52_internal_bootloader_link.py") == 1
build = (root / "build.sh").read_text(encoding="utf-8")
recipe = build.split("apply_nrf52_lora_ota_build_recipe()", 1)[1]
recipe = recipe.split("supports_nrf52_internal_bootloader_update()", 1)[0]
assert "scripts/nrf52_internal_bootloader_link.py" not in recipe
assert "export MESHCORE_NRF52_INTERNAL_BOOTLOADER_UPDATE=1" in recipe
def test_ota_target_generation_honors_explicit_disable():
from gen_targets import ota_envs
from gen_targets import ota_envs, release_aliases
cfg = [
["env:enabled", [["build_flags", ["-D ENABLE_OTA=1"]]]],
@@ -49,6 +76,7 @@ def test_ota_target_generation_honors_explicit_disable():
["env:unrelated", [["build_flags", ["-D NRF52_PLATFORM"]]]],
]
assert ota_envs(cfg) == ["enabled"]
assert "RAK_3401_repeater_lora_ota_no_external_sensors" in release_aliases()
def test_hardware_id_for_env():
@@ -150,6 +178,15 @@ def test_nrf52_layout_record_roundtrip_and_policy():
boot_qspi_image, _ = ml.ensure_endf(ml.ensure_nrf52_layout(_fw(8, 2048), boot_qspi))
assert ml.parse_nrf52_layout(boot_qspi_image) == boot_qspi
assert boot_qspi.bootloader_scratch
shared_internal = ml.Nrf52Layout(
ml.NRF52_APP_BASE_S140_V6, ml.NRF52_APP_END,
ml.NRF52_APP_END, 0)
shared_internal_image, _ = ml.ensure_endf(
ml.ensure_nrf52_layout(_fw(9, 2048), shared_internal))
assert ml.parse_nrf52_layout(shared_internal_image) == shared_internal
assert not shared_internal.bootloader_scratch
assert ((ml.NRF52_APP_END - ml.NRF52_BOOT_CONTAINER_SIZE) &
~(ml.NRF52_FLASH_PAGE - 1)) == ml.NRF52_SHARED_BOOT_STAGE_START
try:
ml.build_nrf52_layout(ml.Nrf52Layout(
ml.NRF52_APP_BASE_S140_V7, ml.NRF52_EXTRAFS_START,
@@ -160,6 +197,7 @@ def test_nrf52_layout_record_roundtrip_and_policy():
for flags in (
ml.NRF52_LAYOUT_FLAG_SD | ml.NRF52_LAYOUT_FLAG_QSPI,
ml.NRF52_LAYOUT_FLAG_QSPI | ml.NRF52_LAYOUT_FLAG_INTERNAL_EXTRAFS,
0x10,
):
try:
ml.build_nrf52_layout(ml.Nrf52Layout(
@@ -176,7 +214,7 @@ def _xiao_bootloader_image(board_id=ml.XIAO_BOOT_BOARD_ID_BASE):
struct.pack_into("<II", image, 0, 0x20040000, ml.XIAO_BOOT_IMAGE_START + 0x101)
struct.pack_into("<8sHHB3x", image, 0x80, ml.XIAO_BOOT_CAPS_MAGIC,
ml.BOOT_FORMAT_VER, 1 << ml.CODEC_FULL,
ml.XIAO_BOOT_STORAGE_QSPI | ml.XIAO_BOOT_STORAGE_UPDATE)
ml.BOOT_STORAGE_QSPI_UPDATE)
name = ml.XIAO_BOOT_DEVICE_NAME
struct.pack_into("<8sHHIII16sI", image, 0x100, ml.XIAO_BOOT_MANIFEST_MAGIC,
ml.XIAO_BOOT_MANIFEST_VERSION, ml.XIAO_BOOT_MANIFEST_SIZE,
@@ -186,6 +224,21 @@ def _xiao_bootloader_image(board_id=ml.XIAO_BOOT_BOARD_ID_BASE):
return bytes(image)
def _generic_bootloader_image(board_id=0x239A0029, device_name="3401_DFU"):
import zlib
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,
ml.BOOT_FORMAT_VER, 1 << ml.CODEC_FULL,
ml.BOOT_STORAGE_INTERNAL_UPDATE)
name = device_name.encode("ascii").ljust(16, b"\0")
struct.pack_into("<8sHHIII16sI", image, 0x100, ml.XIAO_BOOT_MANIFEST_MAGIC,
ml.XIAO_BOOT_MANIFEST_VERSION, ml.XIAO_BOOT_MANIFEST_SIZE,
ml.XIAO_BOOT_IMAGE_START, ml.XIAO_BOOT_IMAGE_SIZE, board_id, name, 0)
struct.pack_into("<I", image, 0x100 + 40, zlib.crc32(image) & 0xFFFFFFFF)
return bytes(image)
def _xiao_manifest_crc(image, offset):
import zlib
trial = bytearray(image)
@@ -269,7 +322,7 @@ def test_xiao_bootloader_identity_skips_bad_decoy_and_rejects_two_valid_manifest
def test_xiao_bootloader_caps_rejects_malformed_or_unaligned_markers():
def caps(*, offset=0, abi=ml.BOOT_FORMAT_VER,
codecs=1 << ml.CODEC_FULL,
storage=ml.XIAO_BOOT_STORAGE_QSPI | ml.XIAO_BOOT_STORAGE_UPDATE,
storage=ml.BOOT_STORAGE_QSPI_UPDATE,
reserved=b"\0\0\0"):
image = bytearray(b"\xff" * 64)
struct.pack_into("<8sHHB3s", image, offset, ml.XIAO_BOOT_CAPS_MAGIC,
@@ -282,6 +335,11 @@ def test_xiao_bootloader_caps_rejects_malformed_or_unaligned_markers():
assert not ml.xiao_bootloader_caps_ok(caps(codecs=0))
assert not ml.xiao_bootloader_caps_ok(caps(storage=0x1C))
assert not ml.xiao_bootloader_caps_ok(caps(reserved=b"\0\x01\0"))
duplicate = bytearray(caps())
struct.pack_into("<8sHHB3s", duplicate, 24, ml.XIAO_BOOT_CAPS_MAGIC,
ml.BOOT_FORMAT_VER, 1 << ml.CODEC_FULL,
ml.BOOT_STORAGE_INTERNAL_UPDATE, b"\0\0\0")
assert ml.bootloader_caps_storage(bytes(duplicate)) is None
def test_bootloader_v3_build_parse_and_strict_contract():
@@ -323,6 +381,84 @@ def test_bootloader_v3_build_parse_and_strict_contract():
pass
def test_generic_internal_bootloader_build_parse_and_strict_contract():
priv = Ed25519PrivateKey.generate()
image = _generic_bootloader_image()
identity = ml.validate_bootloader_image(image)
assert identity.board_id == 0x239A0029 and identity.device_name == "3401_DFU"
target = ml.bootloader_target_id(identity.board_id, identity.device_name)
assert target == 0x23818A80
expected_hw = b"NRF_BL_239A0029_3401_DFU".ljust(32, b"\0")
assert ml.bootloader_hw_id(identity.board_id, identity.device_name) == expected_hw
manifest = ml.build_manifest(
target_id=target, fw_version=ml.pack_version("1.0.0"),
image_size=len(image), payload=image, block_size=1024,
image_hash=ml.mh32(image), codec_id=ml.CODEC_FULL, is_full=True,
sign_priv=priv, bootloader=True)
parsed = ml.parse_container(ml.build_container(manifest, image))
assert parsed.manifest.target_id == target
assert parsed.manifest.hw_id == expected_hw
assert ml.verify(parsed, expect_pub=priv.public_key().public_bytes_raw()) == []
wrong_target = target ^ 1
try:
ml.build_manifest(
target_id=wrong_target, fw_version=1, image_size=len(image), payload=image,
block_size=1024, image_hash=ml.mh32(image), codec_id=ml.CODEC_FULL,
is_full=True, sign_priv=priv, bootloader=True)
assert False, "generic bootloader raw/wrong target accepted"
except ValueError:
pass
def test_bootloader_build_inventory_is_unique_and_disjoint_from_app_targets():
import re
expected = {
(0x239A0071, "TOWER_V2_OTA"): 0x1150F50E,
(0x239A0071, "T096_DFU"): 0x42354C85,
(0x239A0071, "T1_DFU"): 0xFC556FFC,
(0x239A0071, "T114_DFU"): 0x0C3F2902,
(0x239A0071, "MESH_POCKET_OTA"): 0x059277F4,
(0x239A00B3, "KeepteenLT1_OTA"): 0xDB2E7B51,
(0x239A0029, "MX25_DFU"): 0x026AA982,
(0x239A00B3, "PROM_DFU"): 0xAF79E8CC,
(0x28860057, "T1KE_DFU"): 0xE6F5F03F,
(0x239A00DA, "TNM3_DFU"): 0x0CA41DB2,
(0x239A0029, "3401_DFU"): 0x23818A80,
(0x239A0029, "4631_DFU"): 0x2D0DF000,
(0x239A0029, "RTAG_DFU"): 0xC72E9C9C,
}
assert set(ml.INTERNAL_BOOTLOADER_IDENTITIES) == set(expected)
for identity, target in expected.items():
assert ml.bootloader_target_id(*identity) == target
root = Path(__file__).resolve().parents[2]
target_header = (root / "src/helpers/ota/OtaTargets.h").read_text(encoding="utf-8")
app_targets = {
int(value, 16)
for value in re.findall(r"\{ 0x([0-9a-fA-F]{8}),", target_header)
}
audited = ml.audit_bootloader_target_inventory(app_targets)
assert len(audited) == len(expected) + 2 # generic internal plus two legacy XIAOs
# Generic parsing remains useful for inspecting a future candidate, but a
# signing/build call fails closed until its exact identity is qualified.
image = _generic_bootloader_image(device_name="UNKNOWN_DFU")
identity = ml.validate_bootloader_image(image)
target = ml.bootloader_target_id(identity.board_id, identity.device_name)
priv = Ed25519PrivateKey.generate()
try:
ml.build_manifest(
target_id=target, fw_version=1, image_size=len(image), payload=image,
block_size=1024, image_hash=ml.mh32(image), codec_id=ml.CODEC_FULL,
is_full=True, sign_priv=priv, bootloader=True)
assert False, "unqualified generic bootloader package build accepted"
except ValueError:
pass
def test_bootloader_builder_rejects_wrong_identity_geometry_and_continuity():
priv = Ed25519PrivateKey.generate()
image = _xiao_bootloader_image()