feat: add capability-aware firmware build profiles

This commit is contained in:
mikecarper committed 2026-08-25 20:07:09 -07:00
1 parent 5938cbe614
commit ba2170d21b
21 files changed
+1084 -101

No files matched your search

+531 -35
View File
@@ -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 <version>: Firmware version to embed.
--radio-preset <name|number>: Override the USA Cascadia radio default. Stable names are usa-cascadia and target; legacy menu numbers remain accepted.
--profile <default|cascade>: Override the default Cascade firmware settings profile.
--profile <default|cascade>: Override runtime settings embedded in the firmware (not its feature set).
--build-profile <auto|standard|full>: 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 "+<helpers/ota/*.cpp>"
@@ -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
+38 -3
View File
@@ -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
`<firmware>.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.
+10
View File
@@ -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
+9
View File
@@ -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
@@ -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
+23 -15
View File
@@ -25,7 +25,7 @@
#include <helpers/TracePathHelpers.h>
#endif
#if defined(COMPANION_RADIO_FULL)
#if COMPANION_FEATURE_OTA_CLI
#include <helpers/ota/OtaCli.h>
#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 <text>\r\n");
terminalOutput().print(" channels\r\n");
terminalOutput().print(" channel <name-or-slot> <text>\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))
+7 -6
View File
@@ -3,6 +3,7 @@
#include <Arduino.h>
#include <Mesh.h>
#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;
+20 -22
View File
@@ -19,7 +19,7 @@
#else
#define COMPANION_BT_MODEM_SLEEP_AVAILABLE 0
#endif
#if defined(COMPANION_RADIO_FULL)
#if COMPANION_FEATURE_MEMORY_DIAGNOSTICS
#include <esp_heap_caps.h>
#endif
#endif
@@ -80,7 +80,7 @@ MultiSerialInterface interface_manager;
#include <helpers/CLICommandUtils.h>
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 <ip> %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);
@@ -1,6 +1,6 @@
#include "FloodRuleEngine.h"
#if defined(MESHCORE_ESP32_FULL_PROFILE)
#if MESH_ENABLE_ROOM_FLOOD_RULE_ENGINE
#include <stddef.h>
#include <string.h>
@@ -1,6 +1,8 @@
#pragma once
#if defined(MESHCORE_ESP32_FULL_PROFILE)
#include "RoomServerFeatures.h"
#if MESH_ENABLE_ROOM_FLOOD_RULE_ENGINE
#include <Arduino.h>
#include <Mesh.h>
+9 -9
View File
@@ -1,7 +1,7 @@
#include "MyMesh.h"
#include <helpers/radiolib/RxBoostedGainDefaults.h>
#include <helpers/CLICommandUtils.h>
#if defined(MESHCORE_ESP32_FULL_PROFILE)
#if MESH_ENABLE_ROOM_FLOOD_RULE_ENGINE
#include <helpers/FloodFilterPolicy.h>
#endif
#include <helpers/radiolib/RXPowerSaving.h>
@@ -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, &region_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
}
+4 -3
View File
@@ -2,6 +2,7 @@
#include <Arduino.h> // needed for PlatformIO
#include <Mesh.h>
#include "RoomServerFeatures.h"
#if defined(NRF52_PLATFORM)
#include <InternalFileSystem.h>
@@ -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
@@ -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
+107
View File
@@ -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())
+17
View File
@@ -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")
+3 -2
View File
@@ -6,8 +6,9 @@
#include <new>
#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
+2 -2
View File
@@ -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
+114
View File
@@ -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"
+69
View File
@@ -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()
+37
View File
@@ -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
+9 -2
View File
@@ -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