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

+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;
}
}
}
}
}