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