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

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