14 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
swaits 46822c182c feat: add Waveshare RP2040-LoRa board support
First RP2040 board and third MCU family (after ESP32-S3 and nRF52840).

Hardware: RP2040 + SX1262 radio, 850-930 MHz HF, PE4259 antenna switch,
LED on GPIO25, no OLED, USB-C via FPC ribbon cable to adapter board.

New files:
- src/hal/rp2040.rs: MCU primitives (SPI bus, USB driver, flash unique ID MAC)
- src/board/waveshare_rp2040_lora.rs: board implementation
- ld/rp2040-memory.x: linker memory layout

Also fixes cortex-m-rt/set-vtor (moved from global dep to per-board feature,
since Cortex-M0+ has no VTOR register) and makes build.rs memory.x selection
target-aware.
2026-04-10 16:05:08 -06:00
swaitsandClaude Opus 4.6 2b7aacdb99 docs: fix stale path reference in changelog
clients/python/ → donglora on PyPI

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 13:46:12 -06:00
swaitsandClaude Opus 4.6 cabc8c1cc0 feat: add espflash save-image for Xtensa boards
Generate .bin firmware images for ESP32 boards alongside .elf, matching
the existing .hex/.uf2 flow for nRF52840 boards.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 13:34:25 -06:00
swaits 0cbf59765c docs: add missing LICENSE file 2026-04-07 19:52:52 -06:00
37 changed files with 5780 additions and 1517 deletions

No files matched your search

+7
View File
@@ -11,3 +11,10 @@ rustflags = [
"-C", "link-arg=-Tdefmt.x",
"-C", "opt-level=z",
]
[target.thumbv6m-none-eabi]
rustflags = [
"-C", "link-arg=-Tlink.x",
"-C", "link-arg=-Tdefmt.x",
"-C", "opt-level=z",
]
File diff suppressed because it is too large. Load diff
+112 -1
View File
@@ -5,6 +5,117 @@ 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
- Waveshare RP2040-LoRa board support (RP2040 + SX1262, no OLED, LED on GPIO25)
- First RP2040 board — third MCU family after ESP32-S3 and nRF52840
- New `hal/rp2040.rs` module with MCU primitives (SPI, USB, flash unique ID)
- USB-C via FPC ribbon cable to adapter board
- PE4259 antenna switch on GPIO17, 850-930 MHz (HF)
### Changed
- `cortex-m-rt/set-vtor` moved from global dependency to per-board feature
(Cortex-M0+ has no VTOR register)
- `build.rs` memory.x selection is now target-aware (thumbv6m vs thumbv7em)
## [0.2.1] - 2026-04-07
### Fixed
@@ -72,7 +183,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/).
- Config validation against per-board hardware limits
- `TX_POWER_MAX` sentinel (`-128`): auto-use the board's maximum TX power
- Board support: Heltec V3, Heltec V4, RAK WisBlock 4631
- Python host library (`clients/python/`) with `pip install` and `donglora-mux` CLI
- Python host library (`donglora` on PyPI) with `pip install` and `donglora-mux` CLI
- USB multiplexer for sharing one dongle with multiple applications
- Two-way LoRa bridge over TCP (`examples/lora_bridge.py`)
Generated
+794 -72
View File
File diff suppressed because it is too large. Load diff
+70 -11
View File
@@ -3,7 +3,7 @@ members = ["."]
[package]
name = "donglora"
version = "0.2.1"
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,14 +22,17 @@ 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"
# Display rendering
@@ -63,8 +64,10 @@ esp-rtos = { version = "0.2", optional = true, features = [
] }
# nRF52840 (RAK 4631)
cortex-m = { version = "0.7", optional = true, features = ["critical-section-single-core"] }
cortex-m-rt = { version = "0.7", optional = true, features = ["set-vtor"] }
cortex-m = { version = "0.7", optional = true, features = [
"critical-section-single-core",
] }
cortex-m-rt = { version = "0.7", optional = true }
defmt-rtt = { version = "1.0", optional = true }
embassy-nrf = { version = "0.10", optional = true, features = [
"nrf52840",
@@ -74,47 +77,103 @@ 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.10", optional = true, features = [
"rp2040",
"time-driver",
"defmt",
] }
[build-dependencies]
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/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"
@@ -122,6 +181,6 @@ undocumented_unsafe_blocks = "deny"
[profile.release]
codegen-units = 1
lto = "fat"
opt-level = 3 # "s"/"z" break Xtensa (l32r literal range exceeded)
opt-level = 3 # "s"/"z" break Xtensa (l32r literal range exceeded)
panic = "abort"
strip = true
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Stephen Waits <steve@waits.net>
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+2
View File
@@ -23,7 +23,9 @@ 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 |
### Heltec V3 USB Note
+10 -5
View File
@@ -108,14 +108,19 @@ fn main() {
println!("cargo:rerun-if-changed=src/board/mod.rs.j2");
println!("cargo:rerun-if-changed={}", board_dir);
// cortex-m-rt's link.x does `INCLUDE memory.x`. Copy our nRF layout into
// OUT_DIR so it's only visible to ARM builds.
// cortex-m-rt's link.x does `INCLUDE memory.x`. Copy the correct layout
// into OUT_DIR based on the target MCU family.
let target = std::env::var("TARGET").unwrap_or_default();
if !target.starts_with("xtensa") {
let memory_x = match target.as_str() {
"thumbv7em-none-eabihf" => Some("ld/nrf52840-memory.x"),
"thumbv6m-none-eabi" => Some("ld/rp2040-memory.x"),
_ => None,
};
if let Some(src) = memory_x {
let out_dir = std::env::var("OUT_DIR").unwrap();
fs::copy("ld/nrf52840-memory.x", format!("{out_dir}/memory.x"))
.expect("failed to copy ld/nrf52840-memory.x");
fs::copy(src, format!("{out_dir}/memory.x")).expect("failed to copy memory.x");
println!("cargo:rustc-link-search={out_dir}");
}
println!("cargo:rerun-if-changed=ld/nrf52840-memory.x");
println!("cargo:rerun-if-changed=ld/rp2040-memory.x");
}
+30 -21
View File
@@ -1,19 +1,24 @@
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"
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 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:
@@ -28,7 +33,8 @@ setup:
@if ! rustup component list --toolchain nightly --installed 2>/dev/null | grep -q "^rust-src"; then \
rustup component add rust-src --toolchain nightly; \
fi
@# ARM target
@# ARM targets
@rustup target list --installed | grep -q "^thumbv6m-none-eabi$" || rustup target add thumbv6m-none-eabi
@rustup target list --installed | grep -q "^thumbv7em-none-eabihf$" || rustup target add thumbv7em-none-eabihf
# Build release firmware for all boards, installing toolchains as needed
@@ -55,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
@@ -103,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 ───────────────────────────────────────────────────
@@ -116,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]
@@ -138,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)
@@ -176,6 +173,16 @@ _copy_firmware board profile:
if [ -f "$src" ]; then \
cp "$src" "$dst.elf"; echo "→ $dst.elf"; \
case "$target" in \
xtensa-*) \
mise exec -- espflash save-image --chip "$chip" --merge --skip-padding "$src" "$dst.bin"; \
echo "→ $dst.bin"; \
;; \
thumbv6m-*) \
mise exec -- rust-objcopy -O ihex "$src" "$dst.hex"; \
mise exec -- cargo-hex-to-uf2 hex-to-uf2 --input-path "$dst.hex" --output-path "$dst.uf2" --family rp2040; \
rm "$dst.hex"; \
echo "→ $dst.uf2"; \
;; \
thumbv7em-*) \
mise exec -- rust-objcopy -O ihex "$src" "$dst.hex"; \
mise exec -- cargo-hex-to-uf2 hex-to-uf2 --input-path "$dst.hex" --output-path "$dst.uf2" --family nrf52840; \
@@ -206,6 +213,8 @@ _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}}"; \
else echo "Unknown board: {{name}}" >&2; exit 1; fi
+24
View File
@@ -0,0 +1,24 @@
/* RP2040 memory layout — UF2 bootloader (ROM boot2)
*
* 0x10000000 – 0x10000100 boot2 (second-stage bootloader, 256 bytes)
* 0x10000100 – 0x10200000 Application <- we live here
*
* Flash: 2MB (W25Q16JV on Waveshare RP2040-LoRa)
* RAM: 264KB SRAM (two 256KB banks + 2x 4KB scratch)
*/
MEMORY
{
BOOT2 : ORIGIN = 0x10000000, LENGTH = 0x100
FLASH : ORIGIN = 0x10000100, LENGTH = 2048K - 0x100
RAM : ORIGIN = 0x20000000, LENGTH = 264K
}
EXTERN(BOOT2_FIRMWARE)
SECTIONS {
.boot2 ORIGIN(BOOT2) :
{
KEEP(*(.boot2));
} > BOOT2
} INSERT BEFORE .text;
+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.
+1
View File
@@ -7,6 +7,7 @@
Create `src/board/your_board.rs`. Use an existing board as a template:
- ESP32-S3 boards: copy `heltec_v4.rs` (uses shared helpers from `board/esp32s3.rs` and `hal/esp32s3.rs`)
- nRF52840 boards: copy `rak_wisblock_4631.rs` (uses shared helpers from `hal/nrf52840.rs`)
- RP2040 boards: copy `waveshare_rp2040_lora.rs` (uses shared helpers from `hal/rp2040.rs`)
- New MCU family: create a new `hal/<mcu>.rs` with MCU primitives first
`build.rs` auto-discovers your board file — any `.rs` file in `src/board/` that
+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`].
+150
View File
@@ -0,0 +1,150 @@
use embassy_embedded_hal::shared_bus::asynch::spi::SpiDevice;
use embassy_rp::gpio::{Input, Level, Output, Pull};
use embassy_rp::spi;
use embassy_sync::blocking_mutex::raw::NoopRawMutex;
use embassy_time::Delay;
use lora_phy::iv::GenericSx126xInterfaceVariant;
use lora_phy::sx126x::{self, Sx1262, Sx126x};
use super::traits::{BoardParts, LoRaBoard};
use crate::hal::rp2040 as mcu;
// ── Concrete peripheral types ──────────────────────────────────────
type Nss = Output<'static>;
type Iv = GenericSx126xInterfaceVariant<Output<'static>, Input<'static>>;
type RadioSpiDevice = SpiDevice<'static, NoopRawMutex, mcu::SpiBus, Nss>;
pub type RadioDriver = Sx126x<RadioSpiDevice, Iv, Sx1262>;
pub type UsbDriver = mcu::UsbRpDriver;
pub type LedDriver = crate::driver::simple_led::SimpleLed<Output<'static>>;
// Display types — never instantiated (no OLED on this board), but the
// trait requires concrete associated types that satisfy the bounds.
pub type DisplayI2c =
embassy_rp::i2c::I2c<'static, embassy_rp::peripherals::I2C0, embassy_rp::i2c::Async>;
pub type DisplayDriver = ssd1306::Ssd1306Async<
ssd1306::prelude::I2CInterface<DisplayI2c>,
ssd1306::size::DisplaySize128x64,
ssd1306::mode::BufferedGraphicsModeAsync<ssd1306::size::DisplaySize128x64>,
>;
// ── Peripheral bundles ─────────────────────────────────────────────
pub struct RadioParts {
pub driver: RadioDriver,
pub delay: Delay,
}
pub struct UsbParts {
pub driver: UsbDriver,
}
pub struct DisplayParts {
pub i2c: DisplayI2c,
}
// ── Display init ───────────────────────────────────────────────────
pub async fn create_display(_i2c: DisplayI2c) -> Option<DisplayDriver> {
None
}
// ── Board init ─────────────────────────────────────────────────────
pub struct Board {
p: embassy_rp::Peripherals,
}
impl LoRaBoard for Board {
const NAME: &'static str = "Waveshare RP2040-LoRa";
const TX_POWER_RANGE: (i8, i8) = (-9, 22);
type RadioParts = RadioParts;
type CommParts = UsbParts;
type DisplayParts = DisplayParts;
type DisplayDriver = DisplayDriver;
type LedDriver = LedDriver;
fn init() -> Self {
let p = embassy_rp::init(Default::default());
Self { p }
}
fn mac_address() -> [u8; 6] {
mcu::mac_address()
}
fn into_parts(self) -> BoardParts<RadioParts, UsbParts, DisplayParts, LedDriver> {
let p = self.p;
// ── MAC address from flash unique ID ───────────────────
let mut flash =
embassy_rp::flash::Flash::<_, _, { 2 * 1024 * 1024 }>::new_blocking(p.FLASH);
mcu::init_mac(&mut flash);
// ── SPI1 bus for SX1262 ────────────────────────────────
//
// Pin mapping from Meshtastic & MeshCore firmware (confirmed working):
// SCK = GPIO14, MOSI = GPIO15, MISO = GPIO24, CS = GPIO13
//
// The Waveshare wiki (https://www.waveshare.com/wiki/RP2040-LoRa) lists
// different pins (MOSI=11, MISO=8, CS=9) but those don't work in practice.
// Both sets are valid SPI1 alternate functions on RP2040.
let mut spi_cfg = spi::Config::default();
spi_cfg.frequency = 1_000_000;
let spi = spi::Spi::new(
p.SPI1,
p.PIN_14,
p.PIN_15,
p.PIN_24,
p.DMA_CH0,
p.DMA_CH1,
mcu::Irqs,
spi_cfg,
);
let spi_bus = mcu::share_spi_bus(spi);
let nss = Output::new(p.PIN_13, Level::High);
let spi_device = SpiDevice::new(spi_bus, nss);
let reset = Output::new(p.PIN_23, Level::High);
let dio1 = Input::new(p.PIN_16, Pull::Down);
let busy = Input::new(p.PIN_18, Pull::Down);
// PE4259 antenna switch: GPIO17 = !CTRL (rx_enable).
// DIO2 is internally wired as CTRL (tx_enable), managed by the SX1262.
let rx_enable = Output::new(p.PIN_17, Level::Low);
let iv = GenericSx126xInterfaceVariant::new(reset, dio1, busy, Some(rx_enable), None)
.expect("SX1262 interface init");
let sx_config = sx126x::Config {
chip: Sx1262,
tcxo_ctrl: None, // No TCXO on this board
use_dcdc: true,
rx_boost: false,
};
let radio = RadioParts {
driver: Sx126x::new(spi_device, iv, sx_config),
delay: Delay,
};
// ── USB ────────────────────────────────────────────────
let host = UsbParts {
driver: embassy_rp::usb::Driver::new(p.USB, mcu::Irqs),
};
// ── LED on GPIO25 ──────────────────────────────────────
let led_pin = Output::new(p.PIN_25, Level::Low);
let led = crate::driver::simple_led::SimpleLed(led_pin);
BoardParts {
radio,
host,
display: None,
led,
mac: Self::mac_address(),
}
}
}
+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"))]
#[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")
}
+9 -1
View File
@@ -3,8 +3,16 @@
//! 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"))]
pub mod nrf52840;
#[cfg(feature = "waveshare_rp2040_lora")]
pub mod rp2040;
+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))
+80
View File
@@ -0,0 +1,80 @@
//! RP2040 MCU initialization primitives.
//!
//! Provides shared boilerplate for RP2040 boards: interrupt bindings,
//! type aliases, StaticCell bus wrapping, USB init, and MAC address reading.
use embassy_rp::bind_interrupts;
use embassy_sync::blocking_mutex::raw::NoopRawMutex;
use static_cell::StaticCell;
// ── Interrupts (shared by all RP2040 boards) ──────────────────────
bind_interrupts!(pub struct Irqs {
USBCTRL_IRQ => embassy_rp::usb::InterruptHandler<embassy_rp::peripherals::USB>;
DMA_IRQ_0 => embassy_rp::dma::InterruptHandler<embassy_rp::peripherals::DMA_CH0>,
embassy_rp::dma::InterruptHandler<embassy_rp::peripherals::DMA_CH1>;
});
// ── MCU-level types ────────────────────────────────────────────────
pub type SpiBus =
embassy_rp::spi::Spi<'static, embassy_rp::peripherals::SPI1, embassy_rp::spi::Async>;
pub type UsbRpDriver = embassy_rp::usb::Driver<'static, embassy_rp::peripherals::USB>;
// ── SPI bus sharing ────────────────────────────────────────────────
/// Wrap an initialized SPI peripheral in a shared bus (StaticCell + Mutex).
pub fn share_spi_bus(spi: SpiBus) -> &'static embassy_sync::mutex::Mutex<NoopRawMutex, SpiBus> {
static SPI_BUS: StaticCell<embassy_sync::mutex::Mutex<NoopRawMutex, SpiBus>> =
StaticCell::new();
SPI_BUS.init(embassy_sync::mutex::Mutex::new(spi))
}
// ── MAC address ────────────────────────────────────────────────────
/// Store for the MAC address derived from flash unique ID.
/// Populated once during `init_mac()`, read by `mac_address()`.
static MAC: StaticCell<[u8; 6]> = StaticCell::new();
static mut MAC_REF: Option<&'static [u8; 6]> = None;
/// Read the flash chip's unique ID and derive a 6-byte MAC address.
///
/// Must be called once during board init before `mac_address()`.
pub fn init_mac<const FLASH_SIZE: usize>(
flash: &mut embassy_rp::flash::Flash<
'_,
embassy_rp::peripherals::FLASH,
embassy_rp::flash::Blocking,
FLASH_SIZE,
>,
) {
let mut uid = [0u8; 8];
flash.blocking_unique_id(&mut uid).expect("flash unique ID");
// XOR the first 2 bytes into bytes 2..8 to fold 8 bytes into 6,
// then set the locally-administered bit (byte 0, bit 1).
let mac = [
uid[2] ^ uid[0] | 0x02,
uid[3] ^ uid[1],
uid[4],
uid[5],
uid[6],
uid[7],
];
let stored = MAC.init(mac);
// SAFETY: Written once during single-threaded init, before the executor
// starts. All subsequent reads are from &'static reference.
unsafe {
MAC_REF = Some(stored);
}
}
/// Return the MAC address derived from the flash unique ID.
///
/// Panics if `init_mac()` has not been called.
pub fn mac_address() -> [u8; 6] {
// SAFETY: MAC_REF is set once during init before the executor starts,
// and only read (never written) after that.
unsafe { *MAC_REF.expect("init_mac() must be called before mac_address()") }
}
+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;
}
}
+33 -16
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"))] {
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,18 +57,18 @@ 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();
#[cfg(not(test))]
cfg_if::cfg_if! {
if #[cfg(any(feature = "rak_wisblock_4631", feature = "wio_tracker_l1"))] {
if #[cfg(any(feature = "rak_wisblock_4631", feature = "wio_tracker_l1", feature = "waveshare_rp2040_lora"))] {
#[embassy_executor::main]
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
}