diff --git a/build.sh b/build.sh index a826f6a3..c6cbb264 100755 --- a/build.sh +++ b/build.sh @@ -9,6 +9,7 @@ declare -A PIO_ENV_OTA_BY_NAME=() declare -A PIO_ENV_SD_OTA_BY_NAME=() declare -A PIO_ENV_QSPI_OTA_BY_NAME=() declare -A PIO_ENV_BUILD_BASE_BY_NAME=() +declare -A PIO_ENV_COMPLETE_OTA_BASE_BY_NAME=() declare -A PIO_ENV_FULL_BUILD_BY_NAME=() declare -A PIO_ENV_FULL_WIFI_OTA_BY_NAME=() PIO_CONFIG_JSON="" @@ -22,6 +23,16 @@ MQTT_DEBUG_OVERRIDE="" FIRMWARE_FILENAME_INFIX="" ESP32_FULL_BUILD=0 SINGLE_TARGET_FULL_BUILD=0 +AUTO_PREFER_FULL_BUILD=0 +AUTO_COMPLETE_FIRST_PASS=0 +AUTO_REDUCED_FALLBACK_TARGET="" +AUTO_PUBLISH_REDUCED_SECOND_PASS=0 +SKIP_DECLARED_REDUCTIONS=0 +COMPLETE_OTA_FIRST_PASS=0 +FIRMWARE_OUTPUT_ENV_NAME="" +BUILD_PROFILE_OVERRIDE="${BUILD_PROFILE_OVERRIDE:-auto}" +BUILD_PROFILE_EXPLICIT=0 +BUILD_PROFILE_EFFECTIVE="auto" RADIO_SETTINGS_API_URL="https://api.meshcore.nz/api/v1/config" USA_CASCADIA_RADIO_TITLE="USA Cascadia" USA_CASCADIA_FALLBACK_FREQ="910.525" @@ -76,7 +87,7 @@ FALLBACK_VERSION_DATE_FORMAT='+%Y-%m-%d-%H-%M' # External programs invoked by this script: # bash, cat, cp, date, find, flock, git, grep, head, mkdir, mv, pgrep, pio, -# python3, rm, sed, sleep, sort, wc +# python3, rm, sed, sleep, sort, tee, wc # Keep this list in sync when adding or removing non-builtin command usage. global_usage() { @@ -107,13 +118,18 @@ Commands: Options: --firmware-version : Firmware version to embed. --radio-preset : Override the USA Cascadia radio default. Stable names are usa-cascadia and target; legacy menu numbers remain accepted. - --profile : Override the default Cascade firmware settings profile. + --profile : Override runtime settings embedded in the firmware (not its feature set). + --build-profile : Select feature/partition policy. Auto first builds complete LoRa-OTA-capable firmware. A measured size failure triggers the reduced recipe; internal-flash nRF52 repeaters also publish the reduced image for extra delta-staging headroom. Standard applies declared portable-image reductions immediately; full requires the expanded ESP32 recipe. + --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. Examples: -Build firmware for the "RAK_4631_repeater" device target -$ bash build.sh build-firmware RAK_4631_repeater +Build firmware for the "RAK_4631_repeater" device target. Auto first builds +the complete LoRa OTA recipe, then also publishes the reduced +no-external-sensors receiver because this nRF52 repeater stages deltas in +internal flash. +$ bash build.sh build-firmware RAK_4631_repeater --build-profile auto Run without arguments to choose an interactive build action/target, an optional FULL-everything profile for supported ESP32 Option 1 targets, debug options, @@ -304,6 +320,7 @@ for section, options in data: solarxiao_30s_repeater|solarxiao_33s_repeater) continue ;; esac ota_env="${env_name%_}_lora_ota_no_external_sensors" + PIO_ENV_COMPLETE_OTA_BASE_BY_NAME["$ota_env"]="$env_name" if [ -n "${PIO_ENV_PLATFORM_BY_NAME[$ota_env]+x}" ]; then continue fi @@ -581,13 +598,16 @@ prompt_for_build_mode() { prompt_for_single_target_build_profile() { SINGLE_TARGET_FULL_BUILD=0 + BUILD_PROFILE_OVERRIDE="auto" + BUILD_PROFILE_EXPLICIT=1 if ! supports_esp32_full_build "$SELECTED_TARGET"; then - echo "Selected target uses its standard build profile; FULL everything is available only for supported ESP32 targets." + echo "Using the smart auto profile in the target's current partition; expanded FULL is unavailable for this target." return 0 fi local options=( - "Standard/custom build" + "Auto (keep target capabilities and enforce the current partition)" + "Standard portable image (allow documented legacy-slot reductions)" "FULL everything (all features, 254 neighbors, USB logging, WiFi MQTT where available, LoRa OTA, expanded dual-OTA partitions)" ) @@ -602,10 +622,17 @@ prompt_for_single_target_build_profile() { case "$MENU_CHOICE" in 1) - echo "Using the standard/custom single-target build." + BUILD_PROFILE_OVERRIDE="auto" + echo "Using auto: target capabilities are kept and checked against the current partition." return 0 ;; 2) + BUILD_PROFILE_OVERRIDE="standard" + echo "Using standard: documented portable-image reductions may be applied." + return 0 + ;; + 3) + BUILD_PROFILE_OVERRIDE="full" SINGLE_TARGET_FULL_BUILD=1 echo "Using FULL everything: all features, 254 neighbors, USB logging, WiFi MQTT where available, LoRa OTA, and expanded dual-OTA partitions." return 0 @@ -740,7 +767,7 @@ parse_cli_options() { RADIO_PRESET_SELECTION=$2 shift 2 ;; - --profile) + --profile|--settings-profile) if [ $# -lt 2 ]; then echo "$1 requires a value"; return 1; fi case "${2,,}" in default) FIRMWARE_PROFILE_OVERRIDE="" ;; @@ -749,6 +776,30 @@ parse_cli_options() { esac shift 2 ;; + --build-profile) + if [ $# -lt 2 ] || [ -z "$2" ]; then echo "$1 requires a value"; return 1; fi + case "${2,,}" in + auto|standard|full) BUILD_PROFILE_OVERRIDE="${2,,}" ;; + *) echo "Invalid build profile: $2 (use auto, standard, or full)"; return 1 ;; + esac + BUILD_PROFILE_EXPLICIT=1 + shift 2 + ;; + --auto) + BUILD_PROFILE_OVERRIDE="auto" + BUILD_PROFILE_EXPLICIT=1 + shift + ;; + --standard) + BUILD_PROFILE_OVERRIDE="standard" + BUILD_PROFILE_EXPLICIT=1 + shift + ;; + --full) + BUILD_PROFILE_OVERRIDE="full" + BUILD_PROFILE_EXPLICIT=1 + shift + ;; --skip-kiss) KISS_MODE_OVERRIDE="skip" shift @@ -2196,6 +2247,33 @@ is_repeater_role_target() { esac } +is_room_server_role_target() { + case "${1,,}" in + *room_server*|*room_svr*) return 0 ;; + *) return 1 ;; + esac +} + +get_reduced_lora_ota_target() { + local target=$1 + local candidate + + if is_lora_ota_only_target "$target"; then + echo "$target" + return 0 + fi + if ! is_repeater_role_target "$target"; then + return 1 + fi + + candidate="${target%_}_lora_ota_no_external_sensors" + if is_supported_build_env "$candidate"; then + echo "$candidate" + return 0 + fi + return 1 +} + is_lora_ota_build() { local env_name=$1 local env_name_lc=${env_name,,} @@ -2301,10 +2379,78 @@ requires_esp32_full_cli_profile() { || [[ "${env_name,,}" == *bridge_espnow* ]]; } } +record_build_capability() { + BUILD_CAPABILITIES+=("$1") +} + +record_build_reduction() { + BUILD_REDUCTIONS+=("$1") +} + +record_build_expectation() { + BUILD_EXPECTATIONS+=("$1=$2") +} + +declare_build_capability_contract() { + local env_name=$1 + local env_platform=$2 + local env_name_lc=${env_name,,} + local pio_env_name + + record_build_capability "profile.${BUILD_PROFILE_FOR_TARGET}" + + if is_repeater_role_target "$env_name" \ + || is_room_server_role_target "$env_name"; then + record_build_expectation "cli.retry_preset" "retry.preset" + fi + + if is_lora_ota_build "$env_name"; then + record_build_expectation "ota.cli" "OTA: status" + fi + + if [ "$env_platform" = "ESP32_PLATFORM" ] \ + && [ "$BUILD_PROFILE_FOR_TARGET" = "full" ] \ + && ! is_esp32_companion_build "$env_name"; then + pio_env_name=$(get_pio_build_env "$env_name") + if pio_env_option_contains "$pio_env_name" build_flags "ADMIN_PASSWORD"; then + record_build_expectation "web.webconfig" "start webconfig" + fi + fi + + if is_room_server_role_target "$env_name" \ + && [ "$BUILD_PROFILE_FOR_TARGET" = "full" ]; then + record_build_expectation "room.flood_rule_engine" "flood.rule" + fi + + if is_companion_radio_full_target "$env_name"; then + record_build_expectation "companion.temp_radio" "tempradio" + record_build_expectation "companion.ota_cli" "ota folder" + if [ "$env_platform" = "ESP32_PLATFORM" ]; then + record_build_expectation "companion.network_terminal" \ + "Full Companion terminal listening" + record_build_expectation "web.webconfig" "start webconfig" + else + record_build_expectation "companion.usb_mota_source" "ota folder on" + fi + elif [ "$env_platform" = "ESP32_PLATFORM" ] \ + && is_esp32_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" + fi + + case "$env_name_lc" in + *sensecapindicator*lora*comp*wifi*) + record_build_expectation "web.webconfig" "start webconfig" + ;; + esac +} + apply_esp32_lora_ota_size_profile() { local env_name=$1 - if [ "$ESP32_FULL_BUILD" = "1" ] || ! requires_esp32_portable_app_slot "$env_name"; then + if [ "$BUILD_PROFILE_FOR_TARGET" != "standard" ] \ + || ! requires_esp32_portable_app_slot "$env_name"; then return 0 fi @@ -2318,6 +2464,8 @@ apply_esp32_lora_ota_size_profile() { # Keep ENV_INCLUDE_GPS for boards with onboard GPS; their target sensor # managers require that support. export PLATFORMIO_BUILD_FLAGS="${PLATFORMIO_BUILD_FLAGS} -DWEBCONFIG_DISABLED=1" + record_build_reduction \ + "web.webconfig omitted to preserve the legacy portable ESP32 app slot" if is_lora_ota_only_target "$env_name"; then # The no-external-sensors image is also the self-updatable field image. Keep # manual browser OTA available on every ESP32 family through the compact @@ -2327,9 +2475,12 @@ apply_esp32_lora_ota_size_profile() { # dropping WiFi OTA, LoRa OTA, USB seeding, or CLI commands. append_platformio_build_unflags "-DDISABLE_WIFI_OTA=1 -DMAX_NEIGHBOURS=50 -DMAX_NEIGHBOURS=8 -fexceptions" export PLATFORMIO_BUILD_FLAGS="${PLATFORMIO_BUILD_FLAGS} -UDISABLE_WIFI_OTA -DLIGHTWEIGHT_WIFI_OTA=1 -DMAX_NEIGHBOURS=${ESP32_FULL_MAX_NEIGHBOURS} -fno-exceptions" + record_build_capability "web.lightweight_browser_ota" else append_platformio_build_unflags "-DLIGHTWEIGHT_WIFI_OTA=1" export PLATFORMIO_BUILD_FLAGS="${PLATFORMIO_BUILD_FLAGS} -ULIGHTWEIGHT_WIFI_OTA -DDISABLE_WIFI_OTA=1" + record_build_reduction \ + "web.browser_ota omitted because this portable radio role does not otherwise require WiFi" fi } @@ -2374,7 +2525,10 @@ apply_esp32_full_size_profile() { # The FULL artifact uses expanded dual-OTA slots, so restore features that # target or legacy-slot profiles disabled only to save application space. append_platformio_build_unflags "-DWEBCONFIG_DISABLED=1" - export PLATFORMIO_BUILD_FLAGS="${PLATFORMIO_BUILD_FLAGS} -UWEBCONFIG_DISABLED -DWIFI_OTA_SEEDER=1 -DMESHCORE_ESP32_FULL_PROFILE=1" + export PLATFORMIO_BUILD_FLAGS="${PLATFORMIO_BUILD_FLAGS} -UWEBCONFIG_DISABLED -DWIFI_OTA_SEEDER=1 -DMESHCORE_EXPANDED_PARTITION_PROFILE=1" + if is_room_server_role_target "$env_name"; then + export PLATFORMIO_BUILD_FLAGS="${PLATFORMIO_BUILD_FLAGS} -DMESH_ENABLE_ROOM_FLOOD_RULE_ENGINE=1" + fi # Keep ordinary builds at their board-defined neighbor capacity. FULL builds # normally use the largest table supported by the one-byte neighbor discovery @@ -2414,6 +2568,8 @@ apply_repeater_neighbor_capacity() { if requires_dram_limited_neighbors "$env_name"; then max_neighbours=$DRAM_LIMITED_MAX_NEIGHBOURS append_platformio_build_unflags "-DMAX_NEIGHBOURS=8 -DMAX_NEIGHBOURS=254" + record_build_reduction \ + "mesh.neighbors limited to ${DRAM_LIMITED_MAX_NEIGHBOURS} by measured RAM/flash capacity" else append_platformio_build_unflags "-DMAX_NEIGHBOURS=8 -DMAX_NEIGHBOURS=50" fi @@ -2445,6 +2601,9 @@ apply_nrf52_size_profile() { apply_lora_ota_no_external_sensors_profile() { local env_name=$1 + if [ "$SKIP_DECLARED_REDUCTIONS" = "1" ]; then + return 0 + fi if ! is_lora_ota_build "$env_name" \ || ! is_lora_ota_no_external_sensors_target "$env_name"; then return 0 @@ -2456,6 +2615,8 @@ apply_lora_ota_no_external_sensors_profile() { # their location provider even when optional external I2C sensors are absent. append_platformio_build_unflags "-DENV_INCLUDE_AHTX0=1 -DENV_INCLUDE_BME280=1 -DENV_INCLUDE_BMP280=1 -DENV_INCLUDE_SHTC3=1 -DENV_INCLUDE_SHT4X=1 -DENV_INCLUDE_LPS22HB=1 -DENV_INCLUDE_INA3221=1 -DENV_INCLUDE_INA219=1 -DENV_INCLUDE_INA226=1 -DENV_INCLUDE_INA260=1 -DENV_INCLUDE_MLX90614=1 -DENV_INCLUDE_VL53L0X=1 -DENV_INCLUDE_BME680=1 -DENV_INCLUDE_BMP085=1 -DENV_INCLUDE_RAK12035=1 -DENV_INCLUDE_BME680_BSEC=1" export PLATFORMIO_BUILD_FLAGS="${PLATFORMIO_BUILD_FLAGS} -UENV_INCLUDE_AHTX0 -UENV_INCLUDE_BME280 -UENV_INCLUDE_BMP280 -UENV_INCLUDE_SHTC3 -UENV_INCLUDE_SHT4X -UENV_INCLUDE_LPS22HB -UENV_INCLUDE_INA3221 -UENV_INCLUDE_INA219 -UENV_INCLUDE_INA226 -UENV_INCLUDE_INA260 -UENV_INCLUDE_MLX90614 -UENV_INCLUDE_VL53L0X -UENV_INCLUDE_BME680 -UENV_INCLUDE_BMP085 -UENV_INCLUDE_RAK12035 -UENV_INCLUDE_BME680_BSEC" + record_build_reduction \ + "sensors.external omitted by the explicitly named no_external_sensors target" } append_platformio_build_unflags() { @@ -2544,6 +2705,17 @@ apply_nrf52_lora_ota_build_recipe() { append_platformio_extra_script "post:tools/mota/pio_endf.py" fi + # A few feature-rich legacy environments deliberately end their own flags + # with -UENABLE_OTA. PlatformIO build_unflags cannot remove a trailing -U, + # so a pre-script must make the intentional OTA overlay authoritative for + # both compilation and the EndF geometry hook. + if pio_env_option_contains "$pio_env_name" build_flags "-UENABLE_OTA"; then + export MESHCORE_FORCE_LORA_OTA=1 + append_platformio_extra_script "pre:scripts/force_lora_ota.py" + else + unset MESHCORE_FORCE_LORA_OTA + fi + if supports_nrf52_internal_bootloader_update "$env_name"; then export MESHCORE_NRF52_INTERNAL_BOOTLOADER_UPDATE=1 else @@ -2586,12 +2758,52 @@ 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 + # 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" + fi else append_platformio_build_unflags "-DENABLE_OTA=1" export PLATFORMIO_BUILD_FLAGS="${PLATFORMIO_BUILD_FLAGS} -UENABLE_OTA" fi } +apply_logical_ota_tuning_flags() { + local logical_env_name=$1 + local pio_env_name=$2 + local ota_flags + + if [ "$logical_env_name" = "$pio_env_name" ]; then + return 0 + fi + + ota_flags=$(python3 -c ' +import json +import sys + +logical_env = sys.argv[1] +data = json.load(sys.stdin) +for section, options in data: + if section != f"env:{logical_env}": + continue + for key, value in options: + if key != "build_flags": + continue + values = value if isinstance(value, list) else [str(value)] + for item in values: + flag = str(item).strip() + if flag.startswith("-D OTA_") or flag.startswith("-DOTA_"): + print(flag) + break +' "$logical_env_name" <<<"$PIO_CONFIG_JSON") + + if [ -n "$ota_flags" ]; then + export PLATFORMIO_BUILD_FLAGS="${PLATFORMIO_BUILD_FLAGS} ${ota_flags}" + fi +} + apply_companion_radio_full_profile() { local env_name=$1 local pio_env_name=$2 @@ -2603,7 +2815,7 @@ apply_companion_radio_full_profile() { # folder transport. Full also restores WebConfig when a constrained legacy # WiFi sibling disabled it only to fit its smaller application partition. append_platformio_build_unflags "-UENABLE_OTA -DOTA_FLASH_STORE=1 -DOTA_SD_STORE=1 -DDISABLE_LORA_OTA=1 -DWEBCONFIG_DISABLED=1" - export PLATFORMIO_BUILD_FLAGS="${PLATFORMIO_BUILD_FLAGS} -UDISABLE_LORA_OTA -DENABLE_OTA=1 -UOTA_FLASH_STORE -UOTA_SD_STORE -UWEBCONFIG_DISABLED -DOTA_SEEDER_ONLY=1 -DMOTA_TARGET_ID=0 -DCOMPANION_RADIO_FULL=1 -DENABLE_USB_INTERFACE=1 -DBLE_PIN_CODE=123456" + export PLATFORMIO_BUILD_FLAGS="${PLATFORMIO_BUILD_FLAGS} -UDISABLE_LORA_OTA -DENABLE_OTA=1 -UOTA_FLASH_STORE -UOTA_SD_STORE -UWEBCONFIG_DISABLED -DOTA_SEEDER_ONLY=1 -DMOTA_TARGET_ID=0 -DCOMPANION_RADIO_FULL=1 -DCOMPANION_FEATURE_TEMP_RADIO=1 -DCOMPANION_FEATURE_OTA_CLI=1 -DENABLE_USB_INTERFACE=1 -DBLE_PIN_CODE=123456" if is_nrf52_companion_radio_full_target "$env_name"; then # CDC 0 starts as Binary Companion. `motatool serve --serial` switches it @@ -2611,7 +2823,7 @@ apply_companion_radio_full_profile() { # preamble. CDC 1 is a write-only plaintext packet/debug logging stream; # BLE remains an independent Companion link. append_platformio_build_unflags "-UOTA_FOLDER_SERIAL" - export PLATFORMIO_BUILD_FLAGS="${PLATFORMIO_BUILD_FLAGS} -DOTA_FOLDER_SERIAL=1 -DCFG_TUD_CDC=2 -DMESH_DUAL_CDC_LOGGING=1 -DMESH_DEBUG=1 -DMESH_PACKET_LOGGING=1" + export PLATFORMIO_BUILD_FLAGS="${PLATFORMIO_BUILD_FLAGS} -DOTA_FOLDER_SERIAL=1 -DCOMPANION_FEATURE_USB_MOTA_SOURCE=1 -DCOMPANION_FEATURE_DEDICATED_USB_LOGGING=1 -DCFG_TUD_CDC=2 -DMESH_DUAL_CDC_LOGGING=1 -DMESH_DEBUG=1 -DMESH_PACKET_LOGGING=1" if ! pio_env_option_contains "$pio_env_name" build_src_filter "helpers/ota/"; then append_platformio_build_src_filter "+" @@ -2630,13 +2842,13 @@ apply_companion_radio_full_profile() { # flashing reboot gesture; CDC 1 is a write-only plaintext diagnostics # stream and cannot reboot the board. append_platformio_build_unflags "-DARDUINO_USB_MODE=1 -DARDUINO_USB_CDC_ON_BOOT=0" - export PLATFORMIO_BUILD_FLAGS="${PLATFORMIO_BUILD_FLAGS} -DARDUINO_USB_MODE=0 -DARDUINO_USB_CDC_ON_BOOT=1 -DMESH_DUAL_CDC_LOGGING=1 -DMESH_DEBUG=1 -DMESH_PACKET_LOGGING=1" + export PLATFORMIO_BUILD_FLAGS="${PLATFORMIO_BUILD_FLAGS} -DARDUINO_USB_MODE=0 -DARDUINO_USB_CDC_ON_BOOT=1 -DCOMPANION_FEATURE_DEDICATED_USB_LOGGING=1 -DMESH_DUAL_CDC_LOGGING=1 -DMESH_DEBUG=1 -DMESH_PACKET_LOGGING=1" fi # ESP32 keeps both source transports. TCP remains the normal unattended # path; a host may explicitly hold the native USB port in Companion terminal # mode when WiFi is unavailable and serve the same folder protocol there. - export PLATFORMIO_BUILD_FLAGS="${PLATFORMIO_BUILD_FLAGS} -DWIFI_OTA_SEEDER=1" + export PLATFORMIO_BUILD_FLAGS="${PLATFORMIO_BUILD_FLAGS} -DWIFI_OTA_SEEDER=1 -DCOMPANION_FEATURE_NETWORK_TERMINAL=1 -DCOMPANION_FEATURE_MEMORY_DIAGNOSTICS=1" # BLE + WiFi exhaust internal DRAM on these high-capacity ESP32 recipes. Use # measured-safe tables for FULL OTA without changing ordinary USB/BLE/WiFi @@ -2670,9 +2882,14 @@ apply_firmware_profile_overrides() { } print_build_flags() { - local env_name=$1 + local pio_env_name=$1 + local logical_env_name=${2:-$pio_env_name} - echo "Build flags for ${env_name}:" + if [ "$logical_env_name" = "$pio_env_name" ]; then + echo "Build flags for ${logical_env_name}:" + else + echo "Build flags for ${logical_env_name} (PlatformIO base ${pio_env_name}):" + fi python3 -c ' import json import os @@ -2709,7 +2926,7 @@ def print_flags(title, flags): print_flags("platformio.ini build_flags", config_flags) print_flags("PLATFORMIO_BUILD_FLAGS", env_flags) print_flags("PLATFORMIO_BUILD_UNFLAGS", env_unflags) -' "$env_name" <<<"$PIO_CONFIG_JSON" +' "$pio_env_name" <<<"$PIO_CONFIG_JSON" } copy_build_output() { @@ -2724,6 +2941,36 @@ copy_build_output() { cp -- "$source_path" "$output_path" } +write_build_capability_manifest() { + local env_name=$1 + local env_platform=$2 + local pio_env_name=$3 + local firmware_filename=$4 + local build_output_dir="${PIO_BUILD_DIR_OVERRIDE:-${PLATFORMIO_BUILD_DIR:-.pio/build}}/${pio_env_name}" + local -a checker_args=( + --image "${build_output_dir}/firmware.elf" + --output "${OUTPUT_DIR}/${firmware_filename}.capabilities.json" + --target "$env_name" + --artifact-target "${FIRMWARE_OUTPUT_ENV_NAME:-$env_name}" + --platformio-env "$pio_env_name" + --platform "$env_platform" + --build-profile "$BUILD_PROFILE_FOR_TARGET" + ) + local item + + for item in "${BUILD_CAPABILITIES[@]}"; do + checker_args+=(--capability "$item") + done + for item in "${BUILD_REDUCTIONS[@]}"; do + checker_args+=(--reduction "$item") + done + for item in "${BUILD_EXPECTATIONS[@]}"; do + checker_args+=(--expect "$item") + done + + python3 scripts/check_firmware_capabilities.py "${checker_args[@]}" +} + collect_esp32_artifacts() { local env_name=$1 local pio_env_name=$2 @@ -2735,11 +2982,18 @@ collect_esp32_artifacts() { ) local partsig_name=$env_name - if [ "$ESP32_FULL_BUILD" != "1" ] && requires_esp32_portable_size_ceiling "$env_name"; then + if [ "$BUILD_PROFILE_FOR_TARGET" = "standard" ] \ + && requires_esp32_portable_size_ceiling "$env_name"; then size_check_args+=("$ESP32_LORA_OTA_APP_LIMIT") fi - python3 scripts/check_esp32_app_size.py "${size_check_args[@]}" || return $? + python3 scripts/check_esp32_app_size.py "${size_check_args[@]}" + local size_status=$? + if [ "$size_status" -eq 1 ]; then + return 42 + elif [ "$size_status" -ne 0 ]; then + return "$size_status" + fi copy_build_output "${build_output_dir}/firmware.bin" "${OUTPUT_DIR}/${firmware_filename}.bin" || return $? copy_build_output "${build_output_dir}/firmware-merged.bin" "${OUTPUT_DIR}/${firmware_filename}-merged.bin" || return $? @@ -2797,6 +3051,10 @@ build_artifacts_exist() { local env_platform=$1 local firmware_filename=$2 + output_artifact_exists "${firmware_filename}.capabilities.json" || return 1 + grep -q '"verified": true' \ + "${OUTPUT_DIR}/${firmware_filename}.capabilities.json" || return 1 + case "$env_platform" in ESP32_PLATFORM) output_artifact_exists "${firmware_filename}.bin" \ @@ -2829,22 +3087,25 @@ collect_build_artifacts() { # collector after the main firmware build succeeds. case "$env_platform" in ESP32_PLATFORM) - collect_esp32_artifacts "$env_name" "$pio_env_name" "$firmware_filename" + collect_esp32_artifacts "$env_name" "$pio_env_name" "$firmware_filename" || return $? ;; NRF52_PLATFORM) - collect_nrf52_artifacts "$env_name" "$pio_env_name" "$firmware_filename" + collect_nrf52_artifacts "$env_name" "$pio_env_name" "$firmware_filename" || return $? ;; STM32_PLATFORM) - collect_stm32_artifacts "$env_name" "$pio_env_name" "$firmware_filename" + collect_stm32_artifacts "$env_name" "$pio_env_name" "$firmware_filename" || return $? ;; RP2040_PLATFORM) - collect_rp2040_artifacts "$env_name" "$pio_env_name" "$firmware_filename" + collect_rp2040_artifacts "$env_name" "$pio_env_name" "$firmware_filename" || return $? ;; *) echo "Unsupported or unknown platform for env: $env_name" return 1 ;; esac + + write_build_capability_manifest \ + "$env_name" "$env_platform" "$pio_env_name" "$firmware_filename" } get_firmware_filename() { @@ -2908,7 +3169,6 @@ build_firmware() { local original_platformio_build_unflags local original_platformio_build_src_filter local original_platformio_extra_scripts - local target_extra_scripts local had_platformio_build_flags=0 local had_platformio_build_unflags=0 local had_platformio_build_src_filter=0 @@ -2916,6 +3176,10 @@ build_firmware() { local build_status local platformio_package_lock_fd="" local -a pio_run_args=() + local -a BUILD_CAPABILITIES=() + local -a BUILD_REDUCTIONS=() + local -a BUILD_EXPECTATIONS=() + local BUILD_PROFILE_FOR_TARGET=$BUILD_PROFILE_EFFECTIVE # Bash functions use dynamic scoping. These locals let one target be promoted # to FULL without changing the profile selected for later targets in the same @@ -2931,9 +3195,19 @@ build_firmware() { return 1 fi pio_env_name=$(get_pio_build_env "$env_name") + if [ "$COMPLETE_OTA_FIRST_PASS" = "1" ] \ + && [ -n "${PIO_ENV_COMPLETE_OTA_BASE_BY_NAME[$env_name]+x}" ]; then + pio_env_name=${PIO_ENV_COMPLETE_OTA_BASE_BY_NAME[$env_name]} + echo "Complete OTA pass uses feature-rich base environment ${pio_env_name} with stable target identity ${env_name}." + fi if [ "$ESP32_FULL_BUILD" != "1" ] \ && requires_esp32_full_cli_profile "$env_name"; then + if [ "$BUILD_PROFILE_EXPLICIT" = "1" ] \ + && [ "${BUILD_PROFILE_OVERRIDE,,}" = "standard" ]; then + echo "Target ${env_name} requires the expanded FULL profile to retain its complete CLI; refusing an explicit standard build." + return 1 + fi ESP32_FULL_BUILD=1 if [ "$FIRMWARE_FILENAME_INFIX" = "logging" ]; then FIRMWARE_FILENAME_INFIX="full-logging" @@ -2943,6 +3217,12 @@ build_firmware() { echo "Promoting ${env_name} to FULL so the complete CLI is retained." fi + if [ "$ESP32_FULL_BUILD" = "1" ] \ + || is_companion_radio_full_target "$env_name"; then + BUILD_PROFILE_FOR_TARGET="full" + fi + echo "Effective feature profile for ${env_name}: ${BUILD_PROFILE_FOR_TARGET}" + commit_hash=$(git rev-parse --short HEAD) firmware_build_date=$(date -u '+%d-%b-%Y') firmware_build_epoch=$(date -u '+%s') @@ -2958,7 +3238,8 @@ build_firmware() { fi firmware_version_string="${firmware_version}-${commit_hash}" - firmware_filename=$(get_firmware_filename "$env_name" "$firmware_version_string") + firmware_filename=$(get_firmware_filename \ + "${FIRMWARE_OUTPUT_ENV_NAME:-$env_name}" "$firmware_version_string") # OTA target id = sha2-256:4(env_name) as a little-endian uint32 (matches tools/mota target_id_for_env # and the device's MainBoard::getOtaTargetId()). Only OTA-enabled profiles receive this identifier. @@ -3023,6 +3304,7 @@ build_firmware() { apply_mqtt_bridge_override disable_usb_logging_for_mqtt "$env_name" apply_lora_ota_override "$env_name" + apply_logical_ota_tuning_flags "$env_name" "$pio_env_name" apply_companion_radio_full_profile "$env_name" "$pio_env_name" apply_nrf52_lora_ota_build_recipe "$env_name" "$pio_env_name" apply_esp32_lora_ota_size_profile "$env_name" @@ -3033,6 +3315,7 @@ build_firmware() { apply_lora_ota_no_external_sensors_profile "$env_name" apply_radio_overrides apply_firmware_profile_overrides + declare_build_capability_contract "$env_name" "$env_platform" if [ "$ESP32_FULL_BUILD" = "1" ] || is_esp32_companion_radio_full_target "$env_name"; then export MESHCORE_ESP32_FULL_BUILD=1 @@ -3041,20 +3324,16 @@ build_firmware() { else unset MESHCORE_COMPANION_RADIO_FULL fi - target_extra_scripts=$original_platformio_extra_scripts - if [[ "$target_extra_scripts" != *"scripts/esp32_full_partition.py"* ]]; then - if [ -n "$target_extra_scripts" ]; then - target_extra_scripts+=$'\n' - fi - target_extra_scripts+="pre:scripts/esp32_full_partition.py" + if ! pio_env_option_contains "$pio_env_name" extra_scripts \ + "scripts/esp32_full_partition.py"; then + append_platformio_extra_script "pre:scripts/esp32_full_partition.py" fi - export PLATFORMIO_EXTRA_SCRIPTS="$target_extra_scripts" else unset MESHCORE_ESP32_FULL_BUILD unset MESHCORE_COMPANION_RADIO_FULL fi - print_build_flags "$env_name" + print_build_flags "$pio_env_name" "$env_name" build_status=0 if [ "$env_platform" = "ESP32_PLATFORM" ]; then # Official ESP32 and pioarduino builds share global package names even @@ -3086,8 +3365,17 @@ build_firmware() { pio_run_args+=(-t mergebin) fi if [ "$build_status" -eq 0 ]; then - pio "${pio_run_args[@]}" - build_status=$? + local build_output_dir="${PIO_BUILD_DIR_OVERRIDE:-${PLATFORMIO_BUILD_DIR:-.pio/build}}/${pio_env_name}" + local build_output_log="${build_output_dir}/.meshcore-build-output.log" + mkdir -p -- "$build_output_dir" + pio "${pio_run_args[@]}" 2>&1 | tee "$build_output_log" + build_status=${PIPESTATUS[0]} + if [ "$build_status" -ne 0 ] \ + && grep -Eiq \ + 'will not fit in region|region .+ overflowed by|section .+ will not fit|sketch too big|program size is greater than maximum|exceed(s|ing).*(flash|partition|app)' \ + "$build_output_log"; then + build_status=42 + fi fi if [ "$build_status" -eq 0 ]; then collect_build_artifacts "$env_name" "$env_platform" "$pio_env_name" "$firmware_filename" @@ -3102,6 +3390,7 @@ build_firmware() { unset MESHCORE_ESP32_FULL_BUILD unset MESHCORE_COMPANION_RADIO_FULL unset MESHCORE_NRF52_INTERNAL_BOOTLOADER_UPDATE + unset MESHCORE_FORCE_LORA_OTA if [ "$had_platformio_build_unflags" -eq 1 ]; then export PLATFORMIO_BUILD_UNFLAGS="$original_platformio_build_unflags" else @@ -3511,6 +3800,114 @@ resolve_command_targets() { fi } +configure_effective_build_profile() { + local command_name=$1 + local target="" + local reduced_target="" + + if [ "${#RESOLVED_BUILD_TARGETS[@]}" -eq 1 ]; then + target=${RESOLVED_BUILD_TARGETS[0]} + fi + + AUTO_PREFER_FULL_BUILD=0 + AUTO_COMPLETE_FIRST_PASS=0 + AUTO_REDUCED_FALLBACK_TARGET="" + AUTO_PUBLISH_REDUCED_SECOND_PASS=0 + + case "${BUILD_PROFILE_OVERRIDE,,}" in + auto|standard|full) ;; + *) + echo "Invalid build profile: ${BUILD_PROFILE_OVERRIDE} (use auto, standard, or full)" + return 1 + ;; + esac + + if [ "$SINGLE_TARGET_FULL_BUILD" = "1" ]; then + BUILD_PROFILE_EFFECTIVE="full" + elif is_automatic_profile_command "$command_name"; then + if [ "$BUILD_PROFILE_EXPLICIT" = "1" ] \ + && [ "${BUILD_PROFILE_OVERRIDE,,}" != "auto" ]; then + echo "${command_name} manages standard and FULL profiles itself; do not combine it with --build-profile ${BUILD_PROFILE_OVERRIDE}." + return 1 + fi + BUILD_PROFILE_EFFECTIVE="standard" + else + case "${BUILD_PROFILE_OVERRIDE,,}" in + auto) + if [ "$command_name" = "build-firmware" ] \ + && [ "${#RESOLVED_BUILD_TARGETS[@]}" -eq 1 ]; then + if is_companion_radio_full_target "$target"; then + BUILD_PROFILE_EFFECTIVE="full" + elif supports_esp32_full_build "$target"; then + BUILD_PROFILE_EFFECTIVE="full" + AUTO_PREFER_FULL_BUILD=1 + AUTO_REDUCED_FALLBACK_TARGET=$(get_reduced_lora_ota_target "$target" || true) + elif is_lora_ota_no_external_sensors_target "$target"; then + # An explicitly selected reduced target means exactly what its + # name says; do not silently put omitted sensors back into it. + BUILD_PROFILE_EFFECTIVE="standard" + elif reduced_target=$(get_reduced_lora_ota_target "$target"); then + RESOLVED_BUILD_TARGETS[0]=$reduced_target + BUILD_PROFILE_EFFECTIVE="auto" + AUTO_COMPLETE_FIRST_PASS=1 + AUTO_REDUCED_FALLBACK_TARGET=$reduced_target + if [ "${PIO_ENV_PLATFORM_BY_NAME[$target]:-}" = "NRF52_PLATFORM" ] \ + && [ "${PIO_ENV_SD_OTA_BY_NAME[$reduced_target]:-0}" = "0" ] \ + && [ "${PIO_ENV_QSPI_OTA_BY_NAME[$reduced_target]:-0}" = "0" ]; then + AUTO_PUBLISH_REDUCED_SECOND_PASS=1 + fi + echo "Auto pass 1 selected: complete LoRa OTA for ${reduced_target}; reductions are deferred unless it is too large." + else + # Companion and non-repeater targets without an expanded recipe + # keep every capability declared by their target and are checked + # against the current partition. + BUILD_PROFILE_EFFECTIVE="auto" + fi + else + # Canonical and matching builds are release-oriented. Preserve their + # deployed partition contract unless FULL was explicitly requested. + BUILD_PROFILE_EFFECTIVE="standard" + fi + ;; + standard) + BUILD_PROFILE_EFFECTIVE="standard" + ;; + full) + if [ "$command_name" != "build-firmware" ] \ + || [ "${#RESOLVED_BUILD_TARGETS[@]}" -ne 1 ]; then + echo "--build-profile full requires exactly one build-firmware target; use a build-full-* command for a matrix." + return 1 + fi + if is_companion_radio_full_target "$target"; then + BUILD_PROFILE_EFFECTIVE="full" + elif supports_esp32_full_build "$target"; then + BUILD_PROFILE_EFFECTIVE="full" + SINGLE_TARGET_FULL_BUILD=1 + else + echo "Target ${target} has no expanded FULL partition profile. Use --build-profile auto or standard." + return 1 + fi + ;; + esac + fi + + case "$BUILD_PROFILE_EFFECTIVE" in + auto) + echo "Build profile: auto (keep target capabilities; enforce its current partition)." + ;; + standard) + echo "Build profile: standard (preserve the portable/deployed partition contract; documented reductions may apply)." + ;; + full) + if [ "${PIO_ENV_PLATFORM_BY_NAME[$target]:-}" = "ESP32_PLATFORM" ]; then + echo "Build profile: full (expanded partition; a matching merged image is required when the partition table changes)." + else + echo "Build profile: full (feature-complete named target in its existing application layout)." + fi + ;; + esac +} + prepare_output_dir() { local output_dir="$OUTPUT_DIR" @@ -4103,8 +4500,104 @@ validate_command() { esac } +run_auto_two_pass_build() { + local target=$1 + local fallback_target=$AUTO_REDUCED_FALLBACK_TARGET + local original_esp32_full_build=$ESP32_FULL_BUILD + local original_build_profile_effective=$BUILD_PROFILE_EFFECTIVE + local original_firmware_filename_infix=$FIRMWARE_FILENAME_INFIX + local original_skip_declared_reductions=$SKIP_DECLARED_REDUCTIONS + local original_complete_ota_first_pass=$COMPLETE_OTA_FIRST_PASS + local original_firmware_output_env_name=$FIRMWARE_OUTPUT_ENV_NAME + local build_status=0 + + if [ "$AUTO_PREFER_FULL_BUILD" = "1" ]; then + ESP32_FULL_BUILD=1 + BUILD_PROFILE_EFFECTIVE="full" + FIRMWARE_FILENAME_INFIX="full" + SKIP_DECLARED_REDUCTIONS=0 + COMPLETE_OTA_FIRST_PASS=0 + FIRMWARE_OUTPUT_ENV_NAME="" + echo "Auto pass 1/2: building the complete expanded-partition profile for ${target}." + else + ESP32_FULL_BUILD=0 + BUILD_PROFILE_EFFECTIVE="auto" + FIRMWARE_FILENAME_INFIX="full-ota" + SKIP_DECLARED_REDUCTIONS=1 + COMPLETE_OTA_FIRST_PASS=1 + if [ "$AUTO_PUBLISH_REDUCED_SECOND_PASS" = "1" ]; then + FIRMWARE_OUTPUT_ENV_NAME=${PIO_ENV_COMPLETE_OTA_BASE_BY_NAME[$target]:-${target%_lora_ota_no_external_sensors}} + else + FIRMWARE_OUTPUT_ENV_NAME="" + fi + echo "Auto pass 1/2: building complete LoRa OTA for ${target} without declared feature reductions." + fi + + if build_firmware "$target"; then + build_status=0 + else + build_status=$? + fi + + if [ "$build_status" -eq 42 ]; then + if [ -z "$fallback_target" ]; then + echo "The complete image is too large and ${target} has no supported reduced LoRa OTA recipe." + build_status=1 + else + echo "Auto pass 1 exceeded measured flash/partition capacity; pass 2 is rebuilding ${fallback_target} with the standard reduced LoRa OTA recipe." + ESP32_FULL_BUILD=0 + BUILD_PROFILE_EFFECTIVE="standard" + FIRMWARE_FILENAME_INFIX="reduced-ota" + SKIP_DECLARED_REDUCTIONS=0 + COMPLETE_OTA_FIRST_PASS=0 + FIRMWARE_OUTPUT_ENV_NAME="" + if build_firmware "$fallback_target"; then + build_status=0 + else + build_status=$? + fi + if [ "$build_status" -eq 0 ]; then + echo "Auto pass 2 succeeded: ${fallback_target} (reduced LoRa OTA)." + fi + fi + elif [ "$build_status" -ne 0 ]; then + echo "Auto pass 1 failed for a non-size reason; refusing to hide the compiler or capability failure with a reduced build." + elif [ "$AUTO_PUBLISH_REDUCED_SECOND_PASS" = "1" ] \ + && [ -n "$fallback_target" ]; then + echo "Auto pass 1 succeeded. Internal-flash nRF52 repeater policy also publishes a reduced LoRa OTA image with more delta-staging headroom." + ESP32_FULL_BUILD=0 + BUILD_PROFILE_EFFECTIVE="standard" + FIRMWARE_FILENAME_INFIX="reduced-ota" + SKIP_DECLARED_REDUCTIONS=0 + COMPLETE_OTA_FIRST_PASS=0 + FIRMWARE_OUTPUT_ENV_NAME="" + if build_firmware "$fallback_target"; then + build_status=0 + echo "Auto pass 2 succeeded: ${fallback_target} (reduced LoRa OTA)." + else + build_status=$? + fi + else + echo "Auto pass 1 succeeded; no reductions were needed." + fi + + ESP32_FULL_BUILD=$original_esp32_full_build + BUILD_PROFILE_EFFECTIVE=$original_build_profile_effective + FIRMWARE_FILENAME_INFIX=$original_firmware_filename_infix + SKIP_DECLARED_REDUCTIONS=$original_skip_declared_reductions + COMPLETE_OTA_FIRST_PASS=$original_complete_ota_first_pass + FIRMWARE_OUTPUT_ENV_NAME=$original_firmware_output_env_name + return "$build_status" +} + run_command() { # All build commands share execution after validation resolves their target list. + if [ "$AUTO_PREFER_FULL_BUILD" = "1" ] \ + || [ "$AUTO_COMPLETE_FIRST_PASS" = "1" ]; then + run_auto_two_pass_build "${RESOLVED_BUILD_TARGETS[0]}" + return $? + fi + if [ "$SINGLE_TARGET_FULL_BUILD" = "1" ]; then run_full_esp32_build_targets "all" "${RESOLVED_BUILD_TARGETS[@]}" return $? @@ -4214,6 +4707,9 @@ main() { exit 1 fi fi + if ! configure_effective_build_profile "$1"; then + exit 1 + fi refresh_firmware_version_tags prompt_for_resolved_firmware_version diff --git a/docs/cli_build_matrix.md b/docs/cli_build_matrix.md index 9307c1ab..7ea0ae3c 100644 --- a/docs/cli_build_matrix.md +++ b/docs/cli_build_matrix.md @@ -12,6 +12,36 @@ page describes the commands intentionally omitted or limited by build profile. The [CLI Command Availability Matrix](cli_command_availability.md) expands this summary into separate nRF52 and ESP32 command tables. +## Selecting a build profile + +The runtime settings profile and the feature profile are separate switches: + +```bash +bash build.sh build-firmware RAK_3401_repeater \ + --profile cascade \ + --build-profile auto +``` + +`--profile default|cascade` selects saved-setting defaults. It does not select +which code is linked. `--build-profile auto|standard|full` controls features +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. | +| `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. | + +Successful builds also emit +`.capabilities.json`. The sidecar records the effective profile, +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 +advanced flood-rule engine for Full room servers. Resume mode will not accept +an old or failed artifact without a verified sidecar. + ## Role comes first | Role | Text administration CLI | @@ -35,9 +65,10 @@ retain 50 because their MQTT discovery tables are constrained by internal DRAM. | Build/profile | Command availability | |---|---| -| Standard non-MQTT repeater or room server | Keeps the normal role CLI. Size-constrained ESP32 artifacts can omit WebConfig and browser WiFi OTA, so their WebConfig/WiFi commands are unavailable. | +| Standard non-MQTT repeater or room server | Keeps the normal role CLI. The explicitly selected portable policy can omit WebConfig and browser WiFi OTA, so those commands are unavailable and the omission is recorded in the capability manifest. | | Standard logging | Logging does not remove commands by itself. It has the same CLI as the selected role/profile and adds compiled logging behavior. CommonCLI roles persist `get/set usb.logging`. ESP32 roles covered by unified FULL and nRF52 Companions covered by dual-CDC Full Companion are not duplicated here. | | LoRa-OTA (`-ota-`) | LoRa OTA adds the `ota ...` commands; it does not otherwise reduce the role CLI. ESP32 `no_external_sensors` artifacts retain the compact browser WiFi uploader, the complete CLI, and a 254-entry neighbor table. | +| Internal-flash nRF52 repeater auto pair | `full-ota` retains the board's external-sensor drivers; `reduced-ota` omits only the declared external sensors to leave additional internal-flash staging room. Both carry the same stable OTA target identity and both are checked for `ota ...` and `retry.preset`. | | ESP32 MQTT observer or ESP-NOW bridge | Always uses the expanded FULL partition profile. The build never substitutes a reduced CLI to fit the legacy application slot. | | FULL ESP32 USB + WiFi | Uses the matching MQTT target with packet logging on, verbose debug off, and the complete command surface supported by that role and hardware. `get/set logging.output off\|usb\|wifi\|both` selects and persists the active output paths. | | FULL ESP32 logging fallback | Uses the matching non-MQTT target only when no WiFi MQTT sibling exists, with debug and packet logging enabled and the complete command surface supported by that role and hardware. Its persistent USB gate also covers output-off operation, avoiding a second FULL ESP-NOW image. | @@ -83,7 +114,9 @@ partitions, RAM use, active transports, and power behavior. ## Complete CLI policy -The compact ESP32 CLI has been removed. MQTT observers and ESP-NOW bridges are +The compact ESP32 CLI has been removed. `retry.preset` is a checked invariant +for every repeater and room-server artifact, not a FULL-only command. MQTT +observers and ESP-NOW bridges are automatically promoted to FULL builds with expanded partitions rather than dropping administration commands. This keeps `tempradio`, LoRa OTA, power saving, RXPS, logging, statistics, sensor, ACL, routing, and advanced radio @@ -140,4 +173,6 @@ does not exist on that target: - `uf2reset` applies only to nRF52. When diagnosing an unavailable command, check the role first, then the filename -profile, and finally the target's compiled hardware features. +profile, then the target's compiled hardware features and its adjacent +`.capabilities.json` file. A current artifact that promises the command but +does not contain its linked marker is rejected during the build. diff --git a/docs/companion_radio_full.md b/docs/companion_radio_full.md index b0723a50..aabdc591 100644 --- a/docs/companion_radio_full.md +++ b/docs/companion_radio_full.md @@ -156,6 +156,16 @@ after WiFi/WebConfig so their peak startup allocations do not overlap. Artifacts are written to `out/` by default. +Full Companion behavior is selected with independent capability macros for +TempRadio, the OTA CLI, the TCP terminal, USB folder seeding, and memory +diagnostics. The legacy `COMPANION_RADIO_FULL` flag remains an input for older +target recipes, but application behavior no longer uses that umbrella as an +unrelated compile guard. In particular, ESP32 WiFi/WebConfig terminal controls +are compiled from their actual WiFi/WebConfig capability, and BLE is always +started before WiFi on any combined ESP32 WiFi+BLE Companion to avoid heap +fragmentation. The build's capability sidecar verifies the Full controls after +linking. + On 4 MB ESP32 boards, the full target uses a single 3 MB application partition so WiFi, BLE, WebConfig, and source-only mOTA fit together. The T-Beam 1W Full Companion uses that same LilyGo factory-compatible boot layout on its 16 MB diff --git a/docs/ota_easy.md b/docs/ota_easy.md index 41e84f12..6a36345c 100644 --- a/docs/ota_easy.md +++ b/docs/ota_easy.md @@ -102,6 +102,15 @@ ota`), the complete CLI, and a 254-entry neighbor table. RP2040 and STM32 repeaters do not currently have a safe self-apply path, but current repeater firmware can still relay OTA packets opaquely during TempRadio. +For one explicitly selected internal-flash nRF52 repeater, the default +`--build-profile auto` run emits two install-capable choices. The +`*-full-ota-*` artifact retains external sensors, while the +`*_lora_ota_no_external_sensors-reduced-ota-*` artifact trades only those +optional drivers for more delta-staging space. They retain the same OTA target +identity so update tooling does not confuse the optimization choice with a +different board. QSPI- and SD-backed repeaters emit only the complete choice +unless a measured size failure requires a fallback. + nRF52 `-ota-` siblings are compiled with size optimization instead of the Adafruit platform's default speed optimization. This prevents the retained software Ed25519 fallback from expanding beyond the fixed in-place workspace; CC310 hardware crypto, hardware RNG mixing, telemetry history, and board-native features diff --git a/examples/companion_radio/CompanionFeatures.h b/examples/companion_radio/CompanionFeatures.h new file mode 100644 index 00000000..c3448448 --- /dev/null +++ b/examples/companion_radio/CompanionFeatures.h @@ -0,0 +1,59 @@ +#pragma once + +// Features are selected independently so adding a transport or changing an +// image-size profile cannot accidentally remove unrelated Companion behavior. +// Keep the legacy umbrella as a compatibility input for existing target files; +// application code must use the capability macros below. +#if defined(COMPANION_RADIO_FULL) + #ifndef COMPANION_FEATURE_TEMP_RADIO + #define COMPANION_FEATURE_TEMP_RADIO 1 + #endif + #ifndef COMPANION_FEATURE_OTA_CLI + #define COMPANION_FEATURE_OTA_CLI 1 + #endif + #ifndef COMPANION_FEATURE_NETWORK_TERMINAL + #define COMPANION_FEATURE_NETWORK_TERMINAL 1 + #endif + #ifndef COMPANION_FEATURE_MEMORY_DIAGNOSTICS + #define COMPANION_FEATURE_MEMORY_DIAGNOSTICS 1 + #endif + #if defined(MESH_DUAL_CDC_LOGGING) + #ifndef COMPANION_FEATURE_DEDICATED_USB_LOGGING + #define COMPANION_FEATURE_DEDICATED_USB_LOGGING 1 + #endif + #endif + #if defined(NRF52_PLATFORM) && defined(OTA_FOLDER_SERIAL) + #ifndef COMPANION_FEATURE_USB_MOTA_SOURCE + #define COMPANION_FEATURE_USB_MOTA_SOURCE 1 + #endif + #endif +#endif + +#ifndef COMPANION_FEATURE_TEMP_RADIO + #define COMPANION_FEATURE_TEMP_RADIO 0 +#endif +#ifndef COMPANION_FEATURE_OTA_CLI + #define COMPANION_FEATURE_OTA_CLI 0 +#endif +#ifndef COMPANION_FEATURE_NETWORK_TERMINAL + #define COMPANION_FEATURE_NETWORK_TERMINAL 0 +#endif +#ifndef COMPANION_FEATURE_MEMORY_DIAGNOSTICS + #define COMPANION_FEATURE_MEMORY_DIAGNOSTICS 0 +#endif +#ifndef COMPANION_FEATURE_USB_MOTA_SOURCE + #define COMPANION_FEATURE_USB_MOTA_SOURCE 0 +#endif +#ifndef COMPANION_FEATURE_DEDICATED_USB_LOGGING + #define COMPANION_FEATURE_DEDICATED_USB_LOGGING 0 +#endif + +#if COMPANION_FEATURE_NETWORK_TERMINAL && !defined(ENABLE_USB_INTERFACE) + #error "COMPANION_FEATURE_NETWORK_TERMINAL requires ENABLE_USB_INTERFACE" +#endif + +#if COMPANION_FEATURE_USB_MOTA_SOURCE \ + && !(defined(NRF52_PLATFORM) && defined(OTA_FOLDER_SERIAL) \ + && defined(ENABLE_USB_INTERFACE)) + #error "COMPANION_FEATURE_USB_MOTA_SOURCE requires nRF52 USB folder seeding" +#endif diff --git a/examples/companion_radio/MyMesh.cpp b/examples/companion_radio/MyMesh.cpp index 88354562..cc37c0a3 100644 --- a/examples/companion_radio/MyMesh.cpp +++ b/examples/companion_radio/MyMesh.cpp @@ -25,7 +25,7 @@ #include #endif -#if defined(COMPANION_RADIO_FULL) +#if COMPANION_FEATURE_OTA_CLI #include #endif @@ -430,7 +430,7 @@ float MyMesh::getAirtimeBudgetFactor() const { // TempRadio is a short-lived, explicitly coordinated OTA channel. Let its // primary transfer use the full dispatcher budget; the persisted public // channel duty factor becomes authoritative again as soon as TempRadio ends. -#if defined(COMPANION_RADIO_FULL) +#if COMPANION_FEATURE_TEMP_RADIO if (isTempRadioActive()) return 0.0f; #endif return _prefs.airtime_factor; @@ -1413,7 +1413,7 @@ MyMesh::MyMesh(mesh::Radio &radio, mesh::RNG &rng, mesh::RTCClock &rtc, SimpleMe #if MESH_USB_LOGGING_AVAILABLE _usb_logging_reboot_at = 0; #endif -#if defined(COMPANION_RADIO_FULL) +#if COMPANION_FEATURE_TEMP_RADIO _temp_radio_set_at = 0; _temp_radio_revert_at = 0; _temp_radio_retry_at = 0; @@ -1474,7 +1474,7 @@ MyMesh::MyMesh(mesh::Radio &radio, mesh::RNG &rng, mesh::RTCClock &rtc, SimpleMe _prefs.powersaving_enabled = 1; _prefs.powersaving_policy_version = 0; _prefs.wifi_enabled = 1; -#if defined(COMPANION_RADIO_FULL) && defined(MESH_DUAL_CDC_LOGGING) +#if defined(MESH_DUAL_CDC_LOGGING) // Keep the primary CDC stream exclusively framed unless the owner opts in // to the separate plaintext logging interface and reboots. _prefs.usb_logging_enabled = 0; @@ -1800,7 +1800,7 @@ void MyMesh::servicePendingRadioParamApply() { } } -#if defined(COMPANION_RADIO_FULL) +#if COMPANION_FEATURE_TEMP_RADIO static bool isFullCompanionBandwidth(float bw) { static const float supported[] = { 7.8f, 10.4f, 15.6f, 20.8f, 31.25f, 41.7f, @@ -2005,7 +2005,8 @@ bool MyMesh::handleLocalControlCommand(const char* command, char* reply, } #endif -#if defined(COMPANION_RADIO_FULL) +#if COMPANION_FEATURE_TEMP_RADIO || COMPANION_FEATURE_OTA_CLI +#if COMPANION_FEATURE_TEMP_RADIO if (strcmp(command, "tempradio") == 0) { if (_temp_radio_set_at) { snprintf(reply, reply_size, "TempRadio pending: %.3f,%.2f,%u,%u", @@ -2048,7 +2049,9 @@ bool MyMesh::handleLocalControlCommand(const char* command, char* reply, scheduleNormalRadio(reply, reply_size); return true; } +#endif +#if COMPANION_FEATURE_OTA_CLI if (strncmp(command, "ota", 3) == 0 && (command[3] == 0 || command[3] == ' ')) { char ota_reply[160] = {0}; @@ -2056,12 +2059,13 @@ bool MyMesh::handleLocalControlCommand(const char* command, char* reply, snprintf(reply, reply_size, "%s", ota_reply); return true; } +#endif #endif return false; } -#if defined(COMPANION_RADIO_FULL) +#if COMPANION_FEATURE_TEMP_RADIO void MyMesh::serviceTempRadio() { const unsigned long now = _ms->getMillis(); const bool retry_ready = !_temp_radio_retry_at @@ -3149,7 +3153,7 @@ void MyMesh::handleCmdFrame(size_t len) { writeErrFrame(ERR_CODE_ILLEGAL_ARG); return; } -#if defined(COMPANION_RADIO_FULL) +#if COMPANION_FEATURE_TEMP_RADIO if (isTempRadioActive() || _temp_radio_set_at != 0 || _temp_radio_revert_at != 0) { writeErrFrame(ERR_CODE_BAD_STATE); @@ -4217,7 +4221,7 @@ void MyMesh::resetTerminalSession() { void MyMesh::printTerminalBanner(bool show_binary_stop) { Stream& output = terminalOutput(); -#if defined(COMPANION_RADIO_FULL) +#if COMPANION_FEATURE_TEMP_RADIO || COMPANION_FEATURE_NETWORK_TERMINAL output.print("\r\n===== MeshCore Full Companion Terminal =====\r\n\r\n"); #else output.print("\r\n===== MeshCore Companion Terminal =====\r\n\r\n"); @@ -4247,7 +4251,7 @@ void MyMesh::exitTerminalMode() { if (_terminal_output == &Serial) _terminal_output = NULL; } -#if defined(COMPANION_RADIO_FULL) +#if COMPANION_FEATURE_NETWORK_TERMINAL bool MyMesh::enterNetworkTerminalMode(Stream& output) { if (_terminal_mode) return false; _terminal_output = &output; @@ -5059,7 +5063,7 @@ void MyMesh::handleTerminalCommand(char* command) { uint32_t effective_sleep_us = _prefs.rx_ps_sleep_us; uint8_t active_sf = _prefs.sf; float active_bw = _prefs.bw; -#if defined(COMPANION_RADIO_FULL) +#if COMPANION_FEATURE_TEMP_RADIO if (isTempRadioActive()) { active_sf = _temp_radio_sf; active_bw = _temp_radio_bw; @@ -5318,10 +5322,14 @@ void MyMesh::handleTerminalCommand(char* command) { terminalOutput().print(" public \r\n"); terminalOutput().print(" channels\r\n"); terminalOutput().print(" channel \r\n"); -#if defined(COMPANION_RADIO_FULL) +#if COMPANION_FEATURE_TEMP_RADIO || COMPANION_FEATURE_OTA_CLI +#if COMPANION_FEATURE_TEMP_RADIO terminalOutput().print(" tempradio [freq,bw,sf,cr,minutes]\r\n"); terminalOutput().print(" normalradio\r\n"); +#endif +#if COMPANION_FEATURE_OTA_CLI terminalOutput().print(" ota {status|ls|announce|folder|config|...}\r\n"); +#endif #endif terminalOutput().print(" ver\r\n"); if (_terminal_mode) { @@ -5555,7 +5563,7 @@ void MyMesh::loop() { return; } #endif -#if defined(COMPANION_RADIO_FULL) +#if COMPANION_FEATURE_TEMP_RADIO serviceTempRadio(); #endif BaseChatMesh::loop(); @@ -5568,7 +5576,7 @@ void MyMesh::loop() { serviceTerminalTrace(); #endif if (!command_radio_apply_pending && saved_radio_apply_pending && !hasOutbound() -#if defined(COMPANION_RADIO_FULL) +#if COMPANION_FEATURE_TEMP_RADIO && !_temp_radio_applied && _temp_radio_set_at == 0 && _temp_radio_revert_at == 0 #endif @@ -5647,7 +5655,7 @@ bool MyMesh::hasPendingWork() const { || (dirty_contacts_expiry != 0 && millisHasNowPassed(dirty_contacts_expiry)) || (emergency_client_repeat_packet != NULL && millisHasNowPassed(emergency_client_repeat_send_at)) -#if defined(COMPANION_RADIO_FULL) +#if COMPANION_FEATURE_TEMP_RADIO || (_temp_radio_set_at != 0 && millisHasNowPassed(_temp_radio_set_at)) || (_temp_radio_revert_at != 0 && millisHasNowPassed(_temp_radio_revert_at)) || (_temp_radio_retry_at != 0 && millisHasNowPassed(_temp_radio_retry_at)) diff --git a/examples/companion_radio/MyMesh.h b/examples/companion_radio/MyMesh.h index b895a311..b031856e 100644 --- a/examples/companion_radio/MyMesh.h +++ b/examples/companion_radio/MyMesh.h @@ -3,6 +3,7 @@ #include #include #include "AbstractUITask.h" +#include "CompanionFeatures.h" /*------------ Frame Protocol --------------*/ #define FIRMWARE_VER_CODE 13 @@ -80,8 +81,8 @@ #ifndef OFFLINE_QUEUE_SIZE #if defined(ESP32_PLATFORM) && defined(BOARD_HAS_PSRAM) #define OFFLINE_QUEUE_SIZE 512 -#elif defined(COMPANION_RADIO_FULL) || defined(ESP32_PLATFORM) \ - || defined(NRF52_PLATFORM) || defined(RP2040_PLATFORM) +#elif defined(ESP32_PLATFORM) || defined(NRF52_PLATFORM) \ + || defined(RP2040_PLATFORM) #define OFFLINE_QUEUE_SIZE 256 #else #define OFFLINE_QUEUE_SIZE 16 @@ -169,7 +170,7 @@ public: void exitTerminalMode(); bool isTerminalMode() const { return _terminal_mode; } void handleTerminalCommand(char* command); -#if defined(COMPANION_RADIO_FULL) +#if COMPANION_FEATURE_NETWORK_TERMINAL bool enterNetworkTerminalMode(Stream& output); void exitNetworkTerminalMode(Stream& output); bool isNetworkTerminalMode(const Stream& output) const; @@ -184,7 +185,7 @@ public: int getRecentlyHeard(AdvertPath dest[], int max_num); protected: -#if defined(COMPANION_RADIO_FULL) +#if COMPANION_FEATURE_TEMP_RADIO bool isTempRadioActive() const override { return _temp_radio_applied && _temp_radio_revert_at != 0 && !millisHasNowPassed(_temp_radio_revert_at); @@ -357,7 +358,7 @@ private: void finishRadioParamApply(float freq, float bw, uint8_t sf, uint8_t cr, uint8_t repeat); void cancelPendingRadioParamApply(); void servicePendingRadioParamApply(); -#if defined(COMPANION_RADIO_FULL) +#if COMPANION_FEATURE_TEMP_RADIO bool scheduleTempRadio(float freq, float bw, uint8_t sf, uint8_t cr, uint32_t timeout_mins, char* reply, size_t reply_size); void scheduleNormalRadio(char* reply, size_t reply_size); @@ -435,7 +436,7 @@ private: #if MESH_USB_LOGGING_AVAILABLE unsigned long _usb_logging_reboot_at; #endif -#if defined(COMPANION_RADIO_FULL) +#if COMPANION_FEATURE_TEMP_RADIO unsigned long _temp_radio_set_at; unsigned long _temp_radio_revert_at; unsigned long _temp_radio_retry_at; diff --git a/examples/companion_radio/main.cpp b/examples/companion_radio/main.cpp index a697bc04..d731c41a 100644 --- a/examples/companion_radio/main.cpp +++ b/examples/companion_radio/main.cpp @@ -19,7 +19,7 @@ #else #define COMPANION_BT_MODEM_SLEEP_AVAILABLE 0 #endif -#if defined(COMPANION_RADIO_FULL) +#if COMPANION_FEATURE_MEMORY_DIAGNOSTICS #include #endif #endif @@ -80,7 +80,7 @@ MultiSerialInterface interface_manager; #include static const char USB_TERMINAL_START_TOKEN[] = "+++MESHCORE-TERM-START"; static const char USB_TERMINAL_STOP_TOKEN[] = "+++MESHCORE-TERM-STOP"; -#if defined(NRF52_PLATFORM) && defined(COMPANION_RADIO_FULL) && defined(OTA_FOLDER_SERIAL) +#if COMPANION_FEATURE_USB_MOTA_SOURCE // motatool sends this command automatically when `serve --serial` opens the // port. In Binary mode it is an exact, idle-parser control sequence that // hands USB ownership to the host-backed mOTA source. @@ -291,7 +291,7 @@ static char usb_terminal_line[MAX_TRANS_UNIT * 2 + 32]; static size_t usb_terminal_line_len = 0; static bool usb_terminal_discard_line = false; static bool usb_terminal_disconnect_armed = false; -#if defined(NRF52_PLATFORM) && defined(COMPANION_RADIO_FULL) && defined(OTA_FOLDER_SERIAL) +#if COMPANION_FEATURE_USB_MOTA_SOURCE static bool usb_mota_mode = false; static char usb_mota_line[32]; static size_t usb_mota_line_len = 0; @@ -359,7 +359,7 @@ static void leaveUsbTerminalMode(bool acknowledge) { usb_terminal_disconnect_armed = false; } -#if defined(NRF52_PLATFORM) && defined(COMPANION_RADIO_FULL) && defined(OTA_FOLDER_SERIAL) +#if COMPANION_FEATURE_USB_MOTA_SOURCE static void resetUsbMotaMode() { usb_mota_mode = false; usb_mota_line_len = 0; @@ -440,7 +440,7 @@ static void serviceUsbMota() { #endif static void serviceUsbTerminal() { -#if defined(NRF52_PLATFORM) && defined(COMPANION_RADIO_FULL) && defined(OTA_FOLDER_SERIAL) +#if COMPANION_FEATURE_USB_MOTA_SOURCE if (usb_mota_mode) { serviceUsbMota(); return; @@ -449,7 +449,7 @@ static void serviceUsbTerminal() { if (!the_mesh.isTerminalMode()) { if (usb_serial_interface.takeControlSequence()) { enterUsbTerminalMode(); -#if defined(NRF52_PLATFORM) && defined(COMPANION_RADIO_FULL) && defined(OTA_FOLDER_SERIAL) +#if COMPANION_FEATURE_USB_MOTA_SOURCE } else if (usb_serial_interface.takeSecondaryControlSequence()) { enterUsbMotaMode(); #endif @@ -746,7 +746,7 @@ void halt() { static void ota_console_start() { ota_console_server.begin(); -#if defined(COMPANION_RADIO_FULL) && defined(ENABLE_USB_INTERFACE) +#if COMPANION_FEATURE_NETWORK_TERMINAL WIFI_DEBUG_PRINTLN("Full Companion terminal listening on :%d (nc %d)", OTA_CONSOLE_TCP_PORT, OTA_CONSOLE_TCP_PORT); #else @@ -756,7 +756,7 @@ void halt() { } static void ota_console_stop() { -#if defined(COMPANION_RADIO_FULL) && defined(ENABLE_USB_INTERFACE) +#if COMPANION_FEATURE_NETWORK_TERMINAL the_mesh.exitNetworkTerminalMode(ota_console_client); #endif if (ota_console_client) ota_console_client.stop(); @@ -767,7 +767,7 @@ void halt() { static void ota_console_loop() { if (!ota_console_client || !ota_console_client.connected()) { -#if defined(COMPANION_RADIO_FULL) && defined(ENABLE_USB_INTERFACE) +#if COMPANION_FEATURE_NETWORK_TERMINAL the_mesh.exitNetworkTerminalMode(ota_console_client); #endif WiFiClient c = ota_console_server.available(); @@ -775,7 +775,7 @@ void halt() { ota_console_client = c; ota_console_clear_line(); ota_console_discard_line = false; -#if defined(COMPANION_RADIO_FULL) && defined(ENABLE_USB_INTERFACE) +#if COMPANION_FEATURE_NETWORK_TERMINAL if (!the_mesh.enterNetworkTerminalMode(ota_console_client)) { ota_console_client.print( "ERROR: USB currently owns the Full Companion terminal\r\n"); @@ -787,7 +787,7 @@ void halt() { } return; } -#if defined(COMPANION_RADIO_FULL) && defined(ENABLE_USB_INTERFACE) +#if COMPANION_FEATURE_NETWORK_TERMINAL if (!the_mesh.isNetworkTerminalMode(ota_console_client)) { ota_console_client.print( "\r\nERROR: terminal ownership moved to USB; closing\r\n"); @@ -809,7 +809,7 @@ void halt() { if (ch == '\r' || ch == '\n') { if (ota_console_len == 0) continue; // ignore blanks / the CRLF pair ota_console_line[ota_console_len] = 0; -#if defined(COMPANION_RADIO_FULL) && defined(ENABLE_USB_INTERFACE) +#if COMPANION_FEATURE_NETWORK_TERMINAL if (strcmp(ota_console_line, "disconnect") == 0) { ota_console_client.print(" OK - disconnecting\r\n"); the_mesh.exitNetworkTerminalMode(ota_console_client); @@ -1007,7 +1007,7 @@ void halt() { } #endif -#if defined(ESP32_PLATFORM) && defined(COMPANION_RADIO_FULL) +#if defined(ESP32_PLATFORM) && COMPANION_FEATURE_MEMORY_DIAGNOSTICS static void logFullCompanionMemory(const char* stage) { mesh::usbLoggingPort().printf( "Full Companion memory %s: heap=%u largest_internal=%u psram_free=%u/%u offline_queue=%d\r\n", @@ -1152,15 +1152,14 @@ void setup() { loadCompanionWiFiCredentials(); #endif -#if defined(ESP32_PLATFORM) && defined(COMPANION_RADIO_FULL) +#if defined(ESP32_PLATFORM) && COMPANION_FEATURE_MEMORY_DIAGNOSTICS logFullCompanionMemory("before interfaces"); #endif // NimBLE must reserve its internal controller/host memory before WiFi starts // its AP and WebConfig services. Starting WiFi first can leave plenty of total // heap but no internal block large enough for nimble_port_init(). -#if defined(BLE_PIN_CODE) && defined(ESP32) \ - && defined(COMPANION_RADIO_FULL) && defined(WIFI_SSID) +#if defined(BLE_PIN_CODE) && defined(ESP32) && defined(WIFI_SSID) startCompanionBluetooth(); #endif @@ -1189,22 +1188,21 @@ void setup() { } #endif -#if defined(ESP32_PLATFORM) && defined(COMPANION_RADIO_FULL) +#if defined(ESP32_PLATFORM) && COMPANION_FEATURE_MEMORY_DIAGNOSTICS logFullCompanionMemory("after WiFi start"); #endif -// Full ESP32 WiFi+BLE companions started BLE above so its controller memory -// could not be fragmented by WiFi. Other BLE companions start here. +// ESP32 WiFi+BLE companions started BLE above so its controller memory could +// not be fragmented by WiFi. Other BLE companions start here. #if defined(BLE_PIN_CODE) - #if !(defined(ESP32) && defined(COMPANION_RADIO_FULL) \ - && defined(WIFI_SSID)) + #if !(defined(ESP32) && defined(WIFI_SSID)) startCompanionBluetooth(); #endif #endif // add usb interface #if defined(ENABLE_USB_INTERFACE) -#if defined(NRF52_PLATFORM) && defined(COMPANION_RADIO_FULL) && defined(OTA_FOLDER_SERIAL) +#if COMPANION_FEATURE_USB_MOTA_SOURCE usb_serial_interface.begin(Serial, USB_TERMINAL_START_TOKEN, USB_MOTA_START_TOKEN); #else usb_serial_interface.begin(Serial, USB_TERMINAL_START_TOKEN); diff --git a/examples/simple_room_server/FloodRuleEngine.cpp b/examples/simple_room_server/FloodRuleEngine.cpp index 8b0c7780..2bab88b2 100644 --- a/examples/simple_room_server/FloodRuleEngine.cpp +++ b/examples/simple_room_server/FloodRuleEngine.cpp @@ -1,6 +1,6 @@ #include "FloodRuleEngine.h" -#if defined(MESHCORE_ESP32_FULL_PROFILE) +#if MESH_ENABLE_ROOM_FLOOD_RULE_ENGINE #include #include diff --git a/examples/simple_room_server/FloodRuleEngine.h b/examples/simple_room_server/FloodRuleEngine.h index 2ddd550a..b81a7154 100644 --- a/examples/simple_room_server/FloodRuleEngine.h +++ b/examples/simple_room_server/FloodRuleEngine.h @@ -1,6 +1,8 @@ #pragma once -#if defined(MESHCORE_ESP32_FULL_PROFILE) +#include "RoomServerFeatures.h" + +#if MESH_ENABLE_ROOM_FLOOD_RULE_ENGINE #include #include diff --git a/examples/simple_room_server/MyMesh.cpp b/examples/simple_room_server/MyMesh.cpp index 6ce4674e..2911921f 100644 --- a/examples/simple_room_server/MyMesh.cpp +++ b/examples/simple_room_server/MyMesh.cpp @@ -1,7 +1,7 @@ #include "MyMesh.h" #include #include -#if defined(MESHCORE_ESP32_FULL_PROFILE) +#if MESH_ENABLE_ROOM_FLOOD_RULE_ENGINE #include #endif #include @@ -371,7 +371,7 @@ int MyMesh::calcRxDelay(float score, uint32_t air_time) const { return (int)((powf(_prefs.rx_delay_base, 0.85f - score) - 1.0f) * air_time); } -#if defined(MESHCORE_ESP32_FULL_PROFILE) +#if MESH_ENABLE_ROOM_FLOOD_RULE_ENGINE bool MyMesh::evaluateFloodRuleTiming(const mesh::Packet* packet, bool& fast_track) { fast_track = false; @@ -530,14 +530,14 @@ bool MyMesh::allowPacketForward(const mesh::Packet *packet) { _prefs.flood_max_advert)) { return false; } -#if defined(MESHCORE_ESP32_FULL_PROFILE) +#if MESH_ENABLE_ROOM_FLOOD_RULE_ENGINE if (flood_rules.shouldBlock(packet, recv_pkt_rule_match_mask, _ms->getMillis())) { return false; } #endif } -#if defined(MESHCORE_ESP32_FULL_PROFILE) +#if MESH_ENABLE_ROOM_FLOOD_RULE_ENGINE if (packet->isRouteFlood()) { if (recv_pkt_region == NULL && !recv_pkt_regionless_scope_set) { MESH_DEBUG_PRINTLN( @@ -564,7 +564,7 @@ bool MyMesh::allowPacketForward(const mesh::Packet *packet) { } mesh::DispatcherAction MyMesh::onRecvPacket(mesh::Packet* pkt) { -#if defined(MESHCORE_ESP32_FULL_PROFILE) +#if MESH_ENABLE_ROOM_FLOOD_RULE_ENGINE bool scope_changed = false; bool fast_track_scope_change = false; recv_pkt_regionless_scope_set = false; @@ -599,7 +599,7 @@ mesh::DispatcherAction MyMesh::onRecvPacket(mesh::Packet* pkt) { recv_pkt_region = NULL; } mesh::DispatcherAction action = Mesh::onRecvPacket(pkt); -#if defined(MESHCORE_ESP32_FULL_PROFILE) +#if MESH_ENABLE_ROOM_FLOOD_RULE_ENGINE if (scope_changed && action != ACTION_RELEASE && action != ACTION_MANUAL_HOLD) { if (fast_track_scope_change) { @@ -1155,7 +1155,7 @@ MyMesh::MyMesh(mesh::MainBoard &board, mesh::Radio &radio, mesh::MillisecondCloc radio_apply_retry_at = 0; radio_apply_failures = 0; recv_pkt_region = NULL; -#if defined(MESHCORE_ESP32_FULL_PROFILE) +#if MESH_ENABLE_ROOM_FLOOD_RULE_ENGINE recv_pkt_rule_match_mask = 0; recv_pkt_regionless_scope_set = false; #endif @@ -1261,7 +1261,7 @@ void MyMesh::begin(FILESYSTEM *fs) { acl.load(_fs, self_id); region_map.load(_fs); _clock_sync.begin(_fs); -#if defined(MESHCORE_ESP32_FULL_PROFILE) +#if MESH_ENABLE_ROOM_FLOOD_RULE_ENGINE flood_rules.begin(_fs, ®ion_map); #endif @@ -2115,7 +2115,7 @@ void MyMesh::handleCommand(uint32_t sender_timestamp, char *command, char *reply snprintf(reply, MAX_POST_TEXT_LEN, "OK"); } } -#if defined(MESHCORE_ESP32_FULL_PROFILE) +#if MESH_ENABLE_ROOM_FLOOD_RULE_ENGINE else if (flood_rules.handleCommand(command, reply)) { // handled by the FULL-profile persistent flood rule engine } diff --git a/examples/simple_room_server/MyMesh.h b/examples/simple_room_server/MyMesh.h index 4b78b2cd..01128807 100644 --- a/examples/simple_room_server/MyMesh.h +++ b/examples/simple_room_server/MyMesh.h @@ -2,6 +2,7 @@ #include // needed for PlatformIO #include +#include "RoomServerFeatures.h" #if defined(NRF52_PLATFORM) #include @@ -139,7 +140,7 @@ class MyMesh : public mesh::Mesh, public CommonCLICallbacks, NodePrefs _prefs; TransportKeyStore key_store; RegionMap region_map, temp_map; -#if defined(MESHCORE_ESP32_FULL_PROFILE) +#if MESH_ENABLE_ROOM_FLOOD_RULE_ENGINE FloodRuleEngine flood_rules; uint32_t recv_pkt_rule_match_mask; bool recv_pkt_regionless_scope_set; @@ -255,7 +256,7 @@ class MyMesh : public mesh::Mesh, public CommonCLICallbacks, mesh::Packet* createSelfAdvert(); File openAppend(const char* fname); int handleRequest(ClientInfo* sender, uint32_t sender_timestamp, uint8_t* payload, size_t payload_len); -#if defined(MESHCORE_ESP32_FULL_PROFILE) +#if MESH_ENABLE_ROOM_FLOOD_RULE_ENGINE bool evaluateFloodRuleTiming(const mesh::Packet* packet, bool& fast_track); bool isLooped(const mesh::Packet* packet, @@ -278,7 +279,7 @@ protected: void logTxFail(mesh::Packet* pkt, int len) override; int calcRxDelay(float score, uint32_t air_time) const override; -#if defined(MESHCORE_ESP32_FULL_PROFILE) +#if MESH_ENABLE_ROOM_FLOOD_RULE_ENGINE int calcRxDelayForPacket(const mesh::Packet* packet, float score, uint32_t air_time) override; #endif diff --git a/examples/simple_room_server/RoomServerFeatures.h b/examples/simple_room_server/RoomServerFeatures.h new file mode 100644 index 00000000..a259196a --- /dev/null +++ b/examples/simple_room_server/RoomServerFeatures.h @@ -0,0 +1,12 @@ +#pragma once + +// Keep role capabilities independent from partition/profile labels. Existing +// FULL recipes remain compatible, while build tooling can now select this +// feature directly and verify that its CLI survived the link. +#ifndef MESH_ENABLE_ROOM_FLOOD_RULE_ENGINE + #if defined(MESHCORE_ESP32_FULL_PROFILE) + #define MESH_ENABLE_ROOM_FLOOD_RULE_ENGINE 1 + #else + #define MESH_ENABLE_ROOM_FLOOD_RULE_ENGINE 0 + #endif +#endif diff --git a/scripts/check_firmware_capabilities.py b/scripts/check_firmware_capabilities.py new file mode 100644 index 00000000..c3aabd3b --- /dev/null +++ b/scripts/check_firmware_capabilities.py @@ -0,0 +1,107 @@ +#!/usr/bin/env python3 +"""Verify promised firmware capabilities and emit a machine-readable manifest.""" + +from __future__ import annotations + +import argparse +import json +import os +from pathlib import Path +import sys + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser() + parser.add_argument("--image", required=True, type=Path) + parser.add_argument("--output", required=True, type=Path) + parser.add_argument("--target", required=True) + parser.add_argument("--artifact-target") + parser.add_argument("--platformio-env") + parser.add_argument("--platform", required=True) + parser.add_argument("--build-profile", required=True) + parser.add_argument("--capability", action="append", default=[]) + parser.add_argument("--reduction", action="append", default=[]) + parser.add_argument( + "--expect", + action="append", + default=[], + metavar="CAPABILITY=TEXT", + help="Require TEXT to be present in the linked image.", + ) + return parser.parse_args() + + +def stable_unique(values: list[str]) -> list[str]: + return list(dict.fromkeys(value for value in values if value)) + + +def main() -> int: + args = parse_args() + try: + image = args.image.read_bytes() + except OSError as exc: + print(f"capability check: cannot read {args.image}: {exc}", file=sys.stderr) + return 2 + + checks = [] + malformed = False + for expectation in args.expect: + if "=" not in expectation: + print( + f"capability check: malformed --expect {expectation!r}", + file=sys.stderr, + ) + malformed = True + continue + capability, needle = expectation.split("=", 1) + present = bool(needle) and needle.encode("utf-8") in image + checks.append( + { + "capability": capability, + "evidence": needle, + "present": present, + } + ) + + capabilities = stable_unique( + args.capability + [check["capability"] for check in checks] + ) + missing = [check for check in checks if not check["present"]] + manifest = { + "schema_version": 1, + "target": args.target, + "artifact_target": args.artifact_target or args.target, + "platformio_env": args.platformio_env or args.target, + "platform": args.platform, + "build_profile": args.build_profile, + "capabilities": capabilities, + "reductions": stable_unique(args.reduction), + "verification": checks, + "verified": not malformed and not missing, + } + + args.output.parent.mkdir(parents=True, exist_ok=True) + temporary = args.output.with_name(f".{args.output.name}.{os.getpid()}.tmp") + temporary.write_text(json.dumps(manifest, indent=2, sort_keys=True) + "\n") + temporary.replace(args.output) + + if malformed: + return 2 + if missing: + for check in missing: + print( + "capability check failed: " + f"{check['capability']} promised but {check['evidence']!r} " + f"is absent from {args.image}", + file=sys.stderr, + ) + return 1 + + print( + f"Verified {len(checks)} capability marker(s); manifest: {args.output}" + ) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/force_lora_ota.py b/scripts/force_lora_ota.py new file mode 100644 index 00000000..dc037a5b --- /dev/null +++ b/scripts/force_lora_ota.py @@ -0,0 +1,17 @@ +"""Remove a legacy final OTA undefine before PlatformIO parses build flags.""" + +Import("env") # noqa: F821 + +import os +import re + + +if os.environ.get("MESHCORE_FORCE_LORA_OTA") == "1": + cleaned = [] + for item in env.get("BUILD_FLAGS", []): # noqa: F821 + text = re.sub(r"(?:^|\s)-U\s*ENABLE_OTA(?=\s|$)", " ", str(item)) + text = " ".join(text.split()) + if text: + cleaned.append(text) + env.Replace(BUILD_FLAGS=cleaned) # noqa: F821 + print("LoRa OTA overlay: removed legacy -UENABLE_OTA before flag processing") diff --git a/src/helpers/UsbLogging.cpp b/src/helpers/UsbLogging.cpp index e09d411d..9e1926f3 100644 --- a/src/helpers/UsbLogging.cpp +++ b/src/helpers/UsbLogging.cpp @@ -6,8 +6,9 @@ #include #if defined(MESH_DUAL_CDC_LOGGING) - #if !defined(COMPANION_RADIO_FULL) - #error "MESH_DUAL_CDC_LOGGING is only supported by Full Companion" + #if !defined(COMPANION_FEATURE_DEDICATED_USB_LOGGING) || \ + !COMPANION_FEATURE_DEDICATED_USB_LOGGING + #error "MESH_DUAL_CDC_LOGGING requires its dedicated Companion capability" #endif #if defined(NRF52_PLATFORM) #define MESH_NRF52_DUAL_CDC_LOGGING 1 diff --git a/src/helpers/esp32/WebConfigServer.h b/src/helpers/esp32/WebConfigServer.h index efefde32..b63cb86d 100644 --- a/src/helpers/esp32/WebConfigServer.h +++ b/src/helpers/esp32/WebConfigServer.h @@ -36,14 +36,14 @@ class AsyncWebServerRequest; class DNSServer; #ifndef WEBCONFIG_AP_IDLE_TIMEOUT_MS - #if defined(MESHCORE_ESP32_FULL_PROFILE) + #if defined(MESHCORE_EXPANDED_PARTITION_PROFILE) #define WEBCONFIG_AP_IDLE_TIMEOUT_MS WebConfigBatch::kFullSetupApWindowMs #else #define WEBCONFIG_AP_IDLE_TIMEOUT_MS (10UL * 60UL * 1000UL) #endif #endif #ifndef WEBCONFIG_UNCONFIGURED_SETUP_TIMEOUT_MS - #if defined(MESHCORE_ESP32_FULL_PROFILE) + #if defined(MESHCORE_EXPANDED_PARTITION_PROFILE) #define WEBCONFIG_UNCONFIGURED_SETUP_TIMEOUT_MS WebConfigBatch::kFullSetupApWindowMs #else #define WEBCONFIG_UNCONFIGURED_SETUP_TIMEOUT_MS 0UL diff --git a/test/test_build_profiles.sh b/test/test_build_profiles.sh new file mode 100644 index 00000000..d26e4f2c --- /dev/null +++ b/test/test_build_profiles.sh @@ -0,0 +1,114 @@ +#!/usr/bin/env bash +set -euo pipefail + +cd "$(dirname "$0")/.." +source build.sh + +fail() { + echo "test_build_profiles: $*" >&2 + exit 1 +} + +# Synthetic inventory: one ESP32 target qualified for expanded Full and one +# nRF52 target which must attempt complete LoRa OTA in its current partition. +SUPPORTED_PIO_ENVS=( + esp_repeater + esp_repeater_lora_ota_no_external_sensors + nrf_repeater + nrf_repeater_lora_ota_no_external_sensors + nrf_qspi_repeater + nrf_qspi_repeater_lora_ota_no_external_sensors + nrf_sensor +) +PIO_ENV_PLATFORM_BY_NAME[esp_repeater]=ESP32_PLATFORM +PIO_ENV_PLATFORM_BY_NAME[esp_repeater_lora_ota_no_external_sensors]=ESP32_PLATFORM +PIO_ENV_PLATFORM_BY_NAME[nrf_repeater]=NRF52_PLATFORM +PIO_ENV_PLATFORM_BY_NAME[nrf_repeater_lora_ota_no_external_sensors]=NRF52_PLATFORM +PIO_ENV_PLATFORM_BY_NAME[nrf_qspi_repeater]=NRF52_PLATFORM +PIO_ENV_PLATFORM_BY_NAME[nrf_qspi_repeater_lora_ota_no_external_sensors]=NRF52_PLATFORM +PIO_ENV_PLATFORM_BY_NAME[nrf_sensor]=NRF52_PLATFORM +PIO_ENV_QSPI_OTA_BY_NAME[nrf_qspi_repeater_lora_ota_no_external_sensors]=1 +PIO_ENV_FULL_BUILD_BY_NAME[esp_repeater]=1 + +RESOLVED_BUILD_TARGETS=(esp_repeater) +configure_effective_build_profile build-firmware >/dev/null +[ "$BUILD_PROFILE_EFFECTIVE" = full ] || fail "ESP32 auto did not prefer Full" +[ "$AUTO_PREFER_FULL_BUILD" = 1 ] || fail "ESP32 Full pass was not scheduled" +[ "$AUTO_REDUCED_FALLBACK_TARGET" = \ + esp_repeater_lora_ota_no_external_sensors ] \ + || fail "ESP32 reduced fallback was not resolved" + +BUILD_PROFILE_OVERRIDE=auto +BUILD_PROFILE_EXPLICIT=0 +RESOLVED_BUILD_TARGETS=(nrf_repeater) +configure_effective_build_profile build-firmware >/dev/null +[ "${RESOLVED_BUILD_TARGETS[0]}" = \ + nrf_repeater_lora_ota_no_external_sensors ] \ + || fail "nRF52 complete OTA pass did not select the OTA identity" +[ "$AUTO_COMPLETE_FIRST_PASS" = 1 ] \ + || fail "nRF52 complete first pass was not scheduled" +[ "$AUTO_PUBLISH_REDUCED_SECOND_PASS" = 1 ] \ + || fail "internal-flash nRF52 reduced second artifact was not scheduled" + +BUILD_PROFILE_OVERRIDE=auto +BUILD_PROFILE_EXPLICIT=0 +RESOLVED_BUILD_TARGETS=(nrf_qspi_repeater) +configure_effective_build_profile build-firmware >/dev/null +[ "$AUTO_COMPLETE_FIRST_PASS" = 1 ] \ + || fail "external-storage nRF52 complete first pass was not scheduled" +[ "$AUTO_PUBLISH_REDUCED_SECOND_PASS" = 0 ] \ + || fail "external-storage nRF52 incorrectly scheduled a redundant artifact" + +BUILD_PROFILE_OVERRIDE=auto +BUILD_PROFILE_EXPLICIT=0 +RESOLVED_BUILD_TARGETS=(nrf_sensor) +configure_effective_build_profile build-firmware >/dev/null +[ "$BUILD_PROFILE_EFFECTIVE" = auto ] \ + || fail "nRF52 sensor did not retain its ordinary auto profile" +[ "$AUTO_COMPLETE_FIRST_PASS" = 0 ] \ + || fail "non-repeater nRF52 target incorrectly scheduled OTA profile passes" +[ "$AUTO_PUBLISH_REDUCED_SECOND_PASS" = 0 ] \ + || fail "non-repeater nRF52 target incorrectly scheduled two artifacts" + +calls=() +build_firmware() { + calls+=("$1:$BUILD_PROFILE_EFFECTIVE:$SKIP_DECLARED_REDUCTIONS:$FIRMWARE_OUTPUT_ENV_NAME") + if [ "${#calls[@]}" -eq 1 ]; then + return 42 + fi + return 0 +} + +AUTO_PREFER_FULL_BUILD=0 +AUTO_COMPLETE_FIRST_PASS=1 +AUTO_REDUCED_FALLBACK_TARGET=nrf_repeater_lora_ota_no_external_sensors +BUILD_PROFILE_EFFECTIVE=auto +FIRMWARE_FILENAME_INFIX="" +SKIP_DECLARED_REDUCTIONS=0 +run_auto_two_pass_build nrf_repeater_lora_ota_no_external_sensors >/dev/null + +[ "${#calls[@]}" -eq 2 ] || fail "size overflow did not run exactly two passes" +[[ "${calls[0]}" == *:auto:1:* ]] || fail "pass 1 unexpectedly applied reductions" +[[ "${calls[1]}" == *:standard:0:* ]] || fail "pass 2 did not apply standard reductions" + +calls=() +build_firmware() { + calls+=("$1:$BUILD_PROFILE_EFFECTIVE:$SKIP_DECLARED_REDUCTIONS:$FIRMWARE_OUTPUT_ENV_NAME") + return 0 +} + +AUTO_PUBLISH_REDUCED_SECOND_PASS=1 +BUILD_PROFILE_EFFECTIVE=auto +FIRMWARE_FILENAME_INFIX="" +FIRMWARE_OUTPUT_ENV_NAME="" +SKIP_DECLARED_REDUCTIONS=0 +run_auto_two_pass_build nrf_repeater_lora_ota_no_external_sensors >/dev/null + +[ "${#calls[@]}" -eq 2 ] \ + || fail "successful internal-flash nRF52 build did not publish both artifacts" +[[ "${calls[0]}" == *:auto:1:nrf_repeater ]] \ + || fail "complete nRF52 artifact did not use the feature-rich output name" +[[ "${calls[1]}" == *:standard:0: ]] \ + || fail "reduced nRF52 artifact did not use the reduced target name" + +echo "test_build_profiles: OK" diff --git a/test/test_firmware_capabilities.py b/test/test_firmware_capabilities.py new file mode 100644 index 00000000..2037098c --- /dev/null +++ b/test/test_firmware_capabilities.py @@ -0,0 +1,69 @@ +#!/usr/bin/env python3 + +import json +from pathlib import Path +import subprocess +import sys +import tempfile +import unittest + + +ROOT = Path(__file__).resolve().parents[1] +CHECKER = ROOT / "scripts" / "check_firmware_capabilities.py" + + +class FirmwareCapabilityCheckerTest(unittest.TestCase): + def run_checker(self, image_bytes, *extra_args): + 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) + result = subprocess.run( + [ + sys.executable, + str(CHECKER), + "--image", + str(image), + "--output", + str(manifest), + "--target", + "test_target", + "--platform", + "ESP32_PLATFORM", + "--build-profile", + "auto", + *extra_args, + ], + capture_output=True, + text=True, + check=False, + ) + return result, json.loads(manifest.read_text()) + + def test_writes_verified_manifest(self): + result, manifest = self.run_checker( + b"prefix retry.preset suffix", + "--capability", + "cli.retry_preset", + "--expect", + "cli.retry_preset=retry.preset", + ) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertTrue(manifest["verified"]) + self.assertEqual(manifest["build_profile"], "auto") + + def test_fails_when_promised_marker_is_missing(self): + result, manifest = self.run_checker( + b"unrelated image", + "--expect", + "web.webconfig=start webconfig", + ) + self.assertEqual(result.returncode, 1) + self.assertFalse(manifest["verified"]) + self.assertIn("promised", result.stderr) + + +if __name__ == "__main__": + unittest.main() diff --git a/tools/mota/pio_endf.py b/tools/mota/pio_endf.py index d4c91242..75b4c045 100644 --- a/tools/mota/pio_endf.py +++ b/tools/mota/pio_endf.py @@ -27,6 +27,41 @@ sys.path.insert(0, os.path.join(env["PROJECT_DIR"], "tools", "mota")) # noqa: F import motalib as ml +def _apply_forced_lora_ota_overlay() -> None: + """Resolve a legacy trailing ``-UENABLE_OTA`` for release overlays. + + A few feature-rich nRF52 repeater environments are intentionally non-OTA + and carry a final compiler undefine. PlatformIO can remove normal ``-D`` + entries through ``build_unflags``, but it leaves that ``-U`` after all + newly supplied defines. The release builder opts into this correction only + when it is deliberately producing the complete OTA variant. + """ + if os.environ.get("MESHCORE_FORCE_LORA_OTA") != "1": + return + + import re + + defines = [] + enable_seen = False + for item in env.get("CPPDEFINES", []): # noqa: F821 + name = item[0] if isinstance(item, (list, tuple)) else item + if name == "DISABLE_LORA_OTA": + continue + if name == "ENABLE_OTA": + if enable_seen: + continue + enable_seen = True + defines.append(item) + if not enable_seen: + defines.append(("ENABLE_OTA", 1)) + env.Replace(CPPDEFINES=defines) # noqa: F821 + + cppdef_flags = str(env.get("_CPPDEFFLAGS", "")) # noqa: F821 + cppdef_flags = re.sub(r"(?:^|\s)-U\s*ENABLE_OTA(?=\s|$)", " ", cppdef_flags) + env.Replace(_CPPDEFFLAGS=" ".join(cppdef_flags.split())) # noqa: F821 + print("LoRa OTA overlay: removed legacy -UENABLE_OTA and enabled the OTA command surface") + + def _ota_enabled() -> bool: disabled = False enabled = False @@ -239,6 +274,8 @@ def _append_endf_hex(source, target, env): # Intel-HEX path (nRF52: app f f"target={ident.target_id:#010x} hw='{ident.hw_id}' fw={ident.fw_version:#010x})") +_apply_forced_lora_ota_overlay() + if _ota_enabled(): if _is_nrf52(): env.AddPostAction("$BUILD_DIR/${PROGNAME}.hex", _append_endf_hex) # noqa: F821 diff --git a/tools/mota/test_mota.py b/tools/mota/test_mota.py index 72049cda..b7d9942b 100644 --- a/tools/mota/test_mota.py +++ b/tools/mota/test_mota.py @@ -174,7 +174,12 @@ def test_canonical_bulk_matrix_omits_runtime_and_transport_aliases(): companion = (root / "examples/companion_radio/MyMesh.cpp").read_text( encoding="utf-8" ) - assert "defined(COMPANION_RADIO_FULL) && defined(MESH_DUAL_CDC_LOGGING)" in companion + companion_features = ( + root / "examples/companion_radio/CompanionFeatures.h" + ).read_text(encoding="utf-8") + assert "COMPANION_FEATURE_DEDICATED_USB_LOGGING" in companion_features + assert "defined(MESH_DUAL_CDC_LOGGING)" in companion + assert "defined(COMPANION_RADIO_FULL)" not in companion assert "_prefs.usb_logging_enabled = 0" in companion assert 'strcmp(value, "on reboot") == 0' in companion assert 'strcmp(value, "off reboot") == 0' in companion @@ -255,7 +260,9 @@ def test_esp32_s3_full_profiles_inherit_dio_boot_mode(): )[1].split("requires_esp32_companion_full_ota_fallback()", 1)[0] assert "is_esp32_dual_cdc_companion_radio_full_target" in framework_preflight assert 'pio pkg install -e "$pio_env_name"' in framework_preflight - build_call = build.split('print_build_flags "$env_name"', 1)[1].split( + build_call = build.split( + 'print_build_flags "$pio_env_name" "$env_name"', 1 + )[1].split( "restore_platformio_build_flags", 1 )[0] assert 'prepare_esp32_dual_cdc_framework "$env_name" "$pio_env_name"' in build_call