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:
mikecarper committed 2026-10-05 16:38:30 -07:00
1 parent 7d3ee7200f
commit 30fe0d617b
25 files changed
+2082 -93

No files matched your search

+4
View File
@@ -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
View File
@@ -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
+25 -3
View File
@@ -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.
+57 -24
View File
@@ -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
+14 -1
View File
@@ -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
+8
View File
@@ -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) {
+19 -8
View File
@@ -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());
}
+1 -1
View File
@@ -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];
+23 -2
View File
@@ -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
View File
@@ -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.
+43 -3
View File
@@ -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;
+55 -5
View File
@@ -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");
}
+84 -10
View File
@@ -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
}
+2 -2
View File
@@ -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;
+3
View File
@@ -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:
+216
View File
@@ -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
+28
View File
@@ -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
+91 -6
View File
@@ -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=()
+3
View File
@@ -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; }
+326
View File
@@ -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()
+440
View File
@@ -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()
+335
View File
@@ -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()
+179
View File
@@ -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
View File
@@ -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
+5 -3
View File
@@ -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