Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0033bbb941
|
||
|
|
42a6b1142d
|
||
|
|
fa1eeceb7c
|
||
|
|
027ac5eb04
|
||
|
|
b7d9b37bb0
|
||
|
|
b0ce0a7aca
|
||
|
|
2a3c2be060
|
||
|
|
a0b15ddcaa
|
||
|
|
388d07e90f
|
||
|
|
c6ef9559ec
|
||
|
|
596bc3ed96
|
||
|
|
272abceedb
|
||
|
|
f9582c0b94
|
||
|
|
649c29218c
|
||
|
|
fd4e180879
|
||
|
|
8b549d49a9
|
||
|
|
7184610eea
|
||
|
|
46822c182c
|
||
|
|
2b7aacdb99
|
||
|
|
cabc8c1cc0
|
||
|
|
0cbf59765c
|
||
|
|
de38278d7b
|
No files matched your search
@@ -11,3 +11,10 @@ rustflags = [
|
|||||||
"-C", "link-arg=-Tdefmt.x",
|
"-C", "link-arg=-Tdefmt.x",
|
||||||
"-C", "opt-level=z",
|
"-C", "opt-level=z",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[target.thumbv6m-none-eabi]
|
||||||
|
rustflags = [
|
||||||
|
"-C", "link-arg=-Tlink.x",
|
||||||
|
"-C", "link-arg=-Tdefmt.x",
|
||||||
|
"-C", "opt-level=z",
|
||||||
|
]
|
||||||
File diff suppressed because it is too large.
Load diff
+324
@@ -0,0 +1,324 @@
|
|||||||
|
# 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/).
|
||||||
|
|
||||||
|
## [Unreleased]
|
||||||
|
|
||||||
|
## [1.2.0] - 2026-04-22
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **OLED auto-dim after inactivity.** The display is bright on any
|
||||||
|
activity — splash entry, mode change into a dashboard leaf, packet
|
||||||
|
RX/TX — and dims after a short idle window. Splash holds bright for
|
||||||
|
10 s, then drops to dim while the LearnMore / Info carousel keeps
|
||||||
|
alternating. The dashboard stays bright for 3 s after the last
|
||||||
|
packet or mode change, then dims in place without changing
|
||||||
|
Listening/Transmitting state. Cross-leaf TX↔RX hops transition
|
||||||
|
bright→bright with no intermediate dim — `set_dim` is fired as an
|
||||||
|
action by the 3 s timer handler rather than as a sibling `exit:`
|
||||||
|
action, so mode-change leaf-crossings never see a flash.
|
||||||
|
- `DisplayBrightness` trait in `src/driver/mod.rs` unifies the
|
||||||
|
two-level backlight API across the `ssd1306` crate's `Ssd1306Async`
|
||||||
|
and the in-tree `Sh1106` driver. Contrast values centralized as
|
||||||
|
`CONTRAST_BRIGHT` (`0xFF`) and `CONTRAST_DIM` (`0x08`) constants so
|
||||||
|
the two levels can be tuned in one place.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- `src/display.rs` statechart restructured:
|
||||||
|
- `Splash` gains `entry: set_bright` plus an action-only
|
||||||
|
`on(after 10s) => set_dim` handler so the splash bright window
|
||||||
|
is time-scoped without disturbing the LM↔Info alternation.
|
||||||
|
- `Dashboard` `Listening` and `Transmitting` each gain a nested
|
||||||
|
`*Bright` substate owning the `entry: set_bright` action. Packet
|
||||||
|
handlers moved into the leaves and target the leaf's `*Bright` —
|
||||||
|
sibling-to-sibling within the leaf restarts the 3 s timer without
|
||||||
|
re-entering the leaf itself (paint / 1 s sparkline timer
|
||||||
|
preserved). The 3 s timeout handler fires `set_dim` as an action
|
||||||
|
and up-transitions to the leaf, parking the machine at the leaf
|
||||||
|
with no active child.
|
||||||
|
- `On.on(CmdReset)` now targets `LearnMore` directly rather than
|
||||||
|
`Splash`. Under hsmc 0.2 up-transition semantics, targeting
|
||||||
|
`Splash` from within `Splash.LearnMore` (USB-disconnect race path)
|
||||||
|
would only unwind below `Splash` and park with no active child,
|
||||||
|
leaving the LM↔Info alternation timer dead and the QR frozen on
|
||||||
|
screen.
|
||||||
|
- `LoRaBoard::DisplayDriver` trait bound extended with
|
||||||
|
`+ DisplayBrightness` so brightness is part of the board contract
|
||||||
|
alongside `DrawTarget<Color = BinaryColor>`.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Elecrow ThinkNode-M2 board doc corrected: the shipping revision
|
||||||
|
carries a WCH CH340K USB-UART bridge (USB `1a86:7522`), not a
|
||||||
|
Silicon Labs CP2102. `just flash elecrow_thinknode_m2` now matches
|
||||||
|
the CH340K / CH340 VID:PIDs directly instead of falling through
|
||||||
|
to espflash auto-detect.
|
||||||
|
|
||||||
|
### Dependencies
|
||||||
|
|
||||||
|
- `hsmc` bumped to `0.2` to pick up the up-transition fix (transition
|
||||||
|
to an already-active ancestor unwinds only the subtree strictly
|
||||||
|
below the target; no exit/entry on the target; no default descent).
|
||||||
|
|
||||||
|
## [1.1.0] - 2026-04-22
|
||||||
|
|
||||||
|
Small but high-value behavior change: `SET_CONFIG` issued while the
|
||||||
|
radio is in continuous RX no longer drops the radio (and the OLED)
|
||||||
|
back to idle. The radio now auto-re-arms RX with the new modulation
|
||||||
|
once reconfiguration completes, matching DongLoRa Protocol 1.1's
|
||||||
|
§3.5 / §6.3 amendments.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- `src/radio.rs`: inside `state Receiving`, add `on(ConfigApplied)
|
||||||
|
=> ResumingRx` to shadow the parent `Configured` state's
|
||||||
|
`on(ConfigApplied) => Idle` handler. The reconfigure path now
|
||||||
|
runs `apply_set_config` → `ConfigApplied` → `ResumingRx.entry:
|
||||||
|
try_start_rx_hw` → `StartRxOk` → `Receiving` without ever
|
||||||
|
entering `Idle`.
|
||||||
|
- The OLED's `Dashboard → Splash` transition was previously driven
|
||||||
|
by `RadioEvent::Idle` firing on `Configured.Idle.entry`. That
|
||||||
|
event no longer fires during a SET_CONFIG-from-Receiving
|
||||||
|
sequence, so the display stays on Dashboard.
|
||||||
|
|
||||||
|
### Dependencies
|
||||||
|
|
||||||
|
- `donglora-protocol` bumped to `1.1.0` (spec-only change; matches
|
||||||
|
the new normative text the firmware now implements).
|
||||||
|
|
||||||
|
### Use-case unlock
|
||||||
|
|
||||||
|
- Scanner / time-division / dynamic-modulation workflows that
|
||||||
|
rotate LoRa parameters mid-RX no longer need to re-issue
|
||||||
|
`RX_START` after every `SET_CONFIG`. One call, radio continues
|
||||||
|
to receive.
|
||||||
|
|
||||||
|
## [1.0.0] - 2026-04-22
|
||||||
|
|
||||||
|
The 1.0 release. Firmware now speaks DongLoRa Protocol v2 end to end,
|
||||||
|
depends on the freshly-published `donglora-protocol` 1.0.0 and `hsmc`
|
||||||
|
0.1.0 from crates.io, and ships GitHub Release binaries for every
|
||||||
|
supported board.
|
||||||
|
|
||||||
|
### Breaking
|
||||||
|
|
||||||
|
- **DongLoRa Protocol v2 wire format** (wire-incompatible with 0.x
|
||||||
|
clients and firmware). Every `type_id`, tag-correlation rule, and
|
||||||
|
field layout follows `PROTOCOL.md` v1.0.
|
||||||
|
- **Host framing replaced.** `src/host/framing.rs` now drives a
|
||||||
|
streaming `FrameDecoder` from `donglora-protocol` and emits
|
||||||
|
`(type_id, tag, payload)` frames; the 0.x COBS-per-response shim is
|
||||||
|
gone.
|
||||||
|
- **Radio task reshape.** `src/radio.rs` emits v2 `TX_DONE` /
|
||||||
|
`SetConfigResult` / `RxPayload` / `RxOrigin` shapes directly; no 0.x
|
||||||
|
translation layer remains.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- `hsmc` switched from path-dep on the local workspace to
|
||||||
|
`version = "0.1.0"` from crates.io.
|
||||||
|
- `donglora-protocol` switched from path-dep on `../protocol` to
|
||||||
|
`version = "1.0.0"` from crates.io; firmware builds are now
|
||||||
|
reproducible from a clean checkout with no sibling repo required.
|
||||||
|
- `PROTOCOL.md` updated to the DongLoRa Protocol v2 normative spec,
|
||||||
|
kept byte-for-byte in sync with the `donglora-protocol` crate's
|
||||||
|
copy.
|
||||||
|
|
||||||
|
### Infrastructure
|
||||||
|
|
||||||
|
- Cargo.toml `repository` + `homepage` now point at the canonical
|
||||||
|
`https://github.com/donglora/firmware` (the `swaits/donglora`
|
||||||
|
repo is archived).
|
||||||
|
|
||||||
|
## [0.5.0] - 2026-04-16
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- BLE soft-mesh multiplexer design document (`BLE_MUX_DESIGN_DOC.md`) —
|
||||||
|
specification for turning a dongle from a 1:1 USB device into a shared
|
||||||
|
near-field radio gateway for multiple BLE-attached phones. Three-phase
|
||||||
|
rollout (v1 MVP, v2 hardening, v3 multi-dongle mesh) with regulatory
|
||||||
|
duty-cycle and TX-access-control enforcement from v1. Targets nRF52840
|
||||||
|
first; ESP32-S3 reserved for v2.
|
||||||
|
- `debug-checkpoint` cargo feature with a dedicated `debug_blink` task and
|
||||||
|
a latching atomic checkpoint — LED-pattern diagnostic for UART-only
|
||||||
|
boards where defmt output would corrupt the host protocol. Zero
|
||||||
|
footprint when the feature is off.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Radio task rewritten as an `hsmc` statechart (`Unconfigured` /
|
||||||
|
`Configured.{Idle, StartingRx, Receiving, ResumingRx,
|
||||||
|
Transmitting.{FromIdle, FromRx}}`). The main loop is now a single
|
||||||
|
`machine.run().await`; command reading, packet RX, and TX are
|
||||||
|
state-scoped `during:` activities. Post-TX RX re-arm is modeled as
|
||||||
|
state identity (`TransmittingFromRx` → `ResumingRx`) instead of a
|
||||||
|
`was_receiving` flag.
|
||||||
|
- Display task rewritten as an `hsmc` statechart (`Off` /
|
||||||
|
`On.{Splash.{LearnMore, Info}, Dashboard.{Listening, Transmitting}}`).
|
||||||
|
The 5 s splash alternation and 1 s sparkline tick use `on(after …)` /
|
||||||
|
`on(every …)` transitions — no external timer task.
|
||||||
|
- USB host task now carries a small `hsmc` statechart (`Disconnected` /
|
||||||
|
`Connected`) whose entry/exit actions handle display wake, radio
|
||||||
|
`StopRx`, and display reset — replacing the open-coded `was_connected`
|
||||||
|
edge detector.
|
||||||
|
- Radio → Display channel switched from a `RadioStatus` watch to a typed
|
||||||
|
`RadioEvent` queue. The radio emits discrete `EnteredRx` / `EnteredTx`
|
||||||
|
/ `Idle` / `PacketRx` / `PacketTx` / `ConfigChanged` events; downstream
|
||||||
|
consumers observe transitions directly rather than diffing snapshots.
|
||||||
|
- Wire protocol types and COBS framing moved out of firmware into the
|
||||||
|
shared `donglora-protocol` crate (path dep on `../protocol`). Firmware
|
||||||
|
`src/protocol.rs` is now a re-export shim preserving every call site;
|
||||||
|
`src/host/framing.rs` keeps only the embassy-specific `route_command`.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- USB disconnect recovery: on a read error the DTR-edge-detect branch
|
||||||
|
now runs unconditionally (was early-continuing in some paths, starving
|
||||||
|
the display-reset / radio-stop handoff).
|
||||||
|
|
||||||
|
## [0.4.0] - 2026-04-11
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- ELECROW ThinkNode-M2 board support (ESP32-S3 + SX1262 + 1.3" SH1106 OLED
|
||||||
|
via CP2102 UART bridge, 1000 mAh Li-Po handheld)
|
||||||
|
- Pin map verified against Meshtastic's
|
||||||
|
`variants/esp32s3/ELECROW-ThinkNode-M2`
|
||||||
|
- DIO3 TCXO driven at 3.3 V (not 1.8 V like Heltec)
|
||||||
|
- Dedicated `LORA_POWER_EN` (GPIO48) sequenced HIGH before SPI init
|
||||||
|
- `VEXT_ENABLE` (GPIO46) is active HIGH — opposite polarity from Heltec
|
||||||
|
- Reuses the existing async SH1106 driver at I2C address 0x3C
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- `hal::esp32s3::{init_spi, init_i2c}` and `board::esp32s3::init_radio` are
|
||||||
|
now generic over GPIO type, so ESP32-S3 boards with different pin
|
||||||
|
assignments can share the helpers.
|
||||||
|
- `board::esp32s3::init_radio` takes a `TcxoCtrlVoltage` parameter instead
|
||||||
|
of hardcoding `Ctrl1V8` — needed for ThinkNode-M2's 3.3 V TCXO.
|
||||||
|
- SSD1306 display items in `board::esp32s3` are now cfg-gated to Heltec
|
||||||
|
boards (ThinkNode-M2 supplies its own SH1106 `create_display`).
|
||||||
|
- `justfile` uses `cargo +esp` for Xtensa targets instead of `cargo +nightly`
|
||||||
|
with a `RUSTC=esp` override — the self-contained esp toolchain keeps its
|
||||||
|
cargo, rustc, and rust-src versions aligned. Public nightly's rust-src
|
||||||
|
had drifted ahead of the esp fork, breaking `-Zbuild-std`.
|
||||||
|
- Migrated to embassy-executor 0.10 and embassy-rp 0.10
|
||||||
|
- ESP crates (esp-hal, esp-rtos, esp-backtrace, esp-println) patched from
|
||||||
|
esp-rs/esp-hal main branch until esp-rtos publishes with 0.10 support
|
||||||
|
- Adapted to embassy-executor 0.10 SpawnToken API (task functions now return
|
||||||
|
`Result<SpawnToken, SpawnError>`, `Spawner::spawn` no longer returns Result)
|
||||||
|
- Adapted to esp-hal main branch API changes: `efuse::base_mac_address()`,
|
||||||
|
`esp_rtos::start` now requires `SoftwareInterrupt`, embassy-rp SPI
|
||||||
|
constructor now requires DMA interrupt bindings
|
||||||
|
- `just check` now runs clippy with `-D warnings` instead of plain `cargo check`
|
||||||
|
- Rust toolchain bumped to 1.94 in mise.toml
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- `core::mem::forget(output)` pattern in ESP32-S3 board files is now
|
||||||
|
`let _vext = Output::new(...)` — current esp-hal makes `Output` non-Drop,
|
||||||
|
which made `forget` a no-op and tripped clippy's `forget_non_drop` lint.
|
||||||
|
- UART boards (heltec_v3_uart) display stuck on splash screen after boot.
|
||||||
|
Host task sent `DisplayCommand::Reset` (which sets disconnected=true) instead
|
||||||
|
of `DisplayCommand::On`. UART has no DTR, so nothing ever cleared it.
|
||||||
|
|
||||||
|
## [0.3.0] - 2026-04-10
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Waveshare RP2040-LoRa board support (RP2040 + SX1262, no OLED, LED on GPIO25)
|
||||||
|
- First RP2040 board — third MCU family after ESP32-S3 and nRF52840
|
||||||
|
- New `hal/rp2040.rs` module with MCU primitives (SPI, USB, flash unique ID)
|
||||||
|
- USB-C via FPC ribbon cable to adapter board
|
||||||
|
- PE4259 antenna switch on GPIO17, 850-930 MHz (HF)
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- `cortex-m-rt/set-vtor` moved from global dependency to per-board feature
|
||||||
|
(Cortex-M0+ has no VTOR register)
|
||||||
|
- `build.rs` memory.x selection is now target-aware (thumbv6m vs thumbv7em)
|
||||||
|
|
||||||
|
## [0.2.1] - 2026-04-07
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- 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
+800
-71
File diff suppressed because it is too large.
Load diff
+72
-12
@@ -3,20 +3,19 @@ members = ["."]
|
|||||||
|
|
||||||
[package]
|
[package]
|
||||||
name = "donglora"
|
name = "donglora"
|
||||||
version = "0.2.0"
|
version = "1.2.0"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
build = "build.rs"
|
build = "build.rs"
|
||||||
authors = ["Stephen Waits <steve@waits.net>"]
|
authors = ["Stephen Waits <steve@waits.net>"]
|
||||||
license = "MIT"
|
license = "MIT"
|
||||||
description = "Transparent LoRa radio over USB — plug in a board, talk LoRa from your host"
|
description = "Transparent LoRa radio over USB — plug in a board, talk LoRa from your host"
|
||||||
repository = "https://github.com/swaits/donglora"
|
repository = "https://github.com/donglora/firmware"
|
||||||
|
homepage = "https://github.com/donglora/firmware"
|
||||||
keywords = ["lora", "radio", "embedded", "usb", "firmware"]
|
keywords = ["lora", "radio", "embedded", "usb", "firmware"]
|
||||||
categories = ["embedded", "hardware-support", "no-std"]
|
categories = ["embedded", "hardware-support", "no-std"]
|
||||||
|
|
||||||
# UPGRADE BLOCKED: embassy-executor 0.10 and ucobs 0.3 need Rust >= 1.93
|
# NOTE: esp-rtos patched from GitHub main (see [patch.crates-io] below).
|
||||||
# stable. The ESP Xtensa toolchain (espup) is a nightly fork that may not
|
# Remove patches once esp-rtos publishes with embassy-executor 0.10 support.
|
||||||
# support the required language features or pass MSRV gating. Revisit when
|
|
||||||
# espup catches up. Check: rustup run esp rustc --version
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
cfg-if = "1.0"
|
cfg-if = "1.0"
|
||||||
critical-section = "1.2"
|
critical-section = "1.2"
|
||||||
@@ -24,14 +23,17 @@ defmt = "1.0"
|
|||||||
embedded-hal = "1.0"
|
embedded-hal = "1.0"
|
||||||
embedded-hal-async = "1.0"
|
embedded-hal-async = "1.0"
|
||||||
embassy-embedded-hal = "0.6"
|
embassy-embedded-hal = "0.6"
|
||||||
embassy-executor = "0.9"
|
embassy-executor = "0.10"
|
||||||
embassy-sync = "0.8"
|
embassy-sync = "0.8"
|
||||||
embassy-time = "0.5"
|
embassy-time = "0.5"
|
||||||
embassy-usb = { version = "0.6", features = ["defmt"] }
|
embassy-usb = { version = "0.6", features = ["defmt"] }
|
||||||
embassy-usb-driver = "0.2"
|
embassy-usb-driver = "0.2"
|
||||||
ucobs = { version = "0.3", features = ["legacy-msrv"] }
|
ucobs = { version = "0.3", features = ["legacy-msrv"] }
|
||||||
heapless = "0.9"
|
heapless = "0.9"
|
||||||
|
donglora-protocol = "1"
|
||||||
|
hsmc = { version = "0.2", default-features = false, features = ["embassy"] }
|
||||||
lora-phy = "3.0"
|
lora-phy = "3.0"
|
||||||
|
portable-atomic = { version = "1", optional = true, default-features = false, features = ["critical-section"] }
|
||||||
static_cell = "2.1"
|
static_cell = "2.1"
|
||||||
|
|
||||||
# Display rendering
|
# Display rendering
|
||||||
@@ -63,8 +65,10 @@ esp-rtos = { version = "0.2", optional = true, features = [
|
|||||||
] }
|
] }
|
||||||
|
|
||||||
# nRF52840 (RAK 4631)
|
# nRF52840 (RAK 4631)
|
||||||
cortex-m = { version = "0.7", optional = true, features = ["critical-section-single-core"] }
|
cortex-m = { version = "0.7", optional = true, features = [
|
||||||
cortex-m-rt = { version = "0.7", optional = true, features = ["set-vtor"] }
|
"critical-section-single-core",
|
||||||
|
] }
|
||||||
|
cortex-m-rt = { version = "0.7", optional = true }
|
||||||
defmt-rtt = { version = "1.0", optional = true }
|
defmt-rtt = { version = "1.0", optional = true }
|
||||||
embassy-nrf = { version = "0.10", optional = true, features = [
|
embassy-nrf = { version = "0.10", optional = true, features = [
|
||||||
"nrf52840",
|
"nrf52840",
|
||||||
@@ -74,47 +78,103 @@ embassy-nrf = { version = "0.10", optional = true, features = [
|
|||||||
] }
|
] }
|
||||||
panic-probe = { version = "1.0", optional = true, features = ["print-defmt"] }
|
panic-probe = { version = "1.0", optional = true, features = ["print-defmt"] }
|
||||||
|
|
||||||
|
# RP2040 (Waveshare RP2040-LoRa)
|
||||||
|
embassy-rp = { version = "0.10", optional = true, features = [
|
||||||
|
"rp2040",
|
||||||
|
"time-driver",
|
||||||
|
"defmt",
|
||||||
|
] }
|
||||||
|
|
||||||
[build-dependencies]
|
[build-dependencies]
|
||||||
minijinja = "2.18"
|
minijinja = "2.18"
|
||||||
owo-colors = "4.3"
|
owo-colors = "4.3"
|
||||||
|
|
||||||
[features]
|
[features]
|
||||||
|
# The `embassy` feature gates the async-actions codepath inside the `hsmc`
|
||||||
|
# statechart macro. Its `#[cfg(feature = "embassy")]` attributes are
|
||||||
|
# evaluated in *this* crate's context (a proc-macro cfg quirk), so every
|
||||||
|
# board feature must enable it. `tokio` is declared only to silence the
|
||||||
|
# macro's `#[cfg(feature = "tokio")]` checks.
|
||||||
|
embassy = []
|
||||||
|
tokio = []
|
||||||
|
# When enabled, replaces the display task with a LED-based diagnostic
|
||||||
|
# blinker driven by `crate::debug_blink::CHECKPOINT`. Used for diagnosing
|
||||||
|
# the firmware on UART-only boards where defmt output would corrupt the
|
||||||
|
# host protocol on the same UART. The LED flashes N times then pauses —
|
||||||
|
# a human watches the pattern and reports N to determine where execution
|
||||||
|
# reached / stopped.
|
||||||
|
debug-checkpoint = []
|
||||||
heltec_v3 = [
|
heltec_v3 = [
|
||||||
|
"embassy",
|
||||||
"dep:esp-backtrace",
|
"dep:esp-backtrace",
|
||||||
"dep:esp-hal",
|
"dep:esp-hal",
|
||||||
"dep:esp-println",
|
"dep:esp-println",
|
||||||
"dep:esp-rtos",
|
"dep:esp-rtos",
|
||||||
]
|
]
|
||||||
heltec_v3_uart = [
|
heltec_v3_uart = [
|
||||||
|
"embassy",
|
||||||
"dep:esp-backtrace",
|
"dep:esp-backtrace",
|
||||||
"dep:esp-hal",
|
"dep:esp-hal",
|
||||||
"dep:esp-println",
|
"dep:esp-println",
|
||||||
"dep:esp-rtos",
|
"dep:esp-rtos",
|
||||||
]
|
]
|
||||||
heltec_v4 = [
|
heltec_v4 = [
|
||||||
|
"embassy",
|
||||||
|
"dep:esp-backtrace",
|
||||||
|
"dep:esp-hal",
|
||||||
|
"dep:esp-println",
|
||||||
|
"dep:esp-rtos",
|
||||||
|
]
|
||||||
|
elecrow_thinknode_m2 = [
|
||||||
|
"embassy",
|
||||||
"dep:esp-backtrace",
|
"dep:esp-backtrace",
|
||||||
"dep:esp-hal",
|
"dep:esp-hal",
|
||||||
"dep:esp-println",
|
"dep:esp-println",
|
||||||
"dep:esp-rtos",
|
"dep:esp-rtos",
|
||||||
]
|
]
|
||||||
rak_wisblock_4631 = [
|
rak_wisblock_4631 = [
|
||||||
|
"embassy",
|
||||||
"dep:cortex-m",
|
"dep:cortex-m",
|
||||||
"dep:cortex-m-rt",
|
"dep:cortex-m-rt",
|
||||||
|
"cortex-m-rt/set-vtor",
|
||||||
"dep:defmt-rtt",
|
"dep:defmt-rtt",
|
||||||
"dep:embassy-nrf",
|
"dep:embassy-nrf",
|
||||||
"dep:panic-probe",
|
"dep:panic-probe",
|
||||||
"embassy-executor/arch-cortex-m",
|
"embassy-executor/platform-cortex-m",
|
||||||
"embassy-executor/executor-thread",
|
"embassy-executor/executor-thread",
|
||||||
]
|
]
|
||||||
wio_tracker_l1 = [
|
wio_tracker_l1 = [
|
||||||
|
"embassy",
|
||||||
"dep:cortex-m",
|
"dep:cortex-m",
|
||||||
"dep:cortex-m-rt",
|
"dep:cortex-m-rt",
|
||||||
|
"cortex-m-rt/set-vtor",
|
||||||
"dep:defmt-rtt",
|
"dep:defmt-rtt",
|
||||||
"dep:embassy-nrf",
|
"dep:embassy-nrf",
|
||||||
"dep:panic-probe",
|
"dep:panic-probe",
|
||||||
"embassy-executor/arch-cortex-m",
|
"embassy-executor/platform-cortex-m",
|
||||||
"embassy-executor/executor-thread",
|
"embassy-executor/executor-thread",
|
||||||
]
|
]
|
||||||
|
waveshare_rp2040_lora = [
|
||||||
|
"embassy",
|
||||||
|
"dep:cortex-m",
|
||||||
|
"dep:cortex-m-rt",
|
||||||
|
"dep:defmt-rtt",
|
||||||
|
"dep:embassy-rp",
|
||||||
|
"dep:panic-probe",
|
||||||
|
"dep:portable-atomic",
|
||||||
|
"embassy-executor/platform-cortex-m",
|
||||||
|
"embassy-executor/executor-thread",
|
||||||
|
]
|
||||||
|
|
||||||
|
# REVISIT: esp-rtos 0.2.0 on crates.io pins embassy-executor ^0.9. The
|
||||||
|
# esp-rs/esp-hal main branch already supports 0.10. Remove these patches once
|
||||||
|
# esp-rtos publishes a release with embassy-executor 0.10 support. Track:
|
||||||
|
# https://github.com/esp-rs/esp-hal/tree/main/esp-rtos
|
||||||
|
[patch.crates-io]
|
||||||
|
esp-hal = { git = "https://github.com/esp-rs/esp-hal.git", branch = "main" }
|
||||||
|
esp-rtos = { git = "https://github.com/esp-rs/esp-hal.git", branch = "main" }
|
||||||
|
esp-backtrace = { git = "https://github.com/esp-rs/esp-hal.git", branch = "main" }
|
||||||
|
esp-println = { git = "https://github.com/esp-rs/esp-hal.git", branch = "main" }
|
||||||
|
|
||||||
[lints.clippy]
|
[lints.clippy]
|
||||||
undocumented_unsafe_blocks = "deny"
|
undocumented_unsafe_blocks = "deny"
|
||||||
@@ -122,6 +182,6 @@ undocumented_unsafe_blocks = "deny"
|
|||||||
[profile.release]
|
[profile.release]
|
||||||
codegen-units = 1
|
codegen-units = 1
|
||||||
lto = "fat"
|
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"
|
panic = "abort"
|
||||||
strip = true
|
strip = true
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2026 Stephen Waits <steve@waits.net>
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
+1901
-269
File diff suppressed because it is too large.
Load diff
@@ -23,7 +23,9 @@ All commands are run from the `firmware/` directory.
|
|||||||
| Heltec V3 | `heltec_v3` | ESP32-S3 | SX1262 | Native CDC-ACM | xtensa-esp32s3-none-elf |
|
| Heltec V3 | `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 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 |
|
| Heltec V4 | `heltec_v4` | ESP32-S3 | SX1262 | Native CDC-ACM | xtensa-esp32s3-none-elf |
|
||||||
|
| ELECROW ThinkNode-M2 | `elecrow_thinknode_m2` | ESP32-S3 | SX1262 | CP2102 bridge | xtensa-esp32s3-none-elf |
|
||||||
| RAK WisBlock 4631 | `rak_wisblock_4631` | nRF52840 | SX1262 | Native CDC-ACM | thumbv7em-none-eabihf |
|
| 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 |
|
| Wio Tracker L1 | `wio_tracker_l1` | nRF52840 | SX1262 | Native CDC-ACM | thumbv7em-none-eabihf |
|
||||||
|
|
||||||
### Heltec V3 USB Note
|
### Heltec V3 USB Note
|
||||||
|
|||||||
@@ -108,14 +108,19 @@ fn main() {
|
|||||||
println!("cargo:rerun-if-changed=src/board/mod.rs.j2");
|
println!("cargo:rerun-if-changed=src/board/mod.rs.j2");
|
||||||
println!("cargo:rerun-if-changed={}", board_dir);
|
println!("cargo:rerun-if-changed={}", board_dir);
|
||||||
|
|
||||||
// cortex-m-rt's link.x does `INCLUDE memory.x`. Copy our nRF layout into
|
// cortex-m-rt's link.x does `INCLUDE memory.x`. Copy the correct layout
|
||||||
// OUT_DIR so it's only visible to ARM builds.
|
// into OUT_DIR based on the target MCU family.
|
||||||
let target = std::env::var("TARGET").unwrap_or_default();
|
let target = std::env::var("TARGET").unwrap_or_default();
|
||||||
if !target.starts_with("xtensa") {
|
let memory_x = match target.as_str() {
|
||||||
|
"thumbv7em-none-eabihf" => Some("ld/nrf52840-memory.x"),
|
||||||
|
"thumbv6m-none-eabi" => Some("ld/rp2040-memory.x"),
|
||||||
|
_ => None,
|
||||||
|
};
|
||||||
|
if let Some(src) = memory_x {
|
||||||
let out_dir = std::env::var("OUT_DIR").unwrap();
|
let out_dir = std::env::var("OUT_DIR").unwrap();
|
||||||
fs::copy("ld/nrf52840-memory.x", format!("{out_dir}/memory.x"))
|
fs::copy(src, format!("{out_dir}/memory.x")).expect("failed to copy memory.x");
|
||||||
.expect("failed to copy ld/nrf52840-memory.x");
|
|
||||||
println!("cargo:rustc-link-search={out_dir}");
|
println!("cargo:rustc-link-search={out_dir}");
|
||||||
}
|
}
|
||||||
println!("cargo:rerun-if-changed=ld/nrf52840-memory.x");
|
println!("cargo:rerun-if-changed=ld/nrf52840-memory.x");
|
||||||
|
println!("cargo:rerun-if-changed=ld/rp2040-memory.x");
|
||||||
}
|
}
|
||||||
@@ -1,19 +1,24 @@
|
|||||||
set shell := ["bash", "-c"]
|
set shell := ["bash", "-c"]
|
||||||
|
|
||||||
# Board definitions: feature target chip
|
# Board definitions: feature target chip
|
||||||
# Xtensa targets use nightly cargo + esp rustc + -Zbuild-std=core.
|
# Xtensa targets use the self-contained esp toolchain (espup-installed) with
|
||||||
|
# `-Zbuild-std=core,alloc`. Using `+esp` rather than `+nightly` keeps the
|
||||||
|
# compiler and rust-src versions aligned — the public nightly drifts ahead of
|
||||||
|
# the esp fork and breaks core-stdlib compilation otherwise.
|
||||||
# Tool versions are pinned in mise.toml; run `just setup` to install everything.
|
# Tool versions are pinned in mise.toml; run `just setup` to install everything.
|
||||||
heltec_v3 := "heltec_v3 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_v3_uart := "heltec_v3_uart xtensa-esp32s3-none-elf esp32s3"
|
||||||
heltec_v4 := "heltec_v4 xtensa-esp32s3-none-elf esp32s3"
|
heltec_v4 := "heltec_v4 xtensa-esp32s3-none-elf esp32s3"
|
||||||
|
elecrow_thinknode_m2 := "elecrow_thinknode_m2 xtensa-esp32s3-none-elf esp32s3"
|
||||||
rak_wisblock_4631 := "rak_wisblock_4631 thumbv7em-none-eabihf nRF52840_xxAA"
|
rak_wisblock_4631 := "rak_wisblock_4631 thumbv7em-none-eabihf nRF52840_xxAA"
|
||||||
wio_tracker_l1 := "wio_tracker_l1 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"
|
builds_dir := "builds"
|
||||||
version := `sed -n 's/^version = "\(.*\)"/\1/p' Cargo.toml`
|
version := `sed -n 's/^version = "\(.*\)"/\1/p' Cargo.toml`
|
||||||
|
|
||||||
# All known boards
|
# All known boards
|
||||||
boards := "heltec_v3 heltec_v3_uart heltec_v4 rak_wisblock_4631 wio_tracker_l1"
|
boards := "heltec_v3 heltec_v3_uart heltec_v4 elecrow_thinknode_m2 rak_wisblock_4631 waveshare_rp2040_lora wio_tracker_l1"
|
||||||
|
|
||||||
# Install all required tools and toolchains
|
# Install all required tools and toolchains
|
||||||
setup:
|
setup:
|
||||||
@@ -28,7 +33,8 @@ setup:
|
|||||||
@if ! rustup component list --toolchain nightly --installed 2>/dev/null | grep -q "^rust-src"; then \
|
@if ! rustup component list --toolchain nightly --installed 2>/dev/null | grep -q "^rust-src"; then \
|
||||||
rustup component add rust-src --toolchain nightly; \
|
rustup component add rust-src --toolchain nightly; \
|
||||||
fi
|
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
|
@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
|
# Build release firmware for all boards, installing toolchains as needed
|
||||||
@@ -55,12 +61,8 @@ check-all:
|
|||||||
fi; \
|
fi; \
|
||||||
done
|
done
|
||||||
|
|
||||||
# Check a single board compiles
|
# Check a single board compiles (clippy with warnings as errors)
|
||||||
check board:
|
check board:
|
||||||
@just _cargo {{board}} "check --release"
|
|
||||||
|
|
||||||
# Run clippy on a single board
|
|
||||||
clippy board:
|
|
||||||
@just _cargo {{board}} "clippy --release" "-- -D warnings"
|
@just _cargo {{board}} "clippy --release" "-- -D warnings"
|
||||||
|
|
||||||
# Build release firmware and copy to builds/ with a readable name
|
# Build release firmware and copy to builds/ with a readable name
|
||||||
@@ -75,7 +77,7 @@ flash board:
|
|||||||
@read -r feat target chip <<< "$(just _info {{board}})"; \
|
@read -r feat target chip <<< "$(just _info {{board}})"; \
|
||||||
case "$target" in \
|
case "$target" in \
|
||||||
xtensa-*) \
|
xtensa-*) \
|
||||||
port=$(just _find_port "303a:1001" "1209:5741" "10c4:ea60"); \
|
port=$(just _find_port "303a:1001" "1209:5741" "10c4:ea60" "1a86:7522" "1a86:7523"); \
|
||||||
if [ -n "$port" ]; then \
|
if [ -n "$port" ]; then \
|
||||||
echo "Using $port"; \
|
echo "Using $port"; \
|
||||||
espflash flash -p "$port" "{{builds_dir}}/donglora-{{board}}-v{{version}}.elf"; \
|
espflash flash -p "$port" "{{builds_dir}}/donglora-{{board}}-v{{version}}.elf"; \
|
||||||
@@ -103,10 +105,7 @@ test:
|
|||||||
|
|
||||||
# Check for outdated dependencies
|
# Check for outdated dependencies
|
||||||
outdated:
|
outdated:
|
||||||
@# embassy-executor 0.10 renamed arch-* → platform-* features.
|
cargo outdated
|
||||||
@# esp-rtos 0.2 still pins embassy-executor ^0.9, blocking the upgrade.
|
|
||||||
@# cargo-outdated crashes trying to resolve 0.10 with the old feature name.
|
|
||||||
cargo outdated --exclude embassy-executor
|
|
||||||
|
|
||||||
# ── Private helpers ───────────────────────────────────────────────────
|
# ── Private helpers ───────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -116,18 +115,18 @@ _ensure_tools:
|
|||||||
@mise trust --yes . 2>/dev/null; mise install --quiet
|
@mise trust --yes . 2>/dev/null; mise install --quiet
|
||||||
|
|
||||||
# Run a cargo command for a board.
|
# Run a cargo command for a board.
|
||||||
# Xtensa: nightly cargo + esp rustc (via RUSTC override) + -Zbuild-std=core
|
# Xtensa: esp toolchain (cargo + rustc + rust-src together) + -Zbuild-std=core
|
||||||
[private]
|
[private]
|
||||||
_cargo board cmd extra_args="":
|
_cargo board cmd extra_args="":
|
||||||
@just _ensure_tools
|
@just _ensure_tools
|
||||||
@read -r feat target chip <<< "$(just _info {{board}})"; \
|
@read -r feat target chip <<< "$(just _info {{board}})"; \
|
||||||
env=""; extra=""; \
|
extra=""; buildstd=""; \
|
||||||
case "$target" in xtensa-*) \
|
case "$target" in xtensa-*) \
|
||||||
just _require_esp_toolchain; \
|
just _require_esp_toolchain; \
|
||||||
[ -f "$HOME/export-esp.sh" ] && . "$HOME/export-esp.sh"; \
|
[ -f "$HOME/export-esp.sh" ] && . "$HOME/export-esp.sh"; \
|
||||||
env="RUSTC=$(rustup which rustc --toolchain esp)"; extra="+nightly"; buildstd="-Zbuild-std=core,alloc";; \
|
extra="+esp"; buildstd="-Zbuild-std=core,alloc";; \
|
||||||
esac; \
|
esac; \
|
||||||
eval $env cargo $extra {{cmd}} --target "$target" --features "$feat" $buildstd {{extra_args}}
|
cargo $extra {{cmd}} --target "$target" --features "$feat" $buildstd {{extra_args}}
|
||||||
|
|
||||||
# Ensure a board's toolchain is available, auto-installing if needed
|
# Ensure a board's toolchain is available, auto-installing if needed
|
||||||
[private]
|
[private]
|
||||||
@@ -138,17 +137,15 @@ _can_build board:
|
|||||||
*) rustup target list --installed | grep -q "^$target$" || rustup target add "$target" ;; \
|
*) rustup target list --installed | grep -q "^$target$" || rustup target add "$target" ;; \
|
||||||
esac
|
esac
|
||||||
|
|
||||||
# Ensure the ESP Xtensa toolchain is installed (espup installed via mise)
|
# Ensure the ESP Xtensa toolchain is installed (espup installed via mise).
|
||||||
|
# The esp toolchain ships with its own cargo, rustc, and rust-src — no extra
|
||||||
|
# components are needed on top.
|
||||||
[private]
|
[private]
|
||||||
_require_esp_toolchain:
|
_require_esp_toolchain:
|
||||||
@just _ensure_tools
|
@just _ensure_tools
|
||||||
@if ! rustup toolchain list | grep -q "^esp"; then \
|
@if ! rustup toolchain list | grep -q "^esp"; then \
|
||||||
echo "ESP toolchain not found, installing via espup (this may take a while)..." >&2; \
|
echo "ESP toolchain not found, installing via espup (this may take a while)..." >&2; \
|
||||||
espup install || { echo "error: espup install failed" >&2; exit 1; }; \
|
espup install || { echo "error: espup install failed" >&2; exit 1; }; \
|
||||||
fi; \
|
|
||||||
if ! rustup component list --toolchain nightly --installed 2>/dev/null | grep -q "^rust-src"; then \
|
|
||||||
echo "Installing nightly rust-src (needed for -Zbuild-std)..." >&2; \
|
|
||||||
rustup component add rust-src --toolchain nightly || { echo "error: failed to install rust-src" >&2; exit 1; }; \
|
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Find a serial port matching any of the given VID:PID pairs (checked in order)
|
# Find a serial port matching any of the given VID:PID pairs (checked in order)
|
||||||
@@ -176,6 +173,16 @@ _copy_firmware board profile:
|
|||||||
if [ -f "$src" ]; then \
|
if [ -f "$src" ]; then \
|
||||||
cp "$src" "$dst.elf"; echo "→ $dst.elf"; \
|
cp "$src" "$dst.elf"; echo "→ $dst.elf"; \
|
||||||
case "$target" in \
|
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-*) \
|
thumbv7em-*) \
|
||||||
mise exec -- rust-objcopy -O ihex "$src" "$dst.hex"; \
|
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; \
|
mise exec -- cargo-hex-to-uf2 hex-to-uf2 --input-path "$dst.hex" --output-path "$dst.uf2" --family nrf52840; \
|
||||||
@@ -206,6 +213,8 @@ _info name:
|
|||||||
@if [ "{{name}}" == "heltec_v3" ]; then echo "{{heltec_v3}}"; \
|
@if [ "{{name}}" == "heltec_v3" ]; then echo "{{heltec_v3}}"; \
|
||||||
elif [ "{{name}}" == "heltec_v3_uart" ]; then echo "{{heltec_v3_uart}}"; \
|
elif [ "{{name}}" == "heltec_v3_uart" ]; then echo "{{heltec_v3_uart}}"; \
|
||||||
elif [ "{{name}}" == "heltec_v4" ]; then echo "{{heltec_v4}}"; \
|
elif [ "{{name}}" == "heltec_v4" ]; then echo "{{heltec_v4}}"; \
|
||||||
|
elif [ "{{name}}" == "elecrow_thinknode_m2" ]; then echo "{{elecrow_thinknode_m2}}"; \
|
||||||
elif [ "{{name}}" == "rak_wisblock_4631" ]; then echo "{{rak_wisblock_4631}}"; \
|
elif [ "{{name}}" == "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}}"; \
|
elif [ "{{name}}" == "wio_tracker_l1" ]; then echo "{{wio_tracker_l1}}"; \
|
||||||
else echo "Unknown board: {{name}}" >&2; exit 1; fi
|
else echo "Unknown board: {{name}}" >&2; exit 1; fi
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
/* RP2040 memory layout — UF2 bootloader (ROM boot2)
|
||||||
|
*
|
||||||
|
* 0x10000000 – 0x10000100 boot2 (second-stage bootloader, 256 bytes)
|
||||||
|
* 0x10000100 – 0x10200000 Application <- we live here
|
||||||
|
*
|
||||||
|
* Flash: 2MB (W25Q16JV on Waveshare RP2040-LoRa)
|
||||||
|
* RAM: 264KB SRAM (two 256KB banks + 2x 4KB scratch)
|
||||||
|
*/
|
||||||
|
|
||||||
|
MEMORY
|
||||||
|
{
|
||||||
|
BOOT2 : ORIGIN = 0x10000000, LENGTH = 0x100
|
||||||
|
FLASH : ORIGIN = 0x10000100, LENGTH = 2048K - 0x100
|
||||||
|
RAM : ORIGIN = 0x20000000, LENGTH = 264K
|
||||||
|
}
|
||||||
|
|
||||||
|
EXTERN(BOOT2_FIRMWARE)
|
||||||
|
|
||||||
|
SECTIONS {
|
||||||
|
.boot2 ORIGIN(BOOT2) :
|
||||||
|
{
|
||||||
|
KEEP(*(.boot2));
|
||||||
|
} > BOOT2
|
||||||
|
} INSERT BEFORE .text;
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
[tools]
|
[tools]
|
||||||
rust = "1.93"
|
rust = "1.94"
|
||||||
just = "latest"
|
just = "latest"
|
||||||
|
|
||||||
"cargo:cargo-binutils" = "latest" # `cargo size`, `cargo objdump`, etc.
|
"cargo:cargo-binutils" = "latest" # `cargo size`, `cargo objdump`, etc.
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
Create `src/board/your_board.rs`. Use an existing board as a template:
|
Create `src/board/your_board.rs`. Use an existing board as a template:
|
||||||
- ESP32-S3 boards: copy `heltec_v4.rs` (uses shared helpers from `board/esp32s3.rs` and `hal/esp32s3.rs`)
|
- 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`)
|
- 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
|
- New MCU family: create a new `hal/<mcu>.rs` with MCU primitives first
|
||||||
|
|
||||||
`build.rs` auto-discovers your board file — any `.rs` file in `src/board/` that
|
`build.rs` auto-discovers your board file — any `.rs` file in `src/board/` that
|
||||||
|
|||||||
@@ -0,0 +1,141 @@
|
|||||||
|
//! ELECROW ThinkNode-M2 — ESP32-S3 Meshtastic handset with SH1106 OLED.
|
||||||
|
//!
|
||||||
|
//! Tamagotchi-form-factor handheld: ESP32-S3-WROOM-1-N4 + SX1262 + 1.3" SH1106
|
||||||
|
//! OLED + WCH CH340K UART bridge + 1000 mAh Li-Po. Sold pre-loaded with
|
||||||
|
//! Meshtastic; this firmware turns it into a DongLoRa radio bridge.
|
||||||
|
//!
|
||||||
|
//! Pin map (verified from Meshtastic's variants/esp32s3/ELECROW-ThinkNode-M2
|
||||||
|
//! and PR meshtastic/firmware#6354):
|
||||||
|
//! LoRa SPI: SCK=12, MOSI=11, MISO=13, CS=10
|
||||||
|
//! LoRa ctl: RESET=21, BUSY=14, DIO1=3
|
||||||
|
//! LoRa pwr: GPIO48 (must be HIGH before any SPI traffic)
|
||||||
|
//! OLED: I2C0 SDA=16, SCL=15, SH1106 @ 0x3C
|
||||||
|
//! VEXT: GPIO46 (active HIGH — gates OLED supply)
|
||||||
|
//! UART: TX=43, RX=44 via CH340K bridge (USB 1a86:7522)
|
||||||
|
//! LED: GPIO6 (active HIGH indicator)
|
||||||
|
//!
|
||||||
|
//! Notable differences from the Heltec ESP32-S3 boards:
|
||||||
|
//! * Display is SH1106 (not SSD1306) — uses the existing sh1106 driver.
|
||||||
|
//! * VEXT_ENABLE polarity is reversed (active HIGH here, active LOW on Heltec).
|
||||||
|
//! * Dedicated LoRa power enable on GPIO48 — must be sequenced before SPI init.
|
||||||
|
//! * TCXO is driven at 3.3 V via DIO3 (Heltec uses 1.8 V).
|
||||||
|
|
||||||
|
use esp_hal::gpio::{Level, Output, OutputConfig};
|
||||||
|
|
||||||
|
use super::esp32s3;
|
||||||
|
use super::traits::{BoardParts, LoRaBoard};
|
||||||
|
use crate::driver::sh1106::Sh1106;
|
||||||
|
use crate::driver::simple_led::SimpleLed;
|
||||||
|
use crate::hal::esp32s3 as mcu;
|
||||||
|
|
||||||
|
pub use super::esp32s3::{DisplayI2c, RadioDriver, RadioParts};
|
||||||
|
|
||||||
|
// ── Concrete peripheral types ───────────────────────────────────────
|
||||||
|
|
||||||
|
pub type DisplayDriver = Sh1106<DisplayI2c>;
|
||||||
|
pub type UartDriver = esp_hal::uart::Uart<'static, esp_hal::Async>;
|
||||||
|
pub type LedDriver = SimpleLed<Output<'static>>;
|
||||||
|
|
||||||
|
// ── Peripheral bundles ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
pub struct UartParts {
|
||||||
|
pub driver: UartDriver,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct DisplayParts {
|
||||||
|
pub i2c: DisplayI2c,
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Display init ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
pub async fn create_display(i2c: DisplayI2c) -> Option<DisplayDriver> {
|
||||||
|
let mut display = Sh1106::new(i2c, 0x3C);
|
||||||
|
|
||||||
|
embassy_time::Timer::after_millis(100).await;
|
||||||
|
if display.init().await.is_err() {
|
||||||
|
defmt::error!("SH1106 display init failed");
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let _ = display.set_brightness(0xFF).await;
|
||||||
|
Some(display)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Board init ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
pub struct Board {
|
||||||
|
p: esp_hal::peripherals::Peripherals,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl LoRaBoard for Board {
|
||||||
|
const NAME: &'static str = "ELECROW ThinkNode-M2";
|
||||||
|
const TX_POWER_RANGE: (i8, i8) = (-9, 22);
|
||||||
|
|
||||||
|
type RadioParts = RadioParts;
|
||||||
|
type CommParts = UartParts;
|
||||||
|
type DisplayParts = DisplayParts;
|
||||||
|
type DisplayDriver = DisplayDriver;
|
||||||
|
type LedDriver = LedDriver;
|
||||||
|
|
||||||
|
fn init() -> Self {
|
||||||
|
let p = esp_hal::init(esp_hal::Config::default());
|
||||||
|
Self { p }
|
||||||
|
}
|
||||||
|
|
||||||
|
fn mac_address() -> [u8; 6] {
|
||||||
|
mcu::mac_address()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn into_parts(self) -> BoardParts<RadioParts, UartParts, DisplayParts, LedDriver> {
|
||||||
|
let p = self.p;
|
||||||
|
|
||||||
|
mcu::start_timer(p.TIMG0, p.SW_INTERRUPT);
|
||||||
|
|
||||||
|
// VEXT_ENABLE: GPIO46, active HIGH — powers OLED rail (opposite of Heltec).
|
||||||
|
// Output has no Drop impl, so the pin stays HIGH for program lifetime.
|
||||||
|
let _vext = Output::new(p.GPIO46, Level::High, OutputConfig::default());
|
||||||
|
esp_hal::delay::Delay::new().delay_millis(10);
|
||||||
|
|
||||||
|
// LORA_POWER_EN: GPIO48, active HIGH — must be HIGH before any SPI traffic.
|
||||||
|
let _lora_pwr = Output::new(p.GPIO48, Level::High, OutputConfig::default());
|
||||||
|
esp_hal::delay::Delay::new().delay_millis(10);
|
||||||
|
|
||||||
|
// SPI2 for SX1262: SCK=12, MOSI=11, MISO=13
|
||||||
|
let spi_bus = mcu::init_spi(p.SPI2, p.DMA_CH0, p.GPIO12, p.GPIO11, p.GPIO13);
|
||||||
|
|
||||||
|
// SX1262: CS=10, RESET=21, DIO1=3, BUSY=14, TCXO driven at 3.3 V via DIO3.
|
||||||
|
let radio = esp32s3::init_radio(
|
||||||
|
spi_bus,
|
||||||
|
p.GPIO10,
|
||||||
|
p.GPIO21,
|
||||||
|
p.GPIO3,
|
||||||
|
p.GPIO14,
|
||||||
|
lora_phy::sx126x::TcxoCtrlVoltage::Ctrl3V3,
|
||||||
|
);
|
||||||
|
|
||||||
|
// UART0 via CP2102 bridge: TX=43, RX=44
|
||||||
|
use esp_hal::uart::{Config as UartConfig, Uart};
|
||||||
|
let host = UartParts {
|
||||||
|
driver: Uart::new(p.UART0, UartConfig::default())
|
||||||
|
.expect("UART init")
|
||||||
|
.with_tx(p.GPIO43)
|
||||||
|
.with_rx(p.GPIO44)
|
||||||
|
.into_async(),
|
||||||
|
};
|
||||||
|
|
||||||
|
// I2C0 for SH1106 OLED: SDA=16, SCL=15
|
||||||
|
let i2c = mcu::init_i2c(p.I2C0, p.GPIO16, p.GPIO15);
|
||||||
|
let display = Some(DisplayParts { i2c });
|
||||||
|
|
||||||
|
// LED on GPIO6 (active HIGH)
|
||||||
|
let led_pin = Output::new(p.GPIO6, Level::Low, OutputConfig::default());
|
||||||
|
let led = SimpleLed(led_pin);
|
||||||
|
|
||||||
|
BoardParts {
|
||||||
|
radio,
|
||||||
|
host,
|
||||||
|
display,
|
||||||
|
led,
|
||||||
|
mac: Self::mac_address(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+29
-6
@@ -7,7 +7,7 @@
|
|||||||
use embassy_embedded_hal::shared_bus::asynch::spi::SpiDevice;
|
use embassy_embedded_hal::shared_bus::asynch::spi::SpiDevice;
|
||||||
use embassy_sync::blocking_mutex::raw::NoopRawMutex;
|
use embassy_sync::blocking_mutex::raw::NoopRawMutex;
|
||||||
use embassy_time::Delay;
|
use embassy_time::Delay;
|
||||||
use esp_hal::gpio::{Input, InputConfig, Level, Output, OutputConfig, Pull};
|
use esp_hal::gpio::{Input, InputConfig, InputPin, Level, Output, OutputConfig, OutputPin, Pull};
|
||||||
|
|
||||||
use lora_phy::iv::GenericSx126xInterfaceVariant;
|
use lora_phy::iv::GenericSx126xInterfaceVariant;
|
||||||
use lora_phy::sx126x::{self, Sx1262, Sx126x};
|
use lora_phy::sx126x::{self, Sx1262, Sx126x};
|
||||||
@@ -32,12 +32,25 @@ pub struct RadioParts {
|
|||||||
pub delay: Delay,
|
pub delay: Delay,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SSD1306 display support is Heltec-specific. ESP32-S3 boards that use a
|
||||||
|
// different controller (e.g. ThinkNode-M2 uses SH1106) provide their own
|
||||||
|
// DisplayParts / DisplayDriver / create_display directly.
|
||||||
|
#[cfg(any(
|
||||||
|
feature = "heltec_v3",
|
||||||
|
feature = "heltec_v3_uart",
|
||||||
|
feature = "heltec_v4"
|
||||||
|
))]
|
||||||
pub struct DisplayParts {
|
pub struct DisplayParts {
|
||||||
pub i2c: DisplayI2c,
|
pub i2c: DisplayI2c,
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Display driver ──────────────────────────────────────────────────
|
// ── Display driver ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
#[cfg(any(
|
||||||
|
feature = "heltec_v3",
|
||||||
|
feature = "heltec_v3_uart",
|
||||||
|
feature = "heltec_v4"
|
||||||
|
))]
|
||||||
pub type DisplayDriver = ssd1306::Ssd1306Async<
|
pub type DisplayDriver = ssd1306::Ssd1306Async<
|
||||||
ssd1306::prelude::I2CInterface<DisplayI2c>,
|
ssd1306::prelude::I2CInterface<DisplayI2c>,
|
||||||
ssd1306::size::DisplaySize128x64,
|
ssd1306::size::DisplaySize128x64,
|
||||||
@@ -45,6 +58,11 @@ pub type DisplayDriver = ssd1306::Ssd1306Async<
|
|||||||
>;
|
>;
|
||||||
|
|
||||||
/// Construct and initialize an SSD1306 display from raw I2C.
|
/// Construct and initialize an SSD1306 display from raw I2C.
|
||||||
|
#[cfg(any(
|
||||||
|
feature = "heltec_v3",
|
||||||
|
feature = "heltec_v3_uart",
|
||||||
|
feature = "heltec_v4"
|
||||||
|
))]
|
||||||
pub async fn create_display(i2c: DisplayI2c) -> Option<DisplayDriver> {
|
pub async fn create_display(i2c: DisplayI2c) -> Option<DisplayDriver> {
|
||||||
use ssd1306::mode::DisplayConfigAsync;
|
use ssd1306::mode::DisplayConfigAsync;
|
||||||
use ssd1306::prelude::{Brightness, DisplayRotation};
|
use ssd1306::prelude::{Brightness, DisplayRotation};
|
||||||
@@ -67,12 +85,17 @@ pub async fn create_display(i2c: DisplayI2c) -> Option<DisplayDriver> {
|
|||||||
// ── Shared peripheral init helpers ──────────────────────────────────
|
// ── Shared peripheral init helpers ──────────────────────────────────
|
||||||
|
|
||||||
/// Construct SX1262 radio from an initialized SPI bus.
|
/// Construct SX1262 radio from an initialized SPI bus.
|
||||||
|
///
|
||||||
|
/// Generic over GPIO type so boards with different radio pin assignments can
|
||||||
|
/// share this helper. `tcxo_voltage` selects the DIO3-driven TCXO supply:
|
||||||
|
/// Heltec boards use `Ctrl1V8`, the ELECROW ThinkNode-M2 uses `Ctrl3V3`, etc.
|
||||||
pub fn init_radio(
|
pub fn init_radio(
|
||||||
spi_bus: &'static embassy_sync::mutex::Mutex<NoopRawMutex, SpiBus>,
|
spi_bus: &'static embassy_sync::mutex::Mutex<NoopRawMutex, SpiBus>,
|
||||||
nss_pin: esp_hal::peripherals::GPIO8<'static>,
|
nss_pin: impl OutputPin + 'static,
|
||||||
reset_pin: esp_hal::peripherals::GPIO12<'static>,
|
reset_pin: impl OutputPin + 'static,
|
||||||
dio1_pin: esp_hal::peripherals::GPIO14<'static>,
|
dio1_pin: impl InputPin + 'static,
|
||||||
busy_pin: esp_hal::peripherals::GPIO13<'static>,
|
busy_pin: impl InputPin + 'static,
|
||||||
|
tcxo_voltage: sx126x::TcxoCtrlVoltage,
|
||||||
) -> RadioParts {
|
) -> RadioParts {
|
||||||
let nss = Output::new(nss_pin, Level::High, OutputConfig::default());
|
let nss = Output::new(nss_pin, Level::High, OutputConfig::default());
|
||||||
let spi_device = SpiDevice::new(spi_bus, nss);
|
let spi_device = SpiDevice::new(spi_bus, nss);
|
||||||
@@ -86,7 +109,7 @@ pub fn init_radio(
|
|||||||
|
|
||||||
let sx_config = sx126x::Config {
|
let sx_config = sx126x::Config {
|
||||||
chip: Sx1262,
|
chip: Sx1262,
|
||||||
tcxo_ctrl: Some(sx126x::TcxoCtrlVoltage::Ctrl1V8),
|
tcxo_ctrl: Some(tcxo_voltage),
|
||||||
use_dcdc: true,
|
use_dcdc: true,
|
||||||
rx_boost: false,
|
rx_boost: false,
|
||||||
};
|
};
|
||||||
|
|||||||
+15
-10
@@ -14,9 +14,7 @@ use super::traits::{BoardParts, LoRaBoard};
|
|||||||
use crate::driver::simple_led::SimpleLed;
|
use crate::driver::simple_led::SimpleLed;
|
||||||
use crate::hal::esp32s3 as mcu;
|
use crate::hal::esp32s3 as mcu;
|
||||||
|
|
||||||
pub use super::esp32s3::{
|
pub use super::esp32s3::{create_display, DisplayDriver, DisplayParts, RadioDriver, RadioParts};
|
||||||
create_display, DisplayDriver, DisplayParts, RadioDriver, RadioParts,
|
|
||||||
};
|
|
||||||
|
|
||||||
pub type UsbDriver = esp_hal::otg_fs::asynch::Driver<'static>;
|
pub type UsbDriver = esp_hal::otg_fs::asynch::Driver<'static>;
|
||||||
pub type LedDriver = SimpleLed<Output<'static>>;
|
pub type LedDriver = SimpleLed<Output<'static>>;
|
||||||
@@ -51,14 +49,21 @@ impl LoRaBoard for Board {
|
|||||||
fn into_parts(self) -> BoardParts<RadioParts, UsbParts, DisplayParts, LedDriver> {
|
fn into_parts(self) -> BoardParts<RadioParts, UsbParts, DisplayParts, LedDriver> {
|
||||||
let p = self.p;
|
let p = self.p;
|
||||||
|
|
||||||
mcu::start_timer(p.TIMG0);
|
mcu::start_timer(p.TIMG0, p.SW_INTERRUPT);
|
||||||
|
|
||||||
// Vext power: GPIO36, active LOW to enable peripherals
|
// Vext power: GPIO36, active LOW to enable peripherals.
|
||||||
let vext = Output::new(p.GPIO36, Level::Low, OutputConfig::default());
|
// Output has no Drop impl, so the pin stays configured for program lifetime.
|
||||||
core::mem::forget(vext);
|
let _vext = Output::new(p.GPIO36, Level::Low, OutputConfig::default());
|
||||||
|
|
||||||
let spi_bus = mcu::init_spi(p.SPI2, p.DMA_CH0, p.GPIO9, p.GPIO10, p.GPIO11);
|
let spi_bus = mcu::init_spi(p.SPI2, p.DMA_CH0, p.GPIO9, p.GPIO10, p.GPIO11);
|
||||||
let radio = esp32s3::init_radio(spi_bus, p.GPIO8, p.GPIO12, p.GPIO14, p.GPIO13);
|
let radio = esp32s3::init_radio(
|
||||||
|
spi_bus,
|
||||||
|
p.GPIO8,
|
||||||
|
p.GPIO12,
|
||||||
|
p.GPIO14,
|
||||||
|
p.GPIO13,
|
||||||
|
lora_phy::sx126x::TcxoCtrlVoltage::Ctrl1V8,
|
||||||
|
);
|
||||||
|
|
||||||
// Note: switches internal USB PHY from Serial-JTAG to OTG.
|
// 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);
|
let usb_inst = esp_hal::otg_fs::Usb::new(p.USB0, p.GPIO20, p.GPIO19);
|
||||||
@@ -72,12 +77,12 @@ impl LoRaBoard for Board {
|
|||||||
),
|
),
|
||||||
};
|
};
|
||||||
|
|
||||||
// SSD1306 display reset: pulse GPIO21 low->high before I2C init
|
// SSD1306 display reset: pulse GPIO21 low->high before I2C init.
|
||||||
|
// Output has no Drop impl; pin stays HIGH after this block.
|
||||||
let mut display_rst = Output::new(p.GPIO21, Level::Low, OutputConfig::default());
|
let mut display_rst = Output::new(p.GPIO21, Level::Low, OutputConfig::default());
|
||||||
esp_hal::delay::Delay::new().delay_millis(10);
|
esp_hal::delay::Delay::new().delay_millis(10);
|
||||||
display_rst.set_high();
|
display_rst.set_high();
|
||||||
esp_hal::delay::Delay::new().delay_millis(10);
|
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 i2c = mcu::init_i2c(p.I2C0, p.GPIO17, p.GPIO18);
|
||||||
let display = Some(DisplayParts { i2c });
|
let display = Some(DisplayParts { i2c });
|
||||||
|
|||||||
+15
-10
@@ -13,9 +13,7 @@ use super::traits::{BoardParts, LoRaBoard};
|
|||||||
use crate::driver::simple_led::SimpleLed;
|
use crate::driver::simple_led::SimpleLed;
|
||||||
use crate::hal::esp32s3 as mcu;
|
use crate::hal::esp32s3 as mcu;
|
||||||
|
|
||||||
pub use super::esp32s3::{
|
pub use super::esp32s3::{create_display, DisplayDriver, DisplayParts, RadioDriver, RadioParts};
|
||||||
create_display, DisplayDriver, DisplayParts, RadioDriver, RadioParts,
|
|
||||||
};
|
|
||||||
|
|
||||||
pub type UartDriver = esp_hal::uart::Uart<'static, esp_hal::Async>;
|
pub type UartDriver = esp_hal::uart::Uart<'static, esp_hal::Async>;
|
||||||
pub type LedDriver = SimpleLed<Output<'static>>;
|
pub type LedDriver = SimpleLed<Output<'static>>;
|
||||||
@@ -50,14 +48,21 @@ impl LoRaBoard for Board {
|
|||||||
fn into_parts(self) -> BoardParts<RadioParts, UartParts, DisplayParts, LedDriver> {
|
fn into_parts(self) -> BoardParts<RadioParts, UartParts, DisplayParts, LedDriver> {
|
||||||
let p = self.p;
|
let p = self.p;
|
||||||
|
|
||||||
mcu::start_timer(p.TIMG0);
|
mcu::start_timer(p.TIMG0, p.SW_INTERRUPT);
|
||||||
|
|
||||||
// Vext power: GPIO36, active LOW to enable peripherals
|
// Vext power: GPIO36, active LOW to enable peripherals.
|
||||||
let vext = Output::new(p.GPIO36, Level::Low, OutputConfig::default());
|
// Output has no Drop impl, so the pin stays configured for program lifetime.
|
||||||
core::mem::forget(vext);
|
let _vext = Output::new(p.GPIO36, Level::Low, OutputConfig::default());
|
||||||
|
|
||||||
let spi_bus = mcu::init_spi(p.SPI2, p.DMA_CH0, p.GPIO9, p.GPIO10, p.GPIO11);
|
let spi_bus = mcu::init_spi(p.SPI2, p.DMA_CH0, p.GPIO9, p.GPIO10, p.GPIO11);
|
||||||
let radio = esp32s3::init_radio(spi_bus, p.GPIO8, p.GPIO12, p.GPIO14, p.GPIO13);
|
let radio = esp32s3::init_radio(
|
||||||
|
spi_bus,
|
||||||
|
p.GPIO8,
|
||||||
|
p.GPIO12,
|
||||||
|
p.GPIO14,
|
||||||
|
p.GPIO13,
|
||||||
|
lora_phy::sx126x::TcxoCtrlVoltage::Ctrl1V8,
|
||||||
|
);
|
||||||
|
|
||||||
use esp_hal::uart::{Config as UartConfig, Uart};
|
use esp_hal::uart::{Config as UartConfig, Uart};
|
||||||
let host = UartParts {
|
let host = UartParts {
|
||||||
@@ -68,12 +73,12 @@ impl LoRaBoard for Board {
|
|||||||
.into_async(),
|
.into_async(),
|
||||||
};
|
};
|
||||||
|
|
||||||
// SSD1306 display reset: pulse GPIO21 low->high before I2C init
|
// SSD1306 display reset: pulse GPIO21 low->high before I2C init.
|
||||||
|
// Output has no Drop impl; pin stays HIGH after this block.
|
||||||
let mut display_rst = Output::new(p.GPIO21, Level::Low, OutputConfig::default());
|
let mut display_rst = Output::new(p.GPIO21, Level::Low, OutputConfig::default());
|
||||||
esp_hal::delay::Delay::new().delay_millis(10);
|
esp_hal::delay::Delay::new().delay_millis(10);
|
||||||
display_rst.set_high();
|
display_rst.set_high();
|
||||||
esp_hal::delay::Delay::new().delay_millis(10);
|
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 i2c = mcu::init_i2c(p.I2C0, p.GPIO17, p.GPIO18);
|
||||||
let display = Some(DisplayParts { i2c });
|
let display = Some(DisplayParts { i2c });
|
||||||
|
|||||||
+15
-10
@@ -6,9 +6,7 @@ use super::traits::{BoardParts, LoRaBoard};
|
|||||||
use crate::driver::simple_led::SimpleLed;
|
use crate::driver::simple_led::SimpleLed;
|
||||||
use crate::hal::esp32s3 as mcu;
|
use crate::hal::esp32s3 as mcu;
|
||||||
|
|
||||||
pub use super::esp32s3::{
|
pub use super::esp32s3::{create_display, DisplayDriver, DisplayParts, RadioDriver, RadioParts};
|
||||||
create_display, DisplayDriver, DisplayParts, RadioDriver, RadioParts,
|
|
||||||
};
|
|
||||||
|
|
||||||
pub type UsbDriver = esp_hal::otg_fs::asynch::Driver<'static>;
|
pub type UsbDriver = esp_hal::otg_fs::asynch::Driver<'static>;
|
||||||
|
|
||||||
@@ -44,14 +42,21 @@ impl LoRaBoard for Board {
|
|||||||
fn into_parts(self) -> BoardParts<RadioParts, UsbParts, DisplayParts, LedDriver> {
|
fn into_parts(self) -> BoardParts<RadioParts, UsbParts, DisplayParts, LedDriver> {
|
||||||
let p = self.p;
|
let p = self.p;
|
||||||
|
|
||||||
mcu::start_timer(p.TIMG0);
|
mcu::start_timer(p.TIMG0, p.SW_INTERRUPT);
|
||||||
|
|
||||||
// Vext power: GPIO36, active LOW to enable peripherals
|
// Vext power: GPIO36, active LOW to enable peripherals.
|
||||||
let vext = Output::new(p.GPIO36, Level::Low, OutputConfig::default());
|
// Output has no Drop impl, so the pin stays configured for program lifetime.
|
||||||
core::mem::forget(vext);
|
let _vext = Output::new(p.GPIO36, Level::Low, OutputConfig::default());
|
||||||
|
|
||||||
let spi_bus = mcu::init_spi(p.SPI2, p.DMA_CH0, p.GPIO9, p.GPIO10, p.GPIO11);
|
let spi_bus = mcu::init_spi(p.SPI2, p.DMA_CH0, p.GPIO9, p.GPIO10, p.GPIO11);
|
||||||
let radio = esp32s3::init_radio(spi_bus, p.GPIO8, p.GPIO12, p.GPIO14, p.GPIO13);
|
let radio = esp32s3::init_radio(
|
||||||
|
spi_bus,
|
||||||
|
p.GPIO8,
|
||||||
|
p.GPIO12,
|
||||||
|
p.GPIO14,
|
||||||
|
p.GPIO13,
|
||||||
|
lora_phy::sx126x::TcxoCtrlVoltage::Ctrl1V8,
|
||||||
|
);
|
||||||
|
|
||||||
// Note: switches internal USB PHY from Serial-JTAG to OTG.
|
// 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);
|
let usb_inst = esp_hal::otg_fs::Usb::new(p.USB0, p.GPIO20, p.GPIO19);
|
||||||
@@ -65,12 +70,12 @@ impl LoRaBoard for Board {
|
|||||||
),
|
),
|
||||||
};
|
};
|
||||||
|
|
||||||
// SSD1315 display reset: pulse GPIO21 low->high before I2C init
|
// SSD1315 display reset: pulse GPIO21 low->high before I2C init.
|
||||||
|
// Output has no Drop impl; pin stays HIGH after this block.
|
||||||
let mut display_rst = Output::new(p.GPIO21, Level::Low, OutputConfig::default());
|
let mut display_rst = Output::new(p.GPIO21, Level::Low, OutputConfig::default());
|
||||||
esp_hal::delay::Delay::new().delay_millis(10);
|
esp_hal::delay::Delay::new().delay_millis(10);
|
||||||
display_rst.set_high();
|
display_rst.set_high();
|
||||||
esp_hal::delay::Delay::new().delay_millis(10);
|
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 i2c = mcu::init_i2c(p.I2C0, p.GPIO17, p.GPIO18);
|
||||||
let display = Some(DisplayParts { i2c });
|
let display = Some(DisplayParts { i2c });
|
||||||
|
|||||||
@@ -129,7 +129,12 @@ impl LoRaBoard for Board {
|
|||||||
// ── Display (optional RAK1921 SSD1306 OLED on I2C) ─────
|
// ── Display (optional RAK1921 SSD1306 OLED on I2C) ─────
|
||||||
let twim_buf = mcu::alloc_i2c_buffer();
|
let twim_buf = mcu::alloc_i2c_buffer();
|
||||||
let i2c = embassy_nrf::twim::Twim::new(
|
let i2c = embassy_nrf::twim::Twim::new(
|
||||||
p.TWISPI0, mcu::Irqs, p.P0_13, p.P0_14, twim::Config::default(), twim_buf,
|
p.TWISPI0,
|
||||||
|
mcu::Irqs,
|
||||||
|
p.P0_13,
|
||||||
|
p.P0_14,
|
||||||
|
twim::Config::default(),
|
||||||
|
twim_buf,
|
||||||
);
|
);
|
||||||
let display = Some(DisplayParts { i2c });
|
let display = Some(DisplayParts { i2c });
|
||||||
|
|
||||||
|
|||||||
+23
-3
@@ -9,6 +9,8 @@
|
|||||||
use embedded_graphics::draw_target::DrawTarget;
|
use embedded_graphics::draw_target::DrawTarget;
|
||||||
use embedded_graphics::pixelcolor::BinaryColor;
|
use embedded_graphics::pixelcolor::BinaryColor;
|
||||||
|
|
||||||
|
use crate::driver::DisplayBrightness;
|
||||||
|
|
||||||
/// Async RGB LED control. Boards without an LED use `()` which is a no-op.
|
/// Async RGB LED control. Boards without an LED use `()` which is a no-op.
|
||||||
pub trait RgbLed {
|
pub trait RgbLed {
|
||||||
fn set_rgb(&mut self, r: u8, g: u8, b: u8) -> impl core::future::Future<Output = ()>;
|
fn set_rgb(&mut self, r: u8, g: u8, b: u8) -> impl core::future::Future<Output = ()>;
|
||||||
@@ -35,8 +37,9 @@ pub trait LoRaBoard: Sized {
|
|||||||
/// Display peripheral bundle (I2C bus for display init).
|
/// Display peripheral bundle (I2C bus for display init).
|
||||||
type DisplayParts;
|
type DisplayParts;
|
||||||
|
|
||||||
/// Concrete display driver type (must implement DrawTarget for rendering).
|
/// Concrete display driver type (must implement DrawTarget for rendering
|
||||||
type DisplayDriver: DrawTarget<Color = BinaryColor>;
|
/// and DisplayBrightness for the two-level dim control).
|
||||||
|
type DisplayDriver: DrawTarget<Color = BinaryColor> + DisplayBrightness;
|
||||||
|
|
||||||
/// RGB LED driver. Boards without an LED use `()`.
|
/// RGB LED driver. Boards without an LED use `()`.
|
||||||
type LedDriver: RgbLed;
|
type LedDriver: RgbLed;
|
||||||
@@ -47,8 +50,25 @@ pub trait LoRaBoard: Sized {
|
|||||||
/// Read the board's unique hardware address (MAC, device ID, etc.).
|
/// Read the board's unique hardware address (MAC, device ID, etc.).
|
||||||
fn mac_address() -> [u8; 6];
|
fn mac_address() -> [u8; 6];
|
||||||
|
|
||||||
|
/// Radio chip identifier reported in `GET_INFO`. Defaults to SX1262
|
||||||
|
/// (`0x0002`), matching every currently-supported board. Override for
|
||||||
|
/// boards that carry a different chip (LLCC68, SX1261, LR11xx, …).
|
||||||
|
fn radio_chip_id() -> u16 {
|
||||||
|
// SX1262 — see PROTOCOL.md §8.
|
||||||
|
0x0002
|
||||||
|
}
|
||||||
|
|
||||||
|
/// (freq_min_hz, freq_max_hz) — the RF front-end's effective tuning
|
||||||
|
/// window on this specific board. Reported in `GET_INFO`. Defaults
|
||||||
|
/// to the SX1262's nominal 150–960 MHz range.
|
||||||
|
fn freq_range_hz() -> (u32, u32) {
|
||||||
|
(150_000_000, 960_000_000)
|
||||||
|
}
|
||||||
|
|
||||||
/// Decompose initialized board into peripheral bundles for each task.
|
/// Decompose initialized board into peripheral bundles for each task.
|
||||||
fn into_parts(self) -> BoardParts<Self::RadioParts, Self::CommParts, Self::DisplayParts, Self::LedDriver>;
|
fn into_parts(
|
||||||
|
self,
|
||||||
|
) -> BoardParts<Self::RadioParts, Self::CommParts, Self::DisplayParts, Self::LedDriver>;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Peripheral bundles for each firmware task, produced by [`LoRaBoard::into_parts`].
|
/// Peripheral bundles for each firmware task, produced by [`LoRaBoard::into_parts`].
|
||||||
|
|||||||
@@ -0,0 +1,150 @@
|
|||||||
|
use embassy_embedded_hal::shared_bus::asynch::spi::SpiDevice;
|
||||||
|
use embassy_rp::gpio::{Input, Level, Output, Pull};
|
||||||
|
use embassy_rp::spi;
|
||||||
|
use embassy_sync::blocking_mutex::raw::NoopRawMutex;
|
||||||
|
use embassy_time::Delay;
|
||||||
|
|
||||||
|
use lora_phy::iv::GenericSx126xInterfaceVariant;
|
||||||
|
use lora_phy::sx126x::{self, Sx1262, Sx126x};
|
||||||
|
|
||||||
|
use super::traits::{BoardParts, LoRaBoard};
|
||||||
|
use crate::hal::rp2040 as mcu;
|
||||||
|
|
||||||
|
// ── Concrete peripheral types ──────────────────────────────────────
|
||||||
|
|
||||||
|
type Nss = Output<'static>;
|
||||||
|
type Iv = GenericSx126xInterfaceVariant<Output<'static>, Input<'static>>;
|
||||||
|
type RadioSpiDevice = SpiDevice<'static, NoopRawMutex, mcu::SpiBus, Nss>;
|
||||||
|
pub type RadioDriver = Sx126x<RadioSpiDevice, Iv, Sx1262>;
|
||||||
|
pub type UsbDriver = mcu::UsbRpDriver;
|
||||||
|
pub type LedDriver = crate::driver::simple_led::SimpleLed<Output<'static>>;
|
||||||
|
|
||||||
|
// Display types — never instantiated (no OLED on this board), but the
|
||||||
|
// trait requires concrete associated types that satisfy the bounds.
|
||||||
|
pub type DisplayI2c =
|
||||||
|
embassy_rp::i2c::I2c<'static, embassy_rp::peripherals::I2C0, embassy_rp::i2c::Async>;
|
||||||
|
pub type DisplayDriver = ssd1306::Ssd1306Async<
|
||||||
|
ssd1306::prelude::I2CInterface<DisplayI2c>,
|
||||||
|
ssd1306::size::DisplaySize128x64,
|
||||||
|
ssd1306::mode::BufferedGraphicsModeAsync<ssd1306::size::DisplaySize128x64>,
|
||||||
|
>;
|
||||||
|
|
||||||
|
// ── Peripheral bundles ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
pub struct RadioParts {
|
||||||
|
pub driver: RadioDriver,
|
||||||
|
pub delay: Delay,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct UsbParts {
|
||||||
|
pub driver: UsbDriver,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct DisplayParts {
|
||||||
|
pub i2c: DisplayI2c,
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Display init ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
pub async fn create_display(_i2c: DisplayI2c) -> Option<DisplayDriver> {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Board init ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
pub struct Board {
|
||||||
|
p: embassy_rp::Peripherals,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl LoRaBoard for Board {
|
||||||
|
const NAME: &'static str = "Waveshare RP2040-LoRa";
|
||||||
|
const TX_POWER_RANGE: (i8, i8) = (-9, 22);
|
||||||
|
|
||||||
|
type RadioParts = RadioParts;
|
||||||
|
type CommParts = UsbParts;
|
||||||
|
type DisplayParts = DisplayParts;
|
||||||
|
type DisplayDriver = DisplayDriver;
|
||||||
|
type LedDriver = LedDriver;
|
||||||
|
|
||||||
|
fn init() -> Self {
|
||||||
|
let p = embassy_rp::init(Default::default());
|
||||||
|
Self { p }
|
||||||
|
}
|
||||||
|
|
||||||
|
fn mac_address() -> [u8; 6] {
|
||||||
|
mcu::mac_address()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn into_parts(self) -> BoardParts<RadioParts, UsbParts, DisplayParts, LedDriver> {
|
||||||
|
let p = self.p;
|
||||||
|
|
||||||
|
// ── MAC address from flash unique ID ───────────────────
|
||||||
|
let mut flash =
|
||||||
|
embassy_rp::flash::Flash::<_, _, { 2 * 1024 * 1024 }>::new_blocking(p.FLASH);
|
||||||
|
mcu::init_mac(&mut flash);
|
||||||
|
|
||||||
|
// ── SPI1 bus for SX1262 ────────────────────────────────
|
||||||
|
//
|
||||||
|
// Pin mapping from Meshtastic & MeshCore firmware (confirmed working):
|
||||||
|
// SCK = GPIO14, MOSI = GPIO15, MISO = GPIO24, CS = GPIO13
|
||||||
|
//
|
||||||
|
// The Waveshare wiki (https://www.waveshare.com/wiki/RP2040-LoRa) lists
|
||||||
|
// different pins (MOSI=11, MISO=8, CS=9) but those don't work in practice.
|
||||||
|
// Both sets are valid SPI1 alternate functions on RP2040.
|
||||||
|
let mut spi_cfg = spi::Config::default();
|
||||||
|
spi_cfg.frequency = 1_000_000;
|
||||||
|
let spi = spi::Spi::new(
|
||||||
|
p.SPI1,
|
||||||
|
p.PIN_14,
|
||||||
|
p.PIN_15,
|
||||||
|
p.PIN_24,
|
||||||
|
p.DMA_CH0,
|
||||||
|
p.DMA_CH1,
|
||||||
|
mcu::Irqs,
|
||||||
|
spi_cfg,
|
||||||
|
);
|
||||||
|
let spi_bus = mcu::share_spi_bus(spi);
|
||||||
|
|
||||||
|
let nss = Output::new(p.PIN_13, Level::High);
|
||||||
|
let spi_device = SpiDevice::new(spi_bus, nss);
|
||||||
|
|
||||||
|
let reset = Output::new(p.PIN_23, Level::High);
|
||||||
|
let dio1 = Input::new(p.PIN_16, Pull::Down);
|
||||||
|
let busy = Input::new(p.PIN_18, Pull::Down);
|
||||||
|
|
||||||
|
// PE4259 antenna switch: GPIO17 = !CTRL (rx_enable).
|
||||||
|
// DIO2 is internally wired as CTRL (tx_enable), managed by the SX1262.
|
||||||
|
let rx_enable = Output::new(p.PIN_17, Level::Low);
|
||||||
|
let iv = GenericSx126xInterfaceVariant::new(reset, dio1, busy, Some(rx_enable), None)
|
||||||
|
.expect("SX1262 interface init");
|
||||||
|
|
||||||
|
let sx_config = sx126x::Config {
|
||||||
|
chip: Sx1262,
|
||||||
|
tcxo_ctrl: None, // No TCXO on this board
|
||||||
|
use_dcdc: true,
|
||||||
|
rx_boost: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
let radio = RadioParts {
|
||||||
|
driver: Sx126x::new(spi_device, iv, sx_config),
|
||||||
|
delay: Delay,
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── USB ────────────────────────────────────────────────
|
||||||
|
let host = UsbParts {
|
||||||
|
driver: embassy_rp::usb::Driver::new(p.USB, mcu::Irqs),
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── LED on GPIO25 ──────────────────────────────────────
|
||||||
|
let led_pin = Output::new(p.PIN_25, Level::Low);
|
||||||
|
let led = crate::driver::simple_led::SimpleLed(led_pin);
|
||||||
|
|
||||||
|
BoardParts {
|
||||||
|
radio,
|
||||||
|
host,
|
||||||
|
display: None,
|
||||||
|
led,
|
||||||
|
mac: Self::mac_address(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -128,7 +128,12 @@ impl LoRaBoard for Board {
|
|||||||
// ── Display (SH1106 OLED on I2C) ────────────────────────
|
// ── Display (SH1106 OLED on I2C) ────────────────────────
|
||||||
let twim_buf = mcu::alloc_i2c_buffer();
|
let twim_buf = mcu::alloc_i2c_buffer();
|
||||||
let i2c = embassy_nrf::twim::Twim::new(
|
let i2c = embassy_nrf::twim::Twim::new(
|
||||||
p.TWISPI0, mcu::Irqs, p.P0_06, p.P0_05, twim::Config::default(), twim_buf,
|
p.TWISPI0,
|
||||||
|
mcu::Irqs,
|
||||||
|
p.P0_06,
|
||||||
|
p.P0_05,
|
||||||
|
twim::Config::default(),
|
||||||
|
twim_buf,
|
||||||
);
|
);
|
||||||
let display = Some(DisplayParts { i2c });
|
let display = Some(DisplayParts { i2c });
|
||||||
|
|
||||||
|
|||||||
+68
-51
@@ -1,67 +1,84 @@
|
|||||||
//! Inter-task communication channels.
|
//! Inter-task communication channels for DongLoRa Protocol v2.
|
||||||
//!
|
//!
|
||||||
//! Each channel connects exactly two tasks:
|
//! - [`CommandChannel`]: host_task → radio_task (tag-correlated commands)
|
||||||
//! - [`CommandChannel`]: USB → Radio (host commands)
|
//! - [`OutboundChannel`]: radio_task → host_task (tag-correlated OK/ERR/TX_DONE
|
||||||
//! - [`ResponseChannel`]: Radio → USB (firmware responses)
|
//! and tag-0 async RX/ERR events in one queue)
|
||||||
//! - [`DisplayCommandChannel`]: USB → Display (on/off/reset)
|
//! - [`DisplayCommandChannel`]: host_task → display_task (internal only —
|
||||||
//! - [`StatusWatch`]: Radio → Display (observable radio state)
|
//! `On` on connect, `Reset` on disconnect; no longer driven from the wire)
|
||||||
|
//! - [`RadioEventChannel`]: radio_task → display_task (typed mode/packet events)
|
||||||
|
|
||||||
use embassy_sync::blocking_mutex::raw::CriticalSectionRawMutex;
|
use embassy_sync::blocking_mutex::raw::CriticalSectionRawMutex;
|
||||||
use embassy_sync::channel::Channel;
|
use embassy_sync::channel::Channel;
|
||||||
use embassy_sync::watch::Watch;
|
|
||||||
|
|
||||||
use crate::protocol::{Command, RadioConfig, Response};
|
use crate::protocol::{Command, DeviceMessage, LoRaConfig};
|
||||||
|
|
||||||
/// Display commands routed from host_task to display_task.
|
// ── Host → Radio: tag-correlated commands ───────────────────────────
|
||||||
#[derive(Debug, Clone, Copy, defmt::Format)]
|
|
||||||
pub enum DisplayCommand {
|
/// An event flowing from the host task into the radio task.
|
||||||
On,
|
///
|
||||||
Off,
|
/// `Command` carries a non-zero correlation tag (the host framing layer
|
||||||
|
/// rejects tag=0 before it reaches here). `Reset` is a lifecycle signal
|
||||||
|
/// from the host transport layer (DTR drop, UART teardown, or spec §3.4
|
||||||
|
/// inactivity timeout) telling the radio to park back to Unconfigured
|
||||||
|
/// and drain queues silently.
|
||||||
|
#[allow(clippy::large_enum_variant)]
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub enum InboundEvent {
|
||||||
|
Command { tag: u16, cmd: Command },
|
||||||
Reset,
|
Reset,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// USB-to-display command channel (depth 4: display commands are rare events).
|
/// Host-to-radio event channel (depth 8).
|
||||||
|
pub type CommandChannel = Channel<CriticalSectionRawMutex, InboundEvent, 8>;
|
||||||
|
|
||||||
|
// ── Radio → Host: OK / ERR / TX_DONE / RX / async ERR ──────────────
|
||||||
|
|
||||||
|
/// One outbound D→H frame carrying its tag. Tag-correlated messages
|
||||||
|
/// (OK, ERR in response, TX_DONE) carry the originating command's tag;
|
||||||
|
/// async events (RX, async ERR) carry `tag = 0x0000`.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct OutboundFrame {
|
||||||
|
pub tag: u16,
|
||||||
|
pub msg: DeviceMessage,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Radio-to-host outbound channel (depth 32: enough to hold a burst of
|
||||||
|
/// RX events + queued command responses without stalling the radio ISR).
|
||||||
|
pub type OutboundChannel = Channel<CriticalSectionRawMutex, OutboundFrame, 32>;
|
||||||
|
|
||||||
|
// ── Internal: display commands (no longer wire-visible) ────────────
|
||||||
|
|
||||||
|
/// Internal display command routed from host_task to display_task. The
|
||||||
|
/// wire protocol no longer carries DisplayOn/Off (`PROTOCOL.md §15.2`);
|
||||||
|
/// these values drive the display statechart from local events only.
|
||||||
|
#[derive(Debug, Clone, Copy, defmt::Format)]
|
||||||
|
#[allow(dead_code)] // `Off` is reserved for future diagnostic paths.
|
||||||
|
pub enum DisplayCommand {
|
||||||
|
/// Host connected (DTR rose on USB, or UART came up). Wake display.
|
||||||
|
On,
|
||||||
|
/// Host disconnected. Park display.
|
||||||
|
Off,
|
||||||
|
/// Reset cumulative counters (e.g., on config change).
|
||||||
|
Reset,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Host-to-display command channel.
|
||||||
pub type DisplayCommandChannel = Channel<CriticalSectionRawMutex, DisplayCommand, 4>;
|
pub type DisplayCommandChannel = Channel<CriticalSectionRawMutex, DisplayCommand, 4>;
|
||||||
|
|
||||||
/// Host-to-radio command channel (depth 16: buffer bursty host command sequences).
|
// ── Radio → Display: typed packet/mode events ──────────────────────
|
||||||
pub type CommandChannel = Channel<CriticalSectionRawMutex, Command, 16>;
|
|
||||||
|
|
||||||
/// Radio-to-host response channel (depth 32: buffer RX packets while USB writes).
|
/// Radio → downstream typed events. These drive the display; they are
|
||||||
pub type ResponseChannel = Channel<CriticalSectionRawMutex, Response, 32>;
|
/// NOT the same thing as `OutboundFrame` (which goes out the host
|
||||||
|
/// transport). Mode events describe what the radio is doing now; packet
|
||||||
/// Observable radio status for the display task (2 receivers max).
|
/// events describe what just happened.
|
||||||
/// Display always sees the latest state — intermediate updates may be
|
|
||||||
/// skipped, which is fine for a status display.
|
|
||||||
pub type StatusWatch = Watch<CriticalSectionRawMutex, RadioStatus, 2>;
|
|
||||||
|
|
||||||
/// Current radio state exposed to observers (e.g. display).
|
|
||||||
#[derive(Debug, Clone, defmt::Format)]
|
#[derive(Debug, Clone, defmt::Format)]
|
||||||
pub struct RadioStatus {
|
pub enum RadioEvent {
|
||||||
pub state: RadioState,
|
EnteredRx,
|
||||||
pub config: Option<RadioConfig>,
|
EnteredTx,
|
||||||
pub rx_count: u32,
|
|
||||||
pub tx_count: u32,
|
|
||||||
pub last_rssi: Option<i16>,
|
|
||||||
pub last_snr: Option<i16>,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Radio state machine states.
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, defmt::Format)]
|
|
||||||
pub enum RadioState {
|
|
||||||
Idle,
|
Idle,
|
||||||
Receiving,
|
PacketRx { rssi: i16, snr: Option<i16> },
|
||||||
Transmitting,
|
PacketTx,
|
||||||
|
ConfigChanged(LoRaConfig),
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for RadioStatus {
|
pub type RadioEventChannel = Channel<CriticalSectionRawMutex, RadioEvent, 32>;
|
||||||
fn default() -> Self {
|
|
||||||
Self {
|
|
||||||
state: RadioState::Idle,
|
|
||||||
config: None,
|
|
||||||
rx_count: 0,
|
|
||||||
tx_count: 0,
|
|
||||||
last_rssi: None,
|
|
||||||
last_snr: None,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,229 @@
|
|||||||
|
//! LED-based diagnostic blinker (opt-in via the `debug-checkpoint` cargo
|
||||||
|
//! feature).
|
||||||
|
//!
|
||||||
|
//! On boards whose UART is also the host protocol (heltec_v3_uart, any
|
||||||
|
//! UART-only variant), defmt output would corrupt the COBS-framed host
|
||||||
|
//! protocol. We can't use `defmt::info!` for live debugging on those
|
||||||
|
//! boards while the host is connected. Instead we expose a single global
|
||||||
|
//! atomic checkpoint; code sets it at interesting points in the boot /
|
||||||
|
//! dispatch flow, and a dedicated task continuously flashes the board
|
||||||
|
//! LED N times, pauses, and repeats — so a human can count the flashes
|
||||||
|
//! and report "it got to N."
|
||||||
|
//!
|
||||||
|
//! Blink pattern:
|
||||||
|
//! * Each flash: 400 ms ON, 400 ms OFF.
|
||||||
|
//! * After N flashes: 2000 ms pause (LED held OFF).
|
||||||
|
//! * Repeat indefinitely with the current `CHECKPOINT` value.
|
||||||
|
//!
|
||||||
|
//! Usage:
|
||||||
|
//! ```ignore
|
||||||
|
//! #[cfg(feature = "debug-checkpoint")]
|
||||||
|
//! crate::debug_blink::set(3); // "we reached checkpoint 3"
|
||||||
|
//! ```
|
||||||
|
//!
|
||||||
|
//! The task replaces `display_task` when the `debug-checkpoint` feature is
|
||||||
|
//! enabled — it takes the board LED and drives it exclusively.
|
||||||
|
//!
|
||||||
|
//! # Current checkpoint map
|
||||||
|
//!
|
||||||
|
//! Defined by the `crate::radio` module. Updated here any time the
|
||||||
|
//! numbering changes there.
|
||||||
|
//!
|
||||||
|
//! | Flashes | Meaning |
|
||||||
|
//! |--------:|--------------------------------------------------------|
|
||||||
|
//! | 1 | `radio_task` entered (pre-`LoRa::new`) |
|
||||||
|
//! | 2 | `LoRa::new` succeeded |
|
||||||
|
//! | 3 | Machine constructed; about to enter `run()` |
|
||||||
|
//! | 4 | In `Idle` state |
|
||||||
|
//! | 5 | In `Receiving` state |
|
||||||
|
//! | 6 | TX phase 1: entered `perform_tx` (engages latch) |
|
||||||
|
//! | 7 | TX phase 2: in CAD |
|
||||||
|
//! | 8 | TX phase 3: in `prepare_for_tx` |
|
||||||
|
//! | 9 | TX phase 4: awaiting `tx()` |
|
||||||
|
//! | 10 | TX phase 5: `do_tx` returned OK |
|
||||||
|
//! | 11 | TX phase 6: `respond_tx_done` handler entered |
|
||||||
|
//! | 12 | TX phase 7: `responses.send(TxDone)` returned |
|
||||||
|
//! | 13 | TX phase 8: `host_task` received the response from chan |
|
||||||
|
//! | 14 | TX phase 9: `host_task` wrote response bytes to UART — TERMINAL SUCCESS |
|
||||||
|
//! | 15 | TX errored somewhere — TERMINAL ERROR |
|
||||||
|
//! | 16 | Boot error: `LoRa::new` failed |
|
||||||
|
//! | 17 | Boot error: `start_rx` failed inside `start_rx_hw` |
|
||||||
|
//!
|
||||||
|
//! # Latching
|
||||||
|
//!
|
||||||
|
//! * Latch engages **only** on `set(6)` (entry to `perform_tx`).
|
||||||
|
//! * While latched, only TX-phase values (6–15) can update `CHECKPOINT`,
|
||||||
|
//! and only if strictly deeper than the current value.
|
||||||
|
//! * Terminals (14 success, 15 error) freeze the latch until reboot.
|
||||||
|
//! * `set(13)` and `set(14)` called from `host_task` are **no-ops before
|
||||||
|
//! the latch engages** — this prevents unrelated SetConfig/Ok responses
|
||||||
|
//! from polluting the display before any TX has happened.
|
||||||
|
//!
|
||||||
|
//! # Diagnostic logic
|
||||||
|
//!
|
||||||
|
//! If ai-bot reports a timeout but the LED shows:
|
||||||
|
//! * **10** → `do_tx` ok, but `TransmitDone` event never dispatched.
|
||||||
|
//! * **11** → handler entered, but `responses.send(...)` hung.
|
||||||
|
//! * **12** → send pushed to channel, but `host_task` never picked it up.
|
||||||
|
//! * **13** → `host_task` picked it up, but `tx.write_all` to UART hung.
|
||||||
|
//! * **14** → full end-to-end firmware success. Hang is on the host side
|
||||||
|
//! of the UART (mux or wire).
|
||||||
|
//! * **15** → TX errored somewhere along the way.
|
||||||
|
//!
|
||||||
|
//! # Building with the feature
|
||||||
|
//!
|
||||||
|
//! The `just` build/flash recipes pass a fixed feature list and don't
|
||||||
|
//! surface an `extra-features` argument. To build a debug image:
|
||||||
|
//!
|
||||||
|
//! ```sh
|
||||||
|
//! cd firmware
|
||||||
|
//! . ~/export-esp.sh # if building for xtensa (esp32s3)
|
||||||
|
//! cargo +esp build --release \
|
||||||
|
//! --target xtensa-esp32s3-none-elf \
|
||||||
|
//! --features heltec_v3_uart,debug-checkpoint \
|
||||||
|
//! -Zbuild-std=core,alloc
|
||||||
|
//! cp target/xtensa-esp32s3-none-elf/release/donglora \
|
||||||
|
//! builds/donglora-heltec_v3_uart-v$(sed -n 's/^version = "\(.*\)"/\1/p' \
|
||||||
|
//! Cargo.toml).elf
|
||||||
|
//! espflash flash -p /dev/ttyUSB0 builds/donglora-heltec_v3_uart-v0.4.0.elf
|
||||||
|
//! ```
|
||||||
|
//!
|
||||||
|
//! The normal `just flash heltec_v3_uart` command builds without the
|
||||||
|
//! feature, so the debug code has zero footprint on release firmware.
|
||||||
|
|
||||||
|
use core::sync::atomic::{AtomicBool, AtomicU8, Ordering};
|
||||||
|
|
||||||
|
use embassy_executor::task;
|
||||||
|
use embassy_time::{Duration, Timer};
|
||||||
|
|
||||||
|
use crate::board::{LedDriver, RgbLed};
|
||||||
|
|
||||||
|
/// Current diagnostic checkpoint — read by the blinker task. Zero means
|
||||||
|
/// "no checkpoint set yet."
|
||||||
|
///
|
||||||
|
/// Write policy (see [`set`]): once a TX-phase value (6, 11, 12, 13) is
|
||||||
|
/// stored, the latch engages and:
|
||||||
|
/// * subsequent TX-phase writes update the value only if strictly deeper,
|
||||||
|
/// * non-TX-phase writes (including the 7/10 terminal markers and the
|
||||||
|
/// steady-state 4/5 post-TX writes) are **ignored** so the deepest phase
|
||||||
|
/// reached on the FIRST TX attempt persists.
|
||||||
|
///
|
||||||
|
/// Rationale: a stuck-then-recovered TX attempt would normally flicker
|
||||||
|
/// through the display too quickly to count. Latching preserves the
|
||||||
|
/// evidence until reboot.
|
||||||
|
pub static CHECKPOINT: AtomicU8 = AtomicU8::new(0);
|
||||||
|
|
||||||
|
/// Has the TX-phase latch engaged yet? Written by [`set`], checked by
|
||||||
|
/// [`set`]. Not intended for external inspection.
|
||||||
|
static HAS_LATCHED: AtomicBool = AtomicBool::new(false);
|
||||||
|
|
||||||
|
#[inline]
|
||||||
|
fn is_host_phase(n: u8) -> bool {
|
||||||
|
// Values set only from `host_task` — meaningless before a TX has
|
||||||
|
// started, so we refuse to overwrite with them when the latch is
|
||||||
|
// not yet engaged.
|
||||||
|
n == 13 || n == 14
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ordered depth of each TX-related checkpoint. Returns `0` for values
|
||||||
|
/// that are NOT TX phases (e.g. boot / steady-state / error indicators).
|
||||||
|
///
|
||||||
|
/// Value 18 ("next_command received CmdTransmit") is intentionally the
|
||||||
|
/// SHALLOWEST TX phase — it fires before `perform_tx` entry (value 6)
|
||||||
|
/// but is numerically higher because it was added last. The latch uses
|
||||||
|
/// `phase_depth` rather than raw numeric comparison so 18 can engage
|
||||||
|
/// the latch at depth 1 and later TX phases (6..=15) deepen over it.
|
||||||
|
#[inline]
|
||||||
|
fn phase_depth(n: u8) -> u8 {
|
||||||
|
match n {
|
||||||
|
18 => 1, // pre-TX: CmdTransmit just read from commands channel
|
||||||
|
6 => 2, // perform_tx entered
|
||||||
|
7 => 3, // in CAD
|
||||||
|
8 => 4, // in prepare_for_tx
|
||||||
|
9 => 5, // in tx().await
|
||||||
|
10 => 6, // do_tx returned Ok
|
||||||
|
11 => 7, // respond_tx_done entered
|
||||||
|
12 => 8, // respond_tx_done: responses.send returned
|
||||||
|
13 => 9, // host_task received response from channel
|
||||||
|
14 => 10, // host_task wrote response to UART (terminal success)
|
||||||
|
15 => 11, // TX errored (terminal error)
|
||||||
|
_ => 0, // not a TX phase
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[inline]
|
||||||
|
fn is_tx_phase(n: u8) -> bool {
|
||||||
|
phase_depth(n) > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
#[inline]
|
||||||
|
fn is_tx_terminal(n: u8) -> bool {
|
||||||
|
n == 14 || n == 15
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Record that we reached checkpoint `n`. A no-op in terms of visible
|
||||||
|
/// behavior unless the blinker task is spawned; the store itself is very
|
||||||
|
/// cheap so leaving these calls in non-debug builds is harmless.
|
||||||
|
///
|
||||||
|
/// Latch semantics:
|
||||||
|
/// * `set(18)` (CmdTransmit received by `next_command`) or `set(6)`
|
||||||
|
/// (entry to `perform_tx`) engage the latch.
|
||||||
|
/// * While latched, only strictly-deeper TX phases (by `phase_depth`)
|
||||||
|
/// update the checkpoint.
|
||||||
|
/// * Terminal values (14 success, 15 error) freeze the latch until
|
||||||
|
/// reboot.
|
||||||
|
/// * Host-phase values (13, 14) called from `host_task` are ignored
|
||||||
|
/// before the latch engages, so unrelated `Response::Ok` writes from
|
||||||
|
/// SetConfig don't pollute the LED reading before any TX.
|
||||||
|
#[inline]
|
||||||
|
pub fn set(n: u8) {
|
||||||
|
if HAS_LATCHED.load(Ordering::Relaxed) {
|
||||||
|
let cur = CHECKPOINT.load(Ordering::Relaxed);
|
||||||
|
if is_tx_terminal(cur) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if phase_depth(n) > phase_depth(cur) {
|
||||||
|
CHECKPOINT.store(n, Ordering::Relaxed);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Latch not yet engaged.
|
||||||
|
if is_host_phase(n) {
|
||||||
|
// `host_task` is draining the response channel before any TX —
|
||||||
|
// these values would be misleading. Skip them.
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
CHECKPOINT.store(n, Ordering::Relaxed);
|
||||||
|
if is_tx_phase(n) {
|
||||||
|
HAS_LATCHED.store(true, Ordering::Relaxed);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Dedicated task that continuously flashes the LED to show the current
|
||||||
|
/// checkpoint. Owns the board's LED driver exclusively — on boards where
|
||||||
|
/// the display task would normally own the LED for RX/TX blips, spawn
|
||||||
|
/// *either* this task *or* `display_task`, not both.
|
||||||
|
#[task]
|
||||||
|
pub async fn debug_blink_task(mut led: LedDriver) {
|
||||||
|
// Start with the LED off so the first flash is unambiguous.
|
||||||
|
led.set_rgb(0, 0, 0).await;
|
||||||
|
|
||||||
|
loop {
|
||||||
|
let n = CHECKPOINT.load(Ordering::Relaxed);
|
||||||
|
if n == 0 {
|
||||||
|
// No checkpoint yet — wait and re-check.
|
||||||
|
Timer::after(Duration::from_millis(200)).await;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
for _ in 0..n {
|
||||||
|
led.set_rgb(255, 255, 255).await;
|
||||||
|
Timer::after(Duration::from_millis(400)).await;
|
||||||
|
led.set_rgb(0, 0, 0).await;
|
||||||
|
Timer::after(Duration::from_millis(400)).await;
|
||||||
|
}
|
||||||
|
// Long pause to separate cycles so the human can count.
|
||||||
|
Timer::after(Duration::from_millis(2000)).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
+598
-252
File diff suppressed because it is too large.
Load diff
+62
-2
@@ -1,5 +1,65 @@
|
|||||||
#[cfg(feature = "wio_tracker_l1")]
|
#[cfg(any(feature = "wio_tracker_l1", feature = "elecrow_thinknode_m2"))]
|
||||||
pub mod sh1106;
|
pub mod sh1106;
|
||||||
|
|
||||||
#[cfg(any(feature = "heltec_v3", feature = "heltec_v3_uart", feature = "heltec_v4", feature = "wio_tracker_l1"))]
|
#[cfg(any(
|
||||||
|
feature = "heltec_v3",
|
||||||
|
feature = "heltec_v3_uart",
|
||||||
|
feature = "heltec_v4",
|
||||||
|
feature = "elecrow_thinknode_m2",
|
||||||
|
feature = "wio_tracker_l1",
|
||||||
|
feature = "waveshare_rp2040_lora"
|
||||||
|
))]
|
||||||
pub mod simple_led;
|
pub mod simple_led;
|
||||||
|
|
||||||
|
// ── Display brightness ────────────────────────────────────────────────
|
||||||
|
//
|
||||||
|
// The display statechart dims the OLED a few seconds after the last
|
||||||
|
// activity. Our two driver families (`ssd1306` crate and our in-tree
|
||||||
|
// `sh1106`) expose incompatible brightness APIs, so we wrap them in a
|
||||||
|
// single async trait that the state-machine actions can call uniformly.
|
||||||
|
|
||||||
|
/// Two-level backlight control used by the display dashboard.
|
||||||
|
pub trait DisplayBrightness {
|
||||||
|
fn set_bright(&mut self) -> impl core::future::Future<Output = ()>;
|
||||||
|
fn set_dim(&mut self) -> impl core::future::Future<Output = ()>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Contrast values for the two brightness levels, picked to be common across
|
||||||
|
// both driver families. `0xFF` is the SSD1306/SH1106 maximum; `0x08` is ~3%
|
||||||
|
// of max — visible in a dim room, barely noticeable in daylight. Tune here.
|
||||||
|
const CONTRAST_BRIGHT: u8 = 0xFF;
|
||||||
|
const CONTRAST_DIM: u8 = 0x08;
|
||||||
|
|
||||||
|
impl<DI, SIZE, MODE> DisplayBrightness for ssd1306::Ssd1306Async<DI, SIZE, MODE>
|
||||||
|
where
|
||||||
|
DI: display_interface::AsyncWriteOnlyDataCommand,
|
||||||
|
SIZE: ssd1306::size::DisplaySizeAsync,
|
||||||
|
{
|
||||||
|
async fn set_bright(&mut self) {
|
||||||
|
// `Brightness::custom(precharge, contrast)`. Matches the shape of
|
||||||
|
// the crate's BRIGHTEST preset (precharge 0x2) at max contrast.
|
||||||
|
let _ = self
|
||||||
|
.set_brightness(ssd1306::prelude::Brightness::custom(0x2, CONTRAST_BRIGHT))
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
async fn set_dim(&mut self) {
|
||||||
|
// Low precharge pairs with low contrast — same combination the
|
||||||
|
// crate's DIMMEST preset uses.
|
||||||
|
let _ = self
|
||||||
|
.set_brightness(ssd1306::prelude::Brightness::custom(0x1, CONTRAST_DIM))
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(any(feature = "wio_tracker_l1", feature = "elecrow_thinknode_m2"))]
|
||||||
|
impl<I> DisplayBrightness for sh1106::Sh1106<I>
|
||||||
|
where
|
||||||
|
I: embedded_hal_async::i2c::I2c,
|
||||||
|
{
|
||||||
|
async fn set_bright(&mut self) {
|
||||||
|
let _ = self.set_brightness(CONTRAST_BRIGHT).await;
|
||||||
|
}
|
||||||
|
async fn set_dim(&mut self) {
|
||||||
|
let _ = self.set_brightness(CONTRAST_DIM).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -73,9 +73,9 @@ impl<I: I2c> Sh1106<I> {
|
|||||||
for page in 0..PAGES {
|
for page in 0..PAGES {
|
||||||
// Set page address and column start (with SH1106 2-column offset).
|
// Set page address and column start (with SH1106 2-column offset).
|
||||||
self.cmd(&[
|
self.cmd(&[
|
||||||
0xB0 | page as u8, // page address
|
0xB0 | page as u8, // page address
|
||||||
COL_OFFSET & 0x0F, // column low nibble
|
COL_OFFSET & 0x0F, // column low nibble
|
||||||
0x10 | (COL_OFFSET >> 4), // column high nibble
|
0x10 | (COL_OFFSET >> 4), // column high nibble
|
||||||
])
|
])
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
@@ -90,7 +90,6 @@ impl<I: I2c> Sh1106<I> {
|
|||||||
pub async fn set_brightness(&mut self, value: u8) -> Result<(), I::Error> {
|
pub async fn set_brightness(&mut self, value: u8) -> Result<(), I::Error> {
|
||||||
self.cmd(&[0x81, value]).await
|
self.cmd(&[0x81, value]).await
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl<I: I2c> DrawTarget for Sh1106<I> {
|
impl<I: I2c> DrawTarget for Sh1106<I> {
|
||||||
|
|||||||
+23
-8
@@ -6,6 +6,7 @@
|
|||||||
|
|
||||||
use embassy_sync::blocking_mutex::raw::NoopRawMutex;
|
use embassy_sync::blocking_mutex::raw::NoopRawMutex;
|
||||||
use esp_hal::dma::{DmaRxBuf, DmaTxBuf};
|
use esp_hal::dma::{DmaRxBuf, DmaTxBuf};
|
||||||
|
use esp_hal::gpio::interconnect::{PeripheralInput, PeripheralOutput};
|
||||||
use esp_hal::i2c::master::{Config as I2cConfig, I2c};
|
use esp_hal::i2c::master::{Config as I2cConfig, I2c};
|
||||||
use esp_hal::spi::master::{Config as SpiConfig, Spi, SpiDmaBus};
|
use esp_hal::spi::master::{Config as SpiConfig, Spi, SpiDmaBus};
|
||||||
use esp_hal::spi::Mode as SpiMode;
|
use esp_hal::spi::Mode as SpiMode;
|
||||||
@@ -21,20 +22,28 @@ pub type I2cBus = I2c<'static, esp_hal::Async>;
|
|||||||
// ── Timer ───────────────────────────────────────────────────────────
|
// ── Timer ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/// Start the ESP-RTOS scheduler and Embassy time driver.
|
/// Start the ESP-RTOS scheduler and Embassy time driver.
|
||||||
pub fn start_timer(timg0: esp_hal::peripherals::TIMG0<'static>) {
|
pub fn start_timer(
|
||||||
|
timg0: esp_hal::peripherals::TIMG0<'static>,
|
||||||
|
sw_int: esp_hal::peripherals::SW_INTERRUPT<'static>,
|
||||||
|
) {
|
||||||
let timg0 = TimerGroup::new(timg0);
|
let timg0 = TimerGroup::new(timg0);
|
||||||
esp_rtos::start(timg0.timer0);
|
let sw_ints = esp_hal::interrupt::software::SoftwareInterruptControl::new(sw_int);
|
||||||
|
esp_rtos::start(timg0.timer0, sw_ints.software_interrupt0);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── SPI bus ─────────────────────────────────────────────────────────
|
// ── SPI bus ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/// Initialize SPI2 with DMA and wrap in a shared bus (StaticCell + Mutex).
|
/// Initialize SPI2 with DMA and wrap in a shared bus (StaticCell + Mutex).
|
||||||
|
///
|
||||||
|
/// Generic over pin type so boards with different SPI pin assignments can
|
||||||
|
/// share this helper. Any GPIO that implements the esp-hal interconnect
|
||||||
|
/// traits (which is every GPIO on the ESP32-S3) can be passed directly.
|
||||||
pub fn init_spi(
|
pub fn init_spi(
|
||||||
spi2: esp_hal::peripherals::SPI2<'static>,
|
spi2: esp_hal::peripherals::SPI2<'static>,
|
||||||
dma_ch0: esp_hal::peripherals::DMA_CH0<'static>,
|
dma_ch0: esp_hal::peripherals::DMA_CH0<'static>,
|
||||||
sck: esp_hal::peripherals::GPIO9<'static>,
|
sck: impl PeripheralOutput<'static>,
|
||||||
mosi: esp_hal::peripherals::GPIO10<'static>,
|
mosi: impl PeripheralOutput<'static>,
|
||||||
miso: esp_hal::peripherals::GPIO11<'static>,
|
miso: impl PeripheralInput<'static>,
|
||||||
) -> &'static embassy_sync::mutex::Mutex<NoopRawMutex, SpiBus> {
|
) -> &'static embassy_sync::mutex::Mutex<NoopRawMutex, SpiBus> {
|
||||||
let spi = Spi::new(
|
let spi = Spi::new(
|
||||||
spi2,
|
spi2,
|
||||||
@@ -62,10 +71,13 @@ pub fn init_spi(
|
|||||||
// ── I2C bus ─────────────────────────────────────────────────────────
|
// ── I2C bus ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/// Initialize I2C0 for peripherals (display, sensors, etc.).
|
/// Initialize I2C0 for peripherals (display, sensors, etc.).
|
||||||
|
///
|
||||||
|
/// Generic over pin type. I2C lines are bidirectional (open-drain), so both
|
||||||
|
/// `PeripheralInput` and `PeripheralOutput` bounds are required.
|
||||||
pub fn init_i2c(
|
pub fn init_i2c(
|
||||||
i2c0: esp_hal::peripherals::I2C0<'static>,
|
i2c0: esp_hal::peripherals::I2C0<'static>,
|
||||||
sda: esp_hal::peripherals::GPIO17<'static>,
|
sda: impl PeripheralInput<'static> + PeripheralOutput<'static>,
|
||||||
scl: esp_hal::peripherals::GPIO18<'static>,
|
scl: impl PeripheralInput<'static> + PeripheralOutput<'static>,
|
||||||
) -> I2cBus {
|
) -> I2cBus {
|
||||||
I2c::new(i2c0, I2cConfig::default())
|
I2c::new(i2c0, I2cConfig::default())
|
||||||
.expect("I2C init")
|
.expect("I2C init")
|
||||||
@@ -78,5 +90,8 @@ pub fn init_i2c(
|
|||||||
|
|
||||||
/// Read the factory-programmed MAC address from eFuse.
|
/// Read the factory-programmed MAC address from eFuse.
|
||||||
pub fn mac_address() -> [u8; 6] {
|
pub fn mac_address() -> [u8; 6] {
|
||||||
esp_hal::efuse::Efuse::mac_address()
|
esp_hal::efuse::base_mac_address()
|
||||||
|
.as_bytes()
|
||||||
|
.try_into()
|
||||||
|
.expect("MAC is 6 bytes")
|
||||||
}
|
}
|
||||||
+9
-1
@@ -3,8 +3,16 @@
|
|||||||
//! Each submodule provides low-level bus and peripheral init for one MCU family.
|
//! 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.
|
//! Board files call these with specific pins and construct higher-level drivers.
|
||||||
|
|
||||||
#[cfg(any(feature = "heltec_v3", feature = "heltec_v3_uart", feature = "heltec_v4"))]
|
#[cfg(any(
|
||||||
|
feature = "heltec_v3",
|
||||||
|
feature = "heltec_v3_uart",
|
||||||
|
feature = "heltec_v4",
|
||||||
|
feature = "elecrow_thinknode_m2"
|
||||||
|
))]
|
||||||
pub mod esp32s3;
|
pub mod esp32s3;
|
||||||
|
|
||||||
#[cfg(any(feature = "rak_wisblock_4631", feature = "wio_tracker_l1"))]
|
#[cfg(any(feature = "rak_wisblock_4631", feature = "wio_tracker_l1"))]
|
||||||
pub mod nrf52840;
|
pub mod nrf52840;
|
||||||
|
|
||||||
|
#[cfg(feature = "waveshare_rp2040_lora")]
|
||||||
|
pub mod rp2040;
|
||||||
+1
-3
@@ -25,9 +25,7 @@ pub type UsbNrfDriver =
|
|||||||
// ── SPI bus sharing ─────────────────────────────────────────────────
|
// ── SPI bus sharing ─────────────────────────────────────────────────
|
||||||
|
|
||||||
/// Wrap an initialized SPI peripheral in a shared bus (StaticCell + Mutex).
|
/// Wrap an initialized SPI peripheral in a shared bus (StaticCell + Mutex).
|
||||||
pub fn share_spi_bus(
|
pub fn share_spi_bus(spi: SpiBus) -> &'static embassy_sync::mutex::Mutex<NoopRawMutex, SpiBus> {
|
||||||
spi: SpiBus,
|
|
||||||
) -> &'static embassy_sync::mutex::Mutex<NoopRawMutex, SpiBus> {
|
|
||||||
static SPI_BUS: StaticCell<embassy_sync::mutex::Mutex<NoopRawMutex, SpiBus>> =
|
static SPI_BUS: StaticCell<embassy_sync::mutex::Mutex<NoopRawMutex, SpiBus>> =
|
||||||
StaticCell::new();
|
StaticCell::new();
|
||||||
SPI_BUS.init(embassy_sync::mutex::Mutex::new(spi))
|
SPI_BUS.init(embassy_sync::mutex::Mutex::new(spi))
|
||||||
|
|||||||
@@ -0,0 +1,80 @@
|
|||||||
|
//! RP2040 MCU initialization primitives.
|
||||||
|
//!
|
||||||
|
//! Provides shared boilerplate for RP2040 boards: interrupt bindings,
|
||||||
|
//! type aliases, StaticCell bus wrapping, USB init, and MAC address reading.
|
||||||
|
|
||||||
|
use embassy_rp::bind_interrupts;
|
||||||
|
use embassy_sync::blocking_mutex::raw::NoopRawMutex;
|
||||||
|
use static_cell::StaticCell;
|
||||||
|
|
||||||
|
// ── Interrupts (shared by all RP2040 boards) ──────────────────────
|
||||||
|
|
||||||
|
bind_interrupts!(pub struct Irqs {
|
||||||
|
USBCTRL_IRQ => embassy_rp::usb::InterruptHandler<embassy_rp::peripherals::USB>;
|
||||||
|
DMA_IRQ_0 => embassy_rp::dma::InterruptHandler<embassy_rp::peripherals::DMA_CH0>,
|
||||||
|
embassy_rp::dma::InterruptHandler<embassy_rp::peripherals::DMA_CH1>;
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── MCU-level types ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
pub type SpiBus =
|
||||||
|
embassy_rp::spi::Spi<'static, embassy_rp::peripherals::SPI1, embassy_rp::spi::Async>;
|
||||||
|
pub type UsbRpDriver = embassy_rp::usb::Driver<'static, embassy_rp::peripherals::USB>;
|
||||||
|
|
||||||
|
// ── SPI bus sharing ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// Wrap an initialized SPI peripheral in a shared bus (StaticCell + Mutex).
|
||||||
|
pub fn share_spi_bus(spi: SpiBus) -> &'static embassy_sync::mutex::Mutex<NoopRawMutex, SpiBus> {
|
||||||
|
static SPI_BUS: StaticCell<embassy_sync::mutex::Mutex<NoopRawMutex, SpiBus>> =
|
||||||
|
StaticCell::new();
|
||||||
|
SPI_BUS.init(embassy_sync::mutex::Mutex::new(spi))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── MAC address ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// Store for the MAC address derived from flash unique ID.
|
||||||
|
/// Populated once during `init_mac()`, read by `mac_address()`.
|
||||||
|
static MAC: StaticCell<[u8; 6]> = StaticCell::new();
|
||||||
|
static mut MAC_REF: Option<&'static [u8; 6]> = None;
|
||||||
|
|
||||||
|
/// Read the flash chip's unique ID and derive a 6-byte MAC address.
|
||||||
|
///
|
||||||
|
/// Must be called once during board init before `mac_address()`.
|
||||||
|
pub fn init_mac<const FLASH_SIZE: usize>(
|
||||||
|
flash: &mut embassy_rp::flash::Flash<
|
||||||
|
'_,
|
||||||
|
embassy_rp::peripherals::FLASH,
|
||||||
|
embassy_rp::flash::Blocking,
|
||||||
|
FLASH_SIZE,
|
||||||
|
>,
|
||||||
|
) {
|
||||||
|
let mut uid = [0u8; 8];
|
||||||
|
flash.blocking_unique_id(&mut uid).expect("flash unique ID");
|
||||||
|
|
||||||
|
// XOR the first 2 bytes into bytes 2..8 to fold 8 bytes into 6,
|
||||||
|
// then set the locally-administered bit (byte 0, bit 1).
|
||||||
|
let mac = [
|
||||||
|
uid[2] ^ uid[0] | 0x02,
|
||||||
|
uid[3] ^ uid[1],
|
||||||
|
uid[4],
|
||||||
|
uid[5],
|
||||||
|
uid[6],
|
||||||
|
uid[7],
|
||||||
|
];
|
||||||
|
|
||||||
|
let stored = MAC.init(mac);
|
||||||
|
// SAFETY: Written once during single-threaded init, before the executor
|
||||||
|
// starts. All subsequent reads are from &'static reference.
|
||||||
|
unsafe {
|
||||||
|
MAC_REF = Some(stored);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Return the MAC address derived from the flash unique ID.
|
||||||
|
///
|
||||||
|
/// Panics if `init_mac()` has not been called.
|
||||||
|
pub fn mac_address() -> [u8; 6] {
|
||||||
|
// SAFETY: MAC_REF is set once during init before the executor starts,
|
||||||
|
// and only read (never written) after that.
|
||||||
|
unsafe { *MAC_REF.expect("init_mac() must be called before mac_address()") }
|
||||||
|
}
|
||||||
+108
-111
@@ -1,124 +1,121 @@
|
|||||||
//! Shared COBS protocol helpers for USB and UART transports.
|
//! Shared framing helpers for USB and UART host transports.
|
||||||
//!
|
//!
|
||||||
//! Both `usb_task` and `uart_task` use these helpers for frame
|
//! Ingress path: raw bytes → `FrameDecoder` → `(type_id, tag, payload)`
|
||||||
//! accumulation, response encoding, and command routing.
|
//! → `Command::parse` → forwarded to radio task along with the tag. On
|
||||||
|
//! parse failure we echo `ERR(ELENGTH/EPARAM/EMODULATION/EUNKNOWN_CMD)`
|
||||||
|
//! with the received tag. On CRC/COBS failure we emit async
|
||||||
|
//! `ERR(EFRAME)` with tag `0x0000` (`PROTOCOL.md §2.5`).
|
||||||
|
//!
|
||||||
|
//! Egress path: `OutboundFrame` (tag + DeviceMessage) → `encode_frame`
|
||||||
|
//! → bytes ready for transport-level write.
|
||||||
|
|
||||||
use defmt::warn;
|
use defmt::warn;
|
||||||
|
|
||||||
use crate::channel::{CommandChannel, DisplayCommand, DisplayCommandChannel, ResponseChannel};
|
use crate::channel::{CommandChannel, InboundEvent, OutboundChannel, OutboundFrame};
|
||||||
use crate::protocol::{Command, ErrorCode, Response};
|
use crate::protocol::{
|
||||||
|
encode_frame, Command, CommandParseError, DeviceMessage, ErrorCode, FrameEncodeError,
|
||||||
|
MAX_PAYLOAD_FIELD, MAX_WIRE_FRAME,
|
||||||
|
};
|
||||||
|
|
||||||
/// Maximum COBS frame size (bytes).
|
/// Dispatch a frame-level decode into the appropriate downstream queue.
|
||||||
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.
|
/// - `type_id`, `tag`, and `payload` come from `FrameDecoder`.
|
||||||
pub fn cobs_encode_response<'a>(
|
/// - Known commands are forwarded to the radio task via `commands`.
|
||||||
response: Response,
|
/// - Parse failures produce a synchronous `ERR` with the echoed tag on
|
||||||
write_buf: &mut [u8; MAX_FRAME],
|
/// `outbound`, so the host sees a concrete answer instead of timing out.
|
||||||
encode_buf: &'a mut [u8; MAX_FRAME],
|
/// - The caller is responsible for rejecting `tag == 0` before calling
|
||||||
) -> Option<&'a [u8]> {
|
/// this; the spec says hosts MUST NOT send tag 0, and we classify such
|
||||||
let raw_len = response.write_to(write_buf);
|
/// frames as framing errors upstream.
|
||||||
let encoded_len = ucobs::encode(&write_buf[..raw_len], encode_buf).unwrap_or(0);
|
pub async fn dispatch_frame(
|
||||||
if encoded_len < encode_buf.len() {
|
type_id: u8,
|
||||||
encode_buf[encoded_len] = 0x00;
|
tag: u16,
|
||||||
Some(&encode_buf[..encoded_len + 1])
|
payload: &[u8],
|
||||||
} 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,
|
commands: &CommandChannel,
|
||||||
responses: &ResponseChannel,
|
outbound: &OutboundChannel,
|
||||||
display_commands: &DisplayCommandChannel,
|
|
||||||
has_display: bool,
|
|
||||||
mac: [u8; 6],
|
|
||||||
) {
|
) {
|
||||||
match cmd {
|
if tag == 0 {
|
||||||
Command::DisplayOn => {
|
// Hosts MUST NOT use tag 0 on outbound commands. Treat as
|
||||||
if has_display {
|
// framing error; do not route to radio. (Spec §2.2)
|
||||||
display_commands.send(DisplayCommand::On).await;
|
emit_async_err(outbound, ErrorCode::EFrame).await;
|
||||||
responses.send(Response::Ok).await;
|
return;
|
||||||
} else {
|
}
|
||||||
responses.send(Response::Error(ErrorCode::NoDisplay)).await;
|
match Command::parse(type_id, payload) {
|
||||||
|
Ok(cmd) => {
|
||||||
|
let envelope = InboundEvent::Command { tag, cmd };
|
||||||
|
if commands.try_send(envelope).is_err() {
|
||||||
|
emit_sync_err(outbound, tag, ErrorCode::EBusy).await;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Command::DisplayOff => {
|
Err(e) => {
|
||||||
if has_display {
|
let code = parse_err_to_wire_code(e);
|
||||||
display_commands.send(DisplayCommand::Off).await;
|
emit_sync_err(outbound, tag, code).await;
|
||||||
responses.send(Response::Ok).await;
|
}
|
||||||
} else {
|
}
|
||||||
responses.send(Response::Error(ErrorCode::NoDisplay)).await;
|
}
|
||||||
}
|
|
||||||
}
|
/// Emit an async framing error (tag = 0x0000) per spec §2.5.
|
||||||
Command::GetMac => {
|
pub async fn emit_async_err(outbound: &OutboundChannel, code: ErrorCode) {
|
||||||
responses.send(Response::MacAddress(mac)).await;
|
let frame = OutboundFrame {
|
||||||
}
|
tag: 0,
|
||||||
other => {
|
msg: DeviceMessage::Err(code),
|
||||||
commands.send(other).await;
|
};
|
||||||
|
if outbound.try_send(frame).is_err() {
|
||||||
|
warn!("async ERR dropped: outbound queue full");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Emit a synchronous error response echoing the originating tag.
|
||||||
|
async fn emit_sync_err(outbound: &OutboundChannel, tag: u16, code: ErrorCode) {
|
||||||
|
let frame = OutboundFrame {
|
||||||
|
tag,
|
||||||
|
msg: DeviceMessage::Err(code),
|
||||||
|
};
|
||||||
|
if outbound.try_send(frame).is_err() {
|
||||||
|
warn!("sync ERR dropped: outbound queue full");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_err_to_wire_code(e: CommandParseError) -> ErrorCode {
|
||||||
|
match e {
|
||||||
|
CommandParseError::UnknownType => ErrorCode::EUnknownCmd,
|
||||||
|
CommandParseError::WrongLength => ErrorCode::ELength,
|
||||||
|
CommandParseError::InvalidField => ErrorCode::EParam,
|
||||||
|
CommandParseError::ReservedBitSet => ErrorCode::EParam,
|
||||||
|
CommandParseError::UnknownModulation => ErrorCode::EModulation,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Encode an outbound frame into a single wire-ready byte buffer.
|
||||||
|
///
|
||||||
|
/// Returns the number of bytes written to `out`, or `None` on buffer
|
||||||
|
/// overflow / encode failure (caller may log and drop).
|
||||||
|
pub fn encode_outbound(frame: &OutboundFrame, out: &mut [u8; MAX_WIRE_FRAME]) -> Option<usize> {
|
||||||
|
let mut payload_buf = [0u8; MAX_PAYLOAD_FIELD];
|
||||||
|
let payload_len = match frame.msg.encode_payload(&mut payload_buf) {
|
||||||
|
Ok(n) => n,
|
||||||
|
Err(e) => {
|
||||||
|
warn!("encode_payload failed: {:?}", defmt::Debug2Format(&e));
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
match encode_frame(
|
||||||
|
frame.msg.type_id(),
|
||||||
|
frame.tag,
|
||||||
|
&payload_buf[..payload_len],
|
||||||
|
out.as_mut_slice(),
|
||||||
|
) {
|
||||||
|
Ok(n) => Some(n),
|
||||||
|
Err(FrameEncodeError::BufferTooSmall) => {
|
||||||
|
warn!("encode_frame: output buffer too small");
|
||||||
|
None
|
||||||
|
}
|
||||||
|
Err(FrameEncodeError::PayloadTooLarge) => {
|
||||||
|
warn!("encode_frame: payload exceeds MAX_PAYLOAD_FIELD");
|
||||||
|
None
|
||||||
|
}
|
||||||
|
Err(FrameEncodeError::CobsEncode) => {
|
||||||
|
warn!("encode_frame: COBS encode refused");
|
||||||
|
None
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+1
-1
@@ -6,7 +6,7 @@
|
|||||||
pub mod framing;
|
pub mod framing;
|
||||||
|
|
||||||
cfg_if::cfg_if! {
|
cfg_if::cfg_if! {
|
||||||
if #[cfg(feature = "heltec_v3_uart")] {
|
if #[cfg(any(feature = "heltec_v3_uart", feature = "elecrow_thinknode_m2"))] {
|
||||||
mod uart;
|
mod uart;
|
||||||
pub use uart::host_task;
|
pub use uart::host_task;
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
+108
-42
@@ -1,75 +1,141 @@
|
|||||||
//! UART host task: COBS-framed fixed-size LE command/response protocol.
|
//! UART host task for DongLoRa Protocol v2.
|
||||||
//!
|
//!
|
||||||
//! Used for boards with USB-UART bridge chips (e.g. CP2102 on Heltec V3).
|
//! UART has no DTR line, so we assume a host is always present and
|
||||||
|
//! rely entirely on the spec §3.4 inactivity watchdog (1 s of silence
|
||||||
|
//! → Disconnected → drop radio to Unconfigured).
|
||||||
|
|
||||||
use defmt::warn;
|
use defmt::warn;
|
||||||
use embassy_executor::task;
|
use embassy_executor::task;
|
||||||
|
use embassy_time::{Duration, Instant};
|
||||||
use embedded_io_async::Write;
|
use embedded_io_async::Write;
|
||||||
|
|
||||||
use crate::channel::{CommandChannel, DisplayCommand, DisplayCommandChannel, ResponseChannel};
|
use super::framing::{dispatch_frame, emit_async_err, encode_outbound};
|
||||||
use super::framing::{self, CobsDecoder, MAX_FRAME};
|
use crate::channel::{
|
||||||
|
CommandChannel, DisplayCommand, DisplayCommandChannel, InboundEvent, OutboundChannel,
|
||||||
|
};
|
||||||
|
use crate::protocol::{ErrorCode, FrameDecoder, FrameResult, MAX_PAYLOAD_FIELD, MAX_WIRE_FRAME};
|
||||||
|
|
||||||
|
const READ_BUF: usize = 64;
|
||||||
|
const INACTIVITY_MS: u64 = 1000;
|
||||||
|
|
||||||
#[task]
|
#[task]
|
||||||
pub async fn host_task(
|
pub async fn host_task(
|
||||||
parts: crate::board::UartParts,
|
parts: crate::board::UartParts,
|
||||||
commands: &'static CommandChannel,
|
commands: &'static CommandChannel,
|
||||||
responses: &'static ResponseChannel,
|
outbound: &'static OutboundChannel,
|
||||||
display_commands: &'static DisplayCommandChannel,
|
display_commands: &'static DisplayCommandChannel,
|
||||||
has_display: bool,
|
has_display: bool,
|
||||||
mac: [u8; 6],
|
_mac: [u8; 6],
|
||||||
) {
|
) {
|
||||||
let (mut rx, mut tx) = parts.driver.split();
|
let (mut rx, mut tx) = parts.driver.split();
|
||||||
|
|
||||||
// UART has no DTR — signal the display that a host is present at boot.
|
// UART has no DTR — wake display immediately and assume a host.
|
||||||
if has_display {
|
if has_display {
|
||||||
display_commands.send(DisplayCommand::Reset).await;
|
display_commands.send(DisplayCommand::On).await;
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut read_buf = [0u8; 64];
|
let mut read_buf = [0u8; READ_BUF];
|
||||||
let mut write_buf = [0u8; MAX_FRAME];
|
let mut write_buf: [u8; MAX_WIRE_FRAME] = [0u8; MAX_WIRE_FRAME];
|
||||||
let mut cobs_encode_buf = [0u8; MAX_FRAME];
|
let mut decoder = FrameDecoder::new();
|
||||||
let mut decoder = CobsDecoder::new();
|
let mut last_frame_at = Instant::now();
|
||||||
|
let mut connected = true;
|
||||||
|
|
||||||
loop {
|
loop {
|
||||||
use embassy_futures::select::{select, Either};
|
use embassy_futures::select::{select3, Either3};
|
||||||
|
|
||||||
match select(
|
match select3(
|
||||||
embedded_io_async::Read::read(&mut rx, &mut read_buf),
|
embedded_io_async::Read::read(&mut rx, &mut read_buf),
|
||||||
responses.receive(),
|
outbound.receive(),
|
||||||
|
embassy_time::Timer::after(Duration::from_millis(100)),
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
Either::First(result) => {
|
Either3::First(result) => match result {
|
||||||
let n = match result {
|
Ok(0) => continue,
|
||||||
Ok(0) => continue,
|
Ok(n) => {
|
||||||
Ok(n) => n,
|
last_frame_at = Instant::now();
|
||||||
Err(_) => {
|
if !connected {
|
||||||
decoder.reset();
|
if has_display {
|
||||||
embassy_time::Timer::after_millis(100).await;
|
let _ = display_commands.try_send(DisplayCommand::On);
|
||||||
continue;
|
}
|
||||||
|
connected = true;
|
||||||
|
}
|
||||||
|
dispatch_bytes(&mut decoder, &read_buf[..n], commands, outbound).await;
|
||||||
|
}
|
||||||
|
Err(_) => {
|
||||||
|
decoder.reset();
|
||||||
|
embassy_time::Timer::after_millis(100).await;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
Either3::Second(frame) => {
|
||||||
|
if let Some(n) = encode_outbound(&frame, &mut write_buf) {
|
||||||
|
if tx.write_all(&write_buf[..n]).await.is_err() {
|
||||||
|
warn!("UART write failed, frame dropped");
|
||||||
}
|
}
|
||||||
};
|
|
||||||
|
|
||||||
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) => {
|
Either3::Third(()) => {}
|
||||||
if let Some(frame) =
|
}
|
||||||
framing::cobs_encode_response(response, &mut write_buf, &mut cobs_encode_buf)
|
|
||||||
{
|
// Inactivity watchdog.
|
||||||
if tx.write_all(frame).await.is_err() {
|
if connected && last_frame_at.elapsed() >= Duration::from_millis(INACTIVITY_MS) {
|
||||||
warn!("UART write failed, response dropped");
|
warn!("UART host inactivity timeout fired");
|
||||||
}
|
let _ = commands.try_send(InboundEvent::Reset);
|
||||||
}
|
if has_display {
|
||||||
|
let _ = display_commands.try_send(DisplayCommand::Reset);
|
||||||
|
}
|
||||||
|
connected = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn dispatch_bytes(
|
||||||
|
decoder: &mut FrameDecoder,
|
||||||
|
bytes: &[u8],
|
||||||
|
commands: &'static CommandChannel,
|
||||||
|
outbound: &'static OutboundChannel,
|
||||||
|
) {
|
||||||
|
let mut work: heapless::Vec<FrameWork, 4> = heapless::Vec::new();
|
||||||
|
decoder.feed(bytes, |res| match res {
|
||||||
|
FrameResult::Ok {
|
||||||
|
type_id,
|
||||||
|
tag,
|
||||||
|
payload,
|
||||||
|
} => {
|
||||||
|
let mut p: heapless::Vec<u8, MAX_PAYLOAD_FIELD> = heapless::Vec::new();
|
||||||
|
let _ = p.extend_from_slice(payload);
|
||||||
|
let _ = work.push(FrameWork::Ok {
|
||||||
|
type_id,
|
||||||
|
tag,
|
||||||
|
payload: p,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
FrameResult::Err(_) => {
|
||||||
|
let _ = work.push(FrameWork::Frame);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
for w in work {
|
||||||
|
match w {
|
||||||
|
FrameWork::Ok {
|
||||||
|
type_id,
|
||||||
|
tag,
|
||||||
|
payload,
|
||||||
|
} => {
|
||||||
|
dispatch_frame(type_id, tag, &payload, commands, outbound).await;
|
||||||
|
}
|
||||||
|
FrameWork::Frame => {
|
||||||
|
emit_async_err(outbound, ErrorCode::EFrame).await;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[allow(clippy::large_enum_variant)]
|
||||||
|
enum FrameWork {
|
||||||
|
Ok {
|
||||||
|
type_id: u8,
|
||||||
|
tag: u16,
|
||||||
|
payload: heapless::Vec<u8, MAX_PAYLOAD_FIELD>,
|
||||||
|
},
|
||||||
|
Frame,
|
||||||
|
}
|
||||||
+218
-48
@@ -1,22 +1,117 @@
|
|||||||
//! USB CDC-ACM host task: COBS-framed fixed-size LE command/response protocol.
|
//! USB CDC-ACM host task for DongLoRa Protocol v2.
|
||||||
|
//!
|
||||||
|
//! ```text
|
||||||
|
//! Host
|
||||||
|
//! ├── Disconnected (default) no DTR, or inactivity timer fired
|
||||||
|
//! └── Connected host attached, liveness OK
|
||||||
|
//! entry: wake_display, start_liveness_timer
|
||||||
|
//! exit: reset_radio, reset_display, stop_liveness_timer
|
||||||
|
//! ```
|
||||||
|
//!
|
||||||
|
//! The statechart tracks one state bit: "is a host present?". DTR drives
|
||||||
|
//! connect edges; the 1-second inactivity watchdog (spec §3.4) drives
|
||||||
|
//! the disconnect edge when DTR is held high but no bytes arrive.
|
||||||
|
//!
|
||||||
|
//! The data plane (frame decode, command dispatch, frame encode) stays
|
||||||
|
//! imperative in `protocol_loop` for the same reason the radio's
|
||||||
|
//! `rx_once` stayed out of its chart: `read_packet` blocks.
|
||||||
|
|
||||||
use defmt::warn;
|
use defmt::warn;
|
||||||
use embassy_executor::task;
|
use embassy_executor::task;
|
||||||
use embassy_futures::join::join;
|
use embassy_futures::join::join;
|
||||||
|
use embassy_time::{Duration, Instant};
|
||||||
use embassy_usb::class::cdc_acm::{CdcAcmClass, Receiver, Sender, State};
|
use embassy_usb::class::cdc_acm::{CdcAcmClass, Receiver, Sender, State};
|
||||||
use embassy_usb::Builder;
|
use embassy_usb::Builder;
|
||||||
|
use hsmc::{statechart, Duration as HsmcDuration};
|
||||||
use static_cell::StaticCell;
|
use static_cell::StaticCell;
|
||||||
|
|
||||||
use crate::channel::{CommandChannel, DisplayCommand, DisplayCommandChannel, ResponseChannel};
|
use super::framing::{dispatch_frame, emit_async_err, encode_outbound};
|
||||||
use super::framing::{self, CobsDecoder, MAX_FRAME};
|
use crate::channel::{
|
||||||
|
CommandChannel, DisplayCommand, DisplayCommandChannel, InboundEvent, OutboundChannel,
|
||||||
|
};
|
||||||
|
use crate::protocol::{ErrorCode, FrameDecoder, FrameResult, MAX_WIRE_FRAME};
|
||||||
|
|
||||||
const MAX_PACKET: usize = 64;
|
const MAX_USB_PACKET: usize = 64;
|
||||||
|
|
||||||
|
/// Spec §3.4: inactivity timer period. The device must see at least one
|
||||||
|
/// frame (good or bad) every 1000 ms or the session is considered lost.
|
||||||
|
const INACTIVITY_MS: u64 = 1000;
|
||||||
|
|
||||||
|
/// Poll interval for the DTR edge detector / inactivity check.
|
||||||
|
const POLL_INTERVAL_MS: u64 = 100;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub enum HostInput {
|
||||||
|
DtrRaised,
|
||||||
|
DtrDropped,
|
||||||
|
/// Host went silent for >1 s; kick the radio back to Unconfigured.
|
||||||
|
InactivityTimeout,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct HostContext {
|
||||||
|
commands: &'static CommandChannel,
|
||||||
|
display_commands: &'static DisplayCommandChannel,
|
||||||
|
has_display: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl HostContext {
|
||||||
|
async fn wake_display_if_present(&self) {
|
||||||
|
if self.has_display {
|
||||||
|
self.display_commands.send(DisplayCommand::On).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn reset_display_if_present(&self) {
|
||||||
|
if self.has_display {
|
||||||
|
self.display_commands.send(DisplayCommand::Reset).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn send_radio_reset(&self) {
|
||||||
|
if self.commands.try_send(InboundEvent::Reset).is_err() {
|
||||||
|
warn!("radio Reset inject: command queue full");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
statechart! {
|
||||||
|
Host {
|
||||||
|
context: HostContext;
|
||||||
|
events: HostInput;
|
||||||
|
default(Disconnected);
|
||||||
|
|
||||||
|
state Disconnected {
|
||||||
|
on(DtrRaised) => Connected;
|
||||||
|
}
|
||||||
|
|
||||||
|
state Connected {
|
||||||
|
entry: wake_display;
|
||||||
|
exit: reset_radio, reset_display;
|
||||||
|
on(DtrDropped) => Disconnected;
|
||||||
|
on(InactivityTimeout) => Disconnected;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl HostActions for HostActionContext<'_> {
|
||||||
|
async fn wake_display(&mut self) {
|
||||||
|
self.wake_display_if_present().await;
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn reset_radio(&mut self) {
|
||||||
|
self.send_radio_reset();
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn reset_display(&mut self) {
|
||||||
|
self.reset_display_if_present().await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[task]
|
#[task]
|
||||||
pub async fn host_task(
|
pub async fn host_task(
|
||||||
parts: crate::board::UsbParts,
|
parts: crate::board::UsbParts,
|
||||||
commands: &'static CommandChannel,
|
commands: &'static CommandChannel,
|
||||||
responses: &'static ResponseChannel,
|
outbound: &'static OutboundChannel,
|
||||||
display_commands: &'static DisplayCommandChannel,
|
display_commands: &'static DisplayCommandChannel,
|
||||||
has_display: bool,
|
has_display: bool,
|
||||||
mac: [u8; 6],
|
mac: [u8; 6],
|
||||||
@@ -52,13 +147,20 @@ pub async fn host_task(
|
|||||||
|
|
||||||
let mut builder = Builder::new(parts.driver, config, desc_buf, conf_buf, bos_buf, ctrl_buf);
|
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 class = CdcAcmClass::new(&mut builder, cdc_state, MAX_USB_PACKET as u16);
|
||||||
let mut usb_dev = builder.build();
|
let mut usb_dev = builder.build();
|
||||||
let (sender, receiver) = class.split();
|
let (sender, receiver) = class.split();
|
||||||
|
|
||||||
join(
|
join(
|
||||||
usb_dev.run(),
|
usb_dev.run(),
|
||||||
protocol_loop(sender, receiver, commands, responses, display_commands, has_display, mac),
|
protocol_loop(
|
||||||
|
sender,
|
||||||
|
receiver,
|
||||||
|
commands,
|
||||||
|
outbound,
|
||||||
|
display_commands,
|
||||||
|
has_display,
|
||||||
|
),
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
}
|
}
|
||||||
@@ -67,56 +169,47 @@ async fn protocol_loop<'d, D: embassy_usb_driver::Driver<'d>>(
|
|||||||
mut sender: Sender<'d, D>,
|
mut sender: Sender<'d, D>,
|
||||||
mut receiver: Receiver<'d, D>,
|
mut receiver: Receiver<'d, D>,
|
||||||
commands: &'static CommandChannel,
|
commands: &'static CommandChannel,
|
||||||
responses: &'static ResponseChannel,
|
outbound: &'static OutboundChannel,
|
||||||
display_commands: &'static DisplayCommandChannel,
|
display_commands: &'static DisplayCommandChannel,
|
||||||
has_display: bool,
|
has_display: bool,
|
||||||
mac: [u8; 6],
|
|
||||||
) {
|
) {
|
||||||
use embassy_futures::select::{select3, Either3};
|
use embassy_futures::select::{select3, Either3};
|
||||||
|
|
||||||
let mut read_buf = [0u8; MAX_PACKET];
|
let mut machine = Host::new_local(HostContext {
|
||||||
let mut write_buf = [0u8; MAX_FRAME];
|
commands,
|
||||||
let mut cobs_encode_buf = [0u8; MAX_FRAME];
|
display_commands,
|
||||||
let mut decoder = CobsDecoder::new();
|
has_display,
|
||||||
let mut was_connected = false;
|
});
|
||||||
|
machine.step(HsmcDuration::ZERO).await;
|
||||||
|
|
||||||
|
let mut read_buf = [0u8; MAX_USB_PACKET];
|
||||||
|
let mut write_buf: [u8; MAX_WIRE_FRAME] = [0u8; MAX_WIRE_FRAME];
|
||||||
|
let mut decoder = FrameDecoder::new();
|
||||||
|
|
||||||
|
let mut last_frame_at = Instant::now();
|
||||||
|
|
||||||
loop {
|
loop {
|
||||||
match select3(
|
match select3(
|
||||||
receiver.read_packet(&mut read_buf),
|
receiver.read_packet(&mut read_buf),
|
||||||
responses.receive(),
|
outbound.receive(),
|
||||||
embassy_time::Timer::after_millis(250),
|
embassy_time::Timer::after(Duration::from_millis(POLL_INTERVAL_MS)),
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
Either3::First(result) => {
|
Either3::First(Ok(0)) => {}
|
||||||
let n = match result {
|
Either3::First(Ok(n)) => {
|
||||||
Ok(0) => continue,
|
last_frame_at = Instant::now();
|
||||||
Ok(n) => n,
|
dispatch_bytes(&mut decoder, &read_buf[..n], commands, outbound).await;
|
||||||
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) => {
|
Either3::First(Err(_)) => {
|
||||||
if let Some(frame) =
|
decoder.reset();
|
||||||
framing::cobs_encode_response(response, &mut write_buf, &mut cobs_encode_buf)
|
embassy_time::Timer::after_millis(100).await;
|
||||||
{
|
}
|
||||||
for chunk in frame.chunks(MAX_PACKET) {
|
Either3::Second(frame) => {
|
||||||
|
if let Some(n) = encode_outbound(&frame, &mut write_buf) {
|
||||||
|
for chunk in write_buf[..n].chunks(MAX_USB_PACKET) {
|
||||||
if sender.write_packet(chunk).await.is_err() {
|
if sender.write_packet(chunk).await.is_err() {
|
||||||
warn!("USB write failed, response dropped");
|
warn!("USB write failed, frame dropped");
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -125,13 +218,90 @@ async fn protocol_loop<'d, D: embassy_usb_driver::Driver<'d>>(
|
|||||||
Either3::Third(()) => {}
|
Either3::Third(()) => {}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// DTR edge detection — drives Connected / Disconnected transitions.
|
||||||
let connected = receiver.dtr();
|
let connected = receiver.dtr();
|
||||||
if was_connected && !connected && has_display {
|
let was_connected = matches!(machine.current_state(), HostState::Connected);
|
||||||
display_commands.send(DisplayCommand::Reset).await;
|
match (was_connected, connected) {
|
||||||
|
(false, true) => {
|
||||||
|
let _ = machine.dispatch(HostInput::DtrRaised).await;
|
||||||
|
last_frame_at = Instant::now();
|
||||||
|
}
|
||||||
|
(true, false) => {
|
||||||
|
decoder.reset();
|
||||||
|
let _ = machine.dispatch(HostInput::DtrDropped).await;
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
}
|
}
|
||||||
if !was_connected && connected && has_display {
|
|
||||||
display_commands.send(DisplayCommand::On).await;
|
// Inactivity watchdog (spec §3.4): while Connected, if no frame
|
||||||
|
// arrived in the last INACTIVITY_MS, drive the session back to
|
||||||
|
// Disconnected.
|
||||||
|
if matches!(machine.current_state(), HostState::Connected)
|
||||||
|
&& last_frame_at.elapsed() >= Duration::from_millis(INACTIVITY_MS)
|
||||||
|
{
|
||||||
|
warn!("host inactivity timeout fired");
|
||||||
|
let _ = machine.dispatch(HostInput::InactivityTimeout).await;
|
||||||
}
|
}
|
||||||
was_connected = connected;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Feed raw bytes into the frame decoder. For each complete frame:
|
||||||
|
/// - Ok → route into the command channel (or emit sync ERR on parse fail)
|
||||||
|
/// - Err(Crc | Cobs | TooShort) → emit async ERR(EFRAME)
|
||||||
|
///
|
||||||
|
/// Borrow-scoped per the `FrameDecoder::feed` callback contract: the
|
||||||
|
/// payload slice is valid only for the duration of the callback.
|
||||||
|
async fn dispatch_bytes(
|
||||||
|
decoder: &mut FrameDecoder,
|
||||||
|
bytes: &[u8],
|
||||||
|
commands: &'static CommandChannel,
|
||||||
|
outbound: &'static OutboundChannel,
|
||||||
|
) {
|
||||||
|
// `FrameDecoder::feed` hands payloads by reference; we must synchronously
|
||||||
|
// copy any data we need before the callback returns. To avoid fighting
|
||||||
|
// lifetimes we accumulate per-frame work as an enum.
|
||||||
|
let mut work: heapless::Vec<FrameWork, 4> = heapless::Vec::new();
|
||||||
|
decoder.feed(bytes, |res| match res {
|
||||||
|
FrameResult::Ok {
|
||||||
|
type_id,
|
||||||
|
tag,
|
||||||
|
payload,
|
||||||
|
} => {
|
||||||
|
let mut p: heapless::Vec<u8, { crate::protocol::MAX_PAYLOAD_FIELD }> =
|
||||||
|
heapless::Vec::new();
|
||||||
|
let _ = p.extend_from_slice(payload);
|
||||||
|
let _ = work.push(FrameWork::Ok {
|
||||||
|
type_id,
|
||||||
|
tag,
|
||||||
|
payload: p,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
FrameResult::Err(_e) => {
|
||||||
|
let _ = work.push(FrameWork::Frame);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
for w in work {
|
||||||
|
match w {
|
||||||
|
FrameWork::Ok {
|
||||||
|
type_id,
|
||||||
|
tag,
|
||||||
|
payload,
|
||||||
|
} => {
|
||||||
|
dispatch_frame(type_id, tag, &payload, commands, outbound).await;
|
||||||
|
}
|
||||||
|
FrameWork::Frame => {
|
||||||
|
emit_async_err(outbound, ErrorCode::EFrame).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[allow(clippy::large_enum_variant)]
|
||||||
|
enum FrameWork {
|
||||||
|
Ok {
|
||||||
|
type_id: u8,
|
||||||
|
tag: u16,
|
||||||
|
payload: heapless::Vec<u8, { crate::protocol::MAX_PAYLOAD_FIELD }>,
|
||||||
|
},
|
||||||
|
Frame,
|
||||||
|
}
|
||||||
+112
-18
@@ -19,6 +19,8 @@
|
|||||||
mod board;
|
mod board;
|
||||||
#[cfg(not(test))]
|
#[cfg(not(test))]
|
||||||
mod channel;
|
mod channel;
|
||||||
|
#[cfg(all(not(test), feature = "debug-checkpoint"))]
|
||||||
|
mod debug_blink;
|
||||||
#[cfg(not(test))]
|
#[cfg(not(test))]
|
||||||
mod display;
|
mod display;
|
||||||
#[cfg(not(test))]
|
#[cfg(not(test))]
|
||||||
@@ -37,14 +39,16 @@ use embassy_executor::Spawner;
|
|||||||
#[cfg(not(test))]
|
#[cfg(not(test))]
|
||||||
use crate::board::LoRaBoard;
|
use crate::board::LoRaBoard;
|
||||||
#[cfg(not(test))]
|
#[cfg(not(test))]
|
||||||
use crate::channel::{CommandChannel, DisplayCommandChannel, ResponseChannel, StatusWatch};
|
use crate::channel::{CommandChannel, DisplayCommandChannel, OutboundChannel, RadioEventChannel};
|
||||||
|
#[cfg(not(test))]
|
||||||
|
use crate::protocol::{cap, Info, MAX_MCU_UID_LEN, MAX_RADIO_UID_LEN};
|
||||||
|
|
||||||
#[cfg(not(test))]
|
#[cfg(not(test))]
|
||||||
cfg_if::cfg_if! {
|
cfg_if::cfg_if! {
|
||||||
if #[cfg(any(feature = "rak_wisblock_4631", feature = "wio_tracker_l1"))] {
|
if #[cfg(any(feature = "rak_wisblock_4631", feature = "wio_tracker_l1", feature = "waveshare_rp2040_lora"))] {
|
||||||
use defmt_rtt as _;
|
use defmt_rtt as _;
|
||||||
use panic_probe as _;
|
use panic_probe as _;
|
||||||
} else if #[cfg(any(feature = "heltec_v3", feature = "heltec_v3_uart", feature = "heltec_v4"))] {
|
} else if #[cfg(any(feature = "heltec_v3", feature = "heltec_v3_uart", feature = "heltec_v4", feature = "elecrow_thinknode_m2"))] {
|
||||||
use esp_backtrace as _;
|
use esp_backtrace as _;
|
||||||
use esp_println as _;
|
use esp_println as _;
|
||||||
}
|
}
|
||||||
@@ -53,20 +57,22 @@ cfg_if::cfg_if! {
|
|||||||
#[cfg(not(test))]
|
#[cfg(not(test))]
|
||||||
static COMMANDS: CommandChannel = CommandChannel::new();
|
static COMMANDS: CommandChannel = CommandChannel::new();
|
||||||
#[cfg(not(test))]
|
#[cfg(not(test))]
|
||||||
static RESPONSES: ResponseChannel = ResponseChannel::new();
|
static OUTBOUND: OutboundChannel = OutboundChannel::new();
|
||||||
#[cfg(not(test))]
|
#[cfg(not(test))]
|
||||||
static STATUS: StatusWatch = StatusWatch::new();
|
static RADIO_EVENTS: RadioEventChannel = RadioEventChannel::new();
|
||||||
#[cfg(not(test))]
|
#[cfg(not(test))]
|
||||||
static DISPLAY_COMMANDS: DisplayCommandChannel = DisplayCommandChannel::new();
|
static DISPLAY_COMMANDS: DisplayCommandChannel = DisplayCommandChannel::new();
|
||||||
|
#[cfg(not(test))]
|
||||||
|
static mut INFO: Option<Info> = None;
|
||||||
|
|
||||||
#[cfg(not(test))]
|
#[cfg(not(test))]
|
||||||
cfg_if::cfg_if! {
|
cfg_if::cfg_if! {
|
||||||
if #[cfg(any(feature = "rak_wisblock_4631", feature = "wio_tracker_l1"))] {
|
if #[cfg(any(feature = "rak_wisblock_4631", feature = "wio_tracker_l1", feature = "waveshare_rp2040_lora"))] {
|
||||||
#[embassy_executor::main]
|
#[embassy_executor::main]
|
||||||
async fn main(spawner: Spawner) {
|
async fn main(spawner: Spawner) {
|
||||||
run(spawner).await;
|
run(spawner).await;
|
||||||
}
|
}
|
||||||
} else if #[cfg(any(feature = "heltec_v3", feature = "heltec_v3_uart", feature = "heltec_v4"))] {
|
} else if #[cfg(any(feature = "heltec_v3", feature = "heltec_v3_uart", feature = "heltec_v4", feature = "elecrow_thinknode_m2"))] {
|
||||||
#[esp_rtos::main]
|
#[esp_rtos::main]
|
||||||
async fn main(spawner: Spawner) {
|
async fn main(spawner: Spawner) {
|
||||||
run(spawner).await;
|
run(spawner).await;
|
||||||
@@ -79,24 +85,112 @@ async fn run(spawner: Spawner) {
|
|||||||
let board = <board::Board as LoRaBoard>::init();
|
let board = <board::Board as LoRaBoard>::init();
|
||||||
let parts = board.into_parts();
|
let parts = board.into_parts();
|
||||||
|
|
||||||
spawner
|
// Build the GET_INFO struct once at boot. This is the authoritative
|
||||||
.spawn(radio::radio_task(parts.radio, &COMMANDS, &RESPONSES, &STATUS))
|
// snapshot of device capabilities reported over the wire.
|
||||||
.expect("spawn radio_task");
|
let info = build_info(parts.mac);
|
||||||
|
// SAFETY: touched only here at boot, before any task is spawned.
|
||||||
|
let info_ref: &'static Info = unsafe {
|
||||||
|
INFO = Some(info);
|
||||||
|
#[allow(static_mut_refs)]
|
||||||
|
INFO.as_ref().expect("INFO just initialized")
|
||||||
|
};
|
||||||
|
|
||||||
spawner
|
spawner.spawn(
|
||||||
.spawn(host::host_task(
|
radio::radio_task(parts.radio, &COMMANDS, &OUTBOUND, &RADIO_EVENTS, info_ref)
|
||||||
|
.expect("spawn radio_task"),
|
||||||
|
);
|
||||||
|
|
||||||
|
spawner.spawn(
|
||||||
|
host::host_task(
|
||||||
parts.host,
|
parts.host,
|
||||||
&COMMANDS,
|
&COMMANDS,
|
||||||
&RESPONSES,
|
&OUTBOUND,
|
||||||
&DISPLAY_COMMANDS,
|
&DISPLAY_COMMANDS,
|
||||||
parts.display.is_some(),
|
parts.display.is_some(),
|
||||||
parts.mac,
|
parts.mac,
|
||||||
))
|
)
|
||||||
.expect("spawn host_task");
|
.expect("spawn host_task"),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Suppress "assigned but never read" warning on non-USB/UART path.
|
||||||
|
let _ = info_ref;
|
||||||
|
|
||||||
if let Some(dp) = parts.display {
|
if let Some(dp) = parts.display {
|
||||||
spawner
|
#[cfg(feature = "debug-checkpoint")]
|
||||||
.spawn(display::display_task(dp, parts.led, &STATUS, &DISPLAY_COMMANDS))
|
{
|
||||||
.expect("spawn display_task");
|
// LED is taken over by the checkpoint blinker; the display
|
||||||
|
// init struct is dropped unused for this debug build.
|
||||||
|
let _ = dp;
|
||||||
|
spawner
|
||||||
|
.spawn(debug_blink::debug_blink_task(parts.led).expect("spawn debug_blink_task"));
|
||||||
|
}
|
||||||
|
#[cfg(not(feature = "debug-checkpoint"))]
|
||||||
|
{
|
||||||
|
#[allow(clippy::unit_arg)] // LedDriver is () for boards without LEDs
|
||||||
|
spawner.spawn(
|
||||||
|
display::display_task(dp, parts.led, &RADIO_EVENTS, &DISPLAY_COMMANDS)
|
||||||
|
.expect("spawn display_task"),
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(not(test))]
|
||||||
|
fn build_info(mac: [u8; 6]) -> Info {
|
||||||
|
let (fmin, fmax) = <board::Board as LoRaBoard>::freq_range_hz();
|
||||||
|
let chip_id = <board::Board as LoRaBoard>::radio_chip_id();
|
||||||
|
let (tx_min, tx_max) = board::Board::TX_POWER_RANGE;
|
||||||
|
|
||||||
|
let mut mcu_uid = [0u8; MAX_MCU_UID_LEN];
|
||||||
|
mcu_uid[..6].copy_from_slice(&mac);
|
||||||
|
|
||||||
|
Info {
|
||||||
|
proto_major: 1,
|
||||||
|
proto_minor: 0,
|
||||||
|
fw_major: parse_u8_or(env!("CARGO_PKG_VERSION_MAJOR"), 0),
|
||||||
|
fw_minor: parse_u8_or(env!("CARGO_PKG_VERSION_MINOR"), 0),
|
||||||
|
fw_patch: parse_u8_or(env!("CARGO_PKG_VERSION_PATCH"), 0),
|
||||||
|
radio_chip_id: chip_id,
|
||||||
|
capability_bitmap: cap::LORA | cap::CAD_BEFORE_TX,
|
||||||
|
// SX126x supports SF5–SF12 (LLCC68 tops out at SF11 — that board
|
||||||
|
// can override this via a board-level trait method later).
|
||||||
|
supported_sf_bitmap: 0x1FE0,
|
||||||
|
// Sub-GHz BW enum values 0..9. SX128x would flip 10..13; add as
|
||||||
|
// boards come online.
|
||||||
|
supported_bw_bitmap: 0x03FF,
|
||||||
|
max_payload_bytes: crate::protocol::MAX_OTA_PAYLOAD as u16,
|
||||||
|
// Real channel depths from channel.rs.
|
||||||
|
rx_queue_capacity: 32,
|
||||||
|
tx_queue_capacity: 1,
|
||||||
|
freq_min_hz: fmin,
|
||||||
|
freq_max_hz: fmax,
|
||||||
|
tx_power_min_dbm: tx_min,
|
||||||
|
tx_power_max_dbm: tx_max,
|
||||||
|
mcu_uid_len: 6,
|
||||||
|
mcu_uid,
|
||||||
|
radio_uid_len: 0,
|
||||||
|
radio_uid: [0u8; MAX_RADIO_UID_LEN],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(test))]
|
||||||
|
const fn parse_u8_or(s: &str, default: u8) -> u8 {
|
||||||
|
// CARGO_PKG_VERSION_* strings are short decimal ascii. Parse them at
|
||||||
|
// compile time via a tiny constant function instead of pulling in a
|
||||||
|
// dependency.
|
||||||
|
let bytes = s.as_bytes();
|
||||||
|
let mut i = 0;
|
||||||
|
let mut out: u32 = 0;
|
||||||
|
while i < bytes.len() {
|
||||||
|
let b = bytes[i];
|
||||||
|
if b < b'0' || b > b'9' {
|
||||||
|
return default;
|
||||||
|
}
|
||||||
|
out = out * 10 + (b - b'0') as u32;
|
||||||
|
if out > u8::MAX as u32 {
|
||||||
|
return default;
|
||||||
|
}
|
||||||
|
i += 1;
|
||||||
|
}
|
||||||
|
out as u8
|
||||||
|
}
|
||||||
+15
-706
@@ -1,707 +1,16 @@
|
|||||||
//! Wire protocol types and fixed-size little-endian serialization.
|
//! Wire protocol types for DongLoRa Protocol v2.
|
||||||
//!
|
//!
|
||||||
//! Every integer is fixed-width LE. No varints, no zigzag.
|
//! Wire definitions live in the `donglora-protocol` crate so host-side
|
||||||
//! See `docs/PROTOCOL.md` for the complete specification.
|
//! Rust projects can share them with the firmware without duplicating
|
||||||
|
//! wire layouts. This module re-exports them unchanged so firmware
|
||||||
use heapless::Vec;
|
//! modules can `use crate::protocol::*;`.
|
||||||
|
|
||||||
/// Maximum LoRa payload size in bytes.
|
#[allow(unused_imports)]
|
||||||
pub const MAX_PAYLOAD: usize = 256;
|
pub use donglora_protocol::{
|
||||||
|
cap, commands, encode_frame, events, Command, CommandParseError, DeviceMessage,
|
||||||
/// RadioConfig wire size (fixed).
|
DeviceMessageParseError, ErrorCode, FlrcConfig, FrameDecoder, FrameEncodeError, FrameResult,
|
||||||
pub const RADIO_CONFIG_SIZE: usize = 13;
|
FskConfig, Info, InfoParseError, LoRaBandwidth, LoRaCodingRate, LoRaConfig, LoRaHeaderMode,
|
||||||
|
LrFhssConfig, Modulation, ModulationId, OkPayload, Owner, RadioChipId, RxOrigin, RxPayload,
|
||||||
/// Sentinel value for `tx_power_dbm`: use the board's maximum TX power.
|
SetConfigResult, SetConfigResultCode, TxDonePayload, TxFlags, TxResult, MAX_MCU_UID_LEN,
|
||||||
pub const TX_POWER_MAX: i8 = i8::MIN; // -128 on the wire
|
MAX_OK_PAYLOAD, MAX_OTA_PAYLOAD, MAX_PAYLOAD_FIELD, MAX_RADIO_UID_LEN, MAX_WIRE_FRAME,
|
||||||
|
};
|
||||||
/// Sentinel value for `preamble_len`: use the firmware default (16 symbols).
|
|
||||||
pub const PREAMBLE_DEFAULT: u16 = 0;
|
|
||||||
|
|
||||||
/// LoRa signal bandwidth.
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
||||||
#[cfg_attr(not(test), derive(defmt::Format))]
|
|
||||||
#[repr(u8)]
|
|
||||||
pub enum Bandwidth {
|
|
||||||
Khz7 = 0,
|
|
||||||
Khz10 = 1,
|
|
||||||
Khz15 = 2,
|
|
||||||
Khz20 = 3,
|
|
||||||
Khz31 = 4,
|
|
||||||
Khz41 = 5,
|
|
||||||
Khz62 = 6,
|
|
||||||
Khz125 = 7,
|
|
||||||
Khz250 = 8,
|
|
||||||
Khz500 = 9,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Bandwidth {
|
|
||||||
fn from_u8(v: u8) -> Option<Self> {
|
|
||||||
match v {
|
|
||||||
0 => Some(Self::Khz7),
|
|
||||||
1 => Some(Self::Khz10),
|
|
||||||
2 => Some(Self::Khz15),
|
|
||||||
3 => Some(Self::Khz20),
|
|
||||||
4 => Some(Self::Khz31),
|
|
||||||
5 => Some(Self::Khz41),
|
|
||||||
6 => Some(Self::Khz62),
|
|
||||||
7 => Some(Self::Khz125),
|
|
||||||
8 => Some(Self::Khz250),
|
|
||||||
9 => Some(Self::Khz500),
|
|
||||||
_ => None,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Complete LoRa radio configuration.
|
|
||||||
///
|
|
||||||
/// Wire layout (13 bytes, all little-endian):
|
|
||||||
/// ```text
|
|
||||||
/// [freq_hz:4] [bw:1] [sf:1] [cr:1] [sync_word:2] [tx_power_dbm:1] [preamble_len:2] [cad:1]
|
|
||||||
/// ```
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
||||||
#[cfg_attr(not(test), derive(defmt::Format))]
|
|
||||||
pub struct RadioConfig {
|
|
||||||
/// Frequency in Hz (150_000_000 - 960_000_000 for SX1262).
|
|
||||||
pub freq_hz: u32,
|
|
||||||
pub bw: Bandwidth,
|
|
||||||
/// Spreading factor (5-12).
|
|
||||||
pub sf: u8,
|
|
||||||
/// Coding rate denominator (5-8). E.g. 5 = CR 4/5, 8 = CR 4/8.
|
|
||||||
pub cr: u8,
|
|
||||||
pub sync_word: u16,
|
|
||||||
/// Transmit power in dBm. Set to [`TX_POWER_MAX`] (-128) for the
|
|
||||||
/// board's maximum.
|
|
||||||
pub tx_power_dbm: i8,
|
|
||||||
/// Preamble length in symbols. Set to [`PREAMBLE_DEFAULT`] (0) for
|
|
||||||
/// the firmware default (16 symbols). Valid range: 6–65535.
|
|
||||||
pub preamble_len: u16,
|
|
||||||
/// Channel Activity Detection (listen-before-talk) before TX.
|
|
||||||
/// 0 = disabled, non-zero = enabled. Default: 1 (enabled).
|
|
||||||
pub cad: u8,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl RadioConfig {
|
|
||||||
/// Validate fields against hardware limits.
|
|
||||||
pub fn validate(&self, power_range: (i8, i8)) -> Result<(), &'static str> {
|
|
||||||
if !(150_000_000..=960_000_000).contains(&self.freq_hz) {
|
|
||||||
return Err("frequency out of range (150-960 MHz)");
|
|
||||||
}
|
|
||||||
if !(5..=12).contains(&self.sf) {
|
|
||||||
return Err("spreading factor out of range (5-12)");
|
|
||||||
}
|
|
||||||
if !(5..=8).contains(&self.cr) {
|
|
||||||
return Err("coding rate out of range (5-8)");
|
|
||||||
}
|
|
||||||
if self.tx_power_dbm != TX_POWER_MAX
|
|
||||||
&& !(power_range.0..=power_range.1).contains(&self.tx_power_dbm)
|
|
||||||
{
|
|
||||||
return Err("TX power out of range for this board");
|
|
||||||
}
|
|
||||||
if self.preamble_len != PREAMBLE_DEFAULT && self.preamble_len < 6 {
|
|
||||||
return Err("preamble length too short (min 6)");
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Resolve sentinel values to concrete defaults.
|
|
||||||
pub fn resolve(mut self, power_range: (i8, i8)) -> Self {
|
|
||||||
if self.tx_power_dbm == TX_POWER_MAX {
|
|
||||||
self.tx_power_dbm = power_range.1;
|
|
||||||
}
|
|
||||||
if self.preamble_len == PREAMBLE_DEFAULT {
|
|
||||||
self.preamble_len = 16;
|
|
||||||
}
|
|
||||||
self
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Whether CAD (listen-before-talk) is enabled.
|
|
||||||
pub fn cad_enabled(&self) -> bool {
|
|
||||||
self.cad != 0
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Serialize to fixed-size LE bytes. Returns number of bytes written (always 13).
|
|
||||||
pub fn write_to(self, buf: &mut [u8]) -> usize {
|
|
||||||
buf[0..4].copy_from_slice(&self.freq_hz.to_le_bytes());
|
|
||||||
buf[4] = self.bw as u8;
|
|
||||||
buf[5] = self.sf;
|
|
||||||
buf[6] = self.cr;
|
|
||||||
buf[7..9].copy_from_slice(&self.sync_word.to_le_bytes());
|
|
||||||
buf[9] = self.tx_power_dbm as u8;
|
|
||||||
buf[10..12].copy_from_slice(&self.preamble_len.to_le_bytes());
|
|
||||||
buf[12] = self.cad;
|
|
||||||
RADIO_CONFIG_SIZE
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Deserialize from fixed-size LE bytes.
|
|
||||||
pub fn from_bytes(buf: &[u8]) -> Option<Self> {
|
|
||||||
if buf.len() < RADIO_CONFIG_SIZE {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
Some(Self {
|
|
||||||
freq_hz: u32::from_le_bytes(buf[0..4].try_into().ok()?),
|
|
||||||
bw: Bandwidth::from_u8(buf[4])?,
|
|
||||||
sf: buf[5],
|
|
||||||
cr: buf[6],
|
|
||||||
sync_word: u16::from_le_bytes(buf[7..9].try_into().ok()?),
|
|
||||||
tx_power_dbm: buf[9] as i8,
|
|
||||||
preamble_len: u16::from_le_bytes(buf[10..12].try_into().ok()?),
|
|
||||||
cad: buf[12],
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Host → firmware commands.
|
|
||||||
#[allow(clippy::large_enum_variant)]
|
|
||||||
#[derive(Debug, PartialEq)]
|
|
||||||
pub enum Command {
|
|
||||||
Ping,
|
|
||||||
GetConfig,
|
|
||||||
SetConfig(RadioConfig),
|
|
||||||
StartRx,
|
|
||||||
StopRx,
|
|
||||||
Transmit {
|
|
||||||
config: Option<RadioConfig>,
|
|
||||||
payload: Vec<u8, MAX_PAYLOAD>,
|
|
||||||
},
|
|
||||||
DisplayOn,
|
|
||||||
DisplayOff,
|
|
||||||
GetMac,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Command {
|
|
||||||
/// Deserialize from a COBS-decoded frame.
|
|
||||||
pub fn from_bytes(buf: &[u8]) -> Option<Self> {
|
|
||||||
let tag = *buf.first()?;
|
|
||||||
let rest = &buf[1..];
|
|
||||||
match tag {
|
|
||||||
0 => Some(Self::Ping),
|
|
||||||
1 => Some(Self::GetConfig),
|
|
||||||
2 => Some(Self::SetConfig(RadioConfig::from_bytes(rest)?)),
|
|
||||||
3 => Some(Self::StartRx),
|
|
||||||
4 => Some(Self::StopRx),
|
|
||||||
5 => {
|
|
||||||
// Transmit: has_config:u8 + [RadioConfig if 1] + len:u16 LE + payload
|
|
||||||
if rest.is_empty() {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
let (config, pos) = if rest[0] == 0 {
|
|
||||||
(None, 1)
|
|
||||||
} else if rest[0] == 1 && rest.len() > RADIO_CONFIG_SIZE {
|
|
||||||
(
|
|
||||||
Some(RadioConfig::from_bytes(&rest[1..])?),
|
|
||||||
1 + RADIO_CONFIG_SIZE,
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
return None;
|
|
||||||
};
|
|
||||||
if rest.len() < pos + 2 {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
let len = u16::from_le_bytes(rest[pos..pos + 2].try_into().ok()?) as usize;
|
|
||||||
let data_start = pos + 2;
|
|
||||||
if rest.len() < data_start + len {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
let mut payload = Vec::new();
|
|
||||||
let _ = payload.extend_from_slice(&rest[data_start..data_start + len]);
|
|
||||||
Some(Self::Transmit { config, payload })
|
|
||||||
}
|
|
||||||
6 => Some(Self::DisplayOn),
|
|
||||||
7 => Some(Self::DisplayOff),
|
|
||||||
8 => Some(Self::GetMac),
|
|
||||||
_ => None,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Firmware → host responses.
|
|
||||||
#[allow(clippy::large_enum_variant)]
|
|
||||||
#[derive(Debug, PartialEq)]
|
|
||||||
pub enum Response {
|
|
||||||
Pong,
|
|
||||||
Config(RadioConfig),
|
|
||||||
RxPacket {
|
|
||||||
rssi: i16,
|
|
||||||
snr: i16,
|
|
||||||
payload: Vec<u8, MAX_PAYLOAD>,
|
|
||||||
},
|
|
||||||
TxDone,
|
|
||||||
Ok,
|
|
||||||
Error(ErrorCode),
|
|
||||||
MacAddress([u8; 6]),
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Response {
|
|
||||||
/// Serialize to fixed-size LE bytes. Returns number of bytes written.
|
|
||||||
pub fn write_to(self, buf: &mut [u8]) -> usize {
|
|
||||||
match self {
|
|
||||||
Self::Pong => {
|
|
||||||
buf[0] = 0;
|
|
||||||
1
|
|
||||||
}
|
|
||||||
Self::Config(cfg) => {
|
|
||||||
buf[0] = 1;
|
|
||||||
1 + cfg.write_to(&mut buf[1..])
|
|
||||||
}
|
|
||||||
Self::RxPacket { rssi, snr, payload } => {
|
|
||||||
buf[0] = 2;
|
|
||||||
buf[1..3].copy_from_slice(&rssi.to_le_bytes());
|
|
||||||
buf[3..5].copy_from_slice(&snr.to_le_bytes());
|
|
||||||
buf[5..7].copy_from_slice(&(payload.len() as u16).to_le_bytes());
|
|
||||||
buf[7..7 + payload.len()].copy_from_slice(&payload);
|
|
||||||
7 + payload.len()
|
|
||||||
}
|
|
||||||
Self::TxDone => {
|
|
||||||
buf[0] = 3;
|
|
||||||
1
|
|
||||||
}
|
|
||||||
Self::Ok => {
|
|
||||||
buf[0] = 4;
|
|
||||||
1
|
|
||||||
}
|
|
||||||
Self::Error(code) => {
|
|
||||||
buf[0] = 5;
|
|
||||||
buf[1] = code as u8;
|
|
||||||
2
|
|
||||||
}
|
|
||||||
Self::MacAddress(mac) => {
|
|
||||||
buf[0] = 6;
|
|
||||||
buf[1..7].copy_from_slice(&mac);
|
|
||||||
7
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Error codes reported to the host.
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
||||||
#[cfg_attr(not(test), derive(defmt::Format))]
|
|
||||||
#[repr(u8)]
|
|
||||||
pub enum ErrorCode {
|
|
||||||
InvalidConfig = 0,
|
|
||||||
RadioBusy = 1,
|
|
||||||
TxTimeout = 2,
|
|
||||||
// 3 reserved (was CrcError — SX1262 silently drops bad-CRC packets)
|
|
||||||
NotConfigured = 4,
|
|
||||||
NoDisplay = 5,
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Tests ───────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
fn make_config() -> RadioConfig {
|
|
||||||
RadioConfig {
|
|
||||||
freq_hz: 915_000_000,
|
|
||||||
bw: Bandwidth::Khz125,
|
|
||||||
sf: 7,
|
|
||||||
cr: 5,
|
|
||||||
sync_word: 0x3444,
|
|
||||||
tx_power_dbm: 22,
|
|
||||||
preamble_len: 16,
|
|
||||||
cad: 1,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── RadioConfig roundtrip ───────────────────────────────────────
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn radio_config_roundtrip() {
|
|
||||||
let cfg = make_config();
|
|
||||||
let mut buf = [0u8; RADIO_CONFIG_SIZE];
|
|
||||||
let n = cfg.write_to(&mut buf);
|
|
||||||
assert_eq!(n, RADIO_CONFIG_SIZE);
|
|
||||||
assert_eq!(RadioConfig::from_bytes(&buf), Some(cfg));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn radio_config_roundtrip_all_bandwidths() {
|
|
||||||
for bw_val in 0u8..=9 {
|
|
||||||
let bw = Bandwidth::from_u8(bw_val).unwrap();
|
|
||||||
let cfg = RadioConfig {
|
|
||||||
freq_hz: 433_000_000,
|
|
||||||
bw,
|
|
||||||
sf: 12,
|
|
||||||
cr: 8,
|
|
||||||
sync_word: 0x1234,
|
|
||||||
tx_power_dbm: -9,
|
|
||||||
preamble_len: 16,
|
|
||||||
cad: 1,
|
|
||||||
};
|
|
||||||
let mut buf = [0u8; RADIO_CONFIG_SIZE];
|
|
||||||
cfg.write_to(&mut buf);
|
|
||||||
assert_eq!(RadioConfig::from_bytes(&buf), Some(cfg));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn radio_config_roundtrip_negative_power() {
|
|
||||||
let cfg = RadioConfig {
|
|
||||||
tx_power_dbm: TX_POWER_MAX,
|
|
||||||
..make_config()
|
|
||||||
};
|
|
||||||
let mut buf = [0u8; RADIO_CONFIG_SIZE];
|
|
||||||
cfg.write_to(&mut buf);
|
|
||||||
assert_eq!(RadioConfig::from_bytes(&buf), Some(cfg));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn radio_config_from_short_buffer() {
|
|
||||||
let buf = [0u8; RADIO_CONFIG_SIZE - 1];
|
|
||||||
assert!(RadioConfig::from_bytes(&buf).is_none());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn radio_config_from_empty_buffer() {
|
|
||||||
assert!(RadioConfig::from_bytes(&[]).is_none());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn radio_config_invalid_bandwidth() {
|
|
||||||
let mut buf = [0u8; RADIO_CONFIG_SIZE];
|
|
||||||
make_config().write_to(&mut buf);
|
|
||||||
buf[4] = 255; // invalid bandwidth
|
|
||||||
assert!(RadioConfig::from_bytes(&buf).is_none());
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── RadioConfig::validate ───────────────────────────────────────
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn validate_freq_boundaries() {
|
|
||||||
let power_range = (-9, 22);
|
|
||||||
let base = make_config();
|
|
||||||
|
|
||||||
let mut cfg = RadioConfig {
|
|
||||||
freq_hz: 150_000_000,
|
|
||||||
..base
|
|
||||||
};
|
|
||||||
assert!(cfg.validate(power_range).is_ok());
|
|
||||||
|
|
||||||
cfg.freq_hz = 960_000_000;
|
|
||||||
assert!(cfg.validate(power_range).is_ok());
|
|
||||||
|
|
||||||
cfg.freq_hz = 149_999_999;
|
|
||||||
assert!(cfg.validate(power_range).is_err());
|
|
||||||
|
|
||||||
cfg.freq_hz = 960_000_001;
|
|
||||||
assert!(cfg.validate(power_range).is_err());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn validate_sf_boundaries() {
|
|
||||||
let power_range = (-9, 22);
|
|
||||||
let base = make_config();
|
|
||||||
|
|
||||||
for sf in 5..=12 {
|
|
||||||
assert!(RadioConfig { sf, ..base }.validate(power_range).is_ok());
|
|
||||||
}
|
|
||||||
assert!(RadioConfig { sf: 4, ..base }.validate(power_range).is_err());
|
|
||||||
assert!(RadioConfig { sf: 13, ..base }
|
|
||||||
.validate(power_range)
|
|
||||||
.is_err());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn validate_cr_boundaries() {
|
|
||||||
let power_range = (-9, 22);
|
|
||||||
let base = make_config();
|
|
||||||
|
|
||||||
for cr in 5..=8 {
|
|
||||||
assert!(RadioConfig { cr, ..base }.validate(power_range).is_ok());
|
|
||||||
}
|
|
||||||
assert!(RadioConfig { cr: 4, ..base }.validate(power_range).is_err());
|
|
||||||
assert!(RadioConfig { cr: 9, ..base }.validate(power_range).is_err());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn validate_tx_power_max_sentinel() {
|
|
||||||
let cfg = RadioConfig {
|
|
||||||
tx_power_dbm: TX_POWER_MAX,
|
|
||||||
..make_config()
|
|
||||||
};
|
|
||||||
assert!(cfg.validate((-9, 22)).is_ok());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn validate_tx_power_out_of_range() {
|
|
||||||
let cfg = RadioConfig {
|
|
||||||
tx_power_dbm: 23,
|
|
||||||
..make_config()
|
|
||||||
};
|
|
||||||
assert!(cfg.validate((-9, 22)).is_err());
|
|
||||||
|
|
||||||
let cfg = RadioConfig {
|
|
||||||
tx_power_dbm: -10,
|
|
||||||
..make_config()
|
|
||||||
};
|
|
||||||
assert!(cfg.validate((-9, 22)).is_err());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn validate_preamble_default_sentinel() {
|
|
||||||
let cfg = RadioConfig {
|
|
||||||
preamble_len: PREAMBLE_DEFAULT,
|
|
||||||
..make_config()
|
|
||||||
};
|
|
||||||
assert!(cfg.validate((-9, 22)).is_ok());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn validate_preamble_boundaries() {
|
|
||||||
let power_range = (-9, 22);
|
|
||||||
let base = make_config();
|
|
||||||
|
|
||||||
assert!(RadioConfig {
|
|
||||||
preamble_len: 6,
|
|
||||||
..base
|
|
||||||
}
|
|
||||||
.validate(power_range)
|
|
||||||
.is_ok());
|
|
||||||
assert!(RadioConfig {
|
|
||||||
preamble_len: 5,
|
|
||||||
..base
|
|
||||||
}
|
|
||||||
.validate(power_range)
|
|
||||||
.is_err());
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── RadioConfig::resolve ──────────────────────────────────────
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn resolve_power_max_sentinel() {
|
|
||||||
let cfg = RadioConfig {
|
|
||||||
tx_power_dbm: TX_POWER_MAX,
|
|
||||||
..make_config()
|
|
||||||
};
|
|
||||||
assert_eq!(cfg.resolve((-9, 22)).tx_power_dbm, 22);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn resolve_power_explicit_unchanged() {
|
|
||||||
let cfg = RadioConfig {
|
|
||||||
tx_power_dbm: 10,
|
|
||||||
..make_config()
|
|
||||||
};
|
|
||||||
assert_eq!(cfg.resolve((-9, 22)).tx_power_dbm, 10);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn resolve_preamble_default() {
|
|
||||||
let cfg = RadioConfig {
|
|
||||||
preamble_len: PREAMBLE_DEFAULT,
|
|
||||||
..make_config()
|
|
||||||
};
|
|
||||||
assert_eq!(cfg.resolve((-9, 22)).preamble_len, 16);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn resolve_preamble_explicit_unchanged() {
|
|
||||||
let cfg = RadioConfig {
|
|
||||||
preamble_len: 32,
|
|
||||||
..make_config()
|
|
||||||
};
|
|
||||||
assert_eq!(cfg.resolve((-9, 22)).preamble_len, 32);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Command::from_bytes ─────────────────────────────────────────
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn command_ping() {
|
|
||||||
assert_eq!(Command::from_bytes(&[0]), Some(Command::Ping));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn command_get_config() {
|
|
||||||
assert_eq!(Command::from_bytes(&[1]), Some(Command::GetConfig));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn command_set_config() {
|
|
||||||
let cfg = make_config();
|
|
||||||
let mut buf = [0u8; 1 + RADIO_CONFIG_SIZE];
|
|
||||||
buf[0] = 2;
|
|
||||||
cfg.write_to(&mut buf[1..]);
|
|
||||||
assert_eq!(Command::from_bytes(&buf), Some(Command::SetConfig(cfg)));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn command_start_stop_rx() {
|
|
||||||
assert_eq!(Command::from_bytes(&[3]), Some(Command::StartRx));
|
|
||||||
assert_eq!(Command::from_bytes(&[4]), Some(Command::StopRx));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn command_transmit_no_config() {
|
|
||||||
let payload = b"hello";
|
|
||||||
let mut buf = [0u8; 64];
|
|
||||||
buf[0] = 5; // tag
|
|
||||||
buf[1] = 0; // has_config = false
|
|
||||||
buf[2..4].copy_from_slice(&(payload.len() as u16).to_le_bytes());
|
|
||||||
buf[4..9].copy_from_slice(payload);
|
|
||||||
|
|
||||||
match Command::from_bytes(&buf[..9]).unwrap() {
|
|
||||||
Command::Transmit { config, payload: p } => {
|
|
||||||
assert!(config.is_none());
|
|
||||||
assert_eq!(p.as_slice(), b"hello");
|
|
||||||
}
|
|
||||||
_ => panic!("expected Transmit"),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn command_transmit_with_config() {
|
|
||||||
let cfg = make_config();
|
|
||||||
let mut buf = [0u8; 64];
|
|
||||||
buf[0] = 5; // tag
|
|
||||||
buf[1] = 1; // has_config = true
|
|
||||||
cfg.write_to(&mut buf[2..]);
|
|
||||||
let payload = b"test";
|
|
||||||
let pos = 2 + RADIO_CONFIG_SIZE;
|
|
||||||
buf[pos..pos + 2].copy_from_slice(&(payload.len() as u16).to_le_bytes());
|
|
||||||
buf[pos + 2..pos + 6].copy_from_slice(payload);
|
|
||||||
|
|
||||||
match Command::from_bytes(&buf[..pos + 6]).unwrap() {
|
|
||||||
Command::Transmit { config, payload: p } => {
|
|
||||||
assert_eq!(config, Some(cfg));
|
|
||||||
assert_eq!(p.as_slice(), b"test");
|
|
||||||
}
|
|
||||||
_ => panic!("expected Transmit"),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn command_transmit_empty_payload() {
|
|
||||||
let mut buf = [0u8; 4];
|
|
||||||
buf[0] = 5; // tag
|
|
||||||
buf[1] = 0; // has_config = false
|
|
||||||
buf[2..4].copy_from_slice(&0u16.to_le_bytes());
|
|
||||||
|
|
||||||
match Command::from_bytes(&buf).unwrap() {
|
|
||||||
Command::Transmit { config, payload } => {
|
|
||||||
assert!(config.is_none());
|
|
||||||
assert!(payload.is_empty());
|
|
||||||
}
|
|
||||||
_ => panic!("expected Transmit"),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn command_transmit_truncated() {
|
|
||||||
// Tag only — missing has_config byte
|
|
||||||
assert!(Command::from_bytes(&[5]).is_none());
|
|
||||||
|
|
||||||
// has_config=1 but no config bytes
|
|
||||||
assert!(Command::from_bytes(&[5, 1]).is_none());
|
|
||||||
|
|
||||||
// has_config=0 but no length bytes
|
|
||||||
assert!(Command::from_bytes(&[5, 0]).is_none());
|
|
||||||
|
|
||||||
// has_config=0, length says 5 but only 2 bytes of payload
|
|
||||||
let mut buf = [0u8; 6];
|
|
||||||
buf[0] = 5;
|
|
||||||
buf[1] = 0;
|
|
||||||
buf[2..4].copy_from_slice(&5u16.to_le_bytes());
|
|
||||||
buf[4] = 0xAA;
|
|
||||||
buf[5] = 0xBB;
|
|
||||||
assert!(Command::from_bytes(&buf).is_none());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn command_display_and_mac() {
|
|
||||||
assert_eq!(Command::from_bytes(&[6]), Some(Command::DisplayOn));
|
|
||||||
assert_eq!(Command::from_bytes(&[7]), Some(Command::DisplayOff));
|
|
||||||
assert_eq!(Command::from_bytes(&[8]), Some(Command::GetMac));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn command_invalid_tag() {
|
|
||||||
assert!(Command::from_bytes(&[9]).is_none());
|
|
||||||
assert!(Command::from_bytes(&[255]).is_none());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn command_empty_buffer() {
|
|
||||||
assert!(Command::from_bytes(&[]).is_none());
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Response::write_to ──────────────────────────────────────────
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn response_pong() {
|
|
||||||
let mut buf = [0u8; 1];
|
|
||||||
assert_eq!(Response::Pong.write_to(&mut buf), 1);
|
|
||||||
assert_eq!(buf[0], 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn response_config() {
|
|
||||||
let cfg = make_config();
|
|
||||||
let mut buf = [0u8; 1 + RADIO_CONFIG_SIZE];
|
|
||||||
let n = Response::Config(cfg).write_to(&mut buf);
|
|
||||||
assert_eq!(n, 1 + RADIO_CONFIG_SIZE);
|
|
||||||
assert_eq!(buf[0], 1);
|
|
||||||
assert_eq!(RadioConfig::from_bytes(&buf[1..]), Some(cfg));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn response_rx_packet() {
|
|
||||||
let mut payload = Vec::new();
|
|
||||||
let _ = payload.extend_from_slice(b"data");
|
|
||||||
let mut buf = [0u8; 64];
|
|
||||||
let n = Response::RxPacket {
|
|
||||||
rssi: -80,
|
|
||||||
snr: 10,
|
|
||||||
payload,
|
|
||||||
}
|
|
||||||
.write_to(&mut buf);
|
|
||||||
assert_eq!(n, 7 + 4); // tag(1) + rssi(2) + snr(2) + len(2) + "data"(4)
|
|
||||||
assert_eq!(buf[0], 2);
|
|
||||||
assert_eq!(i16::from_le_bytes([buf[1], buf[2]]), -80);
|
|
||||||
assert_eq!(i16::from_le_bytes([buf[3], buf[4]]), 10);
|
|
||||||
assert_eq!(u16::from_le_bytes([buf[5], buf[6]]), 4);
|
|
||||||
assert_eq!(&buf[7..11], b"data");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn response_tx_done() {
|
|
||||||
let mut buf = [0u8; 1];
|
|
||||||
assert_eq!(Response::TxDone.write_to(&mut buf), 1);
|
|
||||||
assert_eq!(buf[0], 3);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn response_ok() {
|
|
||||||
let mut buf = [0u8; 1];
|
|
||||||
assert_eq!(Response::Ok.write_to(&mut buf), 1);
|
|
||||||
assert_eq!(buf[0], 4);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn response_error_codes() {
|
|
||||||
let mut buf = [0u8; 2];
|
|
||||||
for (code, val) in [
|
|
||||||
(ErrorCode::InvalidConfig, 0),
|
|
||||||
(ErrorCode::RadioBusy, 1),
|
|
||||||
(ErrorCode::TxTimeout, 2),
|
|
||||||
(ErrorCode::NotConfigured, 4),
|
|
||||||
(ErrorCode::NoDisplay, 5),
|
|
||||||
] {
|
|
||||||
let n = Response::Error(code).write_to(&mut buf);
|
|
||||||
assert_eq!(n, 2);
|
|
||||||
assert_eq!(buf[0], 5);
|
|
||||||
assert_eq!(buf[1], val);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn response_mac_address() {
|
|
||||||
let mac = [0xAA, 0xBB, 0xCC, 0xDD, 0xEE, 0xFF];
|
|
||||||
let mut buf = [0u8; 7];
|
|
||||||
let n = Response::MacAddress(mac).write_to(&mut buf);
|
|
||||||
assert_eq!(n, 7);
|
|
||||||
assert_eq!(buf[0], 6);
|
|
||||||
assert_eq!(&buf[1..7], &mac);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+666
-292
File diff suppressed because it is too large.
Load diff
Reference in new issue
Block a user