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:
20 files changed
+2599
No files matched your search
@@ -0,0 +1,2 @@
|
|||||||
|
/target
|
||||||
|
src/board/mod.rs
|
||||||
Generated
+1841
File diff suppressed because it is too large.
Load diff
+49
@@ -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",
|
||||||
|
]
|
||||||
@@ -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 |
|
||||||
@@ -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
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 %}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
mod render;
|
||||||
|
mod task;
|
||||||
|
pub use task::display_task;
|
||||||
@@ -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",
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
mod task;
|
||||||
|
pub use task::radio_task;
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
mod task;
|
||||||
|
pub use task::usb_task;
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user