feat: initial lora-dongle firmware scaffold

Transparent LoRa-over-USB dongle firmware supporting Heltec V3 (ESP32-S3)
and RAK 4631 (nRF52840) boards. Actor-model architecture with typed
channels between USB, radio, and display tasks.

- Protocol: Command/Response enums with COBS+postcard framing design
- Board abstraction: auto-discovered via build.rs Jinja2 codegen
- Radio task: SX1262 state machine (Idle → Receiving ↔ Transmitting)
- Display task: optional SSD1306 dashboard via StatusWatch
- RAK 4631: fully wired SPI/USB/I2C peripherals, clippy-clean
- Heltec V3: structure in place, awaits espup Xtensa toolchain
This commit is contained in:
swaits committed 2026-03-26 15:16:42 -06:00
commit c30f480c34
20 files changed
+2599

No files matched your search

+2
View File
@@ -0,0 +1,2 @@
/target
src/board/mod.rs
Generated
+1841
View File
File diff suppressed because it is too large. Load diff
+49
View File
@@ -0,0 +1,49 @@
[package]
name = "lora-dongle"
version = "0.1.0"
edition = "2021"
build = "build.rs"
[dependencies]
cfg-if = "1.0"
critical-section = "1.2"
defmt = "0.3"
embassy-embedded-hal = "0.2"
embassy-executor = { version = "0.6", features = ["task-arena-size-12288", "executor-thread"] }
embassy-sync = "0.6"
embassy-time = "0.3"
embassy-usb = { version = "0.3", features = ["defmt"] }
heapless = { version = "0.8", features = ["serde"] }
lora-phy = "3.0"
postcard = { version = "1.0", default-features = false }
serde = { version = "1.0", default-features = false, features = ["derive"] }
static_cell = "2.1"
# ESP32-S3 (Heltec V3)
esp-backtrace = { version = "0.14", optional = true, features = ["esp32s3", "panic-handler", "defmt"] }
esp-hal = { version = "0.22", optional = true, features = ["esp32s3"] }
esp-hal-embassy = { version = "0.5", optional = true, features = ["esp32s3"] }
esp-println = { version = "0.12", optional = true, features = ["esp32s3", "defmt-espflash"] }
# nRF52840 (RAK 4631)
cortex-m-rt = { version = "0.7", optional = true }
embassy-nrf = { version = "0.2", optional = true, features = ["nrf52840", "gpiote", "time-driver-rtc1", "defmt"] }
panic-probe = { version = "0.3", optional = true, features = ["print-defmt"] }
[build-dependencies]
minijinja = "2.0"
owo-colors = "4.0"
[features]
heltec_v3 = [
"dep:esp-backtrace",
"dep:esp-hal",
"dep:esp-hal-embassy",
"dep:esp-println",
]
rak_4631 = [
"dep:cortex-m-rt",
"dep:embassy-nrf",
"dep:panic-probe",
"embassy-executor/arch-cortex-m",
]
+76
View File
@@ -0,0 +1,76 @@
# Design Principles
These are the non-negotiable first principles of this project. Every design decision,
naming choice, module boundary, and abstraction is evaluated against them. When in
doubt, choose simplicity and clarity over cleverness.
## What This Is
A transparent LoRa radio over USB. Plug the dongle into your PC, talk to any LoRa
radio within range. That's it.
## What This Is Not
Not a mesh network node. Not a Meshtastic device. Not a protocol implementation. Not
an application framework. This firmware has no opinion about what you do with your
radio — it just gives you clean access to it.
## Principles
1. **Single purpose.** Transparent LoRa radio over USB. No mesh, no routing, no
application logic. Ever.
2. **Protocol-agnostic.** The firmware is a dumb pipe. It exposes clean LoRa
parameters (frequency, bandwidth, spreading factor, coding rate, sync word, TX
power) and nothing else. No raw register access, no chip-specific leakage.
3. **Host-driven.** The firmware makes zero autonomous decisions. The radio idles on
boot until the host commands it. No persistence across reboots. The host is always
in control.
4. **Zero-config.** Boots, enumerates USB, waits for commands. The host plugs in and
goes.
5. **Robustness over features.** Tasks never panic. Errors are reported to the host.
The radio self-recovers. Ship fewer features, but make them bulletproof.
6. **Minimal footprint.** Avoid heavy dependencies. Keep the binary small. Every
dependency must justify its inclusion.
7. **Auto-detect peripherals.** If the board has a display, use it. No user
configuration required.
8. **World-class Rust.** Fully leverage the type system for safety. Strict `no_std`.
Always clippy-clean. Thoughtfully designed — everything has an obvious home.
Anyone who reads this code should immediately think *"this is nice."*
9. **Easy board support.** Adding a new board means dropping a `.rs` file and adding
a Cargo feature. Nothing else changes.
## Architecture
```
Host PC
│ USB CDC-ACM (COBS-framed postcard)
▼
usb_task ──Command──► radio_task ──► SX1262
◄──Response── │
StatusWatch
▼
display_task (optional) ──► OLED/TFT
```
Each task exclusively owns its peripheral. No shared state except typed channels and
a watch signal. Pure actor model.
## Technical Decisions
| Decision | Choice | Why |
|---|---|---|
| USB framing | COBS + postcard | Compact, `no_std` native, battle-tested |
| Serialization | postcard + serde | Zero-alloc binary codec, perfect for embedded |
| Async runtime | Embassy | De facto standard for async embedded Rust |
| Display | Radio status dashboard | Freq, BW, SF, packet counts, RSSI/SNR |
| Boot behavior | Idle until host commands | Host-driven principle |
| Persistence | None | Always start fresh, true dumb pipe |
| Board codegen | Jinja2 via build.rs | Auto-discovers boards from filesystem |
+35
View File
@@ -0,0 +1,35 @@
set shell := ["bash", "-c"]
heltec_v3 := "heltec_v3 xtensa-esp32s3-none-elf esp32s3"
rak_4631 := "rak_4631 thumbv7em-none-eabihf nRF52840_xxAA"
# Check all boards compile cleanly
check-all:
just check heltec_v3
just check rak_4631
# Check a single board compiles
check board:
@read -r feat target chip <<< "$(just _info {{board}})"; \
cargo check --target $target --features $feat
# Run clippy on a single board
clippy board:
@read -r feat target chip <<< "$(just _info {{board}})"; \
cargo clippy --target $target --features $feat -- -D warnings
# Build and flash a board via probe-rs
flash board:
@read -r feat target chip <<< "$(just _info {{board}})"; \
cargo build --release --target $target --features $feat; \
probe-rs run --chip $chip --target $target --features $feat
# Show binary size for a release build
size board:
@read -r feat target chip <<< "$(just _info {{board}})"; \
cargo size --release --target $target --features $feat
[private]
_info name:
@if [ "{{name}}" == "heltec_v3" ]; then echo "{{heltec_v3}}"; \
elif [ "{{name}}" == "rak_4631" ]; then echo "{{rak_4631}}"; \
else echo "Unknown board" >&2; exit 1; fi
+35
View File
@@ -0,0 +1,35 @@
use minijinja::{context, Environment};
use owo_colors::OwoColorize;
use std::fs;
fn main() {
let board_dir = "src/board";
let boards: Vec<String> = fs::read_dir(board_dir).unwrap()
.filter_map(|e| {
let p = e.ok()?.path();
if p.is_file() && p.extension()? == "rs" && p.file_stem()? != "mod" {
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 template .j2");
env.add_template("mod", &template).unwrap();
let rendered = env.get_template("mod").unwrap().render(context!(boards => boards)).unwrap();
fs::write("src/board/mod.rs", rendered).unwrap();
println!("cargo:rerun-if-changed=src/board/mod.rs.j2");
println!("cargo:rerun-if-changed={}", board_dir);
}
+70
View File
@@ -0,0 +1,70 @@
use esp_hal::gpio::{GpioPin, Input, Output, PullDown, PushPull};
use esp_hal::peripherals::SPI2;
use esp_hal::spi::master::Spi;
use esp_hal::spi::SpiMode;
use esp_hal::i2c::I2c;
use esp_hal::peripherals::I2C0;
use esp_hal::Delay;
use lora_phy::iv::GenericSx126xInterfaceVariant;
use lora_phy::sx126x::{self, Sx1262, Sx126x};
use lora_phy::LoRa;
/// Board name shown on the display dashboard.
pub const BOARD_NAME: &str = "Heltec V3";
// ── Concrete peripheral types ────────────────────────────────────────
type Iv = GenericSx126xInterfaceVariant<Output<'static, GpioPin<8>>, Input<'static, GpioPin<14>>>;
type RadioSpi = Spi<'static, SPI2>;
type RadioDriver = Sx126x<RadioSpi, Iv, Sx1262>;
/// Fully-constructed LoRa radio driver.
pub type Radio = LoRa<RadioDriver, Delay>;
/// USB driver for CDC-ACM.
// TODO: define concrete USB OTG type once esp-hal USB support is wired up
pub type UsbDriver = ();
/// I2C bus for the built-in SSD1306 OLED.
pub type DisplayI2c = I2c<'static, I2C0>;
// ── Peripheral bundles ───────────────────────────────────────────────
pub struct RadioParts {
pub radio: Radio,
}
pub struct UsbParts {
pub driver: UsbDriver,
}
pub struct DisplayParts {
pub i2c: DisplayI2c,
}
// ── Board init ───────────────────────────────────────────────────────
pub struct Board {
p: esp_hal::peripherals::Peripherals,
}
impl Board {
pub fn init() -> Self {
let p = esp_hal::init(esp_hal::Config::default());
Self { p }
}
pub fn into_parts(self) -> (RadioParts, UsbParts, Option<DisplayParts>) {
let _p = self.p;
// TODO: implement Heltec V3 peripheral init
// - SPI2 for SX1262 (SCK=9, MOSI=10, MISO=11, NSS=8)
// - GPIO for radio control (RST=12, DIO1=14, BUSY=13)
// - Vext pin (GPIO21) LOW to power OLED
// - I2C0 for SSD1306 (SDA=17, SCL=18)
// - USB OTG for CDC-ACM
todo!("Heltec V3 peripheral init — requires espup toolchain to develop")
}
}
+9
View File
@@ -0,0 +1,9 @@
// @generated by build.rs — DO NOT EDIT
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 %}
}
+117
View File
@@ -0,0 +1,117 @@
use embassy_embedded_hal::shared_bus::asynch::spi::SpiDevice;
use embassy_nrf::gpio::{AnyPin, Input, Level, Output, OutputDrive, Pull};
use embassy_nrf::peripherals::USBD;
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};
bind_interrupts!(struct Irqs {
USBD => embassy_nrf::usb::InterruptHandler<USBD>;
SPIM3 => embassy_nrf::spim::InterruptHandler<embassy_nrf::peripherals::SPI3>;
SPIM0_SPIS0_TWIM0_TWIS0_SPI0_TWI0 => embassy_nrf::twim::InterruptHandler<embassy_nrf::peripherals::TWISPI0>;
});
/// Board name shown on the display dashboard.
#[allow(dead_code)]
pub const BOARD_NAME: &str = "RAK 4631";
// ── Concrete peripheral types ────────────────────────────────────────
type SpiBus = Spim<'static, embassy_nrf::peripherals::SPI3>;
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, USBD, &'static embassy_nrf::usb::vbus_detect::SoftwareVbusDetect>;
/// I2C bus for an optional SSD1306 OLED.
pub type DisplayI2c = Twim<'static, embassy_nrf::peripherals::TWISPI0>;
// ── Peripheral bundles ───────────────────────────────────────────────
pub struct RadioParts {
pub driver: RadioDriver,
pub delay: Delay,
}
pub struct UsbParts {
pub driver: UsbDriver,
}
pub struct DisplayParts {
pub i2c: DisplayI2c,
}
// ── Board init ───────────────────────────────────────────────────────
pub struct Board {
p: Peripherals,
}
impl Board {
pub fn init() -> Self {
let p = embassy_nrf::init(Default::default());
Self { p }
}
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(AnyPin::from(p.P1_10), Level::High, OutputDrive::Standard);
let spi_device = SpiDevice::new(spi_bus, nss);
// ── SX1262 control pins ──────────────────────────────────
let reset = Output::new(AnyPin::from(p.P1_06), Level::High, OutputDrive::Standard);
let dio1 = Input::new(AnyPin::from(p.P1_15), Pull::Down);
let busy = Input::new(AnyPin::from(p.P1_14), Pull::Down);
let iv = GenericSx126xInterfaceVariant::new(reset, dio1, busy, None, None).unwrap();
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) ──────
// TODO: probe I2C address 0x3C to detect presence
let i2c_cfg = twim::Config::default();
let i2c = Twim::new(p.TWISPI0, Irqs, p.P0_13, p.P0_14, i2c_cfg);
let display = Some(DisplayParts { i2c });
(radio, usb, display)
}
}
+46
View File
@@ -0,0 +1,46 @@
use embassy_sync::blocking_mutex::raw::CriticalSectionRawMutex;
use embassy_sync::channel::Channel;
use embassy_sync::watch::Watch;
use crate::protocol::{Command, RadioConfig, Response};
/// Host-to-radio command channel.
pub type CommandChannel = Channel<CriticalSectionRawMutex, Command, 16>;
/// Radio-to-host response channel.
pub type ResponseChannel = Channel<CriticalSectionRawMutex, Response, 32>;
/// Observable radio status for the display task.
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<i8>,
}
/// 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,
}
}
}
+3
View File
@@ -0,0 +1,3 @@
mod render;
mod task;
pub use task::display_task;
+25
View File
@@ -0,0 +1,25 @@
use crate::channel::{RadioState, RadioStatus};
/// Render the status dashboard to a framebuffer.
///
/// Pure logic — no hardware dependency. Takes a status snapshot and produces
/// draw calls that the display task flushes to the screen.
pub fn dashboard(status: &RadioStatus) {
// TODO: use embedded-graphics to render:
// Line 1: board name + radio state
// Line 2: freq / BW / SF
// Line 3: TX power / coding rate
// Line 4: RX count / TX count
// Line 5: last RSSI / SNR
let _ = status;
}
/// Format radio state as a short display string.
#[allow(dead_code)]
pub fn state_label(state: RadioState) -> &'static str {
match state {
RadioState::Idle => "IDLE",
RadioState::Receiving => "RX",
RadioState::Transmitting => "TX",
}
}
+22
View File
@@ -0,0 +1,22 @@
use embassy_executor::task;
use crate::board::DisplayParts;
use crate::channel::StatusWatch;
use super::render;
#[task]
pub async fn display_task(parts: DisplayParts, status: &'static StatusWatch) {
let _i2c = parts.i2c;
// TODO: init SSD1306 driver from I2C handle
// TODO: initial render of empty dashboard
let mut receiver = status.receiver().unwrap();
loop {
let state = receiver.changed().await;
render::dashboard(&state);
// TODO: flush framebuffer to display
}
}
+16
View File
@@ -0,0 +1,16 @@
/// Radio hardware errors.
#[allow(dead_code)]
#[derive(Debug, defmt::Format)]
pub enum RadioError {
InitFailed,
TxTimeout,
InvalidConfig,
}
/// USB protocol framing errors.
#[allow(dead_code)]
#[derive(Debug, defmt::Format)]
pub enum ProtocolError {
DeserializeFailed,
BufferOverflow,
}
+39
View File
@@ -0,0 +1,39 @@
#![no_std]
#![no_main]
mod board;
mod channel;
mod display;
mod error;
mod protocol;
mod radio;
mod usb;
use embassy_executor::Spawner;
use crate::channel::{CommandChannel, ResponseChannel, StatusWatch};
cfg_if::cfg_if! {
if #[cfg(feature = "rak_4631")] {
use panic_probe as _;
} else if #[cfg(feature = "heltec_v3")] {
use esp_backtrace as _;
}
}
static COMMANDS: CommandChannel = CommandChannel::new();
static RESPONSES: ResponseChannel = ResponseChannel::new();
static STATUS: StatusWatch = StatusWatch::new();
#[embassy_executor::main]
async fn main(spawner: Spawner) {
let board = board::Board::init();
let (radio, usb, display) = board.into_parts();
spawner.spawn(radio::radio_task(radio, &COMMANDS, &RESPONSES, &STATUS)).unwrap();
spawner.spawn(usb::usb_task(usb, &COMMANDS, &RESPONSES)).unwrap();
if let Some(dp) = display {
spawner.spawn(display::display_task(dp, &STATUS)).unwrap();
}
}
+89
View File
@@ -0,0 +1,89 @@
use heapless::Vec;
use serde::{Deserialize, Serialize};
/// Maximum LoRa payload size in bytes.
pub const MAX_PAYLOAD: usize = 256;
/// Protocol version for host compatibility checks.
#[allow(dead_code)]
pub const PROTOCOL_VERSION: u8 = 1;
/// LoRa signal bandwidth.
#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, 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,
}
/// LoRa forward error correction coding rate.
#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, defmt::Format)]
#[repr(u8)]
pub enum CodingRate {
Cr4_5 = 1,
Cr4_6 = 2,
Cr4_7 = 3,
Cr4_8 = 4,
}
/// Complete LoRa radio configuration.
#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, defmt::Format)]
pub struct RadioConfig {
pub freq_hz: u32,
pub bw: Bandwidth,
pub sf: u8,
pub cr: CodingRate,
pub sync_word: u16,
pub tx_power_dbm: i8,
}
/// Host → firmware commands.
// Payload variant is intentionally large (inline heapless::Vec) — no allocator available.
#[allow(clippy::large_enum_variant)]
#[derive(Serialize, Deserialize, Debug, PartialEq)]
pub enum Command {
Ping,
GetConfig,
SetConfig(RadioConfig),
StartRx,
StopRx,
Transmit {
config: Option<RadioConfig>,
payload: Vec<u8, MAX_PAYLOAD>,
},
}
/// Firmware → host responses.
// Payload variant is intentionally large (inline heapless::Vec) — no allocator available.
#[allow(clippy::large_enum_variant)]
#[derive(Serialize, Deserialize, Debug, PartialEq)]
pub enum Response {
Pong,
Config(RadioConfig),
RxPacket {
rssi: i16,
snr: i8,
payload: Vec<u8, MAX_PAYLOAD>,
},
TxDone,
Error(ErrorCode),
}
/// Error codes reported to the host.
#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, defmt::Format)]
#[repr(u8)]
pub enum ErrorCode {
InvalidConfig = 1,
RadioBusy = 2,
TxTimeout = 3,
CrcError = 4,
NotConfigured = 5,
}
+2
View File
@@ -0,0 +1,2 @@
mod task;
pub use task::radio_task;
+95
View File
@@ -0,0 +1,95 @@
use defmt::error;
use embassy_executor::task;
use lora_phy::LoRa;
use crate::board::RadioParts;
use crate::channel::{CommandChannel, RadioState, RadioStatus, ResponseChannel, StatusWatch};
use crate::protocol::{Command, ErrorCode, Response};
#[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 lora = LoRa::new(parts.driver, false, parts.delay).await;
let mut lora = match lora {
Ok(l) => l,
Err(e) => {
error!("radio init failed: {}", e);
responses.send(Response::Error(ErrorCode::InvalidConfig)).await;
// Cannot proceed without radio — idle forever.
loop {
let cmd = commands.receive().await;
if let Command::Ping = cmd {
responses.send(Response::Pong).await;
} else {
responses.send(Response::Error(ErrorCode::InvalidConfig)).await;
}
}
}
};
loop {
let cmd = commands.receive().await;
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) => {
state.config = Some(cfg);
// TODO: apply config to lora hardware
status.sender().send(state.clone());
}
Command::StartRx => {
if state.config.is_some() {
state.state = RadioState::Receiving;
// TODO: start continuous RX via lora
status.sender().send(state.clone());
} else {
responses.send(Response::Error(ErrorCode::NotConfigured)).await;
}
}
Command::StopRx => {
state.state = RadioState::Idle;
// TODO: put lora radio into standby
status.sender().send(state.clone());
}
Command::Transmit { config, payload } => {
let tx_config = config.or(state.config);
if let Some(_cfg) = tx_config {
let was_receiving = state.state == RadioState::Receiving;
state.state = RadioState::Transmitting;
status.sender().send(state.clone());
// TODO: transmit payload via lora
let _ = &mut lora;
let _ = payload;
state.tx_count = state.tx_count.wrapping_add(1);
responses.send(Response::TxDone).await;
state.state = if was_receiving {
RadioState::Receiving
} else {
RadioState::Idle
};
status.sender().send(state.clone());
} else {
responses.send(Response::Error(ErrorCode::NotConfigured)).await;
}
}
}
}
}
+2
View File
@@ -0,0 +1,2 @@
mod task;
pub use task::usb_task;
+26
View File
@@ -0,0 +1,26 @@
use embassy_executor::task;
use crate::board::UsbParts;
use crate::channel::{CommandChannel, ResponseChannel};
#[task]
pub async fn usb_task(
parts: UsbParts,
commands: &'static CommandChannel,
responses: &'static ResponseChannel,
) {
let _driver = parts.driver;
let _ = commands;
let _ = responses;
// TODO: build embassy_usb CDC-ACM class from driver
// TODO: COBS-framed postcard ser/de loop:
// select! {
// bytes = cdc_read() => deserialize Command → commands.send()
// resp = responses.receive() => serialize Response → cdc_write()
// }
loop {
embassy_time::Timer::after_secs(1).await;
}
}