From 249c3680b3599adab869e4584e1afd185eaf6d13 Mon Sep 17 00:00:00 2001 From: mikecarper Date: Wed, 2 Sep 2026 12:02:41 -0700 Subject: [PATCH] Fix auto nRF52 Companion OTA builds --- build.sh | 26 +++++++++++--- docs/cli_build_matrix.md | 5 +-- docs/companion_protocol.md | 10 +++--- examples/companion_radio/MyMesh.cpp | 2 +- test/test_build_profiles.sh | 45 +++++++++++++++++++++++++ test/test_companion_terminal_profile.py | 14 ++++++++ 6 files changed, 90 insertions(+), 12 deletions(-) diff --git a/build.sh b/build.sh index 4019726aa..97dc681cc 100755 --- a/build.sh +++ b/build.sh @@ -245,6 +245,10 @@ init_project_context() { if [ -z "$env_name" ] || [ -z "$env_platform" ]; then continue fi + # Native Windows Python writes CRLF even when its output is consumed by + # Git Bash. `read` removes LF but leaves CR on the final field, which can + # make exact board checks (for example heltec-rc32) silently fail. + env_board=${env_board%$'\r'} SUPPORTED_PIO_ENVS+=("$env_name") PIO_ENV_PLATFORM_BY_NAME["$env_name"]=$env_platform PIO_ENV_BOARD_BY_NAME["$env_name"]=$env_board @@ -2720,6 +2724,16 @@ is_lora_ota_build() { return 1 fi + # A single-target auto build promises to preserve the capabilities declared + # by its resolved PlatformIO environment and fail if they do not fit. Keep + # that contract for nRF52 Companion and other non-repeater roles which + # deliberately inherit the shared OTA recipe. The standard/release profile + # continues through the opt-in policy below and may select its documented + # portable reductions. + if [ "${BUILD_PROFILE_FOR_TARGET:-$BUILD_PROFILE_EFFECTIVE}" = "auto" ]; then + return 0 + fi + # The OTA manager, staging store, and self-install path are deliberately # opt-in. Most boards use the constrained no-external-sensors sibling. A # purpose-built external-QSPI target may retain the full board feature set. @@ -2746,13 +2760,17 @@ is_lora_ota_build() { esac } -is_esp32_companion_build() { +is_companion_build() { case "${1,,}" in *companion*|*comp_radio*) return 0 ;; *) return 1 ;; esac } +is_esp32_companion_build() { + is_companion_build "$1" +} + requires_esp32_portable_app_slot() { [ "${PIO_ENV_PLATFORM_BY_NAME[$1]:-}" = "ESP32_PLATFORM" ] \ && ! is_esp32_companion_build "$1" @@ -2910,8 +2928,7 @@ declare_build_capability_contract() { record_build_expectation "companion.ble_mota_source" \ "Bluetooth mOTA source" fi - elif [ "$env_platform" = "ESP32_PLATFORM" ] \ - && is_esp32_companion_build "$env_name" \ + elif is_companion_build "$env_name" \ && is_lora_ota_build "$env_name"; then record_build_expectation "companion.temp_radio" "ERR usage: tempradio" record_build_expectation "companion.ota_cli" "OTA: status" @@ -3323,8 +3340,7 @@ apply_lora_ota_override() { 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" fi - if [ "${PIO_ENV_PLATFORM_BY_NAME[$env_name]:-}" = "ESP32_PLATFORM" ] \ - && is_esp32_companion_build "$env_name"; then + if is_companion_build "$env_name"; then # Do not link an OTA manager into a Companion while compiling out the # terminal controls needed to operate it over LoRa. export PLATFORMIO_BUILD_FLAGS="${PLATFORMIO_BUILD_FLAGS} -DCOMPANION_FEATURE_TEMP_RADIO=1 -DCOMPANION_FEATURE_OTA_CLI=1" diff --git a/docs/cli_build_matrix.md b/docs/cli_build_matrix.md index b9e29df22..b65f05ed7 100644 --- a/docs/cli_build_matrix.md +++ b/docs/cli_build_matrix.md @@ -28,7 +28,7 @@ and partition policy: | Selection | Behavior | | --- | --- | -| `auto` | For one explicit target, pass 1 builds the complete supported LoRa-OTA-capable recipe. ESP32 boards with a qualified expanded profile use it; other repeaters attempt the complete recipe in their current application region. A measured flash/partition overflow starts the standard `no_external_sensors` LoRa OTA pass. Internal-flash nRF52 repeaters publish that reduced pass even when the complete image fits, because the smaller running image leaves more room to stage a delta; matched QSPI/SD repeaters do not need the redundant artifact. Compiler errors and missing-capability checks never trigger or conceal a reduced build. Canonical bulk commands keep their established standard partition contract. | +| `auto` | For one explicit target, pass 1 builds the complete supported LoRa-OTA-capable recipe. ESP32 boards with a qualified expanded profile use it; other repeaters attempt the complete recipe in their current application region. A non-repeater keeps every capability declared by its resolved PlatformIO recipe, including LoRa OTA, and fails instead of silently removing one that does not fit. A measured repeater flash/partition overflow starts the standard `no_external_sensors` LoRa OTA pass. Internal-flash nRF52 repeaters publish that reduced pass even when the complete image fits, because the smaller running image leaves more room to stage a delta; matched QSPI/SD repeaters do not need the redundant artifact. Compiler errors and missing-capability checks never trigger or conceal a reduced build. Canonical bulk commands keep their established standard partition contract. | | `standard` | Immediately uses the deployed/portable partition contract and its documented reductions. This is useful when the operator already knows the expanded or complete image is unsuitable. | | `full` | Requires a qualified ESP32 expanded-partition target (or an explicitly named Full Companion). Install a matching merged image when this changes the partition table. | @@ -38,7 +38,8 @@ logical OTA target, actual PlatformIO base, artifact name target, promised capabilities, and every reduction selected by the script. The build fails if a promised linked marker is absent. Current invariants include `retry.preset` for repeater/room-server roles, WebConfig for the Indicator and -ESP32 Full Companion, TempRadio/mOTA controls for Full Companion, and the +ESP32 Full Companion, TempRadio/OTA controls for install-capable Companions, +host-mOTA controls for Full Companion, and the advanced flood-rule engine for Full room servers. Resume mode will not accept an old or failed artifact without a verified sidecar. diff --git a/docs/companion_protocol.md b/docs/companion_protocol.md index 643994543..ae34ea50e 100644 --- a/docs/companion_protocol.md +++ b/docs/companion_protocol.md @@ -235,7 +235,7 @@ catalog; bytes `0x2C`-`0x31` are parked and `0x35` is unused. | `0x3F` / `0x40` | `CMD_SET_DEFAULT_FLOOD_SCOPE` / `CMD_GET_DEFAULT_FLOOD_SCOPE` | Write or read the default flood scope. | | `0x41` | `CMD_SEND_RAW_PACKET` | Queue a fully encoded raw mesh packet. | | `0x42` | `CMD_RUN_CLI_COMMAND` | Run a local CLI command (protocol v14+). | -| `0x4A` | `CMD_EXEC_LOCAL_OTA_CONTROL` | Run one bounded local TempRadio or OTA command on a Full Companion. | +| `0x4A` | `CMD_EXEC_LOCAL_OTA_CONTROL` | Run one bounded local TempRadio or OTA command when the Companion includes the OTA CLI. | | `0x4B` | `CMD_BLE_MOTA_SOURCE` | Query, start, or stop an nRF52 Full Companion's Bluetooth-backed LoRa mOTA source. | | `0x78`-`0x7F` | Deprecated hardware-setting aliases | Receive-only compatibility for clients shipped before command `0x42` became the canonical settings path. | @@ -326,14 +326,16 @@ custom name is limited to 31 valid UTF-8 bytes and takes effect after reboot. ### Bluetooth LoRa mOTA source -Protocol v14 lets a phone use an nRF52 Full Companion as the source for a +Protocol v14 lets a phone control an install-capable Companion's own LoRa OTA +session. An nRF52 Full Companion can additionally act as the source for a remote repeater update without a USB computer. The normal Companion service still carries contacts, repeater login, CLI messages, and these two control commands. The separate mOTA service carries only host-folder request/response frames. `CMD_EXEC_LOCAL_OTA_CONTROL` (`0x4A`) is followed by 1-174 printable ASCII -bytes. Full Companion accepts only these local command families: +bytes. A Companion built with the OTA CLI accepts only these local command +families: ```text tempradio ,,,, @@ -348,7 +350,7 @@ return `ERR_CODE_ILLEGAL_ARG`. A recognized command replies with `RESP_CODE_OK`, one unsigned reply-length byte, and exactly that many printable result bytes. Shell metacharacters are rejected as well; the text is dispatched only to the in-firmware parser and is never passed to a host shell. Firmware -without the Full Companion feature returns `ERR_CODE_UNSUPPORTED_CMD`. +without the OTA CLI feature returns `ERR_CODE_UNSUPPORTED_CMD`. `CMD_BLE_MOTA_SOURCE` (`0x4B`) has one action byte: diff --git a/examples/companion_radio/MyMesh.cpp b/examples/companion_radio/MyMesh.cpp index 8e6c93348..8a12c9517 100644 --- a/examples/companion_radio/MyMesh.cpp +++ b/examples/companion_radio/MyMesh.cpp @@ -4572,7 +4572,7 @@ void MyMesh::handleCmdFrame(size_t len) { } } else if (cmd_frame[0] == mesh::companion::CMD_EXEC_LOCAL_OTA_CONTROL) { -#if defined(COMPANION_RADIO_FULL) +#if COMPANION_FEATURE_OTA_CLI const size_t command_len = len - 1; if (!mesh::companion::isBleOtaControlCommandAllowed( &cmd_frame[1], command_len)) { diff --git a/test/test_build_profiles.sh b/test/test_build_profiles.sh index fe93b02c5..cfa93ff61 100644 --- a/test/test_build_profiles.sh +++ b/test/test_build_profiles.sh @@ -319,6 +319,51 @@ require(rak_usb, "build_flags", "FORCE_GPS_ALIVE") # replace each with that exact board's Full target. init_project_context >/dev/null +# Auto means that an explicitly selected target keeps the capabilities in its +# resolved PlatformIO recipe. T1000-E BLE/USB both inherit nRF52 LoRa OTA; the +# release builder must not silently replace ENABLE_OTA with -UENABLE_OTA merely +# because Companion is not a repeater role. Standard keeps the existing +# portable-release reduction policy. +for t1000_env in \ + t1000e_companion_radio_ble \ + t1000e_companion_radio_usb; do + [ "${PIO_ENV_OTA_BY_NAME[$t1000_env]:-0}" = 1 ] \ + || fail "$t1000_env resolved recipe no longer declares LoRa OTA" + ( + BUILD_PROFILE_FOR_TARGET=auto + is_lora_ota_build "$t1000_env" \ + || fail "$t1000_env auto profile stripped declared LoRa OTA" + + PLATFORMIO_BUILD_FLAGS="" + PLATFORMIO_BUILD_UNFLAGS="" + apply_lora_ota_override "$t1000_env" + [[ "$PLATFORMIO_BUILD_FLAGS" == *"-DENABLE_OTA=1"* ]] \ + || fail "$t1000_env auto overlay omitted ENABLE_OTA" + [[ "$PLATFORMIO_BUILD_FLAGS" != *"-UENABLE_OTA"* ]] \ + || fail "$t1000_env auto overlay still disables LoRa OTA" + [[ "$PLATFORMIO_BUILD_FLAGS" == *"-DCOMPANION_FEATURE_TEMP_RADIO=1"* ]] \ + || fail "$t1000_env auto overlay omitted temporary-radio control" + [[ "$PLATFORMIO_BUILD_FLAGS" == *"-DCOMPANION_FEATURE_OTA_CLI=1"* ]] \ + || fail "$t1000_env auto overlay omitted OTA CLI control" + + BUILD_CAPABILITIES=() + BUILD_REDUCTIONS=() + BUILD_EXPECTATIONS=() + declare_build_capability_contract "$t1000_env" NRF52_PLATFORM + expectations=" ${BUILD_EXPECTATIONS[*]} " + [[ "$expectations" == *"companion.temp_radio=ERR usage: tempradio"* ]] \ + || fail "$t1000_env auto contract omitted temporary-radio verification" + [[ "$expectations" == *"companion.ota_cli=OTA: status"* ]] \ + || fail "$t1000_env auto contract omitted OTA CLI verification" + ) + ( + BUILD_PROFILE_FOR_TARGET=standard + if is_lora_ota_build "$t1000_env"; then + fail "$t1000_env standard profile bypassed portable OTA policy" + fi + ) +done + # Arduino-ESP32 3.x OTA-only targets that have always declared larger A/B # slots must be checked against their real partition table, not the legacy # 1.25 MiB portable ceiling. Keep that exception OTA-only so ordinary RC32 diff --git a/test/test_companion_terminal_profile.py b/test/test_companion_terminal_profile.py index b7a0afc1c..28dc7184c 100644 --- a/test/test_companion_terminal_profile.py +++ b/test/test_companion_terminal_profile.py @@ -100,6 +100,20 @@ assert 'memcmp(command + noun_start, "fold", 4) == 0' in owner_policy assert 'memcmp(command + pos, "on", 2) == 0' in owner_policy assert 'memcmp(command + pos, "off", 3) == 0' in owner_policy +# The bounded framed/BLE OTA-control command belongs to the OTA features, not +# only to the synthetic Full profile. Auto builds of a declared nRF52 OTA +# Companion need this route to select TempRadio and drive receive/apply. +ota_frame_start = source.index( + "== mesh::companion::CMD_EXEC_LOCAL_OTA_CONTROL" +) +ota_frame_end = source.index( + "} else if (cmd_frame[0] == mesh::companion::CMD_BLE_MOTA_SOURCE)", + ota_frame_start, +) +ota_frame = source[ota_frame_start:ota_frame_end] +assert "\n#if COMPANION_FEATURE_OTA_CLI\n" in ota_frame +assert "#if defined(COMPANION_RADIO_FULL)" not in ota_frame + # This compile-time contract prevents either the implementation or help entry # from leaking onto a non-ESP32 build merely because a recipe set the flag. assert re.search(