From 5362d36ac7f39e4238100b0d9e0bfeb6c359e885 Mon Sep 17 00:00:00 2001 From: mikecarper Date: Sun, 23 Aug 2026 19:22:45 -0700 Subject: [PATCH] Unify full firmware profiles and add dual USB logging --- build.sh | 314 ++++++++++--- docs/WiFi.md | 45 +- docs/_javascript/firmware_picker.js | 146 ++++++- docs/cli_build_matrix.md | 37 +- docs/cli_command_availability.md | 16 +- docs/cli_commands.md | 49 ++- docs/companion_radio_full.md | 43 +- docs/firmware_picker.md | 34 +- docs/lora_ota_automation.md | 6 +- docs/ota_easy.md | 18 +- docs/ota_protocol.md | 29 +- examples/companion_radio/DataStore.cpp | 7 + examples/companion_radio/MyMesh.cpp | 20 +- examples/companion_radio/MyMesh.h | 4 +- examples/companion_radio/NodePrefs.h | 1 + examples/companion_radio/main.cpp | 49 ++- examples/simple_repeater/MyMesh.cpp | 9 +- examples/simple_room_server/MyMesh.cpp | 9 +- examples/simple_sensor/SensorMesh.cpp | 5 +- mesh-america/README.md | 13 +- .../generate-mesh-america-catalogs.ps1 | 10 +- .../update-logging-provider-release.py | 110 +++-- mesh-america/update-provider-release.py | 411 ++++++++++++++++-- src/Dispatcher.cpp | 27 +- src/MeshCore.h | 10 +- src/helpers/AlertReporter.cpp | 2 +- src/helpers/CommonCLI.cpp | 61 ++- src/helpers/CommonCLI.h | 5 + src/helpers/UsbLogging.cpp | 51 +++ src/helpers/UsbLogging.h | 13 +- src/helpers/WebConfigBatch.h | 17 + src/helpers/bridges/MQTTBridge.cpp | 2 +- src/helpers/bridges/MQTTBridge.h | 4 +- src/helpers/esp32/ESPNOWRadio.h | 4 +- src/helpers/esp32/SerialBLEInterface.h | 4 +- src/helpers/esp32/SerialWifiInterface.h | 4 +- src/helpers/esp32/WebConfigServer.cpp | 24 +- src/helpers/esp32/WebConfigServer.h | 14 +- .../ethernet/SerialEthernetInterface.h | 6 +- src/helpers/nrf52/SerialBLEInterface.h | 4 +- src/helpers/ota/OtaDebug.h | 2 +- src/helpers/radiolib/CustomLLCC68.h | 4 +- src/helpers/radiolib/CustomLR2021.h | 4 +- src/helpers/radiolib/CustomSX1262.h | 4 +- src/helpers/radiolib/CustomSX1268.h | 4 +- src/helpers/radiolib/CustomSX1276.h | 4 +- .../sensors/MicroNMEALocationProvider.h | 2 +- src/helpers/ui/ST7735Display.cpp | 5 +- .../test_companion_node_prefs.cpp | 16 + .../test_config_serializer.cpp | 4 + test/test_firmware_picker.js | 125 +++++- .../test_webconfig_batch.cpp | 32 ++ tools/mota/test_mota.py | 146 +++++++ variants/minewsemi_me25ls01/target.cpp | 16 +- variants/t1000-e/target.cpp | 4 +- variants/thinknode_m3/target.cpp | 4 +- 56 files changed, 1652 insertions(+), 361 deletions(-) diff --git a/build.sh b/build.sh index b6d60ddb..5fb10a48 100755 --- a/build.sh +++ b/build.sh @@ -83,19 +83,19 @@ Commands: help|usage|-h|--help: Shows this message. list|-l: List firmwares available to build. build-firmware : Build the firmware for the given build target. - build-firmwares: Build all firmwares for all targets. - build-firmwares-logging-matrix: Build all firmwares in standard, logging, FULL ESP32 MQTT, and FULL ESP32 logging (no MQTT) profiles, logging each target under out/build-logs/ and continuing after failures. MQTT observers and ESP-NOW bridges always use FULL. - build-companion-firmwares-logging-matrix: Build every Companion target (including Full Companion and legacy FEM variants) in each applicable standard, logging, MQTT, and expanded FULL profile. - build-full-esp32-firmwares: Build only feature-complete ESP32 MQTT profiles with up to 254 neighbors, LoRa OTA, and expanded dual-OTA partitions. - build-full-esp32-logging-firmwares: Build only feature-complete ESP32 profiles with up to 254 neighbors, logging, MQTT disabled, LoRa OTA, and expanded dual-OTA partitions. + build-firmwares: Build canonical firmwares for all targets. Runtime-setting aliases remain available as explicit builds. + build-firmwares-logging-matrix: Build the canonical standard, logging, unified FULL ESP32 USB+WiFi, and FULL logging fallback profiles, logging each target under out/build-logs/ and continuing after failures. MQTT observers and ESP-NOW bridges always use FULL. nRF52 Full Companion provides binary Companion and logging on separate USB ports. + build-companion-firmwares-logging-matrix: Build canonical Companion targets in each applicable standard, MQTT, and expanded FULL profile. nRF52 Full Companion replaces separate USB, BLE, and USB-logging artifacts by exposing Companion and logging on separate USB ports. + 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. build-matching-firmwares : Build all firmwares for build targets containing the string given for . - build-companion-firmwares: Build canonical companion firmwares; legacy _femoff targets remain available as direct builds. + build-companion-firmwares: Build canonical companion firmwares; legacy setting aliases remain available as direct builds. build-full-companion-firmwares: Build canonical full Companion firmwares for supported ESP32 and nRF52 targets. build-repeater-firmwares: Build all repeater firmwares with 254 neighbors, except DRAM-limited targets that retain 50. build-room-server-firmwares: Build all chat room server firmwares for all build targets. build-sensor-firmwares: Build all sensor firmwares for all build targets. build-kiss-radio-firmwares: Build all KISS radio firmwares for all build targets. - get-companion-firmwares-to-build: List USB and BLE companion targets for release automation. + get-companion-firmwares-to-build: List canonical attached companion targets for release automation; nRF52 uses Full Companion instead of separate USB/BLE artifacts. get-repeater-firmwares-to-build: List standard and specialized external-storage repeater targets for release automation. get-room-server-firmwares-to-build: List standard room-server targets for release automation. @@ -502,14 +502,14 @@ prompt_on_off_choice() { prompt_for_build_mode() { local options=( "Build one firmware target" - "Build all firmwares" - "Build all firmwares in 5 profiles (standard, logging, MQTT, full ESP32 MQTT, full ESP32 logging without MQTT)" + "Build all canonical firmwares (legacy setting aliases remain direct-build only)" + "Build the canonical release matrix with unified FULL ESP32 USB + WiFi output (plus logging fallbacks where WiFi is unavailable)" "Build all repeater firmwares" - "Build canonical companion firmwares (FEM RX gain is runtime configurable)" + "Build canonical companion firmwares (nRF52 Full replaces USB/BLE; power saving and FEM/RX gain are runtime configurable)" "Build all chat room server firmwares" "Build all sensor firmwares" - "Build only FULL ESP32 MQTT firmwares (all features, MQTT, and LoRa OTA)" - "Build only FULL ESP32 logging firmwares (all features, logging, no MQTT, and LoRa OTA)" + "Build FULL ESP32 firmwares (all features, USB logging, WiFi MQTT where available, and LoRa OTA)" + "Build only FULL ESP32 USB-logging fallbacks for targets without WiFi MQTT" "Build canonical full Companion firmwares (runtime FEM control and host-backed LoRa OTA)" ) @@ -578,7 +578,7 @@ prompt_for_single_target_build_profile() { local options=( "Standard/custom build" - "FULL everything (all features, 254 neighbors, logging, MQTT off, LoRa OTA, expanded dual-OTA partitions)" + "FULL everything (all features, 254 neighbors, USB logging, WiFi MQTT where available, LoRa OTA, expanded dual-OTA partitions)" ) echo "Select the Option 1 build profile:" @@ -597,7 +597,7 @@ prompt_for_single_target_build_profile() { ;; 2) SINGLE_TARGET_FULL_BUILD=1 - echo "Using FULL everything: all features, 254 neighbors, logging, MQTT off, LoRa OTA, and expanded dual-OTA partitions." + echo "Using FULL everything: all features, 254 neighbors, USB logging, WiFi MQTT where available, LoRa OTA, and expanded dual-OTA partitions." return 0 ;; esac @@ -1465,7 +1465,9 @@ get_pio_envs_ending_with_string() { shopt -s nocasematch for env in "${SUPPORTED_PIO_ENVS[@]}"; do - if is_supported_build_env "$env" && [[ "$env" == *${suffix} ]]; then + if is_supported_build_env "$env" \ + && ! is_redundant_bulk_build_target "$env" \ + && [[ "$env" == *${suffix} ]]; then printf '%s\n' "$env" fi done @@ -1477,6 +1479,17 @@ print_release_firmware_targets() { get-companion-firmwares-to-build) get_pio_envs_ending_with_string "_companion_radio_usb" get_pio_envs_ending_with_string "_companion_radio_ble" + # A generated nRF52 Full Companion supplies both attached transports and + # source-only serial mOTA in one image. It replaces the separate USB/BLE + # release artifacts without taking on a staging or self-install role. + local env_name + for env_name in "${SUPPORTED_PIO_ENVS[@]}"; do + if [ "${PIO_ENV_PLATFORM_BY_NAME[$env_name]:-}" = "NRF52_PLATFORM" ] \ + && is_companion_radio_full_target "$env_name" \ + && ! is_redundant_bulk_build_target "$env_name"; then + printf '%s\n' "$env_name" + fi + done ;; get-repeater-firmwares-to-build) get_pio_envs_ending_with_string "_repeater" @@ -1959,16 +1972,30 @@ apply_debug_overrides() { disable_usb_logging_for_mqtt() { local env_name=$1 - # FULL logging is an explicit diagnostic profile. Keep its requested USB - # debug and packet logging even when the target also publishes over MQTT. + # ESP32 Full Companion has one serial stream, so plaintext diagnostics would + # corrupt its framed Companion traffic. nRF52 Full Companion has a dedicated + # second CDC interface and enables logging later in its profile overlay. + if is_companion_radio_full_target "$env_name"; then + if is_nrf52_companion_radio_full_target "$env_name"; then + return 0 + fi + export PLATFORMIO_BUILD_FLAGS="${PLATFORMIO_BUILD_FLAGS} -UMESH_DEBUG -UMESH_PACKET_LOGGING -UMQTT_DEBUG -UMQTT_MEMORY_DEBUG" + return 0 + fi + + # Unified non-companion FULL builds deliberately publish the same radio + # stream over USB packet logging and direct WiFi MQTT. Full Companion keeps + # its framed binary serial protocol and must never inherit plaintext logs. if [ "$ESP32_FULL_BUILD" = "1" ] \ - && [ "$FIRMWARE_FILENAME_INFIX" = "full-logging" ]; then + && [ "${PACKET_LOGGING_OVERRIDE,,}" = "on" ] \ + && ! is_esp32_companion_build "$env_name"; then return 0 fi if is_mqtt_bridge_target "$env_name" || [ "${MQTT_BRIDGE_OVERRIDE,,}" == "on" ]; then - # MQTT observers already export packet traffic through the bridge. Keep the - # serial console available for the CLI without compiling a second logging path. + # Ordinary MQTT observers export packet traffic only through the bridge. + # Keep their serial console clean unless the explicit unified FULL profile + # above requested both output paths. export PLATFORMIO_BUILD_FLAGS="${PLATFORMIO_BUILD_FLAGS} -UMESH_DEBUG -UMESH_PACKET_LOGGING -UMQTT_DEBUG -UMQTT_MEMORY_DEBUG" fi } @@ -2463,11 +2490,12 @@ apply_companion_radio_full_profile() { export PLATFORMIO_BUILD_FLAGS="${PLATFORMIO_BUILD_FLAGS} -UDISABLE_LORA_OTA -DENABLE_OTA=1 -UOTA_FLASH_STORE -UOTA_SD_STORE -DOTA_SEEDER_ONLY=1 -DMOTA_TARGET_ID=0 -DCOMPANION_RADIO_FULL=1 -DENABLE_USB_INTERFACE=1 -DBLE_PIN_CODE=123456" if is_nrf52_companion_radio_full_target "$env_name"; then - # The USB stream starts as Binary Companion. `motatool serve --serial` - # switches it into an exclusive host-folder mode with its existing - # `ota folder on` preamble; BLE remains an independent Companion link. + # CDC 0 starts as Binary Companion. `motatool serve --serial` switches it + # into an exclusive host-folder mode with its existing `ota folder on` + # 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" + 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" if ! pio_env_option_contains "$pio_env_name" build_src_filter "helpers/ota/"; then append_platformio_build_src_filter "+" @@ -2711,6 +2739,8 @@ get_firmware_filename() { if [ "$ESP32_FULL_BUILD" = "1" ] && is_lora_ota_build "$env_name"; then if [ "$filename_infix" = "full-logging" ]; then filename_infix="full-logging-ota" + elif [ "$filename_infix" = "full-usb-wifi" ]; then + filename_infix="full-usb-wifi-ota" else filename_infix="full-ota" fi @@ -2946,7 +2976,22 @@ resolve_matching_firmwares() { } resolve_all_firmwares() { - get_supported_pio_envs + local env_name + + while IFS= read -r env_name; do + if ! is_redundant_bulk_build_target "$env_name"; then + printf '%s\n' "$env_name" + fi + done < <(get_supported_pio_envs) +} + +is_legacy_companion_power_saving_target() { + case "${1,,}" in + *companion_radio_*_ps|*companion_radio_*_ps_*) + return 0 + ;; + esac + return 1 } is_legacy_companion_femoff_target() { @@ -2958,21 +3003,100 @@ is_legacy_companion_femoff_target() { return 1 } +is_legacy_radio_gain_profile_target() { + # The Station G2 name only selects the persisted SX126x boosted-RX default; + # the Station G3 name changes only ADVERT_NAME. The ordinary target supports + # `set radio.rxgain on|off`, so neither needs a separate release artifact. + case "${1,,}" in + station_g2_logging_*|station_g3_esp32_logging_*) + return 0 + ;; + esac + return 1 +} + +is_exact_companion_recipe_alias_target() { + # These two Heltec V4 aliases extend the unsuffixed target without changing + # any effective PlatformIO option. Other _femon names have no unsuffixed + # target and therefore remain the canonical recipe for that hardware. + case "${1,,}" in + heltec_v4_companion_radio_usb_femon|heltec_v4_companion_radio_ble_femon) + return 0 + ;; + esac + return 1 +} + +get_nrf52_full_companion_replacement() { + local env_name=$1 + local full_env="" + + [ "${PIO_ENV_PLATFORM_BY_NAME[$env_name]:-}" = "NRF52_PLATFORM" ] || return 1 + case "${env_name,,}" in + *companion_radio_usb*) + full_env=${env_name/companion_radio_usb/companion_radio_full} + ;; + *companion_radio_ble*) + full_env=${env_name/companion_radio_ble/companion_radio_full} + ;; + *) + return 1 + ;; + esac + + [ "${PIO_ENV_PLATFORM_BY_NAME[$full_env]:-}" = "NRF52_PLATFORM" ] || return 1 + printf '%s\n' "$full_env" +} + +is_nrf52_companion_transport_replaced_by_full() { + get_nrf52_full_companion_replacement "$1" >/dev/null +} + +is_runtime_setting_alias_target() { + if is_legacy_companion_power_saving_target "$1" \ + || is_legacy_companion_femoff_target "$1" \ + || is_legacy_radio_gain_profile_target "$1" \ + || is_exact_companion_recipe_alias_target "$1"; then + return 0 + fi + return 1 +} + +is_redundant_bulk_build_target() { + # Keep every legacy name available to `build-firmware` and + # `build-matching-firmwares`, but do not republish binaries that differ only + # by a saved/default setting or by an attached nRF52 transport already + # supplied by Full Companion. + if is_runtime_setting_alias_target "$1" \ + || is_nrf52_companion_transport_replaced_by_full "$1"; then + return 0 + fi + return 1 +} + +resolve_logging_matrix_firmwares() { + # nRF52 Full Companion replaces normal USB, BLE, and USB-logging artifacts. + # It exposes framed Companion traffic and plaintext logging as separate CDC + # interfaces over one physical USB connection. + resolve_all_firmwares +} + resolve_companion_firmwares() { local env_name while IFS= read -r env_name; do - if ! is_legacy_companion_femoff_target "$env_name"; then + if ! is_redundant_bulk_build_target "$env_name"; then printf '%s\n' "$env_name" fi done < <(get_pio_envs_for_variant_role companion) } resolve_all_companion_firmwares() { - # Corrective/replacement releases must cover every published Companion - # artifact, including generated Full Companion aliases and the legacy - # _femoff variants omitted from the canonical day-to-day bulk command. - get_pio_envs_for_variant_role companion + resolve_companion_firmwares +} + +resolve_companion_logging_matrix_firmwares() { + resolve_all_companion_firmwares } resolve_full_companion_firmwares() { @@ -2981,7 +3105,7 @@ resolve_full_companion_firmwares() { for env_name in "${SUPPORTED_PIO_ENVS[@]}"; do if is_supported_build_env "$env_name" \ && is_companion_radio_full_target "$env_name"; then - if is_legacy_companion_femoff_target "$env_name"; then + if is_redundant_bulk_build_target "$env_name"; then continue fi printf '%s\n' "$env_name" @@ -3022,10 +3146,10 @@ get_bulk_build_resolver_name() { echo "resolve_all_firmwares" ;; build-firmwares-logging-matrix) - echo "resolve_all_firmwares" + echo "resolve_logging_matrix_firmwares" ;; build-companion-firmwares-logging-matrix) - echo "resolve_all_companion_firmwares" + echo "resolve_companion_logging_matrix_firmwares" ;; build-full-esp32-firmwares) echo "resolve_full_esp32_firmwares" @@ -3440,13 +3564,26 @@ run_logged_build_targets() { return "$overall_status" } +has_esp32_full_profile() { + local target=$1 + local candidate="" + + candidate=$(get_mqtt_enabled_target "$target") || candidate="" + if [ -n "$candidate" ] && supports_esp32_full_build "$candidate"; then + return 0 + fi + candidate=$(get_mqtt_disabled_target "$target") || candidate="" + [ -n "$candidate" ] && supports_esp32_full_build "$candidate" +} + run_full_esp32_profile() { local profile_label=$1 - local logging_mode=$2 + local profile_mode=$2 shift 2 local targets=("$@") local target local full_target + local mqtt_target local full_targets=() local -A seen_full_targets=() local original_meshdebug_override=$MESHDEBUG_OVERRIDE @@ -3460,7 +3597,13 @@ run_full_esp32_profile() { for target in "${targets[@]}"; do full_target="" - if [ "$logging_mode" = "on" ]; then + if [ "$profile_mode" = "fallback" ]; then + # A matching MQTT environment is emitted once by the unified profile; + # do not also build its former non-MQTT FULL-logging twin. + mqtt_target=$(get_mqtt_enabled_target "$target") || mqtt_target="" + if [ -n "$mqtt_target" ] && supports_esp32_full_build "$mqtt_target"; then + continue + fi full_target=$(get_mqtt_disabled_target "$target") || full_target="" else full_target=$(get_mqtt_enabled_target "$target") || full_target="" @@ -3475,28 +3618,28 @@ run_full_esp32_profile() { done if [ ${#full_targets[@]} -eq 0 ]; then - if [ "$logging_mode" = "on" ]; then - echo "${profile_label}: no non-MQTT ESP32 FULL targets resolved; skipping." + if [ "$profile_mode" = "fallback" ]; then + echo "${profile_label}: every FULL target has a unified WiFi MQTT profile; no logging-only fallback is needed." else - echo "${profile_label}: no MQTT ESP32 FULL targets resolved; skipping." + echo "${profile_label}: no WiFi MQTT ESP32 FULL targets resolved; skipping." fi return 0 fi - if [ "$logging_mode" = "on" ]; then - echo "${profile_label}: building ${#full_targets[@]} feature-complete ESP32 target(s) with up to ${ESP32_FULL_MAX_NEIGHBOURS} neighbors (target DRAM limits apply), logging on, MQTT off, and expanded dual-OTA partitions." - echo "FULL logging artifacts exclude MQTT, include LoRa OTA, and use filename form: name-full-logging-ota-version." + if [ "$profile_mode" = "fallback" ]; then + echo "${profile_label}: building ${#full_targets[@]} feature-complete ESP32 fallback target(s) with up to ${ESP32_FULL_MAX_NEIGHBOURS} neighbors (target DRAM limits apply), USB logging on, no available WiFi MQTT sibling, and expanded dual-OTA partitions." + echo "Fallback artifacts include LoRa OTA and use filename form: name-full-logging-ota-version." MESHDEBUG_OVERRIDE="on" PACKET_LOGGING_OVERRIDE="on" MQTT_BRIDGE_OVERRIDE="off" FIRMWARE_FILENAME_INFIX="full-logging" else - echo "${profile_label}: building ${#full_targets[@]} feature-complete ESP32 MQTT target(s) with up to ${ESP32_FULL_MAX_NEIGHBOURS} neighbors (target DRAM limits apply), logging off, and expanded dual-OTA partitions." - echo "FULL artifacts include MQTT and LoRa OTA and use filename form: name-full-ota-version." + echo "${profile_label}: building ${#full_targets[@]} unified feature-complete ESP32 target(s) with up to ${ESP32_FULL_MAX_NEIGHBOURS} neighbors (target DRAM limits apply), USB packet logging, direct WiFi MQTT, and expanded dual-OTA partitions." + echo "Unified FULL artifacts include LoRa OTA, keep verbose debug off, and use filename form: name-full-usb-wifi-ota-version." MESHDEBUG_OVERRIDE="off" - PACKET_LOGGING_OVERRIDE="off" + PACKET_LOGGING_OVERRIDE="on" MQTT_BRIDGE_OVERRIDE="on" - FIRMWARE_FILENAME_INFIX="full" + FIRMWARE_FILENAME_INFIX="full-usb-wifi" fi echo "Flash the matching merged image once to install the expanded partition table." MQTT_DEBUG_OVERRIDE="off" @@ -3521,19 +3664,32 @@ run_full_esp32_profile() { } run_full_esp32_build_targets() { - local logging_mode=$1 + local profile_mode=$1 shift local targets=("$@") - local profile_name="FULL MQTT" + local profile_name="FULL unified" local build_status=0 + local pass_status=0 - if [ "$logging_mode" = "on" ]; then - profile_name="FULL logging" + if [ "$profile_mode" = "fallback" ]; then + profile_name="FULL logging fallback" fi LOGGING_MATRIX_FAILURES=() - run_full_esp32_profile "${profile_name}-only build" "$logging_mode" "${targets[@]}" - build_status=$? + if [ "$profile_mode" = "fallback" ]; then + run_full_esp32_profile "${profile_name}-only build" "fallback" "${targets[@]}" + build_status=$? + else + run_full_esp32_profile "${profile_name} build" "unified" "${targets[@]}" + pass_status=$? + if [ "$pass_status" -eq 130 ]; then return 130; fi + if [ "$pass_status" -ne 0 ]; then build_status=1; fi + + run_full_esp32_profile "FULL logging fallback build" "fallback" "${targets[@]}" + pass_status=$? + if [ "$pass_status" -eq 130 ]; then return 130; fi + if [ "$pass_status" -ne 0 ]; then build_status=1; fi + fi if [ ${#LOGGING_MATRIX_FAILURES[@]} -gt 0 ]; then echo "${profile_name}-only build completed with ${#LOGGING_MATRIX_FAILURES[@]} failed build(s):" @@ -3551,6 +3707,7 @@ run_logging_matrix_build_targets() { local targets=("$@") local target local standard_targets=() + local logging_source_targets=() local logging_targets=() local filtered_logging_targets=() local constrained_logging_targets=() @@ -3563,7 +3720,9 @@ run_logging_matrix_build_targets() { local original_profile_build_workers=$PROFILE_BUILD_WORKERS local bluetooth_skip_count=0 local lora_ota_only_skip_count=0 - local full_cli_logging_skip_count=0 + local full_only_standard_skip_count=0 + local full_companion_logging_skip_count=0 + local full_profile_logging_skip_count=0 local logging_target_count=0 local build_status=0 local pass_status=0 @@ -3577,13 +3736,21 @@ run_logging_matrix_build_targets() { echo "Option 3 parallelism: ${PROFILE_BUILD_WORKERS} target build(s), ${OPTION3_PIO_JOBS} PlatformIO job(s) per target." for target in "${targets[@]}"; do - if ! is_mqtt_bridge_target "$target"; then + if is_mqtt_bridge_target "$target"; then + continue + fi + logging_source_targets+=("$target") + if requires_esp32_full_cli_profile "$target"; then + full_only_standard_skip_count=$((full_only_standard_skip_count + 1)) + else standard_targets+=("$target") fi done - echo "Profile 1/4: building ${#standard_targets[@]} standard target(s) with logging off and MQTT bridge off." - echo "ESP32 ESP-NOW bridge targets in this pass are automatically promoted to FULL so they retain the complete CLI." + echo "Profile 1/3: building ${#standard_targets[@]} standard target(s) with logging off and MQTT bridge off." + if [ "$full_only_standard_skip_count" -gt 0 ]; then + echo "Deferring ${full_only_standard_skip_count} ESP32 ESP-NOW target(s) to their FULL logging fallback; its persistent USB gate also provides normal output-off operation." + fi ESP32_FULL_BUILD=0 MESHDEBUG_OVERRIDE="off" PACKET_LOGGING_OVERRIDE="off" @@ -3596,8 +3763,8 @@ run_logging_matrix_build_targets() { if [ "$pass_status" -ne 0 ]; then build_status=1; fi fi - mapfile -t logging_targets < <(filter_out_bluetooth_targets "${standard_targets[@]}") - bluetooth_skip_count=$((${#standard_targets[@]} - ${#logging_targets[@]})) + mapfile -t logging_targets < <(filter_out_bluetooth_targets "${logging_source_targets[@]}") + bluetooth_skip_count=$((${#logging_source_targets[@]} - ${#logging_targets[@]})) if [ "$bluetooth_skip_count" -gt 0 ]; then echo "Skipping ${bluetooth_skip_count} Bluetooth target(s) for logging-on pass." @@ -3617,15 +3784,20 @@ run_logging_matrix_build_targets() { filtered_logging_targets=() for target in "${logging_targets[@]}"; do - if requires_esp32_full_cli_profile "$target"; then - full_cli_logging_skip_count=$((full_cli_logging_skip_count + 1)) + if is_companion_radio_full_target "$target"; then + full_companion_logging_skip_count=$((full_companion_logging_skip_count + 1)) + elif has_esp32_full_profile "$target"; then + full_profile_logging_skip_count=$((full_profile_logging_skip_count + 1)) else filtered_logging_targets+=("$target") fi done logging_targets=("${filtered_logging_targets[@]}") - if [ "$full_cli_logging_skip_count" -gt 0 ]; then - echo "Deferring ${full_cli_logging_skip_count} ESP32 target(s) to the FULL logging pass so the complete CLI is retained." + if [ "$full_profile_logging_skip_count" -gt 0 ]; then + echo "Deferring ${full_profile_logging_skip_count} ESP32 target(s) to the unified FULL/fallback pass; their separate standard logging artifacts would be redundant." + fi + if [ "$full_companion_logging_skip_count" -gt 0 ]; then + echo "Skipping ${full_companion_logging_skip_count} Full Companion target(s) for the separate logging-on pass; nRF52 Full already provides logging on its second USB port, while ESP32 Full keeps its single USB stream protocol-safe." fi for target in "${logging_targets[@]}"; do @@ -3637,7 +3809,7 @@ run_logging_matrix_build_targets() { logging_target_count=$((${#logging_targets[@]} + ${#constrained_logging_targets[@]})) if [ "$logging_target_count" -gt 0 ]; then - echo "Profile 2/4: building ${logging_target_count} standard target(s) with logging on and MQTT bridge off." + echo "Profile 2/3: building ${logging_target_count} standard target(s) with logging on and MQTT bridge off." echo "Logging-on artifacts use filename form: name-logging-version" else echo "No non-Bluetooth targets remain for logging-on pass." @@ -3666,12 +3838,12 @@ run_logging_matrix_build_targets() { if [ "$pass_status" -ne 0 ]; then build_status=1; fi fi - run_full_esp32_profile "Profile 3/4" "off" "${targets[@]}" + run_full_esp32_profile "Profile 3/3 unified FULL" "unified" "${targets[@]}" pass_status=$? if [ "$pass_status" -eq 130 ]; then return 130; fi if [ "$pass_status" -ne 0 ]; then build_status=1; fi - run_full_esp32_profile "Profile 4/4" "on" "${targets[@]}" + run_full_esp32_profile "Profile 3/3 logging fallback" "fallback" "${targets[@]}" pass_status=$? if [ "$pass_status" -eq 130 ]; then return 130; fi if [ "$pass_status" -ne 0 ]; then build_status=1; fi @@ -3724,7 +3896,7 @@ validate_command() { run_command() { # All build commands share execution after validation resolves their target list. if [ "$SINGLE_TARGET_FULL_BUILD" = "1" ]; then - run_full_esp32_build_targets "on" "${RESOLVED_BUILD_TARGETS[@]}" + run_full_esp32_build_targets "all" "${RESOLVED_BUILD_TARGETS[@]}" return $? fi @@ -3734,12 +3906,12 @@ run_command() { fi if is_full_esp32_command "$1"; then - run_full_esp32_build_targets "off" "${RESOLVED_BUILD_TARGETS[@]}" + run_full_esp32_build_targets "all" "${RESOLVED_BUILD_TARGETS[@]}" return $? fi if is_full_esp32_logging_command "$1"; then - run_full_esp32_build_targets "on" "${RESOLVED_BUILD_TARGETS[@]}" + run_full_esp32_build_targets "fallback" "${RESOLVED_BUILD_TARGETS[@]}" return $? fi @@ -3796,14 +3968,14 @@ main() { prompt_for_build_mode if [ "$SINGLE_TARGET_FULL_BUILD" = "1" ]; then - echo "Skipping separate debug and MQTT prompts; FULL everything enables logging and explicitly disables MQTT." + echo "Skipping separate debug and MQTT prompts; FULL everything enables USB logging and WiFi MQTT where the hardware supports it." elif is_automatic_profile_command "${SELECTED_COMMAND_ARGS[0]}"; then if is_logging_matrix_command "${SELECTED_COMMAND_ARGS[0]}"; then - echo "Skipping debug and MQTT prompts; this action builds all four profiles automatically." + echo "Skipping debug and MQTT prompts; this action builds standard, logging, and unified FULL profiles automatically." elif is_full_esp32_logging_command "${SELECTED_COMMAND_ARGS[0]}"; then - echo "Skipping debug and MQTT prompts; this action builds only the FULL ESP32 logging profile with MQTT disabled." + echo "Skipping debug and MQTT prompts; this action builds only logging fallbacks for FULL targets without WiFi MQTT." else - echo "Skipping debug and MQTT prompts; this action builds only the FULL ESP32 MQTT profile." + echo "Skipping debug and MQTT prompts; this action builds unified FULL USB + WiFi profiles and required logging fallbacks." fi else prompt_for_mqtt_bridge_build_setting diff --git a/docs/WiFi.md b/docs/WiFi.md index 082b9073..998f3522 100644 --- a/docs/WiFi.md +++ b/docs/WiFi.md @@ -115,6 +115,25 @@ The setup AP is unauthenticated unless the firmware was built with location. When WebConfig is running on the normal LAN, repeater and room-server builds require the node's admin password. +On an expanded FULL profile with no saved SSID, this automatic setup AP has an +absolute 30-minute window. Browser activity or a phone left associated with the +AP does not extend it. If no SSID has been saved when the window expires, +WebConfig closes and the ESP32 WiFi radio remains off automatically for the +remainder of that boot. A reboot or power cycle starts a new 30-minute setup +window; the timeout is deliberately not written to preferences. An +administrator can still override the automatic cutoff with an explicit +`start webconfig` command. + +Once an SSID is saved, the provisioning cutoff no longer applies. When WiFi is +selected by `logging.output wifi|both`, the station stays enabled and keeps +trying the saved network indefinitely: ESP automatic reconnect remains on and +the explicit fallback advances through 15, 30, 60, 120, then 300-second retry +intervals, remaining at five minutes until it reconnects. MQTT broker retries +use their own backoff; a repeatedly failing broker eventually receives one +probe every 30 minutes. Selecting `logging.output off|usb` keeps the MQTT bridge +off, so saved credentials alone do not force WiFi on unless WebConfig is also +enabled explicitly. + The equivalent MQTT observer CLI setup is: ```text @@ -318,9 +337,10 @@ In this case WebConfig owns WiFi only while it is needed. Stopping the portal disconnects WiFi and turns the WiFi radio off. There is no persistent MQTT connection keeping WiFi active. -FULL MQTT and FULL logging repeater/room-server builds both provide these CLI -controls and status checks. FULL MQTT includes the MQTT bridge; FULL logging -does not: +Unified FULL USB + WiFi and FULL logging-fallback repeater/room-server builds +both provide these CLI controls and status checks. The unified profile includes +the MQTT bridge; the fallback is used only where no matching MQTT environment +exists: ```text get wifi.ssid @@ -396,10 +416,10 @@ role. | Build profile | WiFi/MQTT behavior | |---|---| | Standard | Uses the selected target's role. Ordinary legacy-slot ESP32 repeater/room-server artifacts omit WebConfig when needed to fit. ESP32 MQTT observer and ESP-NOW bridge targets are automatically promoted to FULL; WiFi-companion targets keep their companion partition profile. | -| Logging | Enables USB/debug packet logging and disables the MQTT bridge. `get/set usb.logging` can silence or restore the live output until reboot. Logging output is not an MQTT uplink. | +| Logging | Enables USB/debug packet logging and disables the MQTT bridge. CommonCLI roles persist `get/set usb.logging`; logging output itself is not a direct MQTT uplink. | | MQTT | Builds explicit MQTT observer or WiFi-companion-MQTT targets with USB packet logging off. Non-companion ESP32 MQTT observers always use FULL expanded partitions. | -| FULL ESP32 | Uses the board's MQTT target with logging off, expanded dual-OTA partitions, up to 254 neighbors, LoRa OTA, and full-size ESP32 features such as WebConfig where supported. Classic T-Beam MQTT observers retain their 50-entry table because their persistent discovery state exhausts internal DRAM at 254. | -| FULL ESP32 logging | Uses the board's non-MQTT target with debug and packet logging enabled, session-only `get/set usb.logging` control, expanded dual-OTA partitions, up to 254 neighbors, and LoRa OTA. | +| FULL ESP32 USB + WiFi | Uses the board's MQTT target with USB packet logging and direct WiFi MQTT together, expanded dual-OTA partitions, up to 254 neighbors, LoRa OTA, and full-size ESP32 features such as WebConfig where supported. `get/set logging.output off\|usb\|wifi\|both` persists the active paths. Classic T-Beam MQTT observers retain their 50-entry table because their persistent discovery state exhausts internal DRAM at 254. | +| FULL ESP32 logging fallback | Uses the board's non-MQTT target only when no matching WiFi MQTT environment exists. It keeps debug and packet logging, expanded dual-OTA partitions, up to 254 neighbors, and LoRa OTA. Persistent `usb.logging off` also provides normal output-off operation, so ESP-NOW FULL roles need no second non-logging image. | | LoRa-OTA no-external-sensors | A lean repeater image with no MQTT; ESP32 builds retain the compact on-demand browser WiFi uploader and 254 neighbors. | All repeater profiles use the full 254-entry neighbor table, including standard, @@ -407,12 +427,13 @@ logging, bridge, and LoRa-OTA builds on every supported platform. The classic T-Beam SX1262 and SX1276 MQTT observer repeaters retain 50 entries because their persistent MQTT discovery state leaves insufficient internal-DRAM margin at 254. -The interactive Option 1 **FULL everything** choice selects the FULL logging -profile: logging is enabled and MQTT is disabled. The standalone FULL ESP32 -profile and Profile 3 of the four-profile matrix use the matching MQTT target -instead. Both FULL profiles include LoRa OTA, WebConfig where supported, up to -254 neighbors, and expanded dual-OTA partitions. Target-specific internal-DRAM -limits still apply. +The interactive Option 1 **FULL everything** choice and the standalone FULL +command select the unified USB + WiFi image when a matching MQTT target exists; +otherwise they select the logging fallback. The build matrix no longer emits a +separate standard logging image or non-MQTT FULL twin for a role covered by the +unified image. All FULL profiles include LoRa OTA, WebConfig where supported, +up to 254 neighbors, and expanded dual-OTA partitions. Target-specific +internal-DRAM limits still apply. FULL images change the ESP32 partition layout. Flash the matching `*-merged.bin` once when installing that layout. A partition-layout change can diff --git a/docs/_javascript/firmware_picker.js b/docs/_javascript/firmware_picker.js index cbe9b4e6..7f47fe69 100644 --- a/docs/_javascript/firmware_picker.js +++ b/docs/_javascript/firmware_picker.js @@ -37,6 +37,9 @@ none: "None / normal operation", usb: "USB logging / USB-connected MQTT", wifi: "Wi-Fi MQTT observer", + both: "USB logging + Wi-Fi MQTT", + runtime: "Runtime selectable: off / USB / Wi-Fi / both", + "usb-runtime": "Runtime selectable: off / USB", }); const OTA_LABELS = Object.freeze({ @@ -90,9 +93,10 @@ }); const HIDDEN_PROFILE_HARDWARE = Object.freeze([ - // This legacy environment only changes ADVERT_NAME. The standard G3 - // target provides the same firmware behavior, while all legacy files stay - // available through exact filename search. + // These legacy Station environments select a runtime radio-gain default + // or only change ADVERT_NAME. Their ordinary targets provide the same + // behavior, while all legacy files stay available through exact search. + "Station_G2_logging", "Station_G3_ESP32_logging", ]); @@ -102,6 +106,9 @@ // siblings; keep those files searchable without recommending them. "solarxiao_30S_repeater_lora_ota_no_external_sensors", "solarxiao_33S_repeater_lora_ota_no_external_sensors", + // Exact aliases of the unsuffixed Heltec V4 USB/BLE recipes. + "heltec_v4_companion_radio_usb_femon", + "heltec_v4_companion_radio_ble_femon", ]); let pickerInstanceCount = 0; @@ -287,7 +294,13 @@ /^(?:full|ble|usb|wifi|serial|ethernet)(?=$|[_-])/i, " " ); + // Power saving and controllable FEM gain are persisted settings. Legacy + // filenames retain these tokens for exact search, but they are not + // distinct recommended firmware variants. + value = value.replace(/(?:^|[_-])ps(?=$|[_-])/gi, " "); + value = value.replace(/(?:^|[_-])fem(?:on|off)(?=$|[_-])/gi, " "); } + value = value.replace(/-full-usb-wifi/gi, ""); value = value.replace(/-logging/gi, ""); value = value.replace(/(?:^|[_-])full(?=$|[_-])/gi, " "); value = value.replace(/observer_mqtt/gi, " "); @@ -307,11 +320,23 @@ const lowerTarget = target.toLowerCase(); const lowerTail = parts.tail.toLowerCase(); const mode = modeForRole(parts.role, parts.tail); - const logging = lowerTarget.includes("observer_mqtt") - ? "wifi" - : lowerTarget.includes("-logging") - ? "usb" - : "none"; + const unifiedLogging = lowerTarget.includes("-full-usb-wifi"); + const fullLoggingFallback = parts.role !== "companion" && + lowerTarget.includes("-full-logging"); + const logging = unifiedLogging + ? "runtime" + : fullLoggingFallback + ? "usb-runtime" + : lowerTarget.includes("observer_mqtt") + ? "wifi" + : lowerTarget.includes("-logging") + ? "usb" + : "none"; + const loggingModes = unifiedLogging + ? ["none", "usb", "wifi", "both"] + : fullLoggingFallback + ? ["none", "usb"] + : [logging]; const feature = lowerTail.includes("-full") || (parts.role === "companion" && mode === "full") ? "full" @@ -334,6 +359,7 @@ hardware: canonicalHardware(sourceHardware), role: parts.role, logging: logging, + loggingModes: loggingModes, mode: mode, feature: feature, variant: variant, @@ -346,6 +372,47 @@ return HARDWARE_ALIASES[value] || value; } + function isHiddenLegacyProfile(profile) { + const target = String(profile && profile.target || ""); + const lowerTarget = target.toLowerCase(); + return HIDDEN_PROFILE_HARDWARE.includes(profile.sourceHardware) || + HIDDEN_PROFILE_TARGETS.includes(target) || + (lowerTarget.includes("companion_radio_") && + /(?:^|[_-])(?:ps|femoff)(?=$|[_-])/.test(lowerTarget)); + } + + function omitNrf52TransportsReplacedByFull(profiles) { + const fullKeys = new Set((profiles || []).filter(function (profile) { + return profile.role === "companion" && profile.mode === "full" && + profile.installKinds.includes("zip"); + }).map(function (profile) { + return profile.hardware + "\n" + profile.variant; + })); + + return (profiles || []).filter(function (profile) { + const key = profile.hardware + "\n" + profile.variant; + const replacedAttachedTransport = profile.role === "companion" && + (profile.mode === "usb" || profile.mode === "ble"); + return !(replacedAttachedTransport && fullKeys.has(key)); + }); + } + + function applyNrf52FullCompanionCapabilities(profiles) { + return (profiles || []).map(function (profile) { + const nativeNrf52Full = profile.role === "companion" && + profile.mode === "full" && profile.installKinds.includes("zip"); + if (!nativeNrf52Full) return profile; + + // Current nRF52 Full Companion images expose two CDC ACM interfaces: + // framed Companion traffic on interface 00 and plaintext logging on 02. + // The saved USB logging gate covers only the second interface. + profile.logging = "usb-runtime"; + profile.loggingModes = ["none", "usb"]; + profile.dedicatedUsbLogging = true; + return profile; + }); + } + function hardwareFamilyFor(hardware, hardwareNames) { const value = String(hardware || ""); const lowerValue = value.toLowerCase(); @@ -432,7 +499,7 @@ profile.otaPackaged = profile.otaPackaged || file.otaPackaged; }); - const profiles = Array.from(grouped.values()).map(function (profile) { + const visibleProfiles = Array.from(grouped.values()).map(function (profile) { profile.ota = profile.explicitOta || (profile.otaPackaged ? "lora-receiver" : "none"); profile.installKinds = INSTALL_ORDER.filter(function (kind) { @@ -443,9 +510,11 @@ }); return profile; }).filter(function (profile) { - return !HIDDEN_PROFILE_HARDWARE.includes(profile.sourceHardware) && - !HIDDEN_PROFILE_TARGETS.includes(profile.target); - }).sort(function (a, b) { + return !isHiddenLegacyProfile(profile); + }); + const profiles = omitNrf52TransportsReplacedByFull( + applyNrf52FullCompanionCapabilities(visibleProfiles) + ).sort(function (a, b) { return a.target.localeCompare(b.target, undefined, { numeric: true, sensitivity: "base", @@ -473,10 +542,17 @@ const wantedFields = fields || FILTER_FIELDS; return wantedFields.every(function (field) { const value = filters && filters[field]; - return !value || profile[field] === value; + return !value || profileFieldValues(profile, field).includes(value); }); } + function profileFieldValues(profile, field) { + if (field === "logging" && Array.isArray(profile.loggingModes)) { + return profile.loggingModes; + } + return profile[field] ? [profile[field]] : []; + } + function profileMatchesFacets(profile, filters, ignoredField) { const ignoredFields = new Set( Array.isArray(ignoredField) ? ignoredField : [ignoredField] @@ -488,7 +564,7 @@ if (field === "install") { return profile.installKinds.includes(value); } - return profile[field] === value; + return profileFieldValues(profile, field).includes(value); }); } @@ -501,15 +577,15 @@ ? compatible.flatMap(function (profile) { return profile.installKinds; }) - : compatible.map(function (profile) { - return profile[field]; + : compatible.flatMap(function (profile) { + return profileFieldValues(profile, field); }); return Array.from(new Set(values.filter(Boolean))); } function uniqueValues(profiles, field) { - return Array.from(new Set((profiles || []).map(function (profile) { - return profile[field]; + return Array.from(new Set((profiles || []).flatMap(function (profile) { + return profileFieldValues(profile, field); }).filter(Boolean))); } @@ -566,7 +642,7 @@ function optionSort(field, a, b) { const roleOrder = ["companion", "repeater", "room", "sensor", "terminal", "kiss", "other"]; - const loggingOrder = ["none", "usb", "wifi"]; + const loggingOrder = ["none", "usb", "wifi", "both"]; const otaOrder = ["none", "lora-receiver", "lora-source"]; const featureOrder = ["standard", "full"]; const modeOrder = ["standard", "full", "ble", "usb", "wifi", "serial", "ethernet", "mqtt", "espnow", "rs232"]; @@ -627,6 +703,23 @@ "Full Companion can serve a host-supplied update to another node; it does not self-install that LoRa update." ); } + if (profile.logging === "runtime") { + extra.push( + "Use get logging.output and set logging.output off|usb|wifi|both to choose the saved output mode. Avoid both when two consumers publish the same packets to one broker." + ); + extra.push( + "With no saved SSID, the setup AP stays available for 30 minutes after each boot, then Wi-Fi powers off automatically until reboot or an explicit start webconfig command. A configured Wi-Fi mode keeps reconnecting instead." + ); + } else if (profile.logging === "usb-runtime") { + extra.push( + "Use get usb.logging and set usb.logging off|on to choose and save normal output-off or USB packet logging." + ); + if (profile.dedicatedUsbLogging) { + extra.push( + "This nRF52 Full Companion exposes two serial ports on one USB cable: interface 00 carries Companion/terminal/mOTA traffic and interface 02 carries plaintext logging. Match services by USB interface number instead of assuming tty or COM numbering." + ); + } + } return common.concat(byKind[kind] || [], extra); } @@ -649,7 +742,7 @@ const facts = createElement("dl"); facts.className = "firmware-picker-facts"; - replaceFacts(facts, [ + const factRows = [ ["Target", profile.target], ["Connection / mode", labelFor("mode", profile.mode)], ["Logging", labelFor("logging", profile.logging)], @@ -660,7 +753,14 @@ ["File", asset.name], ["Size", formatBytes(asset.size)], ["Release", asset.releaseName], - ]); + ]; + if (profile.dedicatedUsbLogging) { + factRows.splice(3, 0, [ + "USB port split", + "Interface 00 Companion; interface 02 logging", + ]); + } + replaceFacts(facts, factRows); card.appendChild(facts); const actions = createElement("div"); @@ -986,11 +1086,15 @@ flattenReleaseAssets: flattenReleaseAssets, parseFirmwareAsset: parseFirmwareAsset, parseTargetProfile: parseTargetProfile, + applyNrf52FullCompanionCapabilities: + applyNrf52FullCompanionCapabilities, canonicalHardware: canonicalHardware, + omitNrf52TransportsReplacedByFull: omitNrf52TransportsReplacedByFull, hardwareFamilyFor: hardwareFamilyFor, humanizeHardwareVariant: humanizeHardwareVariant, buildCatalog: buildCatalog, profileMatches: profileMatches, + profileFieldValues: profileFieldValues, profileMatchesFacets: profileMatchesFacets, facetValues: facetValues, uniqueValues: uniqueValues, diff --git a/docs/cli_build_matrix.md b/docs/cli_build_matrix.md index 52d7404f..bc2aa939 100644 --- a/docs/cli_build_matrix.md +++ b/docs/cli_build_matrix.md @@ -36,16 +36,45 @@ 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 logging | Logging does not remove commands by itself. It has the same CLI as the selected role/profile, adds the compiled logging behavior, and provides session-only `get/set usb.logging` control. | +| 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. | | 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 | Uses the matching MQTT target with logging off and keeps the complete command surface supported by that role and hardware. | -| FULL ESP32 logging | Uses the matching non-MQTT target with debug and packet logging enabled, session-only `get/set usb.logging` control, and the complete command surface supported by that role and hardware. | +| 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. | +| nRF52 Full Companion | Uses one physical USB connection with interface `00` for framed Companion/terminal/mOTA traffic and interface `02` for plaintext logging. It also provides BLE and source-only LoRa OTA. `get/set usb.logging` persistently gates only the logging interface. | | `no_external_sensors` | Removes optional external-sensor drivers and their settings; it does not remove core repeater discovery or routing commands. GPS-preserving RAK nRF52 OTA profiles retain their GPS commands and provider. The RAK4631 Serial1 RS232 bridge remains GPS-off because both features require Serial1. | `logging`, `OTA`, and `FULL` describe independent build features. Do not infer that a command is missing merely because `logging` appears in the filename. +## Canonical bulk-build policy + +Bulk and release-matrix commands omit legacy names whose behavior is already +available from a canonical image: + +- Companion `_ps` names are replaced by the ordinary Companion image plus the + persisted `powersaving on|off` setting. +- Companion `_femoff` names are replaced by the matching controllable-FEM + image plus `radio.fem.rxgain on|off`. The old names remain explicit build + targets for compatibility. +- `Station_G2_logging_*` and `Station_G3_ESP32_logging_*` hardware names are + replaced by their ordinary Station target. G2 boosted receive gain is the + persisted `radio.rxgain on|off` setting; the G3 alias changed only the + advertised default name. +- When an nRF52 board has a generated Full Companion, that one artifact + replaces its separate USB, BLE, and USB packet-logging Companion artifacts. + It provides BLE plus two USB CDC interfaces: interface `00` carries framed + Companion/terminal/mOTA traffic and interface `02` carries plaintext logs. + Its LoRa OTA support is source-only: it can serve a host file to another node + but has no staging store and cannot update itself over LoRa. + +The old aliases still work with `build-firmware` and +`build-matching-firmwares`. Dedicated repeater LoRa OTA receiver images are not +collapsed; they retain their exact storage, bootloader, role, and target +identity contracts. ESP32 USB, BLE, WiFi, and Full Companion images also remain +separate because Full changes 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 @@ -64,7 +93,7 @@ require MQTT or PSRAM. `discover.neighbors` sends the zero-hop node-discovery request used to refresh the repeater neighbor table. It is available in every repeater build profile, -including standard, logging, OTA, FULL, and FULL logging builds. +including standard, logging, OTA, unified FULL, and FULL logging-fallback builds. The exact command is: diff --git a/docs/cli_command_availability.md b/docs/cli_command_availability.md index 5727070a..069cf811 100644 --- a/docs/cli_command_availability.md +++ b/docs/cli_command_availability.md @@ -17,10 +17,12 @@ Build columns mean: remove commands by itself. - **LoRa OTA** - the explicit `-ota-` repeater or repeater-bridge artifact. Its optional external-sensor drivers are removed, but onboard GPS is retained. -- **FULL** - the expanded-partition ESP32 artifact with LoRa OTA and the - complete parser. MQTT observers and ESP-NOW bridges always use this profile. -- **FULL logging** - the expanded-partition ESP32 non-MQTT artifact with - logging, LoRa OTA, and the complete parser. +- **FULL unified** - the expanded-partition ESP32 artifact with LoRa OTA, the + complete parser, USB packet logging, and direct WiFi MQTT. Its saved output + mode can be off, USB, WiFi, or both. MQTT observers use this profile. +- **FULL fallback** - the expanded-partition ESP32 non-MQTT artifact used only + where there is no MQTT sibling. It has the complete parser and a saved + USB-logging on/off gate. ESP-NOW bridges use this profile. Cell values mean: @@ -69,7 +71,7 @@ fix, no WiFi connection, an inactive bridge, or an nRF52 bootloader without | Statistics | [`get telemetry.temp/volt`; optional GPS history; `get/set telemetry.tx`](cli_commands.md#read-repeater-telemetry-history) | Non-STM32 repeater; GPS commands require a GPS provider; remote access requires administrator | Yes | Yes | Yes | | Logging | [`log start`; `log stop`; `log erase`](cli_commands.md#logging) | Storage-backed roles retain data; other roles can return empty data | Yes | Yes | Yes | | Logging | [`log`](cli_commands.md#print-the-captured-log-to-the-serial-terminal) | Local serial | Serial | Serial | Serial | -| Logging | [`get/set usb.logging`](cli_commands.md#control-live-usb-logging) | Logging artifacts; session-only live USB output gate | No | Yes | No | +| Logging | [`get/set usb.logging`; unified FULL `get/set logging.output`](cli_commands.md#control-live-usb-logging) | Logging artifacts; CommonCLI USB gate is persistent; unified ESP32 FULL selects off/USB/WiFi/both; nRF52 Full Companion gates its second USB port | No | Yes | No | | Radio | [`get radio`; `set radio ...`](cli_commands.md#view-or-change-this-nodes-radio-parameters) | All text CLI roles | Yes | Yes | Yes | | Radio | [`get tx`; `set tx `](cli_commands.md#view-or-change-this-nodes-transmit-power) | Board TX-power limits apply | Yes | Yes | Yes | | Radio | [`tempradio ...`; `normalradio`](cli_commands.md#change-the-radio-parameters-for-a-set-duration) | Full parser | Yes | Yes | Yes | @@ -201,7 +203,7 @@ fix, no WiFi connection, an inactive bridge, or an nRF52 bootloader without ## ESP32 -| Area | Command | Scope | Standard | Logging | LoRa OTA | FULL | FULL logging | +| Area | Command | Scope | Standard | Logging | LoRa OTA | FULL unified | FULL fallback | |---|---|---|---|---|---|---|---| | Operational | [`reboot`](cli_commands.md#reboot-the-node) | All text CLI roles | Yes | Yes | Yes | Yes | Yes | | Operational | [`poweroff`; `shutdown`](cli_commands.md#power-off-the-node) | Board power-off support | Yes | Yes | Yes | Yes | Yes | @@ -231,7 +233,7 @@ fix, no WiFi connection, an inactive bridge, or an nRF52 bootloader without | Statistics | [`get telemetry.temp/volt`; optional GPS history; `get/set telemetry.tx`](cli_commands.md#read-repeater-telemetry-history) | Non-STM32 repeater; GPS commands require a GPS provider; remote access requires administrator | Yes | Yes | Yes | Yes | Yes | | Logging | [`log start`; `log stop`; `log erase`](cli_commands.md#logging) | Storage-backed roles retain data | Yes | Yes | Yes | Yes | Yes | | Logging | [`log`](cli_commands.md#print-the-captured-log-to-the-serial-terminal) | Local serial | Serial | Serial | Serial | Serial | Serial | -| Logging | [`get/set usb.logging`](cli_commands.md#control-live-usb-logging) | Logging artifacts; session-only live USB output gate | No | Yes | No | No | Yes | +| Logging | [`get/set usb.logging`; unified FULL `get/set logging.output`](cli_commands.md#control-live-usb-logging) | Logging artifacts; CommonCLI USB gate is persistent; unified ESP32 FULL selects off/USB/WiFi/both; nRF52 Full Companion gates its second USB port | No | Yes | No | No | Yes | | Radio | [`get radio`; `set radio ...`](cli_commands.md#view-or-change-this-nodes-radio-parameters) | All text CLI roles | Yes | Yes | Yes | Yes | Yes | | Radio | [`get tx`; `set tx `](cli_commands.md#view-or-change-this-nodes-transmit-power) | Board TX-power limits apply | Yes | Yes | Yes | Yes | Yes | | Radio | [`tempradio ...`; `normalradio`](cli_commands.md#change-the-radio-parameters-for-a-set-duration) | Full parser | Yes | Yes | Yes | Yes | Yes | diff --git a/docs/cli_commands.md b/docs/cli_commands.md index 267a8a14..11145b13 100644 --- a/docs/cli_commands.md +++ b/docs/cli_commands.md @@ -201,8 +201,13 @@ WiFi SSID, the command starts the open `MeshCore-Setup-XXXX` captive AP at MQTT bridge, so run `set bridge.enabled off` first. Use `stop webconfig` to close either mode for the current boot. (`stop webconfig` does not change a saved `webui on`.) -LAN mode otherwise remains active until reboot; setup mode stops after 10 minutes -with no connected client. +LAN mode otherwise remains active until reboot. On expanded FULL builds, an +unconfigured automatic setup AP receives one absolute 30-minute window per +boot, then powers WiFi off even if a client remains attached; rebooting starts +a new automatic window. An administrator can explicitly run `start webconfig` +again without rebooting. Once an SSID is saved, the cutoff no longer applies +and the selected WiFi/MQTT mode keeps reconnecting. Other setup sessions retain +their profile's idle timeout. The saved `wifi.cli` setting defaults to `on`. Use `set wifi.cli off` to disable the **CLI** tab. @@ -225,7 +230,8 @@ single-command mode. A command that stops WebConfig, changes its WiFi connection, disables `wifi.cli`, or reboots the node stops the remaining block and can close the page before its reply is collected. -On FULL MQTT and FULL logging ESP32 repeater/room-server builds, +On unified FULL USB + WiFi and FULL logging-fallback ESP32 +repeater/room-server builds, `get wifi.ssid` reports the saved standalone WebConfig network and `get wifi.status` reports whether WiFi is unconfigured, off, connecting, running the setup AP, failed, or connected. A connected result includes the @@ -301,7 +307,7 @@ remain available. - `discover.neighbors` This command is available in every repeater build profile, including portable -MQTT, standard, logging, OTA, FULL, and FULL logging artifacts. It does not +MQTT, standard, logging, OTA, unified FULL, and FULL logging-fallback artifacts. It does not require MQTT or PSRAM. --- @@ -520,9 +526,9 @@ summary. Ordinary `-logging-` artifacts keep packet logging separate from LoRa OTA. Use the separately named `-ota-` artifact when LoRa OTA is required. A -`-full-logging-ota-` artifact is intentionally the exception: it combines -logging with LoRa OTA and the expanded FULL feature set, while MQTT remains -disabled. +`-full-usb-wifi-ota-` artifact combines USB packet logging, direct WiFi MQTT, +LoRa OTA, and the expanded FULL feature set. A `-full-logging-ota-` artifact is +emitted only when that hardware/role has no matching WiFi MQTT environment. ### Control live USB logging @@ -535,11 +541,30 @@ set usb.logging off ``` These commands are compiled into logging artifacts and control their live USB -debug and packet output. The switch is session-only: every reboot starts a -logging artifact with USB logging on. Turning it off does not disable CLI -replies, Companion protocol frames, or essential non-debug serial messages. -It also does not change the node-storage capture controlled by `log start` and -`log stop`. +debug and packet output. CommonCLI roles save the setting in `/com_prefs`, so +it survives reboot; first boot defaults to on. nRF52 Full Companion applies the +gate only to USB interface `02`, its dedicated plaintext logging port. USB +interface `00` continues carrying Companion, terminal, and serial mOTA traffic. +Turning USB logging off does not disable CLI replies or Companion protocol +frames. It also does not change the node-storage capture controlled by `log +start` and `log stop`. + +Unified ESP32 FULL builds add one saved selector for both output paths: + +```text +get logging.output +set logging.output off +set logging.output usb +set logging.output wifi +set logging.output both +``` + +`usb` emits `RAW:` packets for a USB-connected service such as +meshcoretomqtt. `wifi` enables the direct MQTT bridge configured by the +`wifi.*` and `mqtt.*` commands. `both` intentionally duplicates the radio +stream to both consumers; do not point both consumers at the same broker unless +the downstream setup deduplicates messages. Fresh unified FULL installs start +in `both` mode. ### Begin capture of rx log to node storage **Usage:** `log start` diff --git a/docs/companion_radio_full.md b/docs/companion_radio_full.md index b21aa519..e2b037b7 100644 --- a/docs/companion_radio_full.md +++ b/docs/companion_radio_full.md @@ -47,11 +47,15 @@ bash build.sh build-full-companion-firmwares \ --firmware-version v1.17.0 ``` -Heltec `_femon` Companion firmware can switch the external FEM receive gain at -runtime, so the Companion bulk-build commands omit the redundant legacy -`_femoff` targets. Those targets remain available through an explicit -`build-firmware` command for compatibility. In WebConfig, use the **FEM RX -boost** switch. From the text terminal (USB, or TCP 5002 on ESP32), use: +Canonical Companion bulk builds also omit legacy `_ps` and `_femoff` aliases. +Power saving and controllable FEM receive gain are persisted runtime settings; +the old names remain available through an explicit `build-firmware` command +for compatibility. On nRF52, Full Companion replaces separate USB and BLE +normal release artifacts whenever the exact board has both recipes. It also +replaces the USB-only packet-logging artifact: one physical USB connection +enumerates separate Companion and logging serial ports, so plaintext logs +cannot corrupt binary frames. In WebConfig, use the +**FEM RX boost** switch. From the text terminal (USB, or TCP 5002 on ESP32), use: ```text get radio.rxgain @@ -242,9 +246,32 @@ The terminal supports Companion chat commands, including `channels`, `trace [recipient-name-or-prefix]`, plus local `ota`, `tempradio`, and `normalradio` controls. ESP32 builds also provide local WiFi credential, status, WebConfig, CLI-tab, and power-save controls. Logging -artifacts additionally provide session-only -`get/set usb.logging`; turning it off suppresses live USB diagnostics without -disabling Companion frames or terminal replies. +artifacts additionally provide `get/set usb.logging`; turning it off +suppresses live USB diagnostics without disabling Companion frames or terminal +replies. nRF52 Full Companion saves this setting and applies it only to its +dedicated logging port. + +### nRF52 dual USB serial ports + +Current nRF52 Full Companion firmware exposes two CDC ACM serial interfaces on +one physical USB cable: + +- USB interface `00` is the normal Binary Companion, text terminal, and serial + mOTA source port. +- USB interface `02` is a write-only plaintext packet/debug logging port. Host + input on this interface is ignored and cannot invoke firmware commands. + +On Linux these normally appear as two `/dev/ttyACM*` devices. Match the stable +`/dev/serial/by-id/*-if00` and `*-if02` links, or use a udev rule matching +`ID_USB_INTERFACE_NUM`, rather than assuming which tty number is assigned. On +Windows they appear as two COM ports; identify them by USB interface instead of +depending on a particular COM number. The bootloader may temporarily expose +only its normal DFU serial interface while an update is active. + +Point MeshCore Companion software, `meshcli`, and `motatool` at interface `00`. +Point a plaintext reader or USB-connected MQTT service at interface `02`. Use +`set usb.logging off|on` through the Companion terminal to persist whether the +second port emits output. ESP32 Full Companion exposes this same text terminal on TCP port 5002. Connect with `nc DEVICE_IP 5002`; no USB control token is needed. USB terminal mode and diff --git a/docs/firmware_picker.md b/docs/firmware_picker.md index 3001ead5..056cb8ea 100644 --- a/docs/firmware_picker.md +++ b/docs/firmware_picker.md @@ -134,6 +134,7 @@ from the published firmware assets. | Terminal Chat | Standalone serial-terminal interface | | USB logging / USB-connected MQTT | Node remains attached to a computer over a data-capable USB cable | | 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 source only | Full Companion serving a host-supplied update to another node without self-installing it | @@ -155,6 +156,25 @@ Changing between standard and FULL ESP32 layouts requires the exact-board merged image over USB. A running application cannot safely move its own active and inactive partitions. +Current `full-usb-wifi` profiles use one binary for no external output, USB +packet logging/USB-connected MQTT, direct WiFi MQTT, or both. The picker shows +that same exact binary for each compatible logging choice; select the saved +runtime mode with `set logging.output off|usb|wifi|both`. A FULL logging-fallback +profile is listed only when no WiFi MQTT sibling exists; it appears for both +the no-output and USB choices because `set usb.logging off|on` is persistent. +On a fresh unified FULL install with no saved SSID, the setup AP and WiFi radio +remain available for 30 minutes per boot, then turn off automatically until the +next reboot or power cycle. An explicit administrator `start webconfig` remains +available as an override. A saved SSID switches to the normal indefinite +reconnect behavior instead. + +Current nRF52 Full Companion profiles also use one binary for normal attached +Companion use and USB packet logging. One USB cable exposes interface `00` for +Binary Companion, terminal, and mOTA source traffic, plus interface `02` for +plaintext logging. The picker therefore omits the older separate nRF52 USB, +BLE, and USB-logging Companion choices when the matching Full artifact exists. +Use `set usb.logging off|on` to persist the second port's output state. + ## Installation methods | File | Use | @@ -184,6 +204,14 @@ and select the hardware-matched HEX, Serial DFU ZIP, or bootloader-update UF2. Hardware families with multiple released targets get a second hardware-variant menu. It separates revisions, display type, expansion kit, radio/PA layout, pin map, and other physical differences without crowding the first menu. The -firmware-variant menu separately exposes build choices such as FEM on/off, -power saving, serial port, or no-external-sensors. Do not substitute a -similarly named target. +firmware-variant menu separately exposes choices that still require different +code or wiring, such as serial port or no-external-sensors. Companion power +saving, controllable FEM receive gain, and radio-chip receive gain are saved +settings rather than separate recommended firmware files. Do not substitute a +similarly named physical target. + +For nRF52 hardware with a Full Companion image, the picker recommends that one +normal image instead of separate USB, BLE, and USB-logging images. Full +Companion provides both attached transports and a dedicated plaintext logging +port without mixing logs into framed Companion traffic. Exact filename search +still finds old aliases from earlier releases. diff --git a/docs/lora_ota_automation.md b/docs/lora_ota_automation.md index 70b84b31..3a6da82b 100644 --- a/docs/lora_ota_automation.md +++ b/docs/lora_ota_automation.md @@ -56,8 +56,10 @@ The USB ASCII switch (`+++MESHCORE-TERM-START`) is the local control path, not the mOTA data framing. On an nRF52 full Companion, the script uses that mode briefly for `ota status` and TempRadio commands. It then closes the CLI and starts `motatool`, whose existing `ota folder on` preamble switches the same -USB port into exclusive mOTA mode. On an ESP32 Full Companion with serial -folder support, `motatool --companion-terminal` keeps the ASCII session open +USB interface `00` into exclusive mOTA mode. Its separate USB interface `02` +continues to provide plaintext logging and is not a controller or source port. +On an ESP32 Full Companion with serial folder support, +`motatool --companion-terminal` keeps the ASCII session open while the same link carries framed folder requests. BLE remains available. ## Destination requirements diff --git a/docs/ota_easy.md b/docs/ota_easy.md index 75bf2ea1..b69a8668 100644 --- a/docs/ota_easy.md +++ b/docs/ota_easy.md @@ -88,14 +88,15 @@ speed optimization. This prevents the retained software Ed25519 fallback from ex in-place workspace; CC310 hardware crypto, hardware RNG mixing, telemetry history, and board-native features remain enabled. -ESP32 `*-full-ota-*` artifacts retain all compiled features and enable LoRa OTA for every FULL role, +ESP32 `*-full-usb-wifi-ota-*` artifacts retain all compiled features and enable LoRa OTA for every FULL role, including room servers, sensors, observers, and bridges. A FULL image requires its expanded partition table: install the matching merged image over USB once before installing later non-merged FULL updates over LoRa. ESP32 MQTT observers and ESP-NOW bridges are always emitted as FULL artifacts; compact-CLI variants are no longer built. -The `*-full-ota-*` profile uses MQTT with logging off. Use a `*-full-logging-ota-*` artifact when USB debug -and packet logging are needed instead; that diagnostic profile explicitly disables MQTT and can produce -substantial serial output. +The `*-full-usb-wifi-ota-*` profile compiles USB packet logging and direct WiFi MQTT into one image. Use +`set logging.output off|usb|wifi|both` to persist the desired path. A `*-full-logging-ota-*` artifact is now +only a fallback for a hardware/role combination without a WiFi MQTT target and can produce substantial +serial output. ### Choose the source radio @@ -107,8 +108,9 @@ serves host images but cannot stage or install one for itself. ESP32 full combines USB, BLE, and WiFi; nRF52 full combines USB and BLE because nRF52840 has no WiFi. A small set of high-capacity, non-PSRAM classic ESP32 companions -keep their normal image and provide a separate `-full-ota-` image with 100 contacts, 8 group channels, and -a 16-frame offline queue. Install that variant's merged image over USB once before using it. Connect the +keep their normal image and provide a separate `-full-logging-ota-` fallback with 100 contacts, 8 group +channels, a 16-frame offline queue, and persistent USB output selection. Install that variant's merged +image over USB once before using it. Connect the source by USB serial or, when supported, by WiFi. For an ordinary raw-text USB source, confirm that its USB CLI accepts: @@ -116,8 +118,8 @@ source, confirm that its USB CLI accepts: ota folder on ``` -If an older build reports that `OTA_FOLDER_SERIAL` is not compiled in, install a current `-ota-` or -`-full-ota-` build first. Do **not** use a KISS modem: KISS firmware is a TNC/KISS frame interface +If an older build reports that `OTA_FOLDER_SERIAL` is not compiled in, install a current `-ota-`, +`-full-usb-wifi-ota-`, or applicable `-full-logging-ota-` build first. Do **not** use a KISS modem: KISS firmware is a TNC/KISS frame interface and does not provide the MeshCore CLI or the OTA-folder transport that `motatool serve` requires. An nRF52 `companion_radio_full` starts in USB Binary mode. Use diff --git a/docs/ota_protocol.md b/docs/ota_protocol.md index 26aebcca..859916e2 100644 --- a/docs/ota_protocol.md +++ b/docs/ota_protocol.md @@ -116,8 +116,9 @@ ESP32 companion firmware is exempt from the portable-slot limit. USB and WiFi co LoRa OTA and carry `-ota-` in their filenames so they can seed a host folder over serial or TCP; they keep their target partition table rather than using the FULL profile. A small set of high-capacity, non-PSRAM classic ESP32 companions cannot combine their configured contact, group-channel, and offline-queue capacities with LoRa -OTA in internal DRAM. Their normal artifacts remain unchanged, and option 3 also emits `-full-ota-` and -`-full-logging-ota-` variants with 100 contacts, 8 group channels, and a 16-frame offline queue. MQTT +OTA in internal DRAM. Their normal artifacts remain unchanged, and option 3 emits a +`-full-logging-ota-` fallback with 100 contacts, 8 group channels, a 16-frame offline queue, and a saved +USB-logging on/off gate. MQTT observers and ESP-NOW bridges always use FULL builds because fitting them into the legacy slot would require removing CLI and role features. Except for those FULL roles and the ESP32-C6 case below, non-companion ESP32 artifacts, including room, sensor, and repeater roles, must fit the legacy slot from `0x10000` up to @@ -157,21 +158,27 @@ during a settings save restores the last committed common preference image or pu image; it does not leave a partially written `/com_prefs` file to fail on the next boot. A truncated legacy image is rejected before any partial radio or string fields are applied, then rewritten from safe defaults. -Option 3 in `build.sh` emits `*-full-ota-*` and `*-full-logging-ota-*` ESP32 artifacts for -FULL-capable non-companion roles and for the constrained companion fallbacks described above. MQTT observers -and ESP-NOW bridges are emitted only with expanded FULL partitions. Menu option 8, or -`build-full-esp32-firmwares`, builds the logging-off FULL -artifacts from matching MQTT targets. Menu option 9, or `build-full-esp32-logging-firmwares`, builds the -FULL logging artifacts from matching non-MQTT targets. +Option 3 in `build.sh` emits one `*-full-usb-wifi-ota-*` ESP32 artifact for each +FULL-capable non-companion hardware/role that has a matching MQTT environment. +It compiles USB packet logging and direct WiFi MQTT together. A +`*-full-logging-ota-*` fallback is emitted only when there is no MQTT sibling; +the old separate standard-logging and non-MQTT FULL twins are skipped for +covered ESP32 roles. MQTT observers and ESP-NOW bridges are emitted only with +expanded FULL partitions. Menu option 8, or `build-full-esp32-firmwares`, +builds the unified profiles plus necessary fallbacks. Menu option 9, or +`build-full-esp32-logging-firmwares`, builds only those fallbacks. FULL builds restore WebConfig, display support, optional external sensors, and the full role CLI and feature set, full ElegantOTA where that target declares the required library, and LoRa OTA for every included role, including room servers, sensors, observers, and bridges. They use expanded A/B partition tables: 1984 KiB application slots on 4 MiB boards and the framework's larger dual-OTA tables on 8 MiB and 16 MiB boards. Explicit `*_lora_ota_no_external_sensors` targets are not duplicated; their ordinary -repeater build is the FULL, sensor-enabled counterpart. The `*-full-logging-ota-*` profile enables USB -debug and packet logging and explicitly disables MQTT. Install a matching -`*-full-ota-*-merged.bin` or `*-full-logging-ota-*-merged.bin` over USB once to write the expanded partition +repeater build is the FULL, sensor-enabled counterpart. The +`*-full-usb-wifi-ota-*` profile enables USB packet logging and MQTT, with a +persistent `logging.output` selector; its verbose internal debug remains off. +The fallback `*-full-logging-ota-*` profile enables USB debug and packet +logging and has no MQTT target. Install a matching +`*-full-usb-wifi-ota-*-merged.bin` or `*-full-logging-ota-*-merged.bin` over USB once to write the expanded partition table. After that, its matching non-merged FULL application image can be installed through USB, WiFi OTA, or LoRa OTA. Do not install a non-merged FULL image onto a node that still has its old partition table. diff --git a/examples/companion_radio/DataStore.cpp b/examples/companion_radio/DataStore.cpp index ba05fecf..8fe621da 100644 --- a/examples/companion_radio/DataStore.cpp +++ b/examples/companion_radio/DataStore.cpp @@ -349,6 +349,10 @@ void DataStore::loadPrefsInt(const char *filename, CompanionNodePrefs& _prefs, d if (file.available() >= (int)sizeof(_prefs.powersaving_policy_version)) { file.read((uint8_t *)&_prefs.powersaving_policy_version, sizeof(_prefs.powersaving_policy_version)); // 139 + if (file.available() >= (int)sizeof(_prefs.usb_logging_enabled)) { + file.read((uint8_t *)&_prefs.usb_logging_enabled, + sizeof(_prefs.usb_logging_enabled)); // 140 + } } } } @@ -421,6 +425,9 @@ bool DataStore::savePrefs(const CompanionNodePrefs& _prefs, double node_lat, dou success = success && file.write((uint8_t *)&_prefs.powersaving_policy_version, sizeof(_prefs.powersaving_policy_version)) == sizeof(_prefs.powersaving_policy_version); // 139 + success = success && file.write((uint8_t *)&_prefs.usb_logging_enabled, + sizeof(_prefs.usb_logging_enabled)) + == sizeof(_prefs.usb_logging_enabled); // 140 #if defined(NRF52_PLATFORM) success = file.commit(success); diff --git a/examples/companion_radio/MyMesh.cpp b/examples/companion_radio/MyMesh.cpp index 15107afb..cf75c8a8 100644 --- a/examples/companion_radio/MyMesh.cpp +++ b/examples/companion_radio/MyMesh.cpp @@ -1437,6 +1437,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; + _prefs.usb_logging_enabled = 1; recalcRxPowerSavingFromLevel(_prefs.rx_ps_level, _prefs.sf, _prefs.bw, _prefs.rx_ps_preamble, &_prefs.rx_ps_rx_us, &_prefs.rx_ps_sleep_us); @@ -1531,6 +1532,7 @@ void MyMesh::begin(bool has_display, bool radio_available) { _prefs.rx_powersaving_enabled = constrain(_prefs.rx_powersaving_enabled, 0, 1); _prefs.powersaving_enabled = constrain(_prefs.powersaving_enabled, 0, 1); _prefs.wifi_enabled = constrain(_prefs.wifi_enabled, 0, 1); + _prefs.usb_logging_enabled = constrain(_prefs.usb_logging_enabled, 0, 1); _prefs.rx_ps_level = constrain(_prefs.rx_ps_level, 0, 10); if (_prefs.rx_ps_preamble != 16 && _prefs.rx_ps_preamble != 32) { _prefs.rx_ps_preamble = 0; @@ -1542,6 +1544,9 @@ void MyMesh::begin(bool has_display, bool radio_available) { if (power_saving_default_migrated) { _store->savePrefs(_prefs, sensors.node_lat, sensors.node_lon); } +#if MESH_USB_LOGGING_AVAILABLE + mesh::setUsbLoggingEnabled(_prefs.usb_logging_enabled != 0); +#endif #ifdef BLE_PIN_CODE // 123456 by default if (_prefs.ble_pin == 0) { @@ -2085,9 +2090,10 @@ void MyMesh::serviceMQTT(const char* wifi_ssid, const char* wifi_password) { _mqtt_started = true; // begin is one-shot; avoid retrying partial allocations _mqtt_bridge->begin(); if (_mqtt_bridge->isRunning()) { - Serial.println("MQTT companion: bridge started"); + mesh::usbLoggingPort().println("MQTT companion: bridge started"); } else { - Serial.println("MQTT companion: bridge could not start"); + mesh::usbLoggingPort().println( + "MQTT companion: bridge could not start"); } } } @@ -4976,9 +4982,15 @@ void MyMesh::handleTerminalCommand(char* command) { terminalOutput().print(" ERROR: use set usb.logging \r\n"); } else { const bool enabled = strcmp(value, "on") == 0; + _prefs.usb_logging_enabled = enabled ? 1 : 0; mesh::setUsbLoggingEnabled(enabled); - terminalOutput().printf(" OK - USB logging %s until reboot\r\n", - enabled ? "on" : "off"); + if (savePrefs()) { + terminalOutput().printf(" OK - USB logging %s (saved)\r\n", + enabled ? "on" : "off"); + } else { + terminalOutput().print( + " ERROR: USB logging changed for this boot but save failed\r\n"); + } } } else #endif diff --git a/examples/companion_radio/MyMesh.h b/examples/companion_radio/MyMesh.h index 7df924db..e43ed3f9 100644 --- a/examples/companion_radio/MyMesh.h +++ b/examples/companion_radio/MyMesh.h @@ -263,7 +263,9 @@ protected: } public: - void savePrefs() { _store->savePrefs(_prefs, sensors.node_lat, sensors.node_lon); } + bool savePrefs() { + return _store->savePrefs(_prefs, sensors.node_lat, sensors.node_lon); + } #if ENV_INCLUDE_GPS == 1 void applyGpsPrefs() { diff --git a/examples/companion_radio/NodePrefs.h b/examples/companion_radio/NodePrefs.h index 300433ef..dd21a64b 100644 --- a/examples/companion_radio/NodePrefs.h +++ b/examples/companion_radio/NodePrefs.h @@ -53,6 +53,7 @@ struct CompanionNodePrefs { // persisted to file uint8_t powersaving_enabled; // device CPU/GPS idle power saving uint8_t wifi_enabled; // Companion WiFi radio and services uint8_t powersaving_policy_version; // one-time default migration marker + uint8_t usb_logging_enabled; // live USB packet/debug output // Keep the upstream repeat API while retaining the existing binary prefs // layout used by this branch. diff --git a/examples/companion_radio/main.cpp b/examples/companion_radio/main.cpp index b2998bfe..ac9c8da4 100644 --- a/examples/companion_radio/main.cpp +++ b/examples/companion_radio/main.cpp @@ -153,16 +153,17 @@ static void serviceCompanionRadioRecovery() { || (long)(millis() - companion_radio_retry_at) < 0) return; companion_radio_retry_at = millis() + COMPANION_RADIO_RETRY_MS; - Serial.println("Radio recovery probe starting"); + mesh::usbLoggingPort().println("Radio recovery probe starting"); board.powerCycleRadio(); if (!radio_init()) { - Serial.println("Radio recovery probe failed; companion services remain available"); + mesh::usbLoggingPort().println( + "Radio recovery probe failed; companion services remain available"); return; } companion_radio_available = true; the_mesh.activateRadio(); - Serial.println("Radio recovered; mesh transport is active"); + mesh::usbLoggingPort().println("Radio recovered; mesh transport is active"); } #endif @@ -207,8 +208,8 @@ static bool applyCompanionPowerSaving(bool enabled) { esp_err_t pm_result = esp_pm_configure(&pm_config); if (pm_result != ESP_OK) { - Serial.printf("Device power saving failed: %s\r\n", - esp_err_to_name(pm_result)); + mesh::usbLoggingPort().printf("Device power saving failed: %s\r\n", + esp_err_to_name(pm_result)); return false; } #else @@ -216,8 +217,9 @@ static bool applyCompanionPowerSaving(bool enabled) { // which case esp_pm_configure() is a stub returning ESP_ERR_NOT_SUPPORTED. // Keep frequency throttling functional instead of retrying that stub. if (!setCpuFrequencyMhz(max_mhz)) { - Serial.printf("Device power saving failed: CPU %lu MHz is unsupported\r\n", - (unsigned long)max_mhz); + mesh::usbLoggingPort().printf( + "Device power saving failed: CPU %lu MHz is unsupported\r\n", + (unsigned long)max_mhz); return false; } #endif @@ -226,20 +228,21 @@ static bool applyCompanionPowerSaving(bool enabled) { esp_err_t bt_result = enabled ? esp_bt_sleep_enable() : esp_bt_sleep_disable(); if (bt_result != ESP_OK) { - Serial.printf("Bluetooth sleep %s failed: %s\r\n", - enabled ? "enable" : "disable", - esp_err_to_name(bt_result)); + mesh::usbLoggingPort().printf("Bluetooth sleep %s failed: %s\r\n", + enabled ? "enable" : "disable", + esp_err_to_name(bt_result)); } #endif #if COMPANION_IDF_PM_AVAILABLE - Serial.printf("Device power saving %s: CPU %lu-%lu MHz, automatic light sleep %s\r\n", - enabled ? "on" : "off", (unsigned long)min_mhz, - (unsigned long)max_mhz, - automatic_light_sleep ? "on" : "off"); + mesh::usbLoggingPort().printf( + "Device power saving %s: CPU %lu-%lu MHz, automatic light sleep %s\r\n", + enabled ? "on" : "off", (unsigned long)min_mhz, + (unsigned long)max_mhz, automatic_light_sleep ? "on" : "off"); #else - Serial.printf("Device power saving %s: CPU %lu MHz\r\n", - enabled ? "on" : "off", (unsigned long)max_mhz); + mesh::usbLoggingPort().printf("Device power saving %s: CPU %lu MHz\r\n", + enabled ? "on" : "off", + (unsigned long)max_mhz); #endif return true; } @@ -949,12 +952,13 @@ void halt() { static void startCompanionBluetooth() { if (companion_bluetooth_initialized) return; - Serial.println("Companion: starting Bluetooth"); + mesh::usbLoggingPort().println("Companion: starting Bluetooth"); bluetooth_interface.begin(BLE_NAME_PREFIX, the_mesh.getNodePrefs()->node_name, the_mesh.getBLEPin()); if (!interface_manager.addInterface(InterfaceType::Bluetooth, &bluetooth_interface)) { - Serial.println("Companion: no interface slot available for Bluetooth"); + mesh::usbLoggingPort().println( + "Companion: no interface slot available for Bluetooth"); return; } companion_bluetooth_initialized = true; @@ -973,7 +977,7 @@ void halt() { #if defined(ESP32_PLATFORM) && defined(COMPANION_RADIO_FULL) static void logFullCompanionMemory(const char* stage) { - Serial.printf( + mesh::usbLoggingPort().printf( "Full Companion memory %s: heap=%u largest_internal=%u psram_free=%u/%u offline_queue=%d\r\n", stage, (unsigned)ESP.getFreeHeap(), (unsigned)heap_caps_get_largest_free_block(MALLOC_CAP_INTERNAL), @@ -984,6 +988,7 @@ void halt() { void setup() { Serial.begin(115200); + mesh::beginUsbLoggingPort(); board.begin(); #ifdef HAS_EXTERNAL_WATCHDOG @@ -1015,7 +1020,8 @@ void setup() { #if defined(TBEAM_1W) // Continue into a recovery-capable Companion instead of trapping native // USB in a reboot loop. The main loop retries the radio independently. - Serial.println("Radio unavailable; starting display, USB, BLE, and WiFi recovery services"); + mesh::usbLoggingPort().println( + "Radio unavailable; starting display, USB, BLE, and WiFi recovery services"); break; #else MESH_DEBUG_PRINTLN("Radio init failed 3x - rebooting"); @@ -1148,7 +1154,8 @@ void setup() { #if defined(ESP32) && defined(COMPANION_RADIO_FULL) && defined(WIFI_SSID) companion_bluetooth_start_at = millis() + 2000UL; if (companion_bluetooth_start_at == 0) companion_bluetooth_start_at = 1; - Serial.println("Companion: Bluetooth starts in 2 seconds"); + mesh::usbLoggingPort().println( + "Companion: Bluetooth starts in 2 seconds"); #else startCompanionBluetooth(); #endif diff --git a/examples/simple_repeater/MyMesh.cpp b/examples/simple_repeater/MyMesh.cpp index 9d837a3e..4f5b0480 100644 --- a/examples/simple_repeater/MyMesh.cpp +++ b/examples/simple_repeater/MyMesh.cpp @@ -1024,10 +1024,11 @@ void MyMesh::logRxRaw(float snr, float rssi, const uint8_t raw[], int len) { #if MESH_PACKET_LOGGING if (mesh::isUsbLoggingEnabled()) { // Logging builds prefer backpressure over silently losing a packet record. - Serial.print(getLogDateTime()); - Serial.print(" RAW: "); - mesh::Utils::printHex(Serial, raw, len); - Serial.println(); + Stream& logging_port = mesh::usbLoggingPort(); + logging_port.print(getLogDateTime()); + logging_port.print(" RAW: "); + mesh::Utils::printHex(logging_port, raw, len); + logging_port.println(); } #endif diff --git a/examples/simple_room_server/MyMesh.cpp b/examples/simple_room_server/MyMesh.cpp index 16e8d4da..d1447d2b 100644 --- a/examples/simple_room_server/MyMesh.cpp +++ b/examples/simple_room_server/MyMesh.cpp @@ -266,10 +266,11 @@ void MyMesh::logRxRaw(float snr, float rssi, const uint8_t raw[], int len) { #if MESH_PACKET_LOGGING if (mesh::isUsbLoggingEnabled()) { // Logging builds prefer backpressure over silently losing a packet record. - Serial.print(getLogDateTime()); - Serial.print(" RAW: "); - mesh::Utils::printHex(Serial, raw, len); - Serial.println(); + Stream& logging_port = mesh::usbLoggingPort(); + logging_port.print(getLogDateTime()); + logging_port.print(" RAW: "); + mesh::Utils::printHex(logging_port, raw, len); + logging_port.println(); } #endif diff --git a/examples/simple_sensor/SensorMesh.cpp b/examples/simple_sensor/SensorMesh.cpp index 726fd86a..229d6494 100644 --- a/examples/simple_sensor/SensorMesh.cpp +++ b/examples/simple_sensor/SensorMesh.cpp @@ -819,8 +819,9 @@ bool SensorMesh::handleIncomingMsg(ClientInfo& from, uint32_t timestamp, uint8_t MESH_DEBUG_PRINT("handleIncomingMsg: unhandled msg from "); #ifdef MESH_DEBUG if (mesh::isUsbLoggingEnabled()) { - mesh::Utils::printHex(Serial, from.id.pub_key, PUB_KEY_SIZE); - Serial.printf(": %s\n", data); + Stream& logging_port = mesh::usbLoggingPort(); + mesh::Utils::printHex(logging_port, from.id.pub_key, PUB_KEY_SIZE); + logging_port.printf(": %s\n", data); } #endif return false; diff --git a/mesh-america/README.md b/mesh-america/README.md index c814203a..d16ce8bd 100644 --- a/mesh-america/README.md +++ b/mesh-america/README.md @@ -26,6 +26,7 @@ python3 mesh-america/update-provider-release.py \ --artifact-version v1.17.1.2-c9652754 \ --main-tag v1.17.1.2-halo-keymind-cascade-c9652754 \ --advanced-tag lora-ota-v1.17.1.2-halo-keymind-cascade-c9652754 \ + --full-tag full-profiles-v1.17.1.2-halo-keymind-cascade-c9652754 \ --utility-tag kiss-v1.17.1.1-halo-keymind-cascade-759a35fc \ --companion-only ``` @@ -33,13 +34,14 @@ python3 mesh-america/update-provider-release.py \ The updater preserves the catalog's curated device names, role choices, and hardware guidance; validates every referenced artifact; routes URLs to the correct GitHub release page; and replaces legacy portable MQTT choices with the -matching expanded-partition FULL MQTT observer builds. +matching expanded-partition unified FULL USB + Wi-Fi observer builds. The logging catalog likewise keeps unaffected roles on v1.17.1.1 while its Companion, Full Companion, and expanded Companion logging profiles track the -v1.17.1.2 correction. Logging builds are for a USB-connected MQTT/logging host; -they are not the direct on-device Wi-Fi MQTT bridge. Update only the Companion -choices from the same completed matrix with: +v1.17.1.2 correction. Ordinary logging builds are for a USB-connected +MQTT/logging host. Unified FULL ESP32 builds can instead select USB, the direct +on-device Wi-Fi MQTT bridge, both, or neither at runtime. Update only the +Companion choices from the same completed matrix with: ```text python3 mesh-america/update-logging-provider-release.py \ @@ -58,7 +60,8 @@ In Companion-only mode, each updater preserves every unaffected catalog entry, validates every selected replacement artifact, and adds the one-time power-saving migration guidance. Without `--companion-only`, the existing all-role behavior remains available for a future complete matrix release; the -logging updater then validates all 648 referenced firmware identities. +logging updater then validates all curated entries while allowing consolidated +FULL USB + Wi-Fi artifacts to replace their former duplicate identities. The older PowerShell generator remains available for the historical standard and logging source folders: diff --git a/mesh-america/generate-mesh-america-catalogs.ps1 b/mesh-america/generate-mesh-america-catalogs.ps1 index 13ad143b..a208de05 100644 --- a/mesh-america/generate-mesh-america-catalogs.ps1 +++ b/mesh-america/generate-mesh-america-catalogs.ps1 @@ -54,24 +54,24 @@ $RolePatterns = @( @{ Suffix = 'logging_repeater_bridge_espnow'; Role = 'repeater'; Title = 'Repeater Bridge ESP-NOW'; SubTitle = 'Logging' }, @{ Suffix = 'logging_repeater'; Role = 'repeater'; Title = 'Repeater'; SubTitle = 'Logging' }, @{ Suffix = 'companion_radio_ble_femoff'; Role = 'companionBle'; Title = 'Companion BLE'; SubTitle = 'FEM off' }, - @{ Suffix = 'companion_radio_ble_femon'; Role = 'companionBle'; Title = 'Companion BLE'; SubTitle = 'FEM on' }, + @{ Suffix = 'companion_radio_ble_femon'; Role = 'companionBle'; Title = 'Companion BLE'; SubTitle = 'FEM runtime-configurable' }, @{ Suffix = 'companion_radio_ble_ps_femoff'; Role = 'companionBle'; Title = 'Companion BLE'; SubTitle = 'Power saving, FEM off' }, - @{ Suffix = 'companion_radio_ble_ps_femon'; Role = 'companionBle'; Title = 'Companion BLE'; SubTitle = 'Power saving, FEM on' }, + @{ Suffix = 'companion_radio_ble_ps_femon'; Role = 'companionBle'; Title = 'Companion BLE'; SubTitle = 'Legacy alias; settings runtime-configurable' }, @{ Suffix = 'companion_radio_ble_ps'; Role = 'companionBle'; Title = 'Companion BLE'; SubTitle = 'Power saving' }, @{ Suffix = 'companion_radio_ble_'; Role = 'companionBle'; Title = 'Companion BLE'; SubTitle = $null }, @{ Suffix = 'companion_radio_ble'; Role = 'companionBle'; Title = 'Companion BLE'; SubTitle = $null }, @{ Suffix = 'companion_ble'; Role = 'companionBle'; Title = 'Companion BLE'; SubTitle = $null }, @{ Suffix = 'companion_radio_usb_femoff'; Role = 'companionUsb'; Title = 'Companion USB'; SubTitle = 'FEM off' }, - @{ Suffix = 'companion_radio_usb_femon'; Role = 'companionUsb'; Title = 'Companion USB'; SubTitle = 'FEM on' }, + @{ Suffix = 'companion_radio_usb_femon'; Role = 'companionUsb'; Title = 'Companion USB'; SubTitle = 'FEM runtime-configurable' }, @{ Suffix = 'companion_radio_usb_'; Role = 'companionUsb'; Title = 'Companion USB'; SubTitle = $null }, @{ Suffix = 'companion_radio_usb'; Role = 'companionUsb'; Title = 'Companion USB'; SubTitle = $null }, @{ Suffix = 'companion_usb'; Role = 'companionUsb'; Title = 'Companion USB'; SubTitle = $null }, @{ Suffix = 'comp_radio_usb'; Role = 'companionUsb'; Title = 'Companion USB'; SubTitle = $null }, @{ Suffix = 'companion_radio_serial'; Role = 'companionUsb'; Title = 'Companion USB'; SubTitle = 'Serial' }, @{ Suffix = 'companion_radio_wifi_mqtt_femoff'; Role = 'companionWifi'; Title = 'Companion WiFi MQTT'; SubTitle = 'FEM off' }, - @{ Suffix = 'companion_radio_wifi_mqtt_femon'; Role = 'companionWifi'; Title = 'Companion WiFi MQTT'; SubTitle = 'FEM on' }, + @{ Suffix = 'companion_radio_wifi_mqtt_femon'; Role = 'companionWifi'; Title = 'Companion WiFi MQTT'; SubTitle = 'FEM runtime-configurable' }, @{ Suffix = 'companion_radio_wifi_femoff'; Role = 'companionWifi'; Title = 'Companion WiFi'; SubTitle = 'FEM off' }, - @{ Suffix = 'companion_radio_wifi_femon'; Role = 'companionWifi'; Title = 'Companion WiFi'; SubTitle = 'FEM on' }, + @{ Suffix = 'companion_radio_wifi_femon'; Role = 'companionWifi'; Title = 'Companion WiFi'; SubTitle = 'FEM runtime-configurable' }, @{ Suffix = 'companion_radio_wifi_'; Role = 'companionWifi'; Title = 'Companion WiFi'; SubTitle = 'Wi-Fi companion interface' }, @{ Suffix = 'companion_radio_wifi'; Role = 'companionWifi'; Title = 'Companion WiFi'; SubTitle = 'Wi-Fi companion interface' }, @{ Suffix = 'repeater_bridge_rs232_serial1'; Role = 'repeater'; Title = 'Repeater Bridge RS232'; SubTitle = 'Serial 1' }, diff --git a/mesh-america/update-logging-provider-release.py b/mesh-america/update-logging-provider-release.py index dad1944c..a4dbf2c2 100755 --- a/mesh-america/update-logging-provider-release.py +++ b/mesh-america/update-logging-provider-release.py @@ -79,7 +79,7 @@ def is_constrained_logging(identity: str) -> bool: def release_tag(identity: str, args: argparse.Namespace) -> str: lowered = identity.lower() - if "full-logging-ota" in lowered: + if "full-logging-ota" in lowered or "full-usb-wifi-ota" in lowered: return args.full_tag if "-logging" in lowered: return args.logging_utility_tag if is_logging_utility(identity) else args.logging_main_tag @@ -100,6 +100,25 @@ def release_tag(identity: str, args: argparse.Namespace) -> str: return args.utility_tag +def identity_category(identity: str) -> str: + lowered = identity.lower() + if "full-usb-wifi-ota" in lowered: + return "full-unified" + if "full-logging-ota" in lowered: + return "full-logging" + if "-logging" in lowered: + return "logging-utility" if is_logging_utility(identity) else "logging-main" + if ( + "companion_radio_full" in lowered + or "lora_ota" in lowered + or "observer_mqtt" in lowered + ): + return "standard-advanced" + if is_logging_utility(identity): + return "standard-utility" + return "standard-main" + + def update_catalog(catalog: dict, release_files: dict[str, list[Path]], args: argparse.Namespace) -> dict: display_version = args.artifact_version.rsplit("-", 1)[0] if args.companion_only: @@ -117,10 +136,13 @@ def update_catalog(catalog: dict, release_files: dict[str, list[Path]], args: ar f"Keymind Cascade MeshCore {display_version} firmware with Halo/Keymind " "retry tuning, Cascade defaults, and the USA/Canada 910.525 MHz / SF7 / " "BW62.5 / CR5 preset. This catalog contains packet-logging builds with " - "USB debug enabled except on seven flash-constrained STM32 targets, " - "selected non-logging utility and FULL MQTT observer builds, " - "and expanded-partition FULL USB-logging LoRa-OTA builds. Host software " - "can consume the USB serial log and publish it separately. Open Release " + "USB debug enabled except on seven flash-constrained STM32 targets and " + "selected non-logging utilities. Unified expanded-partition FULL builds " + "provide USB packet logging and direct WiFi MQTT in one image with a " + "persistent off/USB/WiFi/both selector. Host software can consume the " + "USB serial log and publish it separately. nRF52 Full Companion uses " + "separate USB interfaces for Companion traffic and plaintext logging, " + "replacing its older separate USB-logging image. Open Release " "notes for role, hardware, installation, and partition requirements." ) @@ -131,10 +153,13 @@ def update_catalog(catalog: dict, release_files: dict[str, list[Path]], args: ar "logging-main": 0, "logging-utility": 0, "full-logging": 0, + "full-unified": 0, "standard-main": 0, "standard-advanced": 0, "standard-utility": 0, } + processed_firmware_ids: set[int] = set() + firmware_priorities: dict[int, int] = {} for device in catalog["device"]: device_type = device["type"] @@ -158,6 +183,10 @@ def update_catalog(catalog: dict, release_files: dict[str, list[Path]], args: ar or converted_observer or "observer_mqtt" in identity.lower() ) + processed_firmware_ids.add(id(firmware)) + firmware_priorities[id(firmware)] = common.legacy_identity_priority( + old_identities, identities + ) paths: list[Path] = [] for identity in identities: if identity not in release_files: @@ -165,22 +194,7 @@ def update_catalog(catalog: dict, release_files: dict[str, list[Path]], args: ar used_identities.add(identity) paths.extend(release_files[identity]) - lowered = identity.lower() - if "full-logging-ota" in lowered: - category_counts["full-logging"] += 1 - elif "-logging" in lowered: - key = "logging-utility" if is_logging_utility(identity) else "logging-main" - category_counts[key] += 1 - elif ( - "companion_radio_full" in lowered - or "lora_ota" in lowered - or "observer_mqtt" in lowered - ): - category_counts["standard-advanced"] += 1 - elif is_logging_utility(identity): - category_counts["standard-utility"] += 1 - else: - category_counts["standard-main"] += 1 + category_counts[identity_category(identity)] += 1 paths.sort(key=lambda path: common.file_sort_key(path, device_type)) files = [] @@ -205,7 +219,14 @@ def update_catalog(catalog: dict, release_files: dict[str, list[Path]], args: ar notes = common.observer_notes( firmware["role"], display_version, identities, old_notes ) - firmware["subTitle"] = "FULL MQTT observer" + firmware["subTitle"] = ( + "Unified FULL USB + Wi-Fi observer" + if any( + "-full-usb-wifi-ota" in identity.lower() + for identity in identities + ) + else "FULL MQTT observer" + ) else: notes = common.replace_release_version(old_notes, display_version) if "USA/Canada 910.525 MHz" not in notes: @@ -246,6 +267,16 @@ def update_catalog(catalog: dict, release_files: dict[str, list[Path]], args: ar notes = common.append_companion_power_saving_note( notes, display_version ) + notes = common.normalize_runtime_companion_metadata( + firmware, notes + ) + if device_type == "nrf52" and any( + "companion_radio_full" in identity.lower() + for identity in identities + ): + notes = common.normalize_nrf52_full_companion_metadata( + firmware, notes + ) if any("-logging" in identity.lower() for identity in identities): logging_note = ( "LOGGING USE - This USB packet-logging build is for a " @@ -259,17 +290,29 @@ def update_catalog(catalog: dict, release_files: dict[str, list[Path]], args: ar entry_count += 1 file_count += len(files) - expected_entries = 129 if args.companion_only else 596 - expected_identities = None if args.companion_only else 648 - if entry_count != expected_entries: - raise ValueError( - f"expected {expected_entries} curated firmware entries, found {entry_count}" - ) - if expected_identities is not None and len(used_identities) != expected_identities: - raise ValueError( - f"expected {expected_identities} curated identities, " - f"found {len(used_identities)}" - ) + collapsed_entries = common.deduplicate_resolved_firmware( + catalog, firmware_priorities + ) + retained_processed = [ + firmware + for device in catalog["device"] + for firmware in device["firmware"] + if id(firmware) in processed_firmware_ids + ] + entry_count = len(retained_processed) + file_count = sum( + len(version["files"]) + for firmware in retained_processed + for version in firmware["version"].values() + ) + used_identities = { + identity + for firmware in retained_processed + for identity in common.ordered_catalog_identities(firmware) + } + category_counts = {key: 0 for key in category_counts} + for identity in used_identities: + category_counts[identity_category(identity)] += 1 if file_count == 0: raise ValueError("catalog update produced no files") print( @@ -279,6 +322,7 @@ def update_catalog(catalog: dict, release_files: dict[str, list[Path]], args: ar "catalog_files": file_count, "release_identities_used": len(used_identities), "identity_categories": category_counts, + "collapsed_alias_entries": collapsed_entries, "update_mode": "companion-only" if args.companion_only else "all", }, sort_keys=True, diff --git a/mesh-america/update-provider-release.py b/mesh-america/update-provider-release.py index d4485a39..baed39bf 100644 --- a/mesh-america/update-provider-release.py +++ b/mesh-america/update-provider-release.py @@ -63,6 +63,10 @@ def parse_args() -> argparse.Namespace: ) parser.add_argument("--main-tag", required=True) parser.add_argument("--advanced-tag", required=True) + parser.add_argument( + "--full-tag", + help="FULL-profile release tag (defaults to --advanced-tag for compatibility)", + ) parser.add_argument("--utility-tag", required=True) parser.add_argument("--repo", default="mikecarper/MeshCore") parser.add_argument("--output", type=Path) @@ -79,7 +83,10 @@ def parse_args() -> argparse.Namespace: "all unaffected roles" ), ) - return parser.parse_args() + args = parser.parse_args() + if not args.full_tag: + args.full_tag = args.advanced_tag + return args def artifact_identity(name: str) -> str: @@ -127,34 +134,300 @@ def ordered_catalog_identities(firmware: dict) -> list[str]: return identities +def canonical_runtime_identity(identity: str) -> str: + """Map legacy setting/profile names to the canonical release identity.""" + result = re.sub( + r"^Station_G2_logging(?=_)", "Station_G2", identity, + flags=re.IGNORECASE, + ) + result = re.sub( + r"^Station_G3_ESP32_logging(?=_)", "Station_G3_ESP32", result, + flags=re.IGNORECASE, + ) + result = re.sub(r"_ps(?=_|-|$)", "", result, flags=re.IGNORECASE) + + # V4.3 _femoff recipes extend the corresponding auto-detect V4 recipe. + # Normalize the physical prefix before turning the compile-time default + # into the canonical runtime-configurable identity. + v4_prefixes = ( + (r"^heltec_v4_3_expansionkit_tft(?=_companion_radio)", + "heltec_v4_expansionkit_tft"), + (r"^heltec_v4_3_tft(?=_companion_radio)", "heltec_v4_tft"), + (r"^heltec_v4_3(?=_companion_radio)", "heltec_v4"), + ) + for pattern, replacement in v4_prefixes: + result = re.sub(pattern, replacement, result, flags=re.IGNORECASE) + result = re.sub( + r"_femoff(?=-|$)", "_femon", result, flags=re.IGNORECASE + ) + + # The unsuffixed V4 USB/BLE targets are exact aliases of their _femon + # recipes and are the shorter canonical release names. + result = re.sub( + r"^(heltec_v4_companion_radio_(?:usb|ble))_femon(?=-|$)", + r"\1", + result, + flags=re.IGNORECASE, + ) + return result + + +def release_identity_candidates(old_identity: str) -> list[str]: + candidates = [old_identity] + fixed_alias = LEGACY_COMPANION_IDENTITY_ALIASES.get(old_identity) + if fixed_alias is not None: + candidates.append(fixed_alias) + for identity in tuple(candidates): + canonical = canonical_runtime_identity(identity) + if canonical not in candidates: + candidates.append(canonical) + return candidates + + +def legacy_identity_priority( + old_identities: list[str], resolved_identities: list[str] +) -> int: + """Prefer canonical catalog rows when several rows resolve to one file.""" + score = 0 + resolved = set(resolved_identities) + for identity in old_identities: + lowered = identity.lower() + if identity not in resolved: + score += 4 + if re.search(r"_ps(?=_|-|$)", lowered): + score += 8 + if "_femoff" in lowered: + score += 8 + if lowered.startswith(("station_g2_logging_", "station_g3_esp32_logging_")): + score += 8 + if re.match( + r"^heltec_v4_companion_radio_(?:usb|ble)_femon(?=-|$)", + lowered, + ): + score += 1 + return score + + +def deduplicate_resolved_firmware( + catalog: dict, priorities: dict[int, int] +) -> int: + """Collapse catalog rows that now point at the same canonical artifacts.""" + collapsed = 0 + for device in catalog["device"]: + retained: list[dict] = [] + index_by_identity: dict[tuple[str, ...], int] = {} + for firmware in device["firmware"]: + identities = tuple(ordered_catalog_identities(firmware)) + existing_index = index_by_identity.get(identities) + if existing_index is None: + index_by_identity[identities] = len(retained) + retained.append(firmware) + continue + existing = retained[existing_index] + if priorities.get(id(firmware), 0) < priorities.get(id(existing), 0): + retained[existing_index] = firmware + collapsed += 1 + device["firmware"] = retained + return collapsed + + +def normalize_runtime_companion_metadata(firmware: dict, notes: str) -> str: + subtitle = firmware.get("subTitle") + if isinstance(subtitle, str): + kept = [] + for part in subtitle.split(","): + token = part.strip() + if token.lower() in { + "power saving", "fem on", "fem off", + }: + continue + kept.append(token) + if kept: + firmware["subTitle"] = ", ".join(kept) + else: + firmware.pop("subTitle", None) + + paragraphs: list[str] = [] + added_runtime_note = False + for paragraph in notes.split("\n\n"): + lowered = paragraph.lower() + if paragraph.startswith("HARDWARE ") and "fem on/off must match" in lowered: + if not added_runtime_note: + paragraphs.append( + "CONFIGURATION - Controllable external FEM receive gain is " + "a saved setting, not a different hardware image. Change it " + "with WebConfig, the Companion protocol, or " + "radio.fem.rxgain on|off where the text CLI is available." + ) + added_runtime_note = True + continue + if paragraph.startswith("SELECTION "): + paragraph = re.sub( + r",?\s*(?:Power saving|FEM (?:on|off))(?=,|\.|$)", + "", + paragraph, + flags=re.IGNORECASE, + ) + paragraph = re.sub(r",\s*,", ",", paragraph) + paragraph = re.sub(r"\s+,", ",", paragraph) + paragraphs.append(paragraph) + return "\n\n".join(paragraphs) + + +def normalize_nrf52_full_companion_metadata( + firmware: dict, notes: str +) -> str: + """Describe the canonical dual-CDC nRF52 Full Companion accurately.""" + firmware["title"] = "Full Companion" + firmware["subTitle"] = ( + "USB Companion + USB logging + BLE + LoRa OTA source" + ) + profile = ( + "PROFILE - nRF52 Full Companion: one USB cable exposes interface 00 " + "for Binary Companion, the text terminal, and serial mOTA source " + "traffic, while interface 02 is a separate plaintext packet/debug " + "logging port. BLE remains available. Use set usb.logging off|on to " + "save whether interface 02 emits output." + ) + logging_use = ( + "LOGGING USE - Point Companion software, meshcli, and motatool at USB " + "interface 00. Point a USB-connected MQTT/logging reader at interface " + "02. Match the USB interface number rather than assuming a tty or COM " + "port number. Input received on interface 02 is ignored." + ) + ota_use = ( + "LORA OTA SOURCE - This Full Companion can serve a host-supplied " + "update to another node. It has no target-side staging store and does " + "not install that LoRa update onto itself." + ) + + paragraphs: list[str] = [] + profile_added = False + for paragraph in notes.split("\n\n"): + if paragraph.startswith("PROFILE "): + if not profile_added: + paragraphs.append(profile) + profile_added = True + continue + if paragraph.startswith("LOGGING USE "): + continue + if paragraph.startswith("SELECTION "): + paragraphs.append( + "SELECTION - nRF52 Full Companion with dual USB serial ports, " + "BLE, and source-only LoRa OTA." + ) + continue + paragraphs.append(paragraph) + if not profile_added: + paragraphs.append(profile) + paragraphs.append(logging_use) + if ota_use not in paragraphs: + paragraphs.append(ota_use) + return "\n\n".join(paragraphs) + + +def release_identity_has_nrf52_package( + release_files: dict[str, list[Path]], identity: str +) -> bool: + return any(path.suffix.lower() == ".zip" for path in release_files[identity]) + + def resolve_release_identity( old_identity: str, release_files: dict[str, list[Path]] ) -> tuple[str, bool]: - if old_identity in release_files: - return old_identity, False + candidates = release_identity_candidates(old_identity) + for candidate in candidates: + if candidate in release_files: + return candidate, False - alias = LEGACY_COMPANION_IDENTITY_ALIASES.get(old_identity) - if alias is not None and alias in release_files: - return alias, False + # Current nRF52 Full Companion also replaces the old USB-only logging + # artifact because its second CDC interface carries plaintext logs. Limit + # this fallback to identities with a native nRF52 DFU ZIP so an ESP32 Full + # Companion is never mistaken for a dual-port logging image. + for candidate in candidates: + if not candidate.endswith("-logging"): + continue + logging_base = candidate.removesuffix("-logging") + match = re.search( + r"_companion_radio_(?:usb|ble)(?=-|_|$)", + logging_base, + flags=re.IGNORECASE, + ) + if match is None: + continue + full_identity = ( + logging_base[:match.start()] + "_companion_radio_full" + + logging_base[match.end():] + ) + if full_identity in release_files and release_identity_has_nrf52_package( + release_files, full_identity + ): + return full_identity, False - # Accept catalogs produced while portable MQTT observers were still - # emitted, and migrate them to the expanded-partition FULL artifact. - if "observer_mqtt" in old_identity.lower(): - full_identity = f"{old_identity}-full-ota" - if full_identity in release_files: - return full_identity, True - if "bridge_espnow" in old_identity.lower(): - full_identity = f"{old_identity}-full-ota" + # Canonical nRF52 Full Companion replaces separate USB and BLE artifacts. + # ESP32 transport-specific images still match exactly above and therefore + # remain separate. Full is source-only for LoRa OTA and needs no staging + # store or self-install slot. + for candidate in candidates: + match = re.search( + r"_companion_radio_(?:usb|ble)(?=-|_|$)", + candidate, + flags=re.IGNORECASE, + ) + if match is None: + continue + full_identity = ( + candidate[:match.start()] + "_companion_radio_full" + + candidate[match.end():] + ) if full_identity in release_files: return full_identity, False + # Accept catalogs produced while portable MQTT observers were still + # emitted, and migrate them to the expanded-partition FULL artifact. + for candidate in candidates: + if "observer_mqtt" in candidate.lower(): + for full_suffix in ("-full-usb-wifi-ota", "-full-ota"): + full_identity = f"{candidate}{full_suffix}" + if full_identity in release_files: + return full_identity, True + if "bridge_espnow" in candidate.lower(): + for full_suffix in ("-full-usb-wifi-ota", "-full-ota"): + full_identity = f"{candidate}{full_suffix}" + if full_identity in release_files: + return full_identity, False + # Retain compatibility with the short-lived reverse migration used by # older release directories. - full_suffix = "-full-ota" - if old_identity.endswith(full_suffix): - portable_identity = old_identity[: -len(full_suffix)] - if portable_identity in release_files: - return portable_identity, True + for candidate in candidates: + for full_suffix in ("-full-usb-wifi-ota", "-full-ota"): + if candidate.endswith(full_suffix): + portable_identity = candidate[: -len(full_suffix)] + if portable_identity in release_files: + return portable_identity, True + unified_identity = f"{portable_identity}-full-usb-wifi-ota" + if unified_identity in release_files: + return unified_identity, True + + # Logging catalogs historically carried a separate USB-only identity for + # every ESP32 target. Prefer the unified FULL observer when present; if the + # role has no MQTT sibling, use its FULL logging fallback instead. + for candidate in candidates: + logging_base = None + if candidate.endswith("-full-logging-ota"): + logging_base = candidate.removesuffix("-full-logging-ota") + elif candidate.endswith("-logging"): + logging_base = candidate.removesuffix("-logging") + if logging_base is None: + continue + logging_candidates = ( + f"{logging_base}_observer_mqtt-full-usb-wifi-ota", + f"{logging_base}-full-usb-wifi-ota", + f"{logging_base}-full-logging-ota", + ) + for identity in logging_candidates: + if identity in release_files: + return identity, "observer_mqtt" in identity.lower() raise ValueError(f"no new release artifact matches catalog target {old_identity!r}") @@ -181,8 +454,13 @@ def replace_legacy_companion_ota_notes(notes: str) -> str: def release_tag_for_identity(identity: str, args: argparse.Namespace) -> str: lowered = identity.lower() if ( - "companion_radio_full" in lowered + "-full-usb-wifi-ota" in lowered + or "-full-logging-ota" in lowered or "-full-ota" in lowered + ): + return args.full_tag + if ( + "companion_radio_full" in lowered or "lora_ota" in lowered or "observer_mqtt" in lowered ): @@ -251,6 +529,7 @@ def replace_release_version(notes: str, display_version: str) -> str: def partition_warning(identities: list[str]) -> str | None: if not any( identity.lower() in LEGACY_PORTABLE_CEILING_EXCEPTIONS + or "-full-usb-wifi-ota" in identity.lower() or "-full-ota" in identity.lower() or "-full-logging-ota" in identity.lower() for identity in identities @@ -302,6 +581,24 @@ def observer_notes( role_paragraph = role_paragraphs.get( role, f"ROLE - {role}: use the firmware only for its named MeshCore role." ) + unified_output = any( + "-full-usb-wifi-ota" in identity.lower() for identity in identities + ) + profile_paragraph = ( + "PROFILE - Unified FULL USB + Wi-Fi observer: uses expanded partitions, " + "compiles USB packet logging and the on-device Wi-Fi MQTT bridge into one " + "image, keeps verbose USB debug off, and retains the complete role CLI. " + "Use logging.output off|usb|wifi|both to persist the active paths. LoRa " + "self-update is enabled. With no saved SSID, the setup AP is available " + "for 30 minutes per boot and then powers Wi-Fi off; configured Wi-Fi " + "retains its normal indefinite reconnect behavior." + if unified_output + else + "PROFILE - FULL MQTT observer: uses expanded partitions, forwards mesh " + "observations to an on-device Wi-Fi MQTT bridge, keeps USB debug/packet " + "logging off, uses bridge NTP instead of mesh clock consensus, and retains " + "the complete role CLI. LoRa self-update is enabled." + ) paragraphs = [ ( f"Keymind Cascade MeshCore {display_version} with Halo/Keymind retry " @@ -309,12 +606,7 @@ def observer_notes( "BW62.5 / CR5 preset." ), role_paragraph, - ( - "PROFILE - FULL MQTT observer: uses expanded partitions, forwards mesh observations to an " - "on-device Wi-Fi MQTT bridge, keeps USB debug/packet logging off, " - "uses bridge NTP instead of mesh clock consensus, and retains the " - "complete role CLI. LoRa self-update is enabled." - ), + profile_paragraph, ( "INSTALL - Flash Full install (the merged bootloader + firmware image) " "over USB once to install the expanded partition table. Routine upgrades " @@ -333,7 +625,11 @@ def observer_notes( "Board selection note:" ): paragraphs.append(paragraph) - paragraphs.append("SELECTION - FULL MQTT observer.") + paragraphs.append( + "SELECTION - Unified FULL USB + Wi-Fi observer." + if unified_output + else "SELECTION - FULL MQTT observer." + ) return "\n\n".join(paragraphs) @@ -355,9 +651,10 @@ def update_catalog(catalog: dict, release_files: dict[str, list[Path]], args: ar "retry tuning, Cascade defaults, and the USA/Canada 910.525 MHz / SF7 / " "BW62.5 / CR5 preset. This catalog contains standard builds, Full " "Companion builds, lean LoRa-OTA builds, and expanded-partition FULL " - "MQTT observer and ESP-NOW bridge builds. MQTT observers keep USB " - "debug/packet logging off. On nRF52, Full Companion " - "replaces separate BLE and USB choices; on ESP32, Full Companion is " + "USB + Wi-Fi observer and ESP-NOW bridge builds. Unified observers " + "provide persistent off/USB/WiFi/both output selection. On nRF52, Full Companion " + "replaces separate BLE, USB, and USB-logging choices with BLE plus " + "separate Companion and logging USB ports; on ESP32, Full Companion is " "offered next to the BLE/USB variants. Open Release notes for role, " "hardware, installation, and partition requirements." ) @@ -366,6 +663,8 @@ def update_catalog(catalog: dict, release_files: dict[str, list[Path]], args: ar updated_files = 0 observer_entries = 0 used_identities: set[str] = set() + processed_firmware_ids: set[int] = set() + firmware_priorities: dict[int, int] = {} for device in catalog["device"]: device_type = device["type"] @@ -377,8 +676,9 @@ def update_catalog(catalog: dict, release_files: dict[str, list[Path]], args: ar resolved_identities: list[str] = [] converted_observer = False converted_legacy_companion_ota = False + old_identities = ordered_catalog_identities(firmware) - for old_identity in ordered_catalog_identities(firmware): + for old_identity in old_identities: identity, converted = resolve_release_identity(old_identity, release_files) converted_legacy_companion_ota = ( converted_legacy_companion_ota @@ -391,6 +691,10 @@ def update_catalog(catalog: dict, release_files: dict[str, list[Path]], args: ar or converted or "observer_mqtt" in identity.lower() ) + processed_firmware_ids.add(id(firmware)) + firmware_priorities[id(firmware)] = legacy_identity_priority( + old_identities, resolved_identities + ) paths: list[Path] = [] for identity in resolved_identities: @@ -420,7 +724,14 @@ def update_catalog(catalog: dict, release_files: dict[str, list[Path]], args: ar resolved_identities, old_notes, ) - firmware["subTitle"] = "FULL MQTT observer" + firmware["subTitle"] = ( + "Unified FULL USB + Wi-Fi observer" + if any( + "-full-usb-wifi-ota" in identity.lower() + for identity in resolved_identities + ) + else "FULL MQTT observer" + ) observer_entries += 1 else: version_key = replacement_version_key(old_keys[0], args.artifact_version) @@ -431,15 +742,42 @@ def update_catalog(catalog: dict, release_files: dict[str, list[Path]], args: ar if firmware["role"].startswith("companion"): notes = append_companion_power_saving_note(notes, display_version) + notes = normalize_runtime_companion_metadata(firmware, notes) + if device_type == "nrf52" and any( + "companion_radio_full" in identity.lower() + for identity in resolved_identities + ): + notes = normalize_nrf52_full_companion_metadata( + firmware, notes + ) firmware["version"] = {version_key: {"notes": notes, "files": files}} updated_entries += 1 updated_files += len(files) - if args.companion_only and updated_entries != 216: - raise ValueError( - f"expected 216 curated Companion entries, found {updated_entries}" - ) + collapsed_entries = deduplicate_resolved_firmware(catalog, firmware_priorities) + retained_processed = [ + firmware + for device in catalog["device"] + for firmware in device["firmware"] + if id(firmware) in processed_firmware_ids + ] + updated_entries = len(retained_processed) + updated_files = sum( + len(version["files"]) + for firmware in retained_processed + for version in firmware["version"].values() + ) + observer_entries = sum( + any("observer_mqtt" in identity.lower() + for identity in ordered_catalog_identities(firmware)) + for firmware in retained_processed + ) + used_identities = { + identity + for firmware in retained_processed + for identity in ordered_catalog_identities(firmware) + } if updated_entries == 0 or updated_files == 0: raise ValueError("catalog update produced no firmware entries") @@ -450,6 +788,7 @@ def update_catalog(catalog: dict, release_files: dict[str, list[Path]], args: ar "catalog_files": updated_files, "observer_entries": observer_entries, "release_identities_used": len(used_identities), + "collapsed_alias_entries": collapsed_entries, "update_mode": "companion-only" if args.companion_only else "all", }, sort_keys=True, diff --git a/src/Dispatcher.cpp b/src/Dispatcher.cpp index b1df9acb..5745a912 100644 --- a/src/Dispatcher.cpp +++ b/src/Dispatcher.cpp @@ -222,10 +222,12 @@ uint32_t Dispatcher::getCADFailMaxDuration() const { #if MESH_PACKET_LOGGING void Dispatcher::logPacketStart(const char* direction, const Packet* packet, int len) { - Serial.print(getLogDateTime()); - Serial.printf(": %s, len=%d (type=%d, route=%s, payload_len=%d)", - direction, len, packet->getPayloadType(), - packet->isRouteDirect() ? "D" : "F", packet->payload_len); + Stream& logging_port = usbLoggingPort(); + logging_port.print(getLogDateTime()); + logging_port.printf(": %s, len=%d (type=%d, route=%s, payload_len=%d)", + direction, len, packet->getPayloadType(), + packet->isRouteDirect() ? "D" : "F", + packet->payload_len); } void Dispatcher::logPacketEnd(const Packet* packet) { @@ -233,10 +235,11 @@ void Dispatcher::logPacketEnd(const Packet* packet) { if (packet->payload_len >= 2 && (type == PAYLOAD_TYPE_PATH || type == PAYLOAD_TYPE_REQ || type == PAYLOAD_TYPE_RESPONSE || type == PAYLOAD_TYPE_TXT_MSG)) { - Serial.printf(" [%02X -> %02X]\n", (uint32_t)packet->payload[1], - (uint32_t)packet->payload[0]); + usbLoggingPort().printf(" [%02X -> %02X]\n", + (uint32_t)packet->payload[1], + (uint32_t)packet->payload[0]); } else { - Serial.write((uint8_t)'\n'); + usbLoggingPort().write((uint8_t)'\n'); } } #endif @@ -593,13 +596,15 @@ void Dispatcher::checkRecv() { #if MESH_PACKET_LOGGING if (isUsbLoggingEnabled()) { logPacketStart("RX", pkt, pkt->getRawLength()); - Serial.printf(" SNR=%d RSSI=%d score=%d time=%d", (int)pkt->getSNR(), - (int)rssi, (int)(score * 1000), air_time); + Stream& logging_port = usbLoggingPort(); + logging_port.printf(" SNR=%d RSSI=%d score=%d time=%d", + (int)pkt->getSNR(), (int)rssi, + (int)(score * 1000), air_time); static uint8_t packet_hash[MAX_HASH_SIZE]; pkt->calculatePacketHash(packet_hash); - Serial.print(" hash="); - mesh::Utils::printHex(Serial, packet_hash, MAX_HASH_SIZE); + logging_port.print(" hash="); + mesh::Utils::printHex(logging_port, packet_hash, MAX_HASH_SIZE); logPacketEnd(pkt); } #endif diff --git a/src/MeshCore.h b/src/MeshCore.h index fb9f6b3d..4b4ca708 100644 --- a/src/MeshCore.h +++ b/src/MeshCore.h @@ -25,23 +25,23 @@ #if MESH_DEBUG && ARDUINO #include - #define MESH_DEBUG_PRINT(F, ...) do { if (mesh::isUsbLoggingEnabled() && Serial.availableForWrite() > 0) { Serial.printf("DEBUG: " F, ##__VA_ARGS__); } } while(0) - #define MESH_DEBUG_PRINTLN(F, ...) do { if (mesh::isUsbLoggingEnabled() && Serial.availableForWrite() > 0) { Serial.printf("DEBUG: " F "\n", ##__VA_ARGS__); } } while(0) + #define MESH_DEBUG_PRINT(F, ...) do { if (mesh::isUsbLoggingEnabled() && mesh::usbLoggingPort().availableForWrite() > 0) { mesh::usbLoggingPort().printf("DEBUG: " F, ##__VA_ARGS__); } } while(0) + #define MESH_DEBUG_PRINTLN(F, ...) do { if (mesh::isUsbLoggingEnabled() && mesh::usbLoggingPort().availableForWrite() > 0) { mesh::usbLoggingPort().printf("DEBUG: " F "\n", ##__VA_ARGS__); } } while(0) #else #define MESH_DEBUG_PRINT(...) {} #define MESH_DEBUG_PRINTLN(...) {} #endif #if BRIDGE_DEBUG && ARDUINO -#define BRIDGE_DEBUG_PRINTLN(F, ...) do { if (mesh::isUsbLoggingEnabled() && Serial.availableForWrite() > 0) { Serial.printf("%s BRIDGE: " F, getLogDateTime(), ##__VA_ARGS__); } } while(0) +#define BRIDGE_DEBUG_PRINTLN(F, ...) do { if (mesh::isUsbLoggingEnabled() && mesh::usbLoggingPort().availableForWrite() > 0) { mesh::usbLoggingPort().printf("%s BRIDGE: " F, getLogDateTime(), ##__VA_ARGS__); } } while(0) #else #define BRIDGE_DEBUG_PRINTLN(...) {} #endif #if POWERSAVING_DEBUG && ARDUINO #include - #define POWERSAVING_DEBUG_PRINT(F, ...) do { if (mesh::isUsbLoggingEnabled()) { Serial.printf("POWERSAVING: " F, ##__VA_ARGS__); } } while(0) - #define POWERSAVING_DEBUG_PRINTLN(F, ...) do { if (mesh::isUsbLoggingEnabled()) { Serial.printf("POWERSAVING: " F "\n", ##__VA_ARGS__); } } while(0) + #define POWERSAVING_DEBUG_PRINT(F, ...) do { if (mesh::isUsbLoggingEnabled()) { mesh::usbLoggingPort().printf("POWERSAVING: " F, ##__VA_ARGS__); } } while(0) + #define POWERSAVING_DEBUG_PRINTLN(F, ...) do { if (mesh::isUsbLoggingEnabled()) { mesh::usbLoggingPort().printf("POWERSAVING: " F "\n", ##__VA_ARGS__); } } while(0) #else #define POWERSAVING_DEBUG_PRINT(...) {} #define POWERSAVING_DEBUG_PRINTLN(...) {} diff --git a/src/helpers/AlertReporter.cpp b/src/helpers/AlertReporter.cpp index d01775b1..2a842a2a 100644 --- a/src/helpers/AlertReporter.cpp +++ b/src/helpers/AlertReporter.cpp @@ -21,7 +21,7 @@ #ifdef MQTT_DEBUG #include -#define ALERT_DEBUG_PRINTLN(...) do { if (mesh::isUsbLoggingEnabled()) { Serial.printf("Alert: " __VA_ARGS__); Serial.println(); } } while (0) +#define ALERT_DEBUG_PRINTLN(...) do { if (mesh::isUsbLoggingEnabled()) { mesh::usbLoggingPort().printf("Alert: " __VA_ARGS__); mesh::usbLoggingPort().println(); } } while (0) #else #define ALERT_DEBUG_PRINTLN(...) do {} while (0) #endif diff --git a/src/helpers/CommonCLI.cpp b/src/helpers/CommonCLI.cpp index 03f47a34..735da909 100644 --- a/src/helpers/CommonCLI.cpp +++ b/src/helpers/CommonCLI.cpp @@ -722,6 +722,7 @@ void CommonCLI::loadPrefs(FILESYSTEM* fs) { // contain its appended byte, so they safely inherit the enabled default. _prefs->system_watchdog_enabled = 1; memset(_prefs->extra_sf, 0, sizeof(_prefs->extra_sf)); + _prefs->usb_logging_enabled = 1; #ifdef WITH_MQTT_BRIDGE bool node_prefs_needs_migration = false; @@ -825,6 +826,9 @@ void CommonCLI::loadPrefs(FILESYSTEM* fs) { #if defined(ENABLE_OTA) if (loaded) syncOtaConfigFromPrefs(); // persisted OTA policy/keys -> OtaContext (else keep safe defaults) #endif +#if MESH_USB_LOGGING_AVAILABLE + mesh::setUsbLoggingEnabled(_prefs->usb_logging_enabled != 0); +#endif } #if defined(ENABLE_OTA) @@ -1205,6 +1209,10 @@ void CommonCLI::loadPrefsInt(FILESYSTEM* fs, const char* filename) { if (file.available() >= (int)sizeof(_prefs->radio_fem_txgain)) { file.read((uint8_t *)&_prefs->radio_fem_txgain, sizeof(_prefs->radio_fem_txgain)); + if (file.available() >= (int)sizeof(_prefs->usb_logging_enabled)) { + file.read((uint8_t *)&_prefs->usb_logging_enabled, + sizeof(_prefs->usb_logging_enabled)); + } } } else if (file.available() > 0) { // Never accept a torn append as a partial detector list. @@ -1275,6 +1283,7 @@ void CommonCLI::loadPrefsInt(FILESYSTEM* fs, const char* filename) { _prefs->rx_boosted_gain = constrain(_prefs->rx_boosted_gain, 0, 1); // boolean _prefs->radio_fem_rxgain = constrain(_prefs->radio_fem_rxgain, 0, 1); // boolean _prefs->radio_fem_txgain = constrain(_prefs->radio_fem_txgain, 0, 1); // boolean + _prefs->usb_logging_enabled = constrain(_prefs->usb_logging_enabled, 0, 1); // boolean _prefs->cad_enabled = constrain(_prefs->cad_enabled, 0, 1); // boolean if (!directRetryPrefsValid(_prefs)) { setDefaultDirectRetryPrefs(_prefs); @@ -1482,6 +1491,7 @@ static bool writeCommonPrefsImage(Writer& writer, NodePrefs* prefs) { WRITE_COMMON_PREFS(&prefs->system_watchdog_enabled); // 855 WRITE_COMMON_PREFS(&prefs->extra_sf); // 856 WRITE_COMMON_PREFS(&prefs->radio_fem_txgain); // 860 + WRITE_COMMON_PREFS(&prefs->usb_logging_enabled); // 861 #undef WRITE_COMMON_PREFS_BYTES #undef WRITE_COMMON_PREFS @@ -1633,7 +1643,8 @@ void CommonCLI::savePrefs(FILESYSTEM* fs, PrefsSaveRouting::Scope scope) { sizeof(_prefs->system_watchdog_enabled)); // 855 file.write((uint8_t *)_prefs->extra_sf, sizeof(_prefs->extra_sf)); // 856 file.write((uint8_t *)&_prefs->radio_fem_txgain, sizeof(_prefs->radio_fem_txgain)); // 860 - // next: 861 + file.write((uint8_t *)&_prefs->usb_logging_enabled, sizeof(_prefs->usb_logging_enabled)); // 861 + // next: 862 #if defined(NRF52_PLATFORM) if (!file.commit()) { @@ -3028,15 +3039,48 @@ void CommonCLI::handleSetCmd(uint32_t sender_timestamp, char* command, char* rep while (*value == ' ' || *value == '\t') value++; if (strcmp(value, "on") == 0 || strcmp(value, "off") == 0) { const bool enabled = strcmp(value, "on") == 0; + _prefs->usb_logging_enabled = enabled ? 1 : 0; mesh::setUsbLoggingEnabled(enabled); - snprintf(reply, 160, "OK - USB logging %s until reboot", - enabled ? "on" : "off"); + savePrefs(); + snprintf(reply, 160, "OK - USB logging %s (saved)", enabled ? "on" : "off"); } else { strcpy(reply, "Error: usage set usb.logging on|off"); } return; } #endif +#if MESH_USB_LOGGING_AVAILABLE && defined(WITH_MQTT_BRIDGE) + if (strncmp(config, "logging.output", 14) == 0 + && (config[14] == 0 || config[14] == ' ' || config[14] == '\t')) { + const char* value = &config[14]; + while (*value == ' ' || *value == '\t') value++; + bool usb_enabled; + bool wifi_enabled; + if (strcmp(value, "off") == 0) { + usb_enabled = false; + wifi_enabled = false; + } else if (strcmp(value, "usb") == 0) { + usb_enabled = true; + wifi_enabled = false; + } else if (strcmp(value, "wifi") == 0) { + usb_enabled = false; + wifi_enabled = true; + } else if (strcmp(value, "both") == 0) { + usb_enabled = true; + wifi_enabled = true; + } else { + strcpy(reply, "Error: usage set logging.output off|usb|wifi|both"); + return; + } + _prefs->usb_logging_enabled = usb_enabled ? 1 : 0; + _prefs->bridge_enabled = wifi_enabled ? 1 : 0; + mesh::setUsbLoggingEnabled(usb_enabled); + _callbacks->setBridgeState(wifi_enabled); + savePrefs(); + snprintf(reply, 160, "OK - logging.output %s (saved)", value); + return; + } +#endif #if defined(ESP32_PLATFORM) || defined(USER_GPIO_CONTROL) if (isGpioConfig(config)) { const UserGpio::SetResult result = _user_gpio.handleSet( @@ -4000,6 +4044,17 @@ void CommonCLI::handleGetCmd(uint32_t sender_timestamp, char* command, char* rep return; } #endif +#if MESH_USB_LOGGING_AVAILABLE && defined(WITH_MQTT_BRIDGE) + if (strcmp(config, "logging.output") == 0) { + const bool usb_enabled = mesh::isUsbLoggingEnabled(); + const bool wifi_enabled = _prefs->bridge_enabled != 0; + const char* mode = usb_enabled + ? (wifi_enabled ? "both" : "usb") + : (wifi_enabled ? "wifi" : "off"); + snprintf(reply, 160, "> %s", mode); + return; + } +#endif #if defined(ESP32_PLATFORM) || defined(USER_GPIO_CONTROL) if (isGpioConfig(config)) { _user_gpio.handleGet(config + 4, reply, 160); diff --git a/src/helpers/CommonCLI.h b/src/helpers/CommonCLI.h index b306fa15..4838fed7 100644 --- a/src/helpers/CommonCLI.h +++ b/src/helpers/CommonCLI.h @@ -166,6 +166,10 @@ public: // in-memory placement here does not shift the established binary layout. uint8_t extra_sf[4] = {}; uint8_t radio_fem_txgain = 0; // LoRa FEM TX gain; persisted at /com_prefs offset 860 + // Runtime USB packet output gate. Appended at /com_prefs offset 861 so + // older images remain readable and logging builds keep their historical + // enabled-at-first-boot behavior. + uint8_t usb_logging_enabled = 1; uint8_t retry_preset = 0; uint8_t direct_retry_attempts = 0; uint16_t direct_retry_base_ms = 0; @@ -263,6 +267,7 @@ private: def("baud", _parent->bridge_baud); def("ch", _parent->bridge_channel); def("secret", _parent->bridge_secret, sizeof(_parent->bridge_secret)); + def("usb_log", _parent->usb_logging_enabled); } public: diff --git a/src/helpers/UsbLogging.cpp b/src/helpers/UsbLogging.cpp index 8fe0233a..10143d93 100644 --- a/src/helpers/UsbLogging.cpp +++ b/src/helpers/UsbLogging.cpp @@ -1,12 +1,28 @@ #include "UsbLogging.h" #if defined(ARDUINO) +#include #include +#if defined(MESH_DUAL_CDC_LOGGING) + #if !defined(NRF52_PLATFORM) || !defined(COMPANION_RADIO_FULL) + #error "MESH_DUAL_CDC_LOGGING is only supported by nRF52 Full Companion" + #endif + #if !defined(CFG_TUD_CDC) || CFG_TUD_CDC < 2 + #error "MESH_DUAL_CDC_LOGGING requires CFG_TUD_CDC >= 2" + #endif + #include +#endif + namespace mesh { static std::atomic usb_logging_enabled{true}; +#if defined(MESH_DUAL_CDC_LOGGING) +static Adafruit_USBD_CDC dedicated_usb_logging_port; +static bool dedicated_usb_logging_port_started = false; +#endif + bool isUsbLoggingEnabled() { return usb_logging_enabled.load(std::memory_order_relaxed); } @@ -15,5 +31,40 @@ void setUsbLoggingEnabled(bool enabled) { usb_logging_enabled.store(enabled, std::memory_order_relaxed); } +void beginUsbLoggingPort() { +#if defined(MESH_DUAL_CDC_LOGGING) + if (dedicated_usb_logging_port_started) return; + + dedicated_usb_logging_port.begin(115200); + dedicated_usb_logging_port_started = true; + + // The nRF52 core starts TinyUSB before setup(). If the host completed + // enumeration in that small window, reconnect once so it reads the expanded + // two-CDC descriptor. Usually enumeration has not completed and no reconnect + // is needed. + if (TinyUSBDevice.mounted()) { + TinyUSBDevice.detach(); + delay(10); + TinyUSBDevice.attach(); + } +#endif +} + +Stream& usbLoggingPort() { +#if defined(MESH_DUAL_CDC_LOGGING) + return dedicated_usb_logging_port; +#else + return Serial; +#endif +} + +bool hasDedicatedUsbLoggingPort() { +#if defined(MESH_DUAL_CDC_LOGGING) + return true; +#else + return false; +#endif +} + } // namespace mesh #endif diff --git a/src/helpers/UsbLogging.h b/src/helpers/UsbLogging.h index e664b2f7..4c1eb2f8 100644 --- a/src/helpers/UsbLogging.h +++ b/src/helpers/UsbLogging.h @@ -12,12 +12,21 @@ #endif #if defined(ARDUINO) +class Stream; + namespace mesh { -// This is intentionally session-only. Logging artifacts start enabled after -// every boot so a stale setting cannot make a diagnostic image look silent. +// Logging starts enabled on first boot. Roles with saved preferences restore +// the persisted setting after their preferences are loaded. bool isUsbLoggingEnabled(); void setUsbLoggingEnabled(bool enabled); +// Start the optional dedicated USB logging interface. Ordinary builds keep +// using Serial. nRF52 Full Companion builds expose a second CDC ACM interface +// so plaintext diagnostics never share the framed Companion stream. +void beginUsbLoggingPort(); +Stream& usbLoggingPort(); +bool hasDedicatedUsbLoggingPort(); + } // namespace mesh #endif diff --git a/src/helpers/WebConfigBatch.h b/src/helpers/WebConfigBatch.h index bbc11b5a..a6d5055b 100644 --- a/src/helpers/WebConfigBatch.h +++ b/src/helpers/WebConfigBatch.h @@ -45,6 +45,7 @@ static const uint32_t kRebootConfirmMs = 3000; // .cpp:784 first result-read static const uint32_t kSetupWiFiConnectTimeoutMs = 20000; static const uint32_t kSetupHandoffRebootConfirmMs = 1000; static const uint32_t kStopWarnMs = 10000; // .h:95 STOP_WARN_MS +static const uint32_t kFullSetupApWindowMs = 30UL * 60UL * 1000UL; // The batch lifecycle. A fresh POST moves Idle->Pending; the drainer moves // Pending->Done; Done stays re-readable until the next POST claims the slot; @@ -70,6 +71,22 @@ static inline bool deadlineReached(uint32_t now, uint32_t deadline) { return (int32_t)(now - deadline) >= 0; } +// FULL images expose an unconfigured setup AP for one bounded, boot-local +// window. Activity and connected stations do not extend it: until an SSID is +// saved the WiFi radio must go dark after 30 minutes. A reboot constructs a new +// WebConfigServer and therefore starts a new automatic window without +// persisting a timeout state. An explicit later `start webconfig` is still an +// operator override. timeout_ms == 0 keeps this policy disabled for profiles +// that retain the legacy idle-only behavior. +static inline bool unconfiguredSetupWindowExpired(bool setup_mode, + bool has_saved_ssid, + uint32_t now, + uint32_t setup_started_at, + uint32_t timeout_ms) { + return timeout_ms != 0 && setup_mode && !has_saved_ssid + && elapsedMs(now, setup_started_at) >= timeout_ms; +} + // -------------------------------------------------------------------------- // POST accept classification (.cpp:637-718). Precedence, verbatim from the // source: an in-flight/finished batch with the SAME reqid is an idempotent diff --git a/src/helpers/bridges/MQTTBridge.cpp b/src/helpers/bridges/MQTTBridge.cpp index 9914a14e..1b65d591 100644 --- a/src/helpers/bridges/MQTTBridge.cpp +++ b/src/helpers/bridges/MQTTBridge.cpp @@ -248,7 +248,7 @@ static void agentLogHeap(const char* location, const char* message, const char* "\"data\":{\"free\":%u,\"max_alloc\":%u,\"internal_free\":%lu,\"spiram_free\":%lu},\"timestamp\":%lu}", location, message, hypothesisId, (unsigned)free_h, (unsigned)max_alloc, internal_free, spiram_free, (unsigned long)millis()); - Serial.println(buf); + mesh::usbLoggingPort().println(buf); } // #endregion #endif diff --git a/src/helpers/bridges/MQTTBridge.h b/src/helpers/bridges/MQTTBridge.h index 4d59414b..1680cdff 100644 --- a/src/helpers/bridges/MQTTBridge.h +++ b/src/helpers/bridges/MQTTBridge.h @@ -31,8 +31,8 @@ class MeshSNMPAgent; // Forward declaration // USB CDC-aware debug macros: only print if Serial is ready (non-blocking check) // Serial.availableForWrite() returns bytes available in write buffer (>0 means ready) // This prevents hangs when USB CDC isn't ready yet (e.g., ESP32-S3 native USB) - #define MQTT_DEBUG_PRINT(F, ...) do { if (mesh::isUsbLoggingEnabled() && Serial.availableForWrite() > 0) { Serial.printf("MQTT: " F, ##__VA_ARGS__); } } while(0) - #define MQTT_DEBUG_PRINTLN(F, ...) do { if (mesh::isUsbLoggingEnabled() && Serial.availableForWrite() > 0) { Serial.printf("MQTT: " F "\n", ##__VA_ARGS__); } } while(0) + #define MQTT_DEBUG_PRINT(F, ...) do { if (mesh::isUsbLoggingEnabled() && mesh::usbLoggingPort().availableForWrite() > 0) { mesh::usbLoggingPort().printf("MQTT: " F, ##__VA_ARGS__); } } while(0) + #define MQTT_DEBUG_PRINTLN(F, ...) do { if (mesh::isUsbLoggingEnabled() && mesh::usbLoggingPort().availableForWrite() > 0) { mesh::usbLoggingPort().printf("MQTT: " F "\n", ##__VA_ARGS__); } } while(0) #else #define MQTT_DEBUG_PRINT(...) {} #define MQTT_DEBUG_PRINTLN(...) {} diff --git a/src/helpers/esp32/ESPNOWRadio.h b/src/helpers/esp32/ESPNOWRadio.h index d8414c7b..be4b5267 100644 --- a/src/helpers/esp32/ESPNOWRadio.h +++ b/src/helpers/esp32/ESPNOWRadio.h @@ -64,8 +64,8 @@ public: #if ESPNOW_DEBUG_LOGGING && ARDUINO #include - #define ESPNOW_DEBUG_PRINT(F, ...) do { if (mesh::isUsbLoggingEnabled()) { Serial.printf("ESP-Now: " F, ##__VA_ARGS__); } } while(0) - #define ESPNOW_DEBUG_PRINTLN(F, ...) do { if (mesh::isUsbLoggingEnabled()) { Serial.printf("ESP-Now: " F "\n", ##__VA_ARGS__); } } while(0) + #define ESPNOW_DEBUG_PRINT(F, ...) do { if (mesh::isUsbLoggingEnabled()) { mesh::usbLoggingPort().printf("ESP-Now: " F, ##__VA_ARGS__); } } while(0) + #define ESPNOW_DEBUG_PRINTLN(F, ...) do { if (mesh::isUsbLoggingEnabled()) { mesh::usbLoggingPort().printf("ESP-Now: " F "\n", ##__VA_ARGS__); } } while(0) #else #define ESPNOW_DEBUG_PRINT(...) {} #define ESPNOW_DEBUG_PRINTLN(...) {} diff --git a/src/helpers/esp32/SerialBLEInterface.h b/src/helpers/esp32/SerialBLEInterface.h index 3120d520..7a218235 100644 --- a/src/helpers/esp32/SerialBLEInterface.h +++ b/src/helpers/esp32/SerialBLEInterface.h @@ -115,8 +115,8 @@ public: #if BLE_DEBUG_LOGGING && ARDUINO #include - #define BLE_DEBUG_PRINT(F, ...) do { if (mesh::isUsbLoggingEnabled()) { Serial.printf("BLE: " F, ##__VA_ARGS__); } } while(0) - #define BLE_DEBUG_PRINTLN(F, ...) do { if (mesh::isUsbLoggingEnabled()) { Serial.printf("BLE: " F "\n", ##__VA_ARGS__); } } while(0) + #define BLE_DEBUG_PRINT(F, ...) do { if (mesh::isUsbLoggingEnabled()) { mesh::usbLoggingPort().printf("BLE: " F, ##__VA_ARGS__); } } while(0) + #define BLE_DEBUG_PRINTLN(F, ...) do { if (mesh::isUsbLoggingEnabled()) { mesh::usbLoggingPort().printf("BLE: " F "\n", ##__VA_ARGS__); } } while(0) #else #define BLE_DEBUG_PRINT(...) {} #define BLE_DEBUG_PRINTLN(...) {} diff --git a/src/helpers/esp32/SerialWifiInterface.h b/src/helpers/esp32/SerialWifiInterface.h index ce5bf48d..b8a84526 100644 --- a/src/helpers/esp32/SerialWifiInterface.h +++ b/src/helpers/esp32/SerialWifiInterface.h @@ -67,8 +67,8 @@ public: #if WIFI_DEBUG_LOGGING && ARDUINO #include - #define WIFI_DEBUG_PRINT(F, ...) do { if (mesh::isUsbLoggingEnabled()) { Serial.printf("WiFi: " F, ##__VA_ARGS__); } } while(0) - #define WIFI_DEBUG_PRINTLN(F, ...) do { if (mesh::isUsbLoggingEnabled()) { Serial.printf("WiFi: " F "\n", ##__VA_ARGS__); } } while(0) + #define WIFI_DEBUG_PRINT(F, ...) do { if (mesh::isUsbLoggingEnabled()) { mesh::usbLoggingPort().printf("WiFi: " F, ##__VA_ARGS__); } } while(0) + #define WIFI_DEBUG_PRINTLN(F, ...) do { if (mesh::isUsbLoggingEnabled()) { mesh::usbLoggingPort().printf("WiFi: " F "\n", ##__VA_ARGS__); } } while(0) #else #define WIFI_DEBUG_PRINT(...) {} #define WIFI_DEBUG_PRINTLN(...) {} diff --git a/src/helpers/esp32/WebConfigServer.cpp b/src/helpers/esp32/WebConfigServer.cpp index e353c99a..12338775 100644 --- a/src/helpers/esp32/WebConfigServer.cpp +++ b/src/helpers/esp32/WebConfigServer.cpp @@ -585,6 +585,7 @@ bool WebConfigServer::startSetupMode(char reply[]) { _retry_saved_wifi_in_setup = false; _setup_reconnect_in_progress = false; _setup_reconnect_deadline = 0; + _setup_started_at = 0; // AP_STA (not pure AP) so the WiFi scan for the SSID picker works while // the AP is up. STA stays unconnected - the bridge won't touch WiFi // while wifi_ssid is empty, and `start webconfig ap` requires it stopped. @@ -630,7 +631,8 @@ bool WebConfigServer::startSetupMode(char reply[]) { NodeSnapshot node = {}; _cb->getNodeSnapshot(node); _initial_setup = _wifi_ssid[0] == 0 && node.admin_password[0] != 0; - _last_activity = millis(); + _setup_started_at = millis(); + _last_activity = _setup_started_at; WiFi.scanNetworks(true); // pre-populate the SSID picker sprintf(reply, "WebConfig AP started: join '%s' then open http://%s/", _ap_ssid, ip.toString().c_str()); @@ -647,6 +649,7 @@ bool WebConfigServer::startLanMode(char reply[]) { return false; } _mode = MODE_LAN; + _setup_started_at = 0; _wifi_reconnect_tracker.noteConnected(); _retry_saved_wifi_in_setup = false; _setup_reconnect_in_progress = false; @@ -678,6 +681,7 @@ bool WebConfigServer::startAutoMode(char reply[]) { _retry_saved_wifi_in_setup = false; _setup_reconnect_in_progress = false; _setup_reconnect_deadline = 0; + _setup_started_at = 0; _wifi_reconnect_tracker.noteDisconnected(millis()); WiFi.begin(_wifi_ssid, _wifi_password); _wifi_power_save = effectiveWiFiPowerSave(_wifi_power_save); @@ -749,6 +753,7 @@ void WebConfigServer::finalizeTeardown() { WiFi.mode(WIFI_OFF); } _initial_setup = false; + _setup_started_at = 0; _stopping = false; _stop_warn_at = 0; _stop_warned = false; @@ -857,6 +862,7 @@ void WebConfigServer::tick(uint32_t now) { WiFi.setAutoReconnect(true); _was_setup_ap = false; _initial_setup = false; + _setup_started_at = 0; _retry_saved_wifi_in_setup = false; _setup_reconnect_in_progress = false; _setup_reconnect_deadline = 0; @@ -907,6 +913,21 @@ void WebConfigServer::tick(uint32_t now) { _stats_built_at = now; } + // A FULL image with no saved SSID gets one absolute setup window per boot. + // Browser activity and an attached station cannot extend it. requestStop() + // tears down the AP and finalizeTeardown() powers WiFi fully off; the saved + // bridge/output preference is deliberately left unchanged for the next boot. + if (WebConfigBatch::unconfiguredSetupWindowExpired( + _mode == MODE_SETUP, _wifi_ssid[0] != 0, now, + _setup_started_at, + (uint32_t)WEBCONFIG_UNCONFIGURED_SETUP_TIMEOUT_MS)) { + Serial.printf( + "WebConfig: WiFi still unconfigured after %lu minutes; powering off until reboot or explicit restart\n", + (unsigned long)((uint32_t)WEBCONFIG_UNCONFIGURED_SETUP_TIMEOUT_MS / 60000UL)); + requestStop(); + return; + } + // Idle timeout: only the setup AP auto-stops (a deployed node must not be // left broadcasting an open AP). LAN mode runs until `stop webconfig`. if (_mode == MODE_SETUP && WiFi.softAPgetStationNum() == 0 && @@ -925,6 +946,7 @@ void WebConfigServer::tick(uint32_t now) { WiFi.setAutoReconnect(true); _was_setup_ap = false; _initial_setup = false; + _setup_started_at = 0; _retry_saved_wifi_in_setup = false; _setup_reconnect_in_progress = false; _setup_reconnect_deadline = 0; diff --git a/src/helpers/esp32/WebConfigServer.h b/src/helpers/esp32/WebConfigServer.h index 621b8693..efefde32 100644 --- a/src/helpers/esp32/WebConfigServer.h +++ b/src/helpers/esp32/WebConfigServer.h @@ -36,7 +36,18 @@ class AsyncWebServerRequest; class DNSServer; #ifndef WEBCONFIG_AP_IDLE_TIMEOUT_MS - #define WEBCONFIG_AP_IDLE_TIMEOUT_MS (10UL * 60UL * 1000UL) + #if defined(MESHCORE_ESP32_FULL_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) + #define WEBCONFIG_UNCONFIGURED_SETUP_TIMEOUT_MS WebConfigBatch::kFullSetupApWindowMs + #else + #define WEBCONFIG_UNCONFIGURED_SETUP_TIMEOUT_MS 0UL + #endif #endif #ifndef WEBCONFIG_SESSION_TTL_MS #define WEBCONFIG_SESSION_TTL_MS (20UL * 60UL * 1000UL) @@ -227,6 +238,7 @@ private: bool _stopping = false; bool _was_setup_ap = false; bool _initial_setup = false; + uint32_t _setup_started_at = 0; uint32_t _connect_deadline = 0; char _wifi_ssid[32] = {0}; char _wifi_password[64] = {0}; diff --git a/src/helpers/ethernet/SerialEthernetInterface.h b/src/helpers/ethernet/SerialEthernetInterface.h index 90859bb7..bed25d35 100644 --- a/src/helpers/ethernet/SerialEthernetInterface.h +++ b/src/helpers/ethernet/SerialEthernetInterface.h @@ -70,9 +70,9 @@ class SerialEthernetInterface : public BaseSerialInterface { #if ETHERNET_DEBUG_LOGGING && ARDUINO #include - #define ETHERNET_DEBUG_PRINT(F, ...) do { if (mesh::isUsbLoggingEnabled()) { Serial.printf("ETH: " F, ##__VA_ARGS__); } } while(0) - #define ETHERNET_DEBUG_PRINTLN(F, ...) do { if (mesh::isUsbLoggingEnabled()) { Serial.printf("ETH: " F "\n", ##__VA_ARGS__); } } while(0) - #define ETHERNET_DEBUG_PRINT_IP(name, ip) do { if (mesh::isUsbLoggingEnabled()) { Serial.printf("ETH: " name ": %u.%u.%u.%u" "\n", ip[0], ip[1], ip[2], ip[3]); } } while(0) + #define ETHERNET_DEBUG_PRINT(F, ...) do { if (mesh::isUsbLoggingEnabled()) { mesh::usbLoggingPort().printf("ETH: " F, ##__VA_ARGS__); } } while(0) + #define ETHERNET_DEBUG_PRINTLN(F, ...) do { if (mesh::isUsbLoggingEnabled()) { mesh::usbLoggingPort().printf("ETH: " F "\n", ##__VA_ARGS__); } } while(0) + #define ETHERNET_DEBUG_PRINT_IP(name, ip) do { if (mesh::isUsbLoggingEnabled()) { mesh::usbLoggingPort().printf("ETH: " name ": %u.%u.%u.%u" "\n", ip[0], ip[1], ip[2], ip[3]); } } while(0) #else #define ETHERNET_DEBUG_PRINT(...) {} #define ETHERNET_DEBUG_PRINTLN(...) {} diff --git a/src/helpers/nrf52/SerialBLEInterface.h b/src/helpers/nrf52/SerialBLEInterface.h index 96de940e..d7a276cb 100644 --- a/src/helpers/nrf52/SerialBLEInterface.h +++ b/src/helpers/nrf52/SerialBLEInterface.h @@ -95,8 +95,8 @@ public: #if BLE_DEBUG_LOGGING && ARDUINO #include - #define BLE_DEBUG_PRINT(F, ...) do { if (mesh::isUsbLoggingEnabled()) { Serial.printf("BLE: " F, ##__VA_ARGS__); } } while(0) - #define BLE_DEBUG_PRINTLN(F, ...) do { if (mesh::isUsbLoggingEnabled()) { Serial.printf("BLE: " F "\n", ##__VA_ARGS__); } } while(0) + #define BLE_DEBUG_PRINT(F, ...) do { if (mesh::isUsbLoggingEnabled()) { mesh::usbLoggingPort().printf("BLE: " F, ##__VA_ARGS__); } } while(0) + #define BLE_DEBUG_PRINTLN(F, ...) do { if (mesh::isUsbLoggingEnabled()) { mesh::usbLoggingPort().printf("BLE: " F "\n", ##__VA_ARGS__); } } while(0) #else #define BLE_DEBUG_PRINT(...) {} #define BLE_DEBUG_PRINTLN(...) {} diff --git a/src/helpers/ota/OtaDebug.h b/src/helpers/ota/OtaDebug.h index ab6665d9..75cdaa87 100644 --- a/src/helpers/ota/OtaDebug.h +++ b/src/helpers/ota/OtaDebug.h @@ -6,7 +6,7 @@ #if defined(OTA_DEBUG) && defined(ARDUINO) #include #include - #define OTA_DBG(...) do { if (mesh::isUsbLoggingEnabled()) { Serial.printf(__VA_ARGS__); } } while (0) + #define OTA_DBG(...) do { if (mesh::isUsbLoggingEnabled()) { mesh::usbLoggingPort().printf(__VA_ARGS__); } } while (0) #else #define OTA_DBG(...) do {} while (0) #endif diff --git a/src/helpers/radiolib/CustomLLCC68.h b/src/helpers/radiolib/CustomLLCC68.h index c988d677..849e664c 100644 --- a/src/helpers/radiolib/CustomLLCC68.h +++ b/src/helpers/radiolib/CustomLLCC68.h @@ -51,8 +51,8 @@ class CustomLLCC68 : public LLCC68 { status = begin(LORA_FREQ, LORA_BW, LORA_SF, cr, RADIOLIB_SX126X_SYNC_WORD_PRIVATE, LORA_TX_POWER, 16, tcxo); } if (status != RADIOLIB_ERR_NONE) { - Serial.print("ERROR: radio init failed: "); - Serial.println(status); + mesh::usbLoggingPort().print("ERROR: radio init failed: "); + mesh::usbLoggingPort().println(status); return false; // fail } diff --git a/src/helpers/radiolib/CustomLR2021.h b/src/helpers/radiolib/CustomLR2021.h index 5bc0628b..1c014563 100644 --- a/src/helpers/radiolib/CustomLR2021.h +++ b/src/helpers/radiolib/CustomLR2021.h @@ -58,8 +58,8 @@ class CustomLR2021 : public LR2021 { status = begin(LORA_FREQ, LORA_BW, LORA_SF, cr, RADIOLIB_LR2021_LORA_SYNC_WORD_PRIVATE, LORA_TX_POWER, 16, tcxo); } if (status != RADIOLIB_ERR_NONE) { - Serial.print("ERROR: radio init failed: "); - Serial.println(status); + mesh::usbLoggingPort().print("ERROR: radio init failed: "); + mesh::usbLoggingPort().println(status); return false; // fail } diff --git a/src/helpers/radiolib/CustomSX1262.h b/src/helpers/radiolib/CustomSX1262.h index 91517ed8..18918a48 100644 --- a/src/helpers/radiolib/CustomSX1262.h +++ b/src/helpers/radiolib/CustomSX1262.h @@ -96,8 +96,8 @@ class CustomSX1262 : public SX1262 { status = begin(LORA_FREQ, LORA_BW, LORA_SF, cr, RADIOLIB_SX126X_SYNC_WORD_PRIVATE, LORA_TX_POWER, 16, tcxo, useRegulatorLDO); } if (status != RADIOLIB_ERR_NONE) { - Serial.print("ERROR: radio init failed: "); - Serial.println(status); + mesh::usbLoggingPort().print("ERROR: radio init failed: "); + mesh::usbLoggingPort().println(status); return false; // fail } diff --git a/src/helpers/radiolib/CustomSX1268.h b/src/helpers/radiolib/CustomSX1268.h index 25aa64c0..849262c0 100644 --- a/src/helpers/radiolib/CustomSX1268.h +++ b/src/helpers/radiolib/CustomSX1268.h @@ -51,8 +51,8 @@ class CustomSX1268 : public SX1268 { status = begin(LORA_FREQ, LORA_BW, LORA_SF, cr, RADIOLIB_SX126X_SYNC_WORD_PRIVATE, LORA_TX_POWER, 16, tcxo); } if (status != RADIOLIB_ERR_NONE) { - Serial.print("ERROR: radio init failed: "); - Serial.println(status); + mesh::usbLoggingPort().print("ERROR: radio init failed: "); + mesh::usbLoggingPort().println(status); return false; // fail } diff --git a/src/helpers/radiolib/CustomSX1276.h b/src/helpers/radiolib/CustomSX1276.h index e6e5e107..9e7ce1b8 100644 --- a/src/helpers/radiolib/CustomSX1276.h +++ b/src/helpers/radiolib/CustomSX1276.h @@ -43,8 +43,8 @@ class CustomSX1276 : public SX1276 { int status = begin(LORA_FREQ, LORA_BW, LORA_SF, cr, RADIOLIB_SX126X_SYNC_WORD_PRIVATE, LORA_TX_POWER, 16); // if radio init fails with -707/-706, try again with tcxo voltage set to 0.0f if (status != RADIOLIB_ERR_NONE) { - Serial.print("ERROR: radio init failed: "); - Serial.println(status); + mesh::usbLoggingPort().print("ERROR: radio init failed: "); + mesh::usbLoggingPort().println(status); return false; // fail } #ifdef SX127X_CURRENT_LIMIT diff --git a/src/helpers/sensors/MicroNMEALocationProvider.h b/src/helpers/sensors/MicroNMEALocationProvider.h index 9f4e2292..8d882727 100644 --- a/src/helpers/sensors/MicroNMEALocationProvider.h +++ b/src/helpers/sensors/MicroNMEALocationProvider.h @@ -147,7 +147,7 @@ public : while (_gps_serial->available()) { char c = _gps_serial->read(); #ifdef GPS_NMEA_DEBUG - if (mesh::isUsbLoggingEnabled()) Serial.print(c); + if (mesh::isUsbLoggingEnabled()) mesh::usbLoggingPort().print(c); #endif nmea.process(c); } diff --git a/src/helpers/ui/ST7735Display.cpp b/src/helpers/ui/ST7735Display.cpp index da1d39f0..3c7d52db 100644 --- a/src/helpers/ui/ST7735Display.cpp +++ b/src/helpers/ui/ST7735Display.cpp @@ -442,11 +442,12 @@ bool ST7735Display::begin() { // alloc offscreen canvas sprite = new TFT_eSprite(&lcd); if (!sprite) { - Serial.println("ST7735Display: failed to alloc canvas"); + mesh::usbLoggingPort().println("ST7735Display: failed to alloc canvas"); return false; } if (!sprite->createSprite(160, 80)) { - Serial.println("ST7735Display: failed to alloc canvas pixels"); + mesh::usbLoggingPort().println( + "ST7735Display: failed to alloc canvas pixels"); delete sprite; sprite = NULL; return false; diff --git a/test/test_companion_node_prefs/test_companion_node_prefs.cpp b/test/test_companion_node_prefs/test_companion_node_prefs.cpp index f2cbe945..16c3b352 100644 --- a/test/test_companion_node_prefs/test_companion_node_prefs.cpp +++ b/test/test_companion_node_prefs/test_companion_node_prefs.cpp @@ -80,6 +80,22 @@ TEST(CompanionNodePrefs, WiFiStateIsIndependentFromPowerSaving) { EXPECT_EQ(1, prefs.powersaving_enabled); } +TEST(CompanionNodePrefs, UsbLoggingStateIsIndependentFromTransports) { + CompanionNodePrefs prefs = {}; + prefs.usb_logging_enabled = 1; + prefs.wifi_enabled = 0; + prefs.powersaving_enabled = 1; + + EXPECT_EQ(1, prefs.usb_logging_enabled); + EXPECT_EQ(0, prefs.wifi_enabled); + EXPECT_EQ(1, prefs.powersaving_enabled); + + prefs.usb_logging_enabled = 0; + EXPECT_EQ(0, prefs.usb_logging_enabled); + EXPECT_EQ(0, prefs.wifi_enabled); + EXPECT_EQ(1, prefs.powersaving_enabled); +} + TEST(CompanionNodePrefs, MigratesRegressedPowerSavingDefaultOnce) { CompanionNodePrefs prefs = {}; prefs.powersaving_enabled = 0; diff --git a/test/test_config_serializer/test_config_serializer.cpp b/test/test_config_serializer/test_config_serializer.cpp index 27c3c811..03a17a32 100644 --- a/test/test_config_serializer/test_config_serializer.cpp +++ b/test/test_config_serializer/test_config_serializer.cpp @@ -196,6 +196,7 @@ TEST(NodePrefs, FemGainSettingsRoundTrip) { NodePrefs saved; saved.radio_fem_rxgain = 0; saved.radio_fem_txgain = 1; + saved.usb_logging_enabled = 0; MockPrintStream output; ASSERT_TRUE(saved.saveSerial(output)); @@ -203,15 +204,18 @@ TEST(NodePrefs, FemGainSettingsRoundTrip) { std::string serialised(reinterpret_cast(output.getBytes()), output.getLength()); EXPECT_NE(std::string::npos, serialised.find("fem_rxgain:0")); EXPECT_NE(std::string::npos, serialised.find("fem_txgain:1")); + EXPECT_NE(std::string::npos, serialised.find("usb_log:0")); MockInputStream input(serialised.c_str()); NodePrefs loaded; loaded.radio_fem_rxgain = 1; loaded.radio_fem_txgain = 0; + loaded.usb_logging_enabled = 1; ASSERT_TRUE(loaded.loadSerial(input)); EXPECT_EQ(0, loaded.radio_fem_rxgain); EXPECT_EQ(1, loaded.radio_fem_txgain); + EXPECT_EQ(0, loaded.usb_logging_enabled); } diff --git a/test/test_firmware_picker.js b/test/test_firmware_picker.js index 7bd04609..61db7afc 100644 --- a/test/test_firmware_picker.js +++ b/test/test_firmware_picker.js @@ -38,6 +38,10 @@ const releases = [ asset("Station_G3_ESP32_logging_repeater-" + family + ".bin"), asset("Heltec_t096_companion_radio_ble_ps_femon-" + family + ".uf2"), asset("Heltec_t096_companion_radio_ble_ps_femon-" + family + ".zip"), + asset("RAK_4631_companion_radio_full-" + family + ".uf2"), + asset("RAK_4631_companion_radio_full-" + family + ".zip"), + asset("RAK_4631_companion_radio_usb-logging-" + family + ".uf2"), + asset("RAK_4631_companion_radio_usb-logging-" + family + ".zip"), ]), release("repeater-room-" + family, "2026-08-23T12:00:05Z", [ asset("Station_G2_repeater-" + family + "-deadbee-merged.bin"), @@ -76,15 +80,15 @@ const releases = [ ]), release("full-profiles-" + family, "2026-08-23T12:00:01Z", [ asset( - "Station_G2_repeater-full-logging-ota-" + family + "-merged.bin" + "Generic_ESPNOW_repeatr-full-logging-ota-" + family + "-merged.bin" ), - asset("Station_G2_repeater-full-logging-ota-" + family + ".bin"), + asset("Generic_ESPNOW_repeatr-full-logging-ota-" + family + ".bin"), asset( - "Station_G2_repeater_observer_mqtt-full-ota-" + + "Station_G2_repeater_observer_mqtt-full-usb-wifi-ota-" + family + "-merged.bin" ), asset( - "Station_G2_repeater_observer_mqtt-full-ota-" + family + ".bin" + "Station_G2_repeater_observer_mqtt-full-usb-wifi-ota-" + family + ".bin" ), ]), release( @@ -106,8 +110,8 @@ assert(!releaseSet.releases.some(function (item) { const catalog = picker.buildCatalog(releases); assert.strictEqual(catalog.releaseSet.familyTag, family); -assert.strictEqual(catalog.profiles.length, 12); -assert.strictEqual(catalog.rows.length, 29); +assert.strictEqual(catalog.profiles.length, 11); +assert.strictEqual(catalog.rows.length, 33); function profile(target) { const found = catalog.profiles.find(function (item) { @@ -126,14 +130,29 @@ assert.strictEqual(companionFull.ota, "lora-source"); assert.strictEqual(companionFull.feature, "full"); assert.strictEqual(companionFull.variant, "default"); -const companionBle = profile("Heltec_t096_companion_radio_ble_ps_femon"); +const rakFull = profile("RAK_4631_companion_radio_full"); +assert.strictEqual(rakFull.logging, "usb-runtime"); +assert.deepStrictEqual(rakFull.loggingModes, ["none", "usb"]); +assert.strictEqual(rakFull.dedicatedUsbLogging, true); +assert(!catalog.profiles.some(function (item) { + return item.target === "RAK_4631_companion_radio_usb-logging"; +})); +assert(catalog.rows.some(function (item) { + return item.target === "RAK_4631_companion_radio_usb-logging"; +})); + +const companionBle = picker.parseTargetProfile( + "Heltec_t096_companion_radio_ble_ps_femon" +); assert.strictEqual(companionBle.hardware, "Heltec_t096"); assert.strictEqual(companionBle.mode, "ble"); -assert.strictEqual(companionBle.variant, "ps-femon"); -assert.strictEqual( - picker.humanizeVariant(companionBle.variant), - "Power save FEM on" -); +assert.strictEqual(companionBle.variant, "default"); +assert(!catalog.profiles.some(function (item) { + return item.target === "Heltec_t096_companion_radio_ble_ps_femon"; +})); +assert(catalog.rows.some(function (item) { + return item.target === "Heltec_t096_companion_radio_ble_ps_femon"; +})); const fullWifiLogging = picker.parseTargetProfile( "Heltec_v2_companion_radio_wifi-full-logging" @@ -156,7 +175,7 @@ assert.strictEqual(heltecV4Full.target, "heltec_v4_2_v4_3_companion_radio_full_femon"); assert.strictEqual(heltecV4Full.hardware, "heltec_v4"); assert.strictEqual(heltecV4Full.mode, "full"); -assert.strictEqual(heltecV4Full.variant, "femon"); +assert.strictEqual(heltecV4Full.variant, "default"); assert.strictEqual( picker.canonicalHardware("heltec_v4_2_v4_3"), "heltec_v4" @@ -174,15 +193,17 @@ const heltecV4FemOff = picker.parseTargetProfile( "heltec_v4_3_companion_radio_ble_femoff" ); assert.strictEqual(heltecV4FemOff.hardware, "heltec_v4_3"); -assert.strictEqual(heltecV4FemOff.variant, "femoff"); +assert.strictEqual(heltecV4FemOff.variant, "default"); const heltecV4TftFemOff = picker.parseTargetProfile( "heltec_v4_3_tft_companion_radio_wifi_femoff" ); assert.strictEqual(heltecV4TftFemOff.hardware, "heltec_v4_3_tft"); -assert.strictEqual(heltecV4TftFemOff.variant, "femoff"); +assert.strictEqual(heltecV4TftFemOff.variant, "default"); -const g2RxBoosted = profile("Station_G2_logging_repeater"); +const g2RxBoosted = picker.parseTargetProfile( + "Station_G2_logging_repeater" +); assert.strictEqual(g2RxBoosted.sourceHardware, "Station_G2_logging"); assert.strictEqual(g2RxBoosted.hardware, "Station_G2"); assert.strictEqual(g2RxBoosted.variant, "rx-boosted"); @@ -190,6 +211,12 @@ assert.strictEqual( picker.humanizeVariant(g2RxBoosted.variant), "RX Boosted" ); +assert(!catalog.profiles.some(function (item) { + return item.target === "Station_G2_logging_repeater"; +})); +assert(catalog.rows.some(function (item) { + return item.target === "Station_G2_logging_repeater"; +})); const g3Standard = profile("Station_G3_ESP32_repeater"); assert.strictEqual(g3Standard.hardware, "Station_G3_ESP32"); @@ -200,6 +227,48 @@ assert(catalog.rows.some(function (item) { return item.target === "Station_G3_ESP32_logging_repeater"; })); +assert.deepStrictEqual( + picker.omitNrf52TransportsReplacedByFull([ + { + target: "RAK_4631_companion_radio_full", + hardware: "RAK_4631", + variant: "default", + role: "companion", + mode: "full", + logging: "none", + installKinds: ["zip", "uf2"], + }, + { + target: "RAK_4631_companion_radio_usb", + hardware: "RAK_4631", + variant: "default", + role: "companion", + mode: "usb", + logging: "none", + installKinds: ["zip", "uf2"], + }, + { + target: "RAK_4631_companion_radio_ble", + hardware: "RAK_4631", + variant: "default", + role: "companion", + mode: "ble", + logging: "none", + installKinds: ["zip", "uf2"], + }, + { + target: "RAK_4631_companion_radio_usb-logging", + hardware: "RAK_4631", + variant: "default", + role: "companion", + mode: "usb", + logging: "usb", + installKinds: ["zip", "uf2"], + }, + ]).map(function (item) { return item.target; }), + ["RAK_4631_companion_radio_full"] +); + const standardRepeater = profile("Station_G2_repeater"); assert.strictEqual(standardRepeater.hardwareFamily, "Station_G2"); assert.strictEqual(standardRepeater.role, "repeater"); @@ -212,16 +281,19 @@ assert.strictEqual( "Station_G2_repeater-" + family + "-merged.bin" ); -const fullLogging = profile("Station_G2_repeater-full-logging"); -assert.strictEqual(fullLogging.logging, "usb"); +const fullLogging = profile("Generic_ESPNOW_repeatr-full-logging"); +assert.strictEqual(fullLogging.logging, "usb-runtime"); +assert.deepStrictEqual(fullLogging.loggingModes, ["none", "usb"]); assert.strictEqual(fullLogging.ota, "lora-receiver"); assert.strictEqual(fullLogging.feature, "full"); assert.strictEqual(fullLogging.variant, "default"); -const mqtt = profile("Station_G2_repeater_observer_mqtt-full"); -assert.strictEqual(mqtt.logging, "wifi"); +const mqtt = profile("Station_G2_repeater_observer_mqtt-full-usb-wifi"); +assert.strictEqual(mqtt.logging, "runtime"); +assert.deepStrictEqual(mqtt.loggingModes, ["none", "usb", "wifi", "both"]); assert.strictEqual(mqtt.mode, "mqtt"); assert.strictEqual(mqtt.ota, "lora-receiver"); +assert.strictEqual(mqtt.variant, "default"); const lora = profile( "Station_G2_repeater_lora_ota_no_external_sensors" @@ -275,7 +347,10 @@ const matches = catalog.profiles.filter(function (item) { }); }); assert.strictEqual(matches.length, 1); -assert.strictEqual(matches[0].target, "Station_G2_repeater-full-logging"); +assert.strictEqual( + matches[0].target, + "Station_G2_repeater_observer_mqtt-full-usb-wifi" +); assert.deepStrictEqual( picker.FACET_FIELDS, @@ -371,6 +446,10 @@ assert.deepStrictEqual( picker.facetValues(catalog.profiles, { logging: "wifi" }, "hardware"), ["Station_G2"] ); +assert.deepStrictEqual( + picker.facetValues(catalog.profiles, { logging: "both" }, "hardware"), + ["Station_G2"] +); assert.deepStrictEqual( picker.facetValues( catalog.profiles, @@ -382,13 +461,13 @@ assert.deepStrictEqual( }, "hardware" ), - ["Station_G2"] + ["Generic_ESPNOW", "Station_G2"] ); assert.deepStrictEqual( picker.uniqueValues(catalog.profiles, "hardware").sort(), [ - "Heltec_t096", + "Generic_ESPNOW", "ProMicro", "RAK_4631", "Station_G2", diff --git a/test/test_webconfig_batch/test_webconfig_batch.cpp b/test/test_webconfig_batch/test_webconfig_batch.cpp index d3c8b6c3..70d1bca1 100644 --- a/test/test_webconfig_batch/test_webconfig_batch.cpp +++ b/test/test_webconfig_batch/test_webconfig_batch.cpp @@ -129,6 +129,38 @@ TEST(WebConfigBatch, SetupWiFiHandoffUsesBoundedConnectAndBriefFlushDelays) { EXPECT_EQ(1000U, Batch::kSetupHandoffRebootConfirmMs); } +TEST(WebConfigBatch, FullUnconfiguredSetupWindowIsAnAbsoluteThirtyMinutes) { + const uint32_t started = 1000; + EXPECT_EQ(30UL * 60UL * 1000UL, Batch::kFullSetupApWindowMs); + EXPECT_FALSE(Batch::unconfiguredSetupWindowExpired( + true, false, started + Batch::kFullSetupApWindowMs - 1, started, + Batch::kFullSetupApWindowMs)); + EXPECT_TRUE(Batch::unconfiguredSetupWindowExpired( + true, false, started + Batch::kFullSetupApWindowMs, started, + Batch::kFullSetupApWindowMs)); + + // Saving any SSID converts the node to deployed behavior, which continues + // reconnecting and is not subject to the provisioning cutoff. + EXPECT_FALSE(Batch::unconfiguredSetupWindowExpired( + true, true, started + Batch::kFullSetupApWindowMs, started, + Batch::kFullSetupApWindowMs)); + EXPECT_FALSE(Batch::unconfiguredSetupWindowExpired( + false, false, started + Batch::kFullSetupApWindowMs, started, + Batch::kFullSetupApWindowMs)); + EXPECT_FALSE(Batch::unconfiguredSetupWindowExpired( + true, false, started + Batch::kFullSetupApWindowMs, started, 0)); +} + +TEST(WebConfigBatch, FullUnconfiguredSetupWindowSurvivesMillisRollover) { + const uint32_t started = std::numeric_limits::max() - 1000; + EXPECT_FALSE(Batch::unconfiguredSetupWindowExpired( + true, false, started + Batch::kFullSetupApWindowMs - 1, started, + Batch::kFullSetupApWindowMs)); + EXPECT_TRUE(Batch::unconfiguredSetupWindowExpired( + true, false, started + Batch::kFullSetupApWindowMs, started, + Batch::kFullSetupApWindowMs)); +} + // -------------------------------------------------------------------------- // Result read // -------------------------------------------------------------------------- diff --git a/tools/mota/test_mota.py b/tools/mota/test_mota.py index 2f715e60..aacc07d3 100644 --- a/tools/mota/test_mota.py +++ b/tools/mota/test_mota.py @@ -10,6 +10,7 @@ Tests for motalib - run with the meshcore venv: from __future__ import annotations import io +import importlib.util import os import random import struct @@ -67,6 +68,151 @@ def test_internal_bootloader_target_wiring_is_central_and_not_duplicated(): assert "export MESHCORE_NRF52_INTERNAL_BOOTLOADER_UPDATE=1" in recipe +def test_full_esp32_profile_unifies_usb_logging_and_wifi_mqtt(): + root = Path(__file__).resolve().parents[2] + build = (root / "build.sh").read_text(encoding="utf-8") + profile = build.split("run_full_esp32_profile()", 1)[1] + profile = profile.split("run_full_esp32_build_targets()", 1)[0] + + assert 'PACKET_LOGGING_OVERRIDE="on"' in profile + assert 'MQTT_BRIDGE_OVERRIDE="on"' in profile + assert 'MESHDEBUG_OVERRIDE="off"' in profile + assert 'FIRMWARE_FILENAME_INFIX="full-usb-wifi"' in profile + assert "do not also build its former non-MQTT FULL-logging twin" in profile + + mqtt_gate = build.split("disable_usb_logging_for_mqtt()", 1)[1] + mqtt_gate = mqtt_gate.split("is_esp32_usb_wifi_companion_ota_build()", 1)[0] + assert 'is_companion_radio_full_target "$env_name"' in mqtt_gate + assert 'PACKET_LOGGING_OVERRIDE,,}" = "on"' in mqtt_gate + assert '! is_esp32_companion_build "$env_name"' in mqtt_gate + + matrix = build.split("run_logging_matrix_build_targets()", 1)[1] + matrix = matrix.split("run_build_targets()", 1)[0] + assert 'is_companion_radio_full_target "$target"' in matrix + assert "nRF52 Full already provides logging on its second USB port" in matrix + + +def test_canonical_bulk_matrix_omits_runtime_and_transport_aliases(): + root = Path(__file__).resolve().parents[2] + build = (root / "build.sh").read_text(encoding="utf-8") + + resolver = build.split("resolve_all_firmwares()", 1)[1] + resolver = resolver.split("is_legacy_companion_power_saving_target()", 1)[0] + assert 'is_redundant_bulk_build_target "$env_name"' in resolver + + runtime = build.split("is_runtime_setting_alias_target()", 1)[1] + runtime = runtime.split("is_redundant_bulk_build_target()", 1)[0] + assert "is_legacy_companion_power_saving_target" in runtime + assert "is_legacy_companion_femoff_target" in runtime + assert "is_legacy_radio_gain_profile_target" in runtime + assert "is_exact_companion_recipe_alias_target" in runtime + + redundant = build.split("is_redundant_bulk_build_target()", 1)[1] + redundant = redundant.split("resolve_logging_matrix_firmwares()", 1)[0] + assert "is_runtime_setting_alias_target" in redundant + assert "is_nrf52_companion_transport_replaced_by_full" in redundant + + logging_matrix = build.split("resolve_logging_matrix_firmwares()", 1)[1] + logging_matrix = logging_matrix.split("resolve_companion_firmwares()", 1)[0] + assert "resolve_all_firmwares" in logging_matrix + assert "print_nrf52_usb_logging_source_targets" not in build + + full = build.split("apply_companion_radio_full_profile()", 1)[1] + full = full.split("get_firmware_filename()", 1)[0] + assert "-DOTA_SEEDER_ONLY=1" in full + assert "-DMOTA_TARGET_ID=0" in full + assert "-UOTA_FLASH_STORE" in full + assert "-UOTA_SD_STORE" in full + assert "-DCFG_TUD_CDC=2" in full + assert "-DMESH_DUAL_CDC_LOGGING=1" in full + assert "-DMESH_DEBUG=1" in full + assert "-DMESH_PACKET_LOGGING=1" in full + + usb_logging = (root / "src/helpers/UsbLogging.cpp").read_text( + encoding="utf-8" + ) + assert "Adafruit_USBD_CDC dedicated_usb_logging_port" in usb_logging + assert "TinyUSBDevice.detach()" in usb_logging + assert "return dedicated_usb_logging_port" in usb_logging + + +def test_release_catalog_resolves_canonical_runtime_aliases(): + root = Path(__file__).resolve().parents[2] + provider_path = root / "mesh-america/update-provider-release.py" + spec = importlib.util.spec_from_file_location( + "meshcore_provider_release_test", provider_path + ) + assert spec is not None and spec.loader is not None + provider = importlib.util.module_from_spec(spec) + spec.loader.exec_module(provider) + + release_files = { + "RAK_4631_companion_radio_full": [Path("rak-full.zip")], + "Heltec_t096_companion_radio_ble_femon": [Path("t096.zip")], + "heltec_v4_companion_radio_ble": [Path("v4.bin")], + "Station_G2_repeater_observer_mqtt-full-usb-wifi-ota": [ + Path("g2.bin") + ], + } + assert provider.resolve_release_identity( + "RAK_4631_companion_radio_usb", release_files + ) == ("RAK_4631_companion_radio_full", False) + assert provider.resolve_release_identity( + "RAK_4631_companion_radio_usb-logging", release_files + ) == ("RAK_4631_companion_radio_full", False) + assert provider.resolve_release_identity( + "Heltec_t096_companion_radio_ble_ps_femoff", release_files + ) == ("Heltec_t096_companion_radio_ble_femon", False) + assert provider.resolve_release_identity( + "heltec_v4_3_companion_radio_ble_femoff", release_files + ) == ("heltec_v4_companion_radio_ble", False) + assert provider.resolve_release_identity( + "Station_G2_logging_repeater-logging", release_files + ) == ("Station_G2_repeater_observer_mqtt-full-usb-wifi-ota", True) + + # ESP32 transport-specific artifacts remain preferred when they exist. + release_files["Station_G2_companion_radio_usb"] = [Path("g2-usb.bin")] + release_files["Station_G2_companion_radio_full"] = [Path("g2-full.bin")] + assert provider.resolve_release_identity( + "Station_G2_companion_radio_usb", release_files + ) == ("Station_G2_companion_radio_usb", False) + + dual_notes = provider.normalize_nrf52_full_companion_metadata( + {"title": "Companion USB", "subTitle": "USB logging"}, + "PROFILE - old profile\n\nLOGGING USE - old use\n\nSELECTION - USB.", + ) + assert "interface 00" in dual_notes + assert "interface 02" in dual_notes + assert "Input received on interface 02 is ignored" in dual_notes + + legacy = { + "role": "companionBle", + "title": "Companion BLE", + "subTitle": "FEM off", + "version": { + "old": { + "notes": "old", + "files": [{"name": "Board_companion_radio_ble-v1.2.3.zip"}], + } + }, + } + canonical = { + "role": "companionBle", + "title": "Companion BLE", + "version": { + "new": { + "notes": "new", + "files": [{"name": "Board_companion_radio_ble-v1.2.3.zip"}], + } + }, + } + catalog = {"device": [{"firmware": [legacy, canonical]}]} + assert provider.deduplicate_resolved_firmware( + catalog, {id(legacy): 8, id(canonical): 0} + ) == 1 + assert catalog["device"][0]["firmware"] == [canonical] + + def test_ota_target_generation_honors_explicit_disable(): from gen_targets import ota_envs, release_aliases diff --git a/variants/minewsemi_me25ls01/target.cpp b/variants/minewsemi_me25ls01/target.cpp index bb2e85a0..54680b13 100644 --- a/variants/minewsemi_me25ls01/target.cpp +++ b/variants/minewsemi_me25ls01/target.cpp @@ -1,5 +1,6 @@ -#include -#include "target.h" +#include +#include "target.h" +#include MinewsemiME25LS01Board board; @@ -58,12 +59,11 @@ bool radio_init() { SPI.setPins(P_LORA_MISO, P_LORA_SCLK, P_LORA_MOSI); SPI.begin(); - int status = radio.begin(LORA_FREQ, LORA_BW, LORA_SF, LORA_CR, RADIOLIB_LR11X0_LORA_SYNC_WORD_PRIVATE, LORA_TX_POWER, 16, tcxo); - if (status != RADIOLIB_ERR_NONE) { - Serial.print("ERROR: radio init failed: "); - Serial.println(status); - return false; // fail - } + int status = radio.begin(LORA_FREQ, LORA_BW, LORA_SF, LORA_CR, RADIOLIB_LR11X0_LORA_SYNC_WORD_PRIVATE, LORA_TX_POWER, 16, tcxo); + if (status != RADIOLIB_ERR_NONE) { + mesh::usbLoggingPort().printf("ERROR: radio init failed: %d\r\n", status); + return false; // fail + } radio.setCRC(1); diff --git a/variants/t1000-e/target.cpp b/variants/t1000-e/target.cpp index 07861513..dcadb53e 100644 --- a/variants/t1000-e/target.cpp +++ b/variants/t1000-e/target.cpp @@ -1,6 +1,7 @@ #include #include "t1000e_sensors.h" #include "target.h" +#include #include T1000eBoard board; @@ -56,8 +57,7 @@ bool radio_init() { SPI.begin(); int status = radio.begin(LORA_FREQ, LORA_BW, LORA_SF, LORA_CR, RADIOLIB_LR11X0_LORA_SYNC_WORD_PRIVATE, LORA_TX_POWER, 16, tcxo); if (status != RADIOLIB_ERR_NONE) { - Serial.print("ERROR: radio init failed: "); - Serial.println(status); + mesh::usbLoggingPort().printf("ERROR: radio init failed: %d\r\n", status); return false; // fail } diff --git a/variants/thinknode_m3/target.cpp b/variants/thinknode_m3/target.cpp index 77a3b9a9..f6ca7eef 100644 --- a/variants/thinknode_m3/target.cpp +++ b/variants/thinknode_m3/target.cpp @@ -1,5 +1,6 @@ #include #include "target.h" +#include #include ThinkNodeM3Board board; @@ -60,8 +61,7 @@ bool radio_init() { SPI.begin(); int status = radio.begin(LORA_FREQ, LORA_BW, LORA_SF, LORA_CR, RADIOLIB_LR11X0_LORA_SYNC_WORD_PRIVATE, LORA_TX_POWER, 16, tcxo); if (status != RADIOLIB_ERR_NONE) { - Serial.print("ERROR: radio init failed: "); - Serial.println(status); + mesh::usbLoggingPort().printf("ERROR: radio init failed: %d\r\n", status); return false; // fail }