diff --git a/firmware/TODO.md b/firmware/TODO.md index 7f83a0ef..1ce960ad 100644 --- a/firmware/TODO.md +++ b/firmware/TODO.md @@ -1,8 +1,4 @@ -- get the code better organized -- firmware file naming, no "release", add version - LED blinking on TX/RX? colored? -- display having board specific stuff in it - publish new client, mux, bridge (updates in client for UART crap) -- try to get heltec v3 back to the right USB port+VID:PID - split out mux and client repos - move to GH organization diff --git a/firmware/src/board/heltec_v3.rs b/firmware/src/board/heltec_v3.rs index b84d134a..9658cf60 100644 --- a/firmware/src/board/heltec_v3.rs +++ b/firmware/src/board/heltec_v3.rs @@ -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 { + fn into_parts(self) -> BoardParts { 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(), } } diff --git a/firmware/src/board/heltec_v3_uart.rs b/firmware/src/board/heltec_v3_uart.rs index 90330b7f..6e6b4120 100644 --- a/firmware/src/board/heltec_v3_uart.rs +++ b/firmware/src/board/heltec_v3_uart.rs @@ -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 { + fn into_parts(self) -> BoardParts { 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(), } } diff --git a/firmware/src/board/heltec_v4.rs b/firmware/src/board/heltec_v4.rs index 1d201472..5603bea2 100644 --- a/firmware/src/board/heltec_v4.rs +++ b/firmware/src/board/heltec_v4.rs @@ -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 { + fn into_parts(self) -> BoardParts { 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(), } } diff --git a/firmware/src/board/rak_wisblock_4631.rs b/firmware/src/board/rak_wisblock_4631.rs index 561a519a..3d694d86 100644 --- a/firmware/src/board/rak_wisblock_4631.rs +++ b/firmware/src/board/rak_wisblock_4631.rs @@ -20,6 +20,7 @@ type RadioSpiDevice = SpiDevice<'static, NoopRawMutex, mcu::SpiBus, Nss>; pub type RadioDriver = Sx126x; pub type UsbDriver = mcu::UsbNrfDriver; pub type DisplayI2c = mcu::I2cBus; +pub type LedDriver = (); pub type DisplayDriver = ssd1306::Ssd1306Async< ssd1306::prelude::I2CInterface, @@ -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 { + fn into_parts(self) -> BoardParts { 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(), } } diff --git a/firmware/src/board/traits.rs b/firmware/src/board/traits.rs index 0f6d8ffb..339af0f9 100644 --- a/firmware/src/board/traits.rs +++ b/firmware/src/board/traits.rs @@ -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; +} + +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; + /// 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; + fn into_parts(self) -> BoardParts; } /// Peripheral bundles for each firmware task, produced by [`LoRaBoard::into_parts`]. -pub struct BoardParts { +pub struct BoardParts { pub radio: R, pub host: C, pub display: Option, + pub led: Option, pub mac: [u8; 6], } diff --git a/firmware/src/board/wio_tracker_l1.rs b/firmware/src/board/wio_tracker_l1.rs index 13b181c2..486cb602 100644 --- a/firmware/src/board/wio_tracker_l1.rs +++ b/firmware/src/board/wio_tracker_l1.rs @@ -20,6 +20,7 @@ type RadioSpiDevice = SpiDevice<'static, NoopRawMutex, mcu::SpiBus, Nss>; pub type RadioDriver = Sx126x; pub type UsbDriver = mcu::UsbNrfDriver; pub type DisplayI2c = mcu::I2cBus; +pub type LedDriver = (); pub type DisplayDriver = crate::driver::sh1106::Sh1106; @@ -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 { + fn into_parts(self) -> BoardParts { 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(), } } diff --git a/firmware/src/display.rs b/firmware/src/display.rs index 8e075886..5fdfbf33 100644 --- a/firmware/src/display.rs +++ b/firmware/src/display.rs @@ -6,11 +6,9 @@ use embassy_time::Timer; use embedded_graphics::draw_target::DrawTarget; use embedded_graphics::pixelcolor::BinaryColor; -use crate::board::DisplayParts; +use crate::board::{Board, DisplayParts, LedDriver, LoRaBoard, RgbLed}; use crate::channel::{DisplayCommand, DisplayCommandChannel, RadioState, RadioStatus, StatusWatch}; -use crate::board::{Board, LoRaBoard}; - use render::RSSI_HISTORY_LEN; const BOARD_NAME: &str = Board::NAME; @@ -103,6 +101,7 @@ fn render_current( #[task] pub async fn display_task( parts: DisplayParts, + mut led: Option, status: &'static StatusWatch, display_commands: &'static DisplayCommandChannel, ) { @@ -150,12 +149,15 @@ pub async fn display_task( if state.disconnected { continue; } + let rx_packet = radio_status.rx_count != state.last_status.rx_count; + let tx_packet = radio_status.tx_count != state.last_status.tx_count; + if let Some(rssi) = radio_status.last_rssi { - if radio_status.rx_count != state.last_status.rx_count { + if rx_packet { state.record_rssi(rssi); } } - if radio_status.tx_count != state.last_status.tx_count { + if tx_packet { state.record_tx(); } state.last_status = radio_status; @@ -163,6 +165,15 @@ pub async fn display_task( if state.display_on { render_current(&mut display, &state, &board_info); let _ = display.flush().await; + + // Blink LED: green on RX, red on TX + if let Some(ref mut led) = led { + if tx_packet { + led.set_rgb(32, 0, 0).await; + } else if rx_packet { + led.set_rgb(0, 32, 0).await; + } + } } } Either3::Second(cmd) => match cmd { @@ -171,6 +182,9 @@ pub async fn display_task( state.display_on = false; render::blank(&mut display); let _ = display.flush().await; + if let Some(ref mut led) = led { + led.set_rgb(0, 0, 0).await; + } } DisplayCommand::On => { state.disconnected = false; @@ -185,11 +199,17 @@ pub async fn display_task( state = DisplayState::new(); render::splash(&mut display, &board_info); let _ = display.flush().await; + if let Some(ref mut led) = led { + led.set_rgb(0, 0, 0).await; + } } }, Either3::Third(()) => { - // Timer tick: advance sparkline slot + // Timer tick: advance sparkline slot, turn off LED blink state.advance_slot(); + if let Some(ref mut led) = led { + led.set_rgb(0, 0, 0).await; + } if state.display_on && state.is_active() { render_current(&mut display, &state, &board_info); let _ = display.flush().await; diff --git a/firmware/src/driver/mod.rs b/firmware/src/driver/mod.rs index f895c6c9..7caf7055 100644 --- a/firmware/src/driver/mod.rs +++ b/firmware/src/driver/mod.rs @@ -1,2 +1,5 @@ #[cfg(feature = "wio_tracker_l1")] pub mod sh1106; + +#[cfg(any(feature = "heltec_v3", feature = "heltec_v3_uart", feature = "heltec_v4"))] +pub mod ws2812; diff --git a/firmware/src/driver/ws2812.rs b/firmware/src/driver/ws2812.rs new file mode 100644 index 00000000..46dbb58f --- /dev/null +++ b/firmware/src/driver/ws2812.rs @@ -0,0 +1,48 @@ +//! WS2812B single-LED driver using the ESP32-S3 RMT peripheral. +//! +//! Encodes RGB values as GRB PulseCodes and transmits via RMT. +//! At 80 MHz with divider=1, each tick is 12.5 ns. + +use esp_hal::gpio::Level; +use esp_hal::rmt::{Channel, PulseCode, Tx}; + +use crate::board::RgbLed; + +/// WS2812 0-bit: High 400ns (32 ticks), Low 850ns (68 ticks). +const BIT_0: PulseCode = PulseCode::new(Level::High, 32, Level::Low, 68); + +/// WS2812 1-bit: High 800ns (64 ticks), Low 450ns (36 ticks). +const BIT_1: PulseCode = PulseCode::new(Level::High, 64, Level::Low, 36); + +/// WS2812B single-LED driver over RMT. +pub struct Ws2812 { + channel: Channel<'static, esp_hal::Async, Tx>, +} + +impl Ws2812 { + pub fn new(channel: Channel<'static, esp_hal::Async, Tx>) -> Self { + Self { channel } + } + + /// Encode 3 bytes (GRB order) + end marker into PulseCodes. + fn encode(r: u8, g: u8, b: u8) -> [PulseCode; 25] { + let mut data = [PulseCode::end_marker(); 25]; + let bytes = [g, r, b]; // WS2812 expects GRB order + let mut idx = 0; + for byte in bytes { + for bit in (0..8).rev() { + data[idx] = if byte & (1 << bit) != 0 { BIT_1 } else { BIT_0 }; + idx += 1; + } + } + // data[24] is already end_marker + data + } +} + +impl RgbLed for Ws2812 { + async fn set_rgb(&mut self, r: u8, g: u8, b: u8) { + let data = Self::encode(r, g, b); + let _ = self.channel.transmit(&data).await; + } +} diff --git a/firmware/src/main.rs b/firmware/src/main.rs index c17b4b39..7ebd943e 100644 --- a/firmware/src/main.rs +++ b/firmware/src/main.rs @@ -96,7 +96,7 @@ async fn run(spawner: Spawner) { if let Some(dp) = parts.display { spawner - .spawn(display::display_task(dp, &STATUS, &DISPLAY_COMMANDS)) + .spawn(display::display_task(dp, parts.led, &STATUS, &DISPLAY_COMMANDS)) .expect("spawn display_task"); } }