diff --git a/build.sh b/build.sh index 5fb10a48..48032e03 100755 --- a/build.sh +++ b/build.sh @@ -70,7 +70,7 @@ FALLBACK_VERSION_PREFIX="dev" FALLBACK_VERSION_DATE_FORMAT='+%Y-%m-%d-%H-%M' # External programs invoked by this script: -# bash, cat, cp, date, find, git, grep, head, mkdir, mv, pgrep, pio, +# bash, cat, cp, date, find, flock, git, grep, head, mkdir, mv, pgrep, pio, # python3, rm, sed, sleep, sort, wc # Keep this list in sync when adding or removing non-builtin command usage. @@ -84,8 +84,8 @@ Commands: list|-l: List firmwares available to build. build-firmware : Build the firmware for the given build target. build-firmwares: Build canonical firmwares for all targets. Runtime-setting aliases 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-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. Supported Full Companion targets provide an optional dedicated logging USB port. + build-companion-firmwares-logging-matrix: Build canonical Companion targets in each applicable standard, MQTT, and expanded FULL profile. Dual-CDC Full Companion replaces separate USB, BLE, WiFi, and USB-logging artifacts where supported. 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 . @@ -95,7 +95,7 @@ Commands: 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 canonical attached companion targets for release automation; nRF52 uses Full Companion instead of separate USB/BLE artifacts. + get-companion-firmwares-to-build: List canonical attached companion targets for release automation; dual-CDC boards use Full Companion instead of separate transport 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. @@ -505,7 +505,7 @@ prompt_for_build_mode() { "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 (nRF52 Full replaces USB/BLE; power saving and FEM/RX gain are runtime configurable)" + "Build canonical companion firmwares (dual-CDC Full replaces separate transports; power saving and FEM/RX gain are runtime configurable)" "Build all chat room server firmwares" "Build all sensor firmwares" "Build FULL ESP32 firmwares (all features, USB logging, WiFi MQTT where available, and LoRa OTA)" @@ -1479,13 +1479,14 @@ 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. + # A dual-CDC Full Companion supplies every ordinary attached transport, + # a separate USB logging interface, and source-only mOTA in one image. It + # replaces separate transport releases without becoming an OTA target. 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" \ + if is_companion_radio_full_target "$env_name" \ + && { is_nrf52_companion_radio_full_target "$env_name" \ + || is_esp32_dual_cdc_companion_radio_full_target "$env_name"; } \ && ! is_redundant_bulk_build_target "$env_name"; then printf '%s\n' "$env_name" fi @@ -1972,11 +1973,12 @@ apply_debug_overrides() { disable_usb_logging_for_mqtt() { local env_name=$1 - # 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. + # Full Companion may enable diagnostics only when it has a dedicated second + # CDC interface. Plaintext on its primary framed stream corrupts Companion + # traffic. if is_companion_radio_full_target "$env_name"; then - if is_nrf52_companion_radio_full_target "$env_name"; then + if is_nrf52_companion_radio_full_target "$env_name" \ + || is_esp32_dual_cdc_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" @@ -2032,6 +2034,44 @@ is_nrf52_companion_radio_full_target() { && is_companion_radio_full_target "$1" } +is_esp32_dual_cdc_companion_radio_full_target() { + is_esp32_companion_radio_full_target "$1" || return 1 + case "${1,,}" in + lilygo_tbeam_1w_companion_radio_full|\ + station_g2_companion_radio_full|\ + station_g3_esp32_companion_radio_full|\ + xiao_s3_wio_companion_radio_full|\ + heltec_tracker_v2_companion_radio_full_femon|\ + meshnology_w12_companion_radio_full|\ + nibble_screen_connect_companion_radio_full_|\ + nibble_zero_connect_companion_radio_full_|\ + heltec_v4_2_v4_3_companion_radio_full_femon|\ + heltec_v4_3_companion_radio_full_femoff|\ + heltec_v4_tft_companion_radio_full_femon|\ + heltec_v4_3_tft_companion_radio_full_femoff|\ + heltec_v4_r8_companion_radio_full|\ + heltec_v4_r8_tft_companion_radio_full) + return 0 + ;; + *) return 1 ;; + esac +} + +prepare_esp32_dual_cdc_framework() { + local env_name=$1 + local pio_env_name=$2 + + is_esp32_dual_cdc_companion_radio_full_target "$env_name" || return 0 + + # PlatformIO's standard ESP32 platform and pioarduino publish incompatible + # Arduino 2.x/3.x cores under the same global package name. `pio run` restores + # the standard core automatically, but pioarduino can otherwise proceed with + # no matching framework after a standard build. Enforce this environment's + # explicit platform_packages pins before SCons starts. + echo "Ensuring Arduino-ESP32 3.3.11 dependencies for ${env_name}..." + pio pkg install -e "$pio_env_name" +} + requires_esp32_companion_full_ota_fallback() { # Some non-PSRAM ESP32 companions cannot hold their configured high-capacity contact, # channel, and offline-queue tables together with BLE, WiFi, and LoRa OTA in @@ -2485,9 +2525,10 @@ apply_companion_radio_full_profile() { # Every full Companion is a LoRa mOTA source, never an update destination. # Remove inherited staging/install stores before adding the platform's host - # folder transport. - append_platformio_build_unflags "-UENABLE_OTA -DOTA_FLASH_STORE=1 -DOTA_SD_STORE=1 -DDISABLE_LORA_OTA=1" - 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" + # folder transport. Full also restores WebConfig when a constrained legacy + # WiFi sibling disabled it only to fit its smaller application partition. + append_platformio_build_unflags "-UENABLE_OTA -DOTA_FLASH_STORE=1 -DOTA_SD_STORE=1 -DDISABLE_LORA_OTA=1 -DWEBCONFIG_DISABLED=1" + export PLATFORMIO_BUILD_FLAGS="${PLATFORMIO_BUILD_FLAGS} -UDISABLE_LORA_OTA -DENABLE_OTA=1 -UOTA_FLASH_STORE -UOTA_SD_STORE -UWEBCONFIG_DISABLED -DOTA_SEEDER_ONLY=1 -DMOTA_TARGET_ID=0 -DCOMPANION_RADIO_FULL=1 -DENABLE_USB_INTERFACE=1 -DBLE_PIN_CODE=123456" if is_nrf52_companion_radio_full_target "$env_name"; then # CDC 0 starts as Binary Companion. `motatool serve --serial` switches it @@ -2507,6 +2548,16 @@ apply_companion_radio_full_profile() { return 0 fi + if is_esp32_dual_cdc_companion_radio_full_target "$env_name"; then + # Qualified ESP32-S3 boards route their data connector to the native USB + # peripheral. Arduino-ESP32 3.x supplies two real TinyUSB CDC ACM + # instances: CDC 0 carries framed Companion traffic and accepts the + # flashing reboot gesture; CDC 1 is a write-only plaintext diagnostics + # stream and cannot reboot the board. + append_platformio_build_unflags "-DARDUINO_USB_MODE=1 -DARDUINO_USB_CDC_ON_BOOT=0" + export PLATFORMIO_BUILD_FLAGS="${PLATFORMIO_BUILD_FLAGS} -DARDUINO_USB_MODE=0 -DARDUINO_USB_CDC_ON_BOOT=1 -DMESH_DUAL_CDC_LOGGING=1 -DMESH_DEBUG=1 -DMESH_PACKET_LOGGING=1" + fi + # ESP32 keeps both source transports. TCP remains the normal unattended # path; a host may explicitly hold the native USB port in Companion terminal # mode when WiFi is unavailable and serve the same folder protocol there. @@ -2788,6 +2839,7 @@ build_firmware() { local had_platformio_build_src_filter=0 local had_platformio_extra_scripts=0 local build_status + local platformio_package_lock_fd="" local -a pio_run_args=() # Bash functions use dynamic scoping. These locals let one target be promoted @@ -2928,6 +2980,27 @@ build_firmware() { fi print_build_flags "$env_name" + build_status=0 + if [ "$env_platform" = "ESP32_PLATFORM" ]; then + # Official ESP32 and pioarduino builds share global package names even + # though they require incompatible Arduino cores. Hold the lock through + # dependency selection and compilation so parallel profile workers cannot + # replace a framework while another ESP32 target is using it. + mkdir -p -- ".pio" + build_status=$? + if [ "$build_status" -eq 0 ]; then + exec {platformio_package_lock_fd}>".pio/esp32-platformio-packages.lock" + build_status=$? + fi + if [ "$build_status" -eq 0 ]; then + flock "$platformio_package_lock_fd" + build_status=$? + fi + fi + if [ "$build_status" -eq 0 ]; then + prepare_esp32_dual_cdc_framework "$env_name" "$pio_env_name" + build_status=$? + fi pio_run_args=(run -e "$pio_env_name") if [[ "${PIO_BUILD_JOBS_OVERRIDE:-}" =~ ^[1-9][0-9]*$ ]]; then pio_run_args+=(-j "$PIO_BUILD_JOBS_OVERRIDE") @@ -2937,12 +3010,18 @@ build_firmware() { # merges in one SCons invocation instead of paying startup twice. pio_run_args+=(-t mergebin) fi - pio "${pio_run_args[@]}" - build_status=$? + if [ "$build_status" -eq 0 ]; then + pio "${pio_run_args[@]}" + build_status=$? + fi if [ "$build_status" -eq 0 ]; then collect_build_artifacts "$env_name" "$env_platform" "$pio_env_name" "$firmware_filename" build_status=$? fi + if [ -n "$platformio_package_lock_fd" ]; then + flock -u "$platformio_package_lock_fd" + exec {platformio_package_lock_fd}>&- + fi restore_platformio_build_flags "$had_platformio_build_flags" "$original_platformio_build_flags" unset MESHCORE_ESP32_FULL_BUILD @@ -3048,8 +3127,64 @@ get_nrf52_full_companion_replacement() { printf '%s\n' "$full_env" } -is_nrf52_companion_transport_replaced_by_full() { - get_nrf52_full_companion_replacement "$1" >/dev/null +get_esp32_dual_cdc_full_companion_replacement() { + local source_env=$1 + local env_name=${source_env,,} + local full_env="" + + [ "${PIO_ENV_PLATFORM_BY_NAME[$1]:-}" = "ESP32_PLATFORM" ] || return 1 + case "$env_name" in + *companion_radio_wifi_mqtt*) return 1 ;; + *companion_radio_usb*|*companion_radio_ble*|*companion_radio_wifi*) ;; + *) return 1 ;; + esac + + # Full Companion includes USB, BLE, and ordinary WiFi Companion. Map legacy + # FEM-default aliases to the one runtime-configurable image for each physical + # V4 display/radio layout. Expansion-kit targets intentionally do not match: + # they have distinct sensor wiring and no corresponding Full recipe. + case "$env_name" in + heltec_v4_r8_tft_companion_radio_*) + full_env=heltec_v4_r8_tft_companion_radio_full + ;; + heltec_v4_r8_companion_radio_*) + full_env=heltec_v4_r8_companion_radio_full + ;; + heltec_v4_3_tft_companion_radio_*|heltec_v4_tft_companion_radio_*) + full_env=heltec_v4_tft_companion_radio_full_femon + ;; + heltec_v4_3_companion_radio_*|heltec_v4_companion_radio_*) + full_env=heltec_v4_2_v4_3_companion_radio_full_femon + ;; + *) + case "$env_name" in + *companion_radio_usb*) + full_env=${source_env/companion_radio_usb/companion_radio_full} + ;; + *companion_radio_ble*) + full_env=${source_env/companion_radio_ble/companion_radio_full} + ;; + *companion_radio_wifi*) + full_env=${source_env/companion_radio_wifi/companion_radio_full} + ;; + *) + return 1 + ;; + esac + ;; + esac + + is_esp32_dual_cdc_companion_radio_full_target "$full_env" || return 1 + printf '%s\n' "$full_env" +} + +get_full_companion_replacement() { + get_nrf52_full_companion_replacement "$1" 2>/dev/null \ + || get_esp32_dual_cdc_full_companion_replacement "$1" +} + +is_companion_transport_replaced_by_full() { + get_full_companion_replacement "$1" >/dev/null } is_runtime_setting_alias_target() { @@ -3065,17 +3200,17 @@ is_runtime_setting_alias_target() { 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. + # by a saved/default setting or by an attached transport already supplied by + # a dual-CDC Full Companion. if is_runtime_setting_alias_target "$1" \ - || is_nrf52_companion_transport_replaced_by_full "$1"; then + || is_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. + # Dual-CDC Full Companion replaces normal transport and USB-logging artifacts. # It exposes framed Companion traffic and plaintext logging as separate CDC # interfaces over one physical USB connection. resolve_all_firmwares @@ -3797,7 +3932,7 @@ run_logging_matrix_build_targets() { 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." + echo "Skipping ${full_companion_logging_skip_count} Full Companion target(s) for the separate logging-on pass; supported Full images can add a dedicated logging USB port after it is enabled and rebooted, while other Full images keep their single USB stream protocol-safe." fi for target in "${logging_targets[@]}"; do diff --git a/docs/_javascript/firmware_picker.js b/docs/_javascript/firmware_picker.js index 7f47fe69..e2e613b1 100644 --- a/docs/_javascript/firmware_picker.js +++ b/docs/_javascript/firmware_picker.js @@ -381,10 +381,31 @@ /(?:^|[_-])(?:ps|femoff)(?=$|[_-])/.test(lowerTarget)); } - function omitNrf52TransportsReplacedByFull(profiles) { + function isDualCdcFullCompanion(profile) { + if (!profile || profile.role !== "companion" || profile.mode !== "full") { + return false; + } + if (profile.installKinds.includes("zip")) return true; + const target = String(profile.target || "").toLowerCase(); + if (/^heltec_v4(?:_2_v4_3|_3)?(?:_r8)?(?:_tft)?_companion_radio_full(?:_|$)/ + .test(target)) { + return true; + } + return [ + "lilygo_tbeam_1w_companion_radio_full", + "station_g2_companion_radio_full", + "station_g3_esp32_companion_radio_full", + "xiao_s3_wio_companion_radio_full", + "heltec_tracker_v2_companion_radio_full_femon", + "meshnology_w12_companion_radio_full", + "nibble_screen_connect_companion_radio_full_", + "nibble_zero_connect_companion_radio_full_", + ].includes(target); + } + + function omitTransportsReplacedByDualCdcFull(profiles) { const fullKeys = new Set((profiles || []).filter(function (profile) { - return profile.role === "companion" && profile.mode === "full" && - profile.installKinds.includes("zip"); + return isDualCdcFullCompanion(profile); }).map(function (profile) { return profile.hardware + "\n" + profile.variant; })); @@ -392,20 +413,19 @@ return (profiles || []).filter(function (profile) { const key = profile.hardware + "\n" + profile.variant; const replacedAttachedTransport = profile.role === "companion" && - (profile.mode === "usb" || profile.mode === "ble"); + (profile.mode === "usb" || profile.mode === "ble" || + profile.mode === "wifi"); return !(replacedAttachedTransport && fullKeys.has(key)); }); } - function applyNrf52FullCompanionCapabilities(profiles) { + function applyDualCdcFullCompanionCapabilities(profiles) { return (profiles || []).map(function (profile) { - const nativeNrf52Full = profile.role === "companion" && - profile.mode === "full" && profile.installKinds.includes("zip"); - if (!nativeNrf52Full) return profile; + if (!isDualCdcFullCompanion(profile)) 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. + // Supported Full Companion images always expose framed Companion traffic + // on interface 00. Logging defaults off; enabling it and rebooting adds + // the independent plaintext CDC interface 02. profile.logging = "usb-runtime"; profile.loggingModes = ["none", "usb"]; profile.dedicatedUsbLogging = true; @@ -512,8 +532,8 @@ }).filter(function (profile) { return !isHiddenLegacyProfile(profile); }); - const profiles = omitNrf52TransportsReplacedByFull( - applyNrf52FullCompanionCapabilities(visibleProfiles) + const profiles = omitTransportsReplacedByDualCdcFull( + applyDualCdcFullCompanionCapabilities(visibleProfiles) ).sort(function (a, b) { return a.target.localeCompare(b.target, undefined, { numeric: true, @@ -712,11 +732,11 @@ ); } 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." + "Full Companion starts with USB logging off and only interface 00. Use get usb.logging, or set usb.logging on reboot to save logging on and reboot when needed; set usb.logging off reboot removes interface 02 again." ); 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." + "Interface 00 always carries Companion/terminal/mOTA traffic. After logging is enabled and the node reboots, interface 02 carries plaintext logs. Match services by USB interface number instead of assuming tty or COM numbering." ); } } @@ -757,7 +777,7 @@ if (profile.dedicatedUsbLogging) { factRows.splice(3, 0, [ "USB port split", - "Interface 00 Companion; interface 02 logging", + "Interface 00 always; interface 02 after logging on + reboot", ]); } replaceFacts(facts, factRows); @@ -1086,10 +1106,15 @@ flattenReleaseAssets: flattenReleaseAssets, parseFirmwareAsset: parseFirmwareAsset, parseTargetProfile: parseTargetProfile, + applyDualCdcFullCompanionCapabilities: + applyDualCdcFullCompanionCapabilities, applyNrf52FullCompanionCapabilities: - applyNrf52FullCompanionCapabilities, + applyDualCdcFullCompanionCapabilities, canonicalHardware: canonicalHardware, - omitNrf52TransportsReplacedByFull: omitNrf52TransportsReplacedByFull, + omitTransportsReplacedByDualCdcFull: + omitTransportsReplacedByDualCdcFull, + omitNrf52TransportsReplacedByFull: + omitTransportsReplacedByDualCdcFull, hardwareFamilyFor: hardwareFamilyFor, humanizeHardwareVariant: humanizeHardwareVariant, buildCatalog: buildCatalog, diff --git a/docs/cli_build_matrix.md b/docs/cli_build_matrix.md index bc2aa939..5c9013f0 100644 --- a/docs/cli_build_matrix.md +++ b/docs/cli_build_matrix.md @@ -41,7 +41,7 @@ retain 50 because their MQTT discovery tables are constrained by internal DRAM. | ESP32 MQTT observer or ESP-NOW bridge | Always uses the expanded FULL partition profile. The build never substitutes a reduced CLI to fit the legacy application slot. | | FULL ESP32 USB + WiFi | Uses the matching MQTT target with packet logging on, verbose debug off, and the complete command surface supported by that role and hardware. `get/set logging.output off\|usb\|wifi\|both` selects and persists the active output paths. | | FULL ESP32 logging fallback | Uses the matching non-MQTT target only when no WiFi MQTT sibling exists, with debug and packet logging enabled and the complete command surface supported by that role and hardware. Its persistent USB gate also covers output-off operation, avoiding a second FULL ESP-NOW image. | -| 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. | +| Dual-CDC Full Companion | nRF52 and qualified native-USB ESP32-S3 Full images use one physical USB connection. Fresh installs expose only interface `00` for framed Companion/terminal/mOTA traffic. Enabling logging and rebooting adds interface `02` for plaintext logs. They also provide BLE and source-only LoRa OTA; ESP32 additionally provides WiFi. `get/set usb.logging` persistently controls whether the logging interface is present. | | `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 @@ -61,19 +61,25 @@ available from a canonical image: 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. +- When a board has a dual-CDC Full Companion, that one artifact replaces its + separate USB, BLE, ordinary WiFi, and USB packet-logging Companion artifacts. + Current support includes nRF52 Full Companion plus qualified native-USB + ESP32-S3 Full targets: Heltec V4, T-Beam 1W, Station G2/G3, XIAO S3 WIO, + Heltec Tracker V2, Meshnology W12, and Nibble Screen/Zero Connect. RAK3112 + and Heltec RC32 keep separate transport and logging artifacts pending live + hardware validation. + It provides BLE plus an always-present interface `00` for framed + Companion/terminal/mOTA traffic. Enabling logging and rebooting adds + interface `02` for 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. +identity contracts. ESP32 boards without the tested dual-CDC Full profile keep +USB, BLE, WiFi, and Full Companion images separate because Full changes +partitions, RAM use, active transports, and power behavior. ## Complete CLI policy diff --git a/docs/cli_command_availability.md b/docs/cli_command_availability.md index 069cf811..9561e7fd 100644 --- a/docs/cli_command_availability.md +++ b/docs/cli_command_availability.md @@ -71,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`; 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 | +| 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; dual-CDC Full Companion adds/removes its second USB port after reboot | 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 | @@ -233,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`; 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 | +| 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; dual-CDC Full Companion adds/removes its second USB port after reboot | 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 11145b13..1e63fdb6 100644 --- a/docs/cli_commands.md +++ b/docs/cli_commands.md @@ -538,13 +538,30 @@ emitted only when that hardware/role has no matching WiFi MQTT environment. get usb.logging set usb.logging on set usb.logging off +set usb.logging on reboot +set usb.logging off reboot ``` These commands are compiled into logging artifacts and control their live USB 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. +it survives reboot; their first boot defaults to on. + +On Full Companion these lines belong to its text terminal, not `meshcli`'s +Binary `get/set` parameter namespace. Open interface `00`, send +`+++MESHCORE-TERM-START`, and then issue the command. Running +`meshcli ... get usb.logging` directly can instead return +`Unknown var usb.logging` because that is a different protocol operation. + +Dual-CDC Full Companion instead defaults logging to off and enumerates only +USB interface `00`, which carries Companion, terminal, and serial mOTA traffic. +Enabling logging adds interface `02`, its dedicated plaintext logging port, on +the next boot. Disabling it removes interface `02` on the next boot. A command +without the optional `reboot` argument saves the choice and reports that a +reboot is required when the USB interface count must change. The exact +`set usb.logging on reboot` and `set usb.logging off reboot` forms save the +choice, send their reply, and reboot one second later only when needed. This +behavior includes nRF52 and qualified native-USB ESP32-S3 Full images. + 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`. diff --git a/docs/companion_radio_full.md b/docs/companion_radio_full.md index e2b037b7..b0723a50 100644 --- a/docs/companion_radio_full.md +++ b/docs/companion_radio_full.md @@ -11,6 +11,7 @@ firmware as an mOTA image. | USB Binary Companion | Yes | Yes | | BLE Binary Companion | Yes | Yes | | USB ASCII terminal | Yes | Yes | +| Dedicated USB plaintext logging | Qualified native-USB ESP32-S3 profiles | Yes | | Host-backed LoRa mOTA source | WiFi TCP 5001 | Exclusive USB mode | | WiFi Companion/WebConfig | Yes | No - nRF52840 has no WiFi | | LoRa self-update | No | No | @@ -50,11 +51,12 @@ bash build.sh build-full-companion-firmwares \ 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 +for compatibility. Dual-CDC Full Companion replaces separate USB, BLE, +ordinary WiFi, and USB-only packet-logging release artifacts whenever the +exact board supports the combined profile. This includes nRF52 Full Companion +and the qualified native-USB ESP32-S3 profiles listed below. One physical USB +connection can enumerate 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 @@ -248,30 +250,61 @@ The terminal supports Companion chat commands, including `channels`, WiFi credential, status, WebConfig, CLI-tab, and power-save controls. Logging 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. +replies. Dual-CDC Full Companion saves this setting and applies it only to its +dedicated logging port. It starts off on a fresh installation. -### nRF52 dual USB serial ports +### Dual USB serial ports -Current nRF52 Full Companion firmware exposes two CDC ACM serial interfaces on -one physical USB cable: +Current nRF52 Full Companion and qualified native-USB ESP32-S3 Full Companion +firmware can expose 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. + mOTA source port. It is always present. +- USB interface `02` is the optional 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 +A fresh Full Companion starts with USB logging off and therefore enumerates +only interface `00`. Use `set usb.logging on` to save logging on; the reply says +that a reboot is required. Use `set usb.logging on reboot` to save it and have +the node reboot automatically after the reply. The second interface appears +after that reboot. Likewise, `set usb.logging off reboot` removes interface +`02`. The optional `reboot` word is accepted only in these exact command forms +and triggers a reboot only when the descriptor actually needs to change. + +These are Full Companion **text-terminal** commands. The superficially similar +`meshcli ... get usb.logging` command uses the Binary Companion parameter +registry and can report `Unknown var usb.logging`; it does not forward that +line to the text terminal. Enter terminal mode on interface `00` with the +`+++MESHCORE-TERM-START` token as described above, then issue the command. + +When logging is on, Linux normally shows 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. +depending on a particular COM number. The nRF52 bootloader temporarily exposes +its normal DFU serial interface during an update. Qualified S3 boards +temporarily expose the ESP32-S3 ROM USB-JTAG serial port during a wired flash. + +Qualified ESP32-S3 targets are Heltec V4, T-Beam 1W, Station G2/G3, XIAO S3 +WIO, Heltec Tracker V2, Meshnology W12, and Nibble Screen/Zero Connect. The +base Heltec V4 profile has completed live two-interface, ROM-flashing, and +logging-off one-interface validation. RAK3112 and Heltec RC32 retain separate +transport and logging images pending hardware validation. Boards whose +connector terminates at an external USB-UART bridge also keep their +transport-specific images: firmware cannot add a second USB interface to that +bridge chip. + +Every ESP32-S3 Full Companion image uses DIO flash mode, including the RAK3112 +and RC32 profiles that do not expose dual CDC. The S3 ROM supports DIO while +loading the software bootloader, and some flash configurations fail before the +application starts when a merged image inherits QIO. DIO trades some maximum +flash-read throughput for compatibility; it does not change a board's PSRAM +type or any ordinary non-Full firmware profile. 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. +When enabled and rebooted, point a plaintext reader or USB-connected MQTT +service at interface `02`. 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 056cb8ea..5297a42a 100644 --- a/docs/firmware_picker.md +++ b/docs/firmware_picker.md @@ -168,12 +168,22 @@ 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. +Current dual-CDC Full Companion profiles also use one binary for normal +attached Companion use and USB packet logging. This includes nRF52 and +qualified native-USB ESP32-S3 Full images. Fresh installs default to logging off and expose only +interface `00` for Binary Companion, terminal, and mOTA source traffic. Enabling +logging and rebooting adds interface `02` for plaintext logs. The picker +therefore omits older separate USB, BLE, ordinary WiFi, and USB-logging +Companion choices when the matching Full artifact exists. Use +`set usb.logging on reboot` or `set usb.logging off reboot` to persist the +choice and apply the corresponding USB interface count. + +Qualified ESP32-S3 hardware currently includes Heltec V4, T-Beam 1W, Station +G2/G3, XIAO S3 WIO, Heltec Tracker V2, Meshnology W12, and Nibble Screen/Zero +Connect layouts. The base Heltec V4 profile has completed live two-interface, +ROM-flashing, and logging-off one-interface validation. RAK3112 and Heltec RC32 +retain separate transport and logging images pending hardware validation, as do +Heltec V3/WSL3, ThinkNode M2/M5/M7/M9, classic ESP32, and ESP32-C3 targets. ## Installation methods @@ -210,8 +220,10 @@ 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. +For hardware with a dual-CDC Full Companion image, the picker recommends that +one normal image instead of separate USB, BLE, ordinary WiFi, and USB-logging +images. Full Companion provides the attached transports and a dedicated +plaintext logging port when enabled without mixing logs into framed Companion +traffic. Logging is off by default, so only the framed port appears until it is +enabled and the node reboots. +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 3a6da82b..d99db9b8 100644 --- a/docs/lora_ota_automation.md +++ b/docs/lora_ota_automation.md @@ -53,12 +53,14 @@ reopening the controller while `motatool` owns the source port. The script rejects an attempt to use the same port for both. 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 +the mOTA data framing. On a dual-CDC 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 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, +USB interface `00` into exclusive mOTA mode. If USB logging was enabled and the +Full Companion rebooted, its separate interface `02` continues to provide +plaintext logging and is not a controller or source port. With default logging +off, interface `02` is not enumerated. +On a native-USB ESP32-S3 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. diff --git a/docs/terminal_chat_cli.md b/docs/terminal_chat_cli.md index b3f464c2..745e89f8 100644 --- a/docs/terminal_chat_cli.md +++ b/docs/terminal_chat_cli.md @@ -94,11 +94,13 @@ GPS idle behavior; it does not change LoRa RXPS or WiFi modem sleep. ``` get usb.logging -set usb.logging {on|off} +set usb.logging {on|off} [reboot] ``` Shows or changes live USB debug and packet output in a Companion logging -artifact. The setting lasts until reboot; logging starts on again at the next -boot. Companion protocol frames and terminal replies remain enabled. +artifact. The setting is persistent. Dual-CDC Full Companion defaults off; +changing its USB interface count requires a reboot, and the optional exact +`reboot` argument performs that reboot after sending the reply. Companion +protocol frames and terminal replies remain enabled on interface `00`. ``` get radio.rxps diff --git a/examples/companion_radio/MyMesh.cpp b/examples/companion_radio/MyMesh.cpp index cf75c8a8..d3826343 100644 --- a/examples/companion_radio/MyMesh.cpp +++ b/examples/companion_radio/MyMesh.cpp @@ -1376,6 +1376,9 @@ MyMesh::MyMesh(mesh::Radio &radio, mesh::RNG &rng, mesh::RTCClock &rtc, SimpleMe command_radio_cr = 0; command_radio_repeat = 0; command_radio_apply_deadline = 0; +#if MESH_USB_LOGGING_AVAILABLE + _usb_logging_reboot_at = 0; +#endif #if defined(COMPANION_RADIO_FULL) _temp_radio_set_at = 0; _temp_radio_revert_at = 0; @@ -1437,7 +1440,13 @@ MyMesh::MyMesh(mesh::Radio &radio, mesh::RNG &rng, mesh::RTCClock &rtc, SimpleMe _prefs.powersaving_enabled = 1; _prefs.powersaving_policy_version = 0; _prefs.wifi_enabled = 1; +#if defined(COMPANION_RADIO_FULL) && defined(MESH_DUAL_CDC_LOGGING) + // Keep the primary CDC stream exclusively framed unless the owner opts in + // to the separate plaintext logging interface and reboots. + _prefs.usb_logging_enabled = 0; +#else _prefs.usb_logging_enabled = 1; +#endif recalcRxPowerSavingFromLevel(_prefs.rx_ps_level, _prefs.sf, _prefs.bw, _prefs.rx_ps_preamble, &_prefs.rx_ps_rx_us, &_prefs.rx_ps_sleep_us); @@ -1457,6 +1466,7 @@ MyMesh::MyMesh(mesh::Radio &radio, mesh::RNG &rng, mesh::RTCClock &rtc, SimpleMe void MyMesh::begin(bool has_display, bool radio_available) { _radio_available = radio_available; setRadioAvailable(radio_available); + initializeContactStorage(); initializeOfflineQueue(); BaseChatMesh::begin(); @@ -1545,7 +1555,11 @@ void MyMesh::begin(bool has_display, bool radio_available) { _store->savePrefs(_prefs, sensors.node_lat, sensors.node_lon); } #if MESH_USB_LOGGING_AVAILABLE - mesh::setUsbLoggingEnabled(_prefs.usb_logging_enabled != 0); + const bool usb_logging_enabled = _prefs.usb_logging_enabled != 0; + mesh::setUsbLoggingEnabled(usb_logging_enabled); + if (!mesh::saveUsbLoggingBootPreference(usb_logging_enabled)) { + MESH_DEBUG_PRINTLN("Unable to save next-boot USB logging interface state"); + } #endif #ifdef BLE_PIN_CODE // 123456 by default @@ -4925,8 +4939,11 @@ void MyMesh::handleTerminalCommand(char* command) { _prefs.powersaving_enabled ? "on" : "off"); #if MESH_USB_LOGGING_AVAILABLE } else if (strcmp(command, "get usb.logging") == 0) { - terminalOutput().printf(" usb.logging %s\r\n", - mesh::isUsbLoggingEnabled() ? "on" : "off"); + terminalOutput().printf( + " usb.logging %s%s\r\n", + mesh::isUsbLoggingEnabled() ? "on" : "off", + mesh::usbLoggingInterfaceRestartRequired() + ? " (reboot required to change USB interfaces)" : ""); #endif } else if (strncmp(command, "powersaving ", 12) == 0) { char reply[160]; @@ -4978,18 +4995,49 @@ void MyMesh::handleTerminalCommand(char* command) { || config[11] == '\t')) { const char* value = config + 11; while (*value == ' ' || *value == '\t') value++; - if (strcmp(value, "on") != 0 && strcmp(value, "off") != 0) { - terminalOutput().print(" ERROR: use set usb.logging \r\n"); + bool enabled = false; + bool reboot_if_needed = false; + bool valid = true; + if (strcmp(value, "on") == 0) { + enabled = true; + } else if (strcmp(value, "off") == 0) { + enabled = false; + } else if (strcmp(value, "on reboot") == 0) { + enabled = true; + reboot_if_needed = true; + } else if (strcmp(value, "off reboot") == 0) { + enabled = false; + reboot_if_needed = true; + } else { + valid = false; + } + + if (!valid) { + terminalOutput().print( + " ERROR: use set usb.logging [reboot]\r\n"); } else { - const bool enabled = strcmp(value, "on") == 0; _prefs.usb_logging_enabled = enabled ? 1 : 0; mesh::setUsbLoggingEnabled(enabled); - if (savePrefs()) { - terminalOutput().printf(" OK - USB logging %s (saved)\r\n", - enabled ? "on" : "off"); - } else { + if (!savePrefs()) { terminalOutput().print( " ERROR: USB logging changed for this boot but save failed\r\n"); + } else if (!mesh::saveUsbLoggingBootPreference(enabled)) { + terminalOutput().print( + " ERROR: setting saved, but next-boot USB interface state could not be saved\r\n"); + } else if (mesh::usbLoggingInterfaceRestartRequired()) { + if (reboot_if_needed) { + terminalOutput().printf( + " OK - USB logging %s (saved); rebooting to change USB interfaces\r\n", + enabled ? "on" : "off"); + _usb_logging_reboot_at = futureMillis(1000); + } else { + terminalOutput().printf( + " OK - USB logging %s (saved); reboot required to change USB interfaces\r\n", + enabled ? "on" : "off"); + } + } else { + terminalOutput().printf(" OK - USB logging %s (saved)\r\n", + enabled ? "on" : "off"); } } } else @@ -5090,7 +5138,7 @@ void MyMesh::handleTerminalCommand(char* command) { terminalOutput().print(" powersaving [on|off]\r\n"); #if MESH_USB_LOGGING_AVAILABLE terminalOutput().print(" get usb.logging\r\n"); - terminalOutput().print(" set usb.logging \r\n"); + terminalOutput().print(" set usb.logging [reboot]\r\n"); #endif #if defined(ESP32) && defined(WIFI_SSID) terminalOutput().print(" get wifi.powersave\r\n"); @@ -5361,6 +5409,14 @@ void MyMesh::checkSerialInterface() { } void MyMesh::loop() { +#if MESH_USB_LOGGING_AVAILABLE + if (_usb_logging_reboot_at != 0 + && millisHasNowPassed(_usb_logging_reboot_at)) { + _usb_logging_reboot_at = 0; + board.reboot(); + return; + } +#endif #if defined(COMPANION_RADIO_FULL) serviceTempRadio(); #endif diff --git a/examples/companion_radio/MyMesh.h b/examples/companion_radio/MyMesh.h index e43ed3f9..5a1c357b 100644 --- a/examples/companion_radio/MyMesh.h +++ b/examples/companion_radio/MyMesh.h @@ -415,6 +415,9 @@ private: uint8_t command_radio_cr; uint8_t command_radio_repeat; unsigned long command_radio_apply_deadline; +#if MESH_USB_LOGGING_AVAILABLE + unsigned long _usb_logging_reboot_at; +#endif #if defined(COMPANION_RADIO_FULL) unsigned long _temp_radio_set_at; unsigned long _temp_radio_revert_at; diff --git a/examples/companion_radio/main.cpp b/examples/companion_radio/main.cpp index ac9c8da4..ea434f33 100644 --- a/examples/companion_radio/main.cpp +++ b/examples/companion_radio/main.cpp @@ -12,6 +12,13 @@ #else #define COMPANION_IDF_PM_AVAILABLE 0 #endif +#if defined(BLE_PIN_CODE) && !CONFIG_IDF_TARGET_ESP32C6 \ + && (defined(CONFIG_BT_CTRL_MODEM_SLEEP) \ + || defined(CONFIG_BTDM_CTRL_MODEM_SLEEP)) +#define COMPANION_BT_MODEM_SLEEP_AVAILABLE 1 +#else +#define COMPANION_BT_MODEM_SLEEP_AVAILABLE 0 +#endif #if defined(COMPANION_RADIO_FULL) #include #endif @@ -180,6 +187,22 @@ static uint32_t companionNominalCpuMhz() { #endif } +#if COMPANION_BT_MODEM_SLEEP_AVAILABLE +static void applyCompanionBluetoothSleep(bool enabled, + bool allow_uninitialized) { + esp_err_t result = enabled ? esp_bt_sleep_enable() + : esp_bt_sleep_disable(); + if (result == ESP_OK + || (allow_uninitialized && result == ESP_ERR_INVALID_STATE)) { + return; + } + + mesh::usbLoggingPort().printf("Bluetooth sleep %s failed: %s\r\n", + enabled ? "enable" : "disable", + esp_err_to_name(result)); +} +#endif + static bool applyCompanionPowerSaving(bool enabled) { const uint32_t nominal_mhz = companionNominalCpuMhz(); const uint32_t max_mhz = enabled && nominal_mhz > 80 ? 80 : nominal_mhz; @@ -224,14 +247,13 @@ static bool applyCompanionPowerSaving(bool enabled) { } #endif -#if defined(BLE_PIN_CODE) && !CONFIG_IDF_TARGET_ESP32C6 - esp_err_t bt_result = enabled ? esp_bt_sleep_enable() - : esp_bt_sleep_disable(); - if (bt_result != ESP_OK) { - mesh::usbLoggingPort().printf("Bluetooth sleep %s failed: %s\r\n", - enabled ? "enable" : "disable", - esp_err_to_name(bt_result)); - } +#if COMPANION_BT_MODEM_SLEEP_AVAILABLE + // Power saving is applied before the wireless interfaces are constructed. + // An uninitialized controller is expected here; apply the saved setting + // again immediately after BLEDevice::init() below. Some prebuilt ESP-IDF + // targets omit Bluetooth modem sleep entirely; the compile-time guard avoids + // calling an API which can only return ESP_ERR_NOT_SUPPORTED in those builds. + applyCompanionBluetoothSleep(enabled, true); #endif #if COMPANION_IDF_PM_AVAILABLE @@ -945,32 +967,47 @@ void halt() { #if defined(BLE_PIN_CODE) static bool companion_bluetooth_initialized = false; -#if defined(ESP32) && defined(COMPANION_RADIO_FULL) && defined(WIFI_SSID) static uint32_t companion_bluetooth_start_at = 0; -#endif + static constexpr uint32_t COMPANION_BLUETOOTH_RETRY_MS = 5000UL; + + static void scheduleCompanionBluetoothRetry() { + companion_bluetooth_start_at = millis() + COMPANION_BLUETOOTH_RETRY_MS; + if (companion_bluetooth_start_at == 0) companion_bluetooth_start_at = 1; + } static void startCompanionBluetooth() { if (companion_bluetooth_initialized) return; + companion_bluetooth_start_at = 0; 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)) { mesh::usbLoggingPort().println( - "Companion: no interface slot available for Bluetooth"); + "Companion: no interface slot available for Bluetooth; retrying"); + scheduleCompanionBluetoothRetry(); return; } + if (!bluetooth_interface.begin(BLE_NAME_PREFIX, + the_mesh.getNodePrefs()->node_name, + the_mesh.getBLEPin())) { + interface_manager.removeInterface(&bluetooth_interface); + mesh::usbLoggingPort().println( + "Companion: Bluetooth initialization failed; retrying in 5 seconds"); + scheduleCompanionBluetoothRetry(); + return; + } +#if defined(ESP32_PLATFORM) && COMPANION_BT_MODEM_SLEEP_AVAILABLE + applyCompanionBluetoothSleep( + the_mesh.getNodePrefs()->powersaving_enabled != 0, false); +#endif companion_bluetooth_initialized = true; if (interface_manager.isEnabled()) bluetooth_interface.enable(); } static void serviceDeferredCompanionBluetooth() { if (companion_bluetooth_initialized) return; -#if defined(ESP32) && defined(COMPANION_RADIO_FULL) && defined(WIFI_SSID) if (companion_bluetooth_start_at == 0 || (int32_t)(millis() - companion_bluetooth_start_at) < 0) return; -#endif startCompanionBluetooth(); } #endif @@ -1109,6 +1146,11 @@ void setup() { #error "need to define filesystem" #endif + // nRF52 cannot decide whether to add its optional logging CDC interface + // until the saved Companion preferences above are available. ESP32 already + // fixed its descriptor from the early NVS mirror, so this is harmless there. + mesh::beginUsbLoggingPort(); + // Load WiFi state before bringing up either wireless stack. #ifdef WIFI_SSID loadCompanionWiFiCredentials(); @@ -1118,6 +1160,14 @@ void setup() { logFullCompanionMemory("before interfaces"); #endif +// NimBLE must reserve its internal controller/host memory before WiFi starts +// its AP and WebConfig services. Starting WiFi first can leave plenty of total +// heap but no internal block large enough for nimble_port_init(). +#if defined(BLE_PIN_CODE) && defined(ESP32) \ + && defined(COMPANION_RADIO_FULL) && defined(WIFI_SSID) + startCompanionBluetooth(); +#endif + // add wifi interface #ifdef WIFI_SSID WiFi.onEvent([](WiFiEvent_t event, WiFiEventInfo_t info){ @@ -1147,16 +1197,11 @@ void setup() { logFullCompanionMemory("after WiFi start"); #endif -// The Full ESP32 profile starts WiFi/WebConfig first, gives its AP/server two -// seconds to settle and reserve heap, then starts BLE from loop(). Other BLE -// companions keep their existing immediate startup. +// Full ESP32 WiFi+BLE companions started BLE above so its controller memory +// could not be fragmented by WiFi. Other BLE companions start here. #if defined(BLE_PIN_CODE) - #if defined(ESP32) && defined(COMPANION_RADIO_FULL) && defined(WIFI_SSID) - companion_bluetooth_start_at = millis() + 2000UL; - if (companion_bluetooth_start_at == 0) companion_bluetooth_start_at = 1; - mesh::usbLoggingPort().println( - "Companion: Bluetooth starts in 2 seconds"); - #else + #if !(defined(ESP32) && defined(COMPANION_RADIO_FULL) \ + && defined(WIFI_SSID)) startCompanionBluetooth(); #endif #endif diff --git a/mesh-america/update-logging-provider-release.py b/mesh-america/update-logging-provider-release.py index a4dbf2c2..a644cd8b 100755 --- a/mesh-america/update-logging-provider-release.py +++ b/mesh-america/update-logging-provider-release.py @@ -140,10 +140,12 @@ def update_catalog(catalog: dict, release_files: dict[str, list[Path]], args: ar "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." + "USB serial log and publish it separately. Dual-CDC Full Companion " + "defaults to its single framed USB interface; enabling logging and " + "rebooting adds a separate plaintext interface, replacing older " + "USB-logging images on nRF52 and qualified native-USB ESP32-S3. " + "Open Release notes for role, hardware, installation, and partition " + "requirements." ) entry_count = 0 @@ -277,6 +279,14 @@ def update_catalog(catalog: dict, release_files: dict[str, list[Path]], args: ar notes = common.normalize_nrf52_full_companion_metadata( firmware, notes ) + elif device_type == "esp32" and any( + common.ESP32_DUAL_CDC_FULL_RE.match(identity) + is not None + for identity in identities + ): + notes = common.normalize_esp32_dual_cdc_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 " diff --git a/mesh-america/update-provider-release.py b/mesh-america/update-provider-release.py index baed39bf..dc0cf683 100644 --- a/mesh-america/update-provider-release.py +++ b/mesh-america/update-provider-release.py @@ -19,6 +19,15 @@ VERSION_LABEL_RE = re.compile(r"v\d+(?:\.\d+)+") CATALOG_VERSION_TEXT_RE = re.compile( r"Keymind Cascade MeshCore v[0-9][A-Za-z0-9_.-]*" ) +ESP32_DUAL_CDC_FULL_RE = re.compile( + r"^(?:" + r"heltec_v4(?:_2_v4_3|_3)?(?:_r8)?(?:_tft)?|" + r"LilyGo_TBeam_1W|Station_G2|Station_G3_ESP32|Xiao_S3_WIO|" + r"heltec_tracker_v2|meshnology_w12|" + r"nibble_(?:screen|zero)_connect" + r")_companion_radio_full(?:_|$)", + flags=re.IGNORECASE, +) LEGACY_PORTABLE_CEILING_EXCEPTIONS = { "heltec_ct62_repeater_lora_ota_no_external_sensors", @@ -169,6 +178,12 @@ def canonical_runtime_identity(identity: str) -> str: result, flags=re.IGNORECASE, ) + result = re.sub( + r"^heltec_v4_companion_radio_full(?:_femon)?(?=-|$)", + "heltec_v4_2_v4_3_companion_radio_full_femon", + result, + flags=re.IGNORECASE, + ) return result @@ -280,20 +295,23 @@ def normalize_nrf52_full_companion_metadata( """Describe the canonical dual-CDC nRF52 Full Companion accurately.""" firmware["title"] = "Full Companion" firmware["subTitle"] = ( - "USB Companion + USB logging + BLE + LoRa OTA source" + "USB Companion + optional USB logging + BLE + LoRa OTA source" ) profile = ( - "PROFILE - nRF52 Full Companion: one USB cable exposes interface 00 " + "PROFILE - nRF52 Full Companion: one USB cable always 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." + "traffic. Fresh installs default USB logging off and do not enumerate " + "interface 02. Enabling logging and rebooting adds interface 02 as a " + "separate plaintext packet/debug port. BLE remains available. Use " + "set usb.logging on reboot or set usb.logging off reboot to save and " + "apply the interface count." ) 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." + "interface 00. After enabling logging and rebooting, 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 " @@ -313,8 +331,8 @@ def normalize_nrf52_full_companion_metadata( continue if paragraph.startswith("SELECTION "): paragraphs.append( - "SELECTION - nRF52 Full Companion with dual USB serial ports, " - "BLE, and source-only LoRa OTA." + "SELECTION - nRF52 Full Companion with an optional second USB " + "logging port, BLE, and source-only LoRa OTA." ) continue paragraphs.append(paragraph) @@ -326,12 +344,95 @@ def normalize_nrf52_full_companion_metadata( return "\n\n".join(paragraphs) +def normalize_esp32_dual_cdc_full_companion_metadata( + firmware: dict, notes: str +) -> str: + """Describe a native-USB ESP32-S3 Full Companion accurately.""" + firmware["title"] = "Full Companion" + firmware["subTitle"] = ( + "USB Companion + optional USB logging + BLE + Wi-Fi + LoRa OTA source" + ) + profile = ( + "PROFILE - Native-USB ESP32-S3 Full Companion: one USB cable always " + "exposes interface 00 for Binary Companion, the text terminal, flashing, and " + "serial mOTA source traffic. Fresh installs default USB logging off and " + "do not enumerate interface 02. Enabling logging and rebooting adds " + "interface 02 as a separate plaintext packet/debug port. BLE, Wi-Fi " + "Companion on TCP 5000, " + "WebConfig, TCP mOTA seeding on 5001, and the text terminal on 5002 " + "remain available. Use set usb.logging on reboot or set usb.logging " + "off reboot to save and apply the interface count." + ) + logging_use = ( + "LOGGING USE - Point Companion software, meshcli, motatool, and wired " + "flashing at USB interface 00. After enabling logging and rebooting, " + "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 and cannot reboot the board." + ) + selection = ( + "SELECTION - One Full image for this exact native-USB hardware layout " + "replaces separate USB, BLE, ordinary Wi-Fi, and USB-logging images." + ) + + paragraphs: list[str] = [] + profile_added = False + selection_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 "): + if not selection_added: + paragraphs.append(selection) + selection_added = True + continue + paragraphs.append(paragraph) + if not profile_added: + paragraphs.append(profile) + if not selection_added: + paragraphs.append(selection) + paragraphs.append(logging_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 canonical_full_identity_for_transport(identity: str) -> str | None: + match = re.search( + r"_companion_radio_(?:usb|ble|wifi(?!_mqtt))(?=-|_|$)", + identity, + flags=re.IGNORECASE, + ) + if match is None: + return None + full_identity = ( + identity[:match.start()] + "_companion_radio_full" + + identity[match.end():] + ) + return canonical_runtime_identity(full_identity) + + +def release_identity_is_dual_cdc_full( + release_files: dict[str, list[Path]], identity: str +) -> bool: + return ( + identity in release_files and + ( + release_identity_has_nrf52_package(release_files, identity) or + ESP32_DUAL_CDC_FULL_RE.match(identity) is not None + ) + ) + + def resolve_release_identity( old_identity: str, release_files: dict[str, list[Path]] ) -> tuple[str, bool]: @@ -340,47 +441,27 @@ def resolve_release_identity( if candidate in release_files: return candidate, 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. + # Dual-CDC Full Companion replaces the old USB-only logging artifact because + # its second CDC interface carries plaintext logs. Limit this fallback to a + # native nRF52 Full package or a qualified native-USB ESP32-S3 identity. 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( + full_identity = canonical_full_identity_for_transport(logging_base) + if full_identity is not None and release_identity_is_dual_cdc_full( release_files, full_identity ): return full_identity, False - # 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. + # Canonical dual-CDC Full Companion replaces separate USB, BLE, and ordinary + # Wi-Fi artifacts. Other ESP32 transport-specific images remain separate. + # Full is source-only for LoRa OTA and needs no staging/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: + full_identity = canonical_full_identity_for_transport(candidate) + if full_identity is not None and release_identity_is_dual_cdc_full( + release_files, full_identity + ): return full_identity, False # Accept catalogs produced while portable MQTT observers were still @@ -652,10 +733,12 @@ def update_catalog(catalog: dict, release_files: dict[str, list[Path]], args: ar "BW62.5 / CR5 preset. This catalog contains standard builds, Full " "Companion builds, lean LoRa-OTA builds, and expanded-partition FULL " "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, " + "provide persistent off/USB/WiFi/both output selection. Dual-CDC Full " + "Companion replaces separate BLE, USB, ordinary Wi-Fi, and USB-logging " + "choices with one image. It defaults to one framed USB port; enabling " + "logging and rebooting adds the separate plaintext port. " + "This applies to nRF52 Full and qualified native-USB ESP32-S3 Full; " + "USB-UART bridge variants remain separate. Open Release notes for role, " "hardware, installation, and partition requirements." ) @@ -750,6 +833,13 @@ def update_catalog(catalog: dict, release_files: dict[str, list[Path]], args: ar notes = normalize_nrf52_full_companion_metadata( firmware, notes ) + elif device_type == "esp32" and any( + ESP32_DUAL_CDC_FULL_RE.match(identity) is not None + for identity in resolved_identities + ): + notes = normalize_esp32_dual_cdc_full_companion_metadata( + firmware, notes + ) firmware["version"] = {version_key: {"notes": notes, "files": files}} updated_entries += 1 diff --git a/platformio.ini b/platformio.ini index 017e3f94..a5df2b5c 100644 --- a/platformio.ini +++ b/platformio.ini @@ -104,6 +104,35 @@ lib_deps = extends = esp32_base platform = https://github.com/pioarduino/platform-espressif32/releases/download/53.03.13-1/platform-espressif32.zip +; ESP32-S3 Full Companion images use the ROM-compatible dual-I/O flash mode. +; This is independent of the board's PSRAM type and of whether the USB +; connector exposes the S3 native USB peripheral. Ordinary non-Full profiles +; keep their existing board-specific flash mode. +[esp32_s3_full] +board_build.flash_mode = dio + +; Full Companion profiles on boards whose connector is wired to the ESP32-S3 +; native USB peripheral use Arduino-ESP32 3.x TinyUSB. This supplies two real +; CDC ACM interfaces: CDC 0 for framed Companion traffic and optional CDC 1 +; for plaintext diagnostics. Do not apply this section to USB-UART-bridge +; boards; firmware cannot add an interface to an external bridge chip. +[esp32_s3_dual_cdc_full] +platform = https://github.com/pioarduino/platform-espressif32/releases/download/55.03.311/platform-espressif32.zip +; Pin the matching Arduino core as an explicit project dependency. Standard +; ESP32 profiles use Arduino 2.x under the same PlatformIO package name, so an +; alternating build otherwise can leave this Arduino 3.x profile resolving the +; wrong global framework directory (or no compatible directory at all). +platform_packages = + framework-arduinoespressif32 @ https://github.com/espressif/arduino-esp32/releases/download/3.3.11/esp32-core-3.3.11.tar.xz + framework-arduinoespressif32-libs @ https://github.com/espressif/arduino-esp32/releases/download/3.3.11/esp32-core-3.3.11-libs.tar.xz +board_build.flash_mode = ${esp32_s3_full.board_build.flash_mode} +build_unflags = + -D ARDUINO_USB_MODE=1 + -D ARDUINO_USB_CDC_ON_BOOT=0 +build_flags = + -D ARDUINO_USB_MODE=0 + -D ARDUINO_USB_CDC_ON_BOOT=1 + ; ----------------- NRF52 --------------------- [nrf52_base] diff --git a/src/helpers/BaseChatMesh.cpp b/src/helpers/BaseChatMesh.cpp index 1a689267..f9af2b17 100644 --- a/src/helpers/BaseChatMesh.cpp +++ b/src/helpers/BaseChatMesh.cpp @@ -3,6 +3,9 @@ #include #include #include +#if defined(ESP32_PLATFORM) && defined(BOARD_HAS_PSRAM) +#include +#endif #ifndef SERVER_RESPONSE_DELAY #define SERVER_RESPONSE_DELAY 300 @@ -19,6 +22,36 @@ bool BaseChatMesh::sendFloodScoped(const mesh::GroupChannel& channel, mesh::Pack return sendFlood(pkt, delay_millis); } +bool BaseChatMesh::initializeContactStorage() { +#if defined(ESP32_PLATFORM) && defined(BOARD_HAS_PSRAM) + const int requested_capacity = MAX_CONTACTS + MAX_ANON_CONTACTS; + if (contact_capacity >= requested_capacity) return true; + + ContactInfo* expanded_contacts = static_cast( + heap_caps_calloc(requested_capacity, sizeof(ContactInfo), + MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT)); + int* expanded_sort_array = static_cast( + heap_caps_calloc(requested_capacity, sizeof(int), + MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT)); + if (expanded_contacts == NULL || expanded_sort_array == NULL) { + if (expanded_contacts != NULL) heap_caps_free(expanded_contacts); + if (expanded_sort_array != NULL) heap_caps_free(expanded_sort_array); + MESH_DEBUG_PRINTLN( + "BaseChatMesh: PSRAM contact allocation failed; using %d slots", + contact_capacity - MAX_ANON_CONTACTS); + return false; + } + + memcpy(expanded_contacts, contacts, sizeof(ContactInfo) * num_contacts); + contacts = expanded_contacts; + sort_array = expanded_sort_array; + contact_capacity = requested_capacity; + MESH_DEBUG_PRINTLN("BaseChatMesh: allocated %d contact slots in PSRAM", + MAX_CONTACTS); +#endif + return true; +} + mesh::Packet* BaseChatMesh::createSelfAdvert(const char* name) { uint8_t app_data[MAX_ADVERT_DATA_SIZE]; uint8_t app_data_len; @@ -86,7 +119,11 @@ ContactInfo* BaseChatMesh::allocateContactSlot(bool transient_only) { return &contacts[oldest_idx]; } } else { +#if defined(ESP32_PLATFORM) && defined(BOARD_HAS_PSRAM) + if (num_contacts < contact_capacity) { +#else if (num_contacts < MAX_ANON_CONTACTS+MAX_CONTACTS) { +#endif return &contacts[num_contacts++]; } else if (shouldOverwriteWhenFull()) { // Find oldest non-favourite contact by oldest lastmod timestamp diff --git a/src/helpers/BaseChatMesh.h b/src/helpers/BaseChatMesh.h index fe4b74b4..96489476 100644 --- a/src/helpers/BaseChatMesh.h +++ b/src/helpers/BaseChatMesh.h @@ -61,9 +61,23 @@ class BaseChatMesh : public mesh::Mesh { friend class ContactsIterator; +#if defined(ESP32_PLATFORM) && defined(BOARD_HAS_PSRAM) + enum { + CONTACT_PSRAM_FALLBACK_REGULAR_SLOTS = + MAX_CONTACTS < 16 ? MAX_CONTACTS : 16, + CONTACT_PSRAM_FALLBACK_TOTAL_SLOTS = + MAX_ANON_CONTACTS + CONTACT_PSRAM_FALLBACK_REGULAR_SLOTS + }; + ContactInfo* contacts; + ContactInfo contacts_fallback[CONTACT_PSRAM_FALLBACK_TOTAL_SLOTS]; + int* sort_array; + int sort_array_fallback[CONTACT_PSRAM_FALLBACK_TOTAL_SLOTS]; + int contact_capacity; +#else ContactInfo contacts[MAX_CONTACTS+MAX_ANON_CONTACTS]; - int num_contacts; int sort_array[MAX_CONTACTS+MAX_ANON_CONTACTS]; +#endif + int num_contacts; int matching_peer_indexes[MAX_SEARCH_RESULTS]; unsigned long txt_send_timeout; #ifdef MAX_GROUP_CHANNELS @@ -81,6 +95,11 @@ protected: BaseChatMesh(mesh::Radio& radio, mesh::MillisecondClock& ms, mesh::RNG& rng, mesh::RTCClock& rtc, mesh::PacketManager& mgr, mesh::MeshTables& tables) : mesh::Mesh(radio, ms, rng, rtc, mgr, tables) { +#if defined(ESP32_PLATFORM) && defined(BOARD_HAS_PSRAM) + contacts = contacts_fallback; + sort_array = sort_array_fallback; + contact_capacity = CONTACT_PSRAM_FALLBACK_TOTAL_SLOTS; +#endif resetContacts(); #ifdef MAX_GROUP_CHANNELS @@ -93,6 +112,7 @@ protected: } void bootstrapRTCfromContacts(); + bool initializeContactStorage(); void resetContacts() { memset(contacts, 0, sizeof(contacts[0])*MAX_ANON_CONTACTS); // set all to have type = ADV_TYPE_NONE(0) diff --git a/src/helpers/ESP32Board.h b/src/helpers/ESP32Board.h index f282845d..1d52c849 100644 --- a/src/helpers/ESP32Board.h +++ b/src/helpers/ESP32Board.h @@ -53,8 +53,10 @@ public: #ifdef PIN_VBAT_READ // battery read support pinMode(PIN_VBAT_READ, INPUT); + #if ESP_ARDUINO_VERSION_MAJOR < 3 adcAttachPin(PIN_VBAT_READ); #endif + #endif #ifdef P_LORA_TX_LED pinMode(P_LORA_TX_LED, OUTPUT); diff --git a/src/helpers/UsbLogging.cpp b/src/helpers/UsbLogging.cpp index 10143d93..e09d411d 100644 --- a/src/helpers/UsbLogging.cpp +++ b/src/helpers/UsbLogging.cpp @@ -3,24 +3,116 @@ #if defined(ARDUINO) #include #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" + #if !defined(COMPANION_RADIO_FULL) + #error "MESH_DUAL_CDC_LOGGING is only supported by Full Companion" #endif - #if !defined(CFG_TUD_CDC) || CFG_TUD_CDC < 2 - #error "MESH_DUAL_CDC_LOGGING requires CFG_TUD_CDC >= 2" + #if defined(NRF52_PLATFORM) + #define MESH_NRF52_DUAL_CDC_LOGGING 1 + #include + #if !defined(CFG_TUD_CDC) || CFG_TUD_CDC < 2 + #error "MESH_DUAL_CDC_LOGGING requires CFG_TUD_CDC >= 2" + #endif + #elif defined(ESP32_PLATFORM) && defined(SOC_USB_OTG_SUPPORTED) && \ + SOC_USB_OTG_SUPPORTED && !ARDUINO_USB_MODE + #define MESH_ESP32_DUAL_CDC_LOGGING 1 + #include + #include + #include + #include + #if !defined(CONFIG_TINYUSB_CDC_MAX_PORTS) || \ + CONFIG_TINYUSB_CDC_MAX_PORTS < 2 + #error "MESH_DUAL_CDC_LOGGING requires two ESP32 TinyUSB CDC ports" + #endif + #else + #error "MESH_DUAL_CDC_LOGGING requires nRF52 or ESP32 native TinyUSB" #endif - #include #endif namespace mesh { static std::atomic usb_logging_enabled{true}; +static std::atomic usb_logging_preference_known{false}; + +class NullUsbLoggingStream : public Stream { + public: + int available() override { return 0; } + int read() override { return -1; } + int peek() override { return -1; } + void flush() override {} + size_t write(uint8_t) override { return 1; } + size_t write(const uint8_t*, size_t size) override { return size; } +}; + +static NullUsbLoggingStream null_usb_logging_stream; #if defined(MESH_DUAL_CDC_LOGGING) -static Adafruit_USBD_CDC dedicated_usb_logging_port; +static bool dedicated_usb_logging_port_configured = false; static bool dedicated_usb_logging_port_started = false; +#if defined(MESH_NRF52_DUAL_CDC_LOGGING) +static Adafruit_USBD_CDC dedicated_usb_logging_port; +#elif defined(MESH_ESP32_DUAL_CDC_LOGGING) +static constexpr char USB_LOGGING_NVS_NAMESPACE[] = "mesh_usb"; +static constexpr char USB_LOGGING_NVS_KEY[] = "log_port"; +alignas(USBCDC) static uint8_t dedicated_usb_logging_port_storage[ + sizeof(USBCDC)]; +static USBCDC* dedicated_usb_logging_port = nullptr; + +static bool readEsp32UsbLoggingBootPreference() { + // Missing or unreadable state must stay protocol-safe: Full Companion then + // exposes only its primary framed CDC interface. The normal preference load + // below mirrors an explicitly enabled setting for the following reboot. + if (nvs_flash_init() != ESP_OK) return false; + + nvs_handle_t handle; + if (nvs_open(USB_LOGGING_NVS_NAMESPACE, NVS_READONLY, &handle) != ESP_OK) { + return false; + } + + uint8_t value = 0; + const esp_err_t result = nvs_get_u8(handle, USB_LOGGING_NVS_KEY, &value); + nvs_close(handle); + return result == ESP_OK && value != 0; +} + +static bool writeEsp32UsbLoggingBootPreference(bool enabled) { + if (nvs_flash_init() != ESP_OK) return false; + + nvs_handle_t handle; + if (nvs_open(USB_LOGGING_NVS_NAMESPACE, NVS_READWRITE, &handle) != ESP_OK) { + return false; + } + + uint8_t current = 0; + esp_err_t result = nvs_get_u8(handle, USB_LOGGING_NVS_KEY, ¤t); + if (result == ESP_OK && current == (enabled ? 1 : 0)) { + nvs_close(handle); + return true; + } + result = nvs_set_u8(handle, USB_LOGGING_NVS_KEY, enabled ? 1 : 0); + if (result == ESP_OK) result = nvs_commit(handle); + nvs_close(handle); + return result == ESP_OK; +} + +class Esp32UsbLoggingBootstrap { + public: + Esp32UsbLoggingBootstrap() { + const bool enabled = readEsp32UsbLoggingBootPreference(); + usb_logging_enabled.store(enabled, std::memory_order_relaxed); + usb_logging_preference_known.store(true, std::memory_order_relaxed); + if (!enabled) return; + + dedicated_usb_logging_port = new (dedicated_usb_logging_port_storage) + USBCDC(1); + dedicated_usb_logging_port_configured = true; + } +}; + +static Esp32UsbLoggingBootstrap esp32_usb_logging_bootstrap; +#endif #endif bool isUsbLoggingEnabled() { @@ -29,15 +121,43 @@ bool isUsbLoggingEnabled() { void setUsbLoggingEnabled(bool enabled) { usb_logging_enabled.store(enabled, std::memory_order_relaxed); + usb_logging_preference_known.store(true, std::memory_order_relaxed); +} + +bool saveUsbLoggingBootPreference(bool enabled) { +#if defined(MESH_ESP32_DUAL_CDC_LOGGING) + return writeEsp32UsbLoggingBootPreference(enabled); +#else + (void)enabled; + return true; +#endif } void beginUsbLoggingPort() { #if defined(MESH_DUAL_CDC_LOGGING) - if (dedicated_usb_logging_port_started) return; + if (dedicated_usb_logging_port_started + || !usb_logging_preference_known.load(std::memory_order_relaxed) + || !isUsbLoggingEnabled()) { + return; + } +#if defined(MESH_ESP32_DUAL_CDC_LOGGING) + // ESP32 registers its descriptor before app_main starts native USB. If the + // saved boot mirror omitted CDC 1, enabling logging takes effect after the + // next reboot instead of trying to mutate a live USB descriptor. + if (dedicated_usb_logging_port == nullptr) return; + // Only the Companion interface may use the CDC control-line reboot gesture. + // A terminal opening or closing the diagnostics interface must never reboot + // the node or put it into the ROM downloader. + dedicated_usb_logging_port->enableReboot(false); + dedicated_usb_logging_port->begin(115200); +#elif defined(MESH_NRF52_DUAL_CDC_LOGGING) dedicated_usb_logging_port.begin(115200); + dedicated_usb_logging_port_configured = true; +#endif dedicated_usb_logging_port_started = true; +#if defined(MESH_NRF52_DUAL_CDC_LOGGING) // 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 @@ -48,12 +168,24 @@ void beginUsbLoggingPort() { TinyUSBDevice.attach(); } #endif +#endif } Stream& usbLoggingPort() { #if defined(MESH_DUAL_CDC_LOGGING) - return dedicated_usb_logging_port; +#if defined(MESH_NRF52_DUAL_CDC_LOGGING) + if (isUsbLoggingEnabled() && dedicated_usb_logging_port_started) { + return dedicated_usb_logging_port; + } +#elif defined(MESH_ESP32_DUAL_CDC_LOGGING) + if (isUsbLoggingEnabled() && dedicated_usb_logging_port_started + && dedicated_usb_logging_port != nullptr) { + return *dedicated_usb_logging_port; + } +#endif + return null_usb_logging_stream; #else + if (!isUsbLoggingEnabled()) return null_usb_logging_stream; return Serial; #endif } @@ -66,5 +198,21 @@ bool hasDedicatedUsbLoggingPort() { #endif } +bool isDedicatedUsbLoggingPortConfigured() { +#if defined(MESH_DUAL_CDC_LOGGING) + return dedicated_usb_logging_port_configured; +#else + return false; +#endif +} + +bool usbLoggingInterfaceRestartRequired() { +#if defined(MESH_DUAL_CDC_LOGGING) + return dedicated_usb_logging_port_configured != isUsbLoggingEnabled(); +#else + return false; +#endif +} + } // namespace mesh #endif diff --git a/src/helpers/UsbLogging.h b/src/helpers/UsbLogging.h index 4c1eb2f8..3e75dae4 100644 --- a/src/helpers/UsbLogging.h +++ b/src/helpers/UsbLogging.h @@ -16,17 +16,24 @@ class Stream; namespace mesh { -// Logging starts enabled on first boot. Roles with saved preferences restore -// the persisted setting after their preferences are loaded. +// Ordinary logging images start enabled. Dual-CDC Full Companion starts with +// logging disabled and restores its saved choice after preferences load. bool isUsbLoggingEnabled(); void setUsbLoggingEnabled(bool enabled); +// ESP32 native USB starts before setup(), so its next-boot interface count is +// mirrored outside the normal role preferences. Other platforms need no +// mirror. Returns false only when that mirror could not be saved. +bool saveUsbLoggingBootPreference(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. +// using Serial. Supported Full Companion builds expose a second CDC ACM +// interface so plaintext diagnostics never share the framed Companion stream. void beginUsbLoggingPort(); Stream& usbLoggingPort(); bool hasDedicatedUsbLoggingPort(); +bool isDedicatedUsbLoggingPortConfigured(); +bool usbLoggingInterfaceRestartRequired(); } // namespace mesh #endif diff --git a/src/helpers/UserGpioPinPolicy.h b/src/helpers/UserGpioPinPolicy.h index 517c5c04..7ee4094c 100644 --- a/src/helpers/UserGpioPinPolicy.h +++ b/src/helpers/UserGpioPinPolicy.h @@ -16,7 +16,12 @@ namespace UserGpioPinPolicy { inline bool isFirmwareReserved(uint8_t pin) { - static const int32_t reserved[] = { + // RadioLib 7 represents its disconnected-pin sentinel as unsigned + // 0xFFFFFFFF. Use a signed type wide enough for that value as well as the + // board definitions that use -1, then reject both forms with the 0..63 + // range check below. A 32-bit signed initializer is a narrowing error under + // the GCC 14 toolchain used by Arduino-ESP32 3.x. + static const int64_t reserved[] = { -1, // LoRa radio, RF switches, FEMs, and activity indicators. diff --git a/src/helpers/bridges/ESPNowBridge.cpp b/src/helpers/bridges/ESPNowBridge.cpp index f39f073c..649bcabc 100644 --- a/src/helpers/bridges/ESPNowBridge.cpp +++ b/src/helpers/bridges/ESPNowBridge.cpp @@ -23,7 +23,13 @@ void ESPNowBridge::recv_cb(const uint8_t *mac, const uint8_t *data, int len) { } #endif +#if ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(5, 5, 0) +void ESPNowBridge::send_cb(const esp_now_send_info_t *info, + esp_now_send_status_t status) { + const uint8_t *mac = info != nullptr ? info->des_addr : nullptr; +#else void ESPNowBridge::send_cb(const uint8_t *mac, esp_now_send_status_t status) { +#endif if (_instance) { _instance->onDataSent(mac, status); } diff --git a/src/helpers/bridges/ESPNowBridge.h b/src/helpers/bridges/ESPNowBridge.h index b229c0c0..7c4a21bc 100644 --- a/src/helpers/bridges/ESPNowBridge.h +++ b/src/helpers/bridges/ESPNowBridge.h @@ -48,7 +48,12 @@ private: #else static void recv_cb(const uint8_t *mac, const uint8_t *data, int len); #endif +#if ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(5, 5, 0) + static void send_cb(const esp_now_send_info_t *info, + esp_now_send_status_t status); +#else static void send_cb(const uint8_t *mac, esp_now_send_status_t status); +#endif /** * ESP-NOW Protocol Structure: diff --git a/src/helpers/esp32/ESPNOWRadio.cpp b/src/helpers/esp32/ESPNOWRadio.cpp index a7557f14..f264c887 100644 --- a/src/helpers/esp32/ESPNOWRadio.cpp +++ b/src/helpers/esp32/ESPNOWRadio.cpp @@ -1,7 +1,11 @@ #include "ESPNOWRadio.h" #include +#include #include #include +#if ESP_ARDUINO_VERSION_MAJOR >= 3 + #include +#endif static uint8_t broadcastAddress[] = {0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF}; static esp_now_peer_info_t peerInfo; @@ -11,12 +15,26 @@ static uint8_t rx_buf[256]; static uint8_t last_rx_len = 0; // callback when data is sent +#if ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(5, 5, 0) +static void OnDataSent(const esp_now_send_info_t *info, + esp_now_send_status_t status) { + (void)info; +#else static void OnDataSent(const uint8_t *mac_addr, esp_now_send_status_t status) { + (void)mac_addr; +#endif is_send_complete = true; ESPNOW_DEBUG_PRINTLN("Send Status: %d", (int)status); } +#if ESP_ARDUINO_VERSION_MAJOR >= 3 +static void OnDataRecv(const esp_now_recv_info_t *info, const uint8_t *data, + int len) { + (void)info; +#else static void OnDataRecv(const uint8_t *mac, const uint8_t *data, int len) { + (void)mac; +#endif ESPNOW_DEBUG_PRINTLN("Recv: len = %d", len); memcpy(rx_buf, data, len); last_rx_len = len; diff --git a/src/helpers/esp32/SerialBLEInterface.cpp b/src/helpers/esp32/SerialBLEInterface.cpp index 004c935d..6364106b 100644 --- a/src/helpers/esp32/SerialBLEInterface.cpp +++ b/src/helpers/esp32/SerialBLEInterface.cpp @@ -1,7 +1,21 @@ #include "SerialBLEInterface.h" #include "../CompanionFrameQueue.h" #include "esp_mac.h" +#if defined(CONFIG_BLUEDROID_ENABLED) #include "esp_gap_ble_api.h" +#endif +#if defined(CONFIG_NIMBLE_ENABLED) +#include +#endif + +// Arduino 3.x relies on a library constructor to mark BLE as used before +// initArduino(), and that constructor ordering is not reliable with all link +// layouts/LTO combinations. A strong override keeps the controller memory +// available until SerialBLEInterface::begin(); Full Companion then reserves +// BLE controller/host memory before starting WiFi. +extern "C" bool bleInUse(void) { + return true; +} // See the following for generating UUIDs: // https://www.uuidgenerator.net/ @@ -12,7 +26,7 @@ #define ADVERT_RESTART_DELAY 1000 // millis -void SerialBLEInterface::begin(const char* prefix, char* name, uint32_t pin_code) { +bool SerialBLEInterface::begin(const char* prefix, char* name, uint32_t pin_code) { _pin_code = pin_code; if (strcmp(name, "@@MAC") == 0) { @@ -26,29 +40,73 @@ void SerialBLEInterface::begin(const char* prefix, char* name, uint32_t pin_code sprintf(dev_name, "%s%s", prefix, name); // Create the BLE Device +#if defined(CONFIG_NIMBLE_ENABLED) + if (!BLEDevice::init(dev_name)) { + BLE_DEBUG_PRINTLN("BLEDevice::init failed"); + return false; + } +#else + // Arduino-ESP32 2.x Bluedroid exposes a void init(). Its first observable + // failure is a null server below, which is handled without dereferencing it. BLEDevice::init(dev_name); +#endif BLEDevice::setSecurityCallbacks(this); // ATT notifications consume three bytes of the negotiated MTU. Reserve // that overhead so a MAX_FRAME_SIZE protocol frame fits without truncation. BLEDevice::setMTU(MAX_FRAME_SIZE + 3); BLESecurity sec; +#if defined(CONFIG_NIMBLE_ENABLED) + sec.setPassKey(true, pin_code); + sec.setAuthenticationMode(true, true, true); +#else sec.setStaticPIN(pin_code); sec.setAuthenticationMode(ESP_LE_AUTH_REQ_SC_MITM_BOND); +#endif //BLEDevice::setPower(ESP_PWR_LVL_N8); // Create the BLE Server pServer = BLEDevice::createServer(); + if (pServer == NULL) { + BLE_DEBUG_PRINTLN("BLEDevice::createServer failed"); + BLEDevice::deinit(false); + return false; + } pServer->setCallbacks(this); // Create the BLE Service pService = pServer->createService(SERVICE_UUID); + if (pService == NULL) { + BLE_DEBUG_PRINTLN("BLEServer::createService failed"); + BLEDevice::deinit(false); + pServer = NULL; + return false; + } // Create a BLE Characteristic - pTxCharacteristic = pService->createCharacteristic(CHARACTERISTIC_UUID_TX, BLECharacteristic::PROPERTY_READ | BLECharacteristic::PROPERTY_NOTIFY); + uint32_t tx_properties = BLECharacteristic::PROPERTY_READ | + BLECharacteristic::PROPERTY_NOTIFY; + uint32_t rx_properties = BLECharacteristic::PROPERTY_WRITE; +#if defined(CONFIG_NIMBLE_ENABLED) + // NimBLE ignores setAccessPermissions(). Authentication requirements must + // be part of the characteristic properties instead. + tx_properties |= BLECharacteristic::PROPERTY_READ_AUTHEN; + rx_properties |= BLECharacteristic::PROPERTY_WRITE_AUTHEN; +#endif + pTxCharacteristic = pService->createCharacteristic( + CHARACTERISTIC_UUID_TX, tx_properties); + if (pTxCharacteristic == NULL) { + BLE_DEBUG_PRINTLN("BLEService::createCharacteristic(TX) failed"); + BLEDevice::deinit(false); + pServer = NULL; + pService = NULL; + return false; + } pTxCharacteristic->setAccessPermissions(ESP_GATT_PERM_READ_ENC_MITM); pTxCharacteristic->setCallbacks(this); +#if !defined(CONFIG_NIMBLE_ENABLED) + // NimBLE creates and owns the 0x2902 descriptor automatically. pTxDescriptor = new BLE2902(); // Make notification setup start/finish pairing before the client begins its // short device-info request timeout. The RX and TX characteristics already @@ -57,12 +115,24 @@ void SerialBLEInterface::begin(const char* prefix, char* name, uint32_t pin_code pTxDescriptor->setAccessPermissions( (esp_gatt_perm_t)(ESP_GATT_PERM_READ_ENC_MITM | ESP_GATT_PERM_WRITE_ENC_MITM)); pTxCharacteristic->addDescriptor(pTxDescriptor); +#endif - BLECharacteristic * pRxCharacteristic = pService->createCharacteristic(CHARACTERISTIC_UUID_RX, BLECharacteristic::PROPERTY_WRITE); + BLECharacteristic * pRxCharacteristic = pService->createCharacteristic( + CHARACTERISTIC_UUID_RX, rx_properties); + if (pRxCharacteristic == NULL) { + BLE_DEBUG_PRINTLN("BLEService::createCharacteristic(RX) failed"); + BLEDevice::deinit(false); + pServer = NULL; + pService = NULL; + pTxCharacteristic = NULL; + pTxDescriptor = NULL; + return false; + } pRxCharacteristic->setAccessPermissions(ESP_GATT_PERM_WRITE_ENC_MITM); pRxCharacteristic->setCallbacks(this); pServer->getAdvertising()->addServiceUUID(SERVICE_UUID); + return true; } // -------- BLESecurityCallbacks methods @@ -89,6 +159,34 @@ bool SerialBLEInterface::onSecurityRequest() { return true; // allow } +#if defined(CONFIG_NIMBLE_ENABLED) +void SerialBLEInterface::onAuthenticationComplete(ble_gap_conn_desc* desc) { + const bool success = desc != NULL && desc->sec_state.encrypted && + desc->sec_state.authenticated; + if (success) { + BLE_DEBUG_PRINTLN(" - SecurityCallback - Authentication Success"); + deviceConnected = true; + } else { + BLE_DEBUG_PRINTLN(" - SecurityCallback - Authentication Failure"); + + if (desc != NULL) { + int remove_result = ble_store_util_delete_peer(&desc->peer_id_addr); + if (remove_result == 0) { + BLE_DEBUG_PRINTLN(" - SecurityCallback - Cleared failed peer bond"); + } else { + BLE_DEBUG_PRINTLN(" - SecurityCallback - No peer bond cleared, err=%d", + remove_result); + } + } + + deviceConnected = false; + if (desc != NULL) { + pServer->disconnect(desc->conn_handle); + } + scheduleAdvertisingRestart((uint32_t)millis()); + } +} +#else void SerialBLEInterface::onAuthenticationComplete(esp_ble_auth_cmpl_t cmpl) { if (cmpl.success) { BLE_DEBUG_PRINTLN(" - SecurityCallback - Authentication Success"); @@ -112,12 +210,35 @@ void SerialBLEInterface::onAuthenticationComplete(esp_ble_auth_cmpl_t cmpl) { scheduleAdvertisingRestart((uint32_t)millis()); } } +#endif // -------- BLEServerCallbacks methods void SerialBLEInterface::onConnect(BLEServer* pServer) { } +#if defined(CONFIG_NIMBLE_ENABLED) +void SerialBLEInterface::onConnect(BLEServer* pServer, ble_gap_conn_desc* desc) { + BLE_DEBUG_PRINTLN("onConnect(), conn_id=%d, mtu=%d", desc->conn_handle, + pServer->getPeerMTU(desc->conn_handle)); + last_conn_id = desc->conn_handle; + deviceConnected = false; // becomes usable only after authentication completes + oldDeviceConnected = false; + notifySucceeded = false; + notificationsEnabled.store(false, std::memory_order_release); + xQueueReset(recv_queue); + _tx_reset_pending.store(true, std::memory_order_release); + _adv_restart_pending = false; +} + +void SerialBLEInterface::onMtuChanged(BLEServer* pServer, + ble_gap_conn_desc* desc, + uint16_t mtu) { + (void)pServer; + (void)desc; + BLE_DEBUG_PRINTLN("onMtuChanged(), mtu=%d", mtu); +} +#else void SerialBLEInterface::onConnect(BLEServer* pServer, esp_ble_gatts_cb_param_t *param) { BLE_DEBUG_PRINTLN("onConnect(), conn_id=%d, mtu=%d", param->connect.conn_id, pServer->getPeerMTU(param->connect.conn_id)); last_conn_id = param->connect.conn_id; @@ -136,11 +257,13 @@ void SerialBLEInterface::onConnect(BLEServer* pServer, esp_ble_gatts_cb_param_t void SerialBLEInterface::onMtuChanged(BLEServer* pServer, esp_ble_gatts_cb_param_t* param) { BLE_DEBUG_PRINTLN("onMtuChanged(), mtu=%d", pServer->getPeerMTU(param->mtu.conn_id)); } +#endif void SerialBLEInterface::onDisconnect(BLEServer* pServer) { BLE_DEBUG_PRINTLN("onDisconnect()"); deviceConnected = false; notifySucceeded = false; + notificationsEnabled.store(false, std::memory_order_release); xQueueReset(recv_queue); _tx_reset_pending.store(true, std::memory_order_release); if (pTxDescriptor != NULL) pTxDescriptor->setNotifications(false); @@ -151,7 +274,15 @@ void SerialBLEInterface::onDisconnect(BLEServer* pServer) { // -------- BLECharacteristicCallbacks methods -void SerialBLEInterface::onWrite(BLECharacteristic* pCharacteristic, esp_ble_gatts_cb_param_t* param) { +#if defined(CONFIG_NIMBLE_ENABLED) +void SerialBLEInterface::onWrite(BLECharacteristic* pCharacteristic, + ble_gap_conn_desc* desc) { + (void)desc; +#else +void SerialBLEInterface::onWrite(BLECharacteristic* pCharacteristic, + esp_ble_gatts_cb_param_t* param) { + (void)param; +#endif if (_tx_disconnect_recovery.pending()) { BLE_DEBUG_PRINTLN("onWrite(): dropping frame while BLE reconnect is pending"); return; @@ -173,6 +304,19 @@ void SerialBLEInterface::onWrite(BLECharacteristic* pCharacteristic, esp_ble_gat } } +#if defined(CONFIG_NIMBLE_ENABLED) +void SerialBLEInterface::onSubscribe(BLECharacteristic* pCharacteristic, + ble_gap_conn_desc* desc, + uint16_t subValue) { + (void)desc; + if (pCharacteristic == pTxCharacteristic) { + // NimBLE uses bit 0 for notification subscriptions. + notificationsEnabled.store((subValue & 0x0001u) != 0, + std::memory_order_release); + } +} +#endif + void SerialBLEInterface::onStatus(BLECharacteristic* pCharacteristic, Status status, uint32_t code) { (void)pCharacteristic; notifySucceeded = status == SUCCESS_NOTIFY; @@ -188,6 +332,7 @@ void SerialBLEInterface::clearBuffers() { xQueueReset(recv_queue); send_queue_len = 0; notifySucceeded = false; + notificationsEnabled.store(false, std::memory_order_release); _tx_stall_watchdog.reset(); _tx_disconnect_recovery.complete(); _tx_reset_pending.store(false, std::memory_order_release); @@ -314,7 +459,13 @@ size_t SerialBLEInterface::checkRecvFrame(uint8_t dest[]) { BLE_WRITE_MIN_INTERVAL) // space the writes apart ) { const uint16_t peer_mtu = pServer->getPeerMTU(last_conn_id); - const bool notifications_ready = pTxDescriptor != NULL && pTxDescriptor->getNotifications(); +#if defined(CONFIG_NIMBLE_ENABLED) + const bool notifications_ready = + notificationsEnabled.load(std::memory_order_acquire); +#else + const bool notifications_ready = + pTxDescriptor != NULL && pTxDescriptor->getNotifications(); +#endif const bool frame_fits = peer_mtu > 3 && send_queue[0].len <= peer_mtu - 3; const bool delivery_required = mesh::companionFrameRequiresDelivery( send_queue[0].buf, send_queue[0].len); diff --git a/src/helpers/esp32/SerialBLEInterface.h b/src/helpers/esp32/SerialBLEInterface.h index 7a218235..0a4eae5a 100644 --- a/src/helpers/esp32/SerialBLEInterface.h +++ b/src/helpers/esp32/SerialBLEInterface.h @@ -19,6 +19,7 @@ class SerialBLEInterface : public BaseSerialInterface, BLESecurityCallbacks, BLE bool deviceConnected; bool oldDeviceConnected; bool notifySucceeded; + std::atomic notificationsEnabled{false}; bool _isEnabled; uint16_t last_conn_id; uint32_t _pin_code; @@ -54,16 +55,31 @@ protected: void onPassKeyNotify(uint32_t pass_key) override; bool onConfirmPIN(uint32_t pass_key) override; bool onSecurityRequest() override; + #if defined(CONFIG_NIMBLE_ENABLED) + void onAuthenticationComplete(ble_gap_conn_desc* desc) override; + #else void onAuthenticationComplete(esp_ble_auth_cmpl_t cmpl) override; + #endif // BLEServerCallbacks methods void onConnect(BLEServer* pServer) override; + #if defined(CONFIG_NIMBLE_ENABLED) + void onConnect(BLEServer* pServer, ble_gap_conn_desc* desc) override; + void onMtuChanged(BLEServer* pServer, ble_gap_conn_desc* desc, uint16_t mtu) override; + #else void onConnect(BLEServer* pServer, esp_ble_gatts_cb_param_t *param) override; void onMtuChanged(BLEServer* pServer, esp_ble_gatts_cb_param_t* param) override; + #endif void onDisconnect(BLEServer* pServer) override; // BLECharacteristicCallbacks methods + #if defined(CONFIG_NIMBLE_ENABLED) + void onWrite(BLECharacteristic* pCharacteristic, ble_gap_conn_desc* desc) override; + void onSubscribe(BLECharacteristic* pCharacteristic, ble_gap_conn_desc* desc, + uint16_t subValue) override; + #else void onWrite(BLECharacteristic* pCharacteristic, esp_ble_gatts_cb_param_t* param) override; + #endif void onStatus(BLECharacteristic* pCharacteristic, Status status, uint32_t code) override; public: @@ -75,6 +91,7 @@ public: deviceConnected = false; oldDeviceConnected = false; notifySucceeded = false; + notificationsEnabled.store(false, std::memory_order_relaxed); _adv_restart_started = 0; _adv_restart_pending = false; _isEnabled = false; @@ -92,7 +109,7 @@ public: * @param name IN/OUT - a name for the device (combined with prefix). If "@@MAC", is modified and returned * @param pin_code the BLE security pin */ - void begin(const char* prefix, char* name, uint32_t pin_code); + bool begin(const char* prefix, char* name, uint32_t pin_code); // BaseSerialInterface methods void enable() override; diff --git a/src/helpers/nrf52/SerialBLEInterface.cpp b/src/helpers/nrf52/SerialBLEInterface.cpp index 310662f3..df0006e5 100644 --- a/src/helpers/nrf52/SerialBLEInterface.cpp +++ b/src/helpers/nrf52/SerialBLEInterface.cpp @@ -199,7 +199,7 @@ bool SerialBLEInterface::removeStoredBondForPeer(const char* cause) { return true; } -void SerialBLEInterface::begin(const char* prefix, char* name, uint32_t pin_code) { +bool SerialBLEInterface::begin(const char* prefix, char* name, uint32_t pin_code) { instance = this; char charpin[20]; @@ -208,7 +208,11 @@ void SerialBLEInterface::begin(const char* prefix, char* name, uint32_t pin_code // If we want to control BLE LED ourselves, uncomment this: // Bluefruit.autoConnLed(false); Bluefruit.configPrphBandwidth(BANDWIDTH_MAX); - Bluefruit.begin(); + if (!Bluefruit.begin()) { + instance = nullptr; + BLE_DEBUG_PRINTLN("Bluefruit.begin failed"); + return false; + } char dev_name[32+16]; if (strcmp(name, "@@MAC") == 0) { @@ -275,6 +279,7 @@ void SerialBLEInterface::begin(const char* prefix, char* name, uint32_t pin_code Bluefruit.Advertising.restartOnDisconnect(true); + return true; } void SerialBLEInterface::clearBuffers() { diff --git a/src/helpers/nrf52/SerialBLEInterface.h b/src/helpers/nrf52/SerialBLEInterface.h index d7a276cb..52ed0cec 100644 --- a/src/helpers/nrf52/SerialBLEInterface.h +++ b/src/helpers/nrf52/SerialBLEInterface.h @@ -76,7 +76,7 @@ public: * @param name IN/OUT - a name for the device (combined with prefix). If "@@MAC", is modified and returned * @param pin_code the BLE security pin */ - void begin(const char* prefix, char* name, uint32_t pin_code); + bool begin(const char* prefix, char* name, uint32_t pin_code); void disconnect(); void enable() override; diff --git a/src/helpers/ui/ST7735Display.cpp b/src/helpers/ui/ST7735Display.cpp index 3c7d52db..b0e64cbd 100644 --- a/src/helpers/ui/ST7735Display.cpp +++ b/src/helpers/ui/ST7735Display.cpp @@ -1,4 +1,5 @@ #include "ST7735Display.h" +#include "../UsbLogging.h" #include //#include diff --git a/test/test_firmware_picker.js b/test/test_firmware_picker.js index 61db7afc..14828896 100644 --- a/test/test_firmware_picker.js +++ b/test/test_firmware_picker.js @@ -42,6 +42,21 @@ const releases = [ 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"), + asset( + "heltec_v4_2_v4_3_companion_radio_full_femon-" + family + + "-merged.bin" + ), + asset( + "heltec_v4_2_v4_3_companion_radio_full_femon-" + family + ".bin" + ), + asset("heltec_v4_companion_radio_usb-" + family + "-merged.bin"), + asset("heltec_v4_companion_radio_usb-" + family + ".bin"), + asset("heltec_v4_companion_radio_ble-" + family + "-merged.bin"), + asset("heltec_v4_companion_radio_ble-" + family + ".bin"), + asset( + "heltec_v4_companion_radio_wifi_femon-" + family + "-merged.bin" + ), + asset("heltec_v4_companion_radio_wifi_femon-" + family + ".bin"), ]), release("repeater-room-" + family, "2026-08-23T12:00:05Z", [ asset("Station_G2_repeater-" + family + "-deadbee-merged.bin"), @@ -110,8 +125,8 @@ assert(!releaseSet.releases.some(function (item) { const catalog = picker.buildCatalog(releases); assert.strictEqual(catalog.releaseSet.familyTag, family); -assert.strictEqual(catalog.profiles.length, 11); -assert.strictEqual(catalog.rows.length, 33); +assert.strictEqual(catalog.profiles.length, 12); +assert.strictEqual(catalog.rows.length, 41); function profile(target) { const found = catalog.profiles.find(function (item) { @@ -125,7 +140,9 @@ const companionFull = profile("Station_G2_companion_radio_full"); assert.strictEqual(companionFull.hardware, "Station_G2"); assert.strictEqual(companionFull.role, "companion"); assert.strictEqual(companionFull.mode, "full"); -assert.strictEqual(companionFull.logging, "none"); +assert.strictEqual(companionFull.logging, "usb-runtime"); +assert.deepStrictEqual(companionFull.loggingModes, ["none", "usb"]); +assert.strictEqual(companionFull.dedicatedUsbLogging, true); assert.strictEqual(companionFull.ota, "lora-source"); assert.strictEqual(companionFull.feature, "full"); assert.strictEqual(companionFull.variant, "default"); @@ -141,6 +158,37 @@ assert(catalog.rows.some(function (item) { return item.target === "RAK_4631_companion_radio_usb-logging"; })); +const v4Full = profile("heltec_v4_2_v4_3_companion_radio_full_femon"); +assert.strictEqual(v4Full.logging, "usb-runtime"); +assert.deepStrictEqual(v4Full.loggingModes, ["none", "usb"]); +assert.strictEqual(v4Full.dedicatedUsbLogging, true); +["usb", "ble", "wifi"].forEach(function (mode) { + assert(!catalog.profiles.some(function (item) { + return item.hardware === "heltec_v4" && item.role === "companion" && + item.mode === mode; + })); +}); + +["RAK_3112", "heltec_rc32", "heltec_rc32_without_display"].forEach( + function (hardware) { + const candidates = ["full", "usb", "ble", "wifi"].map(function (mode) { + return Object.assign( + picker.parseTargetProfile( + hardware + "_companion_radio_" + mode + ), + { installKinds: ["bin"] } + ); + }); + const classified = picker.applyDualCdcFullCompanionCapabilities(candidates); + assert.strictEqual(classified[0].logging, "none"); + assert.strictEqual(classified[0].dedicatedUsbLogging, undefined); + assert.strictEqual( + picker.omitTransportsReplacedByDualCdcFull(classified).length, + candidates.length + ); + } +); + const companionBle = picker.parseTargetProfile( "Heltec_t096_companion_radio_ble_ps_femon" ); @@ -472,6 +520,7 @@ assert.deepStrictEqual( "RAK_4631", "Station_G2", "Station_G3_ESP32", + "heltec_v4", "solarxiao_33S", "wio-e5", ].sort() diff --git a/tools/mota/README.md b/tools/mota/README.md index 1ef5001a..bbcf7695 100644 --- a/tools/mota/README.md +++ b/tools/mota/README.md @@ -11,11 +11,14 @@ no longer a user-facing CLI. ## Setup -Uses the repo's Python venv (`meshcore/`). Dependencies: `detools` (delta), `cryptography` (Ed25519), -`intelhex` (nRF52 `.hex` handling). +Use one pipx-managed `detools` environment. `cryptography` (Ed25519) and +`intelhex` (nRF52 `.hex` handling) are injected into that same environment; +they are not separate pipx applications. ```bash -./meshcore/bin/pip install detools cryptography intelhex +pipx install detools +pipx inject detools cryptography intelhex +DETOOLS_PYTHON="$(pipx environment --value PIPX_LOCAL_VENVS)/detools/bin/python" ``` ## Files @@ -31,10 +34,10 @@ Uses the repo's Python venv (`meshcore/`). Dependencies: `detools` (delta), `cry ## Tests ```bash -./meshcore/bin/python tools/mota/test_mota.py # EndF, merkle+proofs, v2 apps/v3 nRF52 bootloader, - # signing, tamper detection, approval enforcement -./meshcore/bin/python tools/mota/gen_vectors.py # regenerate the native-test cross-check vectors -./meshcore/bin/python tools/mota/gen_targets.py # regenerate src/helpers/ota/OtaTargets.h (needs `pio`) +"$DETOOLS_PYTHON" tools/mota/test_mota.py # EndF, merkle+proofs, v2 apps/v3 nRF52 bootloader, + # signing, tamper detection, approval enforcement +"$DETOOLS_PYTHON" tools/mota/gen_vectors.py # regenerate the native-test cross-check vectors +"$DETOOLS_PYTHON" tools/mota/gen_targets.py # regenerate src/helpers/ota/OtaTargets.h (needs `pio`) ``` ## `EndF` build integration diff --git a/tools/mota/test_mota.py b/tools/mota/test_mota.py index aacc07d3..72049cda 100644 --- a/tools/mota/test_mota.py +++ b/tools/mota/test_mota.py @@ -1,8 +1,8 @@ #!/usr/bin/env python3 """ -Tests for motalib - run with the meshcore venv: +Tests for motalib - run with the pipx-managed detools environment: - ./meshcore/bin/python tools/mota/test_mota.py + "$(pipx environment --value PIPX_LOCAL_VENVS)/detools/bin/python" tools/mota/test_mota.py (Also pytest-compatible: functions are named test_*.) """ @@ -89,7 +89,7 @@ def test_full_esp32_profile_unifies_usb_logging_and_wifi_mqtt(): 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 + assert "can add a dedicated logging USB port" in matrix def test_canonical_bulk_matrix_omits_runtime_and_transport_aliases(): @@ -110,7 +110,7 @@ def test_canonical_bulk_matrix_omits_runtime_and_transport_aliases(): 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 + assert "is_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] @@ -123,17 +123,175 @@ def test_canonical_bulk_matrix_omits_runtime_and_transport_aliases(): assert "-DMOTA_TARGET_ID=0" in full assert "-UOTA_FLASH_STORE" in full assert "-UOTA_SD_STORE" in full + assert "-UWEBCONFIG_DISABLED" 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 + esp32_dual = build.split( + "is_esp32_dual_cdc_companion_radio_full_target()", 1 + )[1].split("requires_esp32_companion_full_ota_fallback()", 1)[0] + assert "heltec_v4_2_v4_3_companion_radio_full_femon" in esp32_dual + assert "rak_3112_companion_radio_full" not in esp32_dual + assert "heltec_rc32_companion_radio_full" not in esp32_dual + assert "heltec_rc32_without_display_companion_radio_full" not in esp32_dual + + for relative, section, next_section in ( + ( + "variants/rak3112/platformio.ini", + "[env:RAK_3112_companion_radio_full]", + "[env:RAK_3112_sensor]", + ), + ( + "variants/heltec_rc32/platformio.ini", + "[env:heltec_rc32_without_display_companion_radio_full]", + "[env:heltec_rc32_without_display_sensor]", + ), + ( + "variants/heltec_rc32/platformio.ini", + "[env:heltec_rc32_companion_radio_full]", + "[env:heltec_rc32_sensor]", + ), + ): + variant = (root / relative).read_text(encoding="utf-8") + profile = variant.split(section, 1)[1].split(next_section, 1)[0] + assert "esp32_s3_dual_cdc_full" not in profile + usb_logging = (root / "src/helpers/UsbLogging.cpp").read_text( encoding="utf-8" ) assert "Adafruit_USBD_CDC dedicated_usb_logging_port" in usb_logging + assert "new (dedicated_usb_logging_port_storage)" in usb_logging + assert "USBCDC(1)" in usb_logging + assert "dedicated_usb_logging_port->enableReboot(false)" in usb_logging + assert "return result == ESP_OK && value != 0" in usb_logging assert "TinyUSBDevice.detach()" in usb_logging assert "return dedicated_usb_logging_port" in usb_logging + assert "return *dedicated_usb_logging_port" in usb_logging + assert "return null_usb_logging_stream" in usb_logging + + companion = (root / "examples/companion_radio/MyMesh.cpp").read_text( + encoding="utf-8" + ) + assert "defined(COMPANION_RADIO_FULL) && defined(MESH_DUAL_CDC_LOGGING)" in companion + assert "_prefs.usb_logging_enabled = 0" in companion + assert 'strcmp(value, "on reboot") == 0' in companion + assert 'strcmp(value, "off reboot") == 0' in companion + assert "reboot required to change USB interfaces" in companion + assert "rebooting to change USB interfaces" in companion + assert "mesh::saveUsbLoggingBootPreference(enabled)" in companion + + +def test_full_companion_wireless_startup_and_psram_contacts_are_resilient(): + root = Path(__file__).resolve().parents[2] + main = (root / "examples/companion_radio/main.cpp").read_text( + encoding="utf-8" + ) + setup = main.split("void setup()", 1)[1] + assert setup.index("startCompanionBluetooth();") < setup.index("WiFi.onEvent") + assert "if (!bluetooth_interface.begin(" in main + assert "interface_manager.removeInterface(&bluetooth_interface)" in main + assert "Bluetooth initialization failed; retrying in 5 seconds" in main + + esp_ble = (root / "src/helpers/esp32/SerialBLEInterface.cpp").read_text( + encoding="utf-8" + ) + nrf_ble = (root / "src/helpers/nrf52/SerialBLEInterface.cpp").read_text( + encoding="utf-8" + ) + assert 'extern "C" bool bleInUse(void)' in esp_ble + assert "bool SerialBLEInterface::begin(" in esp_ble + assert "Arduino-ESP32 2.x Bluedroid exposes a void init()" in esp_ble + assert "if (pServer == NULL)" in esp_ble + assert "if (pService == NULL)" in esp_ble + assert "if (pTxCharacteristic == NULL)" in esp_ble + assert "if (pRxCharacteristic == NULL)" in esp_ble + assert "bool SerialBLEInterface::begin(" in nrf_ble + assert "if (!Bluefruit.begin())" in nrf_ble + + contacts_header = (root / "src/helpers/BaseChatMesh.h").read_text( + encoding="utf-8" + ) + contacts_source = (root / "src/helpers/BaseChatMesh.cpp").read_text( + encoding="utf-8" + ) + assert "CONTACT_PSRAM_FALLBACK_REGULAR_SLOTS" in contacts_header + assert "ContactInfo* contacts" in contacts_header + assert "int* sort_array" in contacts_header + assert "heap_caps_calloc" in contacts_source + assert "MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT" in contacts_source + assert "contact_capacity = requested_capacity" in contacts_source + assert setup.index("the_mesh.begin(") < setup.index("startCompanionBluetooth();") + companion = (root / "examples/companion_radio/MyMesh.cpp").read_text( + encoding="utf-8" + ) + assert companion.index("initializeContactStorage();") < companion.index( + "initializeOfflineQueue();" + ) + + +def test_esp32_s3_full_profiles_inherit_dio_boot_mode(): + root = Path(__file__).resolve().parents[2] + project = (root / "platformio.ini").read_text(encoding="utf-8") + shared = project.split("[esp32_s3_full]", 1)[1].split( + "\n[", 1 + )[0] + assert "board_build.flash_mode = dio" in shared + + dual_cdc = project.split("[esp32_s3_dual_cdc_full]", 1)[1].split( + "\n[", 1 + )[0] + assert ( + "board_build.flash_mode = ${esp32_s3_full.board_build.flash_mode}" + in dual_cdc + ) + assert "esp32-core-3.3.11.tar.xz" in dual_cdc + assert "esp32-core-3.3.11-libs.tar.xz" in dual_cdc + + build = (root / "build.sh").read_text(encoding="utf-8") + framework_preflight = build.split( + "prepare_esp32_dual_cdc_framework()", 1 + )[1].split("requires_esp32_companion_full_ota_fallback()", 1)[0] + assert "is_esp32_dual_cdc_companion_radio_full_target" in framework_preflight + assert 'pio pkg install -e "$pio_env_name"' in framework_preflight + build_call = build.split('print_build_flags "$env_name"', 1)[1].split( + "restore_platformio_build_flags", 1 + )[0] + assert 'prepare_esp32_dual_cdc_framework "$env_name" "$pio_env_name"' in build_call + assert 'if [ "$build_status" -eq 0 ]; then' in build_call + assert 'flock "$platformio_package_lock_fd"' in build_call + assert 'flock -u "$platformio_package_lock_fd"' in build_call + + s3_full_variant_dirs = { + "heltec_rc32", + "heltec_tracker_v2", + "heltec_v4", + "heltec_v4_r8", + "lilygo_tbeam_1w", + "meshnology_w12", + "nibble_screen_connect", + "nibble_zero_connect", + "rak3112", + "station_g2", + "station_g3_esp32", + "xiao_s3_wio", + } + profile_count = 0 + for variant_dir in sorted(s3_full_variant_dirs): + path = root / "variants" / variant_dir / "platformio.ini" + content = path.read_text(encoding="utf-8") + for section in content.split("\n["): + header = section.split("]", 1)[0].lower() + if "companion_radio_full" not in header: + continue + profile_count += 1 + assert ( + "board_build.flash_mode = " + "${esp32_s3_full.board_build.flash_mode}" + ) in section, f"ESP32-S3 Full profile lacks shared DIO mode: {path}" + + assert profile_count == 17 def test_release_catalog_resolves_canonical_runtime_aliases(): @@ -146,10 +304,23 @@ def test_release_catalog_resolves_canonical_runtime_aliases(): provider = importlib.util.module_from_spec(spec) spec.loader.exec_module(provider) + assert provider.ESP32_DUAL_CDC_FULL_RE.match( + "heltec_v4_2_v4_3_companion_radio_full_femon" + ) + assert not provider.ESP32_DUAL_CDC_FULL_RE.match( + "RAK_3112_companion_radio_full" + ) + assert not provider.ESP32_DUAL_CDC_FULL_RE.match( + "heltec_rc32_companion_radio_full" + ) + assert not provider.ESP32_DUAL_CDC_FULL_RE.match( + "heltec_rc32_without_display_companion_radio_full" + ) + 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")], + "heltec_v4_2_v4_3_companion_radio_full_femon": [Path("v4.bin")], "Station_G2_repeater_observer_mqtt-full-usb-wifi-ota": [ Path("g2.bin") ], @@ -165,7 +336,13 @@ def test_release_catalog_resolves_canonical_runtime_aliases(): ) == ("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) + ) == ("heltec_v4_2_v4_3_companion_radio_full_femon", False) + assert provider.resolve_release_identity( + "heltec_v4_companion_radio_usb-logging", release_files + ) == ("heltec_v4_2_v4_3_companion_radio_full_femon", False) + assert provider.resolve_release_identity( + "heltec_v4_companion_radio_wifi_femon", release_files + ) == ("heltec_v4_2_v4_3_companion_radio_full_femon", False) assert provider.resolve_release_identity( "Station_G2_logging_repeater-logging", release_files ) == ("Station_G2_repeater_observer_mqtt-full-usb-wifi-ota", True) @@ -177,6 +354,31 @@ def test_release_catalog_resolves_canonical_runtime_aliases(): "Station_G2_companion_radio_usb", release_files ) == ("Station_G2_companion_radio_usb", False) + # When the canonical release contains only the qualified Full image, every + # ordinary attached transport and the old USB-logging identity resolve to + # it. An unqualified USB-UART bridge must never receive that substitution. + g2_full_only = { + "Station_G2_companion_radio_full": [Path("g2-full.bin")], + "ThinkNode_M2_companion_radio_full": [Path("m2-full.bin")], + } + for old_identity in ( + "Station_G2_companion_radio_usb", + "Station_G2_companion_radio_ble", + "Station_G2_companion_radio_wifi", + "Station_G2_companion_radio_usb-logging", + ): + assert provider.resolve_release_identity( + old_identity, g2_full_only + ) == ("Station_G2_companion_radio_full", False) + try: + provider.resolve_release_identity( + "ThinkNode_M2_companion_radio_usb", g2_full_only + ) + except ValueError: + pass + else: + raise AssertionError("USB-UART bridge was incorrectly mapped to Full") + 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.", @@ -185,6 +387,15 @@ def test_release_catalog_resolves_canonical_runtime_aliases(): assert "interface 02" in dual_notes assert "Input received on interface 02 is ignored" in dual_notes + v4_notes = provider.normalize_esp32_dual_cdc_full_companion_metadata( + {"title": "Companion USB", "subTitle": "USB logging"}, + "PROFILE - old profile\n\nLOGGING USE - old use\n\nSELECTION - USB.", + ) + assert "interface 00" in v4_notes + assert "interface 02" in v4_notes + assert "cannot reboot the board" in v4_notes + assert "ordinary Wi-Fi" in v4_notes + legacy = { "role": "companionBle", "title": "Companion BLE", diff --git a/variants/heltec_rc32/platformio.ini b/variants/heltec_rc32/platformio.ini index 6e728686..1b980395 100644 --- a/variants/heltec_rc32/platformio.ini +++ b/variants/heltec_rc32/platformio.ini @@ -192,6 +192,10 @@ lib_deps = densaugeo/base64 @ ~1.4.0 end2endzone/NonBlockingRTTTL@^1.3.0 +[env:heltec_rc32_without_display_companion_radio_full] +extends = env:heltec_rc32_without_display_companion_radio_wifi +board_build.flash_mode = ${esp32_s3_full.board_build.flash_mode} + [env:heltec_rc32_without_display_sensor] extends = Heltec_RC32 build_flags = @@ -331,6 +335,10 @@ lib_deps = densaugeo/base64 @ ~1.4.0 end2endzone/NonBlockingRTTTL@^1.3.0 +[env:heltec_rc32_companion_radio_full] +extends = env:heltec_rc32_companion_radio_wifi +board_build.flash_mode = ${esp32_s3_full.board_build.flash_mode} + [env:heltec_rc32_sensor] extends = Heltec_RC32_with_display build_flags = diff --git a/variants/heltec_tracker_v2/platformio.ini b/variants/heltec_tracker_v2/platformio.ini index 6059a1ed..99bd3b8e 100644 --- a/variants/heltec_tracker_v2/platformio.ini +++ b/variants/heltec_tracker_v2/platformio.ini @@ -414,6 +414,15 @@ lib_deps = ${Heltec_tracker_v2.lib_deps} densaugeo/base64 @ ~1.4.0 +[env:heltec_tracker_v2_companion_radio_full_femon] +extends = env:heltec_tracker_v2_companion_radio_wifi_femon +platform = ${esp32_s3_dual_cdc_full.platform} +board_build.flash_mode = ${esp32_s3_full.board_build.flash_mode} +build_unflags = ${esp32_s3_dual_cdc_full.build_unflags} +build_flags = + ${env:heltec_tracker_v2_companion_radio_wifi_femon.build_flags} + ${esp32_s3_dual_cdc_full.build_flags} + [env:heltec_tracker_v2_companion_radio_wifi_femoff] extends = env:heltec_tracker_v2_companion_radio_wifi_femon build_flags = diff --git a/variants/heltec_v4/platformio.ini b/variants/heltec_v4/platformio.ini index 707ebbdf..278eb158 100644 --- a/variants/heltec_v4/platformio.ini +++ b/variants/heltec_v4/platformio.ini @@ -520,6 +520,29 @@ build_flags = ${env:heltec_v4_companion_radio_wifi_femon.build_flags} -D RADIO_FEM_RXGAIN=0 ; undefined (default on), 1=on, 0=off +; Full Companion needs two independent native USB CDC interfaces. The pinned +; Arduino-ESP32 2.0.x platform supports only one; the 3.x platform already used +; by this tree's C6 targets supplies CDC 0 + CDC 1 on ESP32-S3. +[env:heltec_v4_2_v4_3_companion_radio_full_femon] +extends = env:heltec_v4_companion_radio_wifi_femon +platform = ${esp32_s3_dual_cdc_full.platform} +; Marking the merged image QIO makes this V4 reset in the ROM before the +; software bootloader can start. Keep the Full image on the shared DIO policy. +board_build.flash_mode = ${esp32_s3_full.board_build.flash_mode} +build_unflags = ${esp32_s3_dual_cdc_full.build_unflags} +build_flags = + ${env:heltec_v4_companion_radio_wifi_femon.build_flags} + ${esp32_s3_dual_cdc_full.build_flags} + +[env:heltec_v4_3_companion_radio_full_femoff] +extends = env:heltec_v4_3_companion_radio_wifi_femoff +platform = ${esp32_s3_dual_cdc_full.platform} +board_build.flash_mode = ${esp32_s3_full.board_build.flash_mode} +build_unflags = ${esp32_s3_dual_cdc_full.build_unflags} +build_flags = + ${env:heltec_v4_3_companion_radio_wifi_femoff.build_flags} + ${esp32_s3_dual_cdc_full.build_flags} + [env:heltec_v4_companion_radio_wifi_mqtt_femon] extends = env:heltec_v4_companion_radio_wifi_femon extra_scripts = @@ -752,6 +775,24 @@ build_flags = ${env:heltec_v4_tft_companion_radio_wifi_femon.build_flags} -D RADIO_FEM_RXGAIN=0 ; undefined (default on), 1=on, 0=off +[env:heltec_v4_tft_companion_radio_full_femon] +extends = env:heltec_v4_tft_companion_radio_wifi_femon +platform = ${esp32_s3_dual_cdc_full.platform} +board_build.flash_mode = ${esp32_s3_full.board_build.flash_mode} +build_unflags = ${esp32_s3_dual_cdc_full.build_unflags} +build_flags = + ${env:heltec_v4_tft_companion_radio_wifi_femon.build_flags} + ${esp32_s3_dual_cdc_full.build_flags} + +[env:heltec_v4_3_tft_companion_radio_full_femoff] +extends = env:heltec_v4_3_tft_companion_radio_wifi_femoff +platform = ${esp32_s3_dual_cdc_full.platform} +board_build.flash_mode = ${esp32_s3_full.board_build.flash_mode} +build_unflags = ${esp32_s3_dual_cdc_full.build_unflags} +build_flags = + ${env:heltec_v4_3_tft_companion_radio_wifi_femoff.build_flags} + ${esp32_s3_dual_cdc_full.build_flags} + [env:heltec_v4_tft_sensor] extends = heltec_v4_tft build_flags = diff --git a/variants/heltec_v4_r8/platformio.ini b/variants/heltec_v4_r8/platformio.ini index 98390416..c868f5ca 100644 --- a/variants/heltec_v4_r8/platformio.ini +++ b/variants/heltec_v4_r8/platformio.ini @@ -195,6 +195,15 @@ lib_deps = ${heltec_v4_r8_oled.lib_deps} densaugeo/base64 @ ~1.4.0 +[env:heltec_v4_r8_companion_radio_full] +extends = env:heltec_v4_r8_companion_radio_wifi +platform = ${esp32_s3_dual_cdc_full.platform} +board_build.flash_mode = ${esp32_s3_full.board_build.flash_mode} +build_unflags = ${esp32_s3_dual_cdc_full.build_unflags} +build_flags = + ${env:heltec_v4_r8_companion_radio_wifi.build_flags} + ${esp32_s3_dual_cdc_full.build_flags} + [env:heltec_v4_r8_sensor] extends = heltec_v4_r8_oled build_flags = @@ -320,6 +329,15 @@ lib_deps = ${heltec_v4_r8_tft.lib_deps} densaugeo/base64 @ ~1.4.0 +[env:heltec_v4_r8_tft_companion_radio_full] +extends = env:heltec_v4_r8_tft_companion_radio_wifi +platform = ${esp32_s3_dual_cdc_full.platform} +board_build.flash_mode = ${esp32_s3_full.board_build.flash_mode} +build_unflags = ${esp32_s3_dual_cdc_full.build_unflags} +build_flags = + ${env:heltec_v4_r8_tft_companion_radio_wifi.build_flags} + ${esp32_s3_dual_cdc_full.build_flags} + [env:heltec_v4_r8_tft_sensor] extends = heltec_v4_r8_tft build_flags = diff --git a/variants/lilygo_tbeam_1w/platformio.ini b/variants/lilygo_tbeam_1w/platformio.ini index 3ea0a689..41fb91db 100644 --- a/variants/lilygo_tbeam_1w/platformio.ini +++ b/variants/lilygo_tbeam_1w/platformio.ini @@ -176,6 +176,15 @@ lib_deps = ${LilyGo_TBeam_1W.lib_deps} densaugeo/base64 @ ~1.4.0 +[env:LilyGo_TBeam_1W_companion_radio_full] +extends = env:LilyGo_TBeam_1W_companion_radio_wifi +platform = ${esp32_s3_dual_cdc_full.platform} +board_build.flash_mode = ${esp32_s3_full.board_build.flash_mode} +build_unflags = ${esp32_s3_dual_cdc_full.build_unflags} +build_flags = + ${env:LilyGo_TBeam_1W_companion_radio_wifi.build_flags} + ${esp32_s3_dual_cdc_full.build_flags} + ; === LILYGO T-Beam 1W Repeater with ESPNow Bridge === [env:LilyGo_TBeam_1W_repeater_bridge_espnow] extends = LilyGo_TBeam_1W diff --git a/variants/meshnology_w12/platformio.ini b/variants/meshnology_w12/platformio.ini index 744d6a6e..69ee04a2 100644 --- a/variants/meshnology_w12/platformio.ini +++ b/variants/meshnology_w12/platformio.ini @@ -190,6 +190,15 @@ lib_deps = ${meshnology_w12.lib_deps} densaugeo/base64 @ ~1.4.0 +[env:meshnology_w12_companion_radio_full] +extends = env:meshnology_w12_companion_radio_wifi +platform = ${esp32_s3_dual_cdc_full.platform} +board_build.flash_mode = ${esp32_s3_full.board_build.flash_mode} +build_unflags = ${esp32_s3_dual_cdc_full.build_unflags} +build_flags = + ${env:meshnology_w12_companion_radio_wifi.build_flags} + ${esp32_s3_dual_cdc_full.build_flags} + [env:meshnology_w12_sensor] extends = meshnology_w12 build_flags = diff --git a/variants/nibble_screen_connect/platformio.ini b/variants/nibble_screen_connect/platformio.ini index 884a4ee0..d71784a1 100644 --- a/variants/nibble_screen_connect/platformio.ini +++ b/variants/nibble_screen_connect/platformio.ini @@ -139,8 +139,8 @@ lib_deps = ${nibble_screen_connect_base.lib_deps} densaugeo/base64 @ ~1.4.0 -[env:nibble_screen_connect_companion_radio_wifi_] -extends = nibble_screen_connect_base +[env:nibble_screen_connect_companion_radio_wifi_] +extends = nibble_screen_connect_base build_flags = ${nibble_screen_connect_base.build_flags} -I examples/companion_radio/ui-new @@ -157,12 +157,21 @@ build_src_filter = ${nibble_screen_connect_base.build_src_filter} + +<../examples/companion_radio/*.cpp> +<../examples/companion_radio/ui-new/*.cpp> -lib_deps = - ${nibble_screen_connect_base.lib_deps} - densaugeo/base64 @ ~1.4.0 - - -[env:nibble_screen_connect_kiss_modem_] +lib_deps = + ${nibble_screen_connect_base.lib_deps} + densaugeo/base64 @ ~1.4.0 + +[env:nibble_screen_connect_companion_radio_full_] +extends = env:nibble_screen_connect_companion_radio_wifi_ +platform = ${esp32_s3_dual_cdc_full.platform} +board_build.flash_mode = ${esp32_s3_full.board_build.flash_mode} +build_unflags = ${esp32_s3_dual_cdc_full.build_unflags} +build_flags = + ${env:nibble_screen_connect_companion_radio_wifi_.build_flags} + ${esp32_s3_dual_cdc_full.build_flags} + + +[env:nibble_screen_connect_kiss_modem_] extends = nibble_screen_connect_base build_src_filter = ${nibble_screen_connect_base.build_src_filter} +<../examples/kiss_modem/> diff --git a/variants/nibble_zero_connect/platformio.ini b/variants/nibble_zero_connect/platformio.ini index 55c215cd..1fa90aa6 100644 --- a/variants/nibble_zero_connect/platformio.ini +++ b/variants/nibble_zero_connect/platformio.ini @@ -157,3 +157,11 @@ lib_deps = ${nibble_zero_connect_base.lib_deps} densaugeo/base64 @ ~1.4.0 +[env:nibble_zero_connect_companion_radio_full_] +extends = env:nibble_zero_connect_companion_radio_wifi_ +platform = ${esp32_s3_dual_cdc_full.platform} +board_build.flash_mode = ${esp32_s3_full.board_build.flash_mode} +build_unflags = ${esp32_s3_dual_cdc_full.build_unflags} +build_flags = + ${env:nibble_zero_connect_companion_radio_wifi_.build_flags} + ${esp32_s3_dual_cdc_full.build_flags} diff --git a/variants/rak3112/platformio.ini b/variants/rak3112/platformio.ini index ea797cab..138b79c2 100644 --- a/variants/rak3112/platformio.ini +++ b/variants/rak3112/platformio.ini @@ -283,6 +283,10 @@ lib_deps = ${rak3112.lib_deps} densaugeo/base64 @ ~1.4.0 +[env:RAK_3112_companion_radio_full] +extends = env:RAK_3112_companion_radio_wifi +board_build.flash_mode = ${esp32_s3_full.board_build.flash_mode} + [env:RAK_3112_sensor] extends = rak3112 build_flags = diff --git a/variants/station_g2/platformio.ini b/variants/station_g2/platformio.ini index c316d79a..86cfeefc 100644 --- a/variants/station_g2/platformio.ini +++ b/variants/station_g2/platformio.ini @@ -4,6 +4,10 @@ board = station-g2 ; Preserve the original 0x10000/0x150000 dual-OTA layout so app-only updates do ; not require replacing the partition table or erasing an installed G2. board_build.partitions = default.csv +; The Arduino-ESP32 3.x board manifest defaults to QIO, but the Station G2 +; bootloader watchdog-resets before starting the app in that mode. The deployed +; hardware and the working 2.x bootloader both require DIO. +board_build.flash_mode = dio build_flags = ${esp32_base.build_flags} ${sensor_base.build_flags} @@ -318,6 +322,15 @@ lib_deps = ${Station_G2.lib_deps} densaugeo/base64 @ ~1.4.0 +[env:Station_G2_companion_radio_full] +extends = env:Station_G2_companion_radio_wifi +platform = ${esp32_s3_dual_cdc_full.platform} +board_build.flash_mode = ${esp32_s3_full.board_build.flash_mode} +build_unflags = ${esp32_s3_dual_cdc_full.build_unflags} +build_flags = + ${env:Station_G2_companion_radio_wifi.build_flags} + ${esp32_s3_dual_cdc_full.build_flags} + [env:Station_G2_kiss_modem] extends = Station_G2 build_flags = diff --git a/variants/station_g3_esp32/platformio.ini b/variants/station_g3_esp32/platformio.ini index 141cd781..2eae4442 100644 --- a/variants/station_g3_esp32/platformio.ini +++ b/variants/station_g3_esp32/platformio.ini @@ -156,6 +156,15 @@ lib_deps = ${Station_G3_ESP32.lib_deps} densaugeo/base64 @ ~1.4.0 +[env:Station_G3_ESP32_companion_radio_full] +extends = env:Station_G3_ESP32_companion_radio_wifi +platform = ${esp32_s3_dual_cdc_full.platform} +board_build.flash_mode = ${esp32_s3_full.board_build.flash_mode} +build_unflags = ${esp32_s3_dual_cdc_full.build_unflags} +build_flags = + ${env:Station_G3_ESP32_companion_radio_wifi.build_flags} + ${esp32_s3_dual_cdc_full.build_flags} + [env:Station_G3_ESP32_kiss_modem] extends = Station_G3_ESP32 build_src_filter = ${Station_G3_ESP32.build_src_filter} diff --git a/variants/xiao_s3_wio/platformio.ini b/variants/xiao_s3_wio/platformio.ini index 26099ba9..741b7157 100644 --- a/variants/xiao_s3_wio/platformio.ini +++ b/variants/xiao_s3_wio/platformio.ini @@ -302,6 +302,15 @@ lib_deps = ${Xiao_S3_WIO.lib_deps} densaugeo/base64 @ ~1.4.0 +[env:Xiao_S3_WIO_companion_radio_full] +extends = env:Xiao_S3_WIO_companion_radio_wifi +platform = ${esp32_s3_dual_cdc_full.platform} +board_build.flash_mode = ${esp32_s3_full.board_build.flash_mode} +build_unflags = ${esp32_s3_dual_cdc_full.build_unflags} +build_flags = + ${env:Xiao_S3_WIO_companion_radio_wifi.build_flags} + ${esp32_s3_dual_cdc_full.build_flags} + [env:Xiao_S3_WIO_sensor] extends = Xiao_S3_WIO build_src_filter = ${Xiao_S3_WIO.build_src_filter}