diff --git a/Cargo.lock b/Cargo.lock index f4e238d4..c56b45b8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -341,6 +341,7 @@ dependencies = [ "embassy-usb", "embassy-usb-driver", "embedded-graphics", + "embedded-hal-async", "embedded-io-async", "esp-backtrace", "esp-hal", diff --git a/Cargo.toml b/Cargo.toml index e883eb0f..09203ffd 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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" diff --git a/build.rs b/build.rs index 2a572575..a4579c5c 100644 --- a/build.rs +++ b/build.rs @@ -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 diff --git a/src/board/heltec_v3.rs b/src/board/heltec_v3.rs index 9d107540..454066a1 100644 --- a/src/board/heltec_v3.rs +++ b/src/board/heltec_v3.rs @@ -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) { let p = self.p; diff --git a/src/board/heltec_v4.rs b/src/board/heltec_v4.rs index 37f0ea11..db034435 100644 --- a/src/board/heltec_v4.rs +++ b/src/board/heltec_v4.rs @@ -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) { 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) diff --git a/src/board/mod.rs.j2 b/src/board/mod.rs.j2 index 55035d1d..aa1a69b8 100644 --- a/src/board/mod.rs.j2 +++ b/src/board/mod.rs.j2 @@ -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 }}")] { diff --git a/src/board/rak_wisblock_4631.rs b/src/board/rak_wisblock_4631.rs index 62d31134..1486dad1 100644 --- a/src/board/rak_wisblock_4631.rs +++ b/src/board/rak_wisblock_4631.rs @@ -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; SPIM3 => embassy_nrf::spim::InterruptHandler; @@ -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) { 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) diff --git a/src/board/traits.rs b/src/board/traits.rs new file mode 100644 index 00000000..f1f61142 --- /dev/null +++ b/src/board/traits.rs @@ -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]; +} diff --git a/src/display/task.rs b/src/display/task.rs index c6592c39..0788db12 100644 --- a/src/display/task.rs +++ b/src/display/task.rs @@ -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; diff --git a/src/main.rs b/src/main.rs index ea91aa83..37f3d7d8 100644 --- a/src/main.rs +++ b/src/main.rs @@ -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 = ::init(); let (radio, usb, display) = board.into_parts(); let has_display = display.is_some(); diff --git a/src/radio/task.rs b/src/radio/task.rs index d0743a44..2bfbe268 100644 --- a/src/radio/task.rs +++ b/src/radio/task.rs @@ -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; @@ -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))