Unify Tower repeater builds on SD-primary OTA storage
Keep the deployed SD target and layout, select qualified internal delta storage when no card is usable, and retain exact SD bootloader-update gates. Add storage, capability, transfer, recipe, and release-selection regression coverage. Also preserve mandatory runtime packet logging in canonical XIAO images when verbose build debugging is disabled, fixing the four stopped release-matrix qualification failures.
This commit is contained in:
1 parent
7d3ee7200f
commit
30fe0d617b
25 files changed
+2082
-93
No files matched your search
@@ -197,6 +197,9 @@ jobs:
|
||||
python3 -B test/test_ota_store_resume.py -v
|
||||
python3 -B test/test_ota_catalog_lifetime.py -v
|
||||
python3 -B test/test_ota_store_flash_nrf52_hybrid.py -v
|
||||
python3 -B test/test_tower_bootloader_caps.py -v
|
||||
python3 -B test/test_tower_auto_storage.py -v
|
||||
python3 -B test/test_tower_auto_integration.py -v
|
||||
python3 -B test/test_ota_dev_staging.py -v
|
||||
python3 -B test/test_ota_dev_reboot_guard.py -v
|
||||
|
||||
@@ -374,6 +377,7 @@ jobs:
|
||||
python3 -B test/test_full_build_flag_dedup.py
|
||||
python3 -B test/test_full_logging_compile.py
|
||||
python3 -B test/test_full_logging_resume.py
|
||||
python3 -B test/test_xiao_canonical_logging.py -v
|
||||
python3 -B test/test_firmware_build_recipe.py -v
|
||||
python3 -B test/test_firmware_capabilities.py
|
||||
MESHCORE_TEST_REAL_PIO_PATHS=1 python3 -B test/test_esp32_partition_migration_build_dir.py -v
|
||||
|
||||
+68
-8
@@ -215,7 +215,9 @@ Environment Variables:
|
||||
offered directly as the editable default.
|
||||
A single custom version suffix found in existing OUTPUT_DIR
|
||||
artifacts is carried forward after the new numeric version.
|
||||
DISABLE_DEBUG=1: Disables all debug logging flags (MESH_DEBUG, MESH_PACKET_LOGGING, etc.)
|
||||
DISABLE_DEBUG=1: Disables build-time debug logging. Full Companion retains its
|
||||
runtime-gated diagnostics; canonical XIAO QSPI repeaters retain
|
||||
runtime-gated USB packet logging while MESH_DEBUG stays disabled.
|
||||
If not set, debug flags from variant platformio.ini files are used.
|
||||
RESUME_BUILD_OUTPUT=1: Preserves out/ and skips targets whose expected output
|
||||
artifacts already exist. Option 3 resumes by default.
|
||||
@@ -1977,6 +1979,11 @@ print_release_firmware_targets() {
|
||||
;;
|
||||
get-repeater-firmwares-to-build)
|
||||
get_pio_envs_ending_with_string "_repeater"
|
||||
# MeshTower keeps its deployed SD target identity as the one canonical
|
||||
# repeater recipe, including when SD staging falls back to internal staging.
|
||||
if is_supported_build_env "Heltec_tower_v2_sdcard_repeater_lora_ota_no_external_sensors"; then
|
||||
printf '%s\n' "Heltec_tower_v2_sdcard_repeater_lora_ota_no_external_sensors"
|
||||
fi
|
||||
# These full-sensor targets are distinct hardware/bootloader contracts,
|
||||
# not generated lean OTA aliases, so tagged repeater releases must ship
|
||||
# them explicitly alongside the canonical standard repeaters.
|
||||
@@ -2683,6 +2690,10 @@ disable_debug_flags() {
|
||||
# apparent order in PLATFORMIO_BUILD_FLAGS.
|
||||
if [ -n "$env_name" ] && is_companion_radio_full_target "$env_name"; then
|
||||
usb_logging_undefs=""
|
||||
elif [ -n "$env_name" ] && is_xiao_qspi_canonical_repeater_build "$env_name"; then
|
||||
# Its combined artifact promises runtime USB packet logging. Keep the
|
||||
# logger linked without retaining optional MESH_DEBUG output.
|
||||
usb_logging_undefs="-UMESH_DEBUG"
|
||||
fi
|
||||
|
||||
if [ "${DISABLE_DEBUG:-0}" == "1" ]; then
|
||||
@@ -2714,9 +2725,13 @@ apply_mqtt_bridge_override() {
|
||||
apply_debug_overrides() {
|
||||
local env_name=${1:-}
|
||||
local preserve_full_companion_logging=0
|
||||
local preserve_runtime_packet_logging=0
|
||||
|
||||
if [ -n "$env_name" ] && is_companion_radio_full_target "$env_name"; then
|
||||
preserve_full_companion_logging=1
|
||||
preserve_runtime_packet_logging=1
|
||||
elif [ -n "$env_name" ] && is_xiao_qspi_canonical_repeater_build "$env_name"; then
|
||||
preserve_runtime_packet_logging=1
|
||||
fi
|
||||
|
||||
case "${MESHDEBUG_OVERRIDE,,}" in
|
||||
@@ -2735,7 +2750,7 @@ apply_debug_overrides() {
|
||||
export PLATFORMIO_BUILD_FLAGS="${PLATFORMIO_BUILD_FLAGS} -DMESH_PACKET_LOGGING=1"
|
||||
;;
|
||||
off)
|
||||
if [ "$preserve_full_companion_logging" -eq 0 ]; then
|
||||
if [ "$preserve_runtime_packet_logging" -eq 0 ]; then
|
||||
export PLATFORMIO_BUILD_FLAGS="${PLATFORMIO_BUILD_FLAGS} -UMESH_PACKET_LOGGING"
|
||||
fi
|
||||
;;
|
||||
@@ -2788,13 +2803,22 @@ apply_merged_standard_usb_logging_profile() {
|
||||
|
||||
uses_merged_standard_usb_logging "$env_name" || return 0
|
||||
|
||||
# Canonical XIAO QSPI repeaters include packet logging in the immutable
|
||||
# combined artifact. The saved usb.logging switch controls its live stream.
|
||||
local preserve_runtime_packet_logging=0
|
||||
if is_xiao_qspi_canonical_repeater_build "$env_name"; then
|
||||
preserve_runtime_packet_logging=1
|
||||
export PLATFORMIO_BUILD_FLAGS="${PLATFORMIO_BUILD_FLAGS} -DMESH_PACKET_LOGGING=1 -DMESH_USB_LOGGING_MERGED=1"
|
||||
fi
|
||||
|
||||
# Explicit diagnostic overrides retain their documented meaning. Canonical
|
||||
# builds otherwise compile packet/debug output into the ordinary artifact;
|
||||
# get/set usb.logging controls the live Serial stream at runtime.
|
||||
if [ "${DISABLE_DEBUG:-0}" = "1" ]; then
|
||||
return 0
|
||||
fi
|
||||
if [ "${PACKET_LOGGING_OVERRIDE,,}" != "off" ]; then
|
||||
if [ "$preserve_runtime_packet_logging" -eq 0 ] \
|
||||
&& [ "${PACKET_LOGGING_OVERRIDE,,}" != "off" ]; then
|
||||
export PLATFORMIO_BUILD_FLAGS="${PLATFORMIO_BUILD_FLAGS} -DMESH_PACKET_LOGGING=1 -DMESH_USB_LOGGING_MERGED=1"
|
||||
fi
|
||||
if [ "${MESHDEBUG_OVERRIDE,,}" != "off" ] \
|
||||
@@ -3054,6 +3078,11 @@ is_nrf52_sensor_ota_pair_target() {
|
||||
get_nrf52_sensor_ota_pair_target() {
|
||||
local target=$1
|
||||
local candidate
|
||||
# The former internal-only MeshTower names are build aliases for the SD
|
||||
# primary. Their old OTA IDs are not compatible aliases on deployed nodes.
|
||||
if candidate=$(get_unified_meshtower_repeater_replacement "$target"); then
|
||||
target=$candidate
|
||||
fi
|
||||
is_nrf52_sensor_ota_pair_target "$target" || return 1
|
||||
# Preserve an explicit/deployed OTA identity and every external-storage
|
||||
# contract. Ordinary internal targets keep the historical auto-build ID of
|
||||
@@ -3074,6 +3103,14 @@ normalize_nrf52_sensor_ota_pair_targets() {
|
||||
local -a normalized=()
|
||||
local -A seen=()
|
||||
for target in "${RESOLVED_BUILD_TARGETS[@]}"; do
|
||||
if candidate=$(get_unified_meshtower_repeater_replacement "$target"); then
|
||||
if ! is_supported_build_env "$candidate"; then
|
||||
echo "MeshTower V2 primary build target is unavailable: ${candidate}" >&2
|
||||
return 1
|
||||
fi
|
||||
echo "MeshTower V2 build alias ${target} resolves to ${candidate}. Old internal-only bootloader/OTA identities require local USB/BLE DFU or SWD migration; this is not a same-target LoRa upgrade." >&2
|
||||
target=$candidate
|
||||
fi
|
||||
candidate=$(get_nrf52_sensor_ota_pair_target "$target") || candidate=$target
|
||||
if [ -z "${seen[$candidate]+x}" ]; then
|
||||
normalized+=("$candidate")
|
||||
@@ -3986,8 +4023,14 @@ apply_lora_ota_override() {
|
||||
append_platformio_build_unflags "-UENABLE_OTA -DDISABLE_LORA_OTA=1 -DOTA_FLASH_STORE=1 -DOTA_SD_STORE=1"
|
||||
export PLATFORMIO_BUILD_FLAGS="${PLATFORMIO_BUILD_FLAGS} -UDISABLE_LORA_OTA -DENABLE_OTA=1 -UOTA_FLASH_STORE -UOTA_SD_STORE -DOTA_QSPI_STORE=1 -DOTA_FOLDER_SERIAL"
|
||||
elif [ "${PIO_ENV_SD_OTA_BY_NAME[$env_name]:-0}" = "1" ]; then
|
||||
append_platformio_build_unflags "-UENABLE_OTA -DDISABLE_LORA_OTA=1 -DOTA_FLASH_STORE=1"
|
||||
export PLATFORMIO_BUILD_FLAGS="${PLATFORMIO_BUILD_FLAGS} -UDISABLE_LORA_OTA -DENABLE_OTA=1 -UOTA_FLASH_STORE -DOTA_SD_STORE=1 -DOTA_FOLDER_SERIAL"
|
||||
if [ "$env_name" = Heltec_tower_v2_sdcard_repeater_lora_ota_no_external_sensors ] \
|
||||
&& pio_env_option_contains "$env_name" build_flags "OTA_TOWER_AUTO_STORE=1"; then
|
||||
append_platformio_build_unflags "-UENABLE_OTA -DDISABLE_LORA_OTA=1"
|
||||
export PLATFORMIO_BUILD_FLAGS="${PLATFORMIO_BUILD_FLAGS} -UDISABLE_LORA_OTA -DENABLE_OTA=1 -DOTA_FLASH_STORE=1 -DOTA_SD_STORE=1 -DOTA_TOWER_AUTO_STORE=1 -DOTA_FOLDER_SERIAL"
|
||||
else
|
||||
append_platformio_build_unflags "-UENABLE_OTA -DDISABLE_LORA_OTA=1 -DOTA_FLASH_STORE=1"
|
||||
export PLATFORMIO_BUILD_FLAGS="${PLATFORMIO_BUILD_FLAGS} -UDISABLE_LORA_OTA -DENABLE_OTA=1 -UOTA_FLASH_STORE -DOTA_SD_STORE=1 -DOTA_FOLDER_SERIAL"
|
||||
fi
|
||||
else
|
||||
append_platformio_build_unflags "-UENABLE_OTA -DDISABLE_LORA_OTA=1"
|
||||
export PLATFORMIO_BUILD_FLAGS="${PLATFORMIO_BUILD_FLAGS} -UDISABLE_LORA_OTA -DENABLE_OTA=1 -DOTA_FLASH_STORE=1 -DOTA_FOLDER_SERIAL"
|
||||
@@ -5388,6 +5431,17 @@ get_combined_usb_ble_companion_replacement() {
|
||||
esac
|
||||
}
|
||||
|
||||
get_unified_meshtower_repeater_replacement() {
|
||||
case "${1,,}" in
|
||||
heltec_tower_v2_repeater|heltec_tower_v2_repeater_lora_ota_no_external_sensors)
|
||||
printf '%s\n' Heltec_tower_v2_sdcard_repeater_lora_ota_no_external_sensors
|
||||
;;
|
||||
*)
|
||||
return 1
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
get_merged_rs232_repeater_replacement() {
|
||||
case "${1,,}" in
|
||||
heltec_t096_repeater_bridge_rs232)
|
||||
@@ -5447,7 +5501,8 @@ get_merged_rs232_repeater_replacement() {
|
||||
}
|
||||
|
||||
is_firmware_role_replaced_by_canonical_artifact() {
|
||||
get_full_companion_replacement "$1" >/dev/null 2>&1 \
|
||||
get_unified_meshtower_repeater_replacement "$1" >/dev/null \
|
||||
|| get_full_companion_replacement "$1" >/dev/null 2>&1 \
|
||||
|| get_terminal_chat_companion_replacement "$1" >/dev/null \
|
||||
|| get_combined_usb_ble_companion_replacement "$1" >/dev/null \
|
||||
|| get_merged_rs232_repeater_replacement "$1" >/dev/null
|
||||
@@ -5534,7 +5589,12 @@ resolve_full_companion_firmwares() {
|
||||
}
|
||||
|
||||
resolve_repeater_firmwares() {
|
||||
get_pio_envs_for_variant_role repeater
|
||||
local env_name
|
||||
while IFS= read -r env_name; do
|
||||
if ! get_unified_meshtower_repeater_replacement "$env_name" >/dev/null; then
|
||||
printf '%s\n' "$env_name"
|
||||
fi
|
||||
done < <(get_pio_envs_for_variant_role repeater)
|
||||
}
|
||||
|
||||
resolve_room_server_firmwares() {
|
||||
@@ -5738,7 +5798,7 @@ resolve_command_targets() {
|
||||
|
||||
# Base and reduced aliases resolve to one exact-identity pair; external
|
||||
# storage and deployed compatibility identities deliberately stay separate.
|
||||
normalize_nrf52_sensor_ota_pair_targets
|
||||
normalize_nrf52_sensor_ota_pair_targets || return $?
|
||||
|
||||
# Keep one queue so parallel workers stay saturated. The scheduler may pull
|
||||
# a later target forward when a generated alias shares an active PlatformIO
|
||||
|
||||
@@ -185,12 +185,34 @@ available from a canonical image:
|
||||
releases. Wio-E5 remains separate because its normal image has only 916
|
||||
bytes free, while the combined image exceeds the fixed 240 KiB application
|
||||
partition by 2,192 bytes.
|
||||
- MeshTower V2 repeaters use
|
||||
`Heltec_tower_v2_sdcard_repeater_lora_ota_no_external_sensors` as the one
|
||||
canonical target. Its SD OTA target `0A9DBBF0`, hardware ID
|
||||
`Heltec_tower_v2`, layout, and filenames stay unchanged, with separate
|
||||
required Full and Reduced sensor profiles. Both
|
||||
`Heltec_tower_v2_repeater` and
|
||||
`Heltec_tower_v2_repeater_lora_ota_no_external_sensors` resolve to this primary
|
||||
in `build-firmware` and matching builds. Old internal-only bootloader/OTA
|
||||
identities require one local USB/BLE DFU or SWD migration; the build aliases
|
||||
do not make the new image a same-target LoRa upgrade for those installed nodes.
|
||||
Existing SD users need no configuration change. The application selects SD
|
||||
first and falls back automatically for a missing or unmountable card only
|
||||
when the matching published combined OTAFIX profile declares exact
|
||||
privileged storage `09` and optional application storage `02` in its separate
|
||||
`MOTASTOR` record. Internal fallback accepts flash-staged signed application
|
||||
in-place deltas from trusted signers that fit its smaller workspace;
|
||||
SD-generated large deltas may be rejected. Full images, bootloader updates,
|
||||
and archive capture remain SD-only. Storage stays pinned through a transfer:
|
||||
use `ota cancel` before
|
||||
selecting a different backend. The application does not use an `APPHYBRID`
|
||||
retained-RAM staging arena. See [MeshTower SD OTA](ota_meshtower_v2_sdcard.md).
|
||||
|
||||
The old aliases still work with `build-firmware` and
|
||||
`build-matching-firmwares`. Dedicated LoRa OTA repeater images are not
|
||||
collapsed; they retain their exact storage, bootloader, role, and target
|
||||
identity contracts. Companion boards keep transport-specific canonical images
|
||||
only when no exact Full recipe has passed the combined flash/RAM qualification.
|
||||
collapsed outside the MeshTower consolidation above; they retain their exact
|
||||
storage, bootloader, role, and target identity contracts. Companion boards
|
||||
keep transport-specific canonical images only when no exact Full recipe has
|
||||
passed the combined flash/RAM qualification.
|
||||
ESP32 deliberately uses one TTY: Binary Companion and plaintext USB logging
|
||||
are mutually exclusive there. nRF52 retains its optional second CDC port.
|
||||
|
||||
|
||||
@@ -1,20 +1,42 @@
|
||||
# MeshTower V2 microSD LoRa OTA
|
||||
|
||||
For **1.17.1.5**, use the exact board/storage profile from
|
||||
[OTAFIX 2.4.6](https://github.com/mikecarper/Adafruit_nRF52_Bootloader_OTAFIX/releases/tag/0.11.0-OTAFIX2.4.6)
|
||||
for nRF52 OTAFIX installations. New internal-flash hybrid receivers require
|
||||
its 64 KiB retained-RAM handoff; QSPI and microSD targets require their own
|
||||
matching bootloader profiles. Earlier preview versions mentioned below
|
||||
describe compatibility/migration history, not the current recommended download.
|
||||
Full Companion is a MOTA source and normally updates itself over USB.
|
||||
`Heltec_tower_v2_sdcard_repeater_lora_ota_no_external_sensors` is the one
|
||||
canonical MeshTower V2 repeater target. Its SD OTA target ID `0A9DBBF0`,
|
||||
hardware ID `Heltec_tower_v2`, application layout, and artifact names remain
|
||||
unchanged. The required Full and Reduced sensor choices use that same target.
|
||||
Existing SD users need no configuration change.
|
||||
|
||||
The build aliases `Heltec_tower_v2_repeater` and
|
||||
`Heltec_tower_v2_repeater_lora_ota_no_external_sensors` resolve to this primary
|
||||
target. An installed internal-only image has a different OTA identity and
|
||||
bootloader/layout contract: migrate it once with the matching combined OTAFIX
|
||||
profile and application over local USB/BLE DFU or SWD. An alias does not make
|
||||
that migration a same-target LoRa update.
|
||||
|
||||
The `Heltec_tower_v2_sdcard_repeater_lora_ota_no_external_sensors` target uses the MeshTower V2 onboard
|
||||
microSD socket as persistent storage for its own LoRa OTA downloads. It accepts
|
||||
full application images, in-place application deltas, and explicitly selected
|
||||
signed v3 bootloader packages. After verification, the matching SD-aware OTAFIX
|
||||
bootloader reads the staged file from the card and programs the selected
|
||||
nRF52840 application or bootloader region.
|
||||
## Automatic storage selection
|
||||
|
||||
The onboard microSD socket remains primary. A mountable card accepts full
|
||||
application images, in-place application deltas, and explicitly selected
|
||||
signed v3 bootloader packages. After verification, the SD-aware OTAFIX
|
||||
bootloader reads the staged file and programs the application or bootloader.
|
||||
|
||||
With a missing or unmountable card, the same application can select internal
|
||||
flash for signed application in-place deltas only. The signer must be trusted
|
||||
by the node, as on the SD application path. This fallback requires the
|
||||
published combined MeshTower SD/internal profile in
|
||||
[OTAFIX 2.4.11](https://github.com/mikecarper/Adafruit_nRF52_Bootloader_OTAFIX/releases/tag/v0.11.0-OTAFIX2.4.11):
|
||||
the privileged storage profile must remain exactly `09` (`SD|BOOT_UPDATE`),
|
||||
with a separate positive `MOTASTOR` optional-application storage record of
|
||||
`02` (`STAGE_CEILING`). A missing record or an internal-only bootloader does
|
||||
not authorize fallback. Full images, bootloader packages, SD archiving, and
|
||||
automatic own-image offering remain unavailable on internal storage.
|
||||
|
||||
Selection is deferred until OTA use so card probing does not delay USB setup.
|
||||
The selected backend stays pinned through the transfer, including a paused or
|
||||
failed transfer. Removing a card produces a storage failure; it does not move
|
||||
the partial download into internal flash. Use `ota cancel` before selecting
|
||||
storage again after changing the card. The fallback uses internal flash only;
|
||||
it does not reserve or use the 64 KiB `APPHYBRID` retained-RAM arena.
|
||||
|
||||
The pin assignment follows the
|
||||
[Heltec MeshTower V2 partial reference circuit](https://resource.heltec.cn/download/MeshTower-V2/schematic/MeshTower_V2_Partial_Reference_Circuit.pdf):
|
||||
@@ -44,9 +66,9 @@ Preview.12 must first be upgraded over USB/BLE DFU or SWD.
|
||||
|
||||
## Capacity and update types
|
||||
|
||||
The card removes the internal-flash staging limit. The `.mota` container may be
|
||||
much larger than the old internal staging gap, and either a full image or an
|
||||
in-place delta may be downloaded. The installed firmware itself must still fit
|
||||
When SD is selected, the card removes the internal-flash staging limit. The
|
||||
`.mota` container may be much larger than the old internal staging gap, and
|
||||
either a full image or an in-place delta may be downloaded. The installed firmware itself must still fit
|
||||
the nRF52840 application region below InternalFS (ending at `0xED000`); SD
|
||||
storage does not increase the MCU's executable flash.
|
||||
|
||||
@@ -74,7 +96,14 @@ motatool build \
|
||||
motatool verify ./motas/*.mota
|
||||
```
|
||||
|
||||
The download is resumable because the partial `.mota` stays on the card.
|
||||
The internal fallback has a smaller flash staging gap and in-place workspace.
|
||||
A delta generated for the larger SD workspace can be rejected even though it
|
||||
updates the same application and target. Keep a usable card inserted for that
|
||||
package, or generate an exact-base delta that fits the fallback's available
|
||||
workspace and staging capacity. The firmware refuses an oversized package;
|
||||
it does not switch backends during its download.
|
||||
|
||||
An SD download is resumable because the partial `.mota` stays on the card.
|
||||
Once it reaches `ready`, `ota install` performs the final verification,
|
||||
publishes a one-reset authorization record, and reboots. SD application
|
||||
installation requires a valid signature from a key in the node's allowlist.
|
||||
@@ -83,14 +112,16 @@ Keep the card inserted through the reboot and installation.
|
||||
The BLM2 retained-auth SD-aware bootloader is mandatory for application OTA.
|
||||
`ota install` refuses to reboot if its continuity metadata does not match the
|
||||
running S140 family/FWID/application layout or if its capability marker does
|
||||
not advertise SD staging and the selected codec. Existing
|
||||
`Heltec_tower_v2_repeater` firmware continues to use the internal-flash delta
|
||||
path and is unchanged.
|
||||
not advertise SD staging and the selected codec. Earlier internal-only
|
||||
MeshTower firmware retains its old identity until the deliberate local
|
||||
migration described above.
|
||||
|
||||
## Signed bootloader update
|
||||
|
||||
Only this exact SD build exposes the privileged LoRa bootloader-update command.
|
||||
It requires an already installed exact-board ABI-3 OTAFIX bootloader whose one
|
||||
Only the canonical SD target with SD selected exposes a usable privileged
|
||||
LoRa bootloader-update path. Internal application fallback does not stage
|
||||
bootloader packages. It requires an already installed exact-board ABI-3
|
||||
OTAFIX bootloader whose one
|
||||
unambiguous capability marker is exactly `0x09` (`SD|BOOT_UPDATE`) and whose
|
||||
codec mask is `0x0005` (`FULL|INPLACE`). Requiring both application codecs
|
||||
prevents a bootloader self-update from disabling either normal SD application
|
||||
@@ -196,7 +227,9 @@ get sdcard dir 3
|
||||
`format` creates a new FAT16, FAT32, or exFAT filesystem according to card
|
||||
size. `erase` first uses the card's raw media erase command and then formats it,
|
||||
so a successful erase finishes with a usable filesystem. Both operations
|
||||
destroy all data on the card and cancel any staged OTA download.
|
||||
destroy all data on the card and cancel a staged SD OTA download. If the
|
||||
internal fallback slot is occupied, card maintenance is refused until an
|
||||
explicit `ota cancel`; it does not abandon an internal download to format SD.
|
||||
|
||||
The firmware records successful format and erase completion times in RAM.
|
||||
Repeating the same operation within five minutes is rejected unless `--force`
|
||||
@@ -213,7 +246,7 @@ file size. The header reports the selected page, total pages, and total files.
|
||||
|
||||
## Persistent OTA archive and seeder
|
||||
|
||||
On the SD-backed target, automatic OTA archiving is on by default. While the
|
||||
With SD selected, automatic OTA archiving is on by default. While the
|
||||
temporary OTA radio is active, the node requests full catalogs from seeders and
|
||||
saves every complete mOTA it discovers, including firmware for other hardware
|
||||
targets and codecs that this node cannot install. Archive downloads use the
|
||||
|
||||
@@ -109,12 +109,25 @@ def select_ordinary_full_records(records: list[dict]) -> list[dict]:
|
||||
their separately built migration packages or direct --full-exact builds.
|
||||
This also removes stale sibling artifacts when a matrix build is resumed.
|
||||
nRF52 Full/reduced sensor OTA records always pass through independently,
|
||||
even when their logical target ID and storage layout are the same.
|
||||
even when their logical target ID and storage layout are the same. The
|
||||
MeshTower SD primary replaces stale internal-only artifacts on resume;
|
||||
those files must never be relabeled with the primary's different OTA ID.
|
||||
"""
|
||||
tower_primary = "heltec_tower_v2_sdcard_repeater_lora_ota_no_external_sensors"
|
||||
tower_legacy = {
|
||||
"heltec_tower_v2_repeater",
|
||||
"heltec_tower_v2_repeater_lora_ota_no_external_sensors",
|
||||
}
|
||||
targets = {record["manifest"]["target"].lower() for record in records}
|
||||
if targets & tower_legacy and tower_primary not in targets:
|
||||
raise ValueError("MeshTower V2 release requires the SD primary; old internal-only "
|
||||
"artifacts cannot substitute for its distinct OTA identity")
|
||||
chosen: dict[str, tuple[int, dict]] = {}
|
||||
passthrough: list[dict] = []
|
||||
for record in records:
|
||||
manifest = record["manifest"]
|
||||
if manifest["target"].lower() in tower_legacy:
|
||||
continue
|
||||
if manifest["platform"] != "ESP32_PLATFORM" or manifest["build_profile"] != "full":
|
||||
passthrough.append(record)
|
||||
continue
|
||||
|
||||
@@ -497,6 +497,11 @@ void __attribute__((noinline)) Mesh::serviceLoopMaintenance() {
|
||||
_next_ota_tick = futureMillis(ota_loop_interval);
|
||||
}
|
||||
if (millisHasNowPassed(_next_ota_tick)) {
|
||||
#if defined(NRF52_PLATFORM) && defined(OTA_TOWER_AUTO_STORE)
|
||||
// Establish storage policy before resume and codec selection. Startup
|
||||
// deliberately performs no SD mount so the USB console is ready first.
|
||||
ota::ota_ctx().prepareTowerStorage();
|
||||
#endif
|
||||
// one-shot on first tick: resume an interrupted fetch left staged in flash before a reboot. Only adopt
|
||||
// a PARTIAL container (continue fetching the holes); a COMPLETE one is left for manual/auto-install,
|
||||
// not re-adopted at boot. requestMissing() (inside resumeStaged) drives the rest via REQ/DATA.
|
||||
@@ -518,6 +523,9 @@ void __attribute__((noinline)) Mesh::serviceLoopMaintenance() {
|
||||
}
|
||||
if (_ota_announce_timer.ready(_ms->getMillis())) { // auto-advertise so peers discover us (tiny beacon)
|
||||
ota::OtaContext& oc = ota::ota_ctx();
|
||||
#if defined(NRF52_PLATFORM) && defined(OTA_TOWER_AUTO_STORE)
|
||||
oc.prepareTowerStorage();
|
||||
#endif
|
||||
bool in_burst = _ota_announce_count < OTA_ANNOUNCE_BURST;
|
||||
uint32_t mins = oc.manager.advert_mins(); // periodic cadence in minutes; 0 = disabled (boot burst only)
|
||||
if (in_burst || mins != 0) {
|
||||
|
||||
@@ -3294,6 +3294,17 @@ bool CommonCLI::handleSdCardSetCmd(const char* config, char* reply) {
|
||||
strcpy(reply, "Error: OTA apply is pending");
|
||||
return true;
|
||||
}
|
||||
#if defined(OTA_TOWER_AUTO_STORE)
|
||||
// Card maintenance must never abandon a fallback update stored internally.
|
||||
if (context.fetch_store.usesInternal() &&
|
||||
(context.manager.fetchState() != mesh::ota::OtaManager::IDLE ||
|
||||
context.fetch_store.staged_size() != 0)) {
|
||||
strcpy(reply, "Error: internal OTA slot is occupied; use ota cancel first");
|
||||
return true;
|
||||
}
|
||||
#endif
|
||||
|
||||
mesh::ota::OtaStoreSdNrf52& card = context.sdStagingStore();
|
||||
|
||||
// A card-wide destructive operation invalidates any staged fetch. First let
|
||||
// an archive capture checkpoint and close its shared card handles.
|
||||
@@ -3305,9 +3316,9 @@ bool CommonCLI::handleSdCardSetCmd(const char* config, char* reply) {
|
||||
context.prev_fstate = mesh::ota::OtaManager::IDLE;
|
||||
|
||||
if (is_format) {
|
||||
if (!context.fetch_store.formatCard(*_board)) {
|
||||
if (!card.formatCard(*_board)) {
|
||||
char error[80];
|
||||
strncpy(error, context.fetch_store.last_error(), sizeof(error) - 1);
|
||||
strncpy(error, card.last_error(), sizeof(error) - 1);
|
||||
error[sizeof(error) - 1] = 0;
|
||||
context.finishSdCardReset(millis());
|
||||
snprintf(reply, 160, "Error: SD card format failed: %s",
|
||||
@@ -3321,9 +3332,9 @@ bool CommonCLI::handleSdCardSetCmd(const char* config, char* reply) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!context.fetch_store.eraseCard(*_board)) {
|
||||
if (!card.eraseCard(*_board)) {
|
||||
char error[80];
|
||||
strncpy(error, context.fetch_store.last_error(), sizeof(error) - 1);
|
||||
strncpy(error, card.last_error(), sizeof(error) - 1);
|
||||
error[sizeof(error) - 1] = 0;
|
||||
context.finishSdCardReset(millis());
|
||||
snprintf(reply, 160, "Error: SD card erase failed: %s",
|
||||
@@ -3333,9 +3344,9 @@ bool CommonCLI::handleSdCardSetCmd(const char* config, char* reply) {
|
||||
_sdcard_erase_recorded = true;
|
||||
_sdcard_erase_at = millis();
|
||||
|
||||
if (!context.fetch_store.formatCard(*_board)) {
|
||||
if (!card.formatCard(*_board)) {
|
||||
char error[80];
|
||||
strncpy(error, context.fetch_store.last_error(), sizeof(error) - 1);
|
||||
strncpy(error, card.last_error(), sizeof(error) - 1);
|
||||
error[sizeof(error) - 1] = 0;
|
||||
context.finishSdCardReset(millis());
|
||||
snprintf(reply, 160, "Error: SD card erased but format failed: %s",
|
||||
@@ -3378,7 +3389,7 @@ bool CommonCLI::handleSdCardGetCmd(const char* config, char* reply) {
|
||||
} else if (strcmp(query, "free") == 0) {
|
||||
uint64_t used_bytes = 0;
|
||||
uint64_t free_bytes = 0;
|
||||
mesh::ota::OtaStoreSdNrf52& store = mesh::ota::ota_ctx().fetch_store;
|
||||
mesh::ota::OtaStoreSdNrf52& store = mesh::ota::ota_ctx().sdStagingStore();
|
||||
if (!store.getSpace(*_board, used_bytes, free_bytes)) {
|
||||
snprintf(reply, 160, "Error: SD card space query failed: %s",
|
||||
store.last_error());
|
||||
@@ -3407,7 +3418,7 @@ bool CommonCLI::handleSdCardGetCmd(const char* config, char* reply) {
|
||||
}
|
||||
page = parsed;
|
||||
}
|
||||
mesh::ota::OtaStoreSdNrf52& store = mesh::ota::ota_ctx().fetch_store;
|
||||
mesh::ota::OtaStoreSdNrf52& store = mesh::ota::ota_ctx().sdStagingStore();
|
||||
if (!store.listFiles(*_board, (uint16_t)page, reply, 160)) {
|
||||
snprintf(reply, 160, "Error: SD card list failed: %s", store.last_error());
|
||||
}
|
||||
|
||||
@@ -106,7 +106,7 @@ void formatStorageLayout(MainBoard& board, char* reply, size_t reply_size) {
|
||||
#elif defined(ENABLE_OTA) && defined(OTA_SD_STORE)
|
||||
uint64_t used_bytes = 0;
|
||||
uint64_t free_bytes = 0;
|
||||
mesh::ota::OtaStoreSdNrf52& store = mesh::ota::ota_ctx().fetch_store;
|
||||
mesh::ota::OtaStoreSdNrf52& store = mesh::ota::ota_ctx().sdStagingStore();
|
||||
if (store.getSpace(board, used_bytes, free_bytes)) {
|
||||
char total[24];
|
||||
char used[24];
|
||||
|
||||
@@ -497,7 +497,7 @@ static bool ota_nrf52_clear_reset_reasons() {
|
||||
|
||||
void ota_reboot_to_apply() { // public: set the apply magic + reset (does not return)
|
||||
uint8_t stage_handoff = GPREGRET2_OTA_STAGE_LEGACY;
|
||||
#if defined(OTA_RAK_AUTO_STORE)
|
||||
#if defined(OTA_RAK_AUTO_STORE) || defined(OTA_TOWER_AUTO_STORE)
|
||||
if (g_nrf52_apply_store) {
|
||||
if (g_nrf52_apply_store->is_hybrid()) {
|
||||
if (!ota_nrf52_clear_reset_reasons() ||
|
||||
@@ -511,7 +511,11 @@ void ota_reboot_to_apply() { // public: set the apply magic +
|
||||
stage_handoff = mota_nrf52_flash_stage_handoff(ota_nrf52_effective_stage_ceiling());
|
||||
}
|
||||
} else {
|
||||
#if defined(OTA_TOWER_AUTO_STORE)
|
||||
stage_handoff = GPREGRET2_OTA_STAGE_SD;
|
||||
#else
|
||||
stage_handoff = g_nrf52_qspi_handoff;
|
||||
#endif
|
||||
}
|
||||
#elif defined(OTA_SD_STORE)
|
||||
stage_handoff = GPREGRET2_OTA_STAGE_SD;
|
||||
@@ -1114,10 +1118,21 @@ static bool ota_prepare_bootloader_update_external(Store& store,
|
||||
OtaBootloaderCapsMarker candidate_caps;
|
||||
if (!ota_bootloader_external_image_metadata(
|
||||
store, payload_off, ota_bootloader_update_storage_flags(),
|
||||
candidate, candidate_caps) ||
|
||||
candidate, candidate_caps,
|
||||
#if defined(OTA_TOWER_AUTO_STORE)
|
||||
current_caps.optional_app_storage
|
||||
#else
|
||||
0
|
||||
#endif
|
||||
) ||
|
||||
!ota_bootloader_identity_matches(installed, candidate)) {
|
||||
strcpy(msg, "candidate bootloader identity/capability/CRC mismatch"); return false;
|
||||
}
|
||||
#if defined(OTA_TOWER_AUTO_STORE)
|
||||
if (current_caps.optional_app_storage && ota_boot_rd32(vectors) > MOTA_NRF52_HYBRID_RAM_START) {
|
||||
strcpy(msg, "candidate must retain the combined bootloader RAM arena"); return false;
|
||||
}
|
||||
#endif
|
||||
const OtaBootloaderContinuityGate continuity = ota_bootloader_continuity_gate(
|
||||
installed, candidate, m.fw_version, OTA_BOOT_CONTINUITY_FAMILY_S140,
|
||||
ota_runtime_softdevice_fwid(), mota_nrf52_app_base(),
|
||||
@@ -1140,6 +1155,9 @@ static bool ota_prepare_bootloader_update_external(Store& store,
|
||||
#if defined(OTA_SD_STORE)
|
||||
bool ota_apply_mota_nrf52(OtaStoreSdNrf52& store, const SignerAllowlist& allow,
|
||||
ApplyState& st, char* msg) {
|
||||
#if defined(OTA_TOWER_AUTO_STORE)
|
||||
g_nrf52_apply_store = nullptr;
|
||||
#endif
|
||||
#if defined(OTA_SD_BOOTLOADER_UPDATE)
|
||||
OtaBootloaderIdentity installed;
|
||||
if (!ota_installed_bootloader_identity(installed) ||
|
||||
@@ -1163,6 +1181,9 @@ bool ota_prepare_bootloader_update_nrf52(OtaStoreSdNrf52& store,
|
||||
const uint8_t operator_mid[4],
|
||||
const uint8_t operator_hash8[8],
|
||||
ApplyState& st, char* msg) {
|
||||
#if defined(OTA_TOWER_AUTO_STORE)
|
||||
g_nrf52_apply_store = nullptr;
|
||||
#endif
|
||||
if (!ota_bootloader_sd_retained_auth_ready(
|
||||
installed, OTA_BOOT_CONTINUITY_FAMILY_S140,
|
||||
ota_runtime_softdevice_fwid(), mota_nrf52_app_base(),
|
||||
|
||||
+41
-12
@@ -28,7 +28,7 @@ struct OtaBlCaps {
|
||||
uint16_t apply_abi = 0; // max .mota format_ver the bootloader can apply
|
||||
uint16_t codec_mask = 0; // bit i set => can apply codec_id i (in-place delta = bit 2)
|
||||
uint8_t storage_flags = 0; // OTA_BL_STORAGE_* capability bits
|
||||
uint8_t optional_app_storage = 0; // separate MOTASTOR record, never a boot-update backend
|
||||
uint8_t optional_app_storage = 0; // separate MOTASTOR record, scoped to its qualified primary profile
|
||||
};
|
||||
|
||||
// Independent marker for the reset-retained hybrid source. Keep this separate
|
||||
@@ -217,10 +217,10 @@ inline OtaBlCaps ota_bl_legacy_app_caps_scan_halfword(const uint8_t* bytes,
|
||||
return c;
|
||||
}
|
||||
|
||||
// Unified RAK loaders preserve the deployed board's privileged MOTABLDR=0x0A
|
||||
// contract. A separate record advertises optional APPLICATION storage. It is
|
||||
// adjacent to the unchanged, unique 64 KiB RAM marker and grants no new
|
||||
// bootloader staging backend.
|
||||
// Unified loaders preserve the deployed primary MOTABLDR contract: 0x0A for
|
||||
// RAK internal storage and 0x09 for Tower SD. A separate record advertises
|
||||
// optional application storage, adjacent to the unchanged, unique RAM marker.
|
||||
// Never infer optional storage from the primary profile alone.
|
||||
inline uint8_t ota_bl_optional_app_storage(const uint8_t* bytes, size_t len) {
|
||||
static const uint8_t record[32] = {
|
||||
'M', 'O', 'T', 'A', 'R', 'A', 'M', 'A', 1, 0, 72, 0, 0, 0, 1, 0,
|
||||
@@ -228,17 +228,39 @@ inline uint8_t ota_bl_optional_app_storage(const uint8_t* bytes, size_t len) {
|
||||
};
|
||||
if (!bytes || !ota_bootloader_supports_hybrid(ota_ram_caps_scan_aligned(bytes, len))) return 0;
|
||||
unsigned matches = 0;
|
||||
for (size_t off = 0; off + sizeof(record) <= len; off += 4) {
|
||||
if (memcmp(bytes + off, record, sizeof(record)) == 0 && ++matches > 1) return 0;
|
||||
uint8_t result = 0;
|
||||
for (size_t off = 0; off + 8u <= len; off += 4u) {
|
||||
if (memcmp(bytes + off, record + 16u, 8u) != 0) continue;
|
||||
// One aligned STOR record must immediately follow the one valid RAM
|
||||
// record. A stray, malformed, truncated or duplicate declaration fails
|
||||
// closed instead of allowing another valid record to hide it.
|
||||
if (++matches != 1u || off < 16u || off + 16u > len ||
|
||||
memcmp(bytes + off - 16u, record, 28u) != 0 ||
|
||||
bytes[off + 13u] || bytes[off + 14u] || bytes[off + 15u]) return 0;
|
||||
const uint8_t flags = bytes[off + 12u];
|
||||
if (flags != (OTA_BL_STORAGE_QSPI | OTA_BL_STORAGE_HEADER_W25) &&
|
||||
flags != OTA_BL_STORAGE_STAGE_CEILING) return 0;
|
||||
result = flags;
|
||||
}
|
||||
return matches == 1 ? OTA_BL_STORAGE_QSPI | OTA_BL_STORAGE_HEADER_W25 : 0;
|
||||
return matches == 1u ? result : 0;
|
||||
}
|
||||
|
||||
inline uint8_t ota_bl_profile_optional_app_storage(const uint8_t* bytes,
|
||||
size_t len,
|
||||
uint8_t primary_storage) {
|
||||
const uint8_t optional = ota_bl_optional_app_storage(bytes, len);
|
||||
if (primary_storage == OTA_BL_PROFILE_INTERNAL_BOOT_UPDATE &&
|
||||
optional == (OTA_BL_STORAGE_QSPI | OTA_BL_STORAGE_HEADER_W25)) return optional;
|
||||
if (primary_storage == OTA_BL_PROFILE_SD_BOOT_UPDATE &&
|
||||
optional == OTA_BL_STORAGE_STAGE_CEILING) return optional;
|
||||
return 0;
|
||||
}
|
||||
|
||||
inline OtaBlCaps ota_bl_app_caps_scan(const uint8_t* bytes, size_t len) {
|
||||
OtaBlCaps aligned = ota_bl_caps_scan_aligned(bytes, len, false);
|
||||
if (aligned.present && aligned.apply_abi >= 3 &&
|
||||
aligned.storage_flags == OTA_BL_PROFILE_INTERNAL_BOOT_UPDATE) {
|
||||
aligned.optional_app_storage = ota_bl_optional_app_storage(bytes, len);
|
||||
if (aligned.present && aligned.apply_abi >= 3) {
|
||||
aligned.optional_app_storage = ota_bl_profile_optional_app_storage(
|
||||
bytes, len, aligned.storage_flags);
|
||||
aligned.storage_flags |= aligned.optional_app_storage;
|
||||
}
|
||||
if (aligned.present) return aligned;
|
||||
@@ -248,7 +270,14 @@ inline OtaBlCaps ota_bl_app_caps_scan(const uint8_t* bytes, size_t len) {
|
||||
inline OtaBlCaps ota_bl_update_caps_scan_aligned(const uint8_t* bytes, size_t len,
|
||||
uint8_t exact_storage_flags) {
|
||||
if (exact_storage_flags == 0) return OtaBlCaps();
|
||||
return ota_bl_caps_scan_aligned(bytes, len, true, exact_storage_flags);
|
||||
OtaBlCaps caps = ota_bl_caps_scan_aligned(bytes, len, true, exact_storage_flags);
|
||||
if (caps.present) {
|
||||
// Preserve the exact privileged storage byte; callers may independently
|
||||
// require an existing optional application capability in a successor.
|
||||
caps.optional_app_storage = ota_bl_profile_optional_app_storage(
|
||||
bytes, len, caps.storage_flags);
|
||||
}
|
||||
return caps;
|
||||
}
|
||||
|
||||
// Scan the bootloader flash region for ordinary application-update capabilities.
|
||||
|
||||
@@ -10,6 +10,19 @@
|
||||
#include "OtaBlInfo.h"
|
||||
#include "OtaByteIO.h"
|
||||
|
||||
#if defined(OTA_TOWER_AUTO_STORE)
|
||||
#if !defined(NRF52_PLATFORM) || !defined(HELTEC_TOWER_V2_SDCARD) || \
|
||||
!defined(OTA_SD_STORE) || !defined(OTA_FLASH_STORE) || \
|
||||
!defined(OTA_SD_BOOTLOADER_UPDATE)
|
||||
#error "OTA_TOWER_AUTO_STORE requires the qualified nRF52 MeshTower SD-primary OTA profile"
|
||||
#endif
|
||||
#if defined(OTA_QSPI_STORE) || defined(QSPIFLASH) || \
|
||||
defined(OTA_RAK_AUTO_STORE) || defined(OTA_INTERNAL_BOOTLOADER_UPDATE) || \
|
||||
defined(OTA_QSPI_BOOTLOADER_UPDATE) || defined(OTA_HYBRID_RAM_STORE)
|
||||
#error "MeshTower automatic storage retains the full-RAM SD profile and SD-only bootloader staging"
|
||||
#endif
|
||||
#endif
|
||||
|
||||
#if defined(OTA_QSPI_BOOTLOADER_UPDATE)
|
||||
#if !defined(NRF52_PLATFORM) || !defined(OTA_QSPI_STORE)
|
||||
#error "OTA_QSPI_BOOTLOADER_UPDATE requires nRF52 raw-QSPI staging"
|
||||
@@ -39,7 +52,8 @@
|
||||
#if !defined(HELTEC_TOWER_V2_SDCARD)
|
||||
#error "OTA_SD_BOOTLOADER_UPDATE is restricted to the qualified MeshTower V2 SD target"
|
||||
#endif
|
||||
#if defined(OTA_FLASH_STORE) || defined(OTA_QSPI_STORE) || defined(QSPIFLASH)
|
||||
#if (defined(OTA_FLASH_STORE) && !defined(OTA_TOWER_AUTO_STORE)) || \
|
||||
defined(OTA_QSPI_STORE) || defined(QSPIFLASH)
|
||||
#error "SD bootloader staging cannot share another OTA/filesystem store"
|
||||
#endif
|
||||
#endif
|
||||
@@ -556,15 +570,25 @@ inline bool ota_bootloader_external_crc_ok(
|
||||
template <typename Store>
|
||||
inline bool ota_bootloader_external_image_metadata(
|
||||
Store& store, uint32_t payload_off, uint8_t exact_storage_flags,
|
||||
OtaBootloaderIdentity& candidate, OtaBootloaderCapsMarker& caps) {
|
||||
OtaBootloaderIdentity& candidate, OtaBootloaderCapsMarker& caps,
|
||||
uint8_t required_optional_storage = 0) {
|
||||
candidate = OtaBootloaderIdentity();
|
||||
caps = OtaBootloaderCapsMarker();
|
||||
if (exact_storage_flags == 0u) return false;
|
||||
if (required_optional_storage != 0u &&
|
||||
!((exact_storage_flags == OTA_BL_PROFILE_SD_BOOT_UPDATE &&
|
||||
required_optional_storage == OTA_BL_STORAGE_STAGE_CEILING) ||
|
||||
(exact_storage_flags == OTA_BL_PROFILE_INTERNAL_BOOT_UPDATE &&
|
||||
required_optional_storage == (OTA_BL_STORAGE_QSPI | OTA_BL_STORAGE_HEADER_W25))))
|
||||
return false;
|
||||
|
||||
static const uint32_t STEP = 512u;
|
||||
uint8_t buf[STEP + OTA_BOOT_ENVELOPE_SIZE - 1u];
|
||||
uint8_t valid_identities = 0;
|
||||
uint8_t valid_caps = 0;
|
||||
uint8_t valid_ram = 0;
|
||||
uint8_t storage_records = 0;
|
||||
uint32_t optional_record_offset = 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);
|
||||
@@ -573,7 +597,22 @@ inline bool ota_bootloader_external_image_metadata(
|
||||
? OTA_BOOT_IMAGE_SIZE - base : STEP;
|
||||
for (uint32_t local = 0; local < starts; ++local) {
|
||||
const uint32_t absolute = base + local;
|
||||
if (required_optional_storage && (absolute & 3u) == 0u &&
|
||||
local + 8u <= len && memcmp(buf + local, "MOTASTOR", 8u) == 0) {
|
||||
// Scan each aligned start once, including chunk boundaries. A valid
|
||||
// RAM record is encountered first and binds its immediately following
|
||||
// STOR; no stray or duplicate declaration may hide beside that pair.
|
||||
if (++storage_records != 1u || valid_ram != 1u ||
|
||||
absolute != optional_record_offset) return false;
|
||||
}
|
||||
if ((absolute & 3u) == 0u && local + 16u <= len) {
|
||||
OtaRamCaps ram;
|
||||
if (required_optional_storage && ota_ram_caps_marker_parse(buf + local, ram)) {
|
||||
if (++valid_ram != 1u || local + 32u > len ||
|
||||
ota_bl_optional_app_storage(buf + local, 32u) != required_optional_storage)
|
||||
return false;
|
||||
optional_record_offset = absolute + 16u;
|
||||
}
|
||||
OtaBootloaderCapsMarker parsed;
|
||||
if (ota_bootloader_caps_marker_parse(buf + local, parsed) &&
|
||||
parsed.apply_abi >= MOTA_BOOT_FORMAT_VER &&
|
||||
@@ -596,7 +635,8 @@ inline bool ota_bootloader_external_image_metadata(
|
||||
}
|
||||
}
|
||||
}
|
||||
if (valid_identities != 1u || valid_caps != 1u || !caps.present ||
|
||||
if ((required_optional_storage && (valid_ram != 1u || storage_records != 1u)) ||
|
||||
valid_identities != 1u || valid_caps != 1u || !caps.present ||
|
||||
candidate.manifest_offset > OTA_BOOT_IMAGE_SIZE - OTA_BOOT_MANIFEST_SIZE)
|
||||
return false;
|
||||
const uint32_t available = OTA_BOOT_IMAGE_SIZE - candidate.manifest_offset;
|
||||
|
||||
@@ -158,6 +158,9 @@ bool handle_ota_command(const char* command, char* reply, mesh::MainBoard& board
|
||||
OtaContext& c = ota_ctx();
|
||||
c.manager.set_clock(millis());
|
||||
c.manager.set_speed(speedFactor());
|
||||
#if defined(NRF52_PLATFORM) && defined(OTA_TOWER_AUTO_STORE)
|
||||
c.prepareTowerStorage();
|
||||
#endif
|
||||
const char* rest = a;
|
||||
|
||||
// ---- raw / internal primitives, tucked under `ota dev ...` ----
|
||||
@@ -196,6 +199,11 @@ bool handle_ota_command(const char* command, char* reply, mesh::MainBoard& board
|
||||
"folder | config | key");
|
||||
#endif
|
||||
#elif defined(NRF52_PLATFORM) && defined(OTA_SD_STORE)
|
||||
#if defined(OTA_TOWER_AUTO_STORE)
|
||||
strcpy(reply,
|
||||
"OTA: status | stats | ls | get <id> flash | install | bootloader | cancel | announce | self | serve | "
|
||||
"storage | folder | cache | config | key");
|
||||
#else
|
||||
#if defined(OTA_SD_BOOTLOADER_UPDATE)
|
||||
strcpy(reply,
|
||||
"OTA: status | stats | ls | get <id> flash | install | bootloader | cancel | announce | self | serve | "
|
||||
@@ -205,6 +213,7 @@ bool handle_ota_command(const char* command, char* reply, mesh::MainBoard& board
|
||||
"OTA: status | stats | ls | get <id> flash | install | cancel | announce | self | serve | folder | "
|
||||
"cache | config | key");
|
||||
#endif
|
||||
#endif
|
||||
#else
|
||||
snprintf(reply, 160,
|
||||
"OTA: status | stats | ls | get <id> flash | install | cancel | announce | self | serve | folder | "
|
||||
@@ -253,7 +262,11 @@ bool handle_ota_command(const char* command, char* reply, mesh::MainBoard& board
|
||||
// nRF52 applies via the bootloader - show (cached) whether it can, so `ota get`/`install` won't surprise.
|
||||
// blrc = the bootloader's last apply code (diagnostic; 0xB8=success, see ota_delta.c).
|
||||
const OtaBlCaps& bl = c.bootloaderAppCaps();
|
||||
#if defined(OTA_RAK_AUTO_STORE)
|
||||
#if defined(OTA_TOWER_AUTO_STORE)
|
||||
const char* bl_state = !bl.present ? "NONE" :
|
||||
c.fetch_store.usesExternal() ? "SD" :
|
||||
c.fetch_store.usesInternal() ? "internal" : "storage-ERR";
|
||||
#elif defined(OTA_RAK_AUTO_STORE)
|
||||
const char* bl_state = !bl.present ? "NONE" :
|
||||
c.fetch_store.usesExternal() ? "QSPI" :
|
||||
c.fetch_store.usesInternal() ? "internal" : "storage-ERR";
|
||||
@@ -415,6 +428,9 @@ bool handle_ota_command(const char* command, char* reply, mesh::MainBoard& board
|
||||
ota_bootloader_self_update_caps_valid(list_bl_update);
|
||||
#if defined(OTA_SD_BOOTLOADER_UPDATE)
|
||||
installable = installable && list_sd_headroom;
|
||||
#if defined(OTA_TOWER_AUTO_STORE)
|
||||
installable = installable && c.fetch_store.usesExternal();
|
||||
#endif
|
||||
#endif
|
||||
#endif
|
||||
fit = installable ? "yours" : "bootloader unsupported";
|
||||
@@ -423,7 +439,11 @@ bool handle_ota_command(const char* command, char* reply, mesh::MainBoard& board
|
||||
#if defined(NRF52_PLATFORM)
|
||||
installable = installable && list_bl.present && list_bl.apply_abi >= MOTA_FORMAT_VER
|
||||
&& h->codec < 16 && (list_bl.codec_mask & (1u << h->codec));
|
||||
#if defined(OTA_SD_STORE)
|
||||
#if defined(OTA_TOWER_AUTO_STORE)
|
||||
installable = installable && c.fetch_store.compatible();
|
||||
if (h->codec == CODEC_FULL)
|
||||
installable = installable && c.fetch_store.usesExternal();
|
||||
#elif defined(OTA_SD_STORE)
|
||||
installable = installable && (list_bl.storage_flags & OTA_BL_STORAGE_SD);
|
||||
#elif defined(OTA_RAK_AUTO_STORE)
|
||||
if (c.fetch_store.usesExternal())
|
||||
@@ -567,6 +587,12 @@ bool handle_ota_command(const char* command, char* reply, mesh::MainBoard& board
|
||||
return true;
|
||||
}
|
||||
#if defined(OTA_SD_BOOTLOADER_UPDATE)
|
||||
#if defined(OTA_TOWER_AUTO_STORE)
|
||||
if (!c.fetch_store.usesExternal()) {
|
||||
strcpy(reply, "ERR bootloader packages require a usable SD card");
|
||||
return true;
|
||||
}
|
||||
#endif
|
||||
SelfFwInfo sd_self;
|
||||
if (!ota_self_firmware(sd_self) ||
|
||||
!ota_bootloader_scratch_headroom_valid(
|
||||
@@ -602,7 +628,12 @@ bool handle_ota_command(const char* command, char* reply, mesh::MainBoard& board
|
||||
need_abi, (unsigned)selcodec, bl.apply_abi, bl.codec_mask);
|
||||
return true;
|
||||
}
|
||||
#if defined(OTA_SD_STORE)
|
||||
#if defined(OTA_TOWER_AUTO_STORE)
|
||||
if (!c.fetch_store.compatible()) {
|
||||
snprintf(reply, 160, "ERR OTA storage unsafe: %s", c.fetch_store.selectionReason());
|
||||
return true;
|
||||
}
|
||||
#elif defined(OTA_SD_STORE)
|
||||
if (!(bl.storage_flags & OTA_BL_STORAGE_SD)) {
|
||||
strcpy(reply, "ERR bootloader cannot apply an update staged on SD; update it over USB first");
|
||||
return true;
|
||||
@@ -709,6 +740,12 @@ bool handle_ota_command(const char* command, char* reply, mesh::MainBoard& board
|
||||
#endif
|
||||
const bool discarded = !was_folder && !was_sd_archive &&
|
||||
c.fetch_store.discard();
|
||||
#if defined(NRF52_PLATFORM) && defined(OTA_TOWER_AUTO_STORE)
|
||||
if (discarded || (!c.fetch_store.usesExternal() && !c.fetch_store.usesInternal())) {
|
||||
c.fetch_store.resetSelection();
|
||||
c.prepareTowerStorage();
|
||||
}
|
||||
#endif
|
||||
// Fetch cancellation and serving are independent. In particular, a
|
||||
// manual serve view can point into serve_buf, so leave the manager
|
||||
// view and its caller-owned buffer intact until `ota dev clear` (which
|
||||
@@ -769,7 +806,10 @@ bool handle_ota_command(const char* command, char* reply, mesh::MainBoard& board
|
||||
|
||||
// ---- raw-QSPI staging diagnostics (read-only probe; preserves a latched fetch failure) ----
|
||||
} else if (is_cmd(a, "qspi|storage", &rest)) {
|
||||
#if defined(NRF52_PLATFORM) && defined(OTA_RAK_AUTO_STORE)
|
||||
#if defined(NRF52_PLATFORM) && defined(OTA_TOWER_AUTO_STORE)
|
||||
snprintf(reply, 160, "OTA storage: %s; capacity=%luK; SD primary, internal application deltas only",
|
||||
c.fetch_store.selectionReason(), (unsigned long)(c.fetch_store.capacity() / 1024));
|
||||
#elif defined(NRF52_PLATFORM) && defined(OTA_RAK_AUTO_STORE)
|
||||
if (c.fetch_store.usesExternal()) {
|
||||
const uint32_t qspi_capacity = c.fetch_store.qspiCapacity();
|
||||
snprintf(reply, 160, "QSPI %s jedec=%06lX size=%luK sr1=%02X stage=%s%s%s",
|
||||
@@ -814,7 +854,11 @@ bool handle_ota_command(const char* command, char* reply, mesh::MainBoard& board
|
||||
#if defined(NRF52_PLATFORM)
|
||||
// nRF52 applies via the bootloader, so surface whether THIS device's bootloader can install this store.
|
||||
const OtaBlCaps& bl = c.bootloaderAppCaps(); // cached (flash scanned once)
|
||||
#if defined(OTA_RAK_AUTO_STORE)
|
||||
#if defined(OTA_TOWER_AUTO_STORE)
|
||||
snprintf(reply + n, 160 - n, " | store:%s | bootloader:%s",
|
||||
c.fetch_store.usesExternal() ? "SD" : c.fetch_store.usesInternal() ? "internal" : "unsafe",
|
||||
c.fetch_store.compatible() ? "apply OK" : "unsupported");
|
||||
#elif defined(OTA_RAK_AUTO_STORE)
|
||||
if (c.fetch_store.usesExternal()) {
|
||||
const uint32_t qspi_capacity = c.fetch_store.qspiCapacity();
|
||||
snprintf(reply + n, 160 - n, " | QSPI store:%s%uK | bootloader: %s",
|
||||
@@ -1323,6 +1367,12 @@ static bool handle_dev(const char* d, char* reply, OtaContext& c) {
|
||||
strcpy(reply, "OK cleared SD archive session; partial retained for resume");
|
||||
} else {
|
||||
const bool discarded = c.fetch_store.discard();
|
||||
#if defined(NRF52_PLATFORM) && defined(OTA_TOWER_AUTO_STORE)
|
||||
if (discarded || (!c.fetch_store.usesExternal() && !c.fetch_store.usesInternal())) {
|
||||
c.fetch_store.resetSelection();
|
||||
c.prepareTowerStorage();
|
||||
}
|
||||
#endif
|
||||
strcpy(reply, discarded ? "OK OTA receive slot confirmed clear"
|
||||
: "ERR RAM state cleared; persistent OTA slot invalidation failed");
|
||||
}
|
||||
|
||||
@@ -27,7 +27,10 @@
|
||||
#define OTA_DYNAMIC_CONTEXT 0
|
||||
#endif
|
||||
|
||||
#if defined(NRF52_PLATFORM) && defined(OTA_RAK_AUTO_STORE)
|
||||
#if defined(NRF52_PLATFORM) && defined(OTA_TOWER_AUTO_STORE)
|
||||
#include "OtaStoreTowerNrf52.h"
|
||||
#include "OtaCacheSdNrf52.h"
|
||||
#elif defined(NRF52_PLATFORM) && defined(OTA_RAK_AUTO_STORE)
|
||||
#include "OtaStoreAdaptiveNrf52.h"
|
||||
#elif defined(NRF52_PLATFORM) && defined(OTA_QSPI_STORE)
|
||||
#include "OtaStoreQspiNrf52.h"
|
||||
@@ -110,6 +113,9 @@ struct OtaContext {
|
||||
// store object for OtaManager, while folder captures replace it with the
|
||||
// host-backed FolderMotaStore for the duration of the pull.
|
||||
OtaStoreRam<1> fetch_store;
|
||||
#elif defined(NRF52_PLATFORM) && defined(OTA_TOWER_AUTO_STORE)
|
||||
OtaStoreTowerNrf52 fetch_store; // SD primary, qualified internal delta fallback
|
||||
OtaCacheSdNrf52 sd_cache; // always owns the stable SD store
|
||||
#elif defined(NRF52_PLATFORM) && defined(OTA_RAK_AUTO_STORE)
|
||||
OtaStoreAdaptiveNrf52 fetch_store;
|
||||
#elif defined(NRF52_PLATFORM) && defined(OTA_QSPI_STORE)
|
||||
@@ -145,9 +151,9 @@ struct OtaContext {
|
||||
#endif
|
||||
uint32_t serve_expected = 0; // size declared by `ota stage`
|
||||
bool serving = false; // manager.serve() succeeded
|
||||
// Capture application-storage eligibility once. An adaptive nRF52 may later
|
||||
// switch to internal flash to stage a bootloader without changing its normal
|
||||
// ability to offer its running application.
|
||||
// RAK application-storage eligibility survives privileged staging changes.
|
||||
// Tower eligibility is established after its deferred SD probe and changes
|
||||
// only when an operator ends the pinned transfer and selects storage again.
|
||||
bool self_serve_supported = false;
|
||||
// flash-backed self-serve: cached merkle leaves (heap, freed on re-serve) + assembled manifest of our
|
||||
// own running firmware. The payload is read from flash per block; only the metadata is held in RAM.
|
||||
@@ -291,6 +297,12 @@ struct OtaContext {
|
||||
strncpy(msg, "refused: use ota bootloader install <MID8> <HASH16>", 96);
|
||||
msg[95] = 0; return false;
|
||||
}
|
||||
#if defined(OTA_TOWER_AUTO_STORE)
|
||||
if (!mm.is_signed()) {
|
||||
strncpy(msg, "refused: MeshTower application OTA requires a trusted signature", 96);
|
||||
msg[95] = 0; return false;
|
||||
}
|
||||
#endif
|
||||
if (trusted_auto) {
|
||||
SelfFwInfo self;
|
||||
if (!mm.is_signed()) {
|
||||
@@ -314,7 +326,7 @@ struct OtaContext {
|
||||
}
|
||||
}
|
||||
bool ok;
|
||||
#if defined(NRF52_PLATFORM) && defined(OTA_RAK_AUTO_STORE)
|
||||
#if defined(NRF52_PLATFORM) && (defined(OTA_RAK_AUTO_STORE) || defined(OTA_TOWER_AUTO_STORE))
|
||||
ok = fetch_store.usesExternal()
|
||||
? ota_apply_mota_nrf52(fetch_store.externalStore(), allow, apply_st, msg)
|
||||
: fetch_store.usesInternal()
|
||||
@@ -363,7 +375,15 @@ struct OtaContext {
|
||||
msg[95] = 0; return false;
|
||||
}
|
||||
const OtaBootloaderIdentity& installed = bootloaderIdentity();
|
||||
#if defined(OTA_RAK_AUTO_STORE)
|
||||
#if defined(OTA_TOWER_AUTO_STORE)
|
||||
if (!fetch_store.usesExternal()) {
|
||||
strncpy(msg, "bootloader package must be staged on SD", 96);
|
||||
msg[95] = 0; return false;
|
||||
}
|
||||
bool ok = ota_prepare_bootloader_update_nrf52(
|
||||
fetch_store.sdStore(), allow, installed, manager.fetchManifestId(), operator_mid,
|
||||
operator_hash8, apply_st, msg);
|
||||
#elif defined(OTA_RAK_AUTO_STORE)
|
||||
if (!fetch_store.usesInternal()) {
|
||||
strncpy(msg, "bootloader package must be staged in internal flash", 96);
|
||||
msg[95] = 0; return false;
|
||||
@@ -545,10 +565,45 @@ struct OtaContext {
|
||||
}
|
||||
|
||||
#if defined(NRF52_PLATFORM) && defined(OTA_SD_STORE)
|
||||
OtaStoreSdNrf52& sdStagingStore() {
|
||||
#if defined(OTA_TOWER_AUTO_STORE)
|
||||
return fetch_store.sdStore();
|
||||
#else
|
||||
return fetch_store;
|
||||
#endif
|
||||
}
|
||||
|
||||
#if defined(OTA_TOWER_AUTO_STORE)
|
||||
// Probe only after startup or an explicit OTA command. Selection is latched
|
||||
// until operator cancellation, including a failed/paused transfer.
|
||||
bool prepareTowerStorage() {
|
||||
const bool usable = fetch_store.selectStorage();
|
||||
const bool external = usable && fetch_store.usesExternal();
|
||||
const bool was_self_serve_supported = self_serve_supported;
|
||||
manager.set_accept_full(external);
|
||||
manager.set_accept_bootloader(external &&
|
||||
ota_bootloader_self_update_caps_valid(bootloaderUpdateCaps()));
|
||||
self_serve_supported = ota_self_serve_supported(external);
|
||||
if (was_self_serve_supported && !self_serve_supported &&
|
||||
manager.servingPrimaryManifest(serve_self_manifest)) {
|
||||
manager.clear_primary();
|
||||
serving = false;
|
||||
}
|
||||
#if MESHCORE_OTA_DEVICE_DEFLATE
|
||||
manager.set_transport_deflate_encoder(self_serve_supported ? ota_transport_deflate : nullptr);
|
||||
#endif
|
||||
if (!external) manager.set_archive_interest(false);
|
||||
return usable;
|
||||
}
|
||||
#endif
|
||||
|
||||
// Initialize and attach the persistent source lazily. This lets Mesh::begin finish quickly when no card
|
||||
// is inserted, while archive interest is already enabled so early OTA advertisements still get queried.
|
||||
bool ensureSdCache() {
|
||||
sd_cache.attach(fetch_store);
|
||||
sd_cache.attach(sdStagingStore());
|
||||
#if defined(OTA_TOWER_AUTO_STORE)
|
||||
if (!prepareTowerStorage() || !fetch_store.usesExternal()) return false;
|
||||
#endif
|
||||
if (!sd_cache.initialize()) return false;
|
||||
if (!_sd_cache_source_attached) {
|
||||
if (!manager.add_source(&sd_cache)) return false;
|
||||
@@ -598,11 +653,21 @@ struct OtaContext {
|
||||
|
||||
void finishSdCardReset(uint32_t now) {
|
||||
sd_cache.resetMedia();
|
||||
#if defined(OTA_TOWER_AUTO_STORE)
|
||||
fetch_store.resetSelection();
|
||||
#endif
|
||||
_sd_cache_init_retry_at = now;
|
||||
ensureSdCache();
|
||||
}
|
||||
|
||||
void serviceSdCache(uint32_t now) {
|
||||
#if defined(OTA_TOWER_AUTO_STORE)
|
||||
if (!prepareTowerStorage() || !fetch_store.usesExternal()) {
|
||||
if (_sd_cache_source_attached) manager.remove_source(&sd_cache);
|
||||
_sd_cache_source_attached = false;
|
||||
return;
|
||||
}
|
||||
#endif
|
||||
if (!_sd_cache_source_attached) {
|
||||
if (_sd_cache_init_retry_at && (int32_t)(now - _sd_cache_init_retry_at) < 0) return;
|
||||
if (!ensureSdCache()) {
|
||||
@@ -694,6 +759,11 @@ struct OtaContext {
|
||||
manager.set_accept_full(true);
|
||||
manager.set_autofetch(OtaManager::AUTOFETCH_OFF);
|
||||
autoinstall = AUTOINSTALL_OFF;
|
||||
#elif defined(NRF52_PLATFORM) && defined(OTA_TOWER_AUTO_STORE)
|
||||
// An SD probe can block when the socket is empty. Bring up USB and the
|
||||
// radio before determining the optional storage backend in the main loop.
|
||||
manager.set_accept_full(false);
|
||||
manager.set_apply_codec(CODEC_DETOOLS_INPLACE);
|
||||
#elif defined(NRF52_PLATFORM) && defined(OTA_RAK_AUTO_STORE)
|
||||
manager.set_accept_full(fetch_store.usesExternal());
|
||||
manager.set_apply_codec(CODEC_DETOOLS_INPLACE);
|
||||
@@ -711,7 +781,9 @@ struct OtaContext {
|
||||
#if defined(NRF52_PLATFORM) && \
|
||||
(defined(OTA_QSPI_BOOTLOADER_UPDATE) || defined(OTA_INTERNAL_BOOTLOADER_UPDATE) || \
|
||||
defined(OTA_SD_BOOTLOADER_UPDATE))
|
||||
#if defined(OTA_RAK_AUTO_STORE)
|
||||
#if defined(OTA_TOWER_AUTO_STORE)
|
||||
manager.set_accept_bootloader(false);
|
||||
#elif defined(OTA_RAK_AUTO_STORE)
|
||||
manager.set_accept_bootloader(
|
||||
ota_bootloader_self_update_caps_valid(ota_bootloader_update_caps()));
|
||||
#else
|
||||
@@ -721,7 +793,9 @@ struct OtaContext {
|
||||
manager.set_accept_bootloader(false);
|
||||
#endif
|
||||
manager.set_fetch_store(&fetch_store);
|
||||
#if defined(NRF52_PLATFORM) && defined(OTA_RAK_AUTO_STORE) && !defined(OTA_SEEDER_ONLY)
|
||||
#if defined(NRF52_PLATFORM) && defined(OTA_TOWER_AUTO_STORE)
|
||||
self_serve_supported = false;
|
||||
#elif defined(NRF52_PLATFORM) && defined(OTA_RAK_AUTO_STORE) && !defined(OTA_SEEDER_ONLY)
|
||||
self_serve_supported = ota_self_serve_supported(fetch_store.usesExternal());
|
||||
#else
|
||||
self_serve_supported = ota_self_serve_supported();
|
||||
@@ -733,7 +807,7 @@ struct OtaContext {
|
||||
manager.set_transport_deflate_encoder(self_serve_supported ? ota_transport_deflate : nullptr);
|
||||
#endif
|
||||
#if defined(NRF52_PLATFORM) && defined(OTA_SD_STORE)
|
||||
sd_cache.attach(fetch_store);
|
||||
sd_cache.attach(sdStagingStore());
|
||||
manager.set_archive_interest(true); // default on; the SD marker can turn it off at first mount
|
||||
#endif
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ namespace mesh { namespace ota {
|
||||
// Default own-image offering is for full-image platforms. Internal-only nRF52
|
||||
// nodes retain the explicit diagnostic export used to capture delta bases, but
|
||||
// do not automatically offer a full image their ordinary peers cannot install.
|
||||
// Adaptive RAK builds must use the qualified application backend, not merely
|
||||
// Adaptive RAK and Tower builds must use the qualified application backend, not merely
|
||||
// the presence of a NOR chip (or the temporary bootloader staging backend).
|
||||
inline bool ota_self_serve_supported(bool external_application_storage = false) {
|
||||
#if defined(OTA_SEEDER_ONLY)
|
||||
@@ -14,7 +14,7 @@ inline bool ota_self_serve_supported(bool external_application_storage = false)
|
||||
#elif defined(ESP32_PLATFORM)
|
||||
(void)external_application_storage;
|
||||
return true;
|
||||
#elif defined(NRF52_PLATFORM) && defined(OTA_RAK_AUTO_STORE)
|
||||
#elif defined(NRF52_PLATFORM) && (defined(OTA_RAK_AUTO_STORE) || defined(OTA_TOWER_AUTO_STORE))
|
||||
return external_application_storage;
|
||||
#elif defined(NRF52_PLATFORM) && (defined(OTA_QSPI_STORE) || defined(OTA_SD_STORE))
|
||||
(void)external_application_storage;
|
||||
|
||||
@@ -52,6 +52,9 @@ public:
|
||||
bool eraseCard(MainBoard& board);
|
||||
bool getSpace(MainBoard& board, uint64_t& used_bytes, uint64_t& free_bytes);
|
||||
bool listFiles(MainBoard& board, uint16_t page, char* reply, size_t cap);
|
||||
// Mount-only probe for the MeshTower selector. Never opens/removes staging
|
||||
// files or changes their live session state.
|
||||
bool probeMedia() { return mount() && capacity() != 0u; }
|
||||
const char* last_error() const { return _error; }
|
||||
|
||||
private:
|
||||
|
||||
@@ -0,0 +1,216 @@
|
||||
#pragma once
|
||||
|
||||
#if defined(OTA_TOWER_AUTO_STORE)
|
||||
#if !defined(NRF52_PLATFORM) || !defined(HELTEC_TOWER_V2_SDCARD) || \
|
||||
!defined(OTA_SD_STORE) || !defined(OTA_FLASH_STORE)
|
||||
#error "MeshTower automatic OTA storage requires the qualified nRF52 SD primary"
|
||||
#endif
|
||||
|
||||
#include "OtaBlInfo.h"
|
||||
#include "OtaStoreFlashNrf52.h"
|
||||
#include "OtaStoreSdNrf52.h"
|
||||
#include "MotaContainer.h"
|
||||
#include <new>
|
||||
|
||||
namespace mesh {
|
||||
namespace ota {
|
||||
|
||||
// Keep the SD volume object stable because the archive shares its mounted
|
||||
// filesystem. Internal construction is deferred: its constructor invalidates
|
||||
// the same retained authorization bytes used by the SD application handoff.
|
||||
// Selecting media never opens/removes a staging file, and an operation failure
|
||||
// never redirects an already selected transfer to a different medium.
|
||||
class OtaStoreTowerNrf52 : public OtaStore {
|
||||
enum Mode : uint8_t { UNSELECTED, SD_MODE, INTERNAL_MODE, UNAVAILABLE };
|
||||
OtaStoreSdNrf52 _sd;
|
||||
alignas(OtaStoreFlashNrf52) uint8_t _internal_bytes[sizeof(OtaStoreFlashNrf52)];
|
||||
OtaStoreFlashNrf52* _internal = nullptr;
|
||||
Mode _mode = UNSELECTED;
|
||||
bool _compatible = false;
|
||||
bool _has_stage = false;
|
||||
bool _layout_rejected = false;
|
||||
bool _layout_accepted = false;
|
||||
const char* _reason = "not selected";
|
||||
const char* _error = nullptr;
|
||||
|
||||
OtaStore* active() {
|
||||
return _mode == SD_MODE ? static_cast<OtaStore*>(&_sd) :
|
||||
_mode == INTERNAL_MODE ? _internal : nullptr;
|
||||
}
|
||||
const OtaStore* active() const {
|
||||
return _mode == SD_MODE ? static_cast<const OtaStore*>(&_sd) :
|
||||
_mode == INTERNAL_MODE ? _internal : nullptr;
|
||||
}
|
||||
bool resumeMatches(const uint8_t* mid, uint32_t target) {
|
||||
uint8_t raw[MOTA_MFL];
|
||||
MotaManifest manifest;
|
||||
const OtaStore* s = active();
|
||||
if (!s || !s->read(8u, raw, sizeof(raw)) ||
|
||||
!mota_parse_manifest(raw, sizeof(raw), manifest)) {
|
||||
_error = "staged manifest is invalid";
|
||||
return false;
|
||||
}
|
||||
if ((mid && memcmp(manifest.merkle_root, mid, 4u) != 0) ||
|
||||
(target != 0u && manifest.target_id != target)) {
|
||||
_error = "staged MID/target does not match";
|
||||
return false;
|
||||
}
|
||||
if (_mode == INTERNAL_MODE && (manifest.is_full() || manifest.is_bootloader())) {
|
||||
_error = "internal fallback accepts application deltas only";
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
public:
|
||||
OtaStoreTowerNrf52() = default;
|
||||
OtaStoreTowerNrf52(const OtaStoreTowerNrf52&) = delete;
|
||||
OtaStoreTowerNrf52& operator=(const OtaStoreTowerNrf52&) = delete;
|
||||
~OtaStoreTowerNrf52() override {
|
||||
if (_internal) _internal->~OtaStoreFlashNrf52();
|
||||
}
|
||||
|
||||
// Call from the deferred OTA preparation path, after normal board startup.
|
||||
// Getter/status calls deliberately cannot turn USB startup into an SD probe.
|
||||
bool selectStorage() {
|
||||
if (_mode != UNSELECTED) return _compatible;
|
||||
const bool sd_ready = _sd.probeMedia();
|
||||
const OtaBlCaps caps = ota_bootloader_app_caps();
|
||||
const bool sd_profile = caps.present && (caps.storage_flags & OTA_BL_STORAGE_SD) != 0u;
|
||||
if (sd_ready) {
|
||||
_mode = SD_MODE;
|
||||
_compatible = sd_profile;
|
||||
_reason = sd_profile ? "SD staging" : "SD present; incompatible OTAFIX bootloader";
|
||||
} else if (sd_profile && caps.optional_app_storage == OTA_BL_STORAGE_STAGE_CEILING) {
|
||||
_internal = new (_internal_bytes) OtaStoreFlashNrf52();
|
||||
_mode = INTERNAL_MODE;
|
||||
_compatible = true;
|
||||
_reason = "SD unavailable; internal delta staging";
|
||||
} else {
|
||||
_mode = UNAVAILABLE;
|
||||
_reason = "SD unavailable; OTAFIX lacks qualified internal fallback";
|
||||
}
|
||||
return _compatible;
|
||||
}
|
||||
// The caller must finish/cancel the transfer first. Do not discard durable
|
||||
// staged bytes here: a following reopen may intentionally resume them.
|
||||
void resetSelection() {
|
||||
if (_internal) {
|
||||
_internal->~OtaStoreFlashNrf52();
|
||||
_internal = nullptr;
|
||||
}
|
||||
_mode = UNSELECTED;
|
||||
_compatible = false;
|
||||
_has_stage = false;
|
||||
_layout_rejected = false;
|
||||
_layout_accepted = false;
|
||||
_reason = "not selected";
|
||||
_error = nullptr;
|
||||
}
|
||||
bool usesExternalStore() const { return _mode == SD_MODE; }
|
||||
bool usesExternal() const { return usesExternalStore(); }
|
||||
bool usesInternal() const { return _mode == INTERNAL_MODE; }
|
||||
bool compatible() const { return _compatible; }
|
||||
const char* selectionReason() const { return _reason; }
|
||||
OtaStoreSdNrf52& sdStore() { return _sd; }
|
||||
OtaStoreSdNrf52& externalStore() { return _sd; }
|
||||
// Only use after usesInternal() succeeds. This accessor must not construct
|
||||
// an internal store while an SD authorization is being prepared/published.
|
||||
OtaStoreFlashNrf52& internalStore() { return *_internal; }
|
||||
const char* last_error() const {
|
||||
if (_error) return _error;
|
||||
return _mode == SD_MODE ? _sd.last_error() : _reason;
|
||||
}
|
||||
|
||||
bool begin(uint32_t n) override {
|
||||
_has_stage = false;
|
||||
if (!selectStorage() || _layout_rejected) return false;
|
||||
if (_mode == INTERNAL_MODE && !_layout_accepted) {
|
||||
_error = "internal fallback requires a planned application delta";
|
||||
return false;
|
||||
}
|
||||
_error = nullptr;
|
||||
_has_stage = active()->begin(n);
|
||||
if (!_has_stage && _mode == INTERNAL_MODE) _error = "internal OTA begin failed";
|
||||
return _has_stage;
|
||||
}
|
||||
bool write(uint32_t p, const uint8_t* d, uint32_t n) override {
|
||||
return _has_stage && active()->write(p, d, n);
|
||||
}
|
||||
bool read(uint32_t p, uint8_t* d, uint32_t n) const override {
|
||||
return _has_stage && active()->read(p, d, n);
|
||||
}
|
||||
uint32_t capacity() const override {
|
||||
const OtaStore* s = active();
|
||||
return _compatible && s ? s->capacity() : 0u;
|
||||
}
|
||||
uint32_t staged_size() const override {
|
||||
return _has_stage ? active()->staged_size() : 0u;
|
||||
}
|
||||
void clear() override {
|
||||
OtaStore* s = active();
|
||||
if (s) s->clear();
|
||||
_has_stage = false;
|
||||
_layout_rejected = false;
|
||||
_layout_accepted = false;
|
||||
_error = nullptr;
|
||||
}
|
||||
bool discard() override {
|
||||
if (!selectStorage()) return false;
|
||||
const bool ok = active()->discard();
|
||||
_has_stage = false;
|
||||
_layout_rejected = false;
|
||||
_layout_accepted = false;
|
||||
if (!ok && _mode == INTERNAL_MODE) _error = "internal OTA discard failed";
|
||||
return ok;
|
||||
}
|
||||
bool set_meta_size(uint32_t n) override {
|
||||
return _has_stage && active()->set_meta_size(n);
|
||||
}
|
||||
bool finalize() override { return _has_stage && active()->finalize(); }
|
||||
void checkpoint() override { if (_has_stage) active()->checkpoint(); }
|
||||
bool reopen() override { return reopenFor(nullptr, 0u); }
|
||||
bool reopenFor(const uint8_t* mid, uint32_t target) override {
|
||||
_has_stage = false;
|
||||
_layout_rejected = false;
|
||||
_layout_accepted = false;
|
||||
_error = nullptr;
|
||||
if (!selectStorage()) return false;
|
||||
OtaStore* s = active();
|
||||
const bool reopened = _mode == SD_MODE ? _sd.reopen() : s->reopenFor(mid, target);
|
||||
// Rejecting a mismatched SD container must not call SD clear(): that
|
||||
// method durably deletes the very file a later explicit pull may need.
|
||||
_has_stage = reopened && resumeMatches(mid, target);
|
||||
return _has_stage;
|
||||
}
|
||||
bool plan_layout(bool full, uint32_t image, uint32_t offset,
|
||||
uint32_t payload, bool bootloader) override {
|
||||
_layout_rejected = true;
|
||||
_layout_accepted = false;
|
||||
_error = nullptr;
|
||||
if (!selectStorage()) return false;
|
||||
if (_mode == INTERNAL_MODE && (full || bootloader)) {
|
||||
_error = "internal fallback accepts application deltas only";
|
||||
return false;
|
||||
}
|
||||
_layout_rejected = !active()->plan_layout(full, image, offset, payload, bootloader);
|
||||
_layout_accepted = !_layout_rejected;
|
||||
return !_layout_rejected;
|
||||
}
|
||||
|
||||
// Management always addresses the physical SD card. These operations never
|
||||
// switch a selected application transfer between SD and internal storage.
|
||||
bool formatCard(MainBoard& board) { return _sd.formatCard(board); }
|
||||
bool eraseCard(MainBoard& board) { return _sd.eraseCard(board); }
|
||||
bool getSpace(MainBoard& board, uint64_t& used, uint64_t& free) {
|
||||
return _sd.getSpace(board, used, free);
|
||||
}
|
||||
bool listFiles(MainBoard& board, uint16_t page, char* reply, size_t cap) {
|
||||
return _sd.listFiles(board, page, reply, cap);
|
||||
}
|
||||
};
|
||||
|
||||
} // namespace ota
|
||||
} // namespace mesh
|
||||
|
||||
#endif
|
||||
@@ -15,6 +15,34 @@ def record(target: str, profile: str = "full", platform: str = "ESP32_PLATFORM")
|
||||
|
||||
|
||||
class ReleaseFullSelectionTest(unittest.TestCase):
|
||||
def test_meshtower_sd_primary_filters_stale_internal_images_without_relabeling(self):
|
||||
primary = "Heltec_tower_v2_sdcard_repeater_lora_ota_no_external_sensors"
|
||||
variants = []
|
||||
for sensor in ("full", "reduced"):
|
||||
item = record(primary, "auto", "NRF52_PLATFORM")
|
||||
item["manifest"].update(artifact_target=primary + "-" + sensor + "-ota",
|
||||
capabilities=["sensor.profile." + sensor])
|
||||
variants.append(item)
|
||||
companion = record("Heltec_tower_v2_companion_radio_full", platform="NRF52_PLATFORM")
|
||||
room = record("Heltec_tower_v2_room_server_lora_ota_no_external_sensors",
|
||||
platform="NRF52_PLATFORM")
|
||||
legacy = [record(target, "auto", "NRF52_PLATFORM") for target in (
|
||||
"Heltec_tower_v2_repeater",
|
||||
"Heltec_tower_v2_repeater_lora_ota_no_external_sensors",
|
||||
)]
|
||||
selected = select_ordinary_full_records(legacy + variants + [companion, room])
|
||||
self.assertEqual(selected, variants + [companion, room])
|
||||
for before, after in zip(variants, selected):
|
||||
self.assertIs(before, after)
|
||||
self.assertEqual(after["manifest"]["target"], primary)
|
||||
|
||||
def test_meshtower_internal_images_cannot_substitute_for_missing_primary(self):
|
||||
for target in ("Heltec_tower_v2_repeater",
|
||||
"Heltec_tower_v2_repeater_lora_ota_no_external_sensors"):
|
||||
with self.subTest(target=target):
|
||||
with self.assertRaisesRegex(ValueError, "requires the SD primary"):
|
||||
select_ordinary_full_records([record(target, "auto", "NRF52_PLATFORM")])
|
||||
|
||||
def test_nrf52_full_and_reduced_ota_pair_is_never_collapsed(self):
|
||||
for role in ('repeater', 'room_server', 'sensor'):
|
||||
target = 'RAK_3401_' + role
|
||||
|
||||
@@ -4,6 +4,11 @@ set -euo pipefail
|
||||
cd "$(dirname "$0")/.."
|
||||
source build.sh
|
||||
fail() { echo "test_nrf52_ota_profiles: $*" >&2; exit 1; }
|
||||
test_output=$(mktemp -d)
|
||||
trap 'rm -rf -- "$test_output"' EXIT
|
||||
tower_primary=Heltec_tower_v2_sdcard_repeater_lora_ota_no_external_sensors
|
||||
tower_legacy=Heltec_tower_v2_repeater_lora_ota_no_external_sensors
|
||||
tower_base=Heltec_tower_v2_repeater
|
||||
|
||||
# This suite deliberately has no PlatformIO dependency. A mocked one-profile
|
||||
# worker exercises the actual single/bulk/matrix dispatch and fail-closed pair
|
||||
@@ -12,7 +17,7 @@ SUPPORTED_PIO_ENVS=(
|
||||
nrf_repeater nrf_repeater_lora_ota_no_external_sensors
|
||||
nrf_room_server nrf_room_server_lora_ota_no_external_sensors
|
||||
nrf_sensor nrf_sensor_lora_ota_no_external_sensors
|
||||
Xiao_nrf52_repeater Heltec_tower_v2_sdcard_repeater_lora_ota_no_external_sensors
|
||||
Xiao_nrf52_repeater "$tower_primary" "$tower_base" "$tower_legacy"
|
||||
RAK_3401_repeater_unified_lora_ota RAK_3401_sensor
|
||||
RAK_3401_sensor_lora_ota_no_external_sensors nrf_kiss_modem
|
||||
nrf_companion_radio_usb nrf_companion_radio_full nrf_terminal_chat
|
||||
@@ -31,6 +36,9 @@ done
|
||||
PIO_ENV_QSPI_OTA_BY_NAME[Xiao_nrf52_repeater]=1
|
||||
PIO_ENV_QSPI_OTA_BY_NAME[RAK_3401_repeater_unified_lora_ota]=1
|
||||
PIO_ENV_SD_OTA_BY_NAME[Heltec_tower_v2_sdcard_repeater_lora_ota_no_external_sensors]=1
|
||||
ALL_PIO_ENVS=("$tower_base" "$tower_primary")
|
||||
PIO_ENV_BUILD_BASE_BY_NAME[$tower_legacy]=$tower_base
|
||||
PIO_ENV_COMPLETE_OTA_BASE_BY_NAME[$tower_legacy]=$tower_base
|
||||
PIO_CONFIG_JSON='[]'
|
||||
|
||||
for target in nrf_repeater nrf_room_server nrf_sensor Xiao_nrf52_repeater \
|
||||
@@ -54,13 +62,88 @@ done
|
||||
Heltec_tower_v2_sdcard_repeater_lora_ota_no_external_sensors ] \
|
||||
|| fail "SD target lost its exact storage identity"
|
||||
|
||||
# The old base and generated internal-only build names now select the exact
|
||||
# SD primary. A direct alias must warn about its different installed OTA ID,
|
||||
# and all bulk/tagged lists must retain only one Full/Reduced target pair.
|
||||
for alias in "$tower_base" "$tower_legacy"; do
|
||||
[ "$(get_nrf52_sensor_ota_pair_target "$alias")" = "$tower_primary" ] \
|
||||
|| fail "$alias did not resolve to the SD primary"
|
||||
resolve_command_targets build-firmware "$alias" > /dev/null \
|
||||
2> "$test_output/tower-alias-warning"
|
||||
[ "${#RESOLVED_BUILD_TARGETS[@]}" -eq 1 ] \
|
||||
&& [ "${RESOLVED_BUILD_TARGETS[0]}" = "$tower_primary" ] \
|
||||
|| fail "$alias direct dispatch retained an internal-only identity"
|
||||
warning=$(cat "$test_output/tower-alias-warning")
|
||||
[[ "$warning" == *"require local USB/BLE DFU or SWD migration"* \
|
||||
&& "$warning" == *"not a same-target LoRa upgrade"* ]] \
|
||||
|| fail "$alias omitted its installed-identity migration warning"
|
||||
is_redundant_bulk_build_target "$alias" \
|
||||
|| fail "$alias remained a second canonical artifact"
|
||||
done
|
||||
for resolver in resolve_all_firmwares resolve_repeater_firmwares; do
|
||||
mapfile -t tower_targets < <("$resolver")
|
||||
target_list=" ${tower_targets[*]} "
|
||||
[[ "$target_list" == *" $tower_primary "* \
|
||||
&& "$target_list" != *" $tower_base "* \
|
||||
&& "$target_list" != *" $tower_legacy "* ]] \
|
||||
|| fail "$resolver did not publish only the Tower SD primary"
|
||||
done
|
||||
mapfile -t tower_targets < <(print_release_firmware_targets get-repeater-firmwares-to-build)
|
||||
target_list=" ${tower_targets[*]} "
|
||||
[[ "$target_list" == *" $tower_primary "* \
|
||||
&& "$target_list" != *" $tower_base "* \
|
||||
&& "$target_list" != *" $tower_legacy "* ]] \
|
||||
|| fail "tagged repeater list omitted the SD primary or retained a duplicate"
|
||||
for unchanged in Heltec_tower_v2_room_server Heltec_tower_v2_companion_radio_full; do
|
||||
if get_unified_meshtower_repeater_replacement "$unchanged" >/dev/null; then
|
||||
fail "$unchanged was redirected outside the repeater role"
|
||||
fi
|
||||
done
|
||||
|
||||
RESOLVED_BUILD_TARGETS=(nrf_repeater nrf_repeater_lora_ota_no_external_sensors \
|
||||
nrf_sensor nrf_sensor_lora_ota_no_external_sensors Xiao_nrf52_repeater \
|
||||
Heltec_tower_v2_sdcard_repeater_lora_ota_no_external_sensors)
|
||||
normalize_nrf52_sensor_ota_pair_targets
|
||||
"$tower_primary" "$tower_base" "$tower_legacy")
|
||||
normalize_nrf52_sensor_ota_pair_targets 2> "$test_output/tower-alias-warning"
|
||||
[ "${#RESOLVED_BUILD_TARGETS[@]}" -eq 4 ] \
|
||||
|| fail "ordinary/reduced aliases did not deduplicate into exactly one pair"
|
||||
|
||||
# Do not silently resurrect an incompatible internal recipe if the canonical
|
||||
# SD target is absent from a narrowed/misconfigured build inventory.
|
||||
unset 'PIO_ENV_PLATFORM_BY_NAME[Heltec_tower_v2_sdcard_repeater_lora_ota_no_external_sensors]'
|
||||
RESOLVED_BUILD_TARGETS=("$tower_legacy")
|
||||
if normalize_nrf52_sensor_ota_pair_targets > /dev/null 2>&1; then
|
||||
fail "unavailable SD primary silently fell back to the old internal recipe"
|
||||
fi
|
||||
PIO_ENV_PLATFORM_BY_NAME[$tower_primary]=NRF52_PLATFORM
|
||||
|
||||
# The build overlay must not undefine the exact primary's internal fallback.
|
||||
# Other SD targets retain their old SD-only contract even if metadata happens
|
||||
# to contain the Tower macro; no generic SD layout is made dual-storage.
|
||||
NRF52_OTA_SENSOR_PROFILE=full
|
||||
PIO_CONFIG_JSON='[["env:Heltec_tower_v2_sdcard_repeater_lora_ota_no_external_sensors", [["build_flags", ["-DOTA_SD_STORE=1", "-DOTA_FLASH_STORE=1", "-DOTA_TOWER_AUTO_STORE=1"]]]]]'
|
||||
PLATFORMIO_BUILD_FLAGS=""
|
||||
PLATFORMIO_BUILD_UNFLAGS=""
|
||||
apply_lora_ota_override "$tower_primary"
|
||||
[[ "$PLATFORMIO_BUILD_FLAGS" == *"-DOTA_FLASH_STORE=1"* \
|
||||
&& "$PLATFORMIO_BUILD_FLAGS" == *"-DOTA_SD_STORE=1"* \
|
||||
&& "$PLATFORMIO_BUILD_FLAGS" == *"-DOTA_TOWER_AUTO_STORE=1"* \
|
||||
&& "$PLATFORMIO_BUILD_FLAGS" != *"-UOTA_FLASH_STORE"* \
|
||||
&& "$PLATFORMIO_BUILD_UNFLAGS" != *"OTA_FLASH_STORE"* ]] \
|
||||
|| fail "SD primary overlay disabled its qualified internal fallback"
|
||||
other_sd_target=other_sd_repeater_lora_ota_no_external_sensors
|
||||
PIO_ENV_PLATFORM_BY_NAME[$other_sd_target]=NRF52_PLATFORM
|
||||
PIO_ENV_SD_OTA_BY_NAME[$other_sd_target]=1
|
||||
PIO_CONFIG_JSON='[["env:other_sd_repeater_lora_ota_no_external_sensors", [["build_flags", ["-DOTA_SD_STORE=1", "-DOTA_TOWER_AUTO_STORE=1"]]]]]'
|
||||
PLATFORMIO_BUILD_FLAGS=""
|
||||
PLATFORMIO_BUILD_UNFLAGS=""
|
||||
apply_lora_ota_override "$other_sd_target"
|
||||
[[ "$PLATFORMIO_BUILD_FLAGS" == *"-UOTA_FLASH_STORE"* \
|
||||
&& "$PLATFORMIO_BUILD_FLAGS" != *"-DOTA_TOWER_AUTO_STORE"* \
|
||||
&& "$PLATFORMIO_BUILD_UNFLAGS" == *"-D OTA_FLASH_STORE=1"* ]] \
|
||||
|| fail "unrelated SD target gained the Tower internal fallback"
|
||||
PIO_CONFIG_JSON='[]'
|
||||
NRF52_OTA_SENSOR_PROFILE=""
|
||||
|
||||
for selected in auto standard full; do
|
||||
BUILD_PROFILE_OVERRIDE=$selected
|
||||
BUILD_PROFILE_EXPLICIT=1
|
||||
@@ -93,13 +176,17 @@ FIRMWARE_FILENAME_INFIX=outer
|
||||
SKIP_DECLARED_REDUCTIONS=0
|
||||
NRF52_OTA_SENSOR_PROFILE=""
|
||||
for target in nrf_repeater nrf_room_server nrf_sensor Xiao_nrf52_repeater \
|
||||
Heltec_tower_v2_sdcard_repeater_lora_ota_no_external_sensors; do
|
||||
"$tower_primary" "$tower_base" "$tower_legacy"; do
|
||||
calls=()
|
||||
build_firmware "$target" >/dev/null
|
||||
[ "${#calls[@]}" -eq 2 ] || fail "$target did not build exactly two options"
|
||||
[[ "${calls[0]}" == *:full:full-ota:1:1:1 ]] || fail "$target full flags/OTA requirement are wrong"
|
||||
[[ "${calls[1]}" == *:reduced:reduced-ota:0:0:1 ]] || fail "$target reduced flags/OTA requirement are wrong"
|
||||
[ "${calls[0]%%:*}" = "${calls[1]%%:*}" ] || fail "$target profiles have different OTA IDs"
|
||||
if [[ "$target" == Heltec_tower_v2* ]]; then
|
||||
[ "${calls[0]%%:*}" = "$tower_primary" ] \
|
||||
|| fail "$target profile worker built the obsolete internal identity"
|
||||
fi
|
||||
done
|
||||
[ "$ESP32_FULL_BUILD:$BUILD_PROFILE_EFFECTIVE:$REQUIRE_OTA_UPDATES:$FIRMWARE_FILENAME_INFIX:$NRF52_OTA_SENSOR_PROFILE" = \
|
||||
'1:standard:0:outer:' ] || fail "pair state leaked into other platform/Companion builds"
|
||||
@@ -117,8 +204,6 @@ failed_profile=""
|
||||
calls=()
|
||||
run_resolved_build_targets nrf_repeater nrf_sensor Xiao_nrf52_repeater >/dev/null
|
||||
[ "${#calls[@]}" -eq 6 ] || fail "bulk builds did not publish every pair"
|
||||
test_output=$(mktemp -d)
|
||||
trap 'rm -rf -- "$test_output"' EXIT
|
||||
OUTPUT_DIR=$test_output
|
||||
FIRMWARE_FILENAME_INFIX=""
|
||||
calls=()
|
||||
|
||||
@@ -39,6 +39,8 @@ class OtaSelfServePolicyTest(unittest.TestCase):
|
||||
(("NRF52_PLATFORM=1", "OTA_QSPI_STORE=1"), True, True),
|
||||
(("NRF52_PLATFORM=1", "OTA_SD_STORE=1"), True, True),
|
||||
(("NRF52_PLATFORM=1", "OTA_RAK_AUTO_STORE=1"), False, True),
|
||||
(("NRF52_PLATFORM=1", "OTA_TOWER_AUTO_STORE=1", "OTA_SD_STORE=1",
|
||||
"OTA_FLASH_STORE=1"), False, True),
|
||||
(("NRF52_PLATFORM=1", "OTA_RAK_AUTO_STORE=1",
|
||||
"OTA_QSPI_STORE=1"), False, True),
|
||||
(("NRF52_PLATFORM=1", "OTA_RAK_AUTO_STORE=1",
|
||||
@@ -116,6 +118,7 @@ struct Context {
|
||||
uint8_t autoinstall = AUTOINSTALL_OFF;
|
||||
char hw_id[33] = {};
|
||||
struct Cache { void attach(Store&) {} } sd_cache;
|
||||
Store& sdStagingStore() { return fetch_store; }
|
||||
#include "begin.h"
|
||||
};
|
||||
static bool send(void*, const uint8_t*, uint16_t, bool) { return true; }
|
||||
|
||||
@@ -0,0 +1,326 @@
|
||||
"""Compile production Tower context policy and reboot dispatch on the host."""
|
||||
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
def method(source, signature):
|
||||
start = source.index(signature)
|
||||
opening = source.index("{", start)
|
||||
depth = 1
|
||||
end = opening + 1
|
||||
while depth:
|
||||
depth += (source[end] == "{") - (source[end] == "}")
|
||||
end += 1
|
||||
return source[start:end]
|
||||
|
||||
|
||||
CONTEXT_SETUP = r'''
|
||||
#include <cassert>
|
||||
#include <cstdint>
|
||||
#include <cstring>
|
||||
#define NRF52_PLATFORM 1
|
||||
#define OTA_SD_STORE 1
|
||||
#define OTA_FLASH_STORE 1
|
||||
#define OTA_SD_BOOTLOADER_UPDATE 1
|
||||
#define OTA_TOWER_AUTO_STORE 1
|
||||
#define MOTA_MIGRATION_TARGET_ID 0x77
|
||||
#define MESHCORE_OTA_DEVICE_DEFLATE 1
|
||||
#include "helpers/ota/OtaSelfServePolicy.h"
|
||||
using namespace mesh::ota;
|
||||
using OtaSend = void (*)();
|
||||
using CodecFunction = void (*)();
|
||||
static constexpr uint8_t CODEC_DETOOLS_INPLACE = 2;
|
||||
static constexpr size_t MOTA_MFL = 197;
|
||||
static void ota_transport_inflate() {}
|
||||
static void ota_transport_deflate() {}
|
||||
struct SelfFwInfo {
|
||||
bool valid = false;
|
||||
uint32_t target_id = 0, fw_version = 0;
|
||||
char hw_id[33] = {};
|
||||
};
|
||||
static SelfFwInfo current;
|
||||
static bool ota_self_firmware(SelfFwInfo& out) { out = current; return current.valid; }
|
||||
struct OtaBlCaps { bool valid = true; };
|
||||
static bool ota_bootloader_self_update_caps_valid(const OtaBlCaps& caps) { return caps.valid; }
|
||||
struct OtaStoreSdNrf52 {};
|
||||
struct TowerStore {
|
||||
enum Mode { Unselected, Sd, Internal, Unsafe };
|
||||
Mode mode = Unselected, fitted = Sd;
|
||||
bool usable = true;
|
||||
unsigned selection_calls = 0, probes = 0;
|
||||
OtaStoreSdNrf52 sd;
|
||||
bool selectStorage() {
|
||||
++selection_calls;
|
||||
if (mode == Unselected) { ++probes; mode = fitted; }
|
||||
return usable && mode != Unsafe;
|
||||
}
|
||||
bool usesExternal() const { return mode == Sd; }
|
||||
bool usesInternal() const { return mode == Internal; }
|
||||
OtaStoreSdNrf52& sdStore() { return sd; }
|
||||
void resetSelection() { mode = Unselected; }
|
||||
};
|
||||
struct Cache {
|
||||
OtaStoreSdNrf52* owner = nullptr;
|
||||
unsigned attachments = 0;
|
||||
void attach(OtaStoreSdNrf52& store) { ++attachments; owner = &store; }
|
||||
};
|
||||
struct Manager {
|
||||
bool full = true, bootloader = true, archive = false, enforce_version = false;
|
||||
uint8_t codec = 0xFF;
|
||||
uint32_t target = 0, migration = 0, floor = 0;
|
||||
CodecFunction encoder = ota_transport_deflate, decoder = nullptr;
|
||||
TowerStore* destination = nullptr;
|
||||
const uint8_t* primary = nullptr;
|
||||
unsigned clear_calls = 0, folder_sources = 3;
|
||||
void begin(uint32_t id, OtaSend, void*) { target = id; }
|
||||
void set_auto_migration_target(uint32_t id) { migration = id; }
|
||||
void set_transport_deflate_decoder(CodecFunction fn) { decoder = fn; }
|
||||
void set_transport_deflate_encoder(CodecFunction fn) { encoder = fn; }
|
||||
void set_auto_version_floor(uint32_t version, bool enforce) { floor = version; enforce_version = enforce; }
|
||||
void set_accept_full(bool value) { full = value; }
|
||||
void set_accept_bootloader(bool value) { bootloader = value; }
|
||||
void set_apply_codec(uint8_t value) { codec = value; }
|
||||
void set_fetch_store(TowerStore* value) { destination = value; }
|
||||
void set_archive_interest(bool value) { archive = value; }
|
||||
bool servingPrimaryManifest(const uint8_t* manifest) const { return primary == manifest; }
|
||||
void clear_primary() { ++clear_calls; primary = nullptr; }
|
||||
};
|
||||
struct Context {
|
||||
TowerStore fetch_store;
|
||||
Cache sd_cache;
|
||||
Manager manager;
|
||||
bool fetch_to_folder = true, self_serve_supported = true, serving = false;
|
||||
char hw_id[33] = {};
|
||||
uint8_t serve_self_manifest[MOTA_MFL] = {};
|
||||
OtaBlCaps caps;
|
||||
unsigned caps_reads = 0;
|
||||
const OtaBlCaps& bootloaderUpdateCaps() { ++caps_reads; return caps; }
|
||||
'''
|
||||
|
||||
CONTEXT_CASES = r'''
|
||||
};
|
||||
static void send() {}
|
||||
int main() {
|
||||
// Startup adopts authenticated self identity without selecting/mounting SD
|
||||
// or enabling a receiver capability that depends on the absent probe.
|
||||
current.valid = true; current.target_id = 0x1234; current.fw_version = 0x01170109;
|
||||
strcpy(current.hw_id, "Heltec_tower_v2");
|
||||
Context sd;
|
||||
sd.begin(0x55, send, nullptr, "fallback");
|
||||
assert(sd.fetch_store.probes == 0 && sd.fetch_store.selection_calls == 0);
|
||||
assert(sd.caps_reads == 0 && !sd.manager.full && !sd.manager.bootloader);
|
||||
assert(!sd.self_serve_supported && sd.manager.encoder == nullptr);
|
||||
assert(sd.manager.decoder == ota_transport_inflate && sd.manager.codec == 2);
|
||||
assert(sd.manager.target == 0x1234 && sd.manager.floor == 0x01170109);
|
||||
assert(sd.manager.enforce_version && sd.manager.migration == 0x77);
|
||||
assert(!sd.fetch_to_folder && strcmp(sd.hw_id, "Heltec_tower_v2") == 0);
|
||||
assert(sd.manager.destination == &sd.fetch_store);
|
||||
assert(sd.sd_cache.owner == &sd.fetch_store.sd && sd.sd_cache.attachments == 1);
|
||||
|
||||
assert(sd.prepareTowerStorage());
|
||||
assert(sd.fetch_store.probes == 1 && sd.manager.full && sd.manager.bootloader);
|
||||
assert(sd.self_serve_supported && sd.manager.encoder == ota_transport_deflate);
|
||||
assert(sd.manager.archive && sd.manager.clear_calls == 0);
|
||||
sd.manager.primary = sd.serve_self_manifest; sd.serving = true;
|
||||
// Mid-session disappearance does not change the selected backend or clear
|
||||
// a successfully offered image: the actual store handles subsequent I/O.
|
||||
sd.fetch_store.fitted = TowerStore::Internal;
|
||||
for (unsigned i = 0; i < 5; ++i) assert(sd.prepareTowerStorage());
|
||||
assert(sd.fetch_store.mode == TowerStore::Sd && sd.fetch_store.probes == 1);
|
||||
assert(sd.serving && sd.manager.primary == sd.serve_self_manifest);
|
||||
|
||||
// A legacy app-capable SD loader does not receive privileged packages.
|
||||
sd.caps.valid = false;
|
||||
assert(sd.prepareTowerStorage());
|
||||
assert(sd.manager.full && sd.self_serve_supported && !sd.manager.bootloader);
|
||||
|
||||
// A deliberate selection reset removes only our automatic primary image.
|
||||
// Attached folder sources are independent and must remain available.
|
||||
sd.fetch_store.resetSelection();
|
||||
assert(sd.prepareTowerStorage());
|
||||
assert(sd.fetch_store.mode == TowerStore::Internal && sd.fetch_store.probes == 2);
|
||||
assert(!sd.manager.full && !sd.manager.bootloader && !sd.manager.archive);
|
||||
assert(!sd.self_serve_supported && sd.manager.encoder == nullptr);
|
||||
assert(!sd.serving && sd.manager.primary == nullptr && sd.manager.clear_calls == 1);
|
||||
assert(sd.manager.folder_sources == 3);
|
||||
for (unsigned i = 0; i < 5; ++i) assert(sd.prepareTowerStorage());
|
||||
assert(sd.manager.clear_calls == 1 && sd.fetch_store.probes == 2);
|
||||
|
||||
// `ota dev serve self` deliberately exports an internal-fallback base for
|
||||
// delta capture. Repeated maintenance/status preparation must preserve that
|
||||
// explicit diagnostic offer while automatic self-serving stays disabled.
|
||||
sd.manager.primary = sd.serve_self_manifest; sd.serving = true;
|
||||
for (unsigned i = 0; i < 5; ++i) assert(sd.prepareTowerStorage());
|
||||
assert(sd.serving && sd.manager.primary == sd.serve_self_manifest);
|
||||
assert(!sd.self_serve_supported && sd.manager.encoder == nullptr);
|
||||
assert(!sd.manager.full && !sd.manager.bootloader && !sd.manager.archive);
|
||||
assert(sd.manager.clear_calls == 1 && sd.fetch_store.probes == 2);
|
||||
|
||||
uint8_t manual_manifest[MOTA_MFL] = {};
|
||||
sd.manager.primary = manual_manifest; sd.serving = true;
|
||||
assert(sd.prepareTowerStorage());
|
||||
assert(sd.manager.primary == manual_manifest && sd.serving);
|
||||
assert(sd.manager.clear_calls == 1 && sd.manager.folder_sources == 3);
|
||||
|
||||
// Unknown storage and a failed compatibility check disable every optional
|
||||
// capability even if the store still reports a physically present SD card.
|
||||
Context unsafe;
|
||||
unsafe.fetch_store.fitted = TowerStore::Unsafe;
|
||||
unsafe.begin(0x55, send, nullptr);
|
||||
assert(!unsafe.prepareTowerStorage());
|
||||
assert(!unsafe.manager.full && !unsafe.manager.bootloader && !unsafe.manager.archive);
|
||||
assert(!unsafe.self_serve_supported && unsafe.manager.encoder == nullptr);
|
||||
Context incompatible;
|
||||
incompatible.fetch_store.fitted = TowerStore::Sd;
|
||||
incompatible.fetch_store.usable = false;
|
||||
incompatible.begin(0x55, send, nullptr);
|
||||
assert(!incompatible.prepareTowerStorage());
|
||||
assert(!incompatible.manager.full && !incompatible.manager.bootloader);
|
||||
assert(!incompatible.self_serve_supported && incompatible.manager.encoder == nullptr);
|
||||
|
||||
current = SelfFwInfo();
|
||||
Context legacy;
|
||||
legacy.begin(0x55, send, nullptr, "fallback");
|
||||
assert(legacy.manager.target == 0x55 && legacy.manager.floor == 0);
|
||||
assert(strcmp(legacy.hw_id, "fallback") == 0 && legacy.fetch_store.probes == 0);
|
||||
}
|
||||
'''
|
||||
|
||||
HANDOFF_SETUP = r'''
|
||||
#include <cassert>
|
||||
#include <cstdint>
|
||||
#define OTA_TOWER_AUTO_STORE 1
|
||||
#define OTA_SD_STORE 1
|
||||
#define OTA_FLASH_STORE 1
|
||||
#define OTA_SD_BOOTLOADER_UPDATE 1
|
||||
static constexpr uint8_t GPREGRET2_OTA_STAGE_LEGACY = 0xD4;
|
||||
static constexpr uint8_t GPREGRET2_OTA_STAGE_EXPANDED = 0xED;
|
||||
static constexpr uint8_t GPREGRET2_OTA_STAGE_SD = 0x53;
|
||||
static constexpr uint8_t GPREGRET2_OTA_STAGE_HYBRID = 0xA6;
|
||||
static constexpr uint8_t GPREGRET2_OTA_STAGE_QSPI = 0x51;
|
||||
static constexpr uint8_t GPREGRET_OTA_APPLY = 0x6A;
|
||||
static constexpr uint8_t GPREGRET_OTA_BOOTLOADER_UPDATE = 0x6B;
|
||||
struct Store {
|
||||
bool hybrid = false, authorized = true;
|
||||
unsigned published = 0;
|
||||
bool is_hybrid() const { return hybrid; }
|
||||
bool publish_hybrid_handoff() { ++published; return authorized; }
|
||||
};
|
||||
static Store* g_nrf52_apply_store = nullptr;
|
||||
static uint32_t ceiling = 0xED000;
|
||||
static bool reset_cleared = true;
|
||||
static uint8_t request = 0, source = 0;
|
||||
static unsigned resets = 0;
|
||||
[[maybe_unused]] static uint32_t ota_nrf52_effective_stage_ceiling() { return ceiling; }
|
||||
[[maybe_unused]] static uint8_t mota_nrf52_flash_stage_handoff(uint32_t n) { return n == 0xED000 ? 0xED : 0xD4; }
|
||||
[[maybe_unused]] static bool ota_nrf52_clear_reset_reasons() { return reset_cleared; }
|
||||
[[maybe_unused]] static void ota_nrf52_set_reset_handoff(uint8_t r, uint8_t s) { request = r; source = s; }
|
||||
[[maybe_unused]] static void NVIC_SystemReset() { ++resets; }
|
||||
'''
|
||||
|
||||
HANDOFF_CASES = r'''
|
||||
int main() {
|
||||
ota_reboot_to_apply();
|
||||
assert(request == 0x6A && source == 0x53 && resets == 1);
|
||||
Store internal;
|
||||
g_nrf52_apply_store = &internal;
|
||||
ota_reboot_to_apply();
|
||||
assert(request == 0x6A && source == 0xED && resets == 2);
|
||||
ceiling = 0xD4000;
|
||||
ota_reboot_to_apply();
|
||||
assert(request == 0x6A && source == 0xD4 && resets == 3);
|
||||
g_nrf52_apply_store = nullptr;
|
||||
ota_reboot_to_apply();
|
||||
assert(request == 0x6A && source == 0x53 && resets == 4);
|
||||
// Shared legacy handling remains fail closed if a retained source cannot be
|
||||
// authorized. Tower's qualified application never enables this RAM store.
|
||||
internal.hybrid = true; internal.authorized = false;
|
||||
g_nrf52_apply_store = &internal;
|
||||
ota_reboot_to_apply();
|
||||
assert(request == 0 && source == 0xBD && resets == 5);
|
||||
internal.authorized = true; reset_cleared = false;
|
||||
ota_reboot_to_apply();
|
||||
assert(request == 0 && source == 0xBD && resets == 6 && internal.published == 1);
|
||||
}
|
||||
'''
|
||||
|
||||
|
||||
class TowerAutoIntegrationTest(unittest.TestCase):
|
||||
def compile_run(self, source, expected=True):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
path = Path(directory)
|
||||
cpp = path / "integration.cpp"
|
||||
cpp.write_text(source, encoding="ascii")
|
||||
executable = path / "integration"
|
||||
subprocess.run(["g++", "-std=c++17", "-Wall", "-Wextra", "-Werror",
|
||||
"-I", str(ROOT / "src"), str(cpp), "-o", str(executable)],
|
||||
check=True, timeout=30)
|
||||
result = subprocess.run([str(executable)], cwd=path, capture_output=True,
|
||||
text=True, timeout=10)
|
||||
self.assertEqual(result.returncode == 0, expected, result.stderr)
|
||||
if not expected:
|
||||
self.assertIn("Assertion", result.stderr)
|
||||
|
||||
def test_production_context_startup_and_deferred_storage_policy(self):
|
||||
context = (ROOT / "src/helpers/ota/OtaContext.h").read_text()
|
||||
methods = "\n".join(method(context, signature) for signature in (
|
||||
"OtaStoreSdNrf52& sdStagingStore()",
|
||||
"bool prepareTowerStorage()",
|
||||
"void begin(uint32_t target_id, OtaSend send, void* ctx",
|
||||
))
|
||||
source = CONTEXT_SETUP + methods + CONTEXT_CASES
|
||||
self.compile_run(source)
|
||||
# Negative controls prove the fixture catches the original startup
|
||||
# probe hazard and policy that accidentally advertises incompatible SD.
|
||||
eager = methods.replace("manager.begin(target_id, send, ctx);",
|
||||
"fetch_store.selectStorage(); manager.begin(target_id, send, ctx);")
|
||||
self.compile_run(CONTEXT_SETUP + eager + CONTEXT_CASES, expected=False)
|
||||
permissive = methods.replace("usable && fetch_store.usesExternal()",
|
||||
"fetch_store.usesExternal()")
|
||||
self.compile_run(CONTEXT_SETUP + permissive + CONTEXT_CASES, expected=False)
|
||||
# The old unconditional cleanup erased explicit internal diagnostic
|
||||
# offers on the very next maintenance loop, blocking base capture.
|
||||
unconditional_clear = methods.replace(
|
||||
" const bool was_self_serve_supported = self_serve_supported;\n", "").replace(
|
||||
"was_self_serve_supported && !self_serve_supported &&",
|
||||
"!self_serve_supported &&")
|
||||
self.assertNotEqual(unconditional_clear, methods)
|
||||
self.compile_run(CONTEXT_SETUP + unconditional_clear + CONTEXT_CASES,
|
||||
expected=False)
|
||||
|
||||
def test_production_reset_handoff_follows_approved_application_store(self):
|
||||
apply = (ROOT / "src/helpers/ota/OtaApply.cpp").read_text()
|
||||
production = method(apply, "void ota_reboot_to_apply() { // public:")
|
||||
self.compile_run(HANDOFF_SETUP + production + HANDOFF_CASES)
|
||||
old_dispatch = production.replace(
|
||||
"#if defined(OTA_RAK_AUTO_STORE) || defined(OTA_TOWER_AUTO_STORE)",
|
||||
"#if defined(OTA_RAK_AUTO_STORE)", 1)
|
||||
# Removing Tower from adaptive dispatch must break the runtime fixture;
|
||||
# it would always request SD even after internal-flash approval.
|
||||
self.compile_run(HANDOFF_SETUP + old_dispatch + HANDOFF_CASES, expected=False)
|
||||
|
||||
def test_selection_precedes_live_resume_and_cli_codec_checks(self):
|
||||
mesh = (ROOT / "src/Mesh.cpp").read_text()
|
||||
maintenance = method(mesh, "void __attribute__((noinline)) Mesh::serviceLoopMaintenance()")
|
||||
self.assertLess(maintenance.index("prepareTowerStorage()"),
|
||||
maintenance.index("resumeStaged(nullptr)"))
|
||||
cli = (ROOT / "src/helpers/ota/OtaCli.cpp").read_text()
|
||||
self.assertLess(cli.index("c.prepareTowerStorage()"), cli.index("c.manager.codecOk(selcodec)"))
|
||||
context = (ROOT / "src/helpers/ota/OtaContext.h").read_text()
|
||||
boot_apply = method(context, "bool apply_fetched_bootloader(")
|
||||
self.assertIn("if (!fetch_store.usesExternal())", boot_apply)
|
||||
self.assertIn("fetch_store.sdStore(), allow", boot_apply)
|
||||
apply = (ROOT / "src/helpers/ota/OtaApply.cpp").read_text()
|
||||
sd_apply = method(apply, "bool ota_apply_mota_nrf52(OtaStoreSdNrf52& store")
|
||||
self.assertLess(sd_apply.index("g_nrf52_apply_store = nullptr"),
|
||||
sd_apply.index("ota_apply_mota_nrf52_external"))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,440 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Run the MeshTower selector with peripheral stores and the real manifest parser."""
|
||||
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
import subprocess
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
OTA = ROOT / "src/helpers/ota"
|
||||
FLAGS = ["-DNRF52_PLATFORM=1", "-DHELTEC_TOWER_V2_SDCARD=1",
|
||||
"-DOTA_SD_STORE=1", "-DOTA_FLASH_STORE=1", "-DOTA_TOWER_AUTO_STORE=1"]
|
||||
|
||||
CAPS = r'''
|
||||
#pragma once
|
||||
#include <stdint.h>
|
||||
namespace mesh { namespace ota {
|
||||
static const uint8_t OTA_BL_STORAGE_SD = 1;
|
||||
static const uint8_t OTA_BL_STORAGE_STAGE_CEILING = 2;
|
||||
struct OtaBlCaps {
|
||||
bool present = false;
|
||||
uint16_t apply_abi = 3, codec_mask = 5;
|
||||
uint8_t storage_flags = 9, optional_app_storage = 2;
|
||||
};
|
||||
extern OtaBlCaps installed;
|
||||
extern int cap_reads;
|
||||
inline OtaBlCaps ota_bootloader_app_caps() { ++cap_reads; return installed; }
|
||||
} }
|
||||
'''
|
||||
|
||||
STORES = r'''
|
||||
#pragma once
|
||||
#include "OtaStore.h"
|
||||
#include <string.h>
|
||||
namespace mesh {
|
||||
class MainBoard {};
|
||||
namespace ota {
|
||||
struct Medium {
|
||||
int constructors = 0, destructors = 0, probes = 0;
|
||||
int begin_calls = 0, plan_calls = 0, read_calls = 0, write_calls = 0;
|
||||
int clear_calls = 0, discard_calls = 0, reopen_calls = 0;
|
||||
int meta_calls = 0, finalize_calls = 0, checkpoints = 0;
|
||||
int format_calls = 0, erase_calls = 0, space_calls = 0, list_calls = 0;
|
||||
bool present = true, mountable = true, begin_ok = true, layout_ok = true;
|
||||
bool io_ok = true, discard_ok = true, staged = false;
|
||||
uint32_t total = 0, last_target = 0;
|
||||
const uint8_t* last_mid = nullptr;
|
||||
uint8_t bytes[512] = {0};
|
||||
};
|
||||
extern Medium sd_medium, flash_medium;
|
||||
extern int auth_clears;
|
||||
class FakeStore : public OtaStore {
|
||||
protected:
|
||||
Medium& media;
|
||||
uint32_t total = 0;
|
||||
public:
|
||||
explicit FakeStore(Medium& m) : media(m) { ++media.constructors; }
|
||||
~FakeStore() override { ++media.destructors; }
|
||||
bool begin(uint32_t n) override {
|
||||
++media.begin_calls;
|
||||
total = 0;
|
||||
if (!media.present || !media.begin_ok) return false;
|
||||
total = media.total = n; media.staged = true; return true;
|
||||
}
|
||||
bool write(uint32_t p, const uint8_t* d, uint32_t n) override {
|
||||
++media.write_calls;
|
||||
if (!media.present || !media.io_ok || p + n > total || p + n > sizeof(media.bytes)) return false;
|
||||
memcpy(media.bytes + p, d, n); return true;
|
||||
}
|
||||
bool read(uint32_t p, uint8_t* d, uint32_t n) const override {
|
||||
++media.read_calls;
|
||||
if (!media.present || !media.io_ok || p + n > total || p + n > sizeof(media.bytes)) return false;
|
||||
memcpy(d, media.bytes + p, n); return true;
|
||||
}
|
||||
uint32_t capacity() const override { return media.present ? 8192u : 0u; }
|
||||
uint32_t staged_size() const override { return total; }
|
||||
void clear() override { ++media.clear_calls; total = 0; }
|
||||
bool discard() override {
|
||||
++media.discard_calls; total = 0;
|
||||
if (!media.present || !media.discard_ok) return false;
|
||||
media.staged = false; media.total = 0; return true;
|
||||
}
|
||||
bool set_meta_size(uint32_t) override { ++media.meta_calls; return media.io_ok; }
|
||||
bool finalize() override { ++media.finalize_calls; return media.present && media.io_ok; }
|
||||
void checkpoint() override { ++media.checkpoints; }
|
||||
bool reopen() override {
|
||||
++media.reopen_calls;
|
||||
total = media.present && media.staged ? media.total : 0;
|
||||
return total != 0;
|
||||
}
|
||||
bool reopenFor(const uint8_t* mid, uint32_t target) override {
|
||||
media.last_mid = mid; media.last_target = target; return reopen();
|
||||
}
|
||||
bool plan_layout(bool, uint32_t, uint32_t, uint32_t, bool) override {
|
||||
++media.plan_calls; return media.layout_ok;
|
||||
}
|
||||
};
|
||||
class OtaStoreSdNrf52 : public FakeStore {
|
||||
public:
|
||||
OtaStoreSdNrf52() : FakeStore(sd_medium) { ++auth_clears; }
|
||||
bool probeMedia() { ++media.probes; return media.present && media.mountable; }
|
||||
void clear() override { ++media.clear_calls; total = 0; media.staged = false; }
|
||||
const char* last_error() const { return "SD peripheral error"; }
|
||||
bool formatCard(MainBoard&) { ++media.format_calls; return media.present; }
|
||||
bool eraseCard(MainBoard&) { ++media.erase_calls; return media.present; }
|
||||
bool getSpace(MainBoard&, uint64_t& used, uint64_t& free) {
|
||||
++media.space_calls; used = 1; free = 2; return media.present;
|
||||
}
|
||||
bool listFiles(MainBoard&, uint16_t, char*, size_t) { ++media.list_calls; return media.present; }
|
||||
};
|
||||
class OtaStoreFlashNrf52 : public FakeStore {
|
||||
public:
|
||||
OtaStoreFlashNrf52() : FakeStore(flash_medium) { ++auth_clears; }
|
||||
};
|
||||
} }
|
||||
'''
|
||||
|
||||
DRIVER = r'''
|
||||
#include "OtaStoreTowerNrf52.h"
|
||||
#include "OtaByteIO.h"
|
||||
#include <assert.h>
|
||||
#include <string.h>
|
||||
using namespace mesh::ota;
|
||||
namespace mesh { namespace ota {
|
||||
OtaBlCaps installed;
|
||||
Medium sd_medium, flash_medium;
|
||||
int cap_reads = 0, auth_clears = 0;
|
||||
} }
|
||||
|
||||
static void defaults() {
|
||||
installed.present = true;
|
||||
installed.storage_flags = 9;
|
||||
installed.optional_app_storage = 2;
|
||||
}
|
||||
static void manifest(Medium& m, uint8_t flags = 0) {
|
||||
m.staged = true;
|
||||
memset(m.bytes, 0, sizeof(m.bytes));
|
||||
uint8_t* p = m.bytes + 8;
|
||||
const bool boot = flags & MFLAG_BOOTLOADER;
|
||||
p[0] = boot ? MOTA_BOOT_FORMAT_VER : MOTA_APP_FORMAT_VER;
|
||||
p[1] = flags; p[2] = HASH_ALGO_SHA256;
|
||||
wr_u32le(p + 3, 11); wr_u32le(p + 7, 1);
|
||||
wr_u32le(p + 11, boot ? 0xA000u : 1024u);
|
||||
wr_u32le(p + 15, boot ? 0xA000u : 64u);
|
||||
p[19] = 10;
|
||||
p[20] = 1; p[21] = 2; p[22] = 3; p[23] = 4;
|
||||
p[56] = flags & MFLAG_FULL ? CODEC_FULL : CODEC_DETOOLS_INPLACE;
|
||||
m.total = boot ? 41330u : 278u;
|
||||
}
|
||||
static void startup() {
|
||||
OtaStoreTowerNrf52 store;
|
||||
assert(sd_medium.constructors == 1 && flash_medium.constructors == 0 && auth_clears == 1);
|
||||
OtaStoreSdNrf52* owner = &store.sdStore();
|
||||
assert(owner == &store.externalStore());
|
||||
assert(!store.usesExternalStore() && !store.usesExternal() && !store.usesInternal());
|
||||
assert(!store.compatible() && store.capacity() == 0 && store.staged_size() == 0);
|
||||
uint8_t byte = 0;
|
||||
assert(!store.read(0, &byte, 1) && !store.write(0, &byte, 1));
|
||||
assert(!store.set_meta_size(1) && !store.finalize());
|
||||
store.checkpoint(); store.clear(); store.resetSelection();
|
||||
assert(owner == &store.sdStore());
|
||||
assert(sd_medium.probes == 0 && cap_reads == 0 && flash_medium.constructors == 0);
|
||||
assert(sd_medium.clear_calls == 0 && sd_medium.reopen_calls == 0);
|
||||
}
|
||||
static void primary() {
|
||||
defaults(); OtaStoreTowerNrf52 store;
|
||||
assert(store.selectStorage() && store.usesExternalStore() && store.compatible());
|
||||
assert(sd_medium.probes == 1 && sd_medium.reopen_calls == 0 && flash_medium.constructors == 0);
|
||||
assert(store.plan_layout(true, 1024, 209, 64, false));
|
||||
assert(store.begin(278));
|
||||
uint8_t data[4] = {1, 2, 3, 4}, got[4] = {0};
|
||||
assert(store.write(0, data, 4) && store.read(0, got, 4) && memcmp(data, got, 4) == 0);
|
||||
assert(store.set_meta_size(16) && store.finalize()); store.checkpoint();
|
||||
assert(store.staged_size() == 278 && store.capacity() == 8192);
|
||||
assert(store.selectStorage() && sd_medium.probes == 1 && cap_reads == 1);
|
||||
assert(sd_medium.begin_calls == 1 && sd_medium.meta_calls == 1 && sd_medium.finalize_calls == 1);
|
||||
assert(sd_medium.checkpoints == 1 && flash_medium.begin_calls == 0 && auth_clears == 1);
|
||||
assert(store.plan_layout(true, 0xA000, 365, 0xA000, true));
|
||||
}
|
||||
static void fallback(bool unmountable) {
|
||||
defaults(); sd_medium.present = unmountable; sd_medium.mountable = false;
|
||||
OtaStoreTowerNrf52 store;
|
||||
assert(store.selectStorage() && store.usesInternal() && !store.usesExternal());
|
||||
assert(flash_medium.constructors == 1 && auth_clears == 2);
|
||||
assert(!store.begin(278) && flash_medium.begin_calls == 0);
|
||||
assert(!store.plan_layout(true, 1024, 209, 64, false));
|
||||
assert(!store.begin(278) && flash_medium.begin_calls == 0);
|
||||
assert(!store.plan_layout(true, 0xA000, 365, 0xA000, true));
|
||||
assert(!store.begin(41330) && flash_medium.begin_calls == 0);
|
||||
assert(store.plan_layout(false, 1024, 209, 64, false) && store.begin(278));
|
||||
assert(flash_medium.plan_calls == 1 && flash_medium.begin_calls == 1);
|
||||
uint8_t data = 7, got = 0;
|
||||
assert(store.write(0, &data, 1) && store.read(0, &got, 1) && got == data);
|
||||
assert(store.set_meta_size(16) && store.finalize()); store.checkpoint();
|
||||
sd_medium.present = sd_medium.mountable = true;
|
||||
assert(store.selectStorage() && store.usesInternal() && sd_medium.probes == 1);
|
||||
assert(sd_medium.begin_calls == 0 && sd_medium.plan_calls == 0);
|
||||
store.clear();
|
||||
assert(store.usesInternal() && !store.begin(278) && flash_medium.begin_calls == 1);
|
||||
assert(store.plan_layout(false, 1024, 209, 64, false) && store.begin(278));
|
||||
}
|
||||
static void old_or_unqualified() {
|
||||
defaults(); sd_medium.present = false;
|
||||
for (uint8_t option : {0u, 1u, 4u, 6u}) {
|
||||
installed.optional_app_storage = option;
|
||||
OtaStoreTowerNrf52 store;
|
||||
assert(!store.selectStorage() && !store.usesInternal() && !store.usesExternal());
|
||||
assert(!store.begin(278) && store.capacity() == 0);
|
||||
}
|
||||
installed.optional_app_storage = 2; installed.storage_flags = 2;
|
||||
{ OtaStoreTowerNrf52 store; assert(!store.selectStorage()); }
|
||||
installed.storage_flags = 9; installed.present = false;
|
||||
{ OtaStoreTowerNrf52 store; assert(!store.selectStorage()); }
|
||||
assert(flash_medium.constructors == 0 && flash_medium.begin_calls == 0);
|
||||
installed.present = true; installed.optional_app_storage = 0; sd_medium.present = true;
|
||||
{ OtaStoreTowerNrf52 store; assert(store.selectStorage() && store.usesExternal()); }
|
||||
installed.present = false;
|
||||
{ OtaStoreTowerNrf52 store;
|
||||
assert(!store.selectStorage() && store.usesExternal() && !store.compatible());
|
||||
assert(!store.begin(278)); }
|
||||
}
|
||||
static void failures() {
|
||||
defaults(); OtaStoreTowerNrf52 store;
|
||||
assert(store.selectStorage());
|
||||
sd_medium.layout_ok = false;
|
||||
assert(!store.plan_layout(false, 1024, 209, 64, false) && !store.begin(278));
|
||||
assert(sd_medium.begin_calls == 0 && store.usesExternal() && flash_medium.constructors == 0);
|
||||
sd_medium.layout_ok = true; sd_medium.begin_ok = false;
|
||||
assert(store.plan_layout(false, 1024, 209, 64, false) && !store.begin(278));
|
||||
assert(sd_medium.begin_calls == 1 && flash_medium.constructors == 0);
|
||||
sd_medium.begin_ok = true; assert(store.begin(278));
|
||||
sd_medium.present = false; uint8_t byte = 0;
|
||||
assert(!store.write(0, &byte, 1) && !store.read(0, &byte, 1) && !store.finalize());
|
||||
assert(store.usesExternal() && store.selectStorage() && sd_medium.probes == 1);
|
||||
assert(flash_medium.constructors == 0 && !store.discard());
|
||||
store.resetSelection();
|
||||
assert(store.selectStorage() && store.usesInternal() && flash_medium.constructors == 1);
|
||||
}
|
||||
static void resume(bool internal) {
|
||||
defaults(); sd_medium.present = !internal;
|
||||
Medium& m = internal ? flash_medium : sd_medium;
|
||||
manifest(m);
|
||||
OtaStoreTowerNrf52 store;
|
||||
uint8_t good[4] = {1, 2, 3, 4}, wrong[4] = {9, 9, 9, 9};
|
||||
assert(!store.reopenFor(wrong, 11) && store.staged_size() == 0);
|
||||
assert(m.staged && m.clear_calls == 0 && m.discard_calls == 0);
|
||||
assert(!store.reopenFor(nullptr, 22) && store.staged_size() == 0);
|
||||
assert(store.reopenFor(good, 11) && store.staged_size() == m.total);
|
||||
if (internal) assert(m.last_mid == good && m.last_target == 11);
|
||||
assert(store.reopen() && store.staged_size() == m.total);
|
||||
manifest(m, MFLAG_FULL);
|
||||
assert(store.reopen() == !internal);
|
||||
manifest(m, MFLAG_FULL | MFLAG_SIGNED | MFLAG_BOOTLOADER);
|
||||
assert(store.reopen() == !internal);
|
||||
m.bytes[8] = 99;
|
||||
assert(!store.reopen());
|
||||
assert(m.staged && m.clear_calls == 0 && m.discard_calls == 0);
|
||||
assert(sd_medium.probes == 1);
|
||||
}
|
||||
static void cancellation() {
|
||||
defaults(); sd_medium.present = false;
|
||||
OtaStoreTowerNrf52 store; OtaStoreSdNrf52* owner = &store.sdStore();
|
||||
assert(store.plan_layout(false, 1024, 209, 64, false) && store.begin(278));
|
||||
store.clear(); assert(store.staged_size() == 0 && flash_medium.staged);
|
||||
manifest(flash_medium); assert(store.reopen());
|
||||
flash_medium.discard_ok = false; assert(!store.discard() && flash_medium.staged);
|
||||
flash_medium.discard_ok = true; assert(store.discard() && !flash_medium.staged);
|
||||
sd_medium.present = true; store.resetSelection();
|
||||
assert(flash_medium.destructors == 1 && owner == &store.sdStore());
|
||||
assert(store.capacity() == 0 && store.selectStorage() && store.usesExternal());
|
||||
assert(store.begin(278)); store.clear();
|
||||
assert(store.usesExternal() && !sd_medium.staged && sd_medium.clear_calls == 1);
|
||||
assert(sd_medium.probes == 2);
|
||||
}
|
||||
static void management() {
|
||||
defaults(); sd_medium.present = false; OtaStoreTowerNrf52 store;
|
||||
assert(store.selectStorage() && store.usesInternal());
|
||||
mesh::MainBoard board; uint64_t used = 0, free = 0; char out[20];
|
||||
assert(!store.formatCard(board) && !store.eraseCard(board));
|
||||
assert(!store.getSpace(board, used, free) && !store.listFiles(board, 0, out, sizeof(out)));
|
||||
assert(sd_medium.format_calls == 1 && sd_medium.erase_calls == 1);
|
||||
assert(sd_medium.space_calls == 1 && sd_medium.list_calls == 1);
|
||||
sd_medium.present = true;
|
||||
assert(store.formatCard(board) && store.eraseCard(board));
|
||||
assert(store.getSpace(board, used, free) && used == 1 && free == 2);
|
||||
assert(store.listFiles(board, 1, out, sizeof(out)) && store.usesInternal());
|
||||
assert(flash_medium.begin_calls == 0 && sd_medium.probes == 1);
|
||||
}
|
||||
int main(int argc, char** argv) {
|
||||
assert(argc == 2);
|
||||
if (!strcmp(argv[1], "startup")) startup();
|
||||
else if (!strcmp(argv[1], "primary")) primary();
|
||||
else if (!strcmp(argv[1], "missing")) fallback(false);
|
||||
else if (!strcmp(argv[1], "unmountable")) fallback(true);
|
||||
else if (!strcmp(argv[1], "legacy")) old_or_unqualified();
|
||||
else if (!strcmp(argv[1], "failures")) failures();
|
||||
else if (!strcmp(argv[1], "resume-sd")) resume(false);
|
||||
else if (!strcmp(argv[1], "resume-internal")) resume(true);
|
||||
else if (!strcmp(argv[1], "cancel")) cancellation();
|
||||
else if (!strcmp(argv[1], "management")) management();
|
||||
else return 2;
|
||||
}
|
||||
'''
|
||||
|
||||
PROBE = r'''
|
||||
#include "OtaStoreSdNrf52.h"
|
||||
#include <assert.h>
|
||||
#include <stdlib.h>
|
||||
using namespace mesh::ota;
|
||||
static bool mountable;
|
||||
static unsigned mounts, capacity_calls;
|
||||
static uint32_t sectors;
|
||||
namespace mesh { namespace ota {
|
||||
OtaStoreSdNrf52::OtaStoreSdNrf52() {}
|
||||
OtaStoreSdNrf52::~OtaStoreSdNrf52() {}
|
||||
bool OtaStoreSdNrf52::mount() { ++mounts; return mountable; }
|
||||
uint32_t OtaStoreSdNrf52::capacity() const { ++capacity_calls; return sectors * 512u; }
|
||||
bool OtaStoreSdNrf52::begin(uint32_t) { abort(); }
|
||||
bool OtaStoreSdNrf52::write(uint32_t, const uint8_t*, uint32_t) { abort(); }
|
||||
bool OtaStoreSdNrf52::read(uint32_t, uint8_t*, uint32_t) const { abort(); }
|
||||
void OtaStoreSdNrf52::clear() { abort(); }
|
||||
bool OtaStoreSdNrf52::discard() { abort(); }
|
||||
bool OtaStoreSdNrf52::set_meta_size(uint32_t) { abort(); }
|
||||
bool OtaStoreSdNrf52::finalize() { abort(); }
|
||||
void OtaStoreSdNrf52::checkpoint() { abort(); }
|
||||
bool OtaStoreSdNrf52::reopen() { abort(); }
|
||||
bool OtaStoreSdNrf52::plan_layout(bool, uint32_t, uint32_t, uint32_t, bool) { abort(); }
|
||||
} }
|
||||
int main() {
|
||||
OtaStoreSdNrf52 store;
|
||||
assert(mounts == 0 && capacity_calls == 0);
|
||||
mountable = false; sectors = 10;
|
||||
assert(!store.probeMedia() && mounts == 1 && capacity_calls == 0);
|
||||
mountable = true; sectors = 0;
|
||||
assert(!store.probeMedia() && mounts == 2 && capacity_calls == 1);
|
||||
sectors = 10;
|
||||
assert(store.probeMedia() && mounts == 3 && capacity_calls == 2);
|
||||
}
|
||||
'''
|
||||
|
||||
|
||||
class TowerAutoStorageTest(unittest.TestCase):
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
cls.compiler = shutil.which("c++")
|
||||
if not cls.compiler:
|
||||
raise unittest.SkipTest("C++ compiler unavailable")
|
||||
cls.temp = tempfile.TemporaryDirectory(prefix="tower-auto-storage-")
|
||||
cls.work = Path(cls.temp.name)
|
||||
for name in ("OtaStore.h", "OtaFormat.h", "MotaContainer.h", "OtaByteIO.h"):
|
||||
shutil.copyfile(OTA / name, cls.work / name)
|
||||
(cls.work / "OtaBlInfo.h").write_text(CAPS)
|
||||
(cls.work / "stores.h").write_text(STORES)
|
||||
for name in ("OtaStoreFlashNrf52.h", "OtaStoreSdNrf52.h"):
|
||||
(cls.work / name).write_text('#include "stores.h"\n')
|
||||
(cls.work / "Utils.h").write_text("""
|
||||
#pragma once
|
||||
#include <stddef.h>
|
||||
#include <stdint.h>
|
||||
namespace mesh { class Utils { public:
|
||||
static void sha256(uint8_t*, size_t, const uint8_t*, int);
|
||||
static void sha256(uint8_t*, size_t, const uint8_t*, int, const uint8_t*, int);
|
||||
}; }
|
||||
""")
|
||||
(cls.work / "test.cpp").write_text('#include <initializer_list>\n' + DRIVER)
|
||||
cls.header = (OTA / "OtaStoreTowerNrf52.h").read_text()
|
||||
cls.exe = cls.compile(cls.header, "selector")
|
||||
|
||||
@classmethod
|
||||
def tearDownClass(cls):
|
||||
cls.temp.cleanup()
|
||||
|
||||
@classmethod
|
||||
def compile(cls, header, name):
|
||||
(cls.work / "OtaStoreTowerNrf52.h").write_text(header)
|
||||
exe = cls.work / name
|
||||
subprocess.run([cls.compiler, "-std=c++11", "-Wall", "-Wextra", "-Werror",
|
||||
"-ffunction-sections", "-fdata-sections", "-Wl,--gc-sections",
|
||||
*FLAGS, "-I", str(cls.work), "-I", str(OTA),
|
||||
str(cls.work / "test.cpp"), str(OTA / "MotaContainer.cpp"),
|
||||
"-o", str(exe)], check=True, capture_output=True, text=True)
|
||||
return exe
|
||||
|
||||
def test_selection_and_transfer_boundaries(self):
|
||||
for scenario in ("startup", "primary", "missing", "unmountable", "legacy",
|
||||
"failures", "resume-sd", "resume-internal", "cancel", "management"):
|
||||
with self.subTest(scenario=scenario):
|
||||
subprocess.run([str(self.exe), scenario], check=True, capture_output=True)
|
||||
|
||||
def test_exact_real_sd_probe_never_opens_or_removes_files(self):
|
||||
# Keep the source away from the selector's peripheral doubles so its
|
||||
# quoted include resolves to the real production SD header.
|
||||
probe_dir = self.work / "real-probe"
|
||||
probe_dir.mkdir()
|
||||
source = probe_dir / "probe.cpp"
|
||||
source.write_text(PROBE)
|
||||
exe = self.work / "probe"
|
||||
subprocess.run([self.compiler, "-std=c++11", "-Wall", "-Wextra", "-Werror",
|
||||
"-DNRF52_PLATFORM=1", "-DOTA_SD_STORE=1", "-I", str(OTA),
|
||||
str(source), "-o", str(exe)], check=True, capture_output=True, text=True)
|
||||
subprocess.run([str(exe)], check=True, capture_output=True)
|
||||
|
||||
def test_unsupported_recipe_cannot_enable_tower_selection(self):
|
||||
(self.work / "OtaStoreTowerNrf52.h").write_text(self.header)
|
||||
source = self.work / "guard.cpp"
|
||||
source.write_text('#include "OtaStoreTowerNrf52.h"\n')
|
||||
for missing in FLAGS[:-1]:
|
||||
with self.subTest(missing=missing):
|
||||
flags = [flag for flag in FLAGS if flag != missing]
|
||||
result = subprocess.run([self.compiler, "-std=c++11", "-fsyntax-only",
|
||||
*flags, "-I", str(self.work), str(source)],
|
||||
capture_output=True, text=True)
|
||||
self.assertNotEqual(result.returncode, 0)
|
||||
self.assertIn("requires the qualified nRF52 SD primary", result.stderr)
|
||||
|
||||
def test_negative_controls_expose_unsafe_selection_and_resume(self):
|
||||
mutations = [
|
||||
("startup-probe", "OtaStoreTowerNrf52() = default;",
|
||||
"OtaStoreTowerNrf52() { selectStorage(); }", "startup"),
|
||||
("legacy-fallback", "sd_profile && caps.optional_app_storage == OTA_BL_STORAGE_STAGE_CEILING",
|
||||
"sd_profile", "legacy"),
|
||||
("internal-full", "_mode == INTERNAL_MODE && (full || bootloader)",
|
||||
"false", "missing"),
|
||||
("missing-layout", "_mode == INTERNAL_MODE && !_layout_accepted",
|
||||
"false", "missing"),
|
||||
("resume-identity", "reopened && resumeMatches(mid, target)",
|
||||
"reopened", "resume-sd"),
|
||||
]
|
||||
for name, old, new, scenario in mutations:
|
||||
with self.subTest(mutation=name):
|
||||
self.assertEqual(self.header.count(old), 1)
|
||||
exe = self.compile(self.header.replace(old, new), name)
|
||||
result = subprocess.run([str(exe), scenario], capture_output=True)
|
||||
self.assertNotEqual(result.returncode, 0)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,335 @@
|
||||
"""Check actual nRF52 capability parsing for SD-primary Tower storage."""
|
||||
|
||||
from pathlib import Path
|
||||
import struct
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(ROOT / "tools/mota"))
|
||||
import motalib as ml
|
||||
from test_mota import _generic_bootloader_image, _rewrite_boot_image
|
||||
|
||||
|
||||
SOURCE = r'''
|
||||
#include <cassert>
|
||||
#include <cstring>
|
||||
#include <sys/mman.h>
|
||||
#include "helpers/ota/OtaBlInfo.h"
|
||||
|
||||
using namespace mesh::ota;
|
||||
|
||||
static const uint8_t bundle[32] = {
|
||||
'M','O','T','A','R','A','M','A',1,0,72,0,0,0,1,0,
|
||||
'M','O','T','A','S','T','O','R',1,0,16,0,2,0,0,0,
|
||||
};
|
||||
|
||||
static void imageFor(uint8_t* image, uint8_t primary, uint8_t optional) {
|
||||
memset(image, 0, 256);
|
||||
const uint8_t boot[16] = {
|
||||
'M','O','T','A','B','L','D','R',3,0,5,0,0,0,0,0,
|
||||
};
|
||||
memcpy(image, boot, sizeof(boot));
|
||||
image[12] = primary;
|
||||
if (optional) {
|
||||
memcpy(image + 32, bundle, sizeof(bundle));
|
||||
image[60] = optional;
|
||||
}
|
||||
}
|
||||
|
||||
static void expectViews(const uint8_t* image, uint8_t primary,
|
||||
uint8_t application, uint8_t optional) {
|
||||
const OtaBlCaps app = ota_bl_app_caps_scan(image, 256);
|
||||
assert(app.present && app.storage_flags == application);
|
||||
assert(app.optional_app_storage == optional);
|
||||
const OtaBlCaps update = ota_bl_update_caps_scan_aligned(image, 256, primary);
|
||||
assert(update.present && update.storage_flags == primary);
|
||||
assert(update.optional_app_storage == optional);
|
||||
}
|
||||
|
||||
int main() {
|
||||
uint8_t image[256];
|
||||
imageFor(image, 0x09, 0x02);
|
||||
expectViews(image, 0x09, 0x0B, 0x02);
|
||||
assert(ota_bootloader_supports_expanded_stage(ota_bl_app_caps_scan(image, 256)));
|
||||
assert(ota_nrf52_effective_stage_ceiling(ota_bl_app_caps_scan(image, 256)) == 0xED000);
|
||||
|
||||
// An ordinary SD loader remains valid for SD and privileged SD upgrades,
|
||||
// but its primary marker must never be mistaken for internal support.
|
||||
imageFor(image, 0x09, 0);
|
||||
expectViews(image, 0x09, 0x09, 0);
|
||||
assert(!ota_bootloader_supports_expanded_stage(ota_bl_app_caps_scan(image, 256)));
|
||||
assert(ota_nrf52_effective_stage_ceiling(ota_bl_app_caps_scan(image, 256)) == 0xD4000);
|
||||
|
||||
// RAK keeps the deployed internal privileged contract and optional NOR.
|
||||
imageFor(image, 0x0A, 0x14);
|
||||
expectViews(image, 0x0A, 0x1E, 0x14);
|
||||
imageFor(image, 0x0A, 0);
|
||||
expectViews(image, 0x0A, 0x0A, 0);
|
||||
|
||||
// Optional records are meaningful only under their exact primary profile.
|
||||
for (uint8_t primary : {uint8_t(0x09), uint8_t(0x0A), uint8_t(0x0E), uint8_t(0x1E)}) {
|
||||
for (uint8_t optional : {uint8_t(0x02), uint8_t(0x14)}) {
|
||||
imageFor(image, primary, optional);
|
||||
const bool qualified = (primary == 0x09 && optional == 2) ||
|
||||
(primary == 0x0A && optional == 0x14);
|
||||
expectViews(image, primary, qualified ? primary | optional : primary,
|
||||
qualified ? optional : 0);
|
||||
}
|
||||
}
|
||||
|
||||
// Every field of the frozen adjacent bundle is enforced. A malformed
|
||||
// optional declaration cannot remove the still-valid SD primary profile.
|
||||
for (unsigned byte = 0; byte < sizeof(bundle); ++byte) {
|
||||
imageFor(image, 0x09, 0x02);
|
||||
image[32 + byte] ^= 1;
|
||||
expectViews(image, 0x09, 0x09, 0);
|
||||
}
|
||||
imageFor(image, 0x09, 0x02);
|
||||
memcpy(image + 80, bundle, sizeof(bundle));
|
||||
expectViews(image, 0x09, 0x09, 0); // duplicate RAM and STOR
|
||||
imageFor(image, 0x09, 0x02);
|
||||
memcpy(image + 80, bundle, 16);
|
||||
expectViews(image, 0x09, 0x09, 0); // duplicate RAM alone
|
||||
imageFor(image, 0x09, 0x02);
|
||||
memcpy(image + 80, bundle + 16, 16);
|
||||
expectViews(image, 0x09, 0x09, 0); // duplicate STOR alone
|
||||
imageFor(image, 0x09, 0x02);
|
||||
memmove(image + 52, image + 48, 16);
|
||||
memset(image + 48, 0, 4);
|
||||
expectViews(image, 0x09, 0x09, 0); // separated records
|
||||
imageFor(image, 0x09, 0x02);
|
||||
memmove(image + 33, image + 32, 32);
|
||||
image[32] = 0;
|
||||
expectViews(image, 0x09, 0x09, 0); // byte-shifted bundle
|
||||
imageFor(image, 0x09, 0x02);
|
||||
assert(ota_bl_optional_app_storage(image, 63) == 0);
|
||||
assert(ota_bl_optional_app_storage(nullptr, 256) == 0);
|
||||
|
||||
// A second privileged marker must not be hidden by optional metadata.
|
||||
memcpy(image + 128, image, 16);
|
||||
assert(!ota_bl_update_caps_scan_aligned(image, 256, 0x09).present);
|
||||
|
||||
// Exercise the production memory-mapped entry points without defining the
|
||||
// retained-RAM application flag: this app retains the full SD RAM layout.
|
||||
void* flash = mmap(reinterpret_cast<void*>(uintptr_t(0xF4000)), 0xA000,
|
||||
PROT_READ | PROT_WRITE, MAP_PRIVATE | MAP_ANONYMOUS | MAP_FIXED,
|
||||
-1, 0);
|
||||
assert(flash == reinterpret_cast<void*>(uintptr_t(0xF4000)));
|
||||
memset(flash, 0, 0xA000);
|
||||
#if defined(OTA_INTERNAL_BOOTLOADER_UPDATE)
|
||||
imageFor(static_cast<uint8_t*>(flash), 0x0A, 0x14);
|
||||
assert(ota_bootloader_update_storage_flags() == 0x0A);
|
||||
assert(ota_bootloader_app_caps().storage_flags == 0x1E);
|
||||
assert(ota_bootloader_update_caps().storage_flags == 0x0A);
|
||||
#else
|
||||
imageFor(static_cast<uint8_t*>(flash), 0x09, 0x02);
|
||||
assert(ota_bootloader_update_storage_flags() == 0x09);
|
||||
assert(ota_bootloader_app_caps().storage_flags == 0x0B);
|
||||
OtaBlCaps update = ota_bootloader_update_caps();
|
||||
assert(update.storage_flags == 0x09 && update.optional_app_storage == 2);
|
||||
assert(ota_bootloader_self_update_caps_valid(update));
|
||||
imageFor(static_cast<uint8_t*>(flash), 0x09, 0);
|
||||
update = ota_bootloader_update_caps();
|
||||
assert(update.storage_flags == 0x09 && update.optional_app_storage == 0);
|
||||
assert(ota_bootloader_self_update_caps_valid(update));
|
||||
#endif
|
||||
assert(!ota_bootloader_ram_caps().present);
|
||||
assert(munmap(flash, 0xA000) == 0);
|
||||
}
|
||||
'''
|
||||
|
||||
STREAM_SOURCE = r'''
|
||||
#include <cassert>
|
||||
#include <cstring>
|
||||
#include <fstream>
|
||||
#include <iterator>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
#include "helpers/ota/OtaBootloaderUpdate.h"
|
||||
using namespace mesh::ota;
|
||||
struct Store {
|
||||
std::vector<uint8_t> bytes;
|
||||
bool fail_read = false;
|
||||
bool read(uint32_t off, uint8_t* out, uint32_t len) {
|
||||
if (uint64_t(off) + len > bytes.size() || (fail_read && off == 37 + 512)) return false;
|
||||
memcpy(out, bytes.data() + off, len);
|
||||
return true;
|
||||
}
|
||||
};
|
||||
int main(int argc, char** argv) {
|
||||
assert(argc == 6);
|
||||
std::ifstream input(argv[1], std::ios::binary);
|
||||
Store store;
|
||||
store.bytes.resize(37, 0xA5);
|
||||
store.bytes.insert(store.bytes.end(), std::istreambuf_iterator<char>(input), {});
|
||||
assert(store.bytes.size() == 37 + OTA_BOOT_IMAGE_SIZE);
|
||||
store.fail_read = std::stoul(argv[5]) != 0;
|
||||
OtaBootloaderIdentity identity;
|
||||
OtaBootloaderCapsMarker caps;
|
||||
const bool actual = ota_bootloader_external_image_metadata(
|
||||
store, 37, uint8_t(std::stoul(argv[2])), identity, caps,
|
||||
uint8_t(std::stoul(argv[3])));
|
||||
assert(actual == (std::stoul(argv[4]) != 0));
|
||||
if (actual) {
|
||||
assert(identity.present && identity.crc_ok && identity.continuity_present);
|
||||
assert(identity.manifest_offset == OTA_BOOT_CANDIDATE_MANIFEST_OFFSET);
|
||||
assert(caps.storage_flags == std::stoul(argv[2]));
|
||||
}
|
||||
}
|
||||
'''
|
||||
|
||||
UTILS_DECLARATIONS = """#pragma once
|
||||
#include <stddef.h>
|
||||
#include <stdint.h>
|
||||
namespace mesh { struct Utils {
|
||||
static void sha256(uint8_t*, size_t, const uint8_t*, int);
|
||||
static void sha256(uint8_t*, size_t, const uint8_t*, int, const uint8_t*, int);
|
||||
}; }
|
||||
"""
|
||||
|
||||
|
||||
class TowerBootloaderCapsTest(unittest.TestCase):
|
||||
def test_sd_primary_and_rak_capability_views(self):
|
||||
for profile in ("OTA_SD_BOOTLOADER_UPDATE", "OTA_INTERNAL_BOOTLOADER_UPDATE"):
|
||||
with self.subTest(profile=profile), tempfile.TemporaryDirectory() as directory:
|
||||
path = Path(directory)
|
||||
source = path / "caps.cpp"
|
||||
source.write_text("#include <initializer_list>\n" + SOURCE, encoding="ascii")
|
||||
executable = path / "caps"
|
||||
subprocess.run([
|
||||
"g++", "-std=c++17", "-Wall", "-Wextra", "-Werror",
|
||||
"-DNRF52_PLATFORM=1", f"-D{profile}=1",
|
||||
"-DMOTA_NRF52_TEST_LAYOUT_STAGE_CEILING=0xED000u",
|
||||
"-I", str(ROOT / "src"), str(source), "-o", str(executable),
|
||||
], check=True, timeout=30)
|
||||
subprocess.run([str(executable)], check=True, timeout=10)
|
||||
|
||||
def test_auto_storage_guard_allows_only_sd_primary_tower(self):
|
||||
required = ["NRF52_PLATFORM", "HELTEC_TOWER_V2_SDCARD", "OTA_SD_STORE",
|
||||
"OTA_FLASH_STORE", "OTA_SD_BOOTLOADER_UPDATE"]
|
||||
forbidden = ["OTA_QSPI_STORE", "QSPIFLASH", "OTA_RAK_AUTO_STORE",
|
||||
"OTA_INTERNAL_BOOTLOADER_UPDATE", "OTA_QSPI_BOOTLOADER_UPDATE",
|
||||
"OTA_HYBRID_RAM_STORE"]
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
path = Path(directory)
|
||||
(path / "Utils.h").write_text(UTILS_DECLARATIONS, encoding="ascii")
|
||||
source = path / "guard.cpp"
|
||||
source.write_text('#include "helpers/ota/OtaBootloaderUpdate.h"\n', encoding="ascii")
|
||||
cases = [(required + ["OTA_TOWER_AUTO_STORE"], True),
|
||||
([item for item in required if item != "OTA_FLASH_STORE"], True),
|
||||
(required, False)]
|
||||
cases += [([item for item in required if item != missing]
|
||||
+ ["OTA_TOWER_AUTO_STORE"], False) for missing in required]
|
||||
cases += [(required + ["OTA_TOWER_AUTO_STORE", item], False) for item in forbidden]
|
||||
for flags, expected in cases:
|
||||
with self.subTest(flags=flags):
|
||||
result = subprocess.run([
|
||||
"g++", "-std=c++17", "-fsyntax-only", "-I", str(path),
|
||||
"-I", str(ROOT / "src"), *[f"-D{flag}=1" for flag in flags],
|
||||
str(source),
|
||||
], capture_output=True, text=True, timeout=30)
|
||||
self.assertEqual(result.returncode == 0, expected, result.stderr)
|
||||
if not expected:
|
||||
self.assertIn("#error", result.stderr)
|
||||
|
||||
def test_streamed_successors_retain_unique_adjacent_optional_storage(self):
|
||||
base = _generic_bootloader_image(0x239A0071, "TOWER_V2_OTA", ml.BOOT_STORAGE_SD_UPDATE)
|
||||
image = self.with_optional(base, 2)
|
||||
ram, optional = image[0x90:0xA0], image[0xA0:0xB0]
|
||||
cases = [(base, 9, 0, True, False), (base, 9, 2, False, False),
|
||||
(image, 9, 2, True, False), (image, 9, 0, True, False),
|
||||
(image, 9, 0x14, False, False), (image, 9, 2, False, True)]
|
||||
for offset in (480, 496, 500, 508, 512, 1020):
|
||||
def move_pair(data, offset=offset):
|
||||
data[0x90:0xB0] = b"\xff" * 32
|
||||
data[offset:offset + 32] = ram + optional
|
||||
cases.append((_rewrite_boot_image(image, move_pair), 9, 2, True, False))
|
||||
mutations = [
|
||||
lambda data: data.__setitem__(slice(0x200, 0x210), ram),
|
||||
lambda data: data.__setitem__(slice(0x200, 0x210), optional),
|
||||
lambda data: data.__setitem__(slice(0x70, 0x80), optional),
|
||||
lambda data: data.__setitem__(0xAC, 0x14),
|
||||
lambda data: data.__setitem__(0xAD, 1),
|
||||
lambda data: data.__setitem__(0x98, 2),
|
||||
lambda data: data.__setitem__(slice(0x90, 0xA0), b"\xff" * 16),
|
||||
]
|
||||
for mutation in mutations:
|
||||
candidate = _rewrite_boot_image(image, mutation)
|
||||
cases += [(candidate, 9, 2, False, False), (candidate, 9, 0, True, False)]
|
||||
rak = self.with_optional(_generic_bootloader_image(), 0x14)
|
||||
cases += [(rak, 0x0A, 0x14, True, False), (rak, 0x0A, 2, False, False)]
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
path = Path(directory)
|
||||
(path / "Utils.h").write_text(UTILS_DECLARATIONS, encoding="ascii")
|
||||
source = path / "stream.cpp"
|
||||
source.write_text(STREAM_SOURCE, encoding="ascii")
|
||||
executable = path / "stream"
|
||||
subprocess.run([
|
||||
"g++", "-std=c++17", "-Wall", "-Wextra", "-Werror",
|
||||
"-I", str(path), "-I", str(ROOT / "src"), str(source),
|
||||
"-o", str(executable),
|
||||
], check=True, timeout=30)
|
||||
for index, (candidate, primary, required, expected, fail_read) in enumerate(cases):
|
||||
with self.subTest(case=index):
|
||||
payload = path / "image.bin"
|
||||
payload.write_bytes(candidate)
|
||||
subprocess.run([str(executable), str(payload), str(primary), str(required),
|
||||
str(int(expected)), str(int(fail_read))], check=True, timeout=10)
|
||||
|
||||
@staticmethod
|
||||
def with_optional(image, optional):
|
||||
def add_storage(data):
|
||||
struct.pack_into("<I", data, 0, 0x20030000)
|
||||
data[0x90:0xB0] = (b"MOTARAMA" + struct.pack("<HHI", 1, 72, 65536)
|
||||
+ b"MOTASTOR" + struct.pack("<HHB3x", 1, 16, optional))
|
||||
return _rewrite_boot_image(image, add_storage)
|
||||
|
||||
def test_python_admits_exact_tower_and_retains_rak_profiles(self):
|
||||
for board, name, primary, optional in (
|
||||
(0x239A0071, "TOWER_V2_OTA", ml.BOOT_STORAGE_SD_UPDATE, 2),
|
||||
(0x239A0029, "3401_DFU", ml.BOOT_STORAGE_INTERNAL_UPDATE, 0x14),
|
||||
(0x239A0029, "4631_DFU", ml.BOOT_STORAGE_INTERNAL_UPDATE, 0x14),
|
||||
):
|
||||
with self.subTest(name=name):
|
||||
base = _generic_bootloader_image(board, name, primary)
|
||||
self.assertEqual(ml.bootloader_optional_app_storage(base), 0)
|
||||
image = self.with_optional(base, optional)
|
||||
self.assertEqual(ml.validate_bootloader_image(image).device_name, name)
|
||||
self.assertEqual(ml.bootloader_caps_storage(image), primary)
|
||||
self.assertEqual(ml.bootloader_optional_app_storage(image), optional)
|
||||
|
||||
def test_python_rejects_corrupt_ambiguous_and_cross_profile_records(self):
|
||||
base = _generic_bootloader_image(0x239A0071, "TOWER_V2_OTA", ml.BOOT_STORAGE_SD_UPDATE)
|
||||
image = self.with_optional(base, 2)
|
||||
ram = image[0x90:0xA0]
|
||||
optional = image[0xA0:0xB0]
|
||||
mutations = [
|
||||
lambda data: data.__setitem__(0xAC, 3),
|
||||
lambda data: data.__setitem__(0xAD, 1),
|
||||
lambda data: data.__setitem__(slice(0xB0, 0xC0), optional),
|
||||
lambda data: data.__setitem__(slice(0xB0, 0xC0), ram),
|
||||
lambda data: data.__setitem__(slice(0x90, 0xA0), b"\xff" * 16),
|
||||
lambda data: struct.pack_into("<I", data, 0, 0x20040000),
|
||||
]
|
||||
for mutation in mutations:
|
||||
with self.subTest(mutation=mutations.index(mutation)):
|
||||
with self.assertRaises(ValueError):
|
||||
ml.validate_bootloader_image(_rewrite_boot_image(image, mutation))
|
||||
for board, name, primary, flags in (
|
||||
(0x239A0071, "TOWER_V2_OTA", ml.BOOT_STORAGE_SD_UPDATE, 0x14),
|
||||
(0x239A0029, "3401_DFU", ml.BOOT_STORAGE_INTERNAL_UPDATE, 2),
|
||||
(0x239A0029, "4631_DFU", ml.BOOT_STORAGE_INTERNAL_UPDATE, 2),
|
||||
(0x239A0071, "TOWER_V2_OTA", ml.BOOT_STORAGE_INTERNAL_UPDATE, 2),
|
||||
):
|
||||
with self.subTest(name=name, primary=primary, flags=flags):
|
||||
candidate = self.with_optional(_generic_bootloader_image(board, name, primary), flags)
|
||||
with self.assertRaises(ValueError):
|
||||
ml.validate_bootloader_image(candidate)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,179 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Canonical QSPI repeater promises must match the compiled USB logger."""
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
import shlex
|
||||
import shutil
|
||||
import subprocess
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
TARGETS = (
|
||||
"ikoka_handheld_nrf_e22_30dbm_repeater",
|
||||
"ikoka_nano_nrf_22dbm_repeater",
|
||||
"ikoka_nano_nrf_30dbm_repeater",
|
||||
"ikoka_nano_nrf_33dbm_repeater",
|
||||
"ikoka_stick_nrf_22dbm_repeater",
|
||||
"ikoka_stick_nrf_30dbm_repeater",
|
||||
"ikoka_stick_nrf_33dbm_repeater",
|
||||
"Xiao_nrf52_repeater",
|
||||
"solarxiao_30S_repeater",
|
||||
"solarxiao_33S_repeater",
|
||||
)
|
||||
|
||||
|
||||
def build_policy(target, *, disable_debug=0, packet="", debug="off",
|
||||
sensor_profile="", infix="", qspi=True):
|
||||
# Source only the production helpers. No PlatformIO command is necessary:
|
||||
# the three inventory facts below are the helper's board-selection input.
|
||||
command = r'''
|
||||
set -euo pipefail
|
||||
source build.sh
|
||||
env_name="$1"
|
||||
DISABLE_DEBUG="$2"
|
||||
PACKET_LOGGING_OVERRIDE="$3"
|
||||
MESHDEBUG_OVERRIDE="$4"
|
||||
NRF52_OTA_SENSOR_PROFILE="$5"
|
||||
FIRMWARE_FILENAME_INFIX="$6"
|
||||
PIO_ENV_PLATFORM_BY_NAME["$env_name"]=NRF52_PLATFORM
|
||||
PIO_ENV_BOARD_BY_NAME["$env_name"]=seeed-xiao-afruitnrf52-nrf52840
|
||||
PIO_ENV_QSPI_OTA_BY_NAME["$env_name"]="$7"
|
||||
BUILD_PROFILE_FOR_TARGET=standard
|
||||
PLATFORMIO_BUILD_FLAGS=""
|
||||
BUILD_APPLICATION_EXPECTATIONS=()
|
||||
disable_debug_flags "$env_name"
|
||||
apply_debug_overrides "$env_name"
|
||||
apply_mqtt_bridge_override "$env_name"
|
||||
disable_usb_logging_for_mqtt "$env_name"
|
||||
apply_merged_standard_usb_logging_profile "$env_name"
|
||||
declare_full_logging_application_contract "$env_name"
|
||||
printf '%s\n' "$PLATFORMIO_BUILD_FLAGS"
|
||||
printf '%s\n' "${BUILD_APPLICATION_EXPECTATIONS[@]}"
|
||||
'''
|
||||
environment = os.environ.copy()
|
||||
# Prevent a caller's global build options from turning a host policy test
|
||||
# into a different, unrelated profile.
|
||||
for name in ("MQTT_BRIDGE_OVERRIDE", "MQTT_DEBUG_OVERRIDE",
|
||||
"PLATFORMIO_BUILD_FLAGS", "MESHDEBUG_OVERRIDE",
|
||||
"PACKET_LOGGING_OVERRIDE", "DISABLE_DEBUG"):
|
||||
environment.pop(name, None)
|
||||
result = subprocess.run(
|
||||
["bash", "-c", command, "xiao-logging-policy", target,
|
||||
str(disable_debug), packet, debug, sensor_profile, infix,
|
||||
"1" if qspi else "0"],
|
||||
cwd=ROOT, env=environment, text=True, capture_output=True, check=True,
|
||||
)
|
||||
lines = result.stdout.splitlines()
|
||||
return shlex.split(lines[0]), lines[1:]
|
||||
|
||||
|
||||
class XiaoCanonicalLoggingTest(unittest.TestCase):
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
cls.compiler = shutil.which("g++") or shutil.which("c++")
|
||||
if cls.compiler is None:
|
||||
raise unittest.SkipTest("C++ preprocessor required")
|
||||
cls.temporary = tempfile.TemporaryDirectory()
|
||||
cls.includes = Path(cls.temporary.name)
|
||||
(cls.includes / "Arduino.h").write_text("// No peripherals are needed.\n",
|
||||
encoding="ascii")
|
||||
cls.macros_cache = {}
|
||||
|
||||
@classmethod
|
||||
def tearDownClass(cls):
|
||||
cls.temporary.cleanup()
|
||||
|
||||
def macros(self, flags):
|
||||
key = tuple(flags)
|
||||
if key in self.macros_cache:
|
||||
return self.macros_cache[key]
|
||||
# PlatformIO emits define flags before undefine flags. Reproduce that
|
||||
# ordering so a trailing -U defeats a superficially correct -D, just
|
||||
# as in the failed packaged Full/Reduced images.
|
||||
defines = [flag for flag in flags if flag.startswith("-D")]
|
||||
undefines = [flag for flag in flags if flag.startswith("-U")]
|
||||
result = subprocess.run(
|
||||
[self.compiler, "-x", "c++", "-E", "-dM", "-",
|
||||
"-DARDUINO=10819", "-DNRF52_PLATFORM=1", "-DUSE_TINYUSB=1",
|
||||
"-DMESH_PACKET_LOGGING=1", "-I", str(self.includes),
|
||||
"-I", str(ROOT / "src"), *defines, *undefines],
|
||||
input='#include <helpers/UsbLogging.h>\n', text=True,
|
||||
capture_output=True, check=True,
|
||||
)
|
||||
macros = {}
|
||||
for line in result.stdout.splitlines():
|
||||
if line.startswith("#define "):
|
||||
parts = line.split(maxsplit=2)
|
||||
macros[parts[1]] = parts[2] if len(parts) == 3 else ""
|
||||
self.macros_cache[key] = macros
|
||||
return macros
|
||||
|
||||
def assert_logger_contract(self, expectations):
|
||||
self.assertIn(
|
||||
"logging.usb.packets=%s: %s, len=%d (type=%d, route=%s, payload_len=%d)",
|
||||
expectations,
|
||||
)
|
||||
self.assertIn("logging.usb.control=OK - USB logging %s (saved)",
|
||||
expectations)
|
||||
|
||||
def test_canonical_sensor_pairs_keep_logger_with_verbose_debug_disabled(self):
|
||||
for target in TARGETS:
|
||||
for sensor_profile in ("", "full", "reduced"):
|
||||
for disable_debug, packet in ((0, ""), (1, ""), (1, "off"),
|
||||
(0, "off")):
|
||||
with self.subTest(target=target, sensors=sensor_profile,
|
||||
disable_debug=disable_debug, packet=packet):
|
||||
flags, expectations = build_policy(
|
||||
target, disable_debug=disable_debug, packet=packet,
|
||||
sensor_profile=sensor_profile,
|
||||
infix="sensor-ota" if sensor_profile else "",
|
||||
)
|
||||
macros = self.macros(flags)
|
||||
self.assertEqual(macros.get("MESH_PACKET_LOGGING"), "1")
|
||||
self.assertEqual(macros.get("MESH_USB_LOGGING_AVAILABLE"), "1")
|
||||
self.assertNotIn("MESH_DEBUG", macros)
|
||||
self.assert_logger_contract(expectations)
|
||||
|
||||
def test_canonical_debug_opt_in_keeps_the_same_packet_contract(self):
|
||||
for disable_debug in (0, 1):
|
||||
with self.subTest(disable_debug=disable_debug):
|
||||
flags, expectations = build_policy(
|
||||
TARGETS[0], disable_debug=disable_debug,
|
||||
debug="on", packet="on",
|
||||
)
|
||||
macros = self.macros(flags)
|
||||
self.assertEqual(macros.get("MESH_PACKET_LOGGING"), "1")
|
||||
self.assertEqual(macros.get("MESH_USB_LOGGING_AVAILABLE"), "1")
|
||||
if disable_debug:
|
||||
self.assertNotIn("MESH_DEBUG", macros)
|
||||
else:
|
||||
self.assertEqual(macros.get("MESH_DEBUG"), "1")
|
||||
self.assert_logger_contract(expectations)
|
||||
|
||||
def test_noncanonical_images_still_honor_explicit_logging_off(self):
|
||||
cases = (
|
||||
(TARGETS[0], {"infix": "custom"}),
|
||||
(TARGETS[0], {"qspi": False}),
|
||||
("Xiao_nrf52_room_server", {}),
|
||||
("Xiao_nrf52_repeater_bridge_rs232", {}),
|
||||
)
|
||||
for target, options in cases:
|
||||
for disable_debug, packet in ((1, ""), (0, "off"), (1, "off")):
|
||||
with self.subTest(target=target, options=options,
|
||||
disable_debug=disable_debug, packet=packet):
|
||||
flags, expectations = build_policy(
|
||||
target, disable_debug=disable_debug, packet=packet,
|
||||
**options,
|
||||
)
|
||||
macros = self.macros(flags)
|
||||
self.assertNotIn("MESH_PACKET_LOGGING", macros)
|
||||
self.assertEqual(macros.get("MESH_USB_LOGGING_AVAILABLE"), "0")
|
||||
self.assertFalse(any(item.startswith("logging.usb.")
|
||||
for item in expectations))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
+12
-5
@@ -855,16 +855,20 @@ def xiao_bootloader_caps_ok(image: bytes) -> bool:
|
||||
|
||||
|
||||
def bootloader_optional_app_storage(image: bytes) -> int:
|
||||
record = (b"MOTARAMA" + struct.pack("<HHI", 1, 72, 65536) +
|
||||
b"MOTASTOR" + struct.pack("<HHBBBB", 1, 16, 0x14, 0, 0, 0))
|
||||
offsets = [off for off in range(0, len(image) - 7, 4)
|
||||
if image[off:off + 8] == b"MOTASTOR"]
|
||||
if not offsets:
|
||||
return 0
|
||||
flags = image[offsets[0] + 12] if offsets[0] + 16 <= len(image) else 0
|
||||
ram = b"MOTARAMA" + struct.pack("<HHI", 1, 72, 65536)
|
||||
record = ram + b"MOTASTOR" + struct.pack("<HHBBBB", 1, 16, flags, 0, 0, 0)
|
||||
ram_count = sum(image[off:off + 16] == ram
|
||||
for off in range(0, len(image) - 15, 4))
|
||||
if (len(offsets) != 1 or offsets[0] < 16 or
|
||||
flags not in (0x14, 0x02) or ram_count != 1 or
|
||||
image[offsets[0] - 16:offsets[0] + 16] != record):
|
||||
raise ValueError("optional RAK application storage marker is invalid or ambiguous")
|
||||
return 0x14
|
||||
raise ValueError("optional application storage marker is invalid or ambiguous")
|
||||
return flags
|
||||
|
||||
|
||||
def validate_bootloader_image(image: bytes, target_id: Optional[int] = None,
|
||||
@@ -899,10 +903,13 @@ def validate_bootloader_image(image: bytes, target_id: Optional[int] = None,
|
||||
expected = "/".join(f"0x{value:02X}" for value in expected_storage)
|
||||
raise ValueError(f"bootloader lacks exact ABI 3 self-update capabilities {expected}")
|
||||
optional_storage = bootloader_optional_app_storage(image)
|
||||
if optional_storage and (actual_storage != BOOT_STORAGE_INTERNAL_UPDATE or
|
||||
if optional_storage == 0x14 and (actual_storage != BOOT_STORAGE_INTERNAL_UPDATE or
|
||||
identity.board_id != 0x239A0029 or
|
||||
identity.device_name not in ("3401_DFU", "4631_DFU")):
|
||||
raise ValueError("optional RAK application storage requires the deployed board identity")
|
||||
if optional_storage == 0x02 and (actual_storage != BOOT_STORAGE_SD_UPDATE or
|
||||
(identity.board_id, identity.device_name) != (0x239A0071, "TOWER_V2_OTA")):
|
||||
raise ValueError("optional internal storage requires the deployed MeshTower SD identity")
|
||||
if actual_storage == BOOT_STORAGE_RAK_AUTO_UPDATE or optional_storage:
|
||||
ram_marker = b"MOTARAMA" + struct.pack("<HHI", 1, 72, 65536)
|
||||
count = sum(image[off:off + 16] == ram_marker
|
||||
|
||||
@@ -46,9 +46,9 @@ build_flags =
|
||||
; -D MESH_DEBUG=1
|
||||
|
||||
; MeshTower V2 self-update target using the onboard microSD socket as the
|
||||
; persistent LoRa-OTA store. Requires the matching SD-aware OTAFIX bootloader.
|
||||
; Unlike the internal-flash target above, this accepts both complete images
|
||||
; and in-place deltas and does not reserve application flash for the container.
|
||||
; primary LoRa-OTA store. Requires the matching SD-aware OTAFIX bootloader.
|
||||
; With no usable card, the combined OTAFIX marker permits internal-flash
|
||||
; application deltas. SD retains full images and signed bootloader packages.
|
||||
; The exact SD repeater env also accepts manually confirmed signed bootloader
|
||||
; packages when OTAFIX advertises the matching SD self-update profile.
|
||||
[Heltec_tower_v2_sdcard]
|
||||
@@ -77,6 +77,8 @@ build_flags =
|
||||
-D MAX_NEIGHBOURS=254
|
||||
-D ENABLE_OTA=1
|
||||
-D OTA_SD_STORE=1
|
||||
-D OTA_FLASH_STORE=1
|
||||
-D OTA_TOWER_AUTO_STORE=1
|
||||
-D OTA_SD_BOOTLOADER_UPDATE=1
|
||||
-D OTA_FOLDER_SERIAL
|
||||
|
||||
|
||||
Reference in new issue
Block a user