Harden nRF52 OTA, USB sessions, and T096 controls

Bind Companion replies to request transports, make dual-CDC USB sessions nonblocking and reconnect-safe, and harden nRF52 watchdog/OTA storage paths. Fix T096 button debounce, display wake, sleep races, and the compact message layout; add exact WisBlock OTA profiles plus expanded native/static/HIL documentation.
This commit is contained in:
mikecarper committed 2026-09-01 20:51:43 -07:00
1 parent 5de1820765
commit 9816e21834
61 files changed
+4446 -226

No files matched your search

+1
View File
@@ -8,6 +8,7 @@
out/
site/
# Local firmware archives and build logs
.releases/
out.*/
out-*/
build-logs/
+10 -4
View File
@@ -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
+4
View File
@@ -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 = [];
+6
View File
@@ -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
+178 -1
View File
@@ -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
+19 -1
View File
@@ -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
+80 -6
View File
@@ -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
+20 -1
View File
@@ -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
+307 -68
View File
@@ -5,6 +5,8 @@
#include <helpers/CompanionHardwareCommandCompat.h>
#include <helpers/CompanionStatusResponse.h>
#include <helpers/IdentityGeneration.h>
#include <helpers/UsbAsciiBinarySwitch.h>
#include <helpers/UsbLogging.h>
#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)))
+27 -6
View File
@@ -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
+175 -34
View File
@@ -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<const uint8_t*>(usb_terminal_line),
visible_len);
usbTerminalOutput().write(
reinterpret_cast<const uint8_t*>(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<const uint8_t*>(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<const uint8_t*>(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)
+15 -1
View File
@@ -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) {
+11 -1
View File
@@ -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();
+26 -4
View File
@@ -1,7 +1,5 @@
#include "UITask.h"
#ifdef DISPLAY_TOUCH_TOGGLE
#include "target.h"
#endif
#include <Arduino.h>
#include <helpers/CommonCLI.h>
#include <helpers/ui/WiFiSetupQrDisplay.h>
@@ -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);
+8 -1
View File
@@ -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();
+18 -1
View File
@@ -1,4 +1,5 @@
#include "UITask.h"
#include "target.h"
#include <Arduino.h>
#include <helpers/CommonCLI.h>
@@ -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) {
+8 -2
View File
@@ -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();
+22
View File
@@ -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
+30 -4
View File
@@ -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
+4
View File
@@ -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;
+14
View File
@@ -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.
+25
View File
@@ -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.
*
+3
View File
@@ -2,6 +2,7 @@
#include <stddef.h>
#include <stdint.h>
#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<const char*>(command), length)) return false;
if (token("ota folder", 10)) return false;
return true;
}
+43 -4
View File
@@ -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;
+6 -2
View File
@@ -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();
}
}
+370
View File
@@ -2,11 +2,373 @@
#include <Arduino.h>
#include <atomic>
#include <limits.h>
#include <stddef.h>
#include <stdint.h>
#include <string.h>
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<size_t>(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 <size_t MAX_WRITE_SIZE = 256>
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 <size_t CAPACITY = 4096, size_t PRINTF_SCRATCH_SIZE = 256>
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<size_t>(INT_MAX)
? INT_MAX
: static_cast<int>(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<size_t>(available)) {
attempt = static_cast<size_t>(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 <size_t SLOT_COUNT = 8, size_t MAX_WRITE_SIZE = 256>
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<int>(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<size_t>(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<size_t> _head{0};
std::atomic<size_t> _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.
+36
View File
@@ -1,9 +1,45 @@
#pragma once
#include <stdint.h>
#include <string.h>
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 {
+521 -17
View File
@@ -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 <Adafruit_TinyUSB.h>
#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 <Adafruit_TinyUSB.h>
#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<uint32_t> primary_usb_reset_generation{0};
static std::atomic<uint32_t> primary_usb_allowed_generation{0};
static std::atomic<bool> primary_usb_line_state_dtr{false};
static constexpr uint32_t primary_usb_session_settle_millis = 8;
static std::atomic<uint32_t> 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<uint8_t>(
reinterpret_cast<uintptr_t>(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<void*>(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<bool> 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<void*>(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<uint32_t> 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<bool> 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<int>(remaining)) {
(void)tud_cdc_n_write_flush(1);
return;
}
const size_t written = single_attempt_dedicated_usb_logging_port.write(
reinterpret_cast<const uint8_t*>(
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
+43 -3
View File
@@ -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();
@@ -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 <class/cdc/cdc.h>
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
+18 -2
View File
@@ -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
+57
View File
@@ -9,6 +9,9 @@
#include "nrf.h"
#include <Arduino.h>
#if defined(OTA_QSPI_RAK3401_RADIO_BUS_HANDOFF)
#include <SPI.h>
#endif
#include <string.h>
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) {
+18 -1
View File
@@ -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;
+4 -1
View File
@@ -2,7 +2,7 @@
#include <stdint.h>
// 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" },
+89 -22
View File
@@ -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;
}
}
+9 -2
View File
@@ -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;
}
};
+52 -11
View File
@@ -2,6 +2,10 @@
#include "../UsbLogging.h"
#include <TFT_eSPI.h>
#if defined(NRF52_PLATFORM)
#include <nrf_gpio.h>
#endif
//#include <Fonts/GFXFF/FreeSans9pt7b.h>
// 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);
+8
View File
@@ -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;
}
}
@@ -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"));
@@ -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;
+27
View File
@@ -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.
+4
View File
@@ -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(
+2 -1
View File
@@ -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)]
@@ -0,0 +1,242 @@
#include <Arduino.h>
#include <gtest/gtest.h>
#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();
}
@@ -1,5 +1,7 @@
#include <gtest/gtest.h>
#include <cstdarg>
#include <cstdio>
#include <string>
#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<const uint8_t*>(output),
static_cast<size_t>(required));
return static_cast<size_t>(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<size_t>(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<SingleAttemptContext*>(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<SingleAttemptContext*>(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<const uint8_t*>(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<size_t>(length));
EXPECT_EQ(port.write(reinterpret_cast<const uint8_t*>(line),
static_cast<size_t>(length)),
static_cast<size_t>(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();
+654 -5
View File
@@ -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<const uint8_t*>(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 <Arduino.h>", override)
self.assertNotIn("#include <Adafruit_TinyUSB.h>", override)
self.assertNotIn("#include <tusb.h>", override)
self.assertNotIn("#include <class/cdc/cdc_device.h>", override)
self.assertIn("#include <class/cdc/cdc.h>", override)
guard = "#if defined(ARDUINO) && defined(NRF52_PLATFORM)"
self.assertLess(override.index(guard), override.index(
"#include <class/cdc/cdc.h>"
))
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()
+41
View File
@@ -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()
@@ -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();
+12
View File
@@ -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"
@@ -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;
+106
View File
@@ -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()
+138
View File
@@ -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()
+51
View File
@@ -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()
+49 -11
View File
@@ -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
+79 -7
View File
@@ -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():
+6
View File
@@ -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}
+<helpers/*.cpp>
+<helpers/sensors>
+7 -1
View File
@@ -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() {
+26
View File
@@ -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}
+<helpers/ota/*.cpp>
+<helpers/ui/SSD1306Display.cpp>
+<../examples/simple_repeater>
[env:RAK_3401_room_server]
extends = rak3401
build_flags =
+8
View File
@@ -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);
+23
View File
@@ -98,6 +98,29 @@ build_src_filter = ${rak4631.build_src_filter}
+<helpers/ui/SSD1306Display.cpp>
+<../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}
+<helpers/ui/SSD1306Display.cpp>
+<../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
+8 -1
View File
@@ -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
}