Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7da10540d1
|
||
|
|
cf2ec92c4a
|
||
|
|
975713636b
|
||
|
|
688938d8e8
|
||
|
|
e9360c28ba
|
||
|
|
d7a1ee86d0
|
||
|
|
0b9f9ee882
|
||
|
|
04dc58c943
|
||
|
|
948589ae5d
|
||
|
|
aca4c3cffd
|
||
|
|
188e445e80
|
||
|
|
333dc1bb3b
|
||
|
|
6c6e74f26f
|
||
|
|
732bd18ac3
|
||
|
|
f7bac5903c
|
||
|
|
b56c044d57
|
||
|
|
f68e5d9be5
|
||
|
|
eb8202058f
|
||
|
|
1c98786ce6
|
||
|
|
16374ca241
|
||
|
|
e5aeb3896a
|
||
|
|
276f941fbb
|
No files matched your search
+232
-1
@@ -7,6 +7,237 @@ and this project adheres to [Semantic Versioning](https://semver.org/).
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [1.5.2] - 2026-05-11
|
||||
|
||||
### Changed
|
||||
|
||||
- Refreshed `Cargo.lock` to pull in the latest compatible patch/minor
|
||||
releases of dependencies. Notable bumps:
|
||||
- `donglora-protocol` 1.1.0 → 1.2.0
|
||||
- `esp-hal` 1.1.0 → 1.1.1
|
||||
- `heapless` 0.9.2 → 0.9.3 (propagates through embassy + esp crates)
|
||||
- `embassy-usb-driver` 0.2.0 → 0.2.1
|
||||
- `embassy-usb-synopsys-otg` 0.3.2 → 0.3.3
|
||||
- `enumset` 1.1.10 → 1.1.12
|
||||
- `digest` 0.11.2 → 0.11.3, `hashbrown` 0.17.0 → 0.17.1,
|
||||
`hybrid-array` 0.4.11 → 0.4.12, `cc` 1.2.61 → 1.2.62
|
||||
No source changes; all 10 board targets compile clean and host-side
|
||||
protocol tests pass.
|
||||
|
||||
## [1.5.1] - 2026-05-11
|
||||
|
||||
### Fixed
|
||||
|
||||
- **`LoRaConfig.sync_word` is now actually applied to the SX126x.**
|
||||
`reconfigure_radio` accepted the host's `sync_word` field in name
|
||||
only — the value was stored in `self.config` but never written to
|
||||
the chip's `LoRaSyncwordMSB`/`LoRaSyncwordLSB` register pair
|
||||
(0x0740/0x0741, SX1261/2 datasheet Rev 2.2 §13.4.4). The chip sat
|
||||
on whatever `LoRa::new(_, false, _)` set at cold init —
|
||||
`LORAWAN_PRIVATE_SYNCWORD` (byte `0x12`, register pair `0x1424`).
|
||||
Everyone got byte `0x12` regardless of the requested value. Worked
|
||||
by accident for MeshCore (which uses byte `0x12`); silently broke
|
||||
any host trying to talk to a non-MeshCore network. Fix wires
|
||||
`cfg.sync_word` through to `lora.set_lora_sync_word(byte)` after
|
||||
`enter_standby`, using the new lora-phy API from
|
||||
[lora-rs/lora-rs#433](https://github.com/lora-rs/lora-rs/pull/433).
|
||||
The `u16` protocol field is decoded back to its underlying byte
|
||||
(`byte = (hi & 0xF0) | (lo >> 4)` after `to_be_bytes()`), the
|
||||
inverse of lora-phy's `convert_sync_word` nibble-expansion. Honors
|
||||
SX1261/2 §8.3.1 (BUSY-low precondition) via the existing
|
||||
`ensure_ready + conditional set_standby` preamble in
|
||||
`LoRa::set_lora_sync_word`.
|
||||
- **First TX after `SET_CONFIG` no longer fails with `ERR(ERadio)` on
|
||||
a freshly-booted chip.** The chart's `prep_for_cad_act`
|
||||
(`TxCadPrep` entry) and `prep_for_tx_act` (`TxPrep` entry) both
|
||||
read `ctx.mdltn` and emit `TransmitFailed` → `ERR(ERadio)` if it's
|
||||
`None`. But `ctx.mdltn` was only ever assigned on the RX path —
|
||||
`try_start_rx_hw` (`RxArming` entry) and `cache_rx_pkt`
|
||||
(`RxSession` entry). `reconfigure_radio` already called
|
||||
`to_lora_mod_params(lora, cfg)` during `SET_CONFIG` but discarded
|
||||
the result via `let _ = …?;` (the dropped comment claimed image
|
||||
calibration; the function is pure compute on SX126x). Hidden in
|
||||
practice by any prior `RX_START` (ai-bot, `ping_pong --role rx`,
|
||||
etc.) leaving `ctx.mdltn` populated until the next reflash. Fix
|
||||
threads the `ModulationParams` out of `reconfigure_radio` as its
|
||||
return value, and `apply_set_config` caches it in `self.mdltn`
|
||||
alongside `self.config`. First TX on a fresh boot now works
|
||||
without a preceding `RX_START`.
|
||||
|
||||
### Changed
|
||||
|
||||
- **`lora-phy` `swaits/lora-rs#fixed` bumped from `e8a52ebd` →
|
||||
`043d7659`.** The fork's `fixed` merge now includes a new
|
||||
`feat/set-sync-word` branch alongside the three pre-existing
|
||||
`fix/*` branches.
|
||||
[lora-rs/lora-rs#433](https://github.com/lora-rs/lora-rs/pull/433)
|
||||
adds the runtime `LoRa::set_lora_sync_word(u8)` API the radio fix
|
||||
above depends on (was unavailable in stock lora-phy; the only
|
||||
public sync-word entry points were `with_syncword` /
|
||||
`new(_, public_network: bool, _)` at construction time). Strictly
|
||||
additive in lora-phy: pure addition, no behavioral change to
|
||||
`init_lora` / `with_syncword` / any existing path.
|
||||
- **`hsmc` 0.5 → 0.6.** Patch bump tracked via `cargo update -p
|
||||
hsmc`; no behavior change in this crate's usage. Pulled in
|
||||
separately from the radio fixes so the dep churn doesn't blur the
|
||||
fix commits.
|
||||
|
||||
## [1.5.0] - 2026-04-30
|
||||
|
||||
### Added
|
||||
|
||||
- **Heltec Mesh Node T114 board support** (nRF52840 + SX1262 + 1.14"
|
||||
240×135 ST7789 color TFT). The first color display board in the
|
||||
project, and the third nRF52840 + SX1262 board alongside the
|
||||
RAK4631 and Wio Tracker L1.
|
||||
- `VEXT_ENABLE` (P0.21) driven HIGH at boot to power the SX1262 +
|
||||
TFT rail. Without this, lora-phy init succeeds at the SPI level
|
||||
but TX_DONE never fires.
|
||||
- `VTFT_CTRL` on P0.03 (active-LOW); PWM backlight on P0.15
|
||||
(active-LOW, inverted) for smooth dim/bright transitions.
|
||||
- TFT SPI2 (SCK P1.08 / MOSI P1.09) at 8 MHz over EasyDMA, async
|
||||
`ExclusiveDevice` from `embedded-hal-bus`.
|
||||
- Linker uses the shared `ld/nrf52840-memory.x` so the S140
|
||||
SoftDevice region (0x01000–0x26000) is preserved per project
|
||||
policy.
|
||||
|
||||
- **`BoardDisplay` trait** (`src/display.rs`) bundles `render_*`,
|
||||
`present()`, `set_bright()`, and `set_dim()`. Replaces the previous
|
||||
`DrawTarget<Color = BinaryColor>` + `DisplayBrightness` combo as
|
||||
the `LoRaBoard::DisplayDriver` bound, so each board renders in its
|
||||
native pixel format and resolution without aliasing or
|
||||
color-quantization round-trips. Existing mono OLED drivers
|
||||
(`Sh1106`, `Ssd1306Async`) get adapter impls that delegate to the
|
||||
existing `display::render` module — no behavior change for any
|
||||
previously supported board.
|
||||
|
||||
- **Async ST7789 driver** (`src/driver/st7789.rs`) wrapping
|
||||
`lcd-async` (async fork of `mipidsi`) with a 64,800-byte raw-byte
|
||||
framebuffer in `.bss`. Renders via
|
||||
`lcd_async::raw_framebuf::RawFrameBuf<Rgb565, _>` and presents via
|
||||
a single async DMA blit (`panel.show_raw_data(...).await`); the
|
||||
await yields the executor for the ~65 ms SPI transfer so the radio
|
||||
task isn't starved during repaints.
|
||||
|
||||
- **Native 240×135 RGB565 renderer** (`src/display/render_color.rs`)
|
||||
with splash, dashboard, and sparkline parity with the mono path,
|
||||
plus a shared QR-code blit (`src/display/qr.rs`) generic over
|
||||
`PixelColor`. The blit re-centers the 64×64 bitmap (cells in cols
|
||||
0..50 with ~14 px right-side padding), so both mono and color
|
||||
splash now show the QR with 7 px quiet zones on all four sides
|
||||
(was visibly left-shifted on mono).
|
||||
|
||||
- **Shared content types** (`BoardInfo`, `DashboardCtx`) decouple
|
||||
*what* to draw from *how* each board renders it. `create_display()`
|
||||
now takes the board's full `DisplayParts` bundle (was just the I2C
|
||||
bus), so non-I2C displays can carry SPI + control pins through.
|
||||
|
||||
- **IDF app descriptor on every ESP image.** New
|
||||
`esp-bootloader-esp-idf` 0.5 dependency (chip-feature-gated like
|
||||
the rest of the `esp-*` stack) plus `esp_app_desc!()` in
|
||||
`src/main.rs`, which emits the `.flash.appdesc` section that
|
||||
`espflash` v4 requires in every ESP binary.
|
||||
|
||||
### Changed
|
||||
|
||||
- **ESP build modernized to upstream crates.io.** Removed the
|
||||
`[patch.crates-io]` block that pinned `esp-hal`, `esp-rtos`,
|
||||
`esp-backtrace`, and `esp-println` to `esp-rs/esp-hal#main`. The
|
||||
versions tracked there (`esp-hal 1.1`, `esp-rtos 0.3`,
|
||||
`esp-backtrace 0.19`, `esp-println 0.17`) have all reached
|
||||
crates.io with the `embassy-executor` 0.10 compatibility we needed,
|
||||
so the local fork pins are no longer required. `cargo:espflash`
|
||||
bumped to v4 in `mise.toml` to match.
|
||||
|
||||
- **`hsmc` 0.5.0 → 0.5.1.** Patch bump pulled in transparently with
|
||||
the lockfile refresh; no behavior change.
|
||||
|
||||
- **Documentation consolidated.** `PROTOCOL.md` removed from the
|
||||
firmware repo (it now lives canonically in the spec repo, linked
|
||||
from `README.md`). `BLE_MUX_DESIGN_DOC.md` moved to
|
||||
`docs/ble_mux_design.md` so all firmware design docs sit under
|
||||
`docs/`.
|
||||
|
||||
## [1.4.1] - 2026-04-29
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Major LoRa reliability boost across every radio family.** Switched
|
||||
`lora-phy` from crates.io to the
|
||||
[`swaits/lora-rs#fixed`](https://github.com/swaits/lora-rs/tree/fixed)
|
||||
fork branch — a merge of three fixes awaiting upstream PR acceptance,
|
||||
pinned at `e8a52ebd` via `Cargo.lock`:
|
||||
- [#428](https://github.com/lora-rs/lora-rs/pull/428) — sx126x:
|
||||
pre-command BUSY check + standby/IRQ-clear hygiene around CAD.
|
||||
- [#431](https://github.com/lora-rs/lora-rs/pull/431) — sx127x:
|
||||
clear stale IRQ flags in `do_rx` before mode change.
|
||||
- [#432](https://github.com/lora-rs/lora-rs/pull/432) — sx127x:
|
||||
clear stale IRQ flags before reconfiguring DIO mapping in
|
||||
`set_irq_params`.
|
||||
|
||||
Stability gain across the fleet: the sx126x BUSY-race fix lands on
|
||||
every nRF / RP2040 board, and the sx127x IRQ-clear fixes unblock the
|
||||
LilyGo T-Beam (classic) in particular.
|
||||
- **UART host RX delivery on UART-only boards.** `RADIO_THROTTLED` was
|
||||
never cleared on UART (`heltec_v3_uart`, `elecrow_thinknode_m2`,
|
||||
`lilygo_tbeam`) — the throttle gate added in v1.3.0's chart-first
|
||||
restructure silently dropped every async RX from boot. Mirrored the
|
||||
USB pattern: clear the throttle on successful `tx.write_all`, re-arm
|
||||
on failure.
|
||||
- **UART host watchdog spurious-trip under saturated bidirectional
|
||||
load.** The `select3`-based loop serialised read and write;
|
||||
`tx.write_all` (~25 ms per max frame at 115200 baud) blocked the read
|
||||
poll long enough for `last_frame_at` to age past 1000 ms, cascading a
|
||||
Reset to radio (`Unconfigured`) and display (splash) and leaving
|
||||
subsequent host TX commands timing out. Restructured `host_task` into
|
||||
two sub-loops joined in the same task: `read_loop` owns rx +
|
||||
watchdog, `write_loop` owns tx + throttle. Same executor, disjoint
|
||||
borrows on the split rx/tx halves, no Mutex needed. Also reset the
|
||||
`FrameDecoder` on watchdog fire so a host that resumes mid-frame
|
||||
resyncs cleanly on the next `0x00` sentinel (USB gets this for free
|
||||
via DTR-drop reset; UART has no DTR).
|
||||
- **Splash board-name overflow on long names.** Names that don't fit
|
||||
`FONT_6X10` (21 ASCII chars on a 128 px-wide OLED) now fall back to
|
||||
`FONT_5X8` (25 chars). A `const_assert` at the `BOARD_NAME`
|
||||
definition rejects names that overflow even the fallback, so the
|
||||
board porter shortens `NAME` at compile time rather than getting
|
||||
silent overflow at runtime. Also dropped the URL-line font on the
|
||||
"learn more" screen from `FONT_5X8` to `FONT_4X6` so `donglora.com`
|
||||
sits inside the 64 px column with breathing room instead of touching
|
||||
both edges.
|
||||
|
||||
### Changed
|
||||
|
||||
- **`hsmc` 0.4 → 0.5.** `default(...)` is optional on every state in
|
||||
0.5; collapsed the `WaitingForFirstFrame` substate that existed only
|
||||
because 0.4 required `Connected` to declare a default child.
|
||||
`Connected` itself is now the resting "DTR up, no traffic yet" state,
|
||||
with `on(FrameReceived) => Active` descending into the
|
||||
inactivity-timer state on the first byte. Behavior is identical
|
||||
(entry/exit chain math unchanged); the host/usb chart has one fewer
|
||||
state and one fewer `default()`. Display and Radio charts left as-is
|
||||
— their defaults serve real grouping/lifecycle purposes, not 0.4
|
||||
artifacts.
|
||||
- **ESP cross-toolchain pinned in `mise.toml`.** `[env]` now owns the
|
||||
PATH (`xtensa-esp-elf-gcc` bin) and `LIBCLANG_PATH`, so any `mise
|
||||
exec` resolves the linker without sourcing espup's stray
|
||||
`~/export-esp.sh`. New `[tasks.esp-install]` invokes espup with the
|
||||
matching crosstool version pin. `cargo:espup` pinned to `0.17.1` to
|
||||
match the `[env]` paths. Triggered by a real failure where
|
||||
`~/export-esp.sh` was moved to Trash and the ESP build broke with
|
||||
`linker xtensa-esp32s3-elf-gcc not found`. Bumping ESP versions
|
||||
remains a two-touch change: edit version dirs in BOTH
|
||||
`[tasks.esp-install]` and `[env]`.
|
||||
- **ESP dependency requirements bumped** to track the upstream
|
||||
`esp-rs/esp-hal` main branch: `esp-hal 1.0 → 1.1`, `esp-rtos 0.2 →
|
||||
0.3`, `esp-backtrace 0.18 → 0.19`, `esp-println 0.16 → 0.17`. `cargo
|
||||
update` had silently invalidated the `[patch.crates-io]` entries (the
|
||||
patched versions were no longer semver-compatible with the old
|
||||
top-level requirements), dropping the patches and falling back to
|
||||
crates.io releases that pin `embassy-executor 0.9` — mismatched with
|
||||
our `0.10`. Catching this keeps the patch suite consistent until the
|
||||
planned `[patch.crates-io]`-removal pass.
|
||||
|
||||
## [1.4.0] - 2026-04-25
|
||||
|
||||
### Added
|
||||
@@ -321,7 +552,7 @@ supported board.
|
||||
### Infrastructure
|
||||
|
||||
- Cargo.toml `repository` + `homepage` now point at the canonical
|
||||
`https://github.com/donglora/firmware` (the `swaits/donglora`
|
||||
`https://git.swaits.com/donglora/firmware` (the `swaits/donglora`
|
||||
repo is archived).
|
||||
|
||||
## [0.5.0] - 2026-04-16
|
||||
|
||||
Generated
+215
-122
@@ -125,9 +125,9 @@ checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a"
|
||||
|
||||
[[package]]
|
||||
name = "bitflags"
|
||||
version = "2.11.0"
|
||||
version = "2.11.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af"
|
||||
checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3"
|
||||
|
||||
[[package]]
|
||||
name = "block-buffer"
|
||||
@@ -167,9 +167,9 @@ checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
|
||||
|
||||
[[package]]
|
||||
name = "cc"
|
||||
version = "1.2.60"
|
||||
version = "1.2.62"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "43c5703da9466b66a946814e1adf53ea2c90f10063b86290cc9eb67ce3478a20"
|
||||
checksum = "a1dce859f0832a7d088c4f1119888ab94ef4b5d6795d1ce05afb7fe159d79f98"
|
||||
dependencies = [
|
||||
"find-msvc-tools",
|
||||
"shlex",
|
||||
@@ -210,7 +210,7 @@ checksum = "ac78aa94ce13e432b332a4d1bf2eff167d3a2520188ee05b337180a42fd2e62e"
|
||||
dependencies = [
|
||||
"cipher",
|
||||
"dbl",
|
||||
"digest 0.11.2",
|
||||
"digest 0.11.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -274,9 +274,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "cpubits"
|
||||
version = "0.1.0"
|
||||
version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5ef0c543070d296ea414df2dd7625d1b24866ce206709d8a4a424f28377f5861"
|
||||
checksum = "15b85f9c39137c3a891689859392b1bd49812121d0d61c9caf00d46ed5ce06ae"
|
||||
|
||||
[[package]]
|
||||
name = "cpufeatures"
|
||||
@@ -506,12 +506,12 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "defmt-rtt"
|
||||
version = "1.1.0"
|
||||
version = "0.4.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "93d5a25c99d89c40f5676bec8cefe0614f17f0f40e916f98e345dae941807f9e"
|
||||
checksum = "c6eca0aae8aa2cf8333200ecbd236274697bc0a394765c858b3d9372eb1abcfa"
|
||||
dependencies = [
|
||||
"critical-section",
|
||||
"defmt 1.0.1",
|
||||
"defmt 0.3.100",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -537,9 +537,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "digest"
|
||||
version = "0.11.2"
|
||||
version = "0.11.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4850db49bf08e663084f7fb5c87d202ef91a3907271aff24a94eb97ff039153c"
|
||||
checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2"
|
||||
dependencies = [
|
||||
"block-buffer 0.12.0",
|
||||
"crypto-common 0.2.1",
|
||||
@@ -586,13 +586,13 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "donglora"
|
||||
version = "1.4.0"
|
||||
version = "1.5.2"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"cortex-m",
|
||||
"cortex-m-rt",
|
||||
"critical-section",
|
||||
"defmt 1.0.1",
|
||||
"defmt 0.3.100",
|
||||
"defmt-rtt",
|
||||
"display-interface",
|
||||
"donglora-protocol",
|
||||
@@ -608,13 +608,16 @@ dependencies = [
|
||||
"embedded-graphics",
|
||||
"embedded-hal 1.0.0",
|
||||
"embedded-hal-async",
|
||||
"embedded-hal-bus",
|
||||
"embedded-io-async 0.7.0",
|
||||
"esp-backtrace",
|
||||
"esp-bootloader-esp-idf",
|
||||
"esp-hal",
|
||||
"esp-println",
|
||||
"esp-rtos",
|
||||
"heapless 0.9.2",
|
||||
"heapless 0.9.3",
|
||||
"hsmc",
|
||||
"lcd-async",
|
||||
"lora-phy",
|
||||
"minijinja",
|
||||
"owo-colors",
|
||||
@@ -627,12 +630,12 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "donglora-protocol"
|
||||
version = "1.1.0"
|
||||
version = "1.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6bae9e9e5a8cebc6b4167f9ef696b61ce25b94c6d8283dbe1b2e97075a481b9d"
|
||||
checksum = "0eea2a87a5ad31629140accc2bae6c931434bf1946706f2184dec9b3d356505d"
|
||||
dependencies = [
|
||||
"defmt 1.0.1",
|
||||
"heapless 0.9.2",
|
||||
"heapless 0.9.3",
|
||||
"ucobs",
|
||||
]
|
||||
|
||||
@@ -743,7 +746,7 @@ version = "0.10.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b715380574504335c40ec3016bc44598fac3c385f82959b26c41b25c30b7648d"
|
||||
dependencies = [
|
||||
"bitflags 2.11.0",
|
||||
"bitflags 2.11.1",
|
||||
"cfg-if",
|
||||
"cortex-m",
|
||||
"cortex-m-rt",
|
||||
@@ -850,7 +853,7 @@ dependencies = [
|
||||
"embedded-io-async 0.7.0",
|
||||
"futures-core",
|
||||
"futures-sink",
|
||||
"heapless 0.9.2",
|
||||
"heapless 0.9.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -880,12 +883,12 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "embassy-time-queue-utils"
|
||||
version = "0.3.0"
|
||||
version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "80e2ee86063bd028a420a5fb5898c18c87a8898026da1d4c852af2c443d0a454"
|
||||
checksum = "168297bf80aaf114b3c9ad589bf38b01b3009b9af7f97cd18086c5bbf96f5693"
|
||||
dependencies = [
|
||||
"embassy-executor-timer-queue",
|
||||
"heapless 0.8.0",
|
||||
"heapless 0.9.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -894,7 +897,7 @@ version = "0.6.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a25746d8b152b72fbf2a217f489a083dbbe243f281f09184a1f2cfbe9bbb245f"
|
||||
dependencies = [
|
||||
"bitflags 2.11.0",
|
||||
"bitflags 2.11.1",
|
||||
"defmt 1.0.1",
|
||||
"embassy-futures",
|
||||
"embassy-net-driver-channel",
|
||||
@@ -902,30 +905,32 @@ dependencies = [
|
||||
"embassy-time",
|
||||
"embassy-usb-driver",
|
||||
"embedded-io-async 0.7.0",
|
||||
"heapless 0.9.2",
|
||||
"heapless 0.9.3",
|
||||
"ssmarshal",
|
||||
"usbd-hid",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "embassy-usb-driver"
|
||||
version = "0.2.0"
|
||||
version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "17119855ccc2d1f7470a39756b12068454ae27a3eabb037d940b5c03d9c77b7a"
|
||||
checksum = "6a189cbf1d8ab3c591f88632f85c1df892b6ab04116037cbee39dc37cbdcd79f"
|
||||
dependencies = [
|
||||
"defmt 1.0.1",
|
||||
"embedded-io-async 0.6.1",
|
||||
"embedded-io-async 0.7.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "embassy-usb-synopsys-otg"
|
||||
version = "0.3.2"
|
||||
version = "0.3.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cbe46f4083109c7ea12a03ca61095d1e87c76fec52c7ca9ee06a42935606dacb"
|
||||
checksum = "fb205e26d59e483e8c48f91f637ec217ec7e2cfb0b61d50482301b991b4ff431"
|
||||
dependencies = [
|
||||
"critical-section",
|
||||
"embassy-sync 0.8.0",
|
||||
"embassy-time",
|
||||
"embassy-usb-driver",
|
||||
"portable-atomic",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -985,6 +990,17 @@ dependencies = [
|
||||
"embedded-hal 1.0.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "embedded-hal-bus"
|
||||
version = "0.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "513e0b3a8fb7d3013a8ae17a834283f170deaf7d0eeab0a7c1a36ad4dd356d22"
|
||||
dependencies = [
|
||||
"critical-section",
|
||||
"embedded-hal 1.0.0",
|
||||
"embedded-hal-async",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "embedded-hal-nb"
|
||||
version = "1.0.0"
|
||||
@@ -1057,18 +1073,18 @@ checksum = "a357d28ed41a50f9c765dbfe56cbc04a64e53e5fc58ba79fbc34c10ef3df831f"
|
||||
|
||||
[[package]]
|
||||
name = "enumset"
|
||||
version = "1.1.10"
|
||||
version = "1.1.12"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "25b07a8dfbbbfc0064c0a6bdf9edcf966de6b1c33ce344bdeca3b41615452634"
|
||||
checksum = "7f96a4a12fe60ac746ae295a1a4ecb5bb02debc20856506c8635288065f142de"
|
||||
dependencies = [
|
||||
"enumset_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "enumset_derive"
|
||||
version = "0.14.0"
|
||||
version = "0.15.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f43e744e4ea338060faee68ed933e46e722fb7f3617e722a5772d7e856d8b3ce"
|
||||
checksum = "4bd536557b58c682b217b8fb199afdff47cd3eff260623f19e77074eb073d63a"
|
||||
dependencies = [
|
||||
"darling 0.21.3",
|
||||
"proc-macro2",
|
||||
@@ -1084,8 +1100,9 @@ checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
|
||||
|
||||
[[package]]
|
||||
name = "esp-backtrace"
|
||||
version = "0.18.1"
|
||||
source = "git+https://github.com/esp-rs/esp-hal.git?branch=main#70930f20d9f681a30ff20aced39d0bd5363126d5"
|
||||
version = "0.19.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "37950e24b2dfd98f1581102d1798281d4d9547af881e6bffc2c2b534c026ec8f"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"defmt 1.0.1",
|
||||
@@ -1093,16 +1110,35 @@ dependencies = [
|
||||
"esp-config",
|
||||
"esp-metadata-generated",
|
||||
"esp-println",
|
||||
"heapless 0.9.2",
|
||||
"heapless 0.9.3",
|
||||
"riscv",
|
||||
"semihosting",
|
||||
"xtensa-lx",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "esp-bootloader-esp-idf"
|
||||
version = "0.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "35ffc117c3a9859835d89d0e90f5ee9886ce2264a71a849a7a22ab5308f6653c"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"defmt 1.0.1",
|
||||
"document-features",
|
||||
"embedded-storage",
|
||||
"esp-config",
|
||||
"esp-hal-procmacros",
|
||||
"esp-metadata-generated",
|
||||
"esp-rom-sys",
|
||||
"jiff",
|
||||
"strum",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "esp-config"
|
||||
version = "0.6.1"
|
||||
source = "git+https://github.com/esp-rs/esp-hal.git?branch=main#70930f20d9f681a30ff20aced39d0bd5363126d5"
|
||||
version = "0.7.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4d9b92fd9cfb0b4f8f1b6219b9763269a335571e307b014903b8201619374b80"
|
||||
dependencies = [
|
||||
"document-features",
|
||||
"esp-metadata-generated",
|
||||
@@ -1113,11 +1149,12 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "esp-hal"
|
||||
version = "1.0.0"
|
||||
source = "git+https://github.com/esp-rs/esp-hal.git?branch=main#70930f20d9f681a30ff20aced39d0bd5363126d5"
|
||||
version = "1.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2bfcf2a0842903717f4663f6a08512c32b0f6b2d7fb7db3c8a6895d2e6d49f72"
|
||||
dependencies = [
|
||||
"bitfield 0.19.4",
|
||||
"bitflags 2.11.0",
|
||||
"bitflags 2.11.1",
|
||||
"bytemuck",
|
||||
"cfg-if",
|
||||
"critical-section",
|
||||
@@ -1156,7 +1193,7 @@ dependencies = [
|
||||
"nb 1.1.0",
|
||||
"paste",
|
||||
"portable-atomic",
|
||||
"rand_core 0.10.0",
|
||||
"rand_core 0.10.1",
|
||||
"rand_core 0.6.4",
|
||||
"rand_core 0.9.5",
|
||||
"riscv",
|
||||
@@ -1170,8 +1207,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "esp-hal-procmacros"
|
||||
version = "0.21.0"
|
||||
source = "git+https://github.com/esp-rs/esp-hal.git?branch=main#70930f20d9f681a30ff20aced39d0bd5363126d5"
|
||||
version = "0.22.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6aebfabb2c21bec45e575e4f6cb6bb7aa8e1b33e7ac45b5dffa0f9d33ff59105"
|
||||
dependencies = [
|
||||
"document-features",
|
||||
"object",
|
||||
@@ -1184,13 +1222,15 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "esp-metadata-generated"
|
||||
version = "0.3.0"
|
||||
source = "git+https://github.com/esp-rs/esp-hal.git?branch=main#70930f20d9f681a30ff20aced39d0bd5363126d5"
|
||||
version = "0.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "42c2ee95b945a4780796e4359e72c033aed3b45073880e8029458f538532db8a"
|
||||
|
||||
[[package]]
|
||||
name = "esp-println"
|
||||
version = "0.16.1"
|
||||
source = "git+https://github.com/esp-rs/esp-hal.git?branch=main#70930f20d9f681a30ff20aced39d0bd5363126d5"
|
||||
version = "0.17.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "42dee1e9ac7c3539bf6464db1707b0edd7557168f98278cf3c84fe70e63c6ce6"
|
||||
dependencies = [
|
||||
"defmt 1.0.1",
|
||||
"document-features",
|
||||
@@ -1202,8 +1242,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "esp-riscv-rt"
|
||||
version = "0.13.0"
|
||||
source = "git+https://github.com/esp-rs/esp-hal.git?branch=main#70930f20d9f681a30ff20aced39d0bd5363126d5"
|
||||
version = "0.14.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "66a814ae91452de56a5e74f69aebfee40579511756837d3774a56fd24cf0ab79"
|
||||
dependencies = [
|
||||
"document-features",
|
||||
"riscv",
|
||||
@@ -1212,8 +1253,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "esp-rom-sys"
|
||||
version = "0.1.3"
|
||||
source = "git+https://github.com/esp-rs/esp-hal.git?branch=main#70930f20d9f681a30ff20aced39d0bd5363126d5"
|
||||
version = "0.1.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "eae852ccb08971155023d1371c96d5490cbc26860f06aee2d629ef73f1a890c3"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"document-features",
|
||||
@@ -1224,8 +1266,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "esp-rtos"
|
||||
version = "0.2.0"
|
||||
source = "git+https://github.com/esp-rs/esp-hal.git?branch=main#70930f20d9f681a30ff20aced39d0bd5363126d5"
|
||||
version = "0.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "551f90766e1527edaa0c91e8d559e9e2a60397b545e93357ac61fb31845e5712"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"document-features",
|
||||
@@ -1246,8 +1289,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "esp-sync"
|
||||
version = "0.1.1"
|
||||
source = "git+https://github.com/esp-rs/esp-hal.git?branch=main#70930f20d9f681a30ff20aced39d0bd5363126d5"
|
||||
version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b4736bfbbb9e3f6353344e14fc61b6d18d3b877c3286914cf8c0a037be0ed224"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"document-features",
|
||||
@@ -1274,64 +1318,64 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "esp32"
|
||||
version = "0.39.0"
|
||||
source = "git+https://github.com/esp-rs/esp-pacs?rev=fc3e6d4#fc3e6d4b34cfc885f750641d27e6fe3e63bad5d2"
|
||||
version = "0.40.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5726e07689249d1a2cb7c492077bc424837fb68a64f7eb5d46569325352e9428"
|
||||
dependencies = [
|
||||
"critical-section",
|
||||
"vcell",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "esp32c2"
|
||||
version = "0.28.0"
|
||||
source = "git+https://github.com/esp-rs/esp-pacs?rev=fc3e6d4#fc3e6d4b34cfc885f750641d27e6fe3e63bad5d2"
|
||||
version = "0.29.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5ef0b623533bbaa37e348c18b6b41cfd5b47c3cb64a4b9e44f0295941d62aa2e"
|
||||
dependencies = [
|
||||
"critical-section",
|
||||
"vcell",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "esp32c3"
|
||||
version = "0.31.0"
|
||||
source = "git+https://github.com/esp-rs/esp-pacs?rev=fc3e6d4#fc3e6d4b34cfc885f750641d27e6fe3e63bad5d2"
|
||||
version = "0.32.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "21e89ed62cf6c043a6d29c520b02a13b359ec8a75d67b65d4330ed717d15fe97"
|
||||
dependencies = [
|
||||
"critical-section",
|
||||
"vcell",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "esp32c6"
|
||||
version = "0.22.0"
|
||||
source = "git+https://github.com/esp-rs/esp-pacs?rev=fc3e6d4#fc3e6d4b34cfc885f750641d27e6fe3e63bad5d2"
|
||||
version = "0.23.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c58f34ff2633968c12125efc7f4f8f101078d5d34c7cb60eab82268db20986f9"
|
||||
dependencies = [
|
||||
"critical-section",
|
||||
"vcell",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "esp32h2"
|
||||
version = "0.18.0"
|
||||
source = "git+https://github.com/esp-rs/esp-pacs?rev=fc3e6d4#fc3e6d4b34cfc885f750641d27e6fe3e63bad5d2"
|
||||
version = "0.19.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c5bab026020ed4606ce113b6fde598dbc48f7eefcc46e9469ece77cc2b1aa4be"
|
||||
dependencies = [
|
||||
"critical-section",
|
||||
"vcell",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "esp32s2"
|
||||
version = "0.30.0"
|
||||
source = "git+https://github.com/esp-rs/esp-pacs?rev=fc3e6d4#fc3e6d4b34cfc885f750641d27e6fe3e63bad5d2"
|
||||
version = "0.31.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d0ad6f21cdf6ec7b06b7f7e0fbe51f0d975fd6a5fa67c3f8a5a910d3981af531"
|
||||
dependencies = [
|
||||
"critical-section",
|
||||
"vcell",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "esp32s3"
|
||||
version = "0.34.0"
|
||||
source = "git+https://github.com/esp-rs/esp-pacs?rev=fc3e6d4#fc3e6d4b34cfc885f750641d27e6fe3e63bad5d2"
|
||||
version = "0.35.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8b4b8c4e4d9f187553ecdb7173edec7b2deb2beea106eedefecdb1654b8ee25a"
|
||||
dependencies = [
|
||||
"critical-section",
|
||||
"vcell",
|
||||
]
|
||||
|
||||
@@ -1511,9 +1555,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "hashbrown"
|
||||
version = "0.16.1"
|
||||
version = "0.17.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100"
|
||||
checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
|
||||
|
||||
[[package]]
|
||||
name = "heapless"
|
||||
@@ -1527,9 +1571,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "heapless"
|
||||
version = "0.9.2"
|
||||
version = "0.9.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2af2455f757db2b292a9b1768c4b70186d443bcb3b316252d6b540aec1cd89ed"
|
||||
checksum = "25ba4bd83f9415b58b4ed8dc5714c76e626a105be4646c02630ad730ad3b5aa4"
|
||||
dependencies = [
|
||||
"defmt 1.0.1",
|
||||
"hash32",
|
||||
@@ -1550,10 +1594,11 @@ checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
|
||||
|
||||
[[package]]
|
||||
name = "hsmc"
|
||||
version = "0.2.0"
|
||||
version = "0.6.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "960effec6ded897c013d5cde8fb7f09bf0137e783efc4c2e0346092a2941c46e"
|
||||
checksum = "18bb06fda289e60e17bc7483a222fc6d9d6865b92648f836242b7a79115cc91d"
|
||||
dependencies = [
|
||||
"defmt 0.3.100",
|
||||
"embassy-futures",
|
||||
"embassy-sync 0.8.0",
|
||||
"embassy-time",
|
||||
@@ -1563,9 +1608,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "hsmc-macros"
|
||||
version = "0.2.0"
|
||||
version = "0.6.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3e1933eea1e95edb4fa03688f969f0a1ffabc4f16fc396b40ead6bedd5c4bdff"
|
||||
checksum = "289a7c9f1b677168229c8ad4e992380c3951d0908b52b1d2bfae8ff3c980c35d"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
@@ -1574,9 +1619,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "hybrid-array"
|
||||
version = "0.4.10"
|
||||
version = "0.4.12"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3944cf8cf766b40e2a1a333ee5e9b563f854d5fa49d6a8ca2764e97c6eddb214"
|
||||
checksum = "9155a582abd142abc056962c29e3ce5ff2ad5469f4246b537ed42c5deba857da"
|
||||
dependencies = [
|
||||
"typenum",
|
||||
]
|
||||
@@ -1589,12 +1634,12 @@ checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39"
|
||||
|
||||
[[package]]
|
||||
name = "indexmap"
|
||||
version = "2.13.0"
|
||||
version = "2.14.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017"
|
||||
checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9"
|
||||
dependencies = [
|
||||
"equivalent",
|
||||
"hashbrown 0.16.1",
|
||||
"hashbrown 0.17.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1643,6 +1688,30 @@ version = "1.0.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
|
||||
|
||||
[[package]]
|
||||
name = "jiff"
|
||||
version = "0.2.24"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f00b5dbd620d61dfdcb6007c9c1f6054ebd75319f163d886a9055cec1155073d"
|
||||
dependencies = [
|
||||
"jiff-static",
|
||||
"log",
|
||||
"portable-atomic",
|
||||
"portable-atomic-util",
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "jiff-static"
|
||||
version = "0.2.24"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e000de030ff8022ea1da3f466fbb0f3a809f5e51ed31f6dd931c35181ad8e6d7"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "keccak"
|
||||
version = "0.1.6"
|
||||
@@ -1691,10 +1760,22 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe"
|
||||
|
||||
[[package]]
|
||||
name = "libc"
|
||||
version = "0.2.184"
|
||||
name = "lcd-async"
|
||||
version = "0.1.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "48f5d2a454e16a5ea0f4ced81bd44e4cfc7bd3a507b61887c99fd3538b28e4af"
|
||||
checksum = "359805d2afdff45f3abad226ae27a61ee980309562d14084842c542af29b422a"
|
||||
dependencies = [
|
||||
"embedded-graphics",
|
||||
"embedded-graphics-core",
|
||||
"embedded-hal 1.0.0",
|
||||
"embedded-hal-async",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "libc"
|
||||
version = "0.2.186"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66"
|
||||
|
||||
[[package]]
|
||||
name = "litrs"
|
||||
@@ -1733,7 +1814,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "lora-modulation"
|
||||
version = "0.1.5"
|
||||
source = "git+https://github.com/swaits/lora-rs.git?branch=fix%2Fsx126x-busy-race#f9ebb303a7029cbc3f19f0760ae164946000b152"
|
||||
source = "git+https://github.com/swaits/lora-rs.git?branch=fixed#043d7659dfe2148913f16b2f7ea3e9241642b770"
|
||||
dependencies = [
|
||||
"defmt 0.3.100",
|
||||
]
|
||||
@@ -1741,7 +1822,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "lora-phy"
|
||||
version = "3.0.2-alpha"
|
||||
source = "git+https://github.com/swaits/lora-rs.git?branch=fix%2Fsx126x-busy-race#f9ebb303a7029cbc3f19f0760ae164946000b152"
|
||||
source = "git+https://github.com/swaits/lora-rs.git?branch=fixed#043d7659dfe2148913f16b2f7ea3e9241642b770"
|
||||
dependencies = [
|
||||
"defmt 0.3.100",
|
||||
"document-features",
|
||||
@@ -1755,7 +1836,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "lorawan"
|
||||
version = "0.9.1"
|
||||
source = "git+https://github.com/swaits/lora-rs.git?branch=fix%2Fsx126x-busy-race#f9ebb303a7029cbc3f19f0760ae164946000b152"
|
||||
source = "git+https://github.com/swaits/lora-rs.git?branch=fixed#043d7659dfe2148913f16b2f7ea3e9241642b770"
|
||||
dependencies = [
|
||||
"aes",
|
||||
"cmac",
|
||||
@@ -1767,13 +1848,13 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "lorawan-device"
|
||||
version = "0.12.2"
|
||||
source = "git+https://github.com/swaits/lora-rs.git?branch=fix%2Fsx126x-busy-race#f9ebb303a7029cbc3f19f0760ae164946000b152"
|
||||
source = "git+https://github.com/swaits/lora-rs.git?branch=fixed#043d7659dfe2148913f16b2f7ea3e9241642b770"
|
||||
dependencies = [
|
||||
"defmt 0.3.100",
|
||||
"document-features",
|
||||
"fastrand",
|
||||
"futures",
|
||||
"heapless 0.9.2",
|
||||
"heapless 0.9.3",
|
||||
"lora-modulation",
|
||||
"lorawan",
|
||||
"rand_core 0.6.4",
|
||||
@@ -1782,7 +1863,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "lorawan-macros"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/swaits/lora-rs.git?branch=fix%2Fsx126x-busy-race#f9ebb303a7029cbc3f19f0760ae164946000b152"
|
||||
source = "git+https://github.com/swaits/lora-rs.git?branch=fixed#043d7659dfe2148913f16b2f7ea3e9241642b770"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
@@ -1831,9 +1912,9 @@ checksum = "c3c8dda44ff03a2f238717214da50f65d5a53b45cd213a7370424ffdb6fae815"
|
||||
|
||||
[[package]]
|
||||
name = "minijinja"
|
||||
version = "2.18.0"
|
||||
version = "2.19.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "328251e58ad8e415be6198888fc207502727dc77945806421ab34f35bf012e7d"
|
||||
checksum = "805bfd7352166bae857ee569628b52bcd85a1cecf7810861ebceb1686b72b75d"
|
||||
dependencies = [
|
||||
"memo-map",
|
||||
"serde",
|
||||
@@ -2057,6 +2138,15 @@ dependencies = [
|
||||
"critical-section",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "portable-atomic-util"
|
||||
version = "0.2.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c2a106d1259c23fac8e543272398ae0e3c0b8d33c88ed73d0cc71b0f1d902618"
|
||||
dependencies = [
|
||||
"portable-atomic",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "precomputed-hash"
|
||||
version = "0.1.1"
|
||||
@@ -2133,7 +2223,7 @@ version = "0.11.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "679341d22c78c6c649893cbd6c3278dcbe9fc4faa62fea3a9296ae2b50c14625"
|
||||
dependencies = [
|
||||
"bitflags 2.11.0",
|
||||
"bitflags 2.11.1",
|
||||
"memchr",
|
||||
"unicase",
|
||||
]
|
||||
@@ -2176,9 +2266,9 @@ checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c"
|
||||
|
||||
[[package]]
|
||||
name = "rand_core"
|
||||
version = "0.10.0"
|
||||
version = "0.10.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0c8d0fd677905edcbeedbf2edb6494d676f0e98d54d5cf9bda0b061cb8fb8aba"
|
||||
checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69"
|
||||
|
||||
[[package]]
|
||||
name = "redox_syscall"
|
||||
@@ -2186,7 +2276,7 @@ version = "0.5.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d"
|
||||
dependencies = [
|
||||
"bitflags 2.11.0",
|
||||
"bitflags 2.11.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2438,9 +2528,9 @@ checksum = "5f179d4e11094a893b82fff208f74d448a7512f99f5a0acbd5c679b705f83ed9"
|
||||
|
||||
[[package]]
|
||||
name = "sha3"
|
||||
version = "0.10.8"
|
||||
version = "0.10.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "75872d278a8f37ef87fa0ddbda7802605cb18344497949862c0d4dcb291eba60"
|
||||
checksum = "77fd7028345d415a4034cf8777cd4f8ab1851274233b45f84e3d955502d93874"
|
||||
dependencies = [
|
||||
"digest 0.10.7",
|
||||
"keccak",
|
||||
@@ -2463,9 +2553,9 @@ checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64"
|
||||
|
||||
[[package]]
|
||||
name = "siphasher"
|
||||
version = "1.0.2"
|
||||
version = "1.0.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b2aa850e253778c88a04c3d7323b043aeda9d3e30d5971937c1855769763678e"
|
||||
checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649"
|
||||
|
||||
[[package]]
|
||||
name = "smallvec"
|
||||
@@ -2665,9 +2755,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "toml_edit"
|
||||
version = "0.25.9+spec-1.1.0"
|
||||
version = "0.25.11+spec-1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "da053d28fe57e2c9d21b48261e14e7b4c8b670b54d2c684847b91feaf4c7dac5"
|
||||
checksum = "0b59c4d22ed448339746c59b905d24568fcbb3ab65a500494f7b8c3e97739f2b"
|
||||
dependencies = [
|
||||
"indexmap",
|
||||
"toml_datetime",
|
||||
@@ -2677,9 +2767,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "toml_parser"
|
||||
version = "1.1.1+spec-1.1.0"
|
||||
version = "1.1.2+spec-1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "39ca317ebc49f06bd748bfba29533eac9485569dc9bf80b849024b025e814fb9"
|
||||
checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526"
|
||||
dependencies = [
|
||||
"winnow",
|
||||
]
|
||||
@@ -2747,9 +2837,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "typenum"
|
||||
version = "1.19.0"
|
||||
version = "1.20.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb"
|
||||
checksum = "40ce102ab67701b8526c123c1bab5cbe42d7040ccfd0f64af1a385808d2f43de"
|
||||
|
||||
[[package]]
|
||||
name = "ucobs"
|
||||
@@ -2916,9 +3006,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "winnow"
|
||||
version = "1.0.1"
|
||||
version = "1.0.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "09dac053f1cd375980747450bfc7250c264eaae0583872e845c0c7cd578872b5"
|
||||
checksum = "2ee1708bef14716a11bae175f579062d4554d95be2c6829f518df847b7b3fdd0"
|
||||
dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
@@ -2926,15 +3016,17 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "xtensa-lx"
|
||||
version = "0.13.0"
|
||||
source = "git+https://github.com/esp-rs/esp-hal.git?branch=main#70930f20d9f681a30ff20aced39d0bd5363126d5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e012d667b0aa6d2592ace8ef145a98bff3e76cca7a644f4181ecd7a916ed289b"
|
||||
dependencies = [
|
||||
"critical-section",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "xtensa-lx-rt"
|
||||
version = "0.21.0"
|
||||
source = "git+https://github.com/esp-rs/esp-hal.git?branch=main#70930f20d9f681a30ff20aced39d0bd5363126d5"
|
||||
version = "0.22.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "409a9b4629d429e995cde4dfbd9fe562ccae66f7624514e200733fc5d0ea8905"
|
||||
dependencies = [
|
||||
"document-features",
|
||||
"xtensa-lx",
|
||||
@@ -2944,7 +3036,8 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "xtensa-lx-rt-proc-macros"
|
||||
version = "0.5.0"
|
||||
source = "git+https://github.com/esp-rs/esp-hal.git?branch=main#70930f20d9f681a30ff20aced39d0bd5363126d5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "96fb42cd29c42f8744c74276e9f5bee7b06685bbe5b88df891516d72cb320450"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
|
||||
+154
-33
@@ -3,23 +3,21 @@ members = ["."]
|
||||
|
||||
[package]
|
||||
name = "donglora"
|
||||
version = "1.4.0"
|
||||
version = "1.5.2"
|
||||
edition = "2021"
|
||||
build = "build.rs"
|
||||
authors = ["Stephen Waits <steve@waits.net>"]
|
||||
license = "MIT"
|
||||
description = "Transparent LoRa radio over USB — plug in a board, talk LoRa from your host"
|
||||
repository = "https://github.com/donglora/firmware"
|
||||
homepage = "https://github.com/donglora/firmware"
|
||||
repository = "https://git.swaits.com/donglora/firmware"
|
||||
homepage = "https://git.swaits.com/donglora/firmware"
|
||||
keywords = ["lora", "radio", "embedded", "usb", "firmware"]
|
||||
categories = ["embedded", "hardware-support", "no-std"]
|
||||
|
||||
# NOTE: esp-rtos patched from GitHub main (see [patch.crates-io] below).
|
||||
# Remove patches once esp-rtos publishes with embassy-executor 0.10 support.
|
||||
[dependencies]
|
||||
cfg-if = "1.0"
|
||||
critical-section = "1.2"
|
||||
defmt = "1.0"
|
||||
defmt = "0.3"
|
||||
embedded-hal = "1.0"
|
||||
embedded-hal-async = "1.0"
|
||||
embassy-embedded-hal = "0.6"
|
||||
@@ -31,18 +29,32 @@ embassy-usb-driver = "0.2"
|
||||
ucobs = { version = "0.3", features = ["legacy-msrv"] }
|
||||
heapless = "0.9"
|
||||
donglora-protocol = "1"
|
||||
hsmc = { version = "0.2", default-features = false, features = ["embassy"] }
|
||||
# Pulled from swaits/lora-rs fork until lora-rs/lora-rs#428 (pre-command
|
||||
# BUSY check) is merged + released on crates.io. Switch back to a normal
|
||||
# crates.io version then.
|
||||
lora-phy = { git = "https://github.com/swaits/lora-rs.git", branch = "fix/sx126x-busy-race", features = ["defmt-03"] }
|
||||
portable-atomic = { version = "1", optional = true, default-features = false, features = ["critical-section"] }
|
||||
hsmc = { version = "0.6", default-features = false, features = ["embassy", "trace-defmt"] }
|
||||
# Fork branch carrying CAD IRQ-clear / standby-after-CAD fixes awaiting upstream PRs.
|
||||
# Switch back to crates.io once changes are upstreamed.
|
||||
lora-phy = { git = "https://github.com/swaits/lora-rs.git", branch = "fixed", features = ["defmt-03"] }
|
||||
portable-atomic = { version = "1", default-features = false, features = ["critical-section"] }
|
||||
static_cell = "2.1"
|
||||
|
||||
# Display rendering
|
||||
display-interface = "0.5"
|
||||
embedded-graphics = "0.8"
|
||||
ssd1306 = { version = "0.10", features = ["async"] }
|
||||
# T114 ST7789 TFT (color, async SPI). Optional — only pulled in by the
|
||||
# heltec_mesh_node_t114 feature. Mono OLED boards keep their existing
|
||||
# display-interface + ssd1306/sh1106 stack and don't pay for these.
|
||||
#
|
||||
# `lcd-async` is an async fork of mipidsi: same model/builder shape but
|
||||
# `init()` and pixel writes are `async fn`s. Combined with embassy-nrf's
|
||||
# DMA-backed async Spim, the dashboard repaint yields the executor for
|
||||
# the duration of the SPI transfer instead of busy-looping for ~65 ms,
|
||||
# so the radio task isn't starved.
|
||||
#
|
||||
# embedded-hal-bus needs the `async` feature for `ExclusiveDevice` to
|
||||
# implement `embedded_hal_async::spi::SpiDevice` (in addition to the
|
||||
# blocking impl).
|
||||
lcd-async = { version = "0.1", optional = true }
|
||||
embedded-hal-bus = { version = "0.3", optional = true, features = ["async"] }
|
||||
|
||||
# Async IO traits and combinators
|
||||
embedded-io-async = "0.7"
|
||||
@@ -51,31 +63,56 @@ embassy-futures = "0.1"
|
||||
# Xtensa ESP crates — requires esp toolchain (espup). The chip feature
|
||||
# (esp32 / esp32s3) is selected per board via the board-level feature flag
|
||||
# below, so the same dep lines support both classic ESP32 and ESP32-S3.
|
||||
esp-backtrace = { version = "0.18", optional = true, features = [
|
||||
esp-backtrace = { version = "0.19", optional = true, features = [
|
||||
"panic-handler",
|
||||
"defmt",
|
||||
] }
|
||||
esp-hal = { version = "1.0", optional = true, features = [
|
||||
esp-hal = { version = "1.1", optional = true, features = [
|
||||
"unstable",
|
||||
] }
|
||||
esp-println = { version = "0.16", optional = true, features = [
|
||||
# `default-features = false` strips `auto` + `colors`. The output target
|
||||
# (jtag-serial / uart / no-op / auto) is selected explicitly per board
|
||||
# feature below. UART-host boards (heltec_v3_uart, elecrow_thinknode_m2,
|
||||
# lilygo_tbeam) MUST use `no-op` — esp-println's `auto` falls back to
|
||||
# UART0 on every ESP target here (no USB-Serial-JTAG host is ever
|
||||
# attached to those boards), and UART0 IS the host protocol wire, so
|
||||
# any defmt::error!() or panic backtrace would corrupt frames mid-flight.
|
||||
# Native-USB boards keep `auto` so espflash-monitor still works in dev.
|
||||
esp-println = { version = "0.17", optional = true, default-features = false, features = [
|
||||
"critical-section",
|
||||
"defmt-espflash",
|
||||
] }
|
||||
esp-rtos = { version = "0.2", optional = true, features = [
|
||||
esp-rtos = { version = "0.3", optional = true, features = [
|
||||
"embassy",
|
||||
] }
|
||||
# Emits the IDF app descriptor (`.flash.appdesc`) that espflash v4
|
||||
# requires in every ESP binary. The chip subfeature is selected per
|
||||
# board below alongside the other esp-* chip features.
|
||||
esp-bootloader-esp-idf = { version = "0.5", optional = true, features = [
|
||||
"defmt",
|
||||
] }
|
||||
|
||||
# nRF52840 (RAK 4631)
|
||||
cortex-m = { version = "0.7", optional = true, features = [
|
||||
"critical-section-single-core",
|
||||
] }
|
||||
cortex-m-rt = { version = "0.7", optional = true }
|
||||
defmt-rtt = { version = "1.0", optional = true }
|
||||
defmt-rtt = { version = "0.4", optional = true }
|
||||
embassy-nrf = { version = "0.10", optional = true, features = [
|
||||
"nrf52840",
|
||||
"gpiote",
|
||||
"time-driver-rtc1",
|
||||
"defmt",
|
||||
# UICR pin-config flags — must match what the Adafruit nRF52 bootloader
|
||||
# actually programmed into UICR on this board. Verified empirically on
|
||||
# RAK4631 via `probe-rs read b32 0x10001200 4`:
|
||||
# PSELRESET[0/1] = 0x12 (P0.18 connected as RESET — NOT gpio)
|
||||
# NFCPINS = 0xFFFFFFFE (bit 0 = 0 → NFC pins as GPIO)
|
||||
# So `nfc-pins-as-gpio` matches; `reset-pin-as-gpio` would NOT match
|
||||
# (would request GPIO when UICR has RESET enabled). The bootloader
|
||||
# uses NRESET for its physical button-reset path, so leaving the pin
|
||||
# as RESET is the right state.
|
||||
"nfc-pins-as-gpio",
|
||||
] }
|
||||
panic-probe = { version = "1.0", optional = true, features = ["print-defmt"] }
|
||||
|
||||
@@ -91,13 +128,22 @@ minijinja = "2.18"
|
||||
owo-colors = "4.3"
|
||||
|
||||
[features]
|
||||
# The `embassy` feature gates the async-actions codepath inside the `hsmc`
|
||||
# statechart macro. Its `#[cfg(feature = "embassy")]` attributes are
|
||||
# evaluated in *this* crate's context (a proc-macro cfg quirk), so every
|
||||
# board feature must enable it. `tokio` is declared only to silence the
|
||||
# macro's `#[cfg(feature = "tokio")]` checks.
|
||||
# Bridge features for hsmc's macro-expanded `#[cfg(feature = "...")]`
|
||||
# attributes. Macros expand into the *call-site* crate, so any cfg the
|
||||
# macro emits is evaluated against THIS crate's feature set, not hsmc's.
|
||||
# That means enabling `hsmc/embassy` or `hsmc/trace-defmt` via the dep
|
||||
# only activates them inside hsmc's own compilation — for the cfg gates
|
||||
# inside expanded macro bodies to fire here, we need same-named features
|
||||
# in this Cargo.toml. `tokio` is declared just to silence the macro's
|
||||
# `#[cfg(feature = "tokio")]` checks.
|
||||
#
|
||||
# `trace-defmt` must be in every board feature so chart trace lines
|
||||
# (entries, exits, transitions, etc.) actually compile in. Without it,
|
||||
# hsmc 0.4's `__chart_observe!` macro expands to nothing for the defmt
|
||||
# arm and you get silent statecharts.
|
||||
embassy = []
|
||||
tokio = []
|
||||
trace-defmt = []
|
||||
# When enabled, replaces the display task with a LED-based diagnostic
|
||||
# blinker driven by `crate::debug_blink::CHECKPOINT`. Used for diagnosing
|
||||
# the firmware on UART-only boards where defmt output would corrupt the
|
||||
@@ -105,74 +151,129 @@ tokio = []
|
||||
# a human watches the pattern and reports N to determine where execution
|
||||
# reached / stopped.
|
||||
debug-checkpoint = []
|
||||
# NOTE: chart auto-tracing in hsmc 0.4 emits `__chart_observe!` calls
|
||||
# whose defmt arm is gated on `#[cfg(feature = "trace-defmt")]`. That
|
||||
# cfg is evaluated against THIS crate (proc-macro cfg quirk above), so
|
||||
# every board feature includes `trace-defmt` to keep the arm live. The
|
||||
# `trace;` keyword in `statechart!` blocks is deprecated in 0.4 and
|
||||
# no longer gates emission. Whether trace lines actually emit at
|
||||
# runtime is still controlled by defmt's standard compile-time level
|
||||
# filter (`DEFMT_LOG` env var) -- release builds with `DEFMT_LOG`
|
||||
# unset compile the calls out to nothing.
|
||||
# When enabled, the SH1106 OLED driver emits a defmt::info! line for
|
||||
# every I2C transaction it issues — bytes, lengths, addresses. Used to
|
||||
# debug the embassy_nrf::twim assertion panic at twim.rs:541 (cannot
|
||||
# happen via the documented call path; instrumenting the SH1106 path
|
||||
# captures the actual transaction sequence preceding the panic so we
|
||||
# can root-cause). Off by default; opt in via
|
||||
# `just flash-attach rak_wisblock_4631 debug-twim`.
|
||||
debug-twim = []
|
||||
heltec_v3 = [
|
||||
"embassy",
|
||||
"trace-defmt",
|
||||
"dep:esp-backtrace",
|
||||
"dep:esp-bootloader-esp-idf",
|
||||
"dep:esp-hal",
|
||||
"dep:esp-println",
|
||||
"dep:esp-rtos",
|
||||
"esp-backtrace/esp32s3",
|
||||
"esp-bootloader-esp-idf/esp32s3",
|
||||
"esp-hal/esp32s3",
|
||||
"esp-println/esp32s3",
|
||||
# Native USB CDC-ACM via OTG_FS — UART0 (GPIO43/44) is dangling, so
|
||||
# `auto`'s fallback target writes into the void: invisible but legal.
|
||||
# Keeps espflash-monitor working in dev when USB-Serial-JTAG ROM mode
|
||||
# is active before the firmware switches to OTG_FS.
|
||||
"esp-println/auto",
|
||||
"esp-rtos/esp32s3",
|
||||
]
|
||||
heltec_v3_uart = [
|
||||
"embassy",
|
||||
"trace-defmt",
|
||||
"dep:esp-backtrace",
|
||||
"dep:esp-bootloader-esp-idf",
|
||||
"dep:esp-hal",
|
||||
"dep:esp-println",
|
||||
"dep:esp-rtos",
|
||||
"esp-backtrace/esp32s3",
|
||||
"esp-bootloader-esp-idf/esp32s3",
|
||||
"esp-hal/esp32s3",
|
||||
"esp-println/esp32s3",
|
||||
# UART0 IS the host protocol wire on this board. `no-op` discards all
|
||||
# esp-println / defmt output so error-level logs or panic backtraces
|
||||
# cannot corrupt frames mid-flight. Use `debug-checkpoint` for
|
||||
# development diagnostics (LED-blink pattern instead of log bytes).
|
||||
"esp-println/no-op",
|
||||
"esp-rtos/esp32s3",
|
||||
]
|
||||
heltec_v4 = [
|
||||
"embassy",
|
||||
"trace-defmt",
|
||||
"dep:esp-backtrace",
|
||||
"dep:esp-bootloader-esp-idf",
|
||||
"dep:esp-hal",
|
||||
"dep:esp-println",
|
||||
"dep:esp-rtos",
|
||||
"esp-backtrace/esp32s3",
|
||||
"esp-bootloader-esp-idf/esp32s3",
|
||||
"esp-hal/esp32s3",
|
||||
"esp-println/esp32s3",
|
||||
# Native USB CDC-ACM — same rationale as heltec_v3.
|
||||
"esp-println/auto",
|
||||
"esp-rtos/esp32s3",
|
||||
]
|
||||
elecrow_thinknode_m2 = [
|
||||
"embassy",
|
||||
"trace-defmt",
|
||||
"dep:esp-backtrace",
|
||||
"dep:esp-bootloader-esp-idf",
|
||||
"dep:esp-hal",
|
||||
"dep:esp-println",
|
||||
"dep:esp-rtos",
|
||||
"esp-backtrace/esp32s3",
|
||||
"esp-bootloader-esp-idf/esp32s3",
|
||||
"esp-hal/esp32s3",
|
||||
"esp-println/esp32s3",
|
||||
# UART0 IS the host protocol wire — see heltec_v3_uart rationale.
|
||||
"esp-println/no-op",
|
||||
"esp-rtos/esp32s3",
|
||||
]
|
||||
lilygo_tbeam_supreme = [
|
||||
"embassy",
|
||||
"trace-defmt",
|
||||
"dep:esp-backtrace",
|
||||
"dep:esp-bootloader-esp-idf",
|
||||
"dep:esp-hal",
|
||||
"dep:esp-println",
|
||||
"dep:esp-rtos",
|
||||
"esp-backtrace/esp32s3",
|
||||
"esp-bootloader-esp-idf/esp32s3",
|
||||
"esp-hal/esp32s3",
|
||||
"esp-println/esp32s3",
|
||||
# Native USB CDC-ACM — same rationale as heltec_v3.
|
||||
"esp-println/auto",
|
||||
"esp-rtos/esp32s3",
|
||||
]
|
||||
lilygo_tbeam = [
|
||||
"embassy",
|
||||
"trace-defmt",
|
||||
"dep:esp-backtrace",
|
||||
"dep:esp-bootloader-esp-idf",
|
||||
"dep:esp-hal",
|
||||
"dep:esp-println",
|
||||
"dep:esp-rtos",
|
||||
"esp-backtrace/esp32",
|
||||
"esp-bootloader-esp-idf/esp32",
|
||||
"esp-hal/esp32",
|
||||
"esp-println/esp32",
|
||||
# ESP32 classic has no USB-Serial-JTAG peripheral — `auto` would
|
||||
# unconditionally route to UART0 = host protocol wire. Use `no-op`.
|
||||
"esp-println/no-op",
|
||||
"esp-rtos/esp32",
|
||||
]
|
||||
rak_wisblock_4631 = [
|
||||
"embassy",
|
||||
"trace-defmt",
|
||||
"dep:cortex-m",
|
||||
"dep:cortex-m-rt",
|
||||
"cortex-m-rt/set-vtor",
|
||||
@@ -184,6 +285,7 @@ rak_wisblock_4631 = [
|
||||
]
|
||||
wio_tracker_l1 = [
|
||||
"embassy",
|
||||
"trace-defmt",
|
||||
"dep:cortex-m",
|
||||
"dep:cortex-m-rt",
|
||||
"cortex-m-rt/set-vtor",
|
||||
@@ -193,27 +295,42 @@ wio_tracker_l1 = [
|
||||
"embassy-executor/platform-cortex-m",
|
||||
"embassy-executor/executor-thread",
|
||||
]
|
||||
heltec_mesh_node_t114 = [
|
||||
"embassy",
|
||||
"trace-defmt",
|
||||
"dep:cortex-m",
|
||||
"dep:cortex-m-rt",
|
||||
"cortex-m-rt/set-vtor",
|
||||
"dep:defmt-rtt",
|
||||
"dep:embassy-nrf",
|
||||
"dep:panic-probe",
|
||||
"dep:lcd-async",
|
||||
"dep:embedded-hal-bus",
|
||||
"embassy-executor/platform-cortex-m",
|
||||
"embassy-executor/executor-thread",
|
||||
]
|
||||
waveshare_rp2040_lora = [
|
||||
"embassy",
|
||||
"trace-defmt",
|
||||
"dep:cortex-m",
|
||||
"dep:cortex-m-rt",
|
||||
"dep:defmt-rtt",
|
||||
"dep:embassy-rp",
|
||||
"dep:panic-probe",
|
||||
"dep:portable-atomic",
|
||||
"embassy-executor/platform-cortex-m",
|
||||
"embassy-executor/executor-thread",
|
||||
]
|
||||
|
||||
# REVISIT: esp-rtos 0.2.0 on crates.io pins embassy-executor ^0.9. The
|
||||
# esp-rs/esp-hal main branch already supports 0.10. Remove these patches once
|
||||
# esp-rtos publishes a release with embassy-executor 0.10 support. Track:
|
||||
# https://github.com/esp-rs/esp-hal/tree/main/esp-rtos
|
||||
[patch.crates-io]
|
||||
esp-hal = { git = "https://github.com/esp-rs/esp-hal.git", branch = "main" }
|
||||
esp-rtos = { git = "https://github.com/esp-rs/esp-hal.git", branch = "main" }
|
||||
esp-backtrace = { git = "https://github.com/esp-rs/esp-hal.git", branch = "main" }
|
||||
esp-println = { git = "https://github.com/esp-rs/esp-hal.git", branch = "main" }
|
||||
[lints.rust]
|
||||
# hsmc 0.3's `statechart!` macro emits `#[cfg(feature = "journal")]`
|
||||
# attributes (and others). check-cfg evaluates those in the CALLER's
|
||||
# crate context, so unless donglora declares the same feature names
|
||||
# (we don't — they're hsmc's, not ours), rustc lints them. Per hsmc's
|
||||
# README, the documented fix is to allow the lint with an explicit
|
||||
# check-cfg list so other unexpected cfgs still surface as warnings.
|
||||
unexpected_cfgs = { level = "allow", check-cfg = [
|
||||
'cfg(feature, values("tokio", "embassy", "journal", "trace-defmt", "trace-log", "trace-tracing"))',
|
||||
] }
|
||||
|
||||
[lints.clippy]
|
||||
undocumented_unsafe_blocks = "deny"
|
||||
@@ -223,4 +340,8 @@ codegen-units = 1
|
||||
lto = "fat"
|
||||
opt-level = 3 # "s"/"z" break Xtensa (l32r literal range exceeded)
|
||||
panic = "abort"
|
||||
strip = true
|
||||
# `strip = true` was killing defmt's `_defmt_version_` / `_defmt_encoding_`
|
||||
# symbols, breaking RTT log decoding via probe-rs. Keep symbols + DWARF
|
||||
# so probe-rs can show source locations for log lines.
|
||||
strip = false
|
||||
debug = 2
|
||||
-1923
File diff suppressed because it is too large.
Load diff
@@ -20,6 +20,7 @@ All commands are run from the `firmware/` directory.
|
||||
|
||||
| Board | Feature | MCU | Radio | USB | Target |
|
||||
|-------|---------|-----|-------|-----|--------|
|
||||
| Heltec Mesh Node T114 | `heltec_mesh_node_t114` | nRF52840 | SX1262 | Native CDC-ACM | thumbv7em-none-eabihf |
|
||||
| Heltec V3 | `heltec_v3` | ESP32-S3 | SX1262 | Native CDC-ACM | xtensa-esp32s3-none-elf |
|
||||
| Heltec V3 (UART) | `heltec_v3_uart` | ESP32-S3 | SX1262 | CP2102 bridge | xtensa-esp32s3-none-elf |
|
||||
| Heltec V4 | `heltec_v4` | ESP32-S3 | SX1262 | Native CDC-ACM | xtensa-esp32s3-none-elf |
|
||||
@@ -62,4 +63,35 @@ See [src/board/PORTING.md](src/board/PORTING.md).
|
||||
|
||||
## Protocol
|
||||
|
||||
See [PROTOCOL.md](PROTOCOL.md) for the complete wire format specification.
|
||||
See [PROTOCOL.md](https://git.swaits.com/donglora/protocol-rs/src/branch/main/PROTOCOL.md)
|
||||
in the [donglora-protocol](https://git.swaits.com/donglora/protocol-rs) repo
|
||||
(crates.io: [`donglora-protocol`](https://crates.io/crates/donglora-protocol))
|
||||
for the complete wire format specification — that is the canonical home;
|
||||
this firmware just consumes it.
|
||||
|
||||
## Trust model and identifiers
|
||||
|
||||
DongLoRa is a **transparent LoRa bridge**, not a security boundary:
|
||||
|
||||
- **No authentication on the host transport.** Anything that can write
|
||||
to the CDC-ACM endpoint or UART pins drives the radio. Physical USB
|
||||
attachment is the trust boundary.
|
||||
- **No RF-regulatory enforcement.** Frequency, sync word, and TX power
|
||||
are whatever the host sends, within the chip's hardware range.
|
||||
Regional compliance (FCC / CE / ETSI / duty cycle) is the user's
|
||||
responsibility, not the firmware's.
|
||||
- **Device identifier exposed without authentication.** The board's
|
||||
6-byte MAC (factory-burned, permanent) is published in two places
|
||||
readable by anyone who can enumerate or open the device:
|
||||
1. The USB CDC-ACM `iSerialNumber` string descriptor (12 hex
|
||||
chars, uppercase). Visible to any OS / userspace process that
|
||||
can read USB device descriptors.
|
||||
2. The `GET_INFO.mcu_uid` field on the wire, available to any
|
||||
host that can issue protocol frames.
|
||||
|
||||
This is intentional — host-side tools (`donglora-mux`,
|
||||
`client-py`, etc.) rely on it to distinguish multiple dongles on
|
||||
one host. The privacy trade-off: a user reusing the same dongle
|
||||
across different hosts/networks is correlatable by that MAC across
|
||||
contexts. If that matters in your threat model, isolate the dongle
|
||||
per context or run it behind a trusted multiplexer.
|
||||
@@ -1,165 +1,37 @@
|
||||
# Firmware TODO List
|
||||
|
||||
## `lora-phy` fix: drop fork pin once PR #428 merges + releases
|
||||
## `lora-phy` fix: drop fork pin once PRs #428 / #431 / #432 merge + release
|
||||
|
||||
Upstream PRs (all open):
|
||||
|
||||
- [#428 — *phy: add pre-command BUSY checks per Semtech
|
||||
datasheets*](https://github.com/lora-rs/lora-rs/pull/428) (sx126x):
|
||||
pre-command BUSY check + standby/IRQ-clear hygiene around CAD.
|
||||
- [#431 — *phy: sx127x: clear stale IRQ flags in do_rx before mode
|
||||
change*](https://github.com/lora-rs/lora-rs/pull/431).
|
||||
- [#432 — *phy: sx127x: clear stale IRQ flags before reconfiguring DIO
|
||||
mapping in set_irq_params*](https://github.com/lora-rs/lora-rs/pull/432).
|
||||
|
||||
Upstream PR: [lora-rs/lora-rs#428 — *phy: add pre-command BUSY checks
|
||||
per Semtech datasheets*](https://github.com/lora-rs/lora-rs/pull/428)
|
||||
(open).
|
||||
Fork branch we ship from:
|
||||
[`swaits/lora-rs @ fix/sx126x-busy-race`](https://github.com/swaits/lora-rs/tree/fix/sx126x-busy-race).
|
||||
[`swaits/lora-rs @ fixed`](https://github.com/swaits/lora-rs/tree/fixed)
|
||||
— a merge of all three fix branches kept up-to-date with upstream
|
||||
`main`.
|
||||
|
||||
Firmware consumes the fix via a direct `git` dependency in
|
||||
Firmware consumes the fixes via a direct `git` dependency in
|
||||
`Cargo.toml`:
|
||||
|
||||
```toml
|
||||
lora-phy = { git = "https://github.com/swaits/lora-rs.git", branch = "fix/sx126x-busy-race", features = ["defmt-03"] }
|
||||
lora-phy = { git = "https://github.com/swaits/lora-rs.git", branch = "fixed", features = ["defmt-03"] }
|
||||
```
|
||||
|
||||
When PR #428 merges and a `lora-phy` release ships on crates.io that
|
||||
contains it, switch this line back to a normal `lora-phy = "X.Y"`
|
||||
crates.io version and drop the explanatory comment block above it.
|
||||
When all three PRs merge and a `lora-phy` release ships on crates.io
|
||||
that contains them, switch this line back to a normal
|
||||
`lora-phy = "X.Y"` crates.io version and drop the explanatory comment
|
||||
block above it.
|
||||
|
||||
Hardware-verified on Wio Tracker L1: 20/20 clean-boot TX after the fix
|
||||
(vs ~50% before). The fix adds a pre-command BUSY check before each
|
||||
`SpiInterface` read/write op, keeping the existing post-command wait
|
||||
in place — belt-and-suspenders bracketing that closes the
|
||||
BUSY-assertion latency window (T_SW ≤ 600 ns) a single level-poll can
|
||||
miss.
|
||||
|
||||
---
|
||||
|
||||
## Drop `[patch.crates-io]` + upgrade esp-hal / esp-rtos + add `esp_app_desc!()` + bump espflash to v4
|
||||
|
||||
Single coordinated change that clears our last remaining dep-graph debt
|
||||
on the ESP boards. All four pieces are coupled, so they land together.
|
||||
|
||||
### Why
|
||||
|
||||
Today `Cargo.toml` overrides `esp-hal`, `esp-rtos`, `esp-backtrace`, and
|
||||
`esp-println` with a git pin to `esp-rs/esp-hal#main`. The note in that
|
||||
block explains the historical reason:
|
||||
|
||||
> REVISIT: esp-rtos 0.2.0 on crates.io pins embassy-executor ^0.9. The
|
||||
> esp-rs/esp-hal main branch already supports 0.10. Remove these
|
||||
> patches once esp-rtos publishes a release with embassy-executor 0.10
|
||||
> support.
|
||||
|
||||
**That condition is now met.** `esp-rtos 0.3.0` is published on crates.io
|
||||
and depends on `embassy-executor ^0.10` and `esp-hal ~1.1.0-rc.0` — exactly
|
||||
what our patches were pulling unofficially. Keeping the patches is no
|
||||
longer free: it blocks `espflash` v4, blocks `esp-bootloader-esp-idf`, and
|
||||
ties reproducible builds to whatever commit happens to be on
|
||||
`esp-rs/esp-hal#main`.
|
||||
|
||||
The immediate trigger is `espflash` v4's required
|
||||
`esp_bootloader_esp_idf::esp_app_desc!()` check. `save-image` refuses any
|
||||
ESP binary that lacks the IDF app descriptor. The crate that provides the
|
||||
macro (`esp-bootloader-esp-idf`) uses the same `esp-rom-sys` native
|
||||
library as `esp-hal` — two copies of the same `links = "esp_rom_sys"` is
|
||||
a hard cargo error, so `esp-bootloader-esp-idf` must come from the _same
|
||||
source_ as `esp-hal`. Easiest way to guarantee that: both from crates.io,
|
||||
no patches.
|
||||
|
||||
### What changes
|
||||
|
||||
1. **`Cargo.toml`** — delete the entire `[patch.crates-io]` block and the
|
||||
preceding `REVISIT:` comment. Bump the four esp-rs deps to published
|
||||
versions:
|
||||
- `esp-hal = "1.0"` → `"1.1.0-rc.0"` (pre-release selector — see Gotchas)
|
||||
- `esp-rtos = "0.2"` → `"0.3"`
|
||||
- `esp-backtrace = "0.18"` → whatever matches esp-rtos 0.3 (check its deps)
|
||||
- `esp-println = "0.16"` → same
|
||||
Add a new optional dep + feature wiring for
|
||||
`esp-bootloader-esp-idf = "0.5"` with `defmt` feature, plus per-board
|
||||
chip feature (`esp-bootloader-esp-idf/esp32s3` for the S3 boards,
|
||||
`esp-bootloader-esp-idf/esp32` for LilyGo T-Beam classic).
|
||||
|
||||
2. **`src/main.rs`** — inside the ESP-family branch of the existing
|
||||
`cfg_if!` block (around line 51), invoke the macro at module scope:
|
||||
|
||||
```rust
|
||||
esp_bootloader_esp_idf::esp_app_desc!();
|
||||
```
|
||||
|
||||
Zero runtime cost. Emits ~256 bytes in `.rodata_desc` with app
|
||||
metadata (name, version, build date) that the ESP-IDF 2nd-stage
|
||||
bootloader + `espflash save-image` look for.
|
||||
|
||||
3. **`mise.toml`** — `"cargo:espflash" = "3"` → `"4"`. Drop the deferral
|
||||
comment on that line.
|
||||
|
||||
4. **Possible API drift fixes** — esp-hal's pre-release versions
|
||||
occasionally rename or move items. Most likely touch points:
|
||||
- `src/board/esp32.rs`, `src/board/esp32s3.rs`
|
||||
- Any board file in `src/board/heltec_*.rs`, `lilygo_*.rs`,
|
||||
`elecrow_*.rs` that calls into esp-hal / esp-rtos init
|
||||
Run `just check-all` after steps 1–3 and patch up whatever surfaces.
|
||||
Don't guess — read the actual compiler errors.
|
||||
|
||||
### How (step by step)
|
||||
|
||||
1. `jj new -m "chore(esp): drop esp-hal patches, bump to published versions, add esp_app_desc!()"`
|
||||
2. Edit `Cargo.toml` — delete `[patch.crates-io]` + `REVISIT:` comment;
|
||||
bump the four versions; add `esp-bootloader-esp-idf` dep + feature
|
||||
wiring for each ESP board feature block.
|
||||
3. Edit `src/main.rs` — add `esp_bootloader_esp_idf::esp_app_desc!();`
|
||||
to the ESP branch of the top-level `cfg_if!`.
|
||||
4. Edit `mise.toml` — espflash `"3"` → `"4"`.
|
||||
5. `mise install` to pull espflash 4.x.
|
||||
6. `cargo update` to let the resolver pick a consistent tree with the
|
||||
new requirements.
|
||||
7. `just check-all` — expect some compilation errors from API drift on
|
||||
ESP boards. Fix them one at a time; consult the official examples
|
||||
in `esp-rs/esp-hal` if in doubt.
|
||||
8. `just build heltec_v3` (or any ESP board you have) — verify
|
||||
`espflash save-image` now succeeds end-to-end producing a valid
|
||||
`.bin`.
|
||||
9. Flash to real hardware if available. **The RAK4631 and Wio Tracker
|
||||
L1 (nRF52) boards should be untouched.**
|
||||
|
||||
### Verification
|
||||
|
||||
- `just check-all` clean on all 9 boards.
|
||||
- `just test` (host-side protocol tests) still green.
|
||||
- For each ESP board that has been physically validated before
|
||||
(Heltec V3, LilyGo T-Beam units): flash + confirm radio TX works
|
||||
end-to-end via ai-bot or a known peer.
|
||||
- RAK4631 + Wio Tracker L1 unchanged; re-check via `just check` on
|
||||
both, but no re-flash needed (they don't go through the ESP code
|
||||
path).
|
||||
|
||||
### Gotchas
|
||||
|
||||
- **Pre-release version selector**: `esp-hal = "1.1.0-rc.0"` by itself
|
||||
does NOT match pre-release versions in cargo's default semver rules.
|
||||
Use an exact pre-release like `"1.1.0-rc.0"`, `"~1.1.0-rc.0"` (only
|
||||
`1.1.0-rc.0`, `1.1.0-rc.1`, … with any patch), or `">=1.1.0-rc.0"`.
|
||||
`"1.1"` will NOT resolve to a pre-release.
|
||||
- **Two copies of `esp-rom-sys`**: the root cause of the first failed
|
||||
attempt at this task. If cargo errors with _"Only one package in the
|
||||
dependency graph may specify the same links value"_ for
|
||||
`esp_rom_sys`, it means `esp-hal` and `esp-bootloader-esp-idf` are
|
||||
being pulled from different sources (one crates.io, one git). Either
|
||||
both from crates.io (this task's plan) or both from the same git
|
||||
commit.
|
||||
- **`esp-bootloader-esp-idf` chip features**: the crate needs exactly
|
||||
one of `esp32 / esp32s3 / esp32c3 / esp32c6 / …` to be enabled.
|
||||
Without it, the build panics at its `build.rs`: _"Expected exactly
|
||||
one of the following features to be enabled: esp32, esp32c2, ..."_.
|
||||
Wire per-board the same way we wire `esp-hal/esp32s3` etc.
|
||||
- **Probe-less repro**: `mise install` after bumping espflash to "4"
|
||||
only installs mise's copy. If the user's global
|
||||
`~/.cargo/bin/espflash` is stale/different, it still wins on PATH.
|
||||
The justfile `set shell := ["mise", "exec", "--", "sh", "-c"]`
|
||||
prepends mise's paths but does NOT reorder `~/.cargo/bin` ahead of
|
||||
them. Either `cargo uninstall espflash` globally, or change the
|
||||
justfile recipes to call `$(mise which espflash)` explicitly.
|
||||
|
||||
### Why not now
|
||||
|
||||
Tried it alongside the RAK4631 bring-up in April 2026 and bounced off
|
||||
the `esp-rom-sys` two-copies problem. Backed out the attempt and left
|
||||
espflash pinned at v3 + the patches in place so the bring-up commit
|
||||
stayed atomic and shippable. This task is what's needed to unblock v4
|
||||
properly, but it deserves its own focused session — not as
|
||||
"while-I'm-here" scope on a board bring-up.
|
||||
Hardware impact: large stability gain on every board — the sx126x
|
||||
BUSY-race fix lands on every nRF/RP2040 board, and the sx127x IRQ-clear
|
||||
fixes unblock TBeam classic in particular. Hardware-verified on Wio
|
||||
Tracker L1 (sx126x): 20/20 clean-boot TX after the fix (vs ~50%
|
||||
before). LilyGo T-Beam classic (sx127x): TX confirmed working
|
||||
2026-04-30 on the post-esp-hal-1.1 build.
|
||||
@@ -108,19 +108,31 @@ fn main() {
|
||||
println!("cargo:rerun-if-changed=src/board/mod.rs.j2");
|
||||
println!("cargo:rerun-if-changed={}", board_dir);
|
||||
|
||||
// cortex-m-rt's link.x does `INCLUDE memory.x`. Copy the correct layout
|
||||
// into OUT_DIR based on the target MCU family.
|
||||
// cortex-m-rt's link.x does `INCLUDE memory.x`. Resolve the layout
|
||||
// in two steps:
|
||||
// 1. Per-board override: `ld/{board}-memory.x` if it exists.
|
||||
// 2. MCU-family default by target triple.
|
||||
// The override path is for boards that deviate from the family
|
||||
// default (e.g. WIO Tracker L1 currently has no SoftDevice in flash
|
||||
// and needs ORIGIN=0x1000 instead of the SD-aware 0x26000). Drop
|
||||
// the per-board file once the layout matches the family default.
|
||||
let target = std::env::var("TARGET").unwrap_or_default();
|
||||
let memory_x = match target.as_str() {
|
||||
"thumbv7em-none-eabihf" => Some("ld/nrf52840-memory.x"),
|
||||
"thumbv6m-none-eabi" => Some("ld/rp2040-memory.x"),
|
||||
_ => None,
|
||||
let board_specific = format!("ld/{}-memory.x", enabled[0]);
|
||||
let memory_x: Option<String> = if std::path::Path::new(&board_specific).exists() {
|
||||
Some(board_specific.clone())
|
||||
} else {
|
||||
match target.as_str() {
|
||||
"thumbv7em-none-eabihf" => Some("ld/nrf52840-memory.x".to_string()),
|
||||
"thumbv6m-none-eabi" => Some("ld/rp2040-memory.x".to_string()),
|
||||
_ => None,
|
||||
}
|
||||
};
|
||||
if let Some(src) = memory_x {
|
||||
let out_dir = std::env::var("OUT_DIR").unwrap();
|
||||
fs::copy(src, format!("{out_dir}/memory.x")).expect("failed to copy memory.x");
|
||||
fs::copy(&src, format!("{out_dir}/memory.x")).expect("failed to copy memory.x");
|
||||
println!("cargo:rustc-link-search={out_dir}");
|
||||
}
|
||||
println!("cargo:rerun-if-changed=ld/nrf52840-memory.x");
|
||||
println!("cargo:rerun-if-changed=ld/rp2040-memory.x");
|
||||
println!("cargo:rerun-if-changed={}", board_specific);
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
workers = ["app.routes"]
|
||||
|
||||
[settings]
|
||||
cache = ".script"
|
||||
address = "127.0.0.1"
|
||||
port = 3500
|
||||
@@ -0,0 +1,115 @@
|
||||
# Phase 1 — Reconnaissance
|
||||
|
||||
## Project
|
||||
|
||||
`donglora` v1.5.2 — Embedded Rust firmware. Single binary per board, compiled with mutually-exclusive cargo features. Exposes the LoRa radio over a host transport (USB CDC-ACM or UART) using a COBS-framed binary protocol (DongLoRa Protocol v2).
|
||||
|
||||
## Languages / runtime
|
||||
|
||||
- **Rust 1.94 stable** for ARM (thumbv6m, thumbv7em); **Rust esp** fork for Xtensa (esp32, esp32-s3). `no_std`, `no_main`, `panic = abort`, LTO fat, opt-level 3.
|
||||
- **No allocator.** All collections are fixed-capacity `heapless::Vec<_, N>`.
|
||||
- **Async runtime: embassy-executor 0.10** (Cortex-M) / **esp-rtos 0.3** (Xtensa). Three long-lived tasks per board.
|
||||
- **Statecharts: hsmc 0.6** drive the host, radio, and display tasks.
|
||||
|
||||
## Supported boards (one feature → one binary)
|
||||
|
||||
| Board | MCU | Radio | Host transport |
|
||||
|-----------------------------|------------|--------|------------------------|
|
||||
| heltec_mesh_node_t114 | nRF52840 | SX1262 | USB CDC-ACM (native) |
|
||||
| heltec_v3 | ESP32-S3 | SX1262 | USB CDC-ACM (native) |
|
||||
| heltec_v3_uart | ESP32-S3 | SX1262 | UART0 via CP2102 |
|
||||
| heltec_v4 | ESP32-S3 | SX1262 | USB CDC-ACM (native) |
|
||||
| elecrow_thinknode_m2 | ESP32-S3 | SX1262 | UART0 via CP2102 |
|
||||
| lilygo_tbeam | ESP32 | SX1276 | UART0 via CP2102 |
|
||||
| lilygo_tbeam_supreme | ESP32-S3 | SX1262 | USB CDC-ACM (native) |
|
||||
| rak_wisblock_4631 | nRF52840 | SX1262 | USB CDC-ACM (native) |
|
||||
| waveshare_rp2040_lora | RP2040 | SX1262 | USB CDC-ACM (native) |
|
||||
| wio_tracker_l1 | nRF52840 | SX1262 | USB CDC-ACM (native) |
|
||||
|
||||
## Entry points (untrusted-byte ingress)
|
||||
|
||||
There is **exactly one network-facing entry point per board image**: the host transport (USB CDC-ACM **or** UART, never both in the same build).
|
||||
|
||||
| Module | Inbound bytes from |
|
||||
|------------------------------|-------------------------------------|
|
||||
| `src/host/usb.rs` | USB CDC-ACM (host OS, kernel-side) |
|
||||
| `src/host/uart.rs` | UART0 (CP2102 → host OS) |
|
||||
| `src/radio.rs` (RX path) | LoRa over-the-air (any peer in RF range, on configured freq+sync) |
|
||||
|
||||
There is **no networking** (TCP/UDP/HTTP/etc.), **no filesystem**, **no shell**, **no storage**. The radio is the only RF surface; OTA RX packets become a `DeviceMessage::Rx(...)` forwarded to the host with `tag = 0` — the firmware itself does not interpret payload bytes.
|
||||
|
||||
USB descriptors / VID:PID `1209:5741` (composite IAD device). The CDC-ACM serial-number string is the device's MAC address as 12-char uppercase hex (`src/host/usb.rs:184-189`).
|
||||
|
||||
## Wire format (one path from byte → command)
|
||||
|
||||
```
|
||||
raw bytes
|
||||
→ FrameDecoder (donglora-protocol 1.2.0 frame.rs)
|
||||
COBS-delimited, CRC16/CCITT-FALSE-protected, payload ≤ MAX_PAYLOAD_FIELD (275 B)
|
||||
→ dispatch_frame (host/framing.rs)
|
||||
rejects tag=0 with async ERR(EFRAME)
|
||||
calls Command::parse(type_id, payload)
|
||||
→ InboundEvent::Command { tag, cmd } via CommandChannel (depth 8)
|
||||
→ radio task statechart (radio.rs)
|
||||
```
|
||||
|
||||
Egress (`DeviceMessage::Ok|Err|Rx|TxDone`) is symmetric: built by the radio task, sent through `OutboundChannel` (depth 64), encoded by `encode_outbound`, written packet-by-packet.
|
||||
|
||||
Commands accepted from host: `PING`, `GET_INFO`, `SET_CONFIG`, `TX`, `RX_START`, `RX_STOP` (only these `type_id` bytes; anything else → `ERR(EUNKNOWN_CMD)`).
|
||||
|
||||
## Trust boundaries
|
||||
|
||||
| Boundary | Direction | Treated as |
|
||||
|---------------------------------------|-----------------|------------------|
|
||||
| USB / UART host → firmware | inbound bytes | **trusted** (it's the user's PC) |
|
||||
| LoRa RF peer → firmware (RX payload) | inbound bytes | **untrusted**, but only memcpy'd to host — firmware never interprets |
|
||||
| firmware → SX1262/SX1276 via SPI | outbound | trusted (own peripheral) |
|
||||
|
||||
The firmware is intentionally a **transparent radio bridge**: there is no auth, no crypto, no session state, no key material on device. The radio's freq, power, sync_word, etc. are whatever the host sets (within hardware limits enforced by `validate_lora`).
|
||||
|
||||
## Auth / session layer
|
||||
|
||||
**None.** Anything that can write CDC-ACM bytes to the device can drive the radio. Per design — the device is a "dongle," ownership is physical-attachment.
|
||||
|
||||
The protocol's inactivity watchdog (`PROTOCOL.md §3.4`, 1 s) drops the radio to `Unconfigured` if the host stops sending bytes — this is liveness/cleanup, **not** auth. USB-side this is gated on DTR (USB CDC line state); UART has no DTR so it relies purely on the watchdog.
|
||||
|
||||
## Data at rest
|
||||
|
||||
**None of security interest.**
|
||||
- Static `Info` blob built once at boot — MAC, fw version, capability bitmaps. Visible to anyone who issues `GET_INFO`.
|
||||
- No credentials, tokens, keys, PII.
|
||||
- Persistent storage: only board-specific bootloader settings region; **firmware never writes to flash at runtime** (see `ld/*.x` — app region is read-only post-flash).
|
||||
- nRF52840 boards' `s140 SoftDevice` region (0x01000–0x26000) preserved at flash time; firmware never touches it.
|
||||
|
||||
## Dependencies (Cargo.lock, security-relevant subset)
|
||||
|
||||
- `donglora-protocol 1.2.0` — own crate, the wire codec (frame, command, modulation, info, events). Fuzz/mutation-tested in sibling repo (`/home/swaits/Code/donglora-org/protocol/`).
|
||||
- `ucobs 0.3` — COBS encode/decode, no_std, very small.
|
||||
- `lora-phy` — git fork from `swaits/lora-rs branch=fixed` (carries CAD IRQ-clear fixes pending upstream PRs #428/#431/#432). All SX126x/SX127x SPI primitives.
|
||||
- `hsmc 0.6` — own crate (statecharts).
|
||||
- `embassy-* 0.5/0.6/0.10/0.8` — well-known embedded async runtime.
|
||||
- `defmt 0.3`, `panic-probe 1.0` (Cortex-M boards) / `esp-backtrace 0.19` + `esp-println 0.17` (Xtensa boards).
|
||||
- `heapless 0.9` — fixed-capacity collections.
|
||||
|
||||
No HTTP, JSON, TLS, SQL, template, regex, deserialization-of-user-data dependencies.
|
||||
|
||||
## Deployment context
|
||||
|
||||
Firmware is flashed via UF2 (Cortex-M) or `espflash` (Xtensa). No OTA update path on the device itself — re-flashing requires physical USB. Production builds run with `DEFMT_LOG` unset → defmt strips info/debug/warn at compile time; only `error!` survives.
|
||||
|
||||
## Existing security controls / hardening already in place
|
||||
|
||||
- **`#![cfg_attr(not(test), no_std)]` / `panic = abort`** — no panic unwinding to abuse.
|
||||
- **`#![deny(clippy::undocumented_unsafe_blocks)]`** in Cargo.toml — every `unsafe` block must justify itself.
|
||||
- **`unsafe` audit** (4 sites total):
|
||||
- `main.rs:105` — one-time `static mut INFO` init at boot, before any task spawns.
|
||||
- `hal/nrf52840.rs:102, 140, 151` — `read_volatile` / `write_volatile` on MMIO registers (POWER.RESETREAS, FICR.DEVICEADDR). Standard.
|
||||
- `hal/rp2040.rs:68, 79` — one-time `static mut MAC_REF` init at boot.
|
||||
- **Memory layout (`ld/nrf52840-memory.x`)** preserves the factory SoftDevice region; UF2-only flashing prevents accidental overwrite.
|
||||
- **CRC-16/CCITT-FALSE** on every frame, COBS framing — corrupt bytes are caught by either layer before reaching `Command::parse`.
|
||||
- **Strict typed parsing**: every enum field rejects out-of-range bytes (`from_u8` returns `Option` and `InvalidField` is propagated); reserved bits in `TxFlags` rejected; reserved byte in `LrFhssConfig` rejected.
|
||||
- **`validate_lora` (`radio.rs:2125-2145`)**: freq within board's chip-supported range, SF ∈ {5..=12} **and** in board bitmap, BW in board bitmap, TX power within board range, preamble ≥ 6.
|
||||
- **Hardware-limit clamping**: `MAX_OTA_PAYLOAD = 255` matches the chip's `payload_len` register width; the receive buffer is sized exactly to it.
|
||||
- **`RADIO_THROTTLED` backpressure flag** prevents queue floods if the host is unresponsive.
|
||||
- **State-machine cancel-safety**: cancel-unsafe lora-phy futures (`lora.tx`, `lora.cad`, `LoRa::new`, `lora.init`) are hosted in chart states whose only event handlers are emitted by the future itself on natural completion — chart cannot drop them.
|
||||
- **Reset-and-recovery state machine** with bounded attempts (`MAX_RECOVERY_ATTEMPTS = 4`) and a terminal `Dead` state that still answers `PING`/`GET_INFO`.
|
||||
@@ -0,0 +1,94 @@
|
||||
# Phase 2 — Threat model
|
||||
|
||||
## Actors
|
||||
|
||||
| Actor | Capabilities | Reach |
|
||||
|--------------------------------------|---------------------------------------------------------------------|--------------------|
|
||||
| **Local USB attacker (LUSB)** | Read/write raw bytes to CDC-ACM endpoint or UART pins; read USB descriptors. Implies kernel-level access on the host *or* physical hardware access. | high impact, but presumes the host is already compromised |
|
||||
| **OTA peer (OTA)** | Transmit arbitrary LoRa packets within RF range, on the configured frequency / sync word / SF / BW. Cannot read or affect host transport directly. | low impact (firmware does not interpret), broad reach |
|
||||
| **Hostile host application (APP)** | Subset of LUSB: a malicious userspace program on the host PC that opens the CDC-ACM device — possibly without the user's intent. Limited to whatever the OS permits on that device node. | depends on host's permissions on the tty/dev node |
|
||||
| **Build-time adversary (BUILD)** | Compromised crate in the dependency graph or compromised local toolchain. | out of scope of runtime audit; noted only for completeness |
|
||||
|
||||
## Entry points and worst-case impact, in attacker-reachability order
|
||||
|
||||
### Entry point #1 — Host transport (USB CDC-ACM / UART)
|
||||
|
||||
**Reachable by:** LUSB, APP.
|
||||
|
||||
**What they can influence:** every byte of every frame after `0x00` sentinels. Specifically: `type_id` (1 B), `tag` (2 B LE), `payload` (≤ 275 B), `crc` (2 B). After CRC + COBS + parser checks, controlled fields are:
|
||||
- `type_id` ∈ {1..=6} (validated by `Command::parse`)
|
||||
- `tag` ∈ 1..=65535 (tag=0 rejected by framing layer)
|
||||
- For `SET_CONFIG`: every byte of `LoRaConfig` (15 B) — freq, SF, BW, CR, preamble_len, sync_word, tx_power, header_mode, payload_crc, iq_invert. The other three modulations parse but the firmware rejects with `EMODULATION` after parsing.
|
||||
- For `TX`: `TxFlags` byte (bit 0 only — reserved bits rejected) + 1..=255 bytes of OTA payload.
|
||||
|
||||
**Worst-case if attacker fully controls input:**
|
||||
|
||||
The firmware is a **transparent bridge by design**. Any attacker who can write to the host transport can:
|
||||
- Tune the radio to any frequency in the chip's range (e.g. 137 MHz – 1020 MHz for SX127x; 150–960 MHz for SX126x).
|
||||
- Set TX power up to the board's `TX_POWER_RANGE.max` (typically +22 dBm).
|
||||
- Transmit arbitrary packet bytes with arbitrary sync word at arbitrary spreading factor.
|
||||
- Receive on arbitrary parameters.
|
||||
- Read the device's MAC address (via `GET_INFO` and/or USB serial descriptor).
|
||||
|
||||
This is **the intentional product feature**. The firmware does not enforce regional frequency rules, duty cycle, listen-before-talk policy beyond the SF-tuned CAD, or any auth: the host owns the radio. Treating "host can drive the radio" as a vulnerability would be a category error.
|
||||
|
||||
The remaining attacker goals against the firmware itself are:
|
||||
- **DoS** — crash, hang, reboot, or wedge the firmware so the user has to power-cycle.
|
||||
- **Memory corruption / RCE** — overflow a buffer, hit an OOB write, leak ASLR-less code-pointer.
|
||||
- **Cross-device escalation** — pivot from the radio task into the host PC (e.g. malicious USB descriptors / report).
|
||||
- **Information leak** — exfiltrate cross-tenant data. But there is no cross-tenant data on this device. Skip.
|
||||
|
||||
### Entry point #2 — Over-the-air RX
|
||||
|
||||
**Reachable by:** OTA. Anyone with a LoRa radio that can transmit on the configured (`freq`, `sf`, `bw`, `sync_word`, `cr`, `preamble_len`, `iq_invert`) tuple while the firmware is in `RxActive`.
|
||||
|
||||
**What they can influence:** the bytes of a single received LoRa packet — `0..=MAX_OTA_PAYLOAD` (255) bytes of body. Side-channel metadata that the firmware *generates* (not the attacker): RSSI (signed i16), SNR (signed i16), timestamp (u64 micros). The header_mode / CRC flags are configured by the host, not the OTA peer.
|
||||
|
||||
**What this eventually does:** `collect_rx_packet` (`radio.rs:1664-1702`) calls `lora.get_rx_result(pkt, &mut ctx.rx_buf)`. Length is clamped to `MAX_OTA_PAYLOAD` before `extend_from_slice` into a fresh `heapless::Vec<u8, 255>`. The vec becomes the body of a `DeviceMessage::Rx`. Wire encoding (`events.rs:228-249`) is a fixed-layout memcpy, no parsing of OTA bytes inside the firmware.
|
||||
|
||||
**Worst-case if attacker fully controls the OTA packet:**
|
||||
- The firmware does not interpret OTA payload bytes; they are an opaque buffer relayed verbatim to the host. Worst is a memory-corruption bug in `get_rx_result` (lora-phy crate) or in `extend_from_slice` (heapless), which would be a third-party bug not firmware logic.
|
||||
- Spam packets at the configured peer ID to flood `OutboundChannel` (depth 64). Throttled by `RADIO_THROTTLED` once host stalls; otherwise the host sees a burst. Standard RF DoS — there is no defense at the device level beyond "host stops listening."
|
||||
- Trigger `IrqWaitFailed` → `RxResuming` retry loop. After `MAX_RESUME_RETRIES = 5` falls back to `Idle`. Recovers cleanly.
|
||||
|
||||
OTA is a **broad reach but very narrow impact** surface: the firmware exposes essentially no interpretation surface to the OTA peer.
|
||||
|
||||
### Entry point #3 — USB descriptor handling (control transfers)
|
||||
|
||||
**Reachable by:** LUSB, APP (anyone enumerating the device).
|
||||
|
||||
**What they can influence:** USB control transfer request type / index / length. Handled inside `embassy_usb::Builder::run()` — out of firmware's code.
|
||||
|
||||
**Worst-case:** a malformed control transfer crashes the USB stack and the firmware. The `CTRL_BUF` size was bumped to 128 specifically because 64 was crashing on some Linux enumeration paths (see comment in `host/usb.rs:191-196`). The user reports modem-manager's `SET_INTERFACE` with out-of-range alt setting is normal and embassy_usb's STALL is correct. **Suspected** dependency-level risk that we accept as a third-party concern.
|
||||
|
||||
## Code paths that warrant deep audit (prioritized)
|
||||
|
||||
In rough descending order of attacker-reachability × impact:
|
||||
|
||||
1. **Frame decode → command dispatch (`host/framing.rs`, `host/usb.rs::dispatch_bytes`, `host/uart.rs::dispatch_bytes`, `donglora-protocol/frame.rs`, `commands.rs`, `modulation.rs`):** every reachable byte from LUSB flows through here. Look for panics, buffer overruns in fixed-capacity scratch buffers, ambiguous overflow / CRC-skip paths.
|
||||
|
||||
2. **`Command::parse` for `SET_CONFIG`:** the deepest parse path — modulation-id dispatch, then nested decode. `LoRaConfig::decode` and friends.
|
||||
|
||||
3. **`apply_set_config` + `validate_lora` (`radio.rs:1233-1309`, `2125-2145`):** post-parse validation. Where do unchecked fields go — sync_word, iq_invert, header_mode, payload_crc? Memory note flags sync_word low-nibble validation as intentionally absent.
|
||||
|
||||
4. **`Command::Tx` ingestion → on-air TX (`radio.rs` chart `TxSession`):** TX payload (1..=255 attacker bytes) reaches the SX1262 PA without further parsing. The body bytes never act on firmware state except as opaque memcpy. Audit for buffer-length confusion between the chart's `pending_tx.data` and lora-phy's `prepare_for_tx(.., &pt.data)`.
|
||||
|
||||
5. **`get_rx_result` → `record_packet` → `OutboundFrame` encode (`radio.rs:1664-1715`, `2008-2040`; `events.rs:228-289`):** the only path where OTA-attacker bytes are ingested. Audit length handling and RxPayload encoding's bounds.
|
||||
|
||||
6. **State machine reachability**: chart panics (`tag_or_panic`, `NonZeroU16::new(...).expect(...)`, `radio chart returned`) — are any reachable from wire input?
|
||||
|
||||
7. **`unsafe` blocks** (4 sites): are they sound under the stated preconditions?
|
||||
|
||||
8. **USB serial number / Info disclosure** — what does the device disclose without prior auth?
|
||||
|
||||
9. **Dependencies**: any reachable CVE-bearing dep? Check `donglora-protocol`, `ucobs`, `lora-phy` (fork), `hsmc`, `heapless`, `embassy-usb`, `embassy-nrf` / `embassy-rp` / `esp-hal`.
|
||||
|
||||
10. **Build-time (`build.rs`)**: code generation reads files from `src/board/`. Build-time only; out of runtime attacker scope.
|
||||
|
||||
## Out-of-scope explicitly
|
||||
|
||||
- The host-side application that consumes the protocol (out of repo: ai-bot, bridge, client-py, mux-rs).
|
||||
- The Anthropic / Claude scripting context.
|
||||
- The donglora-protocol crate's *fuzz-targeted* invariants — they are validated in the sibling repo (`/home/swaits/Code/donglora-org/protocol/fuzz/`); we audit only how the firmware *uses* the crate.
|
||||
- Physical-side-channel attacks (power, timing, EM).
|
||||
- RF-regulatory compliance (firmware is intentionally transparent — see above).
|
||||
@@ -0,0 +1,146 @@
|
||||
# Phase 3 — Targeted analysis (raw notes)
|
||||
|
||||
Walked through every category in MYTHOS Phase 3 against the firmware's actual code paths. Most categories are not applicable to no_std embedded LoRa firmware with no networking, filesystem, shell, database, templates, deserializers, crypto, or auth. The categories that *do* apply have been traced in full below.
|
||||
|
||||
## Categories N/A by construction
|
||||
|
||||
- **SQL / NoSQL injection** — no database, no query language anywhere.
|
||||
- **Command injection** — no `exec` / `spawn`; the firmware has no shell.
|
||||
- **Template / SSTI** — `minijinja` is used only in `build.rs` at build time on a developer-controlled template (`src/board/mod.rs.j2`). Not exposed to runtime input.
|
||||
- **LDAP / XPath / EL** — none.
|
||||
- **Header injection / CRLF / log injection** — no HTTP, no syslog, no user-controlled string output.
|
||||
- **Authentication / Authorization** — none exists; firmware does not implement auth (design choice, see threat model).
|
||||
- **Cryptography** — none used; CRC-16/CCITT-FALSE is integrity-only, not security.
|
||||
- **Deserialization** — no serde, pickle, yaml, marshal, BinaryFormatter, or other reflective deserializer. Every field is decoded byte-by-byte via fixed-offset slicing in `donglora-protocol`.
|
||||
- **XSS / CSRF / CORS / Open redirect / Prototype pollution / ReDoS** — no JS, no HTML, no regex, no URL routing.
|
||||
- **SSRF** — no outbound HTTP / DNS / proxy. The "outbound URL fetcher" analog would be `lora.tx()`, but the host fully controls every parameter by design.
|
||||
- **File operations / Zip slip / Symlink** — no filesystem at runtime.
|
||||
- **XML** — no XML parser.
|
||||
|
||||
## Categories that DO apply (and the traces)
|
||||
|
||||
### Memory safety in parsing pipelines
|
||||
|
||||
Walked from `host_task` byte ingress to every consumer:
|
||||
|
||||
**Frame decode (`donglora-protocol-1.2.0/src/frame.rs`):**
|
||||
- `FrameDecoder::buf: [u8; MAX_COBS_ENCODED = 283]` — bounded.
|
||||
- Index `self.len` only ever written via `if self.len < self.buf.len() { ... self.len += 1 }` — safe.
|
||||
- On overflow → `overflowed = true; len = 0` — bytes are discarded silently until the next `0x00` sentinel resyncs; never out-of-bounds.
|
||||
- On `0x00` → `let mut decoded = [0u8; MAX_PRE_COBS_FRAME = 280]; ucobs::decode(&self.buf[..self.len], &mut decoded)` returns `Option<usize>`; None → emit `FrameDecodeError::Cobs` and reset.
|
||||
- `emit_decoded` requires `decoded.len() >= FRAME_HEADER_SIZE + FRAME_TRAILER_SIZE` (5 B); checks CRC; only then slices `type_id / tag / payload` from fixed offsets.
|
||||
|
||||
**Confirmed safe**: no path to an OOB read/write; no panic; no unbounded allocation.
|
||||
|
||||
**Command parse (`donglora-protocol-1.2.0/src/commands.rs::Command::parse`):**
|
||||
- `TYPE_TX`: requires `!payload.is_empty()`; reads `payload[0]` for flags; rejects reserved bits via `TxFlags::from_byte`; rejects `body.is_empty()`; rejects `body.len() > MAX_OTA_PAYLOAD`; `extend_from_slice` is bounded by both the slice length and the heapless capacity (`MAX_OTA_PAYLOAD = 255`).
|
||||
- `TYPE_SET_CONFIG`: every field byte feeds an `Option`-returning `from_u8` (rejects out-of-range), or a `match { 0 => false, 1 => true, _ => Err }` (rejects non-boolean), or an explicit length check `if buf.len() != WIRE_SIZE { Err }`.
|
||||
- All other types require empty payload.
|
||||
|
||||
**Confirmed safe.**
|
||||
|
||||
**`validate_lora` (`src/radio.rs:2125-2145`):** numeric range checks on `freq_hz`, `sf`, `bw`, `tx_power_dbm`, `preamble_len`. Shift-by-`sf` is safe (`sf` already bounded ≤12 by the preceding check, so `1u16 << sf ≤ 4096`); shift-by-`bw.as_u8()` is safe (`bw` enum repr is ≤ 13, `1u16 << 13 = 8192`).
|
||||
|
||||
Not validated: `preamble_len` upper bound, `sync_word`, the post-parser-checked `header_mode`/`payload_crc`/`iq_invert`.
|
||||
|
||||
- `preamble_len = 65535` — fed to `lora.create_*_packet_params`. `toa_us` math (`src/lbt.rs:44-77`) uses u64 throughout and clamps to `u32::MAX`. With SF12/BW7.81 kHz/preamble 65535: `t_preamble_us = 65535 * 524520 ≈ 3.4e10` which is u64-safe and gets clamped to `u32::MAX = 4.29e9`. **No overflow.** The radio chip's HW register handles whatever value is loaded; reasonable lora-phy behavior expected.
|
||||
- `sync_word = anything` — the memory note flags this as intentional (matches RadioLib/Semtech precedent). Confirmed not a vulnerability.
|
||||
|
||||
**Confirmed safe.**
|
||||
|
||||
**`try_start_rx_hw` / `cache_rx_pkt` (`src/radio.rs:1348+, 1628+`):** `MAX_OTA_PAYLOAD as u8 = 255` — current `MAX_OTA_PAYLOAD = 255` exactly fits `u8::MAX`. If the constant were ever raised above 255 the `as u8` would silently truncate — but the SX126x chip register is one byte wide so the constant is hardware-bound. No current bug.
|
||||
|
||||
**Confirmed safe.**
|
||||
|
||||
**`collect_rx_packet` (`src/radio.rs:1664-1702`):**
|
||||
- `rx_buf: [u8; MAX_OTA_PAYLOAD]` — the buffer handed to `lora.get_rx_result(pkt, &mut rx_buf)`.
|
||||
- The chip's `RX_PLD_LENGTH` is `255` (set in `try_start_rx_hw`), and the chip hardware register is one byte wide → `len` returned is bounded by 255 at the silicon level.
|
||||
- `let copy_len = (len as usize).min(MAX_OTA_PAYLOAD)` — defensive clamp.
|
||||
- `data.extend_from_slice(&ctx.rx_buf[..copy_len])` into `HVec<u8, MAX_OTA_PAYLOAD>` — bounds-safe.
|
||||
|
||||
**Confirmed safe.**
|
||||
|
||||
### Panic reachability from wire input
|
||||
|
||||
Catalog of panic sites and reachability:
|
||||
|
||||
| Location | Trigger | Reachable from wire? |
|
||||
|---|---|---|
|
||||
| `radio.rs:937` `NonZeroU16::new(tag).expect(...)` for `InboundEvent::Command` | tag=0 reaches radio task | No — `host/framing.rs:36-40` rejects tag=0 with `ERR(EFRAME)` before forwarding to `commands` channel |
|
||||
| `radio.rs:1209-1213` `tag_or_panic` | Action reads `incoming_tag` when None | No — actions that read it only run after `next_command` has stashed a tag (chart shape) |
|
||||
| `radio.rs:1753-1754` `PendingTx.tag was 0` | Pending TX with tag=0 | No — same framing layer rejects tag=0 |
|
||||
| `radio.rs:2232-2235` `radio chart returned` | Chart `run()` returns | No — chart is non-terminating (all leaf states have `during:` activities; this would require a chart-design bug, not a wire input) |
|
||||
| `main.rs:108, 113, 125, 138, 145` `.expect("…")` | Spawn / init failure | Boot-time only |
|
||||
| `host/usb.rs:189` `from_utf8(MAC hex)` | Invalid UTF-8 in MAC hex | Hex chars are constants `0-9 A-F`; always valid ASCII |
|
||||
|
||||
**Confirmed: no panic site is reachable from network input.**
|
||||
|
||||
### `unsafe` audit
|
||||
|
||||
Four `unsafe` blocks in firmware code:
|
||||
|
||||
1. `src/main.rs:105` — `INFO = Some(info); INFO.as_ref().expect(...)`. Touched only at boot, in the single-threaded `run(spawner)` before any task is spawned. Subsequent reads via `&'static Info` reference passed by argument. **Sound.**
|
||||
2. `src/hal/nrf52840.rs:102, 140` — `read_volatile` / `write_volatile` on `0x40000400` (POWER.RESETREAS). Valid MMIO. **Sound.**
|
||||
3. `src/hal/nrf52840.rs:151` — `read_volatile` on `0x10000000.byte_add(0xA4/0xA8)` (FICR.DEVICEADDR). Valid MMIO. **Sound.**
|
||||
4. `src/hal/rp2040.rs:68, 79` — `static mut MAC_REF` written once in `init_mac` (single-threaded init), read-only thereafter. **Sound.**
|
||||
|
||||
**Confirmed: no unsoundness in declared unsafe blocks.**
|
||||
|
||||
### TOCTOU / races
|
||||
|
||||
All cross-task communication uses `embassy_sync::channel::Channel` with `CriticalSectionRawMutex` — atomic enqueue/dequeue. Cross-task atomic flags (`RESET_PENDING`, `RADIO_THROTTLED`, `LORA_WEDGED`) use `portable_atomic::AtomicBool` with `AcqRel` swap or `Acquire/Release` loads/stores. The radio chip itself has exactly one user (the radio task), so there is no SPI-bus race.
|
||||
|
||||
**No exploitable race observed.**
|
||||
|
||||
### Information disclosure
|
||||
|
||||
USB serial-number descriptor = MAC address (hex). `GET_INFO` returns same MAC in `mcu_uid`. Either is readable by anyone enumerating the USB device or any successful `GET_INFO`, with no authentication.
|
||||
|
||||
- **Status:** Confirmed
|
||||
- **Severity:** Info (privacy / cross-host tracking)
|
||||
- **Reasoning:** This is the device's only stable cross-context identifier and it leaks unconditionally. A user reusing the dongle across hosts/networks is trivially correlatable.
|
||||
|
||||
### Dependency / supply chain
|
||||
|
||||
- `donglora-protocol 1.2.0` — sibling crate, fuzz-tested in `/home/swaits/Code/donglora-org/protocol/fuzz/`. No known CVEs.
|
||||
- `ucobs 0.3` — small crate; no known CVEs.
|
||||
- `lora-phy` — `git = "https://github.com/swaits/lora-rs.git", branch = "fixed"`, no `rev=` pin. Cargo.lock pins the current commit by SHA → reproducible *today*. But `cargo update -p lora-phy` will silently advance to whatever is on `branch=fixed` at that moment. If the maintainer's GitHub account is compromised, the next `cargo update` ingests the malicious commit.
|
||||
- `heapless 0.9`, `embassy-* 0.5/0.6/0.10/0.8`, `defmt 0.3`, `esp-hal 1.1`, `embassy-usb 0.6` — all current versions; no known reachable CVE in their used surface.
|
||||
|
||||
The `branch=` (not `rev=`) git dep is the only flag — a **Low** finding (hardening).
|
||||
|
||||
### USB stack / control-transfer surface
|
||||
|
||||
`embassy_usb` handles control transfers internally. The firmware allocates `CTRL_BUF: [u8; 128]`. A code comment (`host/usb.rs:191-196`) records that the previous 64-byte size matched the panic-site message `"control buffer too small"` in `embassy_usb/src/lib.rs:758` — a real bug observed during enumeration. The 128-byte size is documented as safely above the largest CDC-ACM request.
|
||||
|
||||
**Status:** Mitigated; relies on third-party (embassy-usb 0.6) correctness for control-transfer parsing. Not an actionable firmware finding.
|
||||
|
||||
### Reliability bug worth noting (not strictly a security finding)
|
||||
|
||||
`lilygo_tbeam` (ESP32 classic) — the host transport is UART0 (`GPIO1/GPIO3 → CP2102 → host`). `esp-println` with the `auto` feature falls back to UART0 on ESP32 classic (no USB-Serial-JTAG peripheral). In production builds (`DEFMT_LOG` unset → `error!` only), if the radio chart enters `Dead` or similar `error!`-emitting paths, defmt bytes are emitted onto the same UART as the binary protocol, corrupting frames.
|
||||
|
||||
- **Status:** Suspected; not reachable from wire input (the error-level logs fire on hardware faults).
|
||||
- **Severity:** Low (reliability, not security).
|
||||
- **Why noted:** Documents a real frame-corruption scenario that an operator should be aware of when soak-testing the T-Beam classic.
|
||||
|
||||
For `heltec_v3_uart` and `elecrow_thinknode_m2` (both ESP32-S3, which has USB-Serial-JTAG), esp-println's `auto` selects USB-Serial-JTAG, which is a *different* peripheral than the host UART. Those boards are NOT affected.
|
||||
|
||||
### Intentional design choices flagged for transparency
|
||||
|
||||
These are NOT findings — they are deliberate, documented features. They appear here so the audit's reader knows they were considered and accepted:
|
||||
|
||||
- **No authentication on host transport.** A "dongle" trust model: physical attachment = full access.
|
||||
- **No RF regulatory enforcement.** Frequency, sync word, and TX power are whatever the host sends (within hardware-supported range). FCC / CE / ETSI compliance is the *user's* responsibility.
|
||||
- **TX `skip_cad=1` bypasses carrier-sense.** Documented in `PROTOCOL.md`; available to anyone with host-transport access.
|
||||
|
||||
## Items considered and dismissed
|
||||
|
||||
- Panic-on-malformed-frame (no — framing layer reflects `ERR(EFRAME)` and clears the decoder).
|
||||
- Integer overflow in `toa_us` (no — u64 intermediates, u32 clamp; bound-checked).
|
||||
- Buffer overrun on max-payload RX (no — chip HW register caps to 255; firmware buffer is sized to 255; `extend_from_slice` is bounded).
|
||||
- Frame-decoder buffer exhaustion DoS (mitigated — overflow state silently discards bytes until resync; one packet, one frame; no accumulation across the limit).
|
||||
- Stack overflow from deep call chains (no recursion; nested stack frames in `dispatch_bytes → feed → emit_decoded → Command::parse → Modulation::decode → LoRaConfig::decode` use ~1.5 KB total on a 4–32 KB task stack budget).
|
||||
- Memory safety in the unsafe blocks (audited above).
|
||||
- TOCTOU between USB read and radio dispatch (channel-mediated; no race).
|
||||
- Side channels (timing on CRC compare, etc.) — irrelevant: there are no secrets to time-leak.
|
||||
- `panic-probe` emitting panic message: emitted via `defmt-rtt`, which is a *different* hardware channel (SWD probe) than USB/UART. A panic locks the device (DoS) but does not leak to host or peer.
|
||||
@@ -0,0 +1,216 @@
|
||||
# Security Audit Report — donglora firmware v1.5.2
|
||||
|
||||
Auditor: **Mythos** (adversarial security review per `MYTHOS.md`)
|
||||
Repo: `/home/swaits/Code/donglora-org/firmware`, HEAD `688938d` (chore: release 1.5.2)
|
||||
Phases: Recon (`01-recon.md`) → Threats (`02-threats.md`) → Targeted analysis (`03-findings-draft.md`) → Verification → this report.
|
||||
|
||||
---
|
||||
|
||||
## Executive summary
|
||||
|
||||
**3 findings total: 0 Critical, 0 High, 0 Medium, 2 Low, 1 Info.**
|
||||
|
||||
The firmware is a small, tightly-scoped, no_std embedded Rust binary that exposes a SX126x/SX127x radio over a single host transport (USB CDC-ACM or UART). It performs **no networking, no filesystem I/O, no shell exec, no template rendering, no SQL/NoSQL/XML, no cryptography, and no deserialization beyond explicit byte slicing**. The whole vulnerability landscape MYTHOS targets is out of scope by construction; the realistic attack surface is exactly two paths: (1) inbound frames on the host transport, and (2) inbound LoRa packets on the configured RF parameters.
|
||||
|
||||
Both paths were traced end-to-end:
|
||||
|
||||
- **Host-transport ingress.** Every reachable byte goes through a CRC-checked, COBS-framed `FrameDecoder` → strict typed `Command::parse` → numeric-bounded `validate_lora`. Reserved bits, unknown enum values, length mismatches, and tag=0 are all explicitly rejected and turned into `ERR(...)` responses. No reachable panic, no buffer overrun, no integer overflow, no OOB access was found.
|
||||
- **OTA RX ingress.** Payload bytes are bounded to 255 (SX126x register width); copied into a `heapless::Vec<u8, 255>` and forwarded to the host verbatim. The firmware never parses OTA bytes — there is no interpretation surface for a remote attacker to exploit.
|
||||
|
||||
The remaining findings concern **disclosure** (MAC visible in the USB serial descriptor — by design but worth documenting), **supply-chain hardening** (git dep not commit-pinned), and **reliability on one board variant** (esp-println output collides with host UART on lilygo_tbeam classic only). All are Low or Info severity.
|
||||
|
||||
**Top 3 systemic observations (NOT findings — design choices flagged for transparency):**
|
||||
|
||||
1. **No authentication on host transport.** Anyone with kernel-level access to the CDC-ACM/UART device node controls the radio. The product is intentionally a "transparent dongle" — physical attachment is the auth boundary.
|
||||
2. **No RF-regulatory enforcement.** Frequency, sync word, and TX power are whatever the host commands, within hardware capability. FCC / CE / ETSI compliance is the user's responsibility.
|
||||
3. **`TX skip_cad=1` bypasses listen-before-talk on demand.** Documented in `PROTOCOL.md`; available to any host-side actor.
|
||||
|
||||
These are deliberate features of a transparent LoRa bridge; treating them as bugs would be a category error. They are surfaced here so a security reader knows the trust model.
|
||||
|
||||
**Recommended remediation priorities:** None are urgent. The two Low findings can be addressed when convenient as hardening work; the Info finding is a documentation matter.
|
||||
|
||||
---
|
||||
|
||||
## Findings
|
||||
|
||||
### [LOW] lora-phy git dependency uses mutable branch ref, not a pinned commit
|
||||
|
||||
- **File(s):** `Cargo.toml:35`
|
||||
- **Category:** Supply chain
|
||||
- **Status:** Confirmed
|
||||
- **Severity:** Low (CVSS-ish 4.4 — depends on a future `cargo update` and a maintainer-account compromise; reproducible builds are not affected today)
|
||||
- **CWE:** CWE-829 (Inclusion of functionality from untrusted control sphere)
|
||||
|
||||
#### Summary
|
||||
|
||||
`lora-phy` is sourced from a git fork via `branch = "fixed"` rather than `rev = "<sha>"`. `Cargo.lock` currently pins the specific commit `043d7659…` (confirmed in `Cargo.lock:632-640`), so builds in this exact tree are reproducible. However, any future `cargo update -p lora-phy` (or running on a developer machine without a committed lockfile, which the firmware repo _does_ commit) will silently advance to whatever sits on `branch=fixed` at that moment. If the `swaits/lora-rs` GitHub account were compromised, the next `cargo update` ingests the malicious commit and the next-built firmware ships it.
|
||||
|
||||
#### Data flow
|
||||
|
||||
1. `Cargo.toml:35` declares `lora-phy = { git = "https://github.com/swaits/lora-rs.git", branch = "fixed", features = ["defmt-03"] }`.
|
||||
2. `Cargo.lock:634` records the current resolved commit hash.
|
||||
3. Any contributor running `cargo update` (or any auto-update tooling on the maintainer's machine) replaces the lockfile entry with whatever is at HEAD of the branch at update time.
|
||||
4. The resulting binary executes whatever the new HEAD commit contains, with no human-readable diff at the dependency boundary unless someone inspects the lockfile delta.
|
||||
|
||||
#### Proof of concept
|
||||
|
||||
Reproducibility today is fine — `cargo build` against the committed lockfile uses commit `043d7659…`. The exposure is **temporal**: the moment `cargo update -p lora-phy` runs, the lockfile takes whatever commit is then on the branch. There is no PoC to demonstrate beyond reading the manifest.
|
||||
|
||||
#### Impact
|
||||
|
||||
A maintainer-account compromise (GitHub credential theft, PAT leak, repo-write malware) lets the attacker push a backdoored commit to the `fixed` branch. The next build that runs `cargo update` ships the backdoor into every flashed dongle. Severity is bounded by (a) the rate at which `cargo update` is run and (b) the difficulty of compromising the maintainer's GitHub. It's not zero — pinning to a commit closes the window.
|
||||
|
||||
#### Fix
|
||||
|
||||
Switch the manifest from `branch = "..."` to `rev = "<commit_sha>"`:
|
||||
|
||||
```toml
|
||||
lora-phy = { git = "https://github.com/swaits/lora-rs.git", rev = "043d7659dfe2148913f16b2f7ea3e9241642b770", features = ["defmt-03"] }
|
||||
```
|
||||
|
||||
Re-pin (bump the `rev`) on each lora-phy upgrade. Drops the temporal exposure entirely; the dep changes only when a human edits the manifest. `TODO.md` already plans to drop the fork once upstream PRs merge — at that point switch back to a crates.io version, which gets the same effect from the registry's immutability.
|
||||
|
||||
---
|
||||
|
||||
### [LOW] On `lilygo_tbeam` (ESP32 classic) only, esp-println output collides with host UART
|
||||
|
||||
- **File(s):** `src/board/lilygo_tbeam.rs:112-120`, `Cargo.toml:73-77`, `src/main.rs:53-57`
|
||||
- **Category:** Wire-level reliability / information disclosure
|
||||
- **Status:** Confirmed (frame corruption when error-level logs fire); not reachable from wire input today
|
||||
- **Severity:** Low (reliability with disclosure side-effects; not directly attacker-triggerable)
|
||||
- **CWE:** CWE-209 (Information Exposure Through an Error Message) — secondary
|
||||
|
||||
#### Summary
|
||||
|
||||
For the `lilygo_tbeam` build target (ESP32 classic, SX1276, CP2102-bridged UART), the host transport is `UART0` on `GPIO1/GPIO3`. `esp-println 0.17` is enabled with the `auto` (default) + `defmt-espflash` features. On ESP32 classic there is no USB-Serial-JTAG peripheral, so `auto` falls back to `UART0` — the _same_ UART the host protocol uses. Any defmt output (in production builds, only `error!` survives the compile-time filter) writes bytes onto the host wire mid-frame, corrupting the binary protocol the host is reading.
|
||||
|
||||
For `heltec_v3_uart` and `elecrow_thinknode_m2` (ESP32-S3, which _does_ have USB-Serial-JTAG), `esp-println auto` selects USB-Serial-JTAG — a _different_ peripheral. Those boards are unaffected.
|
||||
|
||||
#### Data flow
|
||||
|
||||
1. `Cargo.toml:73-77` pulls `esp-println` with `defmt-espflash` (no `uart` / `jtag-serial` feature explicitly set → `auto` picks at runtime).
|
||||
2. `src/main.rs:53-57` installs `esp_println` as defmt's global handler for the esp boards.
|
||||
3. `src/board/lilygo_tbeam.rs:113-120` initializes `Uart::new(p.UART0, ...).with_tx(p.GPIO1).with_rx(p.GPIO3)` as the host transport.
|
||||
4. The ESP32 classic has no `USB_SERIAL_JTAG` peripheral → esp-println's runtime auto-selector falls back to UART0.
|
||||
5. When a `defmt::error!(...)` or `defmt::panic!(...)` line executes on this board, esp-println writes bytes to UART0 → host sees them interleaved with COBS-framed protocol bytes.
|
||||
|
||||
#### Reachability from wire input
|
||||
|
||||
Walked the `error!` and `panic!` callsites: all fire only on hardware faults (`Recovering: exhausted ... transitioning to Dead`, `radio chart entered Dead state`, `lora cold init failed`, chart-invariant panics that are themselves not wire-reachable). **None are reachable from a malformed command.** Treat as a reliability concern, not a wire-attack.
|
||||
|
||||
#### Proof of concept
|
||||
|
||||
Run firmware compiled for `lilygo_tbeam`, intentionally induce a hardware-side error (e.g., disconnect the SX1276 SPI bus while the host is sending commands). Observe `defmt error!` output corrupting the COBS frames on the same UART. Without hardware tampering there is no PoC.
|
||||
|
||||
#### Impact
|
||||
|
||||
If the chip ever wedges hard enough to drive the chart into `Recovering` exhaustion or `Dead`, the host suddenly sees frame-CRC errors caused by defmt bytes instead of the chip-failure response it should have received. The host's framing layer recovers at the next clean `0x00` sentinel, but the lost frames could be the very `ERadio` reply that would have signaled the underlying failure. **Diagnostic blindness during hardware failure** on this one board variant.
|
||||
|
||||
#### Fix
|
||||
|
||||
Pick one of these for the lilygo_tbeam build:
|
||||
|
||||
- **Option A (preferred):** add `"no-op"` to the `esp-println` feature set for the `lilygo_tbeam` board feature, so the defmt global handler discards output entirely. Trade-off: no `probe-rs` defmt log capability on this board. Acceptable since the board has no SWD path anyway — it's CP2102-only.
|
||||
- **Option B:** explicitly route esp-println to a _different_ UART (e.g., `UART1`) by adding the `uart` feature with manual pin selection. Requires reading the esp-println 0.17 docs to confirm the pin override hook.
|
||||
|
||||
This is also the spirit of the existing `debug-checkpoint` feature for UART-only boards (the LED-blink workaround), per `Cargo.toml:139-144`. The same "no shared UART for protocol + logs" rule should apply.
|
||||
|
||||
---
|
||||
|
||||
### [INFO] MAC address disclosed in USB serial descriptor and `GET_INFO` without authentication
|
||||
|
||||
- **File(s):** `src/host/usb.rs:182-189`, `src/main.rs:159-161, 180-181`
|
||||
- **Category:** Information disclosure / privacy
|
||||
- **Status:** Confirmed
|
||||
- **Severity:** Info (intentional disclosure of a stable device identifier; standard for USB devices but worth documenting)
|
||||
- **CWE:** CWE-200 (Exposure of Sensitive Information)
|
||||
|
||||
#### Summary
|
||||
|
||||
The device's 6-byte hardware MAC (nRF52840 FICR.DEVICEADDR / ESP32 eFuse / RP2040 flash-UID-derived) appears in two places, both readable without any authentication:
|
||||
|
||||
1. The USB CDC-ACM serial-number string descriptor (12 chars uppercase hex, no separator).
|
||||
2. `GET_INFO.mcu_uid` (first 6 bytes; remaining 26 are zero).
|
||||
|
||||
Both surfaces are present at every boot. USB descriptors are read by every OS during enumeration without user consent; `GET_INFO` is wire-reachable by anyone who can write to the CDC-ACM/UART device.
|
||||
|
||||
#### Data flow (USB descriptor)
|
||||
|
||||
1. `src/main.rs:99-109` — boot reads `parts.mac` via `board::Board::mac_address()`.
|
||||
2. `src/main.rs:118-124` — MAC passed to `host_task`.
|
||||
3. `src/host/usb.rs:185-189` — MAC bytes formatted as 12 ASCII hex chars; assigned to `config.serial_number`.
|
||||
4. Embassy USB stack publishes it as USB string descriptor (the OS retrieves on enumeration).
|
||||
|
||||
#### Data flow (GET_INFO)
|
||||
|
||||
1. `src/main.rs:159-161` — `mcu_uid[..6].copy_from_slice(&mac); Info.mcu_uid_len = 6`.
|
||||
2. Any host issuing a valid `PING`-tag-2-format `GET_INFO` frame gets the `Info` blob back, including the 6-byte MAC.
|
||||
|
||||
#### Impact
|
||||
|
||||
The MAC is a permanent, factory-burned device identifier. A user moving a dongle between hosts/networks (laptop ↔ desk; home ↔ office; conference Wi-Fi) is correlatable across contexts by any malicious userspace program that enumerates USB. The information is _useful_ — `donglora-mux` and friends rely on it to distinguish multiple dongles — so it cannot simply be removed.
|
||||
|
||||
**This is also the standard model for USB devices in general.** Every USB device with a serial-number descriptor exposes a stable identifier; the user gets no consent prompt at enumeration time. Treat this finding as documentation rather than a remediation request.
|
||||
|
||||
#### Fix (optional, only if you care about cross-host de-anonymization)
|
||||
|
||||
- Replace the MAC in the USB serial descriptor with a per-install random byte string generated once and stored in MCU non-volatile memory (if available). Keep the real MAC accessible via authenticated `GET_INFO` if/when authentication is added.
|
||||
- Or: leave the descriptor MAC alone (it's the standard pattern) and document the privacy expectation in `README.md` so users understand the dongle is trackable.
|
||||
|
||||
The Info-severity rating is the right call: this is a transparency / documentation question, not an actionable bug.
|
||||
|
||||
---
|
||||
|
||||
## Appendix A — Out-of-scope items
|
||||
|
||||
The audit did not cover:
|
||||
|
||||
- **Host-side applications** that consume the protocol (`ai-bot`, `bridge`, `client-py`, `mux-rs`, `mux-py`, `client-rs`, `tools`). Each is a separate audit target.
|
||||
- **The `donglora-protocol` crate's invariants** beyond how the firmware uses them. The sibling repo at `/home/swaits/Code/donglora-org/protocol/` already maintains a fuzz suite (`protocol/fuzz/`) and mutation tests (`protocol/mutants.out*`); those validate the codec independent of this audit.
|
||||
- **Physical-layer / RF-regulatory compliance** (FCC / CE / ETSI). The firmware is _intentionally_ transparent; user is responsible.
|
||||
- **Side-channel analysis** (timing, power, electromagnetic) — irrelevant on a device with no secrets.
|
||||
- **Build-time compromise** (malicious `build.rs`, compromised toolchain, supply-chain attacks against rustc / espup / cargo). The Low finding above is the closest the audit gets to this surface.
|
||||
- **Bootloader / DFU surface** (UF2 / Adafruit nRF52 bootloader / espflash). Those are upstream code paths; this audit covers the application image only.
|
||||
- **Hardware tampering / cold-boot RAM scraping / glitching the MCU**. Out of scope for source-code audit.
|
||||
|
||||
## Appendix B — Negative findings (audited and concluded not vulnerable)
|
||||
|
||||
Each item below was checked at source level and confirmed safe. This list is the most useful part of the audit: it documents what was _examined_ and why a Critical / High finding does not exist.
|
||||
|
||||
- **Frame decoder buffer handling** (`donglora-protocol-1.2.0/src/frame.rs::FrameDecoder::feed`): fixed-size buf, bounded index, overflow → resync. No OOB. No panic. No unbounded growth.
|
||||
- **CRC-16/CCITT-FALSE on every frame**: cryptographically weak (it's a CRC, not a MAC) but appropriate for transport integrity. Defends against the firmware acting on bit-flipped frames.
|
||||
- **`Command::parse` for every type_id** (`donglora-protocol-1.2.0/src/commands.rs:133-181`): reserved bits, unknown enum values, length mismatches, empty payloads all rejected explicitly with typed errors that the firmware turns into `ERR(ELENGTH|EPARAM|EMODULATION|EUNKNOWN_CMD)`. No silent acceptance.
|
||||
- **`Modulation::decode` and `LoRaConfig::decode`** (`donglora-protocol-1.2.0/src/modulation.rs:743-755, 224-263`): every byte either bound-checked by `from_u8` or `match` arms reject non-{0,1} booleans. `WrongLength` returns on size mismatch.
|
||||
- **`validate_lora`** (`radio.rs:2125-2145`): freq, SF, BW, power, preamble bounds. The two unchecked fields (`preamble_len` upper bound and `sync_word`) were traced into downstream `toa_us` (u64 math with `u32::MAX` clamp — confirmed overflow-safe) and `set_lora_sync_word` (per memory-note, silent acceptance is intentional).
|
||||
- **`try_start_rx_hw` → `prepare_for_rx` chain** (`radio.rs:1348-1438`): `MAX_OTA_PAYLOAD as u8 = 255` is hardware-bounded (SX126x payload-length register is one byte). No truncation today; would surface at compile-time if `MAX_OTA_PAYLOAD` ever changed.
|
||||
- **`collect_rx_packet`** (`radio.rs:1664-1702`): `rx_buf: [u8; MAX_OTA_PAYLOAD]` matches the chip-level cap. `copy_len = len.min(MAX_OTA_PAYLOAD)`. `extend_from_slice` is bounds-safe.
|
||||
- **Panic reachability from wire input**: 6 panic sites enumerated; all reach only via chart-shape violations, not from network input. Specifically, the `NonZeroU16::new(tag).expect(...)` at `radio.rs:937` is gated by the `if tag == 0 { ... return; }` at `host/framing.rs:36-40` _before_ the envelope can be enqueued.
|
||||
- **`unsafe` blocks** (4 sites): one-time-init `static mut` patterns (sound under stated preconditions, before any task spawns) and MMIO `read_volatile` / `write_volatile` (sound for `RESETREAS` / `FICR.DEVICEADDR`).
|
||||
- **Cross-task races / TOCTOU**: `embassy_sync::channel::Channel<CriticalSectionRawMutex, ...>` for every cross-task hop. `portable_atomic::AtomicBool` with `AcqRel` swaps for shared flags. The radio chip has exactly one user (radio task), so no SPI-bus race.
|
||||
- **USB-descriptor / control-transfer handling**: `embassy_usb` third-party. The firmware's `CTRL_BUF = 128` is explicitly oversized to defeat a known panic site in the 0.5/0.6 era (documented in source comment at `host/usb.rs:191-196`).
|
||||
- **`embassy_usb_driver::EndpointError` handling** (`host/usb.rs:281-313`): `Disabled` is logged at debug only (normal during enumeration), `BufferOverflow` at warn — and the decoder is reset and the read loop sleeps 100 ms to avoid burning the executor on a stuck driver.
|
||||
- **Stack overflow from nested parse calls**: depth-bounded (`feed → emit_decoded → Command::parse → Modulation::decode → LoRaConfig::decode`); cumulative stack use ≈ 1.5 KB on a 4–32 KB task stack.
|
||||
- **DoS via async-err flood**: `RADIO_THROTTLED` gates `emit_async_err`; outbound depth-64 ring absorbs bursts; the channel can never grow.
|
||||
- **DoS via Reset queue flood**: `RESET_PENDING` AtomicBool dedupes injected resets; documented at `channel.rs:16-29`.
|
||||
- **Reachable `defmt::panic!()` in `radio_task` epilogue**: gated by chart non-termination invariant (every leaf has a `during:` activity); not reachable by wire input.
|
||||
- **TX path payload handling**: `pending_tx.data` is `heapless::Vec<u8, MAX_OTA_PAYLOAD>` — bounded at parse-time. `lora.tx()` writes whatever bytes the host supplied. No memory-corruption surface; no auth surface to bypass.
|
||||
- **OTA RX → outbound queue**: the firmware does not interpret OTA bytes. Wire encoding (`events.rs:228-249`) is a fixed-layout memcpy with bound checks at the front (`if buf.len() < total { Err }`).
|
||||
- **`InboundEvent::Reset` clears the radio chart`incoming_tag` slot** (`radio.rs:923-930`) so a tag-echoing action that runs after Reset doesn't pull a stale tag.
|
||||
- **No deserializers that take untrusted input by reflection** (no serde, no XML, no YAML, no pickle, no protobuf with dynamic types).
|
||||
- **No SQL, NoSQL, shell, template, regex, HTTP, DNS, file, zip, XML surfaces in this firmware.**
|
||||
|
||||
## Appendix C — Confidence and methodology
|
||||
|
||||
- **Confidence:** High for the negative-findings list (every claim was traced with file:line); High for the three findings (each verified by re-reading the cited code after writing the draft).
|
||||
- **Method:** MYTHOS Phase 1–5 walkthrough as defined in `MYTHOS.md`. Static review only; no runtime fuzzing was performed in this pass. The donglora-protocol crate's own fuzz suite (sibling repo) is presumed to validate codec invariants and is not duplicated here.
|
||||
- **Not done:** `cargo audit` / `cargo deny` were not run as part of this audit. Recommended as a CI addition (CHANGELOG.md mentions hardening pulses; this would fit naturally). At the time of writing, no known reachable CVE for any used dependency surface is known to the auditor.
|
||||
|
||||
## Self-check
|
||||
|
||||
- [x] Every finding has file + line numbers
|
||||
- [x] Every Confirmed finding has a PoC, a data-flow trace, or an unambiguous "this is what's in the source today" reference (the Low / Info ratings make a remote PoC moot for the supply-chain and disclosure findings)
|
||||
- [x] Every finding has a one-sentence impact statement
|
||||
- [x] No findings are pure pattern-matches without reachability analysis
|
||||
- [x] Severity ratings are calibrated — nothing rated above Low; that matches what the audit actually found
|
||||
- [x] Negative findings appendix exists
|
||||
- [x] No suggestions beyond what's needed to fix the actual bug
|
||||
File renamed without changes.
File diff suppressed because it is too large.
Load diff
@@ -13,16 +13,17 @@ version := `sed -n 's/^version = "\(.*\)"/\1/p' Cargo.toml`
|
||||
# Xtensa targets are built with `+esp` + `-Zbuild-std=core,alloc` (the
|
||||
# self-contained esp toolchain ships its own rust-src; the public
|
||||
# nightly drifts ahead and breaks core-stdlib compilation).
|
||||
boards := "heltec_v3 heltec_v3_uart heltec_v4 elecrow_thinknode_m2 lilygo_tbeam lilygo_tbeam_supreme rak_wisblock_4631 waveshare_rp2040_lora wio_tracker_l1"
|
||||
boards := "heltec_mesh_node_t114 heltec_v3 heltec_v3_uart heltec_v4 elecrow_thinknode_m2 lilygo_tbeam lilygo_tbeam_supreme rak_wisblock_4631 waveshare_rp2040_lora wio_tracker_l1"
|
||||
|
||||
# Install all required tools and toolchains
|
||||
setup:
|
||||
mise trust --yes
|
||||
mise install
|
||||
@# ESP Xtensa toolchain (not a rustup component — installed out-of-band by espup)
|
||||
@# ESP Xtensa toolchain — installed via the `esp-install` mise task
|
||||
@# (which pins versions to match mise.toml's [env] paths).
|
||||
@if ! rustup toolchain list | grep -q "^esp"; then \
|
||||
echo "ESP toolchain not found, installing via espup (this may take a while)..."; \
|
||||
espup install || { echo "error: espup install failed" >&2; exit 1; }; \
|
||||
echo "ESP toolchain not found, installing via mise task (this may take a while)..."; \
|
||||
mise run esp-install || { echo "error: esp-install task failed" >&2; exit 1; }; \
|
||||
fi
|
||||
|
||||
# Build release firmware for all boards, installing toolchains as needed
|
||||
@@ -82,12 +83,48 @@ flash board extra_features="":
|
||||
|
||||
# Flash ARM board via debug probe (requires J-Link or similar).
|
||||
# `extra_features` is passed through to the build.
|
||||
# Sets DEFMT_LOG at build time so defmt::info!/warn!/error! are actually
|
||||
# compiled in (default filter is ERROR-only — strips info logs).
|
||||
# `embassy_usb=error` silences the benign Linux modem-manager
|
||||
# `SET_INTERFACE: alt setting out of range` WARN that fires once per
|
||||
# enumeration; it's a normal STALL response (correct USB behavior),
|
||||
# the WARN is just embassy_usb noise. Other crates inherit the `info`
|
||||
# default so chart traces, our own info!/warn!/error! all still fire.
|
||||
flash-probe board extra_features="":
|
||||
@just _ensure_tools
|
||||
@just build {{board}} release "{{extra_features}}"
|
||||
@DEFMT_LOG=info,embassy_usb=error just build {{board}} release "{{extra_features}}"
|
||||
@set -- $(just _info {{board}}); feat=$1; target=$2; chip=$3; \
|
||||
probe-rs run --chip $chip "{{builds_dir}}/donglora-{{board}}-v{{version}}.elf"
|
||||
|
||||
# UF2-flash via the bootloader, then probe-rs attach for RTT log streaming.
|
||||
# Required on boards (e.g. RAK4631) where the bootloader gates the app
|
||||
# behind a "valid app flag" in its settings page that only the bootloader
|
||||
# itself can set — `probe-rs run`'s direct SWD writes bypass that flag and
|
||||
# leave the bootloader stuck in DFU mode. UF2 path goes through the
|
||||
# bootloader, sets the flag, then we attach for RTT (no reset, no reflash).
|
||||
flash-attach board extra_features="":
|
||||
@just _ensure_tools
|
||||
@DEFMT_LOG=info,embassy_usb=error just build {{board}} release "{{extra_features}}"
|
||||
@just _flash_uf2 {{board}}
|
||||
@set -- $(just _info {{board}}); feat=$1; target=$2; chip=$3; \
|
||||
sleep 1; \
|
||||
probe-rs attach --chip $chip --no-catch-reset \
|
||||
"{{builds_dir}}/donglora-{{board}}-v{{version}}.elf"
|
||||
|
||||
# Attach probe-rs for live RTT/defmt streaming WITHOUT flashing or
|
||||
# resetting the chip. Logs are tee'd to a UTC-timestamped file in /tmp
|
||||
# so soak captures don't clobber each other across runs. Use this with
|
||||
# the dongle already running the desired firmware (flash via `just flash`
|
||||
# or `just flash-attach` first).
|
||||
attach board:
|
||||
@just _ensure_tools
|
||||
@set -- $(just _info {{board}}); feat=$1; target=$2; chip=$3; \
|
||||
log="/tmp/donglora-soak-$(date -u +%Y%m%dT%H%M%SZ).log"; \
|
||||
echo "Logging to $log"; \
|
||||
probe-rs attach --chip $chip --no-catch-reset \
|
||||
"{{builds_dir}}/donglora-{{board}}-v{{version}}.elf" 2>&1 \
|
||||
| tee "$log"
|
||||
|
||||
# Show binary size for a release build
|
||||
size board:
|
||||
@just _cargo {{board}} "size --release"
|
||||
@@ -118,7 +155,6 @@ _cargo board cmd extra_args="" extra_features="":
|
||||
extra=""; buildstd=""; \
|
||||
case "$target" in xtensa-*) \
|
||||
just _require_esp_toolchain; \
|
||||
[ -f "$HOME/export-esp.sh" ] && . "$HOME/export-esp.sh"; \
|
||||
extra="+esp"; buildstd="-Zbuild-std=core,alloc";; \
|
||||
esac; \
|
||||
cargo $extra {{cmd}} --target "$target" --features "$feat" $buildstd {{extra_args}}
|
||||
@@ -132,15 +168,17 @@ _can_build board:
|
||||
*) rustup target list --installed | grep -q "^$target$" || rustup target add "$target" ;; \
|
||||
esac
|
||||
|
||||
# Ensure the ESP Xtensa toolchain is installed (espup installed via mise).
|
||||
# The esp toolchain ships with its own cargo, rustc, and rust-src — no extra
|
||||
# Ensure the ESP Xtensa toolchain is installed. The actual install is
|
||||
# delegated to the `esp-install` mise task so the version pin lives in
|
||||
# mise.toml alongside the matching [env] PATH/LIBCLANG_PATH entries.
|
||||
# The esp toolchain ships its own cargo, rustc, and rust-src — no extra
|
||||
# components are needed on top.
|
||||
[private]
|
||||
_require_esp_toolchain:
|
||||
@just _ensure_tools
|
||||
@if ! rustup toolchain list | grep -q "^esp"; then \
|
||||
echo "ESP toolchain not found, installing via espup (this may take a while)..." >&2; \
|
||||
espup install || { echo "error: espup install failed" >&2; exit 1; }; \
|
||||
echo "ESP toolchain not found, installing via mise task (this may take a while)..." >&2; \
|
||||
mise run esp-install || { echo "error: esp-install task failed" >&2; exit 1; }; \
|
||||
fi
|
||||
|
||||
# Find a serial port matching any of the given VID:PID pairs (checked in order)
|
||||
@@ -208,6 +246,7 @@ _flash_uf2 board:
|
||||
[private]
|
||||
_info name:
|
||||
@case "{{name}}" in \
|
||||
heltec_mesh_node_t114) echo "heltec_mesh_node_t114 thumbv7em-none-eabihf nRF52840_xxAA" ;; \
|
||||
heltec_v3) echo "heltec_v3 xtensa-esp32s3-none-elf esp32s3" ;; \
|
||||
heltec_v3_uart) echo "heltec_v3_uart xtensa-esp32s3-none-elf esp32s3" ;; \
|
||||
heltec_v4) echo "heltec_v4 xtensa-esp32s3-none-elf esp32s3" ;; \
|
||||
|
||||
@@ -1,16 +1,21 @@
|
||||
/* nRF52840 memory layout — Adafruit UF2 bootloader
|
||||
/* nRF52840 memory layout — Adafruit UF2 bootloader + s140 SoftDevice
|
||||
*
|
||||
* 0x00000 – 0x01000 MBR (Master Boot Record)
|
||||
* 0x01000 – 0xEA000 Application ← we live here
|
||||
* 0x01000 – 0x26000 s140 SoftDevice (dormant — we don't use BLE)
|
||||
* 0x26000 – 0xEA000 Application ← we live here
|
||||
* 0xEA000 – 0xF4000 Reserved (app data)
|
||||
* 0xF4000 – 0xFC000 UF2 Bootloader
|
||||
* 0xFF000 – 0x100000 Bootloader settings
|
||||
*
|
||||
* RAM: first 8 bytes reserved for MBR interrupt forwarding.
|
||||
* RAM: first 8 bytes reserved for MBR interrupt forwarding. SoftDevice
|
||||
* RAM (typically 0x20000008–0x20002000) is NOT reserved here because we
|
||||
* never call sd_softdevice_enable — SD sits dormant in flash and never
|
||||
* touches RAM. cortex-m-rt's set-vtor feature handles relocating VTOR
|
||||
* to the app's vector table at 0x26000 at runtime.
|
||||
*/
|
||||
|
||||
MEMORY
|
||||
{
|
||||
FLASH : ORIGIN = 0x00001000, LENGTH = 0xE9000
|
||||
FLASH : ORIGIN = 0x00026000, LENGTH = 0xCE000
|
||||
RAM : ORIGIN = 0x20000008, LENGTH = 255K
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
/* Wio Tracker L1 memory layout — UF2 bootloader, NO SoftDevice
|
||||
*
|
||||
* 0x00000 – 0x01000 MBR (Master Boot Record)
|
||||
* 0x01000 – 0xEA000 Application ← we live here (no SD present)
|
||||
* 0xEA000 – 0xF4000 Reserved (app data)
|
||||
* 0xF4000 – 0xFC000 UF2 Bootloader
|
||||
* 0xFF000 – 0x100000 Bootloader settings
|
||||
*
|
||||
* Background: Seeed factory firmware ships s140 SoftDevice at
|
||||
* 0x01000–0x26000, but our earlier UF2 flashes (with the original
|
||||
* `ORIGIN = 0x1000` layout) silently overwrote that region. The WIO
|
||||
* bootloader now reports `SoftDevice: not found`; until we restore
|
||||
* s140 via SWD, the app must live at 0x1000 to match the bootloader's
|
||||
* no-SD boot chain.
|
||||
*
|
||||
* Delete this file once SD is reinstalled — `build.rs` will then fall
|
||||
* back to `ld/nrf52840-memory.x` (the SD-aware default at 0x26000)
|
||||
* and the WIO matches every other nRF52840 board.
|
||||
*/
|
||||
|
||||
MEMORY
|
||||
{
|
||||
FLASH : ORIGIN = 0x00001000, LENGTH = 0xE9000
|
||||
RAM : ORIGIN = 0x20000008, LENGTH = 255K
|
||||
}
|
||||
@@ -4,8 +4,8 @@ just = "latest"
|
||||
# Cargo-installed tooling via mise's `cargo:` backend.
|
||||
"cargo:cargo-binutils" = "latest" # rust-objcopy, rust-size
|
||||
"cargo:cargo-hex-to-uf2" = "latest" # ELF → UF2 for nRF52 / RP2040 DFU drives
|
||||
"cargo:espup" = "latest" # bootstraps the ESP Xtensa toolchain
|
||||
"cargo:espflash" = "3" # flash + save-image for ESP targets (4.x requires esp_app_desc!() in app — revisit alongside esp-hal 1.1+ / drop-the-patches cleanup)
|
||||
"cargo:espup" = "0.17.1" # ESP Xtensa toolchain installer (pinned: [env] paths below match this version's defaults)
|
||||
"cargo:espflash" = "4" # flash + save-image for ESP targets
|
||||
"cargo:probe-rs-tools" = "latest" # debug-probe flash/run for ARM targets
|
||||
|
||||
# Stable Rust + cross-compile targets. Targets land under $RUSTUP_HOME for
|
||||
@@ -13,7 +13,7 @@ just = "latest"
|
||||
# extra `rustup target add` step at setup time.
|
||||
#
|
||||
# Xtensa ESP targets are NOT listed here — they use the self-contained `esp`
|
||||
# toolchain installed out-of-band by `espup` (see `just setup`). That
|
||||
# toolchain populated by `mise run esp-install` (see [tasks] below). That
|
||||
# toolchain ships its own rust-src for `-Zbuild-std`.
|
||||
#
|
||||
# profile = "minimal" (just rustc, rust-std, cargo), "default" (adds rust-docs, rustfmt, clippy)
|
||||
@@ -22,3 +22,25 @@ just = "latest"
|
||||
version = "1.94"
|
||||
profile = "default"
|
||||
targets = "thumbv6m-none-eabi,thumbv7em-none-eabihf"
|
||||
|
||||
# ESP Xtensa toolchain. `cargo:espup` above only installs the *installer*;
|
||||
# the actual rustc-esp + xtensa-esp-elf-gcc + esp-clang toolchains are
|
||||
# populated by `mise run esp-install` (or `just setup`).
|
||||
#
|
||||
# espup hardcodes its install root to ~/.rustup/toolchains/esp/ — there's
|
||||
# no --prefix or --install-dir flag — so the binaries necessarily live
|
||||
# there. The [env] block below pins the exact PATH and LIBCLANG_PATH so
|
||||
# every `mise exec` (which the justfile uses for `set shell`) resolves
|
||||
# the cross-toolchain without depending on espup's stray ~/export-esp.sh.
|
||||
#
|
||||
# Bumping versions: edit the version dirs in BOTH [tasks.esp-install] (so
|
||||
# espup installs the matching crosstool) AND [env] (so the path resolves).
|
||||
[env]
|
||||
_.path = [
|
||||
"{{env.HOME}}/.rustup/toolchains/esp/xtensa-esp-elf/esp-15.2.0_20250920/xtensa-esp-elf/bin",
|
||||
]
|
||||
LIBCLANG_PATH = "{{env.HOME}}/.rustup/toolchains/esp/xtensa-esp32-elf-clang/esp-20.1.1_20250829/esp-clang/lib"
|
||||
|
||||
[tasks.esp-install]
|
||||
description = "Install the ESP Xtensa toolchain (rustc-esp + GCC + clang) at versions matching [env]"
|
||||
run = "espup install --crosstool-toolchain-version esp-15.2.0_20250920"
|
||||
@@ -70,8 +70,10 @@ impl LoRaBoard for Board {
|
||||
### 5. Add display init
|
||||
|
||||
```rust
|
||||
pub async fn create_display(i2c: DisplayI2c) -> Option<DisplayDriver> {
|
||||
// Construct and initialize your display driver (SSD1306, SH1106, etc.)
|
||||
pub async fn create_display(parts: DisplayParts) -> Option<DisplayDriver> {
|
||||
// Construct and initialize your display driver (SSD1306, SH1106,
|
||||
// ST7789, etc.) using whatever pins/buses you bundled into
|
||||
// DisplayParts in `into_parts()`.
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
@@ -48,8 +48,8 @@ pub struct DisplayParts {
|
||||
|
||||
// ── Display init ────────────────────────────────────────────────────
|
||||
|
||||
pub async fn create_display(i2c: DisplayI2c) -> Option<DisplayDriver> {
|
||||
let mut display = Sh1106::new(i2c, 0x3C);
|
||||
pub async fn create_display(parts: DisplayParts) -> Option<DisplayDriver> {
|
||||
let mut display = Sh1106::new(parts.i2c, 0x3C);
|
||||
|
||||
embassy_time::Timer::after_millis(100).await;
|
||||
if display.init().await.is_err() {
|
||||
|
||||
+2
-2
@@ -47,13 +47,13 @@ pub type DisplayDriver = ssd1306::Ssd1306Async<
|
||||
>;
|
||||
|
||||
/// Construct and initialize an SSD1306 display from raw I²C.
|
||||
pub async fn create_display(i2c: DisplayI2c) -> Option<DisplayDriver> {
|
||||
pub async fn create_display(parts: DisplayParts) -> Option<DisplayDriver> {
|
||||
use ssd1306::mode::DisplayConfigAsync;
|
||||
use ssd1306::prelude::{Brightness, DisplayRotation};
|
||||
use ssd1306::size::DisplaySize128x64;
|
||||
use ssd1306::{I2CDisplayInterface, Ssd1306Async};
|
||||
|
||||
let interface = I2CDisplayInterface::new(i2c);
|
||||
let interface = I2CDisplayInterface::new(parts.i2c);
|
||||
let mut display = Ssd1306Async::new(interface, DisplaySize128x64, DisplayRotation::Rotate0)
|
||||
.into_buffered_graphics_mode();
|
||||
|
||||
|
||||
@@ -63,13 +63,13 @@ pub type DisplayDriver = ssd1306::Ssd1306Async<
|
||||
feature = "heltec_v3_uart",
|
||||
feature = "heltec_v4"
|
||||
))]
|
||||
pub async fn create_display(i2c: DisplayI2c) -> Option<DisplayDriver> {
|
||||
pub async fn create_display(parts: DisplayParts) -> Option<DisplayDriver> {
|
||||
use ssd1306::mode::DisplayConfigAsync;
|
||||
use ssd1306::prelude::{Brightness, DisplayRotation};
|
||||
use ssd1306::size::DisplaySize128x64;
|
||||
use ssd1306::{I2CDisplayInterface, Ssd1306Async};
|
||||
|
||||
let interface = I2CDisplayInterface::new(i2c);
|
||||
let interface = I2CDisplayInterface::new(parts.i2c);
|
||||
let mut display = Ssd1306Async::new(interface, DisplaySize128x64, DisplayRotation::Rotate0)
|
||||
.into_buffered_graphics_mode();
|
||||
|
||||
|
||||
@@ -0,0 +1,285 @@
|
||||
//! Heltec Mesh Node T114 (nRF52840 + SX1262 + 1.14" ST7789 TFT).
|
||||
//!
|
||||
//! Hardware reference: cross-verified across MeshCore, Meshtastic, and
|
||||
//! RNode firmwares. Pin maps quoted from
|
||||
//! `meshcore-spec/references/MeshCore/variants/heltec_t114/variant.h` and
|
||||
//! `meshtastic/firmware/variants/nrf52840/heltec_mesh_node_t114/variant.h`.
|
||||
//!
|
||||
//! Stage 2: panel comes up with static colors via the `St7789Color`
|
||||
//! driver. Stage 3 swaps in the proper color renderer module.
|
||||
|
||||
use embassy_embedded_hal::shared_bus::asynch::spi::SpiDevice;
|
||||
use embassy_nrf::gpio::{Input, Level, Output, OutputDrive, Pull};
|
||||
use embassy_nrf::pwm::{Prescaler, SimpleConfig, SimplePwm};
|
||||
use embassy_nrf::spim::{self, Spim};
|
||||
use embassy_nrf::usb::Driver;
|
||||
use embassy_sync::blocking_mutex::raw::NoopRawMutex;
|
||||
use embassy_time::{Delay, Timer};
|
||||
|
||||
use embedded_hal_bus::spi::ExclusiveDevice;
|
||||
use lcd_async::interface::SpiInterface;
|
||||
use lcd_async::models::ST7789;
|
||||
use lcd_async::options::{ColorInversion, Orientation, Rotation};
|
||||
use lora_phy::iv::GenericSx126xInterfaceVariant;
|
||||
use lora_phy::sx126x::{self, Sx1262, Sx126x};
|
||||
use static_cell::ConstStaticCell;
|
||||
|
||||
use super::traits::{BoardParts, LoRaBoard};
|
||||
use crate::driver::inverted_pin::InvertedPin;
|
||||
use crate::driver::simple_led::SimpleLed;
|
||||
use crate::driver::st7789::{St7789Color, FB_BYTES};
|
||||
use crate::hal::nrf52840 as mcu;
|
||||
|
||||
// ── Concrete peripheral types ───────────────────────────────────────
|
||||
|
||||
type Nss = Output<'static>;
|
||||
type Iv = GenericSx126xInterfaceVariant<Output<'static>, Input<'static>>;
|
||||
type RadioSpiDevice = SpiDevice<'static, NoopRawMutex, mcu::SpiBus, Nss>;
|
||||
pub type RadioDriver = Sx126x<RadioSpiDevice, Iv, Sx1262>;
|
||||
pub type UsbDriver = mcu::UsbNrfDriver;
|
||||
// Green LED on P1.03, active LOW.
|
||||
pub type LedDriver = SimpleLed<InvertedPin<Output<'static>>>;
|
||||
|
||||
// ── TFT bus and driver type aliases ─────────────────────────────────
|
||||
|
||||
type TftSpim = Spim<'static>;
|
||||
type TftSpiDevice = ExclusiveDevice<TftSpim, Output<'static>, Delay>;
|
||||
type TftInterface = SpiInterface<TftSpiDevice, Output<'static>>;
|
||||
pub type DisplayDriver = St7789Color<TftInterface, Output<'static>>;
|
||||
|
||||
// ── Peripheral bundles ──────────────────────────────────────────────
|
||||
|
||||
pub struct RadioParts {
|
||||
pub driver: RadioDriver,
|
||||
pub delay: Delay,
|
||||
}
|
||||
|
||||
pub struct UsbParts {
|
||||
pub driver: UsbDriver,
|
||||
}
|
||||
|
||||
/// Assembled-but-not-yet-initialized TFT plumbing. Real initialization
|
||||
/// (power gate, RST cycle, mipidsi init) happens in `create_display`.
|
||||
pub struct DisplayParts {
|
||||
interface: TftInterface,
|
||||
rst_pin: Output<'static>,
|
||||
vdd_en: Output<'static>,
|
||||
backlight: SimplePwm<'static>,
|
||||
}
|
||||
|
||||
// ── Display init ────────────────────────────────────────────────────
|
||||
|
||||
pub async fn create_display(parts: DisplayParts) -> Option<DisplayDriver> {
|
||||
let DisplayParts {
|
||||
interface,
|
||||
rst_pin,
|
||||
mut vdd_en,
|
||||
mut backlight,
|
||||
} = parts;
|
||||
|
||||
// 1. Drive TFT VDD enable LOW (active LOW per Meshtastic) and let
|
||||
// the rail settle. Heltec's schematic has the panel power gated
|
||||
// by P0.03 — touching SPI before VDD is stable produces snow.
|
||||
vdd_en.set_low();
|
||||
Timer::after_millis(50).await;
|
||||
|
||||
// 2. Build the lcd-async display.
|
||||
// The T114 panel is wired natively portrait (135w × 240h) at
|
||||
// chip framebuffer offset (52, 40) — the standard 1.14" 135×240
|
||||
// ST7789 die mapping. Deg90 rotation rotates the user-facing
|
||||
// surface to landscape (240w × 135h). Meshtastic's variant.h
|
||||
// sets TFT_OFFSET_X=0/Y=0 because their fork of TFT_eSPI applies
|
||||
// these offsets internally; lcd-async expects them in display_offset.
|
||||
// If the image comes up mirrored or upside-down, swap
|
||||
// Deg90 ↔ Deg270 or set `mirrored = true`.
|
||||
let mut delay = Delay;
|
||||
let panel = match lcd_async::Builder::new(ST7789, interface)
|
||||
.reset_pin(rst_pin)
|
||||
.display_size(135, 240)
|
||||
.display_offset(52, 40)
|
||||
.orientation(Orientation::new().rotate(Rotation::Deg90))
|
||||
.invert_colors(ColorInversion::Inverted)
|
||||
.init(&mut delay)
|
||||
.await
|
||||
{
|
||||
Ok(p) => p,
|
||||
Err(_) => {
|
||||
defmt::error!("ST7789 init failed");
|
||||
return None;
|
||||
}
|
||||
};
|
||||
defmt::info!("ST7789 init OK");
|
||||
|
||||
// 3. Bring the backlight up at full brightness. Stage 2 uses the
|
||||
// full-on duty; the splash dimming logic handled by the display
|
||||
// statechart will hand off to set_dim/set_bright shortly.
|
||||
backlight.set_duty(0, embassy_nrf::pwm::DutyCycle::inverted(0));
|
||||
|
||||
// Forget vdd_en so it stays driven for the lifetime of the program.
|
||||
// We took it by move so we could call set_low() during init.
|
||||
core::mem::forget(vdd_en);
|
||||
|
||||
// Take the static framebuffer (240×135 × 2 bytes Rgb565 BE
|
||||
// = 64,800 bytes zero-initialized in .bss). Stored as raw bytes
|
||||
// so present() can DMA-blit the slice straight to the panel
|
||||
// without an iterator-conversion pass — the lcd-async
|
||||
// `RawFrameBuf<Rgb565, _>` wrapper handles BE byte packing on
|
||||
// each pixel write.
|
||||
static FRAMEBUFFER: ConstStaticCell<[u8; FB_BYTES]> =
|
||||
ConstStaticCell::new([0u8; FB_BYTES]);
|
||||
let fb = FRAMEBUFFER.take();
|
||||
|
||||
Some(St7789Color::new(panel, backlight, fb))
|
||||
}
|
||||
|
||||
// ── Board init ──────────────────────────────────────────────────────
|
||||
|
||||
pub struct Board {
|
||||
p: embassy_nrf::Peripherals,
|
||||
}
|
||||
|
||||
impl LoRaBoard for Board {
|
||||
const NAME: &'static str = "Heltec Mesh Node T114";
|
||||
const TX_POWER_RANGE: (i8, i8) = (-9, 22);
|
||||
|
||||
type RadioParts = RadioParts;
|
||||
type CommParts = UsbParts;
|
||||
type DisplayParts = DisplayParts;
|
||||
type DisplayDriver = DisplayDriver;
|
||||
type LedDriver = LedDriver;
|
||||
|
||||
fn init() -> Self {
|
||||
// USB requires the 32 MHz HFXO (±50 ppm); the RC oscillator is out of spec.
|
||||
let mut config = embassy_nrf::config::Config::default();
|
||||
config.hfclk_source = embassy_nrf::config::HfclkSource::ExternalXtal;
|
||||
config.lfclk_source = embassy_nrf::config::LfclkSource::ExternalXtal;
|
||||
let p = embassy_nrf::init(config);
|
||||
Self { p }
|
||||
}
|
||||
|
||||
fn mac_address() -> [u8; 6] {
|
||||
mcu::mac_address()
|
||||
}
|
||||
|
||||
fn into_parts(self) -> BoardParts<RadioParts, UsbParts, DisplayParts, LedDriver> {
|
||||
let p = self.p;
|
||||
|
||||
// ── VEXT_ENABLE (P0.21, active HIGH) ────────────────────
|
||||
// Powers the SX1262 *and* the TFT/peripheral 3V3 rail. Must be
|
||||
// driven HIGH before any SPI traffic to the radio or panel —
|
||||
// without this, lora-phy init can succeed (SPI bus works
|
||||
// electrically) but TX_DONE never fires because the PA is
|
||||
// unpowered. Held forever via core::mem::forget. Mirrors
|
||||
// Meshtastic's `VEXT_ENABLE = P0.21, VEXT_ON_VALUE = HIGH`.
|
||||
let vext_en = Output::new(p.P0_21, Level::High, OutputDrive::Standard);
|
||||
core::mem::forget(vext_en);
|
||||
|
||||
// ── SPI3 bus for SX1262 ─────────────────────────────────
|
||||
// Per Heltec T114 schematic + Meshtastic variant.h:
|
||||
// SCK=P0.19, MOSI=P0.22, MISO=P0.23
|
||||
// NSS=P0.24, RST=P0.25, BUSY=P0.17, DIO1=P0.20
|
||||
// No external RXEN/TXEN — antenna switch is driven internally
|
||||
// by SX1262 DIO2. TCXO is on DIO3 at 1.8 V.
|
||||
let mut spi_cfg = spim::Config::default();
|
||||
spi_cfg.frequency = spim::Frequency::M1;
|
||||
let spi = Spim::new(p.SPI3, mcu::Irqs, p.P0_19, p.P0_23, p.P0_22, spi_cfg);
|
||||
let spi_bus = mcu::share_spi_bus(spi);
|
||||
|
||||
let nss = Output::new(p.P0_24, Level::High, OutputDrive::Standard);
|
||||
let spi_device = SpiDevice::new(spi_bus, nss);
|
||||
|
||||
let reset = Output::new(p.P0_25, Level::High, OutputDrive::Standard);
|
||||
let dio1 = Input::new(p.P0_20, Pull::Down);
|
||||
// BUSY has an internal 20 kΩ pull-up on the SX1262 die (datasheet
|
||||
// p. 53); an MCU-side Pull fights it. See lora-rs/lora-rs#260.
|
||||
let busy = Input::new(p.P0_17, Pull::None);
|
||||
|
||||
let iv = GenericSx126xInterfaceVariant::new(reset, dio1, busy, None, None)
|
||||
.expect("SX1262 interface init");
|
||||
|
||||
let sx_config = sx126x::Config {
|
||||
chip: Sx1262,
|
||||
tcxo_ctrl: Some(sx126x::TcxoCtrlVoltage::Ctrl1V8),
|
||||
use_dcdc: true,
|
||||
rx_boost: false,
|
||||
};
|
||||
|
||||
let radio = RadioParts {
|
||||
driver: Sx126x::new(spi_device, iv, sx_config),
|
||||
delay: Delay,
|
||||
};
|
||||
|
||||
// ── USB ─────────────────────────────────────────────────
|
||||
let vbus = mcu::alloc_vbus_detect(true, true);
|
||||
let host = UsbParts {
|
||||
driver: Driver::new(p.USBD, mcu::Irqs, vbus),
|
||||
};
|
||||
|
||||
// Battery divider control (P0.06) LOW disables the divider —
|
||||
// saves a few tens of µA. Battery sense (P0.04 / AIN2) deferred
|
||||
// until the protocol gets a battery field.
|
||||
let bat_ctl = Output::new(p.P0_06, Level::Low, OutputDrive::Standard);
|
||||
core::mem::forget(bat_ctl);
|
||||
|
||||
// ── SPI2 bus for ST7789 TFT ─────────────────────────────
|
||||
// Per Meshtastic variant.h:
|
||||
// SCK=P1.08, MOSI=P1.09, CS=P0.11, DC=P0.12, RST=P0.02
|
||||
// VDD enable=P0.03 (active HIGH), Backlight=P0.15 (active LOW PWM)
|
||||
// Write-only bus — embassy_nrf::Spim still requires a MISO slot,
|
||||
// so we wire a free unused pin (P1.06) as a no-op input.
|
||||
let mut tft_spi_cfg = spim::Config::default();
|
||||
// 8 MHz lets us repaint the 240×135 panel comfortably. Higher
|
||||
// rates work with mipidsi but are platform-dependent (board
|
||||
// routing, pull strengths). 8 MHz is the safe default.
|
||||
tft_spi_cfg.frequency = spim::Frequency::M8;
|
||||
let tft_spi = Spim::new(p.SPI2, mcu::Irqs, p.P1_08, p.P1_06, p.P1_09, tft_spi_cfg);
|
||||
|
||||
let tft_cs = Output::new(p.P0_11, Level::High, OutputDrive::Standard);
|
||||
let tft_dc = Output::new(p.P0_12, Level::Low, OutputDrive::Standard);
|
||||
let tft_rst = Output::new(p.P0_02, Level::High, OutputDrive::Standard);
|
||||
// VTFT_CTRL is active LOW per Meshtastic
|
||||
// (`#define TFT_BACKLIGHT_ON LOW`, `digitalWrite(VTFT_CTRL, LOW)`
|
||||
// at boot). Start HIGH (panel rail off); create_display drives
|
||||
// LOW to enable the panel.
|
||||
let tft_vdd = Output::new(p.P0_03, Level::High, OutputDrive::Standard);
|
||||
|
||||
let tft_spi_device = ExclusiveDevice::new(tft_spi, tft_cs, Delay)
|
||||
.expect("TFT ExclusiveDevice init");
|
||||
|
||||
// lcd-async's SpiInterface streams pixel data directly from
|
||||
// the caller's slice — no scratch buffer required (mipidsi's
|
||||
// sync version needed one for batching).
|
||||
let tft_interface = SpiInterface::new(tft_spi_device, tft_dc);
|
||||
|
||||
// ── PWM backlight (active-LOW on P0.15) ─────────────────
|
||||
// Default SimpleConfig is fine — countertop=1000 → ~1 kHz
|
||||
// (well above flicker threshold), prescaler default. Channel 0
|
||||
// idle level matches the active-LOW wiring.
|
||||
let mut bl_cfg = SimpleConfig::default();
|
||||
bl_cfg.max_duty = crate::driver::st7789::BL_MAX;
|
||||
bl_cfg.prescaler = Prescaler::Div128;
|
||||
bl_cfg.ch0_idle_level = Level::High;
|
||||
let backlight = SimplePwm::new_1ch(p.PWM0, p.P0_15, &bl_cfg);
|
||||
|
||||
let display_parts = DisplayParts {
|
||||
interface: tft_interface,
|
||||
rst_pin: tft_rst,
|
||||
vdd_en: tft_vdd,
|
||||
backlight,
|
||||
};
|
||||
|
||||
// ── Green LED (P1.03, active LOW) ───────────────────────
|
||||
// Idle level HIGH = LED off. InvertedPin flips polarity so
|
||||
// SimpleLed can use normal "set_high = on" semantics.
|
||||
let led_pin = Output::new(p.P1_03, Level::High, OutputDrive::Standard);
|
||||
let led = SimpleLed(InvertedPin(led_pin));
|
||||
|
||||
BoardParts {
|
||||
radio,
|
||||
host,
|
||||
display: Some(display_parts),
|
||||
led,
|
||||
mac: Self::mac_address(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -50,8 +50,8 @@ pub struct DisplayParts {
|
||||
|
||||
// ── Display init ────────────────────────────────────────────────────
|
||||
|
||||
pub async fn create_display(i2c: DisplayI2c) -> Option<DisplayDriver> {
|
||||
let mut display = Sh1106::new(i2c, 0x3C);
|
||||
pub async fn create_display(parts: DisplayParts) -> Option<DisplayDriver> {
|
||||
let mut display = Sh1106::new(parts.i2c, 0x3C);
|
||||
|
||||
embassy_time::Timer::after_millis(100).await;
|
||||
if display.init().await.is_err() {
|
||||
|
||||
@@ -22,11 +22,18 @@ pub type UsbDriver = mcu::UsbNrfDriver;
|
||||
pub type DisplayI2c = mcu::I2cBus;
|
||||
pub type LedDriver = crate::driver::simple_led::SimpleLed<Output<'static>>;
|
||||
|
||||
pub type DisplayDriver = ssd1306::Ssd1306Async<
|
||||
ssd1306::prelude::I2CInterface<DisplayI2c>,
|
||||
ssd1306::size::DisplaySize128x64,
|
||||
ssd1306::mode::BufferedGraphicsModeAsync<ssd1306::size::DisplaySize128x64>,
|
||||
>;
|
||||
// SH1106 hand-rolled driver instead of the ssd1306 crate. The RAK1921
|
||||
// OLED ships under various Chinese-OEM packaging (e.g. JMD0.96C) that
|
||||
// can be either SSD1306 or SH1106 silicon — physically and pin
|
||||
// identical, but use different addressing modes. The bigger reason
|
||||
// though: ssd1306 0.10's display-interface-i2c emits I2C transaction
|
||||
// patterns (consecutive writes, 3+ ops) that embassy_nrf::twim's
|
||||
// `unreachable!()` rejects, so even on real SSD1306 silicon the panic
|
||||
// would fire. Our hand-rolled SH1106 driver issues only single writes
|
||||
// per logical op and is twim-safe. SH1106 init opcodes are similar
|
||||
// enough that real SSD1306 silicon mostly works (worst case: mirrored
|
||||
// display until proper runtime detection lands).
|
||||
pub type DisplayDriver = crate::driver::sh1106::Sh1106<DisplayI2c>;
|
||||
|
||||
// ── Peripheral bundles ──────────────────────────────────────────────
|
||||
|
||||
@@ -45,23 +52,30 @@ pub struct DisplayParts {
|
||||
|
||||
// ── Display init ────────────────────────────────────────────────────
|
||||
|
||||
pub async fn create_display(i2c: DisplayI2c) -> Option<DisplayDriver> {
|
||||
use ssd1306::mode::DisplayConfigAsync;
|
||||
use ssd1306::prelude::{Brightness, DisplayRotation};
|
||||
use ssd1306::size::DisplaySize128x64;
|
||||
use ssd1306::{I2CDisplayInterface, Ssd1306Async};
|
||||
pub async fn create_display(parts: DisplayParts) -> Option<DisplayDriver> {
|
||||
let mut display = crate::driver::sh1106::Sh1106::new(parts.i2c, 0x3C);
|
||||
|
||||
let interface = I2CDisplayInterface::new(i2c);
|
||||
let mut display = Ssd1306Async::new(interface, DisplaySize128x64, DisplayRotation::Rotate0)
|
||||
.into_buffered_graphics_mode();
|
||||
|
||||
embassy_time::Timer::after_millis(100).await;
|
||||
if display.init().await.is_err() {
|
||||
defmt::error!("SSD1306 display init failed");
|
||||
return None;
|
||||
// Retry 3× with 100ms backoff. The SH1106's first transaction
|
||||
// after power-on can return Err if the chip's internal voltage
|
||||
// converter hasn't fully settled — observed empirically on
|
||||
// RAK4631/RAK1921. Each attempt is independent (full init
|
||||
// sequence). Display works visibly even if the FIRST attempt
|
||||
// failed, because subsequent attempts succeed.
|
||||
for attempt in 0..3u8 {
|
||||
embassy_time::Timer::after_millis(100).await;
|
||||
if display.init().await.is_ok() {
|
||||
let _ = display.set_brightness(0xFF).await;
|
||||
return Some(display);
|
||||
}
|
||||
if attempt < 2 {
|
||||
defmt::warn!(
|
||||
"SH1106 display init attempt {=u8}/3 failed; retrying",
|
||||
attempt + 1
|
||||
);
|
||||
}
|
||||
}
|
||||
let _ = display.set_brightness(Brightness::BRIGHTEST).await;
|
||||
Some(display)
|
||||
defmt::error!("SH1106 display init failed after 3 attempts");
|
||||
None
|
||||
}
|
||||
|
||||
// ── Board init ──────────────────────────────────────────────────────
|
||||
|
||||
+5
-7
@@ -6,10 +6,7 @@
|
||||
//!
|
||||
//! See `src/board/PORTING.md` for a step-by-step guide to adding a new board.
|
||||
|
||||
use embedded_graphics::draw_target::DrawTarget;
|
||||
use embedded_graphics::pixelcolor::BinaryColor;
|
||||
|
||||
use crate::driver::DisplayBrightness;
|
||||
use crate::display::BoardDisplay;
|
||||
|
||||
/// Async RGB LED control. Boards without an LED use `()` which is a no-op.
|
||||
pub trait RgbLed {
|
||||
@@ -37,9 +34,10 @@ pub trait LoRaBoard: Sized {
|
||||
/// Display peripheral bundle (I2C bus for display init).
|
||||
type DisplayParts;
|
||||
|
||||
/// Concrete display driver type (must implement DrawTarget for rendering
|
||||
/// and DisplayBrightness for the two-level dim control).
|
||||
type DisplayDriver: DrawTarget<Color = BinaryColor> + DisplayBrightness;
|
||||
/// Concrete display driver type. Each board's driver implements
|
||||
/// [`BoardDisplay`] to render the splash + dashboard content into its
|
||||
/// native pixel format and resolution and to flush / dim the panel.
|
||||
type DisplayDriver: BoardDisplay;
|
||||
|
||||
/// RGB LED driver. Boards without an LED use `()`.
|
||||
type LedDriver: RgbLed;
|
||||
|
||||
@@ -41,12 +41,16 @@ pub struct UsbParts {
|
||||
}
|
||||
|
||||
pub struct DisplayParts {
|
||||
// No display on this board, but the trait still wants concrete
|
||||
// peripheral types. The i2c bus type is reserved for the future
|
||||
// possibility of an external OLED add-on.
|
||||
#[allow(dead_code)]
|
||||
pub i2c: DisplayI2c,
|
||||
}
|
||||
|
||||
// ── Display init ───────────────────────────────────────────────────
|
||||
|
||||
pub async fn create_display(_i2c: DisplayI2c) -> Option<DisplayDriver> {
|
||||
pub async fn create_display(_parts: DisplayParts) -> Option<DisplayDriver> {
|
||||
None
|
||||
}
|
||||
|
||||
|
||||
@@ -42,8 +42,8 @@ pub struct DisplayParts {
|
||||
|
||||
// ── Display init ────────────────────────────────────────────────────
|
||||
|
||||
pub async fn create_display(i2c: DisplayI2c) -> Option<DisplayDriver> {
|
||||
let mut display = crate::driver::sh1106::Sh1106::new(i2c, 0x3D);
|
||||
pub async fn create_display(parts: DisplayParts) -> Option<DisplayDriver> {
|
||||
let mut display = crate::driver::sh1106::Sh1106::new(parts.i2c, 0x3D);
|
||||
|
||||
embassy_time::Timer::after_millis(100).await;
|
||||
if display.init().await.is_err() {
|
||||
|
||||
+67
-3
@@ -9,9 +9,66 @@
|
||||
|
||||
use embassy_sync::blocking_mutex::raw::CriticalSectionRawMutex;
|
||||
use embassy_sync::channel::Channel;
|
||||
use portable_atomic::AtomicBool;
|
||||
|
||||
use crate::protocol::{Command, DeviceMessage, LoRaConfig};
|
||||
|
||||
/// True while a Reset has been queued in `CommandChannel` but not yet
|
||||
/// consumed by the radio task. Dedupes injections from any host chart
|
||||
/// (USB DTR-driven OR UART inactivity-driven) that fires `reset_radio`
|
||||
/// repeatedly during quiet/inactive periods. Without this dedup, a slow
|
||||
/// or wedged radio causes the host chart's exit action to inject Reset
|
||||
/// every cycle, filling the commands channel and producing a "queue
|
||||
/// full" warn flood. Cleared by the radio chart's Reset handler
|
||||
/// (`reset_to_unconfigured`).
|
||||
///
|
||||
/// Uses `portable_atomic::AtomicBool` (not `core::sync::atomic`) because
|
||||
/// Cortex-M0+ targets (RP2040) lack native CAS for `swap`. portable-atomic
|
||||
/// transparently uses native atomics on M3+/M4 and critical-section
|
||||
/// emulation on M0/M0+.
|
||||
pub static RESET_PENDING: AtomicBool = AtomicBool::new(false);
|
||||
|
||||
/// True while the host transport's USB IN endpoint is observed-dead
|
||||
/// (write_packet returned Err). Set by `host_task::protocol_loop` on the
|
||||
/// first failed write; cleared on the next successful write. Producers of
|
||||
/// **async** D→H events — `radio_task::record_packet` (RX) and
|
||||
/// `host::framing::emit_async_err` — check this flag before pushing into
|
||||
/// `OutboundChannel` and silently skip when set, so the queue does not
|
||||
/// fill while the host is gone.
|
||||
///
|
||||
/// Tag-correlated responses (OK / sync ERR / TX_DONE) are NOT gated by
|
||||
/// this flag: they're a small, bounded volume and the protocol contract
|
||||
/// requires them to be delivered if we ever reach the host again.
|
||||
///
|
||||
/// Same `portable_atomic::AtomicBool` rationale as `RESET_PENDING`.
|
||||
///
|
||||
/// **Initial value `true`**: at boot we have no proof the host can
|
||||
/// drain the outbound queue. Until host_task observes its first
|
||||
/// successful `write_packet` (and clears this), async-RX producers
|
||||
/// silently skip pushing. Otherwise — at boot, with no client
|
||||
/// attached or with mux-rs idle — RX events would fill the queue
|
||||
/// faster than host_task could drain it (which it can't: `write_packet`
|
||||
/// blocks until the host actually polls), and the depth-64 ring would
|
||||
/// flood `[WARN] RX dropped: outbound queue full` hundreds of times
|
||||
/// per second. Pre-throttle is the structural fix.
|
||||
pub static RADIO_THROTTLED: AtomicBool = AtomicBool::new(true);
|
||||
|
||||
/// Set by `radio::lora_setup` / `lora_setup_with` when a SHORT-DURATION
|
||||
/// lora-phy op blew through its wall-clock budget (almost always BUSY
|
||||
/// stuck high — chip is wedged). Read by chart actions and `during:`
|
||||
/// activities that called the helper: when they observe a downstream
|
||||
/// `Err(_)` AND see this flag set, they emit `WedgeDetected` (which
|
||||
/// drives the radio chart into `Recovering` for an in-place NRESET +
|
||||
/// `lora.init()` retry) instead of the per-action failure event. The
|
||||
/// flag is consumed (`swap(false)`) by whoever reads it so the next
|
||||
/// independent op gets a fresh signal.
|
||||
///
|
||||
/// This is the cleanest plumbing path for "wedge timeout vs ordinary
|
||||
/// driver error" given the helper sits below the chart-action layer
|
||||
/// (free function, no chart-emit access). One atomic, one concern,
|
||||
/// matching the established `RESET_PENDING` / `RADIO_THROTTLED` pattern.
|
||||
pub static LORA_WEDGED: AtomicBool = AtomicBool::new(false);
|
||||
|
||||
// ── Host → Radio: tag-correlated commands ───────────────────────────
|
||||
|
||||
/// An event flowing from the host task into the radio task.
|
||||
@@ -42,9 +99,16 @@ pub struct OutboundFrame {
|
||||
pub msg: DeviceMessage,
|
||||
}
|
||||
|
||||
/// Radio-to-host outbound channel (depth 32: enough to hold a burst of
|
||||
/// RX events + queued command responses without stalling the radio ISR).
|
||||
pub type OutboundChannel = Channel<CriticalSectionRawMutex, OutboundFrame, 32>;
|
||||
/// Radio-to-host outbound channel.
|
||||
///
|
||||
/// Depth 64: absorbs bursts of RX events plus queued command responses
|
||||
/// plus async ERRs while the host transport drains. Each slot is ~280 B
|
||||
/// (max RxPayload + envelope), so 64 slots is ~18 KB — well within RAM
|
||||
/// budget on every supported MCU. The previous depth-32 was provoking
|
||||
/// "outbound queue full" floods during host stalls; the larger queue
|
||||
/// plus `RADIO_THROTTLED` backpressure flag (set by host_task on a
|
||||
/// failed write_packet) together stop the cascade.
|
||||
pub type OutboundChannel = Channel<CriticalSectionRawMutex, OutboundFrame, 64>;
|
||||
|
||||
// ── Internal: display commands (no longer wire-visible) ────────────
|
||||
|
||||
|
||||
+275
-111
@@ -38,18 +38,79 @@ use hsmc::statechart;
|
||||
|
||||
use crate::board::{self, Board, DisplayDriver, DisplayParts, LedDriver, LoRaBoard, RgbLed};
|
||||
use crate::channel::{DisplayCommand, DisplayCommandChannel, RadioEvent, RadioEventChannel};
|
||||
use crate::driver::DisplayBrightness;
|
||||
use crate::protocol::LoRaConfig;
|
||||
|
||||
use render::{BoardInfo, RSSI_HISTORY_LEN};
|
||||
|
||||
const BOARD_NAME: &str = Board::NAME;
|
||||
const VERSION: &str = env!("CARGO_PKG_VERSION");
|
||||
|
||||
/// Number of slots in the dashboard's RSSI/TX sparkline ring buffer.
|
||||
pub const RSSI_HISTORY_LEN: usize = 128;
|
||||
|
||||
// Mono splash board-name font selection. The mono OLEDs we drive are
|
||||
// 128 px wide; the primary font (FONT_6X10, 6 px advance) fits up to 21
|
||||
// ASCII chars across, the fallback (FONT_5X8, 5 px advance) fits up to
|
||||
// 25. If even the fallback can't fit on a mono OLED, fail at compile
|
||||
// time so the board porter shortens NAME rather than silently
|
||||
// overflowing the splash. Color renderers have their own size budget
|
||||
// and simply use the same NAME with their own font choice.
|
||||
const SPLASH_SCREEN_W_PX: u32 = 128;
|
||||
const SPLASH_FONT_PRIMARY_W_PX: u32 = 6;
|
||||
const SPLASH_FONT_FALLBACK_W_PX: u32 = 5;
|
||||
pub(crate) const BOARD_NAME_FITS_PRIMARY_FONT: bool =
|
||||
(BOARD_NAME.len() as u32) * SPLASH_FONT_PRIMARY_W_PX <= SPLASH_SCREEN_W_PX;
|
||||
const _: () = assert!(
|
||||
(BOARD_NAME.len() as u32) * SPLASH_FONT_FALLBACK_W_PX <= SPLASH_SCREEN_W_PX,
|
||||
"Board::NAME is too long for the 128 px mono splash screen even with the FONT_5X8 fallback \
|
||||
(max 25 ASCII chars). Shorten the NAME constant in src/board/<board>.rs.",
|
||||
);
|
||||
|
||||
/// Sentinel: no packet received in this slot. Below SX1262 sensitivity
|
||||
/// floor (-120 dBm), so it cannot be confused with a real RSSI value.
|
||||
const NO_SIGNAL: i16 = -121;
|
||||
|
||||
/// Hand-authored 64 × 64 pixel-perfect QR bitmap for the splash
|
||||
/// "learn more" screen. 1 bit/pixel, MSB-first, row-major. Bit `1` is
|
||||
/// "light" (OLED pixel on), bit `0` is "dark" (OLED pixel off), so when
|
||||
/// drawn with `BinaryColor::On` = lit, the QR appears with the
|
||||
/// traditional dark-on-light look including a proper quiet zone. The
|
||||
/// color renderer remaps bit 1 → white, bit 0 → black for the same
|
||||
/// visual contrast on a black-background TFT.
|
||||
pub(crate) const QR_WIDTH_PX: u32 = 64;
|
||||
pub(crate) const QR_BITMAP: [u8; 512] = [
|
||||
0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF,
|
||||
0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF,
|
||||
0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF,
|
||||
0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFE, 0x00, 0x07, 0x9F, 0xFE, 0x60, 0x00, 0x7F, 0xFE,
|
||||
0x00, 0x07, 0x9F, 0xFE, 0x60, 0x00, 0x7F, 0xFE, 0x7F, 0xE6, 0x18, 0x19, 0xE7, 0xFE, 0x7F, 0xFE,
|
||||
0x7F, 0xE6, 0x18, 0x19, 0xE7, 0xFE, 0x7F, 0xFE, 0x60, 0x67, 0x81, 0xE6, 0x66, 0x06, 0x7F, 0xFE,
|
||||
0x60, 0x67, 0x81, 0xE6, 0x66, 0x06, 0x7F, 0xFE, 0x60, 0x66, 0x19, 0x87, 0xE6, 0x06, 0x7F, 0xFE,
|
||||
0x60, 0x66, 0x19, 0x87, 0xE6, 0x06, 0x7F, 0xFE, 0x60, 0x67, 0xE6, 0x1F, 0xE6, 0x06, 0x7F, 0xFE,
|
||||
0x60, 0x67, 0xE6, 0x1F, 0xE6, 0x06, 0x7F, 0xFE, 0x7F, 0xE6, 0x67, 0x80, 0x67, 0xFE, 0x7F, 0xFE,
|
||||
0x7F, 0xE6, 0x67, 0x80, 0x67, 0xFE, 0x7F, 0xFE, 0x00, 0x06, 0x66, 0x66, 0x60, 0x00, 0x7F, 0xFE,
|
||||
0x00, 0x06, 0x66, 0x66, 0x60, 0x00, 0x7F, 0xFF, 0xFF, 0xFF, 0xFE, 0x61, 0xFF, 0xFF, 0xFF, 0xFF,
|
||||
0xFF, 0xFF, 0xFE, 0x61, 0xFF, 0xFF, 0xFF, 0xFE, 0x00, 0x60, 0x06, 0x01, 0x99, 0x99, 0xFF, 0xFE,
|
||||
0x00, 0x60, 0x06, 0x01, 0x99, 0x99, 0xFF, 0xFE, 0x01, 0x98, 0x1F, 0x9E, 0x79, 0xF9, 0xFF, 0xFE,
|
||||
0x01, 0x98, 0x1F, 0x9E, 0x79, 0xF9, 0xFF, 0xFF, 0xE0, 0x66, 0x18, 0x61, 0x9F, 0x98, 0x7F, 0xFF,
|
||||
0xE0, 0x66, 0x18, 0x61, 0x9F, 0x98, 0x7F, 0xFF, 0x98, 0x1E, 0x01, 0xE7, 0x99, 0xFE, 0x7F, 0xFF,
|
||||
0x98, 0x1E, 0x01, 0xE7, 0x99, 0xFE, 0x7F, 0xFF, 0xE1, 0xE6, 0x19, 0xF8, 0x06, 0x60, 0x7F, 0xFF,
|
||||
0xE1, 0xE6, 0x19, 0xF8, 0x06, 0x60, 0x7F, 0xFE, 0x19, 0xF8, 0x06, 0x1E, 0x79, 0x99, 0xFF, 0xFE,
|
||||
0x19, 0xF8, 0x06, 0x1E, 0x79, 0x99, 0xFF, 0xFE, 0x7E, 0x01, 0xFF, 0x80, 0x18, 0x18, 0x7F, 0xFE,
|
||||
0x7E, 0x01, 0xFF, 0x80, 0x18, 0x18, 0x7F, 0xFE, 0x7E, 0x19, 0x9E, 0x1E, 0x78, 0x7E, 0x7F, 0xFE,
|
||||
0x7E, 0x19, 0x9E, 0x1E, 0x78, 0x7E, 0x7F, 0xFE, 0x60, 0x06, 0x66, 0x66, 0x00, 0x67, 0xFF, 0xFE,
|
||||
0x60, 0x06, 0x66, 0x66, 0x00, 0x67, 0xFF, 0xFF, 0xFF, 0xFE, 0x67, 0xF8, 0x7E, 0x1F, 0xFF, 0xFF,
|
||||
0xFF, 0xFE, 0x67, 0xF8, 0x7E, 0x1F, 0xFF, 0xFE, 0x00, 0x06, 0x00, 0x7E, 0x66, 0x60, 0x7F, 0xFE,
|
||||
0x00, 0x06, 0x00, 0x7E, 0x66, 0x60, 0x7F, 0xFE, 0x7F, 0xE7, 0x99, 0xE6, 0x7E, 0x1E, 0x7F, 0xFE,
|
||||
0x7F, 0xE7, 0x99, 0xE6, 0x7E, 0x1E, 0x7F, 0xFE, 0x60, 0x66, 0x61, 0xF8, 0x00, 0x60, 0x7F, 0xFE,
|
||||
0x60, 0x66, 0x61, 0xF8, 0x00, 0x60, 0x7F, 0xFE, 0x60, 0x66, 0x06, 0x01, 0x86, 0x00, 0x7F, 0xFE,
|
||||
0x60, 0x66, 0x06, 0x01, 0x86, 0x00, 0x7F, 0xFE, 0x60, 0x66, 0x67, 0x86, 0x7F, 0x86, 0x7F, 0xFE,
|
||||
0x60, 0x66, 0x67, 0x86, 0x7F, 0x86, 0x7F, 0xFE, 0x7F, 0xE6, 0x66, 0x18, 0x00, 0x1E, 0x7F, 0xFE,
|
||||
0x7F, 0xE6, 0x66, 0x18, 0x00, 0x1E, 0x7F, 0xFE, 0x00, 0x06, 0x66, 0x67, 0x98, 0x00, 0x7F, 0xFE,
|
||||
0x00, 0x06, 0x66, 0x67, 0x98, 0x00, 0x7F, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF,
|
||||
0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF,
|
||||
0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF,
|
||||
0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF,
|
||||
];
|
||||
|
||||
/// Which dashboard badge is in view (drives the RX/TX indicator only).
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub enum Badge {
|
||||
@@ -57,6 +118,136 @@ pub enum Badge {
|
||||
Tx,
|
||||
}
|
||||
|
||||
// ── Display content types (shared across all renderers) ───────────────
|
||||
//
|
||||
// `BoardInfo` and `DashboardCtx` describe *what* to draw on the splash
|
||||
// and dashboard screens. Each board's `BoardDisplay` impl decides *how*
|
||||
// to render that content for its native pixel format and resolution.
|
||||
|
||||
/// Static-ish board identity rendered on the splash + dashboard.
|
||||
pub struct BoardInfo<'a> {
|
||||
pub name: &'a str,
|
||||
pub version: &'a str,
|
||||
pub mac: &'a str,
|
||||
}
|
||||
|
||||
/// Live dashboard data at the moment of a render call.
|
||||
pub struct DashboardCtx<'a> {
|
||||
pub badge: Badge,
|
||||
pub config: Option<LoRaConfig>,
|
||||
pub rx_count: u32,
|
||||
pub tx_count: u32,
|
||||
pub last_rssi: Option<i16>,
|
||||
pub last_snr: Option<i16>,
|
||||
pub rssi_history: &'a [i16; RSSI_HISTORY_LEN],
|
||||
pub tx_history: &'a [bool; RSSI_HISTORY_LEN],
|
||||
pub rssi_count: usize,
|
||||
pub current_slot_rssi: i16,
|
||||
pub current_slot_tx: bool,
|
||||
pub board: &'a BoardInfo<'a>,
|
||||
}
|
||||
|
||||
// ── BoardDisplay trait ────────────────────────────────────────────────
|
||||
//
|
||||
// One logical display surface, four content states (splash×2, dashboard,
|
||||
// blank), plus brightness control and a flush hook. Each board's display
|
||||
// driver implements this directly — color depth, resolution, dimming
|
||||
// strategy, and any framebuffer flushing live behind the trait.
|
||||
|
||||
/// Single trait every board's display driver implements. The display
|
||||
/// task talks to the trait; the per-driver impl handles native format.
|
||||
///
|
||||
/// `present()` is named distinctly from any inherent `flush()` method on
|
||||
/// concrete display types so method resolution doesn't get ambiguous.
|
||||
pub trait BoardDisplay {
|
||||
fn present(&mut self) -> impl core::future::Future<Output = ()>;
|
||||
fn set_bright(&mut self) -> impl core::future::Future<Output = ()>;
|
||||
fn set_dim(&mut self) -> impl core::future::Future<Output = ()>;
|
||||
|
||||
fn render_splash_learn_more(&mut self);
|
||||
fn render_splash_info(&mut self, info: &BoardInfo<'_>);
|
||||
fn render_dashboard(&mut self, ctx: &DashboardCtx<'_>);
|
||||
fn render_blank(&mut self);
|
||||
}
|
||||
|
||||
// Color renderer module — used by `crate::driver::st7789::St7789Color` on
|
||||
// the Heltec T114 to draw native 240×135 Rgb565 dashboards/splash screens.
|
||||
// Gated to the T114 feature so other boards don't pay for the imports.
|
||||
#[cfg(feature = "heltec_mesh_node_t114")]
|
||||
pub mod render_color;
|
||||
|
||||
// Shared QR-code blit, generic over color. Used by both the mono and
|
||||
// color splash screens to render the bitmap with proper quiet zones.
|
||||
mod qr;
|
||||
|
||||
// ── Mono-OLED adapters ────────────────────────────────────────────────
|
||||
//
|
||||
// SH1106 + SSD1306Async both render `BinaryColor` content into a 128×64
|
||||
// framebuffer flushed via I2C. Their `BoardDisplay` impls all delegate
|
||||
// to `mod render` — the existing pixel-perfect mono renderer.
|
||||
|
||||
#[cfg(any(
|
||||
feature = "wio_tracker_l1",
|
||||
feature = "elecrow_thinknode_m2",
|
||||
feature = "lilygo_tbeam_supreme",
|
||||
feature = "rak_wisblock_4631"
|
||||
))]
|
||||
impl<I> BoardDisplay for crate::driver::sh1106::Sh1106<I>
|
||||
where
|
||||
I: embedded_hal_async::i2c::I2c,
|
||||
{
|
||||
async fn present(&mut self) {
|
||||
let _ = crate::driver::sh1106::Sh1106::flush(self).await;
|
||||
}
|
||||
async fn set_bright(&mut self) {
|
||||
crate::driver::DisplayBrightness::set_bright(self).await;
|
||||
}
|
||||
async fn set_dim(&mut self) {
|
||||
crate::driver::DisplayBrightness::set_dim(self).await;
|
||||
}
|
||||
fn render_splash_learn_more(&mut self) {
|
||||
render::learn_more(self);
|
||||
}
|
||||
fn render_splash_info(&mut self, info: &BoardInfo<'_>) {
|
||||
render::info(self, info);
|
||||
}
|
||||
fn render_dashboard(&mut self, ctx: &DashboardCtx<'_>) {
|
||||
render::dashboard(self, ctx);
|
||||
}
|
||||
fn render_blank(&mut self) {
|
||||
render::blank(self);
|
||||
}
|
||||
}
|
||||
|
||||
impl<DI, SIZE> BoardDisplay
|
||||
for ssd1306::Ssd1306Async<DI, SIZE, ssd1306::mode::BufferedGraphicsModeAsync<SIZE>>
|
||||
where
|
||||
DI: display_interface::AsyncWriteOnlyDataCommand,
|
||||
SIZE: ssd1306::size::DisplaySizeAsync,
|
||||
{
|
||||
async fn present(&mut self) {
|
||||
let _ = ssd1306::Ssd1306Async::flush(self).await;
|
||||
}
|
||||
async fn set_bright(&mut self) {
|
||||
crate::driver::DisplayBrightness::set_bright(self).await;
|
||||
}
|
||||
async fn set_dim(&mut self) {
|
||||
crate::driver::DisplayBrightness::set_dim(self).await;
|
||||
}
|
||||
fn render_splash_learn_more(&mut self) {
|
||||
render::learn_more(self);
|
||||
}
|
||||
fn render_splash_info(&mut self, info: &BoardInfo<'_>) {
|
||||
render::info(self, info);
|
||||
}
|
||||
fn render_dashboard(&mut self, ctx: &DashboardCtx<'_>) {
|
||||
render::dashboard(self, ctx);
|
||||
}
|
||||
fn render_blank(&mut self) {
|
||||
render::blank(self);
|
||||
}
|
||||
}
|
||||
|
||||
/// Fine-grained events driving the `Display` statechart.
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum DisplayEvent {
|
||||
@@ -164,14 +355,14 @@ impl DisplayContext {
|
||||
version: VERSION,
|
||||
mac: mac_str,
|
||||
};
|
||||
render::info(display, &info);
|
||||
let _ = display.flush().await;
|
||||
display.render_splash_info(&info);
|
||||
display.present().await;
|
||||
}
|
||||
|
||||
async fn flush_learn_more(&mut self) {
|
||||
let display = &mut self.peripherals.display;
|
||||
render::learn_more(display);
|
||||
let _ = display.flush().await;
|
||||
display.render_splash_learn_more();
|
||||
display.present().await;
|
||||
}
|
||||
|
||||
async fn flush_dashboard(&mut self, badge: Badge) {
|
||||
@@ -183,27 +374,28 @@ impl DisplayContext {
|
||||
version: VERSION,
|
||||
mac: mac_str,
|
||||
};
|
||||
render::dashboard(
|
||||
display,
|
||||
let ctx = DashboardCtx {
|
||||
badge,
|
||||
self.run.config,
|
||||
self.run.rx_count,
|
||||
self.run.tx_count,
|
||||
self.run.last_rssi,
|
||||
self.run.last_snr,
|
||||
&self.run.rssi_history,
|
||||
&self.run.tx_history,
|
||||
self.run.rssi_count,
|
||||
self.run.current_slot_rssi,
|
||||
self.run.current_slot_tx,
|
||||
&info,
|
||||
);
|
||||
let _ = display.flush().await;
|
||||
config: self.run.config,
|
||||
rx_count: self.run.rx_count,
|
||||
tx_count: self.run.tx_count,
|
||||
last_rssi: self.run.last_rssi,
|
||||
last_snr: self.run.last_snr,
|
||||
rssi_history: &self.run.rssi_history,
|
||||
tx_history: &self.run.tx_history,
|
||||
rssi_count: self.run.rssi_count,
|
||||
current_slot_rssi: self.run.current_slot_rssi,
|
||||
current_slot_tx: self.run.current_slot_tx,
|
||||
board: &info,
|
||||
};
|
||||
display.render_dashboard(&ctx);
|
||||
display.present().await;
|
||||
}
|
||||
|
||||
async fn flush_blank(&mut self) {
|
||||
render::blank(&mut self.peripherals.display);
|
||||
let _ = self.peripherals.display.flush().await;
|
||||
let display = &mut self.peripherals.display;
|
||||
display.render_blank();
|
||||
display.present().await;
|
||||
}
|
||||
|
||||
// Boards without an LED use `LedDriver = ()`; the `RgbLed for ()` impl
|
||||
@@ -237,6 +429,8 @@ statechart! {
|
||||
Display {
|
||||
context: DisplayContext;
|
||||
events: DisplayEvent;
|
||||
// Auto-trace — backend selected by `hsmc/trace-*` cargo feature.
|
||||
trace;
|
||||
default(On);
|
||||
|
||||
state Off {
|
||||
@@ -435,7 +629,7 @@ pub async fn display_task(
|
||||
),
|
||||
);
|
||||
|
||||
let Some(display) = board::create_display(parts.i2c).await else {
|
||||
let Some(display) = board::create_display(parts).await else {
|
||||
defmt::error!("display init failed, running LED-only loop");
|
||||
loop {
|
||||
match display_commands.receive().await {
|
||||
@@ -489,7 +683,7 @@ pub async fn display_task(
|
||||
mod render {
|
||||
use core::fmt::Write;
|
||||
|
||||
use embedded_graphics::mono_font::ascii::{FONT_6X10, FONT_9X15_BOLD};
|
||||
use embedded_graphics::mono_font::ascii::{FONT_5X8, FONT_6X10, FONT_9X15_BOLD};
|
||||
use embedded_graphics::mono_font::MonoTextStyle;
|
||||
use embedded_graphics::pixelcolor::BinaryColor;
|
||||
use embedded_graphics::prelude::*;
|
||||
@@ -498,10 +692,8 @@ mod render {
|
||||
use embedded_graphics::text::{Alignment, Text};
|
||||
use heapless::String;
|
||||
|
||||
use super::Badge;
|
||||
use crate::protocol::{LoRaBandwidth, LoRaConfig};
|
||||
|
||||
pub const RSSI_HISTORY_LEN: usize = 128;
|
||||
use super::{Badge, BoardInfo, DashboardCtx, QR_WIDTH_PX, RSSI_HISTORY_LEN};
|
||||
use crate::protocol::LoRaBandwidth;
|
||||
|
||||
const CHAR_W: i32 = 6;
|
||||
const FONT_H: i32 = 10;
|
||||
@@ -510,72 +702,20 @@ mod render {
|
||||
const RSSI_MIN: i16 = -120;
|
||||
const RSSI_MAX: i16 = 0;
|
||||
|
||||
/// Hand-authored 64 × 64 pixel-perfect QR bitmap for the splash
|
||||
/// "learn more" screen. 1 bit/pixel, MSB-first, row-major. Bit `1` is
|
||||
/// "light" (OLED pixel on), bit `0` is "dark" (OLED pixel off), so
|
||||
/// when drawn with `BinaryColor::On` = lit, the QR appears with the
|
||||
/// traditional dark-on-light look including a proper quiet zone.
|
||||
const QR_WIDTH_PX: u32 = 64;
|
||||
const QR_BITMAP: [u8; 512] = [
|
||||
0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF,
|
||||
0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF,
|
||||
0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF,
|
||||
0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFE, 0x00, 0x07, 0x9F,
|
||||
0xFE, 0x60, 0x00, 0x7F, 0xFE, 0x00, 0x07, 0x9F, 0xFE, 0x60, 0x00, 0x7F, 0xFE, 0x7F, 0xE6,
|
||||
0x18, 0x19, 0xE7, 0xFE, 0x7F, 0xFE, 0x7F, 0xE6, 0x18, 0x19, 0xE7, 0xFE, 0x7F, 0xFE, 0x60,
|
||||
0x67, 0x81, 0xE6, 0x66, 0x06, 0x7F, 0xFE, 0x60, 0x67, 0x81, 0xE6, 0x66, 0x06, 0x7F, 0xFE,
|
||||
0x60, 0x66, 0x19, 0x87, 0xE6, 0x06, 0x7F, 0xFE, 0x60, 0x66, 0x19, 0x87, 0xE6, 0x06, 0x7F,
|
||||
0xFE, 0x60, 0x67, 0xE6, 0x1F, 0xE6, 0x06, 0x7F, 0xFE, 0x60, 0x67, 0xE6, 0x1F, 0xE6, 0x06,
|
||||
0x7F, 0xFE, 0x7F, 0xE6, 0x67, 0x80, 0x67, 0xFE, 0x7F, 0xFE, 0x7F, 0xE6, 0x67, 0x80, 0x67,
|
||||
0xFE, 0x7F, 0xFE, 0x00, 0x06, 0x66, 0x66, 0x60, 0x00, 0x7F, 0xFE, 0x00, 0x06, 0x66, 0x66,
|
||||
0x60, 0x00, 0x7F, 0xFF, 0xFF, 0xFF, 0xFE, 0x61, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFE,
|
||||
0x61, 0xFF, 0xFF, 0xFF, 0xFE, 0x00, 0x60, 0x06, 0x01, 0x99, 0x99, 0xFF, 0xFE, 0x00, 0x60,
|
||||
0x06, 0x01, 0x99, 0x99, 0xFF, 0xFE, 0x01, 0x98, 0x1F, 0x9E, 0x79, 0xF9, 0xFF, 0xFE, 0x01,
|
||||
0x98, 0x1F, 0x9E, 0x79, 0xF9, 0xFF, 0xFF, 0xE0, 0x66, 0x18, 0x61, 0x9F, 0x98, 0x7F, 0xFF,
|
||||
0xE0, 0x66, 0x18, 0x61, 0x9F, 0x98, 0x7F, 0xFF, 0x98, 0x1E, 0x01, 0xE7, 0x99, 0xFE, 0x7F,
|
||||
0xFF, 0x98, 0x1E, 0x01, 0xE7, 0x99, 0xFE, 0x7F, 0xFF, 0xE1, 0xE6, 0x19, 0xF8, 0x06, 0x60,
|
||||
0x7F, 0xFF, 0xE1, 0xE6, 0x19, 0xF8, 0x06, 0x60, 0x7F, 0xFE, 0x19, 0xF8, 0x06, 0x1E, 0x79,
|
||||
0x99, 0xFF, 0xFE, 0x19, 0xF8, 0x06, 0x1E, 0x79, 0x99, 0xFF, 0xFE, 0x7E, 0x01, 0xFF, 0x80,
|
||||
0x18, 0x18, 0x7F, 0xFE, 0x7E, 0x01, 0xFF, 0x80, 0x18, 0x18, 0x7F, 0xFE, 0x7E, 0x19, 0x9E,
|
||||
0x1E, 0x78, 0x7E, 0x7F, 0xFE, 0x7E, 0x19, 0x9E, 0x1E, 0x78, 0x7E, 0x7F, 0xFE, 0x60, 0x06,
|
||||
0x66, 0x66, 0x00, 0x67, 0xFF, 0xFE, 0x60, 0x06, 0x66, 0x66, 0x00, 0x67, 0xFF, 0xFF, 0xFF,
|
||||
0xFE, 0x67, 0xF8, 0x7E, 0x1F, 0xFF, 0xFF, 0xFF, 0xFE, 0x67, 0xF8, 0x7E, 0x1F, 0xFF, 0xFE,
|
||||
0x00, 0x06, 0x00, 0x7E, 0x66, 0x60, 0x7F, 0xFE, 0x00, 0x06, 0x00, 0x7E, 0x66, 0x60, 0x7F,
|
||||
0xFE, 0x7F, 0xE7, 0x99, 0xE6, 0x7E, 0x1E, 0x7F, 0xFE, 0x7F, 0xE7, 0x99, 0xE6, 0x7E, 0x1E,
|
||||
0x7F, 0xFE, 0x60, 0x66, 0x61, 0xF8, 0x00, 0x60, 0x7F, 0xFE, 0x60, 0x66, 0x61, 0xF8, 0x00,
|
||||
0x60, 0x7F, 0xFE, 0x60, 0x66, 0x06, 0x01, 0x86, 0x00, 0x7F, 0xFE, 0x60, 0x66, 0x06, 0x01,
|
||||
0x86, 0x00, 0x7F, 0xFE, 0x60, 0x66, 0x67, 0x86, 0x7F, 0x86, 0x7F, 0xFE, 0x60, 0x66, 0x67,
|
||||
0x86, 0x7F, 0x86, 0x7F, 0xFE, 0x7F, 0xE6, 0x66, 0x18, 0x00, 0x1E, 0x7F, 0xFE, 0x7F, 0xE6,
|
||||
0x66, 0x18, 0x00, 0x1E, 0x7F, 0xFE, 0x00, 0x06, 0x66, 0x67, 0x98, 0x00, 0x7F, 0xFE, 0x00,
|
||||
0x06, 0x66, 0x67, 0x98, 0x00, 0x7F, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF,
|
||||
0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF,
|
||||
0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF,
|
||||
0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF,
|
||||
0xFF, 0xFF,
|
||||
];
|
||||
|
||||
pub struct BoardInfo<'a> {
|
||||
pub name: &'a str,
|
||||
pub version: &'a str,
|
||||
pub mac: &'a str,
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn dashboard(
|
||||
target: &mut impl DrawTarget<Color = BinaryColor>,
|
||||
badge: Badge,
|
||||
config: Option<LoRaConfig>,
|
||||
rx_count: u32,
|
||||
tx_count: u32,
|
||||
last_rssi: Option<i16>,
|
||||
last_snr: Option<i16>,
|
||||
rssi_history: &[i16; RSSI_HISTORY_LEN],
|
||||
tx_history: &[bool; RSSI_HISTORY_LEN],
|
||||
rssi_count: usize,
|
||||
current_slot_rssi: i16,
|
||||
current_slot_tx: bool,
|
||||
board: &BoardInfo,
|
||||
) {
|
||||
pub fn dashboard(target: &mut impl DrawTarget<Color = BinaryColor>, ctx: &DashboardCtx<'_>) {
|
||||
let badge = ctx.badge;
|
||||
let config = ctx.config;
|
||||
let rx_count = ctx.rx_count;
|
||||
let tx_count = ctx.tx_count;
|
||||
let last_rssi = ctx.last_rssi;
|
||||
let last_snr = ctx.last_snr;
|
||||
let rssi_history = ctx.rssi_history;
|
||||
let tx_history = ctx.tx_history;
|
||||
let rssi_count = ctx.rssi_count;
|
||||
let current_slot_rssi = ctx.current_slot_rssi;
|
||||
let current_slot_tx = ctx.current_slot_tx;
|
||||
let board = ctx.board;
|
||||
let bb = target.bounding_box();
|
||||
let w = bb.size.width as i32;
|
||||
let h = bb.size.height as i32;
|
||||
@@ -769,14 +909,31 @@ mod render {
|
||||
.draw(target)
|
||||
.ok();
|
||||
|
||||
Text::with_alignment(
|
||||
board.name,
|
||||
Point::new(center_x, 28),
|
||||
style,
|
||||
Alignment::Center,
|
||||
)
|
||||
.draw(target)
|
||||
.ok();
|
||||
// Board-name font picked at compile time by
|
||||
// BOARD_NAME_FITS_PRIMARY_FONT (see top of display.rs). If the
|
||||
// name fits FONT_6X10 we use it; otherwise we fall back to
|
||||
// FONT_5X8. The const_assert at the BOARD_NAME definition
|
||||
// rejects names that overflow even the fallback.
|
||||
if super::BOARD_NAME_FITS_PRIMARY_FONT {
|
||||
Text::with_alignment(
|
||||
board.name,
|
||||
Point::new(center_x, 28),
|
||||
style,
|
||||
Alignment::Center,
|
||||
)
|
||||
.draw(target)
|
||||
.ok();
|
||||
} else {
|
||||
let small_style = MonoTextStyle::new(&FONT_5X8, BinaryColor::On);
|
||||
Text::with_alignment(
|
||||
board.name,
|
||||
Point::new(center_x, 28),
|
||||
small_style,
|
||||
Alignment::Center,
|
||||
)
|
||||
.draw(target)
|
||||
.ok();
|
||||
}
|
||||
|
||||
Text::with_alignment(
|
||||
board.mac,
|
||||
@@ -798,28 +955,35 @@ mod render {
|
||||
}
|
||||
|
||||
/// Alternate splash screen: 64 × 64 pixel-perfect QR bitmap on the
|
||||
/// right (with its own quiet zone baked in), centered text column on
|
||||
/// the left inviting the user to visit the URL.
|
||||
/// right (centered within its quiet zone via `super::qr::draw`),
|
||||
/// centered text column on the left inviting the user to visit the URL.
|
||||
pub fn learn_more(target: &mut impl DrawTarget<Color = BinaryColor>) {
|
||||
use embedded_graphics::image::{Image, ImageRaw};
|
||||
use embedded_graphics::mono_font::ascii::{FONT_5X8, FONT_7X14_BOLD};
|
||||
use embedded_graphics::mono_font::ascii::{FONT_4X6, FONT_7X14_BOLD};
|
||||
|
||||
let bb = target.bounding_box();
|
||||
let w = bb.size.width as i32;
|
||||
|
||||
let _ = target.clear(BinaryColor::Off);
|
||||
|
||||
// ── QR code: flush to the right edge, 1:1 pixel rendering ───
|
||||
// ── QR code: rightmost 64 × 64 region, centered cells ───────
|
||||
let qr_x = w - QR_WIDTH_PX as i32;
|
||||
let qr_raw: ImageRaw<BinaryColor> = ImageRaw::new(&QR_BITMAP, QR_WIDTH_PX);
|
||||
Image::new(&qr_raw, Point::new(qr_x, 0)).draw(target).ok();
|
||||
super::qr::draw(
|
||||
target,
|
||||
Point::new(qr_x, 0),
|
||||
BinaryColor::On,
|
||||
BinaryColor::Off,
|
||||
);
|
||||
|
||||
// ── Text column (everything left of the QR) ─────────────────
|
||||
let col_w = qr_x;
|
||||
let col_center = col_w / 2;
|
||||
|
||||
let title_style = MonoTextStyle::new(&FONT_7X14_BOLD, BinaryColor::On);
|
||||
let text_style = MonoTextStyle::new(&FONT_5X8, BinaryColor::On);
|
||||
// FONT_4X6 (4 px advance) on the URL line so "donglora.com"
|
||||
// (12 chars × 4 = 48 px) fits the 64 px left column with ~8 px
|
||||
// breathing room either side, instead of FONT_5X8's 60 px which
|
||||
// visually touches both edges.
|
||||
let text_style = MonoTextStyle::new(&FONT_4X6, BinaryColor::On);
|
||||
|
||||
// Layout on a 64 px display:
|
||||
// blank
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
//! Shared QR-code blit for splash screens.
|
||||
//!
|
||||
//! The 512-byte QR bitmap in the parent module is laid out asymmetrically
|
||||
//! within its 64×64 frame: rows 0-6 and 57-63 are top/bottom quiet zones,
|
||||
//! but the QR cells start at col 0 (no left quiet zone) and extend to
|
||||
//! col ~49, leaving ~14 px of white padding on the right plus one stray
|
||||
//! black pixel at col 63. Rendering the bitmap as-is leaves the QR
|
||||
//! visually shifted toward the left of its rendering area, which looks
|
||||
//! wrong on any layout that doesn't tuck the bitmap flush against a
|
||||
//! screen edge.
|
||||
//!
|
||||
//! [`draw`] re-centers the QR data: it skips the bitmap's right padding
|
||||
//! (cols 50-63), fills the rendering area's left and right 7 px with
|
||||
//! the foreground "quiet zone" color, and renders the actual QR cells
|
||||
//! (bitmap cols 0..50) at +7 horizontal offset. Rows are rendered as-is
|
||||
//! so the bitmap's existing top/bottom quiet zones land where expected.
|
||||
//!
|
||||
//! Generic over color so the mono renderer (`BinaryColor`) and the
|
||||
//! T114's color renderer (`Rgb565`) share a single implementation —
|
||||
//! they pass their own foreground (white / lit) and background (black /
|
||||
//! unlit) values.
|
||||
|
||||
use embedded_graphics::draw_target::DrawTarget;
|
||||
use embedded_graphics::geometry::{Point, Size};
|
||||
use embedded_graphics::pixelcolor::PixelColor;
|
||||
use embedded_graphics::primitives::Rectangle;
|
||||
use embedded_graphics::Pixel;
|
||||
|
||||
use super::{QR_BITMAP, QR_WIDTH_PX};
|
||||
|
||||
/// Width of the actual QR data within the bitmap (cols 0..QR_DATA_W).
|
||||
const QR_DATA_W: i32 = 50;
|
||||
/// White padding on left + right to center the QR cells. With
|
||||
/// QR_DATA_W=50 and total area=64 (= QR_WIDTH_PX), padding = 7 each side.
|
||||
const PAD_X: i32 = (QR_WIDTH_PX as i32 - QR_DATA_W) / 2;
|
||||
|
||||
/// Render the 64×64 QR with proper quiet zones on all four sides.
|
||||
///
|
||||
/// `fg` is the "quiet zone / light cell" color (white on a TFT, "on"
|
||||
/// on a mono OLED). `bg` is the "dark cell" color. Bitmap rows 0-6 and
|
||||
/// 57-63 already encode top/bottom quiet zones in `fg`; this function
|
||||
/// fills the missing left/right padding columns explicitly.
|
||||
pub fn draw<D, C>(target: &mut D, origin: Point, fg: C, bg: C)
|
||||
where
|
||||
D: DrawTarget<Color = C>,
|
||||
C: PixelColor,
|
||||
{
|
||||
// Fill left padding (cols 0..PAD_X) with fg.
|
||||
let _ = target.fill_solid(
|
||||
&Rectangle::new(origin, Size::new(PAD_X as u32, QR_WIDTH_PX)),
|
||||
fg,
|
||||
);
|
||||
// Fill right padding (cols PAD_X+QR_DATA_W..QR_WIDTH_PX) with fg.
|
||||
let right_pad_w = QR_WIDTH_PX as i32 - PAD_X - QR_DATA_W;
|
||||
let _ = target.fill_solid(
|
||||
&Rectangle::new(
|
||||
Point::new(origin.x + PAD_X + QR_DATA_W, origin.y),
|
||||
Size::new(right_pad_w as u32, QR_WIDTH_PX),
|
||||
),
|
||||
fg,
|
||||
);
|
||||
|
||||
// Render bitmap cols 0..QR_DATA_W at +PAD_X horizontal offset.
|
||||
let bytes_per_row = (QR_WIDTH_PX / 8) as usize;
|
||||
let pixels = (0..QR_WIDTH_PX as i32).flat_map(move |y| {
|
||||
(0..QR_DATA_W).map(move |x| {
|
||||
let byte = QR_BITMAP[y as usize * bytes_per_row + (x as usize) / 8];
|
||||
let bit = (byte >> (7 - (x as u8 & 7))) & 1;
|
||||
let color = if bit == 1 { fg } else { bg };
|
||||
Pixel(Point::new(origin.x + PAD_X + x, origin.y + y), color)
|
||||
})
|
||||
});
|
||||
let _ = target.draw_iter(pixels);
|
||||
}
|
||||
@@ -0,0 +1,422 @@
|
||||
//! Native 240×135 Rgb565 renderer for the Heltec T114 ST7789 panel.
|
||||
//!
|
||||
//! Mirrors the content shape of the mono OLED renderer in `display::render`
|
||||
//! (same `BoardInfo` / `DashboardCtx` inputs) but lays things out for the
|
||||
//! T114's bigger color screen with native font sizes and colored badges.
|
||||
//! Each board's `BoardDisplay` impl picks the renderer that matches its
|
||||
//! native pixel format — no per-pixel color quantization, no scaling,
|
||||
//! no aliasing.
|
||||
|
||||
use core::fmt::Write;
|
||||
|
||||
use embedded_graphics::mono_font::ascii::{
|
||||
FONT_6X10, FONT_8X13, FONT_8X13_BOLD, FONT_9X15_BOLD, FONT_10X20,
|
||||
};
|
||||
use embedded_graphics::mono_font::MonoTextStyle;
|
||||
use embedded_graphics::pixelcolor::Rgb565;
|
||||
use embedded_graphics::prelude::*;
|
||||
use embedded_graphics::primitives::{Line, PrimitiveStyle, Rectangle};
|
||||
use embedded_graphics::text::renderer::CharacterStyle;
|
||||
use embedded_graphics::text::{Alignment, Text};
|
||||
use heapless::String;
|
||||
|
||||
use super::{qr, Badge, BoardInfo, DashboardCtx, QR_WIDTH_PX, RSSI_HISTORY_LEN};
|
||||
use crate::protocol::LoRaBandwidth;
|
||||
|
||||
// ── Panel dimensions ──────────────────────────────────────────────────
|
||||
|
||||
const W: i32 = 240;
|
||||
const H: i32 = 135;
|
||||
|
||||
// ── Palette ───────────────────────────────────────────────────────────
|
||||
|
||||
const BG: Rgb565 = Rgb565::BLACK;
|
||||
const FG: Rgb565 = Rgb565::WHITE;
|
||||
const DIM: Rgb565 = Rgb565::new(12, 24, 12); // muted gray
|
||||
const ACCENT: Rgb565 = Rgb565::new(8, 30, 31); // bright cyan
|
||||
const RX_COLOR: Rgb565 = Rgb565::new(8, 60, 8); // bright green
|
||||
const TX_COLOR: Rgb565 = Rgb565::new(31, 16, 8); // amber-red
|
||||
const SPARK_COLOR: Rgb565 = Rgb565::new(20, 50, 24); // soft green
|
||||
|
||||
// ── Sparkline ────────────────────────────────────────────────────────
|
||||
|
||||
const RSSI_MIN: i16 = -120;
|
||||
const RSSI_MAX: i16 = 0;
|
||||
/// Sentinel: no packet received in this slot. Mirrors `display::NO_SIGNAL`.
|
||||
const NO_SIGNAL: i16 = -121;
|
||||
|
||||
// ── Public render entry points ────────────────────────────────────────
|
||||
|
||||
pub fn blank<D: DrawTarget<Color = Rgb565>>(target: &mut D) {
|
||||
let _ = target.clear(BG);
|
||||
}
|
||||
|
||||
pub fn splash_learn_more<D: DrawTarget<Color = Rgb565>>(target: &mut D) {
|
||||
let _ = target.clear(BG);
|
||||
|
||||
// QR pinned to the right side, vertically centered. 64×64 fits with
|
||||
// ~36 px headroom top/bottom on the 135-tall panel.
|
||||
let qr_w = QR_WIDTH_PX as i32;
|
||||
let qr_x = W - qr_w - 8;
|
||||
let qr_y = (H - qr_w) / 2;
|
||||
qr::draw(target, Point::new(qr_x, qr_y), FG, BG);
|
||||
|
||||
// Text column to the left of the QR.
|
||||
let col_w = qr_x - 8;
|
||||
let col_center = col_w / 2 + 4;
|
||||
|
||||
let title_style = MonoTextStyle::new(&FONT_10X20, ACCENT);
|
||||
let url_style = MonoTextStyle::new(&FONT_8X13, FG);
|
||||
let caption_style = MonoTextStyle::new(&FONT_6X10, DIM);
|
||||
|
||||
let _ = Text::with_alignment(
|
||||
"DongLoRa",
|
||||
Point::new(col_center, 45),
|
||||
title_style,
|
||||
Alignment::Center,
|
||||
)
|
||||
.draw(target);
|
||||
|
||||
let _ = Text::with_alignment(
|
||||
"donglora.com",
|
||||
Point::new(col_center, 73),
|
||||
url_style,
|
||||
Alignment::Center,
|
||||
)
|
||||
.draw(target);
|
||||
|
||||
let _ = Text::with_alignment(
|
||||
"Scan for docs",
|
||||
Point::new(col_center, 95),
|
||||
caption_style,
|
||||
Alignment::Center,
|
||||
)
|
||||
.draw(target);
|
||||
}
|
||||
|
||||
pub fn splash_info<D: DrawTarget<Color = Rgb565>>(target: &mut D, info: &BoardInfo<'_>) {
|
||||
let _ = target.clear(BG);
|
||||
|
||||
let title_style = MonoTextStyle::new(&FONT_10X20, ACCENT);
|
||||
let board_style = MonoTextStyle::new(&FONT_9X15_BOLD, FG);
|
||||
let mac_style = MonoTextStyle::new(&FONT_8X13, FG);
|
||||
let small_style = MonoTextStyle::new(&FONT_6X10, DIM);
|
||||
|
||||
// Title at top.
|
||||
let _ = Text::with_alignment(
|
||||
"DongLoRa",
|
||||
Point::new(W / 2, 22),
|
||||
title_style,
|
||||
Alignment::Center,
|
||||
)
|
||||
.draw(target);
|
||||
|
||||
// Board name.
|
||||
let _ = Text::with_alignment(
|
||||
info.name,
|
||||
Point::new(W / 2, 50),
|
||||
board_style,
|
||||
Alignment::Center,
|
||||
)
|
||||
.draw(target);
|
||||
|
||||
// MAC.
|
||||
let _ = Text::with_alignment(
|
||||
info.mac,
|
||||
Point::new(W / 2, 75),
|
||||
mac_style,
|
||||
Alignment::Center,
|
||||
)
|
||||
.draw(target);
|
||||
|
||||
// Version.
|
||||
let mut version_buf: String<24> = String::new();
|
||||
let _ = write!(version_buf, "v{}", info.version);
|
||||
let _ = Text::with_alignment(
|
||||
&version_buf,
|
||||
Point::new(W / 2, 95),
|
||||
small_style,
|
||||
Alignment::Center,
|
||||
)
|
||||
.draw(target);
|
||||
|
||||
// Waiting line.
|
||||
let _ = Text::with_alignment(
|
||||
"Waiting for host...",
|
||||
Point::new(W / 2, 120),
|
||||
small_style,
|
||||
Alignment::Center,
|
||||
)
|
||||
.draw(target);
|
||||
}
|
||||
|
||||
pub fn dashboard<D: DrawTarget<Color = Rgb565>>(target: &mut D, ctx: &DashboardCtx<'_>) {
|
||||
let _ = target.clear(BG);
|
||||
|
||||
// ── Top bar (badge + radio config) ──────────────────────────
|
||||
let top_h = 24;
|
||||
let badge_w = 38;
|
||||
let (badge_color, badge_text) = match ctx.badge {
|
||||
Badge::Rx => (RX_COLOR, "RX"),
|
||||
Badge::Tx => (TX_COLOR, "TX"),
|
||||
};
|
||||
|
||||
let badge_rect = Rectangle::new(Point::zero(), Size::new(badge_w as u32, top_h as u32));
|
||||
let _ = badge_rect
|
||||
.into_styled(PrimitiveStyle::with_fill(badge_color))
|
||||
.draw(target);
|
||||
|
||||
let mut badge_style = MonoTextStyle::new(&FONT_8X13_BOLD, FG);
|
||||
badge_style.set_background_color(Some(badge_color));
|
||||
let _ = Text::with_alignment(
|
||||
badge_text,
|
||||
Point::new(badge_w / 2, 16),
|
||||
badge_style,
|
||||
Alignment::Center,
|
||||
)
|
||||
.draw(target);
|
||||
|
||||
// Radio config text right of the badge.
|
||||
let cfg_style = MonoTextStyle::new(&FONT_8X13, FG);
|
||||
let mut buf: String<48> = String::new();
|
||||
if let Some(cfg) = ctx.config {
|
||||
let bw_str = bw_label(cfg.bw);
|
||||
let cr_str = cr_label(cfg.cr);
|
||||
let freq_mhz = cfg.freq_hz / 1_000_000;
|
||||
let freq_khz = (cfg.freq_hz % 1_000_000) / 1_000;
|
||||
let _ = write!(
|
||||
buf,
|
||||
"{}.{:03} {} SF{} CR{}",
|
||||
freq_mhz, freq_khz, bw_str, cfg.sf, cr_str
|
||||
);
|
||||
} else {
|
||||
let _ = write!(buf, "Awaiting config");
|
||||
}
|
||||
let _ = Text::with_alignment(
|
||||
&buf,
|
||||
Point::new(W - 4, 16),
|
||||
cfg_style,
|
||||
Alignment::Right,
|
||||
)
|
||||
.draw(target);
|
||||
|
||||
// Separator line under top bar.
|
||||
let _ = Line::new(Point::new(0, top_h + 1), Point::new(W - 1, top_h + 1))
|
||||
.into_styled(PrimitiveStyle::with_stroke(DIM, 1))
|
||||
.draw(target);
|
||||
|
||||
// ── Bottom bar (counters + RSSI) ───────────────────────────
|
||||
let bottom_h = 30;
|
||||
let bottom_top = H - bottom_h;
|
||||
let _ = Line::new(
|
||||
Point::new(0, bottom_top - 1),
|
||||
Point::new(W - 1, bottom_top - 1),
|
||||
)
|
||||
.into_styled(PrimitiveStyle::with_stroke(DIM, 1))
|
||||
.draw(target);
|
||||
|
||||
let stat_style = MonoTextStyle::new(&FONT_8X13, FG);
|
||||
|
||||
let mut rxtx_buf: String<24> = String::new();
|
||||
let _ = write!(
|
||||
rxtx_buf,
|
||||
"RX {} TX {}",
|
||||
compact_count(ctx.rx_count).as_str(),
|
||||
compact_count(ctx.tx_count).as_str()
|
||||
);
|
||||
let _ = Text::with_alignment(
|
||||
&rxtx_buf,
|
||||
Point::new(4, bottom_top + 11),
|
||||
stat_style,
|
||||
Alignment::Left,
|
||||
)
|
||||
.draw(target);
|
||||
|
||||
let mut rssi_buf: String<32> = String::new();
|
||||
match (ctx.last_rssi, ctx.last_snr) {
|
||||
(Some(rssi), Some(snr)) => {
|
||||
let _ = write!(rssi_buf, "{} dBm SNR {}", rssi, snr);
|
||||
}
|
||||
(Some(rssi), None) => {
|
||||
let _ = write!(rssi_buf, "{} dBm", rssi);
|
||||
}
|
||||
_ => {
|
||||
let _ = write!(rssi_buf, "no signal");
|
||||
}
|
||||
}
|
||||
let _ = Text::with_alignment(
|
||||
&rssi_buf,
|
||||
Point::new(W - 4, bottom_top + 11),
|
||||
stat_style,
|
||||
Alignment::Right,
|
||||
)
|
||||
.draw(target);
|
||||
|
||||
let secondary = MonoTextStyle::new(&FONT_6X10, DIM);
|
||||
let _ = Text::with_alignment(
|
||||
ctx.board.mac,
|
||||
Point::new(W / 2, bottom_top + 25),
|
||||
secondary,
|
||||
Alignment::Center,
|
||||
)
|
||||
.draw(target);
|
||||
|
||||
// ── Sparkline ──────────────────────────────────────────────
|
||||
let spark_top = top_h + 4;
|
||||
let spark_h = (bottom_top - 2) - spark_top;
|
||||
if spark_h > 0 {
|
||||
// 240 wide / 2 px per bar = 120 visible bars (subset of the
|
||||
// 128-slot ring). Matches the mono renderer's
|
||||
// `visible_bars = w / 2` heuristic.
|
||||
let visible_bars = ((W / 2) as usize).min(RSSI_HISTORY_LEN);
|
||||
rssi_sparkline(
|
||||
target,
|
||||
ctx.rssi_history,
|
||||
ctx.tx_history,
|
||||
ctx.rssi_count,
|
||||
ctx.current_slot_rssi,
|
||||
ctx.current_slot_tx,
|
||||
spark_top,
|
||||
spark_h,
|
||||
visible_bars,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────
|
||||
|
||||
fn bw_label(bw: LoRaBandwidth) -> &'static str {
|
||||
match bw {
|
||||
LoRaBandwidth::Khz7 => "7.8k",
|
||||
LoRaBandwidth::Khz10 => "10.4k",
|
||||
LoRaBandwidth::Khz15 => "15.6k",
|
||||
LoRaBandwidth::Khz20 => "20.8k",
|
||||
LoRaBandwidth::Khz31 => "31.2k",
|
||||
LoRaBandwidth::Khz41 => "41.7k",
|
||||
LoRaBandwidth::Khz62 => "62.5k",
|
||||
LoRaBandwidth::Khz125 => "125k",
|
||||
LoRaBandwidth::Khz250 => "250k",
|
||||
LoRaBandwidth::Khz500 => "500k",
|
||||
LoRaBandwidth::Khz200 => "200k",
|
||||
LoRaBandwidth::Khz400 => "400k",
|
||||
LoRaBandwidth::Khz800 => "800k",
|
||||
LoRaBandwidth::Khz1600 => "1.6M",
|
||||
}
|
||||
}
|
||||
|
||||
fn cr_label(cr: crate::protocol::LoRaCodingRate) -> &'static str {
|
||||
match cr {
|
||||
crate::protocol::LoRaCodingRate::Cr4_5 => "5",
|
||||
crate::protocol::LoRaCodingRate::Cr4_6 => "6",
|
||||
crate::protocol::LoRaCodingRate::Cr4_7 => "7",
|
||||
crate::protocol::LoRaCodingRate::Cr4_8 => "8",
|
||||
}
|
||||
}
|
||||
|
||||
fn compact_count(n: u32) -> String<10> {
|
||||
let mut s: String<10> = String::new();
|
||||
if n > 9_999_999 {
|
||||
let _ = write!(s, "{}M", n / 1_000_000);
|
||||
} else if n > 999_999 {
|
||||
let _ = write!(s, "{}k", n / 1_000);
|
||||
} else {
|
||||
let _ = write!(s, "{}", n);
|
||||
}
|
||||
s
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn rssi_sparkline<D: DrawTarget<Color = Rgb565>>(
|
||||
target: &mut D,
|
||||
history: &[i16; RSSI_HISTORY_LEN],
|
||||
tx_history: &[bool; RSSI_HISTORY_LEN],
|
||||
count: usize,
|
||||
current_rssi: i16,
|
||||
current_tx: bool,
|
||||
spark_top: i32,
|
||||
spark_h: i32,
|
||||
visible_bars: usize,
|
||||
) {
|
||||
let live = current_rssi > RSSI_MIN || current_tx;
|
||||
let committed = count.min(RSSI_HISTORY_LEN);
|
||||
let hist_slots = if live {
|
||||
committed.min(visible_bars.saturating_sub(1))
|
||||
} else {
|
||||
committed.min(visible_bars)
|
||||
};
|
||||
let total = hist_slots + if live { 1 } else { 0 };
|
||||
|
||||
if total == 0 {
|
||||
return;
|
||||
}
|
||||
|
||||
for i in 0..hist_slots {
|
||||
let idx = if count <= RSSI_HISTORY_LEN {
|
||||
let effective_bars = if live { visible_bars - 1 } else { visible_bars };
|
||||
i + committed.saturating_sub(effective_bars)
|
||||
} else {
|
||||
let start = count - RSSI_HISTORY_LEN;
|
||||
let skip = RSSI_HISTORY_LEN.saturating_sub(if live {
|
||||
visible_bars - 1
|
||||
} else {
|
||||
visible_bars
|
||||
});
|
||||
(start + skip + i) % RSSI_HISTORY_LEN
|
||||
};
|
||||
let is_tx = tx_history[idx];
|
||||
let rssi = history[idx];
|
||||
|
||||
if let Some(bar_h) = bar_height(rssi, is_tx, spark_h) {
|
||||
let x = (visible_bars - total + i) as i32 * 2;
|
||||
draw_bar(target, x, bar_h, is_tx, spark_top, spark_h);
|
||||
}
|
||||
}
|
||||
|
||||
if live {
|
||||
if let Some(bar_h) = bar_height(current_rssi, current_tx, spark_h) {
|
||||
let x = (visible_bars - 1) as i32 * 2;
|
||||
draw_bar(target, x, bar_h, current_tx, spark_top, spark_h);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn bar_height(rssi: i16, is_tx: bool, spark_h: i32) -> Option<i32> {
|
||||
let h = if rssi <= RSSI_MIN || rssi == NO_SIGNAL {
|
||||
if is_tx {
|
||||
spark_h / 3
|
||||
} else {
|
||||
return None;
|
||||
}
|
||||
} else {
|
||||
let clamped = rssi.clamp(RSSI_MIN, RSSI_MAX);
|
||||
((clamped - RSSI_MIN) as i32 * spark_h) / (RSSI_MAX - RSSI_MIN) as i32
|
||||
};
|
||||
if h == 0 { None } else { Some(h) }
|
||||
}
|
||||
|
||||
fn draw_bar<D: DrawTarget<Color = Rgb565>>(
|
||||
target: &mut D,
|
||||
x: i32,
|
||||
bar_h: i32,
|
||||
is_tx: bool,
|
||||
spark_top: i32,
|
||||
spark_h: i32,
|
||||
) {
|
||||
let y = spark_top + spark_h - bar_h;
|
||||
let color = if is_tx { TX_COLOR } else { SPARK_COLOR };
|
||||
if is_tx {
|
||||
// Hatched bar — every other pixel row — mirrors the mono
|
||||
// renderer's TX visual cue.
|
||||
for row in 0..bar_h {
|
||||
if row % 2 == 0 {
|
||||
let _ = Rectangle::new(Point::new(x, y + row), Size::new(2, 1))
|
||||
.into_styled(PrimitiveStyle::with_fill(color))
|
||||
.draw(target);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
let _ = Rectangle::new(Point::new(x, y), Size::new(2, bar_h as u32))
|
||||
.into_styled(PrimitiveStyle::with_fill(color))
|
||||
.draw(target);
|
||||
}
|
||||
}
|
||||
+9
-3
@@ -1,17 +1,22 @@
|
||||
#[cfg(any(
|
||||
feature = "wio_tracker_l1",
|
||||
feature = "elecrow_thinknode_m2",
|
||||
feature = "lilygo_tbeam_supreme"
|
||||
feature = "lilygo_tbeam_supreme",
|
||||
feature = "rak_wisblock_4631"
|
||||
))]
|
||||
pub mod sh1106;
|
||||
|
||||
#[cfg(any(feature = "lilygo_tbeam_supreme", feature = "lilygo_tbeam"))]
|
||||
pub mod axp;
|
||||
|
||||
#[cfg(feature = "lilygo_tbeam")]
|
||||
#[cfg(any(feature = "lilygo_tbeam", feature = "heltec_mesh_node_t114"))]
|
||||
pub mod inverted_pin;
|
||||
|
||||
#[cfg(feature = "heltec_mesh_node_t114")]
|
||||
pub mod st7789;
|
||||
|
||||
#[cfg(any(
|
||||
feature = "heltec_mesh_node_t114",
|
||||
feature = "heltec_v3",
|
||||
feature = "heltec_v3_uart",
|
||||
feature = "heltec_v4",
|
||||
@@ -67,7 +72,8 @@ where
|
||||
#[cfg(any(
|
||||
feature = "wio_tracker_l1",
|
||||
feature = "elecrow_thinknode_m2",
|
||||
feature = "lilygo_tbeam_supreme"
|
||||
feature = "lilygo_tbeam_supreme",
|
||||
feature = "rak_wisblock_4631"
|
||||
))]
|
||||
impl<I> DisplayBrightness for sh1106::Sh1106<I>
|
||||
where
|
||||
|
||||
@@ -37,6 +37,12 @@ impl<I: I2c> Sh1106<I> {
|
||||
/// Send a sequence of commands (each prefixed with the I2C command byte 0x00).
|
||||
async fn cmd(&mut self, commands: &[u8]) -> Result<(), I::Error> {
|
||||
for &c in commands {
|
||||
#[cfg(feature = "debug-twim")]
|
||||
defmt::info!(
|
||||
"[sh1106] cmd write: addr=0x{=u8:02X} bytes=[0x00, 0x{=u8:02X}]",
|
||||
self.addr,
|
||||
c,
|
||||
);
|
||||
self.i2c.write(self.addr, &[0x00, c]).await?;
|
||||
}
|
||||
Ok(())
|
||||
@@ -44,6 +50,8 @@ impl<I: I2c> Sh1106<I> {
|
||||
|
||||
/// Initialize the display with sensible defaults.
|
||||
pub async fn init(&mut self) -> Result<(), I::Error> {
|
||||
#[cfg(feature = "debug-twim")]
|
||||
defmt::info!("[sh1106] init: starting");
|
||||
self.cmd(&[
|
||||
0xAE, // display off
|
||||
0xD5, 0x80, // clock div: default
|
||||
@@ -71,6 +79,8 @@ impl<I: I2c> Sh1106<I> {
|
||||
page_buf[0] = 0x40;
|
||||
|
||||
for page in 0..PAGES {
|
||||
#[cfg(feature = "debug-twim")]
|
||||
defmt::info!("[sh1106] flush page {=usize}: addr cmds (3 bytes)", page);
|
||||
// Set page address and column start (with SH1106 2-column offset).
|
||||
self.cmd(&[
|
||||
0xB0 | page as u8, // page address
|
||||
@@ -81,6 +91,12 @@ impl<I: I2c> Sh1106<I> {
|
||||
|
||||
let start = page * WIDTH;
|
||||
page_buf[1..].copy_from_slice(&self.buffer[start..start + WIDTH]);
|
||||
#[cfg(feature = "debug-twim")]
|
||||
defmt::info!(
|
||||
"[sh1106] flush page {=usize}: data write {=usize} bytes (0x40 prefix + 128 pixels)",
|
||||
page,
|
||||
page_buf.len(),
|
||||
);
|
||||
self.i2c.write(self.addr, &page_buf).await?;
|
||||
}
|
||||
Ok(())
|
||||
@@ -88,6 +104,8 @@ impl<I: I2c> Sh1106<I> {
|
||||
|
||||
/// Set display brightness (contrast).
|
||||
pub async fn set_brightness(&mut self, value: u8) -> Result<(), I::Error> {
|
||||
#[cfg(feature = "debug-twim")]
|
||||
defmt::info!("[sh1106] set_brightness: 0x{=u8:02X}", value);
|
||||
self.cmd(&[0x81, value]).await
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
//! ST7789 240×135 IPS TFT driver wrapper for the Heltec Mesh Node T114.
|
||||
//!
|
||||
//! Wraps `lcd_async::Display<…, models::ST7789, …>` (async fork of
|
||||
//! mipidsi) with an in-RAM raw byte framebuffer (240×135 × 2 bytes
|
||||
//! Rgb565 BE = 64,800 bytes) and an active-LOW PWM backlight on PWM0.
|
||||
//!
|
||||
//! Renderers draw into the framebuffer via `lcd_async::raw_framebuf::RawFrameBuf`;
|
||||
//! `present()` awaits a single async DMA blit of the entire buffer to
|
||||
//! the panel via `panel.show_raw_data(...).await`. The await lets
|
||||
//! embassy-nrf's DMA-backed Spim yield the executor for the duration
|
||||
//! of the ~65 ms SPI transfer, so the radio task can run alongside
|
||||
//! and the SX1262 IRQ handler isn't starved during repaints.
|
||||
|
||||
use embassy_nrf::pwm::{DutyCycle, SimplePwm};
|
||||
use embedded_graphics::pixelcolor::Rgb565;
|
||||
use lcd_async::interface::Interface;
|
||||
use lcd_async::models::ST7789;
|
||||
use lcd_async::raw_framebuf::RawFrameBuf;
|
||||
use lcd_async::Display;
|
||||
|
||||
use crate::display::{render_color, BoardDisplay, BoardInfo, DashboardCtx};
|
||||
|
||||
/// PWM countertop. Drives the backlight at ~1 kHz (well above flicker).
|
||||
pub const BL_MAX: u16 = 1000;
|
||||
|
||||
fn bl_bright() -> DutyCycle {
|
||||
// Inverted polarity: HIGH for 0 counts → always LOW → full bright.
|
||||
DutyCycle::inverted(0)
|
||||
}
|
||||
|
||||
fn bl_dim() -> DutyCycle {
|
||||
// ~80% HIGH → ~20% LOW → 20% brightness.
|
||||
DutyCycle::inverted((BL_MAX * 4) / 5)
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
fn bl_off() -> DutyCycle {
|
||||
DutyCycle::inverted(BL_MAX)
|
||||
}
|
||||
|
||||
// ── Framebuffer ───────────────────────────────────────────────────────
|
||||
|
||||
pub const FB_W: usize = 240;
|
||||
pub const FB_H: usize = 135;
|
||||
/// Bytes for one full Rgb565 frame (2 bytes/pixel, big-endian — matches
|
||||
/// what ST7789 expects in 16-bit color mode).
|
||||
pub const FB_BYTES: usize = FB_W * FB_H * 2;
|
||||
|
||||
// ── St7789Color ───────────────────────────────────────────────────────
|
||||
|
||||
pub struct St7789Color<DI, RST>
|
||||
where
|
||||
DI: Interface,
|
||||
RST: embedded_hal::digital::OutputPin,
|
||||
{
|
||||
panel: Display<DI, ST7789, RST>,
|
||||
backlight: SimplePwm<'static>,
|
||||
fb: &'static mut [u8; FB_BYTES],
|
||||
}
|
||||
|
||||
impl<DI, RST> St7789Color<DI, RST>
|
||||
where
|
||||
DI: Interface,
|
||||
RST: embedded_hal::digital::OutputPin,
|
||||
{
|
||||
pub fn new(
|
||||
panel: Display<DI, ST7789, RST>,
|
||||
backlight: SimplePwm<'static>,
|
||||
fb: &'static mut [u8; FB_BYTES],
|
||||
) -> Self {
|
||||
Self { panel, backlight, fb }
|
||||
}
|
||||
|
||||
/// Wrap the raw byte buffer as a `RawFrameBuf<Rgb565, _>` so
|
||||
/// `embedded-graphics` primitives can draw into it, then run `f`.
|
||||
/// The borrow ends when this returns, freeing `self.fb` for the
|
||||
/// subsequent blit in `present()`.
|
||||
fn with_fb<F: FnOnce(&mut RawFrameBuf<Rgb565, &mut [u8]>)>(&mut self, f: F) {
|
||||
let mut fbuf = RawFrameBuf::<Rgb565, _>::new(&mut self.fb[..], FB_W, FB_H);
|
||||
f(&mut fbuf);
|
||||
}
|
||||
}
|
||||
|
||||
impl<DI, RST> BoardDisplay for St7789Color<DI, RST>
|
||||
where
|
||||
DI: Interface<Word = u8>,
|
||||
RST: embedded_hal::digital::OutputPin,
|
||||
{
|
||||
/// Blit the entire framebuffer to the panel via the async SPI
|
||||
/// interface. embassy-nrf's Spim runs the transfer over EasyDMA;
|
||||
/// the `.await` yields the executor for the duration so the radio
|
||||
/// task can process SX1262 IRQs concurrently.
|
||||
async fn present(&mut self) {
|
||||
let _ = self
|
||||
.panel
|
||||
.show_raw_data(0, 0, FB_W as u16, FB_H as u16, self.fb.as_ref())
|
||||
.await;
|
||||
}
|
||||
|
||||
async fn set_bright(&mut self) {
|
||||
self.backlight.set_duty(0, bl_bright());
|
||||
}
|
||||
|
||||
async fn set_dim(&mut self) {
|
||||
self.backlight.set_duty(0, bl_dim());
|
||||
}
|
||||
|
||||
fn render_splash_learn_more(&mut self) {
|
||||
self.with_fb(|fb| render_color::splash_learn_more(fb));
|
||||
}
|
||||
|
||||
fn render_splash_info(&mut self, info: &BoardInfo<'_>) {
|
||||
self.with_fb(|fb| render_color::splash_info(fb, info));
|
||||
}
|
||||
|
||||
fn render_dashboard(&mut self, ctx: &DashboardCtx<'_>) {
|
||||
self.with_fb(|fb| render_color::dashboard(fb, ctx));
|
||||
}
|
||||
|
||||
fn render_blank(&mut self) {
|
||||
self.with_fb(|fb| render_color::blank(fb));
|
||||
}
|
||||
}
|
||||
+5
-1
@@ -15,7 +15,11 @@ pub mod esp32s3;
|
||||
#[cfg(feature = "lilygo_tbeam")]
|
||||
pub mod esp32;
|
||||
|
||||
#[cfg(any(feature = "rak_wisblock_4631", feature = "wio_tracker_l1"))]
|
||||
#[cfg(any(
|
||||
feature = "heltec_mesh_node_t114",
|
||||
feature = "rak_wisblock_4631",
|
||||
feature = "wio_tracker_l1"
|
||||
))]
|
||||
pub mod nrf52840;
|
||||
|
||||
#[cfg(feature = "waveshare_rp2040_lora")]
|
||||
|
||||
@@ -13,11 +13,23 @@ bind_interrupts!(pub struct Irqs {
|
||||
USBD => embassy_nrf::usb::InterruptHandler<embassy_nrf::peripherals::USBD>;
|
||||
SPIM3 => embassy_nrf::spim::InterruptHandler<embassy_nrf::peripherals::SPI3>;
|
||||
TWISPI0 => embassy_nrf::twim::InterruptHandler<embassy_nrf::peripherals::TWISPI0>;
|
||||
// SPIM2 is the second free SPI peripheral on nRF52840. Only used
|
||||
// today by the Heltec T114 (ST7789 TFT panel on a write-only bus
|
||||
// that can't share with the SX1262 on SPIM3 because the pins
|
||||
// differ). Boards that don't use SPI2 don't construct anything
|
||||
// against it and this binding is a no-op for them.
|
||||
SPI2 => embassy_nrf::spim::InterruptHandler<embassy_nrf::peripherals::SPI2>;
|
||||
});
|
||||
|
||||
// ── MCU-level types ─────────────────────────────────────────────────
|
||||
|
||||
pub type SpiBus = embassy_nrf::spim::Spim<'static>;
|
||||
// I2C is used by some nRF boards (RAK4631 / Wio Tracker L1 OLED) but not
|
||||
// others (T114 uses SPI for the TFT). The `#[allow(dead_code)]` keeps
|
||||
// the type available for any board that wants it without per-feature
|
||||
// gating; rustc otherwise fires a dead-code lint when the active board
|
||||
// doesn't construct one.
|
||||
#[allow(dead_code)]
|
||||
pub type I2cBus = embassy_nrf::twim::Twim<'static>;
|
||||
pub type UsbNrfDriver =
|
||||
embassy_nrf::usb::Driver<'static, &'static embassy_nrf::usb::vbus_detect::SoftwareVbusDetect>;
|
||||
@@ -37,6 +49,8 @@ pub fn share_spi_bus(spi: SpiBus) -> &'static embassy_sync::mutex::Mutex<NoopRaw
|
||||
///
|
||||
/// Every nRF52840 Twim user needs a `&'static mut [u8]` buffer.
|
||||
/// This provides one via StaticCell so board files don't repeat the pattern.
|
||||
/// Unused on nRF boards that don't expose I2C (e.g. T114 uses SPI for the TFT).
|
||||
#[allow(dead_code)]
|
||||
pub fn alloc_i2c_buffer() -> &'static mut [u8; 256] {
|
||||
static TWIM_BUF: StaticCell<[u8; 256]> = StaticCell::new();
|
||||
TWIM_BUF.init([0u8; 256])
|
||||
@@ -56,6 +70,76 @@ pub fn alloc_vbus_detect(
|
||||
))
|
||||
}
|
||||
|
||||
// ── Reset reason ────────────────────────────────────────────────────
|
||||
|
||||
/// Read the POWER.RESETREAS register, log a human-readable summary,
|
||||
/// then clear it so the next boot reflects only the next reset cause.
|
||||
///
|
||||
/// This is the post-mortem diagnostic for soak runs: if the firmware
|
||||
/// crashes and the chip resets, the next boot's RESETREAS tells us
|
||||
/// what happened (cortex-m lockup, watchdog, soft reset, debugger,
|
||||
/// pin reset, etc.). Without this, a self-rebooting firmware just
|
||||
/// looks like log restarts with no signal as to why.
|
||||
///
|
||||
/// nRF52840 `POWER.RESETREAS` (PS §15.4.1): bits are write-1-to-clear.
|
||||
/// The register accumulates causes across resets until cleared, so
|
||||
/// always clear after reading.
|
||||
pub fn dump_reset_reason() {
|
||||
// POWER.RESETREAS lives at 0x40000400 (POWER base 0x40000000 +
|
||||
// offset 0x400). Direct memory access avoids requiring embassy_nrf's
|
||||
// `unstable-pac` feature. Bit layout per nRF52840 PS §15.4.1:
|
||||
// 0 RESETPIN - external NRESET pin asserted
|
||||
// 1 DOG - watchdog reset
|
||||
// 2 SREQ - software-requested reset (cortex SYSRESETREQ)
|
||||
// 3 LOCKUP - cortex-m core lockup
|
||||
// 16 OFF - wake from System OFF via DETECT
|
||||
// 17 LPCOMP - wake from System OFF via ANADETECT
|
||||
// 18 DIF - wake from System OFF via debug interface
|
||||
// 24 NFC - wake from System OFF via NFC field
|
||||
const RESETREAS_ADDR: *mut u32 = 0x4000_0400 as *mut u32;
|
||||
// SAFETY: 0x40000400 is the POWER.RESETREAS register on nRF52840;
|
||||
// read/write_volatile is correct for memory-mapped hardware.
|
||||
let raw = unsafe { core::ptr::read_volatile(RESETREAS_ADDR) };
|
||||
let dog = (raw & (1 << 1)) != 0;
|
||||
let lockup = (raw & (1 << 3)) != 0;
|
||||
if raw == 0 {
|
||||
defmt::info!("boot: cold POR (RESETREAS=0)");
|
||||
} else if dog || lockup {
|
||||
// Real problems: watchdog timeout (hung task) OR CPU lockup
|
||||
// (HardFault → escalated). Worth a WARN — these are bugs.
|
||||
defmt::warn!(
|
||||
"boot: ABNORMAL reset RESETREAS=0x{=u32:08x} resetpin={=bool} dog={=bool} sreq={=bool} lockup={=bool} off={=bool} lpcomp={=bool} dif={=bool} nfc={=bool}",
|
||||
raw,
|
||||
(raw & (1 << 0)) != 0,
|
||||
dog,
|
||||
(raw & (1 << 2)) != 0,
|
||||
lockup,
|
||||
(raw & (1 << 16)) != 0,
|
||||
(raw & (1 << 17)) != 0,
|
||||
(raw & (1 << 18)) != 0,
|
||||
(raw & (1 << 24)) != 0,
|
||||
);
|
||||
} else {
|
||||
// Normal reset paths: RESETPIN (UF2 bootloader pulses NRESET on
|
||||
// app start; user reset button), SREQ (deliberate sys_reset),
|
||||
// OFF/LPCOMP/DIF/NFC (wake from System OFF). All expected
|
||||
// operational reasons; INFO not WARN.
|
||||
defmt::info!(
|
||||
"boot: RESETREAS=0x{=u32:08x} resetpin={=bool} sreq={=bool} off={=bool} lpcomp={=bool} dif={=bool} nfc={=bool}",
|
||||
raw,
|
||||
(raw & (1 << 0)) != 0,
|
||||
(raw & (1 << 2)) != 0,
|
||||
(raw & (1 << 16)) != 0,
|
||||
(raw & (1 << 17)) != 0,
|
||||
(raw & (1 << 18)) != 0,
|
||||
(raw & (1 << 24)) != 0,
|
||||
);
|
||||
}
|
||||
// Clear all bits — write-1-to-clear per nRF52840 PS §15.4.1.
|
||||
// SAFETY: same justification as the read above.
|
||||
unsafe { core::ptr::write_volatile(RESETREAS_ADDR, 0xFFFF_FFFF) };
|
||||
}
|
||||
|
||||
// ── MAC address ─────────────────────────────────────────────────────
|
||||
|
||||
/// Read the factory-programmed device address from FICR registers.
|
||||
|
||||
+28
-3
@@ -54,7 +54,16 @@ pub async fn dispatch_frame(
|
||||
}
|
||||
|
||||
/// Emit an async framing error (tag = 0x0000) per spec §2.5.
|
||||
///
|
||||
/// Backpressure: if the host transport's IN endpoint is observed-dead
|
||||
/// (RADIO_THROTTLED set by host_task), drop silently. Without this gate,
|
||||
/// a bad-frame burst from a wedged host fills `OutboundChannel` faster
|
||||
/// than host_task can drain it (because it can't drain it at all when
|
||||
/// write_packet keeps failing), producing the warn flood seen in soak.
|
||||
pub async fn emit_async_err(outbound: &OutboundChannel, code: ErrorCode) {
|
||||
if crate::channel::RADIO_THROTTLED.load(portable_atomic::Ordering::Acquire) {
|
||||
return;
|
||||
}
|
||||
let frame = OutboundFrame {
|
||||
tag: 0,
|
||||
msg: DeviceMessage::Err(code),
|
||||
@@ -106,15 +115,31 @@ pub fn encode_outbound(frame: &OutboundFrame, out: &mut [u8; MAX_WIRE_FRAME]) ->
|
||||
) {
|
||||
Ok(n) => Some(n),
|
||||
Err(FrameEncodeError::BufferTooSmall) => {
|
||||
warn!("encode_frame: output buffer too small");
|
||||
warn!(
|
||||
"encode_frame: output buffer too small (type={=u8}, tag={=u16}, payload={=usize}B, out_buf={=usize}B)",
|
||||
frame.msg.type_id(),
|
||||
frame.tag,
|
||||
payload_len,
|
||||
out.len(),
|
||||
);
|
||||
None
|
||||
}
|
||||
Err(FrameEncodeError::PayloadTooLarge) => {
|
||||
warn!("encode_frame: payload exceeds MAX_PAYLOAD_FIELD");
|
||||
warn!(
|
||||
"encode_frame: payload exceeds MAX_PAYLOAD_FIELD (type={=u8}, tag={=u16}, payload={=usize}B)",
|
||||
frame.msg.type_id(),
|
||||
frame.tag,
|
||||
payload_len,
|
||||
);
|
||||
None
|
||||
}
|
||||
Err(FrameEncodeError::CobsEncode) => {
|
||||
warn!("encode_frame: COBS encode refused");
|
||||
warn!(
|
||||
"encode_frame: COBS encode refused (type={=u8}, tag={=u16}, payload={=usize}B)",
|
||||
frame.msg.type_id(),
|
||||
frame.tag,
|
||||
payload_len,
|
||||
);
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
+83
-35
@@ -4,7 +4,7 @@
|
||||
//! rely entirely on the spec §3.4 inactivity watchdog (1 s of silence
|
||||
//! → Disconnected → drop radio to Unconfigured).
|
||||
|
||||
use defmt::warn;
|
||||
use defmt::{info, warn};
|
||||
use embassy_executor::task;
|
||||
use embassy_time::{Duration, Instant};
|
||||
use embedded_io_async::Write;
|
||||
@@ -34,25 +34,32 @@ pub async fn host_task(
|
||||
display_commands.send(DisplayCommand::On).await;
|
||||
}
|
||||
|
||||
let mut read_buf = [0u8; READ_BUF];
|
||||
let mut write_buf: [u8; MAX_WIRE_FRAME] = [0u8; MAX_WIRE_FRAME];
|
||||
let mut decoder = FrameDecoder::new();
|
||||
let mut last_frame_at = Instant::now();
|
||||
let mut connected = true;
|
||||
// Two sub-loops joined in the same task. Splitting read and write
|
||||
// is what stops a long-running tx.write_all (~25 ms for a max-size
|
||||
// frame at 115_200 baud, multiplied across a saturated outbound
|
||||
// queue) from starving the inbound poll. While write_all sits in
|
||||
// its FIFO-drain await, join re-polls the read future on the next
|
||||
// wake, so last_frame_at keeps refreshing and the §3.4 watchdog
|
||||
// does not spuriously fire under sustained bidirectional load.
|
||||
// Same task = single executor + disjoint borrows on the split
|
||||
// rx/tx halves, so no Mutex is needed.
|
||||
let read_loop = async {
|
||||
let mut read_buf = [0u8; READ_BUF];
|
||||
let mut decoder = FrameDecoder::new();
|
||||
let mut last_frame_at = Instant::now();
|
||||
let mut connected = true;
|
||||
|
||||
loop {
|
||||
use embassy_futures::select::{select3, Either3};
|
||||
loop {
|
||||
use embassy_futures::select::{select, Either};
|
||||
|
||||
match select3(
|
||||
embedded_io_async::Read::read(&mut rx, &mut read_buf),
|
||||
outbound.receive(),
|
||||
embassy_time::Timer::after(Duration::from_millis(100)),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Either3::First(result) => match result {
|
||||
Ok(0) => continue,
|
||||
Ok(n) => {
|
||||
match select(
|
||||
embedded_io_async::Read::read(&mut rx, &mut read_buf),
|
||||
embassy_time::Timer::after(Duration::from_millis(100)),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Either::First(Ok(0)) => {}
|
||||
Either::First(Ok(n)) => {
|
||||
last_frame_at = Instant::now();
|
||||
if !connected {
|
||||
if has_display {
|
||||
@@ -62,31 +69,72 @@ pub async fn host_task(
|
||||
}
|
||||
dispatch_bytes(&mut decoder, &read_buf[..n], commands, outbound).await;
|
||||
}
|
||||
Err(_) => {
|
||||
Either::First(Err(_)) => {
|
||||
// Byte-level error (FIFO overrun, UART framing,
|
||||
// parity). Per PROTOCOL.md §3.4: only "byte stream
|
||||
// activity that results in a complete frame
|
||||
// arriving" resets the inactivity timer, so we do
|
||||
// NOT touch last_frame_at here. The 100 ms sleep
|
||||
// is anti-spin: a stuck driver pumping continuous
|
||||
// Errs would otherwise burn the executor.
|
||||
decoder.reset();
|
||||
embassy_time::Timer::after_millis(100).await;
|
||||
}
|
||||
},
|
||||
Either3::Second(frame) => {
|
||||
if let Some(n) = encode_outbound(&frame, &mut write_buf) {
|
||||
if tx.write_all(&write_buf[..n]).await.is_err() {
|
||||
warn!("UART write failed, frame dropped");
|
||||
Either::Second(()) => {}
|
||||
}
|
||||
|
||||
// Inactivity watchdog (PROTOCOL.md §3.4, 1000 ms).
|
||||
if connected && last_frame_at.elapsed() >= Duration::from_millis(INACTIVITY_MS) {
|
||||
warn!("UART host inactivity timeout fired");
|
||||
let _ = commands.try_send(InboundEvent::Reset);
|
||||
if has_display {
|
||||
let _ = display_commands.try_send(DisplayCommand::Reset);
|
||||
}
|
||||
// Drop in-flight frame state. When the host resumes it
|
||||
// may pick up mid-frame; without resetting the decoder
|
||||
// we'd chew through garbage until the next 0x00
|
||||
// sentinel. USB gets the equivalent for free via
|
||||
// DTR-drop reset (host/usb.rs Disconnected entry); UART
|
||||
// has no DTR.
|
||||
decoder.reset();
|
||||
connected = false;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let write_loop = async {
|
||||
let mut write_buf: [u8; MAX_WIRE_FRAME] = [0u8; MAX_WIRE_FRAME];
|
||||
|
||||
loop {
|
||||
let frame = outbound.receive().await;
|
||||
let Some(n) = encode_outbound(&frame, &mut write_buf) else {
|
||||
continue;
|
||||
};
|
||||
// Mirror src/host/usb.rs:349-374. The throttle gate at
|
||||
// src/radio.rs:2017 silently drops async RX into the
|
||||
// outbound queue while RADIO_THROTTLED is true; clear it
|
||||
// on every successful write and re-arm it on failure,
|
||||
// same contract as USB.
|
||||
match tx.write_all(&write_buf[..n]).await {
|
||||
Ok(()) => {
|
||||
if crate::channel::RADIO_THROTTLED
|
||||
.load(portable_atomic::Ordering::Acquire)
|
||||
{
|
||||
info!("UART write recovered; clearing radio throttle");
|
||||
crate::channel::RADIO_THROTTLED
|
||||
.store(false, portable_atomic::Ordering::Release);
|
||||
}
|
||||
}
|
||||
Err(_) => {
|
||||
warn!("UART write failed, frame dropped, throttling radio");
|
||||
crate::channel::RADIO_THROTTLED
|
||||
.store(true, portable_atomic::Ordering::Release);
|
||||
}
|
||||
}
|
||||
Either3::Third(()) => {}
|
||||
}
|
||||
};
|
||||
|
||||
// Inactivity watchdog.
|
||||
if connected && last_frame_at.elapsed() >= Duration::from_millis(INACTIVITY_MS) {
|
||||
warn!("UART host inactivity timeout fired");
|
||||
let _ = commands.try_send(InboundEvent::Reset);
|
||||
if has_display {
|
||||
let _ = display_commands.try_send(DisplayCommand::Reset);
|
||||
}
|
||||
connected = false;
|
||||
}
|
||||
}
|
||||
embassy_futures::join::join(read_loop, write_loop).await;
|
||||
}
|
||||
|
||||
async fn dispatch_bytes(
|
||||
|
||||
+201
-41
@@ -2,24 +2,35 @@
|
||||
//!
|
||||
//! ```text
|
||||
//! Host
|
||||
//! ├── Disconnected (default) no DTR, or inactivity timer fired
|
||||
//! └── Connected host attached, liveness OK
|
||||
//! entry: wake_display, start_liveness_timer
|
||||
//! exit: reset_radio, reset_display, stop_liveness_timer
|
||||
//! ├── Disconnected (default) no DTR
|
||||
//! └── Connected host attached, DTR up
|
||||
//! │ resting here means no traffic yet —
|
||||
//! │ NO inactivity timer (boot quiet is
|
||||
//! │ normal; chart structurally blocks the
|
||||
//! │ spurious 1s fire).
|
||||
//! └── Active ≥1 frame seen since connect
|
||||
//! inactivity timer (1s) armed and
|
||||
//! restarted by every frame via
|
||||
//! self-transition.
|
||||
//! ```
|
||||
//!
|
||||
//! The statechart tracks one state bit: "is a host present?". DTR drives
|
||||
//! connect edges; the 1-second inactivity watchdog (spec §3.4) drives
|
||||
//! the disconnect edge when DTR is held high but no bytes arrive.
|
||||
//! Two chart-level invariants made structurally impossible by the shape:
|
||||
//! 1. **No spurious inactivity at boot.** The 1-second timer only exists
|
||||
//! in `Active`, which requires a `FrameReceived` event to enter.
|
||||
//! A freshly-connected host that holds DTR high but sends nothing
|
||||
//! rests at `Connected` indefinitely (hsmc 0.5 optional-default
|
||||
//! "microwave Standby" pattern) — no timer, no warn, no Reset cascade.
|
||||
//! 2. **Chart-owned timer scope.** Per hsmc README rule §7 (timers
|
||||
//! armed by state lifecycle), the inactivity timer is cancelled on
|
||||
//! every exit of `Active`, restarted on every entry. No global
|
||||
//! `last_frame_at` instant, no manual elapsed check in the loop.
|
||||
//!
|
||||
//! The data plane (frame decode, command dispatch, frame encode) stays
|
||||
//! imperative in `protocol_loop` for the same reason the radio's
|
||||
//! `rx_once` stayed out of its chart: `read_packet` blocks.
|
||||
//! imperative in `protocol_loop` because `read_packet` blocks.
|
||||
|
||||
use defmt::warn;
|
||||
use embassy_executor::task;
|
||||
use embassy_futures::join::join;
|
||||
use embassy_time::{Duration, Instant};
|
||||
use embassy_time::Duration;
|
||||
use embassy_usb::class::cdc_acm::{CdcAcmClass, Receiver, Sender, State};
|
||||
use embassy_usb::Builder;
|
||||
use hsmc::{statechart, Duration as HsmcDuration};
|
||||
@@ -35,17 +46,24 @@ const MAX_USB_PACKET: usize = 64;
|
||||
|
||||
/// Spec §3.4: inactivity timer period. The device must see at least one
|
||||
/// frame (good or bad) every 1000 ms or the session is considered lost.
|
||||
/// Declared in the chart on `Active` — chart owns timer lifecycle.
|
||||
const INACTIVITY_MS: u64 = 1000;
|
||||
|
||||
/// Poll interval for the DTR edge detector / inactivity check.
|
||||
/// Poll interval for the DTR edge detector. Chart owns the inactivity
|
||||
/// timer; this is only the cadence at which protocol_loop wakes when
|
||||
/// no other event is pending, to re-check DTR.
|
||||
const POLL_INTERVAL_MS: u64 = 100;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum HostInput {
|
||||
DtrRaised,
|
||||
DtrDropped,
|
||||
/// Host went silent for >1 s; kick the radio back to Unconfigured.
|
||||
InactivityTimeout,
|
||||
/// A USB read returned ≥1 byte. Drives `Connected → Active` on
|
||||
/// the first frame after DTR up, and the `Active → Active`
|
||||
/// self-transition that restarts the inactivity timer per
|
||||
/// hsmc README rule §4 (self-transition exits + re-enters target,
|
||||
/// re-arming its timer).
|
||||
FrameReceived,
|
||||
}
|
||||
|
||||
pub struct HostContext {
|
||||
@@ -68,8 +86,22 @@ impl HostContext {
|
||||
}
|
||||
|
||||
fn send_radio_reset(&self) {
|
||||
use portable_atomic::Ordering;
|
||||
// Dedupe: if a Reset is already queued (and not yet consumed by
|
||||
// the radio task), don't queue another. Without this, a slow or
|
||||
// wedged radio + repeated chart exits (DTR drop, inactivity,
|
||||
// both transports) fill the commands channel and produce the
|
||||
// "queue full" warn flood. The radio chart's reset_to_unconfigured
|
||||
// clears the flag once it processes the queued Reset.
|
||||
if crate::channel::RESET_PENDING.swap(true, Ordering::AcqRel) {
|
||||
return;
|
||||
}
|
||||
if self.commands.try_send(InboundEvent::Reset).is_err() {
|
||||
warn!("radio Reset inject: command queue full");
|
||||
// Channel genuinely full of non-Reset commands; this is the
|
||||
// edge case worth knowing about. Clear the flag so future
|
||||
// Reset attempts can re-try once the channel drains.
|
||||
defmt::warn!("radio Reset inject: command queue full");
|
||||
crate::channel::RESET_PENDING.store(false, Ordering::Release);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -78,6 +110,8 @@ statechart! {
|
||||
Host {
|
||||
context: HostContext;
|
||||
events: HostInput;
|
||||
// Auto-trace — backend selected by `hsmc/trace-*` cargo feature.
|
||||
trace;
|
||||
default(Disconnected);
|
||||
|
||||
state Disconnected {
|
||||
@@ -88,7 +122,28 @@ statechart! {
|
||||
entry: wake_display;
|
||||
exit: reset_radio, reset_display;
|
||||
on(DtrDropped) => Disconnected;
|
||||
on(InactivityTimeout) => Disconnected;
|
||||
// hsmc 0.5 microwave-Standby pattern: Connected itself is the
|
||||
// resting "DTR up, no traffic yet" state. No inactivity timer
|
||||
// here — boot quiet is normal (mux-rs holds DTR high before
|
||||
// any client attaches), so the chart structurally cannot fire
|
||||
// a 1 s warn before the first byte. The first FrameReceived
|
||||
// descends into Active, which owns the inactivity timer.
|
||||
on(FrameReceived) => Active;
|
||||
|
||||
// Inactivity timer is chart-scoped: armed on every entry to
|
||||
// Active (from Connected on the first frame, or from the
|
||||
// self-transition below on each subsequent frame), cancelled
|
||||
// on every exit (per hsmc README rule §7). The self-transition
|
||||
// re-enters Active per rule §4, re-arming the timer —
|
||||
// equivalent to the "reset last_frame_at on each byte" pattern
|
||||
// but expressed in chart shape.
|
||||
//
|
||||
// hsmc's `after` triggers want `hsmc::Duration` (re-export
|
||||
// of `core::time::Duration`), not `embassy_time::Duration`.
|
||||
state Active {
|
||||
on(after HsmcDuration::from_millis(INACTIVITY_MS)) => Disconnected;
|
||||
on(FrameReceived) => Active;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -133,20 +188,34 @@ pub async fn host_task(
|
||||
}
|
||||
config.serial_number = Some(core::str::from_utf8(serial_buf).expect("MAC hex is valid UTF-8"));
|
||||
|
||||
// CTRL_BUF was 64 bytes; bumped to 128 because the previous size
|
||||
// matches the panic site "control buffer too small" in
|
||||
// embassy_usb/src/lib.rs:758. CdcAcmClass only requires >= 7 for
|
||||
// SET_LINE_CODING, but composite descriptor handlers and string
|
||||
// descriptor responses may overrun a 64 B buffer for some host
|
||||
// enumerations. 128 is safely above the largest CDC-ACM control
|
||||
// request and well within RAM budget.
|
||||
static DESC_BUF: StaticCell<[u8; 256]> = StaticCell::new();
|
||||
static CONF_BUF: StaticCell<[u8; 256]> = StaticCell::new();
|
||||
static BOS_BUF: StaticCell<[u8; 256]> = StaticCell::new();
|
||||
static CTRL_BUF: StaticCell<[u8; 64]> = StaticCell::new();
|
||||
static CTRL_BUF: StaticCell<[u8; 128]> = StaticCell::new();
|
||||
static CDC_STATE: StaticCell<State> = StaticCell::new();
|
||||
|
||||
let desc_buf = DESC_BUF.init([0; 256]);
|
||||
let conf_buf = CONF_BUF.init([0; 256]);
|
||||
let bos_buf = BOS_BUF.init([0; 256]);
|
||||
let ctrl_buf = CTRL_BUF.init([0; 64]);
|
||||
let ctrl_buf = CTRL_BUF.init([0; 128]);
|
||||
let cdc_state = CDC_STATE.init(State::new());
|
||||
|
||||
let mut builder = Builder::new(parts.driver, config, desc_buf, conf_buf, bos_buf, ctrl_buf);
|
||||
|
||||
// Note: when this device enumerates on Linux, modem-manager's
|
||||
// CDC-ACM probe issues SET_INTERFACE with alt settings beyond what
|
||||
// we declare, triggering a "SET_INTERFACE: alt setting out of
|
||||
// range" WARN from embassy_usb. The probe is normal Linux
|
||||
// behavior; embassy_usb's STALL response is correct USB negotiation.
|
||||
// The WARN is benign noise — do NOT add dummy alt settings just to
|
||||
// silence it (risks breaking Windows/macOS enumeration).
|
||||
let class = CdcAcmClass::new(&mut builder, cdc_state, MAX_USB_PACKET as u16);
|
||||
let mut usb_dev = builder.build();
|
||||
let (sender, receiver) = class.split();
|
||||
@@ -186,8 +255,6 @@ async fn protocol_loop<'d, D: embassy_usb_driver::Driver<'d>>(
|
||||
let mut write_buf: [u8; MAX_WIRE_FRAME] = [0u8; MAX_WIRE_FRAME];
|
||||
let mut decoder = FrameDecoder::new();
|
||||
|
||||
let mut last_frame_at = Instant::now();
|
||||
|
||||
loop {
|
||||
match select3(
|
||||
receiver.read_packet(&mut read_buf),
|
||||
@@ -196,52 +263,145 @@ async fn protocol_loop<'d, D: embassy_usb_driver::Driver<'d>>(
|
||||
)
|
||||
.await
|
||||
{
|
||||
Either3::First(Ok(0)) => {}
|
||||
Either3::First(Ok(0)) => {
|
||||
defmt::info!("host_task: read_packet returned 0 bytes");
|
||||
}
|
||||
Either3::First(Ok(n)) => {
|
||||
last_frame_at = Instant::now();
|
||||
defmt::info!(
|
||||
"host_task: read_packet got {=usize}B (DTR={=bool})",
|
||||
n,
|
||||
receiver.dtr(),
|
||||
);
|
||||
// Drives Connected → Active on first byte and
|
||||
// Active → Active (timer-restart) on every byte after.
|
||||
// The chart owns inactivity from here.
|
||||
let _ = machine.dispatch(HostInput::FrameReceived).await;
|
||||
dispatch_bytes(&mut decoder, &read_buf[..n], commands, outbound).await;
|
||||
}
|
||||
Either3::First(Err(_)) => {
|
||||
Either3::First(Err(e)) => {
|
||||
// Per embassy_usb_driver::EndpointError, `read_packet`
|
||||
// returns one of two errors:
|
||||
// - `Disabled`: OUT endpoint not enabled by the host
|
||||
// (USB enumeration in progress, host
|
||||
// suspended, or interface not yet
|
||||
// configured). Completely normal during
|
||||
// bring-up + every host disconnect.
|
||||
// - `BufferOverflow`: incoming packet larger than read_buf.
|
||||
// read_buf is sized to MAX_USB_PACKET
|
||||
// (64) which matches the CDC-ACM EP
|
||||
// max packet size, so this should
|
||||
// never happen in practice.
|
||||
//
|
||||
// `Disabled` floods at boot before USB enumeration finishes
|
||||
// (the OS hasn't sent SET_CONFIGURATION yet) — was previously
|
||||
// logging dozens of "read_packet error" lines per second.
|
||||
// Demote to debug-level since it's expected; reserve WARN
|
||||
// for BufferOverflow which IS a real bug.
|
||||
use embassy_usb_driver::EndpointError;
|
||||
match e {
|
||||
EndpointError::Disabled => {
|
||||
defmt::debug!(
|
||||
"host_task: read_packet returned Disabled (USB endpoint not yet enabled by host — normal during enumeration)"
|
||||
);
|
||||
}
|
||||
EndpointError::BufferOverflow => {
|
||||
defmt::warn!(
|
||||
"host_task: read_packet BufferOverflow — host sent a packet larger than {=usize}B (max EP packet size); decoder reset",
|
||||
MAX_USB_PACKET,
|
||||
);
|
||||
}
|
||||
}
|
||||
decoder.reset();
|
||||
embassy_time::Timer::after_millis(100).await;
|
||||
}
|
||||
Either3::Second(frame) => {
|
||||
let frame_tag = frame.tag;
|
||||
if let Some(n) = encode_outbound(&frame, &mut write_buf) {
|
||||
for chunk in write_buf[..n].chunks(MAX_USB_PACKET) {
|
||||
defmt::info!(
|
||||
"host_task: sending {=usize}B frame tag={=u16}",
|
||||
n,
|
||||
frame_tag,
|
||||
);
|
||||
// Per embassy_usb 0.6: write_packet only returns
|
||||
// EndpointError::Disabled when the IN endpoint is
|
||||
// dead (host gone or USB suspended). If a chunk
|
||||
// mid-frame fails, the partial bytes already sent
|
||||
// are unrecoverable — host's framing layer will see
|
||||
// corruption until a clean frame arrives. Bail on
|
||||
// the rest of THIS frame and set RADIO_THROTTLED so
|
||||
// the radio task stops generating async events
|
||||
// until the host comes back. Without the throttle,
|
||||
// every subsequent frame would also fail mid-write
|
||||
// and produce more wire corruption.
|
||||
let mut frame_failed = false;
|
||||
for (chunk_idx, chunk) in
|
||||
write_buf[..n].chunks(MAX_USB_PACKET).enumerate()
|
||||
{
|
||||
defmt::info!(
|
||||
"host_task: write_packet chunk {=usize} ({=usize}B) tag={=u16}",
|
||||
chunk_idx,
|
||||
chunk.len(),
|
||||
frame_tag,
|
||||
);
|
||||
if sender.write_packet(chunk).await.is_err() {
|
||||
warn!("USB write failed, frame dropped");
|
||||
defmt::warn!(
|
||||
"USB write failed (host stalled or gone); frame dropped tag={=u16}, throttling radio",
|
||||
frame_tag,
|
||||
);
|
||||
crate::channel::RADIO_THROTTLED
|
||||
.store(true, portable_atomic::Ordering::Release);
|
||||
frame_failed = true;
|
||||
break;
|
||||
}
|
||||
defmt::info!(
|
||||
"host_task: write_packet OK chunk {=usize} tag={=u16}",
|
||||
chunk_idx,
|
||||
frame_tag,
|
||||
);
|
||||
}
|
||||
if !frame_failed
|
||||
&& crate::channel::RADIO_THROTTLED
|
||||
.load(portable_atomic::Ordering::Acquire)
|
||||
{
|
||||
defmt::info!(
|
||||
"USB write recovered (tag={=u16}); clearing radio throttle",
|
||||
frame_tag,
|
||||
);
|
||||
crate::channel::RADIO_THROTTLED
|
||||
.store(false, portable_atomic::Ordering::Release);
|
||||
}
|
||||
defmt::info!("host_task: frame done tag={=u16}", frame_tag);
|
||||
}
|
||||
}
|
||||
Either3::Third(()) => {}
|
||||
Either3::Third(()) => {
|
||||
// Heartbeat tick — drives DTR edge polling + inactivity
|
||||
// watchdog below. No log: it would fire every
|
||||
// POLL_INTERVAL_MS and bury the interesting traffic.
|
||||
}
|
||||
}
|
||||
|
||||
// DTR edge detection — drives Connected / Disconnected transitions.
|
||||
let connected = receiver.dtr();
|
||||
let was_connected = matches!(machine.current_state(), HostState::Connected);
|
||||
match (was_connected, connected) {
|
||||
// DTR edge detection — drives Connected / Disconnected
|
||||
// transitions. The chart's `current_state()` may be
|
||||
// Disconnected, Connected (resting), or Connected/Active.
|
||||
// We're connected if current state is anywhere inside
|
||||
// Connected.
|
||||
let dtr_high = receiver.dtr();
|
||||
let was_connected = !matches!(
|
||||
machine.current_state(),
|
||||
HostState::Disconnected
|
||||
);
|
||||
match (was_connected, dtr_high) {
|
||||
(false, true) => {
|
||||
defmt::info!("host_task: DTR raised");
|
||||
let _ = machine.dispatch(HostInput::DtrRaised).await;
|
||||
last_frame_at = Instant::now();
|
||||
}
|
||||
(true, false) => {
|
||||
defmt::info!("host_task: DTR dropped");
|
||||
decoder.reset();
|
||||
let _ = machine.dispatch(HostInput::DtrDropped).await;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
||||
// Inactivity watchdog (spec §3.4): while Connected, if no frame
|
||||
// arrived in the last INACTIVITY_MS, drive the session back to
|
||||
// Disconnected.
|
||||
if matches!(machine.current_state(), HostState::Connected)
|
||||
&& last_frame_at.elapsed() >= Duration::from_millis(INACTIVITY_MS)
|
||||
{
|
||||
warn!("host inactivity timeout fired");
|
||||
let _ = machine.dispatch(HostInput::InactivityTimeout).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+14
-2
@@ -46,12 +46,15 @@ use crate::protocol::{cap, Info, MAX_MCU_UID_LEN, MAX_RADIO_UID_LEN};
|
||||
|
||||
#[cfg(not(test))]
|
||||
cfg_if::cfg_if! {
|
||||
if #[cfg(any(feature = "rak_wisblock_4631", feature = "wio_tracker_l1", feature = "waveshare_rp2040_lora"))] {
|
||||
if #[cfg(any(feature = "heltec_mesh_node_t114", feature = "rak_wisblock_4631", feature = "wio_tracker_l1", feature = "waveshare_rp2040_lora"))] {
|
||||
use defmt_rtt as _;
|
||||
use panic_probe as _;
|
||||
} else if #[cfg(any(feature = "heltec_v3", feature = "heltec_v3_uart", feature = "heltec_v4", feature = "elecrow_thinknode_m2", feature = "lilygo_tbeam_supreme", feature = "lilygo_tbeam"))] {
|
||||
use esp_backtrace as _;
|
||||
use esp_println as _;
|
||||
// Emits the IDF app descriptor into `.flash.appdesc`. espflash v4
|
||||
// refuses any ESP image that lacks it.
|
||||
esp_bootloader_esp_idf::esp_app_desc!();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -68,7 +71,7 @@ static mut INFO: Option<Info> = None;
|
||||
|
||||
#[cfg(not(test))]
|
||||
cfg_if::cfg_if! {
|
||||
if #[cfg(any(feature = "rak_wisblock_4631", feature = "wio_tracker_l1", feature = "waveshare_rp2040_lora"))] {
|
||||
if #[cfg(any(feature = "heltec_mesh_node_t114", feature = "rak_wisblock_4631", feature = "wio_tracker_l1", feature = "waveshare_rp2040_lora"))] {
|
||||
#[embassy_executor::main]
|
||||
async fn main(spawner: Spawner) {
|
||||
run(spawner).await;
|
||||
@@ -84,6 +87,15 @@ cfg_if::cfg_if! {
|
||||
#[cfg(not(test))]
|
||||
async fn run(spawner: Spawner) {
|
||||
let board = <board::Board as LoRaBoard>::init();
|
||||
|
||||
// Read + log + clear POWER.RESETREAS before any other boot output so
|
||||
// the cause of the *previous* reset (if any) lands at the very top
|
||||
// of the RTT log. Critical for soak-debugging silent crash-reboots:
|
||||
// a self-rebooting firmware otherwise looks like the log just
|
||||
// restarted with no explanation. nRF52840-only.
|
||||
#[cfg(any(feature = "heltec_mesh_node_t114", feature = "rak_wisblock_4631", feature = "wio_tracker_l1"))]
|
||||
crate::hal::nrf52840::dump_reset_reason();
|
||||
|
||||
let parts = board.into_parts();
|
||||
|
||||
// Build the GET_INFO struct once at boot. This is the authoritative
|
||||
|
||||
+1680
-328
File diff suppressed because it is too large.
Load diff
Reference in new issue
Block a user