Add shared-slot nRF52 bootloader updates
This commit is contained in:
1 parent
75a2b8f002
commit
177eb06745
43 files changed
+1647
-211
No files matched your search
@@ -8,6 +8,9 @@
|
||||
"extra_flags": "-DNRF52840_XXAA",
|
||||
"f_cpu": "64000000L",
|
||||
"hwids": [
|
||||
["0x239A", "0x8071"],
|
||||
["0x239A", "0x0071"],
|
||||
["0x239A", "0x8072"],
|
||||
["0x239A", "0x4405"],
|
||||
["0x239A", "0x0029"],
|
||||
["0x239A", "0x002A"]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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:
|
||||
|
||||
|
||||
@@ -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).
|
||||
@@ -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
@@ -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
@@ -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>` |
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
)
|
||||
@@ -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
@@ -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; }
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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 ||
|
||||
|
||||
@@ -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
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
@@ -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.
|
||||
@@ -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
@@ -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
|
||||
@@ -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
@@ -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()
|
||||
|
||||
Reference in new issue
Block a user