From bf092b24cc421134b6ecb4d43811a43038755c15 Mon Sep 17 00:00:00 2001 From: mikecarper Date: Sat, 29 Aug 2026 14:55:07 -0700 Subject: [PATCH] Fix release matrix gaps and add background builds --- build.sh | 424 +++++++++++++++++- docs/_javascript/firmware_picker.js | 6 +- docs/cli_build_matrix.md | 2 +- docs/firmware_picker.md | 13 +- examples/companion_radio/MyMesh.cpp | 141 +++--- src/helpers/CLICommandUtils.h | 128 ++++++ test/test_build_background.sh | 198 ++++++++ test/test_build_profiles.sh | 18 +- .../test_cli_command_utils.cpp | 102 +++++ test/test_firmware_picker.js | 2 +- tools/mota/test_mota.py | 33 +- variants/rak11310/RAK11310Board.h | 3 + variants/rak3x72/platformio.ini | 3 + variants/tiny_relay/platformio.ini | 3 + variants/wio-e5-dev/platformio.ini | 3 + variants/wio-e5-mini/platformio.ini | 3 + 16 files changed, 984 insertions(+), 98 deletions(-) create mode 100755 test/test_build_background.sh diff --git a/build.sh b/build.sh index 48fae2c4..54fe4757 100755 --- a/build.sh +++ b/build.sh @@ -16,13 +16,13 @@ PIO_CONFIG_JSON="" MENU_CHOICE="" SELECTED_TARGET="" SELECTED_COMMAND_ARGS=() -MESHDEBUG_OVERRIDE="" -PACKET_LOGGING_OVERRIDE="" -MQTT_BRIDGE_OVERRIDE="" -MQTT_DEBUG_OVERRIDE="" +MESHDEBUG_OVERRIDE="${MESHDEBUG_OVERRIDE-}" +PACKET_LOGGING_OVERRIDE="${PACKET_LOGGING_OVERRIDE-}" +MQTT_BRIDGE_OVERRIDE="${MQTT_BRIDGE_OVERRIDE-}" +MQTT_DEBUG_OVERRIDE="${MQTT_DEBUG_OVERRIDE-}" FIRMWARE_FILENAME_INFIX="" ESP32_FULL_BUILD=0 -SINGLE_TARGET_FULL_BUILD=0 +SINGLE_TARGET_FULL_BUILD="${SINGLE_TARGET_FULL_BUILD:-0}" AUTO_PREFER_FULL_BUILD=0 AUTO_COMPLETE_FIRST_PASS=0 AUTO_REDUCED_FALLBACK_TARGET="" @@ -31,7 +31,7 @@ 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_EXPLICIT="${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" @@ -39,12 +39,12 @@ USA_CASCADIA_FALLBACK_FREQ="910.525" USA_CASCADIA_FALLBACK_BW="62.5" USA_CASCADIA_FALLBACK_SF="7" USA_CASCADIA_FALLBACK_CR="5" -RADIO_SETTING_TITLE="$USA_CASCADIA_RADIO_TITLE" -RADIO_FREQ_OVERRIDE="$USA_CASCADIA_FALLBACK_FREQ" -RADIO_BW_OVERRIDE="$USA_CASCADIA_FALLBACK_BW" -RADIO_SF_OVERRIDE="$USA_CASCADIA_FALLBACK_SF" -RADIO_CR_OVERRIDE="$USA_CASCADIA_FALLBACK_CR" -FIRMWARE_PROFILE_OVERRIDE="${FIRMWARE_PROFILE_OVERRIDE:-cascade}" +RADIO_SETTING_TITLE="${RADIO_SETTING_TITLE-$USA_CASCADIA_RADIO_TITLE}" +RADIO_FREQ_OVERRIDE="${RADIO_FREQ_OVERRIDE-$USA_CASCADIA_FALLBACK_FREQ}" +RADIO_BW_OVERRIDE="${RADIO_BW_OVERRIDE-$USA_CASCADIA_FALLBACK_BW}" +RADIO_SF_OVERRIDE="${RADIO_SF_OVERRIDE-$USA_CASCADIA_FALLBACK_SF}" +RADIO_CR_OVERRIDE="${RADIO_CR_OVERRIDE-$USA_CASCADIA_FALLBACK_CR}" +FIRMWARE_PROFILE_OVERRIDE="${FIRMWARE_PROFILE_OVERRIDE-cascade}" BATCH_BUILD_MODE=0 # PlatformIO shares and cleans .pio/build across environments in this checkout. # Keep target-level builds strictly single-process; OPTION3_PIO_JOBS only @@ -58,10 +58,23 @@ RESOLVED_BUILD_TARGETS=() RESUME_BUILD_OUTPUT="${RESUME_BUILD_OUTPUT:-0}" LOGGING_MATRIX_FAILURES=() RADIO_PRESET_SELECTION="" -KISS_MODE_OVERRIDE="" +KISS_MODE_OVERRIDE="${KISS_MODE_OVERRIDE-}" PARSED_COMMAND_ARGS=() -FIRMWARE_VERSION_EXPLICIT=0 -OUTPUT_POLICY_EXPLICIT=0 +FIRMWARE_VERSION_EXPLICIT="${FIRMWARE_VERSION_EXPLICIT:-0}" +OUTPUT_POLICY_EXPLICIT="${OUTPUT_POLICY_EXPLICIT:-0}" +BUILD_BACKGROUND_REQUESTED=0 +BUILD_BACKGROUND_EXPLICIT=0 +BUILD_BACKGROUND_ACTIVE="${BUILD_BACKGROUND_ACTIVE:-0}" +BUILD_BACKGROUND_CONFIG_RESOLVED="${BUILD_BACKGROUND_CONFIG_RESOLVED:-0}" +BUILD_BACKGROUND_JOB_ID="${BUILD_BACKGROUND_JOB_ID-}" +BUILD_BACKGROUND_DIR="${BUILD_BACKGROUND_DIR-}" +BUILD_BACKGROUND_BACKEND="${BUILD_BACKGROUND_BACKEND-}" +BUILD_BACKGROUND_LOG_FILE="" +BUILD_BACKGROUND_STATUS_FILE="" +BUILD_BACKGROUND_STARTED_AT="" +BUILD_BACKGROUND_HANDOFF_STATE="" +BUILD_SCRIPT_LOCK_FD="" +INTERACTIVE_BUILD_SELECTION=0 ENV_VARIANT_SUFFIX_PATTERN='companion_radio_(wifi_mqtt|serial|wifi|usb|ble|full)(_ps)?(_fem(on|off))?|companion_radio_ethernet|comp_radio_usb|companion_usb|companion_ble|repeater_bridge_rs232_serial1_lora_ota_no_external_sensors|repeater_bridge_rs232_serial2_lora_ota_no_external_sensors|repeater_bridge_rs232_lora_ota_no_external_sensors|repeater_rak15001_slot_c_lora_ota|repeater_lora_ota_no_external_sensors|repeater_bridge_rs232_serial1|repeater_bridge_rs232_serial2|repeater_bridge_rs232|repeater_bridge_espnow|repeater_observer_mqtt|repeater_ethernet|room_server_observer_mqtt|room_server_ethernet|terminal_chat|room_server|room_svr|kiss_modem|sensor|repeatr|repeater' BOARD_MODIFIER_WITHOUT_DISPLAY="_without_display" @@ -89,8 +102,8 @@ FALLBACK_VERSION_PREFIX="dev" 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, tee, wc +# bash, cat, cp, date, env, find, flock, git, grep, head, mkdir, mv, pgrep, +# pio, python3, rm, sed, sleep, sort, systemctl, systemd-run, tee, wc # Keep this list in sync when adding or removing non-builtin command usage. global_usage() { @@ -103,7 +116,7 @@ Commands: list|-l: List firmwares available to build. build-firmware : Build the firmware for the given build target. build-firmwares: Build canonical firmwares for all targets. Runtime-setting aliases and Terminal Chat targets replaced by Full Companion remain available as explicit builds. - build-firmwares-logging-matrix: Build canonical standard artifacts with merged runtime USB logging plus unified FULL ESP32 USB+WiFi and FULL fallback profiles, logging each target under out/build-logs/ and continuing after failures. MQTT observers and ESP-NOW bridges always use FULL. KISS, BLE-only Companion, and constrained LoRa-OTA receiver contracts do not gain plaintext USB logging. + build-firmwares-logging-matrix: Build canonical standard artifacts with merged runtime USB logging plus unified FULL ESP32 USB+WiFi and FULL fallback profiles, logging each target under out/build-logs/ and continuing after failures. MQTT observers and ESP-NOW bridges always use FULL. KISS, BLE-only Companion, and constrained LoRa-OTA repeater contracts do not gain plaintext USB logging. build-companion-firmwares-logging-matrix: Build canonical Companion targets with merged runtime USB logging where the transport is safe, plus applicable MQTT and expanded FULL profiles. Full Companion replaces separate USB, BLE, WiFi, Terminal Chat, and USB-logging artifacts where an exact combined recipe exists. build-full-esp32-firmwares: Build feature-complete ESP32 profiles with up to 254 neighbors, USB packet logging, WiFi MQTT where supported, LoRa OTA, and expanded dual-OTA partitions. build-full-esp32-logging-firmwares: Build only the FULL USB-logging fallback for targets without a matching WiFi MQTT environment. @@ -126,11 +139,13 @@ Options: --auto|--standard|--full: Short forms of --build-profile. --skip-kiss|--include-kiss: Exclude (default) or include KISS modem targets in bulk builds. --clean|--resume: Clean output or resume existing Option 3/FULL-only artifacts. + --background: Run the selected build as a persistent detached job. Its log + and status live outside OUTPUT_DIR so --clean cannot erase them. Examples: 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 +no-external-sensors repeater because this nRF52 repeater stages deltas in internal flash. $ bash build.sh build-firmware RAK_4631_repeater --build-profile auto @@ -150,6 +165,9 @@ $ bash build.sh build-matching-firmwares Build all firmwares in standard, USB logging, feature-complete ESP32 MQTT, and feature-complete ESP32 logging profiles: $ bash build.sh build-firmwares-logging-matrix +Run that matrix persistently in the background: +$ bash build.sh build-firmwares-logging-matrix --background + Build only feature-complete ESP32 firmware: $ bash build.sh build-full-esp32-firmwares @@ -194,6 +212,8 @@ Environment Variables: artifacts already exist. Option 3 resumes by default. OUTPUT_DIR=path: Writes artifacts outside out/ (useful for isolated test builds). OPTION3_PIO_JOBS=8: Compiler jobs inside the single active PlatformIO process. + BUILD_BACKGROUND_DIR=path: Override the detached-job log/status directory. + Defaults to OUTPUT_DIR.background-builds. Examples: Build without debug logging: @@ -724,6 +744,29 @@ prompt_for_build_mode() { done } +prompt_for_background_build_mode() { + local options=( + "Run in foreground" + "Run persistently in background" + ) + + echo "Select how to run this build:" + print_numbered_menu "${options[@]}" + prompt_menu_choice "Execution mode" "${#options[@]}" + case "$MENU_CHOICE" in + 1) + BUILD_BACKGROUND_REQUESTED=0 + ;; + 2) + BUILD_BACKGROUND_REQUESTED=1 + ;; + QUIT) + echo "Cancelled." + exit 1 + ;; + esac +} + prompt_for_single_target_build_profile() { SINGLE_TARGET_FULL_BUILD=0 BUILD_PROFILE_OVERRIDE="auto" @@ -946,6 +989,11 @@ parse_cli_options() { OUTPUT_POLICY_EXPLICIT=1 shift ;; + --background) + BUILD_BACKGROUND_REQUESTED=1 + BUILD_BACKGROUND_EXPLICIT=1 + shift + ;; --) shift positional+=("$@") @@ -1971,7 +2019,7 @@ is_rak_i2c_voltage_monitor_ota_target() { is_logging_size_constrained_target() { case "$1" in - Tiny_Relay_repeater|RAK_3x72_repeater|wio-e5_repeater|wio-e5-repeater_bridge_rs232|wio-e5-mini_companion_radio_usb|wio-e5-mini_repeater|wio-e5-mini_sensor) + Tiny_Relay_companion_radio_usb|Tiny_Relay_repeater|RAK_3x72_companion_radio_usb|RAK_3x72_repeater|wio-e5_companion_radio_usb|wio-e5_repeater|wio-e5-repeater_bridge_rs232|wio-e5-mini_companion_radio_usb|wio-e5-mini_repeater|wio-e5-mini_sensor) return 0 ;; *) @@ -2022,10 +2070,12 @@ prompt_for_kiss_modem_build_policy() { case "$choice" in build) + KISS_MODE_OVERRIDE="build" echo "Including ${kiss_count} KISS modem target(s)." return 0 ;; skip) + KISS_MODE_OVERRIDE="skip" filter_out_kiss_modem_targets echo "Skipped ${kiss_count} KISS modem target(s)." return 0 @@ -5164,7 +5214,293 @@ run_command() { exit 1 } +get_build_project_dir() { + local script_path=${BASH_SOURCE[0]} + local script_dir + + if [[ "$script_path" != /* ]]; then + script_path="${PWD}/${script_path}" + fi + script_dir=${script_path%/*} + (cd -- "$script_dir" && pwd -P) +} + +get_background_build_dir() { + local base_dir=${1:-$PWD} + local configured_dir=$BUILD_BACKGROUND_DIR + + if [ -z "$configured_dir" ]; then + configured_dir="${OUTPUT_DIR%/}.background-builds" + fi + if [[ "$configured_dir" != /* ]]; then + configured_dir="${base_dir}/${configured_dir}" + fi + printf '%s\n' "$configured_dir" +} + +canonicalize_background_build_dir() { + local configured_dir=$1 + local canonical_dir + + if [ -z "$configured_dir" ] || [ "$configured_dir" = "/" ]; then + echo "Refusing unsafe background build directory: ${configured_dir}" >&2 + return 1 + fi + mkdir -p -- "$configured_dir" || return $? + canonical_dir=$(cd -- "$configured_dir" && pwd -P) || return $? + if [ -z "$canonical_dir" ] || [ "$canonical_dir" = "/" ]; then + echo "Refusing unsafe canonical background build directory: ${canonical_dir}" >&2 + return 1 + fi + printf '%s\n' "$canonical_dir" +} + +write_background_build_status() { + local state=$1 + local exit_code=${2:-} + local status_tmp="${BUILD_BACKGROUND_STATUS_FILE}.$$" + + { + printf 'job=%s\n' "$BUILD_BACKGROUND_JOB_ID" + printf 'state=%s\n' "$state" + printf 'backend=%s\n' "$BUILD_BACKGROUND_BACKEND" + printf 'pid=%s\n' "$$" + printf 'started=%s\n' "$BUILD_BACKGROUND_STARTED_AT" + printf 'updated=%s\n' "$(date --iso-8601=seconds)" + if [ -n "$exit_code" ]; then + printf 'exit_code=%s\n' "$exit_code" + fi + printf 'working_directory=%s\n' "$PWD" + printf 'log=%s\n' "$BUILD_BACKGROUND_LOG_FILE" + } > "$status_tmp" + mv -f -- "$status_tmp" "$BUILD_BACKGROUND_STATUS_FILE" +} + +finish_background_build() { + local exit_code=$1 + local state="failed" + + if [ "$exit_code" -eq 0 ]; then + state="completed" + fi + write_background_build_status "$state" "$exit_code" + echo "Background build ${BUILD_BACKGROUND_JOB_ID} ${state} (exit ${exit_code})." + echo "Log: ${BUILD_BACKGROUND_LOG_FILE}" + return 0 +} + +setup_background_build_worker() { + if [ "$BUILD_BACKGROUND_ACTIVE" != "1" ]; then + return 0 + fi + if ! [[ "$BUILD_BACKGROUND_JOB_ID" =~ ^meshcore-build-[A-Za-z0-9_.@-]+$ ]]; then + echo "Invalid background build job id: ${BUILD_BACKGROUND_JOB_ID}" >&2 + return 1 + fi + BUILD_BACKGROUND_DIR=$(canonicalize_background_build_dir "$BUILD_BACKGROUND_DIR") \ + || return $? + BUILD_BACKGROUND_LOG_FILE="${BUILD_BACKGROUND_DIR}/${BUILD_BACKGROUND_JOB_ID}.log" + BUILD_BACKGROUND_STATUS_FILE="${BUILD_BACKGROUND_DIR}/${BUILD_BACKGROUND_JOB_ID}.status" + BUILD_BACKGROUND_STARTED_AT=$(date --iso-8601=seconds) + : >> "$BUILD_BACKGROUND_LOG_FILE" || return $? + exec > >(tee -a -- "$BUILD_BACKGROUND_LOG_FILE") 2>&1 + write_background_build_status "starting" + echo "Background build: ${BUILD_BACKGROUND_JOB_ID}" + echo "Working directory: ${PWD}" + echo "Log: ${BUILD_BACKGROUND_LOG_FILE}" +} + +background_systemd_available() { + command -v systemd-run >/dev/null 2>&1 \ + && command -v systemctl >/dev/null 2>&1 \ + && systemctl --user show-environment >/dev/null 2>&1 +} + +read_background_build_state() { + local status_file=$1 + local key value + + if ! [ -s "$status_file" ]; then + return 1 + fi + while IFS='=' read -r key value; do + if [ "$key" = "state" ]; then + printf '%s\n' "$value" + return 0 + fi + done < "$status_file" + return 1 +} + +wait_for_background_worker_handoff() { + local status_file=$1 + local max_attempts=${2:-100} + local attempt state="" + + BUILD_BACKGROUND_HANDOFF_STATE="" + for ((attempt = 0; attempt < max_attempts; attempt++)); do + state=$(read_background_build_state "$status_file" 2>/dev/null) || state="" + case "$state" in + running|completed) + BUILD_BACKGROUND_HANDOFF_STATE=$state + return 0 + ;; + failed) + BUILD_BACKGROUND_HANDOFF_STATE=$state + return 1 + ;; + esac + sleep 0.1 + done + BUILD_BACKGROUND_HANDOFF_STATE="timeout" + return 124 +} + +launch_background_build() { + local project_dir script_path background_dir command_name timestamp job_id + local variable_name variable_value handoff_status + local status_file + local -a worker_environment=() + local -a forwarded_environment=( + PATH HOME USER LOGNAME SHELL LANG LC_ALL LC_CTYPE TMPDIR VIRTUAL_ENV + XDG_CACHE_HOME XDG_CONFIG_HOME + PLATFORMIO_CORE_DIR PLATFORMIO_BUILD_DIR PLATFORMIO_BUILD_FLAGS + PLATFORMIO_BUILD_UNFLAGS PLATFORMIO_BUILD_SRC_FILTER + PLATFORMIO_EXTRA_SCRIPTS DISABLE_DEBUG OPTION3_PIO_JOBS OUTPUT_DIR + FIRMWARE_VERSION FIRMWARE_BUILD_NUMBER BUILD_PROFILE_OVERRIDE + BUILD_PROFILE_EXPLICIT SINGLE_TARGET_FULL_BUILD FIRMWARE_PROFILE_OVERRIDE + MESHDEBUG_OVERRIDE PACKET_LOGGING_OVERRIDE MQTT_BRIDGE_OVERRIDE + MQTT_DEBUG_OVERRIDE RADIO_SETTING_TITLE RADIO_FREQ_OVERRIDE + RADIO_BW_OVERRIDE RADIO_SF_OVERRIDE RADIO_CR_OVERRIDE + RESUME_BUILD_OUTPUT KISS_MODE_OVERRIDE + SSL_CERT_FILE SSL_CERT_DIR REQUESTS_CA_BUNDLE CURL_CA_BUNDLE + GIT_CONFIG_GLOBAL + ) + + if [ "$BUILD_BACKGROUND_ACTIVE" = "1" ]; then + echo "Refusing to detach an already detached background worker." >&2 + return 1 + fi + if ! background_systemd_available; then + echo "--background requires a running systemd user manager (systemd-run --user)." >&2 + echo "Run this build in the foreground or enable the user systemd service manager." >&2 + return 1 + fi + + project_dir=$(get_build_project_dir) || return $? + script_path="${project_dir}/${BASH_SOURCE[0]##*/}" + background_dir=$(get_background_build_dir "$project_dir") + background_dir=$(canonicalize_background_build_dir "$background_dir") \ + || return $? + + command_name=${1//[^A-Za-z0-9_.@-]/-} + timestamp=$(date '+%Y%m%d-%H%M%S') + job_id="meshcore-build-${command_name}-${timestamp}-$$" + + worker_environment+=( + "BUILD_BACKGROUND_ACTIVE=1" + "BUILD_BACKGROUND_CONFIG_RESOLVED=1" + "BUILD_BACKGROUND_JOB_ID=${job_id}" + "BUILD_BACKGROUND_DIR=${background_dir}" + "BUILD_BACKGROUND_BACKEND=systemd" + "OUTPUT_POLICY_EXPLICIT=1" + ) + # env -i below prevents stale variables in the user service manager (most + # importantly DISABLE_DEBUG) from changing the detached build. Always give + # the worker a usable PATH and HOME, then copy only this explicit allowlist. + # Credentials and proxy URLs are deliberately not placed in the transient + # unit's command line, where service inspection could expose them. + worker_environment+=( + "PATH=${PATH:-/usr/local/bin:/usr/bin:/bin}" + "HOME=${HOME:?HOME is required for a background build}" + ) + for variable_name in "${forwarded_environment[@]}"; do + case "$variable_name" in + PATH|HOME) continue ;; + esac + if [[ -v $variable_name ]]; then + variable_value=${!variable_name} + worker_environment+=("${variable_name}=${variable_value}") + fi + done + + if ! systemd-run --user --quiet \ + --collect \ + --unit="$job_id" \ + --description="MeshCore firmware build: ${1}" \ + --service-type=exec \ + --working-directory="$project_dir" \ + /usr/bin/env -i "${worker_environment[@]}" \ + /usr/bin/bash "$script_path" "$@"; then + echo "Failed to start background build ${job_id}." >&2 + return 1 + fi + + status_file="${background_dir}/${job_id}.status" + if wait_for_background_worker_handoff "$status_file"; then + handoff_status=0 + else + handoff_status=$? + fi + if [ "$BUILD_BACKGROUND_HANDOFF_STATE" != "timeout" ]; then + printf '%s\n' "$job_id" > "${background_dir}/latest-job" + fi + if [ "$handoff_status" -ne 0 ]; then + if [ "$BUILD_BACKGROUND_HANDOFF_STATE" = "failed" ]; then + echo "Background build ${job_id} failed during startup." >&2 + echo "Result status: ${status_file}" >&2 + echo "Build log: ${background_dir}/${job_id}.log" >&2 + else + echo "Background build ${job_id} was accepted by systemd, but its worker did not acquire the build lock within 10 seconds." >&2 + echo "Inspect: journalctl --user -u ${job_id}.service" >&2 + fi + return 1 + fi + + echo "Started background build: ${job_id}" + echo "Status: systemctl --user status ${job_id}.service" + echo "Live journal: journalctl --user -u ${job_id}.service -f" + echo "Build log: ${background_dir}/${job_id}.log" + echo "Result status: ${background_dir}/${job_id}.status" +} + +acquire_build_script_lock() { + local project_dir lock_path owner="" + + if [ -n "$BUILD_SCRIPT_LOCK_FD" ]; then + return 0 + fi + project_dir=$(get_build_project_dir) || return $? + mkdir -p -- "${project_dir}/.pio" || return $? + lock_path="${project_dir}/.pio/build-sh.lock" + exec {BUILD_SCRIPT_LOCK_FD}>> "$lock_path" || return $? + if ! flock -n "$BUILD_SCRIPT_LOCK_FD"; then + owner=$(<"$lock_path") + echo "Another build.sh firmware build owns this checkout's PlatformIO lock." >&2 + if [ -n "$owner" ]; then + echo "Lock owner: ${owner}" >&2 + fi + exec {BUILD_SCRIPT_LOCK_FD}>&- + BUILD_SCRIPT_LOCK_FD="" + return 1 + fi + printf 'pid=%s job=%s started=%s\n' \ + "$$" "${BUILD_BACKGROUND_JOB_ID:-foreground}" "$(date --iso-8601=seconds)" \ + > "$lock_path" +} + +release_build_script_lock() { + if [ -z "$BUILD_SCRIPT_LOCK_FD" ]; then + return 0 + fi + flock -u "$BUILD_SCRIPT_LOCK_FD" || true + exec {BUILD_SCRIPT_LOCK_FD}>&- + BUILD_SCRIPT_LOCK_FD="" +} + main() { + local run_status=0 + if ! parse_cli_options "$@"; then exit 1 fi @@ -5172,15 +5508,27 @@ main() { case "${1:-}" in help|usage|-h|--help) + if [ "$BUILD_BACKGROUND_REQUESTED" = "1" ]; then + echo "--background is valid only with a firmware build command." >&2 + exit 1 + fi global_usage exit 0 ;; list|-l) + if [ "$BUILD_BACKGROUND_REQUESTED" = "1" ]; then + echo "--background is valid only with a firmware build command." >&2 + exit 1 + fi init_project_context get_pio_envs exit 0 ;; get-companion-firmwares-to-build|get-repeater-firmwares-to-build|get-room-server-firmwares-to-build) + if [ "$BUILD_BACKGROUND_REQUESTED" = "1" ]; then + echo "--background is valid only with a firmware build command." >&2 + exit 1 + fi init_project_context print_release_firmware_targets "$1" exit $? @@ -5191,9 +5539,21 @@ main() { validate_command "$@" fi + # Hold one checkout-wide lock from PlatformIO configuration discovery through + # the final target. Detached and foreground builds must never share or clean + # the same .pio/build tree concurrently. + if ! acquire_build_script_lock; then + exit 1 + fi + if [ "$BUILD_BACKGROUND_ACTIVE" = "1" ]; then + write_background_build_status "running" + fi + init_project_context - if [ -n "$RADIO_PRESET_SELECTION" ]; then + if [ "$BUILD_BACKGROUND_CONFIG_RESOLVED" = "1" ]; then + echo "Using build settings resolved by the background launcher." + elif [ -n "$RADIO_PRESET_SELECTION" ]; then if ! apply_cli_radio_preset "$RADIO_PRESET_SELECTION"; then exit 1 fi @@ -5208,6 +5568,7 @@ main() { exit 1 fi + INTERACTIVE_BUILD_SELECTION=1 prompt_for_build_mode if [ "$SINGLE_TARGET_FULL_BUILD" = "1" ]; then echo "Skipping separate debug and MQTT prompts; FULL everything enables USB logging and WiFi MQTT where the hardware supports it." @@ -5260,10 +5621,31 @@ main() { else RESUME_BUILD_OUTPUT=0 fi + + if [ "$INTERACTIVE_BUILD_SELECTION" = "1" ] \ + && [ "$BUILD_BACKGROUND_EXPLICIT" != "1" ]; then + prompt_for_background_build_mode + fi + if [ "$BUILD_BACKGROUND_REQUESTED" = "1" ]; then + # The systemd worker reacquires this lock before invoking PlatformIO. + release_build_script_lock + launch_background_build "$@" + exit $? + fi + prepare_output_dir run_command "$@" + run_status=$? + release_build_script_lock + return "$run_status" } if [[ "${BASH_SOURCE[0]}" == "$0" ]]; then + if [ "$BUILD_BACKGROUND_ACTIVE" = "1" ]; then + if ! setup_background_build_worker; then + exit 1 + fi + trap 'finish_background_build "$?"' EXIT + fi main "$@" fi diff --git a/docs/_javascript/firmware_picker.js b/docs/_javascript/firmware_picker.js index 483726a8..d55b58a2 100644 --- a/docs/_javascript/firmware_picker.js +++ b/docs/_javascript/firmware_picker.js @@ -44,7 +44,7 @@ const OTA_LABELS = Object.freeze({ none: "No LoRa OTA", - "lora-receiver": "LoRa OTA enabled / receiver", + "lora-receiver": "LoRa OTA repeater", "lora-source": "LoRa OTA source only (Full Companion)", }); @@ -790,7 +790,7 @@ ], zip: [ "Use the ZIP as the native nRF52 Serial DFU package; it is not an extra archive.", - "For LoRa OTA receiving, install the exact-board OTAFIX bootloader first.", + "For a LoRa OTA repeater, install the exact-board OTAFIX bootloader first.", ], uf2: [ "Place the device in its UF2 bootloader mode and copy the UF2 to the mounted drive.", @@ -804,7 +804,7 @@ const extra = []; if (profile.ota === "lora-receiver") { extra.push( - "This installs the LoRa OTA receiver profile. The later update package must still match the exact target and partition signature." + "This installs the LoRa OTA repeater profile. The later update package must still match the exact target and partition signature." ); } else if (profile.ota === "lora-source") { extra.push( diff --git a/docs/cli_build_matrix.md b/docs/cli_build_matrix.md index fa40eefe..6d74105d 100644 --- a/docs/cli_build_matrix.md +++ b/docs/cli_build_matrix.md @@ -124,7 +124,7 @@ available from a canonical image: partition by 2,192 bytes. The old aliases still work with `build-firmware` and -`build-matching-firmwares`. Dedicated repeater LoRa OTA receiver images are not +`build-matching-firmwares`. Dedicated LoRa OTA repeater images are not collapsed; they retain their exact storage, bootloader, role, and target identity contracts. Companion boards keep transport-specific canonical images only when no exact Full recipe has passed the combined flash/RAM qualification. diff --git a/docs/firmware_picker.md b/docs/firmware_picker.md index a3aa7697..2442e4e6 100644 --- a/docs/firmware_picker.md +++ b/docs/firmware_picker.md @@ -136,7 +136,7 @@ from the published firmware assets. | Wi-Fi MQTT observer | Firmware connects directly to MQTT over Wi-Fi; this is not USB logging | | USB logging + Wi-Fi MQTT | Unified FULL image sends to both paths; avoid two publishers aimed at the same broker unless messages are deduplicated | | No logging | Normal standalone operation without the dedicated logging/MQTT profile | -| LoRa OTA enabled / receiver | Install-capable profile that can stage an exact matching update received over LoRa | +| LoRa OTA repeater | Repeater profile that can stage an exact matching update received over LoRa | | LoRa OTA source only | Full Companion serving a host-supplied update to another node without self-installing it | Connection and bridge choices depend on the selected role. Companion firmware @@ -205,8 +205,8 @@ recommended. Ordinary non-OTA roles also use one artifact for normal operation and USB logging. Select the saved mode with `set usb.logging off|on`; no `-logging-` -artifact is emitted. KISS, BLE-only Companion, and constrained LoRa-OTA -receiver images retain their protocol/partition contracts and do not inherit +artifact is emitted. KISS, BLE-only Companion, and constrained LoRa OTA +repeater images retain their protocol/partition contracts and do not inherit plaintext USB logging. nRF52 Full Companion keeps the multi-role primary interface on `00`; it starts @@ -241,9 +241,10 @@ node configuration and verify every filename suffix before flashing. ## LoRa OTA and OTAFIX -An OTA receiver build installs the receiving/staging firmware. A later LoRa -update still needs an exact target identity, compatible partition signature, -matching radio settings, and the correct update package. +A LoRa OTA repeater build installs repeater firmware that can receive and +stage updates. A later LoRa update still needs an exact target identity, +compatible partition signature, matching radio settings, and the correct +update package. nRF52 LoRa OTA requires an OTAFIX bootloader built for the exact board. There is no universal bootloader file. Use the diff --git a/examples/companion_radio/MyMesh.cpp b/examples/companion_radio/MyMesh.cpp index 0653bbc8..db71fce9 100644 --- a/examples/companion_radio/MyMesh.cpp +++ b/examples/companion_radio/MyMesh.cpp @@ -1915,9 +1915,8 @@ static bool parseCadTimingMillis(const char* text, uint32_t minimum, return true; } - char* end = NULL; - const unsigned long parsed = strtoul(text, &end, 10); - if (text[0] == 0 || end == NULL || *end != 0 + uint32_t parsed = 0; + if (!mesh::cli::parseUnsignedIntegerStrict(text, parsed) || parsed < minimum || parsed > maximum) { return false; } @@ -1992,21 +1991,21 @@ bool MyMesh::handleCadCommand(const char* command, char* reply, && (value[7] == 0 || value[7] == ' ' || value[7] == '\t')) { value += 7; while (*value == ' ' || *value == '\t') value++; - char scan_text[16]; - char retry_text[16]; - char max_text[16]; - char extra[2]; + char token_storage[64] = {0}; + const char* tokens[3] = {NULL}; + size_t token_count = 0; uint16_t scan_ms; uint16_t retry_ms; uint16_t max_ms; - const int fields = sscanf(value, "%15s %15s %15s %1s", - scan_text, retry_text, max_text, extra); - if (fields != 3 - || !parseCadTimingMillis(scan_text, + if (!mesh::cli::splitWhitespaceFieldsStrict( + value, token_storage, sizeof(token_storage), tokens, 3, + token_count) + || token_count != 3 + || !parseCadTimingMillis(tokens[0], mesh::CAD_SCAN_MIN_TIMEOUT_MS, mesh::CAD_SCAN_MAX_TIMEOUT_MS, scan_ms) - || !parseCadTimingMillis(retry_text, 1, 60000, retry_ms) - || !parseCadTimingMillis(max_text, 1, 60000, max_ms)) { + || !parseCadTimingMillis(tokens[1], 1, 60000, retry_ms) + || !parseCadTimingMillis(tokens[2], 1, 60000, max_ms)) { snprintf(reply, reply_size, "Error: use set radio.cad timings "); return true; @@ -2554,10 +2553,9 @@ static bool wcParseLong(const char* value, long min_value, long max_value, long& } static bool wcParseDouble(const char* value, double min_value, double max_value, double& out) { - if (!value || !value[0]) return false; - char* end = nullptr; - double parsed = strtod(value, &end); - if (!end || *end != 0 || !isfinite(parsed) || parsed < min_value || parsed > max_value) return false; + float parsed = 0.0f; + if (!mesh::cli::parseDecimalStrict(value, parsed) + || parsed < min_value || parsed > max_value) return false; out = parsed; return true; } @@ -4729,64 +4727,57 @@ bool MyMesh::applyAndSaveRxPowerSaving(const char* value, char* reply) { bool level_requested = false; bool manual_requested = false; - unsigned long parsed_level = 0; - unsigned long parsed_preamble = 0; - unsigned long parsed_rx = 0; - unsigned long parsed_sleep = 0; - char extra = 0; + mesh::cli::RxPowerSavingArguments parsed = {}; + if (!mesh::cli::parseRxPowerSavingArgumentsStrict(value, parsed)) { + strcpy(reply, "Error: use off, level 1-10, or RX/SLEEP microseconds"); + return false; + } - if (strcmp(value, "off") == 0) { + if (parsed.mode == mesh::cli::RxPowerSavingArgumentMode::Off) { enabled = 0; - } else if (strcmp(value, "on") == 0 || strcmp(value, "conservative") == 0) { + } else if (parsed.mode + == mesh::cli::RxPowerSavingArgumentMode::Conservative) { enabled = 1; level = RX_POWERSAVING_CONSERVATIVE_LEVEL; preamble = RX_POWERSAVING_PROFILE_PREAMBLE; level_requested = true; - } else if (strcmp(value, "balanced") == 0) { + } else if (parsed.mode == mesh::cli::RxPowerSavingArgumentMode::Balanced) { enabled = 1; level = RX_POWERSAVING_BALANCED_LEVEL; preamble = RX_POWERSAVING_PROFILE_PREAMBLE; level_requested = true; - } else if (sscanf(value, "level %lu preamble %lu %c", - &parsed_level, &parsed_preamble, &extra) == 2) { - if (parsed_level < 1 || parsed_level > 10 - || (parsed_preamble != 16 && parsed_preamble != 32)) { + } else if (parsed.mode == mesh::cli::RxPowerSavingArgumentMode::Level) { + if (parsed.level < 1 || parsed.level > 10) { + strcpy(reply, parsed.preamble == 0 + ? "Error: level must be 1-10" + : "Error: level must be 1-10; preamble must be 16 or 32"); + return false; + } + if (parsed.preamble != 0 + && parsed.preamble != 16 && parsed.preamble != 32) { strcpy(reply, "Error: level must be 1-10; preamble must be 16 or 32"); return false; } enabled = 1; - level = static_cast(parsed_level); - preamble = static_cast(parsed_preamble); + level = static_cast(parsed.level); + preamble = static_cast(parsed.preamble); level_requested = true; - } else if (sscanf(value, "level %lu %c", &parsed_level, &extra) == 1 - || sscanf(value, "%lu %c", &parsed_level, &extra) == 1) { - if (parsed_level < 1 || parsed_level > 10) { - strcpy(reply, "Error: level must be 1-10"); - return false; - } - enabled = 1; - level = static_cast(parsed_level); - preamble = 0; - level_requested = true; - } else if (sscanf(value, "%lu %lu %c", &parsed_rx, &parsed_sleep, &extra) == 2) { - if (parsed_rx < RX_POWERSAVING_MIN_MANUAL_PERIOD_US - || parsed_rx > RX_POWERSAVING_MAX_PERIOD_US - || parsed_sleep < RX_POWERSAVING_MIN_MANUAL_PERIOD_US - || parsed_sleep > RX_POWERSAVING_MAX_PERIOD_US) { + } else if (parsed.mode == mesh::cli::RxPowerSavingArgumentMode::Manual) { + if (parsed.rx_us < RX_POWERSAVING_MIN_MANUAL_PERIOD_US + || parsed.rx_us > RX_POWERSAVING_MAX_PERIOD_US + || parsed.sleep_us < RX_POWERSAVING_MIN_MANUAL_PERIOD_US + || parsed.sleep_us > RX_POWERSAVING_MAX_PERIOD_US) { snprintf(reply, 160, "Error: RX/SLEEP must be %lu-%lu us", (unsigned long)RX_POWERSAVING_MIN_MANUAL_PERIOD_US, (unsigned long)RX_POWERSAVING_MAX_PERIOD_US); return false; } enabled = 1; - rx_us = static_cast(parsed_rx); - sleep_us = static_cast(parsed_sleep); + rx_us = parsed.rx_us; + sleep_us = parsed.sleep_us; level = 0; preamble = 0; manual_requested = true; - } else { - strcpy(reply, "Error: use off, level 1-10, or RX/SLEEP microseconds"); - return false; } if (level_requested) { @@ -5892,29 +5883,53 @@ void MyMesh::handleTerminalCommand(char* command) { terminalOutput().printf(" OK - radio.rxgain %s\r\n", value); } } else if (strncmp(config, "af ", 3) == 0) { - _prefs.airtime_factor = constrain((float)atof(config + 3), 0.0f, 9.0f); - savePrefs(); - terminalOutput().print(" OK\r\n"); + float parsed = 0.0f; + if (!mesh::cli::parseDecimalStrict(config + 3, parsed) + || parsed < 0.0f || parsed > 9.0f) { + terminalOutput().print(" ERROR: airtime factor must be 0-9\r\n"); + } else { + _prefs.airtime_factor = parsed; + savePrefs(); + terminalOutput().print(" OK\r\n"); + } } else if (strncmp(config, "name ", 5) == 0 && config[5] != 0) { StrHelper::strncpy(_prefs.node_name, config + 5, sizeof(_prefs.node_name)); savePrefs(); terminalOutput().print(" OK\r\n"); } else if (strncmp(config, "lat ", 4) == 0) { - sensors.node_lat = constrain(atof(config + 4), -90.0, 90.0); - savePrefs(); - terminalOutput().print(" OK\r\n"); + float parsed = 0.0f; + if (!mesh::cli::parseDecimalStrict(config + 4, parsed) + || parsed < -90.0f || parsed > 90.0f) { + terminalOutput().print(" ERROR: latitude must be -90 to 90\r\n"); + } else { + sensors.node_lat = parsed; + savePrefs(); + terminalOutput().print(" OK\r\n"); + } } else if (strncmp(config, "lon ", 4) == 0) { - sensors.node_lon = constrain(atof(config + 4), -180.0, 180.0); - savePrefs(); - terminalOutput().print(" OK\r\n"); + float parsed = 0.0f; + if (!mesh::cli::parseDecimalStrict(config + 4, parsed) + || parsed < -180.0f || parsed > 180.0f) { + terminalOutput().print(" ERROR: longitude must be -180 to 180\r\n"); + } else { + sensors.node_lon = parsed; + savePrefs(); + terminalOutput().print(" OK\r\n"); + } } else if (strncmp(config, "tx ", 3) == 0) { _prefs.tx_power_dbm = constrain(atoi(config + 3), -9, MAX_LORA_TX_POWER); savePrefs(); terminalOutput().print(" OK - reboot to apply\r\n"); } else if (strncmp(config, "freq ", 5) == 0) { - _prefs.freq = constrain((float)atof(config + 5), 150.0f, 2500.0f); - savePrefs(); - terminalOutput().print(" OK - reboot to apply\r\n"); + float parsed = 0.0f; + if (!mesh::cli::parseDecimalStrict(config + 5, parsed) + || parsed < 150.0f || parsed > 2500.0f) { + terminalOutput().print(" ERROR: frequency must be 150-2500 MHz\r\n"); + } else { + _prefs.freq = parsed; + savePrefs(); + terminalOutput().print(" OK - reboot to apply\r\n"); + } } else if (strncmp(config, "radio.fem.rxgain", 16) == 0 && (config[16] == 0 || config[16] == ' ' || config[16] == '\t')) { diff --git a/src/helpers/CLICommandUtils.h b/src/helpers/CLICommandUtils.h index 093be2cb..3f64f878 100644 --- a/src/helpers/CLICommandUtils.h +++ b/src/helpers/CLICommandUtils.h @@ -612,6 +612,134 @@ inline bool parseIntegerStrict(const char* text, int32_t& result) { return true; } +// Parse an unsigned base-10 integer without libc conversion helpers. A leading +// plus sign and surrounding horizontal whitespace are accepted; negative +// values, overflow, and trailing characters are rejected. +inline bool parseUnsignedIntegerStrict(const char* text, uint32_t& result) { + if (text == nullptr) return false; + while (*text == ' ' || *text == '\t') text++; + + if (*text == '+') { + text++; + } else if (*text == '-') { + return false; + } + + uint32_t value = 0; + bool saw_digit = false; + while (*text >= '0' && *text <= '9') { + const uint8_t digit = static_cast(*text++ - '0'); + if (value > (0xFFFFFFFFUL - digit) / 10UL) return false; + value = value * 10UL + digit; + saw_digit = true; + } + + while (*text == ' ' || *text == '\t') text++; + if (!saw_digit || *text != 0) return false; + result = value; + return true; +} + +// Copy and split a bounded CLI argument string on runs of spaces/tabs. This +// provides scanf-like token handling without pulling scanf into firmware. The +// caller controls the maximum accepted token count through field_capacity. +inline bool splitWhitespaceFieldsStrict(const char* text, char* storage, + size_t storage_size, + const char** fields, + size_t field_capacity, + size_t& field_count) { + field_count = 0; + if (text == nullptr || storage == nullptr || storage_size == 0 + || fields == nullptr || field_capacity == 0) { + return false; + } + + const size_t length = strlen(text); + if (length == 0 || length >= storage_size) return false; + memcpy(storage, text, length + 1); + + char* cursor = storage; + while (*cursor == ' ' || *cursor == '\t') cursor++; + while (*cursor != 0) { + if (field_count >= field_capacity) return false; + fields[field_count++] = cursor; + while (*cursor != 0 && *cursor != ' ' && *cursor != '\t') cursor++; + if (*cursor == 0) break; + *cursor++ = 0; + while (*cursor == ' ' || *cursor == '\t') cursor++; + } + return field_count != 0; +} + +enum class RxPowerSavingArgumentMode : uint8_t { + Off = 0, + Conservative, + Balanced, + Level, + Manual, +}; + +struct RxPowerSavingArguments { + RxPowerSavingArgumentMode mode; + uint32_t level; + uint32_t preamble; + uint32_t rx_us; + uint32_t sleep_us; +}; + +// Parse every documented radio.rxps spelling while leaving hardware-specific +// period and level ranges to the caller. Exact token shapes prevent suffixes or +// injected extra arguments from being silently accepted. +inline bool parseRxPowerSavingArgumentsStrict( + const char* text, RxPowerSavingArguments& result) { + char storage[64] = {0}; + const char* fields[4] = {nullptr}; + size_t field_count = 0; + if (!splitWhitespaceFieldsStrict(text, storage, sizeof(storage), fields, + 4, field_count)) { + return false; + } + + RxPowerSavingArguments parsed = { + RxPowerSavingArgumentMode::Off, 0, 0, 0, 0}; + if (field_count == 1) { + if (strcmp(fields[0], "off") == 0) { + parsed.mode = RxPowerSavingArgumentMode::Off; + } else if (strcmp(fields[0], "on") == 0 + || strcmp(fields[0], "conservative") == 0) { + parsed.mode = RxPowerSavingArgumentMode::Conservative; + } else if (strcmp(fields[0], "balanced") == 0) { + parsed.mode = RxPowerSavingArgumentMode::Balanced; + } else if (parseUnsignedIntegerStrict(fields[0], parsed.level)) { + parsed.mode = RxPowerSavingArgumentMode::Level; + } else { + return false; + } + } else if (field_count == 2) { + if (strcmp(fields[0], "level") == 0) { + if (!parseUnsignedIntegerStrict(fields[1], parsed.level)) return false; + parsed.mode = RxPowerSavingArgumentMode::Level; + } else { + if (!parseUnsignedIntegerStrict(fields[0], parsed.rx_us) + || !parseUnsignedIntegerStrict(fields[1], parsed.sleep_us)) { + return false; + } + parsed.mode = RxPowerSavingArgumentMode::Manual; + } + } else if (field_count == 4 + && strcmp(fields[0], "level") == 0 + && strcmp(fields[2], "preamble") == 0 + && parseUnsignedIntegerStrict(fields[1], parsed.level) + && parseUnsignedIntegerStrict(fields[3], parsed.preamble)) { + parsed.mode = RxPowerSavingArgumentMode::Level; + } else { + return false; + } + + result = parsed; + return true; +} + inline bool splitCommaFieldsStrict(const char* text, char* storage, size_t storage_size, const char** fields, diff --git a/test/test_build_background.sh b/test/test_build_background.sh new file mode 100755 index 00000000..832c7d0b --- /dev/null +++ b/test/test_build_background.sh @@ -0,0 +1,198 @@ +#!/usr/bin/env bash +set -euo pipefail + +cd "$(dirname "$0")/.." +source build.sh + +fail() { + echo "test_build_background: $*" >&2 + exit 1 +} + +test_dir=$(mktemp -d) +trap 'rm -rf -- "$test_dir"' EXIT + +# The option may appear alongside the ordinary build options and must not leak +# into the positional command that the detached worker receives. +parse_cli_options \ + build-firmware RAK_4631_repeater \ + --firmware-version v9.8.7-test \ + --radio-preset target \ + --background \ + --resume +[ "$BUILD_BACKGROUND_REQUESTED" = "1" ] || fail "--background was ignored" +[ "$BUILD_BACKGROUND_EXPLICIT" = "1" ] || fail "explicit background mode was not recorded" +[ "${PARSED_COMMAND_ARGS[*]}" = "build-firmware RAK_4631_repeater" ] \ + || fail "background option leaked into positional arguments" + +BUILD_BACKGROUND_REQUESTED=0 +prompt_for_background_build_mode <<< "2" >/dev/null +[ "$BUILD_BACKGROUND_REQUESTED" = "1" ] \ + || fail "interactive background selection was ignored" + +# Capture systemd-run as a shell function: this validates argv/environment +# serialization without starting a service or invoking PlatformIO. +capture_file="${test_dir}/systemd-run.args" +mock_systemd_exit=0 +mock_systemd_state=running +background_systemd_available() { return 0; } +systemd-run() { + local argument job_id="" job_dir="" + printf '%s\n' "$@" > "$capture_file" + if [ "$mock_systemd_exit" -ne 0 ]; then + return "$mock_systemd_exit" + fi + for argument in "$@"; do + case "$argument" in + BUILD_BACKGROUND_JOB_ID=*) job_id=${argument#*=} ;; + BUILD_BACKGROUND_DIR=*) job_dir=${argument#*=} ;; + esac + done + [ -n "$job_id" ] || return 1 + [ -n "$job_dir" ] || return 1 + mkdir -p -- "$job_dir" + printf 'job=%s\nstate=%s\n' \ + "$job_id" "$mock_systemd_state" > "${job_dir}/${job_id}.status" +} + +BUILD_BACKGROUND_ACTIVE=0 +BUILD_BACKGROUND_DIR="${test_dir}/job records" +OUTPUT_DIR="${test_dir}/firmware output" +unset DISABLE_DEBUG +PLATFORMIO_BUILD_FLAGS="-D BACKGROUND_OVERRIDE_TEST=1" +PLATFORMIO_BUILD_UNFLAGS="-D FOREGROUND_ONLY_FLAG" +PLATFORMIO_BUILD_SRC_FILTER="+" +PLATFORMIO_EXTRA_SCRIPTS="pre:custom script.py" +FIRMWARE_VERSION="v9.8.7 custom" +BUILD_PROFILE_OVERRIDE="standard" +BUILD_PROFILE_EXPLICIT=1 +FIRMWARE_PROFILE_OVERRIDE="" +MESHDEBUG_OVERRIDE="on" +PACKET_LOGGING_OVERRIDE="off" +MQTT_BRIDGE_OVERRIDE="on" +MQTT_DEBUG_OVERRIDE="off" +RADIO_SETTING_TITLE="Custom field test" +RADIO_FREQ_OVERRIDE="910.125" +RADIO_BW_OVERRIDE="125" +RADIO_SF_OVERRIDE="8" +RADIO_CR_OVERRIDE="6" +RESUME_BUILD_OUTPUT=1 +KISS_MODE_OVERRIDE="skip" + +launch_output=$(launch_background_build \ + build-firmware RAK_4631_repeater) + +grep -Fxq -- 'BUILD_BACKGROUND_ACTIVE=1' "$capture_file" \ + || fail "worker recursion guard was not forwarded" +grep -Fxq -- 'BUILD_BACKGROUND_CONFIG_RESOLVED=1' "$capture_file" \ + || fail "resolved-config marker was not forwarded" +grep -Fxq -- 'RADIO_SETTING_TITLE=Custom field test' "$capture_file" \ + || fail "custom radio title was not preserved" +grep -Fxq -- 'RADIO_FREQ_OVERRIDE=910.125' "$capture_file" \ + || fail "custom frequency was not preserved" +grep -Fxq -- 'FIRMWARE_PROFILE_OVERRIDE=' "$capture_file" \ + || fail "empty target-default profile was not preserved" +grep -Fxq -- 'MESHDEBUG_OVERRIDE=on' "$capture_file" \ + || fail "debug choice was not preserved" +grep -Fxq -- 'MQTT_BRIDGE_OVERRIDE=on' "$capture_file" \ + || fail "MQTT choice was not preserved" +grep -Fxq -- 'FIRMWARE_VERSION=v9.8.7 custom' "$capture_file" \ + || fail "firmware version was not preserved" +grep -Fxq -- 'RESUME_BUILD_OUTPUT=1' "$capture_file" \ + || fail "output policy was not preserved" +grep -Fxq -- "--working-directory=$PWD" "$capture_file" \ + || fail "working directory was not preserved" +grep -Fxq -- '--collect' "$capture_file" \ + || fail "completed transient unit would not be collected" +grep -Fxq -- '-i' "$capture_file" \ + || fail "worker environment was not cleared before explicit forwarding" +if grep -Eq '^(--setenv=)?DISABLE_DEBUG=' "$capture_file"; then + fail "unset DISABLE_DEBUG leaked into detached worker" +fi +grep -Fxq -- 'PLATFORMIO_BUILD_FLAGS=-D BACKGROUND_OVERRIDE_TEST=1' "$capture_file" \ + || fail "caller PlatformIO build flags were not preserved" +grep -Fxq -- 'PLATFORMIO_BUILD_UNFLAGS=-D FOREGROUND_ONLY_FLAG' "$capture_file" \ + || fail "caller PlatformIO build unflags were not preserved" +grep -Fxq -- 'PLATFORMIO_BUILD_SRC_FILTER=+' "$capture_file" \ + || fail "caller PlatformIO source filter was not preserved" +grep -Fxq -- 'PLATFORMIO_EXTRA_SCRIPTS=pre:custom script.py' "$capture_file" \ + || fail "caller PlatformIO extra scripts were not preserved" +[ "$(tail -n 2 "$capture_file" | head -n 1)" = "build-firmware" ] \ + || fail "detached command was not forwarded" +[ "$(tail -n 1 "$capture_file")" = "RAK_4631_repeater" ] \ + || fail "detached target was not forwarded" +[[ "$launch_output" == *"systemctl --user status meshcore-build-"* ]] \ + || fail "launch output omitted the persistent status command" +[[ "$launch_output" == *"Build log:"* ]] \ + || fail "launch output omitted the durable log path" +[ -s "${BUILD_BACKGROUND_DIR}/latest-job" ] \ + || fail "latest background job was not recorded" +successful_job=$(<"${BUILD_BACKGROUND_DIR}/latest-job") + +# Failed service creation must not replace the last accepted job, while an +# accepted worker that immediately fails must be reported synchronously. +mock_systemd_exit=1 +if launch_background_build build-repeater-firmwares >/dev/null 2>&1; then + fail "failed systemd launch was reported as successful" +fi +[ "$(<"${BUILD_BACKGROUND_DIR}/latest-job")" = "$successful_job" ] \ + || fail "failed systemd launch left a stale latest-job pointer" +mock_systemd_exit=0 +mock_systemd_state=failed +if launch_background_build build-sensor-firmwares >/dev/null 2>&1; then + fail "immediate worker failure was reported as successful" +fi +failed_job=$(<"${BUILD_BACKGROUND_DIR}/latest-job") +[ "$failed_job" != "$successful_job" ] \ + || fail "accepted failed worker was not recorded as the latest attempt" +grep -Fxq 'state=failed' "${BUILD_BACKGROUND_DIR}/${failed_job}.status" \ + || fail "accepted failed worker status was not retained" +mock_systemd_state=running + +BUILD_BACKGROUND_ACTIVE=1 +if launch_background_build build-firmware RAK_4631_repeater >/dev/null 2>&1; then + fail "a background worker was allowed to detach recursively" +fi +BUILD_BACKGROUND_ACTIVE=0 + +# Canonicalization must catch a configured path that is not visibly root until +# its symlink is resolved, before either caller constructs a log/status path. +root_link="${test_dir}/root-link" +ln -s / "$root_link" +if canonicalize_background_build_dir "$root_link" >/dev/null 2>&1; then + fail "background directory canonicalizer accepted a symlink to root" +fi +BUILD_BACKGROUND_DIR="${test_dir}/job records" + +# The durable status file survives independently of firmware output cleanup. +worker_dir="${test_dir}/worker status" +env \ + BUILD_BACKGROUND_ACTIVE=1 \ + BUILD_BACKGROUND_JOB_ID=meshcore-build-test-worker \ + BUILD_BACKGROUND_DIR="$worker_dir" \ + BUILD_BACKGROUND_BACKEND=test \ + bash build.sh help >/dev/null +grep -Fxq 'state=completed' \ + "$worker_dir/meshcore-build-test-worker.status" \ + || fail "worker did not record successful completion" +grep -Fxq 'exit_code=0' \ + "$worker_dir/meshcore-build-test-worker.status" \ + || fail "worker did not record its exit code" +[ -s "$worker_dir/meshcore-build-test-worker.log" ] \ + || fail "worker did not write a durable log" + +# A second foreground or detached build cannot acquire the checkout lock. +lock_root="${test_dir}/lock root" +mkdir -p "$lock_root/.pio" +get_build_project_dir() { printf '%s\n' "$lock_root"; } +BUILD_SCRIPT_LOCK_FD="" +acquire_build_script_lock || fail "first build lock acquisition failed" +first_lock_fd=$BUILD_SCRIPT_LOCK_FD +BUILD_SCRIPT_LOCK_FD="" +if acquire_build_script_lock >/dev/null 2>&1; then + fail "second build lock acquisition unexpectedly succeeded" +fi +BUILD_SCRIPT_LOCK_FD=$first_lock_fd +release_build_script_lock + +echo "test_build_background: all checks passed" diff --git a/test/test_build_profiles.sh b/test/test_build_profiles.sh index 0d67faf1..bbd79ea3 100644 --- a/test/test_build_profiles.sh +++ b/test/test_build_profiles.sh @@ -489,18 +489,32 @@ configure_effective_build_profile build-firmware >/dev/null || fail "non-repeater nRF52 target incorrectly scheduled two artifacts" # Ordinary USB-loggable roles compile their historical logging profile into -# the canonical artifact. OTA receivers, KISS, and BLE keep their distinct +# the canonical artifact. LoRa OTA repeaters, KISS, and BLE keep their distinct # stream/partition contracts. A standard ESP32 artifact still embeds logging # when the same target also has an expanded FULL profile. PIO_ENV_PLATFORM_BY_NAME[wio-e5-mini_repeater]=STM32_PLATFORM +PIO_ENV_PLATFORM_BY_NAME[RAK_3x72_companion_radio_usb]=STM32_PLATFORM +PIO_ENV_PLATFORM_BY_NAME[Tiny_Relay_companion_radio_usb]=STM32_PLATFORM +PIO_ENV_PLATFORM_BY_NAME[wio-e5-mini_companion_radio_usb]=STM32_PLATFORM +PIO_ENV_PLATFORM_BY_NAME[wio-e5_companion_radio_usb]=STM32_PLATFORM PIO_ENV_PLATFORM_BY_NAME[nrf_kiss_modem]=NRF52_PLATFORM PIO_ENV_PLATFORM_BY_NAME[nrf_companion_radio_ble]=NRF52_PLATFORM uses_merged_standard_usb_logging nrf_sensor \ || fail "ordinary nRF52 sensor omitted merged USB logging" uses_merged_standard_usb_logging wio-e5-mini_repeater \ || fail "size-constrained STM32 target omitted merged packet logging" +for constrained_companion in \ + RAK_3x72_companion_radio_usb \ + Tiny_Relay_companion_radio_usb \ + wio-e5-mini_companion_radio_usb \ + wio-e5_companion_radio_usb; do + uses_merged_standard_usb_logging "$constrained_companion" \ + || fail "$constrained_companion omitted merged packet logging" + is_logging_size_constrained_target "$constrained_companion" \ + || fail "$constrained_companion enabled oversized verbose mesh diagnostics" +done if uses_merged_standard_usb_logging nrf_repeater_lora_ota_no_external_sensors; then - fail "LoRa OTA receiver incorrectly merged USB logging" + fail "LoRa OTA repeater incorrectly merged USB logging" fi if uses_merged_standard_usb_logging nrf_kiss_modem; then fail "KISS target incorrectly merged plaintext USB logging" diff --git a/test/test_cli_command_utils/test_cli_command_utils.cpp b/test/test_cli_command_utils/test_cli_command_utils.cpp index da9d4402..d7b81f5f 100644 --- a/test/test_cli_command_utils/test_cli_command_utils.cpp +++ b/test/test_cli_command_utils/test_cli_command_utils.cpp @@ -418,6 +418,108 @@ TEST(CLICommandUtils, RejectsInvalidOrOverflowingIntegers) { EXPECT_EQ(123, value); } +TEST(CLICommandUtils, ParsesUnsignedIntegersStrictly) { + uint32_t value = 0; + + EXPECT_TRUE(mesh::cli::parseUnsignedIntegerStrict("0", value)); + EXPECT_EQ(0u, value); + EXPECT_TRUE(mesh::cli::parseUnsignedIntegerStrict(" +60000 ", value)); + EXPECT_EQ(60000u, value); + EXPECT_TRUE(mesh::cli::parseUnsignedIntegerStrict("4294967295", value)); + EXPECT_EQ(UINT32_MAX, value); +} + +TEST(CLICommandUtils, RejectsInvalidOrOverflowingUnsignedIntegers) { + uint32_t value = 123; + + EXPECT_FALSE(mesh::cli::parseUnsignedIntegerStrict(nullptr, value)); + EXPECT_FALSE(mesh::cli::parseUnsignedIntegerStrict("", value)); + EXPECT_FALSE(mesh::cli::parseUnsignedIntegerStrict("+", value)); + EXPECT_FALSE(mesh::cli::parseUnsignedIntegerStrict("-1", value)); + EXPECT_FALSE(mesh::cli::parseUnsignedIntegerStrict("1.0", value)); + EXPECT_FALSE(mesh::cli::parseUnsignedIntegerStrict("10 ms", value)); + EXPECT_FALSE(mesh::cli::parseUnsignedIntegerStrict("4294967296", value)); + EXPECT_EQ(123u, value); +} + +TEST(CLICommandUtils, SplitsBoundedWhitespaceFieldsStrictly) { + char storage[48] = {0}; + const char* fields[3] = {nullptr}; + size_t count = 0; + + EXPECT_TRUE(mesh::cli::splitWhitespaceFieldsStrict( + " auto\t100 60000 ", storage, sizeof(storage), fields, 3, count)); + ASSERT_EQ(3u, count); + EXPECT_STREQ("auto", fields[0]); + EXPECT_STREQ("100", fields[1]); + EXPECT_STREQ("60000", fields[2]); + + EXPECT_FALSE(mesh::cli::splitWhitespaceFieldsStrict( + "auto 1 2 extra", storage, sizeof(storage), fields, 3, count)); + EXPECT_FALSE(mesh::cli::splitWhitespaceFieldsStrict( + " ", storage, sizeof(storage), fields, 3, count)); +} + +TEST(CLICommandUtils, ParsesEveryDocumentedRxPowerSavingForm) { + mesh::cli::RxPowerSavingArguments parsed = {}; + + EXPECT_TRUE(mesh::cli::parseRxPowerSavingArgumentsStrict("off", parsed)); + EXPECT_EQ(mesh::cli::RxPowerSavingArgumentMode::Off, parsed.mode); + EXPECT_TRUE(mesh::cli::parseRxPowerSavingArgumentsStrict("on", parsed)); + EXPECT_EQ(mesh::cli::RxPowerSavingArgumentMode::Conservative, parsed.mode); + EXPECT_TRUE(mesh::cli::parseRxPowerSavingArgumentsStrict( + "conservative", parsed)); + EXPECT_EQ(mesh::cli::RxPowerSavingArgumentMode::Conservative, parsed.mode); + EXPECT_TRUE(mesh::cli::parseRxPowerSavingArgumentsStrict("balanced", parsed)); + EXPECT_EQ(mesh::cli::RxPowerSavingArgumentMode::Balanced, parsed.mode); + + EXPECT_TRUE(mesh::cli::parseRxPowerSavingArgumentsStrict("8", parsed)); + EXPECT_EQ(mesh::cli::RxPowerSavingArgumentMode::Level, parsed.mode); + EXPECT_EQ(8u, parsed.level); + EXPECT_EQ(0u, parsed.preamble); + EXPECT_TRUE(mesh::cli::parseRxPowerSavingArgumentsStrict( + " level\t8 ", parsed)); + EXPECT_EQ(mesh::cli::RxPowerSavingArgumentMode::Level, parsed.mode); + EXPECT_EQ(8u, parsed.level); + EXPECT_EQ(0u, parsed.preamble); + EXPECT_TRUE(mesh::cli::parseRxPowerSavingArgumentsStrict( + "level 8 preamble 32", parsed)); + EXPECT_EQ(mesh::cli::RxPowerSavingArgumentMode::Level, parsed.mode); + EXPECT_EQ(8u, parsed.level); + EXPECT_EQ(32u, parsed.preamble); + + EXPECT_TRUE(mesh::cli::parseRxPowerSavingArgumentsStrict( + "65625 60000", parsed)); + EXPECT_EQ(mesh::cli::RxPowerSavingArgumentMode::Manual, parsed.mode); + EXPECT_EQ(65625u, parsed.rx_us); + EXPECT_EQ(60000u, parsed.sleep_us); +} + +TEST(CLICommandUtils, RejectsMalformedRxPowerSavingForms) { + mesh::cli::RxPowerSavingArguments parsed = { + mesh::cli::RxPowerSavingArgumentMode::Manual, 7, 16, 1000, 2000}; + + EXPECT_FALSE(mesh::cli::parseRxPowerSavingArgumentsStrict(nullptr, parsed)); + EXPECT_FALSE(mesh::cli::parseRxPowerSavingArgumentsStrict("", parsed)); + EXPECT_FALSE(mesh::cli::parseRxPowerSavingArgumentsStrict("on now", parsed)); + EXPECT_FALSE(mesh::cli::parseRxPowerSavingArgumentsStrict("level", parsed)); + EXPECT_FALSE(mesh::cli::parseRxPowerSavingArgumentsStrict( + "level 8 preamble", parsed)); + EXPECT_FALSE(mesh::cli::parseRxPowerSavingArgumentsStrict( + "level 8 preamble 16 extra", parsed)); + EXPECT_FALSE(mesh::cli::parseRxPowerSavingArgumentsStrict( + "level 8 suffix 16", parsed)); + EXPECT_FALSE(mesh::cli::parseRxPowerSavingArgumentsStrict("1000 -1", parsed)); + EXPECT_FALSE(mesh::cli::parseRxPowerSavingArgumentsStrict( + "1000 2000 extra", parsed)); + + EXPECT_EQ(mesh::cli::RxPowerSavingArgumentMode::Manual, parsed.mode); + EXPECT_EQ(7u, parsed.level); + EXPECT_EQ(16u, parsed.preamble); + EXPECT_EQ(1000u, parsed.rx_us); + EXPECT_EQ(2000u, parsed.sleep_us); +} + TEST(CLICommandUtils, ParsesRadioTuplesWithoutFloatScanf) { float frequency = 0.0f; float bandwidth = 0.0f; diff --git a/test/test_firmware_picker.js b/test/test_firmware_picker.js index 25de61fe..2466597e 100644 --- a/test/test_firmware_picker.js +++ b/test/test_firmware_picker.js @@ -526,7 +526,7 @@ assert.strictEqual(lora.ota, "lora-receiver"); assert.strictEqual(lora.variant, "no-external-sensors"); assert.strictEqual( picker.OTA_LABELS["lora-receiver"], - "LoRa OTA enabled / receiver" + "LoRa OTA repeater" ); assert(!Object.prototype.hasOwnProperty.call( picker.OTA_LABELS, diff --git a/tools/mota/test_mota.py b/tools/mota/test_mota.py index 555e2102..f426c790 100644 --- a/tools/mota/test_mota.py +++ b/tools/mota/test_mota.py @@ -151,6 +151,37 @@ def test_flash_constrained_stm32_repeaters_pin_the_size_qualified_toolchain(): assert "line.flush(mesh::usbLoggingPort(), false);" in repeater +def test_flash_constrained_stm32_companions_pin_the_size_qualified_toolchain(): + root = Path(__file__).resolve().parents[2] + toolchain = "platformio/toolchain-gccarmnoneeabi@^1.140201.0" + profiles = ( + ( + "variants/rak3x72/platformio.ini", + "[env:RAK_3x72_companion_radio_usb]", + ), + ( + "variants/tiny_relay/platformio.ini", + "[env:Tiny_Relay_companion_radio_usb]", + ), + ( + "variants/wio-e5-mini/platformio.ini", + "[env:wio-e5-mini_companion_radio_usb]", + ), + ( + "variants/wio-e5-dev/platformio.ini", + "[env:wio-e5_companion_radio_usb]", + ), + ) + + for path, section_name in profiles: + text = (root / path).read_text(encoding="utf-8") + section = text.split(section_name, 1)[1].split("\n[", 1)[0] + assert toolchain in section + assert "-fno-schedule-insns2" in section + assert "-Wl,--sort-section=alignment" in section + assert "MESH_PACKET_LOGGING_COMPACT" not in section + + def test_usb_companion_profiles_enable_the_usb_transport(): root = Path(__file__).resolve().parents[2] profiles = ( @@ -329,7 +360,7 @@ def test_single_tty_logging_off_restores_each_builds_default_mode(): keep_ascii = service.split( "case mesh::UsbLoggingTerminalAction::KEEP_ASCII:", 1 )[1].split( - "case mesh::UsbLoggingTerminalAction::NONE:", 1 + "case mesh::UsbLoggingTerminalAction::NO_ACTION:", 1 )[0] assert "usb_logging_terminal_mode = false;" in keep_ascii assert "clearUsbTerminalLine();" in keep_ascii diff --git a/variants/rak11310/RAK11310Board.h b/variants/rak11310/RAK11310Board.h index 119c3992..6bef47b1 100644 --- a/variants/rak11310/RAK11310Board.h +++ b/variants/rak11310/RAK11310Board.h @@ -2,6 +2,7 @@ #include #include +#include // from https://github.com/RAKWireless/RAK11300-AT-Command-Firmware/blob/9c48409a43620a828d653501d536473200aa33af/RAK11300-AT-Arduino/batt.cpp#L17-L19 #define VBAT_MV_PER_LSB (0.806F) // 3.0V ADC range and 12 - bit ADC resolution = 3300mV / 4096 @@ -20,6 +21,8 @@ public: void begin(); uint8_t getStartupReason() const override { return startup_reason; } + void attachDynamicPrefs(KeyValueStore* prefs) { (void)prefs; } // no-op + #ifdef P_LORA_TX_LED void onBeforeTransmit() override { digitalWrite(P_LORA_TX_LED, HIGH); } void onAfterTransmit() override { digitalWrite(P_LORA_TX_LED, LOW); } diff --git a/variants/rak3x72/platformio.ini b/variants/rak3x72/platformio.ini index 87ac346b..c87fe8e9 100644 --- a/variants/rak3x72/platformio.ini +++ b/variants/rak3x72/platformio.ini @@ -63,6 +63,7 @@ build_src_filter = ${rak3x72.build_src_filter} [env:RAK_3x72_companion_radio_usb] extends = rak3x72 +platform_packages = platformio/toolchain-gccarmnoneeabi@^1.140201.0 build_unflags = -Os build_flags = ${rak3x72.build_flags} -Oz @@ -75,6 +76,8 @@ build_flags = ${rak3x72.build_flags} -fno-semantic-interposition -fno-unwind-tables -fno-asynchronous-unwind-tables + -fno-schedule-insns2 + -Wl,--sort-section=alignment ; -D FORMAT_FS=true -D MAX_CONTACTS=100 -D MAX_GROUP_CHANNELS=8 diff --git a/variants/tiny_relay/platformio.ini b/variants/tiny_relay/platformio.ini index 5ccd8f81..d1167185 100644 --- a/variants/tiny_relay/platformio.ini +++ b/variants/tiny_relay/platformio.ini @@ -69,6 +69,7 @@ build_src_filter = ${Tiny_Relay.build_src_filter} [env:Tiny_Relay_companion_radio_usb] extends = Tiny_Relay +platform_packages = platformio/toolchain-gccarmnoneeabi@^1.140201.0 build_unflags = -Os build_flags = ${Tiny_Relay.build_flags} -Oz @@ -81,6 +82,8 @@ build_flags = ${Tiny_Relay.build_flags} -fno-semantic-interposition -fno-unwind-tables -fno-asynchronous-unwind-tables + -fno-schedule-insns2 + -Wl,--sort-section=alignment ; -D FORMAT_FS=true -D MAX_CONTACTS=100 -D MAX_GROUP_CHANNELS=8 diff --git a/variants/wio-e5-dev/platformio.ini b/variants/wio-e5-dev/platformio.ini index 71f6b30f..f8de5dbf 100644 --- a/variants/wio-e5-dev/platformio.ini +++ b/variants/wio-e5-dev/platformio.ini @@ -87,6 +87,7 @@ build_src_filter = ${lora_e5.build_src_filter} [env:wio-e5_companion_radio_usb] extends = lora_e5 +platform_packages = platformio/toolchain-gccarmnoneeabi@^1.140201.0 build_unflags = -Os build_flags = ${lora_e5.build_flags} -Oz @@ -99,6 +100,8 @@ build_flags = ${lora_e5.build_flags} -fno-semantic-interposition -fno-unwind-tables -fno-asynchronous-unwind-tables + -fno-schedule-insns2 + -Wl,--sort-section=alignment -D LORA_TX_POWER=22 -D MAX_CONTACTS=100 -D MAX_GROUP_CHANNELS=8 diff --git a/variants/wio-e5-mini/platformio.ini b/variants/wio-e5-mini/platformio.ini index c091ba07..5404e2d0 100644 --- a/variants/wio-e5-mini/platformio.ini +++ b/variants/wio-e5-mini/platformio.ini @@ -75,6 +75,7 @@ build_src_filter = ${lora_e5_mini.build_src_filter} [env:wio-e5-mini_companion_radio_usb] extends = lora_e5_mini +platform_packages = platformio/toolchain-gccarmnoneeabi@^1.140201.0 build_unflags = -Os build_flags = ${lora_e5_mini.build_flags} -Oz @@ -87,6 +88,8 @@ build_flags = ${lora_e5_mini.build_flags} -fno-semantic-interposition -fno-unwind-tables -fno-asynchronous-unwind-tables + -fno-schedule-insns2 + -Wl,--sort-section=alignment -I examples/companion_radio/ui-orig -D LORA_TX_POWER=22 -D MAX_CONTACTS=100