Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
de38278d7b
|
||
|
|
0c9094dcbd | ||
|
|
41b5775268 | ||
|
|
f9117d71c8 | ||
|
|
3bb41da223 | ||
|
|
eb880d03f6 | ||
|
|
239e72d0a8 | ||
|
|
6558350aee | ||
|
|
019198b636 | ||
|
|
5ac4b73739 | ||
|
|
2b9e236f32 | ||
|
|
6db287c241 | ||
|
|
07651dce44 | ||
|
|
18b509344c | ||
|
|
ecc451affe | ||
|
|
7a54c4dff3 | ||
|
|
622684cdde | ||
|
|
2c852b9f45 | ||
|
|
fb334118fd | ||
|
|
8bdaa72bc3 | ||
|
|
6e8699610a | ||
|
|
73878b792f | ||
|
|
0ae1c84085 | ||
|
|
38684482e8 |
No files matched your search
@@ -4,3 +4,10 @@ rustflags = [
|
||||
"-C", "link-arg=-Wl,-Tdefmt.x",
|
||||
"-C", "link-arg=-nostartfiles",
|
||||
]
|
||||
|
||||
[target.thumbv7em-none-eabihf]
|
||||
rustflags = [
|
||||
"-C", "link-arg=-Tlink.x",
|
||||
"-C", "link-arg=-Tdefmt.x",
|
||||
"-C", "opt-level=z",
|
||||
]
|
||||
@@ -0,0 +1,85 @@
|
||||
# Changelog
|
||||
|
||||
All notable changes to this project will be documented in this file.
|
||||
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/).
|
||||
|
||||
## [0.2.1] - 2026-04-07
|
||||
|
||||
### Fixed
|
||||
|
||||
- Display stuck on status screen after USB disconnect (stale radio status
|
||||
updates re-rendered dashboard over splash because `disconnected` flag was
|
||||
not set in the `Reset` handler)
|
||||
- Radio left running after host disconnect, now sends `StopRx` when DTR drops
|
||||
- System deadlock when host app closes serial port while radio is receiving
|
||||
(unsolicited responses in radio task now use `try_send` to avoid blocking
|
||||
on a full response channel)
|
||||
- LED orphaned if OLED display initialization fails (display task now falls
|
||||
back to a LED-only event loop instead of returning early)
|
||||
|
||||
## [0.2.0] - 2026-04-07
|
||||
|
||||
### Added
|
||||
|
||||
- Wio Tracker L1 board support (nRF52840 + SX1262, SH1106 OLED)
|
||||
- LED blink on packet activity: brief flash on RX and TX when display is on
|
||||
- RX brightness scales with SNR (stronger signal = brighter)
|
||||
- Works on all boards with a user LED (Heltec V3/V4, Wio Tracker)
|
||||
- Native USB CDC-ACM support for Heltec V3 (requires hardware mod: solder R29/R3)
|
||||
- `heltec_v3_uart` feature for stock (unmodified) V3 boards via CP2102 bridge
|
||||
- `RgbLed` trait and `SimpleLed` driver for GPIO-based LEDs
|
||||
- `LoRaBoard` trait with full associated types (`RadioParts`, `CommParts`,
|
||||
`DisplayParts`, `DisplayDriver`, `LedDriver`) and `BoardParts<R, C, D, L>` struct
|
||||
|
||||
### Changed
|
||||
|
||||
- Major firmware architecture reorganization:
|
||||
- `hal/` module for MCU-specific primitives (ESP32-S3, nRF52840)
|
||||
- `driver/` module for hardware peripheral drivers (SH1106, SimpleLed)
|
||||
- `host/` module for unified USB/UART communication with internal cfg dispatch
|
||||
- `board/` contains only board definitions — no MCU helpers or drivers mixed in
|
||||
- Board files are declarative wiring diagrams calling hal primitives
|
||||
- `run()` function has zero board-specific conditional compilation
|
||||
- Display init moved to board layer (`create_display()`) — display task has no cfg
|
||||
- Flattened `radio/` and `display/` directories (no single-file directories)
|
||||
- `build.rs` auto-discovers boards by content (implements `LoRaBoard`) and
|
||||
helper modules by `use super::` imports — no exclusion lists
|
||||
- `check` and `clippy` commands now use `--release` profile
|
||||
- Deduplicated USB/UART protocol framing (shared `CobsDecoder` + `route_command`)
|
||||
- Removed `ErrorCode::CrcError` (SX1262 silently drops bad-CRC packets)
|
||||
|
||||
### Fixed
|
||||
|
||||
- Display stuck on splash screen for UART boards (disconnected flag started true)
|
||||
|
||||
## [0.1.0] - 2026-03-15
|
||||
|
||||
### Added
|
||||
|
||||
- Transparent LoRa radio over USB CDC-ACM (VID `1209`, PID `5741`)
|
||||
- COBS-framed fixed-size LE binary protocol (9 commands, 7 responses)
|
||||
- Radio configuration: frequency, bandwidth, spreading factor, coding rate, TX power
|
||||
- Continuous RX mode with RSSI/SNR per packet
|
||||
- Single-shot TX with optional per-packet config override
|
||||
- SSD1306 OLED display support with status dashboard
|
||||
- Radio state, frequency, modulation parameters
|
||||
- Packet counters (RX/TX)
|
||||
- RSSI sparkline (1-minute history, TX shown as dotted bars)
|
||||
- Combined splash/waiting screen on disconnect
|
||||
- Auto-detect USB host connect/disconnect via DTR
|
||||
- Config validation against per-board hardware limits
|
||||
- `TX_POWER_MAX` sentinel (`-128`): auto-use the board's maximum TX power
|
||||
- Board support: Heltec V3, Heltec V4, RAK WisBlock 4631
|
||||
- Python host library (`clients/python/`) with `pip install` and `donglora-mux` CLI
|
||||
- USB multiplexer for sharing one dongle with multiple applications
|
||||
- Two-way LoRa bridge over TCP (`examples/lora_bridge.py`)
|
||||
|
||||
### Technical
|
||||
|
||||
- Embassy async runtime (no threads, CPU sleeps when idle)
|
||||
- Zero-config: boots and waits for host commands
|
||||
- Protocol-agnostic: firmware has no opinion about what you transmit
|
||||
- Tasks never panic: all errors reported to host or logged
|
||||
- Board abstraction: one `.rs` file + one Cargo feature per board
|
||||
Generated
+15
-1
@@ -111,6 +111,7 @@ checksum = "8ec610d8f49840a5b376c69663b6369e71f4b34484b9b2eb29fb918d92516cb9"
|
||||
dependencies = [
|
||||
"bare-metal",
|
||||
"bitfield 0.13.2",
|
||||
"critical-section",
|
||||
"embedded-hal 0.2.7",
|
||||
"volatile-register",
|
||||
]
|
||||
@@ -301,6 +302,16 @@ dependencies = [
|
||||
"thiserror",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "defmt-rtt"
|
||||
version = "1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "93d5a25c99d89c40f5676bec8cefe0614f17f0f40e916f98e345dae941807f9e"
|
||||
dependencies = [
|
||||
"critical-section",
|
||||
"defmt 1.0.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "delegate"
|
||||
version = "0.13.5"
|
||||
@@ -361,12 +372,14 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "donglora"
|
||||
version = "0.1.0"
|
||||
version = "0.2.1"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"cortex-m",
|
||||
"cortex-m-rt",
|
||||
"critical-section",
|
||||
"defmt 1.0.1",
|
||||
"defmt-rtt",
|
||||
"display-interface",
|
||||
"embassy-embedded-hal 0.6.0",
|
||||
"embassy-executor",
|
||||
@@ -377,6 +390,7 @@ dependencies = [
|
||||
"embassy-usb",
|
||||
"embassy-usb-driver",
|
||||
"embedded-graphics",
|
||||
"embedded-hal 1.0.0",
|
||||
"embedded-hal-async",
|
||||
"embedded-io-async 0.7.0",
|
||||
"esp-backtrace",
|
||||
|
||||
+24
-2
@@ -3,7 +3,7 @@ members = ["."]
|
||||
|
||||
[package]
|
||||
name = "donglora"
|
||||
version = "0.1.0"
|
||||
version = "0.2.1"
|
||||
edition = "2021"
|
||||
build = "build.rs"
|
||||
authors = ["Stephen Waits <steve@waits.net>"]
|
||||
@@ -21,6 +21,7 @@ categories = ["embedded", "hardware-support", "no-std"]
|
||||
cfg-if = "1.0"
|
||||
critical-section = "1.2"
|
||||
defmt = "1.0"
|
||||
embedded-hal = "1.0"
|
||||
embedded-hal-async = "1.0"
|
||||
embassy-embedded-hal = "0.6"
|
||||
embassy-executor = "0.9"
|
||||
@@ -62,7 +63,9 @@ esp-rtos = { version = "0.2", optional = true, features = [
|
||||
] }
|
||||
|
||||
# nRF52840 (RAK 4631)
|
||||
cortex-m-rt = { version = "0.7", optional = true }
|
||||
cortex-m = { version = "0.7", optional = true, features = ["critical-section-single-core"] }
|
||||
cortex-m-rt = { version = "0.7", optional = true, features = ["set-vtor"] }
|
||||
defmt-rtt = { version = "1.0", optional = true }
|
||||
embassy-nrf = { version = "0.10", optional = true, features = [
|
||||
"nrf52840",
|
||||
"gpiote",
|
||||
@@ -82,6 +85,12 @@ heltec_v3 = [
|
||||
"dep:esp-println",
|
||||
"dep:esp-rtos",
|
||||
]
|
||||
heltec_v3_uart = [
|
||||
"dep:esp-backtrace",
|
||||
"dep:esp-hal",
|
||||
"dep:esp-println",
|
||||
"dep:esp-rtos",
|
||||
]
|
||||
heltec_v4 = [
|
||||
"dep:esp-backtrace",
|
||||
"dep:esp-hal",
|
||||
@@ -89,7 +98,18 @@ heltec_v4 = [
|
||||
"dep:esp-rtos",
|
||||
]
|
||||
rak_wisblock_4631 = [
|
||||
"dep:cortex-m",
|
||||
"dep:cortex-m-rt",
|
||||
"dep:defmt-rtt",
|
||||
"dep:embassy-nrf",
|
||||
"dep:panic-probe",
|
||||
"embassy-executor/arch-cortex-m",
|
||||
"embassy-executor/executor-thread",
|
||||
]
|
||||
wio_tracker_l1 = [
|
||||
"dep:cortex-m",
|
||||
"dep:cortex-m-rt",
|
||||
"dep:defmt-rtt",
|
||||
"dep:embassy-nrf",
|
||||
"dep:panic-probe",
|
||||
"embassy-executor/arch-cortex-m",
|
||||
@@ -103,3 +123,5 @@ undocumented_unsafe_blocks = "deny"
|
||||
codegen-units = 1
|
||||
lto = "fat"
|
||||
opt-level = 3 # "s"/"z" break Xtensa (l32r literal range exceeded)
|
||||
panic = "abort"
|
||||
strip = true
|
||||
@@ -18,11 +18,39 @@ All commands are run from the `firmware/` directory.
|
||||
|
||||
## Supported Boards
|
||||
|
||||
| Board | MCU | Radio | Target |
|
||||
|-------|-----|-------|--------|
|
||||
| Heltec V3 | ESP32-S3 | SX1262 | xtensa-esp32s3-none-elf |
|
||||
| Heltec V4 | ESP32-S3 | SX1262 | xtensa-esp32s3-none-elf |
|
||||
| RAK WisBlock 4631 | nRF52840 | SX1262 | thumbv7em-none-eabihf |
|
||||
| Board | Feature | MCU | Radio | USB | Target |
|
||||
|-------|---------|-----|-------|-----|--------|
|
||||
| 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 |
|
||||
| RAK WisBlock 4631 | `rak_wisblock_4631` | nRF52840 | SX1262 | Native CDC-ACM | thumbv7em-none-eabihf |
|
||||
| Wio Tracker L1 | `wio_tracker_l1` | nRF52840 | SX1262 | Native CDC-ACM | thumbv7em-none-eabihf |
|
||||
|
||||
### Heltec V3 USB Note
|
||||
|
||||
The default `heltec_v3` build uses **native USB CDC-ACM**, which requires a
|
||||
hardware modification to your board. Stock Heltec V3 boards route USB through a
|
||||
CP2102 bridge chip to UART — the ESP32-S3's native USB pins (GPIO19/GPIO20) are
|
||||
routed to header pins but not connected to the USB connector.
|
||||
|
||||
**To enable native USB on a Heltec V3:**
|
||||
|
||||
1. Solder 0-ohm (or 22-ohm) resistors at pads **R29** (D+) and **R3** (D-)
|
||||
2. Disconnect the CP2102 from the USB connector's D+/D- lines (cut traces or
|
||||
remove the CP2102 bridge resistors)
|
||||
|
||||
After this mod, the board appears as `/dev/ttyACM0` with the DongLoRa VID:PID
|
||||
(1209:5741), just like the V4 and nRF boards.
|
||||
|
||||
**If you have a stock (unmodified) V3**, use `heltec_v3_uart` instead:
|
||||
|
||||
```sh
|
||||
just build heltec_v3_uart
|
||||
just flash heltec_v3_uart
|
||||
```
|
||||
|
||||
This builds firmware that communicates via the CP2102 bridge (appears as
|
||||
`/dev/ttyUSB0`).
|
||||
|
||||
## Adding a Board
|
||||
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
- LED blinking on TX/RX? colored?
|
||||
- publish new client, mux, bridge (updates in client for UART crap)
|
||||
- split out mux and client repos
|
||||
- move to GH organization
|
||||
@@ -1,32 +1,48 @@
|
||||
use minijinja::{context, Environment};
|
||||
use owo_colors::OwoColorize;
|
||||
use std::collections::HashMap;
|
||||
use std::fs;
|
||||
|
||||
fn main() {
|
||||
// Protocol tests run on the host without a board feature. Cargo doesn't
|
||||
// expose `cfg(test)` to build scripts, so we rely on an env var set by
|
||||
// the justfile `test` recipe. See: `just test`
|
||||
// Protocol tests run on the host without a board feature.
|
||||
if std::env::var_os("DONGLORA_HOST_TEST").is_some() {
|
||||
return;
|
||||
}
|
||||
|
||||
let board_dir = "src/board";
|
||||
let boards: Vec<String> = fs::read_dir(board_dir)
|
||||
.expect("failed to read src/board directory")
|
||||
.filter_map(|e| {
|
||||
let p = e.ok()?.path();
|
||||
let stem = p.file_stem()?.to_str()?;
|
||||
// Exclude non-board files: mod.rs, traits.rs, and platform helpers (esp32s3.rs).
|
||||
if p.is_file()
|
||||
&& p.extension()? == "rs"
|
||||
&& !matches!(stem, "mod" | "traits" | "esp32s3")
|
||||
{
|
||||
return Some(p.file_stem()?.to_str()?.to_string());
|
||||
}
|
||||
None
|
||||
})
|
||||
.collect();
|
||||
|
||||
// ── Discover boards vs helpers by content ───────────────────────
|
||||
//
|
||||
// A .rs file in src/board/ that implements LoRaBoard is a board.
|
||||
// Everything else (traits.rs, esp32s3.rs, etc.) is a helper module.
|
||||
// No exclusion lists — detection is based on what the file contains.
|
||||
let mut boards: Vec<String> = Vec::new();
|
||||
|
||||
for entry in fs::read_dir(board_dir).expect("failed to read src/board directory") {
|
||||
let p = match entry {
|
||||
Ok(e) => e.path(),
|
||||
Err(_) => continue,
|
||||
};
|
||||
if !p.is_file() || p.extension().and_then(|e| e.to_str()) != Some("rs") {
|
||||
continue;
|
||||
}
|
||||
let stem = match p.file_stem().and_then(|s| s.to_str()) {
|
||||
Some("mod") => continue, // Skip generated file
|
||||
Some(s) => s.to_string(),
|
||||
None => continue,
|
||||
};
|
||||
let content = match fs::read_to_string(&p) {
|
||||
Ok(c) => c,
|
||||
Err(_) => continue,
|
||||
};
|
||||
if content.contains("LoRaBoard for Board") {
|
||||
boards.push(stem);
|
||||
}
|
||||
}
|
||||
|
||||
boards.sort(); // Deterministic ordering
|
||||
|
||||
// ── Validate exactly one board feature is enabled ───────────────
|
||||
let enabled: Vec<_> = std::env::vars()
|
||||
.filter(|(k, _)| k.starts_with("CARGO_FEATURE_"))
|
||||
.map(|(k, _)| k.replace("CARGO_FEATURE_", "").to_lowercase())
|
||||
@@ -49,18 +65,57 @@ fn main() {
|
||||
),
|
||||
}
|
||||
|
||||
// ── Auto-discover helper modules used by board files ────────────
|
||||
//
|
||||
// Scan each board file for `use super::<name>` to find which helper
|
||||
// modules they depend on. Generate cfg gates so helpers compile only
|
||||
// when a dependent board is selected.
|
||||
let mut helper_boards: HashMap<String, Vec<String>> = HashMap::new();
|
||||
|
||||
for board in &boards {
|
||||
let path = format!("{}/{}.rs", board_dir, board);
|
||||
if let Ok(content) = fs::read_to_string(&path) {
|
||||
for line in content.lines() {
|
||||
let trimmed = line.trim();
|
||||
if let Some(rest) = trimmed.strip_prefix("use super::") {
|
||||
let helper = rest
|
||||
.split(|c: char| !c.is_alphanumeric() && c != '_')
|
||||
.next()
|
||||
.unwrap_or("");
|
||||
if !helper.is_empty() && helper != "traits" {
|
||||
helper_boards
|
||||
.entry(helper.to_string())
|
||||
.or_default()
|
||||
.push(board.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Render board module template ────────────────────────────────
|
||||
let mut env = Environment::new();
|
||||
let template =
|
||||
fs::read_to_string("src/board/mod.rs.j2").expect("missing src/board/mod.rs.j2");
|
||||
let template = fs::read_to_string("src/board/mod.rs.j2").expect("missing src/board/mod.rs.j2");
|
||||
env.add_template("mod", &template)
|
||||
.expect("failed to parse board template");
|
||||
let rendered = env
|
||||
.get_template("mod")
|
||||
.expect("template not found")
|
||||
.render(context!(boards => boards))
|
||||
.render(context!(boards => boards, helpers => helper_boards))
|
||||
.expect("failed to render board module");
|
||||
fs::write("src/board/mod.rs", rendered).expect("failed to write src/board/mod.rs");
|
||||
|
||||
println!("cargo:rerun-if-changed=src/board/mod.rs.j2");
|
||||
println!("cargo:rerun-if-changed={}", 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.
|
||||
let target = std::env::var("TARGET").unwrap_or_default();
|
||||
if !target.starts_with("xtensa") {
|
||||
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");
|
||||
println!("cargo:rustc-link-search={out_dir}");
|
||||
}
|
||||
println!("cargo:rerun-if-changed=ld/nrf52840-memory.x");
|
||||
}
|
||||
@@ -3,14 +3,17 @@ set shell := ["bash", "-c"]
|
||||
# Board definitions: feature target chip
|
||||
# Xtensa targets use nightly cargo + esp rustc + -Zbuild-std=core.
|
||||
# Tool versions are pinned in mise.toml; run `just setup` to install everything.
|
||||
heltec_v3 := "heltec_v3 xtensa-esp32s3-none-elf esp32s3"
|
||||
heltec_v4 := "heltec_v4 xtensa-esp32s3-none-elf esp32s3"
|
||||
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"
|
||||
rak_wisblock_4631 := "rak_wisblock_4631 thumbv7em-none-eabihf nRF52840_xxAA"
|
||||
wio_tracker_l1 := "wio_tracker_l1 thumbv7em-none-eabihf nRF52840_xxAA"
|
||||
|
||||
builds_dir := "builds"
|
||||
version := `sed -n 's/^version = "\(.*\)"/\1/p' Cargo.toml`
|
||||
|
||||
# All known boards
|
||||
boards := "heltec_v3 heltec_v4 rak_wisblock_4631"
|
||||
boards := "heltec_v3 heltec_v3_uart heltec_v4 rak_wisblock_4631 wio_tracker_l1"
|
||||
|
||||
# Install all required tools and toolchains
|
||||
setup:
|
||||
@@ -54,35 +57,42 @@ check-all:
|
||||
|
||||
# Check a single board compiles
|
||||
check board:
|
||||
@just _cargo {{board}} check
|
||||
@just _cargo {{board}} "check --release"
|
||||
|
||||
# Run clippy on a single board
|
||||
clippy board:
|
||||
@just _cargo {{board}} "clippy" "-- -D warnings"
|
||||
@just _cargo {{board}} "clippy --release" "-- -D warnings"
|
||||
|
||||
# Build release firmware and copy to builds/ with a readable name
|
||||
build board profile="release":
|
||||
@just _cargo {{board}} "build --{{profile}}"
|
||||
@just _copy_firmware {{board}} {{profile}}
|
||||
|
||||
# Build and flash a board (espflash for Xtensa, probe-rs for ARM)
|
||||
# Build and flash a board (espflash for Xtensa, UF2 DFU for ARM)
|
||||
flash board:
|
||||
@just _ensure_tools
|
||||
@just build {{board}} release
|
||||
@read -r feat target chip <<< "$(just _info {{board}})"; \
|
||||
case "$target" in \
|
||||
xtensa-*) \
|
||||
port=$(just _find_port "303a:1001" "1209:5741"); \
|
||||
port=$(just _find_port "303a:1001" "1209:5741" "10c4:ea60"); \
|
||||
if [ -n "$port" ]; then \
|
||||
echo "Using $port"; \
|
||||
espflash flash -p "$port" "{{builds_dir}}/donglora-{{board}}-release.elf"; \
|
||||
espflash flash -p "$port" "{{builds_dir}}/donglora-{{board}}-v{{version}}.elf"; \
|
||||
else \
|
||||
echo "No port found, falling back to espflash auto-detection..." >&2; \
|
||||
espflash flash "{{builds_dir}}/donglora-{{board}}-release.elf"; \
|
||||
espflash flash "{{builds_dir}}/donglora-{{board}}-v{{version}}.elf"; \
|
||||
fi ;; \
|
||||
*) probe-rs run --chip $chip "{{builds_dir}}/donglora-{{board}}-release.elf" ;; \
|
||||
*) just _flash_uf2 {{board}} ;; \
|
||||
esac
|
||||
|
||||
# Flash ARM board via debug probe (requires J-Link or similar)
|
||||
flash-probe board:
|
||||
@just _ensure_tools
|
||||
@just build {{board}} release
|
||||
@read -r feat target chip <<< "$(just _info {{board}})"; \
|
||||
probe-rs run --chip $chip "{{builds_dir}}/donglora-{{board}}-v{{version}}.elf"
|
||||
|
||||
# Show binary size for a release build
|
||||
size board:
|
||||
@just _cargo {{board}} "size --release"
|
||||
@@ -162,12 +172,40 @@ _copy_firmware board profile:
|
||||
@mkdir -p {{builds_dir}}
|
||||
@read -r feat target chip <<< "$(just _info {{board}})"; \
|
||||
src="target/$target/{{profile}}/donglora"; \
|
||||
dst="{{builds_dir}}/donglora-{{board}}-{{profile}}"; \
|
||||
if [ -f "$src" ]; then cp "$src" "$dst.elf"; echo "→ $dst.elf"; fi
|
||||
dst="{{builds_dir}}/donglora-{{board}}-v{{version}}"; \
|
||||
if [ -f "$src" ]; then \
|
||||
cp "$src" "$dst.elf"; echo "→ $dst.elf"; \
|
||||
case "$target" in \
|
||||
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; \
|
||||
rm "$dst.hex"; \
|
||||
echo "→ $dst.uf2"; \
|
||||
;; \
|
||||
esac; \
|
||||
fi
|
||||
|
||||
# Copy UF2 to a mounted UF2 bootloader drive
|
||||
[private]
|
||||
_flash_uf2 board:
|
||||
@uf2="{{builds_dir}}/donglora-{{board}}-v{{version}}.uf2"; \
|
||||
if [ ! -f "$uf2" ]; then \
|
||||
echo "error: $uf2 not found — run 'just build {{board}}' first" >&2; exit 1; \
|
||||
fi; \
|
||||
mnt="$(find /run/media -maxdepth 3 -name INFO_UF2.TXT -printf '%h\n' -quit 2>/dev/null)"; \
|
||||
if [ -z "$mnt" ]; then \
|
||||
echo "error: no UF2 drive found — double-tap reset to enter bootloader" >&2; exit 1; \
|
||||
fi; \
|
||||
echo "Copying to $mnt ..."; \
|
||||
cp "$uf2" "$mnt/"; \
|
||||
sync; \
|
||||
echo "Done — board will reboot automatically"
|
||||
|
||||
[private]
|
||||
_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}}" == "rak_wisblock_4631" ]; then echo "{{rak_wisblock_4631}}"; \
|
||||
elif [ "{{name}}" == "wio_tracker_l1" ]; then echo "{{wio_tracker_l1}}"; \
|
||||
else echo "Unknown board: {{name}}" >&2; exit 1; fi
|
||||
@@ -0,0 +1,16 @@
|
||||
/* nRF52840 memory layout — Adafruit UF2 bootloader
|
||||
*
|
||||
* 0x00000 – 0x01000 MBR (Master Boot Record)
|
||||
* 0x01000 – 0xEA000 Application ← we live here
|
||||
* 0xEA000 – 0xF4000 Reserved (app data)
|
||||
* 0xF4000 – 0xFC000 UF2 Bootloader
|
||||
* 0xFF000 – 0x100000 Bootloader settings
|
||||
*
|
||||
* RAM: first 8 bytes reserved for MBR interrupt forwarding.
|
||||
*/
|
||||
|
||||
MEMORY
|
||||
{
|
||||
FLASH : ORIGIN = 0x00001000, LENGTH = 0xE9000
|
||||
RAM : ORIGIN = 0x20000008, LENGTH = 255K
|
||||
}
|
||||
@@ -3,6 +3,7 @@ rust = "1.93"
|
||||
just = "latest"
|
||||
|
||||
"cargo:cargo-binutils" = "latest" # `cargo size`, `cargo objdump`, etc.
|
||||
"cargo:cargo-hex-to-uf2" = "latest" # ELF → UF2 via Intel HEX for nRF52840 DFU
|
||||
"cargo:espup" = "0.16"
|
||||
"cargo:espflash" = "3" # 4.x has broken nix dependency on current stable Rust
|
||||
"cargo:probe-rs-tools" = "0.31"
|
||||
+25
-21
@@ -5,14 +5,14 @@
|
||||
### 1. Create the board file
|
||||
|
||||
Create `src/board/your_board.rs`. Use an existing board as a template:
|
||||
- ESP32-S3 boards: copy `heltec_v4.rs` (uses shared helpers from `esp32s3.rs`)
|
||||
- nRF52840 boards: copy `rak_wisblock_4631.rs`
|
||||
- ESP32-S3 boards: copy `heltec_v4.rs` (uses shared helpers from `board/esp32s3.rs` and `hal/esp32s3.rs`)
|
||||
- nRF52840 boards: copy `rak_wisblock_4631.rs` (uses shared helpers from `hal/nrf52840.rs`)
|
||||
- New MCU family: create a new `hal/<mcu>.rs` with MCU primitives first
|
||||
|
||||
ESP32-S3 boards share common type aliases, peripheral bundles, and init helpers
|
||||
via `src/board/esp32s3.rs`. Your board file only needs the trait impl and
|
||||
board-specific init (Vext GPIO, display reset). If you add a new ESP32-S3 board,
|
||||
add its feature to the `#[cfg(any(...))]` gate on `mod esp32s3` in
|
||||
`src/board/mod.rs.j2`.
|
||||
`build.rs` auto-discovers your board file — any `.rs` file in `src/board/` that
|
||||
implements `LoRaBoard for Board` is automatically detected as a board. Helper
|
||||
modules (like `esp32s3.rs`) are auto-discovered via `use super::<helper>` imports
|
||||
in board files. No template editing or exclusion lists needed.
|
||||
|
||||
### 2. Define concrete types
|
||||
|
||||
@@ -21,7 +21,9 @@ Your board must export these type aliases:
|
||||
```rust
|
||||
pub type RadioDriver = ...; // Must work with lora_phy::LoRa<RadioDriver, Delay>
|
||||
pub type UsbDriver = ...; // Must implement embassy_usb_driver::Driver<'static>
|
||||
// (or UartDriver for UART boards)
|
||||
pub type DisplayI2c = ...; // Must implement embedded_hal_async::i2c::I2c
|
||||
pub type DisplayDriver = ...; // Must implement DrawTarget<Color = BinaryColor>
|
||||
```
|
||||
|
||||
### 3. Define peripheral bundles
|
||||
@@ -32,39 +34,43 @@ pub struct RadioParts {
|
||||
pub delay: embassy_time::Delay,
|
||||
}
|
||||
|
||||
pub struct UsbParts {
|
||||
pub struct UsbParts { // or UartParts for UART boards
|
||||
pub driver: UsbDriver,
|
||||
}
|
||||
|
||||
pub struct DisplayParts {
|
||||
pub i2c: DisplayI2c,
|
||||
pub mac: [u8; 6],
|
||||
}
|
||||
```
|
||||
|
||||
### 4. Implement the LoRaBoard trait
|
||||
|
||||
```rust
|
||||
use super::traits::LoRaBoard;
|
||||
use super::traits::{BoardParts, LoRaBoard};
|
||||
|
||||
impl LoRaBoard for Board {
|
||||
const NAME: &'static str = "Your Board Name";
|
||||
const TX_POWER_RANGE: (i8, i8) = (-9, 22); // check your radio + PA
|
||||
|
||||
type RadioParts = RadioParts;
|
||||
type CommParts = UsbParts; // or UartParts
|
||||
type DisplayParts = DisplayParts;
|
||||
type DisplayDriver = DisplayDriver;
|
||||
|
||||
fn init() -> Self { ... }
|
||||
fn mac_address() -> [u8; 6] { ... } // read from efuse, FICR, etc.
|
||||
fn into_parts(self) -> BoardParts<RadioParts, UsbParts, DisplayParts> {
|
||||
// Initialize buses via hal::, construct drivers, return BoardParts
|
||||
BoardParts { radio, host, display: Some(display_parts), mac: Self::mac_address() }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The compiler will refuse to build if any of these are missing.
|
||||
|
||||
### 5. Implement `Board::into_parts()`
|
||||
### 5. Add display init
|
||||
|
||||
```rust
|
||||
impl Board {
|
||||
pub fn into_parts(self) -> (RadioParts, UsbParts, Option<DisplayParts>) {
|
||||
// Initialize SPI, DMA, radio, USB, I2C
|
||||
// Return peripheral bundles
|
||||
}
|
||||
pub async fn create_display(i2c: DisplayI2c) -> Option<DisplayDriver> {
|
||||
// Construct and initialize your display driver (SSD1306, SH1106, etc.)
|
||||
}
|
||||
```
|
||||
|
||||
@@ -79,7 +85,7 @@ your_board = ["dep:your-hal", ...]
|
||||
|
||||
### 7. Add to justfile
|
||||
|
||||
Add your board's feature/target/chip to the `boards` list at the top of `justfile`.
|
||||
Add your board's feature/target/chip to the board definitions at the top of `justfile`.
|
||||
|
||||
### 8. Build and test
|
||||
|
||||
@@ -88,5 +94,3 @@ just check your_board # Must compile
|
||||
just clippy your_board # Must be clean
|
||||
just build your_board # Produces firmware
|
||||
```
|
||||
|
||||
`build.rs` auto-discovers your `.rs` file — no template editing needed.
|
||||
+42
-94
@@ -1,100 +1,79 @@
|
||||
//! Shared types and initialization helpers for ESP32-S3 boards.
|
||||
//! Shared types and peripheral wiring for ESP32-S3 boards.
|
||||
//!
|
||||
//! All ESP32-S3 boards in this project share the same SPI/DMA, USB OTG,
|
||||
//! and I2C peripheral wiring. This module provides the common type aliases,
|
||||
//! peripheral bundle structs, and init functions. Each board file re-exports
|
||||
//! these types and calls the helpers from its own `Board::into_parts()`.
|
||||
//! Delegates MCU-level init to `hal::esp32s3` and defines peripheral-level
|
||||
//! types (RadioDriver, display) used by ESP32-S3 board files.
|
||||
//! Each board defines its own comm types (UsbParts/UartParts) directly.
|
||||
|
||||
use embassy_embedded_hal::shared_bus::asynch::spi::SpiDevice;
|
||||
use embassy_sync::blocking_mutex::raw::NoopRawMutex;
|
||||
use embassy_time::Delay;
|
||||
use esp_hal::dma::{DmaRxBuf, DmaTxBuf};
|
||||
use esp_hal::gpio::{Input, InputConfig, Level, Output, OutputConfig, Pull};
|
||||
use esp_hal::i2c::master::{Config as I2cConfig, I2c};
|
||||
use esp_hal::spi::master::{Config as SpiConfig, Spi, SpiDmaBus};
|
||||
use esp_hal::spi::Mode as SpiMode;
|
||||
use esp_hal::time::Rate;
|
||||
use esp_hal::timer::timg::TimerGroup;
|
||||
use static_cell::StaticCell;
|
||||
|
||||
use lora_phy::iv::GenericSx126xInterfaceVariant;
|
||||
use lora_phy::sx126x::{self, Sx1262, Sx126x};
|
||||
|
||||
// ── Concrete peripheral types ────────────────────────────────────────
|
||||
use crate::hal::esp32s3 as mcu;
|
||||
|
||||
// ── Re-export MCU types ─────────────────────────────────────────────
|
||||
|
||||
pub use mcu::{I2cBus as DisplayI2c, SpiBus};
|
||||
|
||||
// ── Concrete peripheral types ───────────────────────────────────────
|
||||
|
||||
type SpiBus = SpiDmaBus<'static, esp_hal::Async>;
|
||||
type Nss = Output<'static>;
|
||||
type Iv = GenericSx126xInterfaceVariant<Output<'static>, Input<'static>>;
|
||||
type RadioSpiDevice = SpiDevice<'static, NoopRawMutex, SpiBus, Nss>;
|
||||
pub type RadioDriver = Sx126x<RadioSpiDevice, Iv, Sx1262>;
|
||||
|
||||
pub type UsbDriver = esp_hal::otg_fs::asynch::Driver<'static>;
|
||||
|
||||
pub type DisplayI2c = I2c<'static, esp_hal::Async>;
|
||||
|
||||
// ── Peripheral bundles ───────────────────────────────────────────────
|
||||
// ── Peripheral bundles ──────────────────────────────────────────────
|
||||
|
||||
pub struct RadioParts {
|
||||
pub driver: RadioDriver,
|
||||
pub delay: Delay,
|
||||
}
|
||||
|
||||
pub struct UsbParts {
|
||||
pub driver: UsbDriver,
|
||||
}
|
||||
|
||||
pub struct DisplayParts {
|
||||
pub i2c: DisplayI2c,
|
||||
pub mac: [u8; 6],
|
||||
}
|
||||
|
||||
// ── Shared initialization helpers ───────────────────────────────────
|
||||
// ── Display driver ──────────────────────────────────────────────────
|
||||
|
||||
/// Start the ESP-RTOS scheduler and Embassy time driver.
|
||||
pub fn start_timer(timg0: esp_hal::peripherals::TIMG0<'static>) {
|
||||
let timg0 = TimerGroup::new(timg0);
|
||||
esp_rtos::start(timg0.timer0);
|
||||
pub type DisplayDriver = ssd1306::Ssd1306Async<
|
||||
ssd1306::prelude::I2CInterface<DisplayI2c>,
|
||||
ssd1306::size::DisplaySize128x64,
|
||||
ssd1306::mode::BufferedGraphicsModeAsync<ssd1306::size::DisplaySize128x64>,
|
||||
>;
|
||||
|
||||
/// Construct and initialize an SSD1306 display from raw I2C.
|
||||
pub async fn create_display(i2c: DisplayI2c) -> Option<DisplayDriver> {
|
||||
use ssd1306::mode::DisplayConfigAsync;
|
||||
use ssd1306::prelude::{Brightness, DisplayRotation};
|
||||
use ssd1306::size::DisplaySize128x64;
|
||||
use ssd1306::{I2CDisplayInterface, Ssd1306Async};
|
||||
|
||||
let interface = I2CDisplayInterface::new(i2c);
|
||||
let mut display = Ssd1306Async::new(interface, DisplaySize128x64, DisplayRotation::Rotate0)
|
||||
.into_buffered_graphics_mode();
|
||||
|
||||
embassy_time::Timer::after_millis(100).await;
|
||||
if display.init().await.is_err() {
|
||||
defmt::error!("SSD1306 display init failed");
|
||||
return None;
|
||||
}
|
||||
let _ = display.set_brightness(Brightness::BRIGHTEST).await;
|
||||
Some(display)
|
||||
}
|
||||
|
||||
/// Initialize SPI + DMA + SX1262 radio.
|
||||
///
|
||||
/// All current ESP32-S3 boards use the same SPI/radio pin mapping:
|
||||
/// SCK=GPIO9, MOSI=GPIO10, MISO=GPIO11, NSS=GPIO8,
|
||||
/// Reset=GPIO12, DIO1=GPIO14, BUSY=GPIO13.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
// ── Shared peripheral init helpers ──────────────────────────────────
|
||||
|
||||
/// Construct SX1262 radio from an initialized SPI bus.
|
||||
pub fn init_radio(
|
||||
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>,
|
||||
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>,
|
||||
) -> RadioParts {
|
||||
let spi = Spi::new(
|
||||
spi2,
|
||||
SpiConfig::default()
|
||||
.with_frequency(Rate::from_mhz(1))
|
||||
.with_mode(SpiMode::_0),
|
||||
)
|
||||
.expect("SPI init")
|
||||
.with_sck(sck)
|
||||
.with_mosi(mosi)
|
||||
.with_miso(miso)
|
||||
.with_dma(dma_ch0);
|
||||
|
||||
let (rx_buf, rx_desc, tx_buf, tx_desc) = esp_hal::dma_buffers!(256);
|
||||
let dma_rx = DmaRxBuf::new(rx_desc, rx_buf).expect("DMA RX buf");
|
||||
let dma_tx = DmaTxBuf::new(tx_desc, tx_buf).expect("DMA TX buf");
|
||||
|
||||
let spi = spi.with_buffers(dma_rx, dma_tx).into_async();
|
||||
|
||||
static SPI_BUS: StaticCell<embassy_sync::mutex::Mutex<NoopRawMutex, SpiBus>> =
|
||||
StaticCell::new();
|
||||
let spi_bus = SPI_BUS.init(embassy_sync::mutex::Mutex::new(spi));
|
||||
|
||||
let nss = Output::new(nss_pin, Level::High, OutputConfig::default());
|
||||
let spi_device = SpiDevice::new(spi_bus, nss);
|
||||
|
||||
@@ -107,7 +86,7 @@ pub fn init_radio(
|
||||
|
||||
let sx_config = sx126x::Config {
|
||||
chip: Sx1262,
|
||||
tcxo_ctrl: Some(sx126x::TcxoCtrlVoltage::Ctrl1V7),
|
||||
tcxo_ctrl: Some(sx126x::TcxoCtrlVoltage::Ctrl1V8),
|
||||
use_dcdc: true,
|
||||
rx_boost: false,
|
||||
};
|
||||
@@ -117,34 +96,3 @@ pub fn init_radio(
|
||||
delay: Delay,
|
||||
}
|
||||
}
|
||||
|
||||
/// Initialize USB OTG CDC-ACM driver.
|
||||
pub fn init_usb(
|
||||
usb0: esp_hal::peripherals::USB0<'static>,
|
||||
dp: esp_hal::peripherals::GPIO20<'static>,
|
||||
dm: esp_hal::peripherals::GPIO19<'static>,
|
||||
) -> UsbParts {
|
||||
let usb_inst = esp_hal::otg_fs::Usb::new(usb0, dp, dm);
|
||||
static EP_OUT_BUF: StaticCell<[u8; 1024]> = StaticCell::new();
|
||||
let ep_out_buf = EP_OUT_BUF.init([0u8; 1024]);
|
||||
UsbParts {
|
||||
driver: esp_hal::otg_fs::asynch::Driver::new(
|
||||
usb_inst,
|
||||
ep_out_buf,
|
||||
esp_hal::otg_fs::asynch::Config::default(),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
/// Initialize I2C bus for display.
|
||||
pub fn init_display_i2c(
|
||||
i2c0: esp_hal::peripherals::I2C0<'static>,
|
||||
sda: esp_hal::peripherals::GPIO17<'static>,
|
||||
scl: esp_hal::peripherals::GPIO18<'static>,
|
||||
) -> DisplayI2c {
|
||||
I2c::new(i2c0, I2cConfig::default())
|
||||
.expect("I2C init")
|
||||
.with_sda(sda)
|
||||
.with_scl(scl)
|
||||
.into_async()
|
||||
}
|
||||
+68
-21
@@ -1,12 +1,29 @@
|
||||
//! Heltec WiFi LoRa 32 V3 — native USB (requires hardware mod).
|
||||
//!
|
||||
//! Stock V3 boards route USB through a CP2102 bridge to UART. To use native
|
||||
//! USB CDC-ACM, solder R29 (D+) and R3 (D-) on the PCB and disconnect the
|
||||
//! CP2102 from the USB data lines. See the project README for details.
|
||||
//!
|
||||
//! For stock (unmodified) V3 boards, use the `heltec_v3_uart` feature instead.
|
||||
|
||||
use esp_hal::gpio::{Level, Output, OutputConfig};
|
||||
use static_cell::StaticCell;
|
||||
|
||||
use super::esp32s3;
|
||||
use super::traits::LoRaBoard;
|
||||
use super::traits::{BoardParts, LoRaBoard};
|
||||
use crate::driver::simple_led::SimpleLed;
|
||||
use crate::hal::esp32s3 as mcu;
|
||||
|
||||
#[allow(unused_imports)] // Re-exported for other modules (display, radio, usb tasks)
|
||||
pub use super::esp32s3::{DisplayI2c, DisplayParts, RadioDriver, RadioParts, UsbDriver, UsbParts};
|
||||
pub use super::esp32s3::{
|
||||
create_display, DisplayDriver, DisplayParts, RadioDriver, RadioParts,
|
||||
};
|
||||
|
||||
// ── Board init ───────────────────────────────────────────────────────
|
||||
pub type UsbDriver = esp_hal::otg_fs::asynch::Driver<'static>;
|
||||
pub type LedDriver = SimpleLed<Output<'static>>;
|
||||
|
||||
pub struct UsbParts {
|
||||
pub driver: UsbDriver,
|
||||
}
|
||||
|
||||
pub struct Board {
|
||||
p: esp_hal::peripherals::Peripherals,
|
||||
@@ -16,35 +33,65 @@ impl LoRaBoard for Board {
|
||||
const NAME: &'static str = "Heltec V3";
|
||||
const TX_POWER_RANGE: (i8, i8) = (-9, 22);
|
||||
|
||||
type RadioParts = RadioParts;
|
||||
type CommParts = UsbParts;
|
||||
type DisplayParts = DisplayParts;
|
||||
type DisplayDriver = DisplayDriver;
|
||||
type LedDriver = LedDriver;
|
||||
|
||||
fn init() -> Self {
|
||||
let p = esp_hal::init(esp_hal::Config::default());
|
||||
Self { p }
|
||||
}
|
||||
|
||||
fn mac_address() -> [u8; 6] {
|
||||
esp_hal::efuse::Efuse::mac_address()
|
||||
mcu::mac_address()
|
||||
}
|
||||
}
|
||||
|
||||
impl Board {
|
||||
pub fn into_parts(self) -> (RadioParts, UsbParts, Option<DisplayParts>) {
|
||||
fn into_parts(self) -> BoardParts<RadioParts, UsbParts, DisplayParts, LedDriver> {
|
||||
let p = self.p;
|
||||
|
||||
esp32s3::start_timer(p.TIMG0);
|
||||
mcu::start_timer(p.TIMG0);
|
||||
|
||||
// Vext power: GPIO21, active LOW to enable OLED
|
||||
let vext = Output::new(p.GPIO21, Level::Low, OutputConfig::default());
|
||||
core::mem::forget(vext); // hold pin low permanently; drop would reset it
|
||||
// Vext power: GPIO36, active LOW to enable peripherals
|
||||
let vext = Output::new(p.GPIO36, Level::Low, OutputConfig::default());
|
||||
core::mem::forget(vext);
|
||||
|
||||
let radio = esp32s3::init_radio(
|
||||
p.SPI2, p.DMA_CH0, p.GPIO9, p.GPIO10, p.GPIO11, p.GPIO8, p.GPIO12, p.GPIO14,
|
||||
p.GPIO13,
|
||||
);
|
||||
let usb = esp32s3::init_usb(p.USB0, p.GPIO20, p.GPIO19);
|
||||
let i2c = esp32s3::init_display_i2c(p.I2C0, p.GPIO17, p.GPIO18);
|
||||
let mac = Self::mac_address();
|
||||
let display = Some(DisplayParts { i2c, mac });
|
||||
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);
|
||||
|
||||
(radio, usb, display)
|
||||
// Note: switches internal USB PHY from Serial-JTAG to OTG.
|
||||
let usb_inst = esp_hal::otg_fs::Usb::new(p.USB0, p.GPIO20, p.GPIO19);
|
||||
static EP_OUT_BUF: StaticCell<[u8; 1024]> = StaticCell::new();
|
||||
let ep_out_buf = EP_OUT_BUF.init([0u8; 1024]);
|
||||
let host = UsbParts {
|
||||
driver: esp_hal::otg_fs::asynch::Driver::new(
|
||||
usb_inst,
|
||||
ep_out_buf,
|
||||
esp_hal::otg_fs::asynch::Config::default(),
|
||||
),
|
||||
};
|
||||
|
||||
// SSD1306 display reset: pulse GPIO21 low->high before I2C init
|
||||
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 });
|
||||
|
||||
// White LED on GPIO35
|
||||
let led_pin = Output::new(p.GPIO35, Level::Low, OutputConfig::default());
|
||||
let led = SimpleLed(led_pin);
|
||||
|
||||
BoardParts {
|
||||
radio,
|
||||
host,
|
||||
display,
|
||||
led,
|
||||
mac: Self::mac_address(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
//! Heltec WiFi LoRa 32 V3 — stock UART (via CP2102 bridge).
|
||||
//!
|
||||
//! This is for unmodified V3 boards where USB goes through the CP2102
|
||||
//! bridge to UART0. The board appears as /dev/ttyUSB* (not ttyACM*).
|
||||
//!
|
||||
//! If you've done the hardware mod (solder R29/R3, disconnect CP2102),
|
||||
//! use the `heltec_v3` feature instead for native USB CDC-ACM support.
|
||||
|
||||
use esp_hal::gpio::{Level, Output, OutputConfig};
|
||||
|
||||
use super::esp32s3;
|
||||
use super::traits::{BoardParts, LoRaBoard};
|
||||
use crate::driver::simple_led::SimpleLed;
|
||||
use crate::hal::esp32s3 as mcu;
|
||||
|
||||
pub use super::esp32s3::{
|
||||
create_display, DisplayDriver, DisplayParts, RadioDriver, RadioParts,
|
||||
};
|
||||
|
||||
pub type UartDriver = esp_hal::uart::Uart<'static, esp_hal::Async>;
|
||||
pub type LedDriver = SimpleLed<Output<'static>>;
|
||||
|
||||
pub struct UartParts {
|
||||
pub driver: UartDriver,
|
||||
}
|
||||
|
||||
pub struct Board {
|
||||
p: esp_hal::peripherals::Peripherals,
|
||||
}
|
||||
|
||||
impl LoRaBoard for Board {
|
||||
const NAME: &'static str = "Heltec V3 (UART)";
|
||||
const TX_POWER_RANGE: (i8, i8) = (-9, 22);
|
||||
|
||||
type RadioParts = RadioParts;
|
||||
type CommParts = UartParts;
|
||||
type DisplayParts = DisplayParts;
|
||||
type DisplayDriver = DisplayDriver;
|
||||
type LedDriver = LedDriver;
|
||||
|
||||
fn init() -> Self {
|
||||
let p = esp_hal::init(esp_hal::Config::default());
|
||||
Self { p }
|
||||
}
|
||||
|
||||
fn mac_address() -> [u8; 6] {
|
||||
mcu::mac_address()
|
||||
}
|
||||
|
||||
fn into_parts(self) -> BoardParts<RadioParts, UartParts, DisplayParts, LedDriver> {
|
||||
let p = self.p;
|
||||
|
||||
mcu::start_timer(p.TIMG0);
|
||||
|
||||
// Vext power: GPIO36, active LOW to enable peripherals
|
||||
let vext = Output::new(p.GPIO36, Level::Low, OutputConfig::default());
|
||||
core::mem::forget(vext);
|
||||
|
||||
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);
|
||||
|
||||
use esp_hal::uart::{Config as UartConfig, Uart};
|
||||
let host = UartParts {
|
||||
driver: Uart::new(p.UART0, UartConfig::default())
|
||||
.expect("UART init")
|
||||
.with_tx(p.GPIO43)
|
||||
.with_rx(p.GPIO44)
|
||||
.into_async(),
|
||||
};
|
||||
|
||||
// SSD1306 display reset: pulse GPIO21 low->high before I2C init
|
||||
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 });
|
||||
|
||||
// White LED on GPIO35
|
||||
let led_pin = Output::new(p.GPIO35, Level::Low, OutputConfig::default());
|
||||
let led = SimpleLed(led_pin);
|
||||
|
||||
BoardParts {
|
||||
radio,
|
||||
host,
|
||||
display,
|
||||
led,
|
||||
mac: Self::mac_address(),
|
||||
}
|
||||
}
|
||||
}
|
||||
+50
-21
@@ -1,12 +1,22 @@
|
||||
use esp_hal::gpio::{Level, Output, OutputConfig};
|
||||
use static_cell::StaticCell;
|
||||
|
||||
use super::esp32s3;
|
||||
use super::traits::LoRaBoard;
|
||||
use super::traits::{BoardParts, LoRaBoard};
|
||||
use crate::driver::simple_led::SimpleLed;
|
||||
use crate::hal::esp32s3 as mcu;
|
||||
|
||||
#[allow(unused_imports)] // Re-exported for other modules (display, radio, usb tasks)
|
||||
pub use super::esp32s3::{DisplayI2c, DisplayParts, RadioDriver, RadioParts, UsbDriver, UsbParts};
|
||||
pub use super::esp32s3::{
|
||||
create_display, DisplayDriver, DisplayParts, RadioDriver, RadioParts,
|
||||
};
|
||||
|
||||
// ── Board implementation ────────────────────────────────────────────
|
||||
pub type UsbDriver = esp_hal::otg_fs::asynch::Driver<'static>;
|
||||
|
||||
pub type LedDriver = SimpleLed<Output<'static>>;
|
||||
|
||||
pub struct UsbParts {
|
||||
pub driver: UsbDriver,
|
||||
}
|
||||
|
||||
pub struct Board {
|
||||
p: esp_hal::peripherals::Peripherals,
|
||||
@@ -16,46 +26,65 @@ impl LoRaBoard for Board {
|
||||
const NAME: &'static str = "Heltec V4";
|
||||
const TX_POWER_RANGE: (i8, i8) = (-9, 22);
|
||||
|
||||
type RadioParts = RadioParts;
|
||||
type CommParts = UsbParts;
|
||||
type DisplayParts = DisplayParts;
|
||||
type DisplayDriver = DisplayDriver;
|
||||
type LedDriver = LedDriver;
|
||||
|
||||
fn init() -> Self {
|
||||
let p = esp_hal::init(esp_hal::Config::default());
|
||||
Self { p }
|
||||
}
|
||||
|
||||
fn mac_address() -> [u8; 6] {
|
||||
esp_hal::efuse::Efuse::mac_address()
|
||||
mcu::mac_address()
|
||||
}
|
||||
}
|
||||
|
||||
impl Board {
|
||||
pub fn into_parts(self) -> (RadioParts, UsbParts, Option<DisplayParts>) {
|
||||
fn into_parts(self) -> BoardParts<RadioParts, UsbParts, DisplayParts, LedDriver> {
|
||||
let p = self.p;
|
||||
|
||||
esp32s3::start_timer(p.TIMG0);
|
||||
mcu::start_timer(p.TIMG0);
|
||||
|
||||
// Vext power: GPIO36, active LOW to enable peripherals
|
||||
let vext = Output::new(p.GPIO36, Level::Low, OutputConfig::default());
|
||||
core::mem::forget(vext); // hold pin low permanently; drop would reset it
|
||||
core::mem::forget(vext);
|
||||
|
||||
let radio = esp32s3::init_radio(
|
||||
p.SPI2, p.DMA_CH0, p.GPIO9, p.GPIO10, p.GPIO11, p.GPIO8, p.GPIO12, p.GPIO14,
|
||||
p.GPIO13,
|
||||
);
|
||||
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);
|
||||
|
||||
// Note: switches internal USB PHY from Serial-JTAG to OTG.
|
||||
// espflash --monitor will stop working after this point.
|
||||
let usb = esp32s3::init_usb(p.USB0, p.GPIO20, p.GPIO19);
|
||||
let usb_inst = esp_hal::otg_fs::Usb::new(p.USB0, p.GPIO20, p.GPIO19);
|
||||
static EP_OUT_BUF: StaticCell<[u8; 1024]> = StaticCell::new();
|
||||
let ep_out_buf = EP_OUT_BUF.init([0u8; 1024]);
|
||||
let host = UsbParts {
|
||||
driver: esp_hal::otg_fs::asynch::Driver::new(
|
||||
usb_inst,
|
||||
ep_out_buf,
|
||||
esp_hal::otg_fs::asynch::Config::default(),
|
||||
),
|
||||
};
|
||||
|
||||
// SSD1315 display reset: pulse GPIO21 low->high before I2C init
|
||||
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); // hold reset high permanently
|
||||
core::mem::forget(display_rst);
|
||||
|
||||
let i2c = esp32s3::init_display_i2c(p.I2C0, p.GPIO17, p.GPIO18);
|
||||
let mac = Self::mac_address();
|
||||
let display = Some(DisplayParts { i2c, mac });
|
||||
let i2c = mcu::init_i2c(p.I2C0, p.GPIO17, p.GPIO18);
|
||||
let display = Some(DisplayParts { i2c });
|
||||
|
||||
(radio, usb, display)
|
||||
// Orange LED on GPIO35
|
||||
let led_pin = Output::new(p.GPIO35, Level::Low, OutputConfig::default());
|
||||
let led = SimpleLed(led_pin);
|
||||
|
||||
BoardParts {
|
||||
radio,
|
||||
host,
|
||||
display,
|
||||
led,
|
||||
mac: Self::mac_address(),
|
||||
}
|
||||
}
|
||||
}
|
||||
+4
-3
@@ -2,9 +2,10 @@
|
||||
pub mod traits;
|
||||
pub use traits::*;
|
||||
|
||||
#[cfg(any(feature = "heltec_v3", feature = "heltec_v4"))]
|
||||
mod esp32s3;
|
||||
|
||||
{% for helper, helper_features in helpers|items -%}
|
||||
#[cfg(any({% for f in helper_features %}feature = "{{ f }}"{% if not loop.last %}, {% endif %}{% endfor %}))]
|
||||
mod {{ helper }};
|
||||
{% endfor %}
|
||||
cfg_if::cfg_if! {
|
||||
{%- for board in boards %}
|
||||
{% if loop.first %}if{% else %}else if{% endif %} #[cfg(feature = "{{ board }}")] {
|
||||
|
||||
@@ -1,40 +1,34 @@
|
||||
use embassy_embedded_hal::shared_bus::asynch::spi::SpiDevice;
|
||||
use embassy_nrf::gpio::{Input, Level, Output, OutputDrive, Pull};
|
||||
use embassy_nrf::spim::{self, Spim};
|
||||
use embassy_nrf::twim::{self, Twim};
|
||||
use embassy_nrf::twim;
|
||||
use embassy_nrf::usb::Driver;
|
||||
use embassy_nrf::{bind_interrupts, Peripherals};
|
||||
use embassy_sync::blocking_mutex::raw::NoopRawMutex;
|
||||
use embassy_time::Delay;
|
||||
use static_cell::StaticCell;
|
||||
|
||||
use lora_phy::iv::GenericSx126xInterfaceVariant;
|
||||
use lora_phy::sx126x::{self, Sx1262, Sx126x};
|
||||
|
||||
use super::traits::LoRaBoard;
|
||||
use super::traits::{BoardParts, LoRaBoard};
|
||||
use crate::hal::nrf52840 as mcu;
|
||||
|
||||
bind_interrupts!(struct Irqs {
|
||||
USBD => embassy_nrf::usb::InterruptHandler<embassy_nrf::peripherals::USBD>;
|
||||
SPIM3 => embassy_nrf::spim::InterruptHandler<embassy_nrf::peripherals::SPI3>;
|
||||
TWISPI0 => embassy_nrf::twim::InterruptHandler<embassy_nrf::peripherals::TWISPI0>;
|
||||
});
|
||||
// ── Concrete peripheral types ───────────────────────────────────────
|
||||
|
||||
// ── Concrete peripheral types ────────────────────────────────────────
|
||||
|
||||
type SpiBus = Spim<'static>;
|
||||
type Nss = Output<'static>;
|
||||
type Iv = GenericSx126xInterfaceVariant<Output<'static>, Input<'static>>;
|
||||
type RadioSpiDevice = SpiDevice<'static, NoopRawMutex, SpiBus, Nss>;
|
||||
/// SX126x driver (pre-LoRa construction — LoRa::new is async).
|
||||
type RadioSpiDevice = SpiDevice<'static, NoopRawMutex, mcu::SpiBus, Nss>;
|
||||
pub type RadioDriver = Sx126x<RadioSpiDevice, Iv, Sx1262>;
|
||||
pub type UsbDriver = mcu::UsbNrfDriver;
|
||||
pub type DisplayI2c = mcu::I2cBus;
|
||||
pub type LedDriver = ();
|
||||
|
||||
/// USB driver for CDC-ACM.
|
||||
pub type UsbDriver = Driver<'static, &'static embassy_nrf::usb::vbus_detect::SoftwareVbusDetect>;
|
||||
pub type DisplayDriver = ssd1306::Ssd1306Async<
|
||||
ssd1306::prelude::I2CInterface<DisplayI2c>,
|
||||
ssd1306::size::DisplaySize128x64,
|
||||
ssd1306::mode::BufferedGraphicsModeAsync<ssd1306::size::DisplaySize128x64>,
|
||||
>;
|
||||
|
||||
/// I2C bus for an optional SSD1306 OLED.
|
||||
pub type DisplayI2c = Twim<'static>;
|
||||
|
||||
// ── Peripheral bundles ───────────────────────────────────────────────
|
||||
// ── Peripheral bundles ──────────────────────────────────────────────
|
||||
|
||||
pub struct RadioParts {
|
||||
pub driver: RadioDriver,
|
||||
@@ -47,72 +41,76 @@ pub struct UsbParts {
|
||||
|
||||
pub struct DisplayParts {
|
||||
pub i2c: DisplayI2c,
|
||||
pub mac: [u8; 6],
|
||||
}
|
||||
|
||||
// ── Board init ───────────────────────────────────────────────────────
|
||||
// ── Display init ────────────────────────────────────────────────────
|
||||
|
||||
pub async fn create_display(i2c: DisplayI2c) -> Option<DisplayDriver> {
|
||||
use ssd1306::mode::DisplayConfigAsync;
|
||||
use ssd1306::prelude::{Brightness, DisplayRotation};
|
||||
use ssd1306::size::DisplaySize128x64;
|
||||
use ssd1306::{I2CDisplayInterface, Ssd1306Async};
|
||||
|
||||
let interface = I2CDisplayInterface::new(i2c);
|
||||
let mut display = Ssd1306Async::new(interface, DisplaySize128x64, DisplayRotation::Rotate0)
|
||||
.into_buffered_graphics_mode();
|
||||
|
||||
embassy_time::Timer::after_millis(100).await;
|
||||
if display.init().await.is_err() {
|
||||
defmt::error!("SSD1306 display init failed");
|
||||
return None;
|
||||
}
|
||||
let _ = display.set_brightness(Brightness::BRIGHTEST).await;
|
||||
Some(display)
|
||||
}
|
||||
|
||||
// ── Board init ──────────────────────────────────────────────────────
|
||||
|
||||
pub struct Board {
|
||||
p: Peripherals,
|
||||
p: embassy_nrf::Peripherals,
|
||||
}
|
||||
|
||||
impl super::traits::LoRaBoard for Board {
|
||||
impl LoRaBoard for Board {
|
||||
const NAME: &'static str = "RAK WisBlock 4631";
|
||||
const TX_POWER_RANGE: (i8, i8) = (-9, 22);
|
||||
|
||||
type RadioParts = RadioParts;
|
||||
type CommParts = UsbParts;
|
||||
type DisplayParts = DisplayParts;
|
||||
type DisplayDriver = DisplayDriver;
|
||||
type LedDriver = ();
|
||||
|
||||
fn init() -> Self {
|
||||
let p = embassy_nrf::init(Default::default());
|
||||
Self { p }
|
||||
}
|
||||
|
||||
fn mac_address() -> [u8; 6] {
|
||||
// SAFETY: 0x10000000 is the nRF52840 FICR (Factory Information Configuration
|
||||
// Registers) base address. Offsets 0x0A4 and 0x0A8 are the DEVICEADDR[0] and
|
||||
// DEVICEADDR[1] registers containing the factory-programmed device address.
|
||||
// read_volatile is correct for memory-mapped hardware registers.
|
||||
unsafe {
|
||||
let ficr = 0x10000000 as *const u32;
|
||||
let addr0 = core::ptr::read_volatile(ficr.byte_add(0x0A4));
|
||||
let addr1 = core::ptr::read_volatile(ficr.byte_add(0x0A8));
|
||||
[
|
||||
addr0 as u8,
|
||||
(addr0 >> 8) as u8,
|
||||
(addr0 >> 16) as u8,
|
||||
(addr0 >> 24) as u8,
|
||||
addr1 as u8,
|
||||
(addr1 >> 8) as u8,
|
||||
]
|
||||
}
|
||||
mcu::mac_address()
|
||||
}
|
||||
}
|
||||
|
||||
impl Board {
|
||||
|
||||
pub fn into_parts(self) -> (RadioParts, UsbParts, Option<DisplayParts>) {
|
||||
fn into_parts(self) -> BoardParts<RadioParts, UsbParts, DisplayParts, ()> {
|
||||
let p = self.p;
|
||||
|
||||
// ── SPI bus for SX1262 ───────────────────────────────────
|
||||
// ── SPI bus for SX1262 ──────────────────────────────────
|
||||
let mut spi_cfg = spim::Config::default();
|
||||
spi_cfg.frequency = spim::Frequency::M1;
|
||||
let spi = Spim::new(p.SPI3, Irqs, p.P1_11, p.P1_13, p.P1_12, spi_cfg);
|
||||
|
||||
static SPI_BUS: StaticCell<embassy_sync::mutex::Mutex<NoopRawMutex, SpiBus>> =
|
||||
StaticCell::new();
|
||||
let spi_bus = SPI_BUS.init(embassy_sync::mutex::Mutex::new(spi));
|
||||
let spi = Spim::new(p.SPI3, mcu::Irqs, p.P1_11, p.P1_13, p.P1_12, spi_cfg);
|
||||
let spi_bus = mcu::share_spi_bus(spi);
|
||||
|
||||
let nss = Output::new(p.P1_10, Level::High, OutputDrive::Standard);
|
||||
let spi_device = SpiDevice::new(spi_bus, nss);
|
||||
|
||||
// ── SX1262 control pins ──────────────────────────────────
|
||||
let reset = Output::new(p.P1_06, Level::High, OutputDrive::Standard);
|
||||
let dio1 = Input::new(p.P1_15, Pull::Down);
|
||||
let busy = Input::new(p.P1_14, Pull::Down);
|
||||
|
||||
let iv = GenericSx126xInterfaceVariant::new(reset, dio1, busy, None, None).expect("SX1262 interface init");
|
||||
let iv = GenericSx126xInterfaceVariant::new(reset, dio1, busy, None, None)
|
||||
.expect("SX1262 interface init");
|
||||
|
||||
let sx_config = sx126x::Config {
|
||||
chip: Sx1262,
|
||||
tcxo_ctrl: Some(sx126x::TcxoCtrlVoltage::Ctrl1V7),
|
||||
tcxo_ctrl: Some(sx126x::TcxoCtrlVoltage::Ctrl1V8),
|
||||
use_dcdc: true,
|
||||
rx_boost: false,
|
||||
};
|
||||
@@ -122,24 +120,25 @@ impl Board {
|
||||
delay: Delay,
|
||||
};
|
||||
|
||||
// ── USB ──────────────────────────────────────────────────
|
||||
static VBUS: StaticCell<embassy_nrf::usb::vbus_detect::SoftwareVbusDetect> =
|
||||
StaticCell::new();
|
||||
let vbus = VBUS.init(embassy_nrf::usb::vbus_detect::SoftwareVbusDetect::new(true, false));
|
||||
let usb = UsbParts {
|
||||
driver: Driver::new(p.USBD, Irqs, vbus),
|
||||
// ── USB ─────────────────────────────────────────────────
|
||||
let vbus = mcu::alloc_vbus_detect(true, false);
|
||||
let host = UsbParts {
|
||||
driver: Driver::new(p.USBD, mcu::Irqs, vbus),
|
||||
};
|
||||
|
||||
// ── Display (optional RAK1921 SSD1306 OLED on I2C) ──────
|
||||
// Always provide the I2C bus; display_task detects presence
|
||||
// via SSD1306 init (fails gracefully if no display attached).
|
||||
static TWIM_BUF: StaticCell<[u8; 256]> = StaticCell::new();
|
||||
let twim_buf = TWIM_BUF.init([0u8; 256]);
|
||||
let i2c_cfg = twim::Config::default();
|
||||
let i2c = Twim::new(p.TWISPI0, Irqs, p.P0_13, p.P0_14, i2c_cfg, twim_buf);
|
||||
let mac = Self::mac_address();
|
||||
let display = Some(DisplayParts { i2c, mac });
|
||||
// ── 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,
|
||||
);
|
||||
let display = Some(DisplayParts { i2c });
|
||||
|
||||
(radio, usb, display)
|
||||
BoardParts {
|
||||
radio,
|
||||
host,
|
||||
display,
|
||||
led: (),
|
||||
mac: Self::mac_address(),
|
||||
}
|
||||
}
|
||||
}
|
||||
+43
-13
@@ -1,31 +1,61 @@
|
||||
//! Board abstraction trait.
|
||||
//!
|
||||
//! Every board must implement [`LoRaBoard`]. The compiler enforces completeness —
|
||||
//! missing constants or methods are compile errors, not runtime surprises.
|
||||
//!
|
||||
//! Board modules also export concrete types (`RadioParts`, `UsbParts`,
|
||||
//! `DisplayParts`, `RadioDriver`, `UsbDriver`, `DisplayI2c`) that the tasks
|
||||
//! use directly. The trait verifies metadata and initialization; the types
|
||||
//! verify hardware compatibility.
|
||||
//! Every board must implement [`LoRaBoard`] with concrete associated types
|
||||
//! for its radio, host communication, display, and LED peripherals. The
|
||||
//! compiler enforces completeness — missing types or methods are compile errors.
|
||||
//!
|
||||
//! See `src/board/PORTING.md` for a step-by-step guide to adding a new board.
|
||||
|
||||
use embedded_graphics::draw_target::DrawTarget;
|
||||
use embedded_graphics::pixelcolor::BinaryColor;
|
||||
|
||||
/// Async RGB LED control. Boards without an LED use `()` which is a no-op.
|
||||
pub trait RgbLed {
|
||||
fn set_rgb(&mut self, r: u8, g: u8, b: u8) -> impl core::future::Future<Output = ()>;
|
||||
}
|
||||
|
||||
impl RgbLed for () {
|
||||
async fn set_rgb(&mut self, _r: u8, _g: u8, _b: u8) {}
|
||||
}
|
||||
|
||||
/// Compile-time contract for a DongLoRa board.
|
||||
///
|
||||
/// Every board's `Board` struct must implement this trait. It verifies that
|
||||
/// the board provides all required metadata and initialization methods.
|
||||
/// The tasks use the board's concrete types directly (not the trait's
|
||||
/// associated types) because Embassy tasks must be concrete.
|
||||
pub trait LoRaBoard {
|
||||
pub trait LoRaBoard: Sized {
|
||||
/// Human-readable board name (shown on display splash screen).
|
||||
const NAME: &'static str;
|
||||
|
||||
/// TX power range in dBm (min, max) for this board's radio + PA.
|
||||
const TX_POWER_RANGE: (i8, i8);
|
||||
|
||||
/// Radio peripheral bundle (driver + delay).
|
||||
type RadioParts;
|
||||
|
||||
/// Host communication peripheral bundle (USB or UART driver).
|
||||
type CommParts;
|
||||
|
||||
/// Display peripheral bundle (I2C bus for display init).
|
||||
type DisplayParts;
|
||||
|
||||
/// Concrete display driver type (must implement DrawTarget for rendering).
|
||||
type DisplayDriver: DrawTarget<Color = BinaryColor>;
|
||||
|
||||
/// RGB LED driver. Boards without an LED use `()`.
|
||||
type LedDriver: RgbLed;
|
||||
|
||||
/// Initialize the board hardware.
|
||||
fn init() -> Self;
|
||||
|
||||
/// Read the board's unique hardware address (MAC, device ID, etc.).
|
||||
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>;
|
||||
}
|
||||
|
||||
/// Peripheral bundles for each firmware task, produced by [`LoRaBoard::into_parts`].
|
||||
pub struct BoardParts<R, C, D, L> {
|
||||
pub radio: R,
|
||||
pub host: C,
|
||||
pub display: Option<D>,
|
||||
pub led: L,
|
||||
pub mac: [u8; 6],
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
use embassy_embedded_hal::shared_bus::asynch::spi::SpiDevice;
|
||||
use embassy_nrf::gpio::{Input, Level, Output, OutputDrive, Pull};
|
||||
use embassy_nrf::spim::{self, Spim};
|
||||
use embassy_nrf::twim;
|
||||
use embassy_nrf::usb::Driver;
|
||||
use embassy_sync::blocking_mutex::raw::NoopRawMutex;
|
||||
use embassy_time::Delay;
|
||||
|
||||
use lora_phy::iv::GenericSx126xInterfaceVariant;
|
||||
use lora_phy::sx126x::{self, Sx1262, Sx126x};
|
||||
|
||||
use super::traits::{BoardParts, LoRaBoard};
|
||||
use crate::hal::nrf52840 as mcu;
|
||||
|
||||
// ── Concrete peripheral types ───────────────────────────────────────
|
||||
|
||||
type Nss = Output<'static>;
|
||||
type Iv = GenericSx126xInterfaceVariant<Output<'static>, Input<'static>>;
|
||||
type RadioSpiDevice = SpiDevice<'static, NoopRawMutex, mcu::SpiBus, Nss>;
|
||||
pub type RadioDriver = Sx126x<RadioSpiDevice, Iv, Sx1262>;
|
||||
pub type UsbDriver = mcu::UsbNrfDriver;
|
||||
pub type DisplayI2c = mcu::I2cBus;
|
||||
|
||||
pub type LedDriver = crate::driver::simple_led::SimpleLed<Output<'static>>;
|
||||
|
||||
pub type DisplayDriver = crate::driver::sh1106::Sh1106<DisplayI2c>;
|
||||
|
||||
// ── Peripheral bundles ──────────────────────────────────────────────
|
||||
|
||||
pub struct RadioParts {
|
||||
pub driver: RadioDriver,
|
||||
pub delay: Delay,
|
||||
}
|
||||
|
||||
pub struct UsbParts {
|
||||
pub driver: UsbDriver,
|
||||
}
|
||||
|
||||
pub struct DisplayParts {
|
||||
pub i2c: DisplayI2c,
|
||||
}
|
||||
|
||||
// ── Display init ────────────────────────────────────────────────────
|
||||
|
||||
pub async fn create_display(i2c: DisplayI2c) -> Option<DisplayDriver> {
|
||||
let mut display = crate::driver::sh1106::Sh1106::new(i2c, 0x3D);
|
||||
|
||||
embassy_time::Timer::after_millis(100).await;
|
||||
if display.init().await.is_err() {
|
||||
defmt::error!("SH1106 display init failed");
|
||||
return None;
|
||||
}
|
||||
let _ = display.set_brightness(0xFF).await;
|
||||
Some(display)
|
||||
}
|
||||
|
||||
// ── Board init ──────────────────────────────────────────────────────
|
||||
|
||||
pub struct Board {
|
||||
p: embassy_nrf::Peripherals,
|
||||
}
|
||||
|
||||
impl LoRaBoard for Board {
|
||||
const NAME: &'static str = "Wio Tracker L1";
|
||||
const TX_POWER_RANGE: (i8, i8) = (-9, 22);
|
||||
|
||||
type RadioParts = RadioParts;
|
||||
type CommParts = UsbParts;
|
||||
type DisplayParts = DisplayParts;
|
||||
type DisplayDriver = DisplayDriver;
|
||||
type LedDriver = LedDriver;
|
||||
|
||||
fn init() -> Self {
|
||||
let mut config = embassy_nrf::config::Config::default();
|
||||
config.hfclk_source = embassy_nrf::config::HfclkSource::ExternalXtal;
|
||||
config.lfclk_source = embassy_nrf::config::LfclkSource::ExternalXtal;
|
||||
let p = embassy_nrf::init(config);
|
||||
Self { p }
|
||||
}
|
||||
|
||||
fn mac_address() -> [u8; 6] {
|
||||
mcu::mac_address()
|
||||
}
|
||||
|
||||
fn into_parts(self) -> BoardParts<RadioParts, UsbParts, DisplayParts, LedDriver> {
|
||||
let p = self.p;
|
||||
|
||||
// ── SPI bus for SX1262 ──────────────────────────────────
|
||||
let mut spi_cfg = spim::Config::default();
|
||||
spi_cfg.frequency = spim::Frequency::M1;
|
||||
let spi = Spim::new(p.SPI3, mcu::Irqs, p.P0_30, p.P0_03, p.P0_28, spi_cfg);
|
||||
let spi_bus = mcu::share_spi_bus(spi);
|
||||
|
||||
let nss = Output::new(p.P1_14, Level::High, OutputDrive::Standard);
|
||||
let spi_device = SpiDevice::new(spi_bus, nss);
|
||||
|
||||
let reset = Output::new(p.P1_07, Level::High, OutputDrive::Standard);
|
||||
let dio1 = Input::new(p.P0_07, Pull::Down);
|
||||
let busy = Input::new(p.P1_10, Pull::Down);
|
||||
|
||||
// RF switch: RXEN on P1.08 (GPIO, toggled by lora_phy), TX via DIO2 (SX1262 internal).
|
||||
let rx_enable = Output::new(p.P1_08, Level::Low, OutputDrive::Standard);
|
||||
let iv = GenericSx126xInterfaceVariant::new(reset, dio1, busy, Some(rx_enable), None)
|
||||
.expect("SX1262 interface init");
|
||||
|
||||
let sx_config = sx126x::Config {
|
||||
chip: Sx1262,
|
||||
tcxo_ctrl: Some(sx126x::TcxoCtrlVoltage::Ctrl1V8),
|
||||
use_dcdc: true,
|
||||
rx_boost: false,
|
||||
};
|
||||
|
||||
let radio = RadioParts {
|
||||
driver: Sx126x::new(spi_device, iv, sx_config),
|
||||
delay: Delay,
|
||||
};
|
||||
|
||||
// ── USB ─────────────────────────────────────────────────
|
||||
let vbus = mcu::alloc_vbus_detect(true, true);
|
||||
let host = UsbParts {
|
||||
driver: Driver::new(p.USBD, mcu::Irqs, vbus),
|
||||
};
|
||||
|
||||
// ── BAT_CTL: P0.04, active HIGH — enables power to OLED and battery sense
|
||||
let bat_ctl = Output::new(p.P0_04, Level::High, OutputDrive::Standard);
|
||||
core::mem::forget(bat_ctl);
|
||||
|
||||
// ── Display (SH1106 OLED on I2C) ────────────────────────
|
||||
let twim_buf = mcu::alloc_i2c_buffer();
|
||||
let i2c = embassy_nrf::twim::Twim::new(
|
||||
p.TWISPI0, mcu::Irqs, p.P0_06, p.P0_05, twim::Config::default(), twim_buf,
|
||||
);
|
||||
let display = Some(DisplayParts { i2c });
|
||||
|
||||
// Green LED on P1.01
|
||||
let led_pin = Output::new(p.P1_01, Level::Low, OutputDrive::Standard);
|
||||
let led = crate::driver::simple_led::SimpleLed(led_pin);
|
||||
|
||||
BoardParts {
|
||||
radio,
|
||||
host,
|
||||
display,
|
||||
led,
|
||||
mac: Self::mac_address(),
|
||||
}
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -12,7 +12,7 @@ use embassy_sync::watch::Watch;
|
||||
|
||||
use crate::protocol::{Command, RadioConfig, Response};
|
||||
|
||||
/// Display commands routed from usb_task to display_task.
|
||||
/// Display commands routed from host_task to display_task.
|
||||
#[derive(Debug, Clone, Copy, defmt::Format)]
|
||||
pub enum DisplayCommand {
|
||||
On,
|
||||
|
||||
+639
@@ -0,0 +1,639 @@
|
||||
//! OLED display dashboard: radio status, sparkline, splash screen.
|
||||
|
||||
use embassy_executor::task;
|
||||
use embassy_futures::select::{select3, Either3};
|
||||
use embassy_time::Timer;
|
||||
use embedded_graphics::draw_target::DrawTarget;
|
||||
use embedded_graphics::pixelcolor::BinaryColor;
|
||||
|
||||
use crate::board::{Board, DisplayParts, LedDriver, LoRaBoard, RgbLed};
|
||||
use crate::channel::{DisplayCommand, DisplayCommandChannel, RadioState, RadioStatus, StatusWatch};
|
||||
|
||||
use render::RSSI_HISTORY_LEN;
|
||||
|
||||
const BOARD_NAME: &str = Board::NAME;
|
||||
|
||||
/// Duration per sparkline slot. 128 slots * 1s = ~2 minutes of history.
|
||||
const SPARK_SLOT_MS: u64 = 1000;
|
||||
|
||||
/// Sentinel: no packet received in this slot. Below SX1262 sensitivity
|
||||
/// floor (-120 dBm), so it cannot be confused with a real RSSI value.
|
||||
const NO_SIGNAL: i16 = -121;
|
||||
|
||||
struct DisplayState {
|
||||
rssi_history: [i16; RSSI_HISTORY_LEN],
|
||||
tx_history: [bool; RSSI_HISTORY_LEN],
|
||||
rssi_count: usize,
|
||||
current_slot_rssi: i16,
|
||||
current_slot_tx: bool,
|
||||
display_on: bool,
|
||||
disconnected: bool,
|
||||
last_status: RadioStatus,
|
||||
}
|
||||
|
||||
impl DisplayState {
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
rssi_history: [NO_SIGNAL; RSSI_HISTORY_LEN],
|
||||
tx_history: [false; RSSI_HISTORY_LEN],
|
||||
rssi_count: 0,
|
||||
current_slot_rssi: NO_SIGNAL,
|
||||
current_slot_tx: false,
|
||||
display_on: true,
|
||||
disconnected: false,
|
||||
last_status: RadioStatus::default(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Record an RSSI sample in the current time slot (keep best).
|
||||
fn record_rssi(&mut self, rssi: i16) {
|
||||
if self.current_slot_rssi == NO_SIGNAL || rssi > self.current_slot_rssi {
|
||||
self.current_slot_rssi = rssi;
|
||||
}
|
||||
}
|
||||
|
||||
/// Mark the current time slot as having a transmit.
|
||||
fn record_tx(&mut self) {
|
||||
self.current_slot_tx = true;
|
||||
}
|
||||
|
||||
/// Advance to the next time slot, committing the current slot's data.
|
||||
fn advance_slot(&mut self) {
|
||||
let idx = self.rssi_count % RSSI_HISTORY_LEN;
|
||||
self.rssi_history[idx] = self.current_slot_rssi;
|
||||
self.tx_history[idx] = self.current_slot_tx;
|
||||
self.rssi_count += 1;
|
||||
self.current_slot_rssi = NO_SIGNAL;
|
||||
self.current_slot_tx = false;
|
||||
}
|
||||
|
||||
/// Whether the active dashboard should be shown (RX or TX mode).
|
||||
fn is_active(&self) -> bool {
|
||||
matches!(
|
||||
self.last_status.state,
|
||||
RadioState::Receiving | RadioState::Transmitting
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// Map SNR (dB) to LED brightness (4..64). Stronger signal = brighter.
|
||||
fn snr_brightness(snr: Option<i16>) -> u8 {
|
||||
let snr = snr.unwrap_or(-10);
|
||||
let clamped = snr.clamp(-20, 15) as i32;
|
||||
// -20 → 4, +15 → 64
|
||||
((clamped + 20) * 60 / 35 + 4) as u8
|
||||
}
|
||||
|
||||
/// Render the appropriate screen for the current state into the display buffer.
|
||||
fn render_current(
|
||||
display: &mut impl DrawTarget<Color = BinaryColor>,
|
||||
state: &DisplayState,
|
||||
board: &render::BoardInfo<'_>,
|
||||
) {
|
||||
if state.is_active() {
|
||||
render::dashboard(
|
||||
display,
|
||||
&state.last_status,
|
||||
&state.rssi_history,
|
||||
&state.tx_history,
|
||||
state.rssi_count,
|
||||
state.current_slot_rssi,
|
||||
state.current_slot_tx,
|
||||
board,
|
||||
);
|
||||
} else {
|
||||
render::splash(display, board);
|
||||
}
|
||||
}
|
||||
|
||||
#[task]
|
||||
pub async fn display_task(
|
||||
parts: DisplayParts,
|
||||
mut led: LedDriver,
|
||||
status: &'static StatusWatch,
|
||||
display_commands: &'static DisplayCommandChannel,
|
||||
) {
|
||||
// Format MAC address as "XX:XX:XX:XX:XX:XX"
|
||||
let mut mac_str: heapless::String<18> = heapless::String::new();
|
||||
let m = Board::mac_address();
|
||||
let _ = core::fmt::Write::write_fmt(
|
||||
&mut mac_str,
|
||||
format_args!(
|
||||
"{:02X}:{:02X}:{:02X}:{:02X}:{:02X}:{:02X}",
|
||||
m[0], m[1], m[2], m[3], m[4], m[5]
|
||||
),
|
||||
);
|
||||
|
||||
let board_info = render::BoardInfo {
|
||||
name: BOARD_NAME,
|
||||
version: env!("CARGO_PKG_VERSION"),
|
||||
mac: &mac_str,
|
||||
};
|
||||
|
||||
let Some(mut display) = crate::board::create_display(parts.i2c).await else {
|
||||
defmt::error!("display init failed, running LED-only loop");
|
||||
loop {
|
||||
match display_commands.receive().await {
|
||||
DisplayCommand::Reset | DisplayCommand::Off => {
|
||||
led.set_rgb(0, 0, 0).await;
|
||||
}
|
||||
DisplayCommand::On => {}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let mut state = DisplayState::new();
|
||||
let Some(mut receiver) = status.receiver() else {
|
||||
defmt::error!("no watch receiver available for display");
|
||||
return;
|
||||
};
|
||||
|
||||
// Show splash/waiting screen
|
||||
render::splash(&mut display, &board_info);
|
||||
let _ = display.flush().await;
|
||||
|
||||
loop {
|
||||
match select3(
|
||||
receiver.changed(),
|
||||
display_commands.receive(),
|
||||
Timer::after_millis(SPARK_SLOT_MS),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Either3::First(radio_status) => {
|
||||
if state.disconnected {
|
||||
continue;
|
||||
}
|
||||
let rx_packet = radio_status.rx_count != state.last_status.rx_count;
|
||||
let tx_packet = radio_status.tx_count != state.last_status.tx_count;
|
||||
|
||||
if let Some(rssi) = radio_status.last_rssi {
|
||||
if rx_packet {
|
||||
state.record_rssi(rssi);
|
||||
}
|
||||
}
|
||||
if tx_packet {
|
||||
state.record_tx();
|
||||
}
|
||||
let last_snr = radio_status.last_snr;
|
||||
state.last_status = radio_status;
|
||||
|
||||
if state.display_on {
|
||||
render_current(&mut display, &state, &board_info);
|
||||
let _ = display.flush().await;
|
||||
|
||||
// Brief LED blink: red on TX, green scaled by SNR on RX
|
||||
if tx_packet {
|
||||
led.set_rgb(32, 0, 0).await;
|
||||
Timer::after_millis(50).await;
|
||||
led.set_rgb(0, 0, 0).await;
|
||||
} else if rx_packet {
|
||||
let b = snr_brightness(last_snr);
|
||||
led.set_rgb(0, b, 0).await;
|
||||
Timer::after_millis(50).await;
|
||||
led.set_rgb(0, 0, 0).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
Either3::Second(cmd) => match cmd {
|
||||
DisplayCommand::Off => {
|
||||
state.disconnected = false;
|
||||
state.display_on = false;
|
||||
render::blank(&mut display);
|
||||
let _ = display.flush().await;
|
||||
led.set_rgb(0, 0, 0).await;
|
||||
}
|
||||
DisplayCommand::On => {
|
||||
state.disconnected = false;
|
||||
state.display_on = true;
|
||||
if let Some(s) = receiver.try_get() {
|
||||
state.last_status = s;
|
||||
}
|
||||
render_current(&mut display, &state, &board_info);
|
||||
let _ = display.flush().await;
|
||||
}
|
||||
DisplayCommand::Reset => {
|
||||
state = DisplayState::new();
|
||||
state.disconnected = true;
|
||||
render::splash(&mut display, &board_info);
|
||||
let _ = display.flush().await;
|
||||
led.set_rgb(0, 0, 0).await;
|
||||
}
|
||||
},
|
||||
Either3::Third(()) => {
|
||||
// Timer tick: advance sparkline slot
|
||||
state.advance_slot();
|
||||
if state.display_on && state.is_active() {
|
||||
render_current(&mut display, &state, &board_info);
|
||||
let _ = display.flush().await;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Rendering ───────────────────────────────────────────────────────
|
||||
|
||||
mod render {
|
||||
use core::fmt::Write;
|
||||
|
||||
use embedded_graphics::mono_font::ascii::{FONT_6X10, FONT_9X15_BOLD};
|
||||
use embedded_graphics::mono_font::MonoTextStyle;
|
||||
use embedded_graphics::pixelcolor::BinaryColor;
|
||||
use embedded_graphics::prelude::*;
|
||||
use embedded_graphics::primitives::{Line, PrimitiveStyle, Rectangle};
|
||||
use embedded_graphics::text::renderer::CharacterStyle;
|
||||
use embedded_graphics::text::{Alignment, Text};
|
||||
use heapless::String;
|
||||
|
||||
use crate::channel::{RadioState, RadioStatus};
|
||||
use crate::protocol::Bandwidth;
|
||||
|
||||
/// RSSI ring buffer length — supports displays up to 256px wide (2px per bar).
|
||||
pub const RSSI_HISTORY_LEN: usize = 128;
|
||||
|
||||
// Font metrics (FONT_6X10)
|
||||
const CHAR_W: i32 = 6;
|
||||
const FONT_H: i32 = 10;
|
||||
|
||||
// Mode box: 2 chars + 1px padding each side
|
||||
const MODE_BOX_W: i32 = 2 * CHAR_W + 2; // 14px
|
||||
|
||||
// RSSI mapping range (dBm)
|
||||
const RSSI_MIN: i16 = -120;
|
||||
const RSSI_MAX: i16 = 0;
|
||||
|
||||
/// Static board identity info for display rendering.
|
||||
pub struct BoardInfo<'a> {
|
||||
pub name: &'a str,
|
||||
pub version: &'a str,
|
||||
pub mac: &'a str,
|
||||
}
|
||||
|
||||
/// Render the active dashboard (shown when radio is in RX or TX mode).
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn dashboard(
|
||||
target: &mut impl DrawTarget<Color = BinaryColor>,
|
||||
status: &RadioStatus,
|
||||
rssi_history: &[i16; RSSI_HISTORY_LEN],
|
||||
tx_history: &[bool; RSSI_HISTORY_LEN],
|
||||
rssi_count: usize,
|
||||
current_slot_rssi: i16,
|
||||
current_slot_tx: bool,
|
||||
board: &BoardInfo,
|
||||
) {
|
||||
let bb = target.bounding_box();
|
||||
let w = bb.size.width as i32;
|
||||
let h = bb.size.height as i32;
|
||||
let title_x = MODE_BOX_W + 1;
|
||||
let title_w = w - title_x;
|
||||
let header_h = 2 * FONT_H;
|
||||
let sep1_y = header_h + 3; // 3px gap below header text (clears descenders)
|
||||
let info_y = sep1_y + 1; // 1px line, info text tight below separator
|
||||
let sep2_y = info_y + 2 * FONT_H + 3; // 3px gap below info text (clears descenders)
|
||||
let spark_top = sep2_y + 2; // 1px line + 1px gap above graph
|
||||
let spark_h = h - spark_top;
|
||||
let visible_bars = ((w / 2) as usize).min(RSSI_HISTORY_LEN);
|
||||
|
||||
let _ = target.clear(BinaryColor::Off);
|
||||
let style = MonoTextStyle::new(&FONT_6X10, BinaryColor::On);
|
||||
let mut buf: String<32> = String::new();
|
||||
|
||||
// ── Mode box (left column, 2 rows tall) ─────────────────────────
|
||||
let fill = PrimitiveStyle::with_fill(BinaryColor::On);
|
||||
let mut inv_style = MonoTextStyle::new(&FONT_6X10, BinaryColor::Off);
|
||||
inv_style.set_background_color(Some(BinaryColor::On));
|
||||
|
||||
match status.state {
|
||||
RadioState::Receiving => {
|
||||
// "RX" inverted on row 0
|
||||
Rectangle::new(
|
||||
Point::new(0, 0),
|
||||
Size::new(MODE_BOX_W as u32, FONT_H as u32),
|
||||
)
|
||||
.into_styled(fill)
|
||||
.draw(target)
|
||||
.ok();
|
||||
Text::new("RX", Point::new(1, FONT_H - 1), inv_style)
|
||||
.draw(target)
|
||||
.ok();
|
||||
}
|
||||
RadioState::Transmitting => {
|
||||
// "TX" inverted on row 1
|
||||
Rectangle::new(
|
||||
Point::new(0, FONT_H),
|
||||
Size::new(MODE_BOX_W as u32, FONT_H as u32),
|
||||
)
|
||||
.into_styled(fill)
|
||||
.draw(target)
|
||||
.ok();
|
||||
Text::new("TX", Point::new(1, 2 * FONT_H - 1), inv_style)
|
||||
.draw(target)
|
||||
.ok();
|
||||
}
|
||||
RadioState::Idle => {} // Not shown on active screen
|
||||
}
|
||||
|
||||
// Vertical separator between mode box and title box
|
||||
Line::new(Point::new(MODE_BOX_W, 0), Point::new(MODE_BOX_W, sep1_y))
|
||||
.into_styled(PrimitiveStyle::with_stroke(BinaryColor::On, 1))
|
||||
.draw(target)
|
||||
.ok();
|
||||
|
||||
// ── Title box (right of mode box) ───────────────────────────────
|
||||
let title_center_x = title_x + title_w / 2;
|
||||
|
||||
// Row 0: "DongLoRa v{version}"
|
||||
buf.clear();
|
||||
let _ = write!(buf, "DongLoRa v{}", board.version);
|
||||
Text::with_alignment(
|
||||
&buf,
|
||||
Point::new(title_center_x, FONT_H - 1),
|
||||
style,
|
||||
Alignment::Center,
|
||||
)
|
||||
.draw(target)
|
||||
.ok();
|
||||
|
||||
// Row 1: radio settings "{freq}/{bw}/{sf}/{cr}"
|
||||
if let Some(cfg) = status.config {
|
||||
buf.clear();
|
||||
let freq_mhz = cfg.freq_hz / 1_000_000;
|
||||
let freq_khz = (cfg.freq_hz % 1_000_000) / 1_000;
|
||||
let bw_str = match cfg.bw {
|
||||
Bandwidth::Khz7 => "7.8",
|
||||
Bandwidth::Khz10 => "10.4",
|
||||
Bandwidth::Khz15 => "15.6",
|
||||
Bandwidth::Khz20 => "20.8",
|
||||
Bandwidth::Khz31 => "31.2",
|
||||
Bandwidth::Khz41 => "41.7",
|
||||
Bandwidth::Khz62 => "62.5",
|
||||
Bandwidth::Khz125 => "125",
|
||||
Bandwidth::Khz250 => "250",
|
||||
Bandwidth::Khz500 => "500",
|
||||
};
|
||||
let _ = write!(
|
||||
buf,
|
||||
"{}.{:03}/{}/{}/{}",
|
||||
freq_mhz, freq_khz, bw_str, cfg.sf, cfg.cr
|
||||
);
|
||||
Text::with_alignment(
|
||||
&buf,
|
||||
Point::new(title_center_x, 2 * FONT_H - 1),
|
||||
style,
|
||||
Alignment::Center,
|
||||
)
|
||||
.draw(target)
|
||||
.ok();
|
||||
}
|
||||
|
||||
// ── First separator line ────────────────────────────────────────
|
||||
Line::new(Point::new(0, sep1_y), Point::new(w - 1, sep1_y))
|
||||
.into_styled(PrimitiveStyle::with_stroke(BinaryColor::On, 1))
|
||||
.draw(target)
|
||||
.ok();
|
||||
|
||||
// ── Info rows (centered) ────────────────────────────────────────
|
||||
let center_x = w / 2;
|
||||
|
||||
// Row 2: packet counters
|
||||
buf.clear();
|
||||
let _ = write!(
|
||||
buf,
|
||||
"RX:{} TX:{}",
|
||||
compact_count(status.rx_count),
|
||||
compact_count(status.tx_count)
|
||||
);
|
||||
Text::with_alignment(
|
||||
&buf,
|
||||
Point::new(center_x, info_y + FONT_H - 1),
|
||||
style,
|
||||
Alignment::Center,
|
||||
)
|
||||
.draw(target)
|
||||
.ok();
|
||||
|
||||
// Row 3: RSSI + SNR
|
||||
buf.clear();
|
||||
match (status.last_rssi, status.last_snr) {
|
||||
(Some(rssi), Some(snr)) => {
|
||||
let _ = write!(buf, "RSSI:{}dBm SNR:{}dB", rssi, snr);
|
||||
}
|
||||
(Some(rssi), None) => {
|
||||
let _ = write!(buf, "RSSI:{}dBm", rssi);
|
||||
}
|
||||
_ => {
|
||||
let _ = write!(buf, "No signal");
|
||||
}
|
||||
}
|
||||
Text::with_alignment(
|
||||
&buf,
|
||||
Point::new(center_x, info_y + 2 * FONT_H - 1),
|
||||
style,
|
||||
Alignment::Center,
|
||||
)
|
||||
.draw(target)
|
||||
.ok();
|
||||
|
||||
// ── Second separator line ───────────────────────────────────────
|
||||
Line::new(Point::new(0, sep2_y), Point::new(w - 1, sep2_y))
|
||||
.into_styled(PrimitiveStyle::with_stroke(BinaryColor::On, 1))
|
||||
.draw(target)
|
||||
.ok();
|
||||
|
||||
// ── Sparkline bar graph ─────────────────────────────────────────
|
||||
if spark_h > 0 {
|
||||
rssi_sparkline(
|
||||
target,
|
||||
rssi_history,
|
||||
tx_history,
|
||||
rssi_count,
|
||||
current_slot_rssi,
|
||||
current_slot_tx,
|
||||
spark_top,
|
||||
spark_h,
|
||||
visible_bars,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Render the splash/waiting screen (shown when idle or no config).
|
||||
pub fn splash(target: &mut impl DrawTarget<Color = BinaryColor>, board: &BoardInfo) {
|
||||
let bb = target.bounding_box();
|
||||
let w = bb.size.width as i32;
|
||||
|
||||
let _ = target.clear(BinaryColor::Off);
|
||||
let style = MonoTextStyle::new(&FONT_6X10, BinaryColor::On);
|
||||
let title_style = MonoTextStyle::new(&FONT_9X15_BOLD, BinaryColor::On);
|
||||
let mut buf: String<32> = String::new();
|
||||
|
||||
let center_x = w / 2;
|
||||
|
||||
// Row 1: "DongLoRa" bold left, version small right
|
||||
Text::new("DongLoRa", Point::new(4, 15), title_style)
|
||||
.draw(target)
|
||||
.ok();
|
||||
buf.clear();
|
||||
let _ = write!(buf, "v{}", board.version);
|
||||
Text::with_alignment(&buf, Point::new(w - 2, 15), style, Alignment::Right)
|
||||
.draw(target)
|
||||
.ok();
|
||||
|
||||
// Row 2: board name
|
||||
Text::with_alignment(
|
||||
board.name,
|
||||
Point::new(center_x, 28),
|
||||
style,
|
||||
Alignment::Center,
|
||||
)
|
||||
.draw(target)
|
||||
.ok();
|
||||
|
||||
// Row 3: MAC address
|
||||
Text::with_alignment(
|
||||
board.mac,
|
||||
Point::new(center_x, 41),
|
||||
style,
|
||||
Alignment::Center,
|
||||
)
|
||||
.draw(target)
|
||||
.ok();
|
||||
|
||||
// Row 4: status
|
||||
Text::with_alignment(
|
||||
"Waiting for host...",
|
||||
Point::new(center_x, 54),
|
||||
style,
|
||||
Alignment::Center,
|
||||
)
|
||||
.draw(target)
|
||||
.ok();
|
||||
}
|
||||
|
||||
/// Format a u32 count compactly for display.
|
||||
fn compact_count(n: u32) -> String<10> {
|
||||
let mut s: String<10> = String::new();
|
||||
if n > 9_999_999 {
|
||||
let _ = write!(s, "{}M", n / 1_000_000);
|
||||
} else if n > 999_999 {
|
||||
let _ = write!(s, "{}k", n / 1_000);
|
||||
} else {
|
||||
let _ = write!(s, "{}", n);
|
||||
}
|
||||
s
|
||||
}
|
||||
|
||||
/// Render the RSSI history as a bar-chart sparkline.
|
||||
///
|
||||
/// The current (uncommitted) slot is rendered at the rightmost position
|
||||
/// so that incoming packets appear on the graph immediately.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn rssi_sparkline(
|
||||
target: &mut impl DrawTarget<Color = BinaryColor>,
|
||||
history: &[i16; RSSI_HISTORY_LEN],
|
||||
tx_history: &[bool; RSSI_HISTORY_LEN],
|
||||
count: usize,
|
||||
current_rssi: i16,
|
||||
current_tx: bool,
|
||||
spark_top: i32,
|
||||
spark_h: i32,
|
||||
visible_bars: usize,
|
||||
) {
|
||||
let live = current_rssi > RSSI_MIN || current_tx;
|
||||
let committed = count.min(RSSI_HISTORY_LEN);
|
||||
let hist_slots = if live {
|
||||
committed.min(visible_bars.saturating_sub(1))
|
||||
} else {
|
||||
committed.min(visible_bars)
|
||||
};
|
||||
let total = hist_slots + if live { 1 } else { 0 };
|
||||
|
||||
if total == 0 {
|
||||
return;
|
||||
}
|
||||
|
||||
let fill = PrimitiveStyle::with_fill(BinaryColor::On);
|
||||
|
||||
// Draw committed history
|
||||
for i in 0..hist_slots {
|
||||
let idx = if count <= RSSI_HISTORY_LEN {
|
||||
// Buffer hasn't wrapped: slots occupy indices 0..committed-1.
|
||||
// Skip oldest entries that don't fit on screen.
|
||||
let effective_bars = if live { visible_bars - 1 } else { visible_bars };
|
||||
i + committed.saturating_sub(effective_bars)
|
||||
} else {
|
||||
let start = count - RSSI_HISTORY_LEN;
|
||||
let skip =
|
||||
RSSI_HISTORY_LEN.saturating_sub(if live { visible_bars - 1 } else { visible_bars });
|
||||
(start + skip + i) % RSSI_HISTORY_LEN
|
||||
};
|
||||
let is_tx = tx_history[idx];
|
||||
let rssi = history[idx];
|
||||
|
||||
if let Some(bar_h) = bar_height(rssi, is_tx, spark_h) {
|
||||
let x = (visible_bars - total + i) as i32 * 2;
|
||||
draw_bar(target, x, bar_h, is_tx, spark_top, spark_h, &fill);
|
||||
}
|
||||
}
|
||||
|
||||
// Draw live (current) bar at the rightmost position
|
||||
if live {
|
||||
if let Some(bar_h) = bar_height(current_rssi, current_tx, spark_h) {
|
||||
let x = (visible_bars - 1) as i32 * 2;
|
||||
draw_bar(target, x, bar_h, current_tx, spark_top, spark_h, &fill);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Compute the pixel height for a sparkline bar, or None to skip.
|
||||
fn bar_height(rssi: i16, is_tx: bool, spark_h: i32) -> Option<i32> {
|
||||
let h = if rssi <= RSSI_MIN {
|
||||
if is_tx {
|
||||
spark_h / 3
|
||||
} else {
|
||||
return None;
|
||||
}
|
||||
} else {
|
||||
let clamped = rssi.clamp(RSSI_MIN, RSSI_MAX);
|
||||
((clamped - RSSI_MIN) as i32 * spark_h) / (RSSI_MAX - RSSI_MIN) as i32
|
||||
};
|
||||
if h == 0 {
|
||||
None
|
||||
} else {
|
||||
Some(h)
|
||||
}
|
||||
}
|
||||
|
||||
/// Draw a single sparkline bar (solid for RX, dotted for TX).
|
||||
fn draw_bar(
|
||||
target: &mut impl DrawTarget<Color = BinaryColor>,
|
||||
x: i32,
|
||||
bar_h: i32,
|
||||
is_tx: bool,
|
||||
spark_top: i32,
|
||||
spark_h: i32,
|
||||
fill: &PrimitiveStyle<BinaryColor>,
|
||||
) {
|
||||
let y = spark_top + spark_h - bar_h;
|
||||
if is_tx {
|
||||
for row in 0..bar_h {
|
||||
if row % 2 == 0 {
|
||||
Rectangle::new(Point::new(x, y + row), Size::new(2, 1))
|
||||
.into_styled(*fill)
|
||||
.draw(target)
|
||||
.ok();
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Rectangle::new(Point::new(x, y), Size::new(2, bar_h as u32))
|
||||
.into_styled(*fill)
|
||||
.draw(target)
|
||||
.ok();
|
||||
}
|
||||
}
|
||||
|
||||
/// Clear the display (display-off state).
|
||||
pub fn blank(target: &mut impl DrawTarget<Color = BinaryColor>) {
|
||||
let _ = target.clear(BinaryColor::Off);
|
||||
}
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
//! OLED display driver with radio status dashboard.
|
||||
|
||||
mod render;
|
||||
mod task;
|
||||
pub use task::display_task;
|
||||
@@ -1,398 +0,0 @@
|
||||
use core::fmt::Write;
|
||||
|
||||
use embedded_graphics::mono_font::ascii::{FONT_6X10, FONT_9X15_BOLD};
|
||||
use embedded_graphics::mono_font::MonoTextStyle;
|
||||
use embedded_graphics::pixelcolor::BinaryColor;
|
||||
use embedded_graphics::prelude::*;
|
||||
use embedded_graphics::primitives::{Line, PrimitiveStyle, Rectangle};
|
||||
use embedded_graphics::text::renderer::CharacterStyle;
|
||||
use embedded_graphics::text::{Alignment, Text};
|
||||
use heapless::String;
|
||||
|
||||
use crate::channel::{RadioState, RadioStatus};
|
||||
use crate::protocol::Bandwidth;
|
||||
|
||||
/// RSSI ring buffer length — supports displays up to 256px wide (2px per bar).
|
||||
pub const RSSI_HISTORY_LEN: usize = 128;
|
||||
|
||||
// Font metrics (FONT_6X10)
|
||||
const CHAR_W: i32 = 6;
|
||||
const FONT_H: i32 = 10;
|
||||
|
||||
// Mode box: 2 chars + 1px padding each side
|
||||
const MODE_BOX_W: i32 = 2 * CHAR_W + 2; // 14px
|
||||
|
||||
// RSSI mapping range (dBm)
|
||||
const RSSI_MIN: i16 = -120;
|
||||
const RSSI_MAX: i16 = 0;
|
||||
|
||||
/// Static board identity info for display rendering.
|
||||
pub struct BoardInfo<'a> {
|
||||
pub name: &'a str,
|
||||
pub version: &'a str,
|
||||
pub mac: &'a str,
|
||||
}
|
||||
|
||||
/// Render the active dashboard (shown when radio is in RX or TX mode).
|
||||
pub fn dashboard(
|
||||
target: &mut impl DrawTarget<Color = BinaryColor>,
|
||||
status: &RadioStatus,
|
||||
rssi_history: &[i16; RSSI_HISTORY_LEN],
|
||||
tx_history: &[bool; RSSI_HISTORY_LEN],
|
||||
rssi_count: usize,
|
||||
current_slot_rssi: i16,
|
||||
current_slot_tx: bool,
|
||||
board: &BoardInfo,
|
||||
) {
|
||||
let bb = target.bounding_box();
|
||||
let w = bb.size.width as i32;
|
||||
let h = bb.size.height as i32;
|
||||
let title_x = MODE_BOX_W + 1;
|
||||
let title_w = w - title_x;
|
||||
let header_h = 2 * FONT_H;
|
||||
let sep1_y = header_h + 3; // 3px gap below header text (clears descenders)
|
||||
let info_y = sep1_y + 1; // 1px line, info text tight below separator
|
||||
let sep2_y = info_y + 2 * FONT_H + 3; // 3px gap below info text (clears descenders)
|
||||
let spark_top = sep2_y + 2; // 1px line + 1px gap above graph
|
||||
let spark_h = h - spark_top;
|
||||
let visible_bars = ((w / 2) as usize).min(RSSI_HISTORY_LEN);
|
||||
|
||||
let _ = target.clear(BinaryColor::Off);
|
||||
let style = MonoTextStyle::new(&FONT_6X10, BinaryColor::On);
|
||||
let mut buf: String<32> = String::new();
|
||||
|
||||
// ── Mode box (left column, 2 rows tall) ─────────────────────────
|
||||
let fill = PrimitiveStyle::with_fill(BinaryColor::On);
|
||||
let mut inv_style = MonoTextStyle::new(&FONT_6X10, BinaryColor::Off);
|
||||
inv_style.set_background_color(Some(BinaryColor::On));
|
||||
|
||||
match status.state {
|
||||
RadioState::Receiving => {
|
||||
// "RX" inverted on row 0
|
||||
Rectangle::new(Point::new(0, 0), Size::new(MODE_BOX_W as u32, FONT_H as u32))
|
||||
.into_styled(fill)
|
||||
.draw(target)
|
||||
.ok();
|
||||
Text::new("RX", Point::new(1, FONT_H - 1), inv_style)
|
||||
.draw(target)
|
||||
.ok();
|
||||
}
|
||||
RadioState::Transmitting => {
|
||||
// "TX" inverted on row 1
|
||||
Rectangle::new(
|
||||
Point::new(0, FONT_H),
|
||||
Size::new(MODE_BOX_W as u32, FONT_H as u32),
|
||||
)
|
||||
.into_styled(fill)
|
||||
.draw(target)
|
||||
.ok();
|
||||
Text::new("TX", Point::new(1, 2 * FONT_H - 1), inv_style)
|
||||
.draw(target)
|
||||
.ok();
|
||||
}
|
||||
RadioState::Idle => {} // Not shown on active screen
|
||||
}
|
||||
|
||||
// Vertical separator between mode box and title box
|
||||
Line::new(Point::new(MODE_BOX_W, 0), Point::new(MODE_BOX_W, sep1_y))
|
||||
.into_styled(PrimitiveStyle::with_stroke(BinaryColor::On, 1))
|
||||
.draw(target)
|
||||
.ok();
|
||||
|
||||
// ── Title box (right of mode box) ───────────────────────────────
|
||||
let title_center_x = title_x + title_w / 2;
|
||||
|
||||
// Row 0: "DongLoRa v{version}"
|
||||
buf.clear();
|
||||
let _ = write!(buf, "DongLoRa v{}", board.version);
|
||||
Text::with_alignment(
|
||||
&buf,
|
||||
Point::new(title_center_x, FONT_H - 1),
|
||||
style,
|
||||
Alignment::Center,
|
||||
)
|
||||
.draw(target)
|
||||
.ok();
|
||||
|
||||
// Row 1: radio settings "{freq}/{bw}/{sf}/{cr}"
|
||||
if let Some(cfg) = status.config {
|
||||
buf.clear();
|
||||
let freq_mhz = cfg.freq_hz / 1_000_000;
|
||||
let freq_khz = (cfg.freq_hz % 1_000_000) / 1_000;
|
||||
let bw_str = match cfg.bw {
|
||||
Bandwidth::Khz7 => "7.8",
|
||||
Bandwidth::Khz10 => "10.4",
|
||||
Bandwidth::Khz15 => "15.6",
|
||||
Bandwidth::Khz20 => "20.8",
|
||||
Bandwidth::Khz31 => "31.2",
|
||||
Bandwidth::Khz41 => "41.7",
|
||||
Bandwidth::Khz62 => "62.5",
|
||||
Bandwidth::Khz125 => "125",
|
||||
Bandwidth::Khz250 => "250",
|
||||
Bandwidth::Khz500 => "500",
|
||||
};
|
||||
let _ = write!(
|
||||
buf,
|
||||
"{}.{:03}/{}/{}/{}",
|
||||
freq_mhz, freq_khz, bw_str, cfg.sf, cfg.cr
|
||||
);
|
||||
Text::with_alignment(
|
||||
&buf,
|
||||
Point::new(title_center_x, 2 * FONT_H - 1),
|
||||
style,
|
||||
Alignment::Center,
|
||||
)
|
||||
.draw(target)
|
||||
.ok();
|
||||
}
|
||||
|
||||
// ── First separator line ────────────────────────────────────────
|
||||
Line::new(Point::new(0, sep1_y), Point::new(w - 1, sep1_y))
|
||||
.into_styled(PrimitiveStyle::with_stroke(BinaryColor::On, 1))
|
||||
.draw(target)
|
||||
.ok();
|
||||
|
||||
// ── Info rows (centered) ────────────────────────────────────────
|
||||
let center_x = w / 2;
|
||||
|
||||
// Row 2: packet counters
|
||||
buf.clear();
|
||||
let _ = write!(
|
||||
buf,
|
||||
"RX:{} TX:{}",
|
||||
compact_count(status.rx_count),
|
||||
compact_count(status.tx_count)
|
||||
);
|
||||
Text::with_alignment(
|
||||
&buf,
|
||||
Point::new(center_x, info_y + FONT_H - 1),
|
||||
style,
|
||||
Alignment::Center,
|
||||
)
|
||||
.draw(target)
|
||||
.ok();
|
||||
|
||||
// Row 3: RSSI + SNR
|
||||
buf.clear();
|
||||
match (status.last_rssi, status.last_snr) {
|
||||
(Some(rssi), Some(snr)) => {
|
||||
let _ = write!(buf, "RSSI:{}dBm SNR:{}dB", rssi, snr);
|
||||
}
|
||||
(Some(rssi), None) => {
|
||||
let _ = write!(buf, "RSSI:{}dBm", rssi);
|
||||
}
|
||||
_ => {
|
||||
let _ = write!(buf, "No signal");
|
||||
}
|
||||
}
|
||||
Text::with_alignment(
|
||||
&buf,
|
||||
Point::new(center_x, info_y + 2 * FONT_H - 1),
|
||||
style,
|
||||
Alignment::Center,
|
||||
)
|
||||
.draw(target)
|
||||
.ok();
|
||||
|
||||
// ── Second separator line ───────────────────────────────────────
|
||||
Line::new(Point::new(0, sep2_y), Point::new(w - 1, sep2_y))
|
||||
.into_styled(PrimitiveStyle::with_stroke(BinaryColor::On, 1))
|
||||
.draw(target)
|
||||
.ok();
|
||||
|
||||
// ── Sparkline bar graph ─────────────────────────────────────────
|
||||
if spark_h > 0 {
|
||||
rssi_sparkline(
|
||||
target,
|
||||
rssi_history,
|
||||
tx_history,
|
||||
rssi_count,
|
||||
current_slot_rssi,
|
||||
current_slot_tx,
|
||||
spark_top,
|
||||
spark_h,
|
||||
visible_bars,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Render the splash/waiting screen (shown when idle or no config).
|
||||
pub fn splash(
|
||||
target: &mut impl DrawTarget<Color = BinaryColor>,
|
||||
board: &BoardInfo,
|
||||
) {
|
||||
let bb = target.bounding_box();
|
||||
let w = bb.size.width as i32;
|
||||
|
||||
let _ = target.clear(BinaryColor::Off);
|
||||
let style = MonoTextStyle::new(&FONT_6X10, BinaryColor::On);
|
||||
let title_style = MonoTextStyle::new(&FONT_9X15_BOLD, BinaryColor::On);
|
||||
let mut buf: String<32> = String::new();
|
||||
|
||||
let center_x = w / 2;
|
||||
|
||||
// Row 1: "DongLoRa" bold left, version small right
|
||||
Text::new("DongLoRa", Point::new(4, 15), title_style)
|
||||
.draw(target)
|
||||
.ok();
|
||||
buf.clear();
|
||||
let _ = write!(buf, "v{}", board.version);
|
||||
Text::with_alignment(&buf, Point::new(w - 2, 15), style, Alignment::Right)
|
||||
.draw(target)
|
||||
.ok();
|
||||
|
||||
// Row 2: board name
|
||||
Text::with_alignment(
|
||||
board.name,
|
||||
Point::new(center_x, 28),
|
||||
style,
|
||||
Alignment::Center,
|
||||
)
|
||||
.draw(target)
|
||||
.ok();
|
||||
|
||||
// Row 3: MAC address
|
||||
Text::with_alignment(
|
||||
board.mac,
|
||||
Point::new(center_x, 41),
|
||||
style,
|
||||
Alignment::Center,
|
||||
)
|
||||
.draw(target)
|
||||
.ok();
|
||||
|
||||
// Row 4: status
|
||||
Text::with_alignment(
|
||||
"Waiting for host...",
|
||||
Point::new(center_x, 54),
|
||||
style,
|
||||
Alignment::Center,
|
||||
)
|
||||
.draw(target)
|
||||
.ok();
|
||||
}
|
||||
|
||||
/// Format a u32 count compactly for display.
|
||||
fn compact_count(n: u32) -> String<10> {
|
||||
let mut s: String<10> = String::new();
|
||||
if n > 9_999_999 {
|
||||
let _ = write!(s, "{}M", n / 1_000_000);
|
||||
} else if n > 999_999 {
|
||||
let _ = write!(s, "{}k", n / 1_000);
|
||||
} else {
|
||||
let _ = write!(s, "{}", n);
|
||||
}
|
||||
s
|
||||
}
|
||||
|
||||
/// Render the RSSI history as a bar-chart sparkline.
|
||||
///
|
||||
/// The current (uncommitted) slot is rendered at the rightmost position
|
||||
/// so that incoming packets appear on the graph immediately.
|
||||
fn rssi_sparkline(
|
||||
target: &mut impl DrawTarget<Color = BinaryColor>,
|
||||
history: &[i16; RSSI_HISTORY_LEN],
|
||||
tx_history: &[bool; RSSI_HISTORY_LEN],
|
||||
count: usize,
|
||||
current_rssi: i16,
|
||||
current_tx: bool,
|
||||
spark_top: i32,
|
||||
spark_h: i32,
|
||||
visible_bars: usize,
|
||||
) {
|
||||
let live = current_rssi > RSSI_MIN || current_tx;
|
||||
let committed = count.min(RSSI_HISTORY_LEN);
|
||||
let hist_slots = if live {
|
||||
committed.min(visible_bars.saturating_sub(1))
|
||||
} else {
|
||||
committed.min(visible_bars)
|
||||
};
|
||||
let total = hist_slots + if live { 1 } else { 0 };
|
||||
|
||||
if total == 0 {
|
||||
return;
|
||||
}
|
||||
|
||||
let fill = PrimitiveStyle::with_fill(BinaryColor::On);
|
||||
|
||||
// Draw committed history
|
||||
for i in 0..hist_slots {
|
||||
let idx = if count <= RSSI_HISTORY_LEN {
|
||||
// Buffer hasn't wrapped: slots occupy indices 0..committed-1.
|
||||
// Skip oldest entries that don't fit on screen.
|
||||
let effective_bars = if live { visible_bars - 1 } else { visible_bars };
|
||||
i + committed.saturating_sub(effective_bars)
|
||||
} else {
|
||||
let start = count - RSSI_HISTORY_LEN;
|
||||
let skip = RSSI_HISTORY_LEN.saturating_sub(if live {
|
||||
visible_bars - 1
|
||||
} else {
|
||||
visible_bars
|
||||
});
|
||||
(start + skip + i) % RSSI_HISTORY_LEN
|
||||
};
|
||||
let is_tx = tx_history[idx];
|
||||
let rssi = history[idx];
|
||||
|
||||
if let Some(bar_h) = bar_height(rssi, is_tx, spark_h) {
|
||||
let x = (visible_bars - total + i) as i32 * 2;
|
||||
draw_bar(target, x, bar_h, is_tx, spark_top, spark_h, &fill);
|
||||
}
|
||||
}
|
||||
|
||||
// Draw live (current) bar at the rightmost position
|
||||
if live {
|
||||
if let Some(bar_h) = bar_height(current_rssi, current_tx, spark_h) {
|
||||
let x = (visible_bars - 1) as i32 * 2;
|
||||
draw_bar(target, x, bar_h, current_tx, spark_top, spark_h, &fill);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Compute the pixel height for a sparkline bar, or None to skip.
|
||||
fn bar_height(rssi: i16, is_tx: bool, spark_h: i32) -> Option<i32> {
|
||||
let h = if rssi <= RSSI_MIN {
|
||||
if is_tx {
|
||||
spark_h / 3
|
||||
} else {
|
||||
return None;
|
||||
}
|
||||
} else {
|
||||
let clamped = rssi.clamp(RSSI_MIN, RSSI_MAX);
|
||||
((clamped - RSSI_MIN) as i32 * spark_h) / (RSSI_MAX - RSSI_MIN) as i32
|
||||
};
|
||||
if h == 0 { None } else { Some(h) }
|
||||
}
|
||||
|
||||
/// Draw a single sparkline bar (solid for RX, dotted for TX).
|
||||
fn draw_bar(
|
||||
target: &mut impl DrawTarget<Color = BinaryColor>,
|
||||
x: i32,
|
||||
bar_h: i32,
|
||||
is_tx: bool,
|
||||
spark_top: i32,
|
||||
spark_h: i32,
|
||||
fill: &PrimitiveStyle<BinaryColor>,
|
||||
) {
|
||||
let y = spark_top + spark_h - bar_h;
|
||||
if is_tx {
|
||||
for row in 0..bar_h {
|
||||
if row % 2 == 0 {
|
||||
Rectangle::new(Point::new(x, y + row), Size::new(2, 1))
|
||||
.into_styled(*fill)
|
||||
.draw(target)
|
||||
.ok();
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Rectangle::new(Point::new(x, y), Size::new(2, bar_h as u32))
|
||||
.into_styled(*fill)
|
||||
.draw(target)
|
||||
.ok();
|
||||
}
|
||||
}
|
||||
|
||||
/// Clear the display (display-off state).
|
||||
pub fn blank(target: &mut impl DrawTarget<Color = BinaryColor>) {
|
||||
let _ = target.clear(BinaryColor::Off);
|
||||
}
|
||||
@@ -1,239 +0,0 @@
|
||||
use embassy_executor::task;
|
||||
use embassy_futures::select::{select3, Either3};
|
||||
use embassy_time::Timer;
|
||||
use ssd1306::mode::DisplayConfigAsync;
|
||||
use ssd1306::prelude::DisplayRotation;
|
||||
use ssd1306::size::DisplaySize128x64;
|
||||
use ssd1306::{I2CDisplayInterface, Ssd1306Async};
|
||||
|
||||
use crate::board::DisplayParts;
|
||||
use crate::channel::{
|
||||
DisplayCommand, DisplayCommandChannel, RadioState, RadioStatus, StatusWatch,
|
||||
};
|
||||
|
||||
use crate::board::{Board, LoRaBoard};
|
||||
|
||||
use super::render::{self, RSSI_HISTORY_LEN};
|
||||
|
||||
const BOARD_NAME: &str = Board::NAME;
|
||||
|
||||
/// Duration per sparkline slot. 128 slots * 1s = ~2 minutes of history.
|
||||
const SPARK_SLOT_MS: u64 = 1000;
|
||||
|
||||
/// Sentinel: no packet received in this slot. Below SX1262 sensitivity
|
||||
/// floor (-120 dBm), so it cannot be confused with a real RSSI value.
|
||||
const NO_SIGNAL: i16 = -121;
|
||||
|
||||
struct DisplayState {
|
||||
rssi_history: [i16; RSSI_HISTORY_LEN],
|
||||
tx_history: [bool; RSSI_HISTORY_LEN],
|
||||
rssi_count: usize,
|
||||
current_slot_rssi: i16,
|
||||
current_slot_tx: bool,
|
||||
display_on: bool,
|
||||
disconnected: bool,
|
||||
last_status: RadioStatus,
|
||||
}
|
||||
|
||||
impl DisplayState {
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
rssi_history: [NO_SIGNAL; RSSI_HISTORY_LEN],
|
||||
tx_history: [false; RSSI_HISTORY_LEN],
|
||||
rssi_count: 0,
|
||||
current_slot_rssi: NO_SIGNAL,
|
||||
current_slot_tx: false,
|
||||
display_on: true,
|
||||
disconnected: true,
|
||||
last_status: RadioStatus::default(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Record an RSSI sample in the current time slot (keep best).
|
||||
fn record_rssi(&mut self, rssi: i16) {
|
||||
if self.current_slot_rssi == NO_SIGNAL || rssi > self.current_slot_rssi {
|
||||
self.current_slot_rssi = rssi;
|
||||
}
|
||||
}
|
||||
|
||||
/// Mark the current time slot as having a transmit.
|
||||
fn record_tx(&mut self) {
|
||||
self.current_slot_tx = true;
|
||||
}
|
||||
|
||||
/// Advance to the next time slot, committing the current slot's data.
|
||||
fn advance_slot(&mut self) {
|
||||
let idx = self.rssi_count % RSSI_HISTORY_LEN;
|
||||
self.rssi_history[idx] = self.current_slot_rssi;
|
||||
self.tx_history[idx] = self.current_slot_tx;
|
||||
self.rssi_count += 1;
|
||||
self.current_slot_rssi = NO_SIGNAL;
|
||||
self.current_slot_tx = false;
|
||||
}
|
||||
|
||||
/// Whether the active dashboard should be shown (RX or TX mode).
|
||||
fn is_active(&self) -> bool {
|
||||
matches!(
|
||||
self.last_status.state,
|
||||
RadioState::Receiving | RadioState::Transmitting
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
#[task]
|
||||
pub async fn display_task(
|
||||
parts: DisplayParts,
|
||||
status: &'static StatusWatch,
|
||||
display_commands: &'static DisplayCommandChannel,
|
||||
) {
|
||||
// Format MAC address as "XX:XX:XX:XX:XX:XX"
|
||||
let mut mac_str: heapless::String<18> = heapless::String::new();
|
||||
let m = parts.mac;
|
||||
let _ = core::fmt::Write::write_fmt(
|
||||
&mut mac_str,
|
||||
format_args!(
|
||||
"{:02X}:{:02X}:{:02X}:{:02X}:{:02X}:{:02X}",
|
||||
m[0], m[1], m[2], m[3], m[4], m[5]
|
||||
),
|
||||
);
|
||||
|
||||
let board_info = render::BoardInfo {
|
||||
name: BOARD_NAME,
|
||||
version: env!("CARGO_PKG_VERSION"),
|
||||
mac: &mac_str,
|
||||
};
|
||||
|
||||
let interface = I2CDisplayInterface::new(parts.i2c);
|
||||
let mut display = Ssd1306Async::new(interface, DisplaySize128x64, DisplayRotation::Rotate0)
|
||||
.into_buffered_graphics_mode();
|
||||
|
||||
if display.init().await.is_err() {
|
||||
defmt::error!("SSD1306 init failed");
|
||||
return;
|
||||
}
|
||||
if display
|
||||
.set_brightness(ssd1306::prelude::Brightness::BRIGHTEST)
|
||||
.await
|
||||
.is_err()
|
||||
{
|
||||
defmt::warn!("display brightness set failed");
|
||||
}
|
||||
|
||||
let mut state = DisplayState::new();
|
||||
let Some(mut receiver) = status.receiver() else {
|
||||
defmt::error!("no watch receiver available for display");
|
||||
return;
|
||||
};
|
||||
|
||||
// Show splash/waiting screen
|
||||
render_splash(&mut display, &board_info).await;
|
||||
|
||||
loop {
|
||||
match select3(
|
||||
receiver.changed(),
|
||||
display_commands.receive(),
|
||||
Timer::after_millis(SPARK_SLOT_MS),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Either3::First(radio_status) => {
|
||||
if state.disconnected {
|
||||
continue;
|
||||
}
|
||||
if let Some(rssi) = radio_status.last_rssi {
|
||||
if radio_status.rx_count != state.last_status.rx_count {
|
||||
state.record_rssi(rssi);
|
||||
}
|
||||
}
|
||||
if radio_status.tx_count != state.last_status.tx_count {
|
||||
state.record_tx();
|
||||
}
|
||||
state.last_status = radio_status;
|
||||
|
||||
if state.display_on {
|
||||
render_current(&mut display, &state, &board_info).await;
|
||||
}
|
||||
}
|
||||
Either3::Second(cmd) => match cmd {
|
||||
DisplayCommand::Off => {
|
||||
state.disconnected = false;
|
||||
state.display_on = false;
|
||||
render::blank(&mut display);
|
||||
let _ = display.flush().await;
|
||||
}
|
||||
DisplayCommand::On => {
|
||||
state.disconnected = false;
|
||||
state.display_on = true;
|
||||
if let Some(s) = receiver.try_get() {
|
||||
state.last_status = s;
|
||||
}
|
||||
render_current(&mut display, &state, &board_info).await;
|
||||
}
|
||||
DisplayCommand::Reset => {
|
||||
state = DisplayState::new();
|
||||
render_splash(&mut display, &board_info).await;
|
||||
}
|
||||
},
|
||||
Either3::Third(()) => {
|
||||
// Timer tick: advance sparkline slot
|
||||
state.advance_slot();
|
||||
if state.display_on && state.is_active() {
|
||||
render_dashboard(&mut display, &state, &board_info).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The concrete display type is verbose but avoids making this generic
|
||||
// over both DrawTarget and the SSD1306-specific flush method.
|
||||
type Display<I> = ssd1306::Ssd1306Async<
|
||||
I,
|
||||
DisplaySize128x64,
|
||||
ssd1306::mode::BufferedGraphicsModeAsync<DisplaySize128x64>,
|
||||
>;
|
||||
|
||||
/// Render the appropriate screen (active dashboard or splash) and flush.
|
||||
async fn render_current<I>(
|
||||
display: &mut Display<I>,
|
||||
state: &DisplayState,
|
||||
board: &render::BoardInfo<'_>,
|
||||
) where
|
||||
I: display_interface::AsyncWriteOnlyDataCommand,
|
||||
{
|
||||
if state.is_active() {
|
||||
render_dashboard(display, state, board).await;
|
||||
} else {
|
||||
render_splash(display, board).await;
|
||||
}
|
||||
}
|
||||
|
||||
/// Render the active dashboard and flush.
|
||||
async fn render_dashboard<I>(
|
||||
display: &mut Display<I>,
|
||||
state: &DisplayState,
|
||||
board: &render::BoardInfo<'_>,
|
||||
) where
|
||||
I: display_interface::AsyncWriteOnlyDataCommand,
|
||||
{
|
||||
render::dashboard(
|
||||
display,
|
||||
&state.last_status,
|
||||
&state.rssi_history,
|
||||
&state.tx_history,
|
||||
state.rssi_count,
|
||||
state.current_slot_rssi,
|
||||
state.current_slot_tx,
|
||||
board,
|
||||
);
|
||||
let _ = display.flush().await;
|
||||
}
|
||||
|
||||
/// Render the splash/waiting screen and flush.
|
||||
async fn render_splash<I>(display: &mut Display<I>, board: &render::BoardInfo<'_>)
|
||||
where
|
||||
I: display_interface::AsyncWriteOnlyDataCommand,
|
||||
{
|
||||
render::splash(display, board);
|
||||
let _ = display.flush().await;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
#[cfg(feature = "wio_tracker_l1")]
|
||||
pub mod sh1106;
|
||||
|
||||
#[cfg(any(feature = "heltec_v3", feature = "heltec_v3_uart", feature = "heltec_v4", feature = "wio_tracker_l1"))]
|
||||
pub mod simple_led;
|
||||
@@ -0,0 +1,132 @@
|
||||
//! Minimal async SH1106 I2C OLED driver (128x64, page addressing).
|
||||
//!
|
||||
//! The SH1106 is very similar to the SSD1306 but only supports page-mode
|
||||
//! addressing (no horizontal/vertical auto-advance). This driver sends
|
||||
//! framebuffer data page-by-page with explicit page/column commands.
|
||||
|
||||
use embedded_graphics::draw_target::DrawTarget;
|
||||
use embedded_graphics::geometry::{OriginDimensions, Size};
|
||||
use embedded_graphics::pixelcolor::BinaryColor;
|
||||
use embedded_graphics::Pixel;
|
||||
use embedded_hal_async::i2c::I2c;
|
||||
|
||||
const WIDTH: usize = 128;
|
||||
const HEIGHT: usize = 64;
|
||||
const PAGES: usize = HEIGHT / 8;
|
||||
const BUF_SIZE: usize = WIDTH * PAGES; // 1024
|
||||
|
||||
/// SH1106 has a 132-column RAM; visible 128 columns are centered with a
|
||||
/// 2-column offset on each side.
|
||||
const COL_OFFSET: u8 = 2;
|
||||
|
||||
pub struct Sh1106<I> {
|
||||
i2c: I,
|
||||
addr: u8,
|
||||
buffer: [u8; BUF_SIZE],
|
||||
}
|
||||
|
||||
impl<I: I2c> Sh1106<I> {
|
||||
pub fn new(i2c: I, addr: u8) -> Self {
|
||||
Self {
|
||||
i2c,
|
||||
addr,
|
||||
buffer: [0u8; BUF_SIZE],
|
||||
}
|
||||
}
|
||||
|
||||
/// Send a sequence of commands (each prefixed with the I2C command byte 0x00).
|
||||
async fn cmd(&mut self, commands: &[u8]) -> Result<(), I::Error> {
|
||||
for &c in commands {
|
||||
self.i2c.write(self.addr, &[0x00, c]).await?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Initialize the display with sensible defaults.
|
||||
pub async fn init(&mut self) -> Result<(), I::Error> {
|
||||
self.cmd(&[
|
||||
0xAE, // display off
|
||||
0xD5, 0x80, // clock div: default
|
||||
0xA8, 0x3F, // multiplex ratio: 64
|
||||
0xD3, 0x00, // display offset: 0
|
||||
0x40, // start line: 0
|
||||
0x8D, 0x14, // charge pump: enable
|
||||
0xA1, // segment remap: column 127 mapped to SEG0
|
||||
0xC8, // COM scan direction: remapped
|
||||
0xDA, 0x12, // COM pin config: alternative, no L/R remap
|
||||
0x81, 0xCF, // contrast: 207
|
||||
0xD9, 0xF1, // pre-charge: phase1=1, phase2=15
|
||||
0xDB, 0x40, // VCOMH deselect: ~0.77×Vcc
|
||||
0xA4, // entire display ON (follow RAM)
|
||||
0xA6, // normal display (not inverted)
|
||||
0xAF, // display on
|
||||
])
|
||||
.await
|
||||
}
|
||||
|
||||
/// Flush the entire framebuffer to the display, page by page.
|
||||
pub async fn flush(&mut self) -> Result<(), I::Error> {
|
||||
// Each I2C data write is prefixed with 0x40 (data continuation byte).
|
||||
let mut page_buf = [0u8; 1 + WIDTH]; // 0x40 + 128 data bytes
|
||||
page_buf[0] = 0x40;
|
||||
|
||||
for page in 0..PAGES {
|
||||
// Set page address and column start (with SH1106 2-column offset).
|
||||
self.cmd(&[
|
||||
0xB0 | page as u8, // page address
|
||||
COL_OFFSET & 0x0F, // column low nibble
|
||||
0x10 | (COL_OFFSET >> 4), // column high nibble
|
||||
])
|
||||
.await?;
|
||||
|
||||
let start = page * WIDTH;
|
||||
page_buf[1..].copy_from_slice(&self.buffer[start..start + WIDTH]);
|
||||
self.i2c.write(self.addr, &page_buf).await?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Set display brightness (contrast).
|
||||
pub async fn set_brightness(&mut self, value: u8) -> Result<(), I::Error> {
|
||||
self.cmd(&[0x81, value]).await
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
impl<I: I2c> DrawTarget for Sh1106<I> {
|
||||
type Color = BinaryColor;
|
||||
type Error = core::convert::Infallible;
|
||||
|
||||
fn draw_iter<T>(&mut self, pixels: T) -> Result<(), Self::Error>
|
||||
where
|
||||
T: IntoIterator<Item = Pixel<Self::Color>>,
|
||||
{
|
||||
for Pixel(pos, color) in pixels {
|
||||
let x = pos.x;
|
||||
let y = pos.y;
|
||||
if x >= 0 && x < WIDTH as i32 && y >= 0 && y < HEIGHT as i32 {
|
||||
let x = x as usize;
|
||||
let y = y as usize;
|
||||
let idx = (y / 8) * WIDTH + x;
|
||||
let bit = (y % 8) as u8;
|
||||
if color.is_on() {
|
||||
self.buffer[idx] |= 1 << bit;
|
||||
} else {
|
||||
self.buffer[idx] &= !(1 << bit);
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn clear(&mut self, color: Self::Color) -> Result<(), Self::Error> {
|
||||
self.buffer.fill(if color.is_on() { 0xFF } else { 0x00 });
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl<I: I2c> OriginDimensions for Sh1106<I> {
|
||||
fn size(&self) -> Size {
|
||||
Size::new(WIDTH as u32, HEIGHT as u32)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
//! Simple single-color LED driver (GPIO on/off).
|
||||
//!
|
||||
//! Generic over any `OutputPin`. Any non-zero RGB value turns the LED on;
|
||||
//! all zeros turns it off.
|
||||
|
||||
use embedded_hal::digital::OutputPin;
|
||||
|
||||
use crate::board::RgbLed;
|
||||
|
||||
pub struct SimpleLed<P>(pub P);
|
||||
|
||||
impl<P: OutputPin> RgbLed for SimpleLed<P> {
|
||||
async fn set_rgb(&mut self, r: u8, g: u8, b: u8) {
|
||||
if r > 0 || g > 0 || b > 0 {
|
||||
let _ = self.0.set_high();
|
||||
} else {
|
||||
let _ = self.0.set_low();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
//! ESP32-S3 MCU initialization primitives.
|
||||
//!
|
||||
//! Provides low-level bus init for ESP32-S3 boards.
|
||||
//! Board files call these with specific pins and construct higher-level
|
||||
//! drivers (Sx126x, SSD1306, USB, UART, etc.) themselves.
|
||||
|
||||
use embassy_sync::blocking_mutex::raw::NoopRawMutex;
|
||||
use esp_hal::dma::{DmaRxBuf, DmaTxBuf};
|
||||
use esp_hal::i2c::master::{Config as I2cConfig, I2c};
|
||||
use esp_hal::spi::master::{Config as SpiConfig, Spi, SpiDmaBus};
|
||||
use esp_hal::spi::Mode as SpiMode;
|
||||
use esp_hal::time::Rate;
|
||||
use esp_hal::timer::timg::TimerGroup;
|
||||
use static_cell::StaticCell;
|
||||
|
||||
// ── MCU-level types ─────────────────────────────────────────────────
|
||||
|
||||
pub type SpiBus = SpiDmaBus<'static, esp_hal::Async>;
|
||||
pub type I2cBus = I2c<'static, esp_hal::Async>;
|
||||
|
||||
// ── Timer ───────────────────────────────────────────────────────────
|
||||
|
||||
/// Start the ESP-RTOS scheduler and Embassy time driver.
|
||||
pub fn start_timer(timg0: esp_hal::peripherals::TIMG0<'static>) {
|
||||
let timg0 = TimerGroup::new(timg0);
|
||||
esp_rtos::start(timg0.timer0);
|
||||
}
|
||||
|
||||
// ── SPI bus ─────────────────────────────────────────────────────────
|
||||
|
||||
/// Initialize SPI2 with DMA and wrap in a shared bus (StaticCell + Mutex).
|
||||
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>,
|
||||
) -> &'static embassy_sync::mutex::Mutex<NoopRawMutex, SpiBus> {
|
||||
let spi = Spi::new(
|
||||
spi2,
|
||||
SpiConfig::default()
|
||||
.with_frequency(Rate::from_mhz(1))
|
||||
.with_mode(SpiMode::_0),
|
||||
)
|
||||
.expect("SPI init")
|
||||
.with_sck(sck)
|
||||
.with_mosi(mosi)
|
||||
.with_miso(miso)
|
||||
.with_dma(dma_ch0);
|
||||
|
||||
let (rx_buf, rx_desc, tx_buf, tx_desc) = esp_hal::dma_buffers!(256);
|
||||
let dma_rx = DmaRxBuf::new(rx_desc, rx_buf).expect("DMA RX buf");
|
||||
let dma_tx = DmaTxBuf::new(tx_desc, tx_buf).expect("DMA TX buf");
|
||||
|
||||
let spi = spi.with_buffers(dma_rx, dma_tx).into_async();
|
||||
|
||||
static SPI_BUS: StaticCell<embassy_sync::mutex::Mutex<NoopRawMutex, SpiBus>> =
|
||||
StaticCell::new();
|
||||
SPI_BUS.init(embassy_sync::mutex::Mutex::new(spi))
|
||||
}
|
||||
|
||||
// ── I2C bus ─────────────────────────────────────────────────────────
|
||||
|
||||
/// Initialize I2C0 for peripherals (display, sensors, etc.).
|
||||
pub fn init_i2c(
|
||||
i2c0: esp_hal::peripherals::I2C0<'static>,
|
||||
sda: esp_hal::peripherals::GPIO17<'static>,
|
||||
scl: esp_hal::peripherals::GPIO18<'static>,
|
||||
) -> I2cBus {
|
||||
I2c::new(i2c0, I2cConfig::default())
|
||||
.expect("I2C init")
|
||||
.with_sda(sda)
|
||||
.with_scl(scl)
|
||||
.into_async()
|
||||
}
|
||||
|
||||
// ── MAC address ─────────────────────────────────────────────────────
|
||||
|
||||
/// Read the factory-programmed MAC address from eFuse.
|
||||
pub fn mac_address() -> [u8; 6] {
|
||||
esp_hal::efuse::Efuse::mac_address()
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
//! MCU family initialization primitives.
|
||||
//!
|
||||
//! Each submodule provides low-level bus and peripheral init for one MCU family.
|
||||
//! Board files call these with specific pins and construct higher-level drivers.
|
||||
|
||||
#[cfg(any(feature = "heltec_v3", feature = "heltec_v3_uart", feature = "heltec_v4"))]
|
||||
pub mod esp32s3;
|
||||
|
||||
#[cfg(any(feature = "rak_wisblock_4631", feature = "wio_tracker_l1"))]
|
||||
pub mod nrf52840;
|
||||
@@ -0,0 +1,82 @@
|
||||
//! nRF52840 MCU initialization primitives.
|
||||
//!
|
||||
//! Provides shared boilerplate for nRF52840 boards: interrupt bindings,
|
||||
//! type aliases, StaticCell bus wrapping, USB init, and MAC address reading.
|
||||
|
||||
use embassy_nrf::bind_interrupts;
|
||||
use embassy_sync::blocking_mutex::raw::NoopRawMutex;
|
||||
use static_cell::StaticCell;
|
||||
|
||||
// ── Interrupts (shared by all nRF52840 boards) ─────────────────────
|
||||
|
||||
bind_interrupts!(pub struct Irqs {
|
||||
USBD => embassy_nrf::usb::InterruptHandler<embassy_nrf::peripherals::USBD>;
|
||||
SPIM3 => embassy_nrf::spim::InterruptHandler<embassy_nrf::peripherals::SPI3>;
|
||||
TWISPI0 => embassy_nrf::twim::InterruptHandler<embassy_nrf::peripherals::TWISPI0>;
|
||||
});
|
||||
|
||||
// ── MCU-level types ─────────────────────────────────────────────────
|
||||
|
||||
pub type SpiBus = embassy_nrf::spim::Spim<'static>;
|
||||
pub type I2cBus = embassy_nrf::twim::Twim<'static>;
|
||||
pub type UsbNrfDriver =
|
||||
embassy_nrf::usb::Driver<'static, &'static embassy_nrf::usb::vbus_detect::SoftwareVbusDetect>;
|
||||
|
||||
// ── SPI bus sharing ─────────────────────────────────────────────────
|
||||
|
||||
/// Wrap an initialized SPI peripheral in a shared bus (StaticCell + Mutex).
|
||||
pub fn share_spi_bus(
|
||||
spi: SpiBus,
|
||||
) -> &'static embassy_sync::mutex::Mutex<NoopRawMutex, SpiBus> {
|
||||
static SPI_BUS: StaticCell<embassy_sync::mutex::Mutex<NoopRawMutex, SpiBus>> =
|
||||
StaticCell::new();
|
||||
SPI_BUS.init(embassy_sync::mutex::Mutex::new(spi))
|
||||
}
|
||||
|
||||
// ── I2C DMA buffer ──────────────────────────────────────────────────
|
||||
|
||||
/// Allocate a DMA-safe buffer for Twim I2C.
|
||||
///
|
||||
/// Every nRF52840 Twim user needs a `&'static mut [u8]` buffer.
|
||||
/// This provides one via StaticCell so board files don't repeat the pattern.
|
||||
pub fn alloc_i2c_buffer() -> &'static mut [u8; 256] {
|
||||
static TWIM_BUF: StaticCell<[u8; 256]> = StaticCell::new();
|
||||
TWIM_BUF.init([0u8; 256])
|
||||
}
|
||||
|
||||
// ── USB VBUS detection ──────────────────────────────────────────────
|
||||
|
||||
/// Allocate and initialize software VBUS detection.
|
||||
pub fn alloc_vbus_detect(
|
||||
vbus_detect: bool,
|
||||
self_powered: bool,
|
||||
) -> &'static embassy_nrf::usb::vbus_detect::SoftwareVbusDetect {
|
||||
static VBUS: StaticCell<embassy_nrf::usb::vbus_detect::SoftwareVbusDetect> = StaticCell::new();
|
||||
VBUS.init(embassy_nrf::usb::vbus_detect::SoftwareVbusDetect::new(
|
||||
vbus_detect,
|
||||
self_powered,
|
||||
))
|
||||
}
|
||||
|
||||
// ── MAC address ─────────────────────────────────────────────────────
|
||||
|
||||
/// Read the factory-programmed device address from FICR registers.
|
||||
pub fn mac_address() -> [u8; 6] {
|
||||
// SAFETY: 0x10000000 is the nRF52840 FICR (Factory Information Configuration
|
||||
// Registers) base address. Offsets 0x0A4 and 0x0A8 are the DEVICEADDR[0] and
|
||||
// DEVICEADDR[1] registers containing the factory-programmed device address.
|
||||
// read_volatile is correct for memory-mapped hardware registers.
|
||||
unsafe {
|
||||
let ficr = 0x10000000 as *const u32;
|
||||
let addr0 = core::ptr::read_volatile(ficr.byte_add(0x0A4));
|
||||
let addr1 = core::ptr::read_volatile(ficr.byte_add(0x0A8));
|
||||
[
|
||||
addr0 as u8,
|
||||
(addr0 >> 8) as u8,
|
||||
(addr0 >> 16) as u8,
|
||||
(addr0 >> 24) as u8,
|
||||
addr1 as u8,
|
||||
(addr1 >> 8) as u8,
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
//! Shared COBS protocol helpers for USB and UART transports.
|
||||
//!
|
||||
//! Both `usb_task` and `uart_task` use these helpers for frame
|
||||
//! accumulation, response encoding, and command routing.
|
||||
|
||||
use defmt::warn;
|
||||
|
||||
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).
|
||||
/// All other commands are forwarded to the radio task (response sent later).
|
||||
/// Both paths feed the same `ResponseChannel`, so the one-outstanding-command
|
||||
/// rule in PROTOCOL.md is required to keep solicited responses ordered.
|
||||
pub async fn route_command(
|
||||
cmd: Command,
|
||||
commands: &CommandChannel,
|
||||
responses: &ResponseChannel,
|
||||
display_commands: &DisplayCommandChannel,
|
||||
has_display: bool,
|
||||
mac: [u8; 6],
|
||||
) {
|
||||
match cmd {
|
||||
Command::DisplayOn => {
|
||||
if has_display {
|
||||
display_commands.send(DisplayCommand::On).await;
|
||||
responses.send(Response::Ok).await;
|
||||
} else {
|
||||
responses.send(Response::Error(ErrorCode::NoDisplay)).await;
|
||||
}
|
||||
}
|
||||
Command::DisplayOff => {
|
||||
if has_display {
|
||||
display_commands.send(DisplayCommand::Off).await;
|
||||
responses.send(Response::Ok).await;
|
||||
} else {
|
||||
responses.send(Response::Error(ErrorCode::NoDisplay)).await;
|
||||
}
|
||||
}
|
||||
Command::GetMac => {
|
||||
responses.send(Response::MacAddress(mac)).await;
|
||||
}
|
||||
other => {
|
||||
commands.send(other).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
//! Host communication: unified USB/UART transport.
|
||||
//!
|
||||
//! Exports a single `host_task` regardless of transport.
|
||||
//! The cfg dispatch is contained entirely within this module.
|
||||
|
||||
pub mod framing;
|
||||
|
||||
cfg_if::cfg_if! {
|
||||
if #[cfg(feature = "heltec_v3_uart")] {
|
||||
mod uart;
|
||||
pub use uart::host_task;
|
||||
} else {
|
||||
mod usb;
|
||||
pub use usb::host_task;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
//! UART host task: COBS-framed fixed-size LE command/response protocol.
|
||||
//!
|
||||
//! Used for boards with USB-UART bridge chips (e.g. CP2102 on Heltec V3).
|
||||
|
||||
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};
|
||||
|
||||
#[task]
|
||||
pub async fn host_task(
|
||||
parts: crate::board::UartParts,
|
||||
commands: &'static CommandChannel,
|
||||
responses: &'static ResponseChannel,
|
||||
display_commands: &'static DisplayCommandChannel,
|
||||
has_display: bool,
|
||||
mac: [u8; 6],
|
||||
) {
|
||||
let (mut rx, mut tx) = parts.driver.split();
|
||||
|
||||
// UART has no DTR — signal the display that a host is present at boot.
|
||||
if has_display {
|
||||
display_commands.send(DisplayCommand::Reset).await;
|
||||
}
|
||||
|
||||
let mut read_buf = [0u8; 64];
|
||||
let mut write_buf = [0u8; MAX_FRAME];
|
||||
let mut cobs_encode_buf = [0u8; MAX_FRAME];
|
||||
let mut decoder = CobsDecoder::new();
|
||||
|
||||
loop {
|
||||
use embassy_futures::select::{select, Either};
|
||||
|
||||
match select(
|
||||
embedded_io_async::Read::read(&mut rx, &mut read_buf),
|
||||
responses.receive(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Either::First(result) => {
|
||||
let n = match result {
|
||||
Ok(0) => continue,
|
||||
Ok(n) => n,
|
||||
Err(_) => {
|
||||
decoder.reset();
|
||||
embassy_time::Timer::after_millis(100).await;
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
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,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
}
|
||||
Either::Second(response) => {
|
||||
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");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+141
@@ -0,0 +1,141 @@
|
||||
//! USB CDC-ACM host task: COBS-framed fixed-size LE command/response protocol.
|
||||
|
||||
use defmt::warn;
|
||||
use embassy_executor::task;
|
||||
use embassy_futures::join::join;
|
||||
use embassy_usb::class::cdc_acm::{CdcAcmClass, Receiver, Sender, State};
|
||||
use embassy_usb::Builder;
|
||||
use static_cell::StaticCell;
|
||||
|
||||
use crate::channel::{CommandChannel, DisplayCommand, DisplayCommandChannel, ResponseChannel};
|
||||
use crate::protocol::Command;
|
||||
use super::framing::{self, CobsDecoder, MAX_FRAME};
|
||||
|
||||
const MAX_PACKET: usize = 64;
|
||||
|
||||
#[task]
|
||||
pub async fn host_task(
|
||||
parts: crate::board::UsbParts,
|
||||
commands: &'static CommandChannel,
|
||||
responses: &'static ResponseChannel,
|
||||
display_commands: &'static DisplayCommandChannel,
|
||||
has_display: bool,
|
||||
mac: [u8; 6],
|
||||
) {
|
||||
let mut config = embassy_usb::Config::new(0x1209, 0x5741);
|
||||
config.manufacturer = Some("DongLoRa");
|
||||
config.product = Some("DongLoRa LoRa Radio");
|
||||
config.device_class = 0xEF;
|
||||
config.device_sub_class = 0x02;
|
||||
config.device_protocol = 0x01;
|
||||
config.composite_with_iads = true;
|
||||
|
||||
static SERIAL_BUF: StaticCell<[u8; 12]> = StaticCell::new();
|
||||
let serial_buf = SERIAL_BUF.init([0u8; 12]);
|
||||
const HEX: &[u8; 16] = b"0123456789ABCDEF";
|
||||
for (i, &byte) in mac.iter().enumerate() {
|
||||
serial_buf[i * 2] = HEX[(byte >> 4) as usize];
|
||||
serial_buf[i * 2 + 1] = HEX[(byte & 0x0F) as usize];
|
||||
}
|
||||
config.serial_number = Some(core::str::from_utf8(serial_buf).expect("MAC hex is valid UTF-8"));
|
||||
|
||||
static DESC_BUF: StaticCell<[u8; 256]> = StaticCell::new();
|
||||
static CONF_BUF: StaticCell<[u8; 256]> = StaticCell::new();
|
||||
static BOS_BUF: StaticCell<[u8; 256]> = StaticCell::new();
|
||||
static CTRL_BUF: StaticCell<[u8; 64]> = StaticCell::new();
|
||||
static CDC_STATE: StaticCell<State> = StaticCell::new();
|
||||
|
||||
let desc_buf = DESC_BUF.init([0; 256]);
|
||||
let conf_buf = CONF_BUF.init([0; 256]);
|
||||
let bos_buf = BOS_BUF.init([0; 256]);
|
||||
let ctrl_buf = CTRL_BUF.init([0; 64]);
|
||||
let cdc_state = CDC_STATE.init(State::new());
|
||||
|
||||
let mut builder = Builder::new(parts.driver, config, desc_buf, conf_buf, bos_buf, ctrl_buf);
|
||||
|
||||
let class = CdcAcmClass::new(&mut builder, cdc_state, MAX_PACKET as u16);
|
||||
let mut usb_dev = builder.build();
|
||||
let (sender, receiver) = class.split();
|
||||
|
||||
join(
|
||||
usb_dev.run(),
|
||||
protocol_loop(sender, receiver, commands, responses, display_commands, has_display, mac),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
async fn protocol_loop<'d, D: embassy_usb_driver::Driver<'d>>(
|
||||
mut sender: Sender<'d, D>,
|
||||
mut receiver: Receiver<'d, D>,
|
||||
commands: &'static CommandChannel,
|
||||
responses: &'static ResponseChannel,
|
||||
display_commands: &'static DisplayCommandChannel,
|
||||
has_display: bool,
|
||||
mac: [u8; 6],
|
||||
) {
|
||||
use embassy_futures::select::{select3, Either3};
|
||||
|
||||
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(
|
||||
receiver.read_packet(&mut read_buf),
|
||||
responses.receive(),
|
||||
embassy_time::Timer::after_millis(250),
|
||||
)
|
||||
.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;
|
||||
}
|
||||
};
|
||||
|
||||
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,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
}
|
||||
Either3::Second(response) => {
|
||||
if let Some(frame) =
|
||||
framing::cobs_encode_response(response, &mut write_buf, &mut cobs_encode_buf)
|
||||
{
|
||||
for chunk in frame.chunks(MAX_PACKET) {
|
||||
if sender.write_packet(chunk).await.is_err() {
|
||||
warn!("USB write failed, response dropped");
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Either3::Third(()) => {}
|
||||
}
|
||||
|
||||
let connected = receiver.dtr();
|
||||
if was_connected && !connected {
|
||||
let _ = commands.try_send(Command::StopRx);
|
||||
if has_display {
|
||||
display_commands.send(DisplayCommand::Reset).await;
|
||||
}
|
||||
}
|
||||
if !was_connected && connected && has_display {
|
||||
display_commands.send(DisplayCommand::On).await;
|
||||
}
|
||||
was_connected = connected;
|
||||
}
|
||||
}
|
||||
+26
-23
@@ -3,11 +3,11 @@
|
||||
//! Three async tasks communicate via static channels:
|
||||
//!
|
||||
//! ```text
|
||||
//! usb_task ──Command──► radio_task ──► SX1262
|
||||
//! ◄──Response── │
|
||||
//! StatusWatch
|
||||
//! ▼
|
||||
//! display_task (optional)
|
||||
//! host_task ──Command──► radio_task ──► SX1262
|
||||
//! ◄──Response── │
|
||||
//! StatusWatch
|
||||
//! ▼
|
||||
//! display_task (optional)
|
||||
//! ```
|
||||
//!
|
||||
//! The host drives everything. The radio idles until commanded.
|
||||
@@ -21,11 +21,15 @@ mod board;
|
||||
mod channel;
|
||||
#[cfg(not(test))]
|
||||
mod display;
|
||||
#[cfg(not(test))]
|
||||
mod driver;
|
||||
#[cfg(not(test))]
|
||||
mod hal;
|
||||
#[cfg(not(test))]
|
||||
mod host;
|
||||
mod protocol;
|
||||
#[cfg(not(test))]
|
||||
mod radio;
|
||||
#[cfg(not(test))]
|
||||
mod usb;
|
||||
|
||||
#[cfg(not(test))]
|
||||
use embassy_executor::Spawner;
|
||||
@@ -37,9 +41,10 @@ use crate::channel::{CommandChannel, DisplayCommandChannel, ResponseChannel, Sta
|
||||
|
||||
#[cfg(not(test))]
|
||||
cfg_if::cfg_if! {
|
||||
if #[cfg(feature = "rak_wisblock_4631")] {
|
||||
if #[cfg(any(feature = "rak_wisblock_4631", feature = "wio_tracker_l1"))] {
|
||||
use defmt_rtt as _;
|
||||
use panic_probe as _;
|
||||
} else if #[cfg(any(feature = "heltec_v3", feature = "heltec_v4"))] {
|
||||
} else if #[cfg(any(feature = "heltec_v3", feature = "heltec_v3_uart", feature = "heltec_v4"))] {
|
||||
use esp_backtrace as _;
|
||||
use esp_println as _;
|
||||
}
|
||||
@@ -56,12 +61,12 @@ static DISPLAY_COMMANDS: DisplayCommandChannel = DisplayCommandChannel::new();
|
||||
|
||||
#[cfg(not(test))]
|
||||
cfg_if::cfg_if! {
|
||||
if #[cfg(feature = "rak_wisblock_4631")] {
|
||||
if #[cfg(any(feature = "rak_wisblock_4631", feature = "wio_tracker_l1"))] {
|
||||
#[embassy_executor::main]
|
||||
async fn main(spawner: Spawner) {
|
||||
run(spawner).await;
|
||||
}
|
||||
} else if #[cfg(any(feature = "heltec_v3", feature = "heltec_v4"))] {
|
||||
} else if #[cfg(any(feature = "heltec_v3", feature = "heltec_v3_uart", feature = "heltec_v4"))] {
|
||||
#[esp_rtos::main]
|
||||
async fn main(spawner: Spawner) {
|
||||
run(spawner).await;
|
||||
@@ -72,28 +77,26 @@ cfg_if::cfg_if! {
|
||||
#[cfg(not(test))]
|
||||
async fn run(spawner: Spawner) {
|
||||
let board = <board::Board as LoRaBoard>::init();
|
||||
let mac = board::Board::mac_address();
|
||||
let (radio, usb, display) = board.into_parts();
|
||||
|
||||
let has_display = display.is_some();
|
||||
let parts = board.into_parts();
|
||||
|
||||
spawner
|
||||
.spawn(radio::radio_task(radio, &COMMANDS, &RESPONSES, &STATUS))
|
||||
.spawn(radio::radio_task(parts.radio, &COMMANDS, &RESPONSES, &STATUS))
|
||||
.expect("spawn radio_task");
|
||||
|
||||
spawner
|
||||
.spawn(usb::usb_task(
|
||||
usb,
|
||||
.spawn(host::host_task(
|
||||
parts.host,
|
||||
&COMMANDS,
|
||||
&RESPONSES,
|
||||
&DISPLAY_COMMANDS,
|
||||
has_display,
|
||||
mac,
|
||||
parts.display.is_some(),
|
||||
parts.mac,
|
||||
))
|
||||
.expect("spawn usb_task");
|
||||
.expect("spawn host_task");
|
||||
|
||||
if let Some(dp) = display {
|
||||
if let Some(dp) = parts.display {
|
||||
spawner
|
||||
.spawn(display::display_task(dp, &STATUS, &DISPLAY_COMMANDS))
|
||||
.spawn(display::display_task(dp, parts.led, &STATUS, &DISPLAY_COMMANDS))
|
||||
.expect("spawn display_task");
|
||||
}
|
||||
}
|
||||
+64
-18
@@ -17,7 +17,6 @@ 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))]
|
||||
@@ -188,7 +187,10 @@ impl Command {
|
||||
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)
|
||||
(
|
||||
Some(RadioConfig::from_bytes(&rest[1..])?),
|
||||
1 + RADIO_CONFIG_SIZE,
|
||||
)
|
||||
} else {
|
||||
return None;
|
||||
};
|
||||
@@ -275,12 +277,11 @@ impl Response {
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
#[cfg_attr(not(test), derive(defmt::Format))]
|
||||
#[repr(u8)]
|
||||
#[allow(dead_code)] // CrcError reserved per PROTOCOL.md (error code 3)
|
||||
pub enum ErrorCode {
|
||||
InvalidConfig = 0,
|
||||
RadioBusy = 1,
|
||||
TxTimeout = 2,
|
||||
CrcError = 3,
|
||||
// 3 reserved (was CrcError — SX1262 silently drops bad-CRC packets)
|
||||
NotConfigured = 4,
|
||||
NoDisplay = 5,
|
||||
}
|
||||
@@ -326,6 +327,8 @@ mod tests {
|
||||
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);
|
||||
@@ -370,7 +373,10 @@ mod tests {
|
||||
let power_range = (-9, 22);
|
||||
let base = make_config();
|
||||
|
||||
let mut cfg = RadioConfig { freq_hz: 150_000_000, ..base };
|
||||
let mut cfg = RadioConfig {
|
||||
freq_hz: 150_000_000,
|
||||
..base
|
||||
};
|
||||
assert!(cfg.validate(power_range).is_ok());
|
||||
|
||||
cfg.freq_hz = 960_000_000;
|
||||
@@ -392,7 +398,9 @@ mod tests {
|
||||
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());
|
||||
assert!(RadioConfig { sf: 13, ..base }
|
||||
.validate(power_range)
|
||||
.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -409,22 +417,34 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn validate_tx_power_max_sentinel() {
|
||||
let cfg = RadioConfig { tx_power_dbm: TX_POWER_MAX, ..make_config() };
|
||||
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() };
|
||||
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() };
|
||||
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() };
|
||||
let cfg = RadioConfig {
|
||||
preamble_len: PREAMBLE_DEFAULT,
|
||||
..make_config()
|
||||
};
|
||||
assert!(cfg.validate((-9, 22)).is_ok());
|
||||
}
|
||||
|
||||
@@ -433,33 +453,55 @@ mod tests {
|
||||
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());
|
||||
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() };
|
||||
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() };
|
||||
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() };
|
||||
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() };
|
||||
let cfg = RadioConfig {
|
||||
preamble_len: 32,
|
||||
..make_config()
|
||||
};
|
||||
assert_eq!(cfg.resolve((-9, 22)).preamble_len, 32);
|
||||
}
|
||||
|
||||
@@ -608,7 +650,12 @@ mod tests {
|
||||
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);
|
||||
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);
|
||||
@@ -638,7 +685,6 @@ mod tests {
|
||||
(ErrorCode::InvalidConfig, 0),
|
||||
(ErrorCode::RadioBusy, 1),
|
||||
(ErrorCode::TxTimeout, 2),
|
||||
(ErrorCode::CrcError, 3),
|
||||
(ErrorCode::NotConfigured, 4),
|
||||
(ErrorCode::NoDisplay, 5),
|
||||
] {
|
||||
|
||||
@@ -113,21 +113,22 @@ pub async fn radio_task(
|
||||
// copy_len <= MAX_PAYLOAD == Vec capacity, so this cannot fail.
|
||||
let _ = payload.extend_from_slice(&rx_buf[..copy_len]);
|
||||
|
||||
responses
|
||||
.send(Response::RxPacket {
|
||||
if responses
|
||||
.try_send(Response::RxPacket {
|
||||
rssi: pkt_status.rssi,
|
||||
snr: pkt_status.snr,
|
||||
payload,
|
||||
})
|
||||
.await;
|
||||
.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());
|
||||
responses
|
||||
.send(Response::Error(ErrorCode::RadioBusy))
|
||||
.await;
|
||||
let _ = responses.try_send(Response::Error(ErrorCode::RadioBusy));
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
@@ -135,9 +136,7 @@ pub async fn radio_task(
|
||||
if start_rx(&mut lora, &cfg).await.is_err() {
|
||||
state.state = RadioState::Idle;
|
||||
status.sender().send(state.clone());
|
||||
responses
|
||||
.send(Response::Error(ErrorCode::RadioBusy))
|
||||
.await;
|
||||
let _ = responses.try_send(Response::Error(ErrorCode::RadioBusy));
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -214,11 +213,15 @@ async fn handle_cmd(
|
||||
let _ = lora.enter_standby().await;
|
||||
state.state = RadioState::Idle;
|
||||
status.sender().send(state.clone());
|
||||
responses.send(Response::Ok).await;
|
||||
// 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);
|
||||
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);
|
||||
@@ -239,9 +242,7 @@ async fn handle_cmd(
|
||||
}
|
||||
Err(e) => {
|
||||
warn!("TX failed: {}", e);
|
||||
responses
|
||||
.send(Response::Error(ErrorCode::TxTimeout))
|
||||
.await;
|
||||
responses.send(Response::Error(ErrorCode::TxTimeout)).await;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -331,7 +332,12 @@ fn modulation_params(
|
||||
async fn start_rx(lora: &mut Radio, cfg: &RadioConfig) -> Result<(), RadioError> {
|
||||
let mdltn = modulation_params(lora, cfg)?;
|
||||
let pkt = lora.create_rx_packet_params(
|
||||
cfg.preamble_len, IMPLICIT_HEADER, MAX_PAYLOAD as u8, CRC_ON, IQ_INVERTED, &mdltn,
|
||||
cfg.preamble_len,
|
||||
IMPLICIT_HEADER,
|
||||
MAX_PAYLOAD as u8,
|
||||
CRC_ON,
|
||||
IQ_INVERTED,
|
||||
&mdltn,
|
||||
)?;
|
||||
lora.prepare_for_rx(RxMode::Continuous, &mdltn, &pkt).await
|
||||
}
|
||||
@@ -343,7 +349,12 @@ async fn rx_once(
|
||||
) -> 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,
|
||||
cfg.preamble_len,
|
||||
IMPLICIT_HEADER,
|
||||
MAX_PAYLOAD as u8,
|
||||
CRC_ON,
|
||||
IQ_INVERTED,
|
||||
&mdltn,
|
||||
)?;
|
||||
lora.rx(&pkt, buf).await
|
||||
}
|
||||
@@ -368,7 +379,11 @@ async fn do_tx(lora: &mut Radio, cfg: &RadioConfig, payload: &[u8]) -> Result<()
|
||||
}
|
||||
|
||||
let mut tx_pkt = lora.create_tx_packet_params(
|
||||
cfg.preamble_len, IMPLICIT_HEADER, CRC_ON, IQ_INVERTED, &mdltn,
|
||||
cfg.preamble_len,
|
||||
IMPLICIT_HEADER,
|
||||
CRC_ON,
|
||||
IQ_INVERTED,
|
||||
&mdltn,
|
||||
)?;
|
||||
lora.prepare_for_tx(&mdltn, &mut tx_pkt, cfg.tx_power_dbm as i32, payload)
|
||||
.await?;
|
||||
@@ -1,4 +0,0 @@
|
||||
//! LoRa radio control via SX1262.
|
||||
|
||||
mod task;
|
||||
pub use task::radio_task;
|
||||
@@ -1,4 +0,0 @@
|
||||
//! USB CDC-ACM interface with COBS-framed command/response protocol.
|
||||
|
||||
mod task;
|
||||
pub use task::usb_task;
|
||||
-234
@@ -1,234 +0,0 @@
|
||||
//! USB CDC-ACM task: COBS-framed fixed-size LE command/response protocol.
|
||||
//!
|
||||
//! Reads COBS frames from USB, decodes them, parses commands, routes them
|
||||
//! to the radio or display task, and sends COBS-framed responses back.
|
||||
|
||||
use defmt::warn;
|
||||
use embassy_executor::task;
|
||||
use embassy_futures::join::join;
|
||||
use embassy_usb::class::cdc_acm::{CdcAcmClass, Receiver, Sender, State};
|
||||
use embassy_usb::Builder;
|
||||
use static_cell::StaticCell;
|
||||
|
||||
use crate::channel::{
|
||||
CommandChannel, DisplayCommand, DisplayCommandChannel, ResponseChannel,
|
||||
};
|
||||
use crate::protocol::{Command, ErrorCode, Response};
|
||||
|
||||
const MAX_FRAME: usize = 512;
|
||||
const MAX_PACKET: usize = 64;
|
||||
|
||||
// Worst case: tag(1) + rssi(2) + snr(2) + len(2) + payload(256) = 263 bytes.
|
||||
// COBS adds ceil(263/254)+1 = 3 bytes. 266 < 512. Plenty of room.
|
||||
const _: () = assert!(
|
||||
MAX_FRAME >= crate::protocol::MAX_PAYLOAD + 64,
|
||||
"MAX_FRAME too small for max payload + COBS overhead"
|
||||
);
|
||||
|
||||
#[task]
|
||||
pub async fn usb_task(
|
||||
parts: crate::board::UsbParts,
|
||||
commands: &'static CommandChannel,
|
||||
responses: &'static ResponseChannel,
|
||||
display_commands: &'static DisplayCommandChannel,
|
||||
has_display: bool,
|
||||
mac: [u8; 6],
|
||||
) {
|
||||
// ── USB device configuration ────────────────────────────────────
|
||||
// VID 0x1209: pid.codes open-source USB vendor ID
|
||||
// PID 0x5741: DongLoRa product ID
|
||||
// Class 0xEF/0x02/0x01: Miscellaneous / Interface Association Descriptor
|
||||
let mut config = embassy_usb::Config::new(0x1209, 0x5741);
|
||||
config.manufacturer = Some("DongLoRa");
|
||||
config.product = Some("DongLoRa LoRa Radio");
|
||||
config.device_class = 0xEF;
|
||||
config.device_sub_class = 0x02;
|
||||
config.device_protocol = 0x01;
|
||||
config.composite_with_iads = true;
|
||||
|
||||
// Use MAC address as USB serial number for unique device identification.
|
||||
static SERIAL_BUF: StaticCell<[u8; 12]> = StaticCell::new();
|
||||
let serial_buf = SERIAL_BUF.init([0u8; 12]);
|
||||
const HEX: &[u8; 16] = b"0123456789ABCDEF";
|
||||
for (i, &byte) in mac.iter().enumerate() {
|
||||
serial_buf[i * 2] = HEX[(byte >> 4) as usize];
|
||||
serial_buf[i * 2 + 1] = HEX[(byte & 0x0F) as usize];
|
||||
}
|
||||
config.serial_number = Some(core::str::from_utf8(serial_buf).expect("MAC hex is valid UTF-8"));
|
||||
|
||||
static DESC_BUF: StaticCell<[u8; 256]> = StaticCell::new();
|
||||
static CONF_BUF: StaticCell<[u8; 256]> = StaticCell::new();
|
||||
static BOS_BUF: StaticCell<[u8; 256]> = StaticCell::new();
|
||||
static CTRL_BUF: StaticCell<[u8; 64]> = StaticCell::new();
|
||||
static CDC_STATE: StaticCell<State> = StaticCell::new();
|
||||
|
||||
let desc_buf = DESC_BUF.init([0; 256]);
|
||||
let conf_buf = CONF_BUF.init([0; 256]);
|
||||
let bos_buf = BOS_BUF.init([0; 256]);
|
||||
let ctrl_buf = CTRL_BUF.init([0; 64]);
|
||||
let cdc_state = CDC_STATE.init(State::new());
|
||||
|
||||
let mut builder = Builder::new(
|
||||
parts.driver,
|
||||
config,
|
||||
desc_buf,
|
||||
conf_buf,
|
||||
bos_buf,
|
||||
ctrl_buf,
|
||||
);
|
||||
|
||||
let class = CdcAcmClass::new(&mut builder, cdc_state, MAX_PACKET as u16);
|
||||
let mut usb_dev = builder.build();
|
||||
let (sender, receiver) = class.split();
|
||||
|
||||
// ── Run USB device + protocol loop concurrently ────────────────
|
||||
join(
|
||||
usb_dev.run(),
|
||||
protocol_loop(sender, receiver, commands, responses, display_commands, has_display, mac),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
async fn protocol_loop<'d, D: embassy_usb_driver::Driver<'d>>(
|
||||
mut sender: Sender<'d, D>,
|
||||
mut receiver: Receiver<'d, D>,
|
||||
commands: &'static CommandChannel,
|
||||
responses: &'static ResponseChannel,
|
||||
display_commands: &'static DisplayCommandChannel,
|
||||
has_display: bool,
|
||||
mac: [u8; 6],
|
||||
) {
|
||||
use embassy_futures::select::select3;
|
||||
use embassy_futures::select::Either3;
|
||||
|
||||
let mut read_buf = [0u8; MAX_PACKET];
|
||||
let mut write_buf = [0u8; MAX_FRAME];
|
||||
let mut cobs_encode_buf = [0u8; MAX_FRAME];
|
||||
|
||||
// COBS accumulator: collect bytes until 0x00 sentinel
|
||||
let mut frame_buf = [0u8; MAX_FRAME];
|
||||
let mut frame_len: usize = 0;
|
||||
|
||||
let mut was_connected = false;
|
||||
|
||||
loop {
|
||||
match select3(
|
||||
receiver.read_packet(&mut read_buf),
|
||||
responses.receive(),
|
||||
embassy_time::Timer::after_millis(250), // DTR poll interval for disconnect detection
|
||||
)
|
||||
.await
|
||||
{
|
||||
Either3::First(result) => {
|
||||
let n = match result {
|
||||
Ok(0) => continue,
|
||||
Ok(n) => n,
|
||||
Err(_) => {
|
||||
frame_len = 0; // reset accumulator
|
||||
embassy_time::Timer::after_millis(100).await;
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
// Feed bytes into the COBS accumulator
|
||||
for &byte in &read_buf[..n] {
|
||||
if byte == 0x00 {
|
||||
// End of COBS frame — decode and parse
|
||||
if frame_len > 0 {
|
||||
let mut decode_buf = [0u8; MAX_FRAME];
|
||||
if let Some(decoded_len) =
|
||||
ucobs::decode(&frame_buf[..frame_len], &mut decode_buf)
|
||||
{
|
||||
if let Some(cmd) = Command::from_bytes(&decode_buf[..decoded_len]) {
|
||||
route_command(
|
||||
cmd, commands, responses, display_commands, has_display, mac,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
}
|
||||
}
|
||||
frame_len = 0;
|
||||
} else if frame_len < MAX_FRAME {
|
||||
frame_buf[frame_len] = byte;
|
||||
frame_len += 1;
|
||||
} else {
|
||||
// Frame too large — discard
|
||||
frame_len = 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
Either3::Second(response) => {
|
||||
// Serialize response to fixed-size LE bytes, then COBS encode
|
||||
let raw_len = response.write_to(&mut write_buf);
|
||||
let encoded_len = ucobs::encode(&write_buf[..raw_len], &mut cobs_encode_buf)
|
||||
.unwrap_or(0);
|
||||
// Append 0x00 sentinel
|
||||
if encoded_len < cobs_encode_buf.len() {
|
||||
cobs_encode_buf[encoded_len] = 0x00;
|
||||
let frame = &cobs_encode_buf[..encoded_len + 1];
|
||||
for chunk in frame.chunks(MAX_PACKET) {
|
||||
if sender.write_packet(chunk).await.is_err() {
|
||||
warn!("USB write failed, response dropped");
|
||||
break;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
warn!("COBS encode buffer overflow");
|
||||
}
|
||||
}
|
||||
Either3::Third(()) => {
|
||||
// Poll DTR every 250ms to detect host disconnect
|
||||
}
|
||||
}
|
||||
|
||||
// Check DTR after every select wake
|
||||
let connected = receiver.dtr();
|
||||
if was_connected && !connected && has_display {
|
||||
display_commands.send(DisplayCommand::Reset).await;
|
||||
}
|
||||
if !was_connected && connected && has_display {
|
||||
display_commands.send(DisplayCommand::On).await;
|
||||
}
|
||||
was_connected = connected;
|
||||
}
|
||||
}
|
||||
|
||||
/// Route a parsed command to the appropriate handler.
|
||||
///
|
||||
/// Display and MAC commands are handled locally (response sent immediately).
|
||||
/// All other commands are forwarded to the radio task (response sent later).
|
||||
/// Both paths feed the same `ResponseChannel`, so the one-outstanding-command
|
||||
/// rule in PROTOCOL.md is required to keep solicited responses ordered.
|
||||
async fn route_command(
|
||||
cmd: Command,
|
||||
commands: &CommandChannel,
|
||||
responses: &ResponseChannel,
|
||||
display_commands: &DisplayCommandChannel,
|
||||
has_display: bool,
|
||||
mac: [u8; 6],
|
||||
) {
|
||||
match cmd {
|
||||
Command::DisplayOn => {
|
||||
if has_display {
|
||||
display_commands.send(DisplayCommand::On).await;
|
||||
responses.send(Response::Ok).await;
|
||||
} else {
|
||||
responses.send(Response::Error(ErrorCode::NoDisplay)).await;
|
||||
}
|
||||
}
|
||||
Command::DisplayOff => {
|
||||
if has_display {
|
||||
display_commands.send(DisplayCommand::Off).await;
|
||||
responses.send(Response::Ok).await;
|
||||
} else {
|
||||
responses.send(Response::Error(ErrorCode::NoDisplay)).await;
|
||||
}
|
||||
}
|
||||
Command::GetMac => {
|
||||
responses.send(Response::MacAddress(mac)).await;
|
||||
}
|
||||
other => {
|
||||
commands.send(other).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user