refactor: LoRaBoard trait for compile-time board completeness verification

Every board's Board struct now implements the LoRaBoard trait, which
enforces that NAME, TX_POWER_RANGE, init(), and mac_address() are all
provided. Missing any of these is a compile error — no "good intentions"
needed.

The trait verifies metadata and initialization. Tasks use the board's
concrete types directly (Embassy tasks must be concrete). Board-specific
type aliases (RadioDriver, UsbDriver, DisplayI2c) and struct definitions
(RadioParts, UsbParts, DisplayParts) remain in each board file.

- New: src/board/traits.rs (LoRaBoard trait definition)
- Modified: all 3 board files (implement trait, remove standalone constants)
- Modified: main.rs (import trait for init)
- Modified: radio/task.rs (use Board::TX_POWER_RANGE via trait)
- Modified: display/task.rs (use Board::NAME via trait)
- Modified: build.rs (exclude traits.rs from board discovery)
- Removed: embedded-hal-async direct dep (not needed for trait approach)
This commit is contained in:
swaits committed 2026-03-30 08:44:39 -06:00
1 parent 99a01b867e
commit 2633daf437
11 files changed
+107 -55

No files matched your search

Generated
+1
View File
@@ -341,6 +341,7 @@ dependencies = [
"embassy-usb",
"embassy-usb-driver",
"embedded-graphics",
"embedded-hal-async",
"embedded-io-async",
"esp-backtrace",
"esp-hal",
+1
View File
@@ -18,6 +18,7 @@ categories = ["embedded", "hardware-support", "no-std"]
cfg-if = "1.0"
critical-section = "1.2"
defmt = "0.3"
embedded-hal-async = "1.0"
embassy-embedded-hal = "0.2"
embassy-executor = { version = "0.6", features = ["task-arena-size-32768"] }
embassy-sync = "0.6"
+2 -1
View File
@@ -8,7 +8,8 @@ fn main() {
.expect("failed to read src/board directory")
.filter_map(|e| {
let p = e.ok()?.path();
if p.is_file() && p.extension()? == "rs" && p.file_stem()? != "mod" {
let stem = p.file_stem()?.to_str()?;
if p.is_file() && p.extension()? == "rs" && stem != "mod" && stem != "traits" {
return Some(p.file_stem()?.to_str()?.to_string());
}
None
+12 -4
View File
@@ -1,3 +1,4 @@
use super::traits::LoRaBoard;
use embassy_embedded_hal::shared_bus::asynch::spi::SpiDevice;
use embassy_sync::blocking_mutex::raw::NoopRawMutex;
use embassy_time::Delay;
@@ -25,8 +26,6 @@ pub type UsbDriver = esp_hal::otg_fs::asynch::Driver<'static>;
pub type DisplayI2c = I2c<'static, esp_hal::Async>;
/// TX power range for this board's radio (SX1262).
pub const TX_POWER_RANGE: (i8, i8) = (-9, 22);
// ── Peripheral bundles ───────────────────────────────────────────────
@@ -50,12 +49,21 @@ pub struct Board {
p: esp_hal::peripherals::Peripherals,
}
impl Board {
pub fn init() -> Self {
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;
+19 -13
View File
@@ -13,6 +13,8 @@ use static_cell::StaticCell;
use lora_phy::iv::GenericSx126xInterfaceVariant;
use lora_phy::sx126x::{self, Sx1262, Sx126x};
use super::traits::LoRaBoard;
// ── Concrete peripheral types ────────────────────────────────────────
type SpiBus = SpiDmaBus<'static, esp_hal::Async>;
@@ -25,9 +27,6 @@ pub type UsbDriver = esp_hal::otg_fs::asynch::Driver<'static>;
pub type DisplayI2c = I2c<'static, esp_hal::Async>;
/// TX power range for this board's radio (SX1262).
pub const TX_POWER_RANGE: (i8, i8) = (-9, 22);
// ── Peripheral bundles ───────────────────────────────────────────────
pub struct RadioParts {
@@ -44,18 +43,27 @@ pub struct DisplayParts {
pub mac: [u8; 6],
}
// ── Board init ───────────────────────────────────────────────────────
// ── Board implementation ────────────────────────────────────────────
pub struct Board {
p: esp_hal::peripherals::Peripherals,
}
impl Board {
pub fn init() -> Self {
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;
@@ -64,7 +72,6 @@ impl Board {
esp_hal_embassy::init(timg0.timer0);
// ── Vext power: GPIO36 ─────────────────────────────────────
// V4 datasheet says "pull low to enable Vext"
let vext = Output::new(p.GPIO36, Level::Low);
core::mem::forget(vext); // hold pin low permanently; drop would reset it
@@ -101,7 +108,8 @@ impl Board {
let dio1 = Input::new(p.GPIO14, Pull::Down);
let busy = Input::new(p.GPIO13, Pull::Down);
let iv = GenericSx126xInterfaceVariant::new(reset, dio1, busy, None, None).expect("SX1262 interface init");
let iv = GenericSx126xInterfaceVariant::new(reset, dio1, busy, None, None)
.expect("SX1262 interface init");
let sx_config = sx126x::Config {
chip: Sx1262,
@@ -115,9 +123,8 @@ impl Board {
delay: Delay,
};
// ── USB Serial (via USB-Serial-JTAG, shares PHY safely) ───
// ── USB OTG CDC-ACM driver ──────────────────────────────
// Note: this switches the internal USB PHY from Serial-JTAG to OTG.
// ── USB OTG CDC-ACM ────────────────────────────────────────
// Note: switches internal USB PHY from Serial-JTAG to OTG.
// espflash --monitor will stop working after this point.
let usb_inst = esp_hal::otg_fs::Usb::new(p.USB0, p.GPIO20, p.GPIO19);
static EP_OUT_BUF: StaticCell<[u8; 1024]> = StaticCell::new();
@@ -131,7 +138,6 @@ impl Board {
};
// ── Display (SSD1315 OLED on I2C, 0x3C) ───────────────────
// Reset display controller
let mut display_rst = Output::new(p.GPIO21, Level::Low);
esp_hal::delay::Delay::new().delay_millis(10);
display_rst.set_high();
@@ -142,7 +148,7 @@ impl Board {
.with_sda(p.GPIO17)
.with_scl(p.GPIO18)
.into_async();
let mac = esp_hal::efuse::Efuse::mac_address();
let mac = Self::mac_address();
let display = Some(DisplayParts { i2c, mac });
(radio, usb, display)
+3
View File
@@ -1,4 +1,7 @@
// @generated by build.rs — DO NOT EDIT
pub mod traits;
pub use traits::*;
cfg_if::cfg_if! {
{%- for board in boards %}
{% if loop.first %}if{% else %}else if{% endif %} #[cfg(feature = "{{ board }}")] {
+27 -21
View File
@@ -12,6 +12,8 @@ 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<USBD>;
SPIM3 => embassy_nrf::spim::InterruptHandler<embassy_nrf::peripherals::SPI3>;
@@ -33,9 +35,6 @@ pub type UsbDriver = Driver<'static, USBD, &'static embassy_nrf::usb::vbus_detec
/// I2C bus for an optional SSD1306 OLED.
pub type DisplayI2c = Twim<'static, embassy_nrf::peripherals::TWISPI0>;
/// TX power range for this board's radio (SX1262).
pub const TX_POWER_RANGE: (i8, i8) = (-9, 22);
// ── Peripheral bundles ───────────────────────────────────────────────
pub struct RadioParts {
@@ -58,12 +57,34 @@ pub struct Board {
p: Peripherals,
}
impl Board {
pub fn init() -> Self {
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] {
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;
@@ -111,22 +132,7 @@ impl Board {
// via SSD1306 init (fails gracefully if no display attached).
let i2c_cfg = twim::Config::default();
let i2c = Twim::new(p.TWISPI0, Irqs, p.P0_13, p.P0_14, i2c_cfg);
// Read device address from FICR (factory info registers).
// FICR is at fixed address 0x10000000 on nRF52840.
let mac = unsafe {
let ficr = 0x10000000 as *const u32;
// DEVICEADDR[0] at offset 0x0A4, DEVICEADDR[1] at offset 0x0A8
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,
]
};
let mac = Self::mac_address();
let display = Some(DisplayParts { i2c, mac });
(radio, usb, display)
+31
View File
@@ -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];
}
+3 -9
View File
@@ -9,17 +9,11 @@ use ssd1306::{I2CDisplayInterface, Ssd1306Async};
use crate::board::DisplayParts;
use crate::channel::{DisplayCommand, DisplayCommandChannel, RadioStatus, StatusWatch};
use crate::board::{Board, LoRaBoard};
use super::render::{self, RSSI_HISTORY_LEN};
const BOARD_NAME: &str = if cfg!(feature = "rak_wisblock_4631") {
"RAK WisBlock 4631"
} else if cfg!(feature = "heltec_v3") {
"Heltec v3"
} else if cfg!(feature = "heltec_v4") {
"Heltec v4"
} else {
"unknown"
};
const BOARD_NAME: &str = Board::NAME;
/// Duration per sparkline slot. 64 slots * 1s = ~1 minute of history.
const SPARK_SLOT_MS: u64 = 1000;
+2 -1
View File
@@ -24,6 +24,7 @@ mod usb;
use embassy_executor::Spawner;
use crate::board::LoRaBoard;
use crate::channel::{CommandChannel, DisplayCommandChannel, ResponseChannel, StatusWatch};
cfg_if::cfg_if! {
@@ -55,7 +56,7 @@ cfg_if::cfg_if! {
}
async fn run(spawner: Spawner) {
let board = board::Board::init();
let board = <board::Board as LoRaBoard>::init();
let (radio, usb, display) = board.into_parts();
let has_display = display.is_some();
+6 -6
View File
@@ -24,12 +24,12 @@ use embassy_time::Delay;
use lora_phy::mod_params::RadioError;
use lora_phy::{LoRa, RxMode};
use crate::board::{RadioDriver, RadioParts, TX_POWER_RANGE};
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;
const POWER_RANGE: (i8, i8) = TX_POWER_RANGE;
// TX power range comes from the board trait: Board::TX_POWER_RANGE
type Radio = LoRa<RadioDriver, Delay>;
@@ -178,14 +178,14 @@ async fn handle_cmd(
}
}
Command::SetConfig(cfg) => {
if let Err(reason) = cfg.validate(POWER_RANGE) {
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_power(POWER_RANGE));
state.config = Some(cfg.resolve_power(Board::TX_POWER_RANGE));
status.sender().send(state.clone());
responses.send(Response::Ok).await;
}
@@ -221,9 +221,9 @@ async fn handle_cmd(
}
Command::DisplayOn | Command::DisplayOff => {}
Command::Transmit { config, payload } => {
let tx_config = config.map(|c| c.resolve_power(POWER_RANGE)).or(state.config);
let tx_config = config.map(|c| c.resolve_power(Board::TX_POWER_RANGE)).or(state.config);
if let Some(cfg) = tx_config {
if let Err(reason) = cfg.validate(POWER_RANGE) {
if let Err(reason) = cfg.validate(Board::TX_POWER_RANGE) {
warn!("TX config rejected: {}", reason);
responses
.send(Response::Error(ErrorCode::InvalidConfig))