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:
11 files changed
+107
-55
No files matched your search
Generated
+1
@@ -341,6 +341,7 @@ dependencies = [
|
||||
"embassy-usb",
|
||||
"embassy-usb-driver",
|
||||
"embedded-graphics",
|
||||
"embedded-hal-async",
|
||||
"embedded-io-async",
|
||||
"esp-backtrace",
|
||||
"esp-hal",
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
@@ -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
@@ -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)
|
||||
|
||||
@@ -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 }}")] {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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))
|
||||
|
||||
Reference in new issue
Block a user