Add signed XIAO bootloader updates over LoRa
This commit is contained in:
1 parent
9aae602023
commit
75a2b8f002
34 files changed
+1604
-78
No files matched your search
@@ -0,0 +1,53 @@
|
||||
/* XIAO nRF52840 LoRa bootloader-update layout.
|
||||
*
|
||||
* The application ends at 0xE0000. OTAFIX owns 0xE0000..0xEA000 as a 40 KiB internal scratch bank
|
||||
* while replacing its own padded 0xF4000..0xFE000 bootloader region from the verified QSPI package.
|
||||
*/
|
||||
|
||||
SEARCH_DIR(.)
|
||||
GROUP(-lgcc -lc -lnosys)
|
||||
|
||||
MEMORY
|
||||
{
|
||||
FLASH (rx) : ORIGIN = 0x27000, LENGTH = 0xE0000 - 0x27000
|
||||
|
||||
/* To keep data in RAM across resets */
|
||||
PERSISTENT_RAM (rwx) : ORIGIN = 0x20006000, LENGTH = 8
|
||||
|
||||
/* SRAM required by SoftDevice depend on
|
||||
* - Attribute Table Size (Number of Services and Characteristics)
|
||||
* - Vendor UUID count
|
||||
* - Max ATT MTU
|
||||
* - Concurrent connection peripheral + central + secure links
|
||||
* - Event Len, HVN queue, Write CMD queue
|
||||
*/
|
||||
RAM (rwx) : ORIGIN = 0x20006000 + 8, LENGTH = 0x20040000 - 0x20006000 - 8
|
||||
}
|
||||
|
||||
SECTIONS
|
||||
{
|
||||
. = ALIGN(4);
|
||||
.persistent (NOLOAD) :
|
||||
{
|
||||
KEEP(*(.persistent_magic))
|
||||
KEEP(*(.persistent_data))
|
||||
. = ALIGN(4);
|
||||
} > PERSISTENT_RAM
|
||||
|
||||
. = ALIGN(4);
|
||||
.svc_data :
|
||||
{
|
||||
PROVIDE(__start_svc_data = .);
|
||||
KEEP(*(.svc_data))
|
||||
PROVIDE(__stop_svc_data = .);
|
||||
} > RAM
|
||||
|
||||
.fs_data :
|
||||
{
|
||||
PROVIDE(__start_fs_data = .);
|
||||
KEEP(*(.fs_data))
|
||||
PROVIDE(__stop_fs_data = .);
|
||||
} > RAM
|
||||
}
|
||||
|
||||
INCLUDE "nrf52_common.ld"
|
||||
@@ -166,6 +166,7 @@ fix, no WiFi connection, an inactive bridge, or an nRF52 bootloader without
|
||||
| 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 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,6 +326,7 @@ fix, no WiFi connection, an inactive bridge, or an nRF52 bootloader without
|
||||
| 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 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 |
|
||||
|
||||
@@ -13,8 +13,13 @@ the right container, move the participating nodes to a temporary radio
|
||||
channel, serve and monitor the download, request installation, restore the
|
||||
controller, and check the rebooted node.
|
||||
|
||||
The script cannot install the destination's first OTA-capable firmware or its
|
||||
nRF52 bootloader. Do those one-time jobs over USB before using LoRa OTA.
|
||||
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.
|
||||
|
||||
## Required topology
|
||||
|
||||
@@ -425,9 +430,10 @@ the destination.
|
||||
2. Authenticate to the target and query its target ID, hardware, running body
|
||||
hash, firmware version, bootloader version, and nRF52 bootloader
|
||||
capabilities.
|
||||
3. Select or build one compatible mOTA and verify all block hashes, Merkle
|
||||
root, full-image hash where applicable, identity fields, signature, codec,
|
||||
base, and the firmware's 1024-byte maximum block size.
|
||||
3. Select or build one compatible **v2 application** mOTA and verify all block
|
||||
hashes, Merkle root, full-image hash where applicable, identity fields,
|
||||
signature, codec, base, and the firmware's 1024-byte maximum block size.
|
||||
Version-3 bootloader packages are refused before any target state changes.
|
||||
4. Save the controller's normal radio tuple and show the confirmation prompt.
|
||||
5. Start TempRadio on the target, then far-to-near relays, then the source;
|
||||
finally switch the controller to the same tuple and read it back. The runner
|
||||
|
||||
@@ -333,6 +333,10 @@ Discovery is asynchronous. `ota ls` says `refreshing`; wait a few seconds and ru
|
||||
shows an older row. Select `[same target]`: it can say `full` for ESP32 and external SD/QSPI nRF52 targets,
|
||||
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).
|
||||
Use the row's stable eight-hex manifest ID rather than its changing list position:
|
||||
|
||||
```text
|
||||
|
||||
+62
-5
@@ -13,8 +13,9 @@ an update is downloaded.
|
||||
|
||||
## Supported repeater families
|
||||
|
||||
QSPI staging is enabled only for repeater-role environments on these currently
|
||||
matched families:
|
||||
QSPI staging is enabled only for explicitly matched repeater-role environments
|
||||
(and RS232 bridge variants that directly inherit one of those repeater targets)
|
||||
on these currently matched families:
|
||||
|
||||
- Seeed XIAO nRF52840 and XIAO nRF52840 Sense modules, including the
|
||||
`Xiao_nrf52`, SolarXiao 30S/33S, and XIAO-module Ikoka handheld, Nano, and
|
||||
@@ -40,8 +41,8 @@ T-Echo target.
|
||||
Companion builds are intentionally excluded. Some companion targets use the
|
||||
same external QSPI as a LittleFS message/data store, where raw OTA staging
|
||||
would corrupt the filesystem; other companions simply do not assign that chip
|
||||
to OTA. Room-server, sensor, KISS, and repeater-bridge roles are also unchanged.
|
||||
Raw QSPI OTA is scoped to the explicitly matched repeater environments.
|
||||
to OTA. Room-server, sensor, and KISS roles are unchanged. Raw QSPI OTA is
|
||||
scoped to the explicitly matched repeater-derived environments.
|
||||
|
||||
### RAK15001 placement and module conflicts
|
||||
|
||||
@@ -135,13 +136,21 @@ starting another pull; later capacity probes preserve that failure detail.
|
||||
The store reads the JEDEC capacity at runtime and accepts supported 1 MiB
|
||||
through 16 MiB devices using 24-bit addressing. QSPI capacity is not the final
|
||||
firmware limit. The reconstructed application, including its 56-byte `EndF`
|
||||
trailer, must fit below InternalFS at `0xED000`:
|
||||
trailer, must fit its build's linked application region:
|
||||
|
||||
| SoftDevice layout | Application region | Maximum image |
|
||||
| --- | --- | --- |
|
||||
| S140 v7, boot-update-capable XIAO module | `0x27000..0xE0000` | `0xB9000` (757,760 bytes) |
|
||||
| S140 v7, app base `0x27000` | `0x27000..0xED000` | `0xC6000` (811,008 bytes) |
|
||||
| S140 v6, app base `0x26000` | `0x26000..0xED000` | `0xC7000` (815,104 bytes) |
|
||||
|
||||
The XIAO limit is deliberately 52 KiB smaller. Its linker and post-link record
|
||||
cap every ordinary application at `0xE0000`; OTAFIX reserves
|
||||
`0xE0000..0xEA000` as a 40 KiB self-update scratch bank, with the remaining gap
|
||||
left unused before InternalFS. Full and delta application packages for these
|
||||
targets derive their effective application end from that `0xE0000` record and
|
||||
are rejected if their reconstructed image or detools geometry crosses it.
|
||||
|
||||
A full package needs only the new raw `firmware.hex` or non-merged application
|
||||
image. An in-place delta still needs the exact image currently running. The
|
||||
automation uses a conservative `0xC6000` detools workspace for external nRF52
|
||||
@@ -165,6 +174,54 @@ operator can deliberately override the routing target for a role change, so
|
||||
`target_id` is not an apply-time safety assertion; the destination still
|
||||
enforces the package's hardware identity before approval.
|
||||
|
||||
## Explicit XIAO bootloader updates over LoRa
|
||||
|
||||
Bootloader delivery is available only when all of these are already true:
|
||||
|
||||
- the application is an `OTA_QSPI_BOOTLOADER_UPDATE` XIAO-module repeater build
|
||||
linked below `0xE0000`;
|
||||
- `ota bootloader` shows a CRC-valid installed `XIAO_DFU` embedded identity for
|
||||
board ID `28860044` (base) or `28860045` (Sense);
|
||||
- the installed OTAFIX marker reports ABI 3 or newer and both QSPI (`0x04`) and
|
||||
boot-update (`0x08`) capability bits;
|
||||
- the package is the exact signed v3/40-KiB/1-KiB-block profile for that board,
|
||||
and its signer is already in `ota key`'s trusted allowlist.
|
||||
|
||||
This is not part of normal automation. Bootloader catalog rows are labelled
|
||||
`bootloader`; even a capable node never autofetches or autoinstalls them, and
|
||||
ordinary `ota install` rejects them in every application backend. The
|
||||
`tools/lora_ota` deployment runner also refuses v3 packages. Fetch and arm one
|
||||
only with the explicit flow:
|
||||
|
||||
```text
|
||||
ota ls
|
||||
ota pull <MID8> flash
|
||||
# wait for ota status to report the same bootloader download as ready
|
||||
ota bootloader
|
||||
# copy the exact mid= and hash= values printed above
|
||||
ota bootloader install <MID8> <HASH16>
|
||||
```
|
||||
|
||||
The pull command is only transport intent; it does not authorize an install.
|
||||
The final command must reproduce both the complete staged manifest ID and the
|
||||
first eight bytes of its signed image hash. Before approval the application
|
||||
again checks package/root/payload/image hashes, Ed25519 signature and trusted
|
||||
allowlist, exact signed `XIAO_BL_...` ID, installed and incoming embedded
|
||||
manifest/CRC/name/board identity, vector table, and ABI-3 QSPI+boot-update
|
||||
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
|
||||
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`.
|
||||
|
||||
This feature cannot update a stock or old bootloader that lacks the capability
|
||||
marker: perform the first exact-board combined bootloader+SoftDevice migration
|
||||
over USB/BLE DFU or SWD. Do not attempt to bootstrap it with a v3 package.
|
||||
|
||||
## Storage ownership and recovery
|
||||
|
||||
The QSPI store is raw, not a file inside LittleFS. It owns the flash from
|
||||
|
||||
+50
-10
@@ -61,11 +61,13 @@ the authoritative reference for byte-level details.
|
||||
| Container `TRAILER` | `76 6B 34 39 36` | `vk496` |
|
||||
| `EndF` marker | `45 6E 64 46` | `EndF` |
|
||||
| `hash_algo` (sha2-256) | `0x12` | multihash code |
|
||||
| `format_ver` | `0x02` | this spec |
|
||||
| application `format_ver` | `0x02` | ordinary full/delta application package |
|
||||
| bootloader `format_ver` | `0x03` | privileged XIAO 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 |
|
||||
| `MFLAG_SIGNED` | `0x02` | flags bit1 |
|
||||
| `MFLAG_BOOTLOADER` | `0x04` | flags bit2; valid only in the exact v3 bootloader profile |
|
||||
| `CODEC_FULL` / `_SEQUENTIAL` / `_INPLACE` | `0` / `1` / `2` | Section 5 |
|
||||
| `PAYLOAD_TYPE_OTA` | `0x0C` | MeshCore packet type (`src/Packet.h`) |
|
||||
| `MAX_PACKET_PAYLOAD` | `184` | usable bytes per packet (`src/MeshCore.h`) |
|
||||
@@ -209,8 +211,8 @@ Only `leaves[]` is variable (one 4-byte hash per block). So the manifest-minus-l
|
||||
|
||||
```
|
||||
off size field notes
|
||||
0 1 format_ver = 0x02
|
||||
1 1 flags bit0 FULL (0=delta/partial, 1=full image); bit1 SIGNED; bits2-7 reserved 0
|
||||
0 1 format_ver = 0x02 application, or 0x03 privileged bootloader package
|
||||
1 1 flags bit0 FULL; bit1 SIGNED; bit2 BOOTLOADER; bits3-7 reserved 0
|
||||
2 1 hash_algo 0x12 = sha2-256
|
||||
3 4 target_id device/arch/role discriminator (Section 9)
|
||||
7 4 fw_version MAJOR<<24 | MINOR<<16 | PATCH<<8 | pre (comparable uint32)
|
||||
@@ -237,6 +239,11 @@ Manifest-minus-leaves size (`mfl`) is a constant **197 bytes** for every contain
|
||||
or unsigned). At 197 bytes the manifest exceeds one packet, so `OTA_MANIFEST` is always sent multi-fragment
|
||||
(Section 8.4, 2 fragments) and reassembled by the fetcher.
|
||||
|
||||
The two versions are deliberately disjoint. Version 2 accepts application packages only and rejects the
|
||||
BOOTLOADER bit. Version 3 accepts only flags exactly `FULL|SIGNED|BOOTLOADER`; a non-bootloader v3 package
|
||||
is invalid. Consequently, deployed v2-only application parsers reject a bootloader package before they can
|
||||
mistake its raw 40 KiB payload for an application image.
|
||||
|
||||
### 4.1 Signed region
|
||||
|
||||
`signature` covers manifest bytes `[0, 129)` - the head + `base_hash` + `signer_pubkey`. It does **not**
|
||||
@@ -253,6 +260,23 @@ 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
|
||||
|
||||
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.
|
||||
|
||||
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
|
||||
manifest. A package must be signed by a key already in the device's trusted allowlist; unlike ordinary
|
||||
application packages, there is no unsigned manual-install exception.
|
||||
|
||||
---
|
||||
|
||||
## 5. Payload, codecs & delta base
|
||||
@@ -746,6 +770,9 @@ 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 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)
|
||||
ota announce | adv serve self + send a beacon now
|
||||
ota self | id print this firmware's EndF (body/image size, base_hash)
|
||||
@@ -817,10 +844,22 @@ ota dev ... bring-up helpers (stage/recv/serve/verify)
|
||||
complete internal application region as workspace. Companion builds never enable this raw store: some use
|
||||
QSPI as a filesystem, while others simply leave that chip outside OTA ownership. See
|
||||
[the nRF52 QSPI guide](ota_nrf52_qspi.md).
|
||||
- **XIAO bootloader self-update (explicit only):** selected XIAO-module QSPI repeater builds link the
|
||||
ordinary application below `0xE0000`, reserving `0xE0000..0xEA000` as a 40 KiB internal scratch bank.
|
||||
They accept a v3 bootloader package only through an exact manual MID pull. Ordinary `ota install`,
|
||||
autofetch, autoinstall, and every application apply backend reject it. The operator then copies the
|
||||
staged package's exact values from `ota bootloader` into
|
||||
`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`
|
||||
(`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.
|
||||
|
||||
A signature, when present, proves author authenticity and must pass the device allowlist. Unsigned packages
|
||||
remain installable when local policy permits them. The one-shot `approval` marker records local consent for
|
||||
either form before the bootloader may apply it.
|
||||
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
|
||||
signed and trusted. The one-shot `approval` marker records local consent before the bootloader may apply it.
|
||||
|
||||
> **Bootloader testing note:** always test apply with a *real different* image (base != target). A same-image
|
||||
> (X->X) "delta" trivially reproduces the target and gives a false positive.
|
||||
@@ -829,7 +868,8 @@ either form before the bootloader may apply it.
|
||||
|
||||
## 13. Versioning of this spec
|
||||
|
||||
`format_ver = 2`. A parser accepts exactly this value and rejects anything else - there is one container
|
||||
format, fixed-layout, and no compatibility shims to carry. If the format ever needs to change, bump
|
||||
`format_ver`; the multihash `hash_algo` separately allows swapping the digest family without a format
|
||||
bump. Unknown `codec_id` / `ota_msg_type` values are ignored (a node simply won't fetch what it can't apply).
|
||||
The fixed byte layout has two intentionally disjoint profiles: `format_ver = 2` for ordinary application
|
||||
packages and `format_ver = 3` only for the exact privileged bootloader profile in Section 4.3. A parser must
|
||||
reject v2+BOOTLOADER, v3 without exact `FULL|SIGNED|BOOTLOADER`, and every other version. The multihash
|
||||
`hash_algo` separately allows swapping the digest family without a format bump. Unknown `codec_id` /
|
||||
`ota_msg_type` values are ignored (a node simply will not fetch what it cannot apply).
|
||||
@@ -216,6 +216,40 @@ 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)
|
||||
|
||||
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:
|
||||
|
||||
```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:
|
||||
|
||||
```text
|
||||
ota pull <MID8> flash
|
||||
# wait until ota status says this bootloader download is ready
|
||||
ota bootloader
|
||||
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.
|
||||
|
||||
### 5. If something goes wrong
|
||||
|
||||
- A download that stalls or gets interrupted just **resumes** later, or you can `ota cancel` and try again.
|
||||
|
||||
+4
-2
@@ -335,7 +335,8 @@ void __attribute__((noinline)) Mesh::serviceLoopMaintenance() {
|
||||
oc.apply_hard = futureMillis(15000);
|
||||
} else if (millisHasNowPassed(oc.apply_at) &&
|
||||
(_mgr->getOutboundTotal() == 0 || millisHasNowPassed(oc.apply_hard))) {
|
||||
ota::ota_reboot_to_apply(); // does not return
|
||||
if (oc.bootloader_apply_pending) ota::ota_reboot_to_bootloader_update();
|
||||
else ota::ota_reboot_to_apply();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -410,7 +411,8 @@ void __attribute__((noinline)) Mesh::serviceLoopMaintenance() {
|
||||
_ota_autoinstall_tried = false;
|
||||
} else if (!_ota_autoinstall_tried && !oc.apply_pending
|
||||
&& oc.autoinstall == ota::OtaContext::AUTOINSTALL_TRUSTED
|
||||
&& oc.manager.fetched_is_signed()) {
|
||||
&& oc.manager.fetched_is_signed()
|
||||
&& !oc.manager.fetched_is_bootloader()) {
|
||||
_ota_autoinstall_tried = true;
|
||||
char msg[100];
|
||||
oc.apply_fetched(msg); // arms + sets apply_pending only if signed & allowlisted; refused otherwise
|
||||
|
||||
@@ -17,8 +17,16 @@ bool MotaManifest::is_approved() const {
|
||||
// always covers manifest[0, MOTA_SIGNED_LEN). Returns false on any over-read or bad format_ver.
|
||||
static bool parse_manifest_fields(ByteReader& r, MotaManifest& out) {
|
||||
out.format_ver = r.u8();
|
||||
if (out.format_ver != MOTA_FORMAT_VER) return false;
|
||||
out.flags = r.u8();
|
||||
// v2 is application-only and v3 is bootloader-only. Requiring exact flags on v3 prevents a
|
||||
// malformed or downgraded package from entering either apply path.
|
||||
if (out.format_ver == MOTA_APP_FORMAT_VER) {
|
||||
if (out.flags & MFLAG_BOOTLOADER || out.flags & ~MFLAG_KNOWN) return false;
|
||||
} else if (out.format_ver == MOTA_BOOT_FORMAT_VER) {
|
||||
if (out.flags != (MFLAG_FULL | MFLAG_SIGNED | MFLAG_BOOTLOADER)) return false;
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
out.hash_algo = r.u8();
|
||||
out.target_id = r.u32();
|
||||
out.fw_version = r.u32();
|
||||
@@ -37,6 +45,13 @@ static bool parse_manifest_fields(ByteReader& r, MotaManifest& out) {
|
||||
if (!r.ok) return false;
|
||||
if (out.block_size_log2 == 0 || out.block_size_log2 > 24 || out.payload_size == 0) return false;
|
||||
out.block_count = (out.payload_size + out.block_size() - 1) / out.block_size();
|
||||
if (out.is_bootloader()) {
|
||||
static const uint8_t zero_base[8] = {0};
|
||||
if (out.fw_version == 0 || out.codec_id != CODEC_FULL || out.image_size != 0xA000u ||
|
||||
out.payload_size != 0xA000u || out.block_size_log2 != 10 ||
|
||||
out.block_count != 40 || memcmp(out.base_hash, zero_base, sizeof(zero_base)) != 0)
|
||||
return false;
|
||||
}
|
||||
// block_idx is uint16 on the wire; capping here also keeps block_count*4 (leaves length) from overflowing.
|
||||
return out.block_count != 0 && out.block_count <= 0xFFFFu;
|
||||
}
|
||||
|
||||
@@ -40,6 +40,7 @@ struct MotaManifest {
|
||||
|
||||
bool is_full() const { return flags & MFLAG_FULL; }
|
||||
bool is_signed() const { return flags & MFLAG_SIGNED; }
|
||||
bool is_bootloader() const { return flags & MFLAG_BOOTLOADER; }
|
||||
uint32_t block_size() const { return 1u << block_size_log2; }
|
||||
bool is_approved() const;
|
||||
};
|
||||
|
||||
@@ -52,7 +52,7 @@ bool ota_apply_set_manifest(const uint8_t* mf, uint32_t len, const SignerAllowli
|
||||
ota_apply_slot_info(&st.slot_addr, &st.slot_size);
|
||||
MotaManifest m;
|
||||
if (!mota_parse_manifest(mf, len, m)) return false;
|
||||
if (!m.is_full()) return false; // A/B apply takes a full image (delta would need decode)
|
||||
if (m.is_bootloader() || !m.is_full()) return false; // boot packages never enter an application slot
|
||||
st.image_size = m.image_size;
|
||||
memcpy(st.image_hash, m.image_hash, 32);
|
||||
st.manifest_ok = true;
|
||||
@@ -293,6 +293,7 @@ bool ota_apply_detools_mota(OtaStoreFlashEsp32& store, const SignerAllowlist& al
|
||||
MotaManifest m;
|
||||
if (mflen < 57 || !store.read(8, mfbuf, mflen) || !mota_parse_manifest(mfbuf, mflen, m)) {
|
||||
strcpy(msg, "manifest parse failed"); return false; }
|
||||
if (m.is_bootloader()) { strcpy(msg, "bootloader package requires explicit bootloader install"); return false; }
|
||||
st.image_size = m.image_size; memcpy(st.image_hash, m.image_hash, 32); st.manifest_ok = true;
|
||||
if (m.image_size == 0 || m.image_size > slot->size) { strcpy(msg, "image > slot"); return false; }
|
||||
|
||||
@@ -367,6 +368,7 @@ bool ota_apply_detools_mota(const uint8_t* buf, uint32_t len, const SignerAllowl
|
||||
st = ApplyState();
|
||||
MotaManifest m;
|
||||
if (!mota_parse(buf, len, m)) { strcpy(msg, "no valid .mota (parse failed)"); return false; }
|
||||
if (m.is_bootloader()) { strcpy(msg, "bootloader package cannot enter an application slot"); return false; }
|
||||
if (m.is_full() || m.codec_id != CODEC_DETOOLS_SEQUENTIAL) { strcpy(msg, "not a detools-sequential delta"); return false; }
|
||||
st.image_size = m.image_size;
|
||||
memcpy(st.image_hash, m.image_hash, 32);
|
||||
@@ -416,6 +418,10 @@ bool ota_rescue_mota_nrf52(const uint8_t*, uint32_t, const SignerAllowlist&, con
|
||||
}
|
||||
|
||||
void ota_reboot_to_apply() { esp_restart(); } // boots the slot armed by ota_apply_detools_mota; no return
|
||||
void ota_reboot_to_bootloader_update() {}
|
||||
bool ota_installed_bootloader_identity(OtaBootloaderIdentity& out) {
|
||||
out = OtaBootloaderIdentity(); return false;
|
||||
}
|
||||
|
||||
#elif defined(NRF52_PLATFORM) // single-slot: verify + mark APPROVED + hand off to the bootloader
|
||||
|
||||
@@ -473,8 +479,28 @@ void ota_reboot_to_apply() { // public: set the apply magic +
|
||||
NVIC_SystemReset(); // does not return
|
||||
}
|
||||
|
||||
void ota_reboot_to_bootloader_update() {
|
||||
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_clr(0, 0xFFFFFFFF);
|
||||
sd_power_gpregret_set(0, GPREGRET_OTA_BOOTLOADER_UPDATE);
|
||||
} else {
|
||||
NRF_POWER->GPREGRET2 = GPREGRET2_OTA_STAGE_QSPI;
|
||||
NRF_POWER->GPREGRET = GPREGRET_OTA_BOOTLOADER_UPDATE;
|
||||
}
|
||||
NVIC_SystemReset();
|
||||
}
|
||||
|
||||
uint8_t ota_bootloader_last_rc() { return g_bootloader_last_rc; }
|
||||
|
||||
bool ota_installed_bootloader_identity(OtaBootloaderIdentity& out) {
|
||||
return ota_bootloader_identity_from_image(
|
||||
(const uint8_t*)(uintptr_t)OTA_BOOT_IMAGE_START, OTA_BOOT_IMAGE_SIZE, out);
|
||||
}
|
||||
|
||||
static bool ota_apply_mota_nrf52_impl(const uint8_t* buf, uint32_t len,
|
||||
const SignerAllowlist& allow,
|
||||
const uint8_t* rescue_base_hash,
|
||||
@@ -483,6 +509,7 @@ static bool ota_apply_mota_nrf52_impl(const uint8_t* buf, uint32_t len,
|
||||
st = ApplyState();
|
||||
MotaManifest m;
|
||||
if (!mota_parse(buf, len, m)) { strcpy(msg, "parse failed"); return false; }
|
||||
if (m.is_bootloader()) { strcpy(msg, "use explicit ota bootloader install"); return false; }
|
||||
if (m.is_full() || m.codec_id != CODEC_DETOOLS_INPLACE) { strcpy(msg, "not an in-place delta"); return false; }
|
||||
st.image_size = m.image_size;
|
||||
memcpy(st.image_hash, m.image_hash, 32);
|
||||
@@ -618,6 +645,10 @@ static bool ota_apply_mota_nrf52_external(Store& store, const SignerAllowlist& a
|
||||
snprintf(msg, NRF52_APPLY_MSG_CAP, "%s manifest parse failed", storage_name);
|
||||
return false;
|
||||
}
|
||||
if (m.is_bootloader()) {
|
||||
strcpy(msg, "use explicit ota bootloader install");
|
||||
return false;
|
||||
}
|
||||
const bool full = m.is_full() && m.codec_id == CODEC_FULL &&
|
||||
m.payload_size == m.image_size;
|
||||
const bool delta = !m.is_full() && m.codec_id == CODEC_DETOOLS_INPLACE;
|
||||
@@ -627,8 +658,9 @@ static bool ota_apply_mota_nrf52_external(Store& store, const SignerAllowlist& a
|
||||
return false;
|
||||
}
|
||||
const uint32_t app_base = mota_nrf52_app_base();
|
||||
if (m.image_size == 0 || app_base >= MOTA_NRF52_APP_END ||
|
||||
m.image_size > MOTA_NRF52_APP_END - app_base) {
|
||||
const uint32_t app_ceiling = mota_nrf52_application_ceiling();
|
||||
if (m.image_size == 0 || app_base >= app_ceiling ||
|
||||
m.image_size > app_ceiling - app_base) {
|
||||
strcpy(msg, "image exceeds nRF52 application region");
|
||||
return false;
|
||||
}
|
||||
@@ -687,7 +719,7 @@ static bool ota_apply_mota_nrf52_external(Store& store, const SignerAllowlist& a
|
||||
}
|
||||
if (!mota_nrf52_external_patch_geometry_valid(
|
||||
d.memory, d.segment, d.shift, d.from, d.to,
|
||||
MOTA_NRF52_APP_END - app_base, fi.image_len, m.image_size)) {
|
||||
app_ceiling - app_base, fi.image_len, m.image_size)) {
|
||||
strcpy(msg, "invalid in-place patch geometry");
|
||||
return false;
|
||||
}
|
||||
@@ -717,6 +749,148 @@ bool ota_apply_mota_nrf52(OtaStoreQspiNrf52& store, const SignerAllowlist& allow
|
||||
ApplyState& st, char* msg) {
|
||||
return ota_apply_mota_nrf52_external(store, allow, OTA_BL_STORAGE_QSPI, "QSPI", st, msg);
|
||||
}
|
||||
|
||||
#if defined(OTA_QSPI_BOOTLOADER_UPDATE)
|
||||
static bool qspi_bootloader_crc_ok(OtaStoreQspiNrf52& store, uint32_t payload_off,
|
||||
const OtaBootloaderIdentity& identity) {
|
||||
uint8_t buf[512];
|
||||
uint32_t crc = UINT32_MAX;
|
||||
for (uint32_t off = 0; off < OTA_BOOT_IMAGE_SIZE; off += sizeof(buf)) {
|
||||
uint32_t len = OTA_BOOT_IMAGE_SIZE - off;
|
||||
if (len > sizeof(buf)) len = sizeof(buf);
|
||||
if (!store.read(payload_off + off, buf, len)) return false;
|
||||
for (uint32_t i = 0; i < len; i++) {
|
||||
const uint32_t pos = off + i;
|
||||
const uint8_t value = pos >= identity.manifest_offset + 40u &&
|
||||
pos < identity.manifest_offset + 44u ? 0 : buf[i];
|
||||
crc = ota_boot_crc32_update(crc, value);
|
||||
}
|
||||
}
|
||||
return ~crc == identity.crc32;
|
||||
}
|
||||
|
||||
static bool qspi_bootloader_image_metadata(OtaStoreQspiNrf52& store, uint32_t payload_off,
|
||||
const OtaBootloaderIdentity& installed,
|
||||
OtaBootloaderIdentity& candidate,
|
||||
OtaBootloaderCapsMarker& caps) {
|
||||
// Keep enough overlap to catch a 44-byte manifest or 16-byte capability marker split across a
|
||||
// window. Every aligned manifest candidate is considered; a magic string in a literal pool is not
|
||||
// a terminal failure.
|
||||
static const uint32_t STEP = 512;
|
||||
uint8_t buf[STEP + OTA_BOOT_MANIFEST_SIZE - 1];
|
||||
uint8_t valid_identities = 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);
|
||||
if (!store.read(payload_off + base, buf, len)) return false;
|
||||
const uint32_t starts = (OTA_BOOT_IMAGE_SIZE - base < STEP)
|
||||
? 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) {
|
||||
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;
|
||||
}
|
||||
}
|
||||
if ((absolute & 3u) != 0 || local + OTA_BOOT_MANIFEST_SIZE > len) continue;
|
||||
OtaBootloaderIdentity parsed;
|
||||
if (!ota_bootloader_manifest_parse(buf + local, absolute, parsed) ||
|
||||
parsed.board_id != installed.board_id ||
|
||||
memcmp(parsed.device_name, installed.device_name, OTA_BOOT_DEVICE_NAME_SIZE) != 0) continue;
|
||||
if (qspi_bootloader_crc_ok(store, payload_off, parsed)) {
|
||||
parsed.crc_ok = true;
|
||||
if (!ota_bootloader_identity_add_unique(parsed, candidate, valid_identities)) return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
return valid_identities == 1u && candidate.present && candidate.crc_ok && caps.present;
|
||||
}
|
||||
|
||||
bool ota_prepare_bootloader_update_nrf52(OtaStoreQspiNrf52& 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();
|
||||
uint8_t hdr[8], manifest[MOTA_MFL];
|
||||
if (total < 8u + MOTA_MFL + 5u || !store.read(0, hdr, sizeof(hdr)) ||
|
||||
memcmp(hdr, MOTA_MAGIC, sizeof(MOTA_MAGIC)) != 0 || rd_u32le(hdr + 4) != total ||
|
||||
!store.read(8, manifest, sizeof(manifest))) {
|
||||
strcpy(msg, "QSPI bootloader container parse failed"); return false;
|
||||
}
|
||||
MotaManifest m;
|
||||
if (!mota_parse_manifest(manifest, sizeof(manifest), m)) {
|
||||
strcpy(msg, "not a valid v3 bootloader package"); 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 board ID 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 (!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)) {
|
||||
strcpy(msg, "installed bootloader cannot safely self-update from QSPI"); return false;
|
||||
}
|
||||
|
||||
const uint64_t payload_off64 = 8u + MOTA_MFL + (uint64_t)m.block_count * 4u;
|
||||
if (payload_off64 > UINT32_MAX || payload_off64 + OTA_BOOT_IMAGE_SIZE + 5u != total) {
|
||||
strcpy(msg, "bootloader container layout mismatch"); return false;
|
||||
}
|
||||
const uint32_t payload_off = (uint32_t)payload_off64;
|
||||
|
||||
VerifyResult vr = ota_verify(static_cast<const OtaStore&>(store), 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;
|
||||
}
|
||||
|
||||
uint8_t vectors[8];
|
||||
if (!store.read(payload_off, vectors, sizeof(vectors)) || !ota_bootloader_vector_sane(vectors)) {
|
||||
strcpy(msg, "bootloader vector table is invalid"); return false;
|
||||
}
|
||||
OtaBootloaderIdentity candidate;
|
||||
OtaBootloaderCapsMarker candidate_caps;
|
||||
if (!qspi_bootloader_image_metadata(store, payload_off, installed, candidate, candidate_caps) ||
|
||||
!ota_bootloader_identity_matches(installed, candidate)) {
|
||||
strcpy(msg, "candidate bootloader identity/capability/CRC mismatch"); return false;
|
||||
}
|
||||
st.slot_ok = true;
|
||||
if (!store.approve_for_bootloader()) {
|
||||
snprintf(msg, CAP, "QSPI bootloader handoff failed: %s", store.last_error()); return false;
|
||||
}
|
||||
strcpy(msg, "trusted bootloader verified and armed; rebooting after this reply");
|
||||
return true;
|
||||
}
|
||||
#endif
|
||||
#endif
|
||||
|
||||
#else // native / other platforms
|
||||
@@ -732,6 +906,10 @@ bool ota_rescue_mota_nrf52(const uint8_t*, uint32_t, const SignerAllowlist&, con
|
||||
st = ApplyState(); strcpy(msg, "unsupported"); return false;
|
||||
}
|
||||
void ota_reboot_to_apply() {}
|
||||
void ota_reboot_to_bootloader_update() {}
|
||||
bool ota_installed_bootloader_identity(OtaBootloaderIdentity& out) {
|
||||
out = OtaBootloaderIdentity(); return false;
|
||||
}
|
||||
uint8_t ota_bootloader_last_rc() { return 0; }
|
||||
|
||||
#endif
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
#include <stddef.h>
|
||||
#include <string.h>
|
||||
#include "SignerAllowlist.h"
|
||||
#include "OtaBootloaderUpdate.h"
|
||||
|
||||
// P6 apply (full-image, ESP32 A/B). The new image is delivered into the inactive OTA slot; the device
|
||||
// then verifies that slot against the signed manifest's image_hash (+ Ed25519/allowlist), and commits
|
||||
@@ -90,6 +91,15 @@ bool ota_apply_mota_nrf52(OtaStoreSdNrf52& store,
|
||||
class OtaStoreQspiNrf52;
|
||||
bool ota_apply_mota_nrf52(OtaStoreQspiNrf52& store,
|
||||
const SignerAllowlist& allow, ApplyState& st, char* msg);
|
||||
#if defined(OTA_QSPI_BOOTLOADER_UPDATE)
|
||||
bool ota_prepare_bootloader_update_nrf52(OtaStoreQspiNrf52& 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
|
||||
#endif
|
||||
|
||||
// Commit the (already approved/armed) update and reboot into it - does NOT return. Call this only after
|
||||
@@ -98,10 +108,17 @@ bool ota_apply_mota_nrf52(OtaStoreQspiNrf52& store,
|
||||
// the GPREGRET apply magic + reset (the bootloader does the in-place decode + verify). ESP32: reboot
|
||||
// into the slot already armed by ota_apply_detools_mota.
|
||||
void ota_reboot_to_apply();
|
||||
void ota_reboot_to_bootloader_update();
|
||||
bool ota_installed_bootloader_identity(OtaBootloaderIdentity& out);
|
||||
|
||||
inline uint8_t ota_nrf52_boot_result_or_zero(uint8_t value) {
|
||||
return ((value >= 0x90u && value <= 0x9Fu) ||
|
||||
(value >= 0xB0u && value <= 0xBCu)) ? value : 0u;
|
||||
(value >= 0xB0u && value <= 0xBCu) ||
|
||||
(value >= 0xC0u && value <= 0xCFu)) ? value : 0u;
|
||||
}
|
||||
|
||||
inline bool ota_nrf52_boot_update_result(uint8_t value) {
|
||||
return value >= 0xC0u && value <= 0xCFu;
|
||||
}
|
||||
|
||||
// DIAGNOSTIC (nRF52): the bootloader stashes its last in-place-apply bail/progress code in GPREGRET2 (see
|
||||
|
||||
+51
-10
@@ -1,5 +1,6 @@
|
||||
#pragma once
|
||||
|
||||
#include <stddef.h>
|
||||
#include <stdint.h>
|
||||
#include <string.h>
|
||||
|
||||
@@ -31,23 +32,63 @@ struct OtaBlCaps {
|
||||
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;
|
||||
|
||||
// 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
|
||||
// must not let a later plausible literal shadow the privileged marker merely because its following
|
||||
// bytes decode as a larger ABI.
|
||||
inline bool ota_bl_caps_prefer(const OtaBlCaps& current, uint16_t candidate_abi,
|
||||
uint8_t candidate_storage, bool require_boot_update) {
|
||||
if (!current.present) return true;
|
||||
if (require_boot_update) {
|
||||
const bool candidate_boot = (candidate_storage & OTA_BL_STORAGE_BOOT_UPDATE) != 0;
|
||||
const bool current_boot = (current.storage_flags & OTA_BL_STORAGE_BOOT_UPDATE) != 0;
|
||||
if (candidate_boot != current_boot) return candidate_boot;
|
||||
}
|
||||
return candidate_abi > current.apply_abi;
|
||||
}
|
||||
|
||||
// Scan a region whose offset zero is word-aligned. The marker structure contains
|
||||
// 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) {
|
||||
OtaBlCaps c;
|
||||
if (!bytes) return c;
|
||||
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 ||
|
||||
p[13] != 0 || p[14] != 0 || p[15] != 0) continue;
|
||||
if (ota_bl_caps_prefer(c, abi, storage, require_boot_update)) {
|
||||
c.present = true;
|
||||
c.apply_abi = abi;
|
||||
c.codec_mask = codecs;
|
||||
c.storage_flags = storage;
|
||||
}
|
||||
}
|
||||
return c;
|
||||
}
|
||||
|
||||
// Scan the bootloader flash region for the marker. Returns {present=false} if not found / non-nRF52.
|
||||
inline OtaBlCaps ota_bootloader_caps() {
|
||||
OtaBlCaps c;
|
||||
#if defined(NRF52_PLATFORM)
|
||||
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;
|
||||
for (const uint8_t* p = lo; p + 16 <= hi; p++) {
|
||||
if (p[0] != OTA_BL_MAGIC[0] || memcmp(p, OTA_BL_MAGIC, 8) != 0) continue;
|
||||
c.present = true;
|
||||
c.apply_abi = (uint16_t)(p[8] | ((uint16_t)p[9] << 8));
|
||||
c.codec_mask = (uint16_t)(p[10] | ((uint16_t)p[11] << 8));
|
||||
c.storage_flags = p[12];
|
||||
break;
|
||||
}
|
||||
return ota_bl_caps_scan_aligned(lo, (size_t)(hi - lo),
|
||||
#if defined(OTA_QSPI_BOOTLOADER_UPDATE)
|
||||
true
|
||||
#else
|
||||
false
|
||||
#endif
|
||||
);
|
||||
#else
|
||||
return OtaBlCaps();
|
||||
#endif
|
||||
return c;
|
||||
}
|
||||
|
||||
// True if this device's bootloader can apply a .mota of the given format_ver + codec_id.
|
||||
|
||||
@@ -0,0 +1,242 @@
|
||||
#pragma once
|
||||
|
||||
#include <stddef.h>
|
||||
#include <stdint.h>
|
||||
#include <string.h>
|
||||
|
||||
#include "MotaContainer.h"
|
||||
|
||||
#if defined(OTA_QSPI_BOOTLOADER_UPDATE)
|
||||
#if !defined(NRF52_PLATFORM) || !defined(OTA_QSPI_STORE)
|
||||
#error "OTA_QSPI_BOOTLOADER_UPDATE requires nRF52 raw-QSPI staging"
|
||||
#endif
|
||||
#if !defined(XIAO_NRF52) && !defined(IKOKA_NRF52)
|
||||
#error "OTA_QSPI_BOOTLOADER_UPDATE is restricted to XIAO nRF52840 module builds"
|
||||
#endif
|
||||
#if defined(QSPIFLASH) || defined(OTA_SD_STORE)
|
||||
#error "bootloader-update QSPI cannot share a filesystem or SD staging configuration"
|
||||
#endif
|
||||
#endif
|
||||
|
||||
namespace mesh {
|
||||
namespace ota {
|
||||
|
||||
// Mirror of OTAFIX src/usb/uf2/bootloader_image.h. This manifest is embedded in the complete,
|
||||
// padded bootloader region and is covered both by its own CRC32 and the signed .mota image_hash.
|
||||
static const uint32_t OTA_BOOT_MANIFEST_MAGIC0 = 0x464D4C42UL; // bytes "BLMF"
|
||||
static const uint32_t OTA_BOOT_MANIFEST_MAGIC1 = 0x31435243UL; // bytes "CRC1"
|
||||
static const uint16_t OTA_BOOT_MANIFEST_VERSION = 1;
|
||||
static const uint16_t OTA_BOOT_MANIFEST_SIZE = 44;
|
||||
static const uint8_t OTA_BOOT_DEVICE_NAME_SIZE = 16;
|
||||
static const uint32_t OTA_BOOT_IMAGE_START = 0x000F4000UL;
|
||||
static const uint32_t OTA_BOOT_IMAGE_SIZE = 0x0000A000UL; // F4000..FE000, padded to exactly 40 KiB
|
||||
static const uint32_t OTA_BOOT_SCRATCH_START = 0x000E0000UL;
|
||||
static const uint32_t OTA_BOOT_SCRATCH_END = 0x000EA000UL;
|
||||
static const uint32_t OTA_NRF52840_RAM_START = 0x20000000UL;
|
||||
static const uint32_t OTA_NRF52840_RAM_END = 0x20040000UL;
|
||||
|
||||
// Only the two official XIAO nRF52840 OTAFIX identities are eligible. Runtime matching against the
|
||||
// installed, CRC-valid manifest distinguishes the base and Sense modules even for carrier-board aliases.
|
||||
static const uint32_t OTA_XIAO_BOARD_ID_BASE = 0x28860044UL;
|
||||
static const uint32_t OTA_XIAO_BOARD_ID_SENSE = 0x28860045UL;
|
||||
static const uint8_t OTA_XIAO_BOOT_DEVICE_NAME[OTA_BOOT_DEVICE_NAME_SIZE] = {
|
||||
'X','I','A','O','_','D','F','U', 0,0,0,0,0,0,0,0
|
||||
};
|
||||
|
||||
struct OtaBootloaderIdentity {
|
||||
bool present = false;
|
||||
bool crc_ok = false;
|
||||
uint32_t manifest_offset = 0;
|
||||
uint32_t image_start = 0;
|
||||
uint32_t image_size = 0;
|
||||
uint32_t board_id = 0;
|
||||
char device_name[OTA_BOOT_DEVICE_NAME_SIZE + 1] = {0};
|
||||
uint32_t crc32 = 0;
|
||||
};
|
||||
|
||||
// Accumulate a stream of already CRC-validated candidates without silently
|
||||
// choosing between two privileged identities. Both the memory-mapped and QSPI
|
||||
// scanners use this helper so their duplicate policy cannot drift.
|
||||
inline bool ota_bootloader_identity_add_unique(const OtaBootloaderIdentity& candidate,
|
||||
OtaBootloaderIdentity& selected,
|
||||
uint8_t& valid_count) {
|
||||
if (!candidate.present || !candidate.crc_ok) return true;
|
||||
if (valid_count != 0u) {
|
||||
selected = OtaBootloaderIdentity();
|
||||
return false;
|
||||
}
|
||||
selected = candidate;
|
||||
valid_count = 1u;
|
||||
return true;
|
||||
}
|
||||
|
||||
inline uint16_t ota_boot_rd16(const uint8_t* p) {
|
||||
return (uint16_t)(p[0] | ((uint16_t)p[1] << 8));
|
||||
}
|
||||
|
||||
inline uint32_t ota_boot_rd32(const uint8_t* p) {
|
||||
return (uint32_t)p[0] | ((uint32_t)p[1] << 8) | ((uint32_t)p[2] << 16) |
|
||||
((uint32_t)p[3] << 24);
|
||||
}
|
||||
|
||||
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_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);
|
||||
const char* value = board_id == OTA_XIAO_BOARD_ID_BASE
|
||||
? "XIAO_BL_28860044" : "XIAO_BL_28860045";
|
||||
memcpy(out, value, strlen(value));
|
||||
return true;
|
||||
}
|
||||
|
||||
inline uint32_t ota_boot_crc32_update(uint32_t crc, uint8_t value) {
|
||||
crc ^= value;
|
||||
for (uint8_t bit = 0; bit < 8; bit++)
|
||||
crc = (crc >> 1) ^ (0xEDB88320UL & (uint32_t)-(int32_t)(crc & 1U));
|
||||
return crc;
|
||||
}
|
||||
|
||||
inline uint32_t ota_boot_image_crc32(const uint8_t* image, size_t image_size,
|
||||
size_t crc_offset) {
|
||||
if (!image || crc_offset > image_size || image_size - crc_offset < sizeof(uint32_t)) return 0;
|
||||
uint32_t crc = UINT32_MAX;
|
||||
for (size_t i = 0; i < image_size; i++) {
|
||||
const uint8_t value = (i >= crc_offset && i < crc_offset + sizeof(uint32_t)) ? 0 : image[i];
|
||||
crc = ota_boot_crc32_update(crc, value);
|
||||
}
|
||||
return ~crc;
|
||||
}
|
||||
|
||||
// Parse one candidate header. The caller independently validates the complete-region CRC so a magic
|
||||
// string in a literal pool cannot establish an identity.
|
||||
inline bool ota_bootloader_manifest_parse(const uint8_t* raw, uint32_t raw_offset,
|
||||
OtaBootloaderIdentity& out) {
|
||||
if (!raw || ota_boot_rd32(raw) != OTA_BOOT_MANIFEST_MAGIC0 ||
|
||||
ota_boot_rd32(raw + 4) != OTA_BOOT_MANIFEST_MAGIC1 ||
|
||||
ota_boot_rd16(raw + 8) != OTA_BOOT_MANIFEST_VERSION ||
|
||||
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;
|
||||
|
||||
out = OtaBootloaderIdentity();
|
||||
out.present = true;
|
||||
out.manifest_offset = raw_offset;
|
||||
out.image_start = ota_boot_rd32(raw + 12);
|
||||
out.image_size = ota_boot_rd32(raw + 16);
|
||||
out.board_id = ota_boot_rd32(raw + 20);
|
||||
memcpy(out.device_name, raw + 24, OTA_BOOT_DEVICE_NAME_SIZE);
|
||||
out.device_name[OTA_BOOT_DEVICE_NAME_SIZE] = 0;
|
||||
out.crc32 = ota_boot_rd32(raw + 40);
|
||||
return true;
|
||||
}
|
||||
|
||||
// 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,
|
||||
OtaBootloaderIdentity& out) {
|
||||
out = OtaBootloaderIdentity();
|
||||
if (!image || image_size != OTA_BOOT_IMAGE_SIZE) return false;
|
||||
uint8_t valid_count = 0;
|
||||
for (size_t offset = 0; offset + OTA_BOOT_MANIFEST_SIZE <= image_size; offset += 4) {
|
||||
OtaBootloaderIdentity candidate;
|
||||
if (!ota_bootloader_manifest_parse(image + offset, (uint32_t)offset, candidate)) continue;
|
||||
candidate.crc_ok = ota_boot_image_crc32(image, image_size, offset + 40) == candidate.crc32;
|
||||
if (!candidate.crc_ok) continue;
|
||||
if (!ota_bootloader_identity_add_unique(candidate, out, valid_count)) return false;
|
||||
}
|
||||
return valid_count == 1u;
|
||||
}
|
||||
|
||||
inline bool ota_bootloader_identity_matches(const OtaBootloaderIdentity& a,
|
||||
const OtaBootloaderIdentity& b) {
|
||||
return a.present && a.crc_ok && b.present && b.crc_ok &&
|
||||
a.image_start == b.image_start && a.image_size == b.image_size &&
|
||||
a.board_id == b.board_id &&
|
||||
memcmp(a.device_name, b.device_name, OTA_BOOT_DEVICE_NAME_SIZE) == 0;
|
||||
}
|
||||
|
||||
// Cheap first-line rejection of a malformed raw bootloader region. OTAFIX repeats this check before
|
||||
// touching internal flash. MSP may point one byte beyond RAM; the reset vector must be Thumb code in
|
||||
// the exact bootloader image range.
|
||||
inline bool ota_bootloader_vector_sane(const uint8_t vectors[8]) {
|
||||
if (!vectors) return false;
|
||||
const uint32_t sp = ota_boot_rd32(vectors);
|
||||
const uint32_t reset = ota_boot_rd32(vectors + 4);
|
||||
const uint32_t entry = reset & ~1UL;
|
||||
return (sp & 7u) == 0 && sp >= OTA_NRF52840_RAM_START && sp <= OTA_NRF52840_RAM_END &&
|
||||
(reset & 1u) != 0 && entry >= OTA_BOOT_IMAGE_START &&
|
||||
entry < OTA_BOOT_IMAGE_START + OTA_BOOT_IMAGE_SIZE;
|
||||
}
|
||||
|
||||
struct OtaBootloaderCapsMarker {
|
||||
bool present = false;
|
||||
uint16_t apply_abi = 0;
|
||||
uint16_t codec_mask = 0;
|
||||
uint8_t storage_flags = 0;
|
||||
};
|
||||
|
||||
inline bool ota_bootloader_caps_marker_parse(const uint8_t raw[16],
|
||||
OtaBootloaderCapsMarker& out) {
|
||||
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[13] != 0 || raw[14] != 0 || raw[15] != 0) return false;
|
||||
out.present = true;
|
||||
out.apply_abi = ota_boot_rd16(raw + 8);
|
||||
out.codec_mask = ota_boot_rd16(raw + 10);
|
||||
out.storage_flags = raw[12];
|
||||
return true;
|
||||
}
|
||||
|
||||
enum OtaBootloaderConfirmGate : uint8_t {
|
||||
OTA_BOOT_CONFIRM_OK = 0,
|
||||
OTA_BOOT_CONFIRM_NOT_BOOT_PACKAGE,
|
||||
OTA_BOOT_CONFIRM_GEOMETRY,
|
||||
OTA_BOOT_CONFIRM_LOCAL_IDENTITY,
|
||||
OTA_BOOT_CONFIRM_TARGET,
|
||||
OTA_BOOT_CONFIRM_HW_ID,
|
||||
OTA_BOOT_CONFIRM_MID,
|
||||
OTA_BOOT_CONFIRM_IMAGE_HASH,
|
||||
};
|
||||
|
||||
// Pure explicit-confirmation gate used by the device command and native tests. Cryptographic and
|
||||
// candidate-image checks run immediately after this structural/typed gate.
|
||||
inline OtaBootloaderConfirmGate ota_bootloader_confirmation_gate(
|
||||
const MotaManifest& m, const OtaBootloaderIdentity& installed,
|
||||
const uint8_t actual_mid[4], const uint8_t operator_mid[4],
|
||||
const uint8_t operator_hash8[8]) {
|
||||
if (m.format_ver != MOTA_BOOT_FORMAT_VER ||
|
||||
m.flags != (MFLAG_FULL | MFLAG_SIGNED | MFLAG_BOOTLOADER))
|
||||
return OTA_BOOT_CONFIRM_NOT_BOOT_PACKAGE;
|
||||
static const uint8_t zero_base[8] = {0};
|
||||
if (m.codec_id != CODEC_FULL || m.image_size != OTA_BOOT_IMAGE_SIZE ||
|
||||
m.payload_size != OTA_BOOT_IMAGE_SIZE || m.block_size_log2 != 10 ||
|
||||
m.block_count != OTA_BOOT_IMAGE_SIZE / 1024u || !m.base_hash ||
|
||||
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))
|
||||
return OTA_BOOT_CONFIRM_LOCAL_IDENTITY;
|
||||
if (m.target_id != installed.board_id) return OTA_BOOT_CONFIRM_TARGET;
|
||||
uint8_t expected_hw[32];
|
||||
if (!ota_xiao_bootloader_hw_id(installed.board_id, 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 ||
|
||||
!m.merkle_root || memcmp(m.merkle_root, actual_mid, 4) != 0)
|
||||
return OTA_BOOT_CONFIRM_MID;
|
||||
if (!operator_hash8 || !m.image_hash || memcmp(m.image_hash, operator_hash8, 8) != 0)
|
||||
return OTA_BOOT_CONFIRM_IMAGE_HASH;
|
||||
return OTA_BOOT_CONFIRM_OK;
|
||||
}
|
||||
|
||||
} // namespace ota
|
||||
} // namespace mesh
|
||||
+133
-11
@@ -51,8 +51,30 @@ static char fstate_char(OtaManager::FetchState s) {
|
||||
}
|
||||
}
|
||||
|
||||
// For users, the only distinction that matters is full image vs. delta (which delta codec is internal).
|
||||
static const char* codec_kind(uint8_t c) { return c == CODEC_FULL ? "full" : "delta"; }
|
||||
// For users, the only distinction that matters is application full/delta vs the separately gated
|
||||
// bootloader package. Which application delta codec is an implementation detail.
|
||||
static const char* codec_kind(uint8_t flags, uint8_t c) {
|
||||
return (flags & MFLAG_BOOTLOADER) ? "bootloader" : c == CODEC_FULL ? "full" : "delta";
|
||||
}
|
||||
|
||||
static bool parse_hex_exact(const char* text, uint8_t* out, size_t bytes) {
|
||||
if (!text || strlen(text) != bytes * 2u) return false;
|
||||
for (size_t i = 0; i < bytes * 2u; i++) {
|
||||
const char c = text[i];
|
||||
if (!((c >= '0' && c <= '9') || (c >= 'a' && c <= 'f') || (c >= 'A' && c <= 'F')))
|
||||
return false;
|
||||
}
|
||||
return mesh::Utils::fromHex(out, bytes, text);
|
||||
}
|
||||
|
||||
static bool staged_manifest(OtaContext& c, MotaManifest& m) {
|
||||
uint8_t hdr[8], manifest[MOTA_MFL];
|
||||
const uint32_t total = c.fetch_store.staged_size();
|
||||
return total >= 8u + MOTA_MFL + 5u && c.fetch_store.read(0, hdr, sizeof(hdr)) &&
|
||||
memcmp(hdr, MOTA_MAGIC, sizeof(MOTA_MAGIC)) == 0 && rd_u32le(hdr + 4) == total &&
|
||||
c.fetch_store.read(8, manifest, sizeof(manifest)) &&
|
||||
mota_parse_manifest(manifest, sizeof(manifest), m);
|
||||
}
|
||||
|
||||
// A plain-language word for the fetch state (shown in `ota status`).
|
||||
static const char* state_word(OtaManager::FetchState s) {
|
||||
@@ -152,9 +174,15 @@ bool handle_ota_command(const char* command, char* reply, mesh::MainBoard& board
|
||||
"OTA: status | stats | ls | get <id> flash [rescue] | install | rescue install <hash16> | "
|
||||
"cancel | announce | self | folder | config | key");
|
||||
#elif defined(NRF52_PLATFORM) && defined(OTA_QSPI_STORE)
|
||||
#if defined(OTA_QSPI_BOOTLOADER_UPDATE)
|
||||
strcpy(reply,
|
||||
"OTA: status | stats | ls | get <id> flash | install | bootloader | cancel | announce | self | "
|
||||
"qspi | folder | config | key");
|
||||
#else
|
||||
strcpy(reply,
|
||||
"OTA: status | stats | ls | get <id> flash | install | cancel | announce | self | qspi | "
|
||||
"folder | config | key");
|
||||
#endif
|
||||
#else
|
||||
snprintf(reply, 160,
|
||||
"OTA: status | stats | ls | get <id> flash | install | cancel | announce | self | folder | "
|
||||
@@ -186,7 +214,8 @@ bool handle_ota_command(const char* command, char* reply, mesh::MainBoard& board
|
||||
snprintf(dl, sizeof dl, "download: failed (%s) %u/%u id=%s",
|
||||
fetch_error_word(c.manager.fetchError()), have, tot, midhx);
|
||||
else
|
||||
snprintf(dl, sizeof dl, "download: %s %u/%u (%u%%) id=%s %us",
|
||||
snprintf(dl, sizeof dl, "%sdownload: %s %u/%u (%u%%) id=%s %us",
|
||||
c.manager.fetched_is_bootloader() ? "bootloader " : "",
|
||||
state_word(fs), have, tot, pct, midhx, age);
|
||||
}
|
||||
const char* hw = (c.hw_id[0]) ? c.hw_id : "?";
|
||||
@@ -208,8 +237,13 @@ bool handle_ota_command(const char* command, char* reply, mesh::MainBoard& board
|
||||
#else
|
||||
const char* bl_state = bl.present ? "apply" : "NONE";
|
||||
#endif
|
||||
if (n < 146) n += snprintf(reply + n, 160 - n, " | bl:%s blrc:%02X",
|
||||
bl_state, ota_bootloader_last_rc());
|
||||
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);
|
||||
}
|
||||
#endif
|
||||
#endif
|
||||
|
||||
@@ -304,7 +338,20 @@ bool handle_ota_command(const char* command, char* reply, mesh::MainBoard& board
|
||||
char hwbuf[16];
|
||||
const char* fit;
|
||||
const char* env = ota_target_env_name(h->target_id);
|
||||
if (myt && h->target_id == myt) {
|
||||
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)
|
||||
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);
|
||||
#endif
|
||||
fit = installable ? "yours" : "bootloader unsupported";
|
||||
} else if (myt && h->target_id == myt) {
|
||||
bool installable = c.manager.codecOk(h->codec);
|
||||
#if defined(NRF52_PLATFORM)
|
||||
installable = installable && list_bl.present && list_bl.apply_abi >= MOTA_FORMAT_VER
|
||||
@@ -326,7 +373,7 @@ bool handle_ota_command(const char* command, char* reply, mesh::MainBoard& board
|
||||
else { snprintf(hwbuf, sizeof hwbuf, "hw %08X", (unsigned)h->target_id); fit = hwbuf; }
|
||||
char midhx[9]; mesh::Utils::toHex(midhx, h->mid, 4);
|
||||
n += snprintf(reply + n, CAP - n, "\n %u) %s %s %s [%s] %un %us%s",
|
||||
(unsigned)(i + 1), midhx, ver, codec_kind(h->codec), fit,
|
||||
(unsigned)(i + 1), midhx, ver, codec_kind(h->flags, h->codec), fit,
|
||||
(unsigned)h->n_seeders, (unsigned)age, tag);
|
||||
shown++;
|
||||
}
|
||||
@@ -401,6 +448,8 @@ bool handle_ota_command(const char* command, char* reply, mesh::MainBoard& board
|
||||
}
|
||||
|
||||
uint8_t selmid[4]; uint32_t seltgt = sel->target_id; uint8_t selcodec = sel->codec;
|
||||
const uint8_t selflags = sel->flags;
|
||||
const bool selboot = (selflags & MFLAG_BOOTLOADER) != 0;
|
||||
memcpy(selmid, sel->mid, 4); // sel may move when catalog traffic arrives
|
||||
|
||||
#if defined(NRF52_PLATFORM) && defined(OTA_SD_STORE)
|
||||
@@ -418,10 +467,33 @@ bool handle_ota_command(const char* command, char* reply, mesh::MainBoard& board
|
||||
strcpy(reply, "ERR seeder-only build cannot stage or install firmware; use `folder`");
|
||||
return true;
|
||||
#else
|
||||
if (!c.manager.codecOk(selcodec)) {
|
||||
if (selboot) {
|
||||
#if defined(NRF52_PLATFORM) && defined(OTA_QSPI_STORE) && defined(OTA_QSPI_BOOTLOADER_UPDATE)
|
||||
const OtaBootloaderIdentity& bid = c.bootloaderIdentity();
|
||||
const OtaBlCaps& bl = c.bootloaderCaps();
|
||||
if (selflags != (MFLAG_FULL | MFLAG_SIGNED | MFLAG_BOOTLOADER) ||
|
||||
selcodec != CODEC_FULL) {
|
||||
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");
|
||||
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)) {
|
||||
strcpy(reply, "ERR installed bootloader lacks safe LoRa bootloader-update support");
|
||||
return true;
|
||||
}
|
||||
#else
|
||||
strcpy(reply, "ERR this build cannot install bootloader packages; use folder capture or USB DFU");
|
||||
return true;
|
||||
#endif
|
||||
} else if (!c.manager.codecOk(selcodec)) {
|
||||
snprintf(reply, 160,
|
||||
"ERR %s codec %u cannot be installed by this build; use `ota pull %s folder` to capture it",
|
||||
codec_kind(selcodec), (unsigned)selcodec, selstr);
|
||||
codec_kind(selflags, selcodec), (unsigned)selcodec, selstr);
|
||||
return true;
|
||||
}
|
||||
#if defined(NRF52_PLATFORM)
|
||||
@@ -430,9 +502,10 @@ bool handle_ota_command(const char* command, char* reply, mesh::MainBoard& board
|
||||
strcpy(reply, "ERR bootloader has no mOTA apply support; update it over USB first");
|
||||
return true;
|
||||
}
|
||||
if (bl.apply_abi < MOTA_FORMAT_VER || selcodec >= 16 || !(bl.codec_mask & (1u << selcodec))) {
|
||||
const uint8_t need_abi = selboot ? MOTA_BOOT_FORMAT_VER : MOTA_APP_FORMAT_VER;
|
||||
if (bl.apply_abi < need_abi || selcodec >= 16 || !(bl.codec_mask & (1u << selcodec))) {
|
||||
snprintf(reply, 160, "ERR bootloader cannot apply mOTA ABI %u codec %u (has abi=%u codecs=0x%x)",
|
||||
MOTA_FORMAT_VER, (unsigned)selcodec, bl.apply_abi, bl.codec_mask);
|
||||
need_abi, (unsigned)selcodec, bl.apply_abi, bl.codec_mask);
|
||||
return true;
|
||||
}
|
||||
#if defined(OTA_SD_STORE)
|
||||
@@ -498,6 +571,7 @@ bool handle_ota_command(const char* command, char* reply, mesh::MainBoard& board
|
||||
|
||||
// ---- discard the current session (e.g. a stalled old fetch) to free the slot ----
|
||||
} else if (is_cmd(a, "drop|cancel|stop", &rest)) {
|
||||
if (c.apply_pending) { strcpy(reply, "ERR update is armed; reboot is pending"); return true; }
|
||||
OtaManager::FetchState fs = c.manager.fetchState();
|
||||
char midhx[9]; strcpy(midhx, "-");
|
||||
if (fs != OtaManager::IDLE) mesh::Utils::toHex(midhx, c.manager.fetchManifestId(), 4);
|
||||
@@ -607,6 +681,54 @@ bool handle_ota_command(const char* command, char* reply, mesh::MainBoard& board
|
||||
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)
|
||||
const OtaBootloaderIdentity& bid = c.bootloaderIdentity();
|
||||
const OtaBlCaps& bl = c.bootloaderCaps();
|
||||
if (*rest == 0 || strcmp(rest, "status") == 0) {
|
||||
if (!bid.present || !bid.crc_ok) {
|
||||
strcpy(reply, "Bootloader update unavailable: installed embedded manifest/CRC is invalid");
|
||||
return true;
|
||||
}
|
||||
MotaManifest staged;
|
||||
char midhx[9] = "-", hashhx[17] = "-";
|
||||
const bool ready = c.manager.fetchState() == OtaManager::COMPLETE &&
|
||||
staged_manifest(c, staged) && staged.is_bootloader();
|
||||
if (ready) {
|
||||
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,
|
||||
bl.apply_abi, bl.storage_flags, ready ? "ready" : "none", midhx, hashhx);
|
||||
return true;
|
||||
}
|
||||
const char* confirm = nullptr;
|
||||
if (!is_cmd(rest, "install", &confirm)) {
|
||||
strcpy(reply, "ERR usage: ota bootloader install <MID8> <HASH16>"); return true;
|
||||
}
|
||||
char midtxt[9] = {0}, hashtxt[17] = {0}, extra[2] = {0};
|
||||
if (sscanf(confirm, "%8s %16s %1s", midtxt, hashtxt, extra) != 2) {
|
||||
strcpy(reply, "ERR usage: ota bootloader install <MID8> <HASH16>"); return true;
|
||||
}
|
||||
uint8_t operator_mid[4], operator_hash8[8];
|
||||
if (!parse_hex_exact(midtxt, operator_mid, sizeof(operator_mid)) ||
|
||||
!parse_hex_exact(hashtxt, operator_hash8, sizeof(operator_hash8))) {
|
||||
strcpy(reply, "ERR MID must be 8 hex and HASH must be 16 hex characters"); return true;
|
||||
}
|
||||
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;
|
||||
}
|
||||
char m2[100];
|
||||
const bool ok = c.apply_fetched_bootloader(operator_mid, operator_hash8, m2);
|
||||
snprintf(reply, 160, "%s | %s", ok ? "OK" : "ERR", m2);
|
||||
#else
|
||||
strcpy(reply, "ERR this build cannot update its bootloader over LoRa");
|
||||
#endif
|
||||
|
||||
} else if (is_cmd(a, "install|apply|applydelta", &rest)) {
|
||||
// Apply the fetched update. Destructive (reflashes + reboots) and GATED, not interactive (no "type
|
||||
// yes" round-trip - unreliable over LoRa): refuse unless the fetch is COMPLETE, then the apply path
|
||||
|
||||
@@ -187,7 +187,14 @@ struct OtaContext {
|
||||
if (total >= 13 && fetch_store.read(0, hdr, 8) && memcmp(hdr, MOTA_MAGIC, 4) == 0) {
|
||||
uint32_t mr = total - 8; if (mr > sizeof(mb)) mr = sizeof(mb);
|
||||
MotaManifest mm;
|
||||
if (fetch_store.read(8, mb, mr) && mota_parse_manifest(mb, mr, mm) && !hwMatches(mm.hw_id)) {
|
||||
if (!fetch_store.read(8, mb, mr) || !mota_parse_manifest(mb, mr, mm)) {
|
||||
strncpy(msg, "refused: invalid staged manifest", 96); msg[95] = 0; return false;
|
||||
}
|
||||
if (mm.is_bootloader()) {
|
||||
strncpy(msg, "refused: use ota bootloader install <MID8> <HASH16>", 96);
|
||||
msg[95] = 0; return false;
|
||||
}
|
||||
if (!hwMatches(mm.hw_id)) {
|
||||
char want[33] = {0}; memcpy(want, mm.hw_id, 32);
|
||||
snprintf(msg, 96, "refused: .mota hw_id '%.32s' != this device '%s' (incompatible hardware)", want, hw_id);
|
||||
return false;
|
||||
@@ -209,16 +216,42 @@ struct OtaContext {
|
||||
#else
|
||||
ok = ota_apply_detools_mota(fetch_store.data(), fetch_store.staged_size(), allow, apply_st, msg);
|
||||
#endif
|
||||
if (ok) apply_pending = true;
|
||||
if (ok) { bootloader_apply_pending = false; apply_pending = true; }
|
||||
return ok;
|
||||
#endif
|
||||
}
|
||||
|
||||
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 (fetch_to_folder) {
|
||||
strncpy(msg, "refused: bootloader package is not in local QSPI storage", 96);
|
||||
msg[95] = 0; return false;
|
||||
}
|
||||
if (manager.fetchState() != OtaManager::COMPLETE || fetch_store.staged_size() == 0) {
|
||||
strncpy(msg, "refused: no complete bootloader package fetched", 96);
|
||||
msg[95] = 0; return false;
|
||||
}
|
||||
const OtaBootloaderIdentity& installed = bootloaderIdentity();
|
||||
bool ok = ota_prepare_bootloader_update_nrf52(
|
||||
fetch_store, allow, installed, manager.fetchManifestId(), operator_mid,
|
||||
operator_hash8, apply_st, msg);
|
||||
if (ok) { bootloader_apply_pending = true; apply_pending = true; }
|
||||
return ok;
|
||||
#else
|
||||
(void)operator_mid; (void)operator_hash8;
|
||||
strncpy(msg, "refused: this build cannot update its bootloader over LoRa", 96);
|
||||
msg[95] = 0; return false;
|
||||
#endif
|
||||
}
|
||||
|
||||
// Deferred apply-reboot: a verified `ota applydelta` approves the update but does NOT reboot inline,
|
||||
// so the CLI can first deliver the "verified; applying" reply (over LoRa it's the only way the
|
||||
// operator learns the apply started). The mesh loop then calls ota_reboot_to_apply() once that reply
|
||||
// has actually been transmitted. apply_at/apply_hard are mesh-clock deadlines the loop fills in.
|
||||
bool apply_pending = false;
|
||||
bool bootloader_apply_pending = false;
|
||||
uint32_t apply_at = 0; // earliest reboot time (lets the reply get queued + start sending)
|
||||
uint32_t apply_hard = 0; // hard cap, in case the TX queue never idles on a busy node
|
||||
|
||||
@@ -230,6 +263,15 @@ struct OtaContext {
|
||||
if (!_bl_caps_read) { _bl_caps = ota_bootloader_caps(); _bl_caps_read = true; }
|
||||
return _bl_caps;
|
||||
}
|
||||
OtaBootloaderIdentity _bl_identity;
|
||||
bool _bl_identity_read = false;
|
||||
const OtaBootloaderIdentity& bootloaderIdentity() {
|
||||
if (!_bl_identity_read) {
|
||||
ota_installed_bootloader_identity(_bl_identity);
|
||||
_bl_identity_read = true;
|
||||
}
|
||||
return _bl_identity;
|
||||
}
|
||||
|
||||
// --- discovery: the "what mOTAs are available around me" view ----------------------------------
|
||||
// The catalog (heard mOTAs) + the heard-sources table now live in OtaManager (built from beacons +
|
||||
@@ -460,6 +502,11 @@ struct OtaContext {
|
||||
manager.set_accept_full(true);
|
||||
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)
|
||||
manager.set_accept_bootloader(true);
|
||||
#else
|
||||
manager.set_accept_bootloader(false);
|
||||
#endif
|
||||
manager.set_fetch_store(&fetch_store);
|
||||
#if defined(NRF52_PLATFORM) && defined(OTA_SD_STORE)
|
||||
|
||||
@@ -27,6 +27,11 @@ inline uint32_t mota_nrf52_app_base() {
|
||||
inline uint32_t mota_nrf52_app_base() { return MOTA_NRF52_APP_BASE_S140_V6; }
|
||||
#endif
|
||||
static const uint32_t MOTA_NRF52_EXTRAFS_START = 0x000D4000u;
|
||||
// Bootloader-update-capable XIAO builds deliberately end the linked application here. The following
|
||||
// 40 KiB is reserved as internal scratch while OTAFIX copies a verified QSPI image into its own
|
||||
// 0xF4000..0xFE000 region. Ordinary nRF52 builds continue to use the 0xED000 application ceiling.
|
||||
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;
|
||||
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;
|
||||
@@ -35,6 +40,7 @@ static const uint32_t MOTA_NRF52_STAGE_CEILING_EXPANDED = MOTA_NRF52_APP_END;
|
||||
static const uint32_t MOTA_NRF52_FS_START = MOTA_NRF52_EXTRAFS_START;
|
||||
static const uint32_t MOTA_NRF52_FLASH_PAGE = 4096u;
|
||||
static const uint8_t GPREGRET_OTA_APPLY = 0x6Au; // distinct from DFU magics 0x57/0x4E/0xA8
|
||||
static const uint8_t GPREGRET_OTA_BOOTLOADER_UPDATE = 0x6Bu;
|
||||
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;
|
||||
@@ -44,12 +50,20 @@ static const uint8_t GPREGRET2_OTA_STAGE_QSPI = 0x51u;
|
||||
// staged-container start; the app and bootloader both validate that per-patch value before writing.
|
||||
static const uint32_t MOTA_NRF52_FALLBACK_INPLACE_MEMORY = 0x00098000u; // 608 KB
|
||||
|
||||
// Bootloader flash region (nRF52840: 39 KB ending just below the CF2/MBR-params pages). The app scans
|
||||
// Bootloader flash region (nRF52840: 40 KiB ending just below the CF2/MBR-params pages). The app scans
|
||||
// this for the bootloader capability marker (OtaBlInfo.h) to know whether THIS device's bootloader can
|
||||
// actually apply a .mota before staging+approving+rebooting.
|
||||
static const uint32_t MOTA_NRF52_BL_START = 0x000F4000u;
|
||||
static const uint32_t MOTA_NRF52_BL_END = 0x000FE000u;
|
||||
|
||||
inline uint32_t mota_nrf52_application_ceiling() {
|
||||
#if defined(OTA_QSPI_BOOTLOADER_UPDATE)
|
||||
return MOTA_NRF52_BOOT_SCRATCH_START;
|
||||
#else
|
||||
return MOTA_NRF52_APP_END;
|
||||
#endif
|
||||
}
|
||||
|
||||
// Compile-time layout-ordering invariants. If a constant above is edited inconsistently these fail the
|
||||
// BUILD rather than silently letting a stage/apply corrupt the filesystem (user prefs) or the app.
|
||||
static_assert((MOTA_NRF52_APP_BASE_S140_V6 % MOTA_NRF52_FLASH_PAGE) == 0, "S140 v6 base must be page-aligned");
|
||||
@@ -60,6 +74,10 @@ static_assert((MOTA_NRF52_STAGE_CEILING_EXPANDED % MOTA_NRF52_FLASH_PAGE) == 0,
|
||||
"expanded staging ceiling must be page-aligned");
|
||||
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,
|
||||
"bootloader scratch must be exactly one padded bootloader region");
|
||||
static_assert(MOTA_NRF52_BOOT_SCRATCH_END <= MOTA_NRF52_APP_END,
|
||||
"bootloader scratch must remain below InternalFS");
|
||||
static_assert(MOTA_NRF52_STAGE_CEILING_EXPANDED < MOTA_NRF52_BL_START,
|
||||
"staging and filesystems must end below the bootloader");
|
||||
static_assert(MOTA_NRF52_BL_START < MOTA_NRF52_BL_END, "bootloader region must be non-empty");
|
||||
@@ -74,7 +92,9 @@ static_assert(MOTA_NRF52_APP_BASE_S140_V7 + MOTA_NRF52_FALLBACK_INPLACE_MEMORY <
|
||||
inline uint32_t mota_nrf52_stage_ceiling_for_layout(uint32_t linked_app_end,
|
||||
bool uses_internal_extrafs) {
|
||||
if (uses_internal_extrafs) return MOTA_NRF52_STAGE_CEILING_LEGACY;
|
||||
if (linked_app_end == MOTA_NRF52_APP_END || linked_app_end == MOTA_NRF52_EXTRAFS_START)
|
||||
if (linked_app_end == MOTA_NRF52_APP_END ||
|
||||
linked_app_end == MOTA_NRF52_BOOT_SCRATCH_START ||
|
||||
linked_app_end == MOTA_NRF52_EXTRAFS_START)
|
||||
return MOTA_NRF52_STAGE_CEILING_EXPANDED;
|
||||
return MOTA_NRF52_STAGE_CEILING_LEGACY;
|
||||
}
|
||||
|
||||
@@ -20,7 +20,12 @@ static const uint8_t ENDF_MAGIC[4] = { 'E', 'n', 'd', 'F' }; // 45 6E 64 4
|
||||
static const uint32_t ENDF_LEN = 56;
|
||||
|
||||
// ---- manifest -------------------------------------------------------------
|
||||
static const uint8_t MOTA_FORMAT_VER = 2; // fixed-layout manifest (see offsets below)
|
||||
// Application packages remain v2. Bootloader packages deliberately use v3 even though the byte
|
||||
// layout is unchanged: every pre-bootloader-update parser rejects v3 before it can mistake the raw
|
||||
// bootloader region for a full application image.
|
||||
static const uint8_t MOTA_FORMAT_VER = 2; // application package (compatibility name)
|
||||
static const uint8_t MOTA_APP_FORMAT_VER = 2;
|
||||
static const uint8_t MOTA_BOOT_FORMAT_VER = 3;
|
||||
static const uint8_t HASH_ALGO_SHA256 = 0x12; // multihash code
|
||||
|
||||
// Fixed manifest layout (manifest-minus-leaves) - every field is present at a constant offset, so the
|
||||
@@ -42,6 +47,8 @@ static const uint8_t MOTA_HW_ID_LEN = 32;
|
||||
|
||||
static const uint8_t MFLAG_FULL = 0x01; // 0 = delta/partial, 1 = full image
|
||||
static const uint8_t MFLAG_SIGNED = 0x02;
|
||||
static const uint8_t MFLAG_BOOTLOADER = 0x04; // v3 only; exact flags must be FULL|SIGNED|BOOTLOADER
|
||||
static const uint8_t MFLAG_KNOWN = MFLAG_FULL | MFLAG_SIGNED | MFLAG_BOOTLOADER;
|
||||
|
||||
static const uint8_t CODEC_FULL = 0;
|
||||
static const uint8_t CODEC_DETOOLS_SEQUENTIAL = 1;
|
||||
|
||||
@@ -921,6 +921,10 @@ bool OtaManager::wantRow(const uint8_t* mid, uint32_t target, uint8_t codec, uin
|
||||
|| _fstate == WANT_LEAVES || _fstate == VERIFYING_STAGED
|
||||
|| _fstate == PAUSED) return false; // busy
|
||||
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 (_have_desired_mid) // manual pull of a specific mid
|
||||
return memcmp(mid, _desired_mid, 4) == 0 && (_desired_target == 0 || target == _desired_target);
|
||||
@@ -1169,10 +1173,16 @@ void OtaManager::handleManifest(const uint8_t* m, uint16_t n) {
|
||||
const uint8_t* mf = _mf_buf; // fully reassembled manifest-minus-leaves
|
||||
uint32_t mfl = _mf_len;
|
||||
if (mfl != MOTA_MFL) { failFetch(FETCH_ERROR_MANIFEST); return; } // fixed 197-byte manifest
|
||||
if (mf[2] != HASH_ALGO_SHA256) { failFetch(FETCH_ERROR_HASH_ALGO); return; }
|
||||
if (!_archive_fetch && !codecOk(mf[56])) { failFetch(FETCH_ERROR_CODEC); return; }
|
||||
uint32_t payload_size = rd_u32le(mf + 15);
|
||||
uint8_t bsl = mf[19];
|
||||
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)) {
|
||||
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; }
|
||||
uint32_t bs = 1u << bsl;
|
||||
// a block must fit our reassembly buffer (and be non-empty) - reject an oversized block_size up front
|
||||
@@ -1193,8 +1203,8 @@ void OtaManager::handleManifest(const uint8_t* m, uint16_t n) {
|
||||
// Hand the store the parsed layout BEFORE begin(), so a partition-backed store (ESP32) can choose
|
||||
// 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 = (mf[1] & MFLAG_FULL) != 0;
|
||||
if (!_fetch->plan_layout(is_full, rd_u32le(mf + 11), payload_off, payload_size)) {
|
||||
bool is_full = parsed.is_full();
|
||||
if (!_fetch->plan_layout(is_full, parsed.image_size, payload_off, payload_size)) {
|
||||
failFetch(FETCH_ERROR_STORAGE); return;
|
||||
}
|
||||
if (!_fetch->begin(total)) { failFetch(FETCH_ERROR_STORAGE); return; }
|
||||
@@ -1316,6 +1326,7 @@ 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;
|
||||
uint32_t mfl = (uint32_t)(m.approval - m.manifest_start) + 4; // manifest-minus-leaves length
|
||||
uint32_t bs = m.block_size();
|
||||
|
||||
@@ -359,6 +359,8 @@ public:
|
||||
// in-place `.mota` can target both ESP32 and nRF52). 0xFF = unset.
|
||||
void set_apply_codec2(uint8_t c) { _apply_codec2 = c; }
|
||||
void set_accept_full(bool on) { _accept_full = on; }
|
||||
void set_accept_bootloader(bool on) { _accept_bootloader = on; }
|
||||
bool accepts_bootloader() const { return _accept_bootloader; }
|
||||
bool codecOk(uint8_t c) const {
|
||||
return (c == CODEC_FULL && _accept_full) || c == _apply_codec || c == _apply_codec2;
|
||||
}
|
||||
@@ -391,6 +393,7 @@ public:
|
||||
void set_max_hops(uint8_t h) { _max_hops = h; }
|
||||
uint8_t max_hops() const { return _max_hops; }
|
||||
bool fetched_is_signed() const { return (_fflags & MFLAG_SIGNED) != 0; } // flags of the fetched manifest
|
||||
bool fetched_is_bootloader() const { return (_fflags & MFLAG_BOOTLOADER) != 0; }
|
||||
|
||||
// This node's id (pubkey[0:4]), stamped into adverts we send so receivers can count distinct seeders.
|
||||
void set_seeder_id(const uint8_t* id4) { if (id4) for (int i = 0; i < 4; i++) _seeder_id[i] = id4[i]; }
|
||||
@@ -601,6 +604,7 @@ private:
|
||||
uint8_t _apply_codec = CODEC_DETOOLS_SEQUENTIAL; // platform's delta codec (OtaContext sets it)
|
||||
uint8_t _apply_codec2 = 0xFF; // optional 2nd accepted delta codec (ESP32: in-place)
|
||||
bool _accept_full = true; // false on nRF52 flash (single-slot cannot apply full)
|
||||
bool _accept_bootloader = false; // explicit/manual only; never an auto-fetch signal
|
||||
bool _archive_fetch = false; // capture-only pull: accept any target/codec
|
||||
bool _archive_interest = false; // query full catalogs for the persistent archive
|
||||
uint8_t _seeder_id[4] = {0,0,0,0}; // our node id (pubkey[0:4]) for advert seeder counting
|
||||
|
||||
@@ -684,8 +684,9 @@ bool OtaStoreQspiNrf52::plan_layout(bool, uint32_t image_size, uint32_t, uint32_
|
||||
_io_ok = true;
|
||||
_stage = OtaQspiStage::IDLE;
|
||||
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) {
|
||||
const uint32_t app_ceiling = mota_nrf52_application_ceiling();
|
||||
if (image_size == 0 || payload_size == 0 || app_base >= app_ceiling ||
|
||||
image_size > app_ceiling - app_base) {
|
||||
fail("image exceeds nRF52 application region");
|
||||
releaseFlash();
|
||||
return false;
|
||||
|
||||
+1
-1
@@ -70,7 +70,7 @@ 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/` | container and EndF integrity; protocol codecs; transfer, resume, and apply safety; adaptive 2-to-4 block-request window growth and stall contraction; active-transfer priority classification |
|
||||
| `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_utils` | `src/Utils.cpp` | `Utils::toHex` (upstream) |
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
#include "helpers/ota/OtaProtocol.h"
|
||||
#include "helpers/ota/OtaManager.h"
|
||||
#include "helpers/ota/OtaApply.h"
|
||||
#include "helpers/ota/OtaBlInfo.h"
|
||||
#include "mota_vectors.h" // auto-generated by tools/mota/gen_vectors.py
|
||||
|
||||
extern "C" {
|
||||
@@ -29,6 +30,11 @@ TEST(OtaBootResult, AcceptsOnlyOtafixApplyDiagnostics) {
|
||||
EXPECT_EQ(ota_nrf52_boot_result_or_zero(0xB0), 0xB0);
|
||||
EXPECT_EQ(ota_nrf52_boot_result_or_zero(0xB8), 0xB8);
|
||||
EXPECT_EQ(ota_nrf52_boot_result_or_zero(0xBC), 0xBC);
|
||||
EXPECT_EQ(ota_nrf52_boot_result_or_zero(0xC0), 0xC0);
|
||||
EXPECT_EQ(ota_nrf52_boot_result_or_zero(0xC8), 0xC8);
|
||||
EXPECT_EQ(ota_nrf52_boot_result_or_zero(0xCF), 0xCF);
|
||||
EXPECT_TRUE(ota_nrf52_boot_update_result(0xC8));
|
||||
EXPECT_FALSE(ota_nrf52_boot_update_result(0xB8));
|
||||
|
||||
EXPECT_EQ(ota_nrf52_boot_result_or_zero(0x00), 0x00);
|
||||
EXPECT_EQ(ota_nrf52_boot_result_or_zero(0x4C), 0x00); // shutdown reason
|
||||
@@ -38,6 +44,154 @@ TEST(OtaBootResult, AcceptsOnlyOtafixApplyDiagnostics) {
|
||||
EXPECT_EQ(ota_nrf52_boot_result_or_zero(0xBD), 0x00);
|
||||
}
|
||||
|
||||
static std::vector<uint8_t> boot_manifest_bytes() {
|
||||
std::vector<uint8_t> m(MOTA_MFL, 0);
|
||||
m[0] = MOTA_BOOT_FORMAT_VER;
|
||||
m[1] = MFLAG_FULL | MFLAG_SIGNED | MFLAG_BOOTLOADER;
|
||||
m[2] = HASH_ALGO_SHA256;
|
||||
wr_u32le(m.data() + 3, OTA_XIAO_BOARD_ID_BASE);
|
||||
wr_u32le(m.data() + 7, 1);
|
||||
wr_u32le(m.data() + 11, OTA_BOOT_IMAGE_SIZE);
|
||||
wr_u32le(m.data() + 15, OTA_BOOT_IMAGE_SIZE);
|
||||
m[19] = 10;
|
||||
m[20] = 0x11; m[21] = 0x22; m[22] = 0x33; m[23] = 0x44;
|
||||
for (uint8_t i = 0; i < 32; i++) m[24 + i] = (uint8_t)(0x80 + i);
|
||||
m[56] = CODEC_FULL;
|
||||
uint8_t hw[32]; EXPECT_TRUE(ota_xiao_bootloader_hw_id(OTA_XIAO_BOARD_ID_BASE, hw));
|
||||
memcpy(m.data() + 57, hw, sizeof(hw));
|
||||
memset(m.data() + MOTA_OFF_APPROVAL, 0xFF, 4);
|
||||
return m;
|
||||
}
|
||||
|
||||
TEST(OtaBootPackage, ParserSeparatesV2ApplicationsFromStrictV3Bootloader) {
|
||||
MotaManifest parsed;
|
||||
auto m = boot_manifest_bytes();
|
||||
ASSERT_TRUE(mota_parse_manifest(m.data(), m.size(), parsed));
|
||||
EXPECT_TRUE(parsed.is_bootloader());
|
||||
EXPECT_EQ(parsed.block_count, 40u);
|
||||
|
||||
m[0] = MOTA_APP_FORMAT_VER;
|
||||
EXPECT_FALSE(mota_parse_manifest(m.data(), m.size(), parsed));
|
||||
m = boot_manifest_bytes(); m[1] &= ~MFLAG_BOOTLOADER;
|
||||
EXPECT_FALSE(mota_parse_manifest(m.data(), m.size(), parsed));
|
||||
m = boot_manifest_bytes(); m[19] = 9;
|
||||
EXPECT_FALSE(mota_parse_manifest(m.data(), m.size(), parsed));
|
||||
m = boot_manifest_bytes(); memset(m.data() + 7, 0, 4);
|
||||
EXPECT_FALSE(mota_parse_manifest(m.data(), m.size(), parsed));
|
||||
m = boot_manifest_bytes(); m[MOTA_OFF_BASE_HASH] = 1;
|
||||
EXPECT_FALSE(mota_parse_manifest(m.data(), m.size(), parsed));
|
||||
}
|
||||
|
||||
TEST(OtaBootPackage, EmbeddedIdentityVectorCapsAndExplicitConfirmationGate) {
|
||||
std::vector<uint8_t> image(OTA_BOOT_IMAGE_SIZE, 0xFF);
|
||||
wr_u32le(image.data(), OTA_NRF52840_RAM_END);
|
||||
wr_u32le(image.data() + 4, OTA_BOOT_IMAGE_START + 0x101u);
|
||||
ASSERT_TRUE(ota_bootloader_vector_sane(image.data()));
|
||||
image[4] &= 0xFE;
|
||||
EXPECT_FALSE(ota_bootloader_vector_sane(image.data()));
|
||||
wr_u32le(image.data() + 4, OTA_BOOT_IMAGE_START + 0x101u);
|
||||
|
||||
const uint32_t moff = 0x100;
|
||||
memset(image.data() + moff, 0, OTA_BOOT_MANIFEST_SIZE);
|
||||
wr_u32le(image.data() + moff, OTA_BOOT_MANIFEST_MAGIC0);
|
||||
wr_u32le(image.data() + moff + 4, OTA_BOOT_MANIFEST_MAGIC1);
|
||||
image[moff + 8] = OTA_BOOT_MANIFEST_VERSION;
|
||||
image[moff + 10] = OTA_BOOT_MANIFEST_SIZE;
|
||||
wr_u32le(image.data() + moff + 12, OTA_BOOT_IMAGE_START);
|
||||
wr_u32le(image.data() + moff + 16, OTA_BOOT_IMAGE_SIZE);
|
||||
wr_u32le(image.data() + moff + 20, OTA_XIAO_BOARD_ID_BASE);
|
||||
memcpy(image.data() + moff + 24, OTA_XIAO_BOOT_DEVICE_NAME,
|
||||
OTA_BOOT_DEVICE_NAME_SIZE);
|
||||
wr_u32le(image.data() + moff + 40,
|
||||
ota_boot_image_crc32(image.data(), image.size(), moff + 40));
|
||||
const std::vector<uint8_t> canonical_image = image;
|
||||
|
||||
OtaBootloaderIdentity identity;
|
||||
ASSERT_TRUE(ota_bootloader_identity_from_image(image.data(), image.size(), identity));
|
||||
EXPECT_TRUE(identity.crc_ok);
|
||||
EXPECT_EQ(identity.board_id, OTA_XIAO_BOARD_ID_BASE);
|
||||
|
||||
// A structurally complete bad-CRC decoy before the real manifest must not
|
||||
// shadow the later valid identity.
|
||||
const uint32_t decoy = 0x20;
|
||||
memcpy(image.data() + decoy, image.data() + moff, OTA_BOOT_MANIFEST_SIZE);
|
||||
wr_u32le(image.data() + decoy + 40, 0xA5A5A5A5u);
|
||||
wr_u32le(image.data() + moff + 40, 0);
|
||||
wr_u32le(image.data() + moff + 40,
|
||||
ota_boot_image_crc32(image.data(), image.size(), moff + 40));
|
||||
ASSERT_NE(ota_boot_image_crc32(image.data(), image.size(), decoy + 40), 0xA5A5A5A5u);
|
||||
ASSERT_TRUE(ota_bootloader_identity_from_image(image.data(), image.size(), identity));
|
||||
EXPECT_EQ(identity.manifest_offset, moff);
|
||||
|
||||
// These coupled CRC values make both otherwise identical manifests valid
|
||||
// in this deterministic fixture. Two privileged identities are ambiguous
|
||||
// and must fail closed in the helper shared with the staged-QSPI scanner.
|
||||
image = canonical_image;
|
||||
const uint32_t second = 0x200;
|
||||
memcpy(image.data() + second, image.data() + moff, OTA_BOOT_MANIFEST_SIZE);
|
||||
wr_u32le(image.data() + moff + 40, 0x07033138u);
|
||||
wr_u32le(image.data() + second + 40, 0x0998508Cu);
|
||||
ASSERT_EQ(ota_boot_image_crc32(image.data(), image.size(), moff + 40), 0x07033138u);
|
||||
ASSERT_EQ(ota_boot_image_crc32(image.data(), image.size(), second + 40), 0x0998508Cu);
|
||||
EXPECT_FALSE(ota_bootloader_identity_from_image(image.data(), image.size(), identity));
|
||||
|
||||
image = canonical_image;
|
||||
image[0x300] ^= 1;
|
||||
EXPECT_FALSE(ota_bootloader_identity_from_image(image.data(), image.size(), identity));
|
||||
|
||||
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};
|
||||
OtaBootloaderCapsMarker caps;
|
||||
EXPECT_TRUE(ota_bootloader_caps_marker_parse(marker, caps));
|
||||
marker[15] = 1;
|
||||
EXPECT_FALSE(ota_bootloader_caps_marker_parse(marker, caps));
|
||||
|
||||
OtaBlCaps selected;
|
||||
selected.present = true;
|
||||
selected.apply_abi = 3;
|
||||
selected.storage_flags = OTA_BL_STORAGE_QSPI | OTA_BL_STORAGE_BOOT_UPDATE;
|
||||
EXPECT_FALSE(ota_bl_caps_prefer(selected, 99, OTA_BL_STORAGE_QSPI, true));
|
||||
EXPECT_TRUE(ota_bl_caps_prefer(selected, 4,
|
||||
OTA_BL_STORAGE_QSPI | OTA_BL_STORAGE_BOOT_UPDATE, true));
|
||||
|
||||
auto mf = boot_manifest_bytes();
|
||||
MotaManifest m;
|
||||
ASSERT_TRUE(mota_parse_manifest(mf.data(), mf.size(), m));
|
||||
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 = OTA_XIAO_BOARD_ID_BASE;
|
||||
memcpy(installed.device_name, OTA_XIAO_BOOT_DEVICE_NAME,
|
||||
OTA_BOOT_DEVICE_NAME_SIZE);
|
||||
uint8_t mid[4] = {0x11,0x22,0x33,0x44};
|
||||
uint8_t hash8[8]; memcpy(hash8, m.image_hash, sizeof(hash8));
|
||||
EXPECT_EQ(ota_bootloader_confirmation_gate(m, installed, mid, mid, hash8), OTA_BOOT_CONFIRM_OK);
|
||||
uint8_t bad_mid[4] = {0};
|
||||
EXPECT_EQ(ota_bootloader_confirmation_gate(m, installed, mid, bad_mid, hash8),
|
||||
OTA_BOOT_CONFIRM_MID);
|
||||
hash8[0] ^= 1;
|
||||
EXPECT_EQ(ota_bootloader_confirmation_gate(m, installed, mid, mid, hash8),
|
||||
OTA_BOOT_CONFIRM_IMAGE_HASH);
|
||||
}
|
||||
|
||||
TEST(OtaBootPackage, CapabilityScannerRejectsAnOtherwiseValidUnalignedMarker) {
|
||||
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};
|
||||
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);
|
||||
|
||||
memset(image, 0xFF, sizeof(image));
|
||||
memcpy(image + 4, marker, sizeof(marker));
|
||||
const OtaBlCaps caps = ota_bl_caps_scan_aligned(image, sizeof(image), true);
|
||||
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));
|
||||
}
|
||||
|
||||
class FakeMotaSeederStream : public Stream {
|
||||
public:
|
||||
using Stream::write;
|
||||
@@ -1812,6 +1966,53 @@ static uint16_t make_have1(uint8_t* buf, uint16_t cap, const uint8_t mid[4],
|
||||
return encode_have(buf, cap, hv);
|
||||
}
|
||||
|
||||
TEST(OtaTransfer, BootloaderPackageRequiresCapableExplicitMidPull) {
|
||||
const auto manifest = boot_manifest_bytes();
|
||||
const uint8_t mid[4] = {0x11, 0x22, 0x33, 0x44};
|
||||
const uint8_t boot_flags = MFLAG_FULL | MFLAG_SIGNED | MFLAG_BOOTLOADER;
|
||||
uint8_t wire[64];
|
||||
|
||||
// Even a capable XIAO must never autofetch a bootloader package. The target is intentionally
|
||||
// its exact installed-bootloader board ID, so this proves the manual-MID gate rather than a
|
||||
// coincidental target mismatch.
|
||||
OtaManager capable;
|
||||
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_bootloader(true);
|
||||
capable.set_autofetch(OtaManager::AUTOFETCH_ANY);
|
||||
capable.on_message(
|
||||
wire, make_have1(wire, sizeof(wire), mid, OTA_XIAO_BOARD_ID_BASE, 1,
|
||||
CODEC_FULL, boot_flags));
|
||||
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);
|
||||
deliver_manifest_fragment(capable, mid, 1, manifest.data() + OTA_MF_FRAG,
|
||||
(uint16_t)(MOTA_MFL - OTA_MF_FRAG));
|
||||
EXPECT_EQ(capable.fetchState(), OtaManager::FETCHING);
|
||||
EXPECT_TRUE(capable.fetched_is_bootloader());
|
||||
EXPECT_GT(capable_store.staged_size(), OTA_BOOT_IMAGE_SIZE);
|
||||
|
||||
// The same explicit pull is rejected before storage begin when this firmware lacks the privileged
|
||||
// capability. This is the manager-layer guard; every apply backend has an independent rejection too.
|
||||
OtaManager incapable;
|
||||
OtaStoreRam<45000> incapable_store;
|
||||
incapable.begin(OTA_XIAO_BOARD_ID_BASE, nullptr, nullptr);
|
||||
incapable.set_fetch_store(&incapable_store);
|
||||
incapable.set_accept_full(true);
|
||||
EXPECT_EQ(incapable.pull(mid, OTA_XIAO_BOARD_ID_BASE), OtaManager::PULL_STARTED);
|
||||
deliver_manifest_fragment(incapable, mid, 0, manifest.data(), OTA_MF_FRAG);
|
||||
deliver_manifest_fragment(incapable, mid, 1, manifest.data() + OTA_MF_FRAG,
|
||||
(uint16_t)(MOTA_MFL - OTA_MF_FRAG));
|
||||
EXPECT_EQ(incapable.fetchState(), OtaManager::FAILED);
|
||||
EXPECT_EQ(incapable.fetchError(), OtaManager::FETCH_ERROR_CODEC);
|
||||
EXPECT_EQ(incapable_store.staged_size(), 0u);
|
||||
}
|
||||
|
||||
// A node must not fetch firmware it can't apply: a catalog row whose codec the platform can't decode is
|
||||
// not fetched. Full-image acceptance is platform-selectable (nRF52 single-slot disables it).
|
||||
TEST(OtaTransfer, RejectsIncompatibleCodec) {
|
||||
|
||||
@@ -69,8 +69,10 @@ TEST(OtaFlashPlan, SelectsCeilingFromLinkedLayoutAndStorage) {
|
||||
// Standard or ExtraFS linker without an internal secondary filesystem reclaims the unused 100 KiB.
|
||||
EXPECT_EQ(mota_nrf52_stage_ceiling_for_layout(EXPANDED, false), EXPANDED);
|
||||
EXPECT_EQ(mota_nrf52_stage_ceiling_for_layout(LEGACY, false), EXPANDED);
|
||||
// The explicit XIAO boot-update linker leaves 40 KiB scratch at 0xE0000 and still stages externally.
|
||||
EXPECT_EQ(mota_nrf52_stage_ceiling_for_layout(MOTA_NRF52_BOOT_SCRATCH_START, false), EXPANDED);
|
||||
// An unrecognized linker region is never permission to erase a larger window.
|
||||
EXPECT_EQ(mota_nrf52_stage_ceiling_for_layout(0xE0000u, false), LEGACY);
|
||||
EXPECT_EQ(mota_nrf52_stage_ceiling_for_layout(0xE1000u, false), LEGACY);
|
||||
}
|
||||
|
||||
TEST(OtaFlashPlan, ValidatesExternalInplacePatchGeometryBeforeHandoff) {
|
||||
|
||||
@@ -43,8 +43,12 @@ import zipfile
|
||||
MOTA_MAGIC = b"mOTA"
|
||||
MOTA_TRAILER = b"vk496"
|
||||
MOTA_FORMAT_VERSION = 2
|
||||
MOTA_BOOT_FORMAT_VERSION = 3
|
||||
MOTA_FIXED_MANIFEST_SIZE = 197
|
||||
MOTA_FLAG_FULL = 0x01
|
||||
MOTA_FLAG_SIGNED = 0x02
|
||||
MOTA_FLAG_BOOTLOADER = 0x04
|
||||
MOTA_KNOWN_FLAGS = MOTA_FLAG_FULL | MOTA_FLAG_SIGNED | MOTA_FLAG_BOOTLOADER
|
||||
MOTA_CODEC_FULL = 0
|
||||
MOTA_CODEC_SEQUENTIAL = 1
|
||||
MOTA_CODEC_IN_PLACE = 2
|
||||
@@ -462,12 +466,19 @@ def parse_mota(blob: bytes, path: Path | None = None) -> MotaInfo:
|
||||
raise OtaError(
|
||||
f"mOTA size field is {declared_size}, but the file is {len(blob)} bytes"
|
||||
)
|
||||
flags = blob[9]
|
||||
if blob[8] == MOTA_BOOT_FORMAT_VERSION and flags & MOTA_FLAG_BOOTLOADER:
|
||||
raise OtaError(
|
||||
"bootloader mOTA packages require the device's explicit `ota bootloader install` "
|
||||
"workflow; this application-update runner deliberately refuses them"
|
||||
)
|
||||
if blob[8] != MOTA_FORMAT_VERSION:
|
||||
raise OtaError(f"unsupported mOTA format version {blob[8]}")
|
||||
if flags & MOTA_FLAG_BOOTLOADER or flags & ~MOTA_KNOWN_FLAGS:
|
||||
raise OtaError("invalid flags in v2 application mOTA")
|
||||
if blob[10] != 0x12:
|
||||
raise OtaError(f"unsupported mOTA hash algorithm 0x{blob[10]:02x}")
|
||||
|
||||
flags = blob[9]
|
||||
target_id, fw_version, image_size, payload_size = struct.unpack_from("<IIII", blob, 11)
|
||||
block_size_log2 = blob[27]
|
||||
if block_size_log2 == 0 or block_size_log2 > 10:
|
||||
|
||||
@@ -137,6 +137,17 @@ class FormatTests(unittest.TestCase):
|
||||
with self.assertRaisesRegex(ota.OtaError, "block hashes"):
|
||||
ota.parse_mota(bytes(blob))
|
||||
|
||||
def test_bootloader_packages_are_explicitly_refused(self) -> None:
|
||||
blob = bytearray(mota_blob(firmware(b"B" * 5000, VERSION_NEW)))
|
||||
blob[8] = ota.MOTA_BOOT_FORMAT_VERSION
|
||||
blob[9] |= ota.MOTA_FLAG_SIGNED | ota.MOTA_FLAG_BOOTLOADER
|
||||
with self.assertRaisesRegex(ota.OtaError, "bootloader mOTA packages"):
|
||||
ota.parse_mota(bytes(blob))
|
||||
|
||||
blob[8] = ota.MOTA_FORMAT_VERSION
|
||||
with self.assertRaisesRegex(ota.OtaError, "invalid flags in v2"):
|
||||
ota.parse_mota(bytes(blob))
|
||||
|
||||
def test_block_larger_than_firmware_buffer_is_rejected(self) -> None:
|
||||
image = firmware(b"C" * 5000, VERSION_NEW)
|
||||
with self.assertRaisesRegex(ota.OtaError, "at most 1024 bytes"):
|
||||
|
||||
+19
-2
@@ -31,8 +31,8 @@ Uses the repo's Python venv (`meshcore/`). Dependencies: `detools` (delta), `cry
|
||||
## Tests
|
||||
|
||||
```bash
|
||||
./meshcore/bin/python tools/mota/test_mota.py # EndF, merkle+proofs, full/delta, signing,
|
||||
# tamper detection, approval enforcement
|
||||
./meshcore/bin/python tools/mota/test_mota.py # EndF, merkle+proofs, v2 apps/v3 XIAO 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`)
|
||||
```
|
||||
@@ -51,3 +51,20 @@ firmware and matches a delta's `base_hash` against its own `EndF`. Wiring (handl
|
||||
`target_id` = `sha2-256:4(pio_env_name)`, `hw_id` = explicit `-D MOTA_HW_ID` or a role-stripped hardware
|
||||
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
|
||||
|
||||
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.
|
||||
|
||||
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).
|
||||
The ordinary `tools/lora_ota` runner rejects bootloader packages.
|
||||
+168
-9
@@ -1,7 +1,7 @@
|
||||
"""
|
||||
motalib - build/parse/verify MeshCore ``.mota`` firmware-update containers.
|
||||
|
||||
Pure logic, no CLI. Implements docs/ota_protocol.md (format_ver=2, fixed layout).
|
||||
Pure logic, no CLI. Implements docs/ota_protocol.md (v2 application / v3 bootloader, fixed layout).
|
||||
|
||||
The wire format (all integers little-endian):
|
||||
|
||||
@@ -37,11 +37,15 @@ ENDF_MAGIC = b"EndF" # 45 6E 64 46
|
||||
# Fixed-length trailer: marker(4) + body_len(4) + body_hash8(8) + fw_version(4) + target_id(4) + hw_id(32).
|
||||
ENDF_LEN = 56
|
||||
|
||||
FORMAT_VER = 2 # the one manifest format (fixed layout, see Manifest); other values are rejected
|
||||
FORMAT_VER = 2 # application compatibility name
|
||||
APP_FORMAT_VER = 2
|
||||
BOOT_FORMAT_VER = 3
|
||||
HASH_ALGO_SHA256 = 0x12 # multihash code for sha2-256
|
||||
|
||||
FLAG_FULL = 0x01
|
||||
FLAG_SIGNED = 0x02
|
||||
FLAG_BOOTLOADER = 0x04
|
||||
FLAG_KNOWN = FLAG_FULL | FLAG_SIGNED | FLAG_BOOTLOADER
|
||||
|
||||
CODEC_FULL = 0
|
||||
CODEC_DETOOLS_SEQUENTIAL = 1 # detools `sequential` patch (decoded on-device by vendored detools C)
|
||||
@@ -61,6 +65,8 @@ NRF52_INPLACE_MEMORY = 0x00098000
|
||||
NRF52_APP_BASE_S140_V6 = 0x00026000
|
||||
NRF52_APP_BASE_S140_V7 = 0x00027000
|
||||
NRF52_EXTRAFS_START = 0x000D4000
|
||||
NRF52_BOOT_SCRATCH_START = 0x000E0000
|
||||
NRF52_BOOT_SCRATCH_END = 0x000EA000
|
||||
NRF52_APP_END = 0x000ED000
|
||||
NRF52_FLASH_PAGE = 4096
|
||||
|
||||
@@ -73,6 +79,19 @@ NRF52_LAYOUT_LEN = 24
|
||||
NRF52_LAYOUT_FLAG_SD = 0x01
|
||||
NRF52_LAYOUT_FLAG_INTERNAL_EXTRAFS = 0x02
|
||||
NRF52_LAYOUT_FLAG_QSPI = 0x04
|
||||
NRF52_LAYOUT_FLAG_BOOTLOADER_SCRATCH = 0x08
|
||||
|
||||
XIAO_BOOT_BOARD_ID_BASE = 0x28860044
|
||||
XIAO_BOOT_BOARD_ID_SENSE = 0x28860045
|
||||
XIAO_BOOT_IMAGE_START = 0x000F4000
|
||||
XIAO_BOOT_IMAGE_SIZE = 0x0000A000
|
||||
XIAO_BOOT_MANIFEST_MAGIC = b"BLMFCRC1"
|
||||
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"
|
||||
XIAO_BOOT_STORAGE_QSPI = 0x04
|
||||
XIAO_BOOT_STORAGE_UPDATE = 0x08
|
||||
|
||||
# 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.
|
||||
@@ -199,12 +218,16 @@ class Nrf52Layout:
|
||||
def external_backed(self) -> bool:
|
||||
return self.sd_backed or self.qspi_backed
|
||||
|
||||
@property
|
||||
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:
|
||||
return NRF52_EXTRAFS_START
|
||||
if linked_app_end in (NRF52_EXTRAFS_START, NRF52_APP_END):
|
||||
if linked_app_end in (NRF52_EXTRAFS_START, NRF52_BOOT_SCRATCH_START, NRF52_APP_END):
|
||||
return NRF52_APP_END
|
||||
return NRF52_EXTRAFS_START
|
||||
|
||||
@@ -212,20 +235,25 @@ 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_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}")
|
||||
if not (layout.app_base < layout.linked_app_end <= NRF52_APP_END):
|
||||
raise ValueError("invalid nRF52 app region")
|
||||
known_flags = (NRF52_LAYOUT_FLAG_SD | NRF52_LAYOUT_FLAG_INTERNAL_EXTRAFS |
|
||||
NRF52_LAYOUT_FLAG_QSPI)
|
||||
NRF52_LAYOUT_FLAG_QSPI | NRF52_LAYOUT_FLAG_BOOTLOADER_SCRATCH)
|
||||
if layout.flags & ~known_flags:
|
||||
raise ValueError(f"unsupported nRF52 layout flags 0x{layout.flags:X}")
|
||||
if layout.sd_backed and layout.qspi_backed:
|
||||
raise ValueError("nRF52 layout cannot use both SD and QSPI staging")
|
||||
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")
|
||||
elif layout.linked_app_end == NRF52_BOOT_SCRATCH_START:
|
||||
raise ValueError("nRF52 linked app end 0xE0000 requires the bootloader-scratch flag")
|
||||
expected_ceiling = (NRF52_APP_END if layout.external_backed else
|
||||
nrf52_stage_ceiling_for_layout(
|
||||
layout.linked_app_end,
|
||||
@@ -417,6 +445,10 @@ class Manifest:
|
||||
def is_signed(self) -> bool:
|
||||
return bool(self.flags & FLAG_SIGNED)
|
||||
|
||||
@property
|
||||
def is_bootloader(self) -> bool:
|
||||
return bool(self.flags & FLAG_BOOTLOADER)
|
||||
|
||||
@property
|
||||
def block_size(self) -> int:
|
||||
return 1 << self.block_size_log2
|
||||
@@ -460,6 +492,98 @@ def hw_id_bytes(s) -> bytes:
|
||||
return raw + b"\0" * (32 - len(raw))
|
||||
|
||||
|
||||
def xiao_bootloader_hw_id(board_id: int) -> str:
|
||||
if board_id == XIAO_BOOT_BOARD_ID_BASE:
|
||||
return "XIAO_BL_28860044"
|
||||
if board_id == XIAO_BOOT_BOARD_ID_SENSE:
|
||||
return "XIAO_BL_28860045"
|
||||
raise ValueError(f"unsupported XIAO bootloader board ID 0x{board_id:08X}")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class XiaoBootloaderIdentity:
|
||||
manifest_offset: int
|
||||
image_start: int
|
||||
image_size: int
|
||||
board_id: int
|
||||
device_name: str
|
||||
crc32: int
|
||||
|
||||
|
||||
def _xiao_bootloader_vector_sane(image: bytes) -> bool:
|
||||
if len(image) < 8:
|
||||
return False
|
||||
sp, reset = struct.unpack_from("<II", image)
|
||||
entry = reset & ~1
|
||||
return (sp & 7) == 0 and 0x20000000 <= sp <= 0x20040000 and (reset & 1) != 0 \
|
||||
and XIAO_BOOT_IMAGE_START <= entry < XIAO_BOOT_IMAGE_START + XIAO_BOOT_IMAGE_SIZE
|
||||
|
||||
|
||||
def parse_xiao_bootloader_identity(image: bytes) -> Optional[XiaoBootloaderIdentity]:
|
||||
"""Find exactly one CRC-valid embedded v1 manifest, continuing past invalid decoys."""
|
||||
if len(image) != XIAO_BOOT_IMAGE_SIZE:
|
||||
return None
|
||||
found = None
|
||||
for off in range(0, len(image) - XIAO_BOOT_MANIFEST_SIZE + 1, 4):
|
||||
if image[off:off + 8] != XIAO_BOOT_MANIFEST_MAGIC:
|
||||
continue
|
||||
version, size = struct.unpack_from("<HH", image, off + 8)
|
||||
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]
|
||||
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):
|
||||
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)
|
||||
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.
|
||||
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
|
||||
|
||||
|
||||
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")
|
||||
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
|
||||
|
||||
|
||||
def _validate_lengths(m: Manifest):
|
||||
assert len(m.merkle_root) == 4
|
||||
assert len(m.image_hash) == 32
|
||||
@@ -472,11 +596,26 @@ def _validate_lengths(m: Manifest):
|
||||
|
||||
def build_manifest(*, target_id: int, fw_version: int, image_size: int, payload: bytes,
|
||||
block_size: int, image_hash: bytes, codec_id: int, is_full: bool,
|
||||
base_hash: Optional[bytes] = None, sign_priv=None, hw_id=None) -> Manifest:
|
||||
base_hash: Optional[bytes] = None, sign_priv=None, hw_id=None,
|
||||
bootloader: bool = False) -> Manifest:
|
||||
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)
|
||||
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")
|
||||
hw_id = expected_hw
|
||||
m = Manifest(
|
||||
flags=(FLAG_FULL if is_full else 0) | (FLAG_SIGNED if sign_priv is not None else 0),
|
||||
format_ver=BOOT_FORMAT_VER if bootloader else APP_FORMAT_VER,
|
||||
flags=((FLAG_FULL if is_full else 0) | (FLAG_SIGNED if sign_priv is not None else 0) |
|
||||
(FLAG_BOOTLOADER if bootloader else 0)),
|
||||
target_id=target_id,
|
||||
fw_version=fw_version,
|
||||
image_size=image_size,
|
||||
@@ -535,9 +674,15 @@ def parse_container(blob: bytes) -> Parsed:
|
||||
|
||||
m = Manifest()
|
||||
m.format_ver = take(1)[0]
|
||||
if m.format_ver != FORMAT_VER:
|
||||
raise ValueError(f"unsupported format_ver {m.format_ver}")
|
||||
m.flags = take(1)[0]
|
||||
if m.format_ver == APP_FORMAT_VER:
|
||||
if m.flags & FLAG_BOOTLOADER or m.flags & ~FLAG_KNOWN:
|
||||
raise ValueError("v2 application manifest has invalid flags")
|
||||
elif m.format_ver == BOOT_FORMAT_VER:
|
||||
if m.flags != FLAG_FULL | FLAG_SIGNED | FLAG_BOOTLOADER:
|
||||
raise ValueError("v3 bootloader manifest requires exact FULL|SIGNED|BOOTLOADER flags")
|
||||
else:
|
||||
raise ValueError(f"unsupported format_ver {m.format_ver}")
|
||||
m.hash_algo = take(1)[0]
|
||||
m.target_id, m.fw_version, m.image_size, m.payload_size = struct.unpack("<IIII", take(16))
|
||||
m.block_size_log2 = take(1)[0]
|
||||
@@ -558,6 +703,14 @@ def parse_container(blob: bytes) -> Parsed:
|
||||
rest = r.read() # trailer was stripped above, so nothing should remain
|
||||
if rest != b"":
|
||||
raise ValueError("trailing bytes after payload")
|
||||
if m.is_bootloader:
|
||||
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))):
|
||||
raise ValueError("v3 bootloader manifest geometry/identity is invalid")
|
||||
return Parsed(manifest=m, payload=payload, total_size=total)
|
||||
|
||||
|
||||
@@ -571,6 +724,12 @@ def verify(parsed: Parsed, *, expect_pub: Optional[bytes] = None,
|
||||
problems: List[str] = []
|
||||
m, payload = parsed.manifest, parsed.payload
|
||||
|
||||
if m.is_bootloader:
|
||||
try:
|
||||
validate_xiao_bootloader_image(payload, m.target_id)
|
||||
except (UnicodeDecodeError, ValueError) as exc:
|
||||
problems.append(f"bootloader image contract: {exc}")
|
||||
|
||||
# block_count / leaves
|
||||
if len(m.leaves) != m.block_count:
|
||||
problems.append(f"leaves count {len(m.leaves)} != block_count {m.block_count}")
|
||||
|
||||
@@ -179,15 +179,23 @@ 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
|
||||
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:
|
||||
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")
|
||||
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_INTERNAL_EXTRAFS if internal_extrafs else 0) |
|
||||
(ml.NRF52_LAYOUT_FLAG_BOOTLOADER_SCRATCH if 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()
|
||||
|
||||
+199
-1
@@ -121,7 +121,8 @@ def test_nrf52_layout_record_roundtrip_and_policy():
|
||||
== ml.NRF52_EXTRAFS_START)
|
||||
assert (ml.nrf52_stage_ceiling_for_layout(ml.NRF52_EXTRAFS_START, False)
|
||||
== ml.NRF52_APP_END)
|
||||
assert ml.nrf52_stage_ceiling_for_layout(0xE0000, False) == ml.NRF52_EXTRAFS_START
|
||||
assert (ml.nrf52_stage_ceiling_for_layout(ml.NRF52_BOOT_SCRATCH_START, False)
|
||||
== ml.NRF52_APP_END)
|
||||
|
||||
layout = ml.Nrf52Layout(ml.NRF52_APP_BASE_S140_V7, ml.NRF52_EXTRAFS_START,
|
||||
ml.NRF52_APP_END, 0)
|
||||
@@ -142,6 +143,13 @@ def test_nrf52_layout_record_roundtrip_and_policy():
|
||||
qspi_image, _ = ml.ensure_endf(ml.ensure_nrf52_layout(_fw(7, 2048), qspi))
|
||||
assert ml.parse_nrf52_layout(qspi_image) == qspi
|
||||
assert qspi.qspi_backed and qspi.external_backed and not qspi.sd_backed
|
||||
boot_qspi = ml.Nrf52Layout(
|
||||
ml.NRF52_APP_BASE_S140_V7, ml.NRF52_BOOT_SCRATCH_START,
|
||||
ml.NRF52_APP_END,
|
||||
ml.NRF52_LAYOUT_FLAG_QSPI | ml.NRF52_LAYOUT_FLAG_BOOTLOADER_SCRATCH)
|
||||
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
|
||||
try:
|
||||
ml.build_nrf52_layout(ml.Nrf52Layout(
|
||||
ml.NRF52_APP_BASE_S140_V7, ml.NRF52_EXTRAFS_START,
|
||||
@@ -162,6 +170,196 @@ def test_nrf52_layout_record_roundtrip_and_policy():
|
||||
pass
|
||||
|
||||
|
||||
def _xiao_bootloader_image(board_id=ml.XIAO_BOOT_BOARD_ID_BASE):
|
||||
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.XIAO_BOOT_STORAGE_QSPI | ml.XIAO_BOOT_STORAGE_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,
|
||||
ml.XIAO_BOOT_IMAGE_START, ml.XIAO_BOOT_IMAGE_SIZE, board_id, name, 0)
|
||||
crc = zlib.crc32(image) & 0xFFFFFFFF
|
||||
struct.pack_into("<I", image, 0x100 + 40, crc)
|
||||
return bytes(image)
|
||||
|
||||
|
||||
def _xiao_manifest_crc(image, offset):
|
||||
import zlib
|
||||
trial = bytearray(image)
|
||||
trial[offset + 40:offset + 44] = b"\0" * 4
|
||||
return zlib.crc32(trial) & 0xFFFFFFFF
|
||||
|
||||
|
||||
def _set_two_valid_xiao_manifest_crcs(image, first, second):
|
||||
"""Solve the two coupled whole-image CRC fields over GF(2)."""
|
||||
first_crc, second_crc = first + 40, second + 40
|
||||
image[first_crc:first_crc + 4] = b"\0" * 4
|
||||
image[second_crc:second_crc + 4] = b"\0" * 4
|
||||
|
||||
def residual(value):
|
||||
trial = bytearray(image)
|
||||
struct.pack_into("<I", trial, first_crc, value & 0xFFFFFFFF)
|
||||
struct.pack_into("<I", trial, second_crc, value >> 32)
|
||||
first_error = struct.unpack_from("<I", trial, first_crc)[0] ^ _xiao_manifest_crc(trial, first)
|
||||
second_error = struct.unpack_from("<I", trial, second_crc)[0] ^ _xiao_manifest_crc(trial, second)
|
||||
return first_error | (second_error << 32)
|
||||
|
||||
affine = residual(0)
|
||||
columns = [residual(1 << bit) ^ affine for bit in range(64)]
|
||||
rows = []
|
||||
for output_bit in range(64):
|
||||
row = sum(1 << input_bit for input_bit, column in enumerate(columns)
|
||||
if (column >> output_bit) & 1)
|
||||
rows.append(row | (((affine >> output_bit) & 1) << 64))
|
||||
|
||||
pivot_columns = []
|
||||
pivot_row = 0
|
||||
for column in range(64):
|
||||
found = next((row for row in range(pivot_row, 64)
|
||||
if (rows[row] >> column) & 1), None)
|
||||
if found is None:
|
||||
continue
|
||||
rows[pivot_row], rows[found] = rows[found], rows[pivot_row]
|
||||
for row in range(64):
|
||||
if row != pivot_row and (rows[row] >> column) & 1:
|
||||
rows[row] ^= rows[pivot_row]
|
||||
pivot_columns.append(column)
|
||||
pivot_row += 1
|
||||
|
||||
assert all((row & ((1 << 64) - 1)) or not ((row >> 64) & 1) for row in rows)
|
||||
solution = 0
|
||||
for row, column in enumerate(pivot_columns):
|
||||
if (rows[row] >> 64) & 1:
|
||||
solution |= 1 << column
|
||||
assert residual(solution) == 0
|
||||
struct.pack_into("<I", image, first_crc, solution & 0xFFFFFFFF)
|
||||
struct.pack_into("<I", image, second_crc, solution >> 32)
|
||||
|
||||
|
||||
def test_xiao_bootloader_identity_skips_bad_decoy_and_rejects_two_valid_manifests():
|
||||
real_offset, decoy_offset, second_offset = 0x100, 0x20, 0x200
|
||||
image = bytearray(_xiao_bootloader_image())
|
||||
image[decoy_offset:decoy_offset + ml.XIAO_BOOT_MANIFEST_SIZE] = \
|
||||
image[real_offset:real_offset + ml.XIAO_BOOT_MANIFEST_SIZE]
|
||||
struct.pack_into("<I", image, decoy_offset + 40, 0xA5A5A5A5)
|
||||
struct.pack_into("<I", image, real_offset + 40, _xiao_manifest_crc(image, real_offset))
|
||||
assert _xiao_manifest_crc(image, decoy_offset) != 0xA5A5A5A5
|
||||
identity = ml.parse_xiao_bootloader_identity(bytes(image))
|
||||
assert identity is not None and identity.manifest_offset == real_offset
|
||||
|
||||
duplicate = bytearray(_xiao_bootloader_image())
|
||||
duplicate[second_offset:second_offset + ml.XIAO_BOOT_MANIFEST_SIZE] = \
|
||||
duplicate[real_offset:real_offset + ml.XIAO_BOOT_MANIFEST_SIZE]
|
||||
_set_two_valid_xiao_manifest_crcs(duplicate, real_offset, second_offset)
|
||||
assert struct.unpack_from("<I", duplicate, real_offset + 40)[0] == \
|
||||
_xiao_manifest_crc(duplicate, real_offset)
|
||||
assert struct.unpack_from("<I", duplicate, second_offset + 40)[0] == \
|
||||
_xiao_manifest_crc(duplicate, second_offset)
|
||||
assert ml.parse_xiao_bootloader_identity(bytes(duplicate)) is None
|
||||
try:
|
||||
ml.validate_xiao_bootloader_image(bytes(duplicate))
|
||||
assert False, "two CRC-valid embedded identities accepted"
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
|
||||
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,
|
||||
reserved=b"\0\0\0"):
|
||||
image = bytearray(b"\xff" * 64)
|
||||
struct.pack_into("<8sHHB3s", image, offset, ml.XIAO_BOOT_CAPS_MAGIC,
|
||||
abi, codecs, storage, reserved)
|
||||
return bytes(image)
|
||||
|
||||
assert ml.xiao_bootloader_caps_ok(caps())
|
||||
assert not ml.xiao_bootloader_caps_ok(caps(offset=1))
|
||||
assert not ml.xiao_bootloader_caps_ok(caps(abi=0xFFFF))
|
||||
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"))
|
||||
|
||||
|
||||
def test_bootloader_v3_build_parse_and_strict_contract():
|
||||
priv = Ed25519PrivateKey.generate()
|
||||
image = _xiao_bootloader_image()
|
||||
identity = ml.validate_xiao_bootloader_image(image, ml.XIAO_BOOT_BOARD_ID_BASE)
|
||||
assert identity.board_id == ml.XIAO_BOOT_BOARD_ID_BASE
|
||||
m = ml.build_manifest(
|
||||
target_id=identity.board_id, 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)
|
||||
blob = ml.build_container(m, image)
|
||||
parsed = ml.parse_container(blob)
|
||||
assert parsed.manifest.format_ver == ml.BOOT_FORMAT_VER
|
||||
assert parsed.manifest.flags == ml.FLAG_FULL | ml.FLAG_SIGNED | ml.FLAG_BOOTLOADER
|
||||
assert parsed.manifest.hw_id == ml.hw_id_bytes("XIAO_BL_28860044")
|
||||
assert ml.verify(parsed, expect_pub=priv.public_key().public_bytes_raw()) == []
|
||||
|
||||
invalid_payload = bytearray(parsed.payload)
|
||||
invalid_payload[4] &= 0xFE
|
||||
parsed.payload = bytes(invalid_payload)
|
||||
assert any(problem.startswith("bootloader image contract:")
|
||||
for problem in ml.verify(parsed))
|
||||
|
||||
bad = bytearray(blob)
|
||||
bad[8] = ml.APP_FORMAT_VER
|
||||
try:
|
||||
ml.parse_container(bytes(bad))
|
||||
assert False, "v2 bootloader flag accepted"
|
||||
except ValueError:
|
||||
pass
|
||||
bad = bytearray(blob)
|
||||
bad[9] &= ~ml.FLAG_BOOTLOADER
|
||||
try:
|
||||
ml.parse_container(bytes(bad))
|
||||
assert False, "v3 non-bootloader flags accepted"
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
|
||||
def test_bootloader_builder_rejects_wrong_identity_geometry_and_continuity():
|
||||
priv = Ed25519PrivateKey.generate()
|
||||
image = _xiao_bootloader_image()
|
||||
kwargs = dict(target_id=ml.XIAO_BOOT_BOARD_ID_BASE, 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)
|
||||
for change in (
|
||||
{"sign_priv": None},
|
||||
{"block_size": 512},
|
||||
{"fw_version": 0},
|
||||
{"target_id": ml.XIAO_BOOT_BOARD_ID_SENSE},
|
||||
{"hw_id": "wrong"},
|
||||
):
|
||||
try:
|
||||
ml.build_manifest(**(kwargs | change))
|
||||
assert False, f"invalid bootloader build accepted: {change}"
|
||||
except ValueError:
|
||||
pass
|
||||
damaged = bytearray(image); damaged[0x200] ^= 1
|
||||
try:
|
||||
ml.validate_xiao_bootloader_image(bytes(damaged))
|
||||
assert False, "bad embedded CRC accepted"
|
||||
except ValueError:
|
||||
pass
|
||||
import zlib
|
||||
wrong_name = bytearray(image)
|
||||
wrong_name[0x100 + 24:0x100 + 40] = b"FRIENDLY NAME".ljust(16, b"\0")
|
||||
wrong_name[0x100 + 40:0x100 + 44] = b"\0" * 4
|
||||
struct.pack_into("<I", wrong_name, 0x100 + 40, zlib.crc32(wrong_name) & 0xFFFFFFFF)
|
||||
try:
|
||||
ml.validate_xiao_bootloader_image(bytes(wrong_name))
|
||||
assert False, "noncanonical embedded device name accepted"
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
|
||||
# --- merkle ----------------------------------------------------------------
|
||||
|
||||
def test_merkle_single_block():
|
||||
|
||||
@@ -88,8 +88,11 @@ build_src_filter = ${ikoka_handheld_nrf_ssd1306_companion.build_src_filter}
|
||||
|
||||
[env:ikoka_handheld_nrf_e22_30dbm_repeater]
|
||||
extends = ikoka_handheld_nrf
|
||||
board_build.ldscript = boards/nrf52840_s140_v7_xiao_bootloader_ota.ld
|
||||
board_upload.maximum_size = 757760
|
||||
build_flags = ${ikoka_handheld_nrf.build_flags}
|
||||
${nrf52_qspi_ota.build_flags}
|
||||
-D OTA_QSPI_BOOTLOADER_UPDATE=1
|
||||
-D ADVERT_NAME='"ikoka_handheld Repeater"'
|
||||
-D ADVERT_LAT=0.0
|
||||
-D ADVERT_LON=0.0
|
||||
|
||||
@@ -118,9 +118,12 @@ lib_deps =
|
||||
|
||||
[ikoka_nano_nrf_repeater]
|
||||
extends = ikoka_nano_nrf
|
||||
board_build.ldscript = boards/nrf52840_s140_v7_xiao_bootloader_ota.ld
|
||||
board_upload.maximum_size = 757760
|
||||
build_flags =
|
||||
${ikoka_nano_nrf.build_flags}
|
||||
${nrf52_qspi_ota.build_flags}
|
||||
-D OTA_QSPI_BOOTLOADER_UPDATE=1
|
||||
-D ADVERT_NAME='"Ikoka Nano Repeater"'
|
||||
-D ADVERT_LAT=0.0
|
||||
-D ADVERT_LON=0.0
|
||||
|
||||
@@ -124,9 +124,12 @@ lib_deps =
|
||||
|
||||
[ikoka_stick_nrf_repeater]
|
||||
extends = ikoka_stick_nrf
|
||||
board_build.ldscript = boards/nrf52840_s140_v7_xiao_bootloader_ota.ld
|
||||
board_upload.maximum_size = 757760
|
||||
build_flags =
|
||||
${ikoka_stick_nrf.build_flags}
|
||||
${nrf52_qspi_ota.build_flags}
|
||||
-D OTA_QSPI_BOOTLOADER_UPDATE=1
|
||||
-D ADVERT_NAME='"Ikoka Stick Repeater"'
|
||||
-D ADVERT_LAT=0.0
|
||||
-D ADVERT_LON=0.0
|
||||
|
||||
@@ -90,9 +90,12 @@ lib_deps =
|
||||
|
||||
[env:Xiao_nrf52_repeater]
|
||||
extends = Xiao_nrf52
|
||||
board_build.ldscript = boards/nrf52840_s140_v7_xiao_bootloader_ota.ld
|
||||
board_upload.maximum_size = 757760
|
||||
build_flags =
|
||||
${Xiao_nrf52.build_flags}
|
||||
${nrf52_qspi_ota.build_flags}
|
||||
-D OTA_QSPI_BOOTLOADER_UPDATE=1
|
||||
-D ADVERT_NAME='"Xiao_nrf52 Repeater"'
|
||||
-D ADVERT_LAT=0.0
|
||||
-D ADVERT_LON=0.0
|
||||
@@ -198,9 +201,12 @@ build_flags =
|
||||
|
||||
[env:solarxiao_30S_repeater]
|
||||
extends = solarxiao
|
||||
board_build.ldscript = boards/nrf52840_s140_v7_xiao_bootloader_ota.ld
|
||||
board_upload.maximum_size = 757760
|
||||
build_flags =
|
||||
${solarxiao.build_flags}
|
||||
${nrf52_qspi_ota.build_flags}
|
||||
-D OTA_QSPI_BOOTLOADER_UPDATE=1
|
||||
-D ADVERT_NAME='"SolarXiao 30S Repeater"'
|
||||
-D ADVERT_LAT=0.0
|
||||
-D ADVERT_LON=0.0
|
||||
|
||||
Reference in new issue
Block a user