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
|
||
|
|
481712930c
|
||
|
|
d262abe853
|
||
|
|
23ddc15557
|
||
|
|
4d723a85ce
|
||
|
|
6380bcbf6a
|
||
|
|
7a045edc1a
|
||
|
|
b53570ce66
|
||
|
|
647929ff8d
|
||
|
|
0fbd8412c2
|
||
|
|
ebd576b4e2
|
||
|
|
a2c611841b
|
||
|
|
a1b92356b3
|
||
|
|
7fca5fcb50
|
||
|
|
6867439044
|
||
|
|
bcfe736b30
|
||
|
|
0033bbb941
|
||
|
|
42a6b1142d
|
||
|
|
fa1eeceb7c
|
||
|
|
027ac5eb04
|
||
|
|
b7d9b37bb0
|
||
|
|
b0ce0a7aca
|
||
|
|
2a3c2be060
|
||
|
|
a0b15ddcaa
|
||
|
|
388d07e90f
|
||
|
|
c6ef9559ec
|
||
|
|
596bc3ed96
|
||
|
|
272abceedb
|
||
|
|
f9582c0b94
|
||
|
|
649c29218c
|
||
|
|
fd4e180879
|
||
|
|
8b549d49a9
|
||
|
|
7184610eea
|
||
|
|
46822c182c
|
||
|
|
2b7aacdb99
|
||
|
|
cabc8c1cc0
|
||
|
|
0cbf59765c
|
||
|
|
de38278d7b
|
||
|
|
0c9094dcbd | ||
|
|
41b5775268 | ||
|
|
f9117d71c8 | ||
|
|
3bb41da223 | ||
|
|
eb880d03f6 | ||
|
|
239e72d0a8 | ||
|
|
6558350aee | ||
|
|
019198b636 | ||
|
|
5ac4b73739 | ||
|
|
2b9e236f32 | ||
|
|
6db287c241 | ||
|
|
07651dce44 | ||
|
|
18b509344c | ||
|
|
ecc451affe | ||
|
|
7a54c4dff3 | ||
|
|
622684cdde | ||
|
|
2c852b9f45 | ||
|
|
fb334118fd | ||
|
|
8bdaa72bc3 | ||
|
|
6e8699610a | ||
|
|
73878b792f | ||
|
|
0ae1c84085 | ||
|
|
38684482e8 |
No files matched your search
@@ -4,3 +4,17 @@ rustflags = [
|
|||||||
"-C", "link-arg=-Wl,-Tdefmt.x",
|
"-C", "link-arg=-Wl,-Tdefmt.x",
|
||||||
"-C", "link-arg=-nostartfiles",
|
"-C", "link-arg=-nostartfiles",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[target.thumbv7em-none-eabihf]
|
||||||
|
rustflags = [
|
||||||
|
"-C", "link-arg=-Tlink.x",
|
||||||
|
"-C", "link-arg=-Tdefmt.x",
|
||||||
|
"-C", "opt-level=z",
|
||||||
|
]
|
||||||
|
|
||||||
|
[target.thumbv6m-none-eabi]
|
||||||
|
rustflags = [
|
||||||
|
"-C", "link-arg=-Tlink.x",
|
||||||
|
"-C", "link-arg=-Tdefmt.x",
|
||||||
|
"-C", "opt-level=z",
|
||||||
|
]
|
||||||
+744
@@ -0,0 +1,744 @@
|
|||||||
|
# Changelog
|
||||||
|
|
||||||
|
All notable changes to this project will be documented in this file.
|
||||||
|
|
||||||
|
The format is based on [Keep a Changelog](https://keepachangelog.com/),
|
||||||
|
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
|
||||||
|
|
||||||
|
- **RAK4631 WisBlock board support** (nRF52840 + SX1262, RAK19007 base
|
||||||
|
+ RAK4631 Core + RAK1921 OLED). The second nRF52840 + SX1262 board
|
||||||
|
alongside the Wio Tracker L1.
|
||||||
|
- `SX126X_POWER_EN` (P1.05) driven HIGH at init — the radio is
|
||||||
|
power-gated on this module; without this the chip has no power
|
||||||
|
and SPI hangs.
|
||||||
|
- HFXO + LFXO external crystals selected in `embassy_nrf::Config`.
|
||||||
|
USB CDC-ACM needs ±500 ppm which the internal RC oscillator
|
||||||
|
can't provide.
|
||||||
|
- `SoftwareVbusDetect(true, true)` so USB enumerates without a
|
||||||
|
`CLOCK_POWER` interrupt handler.
|
||||||
|
- Green LED on P1.03 (RAK19007 Base LED1, active-HIGH) via
|
||||||
|
`SimpleLed`.
|
||||||
|
- Hardware-validated end-to-end: ADVERT round-trip with an external
|
||||||
|
receiver on US915.
|
||||||
|
- Added `debug-checkpoint` blink-map coverage at every TX-phase
|
||||||
|
boundary in `radio.rs`, including a new checkpoint between
|
||||||
|
`prepare_for_cad` and `cad()` so future bring-ups can distinguish
|
||||||
|
a modem-setup hang from a DIO1-wait hang.
|
||||||
|
|
||||||
|
- **Adaptive listen-before-talk.** The CAD pre-TX retry loop scales to
|
||||||
|
the active LoRa config instead of running a fixed 10 retries × 10 ms
|
||||||
|
(which was roughly one max-length packet at SF7/BW500 and a no-op at
|
||||||
|
SF12/BW125).
|
||||||
|
- Per-retry sleep is sized to the time-on-air of an assumed-peer
|
||||||
|
80-byte packet under the current SF/BW/CR — cached on
|
||||||
|
`RadioContext`, recomputed on every `SET_CONFIG`.
|
||||||
|
- Retry count = `clamp(floor(BUDGET/T)+1, 1, 10)` with `BUDGET = 2.5
|
||||||
|
s`. Fast configs still get up to 10 retries; SF11/12 degrade to
|
||||||
|
single-CAD-and-bail (logged at config-apply).
|
||||||
|
- Sleeps jittered ±20% per iteration to decorrelate
|
||||||
|
simultaneously-backing-off contenders.
|
||||||
|
- ToA math, CR→{1..4} mapping, retry-count derivation, and jitter
|
||||||
|
helper live in a pure `src/lbt.rs` module — host-testable via
|
||||||
|
`DONGLORA_HOST_TEST=1 cargo test` (13 tests).
|
||||||
|
- Validated on-device: 20 iterations of the AI Bot test flow that
|
||||||
|
previously saw ~2 `ChannelBusy` errors now sees zero at the same
|
||||||
|
SF/BW.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **First-TX-after-boot reliability on SX1262 boards.** Picked up the
|
||||||
|
upstream `lora-phy` fix that moves the BUSY-handshake check from
|
||||||
|
*after* to *before* each SPI op, per Semtech SX1261/2 §8.3.1, LR1121
|
||||||
|
UM §3, and LR1110 DS §1.2.5. The existing post-op wait is preserved
|
||||||
|
as the belt that closes the BUSY-assertion latency window
|
||||||
|
(T_SW ≤ 600 ns) that a single level-poll can miss. Consumed via
|
||||||
|
`[patch.crates-io]` against `swaits/lora-rs#fix/sx126x-busy-race`
|
||||||
|
until the upstream PR merges.
|
||||||
|
- Hardware-verified on Seeed Wio Tracker L1: 20/20 clean-boot TX
|
||||||
|
(vs ~50% before).
|
||||||
|
- Renames `InterfaceVariant::wait_on_busy` → `wait_until_ready` to
|
||||||
|
match the new semantics.
|
||||||
|
- **BUSY pin pull-down → none on nRF SX1262 boards.** SX1262's BUSY
|
||||||
|
pin has an internal 20 kΩ pull-up (datasheet p. 53); our
|
||||||
|
`Input::new(..., Pull::Down)` was contending with it, leaving BUSY
|
||||||
|
at an indeterminate voltage and intermittently mis-reading the
|
||||||
|
chip's ready state. Per `lora-rs/lora-rs#260`, switched to
|
||||||
|
`Pull::None` on both Wio Tracker L1 and RAK4631. The RAK4631
|
||||||
|
appeared to work before, but was affected by the same class of bug
|
||||||
|
per the upstream issue — now deterministically correct rather than
|
||||||
|
"happens to work".
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- **Build-system DX.** `just flash`, `just flash-probe`, and
|
||||||
|
`just build` accept an optional comma-separated `extra_features`
|
||||||
|
argument that is appended to the board feature, e.g. `just flash
|
||||||
|
wio_tracker_l1 debug-checkpoint`. No more dropping to raw
|
||||||
|
`cargo + rust-objcopy + uf2 cp` for iteration loops.
|
||||||
|
- **`just` recipes now resolve all tools through `mise`** via
|
||||||
|
`set shell := ["mise", "exec", "--", "sh", "-c"]`. `cargo`,
|
||||||
|
`espflash`, `rust-objcopy`, `cargo-hex-to-uf2` etc. always pick up
|
||||||
|
the versions pinned in `mise.toml` regardless of `PATH` ordering.
|
||||||
|
- **`mise.toml` rust pins now carry ARM targets + clippy**, so a fresh
|
||||||
|
`mise install` is sufficient bootstrapping — no manual
|
||||||
|
`rustup target add` for `thumbv6m-none-eabi` /
|
||||||
|
`thumbv7em-none-eabihf` and no separate clippy install.
|
||||||
|
- **New `just flash-dfu` recipe** for the Adafruit nRF52 bootloader
|
||||||
|
(VID:PID `239a:0029`) via `adafruit-nrfutil` under `uvx`, with port
|
||||||
|
autodetect.
|
||||||
|
- **Per-board `_info` chain in the justfile** collapsed from a long
|
||||||
|
per-board `if/elif` ladder + per-board `:=` variables into a single
|
||||||
|
`case` statement — one place per board, two lists (names +
|
||||||
|
metadata).
|
||||||
|
|
||||||
|
## [1.3.1] - 2026-04-23
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **LilyGo T-Beam (classic) red LED now flashes on TX/RX** instead of
|
||||||
|
dimming off. The red LED under the OLED is wired active-LOW on this
|
||||||
|
board (anode to V+, cathode to GPIO4); v1.3.0 drove it as active-HIGH,
|
||||||
|
so the LED sat lit at idle and briefly dimmed during activity.
|
||||||
|
- Empirically verified on hardware against both T-Beam variants.
|
||||||
|
- Introduces `src/driver/inverted_pin.rs::InvertedPin<P>`, a generic
|
||||||
|
`OutputPin` wrapper that swaps `set_high`/`set_low`. Polarity is
|
||||||
|
expressed at the pin (where the physical wiring lives), so
|
||||||
|
`SimpleLed` stays polarity-agnostic and every other board's code
|
||||||
|
path is byte-for-byte unchanged.
|
||||||
|
- Classic T-Beam now constructs its LED as
|
||||||
|
`SimpleLed(InvertedPin(led_pin))` with pin init at `Level::High`
|
||||||
|
(dark at boot).
|
||||||
|
|
||||||
|
## [1.3.0] - 2026-04-23
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- LilyGo T-Beam S3 Supreme board support (ESP32-S3 + SX1262 + SH1106
|
||||||
|
OLED + AXP2101 PMIC, native USB-C).
|
||||||
|
- Pin map verified against MeshCore's
|
||||||
|
`variants/lilygo_tbeam_supreme_SX1262`.
|
||||||
|
- New `src/driver/axp.rs` module with an AXP2101 init sequence
|
||||||
|
(ALDO3 = 3.3 V LoRa, ALDO1 = 3.3 V OLED). The PMIC gates LoRa and
|
||||||
|
display power — without it the SX1262 never responds on SPI.
|
||||||
|
- PMIC lives on a dedicated I²C1 bus (SDA=42, SCL=41), separate
|
||||||
|
from the SH1106 on I²C0 (SDA=17, SCL=18). I²C1 is used blocking
|
||||||
|
for a one-shot init and then dropped.
|
||||||
|
- TCXO driven at 1.6 V via DIO3 (MeshCore uses the same).
|
||||||
|
- LilyGo T-Beam (classic) board support (ESP32 + SX1276 + SSD1306
|
||||||
|
OLED + AXP192 PMIC, CP2102 USB-UART bridge). This is the first
|
||||||
|
board in the firmware on classic ESP32 (non-S3) and the first on
|
||||||
|
the SX127x radio family.
|
||||||
|
- Pin map verified against MeshCore's `variants/lilygo_tbeam_SX1276`.
|
||||||
|
- New `src/hal/esp32.rs` (classic-ESP32 MCU primitives, peripheral
|
||||||
|
DMA via `DMA_SPI2`) and `src/board/esp32.rs` (SX1276 radio helper
|
||||||
|
+ SSD1306 display pipe).
|
||||||
|
- AXP192 init added to `src/driver/axp.rs`: DCDC1 = 3.3 V for the
|
||||||
|
OLED, LDO2 = 3.3 V for the SX1276 PA_BOOST rail. Shares the
|
||||||
|
OLED I²C bus (SDA=21, SCL=22); the I²C instance is built in
|
||||||
|
blocking mode for PMIC init and converted to async for the
|
||||||
|
display task.
|
||||||
|
- SX127x support via lora-phy 3's `Sx127x<Sx1276>` +
|
||||||
|
`GenericSx127xInterfaceVariant` (DIO0 as the sole IRQ; no
|
||||||
|
BUSY line on SX127x). XTAL clocked (no TCXO), `tx_boost = true`
|
||||||
|
for the PA_BOOST path.
|
||||||
|
- `LoRaBoard` trait gains `supported_sf_bitmap()` and
|
||||||
|
`supported_bw_bitmap()` methods with SX126x defaults
|
||||||
|
(`0x1FE0`, `0x03FF`). T-Beam classic overrides SF to `0x1FC0`
|
||||||
|
(SF6–SF12) — the SX1276 has no SF5.
|
||||||
|
- `radio_chip_id()` reports `0x0011` (`RadioChipId::Sx1276`) and
|
||||||
|
`freq_range_hz()` widens to 137 MHz–1020 MHz per the SX1276
|
||||||
|
datasheet.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- `Cargo.toml`: the `esp32s3` chip feature has been moved out of
|
||||||
|
the `esp-hal` / `esp-rtos` / `esp-backtrace` / `esp-println`
|
||||||
|
dependency lines and into each ESP board's feature list (as
|
||||||
|
`esp-hal/esp32s3`, etc.). This lets `lilygo_tbeam` select the
|
||||||
|
classic `esp32` chip flavour from the same dependency set.
|
||||||
|
|
||||||
|
## [1.2.1] - 2026-04-23
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **Radio no longer drops to Idle mid-RX-session on transient failures.**
|
||||||
|
Extends the v1.1.0 invariant ("`SET_CONFIG` during RX auto-re-arms")
|
||||||
|
to all failure paths that can occur while a client has RX active:
|
||||||
|
- `Receiving.on(RxFailed) => Idle` now transitions to `ResumingRx`
|
||||||
|
instead of `Idle`. A transient `next_packet` error re-arms the chip
|
||||||
|
rather than ending the RX session.
|
||||||
|
- `TransmittingFromRx.on(TransmitFailed) => Idle` now transitions to
|
||||||
|
`ResumingRx`. TX failure during an active RX session returns
|
||||||
|
`TX_FAILED` to the caller and resumes RX, matching the shape of the
|
||||||
|
adjacent `ChannelBusy` handler.
|
||||||
|
- `ResumingRx.on(StartRxFailed) => Idle` is now a bounded retry
|
||||||
|
(5 × 25 ms backoff via a new `bump_resume_retries` action and a
|
||||||
|
`ResumeRxRetry` / `ResumeRxExhausted` pair of internal events).
|
||||||
|
The chip only lands in `Idle` after exhausting the retry budget —
|
||||||
|
which means it's genuinely wedged and needs reconfiguring.
|
||||||
|
|
||||||
|
Visible effect: the OLED dashboard no longer bounces to Splash when
|
||||||
|
`ai-bot` (or any other client that TXes while RX is running) sends a
|
||||||
|
packet. Previously any of the three transitions above would fire
|
||||||
|
`RadioEvent::Idle`, which the display uses as its `Dashboard → Splash`
|
||||||
|
trigger; the radio would land in `Idle` with no client aware enough
|
||||||
|
to re-send `RX_START`, stranding the dashboard until the session was
|
||||||
|
manually torn down and restarted.
|
||||||
|
|
||||||
|
- Removed `announce_rx_failed` helper action: its only purpose was
|
||||||
|
emitting `RadioEvent::Idle` on the now-deleted `Receiving.on(RxFailed)
|
||||||
|
=> Idle` edge. No replacement needed — `ResumingRx` handles recovery
|
||||||
|
silently.
|
||||||
|
|
||||||
|
## [1.2.0] - 2026-04-22
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **OLED auto-dim after inactivity.** The display is bright on any
|
||||||
|
activity — splash entry, mode change into a dashboard leaf, packet
|
||||||
|
RX/TX — and dims after a short idle window. Splash holds bright for
|
||||||
|
10 s, then drops to dim while the LearnMore / Info carousel keeps
|
||||||
|
alternating. The dashboard stays bright for 3 s after the last
|
||||||
|
packet or mode change, then dims in place without changing
|
||||||
|
Listening/Transmitting state. Cross-leaf TX↔RX hops transition
|
||||||
|
bright→bright with no intermediate dim — `set_dim` is fired as an
|
||||||
|
action by the 3 s timer handler rather than as a sibling `exit:`
|
||||||
|
action, so mode-change leaf-crossings never see a flash.
|
||||||
|
- `DisplayBrightness` trait in `src/driver/mod.rs` unifies the
|
||||||
|
two-level backlight API across the `ssd1306` crate's `Ssd1306Async`
|
||||||
|
and the in-tree `Sh1106` driver. Contrast values centralized as
|
||||||
|
`CONTRAST_BRIGHT` (`0xFF`) and `CONTRAST_DIM` (`0x08`) constants so
|
||||||
|
the two levels can be tuned in one place.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- `src/display.rs` statechart restructured:
|
||||||
|
- `Splash` gains `entry: set_bright` plus an action-only
|
||||||
|
`on(after 10s) => set_dim` handler so the splash bright window
|
||||||
|
is time-scoped without disturbing the LM↔Info alternation.
|
||||||
|
- `Dashboard` `Listening` and `Transmitting` each gain a nested
|
||||||
|
`*Bright` substate owning the `entry: set_bright` action. Packet
|
||||||
|
handlers moved into the leaves and target the leaf's `*Bright` —
|
||||||
|
sibling-to-sibling within the leaf restarts the 3 s timer without
|
||||||
|
re-entering the leaf itself (paint / 1 s sparkline timer
|
||||||
|
preserved). The 3 s timeout handler fires `set_dim` as an action
|
||||||
|
and up-transitions to the leaf, parking the machine at the leaf
|
||||||
|
with no active child.
|
||||||
|
- `On.on(CmdReset)` now targets `LearnMore` directly rather than
|
||||||
|
`Splash`. Under hsmc 0.2 up-transition semantics, targeting
|
||||||
|
`Splash` from within `Splash.LearnMore` (USB-disconnect race path)
|
||||||
|
would only unwind below `Splash` and park with no active child,
|
||||||
|
leaving the LM↔Info alternation timer dead and the QR frozen on
|
||||||
|
screen.
|
||||||
|
- `LoRaBoard::DisplayDriver` trait bound extended with
|
||||||
|
`+ DisplayBrightness` so brightness is part of the board contract
|
||||||
|
alongside `DrawTarget<Color = BinaryColor>`.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Elecrow ThinkNode-M2 board doc corrected: the shipping revision
|
||||||
|
carries a WCH CH340K USB-UART bridge (USB `1a86:7522`), not a
|
||||||
|
Silicon Labs CP2102. `just flash elecrow_thinknode_m2` now matches
|
||||||
|
the CH340K / CH340 VID:PIDs directly instead of falling through
|
||||||
|
to espflash auto-detect.
|
||||||
|
|
||||||
|
### Dependencies
|
||||||
|
|
||||||
|
- `hsmc` bumped to `0.2` to pick up the up-transition fix (transition
|
||||||
|
to an already-active ancestor unwinds only the subtree strictly
|
||||||
|
below the target; no exit/entry on the target; no default descent).
|
||||||
|
|
||||||
|
## [1.1.0] - 2026-04-22
|
||||||
|
|
||||||
|
Small but high-value behavior change: `SET_CONFIG` issued while the
|
||||||
|
radio is in continuous RX no longer drops the radio (and the OLED)
|
||||||
|
back to idle. The radio now auto-re-arms RX with the new modulation
|
||||||
|
once reconfiguration completes, matching DongLoRa Protocol 1.1's
|
||||||
|
§3.5 / §6.3 amendments.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- `src/radio.rs`: inside `state Receiving`, add `on(ConfigApplied)
|
||||||
|
=> ResumingRx` to shadow the parent `Configured` state's
|
||||||
|
`on(ConfigApplied) => Idle` handler. The reconfigure path now
|
||||||
|
runs `apply_set_config` → `ConfigApplied` → `ResumingRx.entry:
|
||||||
|
try_start_rx_hw` → `StartRxOk` → `Receiving` without ever
|
||||||
|
entering `Idle`.
|
||||||
|
- The OLED's `Dashboard → Splash` transition was previously driven
|
||||||
|
by `RadioEvent::Idle` firing on `Configured.Idle.entry`. That
|
||||||
|
event no longer fires during a SET_CONFIG-from-Receiving
|
||||||
|
sequence, so the display stays on Dashboard.
|
||||||
|
|
||||||
|
### Dependencies
|
||||||
|
|
||||||
|
- `donglora-protocol` bumped to `1.1.0` (spec-only change; matches
|
||||||
|
the new normative text the firmware now implements).
|
||||||
|
|
||||||
|
### Use-case unlock
|
||||||
|
|
||||||
|
- Scanner / time-division / dynamic-modulation workflows that
|
||||||
|
rotate LoRa parameters mid-RX no longer need to re-issue
|
||||||
|
`RX_START` after every `SET_CONFIG`. One call, radio continues
|
||||||
|
to receive.
|
||||||
|
|
||||||
|
## [1.0.0] - 2026-04-22
|
||||||
|
|
||||||
|
The 1.0 release. Firmware now speaks DongLoRa Protocol v2 end to end,
|
||||||
|
depends on the freshly-published `donglora-protocol` 1.0.0 and `hsmc`
|
||||||
|
0.1.0 from crates.io, and ships GitHub Release binaries for every
|
||||||
|
supported board.
|
||||||
|
|
||||||
|
### Breaking
|
||||||
|
|
||||||
|
- **DongLoRa Protocol v2 wire format** (wire-incompatible with 0.x
|
||||||
|
clients and firmware). Every `type_id`, tag-correlation rule, and
|
||||||
|
field layout follows `PROTOCOL.md` v1.0.
|
||||||
|
- **Host framing replaced.** `src/host/framing.rs` now drives a
|
||||||
|
streaming `FrameDecoder` from `donglora-protocol` and emits
|
||||||
|
`(type_id, tag, payload)` frames; the 0.x COBS-per-response shim is
|
||||||
|
gone.
|
||||||
|
- **Radio task reshape.** `src/radio.rs` emits v2 `TX_DONE` /
|
||||||
|
`SetConfigResult` / `RxPayload` / `RxOrigin` shapes directly; no 0.x
|
||||||
|
translation layer remains.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- `hsmc` switched from path-dep on the local workspace to
|
||||||
|
`version = "0.1.0"` from crates.io.
|
||||||
|
- `donglora-protocol` switched from path-dep on `../protocol` to
|
||||||
|
`version = "1.0.0"` from crates.io; firmware builds are now
|
||||||
|
reproducible from a clean checkout with no sibling repo required.
|
||||||
|
- `PROTOCOL.md` updated to the DongLoRa Protocol v2 normative spec,
|
||||||
|
kept byte-for-byte in sync with the `donglora-protocol` crate's
|
||||||
|
copy.
|
||||||
|
|
||||||
|
### Infrastructure
|
||||||
|
|
||||||
|
- Cargo.toml `repository` + `homepage` now point at the canonical
|
||||||
|
`https://git.swaits.com/donglora/firmware` (the `swaits/donglora`
|
||||||
|
repo is archived).
|
||||||
|
|
||||||
|
## [0.5.0] - 2026-04-16
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- BLE soft-mesh multiplexer design document (`BLE_MUX_DESIGN_DOC.md`) —
|
||||||
|
specification for turning a dongle from a 1:1 USB device into a shared
|
||||||
|
near-field radio gateway for multiple BLE-attached phones. Three-phase
|
||||||
|
rollout (v1 MVP, v2 hardening, v3 multi-dongle mesh) with regulatory
|
||||||
|
duty-cycle and TX-access-control enforcement from v1. Targets nRF52840
|
||||||
|
first; ESP32-S3 reserved for v2.
|
||||||
|
- `debug-checkpoint` cargo feature with a dedicated `debug_blink` task and
|
||||||
|
a latching atomic checkpoint — LED-pattern diagnostic for UART-only
|
||||||
|
boards where defmt output would corrupt the host protocol. Zero
|
||||||
|
footprint when the feature is off.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Radio task rewritten as an `hsmc` statechart (`Unconfigured` /
|
||||||
|
`Configured.{Idle, StartingRx, Receiving, ResumingRx,
|
||||||
|
Transmitting.{FromIdle, FromRx}}`). The main loop is now a single
|
||||||
|
`machine.run().await`; command reading, packet RX, and TX are
|
||||||
|
state-scoped `during:` activities. Post-TX RX re-arm is modeled as
|
||||||
|
state identity (`TransmittingFromRx` → `ResumingRx`) instead of a
|
||||||
|
`was_receiving` flag.
|
||||||
|
- Display task rewritten as an `hsmc` statechart (`Off` /
|
||||||
|
`On.{Splash.{LearnMore, Info}, Dashboard.{Listening, Transmitting}}`).
|
||||||
|
The 5 s splash alternation and 1 s sparkline tick use `on(after …)` /
|
||||||
|
`on(every …)` transitions — no external timer task.
|
||||||
|
- USB host task now carries a small `hsmc` statechart (`Disconnected` /
|
||||||
|
`Connected`) whose entry/exit actions handle display wake, radio
|
||||||
|
`StopRx`, and display reset — replacing the open-coded `was_connected`
|
||||||
|
edge detector.
|
||||||
|
- Radio → Display channel switched from a `RadioStatus` watch to a typed
|
||||||
|
`RadioEvent` queue. The radio emits discrete `EnteredRx` / `EnteredTx`
|
||||||
|
/ `Idle` / `PacketRx` / `PacketTx` / `ConfigChanged` events; downstream
|
||||||
|
consumers observe transitions directly rather than diffing snapshots.
|
||||||
|
- Wire protocol types and COBS framing moved out of firmware into the
|
||||||
|
shared `donglora-protocol` crate (path dep on `../protocol`). Firmware
|
||||||
|
`src/protocol.rs` is now a re-export shim preserving every call site;
|
||||||
|
`src/host/framing.rs` keeps only the embassy-specific `route_command`.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- USB disconnect recovery: on a read error the DTR-edge-detect branch
|
||||||
|
now runs unconditionally (was early-continuing in some paths, starving
|
||||||
|
the display-reset / radio-stop handoff).
|
||||||
|
|
||||||
|
## [0.4.0] - 2026-04-11
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- ELECROW ThinkNode-M2 board support (ESP32-S3 + SX1262 + 1.3" SH1106 OLED
|
||||||
|
via CP2102 UART bridge, 1000 mAh Li-Po handheld)
|
||||||
|
- Pin map verified against Meshtastic's
|
||||||
|
`variants/esp32s3/ELECROW-ThinkNode-M2`
|
||||||
|
- DIO3 TCXO driven at 3.3 V (not 1.8 V like Heltec)
|
||||||
|
- Dedicated `LORA_POWER_EN` (GPIO48) sequenced HIGH before SPI init
|
||||||
|
- `VEXT_ENABLE` (GPIO46) is active HIGH — opposite polarity from Heltec
|
||||||
|
- Reuses the existing async SH1106 driver at I2C address 0x3C
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- `hal::esp32s3::{init_spi, init_i2c}` and `board::esp32s3::init_radio` are
|
||||||
|
now generic over GPIO type, so ESP32-S3 boards with different pin
|
||||||
|
assignments can share the helpers.
|
||||||
|
- `board::esp32s3::init_radio` takes a `TcxoCtrlVoltage` parameter instead
|
||||||
|
of hardcoding `Ctrl1V8` — needed for ThinkNode-M2's 3.3 V TCXO.
|
||||||
|
- SSD1306 display items in `board::esp32s3` are now cfg-gated to Heltec
|
||||||
|
boards (ThinkNode-M2 supplies its own SH1106 `create_display`).
|
||||||
|
- `justfile` uses `cargo +esp` for Xtensa targets instead of `cargo +nightly`
|
||||||
|
with a `RUSTC=esp` override — the self-contained esp toolchain keeps its
|
||||||
|
cargo, rustc, and rust-src versions aligned. Public nightly's rust-src
|
||||||
|
had drifted ahead of the esp fork, breaking `-Zbuild-std`.
|
||||||
|
- Migrated to embassy-executor 0.10 and embassy-rp 0.10
|
||||||
|
- ESP crates (esp-hal, esp-rtos, esp-backtrace, esp-println) patched from
|
||||||
|
esp-rs/esp-hal main branch until esp-rtos publishes with 0.10 support
|
||||||
|
- Adapted to embassy-executor 0.10 SpawnToken API (task functions now return
|
||||||
|
`Result<SpawnToken, SpawnError>`, `Spawner::spawn` no longer returns Result)
|
||||||
|
- Adapted to esp-hal main branch API changes: `efuse::base_mac_address()`,
|
||||||
|
`esp_rtos::start` now requires `SoftwareInterrupt`, embassy-rp SPI
|
||||||
|
constructor now requires DMA interrupt bindings
|
||||||
|
- `just check` now runs clippy with `-D warnings` instead of plain `cargo check`
|
||||||
|
- Rust toolchain bumped to 1.94 in mise.toml
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- `core::mem::forget(output)` pattern in ESP32-S3 board files is now
|
||||||
|
`let _vext = Output::new(...)` — current esp-hal makes `Output` non-Drop,
|
||||||
|
which made `forget` a no-op and tripped clippy's `forget_non_drop` lint.
|
||||||
|
- UART boards (heltec_v3_uart) display stuck on splash screen after boot.
|
||||||
|
Host task sent `DisplayCommand::Reset` (which sets disconnected=true) instead
|
||||||
|
of `DisplayCommand::On`. UART has no DTR, so nothing ever cleared it.
|
||||||
|
|
||||||
|
## [0.3.0] - 2026-04-10
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Waveshare RP2040-LoRa board support (RP2040 + SX1262, no OLED, LED on GPIO25)
|
||||||
|
- First RP2040 board — third MCU family after ESP32-S3 and nRF52840
|
||||||
|
- New `hal/rp2040.rs` module with MCU primitives (SPI, USB, flash unique ID)
|
||||||
|
- USB-C via FPC ribbon cable to adapter board
|
||||||
|
- PE4259 antenna switch on GPIO17, 850-930 MHz (HF)
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- `cortex-m-rt/set-vtor` moved from global dependency to per-board feature
|
||||||
|
(Cortex-M0+ has no VTOR register)
|
||||||
|
- `build.rs` memory.x selection is now target-aware (thumbv6m vs thumbv7em)
|
||||||
|
|
||||||
|
## [0.2.1] - 2026-04-07
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Display stuck on status screen after USB disconnect (stale radio status
|
||||||
|
updates re-rendered dashboard over splash because `disconnected` flag was
|
||||||
|
not set in the `Reset` handler)
|
||||||
|
- Radio left running after host disconnect, now sends `StopRx` when DTR drops
|
||||||
|
- System deadlock when host app closes serial port while radio is receiving
|
||||||
|
(unsolicited responses in radio task now use `try_send` to avoid blocking
|
||||||
|
on a full response channel)
|
||||||
|
- LED orphaned if OLED display initialization fails (display task now falls
|
||||||
|
back to a LED-only event loop instead of returning early)
|
||||||
|
|
||||||
|
## [0.2.0] - 2026-04-07
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Wio Tracker L1 board support (nRF52840 + SX1262, SH1106 OLED)
|
||||||
|
- LED blink on packet activity: brief flash on RX and TX when display is on
|
||||||
|
- RX brightness scales with SNR (stronger signal = brighter)
|
||||||
|
- Works on all boards with a user LED (Heltec V3/V4, Wio Tracker)
|
||||||
|
- Native USB CDC-ACM support for Heltec V3 (requires hardware mod: solder R29/R3)
|
||||||
|
- `heltec_v3_uart` feature for stock (unmodified) V3 boards via CP2102 bridge
|
||||||
|
- `RgbLed` trait and `SimpleLed` driver for GPIO-based LEDs
|
||||||
|
- `LoRaBoard` trait with full associated types (`RadioParts`, `CommParts`,
|
||||||
|
`DisplayParts`, `DisplayDriver`, `LedDriver`) and `BoardParts<R, C, D, L>` struct
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Major firmware architecture reorganization:
|
||||||
|
- `hal/` module for MCU-specific primitives (ESP32-S3, nRF52840)
|
||||||
|
- `driver/` module for hardware peripheral drivers (SH1106, SimpleLed)
|
||||||
|
- `host/` module for unified USB/UART communication with internal cfg dispatch
|
||||||
|
- `board/` contains only board definitions — no MCU helpers or drivers mixed in
|
||||||
|
- Board files are declarative wiring diagrams calling hal primitives
|
||||||
|
- `run()` function has zero board-specific conditional compilation
|
||||||
|
- Display init moved to board layer (`create_display()`) — display task has no cfg
|
||||||
|
- Flattened `radio/` and `display/` directories (no single-file directories)
|
||||||
|
- `build.rs` auto-discovers boards by content (implements `LoRaBoard`) and
|
||||||
|
helper modules by `use super::` imports — no exclusion lists
|
||||||
|
- `check` and `clippy` commands now use `--release` profile
|
||||||
|
- Deduplicated USB/UART protocol framing (shared `CobsDecoder` + `route_command`)
|
||||||
|
- Removed `ErrorCode::CrcError` (SX1262 silently drops bad-CRC packets)
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Display stuck on splash screen for UART boards (disconnected flag started true)
|
||||||
|
|
||||||
|
## [0.1.0] - 2026-03-15
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Transparent LoRa radio over USB CDC-ACM (VID `1209`, PID `5741`)
|
||||||
|
- COBS-framed fixed-size LE binary protocol (9 commands, 7 responses)
|
||||||
|
- Radio configuration: frequency, bandwidth, spreading factor, coding rate, TX power
|
||||||
|
- Continuous RX mode with RSSI/SNR per packet
|
||||||
|
- Single-shot TX with optional per-packet config override
|
||||||
|
- SSD1306 OLED display support with status dashboard
|
||||||
|
- Radio state, frequency, modulation parameters
|
||||||
|
- Packet counters (RX/TX)
|
||||||
|
- RSSI sparkline (1-minute history, TX shown as dotted bars)
|
||||||
|
- Combined splash/waiting screen on disconnect
|
||||||
|
- Auto-detect USB host connect/disconnect via DTR
|
||||||
|
- Config validation against per-board hardware limits
|
||||||
|
- `TX_POWER_MAX` sentinel (`-128`): auto-use the board's maximum TX power
|
||||||
|
- Board support: Heltec V3, Heltec V4, RAK WisBlock 4631
|
||||||
|
- Python host library (`donglora` on PyPI) with `pip install` and `donglora-mux` CLI
|
||||||
|
- USB multiplexer for sharing one dongle with multiple applications
|
||||||
|
- Two-way LoRa bridge over TCP (`examples/lora_bridge.py`)
|
||||||
|
|
||||||
|
### Technical
|
||||||
|
|
||||||
|
- Embassy async runtime (no threads, CPU sleeps when idle)
|
||||||
|
- Zero-config: boots and waits for host commands
|
||||||
|
- Protocol-agnostic: firmware has no opinion about what you transmit
|
||||||
|
- Tasks never panic: all errors reported to host or logged
|
||||||
|
- Board abstraction: one `.rs` file + one Cargo feature per board
|
||||||
Generated
+1185
-123
File diff suppressed because it is too large.
Load diff
+262
-20
@@ -3,98 +3,334 @@ members = ["."]
|
|||||||
|
|
||||||
[package]
|
[package]
|
||||||
name = "donglora"
|
name = "donglora"
|
||||||
version = "0.1.0"
|
version = "1.5.2"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
build = "build.rs"
|
build = "build.rs"
|
||||||
authors = ["Stephen Waits <steve@waits.net>"]
|
authors = ["Stephen Waits <steve@waits.net>"]
|
||||||
license = "MIT"
|
license = "MIT"
|
||||||
description = "Transparent LoRa radio over USB — plug in a board, talk LoRa from your host"
|
description = "Transparent LoRa radio over USB — plug in a board, talk LoRa from your host"
|
||||||
repository = "https://github.com/swaits/donglora"
|
repository = "https://git.swaits.com/donglora/firmware"
|
||||||
|
homepage = "https://git.swaits.com/donglora/firmware"
|
||||||
keywords = ["lora", "radio", "embedded", "usb", "firmware"]
|
keywords = ["lora", "radio", "embedded", "usb", "firmware"]
|
||||||
categories = ["embedded", "hardware-support", "no-std"]
|
categories = ["embedded", "hardware-support", "no-std"]
|
||||||
|
|
||||||
# UPGRADE BLOCKED: embassy-executor 0.10 and ucobs 0.3 need Rust >= 1.93
|
|
||||||
# stable. The ESP Xtensa toolchain (espup) is a nightly fork that may not
|
|
||||||
# support the required language features or pass MSRV gating. Revisit when
|
|
||||||
# espup catches up. Check: rustup run esp rustc --version
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
cfg-if = "1.0"
|
cfg-if = "1.0"
|
||||||
critical-section = "1.2"
|
critical-section = "1.2"
|
||||||
defmt = "1.0"
|
defmt = "0.3"
|
||||||
|
embedded-hal = "1.0"
|
||||||
embedded-hal-async = "1.0"
|
embedded-hal-async = "1.0"
|
||||||
embassy-embedded-hal = "0.6"
|
embassy-embedded-hal = "0.6"
|
||||||
embassy-executor = "0.9"
|
embassy-executor = "0.10"
|
||||||
embassy-sync = "0.8"
|
embassy-sync = "0.8"
|
||||||
embassy-time = "0.5"
|
embassy-time = "0.5"
|
||||||
embassy-usb = { version = "0.6", features = ["defmt"] }
|
embassy-usb = { version = "0.6", features = ["defmt"] }
|
||||||
embassy-usb-driver = "0.2"
|
embassy-usb-driver = "0.2"
|
||||||
ucobs = { version = "0.3", features = ["legacy-msrv"] }
|
ucobs = { version = "0.3", features = ["legacy-msrv"] }
|
||||||
heapless = "0.9"
|
heapless = "0.9"
|
||||||
lora-phy = "3.0"
|
donglora-protocol = "1"
|
||||||
|
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"
|
static_cell = "2.1"
|
||||||
|
|
||||||
# Display rendering
|
# Display rendering
|
||||||
display-interface = "0.5"
|
display-interface = "0.5"
|
||||||
embedded-graphics = "0.8"
|
embedded-graphics = "0.8"
|
||||||
ssd1306 = { version = "0.10", features = ["async"] }
|
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
|
# Async IO traits and combinators
|
||||||
embedded-io-async = "0.7"
|
embedded-io-async = "0.7"
|
||||||
embassy-futures = "0.1"
|
embassy-futures = "0.1"
|
||||||
|
|
||||||
# ESP32-S3 (Heltec V3) — requires esp toolchain (espup)
|
# Xtensa ESP crates — requires esp toolchain (espup). The chip feature
|
||||||
esp-backtrace = { version = "0.18", optional = true, features = [
|
# (esp32 / esp32s3) is selected per board via the board-level feature flag
|
||||||
"esp32s3",
|
# below, so the same dep lines support both classic ESP32 and ESP32-S3.
|
||||||
|
esp-backtrace = { version = "0.19", optional = true, features = [
|
||||||
"panic-handler",
|
"panic-handler",
|
||||||
"defmt",
|
"defmt",
|
||||||
] }
|
] }
|
||||||
esp-hal = { version = "1.0", optional = true, features = [
|
esp-hal = { version = "1.1", optional = true, features = [
|
||||||
"esp32s3",
|
|
||||||
"unstable",
|
"unstable",
|
||||||
] }
|
] }
|
||||||
esp-println = { version = "0.16", optional = true, features = [
|
# `default-features = false` strips `auto` + `colors`. The output target
|
||||||
"esp32s3",
|
# (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",
|
"defmt-espflash",
|
||||||
] }
|
] }
|
||||||
esp-rtos = { version = "0.2", optional = true, features = [
|
esp-rtos = { version = "0.3", optional = true, features = [
|
||||||
"esp32s3",
|
|
||||||
"embassy",
|
"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)
|
# nRF52840 (RAK 4631)
|
||||||
|
cortex-m = { version = "0.7", optional = true, features = [
|
||||||
|
"critical-section-single-core",
|
||||||
|
] }
|
||||||
cortex-m-rt = { version = "0.7", optional = true }
|
cortex-m-rt = { version = "0.7", optional = true }
|
||||||
|
defmt-rtt = { version = "0.4", optional = true }
|
||||||
embassy-nrf = { version = "0.10", optional = true, features = [
|
embassy-nrf = { version = "0.10", optional = true, features = [
|
||||||
"nrf52840",
|
"nrf52840",
|
||||||
"gpiote",
|
"gpiote",
|
||||||
"time-driver-rtc1",
|
"time-driver-rtc1",
|
||||||
"defmt",
|
"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"] }
|
panic-probe = { version = "1.0", optional = true, features = ["print-defmt"] }
|
||||||
|
|
||||||
|
# RP2040 (Waveshare RP2040-LoRa)
|
||||||
|
embassy-rp = { version = "0.10", optional = true, features = [
|
||||||
|
"rp2040",
|
||||||
|
"time-driver",
|
||||||
|
"defmt",
|
||||||
|
] }
|
||||||
|
|
||||||
[build-dependencies]
|
[build-dependencies]
|
||||||
minijinja = "2.18"
|
minijinja = "2.18"
|
||||||
owo-colors = "4.3"
|
owo-colors = "4.3"
|
||||||
|
|
||||||
[features]
|
[features]
|
||||||
|
# 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
|
||||||
|
# host protocol on the same UART. The LED flashes N times then pauses —
|
||||||
|
# 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 = [
|
heltec_v3 = [
|
||||||
|
"embassy",
|
||||||
|
"trace-defmt",
|
||||||
"dep:esp-backtrace",
|
"dep:esp-backtrace",
|
||||||
|
"dep:esp-bootloader-esp-idf",
|
||||||
"dep:esp-hal",
|
"dep:esp-hal",
|
||||||
"dep:esp-println",
|
"dep:esp-println",
|
||||||
"dep:esp-rtos",
|
"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 = [
|
heltec_v4 = [
|
||||||
|
"embassy",
|
||||||
|
"trace-defmt",
|
||||||
"dep:esp-backtrace",
|
"dep:esp-backtrace",
|
||||||
|
"dep:esp-bootloader-esp-idf",
|
||||||
"dep:esp-hal",
|
"dep:esp-hal",
|
||||||
"dep:esp-println",
|
"dep:esp-println",
|
||||||
"dep:esp-rtos",
|
"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 = [
|
rak_wisblock_4631 = [
|
||||||
|
"embassy",
|
||||||
|
"trace-defmt",
|
||||||
|
"dep:cortex-m",
|
||||||
"dep:cortex-m-rt",
|
"dep:cortex-m-rt",
|
||||||
|
"cortex-m-rt/set-vtor",
|
||||||
|
"dep:defmt-rtt",
|
||||||
"dep:embassy-nrf",
|
"dep:embassy-nrf",
|
||||||
"dep:panic-probe",
|
"dep:panic-probe",
|
||||||
"embassy-executor/arch-cortex-m",
|
"embassy-executor/platform-cortex-m",
|
||||||
"embassy-executor/executor-thread",
|
"embassy-executor/executor-thread",
|
||||||
]
|
]
|
||||||
|
wio_tracker_l1 = [
|
||||||
|
"embassy",
|
||||||
|
"trace-defmt",
|
||||||
|
"dep:cortex-m",
|
||||||
|
"dep:cortex-m-rt",
|
||||||
|
"cortex-m-rt/set-vtor",
|
||||||
|
"dep:defmt-rtt",
|
||||||
|
"dep:embassy-nrf",
|
||||||
|
"dep:panic-probe",
|
||||||
|
"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",
|
||||||
|
"embassy-executor/platform-cortex-m",
|
||||||
|
"embassy-executor/executor-thread",
|
||||||
|
]
|
||||||
|
|
||||||
|
[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]
|
[lints.clippy]
|
||||||
undocumented_unsafe_blocks = "deny"
|
undocumented_unsafe_blocks = "deny"
|
||||||
@@ -102,4 +338,10 @@ undocumented_unsafe_blocks = "deny"
|
|||||||
[profile.release]
|
[profile.release]
|
||||||
codegen-units = 1
|
codegen-units = 1
|
||||||
lto = "fat"
|
lto = "fat"
|
||||||
opt-level = 3 # "s"/"z" break Xtensa (l32r literal range exceeded)
|
opt-level = 3 # "s"/"z" break Xtensa (l32r literal range exceeded)
|
||||||
|
panic = "abort"
|
||||||
|
# `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
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2026 Stephen Waits <steve@waits.net>
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
-291
@@ -1,291 +0,0 @@
|
|||||||
# DongLoRa USB Protocol
|
|
||||||
|
|
||||||
DongLoRa communicates over USB CDC-ACM (virtual serial port).
|
|
||||||
No baud rate configuration needed.
|
|
||||||
|
|
||||||
**USB identifiers:** VID `1209`, PID `5741`
|
|
||||||
|
|
||||||
## Framing
|
|
||||||
|
|
||||||
Each message is [COBS](https://en.wikipedia.org/wiki/Consistent_Overhead_Byte_Stuffing)-encoded
|
|
||||||
and terminated with a `0x00` sentinel byte:
|
|
||||||
|
|
||||||
```
|
|
||||||
[COBS-encoded bytes] [0x00]
|
|
||||||
```
|
|
||||||
|
|
||||||
COBS guarantees `0x00` never appears in the encoded data, so the
|
|
||||||
sentinel unambiguously marks frame boundaries. USB packets are
|
|
||||||
chunked at 64 bytes.
|
|
||||||
|
|
||||||
## Encoding
|
|
||||||
|
|
||||||
All integers are **fixed-size little-endian**. No variable-length
|
|
||||||
encoding. Every field has a known size at a known offset.
|
|
||||||
|
|
||||||
| Type | Size | Encoding |
|
|
||||||
|------|------|----------|
|
|
||||||
| `u8` | 1 | Raw byte |
|
|
||||||
| `i8` | 1 | Raw byte (two's complement) |
|
|
||||||
| `u16` | 2 | Little-endian |
|
|
||||||
| `i16` | 2 | Little-endian (two's complement) |
|
|
||||||
| `u32` | 4 | Little-endian |
|
|
||||||
|
|
||||||
## RadioConfig (13 bytes)
|
|
||||||
|
|
||||||
| Offset | Size | Field | Type | Valid range |
|
|
||||||
|--------|------|-------|------|-------------|
|
|
||||||
| 0 | 4 | `freq_hz` | u32 LE | 150,000,000 – 960,000,000 |
|
|
||||||
| 4 | 1 | `bw` | u8 | 0–9 (see bandwidth table) |
|
|
||||||
| 5 | 1 | `sf` | u8 | 5–12 |
|
|
||||||
| 6 | 1 | `cr` | u8 | 5–8 (coding rate denominator) |
|
|
||||||
| 7 | 2 | `sync_word` | u16 LE | any |
|
|
||||||
| 9 | 1 | `tx_power_dbm` | i8 | board-dependent, or `-128` for max |
|
|
||||||
| 10 | 2 | `preamble_len` | u16 LE | 6–65535, or `0` for default (16) |
|
|
||||||
| 12 | 1 | `cad` | u8 | 0 = disabled, non-zero = enabled |
|
|
||||||
|
|
||||||
Set `tx_power_dbm` to `-128` (0x80) to use the board's maximum
|
|
||||||
TX power. The firmware resolves this to the actual hardware max.
|
|
||||||
|
|
||||||
Set `preamble_len` to `0` to use the firmware default of 16 symbols.
|
|
||||||
Longer preambles improve reliability in mesh/noisy environments at
|
|
||||||
the cost of slightly longer air time.
|
|
||||||
|
|
||||||
Set `cad` to `1` (or any non-zero value) to enable Channel Activity
|
|
||||||
Detection (listen-before-talk) before each transmission. The radio
|
|
||||||
checks for channel activity and waits for a clear channel before
|
|
||||||
transmitting. Set to `0` to disable. Default: enabled.
|
|
||||||
|
|
||||||
## Commands (Host → Firmware)
|
|
||||||
|
|
||||||
Each command is a tag byte followed by fixed-size fields.
|
|
||||||
|
|
||||||
| Tag | Command | Payload |
|
|
||||||
|-----|---------|---------|
|
|
||||||
| 0 | Ping | — |
|
|
||||||
| 1 | GetConfig | — |
|
|
||||||
| 2 | SetConfig | RadioConfig (10 bytes) |
|
|
||||||
| 3 | StartRx | — |
|
|
||||||
| 4 | StopRx | — |
|
|
||||||
| 5 | Transmit | has_config (1) + [RadioConfig if 1] + len (u16 LE) + payload |
|
|
||||||
| 6 | DisplayOn | — |
|
|
||||||
| 7 | DisplayOff | — |
|
|
||||||
| 8 | GetMac | — |
|
|
||||||
|
|
||||||
## Responses (Firmware → Host)
|
|
||||||
|
|
||||||
| Tag | Response | Payload |
|
|
||||||
|-----|----------|---------|
|
|
||||||
| 0 | Pong | — |
|
|
||||||
| 1 | Config | RadioConfig (10 bytes) |
|
|
||||||
| 2 | RxPacket | rssi (i16 LE) + snr (i16 LE) + len (u16 LE) + payload |
|
|
||||||
| 3 | TxDone | — |
|
|
||||||
| 4 | Ok | — |
|
|
||||||
| 5 | Error | code (u8) |
|
|
||||||
| 6 | MacAddress | 6 raw bytes (board's MAC/device address) |
|
|
||||||
|
|
||||||
## Bandwidth
|
|
||||||
|
|
||||||
| Value | Bandwidth |
|
|
||||||
|-------|-----------|
|
|
||||||
| 0 | 7.8 kHz |
|
|
||||||
| 1 | 10.4 kHz |
|
|
||||||
| 2 | 15.6 kHz |
|
|
||||||
| 3 | 20.8 kHz |
|
|
||||||
| 4 | 31.25 kHz |
|
|
||||||
| 5 | 41.7 kHz |
|
|
||||||
| 6 | 62.5 kHz |
|
|
||||||
| 7 | 125 kHz |
|
|
||||||
| 8 | 250 kHz |
|
|
||||||
| 9 | 500 kHz |
|
|
||||||
|
|
||||||
## Error Codes
|
|
||||||
|
|
||||||
| Value | Error | Meaning |
|
|
||||||
|-------|-------|---------|
|
|
||||||
| 0 | InvalidConfig | Radio config validation failed |
|
|
||||||
| 1 | RadioBusy | Radio busy (e.g. RX restart failed) |
|
|
||||||
| 2 | TxTimeout | Transmission timed out |
|
|
||||||
| 3 | CrcError | CRC error on received packet |
|
|
||||||
| 4 | NotConfigured | SetConfig required first |
|
|
||||||
| 5 | NoDisplay | No display attached |
|
|
||||||
|
|
||||||
## Worked Example
|
|
||||||
|
|
||||||
Configure for 915 MHz, 125 kHz BW, SF7, CR 4/5, max TX power, default preamble, CAD on:
|
|
||||||
|
|
||||||
### 1. Build the bytes
|
|
||||||
|
|
||||||
```python
|
|
||||||
import struct
|
|
||||||
config = struct.pack("<IBBBHBHB",
|
|
||||||
915_000_000, # freq_hz
|
|
||||||
7, # bw (125 kHz)
|
|
||||||
7, # sf
|
|
||||||
5, # cr (4/5)
|
|
||||||
0x1424, # sync_word
|
|
||||||
0x80, # tx_power_dbm (-128 = max)
|
|
||||||
0, # preamble_len (0 = default 16)
|
|
||||||
1, # cad (1 = enabled)
|
|
||||||
)
|
|
||||||
command = b"\x02" + config # tag 2 = SetConfig
|
|
||||||
```
|
|
||||||
|
|
||||||
Raw bytes: `02 C0 CA 89 36 07 07 05 24 14 80 00 00 01`
|
|
||||||
|
|
||||||
### 2. COBS-encode and send
|
|
||||||
|
|
||||||
COBS-encode the bytes, append `0x00`, write to USB serial port.
|
|
||||||
Read back the response, COBS-decode it. First byte `0x04` = Ok.
|
|
||||||
|
|
||||||
## Command/Response Discipline
|
|
||||||
|
|
||||||
### Solicited vs. unsolicited responses
|
|
||||||
|
|
||||||
Every response is either **solicited** (exactly one per command) or
|
|
||||||
**unsolicited** (arrives at any time):
|
|
||||||
|
|
||||||
| Response | Category |
|
|
||||||
|----------|----------|
|
|
||||||
| `RxPacket` | **Unsolicited** — arrives whenever a LoRa packet is received during an active RX session |
|
|
||||||
| All others | **Solicited** — exactly one per command, in the order commands were sent |
|
|
||||||
|
|
||||||
### Command → response mapping
|
|
||||||
|
|
||||||
Each command produces exactly one solicited response:
|
|
||||||
|
|
||||||
| Command | Success | Possible errors |
|
|
||||||
|---------|---------|-----------------|
|
|
||||||
| Ping | Pong | — |
|
|
||||||
| GetConfig | Config | Error(NotConfigured) |
|
|
||||||
| SetConfig | Ok | Error(InvalidConfig) |
|
|
||||||
| StartRx | Ok | Error(NotConfigured), Error(InvalidConfig) |
|
|
||||||
| StopRx | Ok | — |
|
|
||||||
| Transmit | TxDone | Error(NotConfigured), Error(InvalidConfig), Error(TxTimeout) |
|
|
||||||
| DisplayOn | Ok | Error(NoDisplay) |
|
|
||||||
| DisplayOff | Ok | Error(NoDisplay) |
|
|
||||||
| GetMac | MacAddress | — |
|
|
||||||
|
|
||||||
### One outstanding command
|
|
||||||
|
|
||||||
The host **must** wait for the solicited response to the current
|
|
||||||
command before sending the next command. Pipelining multiple commands
|
|
||||||
without waiting for responses produces **undefined response ordering**.
|
|
||||||
|
|
||||||
### Processing order
|
|
||||||
|
|
||||||
Commands are processed in FIFO order. The firmware never reorders
|
|
||||||
commands or drops them silently.
|
|
||||||
|
|
||||||
### Interleaving with RxPacket
|
|
||||||
|
|
||||||
Between sending a command and receiving its solicited response, the
|
|
||||||
host may receive zero or more unsolicited `RxPacket` frames.
|
|
||||||
Hosts **must skip** `RxPacket` frames (tag 2) when waiting for a
|
|
||||||
solicited response. Skipped `RxPacket` frames should be buffered,
|
|
||||||
not discarded, if the application needs received LoRa data.
|
|
||||||
|
|
||||||
### Locally-handled commands
|
|
||||||
|
|
||||||
`DisplayOn`, `DisplayOff`, and `GetMac` are handled immediately by
|
|
||||||
the USB task and respond without involving the radio. All other
|
|
||||||
commands are routed to the radio task and respond after radio
|
|
||||||
processing. The one-outstanding-command rule ensures these two
|
|
||||||
response paths never conflict.
|
|
||||||
|
|
||||||
## Received Packet Quality
|
|
||||||
|
|
||||||
The SX126x radio will demodulate and deliver packets that are
|
|
||||||
technically intact (LoRa CRC passed) but were received so close to the
|
|
||||||
noise floor that the payload may be corrupt. This is inherent to LoRa:
|
|
||||||
the radio's internal CRC is only 16 bits, and at very low SNR the false-
|
|
||||||
positive rate is non-trivial. The firmware delivers every packet the
|
|
||||||
radio accepts — it is the host's responsibility to assess quality.
|
|
||||||
|
|
||||||
### SNR demodulation floor
|
|
||||||
|
|
||||||
Each spreading factor has a theoretical minimum SNR for reliable
|
|
||||||
demodulation. Packets received below this floor are statistically
|
|
||||||
likely to contain errors even though the radio reported a valid CRC:
|
|
||||||
|
|
||||||
| SF | Min SNR (dB) |
|
|
||||||
|----|--------------|
|
|
||||||
| 5 | -2.5 |
|
|
||||||
| 6 | -5.0 |
|
|
||||||
| 7 | -7.5 |
|
|
||||||
| 8 | -10.0 |
|
|
||||||
| 9 | -12.5 |
|
|
||||||
| 10 | -15.0 |
|
|
||||||
| 11 | -17.5 |
|
|
||||||
| 12 | -20.0 |
|
|
||||||
|
|
||||||
The pattern is: **min_snr = -2.5 × (SF - 4)**.
|
|
||||||
|
|
||||||
### SNR validity range
|
|
||||||
|
|
||||||
The SX126x reports SNR as a signed value in the range **-32 to +32 dB**.
|
|
||||||
Values outside this range indicate a firmware or decoding error — treat
|
|
||||||
the packet as unreliable.
|
|
||||||
|
|
||||||
### Recommended quality grading
|
|
||||||
|
|
||||||
Hosts should classify each `RxPacket` before trusting the payload:
|
|
||||||
|
|
||||||
```
|
|
||||||
function grade_packet(snr, sf):
|
|
||||||
# Step 1: reject impossible SNR values
|
|
||||||
if snr < -32 or snr > 32:
|
|
||||||
return INVALID # bad metadata, discard or flag
|
|
||||||
|
|
||||||
# Step 2: check against demodulation floor
|
|
||||||
min_snr = -2.5 * (sf - 4)
|
|
||||||
margin = snr - min_snr # dB above the floor
|
|
||||||
|
|
||||||
if margin < 0:
|
|
||||||
return UNRELIABLE # below floor, likely corrupt
|
|
||||||
if margin < 3:
|
|
||||||
return MARGINAL # near floor, may contain errors
|
|
||||||
return GOOD # comfortable margin
|
|
||||||
```
|
|
||||||
|
|
||||||
**What to do with each grade:**
|
|
||||||
|
|
||||||
- **GOOD** — process normally.
|
|
||||||
- **MARGINAL** — process, but prefer application-layer integrity
|
|
||||||
checks (checksums, sequence numbers, known-format validation)
|
|
||||||
before acting on the payload. Log for diagnostics.
|
|
||||||
- **UNRELIABLE** — log for diagnostics but do not trust the payload
|
|
||||||
for routing decisions, command execution, or retransmission.
|
|
||||||
Applications may still display the data with a warning.
|
|
||||||
- **INVALID** — discard. The SNR metadata itself is corrupt.
|
|
||||||
|
|
||||||
### Application-layer defenses
|
|
||||||
|
|
||||||
For safety-critical or command-and-control applications, do not rely
|
|
||||||
solely on the radio CRC. Add your own integrity checks:
|
|
||||||
|
|
||||||
- An application-layer CRC or hash over the payload
|
|
||||||
- Sequence numbers to detect duplicates and missing packets
|
|
||||||
- Known-format validation (magic bytes, expected lengths)
|
|
||||||
- Require acknowledgement before acting on commands
|
|
||||||
|
|
||||||
### Radio config impact
|
|
||||||
|
|
||||||
Wider bandwidth and lower spreading factor increase throughput but
|
|
||||||
reduce sensitivity, making low-SNR packets more common in marginal
|
|
||||||
links. If your application sees frequent MARGINAL/UNRELIABLE packets,
|
|
||||||
consider increasing SF or decreasing BW to improve link margin at the
|
|
||||||
cost of air time.
|
|
||||||
|
|
||||||
## Host Implementation Checklist
|
|
||||||
|
|
||||||
1. Open USB serial port (find by VID:PID `1209:5741`)
|
|
||||||
2. Implement COBS encode/decode (libraries exist for every language)
|
|
||||||
3. Pack commands with `struct.pack` or equivalent — all fields are fixed-size LE
|
|
||||||
4. Optionally send `GetMac`, read back `MacAddress` (useful with multiple dongles)
|
|
||||||
5. Send `SetConfig`, read back `Ok`
|
|
||||||
6. Send `StartRx`, read back `Ok`
|
|
||||||
7. Loop: read frames, decode `RxPacket` (rssi, snr, payload at known offsets)
|
|
||||||
8. To transmit: send `Transmit`, read back `TxDone`
|
|
||||||
|
|
||||||
See `examples/` for working Python implementations.
|
|
||||||
@@ -18,11 +18,44 @@ All commands are run from the `firmware/` directory.
|
|||||||
|
|
||||||
## Supported Boards
|
## Supported Boards
|
||||||
|
|
||||||
| Board | MCU | Radio | Target |
|
| Board | Feature | MCU | Radio | USB | Target |
|
||||||
|-------|-----|-------|--------|
|
|-------|---------|-----|-------|-----|--------|
|
||||||
| Heltec V3 | ESP32-S3 | SX1262 | xtensa-esp32s3-none-elf |
|
| Heltec Mesh Node T114 | `heltec_mesh_node_t114` | nRF52840 | SX1262 | Native CDC-ACM | thumbv7em-none-eabihf |
|
||||||
| Heltec V4 | ESP32-S3 | SX1262 | xtensa-esp32s3-none-elf |
|
| Heltec V3 | `heltec_v3` | ESP32-S3 | SX1262 | Native CDC-ACM | xtensa-esp32s3-none-elf |
|
||||||
| RAK WisBlock 4631 | nRF52840 | SX1262 | thumbv7em-none-eabihf |
|
| 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 |
|
||||||
|
| ELECROW ThinkNode-M2 | `elecrow_thinknode_m2` | ESP32-S3 | SX1262 | CP2102 bridge | xtensa-esp32s3-none-elf |
|
||||||
|
| LilyGo T-Beam | `lilygo_tbeam` | ESP32 | SX1276 | CP2102 bridge | xtensa-esp32-none-elf |
|
||||||
|
| LilyGo T-Beam S3 Supreme | `lilygo_tbeam_supreme` | ESP32-S3 | SX1262 | Native CDC-ACM | xtensa-esp32s3-none-elf |
|
||||||
|
| RAK WisBlock 4631 | `rak_wisblock_4631` | nRF52840 | SX1262 | Native CDC-ACM | thumbv7em-none-eabihf |
|
||||||
|
| Waveshare RP2040-LoRa | `waveshare_rp2040_lora` | RP2040 | SX1262 | Native CDC-ACM | thumbv6m-none-eabi |
|
||||||
|
| Wio Tracker L1 | `wio_tracker_l1` | nRF52840 | SX1262 | Native CDC-ACM | thumbv7em-none-eabihf |
|
||||||
|
|
||||||
|
### Heltec V3 USB Note
|
||||||
|
|
||||||
|
The default `heltec_v3` build uses **native USB CDC-ACM**, which requires a
|
||||||
|
hardware modification to your board. Stock Heltec V3 boards route USB through a
|
||||||
|
CP2102 bridge chip to UART — the ESP32-S3's native USB pins (GPIO19/GPIO20) are
|
||||||
|
routed to header pins but not connected to the USB connector.
|
||||||
|
|
||||||
|
**To enable native USB on a Heltec V3:**
|
||||||
|
|
||||||
|
1. Solder 0-ohm (or 22-ohm) resistors at pads **R29** (D+) and **R3** (D-)
|
||||||
|
2. Disconnect the CP2102 from the USB connector's D+/D- lines (cut traces or
|
||||||
|
remove the CP2102 bridge resistors)
|
||||||
|
|
||||||
|
After this mod, the board appears as `/dev/ttyACM0` with the DongLoRa VID:PID
|
||||||
|
(1209:5741), just like the V4 and nRF boards.
|
||||||
|
|
||||||
|
**If you have a stock (unmodified) V3**, use `heltec_v3_uart` instead:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
just build heltec_v3_uart
|
||||||
|
just flash heltec_v3_uart
|
||||||
|
```
|
||||||
|
|
||||||
|
This builds firmware that communicates via the CP2102 bridge (appears as
|
||||||
|
`/dev/ttyUSB0`).
|
||||||
|
|
||||||
## Adding a Board
|
## Adding a Board
|
||||||
|
|
||||||
@@ -30,4 +63,35 @@ See [src/board/PORTING.md](src/board/PORTING.md).
|
|||||||
|
|
||||||
## Protocol
|
## 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.
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
# Firmware TODO List
|
||||||
|
|
||||||
|
## `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).
|
||||||
|
|
||||||
|
Fork branch we ship from:
|
||||||
|
[`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 fixes via a direct `git` dependency in
|
||||||
|
`Cargo.toml`:
|
||||||
|
|
||||||
|
```toml
|
||||||
|
lora-phy = { git = "https://github.com/swaits/lora-rs.git", branch = "fixed", features = ["defmt-03"] }
|
||||||
|
```
|
||||||
|
|
||||||
|
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 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.
|
||||||
@@ -1,32 +1,48 @@
|
|||||||
use minijinja::{context, Environment};
|
use minijinja::{context, Environment};
|
||||||
use owo_colors::OwoColorize;
|
use owo_colors::OwoColorize;
|
||||||
|
use std::collections::HashMap;
|
||||||
use std::fs;
|
use std::fs;
|
||||||
|
|
||||||
fn main() {
|
fn main() {
|
||||||
// Protocol tests run on the host without a board feature. Cargo doesn't
|
// Protocol tests run on the host without a board feature.
|
||||||
// expose `cfg(test)` to build scripts, so we rely on an env var set by
|
|
||||||
// the justfile `test` recipe. See: `just test`
|
|
||||||
if std::env::var_os("DONGLORA_HOST_TEST").is_some() {
|
if std::env::var_os("DONGLORA_HOST_TEST").is_some() {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
let board_dir = "src/board";
|
let board_dir = "src/board";
|
||||||
let boards: Vec<String> = fs::read_dir(board_dir)
|
|
||||||
.expect("failed to read src/board directory")
|
|
||||||
.filter_map(|e| {
|
|
||||||
let p = e.ok()?.path();
|
|
||||||
let stem = p.file_stem()?.to_str()?;
|
|
||||||
// Exclude non-board files: mod.rs, traits.rs, and platform helpers (esp32s3.rs).
|
|
||||||
if p.is_file()
|
|
||||||
&& p.extension()? == "rs"
|
|
||||||
&& !matches!(stem, "mod" | "traits" | "esp32s3")
|
|
||||||
{
|
|
||||||
return Some(p.file_stem()?.to_str()?.to_string());
|
|
||||||
}
|
|
||||||
None
|
|
||||||
})
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
|
// ── Discover boards vs helpers by content ───────────────────────
|
||||||
|
//
|
||||||
|
// A .rs file in src/board/ that implements LoRaBoard is a board.
|
||||||
|
// Everything else (traits.rs, esp32s3.rs, etc.) is a helper module.
|
||||||
|
// No exclusion lists — detection is based on what the file contains.
|
||||||
|
let mut boards: Vec<String> = Vec::new();
|
||||||
|
|
||||||
|
for entry in fs::read_dir(board_dir).expect("failed to read src/board directory") {
|
||||||
|
let p = match entry {
|
||||||
|
Ok(e) => e.path(),
|
||||||
|
Err(_) => continue,
|
||||||
|
};
|
||||||
|
if !p.is_file() || p.extension().and_then(|e| e.to_str()) != Some("rs") {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let stem = match p.file_stem().and_then(|s| s.to_str()) {
|
||||||
|
Some("mod") => continue, // Skip generated file
|
||||||
|
Some(s) => s.to_string(),
|
||||||
|
None => continue,
|
||||||
|
};
|
||||||
|
let content = match fs::read_to_string(&p) {
|
||||||
|
Ok(c) => c,
|
||||||
|
Err(_) => continue,
|
||||||
|
};
|
||||||
|
if content.contains("LoRaBoard for Board") {
|
||||||
|
boards.push(stem);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
boards.sort(); // Deterministic ordering
|
||||||
|
|
||||||
|
// ── Validate exactly one board feature is enabled ───────────────
|
||||||
let enabled: Vec<_> = std::env::vars()
|
let enabled: Vec<_> = std::env::vars()
|
||||||
.filter(|(k, _)| k.starts_with("CARGO_FEATURE_"))
|
.filter(|(k, _)| k.starts_with("CARGO_FEATURE_"))
|
||||||
.map(|(k, _)| k.replace("CARGO_FEATURE_", "").to_lowercase())
|
.map(|(k, _)| k.replace("CARGO_FEATURE_", "").to_lowercase())
|
||||||
@@ -49,18 +65,74 @@ fn main() {
|
|||||||
),
|
),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Auto-discover helper modules used by board files ────────────
|
||||||
|
//
|
||||||
|
// Scan each board file for `use super::<name>` to find which helper
|
||||||
|
// modules they depend on. Generate cfg gates so helpers compile only
|
||||||
|
// when a dependent board is selected.
|
||||||
|
let mut helper_boards: HashMap<String, Vec<String>> = HashMap::new();
|
||||||
|
|
||||||
|
for board in &boards {
|
||||||
|
let path = format!("{}/{}.rs", board_dir, board);
|
||||||
|
if let Ok(content) = fs::read_to_string(&path) {
|
||||||
|
for line in content.lines() {
|
||||||
|
let trimmed = line.trim();
|
||||||
|
if let Some(rest) = trimmed.strip_prefix("use super::") {
|
||||||
|
let helper = rest
|
||||||
|
.split(|c: char| !c.is_alphanumeric() && c != '_')
|
||||||
|
.next()
|
||||||
|
.unwrap_or("");
|
||||||
|
if !helper.is_empty() && helper != "traits" {
|
||||||
|
helper_boards
|
||||||
|
.entry(helper.to_string())
|
||||||
|
.or_default()
|
||||||
|
.push(board.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Render board module template ────────────────────────────────
|
||||||
let mut env = Environment::new();
|
let mut env = Environment::new();
|
||||||
let template =
|
let template = fs::read_to_string("src/board/mod.rs.j2").expect("missing src/board/mod.rs.j2");
|
||||||
fs::read_to_string("src/board/mod.rs.j2").expect("missing src/board/mod.rs.j2");
|
|
||||||
env.add_template("mod", &template)
|
env.add_template("mod", &template)
|
||||||
.expect("failed to parse board template");
|
.expect("failed to parse board template");
|
||||||
let rendered = env
|
let rendered = env
|
||||||
.get_template("mod")
|
.get_template("mod")
|
||||||
.expect("template not found")
|
.expect("template not found")
|
||||||
.render(context!(boards => boards))
|
.render(context!(boards => boards, helpers => helper_boards))
|
||||||
.expect("failed to render board module");
|
.expect("failed to render board module");
|
||||||
fs::write("src/board/mod.rs", rendered).expect("failed to write src/board/mod.rs");
|
fs::write("src/board/mod.rs", rendered).expect("failed to write src/board/mod.rs");
|
||||||
|
|
||||||
println!("cargo:rerun-if-changed=src/board/mod.rs.j2");
|
println!("cargo:rerun-if-changed=src/board/mod.rs.j2");
|
||||||
println!("cargo:rerun-if-changed={}", board_dir);
|
println!("cargo:rerun-if-changed={}", board_dir);
|
||||||
|
|
||||||
|
// 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 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");
|
||||||
|
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 diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -1,32 +1,30 @@
|
|||||||
set shell := ["bash", "-c"]
|
# Run every recipe with mise's tool versions active — so `just` invocations
|
||||||
|
# of cargo, espflash, rust-objcopy, etc. always resolve to the pinned
|
||||||
# Board definitions: feature target chip
|
# versions in mise.toml, even if the user has a different version elsewhere
|
||||||
# Xtensa targets use nightly cargo + esp rustc + -Zbuild-std=core.
|
# on PATH (e.g. a stray `cargo install espflash`).
|
||||||
# Tool versions are pinned in mise.toml; run `just setup` to install everything.
|
set shell := ["mise", "exec", "--", "sh", "-c"]
|
||||||
heltec_v3 := "heltec_v3 xtensa-esp32s3-none-elf esp32s3"
|
|
||||||
heltec_v4 := "heltec_v4 xtensa-esp32s3-none-elf esp32s3"
|
|
||||||
rak_wisblock_4631 := "rak_wisblock_4631 thumbv7em-none-eabihf nRF52840_xxAA"
|
|
||||||
|
|
||||||
builds_dir := "builds"
|
builds_dir := "builds"
|
||||||
|
version := `sed -n 's/^version = "\(.*\)"/\1/p' Cargo.toml`
|
||||||
|
|
||||||
# All known boards
|
# All known boards. ADD A NEW BOARD HERE and in the `_info` case below.
|
||||||
boards := "heltec_v3 heltec_v4 rak_wisblock_4631"
|
#
|
||||||
|
# Board metadata ("feature target chip") lives in the `_info` recipe —
|
||||||
|
# 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_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
|
# Install all required tools and toolchains
|
||||||
setup:
|
setup:
|
||||||
mise trust --yes
|
mise trust --yes
|
||||||
mise install
|
mise install
|
||||||
@# ESP Xtensa toolchain (not managed by mise)
|
@# 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 \
|
@if ! rustup toolchain list | grep -q "^esp"; then \
|
||||||
echo "ESP toolchain not found, installing via espup (this may take a while)..."; \
|
echo "ESP toolchain not found, installing via mise task (this may take a while)..."; \
|
||||||
espup install || { echo "error: espup install failed" >&2; exit 1; }; \
|
mise run esp-install || { echo "error: esp-install task failed" >&2; exit 1; }; \
|
||||||
fi
|
fi
|
||||||
@# nightly rust-src for -Zbuild-std
|
|
||||||
@if ! rustup component list --toolchain nightly --installed 2>/dev/null | grep -q "^rust-src"; then \
|
|
||||||
rustup component add rust-src --toolchain nightly; \
|
|
||||||
fi
|
|
||||||
@# ARM target
|
|
||||||
@rustup target list --installed | grep -q "^thumbv7em-none-eabihf$" || rustup target add thumbv7em-none-eabihf
|
|
||||||
|
|
||||||
# Build release firmware for all boards, installing toolchains as needed
|
# Build release firmware for all boards, installing toolchains as needed
|
||||||
build-all:
|
build-all:
|
||||||
@@ -52,37 +50,81 @@ check-all:
|
|||||||
fi; \
|
fi; \
|
||||||
done
|
done
|
||||||
|
|
||||||
# Check a single board compiles
|
# Check a single board compiles (clippy with warnings as errors)
|
||||||
check board:
|
check board:
|
||||||
@just _cargo {{board}} check
|
@just _cargo {{board}} "clippy --release" "-- -D warnings"
|
||||||
|
|
||||||
# Run clippy on a single board
|
# Build release firmware and copy to builds/ with a readable name.
|
||||||
clippy board:
|
# `extra_features` (comma-separated) is appended to the board feature,
|
||||||
@just _cargo {{board}} "clippy" "-- -D warnings"
|
# e.g. `just build wio_tracker_l1 release debug-checkpoint`.
|
||||||
|
build board profile="release" extra_features="":
|
||||||
# Build release firmware and copy to builds/ with a readable name
|
@just _cargo {{board}} "build --{{profile}}" "" "{{extra_features}}"
|
||||||
build board profile="release":
|
|
||||||
@just _cargo {{board}} "build --{{profile}}"
|
|
||||||
@just _copy_firmware {{board}} {{profile}}
|
@just _copy_firmware {{board}} {{profile}}
|
||||||
|
|
||||||
# Build and flash a board (espflash for Xtensa, probe-rs for ARM)
|
# Build and flash a board (espflash for Xtensa, UF2 DFU for ARM).
|
||||||
flash board:
|
# `extra_features` is passed through to the build, e.g.
|
||||||
|
# `just flash wio_tracker_l1 debug-checkpoint`.
|
||||||
|
flash board extra_features="":
|
||||||
@just _ensure_tools
|
@just _ensure_tools
|
||||||
@just build {{board}} release
|
@just build {{board}} release "{{extra_features}}"
|
||||||
@read -r feat target chip <<< "$(just _info {{board}})"; \
|
@set -- $(just _info {{board}}); feat=$1; target=$2; chip=$3; \
|
||||||
case "$target" in \
|
case "$target" in \
|
||||||
xtensa-*) \
|
xtensa-*) \
|
||||||
port=$(just _find_port "303a:1001" "1209:5741"); \
|
port=$(just _find_port "303a:1001" "1209:5741" "10c4:ea60" "1a86:7522" "1a86:7523"); \
|
||||||
if [ -n "$port" ]; then \
|
if [ -n "$port" ]; then \
|
||||||
echo "Using $port"; \
|
echo "Using $port"; \
|
||||||
espflash flash -p "$port" "{{builds_dir}}/donglora-{{board}}-release.elf"; \
|
espflash flash -p "$port" "{{builds_dir}}/donglora-{{board}}-v{{version}}.elf"; \
|
||||||
else \
|
else \
|
||||||
echo "No port found, falling back to espflash auto-detection..." >&2; \
|
echo "No port found, falling back to espflash auto-detection..." >&2; \
|
||||||
espflash flash "{{builds_dir}}/donglora-{{board}}-release.elf"; \
|
espflash flash "{{builds_dir}}/donglora-{{board}}-v{{version}}.elf"; \
|
||||||
fi ;; \
|
fi ;; \
|
||||||
*) probe-rs run --chip $chip "{{builds_dir}}/donglora-{{board}}-release.elf" ;; \
|
*) just _flash_uf2 {{board}} ;; \
|
||||||
esac
|
esac
|
||||||
|
|
||||||
|
# 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
|
||||||
|
@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
|
# Show binary size for a release build
|
||||||
size board:
|
size board:
|
||||||
@just _cargo {{board}} "size --release"
|
@just _cargo {{board}} "size --release"
|
||||||
@@ -93,10 +135,7 @@ test:
|
|||||||
|
|
||||||
# Check for outdated dependencies
|
# Check for outdated dependencies
|
||||||
outdated:
|
outdated:
|
||||||
@# embassy-executor 0.10 renamed arch-* → platform-* features.
|
cargo outdated
|
||||||
@# esp-rtos 0.2 still pins embassy-executor ^0.9, blocking the upgrade.
|
|
||||||
@# cargo-outdated crashes trying to resolve 0.10 with the old feature name.
|
|
||||||
cargo outdated --exclude embassy-executor
|
|
||||||
|
|
||||||
# ── Private helpers ───────────────────────────────────────────────────
|
# ── Private helpers ───────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -105,40 +144,41 @@ outdated:
|
|||||||
_ensure_tools:
|
_ensure_tools:
|
||||||
@mise trust --yes . 2>/dev/null; mise install --quiet
|
@mise trust --yes . 2>/dev/null; mise install --quiet
|
||||||
|
|
||||||
# Run a cargo command for a board.
|
# Run a cargo command for a board, optionally with extra Cargo features
|
||||||
# Xtensa: nightly cargo + esp rustc (via RUSTC override) + -Zbuild-std=core
|
# (comma-separated, e.g. "debug-checkpoint").
|
||||||
|
# Xtensa: esp toolchain (cargo + rustc + rust-src together) + -Zbuild-std=core
|
||||||
[private]
|
[private]
|
||||||
_cargo board cmd extra_args="":
|
_cargo board cmd extra_args="" extra_features="":
|
||||||
@just _ensure_tools
|
@just _ensure_tools
|
||||||
@read -r feat target chip <<< "$(just _info {{board}})"; \
|
@set -- $(just _info {{board}}); feat=$1; target=$2; chip=$3; \
|
||||||
env=""; extra=""; \
|
[ -n "{{extra_features}}" ] && feat="$feat,{{extra_features}}"; \
|
||||||
|
extra=""; buildstd=""; \
|
||||||
case "$target" in xtensa-*) \
|
case "$target" in xtensa-*) \
|
||||||
just _require_esp_toolchain; \
|
just _require_esp_toolchain; \
|
||||||
[ -f "$HOME/export-esp.sh" ] && . "$HOME/export-esp.sh"; \
|
extra="+esp"; buildstd="-Zbuild-std=core,alloc";; \
|
||||||
env="RUSTC=$(rustup which rustc --toolchain esp)"; extra="+nightly"; buildstd="-Zbuild-std=core,alloc";; \
|
|
||||||
esac; \
|
esac; \
|
||||||
eval $env cargo $extra {{cmd}} --target "$target" --features "$feat" $buildstd {{extra_args}}
|
cargo $extra {{cmd}} --target "$target" --features "$feat" $buildstd {{extra_args}}
|
||||||
|
|
||||||
# Ensure a board's toolchain is available, auto-installing if needed
|
# Ensure a board's toolchain is available, auto-installing if needed
|
||||||
[private]
|
[private]
|
||||||
_can_build board:
|
_can_build board:
|
||||||
@read -r feat target chip <<< "$(just _info {{board}})"; \
|
@set -- $(just _info {{board}}); feat=$1; target=$2; chip=$3; \
|
||||||
case "$target" in \
|
case "$target" in \
|
||||||
xtensa-*) just _require_esp_toolchain ;; \
|
xtensa-*) just _require_esp_toolchain ;; \
|
||||||
*) rustup target list --installed | grep -q "^$target$" || rustup target add "$target" ;; \
|
*) rustup target list --installed | grep -q "^$target$" || rustup target add "$target" ;; \
|
||||||
esac
|
esac
|
||||||
|
|
||||||
# Ensure the ESP Xtensa toolchain is installed (espup installed via mise)
|
# 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]
|
[private]
|
||||||
_require_esp_toolchain:
|
_require_esp_toolchain:
|
||||||
@just _ensure_tools
|
@just _ensure_tools
|
||||||
@if ! rustup toolchain list | grep -q "^esp"; then \
|
@if ! rustup toolchain list | grep -q "^esp"; then \
|
||||||
echo "ESP toolchain not found, installing via espup (this may take a while)..." >&2; \
|
echo "ESP toolchain not found, installing via mise task (this may take a while)..." >&2; \
|
||||||
espup install || { echo "error: espup install failed" >&2; exit 1; }; \
|
mise run esp-install || { echo "error: esp-install task failed" >&2; exit 1; }; \
|
||||||
fi; \
|
|
||||||
if ! rustup component list --toolchain nightly --installed 2>/dev/null | grep -q "^rust-src"; then \
|
|
||||||
echo "Installing nightly rust-src (needed for -Zbuild-std)..." >&2; \
|
|
||||||
rustup component add rust-src --toolchain nightly || { echo "error: failed to install rust-src" >&2; exit 1; }; \
|
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Find a serial port matching any of the given VID:PID pairs (checked in order)
|
# Find a serial port matching any of the given VID:PID pairs (checked in order)
|
||||||
@@ -160,14 +200,61 @@ _find_port +vid_pids:
|
|||||||
[private]
|
[private]
|
||||||
_copy_firmware board profile:
|
_copy_firmware board profile:
|
||||||
@mkdir -p {{builds_dir}}
|
@mkdir -p {{builds_dir}}
|
||||||
@read -r feat target chip <<< "$(just _info {{board}})"; \
|
@set -- $(just _info {{board}}); feat=$1; target=$2; chip=$3; \
|
||||||
src="target/$target/{{profile}}/donglora"; \
|
src="target/$target/{{profile}}/donglora"; \
|
||||||
dst="{{builds_dir}}/donglora-{{board}}-{{profile}}"; \
|
dst="{{builds_dir}}/donglora-{{board}}-v{{version}}"; \
|
||||||
if [ -f "$src" ]; then cp "$src" "$dst.elf"; echo "→ $dst.elf"; fi
|
if [ -f "$src" ]; then \
|
||||||
|
cp "$src" "$dst.elf"; echo "→ $dst.elf"; \
|
||||||
|
case "$target" in \
|
||||||
|
xtensa-*) \
|
||||||
|
espflash save-image --chip "$chip" --merge --skip-padding "$src" "$dst.bin"; \
|
||||||
|
echo "→ $dst.bin"; \
|
||||||
|
;; \
|
||||||
|
thumbv6m-*) \
|
||||||
|
rust-objcopy -O ihex "$src" "$dst.hex"; \
|
||||||
|
cargo-hex-to-uf2 hex-to-uf2 --input-path "$dst.hex" --output-path "$dst.uf2" --family rp2040; \
|
||||||
|
rm "$dst.hex"; \
|
||||||
|
echo "→ $dst.uf2"; \
|
||||||
|
;; \
|
||||||
|
thumbv7em-*) \
|
||||||
|
rust-objcopy -O ihex "$src" "$dst.hex"; \
|
||||||
|
cargo-hex-to-uf2 hex-to-uf2 --input-path "$dst.hex" --output-path "$dst.uf2" --family nrf52840; \
|
||||||
|
rm "$dst.hex"; \
|
||||||
|
echo "→ $dst.uf2"; \
|
||||||
|
;; \
|
||||||
|
esac; \
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Copy UF2 to a mounted UF2 bootloader drive
|
||||||
|
[private]
|
||||||
|
_flash_uf2 board:
|
||||||
|
@uf2="{{builds_dir}}/donglora-{{board}}-v{{version}}.uf2"; \
|
||||||
|
if [ ! -f "$uf2" ]; then \
|
||||||
|
echo "error: $uf2 not found — run 'just build {{board}}' first" >&2; exit 1; \
|
||||||
|
fi; \
|
||||||
|
mnt="$(find /run/media -maxdepth 3 -name INFO_UF2.TXT -printf '%h\n' -quit 2>/dev/null)"; \
|
||||||
|
if [ -z "$mnt" ]; then \
|
||||||
|
echo "error: no UF2 drive found — double-tap reset to enter bootloader" >&2; exit 1; \
|
||||||
|
fi; \
|
||||||
|
echo "Copying to $mnt ..."; \
|
||||||
|
cp "$uf2" "$mnt/"; \
|
||||||
|
sync; \
|
||||||
|
echo "Done — board will reboot automatically"
|
||||||
|
|
||||||
|
# Single source of truth for per-board metadata: "feature target chip".
|
||||||
|
# ADD A NEW BOARD HERE and in the `boards` list near the top of this file.
|
||||||
[private]
|
[private]
|
||||||
_info name:
|
_info name:
|
||||||
@if [ "{{name}}" == "heltec_v3" ]; then echo "{{heltec_v3}}"; \
|
@case "{{name}}" in \
|
||||||
elif [ "{{name}}" == "heltec_v4" ]; then echo "{{heltec_v4}}"; \
|
heltec_mesh_node_t114) echo "heltec_mesh_node_t114 thumbv7em-none-eabihf nRF52840_xxAA" ;; \
|
||||||
elif [ "{{name}}" == "rak_wisblock_4631" ]; then echo "{{rak_wisblock_4631}}"; \
|
heltec_v3) echo "heltec_v3 xtensa-esp32s3-none-elf esp32s3" ;; \
|
||||||
else echo "Unknown board: {{name}}" >&2; exit 1; fi
|
heltec_v3_uart) echo "heltec_v3_uart xtensa-esp32s3-none-elf esp32s3" ;; \
|
||||||
|
heltec_v4) echo "heltec_v4 xtensa-esp32s3-none-elf esp32s3" ;; \
|
||||||
|
elecrow_thinknode_m2) echo "elecrow_thinknode_m2 xtensa-esp32s3-none-elf esp32s3" ;; \
|
||||||
|
lilygo_tbeam) echo "lilygo_tbeam xtensa-esp32-none-elf esp32" ;; \
|
||||||
|
lilygo_tbeam_supreme) echo "lilygo_tbeam_supreme xtensa-esp32s3-none-elf esp32s3" ;; \
|
||||||
|
rak_wisblock_4631) echo "rak_wisblock_4631 thumbv7em-none-eabihf nRF52840_xxAA" ;; \
|
||||||
|
waveshare_rp2040_lora) echo "waveshare_rp2040_lora thumbv6m-none-eabi rp2040" ;; \
|
||||||
|
wio_tracker_l1) echo "wio_tracker_l1 thumbv7em-none-eabihf nRF52840_xxAA" ;; \
|
||||||
|
*) echo "Unknown board: {{name}}" >&2; exit 1 ;; \
|
||||||
|
esac
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
/* nRF52840 memory layout — Adafruit UF2 bootloader + s140 SoftDevice
|
||||||
|
*
|
||||||
|
* 0x00000 – 0x01000 MBR (Master Boot Record)
|
||||||
|
* 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. 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 = 0x00026000, LENGTH = 0xCE000
|
||||||
|
RAM : ORIGIN = 0x20000008, LENGTH = 255K
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
/* RP2040 memory layout — UF2 bootloader (ROM boot2)
|
||||||
|
*
|
||||||
|
* 0x10000000 – 0x10000100 boot2 (second-stage bootloader, 256 bytes)
|
||||||
|
* 0x10000100 – 0x10200000 Application <- we live here
|
||||||
|
*
|
||||||
|
* Flash: 2MB (W25Q16JV on Waveshare RP2040-LoRa)
|
||||||
|
* RAM: 264KB SRAM (two 256KB banks + 2x 4KB scratch)
|
||||||
|
*/
|
||||||
|
|
||||||
|
MEMORY
|
||||||
|
{
|
||||||
|
BOOT2 : ORIGIN = 0x10000000, LENGTH = 0x100
|
||||||
|
FLASH : ORIGIN = 0x10000100, LENGTH = 2048K - 0x100
|
||||||
|
RAM : ORIGIN = 0x20000000, LENGTH = 264K
|
||||||
|
}
|
||||||
|
|
||||||
|
EXTERN(BOOT2_FIRMWARE)
|
||||||
|
|
||||||
|
SECTIONS {
|
||||||
|
.boot2 ORIGIN(BOOT2) :
|
||||||
|
{
|
||||||
|
KEEP(*(.boot2));
|
||||||
|
} > BOOT2
|
||||||
|
} INSERT BEFORE .text;
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -1,8 +1,46 @@
|
|||||||
[tools]
|
[tools]
|
||||||
rust = "1.93"
|
|
||||||
just = "latest"
|
just = "latest"
|
||||||
|
|
||||||
"cargo:cargo-binutils" = "latest" # `cargo size`, `cargo objdump`, etc.
|
# Cargo-installed tooling via mise's `cargo:` backend.
|
||||||
"cargo:espup" = "0.16"
|
"cargo:cargo-binutils" = "latest" # rust-objcopy, rust-size
|
||||||
"cargo:espflash" = "3" # 4.x has broken nix dependency on current stable Rust
|
"cargo:cargo-hex-to-uf2" = "latest" # ELF → UF2 for nRF52 / RP2040 DFU drives
|
||||||
"cargo:probe-rs-tools" = "0.31"
|
"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
|
||||||
|
# the pinned toolchain, so they're always available under `mise exec` — no
|
||||||
|
# extra `rustup target add` step at setup time.
|
||||||
|
#
|
||||||
|
# Xtensa ESP targets are NOT listed here — they use the self-contained `esp`
|
||||||
|
# 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)
|
||||||
|
#
|
||||||
|
[tools.rust]
|
||||||
|
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"
|
||||||
+28
-21
@@ -5,14 +5,15 @@
|
|||||||
### 1. Create the board file
|
### 1. Create the board file
|
||||||
|
|
||||||
Create `src/board/your_board.rs`. Use an existing board as a template:
|
Create `src/board/your_board.rs`. Use an existing board as a template:
|
||||||
- ESP32-S3 boards: copy `heltec_v4.rs` (uses shared helpers from `esp32s3.rs`)
|
- ESP32-S3 boards: copy `heltec_v4.rs` (uses shared helpers from `board/esp32s3.rs` and `hal/esp32s3.rs`)
|
||||||
- nRF52840 boards: copy `rak_wisblock_4631.rs`
|
- nRF52840 boards: copy `rak_wisblock_4631.rs` (uses shared helpers from `hal/nrf52840.rs`)
|
||||||
|
- RP2040 boards: copy `waveshare_rp2040_lora.rs` (uses shared helpers from `hal/rp2040.rs`)
|
||||||
|
- New MCU family: create a new `hal/<mcu>.rs` with MCU primitives first
|
||||||
|
|
||||||
ESP32-S3 boards share common type aliases, peripheral bundles, and init helpers
|
`build.rs` auto-discovers your board file — any `.rs` file in `src/board/` that
|
||||||
via `src/board/esp32s3.rs`. Your board file only needs the trait impl and
|
implements `LoRaBoard for Board` is automatically detected as a board. Helper
|
||||||
board-specific init (Vext GPIO, display reset). If you add a new ESP32-S3 board,
|
modules (like `esp32s3.rs`) are auto-discovered via `use super::<helper>` imports
|
||||||
add its feature to the `#[cfg(any(...))]` gate on `mod esp32s3` in
|
in board files. No template editing or exclusion lists needed.
|
||||||
`src/board/mod.rs.j2`.
|
|
||||||
|
|
||||||
### 2. Define concrete types
|
### 2. Define concrete types
|
||||||
|
|
||||||
@@ -21,7 +22,9 @@ Your board must export these type aliases:
|
|||||||
```rust
|
```rust
|
||||||
pub type RadioDriver = ...; // Must work with lora_phy::LoRa<RadioDriver, Delay>
|
pub type RadioDriver = ...; // Must work with lora_phy::LoRa<RadioDriver, Delay>
|
||||||
pub type UsbDriver = ...; // Must implement embassy_usb_driver::Driver<'static>
|
pub type UsbDriver = ...; // Must implement embassy_usb_driver::Driver<'static>
|
||||||
|
// (or UartDriver for UART boards)
|
||||||
pub type DisplayI2c = ...; // Must implement embedded_hal_async::i2c::I2c
|
pub type DisplayI2c = ...; // Must implement embedded_hal_async::i2c::I2c
|
||||||
|
pub type DisplayDriver = ...; // Must implement DrawTarget<Color = BinaryColor>
|
||||||
```
|
```
|
||||||
|
|
||||||
### 3. Define peripheral bundles
|
### 3. Define peripheral bundles
|
||||||
@@ -32,39 +35,45 @@ pub struct RadioParts {
|
|||||||
pub delay: embassy_time::Delay,
|
pub delay: embassy_time::Delay,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub struct UsbParts {
|
pub struct UsbParts { // or UartParts for UART boards
|
||||||
pub driver: UsbDriver,
|
pub driver: UsbDriver,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub struct DisplayParts {
|
pub struct DisplayParts {
|
||||||
pub i2c: DisplayI2c,
|
pub i2c: DisplayI2c,
|
||||||
pub mac: [u8; 6],
|
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
### 4. Implement the LoRaBoard trait
|
### 4. Implement the LoRaBoard trait
|
||||||
|
|
||||||
```rust
|
```rust
|
||||||
use super::traits::LoRaBoard;
|
use super::traits::{BoardParts, LoRaBoard};
|
||||||
|
|
||||||
impl LoRaBoard for Board {
|
impl LoRaBoard for Board {
|
||||||
const NAME: &'static str = "Your Board Name";
|
const NAME: &'static str = "Your Board Name";
|
||||||
const TX_POWER_RANGE: (i8, i8) = (-9, 22); // check your radio + PA
|
const TX_POWER_RANGE: (i8, i8) = (-9, 22); // check your radio + PA
|
||||||
|
|
||||||
|
type RadioParts = RadioParts;
|
||||||
|
type CommParts = UsbParts; // or UartParts
|
||||||
|
type DisplayParts = DisplayParts;
|
||||||
|
type DisplayDriver = DisplayDriver;
|
||||||
|
|
||||||
fn init() -> Self { ... }
|
fn init() -> Self { ... }
|
||||||
fn mac_address() -> [u8; 6] { ... } // read from efuse, FICR, etc.
|
fn mac_address() -> [u8; 6] { ... } // read from efuse, FICR, etc.
|
||||||
|
fn into_parts(self) -> BoardParts<RadioParts, UsbParts, DisplayParts> {
|
||||||
|
// Initialize buses via hal::, construct drivers, return BoardParts
|
||||||
|
BoardParts { radio, host, display: Some(display_parts), mac: Self::mac_address() }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
The compiler will refuse to build if any of these are missing.
|
### 5. Add display init
|
||||||
|
|
||||||
### 5. Implement `Board::into_parts()`
|
|
||||||
|
|
||||||
```rust
|
```rust
|
||||||
impl Board {
|
pub async fn create_display(parts: DisplayParts) -> Option<DisplayDriver> {
|
||||||
pub fn into_parts(self) -> (RadioParts, UsbParts, Option<DisplayParts>) {
|
// Construct and initialize your display driver (SSD1306, SH1106,
|
||||||
// Initialize SPI, DMA, radio, USB, I2C
|
// ST7789, etc.) using whatever pins/buses you bundled into
|
||||||
// Return peripheral bundles
|
// DisplayParts in `into_parts()`.
|
||||||
}
|
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -79,7 +88,7 @@ your_board = ["dep:your-hal", ...]
|
|||||||
|
|
||||||
### 7. Add to justfile
|
### 7. Add to justfile
|
||||||
|
|
||||||
Add your board's feature/target/chip to the `boards` list at the top of `justfile`.
|
Add your board's feature/target/chip to the board definitions at the top of `justfile`.
|
||||||
|
|
||||||
### 8. Build and test
|
### 8. Build and test
|
||||||
|
|
||||||
@@ -88,5 +97,3 @@ just check your_board # Must compile
|
|||||||
just clippy your_board # Must be clean
|
just clippy your_board # Must be clean
|
||||||
just build your_board # Produces firmware
|
just build your_board # Produces firmware
|
||||||
```
|
```
|
||||||
|
|
||||||
`build.rs` auto-discovers your `.rs` file — no template editing needed.
|
|
||||||
@@ -0,0 +1,141 @@
|
|||||||
|
//! ELECROW ThinkNode-M2 — ESP32-S3 Meshtastic handset with SH1106 OLED.
|
||||||
|
//!
|
||||||
|
//! Tamagotchi-form-factor handheld: ESP32-S3-WROOM-1-N4 + SX1262 + 1.3" SH1106
|
||||||
|
//! OLED + WCH CH340K UART bridge + 1000 mAh Li-Po. Sold pre-loaded with
|
||||||
|
//! Meshtastic; this firmware turns it into a DongLoRa radio bridge.
|
||||||
|
//!
|
||||||
|
//! Pin map (verified from Meshtastic's variants/esp32s3/ELECROW-ThinkNode-M2
|
||||||
|
//! and PR meshtastic/firmware#6354):
|
||||||
|
//! LoRa SPI: SCK=12, MOSI=11, MISO=13, CS=10
|
||||||
|
//! LoRa ctl: RESET=21, BUSY=14, DIO1=3
|
||||||
|
//! LoRa pwr: GPIO48 (must be HIGH before any SPI traffic)
|
||||||
|
//! OLED: I2C0 SDA=16, SCL=15, SH1106 @ 0x3C
|
||||||
|
//! VEXT: GPIO46 (active HIGH — gates OLED supply)
|
||||||
|
//! UART: TX=43, RX=44 via CH340K bridge (USB 1a86:7522)
|
||||||
|
//! LED: GPIO6 (active HIGH indicator)
|
||||||
|
//!
|
||||||
|
//! Notable differences from the Heltec ESP32-S3 boards:
|
||||||
|
//! * Display is SH1106 (not SSD1306) — uses the existing sh1106 driver.
|
||||||
|
//! * VEXT_ENABLE polarity is reversed (active HIGH here, active LOW on Heltec).
|
||||||
|
//! * Dedicated LoRa power enable on GPIO48 — must be sequenced before SPI init.
|
||||||
|
//! * TCXO is driven at 3.3 V via DIO3 (Heltec uses 1.8 V).
|
||||||
|
|
||||||
|
use esp_hal::gpio::{Level, Output, OutputConfig};
|
||||||
|
|
||||||
|
use super::esp32s3;
|
||||||
|
use super::traits::{BoardParts, LoRaBoard};
|
||||||
|
use crate::driver::sh1106::Sh1106;
|
||||||
|
use crate::driver::simple_led::SimpleLed;
|
||||||
|
use crate::hal::esp32s3 as mcu;
|
||||||
|
|
||||||
|
pub use super::esp32s3::{DisplayI2c, RadioDriver, RadioParts};
|
||||||
|
|
||||||
|
// ── Concrete peripheral types ───────────────────────────────────────
|
||||||
|
|
||||||
|
pub type DisplayDriver = Sh1106<DisplayI2c>;
|
||||||
|
pub type UartDriver = esp_hal::uart::Uart<'static, esp_hal::Async>;
|
||||||
|
pub type LedDriver = SimpleLed<Output<'static>>;
|
||||||
|
|
||||||
|
// ── Peripheral bundles ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
pub struct UartParts {
|
||||||
|
pub driver: UartDriver,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct DisplayParts {
|
||||||
|
pub i2c: DisplayI2c,
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Display init ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
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() {
|
||||||
|
defmt::error!("SH1106 display init failed");
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let _ = display.set_brightness(0xFF).await;
|
||||||
|
Some(display)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Board init ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
pub struct Board {
|
||||||
|
p: esp_hal::peripherals::Peripherals,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl LoRaBoard for Board {
|
||||||
|
const NAME: &'static str = "ELECROW ThinkNode-M2";
|
||||||
|
const TX_POWER_RANGE: (i8, i8) = (-9, 22);
|
||||||
|
|
||||||
|
type RadioParts = RadioParts;
|
||||||
|
type CommParts = UartParts;
|
||||||
|
type DisplayParts = DisplayParts;
|
||||||
|
type DisplayDriver = DisplayDriver;
|
||||||
|
type LedDriver = LedDriver;
|
||||||
|
|
||||||
|
fn init() -> Self {
|
||||||
|
let p = esp_hal::init(esp_hal::Config::default());
|
||||||
|
Self { p }
|
||||||
|
}
|
||||||
|
|
||||||
|
fn mac_address() -> [u8; 6] {
|
||||||
|
mcu::mac_address()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn into_parts(self) -> BoardParts<RadioParts, UartParts, DisplayParts, LedDriver> {
|
||||||
|
let p = self.p;
|
||||||
|
|
||||||
|
mcu::start_timer(p.TIMG0, p.SW_INTERRUPT);
|
||||||
|
|
||||||
|
// VEXT_ENABLE: GPIO46, active HIGH — powers OLED rail (opposite of Heltec).
|
||||||
|
// Output has no Drop impl, so the pin stays HIGH for program lifetime.
|
||||||
|
let _vext = Output::new(p.GPIO46, Level::High, OutputConfig::default());
|
||||||
|
esp_hal::delay::Delay::new().delay_millis(10);
|
||||||
|
|
||||||
|
// LORA_POWER_EN: GPIO48, active HIGH — must be HIGH before any SPI traffic.
|
||||||
|
let _lora_pwr = Output::new(p.GPIO48, Level::High, OutputConfig::default());
|
||||||
|
esp_hal::delay::Delay::new().delay_millis(10);
|
||||||
|
|
||||||
|
// SPI2 for SX1262: SCK=12, MOSI=11, MISO=13
|
||||||
|
let spi_bus = mcu::init_spi(p.SPI2, p.DMA_CH0, p.GPIO12, p.GPIO11, p.GPIO13);
|
||||||
|
|
||||||
|
// SX1262: CS=10, RESET=21, DIO1=3, BUSY=14, TCXO driven at 3.3 V via DIO3.
|
||||||
|
let radio = esp32s3::init_radio(
|
||||||
|
spi_bus,
|
||||||
|
p.GPIO10,
|
||||||
|
p.GPIO21,
|
||||||
|
p.GPIO3,
|
||||||
|
p.GPIO14,
|
||||||
|
lora_phy::sx126x::TcxoCtrlVoltage::Ctrl3V3,
|
||||||
|
);
|
||||||
|
|
||||||
|
// UART0 via CP2102 bridge: TX=43, RX=44
|
||||||
|
use esp_hal::uart::{Config as UartConfig, Uart};
|
||||||
|
let host = UartParts {
|
||||||
|
driver: Uart::new(p.UART0, UartConfig::default())
|
||||||
|
.expect("UART init")
|
||||||
|
.with_tx(p.GPIO43)
|
||||||
|
.with_rx(p.GPIO44)
|
||||||
|
.into_async(),
|
||||||
|
};
|
||||||
|
|
||||||
|
// I2C0 for SH1106 OLED: SDA=16, SCL=15
|
||||||
|
let i2c = mcu::init_i2c(p.I2C0, p.GPIO16, p.GPIO15);
|
||||||
|
let display = Some(DisplayParts { i2c });
|
||||||
|
|
||||||
|
// LED on GPIO6 (active HIGH)
|
||||||
|
let led_pin = Output::new(p.GPIO6, Level::Low, OutputConfig::default());
|
||||||
|
let led = SimpleLed(led_pin);
|
||||||
|
|
||||||
|
BoardParts {
|
||||||
|
radio,
|
||||||
|
host,
|
||||||
|
display,
|
||||||
|
led,
|
||||||
|
mac: Self::mac_address(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
//! Shared types and peripheral wiring for classic-ESP32 SX1276 boards.
|
||||||
|
//!
|
||||||
|
//! Counterpart to `board::esp32s3` for the SX126x line. Builds an
|
||||||
|
//! `Sx127x<Sx1276>` driver from an already-initialized SPI bus plus the
|
||||||
|
//! board's reset and DIO0 pins. SX127x has no `BUSY` line — DIO0 carries
|
||||||
|
//! every IRQ (TX done, RX done, header valid, …) and lora-phy's
|
||||||
|
//! `GenericSx127xInterfaceVariant` waits on it directly.
|
||||||
|
|
||||||
|
use embassy_embedded_hal::shared_bus::asynch::spi::SpiDevice;
|
||||||
|
use embassy_sync::blocking_mutex::raw::NoopRawMutex;
|
||||||
|
use embassy_time::Delay;
|
||||||
|
use esp_hal::gpio::{Input, InputConfig, InputPin, Level, Output, OutputConfig, OutputPin, Pull};
|
||||||
|
|
||||||
|
use lora_phy::iv::GenericSx127xInterfaceVariant;
|
||||||
|
use lora_phy::sx127x::{self, Sx127x};
|
||||||
|
|
||||||
|
use crate::hal::esp32 as mcu;
|
||||||
|
|
||||||
|
// ── Re-export MCU types ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
pub use mcu::{I2cBus as DisplayI2c, SpiBus};
|
||||||
|
|
||||||
|
// ── Concrete peripheral types ───────────────────────────────────────
|
||||||
|
|
||||||
|
type Nss = Output<'static>;
|
||||||
|
type Iv = GenericSx127xInterfaceVariant<Output<'static>, Input<'static>>;
|
||||||
|
type RadioSpiDevice = SpiDevice<'static, NoopRawMutex, SpiBus, Nss>;
|
||||||
|
pub type RadioDriver = Sx127x<RadioSpiDevice, Iv, sx127x::Sx1276>;
|
||||||
|
|
||||||
|
// ── Peripheral bundles ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
pub struct RadioParts {
|
||||||
|
pub driver: RadioDriver,
|
||||||
|
pub delay: Delay,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct DisplayParts {
|
||||||
|
pub i2c: DisplayI2c,
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Display driver ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
pub type DisplayDriver = ssd1306::Ssd1306Async<
|
||||||
|
ssd1306::prelude::I2CInterface<DisplayI2c>,
|
||||||
|
ssd1306::size::DisplaySize128x64,
|
||||||
|
ssd1306::mode::BufferedGraphicsModeAsync<ssd1306::size::DisplaySize128x64>,
|
||||||
|
>;
|
||||||
|
|
||||||
|
/// Construct and initialize an SSD1306 display from raw I²C.
|
||||||
|
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(parts.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;
|
||||||
|
}
|
||||||
|
let _ = display.set_brightness(Brightness::BRIGHTEST).await;
|
||||||
|
Some(display)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Shared peripheral init helpers ──────────────────────────────────
|
||||||
|
|
||||||
|
/// Construct an SX1276 radio from an initialized SPI bus.
|
||||||
|
///
|
||||||
|
/// `dio0` is the primary IRQ (TX/RX done). The SX1276 module on the T-Beam
|
||||||
|
/// runs from a crystal oscillator, not a TCXO, and power comes from the
|
||||||
|
/// AXP192's PA_BOOST rail — `tx_boost = true` routes TX through the PA
|
||||||
|
/// output for +20 dBm.
|
||||||
|
pub fn init_radio(
|
||||||
|
spi_bus: &'static embassy_sync::mutex::Mutex<NoopRawMutex, SpiBus>,
|
||||||
|
nss_pin: impl OutputPin + 'static,
|
||||||
|
reset_pin: impl OutputPin + 'static,
|
||||||
|
dio0_pin: impl InputPin + 'static,
|
||||||
|
) -> RadioParts {
|
||||||
|
let nss = Output::new(nss_pin, Level::High, OutputConfig::default());
|
||||||
|
let spi_device = SpiDevice::new(spi_bus, nss);
|
||||||
|
|
||||||
|
let reset = Output::new(reset_pin, Level::High, OutputConfig::default());
|
||||||
|
let dio0 = Input::new(dio0_pin, InputConfig::default().with_pull(Pull::Down));
|
||||||
|
|
||||||
|
let iv =
|
||||||
|
GenericSx127xInterfaceVariant::new(reset, dio0, None, None).expect("SX1276 interface init");
|
||||||
|
|
||||||
|
let sx_config = sx127x::Config {
|
||||||
|
chip: sx127x::Sx1276,
|
||||||
|
tcxo_used: false,
|
||||||
|
tx_boost: true,
|
||||||
|
rx_boost: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
RadioParts {
|
||||||
|
driver: Sx127x::new(spi_device, iv, sx_config),
|
||||||
|
delay: Delay,
|
||||||
|
}
|
||||||
|
}
|
||||||
+69
-98
@@ -1,100 +1,102 @@
|
|||||||
//! Shared types and initialization helpers for ESP32-S3 boards.
|
//! Shared types and peripheral wiring for ESP32-S3 boards.
|
||||||
//!
|
//!
|
||||||
//! All ESP32-S3 boards in this project share the same SPI/DMA, USB OTG,
|
//! Delegates MCU-level init to `hal::esp32s3` and defines peripheral-level
|
||||||
//! and I2C peripheral wiring. This module provides the common type aliases,
|
//! types (RadioDriver, display) used by ESP32-S3 board files.
|
||||||
//! peripheral bundle structs, and init functions. Each board file re-exports
|
//! Each board defines its own comm types (UsbParts/UartParts) directly.
|
||||||
//! these types and calls the helpers from its own `Board::into_parts()`.
|
|
||||||
|
|
||||||
use embassy_embedded_hal::shared_bus::asynch::spi::SpiDevice;
|
use embassy_embedded_hal::shared_bus::asynch::spi::SpiDevice;
|
||||||
use embassy_sync::blocking_mutex::raw::NoopRawMutex;
|
use embassy_sync::blocking_mutex::raw::NoopRawMutex;
|
||||||
use embassy_time::Delay;
|
use embassy_time::Delay;
|
||||||
use esp_hal::dma::{DmaRxBuf, DmaTxBuf};
|
use esp_hal::gpio::{Input, InputConfig, InputPin, Level, Output, OutputConfig, OutputPin, Pull};
|
||||||
use esp_hal::gpio::{Input, InputConfig, Level, Output, OutputConfig, Pull};
|
|
||||||
use esp_hal::i2c::master::{Config as I2cConfig, I2c};
|
|
||||||
use esp_hal::spi::master::{Config as SpiConfig, Spi, SpiDmaBus};
|
|
||||||
use esp_hal::spi::Mode as SpiMode;
|
|
||||||
use esp_hal::time::Rate;
|
|
||||||
use esp_hal::timer::timg::TimerGroup;
|
|
||||||
use static_cell::StaticCell;
|
|
||||||
|
|
||||||
use lora_phy::iv::GenericSx126xInterfaceVariant;
|
use lora_phy::iv::GenericSx126xInterfaceVariant;
|
||||||
use lora_phy::sx126x::{self, Sx1262, Sx126x};
|
use lora_phy::sx126x::{self, Sx1262, Sx126x};
|
||||||
|
|
||||||
// ── Concrete peripheral types ────────────────────────────────────────
|
use crate::hal::esp32s3 as mcu;
|
||||||
|
|
||||||
|
// ── Re-export MCU types ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
pub use mcu::{I2cBus as DisplayI2c, SpiBus};
|
||||||
|
|
||||||
|
// ── Concrete peripheral types ───────────────────────────────────────
|
||||||
|
|
||||||
type SpiBus = SpiDmaBus<'static, esp_hal::Async>;
|
|
||||||
type Nss = Output<'static>;
|
type Nss = Output<'static>;
|
||||||
type Iv = GenericSx126xInterfaceVariant<Output<'static>, Input<'static>>;
|
type Iv = GenericSx126xInterfaceVariant<Output<'static>, Input<'static>>;
|
||||||
type RadioSpiDevice = SpiDevice<'static, NoopRawMutex, SpiBus, Nss>;
|
type RadioSpiDevice = SpiDevice<'static, NoopRawMutex, SpiBus, Nss>;
|
||||||
pub type RadioDriver = Sx126x<RadioSpiDevice, Iv, Sx1262>;
|
pub type RadioDriver = Sx126x<RadioSpiDevice, Iv, Sx1262>;
|
||||||
|
|
||||||
pub type UsbDriver = esp_hal::otg_fs::asynch::Driver<'static>;
|
// ── Peripheral bundles ──────────────────────────────────────────────
|
||||||
|
|
||||||
pub type DisplayI2c = I2c<'static, esp_hal::Async>;
|
|
||||||
|
|
||||||
// ── Peripheral bundles ───────────────────────────────────────────────
|
|
||||||
|
|
||||||
pub struct RadioParts {
|
pub struct RadioParts {
|
||||||
pub driver: RadioDriver,
|
pub driver: RadioDriver,
|
||||||
pub delay: Delay,
|
pub delay: Delay,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub struct UsbParts {
|
// SSD1306 display support is Heltec-specific. ESP32-S3 boards that use a
|
||||||
pub driver: UsbDriver,
|
// different controller (e.g. ThinkNode-M2 uses SH1106) provide their own
|
||||||
}
|
// DisplayParts / DisplayDriver / create_display directly.
|
||||||
|
#[cfg(any(
|
||||||
|
feature = "heltec_v3",
|
||||||
|
feature = "heltec_v3_uart",
|
||||||
|
feature = "heltec_v4"
|
||||||
|
))]
|
||||||
pub struct DisplayParts {
|
pub struct DisplayParts {
|
||||||
pub i2c: DisplayI2c,
|
pub i2c: DisplayI2c,
|
||||||
pub mac: [u8; 6],
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Shared initialization helpers ───────────────────────────────────
|
// ── Display driver ──────────────────────────────────────────────────
|
||||||
|
|
||||||
/// Start the ESP-RTOS scheduler and Embassy time driver.
|
#[cfg(any(
|
||||||
pub fn start_timer(timg0: esp_hal::peripherals::TIMG0<'static>) {
|
feature = "heltec_v3",
|
||||||
let timg0 = TimerGroup::new(timg0);
|
feature = "heltec_v3_uart",
|
||||||
esp_rtos::start(timg0.timer0);
|
feature = "heltec_v4"
|
||||||
|
))]
|
||||||
|
pub type DisplayDriver = ssd1306::Ssd1306Async<
|
||||||
|
ssd1306::prelude::I2CInterface<DisplayI2c>,
|
||||||
|
ssd1306::size::DisplaySize128x64,
|
||||||
|
ssd1306::mode::BufferedGraphicsModeAsync<ssd1306::size::DisplaySize128x64>,
|
||||||
|
>;
|
||||||
|
|
||||||
|
/// Construct and initialize an SSD1306 display from raw I2C.
|
||||||
|
#[cfg(any(
|
||||||
|
feature = "heltec_v3",
|
||||||
|
feature = "heltec_v3_uart",
|
||||||
|
feature = "heltec_v4"
|
||||||
|
))]
|
||||||
|
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(parts.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;
|
||||||
|
}
|
||||||
|
let _ = display.set_brightness(Brightness::BRIGHTEST).await;
|
||||||
|
Some(display)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Initialize SPI + DMA + SX1262 radio.
|
// ── Shared peripheral init helpers ──────────────────────────────────
|
||||||
|
|
||||||
|
/// Construct SX1262 radio from an initialized SPI bus.
|
||||||
///
|
///
|
||||||
/// All current ESP32-S3 boards use the same SPI/radio pin mapping:
|
/// Generic over GPIO type so boards with different radio pin assignments can
|
||||||
/// SCK=GPIO9, MOSI=GPIO10, MISO=GPIO11, NSS=GPIO8,
|
/// share this helper. `tcxo_voltage` selects the DIO3-driven TCXO supply:
|
||||||
/// Reset=GPIO12, DIO1=GPIO14, BUSY=GPIO13.
|
/// Heltec boards use `Ctrl1V8`, the ELECROW ThinkNode-M2 uses `Ctrl3V3`, etc.
|
||||||
#[allow(clippy::too_many_arguments)]
|
|
||||||
pub fn init_radio(
|
pub fn init_radio(
|
||||||
spi2: esp_hal::peripherals::SPI2<'static>,
|
spi_bus: &'static embassy_sync::mutex::Mutex<NoopRawMutex, SpiBus>,
|
||||||
dma_ch0: esp_hal::peripherals::DMA_CH0<'static>,
|
nss_pin: impl OutputPin + 'static,
|
||||||
sck: esp_hal::peripherals::GPIO9<'static>,
|
reset_pin: impl OutputPin + 'static,
|
||||||
mosi: esp_hal::peripherals::GPIO10<'static>,
|
dio1_pin: impl InputPin + 'static,
|
||||||
miso: esp_hal::peripherals::GPIO11<'static>,
|
busy_pin: impl InputPin + 'static,
|
||||||
nss_pin: esp_hal::peripherals::GPIO8<'static>,
|
tcxo_voltage: sx126x::TcxoCtrlVoltage,
|
||||||
reset_pin: esp_hal::peripherals::GPIO12<'static>,
|
|
||||||
dio1_pin: esp_hal::peripherals::GPIO14<'static>,
|
|
||||||
busy_pin: esp_hal::peripherals::GPIO13<'static>,
|
|
||||||
) -> RadioParts {
|
) -> RadioParts {
|
||||||
let spi = Spi::new(
|
|
||||||
spi2,
|
|
||||||
SpiConfig::default()
|
|
||||||
.with_frequency(Rate::from_mhz(1))
|
|
||||||
.with_mode(SpiMode::_0),
|
|
||||||
)
|
|
||||||
.expect("SPI init")
|
|
||||||
.with_sck(sck)
|
|
||||||
.with_mosi(mosi)
|
|
||||||
.with_miso(miso)
|
|
||||||
.with_dma(dma_ch0);
|
|
||||||
|
|
||||||
let (rx_buf, rx_desc, tx_buf, tx_desc) = esp_hal::dma_buffers!(256);
|
|
||||||
let dma_rx = DmaRxBuf::new(rx_desc, rx_buf).expect("DMA RX buf");
|
|
||||||
let dma_tx = DmaTxBuf::new(tx_desc, tx_buf).expect("DMA TX buf");
|
|
||||||
|
|
||||||
let spi = spi.with_buffers(dma_rx, dma_tx).into_async();
|
|
||||||
|
|
||||||
static SPI_BUS: StaticCell<embassy_sync::mutex::Mutex<NoopRawMutex, SpiBus>> =
|
|
||||||
StaticCell::new();
|
|
||||||
let spi_bus = SPI_BUS.init(embassy_sync::mutex::Mutex::new(spi));
|
|
||||||
|
|
||||||
let nss = Output::new(nss_pin, Level::High, OutputConfig::default());
|
let nss = Output::new(nss_pin, Level::High, OutputConfig::default());
|
||||||
let spi_device = SpiDevice::new(spi_bus, nss);
|
let spi_device = SpiDevice::new(spi_bus, nss);
|
||||||
|
|
||||||
@@ -107,7 +109,7 @@ pub fn init_radio(
|
|||||||
|
|
||||||
let sx_config = sx126x::Config {
|
let sx_config = sx126x::Config {
|
||||||
chip: Sx1262,
|
chip: Sx1262,
|
||||||
tcxo_ctrl: Some(sx126x::TcxoCtrlVoltage::Ctrl1V7),
|
tcxo_ctrl: Some(tcxo_voltage),
|
||||||
use_dcdc: true,
|
use_dcdc: true,
|
||||||
rx_boost: false,
|
rx_boost: false,
|
||||||
};
|
};
|
||||||
@@ -117,34 +119,3 @@ pub fn init_radio(
|
|||||||
delay: Delay,
|
delay: Delay,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Initialize USB OTG CDC-ACM driver.
|
|
||||||
pub fn init_usb(
|
|
||||||
usb0: esp_hal::peripherals::USB0<'static>,
|
|
||||||
dp: esp_hal::peripherals::GPIO20<'static>,
|
|
||||||
dm: esp_hal::peripherals::GPIO19<'static>,
|
|
||||||
) -> UsbParts {
|
|
||||||
let usb_inst = esp_hal::otg_fs::Usb::new(usb0, dp, dm);
|
|
||||||
static EP_OUT_BUF: StaticCell<[u8; 1024]> = StaticCell::new();
|
|
||||||
let ep_out_buf = EP_OUT_BUF.init([0u8; 1024]);
|
|
||||||
UsbParts {
|
|
||||||
driver: esp_hal::otg_fs::asynch::Driver::new(
|
|
||||||
usb_inst,
|
|
||||||
ep_out_buf,
|
|
||||||
esp_hal::otg_fs::asynch::Config::default(),
|
|
||||||
),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Initialize I2C bus for display.
|
|
||||||
pub fn init_display_i2c(
|
|
||||||
i2c0: esp_hal::peripherals::I2C0<'static>,
|
|
||||||
sda: esp_hal::peripherals::GPIO17<'static>,
|
|
||||||
scl: esp_hal::peripherals::GPIO18<'static>,
|
|
||||||
) -> DisplayI2c {
|
|
||||||
I2c::new(i2c0, I2cConfig::default())
|
|
||||||
.expect("I2C init")
|
|
||||||
.with_sda(sda)
|
|
||||||
.with_scl(scl)
|
|
||||||
.into_async()
|
|
||||||
}
|
|
||||||
@@ -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(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+70
-18
@@ -1,12 +1,27 @@
|
|||||||
|
//! Heltec WiFi LoRa 32 V3 — native USB (requires hardware mod).
|
||||||
|
//!
|
||||||
|
//! Stock V3 boards route USB through a CP2102 bridge to UART. To use native
|
||||||
|
//! USB CDC-ACM, solder R29 (D+) and R3 (D-) on the PCB and disconnect the
|
||||||
|
//! CP2102 from the USB data lines. See the project README for details.
|
||||||
|
//!
|
||||||
|
//! For stock (unmodified) V3 boards, use the `heltec_v3_uart` feature instead.
|
||||||
|
|
||||||
use esp_hal::gpio::{Level, Output, OutputConfig};
|
use esp_hal::gpio::{Level, Output, OutputConfig};
|
||||||
|
use static_cell::StaticCell;
|
||||||
|
|
||||||
use super::esp32s3;
|
use super::esp32s3;
|
||||||
use super::traits::LoRaBoard;
|
use super::traits::{BoardParts, LoRaBoard};
|
||||||
|
use crate::driver::simple_led::SimpleLed;
|
||||||
|
use crate::hal::esp32s3 as mcu;
|
||||||
|
|
||||||
#[allow(unused_imports)] // Re-exported for other modules (display, radio, usb tasks)
|
pub use super::esp32s3::{create_display, DisplayDriver, DisplayParts, RadioDriver, RadioParts};
|
||||||
pub use super::esp32s3::{DisplayI2c, DisplayParts, RadioDriver, RadioParts, UsbDriver, UsbParts};
|
|
||||||
|
|
||||||
// ── Board init ───────────────────────────────────────────────────────
|
pub type UsbDriver = esp_hal::otg_fs::asynch::Driver<'static>;
|
||||||
|
pub type LedDriver = SimpleLed<Output<'static>>;
|
||||||
|
|
||||||
|
pub struct UsbParts {
|
||||||
|
pub driver: UsbDriver,
|
||||||
|
}
|
||||||
|
|
||||||
pub struct Board {
|
pub struct Board {
|
||||||
p: esp_hal::peripherals::Peripherals,
|
p: esp_hal::peripherals::Peripherals,
|
||||||
@@ -16,35 +31,72 @@ impl LoRaBoard for Board {
|
|||||||
const NAME: &'static str = "Heltec V3";
|
const NAME: &'static str = "Heltec V3";
|
||||||
const TX_POWER_RANGE: (i8, i8) = (-9, 22);
|
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 {
|
fn init() -> Self {
|
||||||
let p = esp_hal::init(esp_hal::Config::default());
|
let p = esp_hal::init(esp_hal::Config::default());
|
||||||
Self { p }
|
Self { p }
|
||||||
}
|
}
|
||||||
|
|
||||||
fn mac_address() -> [u8; 6] {
|
fn mac_address() -> [u8; 6] {
|
||||||
esp_hal::efuse::Efuse::mac_address()
|
mcu::mac_address()
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
impl Board {
|
fn into_parts(self) -> BoardParts<RadioParts, UsbParts, DisplayParts, LedDriver> {
|
||||||
pub fn into_parts(self) -> (RadioParts, UsbParts, Option<DisplayParts>) {
|
|
||||||
let p = self.p;
|
let p = self.p;
|
||||||
|
|
||||||
esp32s3::start_timer(p.TIMG0);
|
mcu::start_timer(p.TIMG0, p.SW_INTERRUPT);
|
||||||
|
|
||||||
// Vext power: GPIO21, active LOW to enable OLED
|
// Vext power: GPIO36, active LOW to enable peripherals.
|
||||||
let vext = Output::new(p.GPIO21, Level::Low, OutputConfig::default());
|
// Output has no Drop impl, so the pin stays configured for program lifetime.
|
||||||
core::mem::forget(vext); // hold pin low permanently; drop would reset it
|
let _vext = Output::new(p.GPIO36, Level::Low, OutputConfig::default());
|
||||||
|
|
||||||
|
let spi_bus = mcu::init_spi(p.SPI2, p.DMA_CH0, p.GPIO9, p.GPIO10, p.GPIO11);
|
||||||
let radio = esp32s3::init_radio(
|
let radio = esp32s3::init_radio(
|
||||||
p.SPI2, p.DMA_CH0, p.GPIO9, p.GPIO10, p.GPIO11, p.GPIO8, p.GPIO12, p.GPIO14,
|
spi_bus,
|
||||||
|
p.GPIO8,
|
||||||
|
p.GPIO12,
|
||||||
|
p.GPIO14,
|
||||||
p.GPIO13,
|
p.GPIO13,
|
||||||
|
lora_phy::sx126x::TcxoCtrlVoltage::Ctrl1V8,
|
||||||
);
|
);
|
||||||
let usb = esp32s3::init_usb(p.USB0, p.GPIO20, p.GPIO19);
|
|
||||||
let i2c = esp32s3::init_display_i2c(p.I2C0, p.GPIO17, p.GPIO18);
|
|
||||||
let mac = Self::mac_address();
|
|
||||||
let display = Some(DisplayParts { i2c, mac });
|
|
||||||
|
|
||||||
(radio, usb, display)
|
// Note: switches internal USB PHY from Serial-JTAG to OTG.
|
||||||
|
let usb_inst = esp_hal::otg_fs::Usb::new(p.USB0, p.GPIO20, p.GPIO19);
|
||||||
|
static EP_OUT_BUF: StaticCell<[u8; 1024]> = StaticCell::new();
|
||||||
|
let ep_out_buf = EP_OUT_BUF.init([0u8; 1024]);
|
||||||
|
let host = UsbParts {
|
||||||
|
driver: esp_hal::otg_fs::asynch::Driver::new(
|
||||||
|
usb_inst,
|
||||||
|
ep_out_buf,
|
||||||
|
esp_hal::otg_fs::asynch::Config::default(),
|
||||||
|
),
|
||||||
|
};
|
||||||
|
|
||||||
|
// SSD1306 display reset: pulse GPIO21 low->high before I2C init.
|
||||||
|
// Output has no Drop impl; pin stays HIGH after this block.
|
||||||
|
let mut display_rst = Output::new(p.GPIO21, Level::Low, OutputConfig::default());
|
||||||
|
esp_hal::delay::Delay::new().delay_millis(10);
|
||||||
|
display_rst.set_high();
|
||||||
|
esp_hal::delay::Delay::new().delay_millis(10);
|
||||||
|
|
||||||
|
let i2c = mcu::init_i2c(p.I2C0, p.GPIO17, p.GPIO18);
|
||||||
|
let display = Some(DisplayParts { i2c });
|
||||||
|
|
||||||
|
// White LED on GPIO35
|
||||||
|
let led_pin = Output::new(p.GPIO35, Level::Low, OutputConfig::default());
|
||||||
|
let led = SimpleLed(led_pin);
|
||||||
|
|
||||||
|
BoardParts {
|
||||||
|
radio,
|
||||||
|
host,
|
||||||
|
display,
|
||||||
|
led,
|
||||||
|
mac: Self::mac_address(),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
//! Heltec WiFi LoRa 32 V3 — stock UART (via CP2102 bridge).
|
||||||
|
//!
|
||||||
|
//! This is for unmodified V3 boards where USB goes through the CP2102
|
||||||
|
//! bridge to UART0. The board appears as /dev/ttyUSB* (not ttyACM*).
|
||||||
|
//!
|
||||||
|
//! If you've done the hardware mod (solder R29/R3, disconnect CP2102),
|
||||||
|
//! use the `heltec_v3` feature instead for native USB CDC-ACM support.
|
||||||
|
|
||||||
|
use esp_hal::gpio::{Level, Output, OutputConfig};
|
||||||
|
|
||||||
|
use super::esp32s3;
|
||||||
|
use super::traits::{BoardParts, LoRaBoard};
|
||||||
|
use crate::driver::simple_led::SimpleLed;
|
||||||
|
use crate::hal::esp32s3 as mcu;
|
||||||
|
|
||||||
|
pub use super::esp32s3::{create_display, DisplayDriver, DisplayParts, RadioDriver, RadioParts};
|
||||||
|
|
||||||
|
pub type UartDriver = esp_hal::uart::Uart<'static, esp_hal::Async>;
|
||||||
|
pub type LedDriver = SimpleLed<Output<'static>>;
|
||||||
|
|
||||||
|
pub struct UartParts {
|
||||||
|
pub driver: UartDriver,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct Board {
|
||||||
|
p: esp_hal::peripherals::Peripherals,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl LoRaBoard for Board {
|
||||||
|
const NAME: &'static str = "Heltec V3 (UART)";
|
||||||
|
const TX_POWER_RANGE: (i8, i8) = (-9, 22);
|
||||||
|
|
||||||
|
type RadioParts = RadioParts;
|
||||||
|
type CommParts = UartParts;
|
||||||
|
type DisplayParts = DisplayParts;
|
||||||
|
type DisplayDriver = DisplayDriver;
|
||||||
|
type LedDriver = LedDriver;
|
||||||
|
|
||||||
|
fn init() -> Self {
|
||||||
|
let p = esp_hal::init(esp_hal::Config::default());
|
||||||
|
Self { p }
|
||||||
|
}
|
||||||
|
|
||||||
|
fn mac_address() -> [u8; 6] {
|
||||||
|
mcu::mac_address()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn into_parts(self) -> BoardParts<RadioParts, UartParts, DisplayParts, LedDriver> {
|
||||||
|
let p = self.p;
|
||||||
|
|
||||||
|
mcu::start_timer(p.TIMG0, p.SW_INTERRUPT);
|
||||||
|
|
||||||
|
// Vext power: GPIO36, active LOW to enable peripherals.
|
||||||
|
// Output has no Drop impl, so the pin stays configured for program lifetime.
|
||||||
|
let _vext = Output::new(p.GPIO36, Level::Low, OutputConfig::default());
|
||||||
|
|
||||||
|
let spi_bus = mcu::init_spi(p.SPI2, p.DMA_CH0, p.GPIO9, p.GPIO10, p.GPIO11);
|
||||||
|
let radio = esp32s3::init_radio(
|
||||||
|
spi_bus,
|
||||||
|
p.GPIO8,
|
||||||
|
p.GPIO12,
|
||||||
|
p.GPIO14,
|
||||||
|
p.GPIO13,
|
||||||
|
lora_phy::sx126x::TcxoCtrlVoltage::Ctrl1V8,
|
||||||
|
);
|
||||||
|
|
||||||
|
use esp_hal::uart::{Config as UartConfig, Uart};
|
||||||
|
let host = UartParts {
|
||||||
|
driver: Uart::new(p.UART0, UartConfig::default())
|
||||||
|
.expect("UART init")
|
||||||
|
.with_tx(p.GPIO43)
|
||||||
|
.with_rx(p.GPIO44)
|
||||||
|
.into_async(),
|
||||||
|
};
|
||||||
|
|
||||||
|
// SSD1306 display reset: pulse GPIO21 low->high before I2C init.
|
||||||
|
// Output has no Drop impl; pin stays HIGH after this block.
|
||||||
|
let mut display_rst = Output::new(p.GPIO21, Level::Low, OutputConfig::default());
|
||||||
|
esp_hal::delay::Delay::new().delay_millis(10);
|
||||||
|
display_rst.set_high();
|
||||||
|
esp_hal::delay::Delay::new().delay_millis(10);
|
||||||
|
|
||||||
|
let i2c = mcu::init_i2c(p.I2C0, p.GPIO17, p.GPIO18);
|
||||||
|
let display = Some(DisplayParts { i2c });
|
||||||
|
|
||||||
|
// White LED on GPIO35
|
||||||
|
let led_pin = Output::new(p.GPIO35, Level::Low, OutputConfig::default());
|
||||||
|
let led = SimpleLed(led_pin);
|
||||||
|
|
||||||
|
BoardParts {
|
||||||
|
radio,
|
||||||
|
host,
|
||||||
|
display,
|
||||||
|
led,
|
||||||
|
mac: Self::mac_address(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+55
-21
@@ -1,12 +1,20 @@
|
|||||||
use esp_hal::gpio::{Level, Output, OutputConfig};
|
use esp_hal::gpio::{Level, Output, OutputConfig};
|
||||||
|
use static_cell::StaticCell;
|
||||||
|
|
||||||
use super::esp32s3;
|
use super::esp32s3;
|
||||||
use super::traits::LoRaBoard;
|
use super::traits::{BoardParts, LoRaBoard};
|
||||||
|
use crate::driver::simple_led::SimpleLed;
|
||||||
|
use crate::hal::esp32s3 as mcu;
|
||||||
|
|
||||||
#[allow(unused_imports)] // Re-exported for other modules (display, radio, usb tasks)
|
pub use super::esp32s3::{create_display, DisplayDriver, DisplayParts, RadioDriver, RadioParts};
|
||||||
pub use super::esp32s3::{DisplayI2c, DisplayParts, RadioDriver, RadioParts, UsbDriver, UsbParts};
|
|
||||||
|
|
||||||
// ── Board implementation ────────────────────────────────────────────
|
pub type UsbDriver = esp_hal::otg_fs::asynch::Driver<'static>;
|
||||||
|
|
||||||
|
pub type LedDriver = SimpleLed<Output<'static>>;
|
||||||
|
|
||||||
|
pub struct UsbParts {
|
||||||
|
pub driver: UsbDriver,
|
||||||
|
}
|
||||||
|
|
||||||
pub struct Board {
|
pub struct Board {
|
||||||
p: esp_hal::peripherals::Peripherals,
|
p: esp_hal::peripherals::Peripherals,
|
||||||
@@ -16,46 +24,72 @@ impl LoRaBoard for Board {
|
|||||||
const NAME: &'static str = "Heltec V4";
|
const NAME: &'static str = "Heltec V4";
|
||||||
const TX_POWER_RANGE: (i8, i8) = (-9, 22);
|
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 {
|
fn init() -> Self {
|
||||||
let p = esp_hal::init(esp_hal::Config::default());
|
let p = esp_hal::init(esp_hal::Config::default());
|
||||||
Self { p }
|
Self { p }
|
||||||
}
|
}
|
||||||
|
|
||||||
fn mac_address() -> [u8; 6] {
|
fn mac_address() -> [u8; 6] {
|
||||||
esp_hal::efuse::Efuse::mac_address()
|
mcu::mac_address()
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
impl Board {
|
fn into_parts(self) -> BoardParts<RadioParts, UsbParts, DisplayParts, LedDriver> {
|
||||||
pub fn into_parts(self) -> (RadioParts, UsbParts, Option<DisplayParts>) {
|
|
||||||
let p = self.p;
|
let p = self.p;
|
||||||
|
|
||||||
esp32s3::start_timer(p.TIMG0);
|
mcu::start_timer(p.TIMG0, p.SW_INTERRUPT);
|
||||||
|
|
||||||
// Vext power: GPIO36, active LOW to enable peripherals
|
// Vext power: GPIO36, active LOW to enable peripherals.
|
||||||
let vext = Output::new(p.GPIO36, Level::Low, OutputConfig::default());
|
// Output has no Drop impl, so the pin stays configured for program lifetime.
|
||||||
core::mem::forget(vext); // hold pin low permanently; drop would reset it
|
let _vext = Output::new(p.GPIO36, Level::Low, OutputConfig::default());
|
||||||
|
|
||||||
|
let spi_bus = mcu::init_spi(p.SPI2, p.DMA_CH0, p.GPIO9, p.GPIO10, p.GPIO11);
|
||||||
let radio = esp32s3::init_radio(
|
let radio = esp32s3::init_radio(
|
||||||
p.SPI2, p.DMA_CH0, p.GPIO9, p.GPIO10, p.GPIO11, p.GPIO8, p.GPIO12, p.GPIO14,
|
spi_bus,
|
||||||
|
p.GPIO8,
|
||||||
|
p.GPIO12,
|
||||||
|
p.GPIO14,
|
||||||
p.GPIO13,
|
p.GPIO13,
|
||||||
|
lora_phy::sx126x::TcxoCtrlVoltage::Ctrl1V8,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Note: switches internal USB PHY from Serial-JTAG to OTG.
|
// Note: switches internal USB PHY from Serial-JTAG to OTG.
|
||||||
// espflash --monitor will stop working after this point.
|
let usb_inst = esp_hal::otg_fs::Usb::new(p.USB0, p.GPIO20, p.GPIO19);
|
||||||
let usb = esp32s3::init_usb(p.USB0, p.GPIO20, p.GPIO19);
|
static EP_OUT_BUF: StaticCell<[u8; 1024]> = StaticCell::new();
|
||||||
|
let ep_out_buf = EP_OUT_BUF.init([0u8; 1024]);
|
||||||
|
let host = UsbParts {
|
||||||
|
driver: esp_hal::otg_fs::asynch::Driver::new(
|
||||||
|
usb_inst,
|
||||||
|
ep_out_buf,
|
||||||
|
esp_hal::otg_fs::asynch::Config::default(),
|
||||||
|
),
|
||||||
|
};
|
||||||
|
|
||||||
// SSD1315 display reset: pulse GPIO21 low->high before I2C init
|
// SSD1315 display reset: pulse GPIO21 low->high before I2C init.
|
||||||
|
// Output has no Drop impl; pin stays HIGH after this block.
|
||||||
let mut display_rst = Output::new(p.GPIO21, Level::Low, OutputConfig::default());
|
let mut display_rst = Output::new(p.GPIO21, Level::Low, OutputConfig::default());
|
||||||
esp_hal::delay::Delay::new().delay_millis(10);
|
esp_hal::delay::Delay::new().delay_millis(10);
|
||||||
display_rst.set_high();
|
display_rst.set_high();
|
||||||
esp_hal::delay::Delay::new().delay_millis(10);
|
esp_hal::delay::Delay::new().delay_millis(10);
|
||||||
core::mem::forget(display_rst); // hold reset high permanently
|
|
||||||
|
|
||||||
let i2c = esp32s3::init_display_i2c(p.I2C0, p.GPIO17, p.GPIO18);
|
let i2c = mcu::init_i2c(p.I2C0, p.GPIO17, p.GPIO18);
|
||||||
let mac = Self::mac_address();
|
let display = Some(DisplayParts { i2c });
|
||||||
let display = Some(DisplayParts { i2c, mac });
|
|
||||||
|
|
||||||
(radio, usb, display)
|
// Orange LED on GPIO35
|
||||||
|
let led_pin = Output::new(p.GPIO35, Level::Low, OutputConfig::default());
|
||||||
|
let led = SimpleLed(led_pin);
|
||||||
|
|
||||||
|
BoardParts {
|
||||||
|
radio,
|
||||||
|
host,
|
||||||
|
display,
|
||||||
|
led,
|
||||||
|
mac: Self::mac_address(),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,140 @@
|
|||||||
|
//! LilyGo T-Beam (classic) — ESP32 + SX1276 + SSD1306 OLED + AXP192 PMIC.
|
||||||
|
//!
|
||||||
|
//! First-generation T-Beam. ESP32 (dual-core Xtensa LX6, no native USB),
|
||||||
|
//! SX1276 module with XTAL (no TCXO), 128×64 SSD1306 OLED, AXP192 PMIC
|
||||||
|
//! gating the LoRa and OLED rails, CP2102 USB-UART bridge for host I/O.
|
||||||
|
//!
|
||||||
|
//! The AXP192 shares an I²C bus with the OLED (SDA=21, SCL=22). PMIC init
|
||||||
|
//! runs blocking on that bus first, then the same pins are re-initialized
|
||||||
|
//! in async mode for the display task.
|
||||||
|
//!
|
||||||
|
//! Pin map (verified from MeshCore variants/lilygo_tbeam_SX1276/):
|
||||||
|
//! LoRa SPI: SCK=5, MOSI=27, MISO=19, CS=18
|
||||||
|
//! LoRa ctl: RESET=23, DIO0=26 (primary IRQ — SX127x has no BUSY)
|
||||||
|
//! OLED I²C: SDA=21, SCL=22, SSD1306 @ 0x3C (shared with AXP192 @ 0x34)
|
||||||
|
//! UART host: TX=1, RX=3 via CP2102 bridge (USB 10c4:ea60)
|
||||||
|
//! LED: GPIO4 (active LOW — red LED under the OLED, wrapped in
|
||||||
|
//! InvertedPin so SimpleLed sees normal polarity)
|
||||||
|
//!
|
||||||
|
//! GPS / battery / IMU / PCF8563 RTC are populated on the board but unused
|
||||||
|
//! by this firmware.
|
||||||
|
|
||||||
|
use esp_hal::gpio::{Level, Output, OutputConfig};
|
||||||
|
use esp_hal::i2c::master::{Config as I2cConfig, I2c};
|
||||||
|
|
||||||
|
use super::esp32;
|
||||||
|
use super::traits::{BoardParts, LoRaBoard};
|
||||||
|
use crate::driver::axp;
|
||||||
|
use crate::driver::inverted_pin::InvertedPin;
|
||||||
|
use crate::driver::simple_led::SimpleLed;
|
||||||
|
use crate::hal::esp32 as mcu;
|
||||||
|
use crate::protocol::RadioChipId;
|
||||||
|
|
||||||
|
pub use super::esp32::{create_display, DisplayDriver, DisplayParts, RadioDriver, RadioParts};
|
||||||
|
|
||||||
|
// ── Concrete peripheral types ───────────────────────────────────────
|
||||||
|
|
||||||
|
pub type UartDriver = esp_hal::uart::Uart<'static, esp_hal::Async>;
|
||||||
|
pub type LedDriver = SimpleLed<InvertedPin<Output<'static>>>;
|
||||||
|
|
||||||
|
// ── Peripheral bundles ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
pub struct UartParts {
|
||||||
|
pub driver: UartDriver,
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Board init ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
pub struct Board {
|
||||||
|
p: esp_hal::peripherals::Peripherals,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl LoRaBoard for Board {
|
||||||
|
const NAME: &'static str = "LilyGo T-Beam";
|
||||||
|
// SX1276 PA_BOOST range per the datasheet: +2 dBm min, +20 dBm max.
|
||||||
|
const TX_POWER_RANGE: (i8, i8) = (2, 20);
|
||||||
|
|
||||||
|
type RadioParts = RadioParts;
|
||||||
|
type CommParts = UartParts;
|
||||||
|
type DisplayParts = DisplayParts;
|
||||||
|
type DisplayDriver = DisplayDriver;
|
||||||
|
type LedDriver = LedDriver;
|
||||||
|
|
||||||
|
fn init() -> Self {
|
||||||
|
let p = esp_hal::init(esp_hal::Config::default());
|
||||||
|
Self { p }
|
||||||
|
}
|
||||||
|
|
||||||
|
fn mac_address() -> [u8; 6] {
|
||||||
|
mcu::mac_address()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn radio_chip_id() -> u16 {
|
||||||
|
RadioChipId::Sx1276.as_u16()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn freq_range_hz() -> (u32, u32) {
|
||||||
|
// SX1276 tuning window per datasheet (LF + HF bands combined).
|
||||||
|
(137_000_000, 1_020_000_000)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn supported_sf_bitmap() -> u16 {
|
||||||
|
// SX127x supports SF6–SF12 (no SF5 like the SX126x).
|
||||||
|
0x1FC0
|
||||||
|
}
|
||||||
|
|
||||||
|
fn into_parts(self) -> BoardParts<RadioParts, UartParts, DisplayParts, LedDriver> {
|
||||||
|
let p = self.p;
|
||||||
|
|
||||||
|
mcu::start_timer(p.TIMG0, p.SW_INTERRUPT);
|
||||||
|
|
||||||
|
// AXP192 and SSD1306 share I²C0 (SDA=21, SCL=22). Build the bus
|
||||||
|
// once as blocking, drive the PMIC init, then convert to async for
|
||||||
|
// the display task — avoids re-consuming the I²C0 peripheral.
|
||||||
|
// PMIC failure isn't fatal to boot: log and proceed so the rest of
|
||||||
|
// the firmware can still emit diagnostics.
|
||||||
|
let mut i2c_blocking = I2c::new(p.I2C0, I2cConfig::default())
|
||||||
|
.expect("I2C0 init")
|
||||||
|
.with_sda(p.GPIO21)
|
||||||
|
.with_scl(p.GPIO22);
|
||||||
|
if let Err(_e) = axp::axp192_init_lora_oled(&mut i2c_blocking) {
|
||||||
|
defmt::error!("AXP192 init failed — LoRa rail may be dead");
|
||||||
|
}
|
||||||
|
// Let DCDC1 / LDO2 settle before SPI or display traffic.
|
||||||
|
esp_hal::delay::Delay::new().delay_millis(20);
|
||||||
|
|
||||||
|
// SPI2 for SX1276: SCK=5, MOSI=27, MISO=19
|
||||||
|
let spi_bus = mcu::init_spi(p.SPI2, p.DMA_SPI2, p.GPIO5, p.GPIO27, p.GPIO19);
|
||||||
|
|
||||||
|
// SX1276: CS=18, RESET=23, DIO0=26 (primary IRQ)
|
||||||
|
let radio = esp32::init_radio(spi_bus, p.GPIO18, p.GPIO23, p.GPIO26);
|
||||||
|
|
||||||
|
// UART0 via CP2102 bridge: TX=1, RX=3 (ESP32 default console)
|
||||||
|
use esp_hal::uart::{Config as UartConfig, Uart};
|
||||||
|
let host = UartParts {
|
||||||
|
driver: Uart::new(p.UART0, UartConfig::default())
|
||||||
|
.expect("UART init")
|
||||||
|
.with_tx(p.GPIO1)
|
||||||
|
.with_rx(p.GPIO3)
|
||||||
|
.into_async(),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Hand the (now async) I²C bus to the SSD1306 display task.
|
||||||
|
let display = Some(DisplayParts {
|
||||||
|
i2c: i2c_blocking.into_async(),
|
||||||
|
});
|
||||||
|
|
||||||
|
// TX activity LED on GPIO4 (active LOW — pin idles HIGH = LED off).
|
||||||
|
// InvertedPin flips set_high/set_low so SimpleLed stays polarity-agnostic.
|
||||||
|
let led_pin = Output::new(p.GPIO4, Level::High, OutputConfig::default());
|
||||||
|
let led = SimpleLed(InvertedPin(led_pin));
|
||||||
|
|
||||||
|
BoardParts {
|
||||||
|
radio,
|
||||||
|
host,
|
||||||
|
display,
|
||||||
|
led,
|
||||||
|
mac: Self::mac_address(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,153 @@
|
|||||||
|
//! LilyGo T-Beam S3 Supreme — ESP32-S3 + SX1262 + SH1106 OLED + AXP2101 PMIC.
|
||||||
|
//!
|
||||||
|
//! Big-brother of the classic T-Beam: ESP32-S3-WROOM-1 module, SX1262 with a
|
||||||
|
//! TCXO, 128×64 SH1106 OLED, AXP2101 PMIC on a dedicated I²C1 bus. Native USB
|
||||||
|
//! (no UART bridge chip).
|
||||||
|
//!
|
||||||
|
//! The AXP2101 gates power to the SX1262 and OLED. Without its init sequence
|
||||||
|
//! the LoRa chip never responds on SPI and `LoRa::new` fails.
|
||||||
|
//!
|
||||||
|
//! Pin map (verified from MeshCore variants/lilygo_tbeam_supreme_SX1262/):
|
||||||
|
//! LoRa SPI: SCK=12, MOSI=11, MISO=13, CS=10
|
||||||
|
//! LoRa ctl: RESET=5, DIO1=1, BUSY=4 (TCXO @ 1.6 V via DIO3)
|
||||||
|
//! OLED I²C: SDA=17, SCL=18, SH1106 @ 0x3C (I²C0)
|
||||||
|
//! PMIC I²C: SDA=42, SCL=41, AXP2101 @ 0x34 (I²C1, blocking, PMIC-only)
|
||||||
|
//! USB: native USB-C on GPIO19/20 (fixed ESP32-S3 D+/D-)
|
||||||
|
//! LED: GPIO6 (active HIGH indicator, reuses MeshCore's P_LORA_TX_LED)
|
||||||
|
//!
|
||||||
|
//! GPS / battery / IMU / SD / BME280 are unused — DongLoRa is a transparent
|
||||||
|
//! LoRa-over-USB bridge. The PMIC init only enables ALDO1 (OLED) and ALDO3
|
||||||
|
//! (LoRa); other rails are left at their AXP2101 power-on defaults.
|
||||||
|
|
||||||
|
use esp_hal::gpio::{Level, Output, OutputConfig};
|
||||||
|
use esp_hal::i2c::master::{Config as I2cConfig, I2c};
|
||||||
|
use static_cell::StaticCell;
|
||||||
|
|
||||||
|
use super::esp32s3;
|
||||||
|
use super::traits::{BoardParts, LoRaBoard};
|
||||||
|
use crate::driver::axp;
|
||||||
|
use crate::driver::sh1106::Sh1106;
|
||||||
|
use crate::driver::simple_led::SimpleLed;
|
||||||
|
use crate::hal::esp32s3 as mcu;
|
||||||
|
|
||||||
|
pub use super::esp32s3::{DisplayI2c, RadioDriver, RadioParts};
|
||||||
|
|
||||||
|
// ── Concrete peripheral types ───────────────────────────────────────
|
||||||
|
|
||||||
|
pub type DisplayDriver = Sh1106<DisplayI2c>;
|
||||||
|
pub type UsbDriver = esp_hal::otg_fs::asynch::Driver<'static>;
|
||||||
|
pub type LedDriver = SimpleLed<Output<'static>>;
|
||||||
|
|
||||||
|
// ── Peripheral bundles ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
pub struct UsbParts {
|
||||||
|
pub driver: UsbDriver,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct DisplayParts {
|
||||||
|
pub i2c: DisplayI2c,
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Display init ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
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() {
|
||||||
|
defmt::error!("SH1106 display init failed");
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let _ = display.set_brightness(0xFF).await;
|
||||||
|
Some(display)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Board init ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
pub struct Board {
|
||||||
|
p: esp_hal::peripherals::Peripherals,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl LoRaBoard for Board {
|
||||||
|
const NAME: &'static str = "LilyGo T-Beam S3 Supreme";
|
||||||
|
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 {
|
||||||
|
let p = esp_hal::init(esp_hal::Config::default());
|
||||||
|
Self { p }
|
||||||
|
}
|
||||||
|
|
||||||
|
fn mac_address() -> [u8; 6] {
|
||||||
|
mcu::mac_address()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn into_parts(self) -> BoardParts<RadioParts, UsbParts, DisplayParts, LedDriver> {
|
||||||
|
let p = self.p;
|
||||||
|
|
||||||
|
mcu::start_timer(p.TIMG0, p.SW_INTERRUPT);
|
||||||
|
|
||||||
|
// AXP2101 PMIC on I²C1 (SDA=42, SCL=41). Blocking, one-shot: we only
|
||||||
|
// need to flip the LoRa/OLED rails on, then the bus can be dropped.
|
||||||
|
// If this fails the LoRa chip stays unpowered — log and keep going so
|
||||||
|
// the rest of the boot path can still produce diagnostics.
|
||||||
|
{
|
||||||
|
let mut pmic = I2c::new(p.I2C1, I2cConfig::default())
|
||||||
|
.expect("I2C1 init")
|
||||||
|
.with_sda(p.GPIO42)
|
||||||
|
.with_scl(p.GPIO41);
|
||||||
|
if let Err(_e) = axp::axp2101_init_lora_oled(&mut pmic) {
|
||||||
|
defmt::error!("AXP2101 init failed — LoRa rail may be dead");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Let ALDO1/ALDO3 settle before touching the LoRa SPI or OLED I²C.
|
||||||
|
esp_hal::delay::Delay::new().delay_millis(20);
|
||||||
|
|
||||||
|
// SPI2 for SX1262: SCK=12, MOSI=11, MISO=13
|
||||||
|
let spi_bus = mcu::init_spi(p.SPI2, p.DMA_CH0, p.GPIO12, p.GPIO11, p.GPIO13);
|
||||||
|
|
||||||
|
// SX1262: CS=10, RESET=5, DIO1=1, BUSY=4, TCXO @ 1.6 V via DIO3.
|
||||||
|
let radio = esp32s3::init_radio(
|
||||||
|
spi_bus,
|
||||||
|
p.GPIO10,
|
||||||
|
p.GPIO5,
|
||||||
|
p.GPIO1,
|
||||||
|
p.GPIO4,
|
||||||
|
lora_phy::sx126x::TcxoCtrlVoltage::Ctrl1V6,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Native USB-C on GPIO19/20. Switches the internal USB PHY from
|
||||||
|
// Serial-JTAG to OTG.
|
||||||
|
let usb_inst = esp_hal::otg_fs::Usb::new(p.USB0, p.GPIO20, p.GPIO19);
|
||||||
|
static EP_OUT_BUF: StaticCell<[u8; 1024]> = StaticCell::new();
|
||||||
|
let ep_out_buf = EP_OUT_BUF.init([0u8; 1024]);
|
||||||
|
let host = UsbParts {
|
||||||
|
driver: esp_hal::otg_fs::asynch::Driver::new(
|
||||||
|
usb_inst,
|
||||||
|
ep_out_buf,
|
||||||
|
esp_hal::otg_fs::asynch::Config::default(),
|
||||||
|
),
|
||||||
|
};
|
||||||
|
|
||||||
|
// I2C0 for SH1106 OLED: SDA=17, SCL=18 (async, handed to display task).
|
||||||
|
let i2c = mcu::init_i2c(p.I2C0, p.GPIO17, p.GPIO18);
|
||||||
|
let display = Some(DisplayParts { i2c });
|
||||||
|
|
||||||
|
// Activity LED on GPIO6 (active HIGH).
|
||||||
|
let led_pin = Output::new(p.GPIO6, Level::Low, OutputConfig::default());
|
||||||
|
let led = SimpleLed(led_pin);
|
||||||
|
|
||||||
|
BoardParts {
|
||||||
|
radio,
|
||||||
|
host,
|
||||||
|
display,
|
||||||
|
led,
|
||||||
|
mac: Self::mac_address(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+8
-3
@@ -2,9 +2,14 @@
|
|||||||
pub mod traits;
|
pub mod traits;
|
||||||
pub use traits::*;
|
pub use traits::*;
|
||||||
|
|
||||||
#[cfg(any(feature = "heltec_v3", feature = "heltec_v4"))]
|
{% for helper, helper_features in helpers|items -%}
|
||||||
mod esp32s3;
|
{% if helper_features|length == 1 -%}
|
||||||
|
#[cfg(feature = "{{ helper_features[0] }}")]
|
||||||
|
{% else -%}
|
||||||
|
#[cfg(any({% for f in helper_features %}feature = "{{ f }}"{% if not loop.last %}, {% endif %}{% endfor %}))]
|
||||||
|
{% endif -%}
|
||||||
|
mod {{ helper }};
|
||||||
|
{% endfor %}
|
||||||
cfg_if::cfg_if! {
|
cfg_if::cfg_if! {
|
||||||
{%- for board in boards %}
|
{%- for board in boards %}
|
||||||
{% if loop.first %}if{% else %}else if{% endif %} #[cfg(feature = "{{ board }}")] {
|
{% if loop.first %}if{% else %}else if{% endif %} #[cfg(feature = "{{ board }}")] {
|
||||||
|
|||||||
+110
-72
@@ -1,40 +1,41 @@
|
|||||||
use embassy_embedded_hal::shared_bus::asynch::spi::SpiDevice;
|
use embassy_embedded_hal::shared_bus::asynch::spi::SpiDevice;
|
||||||
use embassy_nrf::gpio::{Input, Level, Output, OutputDrive, Pull};
|
use embassy_nrf::gpio::{Input, Level, Output, OutputDrive, Pull};
|
||||||
use embassy_nrf::spim::{self, Spim};
|
use embassy_nrf::spim::{self, Spim};
|
||||||
use embassy_nrf::twim::{self, Twim};
|
use embassy_nrf::twim;
|
||||||
use embassy_nrf::usb::Driver;
|
use embassy_nrf::usb::Driver;
|
||||||
use embassy_nrf::{bind_interrupts, Peripherals};
|
|
||||||
use embassy_sync::blocking_mutex::raw::NoopRawMutex;
|
use embassy_sync::blocking_mutex::raw::NoopRawMutex;
|
||||||
use embassy_time::Delay;
|
use embassy_time::Delay;
|
||||||
use static_cell::StaticCell;
|
|
||||||
|
|
||||||
use lora_phy::iv::GenericSx126xInterfaceVariant;
|
use lora_phy::iv::GenericSx126xInterfaceVariant;
|
||||||
use lora_phy::sx126x::{self, Sx1262, Sx126x};
|
use lora_phy::sx126x::{self, Sx1262, Sx126x};
|
||||||
|
|
||||||
use super::traits::LoRaBoard;
|
use super::traits::{BoardParts, LoRaBoard};
|
||||||
|
use crate::hal::nrf52840 as mcu;
|
||||||
|
|
||||||
bind_interrupts!(struct Irqs {
|
// ── Concrete peripheral types ───────────────────────────────────────
|
||||||
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>;
|
|
||||||
});
|
|
||||||
|
|
||||||
// ── Concrete peripheral types ────────────────────────────────────────
|
|
||||||
|
|
||||||
type SpiBus = Spim<'static>;
|
|
||||||
type Nss = Output<'static>;
|
type Nss = Output<'static>;
|
||||||
type Iv = GenericSx126xInterfaceVariant<Output<'static>, Input<'static>>;
|
type Iv = GenericSx126xInterfaceVariant<Output<'static>, Input<'static>>;
|
||||||
type RadioSpiDevice = SpiDevice<'static, NoopRawMutex, SpiBus, Nss>;
|
type RadioSpiDevice = SpiDevice<'static, NoopRawMutex, mcu::SpiBus, Nss>;
|
||||||
/// SX126x driver (pre-LoRa construction — LoRa::new is async).
|
|
||||||
pub type RadioDriver = Sx126x<RadioSpiDevice, Iv, Sx1262>;
|
pub type RadioDriver = Sx126x<RadioSpiDevice, Iv, Sx1262>;
|
||||||
|
pub type UsbDriver = mcu::UsbNrfDriver;
|
||||||
|
pub type DisplayI2c = mcu::I2cBus;
|
||||||
|
pub type LedDriver = crate::driver::simple_led::SimpleLed<Output<'static>>;
|
||||||
|
|
||||||
/// USB driver for CDC-ACM.
|
// SH1106 hand-rolled driver instead of the ssd1306 crate. The RAK1921
|
||||||
pub type UsbDriver = Driver<'static, &'static embassy_nrf::usb::vbus_detect::SoftwareVbusDetect>;
|
// 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>;
|
||||||
|
|
||||||
/// I2C bus for an optional SSD1306 OLED.
|
// ── Peripheral bundles ──────────────────────────────────────────────
|
||||||
pub type DisplayI2c = Twim<'static>;
|
|
||||||
|
|
||||||
// ── Peripheral bundles ───────────────────────────────────────────────
|
|
||||||
|
|
||||||
pub struct RadioParts {
|
pub struct RadioParts {
|
||||||
pub driver: RadioDriver,
|
pub driver: RadioDriver,
|
||||||
@@ -47,72 +48,97 @@ pub struct UsbParts {
|
|||||||
|
|
||||||
pub struct DisplayParts {
|
pub struct DisplayParts {
|
||||||
pub i2c: DisplayI2c,
|
pub i2c: DisplayI2c,
|
||||||
pub mac: [u8; 6],
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Board init ───────────────────────────────────────────────────────
|
// ── Display init ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
pub async fn create_display(parts: DisplayParts) -> Option<DisplayDriver> {
|
||||||
|
let mut display = crate::driver::sh1106::Sh1106::new(parts.i2c, 0x3C);
|
||||||
|
|
||||||
|
// 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
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
defmt::error!("SH1106 display init failed after 3 attempts");
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Board init ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
pub struct Board {
|
pub struct Board {
|
||||||
p: Peripherals,
|
p: embassy_nrf::Peripherals,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl super::traits::LoRaBoard for Board {
|
impl LoRaBoard for Board {
|
||||||
const NAME: &'static str = "RAK WisBlock 4631";
|
const NAME: &'static str = "RAK WisBlock 4631";
|
||||||
const TX_POWER_RANGE: (i8, i8) = (-9, 22);
|
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 {
|
fn init() -> Self {
|
||||||
let p = embassy_nrf::init(Default::default());
|
// 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 }
|
Self { p }
|
||||||
}
|
}
|
||||||
|
|
||||||
fn mac_address() -> [u8; 6] {
|
fn mac_address() -> [u8; 6] {
|
||||||
// SAFETY: 0x10000000 is the nRF52840 FICR (Factory Information Configuration
|
mcu::mac_address()
|
||||||
// Registers) base address. Offsets 0x0A4 and 0x0A8 are the DEVICEADDR[0] and
|
|
||||||
// DEVICEADDR[1] registers containing the factory-programmed device address.
|
|
||||||
// read_volatile is correct for memory-mapped hardware registers.
|
|
||||||
unsafe {
|
|
||||||
let ficr = 0x10000000 as *const u32;
|
|
||||||
let addr0 = core::ptr::read_volatile(ficr.byte_add(0x0A4));
|
|
||||||
let addr1 = core::ptr::read_volatile(ficr.byte_add(0x0A8));
|
|
||||||
[
|
|
||||||
addr0 as u8,
|
|
||||||
(addr0 >> 8) as u8,
|
|
||||||
(addr0 >> 16) as u8,
|
|
||||||
(addr0 >> 24) as u8,
|
|
||||||
addr1 as u8,
|
|
||||||
(addr1 >> 8) as u8,
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
impl Board {
|
fn into_parts(self) -> BoardParts<RadioParts, UsbParts, DisplayParts, LedDriver> {
|
||||||
|
|
||||||
pub fn into_parts(self) -> (RadioParts, UsbParts, Option<DisplayParts>) {
|
|
||||||
let p = self.p;
|
let p = self.p;
|
||||||
|
|
||||||
// ── SPI bus for SX1262 ───────────────────────────────────
|
// SX1262 is power-gated on P1.05 — must be HIGH before any SPI access.
|
||||||
|
let sx_power_en = Output::new(p.P1_05, Level::High, OutputDrive::Standard);
|
||||||
|
core::mem::forget(sx_power_en);
|
||||||
|
|
||||||
|
// ── SPI bus for SX1262 ──────────────────────────────────
|
||||||
let mut spi_cfg = spim::Config::default();
|
let mut spi_cfg = spim::Config::default();
|
||||||
spi_cfg.frequency = spim::Frequency::M1;
|
spi_cfg.frequency = spim::Frequency::M1;
|
||||||
let spi = Spim::new(p.SPI3, Irqs, p.P1_11, p.P1_13, p.P1_12, spi_cfg);
|
// embassy_nrf::spim::Spim::new args: (spim, irq, sck, miso, mosi, config).
|
||||||
|
// Per RAK4631 BSP: SCK=P1.11, MISO=P1.13, MOSI=P1.12.
|
||||||
static SPI_BUS: StaticCell<embassy_sync::mutex::Mutex<NoopRawMutex, SpiBus>> =
|
let spi = Spim::new(p.SPI3, mcu::Irqs, p.P1_11, p.P1_13, p.P1_12, spi_cfg);
|
||||||
StaticCell::new();
|
let spi_bus = mcu::share_spi_bus(spi);
|
||||||
let spi_bus = SPI_BUS.init(embassy_sync::mutex::Mutex::new(spi));
|
|
||||||
|
|
||||||
let nss = Output::new(p.P1_10, Level::High, OutputDrive::Standard);
|
let nss = Output::new(p.P1_10, Level::High, OutputDrive::Standard);
|
||||||
let spi_device = SpiDevice::new(spi_bus, nss);
|
let spi_device = SpiDevice::new(spi_bus, nss);
|
||||||
|
|
||||||
// ── SX1262 control pins ──────────────────────────────────
|
|
||||||
let reset = Output::new(p.P1_06, Level::High, OutputDrive::Standard);
|
let reset = Output::new(p.P1_06, Level::High, OutputDrive::Standard);
|
||||||
let dio1 = Input::new(p.P1_15, Pull::Down);
|
let dio1 = Input::new(p.P1_15, Pull::Down);
|
||||||
let busy = Input::new(p.P1_14, Pull::Down);
|
// BUSY has an internal 20 kΩ pull-up on the SX1262 die (datasheet
|
||||||
|
// p. 53); an MCU-side Pull::Down fights it and leaves the line at
|
||||||
|
// an indeterminate voltage, making wait_on_busy unreliable. See
|
||||||
|
// lora-rs/lora-rs#260.
|
||||||
|
let busy = Input::new(p.P1_14, Pull::None);
|
||||||
|
|
||||||
let iv = GenericSx126xInterfaceVariant::new(reset, dio1, busy, None, None).expect("SX1262 interface init");
|
let iv = GenericSx126xInterfaceVariant::new(reset, dio1, busy, None, None)
|
||||||
|
.expect("SX1262 interface init");
|
||||||
|
|
||||||
let sx_config = sx126x::Config {
|
let sx_config = sx126x::Config {
|
||||||
chip: Sx1262,
|
chip: Sx1262,
|
||||||
tcxo_ctrl: Some(sx126x::TcxoCtrlVoltage::Ctrl1V7),
|
tcxo_ctrl: Some(sx126x::TcxoCtrlVoltage::Ctrl1V8),
|
||||||
use_dcdc: true,
|
use_dcdc: true,
|
||||||
rx_boost: false,
|
rx_boost: false,
|
||||||
};
|
};
|
||||||
@@ -122,24 +148,36 @@ impl Board {
|
|||||||
delay: Delay,
|
delay: Delay,
|
||||||
};
|
};
|
||||||
|
|
||||||
// ── USB ──────────────────────────────────────────────────
|
// ── USB ─────────────────────────────────────────────────
|
||||||
static VBUS: StaticCell<embassy_nrf::usb::vbus_detect::SoftwareVbusDetect> =
|
// SoftwareVbusDetect(vbus_detected, power_ready). Mark both true so the
|
||||||
StaticCell::new();
|
// driver doesn't block waiting for a power-ready signal we never raise.
|
||||||
let vbus = VBUS.init(embassy_nrf::usb::vbus_detect::SoftwareVbusDetect::new(true, false));
|
let vbus = mcu::alloc_vbus_detect(true, true);
|
||||||
let usb = UsbParts {
|
let host = UsbParts {
|
||||||
driver: Driver::new(p.USBD, Irqs, vbus),
|
driver: Driver::new(p.USBD, mcu::Irqs, vbus),
|
||||||
};
|
};
|
||||||
|
|
||||||
// ── Display (optional RAK1921 SSD1306 OLED on I2C) ──────
|
// ── Display (optional RAK1921 SSD1306 OLED on I2C) ─────
|
||||||
// Always provide the I2C bus; display_task detects presence
|
let twim_buf = mcu::alloc_i2c_buffer();
|
||||||
// via SSD1306 init (fails gracefully if no display attached).
|
let i2c = embassy_nrf::twim::Twim::new(
|
||||||
static TWIM_BUF: StaticCell<[u8; 256]> = StaticCell::new();
|
p.TWISPI0,
|
||||||
let twim_buf = TWIM_BUF.init([0u8; 256]);
|
mcu::Irqs,
|
||||||
let i2c_cfg = twim::Config::default();
|
p.P0_13,
|
||||||
let i2c = Twim::new(p.TWISPI0, Irqs, p.P0_13, p.P0_14, i2c_cfg, twim_buf);
|
p.P0_14,
|
||||||
let mac = Self::mac_address();
|
twim::Config::default(),
|
||||||
let display = Some(DisplayParts { i2c, mac });
|
twim_buf,
|
||||||
|
);
|
||||||
|
let display = Some(DisplayParts { i2c });
|
||||||
|
|
||||||
(radio, usb, display)
|
// Green LED on P1.03 (RAK19007 Base, active HIGH).
|
||||||
|
let led_pin = Output::new(p.P1_03, Level::Low, OutputDrive::Standard);
|
||||||
|
let led = crate::driver::simple_led::SimpleLed(led_pin);
|
||||||
|
|
||||||
|
BoardParts {
|
||||||
|
radio,
|
||||||
|
host,
|
||||||
|
display,
|
||||||
|
led,
|
||||||
|
mac: Self::mac_address(),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+76
-13
@@ -1,31 +1,94 @@
|
|||||||
//! Board abstraction trait.
|
//! Board abstraction trait.
|
||||||
//!
|
//!
|
||||||
//! Every board must implement [`LoRaBoard`]. The compiler enforces completeness —
|
//! Every board must implement [`LoRaBoard`] with concrete associated types
|
||||||
//! missing constants or methods are compile errors, not runtime surprises.
|
//! for its radio, host communication, display, and LED peripherals. The
|
||||||
//!
|
//! compiler enforces completeness — missing types or methods are compile errors.
|
||||||
//! Board modules also export concrete types (`RadioParts`, `UsbParts`,
|
|
||||||
//! `DisplayParts`, `RadioDriver`, `UsbDriver`, `DisplayI2c`) that the tasks
|
|
||||||
//! use directly. The trait verifies metadata and initialization; the types
|
|
||||||
//! verify hardware compatibility.
|
|
||||||
//!
|
//!
|
||||||
//! See `src/board/PORTING.md` for a step-by-step guide to adding a new board.
|
//! See `src/board/PORTING.md` for a step-by-step guide to adding a new board.
|
||||||
|
|
||||||
|
use crate::display::BoardDisplay;
|
||||||
|
|
||||||
|
/// Async RGB LED control. Boards without an LED use `()` which is a no-op.
|
||||||
|
pub trait RgbLed {
|
||||||
|
fn set_rgb(&mut self, r: u8, g: u8, b: u8) -> impl core::future::Future<Output = ()>;
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RgbLed for () {
|
||||||
|
async fn set_rgb(&mut self, _r: u8, _g: u8, _b: u8) {}
|
||||||
|
}
|
||||||
|
|
||||||
/// Compile-time contract for a DongLoRa board.
|
/// Compile-time contract for a DongLoRa board.
|
||||||
///
|
pub trait LoRaBoard: Sized {
|
||||||
/// Every board's `Board` struct must implement this trait. It verifies that
|
|
||||||
/// the board provides all required metadata and initialization methods.
|
|
||||||
/// The tasks use the board's concrete types directly (not the trait's
|
|
||||||
/// associated types) because Embassy tasks must be concrete.
|
|
||||||
pub trait LoRaBoard {
|
|
||||||
/// Human-readable board name (shown on display splash screen).
|
/// Human-readable board name (shown on display splash screen).
|
||||||
const NAME: &'static str;
|
const NAME: &'static str;
|
||||||
|
|
||||||
/// TX power range in dBm (min, max) for this board's radio + PA.
|
/// TX power range in dBm (min, max) for this board's radio + PA.
|
||||||
const TX_POWER_RANGE: (i8, i8);
|
const TX_POWER_RANGE: (i8, i8);
|
||||||
|
|
||||||
|
/// Radio peripheral bundle (driver + delay).
|
||||||
|
type RadioParts;
|
||||||
|
|
||||||
|
/// Host communication peripheral bundle (USB or UART driver).
|
||||||
|
type CommParts;
|
||||||
|
|
||||||
|
/// Display peripheral bundle (I2C bus for display init).
|
||||||
|
type DisplayParts;
|
||||||
|
|
||||||
|
/// 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;
|
||||||
|
|
||||||
/// Initialize the board hardware.
|
/// Initialize the board hardware.
|
||||||
fn init() -> Self;
|
fn init() -> Self;
|
||||||
|
|
||||||
/// Read the board's unique hardware address (MAC, device ID, etc.).
|
/// Read the board's unique hardware address (MAC, device ID, etc.).
|
||||||
fn mac_address() -> [u8; 6];
|
fn mac_address() -> [u8; 6];
|
||||||
|
|
||||||
|
/// Radio chip identifier reported in `GET_INFO`. Defaults to SX1262
|
||||||
|
/// (`0x0002`), matching every currently-supported board. Override for
|
||||||
|
/// boards that carry a different chip (LLCC68, SX1261, LR11xx, …).
|
||||||
|
fn radio_chip_id() -> u16 {
|
||||||
|
// SX1262 — see PROTOCOL.md §8.
|
||||||
|
0x0002
|
||||||
|
}
|
||||||
|
|
||||||
|
/// (freq_min_hz, freq_max_hz) — the RF front-end's effective tuning
|
||||||
|
/// window on this specific board. Reported in `GET_INFO`. Defaults
|
||||||
|
/// to the SX1262's nominal 150–960 MHz range.
|
||||||
|
fn freq_range_hz() -> (u32, u32) {
|
||||||
|
(150_000_000, 960_000_000)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Bitmap of supported LoRa spreading factors — bit N = SF N. Defaults
|
||||||
|
/// to SX126x's SF5–SF12 (`0x1FE0`). SX127x boards must override to
|
||||||
|
/// `0x1FC0` (SF6–SF12); LLCC68 boards to `0x0FE0` (SF5–SF11).
|
||||||
|
fn supported_sf_bitmap() -> u16 {
|
||||||
|
0x1FE0
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Bitmap of supported LoRa bandwidths. Bit positions match
|
||||||
|
/// `LoRaBandwidth::as_u8()` (sub-GHz BW enum values 0..9). Defaults
|
||||||
|
/// to all sub-GHz BWs (`0x03FF`). SX128x boards flip the 2.4 GHz
|
||||||
|
/// bits when we add them.
|
||||||
|
fn supported_bw_bitmap() -> u16 {
|
||||||
|
0x03FF
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Decompose initialized board into peripheral bundles for each task.
|
||||||
|
fn into_parts(
|
||||||
|
self,
|
||||||
|
) -> BoardParts<Self::RadioParts, Self::CommParts, Self::DisplayParts, Self::LedDriver>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Peripheral bundles for each firmware task, produced by [`LoRaBoard::into_parts`].
|
||||||
|
pub struct BoardParts<R, C, D, L> {
|
||||||
|
pub radio: R,
|
||||||
|
pub host: C,
|
||||||
|
pub display: Option<D>,
|
||||||
|
pub led: L,
|
||||||
|
pub mac: [u8; 6],
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,154 @@
|
|||||||
|
use embassy_embedded_hal::shared_bus::asynch::spi::SpiDevice;
|
||||||
|
use embassy_rp::gpio::{Input, Level, Output, Pull};
|
||||||
|
use embassy_rp::spi;
|
||||||
|
use embassy_sync::blocking_mutex::raw::NoopRawMutex;
|
||||||
|
use embassy_time::Delay;
|
||||||
|
|
||||||
|
use lora_phy::iv::GenericSx126xInterfaceVariant;
|
||||||
|
use lora_phy::sx126x::{self, Sx1262, Sx126x};
|
||||||
|
|
||||||
|
use super::traits::{BoardParts, LoRaBoard};
|
||||||
|
use crate::hal::rp2040 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::UsbRpDriver;
|
||||||
|
pub type LedDriver = crate::driver::simple_led::SimpleLed<Output<'static>>;
|
||||||
|
|
||||||
|
// Display types — never instantiated (no OLED on this board), but the
|
||||||
|
// trait requires concrete associated types that satisfy the bounds.
|
||||||
|
pub type DisplayI2c =
|
||||||
|
embassy_rp::i2c::I2c<'static, embassy_rp::peripherals::I2C0, embassy_rp::i2c::Async>;
|
||||||
|
pub type DisplayDriver = ssd1306::Ssd1306Async<
|
||||||
|
ssd1306::prelude::I2CInterface<DisplayI2c>,
|
||||||
|
ssd1306::size::DisplaySize128x64,
|
||||||
|
ssd1306::mode::BufferedGraphicsModeAsync<ssd1306::size::DisplaySize128x64>,
|
||||||
|
>;
|
||||||
|
|
||||||
|
// ── Peripheral bundles ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
pub struct RadioParts {
|
||||||
|
pub driver: RadioDriver,
|
||||||
|
pub delay: Delay,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct UsbParts {
|
||||||
|
pub driver: UsbDriver,
|
||||||
|
}
|
||||||
|
|
||||||
|
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(_parts: DisplayParts) -> Option<DisplayDriver> {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Board init ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
pub struct Board {
|
||||||
|
p: embassy_rp::Peripherals,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl LoRaBoard for Board {
|
||||||
|
const NAME: &'static str = "Waveshare RP2040-LoRa";
|
||||||
|
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 {
|
||||||
|
let p = embassy_rp::init(Default::default());
|
||||||
|
Self { p }
|
||||||
|
}
|
||||||
|
|
||||||
|
fn mac_address() -> [u8; 6] {
|
||||||
|
mcu::mac_address()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn into_parts(self) -> BoardParts<RadioParts, UsbParts, DisplayParts, LedDriver> {
|
||||||
|
let p = self.p;
|
||||||
|
|
||||||
|
// ── MAC address from flash unique ID ───────────────────
|
||||||
|
let mut flash =
|
||||||
|
embassy_rp::flash::Flash::<_, _, { 2 * 1024 * 1024 }>::new_blocking(p.FLASH);
|
||||||
|
mcu::init_mac(&mut flash);
|
||||||
|
|
||||||
|
// ── SPI1 bus for SX1262 ────────────────────────────────
|
||||||
|
//
|
||||||
|
// Pin mapping from Meshtastic & MeshCore firmware (confirmed working):
|
||||||
|
// SCK = GPIO14, MOSI = GPIO15, MISO = GPIO24, CS = GPIO13
|
||||||
|
//
|
||||||
|
// The Waveshare wiki (https://www.waveshare.com/wiki/RP2040-LoRa) lists
|
||||||
|
// different pins (MOSI=11, MISO=8, CS=9) but those don't work in practice.
|
||||||
|
// Both sets are valid SPI1 alternate functions on RP2040.
|
||||||
|
let mut spi_cfg = spi::Config::default();
|
||||||
|
spi_cfg.frequency = 1_000_000;
|
||||||
|
let spi = spi::Spi::new(
|
||||||
|
p.SPI1,
|
||||||
|
p.PIN_14,
|
||||||
|
p.PIN_15,
|
||||||
|
p.PIN_24,
|
||||||
|
p.DMA_CH0,
|
||||||
|
p.DMA_CH1,
|
||||||
|
mcu::Irqs,
|
||||||
|
spi_cfg,
|
||||||
|
);
|
||||||
|
let spi_bus = mcu::share_spi_bus(spi);
|
||||||
|
|
||||||
|
let nss = Output::new(p.PIN_13, Level::High);
|
||||||
|
let spi_device = SpiDevice::new(spi_bus, nss);
|
||||||
|
|
||||||
|
let reset = Output::new(p.PIN_23, Level::High);
|
||||||
|
let dio1 = Input::new(p.PIN_16, Pull::Down);
|
||||||
|
let busy = Input::new(p.PIN_18, Pull::Down);
|
||||||
|
|
||||||
|
// PE4259 antenna switch: GPIO17 = !CTRL (rx_enable).
|
||||||
|
// DIO2 is internally wired as CTRL (tx_enable), managed by the SX1262.
|
||||||
|
let rx_enable = Output::new(p.PIN_17, Level::Low);
|
||||||
|
let iv = GenericSx126xInterfaceVariant::new(reset, dio1, busy, Some(rx_enable), None)
|
||||||
|
.expect("SX1262 interface init");
|
||||||
|
|
||||||
|
let sx_config = sx126x::Config {
|
||||||
|
chip: Sx1262,
|
||||||
|
tcxo_ctrl: None, // No TCXO on this board
|
||||||
|
use_dcdc: true,
|
||||||
|
rx_boost: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
let radio = RadioParts {
|
||||||
|
driver: Sx126x::new(spi_device, iv, sx_config),
|
||||||
|
delay: Delay,
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── USB ────────────────────────────────────────────────
|
||||||
|
let host = UsbParts {
|
||||||
|
driver: embassy_rp::usb::Driver::new(p.USB, mcu::Irqs),
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── LED on GPIO25 ──────────────────────────────────────
|
||||||
|
let led_pin = Output::new(p.PIN_25, Level::Low);
|
||||||
|
let led = crate::driver::simple_led::SimpleLed(led_pin);
|
||||||
|
|
||||||
|
BoardParts {
|
||||||
|
radio,
|
||||||
|
host,
|
||||||
|
display: None,
|
||||||
|
led,
|
||||||
|
mac: Self::mac_address(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,156 @@
|
|||||||
|
use embassy_embedded_hal::shared_bus::asynch::spi::SpiDevice;
|
||||||
|
use embassy_nrf::gpio::{Input, Level, Output, OutputDrive, Pull};
|
||||||
|
use embassy_nrf::spim::{self, Spim};
|
||||||
|
use embassy_nrf::twim;
|
||||||
|
use embassy_nrf::usb::Driver;
|
||||||
|
use embassy_sync::blocking_mutex::raw::NoopRawMutex;
|
||||||
|
use embassy_time::Delay;
|
||||||
|
|
||||||
|
use lora_phy::iv::GenericSx126xInterfaceVariant;
|
||||||
|
use lora_phy::sx126x::{self, Sx1262, Sx126x};
|
||||||
|
|
||||||
|
use super::traits::{BoardParts, LoRaBoard};
|
||||||
|
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;
|
||||||
|
pub type DisplayI2c = mcu::I2cBus;
|
||||||
|
|
||||||
|
pub type LedDriver = crate::driver::simple_led::SimpleLed<Output<'static>>;
|
||||||
|
|
||||||
|
pub type DisplayDriver = crate::driver::sh1106::Sh1106<DisplayI2c>;
|
||||||
|
|
||||||
|
// ── Peripheral bundles ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
pub struct RadioParts {
|
||||||
|
pub driver: RadioDriver,
|
||||||
|
pub delay: Delay,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct UsbParts {
|
||||||
|
pub driver: UsbDriver,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct DisplayParts {
|
||||||
|
pub i2c: DisplayI2c,
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Display init ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
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() {
|
||||||
|
defmt::error!("SH1106 display init failed");
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let _ = display.set_brightness(0xFF).await;
|
||||||
|
Some(display)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Board init ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
pub struct Board {
|
||||||
|
p: embassy_nrf::Peripherals,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl LoRaBoard for Board {
|
||||||
|
const NAME: &'static str = "Wio Tracker L1";
|
||||||
|
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 {
|
||||||
|
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;
|
||||||
|
|
||||||
|
// ── SPI bus for SX1262 ──────────────────────────────────
|
||||||
|
let mut spi_cfg = spim::Config::default();
|
||||||
|
spi_cfg.frequency = spim::Frequency::M1;
|
||||||
|
let spi = Spim::new(p.SPI3, mcu::Irqs, p.P0_30, p.P0_03, p.P0_28, spi_cfg);
|
||||||
|
let spi_bus = mcu::share_spi_bus(spi);
|
||||||
|
|
||||||
|
let nss = Output::new(p.P1_14, Level::High, OutputDrive::Standard);
|
||||||
|
let spi_device = SpiDevice::new(spi_bus, nss);
|
||||||
|
|
||||||
|
let reset = Output::new(p.P1_07, Level::High, OutputDrive::Standard);
|
||||||
|
let dio1 = Input::new(p.P0_07, Pull::Down);
|
||||||
|
// BUSY has an internal 20 kΩ pull-up on the SX1262 die (datasheet
|
||||||
|
// p. 53); an MCU-side Pull::Down fights it and leaves the line at
|
||||||
|
// an indeterminate voltage, making wait_on_busy unreliable. See
|
||||||
|
// lora-rs/lora-rs#260.
|
||||||
|
let busy = Input::new(p.P1_10, Pull::None);
|
||||||
|
|
||||||
|
// RF switch: RXEN on P1.08 (GPIO, toggled by lora_phy), TX via DIO2 (SX1262 internal).
|
||||||
|
let rx_enable = Output::new(p.P1_08, Level::Low, OutputDrive::Standard);
|
||||||
|
let iv = GenericSx126xInterfaceVariant::new(reset, dio1, busy, Some(rx_enable), 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),
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── BAT_CTL: P0.04, active HIGH — enables power to OLED and battery sense
|
||||||
|
let bat_ctl = Output::new(p.P0_04, Level::High, OutputDrive::Standard);
|
||||||
|
core::mem::forget(bat_ctl);
|
||||||
|
|
||||||
|
// ── Display (SH1106 OLED on I2C) ────────────────────────
|
||||||
|
let twim_buf = mcu::alloc_i2c_buffer();
|
||||||
|
let i2c = embassy_nrf::twim::Twim::new(
|
||||||
|
p.TWISPI0,
|
||||||
|
mcu::Irqs,
|
||||||
|
p.P0_06,
|
||||||
|
p.P0_05,
|
||||||
|
twim::Config::default(),
|
||||||
|
twim_buf,
|
||||||
|
);
|
||||||
|
let display = Some(DisplayParts { i2c });
|
||||||
|
|
||||||
|
// Green LED on P1.01
|
||||||
|
let led_pin = Output::new(p.P1_01, Level::Low, OutputDrive::Standard);
|
||||||
|
let led = crate::driver::simple_led::SimpleLed(led_pin);
|
||||||
|
|
||||||
|
BoardParts {
|
||||||
|
radio,
|
||||||
|
host,
|
||||||
|
display,
|
||||||
|
led,
|
||||||
|
mac: Self::mac_address(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+132
-51
@@ -1,67 +1,148 @@
|
|||||||
//! Inter-task communication channels.
|
//! Inter-task communication channels for DongLoRa Protocol v2.
|
||||||
//!
|
//!
|
||||||
//! Each channel connects exactly two tasks:
|
//! - [`CommandChannel`]: host_task → radio_task (tag-correlated commands)
|
||||||
//! - [`CommandChannel`]: USB → Radio (host commands)
|
//! - [`OutboundChannel`]: radio_task → host_task (tag-correlated OK/ERR/TX_DONE
|
||||||
//! - [`ResponseChannel`]: Radio → USB (firmware responses)
|
//! and tag-0 async RX/ERR events in one queue)
|
||||||
//! - [`DisplayCommandChannel`]: USB → Display (on/off/reset)
|
//! - [`DisplayCommandChannel`]: host_task → display_task (internal only —
|
||||||
//! - [`StatusWatch`]: Radio → Display (observable radio state)
|
//! `On` on connect, `Reset` on disconnect; no longer driven from the wire)
|
||||||
|
//! - [`RadioEventChannel`]: radio_task → display_task (typed mode/packet events)
|
||||||
|
|
||||||
use embassy_sync::blocking_mutex::raw::CriticalSectionRawMutex;
|
use embassy_sync::blocking_mutex::raw::CriticalSectionRawMutex;
|
||||||
use embassy_sync::channel::Channel;
|
use embassy_sync::channel::Channel;
|
||||||
use embassy_sync::watch::Watch;
|
use portable_atomic::AtomicBool;
|
||||||
|
|
||||||
use crate::protocol::{Command, RadioConfig, Response};
|
use crate::protocol::{Command, DeviceMessage, LoRaConfig};
|
||||||
|
|
||||||
/// Display commands routed from usb_task to display_task.
|
/// True while a Reset has been queued in `CommandChannel` but not yet
|
||||||
#[derive(Debug, Clone, Copy, defmt::Format)]
|
/// consumed by the radio task. Dedupes injections from any host chart
|
||||||
pub enum DisplayCommand {
|
/// (USB DTR-driven OR UART inactivity-driven) that fires `reset_radio`
|
||||||
On,
|
/// repeatedly during quiet/inactive periods. Without this dedup, a slow
|
||||||
Off,
|
/// 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.
|
||||||
|
///
|
||||||
|
/// `Command` carries a non-zero correlation tag (the host framing layer
|
||||||
|
/// rejects tag=0 before it reaches here). `Reset` is a lifecycle signal
|
||||||
|
/// from the host transport layer (DTR drop, UART teardown, or spec §3.4
|
||||||
|
/// inactivity timeout) telling the radio to park back to Unconfigured
|
||||||
|
/// and drain queues silently.
|
||||||
|
#[allow(clippy::large_enum_variant)]
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub enum InboundEvent {
|
||||||
|
Command { tag: u16, cmd: Command },
|
||||||
Reset,
|
Reset,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// USB-to-display command channel (depth 4: display commands are rare events).
|
/// Host-to-radio event channel (depth 8).
|
||||||
|
pub type CommandChannel = Channel<CriticalSectionRawMutex, InboundEvent, 8>;
|
||||||
|
|
||||||
|
// ── Radio → Host: OK / ERR / TX_DONE / RX / async ERR ──────────────
|
||||||
|
|
||||||
|
/// One outbound D→H frame carrying its tag. Tag-correlated messages
|
||||||
|
/// (OK, ERR in response, TX_DONE) carry the originating command's tag;
|
||||||
|
/// async events (RX, async ERR) carry `tag = 0x0000`.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct OutboundFrame {
|
||||||
|
pub tag: u16,
|
||||||
|
pub msg: DeviceMessage,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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) ────────────
|
||||||
|
|
||||||
|
/// Internal display command routed from host_task to display_task. The
|
||||||
|
/// wire protocol no longer carries DisplayOn/Off (`PROTOCOL.md §15.2`);
|
||||||
|
/// these values drive the display statechart from local events only.
|
||||||
|
#[derive(Debug, Clone, Copy, defmt::Format)]
|
||||||
|
#[allow(dead_code)] // `Off` is reserved for future diagnostic paths.
|
||||||
|
pub enum DisplayCommand {
|
||||||
|
/// Host connected (DTR rose on USB, or UART came up). Wake display.
|
||||||
|
On,
|
||||||
|
/// Host disconnected. Park display.
|
||||||
|
Off,
|
||||||
|
/// Reset cumulative counters (e.g., on config change).
|
||||||
|
Reset,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Host-to-display command channel.
|
||||||
pub type DisplayCommandChannel = Channel<CriticalSectionRawMutex, DisplayCommand, 4>;
|
pub type DisplayCommandChannel = Channel<CriticalSectionRawMutex, DisplayCommand, 4>;
|
||||||
|
|
||||||
/// Host-to-radio command channel (depth 16: buffer bursty host command sequences).
|
// ── Radio → Display: typed packet/mode events ──────────────────────
|
||||||
pub type CommandChannel = Channel<CriticalSectionRawMutex, Command, 16>;
|
|
||||||
|
|
||||||
/// Radio-to-host response channel (depth 32: buffer RX packets while USB writes).
|
/// Radio → downstream typed events. These drive the display; they are
|
||||||
pub type ResponseChannel = Channel<CriticalSectionRawMutex, Response, 32>;
|
/// NOT the same thing as `OutboundFrame` (which goes out the host
|
||||||
|
/// transport). Mode events describe what the radio is doing now; packet
|
||||||
/// Observable radio status for the display task (2 receivers max).
|
/// events describe what just happened.
|
||||||
/// Display always sees the latest state — intermediate updates may be
|
|
||||||
/// skipped, which is fine for a status display.
|
|
||||||
pub type StatusWatch = Watch<CriticalSectionRawMutex, RadioStatus, 2>;
|
|
||||||
|
|
||||||
/// Current radio state exposed to observers (e.g. display).
|
|
||||||
#[derive(Debug, Clone, defmt::Format)]
|
#[derive(Debug, Clone, defmt::Format)]
|
||||||
pub struct RadioStatus {
|
pub enum RadioEvent {
|
||||||
pub state: RadioState,
|
EnteredRx,
|
||||||
pub config: Option<RadioConfig>,
|
EnteredTx,
|
||||||
pub rx_count: u32,
|
|
||||||
pub tx_count: u32,
|
|
||||||
pub last_rssi: Option<i16>,
|
|
||||||
pub last_snr: Option<i16>,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Radio state machine states.
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, defmt::Format)]
|
|
||||||
pub enum RadioState {
|
|
||||||
Idle,
|
Idle,
|
||||||
Receiving,
|
PacketRx { rssi: i16, snr: Option<i16> },
|
||||||
Transmitting,
|
PacketTx,
|
||||||
|
ConfigChanged(LoRaConfig),
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for RadioStatus {
|
pub type RadioEventChannel = Channel<CriticalSectionRawMutex, RadioEvent, 32>;
|
||||||
fn default() -> Self {
|
|
||||||
Self {
|
|
||||||
state: RadioState::Idle,
|
|
||||||
config: None,
|
|
||||||
rx_count: 0,
|
|
||||||
tx_count: 0,
|
|
||||||
last_rssi: None,
|
|
||||||
last_snr: None,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,230 @@
|
|||||||
|
//! LED-based diagnostic blinker (opt-in via the `debug-checkpoint` cargo
|
||||||
|
//! feature).
|
||||||
|
//!
|
||||||
|
//! On boards whose UART is also the host protocol (heltec_v3_uart, any
|
||||||
|
//! UART-only variant), defmt output would corrupt the COBS-framed host
|
||||||
|
//! protocol. We can't use `defmt::info!` for live debugging on those
|
||||||
|
//! boards while the host is connected. Instead we expose a single global
|
||||||
|
//! atomic checkpoint; code sets it at interesting points in the boot /
|
||||||
|
//! dispatch flow, and a dedicated task continuously flashes the board
|
||||||
|
//! LED N times, pauses, and repeats — so a human can count the flashes
|
||||||
|
//! and report "it got to N."
|
||||||
|
//!
|
||||||
|
//! Blink pattern:
|
||||||
|
//! * Each flash: 400 ms ON, 400 ms OFF.
|
||||||
|
//! * After N flashes: 2000 ms pause (LED held OFF).
|
||||||
|
//! * Repeat indefinitely with the current `CHECKPOINT` value.
|
||||||
|
//!
|
||||||
|
//! Usage:
|
||||||
|
//! ```ignore
|
||||||
|
//! #[cfg(feature = "debug-checkpoint")]
|
||||||
|
//! crate::debug_blink::set(3); // "we reached checkpoint 3"
|
||||||
|
//! ```
|
||||||
|
//!
|
||||||
|
//! The task replaces `display_task` when the `debug-checkpoint` feature is
|
||||||
|
//! enabled — it takes the board LED and drives it exclusively.
|
||||||
|
//!
|
||||||
|
//! # Current checkpoint map
|
||||||
|
//!
|
||||||
|
//! Defined by the `crate::radio` module. Updated here any time the
|
||||||
|
//! numbering changes there.
|
||||||
|
//!
|
||||||
|
//! | Flashes | Meaning |
|
||||||
|
//! |--------:|--------------------------------------------------------|
|
||||||
|
//! | 1 | `radio_task` entered (pre-`LoRa::new`) |
|
||||||
|
//! | 2 | `LoRa::new` succeeded |
|
||||||
|
//! | 3 | Machine constructed; about to enter `run()` |
|
||||||
|
//! | 4 | In `Idle` state |
|
||||||
|
//! | 5 | In `Receiving` state |
|
||||||
|
//! | 6 | TX phase 1: entered `perform_tx` (engages latch) |
|
||||||
|
//! | 7 | TX phase 2: in CAD |
|
||||||
|
//! | 8 | TX phase 3: in `prepare_for_tx` |
|
||||||
|
//! | 9 | TX phase 4: awaiting `tx()` |
|
||||||
|
//! | 10 | TX phase 5: `do_tx` returned OK |
|
||||||
|
//! | 11 | TX phase 6: `respond_tx_done` handler entered |
|
||||||
|
//! | 12 | TX phase 7: `responses.send(TxDone)` returned |
|
||||||
|
//! | 13 | TX phase 8: `host_task` received the response from chan |
|
||||||
|
//! | 14 | TX phase 9: `host_task` wrote response bytes to UART — TERMINAL SUCCESS |
|
||||||
|
//! | 15 | TX errored somewhere — TERMINAL ERROR |
|
||||||
|
//! | 16 | Boot error: `LoRa::new` failed |
|
||||||
|
//! | 17 | Boot error: `start_rx` failed inside `start_rx_hw` |
|
||||||
|
//!
|
||||||
|
//! # Latching
|
||||||
|
//!
|
||||||
|
//! * Latch engages **only** on `set(6)` (entry to `perform_tx`).
|
||||||
|
//! * While latched, only TX-phase values (6–15) can update `CHECKPOINT`,
|
||||||
|
//! and only if strictly deeper than the current value.
|
||||||
|
//! * Terminals (14 success, 15 error) freeze the latch until reboot.
|
||||||
|
//! * `set(13)` and `set(14)` called from `host_task` are **no-ops before
|
||||||
|
//! the latch engages** — this prevents unrelated SetConfig/Ok responses
|
||||||
|
//! from polluting the display before any TX has happened.
|
||||||
|
//!
|
||||||
|
//! # Diagnostic logic
|
||||||
|
//!
|
||||||
|
//! If ai-bot reports a timeout but the LED shows:
|
||||||
|
//! * **10** → `do_tx` ok, but `TransmitDone` event never dispatched.
|
||||||
|
//! * **11** → handler entered, but `responses.send(...)` hung.
|
||||||
|
//! * **12** → send pushed to channel, but `host_task` never picked it up.
|
||||||
|
//! * **13** → `host_task` picked it up, but `tx.write_all` to UART hung.
|
||||||
|
//! * **14** → full end-to-end firmware success. Hang is on the host side
|
||||||
|
//! of the UART (mux or wire).
|
||||||
|
//! * **15** → TX errored somewhere along the way.
|
||||||
|
//!
|
||||||
|
//! # Building with the feature
|
||||||
|
//!
|
||||||
|
//! The `just` build/flash recipes pass a fixed feature list and don't
|
||||||
|
//! surface an `extra-features` argument. To build a debug image:
|
||||||
|
//!
|
||||||
|
//! ```sh
|
||||||
|
//! cd firmware
|
||||||
|
//! . ~/export-esp.sh # if building for xtensa (esp32s3)
|
||||||
|
//! cargo +esp build --release \
|
||||||
|
//! --target xtensa-esp32s3-none-elf \
|
||||||
|
//! --features heltec_v3_uart,debug-checkpoint \
|
||||||
|
//! -Zbuild-std=core,alloc
|
||||||
|
//! cp target/xtensa-esp32s3-none-elf/release/donglora \
|
||||||
|
//! builds/donglora-heltec_v3_uart-v$(sed -n 's/^version = "\(.*\)"/\1/p' \
|
||||||
|
//! Cargo.toml).elf
|
||||||
|
//! espflash flash -p /dev/ttyUSB0 builds/donglora-heltec_v3_uart-v0.4.0.elf
|
||||||
|
//! ```
|
||||||
|
//!
|
||||||
|
//! The normal `just flash heltec_v3_uart` command builds without the
|
||||||
|
//! feature, so the debug code has zero footprint on release firmware.
|
||||||
|
|
||||||
|
use core::sync::atomic::{AtomicBool, AtomicU8, Ordering};
|
||||||
|
|
||||||
|
use embassy_executor::task;
|
||||||
|
use embassy_time::{Duration, Timer};
|
||||||
|
|
||||||
|
use crate::board::{LedDriver, RgbLed};
|
||||||
|
|
||||||
|
/// Current diagnostic checkpoint — read by the blinker task. Zero means
|
||||||
|
/// "no checkpoint set yet."
|
||||||
|
///
|
||||||
|
/// Write policy (see [`set`]): once a TX-phase value (6, 11, 12, 13) is
|
||||||
|
/// stored, the latch engages and:
|
||||||
|
/// * subsequent TX-phase writes update the value only if strictly deeper,
|
||||||
|
/// * non-TX-phase writes (including the 7/10 terminal markers and the
|
||||||
|
/// steady-state 4/5 post-TX writes) are **ignored** so the deepest phase
|
||||||
|
/// reached on the FIRST TX attempt persists.
|
||||||
|
///
|
||||||
|
/// Rationale: a stuck-then-recovered TX attempt would normally flicker
|
||||||
|
/// through the display too quickly to count. Latching preserves the
|
||||||
|
/// evidence until reboot.
|
||||||
|
pub static CHECKPOINT: AtomicU8 = AtomicU8::new(0);
|
||||||
|
|
||||||
|
/// Has the TX-phase latch engaged yet? Written by [`set`], checked by
|
||||||
|
/// [`set`]. Not intended for external inspection.
|
||||||
|
static HAS_LATCHED: AtomicBool = AtomicBool::new(false);
|
||||||
|
|
||||||
|
#[inline]
|
||||||
|
fn is_host_phase(n: u8) -> bool {
|
||||||
|
// Values set only from `host_task` — meaningless before a TX has
|
||||||
|
// started, so we refuse to overwrite with them when the latch is
|
||||||
|
// not yet engaged.
|
||||||
|
n == 13 || n == 14
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ordered depth of each TX-related checkpoint. Returns `0` for values
|
||||||
|
/// that are NOT TX phases (e.g. boot / steady-state / error indicators).
|
||||||
|
///
|
||||||
|
/// Value 18 ("next_command received CmdTransmit") is intentionally the
|
||||||
|
/// SHALLOWEST TX phase — it fires before `perform_tx` entry (value 6)
|
||||||
|
/// but is numerically higher because it was added last. The latch uses
|
||||||
|
/// `phase_depth` rather than raw numeric comparison so 18 can engage
|
||||||
|
/// the latch at depth 1 and later TX phases (6..=15) deepen over it.
|
||||||
|
#[inline]
|
||||||
|
fn phase_depth(n: u8) -> u8 {
|
||||||
|
match n {
|
||||||
|
18 => 1, // pre-TX: CmdTransmit just read from commands channel
|
||||||
|
6 => 2, // perform_tx entered
|
||||||
|
7 => 3, // in CAD (before prepare_for_cad)
|
||||||
|
19 => 4, // after prepare_for_cad (awaiting lora.cad IRQ)
|
||||||
|
8 => 5, // in prepare_for_tx
|
||||||
|
9 => 6, // in tx().await
|
||||||
|
10 => 7, // do_tx returned Ok
|
||||||
|
11 => 8, // respond_tx_done entered
|
||||||
|
12 => 9, // respond_tx_done: responses.send returned
|
||||||
|
13 => 10, // host_task received response from channel
|
||||||
|
14 => 11, // host_task wrote response to UART (terminal success)
|
||||||
|
15 => 12, // TX errored (terminal error)
|
||||||
|
_ => 0, // not a TX phase
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[inline]
|
||||||
|
fn is_tx_phase(n: u8) -> bool {
|
||||||
|
phase_depth(n) > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
#[inline]
|
||||||
|
fn is_tx_terminal(n: u8) -> bool {
|
||||||
|
n == 14 || n == 15
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Record that we reached checkpoint `n`. A no-op in terms of visible
|
||||||
|
/// behavior unless the blinker task is spawned; the store itself is very
|
||||||
|
/// cheap so leaving these calls in non-debug builds is harmless.
|
||||||
|
///
|
||||||
|
/// Latch semantics:
|
||||||
|
/// * `set(18)` (CmdTransmit received by `next_command`) or `set(6)`
|
||||||
|
/// (entry to `perform_tx`) engage the latch.
|
||||||
|
/// * While latched, only strictly-deeper TX phases (by `phase_depth`)
|
||||||
|
/// update the checkpoint.
|
||||||
|
/// * Terminal values (14 success, 15 error) freeze the latch until
|
||||||
|
/// reboot.
|
||||||
|
/// * Host-phase values (13, 14) called from `host_task` are ignored
|
||||||
|
/// before the latch engages, so unrelated `Response::Ok` writes from
|
||||||
|
/// SetConfig don't pollute the LED reading before any TX.
|
||||||
|
#[inline]
|
||||||
|
pub fn set(n: u8) {
|
||||||
|
if HAS_LATCHED.load(Ordering::Relaxed) {
|
||||||
|
let cur = CHECKPOINT.load(Ordering::Relaxed);
|
||||||
|
if is_tx_terminal(cur) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if phase_depth(n) > phase_depth(cur) {
|
||||||
|
CHECKPOINT.store(n, Ordering::Relaxed);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Latch not yet engaged.
|
||||||
|
if is_host_phase(n) {
|
||||||
|
// `host_task` is draining the response channel before any TX —
|
||||||
|
// these values would be misleading. Skip them.
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
CHECKPOINT.store(n, Ordering::Relaxed);
|
||||||
|
if is_tx_phase(n) {
|
||||||
|
HAS_LATCHED.store(true, Ordering::Relaxed);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Dedicated task that continuously flashes the LED to show the current
|
||||||
|
/// checkpoint. Owns the board's LED driver exclusively — on boards where
|
||||||
|
/// the display task would normally own the LED for RX/TX blips, spawn
|
||||||
|
/// *either* this task *or* `display_task`, not both.
|
||||||
|
#[task]
|
||||||
|
pub async fn debug_blink_task(mut led: LedDriver) {
|
||||||
|
// Start with the LED off so the first flash is unambiguous.
|
||||||
|
led.set_rgb(0, 0, 0).await;
|
||||||
|
|
||||||
|
loop {
|
||||||
|
let n = CHECKPOINT.load(Ordering::Relaxed);
|
||||||
|
if n == 0 {
|
||||||
|
// No checkpoint yet — wait and re-check.
|
||||||
|
Timer::after(Duration::from_millis(200)).await;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
for _ in 0..n {
|
||||||
|
led.set_rgb(255, 255, 255).await;
|
||||||
|
Timer::after(Duration::from_millis(400)).await;
|
||||||
|
led.set_rgb(0, 0, 0).await;
|
||||||
|
Timer::after(Duration::from_millis(400)).await;
|
||||||
|
}
|
||||||
|
// Long pause to separate cycles so the human can count.
|
||||||
|
Timer::after(Duration::from_millis(2000)).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
+1141
File diff suppressed because it is too large.
Load diff
@@ -1,5 +0,0 @@
|
|||||||
//! OLED display driver with radio status dashboard.
|
|
||||||
|
|
||||||
mod render;
|
|
||||||
mod task;
|
|
||||||
pub use task::display_task;
|
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -1,398 +0,0 @@
|
|||||||
use core::fmt::Write;
|
|
||||||
|
|
||||||
use embedded_graphics::mono_font::ascii::{FONT_6X10, FONT_9X15_BOLD};
|
|
||||||
use embedded_graphics::mono_font::MonoTextStyle;
|
|
||||||
use embedded_graphics::pixelcolor::BinaryColor;
|
|
||||||
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 crate::channel::{RadioState, RadioStatus};
|
|
||||||
use crate::protocol::Bandwidth;
|
|
||||||
|
|
||||||
/// RSSI ring buffer length — supports displays up to 256px wide (2px per bar).
|
|
||||||
pub const RSSI_HISTORY_LEN: usize = 128;
|
|
||||||
|
|
||||||
// Font metrics (FONT_6X10)
|
|
||||||
const CHAR_W: i32 = 6;
|
|
||||||
const FONT_H: i32 = 10;
|
|
||||||
|
|
||||||
// Mode box: 2 chars + 1px padding each side
|
|
||||||
const MODE_BOX_W: i32 = 2 * CHAR_W + 2; // 14px
|
|
||||||
|
|
||||||
// RSSI mapping range (dBm)
|
|
||||||
const RSSI_MIN: i16 = -120;
|
|
||||||
const RSSI_MAX: i16 = 0;
|
|
||||||
|
|
||||||
/// Static board identity info for display rendering.
|
|
||||||
pub struct BoardInfo<'a> {
|
|
||||||
pub name: &'a str,
|
|
||||||
pub version: &'a str,
|
|
||||||
pub mac: &'a str,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Render the active dashboard (shown when radio is in RX or TX mode).
|
|
||||||
pub fn dashboard(
|
|
||||||
target: &mut impl DrawTarget<Color = BinaryColor>,
|
|
||||||
status: &RadioStatus,
|
|
||||||
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,
|
|
||||||
) {
|
|
||||||
let bb = target.bounding_box();
|
|
||||||
let w = bb.size.width as i32;
|
|
||||||
let h = bb.size.height as i32;
|
|
||||||
let title_x = MODE_BOX_W + 1;
|
|
||||||
let title_w = w - title_x;
|
|
||||||
let header_h = 2 * FONT_H;
|
|
||||||
let sep1_y = header_h + 3; // 3px gap below header text (clears descenders)
|
|
||||||
let info_y = sep1_y + 1; // 1px line, info text tight below separator
|
|
||||||
let sep2_y = info_y + 2 * FONT_H + 3; // 3px gap below info text (clears descenders)
|
|
||||||
let spark_top = sep2_y + 2; // 1px line + 1px gap above graph
|
|
||||||
let spark_h = h - spark_top;
|
|
||||||
let visible_bars = ((w / 2) as usize).min(RSSI_HISTORY_LEN);
|
|
||||||
|
|
||||||
let _ = target.clear(BinaryColor::Off);
|
|
||||||
let style = MonoTextStyle::new(&FONT_6X10, BinaryColor::On);
|
|
||||||
let mut buf: String<32> = String::new();
|
|
||||||
|
|
||||||
// ── Mode box (left column, 2 rows tall) ─────────────────────────
|
|
||||||
let fill = PrimitiveStyle::with_fill(BinaryColor::On);
|
|
||||||
let mut inv_style = MonoTextStyle::new(&FONT_6X10, BinaryColor::Off);
|
|
||||||
inv_style.set_background_color(Some(BinaryColor::On));
|
|
||||||
|
|
||||||
match status.state {
|
|
||||||
RadioState::Receiving => {
|
|
||||||
// "RX" inverted on row 0
|
|
||||||
Rectangle::new(Point::new(0, 0), Size::new(MODE_BOX_W as u32, FONT_H as u32))
|
|
||||||
.into_styled(fill)
|
|
||||||
.draw(target)
|
|
||||||
.ok();
|
|
||||||
Text::new("RX", Point::new(1, FONT_H - 1), inv_style)
|
|
||||||
.draw(target)
|
|
||||||
.ok();
|
|
||||||
}
|
|
||||||
RadioState::Transmitting => {
|
|
||||||
// "TX" inverted on row 1
|
|
||||||
Rectangle::new(
|
|
||||||
Point::new(0, FONT_H),
|
|
||||||
Size::new(MODE_BOX_W as u32, FONT_H as u32),
|
|
||||||
)
|
|
||||||
.into_styled(fill)
|
|
||||||
.draw(target)
|
|
||||||
.ok();
|
|
||||||
Text::new("TX", Point::new(1, 2 * FONT_H - 1), inv_style)
|
|
||||||
.draw(target)
|
|
||||||
.ok();
|
|
||||||
}
|
|
||||||
RadioState::Idle => {} // Not shown on active screen
|
|
||||||
}
|
|
||||||
|
|
||||||
// Vertical separator between mode box and title box
|
|
||||||
Line::new(Point::new(MODE_BOX_W, 0), Point::new(MODE_BOX_W, sep1_y))
|
|
||||||
.into_styled(PrimitiveStyle::with_stroke(BinaryColor::On, 1))
|
|
||||||
.draw(target)
|
|
||||||
.ok();
|
|
||||||
|
|
||||||
// ── Title box (right of mode box) ───────────────────────────────
|
|
||||||
let title_center_x = title_x + title_w / 2;
|
|
||||||
|
|
||||||
// Row 0: "DongLoRa v{version}"
|
|
||||||
buf.clear();
|
|
||||||
let _ = write!(buf, "DongLoRa v{}", board.version);
|
|
||||||
Text::with_alignment(
|
|
||||||
&buf,
|
|
||||||
Point::new(title_center_x, FONT_H - 1),
|
|
||||||
style,
|
|
||||||
Alignment::Center,
|
|
||||||
)
|
|
||||||
.draw(target)
|
|
||||||
.ok();
|
|
||||||
|
|
||||||
// Row 1: radio settings "{freq}/{bw}/{sf}/{cr}"
|
|
||||||
if let Some(cfg) = status.config {
|
|
||||||
buf.clear();
|
|
||||||
let freq_mhz = cfg.freq_hz / 1_000_000;
|
|
||||||
let freq_khz = (cfg.freq_hz % 1_000_000) / 1_000;
|
|
||||||
let bw_str = match cfg.bw {
|
|
||||||
Bandwidth::Khz7 => "7.8",
|
|
||||||
Bandwidth::Khz10 => "10.4",
|
|
||||||
Bandwidth::Khz15 => "15.6",
|
|
||||||
Bandwidth::Khz20 => "20.8",
|
|
||||||
Bandwidth::Khz31 => "31.2",
|
|
||||||
Bandwidth::Khz41 => "41.7",
|
|
||||||
Bandwidth::Khz62 => "62.5",
|
|
||||||
Bandwidth::Khz125 => "125",
|
|
||||||
Bandwidth::Khz250 => "250",
|
|
||||||
Bandwidth::Khz500 => "500",
|
|
||||||
};
|
|
||||||
let _ = write!(
|
|
||||||
buf,
|
|
||||||
"{}.{:03}/{}/{}/{}",
|
|
||||||
freq_mhz, freq_khz, bw_str, cfg.sf, cfg.cr
|
|
||||||
);
|
|
||||||
Text::with_alignment(
|
|
||||||
&buf,
|
|
||||||
Point::new(title_center_x, 2 * FONT_H - 1),
|
|
||||||
style,
|
|
||||||
Alignment::Center,
|
|
||||||
)
|
|
||||||
.draw(target)
|
|
||||||
.ok();
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── First separator line ────────────────────────────────────────
|
|
||||||
Line::new(Point::new(0, sep1_y), Point::new(w - 1, sep1_y))
|
|
||||||
.into_styled(PrimitiveStyle::with_stroke(BinaryColor::On, 1))
|
|
||||||
.draw(target)
|
|
||||||
.ok();
|
|
||||||
|
|
||||||
// ── Info rows (centered) ────────────────────────────────────────
|
|
||||||
let center_x = w / 2;
|
|
||||||
|
|
||||||
// Row 2: packet counters
|
|
||||||
buf.clear();
|
|
||||||
let _ = write!(
|
|
||||||
buf,
|
|
||||||
"RX:{} TX:{}",
|
|
||||||
compact_count(status.rx_count),
|
|
||||||
compact_count(status.tx_count)
|
|
||||||
);
|
|
||||||
Text::with_alignment(
|
|
||||||
&buf,
|
|
||||||
Point::new(center_x, info_y + FONT_H - 1),
|
|
||||||
style,
|
|
||||||
Alignment::Center,
|
|
||||||
)
|
|
||||||
.draw(target)
|
|
||||||
.ok();
|
|
||||||
|
|
||||||
// Row 3: RSSI + SNR
|
|
||||||
buf.clear();
|
|
||||||
match (status.last_rssi, status.last_snr) {
|
|
||||||
(Some(rssi), Some(snr)) => {
|
|
||||||
let _ = write!(buf, "RSSI:{}dBm SNR:{}dB", rssi, snr);
|
|
||||||
}
|
|
||||||
(Some(rssi), None) => {
|
|
||||||
let _ = write!(buf, "RSSI:{}dBm", rssi);
|
|
||||||
}
|
|
||||||
_ => {
|
|
||||||
let _ = write!(buf, "No signal");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Text::with_alignment(
|
|
||||||
&buf,
|
|
||||||
Point::new(center_x, info_y + 2 * FONT_H - 1),
|
|
||||||
style,
|
|
||||||
Alignment::Center,
|
|
||||||
)
|
|
||||||
.draw(target)
|
|
||||||
.ok();
|
|
||||||
|
|
||||||
// ── Second separator line ───────────────────────────────────────
|
|
||||||
Line::new(Point::new(0, sep2_y), Point::new(w - 1, sep2_y))
|
|
||||||
.into_styled(PrimitiveStyle::with_stroke(BinaryColor::On, 1))
|
|
||||||
.draw(target)
|
|
||||||
.ok();
|
|
||||||
|
|
||||||
// ── Sparkline bar graph ─────────────────────────────────────────
|
|
||||||
if spark_h > 0 {
|
|
||||||
rssi_sparkline(
|
|
||||||
target,
|
|
||||||
rssi_history,
|
|
||||||
tx_history,
|
|
||||||
rssi_count,
|
|
||||||
current_slot_rssi,
|
|
||||||
current_slot_tx,
|
|
||||||
spark_top,
|
|
||||||
spark_h,
|
|
||||||
visible_bars,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Render the splash/waiting screen (shown when idle or no config).
|
|
||||||
pub fn splash(
|
|
||||||
target: &mut impl DrawTarget<Color = BinaryColor>,
|
|
||||||
board: &BoardInfo,
|
|
||||||
) {
|
|
||||||
let bb = target.bounding_box();
|
|
||||||
let w = bb.size.width as i32;
|
|
||||||
|
|
||||||
let _ = target.clear(BinaryColor::Off);
|
|
||||||
let style = MonoTextStyle::new(&FONT_6X10, BinaryColor::On);
|
|
||||||
let title_style = MonoTextStyle::new(&FONT_9X15_BOLD, BinaryColor::On);
|
|
||||||
let mut buf: String<32> = String::new();
|
|
||||||
|
|
||||||
let center_x = w / 2;
|
|
||||||
|
|
||||||
// Row 1: "DongLoRa" bold left, version small right
|
|
||||||
Text::new("DongLoRa", Point::new(4, 15), title_style)
|
|
||||||
.draw(target)
|
|
||||||
.ok();
|
|
||||||
buf.clear();
|
|
||||||
let _ = write!(buf, "v{}", board.version);
|
|
||||||
Text::with_alignment(&buf, Point::new(w - 2, 15), style, Alignment::Right)
|
|
||||||
.draw(target)
|
|
||||||
.ok();
|
|
||||||
|
|
||||||
// Row 2: board name
|
|
||||||
Text::with_alignment(
|
|
||||||
board.name,
|
|
||||||
Point::new(center_x, 28),
|
|
||||||
style,
|
|
||||||
Alignment::Center,
|
|
||||||
)
|
|
||||||
.draw(target)
|
|
||||||
.ok();
|
|
||||||
|
|
||||||
// Row 3: MAC address
|
|
||||||
Text::with_alignment(
|
|
||||||
board.mac,
|
|
||||||
Point::new(center_x, 41),
|
|
||||||
style,
|
|
||||||
Alignment::Center,
|
|
||||||
)
|
|
||||||
.draw(target)
|
|
||||||
.ok();
|
|
||||||
|
|
||||||
// Row 4: status
|
|
||||||
Text::with_alignment(
|
|
||||||
"Waiting for host...",
|
|
||||||
Point::new(center_x, 54),
|
|
||||||
style,
|
|
||||||
Alignment::Center,
|
|
||||||
)
|
|
||||||
.draw(target)
|
|
||||||
.ok();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Format a u32 count compactly for display.
|
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Render the RSSI history as a bar-chart sparkline.
|
|
||||||
///
|
|
||||||
/// The current (uncommitted) slot is rendered at the rightmost position
|
|
||||||
/// so that incoming packets appear on the graph immediately.
|
|
||||||
fn rssi_sparkline(
|
|
||||||
target: &mut impl DrawTarget<Color = BinaryColor>,
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
let fill = PrimitiveStyle::with_fill(BinaryColor::On);
|
|
||||||
|
|
||||||
// Draw committed history
|
|
||||||
for i in 0..hist_slots {
|
|
||||||
let idx = if count <= RSSI_HISTORY_LEN {
|
|
||||||
// Buffer hasn't wrapped: slots occupy indices 0..committed-1.
|
|
||||||
// Skip oldest entries that don't fit on screen.
|
|
||||||
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, &fill);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Draw live (current) bar at the rightmost position
|
|
||||||
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, &fill);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Compute the pixel height for a sparkline bar, or None to skip.
|
|
||||||
fn bar_height(rssi: i16, is_tx: bool, spark_h: i32) -> Option<i32> {
|
|
||||||
let h = if rssi <= RSSI_MIN {
|
|
||||||
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) }
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Draw a single sparkline bar (solid for RX, dotted for TX).
|
|
||||||
fn draw_bar(
|
|
||||||
target: &mut impl DrawTarget<Color = BinaryColor>,
|
|
||||||
x: i32,
|
|
||||||
bar_h: i32,
|
|
||||||
is_tx: bool,
|
|
||||||
spark_top: i32,
|
|
||||||
spark_h: i32,
|
|
||||||
fill: &PrimitiveStyle<BinaryColor>,
|
|
||||||
) {
|
|
||||||
let y = spark_top + spark_h - bar_h;
|
|
||||||
if is_tx {
|
|
||||||
for row in 0..bar_h {
|
|
||||||
if row % 2 == 0 {
|
|
||||||
Rectangle::new(Point::new(x, y + row), Size::new(2, 1))
|
|
||||||
.into_styled(*fill)
|
|
||||||
.draw(target)
|
|
||||||
.ok();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
Rectangle::new(Point::new(x, y), Size::new(2, bar_h as u32))
|
|
||||||
.into_styled(*fill)
|
|
||||||
.draw(target)
|
|
||||||
.ok();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Clear the display (display-off state).
|
|
||||||
pub fn blank(target: &mut impl DrawTarget<Color = BinaryColor>) {
|
|
||||||
let _ = target.clear(BinaryColor::Off);
|
|
||||||
}
|
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,239 +0,0 @@
|
|||||||
use embassy_executor::task;
|
|
||||||
use embassy_futures::select::{select3, Either3};
|
|
||||||
use embassy_time::Timer;
|
|
||||||
use ssd1306::mode::DisplayConfigAsync;
|
|
||||||
use ssd1306::prelude::DisplayRotation;
|
|
||||||
use ssd1306::size::DisplaySize128x64;
|
|
||||||
use ssd1306::{I2CDisplayInterface, Ssd1306Async};
|
|
||||||
|
|
||||||
use crate::board::DisplayParts;
|
|
||||||
use crate::channel::{
|
|
||||||
DisplayCommand, DisplayCommandChannel, RadioState, RadioStatus, StatusWatch,
|
|
||||||
};
|
|
||||||
|
|
||||||
use crate::board::{Board, LoRaBoard};
|
|
||||||
|
|
||||||
use super::render::{self, RSSI_HISTORY_LEN};
|
|
||||||
|
|
||||||
const BOARD_NAME: &str = Board::NAME;
|
|
||||||
|
|
||||||
/// Duration per sparkline slot. 128 slots * 1s = ~2 minutes of history.
|
|
||||||
const SPARK_SLOT_MS: u64 = 1000;
|
|
||||||
|
|
||||||
/// 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;
|
|
||||||
|
|
||||||
struct DisplayState {
|
|
||||||
rssi_history: [i16; RSSI_HISTORY_LEN],
|
|
||||||
tx_history: [bool; RSSI_HISTORY_LEN],
|
|
||||||
rssi_count: usize,
|
|
||||||
current_slot_rssi: i16,
|
|
||||||
current_slot_tx: bool,
|
|
||||||
display_on: bool,
|
|
||||||
disconnected: bool,
|
|
||||||
last_status: RadioStatus,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl DisplayState {
|
|
||||||
fn new() -> Self {
|
|
||||||
Self {
|
|
||||||
rssi_history: [NO_SIGNAL; RSSI_HISTORY_LEN],
|
|
||||||
tx_history: [false; RSSI_HISTORY_LEN],
|
|
||||||
rssi_count: 0,
|
|
||||||
current_slot_rssi: NO_SIGNAL,
|
|
||||||
current_slot_tx: false,
|
|
||||||
display_on: true,
|
|
||||||
disconnected: true,
|
|
||||||
last_status: RadioStatus::default(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Record an RSSI sample in the current time slot (keep best).
|
|
||||||
fn record_rssi(&mut self, rssi: i16) {
|
|
||||||
if self.current_slot_rssi == NO_SIGNAL || rssi > self.current_slot_rssi {
|
|
||||||
self.current_slot_rssi = rssi;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Mark the current time slot as having a transmit.
|
|
||||||
fn record_tx(&mut self) {
|
|
||||||
self.current_slot_tx = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Advance to the next time slot, committing the current slot's data.
|
|
||||||
fn advance_slot(&mut self) {
|
|
||||||
let idx = self.rssi_count % RSSI_HISTORY_LEN;
|
|
||||||
self.rssi_history[idx] = self.current_slot_rssi;
|
|
||||||
self.tx_history[idx] = self.current_slot_tx;
|
|
||||||
self.rssi_count += 1;
|
|
||||||
self.current_slot_rssi = NO_SIGNAL;
|
|
||||||
self.current_slot_tx = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Whether the active dashboard should be shown (RX or TX mode).
|
|
||||||
fn is_active(&self) -> bool {
|
|
||||||
matches!(
|
|
||||||
self.last_status.state,
|
|
||||||
RadioState::Receiving | RadioState::Transmitting
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[task]
|
|
||||||
pub async fn display_task(
|
|
||||||
parts: DisplayParts,
|
|
||||||
status: &'static StatusWatch,
|
|
||||||
display_commands: &'static DisplayCommandChannel,
|
|
||||||
) {
|
|
||||||
// Format MAC address as "XX:XX:XX:XX:XX:XX"
|
|
||||||
let mut mac_str: heapless::String<18> = heapless::String::new();
|
|
||||||
let m = parts.mac;
|
|
||||||
let _ = core::fmt::Write::write_fmt(
|
|
||||||
&mut mac_str,
|
|
||||||
format_args!(
|
|
||||||
"{:02X}:{:02X}:{:02X}:{:02X}:{:02X}:{:02X}",
|
|
||||||
m[0], m[1], m[2], m[3], m[4], m[5]
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
let board_info = render::BoardInfo {
|
|
||||||
name: BOARD_NAME,
|
|
||||||
version: env!("CARGO_PKG_VERSION"),
|
|
||||||
mac: &mac_str,
|
|
||||||
};
|
|
||||||
|
|
||||||
let interface = I2CDisplayInterface::new(parts.i2c);
|
|
||||||
let mut display = Ssd1306Async::new(interface, DisplaySize128x64, DisplayRotation::Rotate0)
|
|
||||||
.into_buffered_graphics_mode();
|
|
||||||
|
|
||||||
if display.init().await.is_err() {
|
|
||||||
defmt::error!("SSD1306 init failed");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if display
|
|
||||||
.set_brightness(ssd1306::prelude::Brightness::BRIGHTEST)
|
|
||||||
.await
|
|
||||||
.is_err()
|
|
||||||
{
|
|
||||||
defmt::warn!("display brightness set failed");
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut state = DisplayState::new();
|
|
||||||
let Some(mut receiver) = status.receiver() else {
|
|
||||||
defmt::error!("no watch receiver available for display");
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
|
|
||||||
// Show splash/waiting screen
|
|
||||||
render_splash(&mut display, &board_info).await;
|
|
||||||
|
|
||||||
loop {
|
|
||||||
match select3(
|
|
||||||
receiver.changed(),
|
|
||||||
display_commands.receive(),
|
|
||||||
Timer::after_millis(SPARK_SLOT_MS),
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
{
|
|
||||||
Either3::First(radio_status) => {
|
|
||||||
if state.disconnected {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if let Some(rssi) = radio_status.last_rssi {
|
|
||||||
if radio_status.rx_count != state.last_status.rx_count {
|
|
||||||
state.record_rssi(rssi);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if radio_status.tx_count != state.last_status.tx_count {
|
|
||||||
state.record_tx();
|
|
||||||
}
|
|
||||||
state.last_status = radio_status;
|
|
||||||
|
|
||||||
if state.display_on {
|
|
||||||
render_current(&mut display, &state, &board_info).await;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Either3::Second(cmd) => match cmd {
|
|
||||||
DisplayCommand::Off => {
|
|
||||||
state.disconnected = false;
|
|
||||||
state.display_on = false;
|
|
||||||
render::blank(&mut display);
|
|
||||||
let _ = display.flush().await;
|
|
||||||
}
|
|
||||||
DisplayCommand::On => {
|
|
||||||
state.disconnected = false;
|
|
||||||
state.display_on = true;
|
|
||||||
if let Some(s) = receiver.try_get() {
|
|
||||||
state.last_status = s;
|
|
||||||
}
|
|
||||||
render_current(&mut display, &state, &board_info).await;
|
|
||||||
}
|
|
||||||
DisplayCommand::Reset => {
|
|
||||||
state = DisplayState::new();
|
|
||||||
render_splash(&mut display, &board_info).await;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
Either3::Third(()) => {
|
|
||||||
// Timer tick: advance sparkline slot
|
|
||||||
state.advance_slot();
|
|
||||||
if state.display_on && state.is_active() {
|
|
||||||
render_dashboard(&mut display, &state, &board_info).await;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// The concrete display type is verbose but avoids making this generic
|
|
||||||
// over both DrawTarget and the SSD1306-specific flush method.
|
|
||||||
type Display<I> = ssd1306::Ssd1306Async<
|
|
||||||
I,
|
|
||||||
DisplaySize128x64,
|
|
||||||
ssd1306::mode::BufferedGraphicsModeAsync<DisplaySize128x64>,
|
|
||||||
>;
|
|
||||||
|
|
||||||
/// Render the appropriate screen (active dashboard or splash) and flush.
|
|
||||||
async fn render_current<I>(
|
|
||||||
display: &mut Display<I>,
|
|
||||||
state: &DisplayState,
|
|
||||||
board: &render::BoardInfo<'_>,
|
|
||||||
) where
|
|
||||||
I: display_interface::AsyncWriteOnlyDataCommand,
|
|
||||||
{
|
|
||||||
if state.is_active() {
|
|
||||||
render_dashboard(display, state, board).await;
|
|
||||||
} else {
|
|
||||||
render_splash(display, board).await;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Render the active dashboard and flush.
|
|
||||||
async fn render_dashboard<I>(
|
|
||||||
display: &mut Display<I>,
|
|
||||||
state: &DisplayState,
|
|
||||||
board: &render::BoardInfo<'_>,
|
|
||||||
) where
|
|
||||||
I: display_interface::AsyncWriteOnlyDataCommand,
|
|
||||||
{
|
|
||||||
render::dashboard(
|
|
||||||
display,
|
|
||||||
&state.last_status,
|
|
||||||
&state.rssi_history,
|
|
||||||
&state.tx_history,
|
|
||||||
state.rssi_count,
|
|
||||||
state.current_slot_rssi,
|
|
||||||
state.current_slot_tx,
|
|
||||||
board,
|
|
||||||
);
|
|
||||||
let _ = display.flush().await;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Render the splash/waiting screen and flush.
|
|
||||||
async fn render_splash<I>(display: &mut Display<I>, board: &render::BoardInfo<'_>)
|
|
||||||
where
|
|
||||||
I: display_interface::AsyncWriteOnlyDataCommand,
|
|
||||||
{
|
|
||||||
render::splash(display, board);
|
|
||||||
let _ = display.flush().await;
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
//! AXP PMIC init — turn on the rails that feed LoRa and the OLED.
|
||||||
|
//!
|
||||||
|
//! T-Beam–class boards gate LoRa, OLED (and GPS/sensors, which this firmware
|
||||||
|
//! does not use) through an X-Powers PMIC on I²C. Until the PMIC is told to
|
||||||
|
//! enable the right LDOs/DCDCs, the SX126x/SX127x chip sits unpowered and
|
||||||
|
//! `LoRa::new` fails. This module's only job is to flip those switches.
|
||||||
|
//!
|
||||||
|
//! Two PMICs are covered:
|
||||||
|
//!
|
||||||
|
//! * [`axp2101_init_lora_oled`] — LilyGo T-Beam S3 Supreme. ALDO3 = 3.3 V
|
||||||
|
//! feeds the SX1262; ALDO1 = 3.3 V feeds the SH1106 OLED. Both live on a
|
||||||
|
//! dedicated I²C1 bus (separate from the display).
|
||||||
|
//! * [`axp192_init_lora_oled`] — LilyGo T-Beam (classic). LDO2 = 3.3 V feeds
|
||||||
|
//! the SX1276; DCDC1 = 3.3 V feeds the SSD1306 OLED. PMIC shares the
|
||||||
|
//! display I²C bus.
|
||||||
|
//!
|
||||||
|
//! Everything unrelated to LoRa + OLED (GPS, sensors, SD, batt ADC) is left
|
||||||
|
//! alone. Out-of-scope for a transparent LoRa-over-USB bridge.
|
||||||
|
//!
|
||||||
|
//! Init is blocking on purpose. It runs once during `into_parts`, which is a
|
||||||
|
//! synchronous function called before task spawning. Using the blocking
|
||||||
|
//! `embedded_hal` trait keeps the PMIC wiring out of the async machinery and
|
||||||
|
//! lets the caller drop the I²C bus immediately after init (no bus handoff).
|
||||||
|
|
||||||
|
use embedded_hal::i2c::I2c;
|
||||||
|
|
||||||
|
/// Both chips respond at this 7-bit I²C address.
|
||||||
|
const PMIC_ADDR: u8 = 0x34;
|
||||||
|
|
||||||
|
// ── AXP2101 (T-Beam S3 Supreme) ──────────────────────────────────────
|
||||||
|
|
||||||
|
#[cfg(feature = "lilygo_tbeam_supreme")]
|
||||||
|
mod axp2101 {
|
||||||
|
use super::{I2c, PMIC_ADDR};
|
||||||
|
|
||||||
|
/// LDO on/off control register. Bits:
|
||||||
|
/// 0 = ALDO1, 1 = ALDO2, 2 = ALDO3, 3 = ALDO4, 4 = BLDO1, 5 = BLDO2.
|
||||||
|
const REG_LDO_ONOFF: u8 = 0x90;
|
||||||
|
/// ALDO1 voltage register (OLED rail on T-Beam Supreme).
|
||||||
|
const REG_ALDO1_V: u8 = 0x92;
|
||||||
|
/// ALDO3 voltage register (LoRa rail on T-Beam Supreme).
|
||||||
|
const REG_ALDO3_V: u8 = 0x94;
|
||||||
|
|
||||||
|
/// Voltage code for 3.3 V on ALDOx: 0.5 V + 28 × 100 mV.
|
||||||
|
const V_3V3: u8 = 28;
|
||||||
|
|
||||||
|
/// Mask enabling ALDO1 + ALDO3 in [`REG_LDO_ONOFF`].
|
||||||
|
const ALDO1_ALDO3: u8 = 0b0000_0101;
|
||||||
|
|
||||||
|
/// Bring up LoRa (ALDO3) and OLED (ALDO1) rails at 3.3 V.
|
||||||
|
///
|
||||||
|
/// Voltage registers are set before the enable bits to avoid glitching
|
||||||
|
/// the rails through the reset-default voltage. Other rails are left
|
||||||
|
/// untouched (read-modify-write on the enable register) so any
|
||||||
|
/// board-specific OTP defaults stay intact.
|
||||||
|
pub fn init_lora_oled<I: I2c>(i2c: &mut I) -> Result<(), I::Error> {
|
||||||
|
i2c.write(PMIC_ADDR, &[REG_ALDO3_V, V_3V3])?;
|
||||||
|
i2c.write(PMIC_ADDR, &[REG_ALDO1_V, V_3V3])?;
|
||||||
|
|
||||||
|
let mut onoff = [0u8; 1];
|
||||||
|
i2c.write_read(PMIC_ADDR, &[REG_LDO_ONOFF], &mut onoff)?;
|
||||||
|
i2c.write(PMIC_ADDR, &[REG_LDO_ONOFF, onoff[0] | ALDO1_ALDO3])?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "lilygo_tbeam_supreme")]
|
||||||
|
pub use axp2101::init_lora_oled as axp2101_init_lora_oled;
|
||||||
|
|
||||||
|
// ── AXP192 (T-Beam classic) ──────────────────────────────────────────
|
||||||
|
|
||||||
|
#[cfg(feature = "lilygo_tbeam")]
|
||||||
|
mod axp192 {
|
||||||
|
use super::{I2c, PMIC_ADDR};
|
||||||
|
|
||||||
|
/// DCDC/LDO enable register. Bit layout:
|
||||||
|
/// 0 = DCDC1, 1 = DCDC3, 2 = LDO2, 3 = LDO3, 4 = DCDC2, 6 = EXTEN.
|
||||||
|
const REG_ONOFF: u8 = 0x12;
|
||||||
|
/// DCDC1 output voltage register (OLED rail on T-Beam classic).
|
||||||
|
/// 7-bit code, 25 mV step, 0.7 V offset → 3.3 V = 104 = `0x68`.
|
||||||
|
const REG_DCDC1_V: u8 = 0x26;
|
||||||
|
/// LDO2/LDO3 voltage register. Upper nibble = LDO2 (LoRa), lower nibble
|
||||||
|
/// = LDO3 (GPS). 100 mV step, 1.8 V offset → 3.3 V = 15 = `0xF`.
|
||||||
|
const REG_LDO23_V: u8 = 0x28;
|
||||||
|
|
||||||
|
/// DCDC1 code for 3.3 V.
|
||||||
|
const DCDC1_3V3: u8 = 0x68;
|
||||||
|
/// LDO2 nibble for 3.3 V in the upper half of [`REG_LDO23_V`].
|
||||||
|
const LDO2_3V3_UPPER: u8 = 0xF0;
|
||||||
|
|
||||||
|
/// Mask enabling DCDC1 + LDO2 in [`REG_ONOFF`].
|
||||||
|
const DCDC1_LDO2: u8 = 0b0000_0101;
|
||||||
|
|
||||||
|
/// Bring up LoRa (LDO2) and OLED (DCDC1) rails at 3.3 V on the
|
||||||
|
/// classic T-Beam's AXP192.
|
||||||
|
///
|
||||||
|
/// GPS (LDO3) and EXTEN are not touched — this firmware has no GPS,
|
||||||
|
/// and any board-default for LDO3 voltage is preserved by
|
||||||
|
/// read-modify-write. Voltage is programmed before the enable bit
|
||||||
|
/// flips so the rail doesn't glitch through its reset default.
|
||||||
|
pub fn init_lora_oled<I: I2c>(i2c: &mut I) -> Result<(), I::Error> {
|
||||||
|
let mut ldo23 = [0u8; 1];
|
||||||
|
i2c.write_read(PMIC_ADDR, &[REG_LDO23_V], &mut ldo23)?;
|
||||||
|
i2c.write(
|
||||||
|
PMIC_ADDR,
|
||||||
|
&[REG_LDO23_V, (ldo23[0] & 0x0F) | LDO2_3V3_UPPER],
|
||||||
|
)?;
|
||||||
|
|
||||||
|
i2c.write(PMIC_ADDR, &[REG_DCDC1_V, DCDC1_3V3])?;
|
||||||
|
|
||||||
|
let mut onoff = [0u8; 1];
|
||||||
|
i2c.write_read(PMIC_ADDR, &[REG_ONOFF], &mut onoff)?;
|
||||||
|
i2c.write(PMIC_ADDR, &[REG_ONOFF, onoff[0] | DCDC1_LDO2])?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "lilygo_tbeam")]
|
||||||
|
pub use axp192::init_lora_oled as axp192_init_lora_oled;
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
//! Generic `OutputPin` wrapper that inverts logic levels.
|
||||||
|
//!
|
||||||
|
//! When a peripheral is wired active-low (LED anode to V+, cathode to GPIO;
|
||||||
|
//! reset line idle-high, etc.) the physical polarity is a property of the
|
||||||
|
//! board, not of the driver. Wrapping the pin in `InvertedPin` lets drivers
|
||||||
|
//! keep "set_high = assert" semantics while the board file expresses the
|
||||||
|
//! wiring reality at construction time.
|
||||||
|
//!
|
||||||
|
//! ```ignore
|
||||||
|
//! let pin = Output::new(p.GPIO4, Level::High, OutputConfig::default()); // off
|
||||||
|
//! let led = SimpleLed(InvertedPin(pin)); // driver sees normal polarity
|
||||||
|
//! ```
|
||||||
|
|
||||||
|
use embedded_hal::digital::{ErrorType, OutputPin};
|
||||||
|
|
||||||
|
pub struct InvertedPin<P>(pub P);
|
||||||
|
|
||||||
|
impl<P: ErrorType> ErrorType for InvertedPin<P> {
|
||||||
|
type Error = P::Error;
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<P: OutputPin> OutputPin for InvertedPin<P> {
|
||||||
|
fn set_low(&mut self) -> Result<(), Self::Error> {
|
||||||
|
self.0.set_high()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn set_high(&mut self) -> Result<(), Self::Error> {
|
||||||
|
self.0.set_low()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
#[cfg(any(
|
||||||
|
feature = "wio_tracker_l1",
|
||||||
|
feature = "elecrow_thinknode_m2",
|
||||||
|
feature = "lilygo_tbeam_supreme",
|
||||||
|
feature = "rak_wisblock_4631"
|
||||||
|
))]
|
||||||
|
pub mod sh1106;
|
||||||
|
|
||||||
|
#[cfg(any(feature = "lilygo_tbeam_supreme", feature = "lilygo_tbeam"))]
|
||||||
|
pub mod axp;
|
||||||
|
|
||||||
|
#[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",
|
||||||
|
feature = "elecrow_thinknode_m2",
|
||||||
|
feature = "lilygo_tbeam_supreme",
|
||||||
|
feature = "lilygo_tbeam",
|
||||||
|
feature = "rak_wisblock_4631",
|
||||||
|
feature = "wio_tracker_l1",
|
||||||
|
feature = "waveshare_rp2040_lora"
|
||||||
|
))]
|
||||||
|
pub mod simple_led;
|
||||||
|
|
||||||
|
// ── Display brightness ────────────────────────────────────────────────
|
||||||
|
//
|
||||||
|
// The display statechart dims the OLED a few seconds after the last
|
||||||
|
// activity. Our two driver families (`ssd1306` crate and our in-tree
|
||||||
|
// `sh1106`) expose incompatible brightness APIs, so we wrap them in a
|
||||||
|
// single async trait that the state-machine actions can call uniformly.
|
||||||
|
|
||||||
|
/// Two-level backlight control used by the display dashboard.
|
||||||
|
pub trait DisplayBrightness {
|
||||||
|
fn set_bright(&mut self) -> impl core::future::Future<Output = ()>;
|
||||||
|
fn set_dim(&mut self) -> impl core::future::Future<Output = ()>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Contrast values for the two brightness levels, picked to be common across
|
||||||
|
// both driver families. `0xFF` is the SSD1306/SH1106 maximum; `0x08` is ~3%
|
||||||
|
// of max — visible in a dim room, barely noticeable in daylight. Tune here.
|
||||||
|
const CONTRAST_BRIGHT: u8 = 0xFF;
|
||||||
|
const CONTRAST_DIM: u8 = 0x08;
|
||||||
|
|
||||||
|
impl<DI, SIZE, MODE> DisplayBrightness for ssd1306::Ssd1306Async<DI, SIZE, MODE>
|
||||||
|
where
|
||||||
|
DI: display_interface::AsyncWriteOnlyDataCommand,
|
||||||
|
SIZE: ssd1306::size::DisplaySizeAsync,
|
||||||
|
{
|
||||||
|
async fn set_bright(&mut self) {
|
||||||
|
// `Brightness::custom(precharge, contrast)`. Matches the shape of
|
||||||
|
// the crate's BRIGHTEST preset (precharge 0x2) at max contrast.
|
||||||
|
let _ = self
|
||||||
|
.set_brightness(ssd1306::prelude::Brightness::custom(0x2, CONTRAST_BRIGHT))
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
async fn set_dim(&mut self) {
|
||||||
|
// Low precharge pairs with low contrast — same combination the
|
||||||
|
// crate's DIMMEST preset uses.
|
||||||
|
let _ = self
|
||||||
|
.set_brightness(ssd1306::prelude::Brightness::custom(0x1, CONTRAST_DIM))
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(any(
|
||||||
|
feature = "wio_tracker_l1",
|
||||||
|
feature = "elecrow_thinknode_m2",
|
||||||
|
feature = "lilygo_tbeam_supreme",
|
||||||
|
feature = "rak_wisblock_4631"
|
||||||
|
))]
|
||||||
|
impl<I> DisplayBrightness for sh1106::Sh1106<I>
|
||||||
|
where
|
||||||
|
I: embedded_hal_async::i2c::I2c,
|
||||||
|
{
|
||||||
|
async fn set_bright(&mut self) {
|
||||||
|
let _ = self.set_brightness(CONTRAST_BRIGHT).await;
|
||||||
|
}
|
||||||
|
async fn set_dim(&mut self) {
|
||||||
|
let _ = self.set_brightness(CONTRAST_DIM).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,149 @@
|
|||||||
|
//! Minimal async SH1106 I2C OLED driver (128x64, page addressing).
|
||||||
|
//!
|
||||||
|
//! The SH1106 is very similar to the SSD1306 but only supports page-mode
|
||||||
|
//! addressing (no horizontal/vertical auto-advance). This driver sends
|
||||||
|
//! framebuffer data page-by-page with explicit page/column commands.
|
||||||
|
|
||||||
|
use embedded_graphics::draw_target::DrawTarget;
|
||||||
|
use embedded_graphics::geometry::{OriginDimensions, Size};
|
||||||
|
use embedded_graphics::pixelcolor::BinaryColor;
|
||||||
|
use embedded_graphics::Pixel;
|
||||||
|
use embedded_hal_async::i2c::I2c;
|
||||||
|
|
||||||
|
const WIDTH: usize = 128;
|
||||||
|
const HEIGHT: usize = 64;
|
||||||
|
const PAGES: usize = HEIGHT / 8;
|
||||||
|
const BUF_SIZE: usize = WIDTH * PAGES; // 1024
|
||||||
|
|
||||||
|
/// SH1106 has a 132-column RAM; visible 128 columns are centered with a
|
||||||
|
/// 2-column offset on each side.
|
||||||
|
const COL_OFFSET: u8 = 2;
|
||||||
|
|
||||||
|
pub struct Sh1106<I> {
|
||||||
|
i2c: I,
|
||||||
|
addr: u8,
|
||||||
|
buffer: [u8; BUF_SIZE],
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<I: I2c> Sh1106<I> {
|
||||||
|
pub fn new(i2c: I, addr: u8) -> Self {
|
||||||
|
Self {
|
||||||
|
i2c,
|
||||||
|
addr,
|
||||||
|
buffer: [0u8; BUF_SIZE],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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
|
||||||
|
0xA8, 0x3F, // multiplex ratio: 64
|
||||||
|
0xD3, 0x00, // display offset: 0
|
||||||
|
0x40, // start line: 0
|
||||||
|
0x8D, 0x14, // charge pump: enable
|
||||||
|
0xA1, // segment remap: column 127 mapped to SEG0
|
||||||
|
0xC8, // COM scan direction: remapped
|
||||||
|
0xDA, 0x12, // COM pin config: alternative, no L/R remap
|
||||||
|
0x81, 0xCF, // contrast: 207
|
||||||
|
0xD9, 0xF1, // pre-charge: phase1=1, phase2=15
|
||||||
|
0xDB, 0x40, // VCOMH deselect: ~0.77×Vcc
|
||||||
|
0xA4, // entire display ON (follow RAM)
|
||||||
|
0xA6, // normal display (not inverted)
|
||||||
|
0xAF, // display on
|
||||||
|
])
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Flush the entire framebuffer to the display, page by page.
|
||||||
|
pub async fn flush(&mut self) -> Result<(), I::Error> {
|
||||||
|
// Each I2C data write is prefixed with 0x40 (data continuation byte).
|
||||||
|
let mut page_buf = [0u8; 1 + WIDTH]; // 0x40 + 128 data bytes
|
||||||
|
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
|
||||||
|
COL_OFFSET & 0x0F, // column low nibble
|
||||||
|
0x10 | (COL_OFFSET >> 4), // column high nibble
|
||||||
|
])
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
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(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<I: I2c> DrawTarget for Sh1106<I> {
|
||||||
|
type Color = BinaryColor;
|
||||||
|
type Error = core::convert::Infallible;
|
||||||
|
|
||||||
|
fn draw_iter<T>(&mut self, pixels: T) -> Result<(), Self::Error>
|
||||||
|
where
|
||||||
|
T: IntoIterator<Item = Pixel<Self::Color>>,
|
||||||
|
{
|
||||||
|
for Pixel(pos, color) in pixels {
|
||||||
|
let x = pos.x;
|
||||||
|
let y = pos.y;
|
||||||
|
if x >= 0 && x < WIDTH as i32 && y >= 0 && y < HEIGHT as i32 {
|
||||||
|
let x = x as usize;
|
||||||
|
let y = y as usize;
|
||||||
|
let idx = (y / 8) * WIDTH + x;
|
||||||
|
let bit = (y % 8) as u8;
|
||||||
|
if color.is_on() {
|
||||||
|
self.buffer[idx] |= 1 << bit;
|
||||||
|
} else {
|
||||||
|
self.buffer[idx] &= !(1 << bit);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn clear(&mut self, color: Self::Color) -> Result<(), Self::Error> {
|
||||||
|
self.buffer.fill(if color.is_on() { 0xFF } else { 0x00 });
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<I: I2c> OriginDimensions for Sh1106<I> {
|
||||||
|
fn size(&self) -> Size {
|
||||||
|
Size::new(WIDTH as u32, HEIGHT as u32)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
//! Simple single-color LED driver (GPIO on/off).
|
||||||
|
//!
|
||||||
|
//! Generic over any `OutputPin`. Any non-zero RGB value turns the LED on;
|
||||||
|
//! all zeros turns it off.
|
||||||
|
|
||||||
|
use embedded_hal::digital::OutputPin;
|
||||||
|
|
||||||
|
use crate::board::RgbLed;
|
||||||
|
|
||||||
|
pub struct SimpleLed<P>(pub P);
|
||||||
|
|
||||||
|
impl<P: OutputPin> RgbLed for SimpleLed<P> {
|
||||||
|
async fn set_rgb(&mut self, r: u8, g: u8, b: u8) {
|
||||||
|
if r > 0 || g > 0 || b > 0 {
|
||||||
|
let _ = self.0.set_high();
|
||||||
|
} else {
|
||||||
|
let _ = self.0.set_low();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
//! ESP32 (classic / non-S3) MCU initialization primitives.
|
||||||
|
//!
|
||||||
|
//! Mirror of `hal::esp32s3` with a single delta: the classic ESP32 uses a
|
||||||
|
//! peripheral-bound DMA (`DMA_SPI2`) rather than the S3's channel-addressed
|
||||||
|
//! GDMA (`DMA_CH0`). Timer, I²C, and eFuse access are otherwise identical
|
||||||
|
//! between the two Xtensa parts in this esp-hal vintage.
|
||||||
|
|
||||||
|
use embassy_sync::blocking_mutex::raw::NoopRawMutex;
|
||||||
|
use esp_hal::dma::{DmaRxBuf, DmaTxBuf};
|
||||||
|
use esp_hal::gpio::interconnect::{PeripheralInput, PeripheralOutput};
|
||||||
|
use esp_hal::i2c::master::I2c;
|
||||||
|
use esp_hal::spi::master::{Config as SpiConfig, Spi, SpiDmaBus};
|
||||||
|
use esp_hal::spi::Mode as SpiMode;
|
||||||
|
use esp_hal::time::Rate;
|
||||||
|
use esp_hal::timer::timg::TimerGroup;
|
||||||
|
use static_cell::StaticCell;
|
||||||
|
|
||||||
|
// ── MCU-level types ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
pub type SpiBus = SpiDmaBus<'static, esp_hal::Async>;
|
||||||
|
pub type I2cBus = I2c<'static, esp_hal::Async>;
|
||||||
|
|
||||||
|
// ── Timer ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// Start the ESP-RTOS scheduler and Embassy time driver.
|
||||||
|
pub fn start_timer(
|
||||||
|
timg0: esp_hal::peripherals::TIMG0<'static>,
|
||||||
|
sw_int: esp_hal::peripherals::SW_INTERRUPT<'static>,
|
||||||
|
) {
|
||||||
|
let timg0 = TimerGroup::new(timg0);
|
||||||
|
let sw_ints = esp_hal::interrupt::software::SoftwareInterruptControl::new(sw_int);
|
||||||
|
esp_rtos::start(timg0.timer0, sw_ints.software_interrupt0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── SPI bus ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// Initialize SPI2 with peripheral-bound DMA and wrap in a shared bus
|
||||||
|
/// (`StaticCell` + `Mutex`).
|
||||||
|
///
|
||||||
|
/// Generic over pin type. The classic ESP32's matrix of GPIO → peripheral
|
||||||
|
/// routes lets any GPIO serve as any SPI signal — same as the S3.
|
||||||
|
pub fn init_spi(
|
||||||
|
spi2: esp_hal::peripherals::SPI2<'static>,
|
||||||
|
dma_spi2: esp_hal::peripherals::DMA_SPI2<'static>,
|
||||||
|
sck: impl PeripheralOutput<'static>,
|
||||||
|
mosi: impl PeripheralOutput<'static>,
|
||||||
|
miso: impl PeripheralInput<'static>,
|
||||||
|
) -> &'static embassy_sync::mutex::Mutex<NoopRawMutex, SpiBus> {
|
||||||
|
let spi = Spi::new(
|
||||||
|
spi2,
|
||||||
|
SpiConfig::default()
|
||||||
|
.with_frequency(Rate::from_mhz(1))
|
||||||
|
.with_mode(SpiMode::_0),
|
||||||
|
)
|
||||||
|
.expect("SPI init")
|
||||||
|
.with_sck(sck)
|
||||||
|
.with_mosi(mosi)
|
||||||
|
.with_miso(miso)
|
||||||
|
.with_dma(dma_spi2);
|
||||||
|
|
||||||
|
let (rx_buf, rx_desc, tx_buf, tx_desc) = esp_hal::dma_buffers!(256);
|
||||||
|
let dma_rx = DmaRxBuf::new(rx_desc, rx_buf).expect("DMA RX buf");
|
||||||
|
let dma_tx = DmaTxBuf::new(tx_desc, tx_buf).expect("DMA TX buf");
|
||||||
|
|
||||||
|
let spi = spi.with_buffers(dma_rx, dma_tx).into_async();
|
||||||
|
|
||||||
|
static SPI_BUS: StaticCell<embassy_sync::mutex::Mutex<NoopRawMutex, SpiBus>> =
|
||||||
|
StaticCell::new();
|
||||||
|
SPI_BUS.init(embassy_sync::mutex::Mutex::new(spi))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── MAC address ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// Read the factory-programmed MAC address from eFuse.
|
||||||
|
pub fn mac_address() -> [u8; 6] {
|
||||||
|
esp_hal::efuse::base_mac_address()
|
||||||
|
.as_bytes()
|
||||||
|
.try_into()
|
||||||
|
.expect("MAC is 6 bytes")
|
||||||
|
}
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
//! ESP32-S3 MCU initialization primitives.
|
||||||
|
//!
|
||||||
|
//! Provides low-level bus init for ESP32-S3 boards.
|
||||||
|
//! Board files call these with specific pins and construct higher-level
|
||||||
|
//! drivers (Sx126x, SSD1306, USB, UART, etc.) themselves.
|
||||||
|
|
||||||
|
use embassy_sync::blocking_mutex::raw::NoopRawMutex;
|
||||||
|
use esp_hal::dma::{DmaRxBuf, DmaTxBuf};
|
||||||
|
use esp_hal::gpio::interconnect::{PeripheralInput, PeripheralOutput};
|
||||||
|
use esp_hal::i2c::master::{Config as I2cConfig, I2c};
|
||||||
|
use esp_hal::spi::master::{Config as SpiConfig, Spi, SpiDmaBus};
|
||||||
|
use esp_hal::spi::Mode as SpiMode;
|
||||||
|
use esp_hal::time::Rate;
|
||||||
|
use esp_hal::timer::timg::TimerGroup;
|
||||||
|
use static_cell::StaticCell;
|
||||||
|
|
||||||
|
// ── MCU-level types ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
pub type SpiBus = SpiDmaBus<'static, esp_hal::Async>;
|
||||||
|
pub type I2cBus = I2c<'static, esp_hal::Async>;
|
||||||
|
|
||||||
|
// ── Timer ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// Start the ESP-RTOS scheduler and Embassy time driver.
|
||||||
|
pub fn start_timer(
|
||||||
|
timg0: esp_hal::peripherals::TIMG0<'static>,
|
||||||
|
sw_int: esp_hal::peripherals::SW_INTERRUPT<'static>,
|
||||||
|
) {
|
||||||
|
let timg0 = TimerGroup::new(timg0);
|
||||||
|
let sw_ints = esp_hal::interrupt::software::SoftwareInterruptControl::new(sw_int);
|
||||||
|
esp_rtos::start(timg0.timer0, sw_ints.software_interrupt0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── SPI bus ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// Initialize SPI2 with DMA and wrap in a shared bus (StaticCell + Mutex).
|
||||||
|
///
|
||||||
|
/// Generic over pin type so boards with different SPI pin assignments can
|
||||||
|
/// share this helper. Any GPIO that implements the esp-hal interconnect
|
||||||
|
/// traits (which is every GPIO on the ESP32-S3) can be passed directly.
|
||||||
|
pub fn init_spi(
|
||||||
|
spi2: esp_hal::peripherals::SPI2<'static>,
|
||||||
|
dma_ch0: esp_hal::peripherals::DMA_CH0<'static>,
|
||||||
|
sck: impl PeripheralOutput<'static>,
|
||||||
|
mosi: impl PeripheralOutput<'static>,
|
||||||
|
miso: impl PeripheralInput<'static>,
|
||||||
|
) -> &'static embassy_sync::mutex::Mutex<NoopRawMutex, SpiBus> {
|
||||||
|
let spi = Spi::new(
|
||||||
|
spi2,
|
||||||
|
SpiConfig::default()
|
||||||
|
.with_frequency(Rate::from_mhz(1))
|
||||||
|
.with_mode(SpiMode::_0),
|
||||||
|
)
|
||||||
|
.expect("SPI init")
|
||||||
|
.with_sck(sck)
|
||||||
|
.with_mosi(mosi)
|
||||||
|
.with_miso(miso)
|
||||||
|
.with_dma(dma_ch0);
|
||||||
|
|
||||||
|
let (rx_buf, rx_desc, tx_buf, tx_desc) = esp_hal::dma_buffers!(256);
|
||||||
|
let dma_rx = DmaRxBuf::new(rx_desc, rx_buf).expect("DMA RX buf");
|
||||||
|
let dma_tx = DmaTxBuf::new(tx_desc, tx_buf).expect("DMA TX buf");
|
||||||
|
|
||||||
|
let spi = spi.with_buffers(dma_rx, dma_tx).into_async();
|
||||||
|
|
||||||
|
static SPI_BUS: StaticCell<embassy_sync::mutex::Mutex<NoopRawMutex, SpiBus>> =
|
||||||
|
StaticCell::new();
|
||||||
|
SPI_BUS.init(embassy_sync::mutex::Mutex::new(spi))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── I2C bus ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// Initialize I2C0 for peripherals (display, sensors, etc.).
|
||||||
|
///
|
||||||
|
/// Generic over pin type. I2C lines are bidirectional (open-drain), so both
|
||||||
|
/// `PeripheralInput` and `PeripheralOutput` bounds are required.
|
||||||
|
pub fn init_i2c(
|
||||||
|
i2c0: esp_hal::peripherals::I2C0<'static>,
|
||||||
|
sda: impl PeripheralInput<'static> + PeripheralOutput<'static>,
|
||||||
|
scl: impl PeripheralInput<'static> + PeripheralOutput<'static>,
|
||||||
|
) -> I2cBus {
|
||||||
|
I2c::new(i2c0, I2cConfig::default())
|
||||||
|
.expect("I2C init")
|
||||||
|
.with_sda(sda)
|
||||||
|
.with_scl(scl)
|
||||||
|
.into_async()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── MAC address ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// Read the factory-programmed MAC address from eFuse.
|
||||||
|
pub fn mac_address() -> [u8; 6] {
|
||||||
|
esp_hal::efuse::base_mac_address()
|
||||||
|
.as_bytes()
|
||||||
|
.try_into()
|
||||||
|
.expect("MAC is 6 bytes")
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
//! MCU family initialization primitives.
|
||||||
|
//!
|
||||||
|
//! Each submodule provides low-level bus and peripheral init for one MCU family.
|
||||||
|
//! Board files call these with specific pins and construct higher-level drivers.
|
||||||
|
|
||||||
|
#[cfg(any(
|
||||||
|
feature = "heltec_v3",
|
||||||
|
feature = "heltec_v3_uart",
|
||||||
|
feature = "heltec_v4",
|
||||||
|
feature = "elecrow_thinknode_m2",
|
||||||
|
feature = "lilygo_tbeam_supreme"
|
||||||
|
))]
|
||||||
|
pub mod esp32s3;
|
||||||
|
|
||||||
|
#[cfg(feature = "lilygo_tbeam")]
|
||||||
|
pub mod esp32;
|
||||||
|
|
||||||
|
#[cfg(any(
|
||||||
|
feature = "heltec_mesh_node_t114",
|
||||||
|
feature = "rak_wisblock_4631",
|
||||||
|
feature = "wio_tracker_l1"
|
||||||
|
))]
|
||||||
|
pub mod nrf52840;
|
||||||
|
|
||||||
|
#[cfg(feature = "waveshare_rp2040_lora")]
|
||||||
|
pub mod rp2040;
|
||||||
@@ -0,0 +1,164 @@
|
|||||||
|
//! nRF52840 MCU initialization primitives.
|
||||||
|
//!
|
||||||
|
//! Provides shared boilerplate for nRF52840 boards: interrupt bindings,
|
||||||
|
//! type aliases, StaticCell bus wrapping, USB init, and MAC address reading.
|
||||||
|
|
||||||
|
use embassy_nrf::bind_interrupts;
|
||||||
|
use embassy_sync::blocking_mutex::raw::NoopRawMutex;
|
||||||
|
use static_cell::StaticCell;
|
||||||
|
|
||||||
|
// ── Interrupts (shared by all nRF52840 boards) ─────────────────────
|
||||||
|
|
||||||
|
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>;
|
||||||
|
|
||||||
|
// ── SPI bus sharing ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// Wrap an initialized SPI peripheral in a shared bus (StaticCell + Mutex).
|
||||||
|
pub fn share_spi_bus(spi: SpiBus) -> &'static embassy_sync::mutex::Mutex<NoopRawMutex, SpiBus> {
|
||||||
|
static SPI_BUS: StaticCell<embassy_sync::mutex::Mutex<NoopRawMutex, SpiBus>> =
|
||||||
|
StaticCell::new();
|
||||||
|
SPI_BUS.init(embassy_sync::mutex::Mutex::new(spi))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── I2C DMA buffer ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// Allocate a DMA-safe buffer for Twim I2C.
|
||||||
|
///
|
||||||
|
/// 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])
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── USB VBUS detection ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// Allocate and initialize software VBUS detection.
|
||||||
|
pub fn alloc_vbus_detect(
|
||||||
|
vbus_detect: bool,
|
||||||
|
self_powered: bool,
|
||||||
|
) -> &'static embassy_nrf::usb::vbus_detect::SoftwareVbusDetect {
|
||||||
|
static VBUS: StaticCell<embassy_nrf::usb::vbus_detect::SoftwareVbusDetect> = StaticCell::new();
|
||||||
|
VBUS.init(embassy_nrf::usb::vbus_detect::SoftwareVbusDetect::new(
|
||||||
|
vbus_detect,
|
||||||
|
self_powered,
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 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.
|
||||||
|
pub fn mac_address() -> [u8; 6] {
|
||||||
|
// SAFETY: 0x10000000 is the nRF52840 FICR (Factory Information Configuration
|
||||||
|
// Registers) base address. Offsets 0x0A4 and 0x0A8 are the DEVICEADDR[0] and
|
||||||
|
// DEVICEADDR[1] registers containing the factory-programmed device address.
|
||||||
|
// read_volatile is correct for memory-mapped hardware registers.
|
||||||
|
unsafe {
|
||||||
|
let ficr = 0x10000000 as *const u32;
|
||||||
|
let addr0 = core::ptr::read_volatile(ficr.byte_add(0x0A4));
|
||||||
|
let addr1 = core::ptr::read_volatile(ficr.byte_add(0x0A8));
|
||||||
|
[
|
||||||
|
addr0 as u8,
|
||||||
|
(addr0 >> 8) as u8,
|
||||||
|
(addr0 >> 16) as u8,
|
||||||
|
(addr0 >> 24) as u8,
|
||||||
|
addr1 as u8,
|
||||||
|
(addr1 >> 8) as u8,
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
//! RP2040 MCU initialization primitives.
|
||||||
|
//!
|
||||||
|
//! Provides shared boilerplate for RP2040 boards: interrupt bindings,
|
||||||
|
//! type aliases, StaticCell bus wrapping, USB init, and MAC address reading.
|
||||||
|
|
||||||
|
use embassy_rp::bind_interrupts;
|
||||||
|
use embassy_sync::blocking_mutex::raw::NoopRawMutex;
|
||||||
|
use static_cell::StaticCell;
|
||||||
|
|
||||||
|
// ── Interrupts (shared by all RP2040 boards) ──────────────────────
|
||||||
|
|
||||||
|
bind_interrupts!(pub struct Irqs {
|
||||||
|
USBCTRL_IRQ => embassy_rp::usb::InterruptHandler<embassy_rp::peripherals::USB>;
|
||||||
|
DMA_IRQ_0 => embassy_rp::dma::InterruptHandler<embassy_rp::peripherals::DMA_CH0>,
|
||||||
|
embassy_rp::dma::InterruptHandler<embassy_rp::peripherals::DMA_CH1>;
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── MCU-level types ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
pub type SpiBus =
|
||||||
|
embassy_rp::spi::Spi<'static, embassy_rp::peripherals::SPI1, embassy_rp::spi::Async>;
|
||||||
|
pub type UsbRpDriver = embassy_rp::usb::Driver<'static, embassy_rp::peripherals::USB>;
|
||||||
|
|
||||||
|
// ── SPI bus sharing ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// Wrap an initialized SPI peripheral in a shared bus (StaticCell + Mutex).
|
||||||
|
pub fn share_spi_bus(spi: SpiBus) -> &'static embassy_sync::mutex::Mutex<NoopRawMutex, SpiBus> {
|
||||||
|
static SPI_BUS: StaticCell<embassy_sync::mutex::Mutex<NoopRawMutex, SpiBus>> =
|
||||||
|
StaticCell::new();
|
||||||
|
SPI_BUS.init(embassy_sync::mutex::Mutex::new(spi))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── MAC address ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// Store for the MAC address derived from flash unique ID.
|
||||||
|
/// Populated once during `init_mac()`, read by `mac_address()`.
|
||||||
|
static MAC: StaticCell<[u8; 6]> = StaticCell::new();
|
||||||
|
static mut MAC_REF: Option<&'static [u8; 6]> = None;
|
||||||
|
|
||||||
|
/// Read the flash chip's unique ID and derive a 6-byte MAC address.
|
||||||
|
///
|
||||||
|
/// Must be called once during board init before `mac_address()`.
|
||||||
|
pub fn init_mac<const FLASH_SIZE: usize>(
|
||||||
|
flash: &mut embassy_rp::flash::Flash<
|
||||||
|
'_,
|
||||||
|
embassy_rp::peripherals::FLASH,
|
||||||
|
embassy_rp::flash::Blocking,
|
||||||
|
FLASH_SIZE,
|
||||||
|
>,
|
||||||
|
) {
|
||||||
|
let mut uid = [0u8; 8];
|
||||||
|
flash.blocking_unique_id(&mut uid).expect("flash unique ID");
|
||||||
|
|
||||||
|
// XOR the first 2 bytes into bytes 2..8 to fold 8 bytes into 6,
|
||||||
|
// then set the locally-administered bit (byte 0, bit 1).
|
||||||
|
let mac = [
|
||||||
|
uid[2] ^ uid[0] | 0x02,
|
||||||
|
uid[3] ^ uid[1],
|
||||||
|
uid[4],
|
||||||
|
uid[5],
|
||||||
|
uid[6],
|
||||||
|
uid[7],
|
||||||
|
];
|
||||||
|
|
||||||
|
let stored = MAC.init(mac);
|
||||||
|
// SAFETY: Written once during single-threaded init, before the executor
|
||||||
|
// starts. All subsequent reads are from &'static reference.
|
||||||
|
unsafe {
|
||||||
|
MAC_REF = Some(stored);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Return the MAC address derived from the flash unique ID.
|
||||||
|
///
|
||||||
|
/// Panics if `init_mac()` has not been called.
|
||||||
|
pub fn mac_address() -> [u8; 6] {
|
||||||
|
// SAFETY: MAC_REF is set once during init before the executor starts,
|
||||||
|
// and only read (never written) after that.
|
||||||
|
unsafe { *MAC_REF.expect("init_mac() must be called before mac_address()") }
|
||||||
|
}
|
||||||
@@ -0,0 +1,146 @@
|
|||||||
|
//! Shared framing helpers for USB and UART host transports.
|
||||||
|
//!
|
||||||
|
//! Ingress path: raw bytes → `FrameDecoder` → `(type_id, tag, payload)`
|
||||||
|
//! → `Command::parse` → forwarded to radio task along with the tag. On
|
||||||
|
//! parse failure we echo `ERR(ELENGTH/EPARAM/EMODULATION/EUNKNOWN_CMD)`
|
||||||
|
//! with the received tag. On CRC/COBS failure we emit async
|
||||||
|
//! `ERR(EFRAME)` with tag `0x0000` (`PROTOCOL.md §2.5`).
|
||||||
|
//!
|
||||||
|
//! Egress path: `OutboundFrame` (tag + DeviceMessage) → `encode_frame`
|
||||||
|
//! → bytes ready for transport-level write.
|
||||||
|
|
||||||
|
use defmt::warn;
|
||||||
|
|
||||||
|
use crate::channel::{CommandChannel, InboundEvent, OutboundChannel, OutboundFrame};
|
||||||
|
use crate::protocol::{
|
||||||
|
encode_frame, Command, CommandParseError, DeviceMessage, ErrorCode, FrameEncodeError,
|
||||||
|
MAX_PAYLOAD_FIELD, MAX_WIRE_FRAME,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Dispatch a frame-level decode into the appropriate downstream queue.
|
||||||
|
///
|
||||||
|
/// - `type_id`, `tag`, and `payload` come from `FrameDecoder`.
|
||||||
|
/// - Known commands are forwarded to the radio task via `commands`.
|
||||||
|
/// - Parse failures produce a synchronous `ERR` with the echoed tag on
|
||||||
|
/// `outbound`, so the host sees a concrete answer instead of timing out.
|
||||||
|
/// - The caller is responsible for rejecting `tag == 0` before calling
|
||||||
|
/// this; the spec says hosts MUST NOT send tag 0, and we classify such
|
||||||
|
/// frames as framing errors upstream.
|
||||||
|
pub async fn dispatch_frame(
|
||||||
|
type_id: u8,
|
||||||
|
tag: u16,
|
||||||
|
payload: &[u8],
|
||||||
|
commands: &CommandChannel,
|
||||||
|
outbound: &OutboundChannel,
|
||||||
|
) {
|
||||||
|
if tag == 0 {
|
||||||
|
// Hosts MUST NOT use tag 0 on outbound commands. Treat as
|
||||||
|
// framing error; do not route to radio. (Spec §2.2)
|
||||||
|
emit_async_err(outbound, ErrorCode::EFrame).await;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
match Command::parse(type_id, payload) {
|
||||||
|
Ok(cmd) => {
|
||||||
|
let envelope = InboundEvent::Command { tag, cmd };
|
||||||
|
if commands.try_send(envelope).is_err() {
|
||||||
|
emit_sync_err(outbound, tag, ErrorCode::EBusy).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
let code = parse_err_to_wire_code(e);
|
||||||
|
emit_sync_err(outbound, tag, code).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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),
|
||||||
|
};
|
||||||
|
if outbound.try_send(frame).is_err() {
|
||||||
|
warn!("async ERR dropped: outbound queue full");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Emit a synchronous error response echoing the originating tag.
|
||||||
|
async fn emit_sync_err(outbound: &OutboundChannel, tag: u16, code: ErrorCode) {
|
||||||
|
let frame = OutboundFrame {
|
||||||
|
tag,
|
||||||
|
msg: DeviceMessage::Err(code),
|
||||||
|
};
|
||||||
|
if outbound.try_send(frame).is_err() {
|
||||||
|
warn!("sync ERR dropped: outbound queue full");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_err_to_wire_code(e: CommandParseError) -> ErrorCode {
|
||||||
|
match e {
|
||||||
|
CommandParseError::UnknownType => ErrorCode::EUnknownCmd,
|
||||||
|
CommandParseError::WrongLength => ErrorCode::ELength,
|
||||||
|
CommandParseError::InvalidField => ErrorCode::EParam,
|
||||||
|
CommandParseError::ReservedBitSet => ErrorCode::EParam,
|
||||||
|
CommandParseError::UnknownModulation => ErrorCode::EModulation,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Encode an outbound frame into a single wire-ready byte buffer.
|
||||||
|
///
|
||||||
|
/// Returns the number of bytes written to `out`, or `None` on buffer
|
||||||
|
/// overflow / encode failure (caller may log and drop).
|
||||||
|
pub fn encode_outbound(frame: &OutboundFrame, out: &mut [u8; MAX_WIRE_FRAME]) -> Option<usize> {
|
||||||
|
let mut payload_buf = [0u8; MAX_PAYLOAD_FIELD];
|
||||||
|
let payload_len = match frame.msg.encode_payload(&mut payload_buf) {
|
||||||
|
Ok(n) => n,
|
||||||
|
Err(e) => {
|
||||||
|
warn!("encode_payload failed: {:?}", defmt::Debug2Format(&e));
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
match encode_frame(
|
||||||
|
frame.msg.type_id(),
|
||||||
|
frame.tag,
|
||||||
|
&payload_buf[..payload_len],
|
||||||
|
out.as_mut_slice(),
|
||||||
|
) {
|
||||||
|
Ok(n) => Some(n),
|
||||||
|
Err(FrameEncodeError::BufferTooSmall) => {
|
||||||
|
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 (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 (type={=u8}, tag={=u16}, payload={=usize}B)",
|
||||||
|
frame.msg.type_id(),
|
||||||
|
frame.tag,
|
||||||
|
payload_len,
|
||||||
|
);
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
//! Host communication: unified USB/UART transport.
|
||||||
|
//!
|
||||||
|
//! Exports a single `host_task` regardless of transport.
|
||||||
|
//! The cfg dispatch is contained entirely within this module.
|
||||||
|
|
||||||
|
pub mod framing;
|
||||||
|
|
||||||
|
cfg_if::cfg_if! {
|
||||||
|
if #[cfg(any(
|
||||||
|
feature = "heltec_v3_uart",
|
||||||
|
feature = "elecrow_thinknode_m2",
|
||||||
|
feature = "lilygo_tbeam"
|
||||||
|
))] {
|
||||||
|
mod uart;
|
||||||
|
pub use uart::host_task;
|
||||||
|
} else {
|
||||||
|
mod usb;
|
||||||
|
pub use usb::host_task;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,189 @@
|
|||||||
|
//! UART host task for DongLoRa Protocol v2.
|
||||||
|
//!
|
||||||
|
//! UART has no DTR line, so we assume a host is always present and
|
||||||
|
//! rely entirely on the spec §3.4 inactivity watchdog (1 s of silence
|
||||||
|
//! → Disconnected → drop radio to Unconfigured).
|
||||||
|
|
||||||
|
use defmt::{info, warn};
|
||||||
|
use embassy_executor::task;
|
||||||
|
use embassy_time::{Duration, Instant};
|
||||||
|
use embedded_io_async::Write;
|
||||||
|
|
||||||
|
use super::framing::{dispatch_frame, emit_async_err, encode_outbound};
|
||||||
|
use crate::channel::{
|
||||||
|
CommandChannel, DisplayCommand, DisplayCommandChannel, InboundEvent, OutboundChannel,
|
||||||
|
};
|
||||||
|
use crate::protocol::{ErrorCode, FrameDecoder, FrameResult, MAX_PAYLOAD_FIELD, MAX_WIRE_FRAME};
|
||||||
|
|
||||||
|
const READ_BUF: usize = 64;
|
||||||
|
const INACTIVITY_MS: u64 = 1000;
|
||||||
|
|
||||||
|
#[task]
|
||||||
|
pub async fn host_task(
|
||||||
|
parts: crate::board::UartParts,
|
||||||
|
commands: &'static CommandChannel,
|
||||||
|
outbound: &'static OutboundChannel,
|
||||||
|
display_commands: &'static DisplayCommandChannel,
|
||||||
|
has_display: bool,
|
||||||
|
_mac: [u8; 6],
|
||||||
|
) {
|
||||||
|
let (mut rx, mut tx) = parts.driver.split();
|
||||||
|
|
||||||
|
// UART has no DTR — wake display immediately and assume a host.
|
||||||
|
if has_display {
|
||||||
|
display_commands.send(DisplayCommand::On).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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::{select, Either};
|
||||||
|
|
||||||
|
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 {
|
||||||
|
let _ = display_commands.try_send(DisplayCommand::On);
|
||||||
|
}
|
||||||
|
connected = true;
|
||||||
|
}
|
||||||
|
dispatch_bytes(&mut decoder, &read_buf[..n], commands, outbound).await;
|
||||||
|
}
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
embassy_futures::join::join(read_loop, write_loop).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn dispatch_bytes(
|
||||||
|
decoder: &mut FrameDecoder,
|
||||||
|
bytes: &[u8],
|
||||||
|
commands: &'static CommandChannel,
|
||||||
|
outbound: &'static OutboundChannel,
|
||||||
|
) {
|
||||||
|
let mut work: heapless::Vec<FrameWork, 4> = heapless::Vec::new();
|
||||||
|
decoder.feed(bytes, |res| match res {
|
||||||
|
FrameResult::Ok {
|
||||||
|
type_id,
|
||||||
|
tag,
|
||||||
|
payload,
|
||||||
|
} => {
|
||||||
|
let mut p: heapless::Vec<u8, MAX_PAYLOAD_FIELD> = heapless::Vec::new();
|
||||||
|
let _ = p.extend_from_slice(payload);
|
||||||
|
let _ = work.push(FrameWork::Ok {
|
||||||
|
type_id,
|
||||||
|
tag,
|
||||||
|
payload: p,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
FrameResult::Err(_) => {
|
||||||
|
let _ = work.push(FrameWork::Frame);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
for w in work {
|
||||||
|
match w {
|
||||||
|
FrameWork::Ok {
|
||||||
|
type_id,
|
||||||
|
tag,
|
||||||
|
payload,
|
||||||
|
} => {
|
||||||
|
dispatch_frame(type_id, tag, &payload, commands, outbound).await;
|
||||||
|
}
|
||||||
|
FrameWork::Frame => {
|
||||||
|
emit_async_err(outbound, ErrorCode::EFrame).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[allow(clippy::large_enum_variant)]
|
||||||
|
enum FrameWork {
|
||||||
|
Ok {
|
||||||
|
type_id: u8,
|
||||||
|
tag: u16,
|
||||||
|
payload: heapless::Vec<u8, MAX_PAYLOAD_FIELD>,
|
||||||
|
},
|
||||||
|
Frame,
|
||||||
|
}
|
||||||
+467
@@ -0,0 +1,467 @@
|
|||||||
|
//! USB CDC-ACM host task for DongLoRa Protocol v2.
|
||||||
|
//!
|
||||||
|
//! ```text
|
||||||
|
//! Host
|
||||||
|
//! ├── 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.
|
||||||
|
//! ```
|
||||||
|
//!
|
||||||
|
//! 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` because `read_packet` blocks.
|
||||||
|
|
||||||
|
use embassy_executor::task;
|
||||||
|
use embassy_futures::join::join;
|
||||||
|
use embassy_time::Duration;
|
||||||
|
use embassy_usb::class::cdc_acm::{CdcAcmClass, Receiver, Sender, State};
|
||||||
|
use embassy_usb::Builder;
|
||||||
|
use hsmc::{statechart, Duration as HsmcDuration};
|
||||||
|
use static_cell::StaticCell;
|
||||||
|
|
||||||
|
use super::framing::{dispatch_frame, emit_async_err, encode_outbound};
|
||||||
|
use crate::channel::{
|
||||||
|
CommandChannel, DisplayCommand, DisplayCommandChannel, InboundEvent, OutboundChannel,
|
||||||
|
};
|
||||||
|
use crate::protocol::{ErrorCode, FrameDecoder, FrameResult, MAX_WIRE_FRAME};
|
||||||
|
|
||||||
|
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. 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,
|
||||||
|
/// 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 {
|
||||||
|
commands: &'static CommandChannel,
|
||||||
|
display_commands: &'static DisplayCommandChannel,
|
||||||
|
has_display: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl HostContext {
|
||||||
|
async fn wake_display_if_present(&self) {
|
||||||
|
if self.has_display {
|
||||||
|
self.display_commands.send(DisplayCommand::On).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn reset_display_if_present(&self) {
|
||||||
|
if self.has_display {
|
||||||
|
self.display_commands.send(DisplayCommand::Reset).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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() {
|
||||||
|
// 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
statechart! {
|
||||||
|
Host {
|
||||||
|
context: HostContext;
|
||||||
|
events: HostInput;
|
||||||
|
// Auto-trace — backend selected by `hsmc/trace-*` cargo feature.
|
||||||
|
trace;
|
||||||
|
default(Disconnected);
|
||||||
|
|
||||||
|
state Disconnected {
|
||||||
|
on(DtrRaised) => Connected;
|
||||||
|
}
|
||||||
|
|
||||||
|
state Connected {
|
||||||
|
entry: wake_display;
|
||||||
|
exit: reset_radio, reset_display;
|
||||||
|
on(DtrDropped) => 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl HostActions for HostActionContext<'_> {
|
||||||
|
async fn wake_display(&mut self) {
|
||||||
|
self.wake_display_if_present().await;
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn reset_radio(&mut self) {
|
||||||
|
self.send_radio_reset();
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn reset_display(&mut self) {
|
||||||
|
self.reset_display_if_present().await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[task]
|
||||||
|
pub async fn host_task(
|
||||||
|
parts: crate::board::UsbParts,
|
||||||
|
commands: &'static CommandChannel,
|
||||||
|
outbound: &'static OutboundChannel,
|
||||||
|
display_commands: &'static DisplayCommandChannel,
|
||||||
|
has_display: bool,
|
||||||
|
mac: [u8; 6],
|
||||||
|
) {
|
||||||
|
let mut config = embassy_usb::Config::new(0x1209, 0x5741);
|
||||||
|
config.manufacturer = Some("DongLoRa");
|
||||||
|
config.product = Some("DongLoRa LoRa Radio");
|
||||||
|
config.device_class = 0xEF;
|
||||||
|
config.device_sub_class = 0x02;
|
||||||
|
config.device_protocol = 0x01;
|
||||||
|
config.composite_with_iads = true;
|
||||||
|
|
||||||
|
static SERIAL_BUF: StaticCell<[u8; 12]> = StaticCell::new();
|
||||||
|
let serial_buf = SERIAL_BUF.init([0u8; 12]);
|
||||||
|
const HEX: &[u8; 16] = b"0123456789ABCDEF";
|
||||||
|
for (i, &byte) in mac.iter().enumerate() {
|
||||||
|
serial_buf[i * 2] = HEX[(byte >> 4) as usize];
|
||||||
|
serial_buf[i * 2 + 1] = HEX[(byte & 0x0F) as usize];
|
||||||
|
}
|
||||||
|
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; 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; 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();
|
||||||
|
|
||||||
|
join(
|
||||||
|
usb_dev.run(),
|
||||||
|
protocol_loop(
|
||||||
|
sender,
|
||||||
|
receiver,
|
||||||
|
commands,
|
||||||
|
outbound,
|
||||||
|
display_commands,
|
||||||
|
has_display,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn protocol_loop<'d, D: embassy_usb_driver::Driver<'d>>(
|
||||||
|
mut sender: Sender<'d, D>,
|
||||||
|
mut receiver: Receiver<'d, D>,
|
||||||
|
commands: &'static CommandChannel,
|
||||||
|
outbound: &'static OutboundChannel,
|
||||||
|
display_commands: &'static DisplayCommandChannel,
|
||||||
|
has_display: bool,
|
||||||
|
) {
|
||||||
|
use embassy_futures::select::{select3, Either3};
|
||||||
|
|
||||||
|
let mut machine = Host::new_local(HostContext {
|
||||||
|
commands,
|
||||||
|
display_commands,
|
||||||
|
has_display,
|
||||||
|
});
|
||||||
|
machine.step(HsmcDuration::ZERO).await;
|
||||||
|
|
||||||
|
let mut read_buf = [0u8; MAX_USB_PACKET];
|
||||||
|
let mut write_buf: [u8; MAX_WIRE_FRAME] = [0u8; MAX_WIRE_FRAME];
|
||||||
|
let mut decoder = FrameDecoder::new();
|
||||||
|
|
||||||
|
loop {
|
||||||
|
match select3(
|
||||||
|
receiver.read_packet(&mut read_buf),
|
||||||
|
outbound.receive(),
|
||||||
|
embassy_time::Timer::after(Duration::from_millis(POLL_INTERVAL_MS)),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Either3::First(Ok(0)) => {
|
||||||
|
defmt::info!("host_task: read_packet returned 0 bytes");
|
||||||
|
}
|
||||||
|
Either3::First(Ok(n)) => {
|
||||||
|
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(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) {
|
||||||
|
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() {
|
||||||
|
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(()) => {
|
||||||
|
// 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. 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;
|
||||||
|
}
|
||||||
|
(true, false) => {
|
||||||
|
defmt::info!("host_task: DTR dropped");
|
||||||
|
decoder.reset();
|
||||||
|
let _ = machine.dispatch(HostInput::DtrDropped).await;
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Feed raw bytes into the frame decoder. For each complete frame:
|
||||||
|
/// - Ok → route into the command channel (or emit sync ERR on parse fail)
|
||||||
|
/// - Err(Crc | Cobs | TooShort) → emit async ERR(EFRAME)
|
||||||
|
///
|
||||||
|
/// Borrow-scoped per the `FrameDecoder::feed` callback contract: the
|
||||||
|
/// payload slice is valid only for the duration of the callback.
|
||||||
|
async fn dispatch_bytes(
|
||||||
|
decoder: &mut FrameDecoder,
|
||||||
|
bytes: &[u8],
|
||||||
|
commands: &'static CommandChannel,
|
||||||
|
outbound: &'static OutboundChannel,
|
||||||
|
) {
|
||||||
|
// `FrameDecoder::feed` hands payloads by reference; we must synchronously
|
||||||
|
// copy any data we need before the callback returns. To avoid fighting
|
||||||
|
// lifetimes we accumulate per-frame work as an enum.
|
||||||
|
let mut work: heapless::Vec<FrameWork, 4> = heapless::Vec::new();
|
||||||
|
decoder.feed(bytes, |res| match res {
|
||||||
|
FrameResult::Ok {
|
||||||
|
type_id,
|
||||||
|
tag,
|
||||||
|
payload,
|
||||||
|
} => {
|
||||||
|
let mut p: heapless::Vec<u8, { crate::protocol::MAX_PAYLOAD_FIELD }> =
|
||||||
|
heapless::Vec::new();
|
||||||
|
let _ = p.extend_from_slice(payload);
|
||||||
|
let _ = work.push(FrameWork::Ok {
|
||||||
|
type_id,
|
||||||
|
tag,
|
||||||
|
payload: p,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
FrameResult::Err(_e) => {
|
||||||
|
let _ = work.push(FrameWork::Frame);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
for w in work {
|
||||||
|
match w {
|
||||||
|
FrameWork::Ok {
|
||||||
|
type_id,
|
||||||
|
tag,
|
||||||
|
payload,
|
||||||
|
} => {
|
||||||
|
dispatch_frame(type_id, tag, &payload, commands, outbound).await;
|
||||||
|
}
|
||||||
|
FrameWork::Frame => {
|
||||||
|
emit_async_err(outbound, ErrorCode::EFrame).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[allow(clippy::large_enum_variant)]
|
||||||
|
enum FrameWork {
|
||||||
|
Ok {
|
||||||
|
type_id: u8,
|
||||||
|
tag: u16,
|
||||||
|
payload: heapless::Vec<u8, { crate::protocol::MAX_PAYLOAD_FIELD }>,
|
||||||
|
},
|
||||||
|
Frame,
|
||||||
|
}
|
||||||
+285
@@ -0,0 +1,285 @@
|
|||||||
|
//! Pure-math helpers for Listen-Before-Talk (CAD) retry timing.
|
||||||
|
//!
|
||||||
|
//! The CAD retry loop in `radio::do_tx` scales its backoff to the active
|
||||||
|
//! LoRa configuration: per-retry sleep is one time-on-air of an assumed
|
||||||
|
//! peer packet, total retry budget is capped at `CAD_BUDGET_US`. This
|
||||||
|
//! module owns the ToA math (Semtech AN1200.13) and the retry-count
|
||||||
|
//! derivation, split out so it can be unit-tested host-side without the
|
||||||
|
//! radio module's hardware dependencies.
|
||||||
|
|
||||||
|
use crate::protocol::{LoRaCodingRate, LoRaConfig, LoRaHeaderMode};
|
||||||
|
|
||||||
|
/// Total LBT budget: the maximum time the CAD retry loop is allowed to
|
||||||
|
/// tie up the radio before returning `ChannelBusy`.
|
||||||
|
pub const CAD_BUDGET_US: u32 = 2_500_000;
|
||||||
|
/// Upper bound on retries, independent of budget — caps the work on
|
||||||
|
/// very fast configs (SF7/BW500 would otherwise get 60+ retries).
|
||||||
|
pub const CAD_HARD_CAP: u8 = 10;
|
||||||
|
/// Assumed peer-packet payload (bytes) used to size the per-retry sleep.
|
||||||
|
/// 80 is a representative median for typical mesh traffic. Peers sending
|
||||||
|
/// larger packets may be partially under-waited (accepted trade-off —
|
||||||
|
/// host-level retry handles collisions).
|
||||||
|
#[allow(dead_code)] // consumed by `radio.rs`, which is gated out of test builds
|
||||||
|
pub const CAD_ASSUMED_PEER_PAYLOAD_B: u16 = 80;
|
||||||
|
/// Jitter applied to each sleep, in tenths of a percent (±20 % = 200).
|
||||||
|
pub const CAD_JITTER_TENTHS_PCT: u32 = 200;
|
||||||
|
|
||||||
|
/// ToA formula expects CR ∈ {1..4} for 4/5..4/8 (each coded symbol carries
|
||||||
|
/// `CR+4` bits). The protocol enum starts at 0, so map explicitly — using
|
||||||
|
/// `as u32` alone would underestimate airtime by one code bit everywhere.
|
||||||
|
pub fn cr_numeric(cr: LoRaCodingRate) -> u32 {
|
||||||
|
match cr {
|
||||||
|
LoRaCodingRate::Cr4_5 => 1,
|
||||||
|
LoRaCodingRate::Cr4_6 => 2,
|
||||||
|
LoRaCodingRate::Cr4_7 => 3,
|
||||||
|
LoRaCodingRate::Cr4_8 => 4,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Worst-case LoRa time-on-air in microseconds (Semtech AN1200.13).
|
||||||
|
///
|
||||||
|
/// Uses `N_preamble + 4.25` quarter-symbols for preamble duration; SX126x
|
||||||
|
/// uses 6.25 at SF5/SF6, so the result is low by 2·T_sym at those SFs
|
||||||
|
/// (sub-millisecond — negligible at the LBT-budget scale).
|
||||||
|
pub fn toa_us(cfg: &LoRaConfig, payload_len: u16) -> u32 {
|
||||||
|
let sf = cfg.sf as u64;
|
||||||
|
let bw_hz = cfg.bw.as_hz() as u64;
|
||||||
|
// Symbol time in µs: (2^SF / BW_Hz) * 1e6. u64 avoids overflow at
|
||||||
|
// SF12/BW7 where T_sym ≈ 524 ms.
|
||||||
|
let t_sym_us = (1u64 << sf) * 1_000_000 / bw_hz.max(1);
|
||||||
|
let de: i64 = if t_sym_us > 16_000 { 1 } else { 0 };
|
||||||
|
let crc: i64 = if cfg.payload_crc { 1 } else { 0 };
|
||||||
|
let h: i64 = if matches!(cfg.header_mode, LoRaHeaderMode::Implicit) {
|
||||||
|
1
|
||||||
|
} else {
|
||||||
|
0
|
||||||
|
};
|
||||||
|
let pl = payload_len as i64;
|
||||||
|
let sf_i = sf as i64;
|
||||||
|
|
||||||
|
// Payload symbol count (signed intermediates — `num` goes negative
|
||||||
|
// for small PL at SF12 with H=1, CRC=0).
|
||||||
|
let num = 8 * pl - 4 * sf_i + 28 + 16 * crc - 20 * h;
|
||||||
|
let den = 4 * (sf_i - 2 * de);
|
||||||
|
let payload_sym_ceil = if num <= 0 || den <= 0 {
|
||||||
|
0i64
|
||||||
|
} else {
|
||||||
|
(num + den - 1) / den
|
||||||
|
};
|
||||||
|
let cr_num = cr_numeric(cfg.cr) as i64;
|
||||||
|
let n_payload = 8 + (payload_sym_ceil * (cr_num + 4)).max(0);
|
||||||
|
let t_payload_us = n_payload as u64 * t_sym_us;
|
||||||
|
|
||||||
|
// Preamble duration: (N + 4.25) * T_sym = (4·N + 17) * T_sym / 4.
|
||||||
|
let t_preamble_us = (cfg.preamble_len as u64 * 4 + 17) * t_sym_us / 4;
|
||||||
|
|
||||||
|
(t_preamble_us + t_payload_us).min(u32::MAX as u64) as u32
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Derive the CAD retry count and per-retry sleep from the cached
|
||||||
|
/// `cad_retry_delay_us`. Retries form: N CADs separated by N-1 sleeps.
|
||||||
|
/// When one assumed-packet wait exceeds the total budget, `retries = 1`
|
||||||
|
/// — a single CAD, bail on busy, no sleep.
|
||||||
|
pub fn cad_retries(cad_retry_delay_us: u32) -> (u8, u32) {
|
||||||
|
if cad_retry_delay_us == 0 {
|
||||||
|
// Unconfigured should never reach this path; fall back to a
|
||||||
|
// single CAD with no sleep rather than divide-by-zero.
|
||||||
|
return (1, 0);
|
||||||
|
}
|
||||||
|
let n_sleeps = CAD_BUDGET_US / cad_retry_delay_us;
|
||||||
|
let retries = (n_sleeps + 1).clamp(1, CAD_HARD_CAP as u32) as u8;
|
||||||
|
(retries, cad_retry_delay_us)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Apply ±`CAD_JITTER_TENTHS_PCT`/10 % jitter around `base_us`.
|
||||||
|
/// `entropy` is a raw 32-bit source; caller provides it so this stays
|
||||||
|
/// pure (radio task passes `Instant::now().as_ticks() as u32`).
|
||||||
|
pub fn jittered_sleep_us(base_us: u32, entropy: u32) -> u32 {
|
||||||
|
let mixed = entropy.wrapping_mul(0x9E37_79B9);
|
||||||
|
let span = 2 * CAD_JITTER_TENTHS_PCT + 1; // e.g. 401 for ±20 %
|
||||||
|
let r = mixed % span; // 0..=2·CAD_JITTER_TENTHS_PCT
|
||||||
|
// factor_thousandths ∈ [1000 − CAD_JITTER_TENTHS_PCT, 1000 + CAD_JITTER_TENTHS_PCT]
|
||||||
|
let factor_thousandths = 1000 - CAD_JITTER_TENTHS_PCT + r;
|
||||||
|
((base_us as u64 * factor_thousandths as u64) / 1000) as u32
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::protocol::{LoRaBandwidth, LoRaCodingRate, LoRaConfig, LoRaHeaderMode};
|
||||||
|
|
||||||
|
fn cfg(sf: u8, bw: LoRaBandwidth, cr: LoRaCodingRate, preamble: u16, implicit: bool, crc: bool) -> LoRaConfig {
|
||||||
|
LoRaConfig {
|
||||||
|
freq_hz: 915_000_000,
|
||||||
|
sf,
|
||||||
|
bw,
|
||||||
|
cr,
|
||||||
|
preamble_len: preamble,
|
||||||
|
sync_word: 0x1424,
|
||||||
|
tx_power_dbm: 0,
|
||||||
|
header_mode: if implicit {
|
||||||
|
LoRaHeaderMode::Implicit
|
||||||
|
} else {
|
||||||
|
LoRaHeaderMode::Explicit
|
||||||
|
},
|
||||||
|
payload_crc: crc,
|
||||||
|
iq_invert: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Helper: assert `actual` is within `tolerance_pct`% of `expected_us`.
|
||||||
|
fn approx(actual_us: u32, expected_us: u32, tolerance_pct: u32) {
|
||||||
|
let diff = actual_us.abs_diff(expected_us);
|
||||||
|
let tol = expected_us * tolerance_pct / 100;
|
||||||
|
assert!(
|
||||||
|
diff <= tol,
|
||||||
|
"actual {} us not within {}% of expected {} us (diff {}, tol {})",
|
||||||
|
actual_us,
|
||||||
|
tolerance_pct,
|
||||||
|
expected_us,
|
||||||
|
diff,
|
||||||
|
tol
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reference values sanity-checked against Semtech's LoRa calculator
|
||||||
|
// and AN1200.13.
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn toa_sf7_bw125_cr45_pl10_explicit_crc() {
|
||||||
|
let c = cfg(7, LoRaBandwidth::Khz125, LoRaCodingRate::Cr4_5, 8, false, true);
|
||||||
|
// Hand-computed reference from AN1200.13:
|
||||||
|
// T_sym = 128/125 kHz = 1.024 ms
|
||||||
|
// T_preamble = (8 + 4.25) · 1.024 = 12.544 ms
|
||||||
|
// n_payload = 8 + ceil((80 − 28 + 28 + 16) / 28) · 5 = 8 + 4·5 = 28
|
||||||
|
// T_payload = 28 · 1.024 = 28.672 ms
|
||||||
|
// Total ≈ 41.22 ms
|
||||||
|
approx(toa_us(&c, 10), 41_216, 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn toa_sf12_bw125_cr45_pl10_explicit_crc() {
|
||||||
|
let c = cfg(12, LoRaBandwidth::Khz125, LoRaCodingRate::Cr4_5, 8, false, true);
|
||||||
|
// Reference: ~991 ms for PL=10 at SF12/BW125/CR4/5 (LDRO on).
|
||||||
|
approx(toa_us(&c, 10), 991_000, 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn toa_sf10_bw125_cr45_pl255_max() {
|
||||||
|
let c = cfg(10, LoRaBandwidth::Khz125, LoRaCodingRate::Cr4_5, 8, false, true);
|
||||||
|
// Reference: ~2.34 s for PL=255 at SF10/BW125/CR4/5.
|
||||||
|
approx(toa_us(&c, 255), 2_335_000, 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn toa_ldro_activates_at_sf11_and_sf12_bw125() {
|
||||||
|
// T_sym > 16 ms → DE=1. SF11/BW125: T_sym = 16.384 ms. SF10/BW125: 8.192 ms.
|
||||||
|
let sf10 = cfg(10, LoRaBandwidth::Khz125, LoRaCodingRate::Cr4_5, 8, false, true);
|
||||||
|
let sf11 = cfg(11, LoRaBandwidth::Khz125, LoRaCodingRate::Cr4_5, 8, false, true);
|
||||||
|
let sf12 = cfg(12, LoRaBandwidth::Khz125, LoRaCodingRate::Cr4_5, 8, false, true);
|
||||||
|
// Crude check: each step roughly doubles airtime (LDRO doesn't
|
||||||
|
// break the doubling trend — it just shifts constants).
|
||||||
|
let t10 = toa_us(&sf10, 80);
|
||||||
|
let t11 = toa_us(&sf11, 80);
|
||||||
|
let t12 = toa_us(&sf12, 80);
|
||||||
|
assert!(t11 > t10 * 15 / 10, "SF11 should be > 1.5x SF10, got {} vs {}", t11, t10);
|
||||||
|
assert!(t12 > t11 * 15 / 10, "SF12 should be > 1.5x SF11, got {} vs {}", t12, t11);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn toa_assumed_peer_80b_matches_plan_table() {
|
||||||
|
let mk = |sf, bw| cfg(sf, bw, LoRaCodingRate::Cr4_5, 8, false, true);
|
||||||
|
// Plan table values (approximate, within 5%).
|
||||||
|
approx(toa_us(&mk(7, LoRaBandwidth::Khz500), 80), 36_000, 5);
|
||||||
|
approx(toa_us(&mk(7, LoRaBandwidth::Khz250), 80), 72_000, 5);
|
||||||
|
approx(toa_us(&mk(7, LoRaBandwidth::Khz125), 80), 144_000, 5);
|
||||||
|
approx(toa_us(&mk(8, LoRaBandwidth::Khz125), 80), 257_000, 5);
|
||||||
|
approx(toa_us(&mk(9, LoRaBandwidth::Khz125), 80), 452_000, 5);
|
||||||
|
approx(toa_us(&mk(10, LoRaBandwidth::Khz125), 80), 862_000, 5);
|
||||||
|
approx(toa_us(&mk(11, LoRaBandwidth::Khz125), 80), 1_806_000, 5);
|
||||||
|
approx(toa_us(&mk(12, LoRaBandwidth::Khz125), 80), 3_285_000, 5);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn toa_no_underflow_at_small_pl_sf12_implicit_no_crc() {
|
||||||
|
// num = 8·PL − 4·SF + 28 + 16·CRC − 20·H goes negative here; the
|
||||||
|
// i64 + max(0) guard must kick in without panicking or wrapping.
|
||||||
|
let c = cfg(12, LoRaBandwidth::Khz125, LoRaCodingRate::Cr4_5, 8, true, false);
|
||||||
|
let t = toa_us(&c, 1);
|
||||||
|
// Only 8 payload symbols survive (the formula floors to 0 extra);
|
||||||
|
// should be preamble + 8·T_sym ≈ 401 + 262 = 663 ms roughly.
|
||||||
|
approx(t, 663_000, 5);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Retry-count derivation table from the plan.
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn retries_sf7_bw500_hits_hard_cap() {
|
||||||
|
let (n, s) = cad_retries(36_000);
|
||||||
|
assert_eq!(n, 10);
|
||||||
|
assert_eq!(s, 36_000);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn retries_sf10_bw125() {
|
||||||
|
// T_assumed ≈ 862 ms; budget 2.5 s ⇒ 2 sleeps ⇒ 3 retries.
|
||||||
|
let (n, _) = cad_retries(862_000);
|
||||||
|
assert_eq!(n, 3);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn retries_sf11_bw125() {
|
||||||
|
// T_assumed ≈ 1.81 s; budget 2.5 s ⇒ 1 sleep ⇒ 2 retries.
|
||||||
|
let (n, _) = cad_retries(1_806_000);
|
||||||
|
assert_eq!(n, 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn retries_sf12_bw125_degraded() {
|
||||||
|
// T_assumed ≈ 3.29 s > 2.5 s budget ⇒ 0 sleeps ⇒ 1 retry (bail on busy).
|
||||||
|
let (n, _) = cad_retries(3_285_000);
|
||||||
|
assert_eq!(n, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn retries_zero_delay_falls_back_to_single_cad() {
|
||||||
|
// Unconfigured safety: avoid divide-by-zero.
|
||||||
|
let (n, s) = cad_retries(0);
|
||||||
|
assert_eq!(n, 1);
|
||||||
|
assert_eq!(s, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Jitter sanity.
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn jitter_stays_within_pm_20_pct() {
|
||||||
|
let base = 1_000_000u32;
|
||||||
|
// Exhaustive over the low byte — enough to cover the `% span` range.
|
||||||
|
let mut saw_low = false;
|
||||||
|
let mut saw_high = false;
|
||||||
|
for e in 0u32..=u16::MAX as u32 {
|
||||||
|
let j = jittered_sleep_us(base, e);
|
||||||
|
// ±20% ⇒ [800_000, 1_200_000].
|
||||||
|
assert!(
|
||||||
|
(800_000..=1_200_000).contains(&j),
|
||||||
|
"jitter {} us out of ±20% bounds for entropy {}",
|
||||||
|
j,
|
||||||
|
e
|
||||||
|
);
|
||||||
|
if j <= 850_000 {
|
||||||
|
saw_low = true;
|
||||||
|
}
|
||||||
|
if j >= 1_150_000 {
|
||||||
|
saw_high = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert!(saw_low && saw_high, "jitter didn't span the expected range");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn jitter_zero_base_is_zero() {
|
||||||
|
for e in [0u32, 1, 42, 0xDEAD_BEEF] {
|
||||||
|
assert_eq!(jittered_sleep_us(0, e), 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+140
-32
@@ -3,11 +3,11 @@
|
|||||||
//! Three async tasks communicate via static channels:
|
//! Three async tasks communicate via static channels:
|
||||||
//!
|
//!
|
||||||
//! ```text
|
//! ```text
|
||||||
//! usb_task ──Command──► radio_task ──► SX1262
|
//! host_task ──Command──► radio_task ──► SX1262
|
||||||
//! ◄──Response── │
|
//! ◄──Response── │
|
||||||
//! StatusWatch
|
//! StatusWatch
|
||||||
//! ▼
|
//! ▼
|
||||||
//! display_task (optional)
|
//! display_task (optional)
|
||||||
//! ```
|
//! ```
|
||||||
//!
|
//!
|
||||||
//! The host drives everything. The radio idles until commanded.
|
//! The host drives everything. The radio idles until commanded.
|
||||||
@@ -19,13 +19,20 @@
|
|||||||
mod board;
|
mod board;
|
||||||
#[cfg(not(test))]
|
#[cfg(not(test))]
|
||||||
mod channel;
|
mod channel;
|
||||||
|
#[cfg(all(not(test), feature = "debug-checkpoint"))]
|
||||||
|
mod debug_blink;
|
||||||
#[cfg(not(test))]
|
#[cfg(not(test))]
|
||||||
mod display;
|
mod display;
|
||||||
|
#[cfg(not(test))]
|
||||||
|
mod driver;
|
||||||
|
#[cfg(not(test))]
|
||||||
|
mod hal;
|
||||||
|
#[cfg(not(test))]
|
||||||
|
mod host;
|
||||||
|
mod lbt;
|
||||||
mod protocol;
|
mod protocol;
|
||||||
#[cfg(not(test))]
|
#[cfg(not(test))]
|
||||||
mod radio;
|
mod radio;
|
||||||
#[cfg(not(test))]
|
|
||||||
mod usb;
|
|
||||||
|
|
||||||
#[cfg(not(test))]
|
#[cfg(not(test))]
|
||||||
use embassy_executor::Spawner;
|
use embassy_executor::Spawner;
|
||||||
@@ -33,35 +40,43 @@ use embassy_executor::Spawner;
|
|||||||
#[cfg(not(test))]
|
#[cfg(not(test))]
|
||||||
use crate::board::LoRaBoard;
|
use crate::board::LoRaBoard;
|
||||||
#[cfg(not(test))]
|
#[cfg(not(test))]
|
||||||
use crate::channel::{CommandChannel, DisplayCommandChannel, ResponseChannel, StatusWatch};
|
use crate::channel::{CommandChannel, DisplayCommandChannel, OutboundChannel, RadioEventChannel};
|
||||||
|
#[cfg(not(test))]
|
||||||
|
use crate::protocol::{cap, Info, MAX_MCU_UID_LEN, MAX_RADIO_UID_LEN};
|
||||||
|
|
||||||
#[cfg(not(test))]
|
#[cfg(not(test))]
|
||||||
cfg_if::cfg_if! {
|
cfg_if::cfg_if! {
|
||||||
if #[cfg(feature = "rak_wisblock_4631")] {
|
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 _;
|
use panic_probe as _;
|
||||||
} else if #[cfg(any(feature = "heltec_v3", feature = "heltec_v4"))] {
|
} 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_backtrace as _;
|
||||||
use esp_println 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!();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(not(test))]
|
#[cfg(not(test))]
|
||||||
static COMMANDS: CommandChannel = CommandChannel::new();
|
static COMMANDS: CommandChannel = CommandChannel::new();
|
||||||
#[cfg(not(test))]
|
#[cfg(not(test))]
|
||||||
static RESPONSES: ResponseChannel = ResponseChannel::new();
|
static OUTBOUND: OutboundChannel = OutboundChannel::new();
|
||||||
#[cfg(not(test))]
|
#[cfg(not(test))]
|
||||||
static STATUS: StatusWatch = StatusWatch::new();
|
static RADIO_EVENTS: RadioEventChannel = RadioEventChannel::new();
|
||||||
#[cfg(not(test))]
|
#[cfg(not(test))]
|
||||||
static DISPLAY_COMMANDS: DisplayCommandChannel = DisplayCommandChannel::new();
|
static DISPLAY_COMMANDS: DisplayCommandChannel = DisplayCommandChannel::new();
|
||||||
|
#[cfg(not(test))]
|
||||||
|
static mut INFO: Option<Info> = None;
|
||||||
|
|
||||||
#[cfg(not(test))]
|
#[cfg(not(test))]
|
||||||
cfg_if::cfg_if! {
|
cfg_if::cfg_if! {
|
||||||
if #[cfg(feature = "rak_wisblock_4631")] {
|
if #[cfg(any(feature = "heltec_mesh_node_t114", feature = "rak_wisblock_4631", feature = "wio_tracker_l1", feature = "waveshare_rp2040_lora"))] {
|
||||||
#[embassy_executor::main]
|
#[embassy_executor::main]
|
||||||
async fn main(spawner: Spawner) {
|
async fn main(spawner: Spawner) {
|
||||||
run(spawner).await;
|
run(spawner).await;
|
||||||
}
|
}
|
||||||
} else if #[cfg(any(feature = "heltec_v3", feature = "heltec_v4"))] {
|
} 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"))] {
|
||||||
#[esp_rtos::main]
|
#[esp_rtos::main]
|
||||||
async fn main(spawner: Spawner) {
|
async fn main(spawner: Spawner) {
|
||||||
run(spawner).await;
|
run(spawner).await;
|
||||||
@@ -72,28 +87,121 @@ cfg_if::cfg_if! {
|
|||||||
#[cfg(not(test))]
|
#[cfg(not(test))]
|
||||||
async fn run(spawner: Spawner) {
|
async fn run(spawner: Spawner) {
|
||||||
let board = <board::Board as LoRaBoard>::init();
|
let board = <board::Board as LoRaBoard>::init();
|
||||||
let mac = board::Board::mac_address();
|
|
||||||
let (radio, usb, display) = board.into_parts();
|
|
||||||
|
|
||||||
let has_display = display.is_some();
|
// 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();
|
||||||
|
|
||||||
spawner
|
let parts = board.into_parts();
|
||||||
.spawn(radio::radio_task(radio, &COMMANDS, &RESPONSES, &STATUS))
|
|
||||||
.expect("spawn radio_task");
|
// Build the GET_INFO struct once at boot. This is the authoritative
|
||||||
spawner
|
// snapshot of device capabilities reported over the wire.
|
||||||
.spawn(usb::usb_task(
|
let info = build_info(parts.mac);
|
||||||
usb,
|
// SAFETY: touched only here at boot, before any task is spawned.
|
||||||
|
let info_ref: &'static Info = unsafe {
|
||||||
|
INFO = Some(info);
|
||||||
|
#[allow(static_mut_refs)]
|
||||||
|
INFO.as_ref().expect("INFO just initialized")
|
||||||
|
};
|
||||||
|
|
||||||
|
spawner.spawn(
|
||||||
|
radio::radio_task(parts.radio, &COMMANDS, &OUTBOUND, &RADIO_EVENTS, info_ref)
|
||||||
|
.expect("spawn radio_task"),
|
||||||
|
);
|
||||||
|
|
||||||
|
spawner.spawn(
|
||||||
|
host::host_task(
|
||||||
|
parts.host,
|
||||||
&COMMANDS,
|
&COMMANDS,
|
||||||
&RESPONSES,
|
&OUTBOUND,
|
||||||
&DISPLAY_COMMANDS,
|
&DISPLAY_COMMANDS,
|
||||||
has_display,
|
parts.display.is_some(),
|
||||||
mac,
|
parts.mac,
|
||||||
))
|
)
|
||||||
.expect("spawn usb_task");
|
.expect("spawn host_task"),
|
||||||
|
);
|
||||||
|
|
||||||
if let Some(dp) = display {
|
// Suppress "assigned but never read" warning on non-USB/UART path.
|
||||||
spawner
|
let _ = info_ref;
|
||||||
.spawn(display::display_task(dp, &STATUS, &DISPLAY_COMMANDS))
|
|
||||||
.expect("spawn display_task");
|
if let Some(dp) = parts.display {
|
||||||
|
#[cfg(feature = "debug-checkpoint")]
|
||||||
|
{
|
||||||
|
// LED is taken over by the checkpoint blinker; the display
|
||||||
|
// init struct is dropped unused for this debug build.
|
||||||
|
let _ = dp;
|
||||||
|
spawner
|
||||||
|
.spawn(debug_blink::debug_blink_task(parts.led).expect("spawn debug_blink_task"));
|
||||||
|
}
|
||||||
|
#[cfg(not(feature = "debug-checkpoint"))]
|
||||||
|
{
|
||||||
|
#[allow(clippy::unit_arg)] // LedDriver is () for boards without LEDs
|
||||||
|
spawner.spawn(
|
||||||
|
display::display_task(dp, parts.led, &RADIO_EVENTS, &DISPLAY_COMMANDS)
|
||||||
|
.expect("spawn display_task"),
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(not(test))]
|
||||||
|
fn build_info(mac: [u8; 6]) -> Info {
|
||||||
|
let (fmin, fmax) = <board::Board as LoRaBoard>::freq_range_hz();
|
||||||
|
let chip_id = <board::Board as LoRaBoard>::radio_chip_id();
|
||||||
|
let (tx_min, tx_max) = board::Board::TX_POWER_RANGE;
|
||||||
|
let supported_sf = <board::Board as LoRaBoard>::supported_sf_bitmap();
|
||||||
|
let supported_bw = <board::Board as LoRaBoard>::supported_bw_bitmap();
|
||||||
|
|
||||||
|
let mut mcu_uid = [0u8; MAX_MCU_UID_LEN];
|
||||||
|
mcu_uid[..6].copy_from_slice(&mac);
|
||||||
|
|
||||||
|
Info {
|
||||||
|
proto_major: 1,
|
||||||
|
proto_minor: 0,
|
||||||
|
fw_major: parse_u8_or(env!("CARGO_PKG_VERSION_MAJOR"), 0),
|
||||||
|
fw_minor: parse_u8_or(env!("CARGO_PKG_VERSION_MINOR"), 0),
|
||||||
|
fw_patch: parse_u8_or(env!("CARGO_PKG_VERSION_PATCH"), 0),
|
||||||
|
radio_chip_id: chip_id,
|
||||||
|
capability_bitmap: cap::LORA | cap::CAD_BEFORE_TX,
|
||||||
|
supported_sf_bitmap: supported_sf,
|
||||||
|
supported_bw_bitmap: supported_bw,
|
||||||
|
max_payload_bytes: crate::protocol::MAX_OTA_PAYLOAD as u16,
|
||||||
|
// Real channel depths from channel.rs.
|
||||||
|
rx_queue_capacity: 32,
|
||||||
|
tx_queue_capacity: 1,
|
||||||
|
freq_min_hz: fmin,
|
||||||
|
freq_max_hz: fmax,
|
||||||
|
tx_power_min_dbm: tx_min,
|
||||||
|
tx_power_max_dbm: tx_max,
|
||||||
|
mcu_uid_len: 6,
|
||||||
|
mcu_uid,
|
||||||
|
radio_uid_len: 0,
|
||||||
|
radio_uid: [0u8; MAX_RADIO_UID_LEN],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(test))]
|
||||||
|
const fn parse_u8_or(s: &str, default: u8) -> u8 {
|
||||||
|
// CARGO_PKG_VERSION_* strings are short decimal ascii. Parse them at
|
||||||
|
// compile time via a tiny constant function instead of pulling in a
|
||||||
|
// dependency.
|
||||||
|
let bytes = s.as_bytes();
|
||||||
|
let mut i = 0;
|
||||||
|
let mut out: u32 = 0;
|
||||||
|
while i < bytes.len() {
|
||||||
|
let b = bytes[i];
|
||||||
|
if b < b'0' || b > b'9' {
|
||||||
|
return default;
|
||||||
|
}
|
||||||
|
out = out * 10 + (b - b'0') as u32;
|
||||||
|
if out > u8::MAX as u32 {
|
||||||
|
return default;
|
||||||
|
}
|
||||||
|
i += 1;
|
||||||
|
}
|
||||||
|
out as u8
|
||||||
|
}
|
||||||
+15
-660
@@ -1,661 +1,16 @@
|
|||||||
//! Wire protocol types and fixed-size little-endian serialization.
|
//! Wire protocol types for DongLoRa Protocol v2.
|
||||||
//!
|
//!
|
||||||
//! Every integer is fixed-width LE. No varints, no zigzag.
|
//! Wire definitions live in the `donglora-protocol` crate so host-side
|
||||||
//! See `docs/PROTOCOL.md` for the complete specification.
|
//! Rust projects can share them with the firmware without duplicating
|
||||||
|
//! wire layouts. This module re-exports them unchanged so firmware
|
||||||
use heapless::Vec;
|
//! modules can `use crate::protocol::*;`.
|
||||||
|
|
||||||
/// Maximum LoRa payload size in bytes.
|
#[allow(unused_imports)]
|
||||||
pub const MAX_PAYLOAD: usize = 256;
|
pub use donglora_protocol::{
|
||||||
|
cap, commands, encode_frame, events, Command, CommandParseError, DeviceMessage,
|
||||||
/// RadioConfig wire size (fixed).
|
DeviceMessageParseError, ErrorCode, FlrcConfig, FrameDecoder, FrameEncodeError, FrameResult,
|
||||||
pub const RADIO_CONFIG_SIZE: usize = 13;
|
FskConfig, Info, InfoParseError, LoRaBandwidth, LoRaCodingRate, LoRaConfig, LoRaHeaderMode,
|
||||||
|
LrFhssConfig, Modulation, ModulationId, OkPayload, Owner, RadioChipId, RxOrigin, RxPayload,
|
||||||
/// Sentinel value for `tx_power_dbm`: use the board's maximum TX power.
|
SetConfigResult, SetConfigResultCode, TxDonePayload, TxFlags, TxResult, MAX_MCU_UID_LEN,
|
||||||
pub const TX_POWER_MAX: i8 = i8::MIN; // -128 on the wire
|
MAX_OK_PAYLOAD, MAX_OTA_PAYLOAD, MAX_PAYLOAD_FIELD, MAX_RADIO_UID_LEN, MAX_WIRE_FRAME,
|
||||||
|
};
|
||||||
/// Sentinel value for `preamble_len`: use the firmware default (16 symbols).
|
|
||||||
pub const PREAMBLE_DEFAULT: u16 = 0;
|
|
||||||
|
|
||||||
|
|
||||||
/// LoRa signal bandwidth.
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
||||||
#[cfg_attr(not(test), derive(defmt::Format))]
|
|
||||||
#[repr(u8)]
|
|
||||||
pub enum Bandwidth {
|
|
||||||
Khz7 = 0,
|
|
||||||
Khz10 = 1,
|
|
||||||
Khz15 = 2,
|
|
||||||
Khz20 = 3,
|
|
||||||
Khz31 = 4,
|
|
||||||
Khz41 = 5,
|
|
||||||
Khz62 = 6,
|
|
||||||
Khz125 = 7,
|
|
||||||
Khz250 = 8,
|
|
||||||
Khz500 = 9,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Bandwidth {
|
|
||||||
fn from_u8(v: u8) -> Option<Self> {
|
|
||||||
match v {
|
|
||||||
0 => Some(Self::Khz7),
|
|
||||||
1 => Some(Self::Khz10),
|
|
||||||
2 => Some(Self::Khz15),
|
|
||||||
3 => Some(Self::Khz20),
|
|
||||||
4 => Some(Self::Khz31),
|
|
||||||
5 => Some(Self::Khz41),
|
|
||||||
6 => Some(Self::Khz62),
|
|
||||||
7 => Some(Self::Khz125),
|
|
||||||
8 => Some(Self::Khz250),
|
|
||||||
9 => Some(Self::Khz500),
|
|
||||||
_ => None,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Complete LoRa radio configuration.
|
|
||||||
///
|
|
||||||
/// Wire layout (13 bytes, all little-endian):
|
|
||||||
/// ```text
|
|
||||||
/// [freq_hz:4] [bw:1] [sf:1] [cr:1] [sync_word:2] [tx_power_dbm:1] [preamble_len:2] [cad:1]
|
|
||||||
/// ```
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
||||||
#[cfg_attr(not(test), derive(defmt::Format))]
|
|
||||||
pub struct RadioConfig {
|
|
||||||
/// Frequency in Hz (150_000_000 - 960_000_000 for SX1262).
|
|
||||||
pub freq_hz: u32,
|
|
||||||
pub bw: Bandwidth,
|
|
||||||
/// Spreading factor (5-12).
|
|
||||||
pub sf: u8,
|
|
||||||
/// Coding rate denominator (5-8). E.g. 5 = CR 4/5, 8 = CR 4/8.
|
|
||||||
pub cr: u8,
|
|
||||||
pub sync_word: u16,
|
|
||||||
/// Transmit power in dBm. Set to [`TX_POWER_MAX`] (-128) for the
|
|
||||||
/// board's maximum.
|
|
||||||
pub tx_power_dbm: i8,
|
|
||||||
/// Preamble length in symbols. Set to [`PREAMBLE_DEFAULT`] (0) for
|
|
||||||
/// the firmware default (16 symbols). Valid range: 6–65535.
|
|
||||||
pub preamble_len: u16,
|
|
||||||
/// Channel Activity Detection (listen-before-talk) before TX.
|
|
||||||
/// 0 = disabled, non-zero = enabled. Default: 1 (enabled).
|
|
||||||
pub cad: u8,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl RadioConfig {
|
|
||||||
/// Validate fields against hardware limits.
|
|
||||||
pub fn validate(&self, power_range: (i8, i8)) -> Result<(), &'static str> {
|
|
||||||
if !(150_000_000..=960_000_000).contains(&self.freq_hz) {
|
|
||||||
return Err("frequency out of range (150-960 MHz)");
|
|
||||||
}
|
|
||||||
if !(5..=12).contains(&self.sf) {
|
|
||||||
return Err("spreading factor out of range (5-12)");
|
|
||||||
}
|
|
||||||
if !(5..=8).contains(&self.cr) {
|
|
||||||
return Err("coding rate out of range (5-8)");
|
|
||||||
}
|
|
||||||
if self.tx_power_dbm != TX_POWER_MAX
|
|
||||||
&& !(power_range.0..=power_range.1).contains(&self.tx_power_dbm)
|
|
||||||
{
|
|
||||||
return Err("TX power out of range for this board");
|
|
||||||
}
|
|
||||||
if self.preamble_len != PREAMBLE_DEFAULT && self.preamble_len < 6 {
|
|
||||||
return Err("preamble length too short (min 6)");
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Resolve sentinel values to concrete defaults.
|
|
||||||
pub fn resolve(mut self, power_range: (i8, i8)) -> Self {
|
|
||||||
if self.tx_power_dbm == TX_POWER_MAX {
|
|
||||||
self.tx_power_dbm = power_range.1;
|
|
||||||
}
|
|
||||||
if self.preamble_len == PREAMBLE_DEFAULT {
|
|
||||||
self.preamble_len = 16;
|
|
||||||
}
|
|
||||||
self
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Whether CAD (listen-before-talk) is enabled.
|
|
||||||
pub fn cad_enabled(&self) -> bool {
|
|
||||||
self.cad != 0
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Serialize to fixed-size LE bytes. Returns number of bytes written (always 13).
|
|
||||||
pub fn write_to(self, buf: &mut [u8]) -> usize {
|
|
||||||
buf[0..4].copy_from_slice(&self.freq_hz.to_le_bytes());
|
|
||||||
buf[4] = self.bw as u8;
|
|
||||||
buf[5] = self.sf;
|
|
||||||
buf[6] = self.cr;
|
|
||||||
buf[7..9].copy_from_slice(&self.sync_word.to_le_bytes());
|
|
||||||
buf[9] = self.tx_power_dbm as u8;
|
|
||||||
buf[10..12].copy_from_slice(&self.preamble_len.to_le_bytes());
|
|
||||||
buf[12] = self.cad;
|
|
||||||
RADIO_CONFIG_SIZE
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Deserialize from fixed-size LE bytes.
|
|
||||||
pub fn from_bytes(buf: &[u8]) -> Option<Self> {
|
|
||||||
if buf.len() < RADIO_CONFIG_SIZE {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
Some(Self {
|
|
||||||
freq_hz: u32::from_le_bytes(buf[0..4].try_into().ok()?),
|
|
||||||
bw: Bandwidth::from_u8(buf[4])?,
|
|
||||||
sf: buf[5],
|
|
||||||
cr: buf[6],
|
|
||||||
sync_word: u16::from_le_bytes(buf[7..9].try_into().ok()?),
|
|
||||||
tx_power_dbm: buf[9] as i8,
|
|
||||||
preamble_len: u16::from_le_bytes(buf[10..12].try_into().ok()?),
|
|
||||||
cad: buf[12],
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Host → firmware commands.
|
|
||||||
#[allow(clippy::large_enum_variant)]
|
|
||||||
#[derive(Debug, PartialEq)]
|
|
||||||
pub enum Command {
|
|
||||||
Ping,
|
|
||||||
GetConfig,
|
|
||||||
SetConfig(RadioConfig),
|
|
||||||
StartRx,
|
|
||||||
StopRx,
|
|
||||||
Transmit {
|
|
||||||
config: Option<RadioConfig>,
|
|
||||||
payload: Vec<u8, MAX_PAYLOAD>,
|
|
||||||
},
|
|
||||||
DisplayOn,
|
|
||||||
DisplayOff,
|
|
||||||
GetMac,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Command {
|
|
||||||
/// Deserialize from a COBS-decoded frame.
|
|
||||||
pub fn from_bytes(buf: &[u8]) -> Option<Self> {
|
|
||||||
let tag = *buf.first()?;
|
|
||||||
let rest = &buf[1..];
|
|
||||||
match tag {
|
|
||||||
0 => Some(Self::Ping),
|
|
||||||
1 => Some(Self::GetConfig),
|
|
||||||
2 => Some(Self::SetConfig(RadioConfig::from_bytes(rest)?)),
|
|
||||||
3 => Some(Self::StartRx),
|
|
||||||
4 => Some(Self::StopRx),
|
|
||||||
5 => {
|
|
||||||
// Transmit: has_config:u8 + [RadioConfig if 1] + len:u16 LE + payload
|
|
||||||
if rest.is_empty() {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
let (config, pos) = if rest[0] == 0 {
|
|
||||||
(None, 1)
|
|
||||||
} else if rest[0] == 1 && rest.len() > RADIO_CONFIG_SIZE {
|
|
||||||
(Some(RadioConfig::from_bytes(&rest[1..])?), 1 + RADIO_CONFIG_SIZE)
|
|
||||||
} else {
|
|
||||||
return None;
|
|
||||||
};
|
|
||||||
if rest.len() < pos + 2 {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
let len = u16::from_le_bytes(rest[pos..pos + 2].try_into().ok()?) as usize;
|
|
||||||
let data_start = pos + 2;
|
|
||||||
if rest.len() < data_start + len {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
let mut payload = Vec::new();
|
|
||||||
let _ = payload.extend_from_slice(&rest[data_start..data_start + len]);
|
|
||||||
Some(Self::Transmit { config, payload })
|
|
||||||
}
|
|
||||||
6 => Some(Self::DisplayOn),
|
|
||||||
7 => Some(Self::DisplayOff),
|
|
||||||
8 => Some(Self::GetMac),
|
|
||||||
_ => None,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Firmware → host responses.
|
|
||||||
#[allow(clippy::large_enum_variant)]
|
|
||||||
#[derive(Debug, PartialEq)]
|
|
||||||
pub enum Response {
|
|
||||||
Pong,
|
|
||||||
Config(RadioConfig),
|
|
||||||
RxPacket {
|
|
||||||
rssi: i16,
|
|
||||||
snr: i16,
|
|
||||||
payload: Vec<u8, MAX_PAYLOAD>,
|
|
||||||
},
|
|
||||||
TxDone,
|
|
||||||
Ok,
|
|
||||||
Error(ErrorCode),
|
|
||||||
MacAddress([u8; 6]),
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Response {
|
|
||||||
/// Serialize to fixed-size LE bytes. Returns number of bytes written.
|
|
||||||
pub fn write_to(self, buf: &mut [u8]) -> usize {
|
|
||||||
match self {
|
|
||||||
Self::Pong => {
|
|
||||||
buf[0] = 0;
|
|
||||||
1
|
|
||||||
}
|
|
||||||
Self::Config(cfg) => {
|
|
||||||
buf[0] = 1;
|
|
||||||
1 + cfg.write_to(&mut buf[1..])
|
|
||||||
}
|
|
||||||
Self::RxPacket { rssi, snr, payload } => {
|
|
||||||
buf[0] = 2;
|
|
||||||
buf[1..3].copy_from_slice(&rssi.to_le_bytes());
|
|
||||||
buf[3..5].copy_from_slice(&snr.to_le_bytes());
|
|
||||||
buf[5..7].copy_from_slice(&(payload.len() as u16).to_le_bytes());
|
|
||||||
buf[7..7 + payload.len()].copy_from_slice(&payload);
|
|
||||||
7 + payload.len()
|
|
||||||
}
|
|
||||||
Self::TxDone => {
|
|
||||||
buf[0] = 3;
|
|
||||||
1
|
|
||||||
}
|
|
||||||
Self::Ok => {
|
|
||||||
buf[0] = 4;
|
|
||||||
1
|
|
||||||
}
|
|
||||||
Self::Error(code) => {
|
|
||||||
buf[0] = 5;
|
|
||||||
buf[1] = code as u8;
|
|
||||||
2
|
|
||||||
}
|
|
||||||
Self::MacAddress(mac) => {
|
|
||||||
buf[0] = 6;
|
|
||||||
buf[1..7].copy_from_slice(&mac);
|
|
||||||
7
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Error codes reported to the host.
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
||||||
#[cfg_attr(not(test), derive(defmt::Format))]
|
|
||||||
#[repr(u8)]
|
|
||||||
#[allow(dead_code)] // CrcError reserved per PROTOCOL.md (error code 3)
|
|
||||||
pub enum ErrorCode {
|
|
||||||
InvalidConfig = 0,
|
|
||||||
RadioBusy = 1,
|
|
||||||
TxTimeout = 2,
|
|
||||||
CrcError = 3,
|
|
||||||
NotConfigured = 4,
|
|
||||||
NoDisplay = 5,
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Tests ───────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
fn make_config() -> RadioConfig {
|
|
||||||
RadioConfig {
|
|
||||||
freq_hz: 915_000_000,
|
|
||||||
bw: Bandwidth::Khz125,
|
|
||||||
sf: 7,
|
|
||||||
cr: 5,
|
|
||||||
sync_word: 0x3444,
|
|
||||||
tx_power_dbm: 22,
|
|
||||||
preamble_len: 16,
|
|
||||||
cad: 1,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── RadioConfig roundtrip ───────────────────────────────────────
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn radio_config_roundtrip() {
|
|
||||||
let cfg = make_config();
|
|
||||||
let mut buf = [0u8; RADIO_CONFIG_SIZE];
|
|
||||||
let n = cfg.write_to(&mut buf);
|
|
||||||
assert_eq!(n, RADIO_CONFIG_SIZE);
|
|
||||||
assert_eq!(RadioConfig::from_bytes(&buf), Some(cfg));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn radio_config_roundtrip_all_bandwidths() {
|
|
||||||
for bw_val in 0u8..=9 {
|
|
||||||
let bw = Bandwidth::from_u8(bw_val).unwrap();
|
|
||||||
let cfg = RadioConfig {
|
|
||||||
freq_hz: 433_000_000,
|
|
||||||
bw,
|
|
||||||
sf: 12,
|
|
||||||
cr: 8,
|
|
||||||
sync_word: 0x1234,
|
|
||||||
tx_power_dbm: -9,
|
|
||||||
};
|
|
||||||
let mut buf = [0u8; RADIO_CONFIG_SIZE];
|
|
||||||
cfg.write_to(&mut buf);
|
|
||||||
assert_eq!(RadioConfig::from_bytes(&buf), Some(cfg));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn radio_config_roundtrip_negative_power() {
|
|
||||||
let cfg = RadioConfig {
|
|
||||||
tx_power_dbm: TX_POWER_MAX,
|
|
||||||
..make_config()
|
|
||||||
};
|
|
||||||
let mut buf = [0u8; RADIO_CONFIG_SIZE];
|
|
||||||
cfg.write_to(&mut buf);
|
|
||||||
assert_eq!(RadioConfig::from_bytes(&buf), Some(cfg));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn radio_config_from_short_buffer() {
|
|
||||||
let buf = [0u8; RADIO_CONFIG_SIZE - 1];
|
|
||||||
assert!(RadioConfig::from_bytes(&buf).is_none());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn radio_config_from_empty_buffer() {
|
|
||||||
assert!(RadioConfig::from_bytes(&[]).is_none());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn radio_config_invalid_bandwidth() {
|
|
||||||
let mut buf = [0u8; RADIO_CONFIG_SIZE];
|
|
||||||
make_config().write_to(&mut buf);
|
|
||||||
buf[4] = 255; // invalid bandwidth
|
|
||||||
assert!(RadioConfig::from_bytes(&buf).is_none());
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── RadioConfig::validate ───────────────────────────────────────
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn validate_freq_boundaries() {
|
|
||||||
let power_range = (-9, 22);
|
|
||||||
let base = make_config();
|
|
||||||
|
|
||||||
let mut cfg = RadioConfig { freq_hz: 150_000_000, ..base };
|
|
||||||
assert!(cfg.validate(power_range).is_ok());
|
|
||||||
|
|
||||||
cfg.freq_hz = 960_000_000;
|
|
||||||
assert!(cfg.validate(power_range).is_ok());
|
|
||||||
|
|
||||||
cfg.freq_hz = 149_999_999;
|
|
||||||
assert!(cfg.validate(power_range).is_err());
|
|
||||||
|
|
||||||
cfg.freq_hz = 960_000_001;
|
|
||||||
assert!(cfg.validate(power_range).is_err());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn validate_sf_boundaries() {
|
|
||||||
let power_range = (-9, 22);
|
|
||||||
let base = make_config();
|
|
||||||
|
|
||||||
for sf in 5..=12 {
|
|
||||||
assert!(RadioConfig { sf, ..base }.validate(power_range).is_ok());
|
|
||||||
}
|
|
||||||
assert!(RadioConfig { sf: 4, ..base }.validate(power_range).is_err());
|
|
||||||
assert!(RadioConfig { sf: 13, ..base }.validate(power_range).is_err());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn validate_cr_boundaries() {
|
|
||||||
let power_range = (-9, 22);
|
|
||||||
let base = make_config();
|
|
||||||
|
|
||||||
for cr in 5..=8 {
|
|
||||||
assert!(RadioConfig { cr, ..base }.validate(power_range).is_ok());
|
|
||||||
}
|
|
||||||
assert!(RadioConfig { cr: 4, ..base }.validate(power_range).is_err());
|
|
||||||
assert!(RadioConfig { cr: 9, ..base }.validate(power_range).is_err());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn validate_tx_power_max_sentinel() {
|
|
||||||
let cfg = RadioConfig { tx_power_dbm: TX_POWER_MAX, ..make_config() };
|
|
||||||
assert!(cfg.validate((-9, 22)).is_ok());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn validate_tx_power_out_of_range() {
|
|
||||||
let cfg = RadioConfig { tx_power_dbm: 23, ..make_config() };
|
|
||||||
assert!(cfg.validate((-9, 22)).is_err());
|
|
||||||
|
|
||||||
let cfg = RadioConfig { tx_power_dbm: -10, ..make_config() };
|
|
||||||
assert!(cfg.validate((-9, 22)).is_err());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn validate_preamble_default_sentinel() {
|
|
||||||
let cfg = RadioConfig { preamble_len: PREAMBLE_DEFAULT, ..make_config() };
|
|
||||||
assert!(cfg.validate((-9, 22)).is_ok());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn validate_preamble_boundaries() {
|
|
||||||
let power_range = (-9, 22);
|
|
||||||
let base = make_config();
|
|
||||||
|
|
||||||
assert!(RadioConfig { preamble_len: 6, ..base }.validate(power_range).is_ok());
|
|
||||||
assert!(RadioConfig { preamble_len: 5, ..base }.validate(power_range).is_err());
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── RadioConfig::resolve ──────────────────────────────────────
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn resolve_power_max_sentinel() {
|
|
||||||
let cfg = RadioConfig { tx_power_dbm: TX_POWER_MAX, ..make_config() };
|
|
||||||
assert_eq!(cfg.resolve((-9, 22)).tx_power_dbm, 22);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn resolve_power_explicit_unchanged() {
|
|
||||||
let cfg = RadioConfig { tx_power_dbm: 10, ..make_config() };
|
|
||||||
assert_eq!(cfg.resolve((-9, 22)).tx_power_dbm, 10);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn resolve_preamble_default() {
|
|
||||||
let cfg = RadioConfig { preamble_len: PREAMBLE_DEFAULT, ..make_config() };
|
|
||||||
assert_eq!(cfg.resolve((-9, 22)).preamble_len, 16);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn resolve_preamble_explicit_unchanged() {
|
|
||||||
let cfg = RadioConfig { preamble_len: 32, ..make_config() };
|
|
||||||
assert_eq!(cfg.resolve((-9, 22)).preamble_len, 32);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Command::from_bytes ─────────────────────────────────────────
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn command_ping() {
|
|
||||||
assert_eq!(Command::from_bytes(&[0]), Some(Command::Ping));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn command_get_config() {
|
|
||||||
assert_eq!(Command::from_bytes(&[1]), Some(Command::GetConfig));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn command_set_config() {
|
|
||||||
let cfg = make_config();
|
|
||||||
let mut buf = [0u8; 1 + RADIO_CONFIG_SIZE];
|
|
||||||
buf[0] = 2;
|
|
||||||
cfg.write_to(&mut buf[1..]);
|
|
||||||
assert_eq!(Command::from_bytes(&buf), Some(Command::SetConfig(cfg)));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn command_start_stop_rx() {
|
|
||||||
assert_eq!(Command::from_bytes(&[3]), Some(Command::StartRx));
|
|
||||||
assert_eq!(Command::from_bytes(&[4]), Some(Command::StopRx));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn command_transmit_no_config() {
|
|
||||||
let payload = b"hello";
|
|
||||||
let mut buf = [0u8; 64];
|
|
||||||
buf[0] = 5; // tag
|
|
||||||
buf[1] = 0; // has_config = false
|
|
||||||
buf[2..4].copy_from_slice(&(payload.len() as u16).to_le_bytes());
|
|
||||||
buf[4..9].copy_from_slice(payload);
|
|
||||||
|
|
||||||
match Command::from_bytes(&buf[..9]).unwrap() {
|
|
||||||
Command::Transmit { config, payload: p } => {
|
|
||||||
assert!(config.is_none());
|
|
||||||
assert_eq!(p.as_slice(), b"hello");
|
|
||||||
}
|
|
||||||
_ => panic!("expected Transmit"),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn command_transmit_with_config() {
|
|
||||||
let cfg = make_config();
|
|
||||||
let mut buf = [0u8; 64];
|
|
||||||
buf[0] = 5; // tag
|
|
||||||
buf[1] = 1; // has_config = true
|
|
||||||
cfg.write_to(&mut buf[2..]);
|
|
||||||
let payload = b"test";
|
|
||||||
let pos = 2 + RADIO_CONFIG_SIZE;
|
|
||||||
buf[pos..pos + 2].copy_from_slice(&(payload.len() as u16).to_le_bytes());
|
|
||||||
buf[pos + 2..pos + 6].copy_from_slice(payload);
|
|
||||||
|
|
||||||
match Command::from_bytes(&buf[..pos + 6]).unwrap() {
|
|
||||||
Command::Transmit { config, payload: p } => {
|
|
||||||
assert_eq!(config, Some(cfg));
|
|
||||||
assert_eq!(p.as_slice(), b"test");
|
|
||||||
}
|
|
||||||
_ => panic!("expected Transmit"),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn command_transmit_empty_payload() {
|
|
||||||
let mut buf = [0u8; 4];
|
|
||||||
buf[0] = 5; // tag
|
|
||||||
buf[1] = 0; // has_config = false
|
|
||||||
buf[2..4].copy_from_slice(&0u16.to_le_bytes());
|
|
||||||
|
|
||||||
match Command::from_bytes(&buf).unwrap() {
|
|
||||||
Command::Transmit { config, payload } => {
|
|
||||||
assert!(config.is_none());
|
|
||||||
assert!(payload.is_empty());
|
|
||||||
}
|
|
||||||
_ => panic!("expected Transmit"),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn command_transmit_truncated() {
|
|
||||||
// Tag only — missing has_config byte
|
|
||||||
assert!(Command::from_bytes(&[5]).is_none());
|
|
||||||
|
|
||||||
// has_config=1 but no config bytes
|
|
||||||
assert!(Command::from_bytes(&[5, 1]).is_none());
|
|
||||||
|
|
||||||
// has_config=0 but no length bytes
|
|
||||||
assert!(Command::from_bytes(&[5, 0]).is_none());
|
|
||||||
|
|
||||||
// has_config=0, length says 5 but only 2 bytes of payload
|
|
||||||
let mut buf = [0u8; 6];
|
|
||||||
buf[0] = 5;
|
|
||||||
buf[1] = 0;
|
|
||||||
buf[2..4].copy_from_slice(&5u16.to_le_bytes());
|
|
||||||
buf[4] = 0xAA;
|
|
||||||
buf[5] = 0xBB;
|
|
||||||
assert!(Command::from_bytes(&buf).is_none());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn command_display_and_mac() {
|
|
||||||
assert_eq!(Command::from_bytes(&[6]), Some(Command::DisplayOn));
|
|
||||||
assert_eq!(Command::from_bytes(&[7]), Some(Command::DisplayOff));
|
|
||||||
assert_eq!(Command::from_bytes(&[8]), Some(Command::GetMac));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn command_invalid_tag() {
|
|
||||||
assert!(Command::from_bytes(&[9]).is_none());
|
|
||||||
assert!(Command::from_bytes(&[255]).is_none());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn command_empty_buffer() {
|
|
||||||
assert!(Command::from_bytes(&[]).is_none());
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Response::write_to ──────────────────────────────────────────
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn response_pong() {
|
|
||||||
let mut buf = [0u8; 1];
|
|
||||||
assert_eq!(Response::Pong.write_to(&mut buf), 1);
|
|
||||||
assert_eq!(buf[0], 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn response_config() {
|
|
||||||
let cfg = make_config();
|
|
||||||
let mut buf = [0u8; 1 + RADIO_CONFIG_SIZE];
|
|
||||||
let n = Response::Config(cfg).write_to(&mut buf);
|
|
||||||
assert_eq!(n, 1 + RADIO_CONFIG_SIZE);
|
|
||||||
assert_eq!(buf[0], 1);
|
|
||||||
assert_eq!(RadioConfig::from_bytes(&buf[1..]), Some(cfg));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn response_rx_packet() {
|
|
||||||
let mut payload = Vec::new();
|
|
||||||
let _ = payload.extend_from_slice(b"data");
|
|
||||||
let mut buf = [0u8; 64];
|
|
||||||
let n = Response::RxPacket { rssi: -80, snr: 10, payload }.write_to(&mut buf);
|
|
||||||
assert_eq!(n, 7 + 4); // tag(1) + rssi(2) + snr(2) + len(2) + "data"(4)
|
|
||||||
assert_eq!(buf[0], 2);
|
|
||||||
assert_eq!(i16::from_le_bytes([buf[1], buf[2]]), -80);
|
|
||||||
assert_eq!(i16::from_le_bytes([buf[3], buf[4]]), 10);
|
|
||||||
assert_eq!(u16::from_le_bytes([buf[5], buf[6]]), 4);
|
|
||||||
assert_eq!(&buf[7..11], b"data");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn response_tx_done() {
|
|
||||||
let mut buf = [0u8; 1];
|
|
||||||
assert_eq!(Response::TxDone.write_to(&mut buf), 1);
|
|
||||||
assert_eq!(buf[0], 3);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn response_ok() {
|
|
||||||
let mut buf = [0u8; 1];
|
|
||||||
assert_eq!(Response::Ok.write_to(&mut buf), 1);
|
|
||||||
assert_eq!(buf[0], 4);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn response_error_codes() {
|
|
||||||
let mut buf = [0u8; 2];
|
|
||||||
for (code, val) in [
|
|
||||||
(ErrorCode::InvalidConfig, 0),
|
|
||||||
(ErrorCode::RadioBusy, 1),
|
|
||||||
(ErrorCode::TxTimeout, 2),
|
|
||||||
(ErrorCode::CrcError, 3),
|
|
||||||
(ErrorCode::NotConfigured, 4),
|
|
||||||
(ErrorCode::NoDisplay, 5),
|
|
||||||
] {
|
|
||||||
let n = Response::Error(code).write_to(&mut buf);
|
|
||||||
assert_eq!(n, 2);
|
|
||||||
assert_eq!(buf[0], 5);
|
|
||||||
assert_eq!(buf[1], val);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn response_mac_address() {
|
|
||||||
let mac = [0xAA, 0xBB, 0xCC, 0xDD, 0xEE, 0xFF];
|
|
||||||
let mut buf = [0u8; 7];
|
|
||||||
let n = Response::MacAddress(mac).write_to(&mut buf);
|
|
||||||
assert_eq!(n, 7);
|
|
||||||
assert_eq!(buf[0], 6);
|
|
||||||
assert_eq!(&buf[1..7], &mac);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+2236
File diff suppressed because it is too large.
Load diff
@@ -1,4 +0,0 @@
|
|||||||
//! LoRa radio control via SX1262.
|
|
||||||
|
|
||||||
mod task;
|
|
||||||
pub use task::radio_task;
|
|
||||||
@@ -1,376 +0,0 @@
|
|||||||
//! LoRa radio task: SX1262 state machine driven by host commands.
|
|
||||||
//!
|
|
||||||
//! Owns the radio peripheral exclusively. Receives [`Command`]s from the USB
|
|
||||||
//! task, drives the SX1262 via [`lora_phy`], and sends [`Response`]s back.
|
|
||||||
//! Publishes [`RadioStatus`] to the display task via a watch channel.
|
|
||||||
//!
|
|
||||||
//! # State machine
|
|
||||||
//!
|
|
||||||
//! ```text
|
|
||||||
//! Idle ──StartRx──► Receiving ──StopRx──► Idle
|
|
||||||
//! │ │
|
|
||||||
//! └──Transmit──► Transmitting ──TxDone──► (previous state)
|
|
||||||
//! ```
|
|
||||||
//!
|
|
||||||
//! # Invariants
|
|
||||||
//!
|
|
||||||
//! - This task never panics. All errors are reported to the host or logged.
|
|
||||||
//! - Config is validated before use (see [`RadioConfig::validate`]).
|
|
||||||
|
|
||||||
use defmt::{error, info, warn};
|
|
||||||
use embassy_executor::task;
|
|
||||||
use embassy_futures::select::{select, Either};
|
|
||||||
use embassy_time::Delay;
|
|
||||||
use lora_phy::mod_params::RadioError;
|
|
||||||
use lora_phy::{LoRa, RxMode};
|
|
||||||
|
|
||||||
use crate::board::{Board, LoRaBoard, RadioDriver, RadioParts};
|
|
||||||
use crate::channel::{CommandChannel, RadioState, RadioStatus, ResponseChannel, StatusWatch};
|
|
||||||
use crate::protocol::{self, Command, ErrorCode, RadioConfig, Response};
|
|
||||||
|
|
||||||
const MAX_PAYLOAD: usize = protocol::MAX_PAYLOAD;
|
|
||||||
// TX power range comes from the board trait: Board::TX_POWER_RANGE
|
|
||||||
|
|
||||||
type Radio = LoRa<RadioDriver, Delay>;
|
|
||||||
|
|
||||||
// ── Fixed LoRa radio parameters ─────────────────────────────────────
|
|
||||||
//
|
|
||||||
// These are intentionally not host-configurable. They are sensible
|
|
||||||
// defaults for a general-purpose LoRa pipe. Making them configurable
|
|
||||||
// would add protocol complexity for marginal benefit.
|
|
||||||
|
|
||||||
/// Explicit header mode (variable-length packets).
|
|
||||||
const IMPLICIT_HEADER: bool = false;
|
|
||||||
|
|
||||||
/// Enable CRC on received/transmitted packets.
|
|
||||||
const CRC_ON: bool = true;
|
|
||||||
|
|
||||||
/// Standard IQ polarity (not inverted).
|
|
||||||
const IQ_INVERTED: bool = false;
|
|
||||||
|
|
||||||
// ── Task entry point ────────────────────────────────────────────────
|
|
||||||
|
|
||||||
#[task]
|
|
||||||
pub async fn radio_task(
|
|
||||||
parts: RadioParts,
|
|
||||||
commands: &'static CommandChannel,
|
|
||||||
responses: &'static ResponseChannel,
|
|
||||||
status: &'static StatusWatch,
|
|
||||||
) {
|
|
||||||
let mut state = RadioStatus::default();
|
|
||||||
status.sender().send(state.clone());
|
|
||||||
|
|
||||||
let mut lora = match LoRa::new(parts.driver, false, parts.delay).await {
|
|
||||||
Ok(l) => l,
|
|
||||||
Err(e) => {
|
|
||||||
error!("radio init failed: {}", e);
|
|
||||||
responses
|
|
||||||
.send(Response::Error(ErrorCode::InvalidConfig))
|
|
||||||
.await;
|
|
||||||
// Radio is non-functional — respond to commands but can't do RF.
|
|
||||||
loop {
|
|
||||||
let cmd = commands.receive().await;
|
|
||||||
if let Command::Ping = cmd {
|
|
||||||
responses.send(Response::Pong).await;
|
|
||||||
} else {
|
|
||||||
responses
|
|
||||||
.send(Response::Error(ErrorCode::InvalidConfig))
|
|
||||||
.await;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
info!("radio initialized");
|
|
||||||
let mut rx_buf = [0u8; MAX_PAYLOAD];
|
|
||||||
|
|
||||||
loop {
|
|
||||||
if state.state == RadioState::Receiving {
|
|
||||||
// Defensive: config must be Some when Receiving. If not, recover.
|
|
||||||
let cfg = match state.config {
|
|
||||||
Some(c) => c,
|
|
||||||
None => {
|
|
||||||
warn!("BUG: receiving without config, returning to idle");
|
|
||||||
state.state = RadioState::Idle;
|
|
||||||
status.sender().send(state.clone());
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
match select(rx_once(&mut lora, &cfg, &mut rx_buf), commands.receive()).await {
|
|
||||||
Either::First(rx_result) => match rx_result {
|
|
||||||
Ok((len, pkt_status)) => {
|
|
||||||
state.rx_count = state.rx_count.wrapping_add(1);
|
|
||||||
state.last_rssi = Some(pkt_status.rssi);
|
|
||||||
state.last_snr = Some(pkt_status.snr);
|
|
||||||
status.sender().send(state.clone());
|
|
||||||
|
|
||||||
let copy_len = (len as usize).min(MAX_PAYLOAD);
|
|
||||||
if (len as usize) > copy_len {
|
|
||||||
warn!("RX payload truncated: {} > {}", len, MAX_PAYLOAD);
|
|
||||||
}
|
|
||||||
let mut payload = heapless::Vec::new();
|
|
||||||
// copy_len <= MAX_PAYLOAD == Vec capacity, so this cannot fail.
|
|
||||||
let _ = payload.extend_from_slice(&rx_buf[..copy_len]);
|
|
||||||
|
|
||||||
responses
|
|
||||||
.send(Response::RxPacket {
|
|
||||||
rssi: pkt_status.rssi,
|
|
||||||
snr: pkt_status.snr,
|
|
||||||
payload,
|
|
||||||
})
|
|
||||||
.await;
|
|
||||||
|
|
||||||
if let Err(e) = start_rx(&mut lora, &cfg).await {
|
|
||||||
warn!("restart RX failed: {}", e);
|
|
||||||
state.state = RadioState::Idle;
|
|
||||||
status.sender().send(state.clone());
|
|
||||||
responses
|
|
||||||
.send(Response::Error(ErrorCode::RadioBusy))
|
|
||||||
.await;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
warn!("RX error: {}", e);
|
|
||||||
if start_rx(&mut lora, &cfg).await.is_err() {
|
|
||||||
state.state = RadioState::Idle;
|
|
||||||
status.sender().send(state.clone());
|
|
||||||
responses
|
|
||||||
.send(Response::Error(ErrorCode::RadioBusy))
|
|
||||||
.await;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
Either::Second(cmd) => {
|
|
||||||
handle_cmd(cmd, &mut lora, &mut state, responses, status).await;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
let cmd = commands.receive().await;
|
|
||||||
handle_cmd(cmd, &mut lora, &mut state, responses, status).await;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Command handler ─────────────────────────────────────────────────
|
|
||||||
|
|
||||||
async fn handle_cmd(
|
|
||||||
cmd: Command,
|
|
||||||
lora: &mut Radio,
|
|
||||||
state: &mut RadioStatus,
|
|
||||||
responses: &ResponseChannel,
|
|
||||||
status: &StatusWatch,
|
|
||||||
) {
|
|
||||||
match cmd {
|
|
||||||
Command::Ping => {
|
|
||||||
responses.send(Response::Pong).await;
|
|
||||||
}
|
|
||||||
Command::GetConfig => {
|
|
||||||
if let Some(cfg) = state.config {
|
|
||||||
responses.send(Response::Config(cfg)).await;
|
|
||||||
} else {
|
|
||||||
responses
|
|
||||||
.send(Response::Error(ErrorCode::NotConfigured))
|
|
||||||
.await;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Command::SetConfig(cfg) => {
|
|
||||||
if let Err(reason) = cfg.validate(Board::TX_POWER_RANGE) {
|
|
||||||
warn!("SetConfig rejected: {}", reason);
|
|
||||||
responses
|
|
||||||
.send(Response::Error(ErrorCode::InvalidConfig))
|
|
||||||
.await;
|
|
||||||
} else {
|
|
||||||
// Resolve TX_POWER_MAX sentinel to board's actual max
|
|
||||||
state.config = Some(cfg.resolve(Board::TX_POWER_RANGE));
|
|
||||||
status.sender().send(state.clone());
|
|
||||||
responses.send(Response::Ok).await;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Command::StartRx => {
|
|
||||||
if let Some(cfg) = state.config {
|
|
||||||
match start_rx(lora, &cfg).await {
|
|
||||||
Ok(()) => {
|
|
||||||
state.state = RadioState::Receiving;
|
|
||||||
status.sender().send(state.clone());
|
|
||||||
responses.send(Response::Ok).await;
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
warn!("StartRx failed: {}", e);
|
|
||||||
responses
|
|
||||||
.send(Response::Error(ErrorCode::InvalidConfig))
|
|
||||||
.await;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
responses
|
|
||||||
.send(Response::Error(ErrorCode::NotConfigured))
|
|
||||||
.await;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Command::StopRx => {
|
|
||||||
// Best-effort: if standby fails, radio is in unknown state
|
|
||||||
// but there's nothing useful we can do except continue.
|
|
||||||
let _ = lora.enter_standby().await;
|
|
||||||
state.state = RadioState::Idle;
|
|
||||||
status.sender().send(state.clone());
|
|
||||||
responses.send(Response::Ok).await;
|
|
||||||
}
|
|
||||||
Command::DisplayOn | Command::DisplayOff | Command::GetMac => {}
|
|
||||||
Command::Transmit { config, payload } => {
|
|
||||||
let tx_config = config.map(|c| c.resolve(Board::TX_POWER_RANGE)).or(state.config);
|
|
||||||
if let Some(cfg) = tx_config {
|
|
||||||
if let Err(reason) = cfg.validate(Board::TX_POWER_RANGE) {
|
|
||||||
warn!("TX config rejected: {}", reason);
|
|
||||||
responses
|
|
||||||
.send(Response::Error(ErrorCode::InvalidConfig))
|
|
||||||
.await;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let was_receiving = state.state == RadioState::Receiving;
|
|
||||||
state.state = RadioState::Transmitting;
|
|
||||||
status.sender().send(state.clone());
|
|
||||||
|
|
||||||
match do_tx(lora, &cfg, &payload).await {
|
|
||||||
Ok(()) => {
|
|
||||||
state.tx_count = state.tx_count.wrapping_add(1);
|
|
||||||
responses.send(Response::TxDone).await;
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
warn!("TX failed: {}", e);
|
|
||||||
responses
|
|
||||||
.send(Response::Error(ErrorCode::TxTimeout))
|
|
||||||
.await;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Restore previous state. If we were receiving, restart RX
|
|
||||||
// using the stored config — NOT the per-packet TX override.
|
|
||||||
state.state = if was_receiving {
|
|
||||||
// state.config is always Some when was_receiving (invariant).
|
|
||||||
let rx_cfg = state.config.unwrap_or(cfg);
|
|
||||||
match start_rx(lora, &rx_cfg).await {
|
|
||||||
Ok(()) => RadioState::Receiving,
|
|
||||||
Err(e) => {
|
|
||||||
warn!("post-TX RX restart failed: {}", e);
|
|
||||||
RadioState::Idle
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
RadioState::Idle
|
|
||||||
};
|
|
||||||
status.sender().send(state.clone());
|
|
||||||
} else {
|
|
||||||
responses
|
|
||||||
.send(Response::Error(ErrorCode::NotConfigured))
|
|
||||||
.await;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── LoRa helpers ────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
fn to_bw(bw: protocol::Bandwidth) -> lora_phy::mod_params::Bandwidth {
|
|
||||||
use lora_phy::mod_params::Bandwidth::*;
|
|
||||||
match bw {
|
|
||||||
protocol::Bandwidth::Khz7 => _7KHz,
|
|
||||||
protocol::Bandwidth::Khz10 => _10KHz,
|
|
||||||
protocol::Bandwidth::Khz15 => _15KHz,
|
|
||||||
protocol::Bandwidth::Khz20 => _20KHz,
|
|
||||||
protocol::Bandwidth::Khz31 => _31KHz,
|
|
||||||
protocol::Bandwidth::Khz41 => _41KHz,
|
|
||||||
protocol::Bandwidth::Khz62 => _62KHz,
|
|
||||||
protocol::Bandwidth::Khz125 => _125KHz,
|
|
||||||
protocol::Bandwidth::Khz250 => _250KHz,
|
|
||||||
protocol::Bandwidth::Khz500 => _500KHz,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn to_sf(sf: u8) -> lora_phy::mod_params::SpreadingFactor {
|
|
||||||
use lora_phy::mod_params::SpreadingFactor::*;
|
|
||||||
match sf {
|
|
||||||
5 => _5,
|
|
||||||
6 => _6,
|
|
||||||
7 => _7,
|
|
||||||
8 => _8,
|
|
||||||
9 => _9,
|
|
||||||
10 => _10,
|
|
||||||
11 => _11,
|
|
||||||
12 => _12,
|
|
||||||
_ => {
|
|
||||||
warn!("BUG: invalid SF {} passed validation", sf);
|
|
||||||
_7
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn to_cr(cr: u8) -> lora_phy::mod_params::CodingRate {
|
|
||||||
use lora_phy::mod_params::CodingRate::*;
|
|
||||||
match cr {
|
|
||||||
5 => _4_5,
|
|
||||||
6 => _4_6,
|
|
||||||
7 => _4_7,
|
|
||||||
8 => _4_8,
|
|
||||||
_ => {
|
|
||||||
warn!("BUG: invalid CR {} passed validation", cr);
|
|
||||||
_4_5
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Create modulation parameters from a validated config.
|
|
||||||
fn modulation_params(
|
|
||||||
lora: &mut Radio,
|
|
||||||
cfg: &RadioConfig,
|
|
||||||
) -> Result<lora_phy::mod_params::ModulationParams, RadioError> {
|
|
||||||
lora.create_modulation_params(to_sf(cfg.sf), to_bw(cfg.bw), to_cr(cfg.cr), cfg.freq_hz)
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn start_rx(lora: &mut Radio, cfg: &RadioConfig) -> Result<(), RadioError> {
|
|
||||||
let mdltn = modulation_params(lora, cfg)?;
|
|
||||||
let pkt = lora.create_rx_packet_params(
|
|
||||||
cfg.preamble_len, IMPLICIT_HEADER, MAX_PAYLOAD as u8, CRC_ON, IQ_INVERTED, &mdltn,
|
|
||||||
)?;
|
|
||||||
lora.prepare_for_rx(RxMode::Continuous, &mdltn, &pkt).await
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn rx_once(
|
|
||||||
lora: &mut Radio,
|
|
||||||
cfg: &RadioConfig,
|
|
||||||
buf: &mut [u8],
|
|
||||||
) -> Result<(u8, lora_phy::mod_params::PacketStatus), RadioError> {
|
|
||||||
let mdltn = modulation_params(lora, cfg)?;
|
|
||||||
let pkt = lora.create_rx_packet_params(
|
|
||||||
cfg.preamble_len, IMPLICIT_HEADER, MAX_PAYLOAD as u8, CRC_ON, IQ_INVERTED, &mdltn,
|
|
||||||
)?;
|
|
||||||
lora.rx(&pkt, buf).await
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Maximum CAD attempts before transmitting anyway.
|
|
||||||
const CAD_MAX_RETRIES: u8 = 10;
|
|
||||||
|
|
||||||
async fn do_tx(lora: &mut Radio, cfg: &RadioConfig, payload: &[u8]) -> Result<(), RadioError> {
|
|
||||||
let mdltn = modulation_params(lora, cfg)?;
|
|
||||||
|
|
||||||
// Listen-before-talk: check channel is clear before transmitting.
|
|
||||||
if cfg.cad_enabled() {
|
|
||||||
lora.prepare_for_cad(&mdltn).await?;
|
|
||||||
for _ in 0..CAD_MAX_RETRIES {
|
|
||||||
if !lora.cad(&mdltn).await? {
|
|
||||||
break; // Channel is clear
|
|
||||||
}
|
|
||||||
// Channel busy — wait briefly and retry
|
|
||||||
embassy_time::Timer::after_millis(10).await;
|
|
||||||
lora.prepare_for_cad(&mdltn).await?;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut tx_pkt = lora.create_tx_packet_params(
|
|
||||||
cfg.preamble_len, IMPLICIT_HEADER, CRC_ON, IQ_INVERTED, &mdltn,
|
|
||||||
)?;
|
|
||||||
lora.prepare_for_tx(&mdltn, &mut tx_pkt, cfg.tx_power_dbm as i32, payload)
|
|
||||||
.await?;
|
|
||||||
lora.tx().await
|
|
||||||
}
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
//! USB CDC-ACM interface with COBS-framed command/response protocol.
|
|
||||||
|
|
||||||
mod task;
|
|
||||||
pub use task::usb_task;
|
|
||||||
-234
@@ -1,234 +0,0 @@
|
|||||||
//! USB CDC-ACM task: COBS-framed fixed-size LE command/response protocol.
|
|
||||||
//!
|
|
||||||
//! Reads COBS frames from USB, decodes them, parses commands, routes them
|
|
||||||
//! to the radio or display task, and sends COBS-framed responses back.
|
|
||||||
|
|
||||||
use defmt::warn;
|
|
||||||
use embassy_executor::task;
|
|
||||||
use embassy_futures::join::join;
|
|
||||||
use embassy_usb::class::cdc_acm::{CdcAcmClass, Receiver, Sender, State};
|
|
||||||
use embassy_usb::Builder;
|
|
||||||
use static_cell::StaticCell;
|
|
||||||
|
|
||||||
use crate::channel::{
|
|
||||||
CommandChannel, DisplayCommand, DisplayCommandChannel, ResponseChannel,
|
|
||||||
};
|
|
||||||
use crate::protocol::{Command, ErrorCode, Response};
|
|
||||||
|
|
||||||
const MAX_FRAME: usize = 512;
|
|
||||||
const MAX_PACKET: usize = 64;
|
|
||||||
|
|
||||||
// Worst case: tag(1) + rssi(2) + snr(2) + len(2) + payload(256) = 263 bytes.
|
|
||||||
// COBS adds ceil(263/254)+1 = 3 bytes. 266 < 512. Plenty of room.
|
|
||||||
const _: () = assert!(
|
|
||||||
MAX_FRAME >= crate::protocol::MAX_PAYLOAD + 64,
|
|
||||||
"MAX_FRAME too small for max payload + COBS overhead"
|
|
||||||
);
|
|
||||||
|
|
||||||
#[task]
|
|
||||||
pub async fn usb_task(
|
|
||||||
parts: crate::board::UsbParts,
|
|
||||||
commands: &'static CommandChannel,
|
|
||||||
responses: &'static ResponseChannel,
|
|
||||||
display_commands: &'static DisplayCommandChannel,
|
|
||||||
has_display: bool,
|
|
||||||
mac: [u8; 6],
|
|
||||||
) {
|
|
||||||
// ── USB device configuration ────────────────────────────────────
|
|
||||||
// VID 0x1209: pid.codes open-source USB vendor ID
|
|
||||||
// PID 0x5741: DongLoRa product ID
|
|
||||||
// Class 0xEF/0x02/0x01: Miscellaneous / Interface Association Descriptor
|
|
||||||
let mut config = embassy_usb::Config::new(0x1209, 0x5741);
|
|
||||||
config.manufacturer = Some("DongLoRa");
|
|
||||||
config.product = Some("DongLoRa LoRa Radio");
|
|
||||||
config.device_class = 0xEF;
|
|
||||||
config.device_sub_class = 0x02;
|
|
||||||
config.device_protocol = 0x01;
|
|
||||||
config.composite_with_iads = true;
|
|
||||||
|
|
||||||
// Use MAC address as USB serial number for unique device identification.
|
|
||||||
static SERIAL_BUF: StaticCell<[u8; 12]> = StaticCell::new();
|
|
||||||
let serial_buf = SERIAL_BUF.init([0u8; 12]);
|
|
||||||
const HEX: &[u8; 16] = b"0123456789ABCDEF";
|
|
||||||
for (i, &byte) in mac.iter().enumerate() {
|
|
||||||
serial_buf[i * 2] = HEX[(byte >> 4) as usize];
|
|
||||||
serial_buf[i * 2 + 1] = HEX[(byte & 0x0F) as usize];
|
|
||||||
}
|
|
||||||
config.serial_number = Some(core::str::from_utf8(serial_buf).expect("MAC hex is valid UTF-8"));
|
|
||||||
|
|
||||||
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 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 cdc_state = CDC_STATE.init(State::new());
|
|
||||||
|
|
||||||
let mut builder = Builder::new(
|
|
||||||
parts.driver,
|
|
||||||
config,
|
|
||||||
desc_buf,
|
|
||||||
conf_buf,
|
|
||||||
bos_buf,
|
|
||||||
ctrl_buf,
|
|
||||||
);
|
|
||||||
|
|
||||||
let class = CdcAcmClass::new(&mut builder, cdc_state, MAX_PACKET as u16);
|
|
||||||
let mut usb_dev = builder.build();
|
|
||||||
let (sender, receiver) = class.split();
|
|
||||||
|
|
||||||
// ── Run USB device + protocol loop concurrently ────────────────
|
|
||||||
join(
|
|
||||||
usb_dev.run(),
|
|
||||||
protocol_loop(sender, receiver, commands, responses, display_commands, has_display, mac),
|
|
||||||
)
|
|
||||||
.await;
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn protocol_loop<'d, D: embassy_usb_driver::Driver<'d>>(
|
|
||||||
mut sender: Sender<'d, D>,
|
|
||||||
mut receiver: Receiver<'d, D>,
|
|
||||||
commands: &'static CommandChannel,
|
|
||||||
responses: &'static ResponseChannel,
|
|
||||||
display_commands: &'static DisplayCommandChannel,
|
|
||||||
has_display: bool,
|
|
||||||
mac: [u8; 6],
|
|
||||||
) {
|
|
||||||
use embassy_futures::select::select3;
|
|
||||||
use embassy_futures::select::Either3;
|
|
||||||
|
|
||||||
let mut read_buf = [0u8; MAX_PACKET];
|
|
||||||
let mut write_buf = [0u8; MAX_FRAME];
|
|
||||||
let mut cobs_encode_buf = [0u8; MAX_FRAME];
|
|
||||||
|
|
||||||
// COBS accumulator: collect bytes until 0x00 sentinel
|
|
||||||
let mut frame_buf = [0u8; MAX_FRAME];
|
|
||||||
let mut frame_len: usize = 0;
|
|
||||||
|
|
||||||
let mut was_connected = false;
|
|
||||||
|
|
||||||
loop {
|
|
||||||
match select3(
|
|
||||||
receiver.read_packet(&mut read_buf),
|
|
||||||
responses.receive(),
|
|
||||||
embassy_time::Timer::after_millis(250), // DTR poll interval for disconnect detection
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
{
|
|
||||||
Either3::First(result) => {
|
|
||||||
let n = match result {
|
|
||||||
Ok(0) => continue,
|
|
||||||
Ok(n) => n,
|
|
||||||
Err(_) => {
|
|
||||||
frame_len = 0; // reset accumulator
|
|
||||||
embassy_time::Timer::after_millis(100).await;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Feed bytes into the COBS accumulator
|
|
||||||
for &byte in &read_buf[..n] {
|
|
||||||
if byte == 0x00 {
|
|
||||||
// End of COBS frame — decode and parse
|
|
||||||
if frame_len > 0 {
|
|
||||||
let mut decode_buf = [0u8; MAX_FRAME];
|
|
||||||
if let Some(decoded_len) =
|
|
||||||
ucobs::decode(&frame_buf[..frame_len], &mut decode_buf)
|
|
||||||
{
|
|
||||||
if let Some(cmd) = Command::from_bytes(&decode_buf[..decoded_len]) {
|
|
||||||
route_command(
|
|
||||||
cmd, commands, responses, display_commands, has_display, mac,
|
|
||||||
)
|
|
||||||
.await;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
frame_len = 0;
|
|
||||||
} else if frame_len < MAX_FRAME {
|
|
||||||
frame_buf[frame_len] = byte;
|
|
||||||
frame_len += 1;
|
|
||||||
} else {
|
|
||||||
// Frame too large — discard
|
|
||||||
frame_len = 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Either3::Second(response) => {
|
|
||||||
// Serialize response to fixed-size LE bytes, then COBS encode
|
|
||||||
let raw_len = response.write_to(&mut write_buf);
|
|
||||||
let encoded_len = ucobs::encode(&write_buf[..raw_len], &mut cobs_encode_buf)
|
|
||||||
.unwrap_or(0);
|
|
||||||
// Append 0x00 sentinel
|
|
||||||
if encoded_len < cobs_encode_buf.len() {
|
|
||||||
cobs_encode_buf[encoded_len] = 0x00;
|
|
||||||
let frame = &cobs_encode_buf[..encoded_len + 1];
|
|
||||||
for chunk in frame.chunks(MAX_PACKET) {
|
|
||||||
if sender.write_packet(chunk).await.is_err() {
|
|
||||||
warn!("USB write failed, response dropped");
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
warn!("COBS encode buffer overflow");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Either3::Third(()) => {
|
|
||||||
// Poll DTR every 250ms to detect host disconnect
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check DTR after every select wake
|
|
||||||
let connected = receiver.dtr();
|
|
||||||
if was_connected && !connected && has_display {
|
|
||||||
display_commands.send(DisplayCommand::Reset).await;
|
|
||||||
}
|
|
||||||
if !was_connected && connected && has_display {
|
|
||||||
display_commands.send(DisplayCommand::On).await;
|
|
||||||
}
|
|
||||||
was_connected = connected;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Route a parsed command to the appropriate handler.
|
|
||||||
///
|
|
||||||
/// Display and MAC commands are handled locally (response sent immediately).
|
|
||||||
/// All other commands are forwarded to the radio task (response sent later).
|
|
||||||
/// Both paths feed the same `ResponseChannel`, so the one-outstanding-command
|
|
||||||
/// rule in PROTOCOL.md is required to keep solicited responses ordered.
|
|
||||||
async fn route_command(
|
|
||||||
cmd: Command,
|
|
||||||
commands: &CommandChannel,
|
|
||||||
responses: &ResponseChannel,
|
|
||||||
display_commands: &DisplayCommandChannel,
|
|
||||||
has_display: bool,
|
|
||||||
mac: [u8; 6],
|
|
||||||
) {
|
|
||||||
match cmd {
|
|
||||||
Command::DisplayOn => {
|
|
||||||
if has_display {
|
|
||||||
display_commands.send(DisplayCommand::On).await;
|
|
||||||
responses.send(Response::Ok).await;
|
|
||||||
} else {
|
|
||||||
responses.send(Response::Error(ErrorCode::NoDisplay)).await;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Command::DisplayOff => {
|
|
||||||
if has_display {
|
|
||||||
display_commands.send(DisplayCommand::Off).await;
|
|
||||||
responses.send(Response::Ok).await;
|
|
||||||
} else {
|
|
||||||
responses.send(Response::Error(ErrorCode::NoDisplay)).await;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Command::GetMac => {
|
|
||||||
responses.send(Response::MacAddress(mac)).await;
|
|
||||||
}
|
|
||||||
other => {
|
|
||||||
commands.send(other).await;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Reference in new issue
Block a user