diff --git a/build.sh b/build.sh index 561ee8f1..a8145db6 100755 --- a/build.sh +++ b/build.sh @@ -128,7 +128,7 @@ Commands: build-sensor-firmwares: Build all sensor firmwares for all build targets. build-kiss-radio-firmwares: Build all KISS radio firmwares for all build targets. get-companion-firmwares-to-build: List canonical attached companion targets for release automation; Full Companion replaces separate transport artifacts where qualified. - get-repeater-firmwares-to-build: List standard and specialized external-storage repeater targets for release automation. + get-repeater-firmwares-to-build: List canonical, specialized external-storage, and deployed-target OTA compatibility repeaters for release automation. get-room-server-firmwares-to-build: List standard room-server targets for release automation. Options: @@ -1887,6 +1887,27 @@ get_pio_envs_ending_with_string() { shopt -u nocasematch } +get_deployed_lora_ota_compatibility_targets() { + # These exact target names have distinct OTA IDs embedded in already + # deployed RAK4631 Serial1/Serial2 bridge images. The merged repeater is the + # recommended image for new installs, but it cannot address those nodes. + # Continue publishing an image bearing each legacy ID unless a future, + # explicitly compatible protocol migrates the installed identity. + printf '%s\n' \ + RAK_4631_repeater_bridge_rs232_serial1_lora_ota_no_external_sensors \ + RAK_4631_repeater_bridge_rs232_serial2_lora_ota_no_external_sensors +} + +is_deployed_lora_ota_compatibility_target() { + case "${1,,}" in + rak_4631_repeater_bridge_rs232_serial1_lora_ota_no_external_sensors|\ + rak_4631_repeater_bridge_rs232_serial2_lora_ota_no_external_sensors) + return 0 + ;; + esac + return 1 +} + print_release_firmware_targets() { case "$1" in get-companion-firmwares-to-build) @@ -1912,6 +1933,16 @@ print_release_firmware_targets() { if is_supported_build_env "RAK_4631_repeater_rak15001_slot_c_lora_ota"; then printf '%s\n' "RAK_4631_repeater_rak15001_slot_c_lora_ota" fi + # Functional consolidation does not change an installed image's mOTA + # target ID. Keep exact Serial1/Serial2 bridge identities publishable as + # compatibility assets while recommending the merged image for USB/new + # installs and hiding these legacy profiles in the firmware picker. + local compatibility_target + while IFS= read -r compatibility_target; do + if is_supported_build_env "$compatibility_target"; then + printf '%s\n' "$compatibility_target" + fi + done < <(get_deployed_lora_ota_compatibility_targets) ;; get-room-server-firmwares-to-build) get_pio_envs_ending_with_string "_room_server" @@ -2017,6 +2048,24 @@ is_rak_i2c_voltage_monitor_ota_target() { esac } +is_rak_gps_retaining_ota_target() { + local target_lc=${1,,} + + # Serial1 is the RAK12501 UART on RAK4631. The Serial1 RS-232 bridge owns + # that port instead, so its reduced profile intentionally uses the INA-only + # recipe and must not promise or require the WisBlock GPS provider. + case "$target_lc" in + rak_3401_repeater_lora_ota_no_external_sensors|\ + rak_4631_repeater_lora_ota_no_external_sensors|\ + rak_4631_repeater_bridge_rs232_serial2_lora_ota_no_external_sensors) + return 0 + ;; + *) + return 1 + ;; + esac +} + is_logging_size_constrained_target() { case "$1" in Tiny_Relay_companion_radio_usb|Tiny_Relay_repeater|RAK_3x72_companion_radio_usb|RAK_3x72_repeater|wio-e5_companion_radio_usb|wio-e5_repeater|wio-e5-repeater_bridge_rs232|wio-e5-mini_companion_radio_usb|wio-e5-mini_repeater|wio-e5-mini_sensor) @@ -2606,6 +2655,21 @@ is_room_server_role_target() { esac } +is_sensor_role_target() { + case "${1,,}" in + *_sensor|*_sensor_) return 0 ;; + *) return 1 ;; + esac +} + +requires_esp32_field_browser_ota() { + [ "${PIO_ENV_PLATFORM_BY_NAME[$1]:-}" = "ESP32_PLATFORM" ] \ + && ! is_esp32_companion_build "$1" \ + && { is_repeater_role_target "$1" \ + || is_room_server_role_target "$1" \ + || is_sensor_role_target "$1"; } +} + get_reduced_lora_ota_target() { local target=$1 local candidate @@ -2763,14 +2827,30 @@ declare_build_capability_contract() { record_build_expectation "ota.cli" "OTA: status" fi - if is_rak_i2c_voltage_monitor_ota_target "$env_name"; then - # These two reduced RAK OTA profiles deliberately retain their board GPS - # provider as well as the compact INA monitor set. Keep the release - # manifest honest so a space-oriented target name is not mistaken for a - # GPS-less image. This evidence is emitted only by the linked WisBlock GPS - # provider; generic CLI command text is not sufficient proof. + # A field-installed ESP32 must never leave the release pipeline with every + # self-update path compiled out. Standard field/server profiles retain the + # lightweight browser uploader below; prove that its linked HTML is really + # present rather than trusting build flags or a generic `start ota` command + # whose board implementation may still be the unsupported stub. + if [ "$env_platform" = "ESP32_PLATFORM" ] \ + && [ "$BUILD_PROFILE_FOR_TARGET" = "standard" ] \ + && requires_esp32_field_browser_ota "$env_name"; then + record_build_expectation \ + "web.lightweight_browser_ota" "MeshCore firmware update" + fi + + if is_rak_gps_retaining_ota_target "$env_name"; then + # GPS-compatible reduced RAK OTA profiles retain the WisBlock provider. + # The Serial1 RS-232 profile is deliberately excluded because that UART + # cannot simultaneously carry the RAK12501 protocol. This evidence is + # emitted only by the linked provider; generic CLI text is insufficient. record_build_expectation \ "sensor.gps" "meshcore.capability.rak_wisblock_gps.v1" + fi + + if is_rak_i2c_voltage_monitor_ota_target "$env_name"; then + # Every reduced RAK profile retains this compact monitor set, including + # the Serial1 RS-232 profile that intentionally omits GPS. record_build_expectation "sensor.ina219" "INA219" record_build_expectation "sensor.ina226" "INA226" record_build_expectation "sensor.ina260" "INA260" @@ -2835,6 +2915,19 @@ declare_build_capability_contract() { record_build_expectation "web.webconfig" "start webconfig" ;; esac + + case "$env_name_lc" in + *sensecapindicator*companion_radio_full*) + # A Full Indicator promises self-repair of the RP2040 font asset. Keep + # the release manifest from treating an HTTPS downloader without the + # fresh-time gate as equivalent: these stable messages are emitted on + # the fail-closed NTP-before-TLS path and survive release builds. + record_build_expectation "indicator.font_recovery_ntp_gate" \ + "requesting fresh NTP time before download" + record_build_expectation "indicator.font_recovery_tls" \ + "opening TLS connection to" + ;; + esac } apply_esp32_lora_ota_size_profile() { @@ -2847,8 +2940,11 @@ 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. - # 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 + # Every field/server role retains the compact browser updater. Publishing a + # repeater, room server, or sensor with both LoRa OTA and browser OTA absent + # strands it until someone reaches it with a cable. If the updater pushes a + # constrained target over its slot ceiling, fail that target instead of + # silently reducing away its last recovery path. 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. @@ -2857,7 +2953,8 @@ apply_esp32_lora_ota_size_profile() { export PLATFORMIO_BUILD_FLAGS="${PLATFORMIO_BUILD_FLAGS} -DWEBCONFIG_DISABLED=1" record_build_reduction \ "web.webconfig omitted to preserve the legacy portable ESP32 app slot" - if is_lora_ota_only_target "$env_name"; then + if is_lora_ota_only_target "$env_name" \ + || requires_esp32_field_browser_ota "$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, the complete role CLI, and the full one-byte neighbor-index @@ -3032,12 +3129,14 @@ apply_lora_ota_no_external_sensors_profile() { return 0 fi - # The explicit LoRa-OTA sibling additionally drops optional external sensors. + # The explicit LoRa-OTA sibling drops selected optional environmental and + # ranging drivers; it does not globally disable I2C or board peripherals. # RAK3401/RAK4631 keep the compact INA voltage/current monitor set because it # costs less than 5 KiB and covers their most common external telemetry use. # The ordinary sibling remains fully sensor-enabled. - # Keep board-integrated GPS support. Several target implementations require - # their location provider even when optional external I2C sensors are absent. + # GPS availability follows the exact target recipe. In particular, the + # RAK4631 Serial1 RS-232 bridge intentionally omits GPS because both need the + # same UART; other reduced RAK profiles below retain compatible GPS paths. if ! is_rak_i2c_voltage_monitor_ota_target "$env_name"; then omitted_sensor_flags+=" ${voltage_monitor_flags}" fi @@ -3052,12 +3151,17 @@ apply_lora_ota_no_external_sensors_profile() { retain_overrides+=" -D${flag}=1" done export PLATFORMIO_BUILD_FLAGS="${PLATFORMIO_BUILD_FLAGS}${omit_overrides}${retain_overrides}" - record_build_reduction \ - "sensors.external omitted except INA219/INA226/INA260/INA3221 I2C voltage monitors" + if is_rak_gps_retaining_ota_target "$env_name"; then + record_build_reduction \ + "selected optional environmental/ranging drivers omitted; INA219/INA226/INA260/INA3221 and board display/RTC/GPS retained; RAK12500 and INA3221 require distinct configured I2C addresses" + else + record_build_reduction \ + "selected optional environmental/ranging drivers and GPS omitted; INA219/INA226/INA260/INA3221 and board display/RTC retained; GPS conflicts with RS-232 on Serial1" + fi else export PLATFORMIO_BUILD_FLAGS="${PLATFORMIO_BUILD_FLAGS}${omit_overrides}" record_build_reduction \ - "sensors.external omitted by the explicitly named no_external_sensors target" + "selected optional environmental/ranging drivers omitted; generic I2C and exact-target board peripherals remain unchanged" fi } @@ -3311,6 +3415,10 @@ apply_companion_radio_full_profile() { sensecapindicator-espnow_companion_radio_full|\ sensecapindicator-lora_companion_radio_full) export MESHCORE_ESP32_FULL_PARTITION_TABLE="variants/sensecap_indicator-espnow/dual_ota_2560k_preserve_spiffs.csv" + # Both physical Indicator radio layouts gain ordinary WiFi in their Full + # overlay. Keep font recovery tied to that effective capability rather + # than to whichever historical USB/WiFi environment supplies the recipe. + export PLATFORMIO_BUILD_FLAGS="${PLATFORMIO_BUILD_FLAGS} -DINDICATOR_WIFI_FONT_RECOVERY=1" ;; esac @@ -3585,8 +3693,15 @@ collect_build_artifacts() { local pio_env_name=$3 local firmware_filename=$4 + # Qualify the linked image before copying anything into out/. A failed + # capability contract must not leave an apparently publishable firmware + # binary behind (in particular, an ESP32 field image with no update path). + write_build_capability_manifest \ + "$env_name" "$env_platform" "$pio_env_name" "$firmware_filename" \ + || return $? + # Post-build outputs differ by platform, so dispatch to the matching - # collector after the main firmware build succeeds. + # collector after the main firmware build and capability checks succeed. case "$env_platform" in ESP32_PLATFORM) collect_esp32_artifacts "$env_name" "$pio_env_name" "$firmware_filename" || return $? @@ -3606,8 +3721,6 @@ collect_build_artifacts() { ;; esac - write_build_capability_manifest \ - "$env_name" "$env_platform" "$pio_env_name" "$firmware_filename" } get_firmware_filename() { @@ -4248,7 +4361,12 @@ is_redundant_bulk_build_target() { # `build-matching-firmwares`, but do not republish binaries that differ only # by a saved/default setting, or roles already supplied by Full Companion. # Its text terminal supersedes standalone Terminal Chat on the same exact - # hardware, in addition to its combined attached transports. + # hardware, in addition to its combined attached transports. Deployed mOTA + # IDs are wire compatibility contracts, so their exact targets are the one + # intentional exception to functional artifact consolidation. + if is_deployed_lora_ota_compatibility_target "$1"; then + return 1 + fi if is_runtime_setting_alias_target "$1" \ || is_firmware_role_replaced_by_canonical_artifact "$1"; then return 0 @@ -4526,8 +4644,9 @@ configure_effective_build_profile() { AUTO_PREFER_FULL_BUILD=1 AUTO_REDUCED_FALLBACK_TARGET=$(get_reduced_lora_ota_target "$target" || true) elif is_lora_ota_no_external_sensors_target "$target"; then - # An explicitly selected reduced target means exactly what its - # name says; do not silently put omitted sensors back into it. + # An explicitly selected legacy-named reduced target keeps its + # exact declared driver trim; do not silently put omitted optional + # environmental/ranging drivers back into it. BUILD_PROFILE_EFFECTIVE="standard" elif reduced_target=$(get_reduced_lora_ota_target "$target"); then RESOLVED_BUILD_TARGETS[0]=$reduced_target diff --git a/docs/_javascript/firmware_picker.js b/docs/_javascript/firmware_picker.js index d55b58a2..10783510 100644 --- a/docs/_javascript/firmware_picker.js +++ b/docs/_javascript/firmware_picker.js @@ -501,6 +501,26 @@ }); } + function applyMergedRak4631RepeaterCapabilities(profiles) { + return (profiles || []).map(function (profile) { + const target = String(profile && profile.target || "").toLowerCase(); + if ( + target !== "rak_4631_repeater" && + target !== "rak_4631_repeater_lora_ota_no_external_sensors" + ) { + return profile; + } + + // The canonical RAK4631 repeater contains both the ordinary interface + // and the runtime-selectable RS232 bridge. Legacy Serial1/Serial2 target + // rows remain exact OTA compatibility assets; do not attach their files + // to this profile merely to expose the merged image's second mode. + profile.mode = "standard"; + profile.connectionModes = ["standard", "rs232"]; + return profile; + }); + } + function applyMergedStandardUsbLoggingCapabilities(profiles) { return (profiles || []).map(function (profile) { const safeRole = [ @@ -619,8 +639,10 @@ return !isHiddenLegacyProfile(profile); }); const profiles = applyMergedStandardUsbLoggingCapabilities( - omitTransportsReplacedByFull( - applyFullCompanionCapabilities(visibleProfiles) + applyMergedRak4631RepeaterCapabilities( + omitTransportsReplacedByFull( + applyFullCompanionCapabilities(visibleProfiles) + ) ) ).sort(function (a, b) { return a.target.localeCompare(b.target, undefined, { @@ -655,6 +677,9 @@ } function profileFieldValues(profile, field) { + if (field === "mode" && Array.isArray(profile.connectionModes)) { + return profile.connectionModes; + } if (field === "logging" && Array.isArray(profile.loggingModes)) { return profile.loggingModes; } @@ -714,10 +739,27 @@ rak15001: "RAK15001", qspi: "QSPI", }; - return value.split("-").map(function (token) { - return tokenLabels[token] || - token.charAt(0).toUpperCase() + token.slice(1); - }).join(" "); + const tokens = value.split("-"); + const labels = []; + for (let index = 0; index < tokens.length; index += 1) { + if ( + tokens[index] === "no" && + tokens[index + 1] === "external" && + tokens[index + 2] === "sensors" + ) { + // The legacy target token is an OTA identity, not a literal statement + // that the I2C bus and every external peripheral have been removed. + labels.push("Reduced optional environmental/ranging drivers"); + index += 2; + continue; + } + const token = tokens[index]; + labels.push( + tokenLabels[token] || + token.charAt(0).toUpperCase() + token.slice(1) + ); + } + return labels.join(" "); } function labelFor(field, value) { @@ -860,7 +902,12 @@ facts.className = "firmware-picker-facts"; const factRows = [ ["Target", profile.target], - ["Connection / mode", labelFor("mode", profile.mode)], + [ + "Connection / mode", + profileFieldValues(profile, "mode").map(function (mode) { + return labelFor("mode", mode); + }).join(" / "), + ], ["Logging", labelFor("logging", profile.logging)], ["OTA", labelFor("ota", profile.ota)], ["Feature profile", labelFor("feature", profile.feature)], @@ -1203,6 +1250,8 @@ parseFirmwareAsset: parseFirmwareAsset, parseTargetProfile: parseTargetProfile, applyFullCompanionCapabilities: applyFullCompanionCapabilities, + applyMergedRak4631RepeaterCapabilities: + applyMergedRak4631RepeaterCapabilities, applyMergedStandardUsbLoggingCapabilities: applyMergedStandardUsbLoggingCapabilities, applyDualCdcFullCompanionCapabilities: applyFullCompanionCapabilities, diff --git a/docs/cli_build_matrix.md b/docs/cli_build_matrix.md index 3bafe3cf..b9e29df2 100644 --- a/docs/cli_build_matrix.md +++ b/docs/cli_build_matrix.md @@ -68,13 +68,24 @@ retain 50 because their MQTT discovery tables are constrained by internal DRAM. | Standard non-MQTT repeater or room server | Keeps the normal role CLI and, where USB is a safe plaintext console, embeds debug/packet logging behind persistent `get/set usb.logging`. The explicitly selected portable policy can omit WebConfig and browser WiFi OTA, so those commands are unavailable and the omission is recorded in the capability manifest. | | Legacy standard logging | No longer emitted separately. Its behavior is compiled into the ordinary artifact. Size-constrained STM32 targets embed packet logging without verbose `MESH_DEBUG`. | | LoRa-OTA (`-ota-`) | LoRa OTA adds the `ota ...` commands; it does not otherwise reduce the role CLI. ESP32 `no_external_sensors` artifacts retain the compact browser WiFi uploader, the complete CLI, and a 254-entry neighbor table. | -| Internal-flash nRF52 repeater auto pair | `full-ota` retains the board's external-sensor drivers; `reduced-ota` omits the declared optional sensors to leave additional internal-flash staging room. RAK3401 and RAK4631 reduced builds retain INA219, INA226, INA260, and INA3221 I2C voltage/current monitors at a measured 4,808-byte flash cost. Both artifacts carry the same stable OTA target identity and are checked for `ota ...` and `retry.preset`; RAK artifacts also verify the retained monitor drivers. | +| Internal-flash nRF52 repeater auto pair | `full-ota` retains the board's external-sensor drivers; `reduced-ota` omits the declared optional sensors to leave additional internal-flash staging room. RAK3401 and RAK4631 reduced builds retain INA219, INA226, INA260, and INA3221 I2C voltage/current monitors at a measured cost below 5 KiB. Both artifacts carry the same stable OTA target identity and are checked for `ota ...` and `retry.preset`; RAK artifacts also verify the retained monitor drivers. | | ESP32 MQTT observer or ESP-NOW bridge | Always uses the expanded FULL partition profile. The build never substitutes a reduced CLI to fit the legacy application slot. | | FULL ESP32 USB + WiFi | Uses the matching MQTT target with packet logging on, verbose debug off, and the complete command surface supported by that role and hardware. `get/set logging.output off\|usb\|wifi\|both` selects and persists the active output paths. | | FULL ESP32 logging fallback | Uses the matching non-MQTT target only when no WiFi MQTT sibling exists, with debug and packet logging enabled and the complete command surface supported by that role and hardware. Its persistent USB gate also covers output-off operation, avoiding a second FULL ESP-NOW image. | | nRF52 dual-CDC Full Companion | Fresh installs expose only interface `00`; it starts as an ASCII terminal and automatically hands a complete `<` frame to framed Companion. The same interface also carries exclusive serial mOTA traffic. Enabling logging and rebooting adds interface `02` for plaintext logs. BLE and source-only LoRa OTA remain available. `get/set usb.logging` persistently controls whether the logging interface is present. | | ESP32 single-TTY Full Companion | Every ESP32 Full image starts with the ASCII terminal on its one USB TTY and automatically hands a complete `<` frame to framed Companion. `set usb.logging on` switches that TTY to an input-capable plaintext logging terminal and makes framed Companion unavailable on USB; `set usb.logging off` stops logging but leaves the TTY in normal ASCII mode. The terminal stop token or a valid incoming framed probe then performs the ordinary switch to Binary Companion. A saved logging-on setting starts directly in that logging terminal and disables automatic frame detection. BLE, WiFi, and source-only LoRa OTA remain available. ESP32 Full uses Arduino-ESP32 2.x where supported; RC32 and ESP32-C6 keep their board-required Arduino 3.x platform but still expose only one TTY. | -| `no_external_sensors` | Removes optional external-sensor drivers and their settings; it does not remove core repeater discovery, routing, or runtime RS-232 commands. RAK3401 and RAK4631 profiles retain the four common INA I2C voltage/current monitors. GPS-preserving RAK nRF52 OTA profiles retain their GPS commands and provider; RAK4631 defaults the bridge to UART 2 because GPS uses UART 1. Legacy target suffixes remain stable for OTA identity compatibility. | +| `no_external_sensors` | Trims selected optional environmental/ranging drivers and their settings; it does not remove generic I2C, core repeater discovery, routing, or runtime RS-232 commands. RAK3401 and RAK4631 profiles retain the four common INA I2C voltage/current monitors. GPS-preserving RAK nRF52 OTA profiles retain their GPS commands and provider; RAK4631 defaults the bridge to UART 2 because RAK12501/L76K GPS uses UART 1. Legacy target suffixes remain stable for OTA identity compatibility. | + +The four retained INA drivers are entries in the optional environmental-sensor +table, not the complete set of RAK I2C consumers. Compatible reduced profiles +also retain the SSD1306 OLED, supported autodiscovered RTCs, and RAK12500 I2C +GPS as separate board peripherals. RAK12501/L76K GPS uses UART Serial1. The +explicit RAK4631 Serial1 bridge omits the combined GPS provider, including the +otherwise non-UART RAK12500 path. + +The firmware-configured INA3221 and RAK12500 addresses are both `0x42`. To use +both devices on one bus, leave RAK12500 at `0x42`, strap INA3221 A0 to SCL for +`0x43`, and build with `-DTELEM_INA3221_ADDRESS=0x43`. `logging`, `OTA`, and `FULL` describe independent build features in historical filenames. Current standard artifacts use no `-logging-` infix because their diff --git a/docs/cli_command_availability.md b/docs/cli_command_availability.md index 7742a79d..dbf9f74d 100644 --- a/docs/cli_command_availability.md +++ b/docs/cli_command_availability.md @@ -15,8 +15,16 @@ Build columns mean: filename marker. Safe plaintext-USB roles embed runtime USB logging. - **Logging** - the legacy `-logging-` profile, now represented by the same ordinary artifact and retained as a comparison column only. -- **LoRa OTA** - the explicit `-ota-` repeater or repeater-bridge artifact. Its - optional external-sensor drivers are removed, but onboard GPS is retained. +- **LoRa OTA** - the explicit `-ota-` repeater or repeater-bridge artifact. A + legacy `no_external_sensors` profile trims selected optional environmental + and ranging drivers; it does not disable the generic I2C bus or unrelated + board-integrated peripherals. Reduced RAK3401 and RAK4631 profiles retain + INA219/INA226/INA260/INA3221 as voltage/current entries in the optional sensor + table. The SSD1306 OLED, supported autodiscovered RTCs, and RAK12500 GPS are + separate I2C peripherals in compatible recipes. RAK12501/L76K GPS instead + uses UART Serial1. The plain RAK profiles and RAK4631 Serial2 bridge retain + the combined GPS provider; the explicit RAK4631 Serial1 bridge omits that + provider, including RAK12500, because the bridge owns the RAK12501 UART. - **FULL unified** - the expanded-partition ESP32 artifact with LoRa OTA, the complete parser, USB packet logging, and direct WiFi MQTT. Its saved output mode can be off, USB, WiFi, or both. MQTT observers use this profile. @@ -24,6 +32,11 @@ Build columns mean: where there is no MQTT sibling. It has the complete parser and a saved USB-logging on/off gate. ESP-NOW bridges use this profile. +The firmware-configured INA3221 and RAK12500 addresses are both `0x42`; they +cannot coexist on one bus at those addresses. Leave RAK12500 at `0x42`, strap +INA3221 A0 to SCL for `0x43`, and use a build with +`-DTELEM_INA3221_ADDRESS=0x43` when both are installed. + Cell values mean: - **Yes** - the parser includes the command. The Scope column still applies. @@ -165,11 +178,11 @@ should use command `0x42`. See [Companion radio binary protocol](companion_proto | GPS | [`gps setloc`](cli_commands.md#set-this-nodes-location-based-on-the-gps-coordinates) | Compiled GPS | Feature | Feature | Feature | | GPS | [`gps advert [none/share/prefs]`](cli_commands.md#view-or-change-the-gps-advert-policy) | Compiled GPS | Feature | Feature | Feature | | Sensors | [`get/set telemetry.access`](cli_commands.md#view-or-change-telemetry-access-mode) | Sensor-capable role | Feature | Feature | Limited | -| Sensors | [`sensor list [start]`](cli_commands.md#view-the-list-of-sensors-on-this-node) | Compiled sensor manager; OTA omits external sensors | Feature | Feature | Limited | -| Sensors | [`sensor get`; `sensor set`](cli_commands.md#view-or-change-the-value-of-a-sensor) | Compiled sensor setting; OTA omits external sensors | Feature | Feature | Limited | +| Sensors | [`sensor list [start]`](cli_commands.md#view-the-list-of-sensors-on-this-node) | Compiled sensor manager; reduced OTA trims target-selected optional environmental/ranging drivers | Feature | Feature | Limited | +| Sensors | [`sensor get`; `sensor set`](cli_commands.md#view-or-change-the-value-of-a-sensor) | Compiled sensor setting; reduced OTA trims target-selected optional environmental/ranging drivers | Feature | Feature | Limited | | Sensors | [`io [r/s/t]`](#sensor-io) | Simple sensor role with board GPIO support | Feature | Feature | No | | Bridge | [`get bridge.type`](cli_commands.md#view-the-compiled-bridge-type) | Compiled bridge | Feature | Feature | Feature | -| Bridge | [`get/set bridge.enabled`](cli_commands.md#view-or-change-the-bridge-enabled-flag) | Compiled bridge | Feature | Feature | Feature | +| Bridge | [`get/set bridge.enabled`; `get bridge.running`](cli_commands.md#view-or-change-the-bridge-enabled-flag) | Compiled bridge | Feature | Feature | Feature | | Bridge | [`get/set bridge.delay`](cli_commands.md#add-a-delay-to-packets-routed-through-this-bridge) | Compiled bridge | Feature | Feature | 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 | | 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 | @@ -332,11 +345,11 @@ should use command `0x42`. See [Companion radio binary protocol](companion_proto | 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 | [`sensor list [start]`](cli_commands.md#view-the-list-of-sensors-on-this-node) | Compiled sensor manager; `no_external_sensors` trims target-selected environmental/ranging drivers, not generic I2C | 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` trims target-selected environmental/ranging drivers, not generic I2C | 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.enabled`; `get bridge.running`](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 | diff --git a/docs/cli_commands.md b/docs/cli_commands.md index 3b639cd9..83aebac5 100644 --- a/docs/cli_commands.md +++ b/docs/cli_commands.md @@ -3427,12 +3427,16 @@ still overrides the first-boot default after an update. #### View or change the bridge enabled flag **Usage:** - `get bridge.enabled` +- `get bridge.running` - `set bridge.enabled ` **Parameters:** - `state`: `on`|`off` -**Default:** `off` +`bridge.enabled` is the saved intent. `bridge.running` reports whether the +bridge actually started in this boot; they can differ after a hardware conflict, +missing credentials, or a transient initialization failure. Normal merged +repeater images default to `off`; dedicated bridge images may default to `on`. --- @@ -3455,10 +3459,10 @@ still overrides the first-boot default after an update. **Parameters:** - `source`: - - `logRx`: bridges received packets - - `logTx`: bridges transmitted packets + - `rx`: bridges received packets + - `tx`: bridges transmitted packets -**Default:** `logTx` +**Default:** `tx` > **Note:** For MQTT bridges, use `mqtt.rx` and `mqtt.tx` instead of `bridge.source`. These provide independent per-direction control and support both RX and TX simultaneously. `bridge.source` still works as a convenience alias for MQTT (setting `bridge.source rx` sets `mqtt.rx on` + `mqtt.tx off`, and vice versa), but `mqtt.rx`/`mqtt.tx` are preferred. @@ -3579,11 +3583,20 @@ Requires WiFi connected and the MQTT bridge running. **Parameters:** - `port`: Hardware UART number compiled for the board. Most boards expose one fixed UART. RAK4631 accepts `1` or `2`; UART 2 is the default so UART 1 can - remain available to GPS. + remain available to the RAK12501/L76K GPS. RAK12500 GPS uses I2C rather than + this UART, but the explicit legacy Serial1 bridge omits the combined GPS + provider and therefore does not expose either GPS path. The setting is persistent and restarts an enabled bridge immediately. Normal repeater artifacts start with `bridge.enabled off`; configure the UART and baud -rate before running `set bridge.enabled on`. +rate before running `set bridge.enabled on`. On the canonical RAK4631 runtime +image, UART 1 is reserved even if the bounded boot probe hears no RAK12501. +Silence cannot prove that a cold L76K is physically absent, and that module +remains powered by the shared WB_IO2/3V3_S rail. Use UART 2. UART 1 requires an +explicit no-GPS/dedicated Serial1 bridge image. This fail-closed reservation +also applies when the detected GPS is an I2C RAK12500; that receiver does not +electrically use UART 1, but its presence cannot rule out another silent UART +module. --- diff --git a/docs/faq.md b/docs/faq.md index 80eb2e94..70703df8 100644 --- a/docs/faq.md +++ b/docs/faq.md @@ -787,6 +787,29 @@ the PIN page until Bluetooth connects or the two-minute pairing window expires. 8. The device is erased and newest firmware is installed 9. You might need to remove the pairing in Bluetooth System Settings in order to re-pair the app again. +An erase application or ordinary application ZIP/UF2 does **not** repair a +damaged or partially overwritten SoftDevice. This can happen after flashing an +nRF52 image for a different board or SoftDevice layout. A characteristic +failure is that the application can boot and read its filesystem, but reboots +or hangs when it writes or formats internal flash. Repeating `Erase Flash` is +not a recovery for that condition. + +Recover it with a combined **bootloader + SoftDevice** package for the exact +board (including XIAO Sense versus non-Sense), installed through serial/BLE DFU +or SWD. With `adafruit-nrfutil`, the serial form is: + +```bash +adafruit-nrfutil dfu serial -p \ + -pkg .zip -sb -t 1200 +``` + +Then install the exact-board MeshCore application. Do not use an application- +only UF2, filesystem formatter, or a similarly named package from another +nRF52 board as a substitute. See the confirmed XIAO recovery in +[issue #3284](https://github.com/meshcore-dev/MeshCore/issues/3284) and obtain +the matching package from the +[OTAFIX releases](https://github.com/oltaco/Adafruit_nRF52_Bootloader_OTAFIX/releases). + Separately, starting in firmware version 1.7.0, there is a CLI Rescue mode. If your device has a user button (e.g. some RAK, T114), you can activate the rescue mode by holding down the user button of the device within 8 seconds of boot. Then you can use the 'Console' on ### 6.8. Q: WebFlasher fails on Linux with failed to open diff --git a/docs/firmware_picker.md b/docs/firmware_picker.md index 2442e4e6..ade4f782 100644 --- a/docs/firmware_picker.md +++ b/docs/firmware_picker.md @@ -257,10 +257,21 @@ Hardware families with multiple released targets get a second hardware-variant menu. It separates revisions, display type, expansion kit, radio/PA layout, pin map, and other physical differences without crowding the first menu. The firmware-variant menu separately exposes choices that still require different -code or wiring, such as serial port or no-external-sensors. Companion power -saving, controllable FEM receive gain, and radio-chip receive gain are saved -settings rather than separate recommended firmware files. Do not substitute a -similarly named physical target. +code or wiring, such as serial port or the legacy `no_external_sensors` target +suffix. The picker labels that suffix **Reduced optional environmental/ranging drivers**: +it does not disable generic I2C or unrelated board-integrated peripherals. +Reduced RAK3401 and RAK4631 targets retain INA219/INA226/INA260/INA3221 as +voltage/current entries in the optional sensor table. They are not the only I2C +users: the SSD1306 OLED, supported autodiscovered RTCs, and RAK12500 GPS remain +separate I2C peripherals in compatible recipes. RAK12501/L76K GPS uses Serial1 +instead. The explicit RAK4631 Serial1 bridge omits the combined GPS provider +because its bridge owns the RAK12501 UART, so that legacy image does not expose +RAK12500 either. The firmware-configured INA3221 and RAK12500 addresses are both +`0x42`; to install both, keep RAK12500 at `0x42`, strap INA3221 A0 to SCL for +`0x43`, and use a build with `-DTELEM_INA3221_ADDRESS=0x43`. Companion power saving, +controllable FEM receive gain, and radio-chip receive gain are saved settings +rather than separate recommended firmware files. Do not substitute a similarly +named physical target. The picker recommends one Full Companion image instead of separate USB, BLE, ordinary WiFi, and USB-logging images. On ESP32, logging is off by default so diff --git a/docs/hardware_validation_checklist.md b/docs/hardware_validation_checklist.md new file mode 100644 index 00000000..67d10fa1 --- /dev/null +++ b/docs/hardware_validation_checklist.md @@ -0,0 +1,1001 @@ +# Hardware validation checklist + +Use this checklist for release-candidate hardware runs. A check is complete only +when its log identifies the physical device, firmware artifact, artifact hash, +command result, and cold/warm boot outcome. Do not infer success from a tool's +exit code when the tool has a documented false-success mode. + +## Current marathon ledger (2026-08-30) + +| Hardware | Stable identity | Current state | Next blocking check | +| --- | --- | --- | --- | +| Seeed XIAO nRF52840 | `B35E71C1C3726CE7` | Exact Full Companion application and latency-disable bootloader candidate installed. An exact bonded B-A-B link-profile test passed at 6.225, 3.343, and 6.231 kB/s for 15 ms, untouched 30 ms control, and 15 ms repeat; CRC, activation, target disconnect, and stable USB return passed three times | Power-removal cold test, then exercise BLE-controlled LoRa OTA | +| Seeed Tracker T1000-E | `34A9141999729D5D` | Later connection-policy-reapply OTAFIX candidate and temporary Full Companion are running; the exact bonded warm baseline passed at 3.34 kB/s. Two lab-only pre-START 15 ms/latency-0 DFUs with `0/0` no-preference event-length hints passed at 6.23 and 6.52 kB/s; a 10 ms event-length control timed out before START | Prove installed bootloader bytes by readback, exercise a physical cold 20-to-244-byte readiness transition, restore the protected Repeater identity, cold boot, then force LR1110 reset | +| RAK3401 | `0B81C9C68D8D01B4`; FICR `8D8D01B4 0B81C9C6` | Direct V4 LoRa OTA from `bf092b24` to exact `3caf9dcf` passed. OTAFIX test version `0x02040403` is installed; bidirectional application UF2, exact SWD application/bootloader readback, and unchanged UICR all pass | Add RF packet counters, then repeat bandwidth and routed-hop matrix | +| Heltec MeshTower V2 with SD | `9352162A72082314` | Exact SD LoRa-OTA Repeater `e26d48e4` is running after identity-gated BLE recovery. Card format/cooldown/forced-format/raw-erase/remount pass; live bootloader contract reports ABI 3, FULL+INPLACE, and SD apply | Signed interrupted-download resume, corruption/signature rejection, then full/delta apply | +| Heltec V4 | USB MAC `44:1B:F6:6A:E8:44`; BLE `44:1B:F6:6A:E8:45` | USB/BLE/Wi-Fi and all three TCP services pass on hardware. The final fresh-NTP-gated Full Companion image builds with 4.31 MB app space free | Flash the exact final NTP build, prove NTP-before-TLS success/failure ordering, then OTA seeding | +| SenseCAP Indicator LoRa | CH340 plus USB MAC `D8:3B:DA:75:23:AC`; BLE `D8:3B:DA:75:23:AD` | Exact 2,145,640-byte Full Companion font-recovery application, SHA-256 `88d8d8ae138b297fc7841005ab40c303e1efe9d27a2bb4a89a30735748a073c4`, is installed; identity-gated flash, USB ASCII/Binary switching, runtime logging, configured Wi-Fi, BLE, all three TCP services, fresh-NTP-gated HTTPS recovery, strict Range resume, STAGEV2 install, and exact RP USB readback pass | Physical display-wrap check, LoRa/TempRadio, and OTA seeding | + +The RAK failure log is +`/home/mikec/hwtest/runs/rak3401-syncidle-clean-tSp7PLgA/result.txt` on +the Mercerwood Pi. The tested UF2 SHA-256 was +`650c9fda456685cdf99c1f2996c60f1f551cc9216d2b746a22c8cb78464b0ff7`. +`cp` returned success in 95 ms, but `sync -f` failed with `EIO` after +157,188 ms. Post-failure SWD readback proved that all seven ordinary sectors +whose bytes differed between the old and intended builds still contained the +old values, and the settings page was still erased. The only available +pre-copy SWD dump was taken several hours earlier, however, so it cannot prove +that zero unchanged blocks were written during this attempt or establish when +the stale S140-v7 tail in the first application page appeared. The next run +must capture an immediate pre-copy dump as well as the post-copy dump. Serial +DFU recovery then completed in 35,698 ms with the exact `Device programmed.` +marker. All 1,830 UF2 payload blocks matched SWD readback after recovery, and +the application passed a subsequent real power-removal boot. + +The corrected 2.4.4-preview.2 candidate was installed through identity-gated +SWD from combined-image SHA-256 +`9b1654baccd2a920f68651893e31d79170400d1a7982a3f854ae640f1d3513a8`. +An immediate pre-copy dump closed the earlier evidence gap. A deliberately old +UF2 then copied and flushed successfully in 16,568 ms, and the exact formerly +failing UF2 above copied and flushed successfully in 16,601 ms. After each +direction, all 1,830 payload blocks matched a full SWD readback with zero header +or payload mismatches. The return update changed 123 bytes across five expected +application pages, UICR remained byte-identical, and a subsequent downstream +USB power removal plus Pi reboot returned the RAK as the same stable identity. +It reported the intended `3caf9dcf` application, board, node name, USA radio +tuple, and 22 dBm setting after that cold boot. The new bootloader's CLI source +string is truncated before the injected preview number, so the independently +hashed SWD artifact is the authoritative candidate identity for this run. + +The later TinyUSB lifecycle/generation candidate identifies itself as test +version `0x02040403`. Its exact 40,960-byte bootloader readback SHA-256 is +`f609eb18754070c54f127325b1cb48547baf5c6b1c695d36fd0c8e0874713c13`. +With that candidate installed, a full `CURRENT.UF2` read completed before each +write. The 936,960-byte `bf092b24` application UF2 (SHA-256 +`12477a9734cf12eb920361bd21024e39bf303953fcf79d0bf720e00847e2ae92`) +copied and flushed in 28,203 ms and returned to application mode four seconds +later. The exact `3caf9dcf` application UF2 (SHA-256 +`650c9fda456685cdf99c1f2996c60f1f551cc9216d2b746a22c8cb78464b0ff7`) +then copied and flushed in 26,479 ms and also returned in four seconds. Both +boots retained `RAK3401-OTA-BENCH` and `910.525/62.5/SF7/CR5`. + +Identity-gated SWD then read the entire 468,480-byte padded UF2 application +range. Its SHA-256 exactly matched the converted source on both sides at +`b2e37a7681027ca91d1b2bd8e39b55395d9e91d4863aed17131e50c1ba1d2227`. +The bootloader still matched the installed `0x02040403` image byte-for-byte, +and UICR still matched the pre-test image at +`d029ea630c2f632a1b690cb52b2a96a28b6f870c656c3e1bd74ba3a571c53b9c`. +The authoritative logs are under +`/home/mikec/hwtest/runs/rak3401-uf2-02040403-20260830-170829` and +`/home/mikec/hwtest/runs/rak3401-uf2-return-02040403-sfHiGEqA` on Mercerwood. + +The subsequent direct LoRa qualification used the V4 as controller/seeder and +the RAK's exact `bf092b24` body `91CF4EC2882F23D6` as the base. Its mandatory +fixed three-minute rehearsal passed on `909.95/250/SF5/CR5`, including natural +expiry and exact normal-radio recovery. The 2,132-byte, two-block in-place +delta reached `2/2` and ready in six seconds; install was accepted, and the new +body appeared at the 60-second reboot probe. The automation took 514 seconds +including rehearsal, clock/radio safety checks, and post-install verification. +The final body was `27A223DDFC3A8F47`, version `1.17.1.5` at `3caf9dcf`, and +the bootloader reported `blrc:B8`, which is OTAFIX's successful application +apply result rather than an error. Exact target TX power was wrapped from +22 dBm to 0 dBm for the co-located transfer and restored to 22 dBm afterward; +the source RXPS configuration and both normal radio tuples were also restored. + +Identity-gated SWD then read the live application at `0x26000`. All 468,384 +bytes matched the intended release image exactly (SHA-256 +`b496e3f4419c9f771564ed3c74b3407822f45acdf60cbd6c4625a54b181ca084`), +and the independently recomputed EndF hash was `27A223DDFC3A8F47`. The target +ID was `2FA509C1`, hardware ID was `RAK_3401`, and version word decoded to +`1.17.1.5`. The 40,024-byte bootloader readback also matched the exact installed +`ffb1580`/test-version-`0x02040402` artifact byte-for-byte; UICR was unchanged. +After SWD reset-halt/resume, USB again reported the expected application, +identity, name, radio, 22 dBm, no TempRadio lease, and `blrc:00` (the retained +success diagnostic is intentionally cleared by the next normal boot). + +A serialized VM build of the six connected-board qualification profiles then +completed 6/6 in 136.122 seconds: XIAO nRF52 BLE Companion, T1000-E BLE +Companion, RAK3401 LoRa-OTA Repeater, SenseCAP Indicator LoRa USB/Wi-Fi +Companion, MeshTower V2 SD LoRa-OTA Repeater, and the consolidated Heltec V4 +V4.2/V4.3 Full Companion. The earlier V4 selector typo was rejected before any +compile began and is not a firmware build failure. + +After the fresh-NTP download gate, shared SNTP coordinator, MQTT-to-MeshCore +RTC handoff, and daily refresh policy were added, the complete native matrix +passed 1,102/1,102 cases in 93.953 seconds. The scheduler is host-tested at +24-hours-minus-one-millisecond, the exact 24-hour boundary, normal and wrapped +`millis()` values, a short retry that overrides the daily cadence, the zero +deadline sentinel, and a Wi-Fi reconnect. A reconnect schedules one fresh +sample and clears an arbitrarily old short-retry deadline; while continuously +connected, successful samples are requested only once per day. All current +Python and shell contract suites passed, and the expanded LoRa-OTA automation +suite passed 317/317 cases. + +Serialized real builds also passed after that change. The final event-safe +implementation uses an atomic `GOT_IP` latch so even a disconnect/reconnect +shorter than the 10-second MQTT status sample cannot skip the required fresh +NTP request. The final Heltec V4 MQTT Observer application is 1,853,304 bytes +with SHA-256 +`1938ae88fcdbe601cf8698df44b548656079562d899801886b808af913e5f9ac`. +The consolidated Full V4 image used 2,247,757/6,553,600 flash bytes and left +4,305,368 bytes of app-image space. Its 2,248,232-byte application image has +SHA-256 +`2547bb245349a500b7c9a112ac25bcec91ce70733c648ee0ff9ac7de3176da1a`; +the 2,313,768-byte merged image has SHA-256 +`1f89ff127d50f06c3e3ce11c642611e079dae74772f169da9f2e4643da55a797`. +The focused daily-NTP policy suite passed all 38 cases after the latch was +added, and the Full capability manifest verified all ten expected features. +The reduced RAK3401 LoRa-OTA Repeater also compiled with the verified WisBlock +I2C aliases. At that source state its link used 471,344/815,104 flash bytes; the +reduced external-sensor contract retains INA219/INA226/INA260/INA3221 as the +voltage/current entries in the optional environmental-telemetry table. These +are not the image's only I2C consumers. The image separately retains its +SSD1306 display, auto-discovered I2C RTCs, +RAK12500 I2C GPS, and RAK12501/L76K UART GPS paths. The configured INA3221 and +RAK12500 both default to I2C address `0x42`; those two devices cannot coexist +at those addresses. The supported combined arrangement leaves RAK12500 at +`0x42`, straps INA3221 A0 to SCL for `0x43`, and uses a build with +`-DTELEM_INA3221_ADDRESS=0x43`. +The nRF52840 die-temperature and ADC battery-voltage paths are also retained. +The 472,227-byte DFU ZIP SHA-256 is +`fdd369005a61635756efa9e38b217376a952f334b0864f927a46799b65264fa9`, +and the matching 943,104-byte UF2 SHA-256 is +`2155632fde730e0ae2e4a72ce64f89910861f6c5105a4bd7ac96aaa538a33dca`. +These VM results do not replace the remaining exact-final hardware test with +UDP/123 blocked. + +The later serialized RAK3401 contract build, after the non-blocking nRF52 USB +logging change, used 471,576 bytes before its 56-byte EndF trailer. Its EndF +body hash is `026749684bf6ee8f`, target ID is `2FA509C1`, hardware ID is +`RAK_3401`, and version word is `0x01110105`. The 472,435-byte DFU ZIP has +SHA-256 +`cf0880b26f2d6bd8157f8af76a6718fe1f998de8ea7df8cd952e734448201fb9`; +the 943,616-byte UF2 has SHA-256 +`f893a8cebf309af12a4a6f5483acb28f94158e18632f660e011658c846e8ca7f`. +The linked ELF contains GPS, SSD1306, RTC, and the four INA families, while an +exact symbol audit finds none of AHT10/20, BME280, BMP280, SHTC3, SHT4X, +LPS22HB, MLX90614, VL53L0X, BME680/BSEC, BMP085, or RAK12035. The complete +native matrix passed 1,112/1,112 cases at that intermediate source state. These +are VM gates only; flash the exact artifacts and repeat the physical peripheral +and blocked-clock checks before marking any later source state +hardware-qualified. + +Subsequent source hardening changed RAK peripheral discovery: the shared 3V3_S +rail remains enabled, UART GPS requires a complete checksum-valid NMEA sentence, +an exact INA3221 identity blocks a conflicting u-blox probe, and a successful +I2C GPS claim suppresses a sensor only on the same bus and address. The hashes +and sizes above describe their recorded intermediate artifacts; they are not +current-HEAD evidence and must not be reused for the hardened source. + +The immutable font endpoint was re-probed only after the VM reported +`NTPSynchronized=yes`. GitHub returned HTTP 206 for bytes `0-65535`, an exact +65,536-byte match to the checked-in font (range SHA-256 +`8b0dd308eb01469d1f9e732fba260720a80cc86f6cb5385c28e8a16c76dcfbf3`), +with total length 1,302,608 and strong ETag +`"39ff0cfbe37e36905507697d042869c05374c3202dd96d5a9f98812153f324c1"`. +This validates the current strict Range assumptions against the live service; +the exact-final Indicator still needs the negative hardware run proving that +blocked UDP/123 prevents every TLS request and RP2040 staging write. + +Mercerwood subsequently recovered without a target write. Both its Tailscale +and LAN addresses again accepted SSH, Bluetooth was idle, and the exact T1000-E +stable serial `34A9141999729D5D` was present as application USB `239a:8029`. +The RAK3401 simultaneously uses the same VID/PID, which confirms that every +continuation must resolve the stable serial/by-id identity rather than VID/PID. +The T1000-E remained in the exact bonded Full Companion application; no stale +DFU, scanner, `meshcli`, or HCI-capture process was running. + +## Run-wide gates + +- [ ] Record commit, version string, role/profile, artifact filename, size, and + SHA-256 before flashing. +- [ ] Archive the exact ELF and symbol/objdump map beside each qualification + firmware. A rebuilt ELF is acceptable for diagnosis only after its + firmware binary matches byte-for-byte; compiler `__DATE__`/`__TIME__` + strings can otherwise change both bytes and the EndF body hash. +- [ ] Bind every operation to a stable USB serial number or physical USB path; + never rely on a changing `ttyACM`/`ttyUSB` number. +- [ ] After erasing/reflashing a BLE board that reuses its Bluetooth address, + remove any stale host bond and pair again with the board's current PIN + before diagnosing service-discovery disconnects. On Linux, verify the + BlueZ agent actually accepted the passkey; a scanner seeing the device is + not proof that Companion GATT is usable. +- [ ] Do not use BlueZ's advertisement `service_uuids` as a same-address warm + handoff identity gate. After application/bootloader transitions BlueZ can + merge the bootloader's cached GATT UUIDs into the live application's + advertisement. Gate the application advertisement by exact address and + name, then require the live connected BLEDfu service, control/revision + characteristics, retained bond/encryption, notification subscription, + and observed target disconnect before accepting the handoff. +- [ ] Do not treat `meshcli -P` as an infallible pairing probe. Immediately + after one successful post-DFU session, a second invocation with the exact + paired XIAO address hit the meshcore-cli `NoneType ... pair/disconnect` + path before it created a Bleak client. BlueZ still reported the exact + bond and the same command without `-P` connected and synchronized the + clock successfully. Capture this as a host-tool failure, then verify the + device independently before blaming firmware. +- [ ] After a successful `BleakClient.connect()`, start a fresh disconnect + generation before beginning DFU. BlueZ can fail one internal LE attempt, + retry successfully, and still deliver the failed attempt's callback. A + stale event caused the host to stop notifications and issue a local HCI + disconnect after a valid image CRC but before ACTIVATE; the target and + bootloader had not dropped the link. Preserve an HCI trace through + activation so local-host and remote-target disconnects remain + distinguishable. +- [ ] Before a BLE pairing/throughput test on a Wi-Fi-managed Pi, record both + management addresses and a same-LAN recovery vantage point. The XIAO + pairing run completed, but Mercerwood then stopped answering on both its + Wi-Fi and Tailscale addresses; BellevueBBS confirmed the old LAN address + was absent at ARP and no replacement LAN host exposed SSH. This is an + infrastructure outage until Pi logs prove a cause, not a firmware or BLE + failure. The T1000-E pairing run reproduced the management outage: its + exact application paired successfully and exposed the complete DFU + service, then Mercerwood stopped accepting SSH and ultimately stopped + answering Tailscale pings. No buttonless DFU write had been sent, and the + T1000 remained in its valid application. Capture Pi Wi-Fi/firmware logs + after recovery and avoid leaving discovery enabled between operations. +- [ ] Confirm the selected board and role before every erase or flash. +- [ ] Before every SWD write, read the target FICR device ID and match it to the + intended board's stable USB serial (and physical wiring record). A USB + product name or remembered cable position is not sufficient. +- [ ] Confirm no other PlatformIO process is active; run only one PlatformIO + build, test, clean, or upload at a time. +- [ ] Record the installed bootloader, SoftDevice, partition table, or flash + layout independently of the application banner where applicable. +- [ ] Test both a software reboot and a real power removal/cold boot. +- [ ] After an update, read back identity, version, radio tuple, node name, and + persistent settings instead of assuming they survived. +- [ ] Save command output and elapsed milliseconds. Mark a test `FAIL`, not + `PASS`, if the transport reports an I/O error even when firmware later + boots. +- [ ] Before calling a node hung, validate that the probe uses that profile's + command terminator, check a second transport, and capture PC/LR/watchdog + state over identity-gated SWD before reset. Resume once and retest before + using reset as recovery so the diagnostic state is not destroyed first. +- [ ] Treat a remote login acknowledgement and the following command reply as + separate LoRa packets. A lost login acknowledgement is not proof of + failed authentication when a command-matched private reply from the + selected contact key is present; an explicit negative login result still + fails closed. +- [ ] Restore the intended USA radio preset and normal-radio mode after tests. +- [ ] Compare both semantic version and exact EndF/body hash. A transition + between two commits carrying the same version string must stop at the + normal reinstall guard unless the run plan explicitly records and passes + `--allow-non-upgrade`; a different commit name alone is not authorization. +- [ ] Do not allow a non-interactive transmission-failure prompt to continue + retry cycles forever. Optional capability probes must fail/fall back after + a bounded interval, while mutations and cleanup retain their stronger + bounded-lease recovery rules. +- [ ] Before the first remote command after any controller/source reboot, read + its RTC and compare it with a trusted host clock. Advance a clock which is + behind and prove readback; never move a radio clock backward. Fail closed + when it is more than 10 minutes ahead. During the RAK rehearsal the V4 + rebooted to `2026-03-01` while the RAK remained at `2026-08-30`; the RAK + received the direct packets but correctly rejected their old timestamps + as replays. Setting the V4 to host epoch immediately restored login and + the exact public-key reply at SNR -1.5 dB. +- [ ] Before every external HTTPS download, require a fresh SNTP response for + that bounded operation, a plausible signed wall clock, connected Wi-Fi, + and a non-expired proof immediately before opening TLS. Re-check the proof + before every resumed/Range TLS connection. A retained plausible RTC or + mesh time is not a substitute for observing NTP, and failure must occur + before any HTTP request body or flash writer begins. +- [ ] Treat an unacknowledged immediate `tempradio` mutation as potentially + active, not merely lost. On the tested repeater, scheduled-radio expiry + deliberately waits for `hasOutbound()` to clear; a CLI reply stranded on + the temporary tuple can therefore hold the node there beyond its nominal + lease. Prefer one fixed absolute `tempradioat` window per node, so duplicate + delivery cannot extend it, and prove the return on both tuples before a + transfer. If recovering an older immediate handoff, join the exact + temporary tuple to drain the reply before issuing `normalradio`. +- [ ] Do not diagnose a co-located high-power LoRa bench failure as weak RF + without an overdrive check. On the RAK3401/V4 bench, the RAK at 22 dBm + produced only 3/6 status replies around -1.5 dB SNR, while a reversible + 0 dBm setting produced 6/6 replies in 5--8 seconds at +12 to +12.5 dB + SNR. Read the original power, lower it only for the near-field test, and + require exact restoration afterward. + +### Compute-placement gate + +- [ ] Run firmware builds, PlatformIO, mOTA/delta generation, compression, + full-image hashing sweeps, symbol generation, and other sustained CPU or + memory work on the VM. Do not offload these jobs to a Pi merely because + the hardware and source artifacts are attached there. +- [ ] Use the Mercerwood Pi as the identity-gated hardware gateway, SWD/USB/BLE + test host, and LoRa/Wi-Fi seeder. Copy only the completed artifact plus + its manifest/hash to the Pi, then verify the copied SHA-256 before use. +- [ ] Run CPU work on a Pi only when Pi performance or deployment behavior is + itself the test subject, and label that exception in the run log. +- [ ] Do not assume a non-interactive SSH session has the same `PATH` as an + interactive shell. Resolve pipx-installed tools such as esptool and + detools to their explicit venv path, record the version, and fail before + erase if the pinned executable is unavailable. +- [ ] Treat short serial/BLE/SWD control scripts on the Pi as hardware I/O, not + as permission to generate artifacts there. Use the explicit Python from + the relevant pipx environment when a probe needs one of its dependencies; + the Pi system Python did not contain `pyserial` during the RAK run. + +### Mercerwood Pi USB power-cycle gate + +- [ ] Confirm the Pi is reachable through Wi-Fi/Tailscale and that `eth0` is not + the active management path before cycling its downstream USB tree. +- [ ] Stop all flashes/transfers, run `sync`, and release SWD GPIO 8 and GPIO 11 + to inputs with no pulls before removing USB power. This prevents an SWD + signal from partially back-powering an otherwise unpowered nRF52. +- [ ] Never issue a standalone `uhubctl ... -a off` to Pi root hub location `1`. + Once its only port is off, the hub disappears from `uhubctl` discovery and + a separate `-a on` command fails with `No compatible devices detected at + location 1!`. This has interrupted two hardware runs. +- [ ] A downstream hub may be cycled only after resolving the board's stable + USB serial to an exact sysfs path and proving that hub advertises + per-port power switching. Record the hub location and port, cycle only + that port, and gate the re-enumerated VID/PID/serial before continuing. + The T1000-E qualification safely used child hub `1-1.2`, port 3; this is + not permission to cycle the ganged root hub at location `1`. +- [ ] Do not use `uhubctl` `off`, `on`, **or** atomic `cycle` on Mercerwood root + hub location `1`. Physical testing showed that even one atomic `cycle` + powers the ganged root hub down and then cannot rediscover it to restore + power. Use a planned Wi-Fi-issued `sudo reboot`, which is the verified + recovery, or arrange a true upstream VBUS removal when that distinction + is part of the test. +- [ ] Treat root hub `1` as a ganged operation: every attached test board and USB + Ethernet adapter loses power. After recovery, enumerate every stable + `/dev/serial/by-id` identity again before resuming any board-specific test. +- [ ] On this heavily populated root hub, inspect kernel logs for real transfer + faults before and after a flash, and stop ModemManager while it could open + a test TTY. A CH340/ESP32-S3 write which lost the chip at 460800 baud + completed and verified at 115200 after a planned Pi reboot; start at the + board-qualified conservative rate rather than treating a fast partial + transfer as firmware failure. +- [ ] Under non-interactive SSH, do not let `udisksctl` fall through to an + interactive polkit password prompt. After resolving the exact UF2 block + device by board serial, either use an already-mounted volume or an + explicit `sudo -n mount` at a board-specific mount point; re-check + `INFO_UF2.TXT` model/version before copying. + +## Seeed XIAO nRF52840 + +- [ ] Stable USB identity distinguishes application (`2886:8044`) from UF2 + bootloader (`2886:0044`). +- [ ] Do not repeat the known-failing full-application UF2 copy under the + installed initial 2.4.4 candidate. The identity-gated 897,024-byte copy + returned exit 0, then `sync -f` failed with `EIO` in 850 ms; the kernel + logged an offline device, lost queued writes, and FAT errors before USB + disappeared. Recover through a separately hash-gated BLE/serial path and + qualify a corrected bootloader candidate before retrying UF2. +- [ ] SWD readback exactly matches the intended bootloader-region SHA-256. +- [ ] Actual SoftDevice FWID and runtime application base match the artifact + (`S140 7.3.0`: FWID `0x0123`, application base `0x27000`). +- [ ] Exercise and independently verify the exact-board combined + bootloader+SoftDevice recovery path before relying on it in the field. + A wrong-layout nRF52840 application can overwrite part of S140 while the + UF2 bootloader still accepts files and the application still reads flash; + internal writes or `InternalFS.format()` may then hang or reboot. An + application-only UF2 and a filesystem erase cannot repair that state. + The known XIAO incident in upstream issue #3284 recovered only after the + matching Sense/non-Sense OTAFIX bootloader+S140 ZIP was installed. Keep + board identity, SoftDevice FWID, application base, and package contents + as four separate pre-write gates. +- [ ] Invalid settings enter persistent UF2 recovery after a cold boot. +- [ ] UF2 application copy returns success; `sync -f` returns success; the host + has no offline-sector, lost-write, or FAT I/O errors. +- [ ] Application enumerates after update and reports the expected board, + version, role, and radio tuple. +- [ ] Legacy serial DFU crosses all application pages, emits the exact + `Device programmed.` success marker, and rejects false-success output. + Two application serial-DFU attempts on this board timed out waiting for + the start ACK while `adafruit-nrfutil` still returned exit 0, so exit + status alone is explicitly insufficient. +- [ ] Legacy BLE DFU verifies each cumulative PRN receipt exactly and fails on + a missing, short, regressive, or overreported offset; it may adapt + only among the packet-size-specific safe receipt windows after timely + exact receipts and must step back down on slow exact receipts. Final + application/SoftDevice/bootloader CRC and a peer disconnect are mandatory + before declaring success. +- [ ] Every upward PRN move is a bounded adjacent-level probe. A failed probe + or a later real demotion blocks further increases for that transfer, while + additional slow exact receipts may still negotiate down one level at a + time. This prevents `4 -> 8 -> 4 -> 8` churn without disabling a safe + initial probe or a `32 -> 16 -> 8` fallback. +- [x] The exact Full Companion ZIP + `Xiao_nrf52_companion_radio_full-v1.17.1.5-halo-keymind-cascade-3caf9dcf.zip` + (SHA-256 `9a916c1a3ea83d8f6dd704465fe9c044a363478f852a4369cf6b13424a7a9430`) + installed over BLE in 237,297 ms at about 2,110 B/s using ATT MTU 247, + 244-byte writes, PRN 4, and exact cumulative receipts. Validate and the + target-initiated disconnect passed. USB then reported the exact expected + version/hash, USA radio tuple, key, RXPS policy, and reversible + Terminal/Binary mode; the new application advertised NUS as + `MeshCore-4610BBD2`. +- [x] The post-update application was paired through BlueZ with the expected + PIN. Companion GATT then returned the same public key, USA radio tuple, + 22 dBm limit, non-repeater role, board, and build over BLE. Its RTC was + 18,038 seconds behind the trusted host; `clock sync` advanced it and the + immediate readback was within one second of the host interval. No + backward clock write was performed. +- [x] A second exact same-image update exercised application buttonless DFU and + retained-peer bootloader entry. The bootloader deliberately reused the + bonded application address `E5:C3:A8:B0:60:66` rather than the cold-entry + address ending in `:67`; exact name, Legacy DFU service, and DIS model + gates still passed. The first adaptive threshold remained at PRN 4 and + completed all 448,416 bytes at about 877 B/s in 527,832 ms; validate, + target disconnect, USB identity, application key, role, and USA radio + readback all passed. The application clock was then about 10 minutes + behind because its RTC did not advance during the bootloader transfer; + it was advanced to the host clock and proved by immediate readback. +- [x] A third same-image A/B run used bounded neutral-window exploration. At + 244-byte writes, two exact neutral PRN-4 receipts triggered one adjacent + PRN-8 probe. PRN 8 measured about 845 B/s against an approximately + 879 B/s PRN-4 baseline, so the helper rolled back to PRN 4 after two + receipts and never re-probed. All 448,416 bytes completed at about + 870 B/s in 531,804 ms; CRC validation, target disconnect, USB return, + exact Companion key/role/radio readback, and post-DFU forward clock sync + passed. This proves up/down sender-window adaptation and no churn, while also showing + that PRN receipt overhead is not the bonded-path bottleneck on this run. +- [x] A bootloader candidate built from exact ZIP SHA-256 + `9c70200fc15a315a50ec68d2a905b5bcf2fc07a796a21fbd439535ec19a608be` + disabled inherited slave latency locally during DFU DATA. The controller + trace still reported the peer-negotiated 30 ms interval and latency 4, + with no connection-parameter update after START; the change is therefore + a local event-attendance override, not a GAP speed renegotiation. A cold + address-generation control completed 448,416 bytes at 2,231 B/s and + passed validation/activation in 226,103 ms. +- [x] Two subsequent bonded application-to-bootloader runs completed the exact + same 448,416-byte image at 3,524 and 3,534 B/s. Their complete elapsed + times, including exact application advertisement proof, buttonless + handoff, bootloader scan, erase, DATA, CRC validation, activation, and + reboot, were 169,414 and 169,221 ms. Both targets initiated the final + disconnect; USB returned as stable serial `B35E71C1C3726CE7`; the public + key, 22 dBm setting, USA `910.525/62.5/SF7/CR5` tuple, board, and build + read back exactly. Compared with the prior roughly 877 B/s bonded run, + the DATA rate improved by about 4.0x without increasing the PRN window + beyond 8. +- [x] An exact bonded B-A-B link-profile sequence used the same Full Companion + ZIP SHA-256 `9a916c1a3ea83d8f6dd704465fe9c044a363478f852a4369cf6b13424a7a9430`, + lab helper SHA-256 `4909c617de37b5666cc622b7190568f002280f55bf26a05843ea283f2be8f566`, + handoff helper SHA-256 + `1bad570173079b623bbe2bbb9e5ba7c4f8ac379bf966bfd4382c5266fb9fd214`, + stable USB serial, bonded address, and 448,416-byte application throughout. + First B, `xiao-ble-event-length-prestart-20260830-072641-W7COxm`, used one + exact pre-START 15 ms/latency-0 update with `0/0` no-preference event-length + hints and completed DATA in 72.030 seconds at 6,225 B/s; DFU through target + disconnect was 102.792 seconds and the full workflow was 117,064 ms. + Control A, `xiao-ble-control-prestart-20260830-073149-yKf0lO`, requested no + update, retained the bootloader connection's 30 ms/latency-4 link, and + completed DATA in 134.131 seconds at 3,343 B/s; DFU was 165.137 seconds and + the full workflow was 182,624 ms. Final B, + `xiao-ble-exact15-prestart-20260830-073554-FuGPfH`, repeated exact 15 ms and + completed DATA in 71.970 seconds at 6,231 B/s; DFU was 102.799 seconds and + the full workflow was 117,179 ms. Both B rates were about 1.86x control and + differed by only 0.096%. +- [x] The B-A-B protocol and link gates passed independently. Both B runs probed + PRN 4 -> 8 after an exact 0.135-second four-packet receipt, rolled back + once to PRN 4 after the exact 0.345-second eight-packet receipt, and never + re-probed. Control A likewise probed after 0.269 seconds and rolled back + after 0.600 seconds. Each B bootloader connection had exactly one matching + HCI Update Complete before START; control A had no bootloader-connection + update. All three captures contained zero connection updates from START + through DATA, CRC validation, activation, and the target-initiated + disconnect. Each run ended `RESULT=PASS`, `RC=0`, `STAGE=complete`; final B + returned the exact Full Companion application as USB `2886:8044`, stable + serial `B35E71C1C3726CE7`, with current application name + `MeshCore-4610BBD2`. A fresh post-B binary Companion query independently + re-gated the unchanged public identity, name, Full Companion role, 22 dBm + power, and USA `910.525/62.5/SF7/CR5` radio tuple. A separate bonded BLE + application query then returned the same board, build, and Full Companion + role, so the USB return did not mask a broken Companion BLE path or stale + bootloader alias. +- [x] The current host-controller ceiling is independently characterized. The + Pi Zero 2 W Broadcom UART controller reports HCI/LMP 4.2 but supports only + LE 1M; LE Read Maximum Data Length and Read Suggested Default Data Length + both return Unknown HCI Command. The final B capture contains 1,837 full + 244-byte firmware writes, + each split into ten 27-byte HCI ACL fragments; all 18,425 transmitted ACL + fragments received completed-packet accounting. The median full-write gap + was 29.947 ms at 15 ms versus 59.936 ms in control A. A BLE 5 controller + with working Data Length Extension is therefore the next host-side speed + experiment; this built-in controller cannot exercise DLE or LE 2M. +- [x] The host helper now clears a disconnect callback left by an unsuccessful + internal BlueZ connection attempt only after `connect()` has returned a + live link, and labels PRN changes as adaptation rather than link + negotiation. All 81 strict host tests pass. The first physical run before + this fix still proved the 3,520 B/s DATA rate and returned a successful + validation response, but is correctly recorded as FAIL because the stale + host event prevented ACTIVATE. +- [ ] Warm app-to-bootloader handoff is tested with the application watchdog + active; cold recovery is tested separately. +- [ ] BLE Companion/recovery behavior and radio-reset recovery are exercised. + +## Seeed Tracker T1000-E + +- [x] Stable application identity `34A9141999729D5D` is recorded and is not + confused with the RAK3401 even though both currently enumerate with USB + product ID `239a:8029`. Application is `239a:8029`; the exercised + bootloader is `2886:0057`, with the same stable serial and exact + `T1000-E`/`T1KE_DFU` model/name gates. +- [ ] Board, version, repeater role, bootloader status, and USA radio tuple are + correct after erase/install and after reboot. The live baseline and a + 2,184 ms software reboot passed: `3caf9dcf`, `Seeed Tracker T1000-E`, 22 dBm, + `910.525/62.5/SF7/CR5`, OTA target `7B071FA0`, bootloader + `0.11.0-OTAFIX2.4.3`, and full OTA base hash + `3E92B157128B6963`. A real power-removal boot is still required. +- [ ] UF2 and serial DFU installation paths complete without host I/O errors. + On OTAFIX 2.4.3, an identity-gated root copy of the exact old UF2 returned + success in 112 ms, but the device disconnected before host writeback was + durable: `sync -f` failed with `EIO` after 17,695 ms and the kernel logged + lost asynchronous writes/FAT read errors. The old application happened to + boot and retained its settings, but this transport is a **FAIL**. Retest + with the corrected 2.4.4 candidate before qualifying T1000-E UF2. +- [x] The installed `t1000e_repeater_lora_ota_no_external_sensors` profile does + not compile the Companion BLE transport and did not advertise during a + post-reboot BlueZ scan; that is expected for this repeater image. BLE + Companion qualification belongs to the separate T1000-E Companion build. +- [x] The exact latency-disable bootloader candidate ZIP (SHA-256 + `ebe9739ce40c2c7cc1f52076f9614194959fcf6df93d37666b69b7bd415fab68`) + installed through identity-gated serial DFU. The final output contained + exactly one terminal `Device programmed.` success line, the bootloader + returned as `2886:0057`, and its embedded manifest identifies + `T1KE_DFU`, S140 7.3/FWID `0x0123`, application base `0x27000`, and test + version `0x02040403`. +- [ ] The later connection-policy-reapply combined ZIP (SHA-256 + `c2de1fb2704154b4abc371b6b4827c32ad09c4d15f3f6d97d0f4182bb51fff58`) + was identity-gated, transferred, validated, activated, and returned as + exact `2886:0057` recovery USB. `INFO_UF2.TXT` reported + `0.11.0-OTAFIX2.4.3-3-gffb1580-dirty-test-version-0x02040403`, S140 + 7.3.0, and the T1000-E board. The earlier and later candidates share the + same visible version string, however, so package sequencing and INFO_UF2 + do not independently prove the installed bootloader bytes. Require an + identity-gated live bootloader-region readback or a uniquely versioned + rebuild before checking this item. +- [x] The combined-image BLE transport itself passed: 193,688 bytes at + 3,331 B/s, target CRC validation, and target-initiated activation. The + package contains SoftDevice plus bootloader and `APP=0`, so remaining in + recovery USB was the correct postcondition. Its first supervisor record + is not accepted as run-level evidence: TERM inherited status zero and + wrote a false `PASS` at `STAGE=post_copy_usb`. A separate audit records + the transport result, and all later wrappers map INT/TERM to nonzero and + refuse `PASS` unless `STAGE=complete`. +- [x] A separate exact application recovery after that combined transfer + completed 404,468 bytes at 2,095 B/s, validated, activated, and returned + stable application USB in 225,899 ms. The lower rate is the expected + cold/unbonded class, not the bonded warm baseline. +- [x] A cold exact Full Companion application ZIP (SHA-256 + `b3be000ab2391527a325cc868e2706dec291c167b6e6cd3495908addb8af6c65`) + completed all 404,468 DATA bytes over BLE at 1,572 B/s. The complete + workflow took 281,236 ms (about 1,438 B/s end to end, including setup, + validation, activation, and reboot). ATT MTU reached 247, but BlueZ's + independently cached maximum write size was still 20, so the + identity-gated helper correctly used 20-byte packets, adapted PRN + 8 -> 16 -> 32, validated CRC, activated, observed the target-initiated + disconnect, and gated the exact USB application return. +- [x] The Full Companion application advertised as `MeshCore-F9AD7082` at the + expected address generation, paired with the qualification PIN, and + exposed its bonded, MITM-protected Legacy DFU service after connection. + That DFU service is intentionally absent from the advertisement, so an + advertisement-only check is insufficient. A 1200-baud USB touch instead + selects serial-only DFU and correctly produces no BLE advertisement; + use the bonded application control characteristic for a warm BLE handoff. +- [x] Exact same-address bonded warm buttonless DFU passed. The application + advertisement, Paired/Bonded state, connected BLEDfu service/control/ + revision layout, encrypted notification/write gate, observed target + disconnect, `T1KE_DFU` advertisement, DIS model `T1000-E`, ATT MTU 247, + and 244-byte write capability were all checked. All 404,468 bytes were + confirmed in 121.081 seconds at 3,340 B/s; DFU through target disconnect + took 144.076 seconds and the full supervisor took 176.958 seconds. CRC, + activation, and exact `239a:8029`/`34A9141999729D5D` USB return passed. +- [x] Two accepted in-DATA 30 ms -> 15 ms -> 30 ms controller experiments + completed without corrupting either image, but were decisive negative + performance results. The first finished DATA in 425.398 seconds at + 951 B/s; the later candidate finished DATA in 449.063 seconds at 901 B/s. + HCI capture found the cause: `hcitool lecup` silently encoded both + connection-event-length hints as `0x0001` (0.625 ms). The Pi's Broadcom + 4.2 controller consequently scheduled about one ACL fragment per event: + roughly 64 fragments/s at 15 ms and 32 fragments/s after the 30 ms + "rollback", versus roughly 120--144 fragments/s on the untouched link. + The later pre-START controls prove that 15 ms itself is not slow; the + restrictive event-length hint and in-DATA update/rollback were unsafe. +- [x] Two exact 15 ms/latency-0, zero-event-length lab controls passed: + `t1000-ble-event-length-prestart-20260830-071549-QOeVuw` completed all + 404,468 DATA bytes in 64.905 seconds at 6,232 B/s (95.635 seconds through + target disconnect; 112,639 ms full workflow), and + `t1000-ble-event-length-prestart-20260830-071825-VE9Re3` completed DATA in + 62.070 seconds at 6,516 B/s (92.794 seconds through target disconnect; + 106,807 ms full workflow). Both used exact `0/0` no-preference minimum and + maximum connection-event lengths. On each bootloader connection the HCI + trace contains exactly one successful update before START and none from + START through DATA, validation, activation, and the target disconnect; + Update Complete preceded START by 7.535 and 7.536 seconds. Completed-packet + clusters followed the 15 ms connection anchors, while the median full-size + DFU write gap was 29.997 ms in both traces. Exact package hash, CRC, + activation, and stable application USB return passed twice. +- [x] `t1000-ble-event-length-prestart-20260830-071358-ogzmRy` is the bounded + negative control. HCI accepted exact 15 ms/latency 0 with both event-length + hints set to 10 ms, then reported `Connection Timeout (0x08)` 6.810 seconds + after Update Complete during the 20-second pre-START pause. The run failed + closed at `STAGE=pre_start_update` in 44,227 ms; START and application DATA + were never sent. A 10 ms event-length hint is therefore rejected for this + adapter/target pair. +- [ ] Production BLE DFU keeps the controller-negotiated link and issues no raw + host connection update unless that exact adapter/target profile has been + separately graduated. The opt-in lab path may issue exactly one request on + the bootloader connection before START while sent/confirmed are zero: exact + interval and latency, `0/0` no-preference event-length hints, bounded pause, + and a matching HCI Update Complete are mandatory. Failure or mismatch + disconnects before START. Never update or roll back the link during DATA; + only the protocol PRN/window may adapt there. Compare complete repeated + control/candidate DFUs offline before graduating a production profile. +- [ ] Repeat the cold transfer with the host's bounded BlueZ readiness wait. + When ATT MTU is greater than 23 while the cached write capability remains + 20, it polls the same connected DFU characteristic for at most three + seconds, aborts on disconnect, disappearance, or characteristic-handle + change, and otherwise falls back safely to 20. The four readiness unit + cases and all 81 strict helper tests pass; a physical 20 -> 244-byte + promotion is still required. +- [ ] Restore the protected + `t1000e_repeater_lora_ota_no_external_sensors` application (target + `0x7B071FA0`, exact ZIP SHA-256 + `9b9be2bee9ebb6644d22806d404fe02c415d037707713d206fa43a8c689cf632`) + through identity-gated application DFU while retaining the candidate + bootloader. Do not use the generic repeater image or the unqualified UF2 + path. Verify the protected private/public identity without printing the + private key, plus exact name, latitude/longitude, build/body/OTA target, + USA radio, 22 dBm, GPS off, power saving on, RX gain on, RXPS on, + watchdog on, telemetry access `all`, no TempRadio/download, and a valid + admin login. +- [ ] LoRa radio reset/recovery is forced and normal receive/transmit resumes. + Normal post-reboot bidirectional LoRa is already proven: the V4 learned a + zero-hop path, received the exact message-specific ACK in 558 ms, and + received the T1000-E's exact `3caf9dcf` version reply. A deliberately + forced LR1110 recovery event is still required. +- [x] Direct V4 LoRa OTA receive, apply, reboot, and post-update verification + passed from exact `bf092b24` body `9443FB5E6F23FC1B` to exact `3caf9dcf` + body `3E92B157128B6963` on OTAFIX 2.4.3. The mandatory fixed three-minute + rehearsal passed at `909.950/250/SF5/CR5`, including natural expiry and + return to `910.525/62.5/SF7/CR5`. The 2,036-byte/two-block delta reached + ready in six seconds, the new body replied at the 50-second probe, and the + complete safety-wrapped runner took 519 seconds. V4 source RXPS and both + normal radio tuples were restored. The co-located T1000 was temporarily + reduced to 0 dBm and restored to 22 dBm using bounded retries across its + RXPS busy windows. +- [x] Independent post-LoRa-OTA USB verification matched the protected pre-run + private key, public key, name, latitude, and longitude without printing + private material. GPS remained off; device power saving, system watchdog, + RX gain, and RXPS remained on; telemetry access remained `all`; no + TempRadio lease or download remained. `blrc:B8` is the expected OTAFIX + application-apply success diagnostic. A subsequent software reboot reset + uptime and preserved the exact build/body, name, radio, 22 dBm, GPS, + power-saving, RXPS, watchdog, and no-TempRadio state. The CDC symlink did + not disappear during this warm reset, so uptime/readback—not USB removal— + is the required reboot proof on this board. +- [x] GPS was initially off, enabled successfully under device power saving, + reported active/no-fix/zero-satellites with its duty-cycle deadline, and + was restored to off. Device power saving reported on, RXPS reported + `on,18205,20423`, GPS advert policy `prefs`, and telemetry access `all`. + +## RAK3401 + +- [x] Stable USB identity and SWD FICR identity are independently matched. +- [x] Source-level WisBlock peripheral pins are complete and non-conflicting: + I2C SDA/SCL are P0.13/P0.14 through both `PIN_WIRE_*` and + `PIN_BOARD_*`; GPS-module RX crosses to MCU UART TX P0.16, GPS-module TX + crosses to MCU UART RX P0.15, and PPS is P0.17. A static contract test + prevents these aliases from silently disappearing or being uncrossed. +- [x] Ordinary/full-sensor RAK3401 profiles use the source-built Adafruit BME680 + provider, not Bosch's precompiled BSEC archive, so upstream issue #3292's + soft-float/hard-float link mismatch is not reachable there. The reduced + LoRa-OTA profile deliberately omits BME680/BSEC and the other declared + optional environmental/ranging drivers. A static contract test requires + the hard-float path fix and BSEC dependency if BSEC is enabled in this + board or an inherited full-sensor recipe later. +- [ ] With the RAK13302 radio installed, detect a RAK12501/L76K UART GPS from a + complete checksum-valid NMEA sentence in sensor slot A. Confirm random + UART bytes, an incomplete sentence, and a bad checksum do not claim GPS. +- [ ] Detect RAK12500 I2C GPS alone at `0x42`; exercise GPS power saving and + confirm the shared 3V3_S rail, OLED, RTC, and other slots remain powered. +- [ ] Detect INA3221 alone at the firmware-configured `0x42` and prove the + discovery path does not send u-blox configuration traffic to it. +- [ ] Exercise RAK12500 at `0x42` and INA3221 at `0x43` together after strapping + INA3221 A0 to SCL and building with + `-DTELEM_INA3221_ADDRESS=0x43`; verify both retain independent telemetry. +- [ ] With a representative omitted environmental sensor attached, confirm the + reduced image leaves the generic I2C bus healthy but does not advertise a + driver that was intentionally removed. +- [x] Local USB terminal probes are terminated with carriage return (`\r`). + This CLI echoes an LF-only command without executing it; an echoed + `ver` with no reply is therefore a bad probe, not evidence of a hung + application. Confirm any suspected hang with a CR-terminated command, + LoRa status, and SWD state before resetting the board. +- [x] Assert CDC DTR when probing the RAK terminal. With DTR deasserted, writes + reached the port but no CLI replies were returned; the same exact + stable-identity port returned the expected version immediately after DTR + was asserted. Keep 1200-baud bootloader touch separate from 115200-baud + application probes, and allow a full reply window so delayed responses + are not attributed to the following command. +- [x] Installed S140 6.1.1 FWID (`0x00B6`) and runtime application base + (`0x26000`) are independently verified. +- [x] Bootloader version and apply capabilities are independently reported. +- [x] UF2 application copy returns success; `sync -f` returns success; no USB + reset/timeout or offline-sector errors occur; SWD readback matches every + UF2 payload byte. Current 2.4.4-preview.2 result is **PASS** in both + current→old and old→current directions. +- [x] Board, repeater role, version, name, and USA radio tuple are verified + after serial recovery and again after a real cold boot. +- [x] Six consecutive authenticated `ota self` attempts leave the application + healthy. In the qualification run, two login/reply pairs were lost, one + login succeeded while its command reply was lost, and the next three + commands replied in about 2.5 seconds each. A CR-terminated local `ver` + probe passed after every attempt; packet loss did not become a node hang. +- [ ] An unchanged admin login does not rewrite the ACL flash file, while an + actually changed/newly learned persistent route schedules exactly one + delayed save and survives reboot. Cover delayed flood-path replies, + already-unknown paths, changed permissions/secrets, new/evicted clients, + and the intentionally non-persistent guest case. Do not rely on an + unrelated login write to capture a later PATH response. +- [ ] Location and private-key persistence are verified without exposing the + private key in logs. +- [x] Serial recovery rejects nrfutil's false exit-0 failure, requires the exact + success marker, and produces a byte-exact SWD application readback. +- [ ] UF2/serial recovery works with both the deployed older bootloader family + and the current backward-compatible application. +- [ ] Bluetooth advertisement and connection work when enabled. The tested + repeater profile does not advertise BLE during normal operation. +- [x] Direct LoRa OTA from the V4 passes with elapsed time, bounded retry + behavior, final hash/version verification, exact radio restoration, and + independent SWD readback. The qualified `bf092b24` base + `91CF4EC2882F23D6` reached `3caf9dcf`/`27A223DDFC3A8F47` using a + two-block, 2,132-byte delta: ready in six seconds, new body at the + 60-second reboot probe, and 514 seconds for the complete safety-wrapped + run. Log: + `/home/mikec/hwtest/runs/rak3401-0dbm-final-9KWjey61/run.log`. +- [ ] Add exact RF packet sent/confirmed counters to the device/host telemetry. + The qualified run records two payload blocks, all seeder reads, command + retries, and final success, but those are not equivalent to LoRa packet + counts and must not be presented as such. +- [ ] Repeat at each supported bandwidth and record 0-hop, 1-hop, and 2-hop + estimates from measured airtime rather than extrapolating only by file size. + +## RAK4631 reduced profiles + +- [x] Serialized VM contracts distinguish the three application identities: + the plain and Serial2 reduced profiles retain the combined WisBlock GPS + provider and all four INA voltage/current drivers; the explicit legacy + Serial1 profile retains the INA drivers and omits GPS. This is build + evidence only, not physical peripheral qualification. +- [ ] On the plain reduced profile, detect and read RAK12500 I2C GPS, a + RAK12501/L76K UART GPS in a separate run, the SSD1306 OLED, a supported + autodiscovered RTC, and representative INA telemetry. Verify GPS sleep and + repeated discovery never lower the shared WB_IO2/3V3_S rail. +- [ ] With a UART RAK12501 installed and detected, confirm the Serial2 bridge + and GPS remain usable together. Select Serial1 and try to enable the + bridge with the GPS preference both on and off; verify each attempt is + refused before the bridge starts and does not change GPS transport, + acquisition, hold, or cached-fix state. The shared WB_IO2/3V3_S rail must + remain high and GPS must remain usable after each refusal. +- [ ] With only an I2C RAK12500 installed, and again with neither GPS installed, + select Serial1 on the merged image and confirm bridge enable is refused + without changing saved UART/runtime state. Silence or an I2C-only detection + must not defeat the fail-closed RAK12501 reservation. Confirm Serial2 starts + and I2C GPS telemetry remains usable. +- [ ] Exercise RAK12500 at `0x42` and INA3221 at `0x43` together with the + address-matched build. Separately place INA3221 alone at the configured + `0x42` and prove the identity guard prevents u-blox configuration writes. +- [ ] With a logic analyzer, hold SDA low and then SCL low before sensor + discovery. Confirm the validated board pins receive at most nine + open-drain recovery clocks and a STOP only after SCL rises; the firmware + must skip discovery if either line remains low. Repeat on ProMicro's + remapped SDA 8/SCL 7 and verify the user-button pin is never driven. +- [ ] Record the remaining core limitation: the pinned Adafruit nRF52 Wire + implementation has no transaction timeout. This preflight cannot protect + display/RTC calls made earlier in boot or interrupt a peripheral that + wedges after a transaction begins. Qualify those cases with a hard power + cut/watchdog fixture before claiming bus-hang recovery. +- [ ] Confirm the explicit legacy Serial1 image exposes neither UART RAK12501 + nor I2C RAK12500. Record this as a combined-provider build limitation, not + an assertion that RAK12500 electrically conflicts with the UART. + +## Heltec MeshTower V2 with SD + +- [x] Stable serial `9352162A72082314` and physical downstream-hub path + `1-1.2` port 4 identify this board across application and bootloader + descriptors; no other attached nRF52 endpoint was selected. The older + 2.4.3 bootloader's UF2/serial failure was rejected rather than treated as + a successful flash. +- [x] Exact `1.17.1.5-halo-keymind-cascade-marathon-hwtest-e26d48e4` SD + Repeater boots with hardware ID `Heltec_tower_v2`, target `0A9DBBF0`, + body hash `E9282D470AAC348B`, and USA radio + `910.525/62.5/SF7/CR5`. `ota self` reports SD apply ABI 3 with codec mask + `0x5`; `ota bootloader` reports board `239A0071`, target `1150F50E`, name + `TOWER_V2_OTA`, and capability byte `09`. +- [x] Card detection and destructive maintenance pass. Normal format took + 3,927 ms; an immediate repeat was rejected by the five-minute cooldown; + forced format took 3,933 ms; and raw erase plus format took 3,947 ms. + The card remounted empty with 16.0 KiB used and 959.6 MiB free, and both + completion ages were recorded. Automatic archive capture was then + disabled deterministically, leaving only `/mota/cache.off`. +- [ ] SD card detection, staging, hash validation, apply, cleanup, and recovery + from missing/corrupt media pass. +- [ ] Multi-page application and bootloader update paths pass across a cold boot. +- [ ] LoRa OTA receive/apply reports the expected target ID and installed version. + +## Heltec V4 + +- [x] Exact V4 hardware variant is reported (`Heltec V4.3 OLED`); OLED/TFT + variants are not merged. +- [x] Full Companion boots with USB logging off and Binary Companion selected. + The text terminal independently reports the complete build string; + protocol `ver` has a shorter fixed-width version field. +- [ ] USB logging changes take effect immediately on this single-TTY ESP32 + profile, require no reboot, and return to Binary Companion only through + the explicit terminal-stop mode change. +- [x] Bluetooth pairing and Companion protocol operations pass. Qualification + required removing the Pi's stale pre-flash bond, pairing through a BlueZ + `KeyboardOnly` agent with the current displayed/reported PIN, then + reconnecting; BLE `infos` and `ver` returned the exact V4.3 OLED identity. +- [x] Saved Wi-Fi connects to `SlowFi`, returns at `192.168.1.51` after the test + host reboot, reports Wi-Fi power save `min`, and remains governed by the + configured-network behavior rather than the unconfigured 30-minute + shutdown policy. +- [x] TCP Binary Companion (`5000`), Wi-Fi OTA (`5001`), and text CLI (`5002`) + are each tested and cannot be confused with one another. Port 5000 + returned the exact Companion identity; 5002 returned the full terminal, + version, Wi-Fi, logging, RXPS, and memory state; and a real `motatool + serve --tcp 192.168.1.51:5001 -v` session received `COUNT -> 0` from an + intentionally empty folder. Disconnect then restored `folder:not + connected` while the 5001 listener stayed active. +- [ ] For a shared Full-Companion controller/seeder, bind the source identity + to the exact public key already read from Binary Companion port 5000. + Do not require `get public.key` from text port 5002: the hardware-tested + V4 Full CLI rejects that command. Port 5002 must still prove the local + TempRadio state/tuple, while keyed on-air destination replies prove the + physical LoRa handoff. +- [ ] V4 seeds direct and routed LoRa OTA; TempRadio preflight, apply, and + automatic normal-radio restoration all pass. + +## Seeed SenseCAP Indicator LoRa + +- [x] Flashing selects the ESP32-S3 CH340 endpoint, never the Indicator RP2040 + ACM endpoint; esptool MAC probing succeeds before erase. The qualifying + merged write used 115200 baud and completed with flash hash verification. +- [x] A fresh merged install completes SPIFFS initialization and reports the + intended Full Companion version/role and `Seeed SenseCAP Indicator` + hardware identity. The exact qualifying artifact was + `SenseCapIndicator-LoRa_companion_radio_full-1.17.1.5-halo-keymind-cascade-marathon-e26d48e4-merged.bin`, + 2,070,152 bytes, SHA-256 + `df08b770313ca8776436456525681cf286cd9b5b40e05e7cf55f21ef097f338a`; + its ELF, map, manifest, and partition signature are archived beside it on + the Mercerwood Pi. +- [x] Keep one identity-gated CH340 descriptor open with DTR/RTS deasserted + through startup. On the valid-current-font path, the Indicator streams a + 1,302,608-byte font from its RP2040 at 1 Mbps before registering the USB + terminal, so allow at least 30 seconds before the first command. A + missing/corrupt-font recovery instead runs in the background after the + interfaces start. Reopening short probes can reset the ESP32 repeatedly + and create a false UART-hang diagnosis; the controlled run returned + `ver` and `board` after the same apparent-silence condition. +- [x] Automatic font recovery starts from a physically corrupt RP asset. The + setup deliberately installed 64 all-zero bytes, CRC32 `758d6336`, in + place of `/meshcore/ui-font.vlw`; the ESP32 rejected that asset and kept + its built-in fallback font while recovery ran. +- [x] Every new HTTPS recovery attempt waits for a fresh SNTP callback and a + signed epoch at or after the compiled asset publication time before it + opens TLS. The forced-resume run observed fresh syncs in 3,700, 400, and + 699 ms across three outer attempts, including two failed TLS handshakes; + the final normal run synced in 1,999 ms and only then logged the first + `api.github.com` connection. A retained merely plausible clock is not + accepted. CA validation and the compiled asset SHA-256 remain mandatory. +- [x] The release-source normal path downloaded all 1,302,608 bytes into PSRAM + in 13,205 ms, closed TLS, verified SHA-256 + `61bce9662db314054e7bcfaa26147a28ad7b500b51baac4cae1caacce90b7421`, + staged receiver-paced 512-byte `MCFONT STAGEV2` chunks in 36,676 ms, + committed, and verified RP CRC32 `19f80d64` at 76.7 seconds from reset. + An independent `MCFONT GET` over RP USB then returned exactly 1,302,608 + bytes with the same CRC and SHA in 15.273 seconds. The exact installed + application is + `SenseCapIndicator-LoRa_companion_radio_full-1.17.1.5-halo-keymind-cascade-marathon-font-recovery-e26d48e4.bin`; + its log is + `/home/mikec/hwtest/runs/indicator-release-recovery-lUZEmBHL/esp-serial.log` + on Mercerwood. The current RP service reports protocol 2 and completed + STAGEV2 accounting; its installed flash was not independently read back, + so no RP artifact hash is inferred from the archived candidates. +- [x] Strict Range recovery passed a forced hardware interruption. Exact test + application SHA-256 + `cfd5aec497cb9919f87c0f82f5deaf8614d411582a709cea1c36275529c0bb68` + closed the initial response at verified offset 1,048,576. Its first + same-offset resume handshake failed; bounded retry 2/2 then accepted only + the remaining 254,032-byte `206` response under the original strong + ETag/`If-Range`, retained the same PSRAM buffer and SHA stream, completed + the download in 49,422 ms, staged in 36,702 ms, committed, and passed an + independent exact USB readback. The full log is + `/home/mikec/hwtest/runs/indicator-range-v2-xbKr09wI/esp-serial.log`. + An earlier forced 512 KiB run proved one correct resume at 524,288 but + later exhausted its second reconnect on a TLS failure; it is retained as + regression-trigger evidence rather than an end-to-end pass. +- [x] Rebooting the exact release-source application with the current valid + font performed no NTP query, TLS connection, or GitHub request. The + valid-font boot log is + `/home/mikec/hwtest/runs/indicator-release-valid-a84vd7Du/esp-serial.log`. +- [x] The post-qualification shared SNTP-coordinator source compiles in the + real Arduino-ESP32 2.x LoRa USB/WiFi profile (1,587,965 bytes, 60.6% of + its OTA slot), the dynamically generated ESP-NOW Full profile (2,093,465 + bytes, 79.9%), and the matching RP2040 transactional service. After the + VM independently reported `NTPSynchronized=yes`, an + end-to-end request to the immutable GitHub asset returned HTTP 200, + exactly 1,302,608 bytes, and SHA-256 + `61bce9662db314054e7bcfaa26147a28ad7b500b51baac4cae1caacce90b7421`. + A live `524288-1302607` continuation then returned HTTP 206, the same + strong ETag, an exact 778,320-byte `Content-Length`, and a byte-for-byte + hash match against the checked-in font tail; the pinned Sectigo E46 root + independently verified the current `api.github.com` chain. + The same coordinator also compiles in the real + `heltec_v4_repeater_observer_mqtt` profile: its 1,852,392-byte EndF image + has SHA-256 + `fe8352654437f67488b412e1c1a690f2874e836abb5a5d209654bd030c713d7e`. + MQTT's three process-global `configTime()` paths are lease-protected, + and a busy asynchronous refresh uses a wrap-safe five-second retry + deadline instead of overwriting another service's SNTP callback or + recording a false successful refresh. + The exact final LoRa Full artifact gate then passed 11/11 linked-image + markers, including both `requesting fresh NTP time before download` and + the subsequent TLS-open marker. Its 2,146,312-byte application has + SHA-256 + `f557088a5ef3f49f74ab01ca95fefc76911cca4073f01b77cee4882284fc5110` + and 475,128 bytes remain in its app slot; the matching 2,211,848-byte DIO + merged image has SHA-256 + `52a4559774384b54dd7fb81da82c791e2c9564991fcf016bcad3b6ad756c1fb2`. + The exact ELF, map, capability manifest, and partition signature are + archived together under `/tmp/indicator-full-daily-ntp.WQ6ozM` on + the VM pending transfer to the hardware-run archive. + The ESP-NOW Full sibling passed the same 11/11 gate with a 2,093,928-byte + application (527,512 bytes free), SHA-256 + `fcd9e0e8a37551a845a234fc10eaa4a9f420d54172039b9241f4696a12d4c65f`, + and matching DIO merged-image SHA-256 + `f3637c23f58204f5b752b68a1bd494601cb68bee9c27b664832d40abd03cc186`; + its complete archive is `/tmp/indicator-espnow-full-ntp-gate.YN9T8l`. + Repeat the negative UDP/123-blocked hardware gate on this exact final + source before treating the coordinator refinement as hardware-qualified. +- [ ] Full Wi-Fi SSID, setup address, and related status text wrap without + clipping on the physical display. +- [x] With no saved SSID, setup Wi-Fi powers down after the absolute 30-minute + window even if queried or used. +- [x] A software reboot starts a fresh unconfigured setup window. +- [x] With a saved SSID, Wi-Fi reconnects to `SlowFi`, reports minimum modem + power save, and remains governed by normal configured-network behavior. +- [x] Bluetooth Companion pairing and protocol operations pass after saving a + deterministic qualification PIN, removing any stale host bond, pairing, + disconnecting the `bluetoothctl` client, and reconnecting with meshcli. + Binary `infos`/`ver` returned the exact Indicator identity and USA tuple. + A second connection after the USB-open reboot and logging-mode exercise + also returned the exact build; one initial scanner miss was resolved by + an explicit BlueZ discovery pass and was not a firmware advertisement + failure. +- [x] Wi-Fi/TCP Companion, CLI, and OTA endpoints pass at `192.168.1.54`: + port 5000 returned Binary Companion identity, port 5002 returned the full + text version/status, and motatool completed a port-5001 `COUNT -> 0` + protocol exchange against an empty served folder. +- [x] Fresh Full starts with USB logging off. On this single-TTY ESP32 profile, + logging on/off applies and persists without a descriptor-changing reboot; + the same held-open ASCII terminal reported off -> on -> off correctly, + emitted live Wi-Fi/radio diagnostics while on, then accepted the explicit + terminal-stop token and returned a valid 85-byte Binary Companion device + info frame for `Seeed SenseCAP Indicator` without rebooting. Companion + remained independently usable over Bluetooth and Wi-Fi. +- [x] Normal-radio LoRa transmit and receive pass. At the installed 22 dBm, + a co-located discovery request overloaded the nearby receivers and found + zero nodes; temporarily reducing only the Indicator to 0 dBm produced + three repeater replies at RSSI -29 to -59 dBm, proving both directions. + The saved 22 dBm value was restored and independently read back after the + test; this near-field result is a lab-power gate, not a firmware failure. +- [ ] TempRadio sanity check, OTA seeding, and normal-radio restoration pass. + +## Cross-radio LoRa OTA acceptance + +- [ ] Run a three-minute reachability preflight before staging or changing any + repeater radio settings. +- [ ] Test direct, fully controlled multi-hop, passive TempRadio hop, and mixed + controlled/passive paths independently. +- [ ] Record bytes, LoRa packets sent, packets confirmed, retries, useful + throughput, total elapsed time, and per-phase elapsed time. +- [ ] Confirm every controlled intermediate returns to its saved radio tuple. +- [ ] Verify target application and bootloader hashes after apply, not merely the + OTA tool's final status line. +- [ ] Exercise interruption/retry and confirm incomplete data cannot be approved + or booted. diff --git a/docs/lora_ota_automation.md b/docs/lora_ota_automation.md index ebf3c313..cef2feec 100644 --- a/docs/lora_ota_automation.md +++ b/docs/lora_ota_automation.md @@ -200,6 +200,9 @@ reported bootloader ABI and codec mask against the selected package. If the version command is unavailable on older firmware, the script warns and falls back to the legacy `ota self` platform marker. If an nRF52 bootloader lacks the required capabilities, install the exact-board OTAFIX bootloader first. +`ota stats` is only an optional EndF version probe. It uses one bounded retry +cycle; unsupported firmware or a lost reply falls back directly to the +required `ver` command instead of entering an operator continuation loop. The default TempRadio tuple is: @@ -212,6 +215,30 @@ North American example: choose a legal frequency supported by every participating radio and appropriate to your location. Older radios that do not support SF5 require a complete replacement tuple passed with `--temp-radio`. +Before that long window is allowed, the live runner performs a mandatory +independent three-minute rehearsal. Its exact normal-channel `ota status` and +`ota self` identity proof gets one shared four-minute, read-only budget before +any schedule, radio override, seeder, or transfer is armed; this tolerates a +marginal link without consuming or extending a live lease. It advances a stale +managed controller with exact readback and treats a source terminal's +whole-minute display as an uncertainty window, never as an exact epoch. A +source minute overlapping the host is preserved; a completely stale minute is +pinned to a guarded future value and read back once. Neither clock is moved +backward, and the runner stops if a managed clock cannot be proven within the +ten-minute limit. Each remote participant must expose an empty `tempradioat` +schedule. The runner projects one fixed start/end interval into each +participant's independently sampled RTC, sends each mutation once, proves +every exact identity on the temporary tuple, waits for natural expiry, and +proves the complete normal path again. A lost schedule reply is resolved by +those on-air proofs; it is never blindly replayed with a fresh duration. + +The published RAK3401 `v1.16.7-c1caa5ad` LoRa-OTA image includes +`get/set/del tempradioat` and can take this safe first step. A truly older or +reduced build without fixed TempRadio scheduling is rejected before mutation; +bootstrap it locally or with another explicitly controlled maintenance path. +The automation does not substitute an immediate `tempradio` command whose +first delivery could remain queued after cleanup. + ### RXPS handling during TempRadio RXPS improves receive performance per unit of radio-on time, so the runner @@ -265,8 +292,11 @@ moving back to a slower tuple returns to the operator's saved level. The OTA source has a stricter policy than the destination. For every source with a managed serial or TCP CLI, the runner reads and retains its exact RXPS preference, using the legacy fixed-period query only when the detailed query is -unavailable. It then disables RXPS and verifies the readback before any target -discovery or TempRadio change. Source RXPS stays off through catalog serving, +unavailable. It builds and verifies the package, completes the read-only target +checks, and obtains confirmation while that preference remains unchanged. +Immediately before the first radio mutation, it reads the source RXPS state +again, disables RXPS, and verifies the readback. Source RXPS stays off through +catalog serving, download, installation, and post-install identity verification. Cleanup first proves that the source has returned to its normal radio, then restores and verifies the exact saved level/preamble or fixed-period state once. A source @@ -487,11 +517,15 @@ Useful controls: downloading or staged on the target. Without it, that update is preserved. - `--source-shares-controller` is for a Full Companion whose USB Binary API is the controller while its TCP port `5001` is the source. It verifies that the - source's port-`5000` public key equals the controller key. Port `5002` uses a - bounded local `tempradio` override to move the shared physical radio without - overwriting its saved normal tuple; the Binary API remains the authenticated - transport. Cleanup sends local `normalradio`, proves that override inactive, - and then reasserts the saved Binary tuple. It cannot be combined with + source's port-`5000` public key equals the controller key, then binds the + fresh port-`5002` Full Terminal banner's complete public key to that Binary + identity and challenges it with the supported `ver` command. It does not + assume the Full Terminal implements repeater-only `get public.key`. Port + `5002` uses a bounded local `tempradio` override to move the shared physical + radio without overwriting its saved normal tuple; the Binary API remains the + authenticated transport. Cleanup sends local `normalradio`, proves that + override inactive, and then reasserts the saved Binary tuple. It cannot be + combined with `--leave-controller-radio`, because exact source RXPS restoration requires that shared physical radio to be back on its verified normal tuple. @@ -505,6 +539,12 @@ Useful controls: restores both values before the relay leaves TempRadio. - `--work-dir PATH` chooses a new, non-existent work directory. - `--meshcli PATH` and `--motatool PATH` select binaries not on `PATH`. +- `--package-build-timeout SECONDS` bounds local mOTA generation. It defaults + to 3600 seconds. Run detools, delta/compression, and raw-firmware package + generation on a workstation or build VM, then transfer the completed, + hash-verified `.mota` to a Pi-class radio host. The Pi should perform only + lightweight identity-gated hardware I/O and serve that finished artifact; + preparation still occurs before any radio state changes. - `--debug` prints redacted child commands, timeouts, process status, stdout, and stderr. Admin passwords are never printed, but node names, addresses, paths, and command replies can still be sensitive; share the log carefully. @@ -527,30 +567,41 @@ the destination. ## What happens during a run 1. Validate the input paths and host tools, then prove the source is either an - OTA-enabled raw CLI or a source-only full Companion control interface. -2. Authenticate to the target and query its target ID, hardware, running body + OTA-enabled raw CLI or a source-only full Companion control interface. Read + and durably save a managed source's exact RXPS preference without changing + it. +2. Before sending any remote packet, prove the managed source has no active, + pending, or fixed TempRadio work, then gate the source and controller + clocks. Advance a stale exact clock to host time; for a minute-resolution + source, preserve an overlapping minute or use one guarded forward value + with readback. Preserve a small future lead and fail closed above the + ten-minute drift limit. Then + authenticate to the target, query its target ID, hardware, running body hash, firmware version, bootloader version, and nRF52 bootloader - capabilities. + capabilities, and save the controller's normal radio tuple. 3. Select or build one compatible **v2 application** mOTA and verify all block hashes, Merkle root, full-image hash where applicable, identity fields, signature, codec, base, and the firmware's 1024-byte maximum block size. Version-3 bootloader packages are refused before any target state changes. -4. Read and save a managed source's exact RXPS preference, disable and verify - source RXPS, save the controller's normal radio tuple, read every - participant's version, save the destination's RXPS state, select the - qualified destination policy, and show the confirmation prompt. -5. Apply and verify the destination RXPS policy, then start TempRadio on the +4. Read every participant's version, save the destination's RXPS state, + select the qualified destination policy, and show the confirmation prompt. +5. Run the fixed-window three-minute rehearsal described above. This phase + does not disable RXPS, start a seeder, request a pull, or install anything; + the long transfer remains completely unarmed until temporary and normal + reachability both pass. +6. Fresh-read, disable, and verify source RXPS; apply and verify the + destination RXPS policy; then start TempRadio on the target, far-to-near relays, and source. A separate controller is moved and read back through Binary; a shared Full Companion instead schedules its bounded local override while Binary remains the transport. The runner rejects a TempRadio window that cannot cover setup, seeder startup, discovery, the transfer timeout, final polling, and install checks. -6. Start `motatool serve`, discover the exact eight-hex manifest ID, request +7. Start `motatool serve`, discover the exact eight-hex manifest ID, request `ota pull flash`, and poll until that same ID reports ready. A seeder process exit stops the run immediately. For `--no-install`, schedule all script-controlled nodes back to their normal radios before restoring the controller, unless `--leave-controller-radio` was requested. -7. Recheck that exact ID, give the target a short final TempRadio safety window, +8. Recheck that exact ID, give the target a short final TempRadio safety window, and request `ota install`. Then shorten each relay's TempRadio window so the normal multi-hop route returns, stop the seeder, shorten the source window, restore the controller, and probe `ota self` every 10 seconds through the @@ -573,9 +624,17 @@ status for another manifest ID is an error, never permission to install it. Read-only and replay-safe transmissions retry up to three times. Three retries or 90 seconds, whichever comes first, opens a 10-second stop-or-continue -prompt. Continue is the default on timeout, Enter, and unattended input, so a -temporary outage does not silently abandon a resumable transfer. Enter `s` or -`stop` to end the run; Ctrl-C also remains immediate. +prompt when stdin is an interactive terminal. Continue remains the default on +timeout or Enter, so an operator can persist through a temporary outage; enter +`s` or `stop` to end the run, and Ctrl-C remains immediate. With non-interactive +stdin, the runner stops after that finite automatic retry cycle instead of +silently starting another cycle forever. Optional participant-version probes +always use a bounded cycle and degrade to `unknown`; optional destination +`ota stats` falls back directly to `ver`. The mandatory normal-channel +destination baseline is the narrow exception: it may make nine total read-only +attempts but is stopped by one hard four-minute deadline before any TempRadio +lease or OTA mutation exists. Proofs inside the live rehearsal retain the +smaller four-attempt limit. Commands that change OTA state are reconciled before replay: diff --git a/docs/ota_easy.md b/docs/ota_easy.md index e7cbfa98..9e506165 100644 --- a/docs/ota_easy.md +++ b/docs/ota_easy.md @@ -19,7 +19,8 @@ complete container stays off-chip. See [nRF52 repeater OTA with external QSPI](o [MeshTower V2 microSD LoRa OTA](ota_meshtower_v2_sdcard.md). MeshTower V2 microSD application and bootloader containers must be Ed25519-signed by a key in the device -allowlist; pass `--sign-key` when building one. Its BLM2-capable SD-aware bootloader is also required before +allowlist; pass `--sign` to `motatool build` (or `--sign-key` to the end-to-end +runner) when building one. Its BLM2-capable SD-aware bootloader is also required before ordinary application or bootloader OTA can use the reset-retained authorization record. Upgrade preview.12 locally over USB/BLE DFU or SWD first; there is no raw-card compatibility handoff. @@ -85,13 +86,25 @@ payload. If any required window closes, the transfer stops making progress and c overlapping window. `build.sh` provides a `*_repeater_lora_ota_no_external_sensors` build for standalone ESP32 and nRF52 repeater -targets that need a smaller internal update workspace. Those siblings omit optional external I2C -environmental sensors while retaining board-native features such as displays, buttons, battery monitoring, +targets that need a smaller internal update workspace. Those siblings omit selected optional environmental +and ranging drivers while retaining board-native features such as displays, buttons, battery monitoring, and GPS where the target uses the GPS-preserving lean profile. RAK3401 and RAK4631 reduced builds also -retain INA219, INA226, INA260, and INA3221 I2C voltage/current monitors; together these drivers add 4,808 -bytes over the otherwise reduced image. The RAK3401 OTA repeater retains RAK12501 support in sensor slot A; -slot D conflicts with the RAK13302 radio's BUSY/DIO1 lines. The RAK4631 OTA repeater retains GPS and defaults -its runtime RS-232 bridge to Serial2; Serial1 and GPS use the same UART and must not be enabled together. +retain INA219, INA226, INA260, and INA3221 I2C voltage/current monitors; together these drivers cost less +than 5 KiB in the measured profile. Those four monitor drivers are not the only retained I2C support: the +board's SSD1306 OLED, supported autodiscovered RTCs, and the RAK12500 GPS remain separate I2C peripherals in +GPS-compatible RAK recipes. RAK12501/L76K GPS instead uses UART Serial1. The RAK3401 OTA repeater supports +either GPS module in sensor slot A; slot D conflicts with the RAK13302 radio's BUSY/DIO1 lines. The RAK4631 +OTA repeater defaults its runtime RS-232 bridge to Serial2 so the UART RAK12501 can retain Serial1. The +runtime guard always reserves Serial1 because a bounded silent probe cannot prove a cold RAK12501 is absent; +the shared WB_IO2/3V3_S rail keeps a fitted module powered and driving the UART. Use Serial2. Serial1 requires +an explicit no-GPS/dedicated image. The merged image remains fail-closed even when it detects an I2C RAK12500. +The explicit legacy Serial1 bridge omits the combined GPS provider, including the otherwise non-UART +RAK12500 path. + +The firmware-configured INA3221 address and the RAK12500 address are both `0x42`; they cannot coexist on one +bus at those addresses. Keep RAK12500 at `0x42`, strap INA3221 A0 to SCL for `0x43`, and use a build with +`-DTELEM_INA3221_ADDRESS=0x43` when both are installed. + Selected nRF52 boards with matched external QSPI application and bootloader support can instead make the normal full-sensor repeater install-capable; those targets do not need to reserve internal flash @@ -283,8 +296,10 @@ running application. The hash check in the next step proves that it is the right On RAK4631 repeaters, use the `RAK_4631_repeater_lora_ota_no_external_sensors` environment. It retains built-in battery monitoring but -omits optional external environmental sensor packages so the delta fits the safe in-place workspace. INA219, +omits selected optional environmental/ranging drivers so the delta fits the safe in-place workspace. INA219, INA226, INA260, and INA3221 I2C voltage/current monitors remain supported despite the legacy target name. +That trim does not remove the SSD1306 OLED, autodiscovered I2C RTCs, or GPS-compatible RAK12500 I2C path. +The `0x42` RAK12500/INA3221 addressing rule above still applies. If the device has a RAK15001 installed in sensor slot C and the matching RAK15001 OTAFIX bootloader, use `RAK_4631_repeater_rak15001_slot_c_lora_ota` instead. That target retains the diff --git a/docs/ota_meshtower_v2_sdcard.md b/docs/ota_meshtower_v2_sdcard.md index 19832f7e..d8574d34 100644 --- a/docs/ota_meshtower_v2_sdcard.md +++ b/docs/ota_meshtower_v2_sdcard.md @@ -46,7 +46,7 @@ trailer is `0xC7000` bytes (815,104 bytes). To package a full self-update: ```bash motatool build --fw ./Heltec_tower_v2_sdcard-new.hex \ - --sign-key ./trusted-signer.key --out-dir ./motas + --sign ./trusted-signer.key --out-dir ./motas motatool verify ./motas/*.mota ``` @@ -60,7 +60,7 @@ motatool build \ --fw ./Heltec_tower_v2_sdcard-new.hex \ --patch-type in-place \ --inplace-memory 0xC7000 \ - --sign-key ./trusted-signer.key \ + --sign ./trusted-signer.key \ --out-dir ./motas motatool verify ./motas/*.mota ``` diff --git a/docs/ota_nrf52_bootloader_update.md b/docs/ota_nrf52_bootloader_update.md index 947a7aaa..11d8b984 100644 --- a/docs/ota_nrf52_bootloader_update.md +++ b/docs/ota_nrf52_bootloader_update.md @@ -142,10 +142,22 @@ stable release uses `0xFF`, and low-byte zero or all-ones values are rejected. There is no remote rollback/migration override; intentional rollback uses USB/BLE DFU or SWD. -The `no_external_sensors` profiles omit optional/add-on sensor packages to -preserve flash headroom. A board recipe can retain GPS. The RAK3401 OTA -repeater retains RAK12501 GPS in sensor slot A, and the RAK4631 OTA repeater -retains GPS except where a Serial1 RS232 bridge owns the same UART. +The legacy `no_external_sensors` profiles trim selected optional environmental +and ranging drivers to preserve flash headroom; they do not generally disable +the I2C bus. Reduced RAK3401 and RAK4631 recipes retain INA219, INA226, INA260, +and INA3221 voltage/current monitors. Those are entries in the optional sensor +table, not the complete set of I2C consumers: the SSD1306 OLED, supported +autodiscovered RTCs, and RAK12500 GPS remain separate I2C peripherals where the +exact board recipe enables them. RAK3401 retains its compatible RAK12500 I2C +and RAK12501/L76K UART GPS paths in sensor slot A. The plain RAK4631 repeater +and Serial2 bridge retain GPS. Its RAK12501 path uses Serial1; the explicitly +compiled Serial1 RS232 bridge therefore omits the combined GPS provider, +including RAK12500, even though RAK12500 itself is I2C rather than UART. + +The firmware-configured INA3221 and RAK12500 addresses are both `0x42`. They +cannot coexist on the same bus at those addresses. Leave RAK12500 at `0x42`, +strap INA3221 A0 to SCL for `0x43`, and use firmware built with +`-DTELEM_INA3221_ADDRESS=0x43` when both modules are installed. Boards with onboard external flash are not silently redirected to internal staging. Mesh Solar, Nano G2 Ultra, T-Impulse Plus, ThinkNode M8, T-Echo diff --git a/docs/ota_protocol.md b/docs/ota_protocol.md index 603412b7..5bf8bf4e 100644 --- a/docs/ota_protocol.md +++ b/docs/ota_protocol.md @@ -128,13 +128,23 @@ established target-specific 1920 KiB or larger A/B app layout and are checked ag partition. For standalone ESP32 and nRF52 repeaters that need a lean staging profile, `build.sh` also exposes an explicit `*_lora_ota_no_external_sensors` artifact: the ordinary repeater remains sensor-enabled, while that sibling -disables optional external environmental-sensor drivers for LoRa distribution. SolarXiao 30S and 33S use +trims selected optional environmental/ranging drivers for LoRa distribution. SolarXiao 30S and 33S use matched external QSPI staging, so their ordinary full-sensor repeater is already install-capable and no redundant lean sibling is generated. Integrated GPS and other -board-native telemetry remain enabled where the target selects the GPS-preserving lean profile. The RAK3401 -OTA repeater also retains RAK12501 GPS support; install that module in sensor slot A because slot D conflicts -with the RAK13302 radio's BUSY/DIO1 lines. RAK4631 OTA repeaters retain GPS and default the runtime RS-232 -bridge to UART 2; do not enable GPS and an explicitly selected UART 1 bridge at the same time. +board-native telemetry remain enabled where the target selects the GPS-preserving lean profile. The legacy +suffix describes a driver trim, not removal of the generic I2C bus. Reduced RAK3401 and RAK4631 profiles +retain INA219, INA226, INA260, and INA3221 voltage/current monitors. These are retained optional-sensor-table +drivers, not the only I2C consumers: SSD1306 display, supported autodiscovered RTCs, and RAK12500 GPS remain +separate I2C peripherals where selected by the board recipe. The RAK3401 OTA repeater also retains RAK12500 +I2C and RAK12501/L76K UART GPS support; install either GPS module in sensor slot A because slot D conflicts +with the RAK13302 radio's BUSY/DIO1 lines. The plain RAK4631 OTA repeater and its Serial2 bridge retain GPS. +RAK12501 uses Serial1. Its explicitly compiled Serial1 bridge therefore omits the combined GPS provider, +including RAK12500, even though RAK12500 itself does not use the UART. + +The firmware-configured INA3221 address and RAK12500 address are both `0x42`, so those devices cannot share +one bus at those addresses. Keep RAK12500 at `0x42`, strap INA3221 A0 to SCL for `0x43`, and use firmware +built with `-DTELEM_INA3221_ADDRESS=0x43` when both are installed. + ESP32 siblings retain the compact browser WiFi updater and use the full 254-entry neighbor table. RP2040 and STM32 targets are not offered because those platforms do not yet have a safe bootloader/apply path. @@ -149,8 +159,8 @@ promotes every ESP32 MQTT observer and ESP-NOW bridge to the expanded FULL parti 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. +`*_lora_ota_no_external_sensors` siblings trim selected optional environmental/ranging drivers for LoRa +distribution, and those siblings retain the complete CLI and target-declared I2C peripherals. 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 diff --git a/docs/ota_user_guide.md b/docs/ota_user_guide.md index 9d041285..78c65939 100644 --- a/docs/ota_user_guide.md +++ b/docs/ota_user_guide.md @@ -42,22 +42,41 @@ preserves the RX benefit without risking missed packets from a legacy > install-capable image nor OTAFIX. Check the bootloader release for an exact board match before attempting an update. The following nRF52 repeater families gained firmware-side LoRa OTA targets in -this release. Their ordinary repeater remains the full-sensor build; the -install-capable `lora_ota_no_external_sensors` sibling is smaller: +this release. Their ordinary repeater keeps its complete declared sensor recipe; +the install-capable `lora_ota_no_external_sensors` sibling is smaller: - Heltec Mesh Solar, T1, and Tower V2 - Keepteen LT1, LilyGo T-Impulse Plus, Mesh Pocket, and Nano G2 Ultra - Minewsemi ME25LS01, RAK3401, SenseCAP Solar, and Wio WM1110 -The RAK3401 `RAK_3401_repeater_lora_ota_no_external_sensors` image retains -RAK12501 GPS while omitting the other optional environmental sensors. Install -the GPS in sensor slot A. Slot D's reset/PPS lines conflict with the RAK13302 -radio's BUSY/DIO1 lines. +The RAK3401 `RAK_3401_repeater_lora_ota_no_external_sensors` image trims selected +optional environmental/ranging drivers but does not disable I2C. It retains the +INA219, INA226, INA260, and INA3221 voltage/current monitors, board display and +RTC support, and both RAK12500 I2C and RAK12501/L76K UART GPS paths. The four +INA drivers are only the retained voltage/current entries in the optional +environmental-sensor table; they are not the only I2C users. The SSD1306 OLED, +autodiscovered DS3231/RV3028/PCF8563/RX8130CE RTCs, and RAK12500 GPS use I2C as +separate board peripherals. Install one GPS module in sensor slot A. Slot D's +reset/PPS lines conflict with the RAK13302 radio's BUSY/DIO1 lines. -The RAK4631 internal-flash OTA repeater likewise retains GPS. A RAK12501 can -use sensor slot A or D; a RAK12500 can use slot A or C. Its runtime RS-232 -bridge defaults to Serial2 so GPS can retain Serial1. If you explicitly select -Serial1 with `set bridge.uart 1`, turn GPS off before enabling the bridge. +The firmware-configured INA3221 address and the RAK12500 address are both +`0x42`, so those devices cannot share one bus at those addresses. To install +both, leave the RAK12500 at `0x42`, strap INA3221 A0 to SCL for `0x43`, and use +a firmware build with `-DTELEM_INA3221_ADDRESS=0x43`. + +The reduced RAK4631 profiles likewise retain the four INA monitors. The plain +repeater and Serial2 bridge retain GPS: a RAK12501 can use sensor slot A or D, +and a RAK12500 can use slot A or C. The runtime RS-232 bridge defaults to +Serial2 so the UART RAK12501 can retain Serial1. The merged runtime image always +reserves Serial1: a bounded silent probe cannot prove that a cold RAK12501 is +absent, and turning the GPS setting off cannot stop a fitted module from driving +the shared UART while WB_IO2/3V3_S remains powered. Use Serial2. Serial1 requires +an explicit no-GPS/dedicated image. The reservation remains fail-closed even +when an I2C RAK12500 is detected. The explicitly compiled +`RAK_4631_repeater_bridge_rs232_serial1_lora_ota_no_external_sensors` target +omits the combined GPS provider because its bridge owns the UART used by +RAK12501; consequently that legacy image does not expose the I2C RAK12500 path +either, even though RAK12500 itself does not use the UART. Selected nRF52 repeaters with dedicated external QSPI can now stage the complete package off-chip, so their normal full-sensor repeater build can @@ -69,9 +88,10 @@ RAK15001 slot-C target. These require the corresponding QSPI-aware OTAFIX bootlo The ordinary full-sensor `RAK_4631_repeater` image remains too large for the safe internal in-place update limit. Without external flash, use -`RAK_4631_repeater_lora_ota_no_external_sensors`; it removes optional external -environmental/GPS packages but retains battery monitoring. A RAK4631 fitted -with RAK15001 in sensor slot C can instead use +`RAK_4631_repeater_lora_ota_no_external_sensors`; it trims selected optional +environmental/ranging drivers while retaining generic I2C, battery monitoring, +the four INA monitors, and target-compatible GPS. A RAK4631 fitted with +RAK15001 in sensor slot C can instead use `RAK_4631_repeater_rak15001_slot_c_lora_ota` to retain the full sensor/GPS set and stage full images or deltas off-chip. diff --git a/examples/companion_radio/MyMesh.cpp b/examples/companion_radio/MyMesh.cpp index 8067db68..d9542901 100644 --- a/examples/companion_radio/MyMesh.cpp +++ b/examples/companion_radio/MyMesh.cpp @@ -2233,6 +2233,29 @@ bool MyMesh::handleLocalControlCommand(const char* command, char* reply, return true; } + if (strncmp(command, "set pin", 7) == 0 + && (command[7] == 0 || command[7] == ' ' + || command[7] == '\t')) { + const char* value = command + 7; + while (*value == ' ' || *value == '\t') value++; + int32_t parsed = 0; + if (!mesh::cli::parseIntegerStrict(value, parsed) + || parsed < 0 || parsed > 999999) { + snprintf(reply, reply_size, "Error: pin must be 0-999999"); + } else { + const uint32_t previous = _prefs.ble_pin; + _prefs.ble_pin = static_cast(parsed); + if (!savePrefs()) { + _prefs.ble_pin = previous; + snprintf(reply, reply_size, "Error: pin changed but save failed"); + } else { + snprintf(reply, reply_size, "> pin is now %06lu", + (unsigned long)_prefs.ble_pin); + } + } + return true; + } + #if defined(MESH_PRIMARY_ESPNOW) && MESH_PRIMARY_ESPNOW if (strcmp(command, "get espnow.channel") == 0) { formatEspNowChannel(reply, reply_size); @@ -5999,7 +6022,17 @@ void MyMesh::handleTerminalCommand(char* command) { terminalOutput().printf(" OK - FEM TX gain %s\r\n", value); } } else { - terminalOutput().printf(" ERROR: unknown setting: %s\r\n", config); + // The terminal has a few presentation-specific setters above, while + // handleCommand() owns the shared radio-pref and board-specific command + // surface used by framed/rescue clients. Use it only as the fallback so + // Full Companion does not develop holes such as `set radio ...` without + // changing the established terminal behavior of `set tx`, `set name`, + // or the WiFi commands. + if (handleCommand(command, 0, local_reply)) { + terminalOutput().printf(" %s\r\n", local_reply); + } else { + terminalOutput().printf(" ERROR: unknown setting: %s\r\n", config); + } } } else if (strcmp(command, "reboot") == 0) { terminalOutput().print(" OK - rebooting in 1 second\r\n"); @@ -6018,6 +6051,7 @@ void MyMesh::handleTerminalCommand(char* command) { terminalOutput().print(" set {name|lat|lon|freq|tx|af} {value}\r\n"); terminalOutput().print(" get bluetooth.name\r\n"); terminalOutput().print(" set bluetooth.name \r\n"); + terminalOutput().print(" set pin <0-999999>\r\n"); terminalOutput().print(" powersaving [on|off]\r\n"); #if MESH_USB_LOGGING_AVAILABLE terminalOutput().print(" get usb.logging\r\n"); @@ -6086,6 +6120,11 @@ void MyMesh::handleTerminalCommand(char* command) { } else { terminalOutput().print(" disconnect (closes the TCP terminal)\r\n"); } + } else if (handleCommand(command, 0, local_reply)) { + // Fill the same safe shared-command surface for getters (`get name`, + // `get radio`, `get tx`, and variant commands). Terminal-only commands + // above still win, including the richer `ver` response and reboot flow. + terminalOutput().printf(" %s\r\n", local_reply); } else { terminalOutput().printf(" ERROR: unknown command: %s\r\n", command); } @@ -6235,25 +6274,6 @@ bool MyMesh::handleCommand(const char* command, uint32_t sender_timestamp, return true; } - if (strncmp(command, "set pin ", 8) == 0) { - int32_t parsed = 0; - if (!mesh::cli::parseIntegerStrict(command + 8, parsed) - || parsed < 0 || parsed > 999999) { - strcpy(reply, "Error: pin must be 0-999999"); - } else { - const uint32_t previous = _prefs.ble_pin; - _prefs.ble_pin = static_cast(parsed); - if (!savePrefs()) { - _prefs.ble_pin = previous; - strcpy(reply, "Error: pin changed but save failed"); - } else { - snprintf(reply, reply_capacity, "> pin is now %06lu", - (unsigned long)_prefs.ble_pin); - } - } - return true; - } - if (strcmp(command, "ver") == 0) { snprintf(reply, reply_capacity, "%s (Build: %s)", FIRMWARE_VERSION, FIRMWARE_BUILD_DATE); diff --git a/examples/companion_radio/main.cpp b/examples/companion_radio/main.cpp index d46ffec2..54eff05a 100644 --- a/examples/companion_radio/main.cpp +++ b/examples/companion_radio/main.cpp @@ -67,6 +67,7 @@ MultiSerialInterface interface_manager; #include #include #include + #include #include #include #include @@ -946,6 +947,47 @@ void halt() { static DisplayDriver* companion_setup_display = nullptr; static unsigned long companion_setup_display_refresh = 0; + static bool hasLargeCompanionSetupDisplay() { + return companion_setup_display->width() >= 128 + && companion_setup_display->height() >= 128; + } + + static int drawCompanionSetupValue(int y, int max_lines, + const char* value) { + static constexpr int margin = 6; + static constexpr int line_height = 13; + return mesh::ui::drawTextWrapped( + *companion_setup_display, margin, y, + companion_setup_display->width() - margin * 2, line_height, + max_lines, value != nullptr && value[0] != 0 ? value : "(not set)"); + } + + static void renderLargeCompanionSetupDisplay(const char* title, + const char* wifi_label, + const char* wifi_name, + const char* address, + bool connecting) { + static constexpr int line_height = 13; + companion_setup_display->drawTextCentered( + companion_setup_display->width() / 2, 4, title); + companion_setup_display->setCursor(6, 24); + companion_setup_display->print(wifi_label); + const int wifi_lines = drawCompanionSetupValue(38, 3, wifi_name); + + int next_y = 38 + (wifi_lines > 0 ? wifi_lines : 1) * line_height + 8; + if (connecting) { + companion_setup_display->drawTextCentered( + companion_setup_display->width() / 2, next_y, "Please wait..."); + return; + } + + companion_setup_display->setCursor(6, next_y); + companion_setup_display->print("Open in browser:"); + char url[32]; + snprintf(url, sizeof(url), "http://%s/", address ? address : ""); + drawCompanionSetupValue(next_y + 14, 2, url); + } + static void renderCompanionSetupDisplay() { if (!companion_setup_display || static_cast(millis() - companion_setup_display_refresh) < 0) return; @@ -960,37 +1002,54 @@ void halt() { char setup_ip[16] = {0}; if (WebConfigServer::getSetupInfo(setup_ssid, sizeof(setup_ssid), setup_ip, sizeof(setup_ip))) { - companion_setup_display->drawTextCentered( - companion_setup_display->width() / 2, 0, "WebUI setup"); - companion_setup_display->setCursor(0, 14); - companion_setup_display->print("Join open WiFi:"); - companion_setup_display->drawTextEllipsized( - 0, 25, companion_setup_display->width(), setup_ssid); - companion_setup_display->setCursor(0, 39); - companion_setup_display->print("Open in browser:"); - companion_setup_display->drawTextCentered( - companion_setup_display->width() / 2, 51, setup_ip); + if (hasLargeCompanionSetupDisplay()) { + renderLargeCompanionSetupDisplay( + "WebUI setup", "Join open WiFi:", setup_ssid, setup_ip, false); + } else { + companion_setup_display->drawTextCentered( + companion_setup_display->width() / 2, 0, "WebUI setup"); + companion_setup_display->setCursor(0, 14); + companion_setup_display->print("Join open WiFi:"); + companion_setup_display->drawTextEllipsized( + 0, 25, companion_setup_display->width(), setup_ssid); + companion_setup_display->setCursor(0, 39); + companion_setup_display->print("Open in browser:"); + companion_setup_display->drawTextCentered( + companion_setup_display->width() / 2, 51, setup_ip); + } } else if (WiFi.status() == WL_CONNECTED) { - companion_setup_display->drawTextCentered( - companion_setup_display->width() / 2, 0, "WebUI"); - companion_setup_display->setCursor(0, 14); - companion_setup_display->print("Join WiFi:"); - companion_setup_display->drawTextEllipsized( - 0, 25, companion_setup_display->width(), configured_wifi_ssid); - companion_setup_display->setCursor(0, 39); - companion_setup_display->print("Open in browser:"); const String ip = WiFi.localIP().toString(); - companion_setup_display->drawTextCentered( - companion_setup_display->width() / 2, 51, ip.c_str()); + if (hasLargeCompanionSetupDisplay()) { + renderLargeCompanionSetupDisplay( + "WebUI ready", "Join WiFi:", configured_wifi_ssid, ip.c_str(), + false); + } else { + companion_setup_display->drawTextCentered( + companion_setup_display->width() / 2, 0, "WebUI"); + companion_setup_display->setCursor(0, 14); + companion_setup_display->print("Join WiFi:"); + companion_setup_display->drawTextEllipsized( + 0, 25, companion_setup_display->width(), configured_wifi_ssid); + companion_setup_display->setCursor(0, 39); + companion_setup_display->print("Open in browser:"); + companion_setup_display->drawTextCentered( + companion_setup_display->width() / 2, 51, ip.c_str()); + } } else { - companion_setup_display->drawTextCentered( - companion_setup_display->width() / 2, 8, "WiFi connecting"); - companion_setup_display->setCursor(0, 25); - companion_setup_display->print("SSID:"); - companion_setup_display->drawTextEllipsized( - 0, 38, companion_setup_display->width(), configured_wifi_ssid); - companion_setup_display->drawTextCentered( - companion_setup_display->width() / 2, 52, "Please wait..."); + if (hasLargeCompanionSetupDisplay()) { + renderLargeCompanionSetupDisplay( + "WiFi connecting", "WiFi name:", configured_wifi_ssid, nullptr, + true); + } else { + companion_setup_display->drawTextCentered( + companion_setup_display->width() / 2, 8, "WiFi connecting"); + companion_setup_display->setCursor(0, 25); + companion_setup_display->print("SSID:"); + companion_setup_display->drawTextEllipsized( + 0, 38, companion_setup_display->width(), configured_wifi_ssid); + companion_setup_display->drawTextCentered( + companion_setup_display->width() / 2, 52, "Please wait..."); + } } companion_setup_display->endFrame(); } @@ -1698,6 +1757,13 @@ void loop() { #endif sensors.loop(); #ifdef DISPLAY_CLASS + #ifdef INDICATOR_WIFI_FONT_RECOVERY + // The Indicator keeps rendering with its built-in fallback while a missing + // SD font is recovered by a background TLS task. This poll only launches + // work after station Wi-Fi connects and installs a completed font on the + // main/UI task. + display.serviceFontRecovery(); + #endif #if defined(ESP32) && defined(WIFI_SSID) && defined(WITH_WEBCONFIG) if (isCompanionWiFiEnabled() && (the_mesh.isWebConfigSetupActive() #ifdef WITH_MQTT_BRIDGE diff --git a/examples/simple_repeater/MyMesh.cpp b/examples/simple_repeater/MyMesh.cpp index 5aad7e1b..093fe3c4 100644 --- a/examples/simple_repeater/MyMesh.cpp +++ b/examples/simple_repeater/MyMesh.cpp @@ -5,8 +5,11 @@ #include // for qsort() #include #include +#include +#include #include #include +#include #include #if MESH_PACKET_LOGGING #include @@ -525,18 +528,23 @@ void MyMesh::putNeighbour(const mesh::Identity &id, uint32_t timestamp, float sn } uint8_t MyMesh::handleLoginReq(const mesh::Identity& sender, const uint8_t* secret, uint32_t sender_timestamp, const uint8_t* data, bool is_flood) { - ClientInfo* client = NULL; - if (strcmp((char *)data, _prefs.password) != 0) { // admin pw bypasses ACL (allows upgrade) - client = acl.getClient(sender.pub_key, PUB_KEY_SIZE); - if (client == NULL) { + ClientInfo* existing_client = + acl.getClient(sender.pub_key, PUB_KEY_SIZE); + const bool admin_password = + strcmp((char *)data, _prefs.password) == 0; + ClientInfo* client = admin_password ? NULL : existing_client; + uint8_t perms; + if (client != NULL) { + // A preauthorized ACL identity is authenticated by the anonymous packet's + // ECDH MAC and does not need to repeat a password. Preserve its role. + perms = client->permissions & PERM_ACL_ROLE_MASK; + } else { + if (!admin_password && existing_client == NULL) { #if MESH_DEBUG MESH_DEBUG_PRINTLN("Login, sender not in ACL"); #endif } - } - if (client == NULL) { - uint8_t perms; - if (strcmp((char *)data, _prefs.password) == 0) { // check for valid admin password + if (admin_password) { // valid admin password bypasses ACL (allows upgrade) perms = PERM_ACL_ADMIN; } else if (strcmp((char *)data, _prefs.guest_password) == 0) { // check guest password perms = PERM_ACL_GUEST; @@ -546,31 +554,46 @@ uint8_t MyMesh::handleLoginReq(const mesh::Identity& sender, const uint8_t* secr #endif return 0; } + } + const bool client_existed = existing_client != NULL; + const uint32_t previous_timestamp = + client_existed ? existing_client->last_timestamp : 0; + if (!acl.authorizeLoginTimestamp( + sender.pub_key, sender_timestamp, previous_timestamp, perms)) { + MESH_DEBUG_PRINTLN( + "Login rejected: replayed timestamp or replay state unavailable"); + return 0; + } + + if (client == NULL) { client = acl.putClient(sender, 0); // add to contacts (if not already known) if (client == NULL) { MESH_DEBUG_PRINTLN("Login rejected: ACL is full of protected contacts"); return 0; } - if (sender_timestamp <= client->last_timestamp) { - MESH_DEBUG_PRINTLN("Possible login replay attack!"); - return 0; // FATAL: client table is full -OR- replay attack - } - - MESH_DEBUG_PRINTLN("Login success!"); - client->last_timestamp = sender_timestamp; - client->last_activity = getRTCClock()->getCurrentTime(); - client->permissions &= ~PERM_ACL_ROLE_MASK; - client->permissions |= perms; - memcpy(client->shared_secret, secret, PUB_KEY_SIZE); - - if (perms != PERM_ACL_GUEST) { // keep number of FS writes to a minimum - dirty_contacts_expiry = futureMillis(LAZY_CONTACTS_WRITE_DELAY); - } } - if (is_flood) { - client->out_path_len = OUT_PATH_UNKNOWN; // need to rediscover out_path + MESH_DEBUG_PRINTLN("Login success!"); + const uint8_t previous_permissions = client->permissions; + const bool reset_out_path = is_flood + && client->out_path_len != OUT_PATH_FORCE_FLOOD; + const bool persisted_changed = mesh::applySuccessfulClientLogin( + *client, client_existed, perms, PERM_ACL_ROLE_MASK, + secret, sender_timestamp, + getRTCClock()->getCurrentTime(), reset_out_path, OUT_PATH_UNKNOWN); + + const bool persistence_needed = + mesh::successfulClientLoginNeedsPersistence( + client_existed, previous_permissions, client->permissions, + PERM_ACL_ROLE_MASK, PERM_ACL_GUEST, + persisted_changed); + if (persistence_needed) { + // Keep writes bounded: replay/activity timestamps are transient, so an + // unchanged preauthorized/admin login does not rewrite the complete ACL. + mesh::scheduleLazyPersistenceMutation( + dirty_contacts_expiry, contacts_save_failures, + futureMillis(LAZY_CONTACTS_WRITE_DELAY)); } uint32_t now = getRTCClock()->getCurrentTimeUnique(); @@ -671,9 +694,9 @@ uint8_t MyMesh::handleAnonClockReq(const mesh::Identity& sender, uint32_t sender memcpy(&reply_data[4], &now, 4); // include our clock (for easy clock sync, and packet hash uniqueness) reply_data[8] = 0; // features #ifdef WITH_RS232_BRIDGE - if (_prefs.bridge_enabled) reply_data[8] |= 0x01; // is bridge, type UART + if (isBridgeRunning()) reply_data[8] |= 0x01; // is bridge, type UART #elif WITH_ESPNOW_BRIDGE - reply_data[8] |= 0x03; // is bridge, type ESP-NOW + if (isBridgeRunning()) reply_data[8] |= 0x03; // is bridge, type ESP-NOW #endif if (_prefs.disable_fwd) { // is this repeater currently disabled reply_data[8] |= 0x80; // is disabled @@ -933,23 +956,30 @@ void MyMesh::sendClientReply(ClientInfo* client, mesh::Packet* packet, unsigned fallback_scope_ptr); } -void MyMesh::sendClientReplyWithFallbackScope(ClientInfo* client, mesh::Packet* packet, +bool MyMesh::sendClientReplyWithFallbackScope(ClientInfo* client, mesh::Packet* packet, unsigned long delay_millis, uint8_t path_hash_size, - const TransportKey* fallback_scope) { + const TransportKey* fallback_scope, + bool allow_redundant_copies) { if (packet == NULL) { - return; + return false; } if (client == NULL || !mesh::Packet::isValidPathLen(client->out_path_len)) { + const uint8_t flood_retry_attempts = _prefs.flood_retry_attempts; + if (!allow_redundant_copies) _prefs.flood_retry_attempts = 0; + bool queued; if (fallback_scope != NULL) { - sendFloodScoped(*fallback_scope, packet, delay_millis, path_hash_size); + queued = sendFloodScoped(*fallback_scope, packet, delay_millis, + path_hash_size); } else { - sendFlood(packet, delay_millis, path_hash_size); + queued = sendFlood(packet, delay_millis, path_hash_size); } - return; + _prefs.flood_retry_attempts = flood_retry_attempts; + return queued; } mesh::Packet* alt = NULL; - if (mesh::Packet::isValidPathLen(client->alt_path_len) + if (allow_redundant_copies + && mesh::Packet::isValidPathLen(client->alt_path_len) && !directPathsEqual(client->out_path, client->out_path_len, client->alt_path, client->alt_path_len)) { alt = obtainNewPacket(); if (alt != NULL) { @@ -959,13 +989,17 @@ void MyMesh::sendClientReplyWithFallbackScope(ClientInfo* client, mesh::Packet* } } - sendDirect(packet, client->out_path, client->out_path_len, delay_millis); + const uint8_t direct_retry_enabled = _prefs.direct_retry_enabled; + if (!allow_redundant_copies) _prefs.direct_retry_enabled = 0; + const bool primary_queued = + sendDirect(packet, client->out_path, client->out_path_len, delay_millis); + _prefs.direct_retry_enabled = direct_retry_enabled; if (alt != NULL) { - uint8_t direct_retry_enabled = _prefs.direct_retry_enabled; _prefs.direct_retry_enabled = 0; sendDirect(alt, client->alt_path, client->alt_path_len, delay_millis); _prefs.direct_retry_enabled = direct_retry_enabled; } + return primary_queued; } bool MyMesh::floodChannelDataHopApplies(const mesh::Packet* packet) const { @@ -1088,8 +1122,10 @@ void MyMesh::logRx(mesh::Packet *pkt, int len, float score) { if (mqtt_bridge) mqtt_bridge->onPacketReceived(pkt); #elif defined(WITH_BRIDGE) // Non-MQTT bridge: use bridge.source setting - if (_prefs.bridge_pkt_src == 1) { - activeBridge()->sendPacket(pkt); + AbstractBridge* active_bridge = activeBridge(); + if (_prefs.bridge_pkt_src == 1 && active_bridge + && active_bridge->isRunning()) { + active_bridge->sendPacket(pkt); } #endif @@ -1131,8 +1167,10 @@ void MyMesh::logTx(mesh::Packet *pkt, int len) { if (mqtt_bridge) mqtt_bridge->sendPacket(pkt); #elif defined(WITH_BRIDGE) // Non-MQTT bridge: use bridge.source setting - if (_prefs.bridge_pkt_src == 0) { - activeBridge()->sendPacket(pkt); + AbstractBridge* active_bridge = activeBridge(); + if (_prefs.bridge_pkt_src == 0 && active_bridge + && active_bridge->isRunning()) { + active_bridge->sendPacket(pkt); } #endif @@ -2503,6 +2541,10 @@ void MyMesh::onAnonDataRecv(mesh::Packet *packet, const uint8_t *secret, const m uint8_t *data, size_t len) { if (packet->getPayloadType() == PAYLOAD_TYPE_ANON_REQ) { // received an initial request by a possible admin // client (unknown at this stage) + if (len < 4) { + MESH_DEBUG_PRINTLN("Rejected incomplete anonymous request"); + return; + } uint32_t timestamp; memcpy(×tamp, data, 4); @@ -2750,10 +2792,22 @@ void MyMesh::onPeerDataRecv(mesh::Packet *packet, uint8_t type, int sender_idx, && region_map.getTransportKeysFor(*recv_pkt_region, &reply_scope, 1) > 0; if (cached_retry) { - MESH_DEBUG_PRINTLN("onPeerDataRecv: replaying cached remote CLI reply"); - sendRemoteCliReply(client, secret, packet->getPathHashSize(), - sender_timestamp, cached_response, - reply_scoped ? &reply_scope : NULL); + const bool cached_temp_radio_success = + strncmp(command, "tempradio ", 10) == 0 + && strncmp(cached_response, "OK - temp params for ", 21) == 0; + if (cached_temp_radio_success) { + // The original acknowledgement is the only packet allowed to + // release its radio-mutation barrier. Replaying this success would + // create another untracked copy, including after a TX failure + // cancelled the handoff. + MESH_DEBUG_PRINTLN( + "onPeerDataRecv: TempRadio acknowledgement is already authoritative"); + } else { + MESH_DEBUG_PRINTLN("onPeerDataRecv: replaying cached remote CLI reply"); + sendRemoteCliReply(client, secret, packet->getPathHashSize(), + sender_timestamp, cached_response, + reply_scoped ? &reply_scope : NULL); + } } else if (deferred_cli_command.matches(i, request_id, command, command_len)) { // The original request is already queued. Let it produce the one @@ -2787,10 +2841,12 @@ void MyMesh::onPeerDataRecv(mesh::Packet *packet, uint8_t type, int sender_idx, } } -void MyMesh::sendRemoteCliReply(ClientInfo* client, const uint8_t* secret, +bool MyMesh::sendRemoteCliReply(ClientInfo* client, const uint8_t* secret, uint8_t path_hash_size, uint32_t sender_timestamp, - const char* reply, const TransportKey* fallback_scope) { - if (client == NULL || secret == NULL || reply == NULL) return; + const char* reply, const TransportKey* fallback_scope, + mesh::Packet** queued_packet) { + if (queued_packet != NULL) *queued_packet = NULL; + if (client == NULL || secret == NULL || reply == NULL) return false; if (reply[0] == 0) reply = "OK"; size_t text_len = strlen(reply); @@ -2811,8 +2867,17 @@ void MyMesh::sendRemoteCliReply(ClientInfo* client, const uint8_t* secret, mesh::Packet* packet = createDatagram(PAYLOAD_TYPE_TXT_MSG, client->id, secret, reply_data, 5 + text_len); - sendClientReplyWithFallbackScope(client, packet, CLI_REPLY_DELAY_MILLIS, - path_hash_size, fallback_scope); + // A caller which requests the exact queued packet is going to make a state + // transition depend on that packet's TX result. Keep one authoritative + // copy: an untracked alternate or retry could report success after primary + // failure cancelled the transition, or still be queued when primary success + // lets the radio tuple change. Ordinary replies retain redundant delivery. + const bool allow_redundant_copies = queued_packet == NULL; + const bool queued = sendClientReplyWithFallbackScope( + client, packet, CLI_REPLY_DELAY_MILLIS, path_hash_size, fallback_scope, + allow_redundant_copies); + if (queued && queued_packet != NULL) *queued_packet = packet; + return queued; } #if defined(ESP32_PLATFORM) || defined(USER_GPIO_CONTROL) @@ -3059,13 +3124,31 @@ void __attribute__((noinline)) MyMesh::processDeferredCliCommand() { mesh::RemoteCliReplyCache::fingerprint( deferred_cli_command.command, strlen(deferred_cli_command.command)); + const bool arms_temp_radio = + strncmp(deferred_cli_command.command, "tempradio ", 10) == 0 + && strncmp(reply, "OK - temp params for ", 21) == 0; remote_cli_reply_cache.remember(client->id.pub_key, deferred_cli_command.request_id, command_fingerprint, reply); - sendRemoteCliReply(client, deferred_cli_command.secret, - deferred_cli_command.path_hash_size, - deferred_cli_command.sender_timestamp, reply, - deferred_cli_reply_scoped ? &deferred_cli_reply_scope : NULL); + mesh::Packet* queued_reply = NULL; + const bool reply_queued = sendRemoteCliReply( + client, deferred_cli_command.secret, + deferred_cli_command.path_hash_size, + deferred_cli_command.sender_timestamp, reply, + deferred_cli_reply_scoped ? &deferred_cli_reply_scope : NULL, + arms_temp_radio ? &queued_reply : NULL); + if (arms_temp_radio) { + if (reply_queued && queued_reply != NULL) { + // The old two-RTC-second delay could expire while this exact packet was + // still parked behind CAD/duty/queue work. Do not change modulation + // until Dispatcher confirms that the reply physically transmitted. + temp_radio_reply_barrier.arm(queued_reply); + } else { + // A command which cannot queue its acknowledgement must never strand + // the administrator on an unconfirmed tuple. + scheduleNormalRadio(); + } + } clearDeferredCliCommand(); } @@ -3078,10 +3161,16 @@ bool MyMesh::onPeerPathRecv(mesh::Packet *packet, int sender_idx, const uint8_t MESH_DEBUG_PRINTLN("PATH to client, path_len=%d", (uint32_t)path_len); auto client = acl.getClientByIdx(i); - // store a copy of path, for sendDirect() - if (client->out_path_len != OUT_PATH_FORCE_FLOOD) { - client->out_path_len = mesh::Packet::copyPath(client->out_path, path, path_len); - } + // PATH packets authenticate the sender and are de-duplicated in RAM, but + // the protocol carries no timestamp or request nonce. Keep a learned route + // useful for this boot without making an arbitrarily old replay durable. + const bool persistence_allowed = mesh::clientPathPersistenceAllowed( + client->permissions != 0, false /* no durable replay proof */); + const mesh::ClientPathUpdateResult path_update = + mesh::applyReceivedClientPath( + *client, path, path_len, persistence_allowed, + OUT_PATH_FORCE_FLOOD); + (void)path_update; client->last_activity = getRTCClock()->getCurrentTime(); } else { MESH_DEBUG_PRINTLN("onPeerPathRecv: invalid peer idx: %d", i); @@ -3207,6 +3296,7 @@ MyMesh::MyMesh(mesh::MainBoard &board, mesh::Radio &radio, mesh::MillisecondCloc pending_battery_alert_packet = NULL; battery_alert_sent = false; dirty_contacts_expiry = 0; + contacts_save_failures = 0; active_bw = 0.0f; active_sf = 0; active_cr = 0; @@ -3501,7 +3591,17 @@ void MyMesh::begin(FILESYSTEM *fs) { if (!bridge) { bridge = createRS232Bridge(); } -#endif + if (!bridge || !beginRS232Bridge()) { + // Keep configured intent for a later explicit retry, but clean any + // partial object/GPS ownership. Advertisements and bridge.running report + // the actual stopped state rather than the saved preference. + MESH_DEBUG_PRINTLN("RS232 bridge configured on but failed to start"); + if (!setBridgeState(false)) { + MESH_DEBUG_PRINTLN( + "RS232 bridge cleanup failed; UART/GPS ownership remains tracked"); + } + } +#else AbstractBridge* active_bridge = activeBridge(); if (active_bridge) { #ifdef WITH_MQTT_BRIDGE @@ -3534,6 +3634,7 @@ void MyMesh::begin(FILESYSTEM *fs) { active_bridge->begin(); } +#endif } #endif @@ -3754,6 +3855,7 @@ bool MyMesh::sendRepeatersFloodText(const char* text, const TransportKey* scope, void MyMesh::onSendComplete(mesh::Packet* packet) { mesh::Mesh::onSendComplete(packet); + temp_radio_reply_barrier.complete(packet); if (packet == pending_battery_alert_packet) { pending_battery_alert_packet = NULL; battery_alert_sent = true; @@ -3763,6 +3865,12 @@ void MyMesh::onSendComplete(mesh::Packet* packet) { void MyMesh::onSendFail(mesh::Packet* packet) { mesh::Mesh::onSendFail(packet); + if (temp_radio_reply_barrier.fail(packet)) { + // Failure of the exact acknowledgement packet cancels the unconfirmed + // handoff. A later cached-command retry may replay the reply, but it does + // not re-run the mutation. + scheduleNormalRadio(); + } if (packet == pending_battery_alert_packet) { pending_battery_alert_packet = NULL; } @@ -4075,6 +4183,7 @@ void MyMesh::clearScheduledRadioSetting(int idx, bool restore_if_started) { && scheduled_radio_settings[idx].started; scheduled_radio_settings[idx].active = false; scheduled_radio_settings[idx].started = false; + scheduled_radio_settings[idx].hard_end_uptime_millis = 0; refreshScheduledRadioState(); if (scheduled_radio_settings[idx].temporary && temp_radio_handoff_pending && countScheduledRadioSettings(true) == 0) { @@ -4197,6 +4306,12 @@ void MyMesh::addScheduledRadioParams(bool temporary, float freq, float bw, uint8 scheduled_radio_settings[slot].cr = cr; scheduled_radio_settings[slot].start_time = start_time; scheduled_radio_settings[slot].end_time = temporary ? end_time : 0; + const uint64_t current_uptime_millis = + uptime_millis + (uint32_t)(millis() - last_millis); + scheduled_radio_settings[slot].hard_end_uptime_millis = temporary + ? mesh::TempRadioLeaseDeadline::fromEpochEnd( + current_uptime_millis, now, end_time) + : 0; // A newly requested schedule must not inherit the backoff of an older radio // apply failure, especially when its deadline is sooner than that retry. scheduled_radio_retry_at = 0; @@ -4275,6 +4390,7 @@ void MyMesh::deleteScheduledRadioParams(bool temporary, const char* selector, ch restore_radio = restore_radio || (setting.temporary && setting.started); setting.active = false; setting.started = false; + setting.hard_end_uptime_millis = 0; deleted++; } } @@ -4306,7 +4422,40 @@ void MyMesh::deleteScheduledRadioParams(bool temporary, const char* selector, ch } void MyMesh::processScheduledRadioSettings() { - if (scheduled_radio_retry_at && !millisHasNowPassed(scheduled_radio_retry_at)) return; + const uint64_t current_uptime_millis = + uptime_millis + (uint32_t)(millis() - last_millis); + bool hard_temp_end_due = false; + for (int i = 0; i < MAX_SCHEDULED_RADIO_SETTINGS; i++) { + const ScheduledRadioSetting& setting = scheduled_radio_settings[i]; + if (setting.active && setting.temporary + && mesh::TempRadioLeaseDeadline::expired( + current_uptime_millis, setting.hard_end_uptime_millis)) { + hard_temp_end_due = true; + break; + } + } + + if (temp_radio_reply_barrier.waiting() && !hard_temp_end_due) { + // The exact parameterized-TempRadio reply is queued or on air. Unlike a + // fixed RTC delay, this remains correct under CAD, duty throttling, and + // unrelated queue pressure. TX completion/failure releases the barrier. + scheduled_radio_retry_at = futureMillis(RADIO_APPLY_RETRY_INTERVAL_MILLIS); + return; + } + if (hard_temp_end_due) { + // Expiry is stronger than reply delivery. This matters when a new + // immediate TempRadio command arrives while an older temporary tuple is + // already active: a permanently queued acknowledgement must not preserve + // that old tuple beyond the newly accepted hard lease. The late reply can + // still leave on the restored channel, but it can no longer cause a switch. + temp_radio_reply_barrier.clear(); + } + // A radio-apply backoff must not turn into a lease extension. Bypass it + // only for an expired temporary window; ordinary starts and permanent + // changes retain their existing bounded retry behavior. + if (scheduled_radio_retry_at + && !millisHasNowPassed(scheduled_radio_retry_at) + && !hard_temp_end_due) return; const bool schedule_check_due = next_scheduled_radio_time != 0 && (next_scheduled_radio_check_at == 0 @@ -4320,7 +4469,7 @@ void MyMesh::processScheduledRadioSettings() { } bool saved_apply_due = saved_radio_apply_pending && !temp_radio_handoff_pending && !scheduled_temp_radio_started; - if (!schedule_due && !saved_apply_due) return; + if (!schedule_due && !saved_apply_due && !hard_temp_end_due) return; // Never touch modulation registers while a packet is still on air. Back off // this check too; a long packet should not make the scheduler poll every loop. @@ -4332,6 +4481,28 @@ void MyMesh::processScheduledRadioSettings() { bool apply_failed = false; bool saved_params_changed = false; + if (hard_temp_end_due) { + // This applies to pending as well as active windows. A backward wall-clock + // correction can therefore neither postpone activation into the future nor + // keep an already-active node on the temporary tuple past the accepted + // monotonic lease bound. + for (int i = 0; i < MAX_SCHEDULED_RADIO_SETTINGS; i++) { + ScheduledRadioSetting& setting = scheduled_radio_settings[i]; + if (setting.active && setting.temporary + && mesh::TempRadioLeaseDeadline::expired( + current_uptime_millis, setting.hard_end_uptime_millis)) { + const bool was_started = setting.started; + setting.active = false; + setting.started = false; + setting.hard_end_uptime_millis = 0; + if (was_started || temp_radio_handoff_pending) { + temp_radio_handoff_pending = false; + queueSavedRadioApply(); + } + } + } + } + while (schedule_due) { int due_idx = -1; for (int i = 0; i < MAX_SCHEDULED_RADIO_SETTINGS; i++) { @@ -4374,6 +4545,7 @@ void MyMesh::processScheduledRadioSettings() { if (setting.active && setting.temporary && setting.started && now >= setting.end_time) { setting.active = false; setting.started = false; + setting.hard_end_uptime_millis = 0; queueSavedRadioApply(); } } @@ -4383,6 +4555,7 @@ void MyMesh::processScheduledRadioSettings() { if (setting.active && setting.temporary && !setting.started && now >= setting.start_time) { if (now >= setting.end_time) { setting.active = false; + setting.hard_end_uptime_millis = 0; if (temp_radio_handoff_pending) { temp_radio_handoff_pending = false; queueSavedRadioApply(); @@ -4431,6 +4604,18 @@ bool MyMesh::isMillisTimerDue(unsigned long timestamp) const { } bool MyMesh::hasScheduledRadioWorkDue() const { + const uint64_t current_uptime_millis = + uptime_millis + (uint32_t)(millis() - last_millis); + for (int i = 0; i < MAX_SCHEDULED_RADIO_SETTINGS; i++) { + const ScheduledRadioSetting& setting = scheduled_radio_settings[i]; + if (setting.active && setting.temporary + && mesh::TempRadioLeaseDeadline::expired( + current_uptime_millis, setting.hard_end_uptime_millis)) return true; + } + // The monotonic lease end above is authoritative even while an ordinary + // radio-apply retry is backed off. Checking the retry first could let the + // power manager sleep through the hard end and extend TempRadio by up to a + // complete retry interval. if (scheduled_radio_retry_at && !millisHasNowPassed(scheduled_radio_retry_at)) return false; if (saved_radio_apply_pending && !temp_radio_handoff_pending && !scheduled_temp_radio_started) return true; @@ -4453,10 +4638,21 @@ uint32_t MyMesh::limitSleepToMillisTimer(unsigned long timestamp, uint32_t sleep } uint32_t MyMesh::limitSleepToScheduledRadioWork(uint32_t sleep_secs) const { - if (scheduled_radio_retry_at && !millisHasNowPassed(scheduled_radio_retry_at)) { - return limitSleepToMillisTimer(scheduled_radio_retry_at, sleep_secs); + sleep_secs = limitSleepToMillisTimer(next_scheduled_radio_check_at, sleep_secs); + const uint64_t current_uptime_millis = + uptime_millis + (uint32_t)(millis() - last_millis); + for (int i = 0; i < MAX_SCHEDULED_RADIO_SETTINGS; i++) { + const ScheduledRadioSetting& setting = scheduled_radio_settings[i]; + if (!setting.active || !setting.temporary) continue; + const uint32_t hard_end_seconds = + mesh::TempRadioLeaseDeadline::secondsUntil( + current_uptime_millis, setting.hard_end_uptime_millis); + if (hard_end_seconds < sleep_secs) sleep_secs = hard_end_seconds; } - return limitSleepToMillisTimer(next_scheduled_radio_check_at, sleep_secs); + if (scheduled_radio_retry_at && !millisHasNowPassed(scheduled_radio_retry_at)) { + sleep_secs = limitSleepToMillisTimer(scheduled_radio_retry_at, sleep_secs); + } + return sleep_secs; } uint32_t MyMesh::getPowerSaveSleepSeconds(uint32_t max_secs) const { @@ -4487,6 +4683,10 @@ uint32_t MyMesh::getPowerSaveSleepSeconds(uint32_t max_secs) const { } void MyMesh::applyTempRadioParams(float freq, float bw, uint8_t sf, uint8_t cr, int timeout_mins) { + // A newer TempRadio command supersedes the reply barrier belonging to the + // old schedule. processDeferredCliCommand() arms the new exact reply after + // it has been composed and successfully queued. + temp_radio_reply_barrier.clear(); scheduled_radio_retry_at = 0; scheduled_radio_retry_failures = 0; bool cancelled_started_temp = false; @@ -4495,6 +4695,7 @@ void MyMesh::applyTempRadioParams(float freq, float bw, uint8_t sf, uint8_t cr, cancelled_started_temp = cancelled_started_temp || scheduled_radio_settings[i].started; scheduled_radio_settings[i].active = false; scheduled_radio_settings[i].started = false; + scheduled_radio_settings[i].hard_end_uptime_millis = 0; } } if (cancelled_started_temp) { @@ -4524,6 +4725,12 @@ void MyMesh::applyTempRadioParams(float freq, float bw, uint8_t sf, uint8_t cr, scheduled_radio_settings[slot].cr = cr; scheduled_radio_settings[slot].start_time = start_time; scheduled_radio_settings[slot].end_time = start_time + ((uint32_t)timeout_mins * 60); + const uint64_t current_uptime_millis = + uptime_millis + (uint32_t)(millis() - last_millis); + scheduled_radio_settings[slot].hard_end_uptime_millis = + mesh::TempRadioLeaseDeadline::fromEpochEnd( + current_uptime_millis, getRTCClock()->getCurrentTime(), + scheduled_radio_settings[slot].end_time); refreshScheduledRadioState(); } @@ -4531,10 +4738,12 @@ bool MyMesh::scheduleNormalRadio() { // Cancel every pending/active temporary entry, but leave permanent radioat // changes intact. The saved apply waits for the CLI reply to leave the // outbound queue before changing modulation parameters. + temp_radio_reply_barrier.clear(); for (int i = 0; i < MAX_SCHEDULED_RADIO_SETTINGS; i++) { if (!scheduled_radio_settings[i].temporary) continue; scheduled_radio_settings[i].active = false; scheduled_radio_settings[i].started = false; + scheduled_radio_settings[i].hard_end_uptime_millis = 0; } temp_radio_handoff_pending = false; refreshScheduledRadioState(); @@ -10435,13 +10644,33 @@ bool MyMesh::handleClientPathCommand(ClientInfo* sender, char* command, char* re return true; } + const uint8_t previous_path_len = *stored_path_len; + uint8_t previous_path[MAX_PATH_SIZE]; + memcpy(previous_path, stored_path, sizeof(previous_path)); + const bool previous_out_path_persistable = + sender->out_path_is_persistable; if (path_len == OUT_PATH_UNKNOWN || path_len == OUT_PATH_FORCE_FLOOD) { memset(stored_path, 0, MAX_PATH_SIZE); *stored_path_len = path_len; } else { *stored_path_len = mesh::Packet::copyPath(stored_path, path, path_len); } - dirty_contacts_expiry = futureMillis(LAZY_CONTACTS_WRITE_DELAY); + if (!is_alt) sender->out_path_is_persistable = true; + + // Explicit operator routes are rare and must become durable before the CLI + // acknowledges them. Otherwise a replay-unproven PATH arriving during the + // lazy-write window could replace the only RAM copy. ClientACL::save also + // preserves durable routes for any *other* client currently using a newer + // transient route. + if (!acl.save(_fs)) { + *stored_path_len = previous_path_len; + memcpy(stored_path, previous_path, sizeof(previous_path)); + sender->out_path_is_persistable = previous_out_path_persistable; + strcpy(reply, "Err - path save failed"); + return true; + } + mesh::resetLazyPersistenceAfterSuccess( + dirty_contacts_expiry, contacts_save_failures); formatPathReply(stored_path, *stored_path_len, reply, 160); return true; } @@ -11173,7 +11402,9 @@ void MyMesh::handleCommand(uint32_t sender_timestamp, ClientInfo* sender, char * && mesh::Utils::fromHex(pubkey, (int)(hex_len / 2), hex)) { uint8_t perms = atoi(sp); if (acl.applyPermissions(self_id, pubkey, (int)(hex_len / 2), perms)) { - dirty_contacts_expiry = futureMillis(LAZY_CONTACTS_WRITE_DELAY); // trigger acl.save() + mesh::scheduleLazyPersistenceMutation( + dirty_contacts_expiry, contacts_save_failures, + futureMillis(LAZY_CONTACTS_WRITE_DELAY)); strcpy(reply, "OK"); } else { strcpy(reply, "Err - invalid params"); @@ -11751,8 +11982,21 @@ void __attribute__((noinline)) MyMesh::servicePostMeshLoop() { // is pending dirty contacts write needed? if (dirty_contacts_expiry && millisHasNowPassed(dirty_contacts_expiry)) { - acl.save(_fs); - dirty_contacts_expiry = 0; + const bool saved = acl.save(_fs); + if (saved) { + mesh::resetLazyPersistenceAfterSuccess( + dirty_contacts_expiry, contacts_save_failures); + } else { + const uint32_t retry_delay = + mesh::recordLazyPersistenceSaveFailure( + contacts_save_failures, LAZY_CONTACTS_WRITE_DELAY, + mesh::LAZY_PERSISTENCE_MAX_RETRY_DELAY_MILLIS); + mesh::completeLazyPersistenceSave( + dirty_contacts_expiry, false, futureMillis(retry_delay)); + MESH_DEBUG_PRINTLN( + "ERROR: contacts save failed; retry in %lu ms", + (unsigned long)retry_delay); + } } // update uptime diff --git a/examples/simple_repeater/MyMesh.h b/examples/simple_repeater/MyMesh.h index 003d0308..40201461 100644 --- a/examples/simple_repeater/MyMesh.h +++ b/examples/simple_repeater/MyMesh.h @@ -95,6 +95,8 @@ #endif #include #include +#include +#include #if defined(ESP32_PLATFORM) || defined(USER_GPIO_CONTROL) #include #endif @@ -281,6 +283,7 @@ class MyMesh : public mesh::Mesh, public CommonCLICallbacks uint8_t cr; uint32_t start_time; uint32_t end_time; + uint64_t hard_end_uptime_millis; }; FILESYSTEM* _fs; @@ -289,6 +292,7 @@ class MyMesh : public mesh::Mesh, public CommonCLICallbacks unsigned long next_local_advert, next_flood_advert; mesh::DeferredCliCommand deferred_cli_command; mesh::RemoteCliReplyCache remote_cli_reply_cache; + mesh::TempRadioReplyBarrier temp_radio_reply_barrier; TransportKey deferred_cli_reply_scope; bool deferred_cli_reply_scoped; #if MESH_ENABLE_HOST_CLI @@ -462,6 +466,7 @@ class MyMesh : public mesh::Mesh, public CommonCLICallbacks unsigned long pending_discover_until; bool region_load_active; unsigned long dirty_contacts_expiry; + uint8_t contacts_save_failures; #if MAX_NEIGHBOURS NeighbourInfo neighbours[MAX_NEIGHBOURS]; #endif @@ -501,6 +506,7 @@ class MyMesh : public mesh::Mesh, public CommonCLICallbacks MQTTBridge* mqtt_bridge; #elif defined(WITH_RS232_BRIDGE) RS232Bridge* bridge; + uint8_t active_rs232_bridge_uart = 0; #elif defined(WITH_ESPNOW_BRIDGE) ESPNowBridge bridge; #endif @@ -636,12 +642,14 @@ class MyMesh : public mesh::Mesh, public CommonCLICallbacks #if MESH_ENABLE_HOST_CLI bool completeHostCliRequest(const char* service_reply); #endif - void sendRemoteCliReply(ClientInfo* client, const uint8_t* secret, + bool sendRemoteCliReply(ClientInfo* client, const uint8_t* secret, uint8_t path_hash_size, uint32_t sender_timestamp, - const char* reply, const TransportKey* fallback_scope); - void sendClientReplyWithFallbackScope(ClientInfo* client, mesh::Packet* packet, + const char* reply, const TransportKey* fallback_scope, + mesh::Packet** queued_packet = NULL); + bool sendClientReplyWithFallbackScope(ClientInfo* client, mesh::Packet* packet, unsigned long delay_millis, uint8_t path_hash_size, - const TransportKey* fallback_scope); + const TransportKey* fallback_scope, + bool allow_redundant_copies = true); void servicePostMeshLoop(); #if MESH_ENABLE_TELEMETRY_HISTORY void sampleTelemetryHistory(); @@ -1007,7 +1015,73 @@ public: WITH_RS232_BRIDGE_RX, WITH_RS232_BRIDGE_TX, _mgr, getRTCClock()); } + + bool beginRS232Bridge() { + if (!bridge) return false; + const uint8_t selected_uart = _prefs.bridge_uart; + bool gps_uart_blocked = false; +#if ENV_INCLUDE_GPS == 1 + if (sensors.gpsSerialTransportMayConflict(selected_uart)) { + if (!sensors.gpsUsesSerialUart(selected_uart) + || !sensors.gpsSerialTransportCanYield(selected_uart)) { + return false; + } + if (!sensors.setGpsSerialTransportBlocked(selected_uart, true)) { + return false; + } + gps_uart_blocked = true; + // Record ownership as soon as the GPS yields. If bridge startup then + // fails and the first release attempt also fails, normal bridge cleanup + // still knows which UART must be released and can retry safely. + active_rs232_bridge_uart = selected_uart; + } #endif + bridge->begin(); + if (!bridge->isRunning()) { +#if ENV_INCLUDE_GPS == 1 + if (gps_uart_blocked) { + if (sensors.setGpsSerialTransportBlocked(selected_uart, false)) { + active_rs232_bridge_uart = 0; + } + } +#else + (void)gps_uart_blocked; +#endif + return false; + } + active_rs232_bridge_uart = selected_uart; + return true; + } + + bool endRS232Bridge() { + const uint8_t released_uart = active_rs232_bridge_uart; + bool stopped = true; + // A fail-closed preflight can reject this object before begin() touches + // its UART. Adafruit nRF Uart::end() is not safe as a generic cleanup for + // an unstarted peripheral: it can stop a GPS-owned Serial1 and wait on + // hardware events which will never arrive. End only a bridge which proved + // that it reached running state. + if (bridge && bridge->isRunning()) { + bridge->end(); + stopped = !bridge->isRunning(); + } + bool gps_released = true; +#if ENV_INCLUDE_GPS == 1 + // Release the bridge first, then restore a UART GPS. I2C GPS providers do + // not claim any UART and therefore never enter this path. + if (released_uart != 0 && sensors.gpsUsesSerialUart(released_uart)) { + gps_released = sensors.setGpsSerialTransportBlocked(released_uart, false); + } +#endif + if (stopped && gps_released) active_rs232_bridge_uart = 0; + return stopped && gps_released; + } +#endif + + bool isBridgeRunning() const override { + const AbstractBridge* active_bridge = activeBridge(); + return active_bridge != nullptr && active_bridge->isRunning(); + } bool setBridgeState(bool enable) override { // Disabling an already-absent heap-backed bridge is successful and must @@ -1034,16 +1108,18 @@ public: bridge = createRS232Bridge(); if (!bridge) return false; } + if (!enable) { + const bool stopped = endRS232Bridge(); + if (stopped) { + delete bridge; + bridge = nullptr; + } + return stopped; + } #endif AbstractBridge* active_bridge = activeBridge(); if (!active_bridge) return false; if (enable == active_bridge->isRunning()) { -#ifdef WITH_RS232_BRIDGE - if (!enable) { - delete bridge; - bridge = nullptr; - } -#endif return true; } if (enable) @@ -1059,28 +1135,47 @@ public: mqtt_bridge->setBuildDate(getBuildDate()); mqtt_bridge->setStatsSources(this, _radio, _cli.getBoard(), _ms); #endif +#ifdef WITH_RS232_BRIDGE + const bool started = beginRS232Bridge(); +#else active_bridge->begin(); + const bool started = active_bridge->isRunning(); +#endif #ifdef WITH_MQTT_BRIDGE _alerter.setBridge(mqtt_bridge); #endif + return started; } else { - active_bridge->end(); - const bool stopped = !active_bridge->isRunning(); #ifdef WITH_RS232_BRIDGE + const bool stopped = endRS232Bridge(); delete bridge; bridge = nullptr; +#else + active_bridge->end(); + const bool stopped = !active_bridge->isRunning(); #endif #ifdef WITH_MQTT_BRIDGE _alerter.setBridge(nullptr); #endif return stopped; } - return active_bridge->isRunning(); } bool restartBridge() override { +#ifdef WITH_RS232_BRIDGE + // RS-232 changes must be applied synchronously so the CLI can commit or + // roll back the selected pins/baud. This branch also reconstructs a bridge + // after a prior allocation/start failure and is not WebConfig-coalesced. + if (bridge && (bridge->isRunning() || active_rs232_bridge_uart != 0) + && !endRS232Bridge()) { + return false; + } + delete bridge; + bridge = createRS232Bridge(); + return bridge && beginRS232Bridge(); +#else AbstractBridge* active_bridge = activeBridge(); if (!active_bridge) return false; #ifdef WITH_WEBCONFIG @@ -1090,12 +1185,6 @@ public: } #endif if (active_bridge->isRunning()) active_bridge->end(); -#ifdef WITH_RS232_BRIDGE - delete bridge; - bridge = createRS232Bridge(); - if (bridge) bridge->begin(); - return bridge && bridge->isRunning(); -#endif #ifdef WITH_MQTT_BRIDGE // Set device metadata before restarting bridge (same as in begin()) char device_id[65]; @@ -1109,6 +1198,7 @@ public: #endif active_bridge->begin(); return active_bridge->isRunning(); +#endif } void restartBridgeSlot(int slot) override { diff --git a/examples/simple_room_server/MyMesh.cpp b/examples/simple_room_server/MyMesh.cpp index 3c3ed1ad..747fd17f 100644 --- a/examples/simple_room_server/MyMesh.cpp +++ b/examples/simple_room_server/MyMesh.cpp @@ -1,6 +1,9 @@ #include "MyMesh.h" #include #include +#include +#include +#include #if MESH_PACKET_LOGGING #include #endif @@ -642,23 +645,29 @@ void MyMesh::onAnonDataRecv(mesh::Packet *packet, const uint8_t *secret, const m uint8_t *data, size_t len) { if (packet->getPayloadType() == PAYLOAD_TYPE_ANON_REQ) { // received an initial request by a possible admin // client (unknown at this stage) + if (len < 8) { + MESH_DEBUG_PRINTLN("Rejected incomplete anonymous request"); + return; + } uint32_t sender_timestamp, sender_sync_since; memcpy(&sender_timestamp, data, 4); memcpy(&sender_sync_since, &data[4], 4); // sender's "sync messags SINCE x" timestamp data[len] = 0; // ensure null terminator - ClientInfo* client = NULL; + ClientInfo* existing_client = + acl.getClient(sender.pub_key, PUB_KEY_SIZE); + ClientInfo* client = existing_client; + uint8_t perm; if (data[8] == 0) { // blank password, just check if sender is in ACL - client = acl.getClient(sender.pub_key, PUB_KEY_SIZE); if (client == NULL) { #if MESH_DEBUG MESH_DEBUG_PRINTLN("Login, sender not in ACL"); #endif + return; } - } - if (client == NULL) { - uint8_t perm; + perm = client->permissions & PERM_ACL_ROLE_MASK; + } else { if (strcmp((char *)&data[8], _prefs.password) == 0) { // check for valid admin password perm = PERM_ACL_ADMIN; } else { @@ -671,33 +680,46 @@ void MyMesh::onAnonDataRecv(mesh::Packet *packet, const uint8_t *secret, const m return; // no response. Client will timeout } } + } + const bool client_existed = existing_client != NULL; + const uint32_t previous_timestamp = + client_existed ? existing_client->last_timestamp : 0; + if (!acl.authorizeLoginTimestamp( + sender.pub_key, sender_timestamp, previous_timestamp, perm)) { + MESH_DEBUG_PRINTLN( + "Login rejected: replayed timestamp or replay state unavailable"); + return; + } + + if (client == NULL) { client = acl.putClient(sender, 0); // add to known clients (if not already known) if (client == NULL) { MESH_DEBUG_PRINTLN("Login rejected: ACL is full of protected contacts"); return; } - if (sender_timestamp <= client->last_timestamp) { - MESH_DEBUG_PRINTLN("possible replay attack!"); - return; - } - - MESH_DEBUG_PRINTLN("Login success!"); - client->last_timestamp = sender_timestamp; - client->extra.room.sync_since = sender_sync_since; - client->extra.room.pending_ack = 0; - client->extra.room.push_failures = 0; - - client->last_activity = getRTCClock()->getCurrentTime(); - client->permissions &= ~0x03; - client->permissions |= perm; - memcpy(client->shared_secret, secret, PUB_KEY_SIZE); - - dirty_contacts_expiry = futureMillis(LAZY_CONTACTS_WRITE_DELAY); } - if (packet->isRouteFlood()) { - client->out_path_len = OUT_PATH_UNKNOWN; // need to rediscover out_path + MESH_DEBUG_PRINTLN("Login success!"); + const uint8_t previous_permissions = client->permissions; + const uint32_t previous_sync_since = client->extra.room.sync_since; + const bool reset_out_path = packet->isRouteFlood() + && client->out_path_len != OUT_PATH_FORCE_FLOOD; + bool persisted_changed = mesh::applySuccessfulClientLogin( + *client, client_existed, perm, PERM_ACL_ROLE_MASK, + secret, sender_timestamp, getRTCClock()->getCurrentTime(), + reset_out_path, OUT_PATH_UNKNOWN); + client->extra.room.sync_since = sender_sync_since; + client->extra.room.pending_ack = 0; + client->extra.room.push_failures = 0; + persisted_changed = persisted_changed + || previous_sync_since != sender_sync_since; + if (mesh::successfulClientLoginNeedsPersistence( + client_existed, previous_permissions, client->permissions, + PERM_ACL_ROLE_MASK, PERM_ACL_GUEST, persisted_changed)) { + mesh::scheduleLazyPersistenceMutation( + dirty_contacts_expiry, contacts_save_failures, + futureMillis(LAZY_CONTACTS_WRITE_DELAY)); } uint32_t now = getRTCClock()->getCurrentTimeUnique(); @@ -705,7 +727,9 @@ void MyMesh::onAnonDataRecv(mesh::Packet *packet, const uint8_t *secret, const m // TODO: maybe reply with count of messages waiting to be synced for THIS client? reply_data[4] = RESP_SERVER_LOGIN_OK; reply_data[5] = 0; // Legacy: was recommended keep-alive interval (secs / 16) - reply_data[6] = (client->isAdmin() ? 1 : (client->permissions == 0 ? 2 : 0)); + reply_data[6] = (client->isAdmin() ? 1 + : ((client->permissions & PERM_ACL_ROLE_MASK) == PERM_ACL_GUEST + ? 2 : 0)); // LEGACY: reply_data[7] = getUnsyncedCount(client); reply_data[7] = client->permissions; // NEW getRNG()->random(&reply_data[8], 4); // random blob to help packet-hash uniqueness @@ -1033,7 +1057,15 @@ bool MyMesh::onPeerPathRecv(mesh::Packet *packet, int sender_idx, const uint8_t if (i >= 0 && i < acl.getNumClients()) { // get from our known_clients table (sender SHOULD already be known in this context) MESH_DEBUG_PRINTLN("PATH to client, path_len=%d", (uint32_t)path_len); auto client = acl.getClientByIdx(i); - client->out_path_len = mesh::Packet::copyPath(client->out_path, path, path_len); // store a copy of path, for sendDirect() + // PATH has no durable replay freshness signal. Keep it RAM-only and do not + // let a replay replace an operator-selected force-flood route. + const bool persistence_allowed = mesh::clientPathPersistenceAllowed( + MyMesh::saveFilter(client), false /* no durable replay proof */); + const mesh::ClientPathUpdateResult path_update = + mesh::applyReceivedClientPath( + *client, path, path_len, persistence_allowed, + OUT_PATH_FORCE_FLOOD); + (void)path_update; client->last_activity = getRTCClock()->getCurrentTime(); } else { MESH_DEBUG_PRINTLN("onPeerPathRecv: invalid peer idx: %d", i); @@ -1175,6 +1207,7 @@ MyMesh::MyMesh(mesh::MainBoard &board, mesh::Radio &radio, mesh::MillisecondCloc uptime_millis = 0; next_local_advert = next_flood_advert = 0; dirty_contacts_expiry = 0; + contacts_save_failures = 0; _logging = false; region_load_active = false; set_radio_at = revert_radio_at = 0; @@ -2196,7 +2229,9 @@ void MyMesh::handleCommand(uint32_t sender_timestamp, char *command, char *reply && mesh::Utils::fromHex(pubkey, (int)(hex_len / 2), hex)) { uint8_t perms = atoi(sp); if (acl.applyPermissions(self_id, pubkey, (int)(hex_len / 2), perms)) { - dirty_contacts_expiry = futureMillis(LAZY_CONTACTS_WRITE_DELAY); // trigger acl.save() + mesh::scheduleLazyPersistenceMutation( + dirty_contacts_expiry, contacts_save_failures, + futureMillis(LAZY_CONTACTS_WRITE_DELAY)); strcpy(reply, "OK"); } else { strcpy(reply, "Err - invalid params"); @@ -2481,8 +2516,21 @@ void MyMesh::loop() { // is pending dirty contacts write needed? if (dirty_contacts_expiry && millisHasNowPassed(dirty_contacts_expiry)) { - acl.save(_fs, MyMesh::saveFilter); - dirty_contacts_expiry = 0; + const bool saved = acl.save(_fs, MyMesh::saveFilter); + if (saved) { + mesh::resetLazyPersistenceAfterSuccess( + dirty_contacts_expiry, contacts_save_failures); + } else { + const uint32_t retry_delay = + mesh::recordLazyPersistenceSaveFailure( + contacts_save_failures, LAZY_CONTACTS_WRITE_DELAY, + mesh::LAZY_PERSISTENCE_MAX_RETRY_DELAY_MILLIS); + mesh::completeLazyPersistenceSave( + dirty_contacts_expiry, false, futureMillis(retry_delay)); + MESH_DEBUG_PRINTLN( + "ERROR: contacts save failed; retry in %lu ms", + (unsigned long)retry_delay); + } } // TODO: periodically check for OLD/inactive entries in known_clients[], and evict diff --git a/examples/simple_room_server/MyMesh.h b/examples/simple_room_server/MyMesh.h index 179507ee..3e4cdaf5 100644 --- a/examples/simple_room_server/MyMesh.h +++ b/examples/simple_room_server/MyMesh.h @@ -183,6 +183,7 @@ class MyMesh : public mesh::Mesh, public CommonCLICallbacks, UserGpioReplyTracker _gpio_reply_tracker; #endif unsigned long dirty_contacts_expiry; + uint8_t contacts_save_failures; uint8_t reply_data[MAX_PACKET_PAYLOAD]; unsigned long next_push; uint16_t _num_posted, _num_post_pushes; @@ -481,6 +482,10 @@ public: void loop(); #if defined(WITH_BRIDGE) + bool isBridgeRunning() const override { + return bridge != nullptr && bridge->isRunning(); + } + bool setBridgeState(bool enable) override { // An absent MQTT bridge is already stopped. Do not allocate one solely to // satisfy an idempotent disable request. diff --git a/examples/simple_sensor/SensorMesh.cpp b/examples/simple_sensor/SensorMesh.cpp index fe8013dc..8cc7d0db 100644 --- a/examples/simple_sensor/SensorMesh.cpp +++ b/examples/simple_sensor/SensorMesh.cpp @@ -1,5 +1,8 @@ #include "SensorMesh.h" #include +#include +#include +#include #include static uint32_t nextRadioApplyRetryDelay(uint8_t& failure_count) { @@ -444,15 +447,17 @@ int SensorMesh::getAGCResetInterval() const { } uint8_t SensorMesh::handleLoginReq(const mesh::Identity& sender, const uint8_t* secret, uint32_t sender_timestamp, const uint8_t* data, bool is_flood) { - ClientInfo* client; + ClientInfo* existing_client = acl.getClient(sender.pub_key, PUB_KEY_SIZE); + ClientInfo* client = existing_client; + uint8_t role_permissions; if (data[0] == 0) { // blank password, just check if sender is in ACL - client = acl.getClient(sender.pub_key, PUB_KEY_SIZE); if (client == NULL) { #if MESH_DEBUG MESH_DEBUG_PRINTLN("Login, sender not in ACL"); #endif return 0; } + role_permissions = client->permissions & PERM_ACL_ROLE_MASK; } else { if (strcmp((char *) data, _prefs.password) != 0) { // check for valid admin password #if MESH_DEBUG @@ -460,30 +465,45 @@ uint8_t SensorMesh::handleLoginReq(const mesh::Identity& sender, const uint8_t* #endif return 0; } + role_permissions = PERM_ACL_ADMIN; + } - client = acl.putClient(sender, PERM_RECV_ALERTS_HI | PERM_RECV_ALERTS_LO); // add to contacts (if not already known) + const bool client_existed = existing_client != NULL; + const uint32_t previous_timestamp = + client_existed ? existing_client->last_timestamp : 0; + if (!acl.authorizeLoginTimestamp( + sender.pub_key, sender_timestamp, previous_timestamp, + role_permissions)) { + MESH_DEBUG_PRINTLN( + "Login rejected: replayed timestamp or replay state unavailable"); + return 0; + } + + if (client == NULL) { + client = acl.putClient( + sender, PERM_RECV_ALERTS_HI | PERM_RECV_ALERTS_LO); if (client == NULL) { MESH_DEBUG_PRINTLN("Login rejected: ACL is full of protected contacts"); return 0; } - if (sender_timestamp <= client->last_timestamp) { - MESH_DEBUG_PRINTLN("Possible login replay attack!"); - return 0; // FATAL: client table is full -OR- replay attack - } - - MESH_DEBUG_PRINTLN("Login success!"); - client->last_timestamp = sender_timestamp; - client->last_activity = getRTCClock()->getCurrentTime(); - client->permissions |= PERM_ACL_ADMIN; - memcpy(client->shared_secret, secret, PUB_KEY_SIZE); - - dirty_contacts_expiry = futureMillis(LAZY_CONTACTS_WRITE_DELAY); - updateGpsTelemetryPolicy(); } - if (is_flood) { - client->out_path_len = OUT_PATH_UNKNOWN; // need to rediscover out_path + MESH_DEBUG_PRINTLN("Login success!"); + const uint8_t previous_permissions = client->permissions; + const bool reset_out_path = is_flood + && client->out_path_len != OUT_PATH_FORCE_FLOOD; + const bool persisted_changed = mesh::applySuccessfulClientLogin( + *client, client_existed, role_permissions, PERM_ACL_ROLE_MASK, + secret, sender_timestamp, getRTCClock()->getCurrentTime(), + reset_out_path, OUT_PATH_UNKNOWN); + if (mesh::successfulClientLoginNeedsPersistence( + client_existed, previous_permissions, client->permissions, + PERM_ACL_ROLE_MASK, PERM_ACL_GUEST, persisted_changed)) { + mesh::scheduleLazyPersistenceMutation( + dirty_contacts_expiry, contacts_save_failures, + futureMillis(LAZY_CONTACTS_WRITE_DELAY)); } + updateGpsTelemetryPolicy(); uint32_t now = getRTCClock()->getCurrentTimeUnique(); memcpy(reply_data, &now, 4); // response packets always prefixed with timestamp @@ -590,7 +610,9 @@ void SensorMesh::handleCommand(uint32_t sender_timestamp, char* command, char* r && mesh::Utils::fromHex(pubkey, (int)(hex_len / 2), hex)) { uint8_t perms = atoi(sp); if (acl.applyPermissions(self_id, pubkey, (int)(hex_len / 2), perms)) { - dirty_contacts_expiry = futureMillis(LAZY_CONTACTS_WRITE_DELAY); // trigger acl.save() + mesh::scheduleLazyPersistenceMutation( + dirty_contacts_expiry, contacts_save_failures, + futureMillis(LAZY_CONTACTS_WRITE_DELAY)); updateGpsTelemetryPolicy(); strcpy(reply, "OK"); } else { @@ -651,6 +673,10 @@ void SensorMesh::onGroupPacketRecv(mesh::Packet* packet) { void SensorMesh::onAnonDataRecv(mesh::Packet* packet, const uint8_t* secret, const mesh::Identity& sender, uint8_t* data, size_t len) { if (packet->getPayloadType() == PAYLOAD_TYPE_ANON_REQ) { // received an initial request by a possible admin client (unknown at this stage) + if (len < 4) { + MESH_DEBUG_PRINTLN("Rejected incomplete anonymous request"); + return; + } uint32_t timestamp; memcpy(×tamp, data, 4); @@ -871,17 +897,16 @@ bool SensorMesh::onPeerPathRecv(mesh::Packet* packet, int sender_idx, const uint ClientInfo* from = acl.getClientByIdx(i); MESH_DEBUG_PRINTLN("PATH to contact, path_len=%d", (uint32_t) path_len); - // NOTE: for this impl, we just replace the current 'out_path' regardless, whenever sender sends us a new out_path. - // FUTURE: could store multiple out_paths per contact, and try to find which is the 'best'(?) - from->out_path_len = mesh::Packet::copyPath(from->out_path, path, path_len); // store a copy of path, for sendDirect() + // PATH has an authenticated sender but no timestamp/request nonce. Keep the + // learned route in RAM only so an old replay cannot become durable. + const bool persistence_allowed = mesh::clientPathPersistenceAllowed( + from->permissions != 0, false /* no durable replay proof */); + const mesh::ClientPathUpdateResult path_update = + mesh::applyReceivedClientPath( + *from, path, path_len, persistence_allowed, OUT_PATH_FORCE_FLOOD); + (void)path_update; from->last_activity = getRTCClock()->getCurrentTime(); - // REVISIT: maybe make ALL out_paths non-persisted to minimise flash writes?? - if (from->isAdmin()) { - // only do saveContacts() (of this out_path change) if this is an admin - dirty_contacts_expiry = futureMillis(LAZY_CONTACTS_WRITE_DELAY); - } - // NOTE: no reciprocal path send!! return false; } @@ -909,6 +934,7 @@ SensorMesh::SensorMesh(mesh::MainBoard& board, mesh::Radio& radio, mesh::Millise { next_local_advert = next_flood_advert = 0; dirty_contacts_expiry = 0; + contacts_save_failures = 0; last_read_time = 0; num_alert_tasks = 0; set_radio_at = revert_radio_at = 0; @@ -1298,8 +1324,21 @@ void SensorMesh::loop() { // is there are pending dirty contacts write needed? if (dirty_contacts_expiry && millisHasNowPassed(dirty_contacts_expiry)) { - acl.save(_fs); - dirty_contacts_expiry = 0; + const bool saved = acl.save(_fs); + if (saved) { + mesh::resetLazyPersistenceAfterSuccess( + dirty_contacts_expiry, contacts_save_failures); + } else { + const uint32_t retry_delay = + mesh::recordLazyPersistenceSaveFailure( + contacts_save_failures, LAZY_CONTACTS_WRITE_DELAY, + mesh::LAZY_PERSISTENCE_MAX_RETRY_DELAY_MILLIS); + mesh::completeLazyPersistenceSave( + dirty_contacts_expiry, false, futureMillis(retry_delay)); + MESH_DEBUG_PRINTLN( + "ERROR: contacts save failed; retry in %lu ms", + (unsigned long)retry_delay); + } } } diff --git a/examples/simple_sensor/SensorMesh.h b/examples/simple_sensor/SensorMesh.h index 7abdfdc6..b2eaaacd 100644 --- a/examples/simple_sensor/SensorMesh.h +++ b/examples/simple_sensor/SensorMesh.h @@ -195,6 +195,7 @@ private: #endif uint8_t reply_data[MAX_PACKET_PAYLOAD]; unsigned long dirty_contacts_expiry; + uint8_t contacts_save_failures; CayenneLPP telemetry; TransportKeyStore key_store; RegionMap region_map; diff --git a/mesh-america/keymind-cascade-v1.16.0-provider.json b/mesh-america/keymind-cascade-v1.16.0-provider.json index 1698b100..6a0157d9 100644 --- a/mesh-america/keymind-cascade-v1.16.0-provider.json +++ b/mesh-america/keymind-cascade-v1.16.0-provider.json @@ -181,10 +181,10 @@ { "role": "repeater", "title": "Repeater", - "subTitle": "LoRa OTA, no external sensors", + "subTitle": "LoRa OTA, selected optional sensor drivers omitted", "version": { "lora-ota-no-external-sensors-v1.17.1.1-759a35fc": { - "notes": "Keymind Cascade MeshCore v1.17.1.1 with Halo/Keymind retry tuning and Cascade defaults.\n\nROLE \u2014 Repeater: always-on infrastructure that relays mesh traffic. Configure its name/location, radio settings, and admin password. A repeater does not need an OTA-capable build merely to relay OTA traffic for other nodes.\n\nPROFILE \u2014 Compact LoRa OTA: can install LoRa OTA and keeps core role features plus board-native display/GPS, but removes optional external environmental-sensor drivers to make update staging fit. USB debug/packet logging is OFF.\n\nINSTALL \u2014 First-time setup uses Full install (the merged bootloader + firmware image). Routine upgrades use Update (the app-only image) and normally preserve settings. Enable Erase all flash only when you intentionally want a clean reset.\n\nLORA OTA \u2014 This page installs the base firmware, not a .mota package. Obtain and apply exact board-and-role .mota updates separately through the MeshCore LoRa OTA workflow; .mota files cannot be web flashed here.\n\nSELECTION \u2014 LoRa OTA, no external sensors.", + "notes": "Keymind Cascade MeshCore v1.17.1.1 with Halo/Keymind retry tuning and Cascade defaults.\n\nROLE \u2014 Repeater: always-on infrastructure that relays mesh traffic. Configure its name/location, radio settings, and admin password. A repeater does not need an OTA-capable build merely to relay OTA traffic for other nodes.\n\nPROFILE - Compact LoRa OTA: can install LoRa OTA while retaining this exact target's board functions. To fit update staging, it omits selected optional environmental/ranging drivers; I2C remains available and is not globally disabled. USB debug/packet logging is OFF.\n\nINSTALL \u2014 First-time setup uses Full install (the merged bootloader + firmware image). Routine upgrades use Update (the app-only image) and normally preserve settings. Enable Erase all flash only when you intentionally want a clean reset.\n\nLORA OTA \u2014 This page installs the base firmware, not a .mota package. Obtain and apply exact board-and-role .mota updates separately through the MeshCore LoRa OTA workflow; .mota files cannot be web flashed here.\n\nSELECTION \u2014 LoRa OTA, selected optional sensor drivers omitted.", "files": [ { "type": "flash-wipe", @@ -281,10 +281,10 @@ { "role": "repeater", "title": "Repeater", - "subTitle": "LoRa OTA, no external sensors", + "subTitle": "LoRa OTA, selected optional sensor drivers omitted", "version": { "lora-ota-no-external-sensors-v1.17.1.1-759a35fc": { - "notes": "Keymind Cascade MeshCore v1.17.1.1 with Halo/Keymind retry tuning and Cascade defaults.\n\nROLE \u2014 Repeater: always-on infrastructure that relays mesh traffic. Configure its name/location, radio settings, and admin password. A repeater does not need an OTA-capable build merely to relay OTA traffic for other nodes.\n\nPROFILE \u2014 Compact LoRa OTA: can install LoRa OTA and keeps core role features plus board-native display/GPS, but removes optional external environmental-sensor drivers to make update staging fit. USB debug/packet logging is OFF.\n\nINSTALL \u2014 Browser flashing uses the Serial DFU .zip. The .uf2 is provided for manual drag-and-drop through a compatible UF2 bootloader volume. A normal firmware flash does not erase saved settings.\n\nLORA OTA \u2014 This page installs the base firmware, not a .mota package. Obtain and apply exact board-and-role .mota updates separately through the MeshCore LoRa OTA workflow; .mota files cannot be web flashed here.\n\nBOOTLOADER \u2014 nRF52 LoRa OTA requires the exact-board OTAFIX 2.4.1 Preview 6 bootloader, and \u201cota self\u201d must report \u201cbootloader: apply OK.\u201d Never substitute another board\u2019s bootloader. https://github.com/mikecarper/Adafruit_nRF52_Bootloader_OTAFIX/releases/tag/0.9.2-OTAFIX2.4.1-preview.6\n\nSELECTION \u2014 LoRa OTA, no external sensors.", + "notes": "Keymind Cascade MeshCore v1.17.1.1 with Halo/Keymind retry tuning and Cascade defaults.\n\nROLE \u2014 Repeater: always-on infrastructure that relays mesh traffic. Configure its name/location, radio settings, and admin password. A repeater does not need an OTA-capable build merely to relay OTA traffic for other nodes.\n\nPROFILE - Compact LoRa OTA: can install LoRa OTA while retaining this exact target's board functions. To fit update staging, it omits selected optional environmental/ranging drivers; I2C remains available and is not globally disabled. USB debug/packet logging is OFF.\n\nINSTALL \u2014 Browser flashing uses the Serial DFU .zip. The .uf2 is provided for manual drag-and-drop through a compatible UF2 bootloader volume. A normal firmware flash does not erase saved settings.\n\nLORA OTA \u2014 This page installs the base firmware, not a .mota package. Obtain and apply exact board-and-role .mota updates separately through the MeshCore LoRa OTA workflow; .mota files cannot be web flashed here.\n\nBOOTLOADER \u2014 nRF52 LoRa OTA requires the exact-board OTAFIX 2.4.1 Preview 6 bootloader, and \u201cota self\u201d must report \u201cbootloader: apply OK.\u201d Never substitute another board\u2019s bootloader. https://github.com/mikecarper/Adafruit_nRF52_Bootloader_OTAFIX/releases/tag/0.9.2-OTAFIX2.4.1-preview.6\n\nSELECTION \u2014 LoRa OTA, selected optional sensor drivers omitted.", "files": [ { "type": "flash", @@ -499,10 +499,10 @@ { "role": "repeater", "title": "Repeater", - "subTitle": "LoRa OTA, no external sensors", + "subTitle": "LoRa OTA, selected optional sensor drivers omitted", "version": { "lora-ota-no-external-sensors-v1.17.1.1-759a35fc": { - "notes": "Keymind Cascade MeshCore v1.17.1.1 with Halo/Keymind retry tuning and Cascade defaults.\n\nROLE \u2014 Repeater: always-on infrastructure that relays mesh traffic. Configure its name/location, radio settings, and admin password. A repeater does not need an OTA-capable build merely to relay OTA traffic for other nodes.\n\nPROFILE \u2014 Compact LoRa OTA: can install LoRa OTA and keeps core role features plus board-native display/GPS, but removes optional external environmental-sensor drivers to make update staging fit. USB debug/packet logging is OFF.\n\nINSTALL \u2014 First-time setup uses Full install (the merged bootloader + firmware image). Routine upgrades use Update (the app-only image) and normally preserve settings. Enable Erase all flash only when you intentionally want a clean reset.\n\nLORA OTA \u2014 This page installs the base firmware, not a .mota package. Obtain and apply exact board-and-role .mota updates separately through the MeshCore LoRa OTA workflow; .mota files cannot be web flashed here.\n\nSELECTION \u2014 LoRa OTA, no external sensors.", + "notes": "Keymind Cascade MeshCore v1.17.1.1 with Halo/Keymind retry tuning and Cascade defaults.\n\nROLE \u2014 Repeater: always-on infrastructure that relays mesh traffic. Configure its name/location, radio settings, and admin password. A repeater does not need an OTA-capable build merely to relay OTA traffic for other nodes.\n\nPROFILE - Compact LoRa OTA: can install LoRa OTA while retaining this exact target's board functions. To fit update staging, it omits selected optional environmental/ranging drivers; I2C remains available and is not globally disabled. USB debug/packet logging is OFF.\n\nINSTALL \u2014 First-time setup uses Full install (the merged bootloader + firmware image). Routine upgrades use Update (the app-only image) and normally preserve settings. Enable Erase all flash only when you intentionally want a clean reset.\n\nLORA OTA \u2014 This page installs the base firmware, not a .mota package. Obtain and apply exact board-and-role .mota updates separately through the MeshCore LoRa OTA workflow; .mota files cannot be web flashed here.\n\nSELECTION \u2014 LoRa OTA, selected optional sensor drivers omitted.", "files": [ { "type": "flash-wipe", @@ -599,10 +599,10 @@ { "role": "repeater", "title": "Repeater", - "subTitle": "LoRa OTA, no external sensors", + "subTitle": "LoRa OTA, selected optional sensor drivers omitted", "version": { "lora-ota-no-external-sensors-v1.17.1.1-759a35fc": { - "notes": "Keymind Cascade MeshCore v1.17.1.1 with Halo/Keymind retry tuning and Cascade defaults.\n\nROLE \u2014 Repeater: always-on infrastructure that relays mesh traffic. Configure its name/location, radio settings, and admin password. A repeater does not need an OTA-capable build merely to relay OTA traffic for other nodes.\n\nPROFILE \u2014 Compact LoRa OTA: can install LoRa OTA and keeps core role features plus board-native display/GPS, but removes optional external environmental-sensor drivers to make update staging fit. USB debug/packet logging is OFF.\n\nINSTALL \u2014 Browser flashing uses the Serial DFU .zip. The .uf2 is provided for manual drag-and-drop through a compatible UF2 bootloader volume. A normal firmware flash does not erase saved settings.\n\nLORA OTA \u2014 This page installs the base firmware, not a .mota package. Obtain and apply exact board-and-role .mota updates separately through the MeshCore LoRa OTA workflow; .mota files cannot be web flashed here.\n\nBOOTLOADER \u2014 nRF52 LoRa OTA requires the exact-board OTAFIX 2.4.1 Preview 6 bootloader, and \u201cota self\u201d must report \u201cbootloader: apply OK.\u201d Never substitute another board\u2019s bootloader. https://github.com/mikecarper/Adafruit_nRF52_Bootloader_OTAFIX/releases/tag/0.9.2-OTAFIX2.4.1-preview.6\n\nSELECTION \u2014 LoRa OTA, no external sensors.", + "notes": "Keymind Cascade MeshCore v1.17.1.1 with Halo/Keymind retry tuning and Cascade defaults.\n\nROLE \u2014 Repeater: always-on infrastructure that relays mesh traffic. Configure its name/location, radio settings, and admin password. A repeater does not need an OTA-capable build merely to relay OTA traffic for other nodes.\n\nPROFILE - Compact LoRa OTA: can install LoRa OTA while retaining this exact target's board functions. To fit update staging, it omits selected optional environmental/ranging drivers; I2C remains available and is not globally disabled. USB debug/packet logging is OFF.\n\nINSTALL \u2014 Browser flashing uses the Serial DFU .zip. The .uf2 is provided for manual drag-and-drop through a compatible UF2 bootloader volume. A normal firmware flash does not erase saved settings.\n\nLORA OTA \u2014 This page installs the base firmware, not a .mota package. Obtain and apply exact board-and-role .mota updates separately through the MeshCore LoRa OTA workflow; .mota files cannot be web flashed here.\n\nBOOTLOADER \u2014 nRF52 LoRa OTA requires the exact-board OTAFIX 2.4.1 Preview 6 bootloader, and \u201cota self\u201d must report \u201cbootloader: apply OK.\u201d Never substitute another board\u2019s bootloader. https://github.com/mikecarper/Adafruit_nRF52_Bootloader_OTAFIX/releases/tag/0.9.2-OTAFIX2.4.1-preview.6\n\nSELECTION \u2014 LoRa OTA, selected optional sensor drivers omitted.", "files": [ { "type": "flash", @@ -841,10 +841,10 @@ { "role": "repeater", "title": "Repeater", - "subTitle": "LoRa OTA, no external sensors", + "subTitle": "LoRa OTA, selected optional sensor drivers omitted", "version": { "lora-ota-no-external-sensors-v1.17.1.1-759a35fc": { - "notes": "Keymind Cascade MeshCore v1.17.1.1 with Halo/Keymind retry tuning and Cascade defaults.\n\nROLE \u2014 Repeater: always-on infrastructure that relays mesh traffic. Configure its name/location, radio settings, and admin password. A repeater does not need an OTA-capable build merely to relay OTA traffic for other nodes.\n\nPROFILE \u2014 Compact LoRa OTA: can install LoRa OTA and keeps core role features plus board-native display/GPS, but removes optional external environmental-sensor drivers to make update staging fit. USB debug/packet logging is OFF.\n\nINSTALL \u2014 First-time setup uses Full install (the merged bootloader + firmware image). Routine upgrades use Update (the app-only image) and normally preserve settings. Enable Erase all flash only when you intentionally want a clean reset.\n\nLORA OTA \u2014 This page installs the base firmware, not a .mota package. Obtain and apply exact board-and-role .mota updates separately through the MeshCore LoRa OTA workflow; .mota files cannot be web flashed here.\n\nSELECTION \u2014 LoRa OTA, no external sensors.", + "notes": "Keymind Cascade MeshCore v1.17.1.1 with Halo/Keymind retry tuning and Cascade defaults.\n\nROLE \u2014 Repeater: always-on infrastructure that relays mesh traffic. Configure its name/location, radio settings, and admin password. A repeater does not need an OTA-capable build merely to relay OTA traffic for other nodes.\n\nPROFILE - Compact LoRa OTA: can install LoRa OTA while retaining this exact target's board functions. To fit update staging, it omits selected optional environmental/ranging drivers; I2C remains available and is not globally disabled. USB debug/packet logging is OFF.\n\nINSTALL \u2014 First-time setup uses Full install (the merged bootloader + firmware image). Routine upgrades use Update (the app-only image) and normally preserve settings. Enable Erase all flash only when you intentionally want a clean reset.\n\nLORA OTA \u2014 This page installs the base firmware, not a .mota package. Obtain and apply exact board-and-role .mota updates separately through the MeshCore LoRa OTA workflow; .mota files cannot be web flashed here.\n\nSELECTION \u2014 LoRa OTA, selected optional sensor drivers omitted.", "files": [ { "type": "flash-wipe", @@ -941,10 +941,10 @@ { "role": "repeater", "title": "Repeater", - "subTitle": "LoRa OTA, no external sensors", + "subTitle": "LoRa OTA, selected optional sensor drivers omitted", "version": { "lora-ota-no-external-sensors-v1.17.1.1-759a35fc": { - "notes": "Keymind Cascade MeshCore v1.17.1.1 with Halo/Keymind retry tuning and Cascade defaults.\n\nROLE \u2014 Repeater: always-on infrastructure that relays mesh traffic. Configure its name/location, radio settings, and admin password. A repeater does not need an OTA-capable build merely to relay OTA traffic for other nodes.\n\nPROFILE \u2014 Compact LoRa OTA: can install LoRa OTA and keeps core role features plus board-native display/GPS, but removes optional external environmental-sensor drivers to make update staging fit. USB debug/packet logging is OFF.\n\nINSTALL \u2014 Browser flashing uses the Serial DFU .zip. The .uf2 is provided for manual drag-and-drop through a compatible UF2 bootloader volume. A normal firmware flash does not erase saved settings.\n\nLORA OTA \u2014 This page installs the base firmware, not a .mota package. Obtain and apply exact board-and-role .mota updates separately through the MeshCore LoRa OTA workflow; .mota files cannot be web flashed here.\n\nBOOTLOADER \u2014 nRF52 LoRa OTA requires the exact-board OTAFIX 2.4.1 Preview 6 bootloader, and \u201cota self\u201d must report \u201cbootloader: apply OK.\u201d Never substitute another board\u2019s bootloader. https://github.com/mikecarper/Adafruit_nRF52_Bootloader_OTAFIX/releases/tag/0.9.2-OTAFIX2.4.1-preview.6\n\nSELECTION \u2014 LoRa OTA, no external sensors.", + "notes": "Keymind Cascade MeshCore v1.17.1.1 with Halo/Keymind retry tuning and Cascade defaults.\n\nROLE \u2014 Repeater: always-on infrastructure that relays mesh traffic. Configure its name/location, radio settings, and admin password. A repeater does not need an OTA-capable build merely to relay OTA traffic for other nodes.\n\nPROFILE - Compact LoRa OTA: can install LoRa OTA while retaining this exact target's board functions. To fit update staging, it omits selected optional environmental/ranging drivers; I2C remains available and is not globally disabled. USB debug/packet logging is OFF.\n\nINSTALL \u2014 Browser flashing uses the Serial DFU .zip. The .uf2 is provided for manual drag-and-drop through a compatible UF2 bootloader volume. A normal firmware flash does not erase saved settings.\n\nLORA OTA \u2014 This page installs the base firmware, not a .mota package. Obtain and apply exact board-and-role .mota updates separately through the MeshCore LoRa OTA workflow; .mota files cannot be web flashed here.\n\nBOOTLOADER \u2014 nRF52 LoRa OTA requires the exact-board OTAFIX 2.4.1 Preview 6 bootloader, and \u201cota self\u201d must report \u201cbootloader: apply OK.\u201d Never substitute another board\u2019s bootloader. https://github.com/mikecarper/Adafruit_nRF52_Bootloader_OTAFIX/releases/tag/0.9.2-OTAFIX2.4.1-preview.6\n\nSELECTION \u2014 LoRa OTA, selected optional sensor drivers omitted.", "files": [ { "type": "flash", @@ -1136,10 +1136,10 @@ { "role": "repeater", "title": "Repeater", - "subTitle": "LoRa OTA, no external sensors", + "subTitle": "LoRa OTA, selected optional sensor drivers omitted", "version": { "lora-ota-no-external-sensors-v1.17.1.1-759a35fc": { - "notes": "Keymind Cascade MeshCore v1.17.1.1 with Halo/Keymind retry tuning and Cascade defaults.\n\nROLE \u2014 Repeater: always-on infrastructure that relays mesh traffic. Configure its name/location, radio settings, and admin password. A repeater does not need an OTA-capable build merely to relay OTA traffic for other nodes.\n\nPROFILE \u2014 Compact LoRa OTA: can install LoRa OTA and keeps core role features plus board-native display/GPS, but removes optional external environmental-sensor drivers to make update staging fit. USB debug/packet logging is OFF.\n\nINSTALL \u2014 First-time setup uses Full install (the merged bootloader + firmware image). Routine upgrades use Update (the app-only image) and normally preserve settings. Enable Erase all flash only when you intentionally want a clean reset.\n\nLORA OTA \u2014 This page installs the base firmware, not a .mota package. Obtain and apply exact board-and-role .mota updates separately through the MeshCore LoRa OTA workflow; .mota files cannot be web flashed here.\n\nSELECTION \u2014 LoRa OTA, no external sensors.", + "notes": "Keymind Cascade MeshCore v1.17.1.1 with Halo/Keymind retry tuning and Cascade defaults.\n\nROLE \u2014 Repeater: always-on infrastructure that relays mesh traffic. Configure its name/location, radio settings, and admin password. A repeater does not need an OTA-capable build merely to relay OTA traffic for other nodes.\n\nPROFILE - Compact LoRa OTA: can install LoRa OTA while retaining this exact target's board functions. To fit update staging, it omits selected optional environmental/ranging drivers; I2C remains available and is not globally disabled. USB debug/packet logging is OFF.\n\nINSTALL \u2014 First-time setup uses Full install (the merged bootloader + firmware image). Routine upgrades use Update (the app-only image) and normally preserve settings. Enable Erase all flash only when you intentionally want a clean reset.\n\nLORA OTA \u2014 This page installs the base firmware, not a .mota package. Obtain and apply exact board-and-role .mota updates separately through the MeshCore LoRa OTA workflow; .mota files cannot be web flashed here.\n\nSELECTION \u2014 LoRa OTA, selected optional sensor drivers omitted.", "files": [ { "type": "flash-wipe", @@ -1307,10 +1307,10 @@ { "role": "repeater", "title": "Repeater", - "subTitle": "LoRa OTA, no external sensors", + "subTitle": "LoRa OTA, selected optional sensor drivers omitted", "version": { "lora-ota-no-external-sensors-v1.17.1.1-759a35fc": { - "notes": "Keymind Cascade MeshCore v1.17.1.1 with Halo/Keymind retry tuning and Cascade defaults.\n\nROLE \u2014 Repeater: always-on infrastructure that relays mesh traffic. Configure its name/location, radio settings, and admin password. A repeater does not need an OTA-capable build merely to relay OTA traffic for other nodes.\n\nPROFILE \u2014 Compact LoRa OTA: can install LoRa OTA and keeps core role features plus board-native display/GPS, but removes optional external environmental-sensor drivers to make update staging fit. USB debug/packet logging is OFF.\n\nINSTALL \u2014 First-time setup uses Full install (the merged bootloader + firmware image). Routine upgrades use Update (the app-only image) and normally preserve settings. Enable Erase all flash only when you intentionally want a clean reset.\n\nLORA OTA \u2014 This page installs the base firmware, not a .mota package. Obtain and apply exact board-and-role .mota updates separately through the MeshCore LoRa OTA workflow; .mota files cannot be web flashed here.\n\nSELECTION \u2014 LoRa OTA, no external sensors.", + "notes": "Keymind Cascade MeshCore v1.17.1.1 with Halo/Keymind retry tuning and Cascade defaults.\n\nROLE \u2014 Repeater: always-on infrastructure that relays mesh traffic. Configure its name/location, radio settings, and admin password. A repeater does not need an OTA-capable build merely to relay OTA traffic for other nodes.\n\nPROFILE - Compact LoRa OTA: can install LoRa OTA while retaining this exact target's board functions. To fit update staging, it omits selected optional environmental/ranging drivers; I2C remains available and is not globally disabled. USB debug/packet logging is OFF.\n\nINSTALL \u2014 First-time setup uses Full install (the merged bootloader + firmware image). Routine upgrades use Update (the app-only image) and normally preserve settings. Enable Erase all flash only when you intentionally want a clean reset.\n\nLORA OTA \u2014 This page installs the base firmware, not a .mota package. Obtain and apply exact board-and-role .mota updates separately through the MeshCore LoRa OTA workflow; .mota files cannot be web flashed here.\n\nSELECTION \u2014 LoRa OTA, selected optional sensor drivers omitted.", "files": [ { "type": "flash-wipe", @@ -1407,10 +1407,10 @@ { "role": "repeater", "title": "Repeater", - "subTitle": "LoRa OTA, no external sensors", + "subTitle": "LoRa OTA, selected optional sensor drivers omitted", "version": { "lora-ota-no-external-sensors-v1.17.1.1-759a35fc": { - "notes": "Keymind Cascade MeshCore v1.17.1.1 with Halo/Keymind retry tuning and Cascade defaults.\n\nROLE \u2014 Repeater: always-on infrastructure that relays mesh traffic. Configure its name/location, radio settings, and admin password. A repeater does not need an OTA-capable build merely to relay OTA traffic for other nodes.\n\nPROFILE \u2014 Compact LoRa OTA: can install LoRa OTA and keeps core role features plus board-native display/GPS, but removes optional external environmental-sensor drivers to make update staging fit. USB debug/packet logging is OFF.\n\nINSTALL \u2014 Browser flashing uses the Serial DFU .zip. The .uf2 is provided for manual drag-and-drop through a compatible UF2 bootloader volume. A normal firmware flash does not erase saved settings.\n\nLORA OTA \u2014 This page installs the base firmware, not a .mota package. Obtain and apply exact board-and-role .mota updates separately through the MeshCore LoRa OTA workflow; .mota files cannot be web flashed here.\n\nBOOTLOADER \u2014 nRF52 LoRa OTA requires the exact-board OTAFIX 2.4.1 Preview 6 bootloader, and \u201cota self\u201d must report \u201cbootloader: apply OK.\u201d Never substitute another board\u2019s bootloader. https://github.com/mikecarper/Adafruit_nRF52_Bootloader_OTAFIX/releases/tag/0.9.2-OTAFIX2.4.1-preview.6\n\nSELECTION \u2014 LoRa OTA, no external sensors.", + "notes": "Keymind Cascade MeshCore v1.17.1.1 with Halo/Keymind retry tuning and Cascade defaults.\n\nROLE \u2014 Repeater: always-on infrastructure that relays mesh traffic. Configure its name/location, radio settings, and admin password. A repeater does not need an OTA-capable build merely to relay OTA traffic for other nodes.\n\nPROFILE - Compact LoRa OTA: can install LoRa OTA while retaining this exact target's board functions. To fit update staging, it omits selected optional environmental/ranging drivers; I2C remains available and is not globally disabled. USB debug/packet logging is OFF.\n\nINSTALL \u2014 Browser flashing uses the Serial DFU .zip. The .uf2 is provided for manual drag-and-drop through a compatible UF2 bootloader volume. A normal firmware flash does not erase saved settings.\n\nLORA OTA \u2014 This page installs the base firmware, not a .mota package. Obtain and apply exact board-and-role .mota updates separately through the MeshCore LoRa OTA workflow; .mota files cannot be web flashed here.\n\nBOOTLOADER \u2014 nRF52 LoRa OTA requires the exact-board OTAFIX 2.4.1 Preview 6 bootloader, and \u201cota self\u201d must report \u201cbootloader: apply OK.\u201d Never substitute another board\u2019s bootloader. https://github.com/mikecarper/Adafruit_nRF52_Bootloader_OTAFIX/releases/tag/0.9.2-OTAFIX2.4.1-preview.6\n\nSELECTION \u2014 LoRa OTA, selected optional sensor drivers omitted.", "files": [ { "type": "flash", @@ -1484,10 +1484,10 @@ { "role": "repeater", "title": "Repeater", - "subTitle": "LoRa OTA, no external sensors", + "subTitle": "LoRa OTA, selected optional sensor drivers omitted", "version": { "lora-ota-no-external-sensors-v1.17.1.1-759a35fc": { - "notes": "Keymind Cascade MeshCore v1.17.1.1 with Halo/Keymind retry tuning and Cascade defaults.\n\nROLE \u2014 Repeater: always-on infrastructure that relays mesh traffic. Configure its name/location, radio settings, and admin password. A repeater does not need an OTA-capable build merely to relay OTA traffic for other nodes.\n\nPROFILE \u2014 Compact LoRa OTA: can install LoRa OTA and keeps core role features plus board-native display/GPS, but removes optional external environmental-sensor drivers to make update staging fit. USB debug/packet logging is OFF.\n\nINSTALL \u2014 Browser flashing uses the Serial DFU .zip. The .uf2 is provided for manual drag-and-drop through a compatible UF2 bootloader volume. A normal firmware flash does not erase saved settings.\n\nLORA OTA \u2014 This page installs the base firmware, not a .mota package. Obtain and apply exact board-and-role .mota updates separately through the MeshCore LoRa OTA workflow; .mota files cannot be web flashed here.\n\nBOOTLOADER \u2014 nRF52 LoRa OTA requires the exact-board OTAFIX 2.4.1 Preview 6 bootloader, and \u201cota self\u201d must report \u201cbootloader: apply OK.\u201d Never substitute another board\u2019s bootloader. https://github.com/mikecarper/Adafruit_nRF52_Bootloader_OTAFIX/releases/tag/0.9.2-OTAFIX2.4.1-preview.6\n\nSELECTION \u2014 LoRa OTA, no external sensors.", + "notes": "Keymind Cascade MeshCore v1.17.1.1 with Halo/Keymind retry tuning and Cascade defaults.\n\nROLE \u2014 Repeater: always-on infrastructure that relays mesh traffic. Configure its name/location, radio settings, and admin password. A repeater does not need an OTA-capable build merely to relay OTA traffic for other nodes.\n\nPROFILE - Compact LoRa OTA: can install LoRa OTA while retaining this exact target's board functions. To fit update staging, it omits selected optional environmental/ranging drivers; I2C remains available and is not globally disabled. USB debug/packet logging is OFF.\n\nINSTALL \u2014 Browser flashing uses the Serial DFU .zip. The .uf2 is provided for manual drag-and-drop through a compatible UF2 bootloader volume. A normal firmware flash does not erase saved settings.\n\nLORA OTA \u2014 This page installs the base firmware, not a .mota package. Obtain and apply exact board-and-role .mota updates separately through the MeshCore LoRa OTA workflow; .mota files cannot be web flashed here.\n\nBOOTLOADER \u2014 nRF52 LoRa OTA requires the exact-board OTAFIX 2.4.1 Preview 6 bootloader, and \u201cota self\u201d must report \u201cbootloader: apply OK.\u201d Never substitute another board\u2019s bootloader. https://github.com/mikecarper/Adafruit_nRF52_Bootloader_OTAFIX/releases/tag/0.9.2-OTAFIX2.4.1-preview.6\n\nSELECTION \u2014 LoRa OTA, selected optional sensor drivers omitted.", "files": [ { "type": "flash", @@ -1584,10 +1584,10 @@ { "role": "repeater", "title": "Repeater", - "subTitle": "LoRa OTA, no external sensors", + "subTitle": "LoRa OTA, selected optional sensor drivers omitted", "version": { "lora-ota-no-external-sensors-v1.17.1.1-759a35fc": { - "notes": "Keymind Cascade MeshCore v1.17.1.1 with Halo/Keymind retry tuning and Cascade defaults.\n\nROLE \u2014 Repeater: always-on infrastructure that relays mesh traffic. Configure its name/location, radio settings, and admin password. A repeater does not need an OTA-capable build merely to relay OTA traffic for other nodes.\n\nPROFILE \u2014 Compact LoRa OTA: can install LoRa OTA and keeps core role features plus board-native display/GPS, but removes optional external environmental-sensor drivers to make update staging fit. USB debug/packet logging is OFF.\n\nINSTALL \u2014 Browser flashing uses the Serial DFU .zip. The .uf2 is provided for manual drag-and-drop through a compatible UF2 bootloader volume. A normal firmware flash does not erase saved settings.\n\nLORA OTA \u2014 This page installs the base firmware, not a .mota package. Obtain and apply exact board-and-role .mota updates separately through the MeshCore LoRa OTA workflow; .mota files cannot be web flashed here.\n\nBOOTLOADER \u2014 nRF52 LoRa OTA requires the exact-board OTAFIX 2.4.1 Preview 6 bootloader, and \u201cota self\u201d must report \u201cbootloader: apply OK.\u201d Never substitute another board\u2019s bootloader. https://github.com/mikecarper/Adafruit_nRF52_Bootloader_OTAFIX/releases/tag/0.9.2-OTAFIX2.4.1-preview.6\n\nSELECTION \u2014 LoRa OTA, no external sensors.", + "notes": "Keymind Cascade MeshCore v1.17.1.1 with Halo/Keymind retry tuning and Cascade defaults.\n\nROLE \u2014 Repeater: always-on infrastructure that relays mesh traffic. Configure its name/location, radio settings, and admin password. A repeater does not need an OTA-capable build merely to relay OTA traffic for other nodes.\n\nPROFILE - Compact LoRa OTA: can install LoRa OTA while retaining this exact target's board functions. To fit update staging, it omits selected optional environmental/ranging drivers; I2C remains available and is not globally disabled. USB debug/packet logging is OFF.\n\nINSTALL \u2014 Browser flashing uses the Serial DFU .zip. The .uf2 is provided for manual drag-and-drop through a compatible UF2 bootloader volume. A normal firmware flash does not erase saved settings.\n\nLORA OTA \u2014 This page installs the base firmware, not a .mota package. Obtain and apply exact board-and-role .mota updates separately through the MeshCore LoRa OTA workflow; .mota files cannot be web flashed here.\n\nBOOTLOADER \u2014 nRF52 LoRa OTA requires the exact-board OTAFIX 2.4.1 Preview 6 bootloader, and \u201cota self\u201d must report \u201cbootloader: apply OK.\u201d Never substitute another board\u2019s bootloader. https://github.com/mikecarper/Adafruit_nRF52_Bootloader_OTAFIX/releases/tag/0.9.2-OTAFIX2.4.1-preview.6\n\nSELECTION \u2014 LoRa OTA, selected optional sensor drivers omitted.", "files": [ { "type": "flash", @@ -1661,10 +1661,10 @@ { "role": "repeater", "title": "Repeater - SX1262", - "subTitle": "LoRa OTA, no external sensors", + "subTitle": "LoRa OTA, selected optional sensor drivers omitted", "version": { "lora-ota-no-external-sensors-v1.17.1.1-759a35fc": { - "notes": "Keymind Cascade MeshCore v1.17.1.1 with Halo/Keymind retry tuning and Cascade defaults.\n\nROLE \u2014 Repeater: always-on infrastructure that relays mesh traffic. Configure its name/location, radio settings, and admin password. A repeater does not need an OTA-capable build merely to relay OTA traffic for other nodes.\n\nPROFILE \u2014 Compact LoRa OTA: can install LoRa OTA and keeps core role features plus board-native display/GPS, but removes optional external environmental-sensor drivers to make update staging fit. USB debug/packet logging is OFF.\n\nINSTALL \u2014 First-time setup uses Full install (the merged bootloader + firmware image). Routine upgrades use Update (the app-only image) and normally preserve settings. Enable Erase all flash only when you intentionally want a clean reset.\n\nLORA OTA \u2014 This page installs the base firmware, not a .mota package. Obtain and apply exact board-and-role .mota updates separately through the MeshCore LoRa OTA workflow; .mota files cannot be web flashed here.\n\nSELECTION \u2014 LoRa OTA, no external sensors.", + "notes": "Keymind Cascade MeshCore v1.17.1.1 with Halo/Keymind retry tuning and Cascade defaults.\n\nROLE \u2014 Repeater: always-on infrastructure that relays mesh traffic. Configure its name/location, radio settings, and admin password. A repeater does not need an OTA-capable build merely to relay OTA traffic for other nodes.\n\nPROFILE - Compact LoRa OTA: can install LoRa OTA while retaining this exact target's board functions. To fit update staging, it omits selected optional environmental/ranging drivers; I2C remains available and is not globally disabled. USB debug/packet logging is OFF.\n\nINSTALL \u2014 First-time setup uses Full install (the merged bootloader + firmware image). Routine upgrades use Update (the app-only image) and normally preserve settings. Enable Erase all flash only when you intentionally want a clean reset.\n\nLORA OTA \u2014 This page installs the base firmware, not a .mota package. Obtain and apply exact board-and-role .mota updates separately through the MeshCore LoRa OTA workflow; .mota files cannot be web flashed here.\n\nSELECTION \u2014 LoRa OTA, selected optional sensor drivers omitted.", "files": [ { "type": "flash-wipe", @@ -1708,10 +1708,10 @@ { "role": "repeater", "title": "Repeater - SX1268", - "subTitle": "LoRa OTA, no external sensors", + "subTitle": "LoRa OTA, selected optional sensor drivers omitted", "version": { "lora-ota-no-external-sensors-v1.17.1.1-759a35fc": { - "notes": "Keymind Cascade MeshCore v1.17.1.1 with Halo/Keymind retry tuning and Cascade defaults.\n\nROLE \u2014 Repeater: always-on infrastructure that relays mesh traffic. Configure its name/location, radio settings, and admin password. A repeater does not need an OTA-capable build merely to relay OTA traffic for other nodes.\n\nPROFILE \u2014 Compact LoRa OTA: can install LoRa OTA and keeps core role features plus board-native display/GPS, but removes optional external environmental-sensor drivers to make update staging fit. USB debug/packet logging is OFF.\n\nINSTALL \u2014 First-time setup uses Full install (the merged bootloader + firmware image). Routine upgrades use Update (the app-only image) and normally preserve settings. Enable Erase all flash only when you intentionally want a clean reset.\n\nLORA OTA \u2014 This page installs the base firmware, not a .mota package. Obtain and apply exact board-and-role .mota updates separately through the MeshCore LoRa OTA workflow; .mota files cannot be web flashed here.\n\nSELECTION \u2014 LoRa OTA, no external sensors.", + "notes": "Keymind Cascade MeshCore v1.17.1.1 with Halo/Keymind retry tuning and Cascade defaults.\n\nROLE \u2014 Repeater: always-on infrastructure that relays mesh traffic. Configure its name/location, radio settings, and admin password. A repeater does not need an OTA-capable build merely to relay OTA traffic for other nodes.\n\nPROFILE - Compact LoRa OTA: can install LoRa OTA while retaining this exact target's board functions. To fit update staging, it omits selected optional environmental/ranging drivers; I2C remains available and is not globally disabled. USB debug/packet logging is OFF.\n\nINSTALL \u2014 First-time setup uses Full install (the merged bootloader + firmware image). Routine upgrades use Update (the app-only image) and normally preserve settings. Enable Erase all flash only when you intentionally want a clean reset.\n\nLORA OTA \u2014 This page installs the base firmware, not a .mota package. Obtain and apply exact board-and-role .mota updates separately through the MeshCore LoRa OTA workflow; .mota files cannot be web flashed here.\n\nSELECTION \u2014 LoRa OTA, selected optional sensor drivers omitted.", "files": [ { "type": "flash-wipe", @@ -1832,10 +1832,10 @@ { "role": "repeater", "title": "Repeater", - "subTitle": "LoRa OTA, no external sensors", + "subTitle": "LoRa OTA, selected optional sensor drivers omitted", "version": { "lora-ota-no-external-sensors-v1.17.1.1-759a35fc": { - "notes": "Keymind Cascade MeshCore v1.17.1.1 with Halo/Keymind retry tuning and Cascade defaults.\n\nROLE \u2014 Repeater: always-on infrastructure that relays mesh traffic. Configure its name/location, radio settings, and admin password. A repeater does not need an OTA-capable build merely to relay OTA traffic for other nodes.\n\nPROFILE \u2014 Compact LoRa OTA: can install LoRa OTA and keeps core role features plus board-native display/GPS, but removes optional external environmental-sensor drivers to make update staging fit. USB debug/packet logging is OFF.\n\nINSTALL \u2014 First-time setup uses Full install (the merged bootloader + firmware image). Routine upgrades use Update (the app-only image) and normally preserve settings. Enable Erase all flash only when you intentionally want a clean reset.\n\nLORA OTA \u2014 This page installs the base firmware, not a .mota package. Obtain and apply exact board-and-role .mota updates separately through the MeshCore LoRa OTA workflow; .mota files cannot be web flashed here.\n\nSELECTION \u2014 LoRa OTA, no external sensors.", + "notes": "Keymind Cascade MeshCore v1.17.1.1 with Halo/Keymind retry tuning and Cascade defaults.\n\nROLE \u2014 Repeater: always-on infrastructure that relays mesh traffic. Configure its name/location, radio settings, and admin password. A repeater does not need an OTA-capable build merely to relay OTA traffic for other nodes.\n\nPROFILE - Compact LoRa OTA: can install LoRa OTA while retaining this exact target's board functions. To fit update staging, it omits selected optional environmental/ranging drivers; I2C remains available and is not globally disabled. USB debug/packet logging is OFF.\n\nINSTALL \u2014 First-time setup uses Full install (the merged bootloader + firmware image). Routine upgrades use Update (the app-only image) and normally preserve settings. Enable Erase all flash only when you intentionally want a clean reset.\n\nLORA OTA \u2014 This page installs the base firmware, not a .mota package. Obtain and apply exact board-and-role .mota updates separately through the MeshCore LoRa OTA workflow; .mota files cannot be web flashed here.\n\nSELECTION \u2014 LoRa OTA, selected optional sensor drivers omitted.", "files": [ { "type": "flash-wipe", @@ -2003,10 +2003,10 @@ { "role": "repeater", "title": "Repeater", - "subTitle": "LoRa OTA, no external sensors", + "subTitle": "LoRa OTA, selected optional sensor drivers omitted", "version": { "lora-ota-no-external-sensors-v1.17.1.1-759a35fc": { - "notes": "Keymind Cascade MeshCore v1.17.1.1 with Halo/Keymind retry tuning and Cascade defaults.\n\nROLE \u2014 Repeater: always-on infrastructure that relays mesh traffic. Configure its name/location, radio settings, and admin password. A repeater does not need an OTA-capable build merely to relay OTA traffic for other nodes.\n\nPROFILE \u2014 Compact LoRa OTA: can install LoRa OTA and keeps core role features plus board-native display/GPS, but removes optional external environmental-sensor drivers to make update staging fit. USB debug/packet logging is OFF.\n\nINSTALL \u2014 First-time setup uses Full install (the merged bootloader + firmware image). Routine upgrades use Update (the app-only image) and normally preserve settings. Enable Erase all flash only when you intentionally want a clean reset.\n\nLORA OTA \u2014 This page installs the base firmware, not a .mota package. Obtain and apply exact board-and-role .mota updates separately through the MeshCore LoRa OTA workflow; .mota files cannot be web flashed here.\n\nSELECTION \u2014 LoRa OTA, no external sensors.", + "notes": "Keymind Cascade MeshCore v1.17.1.1 with Halo/Keymind retry tuning and Cascade defaults.\n\nROLE \u2014 Repeater: always-on infrastructure that relays mesh traffic. Configure its name/location, radio settings, and admin password. A repeater does not need an OTA-capable build merely to relay OTA traffic for other nodes.\n\nPROFILE - Compact LoRa OTA: can install LoRa OTA while retaining this exact target's board functions. To fit update staging, it omits selected optional environmental/ranging drivers; I2C remains available and is not globally disabled. USB debug/packet logging is OFF.\n\nINSTALL \u2014 First-time setup uses Full install (the merged bootloader + firmware image). Routine upgrades use Update (the app-only image) and normally preserve settings. Enable Erase all flash only when you intentionally want a clean reset.\n\nLORA OTA \u2014 This page installs the base firmware, not a .mota package. Obtain and apply exact board-and-role .mota updates separately through the MeshCore LoRa OTA workflow; .mota files cannot be web flashed here.\n\nSELECTION \u2014 LoRa OTA, selected optional sensor drivers omitted.", "files": [ { "type": "flash-wipe", @@ -2174,10 +2174,10 @@ { "role": "repeater", "title": "Repeater", - "subTitle": "LoRa OTA, no external sensors", + "subTitle": "LoRa OTA, selected optional sensor drivers omitted", "version": { "lora-ota-no-external-sensors-v1.17.1.1-759a35fc": { - "notes": "Keymind Cascade MeshCore v1.17.1.1 with Halo/Keymind retry tuning and Cascade defaults.\n\nROLE \u2014 Repeater: always-on infrastructure that relays mesh traffic. Configure its name/location, radio settings, and admin password. A repeater does not need an OTA-capable build merely to relay OTA traffic for other nodes.\n\nPROFILE \u2014 Compact LoRa OTA: can install LoRa OTA and keeps core role features plus board-native display/GPS, but removes optional external environmental-sensor drivers to make update staging fit. USB debug/packet logging is OFF.\n\nINSTALL \u2014 First-time setup uses Full install (the merged bootloader + firmware image). Routine upgrades use Update (the app-only image) and normally preserve settings. Enable Erase all flash only when you intentionally want a clean reset.\n\nLORA OTA \u2014 This page installs the base firmware, not a .mota package. Obtain and apply exact board-and-role .mota updates separately through the MeshCore LoRa OTA workflow; .mota files cannot be web flashed here.\n\nSELECTION \u2014 LoRa OTA, no external sensors.", + "notes": "Keymind Cascade MeshCore v1.17.1.1 with Halo/Keymind retry tuning and Cascade defaults.\n\nROLE \u2014 Repeater: always-on infrastructure that relays mesh traffic. Configure its name/location, radio settings, and admin password. A repeater does not need an OTA-capable build merely to relay OTA traffic for other nodes.\n\nPROFILE - Compact LoRa OTA: can install LoRa OTA while retaining this exact target's board functions. To fit update staging, it omits selected optional environmental/ranging drivers; I2C remains available and is not globally disabled. USB debug/packet logging is OFF.\n\nINSTALL \u2014 First-time setup uses Full install (the merged bootloader + firmware image). Routine upgrades use Update (the app-only image) and normally preserve settings. Enable Erase all flash only when you intentionally want a clean reset.\n\nLORA OTA \u2014 This page installs the base firmware, not a .mota package. Obtain and apply exact board-and-role .mota updates separately through the MeshCore LoRa OTA workflow; .mota files cannot be web flashed here.\n\nSELECTION \u2014 LoRa OTA, selected optional sensor drivers omitted.", "files": [ { "type": "flash-wipe", @@ -2392,10 +2392,10 @@ { "role": "repeater", "title": "Repeater", - "subTitle": "LoRa OTA, no external sensors", + "subTitle": "LoRa OTA, selected optional sensor drivers omitted", "version": { "lora-ota-no-external-sensors-v1.17.1.1-759a35fc": { - "notes": "Keymind Cascade MeshCore v1.17.1.1 with Halo/Keymind retry tuning and Cascade defaults.\n\nROLE \u2014 Repeater: always-on infrastructure that relays mesh traffic. Configure its name/location, radio settings, and admin password. A repeater does not need an OTA-capable build merely to relay OTA traffic for other nodes.\n\nPROFILE \u2014 Compact LoRa OTA: can install LoRa OTA and keeps core role features plus board-native display/GPS, but removes optional external environmental-sensor drivers to make update staging fit. USB debug/packet logging is OFF.\n\nINSTALL \u2014 First-time setup uses Full install (the merged bootloader + firmware image). Routine upgrades use Update (the app-only image) and normally preserve settings. Enable Erase all flash only when you intentionally want a clean reset.\n\nLORA OTA \u2014 This page installs the base firmware, not a .mota package. Obtain and apply exact board-and-role .mota updates separately through the MeshCore LoRa OTA workflow; .mota files cannot be web flashed here.\n\nSELECTION \u2014 LoRa OTA, no external sensors.", + "notes": "Keymind Cascade MeshCore v1.17.1.1 with Halo/Keymind retry tuning and Cascade defaults.\n\nROLE \u2014 Repeater: always-on infrastructure that relays mesh traffic. Configure its name/location, radio settings, and admin password. A repeater does not need an OTA-capable build merely to relay OTA traffic for other nodes.\n\nPROFILE - Compact LoRa OTA: can install LoRa OTA while retaining this exact target's board functions. To fit update staging, it omits selected optional environmental/ranging drivers; I2C remains available and is not globally disabled. USB debug/packet logging is OFF.\n\nINSTALL \u2014 First-time setup uses Full install (the merged bootloader + firmware image). Routine upgrades use Update (the app-only image) and normally preserve settings. Enable Erase all flash only when you intentionally want a clean reset.\n\nLORA OTA \u2014 This page installs the base firmware, not a .mota package. Obtain and apply exact board-and-role .mota updates separately through the MeshCore LoRa OTA workflow; .mota files cannot be web flashed here.\n\nSELECTION \u2014 LoRa OTA, selected optional sensor drivers omitted.", "files": [ { "type": "flash-wipe", @@ -2633,10 +2633,10 @@ { "role": "repeater", "title": "Repeater", - "subTitle": "LoRa OTA, no external sensors", + "subTitle": "LoRa OTA, selected optional sensor drivers omitted", "version": { "lora-ota-no-external-sensors-v1.17.1.1-759a35fc": { - "notes": "Keymind Cascade MeshCore v1.17.1.1 with Halo/Keymind retry tuning and Cascade defaults.\n\nROLE \u2014 Repeater: always-on infrastructure that relays mesh traffic. Configure its name/location, radio settings, and admin password. A repeater does not need an OTA-capable build merely to relay OTA traffic for other nodes.\n\nPROFILE \u2014 Compact LoRa OTA: can install LoRa OTA and keeps core role features plus board-native display/GPS, but removes optional external environmental-sensor drivers to make update staging fit. USB debug/packet logging is OFF.\n\nINSTALL \u2014 First-time setup uses Full install (the merged bootloader + firmware image). Routine upgrades use Update (the app-only image) and normally preserve settings. Enable Erase all flash only when you intentionally want a clean reset.\n\nLORA OTA \u2014 This page installs the base firmware, not a .mota package. Obtain and apply exact board-and-role .mota updates separately through the MeshCore LoRa OTA workflow; .mota files cannot be web flashed here.\n\nSELECTION \u2014 LoRa OTA, no external sensors.", + "notes": "Keymind Cascade MeshCore v1.17.1.1 with Halo/Keymind retry tuning and Cascade defaults.\n\nROLE \u2014 Repeater: always-on infrastructure that relays mesh traffic. Configure its name/location, radio settings, and admin password. A repeater does not need an OTA-capable build merely to relay OTA traffic for other nodes.\n\nPROFILE - Compact LoRa OTA: can install LoRa OTA while retaining this exact target's board functions. To fit update staging, it omits selected optional environmental/ranging drivers; I2C remains available and is not globally disabled. USB debug/packet logging is OFF.\n\nINSTALL \u2014 First-time setup uses Full install (the merged bootloader + firmware image). Routine upgrades use Update (the app-only image) and normally preserve settings. Enable Erase all flash only when you intentionally want a clean reset.\n\nLORA OTA \u2014 This page installs the base firmware, not a .mota package. Obtain and apply exact board-and-role .mota updates separately through the MeshCore LoRa OTA workflow; .mota files cannot be web flashed here.\n\nSELECTION \u2014 LoRa OTA, selected optional sensor drivers omitted.", "files": [ { "type": "flash-wipe", @@ -2828,10 +2828,10 @@ { "role": "repeater", "title": "Repeater Bridge RS232", - "subTitle": "LoRa OTA, no external sensors", + "subTitle": "LoRa OTA, selected optional sensor drivers omitted", "version": { "lora-ota-no-external-sensors-v1.17.1.1-759a35fc": { - "notes": "Keymind Cascade MeshCore v1.17.1.1 with Halo/Keymind retry tuning and Cascade defaults.\n\nROLE \u2014 RS232 Repeater Bridge: relays normal MeshCore traffic and also bridges packets over a hardware UART. It requires the target\u2019s documented RX/TX pins, correct voltage levels, and matching serial configuration; choose plain Repeater otherwise.\n\nPROFILE \u2014 Compact LoRa OTA: can install LoRa OTA and keeps core role features plus board-native display/GPS, but removes optional external environmental-sensor drivers to make update staging fit. USB debug/packet logging is OFF.\n\nINSTALL \u2014 Browser flashing uses the Serial DFU .zip. The .uf2 is provided for manual drag-and-drop through a compatible UF2 bootloader volume. A normal firmware flash does not erase saved settings.\n\nLORA OTA \u2014 This page installs the base firmware, not a .mota package. Obtain and apply exact board-and-role .mota updates separately through the MeshCore LoRa OTA workflow; .mota files cannot be web flashed here.\n\nBOOTLOADER \u2014 nRF52 LoRa OTA requires the exact-board OTAFIX 2.4.1 Preview 6 bootloader, and \u201cota self\u201d must report \u201cbootloader: apply OK.\u201d Never substitute another board\u2019s bootloader. https://github.com/mikecarper/Adafruit_nRF52_Bootloader_OTAFIX/releases/tag/0.9.2-OTAFIX2.4.1-preview.6\n\nSELECTION \u2014 LoRa OTA, no external sensors.", + "notes": "Keymind Cascade MeshCore v1.17.1.1 with Halo/Keymind retry tuning and Cascade defaults.\n\nROLE \u2014 RS232 Repeater Bridge: relays normal MeshCore traffic and also bridges packets over a hardware UART. It requires the target\u2019s documented RX/TX pins, correct voltage levels, and matching serial configuration; choose plain Repeater otherwise.\n\nPROFILE - Compact LoRa OTA: can install LoRa OTA while retaining this exact target's board functions. To fit update staging, it omits selected optional environmental/ranging drivers; I2C remains available and is not globally disabled. USB debug/packet logging is OFF.\n\nINSTALL \u2014 Browser flashing uses the Serial DFU .zip. The .uf2 is provided for manual drag-and-drop through a compatible UF2 bootloader volume. A normal firmware flash does not erase saved settings.\n\nLORA OTA \u2014 This page installs the base firmware, not a .mota package. Obtain and apply exact board-and-role .mota updates separately through the MeshCore LoRa OTA workflow; .mota files cannot be web flashed here.\n\nBOOTLOADER \u2014 nRF52 LoRa OTA requires the exact-board OTAFIX 2.4.1 Preview 6 bootloader, and \u201cota self\u201d must report \u201cbootloader: apply OK.\u201d Never substitute another board\u2019s bootloader. https://github.com/mikecarper/Adafruit_nRF52_Bootloader_OTAFIX/releases/tag/0.9.2-OTAFIX2.4.1-preview.6\n\nSELECTION \u2014 LoRa OTA, selected optional sensor drivers omitted.", "files": [ { "type": "flash", @@ -2852,10 +2852,10 @@ { "role": "repeater", "title": "Repeater", - "subTitle": "LoRa OTA, no external sensors", + "subTitle": "LoRa OTA, selected optional sensor drivers omitted", "version": { "lora-ota-no-external-sensors-v1.17.1.1-759a35fc": { - "notes": "Keymind Cascade MeshCore v1.17.1.1 with Halo/Keymind retry tuning and Cascade defaults.\n\nROLE \u2014 Repeater: always-on infrastructure that relays mesh traffic. Configure its name/location, radio settings, and admin password. A repeater does not need an OTA-capable build merely to relay OTA traffic for other nodes.\n\nPROFILE \u2014 Compact LoRa OTA: can install LoRa OTA and keeps core role features plus board-native display/GPS, but removes optional external environmental-sensor drivers to make update staging fit. USB debug/packet logging is OFF.\n\nINSTALL \u2014 Browser flashing uses the Serial DFU .zip. The .uf2 is provided for manual drag-and-drop through a compatible UF2 bootloader volume. A normal firmware flash does not erase saved settings.\n\nLORA OTA \u2014 This page installs the base firmware, not a .mota package. Obtain and apply exact board-and-role .mota updates separately through the MeshCore LoRa OTA workflow; .mota files cannot be web flashed here.\n\nBOOTLOADER \u2014 nRF52 LoRa OTA requires the exact-board OTAFIX 2.4.1 Preview 6 bootloader, and \u201cota self\u201d must report \u201cbootloader: apply OK.\u201d Never substitute another board\u2019s bootloader. https://github.com/mikecarper/Adafruit_nRF52_Bootloader_OTAFIX/releases/tag/0.9.2-OTAFIX2.4.1-preview.6\n\nSELECTION \u2014 LoRa OTA, no external sensors.", + "notes": "Keymind Cascade MeshCore v1.17.1.1 with Halo/Keymind retry tuning and Cascade defaults.\n\nROLE \u2014 Repeater: always-on infrastructure that relays mesh traffic. Configure its name/location, radio settings, and admin password. A repeater does not need an OTA-capable build merely to relay OTA traffic for other nodes.\n\nPROFILE - Compact LoRa OTA: can install LoRa OTA while retaining this exact target's board functions. To fit update staging, it omits selected optional environmental/ranging drivers; I2C remains available and is not globally disabled. USB debug/packet logging is OFF.\n\nINSTALL \u2014 Browser flashing uses the Serial DFU .zip. The .uf2 is provided for manual drag-and-drop through a compatible UF2 bootloader volume. A normal firmware flash does not erase saved settings.\n\nLORA OTA \u2014 This page installs the base firmware, not a .mota package. Obtain and apply exact board-and-role .mota updates separately through the MeshCore LoRa OTA workflow; .mota files cannot be web flashed here.\n\nBOOTLOADER \u2014 nRF52 LoRa OTA requires the exact-board OTAFIX 2.4.1 Preview 6 bootloader, and \u201cota self\u201d must report \u201cbootloader: apply OK.\u201d Never substitute another board\u2019s bootloader. https://github.com/mikecarper/Adafruit_nRF52_Bootloader_OTAFIX/releases/tag/0.9.2-OTAFIX2.4.1-preview.6\n\nSELECTION \u2014 LoRa OTA, selected optional sensor drivers omitted.", "files": [ { "type": "flash", @@ -2975,10 +2975,10 @@ { "role": "repeater", "title": "Repeater", - "subTitle": "LoRa OTA, no external sensors", + "subTitle": "LoRa OTA, selected optional sensor drivers omitted", "version": { "lora-ota-no-external-sensors-v1.17.1.1-759a35fc": { - "notes": "Keymind Cascade MeshCore v1.17.1.1 with Halo/Keymind retry tuning and Cascade defaults.\n\nROLE \u2014 Repeater: always-on infrastructure that relays mesh traffic. Configure its name/location, radio settings, and admin password. A repeater does not need an OTA-capable build merely to relay OTA traffic for other nodes.\n\nPROFILE \u2014 Compact LoRa OTA: can install LoRa OTA and keeps core role features plus board-native display/GPS, but removes optional external environmental-sensor drivers to make update staging fit. USB debug/packet logging is OFF.\n\nINSTALL \u2014 Browser flashing uses the Serial DFU .zip. The .uf2 is provided for manual drag-and-drop through a compatible UF2 bootloader volume. A normal firmware flash does not erase saved settings.\n\nLORA OTA \u2014 This page installs the base firmware, not a .mota package. Obtain and apply exact board-and-role .mota updates separately through the MeshCore LoRa OTA workflow; .mota files cannot be web flashed here.\n\nBOOTLOADER \u2014 nRF52 LoRa OTA requires the exact-board OTAFIX 2.4.1 Preview 6 bootloader, and \u201cota self\u201d must report \u201cbootloader: apply OK.\u201d Never substitute another board\u2019s bootloader. https://github.com/mikecarper/Adafruit_nRF52_Bootloader_OTAFIX/releases/tag/0.9.2-OTAFIX2.4.1-preview.6\n\nSELECTION \u2014 LoRa OTA, no external sensors.", + "notes": "Keymind Cascade MeshCore v1.17.1.1 with Halo/Keymind retry tuning and Cascade defaults.\n\nROLE \u2014 Repeater: always-on infrastructure that relays mesh traffic. Configure its name/location, radio settings, and admin password. A repeater does not need an OTA-capable build merely to relay OTA traffic for other nodes.\n\nPROFILE - Compact LoRa OTA: can install LoRa OTA while retaining this exact target's board functions. To fit update staging, it omits selected optional environmental/ranging drivers; I2C remains available and is not globally disabled. USB debug/packet logging is OFF.\n\nINSTALL \u2014 Browser flashing uses the Serial DFU .zip. The .uf2 is provided for manual drag-and-drop through a compatible UF2 bootloader volume. A normal firmware flash does not erase saved settings.\n\nLORA OTA \u2014 This page installs the base firmware, not a .mota package. Obtain and apply exact board-and-role .mota updates separately through the MeshCore LoRa OTA workflow; .mota files cannot be web flashed here.\n\nBOOTLOADER \u2014 nRF52 LoRa OTA requires the exact-board OTAFIX 2.4.1 Preview 6 bootloader, and \u201cota self\u201d must report \u201cbootloader: apply OK.\u201d Never substitute another board\u2019s bootloader. https://github.com/mikecarper/Adafruit_nRF52_Bootloader_OTAFIX/releases/tag/0.9.2-OTAFIX2.4.1-preview.6\n\nSELECTION \u2014 LoRa OTA, selected optional sensor drivers omitted.", "files": [ { "type": "flash", @@ -3075,10 +3075,10 @@ { "role": "repeater", "title": "Repeater", - "subTitle": "LoRa OTA, no external sensors", + "subTitle": "LoRa OTA, selected optional sensor drivers omitted", "version": { "lora-ota-no-external-sensors-v1.17.1.1-759a35fc": { - "notes": "Keymind Cascade MeshCore v1.17.1.1 with Halo/Keymind retry tuning and Cascade defaults.\n\nROLE \u2014 Repeater: always-on infrastructure that relays mesh traffic. Configure its name/location, radio settings, and admin password. A repeater does not need an OTA-capable build merely to relay OTA traffic for other nodes.\n\nPROFILE \u2014 Compact LoRa OTA: can install LoRa OTA and keeps core role features plus board-native display/GPS, but removes optional external environmental-sensor drivers to make update staging fit. USB debug/packet logging is OFF.\n\nINSTALL \u2014 Browser flashing uses the Serial DFU .zip. The .uf2 is provided for manual drag-and-drop through a compatible UF2 bootloader volume. A normal firmware flash does not erase saved settings.\n\nLORA OTA \u2014 This page installs the base firmware, not a .mota package. Obtain and apply exact board-and-role .mota updates separately through the MeshCore LoRa OTA workflow; .mota files cannot be web flashed here.\n\nBOOTLOADER \u2014 nRF52 LoRa OTA requires the exact-board OTAFIX 2.4.1 Preview 6 bootloader, and \u201cota self\u201d must report \u201cbootloader: apply OK.\u201d Never substitute another board\u2019s bootloader. https://github.com/mikecarper/Adafruit_nRF52_Bootloader_OTAFIX/releases/tag/0.9.2-OTAFIX2.4.1-preview.6\n\nSELECTION \u2014 LoRa OTA, no external sensors.", + "notes": "Keymind Cascade MeshCore v1.17.1.1 with Halo/Keymind retry tuning and Cascade defaults.\n\nROLE \u2014 Repeater: always-on infrastructure that relays mesh traffic. Configure its name/location, radio settings, and admin password. A repeater does not need an OTA-capable build merely to relay OTA traffic for other nodes.\n\nPROFILE - Compact LoRa OTA: can install LoRa OTA while retaining this exact target's board functions. To fit update staging, it omits selected optional environmental/ranging drivers; I2C remains available and is not globally disabled. USB debug/packet logging is OFF.\n\nINSTALL \u2014 Browser flashing uses the Serial DFU .zip. The .uf2 is provided for manual drag-and-drop through a compatible UF2 bootloader volume. A normal firmware flash does not erase saved settings.\n\nLORA OTA \u2014 This page installs the base firmware, not a .mota package. Obtain and apply exact board-and-role .mota updates separately through the MeshCore LoRa OTA workflow; .mota files cannot be web flashed here.\n\nBOOTLOADER \u2014 nRF52 LoRa OTA requires the exact-board OTAFIX 2.4.1 Preview 6 bootloader, and \u201cota self\u201d must report \u201cbootloader: apply OK.\u201d Never substitute another board\u2019s bootloader. https://github.com/mikecarper/Adafruit_nRF52_Bootloader_OTAFIX/releases/tag/0.9.2-OTAFIX2.4.1-preview.6\n\nSELECTION \u2014 LoRa OTA, selected optional sensor drivers omitted.", "files": [ { "type": "flash", @@ -3175,10 +3175,10 @@ { "role": "repeater", "title": "Repeater", - "subTitle": "LoRa OTA, no external sensors", + "subTitle": "LoRa OTA, selected optional sensor drivers omitted", "version": { "lora-ota-no-external-sensors-v1.17.1.1-759a35fc": { - "notes": "Keymind Cascade MeshCore v1.17.1.1 with Halo/Keymind retry tuning and Cascade defaults.\n\nROLE \u2014 Repeater: always-on infrastructure that relays mesh traffic. Configure its name/location, radio settings, and admin password. A repeater does not need an OTA-capable build merely to relay OTA traffic for other nodes.\n\nPROFILE \u2014 Compact LoRa OTA: can install LoRa OTA and keeps core role features plus board-native display/GPS, but removes optional external environmental-sensor drivers to make update staging fit. USB debug/packet logging is OFF.\n\nINSTALL \u2014 Browser flashing uses the Serial DFU .zip. The .uf2 is provided for manual drag-and-drop through a compatible UF2 bootloader volume. A normal firmware flash does not erase saved settings.\n\nLORA OTA \u2014 This page installs the base firmware, not a .mota package. Obtain and apply exact board-and-role .mota updates separately through the MeshCore LoRa OTA workflow; .mota files cannot be web flashed here.\n\nBOOTLOADER \u2014 nRF52 LoRa OTA requires the exact-board OTAFIX 2.4.1 Preview 6 bootloader, and \u201cota self\u201d must report \u201cbootloader: apply OK.\u201d Never substitute another board\u2019s bootloader. https://github.com/mikecarper/Adafruit_nRF52_Bootloader_OTAFIX/releases/tag/0.9.2-OTAFIX2.4.1-preview.6\n\nSELECTION \u2014 LoRa OTA, no external sensors.", + "notes": "Keymind Cascade MeshCore v1.17.1.1 with Halo/Keymind retry tuning and Cascade defaults.\n\nROLE \u2014 Repeater: always-on infrastructure that relays mesh traffic. Configure its name/location, radio settings, and admin password. A repeater does not need an OTA-capable build merely to relay OTA traffic for other nodes.\n\nPROFILE - Compact LoRa OTA: can install LoRa OTA while retaining this exact target's board functions. To fit update staging, it omits selected optional environmental/ranging drivers; I2C remains available and is not globally disabled. USB debug/packet logging is OFF.\n\nINSTALL \u2014 Browser flashing uses the Serial DFU .zip. The .uf2 is provided for manual drag-and-drop through a compatible UF2 bootloader volume. A normal firmware flash does not erase saved settings.\n\nLORA OTA \u2014 This page installs the base firmware, not a .mota package. Obtain and apply exact board-and-role .mota updates separately through the MeshCore LoRa OTA workflow; .mota files cannot be web flashed here.\n\nBOOTLOADER \u2014 nRF52 LoRa OTA requires the exact-board OTAFIX 2.4.1 Preview 6 bootloader, and \u201cota self\u201d must report \u201cbootloader: apply OK.\u201d Never substitute another board\u2019s bootloader. https://github.com/mikecarper/Adafruit_nRF52_Bootloader_OTAFIX/releases/tag/0.9.2-OTAFIX2.4.1-preview.6\n\nSELECTION \u2014 LoRa OTA, selected optional sensor drivers omitted.", "files": [ { "type": "flash", @@ -3277,10 +3277,10 @@ { "role": "repeater", "title": "Repeater", - "subTitle": "Internal flash, LoRa OTA, no external sensors", + "subTitle": "Internal flash, LoRa OTA, selected optional sensor drivers omitted", "version": { "internal-flash-lora-ota-no-external-sensors-v1.17.1.1-759a35fc": { - "notes": "Keymind Cascade MeshCore v1.17.1.1 with Halo/Keymind retry tuning and Cascade defaults.\n\nROLE \u2014 Repeater: always-on infrastructure that relays mesh traffic. Configure its name/location, radio settings, and admin password. A repeater does not need an OTA-capable build merely to relay OTA traffic for other nodes.\n\nPROFILE \u2014 Compact LoRa OTA: can install LoRa OTA and keeps core role features plus board-native display/GPS, but removes optional external environmental-sensor drivers to make update staging fit. USB debug/packet logging is OFF.\n\nINSTALL \u2014 Browser flashing uses the Serial DFU .zip. The .uf2 is provided for manual drag-and-drop through a compatible UF2 bootloader volume. A normal firmware flash does not erase saved settings.\n\nLORA OTA \u2014 This page installs the base firmware, not a .mota package. Obtain and apply exact board-and-role .mota updates separately through the MeshCore LoRa OTA workflow; .mota files cannot be web flashed here.\n\nBOOTLOADER \u2014 nRF52 LoRa OTA requires the exact-board OTAFIX 2.4.1 Preview 6 bootloader, and \u201cota self\u201d must report \u201cbootloader: apply OK.\u201d Never substitute another board\u2019s bootloader. https://github.com/mikecarper/Adafruit_nRF52_Bootloader_OTAFIX/releases/tag/0.9.2-OTAFIX2.4.1-preview.6\n\nSELECTION \u2014 Internal flash, LoRa OTA, no external sensors.", + "notes": "Keymind Cascade MeshCore v1.17.1.1 with Halo/Keymind retry tuning and Cascade defaults.\n\nROLE \u2014 Repeater: always-on infrastructure that relays mesh traffic. Configure its name/location, radio settings, and admin password. A repeater does not need an OTA-capable build merely to relay OTA traffic for other nodes.\n\nPROFILE - Compact LoRa OTA: can install LoRa OTA while retaining this exact target's board functions. To fit update staging, it omits selected optional environmental/ranging drivers; I2C remains available and is not globally disabled. USB debug/packet logging is OFF.\n\nINSTALL \u2014 Browser flashing uses the Serial DFU .zip. The .uf2 is provided for manual drag-and-drop through a compatible UF2 bootloader volume. A normal firmware flash does not erase saved settings.\n\nLORA OTA \u2014 This page installs the base firmware, not a .mota package. Obtain and apply exact board-and-role .mota updates separately through the MeshCore LoRa OTA workflow; .mota files cannot be web flashed here.\n\nBOOTLOADER \u2014 nRF52 LoRa OTA requires the exact-board OTAFIX 2.4.1 Preview 6 bootloader, and \u201cota self\u201d must report \u201cbootloader: apply OK.\u201d Never substitute another board\u2019s bootloader. https://github.com/mikecarper/Adafruit_nRF52_Bootloader_OTAFIX/releases/tag/0.9.2-OTAFIX2.4.1-preview.6\n\nSELECTION \u2014 Internal flash, LoRa OTA, selected optional sensor drivers omitted.", "files": [ { "type": "flash", @@ -3301,10 +3301,10 @@ { "role": "repeater", "title": "Repeater", - "subTitle": "microSD, LoRa OTA, no external sensors", + "subTitle": "microSD, LoRa OTA, selected optional sensor drivers omitted", "version": { "microsd-lora-ota-no-external-sensors-v1.17.1.1-759a35fc": { - "notes": "Keymind Cascade MeshCore v1.17.1.1 with Halo/Keymind retry tuning and Cascade defaults.\n\nROLE \u2014 Repeater: always-on infrastructure that relays mesh traffic. Configure its name/location, radio settings, and admin password. A repeater does not need an OTA-capable build merely to relay OTA traffic for other nodes.\n\nPROFILE \u2014 Compact LoRa OTA: can install LoRa OTA and keeps core role features plus board-native display/GPS, but removes optional external environmental-sensor drivers to make update staging fit. USB debug/packet logging is OFF.\n\nINSTALL \u2014 Browser flashing uses the Serial DFU .zip. The .uf2 is provided for manual drag-and-drop through a compatible UF2 bootloader volume. A normal firmware flash does not erase saved settings.\n\nLORA OTA \u2014 This page installs the base firmware, not a .mota package. Obtain and apply exact board-and-role .mota updates separately through the MeshCore LoRa OTA workflow; .mota files cannot be web flashed here.\n\nBOOTLOADER \u2014 nRF52 LoRa OTA requires the exact-board OTAFIX 2.4.1 Preview 6 bootloader, and \u201cota self\u201d must report \u201cbootloader: apply OK.\u201d Never substitute another board\u2019s bootloader. https://github.com/mikecarper/Adafruit_nRF52_Bootloader_OTAFIX/releases/tag/0.9.2-OTAFIX2.4.1-preview.6\n\nSELECTION \u2014 microSD, LoRa OTA, no external sensors.\n\nSD REQUIREMENT \u2014 MeshTower V2 LoRa OTA stages updates on the onboard microSD. Keep the card inserted through installation and use FAT16/FAT32/exFAT on an MBR card whose first partition starts after sector 1; the matching SD-aware OTAFIX bootloader is mandatory.", + "notes": "Keymind Cascade MeshCore v1.17.1.1 with Halo/Keymind retry tuning and Cascade defaults.\n\nROLE \u2014 Repeater: always-on infrastructure that relays mesh traffic. Configure its name/location, radio settings, and admin password. A repeater does not need an OTA-capable build merely to relay OTA traffic for other nodes.\n\nPROFILE - Compact LoRa OTA: can install LoRa OTA while retaining this exact target's board functions. To fit update staging, it omits selected optional environmental/ranging drivers; I2C remains available and is not globally disabled. USB debug/packet logging is OFF.\n\nINSTALL \u2014 Browser flashing uses the Serial DFU .zip. The .uf2 is provided for manual drag-and-drop through a compatible UF2 bootloader volume. A normal firmware flash does not erase saved settings.\n\nLORA OTA \u2014 This page installs the base firmware, not a .mota package. Obtain and apply exact board-and-role .mota updates separately through the MeshCore LoRa OTA workflow; .mota files cannot be web flashed here.\n\nBOOTLOADER \u2014 nRF52 LoRa OTA requires the exact-board OTAFIX 2.4.1 Preview 6 bootloader, and \u201cota self\u201d must report \u201cbootloader: apply OK.\u201d Never substitute another board\u2019s bootloader. https://github.com/mikecarper/Adafruit_nRF52_Bootloader_OTAFIX/releases/tag/0.9.2-OTAFIX2.4.1-preview.6\n\nSELECTION \u2014 microSD, LoRa OTA, selected optional sensor drivers omitted.\n\nSD REQUIREMENT \u2014 MeshTower V2 LoRa OTA stages updates on the onboard microSD. Keep the card inserted through installation and use FAT16/FAT32/exFAT on an MBR card whose first partition starts after sector 1; the matching SD-aware OTAFIX bootloader is mandatory.", "files": [ { "type": "flash", @@ -3620,10 +3620,10 @@ { "role": "repeater", "title": "Repeater", - "subTitle": "With display, LoRa OTA, no external sensors", + "subTitle": "With display, LoRa OTA, selected optional sensor drivers omitted", "version": { "with-display-lora-ota-no-external-sensors-v1.17.1.1-759a35fc": { - "notes": "Keymind Cascade MeshCore v1.17.1.1 with Halo/Keymind retry tuning and Cascade defaults.\n\nROLE \u2014 Repeater: always-on infrastructure that relays mesh traffic. Configure its name/location, radio settings, and admin password. A repeater does not need an OTA-capable build merely to relay OTA traffic for other nodes.\n\nPROFILE \u2014 Compact LoRa OTA: can install LoRa OTA and keeps core role features plus board-native display/GPS, but removes optional external environmental-sensor drivers to make update staging fit. USB debug/packet logging is OFF.\n\nINSTALL \u2014 First-time setup uses Full install (the merged bootloader + firmware image). Routine upgrades use Update (the app-only image) and normally preserve settings. Enable Erase all flash only when you intentionally want a clean reset.\n\nLORA OTA \u2014 This page installs the base firmware, not a .mota package. Obtain and apply exact board-and-role .mota updates separately through the MeshCore LoRa OTA workflow; .mota files cannot be web flashed here.\n\nSELECTION \u2014 With display, LoRa OTA, no external sensors.\n\nHARDWARE \u2014 Match the display/accessory variant to the hardware actually installed.", + "notes": "Keymind Cascade MeshCore v1.17.1.1 with Halo/Keymind retry tuning and Cascade defaults.\n\nROLE \u2014 Repeater: always-on infrastructure that relays mesh traffic. Configure its name/location, radio settings, and admin password. A repeater does not need an OTA-capable build merely to relay OTA traffic for other nodes.\n\nPROFILE - Compact LoRa OTA: can install LoRa OTA while retaining this exact target's board functions. To fit update staging, it omits selected optional environmental/ranging drivers; I2C remains available and is not globally disabled. USB debug/packet logging is OFF.\n\nINSTALL \u2014 First-time setup uses Full install (the merged bootloader + firmware image). Routine upgrades use Update (the app-only image) and normally preserve settings. Enable Erase all flash only when you intentionally want a clean reset.\n\nLORA OTA \u2014 This page installs the base firmware, not a .mota package. Obtain and apply exact board-and-role .mota updates separately through the MeshCore LoRa OTA workflow; .mota files cannot be web flashed here.\n\nSELECTION \u2014 With display, LoRa OTA, selected optional sensor drivers omitted.\n\nHARDWARE \u2014 Match the display/accessory variant to the hardware actually installed.", "files": [ { "type": "flash-wipe", @@ -3668,10 +3668,10 @@ { "role": "repeater", "title": "Repeater", - "subTitle": "Without display, LoRa OTA, no external sensors", + "subTitle": "Without display, LoRa OTA, selected optional sensor drivers omitted", "version": { "without-display-lora-ota-no-external-sensors-v1.17.1.1-759a35fc": { - "notes": "Keymind Cascade MeshCore v1.17.1.1 with Halo/Keymind retry tuning and Cascade defaults.\n\nROLE \u2014 Repeater: always-on infrastructure that relays mesh traffic. Configure its name/location, radio settings, and admin password. A repeater does not need an OTA-capable build merely to relay OTA traffic for other nodes.\n\nPROFILE \u2014 Compact LoRa OTA: can install LoRa OTA and keeps core role features plus board-native display/GPS, but removes optional external environmental-sensor drivers to make update staging fit. USB debug/packet logging is OFF.\n\nINSTALL \u2014 First-time setup uses Full install (the merged bootloader + firmware image). Routine upgrades use Update (the app-only image) and normally preserve settings. Enable Erase all flash only when you intentionally want a clean reset.\n\nLORA OTA \u2014 This page installs the base firmware, not a .mota package. Obtain and apply exact board-and-role .mota updates separately through the MeshCore LoRa OTA workflow; .mota files cannot be web flashed here.\n\nSELECTION \u2014 Without display, LoRa OTA, no external sensors.\n\nHARDWARE \u2014 Match the display/accessory variant to the hardware actually installed.", + "notes": "Keymind Cascade MeshCore v1.17.1.1 with Halo/Keymind retry tuning and Cascade defaults.\n\nROLE \u2014 Repeater: always-on infrastructure that relays mesh traffic. Configure its name/location, radio settings, and admin password. A repeater does not need an OTA-capable build merely to relay OTA traffic for other nodes.\n\nPROFILE - Compact LoRa OTA: can install LoRa OTA while retaining this exact target's board functions. To fit update staging, it omits selected optional environmental/ranging drivers; I2C remains available and is not globally disabled. USB debug/packet logging is OFF.\n\nINSTALL \u2014 First-time setup uses Full install (the merged bootloader + firmware image). Routine upgrades use Update (the app-only image) and normally preserve settings. Enable Erase all flash only when you intentionally want a clean reset.\n\nLORA OTA \u2014 This page installs the base firmware, not a .mota package. Obtain and apply exact board-and-role .mota updates separately through the MeshCore LoRa OTA workflow; .mota files cannot be web flashed here.\n\nSELECTION \u2014 Without display, LoRa OTA, selected optional sensor drivers omitted.\n\nHARDWARE \u2014 Match the display/accessory variant to the hardware actually installed.", "files": [ { "type": "flash-wipe", @@ -3912,10 +3912,10 @@ { "role": "repeater", "title": "Repeater", - "subTitle": "LoRa OTA, no external sensors", + "subTitle": "LoRa OTA, selected optional sensor drivers omitted", "version": { "lora-ota-no-external-sensors-v1.17.1.1-759a35fc": { - "notes": "Keymind Cascade MeshCore v1.17.1.1 with Halo/Keymind retry tuning and Cascade defaults.\n\nROLE \u2014 Repeater: always-on infrastructure that relays mesh traffic. Configure its name/location, radio settings, and admin password. A repeater does not need an OTA-capable build merely to relay OTA traffic for other nodes.\n\nPROFILE \u2014 Compact LoRa OTA: can install LoRa OTA and keeps core role features plus board-native display/GPS, but removes optional external environmental-sensor drivers to make update staging fit. USB debug/packet logging is OFF.\n\nINSTALL \u2014 Browser flashing uses the Serial DFU .zip. The .uf2 is provided for manual drag-and-drop through a compatible UF2 bootloader volume. A normal firmware flash does not erase saved settings.\n\nLORA OTA \u2014 This page installs the base firmware, not a .mota package. Obtain and apply exact board-and-role .mota updates separately through the MeshCore LoRa OTA workflow; .mota files cannot be web flashed here.\n\nBOOTLOADER \u2014 nRF52 LoRa OTA requires the exact-board OTAFIX 2.4.1 Preview 6 bootloader, and \u201cota self\u201d must report \u201cbootloader: apply OK.\u201d Never substitute another board\u2019s bootloader. https://github.com/mikecarper/Adafruit_nRF52_Bootloader_OTAFIX/releases/tag/0.9.2-OTAFIX2.4.1-preview.6\n\nSELECTION \u2014 LoRa OTA, no external sensors.", + "notes": "Keymind Cascade MeshCore v1.17.1.1 with Halo/Keymind retry tuning and Cascade defaults.\n\nROLE \u2014 Repeater: always-on infrastructure that relays mesh traffic. Configure its name/location, radio settings, and admin password. A repeater does not need an OTA-capable build merely to relay OTA traffic for other nodes.\n\nPROFILE - Compact LoRa OTA: can install LoRa OTA while retaining this exact target's board functions. To fit update staging, it omits selected optional environmental/ranging drivers; I2C remains available and is not globally disabled. USB debug/packet logging is OFF.\n\nINSTALL \u2014 Browser flashing uses the Serial DFU .zip. The .uf2 is provided for manual drag-and-drop through a compatible UF2 bootloader volume. A normal firmware flash does not erase saved settings.\n\nLORA OTA \u2014 This page installs the base firmware, not a .mota package. Obtain and apply exact board-and-role .mota updates separately through the MeshCore LoRa OTA workflow; .mota files cannot be web flashed here.\n\nBOOTLOADER \u2014 nRF52 LoRa OTA requires the exact-board OTAFIX 2.4.1 Preview 6 bootloader, and \u201cota self\u201d must report \u201cbootloader: apply OK.\u201d Never substitute another board\u2019s bootloader. https://github.com/mikecarper/Adafruit_nRF52_Bootloader_OTAFIX/releases/tag/0.9.2-OTAFIX2.4.1-preview.6\n\nSELECTION \u2014 LoRa OTA, selected optional sensor drivers omitted.", "files": [ { "type": "flash", @@ -3960,10 +3960,10 @@ { "role": "repeater", "title": "Repeater", - "subTitle": "Without display, LoRa OTA, no external sensors", + "subTitle": "Without display, LoRa OTA, selected optional sensor drivers omitted", "version": { "without-display-lora-ota-no-external-sensors-v1.17.1.1-759a35fc": { - "notes": "Keymind Cascade MeshCore v1.17.1.1 with Halo/Keymind retry tuning and Cascade defaults.\n\nROLE \u2014 Repeater: always-on infrastructure that relays mesh traffic. Configure its name/location, radio settings, and admin password. A repeater does not need an OTA-capable build merely to relay OTA traffic for other nodes.\n\nPROFILE \u2014 Compact LoRa OTA: can install LoRa OTA and keeps core role features plus board-native display/GPS, but removes optional external environmental-sensor drivers to make update staging fit. USB debug/packet logging is OFF.\n\nINSTALL \u2014 Browser flashing uses the Serial DFU .zip. The .uf2 is provided for manual drag-and-drop through a compatible UF2 bootloader volume. A normal firmware flash does not erase saved settings.\n\nLORA OTA \u2014 This page installs the base firmware, not a .mota package. Obtain and apply exact board-and-role .mota updates separately through the MeshCore LoRa OTA workflow; .mota files cannot be web flashed here.\n\nBOOTLOADER \u2014 nRF52 LoRa OTA requires the exact-board OTAFIX 2.4.1 Preview 6 bootloader, and \u201cota self\u201d must report \u201cbootloader: apply OK.\u201d Never substitute another board\u2019s bootloader. https://github.com/mikecarper/Adafruit_nRF52_Bootloader_OTAFIX/releases/tag/0.9.2-OTAFIX2.4.1-preview.6\n\nSELECTION \u2014 Without display, LoRa OTA, no external sensors.\n\nHARDWARE \u2014 Match the display/accessory variant to the hardware actually installed.", + "notes": "Keymind Cascade MeshCore v1.17.1.1 with Halo/Keymind retry tuning and Cascade defaults.\n\nROLE \u2014 Repeater: always-on infrastructure that relays mesh traffic. Configure its name/location, radio settings, and admin password. A repeater does not need an OTA-capable build merely to relay OTA traffic for other nodes.\n\nPROFILE - Compact LoRa OTA: can install LoRa OTA while retaining this exact target's board functions. To fit update staging, it omits selected optional environmental/ranging drivers; I2C remains available and is not globally disabled. USB debug/packet logging is OFF.\n\nINSTALL \u2014 Browser flashing uses the Serial DFU .zip. The .uf2 is provided for manual drag-and-drop through a compatible UF2 bootloader volume. A normal firmware flash does not erase saved settings.\n\nLORA OTA \u2014 This page installs the base firmware, not a .mota package. Obtain and apply exact board-and-role .mota updates separately through the MeshCore LoRa OTA workflow; .mota files cannot be web flashed here.\n\nBOOTLOADER \u2014 nRF52 LoRa OTA requires the exact-board OTAFIX 2.4.1 Preview 6 bootloader, and \u201cota self\u201d must report \u201cbootloader: apply OK.\u201d Never substitute another board\u2019s bootloader. https://github.com/mikecarper/Adafruit_nRF52_Bootloader_OTAFIX/releases/tag/0.9.2-OTAFIX2.4.1-preview.6\n\nSELECTION \u2014 Without display, LoRa OTA, selected optional sensor drivers omitted.\n\nHARDWARE \u2014 Match the display/accessory variant to the hardware actually installed.", "files": [ { "type": "flash", @@ -4468,10 +4468,10 @@ { "role": "repeater", "title": "Repeater", - "subTitle": "LoRa OTA, no external sensors", + "subTitle": "LoRa OTA, selected optional sensor drivers omitted", "version": { "lora-ota-no-external-sensors-v1.17.1.1-759a35fc": { - "notes": "Keymind Cascade MeshCore v1.17.1.1 with Halo/Keymind retry tuning and Cascade defaults.\n\nROLE \u2014 Repeater: always-on infrastructure that relays mesh traffic. Configure its name/location, radio settings, and admin password. A repeater does not need an OTA-capable build merely to relay OTA traffic for other nodes.\n\nPROFILE \u2014 Compact LoRa OTA: can install LoRa OTA and keeps core role features plus board-native display/GPS, but removes optional external environmental-sensor drivers to make update staging fit. USB debug/packet logging is OFF.\n\nINSTALL \u2014 First-time setup uses Full install (the merged bootloader + firmware image). Routine upgrades use Update (the app-only image) and normally preserve settings. Enable Erase all flash only when you intentionally want a clean reset.\n\nLORA OTA \u2014 This page installs the base firmware, not a .mota package. Obtain and apply exact board-and-role .mota updates separately through the MeshCore LoRa OTA workflow; .mota files cannot be web flashed here.\n\nSELECTION \u2014 LoRa OTA, no external sensors.", + "notes": "Keymind Cascade MeshCore v1.17.1.1 with Halo/Keymind retry tuning and Cascade defaults.\n\nROLE \u2014 Repeater: always-on infrastructure that relays mesh traffic. Configure its name/location, radio settings, and admin password. A repeater does not need an OTA-capable build merely to relay OTA traffic for other nodes.\n\nPROFILE - Compact LoRa OTA: can install LoRa OTA while retaining this exact target's board functions. To fit update staging, it omits selected optional environmental/ranging drivers; I2C remains available and is not globally disabled. USB debug/packet logging is OFF.\n\nINSTALL \u2014 First-time setup uses Full install (the merged bootloader + firmware image). Routine upgrades use Update (the app-only image) and normally preserve settings. Enable Erase all flash only when you intentionally want a clean reset.\n\nLORA OTA \u2014 This page installs the base firmware, not a .mota package. Obtain and apply exact board-and-role .mota updates separately through the MeshCore LoRa OTA workflow; .mota files cannot be web flashed here.\n\nSELECTION \u2014 LoRa OTA, selected optional sensor drivers omitted.", "files": [ { "type": "flash-wipe", @@ -4540,10 +4540,10 @@ { "role": "repeater", "title": "Repeater", - "subTitle": "TFT, LoRa OTA, no external sensors", + "subTitle": "TFT, LoRa OTA, selected optional sensor drivers omitted", "version": { "tft-lora-ota-no-external-sensors-v1.17.1.1-759a35fc": { - "notes": "Keymind Cascade MeshCore v1.17.1.1 with Halo/Keymind retry tuning and Cascade defaults.\n\nROLE \u2014 Repeater: always-on infrastructure that relays mesh traffic. Configure its name/location, radio settings, and admin password. A repeater does not need an OTA-capable build merely to relay OTA traffic for other nodes.\n\nPROFILE \u2014 Compact LoRa OTA: can install LoRa OTA and keeps core role features plus board-native display/GPS, but removes optional external environmental-sensor drivers to make update staging fit. USB debug/packet logging is OFF.\n\nINSTALL \u2014 First-time setup uses Full install (the merged bootloader + firmware image). Routine upgrades use Update (the app-only image) and normally preserve settings. Enable Erase all flash only when you intentionally want a clean reset.\n\nLORA OTA \u2014 This page installs the base firmware, not a .mota package. Obtain and apply exact board-and-role .mota updates separately through the MeshCore LoRa OTA workflow; .mota files cannot be web flashed here.\n\nSELECTION \u2014 TFT, LoRa OTA, no external sensors.\n\nHARDWARE \u2014 Match the display/accessory variant to the hardware actually installed.", + "notes": "Keymind Cascade MeshCore v1.17.1.1 with Halo/Keymind retry tuning and Cascade defaults.\n\nROLE \u2014 Repeater: always-on infrastructure that relays mesh traffic. Configure its name/location, radio settings, and admin password. A repeater does not need an OTA-capable build merely to relay OTA traffic for other nodes.\n\nPROFILE - Compact LoRa OTA: can install LoRa OTA while retaining this exact target's board functions. To fit update staging, it omits selected optional environmental/ranging drivers; I2C remains available and is not globally disabled. USB debug/packet logging is OFF.\n\nINSTALL \u2014 First-time setup uses Full install (the merged bootloader + firmware image). Routine upgrades use Update (the app-only image) and normally preserve settings. Enable Erase all flash only when you intentionally want a clean reset.\n\nLORA OTA \u2014 This page installs the base firmware, not a .mota package. Obtain and apply exact board-and-role .mota updates separately through the MeshCore LoRa OTA workflow; .mota files cannot be web flashed here.\n\nSELECTION \u2014 TFT, LoRa OTA, selected optional sensor drivers omitted.\n\nHARDWARE \u2014 Match the display/accessory variant to the hardware actually installed.", "files": [ { "type": "flash-wipe", @@ -4737,10 +4737,10 @@ { "role": "repeater", "title": "Repeater", - "subTitle": "Expansion Kit, LoRa OTA, no external sensors", + "subTitle": "Expansion Kit, LoRa OTA, selected optional sensor drivers omitted", "version": { "expansion-kit-lora-ota-no-external-sensors-v1.17.1.1-759a35fc": { - "notes": "Keymind Cascade MeshCore v1.17.1.1 with Halo/Keymind retry tuning and Cascade defaults.\n\nROLE \u2014 Repeater: always-on infrastructure that relays mesh traffic. Configure its name/location, radio settings, and admin password. A repeater does not need an OTA-capable build merely to relay OTA traffic for other nodes.\n\nPROFILE \u2014 Compact LoRa OTA: can install LoRa OTA and keeps core role features plus board-native display/GPS, but removes optional external environmental-sensor drivers to make update staging fit. USB debug/packet logging is OFF.\n\nINSTALL \u2014 First-time setup uses Full install (the merged bootloader + firmware image). Routine upgrades use Update (the app-only image) and normally preserve settings. Enable Erase all flash only when you intentionally want a clean reset.\n\nLORA OTA \u2014 This page installs the base firmware, not a .mota package. Obtain and apply exact board-and-role .mota updates separately through the MeshCore LoRa OTA workflow; .mota files cannot be web flashed here.\n\nSELECTION \u2014 Expansion Kit, LoRa OTA, no external sensors.\n\nHARDWARE \u2014 Match the display/accessory variant to the hardware actually installed.", + "notes": "Keymind Cascade MeshCore v1.17.1.1 with Halo/Keymind retry tuning and Cascade defaults.\n\nROLE \u2014 Repeater: always-on infrastructure that relays mesh traffic. Configure its name/location, radio settings, and admin password. A repeater does not need an OTA-capable build merely to relay OTA traffic for other nodes.\n\nPROFILE - Compact LoRa OTA: can install LoRa OTA while retaining this exact target's board functions. To fit update staging, it omits selected optional environmental/ranging drivers; I2C remains available and is not globally disabled. USB debug/packet logging is OFF.\n\nINSTALL \u2014 First-time setup uses Full install (the merged bootloader + firmware image). Routine upgrades use Update (the app-only image) and normally preserve settings. Enable Erase all flash only when you intentionally want a clean reset.\n\nLORA OTA \u2014 This page installs the base firmware, not a .mota package. Obtain and apply exact board-and-role .mota updates separately through the MeshCore LoRa OTA workflow; .mota files cannot be web flashed here.\n\nSELECTION \u2014 Expansion Kit, LoRa OTA, selected optional sensor drivers omitted.\n\nHARDWARE \u2014 Match the display/accessory variant to the hardware actually installed.", "files": [ { "type": "flash-wipe", @@ -5032,10 +5032,10 @@ { "role": "repeater", "title": "Repeater", - "subTitle": "OLED, LoRa OTA, no external sensors", + "subTitle": "OLED, LoRa OTA, selected optional sensor drivers omitted", "version": { "oled-lora-ota-no-external-sensors-v1.17.1.1-759a35fc": { - "notes": "Keymind Cascade MeshCore v1.17.1.1 with Halo/Keymind retry tuning and Cascade defaults.\n\nROLE \u2014 Repeater: always-on infrastructure that relays mesh traffic. Configure its name/location, radio settings, and admin password. A repeater does not need an OTA-capable build merely to relay OTA traffic for other nodes.\n\nPROFILE \u2014 Compact LoRa OTA: can install LoRa OTA and keeps core role features plus board-native display/GPS, but removes optional external environmental-sensor drivers to make update staging fit. USB debug/packet logging is OFF.\n\nINSTALL \u2014 First-time setup uses Full install (the merged bootloader + firmware image). Routine upgrades use Update (the app-only image) and normally preserve settings. Enable Erase all flash only when you intentionally want a clean reset.\n\nLORA OTA \u2014 This page installs the base firmware, not a .mota package. Obtain and apply exact board-and-role .mota updates separately through the MeshCore LoRa OTA workflow; .mota files cannot be web flashed here.\n\nSELECTION \u2014 OLED, LoRa OTA, no external sensors.\n\nHARDWARE \u2014 Match the display/accessory variant to the hardware actually installed.", + "notes": "Keymind Cascade MeshCore v1.17.1.1 with Halo/Keymind retry tuning and Cascade defaults.\n\nROLE \u2014 Repeater: always-on infrastructure that relays mesh traffic. Configure its name/location, radio settings, and admin password. A repeater does not need an OTA-capable build merely to relay OTA traffic for other nodes.\n\nPROFILE - Compact LoRa OTA: can install LoRa OTA while retaining this exact target's board functions. To fit update staging, it omits selected optional environmental/ranging drivers; I2C remains available and is not globally disabled. USB debug/packet logging is OFF.\n\nINSTALL \u2014 First-time setup uses Full install (the merged bootloader + firmware image). Routine upgrades use Update (the app-only image) and normally preserve settings. Enable Erase all flash only when you intentionally want a clean reset.\n\nLORA OTA \u2014 This page installs the base firmware, not a .mota package. Obtain and apply exact board-and-role .mota updates separately through the MeshCore LoRa OTA workflow; .mota files cannot be web flashed here.\n\nSELECTION \u2014 OLED, LoRa OTA, selected optional sensor drivers omitted.\n\nHARDWARE \u2014 Match the display/accessory variant to the hardware actually installed.", "files": [ { "type": "flash-wipe", @@ -5080,10 +5080,10 @@ { "role": "repeater", "title": "Repeater", - "subTitle": "TFT, LoRa OTA, no external sensors", + "subTitle": "TFT, LoRa OTA, selected optional sensor drivers omitted", "version": { "tft-lora-ota-no-external-sensors-v1.17.1.1-759a35fc": { - "notes": "Keymind Cascade MeshCore v1.17.1.1 with Halo/Keymind retry tuning and Cascade defaults.\n\nROLE \u2014 Repeater: always-on infrastructure that relays mesh traffic. Configure its name/location, radio settings, and admin password. A repeater does not need an OTA-capable build merely to relay OTA traffic for other nodes.\n\nPROFILE \u2014 Compact LoRa OTA: can install LoRa OTA and keeps core role features plus board-native display/GPS, but removes optional external environmental-sensor drivers to make update staging fit. USB debug/packet logging is OFF.\n\nINSTALL \u2014 First-time setup uses Full install (the merged bootloader + firmware image). Routine upgrades use Update (the app-only image) and normally preserve settings. Enable Erase all flash only when you intentionally want a clean reset.\n\nLORA OTA \u2014 This page installs the base firmware, not a .mota package. Obtain and apply exact board-and-role .mota updates separately through the MeshCore LoRa OTA workflow; .mota files cannot be web flashed here.\n\nSELECTION \u2014 TFT, LoRa OTA, no external sensors.\n\nHARDWARE \u2014 Match the display/accessory variant to the hardware actually installed.", + "notes": "Keymind Cascade MeshCore v1.17.1.1 with Halo/Keymind retry tuning and Cascade defaults.\n\nROLE \u2014 Repeater: always-on infrastructure that relays mesh traffic. Configure its name/location, radio settings, and admin password. A repeater does not need an OTA-capable build merely to relay OTA traffic for other nodes.\n\nPROFILE - Compact LoRa OTA: can install LoRa OTA while retaining this exact target's board functions. To fit update staging, it omits selected optional environmental/ranging drivers; I2C remains available and is not globally disabled. USB debug/packet logging is OFF.\n\nINSTALL \u2014 First-time setup uses Full install (the merged bootloader + firmware image). Routine upgrades use Update (the app-only image) and normally preserve settings. Enable Erase all flash only when you intentionally want a clean reset.\n\nLORA OTA \u2014 This page installs the base firmware, not a .mota package. Obtain and apply exact board-and-role .mota updates separately through the MeshCore LoRa OTA workflow; .mota files cannot be web flashed here.\n\nSELECTION \u2014 TFT, LoRa OTA, selected optional sensor drivers omitted.\n\nHARDWARE \u2014 Match the display/accessory variant to the hardware actually installed.", "files": [ { "type": "flash-wipe", @@ -5300,10 +5300,10 @@ { "role": "repeater", "title": "Repeater", - "subTitle": "LoRa OTA, no external sensors", + "subTitle": "LoRa OTA, selected optional sensor drivers omitted", "version": { "lora-ota-no-external-sensors-v1.17.1.1-759a35fc": { - "notes": "Keymind Cascade MeshCore v1.17.1.1 with Halo/Keymind retry tuning and Cascade defaults.\n\nROLE \u2014 Repeater: always-on infrastructure that relays mesh traffic. Configure its name/location, radio settings, and admin password. A repeater does not need an OTA-capable build merely to relay OTA traffic for other nodes.\n\nPROFILE \u2014 Compact LoRa OTA: can install LoRa OTA and keeps core role features plus board-native display/GPS, but removes optional external environmental-sensor drivers to make update staging fit. USB debug/packet logging is OFF.\n\nINSTALL \u2014 First-time setup uses Full install (the merged bootloader + firmware image). Routine upgrades use Update (the app-only image) and normally preserve settings. Enable Erase all flash only when you intentionally want a clean reset.\n\nLORA OTA \u2014 This page installs the base firmware, not a .mota package. Obtain and apply exact board-and-role .mota updates separately through the MeshCore LoRa OTA workflow; .mota files cannot be web flashed here.\n\nSELECTION \u2014 LoRa OTA, no external sensors.", + "notes": "Keymind Cascade MeshCore v1.17.1.1 with Halo/Keymind retry tuning and Cascade defaults.\n\nROLE \u2014 Repeater: always-on infrastructure that relays mesh traffic. Configure its name/location, radio settings, and admin password. A repeater does not need an OTA-capable build merely to relay OTA traffic for other nodes.\n\nPROFILE - Compact LoRa OTA: can install LoRa OTA while retaining this exact target's board functions. To fit update staging, it omits selected optional environmental/ranging drivers; I2C remains available and is not globally disabled. USB debug/packet logging is OFF.\n\nINSTALL \u2014 First-time setup uses Full install (the merged bootloader + firmware image). Routine upgrades use Update (the app-only image) and normally preserve settings. Enable Erase all flash only when you intentionally want a clean reset.\n\nLORA OTA \u2014 This page installs the base firmware, not a .mota package. Obtain and apply exact board-and-role .mota updates separately through the MeshCore LoRa OTA workflow; .mota files cannot be web flashed here.\n\nSELECTION \u2014 LoRa OTA, selected optional sensor drivers omitted.", "files": [ { "type": "flash-wipe", @@ -5447,10 +5447,10 @@ { "role": "repeater", "title": "Repeater", - "subTitle": "LoRa OTA, no external sensors", + "subTitle": "LoRa OTA, selected optional sensor drivers omitted", "version": { "lora-ota-no-external-sensors-v1.17.1.1-759a35fc": { - "notes": "Keymind Cascade MeshCore v1.17.1.1 with Halo/Keymind retry tuning and Cascade defaults.\n\nROLE \u2014 Repeater: always-on infrastructure that relays mesh traffic. Configure its name/location, radio settings, and admin password. A repeater does not need an OTA-capable build merely to relay OTA traffic for other nodes.\n\nPROFILE \u2014 Compact LoRa OTA: can install LoRa OTA and keeps core role features plus board-native display/GPS, but removes optional external environmental-sensor drivers to make update staging fit. USB debug/packet logging is OFF.\n\nINSTALL \u2014 First-time setup uses Full install (the merged bootloader + firmware image). Routine upgrades use Update (the app-only image) and normally preserve settings. Enable Erase all flash only when you intentionally want a clean reset.\n\nLORA OTA \u2014 This page installs the base firmware, not a .mota package. Obtain and apply exact board-and-role .mota updates separately through the MeshCore LoRa OTA workflow; .mota files cannot be web flashed here.\n\nSELECTION \u2014 LoRa OTA, no external sensors.", + "notes": "Keymind Cascade MeshCore v1.17.1.1 with Halo/Keymind retry tuning and Cascade defaults.\n\nROLE \u2014 Repeater: always-on infrastructure that relays mesh traffic. Configure its name/location, radio settings, and admin password. A repeater does not need an OTA-capable build merely to relay OTA traffic for other nodes.\n\nPROFILE - Compact LoRa OTA: can install LoRa OTA while retaining this exact target's board functions. To fit update staging, it omits selected optional environmental/ranging drivers; I2C remains available and is not globally disabled. USB debug/packet logging is OFF.\n\nINSTALL \u2014 First-time setup uses Full install (the merged bootloader + firmware image). Routine upgrades use Update (the app-only image) and normally preserve settings. Enable Erase all flash only when you intentionally want a clean reset.\n\nLORA OTA \u2014 This page installs the base firmware, not a .mota package. Obtain and apply exact board-and-role .mota updates separately through the MeshCore LoRa OTA workflow; .mota files cannot be web flashed here.\n\nSELECTION \u2014 LoRa OTA, selected optional sensor drivers omitted.", "files": [ { "type": "flash-wipe", @@ -5594,10 +5594,10 @@ { "role": "repeater", "title": "Repeater", - "subTitle": "LoRa OTA, no external sensors", + "subTitle": "LoRa OTA, selected optional sensor drivers omitted", "version": { "lora-ota-no-external-sensors-v1.17.1.1-759a35fc": { - "notes": "Keymind Cascade MeshCore v1.17.1.1 with Halo/Keymind retry tuning and Cascade defaults.\n\nROLE \u2014 Repeater: always-on infrastructure that relays mesh traffic. Configure its name/location, radio settings, and admin password. A repeater does not need an OTA-capable build merely to relay OTA traffic for other nodes.\n\nPROFILE \u2014 Compact LoRa OTA: can install LoRa OTA and keeps core role features plus board-native display/GPS, but removes optional external environmental-sensor drivers to make update staging fit. USB debug/packet logging is OFF.\n\nINSTALL \u2014 First-time setup uses Full install (the merged bootloader + firmware image). Routine upgrades use Update (the app-only image) and normally preserve settings. Enable Erase all flash only when you intentionally want a clean reset.\n\nLORA OTA \u2014 This page installs the base firmware, not a .mota package. Obtain and apply exact board-and-role .mota updates separately through the MeshCore LoRa OTA workflow; .mota files cannot be web flashed here.\n\nSELECTION \u2014 LoRa OTA, no external sensors.", + "notes": "Keymind Cascade MeshCore v1.17.1.1 with Halo/Keymind retry tuning and Cascade defaults.\n\nROLE \u2014 Repeater: always-on infrastructure that relays mesh traffic. Configure its name/location, radio settings, and admin password. A repeater does not need an OTA-capable build merely to relay OTA traffic for other nodes.\n\nPROFILE - Compact LoRa OTA: can install LoRa OTA while retaining this exact target's board functions. To fit update staging, it omits selected optional environmental/ranging drivers; I2C remains available and is not globally disabled. USB debug/packet logging is OFF.\n\nINSTALL \u2014 First-time setup uses Full install (the merged bootloader + firmware image). Routine upgrades use Update (the app-only image) and normally preserve settings. Enable Erase all flash only when you intentionally want a clean reset.\n\nLORA OTA \u2014 This page installs the base firmware, not a .mota package. Obtain and apply exact board-and-role .mota updates separately through the MeshCore LoRa OTA workflow; .mota files cannot be web flashed here.\n\nSELECTION \u2014 LoRa OTA, selected optional sensor drivers omitted.", "files": [ { "type": "flash-wipe", @@ -5883,10 +5883,10 @@ { "role": "repeater", "title": "Repeater", - "subTitle": "LoRa OTA, no external sensors", + "subTitle": "LoRa OTA, selected optional sensor drivers omitted", "version": { "lora-ota-no-external-sensors-v1.17.1.1-759a35fc": { - "notes": "Keymind Cascade MeshCore v1.17.1.1 with Halo/Keymind retry tuning and Cascade defaults.\n\nROLE \u2014 Repeater: always-on infrastructure that relays mesh traffic. Configure its name/location, radio settings, and admin password. A repeater does not need an OTA-capable build merely to relay OTA traffic for other nodes.\n\nPROFILE \u2014 Compact LoRa OTA: can install LoRa OTA and keeps core role features plus board-native display/GPS, but removes optional external environmental-sensor drivers to make update staging fit. USB debug/packet logging is OFF.\n\nINSTALL \u2014 First-time setup uses Full install (the merged bootloader + firmware image). Routine upgrades use Update (the app-only image) and normally preserve settings. Enable Erase all flash only when you intentionally want a clean reset.\n\nLORA OTA \u2014 This page installs the base firmware, not a .mota package. Obtain and apply exact board-and-role .mota updates separately through the MeshCore LoRa OTA workflow; .mota files cannot be web flashed here.\n\nSELECTION \u2014 LoRa OTA, no external sensors.", + "notes": "Keymind Cascade MeshCore v1.17.1.1 with Halo/Keymind retry tuning and Cascade defaults.\n\nROLE \u2014 Repeater: always-on infrastructure that relays mesh traffic. Configure its name/location, radio settings, and admin password. A repeater does not need an OTA-capable build merely to relay OTA traffic for other nodes.\n\nPROFILE - Compact LoRa OTA: can install LoRa OTA while retaining this exact target's board functions. To fit update staging, it omits selected optional environmental/ranging drivers; I2C remains available and is not globally disabled. USB debug/packet logging is OFF.\n\nINSTALL \u2014 First-time setup uses Full install (the merged bootloader + firmware image). Routine upgrades use Update (the app-only image) and normally preserve settings. Enable Erase all flash only when you intentionally want a clean reset.\n\nLORA OTA \u2014 This page installs the base firmware, not a .mota package. Obtain and apply exact board-and-role .mota updates separately through the MeshCore LoRa OTA workflow; .mota files cannot be web flashed here.\n\nSELECTION \u2014 LoRa OTA, selected optional sensor drivers omitted.", "files": [ { "type": "flash-wipe", @@ -6125,10 +6125,10 @@ { "role": "repeater", "title": "Repeater", - "subTitle": "LoRa OTA, no external sensors", + "subTitle": "LoRa OTA, selected optional sensor drivers omitted", "version": { "lora-ota-no-external-sensors-v1.17.1.1-759a35fc": { - "notes": "Keymind Cascade MeshCore v1.17.1.1 with Halo/Keymind retry tuning and Cascade defaults.\n\nROLE \u2014 Repeater: always-on infrastructure that relays mesh traffic. Configure its name/location, radio settings, and admin password. A repeater does not need an OTA-capable build merely to relay OTA traffic for other nodes.\n\nPROFILE \u2014 Compact LoRa OTA: can install LoRa OTA and keeps core role features plus board-native display/GPS, but removes optional external environmental-sensor drivers to make update staging fit. USB debug/packet logging is OFF.\n\nINSTALL \u2014 First-time setup uses Full install (the merged bootloader + firmware image). Routine upgrades use Update (the app-only image) and normally preserve settings. Enable Erase all flash only when you intentionally want a clean reset.\n\nLORA OTA \u2014 This page installs the base firmware, not a .mota package. Obtain and apply exact board-and-role .mota updates separately through the MeshCore LoRa OTA workflow; .mota files cannot be web flashed here.\n\nSELECTION \u2014 LoRa OTA, no external sensors.", + "notes": "Keymind Cascade MeshCore v1.17.1.1 with Halo/Keymind retry tuning and Cascade defaults.\n\nROLE \u2014 Repeater: always-on infrastructure that relays mesh traffic. Configure its name/location, radio settings, and admin password. A repeater does not need an OTA-capable build merely to relay OTA traffic for other nodes.\n\nPROFILE - Compact LoRa OTA: can install LoRa OTA while retaining this exact target's board functions. To fit update staging, it omits selected optional environmental/ranging drivers; I2C remains available and is not globally disabled. USB debug/packet logging is OFF.\n\nINSTALL \u2014 First-time setup uses Full install (the merged bootloader + firmware image). Routine upgrades use Update (the app-only image) and normally preserve settings. Enable Erase all flash only when you intentionally want a clean reset.\n\nLORA OTA \u2014 This page installs the base firmware, not a .mota package. Obtain and apply exact board-and-role .mota updates separately through the MeshCore LoRa OTA workflow; .mota files cannot be web flashed here.\n\nSELECTION \u2014 LoRa OTA, selected optional sensor drivers omitted.", "files": [ { "type": "flash-wipe", @@ -6448,10 +6448,10 @@ { "role": "repeater", "title": "Repeater", - "subTitle": "LoRa OTA, no external sensors", + "subTitle": "LoRa OTA, selected optional sensor drivers omitted", "version": { "lora-ota-no-external-sensors-v1.17.1.1-759a35fc": { - "notes": "Keymind Cascade MeshCore v1.17.1.1 with Halo/Keymind retry tuning and Cascade defaults.\n\nROLE \u2014 Repeater: always-on infrastructure that relays mesh traffic. Configure its name/location, radio settings, and admin password. A repeater does not need an OTA-capable build merely to relay OTA traffic for other nodes.\n\nPROFILE \u2014 Compact LoRa OTA: can install LoRa OTA and keeps core role features plus board-native display/GPS, but removes optional external environmental-sensor drivers to make update staging fit. USB debug/packet logging is OFF.\n\nINSTALL \u2014 First-time setup uses Full install (the merged bootloader + firmware image). Routine upgrades use Update (the app-only image) and normally preserve settings. Enable Erase all flash only when you intentionally want a clean reset.\n\nLORA OTA \u2014 This page installs the base firmware, not a .mota package. Obtain and apply exact board-and-role .mota updates separately through the MeshCore LoRa OTA workflow; .mota files cannot be web flashed here.\n\nSELECTION \u2014 LoRa OTA, no external sensors.", + "notes": "Keymind Cascade MeshCore v1.17.1.1 with Halo/Keymind retry tuning and Cascade defaults.\n\nROLE \u2014 Repeater: always-on infrastructure that relays mesh traffic. Configure its name/location, radio settings, and admin password. A repeater does not need an OTA-capable build merely to relay OTA traffic for other nodes.\n\nPROFILE - Compact LoRa OTA: can install LoRa OTA while retaining this exact target's board functions. To fit update staging, it omits selected optional environmental/ranging drivers; I2C remains available and is not globally disabled. USB debug/packet logging is OFF.\n\nINSTALL \u2014 First-time setup uses Full install (the merged bootloader + firmware image). Routine upgrades use Update (the app-only image) and normally preserve settings. Enable Erase all flash only when you intentionally want a clean reset.\n\nLORA OTA \u2014 This page installs the base firmware, not a .mota package. Obtain and apply exact board-and-role .mota updates separately through the MeshCore LoRa OTA workflow; .mota files cannot be web flashed here.\n\nSELECTION \u2014 LoRa OTA, selected optional sensor drivers omitted.", "files": [ { "type": "flash-wipe", @@ -6642,10 +6642,10 @@ { "role": "repeater", "title": "Repeater - No display", - "subTitle": "LoRa OTA, no external sensors", + "subTitle": "LoRa OTA, selected optional sensor drivers omitted", "version": { "lora-ota-no-external-sensors-v1.17.1.1-759a35fc": { - "notes": "Keymind Cascade MeshCore v1.17.1.1 with Halo/Keymind retry tuning and Cascade defaults.\n\nROLE \u2014 Repeater: always-on infrastructure that relays mesh traffic. Configure its name/location, radio settings, and admin password. A repeater does not need an OTA-capable build merely to relay OTA traffic for other nodes.\n\nPROFILE \u2014 Compact LoRa OTA: can install LoRa OTA and keeps core role features plus board-native display/GPS, but removes optional external environmental-sensor drivers to make update staging fit. USB debug/packet logging is OFF.\n\nINSTALL \u2014 Browser flashing uses the Serial DFU .zip. The .uf2 is provided for manual drag-and-drop through a compatible UF2 bootloader volume. A normal firmware flash does not erase saved settings.\n\nLORA OTA \u2014 This page installs the base firmware, not a .mota package. Obtain and apply exact board-and-role .mota updates separately through the MeshCore LoRa OTA workflow; .mota files cannot be web flashed here.\n\nBOOTLOADER \u2014 nRF52 LoRa OTA requires the exact-board OTAFIX 2.4.1 Preview 6 bootloader, and \u201cota self\u201d must report \u201cbootloader: apply OK.\u201d Never substitute another board\u2019s bootloader. https://github.com/mikecarper/Adafruit_nRF52_Bootloader_OTAFIX/releases/tag/0.9.2-OTAFIX2.4.1-preview.6\n\nSELECTION \u2014 LoRa OTA, no external sensors.", + "notes": "Keymind Cascade MeshCore v1.17.1.1 with Halo/Keymind retry tuning and Cascade defaults.\n\nROLE \u2014 Repeater: always-on infrastructure that relays mesh traffic. Configure its name/location, radio settings, and admin password. A repeater does not need an OTA-capable build merely to relay OTA traffic for other nodes.\n\nPROFILE - Compact LoRa OTA: can install LoRa OTA while retaining this exact target's board functions. To fit update staging, it omits selected optional environmental/ranging drivers; I2C remains available and is not globally disabled. USB debug/packet logging is OFF.\n\nINSTALL \u2014 Browser flashing uses the Serial DFU .zip. The .uf2 is provided for manual drag-and-drop through a compatible UF2 bootloader volume. A normal firmware flash does not erase saved settings.\n\nLORA OTA \u2014 This page installs the base firmware, not a .mota package. Obtain and apply exact board-and-role .mota updates separately through the MeshCore LoRa OTA workflow; .mota files cannot be web flashed here.\n\nBOOTLOADER \u2014 nRF52 LoRa OTA requires the exact-board OTAFIX 2.4.1 Preview 6 bootloader, and \u201cota self\u201d must report \u201cbootloader: apply OK.\u201d Never substitute another board\u2019s bootloader. https://github.com/mikecarper/Adafruit_nRF52_Bootloader_OTAFIX/releases/tag/0.9.2-OTAFIX2.4.1-preview.6\n\nSELECTION \u2014 LoRa OTA, selected optional sensor drivers omitted.", "files": [ { "type": "flash", @@ -6788,10 +6788,10 @@ { "role": "repeater", "title": "Repeater - 22dBm", - "subTitle": "LoRa OTA, no external sensors", + "subTitle": "LoRa OTA, selected optional sensor drivers omitted", "version": { "lora-ota-no-external-sensors-v1.17.1.1-759a35fc": { - "notes": "Keymind Cascade MeshCore v1.17.1.1 with Halo/Keymind retry tuning and Cascade defaults.\n\nROLE \u2014 Repeater: always-on infrastructure that relays mesh traffic. Configure its name/location, radio settings, and admin password. A repeater does not need an OTA-capable build merely to relay OTA traffic for other nodes.\n\nPROFILE \u2014 Compact LoRa OTA: can install LoRa OTA and keeps core role features plus board-native display/GPS, but removes optional external environmental-sensor drivers to make update staging fit. USB debug/packet logging is OFF.\n\nINSTALL \u2014 Browser flashing uses the Serial DFU .zip. The .uf2 is provided for manual drag-and-drop through a compatible UF2 bootloader volume. A normal firmware flash does not erase saved settings.\n\nLORA OTA \u2014 This page installs the base firmware, not a .mota package. Obtain and apply exact board-and-role .mota updates separately through the MeshCore LoRa OTA workflow; .mota files cannot be web flashed here.\n\nBOOTLOADER \u2014 nRF52 LoRa OTA requires the exact-board OTAFIX 2.4.1 Preview 6 bootloader, and \u201cota self\u201d must report \u201cbootloader: apply OK.\u201d Never substitute another board\u2019s bootloader. https://github.com/mikecarper/Adafruit_nRF52_Bootloader_OTAFIX/releases/tag/0.9.2-OTAFIX2.4.1-preview.6\n\nSELECTION \u2014 LoRa OTA, no external sensors.", + "notes": "Keymind Cascade MeshCore v1.17.1.1 with Halo/Keymind retry tuning and Cascade defaults.\n\nROLE \u2014 Repeater: always-on infrastructure that relays mesh traffic. Configure its name/location, radio settings, and admin password. A repeater does not need an OTA-capable build merely to relay OTA traffic for other nodes.\n\nPROFILE - Compact LoRa OTA: can install LoRa OTA while retaining this exact target's board functions. To fit update staging, it omits selected optional environmental/ranging drivers; I2C remains available and is not globally disabled. USB debug/packet logging is OFF.\n\nINSTALL \u2014 Browser flashing uses the Serial DFU .zip. The .uf2 is provided for manual drag-and-drop through a compatible UF2 bootloader volume. A normal firmware flash does not erase saved settings.\n\nLORA OTA \u2014 This page installs the base firmware, not a .mota package. Obtain and apply exact board-and-role .mota updates separately through the MeshCore LoRa OTA workflow; .mota files cannot be web flashed here.\n\nBOOTLOADER \u2014 nRF52 LoRa OTA requires the exact-board OTAFIX 2.4.1 Preview 6 bootloader, and \u201cota self\u201d must report \u201cbootloader: apply OK.\u201d Never substitute another board\u2019s bootloader. https://github.com/mikecarper/Adafruit_nRF52_Bootloader_OTAFIX/releases/tag/0.9.2-OTAFIX2.4.1-preview.6\n\nSELECTION \u2014 LoRa OTA, selected optional sensor drivers omitted.", "files": [ { "type": "flash", @@ -6858,10 +6858,10 @@ { "role": "repeater", "title": "Repeater - 30dBm", - "subTitle": "LoRa OTA, no external sensors", + "subTitle": "LoRa OTA, selected optional sensor drivers omitted", "version": { "lora-ota-no-external-sensors-v1.17.1.1-759a35fc": { - "notes": "Keymind Cascade MeshCore v1.17.1.1 with Halo/Keymind retry tuning and Cascade defaults.\n\nROLE \u2014 Repeater: always-on infrastructure that relays mesh traffic. Configure its name/location, radio settings, and admin password. A repeater does not need an OTA-capable build merely to relay OTA traffic for other nodes.\n\nPROFILE \u2014 Compact LoRa OTA: can install LoRa OTA and keeps core role features plus board-native display/GPS, but removes optional external environmental-sensor drivers to make update staging fit. USB debug/packet logging is OFF.\n\nINSTALL \u2014 Browser flashing uses the Serial DFU .zip. The .uf2 is provided for manual drag-and-drop through a compatible UF2 bootloader volume. A normal firmware flash does not erase saved settings.\n\nLORA OTA \u2014 This page installs the base firmware, not a .mota package. Obtain and apply exact board-and-role .mota updates separately through the MeshCore LoRa OTA workflow; .mota files cannot be web flashed here.\n\nBOOTLOADER \u2014 nRF52 LoRa OTA requires the exact-board OTAFIX 2.4.1 Preview 6 bootloader, and \u201cota self\u201d must report \u201cbootloader: apply OK.\u201d Never substitute another board\u2019s bootloader. https://github.com/mikecarper/Adafruit_nRF52_Bootloader_OTAFIX/releases/tag/0.9.2-OTAFIX2.4.1-preview.6\n\nSELECTION \u2014 LoRa OTA, no external sensors.", + "notes": "Keymind Cascade MeshCore v1.17.1.1 with Halo/Keymind retry tuning and Cascade defaults.\n\nROLE \u2014 Repeater: always-on infrastructure that relays mesh traffic. Configure its name/location, radio settings, and admin password. A repeater does not need an OTA-capable build merely to relay OTA traffic for other nodes.\n\nPROFILE - Compact LoRa OTA: can install LoRa OTA while retaining this exact target's board functions. To fit update staging, it omits selected optional environmental/ranging drivers; I2C remains available and is not globally disabled. USB debug/packet logging is OFF.\n\nINSTALL \u2014 Browser flashing uses the Serial DFU .zip. The .uf2 is provided for manual drag-and-drop through a compatible UF2 bootloader volume. A normal firmware flash does not erase saved settings.\n\nLORA OTA \u2014 This page installs the base firmware, not a .mota package. Obtain and apply exact board-and-role .mota updates separately through the MeshCore LoRa OTA workflow; .mota files cannot be web flashed here.\n\nBOOTLOADER \u2014 nRF52 LoRa OTA requires the exact-board OTAFIX 2.4.1 Preview 6 bootloader, and \u201cota self\u201d must report \u201cbootloader: apply OK.\u201d Never substitute another board\u2019s bootloader. https://github.com/mikecarper/Adafruit_nRF52_Bootloader_OTAFIX/releases/tag/0.9.2-OTAFIX2.4.1-preview.6\n\nSELECTION \u2014 LoRa OTA, selected optional sensor drivers omitted.", "files": [ { "type": "flash", @@ -6928,10 +6928,10 @@ { "role": "repeater", "title": "Repeater - 33dBm", - "subTitle": "LoRa OTA, no external sensors", + "subTitle": "LoRa OTA, selected optional sensor drivers omitted", "version": { "lora-ota-no-external-sensors-v1.17.1.1-759a35fc": { - "notes": "Keymind Cascade MeshCore v1.17.1.1 with Halo/Keymind retry tuning and Cascade defaults.\n\nROLE \u2014 Repeater: always-on infrastructure that relays mesh traffic. Configure its name/location, radio settings, and admin password. A repeater does not need an OTA-capable build merely to relay OTA traffic for other nodes.\n\nPROFILE \u2014 Compact LoRa OTA: can install LoRa OTA and keeps core role features plus board-native display/GPS, but removes optional external environmental-sensor drivers to make update staging fit. USB debug/packet logging is OFF.\n\nINSTALL \u2014 Browser flashing uses the Serial DFU .zip. The .uf2 is provided for manual drag-and-drop through a compatible UF2 bootloader volume. A normal firmware flash does not erase saved settings.\n\nLORA OTA \u2014 This page installs the base firmware, not a .mota package. Obtain and apply exact board-and-role .mota updates separately through the MeshCore LoRa OTA workflow; .mota files cannot be web flashed here.\n\nBOOTLOADER \u2014 nRF52 LoRa OTA requires the exact-board OTAFIX 2.4.1 Preview 6 bootloader, and \u201cota self\u201d must report \u201cbootloader: apply OK.\u201d Never substitute another board\u2019s bootloader. https://github.com/mikecarper/Adafruit_nRF52_Bootloader_OTAFIX/releases/tag/0.9.2-OTAFIX2.4.1-preview.6\n\nSELECTION \u2014 LoRa OTA, no external sensors.", + "notes": "Keymind Cascade MeshCore v1.17.1.1 with Halo/Keymind retry tuning and Cascade defaults.\n\nROLE \u2014 Repeater: always-on infrastructure that relays mesh traffic. Configure its name/location, radio settings, and admin password. A repeater does not need an OTA-capable build merely to relay OTA traffic for other nodes.\n\nPROFILE - Compact LoRa OTA: can install LoRa OTA while retaining this exact target's board functions. To fit update staging, it omits selected optional environmental/ranging drivers; I2C remains available and is not globally disabled. USB debug/packet logging is OFF.\n\nINSTALL \u2014 Browser flashing uses the Serial DFU .zip. The .uf2 is provided for manual drag-and-drop through a compatible UF2 bootloader volume. A normal firmware flash does not erase saved settings.\n\nLORA OTA \u2014 This page installs the base firmware, not a .mota package. Obtain and apply exact board-and-role .mota updates separately through the MeshCore LoRa OTA workflow; .mota files cannot be web flashed here.\n\nBOOTLOADER \u2014 nRF52 LoRa OTA requires the exact-board OTAFIX 2.4.1 Preview 6 bootloader, and \u201cota self\u201d must report \u201cbootloader: apply OK.\u201d Never substitute another board\u2019s bootloader. https://github.com/mikecarper/Adafruit_nRF52_Bootloader_OTAFIX/releases/tag/0.9.2-OTAFIX2.4.1-preview.6\n\nSELECTION \u2014 LoRa OTA, selected optional sensor drivers omitted.", "files": [ { "type": "flash", @@ -7074,10 +7074,10 @@ { "role": "repeater", "title": "Repeater - 22dBm", - "subTitle": "LoRa OTA, no external sensors", + "subTitle": "LoRa OTA, selected optional sensor drivers omitted", "version": { "lora-ota-no-external-sensors-v1.17.1.1-759a35fc": { - "notes": "Keymind Cascade MeshCore v1.17.1.1 with Halo/Keymind retry tuning and Cascade defaults.\n\nROLE \u2014 Repeater: always-on infrastructure that relays mesh traffic. Configure its name/location, radio settings, and admin password. A repeater does not need an OTA-capable build merely to relay OTA traffic for other nodes.\n\nPROFILE \u2014 Compact LoRa OTA: can install LoRa OTA and keeps core role features plus board-native display/GPS, but removes optional external environmental-sensor drivers to make update staging fit. USB debug/packet logging is OFF.\n\nINSTALL \u2014 Browser flashing uses the Serial DFU .zip. The .uf2 is provided for manual drag-and-drop through a compatible UF2 bootloader volume. A normal firmware flash does not erase saved settings.\n\nLORA OTA \u2014 This page installs the base firmware, not a .mota package. Obtain and apply exact board-and-role .mota updates separately through the MeshCore LoRa OTA workflow; .mota files cannot be web flashed here.\n\nBOOTLOADER \u2014 nRF52 LoRa OTA requires the exact-board OTAFIX 2.4.1 Preview 6 bootloader, and \u201cota self\u201d must report \u201cbootloader: apply OK.\u201d Never substitute another board\u2019s bootloader. https://github.com/mikecarper/Adafruit_nRF52_Bootloader_OTAFIX/releases/tag/0.9.2-OTAFIX2.4.1-preview.6\n\nSELECTION \u2014 LoRa OTA, no external sensors.", + "notes": "Keymind Cascade MeshCore v1.17.1.1 with Halo/Keymind retry tuning and Cascade defaults.\n\nROLE \u2014 Repeater: always-on infrastructure that relays mesh traffic. Configure its name/location, radio settings, and admin password. A repeater does not need an OTA-capable build merely to relay OTA traffic for other nodes.\n\nPROFILE - Compact LoRa OTA: can install LoRa OTA while retaining this exact target's board functions. To fit update staging, it omits selected optional environmental/ranging drivers; I2C remains available and is not globally disabled. USB debug/packet logging is OFF.\n\nINSTALL \u2014 Browser flashing uses the Serial DFU .zip. The .uf2 is provided for manual drag-and-drop through a compatible UF2 bootloader volume. A normal firmware flash does not erase saved settings.\n\nLORA OTA \u2014 This page installs the base firmware, not a .mota package. Obtain and apply exact board-and-role .mota updates separately through the MeshCore LoRa OTA workflow; .mota files cannot be web flashed here.\n\nBOOTLOADER \u2014 nRF52 LoRa OTA requires the exact-board OTAFIX 2.4.1 Preview 6 bootloader, and \u201cota self\u201d must report \u201cbootloader: apply OK.\u201d Never substitute another board\u2019s bootloader. https://github.com/mikecarper/Adafruit_nRF52_Bootloader_OTAFIX/releases/tag/0.9.2-OTAFIX2.4.1-preview.6\n\nSELECTION \u2014 LoRa OTA, selected optional sensor drivers omitted.", "files": [ { "type": "flash", @@ -7144,10 +7144,10 @@ { "role": "repeater", "title": "Repeater - 30dBm", - "subTitle": "LoRa OTA, no external sensors", + "subTitle": "LoRa OTA, selected optional sensor drivers omitted", "version": { "lora-ota-no-external-sensors-v1.17.1.1-759a35fc": { - "notes": "Keymind Cascade MeshCore v1.17.1.1 with Halo/Keymind retry tuning and Cascade defaults.\n\nROLE \u2014 Repeater: always-on infrastructure that relays mesh traffic. Configure its name/location, radio settings, and admin password. A repeater does not need an OTA-capable build merely to relay OTA traffic for other nodes.\n\nPROFILE \u2014 Compact LoRa OTA: can install LoRa OTA and keeps core role features plus board-native display/GPS, but removes optional external environmental-sensor drivers to make update staging fit. USB debug/packet logging is OFF.\n\nINSTALL \u2014 Browser flashing uses the Serial DFU .zip. The .uf2 is provided for manual drag-and-drop through a compatible UF2 bootloader volume. A normal firmware flash does not erase saved settings.\n\nLORA OTA \u2014 This page installs the base firmware, not a .mota package. Obtain and apply exact board-and-role .mota updates separately through the MeshCore LoRa OTA workflow; .mota files cannot be web flashed here.\n\nBOOTLOADER \u2014 nRF52 LoRa OTA requires the exact-board OTAFIX 2.4.1 Preview 6 bootloader, and \u201cota self\u201d must report \u201cbootloader: apply OK.\u201d Never substitute another board\u2019s bootloader. https://github.com/mikecarper/Adafruit_nRF52_Bootloader_OTAFIX/releases/tag/0.9.2-OTAFIX2.4.1-preview.6\n\nSELECTION \u2014 LoRa OTA, no external sensors.", + "notes": "Keymind Cascade MeshCore v1.17.1.1 with Halo/Keymind retry tuning and Cascade defaults.\n\nROLE \u2014 Repeater: always-on infrastructure that relays mesh traffic. Configure its name/location, radio settings, and admin password. A repeater does not need an OTA-capable build merely to relay OTA traffic for other nodes.\n\nPROFILE - Compact LoRa OTA: can install LoRa OTA while retaining this exact target's board functions. To fit update staging, it omits selected optional environmental/ranging drivers; I2C remains available and is not globally disabled. USB debug/packet logging is OFF.\n\nINSTALL \u2014 Browser flashing uses the Serial DFU .zip. The .uf2 is provided for manual drag-and-drop through a compatible UF2 bootloader volume. A normal firmware flash does not erase saved settings.\n\nLORA OTA \u2014 This page installs the base firmware, not a .mota package. Obtain and apply exact board-and-role .mota updates separately through the MeshCore LoRa OTA workflow; .mota files cannot be web flashed here.\n\nBOOTLOADER \u2014 nRF52 LoRa OTA requires the exact-board OTAFIX 2.4.1 Preview 6 bootloader, and \u201cota self\u201d must report \u201cbootloader: apply OK.\u201d Never substitute another board\u2019s bootloader. https://github.com/mikecarper/Adafruit_nRF52_Bootloader_OTAFIX/releases/tag/0.9.2-OTAFIX2.4.1-preview.6\n\nSELECTION \u2014 LoRa OTA, selected optional sensor drivers omitted.", "files": [ { "type": "flash", @@ -7214,10 +7214,10 @@ { "role": "repeater", "title": "Repeater - 33dBm", - "subTitle": "LoRa OTA, no external sensors", + "subTitle": "LoRa OTA, selected optional sensor drivers omitted", "version": { "lora-ota-no-external-sensors-v1.17.1.1-759a35fc": { - "notes": "Keymind Cascade MeshCore v1.17.1.1 with Halo/Keymind retry tuning and Cascade defaults.\n\nROLE \u2014 Repeater: always-on infrastructure that relays mesh traffic. Configure its name/location, radio settings, and admin password. A repeater does not need an OTA-capable build merely to relay OTA traffic for other nodes.\n\nPROFILE \u2014 Compact LoRa OTA: can install LoRa OTA and keeps core role features plus board-native display/GPS, but removes optional external environmental-sensor drivers to make update staging fit. USB debug/packet logging is OFF.\n\nINSTALL \u2014 Browser flashing uses the Serial DFU .zip. The .uf2 is provided for manual drag-and-drop through a compatible UF2 bootloader volume. A normal firmware flash does not erase saved settings.\n\nLORA OTA \u2014 This page installs the base firmware, not a .mota package. Obtain and apply exact board-and-role .mota updates separately through the MeshCore LoRa OTA workflow; .mota files cannot be web flashed here.\n\nBOOTLOADER \u2014 nRF52 LoRa OTA requires the exact-board OTAFIX 2.4.1 Preview 6 bootloader, and \u201cota self\u201d must report \u201cbootloader: apply OK.\u201d Never substitute another board\u2019s bootloader. https://github.com/mikecarper/Adafruit_nRF52_Bootloader_OTAFIX/releases/tag/0.9.2-OTAFIX2.4.1-preview.6\n\nSELECTION \u2014 LoRa OTA, no external sensors.", + "notes": "Keymind Cascade MeshCore v1.17.1.1 with Halo/Keymind retry tuning and Cascade defaults.\n\nROLE \u2014 Repeater: always-on infrastructure that relays mesh traffic. Configure its name/location, radio settings, and admin password. A repeater does not need an OTA-capable build merely to relay OTA traffic for other nodes.\n\nPROFILE - Compact LoRa OTA: can install LoRa OTA while retaining this exact target's board functions. To fit update staging, it omits selected optional environmental/ranging drivers; I2C remains available and is not globally disabled. USB debug/packet logging is OFF.\n\nINSTALL \u2014 Browser flashing uses the Serial DFU .zip. The .uf2 is provided for manual drag-and-drop through a compatible UF2 bootloader volume. A normal firmware flash does not erase saved settings.\n\nLORA OTA \u2014 This page installs the base firmware, not a .mota package. Obtain and apply exact board-and-role .mota updates separately through the MeshCore LoRa OTA workflow; .mota files cannot be web flashed here.\n\nBOOTLOADER \u2014 nRF52 LoRa OTA requires the exact-board OTAFIX 2.4.1 Preview 6 bootloader, and \u201cota self\u201d must report \u201cbootloader: apply OK.\u201d Never substitute another board\u2019s bootloader. https://github.com/mikecarper/Adafruit_nRF52_Bootloader_OTAFIX/releases/tag/0.9.2-OTAFIX2.4.1-preview.6\n\nSELECTION \u2014 LoRa OTA, selected optional sensor drivers omitted.", "files": [ { "type": "flash", @@ -7314,10 +7314,10 @@ { "role": "repeater", "title": "Repeater", - "subTitle": "LoRa OTA, no external sensors", + "subTitle": "LoRa OTA, selected optional sensor drivers omitted", "version": { "lora-ota-no-external-sensors-v1.17.1.1-759a35fc": { - "notes": "Keymind Cascade MeshCore v1.17.1.1 with Halo/Keymind retry tuning and Cascade defaults.\n\nROLE \u2014 Repeater: always-on infrastructure that relays mesh traffic. Configure its name/location, radio settings, and admin password. A repeater does not need an OTA-capable build merely to relay OTA traffic for other nodes.\n\nPROFILE \u2014 Compact LoRa OTA: can install LoRa OTA and keeps core role features plus board-native display/GPS, but removes optional external environmental-sensor drivers to make update staging fit. USB debug/packet logging is OFF.\n\nINSTALL \u2014 Browser flashing uses the Serial DFU .zip. The .uf2 is provided for manual drag-and-drop through a compatible UF2 bootloader volume. A normal firmware flash does not erase saved settings.\n\nLORA OTA \u2014 This page installs the base firmware, not a .mota package. Obtain and apply exact board-and-role .mota updates separately through the MeshCore LoRa OTA workflow; .mota files cannot be web flashed here.\n\nBOOTLOADER \u2014 nRF52 LoRa OTA requires the exact-board OTAFIX 2.4.1 Preview 6 bootloader, and \u201cota self\u201d must report \u201cbootloader: apply OK.\u201d Never substitute another board\u2019s bootloader. https://github.com/mikecarper/Adafruit_nRF52_Bootloader_OTAFIX/releases/tag/0.9.2-OTAFIX2.4.1-preview.6\n\nSELECTION \u2014 LoRa OTA, no external sensors.", + "notes": "Keymind Cascade MeshCore v1.17.1.1 with Halo/Keymind retry tuning and Cascade defaults.\n\nROLE \u2014 Repeater: always-on infrastructure that relays mesh traffic. Configure its name/location, radio settings, and admin password. A repeater does not need an OTA-capable build merely to relay OTA traffic for other nodes.\n\nPROFILE - Compact LoRa OTA: can install LoRa OTA while retaining this exact target's board functions. To fit update staging, it omits selected optional environmental/ranging drivers; I2C remains available and is not globally disabled. USB debug/packet logging is OFF.\n\nINSTALL \u2014 Browser flashing uses the Serial DFU .zip. The .uf2 is provided for manual drag-and-drop through a compatible UF2 bootloader volume. A normal firmware flash does not erase saved settings.\n\nLORA OTA \u2014 This page installs the base firmware, not a .mota package. Obtain and apply exact board-and-role .mota updates separately through the MeshCore LoRa OTA workflow; .mota files cannot be web flashed here.\n\nBOOTLOADER \u2014 nRF52 LoRa OTA requires the exact-board OTAFIX 2.4.1 Preview 6 bootloader, and \u201cota self\u201d must report \u201cbootloader: apply OK.\u201d Never substitute another board\u2019s bootloader. https://github.com/mikecarper/Adafruit_nRF52_Bootloader_OTAFIX/releases/tag/0.9.2-OTAFIX2.4.1-preview.6\n\nSELECTION \u2014 LoRa OTA, selected optional sensor drivers omitted.", "files": [ { "type": "flash", @@ -7555,10 +7555,10 @@ { "role": "repeater", "title": "Repeater", - "subTitle": "LoRa OTA, no external sensors", + "subTitle": "LoRa OTA, selected optional sensor drivers omitted", "version": { "lora-ota-no-external-sensors-v1.17.1.1-759a35fc": { - "notes": "Keymind Cascade MeshCore v1.17.1.1 with Halo/Keymind retry tuning and Cascade defaults.\n\nROLE \u2014 Repeater: always-on infrastructure that relays mesh traffic. Configure its name/location, radio settings, and admin password. A repeater does not need an OTA-capable build merely to relay OTA traffic for other nodes.\n\nPROFILE \u2014 Compact LoRa OTA: can install LoRa OTA and keeps core role features plus board-native display/GPS, but removes optional external environmental-sensor drivers to make update staging fit. USB debug/packet logging is OFF.\n\nINSTALL \u2014 First-time setup uses Full install (the merged bootloader + firmware image). Routine upgrades use Update (the app-only image) and normally preserve settings. Enable Erase all flash only when you intentionally want a clean reset.\n\nLORA OTA \u2014 This page installs the base firmware, not a .mota package. Obtain and apply exact board-and-role .mota updates separately through the MeshCore LoRa OTA workflow; .mota files cannot be web flashed here.\n\nSELECTION \u2014 LoRa OTA, no external sensors.", + "notes": "Keymind Cascade MeshCore v1.17.1.1 with Halo/Keymind retry tuning and Cascade defaults.\n\nROLE \u2014 Repeater: always-on infrastructure that relays mesh traffic. Configure its name/location, radio settings, and admin password. A repeater does not need an OTA-capable build merely to relay OTA traffic for other nodes.\n\nPROFILE - Compact LoRa OTA: can install LoRa OTA while retaining this exact target's board functions. To fit update staging, it omits selected optional environmental/ranging drivers; I2C remains available and is not globally disabled. USB debug/packet logging is OFF.\n\nINSTALL \u2014 First-time setup uses Full install (the merged bootloader + firmware image). Routine upgrades use Update (the app-only image) and normally preserve settings. Enable Erase all flash only when you intentionally want a clean reset.\n\nLORA OTA \u2014 This page installs the base firmware, not a .mota package. Obtain and apply exact board-and-role .mota updates separately through the MeshCore LoRa OTA workflow; .mota files cannot be web flashed here.\n\nSELECTION \u2014 LoRa OTA, selected optional sensor drivers omitted.", "files": [ { "type": "flash-wipe", @@ -7750,10 +7750,10 @@ { "role": "repeater", "title": "Repeater", - "subTitle": "LoRa OTA, no external sensors", + "subTitle": "LoRa OTA, selected optional sensor drivers omitted", "version": { "lora-ota-no-external-sensors-v1.17.1.1-759a35fc": { - "notes": "Keymind Cascade MeshCore v1.17.1.1 with Halo/Keymind retry tuning and Cascade defaults.\n\nROLE \u2014 Repeater: always-on infrastructure that relays mesh traffic. Configure its name/location, radio settings, and admin password. A repeater does not need an OTA-capable build merely to relay OTA traffic for other nodes.\n\nPROFILE \u2014 Compact LoRa OTA: can install LoRa OTA and keeps core role features plus board-native display/GPS, but removes optional external environmental-sensor drivers to make update staging fit. USB debug/packet logging is OFF.\n\nINSTALL \u2014 First-time setup uses Full install (the merged bootloader + firmware image). Routine upgrades use Update (the app-only image) and normally preserve settings. Enable Erase all flash only when you intentionally want a clean reset.\n\nLORA OTA \u2014 This page installs the base firmware, not a .mota package. Obtain and apply exact board-and-role .mota updates separately through the MeshCore LoRa OTA workflow; .mota files cannot be web flashed here.\n\nSELECTION \u2014 LoRa OTA, no external sensors.", + "notes": "Keymind Cascade MeshCore v1.17.1.1 with Halo/Keymind retry tuning and Cascade defaults.\n\nROLE \u2014 Repeater: always-on infrastructure that relays mesh traffic. Configure its name/location, radio settings, and admin password. A repeater does not need an OTA-capable build merely to relay OTA traffic for other nodes.\n\nPROFILE - Compact LoRa OTA: can install LoRa OTA while retaining this exact target's board functions. To fit update staging, it omits selected optional environmental/ranging drivers; I2C remains available and is not globally disabled. USB debug/packet logging is OFF.\n\nINSTALL \u2014 First-time setup uses Full install (the merged bootloader + firmware image). Routine upgrades use Update (the app-only image) and normally preserve settings. Enable Erase all flash only when you intentionally want a clean reset.\n\nLORA OTA \u2014 This page installs the base firmware, not a .mota package. Obtain and apply exact board-and-role .mota updates separately through the MeshCore LoRa OTA workflow; .mota files cannot be web flashed here.\n\nSELECTION \u2014 LoRa OTA, selected optional sensor drivers omitted.", "files": [ { "type": "flash-wipe", @@ -7945,10 +7945,10 @@ { "role": "repeater", "title": "Repeater", - "subTitle": "LoRa OTA, no external sensors", + "subTitle": "LoRa OTA, selected optional sensor drivers omitted", "version": { "lora-ota-no-external-sensors-v1.17.1.1-759a35fc": { - "notes": "Keymind Cascade MeshCore v1.17.1.1 with Halo/Keymind retry tuning and Cascade defaults.\n\nROLE \u2014 Repeater: always-on infrastructure that relays mesh traffic. Configure its name/location, radio settings, and admin password. A repeater does not need an OTA-capable build merely to relay OTA traffic for other nodes.\n\nPROFILE \u2014 Compact LoRa OTA: can install LoRa OTA and keeps core role features plus board-native display/GPS, but removes optional external environmental-sensor drivers to make update staging fit. USB debug/packet logging is OFF.\n\nINSTALL \u2014 First-time setup uses Full install (the merged bootloader + firmware image). Routine upgrades use Update (the app-only image) and normally preserve settings. Enable Erase all flash only when you intentionally want a clean reset.\n\nLORA OTA \u2014 This page installs the base firmware, not a .mota package. Obtain and apply exact board-and-role .mota updates separately through the MeshCore LoRa OTA workflow; .mota files cannot be web flashed here.\n\nSELECTION \u2014 LoRa OTA, no external sensors.", + "notes": "Keymind Cascade MeshCore v1.17.1.1 with Halo/Keymind retry tuning and Cascade defaults.\n\nROLE \u2014 Repeater: always-on infrastructure that relays mesh traffic. Configure its name/location, radio settings, and admin password. A repeater does not need an OTA-capable build merely to relay OTA traffic for other nodes.\n\nPROFILE - Compact LoRa OTA: can install LoRa OTA while retaining this exact target's board functions. To fit update staging, it omits selected optional environmental/ranging drivers; I2C remains available and is not globally disabled. USB debug/packet logging is OFF.\n\nINSTALL \u2014 First-time setup uses Full install (the merged bootloader + firmware image). Routine upgrades use Update (the app-only image) and normally preserve settings. Enable Erase all flash only when you intentionally want a clean reset.\n\nLORA OTA \u2014 This page installs the base firmware, not a .mota package. Obtain and apply exact board-and-role .mota updates separately through the MeshCore LoRa OTA workflow; .mota files cannot be web flashed here.\n\nSELECTION \u2014 LoRa OTA, selected optional sensor drivers omitted.", "files": [ { "type": "flash-wipe", @@ -8215,10 +8215,10 @@ { "role": "repeater", "title": "Repeater", - "subTitle": "1W PA pins: DIO1 1, NSS 15, BUSY 38, LoRa OTA, no external sensors", + "subTitle": "1W PA pins: DIO1 1, NSS 15, BUSY 38, LoRa OTA, selected optional sensor drivers omitted", "version": { "1w-pa-pins-dio1-1-nss-15-busy-38-lora-ota-no-external-sensors-v1.17.1.1-759a35fc": { - "notes": "Keymind Cascade MeshCore v1.17.1.1 with Halo/Keymind retry tuning and Cascade defaults.\n\nROLE \u2014 Repeater: always-on infrastructure that relays mesh traffic. Configure its name/location, radio settings, and admin password. A repeater does not need an OTA-capable build merely to relay OTA traffic for other nodes.\n\nPROFILE \u2014 Compact LoRa OTA: can install LoRa OTA and keeps core role features plus board-native display/GPS, but removes optional external environmental-sensor drivers to make update staging fit. USB debug/packet logging is OFF.\n\nINSTALL \u2014 First-time setup uses Full install (the merged bootloader + firmware image). Routine upgrades use Update (the app-only image) and normally preserve settings. Enable Erase all flash only when you intentionally want a clean reset.\n\nLORA OTA \u2014 This page installs the base firmware, not a .mota package. Obtain and apply exact board-and-role .mota updates separately through the MeshCore LoRa OTA workflow; .mota files cannot be web flashed here.\n\nSELECTION \u2014 1W PA pins: DIO1 1, NSS 15, BUSY 38, LoRa OTA, no external sensors.\n\nHARDWARE \u2014 Match the listed radio module, output stage, and pin map to the exact board; a similarly named radio variant is not a safe substitute.\n\nBoard selection note: Use this only for the LilyGo T-Beam 1W high-power PA board. It is not the standard T-Beam SX1262 target: this build uses LoRa DIO1=1, NSS=15, RESET=3, BUSY=38, SCLK/MISO/MOSI=13/12/11, RXEN=21, and GPS TX/RX=5/6. The standard T-Beam SX1262 target uses DIO1=33, NSS=18, RESET=23, SCLK/MISO/MOSI=5/19/27, and GPS RX/TX=12/34.", + "notes": "Keymind Cascade MeshCore v1.17.1.1 with Halo/Keymind retry tuning and Cascade defaults.\n\nROLE \u2014 Repeater: always-on infrastructure that relays mesh traffic. Configure its name/location, radio settings, and admin password. A repeater does not need an OTA-capable build merely to relay OTA traffic for other nodes.\n\nPROFILE - Compact LoRa OTA: can install LoRa OTA while retaining this exact target's board functions. To fit update staging, it omits selected optional environmental/ranging drivers; I2C remains available and is not globally disabled. USB debug/packet logging is OFF.\n\nINSTALL \u2014 First-time setup uses Full install (the merged bootloader + firmware image). Routine upgrades use Update (the app-only image) and normally preserve settings. Enable Erase all flash only when you intentionally want a clean reset.\n\nLORA OTA \u2014 This page installs the base firmware, not a .mota package. Obtain and apply exact board-and-role .mota updates separately through the MeshCore LoRa OTA workflow; .mota files cannot be web flashed here.\n\nSELECTION \u2014 1W PA pins: DIO1 1, NSS 15, BUSY 38, LoRa OTA, selected optional sensor drivers omitted.\n\nHARDWARE \u2014 Match the listed radio module, output stage, and pin map to the exact board; a similarly named radio variant is not a safe substitute.\n\nBoard selection note: Use this only for the LilyGo T-Beam 1W high-power PA board. It is not the standard T-Beam SX1262 target: this build uses LoRa DIO1=1, NSS=15, RESET=3, BUSY=38, SCLK/MISO/MOSI=13/12/11, RXEN=21, and GPS TX/RX=5/6. The standard T-Beam SX1262 target uses DIO1=33, NSS=18, RESET=23, SCLK/MISO/MOSI=5/19/27, and GPS RX/TX=12/34.", "files": [ { "type": "flash-wipe", @@ -8435,10 +8435,10 @@ { "role": "repeater", "title": "Repeater", - "subTitle": "LoRa OTA, no external sensors", + "subTitle": "LoRa OTA, selected optional sensor drivers omitted", "version": { "lora-ota-no-external-sensors-v1.17.1.1-759a35fc": { - "notes": "Keymind Cascade MeshCore v1.17.1.1 with Halo/Keymind retry tuning and Cascade defaults.\n\nROLE \u2014 Repeater: always-on infrastructure that relays mesh traffic. Configure its name/location, radio settings, and admin password. A repeater does not need an OTA-capable build merely to relay OTA traffic for other nodes.\n\nPROFILE \u2014 Compact LoRa OTA: can install LoRa OTA and keeps core role features plus board-native display/GPS, but removes optional external environmental-sensor drivers to make update staging fit. USB debug/packet logging is OFF.\n\nINSTALL \u2014 First-time setup uses Full install (the merged bootloader + firmware image). Routine upgrades use Update (the app-only image) and normally preserve settings. Enable Erase all flash only when you intentionally want a clean reset.\n\nLORA OTA \u2014 This page installs the base firmware, not a .mota package. Obtain and apply exact board-and-role .mota updates separately through the MeshCore LoRa OTA workflow; .mota files cannot be web flashed here.\n\nSELECTION \u2014 LoRa OTA, no external sensors.", + "notes": "Keymind Cascade MeshCore v1.17.1.1 with Halo/Keymind retry tuning and Cascade defaults.\n\nROLE \u2014 Repeater: always-on infrastructure that relays mesh traffic. Configure its name/location, radio settings, and admin password. A repeater does not need an OTA-capable build merely to relay OTA traffic for other nodes.\n\nPROFILE - Compact LoRa OTA: can install LoRa OTA while retaining this exact target's board functions. To fit update staging, it omits selected optional environmental/ranging drivers; I2C remains available and is not globally disabled. USB debug/packet logging is OFF.\n\nINSTALL \u2014 First-time setup uses Full install (the merged bootloader + firmware image). Routine upgrades use Update (the app-only image) and normally preserve settings. Enable Erase all flash only when you intentionally want a clean reset.\n\nLORA OTA \u2014 This page installs the base firmware, not a .mota package. Obtain and apply exact board-and-role .mota updates separately through the MeshCore LoRa OTA workflow; .mota files cannot be web flashed here.\n\nSELECTION \u2014 LoRa OTA, selected optional sensor drivers omitted.", "files": [ { "type": "flash-wipe", @@ -8607,10 +8607,10 @@ { "role": "repeater", "title": "Repeater", - "subTitle": "LoRa OTA, no external sensors", + "subTitle": "LoRa OTA, selected optional sensor drivers omitted", "version": { "lora-ota-no-external-sensors-v1.17.1.1-759a35fc": { - "notes": "Keymind Cascade MeshCore v1.17.1.1 with Halo/Keymind retry tuning and Cascade defaults.\n\nROLE \u2014 Repeater: always-on infrastructure that relays mesh traffic. Configure its name/location, radio settings, and admin password. A repeater does not need an OTA-capable build merely to relay OTA traffic for other nodes.\n\nPROFILE \u2014 Compact LoRa OTA: can install LoRa OTA and keeps core role features plus board-native display/GPS, but removes optional external environmental-sensor drivers to make update staging fit. USB debug/packet logging is OFF.\n\nINSTALL \u2014 First-time setup uses Full install (the merged bootloader + firmware image). Routine upgrades use Update (the app-only image) and normally preserve settings. Enable Erase all flash only when you intentionally want a clean reset.\n\nLORA OTA \u2014 This page installs the base firmware, not a .mota package. Obtain and apply exact board-and-role .mota updates separately through the MeshCore LoRa OTA workflow; .mota files cannot be web flashed here.\n\nSELECTION \u2014 LoRa OTA, no external sensors.", + "notes": "Keymind Cascade MeshCore v1.17.1.1 with Halo/Keymind retry tuning and Cascade defaults.\n\nROLE \u2014 Repeater: always-on infrastructure that relays mesh traffic. Configure its name/location, radio settings, and admin password. A repeater does not need an OTA-capable build merely to relay OTA traffic for other nodes.\n\nPROFILE - Compact LoRa OTA: can install LoRa OTA while retaining this exact target's board functions. To fit update staging, it omits selected optional environmental/ranging drivers; I2C remains available and is not globally disabled. USB debug/packet logging is OFF.\n\nINSTALL \u2014 First-time setup uses Full install (the merged bootloader + firmware image). Routine upgrades use Update (the app-only image) and normally preserve settings. Enable Erase all flash only when you intentionally want a clean reset.\n\nLORA OTA \u2014 This page installs the base firmware, not a .mota package. Obtain and apply exact board-and-role .mota updates separately through the MeshCore LoRa OTA workflow; .mota files cannot be web flashed here.\n\nSELECTION \u2014 LoRa OTA, selected optional sensor drivers omitted.", "files": [ { "type": "flash-wipe", @@ -8684,10 +8684,10 @@ { "role": "repeater", "title": "Repeater", - "subTitle": "LoRa OTA, no external sensors", + "subTitle": "LoRa OTA, selected optional sensor drivers omitted", "version": { "lora-ota-no-external-sensors-v1.17.1.1-759a35fc": { - "notes": "Keymind Cascade MeshCore v1.17.1.1 with Halo/Keymind retry tuning and Cascade defaults.\n\nROLE \u2014 Repeater: always-on infrastructure that relays mesh traffic. Configure its name/location, radio settings, and admin password. A repeater does not need an OTA-capable build merely to relay OTA traffic for other nodes.\n\nPROFILE \u2014 Compact LoRa OTA: can install LoRa OTA and keeps core role features plus board-native display/GPS, but removes optional external environmental-sensor drivers to make update staging fit. USB debug/packet logging is OFF.\n\nINSTALL \u2014 Browser flashing uses the Serial DFU .zip. The .uf2 is provided for manual drag-and-drop through a compatible UF2 bootloader volume. A normal firmware flash does not erase saved settings.\n\nLORA OTA \u2014 This page installs the base firmware, not a .mota package. Obtain and apply exact board-and-role .mota updates separately through the MeshCore LoRa OTA workflow; .mota files cannot be web flashed here.\n\nBOOTLOADER \u2014 nRF52 LoRa OTA requires the exact-board OTAFIX 2.4.1 Preview 6 bootloader, and \u201cota self\u201d must report \u201cbootloader: apply OK.\u201d Never substitute another board\u2019s bootloader. https://github.com/mikecarper/Adafruit_nRF52_Bootloader_OTAFIX/releases/tag/0.9.2-OTAFIX2.4.1-preview.6\n\nSELECTION \u2014 LoRa OTA, no external sensors.", + "notes": "Keymind Cascade MeshCore v1.17.1.1 with Halo/Keymind retry tuning and Cascade defaults.\n\nROLE \u2014 Repeater: always-on infrastructure that relays mesh traffic. Configure its name/location, radio settings, and admin password. A repeater does not need an OTA-capable build merely to relay OTA traffic for other nodes.\n\nPROFILE - Compact LoRa OTA: can install LoRa OTA while retaining this exact target's board functions. To fit update staging, it omits selected optional environmental/ranging drivers; I2C remains available and is not globally disabled. USB debug/packet logging is OFF.\n\nINSTALL \u2014 Browser flashing uses the Serial DFU .zip. The .uf2 is provided for manual drag-and-drop through a compatible UF2 bootloader volume. A normal firmware flash does not erase saved settings.\n\nLORA OTA \u2014 This page installs the base firmware, not a .mota package. Obtain and apply exact board-and-role .mota updates separately through the MeshCore LoRa OTA workflow; .mota files cannot be web flashed here.\n\nBOOTLOADER \u2014 nRF52 LoRa OTA requires the exact-board OTAFIX 2.4.1 Preview 6 bootloader, and \u201cota self\u201d must report \u201cbootloader: apply OK.\u201d Never substitute another board\u2019s bootloader. https://github.com/mikecarper/Adafruit_nRF52_Bootloader_OTAFIX/releases/tag/0.9.2-OTAFIX2.4.1-preview.6\n\nSELECTION \u2014 LoRa OTA, selected optional sensor drivers omitted.", "files": [ { "type": "flash", @@ -8784,10 +8784,10 @@ { "role": "repeater", "title": "Repeater", - "subTitle": "LoRa OTA, no external sensors", + "subTitle": "LoRa OTA, selected optional sensor drivers omitted", "version": { "lora-ota-no-external-sensors-v1.17.1.1-759a35fc": { - "notes": "Keymind Cascade MeshCore v1.17.1.1 with Halo/Keymind retry tuning and Cascade defaults.\n\nROLE \u2014 Repeater: always-on infrastructure that relays mesh traffic. Configure its name/location, radio settings, and admin password. A repeater does not need an OTA-capable build merely to relay OTA traffic for other nodes.\n\nPROFILE \u2014 Compact LoRa OTA: can install LoRa OTA and keeps core role features plus board-native display/GPS, but removes optional external environmental-sensor drivers to make update staging fit. USB debug/packet logging is OFF.\n\nINSTALL \u2014 Browser flashing uses the Serial DFU .zip. The .uf2 is provided for manual drag-and-drop through a compatible UF2 bootloader volume. A normal firmware flash does not erase saved settings.\n\nLORA OTA \u2014 This page installs the base firmware, not a .mota package. Obtain and apply exact board-and-role .mota updates separately through the MeshCore LoRa OTA workflow; .mota files cannot be web flashed here.\n\nBOOTLOADER \u2014 nRF52 LoRa OTA requires the exact-board OTAFIX 2.4.1 Preview 6 bootloader, and \u201cota self\u201d must report \u201cbootloader: apply OK.\u201d Never substitute another board\u2019s bootloader. https://github.com/mikecarper/Adafruit_nRF52_Bootloader_OTAFIX/releases/tag/0.9.2-OTAFIX2.4.1-preview.6\n\nSELECTION \u2014 LoRa OTA, no external sensors.", + "notes": "Keymind Cascade MeshCore v1.17.1.1 with Halo/Keymind retry tuning and Cascade defaults.\n\nROLE \u2014 Repeater: always-on infrastructure that relays mesh traffic. Configure its name/location, radio settings, and admin password. A repeater does not need an OTA-capable build merely to relay OTA traffic for other nodes.\n\nPROFILE - Compact LoRa OTA: can install LoRa OTA while retaining this exact target's board functions. To fit update staging, it omits selected optional environmental/ranging drivers; I2C remains available and is not globally disabled. USB debug/packet logging is OFF.\n\nINSTALL \u2014 Browser flashing uses the Serial DFU .zip. The .uf2 is provided for manual drag-and-drop through a compatible UF2 bootloader volume. A normal firmware flash does not erase saved settings.\n\nLORA OTA \u2014 This page installs the base firmware, not a .mota package. Obtain and apply exact board-and-role .mota updates separately through the MeshCore LoRa OTA workflow; .mota files cannot be web flashed here.\n\nBOOTLOADER \u2014 nRF52 LoRa OTA requires the exact-board OTAFIX 2.4.1 Preview 6 bootloader, and \u201cota self\u201d must report \u201cbootloader: apply OK.\u201d Never substitute another board\u2019s bootloader. https://github.com/mikecarper/Adafruit_nRF52_Bootloader_OTAFIX/releases/tag/0.9.2-OTAFIX2.4.1-preview.6\n\nSELECTION \u2014 LoRa OTA, selected optional sensor drivers omitted.", "files": [ { "type": "flash", @@ -8885,10 +8885,10 @@ { "role": "repeater", "title": "Repeater", - "subTitle": "LoRa OTA, no external sensors", + "subTitle": "LoRa OTA, selected optional sensor drivers omitted", "version": { "lora-ota-no-external-sensors-v1.17.1.1-759a35fc": { - "notes": "Keymind Cascade MeshCore v1.17.1.1 with Halo/Keymind retry tuning and Cascade defaults.\n\nROLE \u2014 Repeater: always-on infrastructure that relays mesh traffic. Configure its name/location, radio settings, and admin password. A repeater does not need an OTA-capable build merely to relay OTA traffic for other nodes.\n\nPROFILE \u2014 Compact LoRa OTA: can install LoRa OTA and keeps core role features plus board-native display/GPS, but removes optional external environmental-sensor drivers to make update staging fit. USB debug/packet logging is OFF.\n\nINSTALL \u2014 Browser flashing uses the Serial DFU .zip. The .uf2 is provided for manual drag-and-drop through a compatible UF2 bootloader volume. A normal firmware flash does not erase saved settings.\n\nLORA OTA \u2014 This page installs the base firmware, not a .mota package. Obtain and apply exact board-and-role .mota updates separately through the MeshCore LoRa OTA workflow; .mota files cannot be web flashed here.\n\nBOOTLOADER \u2014 nRF52 LoRa OTA requires the exact-board OTAFIX 2.4.1 Preview 6 bootloader, and \u201cota self\u201d must report \u201cbootloader: apply OK.\u201d Never substitute another board\u2019s bootloader. https://github.com/mikecarper/Adafruit_nRF52_Bootloader_OTAFIX/releases/tag/0.9.2-OTAFIX2.4.1-preview.6\n\nSELECTION \u2014 LoRa OTA, no external sensors.", + "notes": "Keymind Cascade MeshCore v1.17.1.1 with Halo/Keymind retry tuning and Cascade defaults.\n\nROLE \u2014 Repeater: always-on infrastructure that relays mesh traffic. Configure its name/location, radio settings, and admin password. A repeater does not need an OTA-capable build merely to relay OTA traffic for other nodes.\n\nPROFILE - Compact LoRa OTA: can install LoRa OTA while retaining this exact target's board functions. To fit update staging, it omits selected optional environmental/ranging drivers; I2C remains available and is not globally disabled. USB debug/packet logging is OFF.\n\nINSTALL \u2014 Browser flashing uses the Serial DFU .zip. The .uf2 is provided for manual drag-and-drop through a compatible UF2 bootloader volume. A normal firmware flash does not erase saved settings.\n\nLORA OTA \u2014 This page installs the base firmware, not a .mota package. Obtain and apply exact board-and-role .mota updates separately through the MeshCore LoRa OTA workflow; .mota files cannot be web flashed here.\n\nBOOTLOADER \u2014 nRF52 LoRa OTA requires the exact-board OTAFIX 2.4.1 Preview 6 bootloader, and \u201cota self\u201d must report \u201cbootloader: apply OK.\u201d Never substitute another board\u2019s bootloader. https://github.com/mikecarper/Adafruit_nRF52_Bootloader_OTAFIX/releases/tag/0.9.2-OTAFIX2.4.1-preview.6\n\nSELECTION \u2014 LoRa OTA, selected optional sensor drivers omitted.", "files": [ { "type": "flash", @@ -9009,10 +9009,10 @@ { "role": "repeater", "title": "Repeater", - "subTitle": "LoRa OTA, no external sensors", + "subTitle": "LoRa OTA, selected optional sensor drivers omitted", "version": { "lora-ota-no-external-sensors-v1.17.1.1-759a35fc": { - "notes": "Keymind Cascade MeshCore v1.17.1.1 with Halo/Keymind retry tuning and Cascade defaults.\n\nROLE \u2014 Repeater: always-on infrastructure that relays mesh traffic. Configure its name/location, radio settings, and admin password. A repeater does not need an OTA-capable build merely to relay OTA traffic for other nodes.\n\nPROFILE \u2014 Compact LoRa OTA: can install LoRa OTA and keeps core role features plus board-native display/GPS, but removes optional external environmental-sensor drivers to make update staging fit. USB debug/packet logging is OFF.\n\nINSTALL \u2014 First-time setup uses Full install (the merged bootloader + firmware image). Routine upgrades use Update (the app-only image) and normally preserve settings. Enable Erase all flash only when you intentionally want a clean reset.\n\nLORA OTA \u2014 This page installs the base firmware, not a .mota package. Obtain and apply exact board-and-role .mota updates separately through the MeshCore LoRa OTA workflow; .mota files cannot be web flashed here.\n\nSELECTION \u2014 LoRa OTA, no external sensors.", + "notes": "Keymind Cascade MeshCore v1.17.1.1 with Halo/Keymind retry tuning and Cascade defaults.\n\nROLE \u2014 Repeater: always-on infrastructure that relays mesh traffic. Configure its name/location, radio settings, and admin password. A repeater does not need an OTA-capable build merely to relay OTA traffic for other nodes.\n\nPROFILE - Compact LoRa OTA: can install LoRa OTA while retaining this exact target's board functions. To fit update staging, it omits selected optional environmental/ranging drivers; I2C remains available and is not globally disabled. USB debug/packet logging is OFF.\n\nINSTALL \u2014 First-time setup uses Full install (the merged bootloader + firmware image). Routine upgrades use Update (the app-only image) and normally preserve settings. Enable Erase all flash only when you intentionally want a clean reset.\n\nLORA OTA \u2014 This page installs the base firmware, not a .mota package. Obtain and apply exact board-and-role .mota updates separately through the MeshCore LoRa OTA workflow; .mota files cannot be web flashed here.\n\nSELECTION \u2014 LoRa OTA, selected optional sensor drivers omitted.", "files": [ { "type": "flash-wipe", @@ -9086,10 +9086,10 @@ { "role": "repeater", "title": "Repeater", - "subTitle": "LoRa OTA, no external sensors", + "subTitle": "LoRa OTA, selected optional sensor drivers omitted", "version": { "lora-ota-no-external-sensors-v1.17.1.1-759a35fc": { - "notes": "Keymind Cascade MeshCore v1.17.1.1 with Halo/Keymind retry tuning and Cascade defaults.\n\nROLE \u2014 Repeater: always-on infrastructure that relays mesh traffic. Configure its name/location, radio settings, and admin password. A repeater does not need an OTA-capable build merely to relay OTA traffic for other nodes.\n\nPROFILE \u2014 Compact LoRa OTA: can install LoRa OTA and keeps core role features plus board-native display/GPS, but removes optional external environmental-sensor drivers to make update staging fit. USB debug/packet logging is OFF.\n\nINSTALL \u2014 Browser flashing uses the Serial DFU .zip. The .uf2 is provided for manual drag-and-drop through a compatible UF2 bootloader volume. A normal firmware flash does not erase saved settings.\n\nLORA OTA \u2014 This page installs the base firmware, not a .mota package. Obtain and apply exact board-and-role .mota updates separately through the MeshCore LoRa OTA workflow; .mota files cannot be web flashed here.\n\nBOOTLOADER \u2014 nRF52 LoRa OTA requires the exact-board OTAFIX 2.4.1 Preview 6 bootloader, and \u201cota self\u201d must report \u201cbootloader: apply OK.\u201d Never substitute another board\u2019s bootloader. https://github.com/mikecarper/Adafruit_nRF52_Bootloader_OTAFIX/releases/tag/0.9.2-OTAFIX2.4.1-preview.6\n\nSELECTION \u2014 LoRa OTA, no external sensors.", + "notes": "Keymind Cascade MeshCore v1.17.1.1 with Halo/Keymind retry tuning and Cascade defaults.\n\nROLE \u2014 Repeater: always-on infrastructure that relays mesh traffic. Configure its name/location, radio settings, and admin password. A repeater does not need an OTA-capable build merely to relay OTA traffic for other nodes.\n\nPROFILE - Compact LoRa OTA: can install LoRa OTA while retaining this exact target's board functions. To fit update staging, it omits selected optional environmental/ranging drivers; I2C remains available and is not globally disabled. USB debug/packet logging is OFF.\n\nINSTALL \u2014 Browser flashing uses the Serial DFU .zip. The .uf2 is provided for manual drag-and-drop through a compatible UF2 bootloader volume. A normal firmware flash does not erase saved settings.\n\nLORA OTA \u2014 This page installs the base firmware, not a .mota package. Obtain and apply exact board-and-role .mota updates separately through the MeshCore LoRa OTA workflow; .mota files cannot be web flashed here.\n\nBOOTLOADER \u2014 nRF52 LoRa OTA requires the exact-board OTAFIX 2.4.1 Preview 6 bootloader, and \u201cota self\u201d must report \u201cbootloader: apply OK.\u201d Never substitute another board\u2019s bootloader. https://github.com/mikecarper/Adafruit_nRF52_Bootloader_OTAFIX/releases/tag/0.9.2-OTAFIX2.4.1-preview.6\n\nSELECTION \u2014 LoRa OTA, selected optional sensor drivers omitted.", "files": [ { "type": "flash", @@ -9163,10 +9163,10 @@ { "role": "repeater", "title": "Repeater", - "subTitle": "LoRa OTA, no external sensors", + "subTitle": "LoRa OTA, selected optional sensor drivers omitted", "version": { "lora-ota-no-external-sensors-v1.17.1.1-759a35fc": { - "notes": "Keymind Cascade MeshCore v1.17.1.1 with Halo/Keymind retry tuning and Cascade defaults.\n\nROLE \u2014 Repeater: always-on infrastructure that relays mesh traffic. Configure its name/location, radio settings, and admin password. A repeater does not need an OTA-capable build merely to relay OTA traffic for other nodes.\n\nPROFILE \u2014 Compact LoRa OTA: can install LoRa OTA and keeps core role features plus board-native display/GPS, but removes optional external environmental-sensor drivers to make update staging fit. USB debug/packet logging is OFF.\n\nINSTALL \u2014 First-time setup uses Full install (the merged bootloader + firmware image). Routine upgrades use Update (the app-only image) and normally preserve settings. Enable Erase all flash only when you intentionally want a clean reset.\n\nLORA OTA \u2014 This page installs the base firmware, not a .mota package. Obtain and apply exact board-and-role .mota updates separately through the MeshCore LoRa OTA workflow; .mota files cannot be web flashed here.\n\nSELECTION \u2014 LoRa OTA, no external sensors.", + "notes": "Keymind Cascade MeshCore v1.17.1.1 with Halo/Keymind retry tuning and Cascade defaults.\n\nROLE \u2014 Repeater: always-on infrastructure that relays mesh traffic. Configure its name/location, radio settings, and admin password. A repeater does not need an OTA-capable build merely to relay OTA traffic for other nodes.\n\nPROFILE - Compact LoRa OTA: can install LoRa OTA while retaining this exact target's board functions. To fit update staging, it omits selected optional environmental/ranging drivers; I2C remains available and is not globally disabled. USB debug/packet logging is OFF.\n\nINSTALL \u2014 First-time setup uses Full install (the merged bootloader + firmware image). Routine upgrades use Update (the app-only image) and normally preserve settings. Enable Erase all flash only when you intentionally want a clean reset.\n\nLORA OTA \u2014 This page installs the base firmware, not a .mota package. Obtain and apply exact board-and-role .mota updates separately through the MeshCore LoRa OTA workflow; .mota files cannot be web flashed here.\n\nSELECTION \u2014 LoRa OTA, selected optional sensor drivers omitted.", "files": [ { "type": "flash-wipe", @@ -9334,10 +9334,10 @@ { "role": "repeater", "title": "Repeater", - "subTitle": "LoRa OTA, no external sensors", + "subTitle": "LoRa OTA, selected optional sensor drivers omitted", "version": { "lora-ota-no-external-sensors-v1.17.1.1-759a35fc": { - "notes": "Keymind Cascade MeshCore v1.17.1.1 with Halo/Keymind retry tuning and Cascade defaults.\n\nROLE \u2014 Repeater: always-on infrastructure that relays mesh traffic. Configure its name/location, radio settings, and admin password. A repeater does not need an OTA-capable build merely to relay OTA traffic for other nodes.\n\nPROFILE \u2014 Compact LoRa OTA: can install LoRa OTA and keeps core role features plus board-native display/GPS, but removes optional external environmental-sensor drivers to make update staging fit. USB debug/packet logging is OFF.\n\nINSTALL \u2014 First-time setup uses Full install (the merged bootloader + firmware image). Routine upgrades use Update (the app-only image) and normally preserve settings. Enable Erase all flash only when you intentionally want a clean reset.\n\nLORA OTA \u2014 This page installs the base firmware, not a .mota package. Obtain and apply exact board-and-role .mota updates separately through the MeshCore LoRa OTA workflow; .mota files cannot be web flashed here.\n\nSELECTION \u2014 LoRa OTA, no external sensors.", + "notes": "Keymind Cascade MeshCore v1.17.1.1 with Halo/Keymind retry tuning and Cascade defaults.\n\nROLE \u2014 Repeater: always-on infrastructure that relays mesh traffic. Configure its name/location, radio settings, and admin password. A repeater does not need an OTA-capable build merely to relay OTA traffic for other nodes.\n\nPROFILE - Compact LoRa OTA: can install LoRa OTA while retaining this exact target's board functions. To fit update staging, it omits selected optional environmental/ranging drivers; I2C remains available and is not globally disabled. USB debug/packet logging is OFF.\n\nINSTALL \u2014 First-time setup uses Full install (the merged bootloader + firmware image). Routine upgrades use Update (the app-only image) and normally preserve settings. Enable Erase all flash only when you intentionally want a clean reset.\n\nLORA OTA \u2014 This page installs the base firmware, not a .mota package. Obtain and apply exact board-and-role .mota updates separately through the MeshCore LoRa OTA workflow; .mota files cannot be web flashed here.\n\nSELECTION \u2014 LoRa OTA, selected optional sensor drivers omitted.", "files": [ { "type": "flash-wipe", @@ -9529,10 +9529,10 @@ { "role": "repeater", "title": "Repeater", - "subTitle": "LoRa OTA, no external sensors", + "subTitle": "LoRa OTA, selected optional sensor drivers omitted", "version": { "lora-ota-no-external-sensors-v1.17.1.1-759a35fc": { - "notes": "Keymind Cascade MeshCore v1.17.1.1 with Halo/Keymind retry tuning and Cascade defaults.\n\nROLE \u2014 Repeater: always-on infrastructure that relays mesh traffic. Configure its name/location, radio settings, and admin password. A repeater does not need an OTA-capable build merely to relay OTA traffic for other nodes.\n\nPROFILE \u2014 Compact LoRa OTA: can install LoRa OTA and keeps core role features plus board-native display/GPS, but removes optional external environmental-sensor drivers to make update staging fit. USB debug/packet logging is OFF.\n\nINSTALL \u2014 First-time setup uses Full install (the merged bootloader + firmware image). Routine upgrades use Update (the app-only image) and normally preserve settings. Enable Erase all flash only when you intentionally want a clean reset.\n\nLORA OTA \u2014 This page installs the base firmware, not a .mota package. Obtain and apply exact board-and-role .mota updates separately through the MeshCore LoRa OTA workflow; .mota files cannot be web flashed here.\n\nSELECTION \u2014 LoRa OTA, no external sensors.", + "notes": "Keymind Cascade MeshCore v1.17.1.1 with Halo/Keymind retry tuning and Cascade defaults.\n\nROLE \u2014 Repeater: always-on infrastructure that relays mesh traffic. Configure its name/location, radio settings, and admin password. A repeater does not need an OTA-capable build merely to relay OTA traffic for other nodes.\n\nPROFILE - Compact LoRa OTA: can install LoRa OTA while retaining this exact target's board functions. To fit update staging, it omits selected optional environmental/ranging drivers; I2C remains available and is not globally disabled. USB debug/packet logging is OFF.\n\nINSTALL \u2014 First-time setup uses Full install (the merged bootloader + firmware image). Routine upgrades use Update (the app-only image) and normally preserve settings. Enable Erase all flash only when you intentionally want a clean reset.\n\nLORA OTA \u2014 This page installs the base firmware, not a .mota package. Obtain and apply exact board-and-role .mota updates separately through the MeshCore LoRa OTA workflow; .mota files cannot be web flashed here.\n\nSELECTION \u2014 LoRa OTA, selected optional sensor drivers omitted.", "files": [ { "type": "flash-wipe", @@ -9653,10 +9653,10 @@ { "role": "repeater", "title": "Repeater", - "subTitle": "LoRa OTA, no external sensors", + "subTitle": "LoRa OTA, selected optional sensor drivers omitted", "version": { "lora-ota-no-external-sensors-v1.17.1.1-759a35fc": { - "notes": "Keymind Cascade MeshCore v1.17.1.1 with Halo/Keymind retry tuning and Cascade defaults.\n\nROLE \u2014 Repeater: always-on infrastructure that relays mesh traffic. Configure its name/location, radio settings, and admin password. A repeater does not need an OTA-capable build merely to relay OTA traffic for other nodes.\n\nPROFILE \u2014 Compact LoRa OTA: can install LoRa OTA and keeps core role features plus board-native display/GPS, but removes optional external environmental-sensor drivers to make update staging fit. USB debug/packet logging is OFF.\n\nINSTALL \u2014 First-time setup uses Full install (the merged bootloader + firmware image). Routine upgrades use Update (the app-only image) and normally preserve settings. Enable Erase all flash only when you intentionally want a clean reset.\n\nLORA OTA \u2014 This page installs the base firmware, not a .mota package. Obtain and apply exact board-and-role .mota updates separately through the MeshCore LoRa OTA workflow; .mota files cannot be web flashed here.\n\nSELECTION \u2014 LoRa OTA, no external sensors.", + "notes": "Keymind Cascade MeshCore v1.17.1.1 with Halo/Keymind retry tuning and Cascade defaults.\n\nROLE \u2014 Repeater: always-on infrastructure that relays mesh traffic. Configure its name/location, radio settings, and admin password. A repeater does not need an OTA-capable build merely to relay OTA traffic for other nodes.\n\nPROFILE - Compact LoRa OTA: can install LoRa OTA while retaining this exact target's board functions. To fit update staging, it omits selected optional environmental/ranging drivers; I2C remains available and is not globally disabled. USB debug/packet logging is OFF.\n\nINSTALL \u2014 First-time setup uses Full install (the merged bootloader + firmware image). Routine upgrades use Update (the app-only image) and normally preserve settings. Enable Erase all flash only when you intentionally want a clean reset.\n\nLORA OTA \u2014 This page installs the base firmware, not a .mota package. Obtain and apply exact board-and-role .mota updates separately through the MeshCore LoRa OTA workflow; .mota files cannot be web flashed here.\n\nSELECTION \u2014 LoRa OTA, selected optional sensor drivers omitted.", "files": [ { "type": "flash-wipe", @@ -9822,10 +9822,10 @@ { "role": "repeater", "title": "Repeater - SX1262", - "subTitle": "LoRa OTA, no external sensors", + "subTitle": "LoRa OTA, selected optional sensor drivers omitted", "version": { "lora-ota-no-external-sensors-v1.17.1.1-759a35fc": { - "notes": "Keymind Cascade MeshCore v1.17.1.1 with Halo/Keymind retry tuning and Cascade defaults.\n\nROLE \u2014 Repeater: always-on infrastructure that relays mesh traffic. Configure its name/location, radio settings, and admin password. A repeater does not need an OTA-capable build merely to relay OTA traffic for other nodes.\n\nPROFILE \u2014 Compact LoRa OTA: can install LoRa OTA and keeps core role features plus board-native display/GPS, but removes optional external environmental-sensor drivers to make update staging fit. USB debug/packet logging is OFF.\n\nINSTALL \u2014 First-time setup uses Full install (the merged bootloader + firmware image). Routine upgrades use Update (the app-only image) and normally preserve settings. Enable Erase all flash only when you intentionally want a clean reset.\n\nLORA OTA \u2014 This page installs the base firmware, not a .mota package. Obtain and apply exact board-and-role .mota updates separately through the MeshCore LoRa OTA workflow; .mota files cannot be web flashed here.\n\nSELECTION \u2014 LoRa OTA, no external sensors.", + "notes": "Keymind Cascade MeshCore v1.17.1.1 with Halo/Keymind retry tuning and Cascade defaults.\n\nROLE \u2014 Repeater: always-on infrastructure that relays mesh traffic. Configure its name/location, radio settings, and admin password. A repeater does not need an OTA-capable build merely to relay OTA traffic for other nodes.\n\nPROFILE - Compact LoRa OTA: can install LoRa OTA while retaining this exact target's board functions. To fit update staging, it omits selected optional environmental/ranging drivers; I2C remains available and is not globally disabled. USB debug/packet logging is OFF.\n\nINSTALL \u2014 First-time setup uses Full install (the merged bootloader + firmware image). Routine upgrades use Update (the app-only image) and normally preserve settings. Enable Erase all flash only when you intentionally want a clean reset.\n\nLORA OTA \u2014 This page installs the base firmware, not a .mota package. Obtain and apply exact board-and-role .mota updates separately through the MeshCore LoRa OTA workflow; .mota files cannot be web flashed here.\n\nSELECTION \u2014 LoRa OTA, selected optional sensor drivers omitted.", "files": [ { "type": "flash-wipe", @@ -9869,10 +9869,10 @@ { "role": "repeater", "title": "Repeater - SX1268", - "subTitle": "LoRa OTA, no external sensors", + "subTitle": "LoRa OTA, selected optional sensor drivers omitted", "version": { "lora-ota-no-external-sensors-v1.17.1.1-759a35fc": { - "notes": "Keymind Cascade MeshCore v1.17.1.1 with Halo/Keymind retry tuning and Cascade defaults.\n\nROLE \u2014 Repeater: always-on infrastructure that relays mesh traffic. Configure its name/location, radio settings, and admin password. A repeater does not need an OTA-capable build merely to relay OTA traffic for other nodes.\n\nPROFILE \u2014 Compact LoRa OTA: can install LoRa OTA and keeps core role features plus board-native display/GPS, but removes optional external environmental-sensor drivers to make update staging fit. USB debug/packet logging is OFF.\n\nINSTALL \u2014 First-time setup uses Full install (the merged bootloader + firmware image). Routine upgrades use Update (the app-only image) and normally preserve settings. Enable Erase all flash only when you intentionally want a clean reset.\n\nLORA OTA \u2014 This page installs the base firmware, not a .mota package. Obtain and apply exact board-and-role .mota updates separately through the MeshCore LoRa OTA workflow; .mota files cannot be web flashed here.\n\nSELECTION \u2014 LoRa OTA, no external sensors.", + "notes": "Keymind Cascade MeshCore v1.17.1.1 with Halo/Keymind retry tuning and Cascade defaults.\n\nROLE \u2014 Repeater: always-on infrastructure that relays mesh traffic. Configure its name/location, radio settings, and admin password. A repeater does not need an OTA-capable build merely to relay OTA traffic for other nodes.\n\nPROFILE - Compact LoRa OTA: can install LoRa OTA while retaining this exact target's board functions. To fit update staging, it omits selected optional environmental/ranging drivers; I2C remains available and is not globally disabled. USB debug/packet logging is OFF.\n\nINSTALL \u2014 First-time setup uses Full install (the merged bootloader + firmware image). Routine upgrades use Update (the app-only image) and normally preserve settings. Enable Erase all flash only when you intentionally want a clean reset.\n\nLORA OTA \u2014 This page installs the base firmware, not a .mota package. Obtain and apply exact board-and-role .mota updates separately through the MeshCore LoRa OTA workflow; .mota files cannot be web flashed here.\n\nSELECTION \u2014 LoRa OTA, selected optional sensor drivers omitted.", "files": [ { "type": "flash-wipe", @@ -10038,10 +10038,10 @@ { "role": "repeater", "title": "Repeater", - "subTitle": "LoRa OTA, no external sensors", + "subTitle": "LoRa OTA, selected optional sensor drivers omitted", "version": { "lora-ota-no-external-sensors-v1.17.1.1-759a35fc": { - "notes": "Keymind Cascade MeshCore v1.17.1.1 with Halo/Keymind retry tuning and Cascade defaults.\n\nROLE \u2014 Repeater: always-on infrastructure that relays mesh traffic. Configure its name/location, radio settings, and admin password. A repeater does not need an OTA-capable build merely to relay OTA traffic for other nodes.\n\nPROFILE \u2014 Compact LoRa OTA: can install LoRa OTA and keeps core role features plus board-native display/GPS, but removes optional external environmental-sensor drivers to make update staging fit. USB debug/packet logging is OFF.\n\nINSTALL \u2014 First-time setup uses Full install (the merged bootloader + firmware image). Routine upgrades use Update (the app-only image) and normally preserve settings. Enable Erase all flash only when you intentionally want a clean reset.\n\nLORA OTA \u2014 This page installs the base firmware, not a .mota package. Obtain and apply exact board-and-role .mota updates separately through the MeshCore LoRa OTA workflow; .mota files cannot be web flashed here.\n\nSELECTION \u2014 LoRa OTA, no external sensors.", + "notes": "Keymind Cascade MeshCore v1.17.1.1 with Halo/Keymind retry tuning and Cascade defaults.\n\nROLE \u2014 Repeater: always-on infrastructure that relays mesh traffic. Configure its name/location, radio settings, and admin password. A repeater does not need an OTA-capable build merely to relay OTA traffic for other nodes.\n\nPROFILE - Compact LoRa OTA: can install LoRa OTA while retaining this exact target's board functions. To fit update staging, it omits selected optional environmental/ranging drivers; I2C remains available and is not globally disabled. USB debug/packet logging is OFF.\n\nINSTALL \u2014 First-time setup uses Full install (the merged bootloader + firmware image). Routine upgrades use Update (the app-only image) and normally preserve settings. Enable Erase all flash only when you intentionally want a clean reset.\n\nLORA OTA \u2014 This page installs the base firmware, not a .mota package. Obtain and apply exact board-and-role .mota updates separately through the MeshCore LoRa OTA workflow; .mota files cannot be web flashed here.\n\nSELECTION \u2014 LoRa OTA, selected optional sensor drivers omitted.", "files": [ { "type": "flash-wipe", @@ -10145,10 +10145,10 @@ { "role": "repeater", "title": "Repeater", - "subTitle": "LoRa OTA, no external sensors", + "subTitle": "LoRa OTA, selected optional sensor drivers omitted", "version": { "lora-ota-no-external-sensors-v1.17.1.1-759a35fc": { - "notes": "Keymind Cascade MeshCore v1.17.1.1 with Halo/Keymind retry tuning and Cascade defaults.\n\nROLE \u2014 Repeater: always-on infrastructure that relays mesh traffic. Configure its name/location, radio settings, and admin password. A repeater does not need an OTA-capable build merely to relay OTA traffic for other nodes.\n\nPROFILE \u2014 Compact LoRa OTA: can install LoRa OTA and keeps core role features plus board-native display/GPS, but removes optional external environmental-sensor drivers to make update staging fit. USB debug/packet logging is OFF.\n\nINSTALL \u2014 Browser flashing uses the Serial DFU .zip. The .uf2 is provided for manual drag-and-drop through a compatible UF2 bootloader volume. A normal firmware flash does not erase saved settings.\n\nLORA OTA \u2014 This page installs the base firmware, not a .mota package. Obtain and apply exact board-and-role .mota updates separately through the MeshCore LoRa OTA workflow; .mota files cannot be web flashed here.\n\nBOOTLOADER \u2014 nRF52 LoRa OTA requires the exact-board OTAFIX 2.4.1 Preview 6 bootloader, and \u201cota self\u201d must report \u201cbootloader: apply OK.\u201d Never substitute another board\u2019s bootloader. https://github.com/mikecarper/Adafruit_nRF52_Bootloader_OTAFIX/releases/tag/0.9.2-OTAFIX2.4.1-preview.6\n\nSELECTION \u2014 LoRa OTA, no external sensors.", + "notes": "Keymind Cascade MeshCore v1.17.1.1 with Halo/Keymind retry tuning and Cascade defaults.\n\nROLE \u2014 Repeater: always-on infrastructure that relays mesh traffic. Configure its name/location, radio settings, and admin password. A repeater does not need an OTA-capable build merely to relay OTA traffic for other nodes.\n\nPROFILE - Compact LoRa OTA: can install LoRa OTA while retaining this exact target's board functions. To fit update staging, it omits selected optional environmental/ranging drivers; I2C remains available and is not globally disabled. USB debug/packet logging is OFF.\n\nINSTALL \u2014 Browser flashing uses the Serial DFU .zip. The .uf2 is provided for manual drag-and-drop through a compatible UF2 bootloader volume. A normal firmware flash does not erase saved settings.\n\nLORA OTA \u2014 This page installs the base firmware, not a .mota package. Obtain and apply exact board-and-role .mota updates separately through the MeshCore LoRa OTA workflow; .mota files cannot be web flashed here.\n\nBOOTLOADER \u2014 nRF52 LoRa OTA requires the exact-board OTAFIX 2.4.1 Preview 6 bootloader, and \u201cota self\u201d must report \u201cbootloader: apply OK.\u201d Never substitute another board\u2019s bootloader. https://github.com/mikecarper/Adafruit_nRF52_Bootloader_OTAFIX/releases/tag/0.9.2-OTAFIX2.4.1-preview.6\n\nSELECTION \u2014 LoRa OTA, selected optional sensor drivers omitted.", "files": [ { "type": "flash", @@ -10245,10 +10245,10 @@ { "role": "repeater", "title": "Repeater", - "subTitle": "LoRa OTA, no external sensors", + "subTitle": "LoRa OTA, selected optional sensor drivers omitted", "version": { "lora-ota-no-external-sensors-v1.17.1.1-759a35fc": { - "notes": "Keymind Cascade MeshCore v1.17.1.1 with Halo/Keymind retry tuning and Cascade defaults.\n\nROLE \u2014 Repeater: always-on infrastructure that relays mesh traffic. Configure its name/location, radio settings, and admin password. A repeater does not need an OTA-capable build merely to relay OTA traffic for other nodes.\n\nPROFILE \u2014 Compact LoRa OTA: can install LoRa OTA and keeps core role features plus board-native display/GPS, but removes optional external environmental-sensor drivers to make update staging fit. USB debug/packet logging is OFF.\n\nINSTALL \u2014 Browser flashing uses the Serial DFU .zip. The .uf2 is provided for manual drag-and-drop through a compatible UF2 bootloader volume. A normal firmware flash does not erase saved settings.\n\nLORA OTA \u2014 This page installs the base firmware, not a .mota package. Obtain and apply exact board-and-role .mota updates separately through the MeshCore LoRa OTA workflow; .mota files cannot be web flashed here.\n\nBOOTLOADER \u2014 nRF52 LoRa OTA requires the exact-board OTAFIX 2.4.1 Preview 6 bootloader, and \u201cota self\u201d must report \u201cbootloader: apply OK.\u201d Never substitute another board\u2019s bootloader. https://github.com/mikecarper/Adafruit_nRF52_Bootloader_OTAFIX/releases/tag/0.9.2-OTAFIX2.4.1-preview.6\n\nSELECTION \u2014 LoRa OTA, no external sensors.", + "notes": "Keymind Cascade MeshCore v1.17.1.1 with Halo/Keymind retry tuning and Cascade defaults.\n\nROLE \u2014 Repeater: always-on infrastructure that relays mesh traffic. Configure its name/location, radio settings, and admin password. A repeater does not need an OTA-capable build merely to relay OTA traffic for other nodes.\n\nPROFILE - Compact LoRa OTA: can install LoRa OTA while retaining this exact target's board functions. To fit update staging, it omits selected optional environmental/ranging drivers; I2C remains available and is not globally disabled. USB debug/packet logging is OFF.\n\nINSTALL \u2014 Browser flashing uses the Serial DFU .zip. The .uf2 is provided for manual drag-and-drop through a compatible UF2 bootloader volume. A normal firmware flash does not erase saved settings.\n\nLORA OTA \u2014 This page installs the base firmware, not a .mota package. Obtain and apply exact board-and-role .mota updates separately through the MeshCore LoRa OTA workflow; .mota files cannot be web flashed here.\n\nBOOTLOADER \u2014 nRF52 LoRa OTA requires the exact-board OTAFIX 2.4.1 Preview 6 bootloader, and \u201cota self\u201d must report \u201cbootloader: apply OK.\u201d Never substitute another board\u2019s bootloader. https://github.com/mikecarper/Adafruit_nRF52_Bootloader_OTAFIX/releases/tag/0.9.2-OTAFIX2.4.1-preview.6\n\nSELECTION \u2014 LoRa OTA, selected optional sensor drivers omitted.", "files": [ { "type": "flash", @@ -10462,10 +10462,10 @@ { "role": "repeater", "title": "Repeater", - "subTitle": "LoRa OTA, no external sensors", + "subTitle": "LoRa OTA, selected optional sensor drivers omitted", "version": { "lora-ota-no-external-sensors-v1.17.1.1-759a35fc": { - "notes": "Keymind Cascade MeshCore v1.17.1.1 with Halo/Keymind retry tuning and Cascade defaults.\n\nROLE \u2014 Repeater: always-on infrastructure that relays mesh traffic. Configure its name/location, radio settings, and admin password. A repeater does not need an OTA-capable build merely to relay OTA traffic for other nodes.\n\nPROFILE \u2014 Compact LoRa OTA: can install LoRa OTA and keeps core role features plus board-native display/GPS, but removes optional external environmental-sensor drivers to make update staging fit. USB debug/packet logging is OFF.\n\nINSTALL \u2014 First-time setup uses Full install (the merged bootloader + firmware image). Routine upgrades use Update (the app-only image) and normally preserve settings. Enable Erase all flash only when you intentionally want a clean reset.\n\nLORA OTA \u2014 This page installs the base firmware, not a .mota package. Obtain and apply exact board-and-role .mota updates separately through the MeshCore LoRa OTA workflow; .mota files cannot be web flashed here.\n\nSELECTION \u2014 LoRa OTA, no external sensors.", + "notes": "Keymind Cascade MeshCore v1.17.1.1 with Halo/Keymind retry tuning and Cascade defaults.\n\nROLE \u2014 Repeater: always-on infrastructure that relays mesh traffic. Configure its name/location, radio settings, and admin password. A repeater does not need an OTA-capable build merely to relay OTA traffic for other nodes.\n\nPROFILE - Compact LoRa OTA: can install LoRa OTA while retaining this exact target's board functions. To fit update staging, it omits selected optional environmental/ranging drivers; I2C remains available and is not globally disabled. USB debug/packet logging is OFF.\n\nINSTALL \u2014 First-time setup uses Full install (the merged bootloader + firmware image). Routine upgrades use Update (the app-only image) and normally preserve settings. Enable Erase all flash only when you intentionally want a clean reset.\n\nLORA OTA \u2014 This page installs the base firmware, not a .mota package. Obtain and apply exact board-and-role .mota updates separately through the MeshCore LoRa OTA workflow; .mota files cannot be web flashed here.\n\nSELECTION \u2014 LoRa OTA, selected optional sensor drivers omitted.", "files": [ { "type": "flash-wipe", @@ -10656,10 +10656,10 @@ { "role": "repeater", "title": "Repeater", - "subTitle": "LoRa OTA, no external sensors", + "subTitle": "LoRa OTA, selected optional sensor drivers omitted", "version": { "lora-ota-no-external-sensors-v1.17.1.1-759a35fc": { - "notes": "Keymind Cascade MeshCore v1.17.1.1 with Halo/Keymind retry tuning and Cascade defaults.\n\nROLE \u2014 Repeater: always-on infrastructure that relays mesh traffic. Configure its name/location, radio settings, and admin password. A repeater does not need an OTA-capable build merely to relay OTA traffic for other nodes.\n\nPROFILE \u2014 Compact LoRa OTA: can install LoRa OTA and keeps core role features plus board-native display/GPS, but removes optional external environmental-sensor drivers to make update staging fit. USB debug/packet logging is OFF.\n\nINSTALL \u2014 First-time setup uses Full install (the merged bootloader + firmware image). Routine upgrades use Update (the app-only image) and normally preserve settings. Enable Erase all flash only when you intentionally want a clean reset.\n\nLORA OTA \u2014 This page installs the base firmware, not a .mota package. Obtain and apply exact board-and-role .mota updates separately through the MeshCore LoRa OTA workflow; .mota files cannot be web flashed here.\n\nSELECTION \u2014 LoRa OTA, no external sensors.", + "notes": "Keymind Cascade MeshCore v1.17.1.1 with Halo/Keymind retry tuning and Cascade defaults.\n\nROLE \u2014 Repeater: always-on infrastructure that relays mesh traffic. Configure its name/location, radio settings, and admin password. A repeater does not need an OTA-capable build merely to relay OTA traffic for other nodes.\n\nPROFILE - Compact LoRa OTA: can install LoRa OTA while retaining this exact target's board functions. To fit update staging, it omits selected optional environmental/ranging drivers; I2C remains available and is not globally disabled. USB debug/packet logging is OFF.\n\nINSTALL \u2014 First-time setup uses Full install (the merged bootloader + firmware image). Routine upgrades use Update (the app-only image) and normally preserve settings. Enable Erase all flash only when you intentionally want a clean reset.\n\nLORA OTA \u2014 This page installs the base firmware, not a .mota package. Obtain and apply exact board-and-role .mota updates separately through the MeshCore LoRa OTA workflow; .mota files cannot be web flashed here.\n\nSELECTION \u2014 LoRa OTA, selected optional sensor drivers omitted.", "files": [ { "type": "flash-wipe", @@ -10780,10 +10780,10 @@ { "role": "repeater", "title": "Repeater", - "subTitle": "LoRa OTA, no external sensors", + "subTitle": "LoRa OTA, selected optional sensor drivers omitted", "version": { "lora-ota-no-external-sensors-v1.17.1.1-759a35fc": { - "notes": "Keymind Cascade MeshCore v1.17.1.1 with Halo/Keymind retry tuning and Cascade defaults.\n\nROLE \u2014 Repeater: always-on infrastructure that relays mesh traffic. Configure its name/location, radio settings, and admin password. A repeater does not need an OTA-capable build merely to relay OTA traffic for other nodes.\n\nPROFILE \u2014 Compact LoRa OTA: can install LoRa OTA and keeps core role features plus board-native display/GPS, but removes optional external environmental-sensor drivers to make update staging fit. USB debug/packet logging is OFF.\n\nINSTALL \u2014 Browser flashing uses the Serial DFU .zip. The .uf2 is provided for manual drag-and-drop through a compatible UF2 bootloader volume. A normal firmware flash does not erase saved settings.\n\nLORA OTA \u2014 This page installs the base firmware, not a .mota package. Obtain and apply exact board-and-role .mota updates separately through the MeshCore LoRa OTA workflow; .mota files cannot be web flashed here.\n\nBOOTLOADER \u2014 nRF52 LoRa OTA requires the exact-board OTAFIX 2.4.1 Preview 6 bootloader, and \u201cota self\u201d must report \u201cbootloader: apply OK.\u201d Never substitute another board\u2019s bootloader. https://github.com/mikecarper/Adafruit_nRF52_Bootloader_OTAFIX/releases/tag/0.9.2-OTAFIX2.4.1-preview.6\n\nSELECTION \u2014 LoRa OTA, no external sensors.", + "notes": "Keymind Cascade MeshCore v1.17.1.1 with Halo/Keymind retry tuning and Cascade defaults.\n\nROLE \u2014 Repeater: always-on infrastructure that relays mesh traffic. Configure its name/location, radio settings, and admin password. A repeater does not need an OTA-capable build merely to relay OTA traffic for other nodes.\n\nPROFILE - Compact LoRa OTA: can install LoRa OTA while retaining this exact target's board functions. To fit update staging, it omits selected optional environmental/ranging drivers; I2C remains available and is not globally disabled. USB debug/packet logging is OFF.\n\nINSTALL \u2014 Browser flashing uses the Serial DFU .zip. The .uf2 is provided for manual drag-and-drop through a compatible UF2 bootloader volume. A normal firmware flash does not erase saved settings.\n\nLORA OTA \u2014 This page installs the base firmware, not a .mota package. Obtain and apply exact board-and-role .mota updates separately through the MeshCore LoRa OTA workflow; .mota files cannot be web flashed here.\n\nBOOTLOADER \u2014 nRF52 LoRa OTA requires the exact-board OTAFIX 2.4.1 Preview 6 bootloader, and \u201cota self\u201d must report \u201cbootloader: apply OK.\u201d Never substitute another board\u2019s bootloader. https://github.com/mikecarper/Adafruit_nRF52_Bootloader_OTAFIX/releases/tag/0.9.2-OTAFIX2.4.1-preview.6\n\nSELECTION \u2014 LoRa OTA, selected optional sensor drivers omitted.", "files": [ { "type": "flash", @@ -11126,10 +11126,10 @@ { "role": "repeater", "title": "Repeater Bridge RS232", - "subTitle": "Serial 1, LoRa OTA, no external sensors", + "subTitle": "Serial 1, LoRa OTA, selected optional sensor drivers omitted", "version": { "serial-1-lora-ota-no-external-sensors-v1.17.1.1-759a35fc": { - "notes": "Keymind Cascade MeshCore v1.17.1.1 with Halo/Keymind retry tuning and Cascade defaults.\n\nROLE \u2014 RS232 Repeater Bridge: relays normal MeshCore traffic and also bridges packets over a hardware UART. It requires the target\u2019s documented RX/TX pins, correct voltage levels, and matching serial configuration; choose plain Repeater otherwise.\n\nPROFILE \u2014 Compact LoRa OTA: can install LoRa OTA and keeps core role features plus board-native display/GPS, but removes optional external environmental-sensor drivers to make update staging fit. USB debug/packet logging is OFF.\n\nINSTALL \u2014 Browser flashing uses the Serial DFU .zip. The .uf2 is provided for manual drag-and-drop through a compatible UF2 bootloader volume. A normal firmware flash does not erase saved settings.\n\nLORA OTA \u2014 This page installs the base firmware, not a .mota package. Obtain and apply exact board-and-role .mota updates separately through the MeshCore LoRa OTA workflow; .mota files cannot be web flashed here.\n\nBOOTLOADER \u2014 nRF52 LoRa OTA requires the exact-board OTAFIX 2.4.1 Preview 6 bootloader, and \u201cota self\u201d must report \u201cbootloader: apply OK.\u201d Never substitute another board\u2019s bootloader. https://github.com/mikecarper/Adafruit_nRF52_Bootloader_OTAFIX/releases/tag/0.9.2-OTAFIX2.4.1-preview.6\n\nSELECTION \u2014 Serial 1, LoRa OTA, no external sensors.\n\nHARDWARE \u2014 Serial 1/2 selects a specific hardware UART and connector/pin set; use the one matching your wiring.", + "notes": "Keymind Cascade MeshCore v1.17.1.1 with Halo/Keymind retry tuning and Cascade defaults.\n\nROLE \u2014 RS232 Repeater Bridge: relays normal MeshCore traffic and also bridges packets over a hardware UART. It requires the target\u2019s documented RX/TX pins, correct voltage levels, and matching serial configuration; choose plain Repeater otherwise.\n\nPROFILE - Compact LoRa OTA: can install LoRa OTA while retaining this exact target's board functions. To fit update staging, it omits selected optional environmental/ranging drivers; I2C remains available and is not globally disabled. USB debug/packet logging is OFF.\n\nRAK SENSOR RETENTION - This reduced RAK build retains INA219, INA226, INA260, and INA3221 I2C current/voltage monitor drivers.\n\nGPS / RS-232 - GPS is omitted from this RAK4631 build because the RS-232 bridge owns Serial1.\n\nINSTALL \u2014 Browser flashing uses the Serial DFU .zip. The .uf2 is provided for manual drag-and-drop through a compatible UF2 bootloader volume. A normal firmware flash does not erase saved settings.\n\nLORA OTA \u2014 This page installs the base firmware, not a .mota package. Obtain and apply exact board-and-role .mota updates separately through the MeshCore LoRa OTA workflow; .mota files cannot be web flashed here.\n\nBOOTLOADER \u2014 nRF52 LoRa OTA requires the exact-board OTAFIX 2.4.1 Preview 6 bootloader, and \u201cota self\u201d must report \u201cbootloader: apply OK.\u201d Never substitute another board\u2019s bootloader. https://github.com/mikecarper/Adafruit_nRF52_Bootloader_OTAFIX/releases/tag/0.9.2-OTAFIX2.4.1-preview.6\n\nSELECTION \u2014 Serial 1, LoRa OTA, selected optional sensor drivers omitted.\n\nHARDWARE \u2014 Serial 1/2 selects a specific hardware UART and connector/pin set; use the one matching your wiring.", "files": [ { "type": "flash", @@ -11174,10 +11174,10 @@ { "role": "repeater", "title": "Repeater Bridge RS232", - "subTitle": "Serial 2, LoRa OTA, no external sensors", + "subTitle": "Serial 2, LoRa OTA, selected optional sensor drivers omitted", "version": { "serial-2-lora-ota-no-external-sensors-v1.17.1.1-759a35fc": { - "notes": "Keymind Cascade MeshCore v1.17.1.1 with Halo/Keymind retry tuning and Cascade defaults.\n\nROLE \u2014 RS232 Repeater Bridge: relays normal MeshCore traffic and also bridges packets over a hardware UART. It requires the target\u2019s documented RX/TX pins, correct voltage levels, and matching serial configuration; choose plain Repeater otherwise.\n\nPROFILE \u2014 Compact LoRa OTA: can install LoRa OTA and keeps core role features plus board-native display/GPS, but removes optional external environmental-sensor drivers to make update staging fit. USB debug/packet logging is OFF.\n\nINSTALL \u2014 Browser flashing uses the Serial DFU .zip. The .uf2 is provided for manual drag-and-drop through a compatible UF2 bootloader volume. A normal firmware flash does not erase saved settings.\n\nLORA OTA \u2014 This page installs the base firmware, not a .mota package. Obtain and apply exact board-and-role .mota updates separately through the MeshCore LoRa OTA workflow; .mota files cannot be web flashed here.\n\nBOOTLOADER \u2014 nRF52 LoRa OTA requires the exact-board OTAFIX 2.4.1 Preview 6 bootloader, and \u201cota self\u201d must report \u201cbootloader: apply OK.\u201d Never substitute another board\u2019s bootloader. https://github.com/mikecarper/Adafruit_nRF52_Bootloader_OTAFIX/releases/tag/0.9.2-OTAFIX2.4.1-preview.6\n\nSELECTION \u2014 Serial 2, LoRa OTA, no external sensors.\n\nHARDWARE \u2014 Serial 1/2 selects a specific hardware UART and connector/pin set; use the one matching your wiring.", + "notes": "Keymind Cascade MeshCore v1.17.1.1 with Halo/Keymind retry tuning and Cascade defaults.\n\nROLE \u2014 RS232 Repeater Bridge: relays normal MeshCore traffic and also bridges packets over a hardware UART. It requires the target\u2019s documented RX/TX pins, correct voltage levels, and matching serial configuration; choose plain Repeater otherwise.\n\nPROFILE - Compact LoRa OTA: can install LoRa OTA while retaining this exact target's board functions. To fit update staging, it omits selected optional environmental/ranging drivers; I2C remains available and is not globally disabled. USB debug/packet logging is OFF.\n\nRAK SENSOR RETENTION - This reduced RAK build retains INA219, INA226, INA260, and INA3221 I2C current/voltage monitor drivers.\n\nGPS RETENTION - Compatible RAK4631 GPS options remain available: RAK12500 over I2C and RAK12501 over Serial1.\n\nINSTALL \u2014 Browser flashing uses the Serial DFU .zip. The .uf2 is provided for manual drag-and-drop through a compatible UF2 bootloader volume. A normal firmware flash does not erase saved settings.\n\nLORA OTA \u2014 This page installs the base firmware, not a .mota package. Obtain and apply exact board-and-role .mota updates separately through the MeshCore LoRa OTA workflow; .mota files cannot be web flashed here.\n\nBOOTLOADER \u2014 nRF52 LoRa OTA requires the exact-board OTAFIX 2.4.1 Preview 6 bootloader, and \u201cota self\u201d must report \u201cbootloader: apply OK.\u201d Never substitute another board\u2019s bootloader. https://github.com/mikecarper/Adafruit_nRF52_Bootloader_OTAFIX/releases/tag/0.9.2-OTAFIX2.4.1-preview.6\n\nSELECTION \u2014 Serial 2, LoRa OTA, selected optional sensor drivers omitted.\n\nHARDWARE \u2014 Serial 1/2 selects a specific hardware UART and connector/pin set; use the one matching your wiring.", "files": [ { "type": "flash", @@ -11222,10 +11222,10 @@ { "role": "repeater", "title": "Repeater", - "subTitle": "LoRa OTA, no external sensors", + "subTitle": "LoRa OTA, selected optional sensor drivers omitted", "version": { "lora-ota-no-external-sensors-v1.17.1.1-759a35fc": { - "notes": "Keymind Cascade MeshCore v1.17.1.1 with Halo/Keymind retry tuning and Cascade defaults.\n\nROLE \u2014 Repeater: always-on infrastructure that relays mesh traffic. Configure its name/location, radio settings, and admin password. A repeater does not need an OTA-capable build merely to relay OTA traffic for other nodes.\n\nPROFILE \u2014 Compact LoRa OTA: can install LoRa OTA and keeps core role features plus board-native display/GPS, but removes optional external environmental-sensor drivers to make update staging fit. USB debug/packet logging is OFF.\n\nINSTALL \u2014 Browser flashing uses the Serial DFU .zip. The .uf2 is provided for manual drag-and-drop through a compatible UF2 bootloader volume. A normal firmware flash does not erase saved settings.\n\nLORA OTA \u2014 This page installs the base firmware, not a .mota package. Obtain and apply exact board-and-role .mota updates separately through the MeshCore LoRa OTA workflow; .mota files cannot be web flashed here.\n\nBOOTLOADER \u2014 nRF52 LoRa OTA requires the exact-board OTAFIX 2.4.1 Preview 6 bootloader, and \u201cota self\u201d must report \u201cbootloader: apply OK.\u201d Never substitute another board\u2019s bootloader. https://github.com/mikecarper/Adafruit_nRF52_Bootloader_OTAFIX/releases/tag/0.9.2-OTAFIX2.4.1-preview.6\n\nSELECTION \u2014 LoRa OTA, no external sensors.", + "notes": "Keymind Cascade MeshCore v1.17.1.1 with Halo/Keymind retry tuning and Cascade defaults.\n\nROLE \u2014 Repeater: always-on infrastructure that relays mesh traffic. Configure its name/location, radio settings, and admin password. A repeater does not need an OTA-capable build merely to relay OTA traffic for other nodes.\n\nPROFILE - Compact LoRa OTA: can install LoRa OTA while retaining this exact target's board functions. To fit update staging, it omits selected optional environmental/ranging drivers; I2C remains available and is not globally disabled. USB debug/packet logging is OFF.\n\nRAK SENSOR RETENTION - This reduced RAK build retains INA219, INA226, INA260, and INA3221 I2C current/voltage monitor drivers.\n\nGPS RETENTION - Compatible RAK4631 GPS options remain available: RAK12500 over I2C and RAK12501 over Serial1.\n\nINSTALL \u2014 Browser flashing uses the Serial DFU .zip. The .uf2 is provided for manual drag-and-drop through a compatible UF2 bootloader volume. A normal firmware flash does not erase saved settings.\n\nLORA OTA \u2014 This page installs the base firmware, not a .mota package. Obtain and apply exact board-and-role .mota updates separately through the MeshCore LoRa OTA workflow; .mota files cannot be web flashed here.\n\nBOOTLOADER \u2014 nRF52 LoRa OTA requires the exact-board OTAFIX 2.4.1 Preview 6 bootloader, and \u201cota self\u201d must report \u201cbootloader: apply OK.\u201d Never substitute another board\u2019s bootloader. https://github.com/mikecarper/Adafruit_nRF52_Bootloader_OTAFIX/releases/tag/0.9.2-OTAFIX2.4.1-preview.6\n\nSELECTION \u2014 LoRa OTA, selected optional sensor drivers omitted.", "files": [ { "type": "flash", @@ -11486,10 +11486,10 @@ { "role": "repeater", "title": "Repeater", - "subTitle": "LoRa OTA, no external sensors", + "subTitle": "LoRa OTA, selected optional sensor drivers omitted", "version": { "lora-ota-no-external-sensors-v1.17.1.1-759a35fc": { - "notes": "Keymind Cascade MeshCore v1.17.1.1 with Halo/Keymind retry tuning and Cascade defaults.\n\nROLE \u2014 Repeater: always-on infrastructure that relays mesh traffic. Configure its name/location, radio settings, and admin password. A repeater does not need an OTA-capable build merely to relay OTA traffic for other nodes.\n\nPROFILE \u2014 Compact LoRa OTA: can install LoRa OTA and keeps core role features plus board-native display/GPS, but removes optional external environmental-sensor drivers to make update staging fit. USB debug/packet logging is OFF.\n\nINSTALL \u2014 First-time setup uses Full install (the merged bootloader + firmware image). Routine upgrades use Update (the app-only image) and normally preserve settings. Enable Erase all flash only when you intentionally want a clean reset.\n\nLORA OTA \u2014 This page installs the base firmware, not a .mota package. Obtain and apply exact board-and-role .mota updates separately through the MeshCore LoRa OTA workflow; .mota files cannot be web flashed here.\n\nSELECTION \u2014 LoRa OTA, no external sensors.", + "notes": "Keymind Cascade MeshCore v1.17.1.1 with Halo/Keymind retry tuning and Cascade defaults.\n\nROLE \u2014 Repeater: always-on infrastructure that relays mesh traffic. Configure its name/location, radio settings, and admin password. A repeater does not need an OTA-capable build merely to relay OTA traffic for other nodes.\n\nPROFILE - Compact LoRa OTA: can install LoRa OTA while retaining this exact target's board functions. To fit update staging, it omits selected optional environmental/ranging drivers; I2C remains available and is not globally disabled. USB debug/packet logging is OFF.\n\nINSTALL \u2014 First-time setup uses Full install (the merged bootloader + firmware image). Routine upgrades use Update (the app-only image) and normally preserve settings. Enable Erase all flash only when you intentionally want a clean reset.\n\nLORA OTA \u2014 This page installs the base firmware, not a .mota package. Obtain and apply exact board-and-role .mota updates separately through the MeshCore LoRa OTA workflow; .mota files cannot be web flashed here.\n\nSELECTION \u2014 LoRa OTA, selected optional sensor drivers omitted.", "files": [ { "type": "flash-wipe", @@ -11657,10 +11657,10 @@ { "role": "repeater", "title": "Repeater", - "subTitle": "LoRa OTA, no external sensors", + "subTitle": "LoRa OTA, selected optional sensor drivers omitted", "version": { "lora-ota-no-external-sensors-v1.17.1.1-759a35fc": { - "notes": "Keymind Cascade MeshCore v1.17.1.1 with Halo/Keymind retry tuning and Cascade defaults.\n\nROLE \u2014 Repeater: always-on infrastructure that relays mesh traffic. Configure its name/location, radio settings, and admin password. A repeater does not need an OTA-capable build merely to relay OTA traffic for other nodes.\n\nPROFILE \u2014 Compact LoRa OTA: can install LoRa OTA and keeps core role features plus board-native display/GPS, but removes optional external environmental-sensor drivers to make update staging fit. USB debug/packet logging is OFF.\n\nINSTALL \u2014 Browser flashing uses the Serial DFU .zip. The .uf2 is provided for manual drag-and-drop through a compatible UF2 bootloader volume. A normal firmware flash does not erase saved settings.\n\nLORA OTA \u2014 This page installs the base firmware, not a .mota package. Obtain and apply exact board-and-role .mota updates separately through the MeshCore LoRa OTA workflow; .mota files cannot be web flashed here.\n\nBOOTLOADER \u2014 nRF52 LoRa OTA requires the exact-board OTAFIX 2.4.1 Preview 6 bootloader, and \u201cota self\u201d must report \u201cbootloader: apply OK.\u201d Never substitute another board\u2019s bootloader. https://github.com/mikecarper/Adafruit_nRF52_Bootloader_OTAFIX/releases/tag/0.9.2-OTAFIX2.4.1-preview.6\n\nSELECTION \u2014 LoRa OTA, no external sensors.", + "notes": "Keymind Cascade MeshCore v1.17.1.1 with Halo/Keymind retry tuning and Cascade defaults.\n\nROLE \u2014 Repeater: always-on infrastructure that relays mesh traffic. Configure its name/location, radio settings, and admin password. A repeater does not need an OTA-capable build merely to relay OTA traffic for other nodes.\n\nPROFILE - Compact LoRa OTA: can install LoRa OTA while retaining this exact target's board functions. To fit update staging, it omits selected optional environmental/ranging drivers; I2C remains available and is not globally disabled. USB debug/packet logging is OFF.\n\nRAK SENSOR RETENTION - This reduced RAK build retains INA219, INA226, INA260, and INA3221 I2C current/voltage monitor drivers.\n\nGPS RETENTION - Compatible RAK3401 GPS options remain available: RAK12500 over I2C and RAK12501 over UART.\n\nINSTALL \u2014 Browser flashing uses the Serial DFU .zip. The .uf2 is provided for manual drag-and-drop through a compatible UF2 bootloader volume. A normal firmware flash does not erase saved settings.\n\nLORA OTA \u2014 This page installs the base firmware, not a .mota package. Obtain and apply exact board-and-role .mota updates separately through the MeshCore LoRa OTA workflow; .mota files cannot be web flashed here.\n\nBOOTLOADER \u2014 nRF52 LoRa OTA requires the exact-board OTAFIX 2.4.1 Preview 6 bootloader, and \u201cota self\u201d must report \u201cbootloader: apply OK.\u201d Never substitute another board\u2019s bootloader. https://github.com/mikecarper/Adafruit_nRF52_Bootloader_OTAFIX/releases/tag/0.9.2-OTAFIX2.4.1-preview.6\n\nSELECTION \u2014 LoRa OTA, selected optional sensor drivers omitted.", "files": [ { "type": "flash", @@ -11782,10 +11782,10 @@ { "role": "repeater", "title": "Repeater", - "subTitle": "WisMesh Tag, LoRa OTA, no external sensors", + "subTitle": "WisMesh Tag, LoRa OTA, selected optional sensor drivers omitted", "version": { "wismesh-tag-lora-ota-no-external-sensors-v1.17.1.1-759a35fc": { - "notes": "Keymind Cascade MeshCore v1.17.1.1 with Halo/Keymind retry tuning and Cascade defaults.\n\nROLE \u2014 Repeater: always-on infrastructure that relays mesh traffic. Configure its name/location, radio settings, and admin password. A repeater does not need an OTA-capable build merely to relay OTA traffic for other nodes.\n\nPROFILE \u2014 Compact LoRa OTA: can install LoRa OTA and keeps core role features plus board-native display/GPS, but removes optional external environmental-sensor drivers to make update staging fit. USB debug/packet logging is OFF.\n\nINSTALL \u2014 Browser flashing uses the Serial DFU .zip. The .uf2 is provided for manual drag-and-drop through a compatible UF2 bootloader volume. A normal firmware flash does not erase saved settings.\n\nLORA OTA \u2014 This page installs the base firmware, not a .mota package. Obtain and apply exact board-and-role .mota updates separately through the MeshCore LoRa OTA workflow; .mota files cannot be web flashed here.\n\nBOOTLOADER \u2014 nRF52 LoRa OTA requires the exact-board OTAFIX 2.4.1 Preview 6 bootloader, and \u201cota self\u201d must report \u201cbootloader: apply OK.\u201d Never substitute another board\u2019s bootloader. https://github.com/mikecarper/Adafruit_nRF52_Bootloader_OTAFIX/releases/tag/0.9.2-OTAFIX2.4.1-preview.6\n\nSELECTION \u2014 WisMesh Tag, LoRa OTA, no external sensors.", + "notes": "Keymind Cascade MeshCore v1.17.1.1 with Halo/Keymind retry tuning and Cascade defaults.\n\nROLE \u2014 Repeater: always-on infrastructure that relays mesh traffic. Configure its name/location, radio settings, and admin password. A repeater does not need an OTA-capable build merely to relay OTA traffic for other nodes.\n\nPROFILE - Compact LoRa OTA: can install LoRa OTA while retaining this exact target's board functions. To fit update staging, it omits selected optional environmental/ranging drivers; I2C remains available and is not globally disabled. USB debug/packet logging is OFF.\n\nINSTALL \u2014 Browser flashing uses the Serial DFU .zip. The .uf2 is provided for manual drag-and-drop through a compatible UF2 bootloader volume. A normal firmware flash does not erase saved settings.\n\nLORA OTA \u2014 This page installs the base firmware, not a .mota package. Obtain and apply exact board-and-role .mota updates separately through the MeshCore LoRa OTA workflow; .mota files cannot be web flashed here.\n\nBOOTLOADER \u2014 nRF52 LoRa OTA requires the exact-board OTAFIX 2.4.1 Preview 6 bootloader, and \u201cota self\u201d must report \u201cbootloader: apply OK.\u201d Never substitute another board\u2019s bootloader. https://github.com/mikecarper/Adafruit_nRF52_Bootloader_OTAFIX/releases/tag/0.9.2-OTAFIX2.4.1-preview.6\n\nSELECTION \u2014 WisMesh Tag, LoRa OTA, selected optional sensor drivers omitted.", "files": [ { "type": "flash", @@ -12113,10 +12113,10 @@ { "role": "repeater", "title": "Repeater", - "subTitle": "LoRa OTA, no external sensors", + "subTitle": "LoRa OTA, selected optional sensor drivers omitted", "version": { "lora-ota-no-external-sensors-v1.17.1.1-759a35fc": { - "notes": "Keymind Cascade MeshCore v1.17.1.1 with Halo/Keymind retry tuning and Cascade defaults.\n\nROLE \u2014 Repeater: always-on infrastructure that relays mesh traffic. Configure its name/location, radio settings, and admin password. A repeater does not need an OTA-capable build merely to relay OTA traffic for other nodes.\n\nPROFILE \u2014 Compact LoRa OTA: can install LoRa OTA and keeps core role features plus board-native display/GPS, but removes optional external environmental-sensor drivers to make update staging fit. USB debug/packet logging is OFF.\n\nINSTALL \u2014 Browser flashing uses the Serial DFU .zip. The .uf2 is provided for manual drag-and-drop through a compatible UF2 bootloader volume. A normal firmware flash does not erase saved settings.\n\nLORA OTA \u2014 This page installs the base firmware, not a .mota package. Obtain and apply exact board-and-role .mota updates separately through the MeshCore LoRa OTA workflow; .mota files cannot be web flashed here.\n\nBOOTLOADER \u2014 nRF52 LoRa OTA requires the exact-board OTAFIX 2.4.1 Preview 6 bootloader, and \u201cota self\u201d must report \u201cbootloader: apply OK.\u201d Never substitute another board\u2019s bootloader. https://github.com/mikecarper/Adafruit_nRF52_Bootloader_OTAFIX/releases/tag/0.9.2-OTAFIX2.4.1-preview.6\n\nSELECTION \u2014 LoRa OTA, no external sensors.", + "notes": "Keymind Cascade MeshCore v1.17.1.1 with Halo/Keymind retry tuning and Cascade defaults.\n\nROLE \u2014 Repeater: always-on infrastructure that relays mesh traffic. Configure its name/location, radio settings, and admin password. A repeater does not need an OTA-capable build merely to relay OTA traffic for other nodes.\n\nPROFILE - Compact LoRa OTA: can install LoRa OTA while retaining this exact target's board functions. To fit update staging, it omits selected optional environmental/ranging drivers; I2C remains available and is not globally disabled. USB debug/packet logging is OFF.\n\nINSTALL \u2014 Browser flashing uses the Serial DFU .zip. The .uf2 is provided for manual drag-and-drop through a compatible UF2 bootloader volume. A normal firmware flash does not erase saved settings.\n\nLORA OTA \u2014 This page installs the base firmware, not a .mota package. Obtain and apply exact board-and-role .mota updates separately through the MeshCore LoRa OTA workflow; .mota files cannot be web flashed here.\n\nBOOTLOADER \u2014 nRF52 LoRa OTA requires the exact-board OTAFIX 2.4.1 Preview 6 bootloader, and \u201cota self\u201d must report \u201cbootloader: apply OK.\u201d Never substitute another board\u2019s bootloader. https://github.com/mikecarper/Adafruit_nRF52_Bootloader_OTAFIX/releases/tag/0.9.2-OTAFIX2.4.1-preview.6\n\nSELECTION \u2014 LoRa OTA, selected optional sensor drivers omitted.", "files": [ { "type": "flash", @@ -12213,10 +12213,10 @@ { "role": "repeater", "title": "Repeater", - "subTitle": "LoRa OTA, no external sensors", + "subTitle": "LoRa OTA, selected optional sensor drivers omitted", "version": { "lora-ota-no-external-sensors-v1.17.1.1-759a35fc": { - "notes": "Keymind Cascade MeshCore v1.17.1.1 with Halo/Keymind retry tuning and Cascade defaults.\n\nROLE \u2014 Repeater: always-on infrastructure that relays mesh traffic. Configure its name/location, radio settings, and admin password. A repeater does not need an OTA-capable build merely to relay OTA traffic for other nodes.\n\nPROFILE \u2014 Compact LoRa OTA: can install LoRa OTA and keeps core role features plus board-native display/GPS, but removes optional external environmental-sensor drivers to make update staging fit. USB debug/packet logging is OFF.\n\nINSTALL \u2014 Browser flashing uses the Serial DFU .zip. The .uf2 is provided for manual drag-and-drop through a compatible UF2 bootloader volume. A normal firmware flash does not erase saved settings.\n\nLORA OTA \u2014 This page installs the base firmware, not a .mota package. Obtain and apply exact board-and-role .mota updates separately through the MeshCore LoRa OTA workflow; .mota files cannot be web flashed here.\n\nBOOTLOADER \u2014 nRF52 LoRa OTA requires the exact-board OTAFIX 2.4.1 Preview 6 bootloader, and \u201cota self\u201d must report \u201cbootloader: apply OK.\u201d Never substitute another board\u2019s bootloader. https://github.com/mikecarper/Adafruit_nRF52_Bootloader_OTAFIX/releases/tag/0.9.2-OTAFIX2.4.1-preview.6\n\nSELECTION \u2014 LoRa OTA, no external sensors.", + "notes": "Keymind Cascade MeshCore v1.17.1.1 with Halo/Keymind retry tuning and Cascade defaults.\n\nROLE \u2014 Repeater: always-on infrastructure that relays mesh traffic. Configure its name/location, radio settings, and admin password. A repeater does not need an OTA-capable build merely to relay OTA traffic for other nodes.\n\nPROFILE - Compact LoRa OTA: can install LoRa OTA while retaining this exact target's board functions. To fit update staging, it omits selected optional environmental/ranging drivers; I2C remains available and is not globally disabled. USB debug/packet logging is OFF.\n\nINSTALL \u2014 Browser flashing uses the Serial DFU .zip. The .uf2 is provided for manual drag-and-drop through a compatible UF2 bootloader volume. A normal firmware flash does not erase saved settings.\n\nLORA OTA \u2014 This page installs the base firmware, not a .mota package. Obtain and apply exact board-and-role .mota updates separately through the MeshCore LoRa OTA workflow; .mota files cannot be web flashed here.\n\nBOOTLOADER \u2014 nRF52 LoRa OTA requires the exact-board OTAFIX 2.4.1 Preview 6 bootloader, and \u201cota self\u201d must report \u201cbootloader: apply OK.\u201d Never substitute another board\u2019s bootloader. https://github.com/mikecarper/Adafruit_nRF52_Bootloader_OTAFIX/releases/tag/0.9.2-OTAFIX2.4.1-preview.6\n\nSELECTION \u2014 LoRa OTA, selected optional sensor drivers omitted.", "files": [ { "type": "flash", @@ -12313,10 +12313,10 @@ { "role": "repeater", "title": "Repeater", - "subTitle": "LoRa OTA, no external sensors", + "subTitle": "LoRa OTA, selected optional sensor drivers omitted", "version": { "lora-ota-no-external-sensors-v1.17.1.1-759a35fc": { - "notes": "Keymind Cascade MeshCore v1.17.1.1 with Halo/Keymind retry tuning and Cascade defaults.\n\nROLE \u2014 Repeater: always-on infrastructure that relays mesh traffic. Configure its name/location, radio settings, and admin password. A repeater does not need an OTA-capable build merely to relay OTA traffic for other nodes.\n\nPROFILE \u2014 Compact LoRa OTA: can install LoRa OTA and keeps core role features plus board-native display/GPS, but removes optional external environmental-sensor drivers to make update staging fit. USB debug/packet logging is OFF.\n\nINSTALL \u2014 Browser flashing uses the Serial DFU .zip. The .uf2 is provided for manual drag-and-drop through a compatible UF2 bootloader volume. A normal firmware flash does not erase saved settings.\n\nLORA OTA \u2014 This page installs the base firmware, not a .mota package. Obtain and apply exact board-and-role .mota updates separately through the MeshCore LoRa OTA workflow; .mota files cannot be web flashed here.\n\nBOOTLOADER \u2014 nRF52 LoRa OTA requires the exact-board OTAFIX 2.4.1 Preview 6 bootloader, and \u201cota self\u201d must report \u201cbootloader: apply OK.\u201d Never substitute another board\u2019s bootloader. https://github.com/mikecarper/Adafruit_nRF52_Bootloader_OTAFIX/releases/tag/0.9.2-OTAFIX2.4.1-preview.6\n\nSELECTION \u2014 LoRa OTA, no external sensors.", + "notes": "Keymind Cascade MeshCore v1.17.1.1 with Halo/Keymind retry tuning and Cascade defaults.\n\nROLE \u2014 Repeater: always-on infrastructure that relays mesh traffic. Configure its name/location, radio settings, and admin password. A repeater does not need an OTA-capable build merely to relay OTA traffic for other nodes.\n\nPROFILE - Compact LoRa OTA: can install LoRa OTA while retaining this exact target's board functions. To fit update staging, it omits selected optional environmental/ranging drivers; I2C remains available and is not globally disabled. USB debug/packet logging is OFF.\n\nINSTALL \u2014 Browser flashing uses the Serial DFU .zip. The .uf2 is provided for manual drag-and-drop through a compatible UF2 bootloader volume. A normal firmware flash does not erase saved settings.\n\nLORA OTA \u2014 This page installs the base firmware, not a .mota package. Obtain and apply exact board-and-role .mota updates separately through the MeshCore LoRa OTA workflow; .mota files cannot be web flashed here.\n\nBOOTLOADER \u2014 nRF52 LoRa OTA requires the exact-board OTAFIX 2.4.1 Preview 6 bootloader, and \u201cota self\u201d must report \u201cbootloader: apply OK.\u201d Never substitute another board\u2019s bootloader. https://github.com/mikecarper/Adafruit_nRF52_Bootloader_OTAFIX/releases/tag/0.9.2-OTAFIX2.4.1-preview.6\n\nSELECTION \u2014 LoRa OTA, selected optional sensor drivers omitted.", "files": [ { "type": "flash", @@ -12401,10 +12401,10 @@ { "role": "repeater", "title": "Repeater", - "subTitle": "LoRa OTA, no external sensors", + "subTitle": "LoRa OTA, selected optional sensor drivers omitted", "version": { "lora-ota-no-external-sensors-v1.17.1.1-759a35fc": { - "notes": "Keymind Cascade MeshCore v1.17.1.1 with Halo/Keymind retry tuning and Cascade defaults.\n\nROLE \u2014 Repeater: always-on infrastructure that relays mesh traffic. Configure its name/location, radio settings, and admin password. A repeater does not need an OTA-capable build merely to relay OTA traffic for other nodes.\n\nPROFILE \u2014 Compact LoRa OTA: can install LoRa OTA and keeps core role features plus board-native display/GPS, but removes optional external environmental-sensor drivers to make update staging fit. USB debug/packet logging is OFF.\n\nINSTALL \u2014 Mesh America provides downloads only for this target. Use the board\u2019s native bootloader or programmer with the matching .uf2, .hex, or .bin; web flashing is unavailable.\n\nLORA OTA \u2014 This page installs the base firmware, not a .mota package. Obtain and apply exact board-and-role .mota updates separately through the MeshCore LoRa OTA workflow; .mota files cannot be web flashed here.\n\nBOOTLOADER \u2014 nRF52 LoRa OTA requires the exact-board OTAFIX 2.4.1 Preview 6 bootloader, and \u201cota self\u201d must report \u201cbootloader: apply OK.\u201d Never substitute another board\u2019s bootloader. https://github.com/mikecarper/Adafruit_nRF52_Bootloader_OTAFIX/releases/tag/0.9.2-OTAFIX2.4.1-preview.6\n\nSELECTION \u2014 LoRa OTA, no external sensors.", + "notes": "Keymind Cascade MeshCore v1.17.1.1 with Halo/Keymind retry tuning and Cascade defaults.\n\nROLE \u2014 Repeater: always-on infrastructure that relays mesh traffic. Configure its name/location, radio settings, and admin password. A repeater does not need an OTA-capable build merely to relay OTA traffic for other nodes.\n\nPROFILE - Compact LoRa OTA: can install LoRa OTA while retaining this exact target's board functions. To fit update staging, it omits selected optional environmental/ranging drivers; I2C remains available and is not globally disabled. USB debug/packet logging is OFF.\n\nINSTALL \u2014 Mesh America provides downloads only for this target. Use the board\u2019s native bootloader or programmer with the matching .uf2, .hex, or .bin; web flashing is unavailable.\n\nLORA OTA \u2014 This page installs the base firmware, not a .mota package. Obtain and apply exact board-and-role .mota updates separately through the MeshCore LoRa OTA workflow; .mota files cannot be web flashed here.\n\nBOOTLOADER \u2014 nRF52 LoRa OTA requires the exact-board OTAFIX 2.4.1 Preview 6 bootloader, and \u201cota self\u201d must report \u201cbootloader: apply OK.\u201d Never substitute another board\u2019s bootloader. https://github.com/mikecarper/Adafruit_nRF52_Bootloader_OTAFIX/releases/tag/0.9.2-OTAFIX2.4.1-preview.6\n\nSELECTION \u2014 LoRa OTA, selected optional sensor drivers omitted.", "files": [ { "type": "download", @@ -12729,10 +12729,10 @@ { "role": "repeater", "title": "Repeater", - "subTitle": "LoRa OTA, no external sensors", + "subTitle": "LoRa OTA, selected optional sensor drivers omitted", "version": { "lora-ota-no-external-sensors-v1.17.1.1-759a35fc": { - "notes": "Keymind Cascade MeshCore v1.17.1.1 with Halo/Keymind retry tuning and Cascade defaults.\n\nROLE \u2014 Repeater: always-on infrastructure that relays mesh traffic. Configure its name/location, radio settings, and admin password. A repeater does not need an OTA-capable build merely to relay OTA traffic for other nodes.\n\nPROFILE \u2014 Compact LoRa OTA: can install LoRa OTA and keeps core role features plus board-native display/GPS, but removes optional external environmental-sensor drivers to make update staging fit. USB debug/packet logging is OFF.\n\nINSTALL \u2014 First-time setup uses Full install (the merged bootloader + firmware image). Routine upgrades use Update (the app-only image) and normally preserve settings. Enable Erase all flash only when you intentionally want a clean reset.\n\nLORA OTA \u2014 This page installs the base firmware, not a .mota package. Obtain and apply exact board-and-role .mota updates separately through the MeshCore LoRa OTA workflow; .mota files cannot be web flashed here.\n\nSELECTION \u2014 LoRa OTA, no external sensors.", + "notes": "Keymind Cascade MeshCore v1.17.1.1 with Halo/Keymind retry tuning and Cascade defaults.\n\nROLE \u2014 Repeater: always-on infrastructure that relays mesh traffic. Configure its name/location, radio settings, and admin password. A repeater does not need an OTA-capable build merely to relay OTA traffic for other nodes.\n\nPROFILE - Compact LoRa OTA: can install LoRa OTA while retaining this exact target's board functions. To fit update staging, it omits selected optional environmental/ranging drivers; I2C remains available and is not globally disabled. USB debug/packet logging is OFF.\n\nINSTALL \u2014 First-time setup uses Full install (the merged bootloader + firmware image). Routine upgrades use Update (the app-only image) and normally preserve settings. Enable Erase all flash only when you intentionally want a clean reset.\n\nLORA OTA \u2014 This page installs the base firmware, not a .mota package. Obtain and apply exact board-and-role .mota updates separately through the MeshCore LoRa OTA workflow; .mota files cannot be web flashed here.\n\nSELECTION \u2014 LoRa OTA, selected optional sensor drivers omitted.", "files": [ { "type": "flash-wipe", @@ -12829,10 +12829,10 @@ { "role": "repeater", "title": "Repeater", - "subTitle": "LoRa OTA, no external sensors", + "subTitle": "LoRa OTA, selected optional sensor drivers omitted", "version": { "lora-ota-no-external-sensors-v1.17.1.1-759a35fc": { - "notes": "Keymind Cascade MeshCore v1.17.1.1 with Halo/Keymind retry tuning and Cascade defaults.\n\nROLE \u2014 Repeater: always-on infrastructure that relays mesh traffic. Configure its name/location, radio settings, and admin password. A repeater does not need an OTA-capable build merely to relay OTA traffic for other nodes.\n\nPROFILE \u2014 Compact LoRa OTA: can install LoRa OTA and keeps core role features plus board-native display/GPS, but removes optional external environmental-sensor drivers to make update staging fit. USB debug/packet logging is OFF.\n\nINSTALL \u2014 First-time setup uses Full install (the merged bootloader + firmware image). Routine upgrades use Update (the app-only image) and normally preserve settings. Enable Erase all flash only when you intentionally want a clean reset.\n\nLORA OTA \u2014 This page installs the base firmware, not a .mota package. Obtain and apply exact board-and-role .mota updates separately through the MeshCore LoRa OTA workflow; .mota files cannot be web flashed here.\n\nSELECTION \u2014 LoRa OTA, no external sensors.", + "notes": "Keymind Cascade MeshCore v1.17.1.1 with Halo/Keymind retry tuning and Cascade defaults.\n\nROLE \u2014 Repeater: always-on infrastructure that relays mesh traffic. Configure its name/location, radio settings, and admin password. A repeater does not need an OTA-capable build merely to relay OTA traffic for other nodes.\n\nPROFILE - Compact LoRa OTA: can install LoRa OTA while retaining this exact target's board functions. To fit update staging, it omits selected optional environmental/ranging drivers; I2C remains available and is not globally disabled. USB debug/packet logging is OFF.\n\nINSTALL \u2014 First-time setup uses Full install (the merged bootloader + firmware image). Routine upgrades use Update (the app-only image) and normally preserve settings. Enable Erase all flash only when you intentionally want a clean reset.\n\nLORA OTA \u2014 This page installs the base firmware, not a .mota package. Obtain and apply exact board-and-role .mota updates separately through the MeshCore LoRa OTA workflow; .mota files cannot be web flashed here.\n\nSELECTION \u2014 LoRa OTA, selected optional sensor drivers omitted.", "files": [ { "type": "flash-wipe", @@ -12906,10 +12906,10 @@ { "role": "repeater", "title": "Repeater", - "subTitle": "LoRa OTA, no external sensors", + "subTitle": "LoRa OTA, selected optional sensor drivers omitted", "version": { "lora-ota-no-external-sensors-v1.17.1.1-759a35fc": { - "notes": "Keymind Cascade MeshCore v1.17.1.1 with Halo/Keymind retry tuning and Cascade defaults.\n\nROLE \u2014 Repeater: always-on infrastructure that relays mesh traffic. Configure its name/location, radio settings, and admin password. A repeater does not need an OTA-capable build merely to relay OTA traffic for other nodes.\n\nPROFILE \u2014 Compact LoRa OTA: can install LoRa OTA and keeps core role features plus board-native display/GPS, but removes optional external environmental-sensor drivers to make update staging fit. USB debug/packet logging is OFF.\n\nINSTALL \u2014 Browser flashing uses the Serial DFU .zip. The .uf2 is provided for manual drag-and-drop through a compatible UF2 bootloader volume. A normal firmware flash does not erase saved settings.\n\nLORA OTA \u2014 This page installs the base firmware, not a .mota package. Obtain and apply exact board-and-role .mota updates separately through the MeshCore LoRa OTA workflow; .mota files cannot be web flashed here.\n\nBOOTLOADER \u2014 nRF52 LoRa OTA requires the exact-board OTAFIX 2.4.1 Preview 6 bootloader, and \u201cota self\u201d must report \u201cbootloader: apply OK.\u201d Never substitute another board\u2019s bootloader. https://github.com/mikecarper/Adafruit_nRF52_Bootloader_OTAFIX/releases/tag/0.9.2-OTAFIX2.4.1-preview.6\n\nSELECTION \u2014 LoRa OTA, no external sensors.", + "notes": "Keymind Cascade MeshCore v1.17.1.1 with Halo/Keymind retry tuning and Cascade defaults.\n\nROLE \u2014 Repeater: always-on infrastructure that relays mesh traffic. Configure its name/location, radio settings, and admin password. A repeater does not need an OTA-capable build merely to relay OTA traffic for other nodes.\n\nPROFILE - Compact LoRa OTA: can install LoRa OTA while retaining this exact target's board functions. To fit update staging, it omits selected optional environmental/ranging drivers; I2C remains available and is not globally disabled. USB debug/packet logging is OFF.\n\nINSTALL \u2014 Browser flashing uses the Serial DFU .zip. The .uf2 is provided for manual drag-and-drop through a compatible UF2 bootloader volume. A normal firmware flash does not erase saved settings.\n\nLORA OTA \u2014 This page installs the base firmware, not a .mota package. Obtain and apply exact board-and-role .mota updates separately through the MeshCore LoRa OTA workflow; .mota files cannot be web flashed here.\n\nBOOTLOADER \u2014 nRF52 LoRa OTA requires the exact-board OTAFIX 2.4.1 Preview 6 bootloader, and \u201cota self\u201d must report \u201cbootloader: apply OK.\u201d Never substitute another board\u2019s bootloader. https://github.com/mikecarper/Adafruit_nRF52_Bootloader_OTAFIX/releases/tag/0.9.2-OTAFIX2.4.1-preview.6\n\nSELECTION \u2014 LoRa OTA, selected optional sensor drivers omitted.", "files": [ { "type": "flash", @@ -13156,10 +13156,10 @@ { "role": "repeater", "title": "Repeater", - "subTitle": "SX1262 pins: DIO1 2, NSS 5, BUSY 4, LoRa OTA, no external sensors", + "subTitle": "SX1262 pins: DIO1 2, NSS 5, BUSY 4, LoRa OTA, selected optional sensor drivers omitted", "version": { "sx1262-pins-dio1-2-nss-5-busy-4-lora-ota-no-external-sensors-v1.17.1.1-759a35fc": { - "notes": "Keymind Cascade MeshCore v1.17.1.1 with Halo/Keymind retry tuning and Cascade defaults.\n\nROLE \u2014 Repeater: always-on infrastructure that relays mesh traffic. Configure its name/location, radio settings, and admin password. A repeater does not need an OTA-capable build merely to relay OTA traffic for other nodes.\n\nPROFILE \u2014 Compact LoRa OTA: can install LoRa OTA and keeps core role features plus board-native display/GPS, but removes optional external environmental-sensor drivers to make update staging fit. USB debug/packet logging is OFF.\n\nINSTALL \u2014 First-time setup uses Full install (the merged bootloader + firmware image). Routine upgrades use Update (the app-only image) and normally preserve settings. Enable Erase all flash only when you intentionally want a clean reset.\n\nLORA OTA \u2014 This page installs the base firmware, not a .mota package. Obtain and apply exact board-and-role .mota updates separately through the MeshCore LoRa OTA workflow; .mota files cannot be web flashed here.\n\nSELECTION \u2014 SX1262 pins: DIO1 2, NSS 5, BUSY 4, LoRa OTA, no external sensors.\n\nHARDWARE \u2014 Match the listed radio module, output stage, and pin map to the exact board; a similarly named radio variant is not a safe substitute.\n\nBoard selection note: Use this for a Seeed XIAO ESP32S3 with an external SX1262 wired to DIO1=2, NSS=5, RESET=3, BUSY=4, SCLK/MISO/MOSI=7/8/9, and RXEN=6. Do not use it for the XIAO S3 WIO board, which uses DIO1=39, NSS=41, RESET=42, BUSY=40, and RXEN=38.", + "notes": "Keymind Cascade MeshCore v1.17.1.1 with Halo/Keymind retry tuning and Cascade defaults.\n\nROLE \u2014 Repeater: always-on infrastructure that relays mesh traffic. Configure its name/location, radio settings, and admin password. A repeater does not need an OTA-capable build merely to relay OTA traffic for other nodes.\n\nPROFILE - Compact LoRa OTA: can install LoRa OTA while retaining this exact target's board functions. To fit update staging, it omits selected optional environmental/ranging drivers; I2C remains available and is not globally disabled. USB debug/packet logging is OFF.\n\nINSTALL \u2014 First-time setup uses Full install (the merged bootloader + firmware image). Routine upgrades use Update (the app-only image) and normally preserve settings. Enable Erase all flash only when you intentionally want a clean reset.\n\nLORA OTA \u2014 This page installs the base firmware, not a .mota package. Obtain and apply exact board-and-role .mota updates separately through the MeshCore LoRa OTA workflow; .mota files cannot be web flashed here.\n\nSELECTION \u2014 SX1262 pins: DIO1 2, NSS 5, BUSY 4, LoRa OTA, selected optional sensor drivers omitted.\n\nHARDWARE \u2014 Match the listed radio module, output stage, and pin map to the exact board; a similarly named radio variant is not a safe substitute.\n\nBoard selection note: Use this for a Seeed XIAO ESP32S3 with an external SX1262 wired to DIO1=2, NSS=5, RESET=3, BUSY=4, SCLK/MISO/MOSI=7/8/9, and RXEN=6. Do not use it for the XIAO S3 WIO board, which uses DIO1=39, NSS=41, RESET=42, BUSY=40, and RXEN=38.", "files": [ { "type": "flash-wipe", @@ -13423,10 +13423,10 @@ { "role": "repeater", "title": "Repeater", - "subTitle": "LoRa OTA, no external sensors", + "subTitle": "LoRa OTA, selected optional sensor drivers omitted", "version": { "lora-ota-no-external-sensors-v1.17.1.1-759a35fc": { - "notes": "Keymind Cascade MeshCore v1.17.1.1 with Halo/Keymind retry tuning and Cascade defaults.\n\nROLE \u2014 Repeater: always-on infrastructure that relays mesh traffic. Configure its name/location, radio settings, and admin password. A repeater does not need an OTA-capable build merely to relay OTA traffic for other nodes.\n\nPROFILE \u2014 Compact LoRa OTA: can install LoRa OTA and keeps core role features plus board-native display/GPS, but removes optional external environmental-sensor drivers to make update staging fit. USB debug/packet logging is OFF.\n\nINSTALL \u2014 First-time setup uses Full install (the merged bootloader + firmware image). Routine upgrades use Update (the app-only image) and normally preserve settings. Enable Erase all flash only when you intentionally want a clean reset.\n\nLORA OTA \u2014 This page installs the base firmware, not a .mota package. Obtain and apply exact board-and-role .mota updates separately through the MeshCore LoRa OTA workflow; .mota files cannot be web flashed here.\n\nSELECTION \u2014 LoRa OTA, no external sensors.", + "notes": "Keymind Cascade MeshCore v1.17.1.1 with Halo/Keymind retry tuning and Cascade defaults.\n\nROLE \u2014 Repeater: always-on infrastructure that relays mesh traffic. Configure its name/location, radio settings, and admin password. A repeater does not need an OTA-capable build merely to relay OTA traffic for other nodes.\n\nPROFILE - Compact LoRa OTA: can install LoRa OTA while retaining this exact target's board functions. To fit update staging, it omits selected optional environmental/ranging drivers; I2C remains available and is not globally disabled. USB debug/packet logging is OFF.\n\nINSTALL \u2014 First-time setup uses Full install (the merged bootloader + firmware image). Routine upgrades use Update (the app-only image) and normally preserve settings. Enable Erase all flash only when you intentionally want a clean reset.\n\nLORA OTA \u2014 This page installs the base firmware, not a .mota package. Obtain and apply exact board-and-role .mota updates separately through the MeshCore LoRa OTA workflow; .mota files cannot be web flashed here.\n\nSELECTION \u2014 LoRa OTA, selected optional sensor drivers omitted.", "files": [ { "type": "flash-wipe", @@ -13668,10 +13668,10 @@ { "role": "repeater", "title": "Repeater", - "subTitle": "30S, SX1262 22 dBm, LoRa OTA, no external sensors", + "subTitle": "30S, SX1262 22 dBm, LoRa OTA, selected optional sensor drivers omitted", "version": { "30s-sx1262-22-dbm-lora-ota-no-external-sensors-v1.17.1.1-759a35fc": { - "notes": "Keymind Cascade MeshCore v1.17.1.1 with Halo/Keymind retry tuning and Cascade defaults.\n\nROLE \u2014 Repeater: always-on infrastructure that relays mesh traffic. Configure its name/location, radio settings, and admin password. A repeater does not need an OTA-capable build merely to relay OTA traffic for other nodes.\n\nPROFILE \u2014 Compact LoRa OTA: can install LoRa OTA and keeps core role features plus board-native display/GPS, but removes optional external environmental-sensor drivers to make update staging fit. USB debug/packet logging is OFF.\n\nINSTALL \u2014 Browser flashing uses the Serial DFU .zip. The .uf2 is provided for manual drag-and-drop through a compatible UF2 bootloader volume. A normal firmware flash does not erase saved settings.\n\nLORA OTA \u2014 This page installs the base firmware, not a .mota package. Obtain and apply exact board-and-role .mota updates separately through the MeshCore LoRa OTA workflow; .mota files cannot be web flashed here.\n\nBOOTLOADER \u2014 nRF52 LoRa OTA requires the exact-board OTAFIX 2.4.1 Preview 6 bootloader, and \u201cota self\u201d must report \u201cbootloader: apply OK.\u201d Never substitute another board\u2019s bootloader. https://github.com/mikecarper/Adafruit_nRF52_Bootloader_OTAFIX/releases/tag/0.9.2-OTAFIX2.4.1-preview.6\n\nSELECTION \u2014 30S, SX1262 22 dBm, LoRa OTA, no external sensors.\n\nHARDWARE \u2014 Match the listed radio module, output stage, and pin map to the exact board; a similarly named radio variant is not a safe substitute.", + "notes": "Keymind Cascade MeshCore v1.17.1.1 with Halo/Keymind retry tuning and Cascade defaults.\n\nROLE \u2014 Repeater: always-on infrastructure that relays mesh traffic. Configure its name/location, radio settings, and admin password. A repeater does not need an OTA-capable build merely to relay OTA traffic for other nodes.\n\nPROFILE - Compact LoRa OTA: can install LoRa OTA while retaining this exact target's board functions. To fit update staging, it omits selected optional environmental/ranging drivers; I2C remains available and is not globally disabled. USB debug/packet logging is OFF.\n\nINSTALL \u2014 Browser flashing uses the Serial DFU .zip. The .uf2 is provided for manual drag-and-drop through a compatible UF2 bootloader volume. A normal firmware flash does not erase saved settings.\n\nLORA OTA \u2014 This page installs the base firmware, not a .mota package. Obtain and apply exact board-and-role .mota updates separately through the MeshCore LoRa OTA workflow; .mota files cannot be web flashed here.\n\nBOOTLOADER \u2014 nRF52 LoRa OTA requires the exact-board OTAFIX 2.4.1 Preview 6 bootloader, and \u201cota self\u201d must report \u201cbootloader: apply OK.\u201d Never substitute another board\u2019s bootloader. https://github.com/mikecarper/Adafruit_nRF52_Bootloader_OTAFIX/releases/tag/0.9.2-OTAFIX2.4.1-preview.6\n\nSELECTION \u2014 30S, SX1262 22 dBm, LoRa OTA, selected optional sensor drivers omitted.\n\nHARDWARE \u2014 Match the listed radio module, output stage, and pin map to the exact board; a similarly named radio variant is not a safe substitute.", "files": [ { "type": "flash", @@ -13716,10 +13716,10 @@ { "role": "repeater", "title": "Repeater", - "subTitle": "33S, 2W PA, LoRa OTA, no external sensors", + "subTitle": "33S, 2W PA, LoRa OTA, selected optional sensor drivers omitted", "version": { "33s-2w-pa-lora-ota-no-external-sensors-v1.17.1.1-759a35fc": { - "notes": "Keymind Cascade MeshCore v1.17.1.1 with Halo/Keymind retry tuning and Cascade defaults.\n\nROLE \u2014 Repeater: always-on infrastructure that relays mesh traffic. Configure its name/location, radio settings, and admin password. A repeater does not need an OTA-capable build merely to relay OTA traffic for other nodes.\n\nPROFILE \u2014 Compact LoRa OTA: can install LoRa OTA and keeps core role features plus board-native display/GPS, but removes optional external environmental-sensor drivers to make update staging fit. USB debug/packet logging is OFF.\n\nINSTALL \u2014 Browser flashing uses the Serial DFU .zip. The .uf2 is provided for manual drag-and-drop through a compatible UF2 bootloader volume. A normal firmware flash does not erase saved settings.\n\nLORA OTA \u2014 This page installs the base firmware, not a .mota package. Obtain and apply exact board-and-role .mota updates separately through the MeshCore LoRa OTA workflow; .mota files cannot be web flashed here.\n\nBOOTLOADER \u2014 nRF52 LoRa OTA requires the exact-board OTAFIX 2.4.1 Preview 6 bootloader, and \u201cota self\u201d must report \u201cbootloader: apply OK.\u201d Never substitute another board\u2019s bootloader. https://github.com/mikecarper/Adafruit_nRF52_Bootloader_OTAFIX/releases/tag/0.9.2-OTAFIX2.4.1-preview.6\n\nSELECTION \u2014 33S, 2W PA, LoRa OTA, no external sensors.\n\nHARDWARE \u2014 Match the listed radio module, output stage, and pin map to the exact board; a similarly named radio variant is not a safe substitute.", + "notes": "Keymind Cascade MeshCore v1.17.1.1 with Halo/Keymind retry tuning and Cascade defaults.\n\nROLE \u2014 Repeater: always-on infrastructure that relays mesh traffic. Configure its name/location, radio settings, and admin password. A repeater does not need an OTA-capable build merely to relay OTA traffic for other nodes.\n\nPROFILE - Compact LoRa OTA: can install LoRa OTA while retaining this exact target's board functions. To fit update staging, it omits selected optional environmental/ranging drivers; I2C remains available and is not globally disabled. USB debug/packet logging is OFF.\n\nINSTALL \u2014 Browser flashing uses the Serial DFU .zip. The .uf2 is provided for manual drag-and-drop through a compatible UF2 bootloader volume. A normal firmware flash does not erase saved settings.\n\nLORA OTA \u2014 This page installs the base firmware, not a .mota package. Obtain and apply exact board-and-role .mota updates separately through the MeshCore LoRa OTA workflow; .mota files cannot be web flashed here.\n\nBOOTLOADER \u2014 nRF52 LoRa OTA requires the exact-board OTAFIX 2.4.1 Preview 6 bootloader, and \u201cota self\u201d must report \u201cbootloader: apply OK.\u201d Never substitute another board\u2019s bootloader. https://github.com/mikecarper/Adafruit_nRF52_Bootloader_OTAFIX/releases/tag/0.9.2-OTAFIX2.4.1-preview.6\n\nSELECTION \u2014 33S, 2W PA, LoRa OTA, selected optional sensor drivers omitted.\n\nHARDWARE \u2014 Match the listed radio module, output stage, and pin map to the exact board; a similarly named radio variant is not a safe substitute.", "files": [ { "type": "flash", @@ -13818,10 +13818,10 @@ { "role": "repeater", "title": "Repeater - SX1262", - "subTitle": "LoRa OTA, no external sensors", + "subTitle": "LoRa OTA, selected optional sensor drivers omitted", "version": { "lora-ota-no-external-sensors-v1.17.1.1-759a35fc": { - "notes": "Keymind Cascade MeshCore v1.17.1.1 with Halo/Keymind retry tuning and Cascade defaults.\n\nROLE \u2014 Repeater: always-on infrastructure that relays mesh traffic. Configure its name/location, radio settings, and admin password. A repeater does not need an OTA-capable build merely to relay OTA traffic for other nodes.\n\nPROFILE \u2014 Compact LoRa OTA: can install LoRa OTA and keeps core role features plus board-native display/GPS, but removes optional external environmental-sensor drivers to make update staging fit. USB debug/packet logging is OFF.\n\nINSTALL \u2014 First-time setup uses Full install (the merged bootloader + firmware image). Routine upgrades use Update (the app-only image) and normally preserve settings. Enable Erase all flash only when you intentionally want a clean reset.\n\nLORA OTA \u2014 This page installs the base firmware, not a .mota package. Obtain and apply exact board-and-role .mota updates separately through the MeshCore LoRa OTA workflow; .mota files cannot be web flashed here.\n\nSELECTION \u2014 LoRa OTA, no external sensors.", + "notes": "Keymind Cascade MeshCore v1.17.1.1 with Halo/Keymind retry tuning and Cascade defaults.\n\nROLE \u2014 Repeater: always-on infrastructure that relays mesh traffic. Configure its name/location, radio settings, and admin password. A repeater does not need an OTA-capable build merely to relay OTA traffic for other nodes.\n\nPROFILE - Compact LoRa OTA: can install LoRa OTA while retaining this exact target's board functions. To fit update staging, it omits selected optional environmental/ranging drivers; I2C remains available and is not globally disabled. USB debug/packet logging is OFF.\n\nINSTALL \u2014 First-time setup uses Full install (the merged bootloader + firmware image). Routine upgrades use Update (the app-only image) and normally preserve settings. Enable Erase all flash only when you intentionally want a clean reset.\n\nLORA OTA \u2014 This page installs the base firmware, not a .mota package. Obtain and apply exact board-and-role .mota updates separately through the MeshCore LoRa OTA workflow; .mota files cannot be web flashed here.\n\nSELECTION \u2014 LoRa OTA, selected optional sensor drivers omitted.", "files": [ { "type": "flash-wipe", @@ -13865,10 +13865,10 @@ { "role": "repeater", "title": "Repeater - SX1268", - "subTitle": "LoRa OTA, no external sensors", + "subTitle": "LoRa OTA, selected optional sensor drivers omitted", "version": { "lora-ota-no-external-sensors-v1.17.1.1-759a35fc": { - "notes": "Keymind Cascade MeshCore v1.17.1.1 with Halo/Keymind retry tuning and Cascade defaults.\n\nROLE \u2014 Repeater: always-on infrastructure that relays mesh traffic. Configure its name/location, radio settings, and admin password. A repeater does not need an OTA-capable build merely to relay OTA traffic for other nodes.\n\nPROFILE \u2014 Compact LoRa OTA: can install LoRa OTA and keeps core role features plus board-native display/GPS, but removes optional external environmental-sensor drivers to make update staging fit. USB debug/packet logging is OFF.\n\nINSTALL \u2014 First-time setup uses Full install (the merged bootloader + firmware image). Routine upgrades use Update (the app-only image) and normally preserve settings. Enable Erase all flash only when you intentionally want a clean reset.\n\nLORA OTA \u2014 This page installs the base firmware, not a .mota package. Obtain and apply exact board-and-role .mota updates separately through the MeshCore LoRa OTA workflow; .mota files cannot be web flashed here.\n\nSELECTION \u2014 LoRa OTA, no external sensors.", + "notes": "Keymind Cascade MeshCore v1.17.1.1 with Halo/Keymind retry tuning and Cascade defaults.\n\nROLE \u2014 Repeater: always-on infrastructure that relays mesh traffic. Configure its name/location, radio settings, and admin password. A repeater does not need an OTA-capable build merely to relay OTA traffic for other nodes.\n\nPROFILE - Compact LoRa OTA: can install LoRa OTA while retaining this exact target's board functions. To fit update staging, it omits selected optional environmental/ranging drivers; I2C remains available and is not globally disabled. USB debug/packet logging is OFF.\n\nINSTALL \u2014 First-time setup uses Full install (the merged bootloader + firmware image). Routine upgrades use Update (the app-only image) and normally preserve settings. Enable Erase all flash only when you intentionally want a clean reset.\n\nLORA OTA \u2014 This page installs the base firmware, not a .mota package. Obtain and apply exact board-and-role .mota updates separately through the MeshCore LoRa OTA workflow; .mota files cannot be web flashed here.\n\nSELECTION \u2014 LoRa OTA, selected optional sensor drivers omitted.", "files": [ { "type": "flash-wipe", @@ -14064,10 +14064,10 @@ { "role": "repeater", "title": "Repeater", - "subTitle": "LoRa OTA, no external sensors", + "subTitle": "LoRa OTA, selected optional sensor drivers omitted", "version": { "lora-ota-no-external-sensors-v1.17.1.1-759a35fc": { - "notes": "Keymind Cascade MeshCore v1.17.1.1 with Halo/Keymind retry tuning and Cascade defaults.\n\nROLE \u2014 Repeater: always-on infrastructure that relays mesh traffic. Configure its name/location, radio settings, and admin password. A repeater does not need an OTA-capable build merely to relay OTA traffic for other nodes.\n\nPROFILE \u2014 Compact LoRa OTA: can install LoRa OTA and keeps core role features plus board-native display/GPS, but removes optional external environmental-sensor drivers to make update staging fit. USB debug/packet logging is OFF.\n\nINSTALL \u2014 Browser flashing uses the Serial DFU .zip. The .uf2 is provided for manual drag-and-drop through a compatible UF2 bootloader volume. A normal firmware flash does not erase saved settings.\n\nLORA OTA \u2014 This page installs the base firmware, not a .mota package. Obtain and apply exact board-and-role .mota updates separately through the MeshCore LoRa OTA workflow; .mota files cannot be web flashed here.\n\nBOOTLOADER \u2014 nRF52 LoRa OTA requires the exact-board OTAFIX 2.4.1 Preview 6 bootloader, and \u201cota self\u201d must report \u201cbootloader: apply OK.\u201d Never substitute another board\u2019s bootloader. https://github.com/mikecarper/Adafruit_nRF52_Bootloader_OTAFIX/releases/tag/0.9.2-OTAFIX2.4.1-preview.6\n\nSELECTION \u2014 LoRa OTA, no external sensors.", + "notes": "Keymind Cascade MeshCore v1.17.1.1 with Halo/Keymind retry tuning and Cascade defaults.\n\nROLE \u2014 Repeater: always-on infrastructure that relays mesh traffic. Configure its name/location, radio settings, and admin password. A repeater does not need an OTA-capable build merely to relay OTA traffic for other nodes.\n\nPROFILE - Compact LoRa OTA: can install LoRa OTA while retaining this exact target's board functions. To fit update staging, it omits selected optional environmental/ranging drivers; I2C remains available and is not globally disabled. USB debug/packet logging is OFF.\n\nINSTALL \u2014 Browser flashing uses the Serial DFU .zip. The .uf2 is provided for manual drag-and-drop through a compatible UF2 bootloader volume. A normal firmware flash does not erase saved settings.\n\nLORA OTA \u2014 This page installs the base firmware, not a .mota package. Obtain and apply exact board-and-role .mota updates separately through the MeshCore LoRa OTA workflow; .mota files cannot be web flashed here.\n\nBOOTLOADER \u2014 nRF52 LoRa OTA requires the exact-board OTAFIX 2.4.1 Preview 6 bootloader, and \u201cota self\u201d must report \u201cbootloader: apply OK.\u201d Never substitute another board\u2019s bootloader. https://github.com/mikecarper/Adafruit_nRF52_Bootloader_OTAFIX/releases/tag/0.9.2-OTAFIX2.4.1-preview.6\n\nSELECTION \u2014 LoRa OTA, selected optional sensor drivers omitted.", "files": [ { "type": "flash", @@ -14306,10 +14306,10 @@ { "role": "repeater", "title": "Repeater", - "subTitle": "Logging, LoRa OTA, no external sensors", + "subTitle": "Logging, LoRa OTA, selected optional sensor drivers omitted", "version": { "logging-target-lora-ota-no-external-sensors-v1.17.1.1-759a35fc": { - "notes": "Keymind Cascade MeshCore v1.17.1.1 with Halo/Keymind retry tuning and Cascade defaults.\n\nROLE \u2014 Repeater: always-on infrastructure that relays mesh traffic. Configure its name/location, radio settings, and admin password. A repeater does not need an OTA-capable build merely to relay OTA traffic for other nodes.\n\nPROFILE \u2014 Compact LoRa OTA: can install LoRa OTA and keeps core role features plus board-native display/GPS, but removes optional external environmental-sensor drivers to make update staging fit. USB debug/packet logging is OFF.\n\nINSTALL \u2014 First-time setup uses Full install (the merged bootloader + firmware image). Routine upgrades use Update (the app-only image) and normally preserve settings. Enable Erase all flash only when you intentionally want a clean reset.\n\nLORA OTA \u2014 This page installs the base firmware, not a .mota package. Obtain and apply exact board-and-role .mota updates separately through the MeshCore LoRa OTA workflow; .mota files cannot be web flashed here.\n\nSELECTION \u2014 Logging, LoRa OTA, no external sensors.\n\nTARGET NAME \u2014 \u201cLogging\u201d is part of this legacy Station G2/G3 target name; USB packet logging is determined by the selected profile described above, not by that word alone.", + "notes": "Keymind Cascade MeshCore v1.17.1.1 with Halo/Keymind retry tuning and Cascade defaults.\n\nROLE \u2014 Repeater: always-on infrastructure that relays mesh traffic. Configure its name/location, radio settings, and admin password. A repeater does not need an OTA-capable build merely to relay OTA traffic for other nodes.\n\nPROFILE - Compact LoRa OTA: can install LoRa OTA while retaining this exact target's board functions. To fit update staging, it omits selected optional environmental/ranging drivers; I2C remains available and is not globally disabled. USB debug/packet logging is OFF.\n\nINSTALL \u2014 First-time setup uses Full install (the merged bootloader + firmware image). Routine upgrades use Update (the app-only image) and normally preserve settings. Enable Erase all flash only when you intentionally want a clean reset.\n\nLORA OTA \u2014 This page installs the base firmware, not a .mota package. Obtain and apply exact board-and-role .mota updates separately through the MeshCore LoRa OTA workflow; .mota files cannot be web flashed here.\n\nSELECTION \u2014 Logging, LoRa OTA, selected optional sensor drivers omitted.\n\nTARGET NAME \u2014 \u201cLogging\u201d is part of this legacy Station G2/G3 target name; USB packet logging is determined by the selected profile described above, not by that word alone.", "files": [ { "type": "flash-wipe", @@ -14330,10 +14330,10 @@ { "role": "repeater", "title": "Repeater", - "subTitle": "LoRa OTA, no external sensors", + "subTitle": "LoRa OTA, selected optional sensor drivers omitted", "version": { "lora-ota-no-external-sensors-v1.17.1.1-759a35fc": { - "notes": "Keymind Cascade MeshCore v1.17.1.1 with Halo/Keymind retry tuning and Cascade defaults.\n\nROLE \u2014 Repeater: always-on infrastructure that relays mesh traffic. Configure its name/location, radio settings, and admin password. A repeater does not need an OTA-capable build merely to relay OTA traffic for other nodes.\n\nPROFILE \u2014 Compact LoRa OTA: can install LoRa OTA and keeps core role features plus board-native display/GPS, but removes optional external environmental-sensor drivers to make update staging fit. USB debug/packet logging is OFF.\n\nINSTALL \u2014 First-time setup uses Full install (the merged bootloader + firmware image). Routine upgrades use Update (the app-only image) and normally preserve settings. Enable Erase all flash only when you intentionally want a clean reset.\n\nLORA OTA \u2014 This page installs the base firmware, not a .mota package. Obtain and apply exact board-and-role .mota updates separately through the MeshCore LoRa OTA workflow; .mota files cannot be web flashed here.\n\nSELECTION \u2014 LoRa OTA, no external sensors.", + "notes": "Keymind Cascade MeshCore v1.17.1.1 with Halo/Keymind retry tuning and Cascade defaults.\n\nROLE \u2014 Repeater: always-on infrastructure that relays mesh traffic. Configure its name/location, radio settings, and admin password. A repeater does not need an OTA-capable build merely to relay OTA traffic for other nodes.\n\nPROFILE - Compact LoRa OTA: can install LoRa OTA while retaining this exact target's board functions. To fit update staging, it omits selected optional environmental/ranging drivers; I2C remains available and is not globally disabled. USB debug/packet logging is OFF.\n\nINSTALL \u2014 First-time setup uses Full install (the merged bootloader + firmware image). Routine upgrades use Update (the app-only image) and normally preserve settings. Enable Erase all flash only when you intentionally want a clean reset.\n\nLORA OTA \u2014 This page installs the base firmware, not a .mota package. Obtain and apply exact board-and-role .mota updates separately through the MeshCore LoRa OTA workflow; .mota files cannot be web flashed here.\n\nSELECTION \u2014 LoRa OTA, selected optional sensor drivers omitted.", "files": [ { "type": "flash-wipe", @@ -14573,10 +14573,10 @@ { "role": "repeater", "title": "Repeater", - "subTitle": "Logging, LoRa OTA, no external sensors", + "subTitle": "Logging, LoRa OTA, selected optional sensor drivers omitted", "version": { "logging-target-lora-ota-no-external-sensors-v1.17.1.1-759a35fc": { - "notes": "Keymind Cascade MeshCore v1.17.1.1 with Halo/Keymind retry tuning and Cascade defaults.\n\nROLE \u2014 Repeater: always-on infrastructure that relays mesh traffic. Configure its name/location, radio settings, and admin password. A repeater does not need an OTA-capable build merely to relay OTA traffic for other nodes.\n\nPROFILE \u2014 Compact LoRa OTA: can install LoRa OTA and keeps core role features plus board-native display/GPS, but removes optional external environmental-sensor drivers to make update staging fit. USB debug/packet logging is OFF.\n\nINSTALL \u2014 First-time setup uses Full install (the merged bootloader + firmware image). Routine upgrades use Update (the app-only image) and normally preserve settings. Enable Erase all flash only when you intentionally want a clean reset.\n\nLORA OTA \u2014 This page installs the base firmware, not a .mota package. Obtain and apply exact board-and-role .mota updates separately through the MeshCore LoRa OTA workflow; .mota files cannot be web flashed here.\n\nSELECTION \u2014 Logging, LoRa OTA, no external sensors.\n\nTARGET NAME \u2014 \u201cLogging\u201d is part of this legacy Station G2/G3 target name; USB packet logging is determined by the selected profile described above, not by that word alone.", + "notes": "Keymind Cascade MeshCore v1.17.1.1 with Halo/Keymind retry tuning and Cascade defaults.\n\nROLE \u2014 Repeater: always-on infrastructure that relays mesh traffic. Configure its name/location, radio settings, and admin password. A repeater does not need an OTA-capable build merely to relay OTA traffic for other nodes.\n\nPROFILE - Compact LoRa OTA: can install LoRa OTA while retaining this exact target's board functions. To fit update staging, it omits selected optional environmental/ranging drivers; I2C remains available and is not globally disabled. USB debug/packet logging is OFF.\n\nINSTALL \u2014 First-time setup uses Full install (the merged bootloader + firmware image). Routine upgrades use Update (the app-only image) and normally preserve settings. Enable Erase all flash only when you intentionally want a clean reset.\n\nLORA OTA \u2014 This page installs the base firmware, not a .mota package. Obtain and apply exact board-and-role .mota updates separately through the MeshCore LoRa OTA workflow; .mota files cannot be web flashed here.\n\nSELECTION \u2014 Logging, LoRa OTA, selected optional sensor drivers omitted.\n\nTARGET NAME \u2014 \u201cLogging\u201d is part of this legacy Station G2/G3 target name; USB packet logging is determined by the selected profile described above, not by that word alone.", "files": [ { "type": "flash-wipe", @@ -14597,10 +14597,10 @@ { "role": "repeater", "title": "Repeater", - "subTitle": "LoRa OTA, no external sensors", + "subTitle": "LoRa OTA, selected optional sensor drivers omitted", "version": { "lora-ota-no-external-sensors-v1.17.1.1-759a35fc": { - "notes": "Keymind Cascade MeshCore v1.17.1.1 with Halo/Keymind retry tuning and Cascade defaults.\n\nROLE \u2014 Repeater: always-on infrastructure that relays mesh traffic. Configure its name/location, radio settings, and admin password. A repeater does not need an OTA-capable build merely to relay OTA traffic for other nodes.\n\nPROFILE \u2014 Compact LoRa OTA: can install LoRa OTA and keeps core role features plus board-native display/GPS, but removes optional external environmental-sensor drivers to make update staging fit. USB debug/packet logging is OFF.\n\nINSTALL \u2014 First-time setup uses Full install (the merged bootloader + firmware image). Routine upgrades use Update (the app-only image) and normally preserve settings. Enable Erase all flash only when you intentionally want a clean reset.\n\nLORA OTA \u2014 This page installs the base firmware, not a .mota package. Obtain and apply exact board-and-role .mota updates separately through the MeshCore LoRa OTA workflow; .mota files cannot be web flashed here.\n\nSELECTION \u2014 LoRa OTA, no external sensors.", + "notes": "Keymind Cascade MeshCore v1.17.1.1 with Halo/Keymind retry tuning and Cascade defaults.\n\nROLE \u2014 Repeater: always-on infrastructure that relays mesh traffic. Configure its name/location, radio settings, and admin password. A repeater does not need an OTA-capable build merely to relay OTA traffic for other nodes.\n\nPROFILE - Compact LoRa OTA: can install LoRa OTA while retaining this exact target's board functions. To fit update staging, it omits selected optional environmental/ranging drivers; I2C remains available and is not globally disabled. USB debug/packet logging is OFF.\n\nINSTALL \u2014 First-time setup uses Full install (the merged bootloader + firmware image). Routine upgrades use Update (the app-only image) and normally preserve settings. Enable Erase all flash only when you intentionally want a clean reset.\n\nLORA OTA \u2014 This page installs the base firmware, not a .mota package. Obtain and apply exact board-and-role .mota updates separately through the MeshCore LoRa OTA workflow; .mota files cannot be web flashed here.\n\nSELECTION \u2014 LoRa OTA, selected optional sensor drivers omitted.", "files": [ { "type": "flash-wipe", @@ -14697,10 +14697,10 @@ { "role": "repeater", "title": "Repeater", - "subTitle": "LoRa OTA, no external sensors", + "subTitle": "LoRa OTA, selected optional sensor drivers omitted", "version": { "lora-ota-no-external-sensors-v1.17.1.1-759a35fc": { - "notes": "Keymind Cascade MeshCore v1.17.1.1 with Halo/Keymind retry tuning and Cascade defaults.\n\nROLE \u2014 Repeater: always-on infrastructure that relays mesh traffic. Configure its name/location, radio settings, and admin password. A repeater does not need an OTA-capable build merely to relay OTA traffic for other nodes.\n\nPROFILE \u2014 Compact LoRa OTA: can install LoRa OTA and keeps core role features plus board-native display/GPS, but removes optional external environmental-sensor drivers to make update staging fit. USB debug/packet logging is OFF.\n\nINSTALL \u2014 First-time setup uses Full install (the merged bootloader + firmware image). Routine upgrades use Update (the app-only image) and normally preserve settings. Enable Erase all flash only when you intentionally want a clean reset.\n\nLORA OTA \u2014 This page installs the base firmware, not a .mota package. Obtain and apply exact board-and-role .mota updates separately through the MeshCore LoRa OTA workflow; .mota files cannot be web flashed here.\n\nSELECTION \u2014 LoRa OTA, no external sensors.", + "notes": "Keymind Cascade MeshCore v1.17.1.1 with Halo/Keymind retry tuning and Cascade defaults.\n\nROLE \u2014 Repeater: always-on infrastructure that relays mesh traffic. Configure its name/location, radio settings, and admin password. A repeater does not need an OTA-capable build merely to relay OTA traffic for other nodes.\n\nPROFILE - Compact LoRa OTA: can install LoRa OTA while retaining this exact target's board functions. To fit update staging, it omits selected optional environmental/ranging drivers; I2C remains available and is not globally disabled. USB debug/packet logging is OFF.\n\nINSTALL \u2014 First-time setup uses Full install (the merged bootloader + firmware image). Routine upgrades use Update (the app-only image) and normally preserve settings. Enable Erase all flash only when you intentionally want a clean reset.\n\nLORA OTA \u2014 This page installs the base firmware, not a .mota package. Obtain and apply exact board-and-role .mota updates separately through the MeshCore LoRa OTA workflow; .mota files cannot be web flashed here.\n\nSELECTION \u2014 LoRa OTA, selected optional sensor drivers omitted.", "files": [ { "type": "flash-wipe", @@ -14797,10 +14797,10 @@ { "role": "repeater", "title": "Repeater", - "subTitle": "LoRa OTA, no external sensors", + "subTitle": "LoRa OTA, selected optional sensor drivers omitted", "version": { "lora-ota-no-external-sensors-v1.17.1.1-759a35fc": { - "notes": "Keymind Cascade MeshCore v1.17.1.1 with Halo/Keymind retry tuning and Cascade defaults.\n\nROLE \u2014 Repeater: always-on infrastructure that relays mesh traffic. Configure its name/location, radio settings, and admin password. A repeater does not need an OTA-capable build merely to relay OTA traffic for other nodes.\n\nPROFILE \u2014 Compact LoRa OTA: can install LoRa OTA and keeps core role features plus board-native display/GPS, but removes optional external environmental-sensor drivers to make update staging fit. USB debug/packet logging is OFF.\n\nINSTALL \u2014 Browser flashing uses the Serial DFU .zip. The .uf2 is provided for manual drag-and-drop through a compatible UF2 bootloader volume. A normal firmware flash does not erase saved settings.\n\nLORA OTA \u2014 This page installs the base firmware, not a .mota package. Obtain and apply exact board-and-role .mota updates separately through the MeshCore LoRa OTA workflow; .mota files cannot be web flashed here.\n\nSELECTION \u2014 LoRa OTA, no external sensors.", + "notes": "Keymind Cascade MeshCore v1.17.1.1 with Halo/Keymind retry tuning and Cascade defaults.\n\nROLE \u2014 Repeater: always-on infrastructure that relays mesh traffic. Configure its name/location, radio settings, and admin password. A repeater does not need an OTA-capable build merely to relay OTA traffic for other nodes.\n\nPROFILE - Compact LoRa OTA: can install LoRa OTA while retaining this exact target's board functions. To fit update staging, it omits selected optional environmental/ranging drivers; I2C remains available and is not globally disabled. USB debug/packet logging is OFF.\n\nINSTALL \u2014 Browser flashing uses the Serial DFU .zip. The .uf2 is provided for manual drag-and-drop through a compatible UF2 bootloader volume. A normal firmware flash does not erase saved settings.\n\nLORA OTA \u2014 This page installs the base firmware, not a .mota package. Obtain and apply exact board-and-role .mota updates separately through the MeshCore LoRa OTA workflow; .mota files cannot be web flashed here.\n\nSELECTION \u2014 LoRa OTA, selected optional sensor drivers omitted.", "files": [ { "type": "flash", @@ -15014,10 +15014,10 @@ { "role": "repeater", "title": "Repeater", - "subTitle": "LoRa OTA, no external sensors", + "subTitle": "LoRa OTA, selected optional sensor drivers omitted", "version": { "lora-ota-no-external-sensors-v1.17.1.1-759a35fc": { - "notes": "Keymind Cascade MeshCore v1.17.1.1 with Halo/Keymind retry tuning and Cascade defaults.\n\nROLE \u2014 Repeater: always-on infrastructure that relays mesh traffic. Configure its name/location, radio settings, and admin password. A repeater does not need an OTA-capable build merely to relay OTA traffic for other nodes.\n\nPROFILE \u2014 Compact LoRa OTA: can install LoRa OTA and keeps core role features plus board-native display/GPS, but removes optional external environmental-sensor drivers to make update staging fit. USB debug/packet logging is OFF.\n\nINSTALL \u2014 First-time setup uses Full install (the merged bootloader + firmware image). Routine upgrades use Update (the app-only image) and normally preserve settings. Enable Erase all flash only when you intentionally want a clean reset.\n\nLORA OTA \u2014 This page installs the base firmware, not a .mota package. Obtain and apply exact board-and-role .mota updates separately through the MeshCore LoRa OTA workflow; .mota files cannot be web flashed here.\n\nSELECTION \u2014 LoRa OTA, no external sensors.", + "notes": "Keymind Cascade MeshCore v1.17.1.1 with Halo/Keymind retry tuning and Cascade defaults.\n\nROLE \u2014 Repeater: always-on infrastructure that relays mesh traffic. Configure its name/location, radio settings, and admin password. A repeater does not need an OTA-capable build merely to relay OTA traffic for other nodes.\n\nPROFILE - Compact LoRa OTA: can install LoRa OTA while retaining this exact target's board functions. To fit update staging, it omits selected optional environmental/ranging drivers; I2C remains available and is not globally disabled. USB debug/packet logging is OFF.\n\nINSTALL \u2014 First-time setup uses Full install (the merged bootloader + firmware image). Routine upgrades use Update (the app-only image) and normally preserve settings. Enable Erase all flash only when you intentionally want a clean reset.\n\nLORA OTA \u2014 This page installs the base firmware, not a .mota package. Obtain and apply exact board-and-role .mota updates separately through the MeshCore LoRa OTA workflow; .mota files cannot be web flashed here.\n\nSELECTION \u2014 LoRa OTA, selected optional sensor drivers omitted.", "files": [ { "type": "flash-wipe", diff --git a/mesh-america/update-provider-release.py b/mesh-america/update-provider-release.py index df724ffc..2d078dc5 100644 --- a/mesh-america/update-provider-release.py +++ b/mesh-america/update-provider-release.py @@ -79,6 +79,36 @@ LEGACY_COMPANION_ARTIFACT_SUFFIXES = ( "-ota", ) +REDUCED_PROFILE_MARKER = "_lora_ota_no_external_sensors" +REDUCED_PROFILE_DESCRIPTION = ( + "PROFILE - Compact LoRa OTA: can install LoRa OTA while retaining this " + "exact target's board functions. To fit update staging, it omits selected " + "optional environmental/ranging drivers; I2C remains available and is not " + "globally disabled. USB debug/packet logging is OFF." +) +REDUCED_PROFILE_SELECTION = "selected optional sensor drivers omitted" +RAK_INA_RETENTION_NOTE = ( + "RAK SENSOR RETENTION - This reduced RAK build retains INA219, INA226, " + "INA260, and INA3221 I2C current/voltage monitor drivers." +) +RAK3401_GPS_RETENTION_NOTE = ( + "GPS RETENTION - Compatible RAK3401 GPS options remain available: " + "RAK12500 over I2C and RAK12501 over UART." +) +RAK4631_GPS_RETENTION_NOTE = ( + "GPS RETENTION - Compatible RAK4631 GPS options remain available: " + "RAK12500 over I2C and RAK12501 over Serial1." +) +RAK4631_SERIAL1_GPS_OMISSION_NOTE = ( + "GPS / RS-232 - GPS is omitted from this RAK4631 build because the " + "RS-232 bridge owns Serial1." +) +REDUCED_PROFILE_SPECIAL_NOTE_PREFIXES = ( + "RAK SENSOR RETENTION ", + "GPS RETENTION ", + "GPS / RS-232 ", +) + def parse_args() -> argparse.Namespace: script_dir = Path(__file__).resolve().parent @@ -89,19 +119,18 @@ def parse_args() -> argparse.Namespace: default=script_dir / "keymind-cascade-v1.16.0-provider.json", help="existing curated provider catalog", ) - parser.add_argument("--release-dir", type=Path, required=True) + parser.add_argument("--release-dir", type=Path) parser.add_argument( "--artifact-version", - required=True, help="filename version token, for example v1.17.1.1-759a35fc", ) - parser.add_argument("--main-tag", required=True) - parser.add_argument("--advanced-tag", required=True) + parser.add_argument("--main-tag") + parser.add_argument("--advanced-tag") parser.add_argument( "--full-tag", help="FULL-profile release tag (defaults to --advanced-tag for compatibility)", ) - parser.add_argument("--utility-tag", required=True) + parser.add_argument("--utility-tag") parser.add_argument("--repo", default="mikecarper/MeshCore") parser.add_argument("--output", type=Path) parser.add_argument( @@ -117,8 +146,41 @@ def parse_args() -> argparse.Namespace: "all unaffected roles" ), ) + parser.add_argument( + "--normalize-reduced-metadata-only", + action="store_true", + help=( + "normalize Compact LoRa OTA descriptions in an existing catalog " + "without changing versions, files, or release URLs" + ), + ) args = parser.parse_args() - if not args.full_tag: + if args.normalize_reduced_metadata_only: + if args.companion_only: + parser.error( + "--companion-only cannot be combined with " + "--normalize-reduced-metadata-only" + ) + else: + required = ( + "release_dir", + "artifact_version", + "main_tag", + "advanced_tag", + "utility_tag", + ) + missing = [ + f"--{name.replace('_', '-')}" + for name in required + if not getattr(args, name) + ] + if missing: + parser.error( + "the following arguments are required unless " + "--normalize-reduced-metadata-only is used: " + + ", ".join(missing) + ) + if not args.full_tag and args.advanced_tag: args.full_tag = args.advanced_tag return args @@ -168,6 +230,99 @@ def ordered_catalog_identities(firmware: dict) -> list[str]: return identities +def is_reduced_lora_ota_profile(identities: list[str]) -> bool: + return any( + REDUCED_PROFILE_MARKER in identity.lower() + for identity in identities + ) + + +def reduced_profile_special_notes(identities: list[str]) -> list[str]: + lowered = [identity.lower() for identity in identities] + is_rak3401 = any(identity.startswith("rak_3401_") for identity in lowered) + is_rak4631 = any(identity.startswith("rak_4631_") for identity in lowered) + if not (is_rak3401 or is_rak4631): + return [] + + notes = [RAK_INA_RETENTION_NOTE] + if is_rak3401: + notes.append(RAK3401_GPS_RETENTION_NOTE) + elif any( + "_repeater_bridge_rs232_serial1_lora_ota_no_external_sensors" + in identity + for identity in lowered + ): + notes.append(RAK4631_SERIAL1_GPS_OMISSION_NOTE) + else: + notes.append(RAK4631_GPS_RETENTION_NOTE) + return notes + + +def normalize_reduced_profile_metadata( + firmware: dict, + notes: str, + identities: list[str], +) -> str: + """Describe reduced LoRa OTA profiles without claiming I2C is absent.""" + if not is_reduced_lora_ota_profile(identities): + return notes + + subtitle = firmware.get("subTitle") + if isinstance(subtitle, str): + firmware["subTitle"] = re.sub( + r"no external sensors", + REDUCED_PROFILE_SELECTION, + subtitle, + flags=re.IGNORECASE, + ) + + paragraphs: list[str] = [] + profile_found = False + for paragraph in notes.split("\n\n"): + if paragraph.startswith(REDUCED_PROFILE_SPECIAL_NOTE_PREFIXES): + continue + if paragraph.startswith("PROFILE ") and "Compact LoRa OTA" in paragraph: + paragraphs.append(REDUCED_PROFILE_DESCRIPTION) + paragraphs.extend(reduced_profile_special_notes(identities)) + profile_found = True + continue + if paragraph.startswith("SELECTION "): + paragraph = re.sub( + r"no external sensors", + REDUCED_PROFILE_SELECTION, + paragraph, + flags=re.IGNORECASE, + ) + paragraphs.append(paragraph) + + if not profile_found: + raise ValueError( + "reduced LoRa OTA catalog entry has no Compact LoRa OTA profile: " + + ", ".join(identities) + ) + return "\n\n".join(paragraphs) + + +def normalize_reduced_catalog_metadata(catalog: dict) -> int: + """Normalize every reduced entry in place and return its entry count.""" + normalized = 0 + for device in catalog["device"]: + for firmware in device["firmware"]: + identities = ordered_catalog_identities(firmware) + if not is_reduced_lora_ota_profile(identities): + continue + for version in firmware["version"].values(): + version["notes"] = normalize_reduced_profile_metadata( + firmware, + version["notes"], + identities, + ) + normalized += 1 + if normalized == 0: + raise ValueError("catalog has no reduced LoRa OTA entries to normalize") + return normalized + + def canonical_runtime_identity(identity: str) -> str: """Map legacy setting/profile names to the canonical release identity.""" result = re.sub( @@ -868,6 +1023,12 @@ def update_catalog(catalog: dict, release_files: dict[str, list[Path]], args: ar notes = replace_legacy_companion_ota_notes(notes) notes = append_partition_warning(notes, resolved_identities) + notes = normalize_reduced_profile_metadata( + firmware, + notes, + resolved_identities, + ) + if firmware["role"].startswith("companion"): notes = append_companion_power_saving_note(notes, display_version) notes = normalize_runtime_companion_metadata(firmware, notes) @@ -936,12 +1097,27 @@ def main() -> int: args = parse_args() output = args.output or args.catalog try: - if not args.artifact_version.startswith("v"): - raise ValueError("--artifact-version must start with 'v'") with args.catalog.open(encoding="utf-8") as handle: catalog = json.load(handle) - release_files = index_release_files(args.release_dir, args.artifact_version) - catalog = update_catalog(catalog, release_files, args) + if args.normalize_reduced_metadata_only: + normalized = normalize_reduced_catalog_metadata(catalog) + print( + json.dumps( + { + "normalized_reduced_entries": normalized, + "update_mode": "reduced-metadata-only", + }, + sort_keys=True, + ) + ) + else: + if not args.artifact_version.startswith("v"): + raise ValueError("--artifact-version must start with 'v'") + release_files = index_release_files( + args.release_dir, + args.artifact_version, + ) + catalog = update_catalog(catalog, release_files, args) if not args.check_only: output.parent.mkdir(parents=True, exist_ok=True) output.write_text( diff --git a/scripts/generate_cert_bundle.py b/scripts/generate_cert_bundle.py index a35acc17..8d540efc 100644 --- a/scripts/generate_cert_bundle.py +++ b/scripts/generate_cert_bundle.py @@ -15,22 +15,40 @@ from __future__ import with_statement from pathlib import Path import os -import struct -import sys -import requests -from io import open - -Import("env") - -try: - from cryptography import x509 - from cryptography.hazmat.backends import default_backend - from cryptography.hazmat.primitives import serialization -except ImportError: - env.Execute("$PYTHONEXE -m pip install cryptography") - from cryptography import x509 - from cryptography.hazmat.backends import default_backend - from cryptography.hazmat.primitives import serialization +import struct +import sys +import warnings +import requests +from io import open + +Import("env") + +try: + from cryptography import x509 + from cryptography.hazmat.backends import default_backend + from cryptography.hazmat.primitives import serialization + from cryptography.utils import CryptographyDeprecationWarning +except ImportError: + env.Execute("$PYTHONEXE -m pip install cryptography") + from cryptography import x509 + from cryptography.hazmat.backends import default_backend + from cryptography.hazmat.primitives import serialization + from cryptography.utils import CryptographyDeprecationWarning + + +# Common platform trust stores still contain serial-zero roots. ESP-IDF's +# bundle generator suppresses this narrowly while pyca retains parsing support: +# https://github.com/espressif/esp-idf/blob/master/components/mbedtls/esp_crt_bundle/gen_crt_bundle.py +# https://github.com/pyca/cryptography/issues/12948 +warnings.filterwarnings( + "ignore", + message=( + r"^Parsed a serial number which wasn't positive \(i.e., it was negative or zero\), " + r"which is disallowed by RFC 5280\. " + r"Loading this certificate will cause an exception in a future release of cryptography\.$" + ), + category=CryptographyDeprecationWarning, +) ca_bundle_bin_file = 'x509_crt_bundle.bin' diff --git a/src/MeshCore.h b/src/MeshCore.h index 394b2674..8b45f3d6 100644 --- a/src/MeshCore.h +++ b/src/MeshCore.h @@ -23,25 +23,98 @@ #define MAX_PATH_SIZE 64 #define MAX_TRANS_UNIT 255 +#if defined(ARDUINO) && defined(NRF52_PLATFORM) && \ + ((defined(MESH_DEBUG) && MESH_DEBUG) || \ + (defined(BRIDGE_DEBUG) && BRIDGE_DEBUG) || \ + (defined(POWERSAVING_DEBUG) && POWERSAVING_DEBUG)) + #include + #include + #include + #include + #include + #include "helpers/NonBlockingWriteStream.h" + +namespace mesh { + +// Adafruit_USBD_CDC::write() waits until the complete buffer has entered the +// TinyUSB FIFO. That is normally convenient, but it can wait forever when a +// host has opened the dedicated logging CDC without draining it. Keep debug +// output best-effort on nRF52: format into one bounded record, preserve a +// visible truncation marker, and submit it only when the whole record fits in +// the FIFO snapshot. The atomic flag also prevents two debug callers from both +// relying on the same availableForWrite() result. +inline size_t nrf52DebugPrintf(const char* format, ...) { + if (format == nullptr || !isUsbLoggingEnabled()) return 0; + + static std::atomic_flag writer_busy = ATOMIC_FLAG_INIT; + if (writer_busy.test_and_set(std::memory_order_acquire)) return 0; + + char output[256]; + va_list args; + va_start(args, format); + const int required = vsnprintf(output, sizeof(output), format, args); + va_end(args); + + size_t length = 0; + if (required > 0) { + length = static_cast(required); + if (length >= sizeof(output)) { + length = sizeof(output) - 1; + const size_t format_length = strlen(format); + const bool preserve_newline = format_length > 0 + && format[format_length - 1] == '\n'; + const size_t marker_length = preserve_newline ? 4 : 3; + memcpy(output + length - marker_length, "...", 3); + if (preserve_newline) output[length - 1] = '\n'; + } + } + + size_t written = 0; + if (length > 0) { + Stream& port = usbLoggingPort(); + written = port.write(reinterpret_cast(output), length); + } + + writer_busy.clear(std::memory_order_release); + return written; +} + +} // namespace mesh +#endif + #if MESH_DEBUG && ARDUINO #include - #define MESH_DEBUG_PRINT(F, ...) do { if (mesh::isUsbLoggingEnabled() && mesh::usbLoggingPort().availableForWrite() > 0) { mesh::usbLoggingPort().printf("DEBUG: " F, ##__VA_ARGS__); } } while(0) - #define MESH_DEBUG_PRINTLN(F, ...) do { if (mesh::isUsbLoggingEnabled() && mesh::usbLoggingPort().availableForWrite() > 0) { mesh::usbLoggingPort().printf("DEBUG: " F "\n", ##__VA_ARGS__); } } while(0) + #if defined(NRF52_PLATFORM) + #define MESH_DEBUG_PRINT(F, ...) do { mesh::nrf52DebugPrintf("DEBUG: " F, ##__VA_ARGS__); } while(0) + #define MESH_DEBUG_PRINTLN(F, ...) do { mesh::nrf52DebugPrintf("DEBUG: " F "\n", ##__VA_ARGS__); } while(0) + #else + #define MESH_DEBUG_PRINT(F, ...) do { if (mesh::isUsbLoggingEnabled() && mesh::usbLoggingPort().availableForWrite() > 0) { mesh::usbLoggingPort().printf("DEBUG: " F, ##__VA_ARGS__); } } while(0) + #define MESH_DEBUG_PRINTLN(F, ...) do { if (mesh::isUsbLoggingEnabled() && mesh::usbLoggingPort().availableForWrite() > 0) { mesh::usbLoggingPort().printf("DEBUG: " F "\n", ##__VA_ARGS__); } } while(0) + #endif #else #define MESH_DEBUG_PRINT(...) {} #define MESH_DEBUG_PRINTLN(...) {} #endif #if BRIDGE_DEBUG && ARDUINO -#define BRIDGE_DEBUG_PRINTLN(F, ...) do { if (mesh::isUsbLoggingEnabled() && mesh::usbLoggingPort().availableForWrite() > 0) { mesh::usbLoggingPort().printf("%s BRIDGE: " F, getLogDateTime(), ##__VA_ARGS__); } } while(0) + #if defined(NRF52_PLATFORM) + #define BRIDGE_DEBUG_PRINTLN(F, ...) do { mesh::nrf52DebugPrintf("%s BRIDGE: " F, getLogDateTime(), ##__VA_ARGS__); } while(0) + #else + #define BRIDGE_DEBUG_PRINTLN(F, ...) do { if (mesh::isUsbLoggingEnabled() && mesh::usbLoggingPort().availableForWrite() > 0) { mesh::usbLoggingPort().printf("%s BRIDGE: " F, getLogDateTime(), ##__VA_ARGS__); } } while(0) + #endif #else -#define BRIDGE_DEBUG_PRINTLN(...) {} + #define BRIDGE_DEBUG_PRINTLN(...) {} #endif #if POWERSAVING_DEBUG && ARDUINO #include - #define POWERSAVING_DEBUG_PRINT(F, ...) do { if (mesh::isUsbLoggingEnabled()) { mesh::usbLoggingPort().printf("POWERSAVING: " F, ##__VA_ARGS__); } } while(0) - #define POWERSAVING_DEBUG_PRINTLN(F, ...) do { if (mesh::isUsbLoggingEnabled()) { mesh::usbLoggingPort().printf("POWERSAVING: " F "\n", ##__VA_ARGS__); } } while(0) + #if defined(NRF52_PLATFORM) + #define POWERSAVING_DEBUG_PRINT(F, ...) do { mesh::nrf52DebugPrintf("POWERSAVING: " F, ##__VA_ARGS__); } while(0) + #define POWERSAVING_DEBUG_PRINTLN(F, ...) do { mesh::nrf52DebugPrintf("POWERSAVING: " F "\n", ##__VA_ARGS__); } while(0) + #else + #define POWERSAVING_DEBUG_PRINT(F, ...) do { if (mesh::isUsbLoggingEnabled()) { mesh::usbLoggingPort().printf("POWERSAVING: " F, ##__VA_ARGS__); } } while(0) + #define POWERSAVING_DEBUG_PRINTLN(F, ...) do { if (mesh::isUsbLoggingEnabled()) { mesh::usbLoggingPort().printf("POWERSAVING: " F "\n", ##__VA_ARGS__); } } while(0) + #endif #else #define POWERSAVING_DEBUG_PRINT(...) {} #define POWERSAVING_DEBUG_PRINTLN(...) {} diff --git a/src/helpers/ClientACL.cpp b/src/helpers/ClientACL.cpp index 652d68e6..3b10da64 100644 --- a/src/helpers/ClientACL.cpp +++ b/src/helpers/ClientACL.cpp @@ -1,11 +1,110 @@ #include "ClientACL.h" +#include "ClientACLFileTransaction.h" +#include "ClientACLFileIntegrity.h" +#include "ClientLoginPersistence.h" +#include "ClientPathPersistence.h" #if defined(NRF52_PLATFORM) #include "AtomicFileWriter.h" #endif static const uint8_t CONTACT_RECORD_VERSION_ALT_PATH = 1; +static const uint8_t EMPTY_OUT_PATH[MAX_PATH_SIZE] = {}; + +static File openRead(FILESYSTEM* fs, const char* filename) { +#if defined(RP2040_PLATFORM) + return fs->open(filename, "r"); +#else + return fs->open(filename); +#endif +} #if !defined(NRF52_PLATFORM) +static const size_t CONTACT_RECORD_SIZE = + 32 + 1 + 4 + 2 + 1 + 64 + PUB_KEY_SIZE + 1 + 64; +static const size_t LEGACY_CONTACT_RECORD_SIZE = + 32 + 1 + 4 + 2 + 1 + 64 + PUB_KEY_SIZE; +#endif + +static bool readPersistedClientPath(FILESYSTEM* fs, + const uint8_t pubkey[PUB_KEY_SIZE], + uint8_t* path_len, + uint8_t path[MAX_PATH_SIZE]) { + if (!fs->exists(mesh::CLIENT_ACL_PRIMARY_PATH)) return false; + File file = openRead(fs, mesh::CLIENT_ACL_PRIMARY_PATH); + if (!file) return false; + + bool found = false; + while (!found) { + uint8_t record_pubkey[PUB_KEY_SIZE]; + uint8_t permissions; + uint32_t sync_since; + uint8_t version[2]; + uint8_t record_path_len; + uint8_t record_path[MAX_PATH_SIZE]; + uint8_t shared_secret[PUB_KEY_SIZE]; + + bool success = file.read(record_pubkey, sizeof(record_pubkey)) + == (int)sizeof(record_pubkey); + success = success && file.read(&permissions, sizeof(permissions)) + == (int)sizeof(permissions); + success = success + && file.read((uint8_t*)&sync_since, sizeof(sync_since)) + == (int)sizeof(sync_since); + success = success && file.read(version, sizeof(version)) + == (int)sizeof(version); + success = success && file.read(&record_path_len, sizeof(record_path_len)) + == (int)sizeof(record_path_len); + success = success && file.read(record_path, sizeof(record_path)) + == (int)sizeof(record_path); + success = success && file.read(shared_secret, sizeof(shared_secret)) + == (int)sizeof(shared_secret); + if (!success) break; + + if (version[0] >= CONTACT_RECORD_VERSION_ALT_PATH) { + uint8_t alt_path_len; + uint8_t alt_path[MAX_PATH_SIZE]; + success = file.read(&alt_path_len, sizeof(alt_path_len)) + == (int)sizeof(alt_path_len); + success = success && file.read(alt_path, sizeof(alt_path)) + == (int)sizeof(alt_path); + if (!success) break; + } + + if (memcmp(record_pubkey, pubkey, PUB_KEY_SIZE) == 0 + && (record_path_len == OUT_PATH_UNKNOWN + || record_path_len == OUT_PATH_FORCE_FLOOD + || mesh::isValidEncodedClientPathLength( + record_path_len, MAX_PATH_SIZE))) { + *path_len = record_path_len; + memcpy(path, record_path, MAX_PATH_SIZE); + found = true; + } + } + file.close(); + return found; +} + +static mesh::StoredClientPathView storedClientPathForSave( + FILESYSTEM* fs, + const ClientInfo* client, + uint8_t prior_path[MAX_PATH_SIZE], + uint8_t* prior_path_len) { + const bool prior_exists = !client->out_path_is_persistable + && readPersistedClientPath( + fs, client->id.pub_key, prior_path_len, prior_path); + return mesh::selectStoredClientPath( + client->out_path_is_persistable, + client->out_path_len, + client->out_path, + prior_exists, + *prior_path_len, + prior_path, + OUT_PATH_UNKNOWN, + EMPTY_OUT_PATH); +} + +#if !defined(NRF52_PLATFORM) + static File openWrite(FILESYSTEM* _fs, const char* filename) { #if defined(STM32_PLATFORM) _fs->remove(filename); @@ -16,11 +115,337 @@ static File openWrite(FILESYSTEM* _fs, const char* filename) { return _fs->open(filename, "w", true); #endif } + +static bool readMatches(File& file, const uint8_t* expected, size_t length) { + uint8_t buffer[32]; + while (length > 0) { + const size_t chunk = length < sizeof(buffer) ? length : sizeof(buffer); + if (file.read(buffer, chunk) != (int)chunk + || memcmp(buffer, expected, chunk) != 0) { + return false; + } + expected += chunk; + length -= chunk; + } + return true; +} + +static bool validateContactsFileIntegrity(FILESYSTEM* fs, + const char* filename) { + File file = openRead(fs, filename); + if (!file) return false; + const size_t size = file.size(); + const bool has_crc = size >= 8 && (size - 8) % CONTACT_RECORD_SIZE == 0; + // A backup means publication was interrupted or is being finalized. In that + // state a primary without its CRC trailer is a torn new image, not a legacy + // image, even when truncation lands exactly on a record-size multiple. + const bool crc_required = strcmp(filename, mesh::CLIENT_ACL_PRIMARY_PATH) == 0 + && fs->exists(mesh::CLIENT_ACL_BACKUP_PATH); + if (!has_crc && size % CONTACT_RECORD_SIZE != 0 + && size % LEGACY_CONTACT_RECORD_SIZE != 0) { + file.close(); + return false; + } + if (!has_crc) { + file.close(); + return !crc_required; // standalone legacy fixed-record image + } + uint32_t crc = 0xFFFFFFFFUL; + size_t remaining = size - 8; + uint8_t buffer[32]; + while (remaining != 0) { + const size_t chunk = remaining < sizeof(buffer) ? remaining : sizeof(buffer); + if (file.read(buffer, chunk) != (int)chunk) { + file.close(); + return false; + } + crc = mesh::updateClientACLCRC(crc, buffer, chunk); + remaining -= chunk; + } + uint8_t magic[4]; + uint32_t stored_crc; + const bool valid = file.read(magic, sizeof(magic)) == (int)sizeof(magic) + && file.read((uint8_t*)&stored_crc, sizeof(stored_crc)) + == (int)sizeof(stored_crc) + && memcmp(magic, mesh::CLIENT_ACL_CRC_MAGIC, sizeof(magic)) == 0 + && stored_crc == (crc ^ 0xFFFFFFFFUL); + file.close(); + return valid; +} + +static bool verifyContactsFile( + FILESYSTEM* fs, + const char* filename, + ClientInfo* clients, + int num_clients, + bool (*filter)(ClientInfo*), + size_t expected_size) { + File file = openRead(fs, filename); + if (!file || file.size() != expected_size + 8) { + if (file) file.close(); + return false; + } + + const uint8_t unused[2] = {CONTACT_RECORD_VERSION_ALT_PATH, 0}; + bool matches = true; + for (int i = 0; matches && i < num_clients; i++) { + ClientInfo* c = &clients[i]; + if (!mesh::clientRoleIsPersisted( + c->permissions, PERM_ACL_ROLE_MASK, PERM_ACL_GUEST) + || (filter && !filter(c))) continue; + uint8_t prior_path_len = OUT_PATH_UNKNOWN; + uint8_t prior_path[MAX_PATH_SIZE] = {}; + const mesh::StoredClientPathView persisted_path = + storedClientPathForSave( + fs, c, prior_path, &prior_path_len); + matches = readMatches(file, c->id.pub_key, 32) + && readMatches(file, &c->permissions, 1) + && readMatches(file, (uint8_t*)&c->extra.room.sync_since, 4) + && readMatches(file, unused, sizeof(unused)) + && readMatches(file, &persisted_path.encoded_path_len, 1) + && readMatches(file, persisted_path.path, 64) + && readMatches(file, c->shared_secret, PUB_KEY_SIZE) + && readMatches(file, &c->alt_path_len, 1) + && readMatches(file, c->alt_path, 64); + } + uint8_t magic[4]; + uint32_t stored_crc; + matches = matches + && file.read(magic, sizeof(magic)) == (int)sizeof(magic) + && file.read((uint8_t*)&stored_crc, sizeof(stored_crc)) + == (int)sizeof(stored_crc) + && memcmp(magic, mesh::CLIENT_ACL_CRC_MAGIC, sizeof(magic)) == 0; + file.close(); + return matches && validateContactsFileIntegrity(fs, filename); +} #endif +static bool validateLoginReplayFileIntegrity(FILESYSTEM* fs, + const char* filename) { + File file = openRead(fs, filename); + if (!file) return false; + const size_t size = file.size(); + if (size < mesh::CLIENT_LOGIN_REPLAY_TRAILER_SIZE) { + file.close(); + return false; + } + const size_t payload_size = + size - mesh::CLIENT_LOGIN_REPLAY_TRAILER_SIZE; + const size_t record_count = + payload_size / mesh::CLIENT_LOGIN_REPLAY_RECORD_SIZE; + if (payload_size % mesh::CLIENT_LOGIN_REPLAY_RECORD_SIZE != 0 + || record_count > mesh::MAX_CLIENT_LOGIN_REPLAY_IDENTITIES) { + file.close(); + return false; + } + + uint32_t crc = 0xFFFFFFFFUL; + bool valid = true; + for (size_t i = 0; valid && i < record_count; i++) { + uint8_t pubkey[PUB_KEY_SIZE]; + uint32_t ceiling; + valid = file.read(pubkey, sizeof(pubkey)) == (int)sizeof(pubkey) + && file.read((uint8_t*)&ceiling, sizeof(ceiling)) + == (int)sizeof(ceiling) + && ceiling != 0; + if (valid) { + crc = mesh::updateClientLoginReplayCRC( + crc, pubkey, sizeof(pubkey)); + crc = mesh::updateClientLoginReplayCRC( + crc, (const uint8_t*)&ceiling, sizeof(ceiling)); + } + } + uint8_t magic[sizeof(mesh::CLIENT_LOGIN_REPLAY_MAGIC)]; + uint32_t stored_crc; + valid = valid + && file.read(magic, sizeof(magic)) == (int)sizeof(magic) + && file.read((uint8_t*)&stored_crc, sizeof(stored_crc)) + == (int)sizeof(stored_crc) + && memcmp(magic, mesh::CLIENT_LOGIN_REPLAY_MAGIC, + sizeof(magic)) == 0 + && stored_crc == (crc ^ 0xFFFFFFFFUL); + file.close(); + return valid; +} + +// The image is validated before this scan. Choose the maximum if a legacy or +// manually recovered image somehow contains a duplicate identity; taking the +// strongest boundary remains fail-safe. +static bool readClientLoginReplayCeiling( + FILESYSTEM* fs, + const uint8_t pubkey[PUB_KEY_SIZE], + uint32_t* ceiling, + bool* found) { + *ceiling = 0; + *found = false; + if (!fs->exists(mesh::CLIENT_LOGIN_REPLAY_PRIMARY_PATH)) return true; + if (!validateLoginReplayFileIntegrity( + fs, mesh::CLIENT_LOGIN_REPLAY_PRIMARY_PATH)) { + return false; + } + File file = openRead(fs, mesh::CLIENT_LOGIN_REPLAY_PRIMARY_PATH); + if (!file) return false; + const size_t record_count = + (file.size() - mesh::CLIENT_LOGIN_REPLAY_TRAILER_SIZE) + / mesh::CLIENT_LOGIN_REPLAY_RECORD_SIZE; + bool success = true; + for (size_t i = 0; success && i < record_count; i++) { + uint8_t record_pubkey[PUB_KEY_SIZE]; + uint32_t record_ceiling; + success = file.read(record_pubkey, sizeof(record_pubkey)) + == (int)sizeof(record_pubkey); + success = success + && file.read((uint8_t*)&record_ceiling, sizeof(record_ceiling)) + == (int)sizeof(record_ceiling); + if (success && memcmp(record_pubkey, pubkey, PUB_KEY_SIZE) == 0 + && (!*found || record_ceiling > *ceiling)) { + *ceiling = record_ceiling; + *found = true; + } + } + file.close(); + return success; +} + +template +static bool writeClientLoginReplayRecord( + Writer& writer, + const uint8_t pubkey[PUB_KEY_SIZE], + uint32_t ceiling, + uint32_t* crc) { + const bool success = writer.write(pubkey, PUB_KEY_SIZE) == PUB_KEY_SIZE + && writer.write((const uint8_t*)&ceiling, sizeof(ceiling)) + == sizeof(ceiling); + if (success) { + *crc = mesh::updateClientLoginReplayCRC( + *crc, pubkey, PUB_KEY_SIZE); + *crc = mesh::updateClientLoginReplayCRC( + *crc, (const uint8_t*)&ceiling, sizeof(ceiling)); + } + return success; +} + +static bool writeClientLoginReplayCeiling( + FILESYSTEM* fs, + const uint8_t pubkey[PUB_KEY_SIZE], + uint32_t new_ceiling, + mesh::ClientLoginReplayReservationAction action) { + if (action == mesh::ClientLoginReplayReservationAction::None) return true; +#if defined(NRF52_PLATFORM) + if (fs->exists(mesh::CLIENT_LOGIN_REPLAY_PRIMARY_PATH) + && !validateLoginReplayFileIntegrity( + fs, mesh::CLIENT_LOGIN_REPLAY_PRIMARY_PATH)) { + return false; + } +#else + if (!mesh::recoverClientLoginReplayFiles( + fs, validateLoginReplayFileIntegrity)) { + return false; + } +#endif + + File source = openRead(fs, mesh::CLIENT_LOGIN_REPLAY_PRIMARY_PATH); + const size_t record_count = source + ? (source.size() - mesh::CLIENT_LOGIN_REPLAY_TRAILER_SIZE) + / mesh::CLIENT_LOGIN_REPLAY_RECORD_SIZE + : 0; +#if defined(NRF52_PLATFORM) + mesh::AtomicFileWriter destination( + fs, mesh::CLIENT_LOGIN_REPLAY_PRIMARY_PATH); +#else + File destination = openWrite( + fs, mesh::CLIENT_LOGIN_REPLAY_TEMP_PATH); +#endif + if (!destination) { + if (source) source.close(); + return false; + } + + uint32_t crc = 0xFFFFFFFFUL; + bool found = false; + bool success = true; + for (size_t i = 0; success && i < record_count; i++) { + uint8_t record_pubkey[PUB_KEY_SIZE]; + uint32_t record_ceiling; + success = source.read(record_pubkey, sizeof(record_pubkey)) + == (int)sizeof(record_pubkey); + success = success + && source.read((uint8_t*)&record_ceiling, sizeof(record_ceiling)) + == (int)sizeof(record_ceiling); + if (!success) break; + if (memcmp(record_pubkey, pubkey, PUB_KEY_SIZE) == 0) { + found = true; + if (record_ceiling < new_ceiling) record_ceiling = new_ceiling; + } + success = writeClientLoginReplayRecord( + destination, record_pubkey, record_ceiling, &crc); + } + if (source) source.close(); + + if (success + && action == mesh::ClientLoginReplayReservationAction::UpdateExisting + && !found) { + // Do not turn a low-trust update into insertion if the image changed + // between its admission read and this transaction. + success = false; + } + if (success && !found + && action == mesh::ClientLoginReplayReservationAction::CreateNew) { + success = mesh::clientLoginReplayCanInsert(record_count); + } + if (success && !found) { + success = writeClientLoginReplayRecord( + destination, pubkey, new_ceiling, &crc); + } + if (success) { + const uint32_t final_crc = crc ^ 0xFFFFFFFFUL; + success = destination.write( + mesh::CLIENT_LOGIN_REPLAY_MAGIC, + sizeof(mesh::CLIENT_LOGIN_REPLAY_MAGIC)) + == sizeof(mesh::CLIENT_LOGIN_REPLAY_MAGIC) + && destination.write( + (const uint8_t*)&final_crc, sizeof(final_crc)) + == sizeof(final_crc); + } + +#if defined(NRF52_PLATFORM) + return destination.commit(success); +#else + destination.close(); + const bool verified = success && validateLoginReplayFileIntegrity( + fs, mesh::CLIENT_LOGIN_REPLAY_TEMP_PATH); + return mesh::publishClientLoginReplayTemp( + fs, verified, validateLoginReplayFileIntegrity); +#endif +} + void ClientACL::load(FILESYSTEM* fs, const mesh::LocalIdentity& self_id) { _fs = fs; num_clients = 0; +#if defined(NRF52_PLATFORM) + // AtomicFileWriter may leave only a harmless temp image when reset before + // rename. The live image remains authoritative. + mesh::removeClientLoginReplayArtifact( + _fs, mesh::CLIENT_LOGIN_REPLAY_TEMP_PATH); + login_replay_store_available = + !_fs->exists(mesh::CLIENT_LOGIN_REPLAY_PRIMARY_PATH) + || validateLoginReplayFileIntegrity( + _fs, mesh::CLIENT_LOGIN_REPLAY_PRIMARY_PATH); +#else + login_replay_store_available = mesh::recoverClientLoginReplayFiles( + _fs, validateLoginReplayFileIntegrity); +#endif + if (!login_replay_store_available) { + MESH_DEBUG_PRINTLN( + "ERROR: ClientACL::load could not recover login replay state"); + } +#if !defined(NRF52_PLATFORM) + if (!mesh::recoverClientACLFilesVerified( + _fs, validateContactsFileIntegrity)) { + MESH_DEBUG_PRINTLN("ERROR: ClientACL::load could not recover contacts files"); + return; + } +#endif if (_fs->exists("/s_contacts")) { #if defined(RP2040_PLATFORM) File file = _fs->open("/s_contacts", "r"); @@ -52,6 +477,22 @@ void ClientACL::load(FILESYSTEM* fs, const mesh::LocalIdentity& self_id) { if (!success) break; // EOF c.id = mesh::Identity(pub_key); + c.out_path_is_persistable = true; + if (login_replay_store_available) { + uint32_t replay_ceiling; + bool replay_entry_found; + if (!readClientLoginReplayCeiling( + _fs, pub_key, &replay_ceiling, &replay_entry_found)) { + login_replay_store_available = false; + c.last_timestamp = UINT32_MAX; + } else if (replay_entry_found) { + // On boot, skip the unused reservation tail. During this boot a + // successful login replaces this with its exact accepted value. + c.last_timestamp = replay_ceiling; + } + } else { + c.last_timestamp = UINT32_MAX; + } self_id.calcSharedSecret(c.shared_secret, pub_key); // recalculate shared secrets in case our private key changed if (num_clients < MAX_CLIENTS) { clients[num_clients++] = c; @@ -64,43 +505,146 @@ void ClientACL::load(FILESYSTEM* fs, const mesh::LocalIdentity& self_id) { } } -void ClientACL::save(FILESYSTEM* fs, bool (*filter)(ClientInfo*)) { +bool ClientACL::authorizeLoginTimestamp( + const uint8_t* pubkey, + uint32_t sender_timestamp, + uint32_t runtime_last_timestamp, + uint8_t login_permissions) { + if (_fs == NULL || pubkey == NULL || !login_replay_store_available) { + return false; + } + + uint32_t persisted_ceiling; + bool replay_entry_found; + if (!readClientLoginReplayCeiling( + _fs, pubkey, &persisted_ceiling, &replay_entry_found)) { + login_replay_store_available = false; + return false; + } + if (!replay_entry_found) persisted_ceiling = 0; + + const bool role_needs_durable_replay = + mesh::clientLoginRoleNeedsDurableReplay( + login_permissions, PERM_ACL_ROLE_MASK, + PERM_ACL_GUEST, PERM_ACL_READ_ONLY); + const mesh::ClientLoginReplayPlan plan = mesh::planClientLoginReplay( + runtime_last_timestamp, persisted_ceiling, sender_timestamp); + if (!plan.fresh) return false; + const mesh::ClientLoginReplayReservationAction action = + mesh::selectClientLoginReplayReservation( + plan.reservation_needed, replay_entry_found, + role_needs_durable_replay); + if (action != mesh::ClientLoginReplayReservationAction::None + && !writeClientLoginReplayCeiling( + _fs, pubkey, plan.reservation_ceiling, action)) { + // The old valid image remains usable after an ordinary write failure, but + // this login is not safe to accept until its new boundary is durable. + return false; + } + return true; +} + +bool ClientACL::save(FILESYSTEM* fs, bool (*filter)(ClientInfo*)) { _fs = fs; #if defined(NRF52_PLATFORM) mesh::AtomicFileWriter file(_fs, "/s_contacts"); #else - File file = openWrite(_fs, "/s_contacts"); -#endif - if (file) { - uint8_t unused[2]; - unused[0] = CONTACT_RECORD_VERSION_ALT_PATH; - unused[1] = 0; - - bool success = true; - for (int i = 0; success && i < num_clients; i++) { - auto c = &clients[i]; - if (c->permissions == 0 || (filter && !filter(c))) continue; // skip deleted entries, or by filter function - - success = (file.write(c->id.pub_key, 32) == 32); - success = success && (file.write((uint8_t *) &c->permissions, 1) == 1); - success = success && (file.write((uint8_t *) &c->extra.room.sync_since, 4) == 4); - success = success && (file.write(unused, 2) == 2); - success = success && (file.write((uint8_t *)&c->out_path_len, 1) == 1); - success = success && (file.write(c->out_path, 64) == 64); - success = success && (file.write(c->shared_secret, PUB_KEY_SIZE) == PUB_KEY_SIZE); - success = success && (file.write((uint8_t *)&c->alt_path_len, 1) == 1); - success = success && (file.write(c->alt_path, 64) == 64); - - if (!success) break; // write failed - } -#if defined(NRF52_PLATFORM) - if (!file.commit(success)) { - MESH_DEBUG_PRINTLN("ERROR: ClientACL::save atomic commit failed"); - } -#else - file.close(); -#endif + if (!mesh::recoverClientACLFilesVerified( + _fs, validateContactsFileIntegrity)) { + MESH_DEBUG_PRINTLN("ERROR: ClientACL::save recovery is incomplete"); + return false; } + File file = openWrite(_fs, mesh::CLIENT_ACL_TEMP_PATH); +#endif + if (!file) { + MESH_DEBUG_PRINTLN("ERROR: ClientACL::save could not open contacts file"); + return false; + } + + uint8_t unused[2]; + unused[0] = CONTACT_RECORD_VERSION_ALT_PATH; + unused[1] = 0; + + bool success = true; +#if !defined(NRF52_PLATFORM) + size_t expected_size = 0; + uint32_t contacts_crc = 0xFFFFFFFFUL; +#endif + for (int i = 0; success && i < num_clients; i++) { + auto c = &clients[i]; + if (!mesh::clientRoleIsPersisted( + c->permissions, PERM_ACL_ROLE_MASK, PERM_ACL_GUEST) + || (filter && !filter(c))) continue; // skip guests/deleted entries or filtered clients + + success = (file.write(c->id.pub_key, 32) == 32); + success = success && (file.write((uint8_t *) &c->permissions, 1) == 1); + success = success && (file.write((uint8_t *) &c->extra.room.sync_since, 4) == 4); + success = success && (file.write(unused, 2) == 2); + uint8_t prior_path_len = OUT_PATH_UNKNOWN; + uint8_t prior_path[MAX_PATH_SIZE] = {}; + const mesh::StoredClientPathView persisted_path = + storedClientPathForSave( + _fs, c, prior_path, &prior_path_len); + success = success + && (file.write(&persisted_path.encoded_path_len, 1) == 1); + success = success + && (file.write(persisted_path.path, MAX_PATH_SIZE) == MAX_PATH_SIZE); + success = success && (file.write(c->shared_secret, PUB_KEY_SIZE) == PUB_KEY_SIZE); + success = success && (file.write((uint8_t *)&c->alt_path_len, 1) == 1); + success = success && (file.write(c->alt_path, 64) == 64); + + #if !defined(NRF52_PLATFORM) + if (success) { + expected_size += 32 + 1 + 4 + 2 + 1 + 64 + PUB_KEY_SIZE + 1 + 64; + contacts_crc = mesh::updateClientACLCRC(contacts_crc, c->id.pub_key, 32); + contacts_crc = mesh::updateClientACLCRC(contacts_crc, &c->permissions, 1); + contacts_crc = mesh::updateClientACLCRC( + contacts_crc, (uint8_t*)&c->extra.room.sync_since, 4); + contacts_crc = mesh::updateClientACLCRC(contacts_crc, unused, sizeof(unused)); + contacts_crc = mesh::updateClientACLCRC( + contacts_crc, &persisted_path.encoded_path_len, 1); + contacts_crc = mesh::updateClientACLCRC( + contacts_crc, persisted_path.path, MAX_PATH_SIZE); + contacts_crc = mesh::updateClientACLCRC(contacts_crc, c->shared_secret, PUB_KEY_SIZE); + contacts_crc = mesh::updateClientACLCRC(contacts_crc, &c->alt_path_len, 1); + contacts_crc = mesh::updateClientACLCRC(contacts_crc, c->alt_path, 64); + } + #endif + } +#if !defined(NRF52_PLATFORM) + if (success) { + const uint32_t final_crc = contacts_crc ^ 0xFFFFFFFFUL; + success = file.write(mesh::CLIENT_ACL_CRC_MAGIC, + sizeof(mesh::CLIENT_ACL_CRC_MAGIC)) + == sizeof(mesh::CLIENT_ACL_CRC_MAGIC) + && file.write((const uint8_t*)&final_crc, sizeof(final_crc)) + == sizeof(final_crc); + } +#endif +#if defined(NRF52_PLATFORM) + success = file.commit(success); + if (!success) { + MESH_DEBUG_PRINTLN("ERROR: ClientACL::save atomic commit failed"); + } +#else + file.close(); + if (success) { + success = verifyContactsFile( + _fs, mesh::CLIENT_ACL_TEMP_PATH, clients, num_clients, + filter, expected_size); + } + if (!success) { + mesh::publishVerifiedClientACLTemp(_fs, false); + MESH_DEBUG_PRINTLN("ERROR: ClientACL::save close/readback failed"); + return false; + } + success = mesh::publishVerifiedClientACLTemp( + _fs, true, validateContactsFileIntegrity); + if (!success) { + MESH_DEBUG_PRINTLN("ERROR: ClientACL::save atomic publish failed"); + } +#endif + return success; } bool ClientACL::clear() { @@ -108,14 +652,14 @@ bool ClientACL::clear() { if (_fs->exists("/s_contacts")) { _fs->remove("/s_contacts"); } -#if defined(NRF52_PLATFORM) - if (_fs->exists("/s_contacts.tmp")) { - _fs->remove("/s_contacts.tmp"); - } -#endif + if (_fs->exists("/s_contacts.tmp")) _fs->remove("/s_contacts.tmp"); + if (_fs->exists("/s_contacts.bak")) _fs->remove("/s_contacts.bak"); + const bool files_cleared = !_fs->exists("/s_contacts") + && !_fs->exists("/s_contacts.tmp") + && !_fs->exists("/s_contacts.bak"); memset(clients, 0, sizeof(clients)); num_clients = 0; - return true; + return files_cleared; } ClientInfo* ClientACL::getClient(const uint8_t* pubkey, int key_len) { @@ -148,6 +692,7 @@ ClientInfo* ClientACL::putClient(const mesh::Identity& id, uint8_t init_perms) { c->permissions = init_perms; c->id = id; c->out_path_len = OUT_PATH_UNKNOWN; + c->out_path_is_persistable = true; c->alt_path_len = OUT_PATH_UNKNOWN; return c; } diff --git a/src/helpers/ClientACL.h b/src/helpers/ClientACL.h index 555b7a0d..bf609b21 100644 --- a/src/helpers/ClientACL.h +++ b/src/helpers/ClientACL.h @@ -20,10 +20,11 @@ struct ClientInfo { uint8_t permissions; uint8_t out_path_len; uint8_t out_path[MAX_PATH_SIZE]; + bool out_path_is_persistable; // live route may replace, but not erase, durable route uint8_t alt_path_len; uint8_t alt_path[MAX_PATH_SIZE]; uint8_t shared_secret[PUB_KEY_SIZE]; - uint32_t last_timestamp; // by THEIR clock (transient) + uint32_t last_timestamp; // by THEIR clock (exact live floor; reserved ceiling after load) uint32_t last_activity; // by OUR clock (transient) union { struct { @@ -50,16 +51,32 @@ class ClientACL { FILESYSTEM* _fs; ClientInfo clients[MAX_CLIENTS]; int num_clients; + bool login_replay_store_available; public: - ClientACL() { + ClientACL() { + _fs = NULL; memset(clients, 0, sizeof(clients)); num_clients = 0; + login_replay_store_available = false; } void load(FILESYSTEM* _fs, const mesh::LocalIdentity& self_id); - void save(FILESYSTEM* _fs, bool (*filter)(ClientInfo*)=NULL); + bool save(FILESYSTEM* _fs, bool (*filter)(ClientInfo*)=NULL); bool clear(); + // Authenticate the sender/password first, then call this before allocating + // or mutating a client. A true result means any required replay high-water + // reservation was durably published. runtime_last_timestamp is zero when + // this boot has no live state for the identity (including after eviction or + // revocation), in which case the durable tombstone is authoritative. + // login_permissions selects whether a missing identity may allocate durable + // state. Guest/read-only sessions still enforce and advance an existing + // tombstone, but otherwise use only their exact in-boot floor. + bool authorizeLoginTimestamp(const uint8_t* pubkey, + uint32_t sender_timestamp, + uint32_t runtime_last_timestamp, + uint8_t login_permissions); + ClientInfo* getClient(const uint8_t* pubkey, int key_len); ClientInfo* putClient(const mesh::Identity& id, uint8_t init_perms); bool applyPermissions(const mesh::LocalIdentity& self_id, const uint8_t* pubkey, int key_len, uint8_t perms); diff --git a/src/helpers/ClientACLFileIntegrity.h b/src/helpers/ClientACLFileIntegrity.h new file mode 100644 index 00000000..f5018916 --- /dev/null +++ b/src/helpers/ClientACLFileIntegrity.h @@ -0,0 +1,49 @@ +#pragma once + +#include +#include +#include + +namespace mesh { + +static const uint8_t CLIENT_ACL_CRC_MAGIC[4] = {'M', 'C', 'A', '1'}; + +inline uint32_t updateClientACLCRC(uint32_t crc, const uint8_t* data, + size_t length) { + while (length-- != 0) { + crc ^= *data++; + for (uint8_t bit = 0; bit < 8; bit++) { + crc = (crc >> 1) ^ (0xEDB88320UL & (uint32_t)-(int32_t)(crc & 1U)); + } + } + return crc; +} + +enum ClientACLImageKind { + CLIENT_ACL_IMAGE_INVALID, + CLIENT_ACL_IMAGE_LEGACY, + CLIENT_ACL_IMAGE_CRC, +}; + +inline ClientACLImageKind classifyClientACLImage( + const uint8_t* image, size_t size, + size_t current_record_size, size_t legacy_record_size) { + if (size >= 8 && (size - 8) % current_record_size == 0) { + const size_t payload_size = size - 8; + uint32_t stored_crc; + memcpy(&stored_crc, image + payload_size + 4, sizeof(stored_crc)); + uint32_t crc = updateClientACLCRC(0xFFFFFFFFUL, image, payload_size) + ^ 0xFFFFFFFFUL; + if (memcmp(image + payload_size, CLIENT_ACL_CRC_MAGIC, 4) == 0 + && stored_crc == crc) { + return CLIENT_ACL_IMAGE_CRC; + } + } + if (size % current_record_size == 0 + || size % legacy_record_size == 0) { + return CLIENT_ACL_IMAGE_LEGACY; + } + return CLIENT_ACL_IMAGE_INVALID; +} + +} // namespace mesh diff --git a/src/helpers/ClientACLFileTransaction.h b/src/helpers/ClientACLFileTransaction.h new file mode 100644 index 00000000..bf940237 --- /dev/null +++ b/src/helpers/ClientACLFileTransaction.h @@ -0,0 +1,119 @@ +#pragma once + +namespace mesh { + +static const char CLIENT_ACL_PRIMARY_PATH[] = "/s_contacts"; +static const char CLIENT_ACL_TEMP_PATH[] = "/s_contacts.tmp"; +static const char CLIENT_ACL_BACKUP_PATH[] = "/s_contacts.bak"; + +template +bool removeClientACLArtifact(Filesystem* fs, const char* path) { + if (!fs->exists(path)) return true; + fs->remove(path); + return !fs->exists(path); +} + +// Recover the last published image after any power-loss boundary in the +// temp -> backup -> primary transaction. A present primary is authoritative; +// if publication had not completed, the backup is restored instead. +template +bool recoverClientACLFiles(Filesystem* fs) { + if (fs->exists(CLIENT_ACL_PRIMARY_PATH)) { + // The primary is already authoritative. Cleanup is best-effort: a + // filesystem which cannot remove a stale artifact must not make the + // committed image appear unavailable. + removeClientACLArtifact(fs, CLIENT_ACL_TEMP_PATH); + removeClientACLArtifact(fs, CLIENT_ACL_BACKUP_PATH); + return true; + } + if (fs->exists(CLIENT_ACL_BACKUP_PATH)) { + if (!fs->rename(CLIENT_ACL_BACKUP_PATH, CLIENT_ACL_PRIMARY_PATH)) { + return false; + } + removeClientACLArtifact(fs, CLIENT_ACL_TEMP_PATH); + return true; + } + return removeClientACLArtifact(fs, CLIENT_ACL_TEMP_PATH); +} + +template +bool recoverClientACLFilesVerified(Filesystem* fs, Validator is_valid) { + if (fs->exists(CLIENT_ACL_PRIMARY_PATH) + && is_valid(fs, CLIENT_ACL_PRIMARY_PATH)) { + // A validated primary remains authoritative even when stale-artifact + // cleanup is temporarily unavailable. + removeClientACLArtifact(fs, CLIENT_ACL_TEMP_PATH); + removeClientACLArtifact(fs, CLIENT_ACL_BACKUP_PATH); + return true; + } + if (fs->exists(CLIENT_ACL_BACKUP_PATH) + && is_valid(fs, CLIENT_ACL_BACKUP_PATH)) { + if (!removeClientACLArtifact(fs, CLIENT_ACL_PRIMARY_PATH) + || !fs->rename(CLIENT_ACL_BACKUP_PATH, CLIENT_ACL_PRIMARY_PATH)) { + return false; + } + removeClientACLArtifact(fs, CLIENT_ACL_TEMP_PATH); + return true; + } + return !fs->exists(CLIENT_ACL_PRIMARY_PATH) + && !fs->exists(CLIENT_ACL_BACKUP_PATH) + && removeClientACLArtifact(fs, CLIENT_ACL_TEMP_PATH); +} + +template +bool publishVerifiedClientACLTemp(Filesystem* fs, bool temp_verified) { + if (!temp_verified) { + removeClientACLArtifact(fs, CLIENT_ACL_TEMP_PATH); + return false; + } + if (fs->exists(CLIENT_ACL_BACKUP_PATH)) return false; + + const bool had_primary = fs->exists(CLIENT_ACL_PRIMARY_PATH); + if (had_primary + && !fs->rename(CLIENT_ACL_PRIMARY_PATH, CLIENT_ACL_BACKUP_PATH)) { + return false; + } + if (!fs->rename(CLIENT_ACL_TEMP_PATH, CLIENT_ACL_PRIMARY_PATH)) { + // Restore the last committed image immediately when possible. If this + // rename also fails, leave both artifacts for recoverClientACLFiles(). + if (had_primary && !fs->exists(CLIENT_ACL_PRIMARY_PATH)) { + fs->rename(CLIENT_ACL_BACKUP_PATH, CLIENT_ACL_PRIMARY_PATH); + } + return false; + } + + // A stale backup does not make the newly published primary unsuccessful; + // recovery removes it before the next load/save. + removeClientACLArtifact(fs, CLIENT_ACL_BACKUP_PATH); + return true; +} + +template +bool publishVerifiedClientACLTemp(Filesystem* fs, bool temp_verified, + Validator is_valid) { + if (!temp_verified) { + removeClientACLArtifact(fs, CLIENT_ACL_TEMP_PATH); + return false; + } + if (fs->exists(CLIENT_ACL_BACKUP_PATH)) return false; + const bool had_primary = fs->exists(CLIENT_ACL_PRIMARY_PATH); + if (had_primary + && !fs->rename(CLIENT_ACL_PRIMARY_PATH, CLIENT_ACL_BACKUP_PATH)) { + return false; + } + if (!fs->rename(CLIENT_ACL_TEMP_PATH, CLIENT_ACL_PRIMARY_PATH) + || !is_valid(fs, CLIENT_ACL_PRIMARY_PATH)) { + removeClientACLArtifact(fs, CLIENT_ACL_PRIMARY_PATH); + if (had_primary) { + fs->rename(CLIENT_ACL_BACKUP_PATH, CLIENT_ACL_PRIMARY_PATH); + } + return false; + } + // Publication committed at the successful temp -> primary rename and + // validation. A stale backup is recoverable housekeeping, not a failed + // save (which could otherwise make RAM roll back while disk holds new data). + removeClientACLArtifact(fs, CLIENT_ACL_BACKUP_PATH); + return true; +} + +} // namespace mesh diff --git a/src/helpers/ClientLoginPersistence.h b/src/helpers/ClientLoginPersistence.h new file mode 100644 index 00000000..1d7e4742 --- /dev/null +++ b/src/helpers/ClientLoginPersistence.h @@ -0,0 +1,298 @@ +#pragma once + +#include +#include +#include + +namespace mesh { + +// Login timestamps are security state, not ordinary contact activity. Keep +// their durable high-water marks outside /s_contacts so an ACL eviction or an +// explicit role revocation cannot erase the replay boundary with the contact. +static const char CLIENT_LOGIN_REPLAY_PRIMARY_PATH[] = "/s_login_replay"; +static const char CLIENT_LOGIN_REPLAY_TEMP_PATH[] = "/s_login_replay.tmp"; +static const char CLIENT_LOGIN_REPLAY_BACKUP_PATH[] = "/s_login_replay.bak"; +static const uint8_t CLIENT_LOGIN_REPLAY_MAGIC[4] = {'M', 'C', 'R', '1'}; +static const size_t CLIENT_LOGIN_REPLAY_RECORD_SIZE = 32 + sizeof(uint32_t); +static const size_t CLIENT_LOGIN_REPLAY_TRAILER_SIZE = + sizeof(CLIENT_LOGIN_REPLAY_MAGIC) + sizeof(uint32_t); + +// A successful login reserves this many sender-clock seconds in one durable +// write. Further monotonically increasing logins inside the reservation need +// no flash write. After a reset, the unused tail is deliberately skipped; +// this is the availability cost that prevents an already accepted timestamp +// from becoming fresh again. One minute bounds that cost while coalescing +// rapid reconnects and Bluetooth/Wi-Fi transport changes. +static const uint32_t CLIENT_LOGIN_REPLAY_RESERVATION_SECONDS = 60; + +// This is deliberately larger than the live ACL. Historical entries are +// privileged tombstones and may not be evicted safely while their password +// remains usable. A full store fails closed for a new privileged identity +// rather than silently discarding an older replay boundary; low-trust roles do +// not allocate records. +static const size_t MAX_CLIENT_LOGIN_REPLAY_IDENTITIES = 128; + +struct ClientLoginReplayPlan { + bool fresh; + bool reservation_needed; + uint32_t reservation_ceiling; +}; + +enum class ClientLoginReplayReservationAction : uint8_t { + None, + UpdateExisting, + CreateNew, +}; + +// Guest and read-only sessions can consume public or deliberately low-trust +// credentials. They still obey the exact in-boot floor and any historical +// privileged tombstone, but must not allocate a new permanent identity in the +// bounded replay store. Read/write and manager roles retain the durable +// pre-allocation reservation which protects a later revocation or ACL +// eviction. +inline bool clientLoginRoleNeedsDurableReplay( + uint8_t permissions, + uint8_t role_mask, + uint8_t guest_role, + uint8_t read_only_role) { + const uint8_t role = permissions & role_mask; + return role != (guest_role & role_mask) + && role != (read_only_role & role_mask); +} + +// Select durable work only after freshness is established. An existing +// privileged tombstone remains authoritative even if the identity currently +// logs in with a guest/read-only role, so advance that record rather than +// silently weakening revoked-role protection. Only a missing low-trust +// identity stays RAM-only. +inline ClientLoginReplayReservationAction selectClientLoginReplayReservation( + bool reservation_needed, + bool identity_already_stored, + bool role_needs_durable_replay) { + if (!reservation_needed) { + return ClientLoginReplayReservationAction::None; + } + if (identity_already_stored) { + return ClientLoginReplayReservationAction::UpdateExisting; + } + return role_needs_durable_replay + ? ClientLoginReplayReservationAction::CreateNew + : ClientLoginReplayReservationAction::None; +} + +inline bool clientLoginReplayCanInsert(size_t stored_identity_count) { + return stored_identity_count < MAX_CLIENT_LOGIN_REPLAY_IDENTITIES; +} + +inline uint32_t reserveClientLoginTimestamp(uint32_t sender_timestamp, + uint32_t reservation_seconds) { + const uint32_t room = UINT32_MAX - sender_timestamp; + return room < reservation_seconds + ? UINT32_MAX + : sender_timestamp + reservation_seconds; +} + +// Plan the admission before mutating the ACL. runtime_last_timestamp is +// non-zero only when this boot has live state for the identity. Otherwise the +// persisted ceiling is the freshness floor (boot, eviction, and revocation all +// take this branch). The caller must durably publish reservation_ceiling +// before treating a plan with reservation_needed as accepted. +inline ClientLoginReplayPlan planClientLoginReplay( + uint32_t runtime_last_timestamp, + uint32_t persisted_ceiling, + uint32_t sender_timestamp, + uint32_t reservation_seconds = + CLIENT_LOGIN_REPLAY_RESERVATION_SECONDS) { + const uint32_t freshness_floor = runtime_last_timestamp != 0 + ? runtime_last_timestamp + : persisted_ceiling; + if (sender_timestamp == 0 || sender_timestamp <= freshness_floor) { + return {false, false, persisted_ceiling}; + } + if (sender_timestamp <= persisted_ceiling) { + return {true, false, persisted_ceiling}; + } + return { + true, + true, + reserveClientLoginTimestamp(sender_timestamp, reservation_seconds), + }; +} + +inline uint32_t updateClientLoginReplayCRC(uint32_t crc, + const uint8_t* data, + size_t length) { + while (length-- != 0) { + crc ^= *data++; + for (uint8_t bit = 0; bit < 8; bit++) { + crc = (crc >> 1) ^ (0xEDB88320UL & + (uint32_t)-(int32_t)(crc & 1U)); + } + } + return crc; +} + +inline bool validateClientLoginReplayImage(const uint8_t* image, + size_t size) { + if (image == NULL || size < CLIENT_LOGIN_REPLAY_TRAILER_SIZE) return false; + const size_t payload_size = size - CLIENT_LOGIN_REPLAY_TRAILER_SIZE; + if (payload_size % CLIENT_LOGIN_REPLAY_RECORD_SIZE != 0 + || payload_size / CLIENT_LOGIN_REPLAY_RECORD_SIZE + > MAX_CLIENT_LOGIN_REPLAY_IDENTITIES) { + return false; + } + if (memcmp(image + payload_size, CLIENT_LOGIN_REPLAY_MAGIC, + sizeof(CLIENT_LOGIN_REPLAY_MAGIC)) != 0) { + return false; + } + for (size_t offset = 0; offset < payload_size; + offset += CLIENT_LOGIN_REPLAY_RECORD_SIZE) { + uint32_t ceiling; + memcpy(&ceiling, image + offset + 32, sizeof(ceiling)); + if (ceiling == 0) return false; + } + uint32_t stored_crc; + memcpy(&stored_crc, + image + payload_size + sizeof(CLIENT_LOGIN_REPLAY_MAGIC), + sizeof(stored_crc)); + const uint32_t crc = updateClientLoginReplayCRC( + 0xFFFFFFFFUL, image, payload_size) ^ 0xFFFFFFFFUL; + return stored_crc == crc; +} + +template +bool removeClientLoginReplayArtifact(Filesystem* fs, const char* path) { + if (!fs->exists(path)) return true; + fs->remove(path); + return !fs->exists(path); +} + +template +bool recoverClientLoginReplayFiles(Filesystem* fs, Validator is_valid) { + if (fs->exists(CLIENT_LOGIN_REPLAY_PRIMARY_PATH) + && is_valid(fs, CLIENT_LOGIN_REPLAY_PRIMARY_PATH)) { + // The validated primary is already authoritative. Cleanup failure must + // not disable replay protection or make committed state appear absent. + removeClientLoginReplayArtifact(fs, CLIENT_LOGIN_REPLAY_TEMP_PATH); + removeClientLoginReplayArtifact(fs, CLIENT_LOGIN_REPLAY_BACKUP_PATH); + return true; + } + if (fs->exists(CLIENT_LOGIN_REPLAY_BACKUP_PATH) + && is_valid(fs, CLIENT_LOGIN_REPLAY_BACKUP_PATH)) { + if (!removeClientLoginReplayArtifact(fs, + CLIENT_LOGIN_REPLAY_PRIMARY_PATH) + || !fs->rename(CLIENT_LOGIN_REPLAY_BACKUP_PATH, + CLIENT_LOGIN_REPLAY_PRIMARY_PATH)) { + return false; + } + removeClientLoginReplayArtifact(fs, CLIENT_LOGIN_REPLAY_TEMP_PATH); + return true; + } + // A never-created store is the upgrade/first-boot case. An invalid live or + // backup image is different: retain it for diagnosis and fail closed. + return !fs->exists(CLIENT_LOGIN_REPLAY_PRIMARY_PATH) + && !fs->exists(CLIENT_LOGIN_REPLAY_BACKUP_PATH) + && removeClientLoginReplayArtifact(fs, + CLIENT_LOGIN_REPLAY_TEMP_PATH); +} + +template +bool publishClientLoginReplayTemp(Filesystem* fs, + bool temp_verified, + Validator is_valid) { + if (!temp_verified) { + removeClientLoginReplayArtifact(fs, CLIENT_LOGIN_REPLAY_TEMP_PATH); + return false; + } + if (fs->exists(CLIENT_LOGIN_REPLAY_BACKUP_PATH)) return false; + + const bool had_primary = fs->exists(CLIENT_LOGIN_REPLAY_PRIMARY_PATH); + if (had_primary + && !fs->rename(CLIENT_LOGIN_REPLAY_PRIMARY_PATH, + CLIENT_LOGIN_REPLAY_BACKUP_PATH)) { + return false; + } + if (!fs->rename(CLIENT_LOGIN_REPLAY_TEMP_PATH, + CLIENT_LOGIN_REPLAY_PRIMARY_PATH) + || !is_valid(fs, CLIENT_LOGIN_REPLAY_PRIMARY_PATH)) { + removeClientLoginReplayArtifact(fs, CLIENT_LOGIN_REPLAY_PRIMARY_PATH); + if (had_primary) { + fs->rename(CLIENT_LOGIN_REPLAY_BACKUP_PATH, + CLIENT_LOGIN_REPLAY_PRIMARY_PATH); + } + return false; + } + // The new replay ceiling committed at the successful rename and + // validation. Treat stale-backup cleanup as best-effort so callers never + // reject a login whose durable reservation is already on disk. + removeClientLoginReplayArtifact(fs, CLIENT_LOGIN_REPLAY_BACKUP_PATH); + return true; +} + +inline bool clientRoleIsPersisted(uint8_t permissions, + uint8_t role_mask, + uint8_t guest_role) { + return (permissions & role_mask) != (guest_role & role_mask); +} + +inline bool isFreshClientLoginTimestamp(bool client_existed, + uint32_t sender_timestamp, + uint32_t last_timestamp) { + return sender_timestamp != 0 + && (!client_existed || sender_timestamp > last_timestamp); +} + +inline bool successfulClientLoginNeedsPersistence(bool client_existed, + uint8_t previous_permissions, + uint8_t next_permissions, + uint8_t role_mask, + uint8_t guest_role, + bool persisted_changed) { + if (!persisted_changed) return false; + const uint8_t guest = guest_role & role_mask; + const bool previously_persisted = client_existed + && clientRoleIsPersisted(previous_permissions, role_mask, guest); + const bool now_persisted = + clientRoleIsPersisted(next_permissions, role_mask, guest); + // A downgrade to guest must rewrite the ACL even though the guest itself is + // omitted from the file; otherwise the prior persisted role survives reboot. + return previously_persisted || now_persisted; +} + +// Apply the successful-login state transition and report whether any field +// written by ClientACL::save() changed. Timestamps are deliberately excluded +// from that result: the exact live value is RAM state, while its reserved +// high-water boundary is synchronously owned by the separate replay store. +template +bool applySuccessfulClientLogin( + Client& client, + bool client_existed, + uint8_t role_permissions, + uint8_t role_mask, + const uint8_t* shared_secret, + uint32_t sender_timestamp, + uint32_t activity_timestamp, + bool reset_out_path, + uint8_t unknown_path) { + const uint8_t next_permissions = + (uint8_t)((client.permissions & (uint8_t)~role_mask) + | (role_permissions & role_mask)); + bool persisted_changed = !client_existed + || client.permissions != next_permissions + || memcmp(client.shared_secret, shared_secret, + sizeof(client.shared_secret)) != 0 + || (reset_out_path && client.out_path_len != unknown_path); + + client.last_timestamp = sender_timestamp; + client.last_activity = activity_timestamp; + client.permissions = next_permissions; + memcpy(client.shared_secret, shared_secret, sizeof(client.shared_secret)); + if (reset_out_path) { + client.out_path_len = unknown_path; + client.out_path_is_persistable = true; + } + + return persisted_changed; +} + +} // namespace mesh diff --git a/src/helpers/ClientPathPersistence.h b/src/helpers/ClientPathPersistence.h new file mode 100644 index 00000000..5de74f85 --- /dev/null +++ b/src/helpers/ClientPathPersistence.h @@ -0,0 +1,108 @@ +#pragma once + +#include +#include +#include + +namespace mesh { + +struct ClientPathUpdateResult { + bool changed; + bool persistence_needed; +}; + +struct StoredClientPathView { + uint8_t encoded_path_len; + const uint8_t* path; +}; + +inline bool clientPathPersistenceAllowed(bool client_is_persistable, + bool replay_freshness_proven) { + return client_is_persistable && replay_freshness_proven; +} + +inline uint8_t storedClientPathLength(bool path_is_persistable, + uint8_t current_path_len, + uint8_t unknown_path) { + return path_is_persistable ? current_path_len : unknown_path; +} + +// A replay-unproven PATH may replace the route used for the remainder of this +// boot, but it must not erase an earlier operator-selected route when an +// unrelated ACL mutation rewrites /s_contacts. Select the live route only +// when it owns durable state; otherwise retain the already-published route (or +// serialize unknown for a contact that has never had one). +inline StoredClientPathView selectStoredClientPath( + bool runtime_path_is_persistable, + uint8_t runtime_path_len, + const uint8_t* runtime_path, + bool prior_path_exists, + uint8_t prior_path_len, + const uint8_t* prior_path, + uint8_t unknown_path, + const uint8_t* empty_path) { + if (runtime_path_is_persistable) { + return {runtime_path_len, runtime_path}; + } + if (prior_path_exists) { + return {prior_path_len, prior_path}; + } + return {unknown_path, empty_path}; +} + +// Packet paths encode the hash width in the upper two bits and the hop count +// in the lower six bits. This intentionally matches Packet::writePath()'s byte +// count without pulling the firmware-only Packet dependencies into host tests. +inline size_t encodedClientPathByteLength(uint8_t encoded_path_len) { + const size_t hash_count = encoded_path_len & 63U; + const size_t hash_size = (encoded_path_len >> 6) + 1U; + return hash_count * hash_size; +} + +inline bool isValidEncodedClientPathLength(uint8_t encoded_path_len, + size_t capacity) { + const size_t hash_size = (encoded_path_len >> 6) + 1U; + return hash_size != 4U + && encodedClientPathByteLength(encoded_path_len) <= capacity; +} + +// Update the RAM route and report whether the caller owns a persistent change. +// FORCE_FLOOD is an explicit operator setting and is never replaced by a +// learned route. Bytes beyond the encoded route length are deliberately +// ignored: they are stale capacity, not part of the logical path. +template +ClientPathUpdateResult applyReceivedClientPath( + Client& client, + const uint8_t* path, + uint8_t encoded_path_len, + bool client_is_persistable, + uint8_t force_flood_path) { + if (client.out_path_len == force_flood_path) return {false, false}; + + const size_t path_bytes = encodedClientPathByteLength(encoded_path_len); + if (!isValidEncodedClientPathLength( + encoded_path_len, sizeof(client.out_path)) + || (path_bytes != 0 && path == NULL)) { + return {false, false}; + } + + const bool changed = client.out_path_len != encoded_path_len + || (path_bytes != 0 + && memcmp(client.out_path, path, path_bytes) != 0); + if (!changed) { + // A later request may carry the replay/freshness proof that the first copy + // lacked. Promote the identical RAM route without requiring it to change + // bytes a second time. + const bool persistence_upgrade = client_is_persistable + && !client.out_path_is_persistable; + if (persistence_upgrade) client.out_path_is_persistable = true; + return {persistence_upgrade, persistence_upgrade}; + } + + if (path_bytes != 0) memcpy(client.out_path, path, path_bytes); + client.out_path_len = encoded_path_len; + client.out_path_is_persistable = client_is_persistable; + return {true, client_is_persistable}; +} + +} // namespace mesh diff --git a/src/helpers/CommonCLI.cpp b/src/helpers/CommonCLI.cpp index ec9bc8b5..cef2c16d 100644 --- a/src/helpers/CommonCLI.cpp +++ b/src/helpers/CommonCLI.cpp @@ -316,6 +316,18 @@ static bool parseUint8Strict(const char* value, uint8_t min_value, uint8_t max_v return true; } +static bool parseOnOffStrict(const char* value, bool& enabled) { + if (strcmp(value, "on") == 0) { + enabled = true; + return true; + } + if (strcmp(value, "off") == 0) { + enabled = false; + return true; + } + return false; +} + static bool bwMatches(float bw, float allowed) { float diff = bw - allowed; if (diff < 0.0f) diff = -diff; @@ -915,7 +927,8 @@ void CommonCLI::loadPrefsInt(FILESYSTEM* fs, const char* filename) { File file = fs->open(filename); #endif if (file) { -#if defined(WITH_RS232_BRIDGE) && defined(RS232_BRIDGE_MERGED) +#if defined(WITH_RS232_BRIDGE) && defined(RS232_BRIDGE_MERGED) \ + && !defined(RS232_BRIDGE_DEFAULT_ON) bool has_runtime_bridge_uart = false; #endif // Every supported layout contains the fixed 290-byte common core. Reject @@ -1280,7 +1293,8 @@ void CommonCLI::loadPrefsInt(FILESYSTEM* fs, const char* filename) { if (file.available() >= (int)sizeof(_prefs->bridge_uart)) { file.read((uint8_t *)&_prefs->bridge_uart, sizeof(_prefs->bridge_uart)); -#if defined(WITH_RS232_BRIDGE) && defined(RS232_BRIDGE_MERGED) +#if defined(WITH_RS232_BRIDGE) && defined(RS232_BRIDGE_MERGED) \ + && !defined(RS232_BRIDGE_DEFAULT_ON) has_runtime_bridge_uart = true; #endif if (file.available() >= (int)sizeof(_prefs->bridge_format)) { @@ -1345,7 +1359,8 @@ void CommonCLI::loadPrefsInt(FILESYSTEM* fs, const char* filename) { _prefs->loop_detect = constrain(_prefs->loop_detect, 0, 3); // LOOP_DETECT_OFF..LOOP_DETECT_STRICT // sanitise bad bridge pref values -#if defined(WITH_RS232_BRIDGE) && defined(RS232_BRIDGE_MERGED) +#if defined(WITH_RS232_BRIDGE) && defined(RS232_BRIDGE_MERGED) \ + && !defined(RS232_BRIDGE_DEFAULT_ON) if (!has_runtime_bridge_uart) { // Pre-merge normal repeaters persisted bridge_enabled=1 even though no // bridge was compiled. Fail safe on the first merged boot instead of @@ -2722,13 +2737,6 @@ void CommonCLI::handleCommand(uint32_t sender_timestamp, char* command, char* re } else if (!valid_gps_toggle) { strcpy(reply, "gps must be 0 or 1"); } -#if defined(WITH_RS232_BRIDGE_GPS_CONFLICT_UART) - else if (is_gps_toggle && strcmp(value, "1") == 0 - && _prefs->bridge_enabled - && _prefs->bridge_uart == WITH_RS232_BRIDGE_GPS_CONFLICT_UART) { - strcpy(reply, "turn the RS232 bridge off or select another UART first"); - } -#endif else if (_sensors->setSettingValue(key, value)) { if (is_gps_interval) { _prefs->gps_interval = gps_interval; @@ -2737,7 +2745,16 @@ void CommonCLI::handleCommand(uint32_t sender_timestamp, char* command, char* re _prefs->gps_enabled = strcmp(value, "1") == 0 ? 1 : 0; savePrefs(); } - strcpy(reply, "ok"); +#ifdef WITH_RS232_BRIDGE + if (is_gps_toggle && strcmp(value, "1") == 0 + && _callbacks->isBridgeRunning() + && _sensors->gpsUsesSerialUart(_prefs->bridge_uart)) { + strcpy(reply, "saved; UART GPS paused while bridge is enabled"); + } else +#endif + { + strcpy(reply, "ok"); + } } else { strcpy(reply, "can't find custom var"); } @@ -2770,17 +2787,19 @@ void CommonCLI::handleCommand(uint32_t sender_timestamp, char* command, char* re handleRegionCmd(command, reply); #if ENV_INCLUDE_GPS == 1 } else if (memcmp(command, "gps on", 6) == 0) { -#if defined(WITH_RS232_BRIDGE_GPS_CONFLICT_UART) - if (_prefs->bridge_enabled - && _prefs->bridge_uart == WITH_RS232_BRIDGE_GPS_CONFLICT_UART) { - strcpy(reply, "turn the RS232 bridge off or select another UART first"); - } else -#endif if (_sensors->setSettingValue("gps", "1")) { _prefs->gps_enabled = 1; savePrefs(); - if (_prefs->powersaving_enabled) { // Power Saving + if (_callbacks->isBridgeRunning() +#ifdef WITH_RS232_BRIDGE + && _sensors->gpsUsesSerialUart(_prefs->bridge_uart) +#else + && false +#endif + ) { + strcpy(reply, "saved; UART GPS paused while bridge is enabled"); + } else if (_prefs->powersaving_enabled) { // Power Saving strcpy(reply, "on (powersaving)"); } else { // Normal mode strcpy(reply, "ok"); @@ -2800,6 +2819,11 @@ void CommonCLI::handleCommand(uint32_t sender_timestamp, char* command, char* re LocationProvider * l = _sensors->getLocationProvider(); if (!_prefs->gps_enabled) { strcpy(reply, "gps is off"); +#ifdef WITH_RS232_BRIDGE + } else if (_callbacks->isBridgeRunning() + && _sensors->gpsUsesSerialUart(_prefs->bridge_uart)) { + strcpy(reply, "gps paused by RS232 bridge"); +#endif } else if (l != NULL) { l->syncTime(); strcpy(reply, "scheduled"); @@ -4231,35 +4255,73 @@ void CommonCLI::handleSetCmd(uint32_t sender_timestamp, char* command, char* rep } #ifdef WITH_BRIDGE } else if (memcmp(config, "bridge.enabled ", 15) == 0) { - const bool enable = memcmp(&config[15], "on", 2) == 0; -#if defined(WITH_RS232_BRIDGE_GPS_CONFLICT_UART) - if (enable && _prefs->gps_enabled - && _prefs->bridge_uart == WITH_RS232_BRIDGE_GPS_CONFLICT_UART) { - strcpy(reply, "Error: turn GPS off or select another UART first"); - } else -#endif - { - _prefs->bridge_enabled = enable; - const bool applied = _callbacks->setBridgeState(_prefs->bridge_enabled); - savePrefs(); - if (applied) { - strcpy(reply, "OK"); - } else { - strcpy(reply, enable ? "Error: bridge failed to start; setting saved" - : "Error: bridge failed to stop; setting saved"); + bool enable = false; + if (!parseOnOffStrict(&config[15], enable)) { + strcpy(reply, "Error: usage set bridge.enabled on|off"); + } else { + #ifdef WITH_RS232_BRIDGE + if (enable + && _sensors->gpsSerialTransportMayConflict(_prefs->bridge_uart) + && (!_sensors->gpsUsesSerialUart(_prefs->bridge_uart) + || !_sensors->gpsSerialTransportCanYield(_prefs->bridge_uart))) { + strcpy(reply, "Error: UART may be driven by GPS; use UART 2 or a no-GPS build"); + return; } + const uint8_t previous_enabled = _prefs->bridge_enabled; + const bool previous_running = _callbacks->isBridgeRunning(); + _prefs->bridge_enabled = enable; + const bool applied = _callbacks->setBridgeState(enable); + if (applied) { + savePrefs(); +#ifdef WITH_RS232_BRIDGE + if (enable && _sensors->gpsUsesSerialUart(_prefs->bridge_uart)) { + strcpy(reply, "OK - UART GPS paused"); + } else +#endif + { + strcpy(reply, "OK"); + } + } else { + _prefs->bridge_enabled = previous_enabled; + bool restored = true; + if (_callbacks->isBridgeRunning() != previous_running + || !previous_running) { + // The false/false case still calls disable to delete a failed, + // non-running heap-backed bridge instance. + restored = _callbacks->setBridgeState(previous_running); + } + strcpy(reply, restored + ? "Error: bridge state change failed; setting unchanged" + : "Error: bridge state change failed; previous runtime state could not be restored"); + } +#else + // MQTT and ESP-NOW preserve configured intent across missing WiFi + // credentials or transient initialization failure. Their runtime can be + // retried later, so do not apply the stricter RS-232 rollback policy. + _prefs->bridge_enabled = enable; + const bool applied = _callbacks->setBridgeState(enable); + savePrefs(); + strcpy(reply, applied ? "OK" + : "Error: bridge runtime change failed; setting saved"); +#endif } } else if (memcmp(config, "bridge.delay ", 13) == 0) { - int delay = _atoi(&config[13]); - if (delay >= 0 && delay <= 10000) { - _prefs->bridge_delay = (uint16_t)delay; + int32_t bridge_delay = 0; + if (mesh::cli::parseIntegerStrict(&config[13], bridge_delay) + && bridge_delay >= 0 && bridge_delay <= 10000) { + _prefs->bridge_delay = (uint16_t)bridge_delay; savePrefs(); strcpy(reply, "OK"); } else { strcpy(reply, "Error: delay must be between 0-10000 ms"); } } else if (memcmp(config, "bridge.source ", 14) == 0) { - _prefs->bridge_pkt_src = memcmp(&config[14], "rx", 2) == 0; + const char* source = &config[14]; + if (strcmp(source, "rx") != 0 && strcmp(source, "tx") != 0) { + strcpy(reply, "Error: usage set bridge.source rx|tx"); + return; + } + _prefs->bridge_pkt_src = strcmp(source, "rx") == 0; #ifdef WITH_MQTT_BRIDGE if (_prefs->bridge_pkt_src == 1) { _mqtt_prefs.mqtt_rx_enabled = 1; @@ -4276,24 +4338,37 @@ void CommonCLI::handleSetCmd(uint32_t sender_timestamp, char* command, char* rep #endif #ifdef WITH_RS232_BRIDGE } else if (memcmp(config, "bridge.baud ", 12) == 0) { - uint32_t baud = atoi(&config[12]); - if (baud >= 9600 && baud <= BRIDGE_MAX_BAUD) { - _prefs->bridge_baud = (uint32_t)baud; + uint32_t baud = 0; + if (mesh::cli::parseUnsignedIntegerStrict(&config[12], baud) + && baud >= 9600 && baud <= BRIDGE_MAX_BAUD) { + const uint32_t previous_baud = _prefs->bridge_baud; + const bool previous_running = _callbacks->isBridgeRunning(); + _prefs->bridge_baud = baud; const bool applied = !_prefs->bridge_enabled || _callbacks->restartBridge(); - savePrefs(); - strcpy(reply, applied ? "OK" - : "Error: setting saved; bridge failed to restart"); + if (applied) { + savePrefs(); + strcpy(reply, "OK"); + } else { + _prefs->bridge_baud = previous_baud; + const bool restored = previous_running + ? _callbacks->restartBridge() + : _callbacks->setBridgeState(false); + strcpy(reply, restored + ? "Error: bridge failed to restart; baud unchanged" + : "Error: bridge failed to restart; previous baud could not be restored"); + } } else { sprintf(reply, "Error: baud rate must be between 9600-%d",BRIDGE_MAX_BAUD); } } else if (memcmp(config, "bridge.uart ", 12) == 0) { - const int uart = atoi(&config[12]); + int32_t uart = 0; + const bool parsed = mesh::cli::parseIntegerStrict(&config[12], uart); const bool supported = uart == WITH_RS232_BRIDGE_UART #ifdef WITH_RS232_BRIDGE_ALT || uart == WITH_RS232_BRIDGE_ALT_UART #endif ; - if (!supported) { + if (!parsed || !supported) { #ifdef WITH_RS232_BRIDGE_ALT sprintf(reply, "Error: UART must be %d or %d", WITH_RS232_BRIDGE_UART, WITH_RS232_BRIDGE_ALT_UART); @@ -4301,18 +4376,34 @@ void CommonCLI::handleSetCmd(uint32_t sender_timestamp, char* command, char* rep sprintf(reply, "Error: UART is fixed at %d", WITH_RS232_BRIDGE_UART); #endif } -#if defined(WITH_RS232_BRIDGE_GPS_CONFLICT_UART) - else if (uart == WITH_RS232_BRIDGE_GPS_CONFLICT_UART - && _prefs->gps_enabled && _prefs->bridge_enabled) { - strcpy(reply, "Error: turn GPS or the bridge off first"); - } -#endif else { + if (_prefs->bridge_enabled + && _sensors->gpsSerialTransportMayConflict((uint8_t)uart) + && (!_sensors->gpsUsesSerialUart((uint8_t)uart) + || !_sensors->gpsSerialTransportCanYield((uint8_t)uart))) { + strcpy(reply, "Error: UART may be driven by GPS; keep UART 2 or use a no-GPS build"); + return; + } + const uint8_t previous_uart = _prefs->bridge_uart; + const bool previous_running = _callbacks->isBridgeRunning(); _prefs->bridge_uart = (uint8_t)uart; const bool applied = !_prefs->bridge_enabled || _callbacks->restartBridge(); - savePrefs(); - strcpy(reply, applied ? "OK" - : "Error: setting saved; bridge failed to restart"); + if (!applied) { + _prefs->bridge_uart = previous_uart; + const bool restored = previous_running + ? _callbacks->restartBridge() + : _callbacks->setBridgeState(false); + strcpy(reply, restored + ? "Error: bridge failed to restart; UART unchanged" + : "Error: bridge failed to restart; previous UART could not be restored"); + } else if (_prefs->bridge_enabled + && _sensors->gpsUsesSerialUart(_prefs->bridge_uart)) { + savePrefs(); + strcpy(reply, "OK - UART GPS paused"); + } else { + savePrefs(); + strcpy(reply, "OK"); + } } #endif #ifdef WITH_ESPNOW_BRIDGE @@ -4740,10 +4831,12 @@ void CommonCLI::handleGetCmd(uint32_t sender_timestamp, char* command, char* rep #ifdef WITH_BRIDGE } else if (configKeyEquals(config, "bridge.enabled")) { sprintf(reply, "> %s", _prefs->bridge_enabled ? "on" : "off"); + } else if (configKeyEquals(config, "bridge.running")) { + sprintf(reply, "> %s", _callbacks->isBridgeRunning() ? "on" : "off"); } else if (configKeyEquals(config, "bridge.delay")) { sprintf(reply, "> %d", (uint32_t)_prefs->bridge_delay); } else if (configKeyEquals(config, "bridge.source")) { - sprintf(reply, "> %s", _prefs->bridge_pkt_src ? "logRx" : "logTx"); + sprintf(reply, "> %s", _prefs->bridge_pkt_src ? "rx" : "tx"); #endif #ifdef WITH_RS232_BRIDGE } else if (configKeyEquals(config, "bridge.baud")) { diff --git a/src/helpers/CommonCLI.h b/src/helpers/CommonCLI.h index c6053274..5dfeab21 100644 --- a/src/helpers/CommonCLI.h +++ b/src/helpers/CommonCLI.h @@ -556,6 +556,8 @@ public: return false; }; + virtual bool isBridgeRunning() const { return false; } + virtual void restartBridgeSlot(int slot) { // Default: fall back to full restart restartBridge(); diff --git a/src/helpers/CommonCLI_Observer.cpp b/src/helpers/CommonCLI_Observer.cpp index 9d03d2c3..7ab92566 100644 --- a/src/helpers/CommonCLI_Observer.cpp +++ b/src/helpers/CommonCLI_Observer.cpp @@ -22,7 +22,9 @@ #include #include #include +#include #include "WiFiPowerSave.h" +#include "esp32/TlsClockValidity.h" #include "esp32/WiFiRadioPolicy.h" #endif #if defined(ESP32_PLATFORM) && defined(ENABLE_OTA) && \ @@ -1166,12 +1168,21 @@ bool CommonCLI::handleObserverCommand(uint32_t sender_timestamp, char* command, client.setCACertBundle(rootca_crt_bundle_start); #endif client.setTimeout(8000); - bool ok = client.connect(host, port); - if (ok) { - client.stop(); - snprintf(reply, 160, "OK: TLS bundle verified %s:%u", host, (unsigned)port); + // This diagnostic must distinguish a bad CA bundle from a clock that + // cannot validate any certificate yet. It does not mutate the global + // SNTP client while the MQTT task is live; normal bridge NTP supplies + // the signed wall clock checked here. + const time_t tls_now = time(nullptr); + if (!mesh::tls_clock::timeIsValid(tls_now)) { + strcpy(reply, "ERR: TLS clock is not synchronized"); } else { - snprintf(reply, 160, "ERR: TLS bundle failed %s:%u", host, (unsigned)port); + bool ok = client.connect(host, port); + if (ok) { + client.stop(); + snprintf(reply, 160, "OK: TLS bundle verified %s:%u", host, (unsigned)port); + } else { + snprintf(reply, 160, "ERR: TLS bundle failed %s:%u", host, (unsigned)port); + } } } } @@ -1193,8 +1204,9 @@ bool CommonCLI::handleObserverCommand(uint32_t sender_timestamp, char* command, } else if (memcmp(command, "ota check", 9) == 0) { // Check is synchronous so its result lands in this reply, and runs with the // MQTT bridge UP: the slim per-variant manifest is tiny, so the fetch only - // costs a single TLS handshake (no large JSON doc) - which fits alongside - // the live MQTT sessions even on no-PSRAM boards. No bridge bounce needed. + // costs one plain-HTTP request (no cert-bundle allocation or large JSON + // document). The later update path refetches it over verified HTTPS after + // the bridge is down. No bridge bounce is needed for this advisory check. _board->otaFromManifest(_callbacks->getFirmwareVer(), true, reply); } else { // `ota update`: cheap pre-check first (plain HTTP, bridge stays up). Only diff --git a/src/helpers/CompanionMqttSetupPortal.cpp b/src/helpers/CompanionMqttSetupPortal.cpp index 65a53eb6..2f98ac4e 100644 --- a/src/helpers/CompanionMqttSetupPortal.cpp +++ b/src/helpers/CompanionMqttSetupPortal.cpp @@ -10,6 +10,7 @@ #include #include #include +#include #include namespace { @@ -553,8 +554,9 @@ bool CompanionMqttSetupPortal::begin(MQTTPrefs* prefs) { impl->task = nullptr; return false; } - Serial.printf("MQTT setup: reconnect to the joined WiFi and open http://%s/\n", - WiFi.localIP().toString().c_str()); + mesh::usbLoggingPort().printf( + "MQTT setup: reconnect to the joined WiFi and open http://%s/\n", + WiFi.localIP().toString().c_str()); return true; } @@ -580,9 +582,13 @@ bool CompanionMqttSetupPortal::loop() { bool CompanionMqttSetupPortal::loadStoredConfig(MQTTPrefs& prefs) { Preferences nvs; - if (!nvs.begin(NVS_NAMESPACE, true)) return false; - const uint16_t version = nvs.getUShort(NVS_VERSION_KEY, 0); - const size_t length = nvs.getBytesLength(NVS_PREFS_KEY); + // The namespace and blob legitimately do not exist on a fresh install. + // Avoid Arduino Preferences turning that state into UART error output. + if (!nvs.begin(NVS_NAMESPACE, false)) return false; + const uint16_t version = nvs.isKey(NVS_VERSION_KEY) + ? nvs.getUShort(NVS_VERSION_KEY, 0) : 0; + const size_t length = nvs.isKey(NVS_PREFS_KEY) + ? nvs.getBytesLength(NVS_PREFS_KEY) : 0; // Companion stores this struct directly rather than using the observer's // versioned file header. Accept the previously shipped pre-display size and // default its append-only display tail so adding observer UI preferences diff --git a/src/helpers/ESP32Board.cpp b/src/helpers/ESP32Board.cpp index b0ac1518..28cc1128 100644 --- a/src/helpers/ESP32Board.cpp +++ b/src/helpers/ESP32Board.cpp @@ -2,6 +2,7 @@ #include "ESP32Board.h" #include +#include "UsbLogging.h" #include "UserGpioPinPolicy.h" namespace { @@ -414,16 +415,113 @@ bool ESP32Board::stopOTAUpdate(char reply[]) { #include #include #include +#include +#include +#include #include #include #include +#include #include +#include // Embedded CA bundle (produced by board_build.embed_files). Weak so non-bundle // builds still link; we check for presence at runtime. extern const uint8_t rootca_crt_bundle_start[] asm("_binary_src_certs_x509_crt_bundle_bin_start") __attribute__((weak)); extern const uint8_t rootca_crt_bundle_end[] asm("_binary_src_certs_x509_crt_bundle_bin_end") __attribute__((weak)); +static constexpr uint32_t OTA_NTP_SYNC_WAIT_MS = 15000UL; +static constexpr uint32_t OTA_TLS_PROOF_MAX_AGE_MS = 300000UL; +static std::atomic ota_tls_fresh_ntp_received{false}; +static std::atomic ota_tls_proof_millis{0}; +static std::atomic ota_tls_proof_generation{0}; +static std::atomic ota_tls_expected_generation{0}; +static std::atomic ota_sntp_operation_generation{0}; + +static void ota_clearNtpCallback() { + // OperationLease invokes this before publishing the global coordinator as + // free, so a stale OTA teardown cannot erase another service's callback. + ota_sntp_operation_generation.store(0, std::memory_order_release); + sntp_set_time_sync_notification_cb(nullptr); +} + +static void ota_noteNtpTime(struct timeval* value) { + const uint32_t generation = + ota_sntp_operation_generation.load(std::memory_order_acquire); + if (!mesh::sntp_coord::processWideCoordinator().owns(generation)) return; + if (value != nullptr && mesh::tls_clock::timeIsValid(value->tv_sec)) { + ota_tls_proof_millis.store(millis(), std::memory_order_release); + ota_tls_proof_generation.store(generation, std::memory_order_release); + ota_tls_fresh_ntp_received.store(true, std::memory_order_release); + } +} + +static bool ota_tlsClockProofValid() { + const bool fresh = ota_tls_fresh_ntp_received.load(std::memory_order_acquire); + const uint32_t proven_at = + ota_tls_proof_millis.load(std::memory_order_acquire); + const uint32_t proof_generation = + ota_tls_proof_generation.load(std::memory_order_acquire); + const uint32_t expected_generation = + ota_tls_expected_generation.load(std::memory_order_acquire); + return mesh::tls_clock::proofGenerationIsValid( + fresh, proof_generation, expected_generation) + && mesh::tls_clock::proofIsValid( + fresh, WiFi.status() == WL_CONNECTED, time(nullptr)) + && mesh::tls_clock::proofAgeIsValid( + fresh, millis(), proven_at, OTA_TLS_PROOF_MAX_AGE_MS); +} + +static bool ota_prepareTlsClock(char reply[]) { + if (WiFi.status() != WL_CONNECTED) { + strcpy(reply, "ERR: WiFi disconnected before NTP"); + return false; + } + + mesh::sntp_coord::OperationLease sntp_operation( + mesh::sntp_coord::processWideCoordinator(), ota_clearNtpCallback); + if (!sntp_operation.tryAcquire()) { + strcpy(reply, "ERR: NTP busy in another firmware service"); + return false; + } + + // The MQTT bridge has stopped for an actual update, but other firmware + // services can still need SNTP. The process-wide lease makes this callback + // and configTime() sequence exclusive. A plausible retained RTC is + // intentionally not enough: observe a response during this operation. + ota_tls_fresh_ntp_received.store(false, std::memory_order_release); + ota_tls_proof_millis.store(0, std::memory_order_release); + ota_tls_proof_generation.store(0, std::memory_order_release); + ota_tls_expected_generation.store( + sntp_operation.generation(), std::memory_order_release); + ota_sntp_operation_generation.store( + sntp_operation.generation(), std::memory_order_release); + sntp_set_time_sync_notification_cb(ota_noteNtpTime); + sntp_set_sync_status(SNTP_SYNC_STATUS_RESET); + mesh::usbLoggingPort().println( + "OTA: requesting fresh NTP time before HTTPS"); + configTime(0, 0, "time.cloudflare.com", "time.google.com", "pool.ntp.org"); + + const uint32_t started = millis(); + while (millis() - started < OTA_NTP_SYNC_WAIT_MS) { + if (WiFi.status() != WL_CONNECTED) { + strcpy(reply, "ERR: WiFi disconnected during NTP"); + return false; + } + const time_t now = time(nullptr); + if (ota_tlsClockProofValid()) { + mesh::usbLoggingPort().printf( + "OTA: fresh NTP time %lld received in %lu ms\n", + (long long)now, (unsigned long)(millis() - started)); + return true; + } + delay(100); + } + + strcpy(reply, "ERR: fresh NTP time unavailable"); + return false; +} + struct OtaHttpResponse { int status; size_t content_length; @@ -513,6 +611,10 @@ static bool ota_openHttp(Client& client, const char* url, bool require_tls, } client.setTimeout(20000); + if (require_tls && !ota_tlsClockProofValid()) { + strcpy(reply, "ERR: TLS clock proof invalid"); + return false; + } if (!client.connect(host, port)) { strcpy(reply, "ERR: HTTP connect failed"); return false; @@ -647,7 +749,7 @@ static bool ota_streamFirmware(Client& client, size_t content_length, char reply int decile = (int)((received_total * 10U) / content_length); if (decile != progress_decile) { progress_decile = decile; - Serial.printf("OTA: %d%%\n", decile * 10); + mesh::usbLoggingPort().printf("OTA: %d%%\n", decile * 10); } } @@ -788,6 +890,9 @@ bool ESP32Board::otaFromManifestImpl(const char* current_ver, bool dry_run, char strcpy(reply, "ERR: no embedded cert bundle"); return false; } + if (!dry_run && !ota_prepareTlsClock(reply)) { + return false; + } // --- Fetch this variant's slim manifest ---------------------------------- // /.json - a ~180 byte per-variant file, not the @@ -822,13 +927,12 @@ bool ESP32Board::otaFromManifestImpl(const char* current_ver, bool dry_run, char mclient.setCACertBundle(rootca_crt_bundle_start); #endif snprintf(murl, sizeof(murl), "%s/%s.json", OTA_MANIFEST_BASE, OTA_VARIANT); + mesh::usbLoggingPort().printf("OTA: downloading manifest %s\n", murl); if (!ota_fetchManifest(mclient, murl, true, doc, reply)) { return false; } } - if (!dry_run) { Serial.print("OTA: checking manifest "); Serial.println(murl); } - // Copy fields out before the document is reused/cleared. char file_url[200] = {0}, avail_version[40] = {0}, avail_base[40] = {0}, avail_hash[24] = {0}; strncpy(file_url, doc["file"] | "", sizeof(file_url) - 1); @@ -931,8 +1035,9 @@ bool ESP32Board::otaFromManifestImpl(const char* current_ver, bool dry_run, char } // --- Stream the .bin (the manifest's full URL) into the inactive OTA slot - - Serial.printf("OTA: update %s -> %s\n", own_disp, avail_disp); - Serial.print("OTA: downloading "); Serial.println(file_url); + mesh::usbLoggingPort().printf( + "OTA: update %s -> %s\n", own_disp, avail_disp); + mesh::usbLoggingPort().printf("OTA: downloading %s\n", file_url); inhibit_sleep = true; // keep awake through the flash WiFiClientSecure uclient; @@ -945,32 +1050,32 @@ bool ESP32Board::otaFromManifestImpl(const char* current_ver, bool dry_run, char OtaHttpResponse response; if (!ota_openHttp(uclient, file_url, true, response, reply)) { inhibit_sleep = false; - Serial.print("OTA: FAILED - "); Serial.println(reply); + mesh::usbLoggingPort().printf("OTA: FAILED - %s\n", reply); return false; } if (response.status != 200) { inhibit_sleep = false; snprintf(reply, 160, "ERR: firmware HTTP %d", response.status); uclient.stop(); - Serial.print("OTA: FAILED - "); Serial.println(reply); + mesh::usbLoggingPort().printf("OTA: FAILED - %s\n", reply); return false; } if (!response.has_content_length) { inhibit_sleep = false; strcpy(reply, "ERR: firmware size missing"); uclient.stop(); - Serial.print("OTA: FAILED - "); Serial.println(reply); + mesh::usbLoggingPort().printf("OTA: FAILED - %s\n", reply); return false; } if (!ota_streamFirmware(uclient, response.content_length, reply)) { inhibit_sleep = false; uclient.stop(); - Serial.print("OTA: FAILED - "); Serial.println(reply); + mesh::usbLoggingPort().printf("OTA: FAILED - %s\n", reply); return false; } uclient.stop(); - Serial.println("OTA: write complete, rebooting..."); + mesh::usbLoggingPort().println("OTA: write complete, rebooting..."); delay(250); esp_restart(); return true; // unreachable diff --git a/src/helpers/IndicatorFontStageV2Protocol.h b/src/helpers/IndicatorFontStageV2Protocol.h new file mode 100644 index 00000000..50b918bb --- /dev/null +++ b/src/helpers/IndicatorFontStageV2Protocol.h @@ -0,0 +1,78 @@ +#pragma once + +#include +#include +#include + +namespace mesh { +namespace indicator_font { + +// STAGEV2 is deliberately a separate command word from the legacy STAGE +// grammar. Old RP2040 services therefore reject it before consuming binary +// data, which makes the explicit ERROR COMMAND response safe to fall back on. +static constexpr size_t kStageV2ChunkBytes = 512; +static constexpr char kStageV2ReadyReply[] = "READY 2 512"; +static constexpr char kStageV2LegacyUnsupportedReply[] = "ERROR COMMAND"; + +enum class StageV2BeginAction : uint8_t { + UseAcknowledged, + UseLegacy, + Fail, +}; + +inline StageV2BeginAction classifyStageV2BeginReply(bool replied, + const char* reply) { + if (!replied || reply == nullptr) return StageV2BeginAction::Fail; + if (strcmp(reply, kStageV2ReadyReply) == 0) { + return StageV2BeginAction::UseAcknowledged; + } + if (strcmp(reply, kStageV2LegacyUnsupportedReply) == 0) { + return StageV2BeginAction::UseLegacy; + } + return StageV2BeginAction::Fail; +} + +inline size_t stageV2ChunkSize(size_t total, size_t acknowledged) { + if (acknowledged >= total) return 0; + const size_t remaining = total - acknowledged; + return remaining < kStageV2ChunkBytes ? remaining : kStageV2ChunkBytes; +} + +// Advance only after the receiver has accepted the complete negotiated block. +// This keeps cumulative ACK offsets unambiguous and rejects premature/partial +// progress observations. +inline bool advanceStageV2Offset(size_t total, size_t acknowledged, + size_t stored, size_t& next) { + const size_t expected = stageV2ChunkSize(total, acknowledged); + if (expected == 0 || stored != expected) return false; + next = acknowledged + expected; // bounded by total, so this cannot overflow + return true; +} + +inline bool parseStageV2Ack(const char* reply, size_t expectedOffset) { + static constexpr char prefix[] = "ACK "; + if (reply == nullptr || expectedOffset == 0 + || strncmp(reply, prefix, sizeof(prefix) - 1) != 0) { + return false; + } + + const char* cursor = reply + sizeof(prefix) - 1; + if (*cursor < '0' || *cursor > '9') return false; + // The RP2040 emits canonical decimal offsets. Reject alternate spellings so + // stale or damaged lines cannot be interpreted leniently. + if (*cursor == '0' && cursor[1] != 0) return false; + + size_t parsed = 0; + const size_t maximum = (size_t)-1; + do { + const size_t digit = (size_t)(*cursor - '0'); + if (parsed > (maximum - digit) / 10) return false; + parsed = parsed * 10 + digit; + ++cursor; + } while (*cursor >= '0' && *cursor <= '9'); + + return *cursor == 0 && parsed == expectedOffset; +} + +} // namespace indicator_font +} // namespace mesh diff --git a/src/helpers/JWTHelper.cpp b/src/helpers/JWTHelper.cpp index b1c1393d..dca40c56 100644 --- a/src/helpers/JWTHelper.cpp +++ b/src/helpers/JWTHelper.cpp @@ -10,6 +10,7 @@ #include #include "ed_25519.h" #include "mbedtls/base64.h" +#include "UsbLogging.h" // Base64 URL encoding table (without padding) static const char base64url_chars[] = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_"; @@ -87,7 +88,11 @@ bool JWTHelper::createAuthToken( #if !defined(PORTABLE_MQTT_OBSERVER) int verify_result = ed25519_verify(signature, (const unsigned char*)signingInput, signingInputLen, public_key); if (verify_result != 1) { - if (Serial.availableForWrite() > 0) Serial.println("JWTHelper: Signature verification failed!"); + if (mesh::isUsbLoggingEnabled() + && mesh::usbLoggingPort().availableForWrite() > 0) { + mesh::usbLoggingPort().println( + "JWTHelper: Signature verification failed!"); + } return false; } #endif diff --git a/src/helpers/LazyPersistence.h b/src/helpers/LazyPersistence.h new file mode 100644 index 00000000..4be6056f --- /dev/null +++ b/src/helpers/LazyPersistence.h @@ -0,0 +1,68 @@ +#pragma once + +#include + +namespace mesh { + +static const uint32_t LAZY_PERSISTENCE_MAX_RETRY_DELAY_MILLIS = 300000UL; + +inline unsigned long nonzeroLazyPersistenceDeadline(unsigned long deadline) { + // Zero is the callers' "not pending" sentinel. Preserve a pending write at + // the single millis() value where deadline arithmetic wraps exactly to zero. + return deadline == 0 ? 1UL : deadline; +} + +inline bool armFirstLazyPersistence(unsigned long& pending_deadline, + unsigned long new_deadline, + bool persistence_needed) { + if (!persistence_needed || pending_deadline != 0) return false; + pending_deadline = nonzeroLazyPersistenceDeadline(new_deadline); + return true; +} + +inline bool scheduleLazyPersistenceMutation( + unsigned long& pending_deadline, + uint8_t& consecutive_failures, + unsigned long new_deadline) { + // Once persistence has failed, ordinary mutations must not turn a permanent + // filesystem fault back into a five-second wake/write loop. Only a + // successful save resets the failure count and its bounded retry deadline. + if (consecutive_failures != 0) { + if (pending_deadline != 0) return false; + pending_deadline = nonzeroLazyPersistenceDeadline(new_deadline); + return true; + } + if (pending_deadline != 0) return false; + pending_deadline = nonzeroLazyPersistenceDeadline(new_deadline); + return true; +} + +inline uint32_t recordLazyPersistenceSaveFailure( + uint8_t& consecutive_failures, + uint32_t base_delay, + uint32_t maximum_delay) { + uint32_t delay = base_delay > maximum_delay ? maximum_delay : base_delay; + uint8_t remaining_doublings = consecutive_failures; + while (remaining_doublings-- != 0 && delay < maximum_delay) { + delay = delay > maximum_delay / 2U ? maximum_delay : delay * 2U; + } + if (consecutive_failures != UINT8_MAX) consecutive_failures++; + return delay; +} + +inline void resetLazyPersistenceAfterSuccess( + unsigned long& pending_deadline, + uint8_t& consecutive_failures) { + pending_deadline = 0; + consecutive_failures = 0; +} + +inline void completeLazyPersistenceSave(unsigned long& pending_deadline, + bool save_succeeded, + unsigned long retry_deadline) { + pending_deadline = save_succeeded + ? 0UL + : nonzeroLazyPersistenceDeadline(retry_deadline); +} + +} // namespace mesh diff --git a/src/helpers/MQTTConnectionPolicy.h b/src/helpers/MQTTConnectionPolicy.h index ce6cfe97..1170d02f 100644 --- a/src/helpers/MQTTConnectionPolicy.h +++ b/src/helpers/MQTTConnectionPolicy.h @@ -1,5 +1,6 @@ #pragma once +#include #include // Pure timing and state-transition policy used by MQTTBridge's connection @@ -13,6 +14,8 @@ static const uint32_t kStableResetMs = 120000UL; static const uint32_t kCircuitBreakerProbeMs = 1800000UL; static const uint32_t kRenewalThrottleMs = 60000UL; static const uint32_t kSlotStaggerMs = 3000UL; +static const uint32_t kNtpRefreshIntervalMs = 86400000UL; // 24 hours +static const uint32_t kNtpRetryMs = 5000UL; static const uint8_t kMaxFailuresAtMaxBackoff = 3; static const uint32_t kDefaultJwtLifetimeSecs = 86400UL; static const uint32_t kMaxJwtStaggerSecs = 300UL; @@ -30,6 +33,53 @@ static inline uint32_t elapsedMs(uint32_t now, uint32_t then) { return now - then; } +// A pending short retry takes precedence over the normal daily schedule. The +// signed deadline comparison is safe because the retry is only five seconds +// away; the daily path uses unsigned elapsed time and therefore survives one +// millis() rollover as well. +static inline bool ntpRefreshDue(uint32_t now, uint32_t last_sync, + uint32_t retry_at) { + if (retry_at != 0) { + return static_cast(now - retry_at) >= 0; + } + return elapsedMs(now, last_sync) >= kNtpRefreshIntervalMs; +} + +static inline uint32_t ntpRetryAt(uint32_t now) { + const uint32_t retry_at = now + kNtpRetryMs; + // Zero is the "no retry scheduled" sentinel, so move that one rollover + // collision forward by one millisecond. + return retry_at == 0 ? 1 : retry_at; +} + +static inline uint32_t ntpReconnectRefreshAt(uint32_t now) { + // Reconnection invalidates any old short deadline. Schedule the refresh for + // the current task loop while preserving zero as the unscheduled sentinel. + return now == 0 ? 1 : now; +} + +// WiFi events can run on a different task from MQTT maintenance. Keep the +// callback to a single atomic edge latch: the MQTT task consumes the edge only +// after WiFi is connected and makes every clock/scheduler decision itself. +// Coalescing multiple GOT_IP events is intentional; one fresh sample after the +// latest reconnect is sufficient. +class NtpReconnectLatch { +public: + NtpReconnectLatch() : _pending(false) {} + + void noteGotIp() { + _pending.store(true, std::memory_order_release); + } + + bool consumeIfConnected(bool wifi_connected) { + if (!wifi_connected) return false; + return _pending.exchange(false, std::memory_order_acq_rel); + } + +private: + std::atomic _pending; +}; + static inline bool reconnectGuardActive(uint32_t now, uint32_t last_reconnect) { return elapsedMs(now, last_reconnect) < kReconnectGuardMs; } @@ -238,6 +288,25 @@ enum class ClockSource : uint8_t { Rtc, }; +// RTCClock carries Unix seconds in a uint32_t. Validate the signed system +// time before converting it: time(nullptr) uses -1 for failure, which would +// otherwise become UINT32_MAX and look newer than every minimum-epoch check. +// Reject values beyond the RTC wire/storage range as well instead of wrapping +// them into an apparently plausible earlier date. +static inline bool checkedRtcEpoch(int64_t candidate, + uint32_t min_valid_epoch, + uint32_t& accepted) { + accepted = 0; + if (candidate < 0 + || static_cast(candidate) > UINT32_MAX) { + return false; + } + const uint32_t converted = static_cast(candidate); + if (converted < min_valid_epoch) return false; + accepted = converted; + return true; +} + // Prefer a plausible system clock, then an RTC. Server validation may not use // a local clock as evidence that the requested NTP host answered. static inline ClockSource chooseFallbackClock(bool validating_server, diff --git a/src/helpers/NonBlockingWriteStream.h b/src/helpers/NonBlockingWriteStream.h new file mode 100644 index 00000000..62292a9c --- /dev/null +++ b/src/helpers/NonBlockingWriteStream.h @@ -0,0 +1,54 @@ +#pragma once + +#include +#include +#include +#include + +namespace mesh { + +// A Stream facade for a transport whose underlying write() may wait for FIFO +// space. It never retries or forwards a write larger than one nRF52 TinyUSB TX +// FIFO. Print::printf() can report a length larger than its 256-byte scratch +// buffer; rejecting such a call here also prevents that stale length from +// making the delegate read beyond the scratch buffer. +template +class WholeRecordNonBlockingStream : public Stream { + public: + explicit WholeRecordNonBlockingStream(Stream& delegate) + : _delegate(delegate) {} + + int available() override { return _delegate.available(); } + int read() override { return _delegate.read(); } + int peek() override { return _delegate.peek(); } + void flush() override { _delegate.flush(); } + int availableForWrite() override { return _delegate.availableForWrite(); } + + size_t write(uint8_t value) override { + return write(&value, 1); + } + + size_t write(const uint8_t* data, size_t size) override { + if (data == nullptr || size == 0 || size > MAX_WRITE_SIZE) return 0; + + // Never wait for another application writer. All diagnostic access to the + // delegate is routed through this facade; the USB task can only drain the + // TX FIFO, so free capacity cannot shrink while this gate is held. + if (_writer_busy.test_and_set(std::memory_order_acquire)) return 0; + + size_t written = 0; + const int available = _delegate.availableForWrite(); + if (available >= 0 && static_cast(available) >= size) { + written = _delegate.write(data, size); + } + + _writer_busy.clear(std::memory_order_release); + return written; + } + + private: + Stream& _delegate; + std::atomic_flag _writer_busy = ATOMIC_FLAG_INIT; +}; + +} // namespace mesh diff --git a/src/helpers/SensorManager.cpp b/src/helpers/SensorManager.cpp index a4b63db8..311977dc 100644 --- a/src/helpers/SensorManager.cpp +++ b/src/helpers/SensorManager.cpp @@ -49,14 +49,15 @@ void SensorManager::updateGpsTelemetryCache(float lat, float lon, float altitude } void SensorManager::maybeStopGpsForTelemetry(unsigned long now) { - if (telemetryGpsActive() && !gps_user_enabled && !gps_acquiring && !gpsTelemetryHoldActive(now)) { + if (gps_transport_available && telemetryGpsActive() && !gps_user_enabled + && !gps_acquiring && !gpsTelemetryHoldActive(now)) { telemetryGpsStop(); gps_next_cache_update_at = now + GPS_TELEMETRY_CACHE_INTERVAL_SEC * 1000UL; } } void SensorManager::beginGpsTelemetryAcquisition(unsigned long now) { - if (!telemetryGpsDetected() || gps_acquiring) return; + if (!gps_transport_available || !telemetryGpsDetected() || gps_acquiring) return; gps_acquiring = true; gps_acquire_has_fix = false; @@ -83,9 +84,19 @@ void SensorManager::finishGpsTelemetryAcquisition(unsigned long now, bool use_we } bool SensorManager::queryGpsTelemetry(uint8_t requester_permissions, CayenneLPP& telemetry) { - if (!(requester_permissions & TELEM_PERM_LOCATION) || !telemetryGpsDetected()) return false; + if (!(requester_permissions & TELEM_PERM_LOCATION) + || !telemetryGpsDetected()) return false; unsigned long now = millis(); + if (!gps_transport_available) { + // A bridge may temporarily own the GPS UART. Authorized callers can still + // receive the last good fix while it is inside the normal freshness bound, + // but the query must not create a hold or try to reclaim the UART. + if (!gpsTelemetryCacheFresh(now)) return false; + telemetry.addGPS(TELEM_CHANNEL_SELF, gps_cache_lat, gps_cache_lon, + gps_cache_altitude); + return true; + } gps_hold_until = now + GPS_TELEMETRY_HOLD_SEC * 1000UL; if (!telemetryGpsActive()) telemetryGpsStart(); if (!gpsTelemetryCacheFresh(now) && !gps_acquiring) beginGpsTelemetryAcquisition(now); @@ -96,6 +107,7 @@ bool SensorManager::queryGpsTelemetry(uint8_t requester_permissions, CayenneLPP& } void SensorManager::processGpsTelemetryFix(float lat, float lon, float altitude, unsigned long now) { + if (!gps_transport_available) return; if (!gps_acquiring) { if (gps_user_enabled || gpsTelemetryHoldActive(now)) { updateGpsTelemetryCache(lat, lon, altitude, now); @@ -129,6 +141,7 @@ void SensorManager::processGpsTelemetryFix(float lat, float lon, float altitude, } void SensorManager::loopGpsTelemetry(unsigned long now) { + if (!gps_transport_available) return; if (!gps_user_enabled && !gpsTelemetryHoldActive(now) && !gps_acquiring) { maybeStopGpsForTelemetry(now); } @@ -146,11 +159,57 @@ void SensorManager::setGpsTelemetryUserEnabled(bool enabled) { gps_user_enabled = enabled; unsigned long now = millis(); if (enabled) { - if (telemetryGpsDetected() && !telemetryGpsActive()) telemetryGpsStart(); + if (gps_transport_available && telemetryGpsDetected() + && !telemetryGpsActive()) telemetryGpsStart(); } else { maybeStopGpsForTelemetry(now); } } + +void SensorManager::setGpsTelemetryTransportAvailable(bool available) { + if (gps_transport_available == available) return; + + gps_transport_available = available; + if (!available) { + // The UART is no longer ours. Cancel both the short acquisition and the + // two-hour remote-query hold so neither can silently reclaim it from a + // bridge. Preserve the user's preference and last good cache. + gps_acquiring = false; + gps_acquire_has_fix = false; + gps_hold_until = 0; + gps_acquire_started_at = 0; + gps_stable_started_at = 0; + gps_weighted_lat = 0; + gps_weighted_lon = 0; + gps_weighted_altitude = 0; + gps_weight_sum = 0; + gps_weight_count = 0; + return; + } + + gps_next_cache_update_at = 0; + if (gps_user_enabled && telemetryGpsDetected() && !telemetryGpsActive()) { + telemetryGpsStart(); + } +} + +void SensorManager::resetGpsTelemetryTransportState() { + // Hardware discovery may be rerun after a bridge-owned UART was blocked. + // Reset transient ownership without invoking a provider callback before the + // new probe has established which provider, if any, is present. + gps_transport_available = true; + gps_acquiring = false; + gps_acquire_has_fix = false; + gps_hold_until = 0; + gps_acquire_started_at = 0; + gps_stable_started_at = 0; + gps_weighted_lat = 0; + gps_weighted_lon = 0; + gps_weighted_altitude = 0; + gps_weight_sum = 0; + gps_weight_count = 0; + gps_next_cache_update_at = 0; +} #endif void SensorManager::setTelemetryLocationAccessAvailable(bool available) { diff --git a/src/helpers/SensorManager.h b/src/helpers/SensorManager.h index 7f4ce3d4..e9bb23a0 100644 --- a/src/helpers/SensorManager.h +++ b/src/helpers/SensorManager.h @@ -16,6 +16,7 @@ class SensorManager { #if ENV_INCLUDE_GPS bool gps_cache_valid = false; bool gps_location_access_available = false; + bool gps_transport_available = true; bool gps_user_enabled = false; bool gps_acquiring = false; bool gps_acquire_has_fix = false; @@ -52,7 +53,12 @@ protected: void processGpsTelemetryFix(float lat, float lon, float altitude, unsigned long now); void loopGpsTelemetry(unsigned long now); void setGpsTelemetryUserEnabled(bool enabled); + void setGpsTelemetryTransportAvailable(bool available); + void resetGpsTelemetryTransportState(); bool isGpsTelemetryUserEnabled() const { return gps_user_enabled; } + bool isGpsTelemetryTransportAvailable() const { + return gps_transport_available; + } bool gpsTelemetryReceiverRequired(unsigned long now) const { return gps_acquiring || gpsTelemetryHoldActive(now); } @@ -109,6 +115,31 @@ public: } virtual LocationProvider* getLocationProvider() { return NULL; } virtual void setPowerSavingEnabled(bool enabled) { powersaving_enabled = enabled; } + // Runtime bridges may temporarily own the UART used by a GPS receiver. A + // sensor manager that actually detected a GPS on that UART overrides these + // hooks; I2C GPS providers and boards without GPS remain unaffected. + virtual bool gpsUsesSerialUart(uint8_t uart) const { + (void)uart; + return false; + } + // A universal image may need to reserve a UART even when a receiver did not + // speak during its bounded boot probe. The default has no such uncertainty; + // hardware-specific managers can fail closed for a known shared connector. + virtual bool gpsSerialTransportMayConflict(uint8_t uart) const { + return gpsUsesSerialUart(uart); + } + // True only when the detected receiver can be made electrically silent on + // the selected UART. Stopping the MCU UART alone is not sufficient when a + // still-powered GPS continues driving its TX pin. + virtual bool gpsSerialTransportCanYield(uint8_t uart) const { + (void)uart; + return false; + } + virtual bool setGpsSerialTransportBlocked(uint8_t uart, bool blocked) { + (void)uart; + (void)blocked; + return false; + } // Helper functions to manage setting by keys (useful in many places ...) const char* getSettingByKey(const char* key) { diff --git a/src/helpers/TempRadioLeaseDeadline.h b/src/helpers/TempRadioLeaseDeadline.h new file mode 100644 index 00000000..2125876e --- /dev/null +++ b/src/helpers/TempRadioLeaseDeadline.h @@ -0,0 +1,38 @@ +#pragma once + +#include + +namespace mesh { + +// Scheduled TempRadio uses wall-clock epochs so independently managed nodes +// can enter one common window. The wall clock may legitimately be corrected +// backward by mesh/GPS/NTP consensus, however, and that must never extend a +// temporary modulation lease. Anchor the accepted end to 64-bit uptime too; +// wall time decides when to start, while either clock is allowed to end it. +class TempRadioLeaseDeadline { +public: + static uint64_t fromEpochEnd(uint64_t now_uptime_millis, + uint32_t now_epoch, + uint32_t end_epoch) { + if (end_epoch <= now_epoch) return now_uptime_millis; + return now_uptime_millis + + (uint64_t)(end_epoch - now_epoch) * 1000ULL; + } + + static bool expired(uint64_t now_uptime_millis, + uint64_t hard_end_uptime_millis) { + return hard_end_uptime_millis != 0 + && now_uptime_millis >= hard_end_uptime_millis; + } + + static uint32_t secondsUntil(uint64_t now_uptime_millis, + uint64_t hard_end_uptime_millis) { + if (hard_end_uptime_millis == 0) return UINT32_MAX; + if (now_uptime_millis >= hard_end_uptime_millis) return 0; + uint64_t remaining_millis = hard_end_uptime_millis - now_uptime_millis; + uint64_t seconds = (remaining_millis + 999ULL) / 1000ULL; + return seconds > UINT32_MAX ? UINT32_MAX : (uint32_t)seconds; + } +}; + +} // namespace mesh diff --git a/src/helpers/TempRadioReplyBarrier.h b/src/helpers/TempRadioReplyBarrier.h new file mode 100644 index 00000000..1991b597 --- /dev/null +++ b/src/helpers/TempRadioReplyBarrier.h @@ -0,0 +1,30 @@ +#pragma once + +namespace mesh { + +// A parameterized TempRadio command changes the modulation used to carry its +// own remote-CLI reply. A wall-clock delay cannot prove that the reply has +// left a busy outbound queue, so keep the scheduled handoff blocked by the +// exact packet object until Dispatcher reports TX completion. This class is +// deliberately pointer-only: packet ownership stays with Dispatcher. +class TempRadioReplyBarrier { + const void* _packet; + +public: + TempRadioReplyBarrier() : _packet(nullptr) {} + + void arm(const void* packet) { _packet = packet; } + void clear() { _packet = nullptr; } + + bool waiting() const { return _packet != nullptr; } + + bool complete(const void* packet) { + if (_packet == nullptr || packet != _packet) return false; + _packet = nullptr; + return true; + } + + bool fail(const void* packet) { return complete(packet); } +}; + +} // namespace mesh diff --git a/src/helpers/UsbLogging.cpp b/src/helpers/UsbLogging.cpp index 4cec1d7f..1f8acc2a 100644 --- a/src/helpers/UsbLogging.cpp +++ b/src/helpers/UsbLogging.cpp @@ -4,6 +4,10 @@ #include #include +#if defined(NRF52_PLATFORM) + #include "NonBlockingWriteStream.h" +#endif + #if defined(MESH_DUAL_CDC_LOGGING) #if !defined(COMPANION_FEATURE_DEDICATED_USB_LOGGING) || \ !COMPANION_FEATURE_DEDICATED_USB_LOGGING @@ -42,11 +46,30 @@ class NullUsbLoggingStream : public Stream { static NullUsbLoggingStream null_usb_logging_stream; +static void setPlatformDebugOutputEnabled(bool enabled) { +#if defined(ESP32_PLATFORM) && defined(ENABLE_USB_INTERFACE) + // Arduino-ESP32 log_e()/ESP-IDF diagnostics otherwise write straight to + // the same UART/CDC stream used by Binary Companion. Keep that low-level + // route under the same runtime gate as MeshCore's own diagnostics. + Serial.setDebugOutput(enabled); +#else + (void)enabled; +#endif +} + #if defined(MESH_DUAL_CDC_LOGGING) static bool dedicated_usb_logging_port_configured = false; static bool dedicated_usb_logging_port_started = false; static bool dedicated_usb_logging_port_connected = false; static Adafruit_USBD_CDC dedicated_usb_logging_port; +static WholeRecordNonBlockingStream<> + nonblocking_dedicated_usb_logging_port(dedicated_usb_logging_port); +#elif defined(NRF52_PLATFORM) +// Single-CDC nRF52 roles need the same protection. In particular, BLE debug +// callbacks and packet logging write through usbLoggingPort() without going +// through MeshCore's formatted-debug helper. +static WholeRecordNonBlockingStream<> + nonblocking_primary_usb_logging_port(Serial); #endif bool isUsbLoggingEnabled() { @@ -56,6 +79,7 @@ bool isUsbLoggingEnabled() { void setUsbLoggingEnabled(bool enabled) { usb_logging_enabled.store(enabled, std::memory_order_relaxed); usb_logging_preference_known.store(true, std::memory_order_relaxed); + setPlatformDebugOutputEnabled(enabled); } bool saveUsbLoggingBootPreference(bool enabled) { @@ -64,6 +88,11 @@ bool saveUsbLoggingBootPreference(bool enabled) { } void beginUsbLoggingPort() { + // setup() calls this once before role preferences are loaded and again + // afterwards. The first call silences framework diagnostics on a protected + // ESP32 Companion stream; setUsbLoggingEnabled() restores them only when the + // saved setting explicitly enables logging. + setPlatformDebugOutputEnabled(isUsbLoggingEnabled()); #if defined(MESH_DUAL_CDC_LOGGING) if (dedicated_usb_logging_port_started || !usb_logging_preference_known.load(std::memory_order_relaxed) @@ -109,12 +138,16 @@ void serviceUsbLoggingPort() { Stream& usbLoggingPort() { #if defined(MESH_DUAL_CDC_LOGGING) if (isUsbLoggingEnabled() && dedicated_usb_logging_port_started) { - return dedicated_usb_logging_port; + return nonblocking_dedicated_usb_logging_port; } return null_usb_logging_stream; #else if (!isUsbLoggingEnabled()) return null_usb_logging_stream; - return Serial; + #if defined(NRF52_PLATFORM) + return nonblocking_primary_usb_logging_port; + #else + return Serial; + #endif #endif } diff --git a/src/helpers/WiFiSetupPortal.cpp b/src/helpers/WiFiSetupPortal.cpp index 5e2d30ce..83336a6b 100644 --- a/src/helpers/WiFiSetupPortal.cpp +++ b/src/helpers/WiFiSetupPortal.cpp @@ -10,6 +10,7 @@ #include #include #include +#include #include #include @@ -173,7 +174,8 @@ static void handleSave(PortalImpl* impl, WiFiClient& client, const char* body) { // A submitted configuration takes priority over a background retry of the // previously saved network. impl->recovery_connecting = false; - Serial.printf("WiFi setup: connecting to '%s'...\n", ssid); + mesh::usbLoggingPort().printf( + "WiFi setup: connecting to '%s'...\n", ssid); mesh::wifi::beginStation(ssid, password); uint32_t deadline = millis() + CONNECT_TIMEOUT_MS; while (impl->active && *impl->active && WiFi.status() != WL_CONNECTED @@ -187,7 +189,8 @@ static void handleSave(PortalImpl* impl, WiFiClient& client, const char* body) { sendResponse(client, 200, "OK", "text/html", "

Connection failed

" "

Check the SSID and password, then try again.

"); - Serial.println("WiFi setup: connection failed; setup AP remains active"); + mesh::usbLoggingPort().println( + "WiFi setup: connection failed; setup AP remains active"); return; } @@ -195,7 +198,8 @@ static void handleSave(PortalImpl* impl, WiFiClient& client, const char* body) { WiFi.disconnect(); sendResponse(client, 500, "Internal Server Error", "text/plain", "Connected, but the credentials could not be saved."); - Serial.println("WiFi setup: connected, but credential persistence failed"); + mesh::usbLoggingPort().println( + "WiFi setup: connected, but credential persistence failed"); return; } @@ -218,7 +222,8 @@ static void handleSave(PortalImpl* impl, WiFiClient& client, const char* body) { ip.c_str()); #endif sendResponse(client, 200, "OK", "text/html", response); - Serial.printf("WiFi setup: connected; IP %s\n", ip.c_str()); + mesh::usbLoggingPort().printf( + "WiFi setup: connected; IP %s\n", ip.c_str()); impl->close_ap_at = millis() + AP_CLOSE_DELAY_MS; } @@ -272,18 +277,22 @@ static void portalTask(void* arg) { if (impl->recovery_connecting) { if (WiFi.status() == WL_CONNECTED) { String ip = WiFi.localIP().toString(); - Serial.printf("WiFi setup: saved network recovered; IP %s\n", ip.c_str()); + mesh::usbLoggingPort().printf( + "WiFi setup: saved network recovered; IP %s\n", ip.c_str()); impl->recovery_connecting = false; break; } if (static_cast(now - impl->recovery_deadline) >= 0) { WiFi.disconnect(); impl->recovery_connecting = false; - Serial.println("WiFi setup: saved network still unavailable; setup AP remains active"); + mesh::usbLoggingPort().println( + "WiFi setup: saved network still unavailable; setup AP remains active"); } } else if (impl->recovery_ssid[0] && impl->recovery_interval_ms && static_cast(now - impl->next_recovery_at) >= 0) { - Serial.printf("WiFi setup: retrying saved network '%s'...\n", impl->recovery_ssid); + mesh::usbLoggingPort().printf( + "WiFi setup: retrying saved network '%s'...\n", + impl->recovery_ssid); mesh::wifi::beginStation( impl->recovery_ssid, impl->recovery_password); impl->recovery_connecting = true; @@ -358,8 +367,9 @@ bool WiFiSetupPortal::begin(const char* ap_name, SaveCallback save_callback, voi impl->task = nullptr; return false; } - Serial.printf("WiFi setup: join open AP '%s' and open http://%s/\n", - impl->ap_name, SETUP_IP.toString().c_str()); + mesh::usbLoggingPort().printf( + "WiFi setup: join open AP '%s' and open http://%s/\n", + impl->ap_name, SETUP_IP.toString().c_str()); return true; } @@ -394,9 +404,14 @@ bool WiFiSetupPortal::loadStoredCredentials(char* ssid, size_t ssid_size, char* password, size_t password_size) { if (!ssid || ssid_size == 0 || !password || password_size == 0) return false; Preferences prefs; - if (!prefs.begin("mesh-wifi", true)) return false; - String stored_ssid = prefs.getString("ssid", ""); - String stored_password = prefs.getString("password", ""); + // Opening read-only reports ESP_ERR_NVS_NOT_FOUND on a normal fresh install. + // Read-write creates the namespace once; guard absent String keys because + // Preferences::getString() also logs NOT_FOUND instead of returning quietly. + if (!prefs.begin("mesh-wifi", false)) return false; + String stored_ssid = prefs.isKey("ssid") + ? prefs.getString("ssid", "") : String(); + String stored_password = prefs.isKey("password") + ? prefs.getString("password", "") : String(); prefs.end(); if (stored_ssid.length() == 0 || stored_ssid.length() >= ssid_size || stored_password.length() >= password_size diff --git a/src/helpers/bridges/MQTTBridge.cpp b/src/helpers/bridges/MQTTBridge.cpp index 9f8e76db..8628740e 100644 --- a/src/helpers/bridges/MQTTBridge.cpp +++ b/src/helpers/bridges/MQTTBridge.cpp @@ -7,6 +7,7 @@ #include "../MQTTRuntimeBufferLifecycle.h" #include "../MQTTTopicRouter.h" #include "../TxtDataHelpers.h" +#include "../UsbLogging.h" #include "../WiFiPowerSave.h" #include #include @@ -25,6 +26,7 @@ #include #include #include +#include #include #include #include @@ -58,6 +60,21 @@ static constexpr size_t kNtpBuiltinFallbackCount = static_assert(MQTTBridge::kMaxNtpServers >= 1 + (int)kNtpBuiltinFallbackCount, "kMaxNtpServers must hold the custom primary plus all built-in fallbacks"); +static constexpr uint32_t MQTT_NTP_REFRESH_TIMEOUT_MS = 15000UL; +#ifndef ESP_PLATFORM +static constexpr uint32_t MQTT_NTP_NON_ESP_SETTLE_MS = 5000UL; +#endif +static constexpr unsigned long MQTT_NTP_MIN_VALID_EPOCH = 1767225600UL; + +static bool mqttNtpRefreshDue(uint32_t now, uint32_t last_sync, + uint32_t retry_at) { + return MQTTConnectionPolicy::ntpRefreshDue(now, last_sync, retry_at); +} + +static uint32_t mqttNtpRetryAt(uint32_t now) { + return MQTTConnectionPolicy::ntpRetryAt(now); +} + static void formatRadioInfo(float freq, float bw, int sf, int cr, char* dest, size_t dest_size) { char freq_text[20]; @@ -664,7 +681,7 @@ MQTTBridge::MQTTBridge(const MQTTNodeInfo& node_info, MQTTPrefs *obs, _manage_wifi(manage_wifi), _queue_count(0), _last_status_publish(0), _last_status_retry(0), _status_interval(300000), - _ntp_client(_ntp_udp, effectiveNtpPrimary(obs), 0, 60000), _last_ntp_sync(0), _ntp_synced(false), _ntp_sync_pending(false), _slots_setup_done(false), _max_active_slots(RUNTIME_MQTT_SLOTS), + _ntp_client(_ntp_udp, effectiveNtpPrimary(obs), 0, 60000), _last_ntp_sync(0), _ntp_refresh_retry_at(0), _ntp_refresh_started_at(0), _ntp_refresh_pending(false), _ntp_synced(false), _ntp_sync_pending(false), _ntp_reconnect_latch(), _slots_setup_done(false), _max_active_slots(RUNTIME_MQTT_SLOTS), _ntp_force_requested(false), _ntp_force_done(false), _ntp_force_result(false), _ntp_diag_requested(false), _ntp_diag_done(false), _ntp_diag_count(0), _ntp_estimate_requested(false), _ntp_estimate_done(false), @@ -847,6 +864,13 @@ void MQTTBridge::begin() { return; } + _ntp_refresh_retry_at = 0; + _ntp_refresh_started_at = 0; + _ntp_refresh_pending = false; + // Do not clear _ntp_reconnect_latch here. The WiFi event handler remains + // registered while MQTT is stopped, so a GOT_IP edge received between end() + // and begin() must survive until the restarted task can consume it. + // A restarted bridge may be using a different preset or custom endpoint. // Clear the old slot description before rebuilding it from _obs below. for (int i = 0; i < RUNTIME_MQTT_SLOTS; i++) { @@ -1316,10 +1340,9 @@ void MQTTBridge::initializeWiFiInTask() { MQTT_DEBUG_PRINTLN("WiFi connected: %s", IPAddress(info.got_ip.ip_info.ip.addr).toString().c_str()); setWifiOutage(AlertFaultPolicy::applyWifiGotIp(wifiOutage())); _wifi_reconnect_backoff_attempt = 0; - // Set flag to trigger NTP sync from loop() instead of doing it here - if (!_ntp_synced && !_ntp_sync_pending) { - _ntp_sync_pending = true; - } + // The 10-second status sampler can miss a short link flap. The event + // callback only records the edge; all NTP state remains task-owned. + _ntp_reconnect_latch.noteGotIp(); break; case ARDUINO_EVENT_WIFI_STA_DISCONNECTED: { const uint8_t reason = info.wifi_sta_disconnected.reason; @@ -1348,7 +1371,8 @@ void MQTTBridge::initializeWiFiInTask() { if (_manage_wifi) { mesh::wifi::beginStation(_obs->wifi_ssid, _obs->wifi_password); } - } else if (!_ntp_synced && !_ntp_sync_pending) { + } else if (!_ntp_synced.load(std::memory_order_acquire) + && !_ntp_sync_pending) { _ntp_sync_pending = true; // already connected but never synced - kick NTP now } @@ -1430,6 +1454,23 @@ void MQTTBridge::mqttTaskLoop() { _slots[i].last_reconnect_attempt = 0; } } + _ntp_reconnect_latch.noteGotIp(); + } + + const bool wifi_connected = WiFi.status() == WL_CONNECTED; + if (_ntp_reconnect_latch.consumeIfConnected(wifi_connected)) { + if (!_ntp_synced.load(std::memory_order_acquire)) { + _ntp_sync_pending = true; + } else { + // A successful reconnect gets one fresh clock sample regardless of how + // long the link was down. This also discards a short retry deadline that + // may be ambiguous after a very long offline interval. The normal + // continuously-connected cadence remains once per day. + _ntp_refresh_pending = false; + _ntp_refresh_started_at = 0; + _ntp_refresh_retry_at = + MQTTConnectionPolicy::ntpReconnectRefreshAt(now); + } } // Check for pending NTP sync (triggered from WiFi event handler) @@ -1439,7 +1480,8 @@ void MQTTBridge::mqttTaskLoop() { } // Retry NTP every 30s if initial sync failed (slots can't start without valid time) - if (!_ntp_synced && WiFi.status() == WL_CONNECTED) { + if (!_ntp_synced.load(std::memory_order_acquire) + && WiFi.status() == WL_CONNECTED) { static unsigned long last_ntp_retry = 0; if (now - last_ntp_retry >= 30000) { last_ntp_retry = now; @@ -1481,7 +1523,7 @@ void MQTTBridge::mqttTaskLoop() { // Deferred slot setup: wait until NTP is synced so JWT tokens get valid timestamps. // This avoids wasted TLS handshakes that get rejected due to bad token times. - if (_ntp_synced && !_slots_setup_done) { + if (_ntp_synced.load(std::memory_order_acquire) && !_slots_setup_done) { _slots_setup_done = true; // Redirect mbedTLS allocations to PSRAM to save ~40KB internal heap per TLS connection. @@ -1586,10 +1628,14 @@ void MQTTBridge::mqttTaskLoop() { // Periodic configuration check (throttled to avoid spam) checkConfigurationMismatch(); - // Periodic NTP refresh (every hour) - lightweight, non-blocking. + // Periodic NTP refresh (once per day) - lightweight, non-blocking. // Uses async SNTP instead of the heavy syncTimeWithNTP() which blocks Core 0 // for up to 20+ seconds with DNS lookups, UDP sockets, and retry loops. - if (WiFi.status() == WL_CONNECTED && now - _last_ntp_sync > 3600000) { + pollNtpRefresh(now); + if (WiFi.status() == WL_CONNECTED + && !_ntp_refresh_pending + && mqttNtpRefreshDue(now, _last_ntp_sync, + _ntp_refresh_retry_at)) { refreshNTP(); } @@ -2132,7 +2178,8 @@ void MQTTBridge::maintainSlotConnections() { // JWT tokens require valid timestamps unsigned long clock_sec = current_time; bool can_do_jwt = MQTTConnectionPolicy::jwtClockAvailable( - _ntp_synced, static_cast(clock_sec)); + _ntp_synced.load(std::memory_order_acquire), + static_cast(clock_sec)); // Count connected slots to inform reconnect decisions int connected_count = 0; @@ -3008,8 +3055,16 @@ void MQTTBridge::loop() { return; #else unsigned long now = millis(); - if (handleWiFiConnection(now) && !_ntp_synced) { - syncTimeWithNTP(); + const bool wifi_just_connected = handleWiFiConnection(now); + if (wifi_just_connected) { + if (!_ntp_synced.load(std::memory_order_acquire)) { + syncTimeWithNTP(); + } else { + _ntp_refresh_pending = false; + _ntp_refresh_started_at = 0; + _ntp_refresh_retry_at = + MQTTConnectionPolicy::ntpReconnectRefreshAt((uint32_t)now); + } } if (_ntp_sync_pending && WiFi.status() == WL_CONNECTED) { _ntp_sync_pending = false; @@ -3022,7 +3077,7 @@ void MQTTBridge::loop() { } // Deferred slot setup after NTP sync (non-ESP32 path) - if (_ntp_synced && !_slots_setup_done) { + if (_ntp_synced.load(std::memory_order_acquire) && !_slots_setup_done) { _slots_setup_done = true; for (int i = 0; i < RUNTIME_MQTT_SLOTS; i++) { if (_slots[i].enabled) { @@ -3055,8 +3110,13 @@ void MQTTBridge::loop() { // Periodic configuration check (throttled to avoid spam) checkConfigurationMismatch(); - // Periodic NTP refresh (every hour) - lightweight, non-blocking. - if (WiFi.status() == WL_CONNECTED && millis() - _last_ntp_sync > 3600000) { + // Periodic NTP refresh (once per day) - lightweight, non-blocking. + const uint32_t ntp_now = millis(); + pollNtpRefresh(ntp_now); + if (WiFi.status() == WL_CONNECTED + && !_ntp_refresh_pending + && mqttNtpRefreshDue(ntp_now, _last_ntp_sync, + _ntp_refresh_retry_at)) { refreshNTP(); } @@ -4037,12 +4097,96 @@ void MQTTBridge::storeRawRadioData(const uint8_t* raw_data, int len, float snr, // --------------------------------------------------------------------------- void MQTTBridge::refreshNTP() { - // Lightweight periodic refresh: just restart SNTP which runs async in the background. - // No blocking DNS, no UDP sockets, no retry loops on the MQTT task loop. - // The heavy syncTimeWithNTP() is only used for initial sync and WiFi reconnect recovery. + const uint32_t now = millis(); + if (_ntp_refresh_pending) return; + + // Both task-loop variants call this through mqttNtpRefreshDue(), but keep the + // deadline check here as well so a future direct caller cannot spin on the + // process-wide SNTP lease. + if (_ntp_refresh_retry_at != 0 + && !mqttNtpRefreshDue(now, _last_ntp_sync, + _ntp_refresh_retry_at)) { + return; + } + +#ifdef ESP_PLATFORM + mesh::sntp_coord::OperationLease sntp_operation( + mesh::sntp_coord::processWideCoordinator()); + if (!sntp_operation.tryAcquire()) { + _ntp_refresh_retry_at = mqttNtpRetryAt(now); + MQTT_DEBUG_PRINTLN( + "NTP refresh deferred; another firmware service owns SNTP"); + return; + } +#endif + + // Restart SNTP and let it run asynchronously. Resetting the status makes the + // later COMPLETED state proof of this request rather than an earlier boot + // sync. Do not advance _last_ntp_sync yet: an attempted configTime() is not a + // successful refresh, and MeshCore consumers read _rtc rather than time(). +#ifdef ESP_PLATFORM + sntp_set_sync_status(SNTP_SYNC_STATUS_RESET); +#endif configTime(0, 0, effectiveNtpPrimary(_obs)); - _last_ntp_sync = millis(); - MQTT_DEBUG_PRINTLN("NTP refresh triggered (async SNTP)"); + _ntp_refresh_retry_at = 0; + _ntp_refresh_started_at = millis(); + _ntp_refresh_pending = true; + MQTT_DEBUG_PRINTLN("NTP refresh requested (async SNTP)"); +} + +void MQTTBridge::pollNtpRefresh(uint32_t now) { + if (!_ntp_refresh_pending) return; + + const uint32_t elapsed = now - _ntp_refresh_started_at; + if (WiFi.status() != WL_CONNECTED) { + _ntp_refresh_pending = false; + _ntp_refresh_retry_at = mqttNtpRetryAt(now); + MQTT_DEBUG_PRINTLN("NTP refresh aborted; WiFi disconnected"); + return; + } + +#ifdef ESP_PLATFORM + const bool completed = + sntp_get_sync_status() == SNTP_SYNC_STATUS_COMPLETED; +#else + // Other Arduino WiFi stacks do not expose ESP-IDF's completion status. Keep + // their historical asynchronous configTime() behavior, but wait before + // pulling the system clock into the MeshCore RTC. + const bool completed = elapsed >= MQTT_NTP_NON_ESP_SETTLE_MS; +#endif + + if (completed) { + const time_t raw_system_time = time(nullptr); + uint32_t system_time = 0; + if (MQTTConnectionPolicy::checkedRtcEpoch( + static_cast(raw_system_time), + MQTT_NTP_MIN_VALID_EPOCH, system_time)) { + if (_rtc) _rtc->setCurrentTime(system_time); + _ntp_synced.store(true, std::memory_order_release); + _last_ntp_sync = now; + _ntp_refresh_retry_at = 0; + _ntp_refresh_pending = false; + MQTT_DEBUG_PRINTLN( + "NTP refresh completed; MeshCore RTC updated: %lu", system_time); + return; + } + + // A completion carrying an implausible time cannot become authoritative. + // The ESP status is edge-like and may reset after it is read, so retry the + // whole operation instead of waiting on a completion that was consumed. + _ntp_refresh_pending = false; + _ntp_refresh_retry_at = mqttNtpRetryAt(now); + MQTT_DEBUG_PRINTLN( + "NTP refresh returned implausible time %lld; retry scheduled", + (long long)raw_system_time); + return; + } + + if (elapsed >= MQTT_NTP_REFRESH_TIMEOUT_MS) { + _ntp_refresh_pending = false; + _ntp_refresh_retry_at = mqttNtpRetryAt(now); + MQTT_DEBUG_PRINTLN("NTP refresh timed out; retry scheduled"); + } } bool MQTTBridge::syncTimeWithNTP(bool force, bool primary_only) { @@ -4052,7 +4196,8 @@ bool MQTTBridge::syncTimeWithNTP(bool force, bool primary_only) { } unsigned long now = millis(); - if (!force && _ntp_synced && (now - _last_ntp_sync) < 5000) { + if (!force && _ntp_synced.load(std::memory_order_acquire) + && (now - _last_ntp_sync) < 5000) { return false; } @@ -4077,8 +4222,9 @@ bool MQTTBridge::syncTimeWithNTP(bool force, bool primary_only) { bool ntp_ok = false; unsigned long epochTime = 0; - const unsigned long kMinValidEpoch = 1767225600; // 2026-01-01 00:00:00 UTC + const unsigned long kMinValidEpoch = MQTT_NTP_MIN_VALID_EPOCH; const char* ntp_server_used = nullptr; + bool sntp_fallback_configured = false; _ntp_client.begin(); const int kMaxNtpRetriesPerServer = 2; @@ -4121,6 +4267,13 @@ bool MQTTBridge::syncTimeWithNTP(bool force, bool primary_only) { for (int s = 0; s < server_count && !ntp_ok; s++) { const char* server = servers[s]; MQTT_DEBUG_PRINTLN("SNTP fallback trying %s...", server); + mesh::sntp_coord::OperationLease sntp_operation( + mesh::sntp_coord::processWideCoordinator()); + if (!sntp_operation.tryAcquire()) { + MQTT_DEBUG_PRINTLN( + "SNTP fallback deferred; another firmware service owns SNTP"); + break; + } // configTime() already stops and reconfigures an active SNTP client on // both Arduino-ESP32 2.x and 3.x. Avoid the legacy unprefixed stop APIs, // which are no longer exported by esp_sntp.h in Arduino 3.x. @@ -4129,15 +4282,20 @@ bool MQTTBridge::syncTimeWithNTP(bool force, bool primary_only) { for (int i = 0; i < 20; i++) { delay(500); if (sntp_get_sync_status() != SNTP_SYNC_STATUS_COMPLETED) continue; - epochTime = (unsigned long)time(nullptr); - if (epochTime >= kMinValidEpoch) { + const time_t raw_system_time = time(nullptr); + uint32_t checked_system_time = 0; + if (MQTTConnectionPolicy::checkedRtcEpoch( + static_cast(raw_system_time), kMinValidEpoch, + checked_system_time)) { + epochTime = checked_system_time; ntp_ok = true; ntp_server_used = server; + sntp_fallback_configured = true; MQTT_DEBUG_PRINTLN("SNTP fallback succeeded on %s: %lu", server, epochTime); } else { MQTT_DEBUG_PRINTLN( - "SNTP fallback: %s synced an implausible epoch %lu", server, - epochTime); + "SNTP fallback: %s synced an implausible epoch %lld", server, + (long long)raw_system_time); } break; } @@ -4146,7 +4304,9 @@ bool MQTTBridge::syncTimeWithNTP(bool force, bool primary_only) { #endif if (!ntp_ok) { - const unsigned long system_time = (unsigned long)time(nullptr); + uint32_t system_time = 0; + MQTTConnectionPolicy::checkedRtcEpoch( + static_cast(time(nullptr)), kMinValidEpoch, system_time); const unsigned long rtc_time = _rtc ? (unsigned long)_rtc->getCurrentTime() : 0; const MQTTConnectionPolicy::ClockSource source = @@ -4171,14 +4331,36 @@ bool MQTTBridge::syncTimeWithNTP(bool force, bool primary_only) { accepted.tv_usec = 0; settimeofday(&accepted, nullptr); - if (ntp_server_used) configTime(0, 0, ntp_server_used); + if (ntp_server_used && !sntp_fallback_configured) { + // A direct NTPClient response has already made the clock authoritative. + // Re-arm background SNTP without letting this asynchronous convenience + // race a callback-owning firmware service. Failure here does not discard + // the fresh direct response; the task loop retries the re-arm shortly. + mesh::sntp_coord::OperationLease sntp_operation( + mesh::sntp_coord::processWideCoordinator()); + if (sntp_operation.tryAcquire()) { + configTime(0, 0, ntp_server_used); + _ntp_refresh_retry_at = 0; + } else { + _ntp_refresh_retry_at = mqttNtpRetryAt(millis()); + MQTT_DEBUG_PRINTLN( + "Background SNTP re-arm deferred; another firmware service owns SNTP"); + } + } else if (sntp_fallback_configured) { + // The successful fallback's configTime() remains the active async client. + _ntp_refresh_retry_at = 0; + } if (_rtc) { _rtc->setCurrentTime(epochTime); } - bool was_ntp_synced = _ntp_synced; - _ntp_synced = true; + bool was_ntp_synced = _ntp_synced.load(std::memory_order_acquire); + _ntp_synced.store(true, std::memory_order_release); + // A blocking/direct sync already copied its accepted epoch into _rtc, so + // it supersedes any older asynchronous periodic request. + _ntp_refresh_pending = false; + _ntp_refresh_started_at = 0; _last_ntp_sync = millis(); sync_in_progress = false; @@ -4361,20 +4543,22 @@ bool MQTTBridge::ntpDiag(char* reply, size_t reply_size, bool verbose) { if (_ntp_diag_results[i].ok) ok_count++; } - if (verbose) { + if (verbose && mesh::isUsbLoggingEnabled()) { // Detailed table to the serial console; reply carries a short summary (the operator // sees the table above the "-> " line). Mirrors the dumpLogFile() convention. - Serial.printf("NTP diag - %d server(s):\r\n", _ntp_diag_count); + Stream& output = mesh::usbLoggingPort(); + output.printf("NTP diag - %d server(s):\r\n", _ntp_diag_count); for (int i = 0; i < _ntp_diag_count; i++) { const NtpDiagResult& r = _ntp_diag_results[i]; if (r.ok) { time_t t = (time_t)r.epoch; struct tm* tmv = gmtime(&t); - Serial.printf(" %-20s OK %04d-%02d-%02d %02d:%02d:%02d UTC\r\n", - r.server, tmv->tm_year + 1900, tmv->tm_mon + 1, tmv->tm_mday, - tmv->tm_hour, tmv->tm_min, tmv->tm_sec); + output.printf( + " %-20s OK %04d-%02d-%02d %02d:%02d:%02d UTC\r\n", + r.server, tmv->tm_year + 1900, tmv->tm_mon + 1, + tmv->tm_mday, tmv->tm_hour, tmv->tm_min, tmv->tm_sec); } else { - Serial.printf(" %-20s FAIL\r\n", r.server); + output.printf(" %-20s FAIL\r\n", r.server); } } snprintf(reply, reply_size, "> NTP diag: %d/%d OK (see console)", ok_count, _ntp_diag_count); diff --git a/src/helpers/bridges/MQTTBridge.h b/src/helpers/bridges/MQTTBridge.h index ad1581dc..4a2be847 100644 --- a/src/helpers/bridges/MQTTBridge.h +++ b/src/helpers/bridges/MQTTBridge.h @@ -10,6 +10,7 @@ #include #include #include "helpers/JWTHelper.h" +#include "helpers/MQTTConnectionPolicy.h" #include "helpers/MQTTPacketFilter.h" #include "helpers/MQTTPresets.h" #include "helpers/MQTTLifecycle.h" @@ -221,8 +222,12 @@ private: WiFiUDP _ntp_udp; NTPClient _ntp_client; unsigned long _last_ntp_sync; - volatile bool _ntp_synced; - bool _ntp_sync_pending; // Flag to trigger NTP sync from loop() instead of event handler + unsigned long _ntp_refresh_retry_at; + unsigned long _ntp_refresh_started_at; + bool _ntp_refresh_pending; + std::atomic _ntp_synced; + bool _ntp_sync_pending; // Owned by the MQTT task; the WiFi callback only records a GOT_IP edge + MQTTConnectionPolicy::NtpReconnectLatch _ntp_reconnect_latch; bool _slots_setup_done; // Deferred: slots set up after NTP sync // WiFi.onEvent() handler registered once and never removed by end(); the bridge // object is reused across restarts, so re-registering would leak handlers and @@ -507,7 +512,8 @@ private: void queuePacket(mesh::Packet* packet, bool is_tx); void dequeuePacket(); bool isAnySlotConnected(); - void refreshNTP(); // Lightweight periodic NTP refresh (non-blocking) + void refreshNTP(); // Start a lightweight daily/reconnect NTP refresh (non-blocking) + void pollNtpRefresh(uint32_t now); // Copy a completed async refresh into MeshCore's RTC void runNtpDiagProbe(); // Probe every server for connectivity; never sets the clock. Core 0 only. void runNtpEstimateProbe(); // Query the first usable server without changing any clock. Core 0 only. // Populates dst_out/std_out with TimeChangeRules for the given IANA or @@ -700,10 +706,12 @@ public: * Performs blocking NTP I/O and must only be called from the MQTT task (Core 0). * Other tasks (e.g. the CLI on Core 1) must use requestForcedNtpSync() instead. */ bool syncTimeWithNTP(bool force = false, bool primary_only = false); - /** Request a forced NTP sync from another task (e.g. CLI on Core 1). Marshals the - * work onto the MQTT task so all NTP I/O stays on Core 0, then blocks up to - * timeout_ms for the result. Returns true if the sync succeeded, false on failure, - * timeout, or if the bridge is not running. */ + /** Request a forced clock refresh from another task (e.g. CLI on Core 1). + * Marshals NTP I/O onto the MQTT task, then blocks up to timeout_ms for the + * result. This validation request tests only the configured primary server + * and never accepts a retained RTC/system clock as success, so a true result + * from a nonzero-timeout call proves a fresh NTP response. A zero timeout is + * fire-and-forget and proves only that the request was queued. */ bool requestForcedNtpSync(uint32_t timeout_ms = 30000); /** Probe every configured NTP server (custom primary + built-in fallbacks) for * connectivity and report each server's reported time WITHOUT touching the system @@ -716,8 +724,11 @@ public: bool requestNtpTimeEstimate(); /** Poll/consume the queued estimate. finished=false means it is still pending. */ bool takeNtpTimeEstimate(uint32_t& epoch, bool& finished); - /** True after this bridge has successfully set the RTC from NTP this boot. */ - bool hasNtpTime() const { return _ntp_synced; } + /** True after the bridge established a usable clock this boot. This may be a + * plausible retained RTC/system clock accepted after NTP was unavailable. */ + bool hasNtpTime() const { + return _ntp_synced.load(std::memory_order_acquire); + } static void formatMqttStatusReply(char* buf, size_t bufsize, const MQTTPrefs* obs); /** On-demand publish-health + heap snapshot for `get mqtt.stats` (per-slot ok/err, * outbox size, free/max heap, queue depth). */ diff --git a/src/helpers/esp32/SntpOperationCoordinator.h b/src/helpers/esp32/SntpOperationCoordinator.h new file mode 100644 index 00000000..46d732c2 --- /dev/null +++ b/src/helpers/esp32/SntpOperationCoordinator.h @@ -0,0 +1,102 @@ +#pragma once + +#include +#include + +namespace mesh { +namespace sntp_coord { + +using CleanupHook = void (*)(); + +// ESP-IDF exposes one process-wide SNTP client and one notification callback. +// Keep callback installation/configTime() sequences mutually exclusive across +// otherwise independent firmware features. Acquisition is deliberately +// non-blocking: a caller can retry through its own bounded policy instead of +// deadlocking behind a failed network operation. +class OperationCoordinator { +public: + OperationCoordinator() : owner_(0), next_generation_(1) {} + + bool owns(uint32_t generation) const { + return generation != 0 + && owner_.load(std::memory_order_acquire) == generation; + } + +private: + friend class OperationLease; + + bool tryAcquire(uint32_t& generation) { + if (generation != 0) return false; + + uint32_t candidate = 0; + do { + candidate = next_generation_.fetch_add(1, std::memory_order_relaxed); + } while (candidate == 0); + + uint32_t expected = 0; + if (!owner_.compare_exchange_strong( + expected, candidate, std::memory_order_acq_rel, + std::memory_order_acquire)) { + return false; + } + generation = candidate; + return true; + } + + bool release(uint32_t generation, CleanupHook cleanup) { + // Run the callback cleanup before publishing the coordinator as free. This + // prevents a new owner from installing its callback and then having a stale + // owner's teardown clear it. Only OperationLease can release a generation. + if (!owns(generation)) return false; + if (cleanup != nullptr) cleanup(); + uint32_t expected = generation; + return owner_.compare_exchange_strong( + expected, 0, std::memory_order_acq_rel, std::memory_order_acquire); + } + + std::atomic owner_; + std::atomic next_generation_; +}; + +class OperationLease { +public: + explicit OperationLease(OperationCoordinator& coordinator, + CleanupHook cleanup = nullptr) + : coordinator_(coordinator), cleanup_(cleanup), generation_(0) {} + + ~OperationLease() { release(); } + + bool tryAcquire() { return coordinator_.tryAcquire(generation_); } + + bool owns() const { return coordinator_.owns(generation_); } + + uint32_t generation() const { return generation_; } + + bool release() { + if (generation_ == 0) return false; + const uint32_t releasing = generation_; + // Make repeated release attempts inert even if cleanup code indirectly + // observes this lease. + generation_ = 0; + return coordinator_.release(releasing, cleanup_); + } + + OperationLease(const OperationLease&) = delete; + OperationLease& operator=(const OperationLease&) = delete; + +private: + OperationCoordinator& coordinator_; + CleanupHook cleanup_; + uint32_t generation_; +}; + +// A function-local static in an external-linkage inline function denotes one +// object program-wide under the C++ ODR, including the Arduino-ESP32 2.x C++11 +// toolchain. Both the font repair and pull-OTA paths use this exact instance. +inline OperationCoordinator& processWideCoordinator() { + static OperationCoordinator coordinator; + return coordinator; +} + +} // namespace sntp_coord +} // namespace mesh diff --git a/src/helpers/esp32/TlsClockValidity.h b/src/helpers/esp32/TlsClockValidity.h new file mode 100644 index 00000000..3a7d2952 --- /dev/null +++ b/src/helpers/esp32/TlsClockValidity.h @@ -0,0 +1,46 @@ +#pragma once + +#include +#include + +namespace mesh { +namespace tls_clock { + +// A certificate-validating TLS handshake must not treat an uninitialised or +// wrapped wall clock as usable. The fresh-proof bit is deliberately supplied by +// caller: a plausible retained RTC alone is not evidence that NTP answered in +// the operation which is about to download trusted content. +static constexpr time_t kMinimumValidEpoch = (time_t)1767225600; // 2026-01-01 UTC + +constexpr bool timeIsValid(time_t now) { + return now >= kMinimumValidEpoch; +} + +constexpr bool proofIsValid(bool fresh_proof, bool wifi_connected, + time_t now) { + return fresh_proof && wifi_connected && timeIsValid(now); +} + +// `now - proven_at` is the standard modulo-uint32_t elapsed-time calculation, +// so a proof remains valid across the one millis() rollover that a bounded +// operation can cross. Callers must reset the fresh-proof/generation state +// before every operation and must not retain a proof for a complete 2^32-ms +// cycle, where a 32-bit elapsed clock necessarily becomes ambiguous. +constexpr bool proofAgeIsValid(bool fresh_proof, uint32_t now, + uint32_t proven_at, + uint32_t maximum_age_millis) { + return fresh_proof && now - proven_at <= maximum_age_millis; +} + +// A callback can already be in flight when its owner unregisters it. Binding +// the proof to the lease generation makes such a late callback fail closed if +// a later operation of the same feature has already started. +constexpr bool proofGenerationIsValid(bool fresh_proof, + uint32_t proof_generation, + uint32_t expected_generation) { + return fresh_proof && expected_generation != 0 + && proof_generation == expected_generation; +} + +} // namespace tls_clock +} // namespace mesh diff --git a/src/helpers/esp32/WebConfigServer.cpp b/src/helpers/esp32/WebConfigServer.cpp index bf3eb0b5..b5d1a505 100644 --- a/src/helpers/esp32/WebConfigServer.cpp +++ b/src/helpers/esp32/WebConfigServer.cpp @@ -26,6 +26,7 @@ #include "WebConfigHtml.h" #include "helpers/CLICommandUtils.h" +#include "helpers/UsbLogging.h" #include "helpers/WebConfigKeys.h" #include "helpers/WiFiPowerSave.h" #include "helpers/esp32/WiFiRadioPolicy.h" @@ -389,8 +390,11 @@ WebConfigServer::~WebConfigServer() { bool WebConfigServer::loadEnabled(bool default_value) { Preferences nvs; - if (!nvs.begin("mesh-webui", true)) return default_value; - bool enabled = nvs.getBool("enabled", default_value); + // A missing namespace is the normal erased-device state. Read-write creates + // it once instead of making Arduino Preferences log NOT_FOUND to Serial. + if (!nvs.begin("mesh-webui", false)) return default_value; + bool enabled = nvs.isKey("enabled") + ? nvs.getBool("enabled", default_value) : default_value; nvs.end(); return enabled; } @@ -405,8 +409,9 @@ bool WebConfigServer::saveEnabled(bool enabled) { bool WebConfigServer::loadCliEnabled(bool default_value) { Preferences nvs; - if (!nvs.begin("mesh-webui", true)) return default_value; - bool enabled = nvs.getBool("cli", default_value); + if (!nvs.begin("mesh-webui", false)) return default_value; + bool enabled = nvs.isKey("cli") + ? nvs.getBool("cli", default_value) : default_value; nvs.end(); return enabled; } @@ -426,11 +431,16 @@ bool WebConfigServer::loadStandaloneWiFi(char* ssid, size_t ssid_len, ssid[0] = 0; password[0] = 0; Preferences nvs; - if (!nvs.begin("mesh-wifi", true)) return false; - String stored_ssid = nvs.getString("ssid", ""); - String stored_password = nvs.getString("password", ""); - uint8_t stored_ps = nvs.getUChar( - "powersave", mesh::wifi::kDefaultPowerSave); + if (!nvs.begin("mesh-wifi", false)) return false; + // Preferences::getString() logs an error for a missing key even when the + // caller supplied a default. Missing credentials are expected on first boot. + String stored_ssid = nvs.isKey("ssid") + ? nvs.getString("ssid", "") : String(); + String stored_password = nvs.isKey("password") + ? nvs.getString("password", "") : String(); + uint8_t stored_ps = nvs.isKey("powersave") + ? nvs.getUChar("powersave", mesh::wifi::kDefaultPowerSave) + : mesh::wifi::kDefaultPowerSave; nvs.end(); if (stored_ssid.length() >= ssid_len || stored_password.length() >= password_len @@ -779,12 +789,13 @@ bool WebConfigServer::startSetupMode(char reply[]) { const esp_err_t sta_protocol_result = esp_wifi_set_protocol( WIFI_IF_STA, mesh::wifi::kProtocolMask); if (ap_protocol_result == ESP_OK && sta_protocol_result == ESP_OK) break; - Serial.printf("WebConfig protocol reset failed: AP=%d STA=%d\n", - (int)ap_protocol_result, (int)sta_protocol_result); + mesh::usbLoggingPort().printf( + "WebConfig protocol reset failed: AP=%d STA=%d\n", + (int)ap_protocol_result, (int)sta_protocol_result); ap_ok = false; } - Serial.printf( + mesh::usbLoggingPort().printf( "WebConfig AP attempt %u failed: mode_ok=%d disconnect_ok=%d mode=%d heap=%u largest=%u\n", (unsigned)attempt, mode_ok, disconnect_ok, (int)WiFi.getMode(), (unsigned)ESP.getFreeHeap(), @@ -969,8 +980,9 @@ void WebConfigServer::tick(uint32_t now) { break; case WebConfigBatch::StopAction::Warn: _stop_warned = true; - Serial.printf("WC: stop waiting for %lu handler(s); retaining session safely\n", - (unsigned long)refs); + mesh::usbLoggingPort().printf( + "WC: stop waiting for %lu handler(s); retaining session safely\n", + (unsigned long)refs); break; case WebConfigBatch::StopAction::Wait: break; @@ -993,9 +1005,12 @@ void WebConfigServer::tick(uint32_t now) { createServer(); _connect_deadline = 0; _last_activity = now; - Serial.printf("WebConfig ready: http://%s/\n", WiFi.localIP().toString().c_str()); + mesh::usbLoggingPort().printf( + "WebConfig ready: http://%s/\n", + WiFi.localIP().toString().c_str()); } else if (_connect_deadline && (int32_t)(now - _connect_deadline) >= 0) { - Serial.printf("WebConfig: WiFi '%s' unavailable; opening setup AP\n", _wifi_ssid); + mesh::usbLoggingPort().printf( + "WebConfig: WiFi '%s' unavailable; opening setup AP\n", _wifi_ssid); const bool retry_saved_wifi = _wifi_ssid[0] != 0; // Keep the WiFi driver running while changing from STA to AP+STA. // Powering it off here and starting an AP immediately can race the @@ -1008,7 +1023,7 @@ void WebConfigServer::tick(uint32_t now) { if (startSetupMode(ignored)) { _retry_saved_wifi_in_setup = retry_saved_wifi; } - Serial.println(ignored); + mesh::usbLoggingPort().println(ignored); } return; } @@ -1024,8 +1039,9 @@ void WebConfigServer::tick(uint32_t now) { _wifi_reconnect_tracker.noteDisconnected(now); if (_wifi_reconnect_tracker.retryDue(now)) { _wifi_reconnect_tracker.noteAttempt(now); - Serial.printf("WebConfig: WiFi still unavailable; retrying '%s'\n", - _wifi_ssid); + mesh::usbLoggingPort().printf( + "WebConfig: WiFi still unavailable; retrying '%s'\n", + _wifi_ssid); WiFi.mode(WIFI_STA); mesh::wifi::setStationAutoReconnect(true); WiFi.disconnect(false, false); @@ -1061,20 +1077,23 @@ void WebConfigServer::tick(uint32_t now) { _wifi_reconnect_tracker.noteConnected(); _mode = MODE_LAN; _last_activity = now; - Serial.printf("WebConfig: saved WiFi recovered; ready at http://%s/\n", - WiFi.localIP().toString().c_str()); + mesh::usbLoggingPort().printf( + "WebConfig: saved WiFi recovered; ready at http://%s/\n", + WiFi.localIP().toString().c_str()); } else if (_owns_wifi && _setup_reconnect_in_progress && (int32_t)(now - _setup_reconnect_deadline) >= 0) { WiFi.disconnect(false, false); _setup_reconnect_in_progress = false; _setup_reconnect_deadline = 0; - Serial.println("WebConfig: saved WiFi still unavailable; setup AP remains active"); + mesh::usbLoggingPort().println( + "WebConfig: saved WiFi still unavailable; setup AP remains active"); } else if (_owns_wifi && !_setup_reconnect_in_progress && _wifi_reconnect_tracker.retryDue(now)) { _wifi_reconnect_tracker.noteAttempt(now); _setup_reconnect_in_progress = true; _setup_reconnect_deadline = now + 20000UL; - Serial.printf("WebConfig: retrying saved WiFi '%s'\n", _wifi_ssid); + mesh::usbLoggingPort().printf( + "WebConfig: retrying saved WiFi '%s'\n", _wifi_ssid); mesh::wifi::beginStation(_wifi_ssid, _wifi_password); } } @@ -1087,15 +1106,18 @@ void WebConfigServer::tick(uint32_t now) { } if (WebConfigBatch::rebootDue(_reboot_at, now)) { - Serial.printf("WC: rebooting now (%s)\n", _batch_reboot_armed ? "confirmed" : "fallback"); + mesh::usbLoggingPort().printf( + "WC: rebooting now (%s)\n", + _batch_reboot_armed ? "confirmed" : "fallback"); _cb->rebootNow(); // does not return } if ((int32_t)(_diag_until - now) > 0 && (now - _diag_last) >= 1000) { _diag_last = now; - Serial.printf("WC: diag sta=%d heap=%u batch=%d/%d state=%d\n", - (int)WiFi.softAPgetStationNum(), (unsigned)ESP.getFreeHeap(), - (int)_batch_next, (int)_batch_count, (int)_batch_state); + mesh::usbLoggingPort().printf( + "WC: diag sta=%d heap=%u batch=%d/%d state=%d\n", + (int)WiFi.softAPgetStationNum(), (unsigned)ESP.getFreeHeap(), + (int)_batch_next, (int)_batch_count, (int)_batch_state); } // Refresh the stats snapshot only while a client is actually polling. @@ -1113,7 +1135,7 @@ void WebConfigServer::tick(uint32_t now) { _mode == MODE_SETUP, _wifi_ssid[0] != 0, now, _setup_started_at, (uint32_t)WEBCONFIG_UNCONFIGURED_SETUP_TIMEOUT_MS)) { - Serial.printf( + mesh::usbLoggingPort().printf( "WebConfig: WiFi still unconfigured after %lu minutes; powering off until reboot or explicit restart\n", (unsigned long)((uint32_t)WEBCONFIG_UNCONFIGURED_SETUP_TIMEOUT_MS / 60000UL)); requestStop(); @@ -1144,7 +1166,8 @@ void WebConfigServer::tick(uint32_t now) { _setup_reconnect_deadline = 0; _mode = MODE_LAN; _last_activity = now; - Serial.println("WebConfig: setup AP idle; saved WiFi recovery continues"); + mesh::usbLoggingPort().println( + "WebConfig: setup AP idle; saved WiFi recovery continues"); } else { requestStop(); } @@ -1179,8 +1202,9 @@ void WebConfigServer::serviceSetupWiFiHandoff(uint32_t now) { _setup_wifi_handoff_pending = false; _setup_wifi_handoff_deadline = 0; } - Serial.printf("WebConfig: joined '%s' at %s; waiting for browser handoff\n", - _wifi_ssid, ip); + mesh::usbLoggingPort().printf( + "WebConfig: joined '%s' at %s; waiting for browser handoff\n", + _wifi_ssid, ip); finishBatch(now); return; } @@ -1207,8 +1231,9 @@ void WebConfigServer::serviceSetupWiFiHandoff(uint32_t now) { _setup_wifi_handoff_deadline = 0; _setup_wifi_handoff_ip[0] = 0; } - Serial.printf("WebConfig: could not join '%s'; setup AP remains active\n", - _wifi_ssid); + mesh::usbLoggingPort().printf( + "WebConfig: could not join '%s'; setup AP remains active\n", + _wifi_ssid); finishBatch(now); } @@ -1314,11 +1339,14 @@ void WebConfigServer::drainBatch(uint32_t now) { // `set wifi.pwd` or `password` from the terminal must not reach the serial // log, which is a different audience from the browser session. if (_batch_kind == BATCH_CLI) { - Serial.printf("WC: cli %d/%d took %lums\n", (int)_batch_next, (int)_batch_count, - (unsigned long)(_batch_last_cmd - t0)); + mesh::usbLoggingPort().printf( + "WC: cli %d/%d took %lums\n", (int)_batch_next, + (int)_batch_count, (unsigned long)(_batch_last_cmd - t0)); } else { - Serial.printf("WC: cmd %d/%d '%s' took %lums\n", (int)_batch_next, (int)_batch_count, - e.key, (unsigned long)(_batch_last_cmd - t0)); + mesh::usbLoggingPort().printf( + "WC: cmd %d/%d '%s' took %lums\n", (int)_batch_next, + (int)_batch_count, e.key, + (unsigned long)(_batch_last_cmd - t0)); } if (!WebConfigBatch::drainFinished(_batch_next, _batch_count)) { return; // more commands next tick @@ -1368,8 +1396,8 @@ void WebConfigServer::drainBatch(uint32_t now) { // that will be shown to the operator before the setup AP is shut down. WiFi.mode(WIFI_AP_STA); WiFi.setAutoReconnect(false); - Serial.printf("WebConfig: testing saved WiFi '%s' before reboot\n", - _wifi_ssid); + mesh::usbLoggingPort().printf( + "WebConfig: testing saved WiFi '%s' before reboot\n", _wifi_ssid); mesh::wifi::beginStation(_wifi_ssid, _wifi_password); return; } @@ -1384,7 +1412,8 @@ void WebConfigServer::drainBatch(uint32_t now) { // Distinguishes "client stopped sending" from "server stopped accepting" when // a save's confirmation polls go missing on hardware. static void wcLogReq(AsyncWebServerRequest* r) { - Serial.printf("WC: http %s %s\n", r->methodToString(), r->url().c_str()); + mesh::usbLoggingPort().printf( + "WC: http %s %s\n", r->methodToString(), r->url().c_str()); } void WebConfigServer::attachRoutes() { @@ -1943,7 +1972,9 @@ void WebConfigServer::handleConfigPost(AsyncWebServerRequest* req) { uint32_t du = millis() + 60000; if (du == 0) du = 1; _diag_until = du; - Serial.printf("WC: config POST accepted, %d cmds, reboot=%d\n", count, (int)reboot_after); + mesh::usbLoggingPort().printf( + "WC: config POST accepted, %d cmds, reboot=%d\n", + count, (int)reboot_after); StaticJsonDocument<96> ack; ack["state"] = "pending"; @@ -1955,8 +1986,16 @@ void WebConfigServer::handleConfigPost(AsyncWebServerRequest* req) { } void WebConfigServer::handleConfigResult(AsyncWebServerRequest* req) { - if (_mode == MODE_OFF) { Serial.println("WC: result read -> 503 (mode off)"); req->send(503); return; } - if (!checkAuth(req)) { Serial.println("WC: result read -> 401"); req->send(401, "application/json", "{\"error\":\"auth\"}"); return; } + if (_mode == MODE_OFF) { + mesh::usbLoggingPort().println("WC: result read -> 503 (mode off)"); + req->send(503); + return; + } + if (!checkAuth(req)) { + mesh::usbLoggingPort().println("WC: result read -> 401"); + req->send(401, "application/json", "{\"error\":\"auth\"}"); + return; + } if (!req->hasParam("reqid")) { req->send(400, "application/json", "{\"error\":\"bad reqid\"}"); return; @@ -1969,7 +2008,9 @@ void WebConfigServer::handleConfigResult(AsyncWebServerRequest* req) { // Entry print BEFORE the lock (racy state read is fine for diag): if this // fires but no branch print follows, the handler is blocked on _mux. - Serial.printf("WC: result entry mode=%d state=%d\n", (int)_mode, (int)_batch_state); + mesh::usbLoggingPort().printf( + "WC: result entry mode=%d state=%d\n", + (int)_mode, (int)_batch_state); WCLock lock(_mux); // A CLI sequence occupying the shared slot is not a config save, whatever the // reqid says: its entries have no `key` and its results belong to the @@ -1979,7 +2020,7 @@ void WebConfigServer::handleConfigResult(AsyncWebServerRequest* req) { const WebConfigBatch::ResultOutcome outcome = WebConfigBatch::classifyResult(toSpecState(_batch_state), mine); if (outcome == WebConfigBatch::ResultOutcome::Idle) { - Serial.println("WC: result read -> idle"); + mesh::usbLoggingPort().println("WC: result read -> idle"); StaticJsonDocument<64> idle; idle["state"] = "idle"; idle["reqid"] = requested_reqid; @@ -2001,9 +2042,10 @@ void WebConfigServer::handleConfigResult(AsyncWebServerRequest* req) { req->send(200, "application/json", out); return; } - Serial.printf("WC: result read -> done (reboot=%d armed=%d all_ok=%d)\n", - (int)_batch_reboot, (int)_batch_reboot_armed, - (int)_batch_all_ok); + mesh::usbLoggingPort().printf( + "WC: result read -> done (reboot=%d armed=%d all_ok=%d)\n", + (int)_batch_reboot, (int)_batch_reboot_armed, + (int)_batch_all_ok); DynamicJsonDocument doc(6144); doc["state"] = "done"; doc["reboot"] = @@ -2206,7 +2248,9 @@ void WebConfigServer::handleCliPost(AsyncWebServerRequest* req) { strncpy(_batch_reqid, reqid, sizeof(_batch_reqid) - 1); _batch_reqid[sizeof(_batch_reqid) - 1] = 0; _batch_state = BATCH_PENDING; // tick() picks it up on the loop task - Serial.printf("WC: cli POST accepted, %d cmds, reboot=%d\n", count, (int)defer_reboot); + mesh::usbLoggingPort().printf( + "WC: cli POST accepted, %d cmds, reboot=%d\n", + count, (int)defer_reboot); StaticJsonDocument<96> ack; ack["state"] = "running"; diff --git a/src/helpers/esp32/WiFiOtaSeeder.cpp b/src/helpers/esp32/WiFiOtaSeeder.cpp index 27e2c700..390d363c 100644 --- a/src/helpers/esp32/WiFiOtaSeeder.cpp +++ b/src/helpers/esp32/WiFiOtaSeeder.cpp @@ -8,6 +8,7 @@ #include #include +#include #include #include #include @@ -60,8 +61,9 @@ void WiFiOtaSeeder::loop() { case ListenerAction::Start: seeder_server.begin(); listener_active = true; - Serial.printf("OTA seeder listening on :%u (motatool serve --tcp)\n", - static_cast(OTA_SEEDER_TCP_PORT)); + mesh::usbLoggingPort().printf( + "OTA seeder listening on :%u (motatool serve --tcp)\n", + static_cast(OTA_SEEDER_TCP_PORT)); break; case ListenerAction::Stop: stop(); @@ -90,7 +92,8 @@ void WiFiOtaSeeder::loop() { if (!WiFiOtaSeederPolicy::canAttachTcpFolder(context.folder_active, tcp_folder_attached)) { incoming.stop(); - Serial.println("OTA seeder rejected TCP client: another folder link is active"); + mesh::usbLoggingPort().println( + "OTA seeder rejected TCP client: another folder link is active"); return; } @@ -99,7 +102,8 @@ void WiFiOtaSeeder::loop() { if (!context.attach_folder_source(&seeder_source, OtaContext::FOLDER_LINK_TCP, "tcp", attach_reply, sizeof(attach_reply))) { seeder_client.stop(); - Serial.printf("OTA seeder rejected TCP client: %s\n", attach_reply); + mesh::usbLoggingPort().printf( + "OTA seeder rejected TCP client: %s\n", attach_reply); return; } @@ -114,7 +118,8 @@ void WiFiOtaSeeder::loop() { context.manager.resumeStaged(context.manager.fetchManifestId()); } context.manager.announce(); - Serial.printf("OTA seeder client connected (%s)\n", link_info); + mesh::usbLoggingPort().printf( + "OTA seeder client connected (%s)\n", link_info); } void WiFiOtaSeeder::stop() { diff --git a/src/helpers/esp32/WiFiRadioPolicy.h b/src/helpers/esp32/WiFiRadioPolicy.h index db13fe43..24c5b94e 100644 --- a/src/helpers/esp32/WiFiRadioPolicy.h +++ b/src/helpers/esp32/WiFiRadioPolicy.h @@ -54,8 +54,12 @@ struct EspNowBootChannelState { inline uint8_t loadConfiguredEspNowChannel() { uint8_t channel = kDefaultEspNowChannel; Preferences prefs; - if (prefs.begin("mesh-wifi", true)) { - channel = prefs.getUChar("espnow_ch", kDefaultEspNowChannel); + // A missing namespace is the normal first-boot state. Read-write creates it + // without Arduino Preferences emitting ESP_ERR_NVS_NOT_FOUND on Serial. + if (prefs.begin("mesh-wifi", false)) { + if (prefs.isKey("espnow_ch")) { + channel = prefs.getUChar("espnow_ch", kDefaultEspNowChannel); + } prefs.end(); } return validEspNowChannelOrDefault(channel, kDefaultEspNowChannel); diff --git a/src/helpers/ota/OtaApply.h b/src/helpers/ota/OtaApply.h index 7d40fb7a..7b1625ac 100644 --- a/src/helpers/ota/OtaApply.h +++ b/src/helpers/ota/OtaApply.h @@ -133,7 +133,9 @@ bool ota_installed_bootloader_identity(OtaBootloaderIdentity& out); inline uint8_t ota_nrf52_boot_result_or_zero(uint8_t value) { return ((value >= 0x90u && value <= 0x9Fu) || - (value >= 0xB0u && value <= 0xBCu) || + // B0..BC are the ordinary apply progress/failure/success results; + // SD-backed OTAFIX additionally uses BD for an authorization failure. + (value >= 0xB0u && value <= 0xBDu) || (value >= 0xC0u && value <= 0xCFu)) ? value : 0u; } diff --git a/src/helpers/sensors/EnvironmentSensorManager.cpp b/src/helpers/sensors/EnvironmentSensorManager.cpp index 7d84f83e..2d1ac898 100644 --- a/src/helpers/sensors/EnvironmentSensorManager.cpp +++ b/src/helpers/sensors/EnvironmentSensorManager.cpp @@ -1,5 +1,7 @@ #include "EnvironmentSensorManager.h" +#include "I2CAddressClaimPolicy.h" #include "EnvironmentI2CConfig.h" +#include "NmeaSentenceProbe.h" #include @@ -9,7 +11,122 @@ #define TELEM_WIRE &Wire // Use default I2C bus for Environment Sensors #endif +#ifdef NRF52_PLATFORM +static bool isValidNrfI2cPinPair(int32_t sda, int32_t scl) { + if (!mesh::isValidI2cPinPair(sda, scl, PINS_COUNT)) return false; +#ifdef NRF_P1 + static const uint32_t MAX_PHYSICAL_NRF_PIN = 48; +#else + static const uint32_t MAX_PHYSICAL_NRF_PIN = 32; +#endif + return digitalPinToPinName(static_cast(sda)) + < MAX_PHYSICAL_NRF_PIN + && digitalPinToPinName(static_cast(scl)) + < MAX_PHYSICAL_NRF_PIN; +} +#endif + +// The pinned Adafruit nRF52 Wire core has no transaction timeout. Reject a +// bus which is already held low, and make one bounded standard nine-clock +// recovery attempt, before entering this manager's blocking transaction loops. +// This cannot protect I2C users which ran earlier in boot or cure a peripheral +// which wedges mid-transaction, but it prevents discovery from entering a bus +// which is already visibly stuck and limits the number of exposed transactions. +static bool ensureI2cBusReleased(TwoWire* wire) { +#ifdef NRF52_PLATFORM + // Board::begin() may remap the primary Wire instance away from the variant's + // PIN_WIRE_* defaults (ProMicro is one example). Use the same board-level + // pins first so recovery never probes or drives an unrelated GPIO. + int32_t sda = -1; + int32_t scl = -1; +#if ENV_HAS_SECONDARY_I2C + if (wire == &Wire1) { + sda = ENV_PIN_SDA; + scl = ENV_PIN_SCL; + } else +#endif + { +#if defined(PIN_BOARD_SDA) && defined(PIN_BOARD_SCL) + sda = PIN_BOARD_SDA; + scl = PIN_BOARD_SCL; +#elif defined(PIN_WIRE_SDA) && defined(PIN_WIRE_SCL) + sda = PIN_WIRE_SDA; + scl = PIN_WIRE_SCL; +#endif + } + + if (!isValidNrfI2cPinPair(sda, scl)) return false; + + if (digitalRead(sda) == HIGH && digitalRead(scl) == HIGH) return true; + + wire->end(); + auto releaseLine = [](int32_t pin) { + // OUTPUT_S0D1 is nRF open drain: drive zero, disconnect for one. The input + // pull-up then observes the external bus without ever driving it high. + digitalWrite(pin, HIGH); + pinMode(pin, INPUT_PULLUP); + }; + auto driveLineLow = [](int32_t pin) { + // Prime the output latch before enabling an output, avoiding a brief + // push-pull high pulse if the previous latch happened to contain one. + digitalWrite(pin, LOW); + pinMode(pin, OUTPUT_S0D1); + }; + auto waitLineHigh = [](int32_t pin) { + for (uint8_t wait = 0; wait < 20; wait++) { + if (digitalRead(pin) == HIGH) return true; + delayMicroseconds(5); + } + return digitalRead(pin) == HIGH; + }; + + releaseLine(sda); + releaseLine(scl); + delayMicroseconds(10); + + // A secondary may have stopped halfway through a byte. Clock it to a byte + // boundary while never driving either open-drain line high. + if (digitalRead(scl) == HIGH) { + for (uint8_t pulse = 0; pulse < 9 && digitalRead(sda) == LOW; pulse++) { + driveLineLow(scl); + delayMicroseconds(5); + releaseLine(scl); + if (!waitLineHigh(scl)) break; + } + + if (waitLineHigh(scl)) { + // Generate a STOP (SDA low-to-high while SCL is confirmed high). + driveLineLow(sda); + delayMicroseconds(5); + releaseLine(scl); + if (waitLineHigh(scl)) { + releaseLine(sda); + delayMicroseconds(10); + } + } + } + +#if ENV_HAS_SECONDARY_I2C + if (wire == &Wire1) { + Wire1.setPins(static_cast(sda), static_cast(scl)); + Wire1.begin(); + Wire1.setClock(100000); + } else +#endif + { + Wire.setPins(static_cast(sda), static_cast(scl)); + Wire.begin(); + } + delayMicroseconds(10); + return digitalRead(sda) == HIGH && digitalRead(scl) == HIGH; +#else + (void)wire; + return true; +#endif +} + bool EnvironmentSensorManager::i2c_probe(TwoWire& wire, uint8_t addr) { + if (!mesh::isValidI2cPeripheralAddress(addr)) return false; wire.beginTransmission(addr); uint8_t error = wire.endTransmission(); return error == 0; @@ -189,11 +306,105 @@ static uint32_t gpsResetPin = -1; static bool i2cGPSFlag = false; static bool serialGPSFlag = false; #ifndef TELEM_RAK12500_ADDRESS +#ifdef GPS_ADDRESS +#define TELEM_RAK12500_ADDRESS GPS_ADDRESS +#else #define TELEM_RAK12500_ADDRESS 0x42 //RAK12500 Ublox GPS via i2c #endif +#endif #include static SFE_UBLOX_GNSS ublox_GNSS; +#ifndef RAK_UART_GPS_PROBE_TIMEOUT_MS +#define RAK_UART_GPS_PROBE_TIMEOUT_MS 1200UL +#endif + +static uint8_t rakGpsControlActiveLevel() { +#ifdef PIN_GPS_EN_ACTIVE + return PIN_GPS_EN_ACTIVE; +#else + return HIGH; +#endif +} + +static void setRakGpsControl(uint8_t pin, bool shared_power_rail, + bool enabled) { + pinMode(pin, OUTPUT); + if (shared_power_rail) { + // WB_IO2 controls every 3V3_S peripheral, not just the GPS. It must never + // be dropped as a GPS power-saving operation. + digitalWrite(pin, HIGH); + return; + } + const uint8_t active = rakGpsControlActiveLevel(); + digitalWrite(pin, enabled ? active : static_cast(!active)); +} + +static bool serialHasValidGpsSentence(Stream& serial, uint32_t timeout_ms) { + mesh::NmeaSentenceProbe probe; + const uint32_t started = millis(); + do { + while (serial.available()) { + if (probe.ingest(static_cast(serial.read()))) return true; + } + delay(5); + } while (static_cast(millis() - started) < timeout_ms); + return false; +} + +#if ENV_INCLUDE_INA3221 +static mesh::I2cRegisterProbeStatus readI2cRegister16(TwoWire* wire, + uint8_t address, + uint8_t reg, + uint16_t& value) { + wire->beginTransmission(address); + if (wire->write(reg) != 1) { + return mesh::I2cRegisterProbeStatus::Inconclusive; + } + const uint8_t error = wire->endTransmission(false); + if (error == 2) return mesh::I2cRegisterProbeStatus::NoResponse; + if (error != 0) return mesh::I2cRegisterProbeStatus::Inconclusive; + if (wire->requestFrom(address, static_cast(2)) != 2) { + return mesh::I2cRegisterProbeStatus::Inconclusive; + } + value = static_cast(wire->read()) << 8; + value |= static_cast(wire->read()); + return mesh::I2cRegisterProbeStatus::Success; +} + +static mesh::I2cIdentityProbeResult probeIna3221Identity(TwoWire* wire, + uint8_t address) { + uint16_t manufacturer = 0; + const mesh::I2cRegisterProbeStatus manufacturer_result = readI2cRegister16( + wire, address, INA3221_REG_MANUFACTURER_ID, manufacturer); + if (manufacturer_result != mesh::I2cRegisterProbeStatus::Success) { + return mesh::classifyIna3221Identity( + manufacturer_result, manufacturer, + mesh::I2cRegisterProbeStatus::Inconclusive, 0, + INA3221_MANUFACTURER_ID, INA3221_DIE_ID); + } + if (manufacturer != INA3221_MANUFACTURER_ID) { + // Require two successful non-INA reads. The bytes exposed by u-blox at + // 0xFE/0xFF are dynamic, so their values need not equal one another. This + // still cannot make a physical address collision safe. + uint16_t repeated_manufacturer = 0; + const mesh::I2cRegisterProbeStatus repeated_result = readI2cRegister16( + wire, address, INA3221_REG_MANUFACTURER_ID, repeated_manufacturer); + return mesh::classifyIna3221Identity( + manufacturer_result, manufacturer, + repeated_result, repeated_manufacturer, + INA3221_MANUFACTURER_ID, INA3221_DIE_ID); + } + + uint16_t die = 0; + const mesh::I2cRegisterProbeStatus die_result = readI2cRegister16( + wire, address, INA3221_REG_DIE_ID, die); + return mesh::classifyIna3221Identity( + manufacturer_result, manufacturer, die_result, die, + INA3221_MANUFACTURER_ID, INA3221_DIE_ID); +} +#endif + class RAK12500LocationProvider : public LocationProvider { long _lat = 0; long _lng = 0; @@ -221,7 +432,23 @@ public: bool isValid() override { return _fix; } long getTimestamp() override { return _epoch; } void sendSentence(const char * sentence) override { } - void reset() override { } + void reset() override { + // Discovery can replace and later reselect this singleton provider. Never + // expose the previous receiver's fix or time samples while the new device + // is still acquiring. + _lat = 0; + _lng = 0; + _alt = 0; + _sats = 0; + _epoch = 0; + _fix = false; + _next_time_check = 0; + _last_time_sync = 0; + _valid_time_samples = 0; + _time_sync_needed = true; + _time_sync_applied = false; + _last_valid_time_sync = 0; + } void begin() override { } void stop() override { } void loop() override { @@ -271,20 +498,6 @@ public: static RAK12500LocationProvider RAK12500_provider; #endif -// ============================================================ -// I2C bus scanner -// Probes every valid address and records which ones ACK. -// This runs before any sensor library is touched, so a missing -// or misbehaving device cannot stall or crash the boot sequence. -// ============================================================ - -static void scanI2CBus(TwoWire* wire, bool found[128]) { - for (uint8_t addr = 0x08; addr < 0x78; addr++) { - wire->beginTransmission(addr); - found[addr] = (wire->endTransmission() == 0); - } -} - // ============================================================ // Per-sensor init and query functions // @@ -630,14 +843,15 @@ static void query_bme680_bsec(uint8_t ch, uint8_t, CayenneLPP& lpp) { // ============================================================ struct SensorDef { - uint8_t address; + uint32_t address; // preserve invalid build overrides until validation const char* name; uint8_t (*init)(TwoWire* wire, uint8_t address); void (*query)(uint8_t channel, uint8_t sub_channel, CayenneLPP& telemetry); bool (*query_voltage)(uint8_t sub_channel, float& voltage); }; -#define TELEM_BOSCH_ALT_ADDR(addr) ((uint8_t)((addr) == 0x76 ? 0x77 : 0x76)) +#define TELEM_BOSCH_ALT_ADDR(addr) \ + ((addr) == 0x76 ? 0x77U : ((addr) == 0x77 ? 0x76U : (addr))) static const SensorDef SENSOR_TABLE[] = { #if ENV_INCLUDE_AHTX0 @@ -704,13 +918,15 @@ static const SensorDef SENSOR_TABLE[] = { static const size_t SENSOR_TABLE_SIZE = (sizeof(SENSOR_TABLE) / sizeof(SENSOR_TABLE[0])) - 1; // ============================================================ -// begin() - scan the I2C bus, then initialize only what was -// found. A sensor whose address does not ACK during the scan -// is never touched by a library call, preventing hangs or -// crashes caused by absent or misbehaving hardware. +// begin() - initialize an optional board GPS, then scan the I2C bus and invoke +// only table-driven sensor initializers whose address ACKed. GPS uses a +// device-specific probe outside SENSOR_TABLE; an address it positively claims +// is not handed to another driver. // ============================================================ bool EnvironmentSensorManager::begin() { + _active_sensor_count = 0; + #if ENV_INCLUDE_GPS #ifdef RAK_WISBLOCK_GPS // A volatile read anchors the externally linked marker even under LTO. @@ -725,45 +941,82 @@ bool EnvironmentSensorManager::begin() { #if ENV_HAS_SECONDARY_I2C #ifdef NRF52_PLATFORM - Wire1.setPins(ENV_PIN_SDA, ENV_PIN_SCL); - Wire1.setClock(100000); + if (!isValidNrfI2cPinPair(ENV_PIN_SDA, ENV_PIN_SCL)) { + MESH_DEBUG_PRINTLN("Second I2C skipped: invalid SDA/SCL pin pair"); + return true; + } + Wire1.setPins(static_cast(ENV_PIN_SDA), + static_cast(ENV_PIN_SCL)); Wire1.begin(); + Wire1.setClock(100000); #else Wire1.begin(ENV_PIN_SDA, ENV_PIN_SCL, 100000); #endif MESH_DEBUG_PRINTLN("Second I2C initialized on pins SDA: %d SCL: %d", ENV_PIN_SDA, ENV_PIN_SCL); #endif - _active_sensor_count = 0; // Avoid touching a shared I2C bus when no environmental drivers were built. if (SENSOR_TABLE_SIZE == 0) { return true; } - // Scan the I2C bus before touching any sensor library. + if (!ensureI2cBusReleased(TELEM_WIRE)) { + MESH_DEBUG_PRINTLN( + "I2C discovery skipped: bus pins unavailable or SDA/SCL remained low"); + return true; + } + + // Probe only unique addresses compiled into this image before invoking any + // table-driven sensor library. The old all-address scan performed 112 + // blocking Wire transactions even when only a few drivers were present. bool detected[128] = {}; - scanI2CBus(TELEM_WIRE, detected); + bool probed[128] = {}; + for (size_t i = 0; i < SENSOR_TABLE_SIZE; i++) { + const uint32_t configured_address = SENSOR_TABLE[i].address; + if (!mesh::isValidI2cPeripheralAddress(configured_address)) { + MESH_DEBUG_PRINTLN("Skipping invalid I2C sensor address %lX", + (unsigned long)configured_address); + continue; + } + const uint8_t address = static_cast(configured_address); + if (!probed[address]) { + probed[address] = true; + detected[address] = i2c_probe(*TELEM_WIRE, address); + } + } // Walk the sensor table and initialize only detected devices. for (size_t i = 0; i < SENSOR_TABLE_SIZE && _active_sensor_count < MAX_ACTIVE_SENSORS; i++) { const SensorDef& def = SENSOR_TABLE[i]; + if (!mesh::isValidI2cPeripheralAddress(def.address)) continue; + const uint8_t address = static_cast(def.address); +#ifdef RAK_WISBLOCK_GPS + if (mesh::shouldSkipSensorAtClaimedGpsAddress( + i2cGPSFlag, TELEM_WIRE == &Wire, + address, TELEM_RAK12500_ADDRESS)) { + MESH_DEBUG_PRINTLN( + "Skipping %s at I2C address %02X: address is claimed by RAK12500 GPS", + def.name, address); + continue; + } +#endif // One static driver instance per type: an alternate address is a fallback, not a second device. bool already_active = false; for (int j = 0; j < _active_sensor_count; j++) { if (_active_sensors[j].query == def.query) { already_active = true; break; } } if (already_active) continue; - if (!detected[def.address]) { - MESH_DEBUG_PRINTLN("%s not detected at I2C address %02X", def.name, def.address); + if (!detected[address]) { + MESH_DEBUG_PRINTLN("%s not detected at I2C address %02X", def.name, address); continue; } - uint8_t n = def.init(TELEM_WIRE, def.address); + uint8_t n = def.init(TELEM_WIRE, address); if (n == 0) { - MESH_DEBUG_PRINTLN("%s found at %02X but failed to initialize", def.name, def.address); + MESH_DEBUG_PRINTLN("%s found at %02X but failed to initialize", def.name, address); continue; } - MESH_DEBUG_PRINTLN("Found %s at address: %02X", def.name, def.address); - detected[def.address] = false; // consumed; later entries must not re-claim this device + MESH_DEBUG_PRINTLN("Found %s at address: %02X", def.name, address); + detected[address] = false; // consumed; later entries must not re-claim this device for (uint8_t sub = 0; sub < n && _active_sensor_count < MAX_ACTIVE_SENSORS; sub++) { _active_sensors[_active_sensor_count++] = { def.query, def.query_voltage, sub @@ -858,16 +1111,13 @@ bool EnvironmentSensorManager::setSettingValue(const char* name, const char* val 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) { + // A UART L76K cannot remove power from the shared 3V3_S rail. "Off" stops + // parsing and releases the MCU UART, but the still-powered receiver keeps + // driving its TX pin. It therefore does not make that pin safe for an + // external UART bridge. + if (serialGPSFlag) { _location->setGPSPowerSaving(false); - setGpsTelemetryUserEnabled(true); + setGpsTelemetryUserEnabled(enabled); return true; } #endif @@ -893,7 +1143,11 @@ void EnvironmentSensorManager::setPowerSavingEnabled(bool enabled) { #if defined(RAK_WISBLOCK_GPS) && defined(FORCE_GPS_ALIVE) if (serialGPSFlag) { _location->setGPSPowerSaving(false); - if (!gps_active) start_gps(); + if (gps_user_enabled) { + if (!gps_active) start_gps(); + } else if (gps_active) { + stop_gps(); + } return; } #endif @@ -916,6 +1170,17 @@ void EnvironmentSensorManager::setPowerSavingEnabled(bool enabled) { #if ENV_INCLUDE_GPS void EnvironmentSensorManager::initBasicGPS() { + // A repeated discovery pass must not steal a UART which an active bridge + // already owns. The bridge release path explicitly restores availability. + if (gps_serial_transport_blocked) { + MESH_DEBUG_PRINTLN("GPS discovery skipped: serial transport is owned by bridge"); + return; + } + + resetGpsTelemetryTransportState(); + gps_serial_transport = false; + gps_serial_transport_blocked = false; + Serial1.setPins(PIN_GPS_TX, PIN_GPS_RX); #ifdef GPS_BAUD_RATE @@ -943,6 +1208,7 @@ void EnvironmentSensorManager::initBasicGPS() { #endif if (gps_detected) { + gps_serial_transport = true; MESH_DEBUG_PRINTLN("GPS detected"); #ifdef PERSISTANT_GPS gps_active = true; @@ -960,6 +1226,26 @@ void EnvironmentSensorManager::initBasicGPS() { // or make a new location provider ... #ifdef RAK_WISBLOCK_GPS void EnvironmentSensorManager::rakGPSInit() { + // Preserve the established owner across repeated begin() calls. Clearing + // this flag and starting Serial1 here would silently take UART1 back from an + // active bridge. + if (gps_serial_transport_blocked) { + MESH_DEBUG_PRINTLN("RAK GPS discovery skipped: UART1 is owned by bridge"); + return; + } + + // A repeated begin() must not retain ownership or a provider from an older + // hardware probe. + i2cGPSFlag = false; + serialGPSFlag = false; + resetGpsTelemetryTransportState(); + gpsResetPin = static_cast(-1); + gps_active = false; + gps_detected = false; + gps_serial_transport = false; + gps_serial_transport_blocked = false; + _location = _configured_location; + Serial1.setPins(PIN_GPS_TX, PIN_GPS_RX); #ifdef GPS_BAUD_RATE @@ -968,9 +1254,18 @@ void EnvironmentSensorManager::rakGPSInit() { Serial1.begin(9600); #endif - // search for the correct IO standby pin depending on socket used - if (gpsIsAwake(WB_IO2)) { - _location->setPinEn(WB_IO2); // WB_IO2 is the power switch for all sensor and IO slots + // A RAK-derived board with a real GPS-enable pin must use it. Stock WisBlock + // bases have no dedicated GPS switch, so their fallback is shared WB_IO2. +#if defined(PIN_GPS_EN) && PIN_GPS_EN >= 0 + const uint8_t gps_control_pin = PIN_GPS_EN; + const bool shared_power_rail = false; +#else + const uint8_t gps_control_pin = WB_IO2; + const bool shared_power_rail = true; +#endif + + if (gpsIsAwake(gps_control_pin, shared_power_rail)) { + _location->setPinEn(gps_control_pin); } else { MESH_DEBUG_PRINTLN("No GPS found"); gps_active = false; @@ -993,28 +1288,69 @@ void EnvironmentSensorManager::rakGPSInit() { #endif } -bool EnvironmentSensorManager::gpsIsAwake(uint8_t ioPin) { -#if defined(ETHERNET_ENABLED) && defined(RAK_BOARD) - if (ioPin == WB_IO2) { - // WB_IO2 powers the Ethernet module on RAK baseboards. - return false; +bool EnvironmentSensorManager::gpsIsAwake(uint8_t ioPin, + bool shared_power_rail) { + if (!shared_power_rail) { + setRakGpsControl(ioPin, false, false); + delay(500); } -#endif - - // RAK3401 shares WB_IO2 with the radio PA supply. Probing a GPS must never - // pulse that rail low or turn the pin back into an input. -#ifndef RAK_3401 - // set initial waking state - pinMode(ioPin, OUTPUT); - digitalWrite(ioPin, LOW); - delay(500); - digitalWrite(ioPin, HIGH); -#endif + setRakGpsControl(ioPin, shared_power_rail, true); // give the receiver time to become responsive delay(500); - // Try to init RAK12500 on I2C - if (ublox_GNSS.begin(Wire) == true) { + // Prove the UART device with a complete checksum-valid GPS sentence. This + // must precede I2C probing so a UART GPS can coexist with INA3221 at 0x42. + if (serialHasValidGpsSentence(Serial1, RAK_UART_GPS_PROBE_TIMEOUT_MS)) { + MESH_DEBUG_PRINTLN("RAK12501 UART GPS identified with pin %i", ioPin); + gpsResetPin = ioPin; + serialGPSFlag = true; + gps_serial_transport = true; + gps_active = true; + gps_detected = true; + return true; + } + + if (!ensureI2cBusReleased(&Wire)) { + MESH_DEBUG_PRINTLN( + "Skipping RAK12500 probe: I2C pins unavailable or bus remains low"); + setRakGpsControl(ioPin, shared_power_rail, false); + return false; + } + + const uint32_t configured_gps_address = + static_cast(TELEM_RAK12500_ADDRESS); + if (!mesh::isValidI2cPeripheralAddress(configured_gps_address)) { + MESH_DEBUG_PRINTLN("Skipping invalid RAK12500 I2C address"); + setRakGpsControl(ioPin, shared_power_rail, false); + return false; + } + const uint8_t gps_i2c_address = + static_cast(configured_gps_address); + bool probe_i2c_gps = true; +#if ENV_INCLUDE_INA3221 + // Inspect the actual device at the GPS address. This must not depend on the + // configured INA address or telemetry bus: an unstrapped INA3221 can still + // physically remain at 0x42 when firmware expects it at 0x43. + const mesh::I2cIdentityProbeResult ina_identity = + probeIna3221Identity(&Wire, gps_i2c_address); + probe_i2c_gps = mesh::shouldProbeI2cGps(ina_identity); + if (!probe_i2c_gps) { + if (ina_identity == mesh::I2cIdentityProbeResult::Match) { + MESH_DEBUG_PRINTLN( + "Skipping RAK12500 probe at I2C address %02X: INA3221 identity confirmed", + gps_i2c_address); + } else { + MESH_DEBUG_PRINTLN( + "Skipping RAK12500 probe at I2C address %02X: INA identity read was inconclusive", + gps_i2c_address); + } + } +#endif + + // After two successful non-INA manufacturer reads, use the u-blox-specific + // probe. No software can make two physical devices at one address safe. + if (probe_i2c_gps + && ublox_GNSS.begin(Wire, gps_i2c_address) == true) { MESH_DEBUG_PRINTLN("RAK12500 GPS init correctly with pin %i", ioPin); ublox_GNSS.setI2COutput(COM_TYPE_UBX); ublox_GNSS.enableGNSS(true, SFE_UBLOX_GNSS_ID_GPS); @@ -1028,24 +1364,17 @@ bool EnvironmentSensorManager::gpsIsAwake(uint8_t ioPin) { ublox_GNSS.saveConfigSelective(VAL_CFG_SUBSEC_IOPORT); gpsResetPin = ioPin; i2cGPSFlag = true; + gps_serial_transport = false; gps_active = true; gps_detected = true; RAK12500_provider.setRTCClock(_location->getRTCClock()); + RAK12500_provider.reset(); _location = &RAK12500_provider; return true; - } else if (Serial1.available()) { // RAK12501 (L76K) on UART - MESH_DEBUG_PRINTLN("Serial GPS init correctly and is turned on"); - gpsResetPin = ioPin; - serialGPSFlag = true; - gps_active = true; - gps_detected = true; - return true; } -#ifndef RAK_3401 - pinMode(ioPin, INPUT); -#endif + setRakGpsControl(ioPin, shared_power_rail, false); MESH_DEBUG_PRINTLN("GPS did not init with this IO pin... try the next"); return false; } @@ -1059,7 +1388,15 @@ void EnvironmentSensorManager::armGpsPowerSavingCycle() { } void EnvironmentSensorManager::start_gps() { - if (gps_active) return; + if (gps_active || gps_serial_transport_blocked) return; + if (gps_serial_transport) { + Serial1.setPins(PIN_GPS_TX, PIN_GPS_RX); +#ifdef GPS_BAUD_RATE + Serial1.begin(GPS_BAUD_RATE); +#else + Serial1.begin(9600); +#endif + } gps_active = true; #ifdef RAK_WISBLOCK_GPS #ifdef FORCE_GPS_ALIVE @@ -1067,8 +1404,7 @@ void EnvironmentSensorManager::start_gps() { // an I2C u-blox receiver can also be explicitly returned to full power. if (i2cGPSFlag) ublox_GNSS.powerSaveMode(false); #else - pinMode(gpsResetPin, OUTPUT); - digitalWrite(gpsResetPin, HIGH); // WB_IO2 is the shared sensor power rail + setRakGpsControl(gpsResetPin, false, true); #endif #else _location->begin(); @@ -1095,12 +1431,15 @@ void EnvironmentSensorManager::stop_gps() { #ifdef RAK_WISBLOCK_GPS #ifdef FORCE_GPS_ALIVE // 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); + // still enter its internal power-save mode. Ending Serial1 stops the MCU + // parser, but a UART L76K remains powered and continues driving its TX pin. + if (i2cGPSFlag) { + ublox_GNSS.powerSaveMode(true); + } else if (serialGPSFlag) { + Serial1.end(); + } #else - pinMode(gpsResetPin, OUTPUT); - digitalWrite(gpsResetPin, LOW); // WB_IO2 + setRakGpsControl(gpsResetPin, false, false); #endif #else _location->stop(); @@ -1112,6 +1451,83 @@ void EnvironmentSensorManager::stop_gps() { } #endif // ENV_INCLUDE_GPS +bool EnvironmentSensorManager::gpsUsesSerialUart(uint8_t uart) const { +#if ENV_INCLUDE_GPS + return uart == 1 && gps_detected && gps_serial_transport; +#else + (void)uart; + return false; +#endif +} + +bool EnvironmentSensorManager::gpsSerialTransportMayConflict( + uint8_t uart) const { +#if ENV_INCLUDE_GPS +#if defined(RAK_WISBLOCK_GPS) \ + && defined(WITH_RS232_BRIDGE_GPS_CONFLICT_UART) + // Silence is not proof of physical absence: a cold RAK12501/L76K can start + // emitting NMEA after the bounded boot probe. Reserve the declared shared + // connector in the merged image. Exact legacy Serial1 bridge targets omit + // GPS support and this reservation; the merged image retains UART2. + if (uart == WITH_RS232_BRIDGE_GPS_CONFLICT_UART) return true; +#endif + return gpsUsesSerialUart(uart); +#else + (void)uart; + return false; +#endif +} + +bool EnvironmentSensorManager::gpsSerialTransportCanYield(uint8_t uart) const { +#if ENV_INCLUDE_GPS + if (!gpsUsesSerialUart(uart)) return false; +#if defined(RAK_WISBLOCK_GPS) && defined(FORCE_GPS_ALIVE) + // RAK12501 exposes no independently controlled standby/power pin. WB_IO2 is + // the shared 3V3_S rail and must remain high, so its L76K keeps transmitting + // NMEA after Serial1.end(). Two TX drivers on UART1 would electrically + // contend; require UART2 or physical removal/isolation of this GPS instead. + return false; +#elif defined(RAK_WISBLOCK_GPS) + // This profile is allowed to remove power with its validated GPS control. + return true; +#endif + // Generic NMEA receivers may share the same serial API but are safe to hand + // off only when the provider has a real enable pin that stop() deasserts. + return _location != NULL && _location->getPinEn() >= 0; +#else + (void)uart; + return false; +#endif +} + +bool EnvironmentSensorManager::setGpsSerialTransportBlocked(uint8_t uart, + bool blocked) { +#if ENV_INCLUDE_GPS + if (!gpsUsesSerialUart(uart)) return false; + if (gps_serial_transport_blocked == blocked) return true; + if (blocked && !gpsSerialTransportCanYield(uart)) return false; + + if (blocked) { + // Cancel remote-query acquisition/hold state before releasing the UART. + setGpsTelemetryTransportAvailable(false); + gps_serial_transport_blocked = true; + if (gps_active) stop_gps(); + Serial1.end(); + } else { + gps_serial_transport_blocked = false; + // Restoring availability restarts the receiver only when the user's GPS + // preference is on. Otherwise a future authorized location query may + // acquire it on demand. + setGpsTelemetryTransportAvailable(true); + } + return true; +#else + (void)uart; + (void)blocked; + return false; +#endif +} + #if ENV_INCLUDE_GPS || defined(ENV_INCLUDE_BME680_BSEC) void EnvironmentSensorManager::loop() { diff --git a/src/helpers/sensors/EnvironmentSensorManager.h b/src/helpers/sensors/EnvironmentSensorManager.h index 40691074..71cb1189 100644 --- a/src/helpers/sensors/EnvironmentSensorManager.h +++ b/src/helpers/sensors/EnvironmentSensorManager.h @@ -22,9 +22,12 @@ protected: bool gps_detected = false; bool gps_active = false; + bool gps_serial_transport = false; + bool gps_serial_transport_blocked = false; #if ENV_INCLUDE_GPS LocationProvider* _location; + LocationProvider* _configured_location; void armGpsPowerSavingCycle(); void start_gps(); void stop_gps(); @@ -35,13 +38,14 @@ protected: void initBasicGPS(); #ifdef RAK_BOARD void rakGPSInit(); - bool gpsIsAwake(uint8_t ioPin); + bool gpsIsAwake(uint8_t ioPin, bool shared_power_rail); #endif #endif public: #if ENV_INCLUDE_GPS - EnvironmentSensorManager(LocationProvider &location): _location(&location){}; + EnvironmentSensorManager(LocationProvider &location): + _location(&location), _configured_location(&location) {}; LocationProvider* getLocationProvider() { return _location; } #else EnvironmentSensorManager(){}; @@ -61,4 +65,8 @@ public: const char* getSettingValue(int i) const override; bool setSettingValue(const char* name, const char* value) override; void setPowerSavingEnabled(bool enabled) override; + bool gpsUsesSerialUart(uint8_t uart) const override; + bool gpsSerialTransportMayConflict(uint8_t uart) const override; + bool gpsSerialTransportCanYield(uint8_t uart) const override; + bool setGpsSerialTransportBlocked(uint8_t uart, bool blocked) override; }; diff --git a/src/helpers/sensors/I2CAddressClaimPolicy.h b/src/helpers/sensors/I2CAddressClaimPolicy.h new file mode 100644 index 00000000..ec3ace85 --- /dev/null +++ b/src/helpers/sensors/I2CAddressClaimPolicy.h @@ -0,0 +1,75 @@ +#pragma once + +#include + +namespace mesh { + +enum class I2cIdentityProbeResult : uint8_t { + NoResponse, + Match, + NotMatch, + Inconclusive, +}; + +enum class I2cRegisterProbeStatus : uint8_t { + NoResponse, + Success, + Inconclusive, +}; + +inline bool isValidI2cPeripheralAddress(uint32_t address) { + // 0x00..0x07 and 0x78..0x7f are reserved in the 7-bit address space. + return address >= 0x08 && address <= 0x77; +} + +inline bool isValidI2cPinPair(int32_t sda, int32_t scl, + uint32_t pin_count) { + return sda >= 0 && scl >= 0 + && static_cast(sda) < pin_count + && static_cast(scl) < pin_count + && sda != scl; +} + +inline I2cIdentityProbeResult classifyIna3221Identity( + I2cRegisterProbeStatus first_status, uint16_t first_value, + I2cRegisterProbeStatus second_status, uint16_t second_value, + uint16_t expected_manufacturer, uint16_t expected_die) { + if (first_status == I2cRegisterProbeStatus::NoResponse) { + return I2cIdentityProbeResult::NoResponse; + } + if (first_status != I2cRegisterProbeStatus::Success + || second_status != I2cRegisterProbeStatus::Success) { + return I2cIdentityProbeResult::Inconclusive; + } + if (first_value == expected_manufacturer) { + return second_value == expected_die + ? I2cIdentityProbeResult::Match + : I2cIdentityProbeResult::Inconclusive; + } + return second_value != expected_manufacturer + ? I2cIdentityProbeResult::NotMatch + : I2cIdentityProbeResult::Inconclusive; +} + +// Once a device-specific probe has positively identified the I2C GPS, do not +// hand the same address to a table-driven sensor initializer. An ACK scan can +// prove only that something answered; it cannot distinguish two devices wired +// to the same address or make that electrical configuration safe. +inline bool shouldSkipSensorAtClaimedGpsAddress(bool gps_claimed, + bool same_bus, + uint8_t sensor_address, + uint8_t gps_address) { + return gps_claimed && same_bus && sensor_address == gps_address; +} + +// A universal RAK image may support either an INA3221 or an I2C u-blox GPS at +// the GPS address. Check the actual device there first, independent of where +// the telemetry table expected an INA3221. An incomplete identity transaction +// is not proof that the device is safe to receive u-blox traffic. +inline bool shouldProbeI2cGps(I2cIdentityProbeResult identity) { + // Absence/NACK can be transient while an INA powers up. Only a stable, + // definitive non-INA result permits the device-specific u-blox exchange. + return identity == I2cIdentityProbeResult::NotMatch; +} + +} // namespace mesh diff --git a/src/helpers/sensors/NmeaSentenceProbe.h b/src/helpers/sensors/NmeaSentenceProbe.h new file mode 100644 index 00000000..85d0b154 --- /dev/null +++ b/src/helpers/sensors/NmeaSentenceProbe.h @@ -0,0 +1,121 @@ +#pragma once + +#include +#include + +namespace mesh { + +// Bounded, allocation-free proof that a UART stream contains a standard GPS +// NMEA sentence. Merely seeing one byte is not enough: RS-232 traffic or line +// noise must not be allowed to claim the GPS UART. +class NmeaSentenceProbe { + public: + bool ingest(uint8_t byte) { + if (_found) return true; + + if (byte == '$') { + resetSentence(); + _state = State::Body; + return false; + } + + switch (_state) { + case State::Idle: + return false; + case State::Body: + if (byte == '*') { + if (_body_length < 6 || !_header_valid) { + resetSentence(); + } else { + _state = State::ChecksumHigh; + } + return false; + } + if (byte < 0x20 || byte > 0x7e || _body_length >= kMaxBodyLength) { + resetSentence(); + return false; + } + validateHeaderByte(byte); + _checksum ^= byte; + ++_body_length; + return false; + case State::ChecksumHigh: { + const int nibble = hexNibble(byte); + if (nibble < 0) { + resetSentence(); + } else { + _expected_checksum = static_cast(nibble << 4); + _state = State::ChecksumLow; + } + return false; + } + case State::ChecksumLow: { + const int nibble = hexNibble(byte); + if (nibble >= 0) { + _expected_checksum |= static_cast(nibble); + _found = _header_valid && recognizedTalker() + && _expected_checksum == _checksum; + } + resetSentence(); + return _found; + } + } + return false; + } + + bool found() const { return _found; } + + private: + enum class State : uint8_t { Idle, Body, ChecksumHigh, ChecksumLow }; + static constexpr size_t kMaxBodyLength = 79; + + State _state = State::Idle; + uint8_t _checksum = 0; + uint8_t _expected_checksum = 0; + size_t _body_length = 0; + char _header[5] = {}; + bool _header_valid = true; + bool _found = false; + + static int hexNibble(uint8_t byte) { + if (byte >= '0' && byte <= '9') return byte - '0'; + if (byte >= 'A' && byte <= 'F') return byte - 'A' + 10; + if (byte >= 'a' && byte <= 'f') return byte - 'a' + 10; + return -1; + } + + void validateHeaderByte(uint8_t byte) { + if (_body_length < 5) { + if (byte < 'A' || byte > 'Z') _header_valid = false; + _header[_body_length] = static_cast(byte); + } else if (_body_length == 5 && byte != ',') { + _header_valid = false; + } + } + + bool recognizedTalker() const { + if (_header[0] == 'G') { + switch (_header[1]) { + case 'A': // Galileo + case 'B': // BeiDou + case 'L': // GLONASS + case 'N': // combined GNSS + case 'P': // GPS + case 'Q': // QZSS + return true; + } + } + return _header[0] == 'B' && _header[1] == 'D'; + } + + void resetSentence() { + _state = State::Idle; + _checksum = 0; + _expected_checksum = 0; + _body_length = 0; + _header_valid = true; + for (char& byte : _header) byte = 0; + } +}; + +} // namespace mesh diff --git a/src/helpers/ui/DisplayTextLayout.h b/src/helpers/ui/DisplayTextLayout.h new file mode 100644 index 00000000..9e3d3cbd --- /dev/null +++ b/src/helpers/ui/DisplayTextLayout.h @@ -0,0 +1,81 @@ +#pragma once + +#include + +namespace mesh { +namespace ui { + +// Draw complete text across a bounded number of rows. Lines may break in the +// middle of a word (which is important for SSIDs, where every space is +// significant), but never in the middle of a UTF-8 codepoint. If the text +// cannot fit in max_lines, only the final row is ellipsized. +inline int drawTextWrapped(DisplayDriver& display, int x, int y, int max_width, + int line_height, int max_lines, const char* str) { + if (str == nullptr || str[0] == 0 || max_width <= 0 + || line_height <= 0 || max_lines <= 0) { + return 0; + } + + int lines = 0; + size_t offset = 0; + while (str[offset] != 0 && lines < max_lines) { + // A display row is necessarily much shorter than this, but retain the + // same generous bound used by DisplayDriver::drawTextEllipsized(). + char row[256]; + size_t row_length = 0; + + while (str[offset + row_length] != 0) { + size_t codepoint_length = 1; + const uint8_t first = (uint8_t)str[offset + row_length]; + if (first >= 0xC2 && first <= 0xDF) { + codepoint_length = 2; + } else if (first >= 0xE0 && first <= 0xEF) { + codepoint_length = 3; + } else if (first >= 0xF0 && first <= 0xF4) { + codepoint_length = 4; + } + for (size_t i = 1; i < codepoint_length; ++i) { + if (str[offset + row_length + i] == 0 + || ((uint8_t)str[offset + row_length + i] & 0xC0) != 0x80) { + codepoint_length = 1; + break; + } + } + + const size_t candidate_length = row_length + codepoint_length; + if (candidate_length >= sizeof(row)) break; + memcpy(row, str + offset, candidate_length); + row[candidate_length] = 0; + if (display.getTextWidth(row) > max_width) break; + row_length = candidate_length; + } + + // Ensure forward progress even for a glyph wider than the viewport. + if (row_length == 0) { + row_length = 1; + while (str[offset + row_length] != 0 + && ((uint8_t)str[offset + row_length] & 0xC0) == 0x80 + && row_length + 1 < sizeof(row)) { + ++row_length; + } + } + + if (lines + 1 == max_lines && str[offset + row_length] != 0) { + display.drawTextEllipsized(x, y + lines * line_height, max_width, + str + offset); + ++lines; + break; + } + + memcpy(row, str + offset, row_length); + row[row_length] = 0; + display.setCursor(x, y + lines * line_height); + display.print(row); + offset += row_length; + ++lines; + } + return lines; +} + +} // namespace ui +} // namespace mesh diff --git a/test/README.md b/test/README.md index 49268219..abc5e2aa 100644 --- a/test/README.md +++ b/test/README.md @@ -14,8 +14,13 @@ pio test -e native # all suites except KISS modem pio test -e native_kiss_modem # KISS modem suite pio test -e native -f test_webconfig_keys # a single suite python3 test/test_indicator_display_profile.py # Indicator RAM/scale contract +python3 test/test_indicator_font_recovery.py # Indicator TLS/SD font recovery contract python3 test/test_companion_terminal_profile.py # Companion CLI capability gates +python3 test/test_client_login_profile_contract.py # ACL login ordering/role contract python3 test/test_esp32_full_partition.py # Full partition-preservation policy +python3 test/test_esp32_usb_serial_hygiene.py # Single-TTY diagnostics/NVS contract +python3 test/test_temp_radio_reply_delivery_contract.py # TempRadio ACK path/barrier integration +python3 test/test_tls_download_clock_gates.py # Fresh-NTP/TLS download integration contract ``` A green `[PASSED]` per suite means GoogleTest returned 0 (all assertions @@ -44,6 +49,7 @@ does not reflect the GoogleTest count -- run the built binary directly | `test_mqtt_prefs_atomic_store` | `src/helpers/MQTTPrefsAtomicStore.h` | transactional MQTT writes and legacy `/node_prefs` handoff; exact short-write detection; begin/finish/rename failure cleanup; original-file preservation | | `test_mqtt_prefs_json_import` | `src/helpers/MQTTPrefsJsonImport.h`, `src/helpers/MQTTPrefsSerializer.h` | one-time observer `/mqtt.json` v1 import grammar, strict schema/repair rules, binary-first precedence, future/artifact preservation, display-tail encoding, and atomic commit failure routing | | `test_display_viewport` | `src/helpers/ui/DisplayViewport.h`, `src/helpers/ui/DisplayFrameSignature.h` | portrait logical-to-physical mapping, span coverage, fitted-width conversion, text scaling, and visible-frame signatures | +| `test_indicator_font_stage_v2_protocol` | `src/helpers/IndicatorFontStageV2Protocol.h` | fail-closed STAGEV2 negotiation and legacy fallback; exact cumulative ACK parsing; 512-byte block boundaries, short final blocks, and real font-asset transfer geometry | | `test_radio_activity_window` | `src/helpers/RadioActivityWindow.h` | rolling minute buckets, derived rates, expiry, warm-up, silence, saturation, and `millis()` rollover | | `test_observer_dashboard` | `src/helpers/ui/ObserverDashboard.h` | landscape and portrait dashboard layout bounds, compact formatting, graph scaling, row signatures, and partial repaint policy | | `test_touch_tap_detector` | `src/helpers/ui/TouchTapDetector.h` | debounced one-shot taps, bounce and long-press handling, minimum gaps, reset, and `millis()` rollover | @@ -55,6 +61,8 @@ does not reflect the GoogleTest count -- run the built binary directly | `test_cli_command_utils` | `src/helpers/CLICommandUtils.h`, `src/helpers/ContactListOrder.h`, `src/helpers/TerminalCommandTracker.h`, `src/helpers/TerminalDisplayFilter.h`, `src/helpers/WiFiChannelPolicy.h`, `src/helpers/bridges/ESPNowBridgeFormat.h` | terminal verb/argument/path parsing; routed receive labels; quiet display defaults and independent emergency filtering; favorite-first contact ordering; standalone WiFi validation including 64-hex WPA/WPA2 PSKs; strict ESP-NOW channel and bridge-format parsing/range/fallback; single-command reply matching, round-trip timing, and rollover-safe expiration | | `test_espnow_raw_fragmentation` | `src/helpers/ESPNowRawFragmentation.h` | byte-exact legacy raw frames; 251-255-byte two-frame encoding; CRC-32 integrity; source-MAC-keyed bounded reassembly; malformed, duplicate, out-of-order, timeout, rollover, and capacity handling | | `test_gps_time_validation` | `src/helpers/sensors/GpsTimeValidation.h` | complete NMEA UTC validation; leap years and calendar bounds; rejection of receiver-default and signed-32-bit-out-of-range dates | +| `test_i2c_address_claim_policy` | `src/helpers/sensors/I2CAddressClaimPolicy.h`, `src/helpers/sensors/NmeaSentenceProbe.h` | a positively identified I2C GPS owns only its address on its bus; an INA3221 identity match blocks conflicting u-blox writes; UART GPS detection requires a complete checksum-valid GPS NMEA sentence | +| `test_gps_transport_ownership` | `src/helpers/SensorManager.cpp` | temporary bridge ownership cancels GPS acquisition/holds, preserves user preference/cache, and restores GPS only after the UART is released | | `test_identity_generation` | `src/helpers/IdentityGeneration.h` | reserved-prefix rejection; bounded retries; final provisioned attempt; fail-closed exhaustion | | `test_remote_cli_reply_cache` | `src/helpers/RemoteCliReplyCache.h`, `src/helpers/RemoteCliRequest.h`, `src/helpers/RemoteCliTimeout.h` | authenticated logical-request matching; bounded recent-reply history; backward-compatible retry identity; 300% response timeout; empty-response completion; on-air truncation and clearing | | `test_companion_frame_queue` | `src/helpers/CompanionFrameQueue.h`, `src/helpers/CompanionHardwareCommandCompat.h` | response/required/best-effort classification; reserved capacity; stable priority; safe eviction; message-waiting coalescing; command `0x42` framed-CLI disambiguation and deprecated hardware-alias mapping | @@ -64,6 +72,11 @@ does not reflect the GoogleTest count -- run the built binary directly | `test_ble_tx_stall_watchdog` | `src/helpers/BleTxStallWatchdog.h` | exact BLE fragment progress; blocked-reply timeout; rollover-safe elapsed time; disconnect recovery retry and completion | | `test_ble_mota_control` | `src/helpers/BleMotaStream.h`, `CompanionMotaControl.h` | encrypted mOTA channel ring buffering, overflow fail-closed behavior, request gating, and strict rejection of injected or USB-ownership control commands | | `test_atomic_file_writer` | `src/helpers/AtomicFileWriter.h` | verified temporary-file commit; short-write, readback, validation, and rename failures; preservation of the live file and stale-temp cleanup | +| `test_client_login_persistence` | `src/helpers/ClientLoginPersistence.h`, `LazyPersistence.h` | pre-allocation durable replay reservations; reboot, ACL-eviction, and revoked-admin tombstones; bounded-store policy (low-trust no-insert, privileged fail-closed); atomic replay-file recovery, including post-commit cleanup failure; preauthorized transient refresh; admin/guest promotion and downgrade; force-flood preservation; role masking and fixed-size secret copy | +| `test_client_path_persistence` | `src/helpers/ClientPathPersistence.h`, `LazyPersistence.h` | encoded path identity and byte lengths; unknown, zero-hop, force-flood, and nonpersistent clients; replay-unqualified RAM-only routes preserve an earlier operator route across unrelated save/reload | +| `test_temp_radio_reply_barrier` | `src/helpers/TempRadioReplyBarrier.h`, `TempRadioLeaseDeadline.h` | exact single-copy queued reply completion/failure handoff; suppression of untracked alternate/retry copies; rejection of foreign callbacks; cancellation; monotonic hard expiry that a backward wall-clock correction cannot extend; bounded remaining-time reporting | +| `test_lazy_persistence` | `src/helpers/LazyPersistence.h` | first-write scheduling without postponement, zero-sentinel rollover preservation, capped exponential save-failure backoff that mutations cannot defeat, and reset only after success | +| `test_client_acl_file_transaction` | `src/helpers/ClientACLFileTransaction.h` | verified temp publication, preservation of the prior ACL on verification/rename failure, post-commit cleanup-failure handling, and recovery at every temp/backup/primary boundary | | `test_cad_timing` | `src/helpers/radiolib/CadTiming.h`, `LR2021SideDetectorConfig.h`, `RadioAirtime.h` | Cascade and slow-profile CAD deadlines; invalid airtime handling; bounded LR2021 side-detector parsing and LDRO recomputation | | `test_companion_node_prefs` | `examples/companion_radio/NodePrefs.h` | independent device power saving, RXPS, Wi-Fi, and FEM preferences; one-time migration of the regressed power-saving default | | `test_config_serializer` | `src/helpers/ConfigSerializer.cpp`, Companion `NodePrefs` | escaped config save/load, whitespace and malformed input, unknown fields, and FEM/ESP-NOW bridge-format preference round trips | @@ -88,6 +101,7 @@ does not reflect the GoogleTest count -- run the built binary directly | `test_user_gpio` | `src/helpers/UserGpio.cpp`, `UserGpioReplyTracker.h` | board-approved pins, get/set/reset, timed nonblocking transitions, duplicate suppression, rollover, and completion-reply routing | | `test_utf8_helpers` | `src/helpers/UTF8Helpers.h` | byte-limit truncation at complete code-point boundaries and rejection of malformed or truncated UTF-8 | | `test_wifi_ota_seeder_policy` | `src/helpers/WiFiOtaSeederPolicy.h`, `WiFiOtaSeederStatus.h` | listener state versus network availability, serial/TCP folder ownership, detach detection, and bounded status formatting | +| `test_tls_clock_validity` | `src/helpers/esp32/TlsClockValidity.h` | signed wall-clock minimum and the fresh-proof/WiFi/time conjunction required before certificate-validating downloads | | `test_ota` | `src/helpers/ota/` | v2 application/v3 bootloader parser separation; legacy XIAO, generic internal, and exact MeshTower V2 SD embedded identity, vector, capability, explicit-confirmation, codec-isolation, scratch-headroom/shared-slot no-EndF gates, and no-autofetch gates; container and EndF integrity; protocol codecs; transfer, resume, and layered apply safety; adaptive 2-to-4 block-request window growth and stall contraction; active-transfer priority classification | | `test_trace_retry` | `src/Mesh.cpp`, `RTCClock`, `ClockSyncUtils.h`, retry and relay policy | app-v2 and boot-v3 traffic sharing `PAYLOAD_TYPE_OTA=0x0C` and the TempRadio suspend policy; opaque OTA relay behavior; background discovery priority; immediate primary transfer relay, receive-delay bypass, fast CAD retry, and no generic flood retry; trace and non-OTA flood retry timing; backward RTC correction; clock consensus/path policy and the 10-minute default drift threshold | | `test_utils` | `src/Utils.cpp` | `Utils::toHex` (upstream) | diff --git a/test/mocks/CayenneLPP.h b/test/mocks/CayenneLPP.h index 9d51c0fc..2aa7b83c 100644 --- a/test/mocks/CayenneLPP.h +++ b/test/mocks/CayenneLPP.h @@ -6,6 +6,17 @@ class CayenneLPP { public: explicit CayenneLPP(size_t) {} + void addGPS(uint8_t, float latitude, float longitude, float altitude) { + gps_count++; + last_latitude = latitude; + last_longitude = longitude; + last_altitude = altitude; + } const uint8_t* getBuffer() const { return nullptr; } uint16_t getSize() const { return 0; } + + uint16_t gps_count = 0; + float last_latitude = 0; + float last_longitude = 0; + float last_altitude = 0; }; diff --git a/test/test_ble_mota_control/test_ble_mota_control.cpp b/test/test_ble_mota_control/test_ble_mota_control.cpp index 762c5d56..a53834fa 100644 --- a/test/test_ble_mota_control/test_ble_mota_control.cpp +++ b/test/test_ble_mota_control/test_ble_mota_control.cpp @@ -26,6 +26,9 @@ TEST(BleMotaControl, AllowsOnlyBoundedOtaSessionCommands) { EXPECT_FALSE(allowed("otafolder status")); EXPECT_FALSE(allowed("ota folder on")); EXPECT_FALSE(allowed("ota folder off")); + // Sharing local command routing with the text/rescue CLI must not expand + // the deliberately narrow Bluetooth mOTA control allowlist. + EXPECT_FALSE(allowed("set pin 123456")); EXPECT_FALSE(allowed("reboot")); } diff --git a/test/test_build_profiles.sh b/test/test_build_profiles.sh index 40485e61..b00cc6bb 100644 --- a/test/test_build_profiles.sh +++ b/test/test_build_profiles.sh @@ -12,6 +12,30 @@ fail() { [ "$OPTION3_BUILD_WORKERS" -eq 1 ] \ || fail "logging matrix permits concurrent PlatformIO target builds" +# Reduced RAK profiles all retain the compact INA set, but GPS depends on +# whether the bridge already owns Serial1. Keep those independent contracts so +# a valid Serial1 image cannot fail release qualification for an absent GPS +# marker, and so its manifest never advertises impossible hardware support. +for rak_target in \ + RAK_3401_repeater_lora_ota_no_external_sensors \ + RAK_4631_repeater_lora_ota_no_external_sensors \ + RAK_4631_repeater_bridge_rs232_serial1_lora_ota_no_external_sensors \ + RAK_4631_repeater_bridge_rs232_serial2_lora_ota_no_external_sensors; do + is_rak_i2c_voltage_monitor_ota_target "$rak_target" \ + || fail "$rak_target lost the retained INA contract" +done +for rak_target in \ + RAK_3401_repeater_lora_ota_no_external_sensors \ + RAK_4631_repeater_lora_ota_no_external_sensors \ + RAK_4631_repeater_bridge_rs232_serial2_lora_ota_no_external_sensors; do + is_rak_gps_retaining_ota_target "$rak_target" \ + || fail "$rak_target lost its compatible GPS contract" +done +if is_rak_gps_retaining_ota_target \ + RAK_4631_repeater_bridge_rs232_serial1_lora_ota_no_external_sensors; then + fail "RAK4631 Serial1 RS-232 target incorrectly promises GPS" +fi + # The expanded-profile marker applied below must select a real, nonzero # boot-local setup window. Keep the build overlay, WebConfig compile-time # mapping, and pure timing policy tied together. @@ -351,6 +375,8 @@ for full_env in "${SUPPORTED_PIO_ENVS[@]}"; do [ "${MESHCORE_ESP32_FULL_PARTITION_TABLE:-}" \ = "variants/sensecap_indicator-espnow/dual_ota_2560k_preserve_spiffs.csv" ] \ || fail "$full_env omitted the Indicator preserve-SPIFFS table" + [[ "$PLATFORMIO_BUILD_FLAGS" == *"INDICATOR_WIFI_FONT_RECOVERY=1"* ]] \ + || fail "$full_env omitted WiFi font recovery" ;; *) [ -z "${MESHCORE_ESP32_FULL_PARTITION_TABLE:-}" ] \ @@ -551,6 +577,60 @@ fi uses_merged_standard_usb_logging esp_repeater \ || fail "standard ESP32 target omitted logging because FULL also exists" +# A standard ESP32 field image may omit LoRa OTA for size, but it must never +# also omit the compact browser updater. This is the exact profile combination +# that previously produced Heltec V4 repeaters where both `ota` and `start ota` +# were dead ends. +verify_esp32_field_browser_ota() { + local env_name=$1 + local PLATFORMIO_BUILD_FLAGS="" + local PLATFORMIO_BUILD_UNFLAGS="" + local BUILD_PROFILE_FOR_TARGET=standard + local ESP32_FULL_BUILD=0 + local -a BUILD_CAPABILITIES=() + local -a BUILD_REDUCTIONS=() + local -a BUILD_EXPECTATIONS=() + local capabilities reductions expectations + + PIO_ENV_PLATFORM_BY_NAME[$env_name]=ESP32_PLATFORM + PIO_ENV_OTA_BY_NAME[$env_name]=0 + apply_esp32_lora_ota_size_profile "$env_name" + + [[ "$PLATFORMIO_BUILD_FLAGS" == *-DLIGHTWEIGHT_WIFI_OTA=1* ]] \ + || fail "$env_name omitted its browser OTA fail-safe" + [[ "$PLATFORMIO_BUILD_FLAGS" == *-UDISABLE_WIFI_OTA* ]] \ + || fail "$env_name did not remove a board-level WiFi OTA disable" + [[ "$PLATFORMIO_BUILD_FLAGS" != *-DDISABLE_WIFI_OTA=1* ]] \ + || fail "$env_name still explicitly disables browser OTA" + capabilities=" ${BUILD_CAPABILITIES[*]} " + [[ "$capabilities" == *" web.lightweight_browser_ota "* ]] \ + || fail "$env_name did not declare browser OTA" + reductions=" ${BUILD_REDUCTIONS[*]} " + [[ "$reductions" != *"web.browser_ota omitted"* ]] \ + || fail "$env_name still records browser OTA as omitted" + + declare_build_capability_contract "$env_name" ESP32_PLATFORM + expectations=" ${BUILD_EXPECTATIONS[*]} " + [[ "$expectations" == *"web.lightweight_browser_ota=MeshCore firmware update"* ]] \ + || fail "$env_name does not verify browser OTA in the linked image" +} + +verify_esp32_field_browser_ota heltec_v4_repeater +verify_esp32_field_browser_ota heltec_v4_room_server +verify_esp32_field_browser_ota heltec_v4_sensor + +# Personal/attached roles can retain their established transport policy; the +# fail-safe is deliberately scoped to remotely installed field/server images. +PIO_ENV_PLATFORM_BY_NAME[heltec_v4_terminal_chat]=ESP32_PLATFORM +PLATFORMIO_BUILD_FLAGS="" +PLATFORMIO_BUILD_UNFLAGS="" +BUILD_PROFILE_FOR_TARGET=standard +BUILD_CAPABILITIES=() +BUILD_REDUCTIONS=() +apply_esp32_lora_ota_size_profile heltec_v4_terminal_chat +[[ "$PLATFORMIO_BUILD_FLAGS" == *-DDISABLE_WIFI_OTA=1* ]] \ + || fail "attached terminal unexpectedly changed its WiFi policy" + PLATFORMIO_BUILD_FLAGS="" MESHDEBUG_OVERRIDE="" PACKET_LOGGING_OVERRIDE="" @@ -816,6 +896,17 @@ expectations=" ${BUILD_EXPECTATIONS[*]} " [[ "$expectations" != *"Full Companion terminal listening"* ]] \ || fail "ESP32 Full contract still depends on optional debug logging" +BUILD_CAPABILITIES=() +BUILD_REDUCTIONS=() +BUILD_EXPECTATIONS=() +declare_build_capability_contract \ + SenseCapIndicator-LoRa_companion_radio_full ESP32_PLATFORM +expectations=" ${BUILD_EXPECTATIONS[*]} " +[[ "$expectations" == *"indicator.font_recovery_ntp_gate=requesting fresh NTP time before download"* ]] \ + || fail "Indicator Full contract omitted the fresh-NTP font-download gate" +[[ "$expectations" == *"indicator.font_recovery_tls=opening TLS connection to"* ]] \ + || fail "Indicator Full contract omitted the TLS font-recovery marker" + pio_env_option_contains() { [ "$1" = Heltec_v3_companion_radio_full ] \ && [ "$2" = build_flags ] \ diff --git a/test/test_cert_bundle_warning_filter.py b/test/test_cert_bundle_warning_filter.py new file mode 100644 index 00000000..c2bc1d53 --- /dev/null +++ b/test/test_cert_bundle_warning_filter.py @@ -0,0 +1,126 @@ +#!/usr/bin/env python3 +"""Regression tests for serial-zero roots in the ESP32 CA bundle.""" + +import contextlib +import io +import os +from pathlib import Path +import runpy +import struct +import tempfile +import unittest +import warnings + +from cryptography.utils import CryptographyDeprecationWarning + + +ROOT = Path(__file__).resolve().parents[1] +SCRIPT = ROOT / "scripts" / "generate_cert_bundle.py" + +NON_POSITIVE_SERIAL_WARNING = ( + "Parsed a serial number which wasn't positive (i.e., it was negative or zero), " + "which is disallowed by RFC 5280. Loading this certificate will cause an " + "exception in a future release of cryptography." +) + +# Go Daddy Root Certificate Authority - G2, one of the serial-zero roots in +# Mozilla and Adafruit trust bundles. +SERIAL_ZERO_ROOT = """-----BEGIN CERTIFICATE----- +MIIDxTCCAq2gAwIBAgIBADANBgkqhkiG9w0BAQsFADCBgzELMAkGA1UEBhMCVVMx +EDAOBgNVBAgTB0FyaXpvbmExEzARBgNVBAcTClNjb3R0c2RhbGUxGjAYBgNVBAoT +EUdvRGFkZHkuY29tLCBJbmMuMTEwLwYDVQQDEyhHbyBEYWRkeSBSb290IENlcnRp +ZmljYXRlIEF1dGhvcml0eSAtIEcyMB4XDTA5MDkwMTAwMDAwMFoXDTM3MTIzMTIz +NTk1OVowgYMxCzAJBgNVBAYTAlVTMRAwDgYDVQQIEwdBcml6b25hMRMwEQYDVQQH +EwpTY290dHNkYWxlMRowGAYDVQQKExFHb0RhZGR5LmNvbSwgSW5jLjExMC8GA1UE +AxMoR28gRGFkZHkgUm9vdCBDZXJ0aWZpY2F0ZSBBdXRob3JpdHkgLSBHMjCCASIw +DQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAL9xYgjx+lk09xvJGKP3gElY6SKD +E6bFIEMBO4Tx5oVJnyfq9oQbTqC023CYxzIBsQU+B07u9PpPL1kwIuerGVZr4oAH +/PMWdYA5UXvl+TW2dE6pjYIT5LY/qQOD+qK+ihVqf94Lw7YZFAXK6sOoBJQ7Rnwy +DfMAZiLIjWltNowRGLfTshxgtDj6AozO091GB94KPutdfMh8+7ArU6SSYmlRJQVh +GkSBjCypQ5Yj36w6gZoOKcUcqeldHraenjAKOc7xiID7S13MMuyFYkMlNAJWJwGR +tDtwKj9useiciAF9n9T521NtYJ2/LOdYq7hfRvzOxBsDPAnrSTFcaUaz4EcCAwEA +AaNCMEAwDwYDVR0TAQH/BAUwAwEB/zAOBgNVHQ8BAf8EBAMCAQYwHQYDVR0OBBYE +FDqahQcQZyi27/a9BUFuIMGU2g/eMA0GCSqGSIb3DQEBCwUAA4IBAQCZ21151fmX +WWcDYfF+OwYxdS2hII5PZYe096acvNjpL9DbWu7PdIxztDhC2gV7+AJ1uP2lsdeu +9tfeE8tTEH6KRtGX+rcuKxGrkLAngPnon1rpN5+r5N9ss4UXnT3ZJE95kTXWXwTr +gIOrmgIttRD02JDHBHNA7XIloKmf7J6raBKZV8aPEjoJpL1E/QYVN8Gb5DKj7Tjo +2GTzLH4U/ALqn83/B2gX2yKQOC16jdFU8WnjXzPKej17CuPKf1855eJ1usV2GDPO +LPAvTK33sefOT6jEm0pUBsV/fdUID+Ic/n4XuKxe9tQWskMJDE32p2u0mYRlynqI +4uJEvlz36hz1 +-----END CERTIFICATE----- +""" + + +class FakeEnvironment: + def subst(self, value): + if value != "$PIOENV": + raise AssertionError(f"unexpected substitution: {value}") + return "certificate_filter_test" + + def GetProjectOption(self, name): + if name != "board_ssl_cert_source": + raise AssertionError(f"unexpected project option: {name}") + # An unrecognized source skips the download path. The fixture is then + # loaded through the script's supported extra-certificate path. + return "fixture-only" + + def Execute(self, _command): + raise AssertionError("test requires cryptography to be preinstalled") + + +class CertificateBundleWarningFilterTest(unittest.TestCase): + def test_serial_zero_root_is_retained_and_filter_is_narrow(self): + with tempfile.TemporaryDirectory() as temporary_directory: + temporary = Path(temporary_directory) + certificates = temporary / "ssl_certs" + certificates.mkdir() + (certificates / "serial_zero.pem").write_text( + SERIAL_ZERO_ROOT, encoding="utf-8" + ) + + previous_directory = Path.cwd() + stderr = io.StringIO() + try: + os.chdir(temporary) + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always") + with contextlib.redirect_stderr(stderr): + runpy.run_path( + str(SCRIPT), + init_globals={ + "Import": lambda _name: None, + "env": FakeEnvironment(), + }, + ) + + warnings.warn( + NON_POSITIVE_SERIAL_WARNING, + CryptographyDeprecationWarning, + ) + warnings.warn( + "unrelated cryptography deprecation", + CryptographyDeprecationWarning, + ) + finally: + os.chdir(previous_directory) + + self.assertEqual(len(caught), 1) + self.assertEqual( + str(caught[0].message), "unrelated cryptography deprecation" + ) + self.assertIs( + caught[0].category, CryptographyDeprecationWarning + ) + + output = temporary / "src" / "certs" / "x509_crt_bundle.bin" + bundle = output.read_bytes() + self.assertEqual(struct.unpack(">H", bundle[:2])[0], 1) + self.assertGreater(len(bundle), 256) + self.assertIn( + "Successfully added 1 certificates in total", + stderr.getvalue(), + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/test/test_client_acl_file_transaction/test_client_acl_file_transaction.cpp b/test/test_client_acl_file_transaction/test_client_acl_file_transaction.cpp new file mode 100644 index 00000000..2c5482a1 --- /dev/null +++ b/test/test_client_acl_file_transaction/test_client_acl_file_transaction.cpp @@ -0,0 +1,295 @@ +#include + +#include +#include +#include +#include + +#include +#include + +namespace { + +class FakeFilesystem { +public: + std::set files; + std::set valid_files; + std::map > images; + std::string fail_from; + std::string fail_to; + std::string fail_remove; + + bool exists(const char* path) const { + return files.count(path) != 0; + } + + bool remove(const char* path) { + if (fail_remove == path) return false; + images.erase(path); + valid_files.erase(path); + return files.erase(path) != 0; + } + + bool rename(const char* from, const char* to) { + if (fail_from == from && fail_to == to) return false; + if (!exists(from) || exists(to)) return false; + files.erase(from); + files.insert(to); + if (valid_files.erase(from) != 0) valid_files.insert(to); + std::map >::iterator image = + images.find(from); + if (image != images.end()) { + images[to] = image->second; + images.erase(image); + } + return true; + } +}; + +bool isValid(FakeFilesystem* fs, const char* path) { + return fs->valid_files.count(path) != 0; +} + +TEST(ClientACLFileTransaction, FailedVerificationPreservesPrimary) { + FakeFilesystem fs; + fs.files = {mesh::CLIENT_ACL_PRIMARY_PATH, mesh::CLIENT_ACL_TEMP_PATH}; + + EXPECT_FALSE(mesh::publishVerifiedClientACLTemp(&fs, false)); + EXPECT_TRUE(fs.exists(mesh::CLIENT_ACL_PRIMARY_PATH)); + EXPECT_FALSE(fs.exists(mesh::CLIENT_ACL_TEMP_PATH)); +} + +TEST(ClientACLFileTransaction, VerifiedTempPublishesThroughBackup) { + FakeFilesystem fs; + fs.files = {mesh::CLIENT_ACL_PRIMARY_PATH, mesh::CLIENT_ACL_TEMP_PATH}; + + EXPECT_TRUE(mesh::publishVerifiedClientACLTemp(&fs, true)); + EXPECT_TRUE(fs.exists(mesh::CLIENT_ACL_PRIMARY_PATH)); + EXPECT_FALSE(fs.exists(mesh::CLIENT_ACL_TEMP_PATH)); + EXPECT_FALSE(fs.exists(mesh::CLIENT_ACL_BACKUP_PATH)); +} + +TEST(ClientACLFileTransaction, PrimaryRenameFailureLeavesOldImage) { + FakeFilesystem fs; + fs.files = {mesh::CLIENT_ACL_PRIMARY_PATH, mesh::CLIENT_ACL_TEMP_PATH}; + fs.fail_from = mesh::CLIENT_ACL_PRIMARY_PATH; + fs.fail_to = mesh::CLIENT_ACL_BACKUP_PATH; + + EXPECT_FALSE(mesh::publishVerifiedClientACLTemp(&fs, true)); + EXPECT_TRUE(fs.exists(mesh::CLIENT_ACL_PRIMARY_PATH)); + EXPECT_TRUE(fs.exists(mesh::CLIENT_ACL_TEMP_PATH)); +} + +TEST(ClientACLFileTransaction, PublishFailureRestoresBackup) { + FakeFilesystem fs; + fs.files = {mesh::CLIENT_ACL_PRIMARY_PATH, mesh::CLIENT_ACL_TEMP_PATH}; + fs.fail_from = mesh::CLIENT_ACL_TEMP_PATH; + fs.fail_to = mesh::CLIENT_ACL_PRIMARY_PATH; + + EXPECT_FALSE(mesh::publishVerifiedClientACLTemp(&fs, true)); + EXPECT_TRUE(fs.exists(mesh::CLIENT_ACL_PRIMARY_PATH)); + EXPECT_TRUE(fs.exists(mesh::CLIENT_ACL_TEMP_PATH)); + EXPECT_FALSE(fs.exists(mesh::CLIENT_ACL_BACKUP_PATH)); +} + +TEST(ClientACLFileTransaction, BootRecoveryKeepsPublishedPrimary) { + FakeFilesystem fs; + fs.files = { + mesh::CLIENT_ACL_PRIMARY_PATH, + mesh::CLIENT_ACL_TEMP_PATH, + mesh::CLIENT_ACL_BACKUP_PATH, + }; + + EXPECT_TRUE(mesh::recoverClientACLFiles(&fs)); + EXPECT_TRUE(fs.exists(mesh::CLIENT_ACL_PRIMARY_PATH)); + EXPECT_FALSE(fs.exists(mesh::CLIENT_ACL_TEMP_PATH)); + EXPECT_FALSE(fs.exists(mesh::CLIENT_ACL_BACKUP_PATH)); +} + +TEST(ClientACLFileTransaction, BootRecoveryRestoresInterruptedPublish) { + FakeFilesystem fs; + fs.files = {mesh::CLIENT_ACL_TEMP_PATH, mesh::CLIENT_ACL_BACKUP_PATH}; + + EXPECT_TRUE(mesh::recoverClientACLFiles(&fs)); + EXPECT_TRUE(fs.exists(mesh::CLIENT_ACL_PRIMARY_PATH)); + EXPECT_FALSE(fs.exists(mesh::CLIENT_ACL_TEMP_PATH)); + EXPECT_FALSE(fs.exists(mesh::CLIENT_ACL_BACKUP_PATH)); +} + +TEST(ClientACLFileTransaction, CorruptPrimaryFallsBackToValidBackup) { + FakeFilesystem fs; + fs.files = { + mesh::CLIENT_ACL_PRIMARY_PATH, mesh::CLIENT_ACL_BACKUP_PATH}; + fs.valid_files = {mesh::CLIENT_ACL_BACKUP_PATH}; + + EXPECT_TRUE(mesh::recoverClientACLFilesVerified(&fs, isValid)); + EXPECT_TRUE(fs.exists(mesh::CLIENT_ACL_PRIMARY_PATH)); + EXPECT_TRUE(fs.valid_files.count(mesh::CLIENT_ACL_PRIMARY_PATH) != 0); + EXPECT_FALSE(fs.exists(mesh::CLIENT_ACL_BACKUP_PATH)); +} + +TEST(ClientACLFileTransaction, CorruptPublishedPrimaryRestoresOldImage) { + FakeFilesystem fs; + fs.files = { + mesh::CLIENT_ACL_PRIMARY_PATH, mesh::CLIENT_ACL_TEMP_PATH}; + fs.valid_files = {mesh::CLIENT_ACL_PRIMARY_PATH}; + + EXPECT_FALSE(mesh::publishVerifiedClientACLTemp(&fs, true, isValid)); + EXPECT_TRUE(fs.exists(mesh::CLIENT_ACL_PRIMARY_PATH)); + EXPECT_TRUE(fs.valid_files.count(mesh::CLIENT_ACL_PRIMARY_PATH) != 0); + EXPECT_FALSE(fs.exists(mesh::CLIENT_ACL_BACKUP_PATH)); +} + +TEST(ClientACLFileTransaction, PublishedPrimarySurvivesBackupCleanupFailure) { + FakeFilesystem fs; + fs.files = { + mesh::CLIENT_ACL_PRIMARY_PATH, mesh::CLIENT_ACL_TEMP_PATH}; + fs.valid_files = { + mesh::CLIENT_ACL_PRIMARY_PATH, mesh::CLIENT_ACL_TEMP_PATH}; + fs.fail_remove = mesh::CLIENT_ACL_BACKUP_PATH; + + EXPECT_TRUE(mesh::publishVerifiedClientACLTemp(&fs, true, isValid)); + EXPECT_TRUE(fs.exists(mesh::CLIENT_ACL_PRIMARY_PATH)); + EXPECT_TRUE(fs.valid_files.count(mesh::CLIENT_ACL_PRIMARY_PATH) != 0); + EXPECT_TRUE(fs.exists(mesh::CLIENT_ACL_BACKUP_PATH)); + + // A later recovery pass can finish the best-effort cleanup without + // changing which image is authoritative. + fs.fail_remove.clear(); + EXPECT_TRUE(mesh::recoverClientACLFilesVerified(&fs, isValid)); + EXPECT_TRUE(fs.exists(mesh::CLIENT_ACL_PRIMARY_PATH)); + EXPECT_FALSE(fs.exists(mesh::CLIENT_ACL_BACKUP_PATH)); +} + +TEST(ClientACLFileTransaction, ValidPrimaryLoadsWhenCleanupCannotFinish) { + FakeFilesystem fs; + fs.files = { + mesh::CLIENT_ACL_PRIMARY_PATH, + mesh::CLIENT_ACL_TEMP_PATH, + mesh::CLIENT_ACL_BACKUP_PATH, + }; + fs.valid_files = {mesh::CLIENT_ACL_PRIMARY_PATH}; + fs.fail_remove = mesh::CLIENT_ACL_BACKUP_PATH; + + EXPECT_TRUE(mesh::recoverClientACLFilesVerified(&fs, isValid)); + EXPECT_TRUE(fs.exists(mesh::CLIENT_ACL_PRIMARY_PATH)); + EXPECT_FALSE(fs.exists(mesh::CLIENT_ACL_TEMP_PATH)); + EXPECT_TRUE(fs.exists(mesh::CLIENT_ACL_BACKUP_PATH)); +} + +std::vector crcImage(size_t records) { + std::vector image(records * 201, 0x5A); + uint32_t crc = mesh::updateClientACLCRC( + 0xFFFFFFFFUL, image.data(), image.size()) ^ 0xFFFFFFFFUL; + image.insert(image.end(), mesh::CLIENT_ACL_CRC_MAGIC, + mesh::CLIENT_ACL_CRC_MAGIC + 4); + const uint8_t* bytes = reinterpret_cast(&crc); + image.insert(image.end(), bytes, bytes + sizeof(crc)); + return image; +} + +bool realImageIsValid(FakeFilesystem* fs, const char* path) { + std::map >::const_iterator found = + fs->images.find(path); + if (found == fs->images.end()) return false; + const mesh::ClientACLImageKind kind = mesh::classifyClientACLImage( + found->second.data(), found->second.size(), 201, 136); + const bool publication_in_progress = std::string(path) + == mesh::CLIENT_ACL_PRIMARY_PATH + && fs->exists(mesh::CLIENT_ACL_BACKUP_PATH); + return publication_in_progress ? kind == mesh::CLIENT_ACL_IMAGE_CRC + : kind != mesh::CLIENT_ACL_IMAGE_INVALID; +} + +TEST(ClientACLFileTransaction, RealCRCClassifierRejectsTornTrailer) { + std::vector image = crcImage(2); + EXPECT_EQ(mesh::classifyClientACLImage(image.data(), image.size(), 201, 136), + mesh::CLIENT_ACL_IMAGE_CRC); + + image.resize(image.size() - 8); // exactly 201*N: ambiguous standalone legacy + EXPECT_EQ(mesh::classifyClientACLImage(image.data(), image.size(), 201, 136), + mesh::CLIENT_ACL_IMAGE_LEGACY); + // Recovery resolves this ambiguity from transaction state: if a backup is + // present, only CRC kind is acceptable for the newly published primary. + EXPECT_TRUE(mesh::classifyClientACLImage( + image.data(), image.size(), 201, 136) != mesh::CLIENT_ACL_IMAGE_CRC); +} + +TEST(ClientACLFileTransaction, RealClassifierCoversRecordBoundaryTruncations) { + std::vector image = crcImage(4); + image.resize(3 * 201); + EXPECT_EQ(mesh::classifyClientACLImage(image.data(), image.size(), 201, 136), + mesh::CLIENT_ACL_IMAGE_LEGACY); + + image.resize(2 * 136); + EXPECT_EQ(mesh::classifyClientACLImage(image.data(), image.size(), 201, 136), + mesh::CLIENT_ACL_IMAGE_LEGACY); + + image.resize(2 * 136 - 1); + EXPECT_EQ(mesh::classifyClientACLImage(image.data(), image.size(), 201, 136), + mesh::CLIENT_ACL_IMAGE_INVALID); +} + +TEST(ClientACLFileTransaction, TornTrailerRestoresRealValidatedBackup) { + FakeFilesystem fs; + fs.files = { + mesh::CLIENT_ACL_PRIMARY_PATH, mesh::CLIENT_ACL_BACKUP_PATH}; + fs.images[mesh::CLIENT_ACL_PRIMARY_PATH] = crcImage(2); + fs.images[mesh::CLIENT_ACL_PRIMARY_PATH].resize(2 * 201); + fs.images[mesh::CLIENT_ACL_BACKUP_PATH] = std::vector(136, 0x33); + + EXPECT_TRUE(mesh::recoverClientACLFilesVerified(&fs, realImageIsValid)); + EXPECT_TRUE(fs.exists(mesh::CLIENT_ACL_PRIMARY_PATH)); + EXPECT_FALSE(fs.exists(mesh::CLIENT_ACL_BACKUP_PATH)); + EXPECT_EQ(fs.images[mesh::CLIENT_ACL_PRIMARY_PATH].size(), 136u); +} + +TEST(ClientACLFileTransaction, TornTrailerRestoresCRCValidatedBackup) { + FakeFilesystem fs; + fs.files = { + mesh::CLIENT_ACL_PRIMARY_PATH, mesh::CLIENT_ACL_BACKUP_PATH}; + fs.images[mesh::CLIENT_ACL_PRIMARY_PATH] = crcImage(2); + fs.images[mesh::CLIENT_ACL_PRIMARY_PATH].resize(2 * 201); + fs.images[mesh::CLIENT_ACL_BACKUP_PATH] = crcImage(1); + + EXPECT_TRUE(mesh::recoverClientACLFilesVerified(&fs, realImageIsValid)); + EXPECT_TRUE(fs.exists(mesh::CLIENT_ACL_PRIMARY_PATH)); + EXPECT_FALSE(fs.exists(mesh::CLIENT_ACL_BACKUP_PATH)); + EXPECT_EQ(mesh::classifyClientACLImage( + fs.images[mesh::CLIENT_ACL_PRIMARY_PATH].data(), + fs.images[mesh::CLIENT_ACL_PRIMARY_PATH].size(), 201, 136), + mesh::CLIENT_ACL_IMAGE_CRC); +} + +TEST(ClientACLFileTransaction, StandaloneNoCRCLayoutsMigrateOnNextPublish) { + const size_t standalone_sizes[] = {136, 201}; + for (size_t standalone_size : standalone_sizes) { + FakeFilesystem fs; + fs.files = {mesh::CLIENT_ACL_PRIMARY_PATH}; + fs.images[mesh::CLIENT_ACL_PRIMARY_PATH] = + std::vector(standalone_size, 0x33); + + EXPECT_TRUE(mesh::recoverClientACLFilesVerified(&fs, realImageIsValid)); + EXPECT_EQ(mesh::classifyClientACLImage( + fs.images[mesh::CLIENT_ACL_PRIMARY_PATH].data(), + fs.images[mesh::CLIENT_ACL_PRIMARY_PATH].size(), 201, 136), + mesh::CLIENT_ACL_IMAGE_LEGACY); + + fs.files.insert(mesh::CLIENT_ACL_TEMP_PATH); + fs.images[mesh::CLIENT_ACL_TEMP_PATH] = crcImage(1); + EXPECT_TRUE(mesh::publishVerifiedClientACLTemp( + &fs, true, realImageIsValid)); + EXPECT_FALSE(fs.exists(mesh::CLIENT_ACL_BACKUP_PATH)); + EXPECT_EQ(mesh::classifyClientACLImage( + fs.images[mesh::CLIENT_ACL_PRIMARY_PATH].data(), + fs.images[mesh::CLIENT_ACL_PRIMARY_PATH].size(), 201, 136), + mesh::CLIENT_ACL_IMAGE_CRC); + } +} + +} // namespace + +int main(int argc, char** argv) { + ::testing::InitGoogleTest(&argc, argv); + return RUN_ALL_TESTS(); +} diff --git a/test/test_client_login_persistence/test_client_login_persistence.cpp b/test/test_client_login_persistence/test_client_login_persistence.cpp new file mode 100644 index 00000000..1c05a3ad --- /dev/null +++ b/test/test_client_login_persistence/test_client_login_persistence.cpp @@ -0,0 +1,479 @@ +#include + +#include +#include +#include +#include +#include + +#include +#include + +namespace { + +static constexpr uint8_t ROLE_MASK = 0x07; +static constexpr uint8_t ROLE_GUEST = 0x00; +static constexpr uint8_t ROLE_READ_ONLY = 0x01; +static constexpr uint8_t ROLE_READ_WRITE = 0x02; +static constexpr uint8_t ROLE_ADMIN = 0x03; +static constexpr uint8_t UNKNOWN_PATH = 0xFF; +static constexpr uint8_t FORCE_FLOOD_PATH = 0xFE; + +struct ClientState { + uint8_t permissions = ROLE_ADMIN; + uint8_t out_path_len = UNKNOWN_PATH; + bool out_path_is_persistable = true; + uint8_t shared_secret[4] = {1, 2, 3, 4}; + uint32_t last_timestamp = 10; + uint32_t last_activity = 20; +}; + +bool applyLogin(ClientState& client, bool existed, const uint8_t* secret, + uint8_t role = ROLE_ADMIN, bool is_flood = false) { + return mesh::applySuccessfulClientLogin( + client, existed, role, ROLE_MASK, secret, + 100, 200, is_flood, UNKNOWN_PATH); +} + +bool loginNeedsSave(bool existed, uint8_t previous, uint8_t next, + bool changed) { + return mesh::successfulClientLoginNeedsPersistence( + existed, previous, next, ROLE_MASK, 0, changed); +} + +TEST(ClientLoginPersistence, UnchangedAdminLoginRefreshesOnlyTransientState) { + ClientState client; + const uint8_t same_secret[4] = {1, 2, 3, 4}; + + EXPECT_FALSE(applyLogin(client, true, same_secret)); + EXPECT_EQ(client.last_timestamp, 100u); + EXPECT_EQ(client.last_activity, 200u); + EXPECT_EQ(client.permissions, ROLE_ADMIN); + EXPECT_EQ(client.out_path_len, UNKNOWN_PATH); + EXPECT_EQ(memcmp(client.shared_secret, same_secret, 4), 0); +} + +TEST(ClientLoginPersistence, NewAdminNeedsPersistenceAndArmsFirstWrite) { + ClientState client; + const uint8_t same_secret[4] = {1, 2, 3, 4}; + unsigned long pending = 0; + + const bool changed = applyLogin(client, false, same_secret); + EXPECT_TRUE(changed); + EXPECT_TRUE(loginNeedsSave(false, 0, ROLE_ADMIN, changed)); + EXPECT_TRUE(mesh::armFirstLazyPersistence( + pending, 5000, + loginNeedsSave(false, 0, ROLE_ADMIN, changed))); + EXPECT_EQ(pending, 5000UL); + EXPECT_EQ(client.last_timestamp, 100u); + EXPECT_EQ(client.last_activity, 200u); +} + +TEST(ClientLoginPersistence, NewGuestStaysRuntimeOnly) { + ClientState client; + client.permissions = 0; + const uint8_t same_secret[4] = {1, 2, 3, 4}; + unsigned long pending = 0; + + const bool changed = applyLogin(client, false, same_secret, 0); + EXPECT_TRUE(changed); + EXPECT_EQ(client.permissions & ROLE_MASK, 0); + EXPECT_FALSE(loginNeedsSave(false, 0, 0, changed)); + EXPECT_FALSE(mesh::armFirstLazyPersistence( + pending, 5000, loginNeedsSave(false, 0, 0, changed))); + EXPECT_EQ(pending, 0UL); +} + +TEST(ClientLoginPersistence, GuestPromotionToAdminNeedsPersistence) { + ClientState client; + client.permissions = 0; + const uint8_t same_secret[4] = {1, 2, 3, 4}; + + const bool changed = applyLogin(client, true, same_secret, ROLE_ADMIN); + EXPECT_TRUE(changed); + EXPECT_TRUE(loginNeedsSave(true, 0, ROLE_ADMIN, changed)); + EXPECT_EQ(client.permissions, ROLE_ADMIN); +} + +TEST(ClientLoginPersistence, AdminDowngradeToGuestRemovesPersistedEntry) { + ClientState client; + client.permissions = 0x83; + const uint8_t same_secret[4] = {1, 2, 3, 4}; + + const bool changed = applyLogin(client, true, same_secret, 0); + EXPECT_TRUE(changed); + EXPECT_EQ(client.permissions & ROLE_MASK, 0); + EXPECT_EQ(client.permissions, 0x80); + EXPECT_FALSE(mesh::clientRoleIsPersisted( + client.permissions, ROLE_MASK, 0)); + EXPECT_TRUE(loginNeedsSave(true, ROLE_ADMIN, 0, changed)); +} + +TEST(ClientLoginPersistence, GuestUpperPermissionBitsNeverMakeItPersistent) { + EXPECT_FALSE(mesh::clientRoleIsPersisted(0x00, ROLE_MASK, 0)); + EXPECT_FALSE(mesh::clientRoleIsPersisted(0x80, ROLE_MASK, 0)); + EXPECT_TRUE(mesh::clientRoleIsPersisted(0x81, ROLE_MASK, 0)); +} + +TEST(ClientLoginPersistence, PreauthorizedLoginStillRefreshesReplayState) { + ClientState client; + client.permissions = 1; + const uint8_t same_secret[4] = {1, 2, 3, 4}; + + EXPECT_FALSE(applyLogin(client, true, same_secret, 1)); + EXPECT_EQ(client.permissions, 1); + EXPECT_EQ(client.last_timestamp, 100u); + EXPECT_EQ(client.last_activity, 200u); +} + +TEST(ClientLoginPersistence, PersistedRoleOrSecretChangesNeedPersistence) { + const uint8_t same_secret[4] = {1, 2, 3, 4}; + const uint8_t changed_secret[4] = {4, 3, 2, 1}; + + ClientState role_changed; + role_changed.permissions = 0xA1; + EXPECT_TRUE(applyLogin(role_changed, true, same_secret, 0xFB)); + EXPECT_EQ(role_changed.permissions, 0xA3); + + ClientState secret_changed; + EXPECT_TRUE(applyLogin(secret_changed, true, changed_secret)); + EXPECT_EQ(memcmp(secret_changed.shared_secret, changed_secret, 4), 0); +} + +TEST(ClientLoginPersistence, FloodPathResetPersistsOnlyWhenValueChanges) { + const uint8_t same_secret[4] = {1, 2, 3, 4}; + + ClientState known_path; + known_path.out_path_len = 2; + EXPECT_TRUE(applyLogin( + known_path, true, same_secret, ROLE_ADMIN, true)); + EXPECT_EQ(known_path.out_path_len, UNKNOWN_PATH); + EXPECT_TRUE(known_path.out_path_is_persistable); + + ClientState already_unknown; + EXPECT_FALSE(applyLogin( + already_unknown, true, same_secret, ROLE_ADMIN, true)); +} + +TEST(ClientLoginPersistence, FloodLoginNeverDestroysForceFlood) { + const uint8_t same_secret[4] = {1, 2, 3, 4}; + ClientState client; + client.out_path_len = FORCE_FLOOD_PATH; + + const bool reset_out_path = client.out_path_len != FORCE_FLOOD_PATH; + EXPECT_FALSE(mesh::applySuccessfulClientLogin( + client, true, ROLE_ADMIN, ROLE_MASK, same_secret, + 100, 200, reset_out_path, UNKNOWN_PATH)); + EXPECT_EQ(client.out_path_len, FORCE_FLOOD_PATH); + EXPECT_TRUE(client.out_path_is_persistable); +} + +TEST(ClientLoginPersistence, ReplayValidationPrecedesAllocation) { + EXPECT_FALSE(mesh::isFreshClientLoginTimestamp(false, 0, 0)); + EXPECT_TRUE(mesh::isFreshClientLoginTimestamp(false, 1, 0)); + + EXPECT_FALSE(mesh::isFreshClientLoginTimestamp(true, 0, 50)); + EXPECT_FALSE(mesh::isFreshClientLoginTimestamp(true, 49, 50)); + EXPECT_FALSE(mesh::isFreshClientLoginTimestamp(true, 50, 50)); + EXPECT_TRUE(mesh::isFreshClientLoginTimestamp(true, 51, 50)); +} + +TEST(ClientLoginPersistence, DurableReservationSurvivesReboot) { + const auto first = mesh::planClientLoginReplay(0, 0, 100); + ASSERT_TRUE(first.fresh); + ASSERT_TRUE(first.reservation_needed); + EXPECT_EQ(first.reservation_ceiling, 160u); + + // Once this boot has accepted 100, reconnects advance against the exact RAM + // floor and consume the already durable reservation without another write. + const auto reconnect = mesh::planClientLoginReplay( + 100, first.reservation_ceiling, 101); + EXPECT_TRUE(reconnect.fresh); + EXPECT_FALSE(reconnect.reservation_needed); + + // A reboot has no exact RAM floor. It deliberately skips the unused tail, + // so neither accepted timestamp can become fresh again. + const auto replay_after_reboot = mesh::planClientLoginReplay( + 0, first.reservation_ceiling, 101); + EXPECT_FALSE(replay_after_reboot.fresh); + const auto after_reserved_tail = mesh::planClientLoginReplay( + 0, first.reservation_ceiling, 161); + EXPECT_TRUE(after_reserved_tail.fresh); + EXPECT_TRUE(after_reserved_tail.reservation_needed); + EXPECT_EQ(after_reserved_tail.reservation_ceiling, 221u); +} + +TEST(ClientLoginPersistence, EvictionCannotEraseReplayFloor) { + const uint32_t tombstone_ceiling = 460; + + // Eviction removes the live ClientInfo and therefore its exact timestamp. + // The absent runtime floor must fall back to the per-identity tombstone. + EXPECT_FALSE(mesh::planClientLoginReplay( + 0, tombstone_ceiling, 400).fresh); + EXPECT_FALSE(mesh::planClientLoginReplay( + 0, tombstone_ceiling, tombstone_ceiling).fresh); + EXPECT_TRUE(mesh::planClientLoginReplay( + 0, tombstone_ceiling, tombstone_ceiling + 1).fresh); +} + +TEST(ClientLoginPersistence, RevokedAdminReplayUsesTombstoneNotRole) { + const uint32_t revoked_admin_floor = 1060; + + // Role removal is intentionally absent from the planner: replay history is + // keyed by public identity and outlives the ACL record/permissions. + const uint8_t current_role = 0; // guest/deleted + (void)current_role; + EXPECT_FALSE(mesh::planClientLoginReplay( + 0, revoked_admin_floor, 1000).fresh); + EXPECT_FALSE(mesh::planClientLoginReplay( + 0, revoked_admin_floor, 1001).fresh); +} + +TEST(ClientLoginPersistence, ReservationSaturatesWithoutWrapping) { + EXPECT_EQ(mesh::reserveClientLoginTimestamp(UINT32_MAX - 10, 60), + UINT32_MAX); + EXPECT_FALSE(mesh::planClientLoginReplay( + UINT32_MAX, UINT32_MAX, UINT32_MAX).fresh); +} + +TEST(ClientLoginPersistence, GuestAndReadOnlyDoNotAllocateDurableIdentity) { + EXPECT_FALSE(mesh::clientLoginRoleNeedsDurableReplay( + ROLE_GUEST, ROLE_MASK, ROLE_GUEST, ROLE_READ_ONLY)); + EXPECT_FALSE(mesh::clientLoginRoleNeedsDurableReplay( + ROLE_READ_ONLY, ROLE_MASK, ROLE_GUEST, ROLE_READ_ONLY)); + // Unrelated upper permission bits do not turn a low-trust role into a + // durable replay-store consumer. + EXPECT_FALSE(mesh::clientLoginRoleNeedsDurableReplay( + 0x81, ROLE_MASK, ROLE_GUEST, ROLE_READ_ONLY)); + + const auto first_guest = mesh::planClientLoginReplay( + 0, 0, 100); + EXPECT_TRUE(first_guest.fresh); + EXPECT_TRUE(first_guest.reservation_needed); + + // A full durable store is irrelevant because policy selects no write for a + // missing low-trust identity. + EXPECT_EQ(mesh::selectClientLoginReplayReservation( + first_guest.reservation_needed, false, false), + mesh::ClientLoginReplayReservationAction::None); +} + +TEST(ClientLoginPersistence, LowTrustLoginStillEnforcesExistingFloors) { + const uint32_t privileged_tombstone = 460; + EXPECT_FALSE(mesh::planClientLoginReplay( + 0, privileged_tombstone, 400).fresh); + EXPECT_FALSE(mesh::planClientLoginReplay( + 0, privileged_tombstone, privileged_tombstone).fresh); + + const auto newer = mesh::planClientLoginReplay( + 0, privileged_tombstone, privileged_tombstone + 1); + EXPECT_TRUE(newer.fresh); + EXPECT_TRUE(newer.reservation_needed); + EXPECT_EQ(mesh::selectClientLoginReplayReservation( + newer.reservation_needed, true, false), + mesh::ClientLoginReplayReservationAction::UpdateExisting); + + // Once admitted, the exact RAM floor still rejects same-boot replay even + // for a missing low-trust identity which deliberately performs no write. + EXPECT_FALSE(mesh::planClientLoginReplay( + privileged_tombstone + 1, privileged_tombstone, + privileged_tombstone + 1).fresh); +} + +TEST(ClientLoginPersistence, FullStoreFailsOnlyNewPrivilegedIdentity) { + for (uint8_t role : {ROLE_READ_WRITE, ROLE_ADMIN, (uint8_t)4, (uint8_t)5}) { + EXPECT_TRUE(mesh::clientLoginRoleNeedsDurableReplay( + role, ROLE_MASK, ROLE_GUEST, ROLE_READ_ONLY)); + } + + const auto privileged = mesh::planClientLoginReplay(0, 0, 100); + ASSERT_TRUE(privileged.fresh); + ASSERT_TRUE(privileged.reservation_needed); + EXPECT_EQ(mesh::selectClientLoginReplayReservation( + privileged.reservation_needed, false, true), + mesh::ClientLoginReplayReservationAction::CreateNew); + EXPECT_FALSE(mesh::clientLoginReplayCanInsert( + mesh::MAX_CLIENT_LOGIN_REPLAY_IDENTITIES)); + EXPECT_TRUE(mesh::clientLoginReplayCanInsert( + mesh::MAX_CLIENT_LOGIN_REPLAY_IDENTITIES - 1)); + + EXPECT_EQ(mesh::selectClientLoginReplayReservation( + privileged.reservation_needed, true, true), + mesh::ClientLoginReplayReservationAction::UpdateExisting); + EXPECT_EQ(mesh::selectClientLoginReplayReservation( + privileged.reservation_needed, true, false), + mesh::ClientLoginReplayReservationAction::UpdateExisting); + EXPECT_EQ(mesh::selectClientLoginReplayReservation( + privileged.reservation_needed, false, false), + mesh::ClientLoginReplayReservationAction::None); + EXPECT_EQ(mesh::selectClientLoginReplayReservation( + false, false, true), + mesh::ClientLoginReplayReservationAction::None); +} + +std::vector replayImage( + const std::vector, uint32_t> >& records) { + std::vector image; + uint32_t crc = 0xFFFFFFFFUL; + for (const auto& record : records) { + EXPECT_EQ(record.first.size(), 32u); + image.insert(image.end(), record.first.begin(), record.first.end()); + crc = mesh::updateClientLoginReplayCRC( + crc, record.first.data(), record.first.size()); + const uint8_t* floor = + reinterpret_cast(&record.second); + image.insert(image.end(), floor, floor + sizeof(record.second)); + crc = mesh::updateClientLoginReplayCRC( + crc, floor, sizeof(record.second)); + } + image.insert(image.end(), mesh::CLIENT_LOGIN_REPLAY_MAGIC, + mesh::CLIENT_LOGIN_REPLAY_MAGIC + 4); + const uint32_t final_crc = crc ^ 0xFFFFFFFFUL; + const uint8_t* trailer = + reinterpret_cast(&final_crc); + image.insert(image.end(), trailer, trailer + sizeof(final_crc)); + return image; +} + +TEST(ClientLoginPersistence, ReplayImageIntegrityRejectsTornOrChangedState) { + std::vector key(32, 0xA5); + std::vector image = replayImage({{key, 160}}); + ASSERT_TRUE(mesh::validateClientLoginReplayImage( + image.data(), image.size())); + + std::vector changed = image; + changed[32] ^= 1; + EXPECT_FALSE(mesh::validateClientLoginReplayImage( + changed.data(), changed.size())); + image.pop_back(); + EXPECT_FALSE(mesh::validateClientLoginReplayImage( + image.data(), image.size())); +} + +class ReplayFakeFilesystem { +public: + std::set files; + std::set valid_files; + std::string fail_from; + std::string fail_to; + std::string fail_remove; + + bool exists(const char* path) const { + return files.count(path) != 0; + } + bool remove(const char* path) { + if (fail_remove == path) return false; + valid_files.erase(path); + return files.erase(path) != 0; + } + bool rename(const char* from, const char* to) { + if (fail_from == from && fail_to == to) return false; + if (!exists(from) || exists(to)) return false; + files.erase(from); + files.insert(to); + if (valid_files.erase(from) != 0) valid_files.insert(to); + return true; + } +}; + +bool replayFileIsValid(ReplayFakeFilesystem* fs, const char* path) { + return fs->valid_files.count(path) != 0; +} + +TEST(ClientLoginPersistence, InterruptedReplayPublishRestoresOldFloorImage) { + ReplayFakeFilesystem fs; + fs.files = { + mesh::CLIENT_LOGIN_REPLAY_PRIMARY_PATH, + mesh::CLIENT_LOGIN_REPLAY_TEMP_PATH, + }; + fs.valid_files = { + mesh::CLIENT_LOGIN_REPLAY_PRIMARY_PATH, + mesh::CLIENT_LOGIN_REPLAY_TEMP_PATH, + }; + fs.fail_from = mesh::CLIENT_LOGIN_REPLAY_TEMP_PATH; + fs.fail_to = mesh::CLIENT_LOGIN_REPLAY_PRIMARY_PATH; + + EXPECT_FALSE(mesh::publishClientLoginReplayTemp( + &fs, true, replayFileIsValid)); + EXPECT_TRUE(fs.exists(mesh::CLIENT_LOGIN_REPLAY_PRIMARY_PATH)); + EXPECT_TRUE(fs.valid_files.count( + mesh::CLIENT_LOGIN_REPLAY_PRIMARY_PATH) != 0); + EXPECT_FALSE(fs.exists(mesh::CLIENT_LOGIN_REPLAY_BACKUP_PATH)); +} + +TEST(ClientLoginPersistence, PublishedReplaySurvivesBackupCleanupFailure) { + ReplayFakeFilesystem fs; + fs.files = { + mesh::CLIENT_LOGIN_REPLAY_PRIMARY_PATH, + mesh::CLIENT_LOGIN_REPLAY_TEMP_PATH, + }; + fs.valid_files = { + mesh::CLIENT_LOGIN_REPLAY_PRIMARY_PATH, + mesh::CLIENT_LOGIN_REPLAY_TEMP_PATH, + }; + fs.fail_remove = mesh::CLIENT_LOGIN_REPLAY_BACKUP_PATH; + + EXPECT_TRUE(mesh::publishClientLoginReplayTemp( + &fs, true, replayFileIsValid)); + EXPECT_TRUE(fs.exists(mesh::CLIENT_LOGIN_REPLAY_PRIMARY_PATH)); + EXPECT_TRUE(fs.valid_files.count( + mesh::CLIENT_LOGIN_REPLAY_PRIMARY_PATH) != 0); + EXPECT_TRUE(fs.exists(mesh::CLIENT_LOGIN_REPLAY_BACKUP_PATH)); + + fs.fail_remove.clear(); + EXPECT_TRUE(mesh::recoverClientLoginReplayFiles( + &fs, replayFileIsValid)); + EXPECT_TRUE(fs.exists(mesh::CLIENT_LOGIN_REPLAY_PRIMARY_PATH)); + EXPECT_FALSE(fs.exists(mesh::CLIENT_LOGIN_REPLAY_BACKUP_PATH)); +} + +TEST(ClientLoginPersistence, ValidReplayLoadsWhenCleanupCannotFinish) { + ReplayFakeFilesystem fs; + fs.files = { + mesh::CLIENT_LOGIN_REPLAY_PRIMARY_PATH, + mesh::CLIENT_LOGIN_REPLAY_TEMP_PATH, + mesh::CLIENT_LOGIN_REPLAY_BACKUP_PATH, + }; + fs.valid_files = {mesh::CLIENT_LOGIN_REPLAY_PRIMARY_PATH}; + fs.fail_remove = mesh::CLIENT_LOGIN_REPLAY_BACKUP_PATH; + + EXPECT_TRUE(mesh::recoverClientLoginReplayFiles( + &fs, replayFileIsValid)); + EXPECT_TRUE(fs.exists(mesh::CLIENT_LOGIN_REPLAY_PRIMARY_PATH)); + EXPECT_FALSE(fs.exists(mesh::CLIENT_LOGIN_REPLAY_TEMP_PATH)); + EXPECT_TRUE(fs.exists(mesh::CLIENT_LOGIN_REPLAY_BACKUP_PATH)); +} + +TEST(ClientLoginPersistence, BootRecoveryRestoresValidReplayBackup) { + ReplayFakeFilesystem fs; + fs.files = { + mesh::CLIENT_LOGIN_REPLAY_PRIMARY_PATH, + mesh::CLIENT_LOGIN_REPLAY_BACKUP_PATH, + mesh::CLIENT_LOGIN_REPLAY_TEMP_PATH, + }; + fs.valid_files = {mesh::CLIENT_LOGIN_REPLAY_BACKUP_PATH}; + + EXPECT_TRUE(mesh::recoverClientLoginReplayFiles( + &fs, replayFileIsValid)); + EXPECT_TRUE(fs.exists(mesh::CLIENT_LOGIN_REPLAY_PRIMARY_PATH)); + EXPECT_TRUE(fs.valid_files.count( + mesh::CLIENT_LOGIN_REPLAY_PRIMARY_PATH) != 0); + EXPECT_FALSE(fs.exists(mesh::CLIENT_LOGIN_REPLAY_BACKUP_PATH)); + EXPECT_FALSE(fs.exists(mesh::CLIENT_LOGIN_REPLAY_TEMP_PATH)); +} + +TEST(ClientLoginPersistence, CorruptReplayStateFailsClosed) { + ReplayFakeFilesystem fs; + fs.files = {mesh::CLIENT_LOGIN_REPLAY_PRIMARY_PATH}; + + EXPECT_FALSE(mesh::recoverClientLoginReplayFiles( + &fs, replayFileIsValid)); + EXPECT_TRUE(fs.exists(mesh::CLIENT_LOGIN_REPLAY_PRIMARY_PATH)); +} + +} // namespace + +int main(int argc, char** argv) { + ::testing::InitGoogleTest(&argc, argv); + return RUN_ALL_TESTS(); +} diff --git a/test/test_client_login_profile_contract.py b/test/test_client_login_profile_contract.py new file mode 100644 index 00000000..cc529126 --- /dev/null +++ b/test/test_client_login_profile_contract.py @@ -0,0 +1,50 @@ +#!/usr/bin/env python3 +"""Static regression contract for ClientACL-backed login handlers.""" + +import re +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[1] +PROFILES = ( + (ROOT / "examples/simple_repeater/MyMesh.cpp", 4, "perms"), + (ROOT / "examples/simple_sensor/SensorMesh.cpp", 4, "role_permissions"), + (ROOT / "examples/simple_room_server/MyMesh.cpp", 8, "perm"), +) + + +def handler_body(path: Path) -> str: + source = path.read_text(encoding="utf-8") + marker = "handleLoginReq" if "simple_room_server" not in str(path) else "onAnonDataRecv" + start = source.index(marker) + end = source.index("RESP_SERVER_LOGIN_OK", start) + return source[start:end] + + +for profile, minimum_anon_length, login_permissions in PROFILES: + body = handler_body(profile) + freshness = body.index("authorizeLoginTimestamp") + allocation = body.index("putClient") + assert freshness < allocation, ( + f"{profile}: durable replay authorization must precede allocation" + ) + assert "applySuccessfulClientLogin" in body, f"{profile}: unmasked role transition" + assert "PERM_ACL_ROLE_MASK" in body, f"{profile}: missing role mask" + assert "OUT_PATH_FORCE_FLOOD" in body, f"{profile}: flood login destroys force-flood" + authorization = body[ + freshness : body.index(")", freshness) + 1 + ] + assert re.search( + rf"previous_timestamp\s*,\s*{re.escape(login_permissions)}\s*\)", + authorization, + ), f"{profile}: replay admission is not bound to the authenticated role" + + source = profile.read_text(encoding="utf-8") + anon_start = source.index("onAnonDataRecv") + first_field_read = source.index("memcpy(", anon_start) + length_guard = source.index(f"len < {minimum_anon_length}", anon_start) + assert length_guard < first_field_read, ( + f"{profile}: anonymous request fields read before minimum-length guard" + ) + +print("client login profile contract: PASS") diff --git a/test/test_client_path_persistence/test_client_path_persistence.cpp b/test/test_client_path_persistence/test_client_path_persistence.cpp new file mode 100644 index 00000000..c9477d40 --- /dev/null +++ b/test/test_client_path_persistence/test_client_path_persistence.cpp @@ -0,0 +1,245 @@ +#include + +#include +#include + +namespace { + +static constexpr uint8_t UNKNOWN_PATH = 0xFF; +static constexpr uint8_t FORCE_FLOOD_PATH = 0xFE; + +struct ClientState { + uint8_t permissions = 3; + uint8_t out_path_len = UNKNOWN_PATH; + uint8_t out_path[64] = {}; + bool out_path_is_persistable = true; +}; + +TEST(ClientPathPersistence, UnknownToLearnedPathNeedsPersistence) { + ClientState client; + const uint8_t path[] = {0x10, 0x11, 0x12, 0x13}; + + const auto result = mesh::applyReceivedClientPath( + client, path, 0x42, true, FORCE_FLOOD_PATH); + EXPECT_TRUE(result.changed); + EXPECT_TRUE(result.persistence_needed); + EXPECT_TRUE(client.out_path_is_persistable); + EXPECT_EQ(client.out_path_len, 0x42); + EXPECT_EQ(memcmp(client.out_path, path, sizeof(path)), 0); +} + +TEST(ClientPathPersistence, SameEncodedLengthAndBytesIsUnchanged) { + ClientState client; + client.out_path_len = 0x42; + const uint8_t path[] = {0x10, 0x11, 0x12, 0x13}; + memcpy(client.out_path, path, sizeof(path)); + client.out_path[10] = 0xA5; // stale capacity is not part of the path + + const auto result = mesh::applyReceivedClientPath( + client, path, 0x42, true, FORCE_FLOOD_PATH); + EXPECT_FALSE(result.changed); + EXPECT_FALSE(result.persistence_needed); + EXPECT_TRUE(client.out_path_is_persistable); + EXPECT_EQ(client.out_path[10], 0xA5); +} + +TEST(ClientPathPersistence, SameEncodedLengthDifferentBytesChanges) { + ClientState client; + client.out_path_len = 2; + client.out_path[0] = 0x10; + client.out_path[1] = 0x11; + const uint8_t path[] = {0x10, 0x22}; + + const auto result = mesh::applyReceivedClientPath( + client, path, 2, true, FORCE_FLOOD_PATH); + EXPECT_TRUE(result.changed); + EXPECT_TRUE(result.persistence_needed); + EXPECT_EQ(client.out_path[1], 0x22); +} + +TEST(ClientPathPersistence, DifferentEncodingChangesEvenAtSameByteLength) { + ClientState client; + client.out_path_len = 2; // two one-byte hashes + client.out_path[0] = 0x10; + client.out_path[1] = 0x11; + const uint8_t path[] = {0x10, 0x11}; + + const auto result = mesh::applyReceivedClientPath( + client, path, 0x41, true, FORCE_FLOOD_PATH); // one two-byte hash + EXPECT_TRUE(result.changed); + EXPECT_TRUE(result.persistence_needed); + EXPECT_EQ(client.out_path_len, 0x41); +} + +TEST(ClientPathPersistence, EncodingValidationMatchesPacketPathRules) { + EXPECT_TRUE(mesh::isValidEncodedClientPathLength(0, 64)); + EXPECT_TRUE(mesh::isValidEncodedClientPathLength(0x5F, 64)); + EXPECT_FALSE(mesh::isValidEncodedClientPathLength(0x61, 64)); + EXPECT_FALSE(mesh::isValidEncodedClientPathLength(0xC0, 64)); +} + +TEST(ClientPathPersistence, ZeroHopIsARealLogicalPath) { + ClientState client; + + const auto result = mesh::applyReceivedClientPath( + client, nullptr, 0, true, FORCE_FLOOD_PATH); + EXPECT_TRUE(result.changed); + EXPECT_TRUE(result.persistence_needed); + EXPECT_EQ(client.out_path_len, 0); + + const auto repeated = mesh::applyReceivedClientPath( + client, nullptr, 0, true, FORCE_FLOOD_PATH); + EXPECT_FALSE(repeated.changed); +} + +TEST(ClientPathPersistence, ForceFloodIsNeverOverwritten) { + ClientState client; + client.out_path_len = FORCE_FLOOD_PATH; + client.out_path[0] = 0x55; + const uint8_t path[] = {0x10}; + + const auto result = mesh::applyReceivedClientPath( + client, path, 1, true, FORCE_FLOOD_PATH); + EXPECT_FALSE(result.changed); + EXPECT_FALSE(result.persistence_needed); + EXPECT_EQ(client.out_path_len, FORCE_FLOOD_PATH); + EXPECT_EQ(client.out_path[0], 0x55); +} + +TEST(ClientPathPersistence, NonpersistentClientStillGetsLatestRamPath) { + ClientState client; + client.permissions = 0; + const uint8_t path[] = {0x33}; + + const auto result = mesh::applyReceivedClientPath( + client, path, 1, false, FORCE_FLOOD_PATH); + EXPECT_TRUE(result.changed); + EXPECT_FALSE(result.persistence_needed); + EXPECT_EQ(client.out_path_len, 1); + EXPECT_EQ(client.out_path[0], 0x33); + EXPECT_FALSE(client.out_path_is_persistable); +} + +TEST(ClientPathPersistence, NoReplayProofForcesRamOnlyPath) { + ClientState client; + const uint8_t path[] = {0x44}; + EXPECT_FALSE(mesh::clientPathPersistenceAllowed(true, false)); + + const auto result = mesh::applyReceivedClientPath( + client, path, 1, + mesh::clientPathPersistenceAllowed(true, false), + FORCE_FLOOD_PATH); + EXPECT_TRUE(result.changed); + EXPECT_FALSE(result.persistence_needed); + EXPECT_EQ(client.out_path[0], 0x44); + EXPECT_FALSE(client.out_path_is_persistable); + EXPECT_EQ(mesh::storedClientPathLength( + client.out_path_is_persistable, client.out_path_len, UNKNOWN_PATH), + UNKNOWN_PATH); +} + +TEST(ClientPathPersistence, IdenticalProvenPathPromotesRamRouteToDurable) { + ClientState client; + const uint8_t path[] = {0x44}; + ASSERT_TRUE(mesh::applyReceivedClientPath( + client, path, 1, false, FORCE_FLOOD_PATH).changed); + ASSERT_FALSE(client.out_path_is_persistable); + + const auto promoted = mesh::applyReceivedClientPath( + client, path, 1, true, FORCE_FLOOD_PATH); + EXPECT_TRUE(promoted.changed); + EXPECT_TRUE(promoted.persistence_needed); + EXPECT_TRUE(client.out_path_is_persistable); +} + +TEST(ClientPathPersistence, + TransientLearnedPathCannotEraseOperatorPathOnUnrelatedSaveReload) { + ClientState client; + const uint8_t operator_path[] = {0x11, 0x22}; + client.out_path_len = 2; + memcpy(client.out_path, operator_path, sizeof(operator_path)); + client.out_path_is_persistable = true; + + // This represents the already-published /s_contacts record. + uint8_t published_path[64] = {}; + memcpy(published_path, client.out_path, sizeof(published_path)); + uint8_t published_path_len = client.out_path_len; + + const uint8_t learned_path[] = {0x33, 0x44}; + const auto learned = mesh::applyReceivedClientPath( + client, learned_path, 2, false, FORCE_FLOOD_PATH); + ASSERT_TRUE(learned.changed); + ASSERT_FALSE(learned.persistence_needed); + ASSERT_FALSE(client.out_path_is_persistable); + ASSERT_EQ(client.out_path[0], 0x33); + + // An unrelated permissions/ACL save must select the prior durable route, + // not UNKNOWN and not the replay-unproven runtime route. + const uint8_t empty_path[64] = {}; + const auto selected = mesh::selectStoredClientPath( + client.out_path_is_persistable, + client.out_path_len, + client.out_path, + true, + published_path_len, + published_path, + UNKNOWN_PATH, + empty_path); + EXPECT_EQ(selected.encoded_path_len, 2); + EXPECT_EQ(memcmp(selected.path, operator_path, sizeof(operator_path)), 0); + + // Emulate reload from that just-written record. + ClientState reloaded; + reloaded.out_path_len = selected.encoded_path_len; + memcpy(reloaded.out_path, selected.path, sizeof(reloaded.out_path)); + reloaded.out_path_is_persistable = true; + EXPECT_EQ(reloaded.out_path_len, 2); + EXPECT_EQ(memcmp(reloaded.out_path, operator_path, + sizeof(operator_path)), 0); +} + +TEST(ClientPathPersistence, TransientPathWithoutPriorRecordSavesUnknown) { + ClientState client; + const uint8_t learned_path[] = {0x55}; + ASSERT_TRUE(mesh::applyReceivedClientPath( + client, learned_path, 1, false, FORCE_FLOOD_PATH).changed); + + const uint8_t empty_path[64] = {}; + const auto selected = mesh::selectStoredClientPath( + client.out_path_is_persistable, + client.out_path_len, + client.out_path, + false, + UNKNOWN_PATH, + empty_path, + UNKNOWN_PATH, + empty_path); + EXPECT_EQ(selected.encoded_path_len, UNKNOWN_PATH); + EXPECT_EQ(selected.path, empty_path); +} + +TEST(ClientPathPersistence, PendingWriteKeepsFirstDeadlineAndLatestRamPath) { + ClientState client; + unsigned long pending = 0; + const uint8_t first[] = {0x11}; + const uint8_t latest[] = {0x22}; + + auto result = mesh::applyReceivedClientPath( + client, first, 1, true, FORCE_FLOOD_PATH); + EXPECT_TRUE(mesh::armFirstLazyPersistence( + pending, 5000, result.persistence_needed)); + + result = mesh::applyReceivedClientPath( + client, latest, 1, true, FORCE_FLOOD_PATH); + EXPECT_FALSE(mesh::armFirstLazyPersistence( + pending, 9000, result.persistence_needed)); + EXPECT_EQ(pending, 5000UL); + EXPECT_EQ(client.out_path[0], 0x22); +} + +} // namespace + +int main(int argc, char** argv) { + ::testing::InitGoogleTest(&argc, argv); + return RUN_ALL_TESTS(); +} diff --git a/test/test_companion_terminal_profile.py b/test/test_companion_terminal_profile.py index 77da34af..4a168368 100644 --- a/test/test_companion_terminal_profile.py +++ b/test/test_companion_terminal_profile.py @@ -24,16 +24,46 @@ assert re.search( assert "offline_queue_len" in local assert "getOfflineQueueCapacity()" in local +# Mutable local settings must be dispatched before the terminal's generic +# `set` fallback. `set pin` used to exist only in handleCommand(), so the +# text terminal intercepted it as an unknown setting while framed/rescue CLI +# happened to work. +assert local.count('strncmp(command, "set pin", 7) == 0') == 1 +assert "mesh::cli::parseIntegerStrict(value, parsed)" in local +assert "const uint32_t previous = _prefs.ble_pin;" in local +assert "_prefs.ble_pin = previous;" in local + terminal_start = source.index("void MyMesh::handleTerminalCommand(") terminal_end = source.index("\nvoid MyMesh::enterCLIRescue()", terminal_start) terminal = source[terminal_start:terminal_end] +assert terminal.index("handleLocalControlCommand(") < terminal.index( + 'strncmp(command, "set ", 4) == 0' +) assert 'terminalOutput().print(" board\\r\\n")' in terminal +assert 'terminalOutput().print(" set pin <0-999999>\\r\\n")' in terminal assert re.search( r"#if COMPANION_FEATURE_MEMORY_DIAGNOSTICS\s+" r'terminalOutput\(\)\.print\(" memory\\r\\n"\);\s+#endif', terminal, ) +# Terminal-specific handlers keep precedence, then both the generic `set` +# fallthrough and the final unknown-command fallthrough delegate to the shared +# framed/rescue command surface. This keeps get/set radio, get name, and +# variant commands available without routing terminal-only commands twice. +assert terminal.count("handleCommand(command, 0, local_reply)") == 2 +assert terminal.index('strncmp(config, "tx ", 3)') < terminal.index( + "handleCommand(command, 0, local_reply)" +) +assert terminal.rindex('strcmp(command, "ver") == 0') < terminal.rindex( + "handleCommand(command, 0, local_reply)" +) + +command_start = source.index("bool MyMesh::handleCommand(") +command_end = source.index("\nvoid MyMesh::checkCLIRescueCmd()", command_start) +command_handler = source[command_start:command_end] +assert 'strncmp(command, "set pin' not in command_handler + # This compile-time contract prevents either the implementation or help entry # from leaking onto a non-ESP32 build merely because a recipe set the flag. assert re.search( diff --git a/test/test_display_driver/test_display_driver.cpp b/test/test_display_driver/test_display_driver.cpp index 1d88f0f2..f8f3889c 100644 --- a/test/test_display_driver/test_display_driver.cpp +++ b/test/test_display_driver/test_display_driver.cpp @@ -1,8 +1,10 @@ #include #include +#include #include +#include namespace { @@ -32,7 +34,16 @@ bool isValidUTF8(const char* text) { class TestDisplay : public DisplayDriver { public: + struct DrawnText { + int x; + int y; + std::string text; + }; + std::string printed; + std::vector rows; + int cursor_x = 0; + int cursor_y = 0; TestDisplay() : DisplayDriver(100, 100) {} @@ -43,8 +54,14 @@ public: void startFrame(ColorVal) override {} void setTextSize(int) override {} void setColor(ColorVal) override {} - void setCursor(int, int) override {} - void print(const char* str) override { printed = str; } + void setCursor(int x, int y) override { + cursor_x = x; + cursor_y = y; + } + void print(const char* str) override { + printed = str; + rows.push_back({cursor_x, cursor_y, str}); + } void fillRect(int, int, int, int) override {} void drawRect(int, int, int, int) override {} void drawXbm(int, int, const uint8_t*, int, int) override {} @@ -77,6 +94,43 @@ TEST(DisplayDriver, FixedBufferDoesNotSplitUTF8Codepoint) { EXPECT_TRUE(isValidUTF8(display.printed.c_str())); } +TEST(DisplayDriver, WrapsCompleteTextAcrossAvailableRows) { + TestDisplay display; + EXPECT_EQ(3, mesh::ui::drawTextWrapped( + display, 4, 10, 3, 9, 3, "ABCDEFG")); + ASSERT_EQ(3U, display.rows.size()); + EXPECT_EQ(4, display.rows[0].x); + EXPECT_EQ(10, display.rows[0].y); + EXPECT_EQ("ABC", display.rows[0].text); + EXPECT_EQ(4, display.rows[1].x); + EXPECT_EQ(19, display.rows[1].y); + EXPECT_EQ("DEF", display.rows[1].text); + EXPECT_EQ(28, display.rows[2].y); + EXPECT_EQ("G", display.rows[2].text); +} + +TEST(DisplayDriver, WrappedTextKeepsUTF8CodepointsIntact) { + TestDisplay display; + EXPECT_EQ(2, mesh::ui::drawTextWrapped( + display, 0, 0, 3, 10, 2, "AB\xF0\x9F\x98\x80" "CDE")); + ASSERT_EQ(2U, display.rows.size()); + EXPECT_EQ("AB\xF0\x9F\x98\x80", display.rows[0].text); + EXPECT_EQ("CDE", display.rows[1].text); + EXPECT_TRUE(isValidUTF8(display.rows[0].text.c_str())); + EXPECT_TRUE(isValidUTF8(display.rows[1].text.c_str())); +} + +TEST(DisplayDriver, KeepsMaximumLengthSSIDWithoutEllipsis) { + TestDisplay display; + const std::string ssid = "1234567890ABCDEF1234567890ABCDEF"; + EXPECT_EQ(3, mesh::ui::drawTextWrapped( + display, 0, 0, 12, 10, 3, ssid.c_str())); + ASSERT_EQ(3U, display.rows.size()); + EXPECT_EQ(ssid, + display.rows[0].text + display.rows[1].text + + display.rows[2].text); +} + int main(int argc, char** argv) { ::testing::InitGoogleTest(&argc, argv); return RUN_ALL_TESTS(); diff --git a/test/test_esp32_usb_serial_hygiene.py b/test/test_esp32_usb_serial_hygiene.py new file mode 100644 index 00000000..94d1e444 --- /dev/null +++ b/test/test_esp32_usb_serial_hygiene.py @@ -0,0 +1,108 @@ +#!/usr/bin/env python3 + +import re +from pathlib import Path +import unittest + + +ROOT = Path(__file__).resolve().parents[1] + + +def source(path: str) -> str: + return (ROOT / path).read_text() + + +class Esp32UsbSerialHygieneTest(unittest.TestCase): + def test_operational_wifi_diagnostics_use_runtime_logging_port(self): + for relative in ( + "src/helpers/ESP32Board.cpp", + "src/helpers/CompanionMqttSetupPortal.cpp", + "src/helpers/JWTHelper.cpp", + "src/helpers/bridges/MQTTBridge.cpp", + "src/helpers/esp32/WiFiOtaSeeder.cpp", + "src/helpers/esp32/WebConfigServer.cpp", + "src/helpers/WiFiSetupPortal.cpp", + ): + text = source(relative) + self.assertIn("UsbLogging.h", text, relative) + self.assertNotRegex( + text, + r"\bSerial\.(?:print|println|printf|write)\s*\(", + relative, + ) + self.assertIn("mesh::usbLoggingPort()", text, relative) + + def test_v4_companion_uses_usb_serial_jtag_mode(self): + platformio = source("variants/heltec_v4/platformio.ini") + base = platformio[ + platformio.index("[Heltec_lora32_v4]") : + platformio.index("[heltec_v4_oled]") + ] + self.assertIn("-D ARDUINO_USB_MODE=1", base) + self.assertNotIn("ARDUINO_USB_MODE=0", base) + + board = source("boards/heltec_v4.json") + self.assertIn('"-DARDUINO_USB_CDC_ON_BOOT=1"', board) + self.assertIn('"-DARDUINO_USB_MODE=1"', board) + + def test_mqtt_ntp_detail_never_bypasses_logging_mode(self): + text = source("src/helpers/bridges/MQTTBridge.cpp") + self.assertIn( + "if (verbose && mesh::isUsbLoggingEnabled())", text + ) + self.assertIn("Stream& output = mesh::usbLoggingPort();", text) + + def test_framework_diagnostics_follow_same_runtime_gate(self): + text = source("src/helpers/UsbLogging.cpp") + self.assertIn("Serial.setDebugOutput(enabled);", text) + + setter = text[ + text.index("void setUsbLoggingEnabled(") : + text.index("bool saveUsbLoggingBootPreference(") + ] + self.assertIn("setPlatformDebugOutputEnabled(enabled);", setter) + + begin = text[ + text.index("void beginUsbLoggingPort(") : + text.index("void serviceUsbLoggingPort(") + ] + self.assertIn( + "setPlatformDebugOutputEnabled(isUsbLoggingEnabled());", begin + ) + + def test_expected_fresh_nvs_state_is_silent(self): + wifi_setup = source("src/helpers/WiFiSetupPortal.cpp") + webconfig = source("src/helpers/esp32/WebConfigServer.cpp") + radio_policy = source("src/helpers/esp32/WiFiRadioPolicy.h") + mqtt_setup = source("src/helpers/CompanionMqttSetupPortal.cpp") + + for text in (wifi_setup, webconfig, radio_policy, mqtt_setup): + self.assertNotRegex( + text, + r'\.begin\("mesh-(?:wifi|webui|mqtt)",\s*true\)', + ) + self.assertNotIn("nvs.begin(NVS_NAMESPACE, true)", mqtt_setup) + + for text in (wifi_setup, webconfig): + self.assertIn('isKey("ssid")', text) + self.assertIn('isKey("password")', text) + self.assertIn('isKey("enabled")', webconfig) + self.assertIn('isKey("cli")', webconfig) + self.assertIn('isKey("powersave")', webconfig) + self.assertIn('isKey("espnow_ch")', radio_policy) + self.assertIn("nvs.isKey(NVS_VERSION_KEY)", mqtt_setup) + self.assertIn("nvs.isKey(NVS_PREFS_KEY)", mqtt_setup) + + def test_indicator_reports_specific_hardware(self): + header = source("variants/sensecap_indicator-espnow/target.h") + implementation = source("variants/sensecap_indicator-espnow/target.cpp") + self.assertIn( + "class SenseCapIndicatorBoard : public ESP32Board", header + ) + self.assertIn('return "Seeed SenseCAP Indicator";', header) + self.assertIn("extern SenseCapIndicatorBoard board;", header) + self.assertIn("SenseCapIndicatorBoard board;", implementation) + + +if __name__ == "__main__": + unittest.main() diff --git a/test/test_firmware_picker.js b/test/test_firmware_picker.js index 2466597e..9c7a904f 100644 --- a/test/test_firmware_picker.js +++ b/test/test_firmware_picker.js @@ -572,12 +572,91 @@ const mergedRs232 = [ installKinds: ["uf2"], }); }); +const mergedRs232Visible = picker.applyMergedRak4631RepeaterCapabilities( + picker.omitTransportsReplacedByFull(mergedRs232) +); assert.deepStrictEqual( - picker.omitTransportsReplacedByFull(mergedRs232).map(function (item) { + mergedRs232Visible.map(function (item) { return item.target; }), ["RAK_4631_repeater"] ); +assert.deepStrictEqual( + picker.profileFieldValues(mergedRs232Visible[0], "mode"), + ["standard", "rs232"] +); +assert(picker.profileMatches( + mergedRs232Visible[0], + { mode: "standard" }, + ["mode"] +)); +assert(picker.profileMatches( + mergedRs232Visible[0], + { mode: "rs232" }, + ["mode"] +)); + +// Exact legacy target rows must stay attached to their own compatibility +// downloads. They disappear only from recommendations when the canonical +// merged image is present; advertising RS232 on that image must not rewrite +// or coalesce the legacy files into the canonical profile. +const rakCompatibilityCatalog = picker.buildCatalog([ + release(family, "2026-08-23T13:00:02Z", []), + release("repeater-room-" + family, "2026-08-23T13:00:01Z", [ + asset("RAK_4631_repeater-" + family + ".uf2"), + asset( + "RAK_4631_repeater_bridge_rs232_serial1-" + family + ".uf2" + ), + asset( + "RAK_4631_repeater_bridge_rs232_serial2-" + family + ".uf2" + ), + ]), + release("lora-ota-" + family, "2026-08-23T13:00:00Z", [ + asset( + "RAK_4631_repeater_lora_ota_no_external_sensors-ota-" + + family + ".zip" + ), + asset( + "RAK_4631_repeater_bridge_rs232_serial1_" + + "lora_ota_no_external_sensors-ota-" + family + ".zip" + ), + asset( + "RAK_4631_repeater_bridge_rs232_serial2_" + + "lora_ota_no_external_sensors-ota-" + family + ".zip" + ), + ]), +]); +const expectedCompatibilityTargets = [ + "RAK_4631_repeater_bridge_rs232_serial1", + "RAK_4631_repeater_bridge_rs232_serial2", + "RAK_4631_repeater_bridge_rs232_serial1_lora_ota_no_external_sensors", + "RAK_4631_repeater_bridge_rs232_serial2_lora_ota_no_external_sensors", +]; +expectedCompatibilityTargets.forEach(function (target) { + assert(rakCompatibilityCatalog.rows.some(function (row) { + return row.target === target; + }), "missing exact compatibility row " + target); + assert(!rakCompatibilityCatalog.profiles.some(function (item) { + return item.target === target; + }), "legacy compatibility target was recommended " + target); +}); +[ + "RAK_4631_repeater", + "RAK_4631_repeater_lora_ota_no_external_sensors", +].forEach(function (target) { + const canonical = rakCompatibilityCatalog.profiles.find(function (item) { + return item.target === target; + }); + assert(canonical, "missing canonical RAK4631 profile " + target); + assert.deepStrictEqual( + picker.profileFieldValues(canonical, "mode"), + ["standard", "rs232"] + ); + assert(canonical.files.every(function (file) { + return file.target === target; + }), "legacy compatibility file was mapped to " + target); +}); + const constrainedRs232 = [ "wio-e5_repeater", "wio-e5-repeater_bridge_rs232", diff --git a/test/test_gps_transport_ownership/test_gps_transport_ownership.cpp b/test/test_gps_transport_ownership/test_gps_transport_ownership.cpp new file mode 100644 index 00000000..a1090b35 --- /dev/null +++ b/test/test_gps_transport_ownership/test_gps_transport_ownership.cpp @@ -0,0 +1,106 @@ +#include +#include + +#define ENV_INCLUDE_GPS 1 +#include "../../src/helpers/SensorManager.cpp" + +class TestGpsSensorManager : public SensorManager { +public: + bool detected = true; + bool active = false; + uint8_t starts = 0; + uint8_t stops = 0; + + void setUserEnabled(bool enabled) { setGpsTelemetryUserEnabled(enabled); } + void setTransportAvailable(bool available) { + setGpsTelemetryTransportAvailable(available); + } + bool receiverRequired(unsigned long now) const { + return gpsTelemetryReceiverRequired(now); + } + bool queryLocation(CayenneLPP& telemetry) { + return queryGpsTelemetry(TELEM_PERM_LOCATION, telemetry); + } + void acceptFix(float lat, float lon, float altitude, unsigned long now) { + processGpsTelemetryFix(lat, lon, altitude, now); + } + void stopReceiver() { telemetryGpsStop(); } + +protected: + bool telemetryGpsDetected() const override { return detected; } + bool telemetryGpsActive() const override { return active; } + void telemetryGpsStart() override { + active = true; + starts++; + } + void telemetryGpsStop() override { + active = false; + stops++; + } +}; + +TEST(GpsTransportOwnership, BlockingCancelsAcquisitionAndRemoteHold) { + resetArduinoMock(); + TestGpsSensorManager sensors; + CayenneLPP telemetry(64); + + EXPECT_FALSE(sensors.queryLocation(telemetry)); + EXPECT_TRUE(sensors.active); + EXPECT_TRUE(sensors.receiverRequired(millis())); + + // Hardware-specific ownership code stops the receiver before handing its + // UART to the bridge; the base transition clears acquisition and hold state. + sensors.stopReceiver(); + sensors.setTransportAvailable(false); + EXPECT_FALSE(sensors.active); + EXPECT_FALSE(sensors.receiverRequired(millis())); + EXPECT_FALSE(sensors.queryLocation(telemetry)); + EXPECT_EQ(1, sensors.starts); +} + +TEST(GpsTransportOwnership, UserPreferenceSurvivesBlockAndRestartsOnRelease) { + resetArduinoMock(); + TestGpsSensorManager sensors; + + sensors.setUserEnabled(true); + ASSERT_TRUE(sensors.active); + ASSERT_EQ(1, sensors.starts); + + sensors.stopReceiver(); + sensors.setTransportAvailable(false); + EXPECT_FALSE(sensors.active); + + sensors.setTransportAvailable(true); + EXPECT_TRUE(sensors.active); + EXPECT_EQ(2, sensors.starts); +} + +TEST(GpsTransportOwnership, LastGoodCacheSurvivesTemporaryUartOwnership) { + resetArduinoMock(); + TestGpsSensorManager sensors; + CayenneLPP telemetry(64); + + EXPECT_FALSE(sensors.queryLocation(telemetry)); + sensors.acceptFix(47.61f, -122.33f, 125.0f, millis()); + g_mock_millis += 31000; + sensors.acceptFix(47.61f, -122.33f, 125.0f, millis()); + ASSERT_TRUE(sensors.queryLocation(telemetry)); + ASSERT_EQ(1, telemetry.gps_count); + + sensors.stopReceiver(); + sensors.setTransportAvailable(false); + EXPECT_TRUE(sensors.queryLocation(telemetry)); + EXPECT_EQ(2, telemetry.gps_count); + EXPECT_FALSE(sensors.receiverRequired(millis())); + + sensors.setTransportAvailable(true); + EXPECT_TRUE(sensors.queryLocation(telemetry)); + EXPECT_EQ(3, telemetry.gps_count); + EXPECT_FLOAT_EQ(47.61f, telemetry.last_latitude); + EXPECT_FLOAT_EQ(-122.33f, telemetry.last_longitude); +} + +int main(int argc, char** argv) { + ::testing::InitGoogleTest(&argc, argv); + return RUN_ALL_TESTS(); +} diff --git a/test/test_i2c_address_claim_policy/test_i2c_address_claim_policy.cpp b/test/test_i2c_address_claim_policy/test_i2c_address_claim_policy.cpp new file mode 100644 index 00000000..dcf93a97 --- /dev/null +++ b/test/test_i2c_address_claim_policy/test_i2c_address_claim_policy.cpp @@ -0,0 +1,131 @@ +#include + +#include +#include + +#include "helpers/sensors/I2CAddressClaimPolicy.h" +#include "helpers/sensors/NmeaSentenceProbe.h" + +static std::string nmeaSentence(const char* body) { + uint8_t checksum = 0; + for (const char* cursor = body; *cursor; ++cursor) { + checksum ^= static_cast(*cursor); + } + char suffix[8]; + std::snprintf(suffix, sizeof(suffix), "*%02X\r\n", checksum); + return std::string("$") + body + suffix; +} + +static bool feed(mesh::NmeaSentenceProbe& probe, const std::string& bytes) { + bool found = false; + for (unsigned char byte : bytes) found = probe.ingest(byte) || found; + return found; +} + +TEST(I2CAddressClaimPolicy, SkipsOnlyTheAddressClaimedByGps) { + EXPECT_TRUE(mesh::shouldSkipSensorAtClaimedGpsAddress( + true, true, 0x42, 0x42)); + EXPECT_FALSE(mesh::shouldSkipSensorAtClaimedGpsAddress( + true, true, 0x43, 0x42)); +} + +TEST(I2CAddressClaimPolicy, FailedOrAbsentGpsLeavesSensorsAvailable) { + EXPECT_FALSE(mesh::shouldSkipSensorAtClaimedGpsAddress( + false, true, 0x42, 0x42)); +} + +TEST(I2CAddressClaimPolicy, SameNumericAddressOnAnotherBusIsIndependent) { + EXPECT_FALSE(mesh::shouldSkipSensorAtClaimedGpsAddress( + true, false, 0x42, 0x42)); +} + +TEST(I2CAddressClaimPolicy, GpsProbeFailsClosedOnInaOrIncompleteIdentity) { + EXPECT_FALSE(mesh::shouldProbeI2cGps( + mesh::I2cIdentityProbeResult::Match)); + EXPECT_FALSE(mesh::shouldProbeI2cGps( + mesh::I2cIdentityProbeResult::Inconclusive)); + EXPECT_FALSE(mesh::shouldProbeI2cGps( + mesh::I2cIdentityProbeResult::NoResponse)); + EXPECT_TRUE(mesh::shouldProbeI2cGps( + mesh::I2cIdentityProbeResult::NotMatch)); +} + +TEST(I2CAddressClaimPolicy, RejectsReservedAndOutOfRangeAddresses) { + EXPECT_FALSE(mesh::isValidI2cPeripheralAddress(0x00)); + EXPECT_FALSE(mesh::isValidI2cPeripheralAddress(0x07)); + EXPECT_TRUE(mesh::isValidI2cPeripheralAddress(0x08)); + EXPECT_TRUE(mesh::isValidI2cPeripheralAddress(0x42)); + EXPECT_TRUE(mesh::isValidI2cPeripheralAddress(0x77)); + EXPECT_FALSE(mesh::isValidI2cPeripheralAddress(0x78)); + EXPECT_FALSE(mesh::isValidI2cPeripheralAddress(0xFF)); + EXPECT_FALSE(mesh::isValidI2cPeripheralAddress(0x142)); + EXPECT_FALSE(mesh::isValidI2cPeripheralAddress(0x10042)); +} + +TEST(I2CAddressClaimPolicy, RejectsUnsafePinPairsBeforeGpioAccess) { + EXPECT_TRUE(mesh::isValidI2cPinPair(8, 7, 23)); + EXPECT_FALSE(mesh::isValidI2cPinPair(-1, 7, 23)); + EXPECT_FALSE(mesh::isValidI2cPinPair(8, -1, 23)); + EXPECT_FALSE(mesh::isValidI2cPinPair(23, 7, 23)); + EXPECT_FALSE(mesh::isValidI2cPinPair(8, 23, 23)); + EXPECT_FALSE(mesh::isValidI2cPinPair(7, 7, 23)); +} + +TEST(I2CAddressClaimPolicy, ClassifiesInaIdentityWithoutAssumingDynamicBytesMatch) { + using Status = mesh::I2cRegisterProbeStatus; + using Result = mesh::I2cIdentityProbeResult; + constexpr uint16_t kManufacturer = 0x5449; + constexpr uint16_t kDie = 0x3220; + + EXPECT_EQ(mesh::classifyIna3221Identity( + Status::Success, kManufacturer, + Status::Success, kDie, kManufacturer, kDie), + Result::Match); + EXPECT_EQ(mesh::classifyIna3221Identity( + Status::NoResponse, 0, + Status::Inconclusive, 0, kManufacturer, kDie), + Result::NoResponse); + EXPECT_EQ(mesh::classifyIna3221Identity( + Status::Success, kManufacturer, + Status::Inconclusive, 0, kManufacturer, kDie), + Result::Inconclusive); + EXPECT_EQ(mesh::classifyIna3221Identity( + Status::Success, kManufacturer, + Status::Success, 0x1234, kManufacturer, kDie), + Result::Inconclusive); + EXPECT_EQ(mesh::classifyIna3221Identity( + Status::Success, 0x0001, + Status::Success, 0x0002, kManufacturer, kDie), + Result::NotMatch); + EXPECT_EQ(mesh::classifyIna3221Identity( + Status::Success, 0x0001, + Status::Success, kManufacturer, kManufacturer, kDie), + Result::Inconclusive); +} + +TEST(NmeaSentenceProbe, AcceptsChecksumValidGpsTalkersIncrementally) { + mesh::NmeaSentenceProbe gps; + EXPECT_TRUE(feed(gps, nmeaSentence("GNGGA,123519,,,,,0,00,99.99,,,,,,"))); + EXPECT_TRUE(gps.found()); + + mesh::NmeaSentenceProbe beidou; + EXPECT_TRUE(feed(beidou, nmeaSentence("BDRMC,123519,V,,,,,,,300826,,,N"))); +} + +TEST(NmeaSentenceProbe, RejectsNoiseBadChecksumsAndNonGpsTalkers) { + mesh::NmeaSentenceProbe probe; + EXPECT_FALSE(feed(probe, "noise\x00\xff$GNGGA,123*00\r\n")); + EXPECT_FALSE(feed(probe, nmeaSentence("AAGGA,123519,,,,,0,00,99.99,,,,,,"))); + EXPECT_FALSE(probe.found()); +} + +TEST(NmeaSentenceProbe, NewDollarRestartsAnIncompleteSentence) { + mesh::NmeaSentenceProbe probe; + const std::string valid = nmeaSentence("GPGGA,123519,,,,,0,00,99.99,,,,,,"); + EXPECT_TRUE(feed(probe, "$GPGGA,broken" + valid)); +} + +int main(int argc, char** argv) { + ::testing::InitGoogleTest(&argc, argv); + return RUN_ALL_TESTS(); +} diff --git a/test/test_indicator_font_recovery.py b/test/test_indicator_font_recovery.py new file mode 100644 index 00000000..3ff82ade --- /dev/null +++ b/test/test_indicator_font_recovery.py @@ -0,0 +1,880 @@ +#!/usr/bin/env python3 + +import base64 +import hashlib +import re +from pathlib import Path +import unittest +import zlib + + +ROOT = Path(__file__).resolve().parents[1] +POLICY = ROOT / "variants/sensecap_indicator-espnow/IndicatorFontRecoveryPolicy.h" +CLIENT = ROOT / "variants/sensecap_indicator-espnow/IndicatorFontClient.cpp" +DISPLAY = ROOT / "variants/sensecap_indicator-espnow/SCIndicatorDisplay.h" +PROFILE = ROOT / "variants/sensecap_indicator-espnow/platformio.ini" +RP2040 = ROOT / "tools/sensecap_indicator_rp2040/src/main.cpp" +STAGE_V2_PROTOCOL = ROOT / "src/helpers/IndicatorFontStageV2Protocol.h" +ASSET = ROOT / "variants/sensecap_indicator-espnow/sd/ui-font.vlw" + + +def source(path: Path) -> str: + return path.read_text() + + +def function_body(text: str, signature: str, next_signature: str) -> str: + start = text.index(signature) + end = text.index(next_signature, start) + return text[start:end] + + +def profile_section(name: str) -> str: + text = source(PROFILE) + match = re.search( + rf"^\[{re.escape(name)}\]\n(?P.*?)(?=^\[|\Z)", + text, + re.MULTILINE | re.DOTALL, + ) + if match is None: + raise AssertionError(f"missing profile section {name}") + return match.group("body") + + +class IndicatorFontRecoveryTest(unittest.TestCase): + def test_compiled_asset_identity_matches_checked_in_file(self): + policy = source(POLICY) + client = source(CLIENT) + data = ASSET.read_bytes() + + size = int(re.search(r"kAssetSize = (\d+)", policy).group(1)) + crc = int(re.search(r"kAssetCrc32 = 0x([0-9a-f]+)", policy).group(1), 16) + sha = re.search(r'kAssetSha256\[\] =\s*"([0-9a-f]{64})"', policy).group(1) + + self.assertEqual(len(data), size) + self.assertEqual(zlib.crc32(data) & 0xFFFFFFFF, crc) + self.assertEqual(hashlib.sha256(data).hexdigest(), sha) + digest_initializer = client[ + client.index("EXPECTED_SHA256[32]") : client.index( + "enum class FontInfoResult" + ) + ] + compiled_digest = bytes( + int(value, 16) + for value in re.findall(r"0x([0-9a-f]{2})", digest_initializer) + ) + self.assertEqual(compiled_digest.hex(), sha) + + def test_source_is_immutable_https_and_tls_is_never_disabled(self): + policy = source(POLICY) + client = source(CLIENT) + url_literals = re.findall( + r'"([^"]*)"', + policy[policy.index("kAssetUrl") : policy.index("kAssetSize")], + ) + url = "".join(url_literals) + + self.assertRegex( + url, + r"^https://api\.github\.com/repos/mikecarper/MeshCore/git/blobs/" + r"[0-9a-f]{40}$", + ) + self.assertNotIn("setInsecure", client) + self.assertNotIn("client.setCACertBundle(", client) + self.assertIn("client.setCACert(FONT_TLS_GITHUB_ROOT_CA)", client) + self.assertIn("Accept: application/vnd.github.raw+json", client) + self.assertIn("X-GitHub-Api-Version: 2026-03-10", client) + self.assertIn("parseHttpStatusCode(line, status_code)", client) + + pem_match = re.search( + r'FONT_TLS_GITHUB_ROOT_CA\[\].*?R"CERT\((.*?)\)CERT";', + client, + re.DOTALL, + ) + self.assertIsNotNone(pem_match) + pem_lines = [ + line.strip() for line in pem_match.group(1).strip().splitlines() + ] + self.assertEqual(pem_lines[0], "-----BEGIN CERTIFICATE-----") + self.assertEqual(pem_lines[-1], "-----END CERTIFICATE-----") + der = base64.b64decode("".join(pem_lines[1:-1]), validate=True) + self.assertEqual( + hashlib.sha256(der).hexdigest(), + "c90f26f0fb1b4018b22227519b5ca2b53e2ca5b3be5cf18efe1bef47380c5383", + ) + + def test_wifi_only_recovery_bootstraps_a_bounded_tls_clock(self): + policy = source(POLICY) + client = source(CLIENT) + proof = function_body( + client, "bool fontTlsClockProofValid", "bool prepareTlsClock" + ) + callback = function_body( + client, "void clearFontNtpCallback", "bool fontTlsClockProofValid" + ) + clock = function_body( + client, "bool prepareTlsClock", "uint32_t remainingHttpHeaderTimeout" + ) + response = function_body( + client, "RecoveryAttemptOutcome openAssetResponse", "bool writeAll" + ) + transfer = function_body( + client, + "RecoveryAttemptOutcome downloadAndInstallAsset", + "void finishRecoveryFailure", + ) + + self.assertIn("kAssetPublishedEpoch = 1787708237UL", policy) + self.assertIn("kNtpSyncWaitMillis = 15000UL", policy) + self.assertIn( + "fontNtpTimeReceived.store(false, std::memory_order_release)", + clock, + ) + self.assertIn("sntp_set_time_sync_notification_cb(noteFontNtpTime)", clock) + self.assertIn("sntp_set_sync_status(SNTP_SYNC_STATUS_RESET)", clock) + self.assertIn('configTime(0, 0, "time.cloudflare.com"', clock) + self.assertIn("OperationLease sntp_operation", clock) + self.assertIn("processWideCoordinator()", clock) + self.assertIn("if (!sntp_operation.tryAcquire())", clock) + self.assertNotIn("sntp_set_time_sync_notification_cb(nullptr)", clock) + self.assertIn("sntp_set_time_sync_notification_cb(nullptr)", callback) + self.assertIn("processWideCoordinator().owns(generation)", callback) + self.assertIn( + "fontNtpTimeReceived.load(std::memory_order_acquire)", proof + ) + self.assertIn("WiFi.status() == WL_CONNECTED", proof) + self.assertIn("mesh::tls_clock::proofIsValid", proof) + self.assertIn("mesh::tls_clock::proofAgeIsValid", proof) + self.assertIn("mesh::tls_clock::proofGenerationIsValid", proof) + self.assertIn("FONT_TLS_PROOF_MAX_AGE_MS = 300000UL", client) + self.assertIn("const time_t now = time(nullptr)", clock) + self.assertIn("fontTlsClockProofValid(now)", clock) + self.assertIn( + "now >= (time_t)mesh::indicator_font::kAssetPublishedEpoch", clock + ) + self.assertNotIn("(uint32_t)time(nullptr)", clock) + self.assertIn("WiFi.status() != WL_CONNECTED", clock) + self.assertNotIn("alreadyPlausible", clock) + self.assertTrue(clock.rstrip().endswith("}")) + self.assertIn("return false;", clock) + self.assertIn("prepareTlsClock()", transfer) + self.assertLess( + transfer.index("prepareTlsClock()"), + transfer.index("openAssetResponse("), + ) + self.assertIn("const time_t tls_now = time(nullptr)", response) + proof_check = response.index("fontTlsClockProofValid(tls_now)") + tls_connect = response.index("client.connect(host, 443)") + self.assertLess(proof_check, tls_connect) + self.assertIn( + "std::atomic fontNtpTimeReceived{false}", client + ) + self.assertIn( + "fontNtpTimeReceived.store(true, std::memory_order_release)", + client, + ) + + def test_release_source_has_no_forced_download_interruption(self): + client = source(CLIENT) + self.assertNotIn("DOWNLOAD_TEST_INTERRUPT_AT", client) + self.assertNotIn("test hook: closing initial HTTP response", client) + + def test_http_headers_have_one_total_deadline(self): + client = source(CLIENT) + response = function_body( + client, "RecoveryAttemptOutcome openAssetResponse", "bool writeAll" + ) + self.assertIn("HTTP_HEADER_TOTAL_TIMEOUT_MS = 30000UL", client) + self.assertIn("remainingHttpHeaderTimeout(headerStarted)", response) + self.assertNotIn( + "readHttpLine(client, line, sizeof(line), HTTP_LINE_TIMEOUT_MS)", + response, + ) + + def test_http_wire_parser_is_bounded_and_rejects_ambiguous_framing(self): + client = source(CLIENT) + reader = function_body( + client, "HttpLineResult readHttpLine", "bool isSecurityCriticalHttpHeader" + ) + decimal = function_body( + client, "bool parseUnsignedDecimalHeader", "bool parseHttpStatusCode" + ) + response = function_body( + client, "RecoveryAttemptOutcome openAssetResponse", "bool writeAll" + ) + + self.assertRegex(client, r"HTTP_HEADER_LIMIT = (?:6[4-9]|[7-9][0-9]|\d{3,})") + self.assertIn("HTTP_WIRE_LINE_LIMIT = 2048", client) + drain = reader.index("while (client.available())") + self.assertIn( + "millis() - started >= timeout_millis", reader[drain:] + ) + self.assertIn("++wire_length > HTTP_WIRE_LINE_LIMIT", reader) + self.assertIn("HttpLineResult::TooLong", reader) + + # Decimal framing accepts digits only, with optional surrounding OWS; + # strtoul-style signs and overflow are not accepted. + self.assertIn("*value < '0' || *value > '9'", decimal) + self.assertIn("UINT64_MAX - digit", decimal) + self.assertNotIn("strtoul", response) + self.assertIn("parsed > MAX_FONT_BYTES", response) + + # Reject every obsolete folded field before handling either retained + # or overflowed extension headers. + obs_fold = response.index("if (line[0] == ' ' || line[0] == '\\t')") + overflow = response.index("if (line_result == HttpLineResult::Overflow)") + self.assertLess(obs_fold, overflow) + + def test_non_200_headers_are_parsed_and_failures_are_classified(self): + client = source(CLIENT) + response = function_body( + client, "RecoveryAttemptOutcome openAssetResponse", "bool writeAll" + ) + classify = function_body( + client, "RecoveryAttemptOutcome classifyHttpResponse", "bool splitHttpsUrl" + ) + finish = function_body( + client, + "void finishRecoveryFailure(const RecoveryAttemptOutcome& outcome)", + "void finishRecoveryFailure()", + ) + + # The status is recorded first, but classification happens only after + # Retry-After and X-RateLimit-Reset have been read from the full block. + self.assertIn('"Retry-After:"', response) + self.assertIn('"X-RateLimit-Reset:"', response) + self.assertGreater( + response.index("classifyHttpResponse("), + response.index("while (header_count <= HTTP_HEADER_LIMIT)"), + ) + self.assertNotIn("status_code != 200", response) + + self.assertIn("status_code == 403 || status_code == 429", classify) + self.assertIn("retry_after_seconds", classify) + self.assertIn("rate_reset_epoch", classify) + self.assertIn("HTTP_RATE_LIMIT_MIN_DELAY_MS", classify) + self.assertIn("HTTP_RATE_LIMIT_FALLBACK_DELAY_MS", classify) + self.assertIn("RecoveryAttemptDisposition::RateLimited", classify) + self.assertIn("status_code >= 500 && status_code <= 599", classify) + self.assertIn("RecoveryAttemptDisposition::RetryableFailure", classify) + self.assertTrue( + classify.rstrip().endswith( + "return recoveryOutcome(RecoveryAttemptDisposition::PermanentFailure);\n}" + ) + ) + + self.assertIn("outcome.retryDelayMillis > delay_millis", finish) + self.assertIn("outcome.retryDelayMillis", finish) + self.assertIn( + "outcome.disposition == RecoveryAttemptDisposition::PermanentFailure", + finish, + ) + self.assertIn("recoveryState = RecoveryState::Exhausted", finish) + + def test_valid_current_font_does_not_arm_download(self): + client = source(CLIENT) + load = client[client.index("uint8_t* IndicatorFontClient::load") :] + self.assertIn("if (!loaded.currentAsset)", load) + self.assertNotIn( + "armRecovery(mesh::indicator_font::RecoveryNeed::None)", load + ) + self.assertIn("loaded.currentAsset = loaded.size ==", client) + self.assertIn("memcmp(digest, EXPECTED_SHA256", client) + + def test_length_and_digest_fail_before_commit(self): + client = source(CLIENT) + response = function_body( + client, "RecoveryAttemptOutcome openAssetResponse", "bool writeAll" + ) + transfer = function_body( + client, + "RecoveryAttemptOutcome downloadAndInstallAsset", + "void finishRecoveryFailure", + ) + + self.assertIn("mesh::indicator_font::kAssetSize - requested_offset", response) + self.assertIn("content_length != expected_content_length", response) + self.assertIn("Transfer-Encoding:", response) + self.assertIn("Content-Encoding:", response) + self.assertIn("HTTP_HEADER_LIMIT", response) + self.assertIn("DOWNLOAD_TOTAL_TIMEOUT_MS", transfer) + self.assertIn("DOWNLOAD_IDLE_TIMEOUT_MS", transfer) + self.assertLess(transfer.index("digest_ok"), transfer.index("MCFONT COMMIT")) + self.assertLess(transfer.index("if (!digest_ok)"), transfer.index("Serial2.begin")) + + def test_range_resume_keeps_one_buffer_and_sha_stream_and_is_strict(self): + client = source(CLIENT) + response = function_body( + client, "RecoveryAttemptOutcome openAssetResponse", "bool writeAll" + ) + transfer = function_body( + client, + "RecoveryAttemptOutcome downloadAndInstallAsset", + "void finishRecoveryFailure", + ) + content_range = function_body( + client, "bool parseContentRangeHeader", "bool parseStrongEtagHeader" + ) + strong_etag = function_body( + client, "bool parseStrongEtagHeader", "bool parseHttpStatusCode" + ) + + self.assertIn("DOWNLOAD_MAX_RESUME_RECONNECTS = 2", client) + self.assertIn("const bool is_resume = expected_etag != nullptr", response) + self.assertIn('"Range: bytes=%lu-%lu\\r\\nIf-Range: %s\\r\\n"', response) + self.assertIn("const int expected_status = is_resume ? 206 : 200", response) + self.assertIn("status_code != expected_status", response) + self.assertIn("content_length != expected_content_length", response) + self.assertIn("parseContentRangeHeader", response) + self.assertIn("parseStrongEtagHeader", response) + self.assertIn("content_range_first != requested_offset", response) + self.assertIn( + "content_range_last != mesh::indicator_font::kAssetSize - 1", + response, + ) + self.assertIn( + "content_range_total != mesh::indicator_font::kAssetSize", response + ) + self.assertIn("strcmp(response_etag, expected_etag) != 0", response) + self.assertIn("if (content_range_seen)", response) + self.assertIn("!etag_seen || !etag_valid", response) + self.assertIn("transfer_encoding_seen", response) + self.assertIn("!content_encoding_identity", response) + + # Content-Range and reflected If-Range inputs use strict decimal and + # quoted-tag grammars; signs, overflow, weak tags, control bytes, and + # embedded quotes cannot pass or inject another request field. + self.assertIn('strncasecmp(value, "bytes ", 6)', content_range) + self.assertIn("parsed_first > parsed_last", content_range) + self.assertIn("parsed_last >= parsed_total", content_range) + self.assertIn("parsed_total > MAX_FONT_BYTES", content_range) + self.assertIn("value[0] != '\"'", strong_etag) + self.assertIn("value[length - 1] != '\"'", strong_etag) + self.assertIn("byte < 0x21 || byte > 0x7e || byte == '\"'", strong_etag) + + # One fresh SNTP reply covers the bounded 180-second transfer. Every + # initial/Range TLS handshake revalidates that proof and the signed + # wall clock centrally in openAssetResponse(). + self.assertEqual(transfer.count("prepareTlsClock()"), 1) + proof_check = response.index("fontTlsClockProofValid(tls_now)") + tls_connect = response.index("client.connect(host, 443)") + self.assertLess(proof_check, tls_connect) + self.assertEqual( + transfer.count("mbedtls_sha256_starts_ret(&sha, 0)"), 1 + ) + self.assertEqual( + transfer.count("ps_malloc(mesh::indicator_font::kAssetSize)"), 1 + ) + initial = transfer.index("client, 0, nullptr, asset_etag") + resume = transfer.index("client, received, asset_etag, nullptr, 0") + self.assertLess(initial, resume) + self.assertIn( + "resume_reconnects >= DOWNLOAD_MAX_RESUME_RECONNECTS", transfer + ) + self.assertIn("discarding %lu unverified bytes", transfer) + self.assertIn("last_progress = millis()", transfer[resume:]) + + # `received` moves only after the complete block has entered the one + # SHA stream. A failed partial block is overwritten at that boundary. + update = transfer.index("mbedtls_sha256_update_ret") + advance = transfer.index("received += wanted", update) + resume_call = transfer.index("openAssetResponse(", advance) + self.assertLess(update, advance) + self.assertLess(advance, resume_call) + self.assertNotIn("received += filled", transfer) + self.assertEqual(transfer.count("uint32_t started = millis();"), 1) + self.assertNotIn("\n started = millis();", transfer[resume:]) + self.assertLess(transfer.index("digest_ok"), transfer.index("Serial2.begin")) + + def test_download_is_buffered_before_receiver_paced_staging(self): + client = source(CLIENT) + protocol = source(STAGE_V2_PROTOCOL) + transfer = function_body( + client, + "RecoveryAttemptOutcome downloadAndInstallAsset", + "void finishRecoveryFailure", + ) + self.assertIn("kStageV2ChunkBytes = 512", protocol) + self.assertIn("ps_malloc(mesh::indicator_font::kAssetSize)", transfer) + self.assertIn("if (wanted > 16 * 1024) wanted = 16 * 1024", transfer) + self.assertIn("client.read(asset + received + filled", transfer) + self.assertLess(transfer.index("client.stop()"), transfer.index("Serial2.begin")) + self.assertLess(transfer.index("if (!digest_ok)"), transfer.index("Serial2.begin")) + self.assertIn("free(asset)", transfer) + self.assertIn("writeAll(Serial2, asset + staged_bytes", transfer) + self.assertIn("stagedChunkAcknowledged(Serial2, next_staged)", transfer) + self.assertIn("stageV2ChunkSize(", transfer) + self.assertIn("advanceStageV2Offset(", transfer) + self.assertIn("delay(LEGACY_STAGE_PACE_MS)", transfer) + + def test_stage_v2_is_receiver_paced_and_legacy_fallback_is_explicit(self): + client = source(CLIENT) + rp = source(RP2040) + protocol = source(STAGE_V2_PROTOCOL) + negotiate = function_body( + client, "bool beginStagedUpload", "bool stagedChunkAcknowledged" + ) + acknowledge = function_body( + client, + "bool stagedChunkAcknowledged", + "RecoveryAttemptOutcome downloadAndInstallAsset", + ) + receiver = function_body( + rp, "void receiveFontStage2", "struct CommandReader" + ) + + self.assertIn("IndicatorFontStageV2Protocol.h", client) + self.assertIn("IndicatorFontStageV2Protocol.h", rp) + self.assertIn("kStageV2ChunkBytes = 512", protocol) + self.assertIn('kStageV2ReadyReply[] = "READY 2 512"', protocol) + self.assertIn( + 'kStageV2LegacyUnsupportedReply[] = "ERROR COMMAND"', protocol + ) + self.assertIn('"MCFONT STAGEV2 %lu %08lx %lu"', negotiate) + self.assertNotIn('"MCFONT STAGE2 ', negotiate) + self.assertNotIn('"MCFONT STAGE2 ', rp) + self.assertIn("classifyStageV2BeginReply(replied, reply)", negotiate) + self.assertIn("StageV2BeginAction::UseAcknowledged", negotiate) + self.assertIn("StageV2BeginAction::UseLegacy", negotiate) + # A missing/delayed protocol-2 reply fails closed. Only the old + # service's explicit unsupported-command response can reach STAGE. + timeout_failure = negotiate.index("action !=") + legacy_command = negotiate.index('"MCFONT STAGE %lu %08lx"') + self.assertLess(timeout_failure, legacy_command) + self.assertIn("return false;", negotiate[timeout_failure:legacy_command]) + self.assertIn("if (!replied)", negotiate[timeout_failure:legacy_command]) + + self.assertIn("parseStageV2Ack(reply, expected_offset)", acknowledge) + self.assertNotIn("strtoul", acknowledge) + + self.assertIn("RECEIVE_TOTAL_TIMEOUT_MS = 180000", rp) + self.assertIn("output.print(mesh::indicator_font::kStageV2ReadyReply)", receiver) + self.assertIn("stageV2ChunkSize(expectedSize, received)", receiver) + fragment_loop = receiver.index("while (chunkReceived < chunkSize)") + write = receiver.index("font.write(buffer, chunkSize)") + ack = receiver.index('output.printf("ACK %lu\\n"') + self.assertLess(fragment_loop, write) + self.assertIn("chunkReceived += actual", receiver[fragment_loop:write]) + self.assertLess(write, ack) + self.assertIn("advanceStageV2Offset(", receiver[write:ack]) + self.assertIn("received = nextReceived", receiver[write:ack]) + checksum = receiver.index("~crc != expectedCrc") + metadata = receiver.index("writeMetadata(STAGED_META_PATH") + staged = receiver.index('output.print("STAGED\\n")') + close = receiver.index("font.close()") + post_close_deadline = receiver.index( + "espStage2LastElapsedMs >= RECEIVE_TOTAL_TIMEOUT_MS", close + ) + failure_handling = receiver.index( + "if (failure != Stage2Result::Idle)", post_close_deadline + ) + self.assertLess(ack, checksum) + self.assertLess(close, post_close_deadline) + self.assertLess(post_close_deadline, failure_handling) + self.assertLess(checksum, metadata) + self.assertLess(metadata, staged) + self.assertIn( + '"MCFONT STAGEV2 %lu %lx %lu %n"', + rp, + ) + self.assertIn("reader.line[consumed] == 0", rp) + + def test_rp2040_retries_a_transient_sd_mount_for_a_bounded_window(self): + rp = source(RP2040) + mount = function_body(rp, "bool mountSdCard", "void serviceSdMount") + service = function_body(rp, "void serviceSdMount", "void sendInfo") + setup = function_body(rp, "void setup()", "void loop()") + loop = rp[rp.index("void loop()") :] + + self.assertIn("SD_MOUNT_RETRY_INTERVAL_MS = 2000", rp) + self.assertIn("SD_MOUNT_MAX_ATTEMPTS = 30", rp) + self.assertIn("sdMountAttempts >= SD_MOUNT_MAX_ATTEMPTS", mount) + self.assertIn("if (sdMountAttempts != 0) SD.end(false)", mount) + self.assertLess(mount.index("++sdMountAttempts"), mount.index("SD.begin(")) + self.assertIn("sdMountNextAttempt = millis() +", mount) + self.assertIn("sdMountAttempts >= SD_MOUNT_MAX_ATTEMPTS", service) + self.assertIn("now - sdMountNextAttempt", service) + self.assertIn("serviceSdMount(millis())", setup) + self.assertIn("serviceSdMount(millis())", loop) + self.assertNotIn("SD.begin(", setup) + + def test_legacy_stage_and_usb_put_have_idle_and_total_deadlines(self): + rp = source(RP2040) + receive = function_body(rp, "void receiveFont(Stream&", "// STAGEV2") + + self.assertIn("const uint32_t started = millis()", receive) + self.assertIn("now - started >= RECEIVE_TOTAL_TIMEOUT_MS", receive) + self.assertIn("now - lastProgress >= RECEIVE_IDLE_TIMEOUT_MS", receive) + self.assertIn("timedOut = true", receive) + self.assertGreater( + receive.index("millis() - started >= RECEIVE_TOTAL_TIMEOUT_MS"), + receive.index("font.close()"), + ) + self.assertIn('timedOut ? "ERROR TIMEOUT\\n"', receive) + + def test_interrupted_stage_cannot_replace_live_font(self): + rp = source(RP2040) + recovery = function_body(rp, "void recoverFontTransaction", "bool refreshFontInfo") + mount = function_body(rp, "bool mountSdCard", "void serviceSdMount") + stage_install = function_body(rp, "bool installStagedPair", "enum class ReceiveMode") + receive = function_body(rp, "void receiveFont", "struct CommandReader") + + self.assertNotIn("STAGED_FONT_PATH", recovery) + self.assertNotIn("STAGED_META_PATH", recovery) + self.assertIn("cleanStagedFiles();", mount) + self.assertLess(stage_install.index("validatePairCrc"), + stage_install.index("installTemporaryPair")) + self.assertIn("removeIfPresent(destinationFont)", receive) + self.assertIn("ReceiveMode::StageOnly", receive) + self.assertIn('output.print("STAGED\\n")', receive) + + def test_interrupted_pair_renames_are_crc_checked_and_recoverable(self): + rp = source(RP2040) + validator = function_body( + rp, "bool validateStoredPairCrc", "void removeIfPresent" + ) + promotion = function_body(rp, "bool promotePair", "void recoverFontTransaction") + recovery = function_body( + rp, "void recoverFontTransaction", "bool refreshFontInfo" + ) + + self.assertIn("validatePairCrc(fontPath, metadataPath, size, crc)", validator) + for font_path, metadata_path in ( + ("FONT_PATH", "TEMP_META_PATH"), + ("FONT_PATH", "BACKUP_META_PATH"), + ("BACKUP_FONT_PATH", "FONT_META_PATH"), + ("TEMP_FONT_PATH", "TEMP_META_PATH"), + ("BACKUP_FONT_PATH", "BACKUP_META_PATH"), + ): + self.assertIn( + f"validateStoredPairCrc({font_path}, {metadata_path}", recovery + ) + + # A reset while moving the old pair to backup leaves its metadata at + # the live name. Complete that backup before trying the new temp pair, + # so a failed temp promotion still has a rollback candidate. + split_backup = recovery.index( + "validateStoredPairCrc(BACKUP_FONT_PATH, FONT_META_PATH" + ) + temp_promotion = recovery.index( + "validateStoredPairCrc(TEMP_FONT_PATH, TEMP_META_PATH" + ) + self.assertLess(split_backup, temp_promotion) + self.assertIn("SD.rename(FONT_META_PATH, BACKUP_META_PATH)", recovery) + + # Once the source font has moved, a metadata-rename failure must leave + # the split pair intact. Recovery also stops after trying any CRC-valid + # candidate so later fallbacks/cleanup cannot erase that retry state. + self.assertEqual(promotion.count("removeIfPresent(FONT_PATH)"), 1) + self.assertNotIn( + "if (SD.rename(sourceMetadata, FONT_META_PATH)) return true;\n" + " removeIfPresent(FONT_PATH)", + promotion, + ) + self.assertIn( + "if (validateStoredPairCrc(TEMP_FONT_PATH, TEMP_META_PATH, size, crc))", + recovery, + ) + self.assertIn( + "if (validateStoredPairCrc(BACKUP_FONT_PATH, BACKUP_META_PATH, size, crc))", + recovery, + ) + temp_start = recovery.index( + "if (validateStoredPairCrc(TEMP_FONT_PATH, TEMP_META_PATH" + ) + backup_start = recovery.index( + "if (validateStoredPairCrc(BACKUP_FONT_PATH, BACKUP_META_PATH" + ) + cleanup_start = recovery.index("removeIfPresent(FONT_PATH)", backup_start) + for block in ( + recovery[temp_start:backup_start], + recovery[backup_start:cleanup_start], + ): + self.assertIn("promotePair", block) + self.assertIn("return;", block) + + # Keep the ordinary healthy-boot INFO path fast; full-file CRC reads + # are reserved for abnormal transaction candidates. + healthy = recovery.index("validatePair(FONT_PATH, FONT_META_PATH") + first_transaction_crc = recovery.index("validateStoredPairCrc(") + self.assertLess(healthy, first_transaction_crc) + + def test_internal_uart_commit_is_separate_from_usb_put(self): + rp = source(RP2040) + self.assertIn( + "CommandReader usbCommands = {Serial, Serial, true, false, false}", rp + ) + self.assertIn( + "CommandReader espCommands = {Serial1, Serial1, false, true, true}", rp + ) + self.assertIn("if (!reader.allowStagedUpload)", rp) + self.assertIn("if (!reader.allowUpload)", rp) + + def test_retry_budget_and_backoff_are_bounded(self): + policy = source(POLICY) + client = source(CLIENT) + self.assertIn("kMaximumAttemptsPerBoot = 4", policy) + self.assertIn("30000UL", policy) + self.assertIn("120000UL", policy) + self.assertIn("600000UL", policy) + self.assertIn( + "recoveryAttempts >= mesh::indicator_font::kMaximumAttemptsPerBoot", + client, + ) + self.assertIn("RecoveryState::Exhausted", client) + self.assertIn("WiFi.status() == WL_CONNECTED", client) + + def test_initial_unavailable_service_gets_bounded_post_wifi_reprobe(self): + policy = source(POLICY) + client = source(CLIENT) + load = function_body( + client, + "uint8_t* IndicatorFontClient::load", + "uint8_t* IndicatorFontClient::serviceRecovery", + ) + probe = function_body(client, "void serviceProbeTask", "void recoveryTask") + service = function_body( + client, + "uint8_t* IndicatorFontClient::serviceRecovery", + "void IndicatorFontClient::noteRuntimeFontInstalled", + ) + + self.assertIn("armServiceProbe();", load) + self.assertIn("RecoveryState::ProbeWaiting", service) + self.assertIn("WiFi.status() == WL_CONNECTED", service) + self.assertIn("++serviceProbeAttempts", service) + self.assertIn("xTaskCreatePinnedToCore(serviceProbeTask", service) + + self.assertIn("LoadedFont loaded = loadFromService();", probe) + self.assertIn("if (loaded.currentAsset)", probe) + self.assertIn("RecoveryState::Ready", probe) + self.assertIn("RecoveryNeed::Missing", probe) + self.assertIn("RecoveryNeed::Corrupt", probe) + self.assertIn("RecoveryNeed::VersionMismatch, true", probe) + self.assertIn("finishServiceProbeFailure();", probe) + + self.assertIn("kMaximumServiceProbeAttemptsPerBoot = 4", policy) + self.assertIn("serviceProbeRetryDelayAfter", policy) + self.assertIn("2000UL", policy) + self.assertIn("5000UL", policy) + self.assertIn("15000UL", policy) + self.assertIn( + ">= mesh::indicator_font::kMaximumServiceProbeAttemptsPerBoot", + client, + ) + + def test_acknowledged_commit_uses_bounded_local_reprobe_not_redownload(self): + policy = source(POLICY) + client = source(CLIENT) + transfer = function_body( + client, + "RecoveryAttemptOutcome downloadAndInstallAsset", + "void finishRecoveryFailure", + ) + arm = function_body( + client, "void armPostCommitProbe", "enum class HttpLineResult" + ) + finish = function_body( + client, + "void finishPostCommitProbeFailure", + "void serviceProbeTask", + ) + probe = function_body( + client, "void postCommitProbeTask", "void recoveryTask" + ) + recovery = function_body( + client, "void recoveryTask", "#endif // INDICATOR_WIFI_FONT_RECOVERY" + ) + service = function_body( + client, + "uint8_t* IndicatorFontClient::serviceRecovery", + "void IndicatorFontClient::noteRuntimeFontInstalled", + ) + + # An acknowledged COMMIT is distinct from a network/staging failure. + # A transient post-commit INFO failure, or an ambiguous/missing COMMIT + # reply, enters the local-only state. Only an explicit ERROR is allowed + # to resume network attempts. + self.assertIn("RecoveryAttemptDisposition::CommittedNeedsProbe", client) + commit = transfer.index("const bool commitReplied = commandReply(") + committed = transfer.index("const bool committed =", commit) + rejected = transfer.index("const bool commitExplicitlyRejected", committed) + verified = transfer.index("bool postCommitVerified = false", commit) + probe_outcome = transfer.index( + "RecoveryAttemptDisposition::CommittedNeedsProbe", verified + ) + self.assertLess(commit, committed) + self.assertLess(committed, rejected) + self.assertLess(rejected, verified) + self.assertLess(verified, probe_outcome) + self.assertIn("if (!committed)", transfer[commit:probe_outcome]) + unresolved = transfer[ + transfer.index("if (!committed)", commit) : + transfer.index("return recoveryOutcome(postCommitVerified", commit) + ] + self.assertIn("commitExplicitlyRejected", unresolved) + self.assertIn("RecoveryAttemptDisposition::RetryableFailure", unresolved) + self.assertIn("RecoveryAttemptDisposition::CommittedNeedsProbe", unresolved) + + self.assertIn("armPostCommitProbe(activateCommittedLive)", recovery) + network_failure = function_body( + client, + "void finishRecoveryFailure(const RecoveryAttemptOutcome& outcome)", + "void finishRecoveryFailure()", + ) + self.assertIn( + "RecoveryAttemptDisposition::CommittedNeedsProbe", + network_failure, + ) + self.assertIn("RecoveryState::Exhausted", network_failure) + local_load = recovery.index("LoadedFont loaded = loadFromService()") + local_failure = recovery.index("loaded.data == nullptr", local_load) + local_success = recovery.index("data = loaded.data", local_failure) + self.assertIn("armPostCommitProbe(true)", recovery[local_failure:local_success]) + self.assertNotIn( + "finishRecoveryFailure", recovery[local_failure:local_success] + ) + + # The local path preserves the consumed download budget, needs no + # Wi-Fi/NTP/TLS, and can only become Ready/Complete or retry/exhaust. + self.assertNotIn("recoveryAttempts =", arm) + self.assertIn("RecoveryNeed::None", arm) + self.assertIn("RecoveryState::PostCommitProbeWaiting", arm) + info_only = probe[ + probe.index("if (!activateLive)") : + probe.index("LoadedFont loaded = loadFromService();") + ] + self.assertIn("probeServiceInfo(size, crc)", info_only) + self.assertIn("mesh::indicator_font::kAssetSize", info_only) + self.assertIn("mesh::indicator_font::kAssetCrc32", info_only) + self.assertIn("RecoveryState::Complete", info_only) + self.assertNotIn("ps_malloc", info_only) + self.assertNotIn("loadFromService", info_only) + self.assertIn("LoadedFont loaded = loadFromService();", probe) + self.assertIn("loaded.data != nullptr && loaded.currentAsset", probe) + self.assertIn("RecoveryState::Ready", probe) + self.assertIn("RecoveryState::Complete", probe) + self.assertIn("finishPostCommitProbeFailure();", probe) + for forbidden in ( + "downloadAndInstallAsset", + "armRecovery", + "finishRecoveryFailure", + "prepareTlsClock", + "WiFi.status", + ): + self.assertNotIn(forbidden, probe) + + self.assertIn( + "kMaximumPostCommitProbeAttemptsPerBoot = 4", policy + ) + post_commit_policy = policy[ + policy.index("postCommitProbeRetryDelayAfter") : + policy.index("constexpr bool deadlineReached") + ] + self.assertIn("2000UL", post_commit_policy) + self.assertIn("5000UL", post_commit_policy) + self.assertIn("15000UL", post_commit_policy) + self.assertIn("RecoveryState::Exhausted", finish) + self.assertIn("RecoveryState::PostCommitProbeWaiting", finish) + self.assertNotIn("RecoveryState::Waiting", finish) + + post_commit_branch = service[ + service.index("RecoveryState::PostCommitProbeWaiting") : + service.index("RecoveryState::ProbeWaiting") + ] + self.assertNotIn("wifiConnected", post_commit_branch) + self.assertIn("++postCommitProbeAttempts", post_commit_branch) + self.assertIn("launchPostCommitProbe = true", post_commit_branch) + self.assertIn("xTaskCreatePinnedToCore(", service) + self.assertIn("postCommitProbeTask", service) + self.assertIn("finishPostCommitProbeFailure();", service) + + maximum = int( + re.search( + r"kMaximumPostCommitProbeAttemptsPerBoot = (\d+)", policy + ).group(1) + ) + downloads_started = 1 + state = "PostCommitProbeWaiting" + for attempt in range(1, maximum + 1): + state = ( + "Exhausted" + if attempt >= maximum + else "PostCommitProbeWaiting" + ) + self.assertEqual(downloads_started, 1) + self.assertEqual(state, "Exhausted") + + def test_rejected_exact_recovered_font_cannot_redownload(self): + client = source(CLIENT) + display = source(DISPLAY) + rejected = function_body( + client, + "void IndicatorFontClient::noteRecoveredFontInvalid", + "#endif", + ) + service = function_body( + display, + "void serviceFontRecovery", + "void turnOn", + ) + + self.assertIn("noteRecoveredFontInvalid()", service) + self.assertIn("RecoveryNeed::None", rejected) + self.assertIn("RecoveryState::Exhausted", rejected) + self.assertNotIn("armRecovery", rejected) + self.assertNotIn("finishRecoveryFailure", rejected) + self.assertNotIn("finishPostCommitProbeFailure", rejected) + self.assertNotIn("RecoveryState::Waiting", rejected) + self.assertNotIn("RecoveryState::PostCommitProbeWaiting", rejected) + self.assertNotIn("recoveryAttempts =", rejected) + self.assertNotIn("downloadAndInstallAsset", rejected) + + def test_missing_corrupt_and_version_mismatch_are_distinct(self): + policy = source(POLICY) + client = source(CLIENT) + for state in ("Missing", "Corrupt", "VersionMismatch"): + self.assertIn(state, policy) + self.assertIn(f"RecoveryNeed::{state}", client) + + def test_live_activation_only_replaces_fallback_font(self): + policy = source(POLICY) + client = source(CLIENT) + display = source(DISPLAY) + self.assertIn( + "need == RecoveryNeed::Missing || need == RecoveryNeed::Corrupt", + policy, + ) + self.assertIn("!hadRuntimeFont", client) + self.assertIn("IndicatorFontClient::serviceRecovery", display) + self.assertIn("installRuntimeFont(fontData, fontSize)", display) + self.assertIn("noteRuntimeFontInstalled", display) + self.assertIn("noteRuntimeFontInvalid", display) + + def test_tls_cost_and_global_state_are_limited_to_wifi_indicator_profile(self): + wifi = profile_section("env:SenseCapIndicator-LoRa_comp_radio_usb_wifi") + usb = profile_section("env:SenseCapIndicator-LoRa_comp_radio_usb") + self.assertIn("INDICATOR_WIFI_FONT_RECOVERY=1", wifi) + self.assertNotIn("board_build.embed_files", wifi) + self.assertNotIn("INDICATOR_WIFI_FONT_RECOVERY", usb) + self.assertNotIn("board_build.embed_files", usb) + + def test_core2_timeout_and_worker_stack_fit_internal_tls_buffers(self): + client = source(CLIENT) + response = function_body( + client, "RecoveryAttemptOutcome openAssetResponse", "bool writeAll" + ) + service = function_body( + client, + "uint8_t* IndicatorFontClient::serviceRecovery", + "void IndicatorFontClient::noteRuntimeFontInstalled", + ) + + self.assertIn("ESP_ARDUINO_VERSION_MAJOR >= 3", response) + self.assertIn("HTTP_LINE_TIMEOUT_MS / 1000UL", response) + self.assertIn("RECOVERY_TASK_STACK_BYTES = 8192UL", client) + self.assertIn("RECOVERY_TASK_STACK_BYTES", service) + self.assertNotIn('"indicator-font", 24576', service) + + +if __name__ == "__main__": + unittest.main() diff --git a/test/test_indicator_font_stage_v2_protocol/test_indicator_font_stage_v2_protocol.cpp b/test/test_indicator_font_stage_v2_protocol/test_indicator_font_stage_v2_protocol.cpp new file mode 100644 index 00000000..a8c8afde --- /dev/null +++ b/test/test_indicator_font_stage_v2_protocol/test_indicator_font_stage_v2_protocol.cpp @@ -0,0 +1,132 @@ +#include + +#include + +using mesh::indicator_font::StageV2BeginAction; + +TEST(IndicatorFontStageV2Protocol, BeginReplyIsExactAndFallbackFailsClosed) { + using mesh::indicator_font::classifyStageV2BeginReply; + + EXPECT_EQ(classifyStageV2BeginReply(true, "READY 2 512"), + StageV2BeginAction::UseAcknowledged); + EXPECT_EQ(classifyStageV2BeginReply(true, "ERROR COMMAND"), + StageV2BeginAction::UseLegacy); + + // A missing response is ambiguous: the receiver may already be waiting for + // binary data after its READY was lost. It must never trigger legacy bytes. + EXPECT_EQ(classifyStageV2BeginReply(false, nullptr), + StageV2BeginAction::Fail); + EXPECT_EQ(classifyStageV2BeginReply(false, "ERROR COMMAND"), + StageV2BeginAction::Fail); + EXPECT_EQ(classifyStageV2BeginReply(true, nullptr), + StageV2BeginAction::Fail); + + for (const char* reply : { + "", "READY", "READY 2", "READY 2 0512", "READY 2 512 ", + "ERROR COMMAND ", "ERROR SD", "ERROR OPEN", "ERROR CHUNK", + "STAGED", + }) { + EXPECT_EQ(classifyStageV2BeginReply(true, reply), + StageV2BeginAction::Fail) + << reply; + } +} + +TEST(IndicatorFontStageV2Protocol, ChunkBoundariesIncludeShortFinalBlock) { + using mesh::indicator_font::stageV2ChunkSize; + + EXPECT_EQ(stageV2ChunkSize(0, 0), 0u); + EXPECT_EQ(stageV2ChunkSize(64, 0), 64u); + EXPECT_EQ(stageV2ChunkSize(64, 64), 0u); + EXPECT_EQ(stageV2ChunkSize(511, 0), 511u); + EXPECT_EQ(stageV2ChunkSize(512, 0), 512u); + EXPECT_EQ(stageV2ChunkSize(512, 512), 0u); + EXPECT_EQ(stageV2ChunkSize(513, 0), 512u); + EXPECT_EQ(stageV2ChunkSize(513, 512), 1u); + EXPECT_EQ(stageV2ChunkSize(513, 513), 0u); + EXPECT_EQ(stageV2ChunkSize(513, 514), 0u); +} + +TEST(IndicatorFontStageV2Protocol, RealAssetHasCanonicalAckSchedule) { + using mesh::indicator_font::advanceStageV2Offset; + using mesh::indicator_font::kStageV2ChunkBytes; + using mesh::indicator_font::stageV2ChunkSize; + + constexpr size_t assetSize = 1302608; + size_t offset = 0; + size_t fullBlocks = 0; + size_t shortBlocks = 0; + size_t blocks = 0; + while (offset < assetSize) { + const size_t chunk = stageV2ChunkSize(assetSize, offset); + ASSERT_GT(chunk, 0u); + size_t next = 0; + ASSERT_TRUE(advanceStageV2Offset(assetSize, offset, chunk, next)); + ASSERT_GT(next, offset); + ASSERT_LE(next, assetSize); + if (chunk == kStageV2ChunkBytes) { + ++fullBlocks; + } else { + ++shortBlocks; + EXPECT_EQ(chunk, 80u); + EXPECT_EQ(next, assetSize); + } + offset = next; + ASSERT_LT(++blocks, 3000u); + } + + EXPECT_EQ(offset, assetSize); + EXPECT_EQ(fullBlocks, 2544u); + EXPECT_EQ(shortBlocks, 1u); + EXPECT_EQ(blocks, 2545u); +} + +TEST(IndicatorFontStageV2Protocol, OffsetAdvancesOnlyForACompleteBlock) { + using mesh::indicator_font::advanceStageV2Offset; + + size_t next = 999; + EXPECT_FALSE(advanceStageV2Offset(1025, 0, 0, next)); + EXPECT_FALSE(advanceStageV2Offset(1025, 0, 511, next)); + EXPECT_FALSE(advanceStageV2Offset(1025, 0, 513, next)); + EXPECT_EQ(next, 999u); + + ASSERT_TRUE(advanceStageV2Offset(1025, 0, 512, next)); + EXPECT_EQ(next, 512u); + ASSERT_TRUE(advanceStageV2Offset(1025, next, 512, next)); + EXPECT_EQ(next, 1024u); + ASSERT_TRUE(advanceStageV2Offset(1025, next, 1, next)); + EXPECT_EQ(next, 1025u); + EXPECT_FALSE(advanceStageV2Offset(1025, next, 1, next)); +} + +TEST(IndicatorFontStageV2Protocol, AckMustBeCanonicalAndCumulative) { + using mesh::indicator_font::parseStageV2Ack; + + EXPECT_TRUE(parseStageV2Ack("ACK 512", 512)); + EXPECT_TRUE(parseStageV2Ack("ACK 1024", 1024)); + EXPECT_TRUE(parseStageV2Ack("ACK 1302608", 1302608)); + + EXPECT_FALSE(parseStageV2Ack(nullptr, 512)); + EXPECT_FALSE(parseStageV2Ack("", 512)); + EXPECT_FALSE(parseStageV2Ack("ACK", 512)); + EXPECT_FALSE(parseStageV2Ack("ACK ", 512)); + EXPECT_FALSE(parseStageV2Ack("ACK 0", 0)); + EXPECT_FALSE(parseStageV2Ack("ACK 0512", 512)); + EXPECT_FALSE(parseStageV2Ack("ACK +512", 512)); + EXPECT_FALSE(parseStageV2Ack("ACK -512", 512)); + EXPECT_FALSE(parseStageV2Ack("ACK 512", 512)); + EXPECT_FALSE(parseStageV2Ack("ACK 512 ", 512)); + EXPECT_FALSE(parseStageV2Ack("ACK 512x", 512)); + EXPECT_FALSE(parseStageV2Ack("ACK 184467440737095516160", 512)); + EXPECT_FALSE(parseStageV2Ack("READY 2 512", 512)); + EXPECT_FALSE(parseStageV2Ack("STAGED", 512)); + + // A prior or future block's otherwise valid ACK cannot release this block. + EXPECT_FALSE(parseStageV2Ack("ACK 512", 1024)); + EXPECT_FALSE(parseStageV2Ack("ACK 1024", 512)); +} + +int main(int argc, char** argv) { + ::testing::InitGoogleTest(&argc, argv); + return RUN_ALL_TESTS(); +} diff --git a/test/test_lazy_persistence/test_lazy_persistence.cpp b/test/test_lazy_persistence/test_lazy_persistence.cpp new file mode 100644 index 00000000..e1da25e1 --- /dev/null +++ b/test/test_lazy_persistence/test_lazy_persistence.cpp @@ -0,0 +1,82 @@ +#include + +#include + +TEST(LazyPersistence, ArmsOnlyTheFirstPendingDeadline) { + unsigned long pending = 0; + EXPECT_TRUE(mesh::armFirstLazyPersistence(pending, 5000, true)); + EXPECT_EQ(pending, 5000UL); + EXPECT_FALSE(mesh::armFirstLazyPersistence(pending, 9000, true)); + EXPECT_EQ(pending, 5000UL); + EXPECT_FALSE(mesh::armFirstLazyPersistence(pending, 9000, false)); +} + +TEST(LazyPersistence, SuccessfulSaveClearsPendingWrite) { + unsigned long pending = 5000; + mesh::completeLazyPersistenceSave(pending, true, 9000); + EXPECT_EQ(pending, 0UL); +} + +TEST(LazyPersistence, FailedSaveRearmsInsteadOfTightLooping) { + unsigned long pending = 5000; + mesh::completeLazyPersistenceSave(pending, false, 10000); + EXPECT_EQ(pending, 10000UL); +} + +TEST(LazyPersistence, WrappedDeadlineNeverLosesPendingWrite) { + unsigned long pending = 0; + EXPECT_TRUE(mesh::armFirstLazyPersistence(pending, 0, true)); + EXPECT_EQ(pending, 1UL); + + mesh::completeLazyPersistenceSave(pending, false, 0); + EXPECT_EQ(pending, 1UL); +} + +TEST(LazyPersistence, MutationDoesNotPostponeFirstPendingWrite) { + unsigned long pending = 0; + uint8_t failures = 0; + EXPECT_TRUE(mesh::scheduleLazyPersistenceMutation( + pending, failures, 5000)); + EXPECT_FALSE(mesh::scheduleLazyPersistenceMutation( + pending, failures, 9000)); + EXPECT_EQ(pending, 5000UL); + EXPECT_EQ(failures, 0); +} + +TEST(LazyPersistence, FailedSavesBackOffAndCap) { + uint8_t failures = 0; + EXPECT_EQ(mesh::recordLazyPersistenceSaveFailure( + failures, 5000, 300000), 5000u); + EXPECT_EQ(mesh::recordLazyPersistenceSaveFailure( + failures, 5000, 300000), 10000u); + EXPECT_EQ(mesh::recordLazyPersistenceSaveFailure( + failures, 5000, 300000), 20000u); + for (int i = 0; i < 20; i++) { + EXPECT_LE(mesh::recordLazyPersistenceSaveFailure( + failures, 5000, 300000), 300000u); + } + EXPECT_EQ(mesh::recordLazyPersistenceSaveFailure( + failures, 5000, 300000), 300000u); +} + +TEST(LazyPersistence, MutationCannotDefeatFailedSaveBackoff) { + unsigned long pending = 300000; + uint8_t failures = 6; + EXPECT_FALSE(mesh::scheduleLazyPersistenceMutation( + pending, failures, 5000)); + EXPECT_EQ(pending, 300000UL); + EXPECT_EQ(failures, 6); +} + +TEST(LazyPersistence, SuccessfulSaveResetsTimerAndBackoff) { + unsigned long pending = 300000; + uint8_t failures = 6; + mesh::resetLazyPersistenceAfterSuccess(pending, failures); + EXPECT_EQ(pending, 0UL); + EXPECT_EQ(failures, 0); +} + +int main(int argc, char** argv) { + ::testing::InitGoogleTest(&argc, argv); + return RUN_ALL_TESTS(); +} diff --git a/test/test_mqtt_connection_policy/test_mqtt_connection_policy.cpp b/test/test_mqtt_connection_policy/test_mqtt_connection_policy.cpp index ebf3baa7..1728d048 100644 --- a/test/test_mqtt_connection_policy/test_mqtt_connection_policy.cpp +++ b/test/test_mqtt_connection_policy/test_mqtt_connection_policy.cpp @@ -13,6 +13,74 @@ TEST(MQTTConnectionPolicy, ElapsedTimeHandlesNormalAndWrappedClocks) { EXPECT_EQ(150U, Policy::elapsedMs(50U, before_wrap)); } +TEST(MQTTConnectionPolicy, NtpRefreshHasExactDailyBoundaryAndHandlesRollover) { + EXPECT_EQ(86400000U, Policy::kNtpRefreshIntervalMs); + EXPECT_FALSE(Policy::ntpRefreshDue( + Policy::kNtpRefreshIntervalMs - 1U, 0U, 0U)); + EXPECT_TRUE(Policy::ntpRefreshDue( + Policy::kNtpRefreshIntervalMs, 0U, 0U)); + + const uint32_t last = std::numeric_limits::max() - 999U; + EXPECT_FALSE(Policy::ntpRefreshDue( + last + Policy::kNtpRefreshIntervalMs - 1U, last, 0U)); + EXPECT_TRUE(Policy::ntpRefreshDue( + last + Policy::kNtpRefreshIntervalMs, last, 0U)); +} + +TEST(MQTTConnectionPolicy, NtpShortRetryOverridesDailySchedule) { + const uint32_t retry_at = Policy::kNtpRefreshIntervalMs + 100U; + EXPECT_FALSE(Policy::ntpRefreshDue( + Policy::kNtpRefreshIntervalMs + 99U, 0U, retry_at)); + EXPECT_TRUE(Policy::ntpRefreshDue(retry_at, 0U, retry_at)); +} + +TEST(MQTTConnectionPolicy, NtpRetryDeadlineHandlesZeroSentinelAtRollover) { + EXPECT_EQ(5000U, Policy::kNtpRetryMs); + EXPECT_EQ(5000U, Policy::ntpRetryAt(0U)); + + const uint32_t now = std::numeric_limits::max() - 4999U; + const uint32_t retry_at = Policy::ntpRetryAt(now); + EXPECT_EQ(1U, retry_at); + EXPECT_FALSE(Policy::ntpRefreshDue( + std::numeric_limits::max(), 0U, retry_at)); + EXPECT_FALSE(Policy::ntpRefreshDue(0U, 0U, retry_at)); + EXPECT_TRUE(Policy::ntpRefreshDue(1U, 0U, retry_at)); +} + +TEST(MQTTConnectionPolicy, NtpReconnectRefreshDiscardsOldDeadlineSafely) { + EXPECT_EQ(1U, Policy::ntpReconnectRefreshAt(0U)); + EXPECT_FALSE(Policy::ntpRefreshDue( + 0U, Policy::kNtpRefreshIntervalMs - 1U, + Policy::ntpReconnectRefreshAt(0U))); + EXPECT_TRUE(Policy::ntpRefreshDue( + 1U, Policy::kNtpRefreshIntervalMs - 1U, + Policy::ntpReconnectRefreshAt(0U))); + + const uint32_t reconnected_at = 123456U; + EXPECT_EQ(reconnected_at, + Policy::ntpReconnectRefreshAt(reconnected_at)); + EXPECT_TRUE(Policy::ntpRefreshDue( + reconnected_at, reconnected_at - 1000U, + Policy::ntpReconnectRefreshAt(reconnected_at))); +} + +TEST(MQTTConnectionPolicy, NtpReconnectLatchPreservesEdgesUntilConnected) { + Policy::NtpReconnectLatch latch; + + EXPECT_FALSE(latch.consumeIfConnected(true)); + latch.noteGotIp(); + EXPECT_FALSE(latch.consumeIfConnected(false)); + EXPECT_TRUE(latch.consumeIfConnected(true)); + EXPECT_FALSE(latch.consumeIfConnected(true)); + + // An edge arriving after one exchange remains available to the next task + // tick, which is the short-flap/store-after-exchange case. + latch.noteGotIp(); + EXPECT_TRUE(latch.consumeIfConnected(true)); + latch.noteGotIp(); + EXPECT_TRUE(latch.consumeIfConnected(true)); +} + TEST(MQTTConnectionPolicy, CrossSlotReconnectGuardHasExactBoundary) { EXPECT_TRUE(Policy::reconnectGuardActive(14999U, 0U)); EXPECT_FALSE(Policy::reconnectGuardActive(15000U, 0U)); @@ -295,6 +363,30 @@ TEST(StaleToken, FailedMintAlwaysDefers) { using Policy::ClockSource; +TEST(FallbackClock, RejectsSignedFailureAndRtcRangeOverflow) { + const uint32_t floor = 1767225600UL; + uint32_t accepted = 1234U; + + EXPECT_FALSE(Policy::checkedRtcEpoch(-1, floor, accepted)); + EXPECT_EQ(0U, accepted); + EXPECT_FALSE(Policy::checkedRtcEpoch( + static_cast(UINT32_MAX) + 1, floor, accepted)); + EXPECT_EQ(0U, accepted); +} + +TEST(FallbackClock, AcceptsOnlyPlausibleValuesWithinRtcRange) { + const uint32_t floor = 1767225600UL; + uint32_t accepted = 0; + + EXPECT_FALSE(Policy::checkedRtcEpoch( + static_cast(floor) - 1, floor, accepted)); + EXPECT_EQ(0U, accepted); + EXPECT_TRUE(Policy::checkedRtcEpoch(floor, floor, accepted)); + EXPECT_EQ(floor, accepted); + EXPECT_TRUE(Policy::checkedRtcEpoch(UINT32_MAX, floor, accepted)); + EXPECT_EQ(UINT32_MAX, accepted); +} + TEST(FallbackClock, PrefersSystemThenRtcAndRejectsInvalidSources) { const uint32_t floor = 1767225600U; const uint32_t old_time = 1715770351U; diff --git a/test/test_nrf52_debug_output/test_nrf52_debug_output.cpp b/test/test_nrf52_debug_output/test_nrf52_debug_output.cpp new file mode 100644 index 00000000..8817c9dd --- /dev/null +++ b/test/test_nrf52_debug_output/test_nrf52_debug_output.cpp @@ -0,0 +1,202 @@ +#include + +#include + +#define ARDUINO 1 +#define NRF52_PLATFORM 1 +#define MESH_DEBUG 1 +#define BRIDGE_DEBUG 1 +#define POWERSAVING_DEBUG 1 +#include + +namespace { + +class CapacityStream : public Stream { + public: + void reset(int capacity) { + capacity_ = capacity; + available_calls_ = 0; + write_calls_ = 0; + oversized_write_ = false; + reenter_on_write_ = false; + reentrant_stream_ = nullptr; + reentrant_result_ = 0; + output_.clear(); + } + + int availableForWrite() override { + available_calls_++; + return capacity_; + } + + size_t write(const uint8_t* buffer, size_t size) override { + write_calls_++; + if (size > static_cast(capacity_)) { + oversized_write_ = true; + return 0; + } + if (reenter_on_write_) { + reenter_on_write_ = false; + if (reentrant_stream_ != nullptr) { + static const uint8_t nested[] = {'n', 'e', 's', 't', 'e', 'd'}; + reentrant_result_ = reentrant_stream_->write(nested, sizeof(nested)); + } else { + MESH_DEBUG_PRINTLN("nested record must be dropped"); + } + } + output_.append(reinterpret_cast(buffer), size); + capacity_ -= static_cast(size); + return size; + } + + int capacity_ = 0; + int available_calls_ = 0; + int write_calls_ = 0; + bool oversized_write_ = false; + bool reenter_on_write_ = false; + Stream* reentrant_stream_ = nullptr; + size_t reentrant_result_ = 0; + std::string output_; +}; + +CapacityStream logging_port; +mesh::WholeRecordNonBlockingStream<> bounded_logging_port(logging_port); +bool logging_enabled = true; + +} // namespace + +const char* getLogDateTime() { + return "12:34"; +} + +namespace mesh { + +bool isUsbLoggingEnabled() { + return logging_enabled; +} + +Stream& usbLoggingPort() { + return bounded_logging_port; +} + +} // namespace mesh + +class Nrf52DebugOutputTest : public testing::Test { + protected: + void SetUp() override { + logging_enabled = true; + logging_port.reset(256); + } +}; + +TEST_F(Nrf52DebugOutputTest, WritesACompleteFormattedRecordWhenItFits) { + MESH_DEBUG_PRINTLN("value=%d", 42); + + EXPECT_EQ(logging_port.output_, "DEBUG: value=42\n"); + EXPECT_EQ(logging_port.available_calls_, 1); + EXPECT_EQ(logging_port.write_calls_, 1); + EXPECT_FALSE(logging_port.oversized_write_); +} + +TEST_F(Nrf52DebugOutputTest, DropsTheWholeRecordWhenFifoCannotFitIt) { + logging_port.reset(10); + + MESH_DEBUG_PRINTLN("this record is longer than ten bytes"); + + EXPECT_TRUE(logging_port.output_.empty()); + EXPECT_EQ(logging_port.available_calls_, 1); + EXPECT_EQ(logging_port.write_calls_, 0); + EXPECT_FALSE(logging_port.oversized_write_); +} + +TEST_F(Nrf52DebugOutputTest, AnUndrainedFullFifoNeverCallsTheWriter) { + logging_port.reset(0); + + MESH_DEBUG_PRINTLN("must not wait"); + + EXPECT_EQ(logging_port.available_calls_, 1); + EXPECT_EQ(logging_port.write_calls_, 0); +} + +TEST_F(Nrf52DebugOutputTest, LongLinesAreBoundedAndVisiblyTruncated) { + const std::string payload(600, 'x'); + + MESH_DEBUG_PRINTLN("%s", payload.c_str()); + + ASSERT_EQ(logging_port.output_.size(), 255U); + EXPECT_EQ(logging_port.output_.substr(0, 7), "DEBUG: "); + EXPECT_EQ(logging_port.output_.substr(logging_port.output_.size() - 4), + "...\n"); + EXPECT_EQ(logging_port.write_calls_, 1); + EXPECT_FALSE(logging_port.oversized_write_); +} + +TEST_F(Nrf52DebugOutputTest, DisabledLoggingDoesNotTouchThePort) { + logging_enabled = false; + + MESH_DEBUG_PRINTLN("disabled"); + + EXPECT_EQ(logging_port.available_calls_, 0); + EXPECT_EQ(logging_port.write_calls_, 0); +} + +TEST_F(Nrf52DebugOutputTest, ReentrantDebugOutputIsDroppedWithoutWaiting) { + logging_port.reenter_on_write_ = true; + + MESH_DEBUG_PRINTLN("outer record"); + + EXPECT_EQ(logging_port.output_, "DEBUG: outer record\n"); + EXPECT_EQ(logging_port.available_calls_, 1); + EXPECT_EQ(logging_port.write_calls_, 1); +} + +TEST_F(Nrf52DebugOutputTest, BridgeAndPowerDebugUseTheSameBoundedPath) { + BRIDGE_DEBUG_PRINTLN("started\n"); + POWERSAVING_DEBUG_PRINTLN("sleeping"); + + EXPECT_EQ(logging_port.output_, + "12:34 BRIDGE: started\nPOWERSAVING: sleeping\n"); + EXPECT_EQ(logging_port.write_calls_, 2); + EXPECT_FALSE(logging_port.oversized_write_); +} + +TEST_F(Nrf52DebugOutputTest, DedicatedFacadeRejectsAStalePrintfLength) { + mesh::WholeRecordNonBlockingStream<> nonblocking_port(logging_port); + const std::string oversized(600, 'x'); + + const size_t written = nonblocking_port.write( + reinterpret_cast(oversized.data()), oversized.size()); + + EXPECT_EQ(written, 0U); + EXPECT_EQ(logging_port.available_calls_, 0); + EXPECT_EQ(logging_port.write_calls_, 0); +} + +TEST_F(Nrf52DebugOutputTest, DedicatedFacadeChecksThenWritesOnce) { + mesh::WholeRecordNonBlockingStream<> nonblocking_port(logging_port); + const uint8_t record[] = {'o', 'k', '\n'}; + logging_port.reset(sizeof(record)); + + EXPECT_EQ(nonblocking_port.write(record, sizeof(record)), sizeof(record)); + EXPECT_EQ(logging_port.output_, "ok\n"); + EXPECT_EQ(logging_port.available_calls_, 1); + EXPECT_EQ(logging_port.write_calls_, 1); +} + +TEST_F(Nrf52DebugOutputTest, DedicatedFacadeDropsAReentrantWriter) { + mesh::WholeRecordNonBlockingStream<> nonblocking_port(logging_port); + const uint8_t outer[] = {'o', 'u', 't', 'e', 'r'}; + logging_port.reenter_on_write_ = true; + logging_port.reentrant_stream_ = &nonblocking_port; + + EXPECT_EQ(nonblocking_port.write(outer, sizeof(outer)), sizeof(outer)); + EXPECT_EQ(logging_port.output_, "outer"); + EXPECT_EQ(logging_port.reentrant_result_, 0U); + EXPECT_EQ(logging_port.available_calls_, 1); + EXPECT_EQ(logging_port.write_calls_, 1); +} + +int main(int argc, char** argv) { + ::testing::InitGoogleTest(&argc, argv); + return RUN_ALL_TESTS(); +} diff --git a/test/test_nrf52_usb_logging_contract.py b/test/test_nrf52_usb_logging_contract.py new file mode 100644 index 00000000..f9ac4f05 --- /dev/null +++ b/test/test_nrf52_usb_logging_contract.py @@ -0,0 +1,49 @@ +#!/usr/bin/env python3 +"""Static integration guards for nRF52 nonblocking USB diagnostics.""" + +from pathlib import Path +import unittest + + +ROOT = Path(__file__).resolve().parents[1] + + +class Nrf52UsbLoggingContractTest(unittest.TestCase): + def test_all_nrf52_logging_ports_return_the_nonblocking_facade(self): + source = (ROOT / "src/helpers/UsbLogging.cpp").read_text() + + self.assertIn( + "nonblocking_dedicated_usb_logging_port(dedicated_usb_logging_port)", + source, + ) + self.assertIn("nonblocking_primary_usb_logging_port(Serial)", source) + self.assertIn("return nonblocking_dedicated_usb_logging_port;", source) + self.assertIn("return nonblocking_primary_usb_logging_port;", source) + # ESP32 and other platforms retain their original Serial path. + self.assertIn("#else\n return Serial;\n #endif", source) + + def test_facade_uses_a_zero_wait_gate_around_capacity_and_write(self): + source = (ROOT / "src/helpers/NonBlockingWriteStream.h").read_text() + + enter = source.index("_writer_busy.test_and_set") + capacity = source.index("_delegate.availableForWrite()", enter) + write = source.index("_delegate.write(data, size)") + release = source.index("_writer_busy.clear") + self.assertLess(enter, capacity) + self.assertLess(capacity, write) + self.assertLess(write, release) + self.assertIn("size > MAX_WRITE_SIZE", source) + + def test_meshcore_debug_macros_use_the_bounded_formatter_only_on_nrf52(self): + source = (ROOT / "src/MeshCore.h").read_text() + + self.assertIn("#if defined(NRF52_PLATFORM)", source) + self.assertIn('mesh::nrf52DebugPrintf("DEBUG: " F', source) + self.assertIn( + 'mesh::usbLoggingPort().printf("DEBUG: " F', + source, + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/test/test_nrf52_variant_contracts.py b/test/test_nrf52_variant_contracts.py new file mode 100644 index 00000000..4702d429 --- /dev/null +++ b/test/test_nrf52_variant_contracts.py @@ -0,0 +1,440 @@ +#!/usr/bin/env python3 +"""Static safety contracts for nRF52840 variants with incompatible layouts.""" + +from pathlib import Path +import re +import unittest + + +ROOT = Path(__file__).resolve().parents[1] + + +def ini_section(source: str, name: str) -> str: + """Return one PlatformIO section without resolving its inheritance.""" + match = re.search( + rf"^\[{re.escape(name)}\]\s*$\n(?P.*?)(?=^\[|\Z)", + source, + re.MULTILINE | re.DOTALL, + ) + if match is None: + raise AssertionError(f"missing PlatformIO section [{name}]") + return match.group("body") + + +def hex_define(source: str, name: str) -> int: + """Read a simple hexadecimal preprocessor address definition.""" + match = re.search( + rf"^\s*#define\s+{re.escape(name)}\s+(0x[0-9A-Fa-f]+)\b", + source, + re.MULTILINE, + ) + if match is None: + raise AssertionError(f"missing hexadecimal definition for {name}") + return int(match.group(1), 16) + + +class Nrf52VariantContractsTest(unittest.TestCase): + def test_rak3401_wisblock_i2c_aliases_are_complete(self) -> None: + variant = (ROOT / "variants/rak3401/variant.h").read_text() + + self.assertRegex(variant, r"#define\s+WB_I2C1_SDA\s+\(13\)") + self.assertRegex(variant, r"#define\s+WB_I2C1_SCL\s+\(14\)") + self.assertRegex( + variant, r"#define\s+PIN_WIRE_SDA\s+\(WB_I2C1_SDA\)" + ) + self.assertRegex( + variant, r"#define\s+PIN_WIRE_SCL\s+\(WB_I2C1_SCL\)" + ) + self.assertRegex(variant, r"#define\s+PIN_BOARD_SDA\s+PIN_WIRE_SDA") + self.assertRegex(variant, r"#define\s+PIN_BOARD_SCL\s+PIN_WIRE_SCL") + + def test_rak3401_gps_uart_is_cross_connected(self) -> None: + variant = (ROOT / "variants/rak3401/variant.h").read_text() + + self.assertRegex(variant, r"#define\s+PIN_SERIAL1_RX\s+\(15\)") + self.assertRegex(variant, r"#define\s+PIN_SERIAL1_TX\s+\(16\)") + self.assertRegex(variant, r"#define\s+PIN_GPS_RX\s+PIN_SERIAL1_TX") + self.assertRegex(variant, r"#define\s+PIN_GPS_TX\s+PIN_SERIAL1_RX") + + def test_rak3401_bsec_opt_in_requires_hard_float_recipe(self) -> None: + recipe = (ROOT / "variants/rak3401/platformio.ini").read_text() + shared = (ROOT / "platformio.ini").read_text() + sensor_base = re.search( + r"^\[sensor_base\]\s*$\n(?P.*?)(?=^\[|\Z)", + shared, + re.MULTILINE | re.DOTALL, + ) + self.assertIsNotNone(sensor_base) + effective_recipe = sensor_base.group("body") + recipe + + if re.search( + r"^\s*-D\s*ENV_INCLUDE_BME680_BSEC(?:=1)?\s*$", + effective_recipe, + re.MULTILINE, + ): + self.assertIn("fix_bsec_lib.py", recipe) + self.assertIn("BSEC Software Library", recipe) + + def test_rak3401_reduced_profile_names_its_narrow_sensor_trim(self) -> None: + recipe = (ROOT / "variants/rak3401/platformio.ini").read_text() + shared = (ROOT / "platformio.ini").read_text() + build_script = (ROOT / "build.sh").read_text() + + expected_ina = { + "ENV_INCLUDE_INA219", + "ENV_INCLUDE_INA226", + "ENV_INCLUDE_INA260", + "ENV_INCLUDE_INA3221", + } + voltage_monitor_section = ini_section(shared, "i2c_voltage_monitor_base") + enabled_monitors = set( + re.findall( + r"-D\s+(ENV_INCLUDE_[A-Z0-9_]+)=1\b", + voltage_monitor_section, + ) + ) + self.assertEqual(enabled_monitors, expected_ina) + + reduced_sensor_section = ini_section( + shared, "nrf52_reduced_sensors_keep_ina_gps" + ) + self.assertIn( + "${nrf52_external_environmental_sensor_trim.build_flags}", + reduced_sensor_section, + ) + self.assertIn( + "${i2c_voltage_monitor_base.build_flags}", reduced_sensor_section + ) + self.assertNotRegex(reduced_sensor_section, r"-U\s*ENV_INCLUDE_GPS\b") + + reduced_target = ini_section( + recipe, "env:RAK_3401_repeater_lora_ota_no_external_sensors" + ) + self.assertIn( + "${nrf52_reduced_sensors_keep_ina_gps.build_flags}", reduced_target + ) + + monitor_flags = re.search( + r'^\s*local voltage_monitor_flags="(?P[^"]+)"', + build_script, + re.MULTILINE, + ) + self.assertIsNotNone(monitor_flags) + self.assertEqual(set(monitor_flags.group("flags").split()), expected_ina) + + self.assertIn( + "selected optional environmental/ranging drivers omitted", + build_script, + ) + self.assertIn("board display/RTC/GPS retained", build_script) + self.assertIn("GPS conflicts with RS-232 on Serial1", build_script) + self.assertNotIn("sensors.external omitted except", build_script) + self.assertIn("environmental telemetry sensors", recipe) + self.assertIn("both RAK12500 I2C and RAK12501 UART GPS paths", recipe) + + def test_rak3401_reduced_profile_retains_non_ina_i2c_peripherals(self) -> None: + recipe = (ROOT / "variants/rak3401/platformio.ini").read_text() + target = (ROOT / "variants/rak3401/target.cpp").read_text() + sensors = ( + ROOT / "src/helpers/sensors/EnvironmentSensorManager.cpp" + ).read_text() + rtc = (ROOT / "src/helpers/AutoDiscoverRTCClock.cpp").read_text() + display = (ROOT / "src/helpers/ui/SSD1306Display.h").read_text() + + reduced_target = ini_section( + recipe, "env:RAK_3401_repeater_lora_ota_no_external_sensors" + ) + self.assertRegex(reduced_target, r"-D\s+DISPLAY_CLASS=SSD1306Display\b") + self.assertIn("SparkFun u-blox GNSS Arduino Library", reduced_target) + self.assertIn("AutoDiscoverRTCClock rtc_clock", target) + self.assertIn("rtc_clock.begin(Wire)", target) + self.assertIn("MicroNMEALocationProvider(Serial1, &rtc_clock)", target) + self.assertIn( + "ublox_GNSS.begin(Wire, gps_i2c_address)", sensors + ) + self.assertIn( + "shouldSkipSensorAtClaimedGpsAddress(", sensors + ) + self.assertIn("i2cGPSFlag, TELEM_WIRE == &Wire", sensors) + self.assertIn( + "probeIna3221Identity(&Wire, gps_i2c_address)", sensors + ) + self.assertIn("static_cast(TELEM_RAK12500_ADDRESS)", sensors) + self.assertIn("I2cRegisterProbeStatus::Inconclusive", sensors) + self.assertIn("serialHasValidGpsSentence(Serial1", sensors) + self.assertNotIn("else if (Serial1.available())", sensors) + + self.assertEqual(hex_define(display, "DISPLAY_ADDRESS"), 0x3C) + self.assertEqual( + { + "DS3231": hex_define(rtc, "DS3231_ADDRESS"), + "RV3028": hex_define(rtc, "RV3028_ADDRESS"), + "PCF8563": hex_define(rtc, "PCF8563_ADDRESS"), + "RX8130CE": hex_define(rtc, "RX8130CE_ADDRESS"), + }, + { + "DS3231": 0x68, + "RV3028": 0x52, + "PCF8563": 0x51, + "RX8130CE": 0x32, + }, + ) + + def test_i2c_recovery_uses_board_remapped_primary_pins(self) -> None: + recipe = (ROOT / "variants/promicro/platformio.ini").read_text() + board = (ROOT / "variants/promicro/PromicroBoard.cpp").read_text() + sensors = ( + ROOT / "src/helpers/sensors/EnvironmentSensorManager.cpp" + ).read_text() + + self.assertRegex(recipe, r"-D\s+PIN_BOARD_SDA=8\b") + self.assertRegex(recipe, r"-D\s+PIN_BOARD_SCL=7\b") + self.assertIn("Wire.setPins(PIN_BOARD_SDA, PIN_BOARD_SCL)", board) + board_pin_branch = sensors.index( + "defined(PIN_BOARD_SDA) && defined(PIN_BOARD_SCL)" + ) + variant_pin_branch = sensors.index( + "defined(PIN_WIRE_SDA) && defined(PIN_WIRE_SCL)" + ) + self.assertLess(board_pin_branch, variant_pin_branch) + self.assertIn("Wire.setPins(static_cast(sda)", sensors) + + def test_rak3401_rak12500_ina3221_address_collision_is_explicit(self) -> None: + recipe = (ROOT / "variants/rak3401/platformio.ini").read_text() + sensors = ( + ROOT / "src/helpers/sensors/EnvironmentSensorManager.cpp" + ).read_text() + + gps_address = hex_define(sensors, "TELEM_RAK12500_ADDRESS") + ina3221_address = hex_define(sensors, "TELEM_INA3221_ADDRESS") + self.assertEqual(gps_address, 0x42) + self.assertEqual(ina3221_address, 0x42) + self.assertEqual(gps_address, ina3221_address) + self.assertIn( + "RAK12500 and the configured INA3221 both use I2C address 0x42", + recipe, + ) + self.assertIn("cannot be installed", recipe) + self.assertIn("strap INA3221 A0 to SCL for 0x43", recipe) + self.assertIn("-DTELEM_INA3221_ADDRESS=0x43", recipe) + + def test_rak4631_shared_sensor_rail_stays_enabled(self) -> None: + recipe = (ROOT / "variants/rak4631/platformio.ini").read_text() + board = (ROOT / "variants/rak4631/RAK4631Board.cpp").read_text() + sensors = ( + ROOT / "src/helpers/sensors/EnvironmentSensorManager.cpp" + ).read_text() + + self.assertRegex(recipe, r"-D\s+FORCE_GPS_ALIVE\b") + self.assertIn("pinMode(WB_IO2, OUTPUT)", board) + self.assertIn("digitalWrite(WB_IO2, HIGH)", board) + self.assertIn("setRakGpsControl(ioPin, shared_power_rail, true)", sensors) + self.assertIn("setRakGpsControl(ioPin, shared_power_rail, false)", sensors) + + def test_rak4631_ethernet_does_not_suppress_shared_rail_gps(self) -> None: + recipe = (ROOT / "variants/rak4631/platformio.ini").read_text() + sensors = ( + ROOT / "src/helpers/sensors/EnvironmentSensorManager.cpp" + ).read_text() + + ethernet = ini_section(recipe, "env:RAK_4631_repeater_ethernet") + self.assertRegex(ethernet, r"-D\s+ETHERNET_ENABLED=1\b") + self.assertNotIn( + "#if defined(ETHERNET_ENABLED) && defined(RAK_BOARD)", sensors + ) + self.assertNotIn("if (ioPin == WB_IO2)", sensors) + self.assertIn( + "setRakGpsControl(ioPin, shared_power_rail, true)", sensors + ) + + def test_rak4631_runtime_bridge_arbitrates_only_uart_gps(self) -> None: + recipe = (ROOT / "variants/rak4631/platformio.ini").read_text() + mesh_header = ( + ROOT / "examples/simple_repeater/MyMesh.h" + ).read_text() + sensor_header = ( + ROOT / "src/helpers/sensors/EnvironmentSensorManager.h" + ).read_text() + sensors = ( + ROOT / "src/helpers/sensors/EnvironmentSensorManager.cpp" + ).read_text() + cli = (ROOT / "src/helpers/CommonCLI.cpp").read_text() + + begin = mesh_header[ + mesh_header.index("bool beginRS232Bridge()"): + mesh_header.index("bool endRS232Bridge()") + ] + end = mesh_header[ + mesh_header.index("bool endRS232Bridge()"): + mesh_header.index("bool setBridgeState(bool enable)") + ] + can_yield = sensors[ + sensors.index("bool EnvironmentSensorManager::gpsSerialTransportCanYield"): + sensors.index("bool EnvironmentSensorManager::setGpsSerialTransportBlocked") + ] + block = sensors[ + sensors.index("bool EnvironmentSensorManager::setGpsSerialTransportBlocked"): + sensors.index("#if ENV_INCLUDE_GPS || defined(ENV_INCLUDE_BME680_BSEC)") + ] + self.assertLess( + begin.index("setGpsSerialTransportBlocked(selected_uart, true)"), + begin.index("bridge->begin()"), + ) + self.assertLess( + begin.index("active_rs232_bridge_uart = selected_uart"), + begin.index("bridge->begin()"), + ) + failed_start = begin[ + begin.index("if (!bridge->isRunning())"): + begin.index( + "active_rs232_bridge_uart = selected_uart", + begin.index("bridge->begin()"), + ) + ] + self.assertIn( + "if (sensors.setGpsSerialTransportBlocked(selected_uart, false))", + failed_start, + ) + self.assertIn("active_rs232_bridge_uart = 0", failed_start) + self.assertLess( + begin.index("gpsSerialTransportMayConflict(selected_uart)"), + begin.index("bridge->begin()"), + ) + self.assertLess( + end.index("if (bridge && bridge->isRunning())"), + end.index("bridge->end()"), + ) + self.assertLess( + end.index("bridge->end()"), + end.index("setGpsSerialTransportBlocked(released_uart, false)"), + ) + self.assertIn("gpsUsesSerialUart(uint8_t uart) const override", sensor_header) + self.assertIn( + "gpsSerialTransportMayConflict(uint8_t uart) const override", + sensor_header, + ) + self.assertIn("-D WITH_RS232_BRIDGE_GPS_CONFLICT_UART=1", recipe) + self.assertIn( + "if (uart == WITH_RS232_BRIDGE_GPS_CONFLICT_UART) return true;", + sensors, + ) + self.assertIn( + "return uart == 1 && gps_detected && gps_serial_transport", sensors + ) + self.assertIn( + "#if defined(RAK_WISBLOCK_GPS) && defined(FORCE_GPS_ALIVE)", + can_yield, + ) + forced_rak = can_yield[ + can_yield.index( + "#if defined(RAK_WISBLOCK_GPS) && defined(FORCE_GPS_ALIVE)" + ): + can_yield.index("#elif defined(RAK_WISBLOCK_GPS)") + ] + self.assertIn("return false;", forced_rak) + refusal = block.index( + "if (blocked && !gpsSerialTransportCanYield(uart)) return false;" + ) + for mutation in ( + "setGpsTelemetryTransportAvailable(false)", + "gps_serial_transport_blocked = true", + "stop_gps()", + "Serial1.end()", + ): + self.assertLess(refusal, block.index(mutation)) + self.assertIn("setGpsTelemetryTransportAvailable(false)", sensors) + self.assertIn("setGpsTelemetryTransportAvailable(true)", sensors) + self.assertNotIn( + "turn the RS232 bridge off or select another UART first", cli + ) + self.assertNotIn("Error: turn GPS off or select another UART first", cli) + + def test_bridge_reports_runtime_state_and_rolls_back_failed_changes(self) -> None: + implementation = ( + ROOT / "examples/simple_repeater/MyMesh.cpp" + ).read_text() + header = (ROOT / "examples/simple_repeater/MyMesh.h").read_text() + cli = (ROOT / "src/helpers/CommonCLI.cpp").read_text() + + self.assertIn("bool isBridgeRunning() const override", header) + self.assertIn("if (isBridgeRunning()) reply_data[8] |= 0x01", implementation) + self.assertIn("if (isBridgeRunning()) reply_data[8] |= 0x03", implementation) + boot_failure = implementation[ + implementation.index("if (!bridge || !beginRS232Bridge())"): + implementation.index("#else", implementation.index( + "if (!bridge || !beginRS232Bridge())" + )) + ] + self.assertIn("setBridgeState(false)", boot_failure) + self.assertNotIn("_prefs.bridge_enabled", boot_failure) + self.assertIn("parseOnOffStrict(&config[15], enable)", cli) + self.assertIn("parseUnsignedIntegerStrict(&config[12], baud)", cli) + self.assertIn("_prefs->bridge_baud = previous_baud", cli) + self.assertIn("_prefs->bridge_uart = previous_uart", cli) + self.assertIn("bridge state change failed; setting unchanged", cli) + self.assertIn('configKeyEquals(config, "bridge.running")', cli) + self.assertIn('_prefs->bridge_pkt_src ? "rx" : "tx"', cli) + self.assertIn("!defined(RS232_BRIDGE_DEFAULT_ON)", cli) + + def test_default_on_rs232_profile_skips_default_off_tail_migration(self) -> None: + cli = (ROOT / "src/helpers/CommonCLI.cpp").read_text() + xiao = (ROOT / "variants/xiao_nrf52/platformio.ini").read_text() + dedicated = ini_section( + xiao, "env:solarxiao_30S_repeater_bridge_rs232" + ) + + self.assertIn("extends = env:solarxiao_30S_repeater", dedicated) + self.assertRegex(dedicated, r"-D\s+RS232_BRIDGE_DEFAULT_ON=1\b") + guard = ( + "#if defined(WITH_RS232_BRIDGE) && defined(RS232_BRIDGE_MERGED) \\\n" + " && !defined(RS232_BRIDGE_DEFAULT_ON)" + ) + # Declaration, persisted-tail detection, and migration must share the + # same guard. A broader use breaks dedicated default-on bridge builds. + self.assertEqual(cli.count(guard), 3) + self.assertEqual(cli.count("has_runtime_bridge_uart"), 3) + + def test_gat562_30s_gps_enable_and_buzzer_pins_are_distinct(self) -> None: + recipe = ( + ROOT / "variants/gat562_30s_mesh_kit/platformio.ini" + ).read_text() + variant = (ROOT / "variants/gat562_30s_mesh_kit/variant.h").read_text() + + # GAT562 30S Mesh KIT V1.1 schematic: IO2 is nRF P1.01 (Arduino + # GPIO 33) and drives the GPS supply; BEE_EN is P1.02 (GPIO 34). + gps_pin = re.search( + r"#define\s+PIN_GPS_EN\s+\((\d+)\)", variant + ) + buzzer_pin = re.search(r"-D\s+PIN_BUZZER=(\d+)\b", recipe) + self.assertIsNotNone(gps_pin) + self.assertIsNotNone(buzzer_pin) + self.assertEqual(int(gps_pin.group(1)), 33) + self.assertEqual(int(buzzer_pin.group(1)), 34) + self.assertNotEqual(gps_pin.group(1), buzzer_pin.group(1)) + + def test_xiao_and_mesh_pocket_softdevice_layouts_are_not_interchangeable( + self, + ) -> None: + xiao = (ROOT / "variants/xiao_nrf52/platformio.ini").read_text() + mesh_pocket = (ROOT / "variants/mesh_pocket/platformio.ini").read_text() + + self.assertRegex( + xiao, + re.compile( + r"^\[Xiao_nrf52\].*?^board_build\.ldscript\s*=\s*" + r"boards/nrf52840_s140_v7\.ld$", + re.MULTILINE | re.DOTALL, + ), + ) + self.assertRegex( + mesh_pocket, + re.compile( + r"^\[Mesh_pocket\].*?^board_build\.ldscript\s*=\s*" + r"boards/nrf52840_s140_v6\.ld$", + re.MULTILINE | re.DOTALL, + ), + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/test/test_ota/test_ota_core.cpp b/test/test_ota/test_ota_core.cpp index efe826c3..9894952f 100644 --- a/test/test_ota/test_ota_core.cpp +++ b/test/test_ota/test_ota_core.cpp @@ -37,6 +37,7 @@ TEST(OtaBootResult, AcceptsOnlyOtafixApplyDiagnostics) { EXPECT_EQ(ota_nrf52_boot_result_or_zero(0xB0), 0xB0); EXPECT_EQ(ota_nrf52_boot_result_or_zero(0xB8), 0xB8); EXPECT_EQ(ota_nrf52_boot_result_or_zero(0xBC), 0xBC); + EXPECT_EQ(ota_nrf52_boot_result_or_zero(0xBD), 0xBD); // SD authorization failure EXPECT_EQ(ota_nrf52_boot_result_or_zero(0xC0), 0xC0); EXPECT_EQ(ota_nrf52_boot_result_or_zero(0xC8), 0xC8); EXPECT_EQ(ota_nrf52_boot_result_or_zero(0xCF), 0xCF); @@ -50,7 +51,7 @@ TEST(OtaBootResult, AcceptsOnlyOtafixApplyDiagnostics) { EXPECT_EQ(ota_nrf52_boot_result_or_zero(0xD4), 0x00); // legacy handoff EXPECT_EQ(ota_nrf52_boot_result_or_zero(0xED), 0x00); // expanded handoff EXPECT_EQ(ota_nrf52_boot_result_or_zero(0xE0), 0x00); // retired handoff remains non-result - EXPECT_EQ(ota_nrf52_boot_result_or_zero(0xBD), 0x00); + EXPECT_EQ(ota_nrf52_boot_result_or_zero(0xBE), 0x00); } TEST(OtaPolicy, TrustedAutoInstallIsStrictlyForwardOnly) { diff --git a/test/test_rak4631_ota_compatibility.sh b/test/test_rak4631_ota_compatibility.sh new file mode 100644 index 00000000..7ef864ef --- /dev/null +++ b/test/test_rak4631_ota_compatibility.sh @@ -0,0 +1,88 @@ +#!/usr/bin/env bash +set -euo pipefail + +cd "$(dirname "$0")/.." +source build.sh + +fail() { + echo "test_rak4631_ota_compatibility: $*" >&2 + exit 1 +} + +assert_array_equals() { + local label=$1 + local actual_name=$2 + shift 2 + local -n actual=$actual_name + local -a expected=("$@") + local index + + [ "${#actual[@]}" -eq "${#expected[@]}" ] \ + || fail "$label count was ${#actual[@]}, expected ${#expected[@]}: ${actual[*]}" + for index in "${!expected[@]}"; do + [ "${actual[$index]}" = "${expected[$index]}" ] \ + || fail "$label[$index] was ${actual[$index]}, expected ${expected[$index]}" + done +} + +compatibility_targets=( + RAK_4631_repeater_bridge_rs232_serial1_lora_ota_no_external_sensors + RAK_4631_repeater_bridge_rs232_serial2_lora_ota_no_external_sensors +) +mapfile -t declared_compatibility_targets < <( + get_deployed_lora_ota_compatibility_targets +) +assert_array_equals \ + "declared compatibility targets" declared_compatibility_targets \ + "${compatibility_targets[@]}" + +for target in "${compatibility_targets[@]}"; do + is_deployed_lora_ota_compatibility_target "$target" \ + || fail "$target lost its deployed-node compatibility classification" + if is_redundant_bulk_build_target "$target"; then + fail "$target was omitted from canonical bulk builds" + fi +done +if is_deployed_lora_ota_compatibility_target RAK_4631_repeater; then + fail "canonical RAK4631 repeater was classified as a legacy OTA identity" +fi + +[ "$(get_merged_rs232_repeater_replacement "${compatibility_targets[0]}")" \ + = RAK_4631_repeater_lora_ota_no_external_sensors ] \ + || fail "Serial1 compatibility target lost its canonical recommendation" +[ "$(get_merged_rs232_repeater_replacement "${compatibility_targets[1]}")" \ + = RAK_4631_repeater_lora_ota_no_external_sensors ] \ + || fail "Serial2 compatibility target lost its canonical recommendation" + +canonical_target=RAK_4631_repeater +canonical_ota_target=RAK_4631_repeater_lora_ota_no_external_sensors +legacy_install_targets=( + RAK_4631_repeater_bridge_rs232_serial1 + RAK_4631_repeater_bridge_rs232_serial2 +) +SUPPORTED_PIO_ENVS=( + "$canonical_target" + "$canonical_ota_target" + "${legacy_install_targets[@]}" + "${compatibility_targets[@]}" +) +for target in "${SUPPORTED_PIO_ENVS[@]}"; do + PIO_ENV_PLATFORM_BY_NAME[$target]=NRF52_PLATFORM +done + +mapfile -t all_targets < <(resolve_all_firmwares) +assert_array_equals \ + "canonical bulk target list" all_targets \ + "$canonical_target" \ + "$canonical_ota_target" \ + "${compatibility_targets[@]}" + +mapfile -t release_targets < <( + print_release_firmware_targets get-repeater-firmwares-to-build +) +assert_array_equals \ + "repeater release target list" release_targets \ + "$canonical_target" \ + "${compatibility_targets[@]}" + +echo "test_rak4631_ota_compatibility: OK" diff --git a/test/test_serial_mode_switch/test_serial_mode_switch.cpp b/test/test_serial_mode_switch/test_serial_mode_switch.cpp index 9a84501a..5774c836 100644 --- a/test/test_serial_mode_switch/test_serial_mode_switch.cpp +++ b/test/test_serial_mode_switch/test_serial_mode_switch.cpp @@ -555,6 +555,27 @@ TEST(SerialFlowControl, FinishesAnUnexpectedShortWriteBeforeNextFrame) { EXPECT_FALSE(interface.hasPendingIO()); } +TEST(SerialFlowControl, RetriesAZeroLengthWriteWithoutLosingTheFrame) { + BufferStream stream; + stream.write_capacity = MAX_FRAME_SIZE + 3; + stream.max_write = 0; + ArduinoSerialInterface interface; + interface.begin(stream); + interface.enableFlowControl(true); + interface.enable(); + + const uint8_t payload[] = {0x88, 0x11, 0x22}; + EXPECT_EQ(interface.writeFrame(payload, sizeof(payload)), sizeof(payload)); + EXPECT_TRUE(stream.output.empty()); + EXPECT_TRUE(interface.hasPendingIO()); + + stream.max_write = std::numeric_limits::max(); + interface.loop(); + const std::vector expected = {'>', 3, 0, 0x88, 0x11, 0x22}; + EXPECT_EQ(stream.output, expected); + EXPECT_FALSE(interface.hasPendingIO()); +} + TEST(SerialFlowControl, DrainsFramesThroughAFifoSmallerThanTheFrame) { BufferStream stream; stream.write_capacity = 4; diff --git a/test/test_temp_radio_reply_barrier/test_temp_radio_lease_deadline.cpp b/test/test_temp_radio_reply_barrier/test_temp_radio_lease_deadline.cpp new file mode 100644 index 00000000..4b5ab4ae --- /dev/null +++ b/test/test_temp_radio_reply_barrier/test_temp_radio_lease_deadline.cpp @@ -0,0 +1,35 @@ +#include + +#include + +TEST(TempRadioLeaseDeadline, EpochWindowGetsAnIndependentUptimeEnd) { + const uint64_t hard_end = mesh::TempRadioLeaseDeadline::fromEpochEnd( + 50'000ULL, 1'800'000'000UL, 1'800'000'180UL); + + EXPECT_EQ(hard_end, 230'000ULL); + EXPECT_FALSE(mesh::TempRadioLeaseDeadline::expired(229'999ULL, hard_end)); + EXPECT_TRUE(mesh::TempRadioLeaseDeadline::expired(230'000ULL, hard_end)); +} + +TEST(TempRadioLeaseDeadline, BackwardRtcCorrectionCannotExtendLease) { + const uint64_t hard_end = mesh::TempRadioLeaseDeadline::fromEpochEnd( + 10'000ULL, 1'800'000'000UL, 1'800'000'180UL); + + // The wall clock has moved backward by an hour and no longer considers the + // epoch end due. Uptime remains authoritative for the safety bound. + const uint32_t corrected_wall_clock = 1'799'996'580UL; + EXPECT_LT(corrected_wall_clock, 1'800'000'180UL); + EXPECT_TRUE(mesh::TempRadioLeaseDeadline::expired(190'000ULL, hard_end)); +} + +TEST(TempRadioLeaseDeadline, PendingWindowAlsoExpiresAndBoundsSleep) { + const uint64_t hard_end = mesh::TempRadioLeaseDeadline::fromEpochEnd( + 4'000ULL, 1'800'000'000UL, 1'800'000'181UL); + + EXPECT_EQ( + mesh::TempRadioLeaseDeadline::secondsUntil(180'001ULL, hard_end), + 5U); + EXPECT_EQ( + mesh::TempRadioLeaseDeadline::secondsUntil(185'000ULL, hard_end), + 0U); +} diff --git a/test/test_temp_radio_reply_barrier/test_temp_radio_reply_barrier.cpp b/test/test_temp_radio_reply_barrier/test_temp_radio_reply_barrier.cpp new file mode 100644 index 00000000..024b2804 --- /dev/null +++ b/test/test_temp_radio_reply_barrier/test_temp_radio_reply_barrier.cpp @@ -0,0 +1,66 @@ +#include + +#include + +TEST(TempRadioReplyBarrier, WaitsForTheExactReplyPacket) { + mesh::TempRadioReplyBarrier barrier; + int reply_packet = 1; + int unrelated_packet = 2; + + EXPECT_FALSE(barrier.waiting()); + barrier.arm(&reply_packet); + EXPECT_TRUE(barrier.waiting()); + + EXPECT_FALSE(barrier.complete(&unrelated_packet)); + EXPECT_TRUE(barrier.waiting()); + EXPECT_TRUE(barrier.complete(&reply_packet)); + EXPECT_FALSE(barrier.waiting()); +} + +TEST(TempRadioReplyBarrier, ExactTransmitFailureReleasesForFailClosedCleanup) { + mesh::TempRadioReplyBarrier barrier; + int reply_packet = 1; + + barrier.arm(&reply_packet); + EXPECT_TRUE(barrier.fail(&reply_packet)); + EXPECT_FALSE(barrier.waiting()); + EXPECT_FALSE(barrier.fail(&reply_packet)); +} + +TEST(TempRadioReplyBarrier, UntrackedAlternateCannotResolveAuthoritativeReply) { + mesh::TempRadioReplyBarrier barrier; + int primary_reply = 1; + int alternate_reply = 2; + + barrier.arm(&primary_reply); + + // TempRadio mutation acknowledgements deliberately queue only the primary + // copy, without an alternate-path copy or a transport retry. If an + // obsolete/foreign packet callback arrives anyway, it must not release or + // fail the authoritative handoff. + EXPECT_FALSE(barrier.complete(&alternate_reply)); + EXPECT_FALSE(barrier.fail(&alternate_reply)); + EXPECT_TRUE(barrier.waiting()); + + EXPECT_TRUE(barrier.complete(&primary_reply)); + EXPECT_FALSE(barrier.waiting()); +} + +TEST(TempRadioReplyBarrier, ClearCancelsAnObsoleteHandoff) { + mesh::TempRadioReplyBarrier barrier; + int old_reply = 1; + int new_reply = 2; + + barrier.arm(&old_reply); + barrier.clear(); + EXPECT_FALSE(barrier.complete(&old_reply)); + + barrier.arm(&new_reply); + EXPECT_FALSE(barrier.complete(&old_reply)); + EXPECT_TRUE(barrier.complete(&new_reply)); +} + +int main(int argc, char** argv) { + ::testing::InitGoogleTest(&argc, argv); + return RUN_ALL_TESTS(); +} diff --git a/test/test_temp_radio_reply_delivery_contract.py b/test/test_temp_radio_reply_delivery_contract.py new file mode 100644 index 00000000..742dca82 --- /dev/null +++ b/test/test_temp_radio_reply_delivery_contract.py @@ -0,0 +1,85 @@ +#!/usr/bin/env python3 +"""Static integration contract for TempRadio's single-copy reply barrier.""" + +from pathlib import Path +import unittest + + +ROOT = Path(__file__).resolve().parents[1] +SOURCE = ROOT / "examples/simple_repeater/MyMesh.cpp" + + +def function_body(text: str, signature: str, next_signature: str) -> str: + start = text.index(signature) + end = text.index(next_signature, start) + return text[start:end] + + +class TempRadioReplyDeliveryContractTest(unittest.TestCase): + def test_exact_packet_tracking_suppresses_all_untracked_copies(self): + source = SOURCE.read_text(encoding="utf-8") + reply = function_body( + source, + "bool MyMesh::sendRemoteCliReply(", + "void MyMesh::onUserGpioTimerCompleted(", + ) + delivery = function_body( + source, + "bool MyMesh::sendClientReplyWithFallbackScope(", + "bool MyMesh::floodChannelDataHopApplies(", + ) + + self.assertIn( + "const bool allow_redundant_copies = queued_packet == NULL;", reply + ) + self.assertIn("fallback_scope,\n allow_redundant_copies", reply) + self.assertIn( + "if (allow_redundant_copies\n" + " && mesh::Packet::isValidPathLen(client->alt_path_len)", + delivery, + ) + self.assertIn( + "if (!allow_redundant_copies) _prefs.direct_retry_enabled = 0;", + delivery, + ) + self.assertIn( + "if (!allow_redundant_copies) _prefs.flood_retry_attempts = 0;", + delivery, + ) + + def test_only_successful_parameterized_temp_radio_requests_track_reply(self): + source = SOURCE.read_text(encoding="utf-8") + receive = function_body( + source, + "void MyMesh::onPeerDataRecv(", + "bool MyMesh::sendRemoteCliReply(", + ) + command = function_body( + source, + "void __attribute__((noinline)) MyMesh::processDeferredCliCommand()", + "bool MyMesh::onPeerPathRecv(", + ) + + self.assertIn( + 'strncmp(deferred_cli_command.command, "tempradio ", 10)', command + ) + self.assertIn('strncmp(reply, "OK - temp params for ", 21)', command) + self.assertIn("arms_temp_radio ? &queued_reply : NULL", command) + + # A packet-level retry can arrive after the success text was cached but + # before the authoritative reply drains. It must not create a second, + # untracked success packet either. + self.assertIn("const bool cached_temp_radio_success", receive) + suppress_start = receive.index("if (cached_temp_radio_success)") + replay_start = receive.index("} else {", suppress_start) + replay_end = receive.index( + "} else if (deferred_cli_command.matches", replay_start + ) + self.assertNotIn( + "sendRemoteCliReply(", receive[suppress_start:replay_start] + ) + self.assertIn("sendRemoteCliReply(", receive[replay_start:replay_end]) + + +if __name__ == "__main__": + unittest.main() diff --git a/test/test_tls_clock_validity/sntp_coordinator_peer.cpp b/test/test_tls_clock_validity/sntp_coordinator_peer.cpp new file mode 100644 index 00000000..7c7bc3f1 --- /dev/null +++ b/test/test_tls_clock_validity/sntp_coordinator_peer.cpp @@ -0,0 +1,20 @@ +#include + +namespace { + +mesh::sntp_coord::OperationLease peer_lease( + mesh::sntp_coord::processWideCoordinator()); + +} // namespace + +extern "C" void* sntpCoordinatorPeerAddress() { + return &mesh::sntp_coord::processWideCoordinator(); +} + +extern "C" bool sntpCoordinatorPeerAcquire() { + return peer_lease.tryAcquire(); +} + +extern "C" bool sntpCoordinatorPeerRelease() { + return peer_lease.release(); +} diff --git a/test/test_tls_clock_validity/test_tls_clock_validity.cpp b/test/test_tls_clock_validity/test_tls_clock_validity.cpp new file mode 100644 index 00000000..4c9fd43d --- /dev/null +++ b/test/test_tls_clock_validity/test_tls_clock_validity.cpp @@ -0,0 +1,102 @@ +#include + +#include +#include + +namespace Clock = mesh::tls_clock; + +extern "C" void* sntpCoordinatorPeerAddress(); +extern "C" bool sntpCoordinatorPeerAcquire(); +extern "C" bool sntpCoordinatorPeerRelease(); + +namespace { + +int cleanup_calls = 0; + +void recordCleanup() { + ++cleanup_calls; +} + +} // namespace + +TEST(TlsClockValidity, RejectsNegativeAndPreMinimumEpochs) { + EXPECT_FALSE(Clock::timeIsValid((time_t)-1)); + EXPECT_FALSE(Clock::timeIsValid(Clock::kMinimumValidEpoch - 1)); +} + +TEST(TlsClockValidity, AcceptsTheExactMinimumAndLaterEpochs) { + EXPECT_TRUE(Clock::timeIsValid(Clock::kMinimumValidEpoch)); + EXPECT_TRUE(Clock::timeIsValid(Clock::kMinimumValidEpoch + 1)); +} + +TEST(TlsClockValidity, RequiresFreshProofAndConnectedWiFi) { + const time_t now = Clock::kMinimumValidEpoch; + EXPECT_FALSE(Clock::proofIsValid(false, true, now)); + EXPECT_FALSE(Clock::proofIsValid(true, false, now)); + EXPECT_FALSE(Clock::proofIsValid(true, true, now - 1)); + EXPECT_TRUE(Clock::proofIsValid(true, true, now)); +} + +TEST(TlsClockValidity, ProofAgeIsBoundedAndMillisRolloverSafe) { + EXPECT_FALSE(Clock::proofAgeIsValid(false, 100, 90, 10)); + EXPECT_TRUE(Clock::proofAgeIsValid(true, 100, 90, 10)); + EXPECT_FALSE(Clock::proofAgeIsValid(true, 101, 90, 10)); + + const uint32_t before_wrap = UINT32_MAX - 5U; + EXPECT_TRUE(Clock::proofAgeIsValid(true, 4, before_wrap, 10)); + EXPECT_FALSE(Clock::proofAgeIsValid(true, 5, before_wrap, 10)); +} + +TEST(TlsClockValidity, LateCallbackGenerationFailsClosed) { + EXPECT_FALSE(Clock::proofGenerationIsValid(false, 7, 7)); + EXPECT_FALSE(Clock::proofGenerationIsValid(true, 7, 0)); + EXPECT_FALSE(Clock::proofGenerationIsValid(true, 6, 7)); + EXPECT_TRUE(Clock::proofGenerationIsValid(true, 7, 7)); +} + +TEST(SntpOperationCoordinator, IsNonBlockingAndOnlyOwnerRunsCleanup) { + mesh::sntp_coord::OperationCoordinator coordinator; + cleanup_calls = 0; + + mesh::sntp_coord::OperationLease first(coordinator, recordCleanup); + mesh::sntp_coord::OperationLease second(coordinator, recordCleanup); + ASSERT_TRUE(first.tryAcquire()); + const uint32_t first_generation = first.generation(); + EXPECT_NE(first_generation, 0U); + EXPECT_TRUE(first.owns()); + EXPECT_FALSE(second.tryAcquire()); + EXPECT_EQ(cleanup_calls, 0); + + EXPECT_TRUE(first.release()); + EXPECT_EQ(cleanup_calls, 1); + EXPECT_FALSE(first.release()); + EXPECT_EQ(cleanup_calls, 1); + + ASSERT_TRUE(second.tryAcquire()); + EXPECT_NE(second.generation(), first_generation); + EXPECT_TRUE(second.owns()); + // A stale/repeated release from the first lease cannot clear the new owner. + EXPECT_FALSE(first.release()); + EXPECT_TRUE(second.owns()); + EXPECT_EQ(cleanup_calls, 1); + EXPECT_TRUE(second.release()); + EXPECT_EQ(cleanup_calls, 2); +} + +TEST(SntpOperationCoordinator, ProcessWideAccessorReturnsOneInstance) { + EXPECT_EQ(&mesh::sntp_coord::processWideCoordinator(), + sntpCoordinatorPeerAddress()); + + mesh::sntp_coord::OperationLease local( + mesh::sntp_coord::processWideCoordinator()); + ASSERT_TRUE(local.tryAcquire()); + EXPECT_FALSE(sntpCoordinatorPeerAcquire()); + ASSERT_TRUE(local.release()); + EXPECT_TRUE(sntpCoordinatorPeerAcquire()); + EXPECT_TRUE(sntpCoordinatorPeerRelease()); +} + +int main(int argc, char** argv) { + ::testing::InitGoogleTest(&argc, argv); + return RUN_ALL_TESTS(); +} diff --git a/test/test_tls_download_clock_gates.py b/test/test_tls_download_clock_gates.py new file mode 100644 index 00000000..6d32d894 --- /dev/null +++ b/test/test_tls_download_clock_gates.py @@ -0,0 +1,376 @@ +#!/usr/bin/env python3 + +from pathlib import Path +import re +import unittest + + +ROOT = Path(__file__).resolve().parents[1] +ESP32_BOARD = ROOT / "src/helpers/ESP32Board.cpp" +INDICATOR_FONT = ROOT / "variants/sensecap_indicator-espnow/IndicatorFontClient.cpp" +OBSERVER_CLI = ROOT / "src/helpers/CommonCLI_Observer.cpp" +TLS_POLICY = ROOT / "src/helpers/esp32/TlsClockValidity.h" +SNTP_COORDINATOR = ROOT / "src/helpers/esp32/SntpOperationCoordinator.h" +MQTT_BRIDGE = ROOT / "src/helpers/bridges/MQTTBridge.h" +MQTT_BRIDGE_IMPL = ROOT / "src/helpers/bridges/MQTTBridge.cpp" +MQTT_POLICY = ROOT / "src/helpers/MQTTConnectionPolicy.h" + + +def source(path: Path) -> str: + return path.read_text() + + +def section(text: str, start: str, end: str) -> str: + first = text.index(start) + return text[first:text.index(end, first)] + + +class TlsDownloadClockGateTest(unittest.TestCase): + def test_outbound_secure_socket_inventory_has_no_unreviewed_path(self): + # Keep this inventory deliberately small and explicit. A new direct + # WiFiClientSecure connect is a new certificate-validating network + # boundary; adding one must also add its clock/proof contract here. + runtime_roots = (ROOT / "src", ROOT / "examples", ROOT / "variants") + secure_connect_files = set() + for runtime_root in runtime_roots: + for path in runtime_root.rglob("*.cpp"): + implementation = source(path) + if "WiFiClientSecure" in implementation and ".connect(" in implementation: + secure_connect_files.add(path.relative_to(ROOT).as_posix()) + + self.assertEqual( + secure_connect_files, + { + "src/helpers/CommonCLI_Observer.cpp", # connect-only CA diagnostic + "src/helpers/ESP32Board.cpp", # manifest + firmware pull OTA + "variants/sensecap_indicator-espnow/IndicatorFontClient.cpp", + }, + ) + + # These higher-level outbound HTTP clients can hide their TLS connect + # from the inventory above. None is currently used by device runtime + # code; fail visibly if one is introduced without a matching audit. + combined_runtime = "\n".join( + source(path) + for runtime_root in runtime_roots + for path in runtime_root.rglob("*.cpp") + ) + for helper in ("HTTPClient", "esp_https_ota", "esp_http_client"): + self.assertNotIn(helper, combined_runtime) + + def test_shared_policy_uses_signed_time_and_requires_every_input(self): + policy = source(TLS_POLICY) + self.assertIn("constexpr bool timeIsValid(time_t now)", policy) + self.assertIn("now >= kMinimumValidEpoch", policy) + self.assertIn("fresh_proof && wifi_connected && timeIsValid(now)", policy) + self.assertNotIn("timeIsValid(uint32_t now", policy) + self.assertNotIn("proofIsValid(bool fresh_proof, bool wifi_connected,\n uint32_t now", policy) + + def test_pull_ota_awaits_fresh_ntp_before_the_https_sequence(self): + board = source(ESP32_BOARD) + prepare = section( + board, "static bool ota_prepareTlsClock", "struct OtaHttpResponse" + ) + implementation = section( + board, + "bool ESP32Board::otaFromManifestImpl", + "#else\nbool ESP32Board::otaFromManifest", + ) + + self.assertIn( + "ota_tls_fresh_ntp_received.store(false, std::memory_order_release)", + prepare, + ) + self.assertIn("sntp_set_time_sync_notification_cb(ota_noteNtpTime)", prepare) + self.assertIn("sntp_set_sync_status(SNTP_SYNC_STATUS_RESET)", prepare) + self.assertIn('configTime(0, 0, "time.cloudflare.com"', prepare) + self.assertIn("const time_t now = time(nullptr)", prepare) + self.assertIn("ota_tlsClockProofValid()", prepare) + self.assertIn("OTA_NTP_SYNC_WAIT_MS", prepare) + self.assertNotIn("(uint32_t)time(nullptr)", prepare) + self.assertIn("OperationLease sntp_operation", prepare) + self.assertIn("processWideCoordinator()", prepare) + self.assertIn("if (!sntp_operation.tryAcquire())", prepare) + self.assertNotIn("sntp_set_time_sync_notification_cb(nullptr)", prepare) + + gate = implementation.index( + "if (!dry_run && !ota_prepareTlsClock(reply))" + ) + manifest_client = implementation.index("WiFiClientSecure mclient") + manifest_log = implementation.index("OTA: downloading manifest") + manifest_fetch = implementation.index( + "ota_fetchManifest(mclient, murl, true, doc, reply)" + ) + self.assertLess(gate, manifest_client) + self.assertLess(gate, manifest_log) + self.assertLess(manifest_log, manifest_fetch) + self.assertIn( + "ota_openHttp(uclient, file_url, true, response, reply)", + implementation, + ) + + def test_every_pull_ota_tls_connect_revalidates_the_bounded_proof(self): + board = source(ESP32_BOARD) + proof = section( + board, "static bool ota_tlsClockProofValid", "static bool ota_prepareTlsClock" + ) + request = section(board, "static bool ota_openHttp", "static bool ota_fetchManifest") + + self.assertIn("ota_tls_fresh_ntp_received.load", proof) + self.assertIn("WiFi.status() == WL_CONNECTED", proof) + self.assertIn("time(nullptr)", proof) + self.assertIn("OTA_TLS_PROOF_MAX_AGE_MS", proof) + self.assertIn("mesh::tls_clock::proofAgeIsValid", proof) + self.assertIn("mesh::tls_clock::proofGenerationIsValid", proof) + guard = request.index("require_tls && !ota_tlsClockProofValid()") + connect = request.index("client.connect(host, port)") + self.assertLess(guard, connect) + self.assertEqual(request.count("client.connect("), 1) + + def test_all_sntp_callback_owners_share_one_nonblocking_generation_lease(self): + board = source(ESP32_BOARD) + indicator = source(INDICATOR_FONT) + coordinator = source(SNTP_COORDINATOR) + + self.assertIn("compare_exchange_strong", coordinator) + self.assertIn("if (!owns(generation)) return false", coordinator) + cleanup = coordinator.index("if (cleanup != nullptr) cleanup()") + publish_free = coordinator.index("expected, 0", cleanup) + self.assertLess(cleanup, publish_free) + self.assertIn("~OperationLease() { release(); }", coordinator) + + for implementation, callback, cleanup_name in ( + (board, "ota_noteNtpTime", "ota_clearNtpCallback"), + (indicator, "noteFontNtpTime", "clearFontNtpCallback"), + ): + self.assertIn("processWideCoordinator()", implementation) + self.assertIn("OperationLease sntp_operation", implementation) + self.assertIn("processWideCoordinator().owns(generation)", implementation) + cleanup_body = section( + implementation, + f"void {cleanup_name}", + f"void {callback}", + ) + self.assertIn("sntp_set_time_sync_notification_cb(nullptr)", cleanup_body) + self.assertIn(".store(0, std::memory_order_release)", cleanup_body) + + def test_tls_bundle_diagnostic_rejects_an_invalid_clock_before_connect(self): + cli = source(OBSERVER_CLI) + diagnostic = section( + cli, + 'if (memcmp(command, "tls.bundletest ", 15) == 0)', + '} else if (memcmp(command, "ota check", 9)', + ) + signed_clock = diagnostic.index("const time_t tls_now = time(nullptr)") + gate = diagnostic.index("mesh::tls_clock::timeIsValid(tls_now)") + connect = diagnostic.index("client.connect(host, port)") + self.assertLess(signed_clock, gate) + self.assertLess(gate, connect) + + def test_mqtt_clock_flag_is_not_documented_as_fresh_ntp_proof(self): + header = source(MQTT_BRIDGE) + self.assertIn("plausible retained RTC/system clock", header) + self.assertNotIn( + "True after this bridge has successfully set the RTC from NTP", + header, + ) + + def test_every_mqtt_sntp_mutation_is_coordinator_leased(self): + implementation = source(MQTT_BRIDGE_IMPL) + refresh = section( + implementation, + "void MQTTBridge::refreshNTP()", + "bool MQTTBridge::syncTimeWithNTP", + ) + sync = section( + implementation, + "bool MQTTBridge::syncTimeWithNTP", + "bool MQTTBridge::requestForcedNtpSync", + ) + + self.assertIn("SntpOperationCoordinator.h", implementation) + self.assertEqual(len(re.findall(r"(?m)^\s*configTime\(", implementation)), 3) + self.assertEqual( + len(re.findall(r"(?m)^\s*sntp_set_sync_status\(", implementation)), + 2, + ) + self.assertEqual( + len(re.findall(r"sntp_get_sync_status\(", implementation)), + 2, + ) + + refresh_lease = refresh.index("OperationLease sntp_operation") + refresh_acquire = refresh.index("if (!sntp_operation.tryAcquire())") + refresh_configure = refresh.index("configTime(") + self.assertLess(refresh_lease, refresh_acquire) + self.assertLess(refresh_acquire, refresh_configure) + + fallback_start = sync.index( + "// Fallback: use ESP32 built-in SNTP (configTime)" + ) + fallback_end = sync.index("\n #endif\n\n if (!ntp_ok) {", fallback_start) + fallback = sync[fallback_start:fallback_end] + fallback_lease = fallback.index("OperationLease sntp_operation") + fallback_acquire = fallback.index("if (!sntp_operation.tryAcquire())") + reset = fallback.index("sntp_set_sync_status(SNTP_SYNC_STATUS_RESET)") + configure = fallback.index("configTime(0, 0, server)") + completed = fallback.index( + "sntp_get_sync_status() != SNTP_SYNC_STATUS_COMPLETED" + ) + self.assertLess(fallback_lease, fallback_acquire) + self.assertLess(fallback_acquire, reset) + self.assertLess(reset, configure) + self.assertLess(configure, completed) + + final_rearm = section( + sync, + "if (ntp_server_used && !sntp_fallback_configured)", + "if (_rtc)", + ) + final_lease = final_rearm.index("OperationLease sntp_operation") + final_acquire = final_rearm.index("if (sntp_operation.tryAcquire())") + final_configure = final_rearm.index("configTime(0, 0, ntp_server_used)") + self.assertLess(final_lease, final_acquire) + self.assertLess(final_acquire, final_configure) + self.assertIn("else if (sntp_fallback_configured)", final_rearm) + + def test_mqtt_async_sntp_contention_uses_a_wrap_safe_bounded_retry(self): + header = source(MQTT_BRIDGE) + implementation = source(MQTT_BRIDGE_IMPL) + policy = source(MQTT_POLICY) + refresh = section( + implementation, + "void MQTTBridge::refreshNTP()", + "bool MQTTBridge::syncTimeWithNTP", + ) + + self.assertIn("unsigned long _ntp_refresh_retry_at", header) + self.assertIn("kNtpRetryMs = 5000UL", policy) + self.assertIn( + "static_cast(now - retry_at) >= 0", policy + ) + self.assertIn("return retry_at == 0 ? 1 : retry_at", policy) + self.assertIn("kNtpRefreshIntervalMs = 86400000UL", policy) + self.assertIn( + "elapsedMs(now, last_sync) >= kNtpRefreshIntervalMs", policy + ) + self.assertIn("ntpReconnectRefreshAt", policy) + self.assertEqual( + implementation.count( + "MQTTConnectionPolicy::ntpReconnectRefreshAt(" + ), + 2, + ) + self.assertGreaterEqual(implementation.count("mqttNtpRefreshDue("), 4) + busy = refresh.index("if (!sntp_operation.tryAcquire())") + retry = refresh.index("mqttNtpRetryAt(now)", busy) + leave = refresh.index("return;", retry) + configure = refresh.index( + "configTime(0, 0, effectiveNtpPrimary(_obs))" + ) + self.assertLess(busy, retry) + self.assertLess(retry, leave) + self.assertLess(leave, configure) + self.assertIn("_ntp_refresh_retry_at = 0", refresh[configure:]) + + sync = section( + implementation, + "bool MQTTBridge::syncTimeWithNTP", + "bool MQTTBridge::requestForcedNtpSync", + ) + final_rearm = section( + sync, + "if (ntp_server_used && !sntp_fallback_configured)", + "if (_rtc)", + ) + self.assertIn( + "_ntp_refresh_retry_at = mqttNtpRetryAt(millis())", + final_rearm, + ) + + def test_mqtt_periodic_refresh_updates_rtc_only_after_sntp_completion(self): + header = source(MQTT_BRIDGE) + implementation = source(MQTT_BRIDGE_IMPL) + refresh = section( + implementation, + "void MQTTBridge::refreshNTP()", + "void MQTTBridge::pollNtpRefresh", + ) + poll = section( + implementation, + "void MQTTBridge::pollNtpRefresh", + "bool MQTTBridge::syncTimeWithNTP", + ) + + self.assertIn("unsigned long _ntp_refresh_started_at", header) + self.assertIn("bool _ntp_refresh_pending", header) + self.assertIn("std::atomic _ntp_synced", header) + self.assertIn( + "MQTTConnectionPolicy::NtpReconnectLatch " + "_ntp_reconnect_latch", + header, + ) + self.assertIn("MQTT_NTP_REFRESH_TIMEOUT_MS = 15000UL", implementation) + self.assertEqual(implementation.count("pollNtpRefresh("), 3) + self.assertGreaterEqual( + implementation.count("&& !_ntp_refresh_pending"), 2 + ) + + reset = refresh.index("sntp_set_sync_status(SNTP_SYNC_STATUS_RESET)") + configure = refresh.index( + "configTime(0, 0, effectiveNtpPrimary(_obs))" + ) + pending = refresh.index("_ntp_refresh_pending = true", configure) + self.assertLess(reset, configure) + self.assertLess(configure, pending) + self.assertNotIn("_last_ntp_sync =", refresh) + + completed = poll.index("sntp_get_sync_status() == SNTP_SYNC_STATUS_COMPLETED") + system_clock = poll.index("time(nullptr)", completed) + valid = poll.index( + "MQTTConnectionPolicy::checkedRtcEpoch(", system_clock + ) + self.assertIn("static_cast(raw_system_time)", poll[valid:]) + rtc = poll.index("_rtc->setCurrentTime(system_time)", valid) + success = poll.index("_last_ntp_sync = now", rtc) + self.assertLess(completed, system_clock) + self.assertLess(system_clock, valid) + self.assertLess(valid, rtc) + self.assertLess(rtc, success) + self.assertEqual(poll.count("_last_ntp_sync ="), 1) + + timeout = poll.index("elapsed >= MQTT_NTP_REFRESH_TIMEOUT_MS") + retry = poll.index("mqttNtpRetryAt(now)", timeout) + self.assertLess(timeout, retry) + self.assertNotIn("_last_ntp_sync =", poll[timeout:]) + + got_ip = section( + implementation, + "case ARDUINO_EVENT_WIFI_STA_GOT_IP:", + "case ARDUINO_EVENT_WIFI_STA_DISCONNECTED:", + ) + self.assertIn("_ntp_reconnect_latch.noteGotIp()", got_ip) + self.assertNotIn("_ntp_synced", got_ip) + self.assertNotIn("_ntp_sync_pending", got_ip) + task_loop = section( + implementation, + "void MQTTBridge::mqttTaskLoop()", + "bool MQTTBridge::isSlotReady", + ) + connected = task_loop.index( + "const bool wifi_connected = WiFi.status() == WL_CONNECTED" + ) + consume = task_loop.index( + "_ntp_reconnect_latch.consumeIfConnected(wifi_connected)" + ) + self.assertLess(connected, consume) + begin = section( + implementation, + "void MQTTBridge::begin()", + "void MQTTBridge::end()", + ) + self.assertNotIn("_ntp_reconnect_latch.", begin) + + +if __name__ == "__main__": + unittest.main() diff --git a/tools/lora_ota/lora_ota.py b/tools/lora_ota/lora_ota.py index 2cb74b93..ed92aaa0 100755 --- a/tools/lora_ota/lora_ota.py +++ b/tools/lora_ota/lora_ota.py @@ -36,7 +36,7 @@ import tempfile import threading import time from dataclasses import dataclass -from datetime import datetime +from datetime import datetime, timezone from typing import Callable, TypeVar import zipfile @@ -66,6 +66,49 @@ TRANSMISSION_PROMPT_SECONDS = 10 SOURCE_RXPS_BUSY_RETRY_LIMIT = 32 ADAPTIVE_POLL_MAX_FACTOR = 3 TEMP_RADIO_SWITCH_DELAY_SECONDS = 3 +# Before a long OTA window, every participant must survive one complete, +# independently bounded TempRadio lease. Three minutes is deliberately long +# enough to expose an unreachable hop or a broken automatic return without +# risking the two-hour maintenance window requested for the transfer itself. +TEMP_RADIO_PREFLIGHT_MINUTES = 3 +TEMP_RADIO_PREFLIGHT_MARGIN_SECONDS = 15 +TEMP_RADIO_PREFLIGHT_OPERATION_TIMEOUT_SECONDS = 30 +# Remote rehearsal nodes use their existing absolute ``tempradioat`` command. +# A common host deadline is projected into each node's independently drifting +# RTC, so an old controller retry can only request the same fixed start/end and +# can never create a fresh three-minute lease after cleanup. The scheduler +# carries the complete measured clock-read RTT into each projected host window; +# the small guard below covers only integer-second quantization and handoff +# scheduling, not transport uncertainty. +TEMP_RADIO_PREFLIGHT_SCHEDULE_GUARD_SECONDS = 3 +TEMP_RADIO_PREFLIGHT_SOURCE_ARM_LEAD_SECONDS = 5 +TEMP_RADIO_PREFLIGHT_ARM_BASE_SECONDS = 30 +# Normal-channel destination identity is read-only and happens before any +# rehearsal lease or radio mutation. A marginal but usable link can lose four +# consecutive replies, so give this exact proof a larger finite budget without +# relaxing the much smaller retry limit inside the live temporary window. +TEMP_RADIO_NORMAL_BASELINE_TIMEOUT_SECONDS = 4 * 60 +TEMP_RADIO_NORMAL_BASELINE_RETRY_LIMIT = 8 +# Remote replay guards depend on the controller's signed timestamps. A +# rebooted companion can be months behind even though its radio and ACL are +# otherwise healthy. Advance stale managed clocks before any remote packet; +# never move a clock backward, and fail closed when a future clock exceeds the +# project's ten-minute drift policy. +TEMP_RADIO_CLOCK_MAX_AHEAD_SECONDS = 10 * 60 +TEMP_RADIO_CLOCK_READ_TOLERANCE_SECONDS = 60 +# A text terminal exposes only whole minutes. When its complete minute window +# is behind the host, request a value far enough ahead that the hidden seconds +# and one bounded CLI operation cannot turn the nominally forward write into a +# backward write. This remains comfortably inside the ten-minute policy. +TEMP_RADIO_SOURCE_CLOCK_PIN_LEAD_SECONDS = 2 * 60 +# A fixed remote `time` value must still be in the future after ordinary +# normal-channel CLI latency. Two minutes remains inside the ten-minute drift +# policy and, unlike `clock sync`, also works when the target began ahead. +TEMP_RADIO_REMOTE_CLOCK_PIN_LEAD_SECONDS = 2 * 60 +# ``meshcli wait_ack`` waits five seconds. Four consecutive waits keep the +# generic source challenge inside the 30-second rehearsal operation bound, +# while accepting an ACK that arrives behind unrelated event traffic. +TEMP_RADIO_PREFLIGHT_ACK_WAITS = 4 TEMP_RADIO_RETURN_MINUTES = 1 TEMP_RADIO_RETURN_MARGIN_SECONDS = 15 SHARED_SOURCE_NORMAL_TIMEOUT_SECONDS = 30 @@ -79,6 +122,10 @@ INSTALL_TARGET_WINDOW_MINUTES = 3 # when the exact installed identity replies. DEFAULT_POST_INSTALL_READY_WAIT_SECONDS = 300 POST_INSTALL_READY_PROBE_INTERVAL_SECONDS = 10 +# In-place detools patch generation is CPU-bound and belongs on a workstation +# or build VM. Keep the explicit preparation workflow bounded; Pi-class radio +# hosts should serve an already-built, hash-verified package instead. +DEFAULT_PACKAGE_BUILD_TIMEOUT_SECONDS = 60 * 60 COMPANION_TERMINAL_START = "+++MESHCORE-TERM-START" COMPANION_TERMINAL_STOP = "+++MESHCORE-TERM-STOP" # Firmware may hold the apply reboot for up to 15 seconds while its reply @@ -222,6 +269,36 @@ class RelayTimingSettings: txdelay: float +@dataclass(frozen=True) +class RemoteClockSample: + """One authenticated remote epoch with complete host-time uncertainty.""" + + epoch: int + monotonic_started_at: float + monotonic_completed_at: float + host_started_at: float + host_completed_at: float + + @property + def round_trip_seconds(self) -> float: + return self.monotonic_completed_at - self.monotonic_started_at + + def projected_host_bounds(self, remote_epoch: int) -> tuple[float, float]: + """Map a remote epoch to every possible host-monotonic instant. + + The binary reply does not expose where inside the request/response RTT + the sampled second was read. If ``epoch`` occurred at any instant in + that complete interval, a later remote epoch can occur at the same + offset from either endpoint. Keeping both endpoints prevents a slow + hop from starting early or expiring late outside cleanup ownership. + """ + delta = remote_epoch - self.epoch + return ( + self.monotonic_started_at + delta, + self.monotonic_completed_at + delta, + ) + + @dataclass(frozen=True) class RxpsSettings: enabled: bool @@ -242,7 +319,21 @@ def prompt_after_transmission_failure( error: TransmissionError, timeout: int = TRANSMISSION_PROMPT_SECONDS, ) -> bool: - """Return True to continue; timeout and blank input deliberately continue.""" + """Return True to continue an interactive replay-safe retry cycle. + + A non-interactive process has nobody who can make the safety decision, so + it stops after the finite automatic retry cycle. Interactive timeout and + blank input deliberately keep the existing persistent behavior. + """ + if not sys.stdin.isatty(): + print( + f"\n[transmission] {label} is still failing after " + f"{TRANSMISSION_RETRY_LIMIT} retries or " + f"{TRANSMISSION_RETRY_WINDOW_SECONDS} seconds: {error}\n" + "Stop or continue? stopping (non-interactive)." + ) + return False + prompt = ( f"\n[transmission] {label} is still failing after " f"{TRANSMISSION_RETRY_LIMIT} retries or " @@ -251,11 +342,6 @@ def prompt_after_transmission_failure( ) print(prompt, end="", flush=True) - if not sys.stdin.isatty(): - time.sleep(timeout) - print("continue") - return True - answer = "" if os.name == "nt": import msvcrt @@ -327,6 +413,85 @@ def retry_transmission(action: Callable[[], T], label: str) -> T: retries = 0 +def retry_transmission_bounded(action: Callable[[], T], label: str) -> T: + """Retry a replay-safe proof without an operator continuation loop.""" + cycle_started = time.monotonic() + retries = 0 + while True: + try: + return action() + except TransmissionError as exc: + elapsed = time.monotonic() - cycle_started + if ( + retries >= TRANSMISSION_RETRY_LIMIT + or elapsed >= TRANSMISSION_RETRY_WINDOW_SECONDS + ): + raise TransmissionError( + f"{label} failed after {retries} retries and " + f"{elapsed:.1f} seconds: {exc}" + ) from exc + retries += 1 + print( + f"[transmission] {label} failed; bounded retry " + f"{retries}/{TRANSMISSION_RETRY_LIMIT}: {exc}" + ) + time.sleep(transmission_retry_delay(retries)) + + +def retry_transmission_before_deadline( + action: Callable[[], T], + label: str, + deadline: float, + *, + retry_limit: int = TRANSMISSION_RETRY_LIMIT, + deadline_label: str = "three-minute rehearsal lease", +) -> T: + """Retry a replay-safe proof within one named absolute deadline.""" + if retry_limit < 0: + raise ValueError("retry_limit must be non-negative") + retries = 0 + last_error: TransmissionError | None = None + while True: + remaining = deadline - time.monotonic() + if remaining <= 0: + detail = f": {last_error}" if last_error is not None else "" + raise TransmissionError( + f"{label} exceeded the {deadline_label}{detail}" + ) from last_error + try: + result = action() + except TransmissionError as exc: + last_error = exc + if retries >= retry_limit: + raise TransmissionError( + f"{label} failed after {retries} retries: {exc}" + ) from exc + retries += 1 + remaining = deadline - time.monotonic() + if remaining <= 0: + raise TransmissionError( + f"{label} exceeded the {deadline_label}: {exc}" + ) from exc + delay = min(transmission_retry_delay(retries), remaining) + if delay >= remaining: + raise TransmissionError( + f"{label} cannot retry before the {deadline_label} " + f"expires: {exc}" + ) from exc + print( + f"[transmission] {label} failed; deadline-bounded retry " + f"{retries}/{retry_limit}: {exc}" + ) + time.sleep(delay) + else: + if time.monotonic() >= deadline: + raise TransmissionError( + f"{label} completed at or after the {deadline_label} " + "expired" + ) + return result + + def transmission_retry_delay(retry_number: int) -> int: """Return a bounded exponential delay for consecutive RF failures.""" exponent = max(0, retry_number - 1) @@ -441,6 +606,30 @@ def lora_airtime_seconds( return (preamble_symbols + 4.25 + payload_symbols) * symbol_seconds +def remote_cli_mutation_drain_seconds(radio: RadioSettings) -> float: + """Conservative lifetime of an ambiguously queued remote CLI mutation. + + A host-side ``trywait_msg`` timeout does not cancel the controller + firmware's outbound packet or its type-2 text retry sequence. Use the + firmware's hard maxima rather than the host timeout: 21 direct retries, + maximum configurable retry/CAD/duty delays, one radio-driver retry per + transmission, and a full 16-packet companion queue ahead of the command. + CR8 is deliberate because adaptive direct retries may be slower than the + saved tuple's coding rate. This bound is only paid after an ambiguous + state-changing command; an exact application reply cancels the risk. + """ + packet_airtime = lora_airtime_seconds( + 184, + radio.bandwidth, + radio.spreading_factor, + 8, + ) + # The derivation's exact upper estimate is roughly 6,000 + 1,250 packet + # airtimes. Round both terms upward so scheduler, serial, and clock + # quantization cannot make cleanup return on the boundary. + return 7200.0 + 1500.0 * packet_airtime + + def ota_path_transmissions(args: argparse.Namespace) -> int: # The source always transmits once; every explicitly managed relay adds # one forwarding transmission to the response train. @@ -1025,10 +1214,18 @@ def load_base_image(path: Path, target: TargetInfo) -> EndFInfo: def redact_text(value: str, sensitive_values: tuple[str, ...] = ()) -> str: - for sensitive in sensitive_values: - if sensitive: - value = value.replace(sensitive, "") - return value + sensitive = sorted( + {item for item in sensitive_values if item}, + key=len, + reverse=True, + ) + if not sensitive: + return value + # Replace all secrets in one pass. Sequential str.replace() calls can + # expose the suffix of a longer password when another password is its + # prefix, and can accidentally rewrite the redaction marker itself. + pattern = re.compile("|".join(re.escape(item) for item in sensitive)) + return pattern.sub("", value) def _debug_text( @@ -1215,7 +1412,11 @@ def prepare_package( command.extend(["--inplace-memory", inplace_memory]) if args.sign_key: command.extend(["--sign", str(args.sign_key.resolve())]) - result = run_checked(command, label="build mOTA", timeout=600) + result = run_checked( + command, + label="build mOTA", + timeout=args.package_build_timeout, + ) print(result.stdout.strip()) selected = parse_mota( read_bounded_file(output, MAX_ARCHIVE_MEMBER_SIZE, "mOTA file"), output @@ -1432,6 +1633,17 @@ class PersistentMeshcliSession: rb"(?:^|\r?\n)" + re.escape(marker.encode("ascii")) + rb"\r?\n" ) + def _redacted_pending_tail( + self, + sensitive_values: tuple[str, ...], + limit: int = 4096, + ) -> str: + # Redact before truncating. If a password crosses the tail boundary, + # truncating first would leave an unrecognizable (and printable) + # password suffix in debug output or an exception. + detail = bytes(self.pending).decode("utf-8", "replace") + return redact_text(detail, sensitive_values)[-limit:] + def _read_frame( self, start: str, @@ -1454,13 +1666,13 @@ class PersistentMeshcliSession: del self.pending[:-64 * 1024] if len(self.pending) > 8 * 1024 * 1024: + self.close() raise OtaError("persistent meshcli output exceeded 8 MiB") remaining = deadline - time.monotonic() if remaining <= 0: debug_stream( "persistent meshcli pending output", - self.pending[-4096:], - sensitive_values, + self._redacted_pending_tail(sensitive_values), ) self.close() raise OtaError("persistent meshcli command timed out") @@ -1468,11 +1680,8 @@ class PersistentMeshcliSession: chunk = self.output_queue.get(timeout=min(remaining, 1.0)) except queue.Empty: if self.process is not None and self.process.poll() is not None: - detail = bytes(self.pending[-4096:]).decode("utf-8", "replace") - debug_stream( - "persistent meshcli final output", detail, - sensitive_values, - ) + detail = self._redacted_pending_tail(sensitive_values) + debug_stream("persistent meshcli final output", detail) self.close() raise OtaError( "persistent meshcli session exited" @@ -1480,11 +1689,8 @@ class PersistentMeshcliSession: ) continue if chunk is None: - detail = bytes(self.pending[-4096:]).decode("utf-8", "replace") - debug_stream( - "persistent meshcli final output", detail, - sensitive_values, - ) + detail = self._redacted_pending_tail(sensitive_values) + debug_stream("persistent meshcli final output", detail) self.close() raise OtaError( "persistent meshcli session closed" @@ -1512,7 +1718,10 @@ class PersistentMeshcliSession: self.process.stdin.flush() except (BrokenPipeError, OSError) as exc: self.close() - raise OtaError(f"persistent meshcli input failed: {exc}") from exc + detail = redact_text(str(exc), sensitive_values) + raise OtaError( + f"persistent meshcli input failed: {detail}" + ) from exc output = self._read_frame( start_marker, end_marker, timeout, sensitive_values ) @@ -1606,7 +1815,10 @@ class Controller: self._authenticated_targets.add(target) def _execute( - self, commands: list[str], label: str + self, + commands: list[str], + label: str, + timeout: float | None = None, ) -> subprocess.CompletedProcess[str]: # Keep admin passwords out of the child process command line. meshcli's # script parser uses POSIX shlex on every platform, so shlex.join gives @@ -1637,10 +1849,14 @@ class Controller: print(f"[debug] meshcli script: {script_path}") if os.name != "nt": script_path.chmod(0o600) - timeout = max(90, self.reply_timeout + 60) + operation_timeout = ( + max(90, self.reply_timeout + 60) + if timeout is None + else max(1.0, timeout) + ) if self._meshcli_session is not None: stdout = self._meshcli_session.run_script( - script_path, frame_start, frame_end, timeout, + script_path, frame_start, frame_end, operation_timeout, sensitive_values, ) result = subprocess.CompletedProcess( @@ -1654,7 +1870,7 @@ class Controller: result = run_checked( command, label=label, - timeout=timeout, + timeout=operation_timeout, sensitive_values=sensitive_values, ) finally: @@ -1674,17 +1890,34 @@ class Controller: ) return OtaError(f"{label} returned no JSON: {detail or 'no output'}") - def _run(self, commands: list[str], label: str) -> list[dict]: - result = self._execute(commands, label) + def _run( + self, + commands: list[str], + label: str, + timeout: float | None = None, + ) -> list[dict]: + result = ( + self._execute(commands, label) + if timeout is None + else self._execute(commands, label, timeout) + ) objects = json_objects(result.stdout) if not objects: raise self._no_json_error(result, label) return objects def _run_marked( - self, commands: list[str], label: str, marker: str + self, + commands: list[str], + label: str, + marker: str, + timeout: float | None = None, ) -> tuple[list[dict], list[dict]]: - result = self._execute(commands, label) + result = ( + self._execute(commands, label) + if timeout is None + else self._execute(commands, label, timeout) + ) before, found, after = result.stdout.partition(marker) if not found: detail = "\n".join( @@ -1695,8 +1928,14 @@ class Controller: ) return json_objects(before + after), json_objects(after) - def get_radio(self) -> RadioSettings: - objects = self._run(["get", "radio"], "read controller radio") + def get_radio(self, timeout: float | None = None) -> RadioSettings: + objects = ( + self._run(["get", "radio"], "read controller radio") + if timeout is None + else self._run( + ["get", "radio"], "read controller radio", timeout + ) + ) for value in reversed(objects): if all(key in value for key in ( "radio_freq", "radio_bw", "radio_sf", "radio_cr" @@ -1710,8 +1949,12 @@ class Controller: ) raise OtaError("meshcli did not return the controller radio settings") - def get_public_key(self) -> str: - objects = self._run(["infos"], "read controller identity") + def get_public_key(self, timeout: float | None = None) -> str: + objects = ( + self._run(["infos"], "read controller identity") + if timeout is None + else self._run(["infos"], "read controller identity", timeout) + ) for value in reversed(objects): public_key = value.get("public_key") if ( @@ -1721,6 +1964,186 @@ class Controller: return public_key.lower() raise OtaError("meshcli did not return the controller public key") + def get_clock(self, timeout: float | None = None) -> int: + objects = ( + self._run(["clock"], "read controller clock") + if timeout is None + else self._run( + ["clock"], "read controller clock", timeout + ) + ) + epochs = [ + value["time"] + for value in objects + if isinstance(value.get("time"), int) + and not isinstance(value.get("time"), bool) + ] + if len(epochs) != 1 or not 0 < epochs[0] <= 0xFFFFFFFF: + raise OtaError( + "meshcli did not return one unambiguous controller epoch" + ) + return int(epochs[0]) + + def sync_clock_forward(self, timeout: float | None = None) -> None: + objects = ( + self._run(["clock", "sync"], "advance controller clock") + if timeout is None + else self._run( + ["clock", "sync"], "advance controller clock", timeout + ) + ) + errors = [value.get("error") for value in objects if "error" in value] + if errors: + raise OtaError( + "meshcli could not advance the controller clock: " + + "; ".join(str(value) for value in errors) + ) + if not any("ok" in value for value in objects): + raise OtaError( + "meshcli returned no acknowledgement for controller clock sync" + ) + + def get_contact_clock( + self, + target: str, + expected_public_key: str, + *, + timeout: float | None = None, + ) -> int: + """Read one contact's authenticated identity and binary clock reply. + + meshcore-cli 1.6 emits ``req_clock`` as one raw decimal line even in + JSON mode. Frame it with a random marker, bind the selected contact to + its complete already-verified key, and reject extra decimal lines so + unrelated asynchronous output can never choose a schedule epoch. + """ + marker = f"MESHCORE_OTA_CLOCK_{secrets.token_hex(16)}" + commands = [ + "echo", marker, + "contact_info", target, + "req_clock", target, + ] + result = ( + self._execute(commands, f"read clock from {target}") + if timeout is None + else self._execute( + commands, f"read clock from {target}", timeout + ) + ) + _before, found, after = result.stdout.partition(marker) + if not found: + raise TransmissionError( + f"meshcli did not reach the clock marker for {target}" + ) + contact_keys = [ + value["public_key"].lower() + for value in json_objects(after) + if value.get("adv_name") == target + and isinstance(value.get("public_key"), str) + and re.fullmatch(r"[0-9A-Fa-f]{64}", value["public_key"]) + ] + if contact_keys != [expected_public_key.lower()]: + raise OtaError( + f"clock query did not expose one exact contact identity for " + f"{target}" + ) + clock_lines = [ + line.strip() + for line in after.replace("\r", "").split("\n") + if re.fullmatch(r"[0-9]{1,10}", line.strip()) + ] + if len(clock_lines) != 1: + raise TransmissionError( + f"clock query for {target} returned {len(clock_lines)} " + "unambiguous epoch values" + ) + epoch = int(clock_lines[0]) + if epoch <= 0 or epoch > 0xFFFFFFFF: + raise OtaError(f"clock query for {target} returned invalid {epoch}") + return epoch + + def prove_contact_ack( + self, + target: str, + expected_public_key: str, + label: str, + *, + timeout: float | None = None, + ) -> None: + """Prove a contact can receive and ACK on the controller's tuple. + + A direct-message ACK works for Companion, repeater, and room-server + roles without changing telemetry access or requiring remote-admin + support. The random, fixed-alphabet probe cannot be interpreted as a + CLI command. Both the contact's complete key and the message-specific + ACK code must match inside one marked meshcli frame. + """ + marker = f"MESHCORE_OTA_ACK_{secrets.token_hex(16)}" + challenge = f"mOTA-preflight-{secrets.token_hex(8)}" + commands = [ + "echo", marker, + "contact_info", target, + "msg", target, challenge, + *("wait_ack" for _ in range(TEMP_RADIO_PREFLIGHT_ACK_WAITS)), + ] + try: + if timeout is None: + _objects, post_objects = self._run_marked( + commands, label, marker + ) + else: + _objects, post_objects = self._run_marked( + commands, label, marker, timeout + ) + except TransmissionError: + raise + except OtaError as exc: + raise TransmissionError( + f"meshcli could not run the on-air challenge for {target}: {exc}" + ) from exc + contact_keys = [ + value["public_key"].lower() + for value in post_objects + if value.get("adv_name") == target + and isinstance(value.get("public_key"), str) + and re.fullmatch(r"[0-9A-Fa-f]{64}", value["public_key"]) + ] + if len(contact_keys) != 1: + if any( + value.get("error") in ("contact unknown", "unknown destination") + for value in post_objects + ): + raise OtaError(f"controller has no contact named {target!r}") + raise OtaError( + f"{label} did not expose one exact contact identity for {target}" + ) + if contact_keys[0] != expected_public_key.lower(): + raise OtaError( + f"{label} contact key mismatch for {target}: expected " + f"{expected_public_key.lower()}, got {contact_keys[0]}" + ) + expected_codes = [ + value["expected_ack"].lower() + for value in post_objects + if isinstance(value.get("expected_ack"), str) + and re.fullmatch(r"[0-9A-Fa-f]{8}", value["expected_ack"]) + ] + if len(expected_codes) != 1: + raise TransmissionError( + f"{label} did not return one message-specific ACK challenge" + ) + acknowledgements = { + value["code"].lower() + for value in post_objects + if isinstance(value.get("code"), str) + and re.fullmatch(r"[0-9A-Fa-f]{8}", value["code"]) + } + if expected_codes[0] not in acknowledgements: + raise TransmissionError( + f"{label} received no matching ACK from {target}" + ) + print(f"[preflight] {target} returned the exact on-air ACK challenge") + def get_firmware_version(self) -> tuple[str, int]: objects = self._run(["ver"], "read controller firmware version") for value in reversed(objects): @@ -1732,11 +2155,30 @@ class Controller: return version_text, version raise OtaError("meshcli did not return the controller firmware version") - def set_radio(self, settings: RadioSettings, label: str) -> None: + def set_radio( + self, + settings: RadioSettings, + label: str, + timeout: float | None = None, + ) -> None: + started_at = time.monotonic() + + def remaining_timeout() -> float | None: + if timeout is None: + return None + remaining = timeout - (time.monotonic() - started_at) + if remaining <= 0: + raise OtaError(f"{label} exceeded its {timeout:.1f}s timeout") + return remaining + command_error: OtaError | None = None try: - objects = self._run( - ["set", "radio", settings.meshcli_value()], label + commands = ["set", "radio", settings.meshcli_value()] + mutation_timeout = remaining_timeout() + objects = ( + self._run(commands, label) + if mutation_timeout is None + else self._run(commands, label, mutation_timeout) ) for value in objects: if "error" in value or "error_code" in value: @@ -1746,7 +2188,12 @@ class Controller: command_error = exc try: - actual = self.get_radio() + verify_timeout = remaining_timeout() + actual = ( + self.get_radio() + if verify_timeout is None + else self.get_radio(verify_timeout) + ) except OtaError as verify_error: if command_error is not None: raise OtaError( @@ -1766,6 +2213,7 @@ class Controller: command_text: str, password: str, reply_timeout: int | None = None, + operation_timeout: float | None = None, ) -> str: command_reply_timeout = ( self.reply_timeout if reply_timeout is None else reply_timeout @@ -1785,11 +2233,19 @@ class Controller: "sync_msgs", ]) try: - objects, post_objects = self._run_marked( - commands, - f"remote command on {target}", - marker, - ) + if operation_timeout is None: + objects, post_objects = self._run_marked( + commands, + f"remote command on {target}", + marker, + ) + else: + objects, post_objects = self._run_marked( + commands, + f"remote command on {target}", + marker, + operation_timeout, + ) except TransmissionError: raise except OtaError as exc: @@ -1801,15 +2257,6 @@ class Controller: for item in objects ): raise OtaError(f"controller has no contact named {target!r}") - login_results = [item for item in objects if "login_success" in item] - if login_required: - if login_results and not login_results[-1].get("login_success"): - raise OtaError(f"admin login failed for {target}") - if not login_results: - raise TransmissionError(f"no admin-login result from {target}") - self._cache_remote_auth(target) - print(f"[auth] remote admin session established for {target}") - target_key = None for item in objects: if ( @@ -1829,6 +2276,26 @@ class Controller: and target_key.startswith(item["pubkey_prefix"].lower()) and reply_matches_command(command_text, item["text"]) ] + login_results = [item for item in objects if "login_success" in item] + if login_required: + if login_results and not login_results[-1].get("login_success"): + raise OtaError(f"admin login failed for {target}") + if not login_results and not messages: + raise TransmissionError(f"no admin-login result from {target}") + self._cache_remote_auth(target) + if login_results: + print(f"[auth] remote admin session established for {target}") + else: + # A Companion can retain a valid repeater-admin session across + # host meshcli processes. In that state a redundant login may + # time out even though the immediately following authenticated + # command succeeds. A command-matched private reply, filtered + # to the selected contact key above, is stronger evidence than + # the lost login acknowledgement and is safe to cache. + print( + f"[auth] {target} accepted the authenticated command; " + "login acknowledgement was not repeated" + ) if not messages: raise TransmissionError( f"no matching CLI reply from {target} for {command_text!r}; " @@ -1847,17 +2314,24 @@ class Controller: password: str | None = None, retry: bool = True, reply_timeout: int | None = None, + operation_timeout: float | None = None, ) -> str: login_password = self.password if password is None else password normalized = command_text.strip().lower() if retry and ( - normalized == "ota install" or normalized.startswith("ota pull ") + normalized == "ota install" + or normalized.startswith("ota pull ") + or normalized.startswith("tempradio ") ): raise OtaError( f"{command_text!r} requires state-aware retry handling" ) action = lambda: self._remote_command_once( - target, command_text, login_password, reply_timeout + target, + command_text, + login_password, + reply_timeout, + operation_timeout, ) if retry: return retry_transmission( @@ -1966,15 +2440,21 @@ def query_target( current_version = None current_version_source = None try: - stats = controller.remote_command(args.target, "ota stats") + stats = retry_transmission_bounded( + lambda: controller.remote_command( + args.target, "ota stats", retry=False + ), + f"optional `ota stats` version probe on {args.target}", + ) version_match = re.search(r"\bfw (v\d+\.\d+\.\d+(?:\.\d+)?)\b", stats) if version_match: current_version = version_match.group(1) current_version_source = "ota stats" - except TransmissionStopped: - raise except OtaError as exc: - print(f"[warn] could not query current OTA version: {exc}") + print( + f"[warn] optional `ota stats` version probe failed; " + f"falling back directly to `ver`: {exc}" + ) if current_version is None: version_reply = controller.remote_command(args.target, "ver") version_value = extract_reply_version(version_reply) @@ -2024,7 +2504,19 @@ def parse_temp_radio(value: str) -> tuple[float, float, int, int, int]: return freq, bandwidth, sf, cr, minutes -def source_cli_command(args: argparse.Namespace, command_text: str, check: bool = True) -> str: +def source_cli_command( + args: argparse.Namespace, + command_text: str, + check: bool = True, + *, + bounded: bool = False, + deadline: float | None = None, + retry: bool = True, +) -> str: + if retry and command_text.strip().lower().startswith("tempradio "): + raise OtaError( + f"{command_text!r} requires state-aware retry handling" + ) serial_port = args.source_cli_serial or args.source_serial tcp_console = args.source_cli_tcp if not serial_port and not tcp_console: @@ -2035,13 +2527,34 @@ def source_cli_command(args: argparse.Namespace, command_text: str, check: bool return "" def run_once() -> str: + operation_timeout = 30.0 + if deadline is not None: + remaining = deadline - time.monotonic() + if remaining <= 0: + raise TransmissionError( + f"source command {command_text!r} exceeded the " + "three-minute rehearsal lease" + ) + operation_timeout = min(operation_timeout, remaining) if tcp_console: host, port = split_host_port(tcp_console, 5002) + socket_deadline = time.monotonic() + operation_timeout + + def remaining_socket_timeout() -> float: + remaining = socket_deadline - time.monotonic() + if remaining <= 0: + raise TimeoutError( + f"source TCP command {command_text!r} timed out" + ) + return min(10.0, remaining) + try: - with socket.create_connection((host, port), timeout=10) as connection: - connection.settimeout(10) + with socket.create_connection( + (host, port), timeout=remaining_socket_timeout() + ) as connection: greeting = bytearray() while b"\r\n> " not in greeting and len(greeting) < 4096: + connection.settimeout(remaining_socket_timeout()) chunk = connection.recv(512) if not chunk: break @@ -2049,12 +2562,34 @@ def source_cli_command(args: argparse.Namespace, command_text: str, check: bool connection.sendall(command_text.encode("utf-8") + b"\r\n") response = bytearray() while b"\r\n> " not in response and len(response) < 4096: + connection.settimeout(remaining_socket_timeout()) chunk = connection.recv(512) if not chunk: break response.extend(chunk) except (OSError, UnicodeError) as exc: raise TransmissionError(f"source TCP console failed: {exc}") from exc + expected_terminal_key = getattr( + args, "shared_source_public_key", None + ) + if expected_terminal_key is not None: + greeting_text = greeting.decode("utf-8", "replace") + banner_keys = re.findall( + r"(?:^|\r?\n)([0-9A-Fa-f]{64})(?=\r?\n)", + greeting_text, + ) + if len(banner_keys) != 1: + raise OtaError( + "shared Full Companion terminal banner did not expose " + "one exact public key" + ) + actual_terminal_key = banner_keys[0].lower() + if actual_terminal_key != str(expected_terminal_key).lower(): + raise OtaError( + "shared Full Companion terminal identity mismatch: " + f"expected {str(expected_terminal_key).lower()}, got " + f"{actual_terminal_key}" + ) text = response.decode("utf-8", "replace") # Legacy port 5002 prefixes its bounded OTA reply with `->`. # Full Companion now exposes the same terminal as USB and writes @@ -2096,7 +2631,7 @@ def source_cli_command(args: argparse.Namespace, command_text: str, check: bool result = run_checked( command, label=f"source command {command_text.split()[0]}", - timeout=30, + timeout=operation_timeout, ) except OtaError as exc: raise TransmissionError(f"source CLI link failed: {exc}") from exc @@ -2116,13 +2651,66 @@ def source_cli_command(args: argparse.Namespace, command_text: str, check: bool return output if check: - return retry_transmission(run_once, f"{command_text!r} on OTA source") + label = f"{command_text!r} on OTA source" + if not retry: + return run_once() + if deadline is not None: + return retry_transmission_before_deadline(run_once, label, deadline) + retry = retry_transmission_bounded if bounded else retry_transmission + return retry(run_once, label) try: return run_once() except OtaError: return "" +def source_cli_reply_is_unsupported(value: str) -> bool: + """Recognize only an explicit legacy/unsupported-command response.""" + lowered = value.lower() + return any( + marker in lowered + for marker in ( + "unknown command", + "command not found", + "unknown setting", + "not supported", + "unsupported", + ) + ) + + +def optional_source_cli_command( + args: argparse.Namespace, + command_text: str, + *, + deadline: float | None = None, +) -> str | None: + """Run an optional source command without hiding transport failures. + + ``None`` means that the source explicitly rejected the command as a legacy + or unsupported operation. A silent reply, timeout, broken serial/TCP link, + or any other rejection is unsafe and remains an error. + """ + try: + reply = source_cli_command( + args, command_text, bounded=True, deadline=deadline + ) + except TransmissionError: + raise + except OtaError as exc: + if source_cli_reply_is_unsupported(str(exc)): + return None + raise + if source_cli_reply_is_unsupported(reply): + return None + if not reply.strip(): + raise OtaError( + f"OTA source returned an empty reply to optional command " + f"{command_text!r}" + ) + return reply + + def preflight_source_cli(args: argparse.Namespace) -> None: if not (args.source_serial or args.source_cli_serial or args.source_cli_tcp): return @@ -2163,6 +2751,225 @@ def has_managed_source_cli(args: argparse.Namespace) -> bool: ) +def ensure_controller_clock_safe( + controller: Controller, + *, + timeout: float = TEMP_RADIO_PREFLIGHT_OPERATION_TIMEOUT_SECONDS, +) -> int: + """Advance a stale controller clock without ever moving it backward. + + Remote login and CLI replay guards authenticate the companion's packet + timestamp. Radio reachability therefore cannot be tested safely until the + local controller is on a credible clock. The firmware's binary set-time + command rejects backward changes; readback proves that property and catches + a transport reporting success for the wrong endpoint. + """ + host_before = int(time.time()) + before = controller.get_clock(timeout=timeout) + lead = before - host_before + if lead > TEMP_RADIO_CLOCK_MAX_AHEAD_SECONDS: + raise OtaError( + "controller clock is " + f"{lead}s ahead of this host (maximum allowed is " + f"{TEMP_RADIO_CLOCK_MAX_AHEAD_SECONDS}s); refusing to move it " + "backward or send replay-sensitive LoRa commands" + ) + + if before < host_before: + controller.sync_clock_forward(timeout=timeout) + after = controller.get_clock(timeout=timeout) + host_after = int(time.time()) + if after < before: + raise OtaError( + "controller clock moved backward during forward-only sync" + ) + if after < host_before: + raise OtaError( + "controller clock sync did not advance to the host clock" + ) + if after - host_after > TEMP_RADIO_CLOCK_MAX_AHEAD_SECONDS: + raise OtaError( + "controller clock readback is more than ten minutes ahead of " + "this host" + ) + print( + f"[clock] advanced controller from {before} to {after}; exact " + "readback passed" + ) + return after + + if lead > 0: + print( + f"[clock] controller is {lead}s ahead of this host; preserving " + "its monotonic clock" + ) + else: + print(f"[clock] controller epoch {before} matches this host") + return before + + +def parse_source_clock_window(reply: str, label: str) -> tuple[int, int]: + """Parse the minute-resolution UTC clock exposed by text CLIs.""" + matches = re.findall( + r"(? tuple[int, int]: + reply = source_cli_command(args, "clock", bounded=True) + return parse_source_clock_window(reply, "OTA source") + + +def ensure_source_clock_safe( + args: argparse.Namespace, +) -> tuple[int, int]: + """Forward-sync a managed text-CLI source and verify minute readback. + + The terminal does not reveal seconds. A displayed minute which contains + the host epoch is therefore neither known-behind nor known-ahead and must + not receive an exact-host ``time`` write: by command arrival, that value + can already be backward. Only a source whose *entire* minute window is + behind is advanced, using a guarded future value and one readback. + """ + host_before = int(time.time()) + before_start, before_end = read_source_clock_window(args) + # The terminal exposes only a minute. Use the end of that uncertainty + # window: accepting solely from the displayed minute's first second could + # silently admit a clock up to 59 seconds beyond the ten-minute limit. + if before_end - host_before > TEMP_RADIO_CLOCK_MAX_AHEAD_SECONDS: + raise OtaError( + "OTA source clock cannot be proven within ten minutes of this " + "host at minute resolution; refusing to move it backward" + ) + + # Advance only when every possible second represented by the terminal is + # behind the host sample. If the windows overlap, preserve the source: an + # exact-host request could be backward even before it reaches firmware. + if before_end < host_before: + request_sample = int(time.time()) + guarded_minimum = ( + max(host_before, request_sample, before_end) + + TEMP_RADIO_SOURCE_CLOCK_PIN_LEAD_SECONDS + ) + # Ask for a whole-minute boundary. A subsequent minute-only readback + # can then prove the write by its lower bound. With an arbitrary + # seconds value, a display of the same minute would not prove whether + # firmware reached the requested second. + requested = ((guarded_minimum + 59) // 60) * 60 + if ( + requested + 59 - request_sample + > TEMP_RADIO_CLOCK_MAX_AHEAD_SECONDS + ): + raise OtaError( + "safe OTA source forward-clock request would exceed the " + "ten-minute drift policy" + ) + reply_lost = False + backward_rejected = False + try: + reply = source_cli_command( + args, + f"time {requested}", + bounded=True, + retry=False, + ) + except TransmissionError as exc: + reply_lost = True + print( + "[clock] OTA source time acknowledgement was unavailable " + f"({exc}); resolving with readback" + ) + except OtaError as exc: + if "cannot go backwards" not in str(exc).lower(): + raise + backward_rejected = True + print( + "[clock] OTA source rejected the guarded fixed epoch as " + "backward; resolving once with readback" + ) + else: + lowered = reply.lower() + backward_rejected = "cannot go backwards" in lowered + if "clock set" not in lowered and not backward_rejected: + raise OtaError( + "OTA source returned no forward-sync acknowledgement: " + f"{reply or 'empty reply'}" + ) + after_start, after_end = read_source_clock_window(args) + host_after = int(time.time()) + if after_end < before_start: + raise OtaError( + "OTA source clock moved backward during forward-only sync" + ) + if after_start < requested: + raise OtaError( + "OTA source clock readback did not reach the requested " + "guarded epoch" + ) + if after_end - host_after > TEMP_RADIO_CLOCK_MAX_AHEAD_SECONDS: + raise OtaError( + "OTA source clock readback cannot be proven within ten " + "minutes of this host at minute resolution" + ) + if reply_lost: + outcome = "lost acknowledgement; " + elif backward_rejected: + outcome = "backward rejection resolved; " + else: + outcome = "" + print( + f"[clock] {outcome}OTA source forward-sync readback passed " + f"({after_start}-{after_end})" + ) + return after_start, after_end + + if before_start <= host_before <= before_end: + print( + "[clock] OTA source minute contains the host epoch; preserving " + "its monotonic clock without an ambiguous seconds-level write" + ) + else: + print( + f"[clock] OTA source is {before_start - host_before}s ahead at " + "minute resolution; preserving its monotonic clock" + ) + return before_start, before_end + + +def require_shared_clock_agreement( + controller_epoch: int, + source_window: tuple[int, int], +) -> None: + """Bind the shared Binary and terminal views to the same device RTC.""" + lower, upper = source_window + tolerance = TEMP_RADIO_CLOCK_READ_TOLERANCE_SECONDS + if controller_epoch < lower - tolerance or controller_epoch > upper + tolerance: + raise OtaError( + "shared source terminal clock does not agree with the Binary " + f"controller clock: controller={controller_epoch}, " + f"terminal={lower}-{upper}" + ) + + def read_lora_ota_participant_versions( controller: Controller, args: argparse.Namespace, @@ -2197,7 +3004,7 @@ def read_lora_ota_participant_versions( print("[rxps] source version unavailable (--source-already-temp)") else: try: - source_reply = source_cli_command(args, "ver") + source_reply = source_cli_command(args, "ver", bounded=True) source_version = extract_reply_version(source_reply) versions["source"] = source_version if source_version is None: @@ -2211,8 +3018,14 @@ def read_lora_ota_participant_versions( for relay_name, relay_password in getattr(args, "relay_values", []): label = f"relay:{relay_name}" try: - reply = controller.remote_command( - relay_name, "ver", password=relay_password + reply = retry_transmission_bounded( + lambda: controller.remote_command( + relay_name, + "ver", + password=relay_password, + retry=False, + ), + f"optional `ver` probe on relay {relay_name}", ) versions[label] = extract_reply_version(reply) if versions[label] is None: @@ -2261,7 +3074,7 @@ def verify_shared_source_identity( controller_baud=args.controller_baud, reply_timeout=args.reply_timeout, ) - controller_key = controller.get_public_key() + controller_key = controller.get_public_key().lower() if controller.connection == ["-t", source_host, "-p", "5000"]: source_key = controller_key else: @@ -2272,7 +3085,21 @@ def verify_shared_source_identity( "--source-shares-controller identity mismatch: controller is " f"{controller_key}, source host is {source_key}" ) - print(f"[source] verified shared Full Companion {source_key}") + # Port 5002's fresh terminal banner exposes the same complete node key. + # Bind it to the already-proven Binary key, then use supported `ver` as a + # live terminal challenge. Full Companion intentionally does not implement + # the repeater-only `get public.key` command. + args.shared_source_public_key = source_key + version_reply = source_cli_command(args, "ver", bounded=True) + if extract_reply_version(version_reply) is None: + raise OtaError( + "shared Full Companion terminal did not answer supported `ver`: " + f"{version_reply or 'no output'}" + ) + print( + f"[source] verified shared Full Companion Binary and terminal " + f"identity {source_key}" + ) class SeederProcess: @@ -3519,9 +4346,1526 @@ def monitor_download( ) -def require_temp_radio_reply(node: str, reply: str) -> None: - if not reply.lower().startswith("ok - temp params for "): +def parse_temp_radio_reply_minutes(node: str, reply: str) -> int: + """Return the lease length from normalized or raw serial CLI output.""" + matches = re.findall( + r"(?:^|\n)\s*(?:->\s*)?OK\s+-\s+temp params for\s+" + r"(\d+)\s+mins\b", + reply.replace("\r", ""), + re.IGNORECASE, + ) + if not matches: raise OtaError(f"{node} did not accept TempRadio: {reply}") + if len(matches) != 1: + raise OtaError( + f"{node} did not return one exact TempRadio acknowledgement: {reply}" + ) + return int(matches[0]) + + +def require_temp_radio_reply( + node: str, + reply: str, + expected_minutes: int | None = None, +) -> int: + actual_minutes = parse_temp_radio_reply_minutes(node, reply) + if expected_minutes is not None and actual_minutes != expected_minutes: + raise OtaError( + f"{node} acknowledged a {actual_minutes}-minute TempRadio lease; " + f"the reachability rehearsal requires exactly {expected_minutes} minutes" + ) + return actual_minutes + + +def temp_radio_command_minutes(command: str) -> int: + match = re.fullmatch(r"\s*tempradio\s+[^\r\n]*,(\d+)\s*", command) + if match is None: + raise OtaError(f"invalid relative TempRadio command: {command!r}") + return int(match.group(1)) + + +def require_normal_radio_reply(node: str, reply: str) -> None: + matches = re.findall( + r"(?:^|\n)\s*(?:->\s*)?OK\s+-\s+normal radio restore " + r"scheduled\b", + reply.replace("\r", ""), + re.IGNORECASE, + ) + if len(matches) != 1: + raise OtaError( + f"{node} did not accept one exact normal-radio restore: {reply}" + ) + + +def parse_cli_public_key(reply: str, label: str) -> str: + matches = re.findall( + r"(?:^|\n)\s*(?:->\s*)?>\s*([0-9A-Fa-f]{64})\s*(?=$|\n)", + reply.replace("\r", ""), + ) + if len(matches) != 1: + raise OtaError(f"{label} did not return an exact public key: {reply}") + return matches[0].lower() + + +def parse_cli_value(reply: str, label: str) -> str: + """Read one ``get`` value from normalized or raw ``meshcli -r`` output.""" + matches = re.findall( + r"(?:^|\n)\s*(?:->\s*)?>\s*(\S(?:.*?\S)?)\s*(?=$|\n)", + reply.replace("\r", ""), + ) + if len(matches) != 1: + raise OtaError(f"{label} did not return one exact CLI value: {reply}") + return matches[0] + + +def require_no_existing_temp_schedule(reply: str | None, label: str) -> bool: + """Refuse existing work and report whether scheduling is supported.""" + if reply is None: + return False + text = reply.strip() + if not text: + raise OtaError( + f"could not prove {label} has no scheduled TempRadio work: " + "empty reply" + ) + lowered = text.lower() + if ( + lowered.startswith(("unknown command", "command not found")) + or "unknown setting" in lowered + or "unsupported" in lowered + ): + # Older firmware exposes only the bounded immediate `tempradio` + # command. It has no user-managed scheduled-entry table to preserve. + return False + schedule_values = re.findall( + r"(?:^|\n)\s*(?:->\s*)?>\s*(\S(?:.*?\S)?)\s*(?=$|\n)", + text.replace("\r", ""), + ) + if len(schedule_values) == 1 and schedule_values[0].casefold() == "-none-": + return True + if schedule_values: + raise OtaError( + f"{label} already has scheduled TempRadio work; the rehearsal " + f"would replace it: {text}" + ) + raise OtaError( + f"could not prove {label} has no scheduled TempRadio work: {text}" + ) + + +def require_scheduled_temp_radio_reply(node: str, reply: str) -> None: + matches = re.findall( + r"(?:^|\n)\s*(?:->\s*)?OK\s+-\s+tempradioat\s+" + r"\d+\s+in\s+\S+", + reply.replace("\r", ""), + re.IGNORECASE, + ) + if len(matches) != 1: + raise OtaError( + f"{node} did not accept one exact fixed TempRadio schedule: {reply}" + ) + + +def scheduled_temp_radio_command( + args: argparse.Namespace, + start_epoch: int, + end_epoch: int, +) -> str: + if start_epoch <= 0 or end_epoch <= start_epoch or end_epoch > 0xFFFFFFFF: + raise OtaError("invalid fixed TempRadio rehearsal interval") + freq, bandwidth, sf, cr, _configured_minutes = args.temp_values + return ( + f"set tempradioat {format_decimal(freq)}," + f"{format_decimal(bandwidth)},{sf},{cr},{start_epoch},{end_epoch}" + ) + + +def sample_remote_clock( + controller: Controller, + target: str, + expected_public_key: str, + *, + timeout: float, +) -> RemoteClockSample: + """Bracket an authenticated clock read with host monotonic/wall samples.""" + monotonic_started_at = time.monotonic() + host_started_at = time.time() + epoch = controller.get_contact_clock( + target, expected_public_key, timeout=timeout + ) + host_completed_at = time.time() + monotonic_completed_at = time.monotonic() + if monotonic_completed_at < monotonic_started_at: + raise OtaError("host monotonic clock moved backward during remote read") + return RemoteClockSample( + epoch=epoch, + monotonic_started_at=monotonic_started_at, + monotonic_completed_at=monotonic_completed_at, + host_started_at=host_started_at, + host_completed_at=host_completed_at, + ) + + +def require_remote_clock_within_host_policy( + sample: RemoteClockSample, + target: str, +) -> None: + """Reject a remote clock that might be over ten minutes ahead of host.""" + # The returned epoch may have been sampled at the beginning of the complete + # request/response interval. Advance it through the full measured RTT and + # compare it with the earliest wall-clock sample, which is the conservative + # upper lead when the host clock changed during the operation. + remote_completed_upper = sample.epoch + math.ceil( + sample.round_trip_seconds + ) + host_lower = math.floor( + min(sample.host_started_at, sample.host_completed_at) + ) + lead = remote_completed_upper - host_lower + if lead > TEMP_RADIO_CLOCK_MAX_AHEAD_SECONDS: + raise OtaError( + f"{target} clock may be {lead}s ahead of this host after its " + f"{sample.round_trip_seconds:.1f}s clock-read RTT (maximum allowed " + f"is {TEMP_RADIO_CLOCK_MAX_AHEAD_SECONDS}s); refusing a remote " + "clock mutation" + ) + + +def pin_remote_clock_forward( + controller: Controller, + target: str, + expected_public_key: str, + *, + password: str | None = None, +) -> RemoteClockSample: + """Freeze automatic backward mesh sync using a replay-safe fixed epoch. + + Current firmware has a monotonic hard end for scheduled TempRadio. Older + scheduling-capable firmware does not, so a later backward RTC correction + could otherwise postpone a pending window. `time N` is forward-only and + suppresses mesh clock consensus for this boot. The fixed write is sent + exactly once. A missing acknowledgement is reconciled through an + authenticated clock readback, never by adding another per-target lead. + """ + timeout = float(TEMP_RADIO_PREFLIGHT_OPERATION_TIMEOUT_SECONDS) + before = sample_remote_clock( + controller, target, expected_public_key, timeout=timeout + ) + require_remote_clock_within_host_policy(before, target) + + host_now = math.ceil( + max(before.host_started_at, before.host_completed_at, time.time()) + ) + remote_now_upper = before.epoch + math.ceil(before.round_trip_seconds) + # Bring a stale node to one common host-relative lead. If it is already + # farther ahead, advance only one second so repeated rehearsals cannot add + # two minutes on every run merely to disable automatic backward sync. + requested = max( + host_now + TEMP_RADIO_REMOTE_CLOCK_PIN_LEAD_SECONDS, + remote_now_upper + 1, + ) + if requested > 0xFFFFFFFF: + raise OtaError(f"{target} clock is too near the epoch limit to pin") + if requested - host_now > TEMP_RADIO_CLOCK_MAX_AHEAD_SECONDS: + raise OtaError( + f"safe forward-clock request for {target} would be " + f"{requested - host_now}s ahead of this host, beyond the ten-minute " + "drift policy" + ) + + acknowledgement_lost = False + backward_rejected = False + try: + reply = controller.remote_command( + target, + f"time {requested}", + password=password, + retry=False, + reply_timeout=min( + int(getattr(controller, "reply_timeout", 20)), + TEMP_RADIO_PREFLIGHT_OPERATION_TIMEOUT_SECONDS, + ), + operation_timeout=TEMP_RADIO_PREFLIGHT_OPERATION_TIMEOUT_SECONDS, + ) + except TransmissionError as exc: + acknowledgement_lost = True + print( + f"[clock] {target} forward-clock acknowledgement was lost ({exc}); " + "resolving the one-shot fixed write by authenticated readback" + ) + else: + lowered = reply.lower() + backward_rejected = "cannot go backwards" in lowered + if "clock set" not in lowered and not backward_rejected: + raise OtaError( + f"{target} did not acknowledge a forward-only clock pin: " + f"{reply}" + ) + + after = retry_transmission_bounded( + lambda: sample_remote_clock( + controller, target, expected_public_key, timeout=timeout + ), + f"forward-clock readback from {target}", + ) + require_remote_clock_within_host_policy(after, target) + if after.epoch < requested: + outcome = ( + "lost acknowledgement" + if acknowledgement_lost + else "backward rejection" + if backward_rejected + else "acknowledged write" + ) + raise OtaError( + f"{target} {outcome} could not be reconciled: clock readback " + f"{after.epoch} did not reach the one-shot fixed value {requested}; " + "the command was not replayed" + ) + print( + f"[clock] {target} forward-only clock pin verified at {after.epoch} " + f"with {after.round_trip_seconds:.1f}s RTT; automatic backward mesh " + "sync is suppressed for this boot" + ) + return after + + +def bounded_remote_command( + controller: Controller, + target_name: str, + command: str, + *, + password: str | None = None, + deadline: float | None = None, + retry_limit: int = TRANSMISSION_RETRY_LIMIT, + deadline_label: str = "three-minute rehearsal lease", +) -> str: + label = f"{command!r} reachability proof on {target_name}" + + def run_once() -> str: + options: dict[str, object] = { + "password": password, + "retry": False, + } + if deadline is not None: + remaining = deadline - time.monotonic() + if remaining <= 1.0: + raise TransmissionError( + f"{label} has no safe operation window left in the " + f"{deadline_label}" + ) + base_reply_timeout = int(getattr(controller, "reply_timeout", 20)) + options["reply_timeout"] = max( + 1, min(base_reply_timeout, int(max(1.0, remaining))) + ) + options["operation_timeout"] = max( + 1.0, + min( + TEMP_RADIO_PREFLIGHT_OPERATION_TIMEOUT_SECONDS, + remaining, + ), + ) + return controller.remote_command(target_name, command, **options) + + if deadline is not None: + return retry_transmission_before_deadline( + run_once, + label, + deadline, + retry_limit=retry_limit, + deadline_label=deadline_label, + ) + return retry_transmission_bounded(run_once, label) + + +def read_remote_public_key_bounded( + controller: Controller, + target_name: str, + *, + password: str | None = None, + deadline: float | None = None, +) -> str: + reply = bounded_remote_command( + controller, + target_name, + "get public.key", + password=password, + deadline=deadline, + ) + return parse_cli_public_key(reply, target_name) + + +def read_source_public_key_bounded( + args: argparse.Namespace, + *, + deadline: float | None = None, +) -> str: + reply = source_cli_command( + args, + "get public.key", + bounded=True, + deadline=deadline, + ) + return parse_cli_public_key(reply, "OTA source") + + +def prove_shared_source_terminal_bounded( + args: argparse.Namespace, + expected_public_key: str, + *, + deadline: float | None = None, +) -> None: + """Prove the shared Full terminal bound to its verified Binary key.""" + verified_key = getattr(args, "shared_source_public_key", None) + if ( + not isinstance(verified_key, str) + or not re.fullmatch(r"[0-9A-Fa-f]{64}", verified_key) + or verified_key.lower() != expected_public_key.lower() + ): + raise OtaError( + "shared Full Companion terminal is not bound to the already " + "verified Binary Companion public key" + ) + reply = source_cli_command( + args, + "ver", + bounded=True, + deadline=deadline, + ) + if extract_reply_version(reply) is None: + raise OtaError( + "shared Full Companion terminal did not answer supported `ver`: " + f"{reply or 'no output'}" + ) + + +def read_source_name_bounded( + args: argparse.Namespace, + *, + deadline: float | None = None, +) -> str: + reply = source_cli_command( + args, "get name", bounded=True, deadline=deadline + ) + return parse_cli_value(reply, "OTA source name") + + +def require_destination_preflight_identity( + controller: Controller, + args: argparse.Namespace, + target: TargetInfo, + phase: str, + *, + deadline: float | None = None, + retry_limit: int = TRANSMISSION_RETRY_LIMIT, + deadline_label: str = "three-minute rehearsal lease", +) -> None: + status = bounded_remote_command( + controller, + args.target, + "ota status", + deadline=deadline, + retry_limit=retry_limit, + deadline_label=deadline_label, + ) + target_match = re.search(r"\btarget:([0-9A-Fa-f]{8})\b", status) + if target_match is None or int(target_match.group(1), 16) != target.target_id: + raise OtaError( + f"destination {phase} identity did not report target " + f"{target.target_id:08X}: {status}" + ) + if target.hw_id is not None: + hw_match = re.search(r"\bhw=([^ |]+)", status) + if hw_match is None or hw_match.group(1) != target.hw_id: + raise OtaError( + f"destination {phase} identity did not report hardware " + f"{target.hw_id}: {status}" + ) + + identity = bounded_remote_command( + controller, + args.target, + "ota self", + deadline=deadline, + retry_limit=retry_limit, + deadline_label=deadline_label, + ) + hash_match = re.search(r"\bbase_hash=([0-9A-Fa-f]{16})\b", identity) + if ( + hash_match is None + or bytes.fromhex(hash_match.group(1)) != target.base_hash + ): + raise OtaError( + f"destination {phase} identity did not report running body " + f"{target.base_hash.hex().upper()}: {identity}" + ) + + +def parse_source_temp_radio_status( + reply: str, +) -> tuple[str, RadioSettings | None] | None: + """Parse a Full Companion TempRadio status; legacy text CLIs return None.""" + text = reply.replace("\r", "").strip() + if not text: + raise OtaError("OTA source returned an empty TempRadio status") + prefix = r"(?:^|\n)\s*(?:->\s*)?(?:>\s*)?" + suffix = r"\s*(?=$|\n)" + inactive_matches = re.findall( + prefix + r"TempRadio inactive" + suffix, + text, + re.IGNORECASE, + ) + active_matches = re.findall( + prefix + r"TempRadio (active|pending):\s*" + r"([0-9]+(?:\.[0-9]+)?),([0-9]+(?:\.[0-9]+)?)," + r"(\d+),(\d+)(?:\s+\d+s left)?" + suffix, + text, + re.IGNORECASE, + ) + if len(inactive_matches) + len(active_matches) != 1: + raise OtaError(f"OTA source returned an invalid TempRadio status: {reply}") + if inactive_matches: + return "inactive", None + match = active_matches[0] + return ( + match[0].lower(), + RadioSettings( + float(match[1]), + float(match[2]), + int(match[3]), + int(match[4]), + False, + ), + ) + + +def read_source_temp_radio_status( + args: argparse.Namespace, + *, + deadline: float | None = None, +) -> tuple[str, RadioSettings | None] | None: + # Bare `tempradio` is available on Full Companions. Older repeater CLIs + # reject it. Only that explicit rejection maps to None; a transport failure + # or silent reply must never masquerade as a safe legacy source. + reply = optional_source_cli_command(args, "tempradio", deadline=deadline) + return None if reply is None else parse_source_temp_radio_status(reply) + + +def ensure_source_clock_gate_safe( + args: argparse.Namespace, +) -> tuple[int, int] | None: + """Prove a managed source has no TempRadio work, then gate its clock. + + Advancing a stale RTC can make an absolute schedule become due. Inspect + both the immediate-override status and the fixed schedule first, while the + endpoint is still local-only. An explicit legacy rejection proves that the + corresponding scheduled feature does not exist; transport ambiguity never + does. The clock operation itself remains forward-only with exact readback. + """ + if not has_managed_source_cli(args): + return None + source_status = read_source_temp_radio_status(args) + if source_status is not None and source_status[0] != "inactive": + raise OtaError( + "OTA source already has active or pending TempRadio work; its " + f"clock will not be changed: {source_status[0]}" + ) + require_no_existing_temp_schedule( + optional_source_cli_command(args, "get tempradioat"), + "OTA source", + ) + return ensure_source_clock_safe(args) + + +def require_radio_settings( + actual: RadioSettings, + expected: RadioSettings, + label: str, +) -> None: + if not expected.matches(actual): + raise OtaError( + f"{label} tuple mismatch: expected {expected.meshcli_value()}, " + f"got {actual.meshcli_value()}" + ) + + +def run_temp_radio_preflight( + controller: Controller, + args: argparse.Namespace, + target: TargetInfo, + normal_radio: RadioSettings, + temp_radio: RadioSettings, +) -> None: + """Qualify every OTA hop with an independent three-minute lease. + + This rehearsal owns only its short leases and controller handoff. It never + touches the long-window ownership flags used by the transfer cleanup, and + it never starts a seeder, pull, download, or install. Every possibly sent + short lease is allowed to expire before this function returns or raises. + """ + if getattr(args, "source_already_temp", False): + raise OtaError( + "the required three-minute TempRadio rehearsal cannot qualify an " + "unmanaged --source-already-temp source; provide its local CLI" + ) + if not has_managed_source_cli(args): + raise OtaError( + "the required three-minute TempRadio rehearsal needs a managed " + "source CLI so its identity and automatic return can be checked" + ) + same_modulation = ( + abs(normal_radio.frequency - temp_radio.frequency) <= 0.001 + and abs(normal_radio.bandwidth - temp_radio.bandwidth) <= 0.001 + and normal_radio.spreading_factor == temp_radio.spreading_factor + and normal_radio.coding_rate == temp_radio.coding_rate + ) + if same_modulation: + raise OtaError( + "the three-minute rehearsal requires --temp-radio frequency, " + "bandwidth, spreading factor, or coding rate to differ from the " + "normal tuple; otherwise on-air reachability cannot prove that " + "TempRadio was armed" + ) + + shared_controller = bool( + getattr(args, "source_shares_controller", False) + ) + relay_values: list[tuple[str, str]] = list( + getattr(args, "relay_values", []) + ) + remote_nodes: list[tuple[str, str | None]] = [ + (args.target, None), + *((name, password) for name, password in relay_values), + ] + remote_names = [name.casefold() for name, _password in remote_nodes] + if len(set(remote_names)) != len(remote_names): + raise OtaError( + "the destination and controlled --relay contact names must be " + "unique for the three-minute rehearsal" + ) + + # Local source reads are deliberately before any LoRa mutation. A separate + # source must also be a controller contact so the rehearsal proves that its + # antenna/radio—not merely its USB or TCP console—works on both tuples. + require_radio_settings( + controller.get_radio(), normal_radio, "preflight controller normal" + ) + controller_key = controller.get_public_key().lower() + if shared_controller: + # verify_shared_source_identity already bound port 5002's banner to + # this exact port-5000 Binary key. Re-prove the supported terminal + # surface instead of issuing repeater-only `get public.key`. + source_key = controller_key + prove_shared_source_terminal_bounded(args, source_key) + else: + source_key = read_source_public_key_bounded(args) + configured_source_contact = getattr(args, "source_contact_value", None) + source_name = None + if not shared_controller and configured_source_contact is None: + source_name = read_source_name_bounded(args) + if shared_controller: + source_contact: str | None = None + elif configured_source_contact is not None: + source_contact = str(configured_source_contact) + else: + source_contact = str(source_name) + if source_contact is not None and not source_contact.strip(): + raise OtaError( + "the separate OTA source on-air proof needs a non-empty contact " + "name; use --source-contact NAME" + ) + + # No remote packet may leave a stale participant: the destination's replay + # guard will silently reject an old controller timestamp even when RF and + # ACL are healthy. Inspect the source's local TempRadio state before a + # forward clock correction can make absolute work due, then complete both + # clock gates before the first on-air challenge or admin login. + source_clock_window = ensure_source_clock_gate_safe(args) + assert source_clock_window is not None + controller_epoch = ensure_controller_clock_safe(controller) + if shared_controller: + # The terminal sync above can advance the shared RTC after the first + # Binary read. Re-read exact seconds and bind both transport views. + controller_epoch = controller.get_clock( + timeout=float(TEMP_RADIO_PREFLIGHT_OPERATION_TIMEOUT_SECONDS) + ) + require_shared_clock_agreement( + controller_epoch, source_clock_window + ) + + participant_names = [name for name, _password in remote_nodes] + if source_contact is not None: + participant_names.append(source_contact) + normalized_names = [name.casefold() for name in participant_names] + if len(set(normalized_names)) != len(normalized_names): + raise OtaError( + "the destination, separate OTA source, and controlled --relay " + "contact names must be unique for the three-minute rehearsal" + ) + + # Controller.remote_command filters each admin reply to the selected + # contact key. The separate source instead uses a plain-message ACK because + # Full Companions do not expose repeater admin commands; its complete + # contact key plus message-specific ACK still proves the exact identity. + remote_keys = { + name: read_remote_public_key_bounded( + controller, name, password=password + ) + for name, password in remote_nodes + } + if source_contact is None and source_key != controller_key: + raise OtaError( + "shared Full Companion local CLI identity differs from the " + f"controller: source={source_key}, controller={controller_key}" + ) + + def require_remote_schedule_empty( + name: str, + password: str | None, + ) -> None: + schedule_supported = require_no_existing_temp_schedule( + bounded_remote_command( + controller, + name, + "get tempradioat", + password=password, + ), + name, + ) + if not schedule_supported: + raise OtaError( + f"{name} does not support fixed `tempradioat` windows; " + "the mandatory rehearsal will not use an immediate mutation " + "whose controller retry can arrive after cleanup" + ) + + def require_source_temp_work_absent() -> None: + source_status = read_source_temp_radio_status(args) + if source_status is not None and source_status[0] != "inactive": + raise OtaError( + "OTA source acquired active or pending TempRadio work during " + f"the read-only preflight: {source_status[0]}" + ) + require_no_existing_temp_schedule( + optional_source_cli_command(args, "get tempradioat"), + "OTA source", + ) + + for name, password in remote_nodes: + require_remote_schedule_empty(name, password) + # This is still a wholly read-only phase: no fixed schedule, immediate + # TempRadio override, seeder, or transfer has been armed. Give a marginal + # normal link more chances than an operation inside the live three-minute + # lease, while retaining one absolute deadline and exact status/body proof. + normal_baseline_deadline = ( + time.monotonic() + TEMP_RADIO_NORMAL_BASELINE_TIMEOUT_SECONDS + ) + require_destination_preflight_identity( + controller, + args, + target, + "normal-channel baseline", + deadline=normal_baseline_deadline, + retry_limit=TEMP_RADIO_NORMAL_BASELINE_RETRY_LIMIT, + deadline_label="normal-channel baseline proof budget", + ) + + # The larger read-only loss budget may have taken four minutes. Refresh + # every no-work proof now so a schedule introduced during that wait cannot + # be activated by the forward clock pins or overwritten by the rehearsal. + require_source_temp_work_absent() + + command = temp_radio_command_for_minutes( + args, TEMP_RADIO_PREFLIGHT_MINUTES + ) + short_leases_may_be_active = False + controller_may_need_restore = False + natural_expiry_waited = False + normal_proven = False + earliest_lease_expiry: float | None = None + latest_lease_expiry: float | None = None + primary_error: BaseException | None = None + cleanup_errors: list[str] = [] + + def record_possible_lease( + started_at: float, + completed_at: float, + minutes: int = TEMP_RADIO_PREFLIGHT_MINUTES, + ) -> None: + nonlocal earliest_lease_expiry, latest_lease_expiry + # Start is the earliest instant the mutation could have reached the + # node. Completion is the latest instant an acknowledged/retried local + # command could have done so. The two bounds prevent both false temp + # proofs and an early cleanup return. + earliest = started_at + minutes * 60 + latest = completed_at + minutes * 60 + earliest_lease_expiry = ( + earliest + if earliest_lease_expiry is None + else min(earliest_lease_expiry, earliest) + ) + latest_lease_expiry = ( + latest + if latest_lease_expiry is None + else max(latest_lease_expiry, latest) + ) + + def record_fixed_lease( + earliest_expiry: float, + latest_expiry: float, + ) -> None: + nonlocal earliest_lease_expiry, latest_lease_expiry + if latest_expiry < earliest_expiry: + raise OtaError("fixed TempRadio lease bounds are inverted") + earliest_lease_expiry = ( + earliest_expiry + if earliest_lease_expiry is None + else min(earliest_lease_expiry, earliest_expiry) + ) + latest_lease_expiry = ( + latest_expiry + if latest_lease_expiry is None + else max(latest_lease_expiry, latest_expiry) + ) + + def wait_until(deadline: float, label: str) -> None: + seconds = max(0.0, deadline - time.monotonic()) + if seconds > 0: + print(f"[preflight] waiting {seconds:.1f}s for {label}") + time.sleep(seconds) + + def temp_proof_deadline(label: str, reserve: float = 0.0) -> float: + if earliest_lease_expiry is None: + raise OtaError(f"{label}: no three-minute lease was armed") + remaining = earliest_lease_expiry - time.monotonic() + if remaining <= reserve: + raise OtaError( + f"{label} cannot finish before the first three-minute lease " + f"expires ({max(0.0, remaining):.1f}s remain, " + f"{reserve:.1f}s required)" + ) + return earliest_lease_expiry + + def controller_timeout(deadline: float | None = None) -> float: + if deadline is None: + return float(TEMP_RADIO_PREFLIGHT_OPERATION_TIMEOUT_SECONDS) + remaining = deadline - time.monotonic() + if remaining <= 0: + raise OtaError("controller operation exceeded the rehearsal lease") + return min( + float(TEMP_RADIO_PREFLIGHT_OPERATION_TIMEOUT_SECONDS), + remaining, + ) + + def prove_source_on_air( + phase: str, + *, + deadline: float | None = None, + ) -> None: + if source_contact is None: + return + label = f"OTA source {phase} on-air ACK" + + def run_once() -> None: + controller.prove_contact_ack( + source_contact, + source_key, + label, + timeout=controller_timeout(deadline), + ) + + if deadline is None: + retry_transmission_bounded(run_once, label) + else: + retry_transmission_before_deadline(run_once, label, deadline) + + def prove_remote_identities( + phase: str, + *, + deadline: float | None = None, + ) -> None: + for name, password in remote_nodes: + actual = read_remote_public_key_bounded( + controller, + name, + password=password, + deadline=deadline, + ) + if actual != remote_keys[name]: + raise OtaError( + f"{name} {phase} public key changed: expected " + f"{remote_keys[name]}, got {actual}" + ) + prove_source_on_air(phase, deadline=deadline) + require_destination_preflight_identity( + controller, args, target, phase, deadline=deadline + ) + + def prove_source_identity( + phase: str, + *, + deadline: float | None = None, + ) -> None: + if shared_controller: + prove_shared_source_terminal_bounded( + args, source_key, deadline=deadline + ) + return + actual = read_source_public_key_bounded(args, deadline=deadline) + if actual != source_key: + raise OtaError( + f"OTA source {phase} public key changed: expected " + f"{source_key}, got {actual}" + ) + + def prove_controller_identity( + phase: str, + *, + deadline: float | None = None, + ) -> None: + actual = controller.get_public_key( + timeout=controller_timeout(deadline) + ).lower() + if actual != controller_key: + raise OtaError( + f"controller {phase} public key changed: expected " + f"{controller_key}, got {actual}" + ) + + def wait_for_natural_expiry() -> None: + nonlocal natural_expiry_waited + if natural_expiry_waited or not short_leases_may_be_active: + return + if latest_lease_expiry is None: + raise OtaError( + "rehearsal mutation ownership is armed without a lease deadline" + ) + wait_until = latest_lease_expiry + TEMP_RADIO_PREFLIGHT_MARGIN_SECONDS + seconds = max(0.0, wait_until - time.monotonic()) + print( + "[preflight] waiting " + f"{seconds:.1f}s for every independent lease and possible " + "fixed rehearsal window to expire" + ) + while True: + remaining = wait_until - time.monotonic() + if remaining <= 0: + break + # If interrupted, the finally block calls this function again and + # resumes from the same absolute deadline instead of restarting a + # complete three-minute wait. + time.sleep(remaining) + natural_expiry_waited = True + + def restore_controller_if_needed(label: str) -> None: + nonlocal controller_may_need_restore + if shared_controller: + # Its bounded local override never overwrote the Binary saved tuple. + controller_may_need_restore = False + return + if controller_may_need_restore: + controller.set_radio( + normal_radio, + label, + timeout=controller_timeout(), + ) + else: + actual = controller.get_radio(timeout=controller_timeout()) + if not normal_radio.matches(actual): + controller.set_radio( + normal_radio, + label, + timeout=controller_timeout(), + ) + controller_may_need_restore = False + + def recover_participants_on_temp() -> None: + """Actively normalize every rehearsal-owned participant. + + A broken auto-return leaves the node unreachable from the normal + tuple. Rejoin the exact rehearsal tuple, send the idempotent restore to + every scripted node once, restore the local source last, and then put + the controller back. Nodes which already returned simply cannot hear + the temporary-channel restore and are validated by the later normal + proof. + """ + nonlocal controller_may_need_restore + print( + "[preflight] normal-path proof failed; attempting bounded " + "temporary-channel recovery" + ) + recovery_source_expiry: float | None = None + recovery_retry_expiry: float | None = None + recovery_diagnostics: list[str] = [] + recovery_interrupt: BaseException | None = None + try: + if shared_controller: + try: + status = read_source_temp_radio_status(args) + except (OtaError, OSError) as exc: + recovery_diagnostics.append( + f"source tuple read before recovery: {exc}" + ) + status = None + source_on_expected_temp = bool( + status is not None + and status[0] == "active" + and status[1] is not None + and RadioSettings( + temp_radio.frequency, + temp_radio.bandwidth, + temp_radio.spreading_factor, + temp_radio.coding_rate, + False, + ).matches(status[1]) + ) + if not source_on_expected_temp: + recovery_started = time.monotonic() + recovery_minutes = TEMP_RADIO_RETURN_MINUTES + # Record ownership before the write: a lost local reply can + # still mean the one-minute cleanup lease was accepted. + recovery_source_expiry = ( + recovery_started + recovery_minutes * 60 + ) + try: + recovery_reply = source_cli_command( + args, + temp_radio_command_for_minutes( + args, TEMP_RADIO_RETURN_MINUTES + ), + bounded=True, + retry=False, + ) + # An unexpected but explicit duration is still a real + # lease. Own that full interval before rejecting it. + recovery_minutes = parse_temp_radio_reply_minutes( + "OTA source recovery", recovery_reply + ) + require_temp_radio_reply( + "OTA source recovery", + recovery_reply, + TEMP_RADIO_RETURN_MINUTES, + ) + except (OtaError, OSError) as exc: + recovery_diagnostics.append( + f"source temporary rejoin: {exc}" + ) + finally: + # The local operation may consume most of its 30-second + # bound before a reply is lost. Bound from completion, + # not merely from the pre-write timestamp. + recovery_source_expiry = max( + recovery_source_expiry, + time.monotonic() + + recovery_minutes * 60, + ) + time.sleep(TEMP_RADIO_SWITCH_DELAY_SECONDS) + else: + controller_may_need_restore = True + try: + controller.set_radio( + temp_radio, + "rejoin temporary tuple for rehearsal recovery", + timeout=controller_timeout(), + ) + except (OtaError, OSError) as exc: + recovery_diagnostics.append( + f"controller temporary rejoin: {exc}" + ) + time.sleep(TEMP_RADIO_SWITCH_DELAY_SECONDS) + + for name, password in remote_nodes: + restore_started = time.monotonic() + restore_reply_proven = False + try: + reply = controller.remote_command( + name, + "normalradio", + password=password, + retry=False, + reply_timeout=min( + int(getattr(controller, "reply_timeout", 20)), + TEMP_RADIO_PREFLIGHT_OPERATION_TIMEOUT_SECONDS, + ), + operation_timeout=( + TEMP_RADIO_PREFLIGHT_OPERATION_TIMEOUT_SECONDS + ), + ) + except TransmissionError as exc: + # Ambiguity is safe for a restore command: never replay it, + # and let the exact normal-channel proof decide whether it + # arrived. A node which already returned is expected here. + print( + f"[preflight] {name} recovery reply unavailable: {exc}" + ) + except OtaError as exc: + recovery_diagnostics.append(f"{name}: {exc}") + else: + try: + require_normal_radio_reply(name, reply) + restore_reply_proven = True + except OtaError as exc: + recovery_diagnostics.append(str(exc)) + finally: + if not restore_reply_proven: + possible_retry_expiry = ( + # The host operation itself can consume its full + # timeout while firmware retains the mutation. + # Start the independent retry horizon only after + # host completion so no queued restore survives. + max(restore_started, time.monotonic()) + + remote_cli_mutation_drain_seconds(temp_radio) + ) + recovery_retry_expiry = ( + possible_retry_expiry + if recovery_retry_expiry is None + else max( + recovery_retry_expiry, + possible_retry_expiry, + ) + ) + except BaseException as exc: + recovery_interrupt = exc + finally: + # The local source is always restored last, including legacy + # sources whose bare TempRadio status command is unsupported. + try: + source_reply = source_cli_command( + args, + "normalradio", + bounded=True, + ) + require_normal_radio_reply("OTA source", source_reply) + except (OtaError, OSError) as exc: + recovery_diagnostics.append(f"OTA source: {exc}") + except BaseException as exc: + if recovery_interrupt is None: + recovery_interrupt = exc + try: + time.sleep(TEMP_RADIO_SWITCH_DELAY_SECONDS) + except BaseException as exc: + if recovery_interrupt is None: + recovery_interrupt = exc + if not shared_controller: + try: + restore_controller_if_needed( + "restore controller after rehearsal recovery" + ) + except (OtaError, OSError) as exc: + recovery_diagnostics.append( + f"controller normal restore: {exc}" + ) + except BaseException as exc: + if recovery_interrupt is None: + recovery_interrupt = exc + + # Drain an ambiguously queued remote restore before another run can + # arm a new lease which that harmless-but-late normalradio would + # cancel. Also honor a shared source's independent one-minute safety + # net even after an explicit normalradio reply. + recovery_deadlines = [ + value + for value in (recovery_source_expiry, recovery_retry_expiry) + if value is not None + ] + if recovery_deadlines: + recovery_wait_until = ( + max(recovery_deadlines) + + TEMP_RADIO_PREFLIGHT_MARGIN_SECONDS + ) + while True: + remaining = recovery_wait_until - time.monotonic() + if remaining <= 0: + break + try: + time.sleep(remaining) + except BaseException as exc: + if recovery_interrupt is None: + recovery_interrupt = exc + if recovery_diagnostics: + print( + "[preflight] recovery diagnostics (normal proof remains " + "authoritative): " + "; ".join(recovery_diagnostics) + ) + if recovery_interrupt is not None: + raise recovery_interrupt + + def prove_normal_path() -> None: + nonlocal normal_proven + require_radio_settings( + controller.get_radio(timeout=controller_timeout()), + normal_radio, + "preflight controller return", + ) + prove_controller_identity("after natural return") + source_status = read_source_temp_radio_status(args) + if source_status is not None and source_status[0] != "inactive": + raise OtaError( + "OTA source did not return automatically after its independent " + f"three-minute lease: {source_status[0]}" + ) + prove_source_identity("after natural return") + prove_remote_identities("after natural return") + normal_proven = True + + try: + print( + "[preflight] starting independent three-minute TempRadio " + "reachability rehearsal" + ) + prove_source_on_air("normal-channel baseline") + + # Pin and sample each independently drifting remote RTC before choosing + # the common host activation instant. The explicit forward-only set + # suppresses automatic backward mesh correction even on older + # scheduling-capable firmware; current firmware also enforces an + # independent monotonic hard end. Every duplicate scheduled command + # still asks for one fixed epoch interval and can never extend it. + clock_samples: dict[str, RemoteClockSample] = {} + for name, password in remote_nodes: + require_remote_schedule_empty(name, password) + clock_sample = pin_remote_clock_forward( + controller, + name, + remote_keys[name], + password=password, + ) + clock_samples[name] = clock_sample + + # Clock pinning is read-only with respect to radio work but can itself + # take time on a lossy path. Make the last source/remote schedule + # snapshot immediately before choosing and arming the fixed window. + require_source_temp_work_absent() + for name, password in remote_nodes: + require_remote_schedule_empty(name, password) + + activation_at = ( + time.monotonic() + + TEMP_RADIO_PREFLIGHT_ARM_BASE_SECONDS + + len(remote_nodes) + * TEMP_RADIO_PREFLIGHT_OPERATION_TIMEOUT_SECONDS + ) + schedule_plan: dict[ + str, + tuple[str, int, int, float, float, float, float], + ] = {} + for name, _password in remote_nodes: + sample = clock_samples[name] + seconds_to_activation = max( + 1, math.ceil(activation_at - sample.monotonic_completed_at) + ) + start_epoch = sample.epoch + seconds_to_activation + end_epoch = ( + start_epoch + TEMP_RADIO_PREFLIGHT_MINUTES * 60 + ) + projected_start_earliest, projected_start_latest = ( + sample.projected_host_bounds(start_epoch) + ) + projected_end_earliest, projected_end_latest = ( + sample.projected_host_bounds(end_epoch) + ) + schedule_plan[name] = ( + scheduled_temp_radio_command( + args, start_epoch, end_epoch + ), + start_epoch, + end_epoch, + projected_start_earliest, + projected_start_latest, + projected_end_earliest, + projected_end_latest, + ) + + remote_activation_earliest = min( + plan[3] for plan in schedule_plan.values() + ) + remote_activation_latest = max( + plan[4] for plan in schedule_plan.values() + ) + print( + "[preflight] fixed remote activation host window " + f"{remote_activation_earliest:.1f}-" + f"{remote_activation_latest:.1f}; complete clock-read RTT " + "uncertainty is owned" + ) + + # Destination first, followed by controlled relays in the documented + # farthest-to-nearest order, while the controller remains normal. + for name, password in remote_nodes: + short_leases_may_be_active = True + ( + scheduled_command, + start_epoch, + end_epoch, + _projected_start_earliest, + _projected_start_latest, + projected_end_earliest, + projected_end_latest, + ) = schedule_plan[name] + record_fixed_lease( + projected_end_earliest + - TEMP_RADIO_PREFLIGHT_SCHEDULE_GUARD_SECONDS, + projected_end_latest + + TEMP_RADIO_PREFLIGHT_SCHEDULE_GUARD_SECONDS, + ) + try: + reply = controller.remote_command( + name, + scheduled_command, + password=password, + retry=False, + reply_timeout=min( + int(getattr(controller, "reply_timeout", 20)), + TEMP_RADIO_PREFLIGHT_OPERATION_TIMEOUT_SECONDS, + ), + operation_timeout=( + TEMP_RADIO_PREFLIGHT_OPERATION_TIMEOUT_SECONDS + ), + ) + except TransmissionError as exc: + # Do not replay. A delayed firmware retry is safe: it can + # only install this same fixed interval before start_epoch; + # at/after start_epoch the node rejects it as past work. + print( + f"[preflight] {name} fixed TempRadio acknowledgement was " + f"lost ({exc}); outcome will be proven on air; window " + f"{start_epoch}-{end_epoch} cannot be extended" + ) + else: + require_scheduled_temp_radio_reply(name, reply) + + wait_until( + remote_activation_earliest + - TEMP_RADIO_PREFLIGHT_SOURCE_ARM_LEAD_SECONDS, + "the earliest possible fixed remote TempRadio activation", + ) + + # The local source is armed last. Like each remote mutation, this is a + # single attempt: a lost acknowledgement is resolved by its exact + # on-air identity after handoff, never by extending the lease through a + # blind replay. + source_arm_deadline = temp_proof_deadline( + "arming OTA source", + TEMP_RADIO_PREFLIGHT_OPERATION_TIMEOUT_SECONDS + + TEMP_RADIO_SWITCH_DELAY_SECONDS, + ) + source_started_at = time.monotonic() + source_minutes = TEMP_RADIO_PREFLIGHT_MINUTES + short_leases_may_be_active = True + try: + try: + source_reply = source_cli_command( + args, + command, + bounded=True, + deadline=source_arm_deadline, + retry=False, + ) + except TransmissionError: + print( + "[preflight] OTA source TempRadio acknowledgement was " + "lost; the exact on-air identity proof will resolve it" + ) + else: + source_minutes = parse_temp_radio_reply_minutes( + "OTA source", source_reply + ) + require_temp_radio_reply( + "OTA source", source_reply, TEMP_RADIO_PREFLIGHT_MINUTES + ) + finally: + record_possible_lease( + source_started_at, + time.monotonic(), + source_minutes, + ) + + wait_until( + remote_activation_latest + + TEMP_RADIO_PREFLIGHT_SCHEDULE_GUARD_SECONDS, + "every RTT-bounded remote RTC to enter its fixed window", + ) + handoff_deadline = temp_proof_deadline( + "controller temporary handoff", + TEMP_RADIO_PREFLIGHT_OPERATION_TIMEOUT_SECONDS + + TEMP_RADIO_SWITCH_DELAY_SECONDS, + ) + if not shared_controller: + controller_may_need_restore = True + controller.set_radio( + temp_radio, + "three-minute preflight controller handoff", + timeout=controller_timeout(handoff_deadline), + ) + temp_proof_deadline( + "controller temporary handoff", TEMP_RADIO_SWITCH_DELAY_SECONDS + ) + time.sleep(TEMP_RADIO_SWITCH_DELAY_SECONDS) + proof_deadline = temp_proof_deadline("temporary path proof") + + source_status = read_source_temp_radio_status( + args, deadline=proof_deadline + ) + if shared_controller and source_status is None: + raise OtaError( + "shared Full Companion did not expose its TempRadio tuple " + "during the three-minute rehearsal" + ) + if source_status is not None: + state, source_tuple = source_status + if state != "active" or source_tuple is None: + raise OtaError( + "OTA source did not report an active TempRadio tuple " + f"during rehearsal: {state}" + ) + expected_source_tuple = RadioSettings( + temp_radio.frequency, + temp_radio.bandwidth, + temp_radio.spreading_factor, + temp_radio.coding_rate, + False, + ) + require_radio_settings( + source_tuple, + expected_source_tuple, + "preflight OTA source", + ) + if not shared_controller: + require_radio_settings( + controller.get_radio( + timeout=controller_timeout(proof_deadline) + ), + temp_radio, + "preflight controller temporary", + ) + prove_controller_identity("on TempRadio", deadline=proof_deadline) + prove_source_identity("on TempRadio", deadline=proof_deadline) + prove_remote_identities("on TempRadio", deadline=proof_deadline) + temp_proof_deadline("temporary path proof completion") + + if not shared_controller: + # Binary `set radio` persists its tuple. Restore it immediately + # after the temporary-path proof instead of leaving a controller + # vulnerable to a host crash during the three-minute wait. The + # destination/relay/source leases remain independently bounded and + # are still required to expire naturally before normal proof. + restore_controller_if_needed( + "restore controller during three-minute preflight" + ) + wait_for_natural_expiry() + restore_controller_if_needed( + "restore controller after three-minute preflight" + ) + prove_normal_path() + print( + "[preflight] PASS: destination, controlled relays, source, and " + "controller reached the exact tuple and returned to normal" + ) + except BaseException as exc: # cleanup must also run after Ctrl-C + primary_error = exc + finally: + # These flags are local to the rehearsal. The long transfer has not + # started and its cleanup ownership remains completely unarmed. + if short_leases_may_be_active: + if controller_may_need_restore and not shared_controller: + try: + restore_controller_if_needed( + "restore controller after failed three-minute preflight" + ) + except KeyboardInterrupt as exc: + # A second Ctrl-C during early controller restoration must + # not bypass the absolute owned-lease wait below. + if primary_error is None: + primary_error = exc + except (OtaError, OSError) as exc: + cleanup_errors.append(f"controller early restore: {exc}") + while not natural_expiry_waited: + try: + wait_for_natural_expiry() + except KeyboardInterrupt as exc: + # Remember Ctrl-C, but do not let repeated interrupts make + # a script-owned lease survive this process. Each retry + # uses the same absolute expiry deadline. + if primary_error is None: + primary_error = exc + continue + except BaseException as exc: + cleanup_errors.append( + f"bounded-lease expiry wait: {exc}" + ) + break + try: + restore_controller_if_needed( + "restore controller after failed three-minute preflight" + ) + except KeyboardInterrupt as exc: + if primary_error is None: + primary_error = exc + except (OtaError, OSError) as exc: + cleanup_errors.append(f"controller restore: {exc}") + if not normal_proven: + first_normal_error: BaseException | None = None + try: + prove_normal_path() + except KeyboardInterrupt as exc: + if primary_error is None: + primary_error = exc + first_normal_error = exc + except (OtaError, OSError) as exc: + first_normal_error = exc + if first_normal_error is not None: + recovery_error: BaseException | None = None + try: + recover_participants_on_temp() + except (OtaError, OSError) as exc: + recovery_error = exc + except KeyboardInterrupt as exc: + if primary_error is None: + primary_error = exc + recovery_error = exc + try: + prove_normal_path() + except KeyboardInterrupt as exc: + if primary_error is None: + primary_error = exc + cleanup_errors.append( + "normal-path proof after active recovery was " + f"interrupted: {exc}" + ) + except (OtaError, OSError) as exc: + detail = ( + f"; active recovery: {recovery_error}" + if recovery_error is not None + else "" + ) + cleanup_errors.append( + f"normal-path proof: {first_normal_error}{detail}; " + f"normal-path proof after active recovery: {exc}" + ) + else: + # Exact identities on the normal tuple are + # authoritative even if a best-effort cleanup command + # reported an error. Keep any earlier independent + # cleanup diagnostics, but do not let them suppress the + # recovery attempt itself. + if recovery_error is not None: + print( + "[preflight] active recovery diagnostic " + f"after proven normal return: {recovery_error}" + ) + + if primary_error is not None: + if cleanup_errors: + raise OtaError( + f"three-minute TempRadio rehearsal failed: {primary_error}; " + "cleanup also failed: " + "; ".join(cleanup_errors) + ) from primary_error + raise primary_error + if cleanup_errors: + raise OtaError( + "three-minute TempRadio rehearsal cleanup failed: " + + "; ".join(cleanup_errors) + ) def arm_target_temp_radio( @@ -3551,8 +5895,15 @@ def arm_target_temp_radio( ) if shared_controller: time.sleep(TEMP_RADIO_SWITCH_DELAY_SECONDS) - identity = controller.remote_command( - args.target, "ota self", retry=False + # Identity reads are idempotent, but this proof must never + # enter the operator-continuation retry loop. Both leases are + # bounded, so exhaust a fixed set of attempts and then check + # the normal channel instead of waiting through lease expiry. + identity = retry_transmission_bounded( + lambda: controller.remote_command( + args.target, "ota self", retry=False + ), + f"temporary-channel identity proof for {args.target}", ) if re.search(r"\bbase_hash=[0-9A-Fa-f]{16}\b", identity) is None: raise OtaError( @@ -3589,8 +5940,11 @@ def arm_target_temp_radio( # on the normal channel therefore proves that the target did not # remain on TempRadio, making a bounded replay safe. try: - identity = controller.remote_command( - args.target, "ota self", retry=False + identity = retry_transmission_bounded( + lambda: controller.remote_command( + args.target, "ota self", retry=False + ), + f"normal-channel identity proof for {args.target}", ) if re.search(r"\bbase_hash=[0-9A-Fa-f]{16}\b", identity) is None: raise OtaError( @@ -3618,7 +5972,9 @@ def arm_target_temp_radio( ) time.sleep(delay) continue - require_temp_radio_reply(args.target, reply) + require_temp_radio_reply( + args.target, reply, temp_radio_command_minutes(command) + ) return @@ -3633,15 +5989,123 @@ def temp_radio_command_for_minutes( ) +def arm_relay_temp_radio_once( + controller: Controller, + relay_name: str, + relay_password: str, + command: str, + armed_relay_values: list[tuple[str, str]], + expected_minutes: int, +) -> bool: + """Send one relative relay lease, owning ambiguity before transmission. + + ``False`` means the command may have arrived but its acknowledgement did + not. The caller must switch to the declared tuple and reconcile the exact + relay identity; it must never replay this relative-duration write. + """ + owned = (relay_name, relay_password) + if owned not in armed_relay_values: + # Arm cleanup first: remote_command can raise after firmware accepted + # the lease but before meshcli returned its private reply. + armed_relay_values.append(owned) + try: + reply = controller.remote_command( + relay_name, + command, + password=relay_password, + retry=False, + ) + except TransmissionError as exc: + print( + f"[relays] {relay_name} TempRadio acknowledgement was lost ({exc}); " + "the relative command will not be replayed" + ) + return False + require_temp_radio_reply(relay_name, reply, expected_minutes) + return True + + +def arm_source_temp_radio_once( + args: argparse.Namespace, + command: str, +) -> bool: + """Send one managed-source relative lease without a blind CLI retry.""" + try: + reply = source_cli_command(args, command, retry=False) + except TransmissionError as exc: + print( + "[source] TempRadio acknowledgement was lost " + f"({exc}); the relative command will not be replayed" + ) + return False + require_temp_radio_reply( + "OTA source", reply, temp_radio_command_minutes(command) + ) + return True + + +def require_source_on_temp_after_uncertain_arm( + args: argparse.Namespace, + temp_radio: RadioSettings, +) -> None: + """Resolve a lost local source acknowledgement through exact tuple state.""" + status = read_source_temp_radio_status(args) + if status is None: + raise OtaError( + "OTA source TempRadio outcome is ambiguous and this legacy source " + "does not expose a state query; the relative command was not replayed" + ) + state, actual = status + expected = RadioSettings( + temp_radio.frequency, + temp_radio.bandwidth, + temp_radio.spreading_factor, + temp_radio.coding_rate, + False, + ) + if state != "active" or actual is None: + raise OtaError( + "OTA source TempRadio acknowledgement was lost and readback did " + f"not prove an active tuple: {state}" + ) + require_radio_settings(actual, expected, "OTA source TempRadio readback") + print( + "[source] lost TempRadio acknowledgement reconciled by exact active " + "tuple readback" + ) + + def arm_target_install_window( controller: Controller, args: argparse.Namespace, + package: MotaInfo, ) -> None: - reply = controller.remote_command( - args.target, - temp_radio_command_for_minutes(args, INSTALL_TARGET_WINDOW_MINUTES), + command = temp_radio_command_for_minutes( + args, INSTALL_TARGET_WINDOW_MINUTES + ) + try: + reply = controller.remote_command( + args.target, + command, + retry=False, + ) + except TransmissionError as exc: + # The existing download lease remains bounded if this one-shot write + # did not arrive. An exact staged-session read proves the same target is + # still reachable, which is sufficient to proceed to the separately + # state-reconciled install request without extending the lease again. + status = controller.remote_command( + args.target, "ota status", retry=False + ) + require_package_session(status, package, ready=True) + print( + "[install] three-minute TempRadio acknowledgement was lost " + f"({exc}); exact staged-session state reconciled it without replay" + ) + return + require_temp_radio_reply( + args.target, reply, INSTALL_TARGET_WINDOW_MINUTES ) - require_temp_radio_reply(args.target, reply) def confirm_ready_to_install( @@ -3679,7 +6143,7 @@ def request_install( while True: # If an install reply is lost and the target also stops replying, this # short window gets it back onto the normal channel for verification. - arm_target_install_window(controller, args) + arm_target_install_window(controller, args, package) if getattr(args, "require_system_watchdog_off", False): require_system_watchdog_off(controller, args) try: @@ -3733,6 +6197,7 @@ def shorten_relay_temp_windows( controller: Controller, args: argparse.Namespace, relay_values: list[tuple[str, str]] | None = None, + expected_public_keys: dict[str, str] | None = None, ) -> None: values = args.relay_values if relay_values is None else relay_values if not values: @@ -3742,11 +6207,63 @@ def shorten_relay_temp_windows( f"[relays] scheduling return to the normal channel in " f"{TEMP_RADIO_RETURN_MINUTES} minute" ) + errors: list[str] = [] + interrupted: KeyboardInterrupt | None = None for relay_name, relay_password in values: - reply = controller.remote_command( - relay_name, command, password=relay_password - ) - require_temp_radio_reply(relay_name, reply) + try: + try: + reply = controller.remote_command( + relay_name, + command, + password=relay_password, + retry=False, + ) + except TransmissionError as exc: + # Never replay a relative lease. Prove which authenticated + # relay remained reachable on this tuple; its original window + # is still bounded if the one-minute write did not arrive. + actual_key = read_remote_public_key_bounded( + controller, + relay_name, + password=relay_password, + ) + expected_key = ( + expected_public_keys.get(relay_name) + if expected_public_keys is not None + else None + ) + if expected_key is not None and actual_key != expected_key: + raise OtaError( + f"identity changed after lost cleanup reply: expected " + f"{expected_key}, got {actual_key}" + ) + print( + f"[relays] {relay_name} one-minute TempRadio reply was " + f"lost ({exc}); exact identity reconciled the one-shot " + "request, and its original lease remains bounded" + ) + else: + require_temp_radio_reply( + relay_name, reply, TEMP_RADIO_RETURN_MINUTES + ) + except KeyboardInterrupt as exc: + # Continue through the remaining independently owned relays. The + # interrupt is re-raised only after each has received one attempt. + if interrupted is None: + interrupted = exc + errors.append(f"{relay_name}: interrupted") + except (OtaError, OSError) as exc: + errors.append(f"{relay_name}: {exc}") + if errors: + detail = "; ".join(errors) + if interrupted is not None: + print( + "WARNING: relay cleanup continued after interruption: " + + detail, + file=sys.stderr, + ) + raise interrupted + raise OtaError("could not shorten every relay TempRadio lease: " + detail) def shorten_target_temp_window( @@ -3758,8 +6275,31 @@ def shorten_target_temp_window( f"[destination] scheduling return to the normal channel in " f"{TEMP_RADIO_RETURN_MINUTES} minute" ) - reply = controller.remote_command(args.target, command) - require_temp_radio_reply(args.target, reply) + try: + reply = controller.remote_command( + args.target, command, retry=False + ) + except TransmissionError as exc: + identity = retry_transmission_bounded( + lambda: controller.remote_command( + args.target, "ota self", retry=False + ), + f"cleanup identity reconciliation for {args.target}", + ) + if re.search(r"\bbase_hash=[0-9A-Fa-f]{16}\b", identity) is None: + raise OtaError( + "destination replied after a lost cleanup request but did not " + "return a valid running EndF identity" + ) + print( + "[destination] one-minute TempRadio reply was lost " + f"({exc}); exact identity reconciled the one-shot request, and the " + "original lease remains bounded" + ) + return + require_temp_radio_reply( + args.target, reply, TEMP_RADIO_RETURN_MINUTES + ) def shorten_source_temp_window( @@ -3832,7 +6372,9 @@ def shorten_source_temp_window( f"[source] scheduling return to the normal channel in " f"{TEMP_RADIO_RETURN_MINUTES} minute" ) - output = source_cli_command(args, command, check=check) + output = source_cli_command( + args, command, check=check, retry=False + ) if not output and not check: print( "[warn] could not shorten the OTA source TempRadio window; " @@ -3858,7 +6400,8 @@ def switch_controller_to_temp_radio( # This local command changes only the bounded live radio tuple. A Binary # `set radio` would persist the temporary tuple, leaving `normalradio` # with no saved normal tuple to restore. - source_cli_command(args, temp_command) + if not arm_source_temp_radio_once(args, temp_command): + require_source_on_temp_after_uncertain_arm(args, temp_radio) return controller.set_radio(temp_radio, "switch controller to TempRadio") @@ -3894,7 +6437,12 @@ def verify_installed( installed_version = None try: - stats_reply = controller.remote_command(args.target, "ota stats") + stats_reply = retry_transmission_bounded( + lambda: controller.remote_command( + args.target, "ota stats", retry=False + ), + f"optional post-reboot `ota stats` probe on {args.target}", + ) version_match = re.search( r"\bfw (v\d+\.\d+\.\d+(?:\.\d+)?)\b", stats_reply ) @@ -4059,6 +6607,15 @@ def build_parser() -> argparse.ArgumentParser: "--relay", action="append", default=[], metavar="NAME[=PASSWORD]", help="optional relay, ordered farthest-to-nearest; repeat as needed", ) + parser.add_argument( + "--source-contact", + metavar="NAME", + help=( + "controller contact for a separate OTA source, used for the " + "three-minute on-air proof; defaults to the source's local name " + "(no remote-admin password is required)" + ), + ) parser.add_argument( "--relay-txdelay", type=float, @@ -4098,6 +6655,15 @@ def build_parser() -> argparse.ArgumentParser: ) parser.add_argument("--meshcli", default="meshcli") parser.add_argument("--motatool", default="motatool") + parser.add_argument( + "--package-build-timeout", + type=int, + default=DEFAULT_PACKAGE_BUILD_TIMEOUT_SECONDS, + help=( + "maximum seconds for a locally generated mOTA; low-power hosts " + "can take many minutes to build an in-place delta" + ), + ) parser.add_argument( "--debug", action="store_true", @@ -4258,6 +6824,8 @@ def validate_args(args: argparse.Namespace, parser: argparse.ArgumentParser) -> parser.error("--nrf-sd and --nrf-qspi are mutually exclusive") if (args.nrf_sd or args.nrf_qspi) and args.platform != "nrf52": parser.error("--nrf-sd/--nrf-qspi require --platform nrf52") + if args.source_contact is not None: + parser.error("--source-contact is only valid during a live run") else: if any((args.platform, args.target_id, args.target_base_hash, args.target_hw, args.nrf_sd, args.nrf_qspi)): @@ -4301,6 +6869,14 @@ def validate_args(args: argparse.Namespace, parser: argparse.ArgumentParser) -> "managed source because source RXPS must be restored only " "after its normal radio is verified" ) + if args.source_shares_controller and args.source_contact is not None: + parser.error( + "--source-contact is not used when the source shares the controller" + ) + if args.source_already_temp and args.source_contact is not None: + parser.error( + "--source-contact cannot qualify an unmanaged --source-already-temp source" + ) if args.controller_serial and args.source_serial: if serial_paths_match(args.controller_serial, args.source_serial): parser.error("controller and source must be separate nodes/serial ports") @@ -4311,6 +6887,7 @@ def validate_args(args: argparse.Namespace, parser: argparse.ArgumentParser) -> "TARGET_NODE": args.target, "--password": args.password, "--target-hw": args.target_hw, + "--source-contact": args.source_contact, **{f"--relay #{index}": value for index, value in enumerate(args.relay, 1)}, } for label, value in unsafe_text.items(): @@ -4319,6 +6896,7 @@ def validate_args(args: argparse.Namespace, parser: argparse.ArgumentParser) -> for name in ( "reply_timeout", "discovery_timeout", "discovery_interval", "poll_seconds", "transfer_timeout_minutes", "seeder_start_wait", "reboot_wait", + "package_build_timeout", ): if getattr(args, name) <= 0: parser.error(f"--{name.replace('_', '-')} must be positive") @@ -4467,6 +7045,7 @@ def main( seeder: SeederProcess | None = None seeder_attempted = False source_temp_owned = False + source_temp_cleanup_attempted = False source_rxps_saved: RxpsSettings | None = None source_rxps_changed = False source_rxps_recovery_path: Path | None = None @@ -4474,6 +7053,7 @@ def main( target_rxps_saved: RxpsSettings | None = None target_rxps_changed = False armed_relay_values: list[tuple[str, str]] = [] + relay_public_keys: dict[str, str] = {} relay_timing_settings: list[RelayTimingSettings] = [] password = args.password or os.environ.get("MESHCORE_ADMIN_PASSWORD", "") temp_command = f"tempradio {args.temp_radio}" @@ -4521,6 +7101,7 @@ def main( if any(char in password for char in "\r\n\0"): raise OtaError("admin password contains an unsupported control character") args.relay_values = [parse_relay(value, password) for value in args.relay] + args.source_contact_value = args.source_contact # Retain recovery state before any source mutation. A hard kill after # RXPS is disabled must not erase the only copy of its saved setting. work_dir = make_work_dir(args.work_dir) @@ -4564,21 +7145,43 @@ def main( "[rxps] source recovery settings: " f"{source_rxps_recovery_path}" ) - # Arm cleanup before the mutating command. The command may - # reach the source even if its acknowledgement is lost. A - # retained record can also differ because a killed prior run - # left the source off; restore that original at cleanup. + # Before this run's first mutation, cleanup is needed only + # when a retained record proves an interrupted earlier run + # left the source different from its original setting. Do + # not arm a no-op restore merely because RXPS is enabled: + # package preparation can take a long time, and a read-only + # failure must not overwrite an external setting change made + # during that interval. The flag is armed for a potentially + # lost disable acknowledgement immediately before disable. source_rxps_changed = ( current_source_rxps != source_rxps_saved - or source_rxps_saved.enabled ) - disabled_now = disable_source_rxps( - args, current_source_rxps - ) - source_rxps_changed = source_rxps_changed or disabled_now if controller is None: controller = Controller(args, password) verify_shared_source_identity(controller, args) + # These gates are intentionally before query_target() or any other + # on-air remote operation. First prove that advancing the managed + # source clock cannot trigger active/pending TempRadio work. A + # companion which rebooted onto a stale fallback RTC otherwise + # emits valid packets that the destination replay guard drops. + source_clock_window = ensure_source_clock_gate_safe(args) + controller_epoch = ensure_controller_clock_safe(controller) + if args.source_shares_controller: + if source_clock_window is None: + raise OtaError( + "shared source has no managed terminal clock gate" + ) + # The terminal gate may have advanced this shared RTC after a + # prior Binary read. Bind both local transport views exactly + # before the first LoRa packet. + controller_epoch = controller.get_clock( + timeout=float( + TEMP_RADIO_PREFLIGHT_OPERATION_TIMEOUT_SECONDS + ) + ) + require_shared_clock_agreement( + controller_epoch, source_clock_window + ) target = query_target(controller, args) original_radio = controller.get_radio() print(f"[controller] saved radio {original_radio.meshcli_value()}") @@ -4647,11 +7250,60 @@ def main( args.target_rxps_saved = target_rxps_saved args.target_rxps_profile = target_rxps_profile confirm_update(args, target, package) + freq, bandwidth, sf, cr, _minutes = args.temp_values temp_radio = RadioSettings( freq, bandwidth, sf, cr, original_radio.repeat ) + # Qualify the entire path before source RXPS, destination RXPS, the + # long TempRadio window, relay timing, seeding, pull, or install can be + # mutated. The rehearsal owns only independent three-minute leases and + # proves their natural return before handing control back to this run. + run_temp_radio_preflight( + controller, + args, + target, + original_radio, + temp_radio, + ) + + # Package generation above belongs on a workstation/build VM; a + # Pi-class radio host should receive only the completed hash-verified + # artifact. Regardless of where preparation ran, keep the source in + # its original RXPS mode through package verification, all read-only + # compatibility checks, and operator confirmation. Re-read immediately + # before the first mutation so an external setting change cannot make + # this decision from a stale snapshot. The durable original remains + # armed for exact cleanup if a prior interrupted run left the source + # off. + if has_managed_source_cli(args): + assert source_rxps_saved is not None + try: + current_source_rxps = read_source_rxps(args) + except TransmissionStopped: + raise + except OtaError as exc: + raise OtaError( + "cannot safely disable OTA source RXPS because its current " + f"state is unavailable: {exc}" + ) from exc + if ( + not source_rxps_changed + and current_source_rxps != source_rxps_saved + ): + raise OtaError( + "OTA source RXPS changed during read-only package " + "preparation; leaving the new external setting unchanged" + ) + source_rxps_changed = ( + source_rxps_changed + or current_source_rxps != source_rxps_saved + or source_rxps_saved.enabled + ) + disabled_now = disable_source_rxps(args, current_source_rxps) + source_rxps_changed = source_rxps_changed or disabled_now + if target_rxps_saved is not None and target_rxps_saved.enabled: recovery_path = write_target_rxps_recovery( work_dir, @@ -4677,6 +7329,18 @@ def main( # can process TempRadio even when its reply is lost, so resolve that # ambiguity by probing its exact identity on the temporary channel # instead of replaying the command from the normal channel. + # Bind every managed relay before any long relative lease is sent. A + # lost acknowledgement is later reconciled against this exact key on + # the temporary tuple instead of blindly extending the lease. + relay_public_keys = { + relay_name: read_remote_public_key_bounded( + controller, + relay_name, + password=relay_password, + ) + for relay_name, relay_password in args.relay_values + } + target_temp_owned = True if args.source_shares_controller: # A lost-reply probe can schedule the shared local source onto the @@ -4686,17 +7350,27 @@ def main( arm_target_temp_radio( controller, args, temp_command, temp_radio, original_radio ) + uncertain_relay_arms: list[tuple[str, str]] = [] for relay_name, relay_password in args.relay_values: - temp_reply = controller.remote_command( - relay_name, temp_command, password=relay_password - ) - require_temp_radio_reply(relay_name, temp_reply) - armed_relay_values.append((relay_name, relay_password)) + if not arm_relay_temp_radio_once( + controller, + relay_name, + relay_password, + temp_command, + armed_relay_values, + int(args.temp_values[-1]), + ): + uncertain_relay_arms.append( + (relay_name, relay_password) + ) + uncertain_source_arm = False if not args.source_already_temp and not args.source_shares_controller: # Arm cleanup before the mutating command because the source can # enter TempRadio even when its acknowledgement is lost. source_temp_owned = True - source_cli_command(args, temp_command) + uncertain_source_arm = not arm_source_temp_radio_once( + args, temp_command + ) if args.source_shares_controller: # Arm cleanup before the local command. Its reply can be lost after @@ -4712,6 +7386,25 @@ def main( controller_changed = True time.sleep(TEMP_RADIO_SWITCH_DELAY_SECONDS) + for relay_name, relay_password in uncertain_relay_arms: + actual_key = read_remote_public_key_bounded( + controller, + relay_name, + password=relay_password, + ) + expected_key = relay_public_keys[relay_name] + if actual_key != expected_key: + raise OtaError( + f"{relay_name} TempRadio identity changed after a lost " + f"acknowledgement: expected {expected_key}, got {actual_key}" + ) + print( + f"[relays] {relay_name} lost TempRadio acknowledgement " + "reconciled by exact identity on the declared tuple" + ) + if uncertain_source_arm: + require_source_on_temp_after_uncertain_arm(args, temp_radio) + for relay_name, relay_password in args.relay_values: saved_timing = read_relay_timing( controller, relay_name, relay_password @@ -4741,10 +7434,19 @@ def main( "--leave-controller-radio was requested" ) else: - shorten_target_temp_window(controller, args) + # Retire one-shot ownership before the write. If its reply is + # lost, reconciliation is read-only and outer cleanup must not + # send the relative duration a second time. target_temp_owned = False - shorten_relay_temp_windows(controller, args) + shorten_target_temp_window(controller, args) + relays_to_shorten = list(armed_relay_values) armed_relay_values.clear() + shorten_relay_temp_windows( + controller, + args, + relays_to_shorten, + relay_public_keys, + ) if target_rxps_changed and target_rxps_saved is not None: if args.leave_controller_radio: print( @@ -4766,6 +7468,7 @@ def main( not args.leave_controller_radio or not args.source_shares_controller ): + source_temp_cleanup_attempted = True if shorten_source_temp_window(args): source_temp_owned = False if controller_changed and not args.leave_controller_radio: @@ -4794,8 +7497,14 @@ def main( # bounded window, then verify through the restored normal route. restore_relay_timings(controller, relay_timing_settings) relay_timing_settings.clear() - shorten_relay_temp_windows(controller, args) + relays_to_shorten = list(armed_relay_values) armed_relay_values.clear() + shorten_relay_temp_windows( + controller, + args, + relays_to_shorten, + relay_public_keys, + ) # Stop seeding before returning the controller to its ordinary channel. seeder.stop() @@ -4804,6 +7513,7 @@ def main( not args.leave_controller_radio or not args.source_shares_controller ): + source_temp_cleanup_attempted = True if shorten_source_temp_window(args): source_temp_owned = False controller.set_radio(original_radio, "restore controller radio for verification") @@ -4894,9 +7604,12 @@ def main( file=sys.stderr, ) if target_temp_owned and not args.leave_controller_radio: + # This relative cleanup write is one-shot even when its + # acknowledgement is lost; retire ownership first so a + # later finally path cannot extend it again. + target_temp_owned = False try: shorten_target_temp_window(controller, args) - target_temp_owned = False except (OtaError, OSError) as exc: print( "WARNING: could not shorten the destination " @@ -4904,11 +7617,18 @@ def main( file=sys.stderr, ) if armed_relay_values and not args.leave_controller_radio: + # Give every independently owned relay exactly one + # cleanup attempt. Clear before transmission so neither + # an aggregate error nor Ctrl-C can replay a duration. + relays_to_shorten = list(armed_relay_values) + armed_relay_values.clear() try: shorten_relay_temp_windows( - controller, args, armed_relay_values + controller, + args, + relays_to_shorten, + relay_public_keys, ) - armed_relay_values.clear() except (OtaError, OSError) as exc: print( "WARNING: could not shorten every relay TempRadio " @@ -4940,7 +7660,8 @@ def main( if source_temp_owned and ( not args.leave_controller_radio or not args.source_shares_controller - ): + ) and not source_temp_cleanup_attempted: + source_temp_cleanup_attempted = True if shorten_source_temp_window(args, check=False): source_temp_owned = False if ( diff --git a/tools/lora_ota/test_lora_ota.py b/tools/lora_ota/test_lora_ota.py index 98287cd9..29d6ab44 100644 --- a/tools/lora_ota/test_lora_ota.py +++ b/tools/lora_ota/test_lora_ota.py @@ -117,6 +117,7 @@ def prepare_args(package: Path, motatool: str, base: Path | None = None) -> argp inplace_memory=None, sign_key=None, public_key=None, + package_build_timeout=ota.DEFAULT_PACKAGE_BUILD_TIMEOUT_SECONDS, ) @@ -235,6 +236,60 @@ class FormatTests(unittest.TestCase): ) ) + def test_optional_participant_version_probes_are_bounded(self) -> None: + class Controller: + def __init__(self) -> None: + self.relay_calls = 0 + + @staticmethod + def get_firmware_version() -> tuple[str, int]: + return "v1.17.1.5", ota.parse_version("v1.17.1.5") + + def remote_command( + self, + _target: str, + command: str, + *, + retry: bool = True, + **_kwargs: object, + ) -> str: + if command != "ver": + raise AssertionError(command) + if retry: + raise AssertionError("optional relay probe was unbounded") + self.relay_calls += 1 + raise ota.TransmissionError("synthetic relay version loss") + + args = argparse.Namespace( + source_shares_controller=False, + source_already_temp=False, + source_cli_serial="/dev/source", + source_cli_tcp=None, + relay_values=[("relay", "secret")], + ) + controller = Controller() + with ( + mock.patch.object( + ota, + "source_cli_command", + side_effect=ota.TransmissionError( + "synthetic source version loss" + ), + ) as source_command, + mock.patch.object(ota.time, "sleep"), + ): + versions = ota.read_lora_ota_participant_versions( + controller, + args, + target(current_version="v1.17.1.5"), + ) + source_command.assert_called_once_with(args, "ver", bounded=True) + self.assertEqual( + controller.relay_calls, ota.TRANSMISSION_RETRY_LIMIT + 1 + ) + self.assertIsNone(versions["source"]) + self.assertIsNone(versions["relay:relay"]) + def test_rxps_config_parser_keeps_legacy_compatibility(self) -> None: self.assertEqual( ota.parse_rxps_settings("> on,65625,60000", "remote"), @@ -303,6 +358,28 @@ class FormatTests(unittest.TestCase): self.assertEqual(chain.temp_radio, "909.950,250,5,5,120") self.assertFalse(chain.legacy_full_airtime) + def test_package_build_timeout_is_configurable_and_positive(self) -> None: + parser = ota.build_parser() + default = parser.parse_args(["release.mota", "remote"]) + self.assertEqual( + default.package_build_timeout, + ota.DEFAULT_PACKAGE_BUILD_TIMEOUT_SECONDS, + ) + + custom = parser.parse_args([ + "release.zip", "offline", "--prepare-only", "--platform", "esp32", + "--target-id", f"{TARGET:08X}", "--package-build-timeout", "7200", + ]) + ota.validate_args(custom, parser) + self.assertEqual(custom.package_build_timeout, 7200) + + invalid = parser.parse_args([ + "release.zip", "offline", "--prepare-only", "--platform", "esp32", + "--target-id", f"{TARGET:08X}", "--package-build-timeout", "0", + ]) + with self.assertRaises(SystemExit), contextlib.redirect_stderr(io.StringIO()): + ota.validate_args(invalid, parser) + def test_airtime_estimator_uses_forward_preamble_contract(self) -> None: expected_preambles = ( (500.0, 7, 32), @@ -543,6 +620,155 @@ class DebugTests(unittest.TestCase): self.assertNotIn("top-secret", rendered) self.assertIn("reply ", rendered) + def test_redact_text_handles_overlapping_sensitive_values_once(self) -> None: + rendered = ota.redact_text( + "short secret and longer secret-suffix", + ("secret", "secret-suffix", "RED"), + ) + self.assertEqual( + rendered, + "short and longer ", + ) + + def test_persistent_meshcli_exit_redacts_exception_and_debug_tail(self) -> None: + ota.DEBUG = True + secret = "admin-secret" + session = ota.PersistentMeshcliSession(["meshcli"]) + session.pending = bytearray( + f"script output: login remote {secret}\nfatal {secret}".encode() + ) + session.output_queue = mock.Mock() + session.output_queue.get.side_effect = ota.queue.Empty + session.process = mock.Mock() + session.process.poll.return_value = 17 + output = io.StringIO() + with ( + mock.patch.object(session, "close"), + contextlib.redirect_stdout(output), + self.assertRaisesRegex( + ota.OtaError, "persistent meshcli session exited" + ) as raised, + ): + session._read_frame( + "FRAME_START", "FRAME_END", 5, (secret,) + ) + rendered = f"{raised.exception}\n{output.getvalue()}" + self.assertNotIn(secret, rendered) + self.assertIn("login remote ", rendered) + + def test_persistent_meshcli_close_redacts_exception_and_debug_tail(self) -> None: + ota.DEBUG = True + secret = "admin-secret" + session = ota.PersistentMeshcliSession(["meshcli"]) + session.pending = bytearray(f"closed after {secret}".encode()) + session.output_queue = mock.Mock() + session.output_queue.get.return_value = None + output = io.StringIO() + with ( + mock.patch.object(session, "close"), + contextlib.redirect_stdout(output), + self.assertRaisesRegex( + ota.OtaError, "persistent meshcli session closed" + ) as raised, + ): + session._read_frame( + "FRAME_START", "FRAME_END", 5, (secret,) + ) + rendered = f"{raised.exception}\n{output.getvalue()}" + self.assertNotIn(secret, rendered) + self.assertIn("closed after ", rendered) + + def test_persistent_meshcli_timeout_redacts_before_tail_truncation(self) -> None: + ota.DEBUG = True + secret = "boundary-secret" + session = ota.PersistentMeshcliSession(["meshcli"]) + # Put the secret across the old 4096-byte raw-tail boundary. Redacting + # only after slicing would expose its suffix in debug output. + session.pending = bytearray( + ("x" * 10 + secret + "y" * 4090).encode() + ) + output = io.StringIO() + with ( + mock.patch.object(ota.time, "monotonic", side_effect=(1.0, 3.0)), + mock.patch.object(session, "close"), + contextlib.redirect_stdout(output), + self.assertRaisesRegex( + ota.OtaError, "persistent meshcli command timed out" + ), + ): + session._read_frame( + "FRAME_START", "FRAME_END", 1, (secret,) + ) + self.assertNotIn(secret, output.getvalue()) + self.assertNotIn(secret[-6:], output.getvalue()) + + def test_persistent_meshcli_output_limit_closes_without_leaking_detail( + self, + ) -> None: + secret = "admin-secret" + session = ota.PersistentMeshcliSession(["meshcli"]) + session.pending = bytearray(b"FRAME_START\n") + session.pending.extend(b"x" * (8 * 1024 * 1024)) + session.pending.extend(secret.encode()) + with ( + mock.patch.object(session, "close") as close, + self.assertRaisesRegex( + ota.OtaError, "persistent meshcli output exceeded 8 MiB" + ) as raised, + ): + session._read_frame( + "FRAME_START", "FRAME_END", 5, (secret,) + ) + close.assert_called_once_with() + self.assertNotIn(secret, str(raised.exception)) + + def test_persistent_meshcli_success_redacts_frame_and_debug_output(self) -> None: + ota.DEBUG = True + secret = "admin-secret" + session = ota.PersistentMeshcliSession(["meshcli"]) + session.pending = bytearray( + f"FRAME_START\nlogin remote {secret}\nFRAME_END\n".encode() + ) + session.process = mock.Mock() + session.process.poll.return_value = None + session.process.stdin = mock.Mock() + output = io.StringIO() + with ( + tempfile.NamedTemporaryFile() as script, + contextlib.redirect_stdout(output), + ): + result = session.run_script( + Path(script.name), + "FRAME_START", + "FRAME_END", + 5, + (secret,), + ) + rendered = f"{result}\n{output.getvalue()}" + self.assertNotIn(secret, rendered) + self.assertIn("login remote ", rendered) + + def test_persistent_meshcli_input_error_redacts_exception(self) -> None: + secret = "admin-secret" + session = ota.PersistentMeshcliSession(["meshcli"]) + session.process = mock.Mock() + session.process.poll.return_value = None + session.process.stdin.write.side_effect = BrokenPipeError( + f"echoed login remote {secret}" + ) + with ( + tempfile.NamedTemporaryFile() as script, + mock.patch.object(session, "close"), + self.assertRaisesRegex( + ota.OtaError, + "persistent meshcli input failed: echoed login remote ", + ) as raised, + ): + session.run_script( + Path(script.name), "FRAME_START", "FRAME_END", 5, (secret,) + ) + self.assertNotIn(secret, str(raised.exception)) + def test_meshcli_debug_redacts_admin_password(self) -> None: ota.DEBUG = True args = argparse.Namespace( @@ -873,9 +1099,22 @@ class SourceCliTests(unittest.TestCase): ota.source_rxps_busy_retry_delay(1) ) - def test_failure_cleanup_restores_source_rxps_once(self) -> None: + def test_read_only_target_failure_does_not_mutate_source_rxps(self) -> None: saved = ota.RxpsSettings(True, 18205, 20423, 8, 16) - disabled = ota.RxpsSettings(False, 18205, 20423, 8, 16) + events: list[str] = [] + + def source_clock_gate(_args: argparse.Namespace) -> tuple[int, int]: + events.append("source-clock-gate") + return (1_800_000_000, 1_800_000_059) + + def controller_clock_gate(_controller: object) -> int: + events.append("controller-clock-gate") + return 1_800_000_000 + + def fail_target_query(*_args: object) -> ota.TargetInfo: + events.append("target-query") + raise ota.OtaError("synthetic failure") + with tempfile.TemporaryDirectory() as directory: work_dir = Path(directory) / "work" argv = [ @@ -892,19 +1131,23 @@ class SourceCliTests(unittest.TestCase): mock.patch.object(ota, "preflight_source_cli"), mock.patch.object( ota, - "read_source_rxps", - side_effect=(saved, disabled, disabled, saved), - ) as read_source, + "ensure_source_clock_gate_safe", + side_effect=source_clock_gate, + ), mock.patch.object( ota, - "source_cli_command", - side_effect=( - "OK - off,18205,20423", - "OK - level 8,on,18205,20423,preamble=16", - ), - ) as source_command, + "ensure_controller_clock_safe", + side_effect=controller_clock_gate, + ), mock.patch.object( - ota, "query_target", side_effect=ota.OtaError("synthetic failure") + ota, + "read_source_rxps", + side_effect=(saved, saved, saved), + ) as read_source, + mock.patch.object(ota, "source_cli_command") as source_command, + mock.patch.object(ota, "disable_source_rxps") as disable_source, + mock.patch.object( + ota, "query_target", side_effect=fail_target_query ), contextlib.redirect_stdout(io.StringIO()), contextlib.redirect_stderr(io.StringIO()), @@ -916,13 +1159,12 @@ class SourceCliTests(unittest.TestCase): self.assertEqual(result, 2) self.assertFalse(recovery_exists) - self.assertEqual(read_source.call_count, 4) + self.assertEqual(read_source.call_count, 1) + disable_source.assert_not_called() + source_command.assert_not_called() self.assertEqual( - [call.args[1] for call in source_command.call_args_list], - [ - "set radio.rxps off", - "set radio.rxps level 8 preamble 16", - ], + events, + ["source-clock-gate", "controller-clock-gate", "target-query"], ) def test_rerun_uses_persisted_source_rxps_instead_of_temporary_off(self) -> None: @@ -953,6 +1195,12 @@ class SourceCliTests(unittest.TestCase): with ( mock.patch.object(ota, "preflight_inputs"), mock.patch.object(ota, "preflight_source_cli"), + mock.patch.object( + ota, + "ensure_source_clock_gate_safe", + return_value=(1_800_000_000, 1_800_000_059), + ), + mock.patch.object(ota, "ensure_controller_clock_safe"), mock.patch.object( ota, "read_source_rxps", return_value=currently_off ), @@ -969,9 +1217,297 @@ class SourceCliTests(unittest.TestCase): result = ota.main(argv, controller_override=mock.Mock()) self.assertEqual(result, 2) - disable.assert_called_once_with(mock.ANY, currently_off) + disable.assert_not_called() restore.assert_called_once_with(mock.ANY, original) + def test_package_and_confirmation_precede_fresh_source_rxps_disable(self) -> None: + saved = ota.RxpsSettings(True, 18205, 20423, 8, 16) + image = firmware(b"phase ordering" * 500, VERSION_NEW) + package = ota.parse_mota(mota_blob(image)) + normal = ota.RadioSettings(910.525, 62.5, 7, 5, False) + controller = mock.Mock() + controller.get_radio.return_value = normal + events: list[str] = [] + read_count = 0 + + def read_source(_args: argparse.Namespace) -> ota.RxpsSettings: + nonlocal read_count + read_count += 1 + events.append(f"source-read-{read_count}") + return saved + + def prepare( + _args: argparse.Namespace, + _target: ota.TargetInfo, + _work_dir: Path, + ) -> tuple[Path, ota.MotaInfo, bytes]: + events.append("package") + return Path("release.mota"), package, ota.parse_endf(image).body_hash + + def confirm( + _args: argparse.Namespace, + _target: ota.TargetInfo, + _package: ota.MotaInfo, + ) -> None: + events.append("confirm") + + def rehearse(*_args: object) -> None: + events.append("three-minute-preflight") + + def disable( + _args: argparse.Namespace, + current: ota.RxpsSettings, + ) -> bool: + self.assertEqual(current, saved) + events.append("disable") + raise ota.OtaError("stop after ordering assertion") + + with tempfile.TemporaryDirectory() as directory: + argv = [ + "release.zip", "remote", + "--controller-serial", "/dev/controller", + "--source-serial", "/dev/source", + "--password", "secret", + "--work-dir", str(Path(directory) / "work"), + "--yes", + ] + with ( + mock.patch.object(ota, "preflight_inputs"), + mock.patch.object(ota, "preflight_source_cli"), + mock.patch.object(ota, "ensure_controller_clock_safe"), + mock.patch.object( + ota, + "ensure_source_clock_gate_safe", + return_value=(1_800_000_000, 1_800_000_059), + ), + mock.patch.object(ota, "read_source_rxps", side_effect=read_source), + mock.patch.object(ota, "query_target", return_value=target()), + mock.patch.object(ota, "prepare_package", side_effect=prepare), + mock.patch.object( + ota, "read_lora_ota_participant_versions", return_value={} + ), + mock.patch.object( + ota, + "read_remote_rxps", + return_value=ota.RxpsSettings(False, 18205, 20423, 8, 16), + ), + mock.patch.object(ota, "confirm_update", side_effect=confirm), + mock.patch.object( + ota, "run_temp_radio_preflight", side_effect=rehearse + ), + mock.patch.object(ota, "disable_source_rxps", side_effect=disable), + mock.patch.object(ota, "restore_source_rxps") as restore, + contextlib.redirect_stdout(io.StringIO()), + contextlib.redirect_stderr(io.StringIO()), + ): + result = ota.main(argv, controller_override=controller) + + self.assertEqual(result, 2) + self.assertEqual( + events, + [ + "source-read-1", + "package", + "confirm", + "three-minute-preflight", + "source-read-2", + "disable", + ], + ) + restore.assert_called_once_with(mock.ANY, saved) + + def test_package_timeout_never_disables_source_rxps(self) -> None: + saved = ota.RxpsSettings(True, 18205, 20423, 8, 16) + normal = ota.RadioSettings(910.525, 62.5, 7, 5, False) + controller = mock.Mock() + controller.get_radio.return_value = normal + with tempfile.TemporaryDirectory() as directory: + argv = [ + "release.zip", "remote", + "--controller-serial", "/dev/controller", + "--source-serial", "/dev/source", + "--password", "secret", + "--work-dir", str(Path(directory) / "work"), + "--yes", + ] + with ( + mock.patch.object(ota, "preflight_inputs"), + mock.patch.object(ota, "preflight_source_cli"), + mock.patch.object(ota, "ensure_controller_clock_safe"), + mock.patch.object(ota, "read_source_rxps", return_value=saved), + mock.patch.object(ota, "query_target", return_value=target()), + mock.patch.object( + ota, + "prepare_package", + side_effect=ota.OtaError("timed out while running build mOTA"), + ), + mock.patch.object(ota, "disable_source_rxps") as disable, + mock.patch.object(ota, "restore_source_rxps") as restore, + contextlib.redirect_stdout(io.StringIO()), + contextlib.redirect_stderr(io.StringIO()), + ): + result = ota.main(argv, controller_override=controller) + + self.assertEqual(result, 2) + disable.assert_not_called() + restore.assert_not_called() + + def test_confirmation_cancellation_never_disables_source_rxps(self) -> None: + saved = ota.RxpsSettings(True, 18205, 20423, 8, 16) + image = firmware(b"confirmation cancellation" * 300, VERSION_NEW) + package = ota.parse_mota(mota_blob(image)) + normal = ota.RadioSettings(910.525, 62.5, 7, 5, False) + controller = mock.Mock() + controller.get_radio.return_value = normal + with tempfile.TemporaryDirectory() as directory: + argv = [ + "release.mota", "remote", + "--controller-serial", "/dev/controller", + "--source-serial", "/dev/source", + "--password", "secret", + "--work-dir", str(Path(directory) / "work"), + ] + with ( + mock.patch.object(ota, "preflight_inputs"), + mock.patch.object(ota, "preflight_source_cli"), + mock.patch.object(ota, "ensure_controller_clock_safe"), + mock.patch.object(ota, "verify_shared_source_identity"), + mock.patch.object(ota, "read_source_rxps", return_value=saved), + mock.patch.object(ota, "query_target", return_value=target()), + mock.patch.object( + ota, + "prepare_package", + return_value=(Path("release.mota"), package, None), + ), + mock.patch.object( + ota, "read_lora_ota_participant_versions", return_value={} + ), + mock.patch.object( + ota, + "read_remote_rxps", + return_value=ota.RxpsSettings(False, 18205, 20423, 8, 16), + ), + mock.patch.object( + ota, + "confirm_update", + side_effect=ota.OtaError("operator cancelled"), + ), + mock.patch.object(ota, "disable_source_rxps") as disable, + mock.patch.object(ota, "restore_source_rxps") as restore, + contextlib.redirect_stdout(io.StringIO()), + contextlib.redirect_stderr(io.StringIO()), + ): + result = ota.main(argv, controller_override=controller) + + self.assertEqual(result, 2) + disable.assert_not_called() + restore.assert_not_called() + + def test_external_source_rxps_change_before_disable_is_preserved(self) -> None: + saved = ota.RxpsSettings(True, 18205, 20423, 8, 16) + changed = ota.RxpsSettings(True, 12520, 16400, 9, 32) + image = firmware(b"external RXPS change" * 300, VERSION_NEW) + package = ota.parse_mota(mota_blob(image)) + controller = mock.Mock() + controller.get_radio.return_value = ota.RadioSettings( + 910.525, 62.5, 7, 5, False + ) + with tempfile.TemporaryDirectory() as directory: + argv = [ + "release.mota", "remote", + "--controller-serial", "/dev/controller", + "--source-serial", "/dev/source", + "--password", "secret", + "--work-dir", str(Path(directory) / "work"), + "--yes", + ] + with ( + mock.patch.object(ota, "preflight_inputs"), + mock.patch.object(ota, "preflight_source_cli"), + mock.patch.object(ota, "ensure_controller_clock_safe"), + mock.patch.object(ota, "verify_shared_source_identity"), + mock.patch.object( + ota, "read_source_rxps", side_effect=(saved, changed) + ), + mock.patch.object(ota, "query_target", return_value=target()), + mock.patch.object( + ota, + "prepare_package", + return_value=(Path("release.mota"), package, None), + ), + mock.patch.object( + ota, "read_lora_ota_participant_versions", return_value={} + ), + mock.patch.object( + ota, + "read_remote_rxps", + return_value=ota.RxpsSettings(False, 18205, 20423, 8, 16), + ), + mock.patch.object(ota, "confirm_update"), + mock.patch.object(ota, "disable_source_rxps") as disable, + mock.patch.object(ota, "restore_source_rxps") as restore, + contextlib.redirect_stdout(io.StringIO()), + contextlib.redirect_stderr(io.StringIO()), + ): + result = ota.main(argv, controller_override=controller) + + self.assertEqual(result, 2) + disable.assert_not_called() + restore.assert_not_called() + + def test_fresh_source_rxps_read_failure_does_not_restore(self) -> None: + saved = ota.RxpsSettings(True, 18205, 20423, 8, 16) + image = firmware(b"fresh RXPS read failure" * 300, VERSION_NEW) + package = ota.parse_mota(mota_blob(image)) + controller = mock.Mock() + controller.get_radio.return_value = ota.RadioSettings( + 910.525, 62.5, 7, 5, False + ) + with tempfile.TemporaryDirectory() as directory: + argv = [ + "release.mota", "remote", + "--controller-serial", "/dev/controller", + "--source-serial", "/dev/source", + "--password", "secret", + "--work-dir", str(Path(directory) / "work"), + "--yes", + ] + with ( + mock.patch.object(ota, "preflight_inputs"), + mock.patch.object(ota, "preflight_source_cli"), + mock.patch.object(ota, "ensure_controller_clock_safe"), + mock.patch.object(ota, "verify_shared_source_identity"), + mock.patch.object( + ota, + "read_source_rxps", + side_effect=(saved, ota.OtaError("fresh read failed")), + ), + mock.patch.object(ota, "query_target", return_value=target()), + mock.patch.object( + ota, + "prepare_package", + return_value=(Path("release.mota"), package, None), + ), + mock.patch.object( + ota, "read_lora_ota_participant_versions", return_value={} + ), + mock.patch.object( + ota, + "read_remote_rxps", + return_value=ota.RxpsSettings(False, 18205, 20423, 8, 16), + ), + mock.patch.object(ota, "confirm_update"), + mock.patch.object(ota, "disable_source_rxps") as disable, + mock.patch.object(ota, "restore_source_rxps") as restore, + contextlib.redirect_stdout(io.StringIO()), + contextlib.redirect_stderr(io.StringIO()), + ): + result = ota.main(argv, controller_override=controller) + + self.assertEqual(result, 2) + disable.assert_not_called() + restore.assert_not_called() + def test_source_recovery_file_inside_attempt_is_rejected_before_mutation(self) -> None: with tempfile.TemporaryDirectory() as directory: work_dir = Path(directory) / "attempt" @@ -988,6 +1524,7 @@ class SourceCliTests(unittest.TestCase): with ( mock.patch.object(ota, "preflight_inputs"), mock.patch.object(ota, "preflight_source_cli") as source_preflight, + mock.patch.object(ota, "ensure_controller_clock_safe"), mock.patch.object(ota, "read_source_rxps") as read_source, mock.patch.object(ota, "disable_source_rxps") as disable_source, contextlib.redirect_stdout(io.StringIO()), @@ -1024,9 +1561,13 @@ class SourceCliTests(unittest.TestCase): for name in ( "preflight_inputs", "preflight_source_cli", + "ensure_source_clock_gate_safe", + "ensure_controller_clock_safe", "verify_shared_source_identity", "confirm_update", + "run_temp_radio_preflight", "arm_target_temp_radio", + "arm_source_temp_radio_once", "switch_controller_to_temp_radio", "find_and_start_pull", "monitor_download", @@ -1113,6 +1654,8 @@ class SourceCliTests(unittest.TestCase): normal = ota.RadioSettings(910.525, 62.5, 7, 5, False) controller = mock.Mock() controller.get_radio.return_value = normal + current_epoch = int(ota.time.time()) + controller.get_clock.return_value = current_epoch package = mock.Mock( version="1.17.1.5", kind="full", @@ -1133,6 +1676,11 @@ class SourceCliTests(unittest.TestCase): with ( mock.patch.object(ota, "preflight_inputs"), mock.patch.object(ota, "preflight_source_cli"), + mock.patch.object( + ota, + "ensure_source_clock_gate_safe", + return_value=(current_epoch, current_epoch), + ), mock.patch.object(ota, "verify_shared_source_identity"), mock.patch.object( ota, @@ -1151,6 +1699,7 @@ class SourceCliTests(unittest.TestCase): ), mock.patch.object(ota, "read_remote_rxps", return_value=None), mock.patch.object(ota, "confirm_update"), + mock.patch.object(ota, "run_temp_radio_preflight"), mock.patch.object( ota, "arm_target_temp_radio", @@ -1169,7 +1718,11 @@ class SourceCliTests(unittest.TestCase): self.assertEqual(result, 2) self.assertIn( - mock.call(mock.ANY, "tempradio 909.950,250,5,5,120"), + mock.call( + mock.ANY, + "tempradio 909.950,250,5,5,120", + retry=False, + ), source_cli.call_args_list, ) controller.set_radio.assert_not_called() @@ -1198,6 +1751,65 @@ class SourceCliTests(unittest.TestCase): connection.sendall.assert_called_once_with(b"ota status\r\n") self.assertEqual(output, "OTA seeder | install:disabled | serving:1") + def test_shared_full_terminal_banner_binds_supported_ver_to_binary_key( + self, + ) -> None: + key = "A5" * 32 + connection = mock.MagicMock() + connection.__enter__.return_value = connection + connection.recv.side_effect = [ + ( + "===== MeshCore Full Companion Terminal =====\r\n\r\n" + f"WELCOME V4\r\n{key}\r\nCompanion v1.17.1.5\r\n> " + ).encode("ascii"), + b"Companion v1.17.1.5 (protocol 1, build test)\r\n> ", + ] + args = argparse.Namespace( + source_cli_serial=None, + source_serial=None, + source_cli_tcp="192.0.2.10:5002", + meshcli="meshcli", + source_baud=115200, + shared_source_public_key=key.lower(), + ) + with mock.patch.object( + ota.socket, "create_connection", return_value=connection + ): + output = ota.source_cli_command(args, "ver") + self.assertEqual( + output, "Companion v1.17.1.5 (protocol 1, build test)" + ) + connection.sendall.assert_called_once_with(b"ver\r\n") + + def test_shared_full_terminal_rejects_banner_for_a_different_key(self) -> None: + connection = mock.MagicMock() + connection.__enter__.return_value = connection + connection.recv.side_effect = [ + ( + "===== MeshCore Full Companion Terminal =====\r\n\r\n" + f"WELCOME V4\r\n{'B6' * 32}\r\n" + "Companion v1.17.1.5\r\n> " + ).encode("ascii"), + b"Companion v1.17.1.5\r\n> ", + ] + args = argparse.Namespace( + source_cli_serial=None, + source_serial=None, + source_cli_tcp="192.0.2.10:5002", + meshcli="meshcli", + source_baud=115200, + shared_source_public_key="A5" * 32, + ) + with ( + mock.patch.object( + ota.socket, "create_connection", return_value=connection + ), + self.assertRaisesRegex( + ota.OtaError, "terminal identity mismatch" + ), + ): + ota.source_cli_command(args, "ver") + def test_legacy_tcp_ota_console_reply_is_still_accepted(self) -> None: connection = mock.MagicMock() connection.__enter__.return_value = connection @@ -1323,7 +1935,7 @@ class SourceCliTests(unittest.TestCase): ) with mock.patch.object(ota, "run_checked", return_value=completed) as run: output = ota.source_cli_command( - args, "tempradio 909.95,250,5,5,120" + args, "tempradio 909.95,250,5,5,120", retry=False ) wire_command = run.call_args.args[0][-1] @@ -1336,6 +1948,13 @@ class SourceCliTests(unittest.TestCase): ) self.assertIn("OK - temp params", output) + def test_source_relative_tempradio_requires_one_shot_handling(self) -> None: + with self.assertRaisesRegex(ota.OtaError, "state-aware"): + ota.source_cli_command( + argparse.Namespace(), + "tempradio 909.95,250,5,5,120", + ) + def test_serial_companion_seeder_uses_direct_mota_preamble(self) -> None: args = argparse.Namespace( motatool="motatool", @@ -1496,6 +2115,48 @@ class CompatibilityTests(unittest.TestCase): self.assertIsNotNone(selected) self.assertFalse(selected[0].is_full) + def test_raw_package_build_uses_configured_timeout(self) -> None: + image = firmware(b"configured build timeout" * 300, VERSION_NEW) + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + archive_path = root / "release.zip" + with zipfile.ZipFile(archive_path, "w") as archive: + archive.writestr("firmware.bin", image) + args = prepare_args(archive_path, "motatool") + args.package_build_timeout = 4321 + work = root / "work" + work.mkdir() + + def run_tool( + command: list[str], *, label: str, timeout: float | None = None, + **_kwargs: object, + ) -> subprocess.CompletedProcess[str]: + if label == "build mOTA": + output = Path(command[command.index("--out") + 1]) + output.write_bytes(mota_blob(image)) + return subprocess.CompletedProcess( + args=command, returncode=0, stdout="OK", stderr="" + ) + + with mock.patch.object( + ota, "run_checked", side_effect=run_tool + ) as run: + _path, package, _expected = ota.prepare_package( + args, target(), work + ) + + self.assertTrue(package.is_full) + build_call = next( + call for call in run.call_args_list + if call.kwargs["label"] == "build mOTA" + ) + self.assertEqual(build_call.kwargs["timeout"], 4321) + verify_call = next( + call for call in run.call_args_list + if call.kwargs["label"].startswith("verify ") + ) + self.assertEqual(verify_call.kwargs["timeout"], 120) + def test_base_zip_selects_running_hash_not_newest_file(self) -> None: other = firmware(b"other" * 1300, VERSION_NEW) with tempfile.TemporaryDirectory() as directory: @@ -1864,6 +2525,2020 @@ class DownloadSessionTests(unittest.TestCase): sleep.assert_called_once_with(args.discovery_interval) +class TempRadioClockSafetyTests(unittest.TestCase): + HOST_EPOCH = 1_800_000_000 + + class Controller: + def __init__(self, epochs: list[int]) -> None: + self.epochs = iter(epochs) + self.sync_calls = 0 + self.read_calls = 0 + + def get_clock(self, *, timeout: float | None = None) -> int: + if timeout is None or timeout <= 0: + raise AssertionError("clock read was not bounded") + self.read_calls += 1 + return next(self.epochs) + + def sync_clock_forward(self, *, timeout: float | None = None) -> None: + if timeout is None or timeout <= 0: + raise AssertionError("clock sync was not bounded") + self.sync_calls += 1 + + def test_rebooted_controller_months_behind_is_advanced_before_use( + self, + ) -> None: + controller = self.Controller([ + 1_700_000_000, + self.HOST_EPOCH, + ]) + with mock.patch.object( + ota.time, "time", return_value=float(self.HOST_EPOCH) + ): + result = ota.ensure_controller_clock_safe(controller) + + self.assertEqual(result, self.HOST_EPOCH) + self.assertEqual(controller.sync_calls, 1) + self.assertEqual(controller.read_calls, 2) + + def test_controller_at_ten_minute_lead_is_preserved(self) -> None: + controller = self.Controller([ + self.HOST_EPOCH + ota.TEMP_RADIO_CLOCK_MAX_AHEAD_SECONDS + ]) + with mock.patch.object( + ota.time, "time", return_value=float(self.HOST_EPOCH) + ): + result = ota.ensure_controller_clock_safe(controller) + + self.assertEqual( + result, + self.HOST_EPOCH + ota.TEMP_RADIO_CLOCK_MAX_AHEAD_SECONDS, + ) + self.assertEqual(controller.sync_calls, 0) + + def test_controller_beyond_ten_minute_lead_fails_closed(self) -> None: + controller = self.Controller([ + self.HOST_EPOCH + + ota.TEMP_RADIO_CLOCK_MAX_AHEAD_SECONDS + + 1 + ]) + with ( + mock.patch.object( + ota.time, "time", return_value=float(self.HOST_EPOCH) + ), + self.assertRaisesRegex(ota.OtaError, "ahead"), + ): + ota.ensure_controller_clock_safe(controller) + self.assertEqual(controller.sync_calls, 0) + + def test_controller_forward_sync_rejects_backward_readback(self) -> None: + controller = self.Controller([ + self.HOST_EPOCH - 3_600, + self.HOST_EPOCH - 3_601, + ]) + with ( + mock.patch.object( + ota.time, "time", return_value=float(self.HOST_EPOCH) + ), + self.assertRaisesRegex(ota.OtaError, "moved backward"), + ): + ota.ensure_controller_clock_safe(controller) + + def test_source_lost_set_reply_is_resolved_by_forward_readback(self) -> None: + args = argparse.Namespace() + host_epoch = self.HOST_EPOCH + 50 + replies: list[object] = [ + "07:30 - 15/1/2027 UTC", + ota.TransmissionError("serial reply lost"), + "08:03 - 15/1/2027 UTC", + ] + with ( + mock.patch.object( + ota, "source_cli_command", side_effect=replies + ) as source_command, + mock.patch.object( + ota.time, "time", return_value=float(host_epoch) + ), + ): + window = ota.ensure_source_clock_safe(args) + + guarded_minimum = ( + host_epoch + ota.TEMP_RADIO_SOURCE_CLOCK_PIN_LEAD_SECONDS + ) + requested = ((guarded_minimum + 59) // 60) * 60 + self.assertEqual(requested % 60, 0) + self.assertEqual(window, (requested, requested + 59)) + self.assertEqual( + [call.args[1] for call in source_command.call_args_list], + ["clock", f"time {requested}", "clock"], + ) + self.assertFalse(source_command.call_args_list[1].kwargs["retry"]) + + def test_source_host_inside_displayed_minute_never_writes_backward( + self, + ) -> None: + # Physical regression: terminal showed 09:38, while host time was + # 09:38:50. The old code wrote that exact host epoch even though the + # hidden source seconds could already be 51-59. + minute_start = 1_788_082_680 + expected_window = (minute_start, minute_start + 59) + args = argparse.Namespace() + for hidden_second in (0, 50, 59): + with self.subTest(hidden_second=hidden_second): + host_epoch = minute_start + hidden_second + with ( + mock.patch.object( + ota, + "source_cli_command", + return_value="09:38 - 30/8/2026 UTC", + ) as source_command, + mock.patch.object( + ota.time, "time", return_value=float(host_epoch) + ), + ): + window = ota.ensure_source_clock_safe(args) + + self.assertEqual(window, expected_window) + source_command.assert_called_once_with( + args, "clock", bounded=True + ) + + def test_source_known_future_minute_is_preserved_without_write(self) -> None: + args = argparse.Namespace() + with ( + mock.patch.object( + ota, + "source_cli_command", + return_value="08:01 - 15/1/2027 UTC", + ) as source_command, + mock.patch.object( + ota.time, "time", return_value=float(self.HOST_EPOCH) + ), + ): + window = ota.ensure_source_clock_safe(args) + + self.assertEqual( + window, (self.HOST_EPOCH + 60, self.HOST_EPOCH + 119) + ) + source_command.assert_called_once_with(args, "clock", bounded=True) + + def test_source_backward_rejection_is_read_back_once_not_replayed( + self, + ) -> None: + args = argparse.Namespace() + requested = ( + self.HOST_EPOCH + + ota.TEMP_RADIO_SOURCE_CLOCK_PIN_LEAD_SECONDS + ) + for rejection in ( + "(ERR: clock cannot go backwards)", + ota.OtaError( + "source rejected fixed time: ERR: clock cannot go backwards" + ), + ): + with self.subTest(rejection=type(rejection).__name__): + replies: list[object] = [ + "07:30 - 15/1/2027 UTC", + rejection, + "08:03 - 15/1/2027 UTC", + ] + with ( + mock.patch.object( + ota, "source_cli_command", side_effect=replies + ) as source_command, + mock.patch.object( + ota.time, "time", return_value=float(self.HOST_EPOCH) + ), + ): + window = ota.ensure_source_clock_safe(args) + + self.assertEqual( + [call.args[1] for call in source_command.call_args_list], + ["clock", f"time {requested}", "clock"], + ) + self.assertEqual( + window, (self.HOST_EPOCH + 180, self.HOST_EPOCH + 239) + ) + + def test_source_forward_write_must_reach_guarded_epoch(self) -> None: + args = argparse.Namespace() + replies = [ + "07:30 - 15/1/2027 UTC", + "OK - clock set: 08:02 - 15/1/2027 UTC", + "08:01 - 15/1/2027 UTC", + ] + with ( + mock.patch.object(ota, "source_cli_command", side_effect=replies), + mock.patch.object( + ota.time, "time", return_value=float(self.HOST_EPOCH) + ), + self.assertRaisesRegex(ota.OtaError, "requested guarded epoch"), + ): + ota.ensure_source_clock_safe(args) + + def test_source_host_backward_step_cannot_reduce_guarded_base(self) -> None: + args = argparse.Namespace() + replies = ["07:30 - 15/1/2027 UTC"] + with ( + mock.patch.object( + ota, "source_cli_command", side_effect=replies + ) as source_command, + mock.patch.object( + ota.time, + "time", + side_effect=( + float(self.HOST_EPOCH), + float(self.HOST_EPOCH - 600), + ), + ), + self.assertRaisesRegex(ota.OtaError, "ten-minute drift policy"), + ): + ota.ensure_source_clock_safe(args) + + # The host stepped backward too far to prove a policy-safe guarded + # epoch, so no state-changing command was attempted. + source_command.assert_called_once_with(args, "clock", bounded=True) + + def test_source_clock_gate_inspects_temp_work_before_clock(self) -> None: + args = argparse.Namespace( + source_serial="/dev/source", + source_cli_serial=None, + source_cli_tcp=None, + ) + events: list[str] = [] + + def status(*_args: object, **_kwargs: object) -> tuple[str, None]: + events.append("immediate-status") + return "inactive", None + + def schedule(*_args: object, **_kwargs: object) -> str: + events.append("fixed-schedule") + return " -> > -none-\r\n> " + + def clock(*_args: object, **_kwargs: object) -> tuple[int, int]: + events.append("clock") + return self.HOST_EPOCH, self.HOST_EPOCH + 59 + + with ( + mock.patch.object( + ota, "read_source_temp_radio_status", side_effect=status + ), + mock.patch.object( + ota, "optional_source_cli_command", side_effect=schedule + ), + mock.patch.object( + ota, "ensure_source_clock_safe", side_effect=clock + ), + ): + result = ota.ensure_source_clock_gate_safe(args) + + self.assertEqual(result, (self.HOST_EPOCH, self.HOST_EPOCH + 59)) + self.assertEqual(events, ["immediate-status", "fixed-schedule", "clock"]) + + def test_source_clock_gate_refuses_active_or_pending_work(self) -> None: + args = argparse.Namespace( + source_serial="/dev/source", + source_cli_serial=None, + source_cli_tcp=None, + ) + for state in ("active", "pending"): + with self.subTest(state=state): + with ( + mock.patch.object( + ota, + "read_source_temp_radio_status", + return_value=(state, mock.Mock()), + ), + mock.patch.object( + ota, "optional_source_cli_command" + ) as schedule, + mock.patch.object(ota, "ensure_source_clock_safe") as clock, + self.assertRaisesRegex( + ota.OtaError, "active or pending TempRadio work" + ), + ): + ota.ensure_source_clock_gate_safe(args) + schedule.assert_not_called() + clock.assert_not_called() + + def test_source_clock_gate_refuses_existing_fixed_schedule(self) -> None: + args = argparse.Namespace( + source_serial="/dev/source", + source_cli_serial=None, + source_cli_tcp=None, + ) + with ( + mock.patch.object( + ota, + "read_source_temp_radio_status", + return_value=("inactive", None), + ), + mock.patch.object( + ota, + "optional_source_cli_command", + return_value=" -> > 1:909.95,250,5,5@100-200\r\n> ", + ), + mock.patch.object(ota, "ensure_source_clock_safe") as clock, + self.assertRaisesRegex(ota.OtaError, "scheduled TempRadio work"), + ): + ota.ensure_source_clock_gate_safe(args) + clock.assert_not_called() + + def test_source_ten_minute_display_is_too_ambiguous_to_accept(self) -> None: + args = argparse.Namespace() + with ( + mock.patch.object( + ota, + "source_cli_command", + return_value="08:10 - 15/1/2027 UTC", + ) as source_command, + mock.patch.object( + ota.time, "time", return_value=float(self.HOST_EPOCH) + ), + self.assertRaisesRegex(ota.OtaError, "minute resolution"), + ): + ota.ensure_source_clock_safe(args) + source_command.assert_called_once_with(args, "clock", bounded=True) + + def test_invalid_or_ambiguous_text_clock_is_rejected(self) -> None: + with self.assertRaisesRegex(ota.OtaError, "2 unambiguous"): + ota.parse_source_clock_window( + "08:00 - 15/1/2027 UTC\n08:01 - 15/1/2027 UTC", + "source", + ) + + def test_remote_clock_pin_loss_is_read_back_without_replay(self) -> None: + class Controller: + reply_timeout = 20 + + def __init__(self) -> None: + self.epochs = iter((1_000, 1_120)) + self.commands: list[tuple[str, bool | None]] = [] + + def get_contact_clock( + self, + _target: str, + _key: str, + *, + timeout: float | None = None, + ) -> int: + if timeout is None or timeout <= 0: + raise AssertionError("unbounded clock read") + return next(self.epochs) + + def remote_command( + self, + _target: str, + command: str, + **kwargs: object, + ) -> str: + self.commands.append((command, kwargs.get("retry"))) + raise ota.TransmissionError("lost one-shot fixed reply") + + controller = Controller() + with ( + mock.patch.object(ota.time, "time", return_value=1_000.0), + mock.patch.object(ota.time, "monotonic", return_value=0.0), + mock.patch.object(ota.time, "sleep") as sleep, + ): + sample = ota.pin_remote_clock_forward( + controller, "remote", "AA" * 32 + ) + + self.assertEqual(sample.epoch, 1_120) + self.assertEqual( + controller.commands, + [("time 1120", False)], + ) + sleep.assert_not_called() + + def test_remote_clock_pin_does_not_add_lead_to_existing_lead(self) -> None: + controller = mock.Mock() + controller.reply_timeout = 20 + controller.get_contact_clock.side_effect = (1_300, 1_301) + controller.remote_command.return_value = ( + "OK - clock set: 08:00 - 15/1/2027 UTC" + ) + with ( + mock.patch.object(ota.time, "time", return_value=1_000.0), + mock.patch.object(ota.time, "monotonic", return_value=0.0), + ): + sample = ota.pin_remote_clock_forward( + controller, "remote", "AA" * 32 + ) + + self.assertEqual(sample.epoch, 1_301) + self.assertEqual( + controller.remote_command.call_args.args[1], "time 1301" + ) + + def test_unreconciled_remote_clock_write_is_never_replayed(self) -> None: + controller = mock.Mock() + controller.reply_timeout = 20 + controller.get_contact_clock.side_effect = (1_000, 1_001) + controller.remote_command.side_effect = ota.TransmissionError( + "lost without delivery" + ) + with ( + mock.patch.object(ota.time, "time", return_value=1_000.0), + mock.patch.object(ota.time, "monotonic", return_value=0.0), + self.assertRaisesRegex(ota.OtaError, "was not replayed"), + ): + ota.pin_remote_clock_forward( + controller, "remote", "AA" * 32 + ) + controller.remote_command.assert_called_once() + + def test_remote_clock_over_ten_minutes_ahead_is_not_mutated(self) -> None: + controller = mock.Mock() + controller.get_contact_clock.return_value = 1_601 + with ( + mock.patch.object(ota.time, "time", return_value=1_000.0), + mock.patch.object(ota.time, "monotonic", return_value=0.0), + self.assertRaisesRegex(ota.OtaError, "maximum allowed"), + ): + ota.pin_remote_clock_forward( + controller, "remote", "AA" * 32 + ) + controller.remote_command.assert_not_called() + + +class TempRadioNormalBaselineBudgetTests(unittest.TestCase): + class Clock: + def __init__(self) -> None: + self.now = 0.0 + self.sleeps: list[float] = [] + + def monotonic(self) -> float: + return self.now + + def sleep(self, seconds: float) -> None: + self.sleeps.append(seconds) + self.now += seconds + + def test_read_only_baseline_survives_four_consecutive_reply_losses( + self, + ) -> None: + clock = self.Clock() + expected = target(base_hash=b"\x01\x23\x45\x67\x89\xAB\xCD\xEF") + + class Controller: + reply_timeout = 20 + + def __init__(self) -> None: + self.calls: list[tuple[str, dict[str, object]]] = [] + self.status_attempts = 0 + + def remote_command( + self, + _target: str, + command: str, + **kwargs: object, + ) -> str: + self.calls.append((command, kwargs)) + if command == "ota status": + self.status_attempts += 1 + if self.status_attempts <= 4: + # Model the physical 20-second reply wait before each + # missed read-only response. + clock.now += 20 + raise ota.TransmissionError("missed status reply") + return ( + "OTA | no download | " + f"target:{expected.target_id:08X} hw={expected.hw_id}" + ) + if command == "ota self": + return ( + "self body=1 image=2 " + f"base_hash={expected.base_hash.hex().upper()}" + ) + raise AssertionError(command) + + controller = Controller() + with ( + mock.patch.object( + ota.time, "monotonic", side_effect=clock.monotonic + ), + mock.patch.object(ota.time, "sleep", side_effect=clock.sleep), + ): + ota.require_destination_preflight_identity( + controller, + argparse.Namespace(target="remote"), + expected, + "normal-channel baseline", + deadline=( + clock.monotonic() + + ota.TEMP_RADIO_NORMAL_BASELINE_TIMEOUT_SECONDS + ), + retry_limit=ota.TEMP_RADIO_NORMAL_BASELINE_RETRY_LIMIT, + deadline_label="normal-channel baseline proof budget", + ) + + self.assertEqual(controller.status_attempts, 5) + self.assertEqual( + [command for command, _kwargs in controller.calls], + ["ota status"] * 5 + ["ota self"], + ) + self.assertTrue( + all( + kwargs.get("retry") is False + for _command, kwargs in controller.calls + ) + ) + self.assertTrue( + all( + float(kwargs["operation_timeout"]) + <= ota.TEMP_RADIO_PREFLIGHT_OPERATION_TIMEOUT_SECONDS + for _command, kwargs in controller.calls + ) + ) + self.assertLess( + clock.now, ota.TEMP_RADIO_NORMAL_BASELINE_TIMEOUT_SECONDS + ) + + def test_baseline_deadline_fails_without_a_state_changing_command( + self, + ) -> None: + clock = self.Clock() + + class Controller: + reply_timeout = 20 + + def __init__(self) -> None: + self.calls: list[tuple[str, dict[str, object]]] = [] + + def remote_command( + self, + _target: str, + command: str, + **kwargs: object, + ) -> str: + self.calls.append((command, kwargs)) + clock.now += 30 + raise ota.TransmissionError("still unreachable") + + controller = Controller() + with ( + mock.patch.object( + ota.time, "monotonic", side_effect=clock.monotonic + ), + mock.patch.object(ota.time, "sleep", side_effect=clock.sleep), + self.assertRaisesRegex( + ota.TransmissionError, + "normal-channel baseline proof budget", + ), + ): + ota.require_destination_preflight_identity( + controller, + argparse.Namespace(target="remote"), + target(), + "normal-channel baseline", + deadline=65.0, + retry_limit=ota.TEMP_RADIO_NORMAL_BASELINE_RETRY_LIMIT, + deadline_label="normal-channel baseline proof budget", + ) + + self.assertEqual( + [command for command, _kwargs in controller.calls], + ["ota status", "ota status"], + ) + self.assertTrue( + all( + kwargs.get("retry") is False + for _command, kwargs in controller.calls + ) + ) + + def test_reply_completing_after_deadline_is_not_accepted(self) -> None: + clock = self.Clock() + + class Controller: + reply_timeout = 20 + + def remote_command( + self, + _target: str, + _command: str, + **_kwargs: object, + ) -> str: + clock.now = 10.0 + return "late success" + + with ( + mock.patch.object( + ota.time, "monotonic", side_effect=clock.monotonic + ), + self.assertRaisesRegex( + ota.TransmissionError, + "completed at or after.*expired", + ), + ): + ota.bounded_remote_command( + Controller(), + "remote", + "ota status", + deadline=10.0, + deadline_label="normal-channel baseline proof budget", + ) + + def test_status_and_self_share_one_deadline(self) -> None: + clock = self.Clock() + expected = target() + + class Controller: + reply_timeout = 20 + + def __init__(self) -> None: + self.calls: list[tuple[str, dict[str, object]]] = [] + + def remote_command( + self, + _target: str, + command: str, + **kwargs: object, + ) -> str: + self.calls.append((command, kwargs)) + if command == "ota status": + clock.now = 225.0 + return ( + "OTA | no download | " + f"target:{expected.target_id:08X} hw={expected.hw_id}" + ) + if command == "ota self": + return "self body=1 image=2 base_hash=0000000000000000" + raise AssertionError(command) + + controller = Controller() + with mock.patch.object( + ota.time, "monotonic", side_effect=clock.monotonic + ): + ota.require_destination_preflight_identity( + controller, + argparse.Namespace(target="remote"), + expected, + "normal-channel baseline", + deadline=240.0, + retry_limit=ota.TEMP_RADIO_NORMAL_BASELINE_RETRY_LIMIT, + deadline_label="normal-channel baseline proof budget", + ) + + self.assertEqual( + controller.calls[1][1]["operation_timeout"], 15.0 + ) + + def test_live_lease_keeps_the_smaller_default_retry_limit(self) -> None: + clock = self.Clock() + + class Controller: + reply_timeout = 20 + + def __init__(self) -> None: + self.calls: list[dict[str, object]] = [] + + def remote_command( + self, + _target: str, + _command: str, + **kwargs: object, + ) -> str: + self.calls.append(kwargs) + raise ota.TransmissionError("missed live-lease reply") + + controller = Controller() + with ( + mock.patch.object( + ota.time, "monotonic", side_effect=clock.monotonic + ), + mock.patch.object(ota.time, "sleep", side_effect=clock.sleep), + self.assertRaisesRegex( + ota.TransmissionError, + rf"failed after {ota.TRANSMISSION_RETRY_LIMIT} retries", + ), + ): + ota.bounded_remote_command( + controller, + "remote", + "ota self", + deadline=1_000.0, + ) + + self.assertEqual( + len(controller.calls), ota.TRANSMISSION_RETRY_LIMIT + 1 + ) + self.assertTrue( + all(call.get("retry") is False for call in controller.calls) + ) + + +class TempRadioPreflightTests(unittest.TestCase): + NORMAL = ota.RadioSettings(910.525, 62.5, 7, 5, False) + TEMP = ota.RadioSettings(909.95, 250.0, 5, 5, False) + CONTROLLER_KEY = "AA" * 32 + SOURCE_KEY = "BB" * 32 + NODE_KEYS = { + "remote": "11" * 32, + "far": "22" * 32, + "near": "33" * 32, + "source": SOURCE_KEY, + } + + class Clock: + def __init__(self, *, interrupt_count: int = 0) -> None: + self.now = 0.0 + self.sleeps: list[float] = [] + self.interrupts_remaining = interrupt_count + self.expiry_wait_started = False + + def monotonic(self) -> float: + return self.now + + def sleep(self, seconds: float) -> None: + self.sleeps.append(seconds) + # The fixed-window design also has a deliberate pre-activation + # wait. Interrupt only the later owned-lease expiry wait. + if seconds > 150: + self.expiry_wait_started = True + if ( + self.interrupts_remaining > 0 + and self.expiry_wait_started + and seconds > 0 + ): + self.interrupts_remaining -= 1 + # First leave five seconds, then half of any smaller tail. + # Cleanup must keep using the same absolute deadline through + # every interrupt rather than restarting or returning early. + tail = 5.0 if seconds > 30 else max(0.5, seconds / 2.0) + self.now += max(0.0, seconds - tail) + raise KeyboardInterrupt("synthetic interrupted expiry wait") + self.now += seconds + + class Controller: + def __init__( + self, + clock: "TempRadioPreflightTests.Clock", + failure: str | None = None, + *, + shared: bool = False, + ) -> None: + self.clock = clock + self.failure = failure + self.shared = shared + self.password = "secret" + self.reply_timeout = 20 + self.radio = TempRadioPreflightTests.NORMAL + self.remote_calls: list[tuple[str, str, dict[str, object]]] = [] + self.source_challenges: list[tuple[str, str, float | None]] = [] + self.radio_calls: list[tuple[ota.RadioSettings, float | None]] = [] + self.temp_until = { + name: 0.0 for name in TempRadioPreflightTests.NODE_KEYS + } + self.scheduled_temp: dict[str, tuple[float, float]] = {} + self.delayed_temp_delivery: dict[ + str, tuple[float, float, float] + ] = {} + self.clock_adjustment = { + name: 0 for name in TempRadioPreflightTests.NODE_KEYS + } + self.source_tuple = TempRadioPreflightTests.TEMP + self.lost_ack_sent = False + self.lost_source_ack_sent = False + self.controller_restore_interrupt_sent = False + self.slow_recovery_restore_completed_at: float | None = None + + def node_on_temp(self, name: str) -> bool: + delayed = self.delayed_temp_delivery.get(name) + if delayed is not None and self.clock.now >= delayed[0]: + _delivery_at, start_at, end_at = delayed + self.scheduled_temp[name] = (start_at, end_at) + del self.delayed_temp_delivery[name] + scheduled = self.scheduled_temp.get(name) + scheduled_active = bool( + scheduled is not None + and scheduled[0] <= self.clock.now < scheduled[1] + ) + return self.clock.now < self.temp_until[name] or scheduled_active + + def controller_on_temp(self) -> bool: + if self.shared: + return self.node_on_temp("source") + return self.radio.matches(TempRadioPreflightTests.TEMP) + + def all_nodes_normal(self) -> bool: + return not any(self.node_on_temp(name) for name in self.temp_until) + + def get_radio(self, timeout: float | None = None) -> ota.RadioSettings: + if timeout is not None: + self.assert_bounded_timeout(timeout) + return self.radio + + @staticmethod + def assert_bounded_timeout(timeout: float) -> None: + if not 0 < timeout <= ota.TEMP_RADIO_PREFLIGHT_OPERATION_TIMEOUT_SECONDS: + raise AssertionError(f"unbounded rehearsal timeout: {timeout}") + + def get_public_key(self, timeout: float | None = None) -> str: + if timeout is not None: + self.assert_bounded_timeout(timeout) + if self.failure == "temp_controller_identity" and self.controller_on_temp(): + return "CC" * 32 + return TempRadioPreflightTests.CONTROLLER_KEY.lower() + + def get_clock(self, timeout: float | None = None) -> int: + if timeout is not None: + self.assert_bounded_timeout(timeout) + return 1_800_000_000 + int(self.clock.now) + + def get_contact_clock( + self, + name: str, + expected_public_key: str, + *, + timeout: float | None = None, + ) -> int: + if timeout is not None: + self.assert_bounded_timeout(timeout) + if expected_public_key.lower() != ( + TempRadioPreflightTests.NODE_KEYS[name].lower() + ): + raise ota.OtaError("synthetic clock identity mismatch") + if self.failure == "clock_read_rtt": + # Exercise distinct complete request/response intervals. The + # returned epoch is sampled at completion, while production + # scheduling must conservatively own any point in the RTT. + self.clock.now += { + "remote": 17.0, + "far": 5.0, + "near": 23.0, + }.get(name, 0.0) + # Deliberately model minutes of inter-node offset within the + # ten-minute safety policy. Projection into each local RTC must + # still yield one common monotonic window. + offsets = {"remote": 300, "far": -300, "near": 180} + return ( + 1_800_000_000 + + offsets.get(name, 0) + + self.clock_adjustment[name] + + int(self.clock.now) + ) + + def prove_contact_ack( + self, + name: str, + expected_public_key: str, + label: str, + *, + timeout: float | None = None, + ) -> None: + if timeout is not None: + self.assert_bounded_timeout(timeout) + self.source_challenges.append((name, label, timeout)) + if name != "source": + raise ota.OtaError(f"unexpected challenge contact {name}") + if self.node_on_temp(name) != self.controller_on_temp(): + raise ota.TransmissionError( + "synthetic source is not on the controller tuple" + ) + on_temp = self.controller_on_temp() + if ( + self.failure == "baseline_source_air" + and not on_temp + and self.clock.now == 0 + ): + raise ota.OtaError( + f"{label} contact key mismatch for source" + ) + if self.failure == "temp_source_air" and on_temp: + raise ota.TransmissionError( + f"{label} received no matching ACK from source" + ) + if ( + self.failure == "normal_source_air" + and not on_temp + and bool(self.scheduled_temp) + ): + raise ota.TransmissionError( + f"{label} received no matching ACK from source" + ) + if expected_public_key.lower() != ( + TempRadioPreflightTests.SOURCE_KEY.lower() + ): + raise ota.OtaError( + f"{label} contact key mismatch for source" + ) + + def set_radio( + self, + settings: ota.RadioSettings, + _label: str, + timeout: float | None = None, + ) -> None: + if timeout is not None: + self.assert_bounded_timeout(timeout) + self.radio_calls.append((settings, timeout)) + if ( + self.failure == "controller_restore_interrupt" + and settings.matches(TempRadioPreflightTests.NORMAL) + and not self.controller_restore_interrupt_sent + ): + self.controller_restore_interrupt_sent = True + raise KeyboardInterrupt( + "synthetic second interrupt during controller restore" + ) + self.radio = settings + if ( + self.failure == "controller_handoff" + and settings.matches(TempRadioPreflightTests.TEMP) + ): + raise ota.OtaError("synthetic controller handoff failure") + if ( + self.failure == "controller_restore_interrupt" + and settings.matches(TempRadioPreflightTests.TEMP) + ): + raise KeyboardInterrupt( + "synthetic first interrupt during controller handoff" + ) + + def remote_command( + self, + name: str, + command: str, + **kwargs: object, + ) -> str: + self.remote_calls.append((name, command, dict(kwargs))) + operation_timeout = kwargs.get("operation_timeout") + if operation_timeout is not None: + self.assert_bounded_timeout(float(operation_timeout)) + on_temp = self.controller_on_temp() + if self.node_on_temp(name) != on_temp: + raise ota.TransmissionError( + f"synthetic {name} is not on the controller tuple" + ) + if command.startswith("time "): + requested = int(command.split(" ", 1)[1]) + offsets = {"remote": 300, "far": -300, "near": 180} + current = ( + 1_800_000_000 + + offsets.get(name, 0) + + self.clock_adjustment[name] + + int(self.clock.now) + ) + if requested <= current: + return "(ERR: clock cannot go backwards)" + self.clock_adjustment[name] += requested - current + return "OK - clock set: 08:00 - 15/1/2027 UTC" + if command.startswith("set tempradioat "): + values = command.split(" ", 2)[2].split(",") + start_epoch = int(values[-2]) + end_epoch = int(values[-1]) + offsets = {"remote": 300, "far": -300, "near": 180} + node_epoch = ( + 1_800_000_000 + offsets.get(name, 0) + + self.clock_adjustment[name] + + int(self.clock.now) + ) + start_at = self.clock.now + (start_epoch - node_epoch) + end_at = self.clock.now + (end_epoch - node_epoch) + if self.failure == "lease_deadline": + self.clock.now += 70.0 + node_epoch = ( + 1_800_000_000 + offsets.get(name, 0) + + self.clock_adjustment[name] + + int(self.clock.now) + ) + if node_epoch >= start_epoch: + return "Error: start is in the past" + if self.failure == "wrong_duration" and name == "remote": + end_at = start_at + 30 * 60 + if self.failure == "target_arm" and name == "remote": + return "Error, invalid params" + if self.failure == "relay_arm" and name == "far": + return "Error, invalid params" + if ( + self.failure == "lost_target_ack_without_delivery" + and name == "remote" + and not self.lost_ack_sent + ): + self.lost_ack_sent = True + raise ota.TransmissionError("synthetic lost acknowledgement") + if ( + self.failure == "lost_target_ack_slow" + and name == "remote" + and not self.lost_ack_sent + ): + self.lost_ack_sent = True + self.clock.now += 30.0 + self.scheduled_temp[name] = (start_at, end_at) + raise ota.TransmissionError( + "synthetic slow lost acknowledgement" + ) + if ( + self.failure == "lost_target_ack_delayed" + and name == "remote" + and not self.lost_ack_sent + ): + self.lost_ack_sent = True + self.delayed_temp_delivery[name] = ( + self.clock.now + 10.0, start_at, end_at + ) + raise ota.TransmissionError("synthetic lost acknowledgement") + self.scheduled_temp[name] = ( + start_at, + float("inf") + if self.failure in ( + "stuck_remote_return", + "stuck_remote_slow_restore", + "late_recovery_source", + "wrong_recovery_duration", + "wrong_duration", + ) and name == "remote" + else end_at, + ) + if ( + self.failure == "lost_target_ack" + and name == "remote" + and not self.lost_ack_sent + ): + self.lost_ack_sent = True + raise ota.TransmissionError("synthetic lost acknowledgement") + return "OK - tempradioat 1 in 2m" + if command == "normalradio": + if ( + self.failure == "stuck_remote_slow_restore" + and name == "remote" + ): + self.clock.now += 30.0 + self.slow_recovery_restore_completed_at = self.clock.now + self.scheduled_temp.pop(name, None) + self.temp_until[name] = self.clock.now + 1.5 + raise ota.TransmissionError( + "synthetic slow lost recovery acknowledgement" + ) + self.scheduled_temp.pop(name, None) + self.temp_until[name] = self.clock.now + 1.5 + return "OK - normal radio restore scheduled" + if command == "get public.key": + key = TempRadioPreflightTests.NODE_KEYS[name] + if self.failure == "temp_remote_identity" and on_temp and name == "far": + return "> " + "44" * 32 + if self.failure == "normal_remote_identity" and not on_temp and self.scheduled_temp and name == "remote": + return "> " + "44" * 32 + return f"> {key}" + if command == "get tempradioat": + if self.failure == "baseline_schedule" and name == "far": + return "> 1:909.95,250,5,5@100-200" + schedule_checks = sum( + 1 + for call_name, call_command, _options in self.remote_calls + if call_name == name and call_command == "get tempradioat" + ) + if ( + self.failure == "remote_schedule_appears_during_baseline" + and name == "remote" + and schedule_checks >= 2 + ): + return "> 1:909.95,250,5,5@100-200" + if ( + self.failure == "baseline_scheduleless" + and name == "remote" + ): + return "Unknown command" + return "> -none-" + if command == "ota status": + if self.failure == "baseline_destination" and not on_temp and self.clock.now == 0: + return "OTA | no download | target:DEADBEEF hw=TestBoard" + return "OTA | no download | target:1234ABCD hw=TestBoard" + if command == "ota self": + if self.failure == "temp_destination" and on_temp: + return "self body=1 image=2 base_hash=DEADBEEFDEADBEEF" + return "self body=1 image=2 base_hash=0000000000000000" + raise AssertionError(f"unexpected remote command {name}: {command}") + + @staticmethod + def args(*, shared: bool = False) -> argparse.Namespace: + return argparse.Namespace( + target="remote", + relay_values=[("far", "far-secret"), ("near", "near-secret")], + temp_values=(909.95, 250.0, 5, 5, 120), + source_already_temp=False, + source_shares_controller=shared, + source_serial=None if shared else "/dev/source", + source_cli_serial=None, + source_cli_tcp="192.0.2.1:5002" if shared else None, + source_contact_value=None, + shared_source_public_key=( + TempRadioPreflightTests.CONTROLLER_KEY if shared else None + ), + ) + + def run_rehearsal( + self, + *, + failure: str | None = None, + shared: bool = False, + capture_error: bool = False, + ) -> tuple[ + "TempRadioPreflightTests.Controller", + list[tuple[str, dict[str, object]]], + "TempRadioPreflightTests.Clock", + dict[str, object], + BaseException | None, + ]: + clock = self.Clock( + interrupt_count=( + 2 if failure == "double_interrupt_wait" + else 1 if failure == "interrupt_wait" + else 0 + ) + ) + controller = self.Controller(clock, failure, shared=shared) + source_calls: list[tuple[str, dict[str, object]]] = [] + source_state: dict[str, object] = {"tuple": self.TEMP} + args = self.args(shared=shared) + if failure == "late_recovery_source": + args.relay_values = [] + + def source_active() -> bool: + return controller.node_on_temp("source") + + def source_command( + _args: argparse.Namespace, + command: str, + check: bool = True, + **kwargs: object, + ) -> str: + source_calls.append((command, {"check": check, **kwargs})) + if command == "get public.key": + if shared: + raise ota.OtaError( + "source rejected 'get public.key': " + "ERROR: unknown command: get public.key" + ) + if failure == "baseline_source_identity" and len( + [call for call, _options in source_calls if call == command] + ) == 1: + return "not a key" + if failure == "temp_source_identity" and source_active(): + return "> " + "DD" * 32 + source_key = self.CONTROLLER_KEY if shared else self.SOURCE_KEY + return f" -> > {source_key}\r\n> " + if command == "get name": + return " -> > source\r\n> " + if command == "ver": + return "Companion v1.17.1.5" + if command == "get tempradioat": + if failure == "source_schedule_transport": + raise ota.TransmissionError("synthetic source schedule link loss") + schedule_checks = sum( + 1 + for call, _options in source_calls + if call == "get tempradioat" + ) + if ( + failure == "source_schedule_appears_during_baseline" + and schedule_checks >= 2 + ): + return " -> > 1:909.95,250,5,5@100-200\r\n> " + return " -> > -none-\r\n> " + if command.startswith("tempradio "): + minutes = int(command.rsplit(",", 1)[1]) + if failure == "source_arm": + raise ota.OtaError("synthetic source arm failure") + if ( + failure == "lost_source_ack_without_delivery" + and not controller.lost_source_ack_sent + ): + controller.lost_source_ack_sent = True + raise ota.TransmissionError( + "synthetic lost source acknowledgement" + ) + if failure == "late_recovery_source" and minutes == 1: + clock.now += 30.0 + controller.temp_until["source"] = clock.now + 60.0 + raise ota.TransmissionError( + "synthetic late accepted recovery re-arm" + ) + if failure == "wrong_recovery_duration" and minutes == 1: + source_state["recovery_accepted_at"] = clock.now + controller.temp_until["source"] = clock.now + 30 * 60 + return " -> OK - temp params for 30 mins\r\n> " + controller.temp_until["source"] = ( + float("inf") + if failure in ( + "normal_source_return", + "stuck_source_return", + "legacy_stuck_source", + ) + else clock.now + minutes * 60 + ) + if ( + failure == "lost_source_ack" + and not controller.lost_source_ack_sent + ): + controller.lost_source_ack_sent = True + raise ota.TransmissionError( + "synthetic lost source acknowledgement" + ) + return ( + f" -> OK - temp params for {minutes} mins\r\n> " + ) + if command == "tempradio": + if failure == "source_status_transport": + raise ota.TransmissionError("synthetic source status link loss") + if failure == "source_status_empty": + return "" + if failure in ("legacy_source", "legacy_stuck_source"): + raise ota.OtaError("source rejected 'tempradio': Unknown command") + if failure == "preexisting_source_active" and not source_active(): + return "TempRadio active: 908.000,125.00,7,5 80s left\r\n> " + if failure == "preexisting_source_pending" and not source_active(): + return "TempRadio pending: 908.000,125.00,7,5 80s left\r\n> " + if source_active(): + if failure == "temp_source_tuple": + return "TempRadio active: 908.000,250.00,5,5 170s left" + return " -> TempRadio active: 909.950,250.00,5,5 170s left\r\n> " + return "TempRadio inactive\r\n> " + if command == "normalradio": + if failure in ( + "late_recovery_source", + "wrong_recovery_duration", + ) and clock.now >= 180: + raise ota.TransmissionError( + "synthetic lost source recovery restore" + ) + controller.temp_until["source"] = clock.now + return " -> OK - normal radio restore scheduled\r\n> " + raise AssertionError(f"unexpected source command: {command}") + + error: BaseException | None = None + with ( + mock.patch.object(ota, "source_cli_command", side_effect=source_command), + mock.patch.object( + ota, + "ensure_controller_clock_safe", + return_value=1_800_000_000, + ), + mock.patch.object( + ota, + "ensure_source_clock_safe", + return_value=(1_800_000_000, 1_800_000_059), + ), + mock.patch.object(ota.time, "monotonic", side_effect=clock.monotonic), + mock.patch.object( + ota.time, + "time", + side_effect=lambda: 1_800_000_000 + clock.now, + ), + mock.patch.object(ota.time, "sleep", side_effect=clock.sleep), + contextlib.redirect_stdout(io.StringIO()), + ): + try: + ota.run_temp_radio_preflight( + controller, + args, + target(base_hash=b"\0" * 8), + self.NORMAL, + self.TEMP, + ) + except BaseException as exc: + if not capture_error: + raise + error = exc + source_state["active"] = source_active() + return controller, source_calls, clock, source_state, error + + def test_raw_serial_source_reply_parsers(self) -> None: + key = "A5" * 32 + self.assertEqual( + ota.parse_cli_public_key(f" -> > {key}\r\n> ", "source"), + key.lower(), + ) + self.assertEqual( + ota.parse_cli_value(" -> > source node\r\n> ", "name"), + "source node", + ) + ota.require_no_existing_temp_schedule( + " -> > -none-\r\n> ", "source" + ) + self.assertEqual( + ota.require_temp_radio_reply( + "source", " -> OK - temp params for 3 mins\r\n> ", 3 + ), + 3, + ) + self.assertEqual( + ota.parse_source_temp_radio_status("TempRadio inactive\r\n> "), + ("inactive", None), + ) + state, radio = ota.parse_source_temp_radio_status( + " -> TempRadio active: 909.950,250.00,5,5 170s left\r\n> " + ) or ("", None) + self.assertEqual(state, "active") + self.assertIsNotNone(radio) + self.assertTrue(self.TEMP.matches(radio)) + with self.assertRaisesRegex(ota.OtaError, "exact public key"): + ota.parse_cli_public_key( + f" -> > {key}\r\n -> > {'B6' * 32}\r\n> ", + "source", + ) + with self.assertRaisesRegex(ota.OtaError, "already has scheduled"): + ota.require_no_existing_temp_schedule( + " -> > -none-\r\n -> > 1:909.95,250,5,5@1-2\r\n> ", + "source", + ) + with self.assertRaisesRegex(ota.OtaError, "invalid TempRadio status"): + ota.parse_source_temp_radio_status( + "TempRadio inactive\r\n" + "TempRadio active: 909.950,250.00,5,5 170s left\r\n> " + ) + + def test_optional_source_command_never_hides_transport_or_silence(self) -> None: + args = self.args() + with mock.patch.object( + ota, + "source_cli_command", + side_effect=ota.TransmissionError("link failed"), + ): + with self.assertRaisesRegex(ota.TransmissionError, "link failed"): + ota.optional_source_cli_command(args, "tempradio") + with mock.patch.object( + ota, + "source_cli_command", + side_effect=ota.OtaError("source rejected: Unknown command"), + ): + self.assertIsNone( + ota.optional_source_cli_command(args, "tempradio") + ) + with mock.patch.object(ota, "source_cli_command", return_value=""): + with self.assertRaisesRegex(ota.OtaError, "empty reply"): + ota.optional_source_cli_command(args, "tempradio") + + def test_generic_source_challenge_requires_exact_contact_key_and_ack(self) -> None: + controller = ota.Controller.__new__(ota.Controller) + valid = [ + {"adv_name": "source", "public_key": self.SOURCE_KEY}, + {"expected_ack": "12AB34CD"}, + {"code": "12ab34cd"}, + ] + with ( + mock.patch.object( + controller, + "_run_marked", + return_value=([], valid), + ) as run, + contextlib.redirect_stdout(io.StringIO()), + ): + controller.prove_contact_ack( + "source", self.SOURCE_KEY, "source challenge", timeout=25 + ) + commands = run.call_args.args[0] + self.assertEqual(commands[0], "echo") + self.assertEqual(commands[2:5], ["contact_info", "source", "msg"]) + self.assertRegex(commands[6], r"^mOTA-preflight-[0-9a-f]{16}$") + self.assertEqual( + commands.count("wait_ack"), + ota.TEMP_RADIO_PREFLIGHT_ACK_WAITS, + ) + self.assertEqual(run.call_args.args[-1], 25) + + cases = { + "contact key mismatch": [ + {"adv_name": "source", "public_key": "CC" * 32}, + {"expected_ack": "12AB34CD"}, + {"code": "12AB34CD"}, + ], + "no matching ACK": [ + {"adv_name": "source", "public_key": self.SOURCE_KEY}, + {"expected_ack": "12AB34CD"}, + {"code": "DEADBEEF"}, + ], + } + for pattern, objects in cases.items(): + with self.subTest(pattern=pattern), mock.patch.object( + controller, "_run_marked", return_value=([], objects) + ), self.assertRaisesRegex(ota.OtaError, pattern): + controller.prove_contact_ack( + "source", self.SOURCE_KEY, "source challenge" + ) + + def test_contact_clock_binds_exact_identity_and_one_raw_epoch(self) -> None: + controller = ota.Controller.__new__(ota.Controller) + + def output_for( + commands: list[str], _label: str, _timeout: float | None = None + ) -> subprocess.CompletedProcess[str]: + marker = commands[1] + return subprocess.CompletedProcess( + [], 0, + stdout=( + f"{marker}\n" + + json.dumps({ + "adv_name": "remote", + "public_key": self.NODE_KEYS["remote"], + }) + + "\n1800000123\n" + ), + stderr="", + ) + + controller._execute = output_for + self.assertEqual( + controller.get_contact_clock( + "remote", self.NODE_KEYS["remote"], timeout=30 + ), + 1_800_000_123, + ) + + def ambiguous( + commands: list[str], _label: str, _timeout: float | None = None + ) -> subprocess.CompletedProcess[str]: + result = output_for(commands, _label, _timeout) + result.stdout += "1800000124\n" + return result + + controller._execute = ambiguous + with self.assertRaisesRegex(ota.TransmissionError, "2 unambiguous"): + controller.get_contact_clock( + "remote", self.NODE_KEYS["remote"], timeout=30 + ) + + controller._execute = output_for + with self.assertRaisesRegex(ota.OtaError, "exact contact identity"): + controller.get_contact_clock("remote", "FF" * 32, timeout=30) + + def test_rehearsal_uses_exact_three_minutes_and_farthest_first(self) -> None: + controller, source_calls, clock, source_state, error = self.run_rehearsal() + self.assertIsNone(error) + + armed = [ + (name, command) + for name, command, _kwargs in controller.remote_calls + if command.startswith("set tempradioat ") + ] + self.assertEqual([name for name, _command in armed], [ + "remote", "far", "near" + ]) + projected_starts = [] + offsets = {"remote": 300, "far": -300, "near": 180} + for name, scheduled_command in armed: + fields = scheduled_command.split(" ", 2)[2].split(",") + start_epoch, end_epoch = map(int, fields[-2:]) + self.assertEqual(end_epoch - start_epoch, 3 * 60) + projected_starts.append( + start_epoch + - ( + 1_800_000_000 + + offsets[name] + + controller.clock_adjustment[name] + ) + ) + self.assertEqual(projected_starts, [120, 120, 120]) + clock_pins = [ + (name, command, options) + for name, command, options in controller.remote_calls + if command.startswith("time ") + ] + self.assertEqual([name for name, _command, _options in clock_pins], [ + "remote", "far", "near" + ]) + self.assertTrue(all(options["retry"] is False for _, _, options in clock_pins)) + source_arm_options = next( + options for command, options in source_calls + if command == "tempradio 909.95,250,5,5,3" + ) + self.assertTrue(source_arm_options["bounded"]) + self.assertEqual(source_arm_options["deadline"], 297.0) + self.assertFalse(source_arm_options["retry"]) + self.assertNotIn("120", " ".join(command for command, _ in source_calls)) + self.assertEqual( + clock.sleeps, + [ + 115.0, + 8.0, + ota.TEMP_RADIO_SWITCH_DELAY_SECONDS, + 192.0, + ], + ) + self.assertEqual( + [radio for radio, _timeout in controller.radio_calls], + [self.TEMP, self.NORMAL], + ) + self.assertTrue( + all( + timeout is not None + and timeout <= ota.TEMP_RADIO_PREFLIGHT_OPERATION_TIMEOUT_SECONDS + for _radio, timeout in controller.radio_calls + ) + ) + self.assertEqual(len(controller.source_challenges), 3) + self.assertTrue(controller.radio.matches(self.NORMAL)) + self.assertTrue(controller.all_nodes_normal()) + self.assertFalse(bool(source_state["active"])) + + def test_clock_read_rtt_expands_activation_and_cleanup_bounds(self) -> None: + controller, _source_calls, clock, source_state, error = ( + self.run_rehearsal( + failure="clock_read_rtt", + capture_error=True, + ) + ) + self.assertIsNone(error) + # The slowest 23-second read moves the earliest possible remote start + # 23 seconds before the latest one. Source arming therefore waits 92s, + # not the zero-RTT 115s, and cleanup owns the corresponding late end. + self.assertEqual(clock.sleeps[0], 92.0) + self.assertGreaterEqual(clock.now, 408.0) + self.assertTrue(controller.all_nodes_normal()) + self.assertFalse(bool(source_state["active"])) + + def test_shared_source_uses_bounded_local_tuple_not_binary_set(self) -> None: + controller, source_calls, _clock, source_state, error = ( + self.run_rehearsal(shared=True) + ) + self.assertIsNone(error) + + self.assertEqual(controller.radio_calls, []) + source_arm_options = next( + options for command, options in source_calls + if command == "tempradio 909.95,250,5,5,3" + ) + self.assertTrue(source_arm_options["bounded"]) + self.assertEqual(source_arm_options["deadline"], 297.0) + self.assertFalse(source_arm_options["retry"]) + self.assertNotIn( + "get public.key", [command for command, _options in source_calls] + ) + self.assertGreaterEqual( + [command for command, _options in source_calls].count("ver"), 3 + ) + self.assertTrue(controller.radio.matches(self.NORMAL)) + self.assertFalse(bool(source_state["active"])) + + def test_lost_fixed_schedule_ack_is_bounded_without_replay(self) -> None: + for failure, succeeds in ( + ("lost_target_ack", True), + ("lost_target_ack_without_delivery", False), + ("lost_target_ack_delayed", True), + ("lost_target_ack_slow", True), + ): + with self.subTest(failure=failure): + controller, _calls, clock, _state, error = self.run_rehearsal( + failure=failure, + capture_error=True, + ) + self.assertEqual(error is None, succeeds) + target_arms = [ + call for call in controller.remote_calls + if call[0] == "remote" + and call[1].startswith("set tempradioat ") + ] + self.assertEqual(len(target_arms), 1) + # Even a lost reply returns after the one fixed rehearsal + # interval; it never pays the multi-hour controller retry + # horizon and never submits a second mutation. + self.assertLess(clock.now, 6 * 60) + self.assertTrue(controller.all_nodes_normal()) + + def test_lost_source_ack_is_resolved_without_replay(self) -> None: + for failure, succeeds in ( + ("lost_source_ack", True), + ("lost_source_ack_without_delivery", False), + ): + with self.subTest(failure=failure): + controller, source_calls, _clock, _state, error = self.run_rehearsal( + failure=failure, + capture_error=True, + ) + self.assertEqual(error is None, succeeds) + source_arms = [ + call for call in source_calls + if call[0].startswith("tempradio ") + ] + self.assertEqual(len(source_arms), 1) + self.assertTrue(controller.all_nodes_normal()) + + def test_source_must_be_proven_over_lora(self) -> None: + for failure, pattern in ( + ("baseline_source_air", "contact key mismatch"), + ("temp_source_air", "matching ACK"), + ("normal_source_air", "matching ACK"), + ): + with self.subTest(failure=failure): + controller, _calls, _clock, _state, error = self.run_rehearsal( + failure=failure, + capture_error=True, + ) + self.assertIsNotNone(error) + self.assertRegex(str(error), pattern) + self.assertTrue(controller.all_nodes_normal()) + + def test_legacy_source_return_is_proven_over_lora(self) -> None: + controller, _calls, _clock, _state, error = self.run_rehearsal( + failure="legacy_source", + ) + self.assertIsNone(error) + self.assertEqual(len(controller.source_challenges), 3) + self.assertTrue(controller.all_nodes_normal()) + + def test_stuck_source_and_remote_are_actively_normalized(self) -> None: + for failure in ( + "stuck_source_return", + "legacy_stuck_source", + "stuck_remote_return", + ): + with self.subTest(failure=failure): + controller, source_calls, clock, state, error = ( + self.run_rehearsal( + failure=failure, + capture_error=True, + ) + ) + self.assertIsNotNone(error) + self.assertTrue(controller.all_nodes_normal()) + self.assertFalse(bool(state["active"])) + self.assertTrue(controller.radio.matches(self.NORMAL)) + self.assertGreaterEqual( + clock.now, + ota.remote_cli_mutation_drain_seconds(self.TEMP) + + ota.TEMP_RADIO_PREFLIGHT_MARGIN_SECONDS, + ) + if failure in ("stuck_source_return", "legacy_stuck_source"): + self.assertIn( + "normalradio", + [command for command, _options in source_calls], + ) + else: + self.assertIn( + ("remote", "normalradio"), + [ + (name, command) + for name, command, _options in controller.remote_calls + ], + ) + + def test_late_shared_recovery_rearm_is_owned_from_completion(self) -> None: + controller, source_calls, clock, state, error = self.run_rehearsal( + failure="late_recovery_source", + shared=True, + capture_error=True, + ) + self.assertIsNotNone(error) + self.assertFalse(bool(state["active"])) + self.assertTrue(controller.all_nodes_normal()) + recovery_arms = [ + command + for command, _options in source_calls + if command.endswith(",1") + ] + self.assertEqual( + recovery_arms, + ["tempradio 909.95,250,5,5,1"], + ) + self.assertGreaterEqual( + clock.now, + 30.0 + + ota.TEMP_RADIO_RETURN_MINUTES * 60 + + ota.TEMP_RADIO_PREFLIGHT_MARGIN_SECONDS, + ) + + def test_shared_recovery_owns_an_explicit_unexpected_duration(self) -> None: + controller, _source_calls, clock, state, error = self.run_rehearsal( + failure="wrong_recovery_duration", + shared=True, + capture_error=True, + ) + self.assertIsNotNone(error) + accepted_at = float(state["recovery_accepted_at"]) + self.assertGreaterEqual( + clock.now, + accepted_at + 30 * 60 + ota.TEMP_RADIO_PREFLIGHT_MARGIN_SECONDS, + ) + self.assertFalse(bool(state["active"])) + self.assertTrue(controller.all_nodes_normal()) + + def test_slow_ambiguous_remote_restore_drains_from_completion(self) -> None: + controller, _source_calls, clock, _state, error = self.run_rehearsal( + failure="stuck_remote_slow_restore", + capture_error=True, + ) + self.assertIsNotNone(error) + completed_at = controller.slow_recovery_restore_completed_at + self.assertIsNotNone(completed_at) + assert completed_at is not None + self.assertGreaterEqual( + clock.now, + completed_at + + ota.remote_cli_mutation_drain_seconds(self.TEMP) + + ota.TEMP_RADIO_PREFLIGHT_MARGIN_SECONDS, + ) + self.assertTrue(controller.all_nodes_normal()) + + def test_second_interrupt_during_controller_restore_cannot_escape_wait( + self, + ) -> None: + controller, _calls, clock, _state, error = self.run_rehearsal( + failure="controller_restore_interrupt", + capture_error=True, + ) + self.assertIsInstance(error, KeyboardInterrupt) + self.assertTrue(controller.controller_restore_interrupt_sent) + self.assertGreaterEqual( + clock.now, + ota.TEMP_RADIO_PREFLIGHT_MINUTES * 60 + + ota.TEMP_RADIO_PREFLIGHT_MARGIN_SECONDS, + ) + self.assertTrue(controller.radio.matches(self.NORMAL)) + self.assertTrue(controller.all_nodes_normal()) + + def test_preexisting_source_work_and_uncertain_status_block_mutation(self) -> None: + cases = { + "preexisting_source_active": "already has active", + "preexisting_source_pending": "already has active or pending", + "source_status_transport": "status link loss", + "source_status_empty": "empty reply", + "source_schedule_transport": "schedule link loss", + } + for failure, pattern in cases.items(): + with self.subTest(failure=failure): + controller, source_calls, _clock, _state, error = self.run_rehearsal( + failure=failure, + capture_error=True, + ) + self.assertIsNotNone(error) + self.assertRegex(str(error), pattern) + self.assertFalse( + any(command.startswith("tempradio ") for command, _ in source_calls) + ) + self.assertFalse( + any(command.startswith("tempradio ") for _, command, _ in controller.remote_calls) + ) + + def test_unmanaged_source_is_rejected_before_any_mutation(self) -> None: + controller = self.Controller(self.Clock()) + args = self.args() + args.source_already_temp = True + args.source_serial = None + with self.assertRaisesRegex(ota.OtaError, "unmanaged"): + ota.run_temp_radio_preflight( + controller, + args, + target(base_hash=b"\0" * 8), + self.NORMAL, + self.TEMP, + ) + self.assertEqual(controller.remote_calls, []) + self.assertEqual(controller.radio_calls, []) + + def test_identical_temp_modulation_is_rejected_before_any_mutation(self) -> None: + controller = self.Controller(self.Clock()) + args = self.args() + same_modulation = ota.RadioSettings( + self.NORMAL.frequency, + self.NORMAL.bandwidth, + self.NORMAL.spreading_factor, + self.NORMAL.coding_rate, + not self.NORMAL.repeat, + ) + with self.assertRaisesRegex(ota.OtaError, "requires --temp-radio"): + ota.run_temp_radio_preflight( + controller, + args, + target(base_hash=b"\0" * 8), + self.NORMAL, + same_modulation, + ) + self.assertEqual(controller.remote_calls, []) + self.assertEqual(controller.radio_calls, []) + + def test_duplicate_hop_names_are_rejected_before_any_mutation(self) -> None: + controller = self.Controller(self.Clock()) + args = self.args() + args.relay_values = [("REMOTE", "other-password")] + with self.assertRaisesRegex(ota.OtaError, "must be unique"): + ota.run_temp_radio_preflight( + controller, + args, + target(base_hash=b"\0" * 8), + self.NORMAL, + self.TEMP, + ) + self.assertEqual(controller.remote_calls, []) + self.assertEqual(controller.radio_calls, []) + + def test_failed_rehearsal_blocks_every_long_transfer_mutation(self) -> None: + image = firmware(b"preflight integration" * 300, VERSION_NEW) + package = ota.parse_mota(mota_blob(image)) + controller = mock.Mock() + controller.get_radio.return_value = self.NORMAL + controller.get_clock.return_value = int(ota.time.time()) + 1 + saved = ota.RxpsSettings(True, 18205, 20423, 8, 16) + with tempfile.TemporaryDirectory() as directory: + argv = [ + "release.mota", "remote", + "--controller-serial", "/dev/controller", + "--source-serial", "/dev/source", + "--password", "secret", + "--work-dir", str(Path(directory) / "work"), + "--yes", + ] + with ( + mock.patch.object(ota, "preflight_inputs"), + mock.patch.object(ota, "preflight_source_cli"), + mock.patch.object( + ota, + "ensure_source_clock_gate_safe", + return_value=(1_800_000_000, 1_800_000_059), + ), + mock.patch.object(ota, "read_source_rxps", return_value=saved), + mock.patch.object(ota, "query_target", return_value=target()), + mock.patch.object( + ota, + "prepare_package", + return_value=(Path("release.mota"), package, None), + ), + mock.patch.object( + ota, + "read_lora_ota_participant_versions", + return_value={"destination": VERSION_NEW}, + ), + mock.patch.object( + ota, + "read_remote_rxps", + return_value=ota.RxpsSettings(False, 18205, 20423, 8, 16), + ), + mock.patch.object(ota, "confirm_update"), + mock.patch.object( + ota, + "run_temp_radio_preflight", + side_effect=ota.OtaError("synthetic rehearsal failure"), + ) as rehearsal, + mock.patch.object(ota, "disable_source_rxps") as disable_source, + mock.patch.object(ota, "apply_remote_rxps_policy") as mutate_target, + mock.patch.object(ota, "arm_target_temp_radio") as arm_long_target, + mock.patch.object(ota, "switch_controller_to_temp_radio") as switch_long, + mock.patch.object(ota, "SeederProcess") as seeder, + mock.patch.object(ota, "find_and_start_pull") as pull, + mock.patch.object(ota, "request_install") as install, + contextlib.redirect_stdout(io.StringIO()), + contextlib.redirect_stderr(io.StringIO()), + ): + result = ota.main(argv, controller_override=controller) + + self.assertEqual(result, 2) + rehearsal.assert_called_once() + disable_source.assert_not_called() + mutate_target.assert_not_called() + arm_long_target.assert_not_called() + switch_long.assert_not_called() + seeder.assert_not_called() + pull.assert_not_called() + install.assert_not_called() + + def test_schedule_appearing_during_baseline_blocks_every_mutation( + self, + ) -> None: + for failure in ( + "source_schedule_appears_during_baseline", + "remote_schedule_appears_during_baseline", + ): + with self.subTest(failure=failure): + controller, source_calls, _clock, _state, error = ( + self.run_rehearsal( + failure=failure, + capture_error=True, + ) + ) + + self.assertIsNotNone(error) + self.assertRegex(str(error), "scheduled TempRadio work") + remote_commands = [ + command + for _name, command, _options in controller.remote_calls + ] + self.assertFalse( + any( + command.startswith("time ") + or command.startswith("set tempradioat ") + or command == "normalradio" + for command in remote_commands + ) + ) + self.assertFalse( + any( + command.startswith("tempradio ") + or command.startswith("time ") + or command == "normalradio" + for command, _options in source_calls + ) + ) + self.assertEqual(controller.radio_calls, []) + + def test_failures_at_each_rehearsal_stage_leave_normal_state(self) -> None: + failure_patterns = { + "baseline_source_identity": "exact public key", + "baseline_destination": "normal-channel baseline identity", + "baseline_schedule": "already has scheduled TempRadio work", + "baseline_scheduleless": "does not support fixed `tempradioat`", + "target_arm": "did not accept one exact fixed TempRadio schedule", + "relay_arm": "did not accept one exact fixed TempRadio schedule", + "source_arm": "source arm failure", + "controller_handoff": "controller handoff failure", + "temp_source_tuple": "tuple mismatch", + "temp_controller_identity": "controller on TempRadio public key changed", + "temp_source_identity": "OTA source on TempRadio public key changed", + "temp_remote_identity": "far on TempRadio public key changed", + "temp_destination": "destination on TempRadio identity", + "normal_source_return": "did not return automatically", + "normal_remote_identity": "remote after natural return public key changed", + } + for failure, pattern in failure_patterns.items(): + with self.subTest(failure=failure): + controller, source_calls, clock, source_state, error = ( + self.run_rehearsal( + failure=failure, + capture_error=True, + ) + ) + self.assertIsNotNone(error) + self.assertRegex(str(error), pattern) + self.assertTrue(controller.radio.matches(self.NORMAL)) + self.assertTrue(controller.all_nodes_normal()) + self.assertFalse(bool(source_state["active"])) + all_commands = [ + command + for _name, command, _kwargs in controller.remote_calls + ] + [command for command, _kwargs in source_calls] + self.assertFalse( + any( + command.rstrip().endswith(",120") + for command in all_commands + ) + ) + if failure.startswith("baseline_"): + self.assertNotIn( + ota.TEMP_RADIO_PREFLIGHT_MINUTES * 60 + + ota.TEMP_RADIO_PREFLIGHT_MARGIN_SECONDS, + clock.sleeps, + ) + else: + self.assertGreaterEqual( + clock.now, + ota.TEMP_RADIO_PREFLIGHT_MINUTES * 60 + + ota.TEMP_RADIO_PREFLIGHT_MARGIN_SECONDS, + ) + + def test_sequential_arming_deadline_fails_without_leaking_leases(self) -> None: + controller, _calls, clock, _state, error = self.run_rehearsal( + failure="lease_deadline", + capture_error=True, + ) + self.assertIsNotNone(error) + self.assertRegex(str(error), "fixed TempRadio schedule") + self.assertGreaterEqual(clock.now, 300) + self.assertTrue(controller.radio.matches(self.NORMAL)) + self.assertTrue(controller.all_nodes_normal()) + + def test_interrupted_expiry_wait_resumes_absolute_deadline(self) -> None: + controller, _calls, clock, _state, error = self.run_rehearsal( + failure="interrupt_wait", + capture_error=True, + ) + self.assertIsInstance(error, KeyboardInterrupt) + long_sleeps = [value for value in clock.sleeps if value > 30] + self.assertEqual(len(long_sleeps), 2) + self.assertIn(5.0, clock.sleeps) + self.assertLess(sum(clock.sleeps), 330) + self.assertTrue(controller.all_nodes_normal()) + + def test_repeated_interrupts_cannot_escape_owned_lease_cleanup(self) -> None: + controller, _calls, clock, _state, error = self.run_rehearsal( + failure="double_interrupt_wait", + capture_error=True, + ) + self.assertIsInstance(error, KeyboardInterrupt) + self.assertEqual(clock.interrupts_remaining, 0) + self.assertGreaterEqual( + clock.now, + ota.TEMP_RADIO_PREFLIGHT_MINUTES * 60 + + ota.TEMP_RADIO_PREFLIGHT_MARGIN_SECONDS, + ) + self.assertLess(sum(clock.sleeps), 335) + self.assertTrue(controller.radio.matches(self.NORMAL)) + self.assertTrue(controller.all_nodes_normal()) + + def test_fixed_schedule_formatter_cannot_extend_three_minutes(self) -> None: + command = ota.scheduled_temp_radio_command( + self.args(), 1_800_000_120, 1_800_000_300 + ) + self.assertEqual( + command, + "set tempradioat 909.95,250,5,5,1800000120,1800000300", + ) + with self.assertRaisesRegex(ota.OtaError, "interval"): + ota.scheduled_temp_radio_command( + self.args(), 1_800_000_120, 1_800_000_120 + ) + + class ReliabilityTests(unittest.TestCase): def test_target_version_falls_back_to_ver(self) -> None: class Controller: @@ -1948,6 +4623,57 @@ class ReliabilityTests(unittest.TestCase): ["ota status", "get bootloader.ver", "ota self", "ota stats"], ) + def test_optional_ota_stats_loss_is_bounded_then_falls_back_to_ver( + self, + ) -> None: + class Controller: + def __init__(self) -> None: + self.commands: list[tuple[str, bool]] = [] + self.stats_calls = 0 + + def remote_command( + self, + _target: str, + command: str, + *, + retry: bool = True, + **_kwargs: object, + ) -> str: + self.commands.append((command, retry)) + if command == "ota status": + return "OTA | no download | target:1234ABCD hw=RAK_3401" + if command == "get bootloader.ver": + return "> 0.9.2-OTAFIX2.4" + if command == "ota self": + return ( + "self body=1 image=2 base_hash=0011223344556677 | " + "bootloader: apply OK (abi=2 codecs=0x4)" + ) + if command == "ota stats": + self.stats_calls += 1 + self.assert_optional_probe(retry) + raise ota.TransmissionError("synthetic unsupported/lost stats") + if command == "ver": + return "v1.16.7" + raise AssertionError(command) + + @staticmethod + def assert_optional_probe(retry: bool) -> None: + if retry: + raise AssertionError("optional stats used unbounded retry") + + controller = Controller() + with mock.patch.object(ota.time, "sleep"): + result = ota.query_target( + controller, argparse.Namespace(target="remote") + ) + self.assertEqual( + controller.stats_calls, ota.TRANSMISSION_RETRY_LIMIT + 1 + ) + self.assertEqual(result.current_version, "v1.16.7") + self.assertEqual(result.current_version_source, "ver") + self.assertEqual(controller.commands[-1], ("ver", True)) + def test_target_detects_qspi_staging(self) -> None: class Controller: def __init__(self) -> None: @@ -2032,19 +4758,54 @@ class ReliabilityTests(unittest.TestCase): self.assertIsNone(result.bootloader_version) self.assertIn("legacy `ota self` platform markers", output.getvalue()) - def test_unattended_prompt_waits_ten_seconds_and_continues(self) -> None: + def test_unattended_prompt_stops_without_another_retry_cycle(self) -> None: output = io.StringIO() with ( mock.patch.object(sys, "stdin", io.StringIO()), mock.patch.object(ota.time, "sleep") as sleep, contextlib.redirect_stdout(output), ): - self.assertTrue(ota.prompt_after_transmission_failure( + self.assertFalse(ota.prompt_after_transmission_failure( "test", ota.TransmissionError("lost") )) - sleep.assert_called_once_with(10) + sleep.assert_not_called() + self.assertIn("stopping (non-interactive)", output.getvalue()) + + def test_interactive_prompt_timeout_still_continues(self) -> None: + interactive_stdin = mock.Mock() + interactive_stdin.isatty.return_value = True + output = io.StringIO() + with ( + mock.patch.object(sys, "stdin", interactive_stdin), + mock.patch.object(ota.os, "name", "posix"), + mock.patch("select.select", return_value=([], [], [])), + contextlib.redirect_stdout(output), + ): + self.assertTrue( + ota.prompt_after_transmission_failure( + "test", ota.TransmissionError("lost") + ) + ) self.assertIn("continuing in 10s", output.getvalue()) + def test_unattended_retry_is_finite(self) -> None: + calls = 0 + + def action() -> str: + nonlocal calls + calls += 1 + raise ota.TransmissionError("lost") + + with ( + mock.patch.object(sys, "stdin", io.StringIO()), + mock.patch.object(ota.time, "sleep"), + self.assertRaisesRegex( + ota.TransmissionStopped, "stopped after transmission" + ), + ): + ota.retry_transmission(action, "unattended probe") + self.assertEqual(calls, ota.TRANSMISSION_RETRY_LIMIT + 1) + def test_retry_prompt_continues_by_default_choice(self) -> None: calls = 0 @@ -2203,6 +4964,49 @@ class ReliabilityTests(unittest.TestCase): reply = controller._remote_command_once("remote", "ota status", "secret") self.assertTrue(reply.startswith("OTA |")) + def test_matching_admin_reply_recovers_lost_login_acknowledgement(self) -> None: + controller = object.__new__(ota.Controller) + controller.reply_timeout = 20 + controller._authenticated_targets = set() + key = "A1" * 32 + controller._run_marked = lambda _commands, _label, _marker: ( + [{"adv_name": "remote", "public_key": key}], + [{ + "txt_type": 1, + "text": "OTA | no download | target:1234ABCD", + "pubkey_prefix": key[:12], + }], + ) + + reply = controller._remote_command_once( + "remote", "ota status", "secret" + ) + + self.assertTrue(reply.startswith("OTA |")) + self.assertEqual(controller._authenticated_targets, {"remote"}) + + def test_explicit_admin_login_failure_overrides_matching_reply(self) -> None: + controller = object.__new__(ota.Controller) + controller.reply_timeout = 20 + controller._authenticated_targets = set() + key = "A1" * 32 + controller._run_marked = lambda _commands, _label, _marker: ( + [ + {"adv_name": "remote", "public_key": key}, + {"login_success": False}, + ], + [{ + "txt_type": 1, + "text": "OTA | no download | target:1234ABCD", + "pubkey_prefix": key[:12], + }], + ) + + with self.assertRaisesRegex(ota.OtaError, "admin login failed"): + controller._remote_command_once("remote", "ota status", "wrong") + + self.assertEqual(controller._authenticated_targets, set()) + def test_remote_admin_login_is_reused_until_explicit_refresh(self) -> None: controller = object.__new__(ota.Controller) controller.reply_timeout = 20 @@ -2290,8 +5094,15 @@ class ReliabilityTests(unittest.TestCase): def test_generic_retry_rejects_state_changing_ota_commands(self) -> None: controller = object.__new__(ota.Controller) controller.password = "secret" - with self.assertRaisesRegex(ota.OtaError, "state-aware"): - controller.remote_command("remote", "ota install") + for command in ( + "ota install", + "ota pull 1", + "tempradio 909.95,250,5,5,120", + ): + with self.subTest(command=command), self.assertRaisesRegex( + ota.OtaError, "state-aware" + ): + controller.remote_command("remote", command) def test_unknown_contact_is_not_retried_as_packet_loss(self) -> None: controller = object.__new__(ota.Controller) @@ -2326,11 +5137,13 @@ class ReliabilityTests(unittest.TestCase): def __init__(self) -> None: self.commands: list[str] = [] self.radios: list[ota.RadioSettings] = [] + self.retries: list[object] = [] def remote_command( self, _target: str, command: str, **_kwargs: object ) -> str: self.commands.append(command) + self.retries.append(_kwargs.get("retry")) if command.startswith("tempradio "): raise ota.TransmissionError("lost reply") return "self body=1 image=2 base_hash=0011223344556677" @@ -2350,8 +5163,44 @@ class ReliabilityTests(unittest.TestCase): controller.commands, ["tempradio 909.95,250,5,5,120", "ota self"], ) + self.assertEqual(controller.retries, [False, False]) self.assertEqual(controller.radios, [temporary, normal]) + @mock.patch.object(ota.time, "sleep") + def test_lost_target_temp_reply_uses_later_bounded_probe( + self, sleep: mock.Mock + ) -> None: + normal = ota.RadioSettings(910.525, 62.5, 7, 5, False) + temporary = ota.RadioSettings(909.95, 250.0, 5, 5, False) + controller = mock.Mock() + controller.remote_command.side_effect = ( + ota.TransmissionError("lost command reply"), + ota.TransmissionError("missed first temp proof"), + "self body=1 image=2 base_hash=0011223344556677", + ) + + ota.arm_target_temp_radio( + controller, + argparse.Namespace(target="remote"), + "tempradio 909.95,250,5,5,120", + temporary, + normal, + ) + + self.assertEqual(controller.remote_command.call_count, 3) + self.assertEqual( + [ + call.kwargs.get("retry") + for call in controller.remote_command.call_args_list + ], + [False, False, False], + ) + sleep.assert_called_once_with(ota.transmission_retry_delay(1)) + controller.set_radio.assert_has_calls([ + mock.call(temporary, "switch controller to TempRadio"), + mock.call(normal, "restore controller after TempRadio probe"), + ]) + def test_shared_lost_target_temp_probe_preserves_saved_controller_tuple(self) -> None: normal = ota.RadioSettings(910.525, 62.5, 7, 5, False) temporary = ota.RadioSettings(909.95, 500.0, 5, 5, False) @@ -2399,7 +5248,7 @@ class ReliabilityTests(unittest.TestCase): self.assertEqual( source_command.call_args_list, [ - mock.call(args, command), + mock.call(args, command, retry=False), mock.call(args, "normalradio", check=True), mock.call(args, "tempradio", check=True), ], @@ -2440,7 +5289,10 @@ class ReliabilityTests(unittest.TestCase): normal, "restore controller after TempRadio probe" ) - def test_ambiguous_target_temp_probe_is_not_replayed(self) -> None: + @mock.patch.object(ota.time, "sleep") + def test_ambiguous_target_temp_probe_is_not_replayed( + self, sleep: mock.Mock + ) -> None: normal = ota.RadioSettings(910.525, 62.5, 7, 5, False) temporary = ota.RadioSettings(909.95, 250.0, 5, 5, False) @@ -2469,9 +5321,10 @@ class ReliabilityTests(unittest.TestCase): ) self.assertEqual( controller.commands, - ["tempradio 909.95,250,5,5,120", "ota self", "ota self"], + ["tempradio 909.95,250,5,5,120"] + ["ota self"] * 8, ) self.assertEqual(controller.radios, [temporary, normal]) + self.assertEqual(sleep.call_count, ota.TRANSMISSION_RETRY_LIMIT * 2) @mock.patch.object(ota.time, "sleep") def test_target_temp_retries_only_after_exact_normal_identity( @@ -2484,12 +5337,17 @@ class ReliabilityTests(unittest.TestCase): def __init__(self) -> None: self.commands: list[str] = [] self.radios: list[ota.RadioSettings] = [] - self.replies = iter([ - ota.TransmissionError("lost command reply"), - ota.TransmissionError("not on temporary channel"), - "self body=1 image=2 base_hash=0011223344556677", - "OK - temp params for 120 mins", - ]) + self.replies = iter( + [ota.TransmissionError("lost command reply")] + + [ + ota.TransmissionError("not on temporary channel") + for _ in range(4) + ] + + [ + "self body=1 image=2 base_hash=0011223344556677", + "OK - temp params for 120 mins", + ] + ) def remote_command( self, _target: str, command: str, **_kwargs: object @@ -2517,11 +5375,22 @@ class ReliabilityTests(unittest.TestCase): "tempradio 909.95,500,5,5,120", "ota self", "ota self", + "ota self", + "ota self", + "ota self", "tempradio 909.95,500,5,5,120", ], ) self.assertEqual(controller.radios, [temporary, normal]) - sleep.assert_called_once_with(ota.transmission_retry_delay(1)) + self.assertEqual( + sleep.call_args_list, + [ + mock.call(ota.transmission_retry_delay(1)), + mock.call(ota.transmission_retry_delay(2)), + mock.call(ota.transmission_retry_delay(3)), + mock.call(ota.transmission_retry_delay(1)), + ], + ) def test_install_retries_only_after_still_ready_is_confirmed(self) -> None: image = firmware(b"install" * 900, VERSION_NEW) @@ -2562,6 +5431,41 @@ class ReliabilityTests(unittest.TestCase): ], ) + def test_lost_install_window_reply_is_reconciled_without_replay(self) -> None: + image = firmware(b"install-window" * 700, VERSION_NEW) + package = ota.parse_mota(mota_blob(image)) + + class Controller: + def __init__(self) -> None: + self.calls: list[tuple[str, bool | None]] = [] + + def remote_command( + self, _target: str, command: str, **kwargs: object + ) -> str: + self.calls.append((command, kwargs.get("retry"))) + if command.startswith("tempradio "): + raise ota.TransmissionError("lost window reply") + if command == "ota status": + return ( + "OTA | download: ready to install 9/9 " + f"id={package.manifest_id} 2s" + ) + raise AssertionError(command) + + controller = Controller() + args = argparse.Namespace( + target="remote", + temp_values=(909.95, 250.0, 5, 5, 120), + ) + ota.arm_target_install_window(controller, args, package) + self.assertEqual( + controller.calls, + [ + ("tempradio 909.95,250,5,5,3", False), + ("ota status", False), + ], + ) + def test_install_watchdog_gate_runs_immediately_before_install(self) -> None: image = firmware(b"watchdog" * 900, VERSION_NEW) package = ota.parse_mota(mota_blob(image)) @@ -2815,13 +5719,17 @@ class ReliabilityTests(unittest.TestCase): def test_stage_cleanup_shortens_target_and_relays(self) -> None: class Controller: def __init__(self) -> None: - self.commands: list[tuple[str, str, str | None]] = [] + self.commands: list[ + tuple[str, str, str | None, bool | None] + ] = [] def remote_command( self, target_name: str, command: str, **kwargs: object ) -> str: password = kwargs.get("password") - self.commands.append((target_name, command, password)) + self.commands.append( + (target_name, command, password, kwargs.get("retry")) + ) return "OK - temp params for 1 mins" controller = Controller() @@ -2835,11 +5743,114 @@ class ReliabilityTests(unittest.TestCase): self.assertEqual( controller.commands, [ - ("remote", "tempradio 909.95,250,5,5,1", None), - ("relay", "tempradio 909.95,250,5,5,1", "relay-secret"), + ("remote", "tempradio 909.95,250,5,5,1", None, False), + ( + "relay", + "tempradio 909.95,250,5,5,1", + "relay-secret", + False, + ), ], ) + def test_relay_arm_owns_lost_ack_before_one_shot_send(self) -> None: + ownership: list[tuple[str, str]] = [] + + class Controller: + owned_during_send = False + calls = 0 + + def remote_command( + self, target: str, command: str, **kwargs: object + ) -> str: + self.calls += 1 + self.owned_during_send = (target, "secret") in ownership + self.retry = kwargs.get("retry") + raise ota.TransmissionError("lost acknowledgement") + + controller = Controller() + confirmed = ota.arm_relay_temp_radio_once( + controller, + "relay", + "secret", + "tempradio 909.95,250,5,5,120", + ownership, + 120, + ) + self.assertFalse(confirmed) + self.assertTrue(controller.owned_during_send) + self.assertEqual(controller.calls, 1) + self.assertIs(controller.retry, False) + self.assertEqual(ownership, [("relay", "secret")]) + + def test_relay_cleanup_attempts_every_node_after_failure(self) -> None: + class Controller: + def __init__(self) -> None: + self.calls: list[tuple[str, bool | None]] = [] + + def remote_command( + self, target: str, command: str, **kwargs: object + ) -> str: + if command.startswith("tempradio "): + self.calls.append((target, kwargs.get("retry"))) + if target == "offline": + raise ota.OtaError("unreachable") + return "OK - temp params for 1 mins" + raise AssertionError(command) + + controller = Controller() + args = argparse.Namespace( + relay_values=[ + ("offline", "offline-secret"), + ("online", "online-secret"), + ], + temp_values=(909.95, 250.0, 5, 5, 120), + ) + with self.assertRaisesRegex( + ota.OtaError, "offline: unreachable" + ): + ota.shorten_relay_temp_windows(controller, args) + self.assertEqual( + controller.calls, + [("offline", False), ("online", False)], + ) + + def test_lost_relay_cleanup_reconciles_identity_without_replay(self) -> None: + key = "AB" * 32 + + class Controller: + def __init__(self) -> None: + self.calls: list[tuple[str, str, bool | None]] = [] + + def remote_command( + self, target: str, command: str, **kwargs: object + ) -> str: + self.calls.append((target, command, kwargs.get("retry"))) + if command.startswith("tempradio "): + raise ota.TransmissionError("lost cleanup reply") + if command == "get public.key": + return f"> {key}" + raise AssertionError(command) + + controller = Controller() + args = argparse.Namespace( + relay_values=[("relay", "secret")], + temp_values=(909.95, 250.0, 5, 5, 120), + ) + ota.shorten_relay_temp_windows( + controller, + args, + expected_public_keys={"relay": key.lower()}, + ) + relative_writes = [ + call for call in controller.calls if call[1].startswith("tempradio ") + ] + self.assertEqual( + relative_writes, + [("relay", "tempradio 909.95,250,5,5,1", False)], + ) + self.assertIn(("relay", "get public.key", False), controller.calls) + def test_managed_relay_timing_is_saved_guarded_and_restored(self) -> None: class Controller: def __init__(self) -> None: @@ -2975,11 +5986,15 @@ class ReliabilityTests(unittest.TestCase): controller = mock.Mock() temporary = ota.RadioSettings(909.95, 500.0, 5, 5, False) command = "tempradio 909.95,500,5,5,120" - with mock.patch.object(ota, "source_cli_command") as source_command: + with mock.patch.object( + ota, + "source_cli_command", + return_value="OK - temp params for 120 mins", + ) as source_command: ota.switch_controller_to_temp_radio( controller, args, command, temporary ) - source_command.assert_called_once_with(args, command) + source_command.assert_called_once_with(args, command, retry=False) controller.set_radio.assert_not_called() def test_separate_controller_temp_switch_uses_binary_tuple(self) -> None: diff --git a/tools/sensecap_indicator_rp2040/README.md b/tools/sensecap_indicator_rp2040/README.md index 8a6030f7..c3da4747 100644 --- a/tools/sensecap_indicator_rp2040/README.md +++ b/tools/sensecap_indicator_rp2040/README.md @@ -30,4 +30,84 @@ resolution. See For diagnostics, send `MCFONT STATUS` over RP2040 USB serial. The response reports internal-UART INFO requests, GET attempts, completed streams, and -bytes sent since the RP2040 last booted. +bytes sent since the RP2040 last booted. `MCFONT STAGESTATUS` reports the +version-2 staging attempts, completed stages, cumulative bytes and elapsed +milliseconds from the last attempt, and its final result. For example: + +```text +MCFONT STAGESTATUS 2 1 1 1302608 18437 STAGED +``` + +The result is one of `IDLE`, `RECEIVING`, `STAGED`, `SD`, `OPEN`, `TIMEOUT`, +`WRITE`, `CHECKSUM`, `METADATA`, or `ABORTED`. + +The internal ESP32 UART also supports the recovery-only two-phase commands +`MCFONT STAGE`, `MCFONT STAGEV2`, `MCFONT COMMIT`, and `MCFONT ABORT`. A staged +file never replaces the live font and is discarded after a reset. Only a +subsequent commit moves the CRC-verified staged pair through the same +temporary/backup transaction used by USB upload. The public USB command parser +does not accept these commands; USB retains its explicit `MCFONT PUT` workflow. +The ESP32 buffers the complete HTTPS response in PSRAM and verifies the +compiled SHA-256 before it starts this staged UART transfer. The RP2040 writes +that verified buffer to the separate staging file and still withholds commit +until the size and CRC32 also match. + +After the ESP32 sends `MCFONT COMMIT`, either an `OK` response or a missing or +malformed response moves recovery to local-only verification. A lost UART reply +is ambiguous because the RP2040 may already have completed its durable rename; +it must not authorize another download of the same GitHub blob. Only an +explicit `ERROR ...` response is treated as a rejected transaction eligible +for a later network attempt. Local verification runs immediately and then with +bounded 2-, 5-, and 15-second delays, for at most four post-commit probes per +boot, and does not require Wi-Fi. When the built-in fallback is active, a probe +must stream the exact compiled size, CRC32, and SHA-256 before activating the +font. When a valid older runtime font is deliberately retained until reboot, +the probe uses exact INFO size and CRC32 instead; RP2040 COMMIT has already read +and CRC-verified the complete stored file, and this avoids allocating a second +1.3 MiB runtime buffer. Failure exhausts locally, keeps the existing/fallback +runtime font, and waits for reboot to classify the service again. + +`STAGEV2` adds receiver pacing for SD-card latency. The distinct command name +also avoids being prefix-parsed as `STAGE 2 ...` by older services. Its +exchange is: + +```text +ESP32 -> MCFONT STAGEV2 512 +RP2040 -> READY 2 512 +ESP32 -> exactly 512 binary bytes (or the exact shorter final chunk) +RP2040 -> ACK +... repeat one chunk at a time ... +RP2040 -> STAGED +``` + +The RP2040 does not acknowledge a partial chunk. It writes each complete chunk +to the SD file before acknowledging it, and accepts no more than 512 bytes per +chunk. Both the receiver-paced protocol and the legacy `STAGE`/USB `PUT` path +have a 10-second idle timeout and a 180-second whole-transfer timeout, so even a +slow byte trickle cannot hold the command service forever. Transfer errors are +single bounded lines (`ERROR SD`, +`ERROR OPEN`, `ERROR TIMEOUT`, `ERROR WRITE`, `ERROR CHECKSUM`, or +`ERROR INSTALL`); invalid requests are rejected before receiving binary data +with `ERROR SIZE`, `ERROR CHUNK`, or `ERROR READONLY`. The original unpaced +`STAGE` command remains available for compatibility with already-deployed +ESP32 firmware, while new firmware should prefer `STAGEV2`. + +The whole-transfer deadline also covers the final SD write and flush: a last +chunk received at the deadline edge is cleaned up and reported as +`ERROR TIMEOUT`, never published as `STAGED` after the budget has expired. + +If the SD card is not ready on the RP2040's first mount attempt, the service +clears the failed filesystem state and retries every two seconds for up to 30 +attempts (about one minute). INFO and upload commands remain responsive with a +missing/SD error during that window and begin using the card automatically as +soon as a retry succeeds. The finite retry budget prevents a permanently +missing card from creating an endless mount loop; reboot to start a new window. + +Boot recovery also covers resets between either pair of file renames: a live +font whose metadata still has the temporary or backup name, and an old backup +font whose metadata still has the live name. These abnormal cross-path pairs +must pass a full-file CRC check before metadata is renamed or a pair is +promoted. If any install transaction artifact exists, the live pair must also +pass its full CRC before recovery deletes the candidates. The normal healthy +boot still uses the metadata/size check and lets the ESP32 verify the streamed +CRC, avoiding an extra full SD read every boot. diff --git a/tools/sensecap_indicator_rp2040/src/main.cpp b/tools/sensecap_indicator_rp2040/src/main.cpp index 3976345c..d542940c 100644 --- a/tools/sensecap_indicator_rp2040/src/main.cpp +++ b/tools/sensecap_indicator_rp2040/src/main.cpp @@ -2,11 +2,16 @@ #include #include +#include "../../../src/helpers/IndicatorFontStageV2Protocol.h" + namespace { constexpr uint32_t FONT_UART_BAUD = 1000000; constexpr uint32_t SD_CLOCK_HZ = 1000000; +constexpr uint32_t SD_MOUNT_RETRY_INTERVAL_MS = 2000; +constexpr uint8_t SD_MOUNT_MAX_ATTEMPTS = 30; constexpr uint32_t RECEIVE_IDLE_TIMEOUT_MS = 10000; +constexpr uint32_t RECEIVE_TOTAL_TIMEOUT_MS = 180000; constexpr size_t MAX_FONT_BYTES = 1536 * 1024; constexpr int FONT_UART_TX = 16; @@ -23,14 +28,56 @@ constexpr const char* TEMP_FONT_PATH = "/meshcore/ui-font.tmp"; constexpr const char* TEMP_META_PATH = "/meshcore/ui-font-meta.tmp"; constexpr const char* BACKUP_FONT_PATH = "/meshcore/ui-font.bak"; constexpr const char* BACKUP_META_PATH = "/meshcore/ui-font-meta.bak"; +// Wi-Fi recovery stages into separate paths. Unlike TEMP_*, these are never +// promoted during boot recovery: the ESP32 must first verify the downloaded +// SHA-256 and explicitly issue MCFONT COMMIT. +constexpr const char* STAGED_FONT_PATH = "/meshcore/ui-font.stage"; +constexpr const char* STAGED_META_PATH = "/meshcore/ui-font-meta.stage"; bool sdReady = false; +uint8_t sdMountAttempts = 0; +uint32_t sdMountNextAttempt = 0; size_t fontSize = 0; uint32_t fontCrc = 0; uint32_t espInfoRequests = 0; uint32_t espGetAttempts = 0; uint32_t espGetCompleted = 0; size_t espLastBytes = 0; +uint32_t espStage2Attempts = 0; +uint32_t espStage2Completed = 0; +size_t espStage2LastBytes = 0; +uint32_t espStage2LastElapsedMs = 0; + +enum class Stage2Result : uint8_t { + Idle, + Receiving, + Staged, + SdUnavailable, + OpenFailed, + Timeout, + WriteFailed, + ChecksumFailed, + MetadataFailed, + Aborted, +}; + +Stage2Result espStage2LastResult = Stage2Result::Idle; + +const char* stage2ResultName(Stage2Result result) { + switch (result) { + case Stage2Result::Idle: return "IDLE"; + case Stage2Result::Receiving: return "RECEIVING"; + case Stage2Result::Staged: return "STAGED"; + case Stage2Result::SdUnavailable: return "SD"; + case Stage2Result::OpenFailed: return "OPEN"; + case Stage2Result::Timeout: return "TIMEOUT"; + case Stage2Result::WriteFailed: return "WRITE"; + case Stage2Result::ChecksumFailed: return "CHECKSUM"; + case Stage2Result::MetadataFailed: return "METADATA"; + case Stage2Result::Aborted: return "ABORTED"; + } + return "UNKNOWN"; +} uint32_t updateCrc32(uint32_t crc, const uint8_t* data, size_t size) { for (size_t i = 0; i < size; ++i) { @@ -90,6 +137,46 @@ bool validatePair(const char* fontPath, const char* metadataPath, return valid; } +bool validatePairCrc(const char* fontPath, const char* metadataPath, + size_t expectedSize, uint32_t expectedCrc) { + size_t size = 0; + uint32_t crc = 0; + if (!validatePair(fontPath, metadataPath, size, crc) + || size != expectedSize || crc != expectedCrc) { + return false; + } + + File font = SD.open(fontPath, FILE_READ); + if (!font) return false; + uint8_t buffer[512]; + size_t readTotal = 0; + uint32_t calculated = 0xFFFFFFFFUL; + while (readTotal < expectedSize) { + size_t wanted = expectedSize - readTotal; + if (wanted > sizeof(buffer)) wanted = sizeof(buffer); + size_t count = font.read(buffer, wanted); + if (count == 0) break; + calculated = updateCrc32(calculated, buffer, count); + readTotal += count; + } + font.close(); + return readTotal == expectedSize && ~calculated == expectedCrc; +} + +// Transaction recovery may need to match a font with metadata left under a +// different path by an interrupted rename. Font releases commonly have the +// same byte length, so size alone is not enough to prove that cross-path pair. +bool validateStoredPairCrc(const char* fontPath, const char* metadataPath, + size_t& size, uint32_t& crc) { + return validatePair(fontPath, metadataPath, size, crc) + && validatePairCrc(fontPath, metadataPath, size, crc); +} + +bool hasInstallTransactionFiles() { + return SD.exists(TEMP_FONT_PATH) || SD.exists(TEMP_META_PATH) + || SD.exists(BACKUP_FONT_PATH) || SD.exists(BACKUP_META_PATH); +} + void removeIfPresent(const char* path) { if (SD.exists(path)) SD.remove(path); } @@ -101,12 +188,19 @@ void cleanTransactionFiles() { removeIfPresent(BACKUP_META_PATH); } +void cleanStagedFiles() { + removeIfPresent(STAGED_FONT_PATH); + removeIfPresent(STAGED_META_PATH); +} + bool promotePair(const char* sourceFont, const char* sourceMetadata) { removeIfPresent(FONT_PATH); removeIfPresent(FONT_META_PATH); if (!SD.rename(sourceFont, FONT_PATH)) return false; if (SD.rename(sourceMetadata, FONT_META_PATH)) return true; - removeIfPresent(FONT_PATH); + // Keep the already-moved font next to its source metadata. A retry or reset + // can recognize that CRC-valid split pair and finish the metadata rename; + // deleting FONT_PATH here would destroy the only copy of the candidate. return false; } @@ -114,29 +208,61 @@ void recoverFontTransaction() { size_t size; uint32_t crc; if (validatePair(FONT_PATH, FONT_META_PATH, size, crc)) { - cleanTransactionFiles(); - return; - } - - // A reset between the two final renames can leave the new font next to its - // temporary metadata. Complete that transaction when the sizes still agree. - if (validatePair(FONT_PATH, TEMP_META_PATH, size, crc)) { - removeIfPresent(FONT_META_PATH); - if (SD.rename(TEMP_META_PATH, FONT_META_PATH)) { + // Metadata and length are enough on an ordinary boot because the ESP32 + // verifies the streamed CRC. If an interrupted install left candidates, + // however, prove the live bytes before deleting the only possible backup. + if (!hasInstallTransactionFiles() + || validatePairCrc(FONT_PATH, FONT_META_PATH, size, crc)) { cleanTransactionFiles(); return; } } - if (validatePair(TEMP_FONT_PATH, TEMP_META_PATH, size, crc) - && promotePair(TEMP_FONT_PATH, TEMP_META_PATH)) { - cleanTransactionFiles(); + // A reset between the two final renames can leave the new font next to its + // temporary metadata. Complete only a byte-for-byte CRC-valid transaction; + // different font releases can have identical lengths. + if (validateStoredPairCrc(FONT_PATH, TEMP_META_PATH, size, crc)) { + removeIfPresent(FONT_META_PATH); + if (SD.rename(TEMP_META_PATH, FONT_META_PATH)) { + cleanTransactionFiles(); + } + // A failed metadata rename still leaves a CRC-valid split pair for the + // next boot. Do not fall through to destructive cleanup. return; } - if (validatePair(BACKUP_FONT_PATH, BACKUP_META_PATH, size, crc) - && promotePair(BACKUP_FONT_PATH, BACKUP_META_PATH)) { - cleanTransactionFiles(); + // Restoring a backup has the same two-rename reset window. In that case the + // backup font is already live while its metadata retains the backup name. + if (validateStoredPairCrc(FONT_PATH, BACKUP_META_PATH, size, crc)) { + removeIfPresent(FONT_META_PATH); + if (SD.rename(BACKUP_META_PATH, FONT_META_PATH)) { + cleanTransactionFiles(); + } + return; + } + + // Moving the old live pair to backup can reset after the font rename but + // before its metadata rename. Finish forming the CRC-valid backup before + // trying the pending replacement, so a failed replacement can roll back. + if (validateStoredPairCrc(BACKUP_FONT_PATH, FONT_META_PATH, size, crc)) { + removeIfPresent(BACKUP_META_PATH); + if (!SD.rename(FONT_META_PATH, BACKUP_META_PATH)) return; + } + + if (validateStoredPairCrc(TEMP_FONT_PATH, TEMP_META_PATH, size, crc)) { + if (promotePair(TEMP_FONT_PATH, TEMP_META_PATH)) { + cleanTransactionFiles(); + } + // A failed rename leaves either the intact temp pair or a live-font/temp- + // metadata split pair. Preserve it for a later retry instead of replacing + // or deleting a candidate whose full CRC has already passed. + return; + } + + if (validateStoredPairCrc(BACKUP_FONT_PATH, BACKUP_META_PATH, size, crc)) { + if (promotePair(BACKUP_FONT_PATH, BACKUP_META_PATH)) { + cleanTransactionFiles(); + } return; } @@ -151,6 +277,39 @@ bool refreshFontInfo() { return sdReady && validatePair(FONT_PATH, FONT_META_PATH, fontSize, fontCrc); } +bool mountSdCard() { + if (sdReady || sdMountAttempts >= SD_MOUNT_MAX_ATTEMPTS) return sdReady; + + // A failed SDFS begin can leave partial mount state behind. Clear only the + // filesystem wrapper before another attempt; SPI1 remains configured below. + if (sdMountAttempts != 0) SD.end(false); + ++sdMountAttempts; + sdReady = SD.begin(SD_CS, SD_CLOCK_HZ, SPI1); + if (!sdReady) { + sdMountNextAttempt = millis() + SD_MOUNT_RETRY_INTERVAL_MS; + if (sdMountNextAttempt == 0) sdMountNextAttempt = 1; + return false; + } + + sdMountNextAttempt = 0; + if (!SD.exists(FONT_DIRECTORY)) SD.mkdir(FONT_DIRECTORY); + recoverFontTransaction(); + // A staged Wi-Fi download has not passed the ESP32's SHA-256 gate after a + // reset, so it is discarded rather than boot-promoted. + cleanStagedFiles(); + refreshFontInfo(); + return true; +} + +void serviceSdMount(uint32_t now) { + if (sdReady || sdMountAttempts >= SD_MOUNT_MAX_ATTEMPTS) return; + if (sdMountNextAttempt != 0 + && static_cast(now - sdMountNextAttempt) < 0) { + return; + } + mountSdCard(); +} + void sendInfo(Print& output, bool trackEspRequest) { if (trackEspRequest) ++espInfoRequests; if (!refreshFontInfo()) { @@ -224,7 +383,13 @@ bool moveCurrentToBackup() { } bool installTemporaryPair(size_t expectedSize, uint32_t expectedCrc) { - if (!moveCurrentToBackup()) return false; + if (!moveCurrentToBackup()) { + // The first rename pair can itself be interrupted. Repair or promote the + // CRC-valid candidate immediately rather than leaving split paths until a + // reboot, then report success only if the requested asset became live. + recoverFontTransaction(); + return refreshFontInfo() && fontSize == expectedSize && fontCrc == expectedCrc; + } if (!SD.rename(TEMP_FONT_PATH, FONT_PATH)) { recoverFontTransaction(); return refreshFontInfo() && fontSize == expectedSize && fontCrc == expectedCrc; @@ -237,16 +402,48 @@ bool installTemporaryPair(size_t expectedSize, uint32_t expectedCrc) { return refreshFontInfo() && fontSize == expectedSize && fontCrc == expectedCrc; } +bool installStagedPair(size_t expectedSize, uint32_t expectedCrc) { + // Re-read the staged file before touching the live pair. The ESP32 has + // already checked SHA-256; this verifies the bytes still on the SD card. + if (!validatePairCrc(STAGED_FONT_PATH, STAGED_META_PATH, + expectedSize, expectedCrc)) { + cleanStagedFiles(); + return false; + } + + removeIfPresent(TEMP_FONT_PATH); + removeIfPresent(TEMP_META_PATH); + if (!SD.rename(STAGED_FONT_PATH, TEMP_FONT_PATH)) { + cleanStagedFiles(); + return false; + } + if (!SD.rename(STAGED_META_PATH, TEMP_META_PATH)) { + removeIfPresent(TEMP_FONT_PATH); + cleanStagedFiles(); + return false; + } + return installTemporaryPair(expectedSize, expectedCrc); +} + +enum class ReceiveMode : uint8_t { + InstallNow, + StageOnly, +}; + void receiveFont(Stream& input, Print& output, size_t expectedSize, - uint32_t expectedCrc) { + uint32_t expectedCrc, ReceiveMode mode) { if (!sdReady) { output.print("ERROR SD\n"); return; } - removeIfPresent(TEMP_FONT_PATH); - removeIfPresent(TEMP_META_PATH); - File font = SD.open(TEMP_FONT_PATH, "w"); + const char* destinationFont = mode == ReceiveMode::StageOnly + ? STAGED_FONT_PATH : TEMP_FONT_PATH; + const char* destinationMetadata = mode == ReceiveMode::StageOnly + ? STAGED_META_PATH : TEMP_META_PATH; + removeIfPresent(destinationFont); + removeIfPresent(destinationMetadata); + File font = SD.open(destinationFont, "w"); if (!font) { output.print("ERROR OPEN\n"); return; @@ -256,10 +453,17 @@ void receiveFont(Stream& input, Print& output, size_t expectedSize, uint8_t buffer[512]; size_t received = 0; uint32_t crc = 0xFFFFFFFFUL; - uint32_t lastProgress = millis(); + const uint32_t started = millis(); + uint32_t lastProgress = started; bool writeFailed = false; - while (received < expectedSize - && millis() - lastProgress < RECEIVE_IDLE_TIMEOUT_MS) { + bool timedOut = false; + while (received < expectedSize) { + const uint32_t now = millis(); + if (now - started >= RECEIVE_TOTAL_TIMEOUT_MS + || now - lastProgress >= RECEIVE_IDLE_TIMEOUT_MS) { + timedOut = true; + break; + } int available = input.available(); if (available <= 0) { delay(1); @@ -280,14 +484,30 @@ void receiveFont(Stream& input, Print& output, size_t expectedSize, } font.flush(); font.close(); + // Also reject a final read/write/flush which crossed the whole-transfer + // deadline after receiving the last byte; success must fit in the same + // absolute budget as every intermediate iteration. + if (millis() - started >= RECEIVE_TOTAL_TIMEOUT_MS) timedOut = true; - if (writeFailed || received != expectedSize || ~crc != expectedCrc) { - removeIfPresent(TEMP_FONT_PATH); - output.print(writeFailed ? "ERROR WRITE\n" : "ERROR CHECKSUM\n"); + if (writeFailed || timedOut || received != expectedSize + || ~crc != expectedCrc) { + removeIfPresent(destinationFont); + removeIfPresent(destinationMetadata); + output.print(writeFailed ? "ERROR WRITE\n" + : timedOut ? "ERROR TIMEOUT\n" : "ERROR CHECKSUM\n"); return; } - if (!writeMetadata(TEMP_META_PATH, expectedSize, expectedCrc) - || !installTemporaryPair(expectedSize, expectedCrc)) { + if (!writeMetadata(destinationMetadata, expectedSize, expectedCrc)) { + removeIfPresent(destinationFont); + removeIfPresent(destinationMetadata); + output.print("ERROR INSTALL\n"); + return; + } + if (mode == ReceiveMode::StageOnly) { + output.print("STAGED\n"); + return; + } + if (!installTemporaryPair(expectedSize, expectedCrc)) { recoverFontTransaction(); output.print("ERROR INSTALL\n"); return; @@ -295,17 +515,135 @@ void receiveFont(Stream& input, Print& output, size_t expectedSize, output.print("OK\n"); } +// STAGEV2 is receiver paced: the sender transmits exactly the negotiated chunk +// (or the shorter final chunk) and waits for the cumulative ACK before sending +// more. This prevents SD latency from overflowing the RP2040 UART FIFO. +void receiveFontStage2(Stream& input, Print& output, size_t expectedSize, + uint32_t expectedCrc) { + ++espStage2Attempts; + espStage2LastBytes = 0; + espStage2LastElapsedMs = 0; + espStage2LastResult = Stage2Result::Receiving; + const uint32_t started = millis(); + + if (!sdReady) { + espStage2LastResult = Stage2Result::SdUnavailable; + espStage2LastElapsedMs = millis() - started; + output.print("ERROR SD\n"); + return; + } + + cleanStagedFiles(); + File font = SD.open(STAGED_FONT_PATH, "w"); + if (!font) { + espStage2LastResult = Stage2Result::OpenFailed; + espStage2LastElapsedMs = millis() - started; + output.print("ERROR OPEN\n"); + return; + } + + output.print(mesh::indicator_font::kStageV2ReadyReply); + output.print('\n'); + uint8_t buffer[mesh::indicator_font::kStageV2ChunkBytes]; + size_t received = 0; + uint32_t crc = 0xFFFFFFFFUL; + uint32_t lastProgress = started; + Stage2Result failure = Stage2Result::Idle; + + while (received < expectedSize) { + const size_t chunkSize = + mesh::indicator_font::stageV2ChunkSize(expectedSize, received); + size_t chunkReceived = 0; + while (chunkReceived < chunkSize) { + const uint32_t now = millis(); + if (now - started >= RECEIVE_TOTAL_TIMEOUT_MS + || now - lastProgress >= RECEIVE_IDLE_TIMEOUT_MS) { + failure = Stage2Result::Timeout; + break; + } + + int available = input.available(); + if (available <= 0) { + delay(1); + continue; + } + size_t count = (size_t)available; + if (count > chunkSize - chunkReceived) { + count = chunkSize - chunkReceived; + } + size_t actual = input.readBytes(buffer + chunkReceived, count); + if (actual == 0) continue; + chunkReceived += actual; + lastProgress = millis(); + } + if (failure != Stage2Result::Idle) break; + + // ACK only after the entire negotiated chunk has reached the SD layer. + if (font.write(buffer, chunkSize) != chunkSize) { + failure = Stage2Result::WriteFailed; + break; + } + crc = updateCrc32(crc, buffer, chunkSize); + size_t nextReceived = 0; + if (!mesh::indicator_font::advanceStageV2Offset( + expectedSize, received, chunkSize, nextReceived)) { + failure = Stage2Result::WriteFailed; + break; + } + received = nextReceived; + espStage2LastBytes = received; + output.printf("ACK %lu\n", (unsigned long)received); + } + + font.flush(); + font.close(); + espStage2LastElapsedMs = millis() - started; + // A final SD write/flush can cross the same absolute transfer deadline even + // though the last byte arrived just before it. Do not publish STAGED after + // the receiver's whole-transfer budget has expired. + if (failure == Stage2Result::Idle + && espStage2LastElapsedMs >= RECEIVE_TOTAL_TIMEOUT_MS) { + failure = Stage2Result::Timeout; + } + + if (failure != Stage2Result::Idle) { + cleanStagedFiles(); + espStage2LastResult = failure; + output.print(failure == Stage2Result::Timeout + ? "ERROR TIMEOUT\n" : "ERROR WRITE\n"); + return; + } + if (received != expectedSize || ~crc != expectedCrc) { + cleanStagedFiles(); + espStage2LastResult = Stage2Result::ChecksumFailed; + output.print("ERROR CHECKSUM\n"); + return; + } + if (!writeMetadata(STAGED_META_PATH, expectedSize, expectedCrc)) { + cleanStagedFiles(); + espStage2LastResult = Stage2Result::MetadataFailed; + output.print("ERROR INSTALL\n"); + return; + } + + ++espStage2Completed; + espStage2LastResult = Stage2Result::Staged; + output.print("STAGED\n"); +} + struct CommandReader { Stream& input; Print& output; bool allowUpload; + bool allowStagedUpload; + bool trackEspRequest; char line[96] = {}; size_t length = 0; }; void processCommand(CommandReader& reader) { if (strcmp(reader.line, "MCFONT INFO") == 0) { - sendInfo(reader.output, !reader.allowUpload); + sendInfo(reader.output, reader.trackEspRequest); return; } if (strcmp(reader.line, "MCFONT STATUS") == 0) { @@ -316,20 +654,79 @@ void processCommand(CommandReader& reader) { (unsigned long)espLastBytes); return; } + if (strcmp(reader.line, "MCFONT STAGESTATUS") == 0) { + reader.output.printf("MCFONT STAGESTATUS 2 %lu %lu %lu %lu %s\n", + (unsigned long)espStage2Attempts, + (unsigned long)espStage2Completed, + (unsigned long)espStage2LastBytes, + (unsigned long)espStage2LastElapsedMs, + stage2ResultName(espStage2LastResult)); + return; + } if (strcmp(reader.line, "MCFONT GET") == 0) { - sendFont(reader.output, !reader.allowUpload); + sendFont(reader.output, reader.trackEspRequest); return; } unsigned long size = 0; unsigned long crc = 0; + unsigned long chunkSize = 0; + int consumed = 0; + if (sscanf(reader.line, "MCFONT STAGEV2 %lu %lx %lu %n", + &size, &crc, &chunkSize, &consumed) == 3 + && reader.line[consumed] == 0) { + if (!reader.allowStagedUpload) { + reader.output.print("ERROR READONLY\n"); + } else if (size < 64 || size > MAX_FONT_BYTES) { + reader.output.print("ERROR SIZE\n"); + } else if (chunkSize != mesh::indicator_font::kStageV2ChunkBytes) { + reader.output.print("ERROR CHUNK\n"); + } else { + receiveFontStage2(reader.input, reader.output, + (size_t)size, (uint32_t)crc); + } + return; + } + if (sscanf(reader.line, "MCFONT STAGE %lu %lx", &size, &crc) == 2) { + if (!reader.allowStagedUpload) { + reader.output.print("ERROR READONLY\n"); + } else if (size < 64 || size > MAX_FONT_BYTES) { + reader.output.print("ERROR SIZE\n"); + } else { + receiveFont(reader.input, reader.output, (size_t)size, (uint32_t)crc, + ReceiveMode::StageOnly); + } + return; + } + if (sscanf(reader.line, "MCFONT COMMIT %lu %lx", &size, &crc) == 2) { + if (!reader.allowStagedUpload) { + reader.output.print("ERROR READONLY\n"); + } else if (installStagedPair((size_t)size, (uint32_t)crc)) { + reader.output.print("OK\n"); + } else { + reader.output.print("ERROR INSTALL\n"); + } + return; + } + if (strcmp(reader.line, "MCFONT ABORT") == 0) { + if (!reader.allowStagedUpload) { + reader.output.print("ERROR READONLY\n"); + } else { + cleanStagedFiles(); + espStage2LastResult = Stage2Result::Aborted; + reader.output.print("OK\n"); + } + return; + } + if (sscanf(reader.line, "MCFONT PUT %lu %lx", &size, &crc) == 2) { if (!reader.allowUpload) { reader.output.print("ERROR READONLY\n"); } else if (size < 64 || size > MAX_FONT_BYTES) { reader.output.print("ERROR SIZE\n"); } else { - receiveFont(reader.input, reader.output, (size_t)size, (uint32_t)crc); + receiveFont(reader.input, reader.output, (size_t)size, (uint32_t)crc, + ReceiveMode::InstallNow); } return; } @@ -354,8 +751,11 @@ void pollCommands(CommandReader& reader) { } } -CommandReader usbCommands = {Serial, Serial, true}; -CommandReader espCommands = {Serial1, Serial1, false}; +// USB keeps the explicit host-side PUT command. The internal UART exposes a +// two-phase STAGE/COMMIT path so ESP32 Wi-Fi recovery cannot replace a valid +// font until its immutable-source SHA-256 has passed. +CommandReader usbCommands = {Serial, Serial, true, false, false}; +CommandReader espCommands = {Serial1, Serial1, false, true, true}; } // namespace @@ -370,15 +770,11 @@ void setup() { SPI1.setSCK(SD_SCK); SPI1.setTX(SD_MOSI); SPI1.setRX(SD_MISO); - sdReady = SD.begin(SD_CS, SD_CLOCK_HZ, SPI1); - if (sdReady) { - if (!SD.exists(FONT_DIRECTORY)) SD.mkdir(FONT_DIRECTORY); - recoverFontTransaction(); - refreshFontInfo(); - } + serviceSdMount(millis()); } void loop() { + serviceSdMount(millis()); pollCommands(espCommands); pollCommands(usbCommands); delay(1); diff --git a/variants/gat562_30s_mesh_kit/platformio.ini b/variants/gat562_30s_mesh_kit/platformio.ini index 4235c1e5..2d054e71 100644 --- a/variants/gat562_30s_mesh_kit/platformio.ini +++ b/variants/gat562_30s_mesh_kit/platformio.ini @@ -17,7 +17,7 @@ build_flags = ${rak4631_hw.build_flags} -D WRAPPER_CLASS=CustomSX1262Wrapper -D LORA_TX_POWER=22 -D SX126X_CURRENT_LIMIT=140 - -D PIN_BUZZER=33 + -D PIN_BUZZER=34 ; schematic net BEE_EN is nRF P1.02; GPIO 33 is GPS IO2 -D SX126X_RX_BOOSTED_GAIN=1 -D SX126X_DIO2_AS_RF_SWITCH=true build_src_filter = ${rak4631_hw.build_src_filter} diff --git a/variants/rak3401/platformio.ini b/variants/rak3401/platformio.ini index 3f24849f..9c13f8ac 100644 --- a/variants/rak3401/platformio.ini +++ b/variants/rak3401/platformio.ini @@ -54,8 +54,12 @@ build_src_filter = ${rak3401.build_src_filter} +<../examples/simple_repeater> ; Explicit lean alternative to the full-sensor, non-OTA repeater above. It removes optional -; environmental sensors while retaining the common INA I2C voltage/current monitors and RAK12501 -; GPS support. Use sensor slot A: slot D conflicts with the RAK13302 radio's BUSY/DIO1 wiring. +; environmental telemetry sensors while retaining the common INA I2C voltage/current monitors, +; board display/RTC support, and both RAK12500 I2C and RAK12501 UART GPS paths. Use sensor slot A: +; slot D conflicts with the RAK13302 radio's BUSY/DIO1 wiring. +; RAK12500 and the configured INA3221 both use I2C address 0x42, so they cannot be installed +; together at those addresses. Keep GPS at 0x42, strap INA3221 A0 to SCL for 0x43, and build +; with -DTELEM_INA3221_ADDRESS=0x43 when both modules are required. ; The legacy target name remains stable for OTA compatibility. [env:RAK_3401_repeater_lora_ota_no_external_sensors] extends = rak3401 diff --git a/variants/rak3401/variant.h b/variants/rak3401/variant.h index 0bbc87e0..e3dcd98b 100644 --- a/variants/rak3401/variant.h +++ b/variants/rak3401/variant.h @@ -135,6 +135,13 @@ static const uint8_t AREF = PIN_AREF; #define PIN_WIRE_SDA (WB_I2C1_SDA) #define PIN_WIRE_SCL (WB_I2C1_SCL) +// Keep the board-level aliases in the variant rather than duplicating the +// physical pin numbers in each PlatformIO environment. MeshCore board and +// sensor helpers use PIN_BOARD_* when they need to select the WisBlock I2C +// bus explicitly; Wire itself uses the equivalent PIN_WIRE_* definitions. +#define PIN_BOARD_SDA PIN_WIRE_SDA +#define PIN_BOARD_SCL PIN_WIRE_SCL + // The RAK3401 has no on-board QSPI flash. These pads are the WisBlock SPI bus // and are used by the external RAK13300/RAK13302 radio module. #define PIN_QSPI_SCK 3 @@ -183,6 +190,8 @@ static const uint8_t AREF = PIN_AREF; // Power is on the controllable 3V3_S rail #define PIN_GPS_PPS (17) // Pulse per second input from the GPS +// PIN_GPS_RX/TX name the pins from the GPS module's perspective. GPS RX is +// connected to the MCU UART TX pin, and GPS TX to the MCU UART RX pin. #define PIN_GPS_RX PIN_SERIAL1_TX #define PIN_GPS_TX PIN_SERIAL1_RX diff --git a/variants/rak4631/RAK4631Board.cpp b/variants/rak4631/RAK4631Board.cpp index 2826e835..3e5f6777 100644 --- a/variants/rak4631/RAK4631Board.cpp +++ b/variants/rak4631/RAK4631Board.cpp @@ -72,6 +72,12 @@ void RAK4631Board::begin() { Wire.begin(); + // WB_IO2 is the shared 3V3_S supply for WisBlock sensor/interface slots. + // Keep it enabled before GPS and environmental discovery; treating it as a + // GPS-only switch makes the subsequent I2C scan see an unpowered bus. + pinMode(WB_IO2, OUTPUT); + digitalWrite(WB_IO2, HIGH); + pinMode(SX126X_POWER_EN, OUTPUT); #ifdef NRF52_POWER_MANAGEMENT // Boot voltage protection check (may not return if voltage too low) diff --git a/variants/rak4631/platformio.ini b/variants/rak4631/platformio.ini index 309009b5..35adc14b 100644 --- a/variants/rak4631/platformio.ini +++ b/variants/rak4631/platformio.ini @@ -15,7 +15,7 @@ build_flags = ${nrf52_base.build_flags} -D MOTA_HW_ID='"RAK4631"' ; OTA hardware tag (apply refuses a .mota for different hw) -D PIN_BOARD_SCL=14 -D PIN_BOARD_SDA=13 -; -D FORCE_GPS_ALIVE // Do not off WB_IO2 if we want to keep other sensor / IO slots on + -D FORCE_GPS_ALIVE ; WB_IO2 is shared 3V3_S; GPS sleep must not power off other slots -D PIN_GPS_EN=-1 -D PIN_OLED_RESET=-1 -D USE_SX1262 @@ -103,6 +103,8 @@ build_src_filter = ${rak4631.build_src_filter} ; RAK12500/RAK12501 GPS support. The legacy no_external_sensors target name remains stable for OTA ; compatibility. RAK4631Board::getBattMilliVolts(), repeater telemetry, and battery.alert remain part ; of the board/repeater sources below. +; RAK12500 and the configured INA3221 both use I2C address 0x42. When both are required, keep GPS at +; 0x42, strap INA3221 A0 to SCL for 0x43, and build with -DTELEM_INA3221_ADDRESS=0x43. [env:RAK_4631_repeater_lora_ota_no_external_sensors] extends = rak4631 extra_scripts = ${nrf52_lora_ota.extra_scripts} diff --git a/variants/sensecap_indicator-espnow/IndicatorFontClient.cpp b/variants/sensecap_indicator-espnow/IndicatorFontClient.cpp index adcb4512..04f607e1 100644 --- a/variants/sensecap_indicator-espnow/IndicatorFontClient.cpp +++ b/variants/sensecap_indicator-espnow/IndicatorFontClient.cpp @@ -3,6 +3,24 @@ #include #include +#include "IndicatorFontRecoveryPolicy.h" + +#ifdef INDICATOR_WIFI_FONT_RECOVERY +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#endif + namespace { static const uint32_t FONT_UART_BAUD = 1000000; @@ -10,6 +28,51 @@ static const int FONT_UART_RX = 20; static const int FONT_UART_TX = 19; static const size_t MAX_FONT_BYTES = 1536 * 1024; +#ifdef INDICATOR_WIFI_FONT_RECOVERY +// api.github.com's current all-ECC chain terminates at Sectigo Public Server +// Authentication Root E46. Trusting this P-384 root per client avoids both the +// process-global compressed-bundle state and the RSA-4096 allocation failures +// seen on Arduino-ESP32 2.x. Source-certificate SHA-256: +// c90f26f0fb1b4018b22227519b5ca2b53e2ca5b3be5cf18efe1bef47380c5383 +// The self-signed root expires 2046-03-21. +static const char FONT_TLS_GITHUB_ROOT_CA[] PROGMEM = R"CERT( +-----BEGIN CERTIFICATE----- +MIICOjCCAcGgAwIBAgIQQvLM2htpN0RfFf51KBC49DAKBggqhkjOPQQDAzBfMQsw +CQYDVQQGEwJHQjEYMBYGA1UEChMPU2VjdGlnbyBMaW1pdGVkMTYwNAYDVQQDEy1T +ZWN0aWdvIFB1YmxpYyBTZXJ2ZXIgQXV0aGVudGljYXRpb24gUm9vdCBFNDYwHhcN +MjEwMzIyMDAwMDAwWhcNNDYwMzIxMjM1OTU5WjBfMQswCQYDVQQGEwJHQjEYMBYG +A1UEChMPU2VjdGlnbyBMaW1pdGVkMTYwNAYDVQQDEy1TZWN0aWdvIFB1YmxpYyBT +ZXJ2ZXIgQXV0aGVudGljYXRpb24gUm9vdCBFNDYwdjAQBgcqhkjOPQIBBgUrgQQA +IgNiAAR2+pmpbiDt+dd34wc7qNs9Xzjoq1WmVk/WSOrsfy2qw7LFeeyZYX8QeccC +WvkEN/U0NSt3zn8gj1KjAIns1aeibVvjS5KToID1AZTc8GgHHs3u/iVStSBDHBv+ +6xnOQ6OjQjBAMB0GA1UdDgQWBBTRItpMWfFLXyY4qp3W7usNw/upYTAOBgNVHQ8B +Af8EBAMCAYYwDwYDVR0TAQH/BAUwAwEB/zAKBggqhkjOPQQDAwNnADBkAjAn7qRa +qCG76UeXlImldCBteU/IvZNeWBj7LRoAasm4PdCkT0RHlAFWovgzJQxC36oCMB3q +4S6ILuH5px0CMk7yn2xVdOOurvulGu7t0vzCAxHrRVxgED1cf5kDW21USAGKcw== +-----END CERTIFICATE----- +)CERT"; + +static const uint8_t EXPECTED_SHA256[32] = { + 0x61, 0xbc, 0xe9, 0x66, 0x2d, 0xb3, 0x14, 0x05, + 0x4e, 0x7b, 0xcf, 0xaa, 0x26, 0x14, 0x7a, 0x28, + 0xad, 0x7b, 0x50, 0x0b, 0x51, 0xba, 0xac, 0x4c, + 0xae, 0x1c, 0xaa, 0xcc, 0xe9, 0x0b, 0x74, 0x21, +}; +#endif + +enum class FontInfoResult : uint8_t { + Present, + Missing, + Unavailable, +}; + +struct LoadedFont { + uint8_t* data = nullptr; + size_t size = 0; + FontInfoResult info = FontInfoResult::Unavailable; + bool currentAsset = false; +}; + uint32_t updateCrc32(uint32_t crc, const uint8_t* data, size_t size) { for (size_t i = 0; i < size; ++i) { crc ^= data[i]; @@ -42,53 +105,83 @@ bool readLine(HardwareSerial& serial, char* line, size_t capacity, return false; } -bool parseInfo(const char* line, size_t& size, uint32_t& crc) { +FontInfoResult parseInfo(const char* line, size_t& size, uint32_t& crc) { unsigned long parsed_size = 0; unsigned long parsed_crc = 0; - int present = 0; - if (sscanf(line, "MCFONT 1 %lu %lx", &parsed_size, &parsed_crc) != 2) { - if (sscanf(line, "MCFONT %d", &present) == 1 && present == 0) return false; - return false; + if (sscanf(line, "MCFONT 1 %lu %lx", &parsed_size, &parsed_crc) == 2) { + if (parsed_size < 64 || parsed_size > MAX_FONT_BYTES) { + return FontInfoResult::Unavailable; + } + size = (size_t)parsed_size; + crc = (uint32_t)parsed_crc; + return FontInfoResult::Present; } - if (parsed_size < 64 || parsed_size > MAX_FONT_BYTES) return false; - size = (size_t)parsed_size; - crc = (uint32_t)parsed_crc; - return true; + + int present = -1; + if (sscanf(line, "MCFONT %d", &present) == 1 && present == 0) { + return FontInfoResult::Missing; + } + return FontInfoResult::Unavailable; } -bool requestInfo(HardwareSerial& serial, size_t& size, uint32_t& crc) { +FontInfoResult requestInfo(HardwareSerial& serial, size_t& size, + uint32_t& crc) { char line[64]; for (int attempt = 0; attempt < 3; ++attempt) { while (serial.available()) serial.read(); serial.print("MCFONT INFO\n"); serial.flush(); - if (readLine(serial, line, sizeof(line), 1000) - && parseInfo(line, size, crc)) { - return true; + if (readLine(serial, line, sizeof(line), 1000)) { + FontInfoResult result = parseInfo(line, size, crc); + if (result != FontInfoResult::Unavailable) return result; } delay(100); } - return false; + return FontInfoResult::Unavailable; +} + +void synchronizeCommandParser(HardwareSerial& serial) { + // End a command fragment left by an ESP/RP reset or an interrupted prior + // transaction. The RP2040's parser answers the empty/partial line, which we + // discard before sending a real command. + serial.print('\n'); + serial.flush(); + delay(25); + while (serial.available()) serial.read(); } bool receiveFont(HardwareSerial& serial, uint8_t* data, size_t size, - uint32_t expected_crc) { + uint32_t expected_crc, uint8_t digest[32]) { char line[64]; serial.print("MCFONT GET\n"); serial.flush(); - size_t response_size; - uint32_t response_crc; + size_t response_size = 0; + uint32_t response_crc = 0; if (!readLine(serial, line, sizeof(line), 1500) - || !parseInfo(line, response_size, response_crc) + || parseInfo(line, response_size, response_crc) != FontInfoResult::Present || response_size != size || response_crc != expected_crc) { return false; } +#ifdef INDICATOR_WIFI_FONT_RECOVERY + mbedtls_sha256_context sha; + mbedtls_sha256_init(&sha); + if (mbedtls_sha256_starts_ret(&sha, 0) != 0) { + mbedtls_sha256_free(&sha); + return false; + } +#else + (void)digest; +#endif + size_t received = 0; uint32_t crc = 0xFFFFFFFFUL; uint32_t last_progress = millis(); +#ifdef INDICATOR_WIFI_FONT_RECOVERY + bool sha_ok = true; +#endif while (received < size && millis() - last_progress < 2000) { int available = serial.available(); if (available <= 0) { @@ -100,34 +193,1544 @@ bool receiveFont(HardwareSerial& serial, uint8_t* data, size_t size, size_t actual = serial.readBytes(data + received, count); if (actual == 0) continue; crc = updateCrc32(crc, data + received, actual); +#ifdef INDICATOR_WIFI_FONT_RECOVERY + if (mbedtls_sha256_update_ret(&sha, data + received, actual) != 0) { + sha_ok = false; + break; + } +#endif received += actual; last_progress = millis(); } +#ifdef INDICATOR_WIFI_FONT_RECOVERY + if (sha_ok) sha_ok = mbedtls_sha256_finish_ret(&sha, digest) == 0; + mbedtls_sha256_free(&sha); + return sha_ok && received == size && ~crc == expected_crc; +#else return received == size && ~crc == expected_crc; +#endif } +LoadedFont loadFromService() { + LoadedFont loaded; + if (!psramFound()) return loaded; + + Serial2.begin(FONT_UART_BAUD, SERIAL_8N1, FONT_UART_RX, FONT_UART_TX); + delay(50); + synchronizeCommandParser(Serial2); + + uint32_t expected_crc = 0; + loaded.info = requestInfo(Serial2, loaded.size, expected_crc); + if (loaded.info != FontInfoResult::Present) { + Serial2.end(); + loaded.size = 0; + return loaded; + } + + loaded.data = (uint8_t*)ps_malloc(loaded.size); + uint8_t digest[32] = {}; + if (loaded.data == nullptr + || !receiveFont(Serial2, loaded.data, loaded.size, expected_crc, digest)) { + free(loaded.data); + loaded.data = nullptr; + Serial2.end(); + return loaded; + } + Serial2.end(); +#ifdef INDICATOR_WIFI_FONT_RECOVERY + loaded.currentAsset = loaded.size == mesh::indicator_font::kAssetSize + && expected_crc == mesh::indicator_font::kAssetCrc32 + && memcmp(digest, EXPECTED_SHA256, sizeof(digest)) == 0; +#endif + return loaded; +} + +#ifdef INDICATOR_WIFI_FONT_RECOVERY + +FontInfoResult probeServiceInfo(size_t& size, uint32_t& crc) { + size = 0; + crc = 0; + Serial2.begin(FONT_UART_BAUD, SERIAL_8N1, FONT_UART_RX, FONT_UART_TX); + delay(50); + synchronizeCommandParser(Serial2); + const FontInfoResult result = requestInfo(Serial2, size, crc); + Serial2.end(); + return result; +} + +enum class RecoveryState : uint8_t { + Dormant, + ProbeWaiting, + ProbeRunning, + PostCommitProbeWaiting, + PostCommitProbeRunning, + Waiting, + Running, + Ready, + Complete, + Exhausted, +}; + +portMUX_TYPE recoveryMux = portMUX_INITIALIZER_UNLOCKED; +RecoveryState recoveryState = RecoveryState::Dormant; +mesh::indicator_font::RecoveryNeed recoveryNeed = + mesh::indicator_font::RecoveryNeed::None; +uint8_t recoveryAttempts = 0; +uint8_t serviceProbeAttempts = 0; +uint8_t postCommitProbeAttempts = 0; +uint32_t recoveryNextAttempt = 0; +bool runtimeFontInstalled = false; +bool recoveryActivateLive = false; +uint8_t* recoveredFont = nullptr; +size_t recoveredFontSize = 0; +std::atomic fontNtpTimeReceived{false}; +std::atomic fontNtpProofMillis{0}; +std::atomic fontNtpProofGeneration{0}; +std::atomic fontNtpExpectedGeneration{0}; +std::atomic fontNtpOperationGeneration{0}; + +static constexpr uint32_t HTTP_LINE_TIMEOUT_MS = 15000UL; +static constexpr uint32_t HTTP_HEADER_TOTAL_TIMEOUT_MS = 30000UL; +// Stay below the RP2040's 10-second staged-write idle timeout so a network +// failure is recognized before that peer leaves binary receive mode. +static constexpr uint32_t DOWNLOAD_IDLE_TIMEOUT_MS = 8000UL; +static constexpr uint32_t DOWNLOAD_TOTAL_TIMEOUT_MS = 180000UL; +// A normal buffered download completes in a few seconds. Two bounded Range +// reconnects cover transient early closes without letting one recovery +// attempt consume an unbounded share of GitHub's anonymous API quota. +static constexpr uint8_t DOWNLOAD_MAX_RESUME_RECONNECTS = 2; +static constexpr size_t HTTP_ETAG_CAPACITY = 96; +// Count fields, not the terminating empty line. GitHub's normal response is +// already fairly header-heavy, so leave room for future extension fields. +static constexpr size_t HTTP_HEADER_LIMIT = 64; +// Retain only the prefix needed to classify a field, but never drain an +// unbounded wire line. The per-line deadline is also checked for every byte +// while an oversized extension field is being discarded. +static constexpr size_t HTTP_WIRE_LINE_LIMIT = 2048; +static constexpr uint32_t HTTP_RATE_LIMIT_MIN_DELAY_MS = 60000UL; +static constexpr uint32_t HTTP_RATE_LIMIT_FALLBACK_DELAY_MS = 900000UL; +// deadlineReached() uses signed wrap-safe subtraction, so custom delays must +// stay comfortably below INT32_MAX. Larger server delays stop retries for the +// rest of this boot instead. +static constexpr uint32_t HTTP_RATE_LIMIT_MAX_DELAY_MS = 86400000UL; +static constexpr uint32_t STAGE_ACK_TIMEOUT_MS = 5000UL; +static constexpr uint32_t STAGE_TOTAL_TIMEOUT_MS = 170000UL; +// Older RP2040 font services accept only the original unacknowledged stream. +// Leave enough receiver-idle time after each 512-byte burst for a 1 MHz SD +// write. Updated services use an ACK after every write and do not need this. +static constexpr uint32_t LEGACY_STAGE_PACE_MS = 20UL; +// Arduino-ESP32 2.x mbedTLS allocates two 16 KiB record buffers from internal +// RAM. A 24 KiB worker stack left the Indicator's largest block too small and +// made every handshake fail with MBEDTLS_ERR_SSL_ALLOC_FAILED. The normal +// Arduino loop stack is 8 KiB; this worker has the same bounded call depth. +static constexpr uint32_t RECOVERY_TASK_STACK_BYTES = 8192UL; +// The longest HTTP body attempt is three minutes. Leave bounded headroom for +// the initial and one deadline-edge TLS/header exchange, but never let a proof +// become an unbounded process-lifetime boolean. +static constexpr uint32_t FONT_TLS_PROOF_MAX_AGE_MS = 300000UL; + +#define FONT_RECOVERY_LOG(...) \ + do { \ + if (mesh::isUsbLoggingEnabled()) { \ + mesh::usbLoggingPort().printf("Indicator font: " __VA_ARGS__); \ + } \ + } while (0) + +void clearFontNtpCallback() { + // This hook runs while the coordinator still owns the process-global SNTP + // slot, so it cannot clear a newer feature's callback. + fontNtpOperationGeneration.store(0, std::memory_order_release); + sntp_set_time_sync_notification_cb(nullptr); +} + +void noteFontNtpTime(struct timeval* value) { + const uint32_t generation = + fontNtpOperationGeneration.load(std::memory_order_acquire); + if (!mesh::sntp_coord::processWideCoordinator().owns(generation)) return; + if (value != nullptr + && value->tv_sec >= (time_t)mesh::indicator_font::kAssetPublishedEpoch) { + fontNtpProofMillis.store(millis(), std::memory_order_release); + fontNtpProofGeneration.store(generation, std::memory_order_release); + fontNtpTimeReceived.store(true, std::memory_order_release); + } +} + +bool fontTlsClockProofValid(time_t now) { + const bool fresh = fontNtpTimeReceived.load(std::memory_order_acquire); + const uint32_t proven_at = + fontNtpProofMillis.load(std::memory_order_acquire); + const uint32_t proof_generation = + fontNtpProofGeneration.load(std::memory_order_acquire); + const uint32_t expected_generation = + fontNtpExpectedGeneration.load(std::memory_order_acquire); + return mesh::tls_clock::proofGenerationIsValid( + fresh, proof_generation, expected_generation) + && mesh::tls_clock::proofIsValid( + fresh, WiFi.status() == WL_CONNECTED, now) + && mesh::tls_clock::proofAgeIsValid( + fresh, millis(), proven_at, FONT_TLS_PROOF_MAX_AGE_MS); +} + +bool prepareTlsClock() { + if (WiFi.status() != WL_CONNECTED) { + FONT_RECOVERY_LOG("NTP skipped; WiFi is disconnected\n"); + return false; + } + + mesh::sntp_coord::OperationLease sntp_operation( + mesh::sntp_coord::processWideCoordinator(), clearFontNtpCallback); + if (!sntp_operation.tryAcquire()) { + FONT_RECOVERY_LOG("NTP busy in another firmware service\n"); + return false; + } + + // A plausible retained RTC or mesh timestamp is not enough for this path. + // Observe a fresh SNTP response in this recovery attempt before opening the + // HTTPS socket. NTP is not a content trust anchor: the CA bundle and the + // compiled asset SHA-256 remain mandatory below. + fontNtpTimeReceived.store(false, std::memory_order_release); + fontNtpProofMillis.store(0, std::memory_order_release); + fontNtpProofGeneration.store(0, std::memory_order_release); + fontNtpExpectedGeneration.store( + sntp_operation.generation(), std::memory_order_release); + fontNtpOperationGeneration.store( + sntp_operation.generation(), std::memory_order_release); + sntp_set_time_sync_notification_cb(noteFontNtpTime); + sntp_set_sync_status(SNTP_SYNC_STATUS_RESET); + FONT_RECOVERY_LOG("requesting fresh NTP time before download\n"); + configTime(0, 0, "time.cloudflare.com", "time.google.com", "pool.ntp.org"); + const uint32_t started = millis(); + while (millis() - started < mesh::indicator_font::kNtpSyncWaitMillis) { + if (WiFi.status() != WL_CONNECTED) { + FONT_RECOVERY_LOG("NTP aborted after %lu ms; WiFi disconnected\n", + (unsigned long)(millis() - started)); + return false; + } + const time_t now = time(nullptr); + if (fontTlsClockProofValid(now) + && now >= (time_t)mesh::indicator_font::kAssetPublishedEpoch) { + FONT_RECOVERY_LOG("fresh NTP time %lu received in %lu ms\n", + (unsigned long)now, + (unsigned long)(millis() - started)); + return true; + } + delay(100); + } + FONT_RECOVERY_LOG("NTP timed out after %lu ms (clock %lu)\n", + (unsigned long)(millis() - started), + (unsigned long)time(nullptr)); + return false; +} + +uint32_t remainingHttpHeaderTimeout(uint32_t started) { + const uint32_t elapsed = millis() - started; + if (elapsed >= HTTP_HEADER_TOTAL_TIMEOUT_MS) return 0; + const uint32_t remaining = HTTP_HEADER_TOTAL_TIMEOUT_MS - elapsed; + return remaining < HTTP_LINE_TIMEOUT_MS ? remaining : HTTP_LINE_TIMEOUT_MS; +} + +void armRecovery(mesh::indicator_font::RecoveryNeed need, + bool activateLive = false) { + if (!mesh::indicator_font::shouldFetch(need)) return; + portENTER_CRITICAL(&recoveryMux); + recoveryNeed = need; + recoveryAttempts = 0; + postCommitProbeAttempts = 0; + recoveryNextAttempt = 0; + recoveryActivateLive = activateLive + || mesh::indicator_font::shouldActivateLive(need); + recoveryState = RecoveryState::Waiting; + portEXIT_CRITICAL(&recoveryMux); +} + +void armServiceProbe() { + portENTER_CRITICAL(&recoveryMux); + recoveryNeed = mesh::indicator_font::RecoveryNeed::None; + recoveryAttempts = 0; + serviceProbeAttempts = 0; + postCommitProbeAttempts = 0; + recoveryNextAttempt = 0; + recoveryActivateLive = false; + recoveryState = RecoveryState::ProbeWaiting; + portEXIT_CRITICAL(&recoveryMux); +} + +void armPostCommitProbe(bool activateLive) { + portENTER_CRITICAL(&recoveryMux); + // COMMIT has already accepted the integrity-checked local pair. Preserve + // the network-attempt count and move to a disjoint local-only budget so a + // transient INFO/GET failure cannot launch the same HTTPS download again. + recoveryNeed = mesh::indicator_font::RecoveryNeed::None; + postCommitProbeAttempts = 0; + recoveryNextAttempt = 0; + recoveryActivateLive = activateLive; + recoveryState = RecoveryState::PostCommitProbeWaiting; + portEXIT_CRITICAL(&recoveryMux); +} + +enum class HttpLineResult : uint8_t { + Complete, + Overflow, + TooLong, + Failed, +}; + +HttpLineResult readHttpLine(Client& client, char* line, size_t capacity, + uint32_t timeout_millis) { + if (capacity == 0) return HttpLineResult::Failed; + size_t length = 0; + size_t wire_length = 0; + bool overflow = false; + uint32_t started = millis(); + while (millis() - started < timeout_millis) { + while (client.available()) { + // client.available() can remain nonzero while a peer streams an + // attacker-controlled line. Enforce the deadline inside the drain loop, + // not just while waiting for another byte. + if (millis() - started >= timeout_millis) { + line[0] = 0; + return HttpLineResult::Failed; + } + int value = client.read(); + if (value < 0) break; + if (++wire_length > HTTP_WIRE_LINE_LIMIT) { + line[0] = 0; + return HttpLineResult::TooLong; + } + if (value == '\n') { + if (length && line[length - 1] == '\r') --length; + line[length] = 0; + return overflow ? HttpLineResult::Overflow + : HttpLineResult::Complete; + } + if (length + 1 < capacity) { + line[length++] = (char)value; + } else { + overflow = true; + } + } + if (!client.connected() && !client.available()) break; + delay(1); + } + if (capacity) line[0] = 0; + return HttpLineResult::Failed; +} + +bool isSecurityCriticalHttpHeader(const char* line) { + return strncasecmp(line, "Content-Length:", 15) == 0 + || strncasecmp(line, "Transfer-Encoding:", 18) == 0 + || strncasecmp(line, "Content-Encoding:", 17) == 0 + || strncasecmp(line, "Content-Range:", 14) == 0 + || strncasecmp(line, "ETag:", 5) == 0 + || strncasecmp(line, "Retry-After:", 12) == 0 + || strncasecmp(line, "X-RateLimit-Reset:", 18) == 0 + || line[0] == ' ' || line[0] == '\t'; +} + +bool parseUnsignedDecimalHeader(const char* value, uint64_t& parsed) { + while (*value == ' ' || *value == '\t') ++value; + if (*value < '0' || *value > '9') return false; + + uint64_t result = 0; + do { + const uint8_t digit = (uint8_t)(*value - '0'); + if (result > (UINT64_MAX - digit) / 10U) return false; + result = result * 10U + digit; + ++value; + } while (*value >= '0' && *value <= '9'); + + while (*value == ' ' || *value == '\t') ++value; + if (*value != 0) return false; + parsed = result; + return true; +} + +bool parseDecimalToken(const char*& value, uint64_t& parsed) { + if (*value < '0' || *value > '9') return false; + uint64_t result = 0; + do { + const uint8_t digit = (uint8_t)(*value - '0'); + if (result > (UINT64_MAX - digit) / 10U) return false; + result = result * 10U + digit; + ++value; + } while (*value >= '0' && *value <= '9'); + parsed = result; + return true; +} + +bool parseContentRangeHeader(const char* value, size_t& first, size_t& last, + size_t& total) { + while (*value == ' ' || *value == '\t') ++value; + if (strncasecmp(value, "bytes ", 6) != 0) return false; + value += 6; + + uint64_t parsed_first = 0; + uint64_t parsed_last = 0; + uint64_t parsed_total = 0; + if (!parseDecimalToken(value, parsed_first) || *value++ != '-' + || !parseDecimalToken(value, parsed_last) || *value++ != '/' + || !parseDecimalToken(value, parsed_total)) { + return false; + } + while (*value == ' ' || *value == '\t') ++value; + if (*value != 0 || parsed_first > parsed_last + || parsed_last >= parsed_total || parsed_total > MAX_FONT_BYTES) { + return false; + } + first = (size_t)parsed_first; + last = (size_t)parsed_last; + total = (size_t)parsed_total; + return true; +} + +bool parseStrongEtagHeader(const char* value, char* etag, size_t capacity) { + if (etag == nullptr || capacity == 0) return false; + etag[0] = 0; + while (*value == ' ' || *value == '\t') ++value; + const char* end = value + strlen(value); + while (end > value && (end[-1] == ' ' || end[-1] == '\t')) --end; + const size_t length = (size_t)(end - value); + if (length < 2 || length >= capacity || value[0] != '"' + || value[length - 1] != '"') { + return false; + } + // A strong entity tag is a quoted opaque value, never W/"...". Restrict + // the reflected If-Range value to printable RFC etagc bytes and reject an + // embedded quote so response data cannot create another request header. + for (size_t i = 1; i + 1 < length; ++i) { + const uint8_t byte = (uint8_t)value[i]; + if (byte < 0x21 || byte > 0x7e || byte == '"') return false; + } + memcpy(etag, value, length); + etag[length] = 0; + return true; +} + +bool parseHttpStatusCode(const char* line, int& status_code) { + if (strncmp(line, "HTTP/", 5) != 0) return false; + const char* status = strchr(line, ' '); + if (status == nullptr) return false; + ++status; + if (status[0] < '0' || status[0] > '9' + || status[1] < '0' || status[1] > '9' + || status[2] < '0' || status[2] > '9' + || (status[3] != 0 && status[3] != ' ' && status[3] != '\t')) { + return false; + } + status_code = (status[0] - '0') * 100 + + (status[1] - '0') * 10 + (status[2] - '0'); + return status_code >= 100 && status_code <= 599; +} + +enum class RecoveryAttemptDisposition : uint8_t { + Succeeded, + CommittedNeedsProbe, + RetryableFailure, + PermanentFailure, + RateLimited, +}; + +struct RecoveryAttemptOutcome { + RecoveryAttemptDisposition disposition; + uint32_t retryDelayMillis; +}; + +RecoveryAttemptOutcome recoveryOutcome( + RecoveryAttemptDisposition disposition, + uint32_t retry_delay_millis = 0) { + RecoveryAttemptOutcome outcome = {disposition, retry_delay_millis}; + return outcome; +} + +RecoveryAttemptOutcome classifyHttpResponse( + int status_code, bool retry_after_valid, uint64_t retry_after_seconds, + bool rate_reset_valid, uint64_t rate_reset_epoch) { + if (status_code == 200 || status_code == 206) { + return recoveryOutcome(RecoveryAttemptDisposition::Succeeded); + } + if (status_code == 403 || status_code == 429) { + uint64_t delay_seconds = 0; + bool delay_from_server = false; + if (retry_after_valid) { + delay_seconds = retry_after_seconds; + delay_from_server = true; + } + if (rate_reset_valid) { + const time_t now = time(nullptr); + uint64_t reset_delay = 0; + if (now > 0 && rate_reset_epoch > (uint64_t)now) { + // Avoid retrying on the exact reset boundary. + reset_delay = rate_reset_epoch - (uint64_t)now + 2U; + } + if (!delay_from_server || reset_delay > delay_seconds) { + delay_seconds = reset_delay; + } + delay_from_server = true; + } + if (!delay_from_server) { + FONT_RECOVERY_LOG( + "HTTP %d rate limit had no usable reset header; waiting 15 minutes\n", + status_code); + return recoveryOutcome(RecoveryAttemptDisposition::RateLimited, + HTTP_RATE_LIMIT_FALLBACK_DELAY_MS); + } + + const uint64_t maximum_seconds = + HTTP_RATE_LIMIT_MAX_DELAY_MS / 1000UL; + if (delay_seconds > maximum_seconds) { + FONT_RECOVERY_LOG( + "HTTP %d rate-limit delay exceeds scheduler range; stopping this boot\n", + status_code); + return recoveryOutcome(RecoveryAttemptDisposition::PermanentFailure); + } + uint32_t delay_millis = (uint32_t)delay_seconds * 1000UL; + if (delay_millis < HTTP_RATE_LIMIT_MIN_DELAY_MS) { + delay_millis = HTTP_RATE_LIMIT_MIN_DELAY_MS; + } + FONT_RECOVERY_LOG("HTTP %d rate limited; next attempt in %lu ms\n", + status_code, (unsigned long)delay_millis); + return recoveryOutcome(RecoveryAttemptDisposition::RateLimited, + delay_millis); + } + if (status_code >= 500 && status_code <= 599) { + FONT_RECOVERY_LOG("HTTP %d server failure is retryable\n", status_code); + return recoveryOutcome(RecoveryAttemptDisposition::RetryableFailure); + } + + // Redirects are deliberately refused: only the commit-pinned TLS origin is + // trusted. Other success codes cannot carry the requested representation, + // and all remaining 4xx responses are permanent for this boot. + FONT_RECOVERY_LOG("HTTP %d response is permanent for this boot\n", + status_code); + return recoveryOutcome(RecoveryAttemptDisposition::PermanentFailure); +} + +bool splitHttpsUrl(const char* url, char* host, size_t host_capacity, + char* path, size_t path_capacity) { + static const char prefix[] = "https://"; + if (strncmp(url, prefix, sizeof(prefix) - 1) != 0) return false; + const char* authority = url + sizeof(prefix) - 1; + const char* slash = strchr(authority, '/'); + if (slash == nullptr) return false; + size_t host_length = (size_t)(slash - authority); + size_t path_length = strlen(slash); + if (host_length == 0 || host_length >= host_capacity + || path_length == 0 || path_length >= path_capacity + || memchr(authority, '@', host_length) != nullptr + || memchr(authority, ':', host_length) != nullptr) { + return false; + } + memcpy(host, authority, host_length); + host[host_length] = 0; + memcpy(path, slash, path_length + 1); + return true; +} + +RecoveryAttemptOutcome openAssetResponse( + WiFiClientSecure& client, size_t requested_offset, + const char* expected_etag, char* captured_etag, + size_t captured_etag_capacity) { + // An interrupted first block resumes at byte zero, so the presence of the + // captured entity tag—not a nonzero offset—distinguishes a Range request. + const bool is_resume = expected_etag != nullptr; + if (requested_offset >= mesh::indicator_font::kAssetSize + || (is_resume && expected_etag[0] == 0) + || (!is_resume + && (captured_etag == nullptr || captured_etag_capacity == 0))) { + FONT_RECOVERY_LOG("invalid HTTP range request state\n"); + return recoveryOutcome(RecoveryAttemptDisposition::PermanentFailure); + } + if (captured_etag != nullptr && captured_etag_capacity != 0) { + captured_etag[0] = 0; + } + + char host[96]; + char path[224]; + if (!splitHttpsUrl(mesh::indicator_font::kAssetUrl, host, sizeof(host), + path, sizeof(path))) { + FONT_RECOVERY_LOG("asset URL is invalid\n"); + return recoveryOutcome(RecoveryAttemptDisposition::PermanentFailure); + } + + // WiFiClientSecure retains this per-client TLS configuration across stop() + // and reconnect. Reapplying Stream::setTimeout() to Arduino-ESP32 2.x's + // just-closed socket emits a misleading EBADF diagnostic, so configure the + // client once for the initial request and reuse it for validated ranges. + if (!is_resume) { + client.setCACert(FONT_TLS_GITHUB_ROOT_CA); +#if ESP_ARDUINO_VERSION_MAJOR >= 3 + client.setTimeout(HTTP_LINE_TIMEOUT_MS); +#else + // Arduino-ESP32 2.x takes seconds here, unlike Arduino 3.x's millisecond + // overload. Passing 15000 would leave a failed socket blocked for 4.2 hours. + client.setTimeout(HTTP_LINE_TIMEOUT_MS / 1000UL); +#endif + client.setHandshakeTimeout(15); + } + // prepareTlsClock() observes one fresh SNTP reply for this bounded recovery + // operation. Recheck that proof, WiFi, and the signed wall clock directly + // before every handshake, including each Range reconnect, without adding a + // second NTP round trip to a transient HTTP recovery. + const time_t tls_now = time(nullptr); + if (!fontTlsClockProofValid(tls_now) + || tls_now < (time_t)mesh::indicator_font::kAssetPublishedEpoch) { + FONT_RECOVERY_LOG("TLS clock proof is no longer valid\n"); + return recoveryOutcome(RecoveryAttemptDisposition::RetryableFailure); + } + FONT_RECOVERY_LOG("opening TLS connection to %s\n", host); + if (!client.connect(host, 443)) { + FONT_RECOVERY_LOG("TLS connection failed\n"); + return recoveryOutcome(RecoveryAttemptDisposition::RetryableFailure); + } + client.printf( + "GET %s HTTP/1.0\r\nHost: %s\r\nUser-Agent: MeshCore-Indicator-Font\r\n" + "Accept: application/vnd.github.raw+json\r\n" + "X-GitHub-Api-Version: 2026-03-10\r\nAccept-Encoding: identity\r\n" + "Cache-Control: no-cache\r\n", + path, host); + if (is_resume) { + client.printf("Range: bytes=%lu-%lu\r\nIf-Range: %s\r\n", + (unsigned long)requested_offset, + (unsigned long)(mesh::indicator_font::kAssetSize - 1), + expected_etag); + } + client.print("Connection: close\r\n\r\n"); + + char line[256]; + const uint32_t headerStarted = millis(); + uint32_t lineTimeout = remainingHttpHeaderTimeout(headerStarted); + int status_code = 0; + if (lineTimeout == 0 + || readHttpLine(client, line, sizeof(line), lineTimeout) + != HttpLineResult::Complete + || !parseHttpStatusCode(line, status_code)) { + FONT_RECOVERY_LOG("HTTP status line failed\n"); + client.stop(); + return recoveryOutcome(RecoveryAttemptDisposition::RetryableFailure); + } + + bool headers_complete = false; + bool content_length_seen = false; + bool content_length_valid = true; + size_t content_length = 0; + bool transfer_encoding_seen = false; + bool content_encoding_seen = false; + bool content_encoding_identity = true; + bool content_range_seen = false; + bool content_range_valid = true; + size_t content_range_first = 0; + size_t content_range_last = 0; + size_t content_range_total = 0; + bool etag_seen = false; + bool etag_valid = true; + char response_etag[HTTP_ETAG_CAPACITY] = {}; + bool critical_header_overflow = false; + bool retry_after_valid = false; + uint64_t retry_after_seconds = 0; + bool rate_reset_valid = false; + uint64_t rate_reset_epoch = 0; + size_t header_count = 0; + while (header_count <= HTTP_HEADER_LIMIT) { + lineTimeout = remainingHttpHeaderTimeout(headerStarted); + if (lineTimeout == 0) { + break; + } + const HttpLineResult line_result = + readHttpLine(client, line, sizeof(line), lineTimeout); + if (line_result == HttpLineResult::Failed) break; + if (line_result == HttpLineResult::TooLong) { + FONT_RECOVERY_LOG("HTTP header exceeds %lu wire bytes\n", + (unsigned long)HTTP_WIRE_LINE_LIMIT); + client.stop(); + return recoveryOutcome(RecoveryAttemptDisposition::RetryableFailure); + } + // RFC 7230 obsolete line folding is ambiguous to intermediaries. Reject + // every continuation line, including a normal-size one, before deciding + // whether an oversized field would otherwise be harmless. + if (line[0] == ' ' || line[0] == '\t') { + FONT_RECOVERY_LOG("refusing obsolete folded HTTP header\n"); + client.stop(); + return recoveryOutcome(RecoveryAttemptDisposition::RetryableFailure); + } + if (line_result == HttpLineResult::Overflow) { + // GitHub currently returns a long Access-Control-Expose-Headers field. + // Drain and ignore oversized extension headers, but never permit a + // truncated framing header or obsolete folded continuation to hide body + // boundaries from a successful response. Keep parsing so a non-200 + // response can still provide rate-limit metadata later in the block. + if (isSecurityCriticalHttpHeader(line)) { + critical_header_overflow = true; + } + ++header_count; + continue; + } + if (line[0] == 0) { + headers_complete = true; + break; + } + if (++header_count > HTTP_HEADER_LIMIT) break; + if (strncasecmp(line, "Content-Length:", 15) == 0) { + if (content_length_seen) { + content_length_valid = false; + } else { + uint64_t parsed = 0; + if (!parseUnsignedDecimalHeader(line + 15, parsed) + || parsed > MAX_FONT_BYTES) { + content_length_valid = false; + } else { + content_length = (size_t)parsed; + } + content_length_seen = true; + } + } else if (strncasecmp(line, "Transfer-Encoding:", 18) == 0) { + transfer_encoding_seen = true; + } else if (strncasecmp(line, "Content-Encoding:", 17) == 0) { + const char* value = line + 17; + while (*value == ' ' || *value == '\t') ++value; + content_encoding_identity = !content_encoding_seen + && strcasecmp(value, "identity") == 0; + content_encoding_seen = true; + } else if (strncasecmp(line, "Content-Range:", 14) == 0) { + if (content_range_seen + || !parseContentRangeHeader(line + 14, content_range_first, + content_range_last, + content_range_total)) { + content_range_valid = false; + } + content_range_seen = true; + } else if (strncasecmp(line, "ETag:", 5) == 0) { + if (etag_seen + || !parseStrongEtagHeader(line + 5, response_etag, + sizeof(response_etag))) { + etag_valid = false; + } + etag_seen = true; + } else if (strncasecmp(line, "Retry-After:", 12) == 0) { + uint64_t parsed = 0; + if (parseUnsignedDecimalHeader(line + 12, parsed)) { + if (!retry_after_valid || parsed > retry_after_seconds) { + retry_after_seconds = parsed; + } + retry_after_valid = true; + } else { + FONT_RECOVERY_LOG("ignoring malformed Retry-After header\n"); + } + } else if (strncasecmp(line, "X-RateLimit-Reset:", 18) == 0) { + uint64_t parsed = 0; + if (parseUnsignedDecimalHeader(line + 18, parsed)) { + if (!rate_reset_valid || parsed > rate_reset_epoch) { + rate_reset_epoch = parsed; + } + rate_reset_valid = true; + } else { + FONT_RECOVERY_LOG("ignoring malformed X-RateLimit-Reset header\n"); + } + } + } + if (!headers_complete) { + FONT_RECOVERY_LOG("HTTP headers incomplete or over limit\n"); + client.stop(); + return recoveryOutcome(RecoveryAttemptDisposition::RetryableFailure); + } + + // Classify only after the complete header block has been parsed. In + // particular, GitHub's 403/429 rate-limit metadata lives in those headers. + const RecoveryAttemptOutcome response = classifyHttpResponse( + status_code, retry_after_valid, retry_after_seconds, + rate_reset_valid, rate_reset_epoch); + if (response.disposition != RecoveryAttemptDisposition::Succeeded) { + client.stop(); + return response; + } + + if (critical_header_overflow) { + FONT_RECOVERY_LOG("oversized critical HTTP header\n"); + client.stop(); + return recoveryOutcome(RecoveryAttemptDisposition::RetryableFailure); + } + const int expected_status = is_resume ? 206 : 200; + if (status_code != expected_status) { + FONT_RECOVERY_LOG( + "HTTP %d rejected for %s request (expected %d)\n", status_code, + is_resume ? "resumed" : "initial", expected_status); + client.stop(); + return recoveryOutcome(RecoveryAttemptDisposition::RetryableFailure); + } + const size_t expected_content_length = + mesh::indicator_font::kAssetSize - requested_offset; + if (!content_length_seen || !content_length_valid + || content_length != expected_content_length) { + FONT_RECOVERY_LOG( + "HTTP headers invalid (length_seen=%u length_valid=%u length=%lu expected=%lu)\n", + content_length_seen ? 1U : 0U, content_length_valid ? 1U : 0U, + (unsigned long)content_length, + (unsigned long)expected_content_length); + client.stop(); + return recoveryOutcome(RecoveryAttemptDisposition::RetryableFailure); + } + if (transfer_encoding_seen) { + FONT_RECOVERY_LOG("refusing transfer-encoded response\n"); + client.stop(); + return recoveryOutcome(RecoveryAttemptDisposition::RetryableFailure); + } + if (!content_encoding_identity) { + FONT_RECOVERY_LOG("refusing encoded response\n"); + client.stop(); + return recoveryOutcome(RecoveryAttemptDisposition::RetryableFailure); + } + if (!etag_seen || !etag_valid) { + FONT_RECOVERY_LOG("missing, duplicate, or weak HTTP ETag\n"); + client.stop(); + return recoveryOutcome(RecoveryAttemptDisposition::RetryableFailure); + } + if (is_resume) { + if (!content_range_seen || !content_range_valid + || content_range_first != requested_offset + || content_range_last != mesh::indicator_font::kAssetSize - 1 + || content_range_total != mesh::indicator_font::kAssetSize + || strcmp(response_etag, expected_etag) != 0) { + FONT_RECOVERY_LOG( + "resumed HTTP range or ETag mismatch (first=%lu last=%lu total=%lu)\n", + (unsigned long)content_range_first, + (unsigned long)content_range_last, + (unsigned long)content_range_total); + client.stop(); + return recoveryOutcome(RecoveryAttemptDisposition::RetryableFailure); + } + } else { + if (content_range_seen) { + FONT_RECOVERY_LOG("unexpected Content-Range on initial response\n"); + client.stop(); + return recoveryOutcome(RecoveryAttemptDisposition::RetryableFailure); + } + const size_t etag_length = strlen(response_etag); + if (etag_length + 1 > captured_etag_capacity) { + FONT_RECOVERY_LOG("HTTP ETag output buffer is too small\n"); + client.stop(); + return recoveryOutcome(RecoveryAttemptDisposition::PermanentFailure); + } + memcpy(captured_etag, response_etag, etag_length + 1); + } + FONT_RECOVERY_LOG("TLS/HTTP verified; downloading %lu bytes at offset %lu\n", + (unsigned long)content_length, + (unsigned long)requested_offset); + return response; +} + +bool writeAll(HardwareSerial& serial, const uint8_t* data, size_t size) { + size_t written = 0; + uint32_t last_progress = millis(); + while (written < size && millis() - last_progress < 5000UL) { + size_t count = serial.write(data + written, size - written); + if (count == 0) { + delay(1); + continue; + } + written += count; + last_progress = millis(); + } + return written == size; +} + +bool commandReply(HardwareSerial& serial, const char* command, char* line, + size_t line_capacity, uint32_t timeout_millis) { + if (line_capacity == 0) return false; + line[0] = 0; + while (serial.available()) serial.read(); + serial.print(command); + serial.print('\n'); + serial.flush(); + return readLine(serial, line, line_capacity, timeout_millis); +} + +enum class StageTransferMode : uint8_t { + Acknowledged, + LegacyPaced, +}; + +bool beginStagedUpload(HardwareSerial& serial, StageTransferMode& mode) { + char command[72]; + char reply[64]; + // STAGEV2 intentionally is not prefixed by the legacy "STAGE " + // grammar. Older sscanf-based services would otherwise parse the `2` in + // STAGE2 as a two-byte file and answer ERROR SIZE instead of the explicit + // ERROR COMMAND compatibility signal. + snprintf(command, sizeof(command), "MCFONT STAGEV2 %lu %08lx %lu", + (unsigned long)mesh::indicator_font::kAssetSize, + (unsigned long)mesh::indicator_font::kAssetCrc32, + (unsigned long)mesh::indicator_font::kStageV2ChunkBytes); + const bool replied = commandReply( + serial, command, reply, sizeof(reply), 2000); + const mesh::indicator_font::StageV2BeginAction action = + mesh::indicator_font::classifyStageV2BeginReply(replied, reply); + if (action + == mesh::indicator_font::StageV2BeginAction::UseAcknowledged) { + mode = StageTransferMode::Acknowledged; + FONT_RECOVERY_LOG("RP2040 staging protocol 2 ready (512-byte ACKs)\n"); + return true; + } + + // Existing RP2040 releases answer an unknown command with ERROR COMMAND. + // Fall back only on that explicit pre-transfer rejection. A delayed READY + // must never be mistaken for permission to inject a legacy command into a + // receiver that has already entered protocol-2 binary mode. + if (action != mesh::indicator_font::StageV2BeginAction::UseLegacy) { + if (!replied) { + FONT_RECOVERY_LOG("RP2040 staging protocol 2 timed out\n"); + } else { + FONT_RECOVERY_LOG("RP2040 rejected staging protocol 2: %s\n", reply); + } + return false; + } + + FONT_RECOVERY_LOG( + "RP2040 staging protocol 2 unavailable; using paced legacy stream\n"); + snprintf(command, sizeof(command), "MCFONT STAGE %lu %08lx", + (unsigned long)mesh::indicator_font::kAssetSize, + (unsigned long)mesh::indicator_font::kAssetCrc32); + if (!commandReply(serial, command, reply, sizeof(reply), 2000) + || strcmp(reply, "READY") != 0) { + FONT_RECOVERY_LOG("RP2040 rejected legacy staging: %s\n", + reply[0] ? reply : ""); + return false; + } + mode = StageTransferMode::LegacyPaced; + return true; +} + +bool stagedChunkAcknowledged(HardwareSerial& serial, size_t expected_offset) { + char reply[64]; + serial.flush(); + if (!readLine(serial, reply, sizeof(reply), STAGE_ACK_TIMEOUT_MS)) { + FONT_RECOVERY_LOG("RP2040 stage ACK timed out at byte %lu\n", + (unsigned long)expected_offset); + return false; + } + if (!mesh::indicator_font::parseStageV2Ack(reply, expected_offset)) { + FONT_RECOVERY_LOG( + "RP2040 stage ACK invalid or out of sequence at byte %lu: %s\n", + (unsigned long)expected_offset, reply); + return false; + } + return true; +} + +RecoveryAttemptOutcome downloadAndInstallAsset() { + WiFiClientSecure client; + if (!prepareTlsClock()) { + return recoveryOutcome(RecoveryAttemptDisposition::RetryableFailure); + } + char asset_etag[HTTP_ETAG_CAPACITY] = {}; + RecoveryAttemptOutcome response = openAssetResponse( + client, 0, nullptr, asset_etag, sizeof(asset_etag)); + if (response.disposition != RecoveryAttemptDisposition::Succeeded) { + return response; + } + + // Do not hold the HTTPS response open while the RP2040 performs SD writes. + // GitHub can close a slow response near its service deadline, and the + // Indicator has enough PSRAM to keep this immutable 1.3 MiB object separate + // even when an older runtime font is still active. + uint8_t* asset = static_cast( + ps_malloc(mesh::indicator_font::kAssetSize)); + if (asset == nullptr) { + FONT_RECOVERY_LOG("PSRAM allocation failed for download\n"); + client.stop(); + return recoveryOutcome(RecoveryAttemptDisposition::RetryableFailure); + } + + mbedtls_sha256_context sha; + mbedtls_sha256_init(&sha); + bool ok = mbedtls_sha256_starts_ret(&sha, 0) == 0; + const char* failure = ok ? nullptr : "SHA-256 initialization"; + size_t received = 0; + uint32_t started = millis(); + uint32_t last_progress = started; + size_t next_progress = 256 * 1024; + uint8_t resume_reconnects = 0; + bool response_failure = false; + while (ok && received < mesh::indicator_font::kAssetSize) { + bool reconnect_needed = false; + size_t discarded_partial = 0; + while (ok && received < mesh::indicator_font::kAssetSize + && !reconnect_needed) { + if (millis() - started >= DOWNLOAD_TOTAL_TIMEOUT_MS) { + failure = "HTTP body total timeout"; + ok = false; + break; + } + + // `received` is both the SHA stream offset and the only legal Range + // resume boundary. Bytes from a short 16 KiB block remain un-hashed and + // are deliberately overwritten if this response closes early. + size_t wanted = mesh::indicator_font::kAssetSize - received; + if (wanted > 16 * 1024) wanted = 16 * 1024; + size_t filled = 0; + while (filled < wanted) { + if (millis() - started >= DOWNLOAD_TOTAL_TIMEOUT_MS) { + failure = "HTTP body total timeout"; + ok = false; + break; + } + if (millis() - last_progress >= DOWNLOAD_IDLE_TIMEOUT_MS) { + failure = "HTTP body idle timeout"; + discarded_partial = filled; + reconnect_needed = true; + break; + } + int available = client.available(); + if (available <= 0) { + if (!client.connected() && !client.available()) { + failure = "HTTP body ended early"; + discarded_partial = filled; + reconnect_needed = true; + break; + } + delay(1); + continue; + } + size_t count_wanted = wanted - filled; + if (count_wanted > (size_t)available) { + count_wanted = (size_t)available; + } + int count = client.read(asset + received + filled, count_wanted); + if (count <= 0) continue; + filled += (size_t)count; + last_progress = millis(); + } + if (!ok || reconnect_needed) break; + + if (mbedtls_sha256_update_ret(&sha, asset + received, wanted) != 0) { + failure = "SHA-256 update"; + ok = false; + break; + } + received += wanted; + if (received >= next_progress) { + FONT_RECOVERY_LOG("downloaded %lu of %lu bytes in %lu ms\n", + (unsigned long)received, + (unsigned long)mesh::indicator_font::kAssetSize, + (unsigned long)(millis() - started)); + next_progress += 256 * 1024; + } + } + + if (!ok || received == mesh::indicator_font::kAssetSize) break; + client.stop(); + if (!reconnect_needed) { + failure = "HTTP body incomplete"; + ok = false; + break; + } + if (millis() - started >= DOWNLOAD_TOTAL_TIMEOUT_MS) { + failure = "HTTP body total timeout"; + ok = false; + break; + } + if (resume_reconnects >= DOWNLOAD_MAX_RESUME_RECONNECTS) { + failure = "HTTP Range reconnect limit"; + ok = false; + break; + } + + bool range_open = false; + while (resume_reconnects < DOWNLOAD_MAX_RESUME_RECONNECTS + && millis() - started < DOWNLOAD_TOTAL_TIMEOUT_MS) { + ++resume_reconnects; + FONT_RECOVERY_LOG( + "response interrupted; Range resume %u/%u at byte %lu (discarding %lu unverified bytes)\n", + (unsigned int)resume_reconnects, + (unsigned int)DOWNLOAD_MAX_RESUME_RECONNECTS, + (unsigned long)received, (unsigned long)discarded_partial); + response = openAssetResponse( + client, received, asset_etag, nullptr, 0); + if (response.disposition == RecoveryAttemptDisposition::Succeeded) { + range_open = true; + break; + } + if (response.disposition + != RecoveryAttemptDisposition::RetryableFailure) { + failure = "HTTP Range response rejected"; + response_failure = true; + break; + } + client.stop(); + if (resume_reconnects < DOWNLOAD_MAX_RESUME_RECONNECTS + && millis() - started < DOWNLOAD_TOTAL_TIMEOUT_MS) { + FONT_RECOVERY_LOG( + "Range connection failed; retrying the same verified offset\n"); + delay(250); + } + } + if (!range_open) { + if (failure == nullptr || !response_failure) { + failure = millis() - started >= DOWNLOAD_TOTAL_TIMEOUT_MS + ? "HTTP body total timeout" : "HTTP Range reconnect limit"; + } + ok = false; + break; + } + // Header parsing and a resumed TLS handshake have their own deadlines. + // Only the body-idle clock restarts; `started` remains the one deadline for + // all response bodies and reconnects in this recovery attempt. + last_progress = millis(); + } + const bool network_connected_at_end = client.connected(); + client.stop(); + + uint8_t digest[32] = {}; + if (ok && mbedtls_sha256_finish_ret(&sha, digest) != 0) { + failure = "SHA-256 finish"; + ok = false; + } + mbedtls_sha256_free(&sha); + if (!ok || received != mesh::indicator_font::kAssetSize) { + FONT_RECOVERY_LOG( + "download failed (%s; bytes=%lu elapsed=%lu idle=%lu connected=%u)\n", + failure ? failure : "incomplete body", (unsigned long)received, + (unsigned long)(millis() - started), + (unsigned long)(millis() - last_progress), + network_connected_at_end ? 1U : 0U); + free(asset); + return response_failure + ? response + : recoveryOutcome(RecoveryAttemptDisposition::RetryableFailure); + } + + bool digest_ok = memcmp(digest, EXPECTED_SHA256, sizeof(digest)) == 0; + FONT_RECOVERY_LOG("download complete in %lu ms; SHA-256=%s\n", + (unsigned long)(millis() - started), + digest_ok ? "verified" : "mismatch"); + if (!digest_ok) { + free(asset); + return recoveryOutcome(RecoveryAttemptDisposition::RetryableFailure); + } + + Serial2.begin(FONT_UART_BAUD, SERIAL_8N1, FONT_UART_RX, FONT_UART_TX); + delay(50); + synchronizeCommandParser(Serial2); + StageTransferMode stage_mode = StageTransferMode::Acknowledged; + if (!beginStagedUpload(Serial2, stage_mode)) { + Serial2.end(); + free(asset); + return recoveryOutcome(RecoveryAttemptDisposition::RetryableFailure); + } + + bool stage_ok = true; + const char* stage_failure = nullptr; + size_t staged_bytes = 0; + const uint32_t stage_started = millis(); + next_progress = 256 * 1024; + while (staged_bytes < mesh::indicator_font::kAssetSize) { + if (millis() - stage_started >= STAGE_TOTAL_TIMEOUT_MS) { + stage_failure = "total timeout"; + stage_ok = false; + break; + } + const size_t wanted = mesh::indicator_font::stageV2ChunkSize( + mesh::indicator_font::kAssetSize, staged_bytes); + if (!writeAll(Serial2, asset + staged_bytes, wanted)) { + stage_failure = "ESP32 UART write"; + stage_ok = false; + break; + } + size_t next_staged = 0; + if (!mesh::indicator_font::advanceStageV2Offset( + mesh::indicator_font::kAssetSize, staged_bytes, wanted, + next_staged)) { + stage_failure = "offset accounting"; + stage_ok = false; + break; + } + if (stage_mode == StageTransferMode::Acknowledged) { + if (!stagedChunkAcknowledged(Serial2, next_staged)) { + stage_failure = "RP2040 acknowledgement"; + stage_ok = false; + break; + } + } else { + // write() may return after queueing bytes. Drain the UART before the + // receiver-safe pause so each legacy burst stays bounded to one block. + Serial2.flush(); + delay(LEGACY_STAGE_PACE_MS); + } + staged_bytes = next_staged; + if (staged_bytes >= next_progress) { + FONT_RECOVERY_LOG("staged %lu of %lu bytes in %lu ms\n", + (unsigned long)staged_bytes, + (unsigned long)mesh::indicator_font::kAssetSize, + (unsigned long)(millis() - stage_started)); + next_progress += 256 * 1024; + } + } + free(asset); + asset = nullptr; + if (!stage_ok) { + // The RP2040's bounded receive removes its separate staging file. Do not + // inject ABORT into a peer that may still be in its binary receive state. + FONT_RECOVERY_LOG("staging failed (%s; bytes=%lu elapsed=%lu)\n", + stage_failure ? stage_failure : "unknown", + (unsigned long)staged_bytes, + (unsigned long)(millis() - stage_started)); + Serial2.end(); + return recoveryOutcome(RecoveryAttemptDisposition::RetryableFailure); + } + + char line[64]; + Serial2.flush(); + bool staged = readLine(Serial2, line, sizeof(line), 20000) + && strcmp(line, "STAGED") == 0; + FONT_RECOVERY_LOG("staging complete in %lu ms; RP2040 reply='%s'\n", + (unsigned long)(millis() - stage_started), + line[0] ? line : ""); + if (!staged) { + Serial2.end(); + return recoveryOutcome(RecoveryAttemptDisposition::RetryableFailure); + } + + char commitCommand[64]; + snprintf(commitCommand, sizeof(commitCommand), "MCFONT COMMIT %lu %08lx", + (unsigned long)mesh::indicator_font::kAssetSize, + (unsigned long)mesh::indicator_font::kAssetCrc32); + char commitReply[64]; + const bool commitReplied = commandReply( + Serial2, commitCommand, commitReply, sizeof(commitReply), 30000); + const bool committed = commitReplied && strcmp(commitReply, "OK") == 0; + const bool commitExplicitlyRejected = commitReplied + && strncmp(commitReply, "ERROR ", 6) == 0; + FONT_RECOVERY_LOG("RP2040 commit %s (reply='%s')\n", + committed ? "accepted" + : commitExplicitlyRejected ? "rejected" : "uncertain", + commitReplied ? commitReply : ""); + bool postCommitVerified = false; + if (committed) { + size_t size = 0; + uint32_t crc = 0; + postCommitVerified = + requestInfo(Serial2, size, crc) == FontInfoResult::Present + && size == mesh::indicator_font::kAssetSize + && crc == mesh::indicator_font::kAssetCrc32; + FONT_RECOVERY_LOG("post-commit font size=%lu crc=%08lx result=%s\n", + (unsigned long)size, (unsigned long)crc, + postCommitVerified ? "verified" : "probe required"); + } + Serial2.end(); + if (!committed) { + // A missing or malformed reply is not proof that COMMIT failed: the + // RP2040 can finish its durable rename just as the UART response is lost. + // Reclassify that ambiguous result using only the local service so it can + // never trigger another download of the identical immutable blob. A + // syntactically explicit ERROR remains a retryable rejected transaction. + return recoveryOutcome(commitExplicitlyRejected + ? RecoveryAttemptDisposition::RetryableFailure + : RecoveryAttemptDisposition::CommittedNeedsProbe); + } + return recoveryOutcome(postCommitVerified + ? RecoveryAttemptDisposition::Succeeded + : RecoveryAttemptDisposition::CommittedNeedsProbe); +} + +void finishRecoveryFailure(const RecoveryAttemptOutcome& outcome) { + uint32_t delay_millis = + mesh::indicator_font::retryDelayAfter(recoveryAttempts); + if (outcome.disposition == RecoveryAttemptDisposition::RateLimited + && outcome.retryDelayMillis > delay_millis) { + delay_millis = outcome.retryDelayMillis; + } + uint32_t next = millis() + delay_millis; + if (next == 0) next = 1; + portENTER_CRITICAL(&recoveryMux); + if (outcome.disposition + == RecoveryAttemptDisposition::CommittedNeedsProbe + || outcome.disposition == RecoveryAttemptDisposition::PermanentFailure + || recoveryAttempts >= mesh::indicator_font::kMaximumAttemptsPerBoot) { + recoveryState = RecoveryState::Exhausted; + } else { + recoveryNextAttempt = next; + recoveryState = RecoveryState::Waiting; + } + portEXIT_CRITICAL(&recoveryMux); +} + +void finishRecoveryFailure() { + finishRecoveryFailure( + recoveryOutcome(RecoveryAttemptDisposition::RetryableFailure)); +} + +void finishServiceProbeFailure() { + uint32_t next = millis() + + mesh::indicator_font::serviceProbeRetryDelayAfter( + serviceProbeAttempts); + if (next == 0) next = 1; + portENTER_CRITICAL(&recoveryMux); + if (serviceProbeAttempts + >= mesh::indicator_font::kMaximumServiceProbeAttemptsPerBoot) { + recoveryState = RecoveryState::Exhausted; + } else { + recoveryNextAttempt = next; + recoveryState = RecoveryState::ProbeWaiting; + } + portEXIT_CRITICAL(&recoveryMux); +} + +void finishPostCommitProbeFailure() { + uint32_t next = millis() + + mesh::indicator_font::postCommitProbeRetryDelayAfter( + postCommitProbeAttempts); + if (next == 0) next = 1; + portENTER_CRITICAL(&recoveryMux); + if (postCommitProbeAttempts + >= mesh::indicator_font::kMaximumPostCommitProbeAttemptsPerBoot) { + // COMMIT was acknowledged, so redownloading the identical immutable blob + // is neither a repair nor safe progress. Leave the fallback/old runtime + // font in place and let the next boot reclassify the local service. + recoveryState = RecoveryState::Exhausted; + } else { + recoveryNextAttempt = next; + recoveryState = RecoveryState::PostCommitProbeWaiting; + } + portEXIT_CRITICAL(&recoveryMux); +} + +void serviceProbeTask(void*) { + LoadedFont loaded = loadFromService(); + if (loaded.data != nullptr) { + if (loaded.currentAsset) { + portENTER_CRITICAL(&recoveryMux); + recoveredFont = loaded.data; + recoveredFontSize = loaded.size; + recoveryState = RecoveryState::Ready; + portEXIT_CRITICAL(&recoveryMux); + } else { + // Startup is still using the fallback because the first INFO exchange + // was inconclusive. Replace an older valid file live after recovery; + // unlike the normal version-mismatch path, no old PSRAM font is active. + free(loaded.data); + armRecovery(mesh::indicator_font::RecoveryNeed::VersionMismatch, true); + } + } else if (loaded.info == FontInfoResult::Missing) { + armRecovery(mesh::indicator_font::RecoveryNeed::Missing); + } else if (loaded.info == FontInfoResult::Present) { + armRecovery(mesh::indicator_font::RecoveryNeed::Corrupt); + } else { + finishServiceProbeFailure(); + } + vTaskDelete(nullptr); +} + +void postCommitProbeTask(void*) { + bool activateLive; + portENTER_CRITICAL(&recoveryMux); + activateLive = recoveryActivateLive; + portEXIT_CRITICAL(&recoveryMux); + + if (!activateLive) { + // A valid older runtime font is deliberately retained until reboot. The + // RP2040's COMMIT path re-reads the complete staged file and verifies its + // CRC before publishing this exact metadata, so an INFO match is enough to + // classify the local install without allocating a second 1.3 MiB buffer. + size_t size = 0; + uint32_t crc = 0; + const FontInfoResult info = probeServiceInfo(size, crc); + if (info == FontInfoResult::Present + && size == mesh::indicator_font::kAssetSize + && crc == mesh::indicator_font::kAssetCrc32) { + portENTER_CRITICAL(&recoveryMux); + recoveryState = RecoveryState::Complete; + portEXIT_CRITICAL(&recoveryMux); + } else { + FONT_RECOVERY_LOG( + "committed asset INFO re-probe failed (info=%u size=%lu crc=%08lx)\n", + (unsigned int)info, (unsigned long)size, (unsigned long)crc); + finishPostCommitProbeFailure(); + } + vTaskDelete(nullptr); + return; + } + + LoadedFont loaded = loadFromService(); + if (loaded.data != nullptr && loaded.currentAsset) { + portENTER_CRITICAL(&recoveryMux); + recoveredFont = loaded.data; + recoveredFontSize = loaded.size; + recoveryState = RecoveryState::Ready; + portEXIT_CRITICAL(&recoveryMux); + } else { + FONT_RECOVERY_LOG( + "committed asset re-probe failed (info=%u data=%u current=%u)\n", + (unsigned int)loaded.info, loaded.data != nullptr ? 1U : 0U, + loaded.currentAsset ? 1U : 0U); + free(loaded.data); + finishPostCommitProbeFailure(); + } + vTaskDelete(nullptr); +} + +void recoveryTask(void*) { + bool hadRuntimeFont; + bool activateLive; + portENTER_CRITICAL(&recoveryMux); + hadRuntimeFont = runtimeFontInstalled; + activateLive = recoveryActivateLive; + portEXIT_CRITICAL(&recoveryMux); + + const RecoveryAttemptOutcome outcome = downloadAndInstallAsset(); + const bool activateCommittedLive = !hadRuntimeFont && activateLive; + if (outcome.disposition + == RecoveryAttemptDisposition::CommittedNeedsProbe) { + armPostCommitProbe(activateCommittedLive); + vTaskDelete(nullptr); + return; + } + if (outcome.disposition != RecoveryAttemptDisposition::Succeeded) { + finishRecoveryFailure(outcome); + vTaskDelete(nullptr); + return; + } + + uint8_t* data = nullptr; + size_t size = 0; + if (activateCommittedLive) { + LoadedFont loaded = loadFromService(); + if (loaded.data == nullptr || !loaded.currentAsset) { + free(loaded.data); + // The RP2040 already acknowledged COMMIT and its INFO metadata matched. + // A failed follow-up GET is a local transport/service problem, not + // authorization to download the same immutable Git blob again. + armPostCommitProbe(true); + vTaskDelete(nullptr); + return; + } + data = loaded.data; + size = loaded.size; + } + + portENTER_CRITICAL(&recoveryMux); + recoveredFont = data; + recoveredFontSize = size; + recoveryState = data != nullptr ? RecoveryState::Ready + : RecoveryState::Complete; + portEXIT_CRITICAL(&recoveryMux); + vTaskDelete(nullptr); +} + +#endif // INDICATOR_WIFI_FONT_RECOVERY + } // namespace uint8_t* IndicatorFontClient::load(size_t& size) { - size = 0; - if (!psramFound()) return nullptr; - - Serial2.begin(FONT_UART_BAUD, SERIAL_8N1, FONT_UART_RX, FONT_UART_TX); - delay(50); - - uint32_t expected_crc; - if (!requestInfo(Serial2, size, expected_crc)) { - Serial2.end(); - size = 0; - return nullptr; + LoadedFont loaded = loadFromService(); + size = loaded.size; +#ifdef INDICATOR_WIFI_FONT_RECOVERY + if (loaded.data != nullptr) { + if (!loaded.currentAsset) { + armRecovery(mesh::indicator_font::RecoveryNeed::VersionMismatch); + } + } else if (loaded.info == FontInfoResult::Missing) { + armRecovery(mesh::indicator_font::RecoveryNeed::Missing); + } else if (loaded.info == FontInfoResult::Present) { + armRecovery(mesh::indicator_font::RecoveryNeed::Corrupt); + } else { + // The RP2040 can still be mounting SD when the ESP32 first asks. Once + // station Wi-Fi is connected, re-probe it on the background worker before + // deciding whether a network repair is needed. + armServiceProbe(); } - - uint8_t* data = (uint8_t*)ps_malloc(size); - if (data == nullptr || !receiveFont(Serial2, data, size, expected_crc)) { - free(data); - data = nullptr; - size = 0; - } - Serial2.end(); - return data; +#endif + return loaded.data; } + +#ifdef INDICATOR_WIFI_FONT_RECOVERY +uint8_t* IndicatorFontClient::serviceRecovery(size_t& size) { + size = 0; + uint8_t* ready = nullptr; + bool launchProbe = false; + bool launchPostCommitProbe = false; + bool launchRecovery = false; + uint32_t now = millis(); + const bool wifiConnected = WiFi.status() == WL_CONNECTED; + + portENTER_CRITICAL(&recoveryMux); + if (recoveryState == RecoveryState::Ready) { + ready = recoveredFont; + size = recoveredFontSize; + recoveredFont = nullptr; + recoveredFontSize = 0; + recoveryState = RecoveryState::Complete; + } else if (recoveryState == RecoveryState::PostCommitProbeWaiting + && mesh::indicator_font::deadlineReached( + now, recoveryNextAttempt)) { + // This path is local UART/SD recovery after an acknowledged COMMIT. It + // deliberately does not require Wi-Fi and cannot transition to Waiting. + recoveryState = RecoveryState::PostCommitProbeRunning; + ++postCommitProbeAttempts; + launchPostCommitProbe = true; + } else if (recoveryState == RecoveryState::ProbeWaiting + && wifiConnected + && mesh::indicator_font::deadlineReached( + now, recoveryNextAttempt)) { + recoveryState = RecoveryState::ProbeRunning; + ++serviceProbeAttempts; + launchProbe = true; + } else if (recoveryState == RecoveryState::Waiting + && wifiConnected + && mesh::indicator_font::deadlineReached( + now, recoveryNextAttempt)) { + recoveryState = RecoveryState::Running; + ++recoveryAttempts; + launchRecovery = true; + } + portEXIT_CRITICAL(&recoveryMux); + + if (launchProbe) { + TaskHandle_t task = nullptr; + if (xTaskCreatePinnedToCore(serviceProbeTask, "indicator-font-probe", + RECOVERY_TASK_STACK_BYTES, nullptr, 2, &task, + 1) != pdPASS) { + finishServiceProbeFailure(); + } + } else if (launchPostCommitProbe) { + TaskHandle_t task = nullptr; + if (xTaskCreatePinnedToCore( + postCommitProbeTask, "indicator-font-committed-probe", + RECOVERY_TASK_STACK_BYTES, nullptr, 2, &task, 1) != pdPASS) { + finishPostCommitProbeFailure(); + } + } else if (launchRecovery) { + TaskHandle_t task = nullptr; + if (xTaskCreatePinnedToCore(recoveryTask, "indicator-font", + RECOVERY_TASK_STACK_BYTES, nullptr, 2, &task, + 1) != pdPASS) { + finishRecoveryFailure(); + } + } + return ready; +} + +void IndicatorFontClient::noteRuntimeFontInstalled() { + portENTER_CRITICAL(&recoveryMux); + runtimeFontInstalled = true; + portEXIT_CRITICAL(&recoveryMux); +} + +void IndicatorFontClient::noteRuntimeFontInvalid() { + portENTER_CRITICAL(&recoveryMux); + runtimeFontInstalled = false; + portEXIT_CRITICAL(&recoveryMux); + armRecovery(mesh::indicator_font::RecoveryNeed::Corrupt); +} + +void IndicatorFontClient::noteRecoveredFontInvalid() { + // Every buffer returned by serviceRecovery() has already matched the exact + // compiled size, CRC32, and SHA-256. If the runtime parser rejects those + // immutable bytes, neither another local stream nor another HTTPS download + // can change the result. Stop for this boot instead of wasting bandwidth or + // creating a download/activation loop; the built-in fallback remains live. + portENTER_CRITICAL(&recoveryMux); + runtimeFontInstalled = false; + recoveryNeed = mesh::indicator_font::RecoveryNeed::None; + recoveryActivateLive = false; + recoveryState = RecoveryState::Exhausted; + portEXIT_CRITICAL(&recoveryMux); +} +#endif diff --git a/variants/sensecap_indicator-espnow/IndicatorFontClient.h b/variants/sensecap_indicator-espnow/IndicatorFontClient.h index 34281a7b..a58abd6c 100644 --- a/variants/sensecap_indicator-espnow/IndicatorFontClient.h +++ b/variants/sensecap_indicator-espnow/IndicatorFontClient.h @@ -6,4 +6,20 @@ class IndicatorFontClient { public: static uint8_t* load(size_t& size); +#ifdef INDICATOR_WIFI_FONT_RECOVERY + // Poll from the Arduino loop. Network repair starts only after station Wi-Fi + // is connected; post-commit local UART probes can run offline. Bounded clock + // sync plus the TLS/download/SD transaction run in a worker task. + // A returned PSRAM buffer is the newly recovered font and transfers ownership + // to LGFXDisplay::installRuntimeFont(). + static uint8_t* serviceRecovery(size_t& size); + static void noteRuntimeFontInstalled(); + // Use for a font loaded during ordinary startup validation. This begins a + // fresh per-boot recovery budget. + static void noteRuntimeFontInvalid(); + // Use only for a freshly recovered font returned by serviceRecovery(). A + // rejected live activation consumes the current attempt instead of + // resetting the per-boot retry budget. + static void noteRecoveredFontInvalid(); +#endif }; diff --git a/variants/sensecap_indicator-espnow/IndicatorFontRecoveryPolicy.h b/variants/sensecap_indicator-espnow/IndicatorFontRecoveryPolicy.h new file mode 100644 index 00000000..d3e30cd6 --- /dev/null +++ b/variants/sensecap_indicator-espnow/IndicatorFontRecoveryPolicy.h @@ -0,0 +1,88 @@ +#pragma once + +#include +#include + +namespace mesh { +namespace indicator_font { + +// This URL is immutable: it names the Git blob itself rather than a branch, +// tag, or "latest" release. The raw media type is selected by the HTTPS +// request in IndicatorFontClient.cpp. A future font update must update the +// URL, published epoch, and all three integrity constants together. +static constexpr char kAssetUrl[] = + "https://api.github.com/repos/mikecarper/MeshCore/git/blobs/" + "45dfe8acac20974f53648ef71a31efefa1333fea"; +static constexpr size_t kAssetSize = 1302608; +static constexpr uint32_t kAssetCrc32 = 0x19f80d64UL; +static constexpr char kAssetSha256[] = + "61bce9662db314054e7bcfaa26147a28ad7b500b51baac4cae1caacce90b7421"; +// The immutable asset commit was published at this epoch. A clock older than +// the object it is fetching cannot validate the origin's rotating TLS leaf. +static constexpr uint32_t kAssetPublishedEpoch = 1787708237UL; + +// SNTP is asynchronous. A font repair must observe a fresh NTP response in the +// current attempt before opening TLS, even if the retained RTC already looks +// plausible. This is a maximum wait; a normal response returns immediately. +static constexpr uint32_t kNtpSyncWaitMillis = 15000UL; + +enum class RecoveryNeed : uint8_t { + None, + Missing, + Corrupt, + VersionMismatch, +}; + +static constexpr uint8_t kMaximumAttemptsPerBoot = 4; +static constexpr uint8_t kMaximumServiceProbeAttemptsPerBoot = 4; +static constexpr uint8_t kMaximumPostCommitProbeAttemptsPerBoot = 4; + +constexpr bool shouldFetch(RecoveryNeed need) { + return need == RecoveryNeed::Missing || need == RecoveryNeed::Corrupt + || need == RecoveryNeed::VersionMismatch; +} + +// A missing/corrupt asset is using the built-in fallback, so the recovered +// asset can be loaded live. A valid older/custom font is kept in RAM and the +// replacement becomes active on the next boot, avoiding two 1.3 MiB PSRAM +// allocations at once. +constexpr bool shouldActivateLive(RecoveryNeed need) { + return need == RecoveryNeed::Missing || need == RecoveryNeed::Corrupt; +} + +// Delay after the numbered failed attempt. The first attempt is immediate +// once station Wi-Fi is connected; a fourth failure stops retries until boot. +constexpr uint32_t retryDelayAfter(uint8_t completed_attempts) { + return completed_attempts <= 1 ? 30000UL + : completed_attempts == 2 ? 120000UL + : 600000UL; +} + +// The RP2040 and ESP32 can leave reset at different times. If the startup INFO +// request cannot classify the font service, retry only after station Wi-Fi is +// usable: immediately, then after 2, 5, and 15 seconds. Four unanswered probes +// stop until the next boot instead of creating an unbounded UART worker loop. +constexpr uint32_t serviceProbeRetryDelayAfter(uint8_t completed_attempts) { + return completed_attempts <= 1 ? 2000UL + : completed_attempts == 2 ? 5000UL + : 15000UL; +} + +// Once COMMIT has been acknowledged, another HTTPS transfer cannot improve a +// transient INFO/GET failure: the verified asset is already local. Re-probe +// that committed pair on a separate finite budget, then stop until reboot. +// The first post-commit probe is immediate; these are the delays after its +// first, second, and third failures. +constexpr uint32_t postCommitProbeRetryDelayAfter( + uint8_t completed_attempts) { + return completed_attempts <= 1 ? 2000UL + : completed_attempts == 2 ? 5000UL + : 15000UL; +} + +constexpr bool deadlineReached(uint32_t now, uint32_t deadline) { + return deadline == 0 || static_cast(now - deadline) >= 0; +} + +} // namespace indicator_font +} // namespace mesh diff --git a/variants/sensecap_indicator-espnow/README.md b/variants/sensecap_indicator-espnow/README.md index 32df5674..93033e9e 100644 --- a/variants/sensecap_indicator-espnow/README.md +++ b/variants/sensecap_indicator-espnow/README.md @@ -100,3 +100,68 @@ the panel framebuffer so the full-color cell is not replaced and redrawn once per second. See [`../../tools/sensecap_indicator_rp2040/README.md`](../../tools/sensecap_indicator_rp2040/README.md) for installation and diagnostics. + +Both Full Companion layouts, `SenseCapIndicator-LoRa_companion_radio_full` and +`SenseCapIndicator-ESPNow_companion_radio_full`, can repair a missing, corrupt, +or older font after station WiFi connects. The LoRa USB/WiFi base profile has +the same recovery support; USB-only images omit it. Startup never waits for the +network: the UI uses its built-in font while a bounded background task fetches +the 1,302,608-byte asset from GitHub's official, content-addressed Git Blob REST +API: + +`https://api.github.com/repos/mikecarper/MeshCore/git/blobs/45dfe8acac20974f53648ef71a31efefa1333fea` + +Before each bounded download attempt, the client must receive a fresh SNTP +response in that attempt (waiting at most 15 seconds) before it opens TLS or +downloads any bytes; a plausible retained clock is not enough. The initial +request and every validated Range reconnect recheck that the proof is still +younger than five minutes, WiFi is connected, and the signed wall clock is +valid immediately before their TLS handshakes. The request uses GitHub's +documented raw media type and then requires CA-verified TLS (never an insecure +client), an exact `Content-Length`, and SHA-256 +`61bce9662db314054e7bcfaa26147a28ad7b500b51baac4cae1caacce90b7421` +before it tells the RP2040 to publish the staged file. The RP2040 independently +checks the 1,302,608-byte size and CRC32 `0x19f80d64`, and uses separate stage, +temporary, backup, and live paths. An interrupted download or reset therefore +cannot replace the last valid font. A missing/corrupt font activates live after +recovery; a valid older font stays active until the next boot to avoid holding +two large runtime font buffers in PSRAM. + +The ESP32-S3 and RP2040 do not always finish reset together. If the startup +`MCFONT INFO` exchange is unavailable rather than explicitly missing, the WiFi +image waits for station WiFi and re-probes the font service in the same +background worker. A current font found by that probe is installed without a +network download. A confirmed missing, corrupt, or older font enters normal +recovery; four still-unavailable probes (immediate, then after 2, 5, and 15 +seconds) stop until the next boot. + +Recovery retries at most four times per boot (immediately, then after 30 +seconds, 2 minutes, and 10 minutes). A later reboot starts a fresh bounded set. +The recovery client uses a per-client PEM trust anchor for Sectigo Public +Server Authentication Root E46, so it does not replace the process-global CA +bundle. That root expires on 2046-03-21; future firmware must still review and +retest GitHub's certificate chain because an origin can change chains before a +root expires. GitHub permits 60 unauthenticated REST requests per hour per +source IP. Devices behind one public IP share that allowance, so the network +request runs only after a missing, corrupt, or older font is confirmed; a +current font performs no GitHub request. + +After `MCFONT COMMIT` is sent, a transient INFO/GET failure does not authorize +another download of the same immutable blob. A missing or malformed COMMIT +reply is also treated as ambiguous rather than failed, because the RP2040 may +already have completed its durable rename; only an explicit `ERROR ...` reply +can return to the network retry path. The ESP32 instead performs at most four +local-only probes (immediate, then after 2, 5, and 15 seconds). If fallback text +is active, that probe streams and verifies the exact size, CRC32, and SHA-256 +before live activation. If a valid older font remains active until reboot, an +exact INFO size/CRC32 check relies on COMMIT's full stored-file CRC pass and +avoids allocating a second 1.3 MiB runtime font. +If the display's runtime parser nevertheless rejects a buffer that already +passed the exact size, CRC32, and SHA-256 checks, recovery stops for that boot +and keeps the built-in fallback; downloading the same immutable bytes again +cannot repair a parser or memory-state failure. + +Automatic recovery requires the matching RP2040 font-service image with the +two-phase `MCFONT STAGE` / `MCFONT COMMIT` protocol. An older RP2040 service +safely rejects the commands, leaves the existing font untouched, and can still +be updated over its own USB connector as described below. diff --git a/variants/sensecap_indicator-espnow/SCIndicatorDisplay.h b/variants/sensecap_indicator-espnow/SCIndicatorDisplay.h index 871ef463..89fe65ab 100644 --- a/variants/sensecap_indicator-espnow/SCIndicatorDisplay.h +++ b/variants/sensecap_indicator-espnow/SCIndicatorDisplay.h @@ -241,10 +241,32 @@ public: setBacklight(true); size_t fontSize; uint8_t* fontData = IndicatorFontClient::load(fontSize); - if (fontData != nullptr) installRuntimeFont(fontData, fontSize); + if (fontData != nullptr) { + const bool installed = installRuntimeFont(fontData, fontSize); +#ifdef INDICATOR_WIFI_FONT_RECOVERY + if (installed) { + IndicatorFontClient::noteRuntimeFontInstalled(); + } else { + IndicatorFontClient::noteRuntimeFontInvalid(); + } +#endif + } return true; } +#ifdef INDICATOR_WIFI_FONT_RECOVERY + void serviceFontRecovery() { + size_t fontSize = 0; + uint8_t* fontData = IndicatorFontClient::serviceRecovery(fontSize); + if (fontData == nullptr) return; + if (installRuntimeFont(fontData, fontSize)) { + IndicatorFontClient::noteRuntimeFontInstalled(); + } else { + IndicatorFontClient::noteRecoveredFontInvalid(); + } + } +#endif + void turnOn() override { setBacklight(true); _isOn = true; diff --git a/variants/sensecap_indicator-espnow/platformio.ini b/variants/sensecap_indicator-espnow/platformio.ini index ae6a76ee..ace0bfad 100644 --- a/variants/sensecap_indicator-espnow/platformio.ini +++ b/variants/sensecap_indicator-espnow/platformio.ini @@ -134,8 +134,15 @@ extends = env:SenseCapIndicator-LoRa_comp_radio_usb ; an already-configured USB Indicator to this build preserves its identity, ; channels, radio settings, and WiFi credentials. board_build.partitions = variants/sensecap_indicator-espnow/dual_ota_2560k_preserve_spiffs.csv +; The Wi-Fi font recovery client gets fresh NTP time before opening TLS to the +; official GitHub Git Blob REST API. It verifies the pinned size and SHA-256; +; the RP2040 independently verifies CRC32. A per-client Sectigo E46 PEM avoids +; process-global bundle state and expires 2046-03-21. GitHub's unauthenticated +; limit is 60 requests/hour/source IP, so a current font never starts a fetch. +; build.sh also enables this path for both Indicator Full layouts. build_flags = ${env:SenseCapIndicator-LoRa_comp_radio_usb.build_flags} + -D INDICATOR_WIFI_FONT_RECOVERY=1 -D WIFI_DEBUG_LOGGING=1 -D WIFI_SSID='"myssid"' -D WIFI_PWD='"mypwd"' diff --git a/variants/sensecap_indicator-espnow/target.cpp b/variants/sensecap_indicator-espnow/target.cpp index 5da359d4..d1ed72a4 100644 --- a/variants/sensecap_indicator-espnow/target.cpp +++ b/variants/sensecap_indicator-espnow/target.cpp @@ -3,7 +3,7 @@ #include #include -ESP32Board board; +SenseCapIndicatorBoard board; #ifdef SENSECAP_INDICATOR_LORA static SPIClass radio_spi(FSPI); diff --git a/variants/sensecap_indicator-espnow/target.h b/variants/sensecap_indicator-espnow/target.h index d2d6a7b1..06f7235d 100644 --- a/variants/sensecap_indicator-espnow/target.h +++ b/variants/sensecap_indicator-espnow/target.h @@ -20,7 +20,14 @@ #include #endif -extern ESP32Board board; +class SenseCapIndicatorBoard : public ESP32Board { + public: + const char* getManufacturerName() const override { + return "Seeed SenseCAP Indicator"; + } +}; + +extern SenseCapIndicatorBoard board; #ifdef SENSECAP_INDICATOR_LORA extern WRAPPER_CLASS radio_driver; #else