diff --git a/.gitignore b/.gitignore index e2568fa3..ec693fb9 100644 --- a/.gitignore +++ b/.gitignore @@ -8,6 +8,7 @@ out/ site/ # Local firmware archives and build logs +.releases/ out.*/ out-*/ build-logs/ diff --git a/build.sh b/build.sh index ec143553..19bcdaea 100755 --- a/build.sh +++ b/build.sh @@ -76,7 +76,7 @@ BUILD_BACKGROUND_HANDOFF_STATE="" BUILD_SCRIPT_LOCK_FD="" INTERACTIVE_BUILD_SELECTION=0 -ENV_VARIANT_SUFFIX_PATTERN='companion_radio_(wifi_mqtt|serial|wifi|usb|ble|full)(_ps)?(_fem(on|off))?|companion_radio_ethernet|comp_radio_usb|companion_usb|companion_ble|repeater_bridge_rs232_serial1_lora_ota_no_external_sensors|repeater_bridge_rs232_serial2_lora_ota_no_external_sensors|repeater_bridge_rs232_lora_ota_no_external_sensors|repeater_rak15001_slot_c_lora_ota|repeater_lora_ota_no_external_sensors|repeater_bridge_rs232_serial1|repeater_bridge_rs232_serial2|repeater_bridge_rs232|repeater_bridge_espnow|repeater_observer_mqtt|repeater_ethernet|room_server_observer_mqtt|room_server_ethernet|terminal_chat|room_server|room_svr|kiss_modem|sensor|repeatr|repeater' +ENV_VARIANT_SUFFIX_PATTERN='companion_radio_(wifi_mqtt|serial|wifi|usb|ble|full)(_ps)?(_fem(on|off))?|companion_radio_ethernet|comp_radio_usb|companion_usb|companion_ble|repeater_bridge_rs232_serial1_lora_ota_no_external_sensors|repeater_bridge_rs232_serial2_lora_ota_no_external_sensors|repeater_bridge_rs232_lora_ota_no_external_sensors|repeater_rak13302_w25q16_lora_ota|repeater_rak15001_slot_c_lora_ota|repeater_w25q16_lora_ota|repeater_lora_ota_no_external_sensors|repeater_bridge_rs232_serial1|repeater_bridge_rs232_serial2|repeater_bridge_rs232|repeater_bridge_espnow|repeater_observer_mqtt|repeater_ethernet|room_server_observer_mqtt|room_server_ethernet|terminal_chat|room_server|room_svr|kiss_modem|sensor|repeatr|repeater' BOARD_MODIFIER_WITHOUT_DISPLAY="_without_display" BOARD_MODIFIER_LOGGING="_logging" BOARD_MODIFIER_TFT="_tft" @@ -1928,12 +1928,18 @@ print_release_firmware_targets() { ;; get-repeater-firmwares-to-build) get_pio_envs_ending_with_string "_repeater" - # This full-sensor target is a distinct hardware/bootloader contract, - # not a generated lean OTA alias, so tagged repeater releases must ship - # it explicitly alongside the canonical standard repeaters. + # These full-sensor targets are distinct hardware/bootloader contracts, + # not generated lean OTA aliases, so tagged repeater releases must ship + # them explicitly alongside the canonical standard repeaters. 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 + if is_supported_build_env "RAK_4631_repeater_w25q16_lora_ota"; then + printf '%s\n' "RAK_4631_repeater_w25q16_lora_ota" + fi + if is_supported_build_env "RAK_3401_repeater_rak13302_w25q16_lora_ota"; then + printf '%s\n' "RAK_3401_repeater_rak13302_w25q16_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 diff --git a/docs/_javascript/firmware_picker.js b/docs/_javascript/firmware_picker.js index 816a1d22..35b477ee 100644 --- a/docs/_javascript/firmware_picker.js +++ b/docs/_javascript/firmware_picker.js @@ -767,7 +767,11 @@ serial2: "Serial 2", sim: "SIM", rak15001: "RAK15001", + rak13302: "RAK13302", + w25q16: "W25Q16", qspi: "QSPI", + lora: "LoRa", + ota: "OTA", }; const tokens = value.split("-"); const labels = []; diff --git a/docs/firmware_picker.md b/docs/firmware_picker.md index 36b4f979..0984988a 100644 --- a/docs/firmware_picker.md +++ b/docs/firmware_picker.md @@ -275,6 +275,12 @@ 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 header-wired RAK19007 W25Q16 LoRa-OTA recipes are also exact hardware +variants. Choose the W25Q16 variant for RAK4631 or for RAK3401 + RAK13302 only +when that core/radio combination, the `EF4015` flash wiring, and its matching +OTAFIX bootloader are all present. The common base-board wiring does not make +the two firmware or bootloader identities interchangeable. + 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 the one USB TTY starts in ASCII and automatically changes to framed Companion diff --git a/docs/hardware_validation_checklist.md b/docs/hardware_validation_checklist.md index 81fd96fb..bf26be8f 100644 --- a/docs/hardware_validation_checklist.md +++ b/docs/hardware_validation_checklist.md @@ -5,13 +5,14 @@ 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) +## Current marathon ledger (through 2026-09-01) | Hardware | Stable identity | Current state | Next blocking check | | --- | --- | --- | --- | | Seeed XIAO nRF52840 | `B35E71C1C3726CE7` | A stalled Companion transport survived the stop token and exact USB reset, then recovered after a 36-second exact application DFU. Current Full Companion USB, bonded BLE, clock sync, terminal/Binary switching, serial-mOTA folder attach, and reversible dual-CDC logging all pass | Prove current-build cold persistence; separately reproduce the stall before using a power cut to test whether power alone clears it; then transfer an actual image through BLE-controlled LoRa OTA | | Seeed Tracker T1000-E | `34A9141999729D5D` | A temporary Full Companion and one of two same-visible-version OTAFIX candidates are running; the exact candidate bytes are not yet proven. The 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` | OTAFIX test version `0x02040403` passes bidirectional application UF2, exact SWD application/bootloader readback, unchanged UICR, and a 74-block direct V4 LoRa apply from exact `3caf9dcf` to current HEAD `9fd580c8`. Post-apply USB and LoRa identity/hash checks pass and the temporary 0 dBm bench setting is restored to 22 dBm | Add exact RF packet counters, then repeat the supported-bandwidth and controlled/passive/mixed routed-hop matrix | +| Heltec T096 | `651F8E496197F882` | OTAFIX HIL candidate `0x02040401`, serial DFU, signed LoRa bootloader OTA, warm-watchdog recovery, physical power-removal cold persistence, bonded Legacy BLE application DFU, serial mOTA ownership, button/display wake, and Full Companion dual-CDC reconnect checks pass on `keymindCascade` `fcd1f8cc`. The final T096-only no-footer Full Companion image is installed | Perform a privileged or physical host-driven USB bus reset without resetting the MCU; complete the deferred multi-click/long-press physical 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` | The current dark-layout application is SHA-256 `81bebdc07b6a8349c1c975cb5a0e30c5f6019d35bcac641e5f3d4df951f7406b`; identity-gated flash, USB ASCII/Binary switching, runtime logging, and separate configured-Wi-Fi, BLE, and TCP checks pass. Fresh-NTP HTTPS recovery, strict Range resume, STAGEV2 install, and RP USB readback passed on earlier exact artifacts; the final coordinator source still needs its blocked-UDP/123 hardware gate | Physical display-wrap and four-mode render/heap checks, exact-final blocked-NTP recovery gate, LoRa/TempRadio, and non-empty OTA seeding | @@ -943,6 +944,182 @@ network, and reboot record in that manifest. nor I2C RAK12500. Record this as a combined-provider build limitation, not an assertion that RAK12500 electrically conflicts with the UART. +## Heltec T096 + +- [x] Stable USB serial `651F8E496197F882` distinguishes the application + (`239A:8029`) from the OTAFIX serial-DFU interface. Every flash followed + that identity across the host-assigned `COM9 -> COM6 -> COM9` transition; + the simultaneously connected MeshPocket on `COM20` was never selected. +- [x] Baseline bootloader `0.11.0-OTAFIX2.4.3` was installed at this checkpoint. The restored Repeater + reports application body hash `3B6D1A729C087243`, target `98411AC6`, apply + ABI 3, codec mask `0x5`, and stage address `0xED000`. The application ZIP + is 708,216 bytes with SHA-256 + `abb00560cc5618ae155bf10252e9261127416a7a91b5f6565837384f0dec2390`. +- [x] A 48,229-byte in-place delta with SHA-256 + `988bd2385e9c995d0ae04effb6bb5cd614227c7c764590d82bd1943da2f18e4d` + transferred over LoRa in 47/47 blocks from the MeshPocket seeder and + applied successfully. The first application boot reported bootloader + result `B8`; the running body hash matched the candidate exactly. No chat + message was sent for this transfer. +- [x] The generic delayed AGC/watchdog arming fix passes warm-reboot hardware + checks on both the T096 and MeshPocket. The restored T096 retained AGC + interval 8 and, after 215 seconds, was in RX with 100 packets received, + 28 sent, zero core errors, and `err_flags=0`. The Pocket's quiet-channel + liveness recovery set only the expected radio-watchdog bit; temporarily + matching the USA channel then received 14 packets with zero receive + errors before its saved `869.618/62.5/SF8/CR5` profile was restored. At + 498 seconds the T096 had one CAD-busy timeout after 37 busy-channel + samples, then returned to RX with no queue, watchdog, or start-RX fault. +- [x] Exact Full Companion ZIP SHA-256 + `ee92856e5bfb7877b0d8bff6d6a5873d8037fc178ddb151202a9e6a6d9d59e73` + installed by serial DFU and passed the reversible dual-CDC test. Logging + off exposed only interface `00`; `set usb.logging on reboot` added + interface `02` as `COM21`. Opening `02` emitted both portable identity + lines, 980 captured bytes were entirely printable, and host input produced + no echo or command reply while Binary Companion on `00` returned the exact + model, key, and radio tuple. +- [x] With interface `02` deliberately unread, 11/11 text-terminal probes stayed + between 212 and 231 ms for 44.4 seconds. A longer 100-second hold buffered + 8,636 entirely printable bytes while 60/60 hard-bounded Binary Companion + reconnects passed with no timeout; the final full identity query completed + in 739 ms. Neither USB interface disconnected. Logging off plus reboot + removed `02`, read back off on `00`, and the qualified Repeater was then + restored with its original name, public key, and USA radio tuple. +- [x] The descriptor/reconnect follow-up built a 482,271-byte Full Companion DFU + ZIP with SHA-256 + `2a5a6bd0fd26031a2c2e047af6ead8f85f0d617da238d0cd6d816f7d5c41ec6f`. + On the physical T096, Windows read interface `02`'s bus-reported + description as `MeshCore Logging`. Two immediate open/close cycles each + delivered the complete 80-byte two-line identity marker with no NUL or + other invalid control byte, while Binary Companion on interface `00` + retained the exact key, model, and `910.525/62.5/SF7/CR5` tuple. +- [x] The final short-write/reconnect guard built a 482,271-byte Full Companion + DFU ZIP with SHA-256 + `bbbc3459b2c032af23c6da05e41984cb0250398b57ba56ea99166456700ace6f` + and EndF body hash `41365E4DB0FF4AE5`. On the physical T096, interface + `02` again reported `MeshCore Logging`; 3/3 explicit DTR sessions each + delivered one complete 80-byte marker with no NUL or invalid control byte. + With that logging endpoint held open and unread, interface `00` completed + 60/60 Binary Companion reconnect queries and retained public key + `8e229d0a9ace9bd6f768fc1237d9e3ebca340d1ff1c6087349983a07f305903a` + plus the exact `910.525/62.5/SF7/CR5` tuple. +- [x] The corrected Windows flasher identifies Binary-mode Full Companion and + links the same-radio `MI_02` inventory row to `MI_00`. An intentional + selection of `COM21/MI_02` was canonicalized to `COM9/MI_00` before any + 1200-baud action; the exact USB serial then followed + `COM9 -> COM6 -> COM9`, never touched the logging endpoint or MeshPocket, + and produced both `Device programmed.` and `Flash completed.`. The VM was + suspended only across each touch/transfer and resumed immediately. After + restoring the qualified Repeater and performing a final warm reboot, the + 58-second snapshot was in RX with 28 packets received, 9 sent, zero core + errors, an empty queue, and `err_flags=0`. +- [x] Final flasher review now revalidates the selected USB identity and exact + interface immediately before both the direct 1200-baud open and its Python + fallback, and balances every successfully written terminal START with a + STOP even when the banner is lost. Windows PowerShell 5.1 and PowerShell 7 + regression suites pass. A second physical restore deliberately selected + `COM21/MI_02`; it was canonicalized to `COM9/MI_00`, followed the same + `COM9 -> COM6 -> COM9` transition, and completed without touching the + MeshPocket. At 112 seconds the restored Repeater retained its name, OTA + body hash `3B6D1A729C087243`, USA radio tuple, and AGC interval 8; it was in + RX with 20 packets received, 6 sent, zero core errors, an empty queue, and + `err_flags=0`. The VM was running again immediately after the flash. +- [x] The T096-only message profile defines `UI_MESSAGE_CHANNEL_FOOTER=0`; + every other profile retains the default footer. The exact incoming + `#testing` message `T096 footer test: wrapped text should reach the screen + bottom.` traversed the live channel receive/display path, and the temporary + empty `#testing` channel slots on both endpoints were restored byte-for-byte. + Static profile coverage passes 2/2. No framebuffer or camera capture was + available, so the evidence is the exact live UI path plus the compiled + board-specific flag rather than a pixel photograph. +- [x] The then-final Full Companion artifact at this checkpoint, + `v1.17.1-t096-head-fcd1f8cc-usbtask8-fcd1f8cc`, was installed. Its DFU ZIP + is 487,231 bytes with SHA-256 + `CD267952F420294DF21CA96E3AA6D924C6EF7467C31C8D69B46F31E6719A1D67`; + the application is 486,428 bytes with a 486,372-byte EndF body and hash + `2E03B1C1166D4CC0`. The ELF exposes strong `T` definitions for both CDC + callbacks, mount/unmount, SOF, and the CDC0-only framework flush override. +- [x] Identity-gated BLE application DFU passed through the bonded address + `CF:A8:18:36:DE:8B`: encrypted application handoff from + `MeshCore-8E229D0A`, same-address `T096_DFU` Legacy service, and DIS model + `HT-n5262G` all matched. The target confirmed 486,428/486,428 bytes in + 565.986 seconds at 859 payload B/s, validated, activated, and initiated + the expected disconnect; end-to-end time was 603.967 seconds. USB serial, + version, board, public key, radio tuple, EndF hash, and bootloader apply + ABI 3/codecs `0x5` returned exactly, and a fresh active scan observed the + application name again at the same address. +- [x] The real host `motatool` opened `COM9`, survived the intentional CDC + session-settle purge, attached an empty folder with `COUNT -> 0` and + `OK folder attached`, then detached it and restored Binary mode. This + test sent no mesh messages. Three primary-port close/reopen cycles after + deliberately abandoned terminal output exposed zero stale bytes and each + returned a clean banner. Five logging-port reopen sessions each began + with the exact 80-byte identity marker and contained no NUL or invalid + control byte. +- [x] With both USB outputs deliberately unread for 65 seconds after a 10,000-byte + terminal input burst, the primary accumulated 16,886 printable bytes and + CDC1 accumulated 3,735 printable bytes beginning with its exact marker. + `ver` and Binary Companion remained responsive. A subsequent warm reboot + removed USB for 2.209 seconds, returned both interfaces in 2.409 seconds, + and CDC1 again emitted the exact marker. At 65 seconds the fresh boot had + `err_flags=0`, an empty outbound queue, 9 received floods, and zero CRC + errors. A Windows host-side composite + restart was attempted but denied by the non-elevated PnP API, so the + distinct host-driven BUS_RESET case remains a physical/privileged check; + it is not inferred from the warm-reboot result. +- [x] After the operator physically removed and restored T096 power (the off + interval was not measured), stable serial `651F8E496197F882` returned as + primary `MI_00`/`COM9` and logging `MI_02`/`COM21`. The cold terminal + reported exact version + `v1.17.1-t096-head-fcd1f8cc-usbtask8-fcd1f8cc`, Heltec T096 identity, + public key, USA radio tuple, BLE name, dedicated-CDC logging state, EndF + body hash `2E03B1C1166D4CC0`, and bootloader apply ABI 3/codecs `0x5` with + stage ceiling `0xD4000`; mOTA remained source-only with no folder attached, + and STOP restored Binary mode. CDC1's first read was the exact 80-byte + identity marker with no NUL. At 150 seconds, Binary stats reported 4,224 + mV, `err_flags=0`, an empty outbound queue, 47 packets received, zero + packets sent, and zero TX airtime. A second core checkpoint advanced + monotonically to 315 seconds with 4,220 mV, `err_flags=0`, and an empty + queue, providing no evidence of a watchdog reboot. A fresh active BLE + scan observed `MeshCore-8E229D0A` at the same `CF:A8:18:36:DE:8B` + address; BlueZ still + reported paired, bonded, and trusted. No mesh message was sent. This proves + cold persistence, but not the distinct host-only USB `BUS_RESET` path. +- [x] The display auto-off wake failure was traced to the shared ST7735 power + lifecycle: `turnOff()` released SCK/MOSI as inputs, but the nRF52 + `SPIClass::begin()` wake path was a no-op while the SPI object still + considered itself initialized. The shared driver now restores the bus-pin + directions and nRF52 high-drive configuration explicitly, waits through + reset release, and primes the retained frame after `DISPON`. Static wake + ordering and the T096 footer profile pass, and the exact Full Companion artifact + `v1.17.1-t096-head-fcd1f8cc-wake1-fcd1f8cc` compiled at 486,348 bytes. + Its 487,231-byte serial-DFU ZIP has SHA-256 + `A091F6DC458177BB26B8384B2AAAC0A9FD550322179F96062CCD3F0F5BB0E585`; + the 486,428-byte OTA image reports a 486,372-byte EndF body with hash + `E9AB2A36E9F7091F`. Identity-gated `meshfirmware` selected `COM21/MI_02`, + canonicalized it to `COM9/MI_00`, followed `COM9 -> COM6 -> COM9`, and + emitted both `Device programmed.` and `Flash completed.` without touching + the MeshPocket. The VM was suspended only across touch/transfer and was + running again as soon as exact serial `651F8E496197F882` returned. Live + readback then matched the public key, Heltec T096 board, USA + `910.525/62.5/SF7/CR5` tuple, ABI 3/codecs `0x5`/stage `0xD4000`, and exact + 80-byte CDC1 marker; core checkpoints advanced through 369 seconds with + `err_flags=0`, an empty queue, and zero TX. Follow-up physical testing + confirmed auto-off wake and single-click page navigation. Double-click, + triple-click, long-press, and simultaneous-button gestures remain deferred. +- [x] A signed bootloader mOTA package for OTAFIX HIL candidate `0x02040401` + transferred over LoRa in 40/40 blocks, passed the privileged-install gate, + installed with the watchdog enabled, and rebooted with unchanged node and + radio settings. The installed bootloader CRC changed to the package's + expected `381BAE6E`; the temporary HIL signer was removed afterwards. +- [x] A 486,796-byte Full Companion application completed bonded Legacy BLE DFU + at approximately 5.7-6.2 kbit/s payload throughput, then returned with the + same identity and USA radio tuple. The later CDC0 first-open fix preserved + immediate `APP_START` input across the eight-millisecond session gate: + first client after reboot passed with CDC1 logging active, followed by + 10/10 immediate COM9 close/reopen queries. CDC1 continued to emit its exact + identity marker and printable packet/debug logs. + ## Heltec MeshTower V2 with SD - [x] Stable serial `9352162A72082314` and physical downstream-hub path diff --git a/docs/ota_easy.md b/docs/ota_easy.md index 9e506165..61af62d1 100644 --- a/docs/ota_easy.md +++ b/docs/ota_easy.md @@ -109,7 +109,11 @@ 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 for the downloaded container. SolarXiao 30S and 33S use this matched external-QSPI -path and therefore do not emit redundant no-external-sensors siblings. Other +path and therefore do not emit redundant no-external-sensors siblings. A +RAK19007 can use the same separately wired W25Q16 with either RAK4631 or +RAK3401 + RAK13302: the flash consumes no WisBlock slot and GPS remains +supported in slot A. This is not the RAK15001 arrangement; RAK15001 and +RAK13302 share a chip-select and cannot be used together. Other normal repeaters can still serve as intermediate relays but cannot necessarily install an update themselves. ESP32 `-ota-` siblings also retain the lightweight browser WiFi uploader (`start @@ -306,6 +310,19 @@ RAK15001 OTAFIX bootloader, use full sensor/GPS set and can install either a full image or a delta from the external 2 MiB store. +With the separately wired Winbond breakout, use +`RAK_4631_repeater_w25q16_lora_ota` for RAK4631 or +`RAK_3401_repeater_rak13302_w25q16_lora_ota` for RAK3401 + RAK13302. Both +require an `EF4015` 2 MiB device. As a one-time prerequisite, install +`wiscore_rak4631_w25q16` for RAK4631 or +`wiscore_rak3401_rak13302_w25q16` for RAK3401 + RAK13302. Wire CLK/MISO/MOSI +to the RAK19007 IO connector pins 26/27/28, CS to J11 AIN1, and VCC/GND to J12 +VDD/GND. Fit an approximately 10 kOhm CS-to-VDD pull-up; never use VBAT. The +SPI pins require underside taps or an interposer, but no sensor slot is +occupied. See the +[nRF52 QSPI guide](ota_nrf52_qspi.md#one-w25q16-wiring-for-rak4631-or-rak3401--rak13302) +before installing either image. + ### 4. Build and check the in-place delta ```bash @@ -483,6 +500,7 @@ ota status A matched QSPI repeater or MeshTower V2 microSD target accepts full images with its corresponding bootloader. - **A QSPI nRF52 reports `QSPI store:ERR 0K` or `bl:NO-QSPI`:** do not download an install package. Install the exact QSPI-aware bootloader and check that the selected application matches the board's flash wiring. + A RAK W25Q16 target must identify exactly `EF4015` and 2048K; also check its physical CS pull-up. - **nRF52 reports no bootloader apply support:** install the exact-board in-place-delta OTAFIX bootloader before trying LoRa OTA. - **nRF52 reports a base mismatch:** the file passed to `--base` is not the exact application running on diff --git a/docs/ota_nrf52_qspi.md b/docs/ota_nrf52_qspi.md index 517b953f..844ac7b3 100644 --- a/docs/ota_nrf52_qspi.md +++ b/docs/ota_nrf52_qspi.md @@ -27,6 +27,11 @@ on these currently matched families: - RAK4631 with a RAK15001 in WisBlock sensor slot C, using the dedicated `RAK_4631_repeater_rak15001_slot_c_lora_ota` application and matching `wiscore_rak4631_board_rak15001_slot_c` OTAFIX bootloader +- RAK4631 with an externally wired Winbond W25Q16JV breakout on RAK19007, + using `RAK_4631_repeater_w25q16_lora_ota` +- RAK3401 with its RAK13302 1 W radio and the same externally wired W25Q16JV + breakout on RAK19007, using + `RAK_3401_repeater_rak13302_w25q16_lora_ota` Heltec T114 is intentionally not in this list. Its public V1, V2.0, and V2.1 schematics show U9 (MX25R1635F) as an optional QSPI footprint, so standard T114 @@ -70,11 +75,62 @@ chip-select. Update a RAK13800 Ethernet build locally over USB using the release's **Manual UF2** or **Serial DFU (.zip)** download; it cannot use this RAK15001 LoRa-OTA staging target. -RAK3401 is intentionally unsupported. Its external RAK13302 1 W radio already +RAK3401 with RAK13302 remains incompatible with **RAK15001**. The 1 W radio uses the same WisBlock SPI clock/data pins **and the same chip-select** as -RAK15001. Firmware cannot independently select or detect the two chips, so a +RAK15001. Firmware cannot independently select or detect those two chips, so a stock RAK3401 + RAK15001 assembly cannot provide reliable OTA staging without -a hardware chip-select rework. +a hardware chip-select rework. This restriction does not apply to the +separate-CS W25Q16 wiring below. + +### One W25Q16 wiring for RAK4631 or RAK3401 + RAK13302 + +An external Winbond W25Q16JV breakout can remain attached to a RAK19007 while +the core is changed between RAK4631 and RAK3401 + RAK13302. It shares only the +WisBlock SPI clock/data nets and has its own chip-select on AIN1: + +| W25Q16 breakout | RAK19007 connection | nRF52840 / Arduino pin | +| --- | --- | --- | +| `CLK` | IO connector pin 26, `SPI_CLK` | P0.03 / `3` | +| `DO` / MISO | IO connector pin 27, `SPI_MISO` | P0.29 / `29` | +| `DI` / MOSI | IO connector pin 28, `SPI_MOSI` | P0.30 / `30` | +| `CS` | J11 `AIN1` | P0.31 / `31` | +| `VCC` | J12 `VDD` | regulated 3.3 V | +| `GND` | J12 `GND` | ground | + +Fit a physical approximately 10 kOhm pull-up from `CS` to J12 `VDD`. That +keeps the flash deselected during reset, bootloader entry, and a core-module +swap. Power the breakout only from J12 `VDD`; **never use J11 `VBAT`**, which +can exceed the flash's supply rating. The six-pin breakout does not expose +WP#/IO2 or HOLD#/IO3; do not add nRF IO2/IO3 wiring. A breakout with an onboard +power LED works, but that LED draws continuously and is undesirable for +low-power or solar operation. + +This arrangement consumes no WisBlock sensor slot. However, `SPI_CLK`, +`SPI_MISO`, and `SPI_MOSI` are not available on the easy 2.54 mm J10/J11/J12 +headers. Those three signals require a short underside-pad tap or a suitable +IO-connector interposer at pins 26-28. J11 and J12 alone are not sufficient. + +GPS remains supported in sensor slot A. RAK12500 uses I2C, while RAK12501/L76K +uses UART plus its auxiliary control signals; neither uses the shared SPI +clock/data bus. On RAK4631 the built-in SX1262 has a separate private SPI bus. +On RAK3401 the RAK13302 and flash share clock/data, but have independent +chip-selects: RAK13302 NSS is P0.26 and flash CS is P0.31. The matched firmware +holds the radio NSS high while it temporarily hands the bus to the flash, then +restores the radio SPI interface. + +The breakout target accepts only a 2 MiB W25Q16 with JEDEC ID `EF 40 15` and +runs it at 8 MHz in standard single-data-line SPI mode. A substituted W25Q32, +W25Q64, or W25Q128 fails closed instead of being treated as the OTA store. +Use the complete application/bootloader identity row that matches the fitted +core: + +| Core and radio | PlatformIO environment | MOTA hardware identity | OTAFIX board / DFU device name | +| --- | --- | --- | --- | +| RAK4631 built-in SX1262 | `RAK_4631_repeater_w25q16_lora_ota` | `RAK4631_W25Q16` | `wiscore_rak4631_w25q16` / `4631_W25Q16_DFU` | +| RAK3401 + RAK13302 1 W | `RAK_3401_repeater_rak13302_w25q16_lora_ota` | `RAK3401_RAK13302_W25Q16` | `wiscore_rak3401_rak13302_w25q16` / `3401_W25Q16_DFU` | + +The physical flash wiring is identical, but the two application and +bootloader pairs are not interchangeable. ## One-time prerequisite @@ -86,6 +142,14 @@ mode. Also install the SoftDevice version expected by that target. The application refuses the install handoff when the bootloader does not advertise QSPI support. +For the external W25Q16 option, install the corresponding bootloader once +before installing its MeshCore application: `wiscore_rak4631_w25q16` +(`DEVICE_NAME=4631_W25Q16_DFU`) for RAK4631, or +`wiscore_rak3401_rak13302_w25q16` (`DEVICE_NAME=3401_W25Q16_DFU`) for RAK3401 +plus RAK13302. Use only a release that explicitly names that exact pairing. Do +not substitute the RAK15001 bootloader or swap the two W25Q16 bootloaders +merely because the base-board wiring is the same. + For the first migration from the ordinary `wiscore_rak4631_board` bootloader to `wiscore_rak4631_board_rak15001_slot_c`, use Nordic serial DFU or a compatible BLE DFU client with the exact slot-C OTAFIX **combined bootloader + SoftDevice @@ -121,10 +185,20 @@ bl:QSPI QSPI jedec=C84015 size=2048K sr1=00 stage=jedec ``` +A ready external W25Q16 target instead identifies the Winbond part: + +```text +QSPI store:2048K +bootloader: QSPI apply OK +bl:QSPI +QSPI jedec=EF4015 size=2048K sr1=00 stage=jedec +``` + Other supported boards can report a capacity different from 2048K; the -RAK15001 target must report exactly 2048K. `QSPI store:ERR 0K`, `NO QSPI`, or -`bl:NO-QSPI` means the flash wiring, flash power, or bootloader does not match. -Do not start an install in that state. +RAK15001 and W25Q16 targets must report exactly 2048K and their respective +exact JEDEC IDs. `QSPI store:ERR 0K`, `NO QSPI`, or `bl:NO-QSPI` means the flash +wiring, flash power, chip-select pull-up, application, or bootloader does not +match. Do not start an install in that state. `ota qspi` is a read-only diagnostic probe available on QSPI OTA builds. It reports the exact JEDEC ID, status-register byte, last store stage, and the diff --git a/docs/ota_user_guide.md b/docs/ota_user_guide.md index 78c65939..0ff07136 100644 --- a/docs/ota_user_guide.md +++ b/docs/ota_user_guide.md @@ -83,9 +83,18 @@ complete package off-chip, so their normal full-sensor repeater build can install a full image or an in-place delta. The current matched families are XIAO nRF52840 and its XIAO-module derivatives, original LilyGo T-Echo, ThinkNode M1/M6, Wio Tracker L1, SenseCAP Solar, and the dedicated RAK4631 + -RAK15001 slot-C target. These require the corresponding QSPI-aware OTAFIX bootloader; see +RAK15001 slot-C target. The list also includes a separately wired W25Q16JV on +RAK19007 with either RAK4631 or RAK3401 + RAK13302. These require the +corresponding exact QSPI-aware OTAFIX bootloader; see [the nRF52 QSPI guide](ota_nrf52_qspi.md). +The W25Q16 option uses one permanent RAK19007 wiring for either core: CLK/MISO/ +MOSI tap IO-connector pins 26/27/28, flash CS uses J11 AIN1, and power uses J12 +VDD (3.3 V) and GND. Add an approximately 10 kOhm CS-to-VDD pull-up and never +power the flash from VBAT. The three SPI signals require underside pads or an +IO-connector interposer; the easy J10/J11/J12 headers do not expose them. The +flash consumes no WisBlock slot, so a supported GPS can remain in slot A. + 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 trims selected optional @@ -95,6 +104,16 @@ 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. +For the external Winbond breakout, use +`RAK_4631_repeater_w25q16_lora_ota` on RAK4631 or +`RAK_3401_repeater_rak13302_w25q16_lora_ota` on RAK3401 + RAK13302. They retain +the full sensor/GPS recipe and require exactly a 2 MiB `EF4015` W25Q16 plus the +bootloader for that same core/radio/flash combination. Install +`wiscore_rak4631_w25q16` for RAK4631 or +`wiscore_rak3401_rak13302_w25q16` for RAK3401 + RAK13302 as a one-time +prerequisite. The physical wiring is shared, but the two firmware identities +are deliberately different and cannot be substituted for one another. + --- ## The important part first: it's safe diff --git a/examples/companion_radio/MyMesh.cpp b/examples/companion_radio/MyMesh.cpp index febbc43f..d32b1a00 100644 --- a/examples/companion_radio/MyMesh.cpp +++ b/examples/companion_radio/MyMesh.cpp @@ -5,6 +5,8 @@ #include #include #include +#include +#include #include "helpers/radiolib/RXPowerSaving.h" #include "helpers/radiolib/RxBoostedGainDefaults.h" #include "helpers/radiolib/CadTiming.h" @@ -263,6 +265,7 @@ static mesh::Packet* emergency_client_repeat_packet; #define ERR_CODE_ILLEGAL_ARG 6 #define MAX_SIGN_DATA_LEN (8 * 1024) // 8K +static constexpr unsigned long SIGN_SESSION_TIMEOUT_MILLIS = 120000; // Auto-add config bitmask // Bit 0: If set, overwrite oldest non-favourite contact when contacts file is full @@ -273,16 +276,29 @@ static mesh::Packet* emergency_client_repeat_packet; #define AUTO_ADD_ROOM_SERVER (1 << 3) // 0x08 - auto-add Room Server (ADV_TYPE_ROOM) #define AUTO_ADD_SENSOR (1 << 4) // 0x10 - auto-add Sensor (ADV_TYPE_SENSOR) -void MyMesh::writeOKFrame() { +void MyMesh::writeOKFrame(BaseSerialInterface* route) { uint8_t buf[1]; buf[0] = RESP_CODE_OK; - _serial->writeFrame(buf, 1); + if (route != NULL) { + _serial->writeFrameToRoute(route, buf, 1); + } else { + _serial->writeFrame(buf, 1); + } } -void MyMesh::writeErrFrame(uint8_t err_code) { +void MyMesh::writeErrFrame(uint8_t err_code, BaseSerialInterface* route) { uint8_t buf[2]; buf[0] = RESP_CODE_ERR; buf[1] = err_code; - _serial->writeFrame(buf, 2); + if (route != NULL) { + _serial->writeFrameToRoute(route, buf, 2); + } else { + _serial->writeFrame(buf, 2); + } +} + +size_t MyMesh::writePendingSerialFrame(const uint8_t frame[], size_t len) { + if (_serial == NULL || pending_serial_reply_route == NULL) return 0; + return _serial->writeFrameToRoute(pending_serial_reply_route, frame, len); } void MyMesh::writeDisabledFrame() { @@ -704,6 +720,15 @@ void MyMesh::expireExpectedAcks() { continue; } + if (entry.reply_route != NULL + && !_serial->isReplyRouteAvailable(entry.reply_route)) { + // The requesting transport owns only the eventual ACK notification, + // not the already-accepted radio transmission. Detach a vanished host + // without cancelling Mesh's retry sequence or forgetting the semantic + // ACK match; a late ACK can still stop those retries safely. + entry.reply_route = NULL; + } + if (entry.expires_at == now || millisHasNowPassed(entry.expires_at)) { if (!hasActiveRetries(entry.retry_key)) { #ifdef ENABLE_USB_INTERFACE @@ -758,7 +783,10 @@ ContactInfo* MyMesh::processAck(const uint8_t *data) { memcpy(&out_frame[1], data, 4); uint32_t trip_time = _ms->getMillis() - expected_ack_table[i].msg_sent; memcpy(&out_frame[5], &trip_time, 4); - _serial->writeFrame(out_frame, 9); + if (expected_ack_table[i].reply_route != NULL) { + _serial->writeFrameToRoute(expected_ack_table[i].reply_route, + out_frame, 9); + } #ifdef ENABLE_USB_INTERFACE if (expected_ack_table[i].terminal_origin && hasTerminalOutput()) { @@ -1162,8 +1190,6 @@ void MyMesh::onContactResponse(const ContactInfo &contact, const uint8_t *data, && memcmp(_terminal_login_key, contact.id.pub_key, sizeof(_terminal_login_key)) == 0; #endif - pending_login = 0; - int i = 0; #ifdef ENABLE_USB_INTERFACE bool login_success = false; @@ -1200,7 +1226,10 @@ void MyMesh::onContactResponse(const ContactInfo &contact, const uint8_t *data, memcpy(&out_frame[i], contact.id.pub_key, 6); i += 6; // pub_key_prefix } - _serial->writeFrame(out_frame, i); + // Terminal login has its own text destination and deliberately captures + // no Binary route. Binary logins retain their exact requester even if a + // different transport has issued a command in the meantime. + writePendingSerialFrame(out_frame, i); #ifdef ENABLE_USB_INTERFACE if (terminal_login_response) { if (hasTerminalOutput()) { @@ -1220,9 +1249,8 @@ void MyMesh::onContactResponse(const ContactInfo &contact, const uint8_t *data, clearTerminalLogin(); } #endif + clearPendingReqs(); } else if (mesh::companionStatusTagMatches(pending_status, tag)) { - pending_status = 0; - // Do not expose a truncated or unrelated response as repeater statistics. // The app parses at least 48 bytes and otherwise throws a RangeError. if (!mesh::companionStatusResponseIsLongEnough(len)) { @@ -1230,6 +1258,7 @@ void MyMesh::onContactResponse(const ContactInfo &contact, const uint8_t *data, "onContactResponse(), short status response: len=%u, expected>=%u", (unsigned)len, (unsigned)mesh::COMPANION_MIN_STATUS_RESPONSE_SIZE); + clearPendingReqs(); return; } @@ -1240,10 +1269,9 @@ void MyMesh::onContactResponse(const ContactInfo &contact, const uint8_t *data, i += 6; // pub_key_prefix memcpy(&out_frame[i], &data[4], len - 4); i += (len - 4); - _serial->writeFrame(out_frame, i); + writePendingSerialFrame(out_frame, i); + clearPendingReqs(); } else if (len > 4 && tag == pending_telemetry) { // check for matching response tag - pending_telemetry = 0; - int i = 0; out_frame[i++] = PUSH_CODE_TELEMETRY_RESPONSE; out_frame[i++] = 0; // reserved @@ -1251,10 +1279,9 @@ void MyMesh::onContactResponse(const ContactInfo &contact, const uint8_t *data, i += 6; // pub_key_prefix memcpy(&out_frame[i], &data[4], len - 4); i += (len - 4); - _serial->writeFrame(out_frame, i); + writePendingSerialFrame(out_frame, i); + clearPendingReqs(); } else if (len > 4 && tag == pending_req) { // check for matching response tag - pending_req = 0; - int i = 0; out_frame[i++] = PUSH_CODE_BINARY_RESPONSE; out_frame[i++] = 0; // reserved @@ -1262,7 +1289,8 @@ void MyMesh::onContactResponse(const ContactInfo &contact, const uint8_t *data, i += 4; memcpy(&out_frame[i], &data[4], len - 4); i += (len - 4); - _serial->writeFrame(out_frame, i); + writePendingSerialFrame(out_frame, i); + clearPendingReqs(); } } @@ -1272,8 +1300,6 @@ bool MyMesh::onContactPathRecv(ContactInfo& contact, uint8_t* in_path, uint8_t i memcpy(&tag, extra, 4); if (tag == pending_discovery) { // check for matching response tag) - pending_discovery = 0; - if (!mesh::Packet::isValidPathLen(in_path_len) || !mesh::Packet::isValidPathLen(out_path_len)) { MESH_DEBUG_PRINTLN("onContactPathRecv, invalid path sizes: %d, %d", in_path_len, out_path_len); } else { @@ -1288,8 +1314,9 @@ bool MyMesh::onContactPathRecv(ContactInfo& contact, uint8_t* in_path, uint8_t i i += mesh::Packet::writePath(&out_frame[i], in_path, in_path_len); // NOTE: telemetry data in 'extra' is discarded at present - _serial->writeFrame(out_frame, i); + writePendingSerialFrame(out_frame, i); } + clearPendingReqs(); return false; // DON'T send reciprocal path! } } @@ -1339,9 +1366,12 @@ void MyMesh::onRawDataRecv(mesh::Packet *packet) { void MyMesh::onTraceRecv(mesh::Packet *packet, uint32_t tag, uint32_t auth_code, uint8_t flags, const uint8_t *path_snrs, const uint8_t *path_hashes, uint8_t path_len) { + const bool binary_trace_match = binary_trace_pending + && tag == binary_trace_tag && auth_code == binary_trace_auth; uint8_t path_sz = flags & 0x03; // NEW v1.11+ if (12 + path_len + (path_len >> path_sz) + 1 > sizeof(out_frame)) { MESH_DEBUG_PRINTLN("onTraceRecv(), path_len is too long: %d", (uint32_t)path_len); + if (binary_trace_match) clearBinaryTraceReply(); return; } int i = 0; @@ -1390,10 +1420,9 @@ void MyMesh::onTraceRecv(mesh::Packet *packet, uint32_t tag, uint32_t auth_code, } #endif - if (_serial->isConnected()) { - _serial->writeFrame(out_frame, i); - } else { - MESH_DEBUG_PRINTLN("onTraceRecv(), data received while app offline"); + if (binary_trace_match) { + _serial->writeFrameToRoute(binary_trace_reply_route, out_frame, i); + clearBinaryTraceReply(); } } @@ -1444,6 +1473,12 @@ MyMesh::MyMesh(mesh::Radio &radio, mesh::RNG &rng, mesh::RTCClock &rtc, SimpleMe command_radio_cr = 0; command_radio_repeat = 0; command_radio_apply_deadline = 0; + command_radio_reply_route = NULL; + binary_trace_pending = false; + binary_trace_tag = 0; + binary_trace_auth = 0; + binary_trace_deadline = 0; + binary_trace_reply_route = NULL; _scheduled_reboot_at = 0; #if COMPANION_FEATURE_TEMP_RADIO _temp_radio_set_at = 0; @@ -1469,6 +1504,8 @@ MyMesh::MyMesh(mesh::Radio &radio, mesh::RNG &rng, mesh::RTCClock &rtc, SimpleMe next_ack_expiry = 0; has_next_ack_expiry = false; sign_data = NULL; + sign_data_reply_route = NULL; + sign_data_deadline = 0; dirty_contacts_expiry = 0; memset(advert_paths, 0, sizeof(advert_paths)); memset(send_scope.key, 0, sizeof(send_scope.key)); @@ -1820,7 +1857,9 @@ bool MyMesh::applySavedRadioParams() { == mesh::RadioParamApplyResult::APPLIED; } -void MyMesh::finishRadioParamApply(float freq, float bw, uint8_t sf, uint8_t cr, uint8_t repeat) { +void MyMesh::finishRadioParamApply(float freq, float bw, uint8_t sf, + uint8_t cr, uint8_t repeat, + BaseSerialInterface* route) { _prefs.sf = sf; _prefs.cr = cr; _prefs.freq = freq; @@ -1838,7 +1877,7 @@ void MyMesh::finishRadioParamApply(float freq, float bw, uint8_t sf, uint8_t cr, MESH_DEBUG_PRINTLN("OK: CMD_SET_RADIO_PARAMS: f=%d, bw=%d, sf=%d, cr=%d", (uint32_t)(freq * 1000.0f), (uint32_t)(bw * 1000.0f), (uint32_t)sf, (uint32_t)cr); - writeOKFrame(); + writeOKFrame(route); } void MyMesh::cancelPendingRadioParamApply() { @@ -1846,6 +1885,7 @@ void MyMesh::cancelPendingRadioParamApply() { command_radio_apply_pending = false; command_radio_apply_deadline = 0; + command_radio_reply_route = NULL; // The requested tuple was not committed. Reassert the persisted tuple in // case a failed hardware apply only restored part of the old configuration. saved_radio_apply_pending = true; @@ -1855,13 +1895,13 @@ void MyMesh::cancelPendingRadioParamApply() { void MyMesh::servicePendingRadioParamApply() { if (!command_radio_apply_pending) return; - if (!_serial || !_serial->isConnected()) { + if (!_serial || !_serial->isReplyRouteAvailable(command_radio_reply_route)) { cancelPendingRadioParamApply(); return; } // Leave enough queue headroom for the eventual command response. - if (_serial->isWriteBusy()) return; + if (_serial->isReplyRouteWriteBusy(command_radio_reply_route)) return; mesh::RadioParamApplyResult result = tryApplyRadioParams( command_radio_freq, command_radio_bw, command_radio_sf, command_radio_cr); @@ -1871,16 +1911,18 @@ void MyMesh::servicePendingRadioParamApply() { uint8_t sf = command_radio_sf; uint8_t cr = command_radio_cr; uint8_t repeat = command_radio_repeat; + BaseSerialInterface* route = command_radio_reply_route; command_radio_apply_pending = false; command_radio_apply_deadline = 0; - finishRadioParamApply(freq, bw, sf, cr, repeat); + command_radio_reply_route = NULL; + finishRadioParamApply(freq, bw, sf, cr, repeat, route); } else if (result == mesh::RadioParamApplyResult::FAILED) { + writeErrFrame(ERR_CODE_ILLEGAL_ARG, command_radio_reply_route); cancelPendingRadioParamApply(); - writeErrFrame(ERR_CODE_ILLEGAL_ARG); } else if (command_radio_apply_deadline == _ms->getMillis() || millisHasNowPassed(command_radio_apply_deadline)) { + writeErrFrame(ERR_CODE_BAD_STATE, command_radio_reply_route); cancelPendingRadioParamApply(); - writeErrFrame(ERR_CODE_BAD_STATE); } } @@ -2076,6 +2118,18 @@ bool MyMesh::handleLocalControlCommand(const char* command, char* reply, return true; } + if (strcmp(command, "get pwrmgt.bootreason") == 0 + || strcmp(command, "powerlog") == 0) { + if (!board.isPowerManagementInitialized()) { + snprintf(reply, reply_size, "Error: power management unsupported"); + } else { + snprintf(reply, reply_size, "> Reset: %s; Shutdown: %s", + board.getResetReasonString(board.getResetReason()), + board.getShutdownReasonString(board.getShutdownReason())); + } + return true; + } + #if COMPANION_FEATURE_MEMORY_DIAGNOSTICS if (strcmp(command, "memory") == 0) { const mesh::CompanionMemoryDiagnostics diagnostics = { @@ -3236,16 +3290,113 @@ void MyMesh::startInterface(BaseSerialInterface &serial) { serial.enable(); } +void MyMesh::clearPendingReqs() { + pending_login = pending_status = pending_telemetry = pending_discovery = + pending_req = 0; + pending_serial_reply_route = NULL; + pending_serial_reply_deadline = 0; +} + +bool MyMesh::hasPendingReqs() const { + return pending_login != 0 || pending_status != 0 || pending_telemetry != 0 + || pending_discovery != 0 || pending_req != 0; +} + +void MyMesh::servicePendingSerialReply() { + if (pending_serial_reply_route == NULL) return; + if (!_serial->isReplyRouteAvailable(pending_serial_reply_route) + || pending_serial_reply_deadline == _ms->getMillis() + || millisHasNowPassed(pending_serial_reply_deadline)) { + clearPendingReqs(); + } +} + +void MyMesh::clearBinaryTraceReply() { + binary_trace_pending = false; + binary_trace_tag = 0; + binary_trace_auth = 0; + binary_trace_deadline = 0; + binary_trace_reply_route = NULL; +} + +void MyMesh::serviceBinaryTraceReply() { + if (!binary_trace_pending) return; + if (!_serial->isReplyRouteAvailable(binary_trace_reply_route) + || binary_trace_deadline == _ms->getMillis() + || millisHasNowPassed(binary_trace_deadline)) { + clearBinaryTraceReply(); + } +} + +void MyMesh::cancelSigningSession() { + if (sign_data != NULL) free(sign_data); + sign_data = NULL; + sign_data_len = 0; + sign_data_reply_route = NULL; + sign_data_deadline = 0; +} + +void MyMesh::serviceSigningSession() { + if (sign_data == NULL) return; + if (!_serial->isReplyRouteAvailable(sign_data_reply_route) + || sign_data_deadline == _ms->getMillis() + || millisHasNowPassed(sign_data_deadline)) { + cancelSigningSession(); + } +} + void MyMesh::cancelSerialResponseStream() { stopContactsIterator(); } +void MyMesh::cancelSerialOperationsForRoute(BaseSerialInterface* route) { + if (route == NULL) return; + if (pending_serial_reply_route == route) clearPendingReqs(); + if (command_radio_reply_route == route) cancelPendingRadioParamApply(); + if (binary_trace_reply_route == route) clearBinaryTraceReply(); + if (sign_data_reply_route == route) cancelSigningSession(); + for (int i = 0; i < EXPECTED_ACK_TABLE_SIZE; ++i) { + if (expected_ack_table[i].reply_route == route) { + // A host-session change must prevent the late ACK from reaching a new + // session on the same transport, but it must not revoke radio delivery + // for a message for which RESP_CODE_SENT was already returned. + expected_ack_table[i].reply_route = NULL; + } + } + expireExpectedAcks(); +} + +void MyMesh::resetUsbHostSessionInput() { + // CLI rescue deliberately remains active across reconnects, but no bytes + // typed by the old host may complete a command for the new one. This is + // especially important for the rescue shell's erase/rm commands. + if (_cli_rescue) memset(cli_command, 0, sizeof(cli_command)); +} + void MyMesh::handleCmdFrame(size_t len) { if (len == 0) { writeErrFrame(ERR_CODE_ILLEGAL_ARG); return; } + const bool starts_long_lived_request = + (cmd_frame[0] == CMD_SEND_LOGIN && len >= 1 + PUB_KEY_SIZE) + || (cmd_frame[0] == CMD_SEND_ANON_REQ && len > 1 + PUB_KEY_SIZE) + || (cmd_frame[0] == CMD_SEND_STATUS_REQ && len >= 1 + PUB_KEY_SIZE) + || (cmd_frame[0] == CMD_SEND_PATH_DISCOVERY_REQ + && len >= 2 + PUB_KEY_SIZE) + || (cmd_frame[0] == CMD_SEND_TELEMETRY_REQ + && len >= 4 + PUB_KEY_SIZE) + || (cmd_frame[0] == CMD_SEND_BINARY_REQ + && len >= 2 + PUB_KEY_SIZE); + if (starts_long_lived_request && hasPendingReqs()) { + // There is one correlation slot for these request families. Reject before + // transmitting rather than orphaning an earlier USB/BLE client's SENT + // operation and reassigning its late response to the new requester. + writeErrFrame(ERR_CODE_BAD_STATE); + return; + } + if (cmd_frame[0] == CMD_DEVICE_QUERY && len >= 2) { // sent when app establishes connection app_target_ver = cmd_frame[1]; // which version of protocol does app understand @@ -3273,8 +3424,9 @@ void MyMesh::handleCmdFrame(size_t len) { cmd_frame[len] = 0; // make app_name null terminated MESH_DEBUG_PRINTLN("App %s connected", app_name); + BaseSerialInterface* app_route = _serial->captureReplyRoute(); stopContactsIterator(); // stop any left-over ContactsIterator - cancelPendingRadioParamApply(); + cancelSerialOperationsForRoute(app_route); int i = 0; out_frame[i++] = RESP_CODE_SELF_INFO; out_frame[i++] = ADV_TYPE_CHAT; // what this node Advert identifies as (maybe node's pronouns too?? :-) @@ -3400,6 +3552,7 @@ void MyMesh::handleCmdFrame(size_t len) { entry.ack = expected_ack; entry.message_timestamp = msg_timestamp; entry.contact = recipient; + entry.reply_route = _serial->captureReplyRoute(); memcpy(entry.text_fingerprint, text_fingerprint, sizeof(entry.text_fingerprint)); memcpy(entry.retry_key, packet_retry_key, sizeof(entry.retry_key)); if (replacement_entry == NULL) { @@ -3732,6 +3885,14 @@ void MyMesh::handleCmdFrame(size_t len) { mesh::RadioParamApplyResult result = tryApplyRadioParams(new_freq, new_bw, sf, cr); if (result == mesh::RadioParamApplyResult::BUSY) { + // Capture this requester instead of relying on the mutable last-RX + // route. This remains correct if a contact stream later unlocks or a + // different transport supplies another command before completion. + command_radio_reply_route = _serial->captureReplyRoute(); + if (command_radio_reply_route == NULL) { + writeErrFrame(ERR_CODE_BAD_STATE); + return; + } command_radio_apply_pending = true; command_radio_freq = new_freq; command_radio_bw = new_bw; @@ -3908,6 +4069,9 @@ void MyMesh::handleCmdFrame(size_t len) { writeErrFrame(ERR_CODE_TABLE_FULL); } else { clearPendingReqs(); + pending_serial_reply_route = _serial->captureReplyRoute(); + pending_serial_reply_deadline = + futureMillis(est_timeout + est_timeout / 5); memcpy(&pending_login, recipient->id.pub_key, 4); // match this to onContactResponse() out_frame[0] = RESP_CODE_SENT; out_frame[1] = (result == MSG_SEND_SENT_FLOOD) ? 1 : 0; @@ -3942,6 +4106,9 @@ void MyMesh::handleCmdFrame(size_t len) { writeErrFrame(ERR_CODE_TABLE_FULL); } else { clearPendingReqs(); + pending_serial_reply_route = _serial->captureReplyRoute(); + pending_serial_reply_deadline = + futureMillis(est_timeout + est_timeout / 5); pending_req = tag; // match this to onContactResponse() out_frame[0] = RESP_CODE_SENT; out_frame[1] = (result == MSG_SEND_SENT_FLOOD) ? 1 : 0; @@ -3962,6 +4129,9 @@ void MyMesh::handleCmdFrame(size_t len) { writeErrFrame(ERR_CODE_TABLE_FULL); } else { clearPendingReqs(); + pending_serial_reply_route = _serial->captureReplyRoute(); + pending_serial_reply_deadline = + futureMillis(est_timeout + est_timeout / 5); pending_status = tag; // match the reflected tag in onContactResponse() out_frame[0] = RESP_CODE_SENT; out_frame[1] = (result == MSG_SEND_SENT_FLOOD) ? 1 : 0; @@ -3991,6 +4161,9 @@ void MyMesh::handleCmdFrame(size_t len) { writeErrFrame(ERR_CODE_TABLE_FULL); } else { clearPendingReqs(); + pending_serial_reply_route = _serial->captureReplyRoute(); + pending_serial_reply_deadline = + futureMillis(est_timeout + est_timeout / 5); pending_discovery = tag; // match this in onContactResponse() out_frame[0] = RESP_CODE_SENT; out_frame[1] = (result == MSG_SEND_SENT_FLOOD) ? 1 : 0; @@ -4011,6 +4184,9 @@ void MyMesh::handleCmdFrame(size_t len) { writeErrFrame(ERR_CODE_TABLE_FULL); } else { clearPendingReqs(); + pending_serial_reply_route = _serial->captureReplyRoute(); + pending_serial_reply_deadline = + futureMillis(est_timeout + est_timeout / 5); pending_telemetry = tag; // match this in onContactResponse() out_frame[0] = RESP_CODE_SENT; out_frame[1] = (result == MSG_SEND_SENT_FLOOD) ? 1 : 0; @@ -4052,6 +4228,9 @@ void MyMesh::handleCmdFrame(size_t len) { writeErrFrame(ERR_CODE_TABLE_FULL); } else { clearPendingReqs(); + pending_serial_reply_route = _serial->captureReplyRoute(); + pending_serial_reply_deadline = + futureMillis(est_timeout + est_timeout / 5); pending_req = tag; // match this in onContactResponse() out_frame[0] = RESP_CODE_SENT; out_frame[1] = (result == MSG_SEND_SENT_FLOOD) ? 1 : 0; @@ -4103,31 +4282,48 @@ void MyMesh::handleCmdFrame(size_t len) { writeErrFrame(ERR_CODE_NOT_FOUND); // bad channel_idx } } else if (cmd_frame[0] == CMD_SIGN_START) { + BaseSerialInterface* signing_route = _serial->captureReplyRoute(); + if (signing_route == NULL) { + writeErrFrame(ERR_CODE_BAD_STATE); + return; + } + if (sign_data != NULL && sign_data_reply_route != signing_route) { + writeErrFrame(ERR_CODE_BAD_STATE); + return; + } + cancelSigningSession(); + sign_data = (uint8_t *)malloc(MAX_SIGN_DATA_LEN); + if (sign_data == NULL) { + writeErrFrame(ERR_CODE_TABLE_FULL); + return; + } + sign_data_len = 0; + sign_data_reply_route = signing_route; + sign_data_deadline = futureMillis(SIGN_SESSION_TIMEOUT_MILLIS); + out_frame[0] = RESP_CODE_SIGN_START; out_frame[1] = 0; // reserved uint32_t len = MAX_SIGN_DATA_LEN; memcpy(&out_frame[2], &len, 4); _serial->writeFrame(out_frame, 6); - - if (sign_data) { - free(sign_data); - } - sign_data = (uint8_t *)malloc(MAX_SIGN_DATA_LEN); - sign_data_len = 0; } else if (cmd_frame[0] == CMD_SIGN_DATA && len > 1) { - if (sign_data == NULL || sign_data_len + (len - 1) > MAX_SIGN_DATA_LEN) { - writeErrFrame(sign_data == NULL ? ERR_CODE_BAD_STATE : ERR_CODE_TABLE_FULL); // error: too long + BaseSerialInterface* signing_route = _serial->captureReplyRoute(); + if (sign_data == NULL || sign_data_reply_route != signing_route) { + writeErrFrame(ERR_CODE_BAD_STATE); + } else if (sign_data_len + (len - 1) > MAX_SIGN_DATA_LEN) { + writeErrFrame(ERR_CODE_TABLE_FULL); // error: too long } else { memcpy(&sign_data[sign_data_len], &cmd_frame[1], len - 1); sign_data_len += (len - 1); + sign_data_deadline = futureMillis(SIGN_SESSION_TIMEOUT_MILLIS); writeOKFrame(); } } else if (cmd_frame[0] == CMD_SIGN_FINISH) { - if (sign_data) { + if (sign_data != NULL + && sign_data_reply_route == _serial->captureReplyRoute()) { self_id.sign(&out_frame[1], sign_data, sign_data_len); - free(sign_data); // don't need sign_data now - sign_data = NULL; + cancelSigningSession(); out_frame[0] = RESP_CODE_SIGNATURE; _serial->writeFrame(out_frame, 1 + SIGNATURE_SIZE); @@ -4135,6 +4331,11 @@ void MyMesh::handleCmdFrame(size_t len) { writeErrFrame(ERR_CODE_BAD_STATE); } } else if (cmd_frame[0] == CMD_SEND_TRACE_PATH && len > 10 && len - 10 <= MAX_PACKET_PAYLOAD - 9) { + serviceBinaryTraceReply(); + if (binary_trace_pending) { + writeErrFrame(ERR_CODE_BAD_STATE); + return; + } uint8_t path_len = len - 10; uint8_t flags = cmd_frame[9]; uint8_t path_sz = flags & 0x03; // NEW v1.11+ @@ -4151,6 +4352,12 @@ void MyMesh::handleCmdFrame(size_t len) { uint32_t t = _radio->getEstAirtimeFor(9 + path_len + 2); uint32_t est_timeout = calcDirectTimeoutMillisFor(t, path_len >> path_sz); if (sendDirect(pkt, &cmd_frame[10], path_len)) { + binary_trace_pending = true; + binary_trace_tag = tag; + binary_trace_auth = auth; + binary_trace_deadline = + futureMillis(est_timeout + est_timeout / 5); + binary_trace_reply_route = _serial->captureReplyRoute(); out_frame[0] = RESP_CODE_SENT; out_frame[1] = 0; memcpy(&out_frame[2], &tag, 4); @@ -5027,7 +5234,7 @@ void MyMesh::printTerminalBanner(bool show_binary_stop) { void MyMesh::enterTerminalMode() { _terminal_mode = true; - _terminal_output = &Serial; + _terminal_output = &mesh::usbTerminalPort(); resetTerminalSession(); printTerminalBanner(true); } @@ -5035,7 +5242,7 @@ void MyMesh::enterTerminalMode() { void MyMesh::exitTerminalMode() { _terminal_mode = false; resetTerminalSession(); - if (_terminal_output == &Serial) _terminal_output = NULL; + if (_terminal_output == &mesh::usbTerminalPort()) _terminal_output = NULL; } #if COMPANION_FEATURE_NETWORK_TERMINAL @@ -5164,6 +5371,7 @@ void MyMesh::rememberTerminalAck(ContactInfo& recipient, const char* text, entry.ack = expected_ack; entry.message_timestamp = message_timestamp; entry.contact = &recipient; + entry.reply_route = NULL; mesh::Utils::sha256(entry.text_fingerprint, sizeof(entry.text_fingerprint), recipient.id.pub_key, PUB_KEY_SIZE, (const uint8_t*)text, strlen(text)); @@ -5331,6 +5539,11 @@ void MyMesh::sendTerminalLogin(ContactInfo& recipient, _terminal_login_target); return; } + if (hasPendingReqs()) { + terminalOutput().print( + " ERROR: another Companion request is still pending\r\n"); + return; + } const size_t password_len = password == NULL ? 0 : strlen(password); if (password_len == 0 || password_len > 15) { @@ -5594,7 +5807,14 @@ void MyMesh::handleTerminalCommand(char* command) { mesh::cli::normalizeCommandVerb(command); char local_reply[160]; - if (handleLocalControlCommand(command, local_reply, sizeof(local_reply))) { +#if COMPANION_FEATURE_USB_MOTA_SOURCE + const bool usb_mota_owner_transition = + mesh::isUsbMotaOwnerTransitionCommand(command); +#else + const bool usb_mota_owner_transition = false; +#endif + if (!usb_mota_owner_transition + && handleLocalControlCommand(command, local_reply, sizeof(local_reply))) { terminalOutput().printf(" %s\r\n", local_reply); return; } @@ -6111,6 +6331,7 @@ void MyMesh::handleTerminalCommand(char* command) { terminalOutput().print("Commands:\r\n"); terminalOutput().print(" board\r\n"); terminalOutput().print(" version\r\n"); + terminalOutput().print(" get pwrmgt.bootreason\r\n"); #if COMPANION_FEATURE_MEMORY_DIAGNOSTICS terminalOutput().print(" memory\r\n"); #endif @@ -6206,7 +6427,7 @@ void MyMesh::handleTerminalCommand(char* command) { void MyMesh::enterCLIRescue() { _cli_rescue = true; cli_command[0] = 0; - Serial.println("========= CLI Rescue ========="); + mesh::usbTerminalPort().println("========= CLI Rescue ========="); } static bool isCompanionRadioPrefsCommand(const char* command) { @@ -6246,6 +6467,14 @@ bool MyMesh::handleCommand(const char* command, uint32_t sender_timestamp, while (*command == ' ' || *command == '\t') command++; } +#if COMPANION_FEATURE_USB_MOTA_SOURCE + if (mesh::isUsbMotaOwnerTransitionCommand(command)) { + snprintf(reply, reply_capacity, + "ERR ota folder on/off requires the owning USB mOTA session"); + return true; + } +#endif + if (handleLocalControlCommand(command, reply, reply_capacity)) return true; if (strncmp(command, "set tx ", 7) == 0) { @@ -6356,14 +6585,16 @@ bool MyMesh::handleCommand(const char* command, uint32_t sender_timestamp, } void MyMesh::checkCLIRescueCmd() { + Stream& output = mesh::usbTerminalPort(); + Stream& input = mesh::usbCompanionPort(); int len = strlen(cli_command); - while (Serial.available() && len < sizeof(cli_command)-1) { - char c = Serial.read(); + while (input.available() && len < sizeof(cli_command)-1) { + char c = input.read(); if (c != '\n') { cli_command[len++] = c; cli_command[len] = 0; } - Serial.print(c); // echo + output.print(c); // echo } if (len == sizeof(cli_command)-1) { // command buffer full cli_command[sizeof(cli_command)-1] = '\r'; @@ -6375,7 +6606,7 @@ void MyMesh::checkCLIRescueCmd() { reply_buf[0] = 0; if (handleCommand(cli_command, 0, reply_buf)) { // command was handled, print reply output - Serial.print(" "); Serial.print(reply_buf); Serial.println(); + output.print(" "); output.print(reply_buf); output.println(); } else if (strcmp(cli_command, "rebuild") == 0) { bool success = _store->formatFileSystem(); if (success) { @@ -6383,16 +6614,16 @@ void MyMesh::checkCLIRescueCmd() { savePrefs(); saveContacts(); saveChannels(); - Serial.println(" > erase and rebuild done"); + output.println(" > erase and rebuild done"); } else { - Serial.println(" Error: erase failed"); + output.println(" Error: erase failed"); } } else if (strcmp(cli_command, "erase") == 0) { bool success = _store->formatFileSystem(); if (success) { - Serial.println(" > erase done"); + output.println(" > erase done"); } else { - Serial.println(" Error: erase failed"); + output.println(" Error: erase failed"); } } else if (memcmp(cli_command, "ls", 2) == 0) { @@ -6406,7 +6637,7 @@ void MyMesh::checkCLIRescueCmd() { path += 7; // skip "ExtraFS" is_fs2 = true; } - Serial.printf("Listing files in %s\n", path); + output.printf("Listing files in %s\n", path); // log each file and directory File root = _store->openRead(path); @@ -6415,9 +6646,10 @@ void MyMesh::checkCLIRescueCmd() { File file = root.openNextFile(); while (file) { if (file.isDirectory()) { - Serial.printf("[dir] UserData%s/%s\n", path, file.name()); + output.printf("[dir] UserData%s/%s\n", path, file.name()); } else { - Serial.printf("[file] UserData%s/%s (%d bytes)\n", path, file.name(), file.size()); + output.printf("[file] UserData%s/%s (%d bytes)\n", path, + file.name(), file.size()); } // move to next file file = root.openNextFile(); @@ -6432,9 +6664,10 @@ void MyMesh::checkCLIRescueCmd() { File file = root2.openNextFile(); while (file) { if (file.isDirectory()) { - Serial.printf("[dir] ExtraFS%s/%s\n", path, file.name()); + output.printf("[dir] ExtraFS%s/%s\n", path, file.name()); } else { - Serial.printf("[file] ExtraFS%s/%s (%d bytes)\n", path, file.name(), file.size()); + output.printf("[file] ExtraFS%s/%s (%d bytes)\n", path, + file.name(), file.size()); } // move to next file file = root2.openNextFile(); @@ -6454,7 +6687,8 @@ void MyMesh::checkCLIRescueCmd() { path += 7; // skip "ExtraFS" is_fs2 = true; } else { - Serial.println("Invalid path provided, must start with UserData/ or ExtraFS/"); + output.println( + "Invalid path provided, must start with UserData/ or ExtraFS/"); cli_command[0] = 0; return; } @@ -6472,8 +6706,8 @@ void MyMesh::checkCLIRescueCmd() { file.read(buffer, file_size); // print hex - mesh::Utils::printHex(Serial, buffer, file_size); - Serial.print("\n"); + mesh::Utils::printHex(output, buffer, file_size); + output.print("\n"); file.close(); @@ -6485,7 +6719,7 @@ void MyMesh::checkCLIRescueCmd() { MESH_DEBUG_PRINTLN("Removing file: %s", path); // ensure path is not empty, or root dir if(!path || strlen(path) == 0 || strcmp(path, "/") == 0){ - Serial.println("Invalid path provided"); + output.println("Invalid path provided"); } else { bool is_fs2 = false; if (memcmp(path, "UserData/", 9) == 0) { @@ -6505,9 +6739,9 @@ void MyMesh::checkCLIRescueCmd() { removed = _store->removeFile(path); } if(removed){ - Serial.println("File removed"); + output.println("File removed"); } else { - Serial.println("Failed to remove file"); + output.println("Failed to remove file"); } } @@ -6515,7 +6749,7 @@ void MyMesh::checkCLIRescueCmd() { } else if (strcmp(cli_command, "reboot") == 0) { board.reboot(); // doesn't return } else { - Serial.println(" Error: unknown command"); + output.println(" Error: unknown command"); } cli_command[0] = 0; // reset command buffer @@ -6526,7 +6760,6 @@ void MyMesh::checkSerialInterface() { size_t len = _serial->checkRecvFrame(cmd_frame); if (!_serial->isConnected()) { stopContactsIterator(); - cancelPendingRadioParamApply(); return; } @@ -6577,6 +6810,9 @@ void MyMesh::loop() { serviceTerminalCommand(); serviceTerminalTrace(); #endif + servicePendingSerialReply(); + serviceBinaryTraceReply(); + serviceSigningSession(); if (!command_radio_apply_pending && saved_radio_apply_pending && !hasOutbound() #if COMPANION_FEATURE_TEMP_RADIO && !_temp_radio_applied && _temp_radio_set_at == 0 @@ -6651,6 +6887,9 @@ bool MyMesh::hasPendingWork() const { return (_serial != NULL && _serial->hasPendingIO()) || (_iter_started && _serial != NULL && _serial->isConnected()) || command_radio_apply_pending + || pending_serial_reply_route != NULL + || binary_trace_pending + || sign_data != NULL || hasQueuedWorkDue() || hasRetryWorkDue() || (saved_radio_apply_pending && (!radio_apply_retry_at || millisHasNowPassed(radio_apply_retry_at))) diff --git a/examples/companion_radio/MyMesh.h b/examples/companion_radio/MyMesh.h index aa328126..fdf57472 100644 --- a/examples/companion_radio/MyMesh.h +++ b/examples/companion_radio/MyMesh.h @@ -133,6 +133,8 @@ public: bool isRadioReady() const { return _radio_available; } void startInterface(BaseSerialInterface &serial); void cancelSerialResponseStream(); + void cancelSerialOperationsForRoute(BaseSerialInterface* route); + void resetUsbHostSessionInput(); const char *getNodeName(); CompanionNodePrefs *getNodePrefs(); @@ -290,9 +292,8 @@ protected: bool onChannelLoaded(uint8_t channel_idx, const ChannelDetails& ch) override { return setChannel(channel_idx, ch); } bool getChannelForSave(uint8_t channel_idx, ChannelDetails& ch) override { return getChannel(channel_idx, ch); } - void clearPendingReqs() { - pending_login = pending_status = pending_telemetry = pending_discovery = pending_req = 0; - } + void clearPendingReqs(); + bool hasPendingReqs() const; public: bool savePrefs() { @@ -315,8 +316,10 @@ public: bool hasPendingWork() const; private: - void writeOKFrame(); - void writeErrFrame(uint8_t err_code); + void writeOKFrame(BaseSerialInterface* route = nullptr); + void writeErrFrame(uint8_t err_code, + BaseSerialInterface* route = nullptr); + size_t writePendingSerialFrame(const uint8_t frame[], size_t len); void writeDisabledFrame(); void writeContactRespFrame(uint8_t code, const ContactInfo &contact); void stopContactsIterator(); @@ -390,9 +393,16 @@ private: mesh::RadioParamApplyResult tryApplyRadioParams(float freq, float bw, uint8_t sf, uint8_t cr); bool applySavedRadioParams(); void configureRadioFromPrefs(); - void finishRadioParamApply(float freq, float bw, uint8_t sf, uint8_t cr, uint8_t repeat); + void finishRadioParamApply(float freq, float bw, uint8_t sf, uint8_t cr, + uint8_t repeat, + BaseSerialInterface* route = nullptr); void cancelPendingRadioParamApply(); void servicePendingRadioParamApply(); + void servicePendingSerialReply(); + void clearBinaryTraceReply(); + void serviceBinaryTraceReply(); + void cancelSigningSession(); + void serviceSigningSession(); #if COMPANION_FEATURE_TEMP_RADIO bool scheduleTempRadio(float freq, float bw, uint8_t sf, uint8_t cr, uint32_t timeout_mins, char* reply, size_t reply_size); @@ -427,6 +437,8 @@ private: uint32_t pending_status; uint32_t pending_telemetry, pending_discovery; // pending _TELEMETRY_REQ uint32_t pending_req; // pending _BINARY_REQ + BaseSerialInterface* pending_serial_reply_route; + unsigned long pending_serial_reply_deadline; BaseSerialInterface *_serial; mesh::companion::MotaSourceControl* _mota_source_control; AbstractUITask* _ui; @@ -469,6 +481,12 @@ private: uint8_t command_radio_cr; uint8_t command_radio_repeat; unsigned long command_radio_apply_deadline; + BaseSerialInterface* command_radio_reply_route; + bool binary_trace_pending; + uint32_t binary_trace_tag; + uint32_t binary_trace_auth; + unsigned long binary_trace_deadline; + BaseSerialInterface* binary_trace_reply_route; // Deferred so USB/TCP terminals can transmit the acknowledgement before // the transport disappears. Also used by USB interface changes. unsigned long _scheduled_reboot_at; @@ -489,6 +507,8 @@ private: uint8_t app_target_ver; uint8_t *sign_data; uint32_t sign_data_len; + BaseSerialInterface* sign_data_reply_route; + unsigned long sign_data_deadline; unsigned long dirty_contacts_expiry; TransportKey send_scope; @@ -527,6 +547,7 @@ private: ContactInfo* contact; uint8_t text_fingerprint[MAX_HASH_SIZE]; uint8_t retry_key[MAX_HASH_SIZE]; + BaseSerialInterface* reply_route; #ifdef ENABLE_USB_INTERFACE bool terminal_origin; #endif diff --git a/examples/companion_radio/main.cpp b/examples/companion_radio/main.cpp index a13619a2..2d9cdd52 100644 --- a/examples/companion_radio/main.cpp +++ b/examples/companion_radio/main.cpp @@ -433,6 +433,15 @@ static size_t usb_mota_line_len = 0; static bool usb_mota_disconnect_armed = false; #endif +// nRF52's Adafruit CDC Stream::write() waits and yields until the complete +// request is queued. Functional terminal text uses a bounded queue over the +// single-attempt CDC0 facade so a normal host receives complete replies while +// an open but unread host cannot starve the mesh loop. Other platforms retain +// their ordinary stream. +static Stream& usbTerminalOutput() { + return mesh::usbTerminalPort(); +} + static void clearUsbTerminalLine() { memset(usb_terminal_line, 0, sizeof(usb_terminal_line)); usb_terminal_line_len = 0; @@ -447,11 +456,11 @@ static void printUsbTerminalInputEcho() { } if (visible_len > 0) { - Serial.write(reinterpret_cast(usb_terminal_line), - visible_len); + usbTerminalOutput().write( + reinterpret_cast(usb_terminal_line), visible_len); } for (size_t i = visible_len; i < usb_terminal_line_len; i++) { - Serial.print('*'); + usbTerminalOutput().print('*'); } } @@ -459,10 +468,10 @@ static void redrawUsbTerminalInput() { // The documented picocom `--imap spchex` converts an echoed BS to "[08]". // It deliberately leaves CR untouched, so redraw the edited line with CR // and printable bytes only. Padding clears a removed tab or wide glyph. - Serial.print("\r> "); + usbTerminalOutput().print("\r> "); printUsbTerminalInputEcho(); - Serial.print(" "); - Serial.print("\r> "); + usbTerminalOutput().print(" "); + usbTerminalOutput().print("\r> "); printUsbTerminalInputEcho(); } @@ -474,6 +483,40 @@ static bool isUsbTerminalDataConnected() { #endif } +static constexpr uint32_t USB_TERMINAL_PROTOCOL_DRAIN_MS = 250; + +static void queueUsbTerminalControlReply(const char* reply) { + if (reply == nullptr) return; + const size_t size = strlen(reply); + if (usbTerminalOutput().write( + reinterpret_cast(reply), size) != size) { + // A transition reply takes priority over stale best-effort terminal text. + // Clear only the application queue; TinyUSB retains any prefix already + // accepted, so Binary/mOTA still cannot overtake it. + mesh::discardUsbTerminalOutput(); + (void)usbTerminalOutput().write( + reinterpret_cast(reply), size); + } +} + +static void drainUsbTerminalOutputBeforeProtocolSwitch() { + const uint32_t started = millis(); + while (mesh::hasPendingUsbTerminalOutput() + && isUsbTerminalDataConnected() + && (uint32_t)(millis() - started) + < USB_TERMINAL_PROTOCOL_DRAIN_MS) { + mesh::serviceUsbTerminalPort(); +#if defined(NRF52_PLATFORM) + board.feedWatchdog(); +#endif + delay(1); + } + // Never let terminal bytes remain queued after another CDC0 protocol is + // enabled. A draining host normally empties this in a few milliseconds; an + // unread host loses only the residual text after the bounded deadline. + mesh::discardUsbTerminalOutput(); +} + static bool hasObservableActiveUsbTerminalClient() { #if defined(ESP32) && defined(ARDUINO_USB_MODE) && ARDUINO_USB_MODE == 1 \ && defined(ARDUINO_USB_CDC_ON_BOOT) && ARDUINO_USB_CDC_ON_BOOT @@ -486,11 +529,23 @@ static bool hasObservableActiveUsbTerminalClient() { #endif } +static void cancelUsbSerialOperations() { + // Contact enumeration uses the manager's pinned streaming route; delayed + // single replies capture their own route inside MyMesh. Cancel only USB's + // ownership so a simultaneous BLE/WiFi operation keeps running. + if (interface_manager.isReplyRouteFor(&usb_serial_interface)) { + the_mesh.cancelSerialResponseStream(); + } + the_mesh.cancelSerialOperationsForRoute(&usb_serial_interface); + interface_manager.forgetReplyRouteForDisconnected(&usb_serial_interface); +} + static void enterUsbTerminalMode() { #if defined(COMPANION_RADIO_FULL) usb_binary_startup_probe.cancel(); #endif - the_mesh.cancelSerialResponseStream(); + cancelUsbSerialOperations(); + mesh::discardUsbTerminalOutput(); usb_serial_interface.setPassthroughMode(true); clearUsbTerminalLine(); usb_terminal_discard_line = false; @@ -506,7 +561,10 @@ static void enterUsbLoggingTerminalMode() { static void leaveUsbTerminalMode(bool acknowledge) { if (acknowledge) { - Serial.print("\r\nOK - Binary mode\r\n"); + queueUsbTerminalControlReply("\r\nOK - Binary mode\r\n"); + drainUsbTerminalOutputBeforeProtocolSwitch(); + } else { + mesh::discardUsbTerminalOutput(); } the_mesh.exitTerminalMode(); usb_serial_interface.setPassthroughMode(false); @@ -518,6 +576,7 @@ static void leaveUsbTerminalMode(bool acknowledge) { #if COMPANION_FEATURE_USB_MOTA_SOURCE static void resetUsbMotaMode() { + mesh::discardUsbTerminalOutput(); usb_mota_mode = false; usb_mota_line_len = 0; usb_mota_line[0] = 0; @@ -529,15 +588,18 @@ static void leaveUsbMotaMode(bool acknowledge) { char reply[160] = {0}; the_mesh.handleLocalControlCommand("ota folder off", reply, sizeof(reply)); if (acknowledge) { - Serial.print("\r\n"); - Serial.print(reply); - Serial.print("\r\nOK - Binary mode\r\n"); + char transition_reply[224]; + snprintf(transition_reply, sizeof(transition_reply), + "\r\n%s\r\nOK - Binary mode\r\n", reply); + queueUsbTerminalControlReply(transition_reply); + drainUsbTerminalOutputBeforeProtocolSwitch(); } resetUsbMotaMode(); } static bool enterUsbMotaMode(mesh::UsbMotaEntryOrigin origin) { - the_mesh.cancelSerialResponseStream(); + cancelUsbSerialOperations(); + mesh::discardUsbTerminalOutput(); usb_serial_interface.setPassthroughMode(true); usb_mota_mode = true; usb_mota_line_len = 0; @@ -547,18 +609,24 @@ static bool enterUsbMotaMode(mesh::UsbMotaEntryOrigin origin) { char reply[160] = {0}; if (!the_mesh.handleLocalControlCommand("ota folder on", reply, sizeof(reply)) || strncmp(reply, "ERR", 3) == 0) { - Serial.print("\r\n"); - Serial.print(reply[0] ? reply : "ERR could not enter mOTA seeder mode"); - Serial.print("\r\n"); + char failure_reply[224]; + snprintf(failure_reply, sizeof(failure_reply), "\r\n%s\r\n", + reply[0] ? reply : "ERR could not enter mOTA seeder mode"); + queueUsbTerminalControlReply(failure_reply); + drainUsbTerminalOutputBeforeProtocolSwitch(); resetUsbMotaMode(); if (mesh::shouldRestoreAsciiAfterMotaFailure(origin)) { enterUsbTerminalMode(); } return false; } - Serial.print("\r\n"); - Serial.print(reply); - Serial.print("\r\n"); + char attached_reply[224]; + snprintf(attached_reply, sizeof(attached_reply), "\r\n%s\r\n", reply); + queueUsbTerminalControlReply(attached_reply); + // The mOTA request stream writes directly through the single-attempt CDC0 + // facade. Finish this text barrier first so its first binary frame can never + // overtake a queued attach reply. + drainUsbTerminalOutputBeforeProtocolSwitch(); return true; } @@ -573,8 +641,9 @@ static void serviceUsbMota() { // SerialMotaSource consumes framed responses synchronously while serving a // block. Bytes left here are host control text, notably motatool's automatic // `ota folder off` on a clean shutdown. - while (Serial.available()) { - int value = Serial.read(); + Stream& usb_input = mesh::usbCompanionPort(); + while (usb_input.available()) { + int value = usb_input.read(); if (value < 0) break; char c = (char)value; if (c == '\r' || c == '\n') { @@ -600,6 +669,54 @@ static void serviceUsbMota() { } #endif +// DTR polling alone cannot distinguish a continuously open handle from a +// close/reopen that happened entirely between two loop iterations. Consume the +// TinyUSB owner-task edge before any protocol parser runs, so no line, login, +// recipient, queued text, or mOTA attachment survives into the next host. +static bool usb_terminal_host_reset_completion_pending = false; + +static void serviceUsbTerminalHostSessionReset() { +#if defined(NRF52_PLATFORM) + if (mesh::takeUsbTerminalSessionReset()) { + usb_terminal_host_reset_completion_pending = true; + // A rapid reopen can make USB look connected again before this loop. Stop + // only work routed to the old USB host; a simultaneous BLE transaction is + // independent and must continue. Clear the route after stopping its + // producer so no remaining response can fall back to another transport. + cancelUsbSerialOperations(); + the_mesh.resetUsbHostSessionInput(); + bool protocol_owner_reset = false; +#if COMPANION_FEATURE_USB_MOTA_SOURCE + if (usb_mota_mode) { + leaveUsbMotaMode(false); + protocol_owner_reset = true; + } +#endif + if (!protocol_owner_reset && the_mesh.isTerminalMode()) { + leaveUsbTerminalMode(false); + protocol_owner_reset = true; + } + + if (!protocol_owner_reset) { + mesh::discardUsbTerminalOutput(); + usb_serial_interface.setPassthroughMode(false); + clearUsbTerminalLine(); + usb_terminal_discard_line = false; + usb_terminal_disconnect_armed = false; + usb_logging_terminal_mode = false; +#if defined(COMPANION_RADIO_FULL) + usb_binary_startup_probe.cancel(); +#endif + } + } + + if (usb_terminal_host_reset_completion_pending + && mesh::tryCompleteUsbTerminalSessionReset()) { + usb_terminal_host_reset_completion_pending = false; + } +#endif +} + static void serviceUsbTerminal() { #if COMPANION_FEATURE_USB_MOTA_SOURCE if (usb_mota_mode) { @@ -649,7 +766,8 @@ static void serviceUsbTerminal() { usb_logging_terminal_mode = false; clearUsbTerminalLine(); usb_terminal_discard_line = false; - Serial.print("\r\nUSB logging off; ASCII terminal active\r\n> "); + usbTerminalOutput().print( + "\r\nUSB logging off; ASCII terminal active\r\n> "); break; case mesh::UsbLoggingTerminalAction::NO_ACTION: break; @@ -699,7 +817,7 @@ static void serviceUsbTerminal() { if (!usb_logging_terminal_mode && usb_binary_startup_probe.shouldStart( usb_terminal_line_len == 0, usb_terminal_discard_line, - Serial.peek())) { + mesh::usbCompanionPort().peek())) { const uint32_t frame_count = usb_serial_interface.getCompletedFrameCount(); leaveUsbTerminalMode(false); usb_binary_startup_probe.start(millis(), frame_count); @@ -707,15 +825,16 @@ static void serviceUsbTerminal() { } #endif - while (Serial.available()) { - int value = Serial.read(); + Stream& usb_input = mesh::usbCompanionPort(); + while (usb_input.available()) { + int value = usb_input.read(); if (value < 0) break; char c = (char)value; if (usb_terminal_discard_line) { if (c == '\r' || c == '\n') { usb_terminal_discard_line = false; - Serial.print("> "); + usbTerminalOutput().print("> "); } continue; } @@ -731,7 +850,7 @@ static void serviceUsbTerminal() { if (c == '\r' || c == '\n') { if (usb_terminal_line_len == 0) continue; - Serial.print("\r\n"); + usbTerminalOutput().print("\r\n"); #if COMPANION_FEATURE_USB_MOTA_SOURCE // motatool is deliberately text-first: `serve --serial` opens the port // and sends this command before its binary mOTA request/reply traffic. @@ -760,21 +879,21 @@ static void serviceUsbTerminal() { #endif } #endif - Serial.print("> "); + usbTerminalOutput().print("> "); return; // service at most one command per mesh loop } if (usb_terminal_line_len >= sizeof(usb_terminal_line) - 1) { clearUsbTerminalLine(); usb_terminal_discard_line = true; - Serial.print("\r\n ERROR: command too long\r\n"); + usbTerminalOutput().print("\r\n ERROR: command too long\r\n"); continue; } usb_terminal_line[usb_terminal_line_len++] = c; usb_terminal_line[usb_terminal_line_len] = 0; - Serial.print(mesh::cli::shouldMaskTerminalInput(usb_terminal_line) ? '*' - : c); + usbTerminalOutput().print( + mesh::cli::shouldMaskTerminalInput(usb_terminal_line) ? '*' : c); if (strcmp(usb_terminal_line, USB_TERMINAL_STOP_TOKEN) == 0) { leaveUsbTerminalMode(true); @@ -793,8 +912,9 @@ static void expireUsbBinaryStartupProbeBeforeDispatch() { // are still incomplete at one second cannot complete a frame afterwards. // Entering passthrough resets the partial binary parser; drain only bytes // already queued for that expired attempt so they cannot begin a new probe. - int pending = Serial.available(); - while (pending-- > 0) Serial.read(); + Stream& usb_input = mesh::usbCompanionPort(); + int pending = usb_input.available(); + while (pending-- > 0) usb_input.read(); enterUsbTerminalMode(); } #endif @@ -1985,9 +2105,11 @@ void setup() { // add usb interface #if defined(ENABLE_USB_INTERFACE) #if COMPANION_FEATURE_USB_MOTA_SOURCE - usb_serial_interface.begin(Serial, USB_TERMINAL_START_TOKEN, USB_MOTA_START_TOKEN); + usb_serial_interface.begin(mesh::usbCompanionPort(), + USB_TERMINAL_START_TOKEN, USB_MOTA_START_TOKEN); #else - usb_serial_interface.begin(Serial, USB_TERMINAL_START_TOKEN); + usb_serial_interface.begin(mesh::usbCompanionPort(), + USB_TERMINAL_START_TOKEN); #endif // keep frames intact and pace the contact stream when the host is slow usb_serial_interface.enableFlowControl(true); @@ -2078,10 +2200,18 @@ void setup() { void loop() { #if defined(NRF52_PLATFORM) board.feedWatchdog(); +#endif +#if defined(ENABLE_USB_INTERFACE) + serviceUsbTerminalHostSessionReset(); #endif // Identify nRF52 CDC 1 when a terminal opens it. Doing this on the connection // edge avoids losing the marker before the host has opened the port. mesh::serviceUsbLoggingPort(); +#if defined(ENABLE_USB_INTERFACE) + // Drain functional CDC0 text before the mesh can emit a Binary Companion or + // serial-mOTA frame in this iteration. + mesh::serviceUsbTerminalPort(); +#endif #if defined(ENABLE_USB_INTERFACE) && defined(COMPANION_RADIO_FULL) expireUsbBinaryStartupProbeBeforeDispatch(); #endif @@ -2091,6 +2221,9 @@ void loop() { #endif #if defined(ENABLE_USB_INTERFACE) serviceUsbTerminal(); + // Commands can synchronously enqueue a multi-line reply. Give it the + // remaining CDC FIFO capacity now; later loops retain any unwritten suffix. + mesh::serviceUsbTerminalPort(); #endif interface_manager.loop(); #if COMPANION_FEATURE_BLE_MOTA_SOURCE @@ -2142,6 +2275,14 @@ void loop() { #if defined(NRF52_PLATFORM) \ || (defined(ESP32_PLATFORM) && defined(ENABLE_USB_INTERFACE)) can_sleep = can_sleep && !board.isUsbHostConnected(); +#endif +#if defined(MOMENTARY_BUTTON_WAKE_FROM_SLEEP) \ + && MOMENTARY_BUTTON_WAKE_FROM_SLEEP \ + && defined(PIN_USER_BTN) && defined(DISPLAY_CLASS) + // GPIO edges wake the nRF52 poller, but multi-click and long-press results + // are due after a time interval rather than another edge. Stay awake only + // while that small gesture state machine has a pending deadline. + can_sleep = can_sleep && !user_btn.needsPolling(); #endif if (can_sleep) { #if defined(NRF52_PLATFORM) diff --git a/examples/companion_radio/ui-new/UITask.cpp b/examples/companion_radio/ui-new/UITask.cpp index ee359dd1..0e3953a7 100644 --- a/examples/companion_radio/ui-new/UITask.cpp +++ b/examples/companion_radio/ui-new/UITask.cpp @@ -1101,6 +1101,9 @@ public: #ifndef UI_COMPACT_MESSAGE_STATUS #define UI_COMPACT_MESSAGE_STATUS 0 #endif +#ifndef UI_MESSAGE_CHANNEL_FOOTER + #define UI_MESSAGE_CHANNEL_FOOTER 1 +#endif class MsgPreviewScreen : public UIScreen { UITask* _task; @@ -1201,6 +1204,7 @@ class MsgPreviewScreen : public UIScreen { } void renderChannelFilter(DisplayDriver& display) const { +#if UI_MESSAGE_CHANNEL_FOOTER == 1 const mesh::ui::CompanionMessageChromeLayout layout = mesh::ui::makeCompanionMessageChromeLayout( UI_COMPACT_MESSAGE_STATUS == 1); @@ -1223,6 +1227,9 @@ class MsgPreviewScreen : public UIScreen { text_y); display.print(">"); display.setCompactText(false); +#else + (void)display; +#endif } public: @@ -1368,7 +1375,14 @@ public: bool handleInput(char c) override { if (c == KEY_NEXT || c == KEY_RIGHT) { - if (view_offset + 1 < filteredCount()) ++view_offset; + if (view_offset + 1 < filteredCount()) { + ++view_offset; + } else { + // The historical inbox flow returned Home after its final item. Keep + // that wrap behavior now that previews persist in MessageHistory; + // otherwise a valid navigation press is a silent no-op at the end. + _task->gotoHomeScreen(); + } return true; } if (c == KEY_PREV || c == KEY_LEFT) { diff --git a/examples/simple_repeater/main.cpp b/examples/simple_repeater/main.cpp index aa70a016..88e4a987 100644 --- a/examples/simple_repeater/main.cpp +++ b/examples/simple_repeater/main.cpp @@ -334,7 +334,17 @@ void loop() { #ifdef HAS_EXTERNAL_WATCHDOG external_watchdog.loop(); #endif - if (the_mesh.getNodePrefs()->powersaving_enabled && !board.isUsbDataConnected()) { + bool can_power_save = the_mesh.getNodePrefs()->powersaving_enabled + && !board.isUsbDataConnected(); +#if defined(MOMENTARY_BUTTON_WAKE_FROM_SLEEP) \ + && MOMENTARY_BUTTON_WAKE_FROM_SLEEP \ + && defined(PIN_USER_BTN) && defined(DISPLAY_CLASS) + // Do not strand a stable-level debounce or multi-click deadline in the raw + // nRF52 event wait. The GPIO edge wakes the first poll; this gate lets the + // remaining 25/280 ms state-machine interval finish. + can_power_save = can_power_save && !user_btn.needsPolling(); +#endif + if (can_power_save) { uint32_t sleep_secs = the_mesh.getPowerSaveSleepSeconds(30); #ifdef HAS_EXTERNAL_WATCHDOG if (sleep_secs > 0) external_watchdog.feed(); diff --git a/examples/simple_room_server/UITask.cpp b/examples/simple_room_server/UITask.cpp index e157a92c..1a1efc56 100644 --- a/examples/simple_room_server/UITask.cpp +++ b/examples/simple_room_server/UITask.cpp @@ -1,7 +1,5 @@ #include "UITask.h" -#ifdef DISPLAY_TOUCH_TOGGLE #include "target.h" -#endif #include #include #include @@ -100,9 +98,12 @@ void UITask::begin(NodePrefs* node_prefs, const char* build_date, const char* fi applyDisplayFlip(); #ifdef DISPLAY_TOUCH_TOGGLE _touch.begin(); -#if defined(PIN_USER_BTN) && defined(DISPLAY_CLASS) - user_btn.begin(); #endif +#if defined(PIN_USER_BTN) && defined(DISPLAY_CLASS) \ + && (defined(DISPLAY_TOUCH_TOGGLE) \ + || (defined(MOMENTARY_BUTTON_WAKE_FROM_SLEEP) \ + && MOMENTARY_BUTTON_WAKE_FROM_SLEEP)) + user_btn.begin(); #endif #ifdef DISPLAY_REDRAW_ON_CHANGE _frame_valid = false; @@ -378,6 +379,27 @@ void UITask::loop() { #endif _next_refresh = 0; } +#elif defined(PIN_USER_BTN) \ + && defined(DISPLAY_CLASS) \ + && defined(MOMENTARY_BUTTON_WAKE_FROM_SLEEP) \ + && MOMENTARY_BUTTON_WAKE_FROM_SLEEP + // Event-driven nRF52 targets must use the shared debounced button state + // machine. Raw 200 ms polling can go back to sleep after the GPIO edge and + // miss both a short press and its release. + int ev = user_btn.check(); + if (ev != BUTTON_EVENT_NONE) { + if (!_display->isOn()) { + _display->turnOn(); +#ifdef DISPLAY_REDRAW_ON_CHANGE + _frame_valid = false; +#endif +#ifdef DISPLAY_ACTIVITY_DASHBOARD + _rows_valid = false; +#endif + _next_refresh = 0; + } + _auto_off = millis() + displayTimeoutMillis(); + } #elif defined(PIN_USER_BTN) if (millisReached(millis(), _next_read)) { int btnState = digitalRead(PIN_USER_BTN); diff --git a/examples/simple_room_server/main.cpp b/examples/simple_room_server/main.cpp index 35049c52..7f982b8d 100644 --- a/examples/simple_room_server/main.cpp +++ b/examples/simple_room_server/main.cpp @@ -208,7 +208,14 @@ void loop() { #ifdef HAS_EXTERNAL_WATCHDOG external_watchdog.loop(); #endif - if (the_mesh.getNodePrefs()->powersaving_enabled && !board.isUsbDataConnected()) { + bool can_power_save = the_mesh.getNodePrefs()->powersaving_enabled + && !board.isUsbDataConnected(); +#if defined(MOMENTARY_BUTTON_WAKE_FROM_SLEEP) \ + && MOMENTARY_BUTTON_WAKE_FROM_SLEEP \ + && defined(PIN_USER_BTN) && defined(DISPLAY_CLASS) + can_power_save = can_power_save && !user_btn.needsPolling(); +#endif + if (can_power_save) { uint32_t sleep_secs = the_mesh.getPowerSaveSleepSeconds(30); #ifdef HAS_EXTERNAL_WATCHDOG if (sleep_secs > 0) external_watchdog.feed(); diff --git a/examples/simple_sensor/UITask.cpp b/examples/simple_sensor/UITask.cpp index 75a0b465..fc5eb997 100644 --- a/examples/simple_sensor/UITask.cpp +++ b/examples/simple_sensor/UITask.cpp @@ -1,4 +1,5 @@ #include "UITask.h" +#include "target.h" #include #include @@ -31,6 +32,11 @@ void UITask::begin(NodePrefs* node_prefs, const char* build_date, const char* fi _auto_off = millis() + AUTO_OFF_MILLIS; _node_prefs = node_prefs; _display->turnOn(); +#if defined(PIN_USER_BTN) && defined(DISPLAY_CLASS) \ + && defined(MOMENTARY_BUTTON_WAKE_FROM_SLEEP) \ + && MOMENTARY_BUTTON_WAKE_FROM_SLEEP + user_btn.begin(); +#endif // strip off dash and commit hash by changing dash to null terminator // e.g: v1.2.3-abcdef -> v1.2.3 @@ -92,7 +98,18 @@ void UITask::renderCurrScreen() { } void UITask::loop() { -#ifdef PIN_USER_BTN +#if defined(PIN_USER_BTN) && defined(DISPLAY_CLASS) \ + && defined(MOMENTARY_BUTTON_WAKE_FROM_SLEEP) \ + && MOMENTARY_BUTTON_WAKE_FROM_SLEEP + int ev = user_btn.check(); + if (ev != BUTTON_EVENT_NONE) { + if (!_display->isOn()) { + _display->turnOn(); + _next_refresh = 0; + } + _auto_off = millis() + AUTO_OFF_MILLIS; + } +#elif defined(PIN_USER_BTN) if (millis() >= _next_read) { int btnState = digitalRead(PIN_USER_BTN); if (btnState != _prevBtnState) { diff --git a/examples/simple_sensor/main.cpp b/examples/simple_sensor/main.cpp index 9d9ed7d3..776b9723 100644 --- a/examples/simple_sensor/main.cpp +++ b/examples/simple_sensor/main.cpp @@ -184,8 +184,14 @@ void loop() { external_watchdog.loop(); #endif - if (the_mesh.getNodePrefs()->powersaving_enabled - && !board.isUsbDataConnected()) { + bool can_power_save = the_mesh.getNodePrefs()->powersaving_enabled + && !board.isUsbDataConnected(); +#if defined(MOMENTARY_BUTTON_WAKE_FROM_SLEEP) \ + && MOMENTARY_BUTTON_WAKE_FROM_SLEEP \ + && defined(PIN_USER_BTN) && defined(DISPLAY_CLASS) + can_power_save = can_power_save && !user_btn.needsPolling(); +#endif + if (can_power_save) { uint32_t sleep_secs = the_mesh.getPowerSaveSleepSeconds(30); #ifdef HAS_EXTERNAL_WATCHDOG if (sleep_secs > 0) external_watchdog.feed(); diff --git a/platformio.ini b/platformio.ini index 3bdab162..3a37d47d 100644 --- a/platformio.ini +++ b/platformio.ini @@ -192,6 +192,27 @@ build_flags = -D OTA_QSPI_EXPECTED_JEDEC_ID=0xC84015UL -D OTA_QSPI_EXPECTED_SIZE=2097152UL +; Six-pin Winbond W25Q16JV breakout wired permanently to a RAK19007. It shares +; the WisBlock SPI clock/data nets but has a dedicated chip-select on AIN1, so +; one base-board wiring can be used with either RAK4631 or RAK3401+RAK13302. +; The breakout exposes standard SPI only, so use single-data-line nRF QSPI +; mode. Exact JEDEC matching rejects substituted W25Q32/64/128 boards. Keep +; the clock at 8 MHz for short wiring and power it from J12 VDD so the matching +; bootloader can read the staged image after reset. +[nrf52_wisblock_w25q16_ota] +build_flags = + ${nrf52_qspi_ota.build_flags} + -D OTA_QSPI_SHARED_WISBLOCK_SPI=1 + -D OTA_QSPI_SCK_ARDUINO_PIN=3 + -D OTA_QSPI_CS_ARDUINO_PIN=31 + -D OTA_QSPI_IO0_ARDUINO_PIN=30 + -D OTA_QSPI_IO1_ARDUINO_PIN=29 + -D OTA_QSPI_IO2_NOT_CONNECTED=1 + -D OTA_QSPI_IO3_NOT_CONNECTED=1 + -D OTA_QSPI_SCK_FREQUENCY=NRF_QSPI_FREQ_32MDIV4 + -D OTA_QSPI_EXPECTED_JEDEC_ID=0xEF4015UL + -D OTA_QSPI_EXPECTED_SIZE=2097152UL + ; Compatibility alias retained for existing nRF52840 board definitions. [rak4631_hw] extends = nrf52_lora_ota @@ -359,6 +380,7 @@ build_src_filter = +<../src/helpers/ArduinoSerialInterface.cpp> +<../src/helpers/DynamicConfigSerializer.cpp> +<../src/helpers/CommonRadioPrefs.cpp> + +<../src/helpers/ui/MomentaryButton.cpp> lib_deps = google/googletest @ 1.17.0 bblanchon/ArduinoJson @ 7.4.3 diff --git a/src/Dispatcher.cpp b/src/Dispatcher.cpp index 1a946e75..8b849536 100644 --- a/src/Dispatcher.cpp +++ b/src/Dispatcher.cpp @@ -45,7 +45,8 @@ void Dispatcher::setRadioAvailable(bool available) { const unsigned long now = _ms->getMillis(); radio_nonrx_start = now; next_floor_calib_time = now; - next_agc_reset_time = now; + armed_agc_reset_interval = 0; + agc_reset_armed = false; _radio->begin(); prev_isrecv_mode = _radio->isInRecvMode(); last_observed_radio_irq = _radio->getLastRadioInterruptMillis(); @@ -73,6 +74,8 @@ void Dispatcher::begin() { last_budget_update = _ms->getMillis(); const unsigned long now = _ms->getMillis(); + armed_agc_reset_interval = 0; + agc_reset_armed = false; dispatcher_started = true; if (radio_available) { _radio->begin(); @@ -461,12 +464,35 @@ void Dispatcher::loop() { } // going back into receive mode now... - next_agc_reset_time = futureMillis(getAGCResetInterval()); + const int agc_interval = getAGCResetInterval(); + if (agc_interval > 0) { + next_agc_reset_time = futureMillis(agc_interval); + armed_agc_reset_interval = agc_interval; + agc_reset_armed = true; + } else { + armed_agc_reset_interval = 0; + agc_reset_armed = false; + } } - if (getAGCResetInterval() > 0 && millisHasNowPassed(next_agc_reset_time)) { + const int agc_interval = getAGCResetInterval(); + if (agc_interval <= 0) { + armed_agc_reset_interval = 0; + agc_reset_armed = false; + } else if (!agc_reset_armed + || agc_interval != armed_agc_reset_interval) { + // MyMesh loads persisted preferences after Dispatcher::begin(). Arm from + // the first loop so the real configured interval is used, and so a freshly + // initialized radio/frontend gets one full settle interval before the + // first warm-sleep AGC reset. A live interval change also gets a complete + // new interval instead of inheriting the old setting's deadline. + next_agc_reset_time = futureMillis(agc_interval); + armed_agc_reset_interval = agc_interval; + agc_reset_armed = true; + } else if (millisHasNowPassed(next_agc_reset_time)) { _radio->resetAGC(); - next_agc_reset_time = futureMillis(getAGCResetInterval()); + next_agc_reset_time = futureMillis(agc_interval); + armed_agc_reset_interval = agc_interval; } // check inbound (delayed) queue diff --git a/src/Dispatcher.h b/src/Dispatcher.h index 34baa831..7475418e 100644 --- a/src/Dispatcher.h +++ b/src/Dispatcher.h @@ -250,6 +250,8 @@ class Dispatcher { unsigned long cad_busy_start; unsigned long radio_nonrx_start; unsigned long next_floor_calib_time, next_agc_reset_time; + int armed_agc_reset_interval; + bool agc_reset_armed; bool prev_isrecv_mode; bool radio_available; bool dispatcher_started; @@ -290,6 +292,8 @@ protected: next_tx_time = ms.getMillis(); cad_busy_start = 0; next_floor_calib_time = next_agc_reset_time = 0; + armed_agc_reset_interval = 0; + agc_reset_armed = false; _err_flags = 0; radio_nonrx_start = 0; prev_isrecv_mode = true; diff --git a/src/helpers/BaseSerialInterface.h b/src/helpers/BaseSerialInterface.h index 37308c95..5c2759f4 100644 --- a/src/helpers/BaseSerialInterface.h +++ b/src/helpers/BaseSerialInterface.h @@ -25,6 +25,20 @@ public: // Returns true once for each pending Bluetooth pairing prompt. Non-BLE // transports keep the default implementation so UI code can poll safely. virtual bool takePairingRequest() { return false; } + // Capture and later address the transport which supplied the current + // command. Multi-transport implementations override these so asynchronous + // replies do not follow a newer client's command or fall back to broadcast. + virtual BaseSerialInterface* captureReplyRoute() { return this; } + virtual bool isReplyRouteAvailable(BaseSerialInterface* route) const { + return route == this && isEnabled() && isConnected(); + } + virtual bool isReplyRouteWriteBusy(BaseSerialInterface* route) const { + return !isReplyRouteAvailable(route) || isWriteBusy(); + } + virtual size_t writeFrameToRoute(BaseSerialInterface* route, + const uint8_t src[], size_t len) { + return route == this ? writeFrame(src, len) : 0; + } // Multi-transport implementations can pin a sequence of response frames to // the interface which supplied the current command. Single transports have // nothing to route, so their default implementations are no-ops. diff --git a/src/helpers/CompanionFrameQueue.h b/src/helpers/CompanionFrameQueue.h index f9afbe3b..f8ed1648 100644 --- a/src/helpers/CompanionFrameQueue.h +++ b/src/helpers/CompanionFrameQueue.h @@ -44,6 +44,31 @@ inline bool companionFrameRequiresDelivery(const uint8_t* frame, size_t len) { return companionFrameClass(frame, len) != COMPANION_BEST_EFFORT_PUSH; } +// Queue priority and transport ownership are separate. Some required pushes +// (MSG_WAITING, control data, and contact-capacity notifications) are +// unsolicited state changes which every connected app should observe. Only +// direct command responses and known asynchronous command completions inherit +// the requester's route. New push codes default to broadcast, which is safer +// than exposing one client's completion to whichever client spoke last. +inline bool companionFrameUsesRequesterRoute(const uint8_t* frame, + size_t len) { + if (frame == NULL || len == 0) return false; + if (!isCompanionPushFrame(frame, len)) return true; + switch (frame[0]) { + case 0x82: // SEND_CONFIRMED + case 0x85: // LOGIN_SUCCESS + case 0x86: // LOGIN_FAIL + case 0x87: // STATUS_RESPONSE + case 0x89: // TRACE_DATA + case 0x8B: // TELEMETRY_RESPONSE + case 0x8C: // BINARY_RESPONSE + case 0x8D: // PATH_DISCOVERY_RESPONSE + return true; + default: + return false; + } +} + /** * Add a frame to a companion transport's contiguous outbound queue. * diff --git a/src/helpers/CompanionMotaControl.h b/src/helpers/CompanionMotaControl.h index bedf4120..8652b006 100644 --- a/src/helpers/CompanionMotaControl.h +++ b/src/helpers/CompanionMotaControl.h @@ -2,6 +2,7 @@ #include #include +#include "UsbAsciiBinarySwitch.h" namespace mesh { namespace companion { @@ -80,6 +81,8 @@ inline bool isBleOtaControlCommandAllowed(const uint8_t* command, if (exact("tempradio", 9)) return true; if (token("tempradio", 9)) return length > 10; if (!token("ota", 3)) return false; + if (mesh::isUsbMotaOwnerTransitionCommand( + reinterpret_cast(command), length)) return false; if (token("ota folder", 10)) return false; return true; } diff --git a/src/helpers/MultiSerialInterface.h b/src/helpers/MultiSerialInterface.h index 0cb26059..19a29088 100644 --- a/src/helpers/MultiSerialInterface.h +++ b/src/helpers/MultiSerialInterface.h @@ -247,6 +247,45 @@ public: return false; } + // Exact transport-session resets can arrive independently of + // isConnected() polling (for example, a close/reopen between two loop + // iterations). Let the application abort only work which belongs to that + // transport, without disturbing an active BLE/WiFi response transaction. + bool isReplyRouteFor(BaseSerialInterface* target) const { + return target != nullptr && replyTarget() == target; + } + + BaseSerialInterface* captureReplyRoute() override { + return replyTarget(); + } + + bool isReplyRouteAvailable(BaseSerialInterface* route) const override { + return _enabled && isAvailableReplyTarget(route); + } + + bool isReplyRouteWriteBusy(BaseSerialInterface* route) const override { + return !_enabled || !isAvailableReplyTarget(route) + || route->isWriteBusy(); + } + + size_t writeFrameToRoute(BaseSerialInterface* route, + const uint8_t src[], size_t len) override { + if (!_enabled || src == nullptr || len == 0 + || !isAvailableReplyTarget(route)) { + return 0; + } + return route->writeFrame(src, len); + } + + // Call this only after the response producer for target has been stopped. + // Forgetting both pointers prevents a new host on the same transport from + // inheriting the prior host's reply destination. + void forgetReplyRouteForDisconnected(BaseSerialInterface* target) { + if (target == nullptr) return; + if (_lockedReplyInterface == target) _lockedReplyInterface = nullptr; + if (_lastRxInterface == target) _lastRxInterface = nullptr; + } + void lockReplyRoute() override { if (isAvailableReplyTarget(_lastRxInterface)) { _lockedReplyInterface = _lastRxInterface; @@ -263,10 +302,10 @@ public: return 0; } - // Responses and delivery-required completion pushes belong to the client - // which supplied the latest command. Best-effort asynchronous observations - // remain broadcast so passive connected apps can keep their view fresh. - if (mesh::companionFrameRequiresDelivery(src, len)) { + // Responses and command-completion pushes belong to their requester. + // Unsolicited pushes remain broadcast regardless of queue priority so + // passive connected apps can keep their view fresh. + if (mesh::companionFrameUsesRequesterRoute(src, len)) { BaseSerialInterface* target = replyTarget(); if (target != nullptr) { if (!isAvailableReplyTarget(target)) return 0; diff --git a/src/helpers/NRF52Board.cpp b/src/helpers/NRF52Board.cpp index d9aa2db7..72c2f036 100644 --- a/src/helpers/NRF52Board.cpp +++ b/src/helpers/NRF52Board.cpp @@ -459,9 +459,13 @@ void NRF52Board::sleep(uint32_t secs) { // flag or BLE queue before sleeping again. sd_app_evt_wait(); } else { - // softdevice is disabled, use raw WFE - __SEV(); + // SoftDevice is disabled. Match the Adafruit core's race-preserving + // waitForEvent() sequence: the first WFE sleeps or consumes an already- + // pending event, then SEV/WFE reliably clears the event register before + // returning. SEV/WFE/WFE can erase the only GPIO edge that arrived between + // the caller's predicate and here. __WFE(); + __SEV(); __WFE(); } } diff --git a/src/helpers/NonBlockingWriteStream.h b/src/helpers/NonBlockingWriteStream.h index 62292a9c..832e4764 100644 --- a/src/helpers/NonBlockingWriteStream.h +++ b/src/helpers/NonBlockingWriteStream.h @@ -2,11 +2,373 @@ #include #include +#include #include #include +#include namespace mesh { +// A Stream facade for transports which provide a genuinely nonblocking write +// primitive separate from Stream::write(). Adafruit_USBD_CDC::write() loops +// and yields until it has queued the complete request; TinyUSB's lower-level +// tud_cdc_n_write() instead returns after one FIFO attempt. Keeping the +// single-attempt policy here makes the primary Companion CDC and the dedicated +// logging CDC share the same reentrancy guard without changing other targets. +class SingleAttemptNonBlockingStream : public Stream { + public: + using TryWrite = size_t (*)(void* context, const uint8_t* data, + size_t size); + using CanAccess = bool (*)(void* context); + using ExclusiveOperation = void (*)(void* context); + + SingleAttemptNonBlockingStream(Stream& delegate, TryWrite try_write, + void* context = nullptr, + CanAccess can_access = nullptr) + : _delegate(delegate), _try_write(try_write), _context(context), + _can_access(can_access) {} + + int available() override { + if (!canAccess()) return 0; + const int count = _delegate.available(); + return canAccess() ? count : 0; + } + int read() override { + if (!canAccess()) return -1; + const int value = _delegate.read(); + // If the host changed while read() was in flight, consume/drop that byte + // instead of handing it to the next session's protocol parser. + return canAccess() ? value : -1; + } + int peek() override { + if (!canAccess()) return -1; + const int value = _delegate.peek(); + return canAccess() ? value : -1; + } + // The delegate's flush may wait forever while a CDC host keeps DTR asserted + // but stops reading. A single-attempt facade must keep every operation + // single-attempt, including flush. + void flush() override {} + int availableForWrite() override { + return canAccess() ? _delegate.availableForWrite() : 0; + } + + 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 || _try_write == nullptr) return 0; + if (_writer_busy.test_and_set(std::memory_order_acquire)) return 0; + + if (!canAccess()) { + _writer_busy.clear(std::memory_order_release); + return 0; + } + + size_t written = _try_write(_context, data, size); + if (written > size) written = size; + + _writer_busy.clear(std::memory_order_release); + return written; + } + + // Check capacity and perform the sole write under the same producer gate. + // A separate outer preflight is insufficient: another writer over this + // shared CDC can otherwise consume capacity between check and write. + size_t writeWholeRecord(const uint8_t* data, size_t size) { + if (data == nullptr || size == 0 || _try_write == nullptr) return 0; + if (_writer_busy.test_and_set(std::memory_order_acquire)) return 0; + + if (!canAccess()) { + _writer_busy.clear(std::memory_order_release); + return 0; + } + + size_t written = 0; + const int available = _delegate.availableForWrite(); + if (available >= 0 && static_cast(available) >= size) { + written = _try_write(_context, data, size); + if (written > size) written = size; + } + + _writer_busy.clear(std::memory_order_release); + return written; + } + + // Run a short transport/session operation only when no application writer + // is in flight. The caller retries later on false; this never waits. + bool tryRunExclusive(ExclusiveOperation operation, void* context = nullptr) { + if (operation == nullptr + || _writer_busy.test_and_set(std::memory_order_acquire)) { + return false; + } + operation(context); + _writer_busy.clear(std::memory_order_release); + return true; + } + + private: + bool canAccess() const { + return _can_access == nullptr || _can_access(_context); + } + + Stream& _delegate; + TryWrite _try_write; + void* _context; + CanAccess _can_access; + std::atomic_flag _writer_busy = ATOMIC_FLAG_INIT; +}; + +// A whole-record view over a shared SingleAttemptNonBlockingStream. Unlike a +// generic wrapper, its capacity preflight uses the delegate's own writer gate, +// so Binary, terminal, logging, and mOTA producers cannot interleave between +// the capacity sample and the one low-level write. +template +class AtomicWholeRecordNonBlockingStream : public Stream { + public: + explicit AtomicWholeRecordNonBlockingStream( + SingleAttemptNonBlockingStream& delegate) : _delegate(delegate) {} + + int available() override { return _delegate.available(); } + int read() override { return _delegate.read(); } + int peek() override { return _delegate.peek(); } + void flush() override {} + 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; + constexpr size_t PRINTF_SCRATCH_SIZE = 256; + if (size == PRINTF_SCRATCH_SIZE && data[size - 1] == '\0') return 0; + return _delegate.writeWholeRecord(data, size); + } + + private: + SingleAttemptNonBlockingStream& _delegate; +}; + +// A bounded byte queue for functional text written through a single-attempt +// transport. Producer calls are admitted whole and never wait; service() keeps +// a byte offset across short delegate writes so an ordinary draining host sees +// the exact ordered stream. Once the queue fills, later records are dropped as +// a whole rather than blocking the application loop. +// +// Adafruit nRF52 Print::printf() formats into a 256-byte scratch buffer but can +// pass vsnprintf()'s larger required length to write(). The NUL at byte 255 is +// the only safe signal that such a record was truncated before this facade. +template +class BufferedNonBlockingWriteStream : public Stream { + public: + static_assert(CAPACITY > 1, "byte queue needs at least two bytes"); + static_assert(PRINTF_SCRATCH_SIZE > 1, + "printf scratch size needs at least two bytes"); + + explicit BufferedNonBlockingWriteStream(Stream& delegate) + : _delegate(delegate) {} + + int available() override { return _delegate.available(); } + int read() override { return _delegate.read(); } + int peek() override { return _delegate.peek(); } + + // Drain only currently available capacity. Never forward a potentially + // blocking delegate flush from the application loop. + void flush() override { service(); } + + int availableForWrite() override { + if (_busy.test_and_set(std::memory_order_acquire)) return 0; + const size_t free = CAPACITY - _count; + _busy.clear(std::memory_order_release); + return free > static_cast(INT_MAX) + ? INT_MAX + : static_cast(free); + } + + 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 > CAPACITY) return 0; + if (size >= PRINTF_SCRATCH_SIZE + && data[PRINTF_SCRATCH_SIZE - 1] == '\0') { + return 0; + } + if (_busy.test_and_set(std::memory_order_acquire)) return 0; + + drainUnlocked(); + if (size > CAPACITY - _count) { + _busy.clear(std::memory_order_release); + return 0; + } + + const size_t first = size < CAPACITY - _head + ? size + : CAPACITY - _head; + memcpy(&_buffer[_head], data, first); + if (first < size) memcpy(_buffer, data + first, size - first); + _head = (_head + size) % CAPACITY; + _count += size; + + drainUnlocked(); + _busy.clear(std::memory_order_release); + return size; + } + + // Called from the application loop. Retains every unwritten suffix for the + // next call and stops immediately when the delegate reports no progress. + size_t service() { + if (_busy.test_and_set(std::memory_order_acquire)) return 0; + const size_t written = drainUnlocked(); + _busy.clear(std::memory_order_release); + return written; + } + + void discardPending() { + if (_busy.test_and_set(std::memory_order_acquire)) return; + _head = 0; + _tail = 0; + _count = 0; + _busy.clear(std::memory_order_release); + } + + size_t queuedByteCount() { + if (_busy.test_and_set(std::memory_order_acquire)) return CAPACITY; + const size_t count = _count; + _busy.clear(std::memory_order_release); + return count; + } + + private: + size_t drainUnlocked() { + size_t total = 0; + while (_count > 0) { + const int available = _delegate.availableForWrite(); + if (available <= 0) break; + + size_t attempt = _count < CAPACITY - _tail + ? _count + : CAPACITY - _tail; + if (attempt > static_cast(available)) { + attempt = static_cast(available); + } + size_t written = _delegate.write(&_buffer[_tail], attempt); + if (written > attempt) written = attempt; + if (written == 0) break; + + _tail = (_tail + written) % CAPACITY; + _count -= written; + total += written; + if (written < attempt) break; + } + return total; + } + + Stream& _delegate; + uint8_t _buffer[CAPACITY]{}; + size_t _head = 0; + size_t _tail = 0; + size_t _count = 0; + std::atomic_flag _busy = ATOMIC_FLAG_INIT; +}; + +// A bounded, lossy record queue for transports whose low-level write must run +// in one owner task. Producers only copy complete records into this queue; they +// never touch the delegate. The owner calls drainOne() from its own task. This +// is particularly important for nRF52 TinyUSB: starting a CDC transfer from an +// application task can race control transfers handled by TinyUSB's high- +// priority task, while an unread host can leave that race armed indefinitely. +template +class TaskOwnedWriteStream : public Stream { + public: + static_assert(SLOT_COUNT >= 2, "record queue needs at least two slots"); + + explicit TaskOwnedWriteStream(Stream& delegate) : _delegate(delegate) {} + + int available() override { return _delegate.available(); } + int read() override { return _delegate.read(); } + int peek() override { return _delegate.peek(); } + + // Flushing is owned by the transport task. A producer must never re-enter + // the delegate through this facade. + void flush() override {} + + int availableForWrite() override { + const size_t head = _head.load(std::memory_order_relaxed); + const size_t next = increment(head); + return next == _tail.load(std::memory_order_acquire) + ? 0 + : static_cast(MAX_WRITE_SIZE); + } + + 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; + if (_producer_busy.test_and_set(std::memory_order_acquire)) return 0; + + const size_t head = _head.load(std::memory_order_relaxed); + const size_t next = increment(head); + if (next == _tail.load(std::memory_order_acquire)) { + _producer_busy.clear(std::memory_order_release); + return 0; + } + + Record& record = _records[head]; + memcpy(record.data, data, size); + record.size = size; + _head.store(next, std::memory_order_release); + _producer_busy.clear(std::memory_order_release); + return size; + } + + // Called only by the delegate's owner task. A short delegate write is + // discarded rather than retried, because replaying a prefix would duplicate + // bytes and diagnostics are intentionally best effort. + size_t drainOne() { + const size_t tail = _tail.load(std::memory_order_relaxed); + if (tail == _head.load(std::memory_order_acquire)) return 0; + + const Record& record = _records[tail]; + const int available = _delegate.availableForWrite(); + if (available < 0 || static_cast(available) < record.size) return 0; + + size_t written = _delegate.write(record.data, record.size); + if (written > record.size) written = record.size; + if (written > 0) { + _tail.store(increment(tail), std::memory_order_release); + } + return written; + } + + // Called by the owner task, or after its transport callback has been disabled + // so no consumer can race this cursor. Producers may publish concurrently; + // loading head before storing tail preserves that newly published record. + void discardPending() { + _tail.store(_head.load(std::memory_order_acquire), + std::memory_order_release); + } + + size_t queuedRecordCount() const { + const size_t head = _head.load(std::memory_order_acquire); + const size_t tail = _tail.load(std::memory_order_acquire); + return head >= tail ? head - tail : SLOT_COUNT - tail + head; + } + + private: + struct Record { + size_t size = 0; + uint8_t data[MAX_WRITE_SIZE]{}; + }; + + static constexpr size_t increment(size_t value) { + return value + 1 == SLOT_COUNT ? 0 : value + 1; + } + + Stream& _delegate; + Record _records[SLOT_COUNT]; + std::atomic _head{0}; + std::atomic _tail{0}; + std::atomic_flag _producer_busy = ATOMIC_FLAG_INIT; +}; + // 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 @@ -31,6 +393,14 @@ class WholeRecordNonBlockingStream : public Stream { size_t write(const uint8_t* data, size_t size) override { if (data == nullptr || size == 0 || size > MAX_WRITE_SIZE) return 0; + // Adafruit nRF52 Print::printf() formats into char[256], then forwards + // vsnprintf()'s required length without clamping it to that buffer. An + // exactly 256-character result therefore arrives here as 255 characters + // plus the formatter's trailing NUL. Drop that truncated record as a + // whole, just as we do for reported lengths greater than the buffer. + constexpr size_t PRINTF_SCRATCH_SIZE = 256; + if (size == PRINTF_SCRATCH_SIZE && data[size - 1] == '\0') 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. diff --git a/src/helpers/UsbAsciiBinarySwitch.h b/src/helpers/UsbAsciiBinarySwitch.h index aa41ad15..ee8b6aab 100644 --- a/src/helpers/UsbAsciiBinarySwitch.h +++ b/src/helpers/UsbAsciiBinarySwitch.h @@ -1,9 +1,45 @@ #pragma once #include +#include namespace mesh { +// OtaCli accepts both `folder` and `fold`, and collapses repeated ASCII spaces +// between its words. Recognize that same mutating command surface here so no +// alias can attach/detach the CDC0-backed folder outside the USB owner state +// machine. Status/list commands deliberately remain ordinary OTA CLI queries. +inline bool isUsbMotaOwnerTransitionCommand(const char* command, + size_t length) { + if (command == nullptr) return false; + size_t pos = 0; + while (pos < length && (command[pos] == ' ' || command[pos] == '\t')) ++pos; + if (length - pos < 3 || memcmp(command + pos, "ota", 3) != 0) return false; + pos += 3; + if (pos < length && command[pos] != ' ') return false; + + while (pos < length && command[pos] == ' ') ++pos; + const size_t noun_start = pos; + while (pos < length && command[pos] != ' ') ++pos; + const size_t noun_len = pos - noun_start; + if (!((noun_len == 6 + && memcmp(command + noun_start, "folder", 6) == 0) + || (noun_len == 4 + && memcmp(command + noun_start, "fold", 4) == 0))) { + return false; + } + + while (pos < length && command[pos] == ' ') ++pos; + const size_t action_len = length - pos; + return (action_len == 2 && memcmp(command + pos, "on", 2) == 0) + || (action_len == 3 && memcmp(command + pos, "off", 3) == 0); +} + +inline bool isUsbMotaOwnerTransitionCommand(const char* command) { + return command != nullptr + && isUsbMotaOwnerTransitionCommand(command, strlen(command)); +} + // Coordinates the Full Companion USB startup handoff without consuming the // '<' byte. ArduinoSerialInterface remains the only binary protocol parser. class UsbBinaryStartupProbe { diff --git a/src/helpers/UsbLogging.cpp b/src/helpers/UsbLogging.cpp index 1f8acc2a..a001ca88 100644 --- a/src/helpers/UsbLogging.cpp +++ b/src/helpers/UsbLogging.cpp @@ -8,6 +8,17 @@ #include "NonBlockingWriteStream.h" #endif +#if defined(NRF52_PLATFORM) && \ + (defined(ENABLE_USB_INTERFACE) || defined(OTA_FOLDER_SERIAL)) + #define MESH_NRF52_PRIMARY_USB_NONBLOCKING 1 + #include + #if !defined(CFG_TUD_CDC) || CFG_TUD_CDC < 1 + #error "nRF52 USB Companion/mOTA requires CFG_TUD_CDC >= 1" + #endif +#else + #define MESH_NRF52_PRIMARY_USB_NONBLOCKING 0 +#endif + #if defined(MESH_DUAL_CDC_LOGGING) #if !defined(COMPANION_FEATURE_DEDICATED_USB_LOGGING) || \ !COMPANION_FEATURE_DEDICATED_USB_LOGGING @@ -16,7 +27,9 @@ #if !defined(NRF52_PLATFORM) #error "MESH_DUAL_CDC_LOGGING is supported only by nRF52 Full Companion" #endif - #include + #if !defined(ENABLE_USB_INTERFACE) + #error "MESH_DUAL_CDC_LOGGING requires ENABLE_USB_INTERFACE" + #endif #if !defined(CFG_TUD_CDC) || CFG_TUD_CDC < 2 #error "MESH_DUAL_CDC_LOGGING requires CFG_TUD_CDC >= 2" #endif @@ -46,6 +59,100 @@ class NullUsbLoggingStream : public Stream { static NullUsbLoggingStream null_usb_logging_stream; +#if MESH_NRF52_PRIMARY_USB_NONBLOCKING +#if defined(ENABLE_USB_INTERFACE) +// Writers/readers may access CDC0 only when DTR is high and the application has +// completed the current close epoch. Incrementing reset_generation therefore +// closes the transport atomically; an older completion can publish only its +// older epoch and can never reopen across a newer close. +static std::atomic primary_usb_reset_generation{0}; +static std::atomic primary_usb_allowed_generation{0}; +static std::atomic primary_usb_line_state_dtr{false}; +static constexpr uint32_t primary_usb_session_settle_millis = 8; +static std::atomic primary_usb_reset_settle_until{0}; + +static uint32_t primaryUsbSessionGeneration() { + return primary_usb_reset_generation.load(std::memory_order_acquire); +} + +static bool canAccessPrimaryUsbSession(void*) { + return primary_usb_line_state_dtr.load(std::memory_order_acquire) + && primary_usb_allowed_generation.load(std::memory_order_acquire) + == primary_usb_reset_generation.load(std::memory_order_acquire); +} + +static void endPrimaryUsbHostSession(bool clear_cdc_fifos) { + const bool previous = primary_usb_line_state_dtr.exchange( + false, std::memory_order_acq_rel); + if (!previous) return; + + // Publish a short quiescence deadline before closing the generation gate. + // TinyUSB's TX clear does not cancel an endpoint transfer already in its + // class buffer, so the later final TX purge catches a racing old-session + // write. RX is purged synchronously below; bytes sent by a reopened host + // during the settle interval remain queued until the gate reopens. + primary_usb_reset_settle_until.store( + millis() + primary_usb_session_settle_millis, + std::memory_order_release); + primary_usb_reset_generation.fetch_add(1, std::memory_order_acq_rel); + if (clear_cdc_fifos) { + // The line-state callback runs before TinyUSB resets the device. A device + // unmount/remount boundary has already reset these class FIFOs. + tud_cdc_n_read_flush(0); + (void)tud_cdc_n_write_clear(0); + } +} +#endif +// Adafruit_USBD_CDC::write() retries from a yield loop until it has queued the +// complete request. A CDC host can change DTR or stop draining between the +// caller's availableForWrite() sample and that loop. Use TinyUSB's native +// single-attempt primitive so both Companion CDCs return short immediately and +// let their existing queue/whole-record policy decide what to retry. +static size_t writeTinyUsbCdcOnce(void* context, const uint8_t* data, + size_t size) { + const uint8_t instance = static_cast( + reinterpret_cast(context)); + if (data == nullptr || size == 0 || instance >= CFG_TUD_CDC) { + return 0; + } +#if defined(ENABLE_USB_INTERFACE) + if (instance == 0 && !canAccessPrimaryUsbSession(nullptr)) { + return 0; + } +#endif + if (!tud_cdc_n_connected(instance)) return 0; + + const size_t available = tud_cdc_n_write_available(instance); + const size_t attempt = size < available ? size : available; + if (attempt == 0) return 0; + return tud_cdc_n_write(instance, data, attempt); +} + +static SingleAttemptNonBlockingStream nonblocking_primary_usb_companion_port( + Serial, writeTinyUsbCdcOnce, reinterpret_cast(uintptr_t{0}) +#if defined(ENABLE_USB_INTERFACE) + , canAccessPrimaryUsbSession +#endif + ); +// SerialMotaSource emits one contiguous request whose largest valid frame is +// 11 bytes. Preflight that entire record before the sole TinyUSB write attempt; +// a busy FIFO drops the request cleanly and lets the transaction retry later. +static AtomicWholeRecordNonBlockingStream<11> + nonblocking_primary_usb_mota_port( + nonblocking_primary_usb_companion_port); +#if defined(ENABLE_USB_INTERFACE) +// Unlike Binary Companion frames, a terminal reply is produced as many Print +// calls whose return values are not consumed by the CLI. Retain one complete +// multi-line response while a normal host drains CDC0, but bound the memory and +// drop later records instead of ever waiting on an unread endpoint. +static BufferedNonBlockingWriteStream<4096> + buffered_primary_usb_terminal_port( + nonblocking_primary_usb_companion_port); +static uint32_t primary_usb_terminal_seen_reset_generation = 0; +static uint32_t primary_usb_terminal_taken_reset_generation = 0; +#endif +#endif + static void setPlatformDebugOutputEnabled(bool enabled) { #if defined(ESP32_PLATFORM) && defined(ENABLE_USB_INTERFACE) // Arduino-ESP32 log_e()/ESP-IDF diagnostics otherwise write straight to @@ -60,16 +167,210 @@ static void setPlatformDebugOutputEnabled(bool enabled) { #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 std::atomic dedicated_usb_logging_port_connected{false}; +static bool dedicated_usb_logging_sof_enabled = false; + +static constexpr char dedicated_usb_logging_descriptor[] = + "MeshCore Logging"; + +// Adafruit_USBD_CDC::begin() assigns "TinyUSB Serial" and immediately copies +// the interface descriptor into TinyUSBDevice's configuration buffer. Calling +// setStringDescriptor() after begin() therefore cannot rename that copied +// descriptor. Assign our string while the virtual descriptor builder is +// running, immediately before the core copies it instead. +class DedicatedUsbLoggingCdc : public Adafruit_USBD_CDC { + public: + uint16_t getInterfaceDescriptor(uint8_t itfnum_deprecated, uint8_t* buf, + uint16_t bufsize) override { + if (buf != nullptr) { + setStringDescriptor(dedicated_usb_logging_descriptor); + } + return Adafruit_USBD_CDC::getInterfaceDescriptor( + itfnum_deprecated, buf, bufsize); + } +}; + +static DedicatedUsbLoggingCdc dedicated_usb_logging_port; +static SingleAttemptNonBlockingStream single_attempt_dedicated_usb_logging_port( + dedicated_usb_logging_port, writeTinyUsbCdcOnce, + reinterpret_cast(uintptr_t{1})); +static TaskOwnedWriteStream<> usb_task_dedicated_usb_logging_port( + single_attempt_dedicated_usb_logging_port); static WholeRecordNonBlockingStream<> - nonblocking_dedicated_usb_logging_port(dedicated_usb_logging_port); + nonblocking_dedicated_usb_logging_port( + usb_task_dedicated_usb_logging_port); + +static constexpr char dedicated_usb_logging_identity[] = + "MeshCore USB logging port\r\n" + "USB CDC 1; interface 02; Linux stable suffix: -if02\r\n"; +static constexpr size_t dedicated_usb_logging_identity_size = + sizeof(dedicated_usb_logging_identity) - 1; +static_assert(dedicated_usb_logging_identity_size <= 256, + "USB identity marker exceeds the nonblocking record limit"); +static_assert(dedicated_usb_logging_identity_size + <= CFG_TUD_CDC_TX_BUFSIZE, + "USB identity marker exceeds the TinyUSB CDC TX FIFO"); +static std::atomic dedicated_usb_logging_reset_generation{0}; +static uint32_t dedicated_usb_logging_seen_reset_generation = 0; +// TinyUSB reports SET_CONTROL_LINE_STATE from its owner task. Keep the last +// DTR value independently of main-loop polling so a close/reopen cannot be +// collapsed into one continuously connected sample. +static std::atomic dedicated_usb_logging_line_state_dtr{false}; +static bool dedicated_usb_logging_usb_task_connected = false; +static size_t dedicated_usb_logging_identity_offset = 0; +// Windows configures line coding and then purges its COM buffers. Keep CDC1 +// silent for more than one Windows scheduling quantum so that purge cannot +// discard the identity marker and expose a later diagnostic as byte zero. +static constexpr uint8_t dedicated_usb_logging_host_settle_sofs = 50; +static uint8_t dedicated_usb_logging_quiet_sofs = 0; + +static void resetDedicatedUsbLoggingIdentity() { + dedicated_usb_logging_identity_offset = 0; +} + +static void resetDedicatedUsbLoggingUsbTaskState() { + dedicated_usb_logging_usb_task_connected = false; + resetDedicatedUsbLoggingIdentity(); + usb_task_dedicated_usb_logging_port.discardPending(); +} + +static void restartDedicatedUsbLoggingHostSession() { + // This callback and the SOF drain both run in TinyUSB's task. Gate producers + // before clearing both queues, then publish a generation so the next SOF + // repeats the reset after any producer which was already in flight. Clearing + // here also prevents bytes left in TinyUSB's TX FIFO from preceding the + // identity marker after a rapid reopen. + dedicated_usb_logging_port_connected.store(false, + std::memory_order_release); + (void)tud_cdc_n_write_clear(1); + resetDedicatedUsbLoggingUsbTaskState(); + dedicated_usb_logging_quiet_sofs = + dedicated_usb_logging_host_settle_sofs; + dedicated_usb_logging_reset_generation.fetch_add( + 1, std::memory_order_acq_rel); +} + +static void handleDedicatedUsbLoggingLineState(bool dtr) { + const bool previous = dedicated_usb_logging_line_state_dtr.exchange( + dtr, std::memory_order_acq_rel); + if (previous == dtr) return; + restartDedicatedUsbLoggingHostSession(); + // TinyUSB clears its SOF-consumer bits on every USB bus reset. A rapid + // reset/re-enumeration can leave the main loop's requested-state cache true + // throughout, so it would otherwise never ask TinyUSB to restore this + // callback. SET_CONTROL_LINE_STATE runs in TinyUSB's owner task after the + // new configuration is active, making the fresh DTR edge the exact place + // to rearm (or disable) CDC1's sole drain tick. + tud_sof_cb_enable(dtr && isUsbLoggingEnabled()); +} + +static void handleDedicatedUsbLoggingLineCoding() { + // Windows can close and immediately reopen a COM handle without exposing a + // distinct DTR-low interval to the device. It still configures line coding + // for the newly opened handle. Treat that owner-task callback as a fresh + // CDC1 host session only when DTR is already asserted; if it is not, the + // subsequent DTR-high edge performs the reset instead. + if (tud_cdc_n_connected(1)) { + restartDedicatedUsbLoggingHostSession(); + } +} + +// Called by TinyUSB's own high-priority task through tud_sof_cb(). Application +// writers only copy complete records into the queue above, so every CDC1 FIFO +// and endpoint operation happens in the same task as control transfers. The +// identity marker bypasses that producer queue: its cursor advances from the +// actual TinyUSB write result, not from queue acceptance. Sampling DTR here at +// 1 kHz also catches a close/reopen that occurs entirely between main-loop +// service calls. +void serviceDedicatedUsbLoggingFromUsbTask() { + const uint32_t reset_generation = + dedicated_usb_logging_reset_generation.load(std::memory_order_acquire); + if (reset_generation != dedicated_usb_logging_seen_reset_generation) { + resetDedicatedUsbLoggingUsbTaskState(); + dedicated_usb_logging_seen_reset_generation = reset_generation; + } + + const bool physical_connected = tud_cdc_n_connected(1); + const bool connected = isUsbLoggingEnabled() && physical_connected; + + if (!physical_connected) { + // A bus reset need not deliver an explicit DTR-low request. Make the next + // DTR-high callback an edge even when the previous host vanished abruptly. + dedicated_usb_logging_line_state_dtr.store( + false, std::memory_order_release); + } + if (!connected) { + dedicated_usb_logging_port_connected.store( + false, std::memory_order_release); + resetDedicatedUsbLoggingUsbTaskState(); + return; + } + + if (dedicated_usb_logging_quiet_sofs > 0) { + dedicated_usb_logging_port_connected.store( + false, std::memory_order_release); + // Drop anything already in flight from the prior session and anything a + // producer raced into the owner queue before the callback gated it. + (void)tud_cdc_n_write_clear(1); + resetDedicatedUsbLoggingUsbTaskState(); + --dedicated_usb_logging_quiet_sofs; + return; + } + + dedicated_usb_logging_port_connected.store( + true, std::memory_order_release); + + if (!dedicated_usb_logging_usb_task_connected) { + // Drop diagnostics from the prior host before sending byte zero of the + // marker to the new host. + usb_task_dedicated_usb_logging_port.discardPending(); + resetDedicatedUsbLoggingIdentity(); + dedicated_usb_logging_usb_task_connected = true; + } + + if (dedicated_usb_logging_identity_offset + < dedicated_usb_logging_identity_size) { + // This direct single-attempt call runs in the sole CDC1 endpoint owner. + // With the complete marker fitting in one TX FIFO, a short result can only + // occur around a connection transition; restart from byte zero for the + // next observed host instead of delivering only a suffix. + const size_t remaining = dedicated_usb_logging_identity_size + - dedicated_usb_logging_identity_offset; + if (single_attempt_dedicated_usb_logging_port.availableForWrite() + < static_cast(remaining)) { + (void)tud_cdc_n_write_flush(1); + return; + } + const size_t written = single_attempt_dedicated_usb_logging_port.write( + reinterpret_cast( + dedicated_usb_logging_identity + + dedicated_usb_logging_identity_offset), + remaining); + + dedicated_usb_logging_identity_offset = + detail::nextUsbLoggingIdentityOffset( + dedicated_usb_logging_identity_offset, remaining, written); + (void)tud_cdc_n_write_flush(1); + return; + } + + // If the host stops reading, the TinyUSB FIFO and this queue simply fill and + // later diagnostics are dropped without blocking either task. + usb_task_dedicated_usb_logging_port.drainOne(); + (void)tud_cdc_n_write_flush(1); +} #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. + #if MESH_NRF52_PRIMARY_USB_NONBLOCKING +static AtomicWholeRecordNonBlockingStream<> + nonblocking_primary_usb_logging_port( + nonblocking_primary_usb_companion_port); + #else static WholeRecordNonBlockingStream<> nonblocking_primary_usb_logging_port(Serial); + #endif #endif bool isUsbLoggingEnabled() { @@ -101,9 +402,6 @@ void beginUsbLoggingPort() { } dedicated_usb_logging_port.begin(115200); - // `begin()` installs the core's generic "TinyUSB Serial" name. Replace it - // before (re-)enumeration so host USB tools can distinguish the log endpoint. - dedicated_usb_logging_port.setStringDescriptor("MeshCore Logging"); dedicated_usb_logging_port_configured = true; dedicated_usb_logging_port_started = true; @@ -121,23 +419,50 @@ void beginUsbLoggingPort() { void serviceUsbLoggingPort() { #if defined(MESH_DUAL_CDC_LOGGING) - bool connected = false; - connected = dedicated_usb_logging_port_started + const bool connected = dedicated_usb_logging_port_started && dedicated_usb_logging_port.dtr(); - - if (connected && !dedicated_usb_logging_port_connected - && isUsbLoggingEnabled()) { - Stream& port = usbLoggingPort(); - port.println("MeshCore USB logging port"); - port.println("USB CDC 1; interface 02; Linux stable suffix: -if02"); + const bool should_service = connected && isUsbLoggingEnabled(); + // Only the TinyUSB owner task publishes a positive producer gate. The main + // loop may close it, but must not bypass the post-open quiet window. + if (!should_service) { + dedicated_usb_logging_port_connected.store( + false, std::memory_order_release); + } + if (!connected) { + dedicated_usb_logging_line_state_dtr.store( + false, std::memory_order_release); + } + + // SOF callbacks execute in TinyUSB's task and provide the bounded owner-task + // drain tick. This cached requested state converges ordinary opens/closes + // and runtime logging changes. TinyUSB clears the actual SOF-consumer bit on + // bus reset, so the owner-task DTR callback above also rearms it after each + // re-enumeration even when this sampled state never changed. Queue cursor + // mutation remains in TinyUSB's owner task, and the reset generation stays + // pending until a later reopen lets that callback reset before its first + // drain. + if (should_service != dedicated_usb_logging_sof_enabled) { + if (should_service) { + // Publish the new generation before the callback can run. + dedicated_usb_logging_reset_generation.fetch_add( + 1, std::memory_order_acq_rel); + tud_sof_cb_enable(true); + } else { + // Stop the consumer before publishing the next reset generation. The + // generation remains pending until a later reopen enables the callback. + tud_sof_cb_enable(false); + dedicated_usb_logging_reset_generation.fetch_add( + 1, std::memory_order_acq_rel); + } + dedicated_usb_logging_sof_enabled = should_service; } - dedicated_usb_logging_port_connected = connected; #endif } Stream& usbLoggingPort() { #if defined(MESH_DUAL_CDC_LOGGING) - if (isUsbLoggingEnabled() && dedicated_usb_logging_port_started) { + if (isUsbLoggingEnabled() && dedicated_usb_logging_port_started + && dedicated_usb_logging_port_connected.load(std::memory_order_acquire)) { return nonblocking_dedicated_usb_logging_port; } return null_usb_logging_stream; @@ -151,6 +476,98 @@ Stream& usbLoggingPort() { #endif } +Stream& usbCompanionPort() { +#if MESH_NRF52_PRIMARY_USB_NONBLOCKING + return nonblocking_primary_usb_companion_port; +#else + return Serial; +#endif +} + +Stream& usbMotaPort() { +#if MESH_NRF52_PRIMARY_USB_NONBLOCKING + return nonblocking_primary_usb_mota_port; +#else + return usbCompanionPort(); +#endif +} + +Stream& usbTerminalPort() { +#if defined(NRF52_PLATFORM) && defined(ENABLE_USB_INTERFACE) + return buffered_primary_usb_terminal_port; +#else + return usbCompanionPort(); +#endif +} + +void serviceUsbTerminalPort() { +#if defined(NRF52_PLATFORM) && defined(ENABLE_USB_INTERFACE) + const uint32_t reset_generation = + primaryUsbSessionGeneration(); + if (reset_generation != primary_usb_terminal_seen_reset_generation) { + buffered_primary_usb_terminal_port.discardPending(); + primary_usb_terminal_seen_reset_generation = reset_generation; + } + buffered_primary_usb_terminal_port.service(); +#endif +} + +void discardUsbTerminalOutput() { +#if defined(NRF52_PLATFORM) && defined(ENABLE_USB_INTERFACE) + buffered_primary_usb_terminal_port.discardPending(); +#endif +} + +bool hasPendingUsbTerminalOutput() { +#if defined(NRF52_PLATFORM) && defined(ENABLE_USB_INTERFACE) + return buffered_primary_usb_terminal_port.queuedByteCount() != 0; +#else + return false; +#endif +} + +bool takeUsbTerminalSessionReset() { +#if defined(NRF52_PLATFORM) && defined(ENABLE_USB_INTERFACE) + const uint32_t reset_generation = + primaryUsbSessionGeneration(); + if (reset_generation == primary_usb_terminal_taken_reset_generation) { + return false; + } + primary_usb_terminal_taken_reset_generation = reset_generation; + return true; +#else + return false; +#endif +} + +#if defined(NRF52_PLATFORM) && defined(ENABLE_USB_INTERFACE) +static void completePrimaryUsbSessionReset(void*) { + // This runs under the primary SingleAttempt stream's producer gate. It + // removes a write that raced the owner-task close callback before allowing + // any producer to address the reopened handle. RX was already purged when + // the close/line-coding boundary was captured. Do not purge it again here: + // hosts such as meshcli send APP_START immediately after opening the port, + // while this generation gate is deliberately settling. + (void)tud_cdc_n_write_clear(0); + const uint32_t cleaned_generation = + primary_usb_terminal_taken_reset_generation; + primary_usb_allowed_generation.store(cleaned_generation, + std::memory_order_release); +} +#endif + +bool tryCompleteUsbTerminalSessionReset() { +#if defined(NRF52_PLATFORM) && defined(ENABLE_USB_INTERFACE) + const uint32_t settle_until = + primary_usb_reset_settle_until.load(std::memory_order_acquire); + if ((int32_t)(millis() - settle_until) < 0) return false; + return nonblocking_primary_usb_companion_port.tryRunExclusive( + completePrimaryUsbSessionReset); +#else + return true; +#endif +} + bool hasDedicatedUsbLoggingPort() { #if defined(MESH_DUAL_CDC_LOGGING) return true; @@ -184,4 +601,91 @@ const char* usbLoggingPortDescription() { } } // namespace mesh + +#if defined(NRF52_PLATFORM) && defined(ENABLE_USB_INTERFACE) +// A protocol-only, weak-declaration-free translation unit supplies strong +// versions of TinyUSB's callbacks and forwards here. Preserve Adafruit's CDC0 +// 1200-baud touch behavior exactly, capture every CDC0 host-session close and +// USB device boundary, and give an optional CDC1 logging endpoint its exact +// reconnect edges. +extern "C" void meshTinyUsbCdcLineStateChanged(uint8_t instance, bool dtr, + bool rts) { + (void)rts; +#if defined(MESH_DUAL_CDC_LOGGING) + if (instance == 1) { + mesh::handleDedicatedUsbLoggingLineState(dtr); + return; + } +#endif + + if (instance == 0 && !dtr) { + // Discard raw input/output already admitted to TinyUSB. The generation + // change keeps every facade closed until the application resets its + // protocol owner and performs the final exclusive TX purge. + mesh::endPrimaryUsbHostSession(true); + cdc_line_coding_t coding; + tud_cdc_get_line_coding(&coding); + if (coding.bit_rate == 1200) { + TinyUSB_Port_EnterDFU(); + } + } else if (instance == 0) { + const bool was_open = mesh::primary_usb_line_state_dtr.load( + std::memory_order_acquire); + if (!was_open) { + // Purge any late bytes from the closed owner before publishing the fresh + // session. New-host input sent after SET_CONTROL_LINE_STATE completes is + // then retained while the application-side generation gate settles. + tud_cdc_n_read_flush(0); + mesh::primary_usb_line_state_dtr.store( + true, std::memory_order_release); + } + } +} + +extern "C" void meshTinyUsbDeviceSessionBoundary() { + // TinyUSB does not issue SET_CONTROL_LINE_STATE(DTR=0) for a hard unplug or + // bus reset. An unmount catches the former; the following mount catches a + // reset/reconfigure whose old DTR state survived in this application. The + // helper is edge-sensitive, so initial enumeration and the mount following + // an already observed unmount do not create a false reset. + mesh::endPrimaryUsbHostSession(false); +#if defined(MESH_DUAL_CDC_LOGGING) + mesh::handleDedicatedUsbLoggingLineState(false); +#endif +} + +extern "C" void meshTinyUsbCdcLineCodingChanged(uint8_t instance) { + if (instance == 0) { + // Windows may close/reopen a COM handle without presenting a distinct + // DTR-low interval to the device, but it configures line coding for the + // new handle. If CDC0 still appears open, treat this as a protocol-session + // boundary. Restore the physical DTR sample after closing the epoch so an + // intentional baud change on a continuously open handle recovers as soon + // as the application has reset its parsers. + if (mesh::primary_usb_line_state_dtr.load(std::memory_order_acquire)) { + mesh::endPrimaryUsbHostSession(true); + mesh::primary_usb_line_state_dtr.store( + tud_cdc_n_connected(0), std::memory_order_release); + } + return; + } +#if defined(MESH_DUAL_CDC_LOGGING) + if (instance == 1) { + mesh::handleDedicatedUsbLoggingLineCoding(); + } +#else + (void)instance; +#endif +} + +#if defined(MESH_DUAL_CDC_LOGGING) +// The weak-declaration-free bridge lives in UsbLoggingLineStateOverride.cpp. +// This forwarding target runs in TinyUSB's task, making CDC1 endpoint +// ownership explicit without inheriting TinyUSB's weak callback attribute. +extern "C" void meshTinyUsbStartOfFrame(uint32_t frame_count) { + (void)frame_count; + mesh::serviceDedicatedUsbLoggingFromUsbTask(); +} +#endif +#endif #endif diff --git a/src/helpers/UsbLogging.h b/src/helpers/UsbLogging.h index c2e308da..db0b5bd0 100644 --- a/src/helpers/UsbLogging.h +++ b/src/helpers/UsbLogging.h @@ -16,6 +16,20 @@ namespace mesh { +namespace detail { + +// Keep the identity-marker reconnect rule pure so host tests can exercise the +// DTR edge case without a TinyUSB device. The USB owner task passes the actual +// TinyUSB write result here. A short write means the old connection vanished +// mid-record; byte zero is the only safe starting point for the next host. +inline size_t nextUsbLoggingIdentityOffset(size_t current_offset, + size_t requested, + size_t written) { + return written == requested ? current_offset + written : 0; +} + +} // namespace detail + // Ordinary merged images start enabled. USB/Full Companion starts disabled to // protect framed traffic and restores its saved choice after preferences load. bool isUsbLoggingEnabled(); @@ -29,11 +43,37 @@ bool saveUsbLoggingBootPreference(bool enabled); // Start the optional dedicated USB logging interface. Ordinary and single-TTY // builds use Serial; nRF52 Full Companion uses a second CDC ACM port. void beginUsbLoggingPort(); -// Emit a short identity marker whenever a host opens the dedicated logging -// endpoint. The device can report its USB interface, but the host alone chooses -// names such as /dev/ttyACM1 or COM7. +// Enable owner-task service when a host opens the dedicated logging endpoint. +// That task emits the identity marker from the actual TinyUSB write result and +// retries a busy CDC TX FIFO without waiting. +// The device can report its USB interface, but the host alone chooses names +// such as /dev/ttyACM1 or COM7. void serviceUsbLoggingPort(); Stream& usbLoggingPort(); +// Primary USB Companion data stream. On nRF52 this uses one direct TinyUSB +// FIFO attempt per write so a host-side open/close race cannot trap the main +// loop in Adafruit_USBD_CDC::write(). Other platforms retain Serial. +Stream& usbCompanionPort(); +// Serial mOTA requests are binary records of at most 11 bytes. On nRF52 this +// facade admits a request only when the complete record fits in CDC0's current +// TX capacity, so a retry can never append to a prefix from the prior attempt. +Stream& usbMotaPort(); +// Functional ASCII output needs the same zero-wait USB primitive plus a +// bounded queue so a normal host receives complete multi-line replies. Service +// it from the application loop; discard it before changing the CDC protocol or +// after a host disconnect so stale text cannot prefix a later Binary session. +Stream& usbTerminalPort(); +void serviceUsbTerminalPort(); +void discardUsbTerminalOutput(); +bool hasPendingUsbTerminalOutput(); +// Consume an exact CDC0 DTR-low edge captured in TinyUSB's owner task. This is +// separate from polling the current DTR value: Windows can close and reopen a +// COM handle between two application-loop iterations. +bool takeUsbTerminalSessionReset(); +// After the application has reset its protocol state, atomically purge any +// CDC0 writer that raced the close callback and reopen the producer gate. A +// false result is a zero-wait busy indication; retry it next loop. +bool tryCompleteUsbTerminalSessionReset(); bool hasDedicatedUsbLoggingPort(); bool isDedicatedUsbLoggingPortConfigured(); bool usbLoggingInterfaceRestartRequired(); diff --git a/src/helpers/UsbLoggingLineStateOverride.cpp b/src/helpers/UsbLoggingLineStateOverride.cpp new file mode 100644 index 00000000..20f696bf --- /dev/null +++ b/src/helpers/UsbLoggingLineStateOverride.cpp @@ -0,0 +1,58 @@ +// Include only TinyUSB's CDC protocol types here. cdc_device.h and the Arduino +// TinyUSB headers declare these callbacks weak, and that attribute would carry +// onto our definitions. This translation unit intentionally supplies the +// strong callbacks which override Adafruit's weak defaults for nRF52 USB +// Companion builds. CDC0 needs exact close events even on single-CDC targets; +// dual-CDC builds additionally forward CDC1 reconnect events. +#if defined(ARDUINO) && defined(NRF52_PLATFORM) \ + && defined(ENABLE_USB_INTERFACE) +#include + +extern "C" void meshTinyUsbCdcLineStateChanged(uint8_t instance, bool dtr, + bool rts); +extern "C" void meshTinyUsbCdcLineCodingChanged(uint8_t instance); +extern "C" void meshTinyUsbDeviceSessionBoundary(); +#if defined(MESH_DUAL_CDC_LOGGING) +extern "C" void meshTinyUsbStartOfFrame(uint32_t frame_count); +// cdc_device.h would attach TinyUSB's weak callback declarations to the strong +// overrides in this translation unit. Declare only the non-callback API used +// below, retaining the exact TinyUSB signature without importing that header. +extern "C" uint32_t tud_cdc_n_write_flush(uint8_t itf); + +// The nRF core calls this weak hook from both the application task and the +// TinyUSB task. Never let those application-side yield()/delay() calls touch +// CDC1; its SOF bridge below is the sole endpoint owner. CDC0 retains the +// framework's ordinary opportunistic flush behavior. +extern "C" void TinyUSB_Device_FlushCDC(void) { + (void)tud_cdc_n_write_flush(0); +} +#endif + +// TinyUSB calls unmount for a hard unplug/configuration loss, but not for an +// ordinary bus-reset event. The subsequent mount is therefore also a session +// boundary; the forwarded handler is edge-sensitive so initial enumeration +// and an unmount/mount pair count at most once. +extern "C" void tud_mount_cb(void) { + meshTinyUsbDeviceSessionBoundary(); +} + +extern "C" void tud_umount_cb(void) { + meshTinyUsbDeviceSessionBoundary(); +} + +extern "C" void tud_cdc_line_state_cb(uint8_t instance, bool dtr, bool rts) { + meshTinyUsbCdcLineStateChanged(instance, dtr, rts); +} + +extern "C" void tud_cdc_line_coding_cb(uint8_t instance, + cdc_line_coding_t const* line_coding) { + (void)line_coding; + meshTinyUsbCdcLineCodingChanged(instance); +} + +#if defined(MESH_DUAL_CDC_LOGGING) +extern "C" void tud_sof_cb(uint32_t frame_count) { + meshTinyUsbStartOfFrame(frame_count); +} +#endif +#endif diff --git a/src/helpers/ota/OtaContext.h b/src/helpers/ota/OtaContext.h index f6a23d45..c83d565c 100644 --- a/src/helpers/ota/OtaContext.h +++ b/src/helpers/ota/OtaContext.h @@ -23,11 +23,27 @@ #if defined(OTA_FOLDER_SERIAL) #include "MotaSourceSerial.h" // relay an external folder served by a host daemon over the USB serial #ifndef OTA_FOLDER_SERIAL_STREAM - #define OTA_FOLDER_SERIAL_STREAM Serial // default: the same USB console the CLI uses (no extra HW) + #if defined(NRF52_PLATFORM) + // Adafruit's CDC write waits while an open host stops reading. Every + // default nRF52 serial-folder request uses a whole-record facade over one + // TinyUSB attempt; dedicated-UART overrides retain their normal stream. + #include "../UsbLogging.h" + #define OTA_FOLDER_SERIAL_STREAM ::mesh::usbMotaPort() + #define MESH_OTA_FOLDER_SERIAL_NONBLOCKING_USB 1 + #else + #define OTA_FOLDER_SERIAL_STREAM Serial // default: shared USB console + #endif #endif #ifndef OTA_FOLDER_SERIAL_BAUD #define OTA_FOLDER_SERIAL_BAUD 115200 #endif + #ifndef OTA_FOLDER_SERIAL_WRITE_POLICY + #if defined(MESH_OTA_FOLDER_SERIAL_NONBLOCKING_USB) + #define OTA_FOLDER_SERIAL_WRITE_POLICY MotaStreamWritePolicy::NoFlush + #else + #define OTA_FOLDER_SERIAL_WRITE_POLICY MotaStreamWritePolicy::FlushTransmit + #endif + #endif // The console Serial is already begun by the example; a DEDICATED UART (override the stream) needs init, // so define OTA_FOLDER_SERIAL_BEGIN to have attach_folder() call .begin(baud) on it. #endif @@ -394,7 +410,7 @@ struct OtaContext { #if defined(OTA_FOLDER_SERIAL) bool attach_folder(char* msg, size_t cap) { static SerialMotaSource src(OTA_FOLDER_SERIAL_STREAM, - MotaStreamWritePolicy::FlushTransmit, 600); + OTA_FOLDER_SERIAL_WRITE_POLICY, 600); #ifdef OTA_FOLDER_SERIAL_BEGIN OTA_FOLDER_SERIAL_STREAM.begin(OTA_FOLDER_SERIAL_BAUD); // dedicated UART; console is already up #endif diff --git a/src/helpers/ota/OtaStoreQspiNrf52.cpp b/src/helpers/ota/OtaStoreQspiNrf52.cpp index e89dd7a3..d0ee842c 100644 --- a/src/helpers/ota/OtaStoreQspiNrf52.cpp +++ b/src/helpers/ota/OtaStoreQspiNrf52.cpp @@ -9,6 +9,9 @@ #include "nrf.h" #include +#if defined(OTA_QSPI_RAK3401_RADIO_BUS_HANDOFF) + #include +#endif #include namespace mesh { @@ -162,6 +165,43 @@ static void configure_qspi_gpio(const nrf_qspi_pins_t& pins) { NRF_GPIO_PIN_NOPULL, NRF_GPIO_PIN_H0H1, NRF_GPIO_PIN_NOSENSE); } +#if defined(OTA_QSPI_RAK3401_RADIO_BUS_HANDOFF) +static void disconnect_qspi_pins() { + const nrf_qspi_pins_t disconnected = { + NRF_QSPI_PIN_NOT_CONNECTED, NRF_QSPI_PIN_NOT_CONNECTED, + NRF_QSPI_PIN_NOT_CONNECTED, NRF_QSPI_PIN_NOT_CONNECTED, + NRF_QSPI_PIN_NOT_CONNECTED, NRF_QSPI_PIN_NOT_CONNECTED + }; + nrf_qspi_pins_set(NRF_QSPI, &disconnected); +} + +static void acquire_rak3401_radio_bus() { + // RadioLib transactions are synchronous in the main loop and its DIO1 ISR + // only records state, so no radio SPI transfer can overlap this handoff. + // The SX1262 may remain in autonomous RX while its distinct NSS is high. + // Reinitializing SPI1 after QSPI releases the pads is necessary because the + // QSPI GPIO setup changes their direction and drive configuration. + const uint8_t flash_cs = qspi_cs_pin(); + nrf_gpio_pin_set(flash_cs); + nrf_gpio_cfg(flash_cs, NRF_GPIO_PIN_DIR_OUTPUT, NRF_GPIO_PIN_INPUT_DISCONNECT, + NRF_GPIO_PIN_NOPULL, NRF_GPIO_PIN_H0H1, NRF_GPIO_PIN_NOSENSE); + pinMode(P_LORA_NSS, OUTPUT); + digitalWrite(P_LORA_NSS, HIGH); + SPI1.end(); +} + +static void release_rak3401_radio_bus() { + const uint8_t flash_cs = qspi_cs_pin(); + nrf_gpio_pin_set(flash_cs); + nrf_gpio_cfg(flash_cs, NRF_GPIO_PIN_DIR_OUTPUT, NRF_GPIO_PIN_INPUT_DISCONNECT, + NRF_GPIO_PIN_NOPULL, NRF_GPIO_PIN_H0H1, NRF_GPIO_PIN_NOSENSE); + disconnect_qspi_pins(); + SPI1.begin(); + pinMode(P_LORA_NSS, OUTPUT); + digitalWrite(P_LORA_NSS, HIGH); +} +#endif + static void wake_qspi_flash_before_activate(const nrf_qspi_pins_t& pins) { // nRF52840 TASKS_ACTIVATE communicates with the NOR before CINSTR is // available. A flash put to sleep by our preceding 0xB9 ignores that @@ -297,6 +337,13 @@ bool OtaStoreQspiNrf52::waitMemoryReady(uint32_t timeout_ms) { bool OtaStoreQspiNrf52::ensureFlash() { if (_qspi_ready) return true; +#if defined(OTA_QSPI_RAK3401_RADIO_BUS_HANDOFF) + if (!_shared_radio_bus_acquired) { + acquire_rak3401_radio_bus(); + _shared_radio_bus_acquired = true; + } +#endif + #if defined(OTA_QSPI_POWER_PIN) pinMode(OTA_QSPI_POWER_PIN, OUTPUT); digitalWrite(OTA_QSPI_POWER_PIN, HIGH); @@ -447,12 +494,16 @@ void OtaStoreQspiNrf52::releaseFlash() { nrf_qspi_disable(NRF_QSPI); nrf_qspi_event_clear(NRF_QSPI, NRF_QSPI_EVENT_READY); if (_memory_operation_pending) { +#if defined(OTA_QSPI_RAK3401_RADIO_BUS_HANDOFF) + disconnect_qspi_pins(); +#else const nrf_qspi_pins_t disconnected = { NRF_QSPI_PIN_NOT_CONNECTED, NRF_QSPI_PIN_NOT_CONNECTED, NRF_QSPI_PIN_NOT_CONNECTED, NRF_QSPI_PIN_NOT_CONNECTED, NRF_QSPI_PIN_NOT_CONNECTED, NRF_QSPI_PIN_NOT_CONNECTED }; nrf_qspi_pins_set(NRF_QSPI, &disconnected); +#endif } } _qspi_active = false; @@ -465,6 +516,12 @@ void OtaStoreQspiNrf52::releaseFlash() { #elif defined(QSPI_FLASH_EN) if (!_memory_operation_pending) digitalWrite(QSPI_FLASH_EN, LOW); #endif +#if defined(OTA_QSPI_RAK3401_RADIO_BUS_HANDOFF) + if (_shared_radio_bus_acquired) { + release_rak3401_radio_bus(); + _shared_radio_bus_acquired = false; + } +#endif } bool OtaStoreQspiNrf52::dmaReadAligned(uint32_t address, uint32_t length) { diff --git a/src/helpers/ota/OtaStoreQspiNrf52.h b/src/helpers/ota/OtaStoreQspiNrf52.h index ded474f4..d7a8fbd6 100644 --- a/src/helpers/ota/OtaStoreQspiNrf52.h +++ b/src/helpers/ota/OtaStoreQspiNrf52.h @@ -15,7 +15,21 @@ #endif #if defined(OTA_QSPI_SHARED_WISBLOCK_SPI) && defined(RAK_3401) -#error "RAK3401's RAK13302 radio already owns the WisBlock SPI bus/chip-select" + #if !defined(OTA_QSPI_RAK3401_RADIO_BUS_HANDOFF) + #error "RAK3401 WisBlock SPI staging requires the explicit RAK13302 bus-handoff contract" + #endif + #if !defined(OTA_QSPI_CS_ARDUINO_PIN) + #error "RAK3401 shared-SPI staging requires an explicit flash chip-select" + #elif OTA_QSPI_CS_ARDUINO_PIN == 26 + #error "RAK3401 W25 flash and RAK13302 radio must use different chip-selects" + #endif + #if !defined(OTA_QSPI_SCK_ARDUINO_PIN) || !defined(OTA_QSPI_IO0_ARDUINO_PIN) || \ + !defined(OTA_QSPI_IO1_ARDUINO_PIN) + #error "RAK3401 radio-bus handoff requires explicit Arduino SPI pin overrides" + #elif OTA_QSPI_SCK_ARDUINO_PIN != 3 || OTA_QSPI_IO0_ARDUINO_PIN != 30 || \ + OTA_QSPI_IO1_ARDUINO_PIN != 29 || OTA_QSPI_CS_ARDUINO_PIN != 31 + #error "RAK3401 W25 handoff is bound to SCK P0.03, MOSI P0.30, MISO P0.29, CS P0.31" + #endif #endif namespace mesh { @@ -113,6 +127,9 @@ class OtaStoreQspiNrf52 : public OtaStore { bool _qspi_active = false; bool _qspi_awake = false; bool _qspi_ready = false; +#if defined(OTA_QSPI_RAK3401_RADIO_BUS_HANDOFF) + bool _shared_radio_bus_acquired = false; +#endif bool _memory_operation_pending = false; bool _io_ok = true; bool _meta_dirty = false; diff --git a/src/helpers/ota/OtaTargets.h b/src/helpers/ota/OtaTargets.h index b52d7d3e..ba795417 100644 --- a/src/helpers/ota/OtaTargets.h +++ b/src/helpers/ota/OtaTargets.h @@ -2,7 +2,7 @@ #include // AUTO-GENERATED by tools/mota/gen_targets.py - do not edit by hand. -// 609 OTA-capable build targets. Maps target_id (= sha2-256:4 of the target name, LE uint32) +// 612 OTA-capable build targets. Maps target_id (= sha2-256:4 of the target name, LE uint32) // to the human-readable env name, so a node/tool can name a target seen over the air WITHOUT // transmitting the string in the .mota / LoRa protocol. Regenerate when the OTA env set changes. // Size-constrained receivers can set OTA_TARGET_NAME_TABLE=0. They still match targets by ID; @@ -444,6 +444,7 @@ inline const char* ota_target_env_name(uint32_t target_id) { { 0xced7e127, "RAK_3112_sensor" }, { 0x6925aaf0, "RAK_3112_terminal_chat" }, { 0x2fa509c1, "RAK_3401_repeater_lora_ota_no_external_sensors" }, + { 0xa52ba3c8, "RAK_3401_repeater_rak13302_w25q16_lora_ota" }, { 0x9f4d58ac, "RAK_4631_companion_radio_ble" }, { 0x7a55dec3, "RAK_4631_companion_radio_ethernet" }, { 0x5e69738a, "RAK_4631_companion_radio_full" }, @@ -453,6 +454,7 @@ inline const char* ota_target_env_name(uint32_t target_id) { { 0x41e33a97, "RAK_4631_repeater_bridge_rs232_serial2_lora_ota_no_external_sensors" }, { 0x29a0da19, "RAK_4631_repeater_lora_ota_no_external_sensors" }, { 0x29bc38dc, "RAK_4631_repeater_rak15001_slot_c_lora_ota" }, + { 0xc62da371, "RAK_4631_repeater_w25q16_lora_ota" }, { 0xf1d3c5a8, "RAK_4631_terminal_chat" }, { 0xc6d55752, "RAK_WisMesh_Tag_companion_radio_ble" }, { 0x60683191, "RAK_WisMesh_Tag_companion_radio_usb" }, @@ -463,6 +465,7 @@ inline const char* ota_target_env_name(uint32_t target_id) { { 0x51fa1a49, "RAK_WisMesh_Tag_sensor" }, { 0xdb73638b, "SenseCap_Solar_repeater" }, { 0xd904c2d5, "SenseCapIndicator-ESPNow_comp_radio_usb" }, + { 0x3a14aac7, "SenseCapIndicator-LoRa-N16R2_comp_radio_usb_wifi" }, { 0x620569f4, "SenseCapIndicator-LoRa_comp_radio_usb" }, { 0x08483f6b, "SenseCapIndicator-LoRa_comp_radio_usb_wifi" }, { 0x0db48f35, "solarxiao_30S_companion_radio_ble" }, diff --git a/src/helpers/ui/MomentaryButton.cpp b/src/helpers/ui/MomentaryButton.cpp index 9d01e5b0..128cd1ef 100644 --- a/src/helpers/ui/MomentaryButton.cpp +++ b/src/helpers/ui/MomentaryButton.cpp @@ -1,6 +1,23 @@ #include "MomentaryButton.h" #define MULTI_CLICK_WINDOW_MS 280 +#define BUTTON_DEBOUNCE_MS 25 + +#if defined(NRF52_PLATFORM) && \ + defined(MOMENTARY_BUTTON_WAKE_FROM_SLEEP) && \ + MOMENTARY_BUTTON_WAKE_FROM_SLEEP +namespace { + +// nRF52 companion builds use sd_app_evt_wait() between loop iterations. The +// button state machine remains intentionally polling-based, but it still needs +// a GPIO event to wake that poller. Both edges matter: a falling edge records +// button-down and a rising edge records release. The companion loop stays +// awake while needsPolling() reports a gesture deadline so long/multi-click +// timing can complete without another GPIO edge. +void wakeMomentaryButtonPoller() {} + +} // namespace +#endif MomentaryButton::MomentaryButton(int8_t pin, int long_press_millis, bool reverse, bool pulldownup, bool multiclick) { _pin = pin; @@ -11,10 +28,14 @@ MomentaryButton::MomentaryButton(int8_t pin, int long_press_millis, bool reverse cancel = 0; _long_millis = long_press_millis; _threshold = 0; + _press_active = false; _click_count = 0; _last_click_time = 0; _multi_click_window = multiclick ? MULTI_CLICK_WINDOW_MS : 0; _pending_click = false; + _candidate_level = prev; + _candidate_since = 0; + _debouncing = false; } MomentaryButton::MomentaryButton(int8_t pin, int long_press_millis, int analog_threshold) { @@ -26,15 +47,26 @@ MomentaryButton::MomentaryButton(int8_t pin, int long_press_millis, int analog_t cancel = 0; _long_millis = long_press_millis; _threshold = analog_threshold; + _press_active = false; _click_count = 0; _last_click_time = 0; _multi_click_window = MULTI_CLICK_WINDOW_MS; _pending_click = false; + _candidate_level = prev; + _candidate_since = 0; + _debouncing = false; } void MomentaryButton::begin() { if (_pin >= 0 && _threshold == 0) { pinMode(_pin, _pull ? (_reverse ? INPUT_PULLUP : INPUT_PULLDOWN) : INPUT); +#if defined(NRF52_PLATFORM) && \ + defined(MOMENTARY_BUTTON_WAKE_FROM_SLEEP) && \ + MOMENTARY_BUTTON_WAKE_FROM_SLEEP + // The ISR deliberately owns no button logic; it only makes a sleeping + // event-driven main loop run the normal debounced polling state machine. + attachInterrupt((uint32_t)_pin, wakeMomentaryButtonPoller, CHANGE); +#endif } } @@ -46,6 +78,7 @@ bool MomentaryButton::isPressed() const { void MomentaryButton::cancelClick() { cancel = 1; down_at = 0; + _press_active = false; _click_count = 0; _last_click_time = 0; _pending_click = false; @@ -66,29 +99,52 @@ int MomentaryButton::check(bool repeat_click) { if (_pin < 0) return BUTTON_EVENT_NONE; int event = BUTTON_EVENT_NONE; - int btn = _threshold > 0 ? (analogRead(_pin) < _threshold) : digitalRead(_pin); + const uint32_t now = millis(); + const int raw_btn = + _threshold > 0 ? (analogRead(_pin) < _threshold) : digitalRead(_pin); + + // Treat prev as the stable electrical level. A raw transition must remain + // unchanged for a short interval before it can alter gesture state. Without + // this, one physical release can be counted two or three times and a short + // click can silently turn into KEY_PREV or a triple-click action. + int btn = prev; + if (raw_btn == prev) { + _debouncing = false; + } else if (!_debouncing || raw_btn != _candidate_level) { + _candidate_level = raw_btn; + _candidate_since = now; + _debouncing = true; + } else if ((uint32_t)(now - _candidate_since) >= BUTTON_DEBOUNCE_MS) { + btn = raw_btn; + _debouncing = false; + } + if (btn != prev) { if (isPressed(btn)) { - down_at = millis(); + down_at = now; + _press_active = true; } else { // button UP - if (_long_millis > 0) { - if (down_at > 0 && (unsigned long)(millis() - down_at) < _long_millis) { // only a CLICK if still within the long_press millis - _click_count++; - _last_click_time = millis(); - _pending_click = true; - } - } else { + if (!cancel && _press_active && _long_millis > 0) { + if ((uint32_t)(now - down_at) >= (uint32_t)_long_millis) { + // Normally the polling loop reports this while the button is still + // down. Also handle release-only polling so event-driven platforms + // cannot lose a long press if no intermediate poll occurred. + event = BUTTON_EVENT_LONG_PRESS; + _click_count = 0; + _last_click_time = 0; + _pending_click = false; + } else { _click_count++; - _last_click_time = millis(); + _last_click_time = now; _pending_click = true; + } + } else if (!cancel && _press_active) { + _click_count++; + _last_click_time = now; + _pending_click = true; } - if (event == BUTTON_EVENT_CLICK && cancel) { - event = BUTTON_EVENT_NONE; - _click_count = 0; - _last_click_time = 0; - _pending_click = false; - } + _press_active = false; down_at = 0; } prev = btn; @@ -97,27 +153,38 @@ int MomentaryButton::check(bool repeat_click) { cancel = 0; } - if (_long_millis > 0 && down_at > 0 && (unsigned long)(millis() - down_at) >= _long_millis) { + if (_long_millis > 0 && _press_active + && (uint32_t)(now - down_at) >= (uint32_t)_long_millis) { if (_pending_click) { // long press during multi-click detection - cancel pending clicks cancelClick(); } else { event = BUTTON_EVENT_LONG_PRESS; + _press_active = false; down_at = 0; _click_count = 0; _last_click_time = 0; _pending_click = false; } } - if (down_at > 0 && repeat_click) { - unsigned long diff = (unsigned long)(millis() - down_at); + if (_press_active && repeat_click) { + uint32_t diff = (uint32_t)(now - down_at); if (diff >= 700) { event = BUTTON_EVENT_CLICK; // wait 700 millis before repeating the click events } } - if (_pending_click && (millis() - _last_click_time) >= _multi_click_window) { - if (down_at > 0) { + if (_pending_click + && (uint32_t)(now - _last_click_time) >= (uint32_t)_multi_click_window) { + // A press candidate first observed before the deadline may legitimately + // become the next click even if its debounce interval crosses the window. + // A candidate first observed at/after the deadline must not revive and + // merge an already-expired sequence. + const bool press_candidate_in_window = _debouncing + && isPressed(_candidate_level) + && (uint32_t)(_candidate_since - _last_click_time) + < (uint32_t)_multi_click_window; + if (press_candidate_in_window || _press_active) { // still pressed - wait for button release before processing clicks return event; } @@ -142,4 +209,4 @@ int MomentaryButton::check(bool repeat_click) { } return event; -} \ No newline at end of file +} diff --git a/src/helpers/ui/MomentaryButton.h b/src/helpers/ui/MomentaryButton.h index 351545ce..1d573e91 100644 --- a/src/helpers/ui/MomentaryButton.h +++ b/src/helpers/ui/MomentaryButton.h @@ -14,11 +14,15 @@ class MomentaryButton { bool _reverse, _pull; int _long_millis; int _threshold; // analog mode - unsigned long down_at; + uint32_t down_at; + bool _press_active; uint8_t _click_count; - unsigned long _last_click_time; + uint32_t _last_click_time; int _multi_click_window; bool _pending_click; + int8_t _candidate_level; + uint32_t _candidate_since; + bool _debouncing; bool isPressed(int level) const; @@ -30,4 +34,7 @@ public: void cancelClick(); // suppress next BUTTON_EVENT_CLICK (if already in DOWN state) uint8_t getPin() { return _pin; } bool isPressed() const; + bool needsPolling() const { + return _debouncing || _press_active || _pending_click; + } }; diff --git a/src/helpers/ui/ST7735Display.cpp b/src/helpers/ui/ST7735Display.cpp index b0e64cbd..c83ee71b 100644 --- a/src/helpers/ui/ST7735Display.cpp +++ b/src/helpers/ui/ST7735Display.cpp @@ -2,6 +2,10 @@ #include "../UsbLogging.h" #include +#if defined(NRF52_PLATFORM) + #include +#endif + //#include // Optimised ST7735 display driver, derived from Adafruit_ST7735 library. @@ -462,12 +466,40 @@ bool ST7735Display::begin() { if (!_isOn) { if (_peripher_power) _peripher_power->claim(); - delay(100); // TEMP!! + delay(100); // allow the peripheral rail to stabilize pinMode(PIN_TFT_RST, OUTPUT); pinMode(PIN_TFT_CS, OUTPUT); pinMode(PIN_TFT_DC, OUTPUT); + // Preload the inactive latch before changing direction. This prevents an + // active-low backlight from flashing while the controller initializes. + digitalWrite(PIN_TFT_LEDA_CTL, !PIN_TFT_LEDA_CTL_ACTIVE); pinMode(PIN_TFT_LEDA_CTL, OUTPUT); + // turnOff() releases the TFT bus pins to prevent back-powering. Restore + // them explicitly before touching SPI: on Adafruit nRF52, SPIClass::begin() + // is intentionally a no-op after the first initialization and therefore + // cannot repair pin modes that application power saving changed. + pinMode(PIN_TFT_SDA, OUTPUT); + pinMode(PIN_TFT_SCL, OUTPUT); + +#if defined(NRF52_PLATFORM) + // SPIClass::begin() deliberately returns early after its first call. Match + // the Adafruit nRF52 SPI core's high-drive GPIO setup here so a display + // wake repairs the bus even while the SPI object remains initialized. + nrf_gpio_cfg(PIN_TFT_SCL, + NRF_GPIO_PIN_DIR_OUTPUT, + NRF_GPIO_PIN_INPUT_CONNECT, + NRF_GPIO_PIN_NOPULL, + NRF_GPIO_PIN_H0H1, + NRF_GPIO_PIN_NOSENSE); + nrf_gpio_cfg(PIN_TFT_SDA, + NRF_GPIO_PIN_DIR_OUTPUT, + NRF_GPIO_PIN_INPUT_DISCONNECT, + NRF_GPIO_PIN_NOPULL, + NRF_GPIO_PIN_H0H1, + NRF_GPIO_PIN_NOSENSE); +#endif + #ifdef ESP_PLATFORM _spi->begin(PIN_TFT_SCL, -1 /* _miso */, PIN_TFT_SDA /* _mosi */, -1); #else @@ -487,7 +519,19 @@ bool ST7735Display::begin() { _resetAndInit(); + // Re-enter normal display mode explicitly and let the controller settle + // before priming its display RAM. A framebuffer transfer made only before + // DISPON can be lost while the panel is completing wake initialization. + sendCommand(ST77XX_NORON); + delay(10); sendCommand(ST77XX_DISPON); + delay(100); + + // Prime the now-active controller with the retained UI frame before enabling + // the backlight. This makes a button-only wake visible even when the UI has + // no other reason to redraw; a newly allocated sprite is already zeroed. + endFrame(); + digitalWrite(PIN_TFT_LEDA_CTL, PIN_TFT_LEDA_CTL_ACTIVE); _isOn = true; } @@ -497,13 +541,15 @@ bool ST7735Display::begin() { void ST7735Display::_resetAndInit() { if (!spriteReady()) return; - // Pulse Reset low for 10ms + // Pulse reset low, then give the controller its full reset-release recovery + // time before sending SLPOUT. Shorter delays can work on a cold panel but + // fail intermittently when the shared peripheral rail has only just cycled. digitalWrite(PIN_TFT_RST, HIGH); delay(2); digitalWrite(PIN_TFT_RST, LOW); delay(10); digitalWrite(PIN_TFT_RST, HIGH); - delay(2); + delay(120); // run init commands displayInit(Rcmd1); @@ -517,13 +563,6 @@ void ST7735Display::_resetAndInit() { displayInit(Rcmd3); setRotation(DISPLAY_ROTATION); - // clear the buffer before display on - sprite->fillScreen(ST77XX_BLACK); - endFrame(); - - // turn on backlight - digitalWrite(PIN_TFT_LEDA_CTL, PIN_TFT_LEDA_CTL_ACTIVE); - } void ST7735Display::turnOn() { @@ -540,7 +579,9 @@ void ST7735Display::turnOff() { // Now turn off the backlight digitalWrite(PIN_TFT_LEDA_CTL, !PIN_TFT_LEDA_CTL_ACTIVE); - // Prevent back-powering to save 2.8 mA + // Prevent back-powering to save 2.8 mA. Keep the SPI object initialized: + // begin() restores these GPIO directions explicitly on the next wake, and + // retaining the peripheral avoids an unchecked nrfx uninit/reinit cycle. pinMode(PIN_TFT_CS, INPUT); pinMode(PIN_TFT_DC, INPUT); pinMode(PIN_TFT_SDA, INPUT); diff --git a/test/mocks/Arduino.h b/test/mocks/Arduino.h index 28affb96..0a8aea49 100644 --- a/test/mocks/Arduino.h +++ b/test/mocks/Arduino.h @@ -11,9 +11,12 @@ constexpr uint8_t LOW = 0; constexpr uint8_t HIGH = 1; constexpr uint8_t INPUT = 0; constexpr uint8_t OUTPUT = 1; +constexpr uint8_t INPUT_PULLUP = 2; +constexpr uint8_t INPUT_PULLDOWN = 3; inline uint8_t g_mock_pin_modes[64] = {}; inline uint8_t g_mock_pin_levels[64] = {}; +inline int g_mock_analog_levels[64] = {}; using std::isnan; @@ -46,10 +49,15 @@ inline int digitalRead(uint8_t pin) { return pin < 64 ? g_mock_pin_levels[pin] : LOW; } +inline int analogRead(uint8_t pin) { + return pin < 64 ? g_mock_analog_levels[pin] : 0; +} + inline void resetArduinoMock() { g_mock_millis = 0; for (uint8_t pin = 0; pin < 64; pin++) { g_mock_pin_modes[pin] = INPUT; g_mock_pin_levels[pin] = LOW; + g_mock_analog_levels[pin] = 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 a53834fa..5f20666a 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")); + EXPECT_FALSE(allowed("ota fold on")); + EXPECT_FALSE(allowed("ota fold off")); + EXPECT_FALSE(allowed("ota folder on")); // 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")); diff --git a/test/test_companion_frame_queue/test_companion_frame_queue.cpp b/test/test_companion_frame_queue/test_companion_frame_queue.cpp index 52db3110..c9ce8d20 100644 --- a/test/test_companion_frame_queue/test_companion_frame_queue.cpp +++ b/test/test_companion_frame_queue/test_companion_frame_queue.cpp @@ -85,6 +85,22 @@ TEST(CompanionFrameQueue, UnknownPushDefaultsToRequired) { EXPECT_FALSE(mesh::companionFrameRequiresDelivery(&packet_log, 1)); } +TEST(CompanionFrameQueue, QueuePriorityDoesNotImplyRequesterOwnership) { + uint8_t response = 0x05; + uint8_t login_result = 0x85; + uint8_t message_waiting = 0x83; + uint8_t control_data = 0x8E; + uint8_t unknown = 0xF1; + + EXPECT_TRUE(mesh::companionFrameUsesRequesterRoute(&response, 1)); + EXPECT_TRUE(mesh::companionFrameUsesRequesterRoute(&login_result, 1)); + EXPECT_FALSE(mesh::companionFrameUsesRequesterRoute(&message_waiting, 1)); + EXPECT_FALSE(mesh::companionFrameUsesRequesterRoute(&control_data, 1)); + EXPECT_FALSE(mesh::companionFrameUsesRequesterRoute(&unknown, 1)); + EXPECT_TRUE(mesh::companionFrameRequiresDelivery(&message_waiting, 1)); + EXPECT_TRUE(mesh::companionFrameRequiresDelivery(&control_data, 1)); +} + TEST(CompanionFrameQueue, ResponsesRunBeforeQueuedPushesAndRemainFifo) { TestFrame queue[5] = {}; size_t count = 0; diff --git a/test/test_companion_terminal_profile.py b/test/test_companion_terminal_profile.py index 36735c87..b7a0afc1 100644 --- a/test/test_companion_terminal_profile.py +++ b/test/test_companion_terminal_profile.py @@ -22,6 +22,11 @@ assert re.search( local, re.DOTALL, ) +assert local.count('strcmp(command, "get pwrmgt.bootreason") == 0') == 1 +assert local.count('strcmp(command, "powerlog") == 0') == 1 +assert "board.isPowerManagementInitialized()" in local +assert "board.getResetReasonString(board.getResetReason())" in local +assert "board.getShutdownReasonString(board.getShutdownReason())" in local assert re.search( r"#if COMPANION_FEATURE_MEMORY_DIAGNOSTICS\s+" r'if \(strcmp\(command, "memory"\) == 0\).*?' @@ -44,11 +49,21 @@ 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 "const bool usb_mota_owner_transition =" in terminal +assert "!usb_mota_owner_transition" in terminal +assert re.search( + r"#if COMPANION_FEATURE_USB_MOTA_SOURCE\s+" + r"const bool usb_mota_owner_transition =\s+" + r"mesh::isUsbMotaOwnerTransitionCommand\(command\);\s+" + r"#else\s+const bool usb_mota_owner_transition = false;", + terminal, +) assert terminal.index("handleLocalControlCommand(") < terminal.index( 'strncmp(command, "set ", 4) == 0' ) assert 'terminalOutput().print(" board\\r\\n")' in terminal assert 'terminalOutput().print(" version\\r\\n")' in terminal +assert 'terminalOutput().print(" get pwrmgt.bootreason\\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+" @@ -72,6 +87,18 @@ 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 +owner_guard = command_handler.index( + "mesh::isUsbMotaOwnerTransitionCommand(command)" +) +local_dispatch = command_handler.index("handleLocalControlCommand(") +assert owner_guard < local_dispatch +assert "#if COMPANION_FEATURE_USB_MOTA_SOURCE" in command_handler[:local_dispatch] +assert "ERR ota folder on/off requires the owning USB mOTA session" in command_handler +owner_policy = (root / "src/helpers/UsbAsciiBinarySwitch.h").read_text() +assert 'memcmp(command + noun_start, "folder", 6) == 0' in owner_policy +assert 'memcmp(command + noun_start, "fold", 4) == 0' in owner_policy +assert 'memcmp(command + pos, "on", 2) == 0' in owner_policy +assert 'memcmp(command + pos, "off", 3) == 0' in owner_policy # 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. diff --git a/test/test_firmware_picker.js b/test/test_firmware_picker.js index c1c4065c..d301c3f1 100644 --- a/test/test_firmware_picker.js +++ b/test/test_firmware_picker.js @@ -849,6 +849,10 @@ assert.deepStrictEqual( ].sort() ); assert.strictEqual(picker.humanizeHardware("RAK_4631"), "RAK 4631"); +assert.strictEqual( + picker.humanizeVariant("rak13302-w25q16-lora-ota"), + "RAK13302 W25Q16 LoRa OTA" +); assert.strictEqual(picker.formatBytes(2097152), "2.00 MiB"); assert.strictEqual( picker.parseFirmwareAsset( diff --git a/test/test_indicator_exclusive_transport.py b/test/test_indicator_exclusive_transport.py index cf907eb6..0c7878e6 100644 --- a/test/test_indicator_exclusive_transport.py +++ b/test/test_indicator_exclusive_transport.py @@ -153,7 +153,8 @@ exclusive_policy = profile.index( ) exclusive_start = profile.index( "sensecapindicator-espnow_companion_radio_full|\\\n" - " sensecapindicator-lora_companion_radio_full)", + " sensecapindicator-lora_companion_radio_full|\\\n" + " sensecapindicator-lora-n16r2_companion_radio_full)", exclusive_policy, ) exclusive_case = profile[exclusive_start:profile.index(";;", exclusive_start)] diff --git a/test/test_momentary_button/test_momentary_button.cpp b/test/test_momentary_button/test_momentary_button.cpp new file mode 100644 index 00000000..8ae5ef3b --- /dev/null +++ b/test/test_momentary_button/test_momentary_button.cpp @@ -0,0 +1,242 @@ +#include +#include + +#include "helpers/ui/MomentaryButton.h" + +namespace { + +constexpr uint8_t kPin = 7; + +void advance(uint32_t millis_delta) { + g_mock_millis += millis_delta; +} + +int beginTransition(MomentaryButton& button, uint8_t level) { + g_mock_pin_levels[kPin] = level; + return button.check(); +} + +int finishDebounce(MomentaryButton& button) { + advance(25); + return button.check(); +} + +void cleanPress(MomentaryButton& button) { + EXPECT_EQ(BUTTON_EVENT_NONE, beginTransition(button, LOW)); + EXPECT_TRUE(button.needsPolling()); + EXPECT_EQ(BUTTON_EVENT_NONE, finishDebounce(button)); + EXPECT_TRUE(button.needsPolling()); +} + +int cleanRelease(MomentaryButton& button) { + EXPECT_EQ(BUTTON_EVENT_NONE, beginTransition(button, HIGH)); + EXPECT_TRUE(button.needsPolling()); + return finishDebounce(button); +} + +class MomentaryButtonTest : public testing::Test { + protected: + void SetUp() override { + resetArduinoMock(); + g_mock_millis = 100; + g_mock_pin_levels[kPin] = HIGH; + } +}; + +TEST_F(MomentaryButtonTest, CleanSingleWaitsForSlidingWindowAndEmitsOnce) { + MomentaryButton button(kPin, 1000, true, true, true); + button.begin(); + + cleanPress(button); + EXPECT_EQ(BUTTON_EVENT_NONE, cleanRelease(button)); + EXPECT_TRUE(button.needsPolling()); + + advance(279); + EXPECT_EQ(BUTTON_EVENT_NONE, button.check()); + advance(1); + EXPECT_EQ(BUTTON_EVENT_CLICK, button.check()); + EXPECT_FALSE(button.needsPolling()); + EXPECT_EQ(BUTTON_EVENT_NONE, button.check()); +} + +TEST_F(MomentaryButtonTest, ReleaseBounceStillProducesOneSingleClick) { + MomentaryButton button(kPin, 1000, true, true, true); + button.begin(); + cleanPress(button); + + EXPECT_EQ(BUTTON_EVENT_NONE, beginTransition(button, HIGH)); + advance(10); + g_mock_pin_levels[kPin] = LOW; + EXPECT_EQ(BUTTON_EVENT_NONE, button.check()); + advance(10); + EXPECT_EQ(BUTTON_EVENT_NONE, beginTransition(button, HIGH)); + EXPECT_EQ(BUTTON_EVENT_NONE, finishDebounce(button)); + + advance(280); + EXPECT_EQ(BUTTON_EVENT_CLICK, button.check()); + EXPECT_FALSE(button.needsPolling()); + EXPECT_EQ(BUTTON_EVENT_NONE, button.check()); +} + +TEST_F(MomentaryButtonTest, TwoCleanClicksProduceDoubleClick) { + MomentaryButton button(kPin, 1000, true, true, true); + button.begin(); + + cleanPress(button); + EXPECT_EQ(BUTTON_EVENT_NONE, cleanRelease(button)); + advance(100); + cleanPress(button); + EXPECT_EQ(BUTTON_EVENT_NONE, cleanRelease(button)); + advance(280); + EXPECT_EQ(BUTTON_EVENT_DOUBLE_CLICK, button.check()); + EXPECT_FALSE(button.needsPolling()); +} + +TEST_F(MomentaryButtonTest, ThreeCleanClicksProduceTripleClick) { + MomentaryButton button(kPin, 1000, true, true, true); + button.begin(); + + cleanPress(button); + EXPECT_EQ(BUTTON_EVENT_NONE, cleanRelease(button)); + advance(80); + cleanPress(button); + EXPECT_EQ(BUTTON_EVENT_NONE, cleanRelease(button)); + advance(80); + cleanPress(button); + EXPECT_EQ(BUTTON_EVENT_NONE, cleanRelease(button)); + advance(280); + EXPECT_EQ(BUTTON_EVENT_TRIPLE_CLICK, button.check()); + EXPECT_FALSE(button.needsPolling()); +} + +TEST_F(MomentaryButtonTest, MulticlickDisabledEmitsOnAcceptedRelease) { + MomentaryButton button(kPin, 1000, true, true, false); + button.begin(); + + cleanPress(button); + EXPECT_EQ(BUTTON_EVENT_CLICK, cleanRelease(button)); + EXPECT_FALSE(button.needsPolling()); +} + +TEST_F(MomentaryButtonTest, LongPressEmitsOnceAndDoesNotBecomeClick) { + MomentaryButton button(kPin, 1000, true, true, true); + button.begin(); + cleanPress(button); + + advance(999); + EXPECT_EQ(BUTTON_EVENT_NONE, button.check()); + advance(1); + EXPECT_EQ(BUTTON_EVENT_LONG_PRESS, button.check()); + EXPECT_FALSE(button.needsPolling()); + EXPECT_EQ(BUTTON_EVENT_NONE, cleanRelease(button)); + advance(280); + EXPECT_EQ(BUTTON_EVENT_NONE, button.check()); +} + +TEST_F(MomentaryButtonTest, ReleaseDebounceCanCrossLongPressBoundary) { + MomentaryButton button(kPin, 1000, true, true, true); + button.begin(); + cleanPress(button); + + advance(990); + EXPECT_EQ(BUTTON_EVENT_NONE, beginTransition(button, HIGH)); + EXPECT_EQ(BUTTON_EVENT_LONG_PRESS, finishDebounce(button)); + EXPECT_FALSE(button.needsPolling()); + advance(280); + EXPECT_EQ(BUTTON_EVENT_NONE, button.check()); +} + +TEST_F(MomentaryButtonTest, ClickThenHoldKeepsReleaseDebounceAlive) { + MomentaryButton button(kPin, 1000, true, true, true); + button.begin(); + + cleanPress(button); + EXPECT_EQ(BUTTON_EVENT_NONE, cleanRelease(button)); + advance(100); + cleanPress(button); + + advance(1000); + EXPECT_EQ(BUTTON_EVENT_NONE, beginTransition(button, HIGH)); + EXPECT_TRUE(button.needsPolling()); + EXPECT_EQ(BUTTON_EVENT_NONE, finishDebounce(button)); + EXPECT_FALSE(button.needsPolling()); + advance(280); + EXPECT_EQ(BUTTON_EVENT_NONE, button.check()); +} + +TEST_F(MomentaryButtonTest, CancelSuppressesImmediateClickMode) { + MomentaryButton button(kPin, 0, true, true, false); + button.begin(); + cleanPress(button); + + button.cancelClick(); + EXPECT_EQ(BUTTON_EVENT_NONE, cleanRelease(button)); + EXPECT_FALSE(button.needsPolling()); + EXPECT_EQ(BUTTON_EVENT_NONE, button.check()); +} + +TEST_F(MomentaryButtonTest, PressAcceptedAtMillisZeroIsNotLost) { + MomentaryButton button(kPin, 1000, true, true, true); + button.begin(); + g_mock_millis = UINT32_MAX - 24; + + cleanPress(button); + EXPECT_EQ(0U, g_mock_millis); + EXPECT_TRUE(button.needsPolling()); + EXPECT_EQ(BUTTON_EVENT_NONE, cleanRelease(button)); + advance(280); + EXPECT_EQ(BUTTON_EVENT_CLICK, button.check()); + EXPECT_FALSE(button.needsPolling()); +} + +TEST_F(MomentaryButtonTest, ClickDeadlineSurvivesMillisRollover) { + MomentaryButton button(kPin, 1000, true, true, true); + button.begin(); + g_mock_millis = UINT32_MAX - 100; + + cleanPress(button); + EXPECT_EQ(BUTTON_EVENT_NONE, cleanRelease(button)); + advance(279); + EXPECT_EQ(BUTTON_EVENT_NONE, button.check()); + advance(1); + EXPECT_EQ(BUTTON_EVENT_CLICK, button.check()); + EXPECT_FALSE(button.needsPolling()); +} + +TEST_F(MomentaryButtonTest, PressBeforeWindowMayFinishDebouncingAfterDeadline) { + MomentaryButton button(kPin, 1000, true, true, true); + button.begin(); + + cleanPress(button); + EXPECT_EQ(BUTTON_EVENT_NONE, cleanRelease(button)); + advance(279); + EXPECT_EQ(BUTTON_EVENT_NONE, beginTransition(button, LOW)); + EXPECT_EQ(BUTTON_EVENT_NONE, finishDebounce(button)); + EXPECT_EQ(BUTTON_EVENT_NONE, cleanRelease(button)); + advance(280); + EXPECT_EQ(BUTTON_EVENT_DOUBLE_CLICK, button.check()); + EXPECT_FALSE(button.needsPolling()); +} + +TEST_F(MomentaryButtonTest, PressAtExpiredWindowStartsNewClickSequence) { + MomentaryButton button(kPin, 1000, true, true, true); + button.begin(); + + cleanPress(button); + EXPECT_EQ(BUTTON_EVENT_NONE, cleanRelease(button)); + advance(280); + EXPECT_EQ(BUTTON_EVENT_CLICK, beginTransition(button, LOW)); + EXPECT_TRUE(button.needsPolling()); + EXPECT_EQ(BUTTON_EVENT_NONE, finishDebounce(button)); + EXPECT_EQ(BUTTON_EVENT_NONE, cleanRelease(button)); + advance(280); + EXPECT_EQ(BUTTON_EVENT_CLICK, button.check()); + EXPECT_FALSE(button.needsPolling()); +} + +} // namespace + +int main(int argc, char** argv) { + ::testing::InitGoogleTest(&argc, argv); + return RUN_ALL_TESTS(); +} diff --git a/test/test_nrf52_debug_output/test_nrf52_debug_output.cpp b/test/test_nrf52_debug_output/test_nrf52_debug_output.cpp index 8817c9dd..75b0fb9f 100644 --- a/test/test_nrf52_debug_output/test_nrf52_debug_output.cpp +++ b/test/test_nrf52_debug_output/test_nrf52_debug_output.cpp @@ -1,5 +1,7 @@ #include +#include +#include #include #define ARDUINO 1 @@ -11,12 +13,28 @@ namespace { +// Match Adafruit nRF52 Print::printf(): it forwards vsnprintf()'s required +// length even when the fixed scratch buffer truncated the formatted output. +size_t nrf52CorePrintf(Print& target, const char* format, ...) { + char output[256]; + va_list args; + va_start(args, format); + const int required = vsnprintf(output, sizeof(output), format, args); + va_end(args); + + if (required <= 0) return 0; + target.write(reinterpret_cast(output), + static_cast(required)); + return static_cast(required); +} + class CapacityStream : public Stream { public: void reset(int capacity) { capacity_ = capacity; available_calls_ = 0; write_calls_ = 0; + flush_calls_ = 0; oversized_write_ = false; reenter_on_write_ = false; reentrant_stream_ = nullptr; @@ -29,6 +47,8 @@ class CapacityStream : public Stream { return capacity_; } + void flush() override { flush_calls_++; } + size_t write(const uint8_t* buffer, size_t size) override { write_calls_++; if (size > static_cast(capacity_)) { @@ -52,6 +72,7 @@ class CapacityStream : public Stream { int capacity_ = 0; int available_calls_ = 0; int write_calls_ = 0; + int flush_calls_ = 0; bool oversized_write_ = false; bool reenter_on_write_ = false; Stream* reentrant_stream_ = nullptr; @@ -59,6 +80,33 @@ class CapacityStream : public Stream { std::string output_; }; +struct SingleAttemptContext { + mesh::SingleAttemptNonBlockingStream* stream = nullptr; + size_t result = 0; + size_t calls = 0; + size_t requested = 0; + size_t nested_result = 99; + bool reenter = false; + bool accessible = true; +}; + +size_t tryWriteOnce(void* opaque, const uint8_t*, size_t size) { + SingleAttemptContext* context = + static_cast(opaque); + context->calls++; + context->requested = size; + if (context->reenter && context->stream != nullptr) { + context->reenter = false; + const uint8_t nested = 'n'; + context->nested_result = context->stream->write(&nested, 1); + } + return context->result; +} + +bool canAccess(void* opaque) { + return static_cast(opaque)->accessible; +} + CapacityStream logging_port; mesh::WholeRecordNonBlockingStream<> bounded_logging_port(logging_port); bool logging_enabled = true; @@ -172,6 +220,52 @@ TEST_F(Nrf52DebugOutputTest, DedicatedFacadeRejectsAStalePrintfLength) { EXPECT_EQ(logging_port.write_calls_, 0); } +TEST_F(Nrf52DebugOutputTest, + DedicatedFacadeDropsExactScratchSizePrintfRecord) { + mesh::WholeRecordNonBlockingStream<> nonblocking_port(logging_port); + const std::string exact_scratch_size(256, 'x'); + + EXPECT_EQ(nrf52CorePrintf(nonblocking_port, "%s", + exact_scratch_size.c_str()), + exact_scratch_size.size()); + EXPECT_TRUE(logging_port.output_.empty()); + EXPECT_EQ(logging_port.available_calls_, 0); + EXPECT_EQ(logging_port.write_calls_, 0); +} + +TEST_F(Nrf52DebugOutputTest, + DedicatedFacadeAcceptsFullSizeNonNulDirectRecord) { + mesh::WholeRecordNonBlockingStream<> nonblocking_port(logging_port); + const std::string full_size_record(256, 'x'); + + EXPECT_EQ(nonblocking_port.write( + reinterpret_cast(full_size_record.data()), + full_size_record.size()), + full_size_record.size()); + EXPECT_EQ(logging_port.output_, full_size_record); + EXPECT_EQ(logging_port.available_calls_, 1); + EXPECT_EQ(logging_port.write_calls_, 1); +} + +TEST_F(Nrf52DebugOutputTest, + PartialIdentityWriteRestartsFromActualUsbTaskResult) { + constexpr size_t marker_size = 80; + size_t offset = 0; + + // The USB owner task observed that TinyUSB accepted only a prefix before the + // first host disconnected. + offset = mesh::detail::nextUsbLoggingIdentityOffset( + offset, marker_size, 19); + EXPECT_EQ(offset, 0U); + + // The next host must still be offered the complete marker. + const size_t next_remaining = marker_size - offset; + EXPECT_EQ(next_remaining, marker_size); + offset = mesh::detail::nextUsbLoggingIdentityOffset( + offset, next_remaining, next_remaining); + EXPECT_EQ(offset, marker_size); +} + TEST_F(Nrf52DebugOutputTest, DedicatedFacadeChecksThenWritesOnce) { mesh::WholeRecordNonBlockingStream<> nonblocking_port(logging_port); const uint8_t record[] = {'o', 'k', '\n'}; @@ -196,6 +290,213 @@ TEST_F(Nrf52DebugOutputTest, DedicatedFacadeDropsAReentrantWriter) { EXPECT_EQ(logging_port.write_calls_, 1); } +TEST_F(Nrf52DebugOutputTest, SingleAttemptFacadeNeverRetriesAShortWrite) { + SingleAttemptContext context; + context.result = 3; + mesh::SingleAttemptNonBlockingStream port(logging_port, tryWriteOnce, + &context); + const uint8_t record[] = {'p', 'a', 'r', 't', 'i', 'a', 'l'}; + + EXPECT_EQ(port.write(record, sizeof(record)), 3U); + EXPECT_EQ(context.calls, 1U); + EXPECT_EQ(context.requested, sizeof(record)); +} + +TEST_F(Nrf52DebugOutputTest, + MotaFacadeDoesNotEmitAPrefixWhenTheWholeRequestCannotFit) { + SingleAttemptContext context; + mesh::SingleAttemptNonBlockingStream single_attempt( + logging_port, tryWriteOnce, &context); + mesh::AtomicWholeRecordNonBlockingStream<11> mota_port(single_attempt); + const uint8_t request[] = {'M', 'S', 3, 1, 2, 3, 4, 5, 6, 7, 0x42}; + context.result = sizeof(request); + + logging_port.reset(sizeof(request) - 1); + EXPECT_EQ(mota_port.write(request, sizeof(request)), 0U); + EXPECT_EQ(context.calls, 0U); + + logging_port.reset(sizeof(request)); + context.reenter = true; + context.stream = &single_attempt; + EXPECT_EQ(mota_port.write(request, sizeof(request)), sizeof(request)); + EXPECT_EQ(context.calls, 1U); + EXPECT_EQ(context.requested, sizeof(request)); + EXPECT_EQ(context.nested_result, 0U); +} + +TEST_F(Nrf52DebugOutputTest, SingleAttemptFacadeNeverForwardsBlockingFlush) { + SingleAttemptContext context; + mesh::SingleAttemptNonBlockingStream port(logging_port, tryWriteOnce, + &context); + + port.flush(); + + EXPECT_EQ(logging_port.flush_calls_, 0); + EXPECT_EQ(context.calls, 0U); +} + +TEST_F(Nrf52DebugOutputTest, SingleAttemptFacadeHonorsTheSharedSessionGate) { + SingleAttemptContext context; + context.result = 3; + context.accessible = false; + mesh::SingleAttemptNonBlockingStream port( + logging_port, tryWriteOnce, &context, canAccess); + const uint8_t record[] = {'n', 'e', 'w'}; + + EXPECT_EQ(port.availableForWrite(), 0); + EXPECT_EQ(port.write(record, sizeof(record)), 0U); + EXPECT_EQ(context.calls, 0U); + + context.accessible = true; + EXPECT_EQ(port.write(record, sizeof(record)), sizeof(record)); + EXPECT_EQ(context.calls, 1U); +} + +TEST_F(Nrf52DebugOutputTest, SingleAttemptFacadeDropsAReentrantWriter) { + SingleAttemptContext context; + context.result = 5; + context.reenter = true; + mesh::SingleAttemptNonBlockingStream port(logging_port, tryWriteOnce, + &context); + context.stream = &port; + const uint8_t record[] = {'o', 'u', 't', 'e', 'r'}; + + EXPECT_EQ(port.write(record, sizeof(record)), sizeof(record)); + EXPECT_EQ(context.calls, 1U); + EXPECT_EQ(context.nested_result, 0U); +} + +TEST_F(Nrf52DebugOutputTest, + BufferedFacadeRetainsACompleteMultiLineReplyAcrossShortDrains) { + logging_port.reset(0); + mesh::BufferedNonBlockingWriteStream<4096> port(logging_port); + std::string expected; + + for (int i = 0; i < 80; ++i) { + char line[48]; + const int length = snprintf(line, sizeof(line), + " command-%02d --with-argument\r\n", i); + ASSERT_GT(length, 0); + expected.append(line, static_cast(length)); + EXPECT_EQ(port.write(reinterpret_cast(line), + static_cast(length)), + static_cast(length)); + } + ASSERT_GT(expected.size(), 256U); + EXPECT_EQ(port.queuedByteCount(), expected.size()); + + int drains = 0; + while (port.queuedByteCount() != 0 && drains++ < 100) { + logging_port.capacity_ = 37; + EXPECT_LE(port.service(), 37U); + } + + EXPECT_LT(drains, 100); + EXPECT_EQ(port.queuedByteCount(), 0U); + EXPECT_EQ(logging_port.output_, expected); +} + +TEST_F(Nrf52DebugOutputTest, + BufferedFacadeDropsAWholeRecordWhenItsBoundIsFull) { + logging_port.reset(0); + mesh::BufferedNonBlockingWriteStream<8> port(logging_port); + const uint8_t first[] = {'1', '2', '3', '4', '5', '6'}; + const uint8_t dropped[] = {'7', '8', '9'}; + + EXPECT_EQ(port.write(first, sizeof(first)), sizeof(first)); + EXPECT_EQ(port.write(dropped, sizeof(dropped)), 0U); + EXPECT_EQ(port.queuedByteCount(), sizeof(first)); + + logging_port.capacity_ = 8; + EXPECT_EQ(port.service(), sizeof(first)); + EXPECT_EQ(logging_port.output_, "123456"); +} + +TEST_F(Nrf52DebugOutputTest, + BufferedFacadeRejectsNrf52PrintfStaleLengthSafely) { + logging_port.reset(0); + mesh::BufferedNonBlockingWriteStream<4096> port(logging_port); + const std::string oversized(600, 'x'); + + EXPECT_EQ(nrf52CorePrintf(port, "%s", oversized.c_str()), + oversized.size()); + EXPECT_EQ(port.queuedByteCount(), 0U); + EXPECT_TRUE(logging_port.output_.empty()); +} + +TEST_F(Nrf52DebugOutputTest, BufferedFacadeDisconnectResetDropsStaleText) { + logging_port.reset(0); + mesh::BufferedNonBlockingWriteStream<64> port(logging_port); + const uint8_t stale[] = {'o', 'l', 'd'}; + const uint8_t fresh[] = {'n', 'e', 'w'}; + + EXPECT_EQ(port.write(stale, sizeof(stale)), sizeof(stale)); + port.discardPending(); + EXPECT_EQ(port.queuedByteCount(), 0U); + EXPECT_EQ(port.write(fresh, sizeof(fresh)), sizeof(fresh)); + + logging_port.capacity_ = 64; + EXPECT_EQ(port.service(), sizeof(fresh)); + EXPECT_EQ(logging_port.output_, "new"); +} + +TEST_F(Nrf52DebugOutputTest, + TaskOwnedFacadeNeverTouchesDelegateFromProducer) { + mesh::TaskOwnedWriteStream<3, 16> port(logging_port); + const uint8_t first[] = {'f', 'i', 'r', 's', 't'}; + const uint8_t second[] = {'n', 'e', 'x', 't'}; + + EXPECT_EQ(port.write(first, sizeof(first)), sizeof(first)); + EXPECT_EQ(port.write(second, sizeof(second)), sizeof(second)); + EXPECT_EQ(port.queuedRecordCount(), 2U); + EXPECT_TRUE(logging_port.output_.empty()); + EXPECT_EQ(logging_port.available_calls_, 0); + EXPECT_EQ(logging_port.write_calls_, 0); + + EXPECT_EQ(port.drainOne(), sizeof(first)); + EXPECT_EQ(logging_port.output_, "first"); + EXPECT_EQ(port.queuedRecordCount(), 1U); + EXPECT_EQ(port.drainOne(), sizeof(second)); + EXPECT_EQ(logging_port.output_, "firstnext"); + EXPECT_EQ(port.queuedRecordCount(), 0U); +} + +TEST_F(Nrf52DebugOutputTest, + TaskOwnedFacadeDropsWhenQueueIsFullAndRetriesDrainLater) { + mesh::TaskOwnedWriteStream<3, 16> port(logging_port); + const uint8_t first[] = {'1'}; + const uint8_t second[] = {'2'}; + const uint8_t dropped[] = {'3'}; + + EXPECT_EQ(port.write(first, sizeof(first)), sizeof(first)); + EXPECT_EQ(port.write(second, sizeof(second)), sizeof(second)); + EXPECT_EQ(port.availableForWrite(), 0); + EXPECT_EQ(port.write(dropped, sizeof(dropped)), 0U); + + logging_port.reset(0); + EXPECT_EQ(port.drainOne(), 0U); + EXPECT_EQ(port.queuedRecordCount(), 2U); + EXPECT_EQ(logging_port.write_calls_, 0); + + logging_port.reset(16); + EXPECT_EQ(port.drainOne(), sizeof(first)); + EXPECT_EQ(port.queuedRecordCount(), 1U); + EXPECT_EQ(port.write(dropped, sizeof(dropped)), sizeof(dropped)); + EXPECT_EQ(port.queuedRecordCount(), 2U); +} + +TEST_F(Nrf52DebugOutputTest, TaskOwnedFacadeDiscardsPendingFromOwnerTask) { + mesh::TaskOwnedWriteStream<4, 16> port(logging_port); + const uint8_t record[] = {'o', 'l', 'd'}; + + EXPECT_EQ(port.write(record, sizeof(record)), sizeof(record)); + EXPECT_EQ(port.queuedRecordCount(), 1U); + port.discardPending(); + EXPECT_EQ(port.queuedRecordCount(), 0U); + EXPECT_EQ(port.drainOne(), 0U); + EXPECT_TRUE(logging_port.output_.empty()); +} + 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 index f9ac4f05..ce7e3428 100644 --- a/test/test_nrf52_usb_logging_contract.py +++ b/test/test_nrf52_usb_logging_contract.py @@ -2,6 +2,7 @@ """Static integration guards for nRF52 nonblocking USB diagnostics.""" from pathlib import Path +import re import unittest @@ -9,14 +10,105 @@ ROOT = Path(__file__).resolve().parents[1] class Nrf52UsbLoggingContractTest(unittest.TestCase): + def test_dedicated_cdc_name_is_installed_while_descriptor_is_built(self): + source = (ROOT / "src/helpers/UsbLogging.cpp").read_text() + + override = source.index("class DedicatedUsbLoggingCdc") + descriptor = source.index("getInterfaceDescriptor(", override) + custom_name = source.index( + "setStringDescriptor(dedicated_usb_logging_descriptor)", descriptor + ) + core_builder = source.index( + "Adafruit_USBD_CDC::getInterfaceDescriptor(", custom_name + ) + begin = source.index("dedicated_usb_logging_port.begin(115200)") + + self.assertLess(descriptor, custom_name) + self.assertLess(custom_name, core_builder) + self.assertLess(core_builder, begin) + self.assertIn('"MeshCore Logging"', source) + # A post-begin setter is too late: the installed Adafruit core has + # already copied the CDC interface descriptor by then. + self.assertNotIn( + 'dedicated_usb_logging_port.setStringDescriptor("MeshCore Logging")', + source, + ) + + def test_connection_marker_retries_one_whole_record_without_waiting(self): + source = (ROOT / "src/helpers/UsbLogging.cpp").read_text() + + service = source.index("void serviceDedicatedUsbLoggingFromUsbTask()") + connection = source.index("tud_cdc_n_connected(1)", service) + capacity = source.index( + "single_attempt_dedicated_usb_logging_port.availableForWrite()", + connection, + ) + write = source.index( + "single_attempt_dedicated_usb_logging_port.write(", capacity + ) + progress = source.index( + "detail::nextUsbLoggingIdentityOffset(", write + ) + queued_drain = source.index( + "usb_task_dedicated_usb_logging_port.drainOne();", progress + ) + + self.assertLess(connection, capacity) + self.assertLess(capacity, write) + self.assertLess(write, progress) + self.assertLess(progress, queued_drain) + self.assertIn("if (should_service)", source) + self.assertIn('"MeshCore USB logging port\\r\\n"\n', source) + self.assertIn( + '"USB CDC 1; interface 02; Linux stable suffix: -if02\\r\\n"', + source, + ) + self.assertRegex( + source, + r"dedicated_usb_logging_identity_size\s*" + r"<= CFG_TUD_CDC_TX_BUFSIZE", + ) + self.assertNotIn('port.println("MeshCore USB logging port")', source) + + def test_partial_marker_write_restarts_from_actual_usb_task_result(self): + source = (ROOT / "src/helpers/UsbLogging.cpp").read_text() + header = (ROOT / "src/helpers/UsbLogging.h").read_text() + + service = source.index("void serviceDedicatedUsbLoggingFromUsbTask()") + write = source.index( + "single_attempt_dedicated_usb_logging_port.write(", service + ) + progress = source.index( + "detail::nextUsbLoggingIdentityOffset(", write + ) + + self.assertLess(write, progress) + self.assertIn( + "return written == requested ? current_offset + written : 0;", + header, + ) + self.assertNotIn( + "dedicated_usb_logging_identity_offset += accepted;", source + ) + self.assertNotIn( + "nonblocking_dedicated_usb_logging_port.write(\n" + " reinterpret_cast(dedicated_usb_logging_identity", + source, + ) + 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)", + "nonblocking_dedicated_usb_logging_port(\n" + " usb_task_dedicated_usb_logging_port)", + source, + ) + self.assertIn( + "nonblocking_primary_usb_logging_port(\n" + " nonblocking_primary_usb_companion_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. @@ -25,15 +117,572 @@ class Nrf52UsbLoggingContractTest(unittest.TestCase): 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") + facade = source.index("class WholeRecordNonBlockingStream") + enter = source.index("_writer_busy.test_and_set", facade) capacity = source.index("_delegate.availableForWrite()", enter) - write = source.index("_delegate.write(data, size)") - release = source.index("_writer_busy.clear") + write = source.index("_delegate.write(data, size)", capacity) + release = source.index("_writer_busy.clear", write) self.assertLess(enter, capacity) self.assertLess(capacity, write) self.assertLess(write, release) self.assertIn("size > MAX_WRITE_SIZE", source) + def test_primary_cdc_uses_one_direct_tinyusb_write(self): + source = (ROOT / "src/helpers/UsbLogging.cpp").read_text() + main = (ROOT / "examples/companion_radio/main.cpp").read_text() + + helper_start = source.index("static size_t writeTinyUsbCdcOnce(") + helper_end = source.index( + "static SingleAttemptNonBlockingStream", helper_start + ) + helper = source[helper_start:helper_end] + self.assertEqual(helper.count("tud_cdc_n_write(instance, data, attempt)"), 1) + self.assertNotIn("Adafruit_USBD_CDC::write", helper) + self.assertIn("tud_cdc_n_connected(instance)", helper) + self.assertIn("tud_cdc_n_write_available(instance)", helper) + self.assertIn("nonblocking_primary_usb_companion_port(", source) + self.assertIn("single_attempt_dedicated_usb_logging_port(", source) + self.assertIn( + "usb_serial_interface.begin(mesh::usbCompanionPort(),", main + ) + + def test_dedicated_logging_is_drained_only_from_tinyusb_task(self): + source = (ROOT / "src/helpers/UsbLogging.cpp").read_text() + stream = (ROOT / "src/helpers/NonBlockingWriteStream.h").read_text() + + self.assertIn( + "TaskOwnedWriteStream<> usb_task_dedicated_usb_logging_port(", + source, + ) + override = ( + ROOT / "src/helpers/UsbLoggingLineStateOverride.cpp" + ).read_text() + self.assertNotIn('extern "C" void tud_sof_cb(', source) + self.assertIn('extern "C" void tud_sof_cb(', override) + self.assertIn("meshTinyUsbStartOfFrame(frame_count);", override) + self.assertIn( + 'extern "C" void meshTinyUsbStartOfFrame(', source + ) + self.assertIn( + 'extern "C" void TinyUSB_Device_FlushCDC(void)', override + ) + self.assertIn( + 'extern "C" uint32_t tud_cdc_n_write_flush(uint8_t itf);', + override, + ) + flush_override = override[override.index( + 'extern "C" void TinyUSB_Device_FlushCDC(void)' + ):override.index('extern "C" void tud_mount_cb(void)')] + self.assertIn("tud_cdc_n_write_flush(0)", flush_override) + self.assertNotIn("tud_cdc_n_write_flush(1)", flush_override) + self.assertGreaterEqual(source.count("tud_cdc_n_write_flush(1)"), 3) + self.assertIn("serviceDedicatedUsbLoggingFromUsbTask();", source) + self.assertIn( + "usb_task_dedicated_usb_logging_port.drainOne();", + source, + ) + self.assertIn( + "single_attempt_dedicated_usb_logging_port.write(", + source, + ) + self.assertIn("tud_cdc_n_connected(1)", source) + self.assertIn("dedicated_usb_logging_reset_generation", source) + self.assertIn("tud_sof_cb_enable(true);", source) + self.assertIn("tud_sof_cb_enable(false);", source) + self.assertIn( + "tud_sof_cb_enable(dtr && isUsbLoggingEnabled());", source + ) + self.assertIn( + "usb_task_dedicated_usb_logging_port.discardPending();", + source, + ) + main_loop_service = source[ + source.index("void serviceUsbLoggingPort()") : + source.index("Stream& usbLoggingPort()") + ] + self.assertNotIn("discardPending()", main_loop_service) + producer = stream[ + stream.index("class TaskOwnedWriteStream") : + stream.index("class WholeRecordNonBlockingStream") + ] + write = producer[ + producer.index("size_t write(const uint8_t* data") : + producer.index("size_t drainOne()") + ] + self.assertNotIn("_delegate.write", write) + self.assertIn("_delegate.write(record.data, record.size)", producer) + + def test_exact_cdc_line_state_hook_preserves_cdc0_touch(self): + source = (ROOT / "src/helpers/UsbLogging.cpp").read_text() + override = ( + ROOT / "src/helpers/UsbLoggingLineStateOverride.cpp" + ).read_text() + + # Including TinyUSB's declaration here would make our definition weak + # like the framework default instead of overriding it. + self.assertNotIn("#include ", override) + self.assertNotIn("#include ", override) + self.assertNotIn("#include ", override) + self.assertNotIn("#include ", override) + self.assertIn("#include ", override) + guard = "#if defined(ARDUINO) && defined(NRF52_PLATFORM)" + self.assertLess(override.index(guard), override.index( + "#include " + )) + self.assertIn( + 'extern "C" void tud_cdc_line_state_cb(', override + ) + self.assertIn( + "meshTinyUsbCdcLineStateChanged(instance, dtr, rts);", override + ) + self.assertIn( + 'extern "C" void tud_cdc_line_coding_cb(', override + ) + self.assertIn( + "cdc_line_coding_t const* line_coding", override + ) + self.assertIn( + "meshTinyUsbCdcLineCodingChanged(instance);", override + ) + self.assertIn('extern "C" void tud_mount_cb(void)', override) + self.assertIn('extern "C" void tud_umount_cb(void)', override) + self.assertEqual( + override.count("meshTinyUsbDeviceSessionBoundary();"), 3 + ) + + bridge = source[source.index( + 'extern "C" void meshTinyUsbCdcLineStateChanged(' + ):] + self.assertIn("if (instance == 1)", bridge) + self.assertIn("handleDedicatedUsbLoggingLineState(dtr);", bridge) + self.assertIn("tud_cdc_n_write_clear(1);", source) + self.assertIn("endPrimaryUsbHostSession(true);", bridge) + self.assertIn("if (instance == 0 && !dtr)", bridge) + self.assertIn("tud_cdc_get_line_coding(&coding);", bridge) + self.assertIn("if (coding.bit_rate == 1200)", bridge) + self.assertIn("TinyUSB_Port_EnterDFU();", bridge) + self.assertIn("const bool was_open =", bridge) + self.assertIn("primary_usb_line_state_dtr.load(", bridge) + cdc0_close = bridge[ + bridge.index("if (instance == 0 && !dtr)") : + bridge.index( + 'extern "C" void meshTinyUsbCdcLineCodingChanged(' + ) + ] + self.assertLess( + cdc0_close.index("endPrimaryUsbHostSession(true)"), + cdc0_close.index("tud_cdc_get_line_coding(&coding)"), + ) + cdc0_open = cdc0_close[cdc0_close.index( + "} else if (instance == 0)" + ):] + self.assertIn("const bool was_open =", cdc0_open) + self.assertIn("if (!was_open)", cdc0_open) + self.assertIn("tud_cdc_n_read_flush(0);", cdc0_open) + self.assertIn("primary_usb_line_state_dtr.store(", cdc0_open) + self.assertLess( + cdc0_open.index("tud_cdc_n_read_flush(0);"), + cdc0_open.index("primary_usb_line_state_dtr.store("), + ) + boundary = source[source.index( + 'extern "C" void meshTinyUsbDeviceSessionBoundary()' + ):source.index( + 'extern "C" void meshTinyUsbCdcLineCodingChanged(' + )] + self.assertIn("endPrimaryUsbHostSession(false);", boundary) + self.assertIn("handleDedicatedUsbLoggingLineState(false);", boundary) + + session_end = source[source.index( + "static void endPrimaryUsbHostSession(" + ):source.index("#endif", source.index( + "static void endPrimaryUsbHostSession(" + ))] + self.assertIn("primary_usb_line_state_dtr.exchange(", session_end) + self.assertIn("if (!previous) return;", session_end) + self.assertLess( + session_end.index("primary_usb_reset_generation.fetch_add("), + session_end.index("tud_cdc_n_read_flush(0)"), + ) + self.assertLess( + session_end.index("tud_cdc_n_read_flush(0)"), + session_end.index("tud_cdc_n_write_clear(0)"), + ) + self.assertIn( + "dedicated_usb_logging_line_state_dtr.exchange(", source + ) + coding_bridge = source[source.index( + 'extern "C" void meshTinyUsbCdcLineCodingChanged(' + ):] + self.assertIn("if (instance == 0)", coding_bridge) + self.assertIn( + "primary_usb_line_state_dtr.load(", coding_bridge + ) + self.assertIn("endPrimaryUsbHostSession(true);", coding_bridge) + self.assertIn("tud_cdc_n_connected(0)", coding_bridge) + self.assertIn("if (instance == 1)", coding_bridge) + self.assertIn("handleDedicatedUsbLoggingLineCoding();", coding_bridge) + self.assertIn("if (tud_cdc_n_connected(1))", source) + self.assertIn("restartDedicatedUsbLoggingHostSession();", source) + self.assertIn("dedicated_usb_logging_host_settle_sofs = 50", source) + self.assertIn("dedicated_usb_logging_quiet_sofs > 0", source) + self.assertIn("--dedicated_usb_logging_quiet_sofs;", source) + + restart = source[ + source.index("static void restartDedicatedUsbLoggingHostSession()"): + source.index("static void handleDedicatedUsbLoggingLineState") + ] + restart_gate = restart.index( + "dedicated_usb_logging_port_connected.store(false" + ) + restart_fifo = restart.index("tud_cdc_n_write_clear(1)") + restart_state = restart.index("resetDedicatedUsbLoggingUsbTaskState()") + restart_quiet = restart.index("dedicated_usb_logging_quiet_sofs =") + restart_generation = restart.index( + "dedicated_usb_logging_reset_generation.fetch_add(" + ) + self.assertLess(restart_gate, restart_fifo) + self.assertLess(restart_fifo, restart_state) + self.assertLess(restart_state, restart_quiet) + self.assertLess(restart_quiet, restart_generation) + + owner_service = source[ + source.index("void serviceDedicatedUsbLoggingFromUsbTask()"): + source.index("#elif defined(NRF52_PLATFORM)") + ] + quiet_start = owner_service.index( + "if (dedicated_usb_logging_quiet_sofs > 0)" + ) + positive_gate = owner_service.index( + "dedicated_usb_logging_port_connected.store(\n" + " true", + quiet_start, + ) + marker_write = owner_service.index( + "single_attempt_dedicated_usb_logging_port.write(", + positive_gate, + ) + queued_drain = owner_service.index( + "usb_task_dedicated_usb_logging_port.drainOne();", + marker_write, + ) + self.assertLess(quiet_start, positive_gate) + self.assertLess(positive_gate, marker_write) + self.assertLess(marker_write, queued_drain) + + quiet_block = owner_service[quiet_start:positive_gate] + quiet_fifo = quiet_block.index("tud_cdc_n_write_clear(1)") + quiet_state = quiet_block.index( + "resetDedicatedUsbLoggingUsbTaskState()" + ) + quiet_decrement = quiet_block.index( + "--dedicated_usb_logging_quiet_sofs;" + ) + quiet_return = quiet_block.index("return;", quiet_decrement) + self.assertLess(quiet_fifo, quiet_state) + self.assertLess(quiet_state, quiet_decrement) + self.assertLess(quiet_decrement, quiet_return) + + positive_publish_sites = list(re.finditer( + r"dedicated_usb_logging_port_connected\.store\(\s*true", + source, + )) + self.assertEqual(1, len(positive_publish_sites)) + owner_start = source.index( + "void serviceDedicatedUsbLoggingFromUsbTask()" + ) + owner_end = source.index("#elif defined(NRF52_PLATFORM)") + self.assertGreater(positive_publish_sites[0].start(), owner_start) + self.assertLess(positive_publish_sites[0].start(), owner_end) + + main_loop_service = source[ + source.index("void serviceUsbLoggingPort()"): + source.index("Stream& usbLoggingPort()") + ] + positive_publish = ( + "dedicated_usb_logging_port_connected.store(\n" + " true" + ) + self.assertNotIn(positive_publish, main_loop_service) + + self.assertIn( + '#error "MESH_DUAL_CDC_LOGGING requires ENABLE_USB_INTERFACE"', + source, + ) + + def test_nrf52_ascii_terminal_uses_the_buffered_nonblocking_primary_port(self): + main = (ROOT / "examples/companion_radio/main.cpp").read_text() + mesh = (ROOT / "examples/companion_radio/MyMesh.cpp").read_text() + usb = (ROOT / "src/helpers/UsbLogging.cpp").read_text() + stream = (ROOT / "src/helpers/NonBlockingWriteStream.h").read_text() + + self.assertIn("static Stream& usbTerminalOutput()", main) + self.assertIn("return mesh::usbTerminalPort();", main) + self.assertNotRegex(main, r"Serial\.(?:print|println|printf|write)\(") + self.assertNotRegex(mesh, r"Serial\.(?:print|println|printf|write)\(") + self.assertIn( + "_terminal_output = &mesh::usbTerminalPort();", + mesh, + ) + self.assertIn( + "if (_terminal_output == &mesh::usbTerminalPort())", + mesh, + ) + self.assertIn("BufferedNonBlockingWriteStream<4096>", usb) + self.assertIn("class BufferedNonBlockingWriteStream", stream) + self.assertIn("mesh::serviceUsbTerminalPort();", main) + self.assertIn("drainUsbTerminalOutputBeforeProtocolSwitch();", main) + self.assertIn("mesh::discardUsbTerminalOutput();", main) + self.assertIn("size > CAPACITY - _count", stream) + self.assertIn("data[PRINTF_SCRATCH_SIZE - 1] == '\\0'", stream) + + def test_nrf52_serial_mota_uses_one_attempt_and_never_flushes(self): + context = (ROOT / "src/helpers/ota/OtaContext.h").read_text() + stream = (ROOT / "src/helpers/NonBlockingWriteStream.h").read_text() + usb = (ROOT / "src/helpers/UsbLogging.cpp").read_text() + + self.assertIn( + "#define OTA_FOLDER_SERIAL_STREAM ::mesh::usbMotaPort()", + context, + ) + self.assertIn( + "#define OTA_FOLDER_SERIAL_WRITE_POLICY MotaStreamWritePolicy::NoFlush", + context, + ) + self.assertIn( + "static SerialMotaSource src(OTA_FOLDER_SERIAL_STREAM,\n" + " OTA_FOLDER_SERIAL_WRITE_POLICY, 600);", + context, + ) + facade = stream[ + stream.index("class SingleAttemptNonBlockingStream") : + stream.index("class BufferedNonBlockingWriteStream") + ] + self.assertIn("void flush() override {}", facade) + self.assertNotIn("_delegate.flush()", facade) + self.assertIn( + "AtomicWholeRecordNonBlockingStream<11>\n" + " nonblocking_primary_usb_mota_port(", + usb, + ) + self.assertIn("Stream& usbMotaPort()", usb) + self.assertIn("return nonblocking_primary_usb_mota_port;", usb) + atomic = stream[ + stream.index("class AtomicWholeRecordNonBlockingStream") : + stream.index("class BufferedNonBlockingWriteStream") + ] + self.assertIn("_delegate.writeWholeRecord(data, size)", atomic) + inner = stream[ + stream.index("size_t writeWholeRecord(") : + stream.index("bool tryRunExclusive(") + ] + self.assertLess( + inner.index("_writer_busy.test_and_set"), + inner.index("_delegate.availableForWrite()"), + ) + self.assertLess( + inner.index("_delegate.availableForWrite()"), + inner.index("_try_write(_context, data, size)"), + ) + + def test_cdc0_close_resets_the_complete_application_session_before_dispatch(self): + source = (ROOT / "src/helpers/UsbLogging.cpp").read_text() + header = (ROOT / "src/helpers/UsbLogging.h").read_text() + main = (ROOT / "examples/companion_radio/main.cpp").read_text() + + self.assertIn("bool takeUsbTerminalSessionReset();", header) + self.assertIn("bool tryCompleteUsbTerminalSessionReset();", header) + self.assertIn("bool takeUsbTerminalSessionReset()", source) + self.assertIn("bool tryCompleteUsbTerminalSessionReset()", source) + reset = main[ + main.index("static void serviceUsbTerminalHostSessionReset()") : + main.index("static void serviceUsbTerminal()") + ] + self.assertIn("mesh::takeUsbTerminalSessionReset()", reset) + self.assertIn("mesh::tryCompleteUsbTerminalSessionReset()", reset) + self.assertIn("leaveUsbMotaMode(false);", reset) + self.assertIn("leaveUsbTerminalMode(false);", reset) + self.assertIn("usb_serial_interface.setPassthroughMode(false);", reset) + self.assertIn("clearUsbTerminalLine();", reset) + self.assertIn("usb_binary_startup_probe.cancel();", reset) + self.assertIn("usb_terminal_host_reset_completion_pending", reset) + self.assertIn("cancelUsbSerialOperations();", reset) + usb_cancel = main[ + main.index("static void cancelUsbSerialOperations()"): + main.index("static void enterUsbTerminalMode()") + ] + route_check = usb_cancel.index( + "interface_manager.isReplyRouteFor(&usb_serial_interface)" + ) + route_cancel = usb_cancel.index( + "the_mesh.cancelSerialResponseStream()" + ) + delayed_cancel = usb_cancel.index( + "the_mesh.cancelSerialOperationsForRoute(&usb_serial_interface)" + ) + route_forget = usb_cancel.index( + "interface_manager.forgetReplyRouteForDisconnected(" + ) + self.assertLess(route_check, route_cancel) + self.assertLess(route_cancel, delayed_cancel) + self.assertLess(delayed_cancel, route_forget) + self.assertIn("the_mesh.resetUsbHostSessionInput();", reset) + self.assertNotRegex(main, r"Serial\.(?:available|read|peek)\(") + mesh_source = ( + ROOT / "examples/companion_radio/MyMesh.cpp" + ).read_text() + self.assertNotRegex( + mesh_source, r"Serial\.(?:available|read|peek)\(" + ) + session_operations = mesh_source[ + mesh_source.index("void MyMesh::cancelSerialOperationsForRoute("): + mesh_source.index("void MyMesh::handleCmdFrame(") + ] + self.assertIn( + "pending_serial_reply_route == route", session_operations + ) + self.assertIn( + "command_radio_reply_route == route", session_operations + ) + self.assertIn("cancelPendingRadioParamApply();", session_operations) + self.assertIn("if (_cli_rescue)", session_operations) + self.assertIn( + "memset(cli_command, 0, sizeof(cli_command));", + session_operations, + ) + + # Every delayed Binary mesh response uses a captured requester. A + # missing/disconnected route fails closed instead of broadcasting. + self.assertEqual( + mesh_source.count( + "pending_serial_reply_route = _serial->captureReplyRoute();" + ), + 6, + ) + self.assertGreaterEqual( + mesh_source.count("writePendingSerialFrame(out_frame, i);"), 5 + ) + pending_writer = mesh_source[ + mesh_source.index("size_t MyMesh::writePendingSerialFrame("): + mesh_source.index("void MyMesh::writeDisabledFrame(") + ] + self.assertIn("pending_serial_reply_route == NULL", pending_writer) + self.assertIn("writeFrameToRoute(", pending_writer) + self.assertNotIn("_serial->writeFrame(out_frame, i);", pending_writer) + self.assertIn( + "command_radio_reply_route = _serial->captureReplyRoute();", + mesh_source, + ) + self.assertIn( + "isReplyRouteAvailable(command_radio_reply_route)", mesh_source + ) + self.assertIn( + "isReplyRouteWriteBusy(command_radio_reply_route)", mesh_source + ) + + completion = source[ + source.index("static void completePrimaryUsbSessionReset(") : + source.index("bool tryCompleteUsbTerminalSessionReset()") + ] + # The owner-task close/line-coding callback purges old RX immediately. + # A second purge after the settle gate would erase a new host's + # immediately sent APP_START (meshcli does not delay after open). + self.assertNotIn("tud_cdc_n_read_flush(0);", completion) + self.assertIn("tud_cdc_n_write_clear(0);", completion) + self.assertIn("primary_usb_allowed_generation.store(", completion) + self.assertIn(".tryRunExclusive(", source) + self.assertIn("primary_usb_reset_settle_until.store(", source) + self.assertIn("primary_usb_session_settle_millis = 8", source) + self.assertIn( + "(int32_t)(millis() - settle_until) < 0", source + ) + access = source[ + source.index("static bool canAccessPrimaryUsbSession(") : + source.index("#endif", source.index( + "static bool canAccessPrimaryUsbSession(" + )) + ] + self.assertIn("primary_usb_line_state_dtr.load(", access) + self.assertIn("primary_usb_allowed_generation.load(", access) + self.assertIn("primary_usb_reset_generation.load(", access) + + loop = main[main.index("void loop() {"):] + self.assertLess( + loop.index("serviceUsbTerminalHostSessionReset();"), + loop.index("the_mesh.loop();"), + ) + + def test_delayed_companion_work_keeps_exact_transport_ownership(self): + source = (ROOT / "examples/companion_radio/MyMesh.cpp").read_text() + header = (ROOT / "examples/companion_radio/MyMesh.h").read_text() + + self.assertIn("BaseSerialInterface* reply_route;", header) + self.assertIn( + "entry.reply_route = _serial->captureReplyRoute();", source + ) + self.assertIn( + "_serial->writeFrameToRoute(expected_ack_table[i].reply_route,", + source, + ) + self.assertIn("expected_ack_table[i].reply_route == route", source) + ack_expiry = source[ + source.index("void MyMesh::expireExpectedAcks()") : + source.index("MyMesh::AckTableEntry* MyMesh::findPendingTextMessage(") + ] + self.assertIn("entry.reply_route = NULL;", ack_expiry) + self.assertNotIn("clearExpectedAck(entry);", ack_expiry) + route_cancel = source[ + source.index("void MyMesh::cancelSerialOperationsForRoute(") : + source.index("void MyMesh::resetUsbHostSessionInput()") + ] + self.assertIn( + "expected_ack_table[i].reply_route = NULL;", route_cancel + ) + self.assertNotIn( + "clearExpectedAck(expected_ack_table[i]);", route_cancel + ) + + self.assertIn("const bool starts_long_lived_request", source) + self.assertIn( + "starts_long_lived_request && hasPendingReqs()", source + ) + self.assertEqual( + source.count( + "pending_serial_reply_deadline =\n" + " futureMillis(est_timeout + est_timeout / 5);" + ), + 6, + ) + self.assertIn("servicePendingSerialReply();", source) + self.assertIn( + "another Companion request is still pending", source + ) + + self.assertIn( + "binary_trace_reply_route = _serial->captureReplyRoute();", + source, + ) + self.assertIn( + "_serial->writeFrameToRoute(binary_trace_reply_route,", source + ) + self.assertIn("binary_trace_reply_route == route", source) + + self.assertIn("sign_data_reply_route != signing_route", source) + self.assertIn("sign_data_reply_route == route", source) + self.assertIn("SIGN_SESSION_TIMEOUT_MILLIS = 120000", source) + self.assertIn("serviceSigningSession();", source) + + app_start = source[ + source.index("cmd_frame[0] == CMD_APP_START"): + source.index("cmd_frame[0] == CMD_GET_CONTACTS") + ] + self.assertIn("cancelSerialOperationsForRoute(app_route);", app_start) + self.assertNotIn("cancelPendingRadioParamApply();", app_start) + serial_service = source[ + source.index("void MyMesh::checkSerialInterface()"): + source.index("void MyMesh::loop()") + ] + self.assertNotIn("cancelPendingRadioParamApply();", serial_service) + def test_meshcore_debug_macros_use_the_bounded_formatter_only_on_nrf52(self): source = (ROOT / "src/MeshCore.h").read_text() diff --git a/test/test_nrf52_variant_contracts.py b/test/test_nrf52_variant_contracts.py index 4702d429..d232ede9 100644 --- a/test/test_nrf52_variant_contracts.py +++ b/test/test_nrf52_variant_contracts.py @@ -34,6 +34,47 @@ def hex_define(source: str, name: str) -> int: class Nrf52VariantContractsTest(unittest.TestCase): + def test_rak3401_w25q16_handoff_keeps_rak13302_and_gps_isolated(self) -> None: + shared = (ROOT / "platformio.ini").read_text() + recipe = (ROOT / "variants/rak3401/platformio.ini").read_text() + variant = (ROOT / "variants/rak3401/variant.h").read_text() + store_h = (ROOT / "src/helpers/ota/OtaStoreQspiNrf52.h").read_text() + store_cpp = (ROOT / "src/helpers/ota/OtaStoreQspiNrf52.cpp").read_text() + + flash = ini_section(shared, "nrf52_wisblock_w25q16_ota") + for define in ( + "OTA_QSPI_SCK_ARDUINO_PIN=3", + "OTA_QSPI_CS_ARDUINO_PIN=31", + "OTA_QSPI_IO0_ARDUINO_PIN=30", + "OTA_QSPI_IO1_ARDUINO_PIN=29", + ): + self.assertIn(define, flash) + + profile = ini_section( + recipe, "env:RAK_3401_repeater_rak13302_w25q16_lora_ota" + ) + self.assertIn("OTA_QSPI_RAK3401_RADIO_BUS_HANDOFF=1", profile) + self.assertNotRegex(profile, r"-D\s+ENV_INCLUDE_GPS=0\b") + + self.assertRegex(variant, r"#define\s+SX126X_CS\s+\(26\)") + 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_PPS\s+\(17\)") + + self.assertIn( + "RAK3401 W25 flash and RAK13302 radio must use different chip-selects", + store_h, + ) + acquire = store_cpp.split("static void acquire_rak3401_radio_bus()", 1)[1] + acquire = acquire.split("static void release_rak3401_radio_bus()", 1)[0] + self.assertLess( + acquire.index("digitalWrite(P_LORA_NSS, HIGH)"), + acquire.index("SPI1.end()"), + ) + release = store_cpp.split("static void release_rak3401_radio_bus()", 1)[1] + release = release.split("#endif", 1)[0] + self.assertLess(release.index("disconnect_qspi_pins()"), release.index("SPI1.begin()")) + def test_rak3401_wisblock_i2c_aliases_are_complete(self) -> None: variant = (ROOT / "variants/rak3401/variant.h").read_text() diff --git a/test/test_packet_manager/test_rx_reserve_packet_manager.cpp b/test/test_packet_manager/test_rx_reserve_packet_manager.cpp index 825e5e57..5d0411c6 100644 --- a/test/test_packet_manager/test_rx_reserve_packet_manager.cpp +++ b/test/test_packet_manager/test_rx_reserve_packet_manager.cpp @@ -18,6 +18,7 @@ public: int soft_recoveries = 0; int hard_recoveries = 0; int cad_set_calls = 0; + int agc_resets = 0; bool cad_enabled = false; unsigned long last_irq = 0; bool recovery_result = true; @@ -53,6 +54,7 @@ public: cad_set_calls++; cad_enabled = enable; } + void resetAGC() override { agc_resets++; } unsigned long getLastRadioInterruptMillis() const override { return last_irq; } bool recoverRadio(bool hard) override { if (hard) { @@ -109,6 +111,11 @@ public: int forced_rx_delay = -1; bool bypass_rx_delay = false; bool configured_cad_enabled = false; + int configured_agc_interval = 0; + + int getAGCResetInterval() const override { + return configured_agc_interval; + } TestDispatcher(TestRadio& radio, TestClock& clock, RxReservePacketManager& mgr) : mesh::Dispatcher(radio, clock, mgr), manager(mgr) { } @@ -278,6 +285,204 @@ TEST(Dispatcher, ConfiguredCadStateIsPropagatedToTheRadio) { EXPECT_FALSE(radio.cad_enabled); } +TEST(Dispatcher, FirstAgcResetWaitsForConfiguredIntervalAfterStartup) { + RxReservePacketManager manager(4, 1); + TestClock clock; + clock.now = 1000; + TestRadio radio; + TestDispatcher dispatcher(radio, clock, manager); + dispatcher.begin(); + + // Repeater preferences are loaded after Dispatcher::begin(). The first loop + // must arm from that persisted value rather than treating a zero deadline as + // already expired. + dispatcher.configured_agc_interval = 8000; + dispatcher.loop(); + EXPECT_EQ(0, radio.agc_resets); + + clock.now = 8999; + dispatcher.loop(); + EXPECT_EQ(0, radio.agc_resets); + + clock.now = 9001; + dispatcher.loop(); + EXPECT_EQ(1, radio.agc_resets); +} + +TEST(Dispatcher, FirstAgcResetWaitsAfterDelayedRadioActivation) { + RxReservePacketManager manager(4, 1); + TestClock clock; + TestRadio radio; + TestDispatcher dispatcher(radio, clock, manager); + dispatcher.configured_agc_interval = 8000; + dispatcher.makeRadioAvailable(false); + dispatcher.begin(); + + clock.now = 2000; + dispatcher.makeRadioAvailable(true); + dispatcher.loop(); + EXPECT_EQ(0, radio.agc_resets); + + clock.now = 9999; + dispatcher.loop(); + EXPECT_EQ(0, radio.agc_resets); + + clock.now = 10001; + dispatcher.loop(); + EXPECT_EQ(1, radio.agc_resets); +} + +TEST(Dispatcher, EnablingAgcResetAtRuntimeArmsWithoutImmediateReset) { + RxReservePacketManager manager(4, 1); + TestClock clock; + TestRadio radio; + TestDispatcher dispatcher(radio, clock, manager); + dispatcher.begin(); + dispatcher.loop(); + + clock.now = 5000; + dispatcher.configured_agc_interval = 8000; + dispatcher.loop(); + EXPECT_EQ(0, radio.agc_resets); + + clock.now = 13001; + dispatcher.loop(); + EXPECT_EQ(1, radio.agc_resets); +} + +TEST(Dispatcher, LengtheningPositiveAgcIntervalRearmsTheDeadline) { + RxReservePacketManager manager(4, 1); + TestClock clock; + clock.now = 1000; + TestRadio radio; + TestDispatcher dispatcher(radio, clock, manager); + dispatcher.configured_agc_interval = 8000; + dispatcher.begin(); + dispatcher.loop(); + + clock.now = 2000; + dispatcher.configured_agc_interval = 240000; + dispatcher.loop(); + + // The former 8-second deadline must no longer fire. + clock.now = 9001; + dispatcher.loop(); + EXPECT_EQ(0, radio.agc_resets); + + clock.now = 242001; + dispatcher.loop(); + EXPECT_EQ(1, radio.agc_resets); +} + +TEST(Dispatcher, ShorteningPositiveAgcIntervalRearmsTheDeadline) { + RxReservePacketManager manager(4, 1); + TestClock clock; + clock.now = 1000; + TestRadio radio; + TestDispatcher dispatcher(radio, clock, manager); + dispatcher.configured_agc_interval = 240000; + dispatcher.begin(); + dispatcher.loop(); + + clock.now = 2000; + dispatcher.configured_agc_interval = 8000; + dispatcher.loop(); + + clock.now = 9999; + dispatcher.loop(); + EXPECT_EQ(0, radio.agc_resets); + + clock.now = 10001; + dispatcher.loop(); + EXPECT_EQ(1, radio.agc_resets); +} + +TEST(Dispatcher, DisablingAndReenablingAgcStartsANewFullInterval) { + RxReservePacketManager manager(4, 1); + TestClock clock; + TestRadio radio; + TestDispatcher dispatcher(radio, clock, manager); + dispatcher.configured_agc_interval = 8000; + dispatcher.begin(); + dispatcher.loop(); + + clock.now = 4000; + dispatcher.configured_agc_interval = 0; + dispatcher.loop(); + + clock.now = 10000; + dispatcher.configured_agc_interval = 8000; + dispatcher.loop(); + clock.now = 17999; + dispatcher.loop(); + EXPECT_EQ(0, radio.agc_resets); + + clock.now = 18001; + dispatcher.loop(); + EXPECT_EQ(1, radio.agc_resets); +} + +TEST(Dispatcher, AgcResetRepeatsAtTheConfiguredInterval) { + RxReservePacketManager manager(4, 1); + TestClock clock; + TestRadio radio; + TestDispatcher dispatcher(radio, clock, manager); + dispatcher.configured_agc_interval = 100; + dispatcher.begin(); + dispatcher.loop(); + + clock.now = 101; + dispatcher.loop(); + EXPECT_EQ(1, radio.agc_resets); + + clock.now = 202; + dispatcher.loop(); + EXPECT_EQ(2, radio.agc_resets); +} + +TEST(Dispatcher, RadioReactivationStartsANewFullAgcInterval) { + RxReservePacketManager manager(4, 1); + TestClock clock; + TestRadio radio; + TestDispatcher dispatcher(radio, clock, manager); + dispatcher.configured_agc_interval = 8000; + dispatcher.begin(); + dispatcher.loop(); + + clock.now = 7000; + dispatcher.makeRadioAvailable(false); + clock.now = 12000; + dispatcher.makeRadioAvailable(true); + dispatcher.loop(); + + clock.now = 19999; + dispatcher.loop(); + EXPECT_EQ(0, radio.agc_resets); + + clock.now = 20001; + dispatcher.loop(); + EXPECT_EQ(1, radio.agc_resets); +} + +TEST(Dispatcher, AgcResetDeadlineSurvivesMillisRollover) { + RxReservePacketManager manager(4, 1); + TestClock clock; + clock.now = ~0UL - 0xFFUL; + TestRadio radio; + TestDispatcher dispatcher(radio, clock, manager); + dispatcher.configured_agc_interval = 512; + dispatcher.begin(); + dispatcher.loop(); + + clock.now = 0xFFUL; + dispatcher.loop(); + EXPECT_EQ(0, radio.agc_resets); + + clock.now = 0x101UL; + dispatcher.loop(); + EXPECT_EQ(1, radio.agc_resets); +} + TEST(StaticPoolPacketManager, ReportsEarliestQueueTimesWithoutDequeuing) { StaticPoolPacketManager manager(8); mesh::Packet* later = manager.allocNew(); diff --git a/test/test_rak4631_ota_compatibility.sh b/test/test_rak4631_ota_compatibility.sh index 7ef864ef..bd5bcdc7 100644 --- a/test/test_rak4631_ota_compatibility.sh +++ b/test/test_rak4631_ota_compatibility.sh @@ -56,6 +56,9 @@ fi canonical_target=RAK_4631_repeater canonical_ota_target=RAK_4631_repeater_lora_ota_no_external_sensors +rak15001_target=RAK_4631_repeater_rak15001_slot_c_lora_ota +rak4631_w25_target=RAK_4631_repeater_w25q16_lora_ota +rak3401_w25_target=RAK_3401_repeater_rak13302_w25q16_lora_ota legacy_install_targets=( RAK_4631_repeater_bridge_rs232_serial1 RAK_4631_repeater_bridge_rs232_serial2 @@ -63,6 +66,9 @@ legacy_install_targets=( SUPPORTED_PIO_ENVS=( "$canonical_target" "$canonical_ota_target" + "$rak15001_target" + "$rak4631_w25_target" + "$rak3401_w25_target" "${legacy_install_targets[@]}" "${compatibility_targets[@]}" ) @@ -75,6 +81,9 @@ assert_array_equals \ "canonical bulk target list" all_targets \ "$canonical_target" \ "$canonical_ota_target" \ + "$rak15001_target" \ + "$rak4631_w25_target" \ + "$rak3401_w25_target" \ "${compatibility_targets[@]}" mapfile -t release_targets < <( @@ -83,6 +92,9 @@ mapfile -t release_targets < <( assert_array_equals \ "repeater release target list" release_targets \ "$canonical_target" \ + "$rak15001_target" \ + "$rak4631_w25_target" \ + "$rak3401_w25_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 5774c836..6effb100 100644 --- a/test/test_serial_mode_switch/test_serial_mode_switch.cpp +++ b/test/test_serial_mode_switch/test_serial_mode_switch.cpp @@ -52,6 +52,20 @@ public: static const char START_TOKEN[] = "+++MESHCORE-TERM-START"; static const char SEEDER_TOKEN[] = "ota folder on"; +TEST(UsbMotaOwnerPolicy, MatchesEveryOtaCliAttachDetachSpelling) { + EXPECT_TRUE(mesh::isUsbMotaOwnerTransitionCommand("ota folder on")); + EXPECT_TRUE(mesh::isUsbMotaOwnerTransitionCommand("ota folder off")); + EXPECT_TRUE(mesh::isUsbMotaOwnerTransitionCommand("ota fold on")); + EXPECT_TRUE(mesh::isUsbMotaOwnerTransitionCommand("ota fold off")); + EXPECT_TRUE(mesh::isUsbMotaOwnerTransitionCommand("ota folder on")); + EXPECT_TRUE(mesh::isUsbMotaOwnerTransitionCommand(" ota fold off")); + + EXPECT_FALSE(mesh::isUsbMotaOwnerTransitionCommand("ota folder")); + EXPECT_FALSE(mesh::isUsbMotaOwnerTransitionCommand("ota folder status")); + EXPECT_FALSE(mesh::isUsbMotaOwnerTransitionCommand("ota folder on extra")); + EXPECT_FALSE(mesh::isUsbMotaOwnerTransitionCommand("ota pull 1234 flash")); +} + class FakeSerialInterface : public BaseSerialInterface { public: bool enabled = false; @@ -154,6 +168,14 @@ TEST(MultiSerialInterface, RoutesRequiredRepliesToTheirRequestingInterface) { sizeof(best_effort_push)); ASSERT_EQ(usb.sent_frames.size(), 3u); ASSERT_EQ(wifi.sent_frames.size(), 1u); + + // MSG_WAITING is delivery-required for queue admission, but it is an + // unsolicited state notification and therefore remains broadcast. + const uint8_t message_waiting[] = {0x83}; + EXPECT_EQ(manager.writeFrame(message_waiting, sizeof(message_waiting)), + sizeof(message_waiting)); + ASSERT_EQ(usb.sent_frames.size(), 4u); + ASSERT_EQ(wifi.sent_frames.size(), 2u); } TEST(MultiSerialInterface, LocksMultiFrameRepliesToOneRequester) { @@ -189,6 +211,79 @@ TEST(MultiSerialInterface, LocksMultiFrameRepliesToOneRequester) { ASSERT_EQ(wifi.sent_frames.size(), 1u); } +TEST(MultiSerialInterface, ForgetsOnlyTheDisconnectedHostReplyRoute) { + MultiSerialInterface manager; + FakeSerialInterface usb; + FakeSerialInterface bluetooth; + usb.connected = true; + bluetooth.connected = true; + ASSERT_TRUE(manager.addInterface(InterfaceType::USB, &usb)); + ASSERT_TRUE(manager.addInterface(InterfaceType::Bluetooth, &bluetooth)); + manager.enable(); + + usb.received_frames.push_back({0x04}); + uint8_t command[MAX_FRAME_SIZE] = {}; + ASSERT_EQ(manager.checkRecvFrame(command), 1u); + manager.lockReplyRoute(); + EXPECT_TRUE(manager.isReplyRouteFor(&usb)); + EXPECT_FALSE(manager.isReplyRouteFor(&bluetooth)); + + manager.forgetReplyRouteForDisconnected(&bluetooth); + EXPECT_TRUE(manager.isReplyRouteFor(&usb)); + + // The application stops the USB response producer before forgetting this + // route. A new host then starts without inheriting either routing pointer. + manager.unlockReplyRoute(); + manager.forgetReplyRouteForDisconnected(&usb); + EXPECT_FALSE(manager.isReplyRouteFor(&usb)); + + bluetooth.received_frames.push_back({0x16}); + ASSERT_EQ(manager.checkRecvFrame(command), 1u); + EXPECT_EQ(command[0], 0x16); + EXPECT_TRUE(manager.isReplyRouteFor(&bluetooth)); +} + +TEST(MultiSerialInterface, CapturedAsyncReplySurvivesLaterRouteChanges) { + MultiSerialInterface manager; + FakeSerialInterface usb; + FakeSerialInterface bluetooth; + usb.connected = true; + bluetooth.connected = true; + ASSERT_TRUE(manager.addInterface(InterfaceType::USB, &usb)); + ASSERT_TRUE(manager.addInterface(InterfaceType::Bluetooth, &bluetooth)); + manager.enable(); + + usb.received_frames.push_back({0x04}); + uint8_t command[MAX_FRAME_SIZE] = {}; + ASSERT_EQ(manager.checkRecvFrame(command), 1u); + BaseSerialInterface* captured = manager.captureReplyRoute(); + ASSERT_EQ(captured, &usb); + + // Capturing while a contact stream owns the route is valid even after that + // producer releases its independent lock. + manager.lockReplyRoute(); + manager.unlockReplyRoute(); + + bluetooth.received_frames.push_back({0x16}); + ASSERT_EQ(manager.checkRecvFrame(command), 1u); + EXPECT_EQ(command[0], 0x16); + EXPECT_TRUE(manager.isReplyRouteFor(&bluetooth)); + + const uint8_t delayed_response[] = {0x85, 0x42}; + EXPECT_FALSE(manager.isReplyRouteWriteBusy(captured)); + EXPECT_EQ(manager.writeFrameToRoute( + captured, delayed_response, sizeof(delayed_response)), + sizeof(delayed_response)); + ASSERT_EQ(usb.sent_frames.size(), 1u); + EXPECT_TRUE(bluetooth.sent_frames.empty()); + + usb.connected = false; + EXPECT_FALSE(manager.isReplyRouteAvailable(captured)); + EXPECT_EQ(manager.writeFrameToRoute( + captured, delayed_response, sizeof(delayed_response)), 0u); + EXPECT_TRUE(bluetooth.sent_frames.empty()); +} + TEST(MultiSerialInterface, LosingLockedRequesterCannotFallBackToBroadcast) { MultiSerialInterface manager; FakeSerialInterface usb; diff --git a/test/test_st7735_display_wake_profile.py b/test/test_st7735_display_wake_profile.py new file mode 100644 index 00000000..e03d6a06 --- /dev/null +++ b/test/test_st7735_display_wake_profile.py @@ -0,0 +1,106 @@ +#!/usr/bin/env python3 + +from pathlib import Path +import unittest + + +ROOT = Path(__file__).resolve().parents[1] +DRIVER = ROOT / "src" / "helpers" / "ui" / "ST7735Display.cpp" + + +class ST7735DisplayWakeProfileTest(unittest.TestCase): + def test_power_off_releases_bus_without_uninitializing_spi(self): + source = DRIVER.read_text(encoding="utf-8") + turn_off_start = source.index("void ST7735Display::turnOff()") + turn_off_end = source.index("\n}\n", turn_off_start) + turn_off = source[turn_off_start:turn_off_end] + + self.assertNotIn("_spi->end();", turn_off) + self.assertIn("pinMode(PIN_TFT_SDA, INPUT);", turn_off) + self.assertIn("pinMode(PIN_TFT_SCL, INPUT);", turn_off) + + def test_begin_restores_bus_pins_before_spi_begin(self): + source = DRIVER.read_text(encoding="utf-8") + begin_start = source.index("bool ST7735Display::begin()") + begin_end = source.index("\n}\n", begin_start) + begin = source[begin_start:begin_end] + + sda_restore = begin.index("pinMode(PIN_TFT_SDA, OUTPUT);") + scl_restore = begin.index("pinMode(PIN_TFT_SCL, OUTPUT);") + spi_begin = begin.index("_spi->begin(") + self.assertLess(sda_restore, spi_begin) + self.assertLess(scl_restore, spi_begin) + + backlight_inactive = begin.index( + "digitalWrite(PIN_TFT_LEDA_CTL, !PIN_TFT_LEDA_CTL_ACTIVE);" + ) + backlight_output = begin.index("pinMode(PIN_TFT_LEDA_CTL, OUTPUT);") + self.assertLess(backlight_inactive, backlight_output) + + def test_nrf52_wake_restores_spi_high_drive(self): + source = DRIVER.read_text(encoding="utf-8") + begin_start = source.index("bool ST7735Display::begin()") + begin_end = source.index("\n}\n", begin_start) + begin = source[begin_start:begin_end] + + self.assertIn("nrf_gpio_cfg(PIN_TFT_SCL,", begin) + self.assertIn("NRF_GPIO_PIN_INPUT_CONNECT", begin) + self.assertIn("nrf_gpio_cfg(PIN_TFT_SDA,", begin) + self.assertIn("NRF_GPIO_PIN_INPUT_DISCONNECT", begin) + self.assertEqual(begin.count("NRF_GPIO_PIN_H0H1"), 2) + + def test_wake_reenters_full_display_begin_path(self): + source = DRIVER.read_text(encoding="utf-8") + turn_on_start = source.index("void ST7735Display::turnOn()") + turn_on_end = source.index("\n}\n", turn_on_start) + turn_on = source[turn_on_start:turn_on_end] + + self.assertIn("begin();", turn_on) + + def test_reset_release_waits_before_first_init_command(self): + source = DRIVER.read_text(encoding="utf-8") + reset_start = source.index("void ST7735Display::_resetAndInit()") + reset_end = source.index("\n}\n", reset_start) + reset = source[reset_start:reset_end] + + reset_release = reset.index("digitalWrite(PIN_TFT_RST, HIGH);", 100) + recovery_wait = reset.index("delay(120);", reset_release) + display_init = reset.index("displayInit(Rcmd1);", reset_release) + self.assertLess(reset_release, recovery_wait) + self.assertLess(recovery_wait, display_init) + + def test_begin_settles_normal_mode_before_marking_display_on(self): + source = DRIVER.read_text(encoding="utf-8") + begin_start = source.index("bool ST7735Display::begin()") + begin_end = source.index("\n}\n", begin_start) + begin = source[begin_start:begin_end] + + display_init = begin.index("_resetAndInit();") + normal_on = begin.index("sendCommand(ST77XX_NORON);", display_init) + normal_delay = begin.index("delay(10);", normal_on) + display_on = begin.index("sendCommand(ST77XX_DISPON);", normal_delay) + display_delay = begin.index("delay(100);", display_on) + prime_frame = begin.index("endFrame();", display_delay) + backlight = begin.index( + "digitalWrite(PIN_TFT_LEDA_CTL, PIN_TFT_LEDA_CTL_ACTIVE);", + prime_frame, + ) + marked_on = begin.index("_isOn = true;", backlight) + + self.assertLess(display_init, normal_on) + self.assertLess(normal_on, normal_delay) + self.assertLess(normal_delay, display_on) + self.assertLess(display_on, display_delay) + self.assertLess(display_delay, prime_frame) + self.assertLess(prime_frame, backlight) + self.assertLess(backlight, marked_on) + self.assertNotIn("fillScreen(ST77XX_BLACK)", begin[display_delay:prime_frame]) + + reset_start = source.index("void ST7735Display::_resetAndInit()") + reset_end = source.index("\n}\n", reset_start) + reset = source[reset_start:reset_end] + self.assertNotIn("endFrame();", reset) + + +if __name__ == "__main__": + unittest.main() diff --git a/test/test_t096_button_wake_profile.py b/test/test_t096_button_wake_profile.py new file mode 100644 index 00000000..1f559a35 --- /dev/null +++ b/test/test_t096_button_wake_profile.py @@ -0,0 +1,138 @@ +#!/usr/bin/env python3 + +from pathlib import Path +import unittest + + +ROOT = Path(__file__).resolve().parents[1] +TARGET = ROOT / "variants" / "heltec_t096" / "target.cpp" +PROFILE = ROOT / "variants" / "heltec_t096" / "platformio.ini" +UI = ROOT / "examples" / "companion_radio" / "ui-new" / "UITask.cpp" +DISPLAY = ROOT / "src" / "helpers" / "ui" / "ST7735Display.cpp" +BUTTON = ROOT / "src" / "helpers" / "ui" / "MomentaryButton.cpp" +BUTTON_HEADER = ROOT / "src" / "helpers" / "ui" / "MomentaryButton.h" +MAIN = ROOT / "examples" / "companion_radio" / "main.cpp" +REPEATER_MAIN = ROOT / "examples" / "simple_repeater" / "main.cpp" +ROOM_UI = ROOT / "examples" / "simple_room_server" / "UITask.cpp" +ROOM_MAIN = ROOT / "examples" / "simple_room_server" / "main.cpp" +SENSOR_UI = ROOT / "examples" / "simple_sensor" / "UITask.cpp" +SENSOR_MAIN = ROOT / "examples" / "simple_sensor" / "main.cpp" +NRF52_BOARD = ROOT / "src" / "helpers" / "NRF52Board.cpp" + + +class T096ButtonWakeProfileTest(unittest.TestCase): + def test_active_low_user_button_enables_internal_pullup(self): + target = TARGET.read_text(encoding="utf-8") + self.assertIn( + "MomentaryButton user_btn(PIN_USER_BTN, 1000, true, true, true);", + target, + ) + + def test_multiclick_remains_enabled_for_previous_page_gesture(self): + target = TARGET.read_text(encoding="utf-8") + self.assertIn( + "MomentaryButton user_btn(PIN_USER_BTN, 1000, true, true, true);", + target, + ) + + def test_nrf52_button_wakes_poller_on_press_and_release(self): + button = BUTTON.read_text(encoding="utf-8") + profile = PROFILE.read_text(encoding="utf-8") + t096_base = profile.split("[Heltec_t096]", 1)[1].split("[env:", 1)[0] + begin_start = button.index("void MomentaryButton::begin()") + begin_end = button.index("\n}\n", begin_start) + begin = button[begin_start:begin_end] + + self.assertIn("-D MOMENTARY_BUTTON_WAKE_FROM_SLEEP=1", t096_base) + self.assertIn("defined(MOMENTARY_BUTTON_WAKE_FROM_SLEEP)", button) + self.assertIn("wakeMomentaryButtonPoller", button) + self.assertIn( + "attachInterrupt((uint32_t)_pin, wakeMomentaryButtonPoller, CHANGE)", + begin, + ) + + def test_pending_gesture_deadlines_prevent_event_sleep(self): + header = BUTTON_HEADER.read_text(encoding="utf-8") + main = MAIN.read_text(encoding="utf-8") + repeater_main = REPEATER_MAIN.read_text(encoding="utf-8") + room_main = ROOM_MAIN.read_text(encoding="utf-8") + sensor_main = SENSOR_MAIN.read_text(encoding="utf-8") + + self.assertIn( + "return _debouncing || _press_active || _pending_click;", header + ) + self.assertIn("defined(MOMENTARY_BUTTON_WAKE_FROM_SLEEP)", main) + self.assertIn("!user_btn.needsPolling()", main) + self.assertIn("defined(MOMENTARY_BUTTON_WAKE_FROM_SLEEP)", repeater_main) + self.assertIn("!user_btn.needsPolling()", repeater_main) + self.assertIn("defined(MOMENTARY_BUTTON_WAKE_FROM_SLEEP)", room_main) + self.assertIn("!user_btn.needsPolling()", room_main) + self.assertIn("defined(MOMENTARY_BUTTON_WAKE_FROM_SLEEP)", sensor_main) + self.assertIn("!user_btn.needsPolling()", sensor_main) + + def test_room_and_sensor_use_event_driven_button_on_t096(self): + room_ui = ROOM_UI.read_text(encoding="utf-8") + sensor_ui = SENSOR_UI.read_text(encoding="utf-8") + + for source in (room_ui, sensor_ui): + self.assertIn('#include "target.h"', source) + self.assertIn("defined(MOMENTARY_BUTTON_WAKE_FROM_SLEEP)", source) + self.assertIn("defined(DISPLAY_CLASS)", source) + self.assertIn("user_btn.begin();", source) + self.assertIn("user_btn.check();", source) + self.assertIn("ev != BUTTON_EVENT_NONE", source) + self.assertIn("digitalRead(PIN_USER_BTN)", source) + + def test_raw_nrf52_event_wait_preserves_pending_gpio_edge(self): + board = NRF52_BOARD.read_text(encoding="utf-8") + sleep = board[board.index("void NRF52Board::sleep"):] + raw_wait = sleep.split("} else {", 1)[1] + + self.assertLess(raw_wait.index("__WFE();"), raw_wait.index("__SEV();")) + self.assertIn("__WFE();\n __SEV();\n __WFE();", raw_wait) + + def test_switch_bounce_is_stabilized_before_counting_clicks(self): + button = BUTTON.read_text(encoding="utf-8") + header = BUTTON_HEADER.read_text(encoding="utf-8") + + self.assertIn("#define BUTTON_DEBOUNCE_MS", button) + self.assertIn("raw_btn == prev", button) + self.assertIn("now - _candidate_since", button) + self.assertIn("press_candidate_in_window", button) + self.assertIn("bool _debouncing;", header) + self.assertIn("bool _press_active;", header) + + def test_release_only_polling_preserves_long_press(self): + button = BUTTON.read_text(encoding="utf-8") + release_start = button.index("// button UP") + release_end = button.index("down_at = 0;", release_start) + release = button[release_start:release_end] + + self.assertIn(">= (uint32_t)_long_millis", release) + self.assertIn("event = BUTTON_EVENT_LONG_PRESS", release) + + def test_last_message_navigation_returns_home_instead_of_noop(self): + ui = UI.read_text(encoding="utf-8") + + msg_input_start = ui.index("bool handleInput(char c) override", ui.index("class MsgPreviewScreen")) + msg_input_end = ui.index("\n }\n};", msg_input_start) + msg_input = ui[msg_input_start:msg_input_end] + + self.assertIn("if (view_offset + 1 < filteredCount())", msg_input) + self.assertIn("_task->gotoHomeScreen();", msg_input) + + def test_temporary_wake_diagnostics_are_not_shipped(self): + ui = UI.read_text(encoding="utf-8") + display = DISPLAY.read_text(encoding="utf-8") + button = BUTTON.read_text(encoding="utf-8") + + self.assertNotIn("T096_BUTTON_WAKE_DIAGNOSTICS", ui) + self.assertNotIn("T096_BUTTON_WAKE_DIAGNOSTICS", display) + self.assertNotIn("T096_BUTTON_WAKE_DIAGNOSTICS", button) + self.assertNotIn("T096_WAKE", ui) + self.assertNotIn("T096_WAKE", display) + self.assertNotIn("wake-bright", display) + + +if __name__ == "__main__": + unittest.main() diff --git a/test/test_t096_message_footer_profile.py b/test/test_t096_message_footer_profile.py new file mode 100644 index 00000000..82601a79 --- /dev/null +++ b/test/test_t096_message_footer_profile.py @@ -0,0 +1,51 @@ +#!/usr/bin/env python3 + +from pathlib import Path +import unittest + + +ROOT = Path(__file__).resolve().parents[1] +PROFILE = ROOT / "variants" / "heltec_t096" / "platformio.ini" +UI = ROOT / "examples" / "companion_radio" / "ui-new" / "UITask.cpp" + + +class T096MessageFooterProfileTest(unittest.TestCase): + def test_t096_disables_noninteractive_message_channel_footer(self): + profile = PROFILE.read_text(encoding="utf-8") + base_profile = profile.split("[Heltec_t096]", 1)[1].split( + "[env:", 1 + )[0] + build_flags = base_profile.split("build_flags =", 1)[1].split( + "build_src_filter =", 1 + )[0] + active_flags = { + line.strip() + for line in build_flags.splitlines() + if line.strip() and not line.lstrip().startswith(";") + } + self.assertIn("-D UI_MESSAGE_CHANNEL_FOOTER=0", active_flags) + + def test_other_ui_new_targets_keep_the_footer_by_default(self): + ui = UI.read_text(encoding="utf-8") + self.assertIn( + "#ifndef UI_MESSAGE_CHANNEL_FOOTER\n" + " #define UI_MESSAGE_CHANNEL_FOOTER 1\n" + "#endif", + ui, + ) + footer_start = ui.index( + " void renderChannelFilter(DisplayDriver& display) const {" + ) + footer_end = ui.index("\n }", footer_start) + footer = ui[footer_start:footer_end] + enabled, disabled = footer.split("#else", 1) + disabled = disabled.split("#endif", 1)[0] + self.assertIn("#if UI_MESSAGE_CHANNEL_FOOTER == 1", enabled) + self.assertIn("display.fillRect", enabled) + self.assertIn("display.drawTextCentered", enabled) + self.assertNotIn("display.", disabled) + self.assertIn("(void)display;", disabled) + + +if __name__ == "__main__": + unittest.main() diff --git a/tools/mota/motalib.py b/tools/mota/motalib.py index bc878c9b..837cab70 100644 --- a/tools/mota/motalib.py +++ b/tools/mota/motalib.py @@ -181,6 +181,19 @@ def mh32(data: bytes) -> bytes: return hashlib.sha256(data).digest() +def ed25519_public_bytes(key) -> bytes: + """Return a raw Ed25519 public key across supported cryptography versions.""" + public_key = key.public_key() if hasattr(key, "public_key") else key + public_bytes_raw = getattr(public_key, "public_bytes_raw", None) + if public_bytes_raw is not None: + return public_bytes_raw() + + # public_bytes_raw() was added after the stable serialization API. Keep + # the OTA builder usable on distro-provided cryptography releases too. + from cryptography.hazmat.primitives.serialization import Encoding, PublicFormat + return public_key.public_bytes(Encoding.Raw, PublicFormat.Raw) + + # --------------------------------------------------------------------------- # fw_version packing # --------------------------------------------------------------------------- @@ -224,12 +237,14 @@ def hardware_id_for_env(env_name: str) -> str: """ # Optional storage that requires a different bootloader/application pair is # a distinct hardware class even though it uses the same WisBlock Core. - if re.fullmatch( - r"RAK_4631_repeater_rak15001_slot_c_lora_ota", - env_name, - re.IGNORECASE, - ): - return "RAK4631_RAK15001_C" + exact_storage_hardware = { + "RAK_4631_repeater_rak15001_slot_c_lora_ota": "RAK4631_RAK15001_C", + "RAK_4631_repeater_w25q16_lora_ota": "RAK4631_W25Q16", + "RAK_3401_repeater_rak13302_w25q16_lora_ota": "RAK3401_RAK13302_W25Q16", + } + for exact_env, hardware_id in exact_storage_hardware.items(): + if env_name.casefold() == exact_env.casefold(): + return hardware_id role = re.search( r"[_-](?:repeater|repeatr|room_server|room_svr|sensor|terminal_chat|kiss_modem|" @@ -880,7 +895,7 @@ def build_manifest(*, target_id: int, fw_version: int, image_size: int, payload: if not is_full and (base_hash is None or len(base_hash) != 8): raise ValueError("delta requires an 8-byte base_hash") if sign_priv is not None: - m.signer_pubkey = sign_priv.public_key().public_bytes_raw() + m.signer_pubkey = ed25519_public_bytes(sign_priv) m.signature = sign_priv.sign(m.signed_region()) # else signer_pubkey/signature stay zero _validate_lengths(m) return m @@ -1029,10 +1044,7 @@ def verify(parsed: Parsed, *, expect_pub: Optional[bytes] = None, elif base_image is not None: # delta: optionally apply against a provided base to confirm image_hash try: - import detools - out = io.BytesIO() - detools.apply_patch(io.BytesIO(_ensure_base(base_image)), io.BytesIO(payload), out) - rebuilt = out.getvalue() + rebuilt = _apply_detools_delta(m.codec_id, payload, _ensure_base(base_image)) if mh32(rebuilt) != m.image_hash: problems.append("delta applied to base does not match image_hash") if len(rebuilt) != m.image_size: @@ -1042,6 +1054,32 @@ def verify(parsed: Parsed, *, expect_pub: Optional[bytes] = None, return problems +def _apply_detools_delta(codec_id: int, payload: bytes, base_image: bytes) -> bytes: + """Apply a detools delta using the decoder selected by its manifest codec.""" + import detools + + if codec_id == CODEC_DETOOLS_SEQUENTIAL: + out = io.BytesIO() + detools.apply_patch(io.BytesIO(base_image), io.BytesIO(payload), out) + return out.getvalue() + + if codec_id == CODEC_DETOOLS_INPLACE: + patch_type, patch_info = detools.patch_info(io.BytesIO(payload)) + if patch_type != "in-place": + raise ValueError(f"in-place codec contains a {patch_type} patch") + + # detools embeds the required flash-memory geometry in every in-place + # patch. apply_patch_in_place() requires a mutable image of at least + # that size and writes the reconstructed image at offset zero. + memory_size = patch_info[3] + memory = io.BytesIO(base_image.ljust(memory_size, b"\xff")) + rebuilt_size = detools.apply_patch_in_place(memory, io.BytesIO(payload)) + memory.seek(0) + return memory.read(rebuilt_size) + + raise ValueError(f"unsupported detools codec_id {codec_id}") + + def _ensure_base(base_image: bytes) -> bytes: img, _ = ensure_endf(base_image) return img diff --git a/tools/mota/test_mota.py b/tools/mota/test_mota.py index f426c790..cc9a7fc8 100644 --- a/tools/mota/test_mota.py +++ b/tools/mota/test_mota.py @@ -293,7 +293,8 @@ def test_canonical_bulk_matrix_omits_runtime_and_transport_aliases(): usb_logging = (root / "src/helpers/UsbLogging.cpp").read_text( encoding="utf-8" ) - assert "Adafruit_USBD_CDC dedicated_usb_logging_port" in usb_logging + assert "class DedicatedUsbLoggingCdc : public Adafruit_USBD_CDC" in usb_logging + assert "static DedicatedUsbLoggingCdc dedicated_usb_logging_port" in usb_logging assert "supported only by nRF52 Full Companion" in usb_logging assert "MESH_ESP32_DUAL_CDC_LOGGING" not in usb_logging assert "USBCDC(1)" not in usb_logging @@ -371,7 +372,7 @@ def test_single_tty_logging_off_restores_each_builds_default_mode(): # stays in ASCII, while a non-Full Companion returns to Binary immediately. command_disable = service.split( "the_mesh.handleTerminalCommand(usb_terminal_line);", 1 - )[1].split('Serial.print("> ");', 1)[0] + )[1].split('usbTerminalOutput().print("> ");', 1)[0] assert "!mesh::isUsbLoggingEnabled()" in command_disable assert "usb_logging_terminal_mode = false;" in command_disable assert "#if defined(COMPANION_RADIO_FULL)" in command_disable @@ -936,6 +937,14 @@ def test_hardware_id_for_env(): ml.hardware_id_for_env("RAK_4631_repeater_rak15001_slot_c_lora_ota") == "RAK4631_RAK15001_C" ) + assert ( + ml.hardware_id_for_env("RAK_4631_repeater_w25q16_lora_ota") + == "RAK4631_W25Q16" + ) + assert ( + ml.hardware_id_for_env("RAK_3401_repeater_rak13302_w25q16_lora_ota") + == "RAK3401_RAK13302_W25Q16" + ) assert ( ml.hardware_id_for_env("RAK_3401_repeater_rak15001_slot_c_lora_ota") == "RAK_3401" @@ -953,6 +962,38 @@ def test_hardware_id_for_env(): assert tag != ml.hardware_id_for_env("ikoka_handheld_nrf_e22_22dbm_096_rotated_room_server") +def test_wisblock_w25q16_profiles_are_exactly_bound(): + root = Path(__file__).resolve().parents[2] + common = (root / "platformio.ini").read_text(encoding="utf-8") + recipe = common.split("[nrf52_wisblock_w25q16_ota]", 1)[1].split("\n[", 1)[0] + assert "OTA_QSPI_EXPECTED_JEDEC_ID=0xEF4015UL" in recipe + assert "OTA_QSPI_EXPECTED_SIZE=2097152UL" in recipe + assert "OTA_QSPI_SHARED_WISBLOCK_SPI=1" in recipe + assert "OTA_QSPI_SCK_ARDUINO_PIN=3" in recipe + assert "OTA_QSPI_CS_ARDUINO_PIN=31" in recipe + assert "OTA_QSPI_IO0_ARDUINO_PIN=30" in recipe + assert "OTA_QSPI_IO1_ARDUINO_PIN=29" in recipe + assert "OTA_QSPI_IO2_NOT_CONNECTED=1" in recipe + assert "OTA_QSPI_IO3_NOT_CONNECTED=1" in recipe + + variant = (root / "variants/rak4631/platformio.ini").read_text(encoding="utf-8") + profile = variant.split( + "[env:RAK_4631_repeater_w25q16_lora_ota]", 1 + )[1].split("\n[", 1)[0] + assert "${nrf52_wisblock_w25q16_ota.build_flags}" in profile + assert "MOTA_HW_ID='\"RAK4631_W25Q16\"'" in profile + assert "OTA_FLASH_STORE=1" in profile + + rak3401 = (root / "variants/rak3401/platformio.ini").read_text(encoding="utf-8") + profile = rak3401.split( + "[env:RAK_3401_repeater_rak13302_w25q16_lora_ota]", 1 + )[1].split("\n[", 1)[0] + assert "${nrf52_wisblock_w25q16_ota.build_flags}" in profile + assert "OTA_QSPI_RAK3401_RADIO_BUS_HANDOFF=1" in profile + assert "MOTA_HW_ID='\"RAK3401_RAK13302_W25Q16\"'" in profile + assert "ENABLE_OTA=1" in profile + + # --- EndF ------------------------------------------------------------------ def test_endf_roundtrip_and_idempotent(): @@ -1283,7 +1324,7 @@ def test_bootloader_v3_build_parse_and_strict_contract(): assert parsed.manifest.format_ver == ml.BOOT_FORMAT_VER assert parsed.manifest.flags == ml.FLAG_FULL | ml.FLAG_SIGNED | ml.FLAG_BOOTLOADER assert parsed.manifest.hw_id == ml.hw_id_bytes("XIAO_BL_28860044") - assert ml.verify(parsed, expect_pub=priv.public_key().public_bytes_raw()) == [] + assert ml.verify(parsed, expect_pub=ml.ed25519_public_bytes(priv)) == [] invalid_payload = bytearray(parsed.payload) invalid_payload[4] &= 0xFE @@ -1325,7 +1366,7 @@ def test_generic_internal_bootloader_build_parse_and_strict_contract(): parsed = ml.parse_container(ml.build_container(manifest, image)) assert parsed.manifest.target_id == target assert parsed.manifest.hw_id == expected_hw - assert ml.verify(parsed, expect_pub=priv.public_key().public_bytes_raw()) == [] + assert ml.verify(parsed, expect_pub=ml.ed25519_public_bytes(priv)) == [] wrong_target = target ^ 1 try: @@ -1355,7 +1396,7 @@ def test_meshtower_sd_bootloader_profile_builds_the_same_exact_identity(): sign_priv=priv, bootloader=True) parsed = ml.parse_container(ml.build_container(manifest, image)) assert parsed.manifest.hw_id == b"NRF_BL_239A0071_TOWER_V2_OTA".ljust(32, b"\0") - assert ml.verify(parsed, expect_pub=priv.public_key().public_bytes_raw()) == [] + assert ml.verify(parsed, expect_pub=ml.ed25519_public_bytes(priv)) == [] def test_meshtower_sd_bootloader_build_flag_is_exact_target_only(): @@ -1672,9 +1713,9 @@ def test_signed_build_and_verify(): is_full=True, sign_priv=priv) parsed = ml.parse_container(ml.build_container(m, image)) assert parsed.manifest.is_signed - assert ml.verify(parsed, expect_pub=priv.public_key().public_bytes_raw()) == [] + assert ml.verify(parsed, expect_pub=ml.ed25519_public_bytes(priv)) == [] # wrong expected key -> flagged - other = Ed25519PrivateKey.generate().public_key().public_bytes_raw() + other = ml.ed25519_public_bytes(Ed25519PrivateKey.generate()) assert any("signer_pubkey" in p for p in ml.verify(parsed, expect_pub=other)) @@ -1740,6 +1781,37 @@ def test_delta_build_apply_verify(): assert wrong, "delta verify against a wrong base should fail" +def test_delta_in_place_build_apply_verify(): + old_body = _fw(22, 8 * 1024) + new_body = bytearray(old_body) + for i in range(700, 1200): + new_body[i] = (new_body[i] + 1) & 0xFF + new_body += _fw(23, 1024) + old_image, base_hash = ml.ensure_endf(old_body) + new_image, _ = ml.ensure_endf(bytes(new_body)) + + import detools + fp = io.BytesIO() + detools.create_patch( + io.BytesIO(old_image), io.BytesIO(new_image), fp, + patch_type="in-place", compression="crle", + memory_size=ml.NRF52_INPLACE_MEMORY + ml.NRF52_FLASH_PAGE, + segment_size=ml.NRF52_FLASH_PAGE, use_mmap=False) + delta = fp.getvalue() + + m = ml.build_manifest(target_id=0xABCD, fw_version=ml.pack_version("1.2.1"), + image_size=len(new_image), payload=delta, block_size=1024, + image_hash=ml.mh32(new_image), codec_id=ml.CODEC_DETOOLS_INPLACE, + is_full=False, base_hash=base_hash) + parsed = ml.parse_container(ml.build_container(m, delta)) + + # Verification must use the in-place decoder and compare the reconstructed + # bytes (not the full mutable memory image) with image_size/image_hash. + assert ml.verify(parsed, base_image=old_image) == [] + wrong = ml.verify(parsed, base_image=_fw(99, len(old_body))) + assert "delta applied to base does not match image_hash" in wrong + + # --- runner ---------------------------------------------------------------- def _run(): diff --git a/variants/heltec_t096/platformio.ini b/variants/heltec_t096/platformio.ini index 646b1685..1ce3783e 100644 --- a/variants/heltec_t096/platformio.ini +++ b/variants/heltec_t096/platformio.ini @@ -14,6 +14,9 @@ build_flags = ${rak4631_hw.build_flags} -I variants/heltec_t096 -I src/helpers/ui -D HELTEC_T096 + ; P1.10 must wake the event-driven nRF52 loop on both press and release so + ; the polling button state machine cannot sleep through a short click. + -D MOMENTARY_BUTTON_WAKE_FROM_SLEEP=1 -D NRF52_POWER_MANAGEMENT -D P_LORA_DIO_1=21 -D P_LORA_NSS=5 @@ -47,6 +50,9 @@ build_flags = ${rak4631_hw.build_flags} -D PIN_BAT_CTL=47 -D DISPLAY_CLASS=ST7735Display -D DISPLAY_ROTATION=1 + ; The 160x80 T096 has no touch/navigation control for the message channel + ; selector, so let message text use its 24 logical (30 physical) rows. + -D UI_MESSAGE_CHANNEL_FOOTER=0 build_src_filter = ${rak4631_hw.build_src_filter} + + diff --git a/variants/heltec_t096/target.cpp b/variants/heltec_t096/target.cpp index 30509977..0ff5b7db 100644 --- a/variants/heltec_t096/target.cpp +++ b/variants/heltec_t096/target.cpp @@ -30,7 +30,13 @@ EnvironmentSensorManager sensors; #ifdef DISPLAY_CLASS DISPLAY_CLASS display(&board.periph_power); -MomentaryButton user_btn(PIN_USER_BTN, 1000, true); +// The T096 USER switch is active-low. Keep the nRF52 pull-up enabled even +// though the schematic also shows an external 10K pull-up: the OTAFIX +// bootloader uses the same defensive configuration, and it prevents a weak or +// missing board resistor from leaving the application button input floating. +// Keep multi-click enabled now that the shared state machine rejects switch +// bounce and the event-driven loop remains awake through gesture deadlines. +MomentaryButton user_btn(PIN_USER_BTN, 1000, true, true, true); #endif bool radio_init() { diff --git a/variants/rak3401/platformio.ini b/variants/rak3401/platformio.ini index 9c13f8ac..8610fec1 100644 --- a/variants/rak3401/platformio.ini +++ b/variants/rak3401/platformio.ini @@ -90,6 +90,32 @@ lib_deps = adafruit/Adafruit SSD1306 @ ^2.5.13 sparkfun/SparkFun u-blox GNSS Arduino Library@^2.2.27 +; Full-sensor/GPS repeater with RAK13302 1 W radio and an exact 2 MiB Winbond +; W25Q16JV breakout. The flash shares SCK/MOSI/MISO with the RAK13302 but uses +; its own CS on J11 AIN1. The QSPI store temporarily releases and restores SPI1 +; while holding the radio NSS high. GPS remains supported in sensor slot A. +[env:RAK_3401_repeater_rak13302_w25q16_lora_ota] +extends = rak3401 +extra_scripts = ${nrf52_lora_ota.extra_scripts} +build_flags = + ${rak3401.build_flags} + ${nrf52_wisblock_w25q16_ota.build_flags} + -D OTA_QSPI_RAK3401_RADIO_BUS_HANDOFF=1 + -D MOTA_HW_ID='"RAK3401_RAK13302_W25Q16"' + -D DISPLAY_CLASS=SSD1306Display + -D ADVERT_NAME='"RAK3401 1W + W25Q16 OTA"' + -D ADVERT_LAT=0.0 + -D ADVERT_LON=0.0 + -D ADMIN_PASSWORD='"password"' + -D MAX_NEIGHBOURS=50 + -D ENABLE_OTA=1 + -D OTA_FOLDER_SERIAL + -D OTA_FETCH_PIPELINE=4 +build_src_filter = ${rak3401.build_src_filter} + + + + + +<../examples/simple_repeater> + [env:RAK_3401_room_server] extends = rak3401 build_flags = diff --git a/variants/rak3401/variant.cpp b/variants/rak3401/variant.cpp index 9301ecba..a62df4a7 100644 --- a/variants/rak3401/variant.cpp +++ b/variants/rak3401/variant.cpp @@ -52,6 +52,14 @@ void initVariant() // Keep it on from early startup and never cycle it for radio-only recovery. digitalWrite(PIN_3V3_EN, HIGH); +#if defined(OTA_QSPI_RAK3401_RADIO_BUS_HANDOFF) + // Keep the separately selected W25Q16 deselected before the RAK13302 starts + // using their shared SCK/MOSI/MISO nets. A physical pull-up is still + // required so CS# remains defined during reset and core-module swaps. + pinMode(OTA_QSPI_CS_ARDUINO_PIN, OUTPUT); + digitalWrite(OTA_QSPI_CS_ARDUINO_PIN, HIGH); +#endif + // Keep the RAK13302 FEM disabled until board startup has completed its // voltage check and explicitly enables the frontend. pinMode(SX126X_POWER_EN, OUTPUT); diff --git a/variants/rak4631/platformio.ini b/variants/rak4631/platformio.ini index 1439bd38..df08c29a 100644 --- a/variants/rak4631/platformio.ini +++ b/variants/rak4631/platformio.ini @@ -98,6 +98,29 @@ build_src_filter = ${rak4631.build_src_filter} + +<../examples/simple_repeater> +; Full-sensor/GPS repeater with an exact 2 MiB Winbond W25Q16JV breakout. The +; flash shares the RAK19007 SPI clock/data nets, uses its own CS on J11 AIN1, +; and does not occupy a sensor slot. The RAK4631 radio has a separate internal +; SPI bus. A matching exact-EF4015 OTAFIX bootloader is required. +[env:RAK_4631_repeater_w25q16_lora_ota] +extends = rak4631 +build_unflags = + -D MOTA_HW_ID='"RAK4631"' + -D OTA_FLASH_STORE=1 +build_flags = + ${rak4631.build_flags} + ${nrf52_wisblock_w25q16_ota.build_flags} + -D MOTA_HW_ID='"RAK4631_W25Q16"' + -D DISPLAY_CLASS=SSD1306Display + -D ADVERT_NAME='"RAK4631 + W25Q16 OTA"' + -D ADVERT_LAT=0.0 + -D ADVERT_LON=0.0 + -D ADMIN_PASSWORD='"password"' + -D MAX_NEIGHBOURS=50 +build_src_filter = ${rak4631.build_src_filter} + + + +<../examples/simple_repeater> + ; Lean OTA target for space-constrained RAK4631 repeaters. This removes optional external ; environmental sensor packages while retaining the common INA I2C voltage/current monitors and ; RAK12500/RAK12501 GPS support. The legacy no_external_sensors target name remains stable for OTA diff --git a/variants/rak4631/variant.cpp b/variants/rak4631/variant.cpp index bd85e971..d19c8ea3 100644 --- a/variants/rak4631/variant.cpp +++ b/variants/rak4631/variant.cpp @@ -45,5 +45,12 @@ void initVariant() pinMode(PIN_LED2, OUTPUT); ledOff(PIN_LED2);; -} +#if defined(OTA_QSPI_STORE) && defined(OTA_QSPI_CS_ARDUINO_PIN) + // Deselect a wired OTA NOR before any other device can toggle shared + // WisBlock SPI nets. The W25Q16 installation also needs a physical CS# + // pull-up so the signal is defined while the MCU is in reset. + pinMode(OTA_QSPI_CS_ARDUINO_PIN, OUTPUT); + digitalWrite(OTA_QSPI_CS_ARDUINO_PIN, HIGH); +#endif +}