chore: massive repo reorganization
This commit is contained in:
commit
bb588c93ce
26 files changed
+5321
No files matched your search
@@ -0,0 +1,6 @@
|
||||
[target.'cfg(target_arch = "xtensa")']
|
||||
rustflags = [
|
||||
"-C", "link-arg=-Wl,-Tlinkall.x",
|
||||
"-C", "link-arg=-Wl,-Tdefmt.x",
|
||||
"-C", "link-arg=-nostartfiles",
|
||||
]
|
||||
@@ -0,0 +1,5 @@
|
||||
/target
|
||||
/vendor
|
||||
/builds
|
||||
/.hypothesis
|
||||
src/board/mod.rs
|
||||
Generated
+2003
File diff suppressed because it is too large.
Load diff
+105
@@ -0,0 +1,105 @@
|
||||
[workspace]
|
||||
members = ["."]
|
||||
|
||||
[package]
|
||||
name = "donglora"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
build = "build.rs"
|
||||
authors = ["Stephen Waits <steve@waits.net>"]
|
||||
license = "MIT"
|
||||
description = "Transparent LoRa radio over USB — plug in a board, talk LoRa from your host"
|
||||
repository = "https://github.com/swaits/donglora"
|
||||
keywords = ["lora", "radio", "embedded", "usb", "firmware"]
|
||||
categories = ["embedded", "hardware-support", "no-std"]
|
||||
|
||||
# UPGRADE BLOCKED: embassy-executor 0.10 and ucobs 0.3 need Rust >= 1.93
|
||||
# stable. The ESP Xtensa toolchain (espup) is a nightly fork that may not
|
||||
# support the required language features or pass MSRV gating. Revisit when
|
||||
# espup catches up. Check: rustup run esp rustc --version
|
||||
[dependencies]
|
||||
cfg-if = "1.0"
|
||||
critical-section = "1.2"
|
||||
defmt = "1.0"
|
||||
embedded-hal-async = "1.0"
|
||||
embassy-embedded-hal = "0.6"
|
||||
embassy-executor = "0.9"
|
||||
embassy-sync = "0.8"
|
||||
embassy-time = "0.5"
|
||||
embassy-usb = { version = "0.6", features = ["defmt"] }
|
||||
embassy-usb-driver = "0.2"
|
||||
ucobs = { version = "0.3", features = ["legacy-msrv"] }
|
||||
heapless = "0.9"
|
||||
lora-phy = "3.0"
|
||||
static_cell = "2.1"
|
||||
|
||||
# Display rendering
|
||||
display-interface = "0.5"
|
||||
embedded-graphics = "0.8"
|
||||
ssd1306 = { version = "0.10", features = ["async"] }
|
||||
|
||||
# Async IO traits and combinators
|
||||
embedded-io-async = "0.7"
|
||||
embassy-futures = "0.1"
|
||||
|
||||
# ESP32-S3 (Heltec V3) — requires esp toolchain (espup)
|
||||
esp-backtrace = { version = "0.18", optional = true, features = [
|
||||
"esp32s3",
|
||||
"panic-handler",
|
||||
"defmt",
|
||||
] }
|
||||
esp-hal = { version = "1.0", optional = true, features = [
|
||||
"esp32s3",
|
||||
"unstable",
|
||||
] }
|
||||
esp-println = { version = "0.16", optional = true, features = [
|
||||
"esp32s3",
|
||||
"defmt-espflash",
|
||||
] }
|
||||
esp-rtos = { version = "0.2", optional = true, features = [
|
||||
"esp32s3",
|
||||
"embassy",
|
||||
] }
|
||||
|
||||
# nRF52840 (RAK 4631)
|
||||
cortex-m-rt = { version = "0.7", optional = true }
|
||||
embassy-nrf = { version = "0.10", optional = true, features = [
|
||||
"nrf52840",
|
||||
"gpiote",
|
||||
"time-driver-rtc1",
|
||||
"defmt",
|
||||
] }
|
||||
panic-probe = { version = "1.0", optional = true, features = ["print-defmt"] }
|
||||
|
||||
[build-dependencies]
|
||||
minijinja = "2.18"
|
||||
owo-colors = "4.3"
|
||||
|
||||
[features]
|
||||
heltec_v3 = [
|
||||
"dep:esp-backtrace",
|
||||
"dep:esp-hal",
|
||||
"dep:esp-println",
|
||||
"dep:esp-rtos",
|
||||
]
|
||||
heltec_v4 = [
|
||||
"dep:esp-backtrace",
|
||||
"dep:esp-hal",
|
||||
"dep:esp-println",
|
||||
"dep:esp-rtos",
|
||||
]
|
||||
rak_wisblock_4631 = [
|
||||
"dep:cortex-m-rt",
|
||||
"dep:embassy-nrf",
|
||||
"dep:panic-probe",
|
||||
"embassy-executor/arch-cortex-m",
|
||||
"embassy-executor/executor-thread",
|
||||
]
|
||||
|
||||
[lints.clippy]
|
||||
undocumented_unsafe_blocks = "deny"
|
||||
|
||||
[profile.release]
|
||||
codegen-units = 1
|
||||
lto = "fat"
|
||||
opt-level = 3 # "s"/"z" break Xtensa (l32r literal range exceeded)
|
||||
+291
@@ -0,0 +1,291 @@
|
||||
# DongLoRa USB Protocol
|
||||
|
||||
DongLoRa communicates over USB CDC-ACM (virtual serial port).
|
||||
No baud rate configuration needed.
|
||||
|
||||
**USB identifiers:** VID `1209`, PID `5741`
|
||||
|
||||
## Framing
|
||||
|
||||
Each message is [COBS](https://en.wikipedia.org/wiki/Consistent_Overhead_Byte_Stuffing)-encoded
|
||||
and terminated with a `0x00` sentinel byte:
|
||||
|
||||
```
|
||||
[COBS-encoded bytes] [0x00]
|
||||
```
|
||||
|
||||
COBS guarantees `0x00` never appears in the encoded data, so the
|
||||
sentinel unambiguously marks frame boundaries. USB packets are
|
||||
chunked at 64 bytes.
|
||||
|
||||
## Encoding
|
||||
|
||||
All integers are **fixed-size little-endian**. No variable-length
|
||||
encoding. Every field has a known size at a known offset.
|
||||
|
||||
| Type | Size | Encoding |
|
||||
|------|------|----------|
|
||||
| `u8` | 1 | Raw byte |
|
||||
| `i8` | 1 | Raw byte (two's complement) |
|
||||
| `u16` | 2 | Little-endian |
|
||||
| `i16` | 2 | Little-endian (two's complement) |
|
||||
| `u32` | 4 | Little-endian |
|
||||
|
||||
## RadioConfig (13 bytes)
|
||||
|
||||
| Offset | Size | Field | Type | Valid range |
|
||||
|--------|------|-------|------|-------------|
|
||||
| 0 | 4 | `freq_hz` | u32 LE | 150,000,000 – 960,000,000 |
|
||||
| 4 | 1 | `bw` | u8 | 0–9 (see bandwidth table) |
|
||||
| 5 | 1 | `sf` | u8 | 5–12 |
|
||||
| 6 | 1 | `cr` | u8 | 5–8 (coding rate denominator) |
|
||||
| 7 | 2 | `sync_word` | u16 LE | any |
|
||||
| 9 | 1 | `tx_power_dbm` | i8 | board-dependent, or `-128` for max |
|
||||
| 10 | 2 | `preamble_len` | u16 LE | 6–65535, or `0` for default (16) |
|
||||
| 12 | 1 | `cad` | u8 | 0 = disabled, non-zero = enabled |
|
||||
|
||||
Set `tx_power_dbm` to `-128` (0x80) to use the board's maximum
|
||||
TX power. The firmware resolves this to the actual hardware max.
|
||||
|
||||
Set `preamble_len` to `0` to use the firmware default of 16 symbols.
|
||||
Longer preambles improve reliability in mesh/noisy environments at
|
||||
the cost of slightly longer air time.
|
||||
|
||||
Set `cad` to `1` (or any non-zero value) to enable Channel Activity
|
||||
Detection (listen-before-talk) before each transmission. The radio
|
||||
checks for channel activity and waits for a clear channel before
|
||||
transmitting. Set to `0` to disable. Default: enabled.
|
||||
|
||||
## Commands (Host → Firmware)
|
||||
|
||||
Each command is a tag byte followed by fixed-size fields.
|
||||
|
||||
| Tag | Command | Payload |
|
||||
|-----|---------|---------|
|
||||
| 0 | Ping | — |
|
||||
| 1 | GetConfig | — |
|
||||
| 2 | SetConfig | RadioConfig (10 bytes) |
|
||||
| 3 | StartRx | — |
|
||||
| 4 | StopRx | — |
|
||||
| 5 | Transmit | has_config (1) + [RadioConfig if 1] + len (u16 LE) + payload |
|
||||
| 6 | DisplayOn | — |
|
||||
| 7 | DisplayOff | — |
|
||||
| 8 | GetMac | — |
|
||||
|
||||
## Responses (Firmware → Host)
|
||||
|
||||
| Tag | Response | Payload |
|
||||
|-----|----------|---------|
|
||||
| 0 | Pong | — |
|
||||
| 1 | Config | RadioConfig (10 bytes) |
|
||||
| 2 | RxPacket | rssi (i16 LE) + snr (i16 LE) + len (u16 LE) + payload |
|
||||
| 3 | TxDone | — |
|
||||
| 4 | Ok | — |
|
||||
| 5 | Error | code (u8) |
|
||||
| 6 | MacAddress | 6 raw bytes (board's MAC/device address) |
|
||||
|
||||
## Bandwidth
|
||||
|
||||
| Value | Bandwidth |
|
||||
|-------|-----------|
|
||||
| 0 | 7.8 kHz |
|
||||
| 1 | 10.4 kHz |
|
||||
| 2 | 15.6 kHz |
|
||||
| 3 | 20.8 kHz |
|
||||
| 4 | 31.25 kHz |
|
||||
| 5 | 41.7 kHz |
|
||||
| 6 | 62.5 kHz |
|
||||
| 7 | 125 kHz |
|
||||
| 8 | 250 kHz |
|
||||
| 9 | 500 kHz |
|
||||
|
||||
## Error Codes
|
||||
|
||||
| Value | Error | Meaning |
|
||||
|-------|-------|---------|
|
||||
| 0 | InvalidConfig | Radio config validation failed |
|
||||
| 1 | RadioBusy | Radio busy (e.g. RX restart failed) |
|
||||
| 2 | TxTimeout | Transmission timed out |
|
||||
| 3 | CrcError | CRC error on received packet |
|
||||
| 4 | NotConfigured | SetConfig required first |
|
||||
| 5 | NoDisplay | No display attached |
|
||||
|
||||
## Worked Example
|
||||
|
||||
Configure for 915 MHz, 125 kHz BW, SF7, CR 4/5, max TX power, default preamble, CAD on:
|
||||
|
||||
### 1. Build the bytes
|
||||
|
||||
```python
|
||||
import struct
|
||||
config = struct.pack("<IBBBHBHB",
|
||||
915_000_000, # freq_hz
|
||||
7, # bw (125 kHz)
|
||||
7, # sf
|
||||
5, # cr (4/5)
|
||||
0x1424, # sync_word
|
||||
0x80, # tx_power_dbm (-128 = max)
|
||||
0, # preamble_len (0 = default 16)
|
||||
1, # cad (1 = enabled)
|
||||
)
|
||||
command = b"\x02" + config # tag 2 = SetConfig
|
||||
```
|
||||
|
||||
Raw bytes: `02 C0 CA 89 36 07 07 05 24 14 80 00 00 01`
|
||||
|
||||
### 2. COBS-encode and send
|
||||
|
||||
COBS-encode the bytes, append `0x00`, write to USB serial port.
|
||||
Read back the response, COBS-decode it. First byte `0x04` = Ok.
|
||||
|
||||
## Command/Response Discipline
|
||||
|
||||
### Solicited vs. unsolicited responses
|
||||
|
||||
Every response is either **solicited** (exactly one per command) or
|
||||
**unsolicited** (arrives at any time):
|
||||
|
||||
| Response | Category |
|
||||
|----------|----------|
|
||||
| `RxPacket` | **Unsolicited** — arrives whenever a LoRa packet is received during an active RX session |
|
||||
| All others | **Solicited** — exactly one per command, in the order commands were sent |
|
||||
|
||||
### Command → response mapping
|
||||
|
||||
Each command produces exactly one solicited response:
|
||||
|
||||
| Command | Success | Possible errors |
|
||||
|---------|---------|-----------------|
|
||||
| Ping | Pong | — |
|
||||
| GetConfig | Config | Error(NotConfigured) |
|
||||
| SetConfig | Ok | Error(InvalidConfig) |
|
||||
| StartRx | Ok | Error(NotConfigured), Error(InvalidConfig) |
|
||||
| StopRx | Ok | — |
|
||||
| Transmit | TxDone | Error(NotConfigured), Error(InvalidConfig), Error(TxTimeout) |
|
||||
| DisplayOn | Ok | Error(NoDisplay) |
|
||||
| DisplayOff | Ok | Error(NoDisplay) |
|
||||
| GetMac | MacAddress | — |
|
||||
|
||||
### One outstanding command
|
||||
|
||||
The host **must** wait for the solicited response to the current
|
||||
command before sending the next command. Pipelining multiple commands
|
||||
without waiting for responses produces **undefined response ordering**.
|
||||
|
||||
### Processing order
|
||||
|
||||
Commands are processed in FIFO order. The firmware never reorders
|
||||
commands or drops them silently.
|
||||
|
||||
### Interleaving with RxPacket
|
||||
|
||||
Between sending a command and receiving its solicited response, the
|
||||
host may receive zero or more unsolicited `RxPacket` frames.
|
||||
Hosts **must skip** `RxPacket` frames (tag 2) when waiting for a
|
||||
solicited response. Skipped `RxPacket` frames should be buffered,
|
||||
not discarded, if the application needs received LoRa data.
|
||||
|
||||
### Locally-handled commands
|
||||
|
||||
`DisplayOn`, `DisplayOff`, and `GetMac` are handled immediately by
|
||||
the USB task and respond without involving the radio. All other
|
||||
commands are routed to the radio task and respond after radio
|
||||
processing. The one-outstanding-command rule ensures these two
|
||||
response paths never conflict.
|
||||
|
||||
## Received Packet Quality
|
||||
|
||||
The SX126x radio will demodulate and deliver packets that are
|
||||
technically intact (LoRa CRC passed) but were received so close to the
|
||||
noise floor that the payload may be corrupt. This is inherent to LoRa:
|
||||
the radio's internal CRC is only 16 bits, and at very low SNR the false-
|
||||
positive rate is non-trivial. The firmware delivers every packet the
|
||||
radio accepts — it is the host's responsibility to assess quality.
|
||||
|
||||
### SNR demodulation floor
|
||||
|
||||
Each spreading factor has a theoretical minimum SNR for reliable
|
||||
demodulation. Packets received below this floor are statistically
|
||||
likely to contain errors even though the radio reported a valid CRC:
|
||||
|
||||
| SF | Min SNR (dB) |
|
||||
|----|--------------|
|
||||
| 5 | -2.5 |
|
||||
| 6 | -5.0 |
|
||||
| 7 | -7.5 |
|
||||
| 8 | -10.0 |
|
||||
| 9 | -12.5 |
|
||||
| 10 | -15.0 |
|
||||
| 11 | -17.5 |
|
||||
| 12 | -20.0 |
|
||||
|
||||
The pattern is: **min_snr = -2.5 × (SF - 4)**.
|
||||
|
||||
### SNR validity range
|
||||
|
||||
The SX126x reports SNR as a signed value in the range **-32 to +32 dB**.
|
||||
Values outside this range indicate a firmware or decoding error — treat
|
||||
the packet as unreliable.
|
||||
|
||||
### Recommended quality grading
|
||||
|
||||
Hosts should classify each `RxPacket` before trusting the payload:
|
||||
|
||||
```
|
||||
function grade_packet(snr, sf):
|
||||
# Step 1: reject impossible SNR values
|
||||
if snr < -32 or snr > 32:
|
||||
return INVALID # bad metadata, discard or flag
|
||||
|
||||
# Step 2: check against demodulation floor
|
||||
min_snr = -2.5 * (sf - 4)
|
||||
margin = snr - min_snr # dB above the floor
|
||||
|
||||
if margin < 0:
|
||||
return UNRELIABLE # below floor, likely corrupt
|
||||
if margin < 3:
|
||||
return MARGINAL # near floor, may contain errors
|
||||
return GOOD # comfortable margin
|
||||
```
|
||||
|
||||
**What to do with each grade:**
|
||||
|
||||
- **GOOD** — process normally.
|
||||
- **MARGINAL** — process, but prefer application-layer integrity
|
||||
checks (checksums, sequence numbers, known-format validation)
|
||||
before acting on the payload. Log for diagnostics.
|
||||
- **UNRELIABLE** — log for diagnostics but do not trust the payload
|
||||
for routing decisions, command execution, or retransmission.
|
||||
Applications may still display the data with a warning.
|
||||
- **INVALID** — discard. The SNR metadata itself is corrupt.
|
||||
|
||||
### Application-layer defenses
|
||||
|
||||
For safety-critical or command-and-control applications, do not rely
|
||||
solely on the radio CRC. Add your own integrity checks:
|
||||
|
||||
- An application-layer CRC or hash over the payload
|
||||
- Sequence numbers to detect duplicates and missing packets
|
||||
- Known-format validation (magic bytes, expected lengths)
|
||||
- Require acknowledgement before acting on commands
|
||||
|
||||
### Radio config impact
|
||||
|
||||
Wider bandwidth and lower spreading factor increase throughput but
|
||||
reduce sensitivity, making low-SNR packets more common in marginal
|
||||
links. If your application sees frequent MARGINAL/UNRELIABLE packets,
|
||||
consider increasing SF or decreasing BW to improve link margin at the
|
||||
cost of air time.
|
||||
|
||||
## Host Implementation Checklist
|
||||
|
||||
1. Open USB serial port (find by VID:PID `1209:5741`)
|
||||
2. Implement COBS encode/decode (libraries exist for every language)
|
||||
3. Pack commands with `struct.pack` or equivalent — all fields are fixed-size LE
|
||||
4. Optionally send `GetMac`, read back `MacAddress` (useful with multiple dongles)
|
||||
5. Send `SetConfig`, read back `Ok`
|
||||
6. Send `StartRx`, read back `Ok`
|
||||
7. Loop: read frames, decode `RxPacket` (rssi, snr, payload at known offsets)
|
||||
8. To transmit: send `Transmit`, read back `TxDone`
|
||||
|
||||
See `examples/` for working Python implementations.
|
||||
@@ -0,0 +1,33 @@
|
||||
# DongLoRa Firmware
|
||||
|
||||
Embedded Rust firmware for DongLoRa boards. Exposes the LoRa radio over
|
||||
USB CDC-ACM using a COBS-framed binary protocol.
|
||||
|
||||
## Building
|
||||
|
||||
```sh
|
||||
just setup # one-time toolchain install
|
||||
just build heltec_v4 # build for a specific board
|
||||
just flash heltec_v4 # build + flash
|
||||
just check-all # compile-check all boards
|
||||
just clippy heltec_v4 # lint
|
||||
just test # host-side protocol unit tests
|
||||
```
|
||||
|
||||
All commands are run from the `firmware/` directory.
|
||||
|
||||
## Supported Boards
|
||||
|
||||
| Board | MCU | Radio | Target |
|
||||
|-------|-----|-------|--------|
|
||||
| Heltec V3 | ESP32-S3 | SX1262 | xtensa-esp32s3-none-elf |
|
||||
| Heltec V4 | ESP32-S3 | SX1262 | xtensa-esp32s3-none-elf |
|
||||
| RAK WisBlock 4631 | nRF52840 | SX1262 | thumbv7em-none-eabihf |
|
||||
|
||||
## Adding a Board
|
||||
|
||||
See [src/board/PORTING.md](src/board/PORTING.md).
|
||||
|
||||
## Protocol
|
||||
|
||||
See [PROTOCOL.md](PROTOCOL.md) for the complete wire format specification.
|
||||
@@ -0,0 +1,66 @@
|
||||
use minijinja::{context, Environment};
|
||||
use owo_colors::OwoColorize;
|
||||
use std::fs;
|
||||
|
||||
fn main() {
|
||||
// Protocol tests run on the host without a board feature. Cargo doesn't
|
||||
// expose `cfg(test)` to build scripts, so we rely on an env var set by
|
||||
// the justfile `test` recipe. See: `just test`
|
||||
if std::env::var_os("DONGLORA_HOST_TEST").is_some() {
|
||||
return;
|
||||
}
|
||||
|
||||
let board_dir = "src/board";
|
||||
let boards: Vec<String> = fs::read_dir(board_dir)
|
||||
.expect("failed to read src/board directory")
|
||||
.filter_map(|e| {
|
||||
let p = e.ok()?.path();
|
||||
let stem = p.file_stem()?.to_str()?;
|
||||
// Exclude non-board files: mod.rs, traits.rs, and platform helpers (esp32s3.rs).
|
||||
if p.is_file()
|
||||
&& p.extension()? == "rs"
|
||||
&& !matches!(stem, "mod" | "traits" | "esp32s3")
|
||||
{
|
||||
return Some(p.file_stem()?.to_str()?.to_string());
|
||||
}
|
||||
None
|
||||
})
|
||||
.collect();
|
||||
|
||||
let enabled: Vec<_> = std::env::vars()
|
||||
.filter(|(k, _)| k.starts_with("CARGO_FEATURE_"))
|
||||
.map(|(k, _)| k.replace("CARGO_FEATURE_", "").to_lowercase())
|
||||
.filter(|f| boards.contains(f))
|
||||
.collect();
|
||||
|
||||
match enabled.len() {
|
||||
0 => panic!(
|
||||
"\n\n{}\n",
|
||||
format!("No board selected! Found: {:?}", boards)
|
||||
.red()
|
||||
.bold()
|
||||
),
|
||||
1 => println!("cargo:rustc-cfg=board_selected"),
|
||||
_ => panic!(
|
||||
"\n\n{}\n",
|
||||
format!("Conflict: Multiple boards enabled {:?}", enabled)
|
||||
.yellow()
|
||||
.bold()
|
||||
),
|
||||
}
|
||||
|
||||
let mut env = Environment::new();
|
||||
let template =
|
||||
fs::read_to_string("src/board/mod.rs.j2").expect("missing src/board/mod.rs.j2");
|
||||
env.add_template("mod", &template)
|
||||
.expect("failed to parse board template");
|
||||
let rendered = env
|
||||
.get_template("mod")
|
||||
.expect("template not found")
|
||||
.render(context!(boards => boards))
|
||||
.expect("failed to render board module");
|
||||
fs::write("src/board/mod.rs", rendered).expect("failed to write src/board/mod.rs");
|
||||
|
||||
println!("cargo:rerun-if-changed=src/board/mod.rs.j2");
|
||||
println!("cargo:rerun-if-changed={}", board_dir);
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
set shell := ["bash", "-c"]
|
||||
|
||||
# Board definitions: feature target chip
|
||||
# Xtensa targets use nightly cargo + esp rustc + -Zbuild-std=core.
|
||||
# Tool versions are pinned in mise.toml; run `just setup` to install everything.
|
||||
heltec_v3 := "heltec_v3 xtensa-esp32s3-none-elf esp32s3"
|
||||
heltec_v4 := "heltec_v4 xtensa-esp32s3-none-elf esp32s3"
|
||||
rak_wisblock_4631 := "rak_wisblock_4631 thumbv7em-none-eabihf nRF52840_xxAA"
|
||||
|
||||
builds_dir := "builds"
|
||||
|
||||
# All known boards
|
||||
boards := "heltec_v3 heltec_v4 rak_wisblock_4631"
|
||||
|
||||
# Install all required tools and toolchains
|
||||
setup:
|
||||
mise trust --yes
|
||||
mise install
|
||||
@# ESP Xtensa toolchain (not managed by mise)
|
||||
@if ! rustup toolchain list | grep -q "^esp"; then \
|
||||
echo "ESP toolchain not found, installing via espup (this may take a while)..."; \
|
||||
espup install || { echo "error: espup install failed" >&2; exit 1; }; \
|
||||
fi
|
||||
@# nightly rust-src for -Zbuild-std
|
||||
@if ! rustup component list --toolchain nightly --installed 2>/dev/null | grep -q "^rust-src"; then \
|
||||
rustup component add rust-src --toolchain nightly; \
|
||||
fi
|
||||
@# ARM target
|
||||
@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-all:
|
||||
@trap 'exit 130' INT; \
|
||||
for board in {{boards}}; do \
|
||||
if just _can_build $board; then \
|
||||
echo "── building $board ──"; \
|
||||
just build $board || exit $?; \
|
||||
else \
|
||||
echo "── skipping $board (toolchain install failed) ──"; \
|
||||
fi; \
|
||||
done
|
||||
|
||||
# Check all boards compile, installing toolchains as needed
|
||||
check-all:
|
||||
@trap 'exit 130' INT; \
|
||||
for board in {{boards}}; do \
|
||||
if just _can_build $board; then \
|
||||
echo "── checking $board ──"; \
|
||||
just check $board || exit $?; \
|
||||
else \
|
||||
echo "── skipping $board (toolchain install failed) ──"; \
|
||||
fi; \
|
||||
done
|
||||
|
||||
# Check a single board compiles
|
||||
check board:
|
||||
@just _cargo {{board}} check
|
||||
|
||||
# Run clippy on a single board
|
||||
clippy board:
|
||||
@just _cargo {{board}} "clippy" "-- -D warnings"
|
||||
|
||||
# Build release firmware and copy to builds/ with a readable name
|
||||
build board profile="release":
|
||||
@just _cargo {{board}} "build --{{profile}}"
|
||||
@just _copy_firmware {{board}} {{profile}}
|
||||
|
||||
# Build and flash a board (espflash for Xtensa, probe-rs for ARM)
|
||||
flash board:
|
||||
@just _ensure_tools
|
||||
@just build {{board}} release
|
||||
@read -r feat target chip <<< "$(just _info {{board}})"; \
|
||||
case "$target" in \
|
||||
xtensa-*) \
|
||||
port=$(just _find_port "303a:1001" "1209:5741"); \
|
||||
if [ -n "$port" ]; then \
|
||||
echo "Using $port"; \
|
||||
espflash flash -p "$port" "{{builds_dir}}/donglora-{{board}}-release.elf"; \
|
||||
else \
|
||||
echo "No port found, falling back to espflash auto-detection..." >&2; \
|
||||
espflash flash "{{builds_dir}}/donglora-{{board}}-release.elf"; \
|
||||
fi ;; \
|
||||
*) probe-rs run --chip $chip "{{builds_dir}}/donglora-{{board}}-release.elf" ;; \
|
||||
esac
|
||||
|
||||
# Show binary size for a release build
|
||||
size board:
|
||||
@just _cargo {{board}} "size --release"
|
||||
|
||||
# Run host-side protocol unit tests
|
||||
test:
|
||||
DONGLORA_HOST_TEST=1 cargo test
|
||||
|
||||
# Check for outdated dependencies
|
||||
outdated:
|
||||
@# embassy-executor 0.10 renamed arch-* → platform-* features.
|
||||
@# esp-rtos 0.2 still pins embassy-executor ^0.9, blocking the upgrade.
|
||||
@# cargo-outdated crashes trying to resolve 0.10 with the old feature name.
|
||||
cargo outdated --exclude embassy-executor
|
||||
|
||||
# ── Private helpers ───────────────────────────────────────────────────
|
||||
|
||||
# Install mise-managed tools if any are missing (fast no-op when current)
|
||||
[private]
|
||||
_ensure_tools:
|
||||
@mise trust --yes . 2>/dev/null; mise install --quiet
|
||||
|
||||
# Run a cargo command for a board.
|
||||
# Xtensa: nightly cargo + esp rustc (via RUSTC override) + -Zbuild-std=core
|
||||
[private]
|
||||
_cargo board cmd extra_args="":
|
||||
@just _ensure_tools
|
||||
@read -r feat target chip <<< "$(just _info {{board}})"; \
|
||||
env=""; extra=""; \
|
||||
case "$target" in xtensa-*) \
|
||||
just _require_esp_toolchain; \
|
||||
[ -f "$HOME/export-esp.sh" ] && . "$HOME/export-esp.sh"; \
|
||||
env="RUSTC=$(rustup which rustc --toolchain esp)"; extra="+nightly"; buildstd="-Zbuild-std=core,alloc";; \
|
||||
esac; \
|
||||
eval $env cargo $extra {{cmd}} --target "$target" --features "$feat" $buildstd {{extra_args}}
|
||||
|
||||
# Ensure a board's toolchain is available, auto-installing if needed
|
||||
[private]
|
||||
_can_build board:
|
||||
@read -r feat target chip <<< "$(just _info {{board}})"; \
|
||||
case "$target" in \
|
||||
xtensa-*) just _require_esp_toolchain ;; \
|
||||
*) rustup target list --installed | grep -q "^$target$" || rustup target add "$target" ;; \
|
||||
esac
|
||||
|
||||
# Ensure the ESP Xtensa toolchain is installed (espup installed via mise)
|
||||
[private]
|
||||
_require_esp_toolchain:
|
||||
@just _ensure_tools
|
||||
@if ! rustup toolchain list | grep -q "^esp"; then \
|
||||
echo "ESP toolchain not found, installing via espup (this may take a while)..." >&2; \
|
||||
espup install || { echo "error: espup install failed" >&2; exit 1; }; \
|
||||
fi; \
|
||||
if ! rustup component list --toolchain nightly --installed 2>/dev/null | grep -q "^rust-src"; then \
|
||||
echo "Installing nightly rust-src (needed for -Zbuild-std)..." >&2; \
|
||||
rustup component add rust-src --toolchain nightly || { echo "error: failed to install rust-src" >&2; exit 1; }; \
|
||||
fi
|
||||
|
||||
# Find a serial port matching any of the given VID:PID pairs (checked in order)
|
||||
[private]
|
||||
_find_port +vid_pids:
|
||||
@for vidpid in {{vid_pids}}; do \
|
||||
vid="${vidpid%%:*}"; pid="${vidpid##*:}"; \
|
||||
for dev in /dev/ttyACM* /dev/ttyUSB*; do \
|
||||
[ -e "$dev" ] || continue; \
|
||||
info=$(udevadm info --query=property --name="$dev" 2>/dev/null) || continue; \
|
||||
dev_vid=$(echo "$info" | sed -n 's/^ID_VENDOR_ID=//p' | tr '[:upper:]' '[:lower:]'); \
|
||||
dev_pid=$(echo "$info" | sed -n 's/^ID_MODEL_ID=//p' | tr '[:upper:]' '[:lower:]'); \
|
||||
if [ "$dev_vid" = "$vid" ] && [ "$dev_pid" = "$pid" ]; then \
|
||||
echo "$dev"; exit 0; \
|
||||
fi; \
|
||||
done; \
|
||||
done
|
||||
|
||||
[private]
|
||||
_copy_firmware board profile:
|
||||
@mkdir -p {{builds_dir}}
|
||||
@read -r feat target chip <<< "$(just _info {{board}})"; \
|
||||
src="target/$target/{{profile}}/donglora"; \
|
||||
dst="{{builds_dir}}/donglora-{{board}}-{{profile}}"; \
|
||||
if [ -f "$src" ]; then cp "$src" "$dst.elf"; echo "→ $dst.elf"; fi
|
||||
|
||||
[private]
|
||||
_info name:
|
||||
@if [ "{{name}}" == "heltec_v3" ]; then echo "{{heltec_v3}}"; \
|
||||
elif [ "{{name}}" == "heltec_v4" ]; then echo "{{heltec_v4}}"; \
|
||||
elif [ "{{name}}" == "rak_wisblock_4631" ]; then echo "{{rak_wisblock_4631}}"; \
|
||||
else echo "Unknown board: {{name}}" >&2; exit 1; fi
|
||||
@@ -0,0 +1,8 @@
|
||||
[tools]
|
||||
rust = "1.93"
|
||||
just = "latest"
|
||||
|
||||
"cargo:cargo-binutils" = "latest" # `cargo size`, `cargo objdump`, etc.
|
||||
"cargo:espup" = "0.16"
|
||||
"cargo:espflash" = "3" # 4.x has broken nix dependency on current stable Rust
|
||||
"cargo:probe-rs-tools" = "0.31"
|
||||
@@ -0,0 +1,92 @@
|
||||
# Porting DongLoRa to a New Board
|
||||
|
||||
## Steps
|
||||
|
||||
### 1. Create the board file
|
||||
|
||||
Create `src/board/your_board.rs`. Use an existing board as a template:
|
||||
- ESP32-S3 boards: copy `heltec_v4.rs` (uses shared helpers from `esp32s3.rs`)
|
||||
- nRF52840 boards: copy `rak_wisblock_4631.rs`
|
||||
|
||||
ESP32-S3 boards share common type aliases, peripheral bundles, and init helpers
|
||||
via `src/board/esp32s3.rs`. Your board file only needs the trait impl and
|
||||
board-specific init (Vext GPIO, display reset). If you add a new ESP32-S3 board,
|
||||
add its feature to the `#[cfg(any(...))]` gate on `mod esp32s3` in
|
||||
`src/board/mod.rs.j2`.
|
||||
|
||||
### 2. Define concrete types
|
||||
|
||||
Your board must export these type aliases:
|
||||
|
||||
```rust
|
||||
pub type RadioDriver = ...; // Must work with lora_phy::LoRa<RadioDriver, Delay>
|
||||
pub type UsbDriver = ...; // Must implement embassy_usb_driver::Driver<'static>
|
||||
pub type DisplayI2c = ...; // Must implement embedded_hal_async::i2c::I2c
|
||||
```
|
||||
|
||||
### 3. Define peripheral bundles
|
||||
|
||||
```rust
|
||||
pub struct RadioParts {
|
||||
pub driver: RadioDriver,
|
||||
pub delay: embassy_time::Delay,
|
||||
}
|
||||
|
||||
pub struct UsbParts {
|
||||
pub driver: UsbDriver,
|
||||
}
|
||||
|
||||
pub struct DisplayParts {
|
||||
pub i2c: DisplayI2c,
|
||||
pub mac: [u8; 6],
|
||||
}
|
||||
```
|
||||
|
||||
### 4. Implement the LoRaBoard trait
|
||||
|
||||
```rust
|
||||
use super::traits::LoRaBoard;
|
||||
|
||||
impl LoRaBoard for Board {
|
||||
const NAME: &'static str = "Your Board Name";
|
||||
const TX_POWER_RANGE: (i8, i8) = (-9, 22); // check your radio + PA
|
||||
fn init() -> Self { ... }
|
||||
fn mac_address() -> [u8; 6] { ... } // read from efuse, FICR, etc.
|
||||
}
|
||||
```
|
||||
|
||||
The compiler will refuse to build if any of these are missing.
|
||||
|
||||
### 5. Implement `Board::into_parts()`
|
||||
|
||||
```rust
|
||||
impl Board {
|
||||
pub fn into_parts(self) -> (RadioParts, UsbParts, Option<DisplayParts>) {
|
||||
// Initialize SPI, DMA, radio, USB, I2C
|
||||
// Return peripheral bundles
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 6. Add Cargo feature
|
||||
|
||||
In `firmware/Cargo.toml`, add a feature with your board's HAL dependencies:
|
||||
|
||||
```toml
|
||||
[features]
|
||||
your_board = ["dep:your-hal", ...]
|
||||
```
|
||||
|
||||
### 7. Add to justfile
|
||||
|
||||
Add your board's feature/target/chip to the `boards` list at the top of `justfile`.
|
||||
|
||||
### 8. Build and test
|
||||
|
||||
```sh
|
||||
just check your_board # Must compile
|
||||
just clippy your_board # Must be clean
|
||||
just build your_board # Produces firmware
|
||||
```
|
||||
|
||||
`build.rs` auto-discovers your `.rs` file — no template editing needed.
|
||||
@@ -0,0 +1,150 @@
|
||||
//! Shared types and initialization helpers for ESP32-S3 boards.
|
||||
//!
|
||||
//! All ESP32-S3 boards in this project share the same SPI/DMA, USB OTG,
|
||||
//! and I2C peripheral wiring. This module provides the common type aliases,
|
||||
//! peripheral bundle structs, and init functions. Each board file re-exports
|
||||
//! these types and calls the helpers from its own `Board::into_parts()`.
|
||||
|
||||
use embassy_embedded_hal::shared_bus::asynch::spi::SpiDevice;
|
||||
use embassy_sync::blocking_mutex::raw::NoopRawMutex;
|
||||
use embassy_time::Delay;
|
||||
use esp_hal::dma::{DmaRxBuf, DmaTxBuf};
|
||||
use esp_hal::gpio::{Input, InputConfig, Level, Output, OutputConfig, Pull};
|
||||
use esp_hal::i2c::master::{Config as I2cConfig, I2c};
|
||||
use esp_hal::spi::master::{Config as SpiConfig, Spi, SpiDmaBus};
|
||||
use esp_hal::spi::Mode as SpiMode;
|
||||
use esp_hal::time::Rate;
|
||||
use esp_hal::timer::timg::TimerGroup;
|
||||
use static_cell::StaticCell;
|
||||
|
||||
use lora_phy::iv::GenericSx126xInterfaceVariant;
|
||||
use lora_phy::sx126x::{self, Sx1262, Sx126x};
|
||||
|
||||
// ── Concrete peripheral types ────────────────────────────────────────
|
||||
|
||||
type SpiBus = SpiDmaBus<'static, esp_hal::Async>;
|
||||
type Nss = Output<'static>;
|
||||
type Iv = GenericSx126xInterfaceVariant<Output<'static>, Input<'static>>;
|
||||
type RadioSpiDevice = SpiDevice<'static, NoopRawMutex, SpiBus, Nss>;
|
||||
pub type RadioDriver = Sx126x<RadioSpiDevice, Iv, Sx1262>;
|
||||
|
||||
pub type UsbDriver = esp_hal::otg_fs::asynch::Driver<'static>;
|
||||
|
||||
pub type DisplayI2c = I2c<'static, esp_hal::Async>;
|
||||
|
||||
// ── Peripheral bundles ───────────────────────────────────────────────
|
||||
|
||||
pub struct RadioParts {
|
||||
pub driver: RadioDriver,
|
||||
pub delay: Delay,
|
||||
}
|
||||
|
||||
pub struct UsbParts {
|
||||
pub driver: UsbDriver,
|
||||
}
|
||||
|
||||
pub struct DisplayParts {
|
||||
pub i2c: DisplayI2c,
|
||||
pub mac: [u8; 6],
|
||||
}
|
||||
|
||||
// ── Shared initialization helpers ───────────────────────────────────
|
||||
|
||||
/// Start the ESP-RTOS scheduler and Embassy time driver.
|
||||
pub fn start_timer(timg0: esp_hal::peripherals::TIMG0<'static>) {
|
||||
let timg0 = TimerGroup::new(timg0);
|
||||
esp_rtos::start(timg0.timer0);
|
||||
}
|
||||
|
||||
/// Initialize SPI + DMA + SX1262 radio.
|
||||
///
|
||||
/// All current ESP32-S3 boards use the same SPI/radio pin mapping:
|
||||
/// SCK=GPIO9, MOSI=GPIO10, MISO=GPIO11, NSS=GPIO8,
|
||||
/// Reset=GPIO12, DIO1=GPIO14, BUSY=GPIO13.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn init_radio(
|
||||
spi2: esp_hal::peripherals::SPI2<'static>,
|
||||
dma_ch0: esp_hal::peripherals::DMA_CH0<'static>,
|
||||
sck: esp_hal::peripherals::GPIO9<'static>,
|
||||
mosi: esp_hal::peripherals::GPIO10<'static>,
|
||||
miso: esp_hal::peripherals::GPIO11<'static>,
|
||||
nss_pin: esp_hal::peripherals::GPIO8<'static>,
|
||||
reset_pin: esp_hal::peripherals::GPIO12<'static>,
|
||||
dio1_pin: esp_hal::peripherals::GPIO14<'static>,
|
||||
busy_pin: esp_hal::peripherals::GPIO13<'static>,
|
||||
) -> RadioParts {
|
||||
let spi = Spi::new(
|
||||
spi2,
|
||||
SpiConfig::default()
|
||||
.with_frequency(Rate::from_mhz(1))
|
||||
.with_mode(SpiMode::_0),
|
||||
)
|
||||
.expect("SPI init")
|
||||
.with_sck(sck)
|
||||
.with_mosi(mosi)
|
||||
.with_miso(miso)
|
||||
.with_dma(dma_ch0);
|
||||
|
||||
let (rx_buf, rx_desc, tx_buf, tx_desc) = esp_hal::dma_buffers!(256);
|
||||
let dma_rx = DmaRxBuf::new(rx_desc, rx_buf).expect("DMA RX buf");
|
||||
let dma_tx = DmaTxBuf::new(tx_desc, tx_buf).expect("DMA TX buf");
|
||||
|
||||
let spi = spi.with_buffers(dma_rx, dma_tx).into_async();
|
||||
|
||||
static SPI_BUS: StaticCell<embassy_sync::mutex::Mutex<NoopRawMutex, SpiBus>> =
|
||||
StaticCell::new();
|
||||
let spi_bus = SPI_BUS.init(embassy_sync::mutex::Mutex::new(spi));
|
||||
|
||||
let nss = Output::new(nss_pin, Level::High, OutputConfig::default());
|
||||
let spi_device = SpiDevice::new(spi_bus, nss);
|
||||
|
||||
let reset = Output::new(reset_pin, Level::High, OutputConfig::default());
|
||||
let dio1 = Input::new(dio1_pin, InputConfig::default().with_pull(Pull::Down));
|
||||
let busy = Input::new(busy_pin, InputConfig::default().with_pull(Pull::Down));
|
||||
|
||||
let iv = GenericSx126xInterfaceVariant::new(reset, dio1, busy, None, None)
|
||||
.expect("SX1262 interface init");
|
||||
|
||||
let sx_config = sx126x::Config {
|
||||
chip: Sx1262,
|
||||
tcxo_ctrl: Some(sx126x::TcxoCtrlVoltage::Ctrl1V7),
|
||||
use_dcdc: true,
|
||||
rx_boost: false,
|
||||
};
|
||||
|
||||
RadioParts {
|
||||
driver: Sx126x::new(spi_device, iv, sx_config),
|
||||
delay: Delay,
|
||||
}
|
||||
}
|
||||
|
||||
/// Initialize USB OTG CDC-ACM driver.
|
||||
pub fn init_usb(
|
||||
usb0: esp_hal::peripherals::USB0<'static>,
|
||||
dp: esp_hal::peripherals::GPIO20<'static>,
|
||||
dm: esp_hal::peripherals::GPIO19<'static>,
|
||||
) -> UsbParts {
|
||||
let usb_inst = esp_hal::otg_fs::Usb::new(usb0, dp, dm);
|
||||
static EP_OUT_BUF: StaticCell<[u8; 1024]> = StaticCell::new();
|
||||
let ep_out_buf = EP_OUT_BUF.init([0u8; 1024]);
|
||||
UsbParts {
|
||||
driver: esp_hal::otg_fs::asynch::Driver::new(
|
||||
usb_inst,
|
||||
ep_out_buf,
|
||||
esp_hal::otg_fs::asynch::Config::default(),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
/// Initialize I2C bus for display.
|
||||
pub fn init_display_i2c(
|
||||
i2c0: esp_hal::peripherals::I2C0<'static>,
|
||||
sda: esp_hal::peripherals::GPIO17<'static>,
|
||||
scl: esp_hal::peripherals::GPIO18<'static>,
|
||||
) -> DisplayI2c {
|
||||
I2c::new(i2c0, I2cConfig::default())
|
||||
.expect("I2C init")
|
||||
.with_sda(sda)
|
||||
.with_scl(scl)
|
||||
.into_async()
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
use esp_hal::gpio::{Level, Output, OutputConfig};
|
||||
|
||||
use super::esp32s3;
|
||||
use super::traits::LoRaBoard;
|
||||
|
||||
#[allow(unused_imports)] // Re-exported for other modules (display, radio, usb tasks)
|
||||
pub use super::esp32s3::{DisplayI2c, DisplayParts, RadioDriver, RadioParts, UsbDriver, UsbParts};
|
||||
|
||||
// ── Board init ───────────────────────────────────────────────────────
|
||||
|
||||
pub struct Board {
|
||||
p: esp_hal::peripherals::Peripherals,
|
||||
}
|
||||
|
||||
impl LoRaBoard for Board {
|
||||
const NAME: &'static str = "Heltec V3";
|
||||
const TX_POWER_RANGE: (i8, i8) = (-9, 22);
|
||||
|
||||
fn init() -> Self {
|
||||
let p = esp_hal::init(esp_hal::Config::default());
|
||||
Self { p }
|
||||
}
|
||||
|
||||
fn mac_address() -> [u8; 6] {
|
||||
esp_hal::efuse::Efuse::mac_address()
|
||||
}
|
||||
}
|
||||
|
||||
impl Board {
|
||||
pub fn into_parts(self) -> (RadioParts, UsbParts, Option<DisplayParts>) {
|
||||
let p = self.p;
|
||||
|
||||
esp32s3::start_timer(p.TIMG0);
|
||||
|
||||
// Vext power: GPIO21, active LOW to enable OLED
|
||||
let vext = Output::new(p.GPIO21, Level::Low, OutputConfig::default());
|
||||
core::mem::forget(vext); // hold pin low permanently; drop would reset it
|
||||
|
||||
let radio = esp32s3::init_radio(
|
||||
p.SPI2, p.DMA_CH0, p.GPIO9, p.GPIO10, p.GPIO11, p.GPIO8, p.GPIO12, p.GPIO14,
|
||||
p.GPIO13,
|
||||
);
|
||||
let usb = esp32s3::init_usb(p.USB0, p.GPIO20, p.GPIO19);
|
||||
let i2c = esp32s3::init_display_i2c(p.I2C0, p.GPIO17, p.GPIO18);
|
||||
let mac = Self::mac_address();
|
||||
let display = Some(DisplayParts { i2c, mac });
|
||||
|
||||
(radio, usb, display)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
use esp_hal::gpio::{Level, Output, OutputConfig};
|
||||
|
||||
use super::esp32s3;
|
||||
use super::traits::LoRaBoard;
|
||||
|
||||
#[allow(unused_imports)] // Re-exported for other modules (display, radio, usb tasks)
|
||||
pub use super::esp32s3::{DisplayI2c, DisplayParts, RadioDriver, RadioParts, UsbDriver, UsbParts};
|
||||
|
||||
// ── Board implementation ────────────────────────────────────────────
|
||||
|
||||
pub struct Board {
|
||||
p: esp_hal::peripherals::Peripherals,
|
||||
}
|
||||
|
||||
impl LoRaBoard for Board {
|
||||
const NAME: &'static str = "Heltec V4";
|
||||
const TX_POWER_RANGE: (i8, i8) = (-9, 22);
|
||||
|
||||
fn init() -> Self {
|
||||
let p = esp_hal::init(esp_hal::Config::default());
|
||||
Self { p }
|
||||
}
|
||||
|
||||
fn mac_address() -> [u8; 6] {
|
||||
esp_hal::efuse::Efuse::mac_address()
|
||||
}
|
||||
}
|
||||
|
||||
impl Board {
|
||||
pub fn into_parts(self) -> (RadioParts, UsbParts, Option<DisplayParts>) {
|
||||
let p = self.p;
|
||||
|
||||
esp32s3::start_timer(p.TIMG0);
|
||||
|
||||
// Vext power: GPIO36, active LOW to enable peripherals
|
||||
let vext = Output::new(p.GPIO36, Level::Low, OutputConfig::default());
|
||||
core::mem::forget(vext); // hold pin low permanently; drop would reset it
|
||||
|
||||
let radio = esp32s3::init_radio(
|
||||
p.SPI2, p.DMA_CH0, p.GPIO9, p.GPIO10, p.GPIO11, p.GPIO8, p.GPIO12, p.GPIO14,
|
||||
p.GPIO13,
|
||||
);
|
||||
|
||||
// Note: switches internal USB PHY from Serial-JTAG to OTG.
|
||||
// espflash --monitor will stop working after this point.
|
||||
let usb = esp32s3::init_usb(p.USB0, p.GPIO20, p.GPIO19);
|
||||
|
||||
// SSD1315 display reset: pulse GPIO21 low->high before I2C init
|
||||
let mut display_rst = Output::new(p.GPIO21, Level::Low, OutputConfig::default());
|
||||
esp_hal::delay::Delay::new().delay_millis(10);
|
||||
display_rst.set_high();
|
||||
esp_hal::delay::Delay::new().delay_millis(10);
|
||||
core::mem::forget(display_rst); // hold reset high permanently
|
||||
|
||||
let i2c = esp32s3::init_display_i2c(p.I2C0, p.GPIO17, p.GPIO18);
|
||||
let mac = Self::mac_address();
|
||||
let display = Some(DisplayParts { i2c, mac });
|
||||
|
||||
(radio, usb, display)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
// @generated by build.rs — DO NOT EDIT
|
||||
pub mod traits;
|
||||
pub use traits::*;
|
||||
|
||||
#[cfg(any(feature = "heltec_v3", feature = "heltec_v4"))]
|
||||
mod esp32s3;
|
||||
|
||||
cfg_if::cfg_if! {
|
||||
{%- for board in boards %}
|
||||
{% if loop.first %}if{% else %}else if{% endif %} #[cfg(feature = "{{ board }}")] {
|
||||
mod {{ board }};
|
||||
pub use {{ board }}::*;
|
||||
}
|
||||
{%- endfor %}
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
use embassy_embedded_hal::shared_bus::asynch::spi::SpiDevice;
|
||||
use embassy_nrf::gpio::{Input, Level, Output, OutputDrive, Pull};
|
||||
use embassy_nrf::spim::{self, Spim};
|
||||
use embassy_nrf::twim::{self, Twim};
|
||||
use embassy_nrf::usb::Driver;
|
||||
use embassy_nrf::{bind_interrupts, Peripherals};
|
||||
use embassy_sync::blocking_mutex::raw::NoopRawMutex;
|
||||
use embassy_time::Delay;
|
||||
use static_cell::StaticCell;
|
||||
|
||||
use lora_phy::iv::GenericSx126xInterfaceVariant;
|
||||
use lora_phy::sx126x::{self, Sx1262, Sx126x};
|
||||
|
||||
use super::traits::LoRaBoard;
|
||||
|
||||
bind_interrupts!(struct Irqs {
|
||||
USBD => embassy_nrf::usb::InterruptHandler<embassy_nrf::peripherals::USBD>;
|
||||
SPIM3 => embassy_nrf::spim::InterruptHandler<embassy_nrf::peripherals::SPI3>;
|
||||
TWISPI0 => embassy_nrf::twim::InterruptHandler<embassy_nrf::peripherals::TWISPI0>;
|
||||
});
|
||||
|
||||
// ── Concrete peripheral types ────────────────────────────────────────
|
||||
|
||||
type SpiBus = Spim<'static>;
|
||||
type Nss = Output<'static>;
|
||||
type Iv = GenericSx126xInterfaceVariant<Output<'static>, Input<'static>>;
|
||||
type RadioSpiDevice = SpiDevice<'static, NoopRawMutex, SpiBus, Nss>;
|
||||
/// SX126x driver (pre-LoRa construction — LoRa::new is async).
|
||||
pub type RadioDriver = Sx126x<RadioSpiDevice, Iv, Sx1262>;
|
||||
|
||||
/// USB driver for CDC-ACM.
|
||||
pub type UsbDriver = Driver<'static, &'static embassy_nrf::usb::vbus_detect::SoftwareVbusDetect>;
|
||||
|
||||
/// I2C bus for an optional SSD1306 OLED.
|
||||
pub type DisplayI2c = Twim<'static>;
|
||||
|
||||
// ── Peripheral bundles ───────────────────────────────────────────────
|
||||
|
||||
pub struct RadioParts {
|
||||
pub driver: RadioDriver,
|
||||
pub delay: Delay,
|
||||
}
|
||||
|
||||
pub struct UsbParts {
|
||||
pub driver: UsbDriver,
|
||||
}
|
||||
|
||||
pub struct DisplayParts {
|
||||
pub i2c: DisplayI2c,
|
||||
pub mac: [u8; 6],
|
||||
}
|
||||
|
||||
// ── Board init ───────────────────────────────────────────────────────
|
||||
|
||||
pub struct Board {
|
||||
p: Peripherals,
|
||||
}
|
||||
|
||||
impl super::traits::LoRaBoard for Board {
|
||||
const NAME: &'static str = "RAK WisBlock 4631";
|
||||
const TX_POWER_RANGE: (i8, i8) = (-9, 22);
|
||||
|
||||
fn init() -> Self {
|
||||
let p = embassy_nrf::init(Default::default());
|
||||
Self { p }
|
||||
}
|
||||
|
||||
fn mac_address() -> [u8; 6] {
|
||||
// SAFETY: 0x10000000 is the nRF52840 FICR (Factory Information Configuration
|
||||
// Registers) base address. Offsets 0x0A4 and 0x0A8 are the DEVICEADDR[0] and
|
||||
// DEVICEADDR[1] registers containing the factory-programmed device address.
|
||||
// read_volatile is correct for memory-mapped hardware registers.
|
||||
unsafe {
|
||||
let ficr = 0x10000000 as *const u32;
|
||||
let addr0 = core::ptr::read_volatile(ficr.byte_add(0x0A4));
|
||||
let addr1 = core::ptr::read_volatile(ficr.byte_add(0x0A8));
|
||||
[
|
||||
addr0 as u8,
|
||||
(addr0 >> 8) as u8,
|
||||
(addr0 >> 16) as u8,
|
||||
(addr0 >> 24) as u8,
|
||||
addr1 as u8,
|
||||
(addr1 >> 8) as u8,
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Board {
|
||||
|
||||
pub fn into_parts(self) -> (RadioParts, UsbParts, Option<DisplayParts>) {
|
||||
let p = self.p;
|
||||
|
||||
// ── SPI bus for SX1262 ───────────────────────────────────
|
||||
let mut spi_cfg = spim::Config::default();
|
||||
spi_cfg.frequency = spim::Frequency::M1;
|
||||
let spi = Spim::new(p.SPI3, Irqs, p.P1_11, p.P1_13, p.P1_12, spi_cfg);
|
||||
|
||||
static SPI_BUS: StaticCell<embassy_sync::mutex::Mutex<NoopRawMutex, SpiBus>> =
|
||||
StaticCell::new();
|
||||
let spi_bus = SPI_BUS.init(embassy_sync::mutex::Mutex::new(spi));
|
||||
|
||||
let nss = Output::new(p.P1_10, Level::High, OutputDrive::Standard);
|
||||
let spi_device = SpiDevice::new(spi_bus, nss);
|
||||
|
||||
// ── SX1262 control pins ──────────────────────────────────
|
||||
let reset = Output::new(p.P1_06, Level::High, OutputDrive::Standard);
|
||||
let dio1 = Input::new(p.P1_15, Pull::Down);
|
||||
let busy = Input::new(p.P1_14, Pull::Down);
|
||||
|
||||
let iv = GenericSx126xInterfaceVariant::new(reset, dio1, busy, None, None).expect("SX1262 interface init");
|
||||
|
||||
let sx_config = sx126x::Config {
|
||||
chip: Sx1262,
|
||||
tcxo_ctrl: Some(sx126x::TcxoCtrlVoltage::Ctrl1V7),
|
||||
use_dcdc: true,
|
||||
rx_boost: false,
|
||||
};
|
||||
|
||||
let radio = RadioParts {
|
||||
driver: Sx126x::new(spi_device, iv, sx_config),
|
||||
delay: Delay,
|
||||
};
|
||||
|
||||
// ── USB ──────────────────────────────────────────────────
|
||||
static VBUS: StaticCell<embassy_nrf::usb::vbus_detect::SoftwareVbusDetect> =
|
||||
StaticCell::new();
|
||||
let vbus = VBUS.init(embassy_nrf::usb::vbus_detect::SoftwareVbusDetect::new(true, false));
|
||||
let usb = UsbParts {
|
||||
driver: Driver::new(p.USBD, Irqs, vbus),
|
||||
};
|
||||
|
||||
// ── Display (optional RAK1921 SSD1306 OLED on I2C) ──────
|
||||
// Always provide the I2C bus; display_task detects presence
|
||||
// via SSD1306 init (fails gracefully if no display attached).
|
||||
static TWIM_BUF: StaticCell<[u8; 256]> = StaticCell::new();
|
||||
let twim_buf = TWIM_BUF.init([0u8; 256]);
|
||||
let i2c_cfg = twim::Config::default();
|
||||
let i2c = Twim::new(p.TWISPI0, Irqs, p.P0_13, p.P0_14, i2c_cfg, twim_buf);
|
||||
let mac = Self::mac_address();
|
||||
let display = Some(DisplayParts { i2c, mac });
|
||||
|
||||
(radio, usb, display)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
//! Board abstraction trait.
|
||||
//!
|
||||
//! Every board must implement [`LoRaBoard`]. The compiler enforces completeness —
|
||||
//! missing constants or methods are compile errors, not runtime surprises.
|
||||
//!
|
||||
//! Board modules also export concrete types (`RadioParts`, `UsbParts`,
|
||||
//! `DisplayParts`, `RadioDriver`, `UsbDriver`, `DisplayI2c`) that the tasks
|
||||
//! use directly. The trait verifies metadata and initialization; the types
|
||||
//! verify hardware compatibility.
|
||||
//!
|
||||
//! See `src/board/PORTING.md` for a step-by-step guide to adding a new board.
|
||||
|
||||
/// Compile-time contract for a DongLoRa board.
|
||||
///
|
||||
/// Every board's `Board` struct must implement this trait. It verifies that
|
||||
/// the board provides all required metadata and initialization methods.
|
||||
/// The tasks use the board's concrete types directly (not the trait's
|
||||
/// associated types) because Embassy tasks must be concrete.
|
||||
pub trait LoRaBoard {
|
||||
/// Human-readable board name (shown on display splash screen).
|
||||
const NAME: &'static str;
|
||||
|
||||
/// TX power range in dBm (min, max) for this board's radio + PA.
|
||||
const TX_POWER_RANGE: (i8, i8);
|
||||
|
||||
/// Initialize the board hardware.
|
||||
fn init() -> Self;
|
||||
|
||||
/// Read the board's unique hardware address (MAC, device ID, etc.).
|
||||
fn mac_address() -> [u8; 6];
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
//! Inter-task communication channels.
|
||||
//!
|
||||
//! Each channel connects exactly two tasks:
|
||||
//! - [`CommandChannel`]: USB → Radio (host commands)
|
||||
//! - [`ResponseChannel`]: Radio → USB (firmware responses)
|
||||
//! - [`DisplayCommandChannel`]: USB → Display (on/off/reset)
|
||||
//! - [`StatusWatch`]: Radio → Display (observable radio state)
|
||||
|
||||
use embassy_sync::blocking_mutex::raw::CriticalSectionRawMutex;
|
||||
use embassy_sync::channel::Channel;
|
||||
use embassy_sync::watch::Watch;
|
||||
|
||||
use crate::protocol::{Command, RadioConfig, Response};
|
||||
|
||||
/// Display commands routed from usb_task to display_task.
|
||||
#[derive(Debug, Clone, Copy, defmt::Format)]
|
||||
pub enum DisplayCommand {
|
||||
On,
|
||||
Off,
|
||||
Reset,
|
||||
}
|
||||
|
||||
/// USB-to-display command channel (depth 4: display commands are rare events).
|
||||
pub type DisplayCommandChannel = Channel<CriticalSectionRawMutex, DisplayCommand, 4>;
|
||||
|
||||
/// Host-to-radio command channel (depth 16: buffer bursty host command sequences).
|
||||
pub type CommandChannel = Channel<CriticalSectionRawMutex, Command, 16>;
|
||||
|
||||
/// Radio-to-host response channel (depth 32: buffer RX packets while USB writes).
|
||||
pub type ResponseChannel = Channel<CriticalSectionRawMutex, Response, 32>;
|
||||
|
||||
/// Observable radio status for the display task (2 receivers max).
|
||||
/// Display always sees the latest state — intermediate updates may be
|
||||
/// skipped, which is fine for a status display.
|
||||
pub type StatusWatch = Watch<CriticalSectionRawMutex, RadioStatus, 2>;
|
||||
|
||||
/// Current radio state exposed to observers (e.g. display).
|
||||
#[derive(Debug, Clone, defmt::Format)]
|
||||
pub struct RadioStatus {
|
||||
pub state: RadioState,
|
||||
pub config: Option<RadioConfig>,
|
||||
pub rx_count: u32,
|
||||
pub tx_count: u32,
|
||||
pub last_rssi: Option<i16>,
|
||||
pub last_snr: Option<i16>,
|
||||
}
|
||||
|
||||
/// Radio state machine states.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, defmt::Format)]
|
||||
pub enum RadioState {
|
||||
Idle,
|
||||
Receiving,
|
||||
Transmitting,
|
||||
}
|
||||
|
||||
impl Default for RadioStatus {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
state: RadioState::Idle,
|
||||
config: None,
|
||||
rx_count: 0,
|
||||
tx_count: 0,
|
||||
last_rssi: None,
|
||||
last_snr: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
//! OLED display driver with radio status dashboard.
|
||||
|
||||
mod render;
|
||||
mod task;
|
||||
pub use task::display_task;
|
||||
@@ -0,0 +1,398 @@
|
||||
use core::fmt::Write;
|
||||
|
||||
use embedded_graphics::mono_font::ascii::{FONT_6X10, FONT_9X15_BOLD};
|
||||
use embedded_graphics::mono_font::MonoTextStyle;
|
||||
use embedded_graphics::pixelcolor::BinaryColor;
|
||||
use embedded_graphics::prelude::*;
|
||||
use embedded_graphics::primitives::{Line, PrimitiveStyle, Rectangle};
|
||||
use embedded_graphics::text::renderer::CharacterStyle;
|
||||
use embedded_graphics::text::{Alignment, Text};
|
||||
use heapless::String;
|
||||
|
||||
use crate::channel::{RadioState, RadioStatus};
|
||||
use crate::protocol::Bandwidth;
|
||||
|
||||
/// RSSI ring buffer length — supports displays up to 256px wide (2px per bar).
|
||||
pub const RSSI_HISTORY_LEN: usize = 128;
|
||||
|
||||
// Font metrics (FONT_6X10)
|
||||
const CHAR_W: i32 = 6;
|
||||
const FONT_H: i32 = 10;
|
||||
|
||||
// Mode box: 2 chars + 1px padding each side
|
||||
const MODE_BOX_W: i32 = 2 * CHAR_W + 2; // 14px
|
||||
|
||||
// RSSI mapping range (dBm)
|
||||
const RSSI_MIN: i16 = -120;
|
||||
const RSSI_MAX: i16 = 0;
|
||||
|
||||
/// Static board identity info for display rendering.
|
||||
pub struct BoardInfo<'a> {
|
||||
pub name: &'a str,
|
||||
pub version: &'a str,
|
||||
pub mac: &'a str,
|
||||
}
|
||||
|
||||
/// Render the active dashboard (shown when radio is in RX or TX mode).
|
||||
pub fn dashboard(
|
||||
target: &mut impl DrawTarget<Color = BinaryColor>,
|
||||
status: &RadioStatus,
|
||||
rssi_history: &[i16; RSSI_HISTORY_LEN],
|
||||
tx_history: &[bool; RSSI_HISTORY_LEN],
|
||||
rssi_count: usize,
|
||||
current_slot_rssi: i16,
|
||||
current_slot_tx: bool,
|
||||
board: &BoardInfo,
|
||||
) {
|
||||
let bb = target.bounding_box();
|
||||
let w = bb.size.width as i32;
|
||||
let h = bb.size.height as i32;
|
||||
let title_x = MODE_BOX_W + 1;
|
||||
let title_w = w - title_x;
|
||||
let header_h = 2 * FONT_H;
|
||||
let sep1_y = header_h + 3; // 3px gap below header text (clears descenders)
|
||||
let info_y = sep1_y + 1; // 1px line, info text tight below separator
|
||||
let sep2_y = info_y + 2 * FONT_H + 3; // 3px gap below info text (clears descenders)
|
||||
let spark_top = sep2_y + 2; // 1px line + 1px gap above graph
|
||||
let spark_h = h - spark_top;
|
||||
let visible_bars = ((w / 2) as usize).min(RSSI_HISTORY_LEN);
|
||||
|
||||
let _ = target.clear(BinaryColor::Off);
|
||||
let style = MonoTextStyle::new(&FONT_6X10, BinaryColor::On);
|
||||
let mut buf: String<32> = String::new();
|
||||
|
||||
// ── Mode box (left column, 2 rows tall) ─────────────────────────
|
||||
let fill = PrimitiveStyle::with_fill(BinaryColor::On);
|
||||
let mut inv_style = MonoTextStyle::new(&FONT_6X10, BinaryColor::Off);
|
||||
inv_style.set_background_color(Some(BinaryColor::On));
|
||||
|
||||
match status.state {
|
||||
RadioState::Receiving => {
|
||||
// "RX" inverted on row 0
|
||||
Rectangle::new(Point::new(0, 0), Size::new(MODE_BOX_W as u32, FONT_H as u32))
|
||||
.into_styled(fill)
|
||||
.draw(target)
|
||||
.ok();
|
||||
Text::new("RX", Point::new(1, FONT_H - 1), inv_style)
|
||||
.draw(target)
|
||||
.ok();
|
||||
}
|
||||
RadioState::Transmitting => {
|
||||
// "TX" inverted on row 1
|
||||
Rectangle::new(
|
||||
Point::new(0, FONT_H),
|
||||
Size::new(MODE_BOX_W as u32, FONT_H as u32),
|
||||
)
|
||||
.into_styled(fill)
|
||||
.draw(target)
|
||||
.ok();
|
||||
Text::new("TX", Point::new(1, 2 * FONT_H - 1), inv_style)
|
||||
.draw(target)
|
||||
.ok();
|
||||
}
|
||||
RadioState::Idle => {} // Not shown on active screen
|
||||
}
|
||||
|
||||
// Vertical separator between mode box and title box
|
||||
Line::new(Point::new(MODE_BOX_W, 0), Point::new(MODE_BOX_W, sep1_y))
|
||||
.into_styled(PrimitiveStyle::with_stroke(BinaryColor::On, 1))
|
||||
.draw(target)
|
||||
.ok();
|
||||
|
||||
// ── Title box (right of mode box) ───────────────────────────────
|
||||
let title_center_x = title_x + title_w / 2;
|
||||
|
||||
// Row 0: "DongLoRa v{version}"
|
||||
buf.clear();
|
||||
let _ = write!(buf, "DongLoRa v{}", board.version);
|
||||
Text::with_alignment(
|
||||
&buf,
|
||||
Point::new(title_center_x, FONT_H - 1),
|
||||
style,
|
||||
Alignment::Center,
|
||||
)
|
||||
.draw(target)
|
||||
.ok();
|
||||
|
||||
// Row 1: radio settings "{freq}/{bw}/{sf}/{cr}"
|
||||
if let Some(cfg) = status.config {
|
||||
buf.clear();
|
||||
let freq_mhz = cfg.freq_hz / 1_000_000;
|
||||
let freq_khz = (cfg.freq_hz % 1_000_000) / 1_000;
|
||||
let bw_str = match cfg.bw {
|
||||
Bandwidth::Khz7 => "7.8",
|
||||
Bandwidth::Khz10 => "10.4",
|
||||
Bandwidth::Khz15 => "15.6",
|
||||
Bandwidth::Khz20 => "20.8",
|
||||
Bandwidth::Khz31 => "31.2",
|
||||
Bandwidth::Khz41 => "41.7",
|
||||
Bandwidth::Khz62 => "62.5",
|
||||
Bandwidth::Khz125 => "125",
|
||||
Bandwidth::Khz250 => "250",
|
||||
Bandwidth::Khz500 => "500",
|
||||
};
|
||||
let _ = write!(
|
||||
buf,
|
||||
"{}.{:03}/{}/{}/{}",
|
||||
freq_mhz, freq_khz, bw_str, cfg.sf, cfg.cr
|
||||
);
|
||||
Text::with_alignment(
|
||||
&buf,
|
||||
Point::new(title_center_x, 2 * FONT_H - 1),
|
||||
style,
|
||||
Alignment::Center,
|
||||
)
|
||||
.draw(target)
|
||||
.ok();
|
||||
}
|
||||
|
||||
// ── First separator line ────────────────────────────────────────
|
||||
Line::new(Point::new(0, sep1_y), Point::new(w - 1, sep1_y))
|
||||
.into_styled(PrimitiveStyle::with_stroke(BinaryColor::On, 1))
|
||||
.draw(target)
|
||||
.ok();
|
||||
|
||||
// ── Info rows (centered) ────────────────────────────────────────
|
||||
let center_x = w / 2;
|
||||
|
||||
// Row 2: packet counters
|
||||
buf.clear();
|
||||
let _ = write!(
|
||||
buf,
|
||||
"RX:{} TX:{}",
|
||||
compact_count(status.rx_count),
|
||||
compact_count(status.tx_count)
|
||||
);
|
||||
Text::with_alignment(
|
||||
&buf,
|
||||
Point::new(center_x, info_y + FONT_H - 1),
|
||||
style,
|
||||
Alignment::Center,
|
||||
)
|
||||
.draw(target)
|
||||
.ok();
|
||||
|
||||
// Row 3: RSSI + SNR
|
||||
buf.clear();
|
||||
match (status.last_rssi, status.last_snr) {
|
||||
(Some(rssi), Some(snr)) => {
|
||||
let _ = write!(buf, "RSSI:{}dBm SNR:{}dB", rssi, snr);
|
||||
}
|
||||
(Some(rssi), None) => {
|
||||
let _ = write!(buf, "RSSI:{}dBm", rssi);
|
||||
}
|
||||
_ => {
|
||||
let _ = write!(buf, "No signal");
|
||||
}
|
||||
}
|
||||
Text::with_alignment(
|
||||
&buf,
|
||||
Point::new(center_x, info_y + 2 * FONT_H - 1),
|
||||
style,
|
||||
Alignment::Center,
|
||||
)
|
||||
.draw(target)
|
||||
.ok();
|
||||
|
||||
// ── Second separator line ───────────────────────────────────────
|
||||
Line::new(Point::new(0, sep2_y), Point::new(w - 1, sep2_y))
|
||||
.into_styled(PrimitiveStyle::with_stroke(BinaryColor::On, 1))
|
||||
.draw(target)
|
||||
.ok();
|
||||
|
||||
// ── Sparkline bar graph ─────────────────────────────────────────
|
||||
if spark_h > 0 {
|
||||
rssi_sparkline(
|
||||
target,
|
||||
rssi_history,
|
||||
tx_history,
|
||||
rssi_count,
|
||||
current_slot_rssi,
|
||||
current_slot_tx,
|
||||
spark_top,
|
||||
spark_h,
|
||||
visible_bars,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Render the splash/waiting screen (shown when idle or no config).
|
||||
pub fn splash(
|
||||
target: &mut impl DrawTarget<Color = BinaryColor>,
|
||||
board: &BoardInfo,
|
||||
) {
|
||||
let bb = target.bounding_box();
|
||||
let w = bb.size.width as i32;
|
||||
|
||||
let _ = target.clear(BinaryColor::Off);
|
||||
let style = MonoTextStyle::new(&FONT_6X10, BinaryColor::On);
|
||||
let title_style = MonoTextStyle::new(&FONT_9X15_BOLD, BinaryColor::On);
|
||||
let mut buf: String<32> = String::new();
|
||||
|
||||
let center_x = w / 2;
|
||||
|
||||
// Row 1: "DongLoRa" bold left, version small right
|
||||
Text::new("DongLoRa", Point::new(4, 15), title_style)
|
||||
.draw(target)
|
||||
.ok();
|
||||
buf.clear();
|
||||
let _ = write!(buf, "v{}", board.version);
|
||||
Text::with_alignment(&buf, Point::new(w - 2, 15), style, Alignment::Right)
|
||||
.draw(target)
|
||||
.ok();
|
||||
|
||||
// Row 2: board name
|
||||
Text::with_alignment(
|
||||
board.name,
|
||||
Point::new(center_x, 28),
|
||||
style,
|
||||
Alignment::Center,
|
||||
)
|
||||
.draw(target)
|
||||
.ok();
|
||||
|
||||
// Row 3: MAC address
|
||||
Text::with_alignment(
|
||||
board.mac,
|
||||
Point::new(center_x, 41),
|
||||
style,
|
||||
Alignment::Center,
|
||||
)
|
||||
.draw(target)
|
||||
.ok();
|
||||
|
||||
// Row 4: status
|
||||
Text::with_alignment(
|
||||
"Waiting for host...",
|
||||
Point::new(center_x, 54),
|
||||
style,
|
||||
Alignment::Center,
|
||||
)
|
||||
.draw(target)
|
||||
.ok();
|
||||
}
|
||||
|
||||
/// Format a u32 count compactly for display.
|
||||
fn compact_count(n: u32) -> String<10> {
|
||||
let mut s: String<10> = String::new();
|
||||
if n > 9_999_999 {
|
||||
let _ = write!(s, "{}M", n / 1_000_000);
|
||||
} else if n > 999_999 {
|
||||
let _ = write!(s, "{}k", n / 1_000);
|
||||
} else {
|
||||
let _ = write!(s, "{}", n);
|
||||
}
|
||||
s
|
||||
}
|
||||
|
||||
/// Render the RSSI history as a bar-chart sparkline.
|
||||
///
|
||||
/// The current (uncommitted) slot is rendered at the rightmost position
|
||||
/// so that incoming packets appear on the graph immediately.
|
||||
fn rssi_sparkline(
|
||||
target: &mut impl DrawTarget<Color = BinaryColor>,
|
||||
history: &[i16; RSSI_HISTORY_LEN],
|
||||
tx_history: &[bool; RSSI_HISTORY_LEN],
|
||||
count: usize,
|
||||
current_rssi: i16,
|
||||
current_tx: bool,
|
||||
spark_top: i32,
|
||||
spark_h: i32,
|
||||
visible_bars: usize,
|
||||
) {
|
||||
let live = current_rssi > RSSI_MIN || current_tx;
|
||||
let committed = count.min(RSSI_HISTORY_LEN);
|
||||
let hist_slots = if live {
|
||||
committed.min(visible_bars.saturating_sub(1))
|
||||
} else {
|
||||
committed.min(visible_bars)
|
||||
};
|
||||
let total = hist_slots + if live { 1 } else { 0 };
|
||||
|
||||
if total == 0 {
|
||||
return;
|
||||
}
|
||||
|
||||
let fill = PrimitiveStyle::with_fill(BinaryColor::On);
|
||||
|
||||
// Draw committed history
|
||||
for i in 0..hist_slots {
|
||||
let idx = if count <= RSSI_HISTORY_LEN {
|
||||
// Buffer hasn't wrapped: slots occupy indices 0..committed-1.
|
||||
// Skip oldest entries that don't fit on screen.
|
||||
let effective_bars = if live { visible_bars - 1 } else { visible_bars };
|
||||
i + committed.saturating_sub(effective_bars)
|
||||
} else {
|
||||
let start = count - RSSI_HISTORY_LEN;
|
||||
let skip = RSSI_HISTORY_LEN.saturating_sub(if live {
|
||||
visible_bars - 1
|
||||
} else {
|
||||
visible_bars
|
||||
});
|
||||
(start + skip + i) % RSSI_HISTORY_LEN
|
||||
};
|
||||
let is_tx = tx_history[idx];
|
||||
let rssi = history[idx];
|
||||
|
||||
if let Some(bar_h) = bar_height(rssi, is_tx, spark_h) {
|
||||
let x = (visible_bars - total + i) as i32 * 2;
|
||||
draw_bar(target, x, bar_h, is_tx, spark_top, spark_h, &fill);
|
||||
}
|
||||
}
|
||||
|
||||
// Draw live (current) bar at the rightmost position
|
||||
if live {
|
||||
if let Some(bar_h) = bar_height(current_rssi, current_tx, spark_h) {
|
||||
let x = (visible_bars - 1) as i32 * 2;
|
||||
draw_bar(target, x, bar_h, current_tx, spark_top, spark_h, &fill);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Compute the pixel height for a sparkline bar, or None to skip.
|
||||
fn bar_height(rssi: i16, is_tx: bool, spark_h: i32) -> Option<i32> {
|
||||
let h = if rssi <= RSSI_MIN {
|
||||
if is_tx {
|
||||
spark_h / 3
|
||||
} else {
|
||||
return None;
|
||||
}
|
||||
} else {
|
||||
let clamped = rssi.clamp(RSSI_MIN, RSSI_MAX);
|
||||
((clamped - RSSI_MIN) as i32 * spark_h) / (RSSI_MAX - RSSI_MIN) as i32
|
||||
};
|
||||
if h == 0 { None } else { Some(h) }
|
||||
}
|
||||
|
||||
/// Draw a single sparkline bar (solid for RX, dotted for TX).
|
||||
fn draw_bar(
|
||||
target: &mut impl DrawTarget<Color = BinaryColor>,
|
||||
x: i32,
|
||||
bar_h: i32,
|
||||
is_tx: bool,
|
||||
spark_top: i32,
|
||||
spark_h: i32,
|
||||
fill: &PrimitiveStyle<BinaryColor>,
|
||||
) {
|
||||
let y = spark_top + spark_h - bar_h;
|
||||
if is_tx {
|
||||
for row in 0..bar_h {
|
||||
if row % 2 == 0 {
|
||||
Rectangle::new(Point::new(x, y + row), Size::new(2, 1))
|
||||
.into_styled(*fill)
|
||||
.draw(target)
|
||||
.ok();
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Rectangle::new(Point::new(x, y), Size::new(2, bar_h as u32))
|
||||
.into_styled(*fill)
|
||||
.draw(target)
|
||||
.ok();
|
||||
}
|
||||
}
|
||||
|
||||
/// Clear the display (display-off state).
|
||||
pub fn blank(target: &mut impl DrawTarget<Color = BinaryColor>) {
|
||||
let _ = target.clear(BinaryColor::Off);
|
||||
}
|
||||
@@ -0,0 +1,239 @@
|
||||
use embassy_executor::task;
|
||||
use embassy_futures::select::{select3, Either3};
|
||||
use embassy_time::Timer;
|
||||
use ssd1306::mode::DisplayConfigAsync;
|
||||
use ssd1306::prelude::DisplayRotation;
|
||||
use ssd1306::size::DisplaySize128x64;
|
||||
use ssd1306::{I2CDisplayInterface, Ssd1306Async};
|
||||
|
||||
use crate::board::DisplayParts;
|
||||
use crate::channel::{
|
||||
DisplayCommand, DisplayCommandChannel, RadioState, RadioStatus, StatusWatch,
|
||||
};
|
||||
|
||||
use crate::board::{Board, LoRaBoard};
|
||||
|
||||
use super::render::{self, RSSI_HISTORY_LEN};
|
||||
|
||||
const BOARD_NAME: &str = Board::NAME;
|
||||
|
||||
/// Duration per sparkline slot. 128 slots * 1s = ~2 minutes of history.
|
||||
const SPARK_SLOT_MS: u64 = 1000;
|
||||
|
||||
/// Sentinel: no packet received in this slot. Below SX1262 sensitivity
|
||||
/// floor (-120 dBm), so it cannot be confused with a real RSSI value.
|
||||
const NO_SIGNAL: i16 = -121;
|
||||
|
||||
struct DisplayState {
|
||||
rssi_history: [i16; RSSI_HISTORY_LEN],
|
||||
tx_history: [bool; RSSI_HISTORY_LEN],
|
||||
rssi_count: usize,
|
||||
current_slot_rssi: i16,
|
||||
current_slot_tx: bool,
|
||||
display_on: bool,
|
||||
disconnected: bool,
|
||||
last_status: RadioStatus,
|
||||
}
|
||||
|
||||
impl DisplayState {
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
rssi_history: [NO_SIGNAL; RSSI_HISTORY_LEN],
|
||||
tx_history: [false; RSSI_HISTORY_LEN],
|
||||
rssi_count: 0,
|
||||
current_slot_rssi: NO_SIGNAL,
|
||||
current_slot_tx: false,
|
||||
display_on: true,
|
||||
disconnected: true,
|
||||
last_status: RadioStatus::default(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Record an RSSI sample in the current time slot (keep best).
|
||||
fn record_rssi(&mut self, rssi: i16) {
|
||||
if self.current_slot_rssi == NO_SIGNAL || rssi > self.current_slot_rssi {
|
||||
self.current_slot_rssi = rssi;
|
||||
}
|
||||
}
|
||||
|
||||
/// Mark the current time slot as having a transmit.
|
||||
fn record_tx(&mut self) {
|
||||
self.current_slot_tx = true;
|
||||
}
|
||||
|
||||
/// Advance to the next time slot, committing the current slot's data.
|
||||
fn advance_slot(&mut self) {
|
||||
let idx = self.rssi_count % RSSI_HISTORY_LEN;
|
||||
self.rssi_history[idx] = self.current_slot_rssi;
|
||||
self.tx_history[idx] = self.current_slot_tx;
|
||||
self.rssi_count += 1;
|
||||
self.current_slot_rssi = NO_SIGNAL;
|
||||
self.current_slot_tx = false;
|
||||
}
|
||||
|
||||
/// Whether the active dashboard should be shown (RX or TX mode).
|
||||
fn is_active(&self) -> bool {
|
||||
matches!(
|
||||
self.last_status.state,
|
||||
RadioState::Receiving | RadioState::Transmitting
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
#[task]
|
||||
pub async fn display_task(
|
||||
parts: DisplayParts,
|
||||
status: &'static StatusWatch,
|
||||
display_commands: &'static DisplayCommandChannel,
|
||||
) {
|
||||
// Format MAC address as "XX:XX:XX:XX:XX:XX"
|
||||
let mut mac_str: heapless::String<18> = heapless::String::new();
|
||||
let m = parts.mac;
|
||||
let _ = core::fmt::Write::write_fmt(
|
||||
&mut mac_str,
|
||||
format_args!(
|
||||
"{:02X}:{:02X}:{:02X}:{:02X}:{:02X}:{:02X}",
|
||||
m[0], m[1], m[2], m[3], m[4], m[5]
|
||||
),
|
||||
);
|
||||
|
||||
let board_info = render::BoardInfo {
|
||||
name: BOARD_NAME,
|
||||
version: env!("CARGO_PKG_VERSION"),
|
||||
mac: &mac_str,
|
||||
};
|
||||
|
||||
let interface = I2CDisplayInterface::new(parts.i2c);
|
||||
let mut display = Ssd1306Async::new(interface, DisplaySize128x64, DisplayRotation::Rotate0)
|
||||
.into_buffered_graphics_mode();
|
||||
|
||||
if display.init().await.is_err() {
|
||||
defmt::error!("SSD1306 init failed");
|
||||
return;
|
||||
}
|
||||
if display
|
||||
.set_brightness(ssd1306::prelude::Brightness::BRIGHTEST)
|
||||
.await
|
||||
.is_err()
|
||||
{
|
||||
defmt::warn!("display brightness set failed");
|
||||
}
|
||||
|
||||
let mut state = DisplayState::new();
|
||||
let Some(mut receiver) = status.receiver() else {
|
||||
defmt::error!("no watch receiver available for display");
|
||||
return;
|
||||
};
|
||||
|
||||
// Show splash/waiting screen
|
||||
render_splash(&mut display, &board_info).await;
|
||||
|
||||
loop {
|
||||
match select3(
|
||||
receiver.changed(),
|
||||
display_commands.receive(),
|
||||
Timer::after_millis(SPARK_SLOT_MS),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Either3::First(radio_status) => {
|
||||
if state.disconnected {
|
||||
continue;
|
||||
}
|
||||
if let Some(rssi) = radio_status.last_rssi {
|
||||
if radio_status.rx_count != state.last_status.rx_count {
|
||||
state.record_rssi(rssi);
|
||||
}
|
||||
}
|
||||
if radio_status.tx_count != state.last_status.tx_count {
|
||||
state.record_tx();
|
||||
}
|
||||
state.last_status = radio_status;
|
||||
|
||||
if state.display_on {
|
||||
render_current(&mut display, &state, &board_info).await;
|
||||
}
|
||||
}
|
||||
Either3::Second(cmd) => match cmd {
|
||||
DisplayCommand::Off => {
|
||||
state.disconnected = false;
|
||||
state.display_on = false;
|
||||
render::blank(&mut display);
|
||||
let _ = display.flush().await;
|
||||
}
|
||||
DisplayCommand::On => {
|
||||
state.disconnected = false;
|
||||
state.display_on = true;
|
||||
if let Some(s) = receiver.try_get() {
|
||||
state.last_status = s;
|
||||
}
|
||||
render_current(&mut display, &state, &board_info).await;
|
||||
}
|
||||
DisplayCommand::Reset => {
|
||||
state = DisplayState::new();
|
||||
render_splash(&mut display, &board_info).await;
|
||||
}
|
||||
},
|
||||
Either3::Third(()) => {
|
||||
// Timer tick: advance sparkline slot
|
||||
state.advance_slot();
|
||||
if state.display_on && state.is_active() {
|
||||
render_dashboard(&mut display, &state, &board_info).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The concrete display type is verbose but avoids making this generic
|
||||
// over both DrawTarget and the SSD1306-specific flush method.
|
||||
type Display<I> = ssd1306::Ssd1306Async<
|
||||
I,
|
||||
DisplaySize128x64,
|
||||
ssd1306::mode::BufferedGraphicsModeAsync<DisplaySize128x64>,
|
||||
>;
|
||||
|
||||
/// Render the appropriate screen (active dashboard or splash) and flush.
|
||||
async fn render_current<I>(
|
||||
display: &mut Display<I>,
|
||||
state: &DisplayState,
|
||||
board: &render::BoardInfo<'_>,
|
||||
) where
|
||||
I: display_interface::AsyncWriteOnlyDataCommand,
|
||||
{
|
||||
if state.is_active() {
|
||||
render_dashboard(display, state, board).await;
|
||||
} else {
|
||||
render_splash(display, board).await;
|
||||
}
|
||||
}
|
||||
|
||||
/// Render the active dashboard and flush.
|
||||
async fn render_dashboard<I>(
|
||||
display: &mut Display<I>,
|
||||
state: &DisplayState,
|
||||
board: &render::BoardInfo<'_>,
|
||||
) where
|
||||
I: display_interface::AsyncWriteOnlyDataCommand,
|
||||
{
|
||||
render::dashboard(
|
||||
display,
|
||||
&state.last_status,
|
||||
&state.rssi_history,
|
||||
&state.tx_history,
|
||||
state.rssi_count,
|
||||
state.current_slot_rssi,
|
||||
state.current_slot_tx,
|
||||
board,
|
||||
);
|
||||
let _ = display.flush().await;
|
||||
}
|
||||
|
||||
/// Render the splash/waiting screen and flush.
|
||||
async fn render_splash<I>(display: &mut Display<I>, board: &render::BoardInfo<'_>)
|
||||
where
|
||||
I: display_interface::AsyncWriteOnlyDataCommand,
|
||||
{
|
||||
render::splash(display, board);
|
||||
let _ = display.flush().await;
|
||||
}
|
||||
+99
@@ -0,0 +1,99 @@
|
||||
//! DongLoRa firmware — transparent LoRa radio over USB.
|
||||
//!
|
||||
//! Three async tasks communicate via static channels:
|
||||
//!
|
||||
//! ```text
|
||||
//! usb_task ──Command──► radio_task ──► SX1262
|
||||
//! ◄──Response── │
|
||||
//! StatusWatch
|
||||
//! ▼
|
||||
//! display_task (optional)
|
||||
//! ```
|
||||
//!
|
||||
//! The host drives everything. The radio idles until commanded.
|
||||
|
||||
#![cfg_attr(not(test), no_std)]
|
||||
#![cfg_attr(not(test), no_main)]
|
||||
|
||||
#[cfg(not(test))]
|
||||
mod board;
|
||||
#[cfg(not(test))]
|
||||
mod channel;
|
||||
#[cfg(not(test))]
|
||||
mod display;
|
||||
mod protocol;
|
||||
#[cfg(not(test))]
|
||||
mod radio;
|
||||
#[cfg(not(test))]
|
||||
mod usb;
|
||||
|
||||
#[cfg(not(test))]
|
||||
use embassy_executor::Spawner;
|
||||
|
||||
#[cfg(not(test))]
|
||||
use crate::board::LoRaBoard;
|
||||
#[cfg(not(test))]
|
||||
use crate::channel::{CommandChannel, DisplayCommandChannel, ResponseChannel, StatusWatch};
|
||||
|
||||
#[cfg(not(test))]
|
||||
cfg_if::cfg_if! {
|
||||
if #[cfg(feature = "rak_wisblock_4631")] {
|
||||
use panic_probe as _;
|
||||
} else if #[cfg(any(feature = "heltec_v3", feature = "heltec_v4"))] {
|
||||
use esp_backtrace as _;
|
||||
use esp_println as _;
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(test))]
|
||||
static COMMANDS: CommandChannel = CommandChannel::new();
|
||||
#[cfg(not(test))]
|
||||
static RESPONSES: ResponseChannel = ResponseChannel::new();
|
||||
#[cfg(not(test))]
|
||||
static STATUS: StatusWatch = StatusWatch::new();
|
||||
#[cfg(not(test))]
|
||||
static DISPLAY_COMMANDS: DisplayCommandChannel = DisplayCommandChannel::new();
|
||||
|
||||
#[cfg(not(test))]
|
||||
cfg_if::cfg_if! {
|
||||
if #[cfg(feature = "rak_wisblock_4631")] {
|
||||
#[embassy_executor::main]
|
||||
async fn main(spawner: Spawner) {
|
||||
run(spawner).await;
|
||||
}
|
||||
} else if #[cfg(any(feature = "heltec_v3", feature = "heltec_v4"))] {
|
||||
#[esp_rtos::main]
|
||||
async fn main(spawner: Spawner) {
|
||||
run(spawner).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(test))]
|
||||
async fn run(spawner: Spawner) {
|
||||
let board = <board::Board as LoRaBoard>::init();
|
||||
let mac = board::Board::mac_address();
|
||||
let (radio, usb, display) = board.into_parts();
|
||||
|
||||
let has_display = display.is_some();
|
||||
|
||||
spawner
|
||||
.spawn(radio::radio_task(radio, &COMMANDS, &RESPONSES, &STATUS))
|
||||
.expect("spawn radio_task");
|
||||
spawner
|
||||
.spawn(usb::usb_task(
|
||||
usb,
|
||||
&COMMANDS,
|
||||
&RESPONSES,
|
||||
&DISPLAY_COMMANDS,
|
||||
has_display,
|
||||
mac,
|
||||
))
|
||||
.expect("spawn usb_task");
|
||||
|
||||
if let Some(dp) = display {
|
||||
spawner
|
||||
.spawn(display::display_task(dp, &STATUS, &DISPLAY_COMMANDS))
|
||||
.expect("spawn display_task");
|
||||
}
|
||||
}
|
||||
+661
@@ -0,0 +1,661 @@
|
||||
//! Wire protocol types and fixed-size little-endian serialization.
|
||||
//!
|
||||
//! Every integer is fixed-width LE. No varints, no zigzag.
|
||||
//! See `docs/PROTOCOL.md` for the complete specification.
|
||||
|
||||
use heapless::Vec;
|
||||
|
||||
/// Maximum LoRa payload size in bytes.
|
||||
pub const MAX_PAYLOAD: usize = 256;
|
||||
|
||||
/// RadioConfig wire size (fixed).
|
||||
pub const RADIO_CONFIG_SIZE: usize = 13;
|
||||
|
||||
/// Sentinel value for `tx_power_dbm`: use the board's maximum TX power.
|
||||
pub const TX_POWER_MAX: i8 = i8::MIN; // -128 on the wire
|
||||
|
||||
/// Sentinel value for `preamble_len`: use the firmware default (16 symbols).
|
||||
pub const PREAMBLE_DEFAULT: u16 = 0;
|
||||
|
||||
|
||||
/// LoRa signal bandwidth.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
#[cfg_attr(not(test), derive(defmt::Format))]
|
||||
#[repr(u8)]
|
||||
pub enum Bandwidth {
|
||||
Khz7 = 0,
|
||||
Khz10 = 1,
|
||||
Khz15 = 2,
|
||||
Khz20 = 3,
|
||||
Khz31 = 4,
|
||||
Khz41 = 5,
|
||||
Khz62 = 6,
|
||||
Khz125 = 7,
|
||||
Khz250 = 8,
|
||||
Khz500 = 9,
|
||||
}
|
||||
|
||||
impl Bandwidth {
|
||||
fn from_u8(v: u8) -> Option<Self> {
|
||||
match v {
|
||||
0 => Some(Self::Khz7),
|
||||
1 => Some(Self::Khz10),
|
||||
2 => Some(Self::Khz15),
|
||||
3 => Some(Self::Khz20),
|
||||
4 => Some(Self::Khz31),
|
||||
5 => Some(Self::Khz41),
|
||||
6 => Some(Self::Khz62),
|
||||
7 => Some(Self::Khz125),
|
||||
8 => Some(Self::Khz250),
|
||||
9 => Some(Self::Khz500),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Complete LoRa radio configuration.
|
||||
///
|
||||
/// Wire layout (13 bytes, all little-endian):
|
||||
/// ```text
|
||||
/// [freq_hz:4] [bw:1] [sf:1] [cr:1] [sync_word:2] [tx_power_dbm:1] [preamble_len:2] [cad:1]
|
||||
/// ```
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
#[cfg_attr(not(test), derive(defmt::Format))]
|
||||
pub struct RadioConfig {
|
||||
/// Frequency in Hz (150_000_000 - 960_000_000 for SX1262).
|
||||
pub freq_hz: u32,
|
||||
pub bw: Bandwidth,
|
||||
/// Spreading factor (5-12).
|
||||
pub sf: u8,
|
||||
/// Coding rate denominator (5-8). E.g. 5 = CR 4/5, 8 = CR 4/8.
|
||||
pub cr: u8,
|
||||
pub sync_word: u16,
|
||||
/// Transmit power in dBm. Set to [`TX_POWER_MAX`] (-128) for the
|
||||
/// board's maximum.
|
||||
pub tx_power_dbm: i8,
|
||||
/// Preamble length in symbols. Set to [`PREAMBLE_DEFAULT`] (0) for
|
||||
/// the firmware default (16 symbols). Valid range: 6–65535.
|
||||
pub preamble_len: u16,
|
||||
/// Channel Activity Detection (listen-before-talk) before TX.
|
||||
/// 0 = disabled, non-zero = enabled. Default: 1 (enabled).
|
||||
pub cad: u8,
|
||||
}
|
||||
|
||||
impl RadioConfig {
|
||||
/// Validate fields against hardware limits.
|
||||
pub fn validate(&self, power_range: (i8, i8)) -> Result<(), &'static str> {
|
||||
if !(150_000_000..=960_000_000).contains(&self.freq_hz) {
|
||||
return Err("frequency out of range (150-960 MHz)");
|
||||
}
|
||||
if !(5..=12).contains(&self.sf) {
|
||||
return Err("spreading factor out of range (5-12)");
|
||||
}
|
||||
if !(5..=8).contains(&self.cr) {
|
||||
return Err("coding rate out of range (5-8)");
|
||||
}
|
||||
if self.tx_power_dbm != TX_POWER_MAX
|
||||
&& !(power_range.0..=power_range.1).contains(&self.tx_power_dbm)
|
||||
{
|
||||
return Err("TX power out of range for this board");
|
||||
}
|
||||
if self.preamble_len != PREAMBLE_DEFAULT && self.preamble_len < 6 {
|
||||
return Err("preamble length too short (min 6)");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Resolve sentinel values to concrete defaults.
|
||||
pub fn resolve(mut self, power_range: (i8, i8)) -> Self {
|
||||
if self.tx_power_dbm == TX_POWER_MAX {
|
||||
self.tx_power_dbm = power_range.1;
|
||||
}
|
||||
if self.preamble_len == PREAMBLE_DEFAULT {
|
||||
self.preamble_len = 16;
|
||||
}
|
||||
self
|
||||
}
|
||||
|
||||
/// Whether CAD (listen-before-talk) is enabled.
|
||||
pub fn cad_enabled(&self) -> bool {
|
||||
self.cad != 0
|
||||
}
|
||||
|
||||
/// Serialize to fixed-size LE bytes. Returns number of bytes written (always 13).
|
||||
pub fn write_to(self, buf: &mut [u8]) -> usize {
|
||||
buf[0..4].copy_from_slice(&self.freq_hz.to_le_bytes());
|
||||
buf[4] = self.bw as u8;
|
||||
buf[5] = self.sf;
|
||||
buf[6] = self.cr;
|
||||
buf[7..9].copy_from_slice(&self.sync_word.to_le_bytes());
|
||||
buf[9] = self.tx_power_dbm as u8;
|
||||
buf[10..12].copy_from_slice(&self.preamble_len.to_le_bytes());
|
||||
buf[12] = self.cad;
|
||||
RADIO_CONFIG_SIZE
|
||||
}
|
||||
|
||||
/// Deserialize from fixed-size LE bytes.
|
||||
pub fn from_bytes(buf: &[u8]) -> Option<Self> {
|
||||
if buf.len() < RADIO_CONFIG_SIZE {
|
||||
return None;
|
||||
}
|
||||
Some(Self {
|
||||
freq_hz: u32::from_le_bytes(buf[0..4].try_into().ok()?),
|
||||
bw: Bandwidth::from_u8(buf[4])?,
|
||||
sf: buf[5],
|
||||
cr: buf[6],
|
||||
sync_word: u16::from_le_bytes(buf[7..9].try_into().ok()?),
|
||||
tx_power_dbm: buf[9] as i8,
|
||||
preamble_len: u16::from_le_bytes(buf[10..12].try_into().ok()?),
|
||||
cad: buf[12],
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Host → firmware commands.
|
||||
#[allow(clippy::large_enum_variant)]
|
||||
#[derive(Debug, PartialEq)]
|
||||
pub enum Command {
|
||||
Ping,
|
||||
GetConfig,
|
||||
SetConfig(RadioConfig),
|
||||
StartRx,
|
||||
StopRx,
|
||||
Transmit {
|
||||
config: Option<RadioConfig>,
|
||||
payload: Vec<u8, MAX_PAYLOAD>,
|
||||
},
|
||||
DisplayOn,
|
||||
DisplayOff,
|
||||
GetMac,
|
||||
}
|
||||
|
||||
impl Command {
|
||||
/// Deserialize from a COBS-decoded frame.
|
||||
pub fn from_bytes(buf: &[u8]) -> Option<Self> {
|
||||
let tag = *buf.first()?;
|
||||
let rest = &buf[1..];
|
||||
match tag {
|
||||
0 => Some(Self::Ping),
|
||||
1 => Some(Self::GetConfig),
|
||||
2 => Some(Self::SetConfig(RadioConfig::from_bytes(rest)?)),
|
||||
3 => Some(Self::StartRx),
|
||||
4 => Some(Self::StopRx),
|
||||
5 => {
|
||||
// Transmit: has_config:u8 + [RadioConfig if 1] + len:u16 LE + payload
|
||||
if rest.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let (config, pos) = if rest[0] == 0 {
|
||||
(None, 1)
|
||||
} else if rest[0] == 1 && rest.len() > RADIO_CONFIG_SIZE {
|
||||
(Some(RadioConfig::from_bytes(&rest[1..])?), 1 + RADIO_CONFIG_SIZE)
|
||||
} else {
|
||||
return None;
|
||||
};
|
||||
if rest.len() < pos + 2 {
|
||||
return None;
|
||||
}
|
||||
let len = u16::from_le_bytes(rest[pos..pos + 2].try_into().ok()?) as usize;
|
||||
let data_start = pos + 2;
|
||||
if rest.len() < data_start + len {
|
||||
return None;
|
||||
}
|
||||
let mut payload = Vec::new();
|
||||
let _ = payload.extend_from_slice(&rest[data_start..data_start + len]);
|
||||
Some(Self::Transmit { config, payload })
|
||||
}
|
||||
6 => Some(Self::DisplayOn),
|
||||
7 => Some(Self::DisplayOff),
|
||||
8 => Some(Self::GetMac),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Firmware → host responses.
|
||||
#[allow(clippy::large_enum_variant)]
|
||||
#[derive(Debug, PartialEq)]
|
||||
pub enum Response {
|
||||
Pong,
|
||||
Config(RadioConfig),
|
||||
RxPacket {
|
||||
rssi: i16,
|
||||
snr: i16,
|
||||
payload: Vec<u8, MAX_PAYLOAD>,
|
||||
},
|
||||
TxDone,
|
||||
Ok,
|
||||
Error(ErrorCode),
|
||||
MacAddress([u8; 6]),
|
||||
}
|
||||
|
||||
impl Response {
|
||||
/// Serialize to fixed-size LE bytes. Returns number of bytes written.
|
||||
pub fn write_to(self, buf: &mut [u8]) -> usize {
|
||||
match self {
|
||||
Self::Pong => {
|
||||
buf[0] = 0;
|
||||
1
|
||||
}
|
||||
Self::Config(cfg) => {
|
||||
buf[0] = 1;
|
||||
1 + cfg.write_to(&mut buf[1..])
|
||||
}
|
||||
Self::RxPacket { rssi, snr, payload } => {
|
||||
buf[0] = 2;
|
||||
buf[1..3].copy_from_slice(&rssi.to_le_bytes());
|
||||
buf[3..5].copy_from_slice(&snr.to_le_bytes());
|
||||
buf[5..7].copy_from_slice(&(payload.len() as u16).to_le_bytes());
|
||||
buf[7..7 + payload.len()].copy_from_slice(&payload);
|
||||
7 + payload.len()
|
||||
}
|
||||
Self::TxDone => {
|
||||
buf[0] = 3;
|
||||
1
|
||||
}
|
||||
Self::Ok => {
|
||||
buf[0] = 4;
|
||||
1
|
||||
}
|
||||
Self::Error(code) => {
|
||||
buf[0] = 5;
|
||||
buf[1] = code as u8;
|
||||
2
|
||||
}
|
||||
Self::MacAddress(mac) => {
|
||||
buf[0] = 6;
|
||||
buf[1..7].copy_from_slice(&mac);
|
||||
7
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Error codes reported to the host.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
#[cfg_attr(not(test), derive(defmt::Format))]
|
||||
#[repr(u8)]
|
||||
#[allow(dead_code)] // CrcError reserved per PROTOCOL.md (error code 3)
|
||||
pub enum ErrorCode {
|
||||
InvalidConfig = 0,
|
||||
RadioBusy = 1,
|
||||
TxTimeout = 2,
|
||||
CrcError = 3,
|
||||
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,
|
||||
};
|
||||
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::CrcError, 3),
|
||||
(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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
//! LoRa radio control via SX1262.
|
||||
|
||||
mod task;
|
||||
pub use task::radio_task;
|
||||
@@ -0,0 +1,376 @@
|
||||
//! LoRa radio task: SX1262 state machine driven by host commands.
|
||||
//!
|
||||
//! Owns the radio peripheral exclusively. Receives [`Command`]s from the USB
|
||||
//! task, drives the SX1262 via [`lora_phy`], and sends [`Response`]s back.
|
||||
//! Publishes [`RadioStatus`] to the display task via a watch channel.
|
||||
//!
|
||||
//! # State machine
|
||||
//!
|
||||
//! ```text
|
||||
//! Idle ──StartRx──► Receiving ──StopRx──► Idle
|
||||
//! │ │
|
||||
//! └──Transmit──► Transmitting ──TxDone──► (previous state)
|
||||
//! ```
|
||||
//!
|
||||
//! # Invariants
|
||||
//!
|
||||
//! - This task never panics. All errors are reported to the host or logged.
|
||||
//! - Config is validated before use (see [`RadioConfig::validate`]).
|
||||
|
||||
use defmt::{error, info, warn};
|
||||
use embassy_executor::task;
|
||||
use embassy_futures::select::{select, Either};
|
||||
use embassy_time::Delay;
|
||||
use lora_phy::mod_params::RadioError;
|
||||
use lora_phy::{LoRa, RxMode};
|
||||
|
||||
use crate::board::{Board, LoRaBoard, RadioDriver, RadioParts};
|
||||
use crate::channel::{CommandChannel, RadioState, RadioStatus, ResponseChannel, StatusWatch};
|
||||
use crate::protocol::{self, Command, ErrorCode, RadioConfig, Response};
|
||||
|
||||
const MAX_PAYLOAD: usize = protocol::MAX_PAYLOAD;
|
||||
// TX power range comes from the board trait: Board::TX_POWER_RANGE
|
||||
|
||||
type Radio = LoRa<RadioDriver, Delay>;
|
||||
|
||||
// ── Fixed LoRa radio parameters ─────────────────────────────────────
|
||||
//
|
||||
// These are intentionally not host-configurable. They are sensible
|
||||
// defaults for a general-purpose LoRa pipe. Making them configurable
|
||||
// would add protocol complexity for marginal benefit.
|
||||
|
||||
/// Explicit header mode (variable-length packets).
|
||||
const IMPLICIT_HEADER: bool = false;
|
||||
|
||||
/// Enable CRC on received/transmitted packets.
|
||||
const CRC_ON: bool = true;
|
||||
|
||||
/// Standard IQ polarity (not inverted).
|
||||
const IQ_INVERTED: bool = false;
|
||||
|
||||
// ── Task entry point ────────────────────────────────────────────────
|
||||
|
||||
#[task]
|
||||
pub async fn radio_task(
|
||||
parts: RadioParts,
|
||||
commands: &'static CommandChannel,
|
||||
responses: &'static ResponseChannel,
|
||||
status: &'static StatusWatch,
|
||||
) {
|
||||
let mut state = RadioStatus::default();
|
||||
status.sender().send(state.clone());
|
||||
|
||||
let mut lora = match LoRa::new(parts.driver, false, parts.delay).await {
|
||||
Ok(l) => l,
|
||||
Err(e) => {
|
||||
error!("radio init failed: {}", e);
|
||||
responses
|
||||
.send(Response::Error(ErrorCode::InvalidConfig))
|
||||
.await;
|
||||
// Radio is non-functional — respond to commands but can't do RF.
|
||||
loop {
|
||||
let cmd = commands.receive().await;
|
||||
if let Command::Ping = cmd {
|
||||
responses.send(Response::Pong).await;
|
||||
} else {
|
||||
responses
|
||||
.send(Response::Error(ErrorCode::InvalidConfig))
|
||||
.await;
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
info!("radio initialized");
|
||||
let mut rx_buf = [0u8; MAX_PAYLOAD];
|
||||
|
||||
loop {
|
||||
if state.state == RadioState::Receiving {
|
||||
// Defensive: config must be Some when Receiving. If not, recover.
|
||||
let cfg = match state.config {
|
||||
Some(c) => c,
|
||||
None => {
|
||||
warn!("BUG: receiving without config, returning to idle");
|
||||
state.state = RadioState::Idle;
|
||||
status.sender().send(state.clone());
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
match select(rx_once(&mut lora, &cfg, &mut rx_buf), commands.receive()).await {
|
||||
Either::First(rx_result) => match rx_result {
|
||||
Ok((len, pkt_status)) => {
|
||||
state.rx_count = state.rx_count.wrapping_add(1);
|
||||
state.last_rssi = Some(pkt_status.rssi);
|
||||
state.last_snr = Some(pkt_status.snr);
|
||||
status.sender().send(state.clone());
|
||||
|
||||
let copy_len = (len as usize).min(MAX_PAYLOAD);
|
||||
if (len as usize) > copy_len {
|
||||
warn!("RX payload truncated: {} > {}", len, MAX_PAYLOAD);
|
||||
}
|
||||
let mut payload = heapless::Vec::new();
|
||||
// copy_len <= MAX_PAYLOAD == Vec capacity, so this cannot fail.
|
||||
let _ = payload.extend_from_slice(&rx_buf[..copy_len]);
|
||||
|
||||
responses
|
||||
.send(Response::RxPacket {
|
||||
rssi: pkt_status.rssi,
|
||||
snr: pkt_status.snr,
|
||||
payload,
|
||||
})
|
||||
.await;
|
||||
|
||||
if let Err(e) = start_rx(&mut lora, &cfg).await {
|
||||
warn!("restart RX failed: {}", e);
|
||||
state.state = RadioState::Idle;
|
||||
status.sender().send(state.clone());
|
||||
responses
|
||||
.send(Response::Error(ErrorCode::RadioBusy))
|
||||
.await;
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
warn!("RX error: {}", e);
|
||||
if start_rx(&mut lora, &cfg).await.is_err() {
|
||||
state.state = RadioState::Idle;
|
||||
status.sender().send(state.clone());
|
||||
responses
|
||||
.send(Response::Error(ErrorCode::RadioBusy))
|
||||
.await;
|
||||
}
|
||||
}
|
||||
},
|
||||
Either::Second(cmd) => {
|
||||
handle_cmd(cmd, &mut lora, &mut state, responses, status).await;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
let cmd = commands.receive().await;
|
||||
handle_cmd(cmd, &mut lora, &mut state, responses, status).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Command handler ─────────────────────────────────────────────────
|
||||
|
||||
async fn handle_cmd(
|
||||
cmd: Command,
|
||||
lora: &mut Radio,
|
||||
state: &mut RadioStatus,
|
||||
responses: &ResponseChannel,
|
||||
status: &StatusWatch,
|
||||
) {
|
||||
match cmd {
|
||||
Command::Ping => {
|
||||
responses.send(Response::Pong).await;
|
||||
}
|
||||
Command::GetConfig => {
|
||||
if let Some(cfg) = state.config {
|
||||
responses.send(Response::Config(cfg)).await;
|
||||
} else {
|
||||
responses
|
||||
.send(Response::Error(ErrorCode::NotConfigured))
|
||||
.await;
|
||||
}
|
||||
}
|
||||
Command::SetConfig(cfg) => {
|
||||
if let Err(reason) = cfg.validate(Board::TX_POWER_RANGE) {
|
||||
warn!("SetConfig rejected: {}", reason);
|
||||
responses
|
||||
.send(Response::Error(ErrorCode::InvalidConfig))
|
||||
.await;
|
||||
} else {
|
||||
// Resolve TX_POWER_MAX sentinel to board's actual max
|
||||
state.config = Some(cfg.resolve(Board::TX_POWER_RANGE));
|
||||
status.sender().send(state.clone());
|
||||
responses.send(Response::Ok).await;
|
||||
}
|
||||
}
|
||||
Command::StartRx => {
|
||||
if let Some(cfg) = state.config {
|
||||
match start_rx(lora, &cfg).await {
|
||||
Ok(()) => {
|
||||
state.state = RadioState::Receiving;
|
||||
status.sender().send(state.clone());
|
||||
responses.send(Response::Ok).await;
|
||||
}
|
||||
Err(e) => {
|
||||
warn!("StartRx failed: {}", e);
|
||||
responses
|
||||
.send(Response::Error(ErrorCode::InvalidConfig))
|
||||
.await;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
responses
|
||||
.send(Response::Error(ErrorCode::NotConfigured))
|
||||
.await;
|
||||
}
|
||||
}
|
||||
Command::StopRx => {
|
||||
// Best-effort: if standby fails, radio is in unknown state
|
||||
// but there's nothing useful we can do except continue.
|
||||
let _ = lora.enter_standby().await;
|
||||
state.state = RadioState::Idle;
|
||||
status.sender().send(state.clone());
|
||||
responses.send(Response::Ok).await;
|
||||
}
|
||||
Command::DisplayOn | Command::DisplayOff | Command::GetMac => {}
|
||||
Command::Transmit { config, payload } => {
|
||||
let tx_config = config.map(|c| c.resolve(Board::TX_POWER_RANGE)).or(state.config);
|
||||
if let Some(cfg) = tx_config {
|
||||
if let Err(reason) = cfg.validate(Board::TX_POWER_RANGE) {
|
||||
warn!("TX config rejected: {}", reason);
|
||||
responses
|
||||
.send(Response::Error(ErrorCode::InvalidConfig))
|
||||
.await;
|
||||
return;
|
||||
}
|
||||
|
||||
let was_receiving = state.state == RadioState::Receiving;
|
||||
state.state = RadioState::Transmitting;
|
||||
status.sender().send(state.clone());
|
||||
|
||||
match do_tx(lora, &cfg, &payload).await {
|
||||
Ok(()) => {
|
||||
state.tx_count = state.tx_count.wrapping_add(1);
|
||||
responses.send(Response::TxDone).await;
|
||||
}
|
||||
Err(e) => {
|
||||
warn!("TX failed: {}", e);
|
||||
responses
|
||||
.send(Response::Error(ErrorCode::TxTimeout))
|
||||
.await;
|
||||
}
|
||||
}
|
||||
|
||||
// Restore previous state. If we were receiving, restart RX
|
||||
// using the stored config — NOT the per-packet TX override.
|
||||
state.state = if was_receiving {
|
||||
// state.config is always Some when was_receiving (invariant).
|
||||
let rx_cfg = state.config.unwrap_or(cfg);
|
||||
match start_rx(lora, &rx_cfg).await {
|
||||
Ok(()) => RadioState::Receiving,
|
||||
Err(e) => {
|
||||
warn!("post-TX RX restart failed: {}", e);
|
||||
RadioState::Idle
|
||||
}
|
||||
}
|
||||
} else {
|
||||
RadioState::Idle
|
||||
};
|
||||
status.sender().send(state.clone());
|
||||
} else {
|
||||
responses
|
||||
.send(Response::Error(ErrorCode::NotConfigured))
|
||||
.await;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── LoRa helpers ────────────────────────────────────────────────────
|
||||
|
||||
fn to_bw(bw: protocol::Bandwidth) -> lora_phy::mod_params::Bandwidth {
|
||||
use lora_phy::mod_params::Bandwidth::*;
|
||||
match bw {
|
||||
protocol::Bandwidth::Khz7 => _7KHz,
|
||||
protocol::Bandwidth::Khz10 => _10KHz,
|
||||
protocol::Bandwidth::Khz15 => _15KHz,
|
||||
protocol::Bandwidth::Khz20 => _20KHz,
|
||||
protocol::Bandwidth::Khz31 => _31KHz,
|
||||
protocol::Bandwidth::Khz41 => _41KHz,
|
||||
protocol::Bandwidth::Khz62 => _62KHz,
|
||||
protocol::Bandwidth::Khz125 => _125KHz,
|
||||
protocol::Bandwidth::Khz250 => _250KHz,
|
||||
protocol::Bandwidth::Khz500 => _500KHz,
|
||||
}
|
||||
}
|
||||
|
||||
fn to_sf(sf: u8) -> lora_phy::mod_params::SpreadingFactor {
|
||||
use lora_phy::mod_params::SpreadingFactor::*;
|
||||
match sf {
|
||||
5 => _5,
|
||||
6 => _6,
|
||||
7 => _7,
|
||||
8 => _8,
|
||||
9 => _9,
|
||||
10 => _10,
|
||||
11 => _11,
|
||||
12 => _12,
|
||||
_ => {
|
||||
warn!("BUG: invalid SF {} passed validation", sf);
|
||||
_7
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn to_cr(cr: u8) -> lora_phy::mod_params::CodingRate {
|
||||
use lora_phy::mod_params::CodingRate::*;
|
||||
match cr {
|
||||
5 => _4_5,
|
||||
6 => _4_6,
|
||||
7 => _4_7,
|
||||
8 => _4_8,
|
||||
_ => {
|
||||
warn!("BUG: invalid CR {} passed validation", cr);
|
||||
_4_5
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Create modulation parameters from a validated config.
|
||||
fn modulation_params(
|
||||
lora: &mut Radio,
|
||||
cfg: &RadioConfig,
|
||||
) -> Result<lora_phy::mod_params::ModulationParams, RadioError> {
|
||||
lora.create_modulation_params(to_sf(cfg.sf), to_bw(cfg.bw), to_cr(cfg.cr), cfg.freq_hz)
|
||||
}
|
||||
|
||||
async fn start_rx(lora: &mut Radio, cfg: &RadioConfig) -> Result<(), RadioError> {
|
||||
let mdltn = modulation_params(lora, cfg)?;
|
||||
let pkt = lora.create_rx_packet_params(
|
||||
cfg.preamble_len, IMPLICIT_HEADER, MAX_PAYLOAD as u8, CRC_ON, IQ_INVERTED, &mdltn,
|
||||
)?;
|
||||
lora.prepare_for_rx(RxMode::Continuous, &mdltn, &pkt).await
|
||||
}
|
||||
|
||||
async fn rx_once(
|
||||
lora: &mut Radio,
|
||||
cfg: &RadioConfig,
|
||||
buf: &mut [u8],
|
||||
) -> Result<(u8, lora_phy::mod_params::PacketStatus), RadioError> {
|
||||
let mdltn = modulation_params(lora, cfg)?;
|
||||
let pkt = lora.create_rx_packet_params(
|
||||
cfg.preamble_len, IMPLICIT_HEADER, MAX_PAYLOAD as u8, CRC_ON, IQ_INVERTED, &mdltn,
|
||||
)?;
|
||||
lora.rx(&pkt, buf).await
|
||||
}
|
||||
|
||||
/// Maximum CAD attempts before transmitting anyway.
|
||||
const CAD_MAX_RETRIES: u8 = 10;
|
||||
|
||||
async fn do_tx(lora: &mut Radio, cfg: &RadioConfig, payload: &[u8]) -> Result<(), RadioError> {
|
||||
let mdltn = modulation_params(lora, cfg)?;
|
||||
|
||||
// Listen-before-talk: check channel is clear before transmitting.
|
||||
if cfg.cad_enabled() {
|
||||
lora.prepare_for_cad(&mdltn).await?;
|
||||
for _ in 0..CAD_MAX_RETRIES {
|
||||
if !lora.cad(&mdltn).await? {
|
||||
break; // Channel is clear
|
||||
}
|
||||
// Channel busy — wait briefly and retry
|
||||
embassy_time::Timer::after_millis(10).await;
|
||||
lora.prepare_for_cad(&mdltn).await?;
|
||||
}
|
||||
}
|
||||
|
||||
let mut tx_pkt = lora.create_tx_packet_params(
|
||||
cfg.preamble_len, IMPLICIT_HEADER, CRC_ON, IQ_INVERTED, &mdltn,
|
||||
)?;
|
||||
lora.prepare_for_tx(&mdltn, &mut tx_pkt, cfg.tx_power_dbm as i32, payload)
|
||||
.await?;
|
||||
lora.tx().await
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
//! USB CDC-ACM interface with COBS-framed command/response protocol.
|
||||
|
||||
mod task;
|
||||
pub use task::usb_task;
|
||||
+234
@@ -0,0 +1,234 @@
|
||||
//! USB CDC-ACM task: COBS-framed fixed-size LE command/response protocol.
|
||||
//!
|
||||
//! Reads COBS frames from USB, decodes them, parses commands, routes them
|
||||
//! to the radio or display task, and sends COBS-framed responses back.
|
||||
|
||||
use defmt::warn;
|
||||
use embassy_executor::task;
|
||||
use embassy_futures::join::join;
|
||||
use embassy_usb::class::cdc_acm::{CdcAcmClass, Receiver, Sender, State};
|
||||
use embassy_usb::Builder;
|
||||
use static_cell::StaticCell;
|
||||
|
||||
use crate::channel::{
|
||||
CommandChannel, DisplayCommand, DisplayCommandChannel, ResponseChannel,
|
||||
};
|
||||
use crate::protocol::{Command, ErrorCode, Response};
|
||||
|
||||
const MAX_FRAME: usize = 512;
|
||||
const MAX_PACKET: usize = 64;
|
||||
|
||||
// Worst case: tag(1) + rssi(2) + snr(2) + len(2) + payload(256) = 263 bytes.
|
||||
// COBS adds ceil(263/254)+1 = 3 bytes. 266 < 512. Plenty of room.
|
||||
const _: () = assert!(
|
||||
MAX_FRAME >= crate::protocol::MAX_PAYLOAD + 64,
|
||||
"MAX_FRAME too small for max payload + COBS overhead"
|
||||
);
|
||||
|
||||
#[task]
|
||||
pub async fn usb_task(
|
||||
parts: crate::board::UsbParts,
|
||||
commands: &'static CommandChannel,
|
||||
responses: &'static ResponseChannel,
|
||||
display_commands: &'static DisplayCommandChannel,
|
||||
has_display: bool,
|
||||
mac: [u8; 6],
|
||||
) {
|
||||
// ── USB device configuration ────────────────────────────────────
|
||||
// VID 0x1209: pid.codes open-source USB vendor ID
|
||||
// PID 0x5741: DongLoRa product ID
|
||||
// Class 0xEF/0x02/0x01: Miscellaneous / Interface Association Descriptor
|
||||
let mut config = embassy_usb::Config::new(0x1209, 0x5741);
|
||||
config.manufacturer = Some("DongLoRa");
|
||||
config.product = Some("DongLoRa LoRa Radio");
|
||||
config.device_class = 0xEF;
|
||||
config.device_sub_class = 0x02;
|
||||
config.device_protocol = 0x01;
|
||||
config.composite_with_iads = true;
|
||||
|
||||
// Use MAC address as USB serial number for unique device identification.
|
||||
static SERIAL_BUF: StaticCell<[u8; 12]> = StaticCell::new();
|
||||
let serial_buf = SERIAL_BUF.init([0u8; 12]);
|
||||
const HEX: &[u8; 16] = b"0123456789ABCDEF";
|
||||
for (i, &byte) in mac.iter().enumerate() {
|
||||
serial_buf[i * 2] = HEX[(byte >> 4) as usize];
|
||||
serial_buf[i * 2 + 1] = HEX[(byte & 0x0F) as usize];
|
||||
}
|
||||
config.serial_number = Some(core::str::from_utf8(serial_buf).expect("MAC hex is valid UTF-8"));
|
||||
|
||||
static DESC_BUF: StaticCell<[u8; 256]> = StaticCell::new();
|
||||
static CONF_BUF: StaticCell<[u8; 256]> = StaticCell::new();
|
||||
static BOS_BUF: StaticCell<[u8; 256]> = StaticCell::new();
|
||||
static CTRL_BUF: StaticCell<[u8; 64]> = StaticCell::new();
|
||||
static CDC_STATE: StaticCell<State> = StaticCell::new();
|
||||
|
||||
let desc_buf = DESC_BUF.init([0; 256]);
|
||||
let conf_buf = CONF_BUF.init([0; 256]);
|
||||
let bos_buf = BOS_BUF.init([0; 256]);
|
||||
let ctrl_buf = CTRL_BUF.init([0; 64]);
|
||||
let cdc_state = CDC_STATE.init(State::new());
|
||||
|
||||
let mut builder = Builder::new(
|
||||
parts.driver,
|
||||
config,
|
||||
desc_buf,
|
||||
conf_buf,
|
||||
bos_buf,
|
||||
ctrl_buf,
|
||||
);
|
||||
|
||||
let class = CdcAcmClass::new(&mut builder, cdc_state, MAX_PACKET as u16);
|
||||
let mut usb_dev = builder.build();
|
||||
let (sender, receiver) = class.split();
|
||||
|
||||
// ── Run USB device + protocol loop concurrently ────────────────
|
||||
join(
|
||||
usb_dev.run(),
|
||||
protocol_loop(sender, receiver, commands, responses, display_commands, has_display, mac),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
async fn protocol_loop<'d, D: embassy_usb_driver::Driver<'d>>(
|
||||
mut sender: Sender<'d, D>,
|
||||
mut receiver: Receiver<'d, D>,
|
||||
commands: &'static CommandChannel,
|
||||
responses: &'static ResponseChannel,
|
||||
display_commands: &'static DisplayCommandChannel,
|
||||
has_display: bool,
|
||||
mac: [u8; 6],
|
||||
) {
|
||||
use embassy_futures::select::select3;
|
||||
use embassy_futures::select::Either3;
|
||||
|
||||
let mut read_buf = [0u8; MAX_PACKET];
|
||||
let mut write_buf = [0u8; MAX_FRAME];
|
||||
let mut cobs_encode_buf = [0u8; MAX_FRAME];
|
||||
|
||||
// COBS accumulator: collect bytes until 0x00 sentinel
|
||||
let mut frame_buf = [0u8; MAX_FRAME];
|
||||
let mut frame_len: usize = 0;
|
||||
|
||||
let mut was_connected = false;
|
||||
|
||||
loop {
|
||||
match select3(
|
||||
receiver.read_packet(&mut read_buf),
|
||||
responses.receive(),
|
||||
embassy_time::Timer::after_millis(250), // DTR poll interval for disconnect detection
|
||||
)
|
||||
.await
|
||||
{
|
||||
Either3::First(result) => {
|
||||
let n = match result {
|
||||
Ok(0) => continue,
|
||||
Ok(n) => n,
|
||||
Err(_) => {
|
||||
frame_len = 0; // reset accumulator
|
||||
embassy_time::Timer::after_millis(100).await;
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
// Feed bytes into the COBS accumulator
|
||||
for &byte in &read_buf[..n] {
|
||||
if byte == 0x00 {
|
||||
// End of COBS frame — decode and parse
|
||||
if frame_len > 0 {
|
||||
let mut decode_buf = [0u8; MAX_FRAME];
|
||||
if let Some(decoded_len) =
|
||||
ucobs::decode(&frame_buf[..frame_len], &mut decode_buf)
|
||||
{
|
||||
if let Some(cmd) = Command::from_bytes(&decode_buf[..decoded_len]) {
|
||||
route_command(
|
||||
cmd, commands, responses, display_commands, has_display, mac,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
}
|
||||
}
|
||||
frame_len = 0;
|
||||
} else if frame_len < MAX_FRAME {
|
||||
frame_buf[frame_len] = byte;
|
||||
frame_len += 1;
|
||||
} else {
|
||||
// Frame too large — discard
|
||||
frame_len = 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
Either3::Second(response) => {
|
||||
// Serialize response to fixed-size LE bytes, then COBS encode
|
||||
let raw_len = response.write_to(&mut write_buf);
|
||||
let encoded_len = ucobs::encode(&write_buf[..raw_len], &mut cobs_encode_buf)
|
||||
.unwrap_or(0);
|
||||
// Append 0x00 sentinel
|
||||
if encoded_len < cobs_encode_buf.len() {
|
||||
cobs_encode_buf[encoded_len] = 0x00;
|
||||
let frame = &cobs_encode_buf[..encoded_len + 1];
|
||||
for chunk in frame.chunks(MAX_PACKET) {
|
||||
if sender.write_packet(chunk).await.is_err() {
|
||||
warn!("USB write failed, response dropped");
|
||||
break;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
warn!("COBS encode buffer overflow");
|
||||
}
|
||||
}
|
||||
Either3::Third(()) => {
|
||||
// Poll DTR every 250ms to detect host disconnect
|
||||
}
|
||||
}
|
||||
|
||||
// Check DTR after every select wake
|
||||
let connected = receiver.dtr();
|
||||
if was_connected && !connected && has_display {
|
||||
display_commands.send(DisplayCommand::Reset).await;
|
||||
}
|
||||
if !was_connected && connected && has_display {
|
||||
display_commands.send(DisplayCommand::On).await;
|
||||
}
|
||||
was_connected = connected;
|
||||
}
|
||||
}
|
||||
|
||||
/// Route a parsed command to the appropriate handler.
|
||||
///
|
||||
/// Display and MAC commands are handled locally (response sent immediately).
|
||||
/// All other commands are forwarded to the radio task (response sent later).
|
||||
/// Both paths feed the same `ResponseChannel`, so the one-outstanding-command
|
||||
/// rule in PROTOCOL.md is required to keep solicited responses ordered.
|
||||
async fn route_command(
|
||||
cmd: Command,
|
||||
commands: &CommandChannel,
|
||||
responses: &ResponseChannel,
|
||||
display_commands: &DisplayCommandChannel,
|
||||
has_display: bool,
|
||||
mac: [u8; 6],
|
||||
) {
|
||||
match cmd {
|
||||
Command::DisplayOn => {
|
||||
if has_display {
|
||||
display_commands.send(DisplayCommand::On).await;
|
||||
responses.send(Response::Ok).await;
|
||||
} else {
|
||||
responses.send(Response::Error(ErrorCode::NoDisplay)).await;
|
||||
}
|
||||
}
|
||||
Command::DisplayOff => {
|
||||
if has_display {
|
||||
display_commands.send(DisplayCommand::Off).await;
|
||||
responses.send(Response::Ok).await;
|
||||
} else {
|
||||
responses.send(Response::Error(ErrorCode::NoDisplay)).await;
|
||||
}
|
||||
}
|
||||
Command::GetMac => {
|
||||
responses.send(Response::MacAddress(mac)).await;
|
||||
}
|
||||
other => {
|
||||
commands.send(other).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user