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:
1 parent
5de1820765
commit
9816e21834
61 files changed
+4446
-226
No files matched your search
@@ -8,6 +8,7 @@
|
||||
out/
|
||||
site/
|
||||
# Local firmware archives and build logs
|
||||
.releases/
|
||||
out.*/
|
||||
out-*/
|
||||
build-logs/
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 = [];
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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)))
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
*
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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" },
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
};
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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
@@ -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
@@ -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():
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
Reference in new issue
Block a user