Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a0b15ddcaa
|
||
|
|
388d07e90f
|
||
|
|
c6ef9559ec
|
||
|
|
596bc3ed96
|
||
|
|
272abceedb
|
||
|
|
f9582c0b94
|
||
|
|
649c29218c
|
||
|
|
fd4e180879
|
||
|
|
8b549d49a9
|
||
|
|
7184610eea
|
||
|
|
46822c182c
|
||
|
|
2b7aacdb99
|
||
|
|
cabc8c1cc0
|
||
|
|
0cbf59765c
|
No files matched your search
@@ -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
@@ -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
File diff suppressed because it is too large.
Load diff
+70
-11
@@ -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
|
||||
@@ -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.
|
||||
@@ -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
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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
|
||||
@@ -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,5 +1,5 @@
|
||||
[tools]
|
||||
rust = "1.93"
|
||||
rust = "1.94"
|
||||
just = "latest"
|
||||
|
||||
"cargo:cargo-binutils" = "latest" # `cargo size`, `cargo objdump`, etc.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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 });
|
||||
|
||||
@@ -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
@@ -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`].
|
||||
|
||||
@@ -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(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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>;
|
||||
@@ -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
File diff suppressed because it is too large.
Load diff
+9
-2
@@ -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;
|
||||
@@ -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
@@ -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
@@ -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
@@ -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))
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
}
|
||||
Reference in new issue
Block a user