Prepare 1.17.1.5 USA Cascade release and verify update capabilities

This commit is contained in:
mikecarper committed 2026-09-05 08:59:50 -07:00
1 parent 8995675e1c
commit a2c3761700
12 files changed
+813 -9

No files matched your search

+5
View File
@@ -1,5 +1,10 @@
# Releasing Firmware
For the USA Cascade 1.17.1.5 matrix, use
[the option 3 release instructions](docs/releases/1.17.1.5.md).
The [Full Companion feature guide](docs/full_companion_features.md) is intended
to accompany the release assets.
GitHub Actions is set up to automatically build and release firmware.
It will automatically build firmware when one of the following tag formats are pushed.
+101 -3
View File
@@ -56,6 +56,8 @@ PIO_BUILD_JOBS_OVERRIDE=""
PIO_BUILD_DIR_OVERRIDE=""
RESOLVED_BUILD_TARGETS=()
RESUME_BUILD_OUTPUT="${RESUME_BUILD_OUTPUT:-0}"
REQUIRE_OTA_UPDATES="${REQUIRE_OTA_UPDATES:-}"
OTA_EXCLUDED_TARGETS=()
LOGGING_MATRIX_FAILURES=()
LOGGING_MATRIX_DEFERRED_TARGETS=()
RADIO_PRESET_SELECTION=""
@@ -140,6 +142,10 @@ Options:
--auto|--standard|--full: Short forms of --build-profile.
--skip-kiss|--include-kiss: Exclude (default) or include KISS modem targets in bulk builds.
--clean|--resume: Clean output or resume existing Option 3/FULL-only artifacts.
--require-ota: Require a verified wireless self-update path for infrastructure.
Default for Option 3; Companion USB updates remain supported.
Cable-only infrastructure platforms are reported and omitted.
--allow-no-ota: Include cable-only development targets instead.
--background: Run the selected build as a persistent detached job. Its log
and status live outside OUTPUT_DIR so --clean cannot erase them.
@@ -1000,6 +1006,14 @@ parse_cli_options() {
BUILD_BACKGROUND_EXPLICIT=1
shift
;;
--require-ota)
REQUIRE_OTA_UPDATES=1
shift
;;
--allow-no-ota)
REQUIRE_OTA_UPDATES=0
shift
;;
--)
shift
positional+=("$@")
@@ -2841,6 +2855,26 @@ declare_build_capability_contract() {
record_build_capability "profile.${BUILD_PROFILE_FOR_TARGET}"
if [ "$env_platform" = "NRF52_PLATFORM" ] && ! is_kiss_modem_target "$env_name"; then
# Prove the real DFU service is linked, not merely a generic OTA CLI stub.
record_build_expectation "ota.update.bluetooth" "_ZN6BLEDfu5beginEv"
fi
if [ "$env_platform" = "ESP32_PLATFORM" ]; then
if is_companion_radio_full_target "$env_name" \
|| { [ "$BUILD_PROFILE_FOR_TARGET" = "standard" ] \
&& requires_esp32_field_browser_ota "$env_name"; }; then
record_build_expectation "ota.update.wifi" "MeshCore firmware update"
elif ! is_companion_build "$env_name" \
&& [ "${PIO_ENV_FULL_WIFI_OTA_BY_NAME[$env_name]:-0}" = "1" ] \
&& [ "$BUILD_PROFILE_FOR_TARGET" = "full" ]; then
record_build_expectation "ota.update.wifi" "Started: http://%s/update"
fi
if is_lora_ota_build "$env_name" && ! is_companion_radio_full_target "$env_name"; then
record_build_expectation "ota.update.lora" "image_hash MISMATCH after decode"
fi
fi
if is_repeater_role_target "$env_name" \
|| is_room_server_role_target "$env_name"; then
record_build_expectation "cli.retry_preset" "retry.preset"
@@ -2905,6 +2939,8 @@ declare_build_capability_contract() {
"Companion: starting Bluetooth"
record_build_expectation "companion.usb_logging" "get usb.logging"
record_build_expectation "companion.usb_mota_source" "ota folder on"
record_build_expectation "companion.mota_sender" \
"_ZN4mesh3ota16SerialMotaSource4read"
if is_nrf52_companion_radio_full_target "$env_name"; then
record_build_expectation "companion.dedicated_usb_logging" \
"get usb.logging"
@@ -3429,6 +3465,8 @@ apply_companion_radio_full_profile() {
# layout explicitly so Full-only runtime policies, including the bounded
# first-boot WebConfig window, cannot silently compile with legacy defaults.
export PLATFORMIO_BUILD_FLAGS="${PLATFORMIO_BUILD_FLAGS} -DWIFI_OTA_SEEDER=1 -DCOMPANION_FEATURE_USB_MOTA_SOURCE=1 -DCOMPANION_FEATURE_NETWORK_TERMINAL=1 -DCOMPANION_FEATURE_MEMORY_DIAGNOSTICS=1 -DMESHCORE_EXPANDED_PARTITION_PROFILE=1"
append_platformio_build_unflags "-DDISABLE_WIFI_OTA=1 -fexceptions"
export PLATFORMIO_BUILD_FLAGS="${PLATFORMIO_BUILD_FLAGS} -UDISABLE_WIFI_OTA -DLIGHTWEIGHT_WIFI_OTA=1 -fno-exceptions"
# Both Indicator radio layouts share the same original NVS/SPIFFS data
# placement. The LoRa WiFi base names that map directly; the ESP-NOW USB
@@ -3626,6 +3664,16 @@ write_build_capability_manifest() {
)
local item
if [ "${REQUIRE_OTA_UPDATES:-0}" = "1" ] && ! is_companion_build "$env_name"; then
checker_args+=(--require-ota)
fi
if [ "$env_platform" = "ESP32_PLATFORM" ]; then
checker_args+=(--firmware-bin "${build_output_dir}/firmware.bin"
--partitions "${build_output_dir}/partitions.bin")
elif [ "$env_platform" = "NRF52_PLATFORM" ]; then
checker_args+=(--dfu-package "${build_output_dir}/firmware.zip")
fi
for item in "${BUILD_CAPABILITIES[@]}"; do
checker_args+=(--capability "$item")
done
@@ -3722,6 +3770,16 @@ build_artifacts_exist() {
output_artifact_exists "${firmware_filename}.capabilities.json" || return 1
grep -q '"verified": true' \
"${OUTPUT_DIR}/${firmware_filename}.capabilities.json" || return 1
if [ "${REQUIRE_OTA_UPDATES:-0}" = "1" ]; then
python3 - "${OUTPUT_DIR}/${firmware_filename}.capabilities.json" <<'PY' || return 1
import json, sys
manifest = json.load(open(sys.argv[1]))
target = manifest.get("target", "").lower()
companion = "companion" in target or "comp_radio" in target
sys.exit(0 if manifest.get("schema_version", 0) >= 2 and
(companion or manifest.get("ota_update_verified")) else 1)
PY
fi
case "$env_platform" in
ESP32_PLATFORM)
@@ -3729,7 +3787,9 @@ build_artifacts_exist() {
&& output_artifact_exists "${firmware_filename}-merged.bin"
;;
NRF52_PLATFORM)
output_artifact_exists "${firmware_filename}.uf2"
output_artifact_exists "${firmware_filename}.uf2" \
&& { [ "${REQUIRE_OTA_UPDATES:-0}" != "1" ] \
|| output_artifact_exists "${firmware_filename}.zip"; }
;;
STM32_PLATFORM)
output_artifact_exists "${firmware_filename}.bin" \
@@ -4665,6 +4725,33 @@ resolve_command_targets() {
fi
fi
OTA_EXCLUDED_TARGETS=()
if [ -z "$REQUIRE_OTA_UPDATES" ]; then
REQUIRE_OTA_UPDATES=0
if is_logging_matrix_command "$1"; then REQUIRE_OTA_UPDATES=1; fi
fi
if [ "$REQUIRE_OTA_UPDATES" = "1" ]; then
local ota_targets=()
for target in "${RESOLVED_BUILD_TARGETS[@]}"; do
if is_companion_build "$target"; then
ota_targets+=("$target")
continue
fi
case "${PIO_ENV_PLATFORM_BY_NAME[$target]:-}" in
ESP32_PLATFORM|NRF52_PLATFORM) ota_targets+=("$target") ;;
*) OTA_EXCLUDED_TARGETS+=("$target") ;;
esac
done
if [ ${#OTA_EXCLUDED_TARGETS[@]} -gt 0 ]; then
echo "No wireless updater exists for these cable-only targets:"
printf ' %s\n' "${OTA_EXCLUDED_TARGETS[@]}"
if [ "$1" = "build-firmware" ]; then return 1; fi
fi
RESOLVED_BUILD_TARGETS=("${ota_targets[@]}")
[ ${#RESOLVED_BUILD_TARGETS[@]} -gt 0 ] || return 1
echo "OTA required: infrastructure must prove a WiFi, Bluetooth, or LoRa self-update path; Companions may update over USB."
fi
# Keep one queue so parallel workers stay saturated. The scheduler may pull
# a later target forward when a generated alias shares an active PlatformIO
# base environment, so this is a best-effort start order rather than a phase
@@ -4673,7 +4760,7 @@ resolve_command_targets() {
mapfile -t RESOLVED_BUILD_TARGETS < <(
sort_build_targets_by_platform_and_name "${RESOLVED_BUILD_TARGETS[@]}"
)
echo "Bulk target start order: nRF52, ESP32, RP2040, STM32; alphabetical within each platform (best effort with parallel workers)."
echo "Bulk target order: nRF52, ESP32, RP2040, STM32; alphabetical within each platform, one PlatformIO process at a time."
fi
}
@@ -5247,6 +5334,9 @@ run_logging_matrix_build_targets() {
fi
LOGGING_MATRIX_FAILURES=()
LOGGING_MATRIX_DEFERRED_TARGETS=()
if [ "${REQUIRE_OTA_UPDATES:-0}" = "1" ]; then
printf '%s\n' "${OTA_EXCLUDED_TARGETS[@]}" > "${OUTPUT_DIR}/ota-excluded-targets.txt"
fi
PROFILE_BUILD_WORKERS=$OPTION3_BUILD_WORKERS
echo "Option 3 PlatformIO policy: one target build at a time, ${OPTION3_PIO_JOBS} compiler job(s) inside that process."
@@ -5531,6 +5621,14 @@ write_background_build_status() {
printf 'exit_code=%s\n' "$exit_code"
fi
printf 'working_directory=%s\n' "$PWD"
printf 'output_directory=%s\n' "$OUTPUT_DIR"
printf 'source_commit=%s\n' "$(git rev-parse HEAD)"
printf 'firmware_version=%s\n' "${FIRMWARE_VERSION:-}"
printf 'firmware_profile=%s\n' "$FIRMWARE_PROFILE_OVERRIDE"
printf 'radio_frequency=%s\n' "$RADIO_FREQ_OVERRIDE"
printf 'radio_bandwidth=%s\n' "$RADIO_BW_OVERRIDE"
printf 'radio_sf=%s\n' "$RADIO_SF_OVERRIDE"
printf 'radio_cr=%s\n' "$RADIO_CR_OVERRIDE"
printf 'log=%s\n' "$BUILD_BACKGROUND_LOG_FILE"
} > "$status_tmp"
mv -f -- "$status_tmp" "$BUILD_BACKGROUND_STATUS_FILE"
@@ -5632,7 +5730,7 @@ launch_background_build() {
MESHDEBUG_OVERRIDE PACKET_LOGGING_OVERRIDE MQTT_BRIDGE_OVERRIDE
MQTT_DEBUG_OVERRIDE RADIO_SETTING_TITLE RADIO_FREQ_OVERRIDE
RADIO_BW_OVERRIDE RADIO_SF_OVERRIDE RADIO_CR_OVERRIDE
RESUME_BUILD_OUTPUT KISS_MODE_OVERRIDE
RESUME_BUILD_OUTPUT KISS_MODE_OVERRIDE REQUIRE_OTA_UPDATES
SSL_CERT_FILE SSL_CERT_DIR REQUESTS_CA_BUNDLE CURL_CA_BUNDLE
GIT_CONFIG_GLOBAL
)
+4
View File
@@ -1,5 +1,8 @@
# Full Companion
For practical on/off commands and update directions, see
[Full Companion feature switches](full_companion_features.md).
`companion_radio_full` combines every qualified Companion transport for its
platform into one artifact and acts as a host-backed LoRa mOTA source for
updating other nodes. Most targets can run those transports simultaneously;
@@ -21,6 +24,7 @@ firmware as an mOTA image.
| Hardware serial Companion | On targets with assigned serial pins | On targets with assigned serial pins |
| Ethernet Companion | On targets with an Ethernet module | On RAK4631 with RAK13800 |
| LoRa self-update | No | No |
| Optional self-update | WiFi on existing dual-app layouts; USB on single-app layouts | Bluetooth DFU with a compatible bootloader; USB always supported |
### SenseCAP Indicator transport and rendering exception
+163
View File
@@ -0,0 +1,163 @@
# Full Companion: turn features on and off
Use the exact `companion_radio_full` image for your board. Full includes its
supported transports and MOTA sending in one firmware; ordinary settings do
not require rebuilding it. USB remains the normal way to update the Companion
itself. WiFi/Bluetooth updates are optional, and Full does not install LoRa
MOTA packages on itself.
## Open the text terminal
Close the app or serial monitor using the USB port, then open a terminal at
115200 baud. A fresh boot starts in text mode. If it is already in Binary
Companion mode, send this line once and wait for the terminal banner:
```text
+++MESHCORE-TERM-START
```
Run `help`, `board`, and `version` to inspect the device. On ESP32 you can
alternatively connect to its text terminal with `nc DEVICE_IP 5002`.
When finished, send `+++MESHCORE-TERM-STOP`, close the terminal, and reconnect
your Companion app. These are local text commands; an app's custom-variable
editor does not necessarily forward them.
## Common switches
| Feature | Turn on | Turn off | When it takes effect |
| --- | --- | --- | --- |
| Device power saving | `powersaving on` | `powersaving off` | Immediately; saved |
| LoRa receive power saving | `set radio.rxps on` | `set radio.rxps off` | Saved; radio applies when available |
| Radio chip RX boost | `set radio.rxgain on` | `set radio.rxgain off` | Immediately; saved; supported radios only |
| External FEM RX boost | `set radio.fem.rxgain on` | `set radio.fem.rxgain off` | Immediately; saved; controllable FEM only |
| External FEM TX gain | `set radio.fem.txgain on` | `set radio.fem.txgain off` | Immediately; saved; controllable PA only |
| ESP32 USB packet/debug logging | `set usb.logging on` | `set usb.logging off` | Immediately; saved; logging owns the USB terminal |
| nRF52 separate USB logging port | `set usb.logging on reboot` | `set usb.logging off reboot` | Saves and reboots to add/remove the second USB port |
| ESP32 browser settings | `set webui on` | `set webui off` | Saved; starts/stops WebConfig |
| ESP32 temporary setup portal | `start webconfig ap` | `stop webconfig` | This session; opens a setup network/QR where available |
| ESP32 WiFi firmware uploader | `start ota` or `start ota ap` | `stop ota` | This session; only usable with two application slots |
| Temporary MOTA radio window | `tempradio 910.525,250,5,5,120` | `normalradio` | Bounded window; saved normal settings return afterward |
Check a saved switch with the corresponding `get` command, for example
`get usb.logging`, `get radio.rxps`, or `get webui`. Use `powersaving` to
inspect device power saving. Fresh installations enable device power saving
and leave USB logging off. Existing saved preferences take precedence.
ESP32 logging and Binary Companion share one USB port. After turning logging
off, return to Binary Companion with `+++MESHCORE-TERM-STOP`. nRF52 keeps its
optional logging port separate from its primary USB port and BLE connection.
See the [USB switching guide](full_companion_usb_switcher.md).
## WiFi, Bluetooth, GPS, and MQTT
On ESP32, configure WiFi through the setup portal or these commands:
```text
set wifi.ssid MyNetwork
set wifi.pwd my-password
get wifi.status
```
Credentials are saved. A credential change schedules a reconnect; a TCP
terminal will disconnect. The WiFi switch on supported device displays or
their assigned WiFi button controls the Companion WiFi services. Stopping
WebConfig alone stops its browser service, while the Companion's TCP and
MOTA services can remain available.
WiFi modem sleep is independent of device power saving. Use
`set wifi.powersave min` for normal BLE/WiFi coexistence; inspect the effective
state with `get wifi.powersave`. `max` is available only where the firmware's
radio coexistence policy permits it.
Most Full Companions provide BLE alongside their other transports. There is
no universal `set bluetooth off` command. SenseCAP Indicator selects one
secondary wireless transport per boot:
```text
set companion.transport wifi
reboot
```
Use `set companion.transport ble` followed by `reboot` to select Bluetooth
instead. USB stays available. The ESP-NOW layout keeps its primary ESP-NOW
mesh active in either mode.
For GPS-equipped boards, use the Companion app's GPS/custom sensor setting:
`gps=1` enables GPS and `gps=0` disables it. Only boards with a compiled GPS
provider expose this setting. This is separate from sharing location with
contacts.
When the exact Full image includes MQTT, use WebConfig's MQTT cards. Select
and configure a broker slot to enable it; choose the `none` preset for every
slot to disable broker connections. The MQTT status, packets, raw, receive,
and transmit switches control their individual functions. Save the settings
and follow any reboot instruction shown. Builds without MQTT omit these
controls. There is no need for a separate logging or WiFi-MQTT Companion
image when that feature is included in Full.
## Send MOTA from any Full Companion
Place valid, destination-specific `.mota` files in a host directory. Set the
source, destination, controller, and required relays to the same bounded
temporary radio tuple. For USA Cascade on LoRa hardware:
```text
tempradio 910.525,250,5,5,120
```
On the computer connected to the Full Companion, run:
```bash
motatool serve --serial /dev/ttyACM0 --dir ./motas -v
```
This works on both ESP32 and nRF52 Full. Close the text terminal first.
`motatool` automatically sends `ota folder on` and owns USB while serving.
Stop the host tool to detach the source; use `normalradio` afterward to
return early, or let the window expire. A `.bin`, `.uf2`, or DFU `.zip` is
not itself a `.mota`; use [motatool](../tools/mota/README.md) to prepare the
package for the exact destination firmware and hardware.
ESP32 also accepts a host over WiFi:
```bash
motatool serve --tcp DEVICE_IP:5001 --dir ./motas -v
```
nRF52 also accepts an authenticated Bluetooth host:
```bash
python3 tools/ble_mota/ble_mota_seeder.py \
--device MeshCore-MyCompanion --dir ./motas \
--local 'tempradio 910.525,250,5,5,120'
```
Use one MOTA source transport at a time. See the
[complete MOTA instructions](companion_radio_full.md#serve-mota-images-manually)
for pairing, remote destination commands, and automation. ESP-NOW hardware
uses its ESP-NOW primary radio rather than transmitting LoRa.
## Update the Companion itself
USB is supported for every Full Companion. Use the exact board's UF2/DFU
package or ESP32 image and the normal flasher. Preserve the target's existing
partition layout unless deliberately performing a documented USB migration.
For ESP32 with two application slots, `start ota` returns the WiFi uploader
URL, normally `http://DEVICE_IP:8080/update`. `start ota ap` explicitly opens
`MeshCore-OTA`; join it and use the returned URL. Upload the exact board's
application `.bin`. The device reboots when the upload succeeds. Use
`stop ota` to close an unused uploader. Port 8080 keeps WebConfig on port 80
available. Use a trusted local network or a temporary setup network.
The 4 MB Full layouts and T-Beam 1W Full use a single application slot and
require USB; the command reports that limitation. The artifact's
`.capabilities.json` lists verified `ota_update_methods` when a wireless
self-update path is usable.
nRF52 Full exposes Bluetooth DFU to paired clients. Use the matching
application DFU `.zip` with a compatible Nordic/Adafruit Legacy DFU client
and the board's matching BLE-DFU-capable bootloader/SoftDevice. USB remains
the fallback when the installed bootloader does not support that procedure.
Bluetooth DFU updates the Companion; the separate Bluetooth MOTA source
service feeds packages to other mesh nodes.
+57
View File
@@ -0,0 +1,57 @@
# MeshCore 1.17.1.5 — USA Cascade
This release uses the USA/Canada radio preset and Cascade runtime defaults.
The release build records the resolved frequency, bandwidth, spreading factor,
and coding rate. The offline USA Cascadia fallback is 910.525 MHz, BW62.5,
SF7, CR5. Saved node settings survive ordinary firmware updates.
Use the exact board's Full Companion image when available. Every Full
Companion must pass linked-firmware checks for MOTA sending, its text terminal,
and the platform's source transports. ESP32 supplies USB and WiFi sources;
nRF52 supplies USB and encrypted Bluetooth sources. Full Companions remain
USB-updateable and do not require LoRa self-installation. Optional ESP32
WiFi self-update uses port 8080 where the existing partition layout has two
application slots; nRF52 retains Bluetooth DFU with a compatible bootloader.
Read [Full Companion feature switches](../full_companion_features.md) for
on/off commands, USB logging, power saving, WiFi/Bluetooth selection, GPS,
MQTT, MOTA sending, and update directions.
Infrastructure artifacts must have a verified wireless self-update path.
Option 3 reports and omits infrastructure on platforms without a wireless
updater. The report is `ota-excluded-targets.txt`. Source/seeder capability
alone does not count as self-update support. ESP32 qualification checks both
application slots and OTA metadata; nRF52 Bluetooth qualification checks the
linked DFU service and its application ZIP. LoRa installation on nRF52 still
requires the exact matching OTAFIX bootloader and storage arrangement.
Since 1.17.1.4, this branch also includes USB session and contact-persistence
fixes, automatic nRF52 ExtraFS recovery, improved Indicator setup/display
handling, compressed MOTA transport, and measured ESP32 memory limits.
## Build using option 3
The equivalent reproducible command is:
```bash
OUTPUT_DIR=.releases/1.17.1.5-usa-cascade \
bash build.sh build-firmwares-logging-matrix \
--firmware-version v1.17.1.5 \
--radio-preset usa-cascadia --profile cascade \
--require-ota --skip-kiss --resume --background
```
Or run `bash build.sh`, select option 3, set version `v1.17.1.5`, and retain
USA Cascadia/Cascade. Option 3 enables the infrastructure OTA requirement by
default. It merges supported runtime USB logging into canonical artifacts and
builds the expanded ESP32 profiles. It runs one PlatformIO process at a time.
The background launcher prints its persistent log and status paths. Each
target also gets a log in `OUTPUT_DIR/build-logs/`. Resume accepts only
artifacts with the current capability schema and a successful qualification;
infrastructure also needs a verified update method. Any failed target leaves
the matrix unsuccessful and must be resolved before publication.
`--allow-no-ota` is an explicit development override for cable-only
infrastructure. Those outputs do not belong in this OTA-required release.
Use a separate output directory when changing the radio or settings profile.
+36
View File
@@ -2184,6 +2184,38 @@ bool MyMesh::handleLocalControlCommand(const char* command, char* reply,
if (!command || !reply || reply_size == 0) return false;
while (*command == ' ') command++;
#if defined(ESP32_PLATFORM) && defined(COMPANION_RADIO_FULL)
if (strcmp(command, "start ota") == 0
|| strcmp(command, "start ota ap") == 0) {
#if defined(COMPANION_EXCLUSIVE_WIFI_BLE)
if (!isCompanionWiFiEnabled()) {
snprintf(reply, reply_size,
"ERR: select WiFi transport and reboot before starting WiFi OTA");
return true;
}
#endif
// Board implementations use the common 160-byte CLI reply contract.
// Binary Companion callers may supply a shorter buffer.
char ota_reply[160] = {0};
if (!board.startOTAUpdate(_prefs.node_name, ota_reply,
strcmp(command, "start ota ap") == 0)) {
snprintf(reply, reply_size, "%s",
ota_reply[0] ? ota_reply : "ERR: WiFi OTA unavailable");
} else {
snprintf(reply, reply_size, "%s", ota_reply);
}
return true;
}
if (strcmp(command, "stop ota") == 0) {
char ota_reply[160] = {0};
if (!board.stopOTAUpdate(ota_reply) && !ota_reply[0]) {
snprintf(ota_reply, sizeof(ota_reply), "ERR: WiFi OTA unavailable");
}
snprintf(reply, reply_size, "%s", ota_reply);
return true;
}
#endif
if (strcmp(command, "board") == 0) {
const char* hardware_name = board.getManufacturerName();
snprintf(reply, reply_size, "%s",
@@ -7150,6 +7182,10 @@ void MyMesh::handleTerminalCommand(char* command) {
terminalOutput().print(" set usb.logging <on|off> [reboot]\r\n");
#endif
#if defined(ESP32) && defined(WIFI_SSID)
#if defined(COMPANION_RADIO_FULL)
terminalOutput().print(" start ota [ap] (WiFi firmware update on port 8080)\r\n");
terminalOutput().print(" stop ota\r\n");
#endif
#if defined(COMPANION_EXCLUSIVE_WIFI_BLE)
terminalOutput().print(" get companion.transport\r\n");
terminalOutput().print(" set companion.transport <wifi|ble>\r\n");
+66 -2
View File
@@ -8,6 +8,8 @@ import json
import os
from pathlib import Path
import sys
import struct
import zipfile
def parse_args() -> argparse.Namespace:
@@ -21,6 +23,10 @@ def parse_args() -> argparse.Namespace:
parser.add_argument("--build-profile", required=True)
parser.add_argument("--capability", action="append", default=[])
parser.add_argument("--reduction", action="append", default=[])
parser.add_argument("--require-ota", action="store_true")
parser.add_argument("--firmware-bin", type=Path)
parser.add_argument("--partitions", type=Path)
parser.add_argument("--dfu-package", type=Path)
parser.add_argument(
"--expect",
action="append",
@@ -35,6 +41,50 @@ def stable_unique(values: list[str]) -> list[str]:
return list(dict.fromkeys(value for value in values if value))
def verify_ota_artifacts(args, methods):
"""Check the actual update medium; source/seeder support cannot qualify."""
if not methods:
return False, "no linked wireless self-update implementation"
try:
if args.platform == "ESP32_PLATFORM":
if args.partitions is None or args.firmware_bin is None:
return False, "ESP32 OTA requires firmware.bin and partitions.bin"
table = args.partitions.read_bytes()
slots = {}
otadata = False
for offset in range(0, len(table) - 31, 32):
magic, kind, subtype, address, size = struct.unpack_from("<HBBII", table, offset)
if magic != 0x50AA:
continue
if kind == 0 and subtype in (0x10, 0x11):
slots[subtype] = (address, size)
if kind == 1 and subtype == 0 and size >= 0x2000:
otadata = True
if len(slots) != 2 or not otadata:
return False, "ESP32 OTA requires ota_0, ota_1, and otadata"
image_size = args.firmware_bin.stat().st_size
ordered = sorted(slots.values())
if ordered[0][0] + ordered[0][1] > ordered[1][0]:
return False, "ESP32 OTA application slots overlap"
if image_size <= 0 or any(image_size > size for _, size in ordered):
return False, "ESP32 firmware does not fit both OTA application slots"
return True, "firmware fits both OTA application slots; otadata present"
if args.platform == "NRF52_PLATFORM" and "bluetooth" in methods:
if args.dfu_package is None:
return False, "Bluetooth DFU requires its application ZIP"
with zipfile.ZipFile(args.dfu_package) as package:
manifest = json.loads(package.read("manifest.json"))["manifest"]
application = manifest["application"]
if not package.read(application["bin_file"]) or not package.read(application["dat_file"]):
return False, "Bluetooth DFU application ZIP is empty"
if package.testzip() is not None:
return False, "Bluetooth DFU application ZIP is corrupt"
return True, "application DFU ZIP verified; matching BLE DFU bootloader required"
except (OSError, ValueError, KeyError, zipfile.BadZipFile) as exc:
return False, str(exc)
return False, "no qualified wireless update artifact for this platform"
def main() -> int:
args = parse_args()
try:
@@ -67,8 +117,16 @@ def main() -> int:
args.capability + [check["capability"] for check in checks]
)
missing = [check for check in checks if not check["present"]]
ota_methods = stable_unique([
check["capability"].removeprefix("ota.update.")
for check in checks
if check["present"] and check["capability"].startswith("ota.update.")
])
ota_verified, ota_evidence = verify_ota_artifacts(args, ota_methods)
if not ota_verified:
capabilities = [value for value in capabilities if not value.startswith("ota.update.")]
manifest = {
"schema_version": 1,
"schema_version": 2,
"target": args.target,
"artifact_target": args.artifact_target or args.target,
"platformio_env": args.platformio_env or args.target,
@@ -77,7 +135,10 @@ def main() -> int:
"capabilities": capabilities,
"reductions": stable_unique(args.reduction),
"verification": checks,
"verified": not malformed and not missing,
"ota_update_methods": ota_methods if ota_verified else [],
"ota_update_verified": ota_verified,
"ota_update_evidence": ota_evidence,
"verified": not malformed and not missing and (not args.require_ota or ota_verified),
}
args.output.parent.mkdir(parents=True, exist_ok=True)
@@ -87,6 +148,9 @@ def main() -> int:
if malformed:
return 2
if args.require_ota and not ota_verified:
print(f"capability check failed: wireless self-update: {ota_evidence}", file=sys.stderr)
return 1
if missing:
for check in missing:
print(
+187
View File
@@ -0,0 +1,187 @@
#!/usr/bin/env python3
"""Stage a completed Option 3 matrix as audited, directly downloadable releases."""
from __future__ import annotations
import argparse
import hashlib
import html
import json
from pathlib import Path
import shutil
from urllib.parse import quote
ROOT = Path(__file__).resolve().parents[1]
FIRMWARE_SUFFIXES = {".bin", ".uf2", ".hex", ".zip"}
def is_companion(target):
return "companion" in target.lower() or "comp_radio" in target.lower()
def validate_manifest(manifest):
target = manifest["target"]
if manifest.get("schema_version", 0) < 2 or not manifest.get("verified"):
raise ValueError(f"{target}: firmware capability qualification failed")
if not is_companion(target) and not manifest.get("ota_update_verified"):
raise ValueError(f"{target}: infrastructure has no verified wireless updater")
if "companion_radio_full" in target.lower():
required = {"companion.usb_mota_source", "companion.mota_sender",
"companion.temp_radio", "companion.ota_cli"}
required.add("companion.wifi_ota_seeder" if manifest["platform"] == "ESP32_PLATFORM"
else "companion.ble_mota_source")
verified = {item["capability"] for item in manifest["verification"] if item["present"]}
if not required <= verified:
raise ValueError(f"{target}: Full Companion lacks verified MOTA sending")
def collect_artifacts(directory, version):
records = []
accounted = set()
for path in sorted(directory.glob("*.capabilities.json")):
stem = path.name.removesuffix(".capabilities.json")
files = [candidate for candidate in directory.glob(stem + ".*")
if candidate.suffix in FIRMWARE_SUFFIXES]
merged = directory / (stem + "-merged.bin")
if merged.is_file():
files.append(merged)
if not files:
continue # A measured rejected attempt may leave only a manifest.
if not stem.endswith("-" + version):
raise ValueError(f"stale or mixed-version artifact: {stem}")
manifest = json.loads(path.read_text())
validate_manifest(manifest)
extensions = {item.suffix for item in files}
platform = manifest["platform"]
if platform == "ESP32_PLATFORM" and (not merged.is_file() or not (directory / (stem + ".bin")).is_file()):
raise ValueError(f"{stem}: ESP32 application/merged pair incomplete")
if platform == "NRF52_PLATFORM":
if ".uf2" not in extensions or (manifest.get("ota_update_verified") and ".zip" not in extensions):
raise ValueError(f"{stem}: nRF52 UF2/DFU artifacts incomplete")
if any(item.stat().st_size == 0 for item in files):
raise ValueError(f"{stem}: empty firmware artifact")
accounted.update(files)
records.append({"manifest": manifest, "files": sorted(files) + [path]})
unaccounted = {path for path in directory.iterdir() if path.suffix in FIRMWARE_SUFFIXES} - accounted
if unaccounted:
raise ValueError("firmware without qualification: " + ", ".join(sorted(p.name for p in unaccounted)))
if not records:
raise ValueError("no qualified firmware artifacts")
return records
def category(record):
manifest = record["manifest"]
if is_companion(manifest["target"]):
return "companion"
if manifest["build_profile"] == "full":
return "full-profiles"
if "lora_ota" in manifest["target"].lower():
return "lora-ota"
return "infrastructure"
def sha256(path):
with path.open("rb") as stream:
return hashlib.file_digest(stream, "sha256").hexdigest()
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--input", required=True, type=Path)
parser.add_argument("--output", required=True, type=Path)
parser.add_argument("--build-status", required=True, type=Path)
parser.add_argument("--version", default="1.17.1.5")
parser.add_argument("--commit", required=True)
parser.add_argument("--repo", default="mikecarper/MeshCore")
args = parser.parse_args()
status = dict(line.split("=", 1) for line in args.build_status.read_text().splitlines() if "=" in line)
if status.get("state") != "completed" or status.get("exit_code") != "0":
raise ValueError("the Option 3 matrix has not completed successfully")
output_directory = Path(status["working_directory"]) / status["output_directory"]
if output_directory.resolve() != args.input.resolve():
raise ValueError("build status belongs to another output directory")
if status.get("source_commit") != args.commit or status.get("firmware_version") != "v" + args.version:
raise ValueError("build status belongs to another source revision or version")
if status.get("firmware_profile") != "cascade":
raise ValueError("matrix did not use Cascade runtime defaults")
radio = {key: status["radio_" + key] for key in ("frequency", "bandwidth", "sf", "cr")}
if args.output.exists() and any(args.output.iterdir()):
raise ValueError("staging directory must be empty; existing releases are never overwritten")
version = f"v{args.version}-{args.commit[:8]}"
records = collect_artifacts(args.input, version)
base_tag = f"v{args.version}-usa-cascade-{args.commit[:8]}"
groups = []
for name in ("companion", "infrastructure", "lora-ota", "full-profiles"):
current = []
count = 0
chunks = []
for record in (r for r in records if category(r) == name):
if count + len(record["files"]) > 900:
chunks.append(current)
current, count = [], 0
current.append(record)
count += len(record["files"])
if current:
chunks.append(current)
for index, chunk in enumerate(chunks):
key = name + (f"-{index + 1}" if index else "")
tag = base_tag if key == "companion" else f"{key}-{base_tag}"
groups.append({"key": key, "tag": tag, "records": chunk})
links = "\n".join(f"- [{g['key']}](https://github.com/{args.repo}/releases/tag/{g['tag']})" for g in groups)
source_url = f"https://github.com/{args.repo}/blob/{args.commit}"
rows = []
for group in groups:
destination = args.output / group["key"]
destination.mkdir(parents=True)
summaries = []
for record in group["records"]:
manifest = record["manifest"]
file_links = []
for path in record["files"]:
shutil.copy2(path, destination / path.name)
if path.suffix in FIRMWARE_SUFFIXES:
url = f"https://github.com/{args.repo}/releases/download/{group['tag']}/{quote(path.name)}"
label = "merged.bin (USB)" if path.name.endswith("-merged.bin") else path.suffix[1:]
file_links.append(f'<a href="{html.escape(url)}">{html.escape(label)}</a>')
methods = ", ".join(manifest.get("ota_update_methods", [])) or "USB"
summaries.append({**manifest, "files": [path.name for path in record["files"]]})
rows.append(f"<tr><td>{html.escape(manifest['artifact_target'])}</td><td>{html.escape(manifest['build_profile'])}</td><td>{html.escape(methods)}</td><td>{' · '.join(file_links)}</td></tr>")
(destination / "TARGET-MANIFEST.json").write_text(json.dumps(summaries, indent=2) + "\n")
shutil.copy2(ROOT / "docs/full_companion_features.md", destination / "FULL-COMPANION-FEATURES.md")
exclusions = args.input / "ota-excluded-targets.txt"
if exclusions.is_file():
shutil.copy2(exclusions, destination / exclusions.name)
body = (f"# MeshCore {args.version} — USA Cascade\n\n"
f"Source: `{args.commit}`. USA/Canada: **{radio['frequency']} MHz, BW{radio['bandwidth']}, SF{radio['sf']}, CR{radio['cr']}**; Cascade defaults.\n\n"
f"This page contains **{len(summaries)} qualified firmware profiles**. Full Companions send MOTA and normally update over USB. Infrastructure has a verified wireless update path; see each capability manifest.\n\n"
f"[Feature on/off and update directions]({source_url}/docs/full_companion_features.md) · "
f"[Release details]({source_url}/docs/releases/{args.version}.md)\n\n"
"Match the exact board, radio, display, and storage variant. ESP32 WiFi updates use the application `.bin`; the merged image installs boot/partition data over USB. nRF52 `.zip` files are native application DFU packages. LoRa MOTA installation requires an exact destination package; nRF52 also requires its matching OTAFIX bootloader. Full Companions do not LoRa-install onto themselves.\n\n"
"Firmware and capability checks passed in the build matrix. Hardware update testing across every board was not performed.\n\n"
+ links + "\n")
(args.output / (group["key"] + "-notes.md")).write_text(body)
picker = ("<!doctype html><meta charset=utf-8><meta name=viewport content='width=device-width'>"
f"<title>MeshCore {html.escape(args.version)} USA Cascade</title>"
"<style>body{font:16px system-ui;margin:2rem}input{font:inherit;padding:.5rem;width:90%;max-width:40rem}table{border-collapse:collapse;width:100%}td,th{text-align:left;padding:.7rem;border-bottom:1px solid #ccc}td:first-child{overflow-wrap:anywhere}a{color:#165acb}</style>"
f"<h1>MeshCore {html.escape(args.version)} USA Cascade</h1>"
"<p>Match your exact hardware. Full Companions can send MOTA; USB is their normal update method.</p>"
f"<p><a href='{source_url}/docs/full_companion_features.md'>Feature switches and update instructions</a></p>"
"<input id=search placeholder='Filter by board, profile, or update method' aria-label='Filter firmware'>"
"<table><thead><tr><th>Target</th><th>Profile</th><th>Self-update</th><th>Downloads</th></tr></thead><tbody>"
+ "\n".join(rows) + "</tbody></table>"
"<script>document.getElementById('search').oninput=function(){const q=this.value.toLowerCase();document.querySelectorAll('tbody tr').forEach(r=>r.hidden=!r.textContent.toLowerCase().includes(q))}</script>")
for group in groups:
destination = args.output / group["key"]
(destination / f"FIRMWARE-PICKER-{args.version}.html").write_text(picker)
files = sorted(destination.iterdir())
(destination / "SHA256SUMS.txt").write_text("".join(f"{sha256(path)} {path.name}\n" for path in files))
group["asset_count"] = len(files) + 1
group["target_count"] = len(group.pop("records"))
(args.output / "release-plan.json").write_text(json.dumps({"source": args.commit, "version": args.version, "radio": radio, "groups": groups}, indent=2) + "\n")
print(json.dumps({"targets": len(records), "groups": groups}, indent=2))
if __name__ == "__main__":
main()
+26 -3
View File
@@ -55,9 +55,11 @@ bool ESP32Board::isUserGpioAvailable(uint8_t pin) const {
return !UserGpioPinPolicy::isFirmwareReserved(pin);
}
#if defined(ADMIN_PASSWORD) && defined(LIGHTWEIGHT_WIFI_OTA)
#if defined(LIGHTWEIGHT_WIFI_OTA) && \
(defined(ADMIN_PASSWORD) || defined(COMPANION_RADIO_FULL))
#include <WiFi.h>
#include <Update.h>
#include <esp_ota_ops.h>
#include <SPIFFS.h>
#include <freertos/FreeRTOS.h>
#include <freertos/task.h>
@@ -65,6 +67,14 @@ bool ESP32Board::isUserGpioAvailable(uint8_t pin) const {
static bool lightweight_ota_started_ap;
// Full Companion keeps WebConfig on port 80 while an explicitly started
// updater uses 8080. Infrastructure retains its established /update URL.
#if defined(COMPANION_RADIO_FULL)
static constexpr uint16_t LIGHTWEIGHT_OTA_PORT = 8080;
#else
static constexpr uint16_t LIGHTWEIGHT_OTA_PORT = 80;
#endif
static const char LIGHTWEIGHT_OTA_PAGE[] = R"HTML(<!doctype html>
<html><head><meta name="viewport" content="width=device-width"><title>MeshCore OTA</title>
<style>body{font-family:sans-serif;max-width:32rem;margin:3rem auto;padding:0 1rem}button,input{font-size:1rem;margin:.5rem 0}pre{white-space:pre-wrap}</style></head>
@@ -78,7 +88,7 @@ x.onload=()=>{s.textContent=x.responseText;b.disabled=false};x.onerror=()=>{s.te
</script></body></html>)HTML";
class LightweightOTAServer {
WiFiServer server{80};
WiFiServer server{LIGHTWEIGHT_OTA_PORT};
TaskHandle_t task = nullptr;
volatile bool running = false;
ESP32Board* board = nullptr;
@@ -265,6 +275,14 @@ static LightweightOTAServer lightweight_ota_server;
bool ESP32Board::startOTAUpdate(const char* id, char reply[], bool force_ap) {
(void)id;
#if defined(COMPANION_RADIO_FULL)
const esp_partition_t* running = esp_ota_get_running_partition();
const esp_partition_t* next = esp_ota_get_next_update_partition(nullptr);
if (!running || !next || running->address == next->address) {
strcpy(reply, "ERR: this partition layout requires a USB firmware update");
return false;
}
#endif
inhibit_sleep = true;
IPAddress ip;
@@ -296,7 +314,12 @@ bool ESP32Board::startOTAUpdate(const char* id, char reply[], bool force_ap) {
ota_server = &lightweight_ota_server;
}
snprintf(reply, 160, "Started: http://%s/update", ip.toString().c_str());
if (LIGHTWEIGHT_OTA_PORT == 80) {
snprintf(reply, 160, "Started: http://%s/update", ip.toString().c_str());
} else {
snprintf(reply, 160, "Started: http://%s:%u/update", ip.toString().c_str(),
static_cast<unsigned>(LIGHTWEIGHT_OTA_PORT));
}
MESH_DEBUG_PRINTLN("startOTAUpdate: %s", reply);
return true;
}
+54
View File
@@ -0,0 +1,54 @@
#!/usr/bin/env python3
import importlib.util
import json
from pathlib import Path
import tempfile
import unittest
ROOT = Path(__file__).resolve().parents[1]
spec = importlib.util.spec_from_file_location("package_cascade_release", ROOT / "scripts/package_cascade_release.py")
package = importlib.util.module_from_spec(spec)
spec.loader.exec_module(package)
class ReleaseQualificationTest(unittest.TestCase):
def manifest(self, target="test_repeater", **changes):
return {"target": target, "platform": "ESP32_PLATFORM",
"schema_version": 2, "verified": True,
"ota_update_verified": True, "verification": [], **changes}
def test_source_capability_cannot_qualify_infrastructure(self):
with self.assertRaisesRegex(ValueError, "wireless updater"):
package.validate_manifest(self.manifest(ota_update_verified=False))
def test_usb_companion_is_accepted(self):
package.validate_manifest(self.manifest("test_companion_radio_usb", ota_update_verified=False))
def test_full_companion_requires_linked_source_implementation(self):
manifest = self.manifest("test_companion_radio_full", ota_update_verified=False)
with self.assertRaisesRegex(ValueError, "MOTA sending"):
package.validate_manifest(manifest)
manifest["verification"] = [{"capability": name, "present": True} for name in (
"companion.usb_mota_source", "companion.mota_sender", "companion.temp_radio",
"companion.ota_cli", "companion.wifi_ota_seeder")]
package.validate_manifest(manifest)
manifest["verification"][1]["present"] = False
with self.assertRaisesRegex(ValueError, "MOTA sending"):
package.validate_manifest(manifest)
def test_missing_qualification_and_mixed_versions_are_rejected(self):
with tempfile.TemporaryDirectory() as temp:
directory = Path(temp)
stem = "test_companion_radio_usb-v1.0-deadbeef"
(directory / (stem + ".bin")).write_bytes(b"firmware")
with self.assertRaisesRegex(ValueError, "without qualification"):
package.collect_artifacts(directory, "v1.0-deadbeef")
(directory / (stem + ".capabilities.json")).write_text(json.dumps(self.manifest("test_companion_radio_usb")))
with self.assertRaisesRegex(ValueError, "mixed-version"):
package.collect_artifacts(directory, "v2.0-deadbeef")
with self.assertRaisesRegex(ValueError, "pair incomplete"):
package.collect_artifacts(directory, "v1.0-deadbeef")
if __name__ == "__main__":
unittest.main()
+73 -1
View File
@@ -6,6 +6,9 @@ import subprocess
import sys
import tempfile
import unittest
import struct
import io
import zipfile
ROOT = Path(__file__).resolve().parents[1]
@@ -13,13 +16,18 @@ CHECKER = ROOT / "scripts" / "check_firmware_capabilities.py"
class FirmwareCapabilityCheckerTest(unittest.TestCase):
def run_checker(self, image_bytes, *extra_args):
def run_checker(self, image_bytes, *extra_args, artifacts=None):
temp_dir = tempfile.TemporaryDirectory()
self.addCleanup(temp_dir.cleanup)
directory = Path(temp_dir.name)
image = directory / "firmware.elf"
manifest = directory / "firmware.capabilities.json"
image.write_bytes(image_bytes)
artifact_args = []
for option, data in (artifacts or {}).items():
path = directory / option
path.write_bytes(data)
artifact_args.extend(["--" + option, str(path)])
result = subprocess.run(
[
sys.executable,
@@ -35,6 +43,7 @@ class FirmwareCapabilityCheckerTest(unittest.TestCase):
"--build-profile",
"auto",
*extra_args,
*artifact_args,
],
capture_output=True,
text=True,
@@ -64,6 +73,69 @@ class FirmwareCapabilityCheckerTest(unittest.TestCase):
self.assertFalse(manifest["verified"])
self.assertIn("promised", result.stderr)
def ota_artifacts(self, *, second_slot=True, image_size=48):
def entry(kind, subtype, address, size):
return struct.pack("<HBBII16sI", 0x50AA, kind, subtype,
address, size, b"test", 0)
table = entry(1, 0, 0xe000, 0x2000) + entry(0, 0x10, 0x10000, 64)
if second_slot:
table += entry(0, 0x11, 0x20000, 64)
return {"firmware-bin": b"x" * image_size, "partitions": table}
def test_seeder_does_not_count_as_self_update(self):
result, manifest = self.run_checker(
b"ota folder on", "--require-ota", "--expect",
"companion.usb_mota_source=ota folder on")
self.assertEqual(result.returncode, 1)
self.assertFalse(manifest["verified"])
self.assertEqual(manifest["ota_update_methods"], [])
def test_wifi_update_requires_two_slots(self):
result, manifest = self.run_checker(
b"uploader", "--require-ota", "--expect", "ota.update.wifi=uploader",
artifacts=self.ota_artifacts(second_slot=False))
self.assertEqual(result.returncode, 1)
self.assertIn("ota_1", manifest["ota_update_evidence"])
def test_wifi_update_must_fit_both_slots(self):
result, manifest = self.run_checker(
b"uploader", "--require-ota", "--expect", "ota.update.wifi=uploader",
artifacts=self.ota_artifacts(image_size=65))
self.assertEqual(result.returncode, 1)
self.assertFalse(manifest["ota_update_verified"])
def test_verified_wifi_update(self):
result, manifest = self.run_checker(
b"uploader", "--require-ota", "--expect", "ota.update.wifi=uploader",
artifacts=self.ota_artifacts())
self.assertEqual(result.returncode, 0, result.stderr)
self.assertTrue(manifest["ota_update_verified"])
self.assertEqual(manifest["ota_update_methods"], ["wifi"])
def test_bluetooth_requires_a_real_dfu_package(self):
result, manifest = self.run_checker(
b"dfu", "--platform", "NRF52_PLATFORM", "--require-ota", "--expect",
"ota.update.bluetooth=dfu")
self.assertEqual(result.returncode, 1)
archive = io.BytesIO()
with zipfile.ZipFile(archive, "w") as package:
package.writestr("manifest.json", json.dumps({"manifest": {
"application": {"bin_file": "app.bin", "dat_file": "app.dat"}}}))
package.writestr("app.bin", b"firmware")
package.writestr("app.dat", b"init packet")
result, manifest = self.run_checker(
b"dfu", "--platform", "NRF52_PLATFORM", "--require-ota", "--expect",
"ota.update.bluetooth=dfu", artifacts={"dfu-package": archive.getvalue()})
self.assertEqual(result.returncode, 0, result.stderr)
self.assertEqual(manifest["ota_update_methods"], ["bluetooth"])
def test_usb_companion_can_report_no_self_update(self):
result, manifest = self.run_checker(
b"ota folder on", "--expect", "companion.usb_mota_source=ota folder on")
self.assertEqual(result.returncode, 0, result.stderr)
self.assertTrue(manifest["verified"])
self.assertFalse(manifest["ota_update_verified"])
if __name__ == "__main__":
unittest.main()
+41
View File
@@ -0,0 +1,41 @@
#!/usr/bin/env bash
set -euo pipefail
cd "$(dirname "$0")/.."
source build.sh
fail() { echo "test_release_ota_policy: $*" >&2; exit 1; }
# Exercise release resolution without starting PlatformIO. This inventory
# includes a personal USB Companion and infrastructure on the same platform.
PIO_ENV_PLATFORM_BY_NAME[nrf_repeater]=NRF52_PLATFORM
PIO_ENV_PLATFORM_BY_NAME[esp_companion_radio_full]=ESP32_PLATFORM
PIO_ENV_PLATFORM_BY_NAME[rp_companion_radio_usb]=RP2040_PLATFORM
PIO_ENV_PLATFORM_BY_NAME[rp_repeater]=RP2040_PLATFORM
PIO_ENV_PLATFORM_BY_NAME[stm_sensor]=STM32_PLATFORM
resolve_bulk_command_targets() {
RESOLVED_BUILD_TARGETS=(nrf_repeater esp_companion_radio_full
rp_companion_radio_usb rp_repeater stm_sensor)
}
sort_build_targets_by_platform_and_name() { printf '%s\n' "$@"; }
validate_build_target() { return 0; }
REQUIRE_OTA_UPDATES=""
resolve_command_targets build-firmwares-logging-matrix >/dev/null
[ "$REQUIRE_OTA_UPDATES" = 1 ] || fail "option 3 did not require infrastructure OTA"
[ "${#RESOLVED_BUILD_TARGETS[@]}" = 3 ] || fail "wrong release inventory"
[[ " ${RESOLVED_BUILD_TARGETS[*]} " == *" rp_companion_radio_usb "* ]] \
|| fail "USB-updated Companion was excluded"
[ "${#OTA_EXCLUDED_TARGETS[@]}" = 2 ] || fail "missing exclusion report"
parse_cli_options build-firmwares-logging-matrix --allow-no-ota
resolve_command_targets "${PARSED_COMMAND_ARGS[@]}" >/dev/null
[ "${#RESOLVED_BUILD_TARGETS[@]}" = 5 ] || fail "development override lost targets"
parse_cli_options build-firmware rp_repeater --require-ota
if resolve_command_targets "${PARSED_COMMAND_ARGS[@]}" >/dev/null; then
fail "explicit cable-only infrastructure build passed OTA requirement"
fi
resolve_command_targets build-firmware rp_companion_radio_usb >/dev/null
[ "${#RESOLVED_BUILD_TARGETS[@]}" = 1 ] || fail "explicit USB Companion was rejected"
echo "test_release_ota_policy: OK"