10 Commits
Author SHA1 Message Date
swaits a0b15ddcaa chore: release v0.5.0 2026-04-16 13:30:52 -06:00
swaits 388d07e90f refactor: complete hsmc conversion and add debug-checkpoint feature
Finishes the hsmc migration that was staged across the prior two
commits:

- Radio task is now a single `machine.run().await`; all I/O lives in
  state-scoped `during:` activities (`next_command`, `next_packet`,
  `perform_tx`). The old `push/drain` helpers, the imperative
  top-level `select`, `handle_cmd`, `handle_rx_result`, and
  `execute_transmit` are gone. `StartingRx` / `ResumingRx` intermediate
  states model the host-visible difference between a responding and a
  silent RX re-arm.

- Host/USB task is now an hsmc statechart (Disconnected/Connected)
  with entry/exit actions handling display wake, radio StopRx, and
  display reset — replacing the open-coded `was_connected` edge
  detector. The `protocol_loop` keeps its imperative read path because
  `read_packet` blocks.

- New `debug-checkpoint` cargo feature ships a dedicated
  `debug_blink.rs` task and a shared atomic counter that code along
  the boot/dispatch/TX path bumps at notable points. A ladder-latching
  write policy preserves the deepest TX phase reached on the first
  attempt until reboot — diagnostic output for UART-only boards where
  defmt would corrupt the host protocol. Zero footprint when the
  feature is off.

- Silence a stray `clippy::unit_arg` on `led_off` / `led_blip` in the
  display task for boards with `LedDriver = ()`.
2026-04-16 13:28:44 -06:00
swaits c6ef9559ec refactor: drive radio task via hsmc statechart
Replaces the imperative Mode enum + if-branches in the radio loop
with a declarative hsmc statechart mirroring the display:

  Radio
  ├── Unconfigured
  └── Configured
      ├── Idle
      ├── Receiving
      └── Transmitting
          ├── FromIdle
          └── FromRx

The `TransmittingFrom{Idle,Rx}` substates encode "where to return after
TX" as state identity, killing the `was_receiving` bookkeeping. Entry
actions announce `RadioEvent`s on the display channel; RF I/O stays in
the task loop because `rx_once` blocks indefinitely and cannot live
inside an action. Radio-init failure runs a Ping-only degraded loop
without ever instantiating the machine.
2026-04-16 13:26:23 -06:00
swaits 596bc3ed96 refactor: drive display via hsmc statechart
Replaces the ad-hoc display loop with a declarative hsmc statechart
(Off/On.{Splash.{LearnMore,Info}, Dashboard.{Listening,Transmitting}})
and swaps the Radio→Display channel from a RadioStatus watch to a
typed RadioEvent queue. The radio task now emits discrete mode and
packet events instead of publishing full status snapshots; the
display consumes them through the statechart, which also handles the
5 s splash alternation and 1 s sparkline tick via `on(after …)` /
`on(every …)` transitions.

Also:
- Add hsmc dep and embassy/tokio feature shims (hsmc proc macro
  evaluates `cfg(feature = "embassy")` in this crate).
- Fix USB disconnect path in `host/usb.rs` so the DTR branch always
  runs on read error (was early-continuing, starving display/radio
  reset).
2026-04-16 13:26:04 -06:00
swaits 272abceedb refactor: consume donglora-protocol as a path dep
Pulls the wire protocol types and COBS framing out of firmware and into
the sibling ../protocol crate so client-rs, mux-rs, and bridge can share
them. Firmware's src/protocol.rs is now a thin re-export shim preserving
every crate::protocol::\* call site; src/host/framing.rs drops the
in-tree CobsDecoder / cobs_encode_response and re-exports the codec,
keeping only route_command (which needs embassy channels).

All seven board features still build clean, cargo fmt clean, cargo test
green.
2026-04-16 13:25:20 -06:00
swaits f9582c0b94 style: cargo fmt 2026-04-16 13:25:19 -06:00
swaits 649c29218c docs: add BLE soft-mesh multiplexer design doc 2026-04-16 13:24:37 -06:00
swaits fd4e180879 chore: release v0.4.0 2026-04-11 22:35:33 -06:00
swaits 8b549d49a9 feat: add ELECROW ThinkNode-M2 board support
ESP32-S3 + SX1262 + 1.3" SH1106 OLED + CP2102 UART bridge +
1000 mAh Li-Po. Pin map verified against Meshtastic's
variants/esp32s3/ELECROW-ThinkNode-M2. DIO3 TCXO at 3.3 V (vs 1.8 V
on Heltec), VEXT_ENABLE active HIGH (opposite polarity), dedicated
LORA_POWER_EN on GPIO48 sequenced before SPI init. Reuses the
existing async SH1106 driver at I2C address 0x3C.

Refactors hal::esp32s3::{init_spi,init_i2c} and board::esp32s3::init_radio
to accept generic GPIO bounds so ESP32-S3 boards with different pin
assignments can share the helpers. init_radio now takes a
TcxoCtrlVoltage parameter instead of hardcoding Ctrl1V8.

Incidental cleanups needed to make `just check-all` green:

- Replace the core::mem::forget(output) pattern with let _foo = ...;
  current esp-hal makes Output non-Drop, so forget is a no-op that
  trips clippy's forget_non_drop lint.

- Switch justfile xtensa builds from `cargo +nightly` with a RUSTC
  override to `cargo +esp` so the compiler and rust-src versions stay
  aligned. Public nightly's rust-src had drifted two months ahead of
  the esp fork and broke -Zbuild-std on core.
2026-04-11 22:29:04 -06:00
swaits 7184610eea feat: migrate to embassy-executor 0.10
Bump embassy-executor 0.9 -> 0.10 and embassy-rp 0.9 -> 0.10 from crates.io.
Patch esp-hal, esp-rtos, esp-backtrace, esp-println from esp-rs/esp-hal main
branch (esp-rtos 0.2.0 on crates.io pins embassy-executor ^0.9).

API migrations:
- embassy-executor: arch-cortex-m -> platform-cortex-m feature rename
- embassy-executor: SpawnToken now returned as Result from task functions
- esp-hal main: efuse::Efuse::mac_address() -> efuse::base_mac_address()
- esp-hal main: esp_rtos::start() now requires SoftwareInterrupt<0>
- embassy-rp 0.10: SPI constructor requires DMA interrupt bindings

Also fixes UART display stuck on splash (sent Reset instead of On at boot)
and switches just check to clippy -D warnings.
2026-04-11 02:34:04 -06:00
33 changed files with 4944 additions and 1534 deletions

No files matched your search

File diff suppressed because it is too large. Load diff
+95
View File
@@ -5,6 +5,101 @@ 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]
## [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
Generated
+286 -86
View File
@@ -145,12 +145,33 @@ version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
[[package]]
name = "cc"
version = "1.2.60"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "43c5703da9466b66a946814e1adf53ea2c90f10063b86290cc9eb67ce3478a20"
dependencies = [
"find-msvc-tools",
"shlex",
]
[[package]]
name = "cfg-if"
version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
[[package]]
name = "chrono"
version = "0.4.44"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0"
dependencies = [
"defmt 1.0.1",
"num-traits",
"pure-rust-locales",
]
[[package]]
name = "codespan-reporting"
version = "0.11.1"
@@ -161,6 +182,16 @@ dependencies = [
"unicode-width",
]
[[package]]
name = "cordyceps"
version = "0.3.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "688d7fbb8092b8de775ef2536f36c8c31f2bc4006ece2e8d8ad2d17d00ce0a2a"
dependencies = [
"loom",
"tracing",
]
[[package]]
name = "cortex-m"
version = "0.7.7"
@@ -455,7 +486,7 @@ dependencies = [
[[package]]
name = "donglora"
version = "0.2.1"
version = "0.5.0"
dependencies = [
"cfg-if",
"cortex-m",
@@ -464,7 +495,8 @@ dependencies = [
"defmt 1.0.1",
"defmt-rtt",
"display-interface",
"embassy-embedded-hal 0.6.0",
"donglora-protocol",
"embassy-embedded-hal",
"embassy-executor",
"embassy-futures",
"embassy-nrf",
@@ -482,6 +514,7 @@ dependencies = [
"esp-println",
"esp-rtos",
"heapless 0.9.2",
"hsmc",
"lora-phy",
"minijinja",
"owo-colors",
@@ -492,29 +525,21 @@ dependencies = [
"ucobs",
]
[[package]]
name = "donglora-protocol"
version = "0.1.0"
dependencies = [
"defmt 1.0.1",
"heapless 0.9.2",
"ucobs",
]
[[package]]
name = "either"
version = "1.15.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719"
[[package]]
name = "embassy-embedded-hal"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "554e3e840696f54b4c9afcf28a0f24da431c927f4151040020416e7393d6d0d8"
dependencies = [
"embassy-futures",
"embassy-hal-internal 0.3.0",
"embassy-sync 0.7.2",
"embedded-hal 0.2.7",
"embedded-hal 1.0.0",
"embedded-hal-async",
"embedded-storage",
"embedded-storage-async",
"nb 1.1.0",
]
[[package]]
name = "embassy-embedded-hal"
version = "0.6.0"
@@ -536,10 +561,11 @@ dependencies = [
[[package]]
name = "embassy-executor"
version = "0.9.1"
version = "0.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "06070468370195e0e86f241c8e5004356d696590a678d47d6676795b2e439c6b"
checksum = "5d0d3b15c9d7dc4fec1d8cb77112472fb008b3b28c51ad23838d83587a6d2f1e"
dependencies = [
"cordyceps",
"cortex-m",
"critical-section",
"document-features",
@@ -549,9 +575,9 @@ dependencies = [
[[package]]
name = "embassy-executor-macros"
version = "0.7.0"
version = "0.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dfdddc3a04226828316bf31393b6903ee162238576b1584ee2669af215d55472"
checksum = "d11a246f53de5f97a387f40ac24726817cd0b6f833e7603baac784f29d6ff276"
dependencies = [
"darling 0.20.11",
"proc-macro2",
@@ -571,18 +597,6 @@ version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dc2d050bdc5c21e0862a89256ed8029ae6c290a93aecefc73084b3002cdebb01"
[[package]]
name = "embassy-hal-internal"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "95285007a91b619dc9f26ea8f55452aa6c60f7115a4edc05085cd2bd3127cd7a"
dependencies = [
"cortex-m",
"critical-section",
"defmt 1.0.1",
"num-traits",
]
[[package]]
name = "embassy-hal-internal"
version = "0.4.0"
@@ -634,7 +648,7 @@ dependencies = [
"critical-section",
"defmt 1.0.1",
"document-features",
"embassy-embedded-hal 0.6.0",
"embassy-embedded-hal",
"embassy-futures",
"embassy-hal-internal 0.5.0",
"embassy-sync 0.8.0",
@@ -656,20 +670,21 @@ dependencies = [
[[package]]
name = "embassy-rp"
version = "0.9.0"
version = "0.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "af8d5ac11a8bc209d359ad98bb10a10f786471dd474790f3a4f991c77ae94f6f"
checksum = "5d98f472894c1ecffac5596c657af5305c57282d29e8746d7fec033951931bc8"
dependencies = [
"cfg-if",
"chrono",
"cortex-m",
"cortex-m-rt",
"critical-section",
"defmt 1.0.1",
"document-features",
"embassy-embedded-hal 0.5.0",
"embassy-embedded-hal",
"embassy-futures",
"embassy-hal-internal 0.3.0",
"embassy-sync 0.7.2",
"embassy-hal-internal 0.5.0",
"embassy-sync 0.8.0",
"embassy-time",
"embassy-time-driver",
"embassy-time-queue-utils",
@@ -678,8 +693,8 @@ dependencies = [
"embedded-hal 1.0.0",
"embedded-hal-async",
"embedded-hal-nb",
"embedded-io 0.6.1",
"embedded-io-async 0.6.1",
"embedded-io 0.7.1",
"embedded-io-async 0.7.0",
"embedded-storage",
"embedded-storage-async",
"fixed",
@@ -968,8 +983,7 @@ checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
[[package]]
name = "esp-backtrace"
version = "0.18.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3318413fb566c7227387f67736cf70cd74d80a11f2bb31c7b95a9eb48d079669"
source = "git+https://github.com/esp-rs/esp-hal.git?branch=main#70930f20d9f681a30ff20aced39d0bd5363126d5"
dependencies = [
"cfg-if",
"defmt 1.0.1",
@@ -986,8 +1000,7 @@ dependencies = [
[[package]]
name = "esp-config"
version = "0.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "102871054f8dd98202177b9890cb4b71d0c6fe1f1413b7a379a8e0841fc2473c"
source = "git+https://github.com/esp-rs/esp-hal.git?branch=main#70930f20d9f681a30ff20aced39d0bd5363126d5"
dependencies = [
"document-features",
"esp-metadata-generated",
@@ -999,8 +1012,7 @@ dependencies = [
[[package]]
name = "esp-hal"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "54786287c0a61ca0f78cb0c338a39427551d1be229103b4444591796c579e093"
source = "git+https://github.com/esp-rs/esp-hal.git?branch=main#70930f20d9f681a30ff20aced39d0bd5363126d5"
dependencies = [
"bitfield 0.19.4",
"bitflags 2.11.0",
@@ -1010,9 +1022,9 @@ dependencies = [
"delegate",
"digest",
"document-features",
"embassy-embedded-hal 0.5.0",
"embassy-embedded-hal",
"embassy-futures",
"embassy-sync 0.7.2",
"embassy-sync 0.8.0",
"embassy-usb-driver",
"embassy-usb-synopsys-otg",
"embedded-can",
@@ -1042,6 +1054,7 @@ dependencies = [
"nb 1.1.0",
"paste",
"portable-atomic",
"rand_core 0.10.0",
"rand_core 0.6.4",
"rand_core 0.9.5",
"riscv",
@@ -1054,8 +1067,7 @@ dependencies = [
[[package]]
name = "esp-hal-procmacros"
version = "0.21.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3e025a7a7a0affdb4ff913b5c4494aef96ee03d085bf83c27453ae3a71d50da6"
source = "git+https://github.com/esp-rs/esp-hal.git?branch=main#70930f20d9f681a30ff20aced39d0bd5363126d5"
dependencies = [
"document-features",
"object",
@@ -1069,14 +1081,12 @@ dependencies = [
[[package]]
name = "esp-metadata-generated"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9a93e39c8ad8d390d248dc7b9f4b59a873f313bf535218b8e2351356972399e3"
source = "git+https://github.com/esp-rs/esp-hal.git?branch=main#70930f20d9f681a30ff20aced39d0bd5363126d5"
[[package]]
name = "esp-println"
version = "0.16.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5a30e6c9fbcc01c348d46706fef8131c7775ab84c254a3cd65d0cd3f6414d592"
source = "git+https://github.com/esp-rs/esp-hal.git?branch=main#70930f20d9f681a30ff20aced39d0bd5363126d5"
dependencies = [
"defmt 1.0.1",
"document-features",
@@ -1089,8 +1099,7 @@ dependencies = [
[[package]]
name = "esp-riscv-rt"
version = "0.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "502744a5b1e7268d27fd2a4e56ad45efe42ead517d6c517a6961540de949b0ee"
source = "git+https://github.com/esp-rs/esp-hal.git?branch=main#70930f20d9f681a30ff20aced39d0bd5363126d5"
dependencies = [
"document-features",
"riscv",
@@ -1100,44 +1109,46 @@ dependencies = [
[[package]]
name = "esp-rom-sys"
version = "0.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cd66cccc6dd2d13e9f33668a57717ab14a6d217180ec112e6be533de93e7ecbf"
source = "git+https://github.com/esp-rs/esp-hal.git?branch=main#70930f20d9f681a30ff20aced39d0bd5363126d5"
dependencies = [
"cfg-if",
"document-features",
"esp-metadata-generated",
"esp32s3",
]
[[package]]
name = "esp-rtos"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "162ec711c8d06e79c67b75d01595539e86b0aac209643af98ca87a12250428b3"
source = "git+https://github.com/esp-rs/esp-hal.git?branch=main#70930f20d9f681a30ff20aced39d0bd5363126d5"
dependencies = [
"cfg-if",
"document-features",
"embassy-executor",
"embassy-sync 0.7.2",
"embassy-sync 0.8.0",
"embassy-time-driver",
"embassy-time-queue-utils",
"esp-config",
"esp-hal",
"esp-hal-procmacros",
"esp-metadata-generated",
"esp-rom-sys",
"esp-sync",
"portable-atomic",
"riscv",
"xtensa-lx",
]
[[package]]
name = "esp-sync"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d44974639b4e88914f83fe60d2832c00276657d7d857628fdfc966cc7302e8a8"
source = "git+https://github.com/esp-rs/esp-hal.git?branch=main#70930f20d9f681a30ff20aced39d0bd5363126d5"
dependencies = [
"cfg-if",
"document-features",
"embassy-sync 0.6.2",
"embassy-sync 0.7.2",
"embassy-sync 0.8.0",
"esp-metadata-generated",
"riscv",
"xtensa-lx",
@@ -1159,8 +1170,7 @@ dependencies = [
[[package]]
name = "esp32"
version = "0.39.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b76170a463d18f888a1ad258031901036fd827a9ef126733053ba5f8739fb0c8"
source = "git+https://github.com/esp-rs/esp-pacs?rev=fc3e6d4#fc3e6d4b34cfc885f750641d27e6fe3e63bad5d2"
dependencies = [
"critical-section",
"vcell",
@@ -1169,8 +1179,7 @@ dependencies = [
[[package]]
name = "esp32c2"
version = "0.28.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e62cf8932966b8d445b6f1832977b468178f0a84effb2e9fda89f60c24d45aa3"
source = "git+https://github.com/esp-rs/esp-pacs?rev=fc3e6d4#fc3e6d4b34cfc885f750641d27e6fe3e63bad5d2"
dependencies = [
"critical-section",
"vcell",
@@ -1179,8 +1188,7 @@ dependencies = [
[[package]]
name = "esp32c3"
version = "0.31.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "356af3771d0d6536c735bf71136594f4d1cbb506abf6e0c51a6639e9bf4e7988"
source = "git+https://github.com/esp-rs/esp-pacs?rev=fc3e6d4#fc3e6d4b34cfc885f750641d27e6fe3e63bad5d2"
dependencies = [
"critical-section",
"vcell",
@@ -1189,8 +1197,7 @@ dependencies = [
[[package]]
name = "esp32c6"
version = "0.22.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f5e511df672d79cd63365c92045135e01ba952b6bddd25b660baff5e1110f6b"
source = "git+https://github.com/esp-rs/esp-pacs?rev=fc3e6d4#fc3e6d4b34cfc885f750641d27e6fe3e63bad5d2"
dependencies = [
"critical-section",
"vcell",
@@ -1199,8 +1206,7 @@ dependencies = [
[[package]]
name = "esp32h2"
version = "0.18.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed4a50bbd1380931e095e0973b9b12f782a9c481f2edf1f7c42e7eb4ff736d6d"
source = "git+https://github.com/esp-rs/esp-pacs?rev=fc3e6d4#fc3e6d4b34cfc885f750641d27e6fe3e63bad5d2"
dependencies = [
"critical-section",
"vcell",
@@ -1209,8 +1215,7 @@ dependencies = [
[[package]]
name = "esp32s2"
version = "0.30.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "98574d4c577fbe888fe3e6df7fc80d25a05624d9998f7d7de1500ae21fcca78f"
source = "git+https://github.com/esp-rs/esp-pacs?rev=fc3e6d4#fc3e6d4b34cfc885f750641d27e6fe3e63bad5d2"
dependencies = [
"critical-section",
"vcell",
@@ -1219,13 +1224,18 @@ dependencies = [
[[package]]
name = "esp32s3"
version = "0.34.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1810d8ee4845ef87542af981e38eb80ab531d0ef1061e1486014ab7af74c337a"
source = "git+https://github.com/esp-rs/esp-pacs?rev=fc3e6d4#fc3e6d4b34cfc885f750641d27e6fe3e63bad5d2"
dependencies = [
"critical-section",
"vcell",
]
[[package]]
name = "find-msvc-tools"
version = "0.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582"
[[package]]
name = "fixed"
version = "1.29.0"
@@ -1303,6 +1313,21 @@ version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1d758ba1b47b00caf47f24925c0074ecb20d6dfcffe7f6d53395c0465674841a"
[[package]]
name = "generator"
version = "0.8.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "52f04ae4152da20c76fe800fa48659201d5cf627c5149ca0b707b69d7eef6cf9"
dependencies = [
"cc",
"cfg-if",
"libc",
"log",
"rustversion",
"windows-link",
"windows-result",
]
[[package]]
name = "generic-array"
version = "0.14.7"
@@ -1374,6 +1399,26 @@ version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
[[package]]
name = "hsmc"
version = "0.1.0"
dependencies = [
"embassy-futures",
"embassy-sync 0.8.0",
"embassy-time",
"heapless 0.8.0",
"hsmc-macros",
]
[[package]]
name = "hsmc-macros"
version = "0.1.0"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.117",
]
[[package]]
name = "ident_case"
version = "1.0.1"
@@ -1468,6 +1513,12 @@ dependencies = [
"rustversion",
]
[[package]]
name = "lazy_static"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe"
[[package]]
name = "libc"
version = "0.2.184"
@@ -1495,6 +1546,19 @@ version = "0.4.29"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897"
[[package]]
name = "loom"
version = "0.7.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "419e0dc8046cb947daa77eb95ae174acfbddb7673b4151f56d1eed8e93fbfaca"
dependencies = [
"cfg-if",
"generator",
"scoped-tls",
"tracing",
"tracing-subscriber",
]
[[package]]
name = "lora-modulation"
version = "0.1.5"
@@ -1514,6 +1578,15 @@ dependencies = [
"num-traits",
]
[[package]]
name = "matchers"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9"
dependencies = [
"regex-automata",
]
[[package]]
name = "maybe-async-cfg"
version = "0.2.4"
@@ -1586,6 +1659,15 @@ dependencies = [
"cortex-m-rt",
]
[[package]]
name = "nu-ansi-term"
version = "0.50.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5"
dependencies = [
"windows-sys",
]
[[package]]
name = "num-traits"
version = "0.2.19"
@@ -1845,6 +1927,15 @@ dependencies = [
"unicase",
]
[[package]]
name = "pure-rust-locales"
version = "0.8.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "869675ad2d7541aea90c6d88c81f46a7f4ea9af8cd0395d38f11a95126998a0d"
dependencies = [
"defmt 1.0.1",
]
[[package]]
name = "quote"
version = "1.0.45"
@@ -1872,6 +1963,12 @@ version = "0.9.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c"
[[package]]
name = "rand_core"
version = "0.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0c8d0fd677905edcbeedbf2edb6494d676f0e98d54d5cf9bda0b061cb8fb8aba"
[[package]]
name = "redox_syscall"
version = "0.5.18"
@@ -2024,6 +2121,12 @@ dependencies = [
"winapi-util",
]
[[package]]
name = "scoped-tls"
version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e1cf6437eb19a8f4a6cc0f7dca544973b0b78843adbfeb3683d1a94a0024a294"
[[package]]
name = "scopeguard"
version = "1.2.0"
@@ -2110,6 +2213,21 @@ dependencies = [
"keccak",
]
[[package]]
name = "sharded-slab"
version = "0.1.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6"
dependencies = [
"lazy_static",
]
[[package]]
name = "shlex"
version = "1.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64"
[[package]]
name = "siphasher"
version = "1.0.2"
@@ -2294,6 +2412,15 @@ dependencies = [
"syn 2.0.117",
]
[[package]]
name = "thread_local"
version = "1.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185"
dependencies = [
"cfg-if",
]
[[package]]
name = "toml_datetime"
version = "1.1.1+spec-1.1.0"
@@ -2324,6 +2451,67 @@ dependencies = [
"winnow",
]
[[package]]
name = "tracing"
version = "0.1.44"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100"
dependencies = [
"pin-project-lite",
"tracing-attributes",
"tracing-core",
]
[[package]]
name = "tracing-attributes"
version = "0.1.31"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.117",
]
[[package]]
name = "tracing-core"
version = "0.1.36"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a"
dependencies = [
"once_cell",
"valuable",
]
[[package]]
name = "tracing-log"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3"
dependencies = [
"log",
"once_cell",
"tracing-core",
]
[[package]]
name = "tracing-subscriber"
version = "0.3.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319"
dependencies = [
"matchers",
"nu-ansi-term",
"once_cell",
"regex-automata",
"sharded-slab",
"smallvec",
"thread_local",
"tracing",
"tracing-core",
"tracing-log",
]
[[package]]
name = "typenum"
version = "1.19.0"
@@ -2417,6 +2605,12 @@ dependencies = [
"usbd-hid-descriptors",
]
[[package]]
name = "valuable"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65"
[[package]]
name = "vcell"
version = "0.1.3"
@@ -2469,6 +2663,15 @@ version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
[[package]]
name = "windows-result"
version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5"
dependencies = [
"windows-link",
]
[[package]]
name = "windows-sys"
version = "0.61.2"
@@ -2490,8 +2693,7 @@ dependencies = [
[[package]]
name = "xtensa-lx"
version = "0.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e012d667b0aa6d2592ace8ef145a98bff3e76cca7a644f4181ecd7a916ed289b"
source = "git+https://github.com/esp-rs/esp-hal.git?branch=main#70930f20d9f681a30ff20aced39d0bd5363126d5"
dependencies = [
"critical-section",
]
@@ -2499,8 +2701,7 @@ dependencies = [
[[package]]
name = "xtensa-lx-rt"
version = "0.21.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8709f037fb123fe7ff146d2bce86f9dc0dfc53045c016bfd9d703317b6502845"
source = "git+https://github.com/esp-rs/esp-hal.git?branch=main#70930f20d9f681a30ff20aced39d0bd5363126d5"
dependencies = [
"document-features",
"xtensa-lx",
@@ -2510,8 +2711,7 @@ dependencies = [
[[package]]
name = "xtensa-lx-rt-proc-macros"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "96fb42cd29c42f8744c74276e9f5bee7b06685bbe5b88df891516d72cb320450"
source = "git+https://github.com/esp-rs/esp-hal.git?branch=main#70930f20d9f681a30ff20aced39d0bd5363126d5"
dependencies = [
"proc-macro2",
"quote",
+47 -10
View File
@@ -3,7 +3,7 @@ members = ["."]
[package]
name = "donglora"
version = "0.3.0"
version = "0.5.0"
edition = "2021"
build = "build.rs"
authors = ["Stephen Waits <steve@waits.net>"]
@@ -13,10 +13,8 @@ repository = "https://github.com/swaits/donglora"
keywords = ["lora", "radio", "embedded", "usb", "firmware"]
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
# NOTE: esp-rtos patched from GitHub main (see [patch.crates-io] below).
# Remove patches once esp-rtos publishes with embassy-executor 0.10 support.
[dependencies]
cfg-if = "1.0"
critical-section = "1.2"
@@ -24,13 +22,15 @@ defmt = "1.0"
embedded-hal = "1.0"
embedded-hal-async = "1.0"
embassy-embedded-hal = "0.6"
embassy-executor = "0.9"
embassy-executor = "0.10"
embassy-sync = "0.8"
embassy-time = "0.5"
embassy-usb = { version = "0.6", features = ["defmt"] }
embassy-usb-driver = "0.2"
ucobs = { version = "0.3", features = ["legacy-msrv"] }
heapless = "0.9"
donglora-protocol = { path = "../protocol" }
hsmc = { path = "../../hsmc/hsmc", default-features = false, features = ["embassy"] }
lora-phy = "3.0"
portable-atomic = { version = "1", optional = true, default-features = false, features = ["critical-section"] }
static_cell = "2.1"
@@ -78,7 +78,7 @@ embassy-nrf = { version = "0.10", optional = true, features = [
panic-probe = { version = "1.0", optional = true, features = ["print-defmt"] }
# RP2040 (Waveshare RP2040-LoRa)
embassy-rp = { version = "0.9", optional = true, features = [
embassy-rp = { version = "0.10", optional = true, features = [
"rp2040",
"time-driver",
"defmt",
@@ -89,55 +89,92 @@ minijinja = "2.18"
owo-colors = "4.3"
[features]
# The `embassy` feature gates the async-actions codepath inside the `hsmc`
# statechart macro. Its `#[cfg(feature = "embassy")]` attributes are
# evaluated in *this* crate's context (a proc-macro cfg quirk), so every
# board feature must enable it. `tokio` is declared only to silence the
# macro's `#[cfg(feature = "tokio")]` checks.
embassy = []
tokio = []
# 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 = []
heltec_v3 = [
"embassy",
"dep:esp-backtrace",
"dep:esp-hal",
"dep:esp-println",
"dep:esp-rtos",
]
heltec_v3_uart = [
"embassy",
"dep:esp-backtrace",
"dep:esp-hal",
"dep:esp-println",
"dep:esp-rtos",
]
heltec_v4 = [
"embassy",
"dep:esp-backtrace",
"dep:esp-hal",
"dep:esp-println",
"dep:esp-rtos",
]
elecrow_thinknode_m2 = [
"embassy",
"dep:esp-backtrace",
"dep:esp-hal",
"dep:esp-println",
"dep:esp-rtos",
]
rak_wisblock_4631 = [
"embassy",
"dep:cortex-m",
"dep:cortex-m-rt",
"cortex-m-rt/set-vtor",
"dep:defmt-rtt",
"dep:embassy-nrf",
"dep:panic-probe",
"embassy-executor/arch-cortex-m",
"embassy-executor/platform-cortex-m",
"embassy-executor/executor-thread",
]
wio_tracker_l1 = [
"embassy",
"dep:cortex-m",
"dep:cortex-m-rt",
"cortex-m-rt/set-vtor",
"dep:defmt-rtt",
"dep:embassy-nrf",
"dep:panic-probe",
"embassy-executor/arch-cortex-m",
"embassy-executor/platform-cortex-m",
"embassy-executor/executor-thread",
]
waveshare_rp2040_lora = [
"embassy",
"dep:cortex-m",
"dep:cortex-m-rt",
"dep:defmt-rtt",
"dep:embassy-rp",
"dep:panic-probe",
"dep:portable-atomic",
"embassy-executor/arch-cortex-m",
"embassy-executor/platform-cortex-m",
"embassy-executor/executor-thread",
]
# REVISIT: esp-rtos 0.2.0 on crates.io pins embassy-executor ^0.9. The
# esp-rs/esp-hal main branch already supports 0.10. Remove these patches once
# esp-rtos publishes a release with embassy-executor 0.10 support. Track:
# https://github.com/esp-rs/esp-hal/tree/main/esp-rtos
[patch.crates-io]
esp-hal = { git = "https://github.com/esp-rs/esp-hal.git", branch = "main" }
esp-rtos = { git = "https://github.com/esp-rs/esp-hal.git", branch = "main" }
esp-backtrace = { git = "https://github.com/esp-rs/esp-hal.git", branch = "main" }
esp-println = { git = "https://github.com/esp-rs/esp-hal.git", branch = "main" }
[lints.clippy]
undocumented_unsafe_blocks = "deny"
+1
View File
@@ -23,6 +23,7 @@ All commands are run from the `firmware/` directory.
| Heltec V3 | `heltec_v3` | ESP32-S3 | SX1262 | Native CDC-ACM | xtensa-esp32s3-none-elf |
| Heltec V3 (UART) | `heltec_v3_uart` | ESP32-S3 | SX1262 | CP2102 bridge | xtensa-esp32s3-none-elf |
| Heltec V4 | `heltec_v4` | ESP32-S3 | SX1262 | Native CDC-ACM | xtensa-esp32s3-none-elf |
| ELECROW ThinkNode-M2 | `elecrow_thinknode_m2` | ESP32-S3 | SX1262 | CP2102 bridge | 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 |
+1 -2
View File
@@ -118,8 +118,7 @@ fn main() {
};
if let Some(src) = memory_x {
let out_dir = std::env::var("OUT_DIR").unwrap();
fs::copy(src, format!("{out_dir}/memory.x"))
.expect("failed to copy memory.x");
fs::copy(src, format!("{out_dir}/memory.x")).expect("failed to copy memory.x");
println!("cargo:rustc-link-search={out_dir}");
}
println!("cargo:rerun-if-changed=ld/nrf52840-memory.x");
+16 -20
View File
@@ -1,11 +1,15 @@
set shell := ["bash", "-c"]
# Board definitions: feature target chip
# Xtensa targets use nightly cargo + esp rustc + -Zbuild-std=core.
# Xtensa targets use the self-contained esp toolchain (espup-installed) with
# `-Zbuild-std=core,alloc`. Using `+esp` rather than `+nightly` keeps the
# compiler and rust-src versions aligned — the public nightly drifts ahead of
# the esp fork and breaks core-stdlib compilation otherwise.
# Tool versions are pinned in mise.toml; run `just setup` to install everything.
heltec_v3 := "heltec_v3 xtensa-esp32s3-none-elf esp32s3"
heltec_v3_uart := "heltec_v3_uart xtensa-esp32s3-none-elf esp32s3"
heltec_v4 := "heltec_v4 xtensa-esp32s3-none-elf esp32s3"
elecrow_thinknode_m2 := "elecrow_thinknode_m2 xtensa-esp32s3-none-elf esp32s3"
rak_wisblock_4631 := "rak_wisblock_4631 thumbv7em-none-eabihf nRF52840_xxAA"
wio_tracker_l1 := "wio_tracker_l1 thumbv7em-none-eabihf nRF52840_xxAA"
waveshare_rp2040_lora := "waveshare_rp2040_lora thumbv6m-none-eabi rp2040"
@@ -14,7 +18,7 @@ builds_dir := "builds"
version := `sed -n 's/^version = "\(.*\)"/\1/p' Cargo.toml`
# All known boards
boards := "heltec_v3 heltec_v3_uart heltec_v4 rak_wisblock_4631 waveshare_rp2040_lora wio_tracker_l1"
boards := "heltec_v3 heltec_v3_uart heltec_v4 elecrow_thinknode_m2 rak_wisblock_4631 waveshare_rp2040_lora wio_tracker_l1"
# Install all required tools and toolchains
setup:
@@ -57,12 +61,8 @@ check-all:
fi; \
done
# Check a single board compiles
# Check a single board compiles (clippy with warnings as errors)
check board:
@just _cargo {{board}} "check --release"
# Run clippy on a single board
clippy board:
@just _cargo {{board}} "clippy --release" "-- -D warnings"
# Build release firmware and copy to builds/ with a readable name
@@ -105,10 +105,7 @@ test:
# Check for outdated dependencies
outdated:
@# embassy-executor 0.10 renamed arch-* → platform-* features.
@# 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
cargo outdated
# ── Private helpers ───────────────────────────────────────────────────
@@ -118,18 +115,18 @@ _ensure_tools:
@mise trust --yes . 2>/dev/null; mise install --quiet
# Run a cargo command for a board.
# Xtensa: nightly cargo + esp rustc (via RUSTC override) + -Zbuild-std=core
# Xtensa: esp toolchain (cargo + rustc + rust-src together) + -Zbuild-std=core
[private]
_cargo board cmd extra_args="":
@just _ensure_tools
@read -r feat target chip <<< "$(just _info {{board}})"; \
env=""; extra=""; \
extra=""; buildstd=""; \
case "$target" in xtensa-*) \
just _require_esp_toolchain; \
[ -f "$HOME/export-esp.sh" ] && . "$HOME/export-esp.sh"; \
env="RUSTC=$(rustup which rustc --toolchain esp)"; extra="+nightly"; buildstd="-Zbuild-std=core,alloc";; \
extra="+esp"; buildstd="-Zbuild-std=core,alloc";; \
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
[private]
@@ -140,17 +137,15 @@ _can_build board:
*) rustup target list --installed | grep -q "^$target$" || rustup target add "$target" ;; \
esac
# Ensure the ESP Xtensa toolchain is installed (espup installed via mise)
# Ensure the ESP Xtensa toolchain is installed (espup installed via mise).
# The esp toolchain ships with its own cargo, rustc, and rust-src — no extra
# components are needed on top.
[private]
_require_esp_toolchain:
@just _ensure_tools
@if ! rustup toolchain list | grep -q "^esp"; then \
echo "ESP toolchain not found, installing via espup (this may take a while)..." >&2; \
espup install || { echo "error: espup install failed" >&2; exit 1; }; \
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
# Find a serial port matching any of the given VID:PID pairs (checked in order)
@@ -218,6 +213,7 @@ _info name:
@if [ "{{name}}" == "heltec_v3" ]; then echo "{{heltec_v3}}"; \
elif [ "{{name}}" == "heltec_v3_uart" ]; then echo "{{heltec_v3_uart}}"; \
elif [ "{{name}}" == "heltec_v4" ]; then echo "{{heltec_v4}}"; \
elif [ "{{name}}" == "elecrow_thinknode_m2" ]; then echo "{{elecrow_thinknode_m2}}"; \
elif [ "{{name}}" == "rak_wisblock_4631" ]; then echo "{{rak_wisblock_4631}}"; \
elif [ "{{name}}" == "waveshare_rp2040_lora" ]; then echo "{{waveshare_rp2040_lora}}"; \
elif [ "{{name}}" == "wio_tracker_l1" ]; then echo "{{wio_tracker_l1}}"; \
+1 -1
View File
@@ -1,5 +1,5 @@
[tools]
rust = "1.93"
rust = "1.94"
just = "latest"
"cargo:cargo-binutils" = "latest" # `cargo size`, `cargo objdump`, etc.
+141
View File
@@ -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 + CP2102 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 CP2102 bridge
//! 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(i2c: DisplayI2c) -> Option<DisplayDriver> {
let mut display = Sh1106::new(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(),
}
}
}
+29 -6
View File
@@ -7,7 +7,7 @@
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, Level, Output, OutputConfig, Pull};
use esp_hal::gpio::{Input, InputConfig, InputPin, Level, Output, OutputConfig, OutputPin, Pull};
use lora_phy::iv::GenericSx126xInterfaceVariant;
use lora_phy::sx126x::{self, Sx1262, Sx126x};
@@ -32,12 +32,25 @@ pub struct RadioParts {
pub delay: Delay,
}
// SSD1306 display support is Heltec-specific. ESP32-S3 boards that use a
// 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 i2c: DisplayI2c,
}
// ── Display driver ──────────────────────────────────────────────────
#[cfg(any(
feature = "heltec_v3",
feature = "heltec_v3_uart",
feature = "heltec_v4"
))]
pub type DisplayDriver = ssd1306::Ssd1306Async<
ssd1306::prelude::I2CInterface<DisplayI2c>,
ssd1306::size::DisplaySize128x64,
@@ -45,6 +58,11 @@ pub type DisplayDriver = ssd1306::Ssd1306Async<
>;
/// 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(i2c: DisplayI2c) -> Option<DisplayDriver> {
use ssd1306::mode::DisplayConfigAsync;
use ssd1306::prelude::{Brightness, DisplayRotation};
@@ -67,12 +85,17 @@ pub async fn create_display(i2c: DisplayI2c) -> Option<DisplayDriver> {
// ── Shared peripheral init helpers ──────────────────────────────────
/// Construct SX1262 radio from an initialized SPI bus.
///
/// Generic over GPIO type so boards with different radio pin assignments can
/// share this helper. `tcxo_voltage` selects the DIO3-driven TCXO supply:
/// Heltec boards use `Ctrl1V8`, the ELECROW ThinkNode-M2 uses `Ctrl3V3`, etc.
pub fn init_radio(
spi_bus: &'static embassy_sync::mutex::Mutex<NoopRawMutex, SpiBus>,
nss_pin: esp_hal::peripherals::GPIO8<'static>,
reset_pin: esp_hal::peripherals::GPIO12<'static>,
dio1_pin: esp_hal::peripherals::GPIO14<'static>,
busy_pin: esp_hal::peripherals::GPIO13<'static>,
nss_pin: impl OutputPin + 'static,
reset_pin: impl OutputPin + 'static,
dio1_pin: impl InputPin + 'static,
busy_pin: impl InputPin + 'static,
tcxo_voltage: sx126x::TcxoCtrlVoltage,
) -> RadioParts {
let nss = Output::new(nss_pin, Level::High, OutputConfig::default());
let spi_device = SpiDevice::new(spi_bus, nss);
@@ -86,7 +109,7 @@ pub fn init_radio(
let sx_config = sx126x::Config {
chip: Sx1262,
tcxo_ctrl: Some(sx126x::TcxoCtrlVoltage::Ctrl1V8),
tcxo_ctrl: Some(tcxo_voltage),
use_dcdc: true,
rx_boost: false,
};
+15 -10
View File
@@ -14,9 +14,7 @@ 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 use super::esp32s3::{create_display, DisplayDriver, DisplayParts, RadioDriver, RadioParts};
pub type UsbDriver = esp_hal::otg_fs::asynch::Driver<'static>;
pub type LedDriver = SimpleLed<Output<'static>>;
@@ -51,14 +49,21 @@ impl LoRaBoard for Board {
fn into_parts(self) -> BoardParts<RadioParts, UsbParts, DisplayParts, LedDriver> {
let p = self.p;
mcu::start_timer(p.TIMG0);
mcu::start_timer(p.TIMG0, p.SW_INTERRUPT);
// Vext power: GPIO36, active LOW to enable peripherals
let vext = Output::new(p.GPIO36, Level::Low, OutputConfig::default());
core::mem::forget(vext);
// 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);
let radio = esp32s3::init_radio(
spi_bus,
p.GPIO8,
p.GPIO12,
p.GPIO14,
p.GPIO13,
lora_phy::sx126x::TcxoCtrlVoltage::Ctrl1V8,
);
// 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);
@@ -72,12 +77,12 @@ impl LoRaBoard for Board {
),
};
// SSD1306 display reset: pulse GPIO21 low->high before I2C init
// 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);
core::mem::forget(display_rst);
let i2c = mcu::init_i2c(p.I2C0, p.GPIO17, p.GPIO18);
let display = Some(DisplayParts { i2c });
+15 -10
View File
@@ -13,9 +13,7 @@ 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 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>>;
@@ -50,14 +48,21 @@ impl LoRaBoard for Board {
fn into_parts(self) -> BoardParts<RadioParts, UartParts, DisplayParts, LedDriver> {
let p = self.p;
mcu::start_timer(p.TIMG0);
mcu::start_timer(p.TIMG0, p.SW_INTERRUPT);
// Vext power: GPIO36, active LOW to enable peripherals
let vext = Output::new(p.GPIO36, Level::Low, OutputConfig::default());
core::mem::forget(vext);
// 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);
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 {
@@ -68,12 +73,12 @@ impl LoRaBoard for Board {
.into_async(),
};
// SSD1306 display reset: pulse GPIO21 low->high before I2C init
// 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);
core::mem::forget(display_rst);
let i2c = mcu::init_i2c(p.I2C0, p.GPIO17, p.GPIO18);
let display = Some(DisplayParts { i2c });
+15 -10
View File
@@ -6,9 +6,7 @@ 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 use super::esp32s3::{create_display, DisplayDriver, DisplayParts, RadioDriver, RadioParts};
pub type UsbDriver = esp_hal::otg_fs::asynch::Driver<'static>;
@@ -44,14 +42,21 @@ impl LoRaBoard for Board {
fn into_parts(self) -> BoardParts<RadioParts, UsbParts, DisplayParts, LedDriver> {
let p = self.p;
mcu::start_timer(p.TIMG0);
mcu::start_timer(p.TIMG0, p.SW_INTERRUPT);
// Vext power: GPIO36, active LOW to enable peripherals
let vext = Output::new(p.GPIO36, Level::Low, OutputConfig::default());
core::mem::forget(vext);
// 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);
let radio = esp32s3::init_radio(
spi_bus,
p.GPIO8,
p.GPIO12,
p.GPIO14,
p.GPIO13,
lora_phy::sx126x::TcxoCtrlVoltage::Ctrl1V8,
);
// 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);
@@ -65,12 +70,12 @@ impl LoRaBoard for Board {
),
};
// 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());
esp_hal::delay::Delay::new().delay_millis(10);
display_rst.set_high();
esp_hal::delay::Delay::new().delay_millis(10);
core::mem::forget(display_rst);
let i2c = mcu::init_i2c(p.I2C0, p.GPIO17, p.GPIO18);
let display = Some(DisplayParts { i2c });
+6 -1
View File
@@ -129,7 +129,12 @@ impl LoRaBoard for Board {
// ── Display (optional RAK1921 SSD1306 OLED on I2C) ─────
let twim_buf = mcu::alloc_i2c_buffer();
let i2c = embassy_nrf::twim::Twim::new(
p.TWISPI0, mcu::Irqs, p.P0_13, p.P0_14, twim::Config::default(), twim_buf,
p.TWISPI0,
mcu::Irqs,
p.P0_13,
p.P0_14,
twim::Config::default(),
twim_buf,
);
let display = Some(DisplayParts { i2c });
+3 -1
View File
@@ -48,7 +48,9 @@ pub trait LoRaBoard: Sized {
fn mac_address() -> [u8; 6];
/// Decompose initialized board into peripheral bundles for each task.
fn into_parts(self) -> BoardParts<Self::RadioParts, Self::CommParts, Self::DisplayParts, Self::LedDriver>;
fn into_parts(
self,
) -> BoardParts<Self::RadioParts, Self::CommParts, Self::DisplayParts, Self::LedDriver>;
}
/// Peripheral bundles for each firmware task, produced by [`LoRaBoard::into_parts`].
+12 -3
View File
@@ -21,7 +21,8 @@ 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 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,
@@ -78,7 +79,8 @@ impl LoRaBoard for Board {
let p = self.p;
// ── MAC address from flash unique ID ───────────────────
let mut flash = embassy_rp::flash::Flash::<_, _, { 2 * 1024 * 1024 }>::new_blocking(p.FLASH);
let mut flash =
embassy_rp::flash::Flash::<_, _, { 2 * 1024 * 1024 }>::new_blocking(p.FLASH);
mcu::init_mac(&mut flash);
// ── SPI1 bus for SX1262 ────────────────────────────────
@@ -92,7 +94,14 @@ impl LoRaBoard for Board {
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, spi_cfg,
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);
+6 -1
View File
@@ -128,7 +128,12 @@ impl LoRaBoard for Board {
// ── 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,
p.TWISPI0,
mcu::Irqs,
p.P0_06,
p.P0_05,
twim::Config::default(),
twim_buf,
);
let display = Some(DisplayParts { i2c });
+22 -35
View File
@@ -1,14 +1,12 @@
//! Inter-task communication channels.
//!
//! Each channel connects exactly two tasks:
//! - [`CommandChannel`]: USB → Radio (host commands)
//! - [`ResponseChannel`]: Radio → USB (firmware responses)
//! - [`DisplayCommandChannel`]: USB → Display (on/off/reset)
//! - [`StatusWatch`]: Radio → Display (observable radio state)
//! - [`RadioEventChannel`]: Radio → Display (typed events from the radio)
use embassy_sync::blocking_mutex::raw::CriticalSectionRawMutex;
use embassy_sync::channel::Channel;
use embassy_sync::watch::Watch;
use crate::protocol::{Command, RadioConfig, Response};
@@ -17,6 +15,7 @@ use crate::protocol::{Command, RadioConfig, Response};
pub enum DisplayCommand {
On,
Off,
#[allow(dead_code)] // only used by USB host task, not UART
Reset,
}
@@ -29,39 +28,27 @@ pub type CommandChannel = Channel<CriticalSectionRawMutex, Command, 16>;
/// Radio-to-host response channel (depth 32: buffer RX packets while USB writes).
pub type ResponseChannel = Channel<CriticalSectionRawMutex, Response, 32>;
/// Observable radio status for the display task (2 receivers max).
/// 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).
/// Radio → downstream typed events. The radio task emits one of these at
/// every mode transition and every RF packet. Mode events and data events
/// are separate: `EnteredRx` / `EnteredTx` / `Idle` describe what the radio
/// is *doing now*, while `PacketRx` / `PacketTx` describe *what just
/// happened* without implying a mode change.
#[derive(Debug, Clone, defmt::Format)]
pub struct RadioStatus {
pub state: RadioState,
pub config: Option<RadioConfig>,
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 {
pub enum RadioEvent {
/// Radio is now in RX mode.
EnteredRx,
/// Radio is now in TX mode.
EnteredTx,
/// Radio is now idle.
Idle,
Receiving,
Transmitting,
/// A packet was received with these link metrics.
PacketRx { rssi: i16, snr: Option<i16> },
/// A packet was transmitted successfully.
PacketTx,
/// `SetConfig` applied a new validated config.
ConfigChanged(RadioConfig),
}
impl Default for RadioStatus {
fn default() -> Self {
Self {
state: RadioState::Idle,
config: None,
rx_count: 0,
tx_count: 0,
last_rssi: None,
last_snr: None,
}
}
}
/// Radio → Display event stream (depth 32: buffer RX bursts while the
/// display drains slowly).
pub type RadioEventChannel = Channel<CriticalSectionRawMutex, RadioEvent, 32>;
+229
View File
@@ -0,0 +1,229 @@
//! 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
8 => 4, // in prepare_for_tx
9 => 5, // in tx().await
10 => 6, // do_tx returned Ok
11 => 7, // respond_tx_done entered
12 => 8, // respond_tx_done: responses.send returned
13 => 9, // host_task received response from channel
14 => 10, // host_task wrote response to UART (terminal success)
15 => 11, // 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;
}
}
+504 -237
View File
File diff suppressed because it is too large. Load diff
+9 -2
View File
@@ -1,5 +1,12 @@
#[cfg(feature = "wio_tracker_l1")]
#[cfg(any(feature = "wio_tracker_l1", feature = "elecrow_thinknode_m2"))]
pub mod sh1106;
#[cfg(any(feature = "heltec_v3", feature = "heltec_v3_uart", feature = "heltec_v4", feature = "wio_tracker_l1", feature = "waveshare_rp2040_lora"))]
#[cfg(any(
feature = "heltec_v3",
feature = "heltec_v3_uart",
feature = "heltec_v4",
feature = "elecrow_thinknode_m2",
feature = "wio_tracker_l1",
feature = "waveshare_rp2040_lora"
))]
pub mod simple_led;
+3 -4
View File
@@ -73,9 +73,9 @@ impl<I: I2c> Sh1106<I> {
for page in 0..PAGES {
// 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
0xB0 | page as u8, // page address
COL_OFFSET & 0x0F, // column low nibble
0x10 | (COL_OFFSET >> 4), // column high nibble
])
.await?;
@@ -90,7 +90,6 @@ impl<I: I2c> Sh1106<I> {
pub async fn set_brightness(&mut self, value: u8) -> Result<(), I::Error> {
self.cmd(&[0x81, value]).await
}
}
impl<I: I2c> DrawTarget for Sh1106<I> {
+23 -8
View File
@@ -6,6 +6,7 @@
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;
@@ -21,20 +22,28 @@ 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>) {
pub fn start_timer(
timg0: esp_hal::peripherals::TIMG0<'static>,
sw_int: esp_hal::peripherals::SW_INTERRUPT<'static>,
) {
let timg0 = TimerGroup::new(timg0);
esp_rtos::start(timg0.timer0);
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: esp_hal::peripherals::GPIO9<'static>,
mosi: esp_hal::peripherals::GPIO10<'static>,
miso: esp_hal::peripherals::GPIO11<'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,
@@ -62,10 +71,13 @@ pub fn init_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: esp_hal::peripherals::GPIO17<'static>,
scl: esp_hal::peripherals::GPIO18<'static>,
sda: impl PeripheralInput<'static> + PeripheralOutput<'static>,
scl: impl PeripheralInput<'static> + PeripheralOutput<'static>,
) -> I2cBus {
I2c::new(i2c0, I2cConfig::default())
.expect("I2C init")
@@ -78,5 +90,8 @@ pub fn init_i2c(
/// Read the factory-programmed MAC address from eFuse.
pub fn mac_address() -> [u8; 6] {
esp_hal::efuse::Efuse::mac_address()
esp_hal::efuse::base_mac_address()
.as_bytes()
.try_into()
.expect("MAC is 6 bytes")
}
+6 -1
View File
@@ -3,7 +3,12 @@
//! 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"))]
#[cfg(any(
feature = "heltec_v3",
feature = "heltec_v3_uart",
feature = "heltec_v4",
feature = "elecrow_thinknode_m2"
))]
pub mod esp32s3;
#[cfg(any(feature = "rak_wisblock_4631", feature = "wio_tracker_l1"))]
+1 -3
View File
@@ -25,9 +25,7 @@ pub type UsbNrfDriver =
// ── 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> {
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))
+12 -8
View File
@@ -11,19 +11,20 @@ use static_cell::StaticCell;
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 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> {
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))
@@ -40,7 +41,12 @@ static mut MAC_REF: Option<&'static [u8; 6]> = None;
///
/// 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>,
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");
@@ -70,7 +76,5 @@ pub fn init_mac<const FLASH_SIZE: usize>(
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()")
}
unsafe { *MAC_REF.expect("init_mac() must be called before mac_address()") }
}
+5 -76
View File
@@ -2,87 +2,16 @@
//!
//! Both `usb_task` and `uart_task` use these helpers for frame
//! accumulation, response encoding, and command routing.
//!
//! The actual codec lives in `donglora-protocol` so host crates can
//! reuse it; this module re-exports the codec and owns the
//! firmware-only [`route_command`] (which touches embassy channels).
use defmt::warn;
pub use donglora_protocol::framing::{cobs_encode_response, CobsDecoder, MAX_FRAME};
use crate::channel::{CommandChannel, DisplayCommand, DisplayCommandChannel, ResponseChannel};
use crate::protocol::{Command, ErrorCode, Response};
/// Maximum COBS frame size (bytes).
pub const MAX_FRAME: usize = 512;
// 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"
);
/// Decodes COBS-framed commands from a byte stream.
pub struct CobsDecoder {
buf: [u8; MAX_FRAME],
len: usize,
}
impl CobsDecoder {
pub const fn new() -> Self {
Self {
buf: [0u8; MAX_FRAME],
len: 0,
}
}
/// Reset the accumulator (discard partial frame).
pub fn reset(&mut self) {
self.len = 0;
}
/// Feed a chunk of bytes, calling `on_command` for each decoded command.
pub fn feed(&mut self, data: &[u8], mut on_command: impl FnMut(Command)) {
for &byte in data {
if byte == 0x00 {
// End of COBS frame — decode and dispatch
if self.len > 0 {
let mut decode_buf = [0u8; MAX_FRAME];
if let Some(decoded_len) =
ucobs::decode(&self.buf[..self.len], &mut decode_buf)
{
if let Some(cmd) = Command::from_bytes(&decode_buf[..decoded_len]) {
on_command(cmd);
}
}
}
self.len = 0;
} else if self.len < MAX_FRAME {
self.buf[self.len] = byte;
self.len += 1;
} else {
// Frame too large — discard
self.len = 0;
}
}
}
}
/// COBS-encode a response into `encode_buf` with trailing 0x00 sentinel.
///
/// Returns the slice to send (encoded frame + sentinel), or `None` on overflow.
pub fn cobs_encode_response<'a>(
response: Response,
write_buf: &mut [u8; MAX_FRAME],
encode_buf: &'a mut [u8; MAX_FRAME],
) -> Option<&'a [u8]> {
let raw_len = response.write_to(write_buf);
let encoded_len = ucobs::encode(&write_buf[..raw_len], encode_buf).unwrap_or(0);
if encoded_len < encode_buf.len() {
encode_buf[encoded_len] = 0x00;
Some(&encode_buf[..encoded_len + 1])
} else {
warn!("COBS encode buffer overflow");
None
}
}
/// Route a parsed command to the appropriate handler.
///
/// Display and MAC commands are handled locally (response sent immediately).
+1 -1
View File
@@ -6,7 +6,7 @@
pub mod framing;
cfg_if::cfg_if! {
if #[cfg(feature = "heltec_v3_uart")] {
if #[cfg(any(feature = "heltec_v3_uart", feature = "elecrow_thinknode_m2"))] {
mod uart;
pub use uart::host_task;
} else {
+14 -4
View File
@@ -6,8 +6,8 @@ use defmt::warn;
use embassy_executor::task;
use embedded_io_async::Write;
use crate::channel::{CommandChannel, DisplayCommand, DisplayCommandChannel, ResponseChannel};
use super::framing::{self, CobsDecoder, MAX_FRAME};
use crate::channel::{CommandChannel, DisplayCommand, DisplayCommandChannel, ResponseChannel};
#[task]
pub async fn host_task(
@@ -20,9 +20,9 @@ pub async fn host_task(
) {
let (mut rx, mut tx) = parts.driver.split();
// UART has no DTR — signal the display that a host is present at boot.
// UART has no DTR — assume a host is always present.
if has_display {
display_commands.send(DisplayCommand::Reset).await;
display_commands.send(DisplayCommand::On).await;
}
let mut read_buf = [0u8; 64];
@@ -56,17 +56,27 @@ pub async fn host_task(
});
for cmd in cmds {
framing::route_command(
cmd, commands, responses, display_commands, has_display, mac,
cmd,
commands,
responses,
display_commands,
has_display,
mac,
)
.await;
}
}
Either::Second(response) => {
#[cfg(feature = "debug-checkpoint")]
crate::debug_blink::set(13);
if let Some(frame) =
framing::cobs_encode_response(response, &mut write_buf, &mut cobs_encode_buf)
{
if tx.write_all(frame).await.is_err() {
warn!("UART write failed, response dropped");
} else {
#[cfg(feature = "debug-checkpoint")]
crate::debug_blink::set(14);
}
}
}
+147 -23
View File
@@ -1,18 +1,112 @@
//! USB CDC-ACM host task: COBS-framed fixed-size LE command/response protocol.
//!
//! ```text
//! Host
//! ├── Disconnected (default) no DTR
//! └── Connected host attached
//! entry: wake_display
//! exit: stop_radio, reset_display
//! ```
//!
//! The statechart tracks exactly one thing: DTR. Entry/exit actions
//! replace the old `was_connected` edge-detector and emit the display
//! wake / radio-stop / display-reset side-effects that used to be open
//! coded around the `select3` loop.
//!
//! The data plane (COBS decoding, command routing, response encoding)
//! stays imperative in `protocol_loop` for the same reason the radio's
//! `rx_once` stayed out of its chart: `read_packet` blocks and can't
//! live inside a statechart action. Host-contract lifecycle (one
//! outstanding command, unsolicited `RxPacket`, synchronous local
//! responses) is deliberately *not* modeled — any finer chart here
//! would desynchronise from the concurrent `select3` reality and lie
//! about what the firmware is actually doing.
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 hsmc::{statechart, Duration};
use static_cell::StaticCell;
use super::framing::{self, CobsDecoder, MAX_FRAME};
use crate::channel::{CommandChannel, DisplayCommand, DisplayCommandChannel, ResponseChannel};
use crate::protocol::Command;
use super::framing::{self, CobsDecoder, MAX_FRAME};
const MAX_PACKET: usize = 64;
/// Internal events driving `Host` state transitions.
#[derive(Debug, Clone)]
pub enum HostInput {
/// DTR rose: host opened the serial port.
DtrRaised,
/// DTR fell: host closed or disconnected.
DtrDropped,
}
/// Machine context — channel handles the actions need to broadcast
/// DTR-edge side-effects. The COBS decoder, read/write scratch, and
/// USB endpoints stay local to `protocol_loop` because their async
/// operations can't live inside statechart actions.
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 stop_radio_best_effort(&self) {
if self.commands.try_send(Command::StopRx).is_err() {
warn!("host DTR-drop: StopRx dropped (command channel full)");
}
}
}
statechart! {
Host {
context: HostContext;
events: HostInput;
default(Disconnected);
state Disconnected {
on(DtrRaised) => Connected;
}
state Connected {
entry: wake_display;
exit: stop_radio, reset_display;
on(DtrDropped) => Disconnected;
}
}
}
impl HostActions for HostActionContext<'_> {
async fn wake_display(&mut self) {
self.wake_display_if_present().await;
}
async fn stop_radio(&mut self) {
self.stop_radio_best_effort();
}
async fn reset_display(&mut self) {
self.reset_display_if_present().await;
}
}
#[task]
pub async fn host_task(
parts: crate::board::UsbParts,
@@ -59,7 +153,15 @@ pub async fn host_task(
join(
usb_dev.run(),
protocol_loop(sender, receiver, commands, responses, display_commands, has_display, mac),
protocol_loop(
sender,
receiver,
commands,
responses,
display_commands,
has_display,
mac,
),
)
.await;
}
@@ -75,11 +177,18 @@ async fn protocol_loop<'d, D: embassy_usb_driver::Driver<'d>>(
) {
use embassy_futures::select::{select3, Either3};
let mut machine = Host::new_local(HostContext {
commands,
display_commands,
has_display,
});
// Enter the default: Disconnected.
machine.step(Duration::ZERO).await;
let mut read_buf = [0u8; MAX_PACKET];
let mut write_buf = [0u8; MAX_FRAME];
let mut cobs_encode_buf = [0u8; MAX_FRAME];
let mut decoder = CobsDecoder::new();
let mut was_connected = false;
loop {
match select3(
@@ -89,28 +198,32 @@ async fn protocol_loop<'d, D: embassy_usb_driver::Driver<'d>>(
)
.await
{
Either3::First(result) => {
let n = match result {
Ok(0) => continue,
Ok(n) => n,
Err(_) => {
decoder.reset();
embassy_time::Timer::after_millis(100).await;
continue;
}
};
Either3::First(Ok(0)) => {}
Either3::First(Ok(n)) => {
let mut cmds = heapless::Vec::<_, 4>::new();
decoder.feed(&read_buf[..n], |cmd| {
let _ = cmds.push(cmd);
});
for cmd in cmds {
framing::route_command(
cmd, commands, responses, display_commands, has_display, mac,
cmd,
commands,
responses,
display_commands,
has_display,
mac,
)
.await;
}
}
Either3::First(Err(_)) => {
// Read errored — usually a USB disconnect. Drop decoder
// scratch and back off briefly so we don't spin at I/O
// rate. Do NOT `continue`: the DTR check below must
// still run so the machine's exit action fires.
decoder.reset();
embassy_time::Timer::after_millis(100).await;
}
Either3::Second(response) => {
if let Some(frame) =
framing::cobs_encode_response(response, &mut write_buf, &mut cobs_encode_buf)
@@ -126,16 +239,27 @@ async fn protocol_loop<'d, D: embassy_usb_driver::Driver<'d>>(
Either3::Third(()) => {}
}
// Edge-detect DTR against the machine's current state. The
// machine's entry/exit actions own the side-effects. dispatch()
// pushes the event AND drains the internal queue, so
// current_state() reflects the post-dispatch state before we
// loop back around.
let connected = receiver.dtr();
if was_connected && !connected {
let _ = commands.try_send(Command::StopRx);
if has_display {
display_commands.send(DisplayCommand::Reset).await;
let was_connected = matches!(machine.current_state(), HostState::Connected);
match (was_connected, connected) {
(false, true) => {
let _ = machine.dispatch(HostInput::DtrRaised).await;
}
(true, false) => {
// Belt-and-braces: reset decoder here too. The error
// arm above typically fires on disconnect and already
// resets it, but this makes intent explicit and
// survives embassy-usb behavior changes where a
// disconnect doesn't surface as a read error.
decoder.reset();
let _ = machine.dispatch(HostInput::DtrDropped).await;
}
_ => {}
}
if !was_connected && connected && has_display {
display_commands.send(DisplayCommand::On).await;
}
was_connected = connected;
}
}
+31 -14
View File
@@ -19,6 +19,8 @@
mod board;
#[cfg(not(test))]
mod channel;
#[cfg(all(not(test), feature = "debug-checkpoint"))]
mod debug_blink;
#[cfg(not(test))]
mod display;
#[cfg(not(test))]
@@ -37,14 +39,14 @@ use embassy_executor::Spawner;
#[cfg(not(test))]
use crate::board::LoRaBoard;
#[cfg(not(test))]
use crate::channel::{CommandChannel, DisplayCommandChannel, ResponseChannel, StatusWatch};
use crate::channel::{CommandChannel, DisplayCommandChannel, RadioEventChannel, ResponseChannel};
#[cfg(not(test))]
cfg_if::cfg_if! {
if #[cfg(any(feature = "rak_wisblock_4631", feature = "wio_tracker_l1", feature = "waveshare_rp2040_lora"))] {
use defmt_rtt as _;
use panic_probe as _;
} else if #[cfg(any(feature = "heltec_v3", feature = "heltec_v3_uart", feature = "heltec_v4"))] {
} else if #[cfg(any(feature = "heltec_v3", feature = "heltec_v3_uart", feature = "heltec_v4", feature = "elecrow_thinknode_m2"))] {
use esp_backtrace as _;
use esp_println as _;
}
@@ -55,7 +57,7 @@ static COMMANDS: CommandChannel = CommandChannel::new();
#[cfg(not(test))]
static RESPONSES: ResponseChannel = ResponseChannel::new();
#[cfg(not(test))]
static STATUS: StatusWatch = StatusWatch::new();
static RADIO_EVENTS: RadioEventChannel = RadioEventChannel::new();
#[cfg(not(test))]
static DISPLAY_COMMANDS: DisplayCommandChannel = DisplayCommandChannel::new();
@@ -66,7 +68,7 @@ cfg_if::cfg_if! {
async fn main(spawner: Spawner) {
run(spawner).await;
}
} else if #[cfg(any(feature = "heltec_v3", feature = "heltec_v3_uart", feature = "heltec_v4"))] {
} else if #[cfg(any(feature = "heltec_v3", feature = "heltec_v3_uart", feature = "heltec_v4", feature = "elecrow_thinknode_m2"))] {
#[esp_rtos::main]
async fn main(spawner: Spawner) {
run(spawner).await;
@@ -79,24 +81,39 @@ async fn run(spawner: Spawner) {
let board = <board::Board as LoRaBoard>::init();
let parts = board.into_parts();
spawner
.spawn(radio::radio_task(parts.radio, &COMMANDS, &RESPONSES, &STATUS))
.expect("spawn radio_task");
spawner.spawn(
radio::radio_task(parts.radio, &COMMANDS, &RESPONSES, &RADIO_EVENTS)
.expect("spawn radio_task"),
);
spawner
.spawn(host::host_task(
spawner.spawn(
host::host_task(
parts.host,
&COMMANDS,
&RESPONSES,
&DISPLAY_COMMANDS,
parts.display.is_some(),
parts.mac,
))
.expect("spawn host_task");
)
.expect("spawn host_task"),
);
if let Some(dp) = parts.display {
spawner
.spawn(display::display_task(dp, parts.led, &STATUS, &DISPLAY_COMMANDS))
.expect("spawn display_task");
#[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"),
);
}
}
}
+11 -706
View File
@@ -1,707 +1,12 @@
//! Wire protocol types and fixed-size little-endian serialization.
//! Wire protocol types.
//!
//! Every integer is fixed-width LE. No varints, no zigzag.
//! See `docs/PROTOCOL.md` for the complete specification.
use heapless::Vec;
/// Maximum LoRa payload size in bytes.
pub const MAX_PAYLOAD: usize = 256;
/// RadioConfig wire size (fixed).
pub const RADIO_CONFIG_SIZE: usize = 13;
/// Sentinel value for `tx_power_dbm`: use the board's maximum TX power.
pub const TX_POWER_MAX: i8 = i8::MIN; // -128 on the wire
/// 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)]
pub enum ErrorCode {
InvalidConfig = 0,
RadioBusy = 1,
TxTimeout = 2,
// 3 reserved (was CrcError — SX1262 silently drops bad-CRC packets)
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,
preamble_len: 16,
cad: 1,
};
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::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);
}
}
//! The definitions live in the `donglora-protocol` crate so host-side
//! Rust projects (client-rs, mux-rs, bridge) can share them with the
//! firmware without duplicating wire layouts. This module re-exports
//! them unchanged for existing `crate::protocol::*` call sites.
#[allow(unused_imports)]
pub use donglora_protocol::{
Bandwidth, Command, ErrorCode, RadioConfig, Response, MAX_PAYLOAD, PREAMBLE_DEFAULT,
RADIO_CONFIG_SIZE, TX_POWER_MAX,
};
+557 -251
View File
@@ -1,272 +1,593 @@
//! 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
//! LoRa radio task: SX1262 statechart driven by host commands.
//!
//! ```text
//! Idle ──StartRx──► Receiving ──StopRx──► Idle
//! │ │
//! └──Transmit──► Transmitting ──TxDone──► (previous state)
//! Radio
//! ├── Unconfigured (default) no config cached → no RF ops possible
//! └── Configured
//! ├── Idle (default) ready, waiting
//! ├── StartingRx brief: HW init in response to host
//! │ CmdStartRx; sends Ok/Error then →
//! │ Receiving (Ok) or Idle (Err)
//! ├── Receiving continuous RX
//! ├── ResumingRx brief: post-TX HW re-arm; silently
//! │ → Receiving (Ok) or Idle (Err)
//! └── Transmitting
//! ├── FromIdle (default) TxDone/TxFailed → Idle
//! └── FromRx TxDone → ResumingRx, TxFailed → Idle
//! ```
//!
//! # Invariants
//! The task body is a single `machine.run().await`. All I/O lives inside
//! state-scoped `during:` activities:
//!
//! - This task never panics. All errors are reported to the host or logged.
//! - Config is validated before use (see [`RadioConfig::validate`]).
//! * Root-level `during: next_command(commands)` continuously reads the
//! host CommandChannel and converts each command into a `RadioInput`
//! event.
//! * `Receiving.during: next_packet(lora, rx_buf)` runs the SX1262 RX
//! and yields `PacketReceived` events.
//! * `TransmittingFromIdle/FromRx.during: perform_tx(lora, pending_tx)`
//! drives `do_tx` and yields `TransmitDone`/`TransmitFailed`.
use defmt::{error, info, warn};
use embassy_executor::task;
use embassy_futures::select::{select, Either};
use embassy_sync::blocking_mutex::raw::CriticalSectionRawMutex;
use embassy_sync::channel::Channel;
use embassy_time::Delay;
use hsmc::statechart;
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::channel::{CommandChannel, RadioEvent, RadioEventChannel, ResponseChannel};
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>;
type LoRaDriver = 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 ────────────────────────────────────────────────
/// A pending TX request staged by an incoming `Transmit` command, consumed
/// by the `perform_tx` during activity when the machine enters a
/// `Transmitting.*` state. `config` is `None` when the command asked to
/// reuse the cached config; the `stage_tx` handler resolves the effective
/// config.
#[derive(Debug, Clone)]
pub struct PendingTx {
pub config: Option<RadioConfig>,
pub payload: heapless::Vec<u8, MAX_PAYLOAD>,
}
/// Events driving the radio statechart. Host commands arrive as `Cmd*`
/// variants via the `next_command` during; low-level I/O results arrive
/// as internal variants via the `next_packet` / `perform_tx` durings.
#[derive(Debug, Clone)]
pub enum RadioInput {
// Commands flowing in from the host via CommandChannel:
CmdPing,
CmdGetConfig,
CmdSetConfig(RadioConfig),
CmdStartRx,
CmdStopRx,
CmdTransmit(PendingTx),
// Internal events from the RX during activity:
PacketReceived {
rssi: i16,
snr: i16,
len: u8,
data: heapless::Vec<u8, MAX_PAYLOAD>,
},
RxFailed(&'static str),
// Internal events from `try_start_rx_hw` (the entry action of
// StartingRx and ResumingRx). Two distinct states consume these,
// and their differing transitions are how the chart encodes whether
// the original trigger was a host CmdStartRx (must respond) or a
// post-TX RX re-arm (must NOT respond).
StartRxOk,
StartRxFailed(&'static str),
// Internal events from the TX during activity:
TransmitDone,
TransmitFailed(&'static str),
}
/// Root context. Owns the LoRa driver, the RX scratch buffer, the cached
/// configuration, the outbound event + response channels, and a staging
/// slot for pending TX requests.
pub struct RadioContext {
pub commands: &'static CommandChannel,
pub responses: &'static ResponseChannel,
pub events: &'static RadioEventChannel,
pub lora: LoRaDriver,
pub rx_buf: [u8; MAX_PAYLOAD],
pub config: Option<RadioConfig>,
pub pending_tx: Option<PendingTx>,
}
impl RadioContext {
fn announce(&self, ev: RadioEvent) {
if self.events.try_send(ev).is_err() {
warn!("radio event dropped: downstream queue full");
}
}
}
statechart! {
Radio {
context: RadioContext;
events: RadioInput;
default(Unconfigured);
// Commands handled the same way in every state. The actual command
// read — `during: next_command(commands)` — is placed only on states
// where commands are safe to interleave (NOT on Transmitting.*:
// canceling perform_tx mid-flight would abort a LoRa TX).
on(CmdPing) => respond_pong;
on(CmdGetConfig) => respond_get_config;
state Unconfigured {
during: next_command(commands);
on(CmdSetConfig(cfg: RadioConfig)) => cache_config;
on(CmdSetConfig) => Configured;
// Pre-configuration, only Ping/GetConfig make sense.
on(CmdStartRx) => respond_not_configured;
on(CmdStopRx) => respond_not_configured;
on(CmdTransmit) => respond_not_configured;
}
state Configured {
on(CmdSetConfig(cfg: RadioConfig)) => cache_config;
default(Idle);
state Idle {
entry: announce_idle;
during: next_command(commands);
on(CmdStartRx) => StartingRx;
on(CmdTransmit(p: PendingTx)) => stage_tx;
on(CmdTransmit) => TransmittingFromIdle;
on(CmdStopRx) => respond_ok;
}
// Brief intermediate state: a host `StartRx` is in flight,
// hardware is being configured. `try_start_rx_hw` (entry)
// emits `StartRxOk` or `StartRxFailed`; the transitions from
// here are what tell the host whether RX actually started.
// No `during: next_command` — this state is self-driven and
// we don't want to interleave other commands with the ack.
state StartingRx {
entry: try_start_rx_hw;
on(StartRxOk) => respond_ok, Receiving;
on(StartRxFailed(err: &'static str)) => respond_start_rx_error;
on(StartRxFailed) => Idle;
}
state Receiving {
entry: announce_entered_rx;
during: next_command(commands);
during: next_packet(lora, rx_buf, config);
// Idempotent ack: a redundant StartRx (same client re-issuing,
// or another client's request that mux still chose to forward)
// gets `Ok` without re-touching hardware.
on(CmdStartRx) => respond_ok;
on(PacketReceived {
rssi: i16,
snr: i16,
len: u8,
data: heapless::Vec<u8, MAX_PAYLOAD>,
}) => record_packet;
on(RxFailed(err: &'static str)) => announce_rx_failed;
on(RxFailed) => Idle;
on(CmdStopRx) => stop_rx_hw, respond_ok;
on(CmdStopRx) => Idle;
on(CmdTransmit(p: PendingTx)) => stage_tx;
on(CmdTransmit) => TransmittingFromRx;
}
// Brief intermediate state: a TX from `Receiving` just
// finished and the radio needs to be put back into RX mode.
// Same `try_start_rx_hw` entry as `StartingRx`, but the
// transitions DO NOT send a response — `respond_tx_done` was
// already sent by the TransmittingFromRx → here transition.
state ResumingRx {
entry: try_start_rx_hw;
on(StartRxOk) => Receiving;
on(StartRxFailed) => Idle;
}
// Transmitting.* deliberately has no `next_command` during: the
// TX operation inside `perform_tx` cannot be safely cancelled
// by an incoming host command. Commands arriving during TX
// queue on the CommandChannel and are processed by the next
// state that reads them (Idle or Receiving).
state Transmitting {
entry: announce_entered_tx;
exit: announce_packet_tx;
default(TransmittingFromIdle);
state TransmittingFromIdle {
during: perform_tx(lora, pending_tx);
on(TransmitDone) => respond_tx_done;
on(TransmitDone) => Idle;
on(TransmitFailed(err: &'static str)) => respond_tx_failed;
on(TransmitFailed) => Idle;
}
state TransmittingFromRx {
during: perform_tx(lora, pending_tx);
on(TransmitDone) => respond_tx_done;
on(TransmitDone) => ResumingRx;
on(TransmitFailed(err: &'static str)) => respond_tx_failed;
on(TransmitFailed) => Idle;
}
}
}
}
}
// ── during: activities ──────────────────────────────────────────────
/// Bridges the host's `CommandChannel` (typed `Command`) into the
/// machine's event stream. Each awaited command becomes one `RadioInput`.
async fn next_command(commands: &mut &'static CommandChannel) -> RadioInput {
let cmd = commands.receive().await;
match cmd {
Command::Ping => RadioInput::CmdPing,
Command::GetConfig => RadioInput::CmdGetConfig,
Command::SetConfig(cfg) => RadioInput::CmdSetConfig(cfg),
Command::StartRx => RadioInput::CmdStartRx,
Command::StopRx => RadioInput::CmdStopRx,
Command::Transmit { config, payload } => {
// Mark the moment we learned about the Transmit command.
// This runs before the latch engages, so it's visible as a
// distinct LED value (18) when the firmware is sitting in
// Receiving (5) waiting for something to process — if LED
// stays on 5, the command never arrived at the firmware
// (mux / UART delay); if LED flashes through 18 then deeper,
// the command arrived normally.
#[cfg(feature = "debug-checkpoint")]
crate::debug_blink::set(18);
// The `stage_tx` handler resolves the effective config from
// either the inline `config` or the cached one.
RadioInput::CmdTransmit(PendingTx { config, payload })
}
Command::DisplayOn | Command::DisplayOff | Command::GetMac => {
// These are dispatched elsewhere in the host; if they land
// here it's a routing bug. Map to Ping → Pong for minimum
// harm (better: surface an error; there's no "unknown
// command" variant today).
RadioInput::CmdPing
}
}
}
/// Runs one iteration of continuous RX. Produces a `PacketReceived` event
/// on success or `RxFailed` on error. The radio was configured for
/// continuous RX by `try_start_rx_hw` (entry action of `StartingRx` /
/// `ResumingRx`) before the chart entered `Receiving`; each iteration
/// of this during awaits the next packet.
async fn next_packet(
lora: &mut LoRaDriver,
rx_buf: &mut [u8; MAX_PAYLOAD],
config: &mut Option<RadioConfig>,
) -> RadioInput {
let Some(cfg) = config.as_ref() else {
return RadioInput::RxFailed("no cached config");
};
let mdltn = match modulation_params(lora, cfg) {
Ok(m) => m,
Err(_) => return RadioInput::RxFailed("mdltn"),
};
let pkt = match lora.create_rx_packet_params(
cfg.preamble_len,
IMPLICIT_HEADER,
MAX_PAYLOAD as u8,
CRC_ON,
IQ_INVERTED,
&mdltn,
) {
Ok(p) => p,
Err(_) => return RadioInput::RxFailed("pkt_params"),
};
match lora.rx(&pkt, rx_buf).await {
Ok((len, status)) => {
let copy_len = (len as usize).min(MAX_PAYLOAD);
let mut data: heapless::Vec<u8, MAX_PAYLOAD> = heapless::Vec::new();
let _ = data.extend_from_slice(&rx_buf[..copy_len]);
RadioInput::PacketReceived {
rssi: status.rssi,
snr: status.snr,
len,
data,
}
}
Err(_e) => RadioInput::RxFailed("rx"),
}
}
/// Drives one TX operation using a staged `PendingTx`. Produces
/// `TransmitDone` on success or `TransmitFailed` on error.
async fn perform_tx(lora: &mut LoRaDriver, pending: &mut Option<PendingTx>) -> RadioInput {
#[cfg(feature = "debug-checkpoint")]
crate::debug_blink::set(6);
let Some(tx) = pending.take() else {
#[cfg(feature = "debug-checkpoint")]
crate::debug_blink::set(15);
return RadioInput::TransmitFailed("no pending tx");
};
let Some(cfg) = tx.config.as_ref() else {
#[cfg(feature = "debug-checkpoint")]
crate::debug_blink::set(15);
return RadioInput::TransmitFailed("no effective config");
};
match do_tx(lora, cfg, &tx.payload).await {
Ok(()) => {
#[cfg(feature = "debug-checkpoint")]
crate::debug_blink::set(10);
RadioInput::TransmitDone
}
Err(_) => {
#[cfg(feature = "debug-checkpoint")]
crate::debug_blink::set(15);
RadioInput::TransmitFailed("tx")
}
}
}
// ── Actions ─────────────────────────────────────────────────────────
impl RadioActions for RadioActionContext<'_> {
// ── responses ────────────────────────────────────────────────────
async fn respond_pong(&mut self) {
self.responses.send(Response::Pong).await;
}
async fn respond_get_config(&mut self) {
match self.config {
Some(cfg) => self.responses.send(Response::Config(cfg)).await,
None => {
self.responses
.send(Response::Error(ErrorCode::NotConfigured))
.await;
}
}
}
async fn respond_ok(&mut self) {
// try_send: StopRx after RX with full response channel is
// tolerable — host is disconnected.
let _ = self.responses.try_send(Response::Ok);
}
async fn respond_not_configured(&mut self) {
self.responses
.send(Response::Error(ErrorCode::NotConfigured))
.await;
}
async fn respond_tx_done(&mut self) {
#[cfg(feature = "debug-checkpoint")]
crate::debug_blink::set(11);
self.responses.send(Response::TxDone).await;
#[cfg(feature = "debug-checkpoint")]
crate::debug_blink::set(12);
}
async fn respond_tx_failed(&mut self, _err: &'static str) {
self.responses
.send(Response::Error(ErrorCode::TxTimeout))
.await;
}
// ── state bookkeeping ─────────────────────────────────────────────
async fn cache_config(&mut self, cfg: RadioConfig) {
if let Err(reason) = cfg.validate(Board::TX_POWER_RANGE) {
warn!("SetConfig rejected: {}", reason);
self.responses
.send(Response::Error(ErrorCode::InvalidConfig))
.await;
return;
}
let resolved = cfg.resolve(Board::TX_POWER_RANGE);
self.config = Some(resolved);
self.announce(RadioEvent::ConfigChanged(resolved));
self.responses.send(Response::Ok).await;
}
async fn stage_tx(&mut self, p: PendingTx) {
let effective = p.config.or(self.config);
let Some(cfg) = effective else {
self.responses
.send(Response::Error(ErrorCode::NotConfigured))
.await;
return;
};
if let Err(reason) = cfg.validate(Board::TX_POWER_RANGE) {
warn!("TX config rejected: {}", reason);
self.responses
.send(Response::Error(ErrorCode::InvalidConfig))
.await;
return;
}
let resolved = cfg.resolve(Board::TX_POWER_RANGE);
self.pending_tx = Some(PendingTx {
config: Some(resolved),
payload: p.payload,
});
}
// ── RX hardware ──────────────────────────────────────────────────
/// Entry action for `StartingRx` and `ResumingRx`. Performs HW init
/// and emits `StartRxOk` on success or `StartRxFailed` on failure.
/// Whether the host receives a Response::Ok / Response::Error is
/// decided by the parent state (StartingRx responds; ResumingRx is
/// silent — `respond_tx_done` already went out).
async fn try_start_rx_hw(&mut self) {
let Some(cfg) = self.config else {
error!("try_start_rx_hw: no config cached — state invariant broken");
let _ = self.emit(RadioInput::StartRxFailed("no config"));
return;
};
match start_rx(&mut self.lora, &cfg).await {
Ok(()) => {
let _ = self.emit(RadioInput::StartRxOk);
}
Err(e) => {
warn!("start_rx failed: {}", e);
#[cfg(feature = "debug-checkpoint")]
crate::debug_blink::set(17);
let _ = self.emit(RadioInput::StartRxFailed("start_rx"));
}
}
}
async fn respond_start_rx_error(&mut self, _err: &'static str) {
self.responses
.send(Response::Error(ErrorCode::InvalidConfig))
.await;
}
async fn stop_rx_hw(&mut self) {
let _ = self.lora.enter_standby().await;
}
// ── announcements ────────────────────────────────────────────────
async fn announce_idle(&mut self) {
#[cfg(feature = "debug-checkpoint")]
crate::debug_blink::set(4);
self.announce(RadioEvent::Idle);
}
async fn announce_entered_rx(&mut self) {
#[cfg(feature = "debug-checkpoint")]
crate::debug_blink::set(5);
self.announce(RadioEvent::EnteredRx);
}
async fn announce_entered_tx(&mut self) {
self.announce(RadioEvent::EnteredTx);
}
async fn announce_packet_tx(&mut self) {
self.announce(RadioEvent::PacketTx);
}
async fn announce_rx_failed(&mut self, _err: &'static str) {
self.announce(RadioEvent::Idle);
}
// ── packet bookkeeping ────────────────────────────────────────────
async fn record_packet(
&mut self,
rssi: i16,
snr: i16,
_len: u8,
data: heapless::Vec<u8, MAX_PAYLOAD>,
) {
self.announce(RadioEvent::PacketRx {
rssi,
snr: Some(snr),
});
let response = Response::RxPacket {
rssi,
snr,
payload: data,
};
if self.responses.try_send(response).is_err() {
warn!("RX packet dropped: response channel full");
}
}
}
// ── Debug-checkpoint mapping (see `crate::debug_blink`) ─────────────
//
// Boot / steady states (latch is INACTIVE — values overwrite normally):
// 1 boot: radio_task entered
// 2 boot: LoRa::new ok (driver is live)
// 3 boot: machine constructed, about to enter run()
// 4 in Idle state (announce_idle ran)
// 5 in Receiving state (announce_entered_rx ran — HW is in RX)
//
// TX phases 6–15 (linear; latch ENGAGES only on `set(6)`; only deeper
// TX-phase writes can update; 14/15 are terminal and freeze the latch):
// 6 TX phase 1: entered perform_tx
// 7 TX phase 2: in CAD (prepare_for_cad / cad loop)
// 8 TX phase 3: in prepare_for_tx (SPI config write)
// 9 TX phase 4: awaiting tx() completion (on-air transmit)
// 10 TX phase 5: do_tx returned Ok (back in perform_tx)
// 11 TX phase 6: respond_tx_done handler entered
// 12 TX phase 7: responses.send(TxDone) completed (radio side done)
// 13 TX phase 8: host_task received the response from channel (set in host/uart.rs)
// 14 TX phase 9: host_task wrote response to UART — TERMINAL success
// 15 TX errored somewhere — TERMINAL error
//
// Boot-time errors (can only be seen before any TX engages the latch):
// 16 LoRa::new failed at boot (degraded_loop branch)
// 17 try_start_rx_hw failed (radio HW init returned Err)
#[task]
pub async fn radio_task(
parts: RadioParts,
commands: &'static CommandChannel,
responses: &'static ResponseChannel,
status: &'static StatusWatch,
events: &'static RadioEventChannel,
) {
let mut state = RadioStatus::default();
status.sender().send(state.clone());
#[cfg(feature = "debug-checkpoint")]
crate::debug_blink::set(1);
let mut lora = match LoRa::new(parts.driver, false, parts.delay).await {
Ok(l) => l,
// Init the radio BEFORE the machine exists in any meaningful way —
// a failure here never touches the statechart; we just run a
// Ping-only loop forever.
let lora = match LoRa::new(parts.driver, false, parts.delay).await {
Ok(l) => {
info!("radio initialized");
#[cfg(feature = "debug-checkpoint")]
crate::debug_blink::set(2);
l
}
Err(e) => {
error!("radio init failed: {}", e);
#[cfg(feature = "debug-checkpoint")]
crate::debug_blink::set(16);
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;
}
}
degraded_loop(commands, responses).await;
return;
}
};
info!("radio initialized");
let mut rx_buf = [0u8; MAX_PAYLOAD];
let ctx = RadioContext {
commands,
responses,
events,
lora,
rx_buf: [0u8; MAX_PAYLOAD],
config: None,
pending_tx: None,
};
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]);
if responses
.try_send(Response::RxPacket {
rssi: pkt_status.rssi,
snr: pkt_status.snr,
payload,
})
.is_err()
{
warn!("RX packet dropped: response channel full");
}
if let Err(e) = start_rx(&mut lora, &cfg).await {
warn!("restart RX failed: {}", e);
state.state = RadioState::Idle;
status.sender().send(state.clone());
let _ = responses.try_send(Response::Error(ErrorCode::RadioBusy));
}
}
Err(e) => {
warn!("RX error: {}", e);
if start_rx(&mut lora, &cfg).await.is_err() {
state.state = RadioState::Idle;
status.sender().send(state.clone());
let _ = responses.try_send(Response::Error(ErrorCode::RadioBusy));
}
}
},
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;
}
}
// `run()` under the embassy feature requires a bound channel even when
// no external task injects events. Radio inputs come from the root
// `during: next_command(commands)` activity and from internal durings;
// this static is kept solely to satisfy the constructor.
static RADIO_INTERNAL_CH: Channel<CriticalSectionRawMutex, RadioInput, 8> = Channel::new();
let mut machine = Radio::new(ctx, &RADIO_INTERNAL_CH);
#[cfg(feature = "debug-checkpoint")]
crate::debug_blink::set(3);
let _ = machine.run().await;
}
// ── Command handler ─────────────────────────────────────────────────
async fn handle_cmd(
cmd: Command,
lora: &mut Radio,
state: &mut RadioStatus,
responses: &ResponseChannel,
status: &StatusWatch,
) {
match cmd {
Command::Ping => {
/// Init-failure fallback. Answers Ping; everything else errors.
async fn degraded_loop(commands: &CommandChannel, responses: &ResponseChannel) {
loop {
let cmd = commands.receive().await;
if let Command::Ping = cmd {
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());
// try_send: StopRx may be sent internally on USB disconnect
// when the host isn't draining responses.
let _ = responses.try_send(Response::Ok);
}
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;
}
} else {
responses
.send(Response::Error(ErrorCode::InvalidConfig))
.await;
}
}
}
@@ -321,15 +642,14 @@ fn to_cr(cr: u8) -> lora_phy::mod_params::CodingRate {
}
}
/// Create modulation parameters from a validated config.
fn modulation_params(
lora: &mut Radio,
lora: &mut LoRaDriver,
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> {
async fn start_rx(lora: &mut LoRaDriver, cfg: &RadioConfig) -> Result<(), RadioError> {
let mdltn = modulation_params(lora, cfg)?;
let pkt = lora.create_rx_packet_params(
cfg.preamble_len,
@@ -342,37 +662,19 @@ async fn start_rx(lora: &mut Radio, cfg: &RadioConfig) -> Result<(), RadioError>
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> {
async fn do_tx(lora: &mut LoRaDriver, 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() {
#[cfg(feature = "debug-checkpoint")]
crate::debug_blink::set(7);
lora.prepare_for_cad(&mdltn).await?;
for _ in 0..CAD_MAX_RETRIES {
if !lora.cad(&mdltn).await? {
break; // Channel is clear
break;
}
// Channel busy — wait briefly and retry
embassy_time::Timer::after_millis(10).await;
lora.prepare_for_cad(&mdltn).await?;
}
@@ -385,7 +687,11 @@ async fn do_tx(lora: &mut Radio, cfg: &RadioConfig, payload: &[u8]) -> Result<()
IQ_INVERTED,
&mdltn,
)?;
#[cfg(feature = "debug-checkpoint")]
crate::debug_blink::set(8);
lora.prepare_for_tx(&mdltn, &mut tx_pkt, cfg.tx_power_dbm as i32, payload)
.await?;
#[cfg(feature = "debug-checkpoint")]
crate::debug_blink::set(9);
lora.tx().await
}