28 Commits
Author SHA1 Message Date
swaits 46822c182c feat: add Waveshare RP2040-LoRa board support
First RP2040 board and third MCU family (after ESP32-S3 and nRF52840).

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

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

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

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

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 13:34:25 -06:00
swaits 0cbf59765c docs: add missing LICENSE file 2026-04-07 19:52:52 -06:00
swaitsandClaude Opus 4.6 de38278d7b fix: resolve display stuck after disconnect and radio task deadlock
- Set disconnected flag on DisplayCommand::Reset to block stale radio
  status updates from re-rendering dashboard over splash screen
- Send StopRx to radio when USB DTR drops so radio stops generating
  packets after host disconnects
- Use try_send for unsolicited responses (RxPacket, error recovery,
  StopRx Ok) to prevent radio task deadlock on full response channel
- Fall back to LED-only event loop when display init fails instead of
  returning early and orphaning the LED

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 19:49:15 -06:00
swaits 0c9094dcbd release: firmware v0.2.0
Major architecture reorganization, Wio Tracker L1 support, LED blink
on packet activity, native USB for Heltec V3 (hardware mod required).
See CHANGELOG.md for full details.
2026-04-07 12:46:58 -06:00
swaits 41b5775268 refactor: unify SimpleLed via embedded-hal, remove Option<L> from BoardParts
- Added embedded-hal 1.0 as direct dep (already transitive via embedded-hal-async)
- SimpleLed<P: OutputPin> now generic over any HAL — single definition in driver/
- Removed duplicate SimpleLed from board/esp32s3.rs and board/wio_tracker_l1.rs
- Changed BoardParts.led from Option<L> to L — boards with no LED use ()
- Display task calls led.set_rgb() unconditionally (no-op for () boards)
- RAK board: led: () instead of led: None
2026-04-07 12:40:55 -06:00
swaits f9117d71c8 fix: Wio Tracker LED on P1.01, move SimpleLed to board layer
Per the Wio Tracker L1 schematic, it has a green LED on P1.01.
Moved SimpleLed from driver/ to board layer (esp32s3.rs and
wio_tracker_l1.rs) since the impl needs HAL-specific Output types.
Removed driver/simple_led.rs — each board layer defines its own.
RAK WisBlock remains LedDriver=() (no user LED on the core module).
2026-04-07 12:20:20 -06:00
swaits 3bb41da223 fix: V4 also has plain LED on GPIO35, not WS2812 on GPIO38
Per the official V4 schematic (HTIT-WB32LAF_V4.3.pdf), the V4 has
an orange LED on GPIO35 with a series resistor — same setup as V3.
GPIO38 is GNSS_RX on V4, not an LED. Neither V3 nor V4 has a WS2812.

- V4: switched from WS2812/GPIO38 to SimpleLed/GPIO35
- Removed ws2812.rs driver (no current board uses addressable LEDs)
- All ESP boards now use the same SimpleLed driver on GPIO35
2026-04-07 11:59:05 -06:00
swaits eb880d03f6 fix: switch WS2812 driver to blocking RMT transmit
Async RMT had peripheral lifetime issues causing silent failures.
Blocking transmit is ~31µs per update (negligible) and avoids the
async complexity. Also removes the ManuallyDrop/unsafe workaround.
2026-04-07 11:51:18 -06:00
swaits 239e72d0a8 fix: prevent RMT peripheral drop on V4, log WS2812 errors
The Rmt struct was being dropped after extracting channel0, which
likely disabled the RMT peripheral. Use ManuallyDrop to keep it alive.
Also log WS2812 transmit errors instead of silently discarding them.
2026-04-07 11:48:34 -06:00
swaits 6558350aee fix: short 50ms LED blink, RX brightness scaled by SNR
- LED blink is now 50ms (was waiting for 1s timer tick)
- RX blink: green brightness scaled by SNR (-20dB→dim, +15dB→bright)
- TX blink: fixed red brightness
- Blink is self-contained (on→50ms delay→off) instead of timer-based
- V3 white LED: any non-zero = on, so SNR scaling is invisible but
  V4 WS2812 will show green intensity varying with signal quality
2026-04-07 11:34:51 -06:00
swaits 019198b636 fix: V3 has white LED on GPIO35, not WS2812B on GPIO38
The Heltec V3 has a plain white LED on GPIO35 (simple GPIO, not
addressable). Only the V4 has a WS2812B RGB LED on GPIO38.

- Created driver/simple_led.rs for single-color GPIO LEDs
- V3/V3-UART boards: SimpleLed on GPIO35 (white blink on RX/TX)
- V4 board: unchanged (WS2812 on GPIO38, green/red colors)
- nRF boards: unchanged (no LED, type LedDriver = ())
2026-04-07 11:30:16 -06:00
swaits 5ac4b73739 feat: RGB LED support — blinks green on RX, red on TX
- Added RgbLed trait with impl on () for boards without LED
- Added WS2812 driver (driver/ws2812.rs) using ESP32-S3 RMT peripheral
  - 80MHz clock, 12.5ns resolution, 24-bit GRB encoding
  - Zero extra crate dependencies
- Heltec V3/V3-UART/V4: WS2812B on GPIO38 via RMT channel 0
- RAK WisBlock/Wio Tracker: type LedDriver = (), led = None (no-op)
- Display task: green blink on RX packet, red blink on TX packet
- LED auto-off on timer tick (~1s), off when display is off/reset
- LoRaBoard trait gains type LedDriver: RgbLed associated type
- BoardParts gains led: Option<L> field
2026-04-07 11:18:30 -06:00
swaits 2b9e236f32 feat: Heltec V3 native USB, remove all dead_code suppressions
- heltec_v3 now uses USB OTG (requires hardware mod: solder R29/R3)
- heltec_v3_uart added as fallback for stock boards
- Removed every #[allow(dead_code)] — fixed root causes instead:
  - Comm types (UsbParts/UartParts) moved to individual board files
  - USB/UART init removed from hal (boards call esp-hal directly)
  - SH1106 driver gated on boards that use it
  - UART task sends DisplayCommand::Reset at startup (variant now used)
  - ErrorCode::CrcError removed (SX1262 drops bad-CRC silently)
- check/clippy now use --release (no more Xtensa dev-mode warnings)
- Updated README with hardware mod docs and board table
2026-04-07 10:52:58 -06:00
swaits 6db287c241 feat: native USB CDC-ACM support for Heltec V3
The default heltec_v3 feature now uses native USB OTG (same as V4),
appearing as /dev/ttyACM0 with the DongLoRa VID:PID (1209:5741).
This requires a hardware mod: solder R29/R3 and disconnect the CP2102
from the USB data lines.

Stock (unmodified) V3 boards should use the new heltec_v3_uart feature
which preserves the original UART-over-CP2102 behavior (/dev/ttyUSB0).

- heltec_v3: switched from UART to USB OTG
- heltec_v3_uart: new feature, UART fallback for stock boards
- Removed #[cfg] gates on UsbParts/UartParts (both always defined)
- Updated README with hardware mod instructions and board table
- Updated justfile with heltec_v3_uart board definition
2026-04-07 10:44:25 -06:00
swaits 07651dce44 fix: display stuck on splash for UART boards (disconnected flag)
The display task started with disconnected=true, waiting for a
DisplayCommand::On that the USB task sends on DTR connect. The UART
task has no DTR, so the display never left the "disconnected" state
and ignored all radio status updates. Start connected instead — the
USB task will send Reset on actual disconnect.
2026-04-07 10:27:27 -06:00
swaits 18b509344c refactor: build system auto-discovery, updated docs
- build.rs discovers boards by content (implements LoRaBoard), not exclusion lists
- build.rs auto-discovers helper modules by scanning board files for use super::<helper>
- Jinja template dynamically generates cfg gates for helpers
- Updated PORTING.md for new architecture (hal/, driver/, BoardParts, associated types)
- Zero manual template editing needed when adding boards to existing MCU families
2026-04-07 09:54:32 -06:00
swaits ecc451affe refactor: LoRaBoard trait with full associated types + BoardParts
- LoRaBoard trait now has associated types: RadioParts, CommParts, DisplayParts, DisplayDriver
- into_parts() is on the trait (enforced by compiler, not implicit convention)
- BoardParts<R, C, D> struct replaces tuple return from into_parts()
- mac_address() stays on trait, MAC also in BoardParts for convenience
- Removed mac field from DisplayParts (display task calls Board::mac_address())
- DisplayDriver associated type requires DrawTarget<Color = BinaryColor>
2026-04-07 09:50:50 -06:00
swaits 7a54c4dff3 refactor: board-owned display init, zero cfg in application code
- Each board now exports DisplayDriver type and create_display() async fn
- ESP32-S3 boards share SSD1306 create_display in board/esp32s3.rs
- RAK WisBlock has its own SSD1306 create_display (different I2C type)
- Wio Tracker has SH1106 create_display (different display IC)
- Removed all cfg_if from display.rs — calls board::create_display()
- Removed display init retry logic (single attempt, return None on failure)
- ZERO board-specific #[cfg(feature)] outside board/, host/mod.rs, and hal/
2026-04-07 09:46:25 -06:00
swaits 622684cdde refactor: flatten radio.rs and display.rs, zero warnings
- Flattened radio/ directory to radio.rs (was just one file)
- Flattened display/ to display.rs (combined task + rendering as inline module)
- Moved SH1106 display driver reference from display/ to driver/ (done in Phase 2)
- Suppressed dead_code warnings for conditional USB/UART types in hal
- All boards: zero warnings, zero cfg in application code
2026-04-07 09:43:46 -06:00
swaits 2c852b9f45 refactor: unified host/ communication module, cfg-free run()
- Created host/ module with internal cfg dispatch (USB vs UART)
- Moved usb/task.rs → host/usb.rs, uart/task.rs → host/uart.rs
- Moved protocol_io.rs → host/framing.rs
- Renamed usb_task/uart_task to host_task (uniform name)
- Removed all host-comm cfg from main.rs
- The run() function now has ZERO board-specific conditional compilation
- Deleted old usb/, uart/, protocol_io.rs modules
2026-04-07 09:40:59 -06:00
swaits fb334118fd refactor: extract hal/ and driver/ modules
- Created hal/esp32s3.rs with MCU primitives (SPI, I2C, USB, UART, timer, MAC)
- Created hal/nrf52840.rs with shared nRF boilerplate (interrupts, bus wrapping, VBUS, MAC)
- Moved SH1106 OLED driver from display/ to driver/ (clear separation from app code)
- Board esp32s3.rs now delegates MCU init to hal, keeps only peripheral wiring (RadioParts)
- nRF board files use hal for shared StaticCell patterns, mac_address, interrupt bindings
- Board files still own pin assignments and peripheral construction
2026-04-07 09:38:56 -06:00
swaits 8bdaa72bc3 refactor: deduplicate USB/UART protocol framing
- Made protocol_io.rs compile for all boards (not just heltec_v3)
- Renamed FrameAccumulator → CobsDecoder, encode_response → cobs_encode_response
- Refactored USB task to use shared CobsDecoder and route_command from protocol_io
- Removed ~80 lines of duplicated COBS framing and command routing from usb/task.rs
- Single canonical MAX_FRAME definition in protocol_io.rs
2026-04-07 09:24:59 -06:00
swaits 6e8699610a WIP: fix firmware filenaming 2026-04-07 08:18:28 -06:00
swaits 73878b792f WIP: get Heltec V3 working 2026-04-07 07:58:57 -06:00
swaits 0ae1c84085 WIP: cleanup a bit, strip binaries 2026-04-06 22:30:45 -06:00
swaits 38684482e8 WIP: functional checkpoint! 2026-04-06 21:15:35 -06:00
44 changed files with 3155 additions and 1228 deletions

No files matched your search

+14
View File
@@ -4,3 +4,17 @@ 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",
]
[target.thumbv6m-none-eabi]
rustflags = [
"-C", "link-arg=-Tlink.x",
"-C", "link-arg=-Tdefmt.x",
"-C", "opt-level=z",
]
+101
View File
@@ -0,0 +1,101 @@
# 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.3.0] - 2026-04-10
### Added
- Waveshare RP2040-LoRa board support (RP2040 + SX1262, no OLED, LED on GPIO25)
- First RP2040 board — third MCU family after ESP32-S3 and nRF52840
- New `hal/rp2040.rs` module with MCU primitives (SPI, USB, flash unique ID)
- USB-C via FPC ribbon cable to adapter board
- PE4259 antenna switch on GPIO17, 850-930 MHz (HF)
### Changed
- `cortex-m-rt/set-vtor` moved from global dependency to per-board feature
(Cortex-M0+ has no VTOR register)
- `build.rs` memory.x selection is now target-aware (thumbv6m vs thumbv7em)
## [0.2.1] - 2026-04-07
### Fixed
- Display stuck on status screen after USB disconnect (stale radio status
updates re-rendered dashboard over splash because `disconnected` flag was
not set in the `Reset` handler)
- Radio left running after host disconnect, now sends `StopRx` when DTR drops
- System deadlock when host app closes serial port while radio is receiving
(unsolicited responses in radio task now use `try_send` to avoid blocking
on a full response channel)
- LED orphaned if OLED display initialization fails (display task now falls
back to a LED-only event loop instead of returning early)
## [0.2.0] - 2026-04-07
### Added
- Wio Tracker L1 board support (nRF52840 + SX1262, SH1106 OLED)
- LED blink on packet activity: brief flash on RX and TX when display is on
- RX brightness scales with SNR (stronger signal = brighter)
- Works on all boards with a user LED (Heltec V3/V4, Wio Tracker)
- Native USB CDC-ACM support for Heltec V3 (requires hardware mod: solder R29/R3)
- `heltec_v3_uart` feature for stock (unmodified) V3 boards via CP2102 bridge
- `RgbLed` trait and `SimpleLed` driver for GPIO-based LEDs
- `LoRaBoard` trait with full associated types (`RadioParts`, `CommParts`,
`DisplayParts`, `DisplayDriver`, `LedDriver`) and `BoardParts<R, C, D, L>` struct
### Changed
- Major firmware architecture reorganization:
- `hal/` module for MCU-specific primitives (ESP32-S3, nRF52840)
- `driver/` module for hardware peripheral drivers (SH1106, SimpleLed)
- `host/` module for unified USB/UART communication with internal cfg dispatch
- `board/` contains only board definitions — no MCU helpers or drivers mixed in
- Board files are declarative wiring diagrams calling hal primitives
- `run()` function has zero board-specific conditional compilation
- Display init moved to board layer (`create_display()`) — display task has no cfg
- Flattened `radio/` and `display/` directories (no single-file directories)
- `build.rs` auto-discovers boards by content (implements `LoRaBoard`) and
helper modules by `use super::` imports — no exclusion lists
- `check` and `clippy` commands now use `--release` profile
- Deduplicated USB/UART protocol framing (shared `CobsDecoder` + `route_command`)
- Removed `ErrorCode::CrcError` (SX1262 silently drops bad-CRC packets)
### Fixed
- Display stuck on splash screen for UART boards (disconnected flag started true)
## [0.1.0] - 2026-03-15
### Added
- Transparent LoRa radio over USB CDC-ACM (VID `1209`, PID `5741`)
- COBS-framed fixed-size LE binary protocol (9 commands, 7 responses)
- Radio configuration: frequency, bandwidth, spreading factor, coding rate, TX power
- Continuous RX mode with RSSI/SNR per packet
- Single-shot TX with optional per-packet config override
- SSD1306 OLED display support with status dashboard
- Radio state, frequency, modulation parameters
- Packet counters (RX/TX)
- RSSI sparkline (1-minute history, TX shown as dotted bars)
- Combined splash/waiting screen on disconnect
- Auto-detect USB host connect/disconnect via DTR
- Config validation against per-board hardware limits
- `TX_POWER_MAX` sentinel (`-128`): auto-use the board's maximum TX power
- Board support: Heltec V3, Heltec V4, RAK WisBlock 4631
- Python host library (`donglora` on PyPI) with `pip install` and `donglora-mux` CLI
- USB multiplexer for sharing one dongle with multiple applications
- Two-way LoRa bridge over TCP (`examples/lora_bridge.py`)
### Technical
- Embassy async runtime (no threads, CPU sleeps when idle)
- Zero-config: boots and waits for host commands
- Protocol-agnostic: firmware has no opinion about what you transmit
- Tasks never panic: all errors reported to host or logged
- Board abstraction: one `.rs` file + one Cargo feature per board
Generated
+537 -1
View File
@@ -14,6 +14,30 @@ dependencies = [
"zerocopy",
]
[[package]]
name = "aho-corasick"
version = "1.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301"
dependencies = [
"memchr",
]
[[package]]
name = "arrayvec"
version = "0.7.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50"
[[package]]
name = "ascii-canvas"
version = "4.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ef1e3e699d84ab1b0911a1010c5c106aa34ae89aeac103be5ce0c3859db1e891"
dependencies = [
"term",
]
[[package]]
name = "autocfg"
version = "1.5.0"
@@ -35,6 +59,21 @@ dependencies = [
"rustc_version",
]
[[package]]
name = "bit-set"
version = "0.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3"
dependencies = [
"bit-vec",
]
[[package]]
name = "bit-vec"
version = "0.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7"
[[package]]
name = "bitfield"
version = "0.13.2"
@@ -79,6 +118,15 @@ version = "2.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af"
[[package]]
name = "block-buffer"
version = "0.10.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71"
dependencies = [
"generic-array",
]
[[package]]
name = "byte-slice-cast"
version = "1.2.3"
@@ -103,6 +151,16 @@ version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
[[package]]
name = "codespan-reporting"
version = "0.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3538270d33cc669650c4b093848450d380def10c331d38c768e34cac80576e6e"
dependencies = [
"termcolor",
"unicode-width",
]
[[package]]
name = "cortex-m"
version = "0.7.7"
@@ -111,6 +169,7 @@ checksum = "8ec610d8f49840a5b376c69663b6369e71f4b34484b9b2eb29fb918d92516cb9"
dependencies = [
"bare-metal",
"bitfield 0.13.2",
"critical-section",
"embedded-hal 0.2.7",
"volatile-register",
]
@@ -135,6 +194,24 @@ dependencies = [
"syn 2.0.117",
]
[[package]]
name = "cpufeatures"
version = "0.2.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280"
dependencies = [
"libc",
]
[[package]]
name = "crc-any"
version = "2.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a62ec9ff5f7965e4d7280bd5482acd20aadb50d632cf6c1d74493856b011fa73"
dependencies = [
"debug-helper",
]
[[package]]
name = "critical-section"
version = "1.2.0"
@@ -260,6 +337,12 @@ dependencies = [
"syn 2.0.117",
]
[[package]]
name = "debug-helper"
version = "0.3.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f578e8e2c440e7297e008bb5486a3a8a194775224bbc23729b0dbdfaeebf162e"
[[package]]
name = "defmt"
version = "0.3.100"
@@ -301,6 +384,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"
@@ -318,6 +411,7 @@ version = "0.10.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
dependencies = [
"block-buffer",
"crypto-common",
]
@@ -361,22 +455,26 @@ 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",
"embassy-futures",
"embassy-nrf",
"embassy-rp",
"embassy-sync 0.8.0",
"embassy-time",
"embassy-usb",
"embassy-usb-driver",
"embedded-graphics",
"embedded-hal 1.0.0",
"embedded-hal-async",
"embedded-io-async 0.7.0",
"esp-backtrace",
@@ -388,11 +486,18 @@ dependencies = [
"minijinja",
"owo-colors",
"panic-probe",
"portable-atomic",
"ssd1306",
"static_cell",
"ucobs",
]
[[package]]
name = "either"
version = "1.15.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719"
[[package]]
name = "embassy-embedded-hal"
version = "0.5.0"
@@ -472,6 +577,9 @@ version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "95285007a91b619dc9f26ea8f55452aa6c60f7115a4edc05085cd2bd3127cd7a"
dependencies = [
"cortex-m",
"critical-section",
"defmt 1.0.1",
"num-traits",
]
@@ -546,6 +654,45 @@ dependencies = [
"rand_core 0.9.5",
]
[[package]]
name = "embassy-rp"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "af8d5ac11a8bc209d359ad98bb10a10f786471dd474790f3a4f991c77ae94f6f"
dependencies = [
"cfg-if",
"cortex-m",
"cortex-m-rt",
"critical-section",
"defmt 1.0.1",
"document-features",
"embassy-embedded-hal 0.5.0",
"embassy-futures",
"embassy-hal-internal 0.3.0",
"embassy-sync 0.7.2",
"embassy-time",
"embassy-time-driver",
"embassy-time-queue-utils",
"embassy-usb-driver",
"embedded-hal 0.2.7",
"embedded-hal 1.0.0",
"embedded-hal-async",
"embedded-hal-nb",
"embedded-io 0.6.1",
"embedded-io-async 0.6.1",
"embedded-storage",
"embedded-storage-async",
"fixed",
"nb 1.1.0",
"pio",
"rand_core 0.6.4",
"rand_core 0.9.5",
"rp-pac",
"rp2040-boot2",
"sha2-const-stable",
"smart-leds",
]
[[package]]
name = "embassy-sync"
version = "0.6.2"
@@ -721,6 +868,16 @@ dependencies = [
"embedded-hal 1.0.0",
]
[[package]]
name = "embedded-hal-nb"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fba4268c14288c828995299e59b12babdbe170f6c6d73731af1b4648142e8605"
dependencies = [
"embedded-hal 1.0.0",
"nb 1.1.0",
]
[[package]]
name = "embedded-io"
version = "0.6.1"
@@ -766,6 +923,15 @@ dependencies = [
"embedded-storage",
]
[[package]]
name = "ena"
version = "0.14.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "eabffdaee24bd1bf95c5ef7cec31260444317e72ea56c4c91750e8b7ee58d5f1"
dependencies = [
"log",
]
[[package]]
name = "encode_unicode"
version = "0.3.6"
@@ -1072,6 +1238,12 @@ dependencies = [
"typenum",
]
[[package]]
name = "fixedbitset"
version = "0.5.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99"
[[package]]
name = "float-cmp"
version = "0.9.0"
@@ -1240,18 +1412,83 @@ dependencies = [
"syn 2.0.117",
]
[[package]]
name = "itertools"
version = "0.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285"
dependencies = [
"either",
]
[[package]]
name = "itoa"
version = "1.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
[[package]]
name = "keccak"
version = "0.1.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cb26cec98cce3a3d96cbb7bced3c4b16e3d13f27ec56dbd62cbc8f39cfb9d653"
dependencies = [
"cpufeatures",
]
[[package]]
name = "lalrpop"
version = "0.22.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ba4ebbd48ce411c1d10fb35185f5a51a7bfa3d8b24b4e330d30c9e3a34129501"
dependencies = [
"ascii-canvas",
"bit-set",
"ena",
"itertools",
"lalrpop-util",
"petgraph",
"pico-args",
"regex",
"regex-syntax",
"sha3",
"string_cache",
"term",
"unicode-xid",
"walkdir",
]
[[package]]
name = "lalrpop-util"
version = "0.22.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b5baa5e9ff84f1aefd264e6869907646538a52147a755d494517a8007fb48733"
dependencies = [
"regex-automata",
"rustversion",
]
[[package]]
name = "libc"
version = "0.2.184"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "48f5d2a454e16a5ea0f4ced81bd44e4cfc7bd3a507b61887c99fd3538b28e4af"
[[package]]
name = "litrs"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092"
[[package]]
name = "lock_api"
version = "0.4.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965"
dependencies = [
"scopeguard",
]
[[package]]
name = "log"
version = "0.4.29"
@@ -1333,6 +1570,12 @@ version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8d5439c4ad607c3c23abf66de8c8bf57ba8adcd1f129e699851a6e43935d339d"
[[package]]
name = "new_debug_unreachable"
version = "1.0.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086"
[[package]]
name = "nrf-pac"
version = "0.3.0"
@@ -1352,6 +1595,27 @@ dependencies = [
"autocfg",
]
[[package]]
name = "num_enum"
version = "0.7.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5d0bca838442ec211fa11de3a8b0e0e8f3a4522575b5c4c06ed722e005036f26"
dependencies = [
"num_enum_derive",
"rustversion",
]
[[package]]
name = "num_enum_derive"
version = "0.7.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "680998035259dcfcafe653688bf2aa6d3e2dc05e98be6ab46afb089dc84f1df8"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.117",
]
[[package]]
name = "object"
version = "0.37.3"
@@ -1383,23 +1647,128 @@ dependencies = [
"defmt 1.0.1",
]
[[package]]
name = "parking_lot"
version = "0.12.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a"
dependencies = [
"lock_api",
"parking_lot_core",
]
[[package]]
name = "parking_lot_core"
version = "0.9.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1"
dependencies = [
"cfg-if",
"libc",
"redox_syscall",
"smallvec",
"windows-link",
]
[[package]]
name = "paste"
version = "1.0.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a"
[[package]]
name = "petgraph"
version = "0.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3672b37090dbd86368a4145bc067582552b29c27377cad4e0a306c97f9bd7772"
dependencies = [
"fixedbitset",
"indexmap",
]
[[package]]
name = "phf_shared"
version = "0.11.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5"
dependencies = [
"siphasher",
]
[[package]]
name = "pico-args"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5be167a7af36ee22fe3115051bc51f6e6c7054c9348e28deb4f49bd6f705a315"
[[package]]
name = "pin-project-lite"
version = "0.2.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
[[package]]
name = "pio"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d0ba4153cee9585abc451271aa437d9e8defdea8b468d48ba6b8f098cbe03d7f"
dependencies = [
"pio-core",
"pio-proc",
]
[[package]]
name = "pio-core"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "61d90fddc3d67f21bbf93683bc461b05d6a29c708caf3ffb79947d7ff7095406"
dependencies = [
"arrayvec",
"num_enum",
"paste",
]
[[package]]
name = "pio-parser"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "825266c1eaddf54f636d06eefa4bf3c99d774c14ec46a4a6c6e5128a0f10d205"
dependencies = [
"lalrpop",
"lalrpop-util",
"pio-core",
]
[[package]]
name = "pio-proc"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed4a76571f5fe51af43cc80ac870fe0c79cc0cdd686b9002a6c4c84bfdd0176b"
dependencies = [
"codespan-reporting",
"lalrpop-util",
"pio-core",
"pio-parser",
"proc-macro-error2",
"proc-macro2",
"quote",
"syn 2.0.117",
]
[[package]]
name = "portable-atomic"
version = "1.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49"
dependencies = [
"critical-section",
]
[[package]]
name = "precomputed-hash"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c"
[[package]]
name = "proc-macro-crate"
@@ -1503,6 +1872,50 @@ version = "0.9.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c"
[[package]]
name = "redox_syscall"
version = "0.5.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d"
dependencies = [
"bitflags 2.11.0",
]
[[package]]
name = "regex"
version = "1.12.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276"
dependencies = [
"aho-corasick",
"memchr",
"regex-automata",
"regex-syntax",
]
[[package]]
name = "regex-automata"
version = "0.4.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f"
dependencies = [
"aho-corasick",
"memchr",
"regex-syntax",
]
[[package]]
name = "regex-syntax"
version = "0.8.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a"
[[package]]
name = "rgb"
version = "0.8.53"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "47b34b781b31e5d73e9fbc8689c70551fd1ade9a19e3e28cfec8580a79290cc4"
[[package]]
name = "riscv"
version = "0.15.0"
@@ -1562,6 +1975,25 @@ version = "0.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b1376b15f3ff160e9b1e8ea564ce427f2f6fcf77528cc0a8bf405cb476f9cea7"
[[package]]
name = "rp-pac"
version = "7.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8af65855c40b2c35079514c5489abffc0429347fef25d8467ff98ad84b4322d3"
dependencies = [
"cortex-m",
"cortex-m-rt",
]
[[package]]
name = "rp2040-boot2"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7c92f344f63f950ee36cf4080050e4dce850839b9175da38f9d2ffb69b4dbb21"
dependencies = [
"crc-any",
]
[[package]]
name = "rustc_version"
version = "0.2.3"
@@ -1583,6 +2015,21 @@ version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f"
[[package]]
name = "same-file"
version = "1.0.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502"
dependencies = [
"winapi-util",
]
[[package]]
name = "scopeguard"
version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
[[package]]
name = "semihosting"
version = "0.1.25"
@@ -1647,6 +2094,52 @@ dependencies = [
"unsafe-libyaml",
]
[[package]]
name = "sha2-const-stable"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5f179d4e11094a893b82fff208f74d448a7512f99f5a0acbd5c679b705f83ed9"
[[package]]
name = "sha3"
version = "0.10.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "75872d278a8f37ef87fa0ddbda7802605cb18344497949862c0d4dcb291eba60"
dependencies = [
"digest",
"keccak",
]
[[package]]
name = "siphasher"
version = "1.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b2aa850e253778c88a04c3d7323b043aeda9d3e30d5971937c1855769763678e"
[[package]]
name = "smallvec"
version = "1.15.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03"
[[package]]
name = "smart-leds"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "66df34e571fa9993fa6f99131a374d58ca3d694b75f9baac93458fe0d6057bf0"
dependencies = [
"smart-leds-trait",
]
[[package]]
name = "smart-leds-trait"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a7f4441a131924d58da6b83a7ad765c460e64630cce504376c3a87a2558c487f"
dependencies = [
"rgb",
]
[[package]]
name = "somni-expr"
version = "0.2.0"
@@ -1702,6 +2195,18 @@ dependencies = [
"portable-atomic",
]
[[package]]
name = "string_cache"
version = "0.8.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bf776ba3fa74f83bf4b63c3dcbbf82173db2632ed8452cb2d891d33f459de70f"
dependencies = [
"new_debug_unreachable",
"parking_lot",
"phf_shared",
"precomputed-hash",
]
[[package]]
name = "strsim"
version = "0.11.1"
@@ -1751,6 +2256,15 @@ dependencies = [
"unicode-ident",
]
[[package]]
name = "term"
version = "1.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d8c27177b12a6399ffc08b98f76f7c9a1f4fe9fc967c784c5a071fa8d93cf7e1"
dependencies = [
"windows-sys",
]
[[package]]
name = "termcolor"
version = "1.4.1"
@@ -1840,6 +2354,18 @@ version = "1.0.24"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
[[package]]
name = "unicode-width"
version = "0.1.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af"
[[package]]
name = "unicode-xid"
version = "0.2.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853"
[[package]]
name = "unsafe-libyaml"
version = "0.2.11"
@@ -1918,6 +2444,16 @@ dependencies = [
"vcell",
]
[[package]]
name = "walkdir"
version = "2.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b"
dependencies = [
"same-file",
"winapi-util",
]
[[package]]
name = "winapi-util"
version = "0.1.11"
+46 -2
View File
@@ -3,7 +3,7 @@ members = ["."]
[package]
name = "donglora"
version = "0.1.0"
version = "0.3.0"
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"
@@ -31,6 +32,7 @@ embassy-usb-driver = "0.2"
ucobs = { version = "0.3", features = ["legacy-msrv"] }
heapless = "0.9"
lora-phy = "3.0"
portable-atomic = { version = "1", optional = true, default-features = false, features = ["critical-section"] }
static_cell = "2.1"
# Display rendering
@@ -62,7 +64,11 @@ esp-rtos = { version = "0.2", optional = true, features = [
] }
# nRF52840 (RAK 4631)
cortex-m = { version = "0.7", optional = true, features = [
"critical-section-single-core",
] }
cortex-m-rt = { version = "0.7", optional = true }
defmt-rtt = { version = "1.0", optional = true }
embassy-nrf = { version = "0.10", optional = true, features = [
"nrf52840",
"gpiote",
@@ -71,6 +77,13 @@ embassy-nrf = { version = "0.10", optional = true, features = [
] }
panic-probe = { version = "1.0", optional = true, features = ["print-defmt"] }
# RP2040 (Waveshare RP2040-LoRa)
embassy-rp = { version = "0.9", optional = true, features = [
"rp2040",
"time-driver",
"defmt",
] }
[build-dependencies]
minijinja = "2.18"
owo-colors = "4.3"
@@ -82,6 +95,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,12 +108,35 @@ heltec_v4 = [
"dep:esp-rtos",
]
rak_wisblock_4631 = [
"dep:cortex-m",
"dep:cortex-m-rt",
"cortex-m-rt/set-vtor",
"dep:defmt-rtt",
"dep:embassy-nrf",
"dep:panic-probe",
"embassy-executor/arch-cortex-m",
"embassy-executor/executor-thread",
]
wio_tracker_l1 = [
"dep:cortex-m",
"dep:cortex-m-rt",
"cortex-m-rt/set-vtor",
"dep:defmt-rtt",
"dep:embassy-nrf",
"dep:panic-probe",
"embassy-executor/arch-cortex-m",
"embassy-executor/executor-thread",
]
waveshare_rp2040_lora = [
"dep:cortex-m",
"dep:cortex-m-rt",
"dep:defmt-rtt",
"dep:embassy-rp",
"dep:panic-probe",
"dep:portable-atomic",
"embassy-executor/arch-cortex-m",
"embassy-executor/executor-thread",
]
[lints.clippy]
undocumented_unsafe_blocks = "deny"
@@ -102,4 +144,6 @@ undocumented_unsafe_blocks = "deny"
[profile.release]
codegen-units = 1
lto = "fat"
opt-level = 3 # "s"/"z" break Xtensa (l32r literal range exceeded)
opt-level = 3 # "s"/"z" break Xtensa (l32r literal range exceeded)
panic = "abort"
strip = true
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Stephen Waits <steve@waits.net>
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+34 -5
View File
@@ -18,11 +18,40 @@ 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 |
| Waveshare RP2040-LoRa | `waveshare_rp2040_lora` | RP2040 | SX1262 | Native CDC-ACM | thumbv6m-none-eabi |
| Wio Tracker L1 | `wio_tracker_l1` | nRF52840 | SX1262 | Native CDC-ACM | thumbv7em-none-eabihf |
### Heltec V3 USB Note
The default `heltec_v3` build uses **native USB CDC-ACM**, which requires a
hardware modification to your board. Stock Heltec V3 boards route USB through a
CP2102 bridge chip to UART — the ESP32-S3's native USB pins (GPIO19/GPIO20) are
routed to header pins but not connected to the USB connector.
**To enable native USB on a Heltec V3:**
1. Solder 0-ohm (or 22-ohm) resistors at pads **R29** (D+) and **R3** (D-)
2. Disconnect the CP2102 from the USB connector's D+/D- lines (cut traces or
remove the CP2102 bridge resistors)
After this mod, the board appears as `/dev/ttyACM0` with the DongLoRa VID:PID
(1209:5741), just like the V4 and nRF boards.
**If you have a stock (unmodified) V3**, use `heltec_v3_uart` instead:
```sh
just build heltec_v3_uart
just flash heltec_v3_uart
```
This builds firmware that communicates via the CP2102 bridge (appears as
`/dev/ttyUSB0`).
## Adding a Board
+4
View File
@@ -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
+82 -21
View File
@@ -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,63 @@ 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 the correct layout
// into OUT_DIR based on the target MCU family.
let target = std::env::var("TARGET").unwrap_or_default();
let memory_x = match target.as_str() {
"thumbv7em-none-eabihf" => Some("ld/nrf52840-memory.x"),
"thumbv6m-none-eabi" => Some("ld/rp2040-memory.x"),
_ => None,
};
if let Some(src) = memory_x {
let out_dir = std::env::var("OUT_DIR").unwrap();
fs::copy(src, format!("{out_dir}/memory.x"))
.expect("failed to copy memory.x");
println!("cargo:rustc-link-search={out_dir}");
}
println!("cargo:rerun-if-changed=ld/nrf52840-memory.x");
println!("cargo:rerun-if-changed=ld/rp2040-memory.x");
}
+64 -13
View File
@@ -3,14 +3,18 @@ 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"
waveshare_rp2040_lora := "waveshare_rp2040_lora thumbv6m-none-eabi rp2040"
builds_dir := "builds"
version := `sed -n 's/^version = "\(.*\)"/\1/p' Cargo.toml`
# All known boards
boards := "heltec_v3 heltec_v4 rak_wisblock_4631"
boards := "heltec_v3 heltec_v3_uart heltec_v4 rak_wisblock_4631 waveshare_rp2040_lora wio_tracker_l1"
# Install all required tools and toolchains
setup:
@@ -25,7 +29,8 @@ setup:
@if ! rustup component list --toolchain nightly --installed 2>/dev/null | grep -q "^rust-src"; then \
rustup component add rust-src --toolchain nightly; \
fi
@# ARM target
@# ARM targets
@rustup target list --installed | grep -q "^thumbv6m-none-eabi$" || rustup target add thumbv6m-none-eabi
@rustup target list --installed | grep -q "^thumbv7em-none-eabihf$" || rustup target add thumbv7em-none-eabihf
# Build release firmware for all boards, installing toolchains as needed
@@ -54,35 +59,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 +174,51 @@ _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 \
xtensa-*) \
mise exec -- espflash save-image --chip "$chip" --merge --skip-padding "$src" "$dst.bin"; \
echo "→ $dst.bin"; \
;; \
thumbv6m-*) \
mise exec -- rust-objcopy -O ihex "$src" "$dst.hex"; \
mise exec -- cargo-hex-to-uf2 hex-to-uf2 --input-path "$dst.hex" --output-path "$dst.uf2" --family rp2040; \
rm "$dst.hex"; \
echo "→ $dst.uf2"; \
;; \
thumbv7em-*) \
mise exec -- rust-objcopy -O ihex "$src" "$dst.hex"; \
mise exec -- cargo-hex-to-uf2 hex-to-uf2 --input-path "$dst.hex" --output-path "$dst.uf2" --family nrf52840; \
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}}" == "waveshare_rp2040_lora" ]; then echo "{{waveshare_rp2040_lora}}"; \
elif [ "{{name}}" == "wio_tracker_l1" ]; then echo "{{wio_tracker_l1}}"; \
else echo "Unknown board: {{name}}" >&2; exit 1; fi
+16
View File
@@ -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
}
+24
View File
@@ -0,0 +1,24 @@
/* RP2040 memory layout — UF2 bootloader (ROM boot2)
*
* 0x10000000 – 0x10000100 boot2 (second-stage bootloader, 256 bytes)
* 0x10000100 – 0x10200000 Application <- we live here
*
* Flash: 2MB (W25Q16JV on Waveshare RP2040-LoRa)
* RAM: 264KB SRAM (two 256KB banks + 2x 4KB scratch)
*/
MEMORY
{
BOOT2 : ORIGIN = 0x10000000, LENGTH = 0x100
FLASH : ORIGIN = 0x10000100, LENGTH = 2048K - 0x100
RAM : ORIGIN = 0x20000000, LENGTH = 264K
}
EXTERN(BOOT2_FIRMWARE)
SECTIONS {
.boot2 ORIGIN(BOOT2) :
{
KEEP(*(.boot2));
} > BOOT2
} INSERT BEFORE .text;
+1
View File
@@ -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"
+26 -21
View File
@@ -5,14 +5,15 @@
### 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`)
- RP2040 boards: copy `waveshare_rp2040_lora.rs` (uses shared helpers from `hal/rp2040.rs`)
- New MCU family: create a new `hal/<mcu>.rs` with MCU primitives first
ESP32-S3 boards share common type aliases, peripheral bundles, and init helpers
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 +22,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 +35,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 +86,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 +95,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
View File
@@ -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
View File
@@ -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(),
}
}
}
+93
View File
@@ -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
View File
@@ -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
View File
@@ -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 }}")] {
+69 -70
View File
@@ -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
View File
@@ -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],
}
+141
View File
@@ -0,0 +1,141 @@
use embassy_embedded_hal::shared_bus::asynch::spi::SpiDevice;
use embassy_rp::gpio::{Input, Level, Output, Pull};
use embassy_rp::spi;
use embassy_sync::blocking_mutex::raw::NoopRawMutex;
use embassy_time::Delay;
use lora_phy::iv::GenericSx126xInterfaceVariant;
use lora_phy::sx126x::{self, Sx1262, Sx126x};
use super::traits::{BoardParts, LoRaBoard};
use crate::hal::rp2040 as mcu;
// ── Concrete peripheral types ──────────────────────────────────────
type Nss = Output<'static>;
type Iv = GenericSx126xInterfaceVariant<Output<'static>, Input<'static>>;
type RadioSpiDevice = SpiDevice<'static, NoopRawMutex, mcu::SpiBus, Nss>;
pub type RadioDriver = Sx126x<RadioSpiDevice, Iv, Sx1262>;
pub type UsbDriver = mcu::UsbRpDriver;
pub type LedDriver = crate::driver::simple_led::SimpleLed<Output<'static>>;
// Display types — never instantiated (no OLED on this board), but the
// trait requires concrete associated types that satisfy the bounds.
pub type DisplayI2c = embassy_rp::i2c::I2c<'static, embassy_rp::peripherals::I2C0, embassy_rp::i2c::Async>;
pub type DisplayDriver = ssd1306::Ssd1306Async<
ssd1306::prelude::I2CInterface<DisplayI2c>,
ssd1306::size::DisplaySize128x64,
ssd1306::mode::BufferedGraphicsModeAsync<ssd1306::size::DisplaySize128x64>,
>;
// ── Peripheral bundles ─────────────────────────────────────────────
pub struct RadioParts {
pub driver: RadioDriver,
pub delay: Delay,
}
pub struct UsbParts {
pub driver: UsbDriver,
}
pub struct DisplayParts {
pub i2c: DisplayI2c,
}
// ── Display init ───────────────────────────────────────────────────
pub async fn create_display(_i2c: DisplayI2c) -> Option<DisplayDriver> {
None
}
// ── Board init ─────────────────────────────────────────────────────
pub struct Board {
p: embassy_rp::Peripherals,
}
impl LoRaBoard for Board {
const NAME: &'static str = "Waveshare RP2040-LoRa";
const TX_POWER_RANGE: (i8, i8) = (-9, 22);
type RadioParts = RadioParts;
type CommParts = UsbParts;
type DisplayParts = DisplayParts;
type DisplayDriver = DisplayDriver;
type LedDriver = LedDriver;
fn init() -> Self {
let p = embassy_rp::init(Default::default());
Self { p }
}
fn mac_address() -> [u8; 6] {
mcu::mac_address()
}
fn into_parts(self) -> BoardParts<RadioParts, UsbParts, DisplayParts, LedDriver> {
let p = self.p;
// ── MAC address from flash unique ID ───────────────────
let mut flash = embassy_rp::flash::Flash::<_, _, { 2 * 1024 * 1024 }>::new_blocking(p.FLASH);
mcu::init_mac(&mut flash);
// ── SPI1 bus for SX1262 ────────────────────────────────
//
// Pin mapping from Meshtastic & MeshCore firmware (confirmed working):
// SCK = GPIO14, MOSI = GPIO15, MISO = GPIO24, CS = GPIO13
//
// The Waveshare wiki (https://www.waveshare.com/wiki/RP2040-LoRa) lists
// different pins (MOSI=11, MISO=8, CS=9) but those don't work in practice.
// Both sets are valid SPI1 alternate functions on RP2040.
let mut spi_cfg = spi::Config::default();
spi_cfg.frequency = 1_000_000;
let spi = spi::Spi::new(
p.SPI1, p.PIN_14, p.PIN_15, p.PIN_24, p.DMA_CH0, p.DMA_CH1, spi_cfg,
);
let spi_bus = mcu::share_spi_bus(spi);
let nss = Output::new(p.PIN_13, Level::High);
let spi_device = SpiDevice::new(spi_bus, nss);
let reset = Output::new(p.PIN_23, Level::High);
let dio1 = Input::new(p.PIN_16, Pull::Down);
let busy = Input::new(p.PIN_18, Pull::Down);
// PE4259 antenna switch: GPIO17 = !CTRL (rx_enable).
// DIO2 is internally wired as CTRL (tx_enable), managed by the SX1262.
let rx_enable = Output::new(p.PIN_17, Level::Low);
let iv = GenericSx126xInterfaceVariant::new(reset, dio1, busy, Some(rx_enable), None)
.expect("SX1262 interface init");
let sx_config = sx126x::Config {
chip: Sx1262,
tcxo_ctrl: None, // No TCXO on this board
use_dcdc: true,
rx_boost: false,
};
let radio = RadioParts {
driver: Sx126x::new(spi_device, iv, sx_config),
delay: Delay,
};
// ── USB ────────────────────────────────────────────────
let host = UsbParts {
driver: embassy_rp::usb::Driver::new(p.USB, mcu::Irqs),
};
// ── LED on GPIO25 ──────────────────────────────────────
let led_pin = Output::new(p.PIN_25, Level::Low);
let led = crate::driver::simple_led::SimpleLed(led_pin);
BoardParts {
radio,
host,
display: None,
led,
mac: Self::mac_address(),
}
}
}
+147
View File
@@ -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
View File
@@ -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
View File
@@ -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);
}
}
-5
View File
@@ -1,5 +0,0 @@
//! OLED display driver with radio status dashboard.
mod render;
mod task;
pub use task::display_task;
-398
View File
@@ -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);
}
-239
View File
@@ -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;
}
+5
View File
@@ -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", feature = "waveshare_rp2040_lora"))]
pub mod simple_led;
+132
View File
@@ -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)
}
}
+20
View File
@@ -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();
}
}
}
+82
View File
@@ -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()
}
+13
View File
@@ -0,0 +1,13 @@
//! 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;
#[cfg(feature = "waveshare_rp2040_lora")]
pub mod rp2040;
+82
View File
@@ -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,
]
}
}
+76
View File
@@ -0,0 +1,76 @@
//! RP2040 MCU initialization primitives.
//!
//! Provides shared boilerplate for RP2040 boards: interrupt bindings,
//! type aliases, StaticCell bus wrapping, USB init, and MAC address reading.
use embassy_rp::bind_interrupts;
use embassy_sync::blocking_mutex::raw::NoopRawMutex;
use static_cell::StaticCell;
// ── Interrupts (shared by all RP2040 boards) ──────────────────────
bind_interrupts!(pub struct Irqs {
USBCTRL_IRQ => embassy_rp::usb::InterruptHandler<embassy_rp::peripherals::USB>;
});
// ── MCU-level types ────────────────────────────────────────────────
pub type SpiBus = embassy_rp::spi::Spi<'static, embassy_rp::peripherals::SPI1, embassy_rp::spi::Async>;
pub type UsbRpDriver = embassy_rp::usb::Driver<'static, embassy_rp::peripherals::USB>;
// ── SPI bus sharing ────────────────────────────────────────────────
/// Wrap an initialized SPI peripheral in a shared bus (StaticCell + Mutex).
pub fn share_spi_bus(
spi: SpiBus,
) -> &'static embassy_sync::mutex::Mutex<NoopRawMutex, SpiBus> {
static SPI_BUS: StaticCell<embassy_sync::mutex::Mutex<NoopRawMutex, SpiBus>> =
StaticCell::new();
SPI_BUS.init(embassy_sync::mutex::Mutex::new(spi))
}
// ── MAC address ────────────────────────────────────────────────────
/// Store for the MAC address derived from flash unique ID.
/// Populated once during `init_mac()`, read by `mac_address()`.
static MAC: StaticCell<[u8; 6]> = StaticCell::new();
static mut MAC_REF: Option<&'static [u8; 6]> = None;
/// Read the flash chip's unique ID and derive a 6-byte MAC address.
///
/// Must be called once during board init before `mac_address()`.
pub fn init_mac<const FLASH_SIZE: usize>(
flash: &mut embassy_rp::flash::Flash<'_, embassy_rp::peripherals::FLASH, embassy_rp::flash::Blocking, FLASH_SIZE>,
) {
let mut uid = [0u8; 8];
flash.blocking_unique_id(&mut uid).expect("flash unique ID");
// XOR the first 2 bytes into bytes 2..8 to fold 8 bytes into 6,
// then set the locally-administered bit (byte 0, bit 1).
let mac = [
uid[2] ^ uid[0] | 0x02,
uid[3] ^ uid[1],
uid[4],
uid[5],
uid[6],
uid[7],
];
let stored = MAC.init(mac);
// SAFETY: Written once during single-threaded init, before the executor
// starts. All subsequent reads are from &'static reference.
unsafe {
MAC_REF = Some(stored);
}
}
/// Return the MAC address derived from the flash unique ID.
///
/// Panics if `init_mac()` has not been called.
pub fn mac_address() -> [u8; 6] {
// SAFETY: MAC_REF is set once during init before the executor starts,
// and only read (never written) after that.
unsafe {
*MAC_REF.expect("init_mac() must be called before mac_address()")
}
}
+124
View File
@@ -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;
}
}
}
+16
View File
@@ -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;
}
}
+75
View File
@@ -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
View File
@@ -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
View File
@@ -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", feature = "waveshare_rp2040_lora"))] {
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", feature = "waveshare_rp2040_lora"))] {
#[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
View File
@@ -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),
] {
+32 -17
View File
@@ -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?;
-4
View File
@@ -1,4 +0,0 @@
//! LoRa radio control via SX1262.
mod task;
pub use task::radio_task;
-4
View File
@@ -1,4 +0,0 @@
//! USB CDC-ACM interface with COBS-framed command/response protocol.
mod task;
pub use task::usb_task;
-234
View File
@@ -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;
}
}
}