diff --git a/build.sh b/build.sh index 942a8966..821b35a5 100755 --- a/build.sh +++ b/build.sh @@ -83,7 +83,7 @@ Commands: list|-l: List firmwares available to build. build-firmware : Build the firmware for the given build target. build-firmwares: Build all firmwares for all targets. - build-firmwares-logging-matrix: Build all firmwares in standard, logging, MQTT, FULL ESP32 MQTT, and FULL ESP32 logging (no MQTT) profiles, logging each target under out/build-logs/ and continuing after failures. + build-firmwares-logging-matrix: Build all firmwares in standard, logging, FULL ESP32 MQTT, and FULL ESP32 logging (no MQTT) profiles, logging each target under out/build-logs/ and continuing after failures. MQTT observers and ESP-NOW bridges always use FULL. build-companion-firmwares-logging-matrix: Build every Companion target (including Full Companion and legacy FEM variants) in each applicable standard, logging, MQTT, and expanded FULL profile. build-full-esp32-firmwares: Build only feature-complete ESP32 MQTT profiles with up to 254 neighbors, LoRa OTA, and expanded dual-OTA partitions. build-full-esp32-logging-firmwares: Build only feature-complete ESP32 profiles with up to 254 neighbors, logging, MQTT disabled, LoRa OTA, and expanded dual-OTA partitions. @@ -117,7 +117,7 @@ $ bash build.sh Build all firmwares for device targets containing the string "RAK_4631" $ bash build.sh build-matching-firmwares -Build all firmwares in standard, USB logging, MQTT observer, feature-complete ESP32, and feature-complete ESP32 logging profiles: +Build all firmwares in standard, USB logging, feature-complete ESP32 MQTT, and feature-complete ESP32 logging profiles: $ bash build.sh build-firmwares-logging-matrix Build only feature-complete ESP32 firmware: @@ -2111,6 +2111,18 @@ supports_esp32_full_build() { && ! is_lora_ota_only_target "$env_name" } +requires_esp32_full_cli_profile() { + local env_name=$1 + + # MQTT observers and ESP-NOW bridges previously depended on a reduced CLI to + # fit the legacy application slot. Always build those roles with the expanded + # FULL partition profile so no administration commands are removed. + [ "${PIO_ENV_PLATFORM_BY_NAME[$env_name]:-}" = "ESP32_PLATFORM" ] \ + && ! is_esp32_companion_build "$env_name" \ + && { is_mqtt_bridge_target "$env_name" \ + || [[ "${env_name,,}" == *bridge_espnow* ]]; } +} + apply_esp32_lora_ota_size_profile() { local env_name=$1 @@ -2120,35 +2132,19 @@ apply_esp32_lora_ota_size_profile() { # All non-companion ESP32 artifacts must remain installable into the legacy # 0x10000..0x150000 app slot. The WebConfig portal is deliberately omitted. - # WiFi/MQTT observers and lean LoRa-OTA repeaters retain the compact browser - # updater; other radio-only roles avoid linking WiFi solely for that updater. + # Lean LoRa-OTA repeaters retain the compact browser updater; other radio-only + # roles avoid linking WiFi solely for that updater. MQTT observers and ESP-NOW + # bridges are always promoted to the expanded FULL profile so they retain the + # complete CLI and feature set. # Companions retain their target defaults because they are installed over USB. # Keep ENV_INCLUDE_GPS for boards with onboard GPS; their target sensor # managers require that support. export PLATFORMIO_BUILD_FLAGS="${PLATFORMIO_BUILD_FLAGS} -DWEBCONFIG_DISABLED=1" - if is_mqtt_bridge_target "$env_name"; then - # Keep the standard ESP-IDF libc. Use its ABI with the chip-ROM formatter, - # and retain a compact CLI for radio and active-bridge settings. - export PLATFORMIO_BUILD_FLAGS="${PLATFORMIO_BUILD_FLAGS} -DLIGHTWEIGHT_WIFI_OTA=1 -DPORTABLE_MQTT_OBSERVER=1 -DPORTABLE_ESP32_RADIO_CLI=1 -UDISPLAY_CLASS" - if [ "$FIRMWARE_FILENAME_INFIX" != "logging" ] \ - && [ "${MESHDEBUG_OVERRIDE,,}" != "on" ] \ - && [ "${PACKET_LOGGING_OVERRIDE,,}" != "on" ]; then - export PLATFORMIO_BUILD_FLAGS="${PLATFORMIO_BUILD_FLAGS} -DPORTABLE_ESP32_ROM_NANO_FORMAT=1" - fi - append_platformio_build_unflags "-DWITH_SNMP=1 -DMQTT_DEBUG=1 -DMQTT_MEMORY_DEBUG=1 -DDISPLAY_CLASS=SSD1306Display -DENV_INCLUDE_AHTX0=1 -DENV_INCLUDE_BME280=1 -DENV_INCLUDE_BMP280=1 -DENV_INCLUDE_SHTC3=1 -DENV_INCLUDE_SHT4X=1 -DENV_INCLUDE_LPS22HB=1 -DENV_INCLUDE_INA3221=1 -DENV_INCLUDE_INA219=1 -DENV_INCLUDE_INA226=1 -DENV_INCLUDE_INA260=1 -DENV_INCLUDE_MLX90614=1 -DENV_INCLUDE_VL53L0X=1 -DENV_INCLUDE_BME680=1 -DENV_INCLUDE_BMP085=1 -DENV_INCLUDE_RAK12035=1 -DENV_INCLUDE_BME680_BSEC=1" - elif [[ "${env_name,,}" == *bridge_espnow* ]]; then - append_platformio_build_unflags "-DLIGHTWEIGHT_WIFI_OTA=1" - export PLATFORMIO_BUILD_FLAGS="${PLATFORMIO_BUILD_FLAGS} -ULIGHTWEIGHT_WIFI_OTA -DDISABLE_WIFI_OTA=1 -DPORTABLE_ESP32_RADIO_CLI=1 -UDISPLAY_CLASS" - if [ "$FIRMWARE_FILENAME_INFIX" != "logging" ] \ - && [ "${MESHDEBUG_OVERRIDE,,}" != "on" ] \ - && [ "${PACKET_LOGGING_OVERRIDE,,}" != "on" ]; then - export PLATFORMIO_BUILD_FLAGS="${PLATFORMIO_BUILD_FLAGS} -DPORTABLE_ESP32_ROM_NANO_FORMAT=1" - fi - append_platformio_build_unflags "-DDISPLAY_CLASS=SSD1306Display -DENV_INCLUDE_AHTX0=1 -DENV_INCLUDE_BME280=1 -DENV_INCLUDE_BMP280=1 -DENV_INCLUDE_SHTC3=1 -DENV_INCLUDE_SHT4X=1 -DENV_INCLUDE_LPS22HB=1 -DENV_INCLUDE_INA3221=1 -DENV_INCLUDE_INA219=1 -DENV_INCLUDE_INA226=1 -DENV_INCLUDE_INA260=1 -DENV_INCLUDE_MLX90614=1 -DENV_INCLUDE_VL53L0X=1 -DENV_INCLUDE_BME680=1 -DENV_INCLUDE_BMP085=1 -DENV_INCLUDE_RAK12035=1 -DENV_INCLUDE_BME680_BSEC=1" - elif is_lora_ota_only_target "$env_name"; then + if is_lora_ota_only_target "$env_name"; then # The no-external-sensors image is also the self-updatable field image. Keep # manual browser OTA available on every ESP32 family through the compact - # uploader, and use the full one-byte neighbor-index range. + # uploader, the complete role CLI, and the full one-byte neighbor-index + # range. append_platformio_build_unflags "-DDISABLE_WIFI_OTA=1 -DMAX_NEIGHBOURS=50 -DMAX_NEIGHBOURS=8" export PLATFORMIO_BUILD_FLAGS="${PLATFORMIO_BUILD_FLAGS} -UDISABLE_WIFI_OTA -DLIGHTWEIGHT_WIFI_OTA=1 -DMAX_NEIGHBOURS=${ESP32_FULL_MAX_NEIGHBOURS}" else @@ -2167,7 +2163,7 @@ apply_esp32_full_size_profile() { fi # The FULL artifact uses expanded dual-OTA slots, so restore features that - # target or portable profiles disabled only to save application space. + # target or legacy-slot profiles disabled only to save application space. append_platformio_build_unflags "-DWEBCONFIG_DISABLED=1" export PLATFORMIO_BUILD_FLAGS="${PLATFORMIO_BUILD_FLAGS} -UWEBCONFIG_DISABLED -DWIFI_OTA_SEEDER=1 -DMESHCORE_ESP32_FULL_PROFILE=1" @@ -2215,12 +2211,15 @@ apply_repeater_neighbor_capacity() { export PLATFORMIO_BUILD_FLAGS="${PLATFORMIO_BUILD_FLAGS} -DMAX_NEIGHBOURS=${max_neighbours}" } -apply_nrf52_lora_ota_size_profile() { +apply_nrf52_size_profile() { local env_name=$1 - if [ "${PIO_ENV_PLATFORM_BY_NAME[$env_name]:-}" != "NRF52_PLATFORM" ] \ - || ! is_lora_ota_build "$env_name" \ - || ! is_lora_ota_only_target "$env_name"; then + if [ "${PIO_ENV_PLATFORM_BY_NAME[$env_name]:-}" != "NRF52_PLATFORM" ]; then + return 0 + fi + if ! is_nrf52_companion_radio_full_target "$env_name" \ + && { ! is_lora_ota_build "$env_name" \ + || ! is_lora_ota_only_target "$env_name"; }; then return 0 fi @@ -2228,7 +2227,8 @@ apply_nrf52_lora_ota_size_profile() { # runtime software Ed25519 fallback is linked alongside CC310, that setting # fully expands repeated Curve25519 arithmetic and wastes tens of kilobytes. # Keep hardware crypto, RNG mixing, the software fallback, and board features; - # only select the size optimizer for the constrained self-updatable image. + # select the size optimizer for both constrained self-updating images and + # Full Companion source images, especially their diagnostic profile. append_platformio_build_unflags "-Ofast" export PLATFORMIO_BUILD_FLAGS="${PLATFORMIO_BUILD_FLAGS} -Os" } @@ -2501,7 +2501,7 @@ collect_esp32_artifacts() { # Emit the partition-table signature for OTA partition-compatibility checks. # Standard builds keep the env-name key used by the slim-manifest generator; # FULL builds use a suffix so their expanded table does not overwrite the - # portable signature. The firmware computes the same signature at runtime. + # legacy-slot signature. The firmware computes the same signature at runtime. # Best-effort: local builds without the script's deps just skip it. if [ -f "${build_output_dir}/partitions.bin" ]; then if [ "$ESP32_FULL_BUILD" = "1" ]; then @@ -2669,6 +2669,14 @@ build_firmware() { local build_status local -a pio_run_args=() + # Bash functions use dynamic scoping. These locals let one target be promoted + # to FULL without changing the profile selected for later targets in the same + # batch. + local inherited_esp32_full_build=$ESP32_FULL_BUILD + local inherited_firmware_filename_infix=$FIRMWARE_FILENAME_INFIX + local ESP32_FULL_BUILD=$inherited_esp32_full_build + local FIRMWARE_FILENAME_INFIX=$inherited_firmware_filename_infix + env_platform=$(get_platform_for_env "$env_name") if ! is_supported_platform "$env_platform"; then echo "Unsupported or unknown platform for env: $env_name" @@ -2676,6 +2684,17 @@ build_firmware() { fi pio_env_name=$(get_pio_build_env "$env_name") + if [ "$ESP32_FULL_BUILD" != "1" ] \ + && requires_esp32_full_cli_profile "$env_name"; then + ESP32_FULL_BUILD=1 + if [ "$FIRMWARE_FILENAME_INFIX" = "logging" ]; then + FIRMWARE_FILENAME_INFIX="full-logging" + else + FIRMWARE_FILENAME_INFIX="full" + fi + echo "Promoting ${env_name} to FULL so the complete CLI is retained." + fi + commit_hash=$(git rev-parse --short HEAD) firmware_build_date=$(date -u '+%d-%b-%Y') firmware_build_epoch=$(date -u '+%s') @@ -2761,7 +2780,7 @@ build_firmware() { apply_esp32_lora_ota_size_profile "$env_name" apply_esp32_full_size_profile "$env_name" apply_repeater_neighbor_capacity "$env_name" - apply_nrf52_lora_ota_size_profile "$env_name" + apply_nrf52_size_profile "$env_name" apply_lora_ota_no_external_sensors_profile "$env_name" apply_radio_overrides apply_firmware_profile_overrides @@ -3440,8 +3459,8 @@ run_logging_matrix_build_targets() { local target local standard_targets=() local logging_targets=() + local filtered_logging_targets=() local constrained_logging_targets=() - local mqtt_targets=() local original_meshdebug_override=$MESHDEBUG_OVERRIDE local original_packet_logging_override=$PACKET_LOGGING_OVERRIDE local original_mqtt_bridge_override=$MQTT_BRIDGE_OVERRIDE @@ -3451,6 +3470,7 @@ run_logging_matrix_build_targets() { local original_profile_build_workers=$PROFILE_BUILD_WORKERS local bluetooth_skip_count=0 local lora_ota_only_skip_count=0 + local full_cli_logging_skip_count=0 local logging_target_count=0 local build_status=0 local pass_status=0 @@ -3464,14 +3484,13 @@ run_logging_matrix_build_targets() { echo "Option 3 parallelism: ${PROFILE_BUILD_WORKERS} target build(s), ${OPTION3_PIO_JOBS} PlatformIO job(s) per target." for target in "${targets[@]}"; do - if is_mqtt_bridge_target "$target"; then - mqtt_targets+=("$target") - else + if ! is_mqtt_bridge_target "$target"; then standard_targets+=("$target") fi done - echo "Profile 1/5: building ${#standard_targets[@]} standard target(s) with logging off and MQTT bridge off." + echo "Profile 1/4: building ${#standard_targets[@]} standard target(s) with logging off and MQTT bridge off." + echo "ESP32 ESP-NOW bridge targets in this pass are automatically promoted to FULL so they retain the complete CLI." ESP32_FULL_BUILD=0 MESHDEBUG_OVERRIDE="off" PACKET_LOGGING_OVERRIDE="off" @@ -3503,6 +3522,19 @@ run_logging_matrix_build_targets() { echo "Skipping ${lora_ota_only_skip_count} LoRa-OTA-only target(s) for logging-on pass because logging disables LoRa OTA." fi + filtered_logging_targets=() + for target in "${logging_targets[@]}"; do + if requires_esp32_full_cli_profile "$target"; then + full_cli_logging_skip_count=$((full_cli_logging_skip_count + 1)) + else + filtered_logging_targets+=("$target") + fi + done + logging_targets=("${filtered_logging_targets[@]}") + if [ "$full_cli_logging_skip_count" -gt 0 ]; then + echo "Deferring ${full_cli_logging_skip_count} ESP32 target(s) to the FULL logging pass so the complete CLI is retained." + fi + for target in "${logging_targets[@]}"; do if is_logging_size_constrained_target "$target"; then constrained_logging_targets+=("$target") @@ -3512,7 +3544,7 @@ run_logging_matrix_build_targets() { logging_target_count=$((${#logging_targets[@]} + ${#constrained_logging_targets[@]})) if [ "$logging_target_count" -gt 0 ]; then - echo "Profile 2/5: building ${logging_target_count} standard target(s) with logging on and MQTT bridge off." + echo "Profile 2/4: building ${logging_target_count} standard target(s) with logging on and MQTT bridge off." echo "Logging-on artifacts use filename form: name-logging-version" else echo "No non-Bluetooth targets remain for logging-on pass." @@ -3541,27 +3573,12 @@ run_logging_matrix_build_targets() { if [ "$pass_status" -ne 0 ]; then build_status=1; fi fi - if [ ${#mqtt_targets[@]} -gt 0 ]; then - echo "Profile 3/5: building ${#mqtt_targets[@]} MQTT bridge target(s) for direct radio-to-MQTT forwarding over WiFi, with logging off." - MESHDEBUG_OVERRIDE="off" - PACKET_LOGGING_OVERRIDE="off" - MQTT_BRIDGE_OVERRIDE="on" - MQTT_DEBUG_OVERRIDE="off" - FIRMWARE_FILENAME_INFIX="" - run_logged_build_targets "${mqtt_targets[@]}" - pass_status=$? - if [ "$pass_status" -eq 130 ]; then return 130; fi - if [ "$pass_status" -ne 0 ]; then build_status=1; fi - else - echo "No MQTT bridge targets are configured; skipping profile 3/5." - fi - - run_full_esp32_profile "Profile 4/5" "off" "${targets[@]}" + run_full_esp32_profile "Profile 3/4" "off" "${targets[@]}" pass_status=$? if [ "$pass_status" -eq 130 ]; then return 130; fi if [ "$pass_status" -ne 0 ]; then build_status=1; fi - run_full_esp32_profile "Profile 5/5" "on" "${targets[@]}" + run_full_esp32_profile "Profile 4/4" "on" "${targets[@]}" pass_status=$? if [ "$pass_status" -eq 130 ]; then return 130; fi if [ "$pass_status" -ne 0 ]; then build_status=1; fi @@ -3689,7 +3706,7 @@ main() { echo "Skipping separate debug and MQTT prompts; FULL everything enables logging and explicitly disables MQTT." elif is_automatic_profile_command "${SELECTED_COMMAND_ARGS[0]}"; then if is_logging_matrix_command "${SELECTED_COMMAND_ARGS[0]}"; then - echo "Skipping debug and MQTT prompts; this action builds all five profiles automatically." + echo "Skipping debug and MQTT prompts; this action builds all four profiles automatically." elif is_full_esp32_logging_command "${SELECTED_COMMAND_ARGS[0]}"; then echo "Skipping debug and MQTT prompts; this action builds only the FULL ESP32 logging profile with MQTT disabled." else diff --git a/docs/WiFi.md b/docs/WiFi.md index 95775838..816d4544 100644 --- a/docs/WiFi.md +++ b/docs/WiFi.md @@ -167,9 +167,9 @@ stop webconfig set bridge.enabled on ``` -Some size-constrained, portable MQTT observer artifacts omit WebConfig so they -fit the legacy ESP32 application slot. They retain the serial/remote CLI and a -small WiFi updater. Configure those builds with the CLI. +Current MQTT observer artifacts use the expanded FULL partition profile so +WebConfig and the complete role CLI are retained. Install the matching merged +image over USB once when moving a device from the legacy partition layout. ## WiFi companion setup @@ -374,11 +374,11 @@ role. | Build profile | WiFi/MQTT behavior | |---|---| -| Standard | Uses the selected target's role. Ordinary portable ESP32 repeater/room-server artifacts omit WebConfig to fit the legacy app slot. Explicit MQTT and WiFi-companion targets still use WiFi. | -| Logging | Enables USB/debug packet logging and disables the MQTT bridge. Logging output is not an MQTT uplink. | -| MQTT | Builds explicit MQTT observer or WiFi-companion-MQTT targets with USB packet logging off. | +| Standard | Uses the selected target's role. Ordinary legacy-slot ESP32 repeater/room-server artifacts omit WebConfig when needed to fit. ESP32 MQTT observer and ESP-NOW bridge targets are automatically promoted to FULL; WiFi-companion targets keep their companion partition profile. | +| Logging | Enables USB/debug packet logging and disables the MQTT bridge. `get/set usb.logging` can silence or restore the live output until reboot. Logging output is not an MQTT uplink. | +| MQTT | Builds explicit MQTT observer or WiFi-companion-MQTT targets with USB packet logging off. Non-companion ESP32 MQTT observers always use FULL expanded partitions. | | FULL ESP32 | Uses the board's MQTT target with logging off, expanded dual-OTA partitions, up to 254 neighbors, LoRa OTA, and full-size ESP32 features such as WebConfig where supported. Classic T-Beam MQTT observers retain their 50-entry table because their persistent discovery state exhausts internal DRAM at 254. | -| FULL ESP32 logging | Uses the board's non-MQTT target with debug and packet logging enabled, expanded dual-OTA partitions, up to 254 neighbors, and LoRa OTA. | +| FULL ESP32 logging | Uses the board's non-MQTT target with debug and packet logging enabled, session-only `get/set usb.logging` control, expanded dual-OTA partitions, up to 254 neighbors, and LoRa OTA. | | LoRa-OTA no-external-sensors | A lean repeater image with no MQTT; ESP32 builds retain the compact on-demand browser WiFi uploader and 254 neighbors. | All repeater profiles use the full 254-entry neighbor table, including standard, @@ -388,7 +388,7 @@ persistent MQTT discovery state leaves insufficient internal-DRAM margin at 254. The interactive Option 1 **FULL everything** choice selects the FULL logging profile: logging is enabled and MQTT is disabled. The standalone FULL ESP32 -profile and Profile 4 of the five-profile matrix use the matching MQTT target +profile and Profile 3 of the four-profile matrix use the matching MQTT target instead. Both FULL profiles include LoRa OTA, WebConfig where supported, up to 254 neighbors, and expanded dual-OTA partitions. Target-specific internal-DRAM limits still apply. @@ -432,8 +432,8 @@ their role-specific interfaces described above; they do not expose the full infrastructure WiFi CLI family. MQTT commands such as `get mqtt.status` and `set mqtt1.preset ...` still require an MQTT observer target. Unknown settings return `Error: unknown setting: -`. Older portable builds can instead report `Unsupported in this -firmware` when a command was intentionally cut for space. +`. Older firmware that used the discontinued compact CLI can instead +report `Unsupported in this firmware` when a command was cut for space. Check the complete firmware filename and role. In particular: @@ -467,7 +467,7 @@ Common causes are: - all MQTT slots disabled; - too many TLS/WSS slots for the available internal memory; - WiFi power saving being too aggressive; -- the wrong firmware role or a portable build without the full WebConfig CLI. +- the wrong firmware role or an older compact-CLI build without WebConfig. For a WiFi companion, find its station IP in the router, connect the client to TCP port 5000, and use the setup AP if it cannot join the saved network. MQTT diff --git a/docs/cli_build_matrix.md b/docs/cli_build_matrix.md index 2ee29891..bf6a7ab9 100644 --- a/docs/cli_build_matrix.md +++ b/docs/cli_build_matrix.md @@ -3,7 +3,7 @@ MeshCore command availability is determined in three layers: 1. **Role** - repeater, room server, sensor, companion, bridge, or KISS modem. -2. **Build profile** - standard, portable, logging, OTA, or FULL. +2. **Build profile** - standard, logging, OTA, or FULL. 3. **Compiled hardware features** - WiFi, MQTT, GPS, external sensors, PSRAM, Ethernet, and similar optional support. @@ -36,57 +36,35 @@ retain 50 because their MQTT discovery tables are constrained by internal DRAM. | Build/profile | Command availability | |---|---| | Standard non-MQTT repeater or room server | Keeps the normal role CLI. Size-constrained ESP32 artifacts can omit WebConfig and browser WiFi OTA, so their WebConfig/WiFi commands are unavailable. | -| Standard logging | Logging does not remove commands by itself. It has the same CLI as the selected role/profile and adds the compiled logging behavior. | -| LoRa-OTA (`-ota-`) | LoRa OTA adds the `ota ...` commands; it does not otherwise reduce the role CLI. ESP32 `no_external_sensors` artifacts retain the compact browser WiFi uploader and use a 254-entry neighbor table. A portable OTA artifact can still have the other portable restrictions described below. | -| Portable MQTT observer | Keeps MQTT/WiFi commissioning, bridge control, radio essentials, update commands, basic identity/status commands, `neighbors`, `discover.neighbors`, `outpath`, and `altpath`. The large repeater administration tree is omitted to fit the legacy ESP32 application slot. | -| Portable ESP-NOW bridge | Keeps the repeater's role-specific handlers and a reduced common configuration surface containing radio and bridge essentials, including `rxdelay`, `txdelay`, `outpath`, and `altpath`. | -| FULL ESP32 | Uses the matching MQTT target with logging off, removes size-based CLI cuts, and restores the complete command surface supported by that role and hardware. | -| FULL ESP32 logging | Uses the matching non-MQTT target with debug and packet logging enabled and the complete command surface supported by that role and hardware. | +| Standard logging | Logging does not remove commands by itself. It has the same CLI as the selected role/profile, adds the compiled logging behavior, and provides session-only `get/set usb.logging` control. | +| LoRa-OTA (`-ota-`) | LoRa OTA adds the `ota ...` commands; it does not otherwise reduce the role CLI. ESP32 `no_external_sensors` artifacts retain the compact browser WiFi uploader, the complete CLI, and a 254-entry neighbor table. | +| ESP32 MQTT observer or ESP-NOW bridge | Always uses the expanded FULL partition profile. The build never substitutes a reduced CLI to fit the legacy application slot. | +| FULL ESP32 | Uses the matching MQTT target with logging off and keeps the complete command surface supported by that role and hardware. | +| FULL ESP32 logging | Uses the matching non-MQTT target with debug and packet logging enabled, session-only `get/set usb.logging` control, and the complete command surface supported by that role and hardware. | | `no_external_sensors` | Removes optional external-sensor drivers and their settings; it does not remove core repeater discovery or routing commands. | `logging`, `OTA`, and `FULL` describe independent build features. Do not infer that a command is missing merely because `logging` appears in the filename. -## Portable MQTT observer retained surface +## Complete CLI policy -The portable MQTT observer keeps these command groups: +The compact ESP32 CLI has been removed. MQTT observers and ESP-NOW bridges are +automatically promoted to FULL builds with expanded partitions rather than +dropping administration commands. This keeps `tempradio`, LoRa OTA, power +saving, RXPS, logging, statistics, sensor, ACL, routing, and advanced radio +commands whenever the role and compiled hardware support them. -- lifecycle and identity: `reboot`, `poweroff`, `shutdown`, `ver`, `board`, - `password`, and `erase` on the local console; -- radio operation: `advert`, `advert.zerohop`, `clock`, `clock sync`, `time`, - `memory`, `neighbors`, `discover.neighbors`, and the remote-client routing - controls `outpath` and `altpath`; -- browser/update control when compiled: `start ota`, `stop ota`, `ota check`, - and `ota update`; -- radio essentials through `get`/`set`: radio parameters, TX power, CAD, - interference threshold, AGC reset interval, RX gain, `rxdelay`, `txdelay`, - repeat state, and applicable FEM controls; -- MQTT, WiFi, NTP, bridge, and alert commands implemented by the observer - feature set; -- region and onboard-GPS commands that fit and are compiled into the selected - target. - -Everything in the repeater-only administration tree that is not listed above is -intentionally cut from this portable profile. The main omissions include ACL -editing, flood filter/moderation/scope administration, advanced mesh-clock -controls, recent-repeater/path administration, battery-alert/RX-watchdog -controls, stored-log management, and external-sensor administration. Use the -matching FULL ESP32 build when those commands are required. - -Some observer commands have their own hardware limit: - -- MQTT neighbor-table publishing and `discover.scopes` require the compiled - `WITH_MQTT_NEIGHBORS` feature. PSRAM boards enable it automatically; selected - non-PSRAM variants opt in with `MQTT_NEIGHBORS_WITHOUT_PSRAM`. The commands - can therefore be present in either portable or FULL MQTT profiles. -- `discover.neighbors` does **not** require MQTT or PSRAM. -- full NTP connectivity diagnostics are omitted from the portable profile. +Some commands still have hardware or feature limits. MQTT neighbor-table +publishing and `discover.scopes` require the compiled `WITH_MQTT_NEIGHBORS` +feature. PSRAM boards enable it automatically, and selected non-PSRAM variants +opt in with `MQTT_NEIGHBORS_WITHOUT_PSRAM`. `discover.neighbors` does not +require MQTT or PSRAM. ## Discovery invariant `discover.neighbors` sends the zero-hop node-discovery request used to refresh the repeater neighbor table. It is available in every repeater build profile, -including portable MQTT, standard, logging, OTA, FULL, and FULL logging builds. +including standard, logging, OTA, FULL, and FULL logging builds. The exact command is: diff --git a/docs/cli_command_availability.md b/docs/cli_command_availability.md index ed24e4de..b87f70c3 100644 --- a/docs/cli_command_availability.md +++ b/docs/cli_command_availability.md @@ -17,11 +17,8 @@ Build columns mean: remove commands by itself. - **LoRa OTA** - the explicit `-ota-` repeater or repeater-bridge artifact. Its optional external-sensor drivers are removed, but onboard GPS is retained. -- **MQTT portable** - the size-reduced ESP32 MQTT observer for the legacy - application slot. -- **ESP-NOW portable** - the size-reduced ESP32 ESP-NOW repeater bridge. -- **FULL MQTT** - the expanded-partition ESP32 MQTT artifact with LoRa OTA and - the complete parser. +- **FULL** - the expanded-partition ESP32 artifact with LoRa OTA and the + complete parser. MQTT observers and ESP-NOW bridges always use this profile. - **FULL logging** - the expanded-partition ESP32 non-MQTT artifact with logging, LoRa OTA, and the complete parser. @@ -71,6 +68,7 @@ fix, no WiFi connection, an inactive bridge, or an nRF52 bootloader without | Statistics | [`get telemetry.temp/volt`; optional GPS history; `get/set telemetry.tx`](cli_commands.md#read-repeater-telemetry-history) | Non-STM32 repeater; GPS commands require a GPS provider; remote access requires administrator | Yes | Yes | Yes | | Logging | [`log start`; `log stop`; `log erase`](cli_commands.md#logging) | Storage-backed roles retain data; other roles can return empty data | Yes | Yes | Yes | | Logging | [`log`](cli_commands.md#print-the-captured-log-to-the-serial-terminal) | Local serial | Serial | Serial | Serial | +| Logging | [`get/set usb.logging`](cli_commands.md#control-live-usb-logging) | Logging artifacts; session-only live USB output gate | No | Yes | No | | 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 ...`](cli_commands.md#change-the-radio-parameters-for-a-set-duration) | Full parser | Yes | Yes | Yes | @@ -199,160 +197,161 @@ fix, no WiFi connection, an inactive bridge, or an nRF52 bootloader without ## ESP32 -| Area | Command | Scope | Standard | Logging | LoRa OTA | MQTT portable | ESP-NOW portable | FULL MQTT | FULL logging | -|---|---|---|---|---|---|---|---|---|---| -| Operational | [`reboot`](cli_commands.md#reboot-the-node) | All text CLI roles | Yes | Yes | Yes | Yes | Yes | Yes | Yes | -| Operational | [`poweroff`; `shutdown`](cli_commands.md#power-off-the-node) | Board power-off support | Yes | Yes | Yes | Yes | Yes | Yes | Yes | -| Operational | [`uf2reset`](cli_commands.md#enter-the-uf2-bootloader-nrf52-only) | nRF52 only | No | No | No | No | No | No | No | -| Operational | [`clkreboot`](cli_commands.md#reset-the-clock-and-reboot) | Full parser | Yes | Yes | Yes | No | No | Yes | Yes | -| Operational | [`clock sync`](cli_commands.md#sync-the-clock-with-the-remote-device) | All text CLI roles | Yes | Yes | Yes | Yes | Yes | Yes | Yes | -| Operational | [`clock`](cli_commands.md#display-current-time-in-utc) | All text CLI roles | Yes | Yes | Yes | Yes | Yes | Yes | Yes | -| Operational | [`time `](cli_commands.md#set-the-time-to-a-specific-timestamp) | Clock only moves forward | Yes | Yes | Yes | Yes | Yes | Yes | Yes | -| Operational | [`advert`](cli_commands.md#send-a-flood-advert) | Advert-capable role | Yes | Yes | Yes | Yes | Yes | Yes | Yes | -| Operational | [`advert.zerohop`](cli_commands.md#send-a-zero-hop-advert) | Advert-capable role | Yes | Yes | Yes | Yes | Yes | Yes | Yes | -| Operational | [`erase`](cli_commands.md#erasefactory-reset) | Local serial | Serial | Serial | Serial | Serial | Serial | Serial | Serial | -| Information | [`ver`](cli_commands.md#get-the-version) | All text CLI roles | Yes | Yes | Yes | Yes | Yes | Yes | Yes | -| Information | [`board`](cli_commands.md#show-the-hardware-name) | All text CLI roles | Yes | Yes | Yes | Yes | Yes | Yes | Yes | -| Diagnostics | [`memory`](#memory) | ESP32 heap and PSRAM summary | Yes | Yes | Yes | Yes | Yes | Yes | Yes | -| Diagnostics | [`sensor`](#sensor-hardware-summary) | Hardware wiring summary | Yes | Yes | Yes | Yes | Yes | Yes | Yes | -| Diagnostics | [`powerlog`](#powerlog) | Reset-reason summary | Yes | Yes | Yes | Yes | Yes | Yes | Yes | -| Neighbors | [`neighbors`](cli_commands.md#list-nearby-neighbors) | Role with a neighbor table | Yes | Yes | Yes | Yes | Yes | Yes | Yes | -| Neighbors | [`neighbor.remove `](cli_commands.md#remove-a-neighbor) | Role with a mutable neighbor table | Yes | Yes | Yes | No | No | Yes | Yes | -| Neighbors | [`discover.neighbors`](cli_commands.md#discover-zero-hop-neighbors) | Repeater; some MQTT room servers | Yes | Yes | Yes | Yes | Yes | Yes | Yes | -| Neighbors | [`discover.scopes`](cli_commands.md#discover-neighbor-scopes-mqtt-observer-neighbors-feature) | MQTT observer with compiled neighbor support | No | No | No | Feature | No | Feature | No | -| Statistics | [`clear stats`](cli_commands.md#clear-stats) | Full parser | Yes | Yes | Yes | No | No | Yes | Yes | -| Statistics | [`stats-core`](cli_commands.md#stats-core) | Local serial | Serial | Serial | Serial | No | No | Serial | Serial | -| Statistics | [`stats-radio`](cli_commands.md#stats-radio) | Local serial | Serial | Serial | Serial | No | No | Serial | Serial | -| Statistics | [`stats-radio-diag`](#stats-radio-diag) | Local serial | Serial | Serial | Serial | No | No | Serial | Serial | -| Statistics | [`stats-packets`](cli_commands.md#stats-packets) | Local serial | Serial | Serial | Serial | No | No | Serial | Serial | -| 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 | Yes | Yes | -| Logging | [`log start`; `log stop`; `log erase`](cli_commands.md#logging) | Storage-backed roles retain data | Yes | Yes | Yes | No | No | Yes | Yes | -| Logging | [`log`](cli_commands.md#print-the-captured-log-to-the-serial-terminal) | Local serial | Serial | Serial | Serial | No | No | Serial | Serial | -| Radio | [`get radio`; `set radio ...`](cli_commands.md#view-or-change-this-nodes-radio-parameters) | All text CLI roles | Yes | Yes | 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 | Yes | Yes | -| Radio | [`tempradio ...`](cli_commands.md#change-the-radio-parameters-for-a-set-duration) | Full parser | Yes | Yes | Yes | No | No | Yes | Yes | -| Radio | [`get/set/del radioat`; `get/set/del tempradioat`](cli_commands.md#schedule-radio-parameter-changes) | Full parser | Yes | Yes | Yes | No | No | Yes | Yes | -| Radio | [`get freq`; `set freq `](cli_commands.md#view-or-change-this-nodes-frequency) | `set` is local serial only | Yes | Yes | Yes | Yes | Yes | Yes | Yes | -| Radio | [`get/set radio.rxgain`](cli_commands.md#view-or-change-this-nodes-rx-boosted-gain-mode-sx12xx-and-lr1110-v1141) | Supported radio | Feature | Feature | Feature | Feature | Feature | Feature | Feature | -| Radio | [`get/set radio.fem.rxgain`](cli_commands.md#view-or-change-the-lora-fem-receive-path-gain-state-on-supported-boards) | Controllable LoRa FEM | Feature | Feature | Feature | Feature | Feature | Feature | Feature | -| Radio | [`get/set radio.fem.txgain`](cli_commands.md#view-or-change-the-lora-fem-transmit-path-gain-state-on-supported-boards) | Controllable LoRa FEM | Feature | Feature | Feature | Feature | Feature | Feature | Feature | -| Radio | [`get/set radio.rxps`; `get rxps.wd`](#radio-rxps) | Full parser and RX power-saving support | Feature | Feature | Feature | No | No | Feature | Feature | -| System | [`get/set name`](cli_commands.md#view-or-change-this-nodes-name) | All full-parser text CLI roles | Yes | Yes | Yes | No | No | Yes | Yes | -| System | [`get/set lat`](cli_commands.md#view-or-change-this-nodes-latitude) | All full-parser text CLI roles | Yes | Yes | Yes | No | No | Yes | Yes | -| System | [`get/set lon`](cli_commands.md#view-or-change-this-nodes-longitude) | All full-parser text CLI roles | Yes | Yes | Yes | No | No | Yes | Yes | -| System | [`get/set prv.key`](cli_commands.md#view-or-change-this-nodes-identity-private-key) | `get` is local serial only | Yes | Yes | Yes | No | No | Yes | Yes | -| System | [`password `](cli_commands.md#change-this-nodes-admin-password) | Administrator | Yes | Yes | Yes | Yes | Yes | Yes | Yes | -| System | [`get/set guest.password`](cli_commands.md#view-or-change-this-nodes-guest-password) | Role with guest administration | Yes | Yes | Yes | No | No | Yes | Yes | -| System | [`get/set owner.info`](cli_commands.md#view-or-change-this-nodes-owner-info) | Full parser | Yes | Yes | Yes | No | No | Yes | Yes | -| System | [`get/set adc.multiplier`](cli_commands.md#fine-tune-the-battery-reading) | Board ADC override support | Feature | Feature | Feature | No | No | Feature | Feature | -| System | [`send text.flood `](cli_commands.md#send-a-repeater-flood-text) | Repeater | Yes | Yes | Yes | No | Yes | Yes | Yes | -| System | [`get/set battery.alert`; `get battery.alert.region`](cli_commands.md#view-or-change-battery-alert-state) | Repeater | Yes | Yes | Yes | No | Yes | Yes | Yes | -| System | [`get/set battery.alert.low`; `get/set battery.alert.critical`](cli_commands.md#view-or-change-battery-alert-thresholds) | Repeater | Yes | Yes | Yes | No | Yes | Yes | Yes | -| System | [`get/set rx.watchdog`](cli_commands.md#enable-or-disable-the-rx-inactivity-watchdog-repeater-only) | Repeater | Yes | Yes | Yes | No | Yes | Yes | Yes | -| System | [`get/set system.watchdog`](cli_commands.md#enable-or-disable-the-nrf52-system-watchdog) | nRF52 only | No | No | No | No | No | No | No | -| System | [`get public.key`](cli_commands.md#view-this-nodes-public-key) | Full parser | Yes | Yes | Yes | No | No | Yes | Yes | -| System | [`get role`](cli_commands.md#view-this-nodes-configured-role) | All text CLI roles | Yes | Yes | Yes | Yes | Yes | Yes | Yes | -| System | [`powersaving`; `powersaving on/off`](cli_commands.md#view-or-change-this-nodes-power-saving-flag) | Supported repeater board | Feature | Feature | Feature | No | No | Feature | Feature | -| System | [`get/set reboot.interval`](#reboot-interval) | Full parser | Yes | Yes | Yes | No | No | Yes | Yes | -| Clock sync | [`get/set clock.sync.*`; `clock.sync.mesh now`](cli_commands.md#estimate-and-correct-infrastructure-node-time-after-startup) | Repeater, sensor, and room server; `clock.sync.internet` needs MQTT repeater | Yes | Yes | Yes | No | Yes | Yes | Yes | -| Routing | [`get/set repeat`](cli_commands.md#view-or-change-this-nodes-repeat-flag) | Forwarding-capable role | Yes | Yes | Yes | Yes | Yes | Yes | Yes | -| Routing | [`get/set path.hash.mode`](cli_commands.md#view-or-change-this-nodes-advert-path-hash-size) | Full parser | Yes | Yes | Yes | No | No | Yes | Yes | -| Routing | [`get/set loop.detect`](cli_commands.md#view-or-change-this-nodes-loop-detection) | Repeater, full common parser | Yes | Yes | Yes | No | No | Yes | Yes | -| Routing | [`get/set txdelay`](cli_commands.md#view-or-change-the-retransmit-delay-factor-for-flood-traffic) | All text CLI roles, including bridges | Yes | Yes | Yes | Yes | Yes | Yes | Yes | -| Routing | [`get/set direct.txdelay`](cli_commands.md#view-or-change-the-retransmit-delay-factor-for-direct-traffic) | Full parser | Yes | Yes | Yes | No | No | Yes | Yes | -| Routing | [`get/set rxdelay`](cli_commands.md#experimental-view-or-change-the-processing-delay-for-received-traffic) | All text CLI roles, including bridges | Yes | Yes | Yes | Yes | Yes | Yes | Yes | -| Routing | [`get/set dutycycle`](cli_commands.md#view-or-change-the-duty-cycle-limit) | Full parser | Yes | Yes | Yes | No | No | Yes | Yes | -| Routing | [`get/set af`](cli_commands.md#view-or-change-the-airtime-factor-duty-cycle-limit) | Full parser | Yes | Yes | Yes | No | No | Yes | Yes | -| Routing | [`get/set int.thresh`](cli_commands.md#view-or-change-the-local-interference-threshold) | All text CLI roles | Yes | Yes | Yes | Yes | Yes | Yes | Yes | -| Routing | [`get/set cad`](cli_commands.md#enable-or-disable-hardware-channel-activity-detection-cad) | Radio CAD support | Feature | Feature | Feature | Feature | Feature | Feature | Feature | -| Routing | [`get/set agc.reset.interval`](cli_commands.md#view-or-change-the-agc-reset-interval) | All text CLI roles | Yes | Yes | Yes | Yes | Yes | Yes | Yes | -| Routing | [`get/set radio.watchdog`](cli_commands.md#view-or-change-the-radio-watchdog-interval-mqtt-observer-only) | MQTT observer | No | No | No | Yes | No | Yes | No | -| Routing | [`get/set multi.acks`](cli_commands.md#enable-or-disable-multi-acks-support) | Full parser | Yes | Yes | Yes | No | No | Yes | Yes | -| Routing | [`get/set flood.advert.interval`](cli_commands.md#view-or-change-the-flood-advert-interval) | Repeater, full common parser | Yes | Yes | Yes | No | No | Yes | Yes | -| Routing | [`get/set advert.interval`](cli_commands.md#view-or-change-the-zero-hop-advert-interval) | Full common parser | Yes | Yes | Yes | No | No | Yes | Yes | -| Routing | [`get/set flood.max`](cli_commands.md#limit-the-number-of-hops-for-a-flood-message) | Repeater, full common parser | Yes | Yes | Yes | No | No | Yes | Yes | -| Routing | [`get/set flood.max.unscoped`](cli_commands.md#limit-the-number-of-hops-for-an-unscoped-flood-message) | Repeater, full common parser | Yes | Yes | Yes | No | No | Yes | Yes | -| Routing | [`get/set flood.max.advert`](cli_commands.md#limit-the-number-of-hops-for-an-advert-flood-message) | Repeater, full common parser | Yes | Yes | Yes | No | No | Yes | Yes | -| Routing | [`get/set flood.channel.data`; `get/set flood.channel.data.hops`](cli_commands.md#forward-flood-group-data-packets-on-repeaters) | Repeater, full common parser | Yes | Yes | Yes | No | No | Yes | Yes | -| Routing | [`get/set/del flood.channel.scope*`](cli_commands.md#force-a-transport-scope-onto-floods) | Repeater role handler | Yes | Yes | Yes | No | Yes | Yes | Yes | -| Routing | [`get/set/del flood.channel.scope.require*`](cli_commands.md#require-valid-incoming-scopes-only-on-selected-channels) | Repeater role handler | Yes | Yes | Yes | No | Yes | Yes | Yes | -| Routing | [`get/set/del flood.rule*`; `get/set/del flood.filter*`; `get/set/del flood.filter.blacklist*`](cli_commands.md#change-persistent-flood-rules-in-the-field) | Repeater role handler; `flood.rule`/`flood.filter` also on FULL ESP32 room server (no blacklist) | Yes | Yes | Yes | No | Yes | Yes | Yes | -| Routing | [`get/set/del flood.moderation*`](cli_commands.md#moderate-flood-group-text-by-channel-sender-and-source-path) | Repeater role handler | Yes | Yes | Yes | No | Yes | Yes | Yes | -| Routing | [`get/set outpath`](halo_keymind_settings.md#direct-path-overrides) | Repeater remote-client context | Yes | Yes | Yes | Yes | Yes | Yes | Yes | -| Routing | [`get/set altpath`](halo_keymind_settings.md#direct-path-overrides) | Repeater remote-client context | Yes | Yes | Yes | Yes | Yes | Yes | Yes | -| ACL | [`setperm `](cli_commands.md#add-update-or-remove-permissions-for-a-companion) | Repeater, room server, or sensor | Yes | Yes | Yes | No | Yes | Yes | Yes | -| ACL | [`get acl`](cli_commands.md#view-the-current-acl) | Local serial | Serial | Serial | Serial | No | Serial | Serial | Serial | -| ACL | [`get/set allow.read.only`](cli_commands.md#view-or-change-this-room-servers-read-only-flag) | Room server | Yes | Yes | No | No | No | Yes | Yes | -| Regions | [`region load`](cli_commands.md#bulk-load-region-lists); [`region save`](cli_commands.md#save-any-changes-to-regions-made-since-reboot) | Role with region storage | Yes | Yes | Yes | Yes | Yes | Yes | Yes | -| Regions | [`region allowf`](cli_commands.md#allow-a-region); [`region denyf`](cli_commands.md#block-a-region) | Role with region storage | Yes | Yes | Yes | Yes | Yes | Yes | Yes | -| Regions | [`region get`](cli_commands.md#show-information-for-a-region); [`region list`](cli_commands.md#view-all-regions); [`region`](cli_commands.md#dump-all-defined-regions-and-flood-permissions) | Role with region storage | Yes | Yes | Yes | Yes | Yes | Yes | Yes | -| Regions | [`region home`](cli_commands.md#view-or-change-the-home-region-for-this-node); [`region default`](cli_commands.md#view-or-change-the-default-scope-region-for-this-node) | Role with region storage | Yes | Yes | Yes | Yes | Yes | Yes | Yes | -| Regions | [`region put`](cli_commands.md#create-a-new-region); [`region def`](cli_commands.md#define-region-hierarchy-single-line); [`region remove`](cli_commands.md#remove-a-region) | Role with region storage | Yes | Yes | Yes | Yes | Yes | Yes | Yes | -| Retry | [`get/set direct.retry`](cli_commands.md#view-or-change-direct-retry-state); [`get/set direct.retry.heard`](cli_commands.md#view-or-change-direct-retry-heard-table-gate) | Role with basic retry support | Yes | Yes | Yes | No | No | Yes | Yes | -| Retry | [`get/set retry.preset`](cli_commands.md#view-or-apply-a-retry-preset) | Role with retry support | Yes | Yes | Yes | No | No | Yes | Yes | -| Retry | [`get/set flood.retry.count`](cli_commands.md#view-or-change-flood-retry-count); [`get/set flood.retry.path`](cli_commands.md#view-or-change-flood-retry-path-gate); [`get/set flood.retry.group.path`](cli_commands.md#view-or-change-the-group-data-flood-retry-path-gate) | Repeater | Yes | Yes | Yes | No | No | Yes | Yes | -| Retry | [`get/set flood.retry.advert`](cli_commands.md#view-or-change-flood-retry-advert-handling); [`get/set flood.retry.prefixes`](cli_commands.md#view-or-change-flood-retry-target-prefixes); [`get/set flood.retry.ignore`](cli_commands.md#view-or-change-flood-retry-ignored-prefixes) | Repeater | Yes | Yes | Yes | No | No | Yes | Yes | -| Retry | [`get/set flood.retry.bridge`](cli_commands.md#view-or-change-flood-retry-bridge-mode); [`get/set flood.retry.bucket`](cli_commands.md#view-or-change-flood-retry-bridge-buckets) | Repeater bridge retry support | Feature | Feature | Feature | No | No | Feature | Feature | -| Retry | [`get/set direct.retry.count`](cli_commands.md#view-or-change-direct-retry-count); [`get/set direct.retry.base`](cli_commands.md#view-or-change-direct-retry-base-delay); [`get/set direct.retry.step`](cli_commands.md#view-or-change-direct-retry-step-delay) | Role with retry support | Yes | Yes | Yes | No | No | Yes | Yes | -| Retry | [`get/set direct.retry.margin`](cli_commands.md#view-or-change-direct-retry-snr-margin); [`get/set direct.retry.cr`](cli_commands.md#view-or-change-adaptive-direct-retry-coding-rate) | Role with retry support | Yes | Yes | Yes | No | No | Yes | Yes | -| Retry | [`get/set/clear recent.repeater`; `get recent.repeaters`](cli_commands.md#view-seed-or-clear-the-recent-repeater-table) | Repeater; portable ESP-NOW retains reads only | Yes | Yes | Yes | No | Limited | Yes | Yes | -| GPS | [`gps`; `gps on/off`](cli_commands.md#view-or-change-gps-state) | Compiled onboard GPS | Feature | Feature | Feature | Feature | Feature | Feature | Feature | -| GPS | [`gps sync`](cli_commands.md#sync-this-nodes-clock-with-gps-time) | Compiled onboard GPS | Feature | Feature | Feature | Feature | Feature | Feature | Feature | -| GPS | [`gps setloc`](cli_commands.md#set-this-nodes-location-based-on-the-gps-coordinates) | Compiled onboard GPS | Feature | Feature | Feature | Feature | Feature | Feature | Feature | -| GPS | [`gps advert [none/share/prefs]`](cli_commands.md#view-or-change-the-gps-advert-policy) | Compiled onboard GPS | Feature | Feature | Feature | Feature | Feature | Feature | Feature | -| Sensors | [`get/set telemetry.access`](cli_commands.md#view-or-change-telemetry-access-mode) | Sensor-capable full parser | Feature | Feature | Limited | No | No | Feature | Feature | -| Sensors | [`sensor list [start]`](cli_commands.md#view-the-list-of-sensors-on-this-node) | Compiled sensor manager; portable profiles omit external sensors | Feature | Feature | Limited | No | No | Feature | Feature | -| Sensors | [`sensor get`; `sensor set`](cli_commands.md#view-or-change-the-value-of-a-sensor) | Compiled sensor setting; portable profiles omit external sensors | Feature | Feature | Limited | No | No | Feature | Feature | -| Sensors | [`io [r/s/t]`](#sensor-io) | Simple sensor role with board GPIO support | Feature | Feature | No | No | No | No | Feature | -| Bridge | [`get bridge.type`](cli_commands.md#view-the-compiled-bridge-type) | Compiled bridge | Feature | Feature | Feature | Yes | Yes | Yes | Feature | -| Bridge | [`get/set bridge.enabled`](cli_commands.md#view-or-change-the-bridge-enabled-flag) | Compiled bridge | Feature | Feature | Feature | Yes | Yes | Yes | Feature | -| Bridge | [`get/set bridge.delay`](cli_commands.md#add-a-delay-to-packets-routed-through-this-bridge) | Compiled bridge | Feature | Feature | Feature | Yes | Yes | Yes | Feature | -| Bridge | [`get/set bridge.source`](cli_commands.md#view-or-change-the-source-of-packets-bridged-to-the-external-interface) | Compiled bridge | Feature | Feature | Feature | Yes | Yes | Yes | Feature | -| Bridge | [`get/set bridge.baud`](cli_commands.md#view-or-change-the-speed-of-the-bridge-rs-232-only) | RS-232 bridge | Feature | Feature | Feature | No | No | No | Feature | -| Bridge | [`get/set bridge.channel`](cli_commands.md#view-or-change-the-channel-used-for-bridging-espnow-only) | ESP-NOW bridge | No | No | No | No | Yes | No | Feature | -| Bridge | [`get/set bridge.secret`](cli_commands.md#set-the-esp-now-secret) | ESP-NOW bridge | No | No | No | No | Yes | No | Feature | -| Board | [`get bootloader.ver`](cli_commands.md#view-the-bootloader-version-nrf52-only) | nRF52 only | No | No | No | No | No | No | No | -| Board | [`get pwrmgt.support`; `get pwrmgt.source`; `get pwrmgt.bootreason`; `get pwrmgt.bootmv`](nrf52_power_management.md#cli-commands) | nRF52 only | No | No | No | No | No | No | No | -| Ethernet | [`eth.status`](cli_commands.md#view-ethernet-connection-status) | Ethernet target | Feature | Feature | No | No | No | No | Feature | -| Browser OTA | [`start ota [ap]`; `stop ota`](cli_commands.md#start-or-stop-an-over-the-air-ota-firmware-update) | Compiled browser uploader | No | No | Yes | Yes | No | Feature | Feature | -| WebConfig | [`start webconfig [ap]`; `stop webconfig`; `get/set webui`](cli_commands.md#browser-configuration-portal-esp32-repeater-and-room-server) | Compiled WebConfig | No | No | No | No | No | Feature | Feature | -| WiFi | [`get/set wifi.ssid`; `set wifi.pwd`; `get wifi.status`; `get/set wifi.powersave`](https://github.com/mikecarper/MeshCore/blob/keymindCascade/MQTT_IMPLEMENTATION.md#wifi-commands) | MQTT WiFi, or standalone FULL WebConfig; standalone has no `get wifi.pwd` | No | No | No | Yes | No | Yes | Feature | -| WiFi | [`get/set wifi.cli`](cli_commands.md#browser-configuration-portal-esp32-repeater-and-room-server) | Compiled WebConfig | No | No | No | No | No | Feature | Feature | -| LoRa OTA | [`ota help`; `ota ?`; `ota h`](ota_protocol.md#11-cli-surface-otaclicpp) | LoRa OTA build | No | No | Yes | No | No | Yes | Yes | -| LoRa OTA | [`ota`; `ota status`; `ota st`](ota_protocol.md#11-cli-surface-otaclicpp) | LoRa OTA build | No | No | Yes | No | No | Yes | Yes | -| LoRa OTA | [`ota stats`](ota_user_guide.md#1-see-what-im-running-and-whether-anything-is-going-on) | LoRa OTA build | No | No | Yes | No | No | Yes | Yes | -| LoRa OTA | [`ota ls`; `ota neighbors`; `ota nbrs`; `ota updates`; `ota n`](ota_protocol.md#11-cli-surface-otaclicpp) | LoRa OTA build | No | No | Yes | No | No | Yes | Yes | -| LoRa OTA | [`ota get`; `ota pull`; `ota download`](ota_protocol.md#11-cli-surface-otaclicpp) | LoRa OTA build | No | No | Yes | No | No | Yes | Yes | -| LoRa OTA | [`ota install`; `ota apply`; `ota applydelta`](ota_protocol.md#11-cli-surface-otaclicpp) | Compatible completed update | No | No | Yes | No | No | Yes | Yes | -| LoRa OTA | [`ota rescue install `](ota_protocol.md#12-apply-bootloader-contract) | Internal-flash nRF52 LoRa OTA build with failed app-side EndF validation | No | No | Feature | No | No | No | No | -| LoRa OTA | [`ota cancel`; `ota drop`; `ota stop`](ota_protocol.md#11-cli-surface-otaclicpp) | LoRa OTA build | No | No | Yes | No | No | Yes | Yes | -| LoRa OTA | [`ota announce`; `ota adv`](ota_protocol.md#11-cli-surface-otaclicpp) | LoRa OTA build | No | No | Yes | No | No | Yes | Yes | -| LoRa OTA | [`ota self`; `ota id`](ota_protocol.md#11-cli-surface-otaclicpp) | Firmware with EndF trailer | No | No | Yes | No | No | Yes | Yes | -| LoRa OTA | [`ota folder`; `ota fold`](ota_protocol.md#11-cli-surface-otaclicpp) | `on` needs compiled serial or TCP folder transport | No | No | Feature | No | No | Feature | Feature | -| LoRa OTA | [`ota config`; `ota cfg`; `ota set`](ota_protocol.md#11-cli-surface-otaclicpp) | LoRa OTA build | No | No | Yes | No | No | Yes | Yes | -| LoRa OTA | [`ota key`; `ota keys`](ota_protocol.md#11-cli-surface-otaclicpp) | LoRa OTA build | No | No | Yes | No | No | Yes | Yes | -| LoRa OTA | [`ota dev ...`](ota_protocol.md#11-cli-surface-otaclicpp) | Developer diagnostics | No | No | Yes | No | No | Yes | Yes | -| MQTT | [`get/set mqttN.preset`](https://github.com/mikecarper/MeshCore/blob/keymindCascade/MQTT_IMPLEMENTATION.md#mqtt-slot-commands) | MQTT observer | No | No | No | Yes | No | Yes | No | -| MQTT | [`get/set mqttN.server`; `get/set mqttN.port`; `get/set mqttN.username`; `get/set mqttN.password`](https://github.com/mikecarper/MeshCore/blob/keymindCascade/MQTT_IMPLEMENTATION.md#mqtt-slot-commands) | MQTT observer | No | No | No | Yes | No | Yes | No | -| MQTT | [`get/set mqttN.token`; `get/set mqttN.topic`; `get/set mqttN.audience`](https://github.com/mikecarper/MeshCore/blob/keymindCascade/MQTT_IMPLEMENTATION.md#mqtt-slot-commands) | MQTT observer | No | No | No | Yes | No | Yes | No | -| MQTT | [`get mqttN.diag`](#mqtt-slot-diagnostics) | MQTT observer | No | No | No | Yes | No | Yes | No | -| MQTT | [`get/set mqtt.origin`; `get/set mqtt.iata`; `get mqtt.presets`](https://github.com/mikecarper/MeshCore/blob/keymindCascade/MQTT_IMPLEMENTATION.md#mqtt-shared-commands) | MQTT observer | No | No | No | Yes | No | Yes | No | -| MQTT | [`get mqtt.stats`](#mqtt-stats) | MQTT observer | No | No | No | Yes | No | Yes | No | -| MQTT | [`get/set mqtt.status`; `get/set mqtt.packets`; `get/set mqtt.raw`; `get/set mqtt.interval`](https://github.com/mikecarper/MeshCore/blob/keymindCascade/MQTT_IMPLEMENTATION.md#mqtt-shared-commands) | MQTT observer | No | No | No | Yes | No | Yes | No | -| MQTT | [`get/set mqtt.rx`](cli_commands.md#view-or-change-mqtt-rx-packet-uplinking); [`get/set mqtt.tx`](cli_commands.md#view-or-change-mqtt-tx-packet-uplinking) | MQTT observer | No | No | No | Yes | No | Yes | No | -| MQTT | [`get/set mqtt.neighbors`](cli_commands.md#view-or-change-periodic-neighbors-publishing-mqtt-observer-neighbors-feature); [`get/set mqtt.neighbors.interval`](cli_commands.md#view-or-change-the-neighbors-publish-interval-mqtt-observer-neighbors-feature) | MQTT observer with compiled neighbor support | No | No | No | Feature | No | Feature | No | -| MQTT | [`get/set mqtt.ntp`](cli_commands.md#view-or-change-the-ntp-server-mqtt-observer-only) | MQTT observer | No | No | No | Yes | No | Yes | No | -| MQTT | [`get mqtt.ntp.diag`](cli_commands.md#diagnose-ntp-server-connectivity-mqtt-observer-only) | Full MQTT observer; intentionally cut from portable | No | No | No | No | No | Yes | No | -| MQTT | [`get/set timezone`; `get/set timezone.offset`](https://github.com/mikecarper/MeshCore/blob/keymindCascade/MQTT_IMPLEMENTATION.md#timezone-commands) | MQTT observer | No | No | No | Yes | No | Yes | No | -| MQTT | [`get/set mqtt.analyzer.us`; `get/set mqtt.analyzer.eu`](https://github.com/mikecarper/MeshCore/blob/keymindCascade/MQTT_IMPLEMENTATION.md#migration-from-old-configuration) | Legacy MQTT aliases | No | No | No | Yes | No | Yes | No | -| MQTT | [`get/set mqtt.owner`; `get/set mqtt.email`](https://github.com/mikecarper/MeshCore/blob/keymindCascade/MQTT_IMPLEMENTATION.md#mqtt-shared-commands) | MQTT observer; `get` is local serial only | No | No | No | Yes | No | Yes | No | -| MQTT | [`get mqtt.config.valid`](#mqtt-config-valid) | MQTT observer | No | No | No | Yes | No | Yes | No | -| SNMP | [`get/set snmp`; `get/set snmp.community`](https://github.com/mikecarper/MeshCore/blob/keymindCascade/MQTT_SNMP.md#cli-commands) | MQTT target compiled with SNMP | No | No | No | No | No | Feature | No | -| Alerts | [`get/set alert`; `get/set alert.psk`; `get/set alert.hashtag`; `get/set alert.region`; `get/set alert.wifi`; `get/set alert.mqtt`; `get/set alert.interval`](https://github.com/mikecarper/MeshCore/blob/keymindCascade/ALERTS.md#cli) | MQTT observer | No | No | No | Yes | No | Yes | No | -| Alerts | [`alert test [message]`](https://github.com/mikecarper/MeshCore/blob/keymindCascade/ALERTS.md#cli) | MQTT observer with configured alert channel | No | No | No | Yes | No | Yes | No | -| TLS | [`tls.bundletest `](#tls-bundle-test) | MQTT target with embedded certificate bundle | No | No | No | Feature | No | Feature | No | -| Manifest OTA | [`ota check`](#manifest-ota) | MQTT target with `OTA_MANIFEST_BASE` | No | No | No | Manifest | No | Manifest | No | -| Manifest OTA | [`ota update`](#manifest-ota) | MQTT target with `OTA_MANIFEST_BASE` | No | No | No | Manifest | No | Manifest | No | +| Area | Command | Scope | Standard | Logging | LoRa OTA | FULL | FULL logging | +|---|---|---|---|---|---|---|---| +| Operational | [`reboot`](cli_commands.md#reboot-the-node) | All text CLI roles | Yes | Yes | Yes | Yes | Yes | +| Operational | [`poweroff`; `shutdown`](cli_commands.md#power-off-the-node) | Board power-off support | Yes | Yes | Yes | Yes | Yes | +| Operational | [`uf2reset`](cli_commands.md#enter-the-uf2-bootloader-nrf52-only) | nRF52 only | No | No | No | No | No | +| Operational | [`clkreboot`](cli_commands.md#reset-the-clock-and-reboot) | Full parser | Yes | Yes | Yes | Yes | Yes | +| Operational | [`clock sync`](cli_commands.md#sync-the-clock-with-the-remote-device) | All text CLI roles | Yes | Yes | Yes | Yes | Yes | +| Operational | [`clock`](cli_commands.md#display-current-time-in-utc) | All text CLI roles | Yes | Yes | Yes | Yes | Yes | +| Operational | [`time `](cli_commands.md#set-the-time-to-a-specific-timestamp) | Clock only moves forward | Yes | Yes | Yes | Yes | Yes | +| Operational | [`advert`](cli_commands.md#send-a-flood-advert) | Advert-capable role | Yes | Yes | Yes | Yes | Yes | +| Operational | [`advert.zerohop`](cli_commands.md#send-a-zero-hop-advert) | Advert-capable role | Yes | Yes | Yes | Yes | Yes | +| Operational | [`erase`](cli_commands.md#erasefactory-reset) | Local serial | Serial | Serial | Serial | Serial | Serial | +| Information | [`ver`](cli_commands.md#get-the-version) | All text CLI roles | Yes | Yes | Yes | Yes | Yes | +| Information | [`board`](cli_commands.md#show-the-hardware-name) | All text CLI roles | Yes | Yes | Yes | Yes | Yes | +| Diagnostics | [`memory`](#memory) | ESP32 heap and PSRAM summary | Yes | Yes | Yes | Yes | Yes | +| Diagnostics | [`sensor`](#sensor-hardware-summary) | Hardware wiring summary | Yes | Yes | Yes | Yes | Yes | +| Diagnostics | [`powerlog`](#powerlog) | Reset-reason summary | Yes | Yes | Yes | Yes | Yes | +| Neighbors | [`neighbors`](cli_commands.md#list-nearby-neighbors) | Role with a neighbor table | Yes | Yes | Yes | Yes | Yes | +| Neighbors | [`neighbor.remove `](cli_commands.md#remove-a-neighbor) | Role with a mutable neighbor table | Yes | Yes | Yes | Yes | Yes | +| Neighbors | [`discover.neighbors`](cli_commands.md#discover-zero-hop-neighbors) | Repeater; some MQTT room servers | Yes | Yes | Yes | Yes | Yes | +| Neighbors | [`discover.scopes`](cli_commands.md#discover-neighbor-scopes-mqtt-observer-neighbors-feature) | MQTT observer with compiled neighbor support | No | No | No | Feature | No | +| Statistics | [`clear stats`](cli_commands.md#clear-stats) | Full parser | Yes | Yes | Yes | Yes | Yes | +| Statistics | [`stats-core`](cli_commands.md#stats-core) | Local serial | Serial | Serial | Serial | Serial | Serial | +| Statistics | [`stats-radio`](cli_commands.md#stats-radio) | Local serial | Serial | Serial | Serial | Serial | Serial | +| Statistics | [`stats-radio-diag`](#stats-radio-diag) | Local serial | Serial | Serial | Serial | Serial | Serial | +| Statistics | [`stats-packets`](cli_commands.md#stats-packets) | Local serial | Serial | Serial | Serial | Serial | Serial | +| Statistics | [`get telemetry.temp/volt`; optional GPS history; `get/set telemetry.tx`](cli_commands.md#read-repeater-telemetry-history) | Non-STM32 repeater; GPS commands require a GPS provider; remote access requires administrator | Yes | Yes | Yes | Yes | Yes | +| Logging | [`log start`; `log stop`; `log erase`](cli_commands.md#logging) | Storage-backed roles retain data | Yes | Yes | Yes | Yes | Yes | +| Logging | [`log`](cli_commands.md#print-the-captured-log-to-the-serial-terminal) | Local serial | Serial | Serial | Serial | Serial | Serial | +| Logging | [`get/set usb.logging`](cli_commands.md#control-live-usb-logging) | Logging artifacts; session-only live USB output gate | No | Yes | No | No | Yes | +| 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 ...`](cli_commands.md#change-the-radio-parameters-for-a-set-duration) | Full parser | Yes | Yes | Yes | Yes | Yes | +| Radio | [`get/set/del radioat`; `get/set/del tempradioat`](cli_commands.md#schedule-radio-parameter-changes) | Full parser | Yes | Yes | Yes | Yes | Yes | +| Radio | [`get freq`; `set freq `](cli_commands.md#view-or-change-this-nodes-frequency) | `set` is local serial only | Yes | Yes | Yes | Yes | Yes | +| Radio | [`get/set radio.rxgain`](cli_commands.md#view-or-change-this-nodes-rx-boosted-gain-mode-sx12xx-and-lr1110-v1141) | Supported radio | Feature | Feature | Feature | Feature | Feature | +| Radio | [`get/set radio.fem.rxgain`](cli_commands.md#view-or-change-the-lora-fem-receive-path-gain-state-on-supported-boards) | Controllable LoRa FEM | Feature | Feature | Feature | Feature | Feature | +| Radio | [`get/set radio.fem.txgain`](cli_commands.md#view-or-change-the-lora-fem-transmit-path-gain-state-on-supported-boards) | Controllable LoRa FEM | Feature | Feature | Feature | Feature | Feature | +| Radio | [`get/set radio.rxps`; `get rxps.wd`](#radio-rxps) | Full parser and RX power-saving support | Feature | Feature | Feature | Feature | Feature | +| System | [`get/set name`](cli_commands.md#view-or-change-this-nodes-name) | All full-parser text CLI roles | Yes | Yes | Yes | Yes | Yes | +| System | [`get/set lat`](cli_commands.md#view-or-change-this-nodes-latitude) | All full-parser text CLI roles | Yes | Yes | Yes | Yes | Yes | +| System | [`get/set lon`](cli_commands.md#view-or-change-this-nodes-longitude) | All full-parser text CLI roles | Yes | Yes | Yes | Yes | Yes | +| System | [`get/set prv.key`](cli_commands.md#view-or-change-this-nodes-identity-private-key) | `get` is local serial only | Yes | Yes | Yes | Yes | Yes | +| System | [`password `](cli_commands.md#change-this-nodes-admin-password) | Administrator | Yes | Yes | Yes | Yes | Yes | +| System | [`get/set guest.password`](cli_commands.md#view-or-change-this-nodes-guest-password) | Role with guest administration | Yes | Yes | Yes | Yes | Yes | +| System | [`get/set owner.info`](cli_commands.md#view-or-change-this-nodes-owner-info) | Full parser | Yes | Yes | Yes | Yes | Yes | +| System | [`get/set adc.multiplier`](cli_commands.md#fine-tune-the-battery-reading) | Board ADC override support | Feature | Feature | Feature | Feature | Feature | +| System | [`send text.flood `](cli_commands.md#send-a-repeater-flood-text) | Repeater | Yes | Yes | Yes | Yes | Yes | +| System | [`get/set battery.alert`; `get battery.alert.region`](cli_commands.md#view-or-change-battery-alert-state) | Repeater | Yes | Yes | Yes | Yes | Yes | +| System | [`get/set battery.alert.low`; `get/set battery.alert.critical`](cli_commands.md#view-or-change-battery-alert-thresholds) | Repeater | Yes | Yes | Yes | Yes | Yes | +| System | [`get/set rx.watchdog`](cli_commands.md#enable-or-disable-the-rx-inactivity-watchdog-repeater-only) | Repeater | Yes | Yes | Yes | Yes | Yes | +| System | [`get/set system.watchdog`](cli_commands.md#enable-or-disable-the-nrf52-system-watchdog) | nRF52 only | No | No | No | No | No | +| System | [`get public.key`](cli_commands.md#view-this-nodes-public-key) | Full parser | Yes | Yes | Yes | Yes | Yes | +| System | [`get role`](cli_commands.md#view-this-nodes-configured-role) | All text CLI roles | Yes | Yes | Yes | Yes | Yes | +| System | [`powersaving`; `powersaving on/off`](cli_commands.md#view-or-change-this-nodes-power-saving-flag) | Supported repeater board | Feature | Feature | Feature | Feature | Feature | +| System | [`get/set reboot.interval`](#reboot-interval) | Full parser | Yes | Yes | Yes | Yes | Yes | +| Clock sync | [`get/set clock.sync.*`; `clock.sync.mesh now`](cli_commands.md#estimate-and-correct-infrastructure-node-time-after-startup) | Repeater, sensor, and room server; `clock.sync.internet` needs MQTT repeater | Yes | Yes | Yes | Yes | Yes | +| Routing | [`get/set repeat`](cli_commands.md#view-or-change-this-nodes-repeat-flag) | Forwarding-capable role | Yes | Yes | Yes | Yes | Yes | +| Routing | [`get/set path.hash.mode`](cli_commands.md#view-or-change-this-nodes-advert-path-hash-size) | Full parser | Yes | Yes | Yes | Yes | Yes | +| Routing | [`get/set loop.detect`](cli_commands.md#view-or-change-this-nodes-loop-detection) | Repeater, full common parser | Yes | Yes | Yes | Yes | Yes | +| Routing | [`get/set txdelay`](cli_commands.md#view-or-change-the-retransmit-delay-factor-for-flood-traffic) | All text CLI roles, including bridges | Yes | Yes | Yes | Yes | Yes | +| Routing | [`get/set direct.txdelay`](cli_commands.md#view-or-change-the-retransmit-delay-factor-for-direct-traffic) | Full parser | Yes | Yes | Yes | Yes | Yes | +| Routing | [`get/set rxdelay`](cli_commands.md#experimental-view-or-change-the-processing-delay-for-received-traffic) | All text CLI roles, including bridges | Yes | Yes | Yes | Yes | Yes | +| Routing | [`get/set dutycycle`](cli_commands.md#view-or-change-the-duty-cycle-limit) | Full parser | Yes | Yes | Yes | Yes | Yes | +| Routing | [`get/set af`](cli_commands.md#view-or-change-the-airtime-factor-duty-cycle-limit) | Full parser | Yes | Yes | Yes | Yes | Yes | +| Routing | [`get/set int.thresh`](cli_commands.md#view-or-change-the-local-interference-threshold) | All text CLI roles | Yes | Yes | Yes | Yes | Yes | +| Routing | [`get/set cad`](cli_commands.md#enable-or-disable-hardware-channel-activity-detection-cad) | Radio CAD support | Feature | Feature | Feature | Feature | Feature | +| Routing | [`get/set agc.reset.interval`](cli_commands.md#view-or-change-the-agc-reset-interval) | All text CLI roles | Yes | Yes | Yes | Yes | Yes | +| Routing | [`get/set radio.watchdog`](cli_commands.md#view-or-change-the-radio-watchdog-interval-mqtt-observer-only) | MQTT observer | No | No | No | Yes | No | +| Routing | [`get/set multi.acks`](cli_commands.md#enable-or-disable-multi-acks-support) | Full parser | Yes | Yes | Yes | Yes | Yes | +| Routing | [`get/set flood.advert.interval`](cli_commands.md#view-or-change-the-flood-advert-interval) | Repeater, full common parser | Yes | Yes | Yes | Yes | Yes | +| Routing | [`get/set advert.interval`](cli_commands.md#view-or-change-the-zero-hop-advert-interval) | Full common parser | Yes | Yes | Yes | Yes | Yes | +| Routing | [`get/set flood.max`](cli_commands.md#limit-the-number-of-hops-for-a-flood-message) | Repeater, full common parser | Yes | Yes | Yes | Yes | Yes | +| Routing | [`get/set flood.max.unscoped`](cli_commands.md#limit-the-number-of-hops-for-an-unscoped-flood-message) | Repeater, full common parser | Yes | Yes | Yes | Yes | Yes | +| Routing | [`get/set flood.max.advert`](cli_commands.md#limit-the-number-of-hops-for-an-advert-flood-message) | Repeater, full common parser | Yes | Yes | Yes | Yes | Yes | +| Routing | [`get/set flood.channel.data`; `get/set flood.channel.data.hops`](cli_commands.md#forward-flood-group-data-packets-on-repeaters) | Repeater, full common parser | Yes | Yes | Yes | Yes | Yes | +| Routing | [`get/set/del flood.channel.scope*`](cli_commands.md#force-a-transport-scope-onto-floods) | Repeater role handler | Yes | Yes | Yes | Yes | Yes | +| Routing | [`get/set/del flood.channel.scope.require*`](cli_commands.md#require-valid-incoming-scopes-only-on-selected-channels) | Repeater role handler | Yes | Yes | Yes | Yes | Yes | +| Routing | [`get/set/del flood.rule*`; `get/set/del flood.filter*`; `get/set/del flood.filter.blacklist*`](cli_commands.md#change-persistent-flood-rules-in-the-field) | Repeater role handler; `flood.rule`/`flood.filter` also on FULL ESP32 room server (no blacklist) | Yes | Yes | Yes | Yes | Yes | +| Routing | [`get/set/del flood.moderation*`](cli_commands.md#moderate-flood-group-text-by-channel-sender-and-source-path) | Repeater role handler | Yes | Yes | Yes | Yes | Yes | +| Routing | [`get/set outpath`](halo_keymind_settings.md#direct-path-overrides) | Repeater remote-client context | Yes | Yes | Yes | Yes | Yes | +| Routing | [`get/set altpath`](halo_keymind_settings.md#direct-path-overrides) | Repeater remote-client context | Yes | Yes | Yes | Yes | Yes | +| ACL | [`setperm `](cli_commands.md#add-update-or-remove-permissions-for-a-companion) | Repeater, room server, or sensor | Yes | Yes | Yes | Yes | Yes | +| ACL | [`get acl`](cli_commands.md#view-the-current-acl) | Local serial | Serial | Serial | Serial | Serial | Serial | +| ACL | [`get/set allow.read.only`](cli_commands.md#view-or-change-this-room-servers-read-only-flag) | Room server | Yes | Yes | No | Yes | Yes | +| Regions | [`region load`](cli_commands.md#bulk-load-region-lists); [`region save`](cli_commands.md#save-any-changes-to-regions-made-since-reboot) | Role with region storage | Yes | Yes | Yes | Yes | Yes | +| Regions | [`region allowf`](cli_commands.md#allow-a-region); [`region denyf`](cli_commands.md#block-a-region) | Role with region storage | Yes | Yes | Yes | Yes | Yes | +| Regions | [`region get`](cli_commands.md#show-information-for-a-region); [`region list`](cli_commands.md#view-all-regions); [`region`](cli_commands.md#dump-all-defined-regions-and-flood-permissions) | Role with region storage | Yes | Yes | Yes | Yes | Yes | +| Regions | [`region home`](cli_commands.md#view-or-change-the-home-region-for-this-node); [`region default`](cli_commands.md#view-or-change-the-default-scope-region-for-this-node) | Role with region storage | Yes | Yes | Yes | Yes | Yes | +| Regions | [`region put`](cli_commands.md#create-a-new-region); [`region def`](cli_commands.md#define-region-hierarchy-single-line); [`region remove`](cli_commands.md#remove-a-region) | Role with region storage | Yes | Yes | Yes | Yes | Yes | +| Retry | [`get/set direct.retry`](cli_commands.md#view-or-change-direct-retry-state); [`get/set direct.retry.heard`](cli_commands.md#view-or-change-direct-retry-heard-table-gate) | Role with basic retry support | Yes | Yes | Yes | Yes | Yes | +| Retry | [`get/set retry.preset`](cli_commands.md#view-or-apply-a-retry-preset) | Role with retry support | Yes | Yes | Yes | Yes | Yes | +| Retry | [`get/set flood.retry.count`](cli_commands.md#view-or-change-flood-retry-count); [`get/set flood.retry.path`](cli_commands.md#view-or-change-flood-retry-path-gate); [`get/set flood.retry.group.path`](cli_commands.md#view-or-change-the-group-data-flood-retry-path-gate) | Repeater | Yes | Yes | Yes | Yes | Yes | +| Retry | [`get/set flood.retry.advert`](cli_commands.md#view-or-change-flood-retry-advert-handling); [`get/set flood.retry.prefixes`](cli_commands.md#view-or-change-flood-retry-target-prefixes); [`get/set flood.retry.ignore`](cli_commands.md#view-or-change-flood-retry-ignored-prefixes) | Repeater | Yes | Yes | Yes | Yes | Yes | +| Retry | [`get/set flood.retry.bridge`](cli_commands.md#view-or-change-flood-retry-bridge-mode); [`get/set flood.retry.bucket`](cli_commands.md#view-or-change-flood-retry-bridge-buckets) | Repeater bridge retry support | Feature | Feature | Feature | Feature | Feature | +| Retry | [`get/set direct.retry.count`](cli_commands.md#view-or-change-direct-retry-count); [`get/set direct.retry.base`](cli_commands.md#view-or-change-direct-retry-base-delay); [`get/set direct.retry.step`](cli_commands.md#view-or-change-direct-retry-step-delay) | Role with retry support | Yes | Yes | Yes | Yes | Yes | +| Retry | [`get/set direct.retry.margin`](cli_commands.md#view-or-change-direct-retry-snr-margin); [`get/set direct.retry.cr`](cli_commands.md#view-or-change-adaptive-direct-retry-coding-rate) | Role with retry support | Yes | Yes | Yes | Yes | Yes | +| Retry | [`get/set/clear recent.repeater`; `get recent.repeaters`](cli_commands.md#view-seed-or-clear-the-recent-repeater-table) | Repeater | Yes | Yes | Yes | Yes | Yes | +| GPS | [`gps`; `gps on/off`](cli_commands.md#view-or-change-gps-state) | Compiled onboard GPS | Feature | Feature | Feature | Feature | Feature | +| GPS | [`gps sync`](cli_commands.md#sync-this-nodes-clock-with-gps-time) | Compiled onboard GPS | Feature | Feature | Feature | Feature | Feature | +| GPS | [`gps setloc`](cli_commands.md#set-this-nodes-location-based-on-the-gps-coordinates) | Compiled onboard GPS | Feature | Feature | Feature | Feature | Feature | +| GPS | [`gps advert [none/share/prefs]`](cli_commands.md#view-or-change-the-gps-advert-policy) | Compiled onboard GPS | Feature | Feature | Feature | Feature | Feature | +| Sensors | [`get/set telemetry.access`](cli_commands.md#view-or-change-telemetry-access-mode) | Sensor-capable full parser | Feature | Feature | Limited | Feature | Feature | +| Sensors | [`sensor list [start]`](cli_commands.md#view-the-list-of-sensors-on-this-node) | Compiled sensor manager; `no_external_sensors` omits optional drivers | Feature | Feature | Limited | Feature | Feature | +| Sensors | [`sensor get`; `sensor set`](cli_commands.md#view-or-change-the-value-of-a-sensor) | Compiled sensor setting; `no_external_sensors` omits optional drivers | Feature | Feature | Limited | Feature | Feature | +| Sensors | [`io [r/s/t]`](#sensor-io) | Simple sensor role with board GPIO support | Feature | Feature | No | No | Feature | +| Bridge | [`get bridge.type`](cli_commands.md#view-the-compiled-bridge-type) | Compiled bridge | Feature | Feature | Feature | Yes | Feature | +| Bridge | [`get/set bridge.enabled`](cli_commands.md#view-or-change-the-bridge-enabled-flag) | Compiled bridge | Feature | Feature | Feature | Yes | Feature | +| Bridge | [`get/set bridge.delay`](cli_commands.md#add-a-delay-to-packets-routed-through-this-bridge) | Compiled bridge | Feature | Feature | Feature | Yes | Feature | +| Bridge | [`get/set bridge.source`](cli_commands.md#view-or-change-the-source-of-packets-bridged-to-the-external-interface) | Compiled bridge | Feature | Feature | Feature | Yes | Feature | +| Bridge | [`get/set bridge.baud`](cli_commands.md#view-or-change-the-speed-of-the-bridge-rs-232-only) | RS-232 bridge | Feature | Feature | Feature | No | Feature | +| Bridge | [`get/set bridge.channel`](cli_commands.md#view-or-change-the-channel-used-for-bridging-espnow-only) | ESP-NOW bridge | No | No | No | Feature | Feature | +| Bridge | [`get/set bridge.secret`](cli_commands.md#set-the-esp-now-secret) | ESP-NOW bridge | No | No | No | Feature | Feature | +| Board | [`get bootloader.ver`](cli_commands.md#view-the-bootloader-version-nrf52-only) | nRF52 only | No | No | No | No | No | +| Board | [`get pwrmgt.support`; `get pwrmgt.source`; `get pwrmgt.bootreason`; `get pwrmgt.bootmv`](nrf52_power_management.md#cli-commands) | nRF52 only | No | No | No | No | No | +| Ethernet | [`eth.status`](cli_commands.md#view-ethernet-connection-status) | Ethernet target | Feature | Feature | No | No | Feature | +| Browser OTA | [`start ota [ap]`; `stop ota`](cli_commands.md#start-or-stop-an-over-the-air-ota-firmware-update) | Compiled browser uploader | No | No | Yes | Feature | Feature | +| WebConfig | [`start webconfig [ap]`; `stop webconfig`; `get/set webui`](cli_commands.md#browser-configuration-portal-esp32-repeater-and-room-server) | Compiled WebConfig | No | No | No | Feature | Feature | +| WiFi | [`get/set wifi.ssid`; `set wifi.pwd`; `get wifi.status`; `get/set wifi.powersave`](https://github.com/mikecarper/MeshCore/blob/keymindCascade/MQTT_IMPLEMENTATION.md#wifi-commands) | MQTT WiFi, or standalone FULL WebConfig; standalone has no `get wifi.pwd` | No | No | No | Yes | Feature | +| WiFi | [`get/set wifi.cli`](cli_commands.md#browser-configuration-portal-esp32-repeater-and-room-server) | Compiled WebConfig | No | No | No | Feature | Feature | +| LoRa OTA | [`ota help`; `ota ?`; `ota h`](ota_protocol.md#11-cli-surface-otaclicpp) | LoRa OTA build | No | No | Yes | Yes | Yes | +| LoRa OTA | [`ota`; `ota status`; `ota st`](ota_protocol.md#11-cli-surface-otaclicpp) | LoRa OTA build | No | No | Yes | Yes | Yes | +| LoRa OTA | [`ota stats`](ota_user_guide.md#1-see-what-im-running-and-whether-anything-is-going-on) | LoRa OTA build | No | No | Yes | Yes | Yes | +| LoRa OTA | [`ota ls`; `ota neighbors`; `ota nbrs`; `ota updates`; `ota n`](ota_protocol.md#11-cli-surface-otaclicpp) | LoRa OTA build | No | No | Yes | Yes | Yes | +| LoRa OTA | [`ota get`; `ota pull`; `ota download`](ota_protocol.md#11-cli-surface-otaclicpp) | LoRa OTA build | No | No | Yes | Yes | Yes | +| LoRa OTA | [`ota install`; `ota apply`; `ota applydelta`](ota_protocol.md#11-cli-surface-otaclicpp) | Compatible completed update | No | No | Yes | Yes | Yes | +| LoRa OTA | [`ota rescue install `](ota_protocol.md#12-apply-bootloader-contract) | Internal-flash nRF52 LoRa OTA build with failed app-side EndF validation | No | No | Feature | No | No | +| LoRa OTA | [`ota cancel`; `ota drop`; `ota stop`](ota_protocol.md#11-cli-surface-otaclicpp) | LoRa OTA build | No | No | Yes | Yes | Yes | +| LoRa OTA | [`ota announce`; `ota adv`](ota_protocol.md#11-cli-surface-otaclicpp) | LoRa OTA build | No | No | Yes | Yes | Yes | +| LoRa OTA | [`ota self`; `ota id`](ota_protocol.md#11-cli-surface-otaclicpp) | Firmware with EndF trailer | No | No | Yes | Yes | Yes | +| LoRa OTA | [`ota folder`; `ota fold`](ota_protocol.md#11-cli-surface-otaclicpp) | `on` needs compiled serial or TCP folder transport | No | No | Feature | Feature | Feature | +| LoRa OTA | [`ota config`; `ota cfg`; `ota set`](ota_protocol.md#11-cli-surface-otaclicpp) | LoRa OTA build | No | No | Yes | Yes | Yes | +| LoRa OTA | [`ota key`; `ota keys`](ota_protocol.md#11-cli-surface-otaclicpp) | LoRa OTA build | No | No | Yes | Yes | Yes | +| LoRa OTA | [`ota dev ...`](ota_protocol.md#11-cli-surface-otaclicpp) | Developer diagnostics | No | No | Yes | Yes | Yes | +| MQTT | [`get/set mqttN.preset`](https://github.com/mikecarper/MeshCore/blob/keymindCascade/MQTT_IMPLEMENTATION.md#mqtt-slot-commands) | MQTT observer | No | No | No | Yes | No | +| MQTT | [`get/set mqttN.server`; `get/set mqttN.port`; `get/set mqttN.username`; `get/set mqttN.password`](https://github.com/mikecarper/MeshCore/blob/keymindCascade/MQTT_IMPLEMENTATION.md#mqtt-slot-commands) | MQTT observer | No | No | No | Yes | No | +| MQTT | [`get/set mqttN.token`; `get/set mqttN.topic`; `get/set mqttN.audience`](https://github.com/mikecarper/MeshCore/blob/keymindCascade/MQTT_IMPLEMENTATION.md#mqtt-slot-commands) | MQTT observer | No | No | No | Yes | No | +| MQTT | [`get mqttN.diag`](#mqtt-slot-diagnostics) | MQTT observer | No | No | No | Yes | No | +| MQTT | [`get/set mqtt.origin`; `get/set mqtt.iata`; `get mqtt.presets`](https://github.com/mikecarper/MeshCore/blob/keymindCascade/MQTT_IMPLEMENTATION.md#mqtt-shared-commands) | MQTT observer | No | No | No | Yes | No | +| MQTT | [`get mqtt.stats`](#mqtt-stats) | MQTT observer | No | No | No | Yes | No | +| MQTT | [`get/set mqtt.status`; `get/set mqtt.packets`; `get/set mqtt.raw`; `get/set mqtt.interval`](https://github.com/mikecarper/MeshCore/blob/keymindCascade/MQTT_IMPLEMENTATION.md#mqtt-shared-commands) | MQTT observer | No | No | No | Yes | No | +| MQTT | [`get/set mqtt.rx`](cli_commands.md#view-or-change-mqtt-rx-packet-uplinking); [`get/set mqtt.tx`](cli_commands.md#view-or-change-mqtt-tx-packet-uplinking) | MQTT observer | No | No | No | Yes | No | +| MQTT | [`get/set mqtt.neighbors`](cli_commands.md#view-or-change-periodic-neighbors-publishing-mqtt-observer-neighbors-feature); [`get/set mqtt.neighbors.interval`](cli_commands.md#view-or-change-the-neighbors-publish-interval-mqtt-observer-neighbors-feature) | MQTT observer with compiled neighbor support | No | No | No | Feature | No | +| MQTT | [`get/set mqtt.ntp`](cli_commands.md#view-or-change-the-ntp-server-mqtt-observer-only) | MQTT observer | No | No | No | Yes | No | +| MQTT | [`get mqtt.ntp.diag`](cli_commands.md#diagnose-ntp-server-connectivity-mqtt-observer-only) | MQTT observer | No | No | No | Yes | No | +| MQTT | [`get/set timezone`; `get/set timezone.offset`](https://github.com/mikecarper/MeshCore/blob/keymindCascade/MQTT_IMPLEMENTATION.md#timezone-commands) | MQTT observer | No | No | No | Yes | No | +| MQTT | [`get/set mqtt.analyzer.us`; `get/set mqtt.analyzer.eu`](https://github.com/mikecarper/MeshCore/blob/keymindCascade/MQTT_IMPLEMENTATION.md#migration-from-old-configuration) | Legacy MQTT aliases | No | No | No | Yes | No | +| MQTT | [`get/set mqtt.owner`; `get/set mqtt.email`](https://github.com/mikecarper/MeshCore/blob/keymindCascade/MQTT_IMPLEMENTATION.md#mqtt-shared-commands) | MQTT observer; `get` is local serial only | No | No | No | Yes | No | +| MQTT | [`get mqtt.config.valid`](#mqtt-config-valid) | MQTT observer | No | No | No | Yes | No | +| SNMP | [`get/set snmp`; `get/set snmp.community`](https://github.com/mikecarper/MeshCore/blob/keymindCascade/MQTT_SNMP.md#cli-commands) | MQTT target compiled with SNMP | No | No | No | Feature | No | +| Alerts | [`get/set alert`; `get/set alert.psk`; `get/set alert.hashtag`; `get/set alert.region`; `get/set alert.wifi`; `get/set alert.mqtt`; `get/set alert.interval`](https://github.com/mikecarper/MeshCore/blob/keymindCascade/ALERTS.md#cli) | MQTT observer | No | No | No | Yes | No | +| Alerts | [`alert test [message]`](https://github.com/mikecarper/MeshCore/blob/keymindCascade/ALERTS.md#cli) | MQTT observer with configured alert channel | No | No | No | Yes | No | +| TLS | [`tls.bundletest `](#tls-bundle-test) | MQTT target with embedded certificate bundle | No | No | No | Feature | No | +| Manifest OTA | [`ota check`](#manifest-ota) | MQTT target with `OTA_MANIFEST_BASE` | No | No | No | Manifest | No | +| Manifest OTA | [`ota update`](#manifest-ota) | MQTT target with `OTA_MANIFEST_BASE` | No | No | No | Manifest | No | ## Supplemental command notes diff --git a/docs/cli_commands.md b/docs/cli_commands.md index 18c16d63..1f3a6042 100644 --- a/docs/cli_commands.md +++ b/docs/cli_commands.md @@ -483,6 +483,23 @@ Use the separately named `-ota-` artifact when LoRa OTA is required. A logging with LoRa OTA and the expanded FULL feature set, while MQTT remains disabled. +### Control live USB logging + +**Usage:** + +```text +get usb.logging +set usb.logging on +set usb.logging off +``` + +These commands are compiled into logging artifacts and control their live USB +debug and packet output. The switch is session-only: every reboot starts a +logging artifact with USB logging on. Turning it off does not disable CLI +replies, Companion protocol frames, or essential non-debug serial messages. +It also does not change the node-storage capture controlled by `log start` and +`log stop`. + ### Begin capture of rx log to node storage **Usage:** `log start` diff --git a/docs/companion_radio_full.md b/docs/companion_radio_full.md index 2243897d..06ccb51a 100644 --- a/docs/companion_radio_full.md +++ b/docs/companion_radio_full.md @@ -218,7 +218,9 @@ The terminal supports Companion chat commands, including `channels`, `login ` and `cmd `, and routed `trace [recipient-name-or-prefix]`, plus local `ota`, `tempradio`, and `normalradio` controls. ESP32 builds also provide local -`get/set wifi.powersave`. For example: +`get/set wifi.powersave`. Logging artifacts additionally provide session-only +`get/set usb.logging`; turning it off suppresses live USB diagnostics without +disabling Companion frames or terminal replies. For example: ```text channels diff --git a/docs/ota_easy.md b/docs/ota_easy.md index 5f05e80e..a35bda6d 100644 --- a/docs/ota_easy.md +++ b/docs/ota_easy.md @@ -46,7 +46,7 @@ Both paths require: - An OTA-enabled build whose artifact filename contains `-ota-` on the destination. The `-ota-` stamp confirms that the node can discover, download, verify, and install LoRa OTA. Intermediate repeaters do **not** need an OTA-enabled build: current repeater firmware relays OTA packets opaquely without storing or installing them. - Portable logging, portable MQTT, and untagged builds cannot install LoRa OTA; FULL logging OTA builds can. + Standard logging and untagged builds cannot install LoRa OTA; FULL MQTT and FULL logging OTA builds can. - An OTA-enabled MeshCore source connected to the computer by USB serial, or an ESP32 WiFi companion/FULL source connected over WiFi as described below. - Overlapping `tempradio` windows on the source, destination, and every repeater needed between them. @@ -60,8 +60,8 @@ overlapping window. repeater target. The normal repeater build keeps its external-sensor support and can serve as an intermediate OTA relay, but it cannot download or install an update for itself. The `-ota-` sibling omits optional external I2C environmental sensors to preserve the update workspace, while retaining board-native features such as its -display, buttons, battery monitoring, and integrated GPS. ESP32 `-ota-` siblings also retain the compact -browser WiFi uploader (`start ota`) and use a 254-entry neighbor table. RP2040 and STM32 repeaters do not +display, buttons, battery monitoring, and integrated GPS. ESP32 `-ota-` siblings also retain the lightweight +browser WiFi uploader (`start ota`), the complete CLI, and a 254-entry neighbor table. RP2040 and STM32 repeaters do not currently have a safe self-apply path, but current repeater firmware can still relay OTA packets opaquely during TempRadio. @@ -73,6 +73,8 @@ remain enabled. ESP32 `*-full-ota-*` artifacts retain all compiled features and enable LoRa OTA for every FULL role, including room servers, sensors, observers, and bridges. A FULL image requires its expanded partition table: install the matching merged image over USB once before installing later non-merged FULL updates over LoRa. +ESP32 MQTT observers and ESP-NOW bridges are always emitted as FULL artifacts; compact-CLI variants are no +longer built. The `*-full-ota-*` profile uses MQTT with logging off. Use a `*-full-logging-ota-*` artifact when USB debug and packet logging are needed instead; that diagnostic profile explicitly disables MQTT and can produce substantial serial output. diff --git a/docs/ota_protocol.md b/docs/ota_protocol.md index bd484888..f97f6b03 100644 --- a/docs/ota_protocol.md +++ b/docs/ota_protocol.md @@ -108,16 +108,17 @@ EndF trailer (fixed 56 bytes): The "reconstructed image" referenced by the manifest is the full `BODY || EndF` (what gets flashed). -### ESP32 portable app-slot profile +### ESP32 application-slot profiles ESP32 companion firmware is exempt from the portable-slot limit. USB and WiFi companion artifacts retain LoRa OTA and carry `-ota-` in their filenames so they can seed a host folder over serial or TCP; they keep their target partition table rather than using the FULL profile. A small set of high-capacity classic ESP32 companions cannot combine their configured contact, group-channel, and offline-queue capacities with LoRa OTA in internal DRAM. Their normal artifacts remain unchanged, and option 3 also emits `-full-ota-` and -`-full-logging-ota-` variants with 100 contacts, 8 group channels, and a 16-frame offline queue. Except for -the ESP32-C6 case below, every other ESP32 artifact, including room, -sensor, and repeater roles, must fit the legacy slot from `0x10000` up to +`-full-logging-ota-` variants with 100 contacts, 8 group channels, and a 16-frame offline queue. MQTT +observers and ESP-NOW bridges always use FULL builds because fitting them into the legacy slot would require +removing CLI and role features. Except for those FULL roles and the ESP32-C6 case below, non-companion ESP32 +artifacts, including room, sensor, and repeater roles, must fit the legacy slot from `0x10000` up to `0x150000` (`0x140000`, 1,310,720 bytes), including the 56-byte `EndF` trailer. The build checks both that limit and the target's actual app partition. The ESP32-C6 `no_external_sensors` OTA siblings are the narrow exception: the Arduino 3.x WiFi runtime cannot fit that cross-family ceiling, so those images retain their @@ -134,29 +135,23 @@ keeps the runtime software Ed25519 fallback from being expanded into tens of kil arithmetic while retaining CC310 hardware crypto, hardware RNG mixing, telemetry history, and board-native features. -Two WiFi-heavy non-companion profiles need additional reductions to remain portable. MQTT observer builds -keep MQTT/TLS, onboard GPS, and their WiFi pull-updater, but omit WebConfig, SNMP, debug logging, display -support, and optional external sensor drivers. Their compact CLI keeps observer controls plus the radio, -TX power, CAD, interference-threshold, AGC, repeat, and retained bridge controls. It uses UTC or fixed UTC/GMT -offsets instead of the full named-timezone table. Built-in TLS presets keep their pinned CA roots; the 66 KB -general CA bundle for custom TLS brokers is omitted, so portable observers use a built-in preset or a custom -non-TLS broker. Size-constrained classic ESP32 observers without PSRAM may use Espressif's compact printf -implementation from chip ROM while retaining the normal ESP-IDF C library and ABI. Generic ESP-IDF/mbedTLS -error text keeps error codes and MQTT status available. Classic T-Beam observers retain AXP192/AXP2101 radio -and GPS rail setup plus battery-voltage -readings, but omit unrelated PMU policy. ESP-NOW bridge builds keep the ESP-NOW bridge, onboard GPS, and the -same radio-capable compact CLI, but omit display support and optional external sensors. These reductions do -not apply to companion builds. Ordinary repeater builds remain sensor-enabled; only explicitly named -`*_lora_ota_no_external_sensors` siblings omit sensors for LoRa distribution. +WiFi-heavy non-companion roles are not reduced to fit the legacy application slot. `build.sh` automatically +promotes every ESP32 MQTT observer and ESP-NOW bridge to the expanded FULL partition profile. These artifacts +retain the complete role CLI, WebConfig where supported, display and optional sensor support, full timezone +and TLS behavior, and the board's normal power-management implementation. The compact CLI is not compiled +into any build. Ordinary repeater builds remain sensor-enabled; only explicitly named +`*_lora_ota_no_external_sensors` siblings omit sensors for LoRa distribution, and those siblings retain the +complete CLI. MQTT observer radio and bridge preferences use verified temporary files plus a recoverable backup. A reset during a settings save restores the last committed common preference image or publishes the completed new image; it does not leave a partially written `/com_prefs` file to fail on the next boot. A truncated legacy image is rejected before any partial radio or string fields are applied, then rewritten from safe defaults. -Option 3 in `build.sh` also emits `*-full-ota-*` and `*-full-logging-ota-*` ESP32 artifacts for -non-companion roles where the portable profile removes a compiled feature and for the constrained companion -fallbacks described above. Menu option 8, or `build-full-esp32-firmwares`, builds the logging-off FULL +Option 3 in `build.sh` emits `*-full-ota-*` and `*-full-logging-ota-*` ESP32 artifacts for +FULL-capable non-companion roles and for the constrained companion fallbacks described above. MQTT observers +and ESP-NOW bridges are emitted only with expanded FULL partitions. Menu option 8, or +`build-full-esp32-firmwares`, builds the logging-off FULL artifacts from matching MQTT targets. Menu option 9, or `build-full-esp32-logging-firmwares`, builds the FULL logging artifacts from matching non-MQTT targets. FULL builds restore WebConfig, display support, optional external sensors, and the full role CLI and feature @@ -745,6 +740,21 @@ ota dev ... bring-up helpers (stage/recv/serve/verify) 2. re-checks `TRAILER`, `image_hash`, `approval == "APRV"`, and that the delta's `base_hash` equals the running firmware's `EndF.body_hash` (recomputed by scanning for `EndF` - never trust `bank_0_size`), 3. applies the in-place codec over the app region and boots only if the result hashes to `image_hash`. +- **nRF52 internal staging ceiling:** the application derives the ceiling from facts available in every + build, not a board-name list. A companion that actually links the internal ExtraFS datastore stays below + `0xD4000`; a default linker region, QSPI secondary storage, or a role that does not mount ExtraFS can + reclaim the unused 100 KiB through `0xED000`. The application uses the larger window only when the + installed bootloader advertises the GPREGRET2 ceiling-handoff capability. The bootloader treats every + unknown/legacy handoff value as + `0xD4000`, and accepts a container only at the bottom-aligned position for the selected ceiling. +- **nRF52 dynamic apply window:** the post-build hook records the resolved app base, linked app end, + storage flags, and desired staging ceiling immediately before `EndF`. `motatool` reads that authenticated + firmware record and chooses `memory_size` from the actual patch size and bottom-aligned stage address; + firmware without the record retains the conservative `0x98000` default. Before writing `APRV`, the app + verifies the detools header fits below the staged container, and the bootloader independently repeats + the geometry check before its first application write. Expanded auto-sized packages require a bootloader + with the ceiling-handoff capability; use `--inplace-memory 0x98000` when intentionally targeting an older + bootloader and the images still fit that window. - **nRF52 EndF rescue:** `ota rescue install ` is a pre-provisioned recovery path for an internal-flash nRF52 application that still runs but cannot validate its own EndF identity. It refuses when normal EndF validation succeeds, requires the operator hash to exactly equal the staged delta's diff --git a/docs/terminal_chat_cli.md b/docs/terminal_chat_cli.md index 43dd2d30..b3f464c2 100644 --- a/docs/terminal_chat_cli.md +++ b/docs/terminal_chat_cli.md @@ -92,6 +92,14 @@ set powersaving {on|off} Shows or changes Companion device power saving. On ESP32 this controls CPU and GPS idle behavior; it does not change LoRa RXPS or WiFi modem sleep. +``` +get usb.logging +set usb.logging {on|off} +``` +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. + ``` get radio.rxps set radio.rxps {off|on|level 1-10 [preamble 16|32]|rx_us sleep_us} diff --git a/examples/companion_radio/DataStore.cpp b/examples/companion_radio/DataStore.cpp index eef54d24..ba05fecf 100644 --- a/examples/companion_radio/DataStore.cpp +++ b/examples/companion_radio/DataStore.cpp @@ -6,6 +6,13 @@ #include #endif +// Linked presence of this symbol is the authoritative signal that this firmware actually mounts the +// internal 0xD4000 ExtraFS. OTA layout code references it weakly, so non-companion roles can reclaim the +// reserved range even though nrf52_base defines EXTRAFS globally. +#if defined(NRF52_PLATFORM) && defined(EXTRAFS) && !defined(QSPIFLASH) +extern "C" __attribute__((used)) const uint8_t g_meshcore_internal_extrafs = 1u; +#endif + #if defined(EXTRAFS) || defined(QSPIFLASH) #define MAX_BLOBRECS 100 #else diff --git a/examples/companion_radio/MyMesh.cpp b/examples/companion_radio/MyMesh.cpp index 56c9eef2..880d67be 100644 --- a/examples/companion_radio/MyMesh.cpp +++ b/examples/companion_radio/MyMesh.cpp @@ -1354,7 +1354,7 @@ MyMesh::MyMesh(mesh::Radio &radio, mesh::RNG &rng, mesh::RTCClock &rtc, SimpleMe _prefs.tx_power_dbm = LORA_TX_POWER; _prefs.buzzer_quiet = DEFAULT_BUZZER_QUIET ? 1 : 0; _prefs.gps_enabled = 0; // GPS disabled by default - _prefs.gps_interval = 0; // No automatic GPS updates by default + _prefs.gps_interval = 0; // Use the default 1-second fix-processing interval _prefs.autoadd_config = DEFAULT_AUTOADD_CONFIG; _prefs.path_hash_mode = DEFAULT_PATH_HASH_MODE; _prefs.radio_fem_txgain = 0; @@ -4665,6 +4665,11 @@ void MyMesh::handleTerminalCommand(char* command) { || strcmp(command, "get powersaving") == 0) { Serial.printf(" powersaving %s\r\n", _prefs.powersaving_enabled ? "on" : "off"); +#if MESH_USB_LOGGING_AVAILABLE + } else if (strcmp(command, "get usb.logging") == 0) { + Serial.printf(" usb.logging %s\r\n", + mesh::isUsbLoggingEnabled() ? "on" : "off"); +#endif } else if (strncmp(command, "powersaving ", 12) == 0) { char reply[160]; applyAndSavePowerSaving(command + 12, reply); @@ -4709,6 +4714,22 @@ void MyMesh::handleTerminalCommand(char* command) { } } else if (strncmp(command, "set ", 4) == 0) { const char* config = command + 4; +#if MESH_USB_LOGGING_AVAILABLE + if (strncmp(config, "usb.logging", 11) == 0 + && (config[11] == 0 || config[11] == ' ' + || config[11] == '\t')) { + const char* value = config + 11; + while (*value == ' ' || *value == '\t') value++; + if (strcmp(value, "on") != 0 && strcmp(value, "off") != 0) { + Serial.print(" ERROR: use set usb.logging \r\n"); + } else { + const bool enabled = strcmp(value, "on") == 0; + mesh::setUsbLoggingEnabled(enabled); + Serial.printf(" OK - USB logging %s until reboot\r\n", + enabled ? "on" : "off"); + } + } else +#endif if (strncmp(config, "powersaving ", 12) == 0) { char reply[160]; applyAndSavePowerSaving(config + 12, reply); @@ -4803,6 +4824,10 @@ void MyMesh::handleTerminalCommand(char* command) { Serial.print("Commands:\r\n"); Serial.print(" set {name|lat|lon|freq|tx|af} {value}\r\n"); Serial.print(" powersaving [on|off]\r\n"); +#if MESH_USB_LOGGING_AVAILABLE + Serial.print(" get usb.logging\r\n"); + Serial.print(" set usb.logging \r\n"); +#endif #if defined(ESP32) && defined(WIFI_SSID) Serial.print(" get wifi.powersave\r\n"); Serial.print(" set wifi.powersave \r\n"); diff --git a/examples/companion_radio/MyMesh.h b/examples/companion_radio/MyMesh.h index 353da306..178c2651 100644 --- a/examples/companion_radio/MyMesh.h +++ b/examples/companion_radio/MyMesh.h @@ -253,11 +253,9 @@ public: #if ENV_INCLUDE_GPS == 1 void applyGpsPrefs() { sensors.setSettingValue("gps", _prefs.gps_enabled ? "1" : "0"); - if (_prefs.gps_interval > 0) { - char interval_str[12]; // Max: 24 hours = 86400 seconds (5 digits + null) - sprintf(interval_str, "%u", _prefs.gps_interval); - sensors.setSettingValue("gps_interval", interval_str); - } + char interval_str[12]; // Max: 24 hours = 86400 seconds (5 digits + null) + sprintf(interval_str, "%u", _prefs.gps_interval); + sensors.setSettingValue("gps_interval", interval_str); } #endif diff --git a/examples/companion_radio/NodePrefs.h b/examples/companion_radio/NodePrefs.h index b75c7e6d..300433ef 100644 --- a/examples/companion_radio/NodePrefs.h +++ b/examples/companion_radio/NodePrefs.h @@ -33,7 +33,7 @@ struct CompanionNodePrefs { // persisted to file uint8_t advert_loc_policy; uint8_t buzzer_quiet; uint8_t gps_enabled; // GPS enabled flag (0=disabled, 1=enabled) - uint32_t gps_interval; // GPS read interval in seconds + uint32_t gps_interval; // GPS read interval in seconds; 0 uses the 1-second default uint8_t autoadd_config; // bitmask for auto-add contacts config uint8_t rx_boosted_gain; // SX126x RX boosted gain mode (0=power saving, 1=boosted) uint8_t radio_fem_rxgain; // LoRa FEM RX gain setting diff --git a/examples/companion_radio/main.cpp b/examples/companion_radio/main.cpp index 4441747d..8b3091b0 100644 --- a/examples/companion_radio/main.cpp +++ b/examples/companion_radio/main.cpp @@ -5,6 +5,13 @@ #ifdef ESP32_PLATFORM #include "esp_bt.h" +#include "esp_pm.h" +#include "esp_sleep.h" +#if defined(CONFIG_PM_ENABLE) && CONFIG_PM_ENABLE +#define COMPANION_IDF_PM_AVAILABLE 1 +#else +#define COMPANION_IDF_PM_AVAILABLE 0 +#endif #if defined(COMPANION_RADIO_FULL) #include #endif @@ -174,12 +181,39 @@ static uint32_t companionNominalCpuMhz() { static bool applyCompanionPowerSaving(bool enabled) { const uint32_t nominal_mhz = companionNominalCpuMhz(); - const uint32_t target_mhz = enabled && nominal_mhz > 80 ? 80 : nominal_mhz; - if (!setCpuFrequencyMhz(target_mhz)) { - Serial.printf("Device power saving failed: CPU %lu MHz is unsupported\r\n", - (unsigned long)target_mhz); + const uint32_t max_mhz = enabled && nominal_mhz > 80 ? 80 : nominal_mhz; + +#if COMPANION_IDF_PM_AVAILABLE + const uint32_t min_mhz = enabled && max_mhz > 40 ? 40 : max_mhz; +#if CONFIG_IDF_TARGET_ESP32C3 + esp_pm_config_esp32c3_t pm_config; +#elif CONFIG_IDF_TARGET_ESP32S3 + esp_pm_config_esp32s3_t pm_config; +#elif CONFIG_IDF_TARGET_ESP32 + esp_pm_config_esp32_t pm_config; +#else + esp_pm_config_t pm_config; +#endif + pm_config.max_freq_mhz = max_mhz; + pm_config.min_freq_mhz = min_mhz; + pm_config.light_sleep_enable = enabled; + + esp_err_t pm_result = esp_pm_configure(&pm_config); + if (pm_result != ESP_OK) { + Serial.printf("Device power saving failed: %s\r\n", + esp_err_to_name(pm_result)); return false; } +#else + // Arduino's prebuilt ESP-IDF normally has CONFIG_PM_ENABLE disabled, in + // which case esp_pm_configure() is a stub returning ESP_ERR_NOT_SUPPORTED. + // Keep frequency throttling functional instead of retrying that stub. + if (!setCpuFrequencyMhz(max_mhz)) { + Serial.printf("Device power saving failed: CPU %lu MHz is unsupported\r\n", + (unsigned long)max_mhz); + return false; + } +#endif #if defined(BLE_PIN_CODE) && !CONFIG_IDF_TARGET_ESP32C6 esp_err_t bt_result = enabled ? esp_bt_sleep_enable() @@ -191,8 +225,14 @@ static bool applyCompanionPowerSaving(bool enabled) { } #endif +#if COMPANION_IDF_PM_AVAILABLE + Serial.printf("Device power saving %s: CPU %lu-%lu MHz, automatic light sleep %s\r\n", + enabled ? "on" : "off", (unsigned long)min_mhz, + (unsigned long)max_mhz, enabled ? "on" : "off"); +#else Serial.printf("Device power saving %s: CPU %lu MHz\r\n", - enabled ? "on" : "off", (unsigned long)target_mhz); + enabled ? "on" : "off", (unsigned long)max_mhz); +#endif return true; } @@ -1147,7 +1187,29 @@ void loop() { #if defined(NRF52_PLATFORM) board.sleep(0); // nrf ignores seconds param, sleeps whenever possible #elif defined(ESP32_PLATFORM) - vTaskDelay(pdMS_TO_TICKS(10)); // attempt to sleep +#if COMPANION_IDF_PM_AVAILABLE + // Yield long enough for ESP-IDF automatic light sleep to enter when no + // driver holds a power-management lock. + vTaskDelay(pdMS_TO_TICKS(10)); +#elif defined(ENABLE_USB_INTERFACE) \ + && defined(ARDUINO_USB_CDC_ON_BOOT) && ARDUINO_USB_CDC_ON_BOOT \ + && !defined(BLE_PIN_CODE) && !defined(WIFI_SSID) \ + && !defined(ETHERNET_ENABLED) && !defined(SERIAL_RX) + // The stock Arduino core has no automatic light sleep. A short timer + // slice gives native-USB-only battery builds real light sleep without + // delaying radio, GPS, button, or newly attached USB work by more than the + // normal 10 ms loop cadence. can_sleep already proved no USB host is up. + if (esp_sleep_enable_timer_wakeup(10000ULL) != ESP_OK + || esp_light_sleep_start() != ESP_OK) { + vTaskDelay(pdMS_TO_TICKS(10)); + } +#else + // Connected transports need their own modem sleep and must retain the + // normal FreeRTOS idle behavior. + vTaskDelay(pdMS_TO_TICKS(10)); +#endif +#elif defined(RP2040_PLATFORM) || defined(STM32_PLATFORM) + board.sleep(0); // event-driven idle; interrupts wake the main loop #endif } diff --git a/examples/kiss_modem/main.cpp b/examples/kiss_modem/main.cpp index 0202bfb3..fd2ac265 100644 --- a/examples/kiss_modem/main.cpp +++ b/examples/kiss_modem/main.cpp @@ -154,6 +154,9 @@ void loop() { board.feedWatchdog(); #endif modem->loop(); + // GPS acquisition, cache refresh, and delayed shutdown are driven by the + // sensor manager loop even though KISS itself is normally host powered. + sensors.loop(); if (!modem->isActuallyTransmitting() && !modem->isHostOutputBackedUp()) { if (!modem->isTxBusy()) { diff --git a/examples/simple_repeater/MyMesh.cpp b/examples/simple_repeater/MyMesh.cpp index 82ad8cec..3c589282 100644 --- a/examples/simple_repeater/MyMesh.cpp +++ b/examples/simple_repeater/MyMesh.cpp @@ -1016,11 +1016,13 @@ const char *MyMesh::getLogDateTime() { void MyMesh::logRxRaw(float snr, float rssi, const uint8_t raw[], int len) { #if MESH_PACKET_LOGGING && !defined(MESH_COMPACT_PACKET_LOGGING) - // Logging builds prefer backpressure over silently losing a packet record. - Serial.print(getLogDateTime()); - Serial.print(" RAW: "); - mesh::Utils::printHex(Serial, raw, len); - Serial.println(); + if (mesh::isUsbLoggingEnabled()) { + // Logging builds prefer backpressure over silently losing a packet record. + Serial.print(getLogDateTime()); + Serial.print(" RAW: "); + mesh::Utils::printHex(Serial, raw, len); + Serial.println(); + } #endif #ifdef WITH_MQTT_BRIDGE @@ -9510,9 +9512,8 @@ void MyMesh::formatClockSyncStatus(const char* args, char* reply, size_t reply_l #else -// Portable MQTT observers use their bridge's NTP source. Flash-constrained -// repeaters can also omit mesh consensus while retaining manual clock setting -// and scheduled radio changes. +// Builds without mesh consensus retain manual clock setting and scheduled +// radio changes. MQTT observers use their bridge's NTP source. static const char* clockSyncMeshSuppressionName(uint8_t source) { (void)source; return "unavailable"; @@ -10408,9 +10409,8 @@ void MyMesh::handleCommand(uint32_t sender_timestamp, ClientInfo* sender, char * #endif #if defined(PORTABLE_MQTT_OBSERVER) - // Neighbor refresh is a core repeater operation, not an MQTT feature. Keep - // it ahead of the portable observer's reduced CommonCLI handoff so every - // repeater build exposes the same command. + // Legacy PORTABLE_MQTT_OBSERVER builds kept neighbor refresh ahead of their + // reduced role handoff. Current build.sh profiles never define this macro. if (discover_neighbors_match != mesh::cli::NoArgCommandMatch::NoMatch) { if (discover_neighbors_match == mesh::cli::NoArgCommandMatch::HasArguments) { @@ -10422,9 +10422,8 @@ void MyMesh::handleCommand(uint32_t sender_timestamp, ClientInfo* sender, char * return; } - // Reply-path overrides are core remote-client routing controls. Keep them - // available before the portable observer hands the remaining commands to - // its reduced CommonCLI parser. + // Legacy PORTABLE_MQTT_OBSERVER builds kept reply-path overrides ahead of + // their reduced role handoff. Current build.sh profiles use FULL instead. if (classifyClientPathCommand(command) != CLIENT_PATH_NONE) { if (sender && !sender->isAdmin()) { bool allowed = (sender->isRegionMgr() || sender->isFilterMgr()) @@ -10438,9 +10437,8 @@ void MyMesh::handleCommand(uint32_t sender_timestamp, ClientInfo* sender, char * return; } - // The portable observer exposes its MQTT/WiFi/update controls through - // CommonCLI. Omit the repeater's large remote-administration command tree; - // its mesh behavior remains fixed by the selected build profile. + // Compatibility path for manually defined legacy portable builds. Current + // release builds use FULL and do not enter this branch. _cli.handleCommand(sender_timestamp, command, reply); return; #endif diff --git a/examples/simple_repeater/MyMesh.h b/examples/simple_repeater/MyMesh.h index b1e4856b..8c4686f8 100644 --- a/examples/simple_repeater/MyMesh.h +++ b/examples/simple_repeater/MyMesh.h @@ -822,6 +822,9 @@ protected: void applyGpsPrefs() { sensors.setPowerSavingEnabled(_prefs.powersaving_enabled != 0); sensors.setSettingValue("gps", _prefs.gps_enabled?"1":"0"); + char interval_str[12]; + sprintf(interval_str, "%u", _prefs.gps_interval); + sensors.setSettingValue("gps_interval", interval_str); } #endif diff --git a/examples/simple_room_server/MyMesh.cpp b/examples/simple_room_server/MyMesh.cpp index 643fa526..e6395e31 100644 --- a/examples/simple_room_server/MyMesh.cpp +++ b/examples/simple_room_server/MyMesh.cpp @@ -264,11 +264,13 @@ int MyMesh::handleRequest(ClientInfo *sender, uint32_t sender_timestamp, uint8_t void MyMesh::logRxRaw(float snr, float rssi, const uint8_t raw[], int len) { #if MESH_PACKET_LOGGING - // Logging builds prefer backpressure over silently losing a packet record. - Serial.print(getLogDateTime()); - Serial.print(" RAW: "); - mesh::Utils::printHex(Serial, raw, len); - Serial.println(); + if (mesh::isUsbLoggingEnabled()) { + // Logging builds prefer backpressure over silently losing a packet record. + Serial.print(getLogDateTime()); + Serial.print(" RAW: "); + mesh::Utils::printHex(Serial, raw, len); + Serial.println(); + } #endif #ifdef WITH_MQTT_BRIDGE diff --git a/examples/simple_room_server/MyMesh.h b/examples/simple_room_server/MyMesh.h index 9b48d4f8..e643de3e 100644 --- a/examples/simple_room_server/MyMesh.h +++ b/examples/simple_room_server/MyMesh.h @@ -331,6 +331,9 @@ protected: void applyGpsPrefs() { sensors.setPowerSavingEnabled(_prefs.powersaving_enabled != 0); sensors.setSettingValue("gps", _prefs.gps_enabled?"1":"0"); + char interval_str[12]; + sprintf(interval_str, "%u", _prefs.gps_interval); + sensors.setSettingValue("gps_interval", interval_str); } #endif diff --git a/examples/simple_sensor/SensorMesh.cpp b/examples/simple_sensor/SensorMesh.cpp index b5940bc0..99307562 100644 --- a/examples/simple_sensor/SensorMesh.cpp +++ b/examples/simple_sensor/SensorMesh.cpp @@ -818,8 +818,10 @@ void SensorMesh::onPeerDataRecv(mesh::Packet* packet, uint8_t type, int sender_i bool SensorMesh::handleIncomingMsg(ClientInfo& from, uint32_t timestamp, uint8_t* data, uint8_t flags, size_t len) { MESH_DEBUG_PRINT("handleIncomingMsg: unhandled msg from "); #ifdef MESH_DEBUG - mesh::Utils::printHex(Serial, from.id.pub_key, PUB_KEY_SIZE); - Serial.printf(": %s\n", data); + if (mesh::isUsbLoggingEnabled()) { + mesh::Utils::printHex(Serial, from.id.pub_key, PUB_KEY_SIZE); + Serial.printf(": %s\n", data); + } #endif return false; } @@ -1286,3 +1288,79 @@ void SensorMesh::loop() { dirty_contacts_expiry = 0; } } + +bool SensorMesh::isMillisTimerDue(unsigned long timestamp) const { + return timestamp && millisHasNowPassed(timestamp); +} + +uint32_t SensorMesh::limitSleepToMillisTimer(unsigned long timestamp, + uint32_t sleep_secs) const { + if (!timestamp || sleep_secs == 0) return sleep_secs; + + unsigned long now = millis(); + if ((long)(now - timestamp) >= 0) return 0; + + uint32_t remaining_secs = (timestamp - now + 999UL) / 1000UL; + return remaining_secs < sleep_secs ? remaining_secs : sleep_secs; +} + +bool SensorMesh::hasPendingWork() const { + if (_cli.hasActiveUserGpioTimer()) return true; + if (radio_driver.isWatchdogObserving()) return true; + if (radio_driver.isCalibratingNoiseFloor()) return true; + if (hasQueuedWorkDue() || hasRetryWorkDue()) return true; + if (isMillisTimerDue(next_flood_advert) + || isMillisTimerDue(next_local_advert) + || isMillisTimerDue(dirty_contacts_expiry)) return true; + if (num_alert_tasks > 0 + && isMillisTimerDue(alert_tasks[0]->send_expiry)) return true; + + const bool radio_apply_backoff = radio_apply_retry_at + && !millisHasNowPassed(radio_apply_retry_at); + if (!radio_apply_backoff + && (isMillisTimerDue(set_radio_at) || isMillisTimerDue(revert_radio_at) + || (saved_radio_apply_pending && !temp_radio_applied))) return true; + + uint32_t now_secs = getRTCClock()->getCurrentTime(); + return now_secs >= last_read_time + SENSOR_READ_INTERVAL_SECS; +} + +uint32_t SensorMesh::getPowerSaveSleepSeconds(uint32_t max_secs) const { + if (max_secs == 0 || hasPendingWork()) return 0; + + uint32_t sleep_secs = max_secs; + uint32_t wake_delay_ms; + if (getNextQueueWakeDelay(wake_delay_ms)) { + uint32_t wake_delay_secs = (wake_delay_ms + 999UL) / 1000UL; + if (wake_delay_secs < sleep_secs) sleep_secs = wake_delay_secs; + } + if (getNextRetryWakeDelay(wake_delay_ms)) { + uint32_t wake_delay_secs = (wake_delay_ms + 999UL) / 1000UL; + if (wake_delay_secs < sleep_secs) sleep_secs = wake_delay_secs; + } + + sleep_secs = limitSleepToMillisTimer(next_flood_advert, sleep_secs); + sleep_secs = limitSleepToMillisTimer(next_local_advert, sleep_secs); + sleep_secs = limitSleepToMillisTimer(dirty_contacts_expiry, sleep_secs); + if (num_alert_tasks > 0) { + sleep_secs = limitSleepToMillisTimer(alert_tasks[0]->send_expiry, + sleep_secs); + } + + const bool radio_apply_backoff = radio_apply_retry_at + && !millisHasNowPassed(radio_apply_retry_at); + if (radio_apply_backoff) { + sleep_secs = limitSleepToMillisTimer(radio_apply_retry_at, sleep_secs); + } else { + sleep_secs = limitSleepToMillisTimer(set_radio_at, sleep_secs); + sleep_secs = limitSleepToMillisTimer(revert_radio_at, sleep_secs); + } + + uint32_t now_secs = getRTCClock()->getCurrentTime(); + uint32_t sensor_due = last_read_time + SENSOR_READ_INTERVAL_SECS; + if (now_secs < sensor_due) { + uint32_t sensor_delay = sensor_due - now_secs; + if (sensor_delay < sleep_secs) sleep_secs = sensor_delay; + } + return sleep_secs; +} diff --git a/examples/simple_sensor/SensorMesh.h b/examples/simple_sensor/SensorMesh.h index 630b7e01..78c6de18 100644 --- a/examples/simple_sensor/SensorMesh.h +++ b/examples/simple_sensor/SensorMesh.h @@ -66,6 +66,7 @@ public: const char* getRole() override { return FIRMWARE_ROLE; } const char* getNodeName() { return _prefs.node_name; } NodePrefs* getNodePrefs() { return &_prefs; } + uint32_t getPowerSaveSleepSeconds(uint32_t max_secs) const; void savePrefs( PrefsSaveRouting::Scope scope = PrefsSaveRouting::Scope::Common) override { _cli.savePrefs(_fs, scope); @@ -213,6 +214,10 @@ private: uint8_t radio_apply_failures; bool applySavedRadioParams(); + bool isMillisTimerDue(unsigned long timestamp) const; + uint32_t limitSleepToMillisTimer(unsigned long timestamp, + uint32_t sleep_secs) const; + bool hasPendingWork() const; uint8_t handleLoginReq(const mesh::Identity& sender, const uint8_t* secret, uint32_t sender_timestamp, const uint8_t* data, bool is_flood); uint8_t handleRequest(uint8_t perms, uint32_t sender_timestamp, uint8_t req_type, uint8_t* payload, size_t payload_len); @@ -226,6 +231,9 @@ private: void applyGpsPrefs() { sensors.setPowerSavingEnabled(_prefs.powersaving_enabled != 0); sensors.setSettingValue("gps", _prefs.gps_enabled?"1":"0"); + char interval_str[12]; + sprintf(interval_str, "%u", _prefs.gps_interval); + sensors.setSettingValue("gps_interval", interval_str); } #endif }; diff --git a/examples/simple_sensor/main.cpp b/examples/simple_sensor/main.cpp index e8e853ec..b12decf5 100644 --- a/examples/simple_sensor/main.cpp +++ b/examples/simple_sensor/main.cpp @@ -56,6 +56,7 @@ void halt() { } static char command[160]; +static const unsigned long POWERSAVING_FIRST_SLEEP_SECS = 120; void setup() { Serial.begin(115200); @@ -179,4 +180,21 @@ void loop() { #ifdef HAS_EXTERNAL_WATCHDOG external_watchdog.loop(); #endif + + if (the_mesh.getNodePrefs()->powersaving_enabled + && !board.isUsbDataConnected()) { + uint32_t sleep_secs = the_mesh.getPowerSaveSleepSeconds(30); +#ifdef HAS_EXTERNAL_WATCHDOG + if (sleep_secs > 0) external_watchdog.feed(); +#endif +#if defined(NRF52_PLATFORM) + if (sleep_secs > 0) board.sleep(0); +#else + if (sleep_secs > 0 + && the_mesh.millisHasNowPassed( + POWERSAVING_FIRST_SLEEP_SECS * 1000UL)) { + board.sleep(sleep_secs); + } +#endif + } } diff --git a/mesh-america/README.md b/mesh-america/README.md index 72b6d96d..c814203a 100644 --- a/mesh-america/README.md +++ b/mesh-america/README.md @@ -32,8 +32,8 @@ python3 mesh-america/update-provider-release.py \ The updater preserves the catalog's curated device names, role choices, and hardware guidance; validates every referenced artifact; routes URLs to the -correct GitHub release page; and replaces legacy FULL MQTT choices with the -matching portable MQTT observer builds. +correct GitHub release page; and replaces legacy portable MQTT choices with the +matching expanded-partition FULL MQTT observer builds. The logging catalog likewise keeps unaffected roles on v1.17.1.1 while its Companion, Full Companion, and expanded Companion logging profiles track the diff --git a/mesh-america/update-logging-provider-release.py b/mesh-america/update-logging-provider-release.py index 34373ec6..dad1944c 100755 --- a/mesh-america/update-logging-provider-release.py +++ b/mesh-america/update-logging-provider-release.py @@ -85,6 +85,7 @@ def release_tag(identity: str, args: argparse.Namespace) -> str: return args.logging_utility_tag if is_logging_utility(identity) else args.logging_main_tag if ( "companion_radio_full" in lowered + or "-full-ota" in lowered or "lora_ota" in lowered or "observer_mqtt" in lowered ): @@ -117,7 +118,7 @@ def update_catalog(catalog: dict, release_files: dict[str, list[Path]], args: ar "retry tuning, Cascade defaults, and the USA/Canada 910.525 MHz / SF7 / " "BW62.5 / CR5 preset. This catalog contains packet-logging builds with " "USB debug enabled except on seven flash-constrained STM32 targets, " - "selected non-logging utility and portable MQTT observer builds, " + "selected non-logging utility and FULL MQTT observer builds, " "and expanded-partition FULL USB-logging LoRa-OTA builds. Host software " "can consume the USB serial log and publish it separately. Open Release " "notes for role, hardware, installation, and partition requirements." @@ -143,7 +144,20 @@ def update_catalog(catalog: dict, release_files: dict[str, list[Path]], args: ar old_keys = list(firmware["version"]) old_version = next(iter(firmware["version"].values())) old_notes = old_version["notes"] - identities = common.ordered_catalog_identities(firmware) + old_identities = common.ordered_catalog_identities(firmware) + identities: list[str] = [] + observer_entry = False + for old_identity in old_identities: + identity, converted_observer = common.resolve_release_identity( + old_identity, release_files + ) + if identity not in identities: + identities.append(identity) + observer_entry = ( + observer_entry + or converted_observer + or "observer_mqtt" in identity.lower() + ) paths: list[Path] = [] for identity in identities: if identity not in release_files: @@ -187,7 +201,13 @@ def update_catalog(catalog: dict, release_files: dict[str, list[Path]], args: ar ) version_key = common.replacement_version_key(old_keys[0], args.artifact_version) - notes = common.replace_release_version(old_notes, display_version) + if observer_entry: + notes = common.observer_notes( + firmware["role"], display_version, identities, old_notes + ) + firmware["subTitle"] = "FULL MQTT observer" + else: + notes = common.replace_release_version(old_notes, display_version) if "USA/Canada 910.525 MHz" not in notes: notes = notes.replace( "with Halo/Keymind retry tuning and Cascade defaults.", diff --git a/mesh-america/update-provider-release.py b/mesh-america/update-provider-release.py index 2e28809e..d4485a39 100644 --- a/mesh-america/update-provider-release.py +++ b/mesh-america/update-provider-release.py @@ -137,9 +137,19 @@ def resolve_release_identity( if alias is not None and alias in release_files: return alias, False - # The previous catalog used expanded-partition FULL MQTT images. The new - # standard matrix emits a portable MQTT observer under the same target name - # without the build-only "-full-ota" filename marker. + # Accept catalogs produced while portable MQTT observers were still + # emitted, and migrate them to the expanded-partition FULL artifact. + if "observer_mqtt" in old_identity.lower(): + full_identity = f"{old_identity}-full-ota" + if full_identity in release_files: + return full_identity, True + if "bridge_espnow" in old_identity.lower(): + full_identity = f"{old_identity}-full-ota" + if full_identity in release_files: + return full_identity, False + + # Retain compatibility with the short-lived reverse migration used by + # older release directories. full_suffix = "-full-ota" if old_identity.endswith(full_suffix): portable_identity = old_identity[: -len(full_suffix)] @@ -172,6 +182,7 @@ def release_tag_for_identity(identity: str, args: argparse.Namespace) -> str: lowered = identity.lower() if ( "companion_radio_full" in lowered + or "-full-ota" in lowered or "lora_ota" in lowered or "observer_mqtt" in lowered ): @@ -240,6 +251,8 @@ def replace_release_version(notes: str, display_version: str) -> str: def partition_warning(identities: list[str]) -> str | None: if not any( identity.lower() in LEGACY_PORTABLE_CEILING_EXCEPTIONS + or "-full-ota" in identity.lower() + or "-full-logging-ota" in identity.lower() for identity in identities ): return None @@ -297,15 +310,15 @@ def observer_notes( ), role_paragraph, ( - "PROFILE - Portable MQTT observer: forwards mesh observations to an " - "on-device Wi-Fi MQTT bridge, keeps USB debug/packet logging off, and " - "uses bridge NTP instead of mesh clock consensus. LoRa self-update is " - "off in this compact profile." + "PROFILE - FULL MQTT observer: uses expanded partitions, forwards mesh observations to an " + "on-device Wi-Fi MQTT bridge, keeps USB debug/packet logging off, " + "uses bridge NTP instead of mesh clock consensus, and retains the " + "complete role CLI. LoRa self-update is enabled." ), ( - "INSTALL - First-time setup uses Full install (the merged bootloader + " - "firmware image). Routine upgrades use Update (the app-only image) only " - "when the existing app partition is compatible." + "INSTALL - Flash Full install (the merged bootloader + firmware image) " + "over USB once to install the expanded partition table. Routine upgrades " + "can then use Update (the app-only image) while that layout remains installed." ), ] warning = partition_warning(identities) @@ -320,7 +333,7 @@ def observer_notes( "Board selection note:" ): paragraphs.append(paragraph) - paragraphs.append("SELECTION - Portable MQTT observer.") + paragraphs.append("SELECTION - FULL MQTT observer.") return "\n\n".join(paragraphs) @@ -341,8 +354,9 @@ def update_catalog(catalog: dict, release_files: dict[str, list[Path]], args: ar f"Keymind Cascade MeshCore {display_version} firmware with Halo/Keymind " "retry tuning, Cascade defaults, and the USA/Canada 910.525 MHz / SF7 / " "BW62.5 / CR5 preset. This catalog contains standard builds, Full " - "Companion builds, compact LoRa-OTA builds, and portable MQTT observer " - "builds with USB debug/packet logging off. On nRF52, Full Companion " + "Companion builds, lean LoRa-OTA builds, and expanded-partition FULL " + "MQTT observer and ESP-NOW bridge builds. MQTT observers keep USB " + "debug/packet logging off. On nRF52, Full Companion " "replaces separate BLE and USB choices; on ESP32, Full Companion is " "offered next to the BLE/USB variants. Open Release notes for role, " "hardware, installation, and partition requirements." @@ -406,7 +420,7 @@ def update_catalog(catalog: dict, release_files: dict[str, list[Path]], args: ar resolved_identities, old_notes, ) - firmware["subTitle"] = "Portable MQTT observer" + firmware["subTitle"] = "FULL MQTT observer" observer_entries += 1 else: version_key = replacement_version_key(old_keys[0], args.artifact_version) diff --git a/platformio.ini b/platformio.ini index 62f387da..b73c0f28 100644 --- a/platformio.ini +++ b/platformio.ini @@ -130,9 +130,12 @@ lib_ignore = PsychicMqttClient ; Shared LoRa OTA recipe for nRF52840 variants. nRF52840 has no A/B slot, so the update is ; applied in place by the custom OTAFIX bootloader (in-place detools); the app only stages + verifies + -; approves it. OtaFlashLayout_nrf52.h uses FS_START 0xD4000 as the safe staging ceiling for the supported -; S140 v6/v7 linker scripts. The device must run the matching OTAFIX -; bootloader for `applydelta` to actually apply. (detools.c is intentionally NOT built into the app - +; approves it. OtaFlashLayout_nrf52.h derives the safe ceiling from the linked app region and secondary +; storage: internal ExtraFS stays protected at 0xD4000; QSPI/no-ExtraFS roles can stage through 0xED000. +; The EndF hook records that resolved geometry so motatool can size each in-place patch without target-name +; heuristics. An older bootloader makes the app stage below 0xD4000; packages sized for the expanded window +; require the updated OTAFIX bootloader (or a legacy motatool override). The device must run the matching +; OTAFIX bootloader for `applydelta` to actually apply. (detools.c is intentionally NOT built into the app - ; only the bootloader decodes.) The [rak4631] base defines this same recipe inline because it carries ; board-specific extras (fix_bsec_lib.py, BSEC lib) and a fixed post-script order. [nrf52_lora_ota] diff --git a/src/Dispatcher.cpp b/src/Dispatcher.cpp index 17d28fe9..75080c87 100644 --- a/src/Dispatcher.cpp +++ b/src/Dispatcher.cpp @@ -220,6 +220,7 @@ uint32_t Dispatcher::getCADFailMaxDuration() const { #if MESH_PACKET_LOGGING void Dispatcher::logPacketStart(const char* direction, const Packet* packet, int len) { + if (!isUsbLoggingEnabled()) return; #if defined(MESH_COMPACT_PACKET_LOGGING) (void)packet; Serial.printf("%s %d\n", direction, len); @@ -232,6 +233,7 @@ void Dispatcher::logPacketStart(const char* direction, const Packet* packet, int } void Dispatcher::logPacketEnd(const Packet* packet) { + if (!isUsbLoggingEnabled()) return; #if defined(MESH_COMPACT_PACKET_LOGGING) (void)packet; #else @@ -598,17 +600,19 @@ void Dispatcher::checkRecv() { } if (pkt) { #if MESH_PACKET_LOGGING - logPacketStart("RX", pkt, pkt->getRawLength()); + if (isUsbLoggingEnabled()) { + logPacketStart("RX", pkt, pkt->getRawLength()); #if !defined(MESH_COMPACT_PACKET_LOGGING) - Serial.printf(" SNR=%d RSSI=%d score=%d time=%d", (int)pkt->getSNR(), - (int)rssi, (int)(score * 1000), air_time); + Serial.printf(" SNR=%d RSSI=%d score=%d time=%d", (int)pkt->getSNR(), + (int)rssi, (int)(score * 1000), air_time); - static uint8_t packet_hash[MAX_HASH_SIZE]; - pkt->calculatePacketHash(packet_hash); - Serial.print(" hash="); - mesh::Utils::printHex(Serial, packet_hash, MAX_HASH_SIZE); - logPacketEnd(pkt); + static uint8_t packet_hash[MAX_HASH_SIZE]; + pkt->calculatePacketHash(packet_hash); + Serial.print(" hash="); + mesh::Utils::printHex(Serial, packet_hash, MAX_HASH_SIZE); + logPacketEnd(pkt); #endif + } #endif logRx(pkt, pkt->getRawLength(), score); // hook for custom logging diff --git a/src/MeshCore.h b/src/MeshCore.h index b389f8ec..fb9f6b3d 100644 --- a/src/MeshCore.h +++ b/src/MeshCore.h @@ -3,6 +3,7 @@ #include #include #include +#include "helpers/UsbLogging.h" #define MAX_HASH_SIZE 8 #define PUB_KEY_SIZE 32 @@ -24,23 +25,23 @@ #if MESH_DEBUG && ARDUINO #include - #define MESH_DEBUG_PRINT(F, ...) do { if (Serial.availableForWrite() > 0) { Serial.printf("DEBUG: " F, ##__VA_ARGS__); } } while(0) - #define MESH_DEBUG_PRINTLN(F, ...) do { if (Serial.availableForWrite() > 0) { Serial.printf("DEBUG: " F "\n", ##__VA_ARGS__); } } while(0) + #define MESH_DEBUG_PRINT(F, ...) do { if (mesh::isUsbLoggingEnabled() && Serial.availableForWrite() > 0) { Serial.printf("DEBUG: " F, ##__VA_ARGS__); } } while(0) + #define MESH_DEBUG_PRINTLN(F, ...) do { if (mesh::isUsbLoggingEnabled() && Serial.availableForWrite() > 0) { Serial.printf("DEBUG: " F "\n", ##__VA_ARGS__); } } while(0) #else #define MESH_DEBUG_PRINT(...) {} #define MESH_DEBUG_PRINTLN(...) {} #endif #if BRIDGE_DEBUG && ARDUINO -#define BRIDGE_DEBUG_PRINTLN(F, ...) do { if (Serial.availableForWrite() > 0) { Serial.printf("%s BRIDGE: " F, getLogDateTime(), ##__VA_ARGS__); } } while(0) +#define BRIDGE_DEBUG_PRINTLN(F, ...) do { if (mesh::isUsbLoggingEnabled() && Serial.availableForWrite() > 0) { Serial.printf("%s BRIDGE: " F, getLogDateTime(), ##__VA_ARGS__); } } while(0) #else #define BRIDGE_DEBUG_PRINTLN(...) {} #endif #if POWERSAVING_DEBUG && ARDUINO #include - #define POWERSAVING_DEBUG_PRINT(F, ...) Serial.printf("POWERSAVING: " F, ##__VA_ARGS__) - #define POWERSAVING_DEBUG_PRINTLN(F, ...) Serial.printf("POWERSAVING: " F "\n", ##__VA_ARGS__) + #define POWERSAVING_DEBUG_PRINT(F, ...) do { if (mesh::isUsbLoggingEnabled()) { Serial.printf("POWERSAVING: " F, ##__VA_ARGS__); } } while(0) + #define POWERSAVING_DEBUG_PRINTLN(F, ...) do { if (mesh::isUsbLoggingEnabled()) { Serial.printf("POWERSAVING: " F "\n", ##__VA_ARGS__); } } while(0) #else #define POWERSAVING_DEBUG_PRINT(...) {} #define POWERSAVING_DEBUG_PRINTLN(...) {} @@ -71,7 +72,15 @@ public: // Default no-op: boards that don't care need not implement anything. virtual void onBootComplete() { /* no op */ } virtual uint32_t getIRQGpio() { return -1; } // not supported. Returns DIO1 (SX1262) and DIO0 (SX127x) - virtual void sleep(uint32_t secs) { /* no op */ } + virtual void sleep(uint32_t secs) { + (void)secs; +#if defined(RP2040_PLATFORM) || defined(STM32_PLATFORM) + // These platforms have no shared deep-sleep board implementation. WFI is + // still a real CPU idle state and preserves all configured interrupt wake + // sources, including the radio and USB. + __asm volatile("wfi"); +#endif + } virtual uint32_t getGpio() { return 0; } virtual void setGpio(uint32_t values) {} // Returns true only for physical MCU GPIOs that are safe for the user to diff --git a/src/helpers/AlertReporter.cpp b/src/helpers/AlertReporter.cpp index e54d7f03..d01775b1 100644 --- a/src/helpers/AlertReporter.cpp +++ b/src/helpers/AlertReporter.cpp @@ -21,7 +21,7 @@ #ifdef MQTT_DEBUG #include -#define ALERT_DEBUG_PRINTLN(...) Serial.printf("Alert: " __VA_ARGS__); Serial.println() +#define ALERT_DEBUG_PRINTLN(...) do { if (mesh::isUsbLoggingEnabled()) { Serial.printf("Alert: " __VA_ARGS__); Serial.println(); } } while (0) #else #define ALERT_DEBUG_PRINTLN(...) do {} while (0) #endif diff --git a/src/helpers/CommonCLI.cpp b/src/helpers/CommonCLI.cpp index 99b4b5a2..16d12e45 100644 --- a/src/helpers/CommonCLI.cpp +++ b/src/helpers/CommonCLI.cpp @@ -2250,238 +2250,6 @@ void CommonCLI::handleCommand(uint32_t sender_timestamp, char* command, char* re // Observer-only top-level commands (ota check/update, tls.bundletest, alert test) // live in CommonCLI_Observer.cpp. if (handleObserverCommand(sender_timestamp, command, reply)) return; -#if defined(PORTABLE_ESP32_RADIO_CLI) - // Portable WiFi-heavy images keep the commands needed to commission, - // update, and diagnose the node. Their feature-complete FULL siblings keep - // the complete administration, logging, and external-sensor command tree. - if (strcmp(command, "poweroff") == 0 || strcmp(command, "shutdown") == 0) { - _board->powerOff(); // doesn't return - } else if (strcmp(command, "reboot") == 0) { - _board->reboot(); // doesn't return - } else if (strcmp(command, "advert.zerohop") == 0) { - _callbacks->sendSelfAdvertisement(1500, false); - strcpy(reply, "OK - zerohop advert sent"); - } else if (strcmp(command, "advert") == 0) { - _callbacks->sendSelfAdvertisement(1500, true); - strcpy(reply, "OK - Advert sent"); - } else if (strcmp(command, "clock sync") == 0) { - uint32_t curr = getRTCClock()->getCurrentTime(); - if (sender_timestamp > curr) { - getRTCClock()->setCurrentTime(sender_timestamp + 1); - _callbacks->onManualClockSet(); - uint32_t now = getRTCClock()->getCurrentTime(); - DateTime dt = DateTime(now); - sprintf(reply, "OK - clock set: %02d:%02d - %d/%d/%d UTC", - dt.hour(), dt.minute(), dt.day(), dt.month(), dt.year()); - } else { - strcpy(reply, "ERR: clock cannot go backwards"); - } -#ifdef ESP_PLATFORM - } else if (strcmp(command, "memory") == 0) { - sprintf(reply, "Free: %d, Min: %d, Max: %d, Queue: %d, IntFree: %d, IntMax: %d, PSRAM: %d/%d", - ESP.getFreeHeap(), ESP.getMinFreeHeap(), ESP.getMaxAllocHeap(), - _callbacks->getQueueSize(), - (int)heap_caps_get_free_size(MALLOC_CAP_INTERNAL), - (int)heap_caps_get_largest_free_block(MALLOC_CAP_INTERNAL), - (int)heap_caps_get_free_size(MALLOC_CAP_SPIRAM), - (int)heap_caps_get_total_size(MALLOC_CAP_SPIRAM)); -#endif - } else if (memcmp(command, "start ota", 9) == 0 - && (command[9] == 0 || command[9] == ' ')) { - const bool force_ap = command[9] == ' ' && strcmp(&command[10], "ap") == 0; - if (command[9] == ' ' && !force_ap) { - strcpy(reply, "ERR: usage start ota [ap]"); - } else if (!_board->startOTAUpdate(_prefs->node_name, reply, force_ap)) { - strcpy(reply, "Error"); - } -#if defined(WITH_MQTT_BRIDGE) && defined(LIGHTWEIGHT_WIFI_OTA) - else { - _callbacks->setBridgeState(false); - } -#endif - } else if (strcmp(command, "stop ota") == 0) { - if (!_board->stopOTAUpdate(reply)) { - strcpy(reply, "Error"); - } -#if defined(WITH_MQTT_BRIDGE) && defined(LIGHTWEIGHT_WIFI_OTA) - else if (_prefs->bridge_enabled) { - _callbacks->setBridgeState(true); - } -#endif - } else if (strcmp(command, "clock") == 0) { - uint32_t now = getRTCClock()->getCurrentTime(); - DateTime dt = DateTime(now); - sprintf(reply, "%02d:%02d - %d/%d/%d UTC", - dt.hour(), dt.minute(), dt.day(), dt.month(), dt.year()); - } else if (memcmp(command, "time ", 5) == 0) { - uint32_t secs = _atoi(&command[5]); - uint32_t curr = getRTCClock()->getCurrentTime(); - if (secs > curr) { - getRTCClock()->setCurrentTime(secs); - _callbacks->onManualClockSet(); - uint32_t now = getRTCClock()->getCurrentTime(); - DateTime dt = DateTime(now); - sprintf(reply, "OK - clock set: %02d:%02d - %d/%d/%d UTC", - dt.hour(), dt.minute(), dt.day(), dt.month(), dt.year()); - } else { - strcpy(reply, "(ERR: clock cannot go backwards)"); - } - } else if (strcmp(command, "neighbors") == 0) { - _callbacks->formatNeighborsReply(reply); - } else if (memcmp(command, "password ", 9) == 0 && command[9] != 0) { - StrHelper::strncpy(_prefs->password, &command[9], sizeof(_prefs->password)); - savePrefs(); - sprintf(reply, "password now: %s", _prefs->password); - } else if (memcmp(command, "get ", 4) == 0) { - handleGetCmd(sender_timestamp, command, reply); - } else if (memcmp(command, "set ", 4) == 0) { - handleSetCmd(sender_timestamp, command, reply); - } else if (sender_timestamp == 0 && strcmp(command, "erase") == 0) { - bool success = _callbacks->formatFileSystem(); - sprintf(reply, "File system erase: %s", success ? "OK" : "Err"); - } else if (strcmp(command, "ver") == 0) { - sprintf(reply, "%s (Build: %s)", - _callbacks->getFirmwareVer(), _callbacks->getBuildDate()); - } else if (strcmp(command, "board") == 0) { - sprintf(reply, "%s", _board->getManufacturerName()); - } else if (memcmp(command, "region", 6) == 0 - && (command[6] == 0 || command[6] == ' ')) { - handleRegionCmd(command, reply); -#if ENV_INCLUDE_GPS == 1 - } else if (strcmp(command, "gps on") == 0) { - if (_sensors->setSettingValue("gps", "1")) { - _prefs->gps_enabled = 1; - savePrefs(); - strcpy(reply, _prefs->powersaving_enabled ? "on (powersaving)" : "ok"); - } else { - strcpy(reply, "gps toggle not found"); - } - } else if (strcmp(command, "gps off") == 0) { - if (_sensors->setSettingValue("gps", "0")) { - _prefs->gps_enabled = 0; - savePrefs(); - strcpy(reply, "ok"); - } else { - strcpy(reply, "gps toggle not found"); - } - } else if (strcmp(command, "gps sync") == 0) { - LocationProvider* location = _sensors->getLocationProvider(); - if (!_prefs->gps_enabled) { - strcpy(reply, "gps is off"); - } else if (location != NULL) { - location->syncTime(); - strcpy(reply, "scheduled"); - } else { - strcpy(reply, "gps provider not found"); - } - } else if (strcmp(command, "gps setloc") == 0) { - _prefs->node_lat = _sensors->node_lat; - _prefs->node_lon = _sensors->node_lon; - savePrefs(); - strcpy(reply, "ok"); - } else if (memcmp(command, "gps advert", 10) == 0 - && (command[10] == 0 || command[10] == ' ')) { - if (command[10] == 0) { - switch (_prefs->advert_loc_policy) { - case ADVERT_LOC_NONE: strcpy(reply, "> none"); break; - case ADVERT_LOC_PREFS: strcpy(reply, "> prefs"); break; - case ADVERT_LOC_SHARE: strcpy(reply, "> share"); break; - default: strcpy(reply, "error"); - } - } else if (strcmp(&command[11], "none") == 0) { - _prefs->advert_loc_policy = ADVERT_LOC_NONE; - savePrefs(); - strcpy(reply, "ok"); - } else if (strcmp(&command[11], "share") == 0) { - _prefs->advert_loc_policy = ADVERT_LOC_SHARE; - savePrefs(); - strcpy(reply, "ok"); - } else if (strcmp(&command[11], "prefs") == 0) { - _prefs->advert_loc_policy = ADVERT_LOC_PREFS; - savePrefs(); - strcpy(reply, "ok"); - } else { - strcpy(reply, "error"); - } - } else if (strcmp(command, "gps") == 0) { - LocationProvider* location = _sensors->getLocationProvider(); - if (location == NULL) { - strcpy(reply, "Can't find GPS"); - } else { - bool enabled = location->isEnabled(); - bool fix = location->isValid(); - int sats = location->satellitesCount(); - const char* gps_setting = _sensors->getSettingByKey("gps"); - bool active = gps_setting != NULL && strcmp(gps_setting, "1") == 0; - if (_prefs->powersaving_enabled && location->getGPSPowerSaving()) { - unsigned long now = millis(); - unsigned long next_off = location->getNextGPSOff(); - unsigned long deadline = - next_off != 0 ? next_off : location->getNextGPSOn(); - long remaining_ms = deadline == 0 ? 0 : (long)(deadline - now); - unsigned long mins = - remaining_ms > 0 ? (unsigned long)remaining_ms / 60000UL : 0; - if (next_off != 0) { - snprintf(reply, 160, - "on (powersaving, sleep in %luh %lum), %s, %s, %d sats", - mins / 60UL, mins % 60UL, - active ? "active" : "deactivated", - fix ? "fix" : "no fix", sats); - } else { - snprintf(reply, 160, "off (powersaving, wake in %luh %lum)", - mins / 60UL, mins % 60UL); - } - unsigned long last_sync = location->getLastValidTimeSync(); - size_t used = strlen(reply); - if (last_sync == 0) { - snprintf(reply + used, 160 - used, ", last sync: none"); - } else { - DateTime dt = DateTime(last_sync); - snprintf(reply + used, 160 - used, - ", last sync: %02d:%02d - %d/%d/%d UTC", - dt.hour(), dt.minute(), dt.day(), dt.month(), dt.year()); - } - } else if (enabled) { - sprintf(reply, "on, %s, %s, %d sats", - active ? "active" : "deactivated", - fix ? "fix" : "no fix", sats); - } else { - strcpy(reply, "off"); - } - } -#endif - } else if (strcmp(command, "sensor") == 0) { -#if defined(ENV_PIN_SDA) && defined(ENV_PIN_SCL) - sprintf(reply, "I2C Wire1: SDA=%s,SCL=%s\r\n", - STR(ENV_PIN_SDA), STR(ENV_PIN_SCL)); -#elif defined(PIN_BOARD_SDA) && defined(PIN_BOARD_SCL) - sprintf(reply, "I2C Wire: SDA=%s, SCL=%s\r\n", - STR(PIN_BOARD_SDA), STR(PIN_BOARD_SCL)); -#elif defined(PIN_WIRE_SDA) && defined(PIN_WIRE_SCL) - sprintf(reply, "I2C Wire: SDA=%s, SCL=%s\r\n", - STR(PIN_WIRE_SDA), STR(PIN_WIRE_SCL)); -#else - sprintf(reply, "I2C GPIOs not defined\r\n"); -#endif -#if defined(PIN_GPS_RX) && defined(PIN_GPS_TX) - sprintf(reply + strlen(reply), "GPS Serial: RX=%s, TX=%s", - STR(PIN_GPS_RX), STR(PIN_GPS_TX)); - #if defined(ENV_INCLUDE_GPS) && ENV_INCLUDE_GPS > 0 - sprintf(reply + strlen(reply), ". Configured"); - #else - sprintf(reply + strlen(reply), ". Not configured"); - #endif -#else - sprintf(reply + strlen(reply), "GPS Serial not defined"); -#endif - } else if (strcmp(command, "powerlog") == 0) { - sprintf(reply, "Last reset reason: %s", - _board->getResetReasonString(_board->getResetReason())); - } else { - strcpy(reply, "Unknown command"); - } - return; -#else if (memcmp(command, "poweroff", 8) == 0 || memcmp(command, "shutdown", 8) == 0) { _board->powerOff(); // doesn't return } else if (memcmp(command, "reboot", 6) == 0) { @@ -2687,7 +2455,27 @@ void CommonCLI::handleCommand(uint32_t sender_timestamp, char* command, char* re int num = mesh::Utils::parseTextParts(tmp, parts, 2, ' '); const char *key = (num > 0) ? parts[0] : ""; const char *value = (num > 1) ? parts[1] : "null"; - if (_sensors->setSettingValue(key, value)) { + uint32_t gps_interval = 0; + const bool is_gps_interval = strcmp(key, "gps_interval") == 0; + const bool is_gps_toggle = strcmp(key, "gps") == 0; + const bool valid_gps_interval = !is_gps_interval + || (parseUint32Strict(value, gps_interval) + && gps_interval <= mesh::gps::MAX_UPDATE_INTERVAL_SEC); + const bool valid_gps_toggle = !is_gps_toggle + || strcmp(value, "0") == 0 || strcmp(value, "1") == 0; + + if (!valid_gps_interval) { + strcpy(reply, "gps_interval must be 0..86400 seconds"); + } else if (!valid_gps_toggle) { + strcpy(reply, "gps must be 0 or 1"); + } else if (_sensors->setSettingValue(key, value)) { + if (is_gps_interval) { + _prefs->gps_interval = gps_interval; + savePrefs(); + } else if (is_gps_toggle) { + _prefs->gps_enabled = strcmp(value, "1") == 0 ? 1 : 0; + savePrefs(); + } strcpy(reply, "ok"); } else { strcpy(reply, "can't find custom var"); @@ -2921,7 +2709,6 @@ void CommonCLI::handleCommand(uint32_t sender_timestamp, char* command, char* re } else { strcpy(reply, "Unknown command"); } -#endif } #if defined(NRF52_PLATFORM) && defined(OTA_SD_STORE) @@ -3093,6 +2880,22 @@ bool CommonCLI::handleSdCardGetCmd(const char* config, char* reply) { void CommonCLI::handleSetCmd(uint32_t sender_timestamp, char* command, char* reply) { const char* config = &command[4]; +#if MESH_USB_LOGGING_AVAILABLE + if (strncmp(config, "usb.logging", 11) == 0 + && (config[11] == 0 || config[11] == ' ' || config[11] == '\t')) { + const char* value = &config[11]; + while (*value == ' ' || *value == '\t') value++; + if (strcmp(value, "on") == 0 || strcmp(value, "off") == 0) { + const bool enabled = strcmp(value, "on") == 0; + mesh::setUsbLoggingEnabled(enabled); + snprintf(reply, 160, "OK - USB logging %s until reboot", + enabled ? "on" : "off"); + } else { + strcpy(reply, "Error: usage set usb.logging on|off"); + } + return; + } +#endif #if defined(ESP32_PLATFORM) || defined(USER_GPIO_CONTROL) if (isGpioConfig(config)) { const UserGpio::SetResult result = _user_gpio.handleSet( @@ -3169,215 +2972,6 @@ void CommonCLI::handleSetCmd(uint32_t sender_timestamp, char* command, char* rep return; } #endif -#if defined(PORTABLE_ESP32_RADIO_CLI) - // Portable WiFi-heavy images keep the controls needed to configure and - // diagnose their radio. The full parser remains available in the FULL image. - if (memcmp(config, "int.thresh ", 11) == 0) { - _prefs->interference_threshold = atoi(&config[11]); - savePrefs(); - strcpy(reply, "OK"); - } else if (memcmp(config, "cad ", 4) == 0) { - const char* value = &config[4]; - if (strcmp(value, "on") != 0 && strcmp(value, "off") != 0) { - strcpy(reply, "Error: use set cad on|off"); - } else { - _prefs->cad_enabled = strcmp(value, "on") == 0; - savePrefs(); - strcpy(reply, "OK"); - } - } else if (memcmp(config, "agc.reset.interval ", 19) == 0) { - _prefs->agc_reset_interval = atoi(&config[19]) / 4; - savePrefs(); - sprintf(reply, "OK - interval rounded to %d", - ((uint32_t)_prefs->agc_reset_interval) * 4); - } else if (memcmp(config, "repeat ", 7) == 0) { - const char* value = &config[7]; - if (strcmp(value, "on") != 0 && strcmp(value, "off") != 0) { - strcpy(reply, "Error, must be on or off"); - } else { - _prefs->disable_fwd = strcmp(value, "off") == 0; - savePrefs(); - _callbacks->onRetryConfigChanged(); - strcpy(reply, _prefs->disable_fwd - ? "OK - repeat is now OFF" : "OK - repeat is now ON"); - } - } else if (memcmp(config, "radio.rxgain ", 13) == 0) { - const char* value = &config[13]; - if (strcmp(value, "on") != 0 && strcmp(value, "off") != 0) { - strcpy(reply, "Error: use set radio.rxgain on|off"); - } else { - bool enabled = strcmp(value, "on") == 0; - if (_callbacks->setRxBoostedGain(enabled)) { - _prefs->rx_boosted_gain = enabled; - savePrefs(); - strcpy(reply, "OK"); - } else { - strcpy(reply, "Error: unsupported"); - } - } - } else if (memcmp(config, "radio.fem.rxgain ", 17) == 0) { - const char* value = &config[17]; - if (!_board->canControlLoRaFemLna()) { - strcpy(reply, "Error: unsupported"); - } else if (strcmp(value, "on") != 0 && strcmp(value, "off") != 0) { - strcpy(reply, "Error: state must be on or off"); - } else { - bool enabled = strcmp(value, "on") == 0; - bool changed = _board->isLoRaFemLnaEnabled() != enabled; - if (_board->setLoRaFemLnaEnabled(enabled)) { - if (changed) _callbacks->recalibrateNoiseFloor(); - _prefs->radio_fem_rxgain = enabled ? 1 : 0; - savePrefs(); - strcpy(reply, enabled - ? "OK - LoRa FEM RX gain on" : "OK - LoRa FEM RX gain off"); - } else { - strcpy(reply, "Error: failed to apply LoRa FEM RX gain"); - } - } - } else if (memcmp(config, "radio.fem.txgain ", 17) == 0) { - const char* value = &config[17]; - if (!_board->canControlLoRaFemPaGain()) { - strcpy(reply, "Error: unsupported"); - } else if (strcmp(value, "on") != 0 && strcmp(value, "off") != 0) { - strcpy(reply, "Error: state must be on or off"); - } else { - const bool enabled = strcmp(value, "on") == 0; - if (_board->setLoRaFemPaGainEnabled(enabled)) { - _prefs->radio_fem_txgain = enabled ? 1 : 0; - savePrefs(); - strcpy(reply, enabled - ? "OK - LoRa FEM TX gain on" : "OK - LoRa FEM TX gain off"); - } else { - strcpy(reply, "Error: failed to apply LoRa FEM TX gain"); - } - } - } else if (memcmp(config, "radio ", 6) == 0) { - strcpy(tmp, &config[6]); - const char* parts[4]; - int num = mesh::Utils::parseTextParts(tmp, parts, 4); - float freq = 0.0f; - float bw = 0.0f; - uint8_t sf = 0; - uint8_t cr = 0; - if (num == 4 - && mesh::cli::parseDecimalStrict(parts[0], freq) - && mesh::cli::parseDecimalStrict(parts[1], bw) - && parseUint8Strict(parts[2], 5, 12, sf) - && parseUint8Strict(parts[3], 5, 8, cr) - && freq >= 150.0f && freq <= 2500.0f - && sf >= 5 && sf <= 12 && cr >= 5 && cr <= 8 - && isValidLoRaBandwidth(bw)) { - _prefs->sf = sf; - _prefs->cr = cr; - _prefs->freq = freq; - _prefs->bw = bw; - bool rxps_retuned = recalculateRxPowerSavingFromLevel(_prefs); - _callbacks->savePrefs(); - strcpy(reply, rxps_retuned - ? "OK - reboot to apply (rxps retuned)" : "OK - reboot to apply"); - } else { - strcpy(reply, "Error, invalid radio params"); - } - } else if (memcmp(config, "tx ", 3) == 0) { - _prefs->tx_power_dbm = atoi(&config[3]); - savePrefs(); - _callbacks->setTxPower(_prefs->tx_power_dbm); - strcpy(reply, "OK"); - } else if (sender_timestamp == 0 && memcmp(config, "freq ", 5) == 0) { - float freq = 0.0f; - if (mesh::cli::parseDecimalStrict(&config[5], freq) - && freq >= 150.0f && freq <= 2500.0f) { - _prefs->freq = freq; - savePrefs(); - strcpy(reply, "OK - reboot to apply"); - } else { - strcpy(reply, "Error, invalid frequency"); - } -#ifdef WITH_BRIDGE - } else if (memcmp(config, "rxdelay ", 8) == 0) { - float delay = 0.0f; - if (mesh::cli::parseDecimalStrict(&config[8], delay) - && delay >= 0.0f && delay <= 20.0f) { - _prefs->rx_delay_base = delay; - savePrefs(); - strcpy(reply, "OK"); - } else { - strcpy(reply, "Error, must be 0-20"); - } - } else if (memcmp(config, "txdelay ", 8) == 0) { - float factor = 0.0f; - if (mesh::cli::parseDecimalStrict(&config[8], factor) - && factor >= 0.0f && factor <= 2.0f) { - _prefs->tx_delay_factor = factor; - savePrefs(); - strcpy(reply, "OK"); - } else { - strcpy(reply, "Error, must be 0-2"); - } - } else if (memcmp(config, "bridge.enabled ", 15) == 0) { - const char* value = &config[15]; - if (strcmp(value, "on") != 0 && strcmp(value, "off") != 0) { - strcpy(reply, "Error: use set bridge.enabled on|off"); - } else { - _prefs->bridge_enabled = strcmp(value, "on") == 0; - _callbacks->setBridgeState(_prefs->bridge_enabled); - savePrefs(); - strcpy(reply, "OK"); - } - } else if (memcmp(config, "bridge.delay ", 13) == 0) { - int delay = _atoi(&config[13]); - if (delay >= 0 && delay <= 10000) { - _prefs->bridge_delay = (uint16_t)delay; - savePrefs(); - strcpy(reply, "OK"); - } else { - strcpy(reply, "Error: delay must be between 0-10000 ms"); - } - } else if (memcmp(config, "bridge.source ", 14) == 0) { - const char* value = &config[14]; - if (strcmp(value, "rx") != 0 && strcmp(value, "tx") != 0) { - strcpy(reply, "Error: use set bridge.source rx|tx"); - } else { - _prefs->bridge_pkt_src = strcmp(value, "rx") == 0; -#ifdef WITH_MQTT_BRIDGE - _mqtt_prefs.mqtt_rx_enabled = _prefs->bridge_pkt_src; - _mqtt_prefs.mqtt_tx_enabled = !_prefs->bridge_pkt_src; - savePrefs(PrefsSaveRouting::Scope::Both); -#else - savePrefs(); -#endif - strcpy(reply, "OK"); - } -#endif -#ifdef WITH_ESPNOW_BRIDGE - } else if (memcmp(config, "bridge.channel ", 15) == 0) { - int channel = atoi(&config[15]); - if (channel >= 1 && channel <= 14) { - _prefs->bridge_channel = (uint8_t)channel; - _callbacks->restartBridge(); - savePrefs(); - strcpy(reply, "OK"); - } else { - strcpy(reply, "Error: channel must be between 1-14"); - } - } else if (memcmp(config, "bridge.secret ", 14) == 0) { - const char* secret = &config[14]; - if (secret[0] == 0 || strlen(secret) >= sizeof(_prefs->bridge_secret)) { - sprintf(reply, "Error: secret must be 1-%u characters", - (unsigned)(sizeof(_prefs->bridge_secret) - 1)); - } else { - StrHelper::strncpy(_prefs->bridge_secret, secret, - sizeof(_prefs->bridge_secret)); - _callbacks->restartBridge(); - savePrefs(); - strcpy(reply, "OK"); - } -#endif - } else { - sprintf(reply, "Unsupported in this firmware: %s", config); - } - return; -#else if (isAdvancedRetryConfig(config) && !_callbacks->supportsAdvancedRetryConfig()) { strcpy(reply, "Error, unsupported on this role"); return; @@ -4254,11 +3848,17 @@ void CommonCLI::handleSetCmd(uint32_t sender_timestamp, char* command, char* rep } else { sprintf(reply, "unknown config: %s", config); } -#endif } void CommonCLI::handleGetCmd(uint32_t sender_timestamp, char* command, char* reply) { const char* config = &command[4]; +#if MESH_USB_LOGGING_AVAILABLE + if (strcmp(config, "usb.logging") == 0) { + snprintf(reply, 160, "> %s", + mesh::isUsbLoggingEnabled() ? "on" : "off"); + return; + } +#endif #if defined(ESP32_PLATFORM) || defined(USER_GPIO_CONTROL) if (isGpioConfig(config)) { _user_gpio.handleGet(config + 4, reply, 160); @@ -4319,79 +3919,6 @@ void CommonCLI::handleGetCmd(uint32_t sender_timestamp, char* command, char* rep return; } #endif -#if defined(PORTABLE_ESP32_RADIO_CLI) - if (strcmp(config, "int.thresh") == 0) { - sprintf(reply, "> %d", (uint32_t)_prefs->interference_threshold); - } else if (strcmp(config, "cad") == 0) { - sprintf(reply, "> %s. # channel busy: %u", - _prefs->cad_enabled ? "on" : "off", _board->n_cad_busy); - } else if (strcmp(config, "agc.reset.interval") == 0) { - sprintf(reply, "> %d", ((uint32_t)_prefs->agc_reset_interval) * 4); - } else if (strcmp(config, "repeat") == 0) { - sprintf(reply, "> %s", _prefs->disable_fwd ? "off" : "on"); - } else if (strcmp(config, "radio.rxgain") == 0) { - sprintf(reply, "> %s", _prefs->rx_boosted_gain ? "on" : "off"); - } else if (strcmp(config, "radio.fem.rxgain") == 0) { - if (!_board->canControlLoRaFemLna()) { - strcpy(reply, "Error: unsupported"); - } else { - sprintf(reply, "> %s", - _board->isLoRaFemLnaEnabled() ? "on" : "off"); - } - } else if (strcmp(config, "radio.fem.txgain") == 0) { - if (!_board->canControlLoRaFemPaGain()) { - strcpy(reply, "Error: unsupported"); - } else { - sprintf(reply, "> %s", - _board->isLoRaFemPaGainEnabled() ? "on" : "off"); - } - } else if (strcmp(config, "radio") == 0) { - char freq[16], bw[16]; - strcpy(freq, StrHelper::ftoa(_prefs->freq)); - strcpy(bw, StrHelper::ftoa3(_prefs->bw)); - sprintf(reply, "> %s,%s,%d,%d", - freq, bw, (uint32_t)_prefs->sf, (uint32_t)_prefs->cr); - } else if (strcmp(config, "tx") == 0) { - sprintf(reply, "> %d", (int32_t)_prefs->tx_power_dbm); - } else if (strcmp(config, "freq") == 0) { - sprintf(reply, "> %s", StrHelper::ftoa(_prefs->freq)); - } else if (strcmp(config, "role") == 0) { - sprintf(reply, "> %s", _callbacks->getRole()); -#ifdef WITH_BRIDGE - } else if (strcmp(config, "rxdelay") == 0) { - sprintf(reply, "> %s", StrHelper::ftoa(_prefs->rx_delay_base)); - } else if (strcmp(config, "txdelay") == 0) { - sprintf(reply, "> %s", StrHelper::ftoa(_prefs->tx_delay_factor)); - } else if (strcmp(config, "bridge.enabled") == 0) { - sprintf(reply, "> %s", _prefs->bridge_enabled ? "on" : "off"); - } else if (strcmp(config, "bridge.delay") == 0) { - sprintf(reply, "> %d", (uint32_t)_prefs->bridge_delay); - } else if (strcmp(config, "bridge.source") == 0) { - sprintf(reply, "> %s", _prefs->bridge_pkt_src ? "logRx" : "logTx"); - } else if (strcmp(config, "bridge.type") == 0) { - sprintf(reply, "> %s", -#ifdef WITH_RS232_BRIDGE - "rs232" -#elif WITH_ESPNOW_BRIDGE - "espnow" -#elif WITH_MQTT_BRIDGE - "mqtt" -#else - "none" -#endif - ); -#endif -#ifdef WITH_ESPNOW_BRIDGE - } else if (strcmp(config, "bridge.channel") == 0) { - sprintf(reply, "> %d", (uint32_t)_prefs->bridge_channel); - } else if (strcmp(config, "bridge.secret") == 0) { - sprintf(reply, "> %s", _prefs->bridge_secret); -#endif - } else { - sprintf(reply, "Unsupported in this firmware: %s", config); - } - return; -#else if (isAdvancedRetryConfig(config) && !_callbacks->supportsAdvancedRetryConfig()) { strcpy(reply, "Error, unsupported on this role"); return; @@ -4686,7 +4213,6 @@ void CommonCLI::handleGetCmd(uint32_t sender_timestamp, char* command, char* rep } else { mesh::cli::formatUnknownSetting(reply, 160, config); } -#endif } void CommonCLI::handleDelCmd(char* command, char* reply) { diff --git a/src/helpers/CommonCLI.h b/src/helpers/CommonCLI.h index 46647fd3..e50162d6 100644 --- a/src/helpers/CommonCLI.h +++ b/src/helpers/CommonCLI.h @@ -148,7 +148,7 @@ public: uint8_t reboot_interval = 0; // hours, 0-255 (default 0=disable) // Gps settings uint8_t gps_enabled = 0; - uint32_t gps_interval = 0; // in seconds + uint32_t gps_interval = 0; // seconds; 0 uses the 1-second default uint8_t advert_loc_policy = 0; uint32_t discovery_mod_timestamp = 0; float adc_multiplier = 0; diff --git a/src/helpers/GpsPowerPolicy.h b/src/helpers/GpsPowerPolicy.h new file mode 100644 index 00000000..516078da --- /dev/null +++ b/src/helpers/GpsPowerPolicy.h @@ -0,0 +1,35 @@ +#pragma once + +#include + +namespace mesh { +namespace gps { + +static const uint32_t DEFAULT_UPDATE_INTERVAL_SEC = 1; +static const uint32_t MAX_UPDATE_INTERVAL_SEC = 24UL * 60UL * 60UL; + +inline bool parseUpdateInterval(const char* value, uint32_t& configured_sec) { + if (value == nullptr || *value == 0) return false; + + uint32_t parsed = 0; + for (const char* p = value; *p; ++p) { + if (*p < '0' || *p > '9') return false; + uint32_t digit = (uint32_t)(*p - '0'); + if (parsed > (MAX_UPDATE_INTERVAL_SEC - digit) / 10UL) return false; + parsed = parsed * 10UL + digit; + } + + configured_sec = parsed; + return true; +} + +inline uint32_t effectiveUpdateIntervalSec(uint32_t configured_sec) { + return configured_sec == 0 ? DEFAULT_UPDATE_INTERVAL_SEC : configured_sec; +} + +inline uint32_t updateIntervalMillis(uint32_t configured_sec) { + return effectiveUpdateIntervalSec(configured_sec) * 1000UL; +} + +} // namespace gps +} // namespace mesh diff --git a/src/helpers/SensorManager.cpp b/src/helpers/SensorManager.cpp index 2ab391ac..a4b63db8 100644 --- a/src/helpers/SensorManager.cpp +++ b/src/helpers/SensorManager.cpp @@ -49,14 +49,10 @@ void SensorManager::updateGpsTelemetryCache(float lat, float lon, float altitude } void SensorManager::maybeStopGpsForTelemetry(unsigned long now) { -#if defined(PERSISTANT_GPS) || defined(FORCE_GPS_ALIVE) - (void)now; -#else if (telemetryGpsActive() && !gps_user_enabled && !gps_acquiring && !gpsTelemetryHoldActive(now)) { telemetryGpsStop(); gps_next_cache_update_at = now + GPS_TELEMETRY_CACHE_INTERVAL_SEC * 1000UL; } -#endif } void SensorManager::beginGpsTelemetryAcquisition(unsigned long now) { diff --git a/src/helpers/SensorManager.h b/src/helpers/SensorManager.h index 77406348..877460f6 100644 --- a/src/helpers/SensorManager.h +++ b/src/helpers/SensorManager.h @@ -1,7 +1,9 @@ #pragma once #include +#include "GpsPowerPolicy.h" #include "sensors/LocationProvider.h" +#include #include #define TELEM_PERM_BASE 0x01 // 'base' permission includes battery @@ -32,6 +34,7 @@ class SensorManager { unsigned long gps_hold_until = 0; unsigned long gps_acquire_started_at = 0; unsigned long gps_stable_started_at = 0; + uint32_t gps_update_interval_sec = 0; bool gpsTelemetryHoldActive(unsigned long now) const; bool gpsTelemetryCacheFresh(unsigned long now) const; @@ -50,6 +53,15 @@ protected: void loopGpsTelemetry(unsigned long now); void setGpsTelemetryUserEnabled(bool enabled); bool isGpsTelemetryUserEnabled() const { return gps_user_enabled; } + bool gpsTelemetryReceiverRequired(unsigned long now) const { + return gps_acquiring || gpsTelemetryHoldActive(now); + } + bool setGpsUpdateIntervalValue(const char* value) { + return mesh::gps::parseUpdateInterval(value, gps_update_interval_sec); + } + uint32_t getGpsUpdateIntervalMillis() const { + return mesh::gps::updateIntervalMillis(gps_update_interval_sec); + } #endif public: @@ -66,7 +78,17 @@ public: virtual int getNumSettings() const { return 0; } virtual const char* getSettingName(int i) const { return NULL; } virtual const char* getSettingValue(int i) const { return NULL; } - virtual bool setSettingValue(const char* name, const char* value) { return false; } + virtual bool setSettingValue(const char* name, const char* value) { +#if ENV_INCLUDE_GPS + if (strcmp(name, "gps_interval") == 0) { + return setGpsUpdateIntervalValue(value); + } +#else + (void)name; + (void)value; +#endif + return false; + } virtual LocationProvider* getLocationProvider() { return NULL; } virtual void setPowerSavingEnabled(bool enabled) { powersaving_enabled = enabled; } diff --git a/src/helpers/UsbLogging.cpp b/src/helpers/UsbLogging.cpp new file mode 100644 index 00000000..8fe0233a --- /dev/null +++ b/src/helpers/UsbLogging.cpp @@ -0,0 +1,19 @@ +#include "UsbLogging.h" + +#if defined(ARDUINO) +#include + +namespace mesh { + +static std::atomic usb_logging_enabled{true}; + +bool isUsbLoggingEnabled() { + return usb_logging_enabled.load(std::memory_order_relaxed); +} + +void setUsbLoggingEnabled(bool enabled) { + usb_logging_enabled.store(enabled, std::memory_order_relaxed); +} + +} // namespace mesh +#endif diff --git a/src/helpers/UsbLogging.h b/src/helpers/UsbLogging.h new file mode 100644 index 00000000..e664b2f7 --- /dev/null +++ b/src/helpers/UsbLogging.h @@ -0,0 +1,23 @@ +#pragma once + +// Logging artifacts compile at least one of these two diagnostics. Keep the +// command surface out of ordinary images while providing one runtime gate for +// every diagnostic category that writes to the USB Serial stream. +#if defined(ARDUINO) && \ + ((defined(MESH_DEBUG) && MESH_DEBUG) || \ + (defined(MESH_PACKET_LOGGING) && MESH_PACKET_LOGGING)) + #define MESH_USB_LOGGING_AVAILABLE 1 +#else + #define MESH_USB_LOGGING_AVAILABLE 0 +#endif + +#if defined(ARDUINO) +namespace mesh { + +// This is intentionally session-only. Logging artifacts start enabled after +// every boot so a stale setting cannot make a diagnostic image look silent. +bool isUsbLoggingEnabled(); +void setUsbLoggingEnabled(bool enabled); + +} // namespace mesh +#endif diff --git a/src/helpers/bridges/MQTTBridge.cpp b/src/helpers/bridges/MQTTBridge.cpp index cd1947fb..ea757fe9 100644 --- a/src/helpers/bridges/MQTTBridge.cpp +++ b/src/helpers/bridges/MQTTBridge.cpp @@ -209,6 +209,7 @@ static unsigned long s_wifi_disconnect_time = 0; // #region agent log static void agentLogHeap(const char* location, const char* message, const char* hypothesisId, size_t free_h, size_t max_alloc, unsigned long internal_free, unsigned long spiram_free) { + if (!mesh::isUsbLoggingEnabled()) return; char buf[320]; snprintf(buf, sizeof(buf), "{\"sessionId\":\"debug-session\",\"location\":\"%s\",\"message\":\"%s\",\"hypothesisId\":\"%s\"," diff --git a/src/helpers/bridges/MQTTBridge.h b/src/helpers/bridges/MQTTBridge.h index 40c4760a..1810519e 100644 --- a/src/helpers/bridges/MQTTBridge.h +++ b/src/helpers/bridges/MQTTBridge.h @@ -31,8 +31,8 @@ class MeshSNMPAgent; // Forward declaration // USB CDC-aware debug macros: only print if Serial is ready (non-blocking check) // Serial.availableForWrite() returns bytes available in write buffer (>0 means ready) // This prevents hangs when USB CDC isn't ready yet (e.g., ESP32-S3 native USB) - #define MQTT_DEBUG_PRINT(F, ...) do { if (Serial.availableForWrite() > 0) { Serial.printf("MQTT: " F, ##__VA_ARGS__); } } while(0) - #define MQTT_DEBUG_PRINTLN(F, ...) do { if (Serial.availableForWrite() > 0) { Serial.printf("MQTT: " F "\n", ##__VA_ARGS__); } } while(0) + #define MQTT_DEBUG_PRINT(F, ...) do { if (mesh::isUsbLoggingEnabled() && Serial.availableForWrite() > 0) { Serial.printf("MQTT: " F, ##__VA_ARGS__); } } while(0) + #define MQTT_DEBUG_PRINTLN(F, ...) do { if (mesh::isUsbLoggingEnabled() && Serial.availableForWrite() > 0) { Serial.printf("MQTT: " F "\n", ##__VA_ARGS__); } } while(0) #else #define MQTT_DEBUG_PRINT(...) {} #define MQTT_DEBUG_PRINTLN(...) {} diff --git a/src/helpers/esp32/ESPNOWRadio.h b/src/helpers/esp32/ESPNOWRadio.h index c58f79ae..d8414c7b 100644 --- a/src/helpers/esp32/ESPNOWRadio.h +++ b/src/helpers/esp32/ESPNOWRadio.h @@ -64,8 +64,8 @@ public: #if ESPNOW_DEBUG_LOGGING && ARDUINO #include - #define ESPNOW_DEBUG_PRINT(F, ...) Serial.printf("ESP-Now: " F, ##__VA_ARGS__) - #define ESPNOW_DEBUG_PRINTLN(F, ...) Serial.printf("ESP-Now: " F "\n", ##__VA_ARGS__) + #define ESPNOW_DEBUG_PRINT(F, ...) do { if (mesh::isUsbLoggingEnabled()) { Serial.printf("ESP-Now: " F, ##__VA_ARGS__); } } while(0) + #define ESPNOW_DEBUG_PRINTLN(F, ...) do { if (mesh::isUsbLoggingEnabled()) { Serial.printf("ESP-Now: " F "\n", ##__VA_ARGS__); } } while(0) #else #define ESPNOW_DEBUG_PRINT(...) {} #define ESPNOW_DEBUG_PRINTLN(...) {} diff --git a/src/helpers/esp32/SerialBLEInterface.h b/src/helpers/esp32/SerialBLEInterface.h index d413e500..3120d520 100644 --- a/src/helpers/esp32/SerialBLEInterface.h +++ b/src/helpers/esp32/SerialBLEInterface.h @@ -2,6 +2,7 @@ #include "../BaseSerialInterface.h" #include "../BleTxStallWatchdog.h" +#include "../UsbLogging.h" #include #include #include @@ -114,8 +115,8 @@ public: #if BLE_DEBUG_LOGGING && ARDUINO #include - #define BLE_DEBUG_PRINT(F, ...) Serial.printf("BLE: " F, ##__VA_ARGS__) - #define BLE_DEBUG_PRINTLN(F, ...) Serial.printf("BLE: " F "\n", ##__VA_ARGS__) + #define BLE_DEBUG_PRINT(F, ...) do { if (mesh::isUsbLoggingEnabled()) { Serial.printf("BLE: " F, ##__VA_ARGS__); } } while(0) + #define BLE_DEBUG_PRINTLN(F, ...) do { if (mesh::isUsbLoggingEnabled()) { Serial.printf("BLE: " F "\n", ##__VA_ARGS__); } } while(0) #else #define BLE_DEBUG_PRINT(...) {} #define BLE_DEBUG_PRINTLN(...) {} diff --git a/src/helpers/esp32/SerialWifiInterface.h b/src/helpers/esp32/SerialWifiInterface.h index 5959e2d9..ce5bf48d 100644 --- a/src/helpers/esp32/SerialWifiInterface.h +++ b/src/helpers/esp32/SerialWifiInterface.h @@ -1,6 +1,7 @@ #pragma once #include "../BaseSerialInterface.h" +#include "../UsbLogging.h" #include class SerialWifiInterface : public BaseSerialInterface { @@ -66,8 +67,8 @@ public: #if WIFI_DEBUG_LOGGING && ARDUINO #include - #define WIFI_DEBUG_PRINT(F, ...) Serial.printf("WiFi: " F, ##__VA_ARGS__) - #define WIFI_DEBUG_PRINTLN(F, ...) Serial.printf("WiFi: " F "\n", ##__VA_ARGS__) + #define WIFI_DEBUG_PRINT(F, ...) do { if (mesh::isUsbLoggingEnabled()) { Serial.printf("WiFi: " F, ##__VA_ARGS__); } } while(0) + #define WIFI_DEBUG_PRINTLN(F, ...) do { if (mesh::isUsbLoggingEnabled()) { Serial.printf("WiFi: " F "\n", ##__VA_ARGS__); } } while(0) #else #define WIFI_DEBUG_PRINT(...) {} #define WIFI_DEBUG_PRINTLN(...) {} diff --git a/src/helpers/esp32/TBeamBoard.cpp b/src/helpers/esp32/TBeamBoard.cpp index 0da325b5..55770a6d 100644 --- a/src/helpers/esp32/TBeamBoard.cpp +++ b/src/helpers/esp32/TBeamBoard.cpp @@ -55,8 +55,9 @@ void TBeamBoard::scanDevices(TwoWire *w) uint8_t err, addr; int nDevices = 0; uint32_t start = 0; + const bool usb_logging = mesh::isUsbLoggingEnabled(); - Serial.println("Scanning I2C for Devices"); + if (usb_logging) Serial.println("Scanning I2C for Devices"); for (addr = 1; addr < 127; addr++) { start = millis(); w->beginTransmission(addr); delay(2); @@ -66,45 +67,49 @@ void TBeamBoard::scanDevices(TwoWire *w) switch (addr) { case 0x77: case 0x76: - Serial.println("\tFound BME280 Sensor"); + if (usb_logging) Serial.println("\tFound BME280 Sensor"); deviceOnline |= BME280_ONLINE; break; case 0x34: - Serial.println("\tFound AXP192/AXP2101 PMU"); + if (usb_logging) Serial.println("\tFound AXP192/AXP2101 PMU"); deviceOnline |= POWERMANAGE_ONLINE; break; case 0x3C: - Serial.println("\tFound SSD1306/SH1106 display"); + if (usb_logging) Serial.println("\tFound SSD1306/SH1106 display"); deviceOnline |= DISPLAY_ONLINE; break; case 0x51: - Serial.println("\tFound PCF8563 RTC"); + if (usb_logging) Serial.println("\tFound PCF8563 RTC"); deviceOnline |= PCF8563_ONLINE; break; case 0x1C: - Serial.println("\tFound QMC6310 MAG Sensor"); + if (usb_logging) Serial.println("\tFound QMC6310 MAG Sensor"); deviceOnline |= QMC6310_ONLINE; break; default: - Serial.print("\tI2C device found at address 0x"); - if (addr < 16) { - Serial.print("0"); + if (usb_logging) { + Serial.print("\tI2C device found at address 0x"); + if (addr < 16) { + Serial.print("0"); + } + Serial.print(addr, HEX); + Serial.println(" !"); } - Serial.print(addr, HEX); - Serial.println(" !"); break; } } else if (err == 4) { - Serial.print("Unknow error at address 0x"); - if (addr < 16) { - Serial.print("0"); + if (usb_logging) { + Serial.print("Unknow error at address 0x"); + if (addr < 16) { + Serial.print("0"); + } + Serial.println(addr, HEX); } - Serial.println(addr, HEX); } } - if (nDevices == 0) - Serial.println("No I2C devices found\n"); + if (!usb_logging) return; + if (nDevices == 0) Serial.println("No I2C devices found\n"); Serial.println("Scan for devices is complete."); Serial.println("\n"); @@ -115,6 +120,7 @@ void TBeamBoard::scanDevices(TwoWire *w) } void TBeamBoard::printPMU() { + if (!mesh::isUsbLoggingEnabled()) return; Serial.print("isCharging:"); Serial.println(PMU->isCharging() ? "YES" : "NO"); Serial.print("isDischarge:"); Serial.println(PMU->isDischarge() ? "YES" : "NO"); Serial.print("isVbusIn:"); Serial.println(PMU->isVbusIn() ? "YES" : "NO"); diff --git a/src/helpers/ethernet/SerialEthernetInterface.h b/src/helpers/ethernet/SerialEthernetInterface.h index 1d497a01..90859bb7 100644 --- a/src/helpers/ethernet/SerialEthernetInterface.h +++ b/src/helpers/ethernet/SerialEthernetInterface.h @@ -1,6 +1,7 @@ #pragma once #include "../BaseSerialInterface.h" +#include "../UsbLogging.h" #ifndef ETHERNET_TCP_PORT #define ETHERNET_TCP_PORT 5000 @@ -69,9 +70,9 @@ class SerialEthernetInterface : public BaseSerialInterface { #if ETHERNET_DEBUG_LOGGING && ARDUINO #include - #define ETHERNET_DEBUG_PRINT(F, ...) Serial.printf("ETH: " F, ##__VA_ARGS__) - #define ETHERNET_DEBUG_PRINTLN(F, ...) Serial.printf("ETH: " F "\n", ##__VA_ARGS__) - #define ETHERNET_DEBUG_PRINT_IP(name, ip) Serial.printf("ETH: " name ": %u.%u.%u.%u" "\n", ip[0], ip[1], ip[2], ip[3]) + #define ETHERNET_DEBUG_PRINT(F, ...) do { if (mesh::isUsbLoggingEnabled()) { Serial.printf("ETH: " F, ##__VA_ARGS__); } } while(0) + #define ETHERNET_DEBUG_PRINTLN(F, ...) do { if (mesh::isUsbLoggingEnabled()) { Serial.printf("ETH: " F "\n", ##__VA_ARGS__); } } while(0) + #define ETHERNET_DEBUG_PRINT_IP(name, ip) do { if (mesh::isUsbLoggingEnabled()) { Serial.printf("ETH: " name ": %u.%u.%u.%u" "\n", ip[0], ip[1], ip[2], ip[3]); } } while(0) #else #define ETHERNET_DEBUG_PRINT(...) {} #define ETHERNET_DEBUG_PRINTLN(...) {} diff --git a/src/helpers/nrf52/SerialBLEInterface.h b/src/helpers/nrf52/SerialBLEInterface.h index cc1ce810..96de940e 100644 --- a/src/helpers/nrf52/SerialBLEInterface.h +++ b/src/helpers/nrf52/SerialBLEInterface.h @@ -2,6 +2,7 @@ #include "../BaseSerialInterface.h" #include "../BleTxStallWatchdog.h" +#include "../UsbLogging.h" #include "SecuritySessionTimer.h" #include #include @@ -94,8 +95,8 @@ public: #if BLE_DEBUG_LOGGING && ARDUINO #include - #define BLE_DEBUG_PRINT(F, ...) Serial.printf("BLE: " F, ##__VA_ARGS__) - #define BLE_DEBUG_PRINTLN(F, ...) Serial.printf("BLE: " F "\n", ##__VA_ARGS__) + #define BLE_DEBUG_PRINT(F, ...) do { if (mesh::isUsbLoggingEnabled()) { Serial.printf("BLE: " F, ##__VA_ARGS__); } } while(0) + #define BLE_DEBUG_PRINTLN(F, ...) do { if (mesh::isUsbLoggingEnabled()) { Serial.printf("BLE: " F "\n", ##__VA_ARGS__); } } while(0) #else #define BLE_DEBUG_PRINT(...) {} #define BLE_DEBUG_PRINTLN(...) {} diff --git a/src/helpers/ota/OtaApply.cpp b/src/helpers/ota/OtaApply.cpp index 16ed25fa..9f89d2fd 100644 --- a/src/helpers/ota/OtaApply.cpp +++ b/src/helpers/ota/OtaApply.cpp @@ -416,6 +416,23 @@ void ota_reboot_to_apply() { esp_restart(); } // boots the slot armed by ota_ap #elif defined(NRF52_PLATFORM) // single-slot: verify + mark APPROVED + hand off to the bootloader +struct InplacePatchDims { + uint32_t memory = 0; + uint32_t segment = 0; + uint32_t shift = 0; + uint32_t from = 0; + uint32_t to = 0; +}; + +static bool parse_inplace_patch_dims(const uint8_t* payload, uint32_t payload_len, + InplacePatchDims& d) { + if (!payload || payload_len < 2 || ((payload[0] >> 4) & 0x07u) != 1u) return false; + ByteReader r(payload, payload_len); + r.u8(); // patch type/compression header + return r.detools_size(d.memory) && r.detools_size(d.segment) && r.detools_size(d.shift) && + r.detools_size(d.from) && r.detools_size(d.to) && r.ok; +} + // ESP32 A/B-only entry points are unsupported on nRF52. bool ota_apply_slot_info(uint32_t*, uint32_t*) { return false; } bool ota_apply_set_manifest(const uint8_t*, uint32_t, const SignerAllowlist&, ApplyState& st) { st = ApplyState(); return false; } @@ -424,12 +441,20 @@ bool ota_apply_commit() { return false; } bool ota_apply_detools_mota(const uint8_t*, uint32_t, const SignerAllowlist&, ApplyState& st, char* msg) { st = ApplyState(); strcpy(msg, "use ota_apply_mota_nrf52"); return false; } void ota_reboot_to_apply() { // public: set the apply magic + reset (does not return) + uint8_t stage_handoff = GPREGRET2_OTA_STAGE_LEGACY; +#if defined(OTA_FLASH_STORE) + if (ota_nrf52_effective_stage_ceiling() == MOTA_NRF52_STAGE_CEILING_EXPANDED) + stage_handoff = GPREGRET2_OTA_STAGE_EXPANDED; +#endif uint8_t sd_en = 0; sd_softdevice_is_enabled(&sd_en); if (sd_en) { // POWER is SD-restricted while the SoftDevice runs + sd_power_gpregret_clr(1, 0xFFFFFFFF); + sd_power_gpregret_set(1, stage_handoff); sd_power_gpregret_clr(0, 0xFFFFFFFF); sd_power_gpregret_set(0, GPREGRET_OTA_APPLY); } else { + NRF_POWER->GPREGRET2 = stage_handoff; NRF_POWER->GPREGRET = GPREGRET_OTA_APPLY; } NVIC_SystemReset(); // does not return @@ -510,6 +535,31 @@ static bool ota_apply_mota_nrf52_impl(const uint8_t* buf, uint32_t len, if (!vr.trusted) { strcpy(msg, "untrusted signer (pubkey not in allowlist)"); return false; } } + // 4) detools geometry. memory_size is selected by motatool for this exact staged address; reject a + // mismatched/legacy package before writing APRV so the bootloader never starts a doomed in-place apply. + { + InplacePatchDims d; + if (!parse_inplace_patch_dims(m.payload, m.payload_size, d)) { + strcpy(msg, "bad in-place patch header"); return false; + } + if (d.memory == 0 || d.segment != MOTA_NRF52_FLASH_PAGE || d.shift > d.memory || + d.shift % d.segment != 0 || d.from > d.memory - d.shift || d.to > d.memory || + d.to != m.image_size || (self_valid && d.from != fi.image_len)) { + strcpy(msg, "invalid in-place patch geometry"); return false; + } +#if defined(OTA_FLASH_STORE) + const uint32_t app_base = mota_nrf52_app_base(); + const uint32_t mota_start = (uint32_t)(uintptr_t)buf; + const uint32_t stage_ceiling = ota_nrf52_effective_stage_ceiling(); + if (mota_start < app_base || mota_start >= stage_ceiling || + d.memory > mota_start - app_base) { + snprintf(msg, 159, "patch memory 0x%x exceeds staging at 0x%x", + (unsigned)d.memory, (unsigned)mota_start); + return false; + } +#endif + } + // mark the staged manifest APPROVED in flash (buf is the memory-mapped staging region, so // m.approval is a real flash address). NOR-clear over the erased 0xFFFFFFFF -> "APRV". uint32_t approval_addr = (uint32_t)(uintptr_t)m.approval; diff --git a/src/helpers/ota/OtaBlInfo.h b/src/helpers/ota/OtaBlInfo.h index d051d4b9..86ecf1b2 100644 --- a/src/helpers/ota/OtaBlInfo.h +++ b/src/helpers/ota/OtaBlInfo.h @@ -25,10 +25,11 @@ struct OtaBlCaps { bool present = false; uint16_t apply_abi = 0; // max .mota format_ver the bootloader can apply uint16_t codec_mask = 0; // bit i set => can apply codec_id i (in-place delta = bit 2) - uint8_t storage_flags = 0; // bit 0 => raw-SD handoff/apply is supported + uint8_t storage_flags = 0; // OTA_BL_STORAGE_* capability bits }; -static const uint8_t OTA_BL_STORAGE_SD = 0x01; +static const uint8_t OTA_BL_STORAGE_SD = 0x01; +static const uint8_t OTA_BL_STORAGE_STAGE_CEILING = 0x02; // Scan the bootloader flash region for the marker. Returns {present=false} if not found / non-nRF52. inline OtaBlCaps ota_bootloader_caps() { @@ -54,5 +55,23 @@ inline bool ota_bootloader_can_apply(uint8_t format_ver, uint8_t codec_id) { return c.present && c.apply_abi >= format_ver && (c.codec_mask & (1u << codec_id)) != 0; } +#if defined(NRF52_PLATFORM) +// Use the layout's larger ceiling only when the installed bootloader advertises the matching +// GPREGRET2 handoff. New applications remain filesystem-safe with older OTAFIX bootloaders by retaining +// the legacy 0xD4000 ceiling. Packages sized for the expanded window still require the newer bootloader. +inline uint32_t ota_nrf52_effective_stage_ceiling(const OtaBlCaps& c) { + const uint32_t desired = mota_nrf52_layout_stage_ceiling(); + if (desired == MOTA_NRF52_STAGE_CEILING_EXPANDED) { + if (!c.present || !(c.storage_flags & OTA_BL_STORAGE_STAGE_CEILING)) + return MOTA_NRF52_STAGE_CEILING_LEGACY; + } + return desired; +} + +inline uint32_t ota_nrf52_effective_stage_ceiling() { + return ota_nrf52_effective_stage_ceiling(ota_bootloader_caps()); +} +#endif + } // namespace ota } // namespace mesh diff --git a/src/helpers/ota/OtaByteIO.h b/src/helpers/ota/OtaByteIO.h index 2665c856..694f7c54 100644 --- a/src/helpers/ota/OtaByteIO.h +++ b/src/helpers/ota/OtaByteIO.h @@ -52,6 +52,26 @@ struct ByteReader { const uint8_t* r = p + n; n += k; return r; } void skip(uint32_t k) { if (!fits(k)) { ok = false; return; } n += k; } + + // Positive detools header size (signed varint encoding, but flash geometry never accepts negatives). + bool detools_size(uint32_t& out) { + if (!fits(1)) { ok = false; return false; } + uint8_t byte = p[n++]; + if (byte & 0x40u) { ok = false; return false; } + uint32_t value = byte & 0x3Fu; + uint32_t shift = 6; + while (byte & 0x80u) { + if (!fits(1)) { ok = false; return false; } + byte = p[n++]; + const uint32_t bits = byte & 0x7Fu; + if (shift >= 32 || bits > (UINT32_MAX >> shift)) { ok = false; return false; } + value |= bits << shift; + shift += 7; + } + if (value > 0x7FFFFFFFu) { ok = false; return false; } + out = value; + return true; + } }; } // namespace ota diff --git a/src/helpers/ota/OtaCli.cpp b/src/helpers/ota/OtaCli.cpp index 359115bb..cbe48c69 100644 --- a/src/helpers/ota/OtaCli.cpp +++ b/src/helpers/ota/OtaCli.cpp @@ -523,7 +523,12 @@ bool handle_ota_command(const char* command, char* reply, mesh::MainBoard& board else snprintf(reply + n, 160 - n, " | bootloader: NO SD mota-apply support (install will refuse)"); #else - if (bl.present) snprintf(reply + n, 160 - n, " | bootloader: apply OK (abi=%u codecs=0x%x)", bl.apply_abi, bl.codec_mask); + if (bl.present) { + const uint32_t stage_ceiling = ota_nrf52_effective_stage_ceiling(bl); + snprintf(reply + n, 160 - n, + " | bootloader: apply OK (abi=%u codecs=0x%x stage=%05X)", + bl.apply_abi, bl.codec_mask, (unsigned)stage_ceiling); + } else snprintf(reply + n, 160 - n, " | bootloader: NO mota-apply support (delta install will refuse)"); #endif #endif diff --git a/src/helpers/ota/OtaDebug.h b/src/helpers/ota/OtaDebug.h index a126cdec..ab6665d9 100644 --- a/src/helpers/ota/OtaDebug.h +++ b/src/helpers/ota/OtaDebug.h @@ -5,7 +5,8 @@ // touches a non-debug or test build. #if defined(OTA_DEBUG) && defined(ARDUINO) #include - #define OTA_DBG(...) do { Serial.printf(__VA_ARGS__); } while (0) + #include + #define OTA_DBG(...) do { if (mesh::isUsbLoggingEnabled()) { Serial.printf(__VA_ARGS__); } } while (0) #else #define OTA_DBG(...) do {} while (0) #endif diff --git a/src/helpers/ota/OtaFlashLayout_nrf52.h b/src/helpers/ota/OtaFlashLayout_nrf52.h index df07ff6a..fd8e708d 100644 --- a/src/helpers/ota/OtaFlashLayout_nrf52.h +++ b/src/helpers/ota/OtaFlashLayout_nrf52.h @@ -2,11 +2,11 @@ // Shared OTA flash-layout constants for the nRF52840 single-slot delta-apply path. // -// The running app occupies [APP_BASE, app_end]; the primary LittleFS (InternalFS) starts at FS_START. -// MeshCore stages a verified+approved `.mota` in the free flash below FS_START (bottom-aligned), then -// sets GPREGRET_OTA_APPLY and resets; the bootloader scans [APP_BASE, FS_START) for it and applies it -// in place. APP_BASE is obtained from the linker so S140 v6 (0x26000) and v7 (0x27000) both work. The -// bootloader independently uses DFU_BANK_0_REGION_START, which resolves to the same address. +// The running app occupies [APP_BASE, app_end]. Internal ExtraFS, when actually used, starts at +// 0xD4000; primary InternalFS starts at 0xED000. MeshCore derives the safe staging ceiling from the +// linked application region and secondary-storage type, bottom-aligns the `.mota` below it, and passes +// the choice to a capable bootloader in GPREGRET2. APP_BASE is obtained from the linker so S140 v6 +// (0x26000) and v7 (0x27000) both work. The bootloader independently uses DFU_BANK_0_REGION_START. #include @@ -17,6 +17,8 @@ static const uint32_t MOTA_NRF52_APP_BASE_S140_V6 = 0x00026000u; static const uint32_t MOTA_NRF52_APP_BASE_S140_V7 = 0x00027000u; #if defined(NRF52_PLATFORM) extern "C" uint32_t __flash_arduino_start[]; // nrf52_common.ld: ORIGIN(FLASH) +extern "C" uint32_t __flash_arduino_end[]; // nrf52_common.ld: ORIGIN(FLASH) + LENGTH(FLASH) +extern "C" const uint8_t g_meshcore_internal_extrafs __attribute__((weak)); inline uint32_t mota_nrf52_app_base() { return (uint32_t)(uintptr_t)__flash_arduino_start; } @@ -24,22 +26,22 @@ inline uint32_t mota_nrf52_app_base() { // Native geometry tests have no linker script; default their runtime helper to the v6 layout. inline uint32_t mota_nrf52_app_base() { return MOTA_NRF52_APP_BASE_S140_V6; } #endif -// Staging ceiling: the lowest filesystem region above the app. nRF52840 ExtraFS linker scripts place -// ExtraFS at 0xD4000..0xED000 (and InternalFS at 0xED000), while default scripts leave that range free. -// Staging below 0xD4000 therefore never touches either filesystem. -static const uint32_t MOTA_NRF52_FS_START = 0x000D4000u; // ExtraFS start (universal staging ceiling) -// End of the normal nRF52840 application region. The SD-backed MeshTower V2 -// target can use this whole range because its staged container is off-chip. -// InternalFS begins here and must never be erased by the bootloader. -static const uint32_t MOTA_NRF52_APP_END = 0x000ED000u; +static const uint32_t MOTA_NRF52_EXTRAFS_START = 0x000D4000u; +static const uint32_t MOTA_NRF52_APP_END = 0x000ED000u; +static const uint32_t MOTA_NRF52_STAGE_CEILING_LEGACY = MOTA_NRF52_EXTRAFS_START; +static const uint32_t MOTA_NRF52_STAGE_CEILING_EXPANDED = MOTA_NRF52_APP_END; +// Compatibility name for code that refers to the fixed lower boundary. New staging code must use an +// explicit ceiling so it cannot accidentally cross an Internal ExtraFS. +static const uint32_t MOTA_NRF52_FS_START = MOTA_NRF52_EXTRAFS_START; static const uint32_t MOTA_NRF52_FLASH_PAGE = 4096u; static const uint8_t GPREGRET_OTA_APPLY = 0x6Au; // distinct from DFU magics 0x57/0x4E/0xA8 +static const uint8_t GPREGRET2_OTA_STAGE_LEGACY = 0xD4u; +static const uint8_t GPREGRET2_OTA_STAGE_EXPANDED = 0xEDu; -// In-place patches are built with --inplace-memory = this (the apply workspace, from APP_BASE up). -// It must hold the new image (~520 KB) yet leave the staged mota room below FS_START: workspace ends -// at APP_BASE+this = 0xBE000 (S140 v6) or 0xBF000 (v7), leaving 88/84 KB for the staged delta. The -// bootloader also bounds writes to < the (scanned) mota start, so a mis-sized memory still fails safe. -static const uint32_t MOTA_NRF52_INPLACE_MEMORY = 0x00098000u; // 608 KB +// Firmware without a valid EndF and older host tooling fall back to this conservative apply workspace. +// New motatool builds read the firmware's appended layout record and derive memory_size from the actual +// staged-container start; the app and bootloader both validate that per-patch value before writing. +static const uint32_t MOTA_NRF52_FALLBACK_INPLACE_MEMORY = 0x00098000u; // 608 KB // Bootloader flash region (nRF52840: 39 KB ending just below the CF2/MBR-params pages). The app scans // this for the bootloader capability marker (OtaBlInfo.h) to know whether THIS device's bootloader can @@ -51,44 +53,93 @@ static const uint32_t MOTA_NRF52_BL_END = 0x000FE000u; // BUILD rather than silently letting a stage/apply corrupt the filesystem (user prefs) or the app. static_assert((MOTA_NRF52_APP_BASE_S140_V6 % MOTA_NRF52_FLASH_PAGE) == 0, "S140 v6 base must be page-aligned"); static_assert((MOTA_NRF52_APP_BASE_S140_V7 % MOTA_NRF52_FLASH_PAGE) == 0, "S140 v7 base must be page-aligned"); -static_assert((MOTA_NRF52_FS_START % MOTA_NRF52_FLASH_PAGE) == 0, "FS_START must be page-aligned"); -static_assert(MOTA_NRF52_FS_START < MOTA_NRF52_BL_START, "staging (+FS) must end below the bootloader"); +static_assert((MOTA_NRF52_STAGE_CEILING_LEGACY % MOTA_NRF52_FLASH_PAGE) == 0, + "legacy staging ceiling must be page-aligned"); +static_assert((MOTA_NRF52_STAGE_CEILING_EXPANDED % MOTA_NRF52_FLASH_PAGE) == 0, + "expanded staging ceiling must be page-aligned"); +static_assert(MOTA_NRF52_STAGE_CEILING_LEGACY < MOTA_NRF52_STAGE_CEILING_EXPANDED, + "legacy staging ceiling must precede expanded ceiling"); +static_assert(MOTA_NRF52_STAGE_CEILING_EXPANDED < MOTA_NRF52_BL_START, + "staging and filesystems must end below the bootloader"); static_assert(MOTA_NRF52_BL_START < MOTA_NRF52_BL_END, "bootloader region must be non-empty"); -// The in-place apply workspace [APP_BASE, APP_BASE+INPLACE_MEMORY) must end at/below the staging ceiling, -// so an in-place apply never writes into ExtraFS/InternalFS (where user prefs live). -static_assert(MOTA_NRF52_APP_BASE_S140_V7 + MOTA_NRF52_INPLACE_MEMORY <= MOTA_NRF52_FS_START, - "in-place apply workspace must end at or below the staging ceiling"); +static_assert(MOTA_NRF52_APP_BASE_S140_V7 + MOTA_NRF52_FALLBACK_INPLACE_MEMORY <= + MOTA_NRF52_STAGE_CEILING_LEGACY, + "fallback workspace must end at or below the legacy staging ceiling"); -inline bool mota_nrf52_layout_valid(uint32_t app_base) { - return (app_base % MOTA_NRF52_FLASH_PAGE) == 0 && app_base < MOTA_NRF52_FS_START && - (uint64_t)app_base + MOTA_NRF52_INPLACE_MEMORY <= MOTA_NRF52_FS_START; +// Pure policy helper. Actual internal ExtraFS use always wins and keeps the legacy ceiling. Otherwise +// either supported linker region may safely reclaim through InternalFS at 0xED000. This handles +// repeaters, XIAO/QSPI companions, and future boards without maintaining a board-name allowlist. Unknown +// linker geometry falls back to the legacy ceiling. +inline uint32_t mota_nrf52_stage_ceiling_for_layout(uint32_t linked_app_end, + bool uses_internal_extrafs) { + if (uses_internal_extrafs) return MOTA_NRF52_STAGE_CEILING_LEGACY; + if (linked_app_end == MOTA_NRF52_APP_END || linked_app_end == MOTA_NRF52_EXTRAFS_START) + return MOTA_NRF52_STAGE_CEILING_EXPANDED; + return MOTA_NRF52_STAGE_CEILING_LEGACY; } -inline uint32_t mota_nrf52_stage_capacity(uint32_t app_base) { - return mota_nrf52_layout_valid(app_base) - ? MOTA_NRF52_FS_START - (app_base + MOTA_NRF52_INPLACE_MEMORY) : 0; +inline uint32_t mota_nrf52_layout_stage_ceiling() { +#if defined(NRF52_PLATFORM) + // DataStore.cpp defines this symbol only for a companion that really constructs internal ExtraFS. + // An undefined weak symbol has address zero, so other roles do not inherit nrf52_base's broad EXTRAFS + // feature define as a false-positive storage reservation. + const bool uses_internal_extrafs = (uintptr_t)&g_meshcore_internal_extrafs != 0u; + return mota_nrf52_stage_ceiling_for_layout((uint32_t)(uintptr_t)__flash_arduino_end, + uses_internal_extrafs); +#else + // Native callers use the explicit-ceiling helpers below; keep the no-argument fallback conservative. + return MOTA_NRF52_STAGE_CEILING_LEGACY; +#endif +} + +inline bool mota_nrf52_stage_ceiling_valid(uint32_t stage_ceiling) { + return stage_ceiling == MOTA_NRF52_STAGE_CEILING_LEGACY || + stage_ceiling == MOTA_NRF52_STAGE_CEILING_EXPANDED; +} + +inline bool mota_nrf52_layout_valid(uint32_t app_base, uint32_t stage_ceiling) { + return mota_nrf52_stage_ceiling_valid(stage_ceiling) && + (app_base % MOTA_NRF52_FLASH_PAGE) == 0 && app_base < stage_ceiling; +} + +inline bool mota_nrf52_layout_valid(uint32_t app_base) { + return mota_nrf52_layout_valid(app_base, mota_nrf52_layout_stage_ceiling()); +} + +inline uint32_t mota_nrf52_stage_capacity(uint32_t app_base, uint32_t app_end, + uint32_t stage_ceiling) { + if (!mota_nrf52_layout_valid(app_base, stage_ceiling) || app_end < app_base || + app_end > stage_ceiling || app_end > UINT32_MAX - (MOTA_NRF52_FLASH_PAGE - 1u)) return 0; + const uint32_t first_free_page = + (app_end + MOTA_NRF52_FLASH_PAGE - 1u) & ~(MOTA_NRF52_FLASH_PAGE - 1u); + return first_free_page <= stage_ceiling ? stage_ceiling - first_free_page : 0; } // Plan where to stage a received `.mota` of `total_size` bytes. It is placed bottom-aligned within the -// highest flash page below FS_START (the bootloader scans downward from there), and it must sit -// ENTIRELY above both the running image and the in-place decoder workspace, and below the filesystem -// region (ExtraFS/InternalFS - where user prefs live, assumed immutable). Reserving the full workspace -// here prevents accepting a large container that the bootloader would later reject as overlapping it. +// highest flash page below stage_ceiling (the bootloader scans downward from there), and it must sit +// ENTIRELY above the running image and below the filesystem region (ExtraFS/InternalFS - where user prefs +// live, assumed immutable). The apply path separately verifies the patch's own detools memory_size ends +// at or below this planned start before it marks the package approved. // Returns false (and leaves out_start untouched) if it does not fit. This is the SINGLE place the FS // ceiling + app-collision bounds are enforced; begin()/reopen() both go through it. Pure - no flash I/O - // so it is unit-tested natively in test/test_ota/test_ota_flashplan.cpp. inline bool mota_nrf52_stage_plan(uint32_t total_size, uint32_t app_base, uint32_t app_end, - uint32_t& out_start) { - if (!mota_nrf52_layout_valid(app_base) || app_end < app_base || - app_end > app_base + MOTA_NRF52_INPLACE_MEMORY) return false; - const uint32_t workspace_end = app_base + MOTA_NRF52_INPLACE_MEMORY; - const uint32_t capacity = MOTA_NRF52_FS_START - workspace_end; - if (total_size < 13 || total_size > capacity) return false; // 13 = header(8)+trailer(5); must fit below FS - uint32_t start = (MOTA_NRF52_FS_START - total_size) & ~(MOTA_NRF52_FLASH_PAGE - 1); // bottom-align down - if (start < app_end || start < workspace_end) return false; // would overlap app / decoder workspace + uint32_t stage_ceiling, uint32_t& out_start) { + if (!mota_nrf52_layout_valid(app_base, stage_ceiling) || app_end < app_base || + app_end > stage_ceiling) return false; + const uint32_t capacity = stage_ceiling - app_base; + if (total_size < 13 || total_size > capacity) return false; // 13 = header(8)+trailer(5) + uint32_t start = (stage_ceiling - total_size) & ~(MOTA_NRF52_FLASH_PAGE - 1); // bottom-align down + if (start < app_end) return false; // would overlap the running app out_start = start; return true; } +inline bool mota_nrf52_stage_plan(uint32_t total_size, uint32_t app_base, uint32_t app_end, + uint32_t& out_start) { + return mota_nrf52_stage_plan(total_size, app_base, app_end, + mota_nrf52_layout_stage_ceiling(), out_start); +} + } // namespace ota } // namespace mesh diff --git a/src/helpers/ota/OtaSelf.cpp b/src/helpers/ota/OtaSelf.cpp index 05060000..3d1fc7ff 100644 --- a/src/helpers/ota/OtaSelf.cpp +++ b/src/helpers/ota/OtaSelf.cpp @@ -68,14 +68,15 @@ bool ota_self_firmware(SelfFwInfo& out) { // firmware's own trailer), ignoring any staged `.mota` (which carries its own embedded EndF) higher up. bool ota_self_firmware(SelfFwInfo& out) { const uint32_t app_base = mota_nrf52_app_base(); - if (!mota_nrf52_layout_valid(app_base)) { out = SelfFwInfo(); return false; } - const uint8_t* region = (const uint8_t*)(uintptr_t)app_base; - uint32_t region_len = + const uint32_t stage_ceiling = #if defined(OTA_SD_STORE) - MOTA_NRF52_APP_END - app_base; + MOTA_NRF52_APP_END; #else - MOTA_NRF52_FS_START - app_base; + mota_nrf52_layout_stage_ceiling(); #endif + if (!mota_nrf52_layout_valid(app_base, stage_ceiling)) { out = SelfFwInfo(); return false; } + const uint8_t* region = (const uint8_t*)(uintptr_t)app_base; + const uint32_t region_len = stage_ceiling - app_base; return find_self_firmware(region, region_len, out, /*verify_body=*/true); } #else @@ -97,7 +98,9 @@ bool ota_self_read(uint32_t off, uint8_t* buf, uint32_t len) { #if defined(OTA_SD_STORE) if (app_base >= MOTA_NRF52_APP_END || (uint64_t)app_base + off + len > MOTA_NRF52_APP_END) return false; #else - if (!mota_nrf52_layout_valid(app_base) || (uint64_t)app_base + off + len > MOTA_NRF52_FS_START) return false; + const uint32_t stage_ceiling = mota_nrf52_layout_stage_ceiling(); + if (!mota_nrf52_layout_valid(app_base, stage_ceiling) || + (uint64_t)app_base + off + len > stage_ceiling) return false; #endif memcpy(buf, (const uint8_t*)(uintptr_t)(app_base + off), len); return true; diff --git a/src/helpers/ota/OtaStoreFlashNrf52.cpp b/src/helpers/ota/OtaStoreFlashNrf52.cpp index e3294cbc..6692a0cd 100644 --- a/src/helpers/ota/OtaStoreFlashNrf52.cpp +++ b/src/helpers/ota/OtaStoreFlashNrf52.cpp @@ -3,6 +3,7 @@ #if defined(NRF52_PLATFORM) && defined(OTA_FLASH_STORE) #include "OtaSelf.h" +#include "OtaBlInfo.h" #include "OtaDebug.h" #include "OtaByteIO.h" // align_down / rd_u32le (flash-page geometry + header read) #include @@ -11,10 +12,19 @@ namespace mesh { namespace ota { +// A valid EndF gives the exact live-image extent. Rescue/bring-up builds without one retain the old +// 608 KiB protection floor so staging cannot begin in the middle of a normally sized application. +static bool protected_app_end(uint32_t app_base, uint32_t stage_ceiling, uint32_t& app_end) { + if (!mota_nrf52_layout_valid(app_base, stage_ceiling)) return false; + app_end = app_base + MOTA_NRF52_FALLBACK_INPLACE_MEMORY; + SelfFwInfo fi; + if (ota_self_firmware(fi) && fi.valid) app_end = app_base + fi.image_len; + return app_end >= app_base && app_end <= stage_ceiling; +} + // Write one whole 4 KB page from `buf` to flash (erase + program, ~85 ms). `buf` is PG bytes, 0xFF-padded -// past the container, so the program is clean. The last container page ends exactly at FS_START (both -// FS_START and _write_start are page-aligned and the container ends <= FS_START), so a full-page write -// never reaches into ExtraFS. +// past the container, so the program is clean. The selected ceiling and _write_start are page-aligned, +// and the container ends below the ceiling, so a full-page write never reaches protected storage. static bool flash_matches(uint32_t addr, const uint8_t* expected, uint32_t n) { const volatile uint8_t* actual = (const volatile uint8_t*)(uintptr_t)addr; for (uint32_t i = 0; i < n; i++) if (actual[i] != expected[i]) return false; @@ -22,8 +32,8 @@ static bool flash_matches(uint32_t addr, const uint8_t* expected, uint32_t n) { } bool OtaStoreFlashNrf52::flush_page(uint32_t page_idx, const uint8_t* buf) { - if (!_io_ok || _write_start > MOTA_NRF52_FS_START - PG || - page_idx > (MOTA_NRF52_FS_START - PG - _write_start) / PG) { + if (!_io_ok || _write_start > _stage_ceiling - PG || + page_idx > (_stage_ceiling - PG - _write_start) / PG) { _io_ok = false; return false; } @@ -35,6 +45,14 @@ bool OtaStoreFlashNrf52::flush_page(uint32_t page_idx, const uint8_t* buf) { return true; } +uint32_t OtaStoreFlashNrf52::capacity() const { + const uint32_t app_base = mota_nrf52_app_base(); + const uint32_t stage_ceiling = ota_nrf52_effective_stage_ceiling(); + uint32_t app_end; + return protected_app_end(app_base, stage_ceiling, app_end) + ? mota_nrf52_stage_capacity(app_base, app_end, stage_ceiling) : 0; +} + bool OtaStoreFlashNrf52::flush_pay() { return _pay_idx == 0 || flush_page(_pay_idx, _pay_page); // _pay_idx 0 == no payload page open } @@ -74,28 +92,25 @@ bool OtaStoreFlashNrf52::begin(uint32_t total_size) { // never collide with the running application image (its extent comes from its EndF trailer) const uint32_t app_base = mota_nrf52_app_base(); - if (!mota_nrf52_layout_valid(app_base)) return false; - uint32_t app_end = app_base; - SelfFwInfo fi; - if (ota_self_firmware(fi) && fi.valid) { - if ((uint64_t)app_base + fi.image_len > MOTA_NRF52_FS_START) return false; - app_end = app_base + fi.image_len; - } + const uint32_t stage_ceiling = ota_nrf52_effective_stage_ceiling(); + uint32_t app_end; + if (!protected_app_end(app_base, stage_ceiling, app_end)) return false; - // Bottom-align below FS_START and reject unless it sits above the running image AND the full detools - // workspace (the FS/prefs-safe bounds check; pure + unit-tested in the native OTA suite). + // Bottom-align below the selected ceiling and reject unless it sits above the running image. The + // approval path later verifies the patch's detools workspace ends at/below this exact start. uint32_t start; - if (!mota_nrf52_stage_plan(total_size, app_base, app_end, start)) return false; + if (!mota_nrf52_stage_plan(total_size, app_base, app_end, stage_ceiling, start)) return false; _write_start = start; + _stage_ceiling = stage_ceiling; _total = total_size; memset(_meta_page, 0xFF, PG); // assemble page 0 in RAM; 0xFF = erased sentinel (unfilled leaf slots) memset(_trailer, 0xFF, sizeof(_trailer)); _pay_idx = 0; _flushed = false; _io_ok = true; - OTA_DBG("OTA flash: begin total=%u start=%08x app_end=%08x\n", - (unsigned)total_size, (unsigned)start, (unsigned)app_end); + OTA_DBG("OTA flash: begin total=%u start=%08x app_end=%08x ceiling=%08x\n", + (unsigned)total_size, (unsigned)start, (unsigned)app_end, (unsigned)stage_ceiling); return true; // no pre-erase: each page is erased by its own (single) flush } @@ -156,33 +171,31 @@ void OtaStoreFlashNrf52::checkpoint() { } // Re-attach to a container already staged in flash (after a reboot), without erasing. The container is -// bottom-aligned (begin: start = (FS_START - total) & ~(PG-1)) and flash is memory-mapped, so scan page -// starts from just below FS_START down to the app end for MOTA_MAGIC with a self-consistent total; adopt +// bottom-aligned (begin: start = (ceiling - total) & ~(PG-1)) and flash is memory-mapped, so scan page +// starts from just below that ceiling down to the app end for MOTA_MAGIC with a self-consistent total; adopt // the first match (highest address = most recent for the common single-container case). The manager then // parses the loaded manifest and validates geometry/root, so a stale leftover is rejected there. bool OtaStoreFlashNrf52::reopen() { const uint32_t app_base = mota_nrf52_app_base(); - if (!mota_nrf52_layout_valid(app_base)) return false; - uint32_t app_end = app_base; - SelfFwInfo fi; - if (ota_self_firmware(fi) && fi.valid) { - if ((uint64_t)app_base + fi.image_len > MOTA_NRF52_FS_START) return false; - app_end = app_base + fi.image_len; - } - for (uint32_t start = align_down(MOTA_NRF52_FS_START - PG, PG); start >= app_end; start -= PG) { + const uint32_t stage_ceiling = ota_nrf52_effective_stage_ceiling(); + uint32_t app_end; + if (!protected_app_end(app_base, stage_ceiling, app_end)) return false; + for (uint32_t start = align_down(stage_ceiling - PG, PG); start >= app_end; start -= PG) { const uint8_t* p = (const uint8_t*)(uintptr_t)start; if (memcmp(p, MOTA_MAGIC, 4) != 0) continue; uint32_t total = rd_u32le(p + 4); uint32_t want; // must be valid + placed exactly where begin() would have staged it (same bounds fn) - if (!mota_nrf52_stage_plan(total, app_base, app_end, want) || want != start) continue; + if (!mota_nrf52_stage_plan(total, app_base, app_end, stage_ceiling, want) || want != start) continue; _write_start = start; + _stage_ceiling = stage_ceiling; _total = total; memcpy(_meta_page, p, PG); // load page 0 (header+manifest+leaves) into RAM to continue memcpy(_trailer, p + (total - 5), 5); // recover the trailer tail (flushed at last finalize, if any) _pay_idx = 0; _flushed = false; _io_ok = true; - OTA_DBG("OTA flash: reopen total=%u start=%08x\n", (unsigned)total, (unsigned)start); + OTA_DBG("OTA flash: reopen total=%u start=%08x ceiling=%08x\n", + (unsigned)total, (unsigned)start, (unsigned)stage_ceiling); return true; } return false; diff --git a/src/helpers/ota/OtaStoreFlashNrf52.h b/src/helpers/ota/OtaStoreFlashNrf52.h index fd235379..5ef3479c 100644 --- a/src/helpers/ota/OtaStoreFlashNrf52.h +++ b/src/helpers/ota/OtaStoreFlashNrf52.h @@ -5,10 +5,10 @@ #include "OtaStore.h" #include "OtaFlashLayout_nrf52.h" -// Persistent flash-backed OtaStore for nRF52840. Stages the received `.mota` in the free flash -// below the primary LittleFS (FS_START), bottom-aligned so its trailer ends at FS_START and the -// bootloader can scan for it. Survives reboot - the whole point - so the bootloader can apply the -// staged delta on the next boot. +// Persistent flash-backed OtaStore for nRF52840. Stages the received `.mota` below the selected +// filesystem-safe ceiling, bottom-aligned so the bootloader can find it. Internal ExtraFS roles stop +// at 0xD4000; QSPI/no-ExtraFS roles can use the otherwise-free range through 0xED000. Survives reboot - +// the whole point - so the bootloader can apply the staged delta on the next boot. // // RAM is bounded to O(one flash page), NEVER O(mota): a 100 KB+ delta must not live in RAM. // - On nRF52 the flash *erase* unit is one 4 KB page and the only SoftDevice-safe writer @@ -36,6 +36,7 @@ class OtaStoreFlashNrf52 : public OtaStore { static const uint32_t PG = MOTA_NRF52_FLASH_PAGE; // 4096 uint32_t _write_start = 0; // flash address of container offset 0 (page-aligned) + uint32_t _stage_ceiling = MOTA_NRF52_STAGE_CEILING_LEGACY; uint32_t _total = 0; // staged container size (0 = none) bool _flushed = false; // finalize() committed everything to flash bool _io_ok = true; // cleared on a failed/out-of-bounds flash write or readback mismatch @@ -58,9 +59,12 @@ public: bool begin(uint32_t total_size) override; bool write(uint32_t offset, const uint8_t* data, uint32_t len) override; bool read(uint32_t offset, uint8_t* buf, uint32_t len) const override; - uint32_t capacity() const override { return mota_nrf52_stage_capacity(mota_nrf52_app_base()); } + uint32_t capacity() const override; uint32_t staged_size() const override { return _total; } - void clear() override { _total = 0; _pay_idx = 0; _flushed = false; _io_ok = true; } + void clear() override { + _stage_ceiling = MOTA_NRF52_STAGE_CEILING_LEGACY; + _total = 0; _pay_idx = 0; _flushed = false; _io_ok = true; + } bool set_meta_size(uint32_t meta_bytes) override { return meta_bytes <= PG; } // leaves must fit page 0 bool finalize() override; void checkpoint() override; // persist page 0 (leaves) + the open payload page so a reboot can resume diff --git a/src/helpers/sensors/EnvironmentSensorManager.cpp b/src/helpers/sensors/EnvironmentSensorManager.cpp index 8abd6624..4f51a8de 100644 --- a/src/helpers/sensors/EnvironmentSensorManager.cpp +++ b/src/helpers/sensors/EnvironmentSensorManager.cpp @@ -780,6 +780,20 @@ bool EnvironmentSensorManager::setSettingValue(const char* name, const char* val #if ENV_INCLUDE_GPS if (gps_detected && strcmp(name, "gps") == 0) { bool enabled = strcmp(value, "0") != 0; +#if defined(RAK_WISBLOCK_GPS) && defined(FORCE_GPS_ALIVE) + // RAK3401 must keep 3V3_S on for its radio FEM. RAK12501/L76K has no + // separately wired standby control, so claiming it is off would only stop + // parsing while the receiver continued drawing full power. + if (!enabled && serialGPSFlag) { + setGpsTelemetryUserEnabled(true); + return false; + } + if (enabled && serialGPSFlag) { + _location->setGPSPowerSaving(false); + setGpsTelemetryUserEnabled(true); + return true; + } +#endif bool was_active = gps_active; _location->setGPSPowerSaving(enabled && powersaving_enabled); setGpsTelemetryUserEnabled(enabled); @@ -788,13 +802,8 @@ bool EnvironmentSensorManager::setSettingValue(const char* name, const char* val } return true; } - if (strcmp(name, "gps_interval") == 0) { - uint32_t interval_seconds = atoi(value); - gps_update_interval_sec = interval_seconds > 0 ? interval_seconds : 1; - return true; - } #endif - return false; // not supported + return SensorManager::setSettingValue(name, value); } void EnvironmentSensorManager::setPowerSavingEnabled(bool enabled) { @@ -804,6 +813,13 @@ void EnvironmentSensorManager::setPowerSavingEnabled(bool enabled) { #if ENV_INCLUDE_GPS if (!gps_detected) return; bool gps_user_enabled = isGpsTelemetryUserEnabled(); +#if defined(RAK_WISBLOCK_GPS) && defined(FORCE_GPS_ALIVE) + if (serialGPSFlag) { + _location->setGPSPowerSaving(false); + if (!gps_active) start_gps(); + return; + } +#endif _location->setGPSPowerSaving(enabled && gps_user_enabled); if (!gps_user_enabled) return; @@ -886,8 +902,16 @@ void EnvironmentSensorManager::rakGPSInit() { return; } -#ifndef FORCE_GPS_ALIVE // for use with repeaters, until GPS toggle is implimented - // Now that GPS is found and set up, set to sleep for initial state +#ifdef FORCE_GPS_ALIVE + if (i2cGPSFlag) { + // The u-blox receiver can sleep while the shared rail remains available. + stop_gps(); + } else if (serialGPSFlag) { + // The UART L76K cannot be isolated from the RAK3401 radio power rail. + setGpsTelemetryUserEnabled(true); + } +#else + // Now that GPS is found and set up, set it to sleep for the initial state. stop_gps(); #endif } @@ -986,8 +1010,10 @@ void EnvironmentSensorManager::stop_gps() { #ifdef RAK_WISBLOCK_GPS #ifdef FORCE_GPS_ALIVE - // Keep the shared rail alive. The UART L76K cannot be put to sleep here. - if (i2cGPSFlag) ublox_GNSS.powerSaveMode(false); + // Keep the shared rail alive for the RAK3401 radio FEM. The I2C u-blox can + // still enter its internal power-save mode. UART L76K builds reject GPS off + // above because that module has no independent standby connection. + if (i2cGPSFlag) ublox_GNSS.powerSaveMode(true); #else pinMode(gpsResetPin, OUTPUT); digitalWrite(gpsResetPin, LOW); // WB_IO2 @@ -1013,8 +1039,9 @@ void EnvironmentSensorManager::loop() { if (powersaving_enabled && gps_detected && _location->getGPSPowerSaving()) { unsigned long next_off = _location->getNextGPSOff(); unsigned long next_on = _location->getNextGPSOn(); - if (gps_active && ((next_off != 0 && (long)(now - next_off) >= 0) - || !_location->waitingTimeSync())) { + if (gps_active && !gpsTelemetryReceiverRequired(now) + && ((next_off != 0 && (long)(now - next_off) >= 0) + || !_location->waitingTimeSync())) { POWERSAVING_DEBUG_PRINTLN("GPS entering sleep"); stop_gps(); } else if (!gps_active && ((next_on != 0 && (long)(now - next_on) >= 0) @@ -1055,7 +1082,7 @@ void EnvironmentSensorManager::loop() { #endif } - next_gps_update = now + (gps_update_interval_sec * 1000); + next_gps_update = now + getGpsUpdateIntervalMillis(); } #endif #if ENV_INCLUDE_BME680_BSEC diff --git a/src/helpers/sensors/EnvironmentSensorManager.h b/src/helpers/sensors/EnvironmentSensorManager.h index e2855c0f..b224a5d3 100644 --- a/src/helpers/sensors/EnvironmentSensorManager.h +++ b/src/helpers/sensors/EnvironmentSensorManager.h @@ -21,7 +21,6 @@ protected: bool gps_detected = false; bool gps_active = false; - uint32_t gps_update_interval_sec = 1; #if ENV_INCLUDE_GPS LocationProvider* _location; diff --git a/src/helpers/sensors/MicroNMEALocationProvider.h b/src/helpers/sensors/MicroNMEALocationProvider.h index 69e4ce90..9f4e2292 100644 --- a/src/helpers/sensors/MicroNMEALocationProvider.h +++ b/src/helpers/sensors/MicroNMEALocationProvider.h @@ -147,7 +147,7 @@ public : while (_gps_serial->available()) { char c = _gps_serial->read(); #ifdef GPS_NMEA_DEBUG - Serial.print(c); + if (mesh::isUsbLoggingEnabled()) Serial.print(c); #endif nmea.process(c); } diff --git a/test/test_ota/test_ota_flashplan.cpp b/test/test_ota/test_ota_flashplan.cpp index 6f5dce18..7da160a2 100644 --- a/test/test_ota/test_ota_flashplan.cpp +++ b/test/test_ota/test_ota_flashplan.cpp @@ -15,18 +15,34 @@ using namespace mesh::ota; static constexpr uint32_t APP_V6 = MOTA_NRF52_APP_BASE_S140_V6; static constexpr uint32_t APP_V7 = MOTA_NRF52_APP_BASE_S140_V7; -static constexpr uint32_t CAP_V6 = MOTA_NRF52_FS_START - (APP_V6 + MOTA_NRF52_INPLACE_MEMORY); // 88 KB -static constexpr uint32_t CAP_V7 = MOTA_NRF52_FS_START - (APP_V7 + MOTA_NRF52_INPLACE_MEMORY); // 84 KB +static constexpr uint32_t LEGACY = MOTA_NRF52_STAGE_CEILING_LEGACY; +static constexpr uint32_t EXPANDED = MOTA_NRF52_STAGE_CEILING_EXPANDED; +static constexpr uint32_t APP_IMAGE_SIZE = 520u * 1024u; +static constexpr uint32_t APP_END_V6 = APP_V6 + APP_IMAGE_SIZE; +static constexpr uint32_t APP_END_V7 = APP_V7 + APP_IMAGE_SIZE; +static constexpr uint32_t CAP_V6 = LEGACY - APP_END_V6; +static constexpr uint32_t CAP_V7 = LEGACY - APP_END_V7; +static constexpr uint32_t CAP_V6_EXPANDED = EXPANDED - APP_END_V6; +static constexpr uint32_t CAP_V7_EXPANDED = EXPANDED - APP_END_V7; -// A typical running image (~520 KB) leaves room; the container lands strictly within (app_end, FS_START]. +TEST(OtaFlashPlan, SelectsCeilingFromLinkedLayoutAndStorage) { + // Actual internal secondary storage is authoritative regardless of linker selection. + EXPECT_EQ(mota_nrf52_stage_ceiling_for_layout(EXPANDED, true), LEGACY); + EXPECT_EQ(mota_nrf52_stage_ceiling_for_layout(LEGACY, true), LEGACY); + // Standard or ExtraFS linker without an internal secondary filesystem reclaims the unused 100 KiB. + EXPECT_EQ(mota_nrf52_stage_ceiling_for_layout(EXPANDED, false), EXPANDED); + EXPECT_EQ(mota_nrf52_stage_ceiling_for_layout(LEGACY, false), EXPANDED); + // An unrecognized linker region is never permission to erase a larger window. + EXPECT_EQ(mota_nrf52_stage_ceiling_for_layout(0xE0000u, false), LEGACY); +} + +// A typical running image (~520 KB) leaves room; the container lands strictly above it and below ExtraFS. TEST(OtaFlashPlan, StagesBelowFilesystemAndAboveApp) { - uint32_t app_end = APP_V6 + 520u * 1024u; uint32_t start = 0xDEADBEEF; - ASSERT_TRUE(mota_nrf52_stage_plan(64u * 1024u, APP_V6, app_end, start)); - EXPECT_GE(start, app_end); // never overlaps the running image - EXPECT_GE(start, APP_V6 + MOTA_NRF52_INPLACE_MEMORY); // never overlaps detools workspace - EXPECT_LE(start + 64u * 1024u, MOTA_NRF52_FS_START); // never reaches into ExtraFS/InternalFS/prefs - EXPECT_EQ(start % MOTA_NRF52_FLASH_PAGE, 0u); // page-aligned (the flash erase unit) + ASSERT_TRUE(mota_nrf52_stage_plan(64u * 1024u, APP_V6, APP_END_V6, LEGACY, start)); + EXPECT_GE(start, APP_END_V6); // never overlaps the running image + EXPECT_LE(start + 64u * 1024u, LEGACY); // never reaches into ExtraFS/prefs + EXPECT_EQ(start % MOTA_NRF52_FLASH_PAGE, 0u); // page-aligned (the flash erase unit) } // Bottom-aligned: start is the page-aligned FS_START - total_size, so the trailer sits within the @@ -34,67 +50,91 @@ TEST(OtaFlashPlan, StagesBelowFilesystemAndAboveApp) { TEST(OtaFlashPlan, BottomAlignedBelowCeiling) { uint32_t start = 0; uint32_t total = 60000; - ASSERT_TRUE(mota_nrf52_stage_plan(total, APP_V6, APP_V6, start)); - EXPECT_EQ(start, (MOTA_NRF52_FS_START - total) & ~(MOTA_NRF52_FLASH_PAGE - 1)); - EXPECT_LE(start + total, MOTA_NRF52_FS_START); - EXPECT_GT(start + total, MOTA_NRF52_FS_START - MOTA_NRF52_FLASH_PAGE); // within one page of the ceiling + ASSERT_TRUE(mota_nrf52_stage_plan(total, APP_V6, APP_V6, LEGACY, start)); + EXPECT_EQ(start, (LEGACY - total) & ~(MOTA_NRF52_FLASH_PAGE - 1)); + EXPECT_LE(start + total, LEGACY); + EXPECT_GT(start + total, LEGACY - MOTA_NRF52_FLASH_PAGE); // within one page of the ceiling } -// An exactly-capacity container fills the region above the decoder workspace; one byte more never fits. +// An exactly-capacity container fills the page-aligned space above the app; one byte more never fits. TEST(OtaFlashPlan, RejectsOversizedContainer) { uint32_t start = 0; - ASSERT_TRUE(mota_nrf52_stage_plan(CAP_V6, APP_V6, APP_V6, start)); - EXPECT_EQ(start, APP_V6 + MOTA_NRF52_INPLACE_MEMORY); - EXPECT_EQ(start + CAP_V6, MOTA_NRF52_FS_START); - EXPECT_FALSE(mota_nrf52_stage_plan(CAP_V6 + 1, APP_V6, APP_V6, start)); + ASSERT_TRUE(mota_nrf52_stage_plan(CAP_V6, APP_V6, APP_END_V6, LEGACY, start)); + EXPECT_EQ(start, APP_END_V6); + EXPECT_EQ(start + CAP_V6, LEGACY); + EXPECT_FALSE(mota_nrf52_stage_plan(CAP_V6 + 1, APP_V6, APP_END_V6, LEGACY, start)); } -// A running image that exceeds detools' fixed in-place memory can never be a valid delta base. -TEST(OtaFlashPlan, RejectsAppLargerThanWorkspace) { - uint32_t app_end = APP_V6 + MOTA_NRF52_INPLACE_MEMORY + 1; +// The package carries its own checked memory_size, so a valid app may exceed the old 608 KiB fallback. +TEST(OtaFlashPlan, AcceptsAppLargerThanFallbackWhenPackageFits) { + const uint32_t app_end = APP_V6 + MOTA_NRF52_FALLBACK_INPLACE_MEMORY + 32u * 1024u; uint32_t start = 0; - EXPECT_FALSE(mota_nrf52_stage_plan(4u * 1024u, APP_V6, app_end, start)); + ASSERT_TRUE(mota_nrf52_stage_plan(16u * 1024u, APP_V6, app_end, LEGACY, start)); + EXPECT_GE(start, app_end); } // Minimum container is header(8)+trailer(5)=13 bytes; anything smaller is not a container. TEST(OtaFlashPlan, RejectsUndersizedContainer) { uint32_t start = 0; - EXPECT_FALSE(mota_nrf52_stage_plan(12, APP_V6, APP_V6, start)); - EXPECT_TRUE(mota_nrf52_stage_plan(13, APP_V6, APP_V6, start)); + EXPECT_FALSE(mota_nrf52_stage_plan(12, APP_V6, APP_V6, LEGACY, start)); + EXPECT_TRUE(mota_nrf52_stage_plan(13, APP_V6, APP_V6, LEGACY, start)); } // The user-preferences filesystems (ExtraFS @ 0xD4000, InternalFS @ 0xED000) are entirely ABOVE any -// staged container AND above the in-place apply workspace - the immutability the shared-firmware hashes -// depend on. Hard-code the FS addresses here (independent of the layout header) so a future edit to -// FS_START that drifts into a filesystem is caught. +// staged container. Hard-code the FS addresses here (independent of the layout header) so a future edit +// that drifts into a filesystem is caught. TEST(OtaFlashPlan, PrefsRegionNeverStaged) { const uint32_t EXTRAFS_START = 0xD4000u; // companion ExtraFS (CustomLFS(0xD4000, 0x19000)) const uint32_t INTERNALFS_START = 0xED000u; // primary LittleFS (holds /com_prefs) - EXPECT_LE(MOTA_NRF52_FS_START, EXTRAFS_START); // staging ceiling at/below the first filesystem + EXPECT_EQ(LEGACY, EXTRAFS_START); + EXPECT_EQ(EXPANDED, INTERNALFS_START); EXPECT_LT(EXTRAFS_START, INTERNALFS_START); // the largest possible staged container still ends at the ceiling, never into a filesystem uint32_t start = 0; - ASSERT_TRUE(mota_nrf52_stage_plan(CAP_V6, APP_V6, APP_V6, start)); + ASSERT_TRUE(mota_nrf52_stage_plan(CAP_V6, APP_V6, APP_END_V6, LEGACY, start)); EXPECT_LE(start + CAP_V6, EXTRAFS_START); - // and the in-place apply workspace ends below the filesystem too - EXPECT_LE(APP_V6 + MOTA_NRF52_INPLACE_MEMORY, EXTRAFS_START); } // S140 v7 moves the app start by one page. Runtime linker-base discovery must leave a correspondingly // smaller but still safe staging region rather than scanning the v6 address and missing EndF. TEST(OtaFlashPlan, SupportsS140V7RuntimeBase) { - EXPECT_TRUE(mota_nrf52_layout_valid(APP_V7)); - EXPECT_EQ(mota_nrf52_stage_capacity(APP_V7), CAP_V7); + EXPECT_TRUE(mota_nrf52_layout_valid(APP_V7, LEGACY)); + EXPECT_EQ(mota_nrf52_stage_capacity(APP_V7, APP_END_V7, LEGACY), CAP_V7); uint32_t start = 0; - ASSERT_TRUE(mota_nrf52_stage_plan(CAP_V7, APP_V7, APP_V7 + 520u * 1024u, start)); - EXPECT_EQ(start, APP_V7 + MOTA_NRF52_INPLACE_MEMORY); - EXPECT_EQ(start + CAP_V7, MOTA_NRF52_FS_START); + ASSERT_TRUE(mota_nrf52_stage_plan(CAP_V7, APP_V7, APP_END_V7, LEGACY, start)); + EXPECT_EQ(start, APP_END_V7); + EXPECT_EQ(start + CAP_V7, LEGACY); +} + +TEST(OtaFlashPlan, ExpandedCeilingAddsExactly100KiBForV6AndV7) { + EXPECT_EQ(EXPANDED - LEGACY, 100u * 1024u); + EXPECT_EQ(CAP_V6_EXPANDED - CAP_V6, 100u * 1024u); + EXPECT_EQ(CAP_V7_EXPANDED - CAP_V7, 100u * 1024u); + EXPECT_EQ(mota_nrf52_stage_capacity(APP_V6, APP_END_V6, EXPANDED), CAP_V6_EXPANDED); + EXPECT_EQ(mota_nrf52_stage_capacity(APP_V7, APP_END_V7, EXPANDED), CAP_V7_EXPANDED); + + uint32_t start = 0; + ASSERT_TRUE(mota_nrf52_stage_plan(CAP_V7_EXPANDED, APP_V7, APP_END_V7, EXPANDED, start)); + EXPECT_EQ(start, APP_END_V7); + EXPECT_EQ(start + CAP_V7_EXPANDED, EXPANDED); +} + +TEST(OtaFlashPlan, RejectsAppOutsideSelectedRegion) { + uint32_t start = 0x1234ABCD; + EXPECT_FALSE(mota_nrf52_stage_plan(4096, APP_V6, LEGACY + 1, LEGACY, start)); + EXPECT_EQ(start, 0x1234ABCDu); +} + +TEST(OtaFlashPlan, RejectsUnknownCeiling) { + uint32_t start = 0x1234ABCD; + EXPECT_FALSE(mota_nrf52_stage_plan(4096, APP_V6, APP_V6, 0xE0000u, start)); + EXPECT_EQ(start, 0x1234ABCDu); } // out_start is only written on success - a rejected plan must not clobber the caller's variable. TEST(OtaFlashPlan, LeavesOutputUntouchedOnReject) { uint32_t start = 0x1234ABCD; - EXPECT_FALSE(mota_nrf52_stage_plan(CAP_V6 + 1, APP_V6, APP_V6, start)); + EXPECT_FALSE(mota_nrf52_stage_plan(CAP_V6 + 1, APP_V6, APP_END_V6, LEGACY, start)); EXPECT_EQ(start, 0x1234ABCDu); } diff --git a/test/test_power_management/test_power_management.cpp b/test/test_power_management/test_power_management.cpp index 3c40f115..bab51087 100644 --- a/test/test_power_management/test_power_management.cpp +++ b/test/test_power_management/test_power_management.cpp @@ -1,6 +1,7 @@ #include #include +#include TEST(PowerManagement, MedianRejectsOneBrownoutSample) { EXPECT_EQ(mesh::power::medianVoltage(3290, 3700, 3710), 3700); @@ -16,6 +17,29 @@ TEST(PowerManagement, BootLockRequiresValidLowBatteryReading) { EXPECT_FALSE(mesh::power::shouldBootLock(3000, 3300, true)); } +TEST(GpsPowerPolicy, ZeroUsesOneSecondDefault) { + uint32_t configured = 99; + ASSERT_TRUE(mesh::gps::parseUpdateInterval("0", configured)); + EXPECT_EQ(0U, configured); + EXPECT_EQ(1U, mesh::gps::effectiveUpdateIntervalSec(configured)); + EXPECT_EQ(1000U, mesh::gps::updateIntervalMillis(configured)); +} + +TEST(GpsPowerPolicy, AcceptsPersistedIntervalThroughOneDay) { + uint32_t configured = 0; + ASSERT_TRUE(mesh::gps::parseUpdateInterval("86400", configured)); + EXPECT_EQ(86400U, configured); + EXPECT_EQ(86400000U, mesh::gps::updateIntervalMillis(configured)); +} + +TEST(GpsPowerPolicy, RejectsMalformedAndOutOfRangeIntervals) { + uint32_t configured = 17; + EXPECT_FALSE(mesh::gps::parseUpdateInterval("", configured)); + EXPECT_FALSE(mesh::gps::parseUpdateInterval("5s", configured)); + EXPECT_FALSE(mesh::gps::parseUpdateInterval("86401", configured)); + EXPECT_EQ(17U, configured); +} + int main(int argc, char** argv) { ::testing::InitGoogleTest(&argc, argv); return RUN_ALL_TESTS(); diff --git a/tools/mota/motalib.py b/tools/mota/motalib.py index 786f989c..8ff8799a 100644 --- a/tools/mota/motalib.py +++ b/tools/mota/motalib.py @@ -54,10 +54,25 @@ APPROVAL_YES = b"APRV" # 41 50 52 56 = approved DEFAULT_BLOCK_SIZE = 1024 -# nRF52840 OTAFIX's detools in-place workspace. A firmware image (including EndF) must fit here, and -# the staged .mota must begin above it. Keep in sync with OtaFlashLayout_nrf52.h / the OTAFIX bootloader. +# Conservative fallback for firmware built before the layout record below existed. Keep in sync with +# OtaFlashLayout_nrf52.h and motatool's format.rs. NRF52_INPLACE_MEMORY = 0x00098000 +NRF52_APP_BASE_S140_V6 = 0x00026000 +NRF52_APP_BASE_S140_V7 = 0x00027000 +NRF52_EXTRAFS_START = 0x000D4000 +NRF52_APP_END = 0x000ED000 +NRF52_FLASH_PAGE = 4096 + +# A validated nRF52 firmware carries this record immediately before EndF. It lets an offline packager +# derive the actual app base and staging ceiling from the built artifact, without a board-name allowlist. +# magic(8) | version(1) | flags(1) | record_len(2) | app_base(4) | linked_app_end(4) | stage_ceiling(4) +NRF52_LAYOUT_MAGIC = b"mOTALay1" +NRF52_LAYOUT_VERSION = 1 +NRF52_LAYOUT_LEN = 24 +NRF52_LAYOUT_FLAG_SD = 0x01 +NRF52_LAYOUT_FLAG_INTERNAL_EXTRAFS = 0x02 + # MeshTower V2's SD-backed OTA target keeps the staged .mota off-chip, so the application may use the # complete S140 v6 application region up to InternalFS instead of leaving room for internal staging. # This is deliberately target-specific: other nRF52 OTA builds still need NRF52_INPLACE_MEMORY above. @@ -154,6 +169,76 @@ class FwIdent: hw_id: str = "" # readable hardware tag (brick-safety), e.g. "RAK4631" +@dataclass(frozen=True) +class Nrf52Layout: + """Resolved flash geometry embedded immediately before an nRF52 firmware's EndF trailer.""" + app_base: int + linked_app_end: int + stage_ceiling: int + flags: int = 0 + + @property + def sd_backed(self) -> bool: + return bool(self.flags & NRF52_LAYOUT_FLAG_SD) + + +def nrf52_stage_ceiling_for_layout(linked_app_end: int, uses_internal_extrafs: bool) -> int: + """Select a safe staging ceiling from linker geometry and actual secondary-storage type.""" + if uses_internal_extrafs: + return NRF52_EXTRAFS_START + if linked_app_end in (NRF52_EXTRAFS_START, NRF52_APP_END): + return NRF52_APP_END + return NRF52_EXTRAFS_START + + +def build_nrf52_layout(layout: Nrf52Layout) -> bytes: + if layout.app_base not in (NRF52_APP_BASE_S140_V6, NRF52_APP_BASE_S140_V7): + raise ValueError(f"unsupported nRF52 app base 0x{layout.app_base:X}") + if layout.linked_app_end not in (NRF52_EXTRAFS_START, NRF52_APP_END): + raise ValueError(f"unsupported nRF52 linked app end 0x{layout.linked_app_end:X}") + if layout.stage_ceiling not in (NRF52_EXTRAFS_START, NRF52_APP_END): + raise ValueError(f"unsupported nRF52 staging ceiling 0x{layout.stage_ceiling:X}") + if not (layout.app_base < layout.linked_app_end <= NRF52_APP_END): + raise ValueError("invalid nRF52 app region") + if layout.flags & ~(NRF52_LAYOUT_FLAG_SD | NRF52_LAYOUT_FLAG_INTERNAL_EXTRAFS): + raise ValueError(f"unsupported nRF52 layout flags 0x{layout.flags:X}") + expected_ceiling = (NRF52_APP_END if layout.sd_backed else + nrf52_stage_ceiling_for_layout( + layout.linked_app_end, + bool(layout.flags & NRF52_LAYOUT_FLAG_INTERNAL_EXTRAFS))) + if layout.stage_ceiling != expected_ceiling: + raise ValueError(f"nRF52 staging ceiling 0x{layout.stage_ceiling:X} is inconsistent with layout flags") + return struct.pack("<8sBBHIII", NRF52_LAYOUT_MAGIC, NRF52_LAYOUT_VERSION, layout.flags, + NRF52_LAYOUT_LEN, layout.app_base, layout.linked_app_end, + layout.stage_ceiling) + + +def ensure_nrf52_layout(body: bytes, layout: Nrf52Layout) -> bytes: + """Append or replace the fixed layout record on an EndF-free firmware body.""" + record = build_nrf52_layout(layout) + if len(body) >= NRF52_LAYOUT_LEN and body[-NRF52_LAYOUT_LEN:-NRF52_LAYOUT_LEN + 8] == NRF52_LAYOUT_MAGIC: + body = body[:-NRF52_LAYOUT_LEN] + return body + record + + +def parse_nrf52_layout(image: bytes) -> Optional[Nrf52Layout]: + """Read a validated layout record from an EndF-trailed firmware; old firmware returns None.""" + if not has_endf(image) or len(image) < ENDF_LEN + NRF52_LAYOUT_LEN: + return None + record = image[-ENDF_LEN - NRF52_LAYOUT_LEN:-ENDF_LEN] + try: + magic, version, flags, record_len, app_base, linked_end, ceiling = struct.unpack( + "<8sBBHIII", record) + if magic != NRF52_LAYOUT_MAGIC or version != NRF52_LAYOUT_VERSION or record_len != NRF52_LAYOUT_LEN: + return None + layout = Nrf52Layout(app_base, linked_end, ceiling, flags) + if build_nrf52_layout(layout) != record: + return None + return layout + except (ValueError, struct.error): + return None + + def build_endf(body: bytes, ident: Optional["FwIdent"] = None) -> bytes: """The fixed 56-byte EndF trailer for a firmware BODY (identity zero-filled if not given).""" ident = ident or FwIdent() diff --git a/tools/mota/pio_endf.py b/tools/mota/pio_endf.py index e7124c0e..507028ee 100644 --- a/tools/mota/pio_endf.py +++ b/tools/mota/pio_endf.py @@ -66,6 +66,24 @@ def _board_maximum_size(build_env): return None +def _source_filter_text(): + """Resolved source-filter text for detecting code that is actually part of this environment.""" + srcf = "" + for getter in (lambda: env.GetProjectOption("build_src_filter", ""), # noqa: F821 + lambda: env.GetProjectOption("src_filter", ""), # noqa: F821 + lambda: env.get("SRC_FILTER", "")): # noqa: F821 + try: + value = getter() + srcf += " " + (" ".join(map(str, value)) if isinstance(value, (list, tuple)) else str(value)) + except Exception: + pass + return srcf + + +def _builds_companion_radio() -> bool: + return "examples/companion_radio" in _source_filter_text().replace("\\", "/") + + def _version_from_headers(): """FIRMWARE_VERSION is a header ``#define`` in the example (upstream MeshCore convention), not a -D, so _cppdef() can't see it and the EndF version would otherwise default to 0. Read it from the source WITHOUT @@ -90,15 +108,7 @@ def _version_from_headers(): return vals # 1) restrict to the example(s) this env compiles (build_src_filter -> examples/) - srcf = "" - for getter in (lambda: env.GetProjectOption("build_src_filter", ""), # noqa: F821 - lambda: env.GetProjectOption("src_filter", ""), # noqa: F821 - lambda: env.get("SRC_FILTER", "")): # noqa: F821 - try: - v = getter() - srcf += " " + (" ".join(map(str, v)) if isinstance(v, (list, tuple)) else str(v)) - except Exception: - pass + srcf = _source_filter_text() ex_dirs = set(re.findall(r"examples[/\\]([A-Za-z0-9_]+)", srcf)) hdrs = [] for d in ex_dirs: @@ -155,33 +165,48 @@ def _append_endf_hex(source, target, env): # Intel-HEX path (nRF52: app f if not segs: print("EndF: empty .hex, skipping"); return app_start, app_end = segs[0] # first (lowest) segment = the application image - body = bytes(ih.tobinarray(start=app_start, size=app_end - app_start)) + raw_body = bytes(ih.tobinarray(start=app_start, size=app_end - app_start)) + if ml.has_endf(raw_body): + print(f"EndF: already present in {os.path.basename(path)} (no change)"); return + + app_region_size = _board_maximum_size(env) + if app_region_size is None: + raise RuntimeError("nRF52 linked application region is unavailable") + linked_app_end = app_start + app_region_size + internal_extrafs = (_cppdef("EXTRAFS") is not None and _cppdef("QSPIFLASH") is None + and _builds_companion_radio()) + sd_backed = _cppdef("OTA_SD_STORE") is not None + stage_ceiling = (ml.NRF52_APP_END if sd_backed else + ml.nrf52_stage_ceiling_for_layout(linked_app_end, internal_extrafs)) + layout_flags = ((ml.NRF52_LAYOUT_FLAG_SD if sd_backed else 0) | + (ml.NRF52_LAYOUT_FLAG_INTERNAL_EXTRAFS if internal_extrafs else 0)) + layout = ml.Nrf52Layout(app_start, linked_app_end, stage_ceiling, layout_flags) + body = ml.ensure_nrf52_layout(raw_body, layout) ident = _firmware_ident() out, h8 = ml.ensure_endf(body, ident) - sd_backed = _cppdef("OTA_SD_STORE") is not None seeder_only = _cppdef("OTA_SEEDER_ONLY") is not None if seeder_only: # A source-only full Companion never stages or applies an update to # itself, so it does not need to fit in OTAFIX's in-place workspace. # It must still fit the board/env's resolved flash application region. - image_limit = _board_maximum_size(env) - if image_limit is None: - raise RuntimeError("nRF52 seeder-only application limit is unavailable") + image_limit = app_region_size limit_name = "application" else: - image_limit = ml.NRF52_SD_APP_MEMORY if sd_backed else ml.NRF52_INPLACE_MEMORY - limit_name = "SD application" if sd_backed else "in-place" + # Dynamic in-place patches carry their own memory_size and are checked against the staged + # container before approval. The firmware itself only needs to remain inside both its linked + # application region and the selected filesystem-safe ceiling. + image_limit = min(linked_app_end, stage_ceiling) - app_start + limit_name = "nRF52 OTA application" if image_limit is not None and len(out) > image_limit: raise RuntimeError(f"nRF52 OTA image is {len(out)} bytes; {limit_name} limit is " f"{image_limit} bytes") - if len(out) == len(body): - print(f"EndF: already present in {os.path.basename(path)} (no change)"); return - trailer = out[len(body):] # the EndF trailer (56 bytes with identity) - for i, b in enumerate(trailer): + suffix = out[len(raw_body):] # layout record + EndF identity trailer + for i, b in enumerate(suffix): ih[app_end + i] = b # write it right after the app's last byte ih.write_hex_file(path) print(f"EndF: appended to {os.path.basename(path)} at 0x{app_end:X} " f"(app=0x{app_start:X}.. body_len={len(body)} body_hash={h8.hex()} " + f"stage=0x{stage_ceiling:X} " f"target={ident.target_id:#010x} hw='{ident.hw_id}' fw={ident.fw_version:#010x})") diff --git a/tools/mota/test_mota.py b/tools/mota/test_mota.py index cb6b5946..403eaf51 100644 --- a/tools/mota/test_mota.py +++ b/tools/mota/test_mota.py @@ -105,6 +105,38 @@ def test_endf_identity(): assert len(z) == len(body) + ml.ENDF_LEN and ml.parse_endf_ident(z) == ml.FwIdent(0, 0, "") +def test_nrf52_layout_record_roundtrip_and_policy(): + assert (ml.nrf52_stage_ceiling_for_layout(ml.NRF52_APP_END, True) + == ml.NRF52_EXTRAFS_START) + assert ml.nrf52_stage_ceiling_for_layout(ml.NRF52_APP_END, False) == ml.NRF52_APP_END + assert (ml.nrf52_stage_ceiling_for_layout(ml.NRF52_EXTRAFS_START, True) + == ml.NRF52_EXTRAFS_START) + assert (ml.nrf52_stage_ceiling_for_layout(ml.NRF52_EXTRAFS_START, False) + == ml.NRF52_APP_END) + assert ml.nrf52_stage_ceiling_for_layout(0xE0000, False) == ml.NRF52_EXTRAFS_START + + layout = ml.Nrf52Layout(ml.NRF52_APP_BASE_S140_V7, ml.NRF52_EXTRAFS_START, + ml.NRF52_APP_END, 0) + body = ml.ensure_nrf52_layout(_fw(4, 2048), layout) + image, _ = ml.ensure_endf(body, ml.FwIdent(hw_id="Xiao_nrf52")) + assert ml.parse_nrf52_layout(image) == layout + # Re-running the record step replaces the tail instead of duplicating it. + assert ml.ensure_nrf52_layout(body, layout) == body + assert ml.parse_nrf52_layout(ml.ensure_endf(_fw(5, 2048))[0]) is None + internal = ml.Nrf52Layout( + ml.NRF52_APP_BASE_S140_V7, ml.NRF52_EXTRAFS_START, + ml.NRF52_EXTRAFS_START, ml.NRF52_LAYOUT_FLAG_INTERNAL_EXTRAFS) + internal_image, _ = ml.ensure_endf(ml.ensure_nrf52_layout(_fw(6, 2048), internal)) + assert ml.parse_nrf52_layout(internal_image) == internal + try: + ml.build_nrf52_layout(ml.Nrf52Layout( + ml.NRF52_APP_BASE_S140_V7, ml.NRF52_EXTRAFS_START, + ml.NRF52_EXTRAFS_START, 0)) + assert False, "inconsistent layout record accepted" + except ValueError: + pass + + # --- merkle ---------------------------------------------------------------- def test_merkle_single_block(): diff --git a/variants/heltec_mesh_solar/target.cpp b/variants/heltec_mesh_solar/target.cpp index 94e6146e..8a4e48ca 100644 --- a/variants/heltec_mesh_solar/target.cpp +++ b/variants/heltec_mesh_solar/target.cpp @@ -56,16 +56,12 @@ void SolarSensorManager::stop_gps() { bool SolarSensorManager::begin() { Serial1.begin(9600); - - // We'll consider GPS detected if we see any data on Serial1 - gps_detected = (Serial1.available() > 0); - - if (gps_detected) { - MESH_DEBUG_PRINTLN("GPS detected"); - } else { - MESH_DEBUG_PRINTLN("No GPS detected"); - } - + // GPS is onboard. An immediate UART availability check races its first NMEA + // sentence and permanently hid the GPS setting on most cold boots. + gps_detected = true; + gps_active = false; + _location->stop(); + MESH_DEBUG_PRINTLN("Onboard GPS available"); return true; } @@ -82,8 +78,9 @@ void SolarSensorManager::loop() { if (powersaving_enabled && gps_detected && _location->getGPSPowerSaving()) { unsigned long next_off = _location->getNextGPSOff(); unsigned long next_on = _location->getNextGPSOn(); - if (gps_active && ((next_off != 0 && (long)(now - next_off) >= 0) - || !_location->waitingTimeSync())) { + if (gps_active && !gpsTelemetryReceiverRequired(now) + && ((next_off != 0 && (long)(now - next_off) >= 0) + || !_location->waitingTimeSync())) { POWERSAVING_DEBUG_PRINTLN("GPS entering sleep"); stop_gps(); } else if (!gps_active && ((next_on != 0 && (long)(now - next_on) >= 0) @@ -103,7 +100,7 @@ void SolarSensorManager::loop() { processGpsTelemetryFix(node_lat, node_lon, node_altitude, now); MESH_DEBUG_PRINTLN("lat %f lon %f", node_lat, node_lon); } - next_gps_update = now + 1000; + next_gps_update = now + getGpsUpdateIntervalMillis(); } } @@ -133,7 +130,7 @@ bool SolarSensorManager::setSettingValue(const char* name, const char* value) { } return true; } - return false; // not supported + return SensorManager::setSettingValue(name, value); } void SolarSensorManager::setPowerSavingEnabled(bool enabled) { diff --git a/variants/heltec_tracker/target.cpp b/variants/heltec_tracker/target.cpp index e77c02d2..256f04d8 100644 --- a/variants/heltec_tracker/target.cpp +++ b/variants/heltec_tracker/target.cpp @@ -87,8 +87,9 @@ void HWTSensorManager::loop() { if (powersaving_enabled && _location->getGPSPowerSaving()) { unsigned long next_off = _location->getNextGPSOff(); unsigned long next_on = _location->getNextGPSOn(); - if (gps_active && ((next_off != 0 && (long)(now - next_off) >= 0) - || !_location->waitingTimeSync())) { + if (gps_active && !gpsTelemetryReceiverRequired(now) + && ((next_off != 0 && (long)(now - next_off) >= 0) + || !_location->waitingTimeSync())) { POWERSAVING_DEBUG_PRINTLN("GPS entering sleep"); stop_gps(); } else if (!gps_active && ((next_on != 0 && (long)(now - next_on) >= 0) @@ -108,7 +109,7 @@ void HWTSensorManager::loop() { processGpsTelemetryFix(node_lat, node_lon, node_altitude, now); MESH_DEBUG_PRINTLN("lat %f lon %f", node_lat, node_lon); } - next_gps_update = now + 1000; + next_gps_update = now + getGpsUpdateIntervalMillis(); } } @@ -134,7 +135,7 @@ bool HWTSensorManager::setSettingValue(const char* name, const char* value) { } return true; } - return false; // not supported + return SensorManager::setSettingValue(name, value); } void HWTSensorManager::setPowerSavingEnabled(bool enabled) { diff --git a/variants/meshadventurer/target.cpp b/variants/meshadventurer/target.cpp index 3ec9d5c3..aa6b2577 100644 --- a/variants/meshadventurer/target.cpp +++ b/variants/meshadventurer/target.cpp @@ -36,24 +36,37 @@ mesh::LocalIdentity radio_new_identity() { } +void MASensorManager::armGpsPowerSavingCycle() { + if (!powersaving_enabled || !_location->getGPSPowerSaving()) return; + _location->syncTime(); + _location->setNextGPSOn(0); + _location->setNextSleep(); +} + void MASensorManager::start_gps() { - if(!gps_active) { - MESH_DEBUG_PRINTLN("starting GPS"); - gps_active = true; - } + if (gps_active) return; + MESH_DEBUG_PRINTLN("starting GPS"); + _location->begin(); + _location->reset(); + gps_active = true; + armGpsPowerSavingCycle(); } void MASensorManager::stop_gps() { - if(gps_active) { - MESH_DEBUG_PRINTLN("stopping GPS"); - gps_active = false; + if (!gps_active) return; + MESH_DEBUG_PRINTLN("stopping GPS"); + gps_active = false; + if (powersaving_enabled && _location->getGPSPowerSaving()) { + _location->stopTimeSync(); + _location->setNextGPSOff(0); + _location->setNextWake(); } + _location->stop(); } bool MASensorManager::begin() { Serial1.setPins(PIN_GPS_RX, PIN_GPS_TX); Serial1.begin(9600); - delay(500); return true; } @@ -66,6 +79,20 @@ void MASensorManager::loop() { static unsigned long next_gps_update = 0; unsigned long now = millis(); loopGpsTelemetry(now); + + if (powersaving_enabled && _location->getGPSPowerSaving()) { + unsigned long next_off = _location->getNextGPSOff(); + unsigned long next_on = _location->getNextGPSOn(); + if (gps_active && !gpsTelemetryReceiverRequired(now) + && ((next_off != 0 && (long)(now - next_off) >= 0) + || !_location->waitingTimeSync())) { + stop_gps(); + } else if (!gps_active && ((next_on != 0 && (long)(now - next_on) >= 0) + || _location->waitingTimeSync())) { + start_gps(); + } + } + if (gps_active) _location->loop(); if ((long)(now - next_gps_update) >= 0 && gps_active) { if(_location->isValid()) { @@ -75,7 +102,7 @@ void MASensorManager::loop() { processGpsTelemetryFix(node_lat, node_lon, node_altitude, now); MESH_DEBUG_PRINTLN("lat %f lon %f", node_lat, node_lon); } - next_gps_update = now + 1000; + next_gps_update = now + getGpsUpdateIntervalMillis(); } } @@ -92,8 +119,30 @@ const char* MASensorManager::getSettingValue(int i) const { } bool MASensorManager::setSettingValue(const char* name, const char* value) { if(strcmp(name, "gps") == 0) { - setGpsTelemetryUserEnabled(strcmp(value, "0") != 0); + bool enabled = strcmp(value, "0") != 0; + bool was_active = gps_active; + _location->setGPSPowerSaving(enabled && powersaving_enabled); + setGpsTelemetryUserEnabled(enabled); + if (enabled && powersaving_enabled && was_active) { + armGpsPowerSavingCycle(); + } return true; } - return false; // not supported + return SensorManager::setSettingValue(name, value); +} + +void MASensorManager::setPowerSavingEnabled(bool enabled) { + if (powersaving_enabled == enabled) return; + powersaving_enabled = enabled; + + bool gps_user_enabled = isGpsTelemetryUserEnabled(); + _location->setGPSPowerSaving(enabled && gps_user_enabled); + if (!gps_user_enabled) return; + + if (enabled) { + if (gps_active) armGpsPowerSavingCycle(); + else start_gps(); + } else if (!gps_active) { + start_gps(); + } } diff --git a/variants/meshadventurer/target.h b/variants/meshadventurer/target.h index 2a549480..bd56dc53 100644 --- a/variants/meshadventurer/target.h +++ b/variants/meshadventurer/target.h @@ -18,6 +18,7 @@ class MASensorManager : public SensorManager { bool gps_active = false; LocationProvider * _location; + void armGpsPowerSavingCycle(); void start_gps(); void stop_gps(); bool telemetryGpsDetected() const override { return true; } @@ -33,6 +34,8 @@ public: const char* getSettingName(int i) const override; const char* getSettingValue(int i) const override; bool setSettingValue(const char* name, const char* value) override; + void setPowerSavingEnabled(bool enabled) override; + LocationProvider* getLocationProvider() override { return _location; } }; extern MeshadventurerBoard board; diff --git a/variants/meshtracker_x1/target.cpp b/variants/meshtracker_x1/target.cpp index d6d548b5..9b3c654e 100644 --- a/variants/meshtracker_x1/target.cpp +++ b/variants/meshtracker_x1/target.cpp @@ -25,7 +25,15 @@ mesh::LocalIdentity radio_new_identity() { return mesh::LocalIdentity(&rng); // create new random identity } +void MeshTrackerX1SensorManager::armGpsPowerSavingCycle() { + if (!powersaving_enabled || !_nmea->getGPSPowerSaving()) return; + _nmea->syncTime(); + _nmea->setNextGPSOn(0); + _nmea->setNextSleep(); +} + void MeshTrackerX1SensorManager::start_gps() { + if (gps_active) return; gps_active = true; // this init sequence comes from seeed examples and deals with all gps pins pinMode(GPS_EN, OUTPUT); @@ -44,10 +52,19 @@ void MeshTrackerX1SensorManager::start_gps() { digitalWrite(GPS_SLEEP_INT, HIGH); pinMode(GPS_RTC_INT, OUTPUT); digitalWrite(GPS_RTC_INT, LOW); + _nmea->begin(); + armGpsPowerSavingCycle(); } void MeshTrackerX1SensorManager::sleep_gps() { + if (!gps_active) return; gps_active = false; + if (powersaving_enabled && _nmea->getGPSPowerSaving()) { + _nmea->stopTimeSync(); + _nmea->setNextGPSOff(0); + _nmea->setNextWake(); + } + _nmea->stop(); digitalWrite(GPS_VRTC_EN, HIGH); // keep RTC alive for faster fix on wake digitalWrite(GPS_EN, LOW); digitalWrite(GPS_RESET, LOW); @@ -57,6 +74,7 @@ void MeshTrackerX1SensorManager::sleep_gps() { void MeshTrackerX1SensorManager::stop_gps() { gps_active = false; + _nmea->stop(); digitalWrite(GPS_VRTC_EN, LOW); digitalWrite(GPS_EN, LOW); digitalWrite(GPS_RESET, LOW); @@ -67,6 +85,12 @@ void MeshTrackerX1SensorManager::stop_gps() { bool MeshTrackerX1SensorManager::begin() { // init GPS Serial1.begin(GPS_BAUD_RATE); + pinMode(GPS_VRTC_EN, OUTPUT); + pinMode(GPS_EN, OUTPUT); + pinMode(GPS_RESET, OUTPUT); + pinMode(GPS_SLEEP_INT, OUTPUT); + pinMode(GPS_RTC_INT, OUTPUT); + stop_gps(); // init SPA06-003 barometer baro_ok = spa06.begin(SPA06_003_DEFAULT_ADDR, &Wire) || spa06.begin(0x76, &Wire); @@ -82,9 +106,7 @@ bool MeshTrackerX1SensorManager::begin() { } bool MeshTrackerX1SensorManager::querySensors(uint8_t requester_permissions, CayenneLPP& telemetry) { - if (requester_permissions & TELEM_PERM_LOCATION) { // does requester have permission? - telemetry.addGPS(TELEM_CHANNEL_SELF, node_lat, node_lon, node_altitude); - } + queryGpsTelemetry(requester_permissions, telemetry); if (requester_permissions & TELEM_PERM_ENVIRONMENT && baro_ok) { telemetry.addTemperature(TELEM_CHANNEL_SELF, spa06.readTemperature()); telemetry.addBarometricPressure(TELEM_CHANNEL_SELF, spa06.readPressure()); @@ -93,18 +115,33 @@ bool MeshTrackerX1SensorManager::querySensors(uint8_t requester_permissions, Cay } void MeshTrackerX1SensorManager::loop() { - static long next_gps_update = 0; + static unsigned long next_gps_update = 0; + unsigned long now = millis(); + loopGpsTelemetry(now); + if (powersaving_enabled && _nmea->getGPSPowerSaving()) { + unsigned long next_off = _nmea->getNextGPSOff(); + unsigned long next_on = _nmea->getNextGPSOn(); + if (gps_active && !gpsTelemetryReceiverRequired(now) + && ((next_off != 0 && (long)(now - next_off) >= 0) + || !_nmea->waitingTimeSync())) { + sleep_gps(); + } else if (!gps_active && ((next_on != 0 && (long)(now - next_on) >= 0) + || _nmea->waitingTimeSync())) { + start_gps(); + } + } - _nmea->loop(); + if (gps_active) _nmea->loop(); - if (millis() > next_gps_update) { + if ((long)(now - next_gps_update) >= 0) { if (gps_active && _nmea->isValid()) { node_lat = ((double)_nmea->getLatitude())/1000000.; node_lon = ((double)_nmea->getLongitude())/1000000.; node_altitude = ((double)_nmea->getAltitude()) / 1000.0; + processGpsTelemetryFix(node_lat, node_lon, node_altitude, now); } - next_gps_update = millis() + 1000; + next_gps_update = now + getGpsUpdateIntervalMillis(); } } @@ -115,18 +152,36 @@ const char* MeshTrackerX1SensorManager::getSettingName(int i) const { } const char* MeshTrackerX1SensorManager::getSettingValue(int i) const { if (i == 0) { - return gps_active ? "1" : "0"; + return isGpsTelemetryUserEnabled() ? "1" : "0"; } return NULL; } bool MeshTrackerX1SensorManager::setSettingValue(const char* name, const char* value) { if (strcmp(name, "gps") == 0) { - if (strcmp(value, "0") == 0) { - sleep_gps(); // sleep for faster fix ! - } else { - start_gps(); + bool enabled = strcmp(value, "0") != 0; + bool was_active = gps_active; + _nmea->setGPSPowerSaving(enabled && powersaving_enabled); + setGpsTelemetryUserEnabled(enabled); + if (enabled && powersaving_enabled && was_active) { + armGpsPowerSavingCycle(); } return true; } - return false; // not supported + return SensorManager::setSettingValue(name, value); +} + +void MeshTrackerX1SensorManager::setPowerSavingEnabled(bool enabled) { + if (powersaving_enabled == enabled) return; + powersaving_enabled = enabled; + + bool gps_user_enabled = isGpsTelemetryUserEnabled(); + _nmea->setGPSPowerSaving(enabled && gps_user_enabled); + if (!gps_user_enabled) return; + + if (enabled) { + if (gps_active) armGpsPowerSavingCycle(); + else start_gps(); + } else if (!gps_active) { + start_gps(); + } } diff --git a/variants/meshtracker_x1/target.h b/variants/meshtracker_x1/target.h index 9741efb6..81d009fd 100644 --- a/variants/meshtracker_x1/target.h +++ b/variants/meshtracker_x1/target.h @@ -19,9 +19,14 @@ class MeshTrackerX1SensorManager: public SensorManager { Adafruit_SPA06_003 spa06; LocationProvider * _nmea; + void armGpsPowerSavingCycle(); void start_gps(); void sleep_gps(); void stop_gps(); + bool telemetryGpsDetected() const override { return true; } + bool telemetryGpsActive() const override { return gps_active; } + void telemetryGpsStart() override { start_gps(); } + void telemetryGpsStop() override { sleep_gps(); } public: MeshTrackerX1SensorManager(LocationProvider &nmea): _nmea(&nmea) { } bool begin() override; @@ -31,7 +36,8 @@ public: const char* getSettingName(int i) const override; const char* getSettingValue(int i) const override; bool setSettingValue(const char* name, const char* value) override; - LocationProvider* getLocationProvider() { return _nmea; } + void setPowerSavingEnabled(bool enabled) override; + LocationProvider* getLocationProvider() override { return _nmea; } }; #ifdef DISPLAY_CLASS diff --git a/variants/nano_g2_ultra/target.cpp b/variants/nano_g2_ultra/target.cpp index 965c1f0c..eab7b8a0 100644 --- a/variants/nano_g2_ultra/target.cpp +++ b/variants/nano_g2_ultra/target.cpp @@ -25,43 +25,41 @@ bool radio_init() { return radio.std_init(&SPI); } +void NanoG2UltraSensorManager::armGpsPowerSavingCycle() { + if (!powersaving_enabled || !_location->getGPSPowerSaving()) return; + _location->syncTime(); + _location->setNextGPSOn(0); + _location->setNextSleep(); +} + void NanoG2UltraSensorManager::start_gps() { + if (gps_active) return; MESH_DEBUG_PRINTLN("Starting GPS"); - if (!gps_active) { - digitalWrite(PIN_GPS_STANDBY, HIGH); // Wake GPS from standby - Serial1.setPins(PIN_GPS_TX, PIN_GPS_RX); - Serial1.begin(9600); - MESH_DEBUG_PRINTLN("Waiting for gps to power up"); - delay(1000); - gps_active = true; - } + digitalWrite(PIN_GPS_STANDBY, HIGH); // Wake GPS from standby _location->begin(); + gps_active = true; + armGpsPowerSavingCycle(); } void NanoG2UltraSensorManager::stop_gps() { + if (!gps_active) return; MESH_DEBUG_PRINTLN("Stopping GPS"); - if (gps_active) { - digitalWrite(PIN_GPS_STANDBY, LOW); // sleep GPS - gps_active = false; + gps_active = false; + if (powersaving_enabled && _location->getGPSPowerSaving()) { + _location->stopTimeSync(); + _location->setNextGPSOff(0); + _location->setNextWake(); } _location->stop(); + digitalWrite(PIN_GPS_STANDBY, LOW); // sleep GPS } bool NanoG2UltraSensorManager::begin() { - digitalWrite(PIN_GPS_STANDBY, HIGH); // Wake GPS from standby + pinMode(PIN_GPS_STANDBY, OUTPUT); + digitalWrite(PIN_GPS_STANDBY, LOW); // Known onboard GPS starts asleep Serial1.setPins(PIN_GPS_TX, PIN_GPS_RX); Serial1.begin(9600); - MESH_DEBUG_PRINTLN("Checking GPS switch state"); - delay(1000); - - // Check initial switch state to determine if GPS should be active - if (Serial1.available() > 0) { - MESH_DEBUG_PRINTLN("GPS was on at boot, GPS enabled"); - start_gps(); - } else { - MESH_DEBUG_PRINTLN("GPS was not on at boot, GPS disabled"); - } - + gps_active = false; return true; } @@ -75,6 +73,19 @@ void NanoG2UltraSensorManager::loop() { unsigned long now = millis(); loopGpsTelemetry(now); + if (powersaving_enabled && _location->getGPSPowerSaving()) { + unsigned long next_off = _location->getNextGPSOff(); + unsigned long next_on = _location->getNextGPSOn(); + if (gps_active && !gpsTelemetryReceiverRequired(now) + && ((next_off != 0 && (long)(now - next_off) >= 0) + || !_location->waitingTimeSync())) { + stop_gps(); + } else if (!gps_active && ((next_on != 0 && (long)(now - next_on) >= 0) + || _location->waitingTimeSync())) { + start_gps(); + } + } + if (!gps_active) { return; // GPS is not active, skip further processing } @@ -92,7 +103,7 @@ void NanoG2UltraSensorManager::loop() { MESH_DEBUG_PRINTLN("INVALID location, waiting for fix"); } MESH_DEBUG_PRINTLN("GPS satellites: %d", _location->satellitesCount()); - next_gps_update = now + 1000; + next_gps_update = now + getGpsUpdateIntervalMillis(); } } @@ -113,10 +124,32 @@ const char *NanoG2UltraSensorManager::getSettingValue(int i) const { bool NanoG2UltraSensorManager::setSettingValue(const char *name, const char *value) { if (strcmp(name, "gps") == 0) { - setGpsTelemetryUserEnabled(strcmp(value, "0") != 0); + bool enabled = strcmp(value, "0") != 0; + bool was_active = gps_active; + _location->setGPSPowerSaving(enabled && powersaving_enabled); + setGpsTelemetryUserEnabled(enabled); + if (enabled && powersaving_enabled && was_active) { + armGpsPowerSavingCycle(); + } return true; } - return false; // not supported + return SensorManager::setSettingValue(name, value); +} + +void NanoG2UltraSensorManager::setPowerSavingEnabled(bool enabled) { + if (powersaving_enabled == enabled) return; + powersaving_enabled = enabled; + + bool gps_user_enabled = isGpsTelemetryUserEnabled(); + _location->setGPSPowerSaving(enabled && gps_user_enabled); + if (!gps_user_enabled) return; + + if (enabled) { + if (gps_active) armGpsPowerSavingCycle(); + else start_gps(); + } else if (!gps_active) { + start_gps(); + } } mesh::LocalIdentity radio_new_identity() { diff --git a/variants/nano_g2_ultra/target.h b/variants/nano_g2_ultra/target.h index e11b28f5..4c0008e3 100644 --- a/variants/nano_g2_ultra/target.h +++ b/variants/nano_g2_ultra/target.h @@ -18,6 +18,7 @@ class NanoG2UltraSensorManager : public SensorManager { bool gps_active = false; LocationProvider *_location; + void armGpsPowerSavingCycle(); void start_gps(); void stop_gps(); bool telemetryGpsDetected() const override { return true; } @@ -34,6 +35,8 @@ public: const char *getSettingName(int i) const override; const char *getSettingValue(int i) const override; bool setSettingValue(const char *name, const char *value) override; + void setPowerSavingEnabled(bool enabled) override; + LocationProvider* getLocationProvider() override { return _location; } }; extern NanoG2Ultra board; diff --git a/variants/station_g2/platformio.ini b/variants/station_g2/platformio.ini index a9a3a26b..990b0e71 100644 --- a/variants/station_g2/platformio.ini +++ b/variants/station_g2/platformio.ini @@ -188,7 +188,7 @@ board_ssl_cert_source = adafruit-full board_build.embed_files = src/certs/x509_crt_bundle.bin build_flags = ${Station_G2.build_flags} - ; Portable MQTT observers use CLI setup and the lightweight WiFi updater. + ; The base observer disables LoRa OTA; build.sh promotes it to FULL and restores OTA. -UENABLE_OTA -D DISABLE_LORA_OTA=1 -UMESH_DEBUG @@ -276,6 +276,10 @@ build_flags = -UENV_INCLUDE_MLX90614 -UENV_INCLUDE_VL53L0X -UENV_INCLUDE_BME680 + ; Use the ESP32-S3 ROM formatter so this full BLE companion stays within + ; the Station G2 legacy 0x140000 application partition without dropping UI, + ; GPS, or any additional external-sensor support. + -D PORTABLE_ESP32_ROM_NANO_FORMAT=1 -I examples/companion_radio/ui-new -D MAX_CONTACTS=350 -D MAX_GROUP_CHANNELS=40 @@ -334,7 +338,7 @@ board_ssl_cert_source = adafruit-full board_build.embed_files = src/certs/x509_crt_bundle.bin build_flags = ${Station_G2.build_flags} - ; Portable MQTT observers use CLI setup and the lightweight WiFi updater. + ; The base observer disables LoRa OTA; build.sh promotes it to FULL and restores OTA. -UENABLE_OTA -D DISABLE_LORA_OTA=1 -UMESH_DEBUG diff --git a/variants/t1000-e/target.cpp b/variants/t1000-e/target.cpp index 0e1b33dc..07861513 100644 --- a/variants/t1000-e/target.cpp +++ b/variants/t1000-e/target.cpp @@ -158,6 +158,14 @@ void T1000SensorManager::stop_gps() { bool T1000SensorManager::begin() { // init GPS Serial1.begin(115200); + pinMode(GPS_VRTC_EN, OUTPUT); + pinMode(GPS_EN, OUTPUT); + pinMode(GPS_RESET, OUTPUT); + pinMode(GPS_SLEEP_INT, OUTPUT); + pinMode(GPS_RTC_INT, OUTPUT); + pinMode(GPS_RESETB, OUTPUT); + gps_active = true; + stop_gps(); return true; } @@ -179,8 +187,9 @@ void T1000SensorManager::loop() { if (powersaving_enabled && _nmea->getGPSPowerSaving()) { unsigned long next_off = _nmea->getNextGPSOff(); unsigned long next_on = _nmea->getNextGPSOn(); - if (gps_active && ((next_off != 0 && (long)(now - next_off) >= 0) - || !_nmea->waitingTimeSync())) { + if (gps_active && !gpsTelemetryReceiverRequired(now) + && ((next_off != 0 && (long)(now - next_off) >= 0) + || !_nmea->waitingTimeSync())) { POWERSAVING_DEBUG_PRINTLN("GPS entering sleep"); stop_gps(); } else if (!gps_active && ((next_on != 0 && (long)(now - next_on) >= 0) @@ -200,7 +209,7 @@ void T1000SensorManager::loop() { processGpsTelemetryFix(node_lat, node_lon, node_altitude, now); //Serial.printf("lat %f lon %f\r\n", _lat, _lon); } - next_gps_update = now + 1000; + next_gps_update = now + getGpsUpdateIntervalMillis(); } } @@ -226,7 +235,7 @@ bool T1000SensorManager::setSettingValue(const char* name, const char* value) { } return true; } - return false; // not supported + return SensorManager::setSettingValue(name, value); } void T1000SensorManager::setPowerSavingEnabled(bool enabled) { diff --git a/variants/thinknode_m1/target.cpp b/variants/thinknode_m1/target.cpp index 9cb43e34..81c0f778 100644 --- a/variants/thinknode_m1/target.cpp +++ b/variants/thinknode_m1/target.cpp @@ -29,18 +29,29 @@ mesh::LocalIdentity radio_new_identity() { return mesh::LocalIdentity(&rng); // create new random identity } +void ThinkNodeM1SensorManager::armGpsPowerSavingCycle() { + if (!powersaving_enabled || !_location->getGPSPowerSaving()) return; + _location->syncTime(); + _location->setNextGPSOn(0); + _location->setNextSleep(); +} + void ThinkNodeM1SensorManager::start_gps() { - if (!gps_active) { - gps_active = true; - _location->begin(); - } + if (gps_active) return; + gps_active = true; + _location->begin(); + armGpsPowerSavingCycle(); } void ThinkNodeM1SensorManager::stop_gps() { - if (gps_active) { - gps_active = false; - _location->stop(); + if (!gps_active) return; + gps_active = false; + if (powersaving_enabled && _location->getGPSPowerSaving()) { + _location->stopTimeSync(); + _location->setNextGPSOff(0); + _location->setNextWake(); } + _location->stop(); } bool ThinkNodeM1SensorManager::begin() { @@ -55,7 +66,7 @@ bool ThinkNodeM1SensorManager::begin() { // Check initial switch state to determine if GPS should be active if (last_gps_switch_state == HIGH) { // Switch is HIGH when ON - setGpsTelemetryUserEnabled(true); + setSettingValue("gps", "1"); } return true; @@ -82,16 +93,29 @@ void ThinkNodeM1SensorManager::loop() { if (current_switch_state == HIGH) { // Switch is ON MESH_DEBUG_PRINTLN("GPS switch ON"); - setGpsTelemetryUserEnabled(true); + setSettingValue("gps", "1"); } else { // Switch is OFF MESH_DEBUG_PRINTLN("GPS switch OFF"); - setGpsTelemetryUserEnabled(false); + setSettingValue("gps", "0"); } } last_switch_check = now; } + if (powersaving_enabled && _location->getGPSPowerSaving()) { + unsigned long next_off = _location->getNextGPSOff(); + unsigned long next_on = _location->getNextGPSOn(); + if (gps_active && !gpsTelemetryReceiverRequired(now) + && ((next_off != 0 && (long)(now - next_off) >= 0) + || !_location->waitingTimeSync())) { + stop_gps(); + } else if (!gps_active && ((next_on != 0 && (long)(now - next_on) >= 0) + || _location->waitingTimeSync())) { + start_gps(); + } + } + if (!gps_active) { return; // GPS is not active, skip further processing } @@ -106,7 +130,7 @@ void ThinkNodeM1SensorManager::loop() { processGpsTelemetryFix(node_lat, node_lon, node_altitude, now); MESH_DEBUG_PRINTLN("lat %f lon %f", node_lat, node_lon); } - next_gps_update = now + 1000; + next_gps_update = now + getGpsUpdateIntervalMillis(); } } @@ -127,8 +151,30 @@ const char* ThinkNodeM1SensorManager::getSettingValue(int i) const { bool ThinkNodeM1SensorManager::setSettingValue(const char* name, const char* value) { if (strcmp(name, "gps") == 0) { - setGpsTelemetryUserEnabled(strcmp(value, "0") != 0); + bool enabled = strcmp(value, "0") != 0; + bool was_active = gps_active; + _location->setGPSPowerSaving(enabled && powersaving_enabled); + setGpsTelemetryUserEnabled(enabled); + if (enabled && powersaving_enabled && was_active) { + armGpsPowerSavingCycle(); + } return true; } - return false; // not supported + return SensorManager::setSettingValue(name, value); +} + +void ThinkNodeM1SensorManager::setPowerSavingEnabled(bool enabled) { + if (powersaving_enabled == enabled) return; + powersaving_enabled = enabled; + + bool gps_user_enabled = isGpsTelemetryUserEnabled(); + _location->setGPSPowerSaving(enabled && gps_user_enabled); + if (!gps_user_enabled) return; + + if (enabled) { + if (gps_active) armGpsPowerSavingCycle(); + else start_gps(); + } else if (!gps_active) { + start_gps(); + } } diff --git a/variants/thinknode_m1/target.h b/variants/thinknode_m1/target.h index 7a95d1b9..26ebcac3 100644 --- a/variants/thinknode_m1/target.h +++ b/variants/thinknode_m1/target.h @@ -18,6 +18,7 @@ class ThinkNodeM1SensorManager : public SensorManager { bool last_gps_switch_state = false; LocationProvider* _location; + void armGpsPowerSavingCycle(); void start_gps(); void stop_gps(); bool telemetryGpsDetected() const override { return true; } @@ -34,6 +35,7 @@ public: const char* getSettingName(int i) const override; const char* getSettingValue(int i) const override; bool setSettingValue(const char* name, const char* value) override; + void setPowerSavingEnabled(bool enabled) override; }; extern ThinkNodeM1Board board; diff --git a/variants/thinknode_m8/platformio.ini b/variants/thinknode_m8/platformio.ini index b8c70973..a63fcdba 100644 --- a/variants/thinknode_m8/platformio.ini +++ b/variants/thinknode_m8/platformio.ini @@ -41,6 +41,7 @@ build_flags = ${nrf52_base.build_flags} -D LORA_TX_POWER=22 build_src_filter = ${nrf52_base.build_src_filter} + + + + +<../variants/thinknode_m8> lib_deps =