feat: RGB LED support — blinks green on RX, red on TX

- Added RgbLed trait with impl on () for boards without LED
- Added WS2812 driver (driver/ws2812.rs) using ESP32-S3 RMT peripheral
  - 80MHz clock, 12.5ns resolution, 24-bit GRB encoding
  - Zero extra crate dependencies
- Heltec V3/V3-UART/V4: WS2812B on GPIO38 via RMT channel 0
- RAK WisBlock/Wio Tracker: type LedDriver = (), led = None (no-op)
- Display task: green blink on RX packet, red blink on TX packet
- LED auto-off on timer tick (~1s), off when display is off/reset
- LoRaBoard trait gains type LedDriver: RgbLed associated type
- BoardParts gains led: Option<L> field
This commit is contained in:
swaits committed 2026-04-07 11:18:30 -06:00
1 parent 2b9e236f32
commit 5ac4b73739
11 files changed
+157 -20

No files matched your search

+16 -1
View File
@@ -7,10 +7,13 @@
//! For stock (unmodified) V3 boards, use the `heltec_v3_uart` feature instead.
use esp_hal::gpio::{Level, Output, OutputConfig};
use esp_hal::rmt::{Rmt, TxChannelConfig, TxChannelCreator};
use esp_hal::time::Rate;
use static_cell::StaticCell;
use super::esp32s3;
use super::traits::{BoardParts, LoRaBoard};
use crate::driver::ws2812::Ws2812;
use crate::hal::esp32s3 as mcu;
pub use super::esp32s3::{
@@ -18,6 +21,7 @@ pub use super::esp32s3::{
};
pub type UsbDriver = esp_hal::otg_fs::asynch::Driver<'static>;
pub type LedDriver = Ws2812;
pub struct UsbParts {
pub driver: UsbDriver,
@@ -35,6 +39,7 @@ impl LoRaBoard for Board {
type CommParts = UsbParts;
type DisplayParts = DisplayParts;
type DisplayDriver = DisplayDriver;
type LedDriver = Ws2812;
fn init() -> Self {
let p = esp_hal::init(esp_hal::Config::default());
@@ -45,7 +50,7 @@ impl LoRaBoard for Board {
mcu::mac_address()
}
fn into_parts(self) -> BoardParts<RadioParts, UsbParts, DisplayParts> {
fn into_parts(self) -> BoardParts<RadioParts, UsbParts, DisplayParts, Ws2812> {
let p = self.p;
mcu::start_timer(p.TIMG0);
@@ -79,10 +84,20 @@ impl LoRaBoard for Board {
let i2c = mcu::init_i2c(p.I2C0, p.GPIO17, p.GPIO18);
let display = Some(DisplayParts { i2c });
// WS2812B RGB LED on GPIO38 via RMT channel 0
let rmt = Rmt::new(p.RMT, Rate::from_mhz(80))
.expect("RMT init")
.into_async();
let led_channel = rmt.channel0
.configure_tx(p.GPIO38, TxChannelConfig::default().with_clk_divider(1).with_idle_output(true))
.expect("RMT TX channel init");
let led = Some(Ws2812::new(led_channel));
BoardParts {
radio,
host,
display,
led,
mac: Self::mac_address(),
}
}
+16 -1
View File
@@ -7,9 +7,12 @@
//! use the `heltec_v3` feature instead for native USB CDC-ACM support.
use esp_hal::gpio::{Level, Output, OutputConfig};
use esp_hal::rmt::{Rmt, TxChannelConfig, TxChannelCreator};
use esp_hal::time::Rate;
use super::esp32s3;
use super::traits::{BoardParts, LoRaBoard};
use crate::driver::ws2812::Ws2812;
use crate::hal::esp32s3 as mcu;
pub use super::esp32s3::{
@@ -17,6 +20,7 @@ pub use super::esp32s3::{
};
pub type UartDriver = esp_hal::uart::Uart<'static, esp_hal::Async>;
pub type LedDriver = Ws2812;
pub struct UartParts {
pub driver: UartDriver,
@@ -34,6 +38,7 @@ impl LoRaBoard for Board {
type CommParts = UartParts;
type DisplayParts = DisplayParts;
type DisplayDriver = DisplayDriver;
type LedDriver = Ws2812;
fn init() -> Self {
let p = esp_hal::init(esp_hal::Config::default());
@@ -44,7 +49,7 @@ impl LoRaBoard for Board {
mcu::mac_address()
}
fn into_parts(self) -> BoardParts<RadioParts, UartParts, DisplayParts> {
fn into_parts(self) -> BoardParts<RadioParts, UartParts, DisplayParts, Ws2812> {
let p = self.p;
mcu::start_timer(p.TIMG0);
@@ -75,10 +80,20 @@ impl LoRaBoard for Board {
let i2c = mcu::init_i2c(p.I2C0, p.GPIO17, p.GPIO18);
let display = Some(DisplayParts { i2c });
// WS2812B RGB LED on GPIO38 via RMT channel 0
let rmt = Rmt::new(p.RMT, Rate::from_mhz(80))
.expect("RMT init")
.into_async();
let led_channel = rmt.channel0
.configure_tx(p.GPIO38, TxChannelConfig::default().with_clk_divider(1).with_idle_output(true))
.expect("RMT TX channel init");
let led = Some(Ws2812::new(led_channel));
BoardParts {
radio,
host,
display,
led,
mac: Self::mac_address(),
}
}
+22 -1
View File
@@ -1,8 +1,11 @@
use esp_hal::gpio::{Level, Output, OutputConfig};
use esp_hal::rmt::{Rmt, TxChannelConfig, TxChannelCreator};
use esp_hal::time::Rate;
use static_cell::StaticCell;
use super::esp32s3;
use super::traits::{BoardParts, LoRaBoard};
use crate::driver::ws2812::Ws2812;
use crate::hal::esp32s3 as mcu;
pub use super::esp32s3::{
@@ -11,6 +14,8 @@ pub use super::esp32s3::{
pub type UsbDriver = esp_hal::otg_fs::asynch::Driver<'static>;
pub type LedDriver = Ws2812;
pub struct UsbParts {
pub driver: UsbDriver,
}
@@ -27,6 +32,7 @@ impl LoRaBoard for Board {
type CommParts = UsbParts;
type DisplayParts = DisplayParts;
type DisplayDriver = DisplayDriver;
type LedDriver = Ws2812;
fn init() -> Self {
let p = esp_hal::init(esp_hal::Config::default());
@@ -37,7 +43,7 @@ impl LoRaBoard for Board {
mcu::mac_address()
}
fn into_parts(self) -> BoardParts<RadioParts, UsbParts, DisplayParts> {
fn into_parts(self) -> BoardParts<RadioParts, UsbParts, DisplayParts, Ws2812> {
let p = self.p;
mcu::start_timer(p.TIMG0);
@@ -71,10 +77,25 @@ impl LoRaBoard for Board {
let i2c = mcu::init_i2c(p.I2C0, p.GPIO17, p.GPIO18);
let display = Some(DisplayParts { i2c });
// WS2812B RGB LED on GPIO38 via RMT channel 0
let rmt = Rmt::new(p.RMT, Rate::from_mhz(80))
.expect("RMT init")
.into_async();
let led_channel = rmt.channel0
.configure_tx(
p.GPIO38,
TxChannelConfig::default()
.with_clk_divider(1)
.with_idle_output(true),
)
.expect("RMT TX channel init");
let led = Some(Ws2812::new(led_channel));
BoardParts {
radio,
host,
display,
led,
mac: Self::mac_address(),
}
}
+4 -1
View File
@@ -20,6 +20,7 @@ type RadioSpiDevice = SpiDevice<'static, NoopRawMutex, mcu::SpiBus, Nss>;
pub type RadioDriver = Sx126x<RadioSpiDevice, Iv, Sx1262>;
pub type UsbDriver = mcu::UsbNrfDriver;
pub type DisplayI2c = mcu::I2cBus;
pub type LedDriver = ();
pub type DisplayDriver = ssd1306::Ssd1306Async<
ssd1306::prelude::I2CInterface<DisplayI2c>,
@@ -77,6 +78,7 @@ impl LoRaBoard for Board {
type CommParts = UsbParts;
type DisplayParts = DisplayParts;
type DisplayDriver = DisplayDriver;
type LedDriver = ();
fn init() -> Self {
let p = embassy_nrf::init(Default::default());
@@ -87,7 +89,7 @@ impl LoRaBoard for Board {
mcu::mac_address()
}
fn into_parts(self) -> BoardParts<RadioParts, UsbParts, DisplayParts> {
fn into_parts(self) -> BoardParts<RadioParts, UsbParts, DisplayParts, ()> {
let p = self.p;
// ── SPI bus for SX1262 ──────────────────────────────────
@@ -135,6 +137,7 @@ impl LoRaBoard for Board {
radio,
host,
display,
led: None,
mac: Self::mac_address(),
}
}
+17 -4
View File
@@ -1,14 +1,23 @@
//! Board abstraction trait.
//!
//! Every board must implement [`LoRaBoard`] with concrete associated types
//! for its radio, host communication, and display peripherals. The compiler
//! enforces completeness — missing types or methods are compile errors.
//! for its radio, host communication, display, and LED peripherals. The
//! compiler enforces completeness — missing types or methods are compile errors.
//!
//! See `src/board/PORTING.md` for a step-by-step guide to adding a new board.
use embedded_graphics::draw_target::DrawTarget;
use embedded_graphics::pixelcolor::BinaryColor;
/// Async RGB LED control. Boards without an LED use `()` which is a no-op.
pub trait RgbLed {
fn set_rgb(&mut self, r: u8, g: u8, b: u8) -> impl core::future::Future<Output = ()>;
}
impl RgbLed for () {
async fn set_rgb(&mut self, _r: u8, _g: u8, _b: u8) {}
}
/// Compile-time contract for a DongLoRa board.
pub trait LoRaBoard: Sized {
/// Human-readable board name (shown on display splash screen).
@@ -29,6 +38,9 @@ pub trait LoRaBoard: Sized {
/// Concrete display driver type (must implement DrawTarget for rendering).
type DisplayDriver: DrawTarget<Color = BinaryColor>;
/// RGB LED driver. Boards without an LED use `()`.
type LedDriver: RgbLed;
/// Initialize the board hardware.
fn init() -> Self;
@@ -36,13 +48,14 @@ pub trait LoRaBoard: Sized {
fn mac_address() -> [u8; 6];
/// Decompose initialized board into peripheral bundles for each task.
fn into_parts(self) -> BoardParts<Self::RadioParts, Self::CommParts, Self::DisplayParts>;
fn into_parts(self) -> BoardParts<Self::RadioParts, Self::CommParts, Self::DisplayParts, Self::LedDriver>;
}
/// Peripheral bundles for each firmware task, produced by [`LoRaBoard::into_parts`].
pub struct BoardParts<R, C, D> {
pub struct BoardParts<R, C, D, L> {
pub radio: R,
pub host: C,
pub display: Option<D>,
pub led: Option<L>,
pub mac: [u8; 6],
}
+4 -1
View File
@@ -20,6 +20,7 @@ type RadioSpiDevice = SpiDevice<'static, NoopRawMutex, mcu::SpiBus, Nss>;
pub type RadioDriver = Sx126x<RadioSpiDevice, Iv, Sx1262>;
pub type UsbDriver = mcu::UsbNrfDriver;
pub type DisplayI2c = mcu::I2cBus;
pub type LedDriver = ();
pub type DisplayDriver = crate::driver::sh1106::Sh1106<DisplayI2c>;
@@ -66,6 +67,7 @@ impl LoRaBoard for Board {
type CommParts = UsbParts;
type DisplayParts = DisplayParts;
type DisplayDriver = DisplayDriver;
type LedDriver = ();
fn init() -> Self {
let mut config = embassy_nrf::config::Config::default();
@@ -79,7 +81,7 @@ impl LoRaBoard for Board {
mcu::mac_address()
}
fn into_parts(self) -> BoardParts<RadioParts, UsbParts, DisplayParts> {
fn into_parts(self) -> BoardParts<RadioParts, UsbParts, DisplayParts, ()> {
let p = self.p;
// ── SPI bus for SX1262 ──────────────────────────────────
@@ -133,6 +135,7 @@ impl LoRaBoard for Board {
radio,
host,
display,
led: None,
mac: Self::mac_address(),
}
}