diff --git a/firmware/src/board/esp32s3.rs b/firmware/src/board/esp32s3.rs index fff4f37a..5026991a 100644 --- a/firmware/src/board/esp32s3.rs +++ b/firmware/src/board/esp32s3.rs @@ -15,6 +15,7 @@ use crate::hal::esp32s3 as mcu; // ── Re-export MCU types ───────────────────────────────────────────── +#[allow(unused_imports)] // Each board uses one of USB/UART, not both pub use mcu::{I2cBus as DisplayI2c, SpiBus, UartDriver, UsbOtgDriver as UsbDriver}; // ── Concrete peripheral types ─────────────────────────────────────── diff --git a/firmware/src/channel.rs b/firmware/src/channel.rs index 1eda48cb..5ea7e14a 100644 --- a/firmware/src/channel.rs +++ b/firmware/src/channel.rs @@ -12,8 +12,9 @@ use embassy_sync::watch::Watch; use crate::protocol::{Command, RadioConfig, Response}; -/// Display commands routed from usb_task to display_task. +/// Display commands routed from host_task to display_task. #[derive(Debug, Clone, Copy, defmt::Format)] +#[allow(dead_code)] // Reset is only used by USB boards (DTR disconnect detection) pub enum DisplayCommand { On, Off, diff --git a/firmware/src/display.rs b/firmware/src/display.rs new file mode 100644 index 00000000..4a735611 --- /dev/null +++ b/firmware/src/display.rs @@ -0,0 +1,648 @@ +//! OLED display dashboard: radio status, sparkline, splash screen. + +use embassy_executor::task; +use embassy_futures::select::{select3, Either3}; +use embassy_time::Timer; +use embedded_graphics::draw_target::DrawTarget; +use embedded_graphics::pixelcolor::BinaryColor; + +use crate::board::DisplayParts; +use crate::channel::{DisplayCommand, DisplayCommandChannel, RadioState, RadioStatus, StatusWatch}; + +use crate::board::{Board, LoRaBoard}; + +use render::RSSI_HISTORY_LEN; + +const BOARD_NAME: &str = Board::NAME; + +/// Duration per sparkline slot. 128 slots * 1s = ~2 minutes of history. +const SPARK_SLOT_MS: u64 = 1000; + +/// Sentinel: no packet received in this slot. Below SX1262 sensitivity +/// floor (-120 dBm), so it cannot be confused with a real RSSI value. +const NO_SIGNAL: i16 = -121; + +struct DisplayState { + rssi_history: [i16; RSSI_HISTORY_LEN], + tx_history: [bool; RSSI_HISTORY_LEN], + rssi_count: usize, + current_slot_rssi: i16, + current_slot_tx: bool, + display_on: bool, + disconnected: bool, + last_status: RadioStatus, +} + +impl DisplayState { + fn new() -> Self { + Self { + rssi_history: [NO_SIGNAL; RSSI_HISTORY_LEN], + tx_history: [false; RSSI_HISTORY_LEN], + rssi_count: 0, + current_slot_rssi: NO_SIGNAL, + current_slot_tx: false, + display_on: true, + disconnected: true, + last_status: RadioStatus::default(), + } + } + + /// Record an RSSI sample in the current time slot (keep best). + fn record_rssi(&mut self, rssi: i16) { + if self.current_slot_rssi == NO_SIGNAL || rssi > self.current_slot_rssi { + self.current_slot_rssi = rssi; + } + } + + /// Mark the current time slot as having a transmit. + fn record_tx(&mut self) { + self.current_slot_tx = true; + } + + /// Advance to the next time slot, committing the current slot's data. + fn advance_slot(&mut self) { + let idx = self.rssi_count % RSSI_HISTORY_LEN; + self.rssi_history[idx] = self.current_slot_rssi; + self.tx_history[idx] = self.current_slot_tx; + self.rssi_count += 1; + self.current_slot_rssi = NO_SIGNAL; + self.current_slot_tx = false; + } + + /// Whether the active dashboard should be shown (RX or TX mode). + fn is_active(&self) -> bool { + matches!( + self.last_status.state, + RadioState::Receiving | RadioState::Transmitting + ) + } +} + +/// Render the appropriate screen for the current state into the display buffer. +fn render_current( + display: &mut impl DrawTarget, + state: &DisplayState, + board: &render::BoardInfo<'_>, +) { + if state.is_active() { + render::dashboard( + display, + &state.last_status, + &state.rssi_history, + &state.tx_history, + state.rssi_count, + state.current_slot_rssi, + state.current_slot_tx, + board, + ); + } else { + render::splash(display, board); + } +} + +#[task] +pub async fn display_task( + parts: DisplayParts, + status: &'static StatusWatch, + display_commands: &'static DisplayCommandChannel, +) { + // Format MAC address as "XX:XX:XX:XX:XX:XX" + let mut mac_str: heapless::String<18> = heapless::String::new(); + let m = parts.mac; + let _ = core::fmt::Write::write_fmt( + &mut mac_str, + format_args!( + "{:02X}:{:02X}:{:02X}:{:02X}:{:02X}:{:02X}", + m[0], m[1], m[2], m[3], m[4], m[5] + ), + ); + + let board_info = render::BoardInfo { + name: BOARD_NAME, + version: env!("CARGO_PKG_VERSION"), + mac: &mac_str, + }; + + cfg_if::cfg_if! { + if #[cfg(feature = "wio_tracker_l1")] { + let mut display = crate::driver::sh1106::Sh1106::new(parts.i2c, 0x3D); + + Timer::after_millis(100).await; + + if display.init().await.is_err() { + defmt::error!("SH1106 init failed — retrying"); + Timer::after_millis(500).await; + if display.init().await.is_err() { + defmt::error!("SH1106 init failed twice, giving up"); + return; + } + } + if display.set_brightness(0xFF).await.is_err() { + defmt::warn!("display brightness set failed"); + } + } else { + use ssd1306::mode::DisplayConfigAsync; + use ssd1306::prelude::DisplayRotation; + use ssd1306::size::DisplaySize128x64; + use ssd1306::{I2CDisplayInterface, Ssd1306Async}; + + let interface = I2CDisplayInterface::new(parts.i2c); + let mut display = Ssd1306Async::new(interface, DisplaySize128x64, DisplayRotation::Rotate0) + .into_buffered_graphics_mode(); + + Timer::after_millis(100).await; + + if display.init().await.is_err() { + defmt::error!("SSD1306 init failed — retrying"); + Timer::after_millis(500).await; + if display.init().await.is_err() { + defmt::error!("SSD1306 init failed twice, giving up"); + return; + } + } + if display + .set_brightness(ssd1306::prelude::Brightness::BRIGHTEST) + .await + .is_err() + { + defmt::warn!("display brightness set failed"); + } + } + } + + let mut state = DisplayState::new(); + let Some(mut receiver) = status.receiver() else { + defmt::error!("no watch receiver available for display"); + return; + }; + + // Show splash/waiting screen + render::splash(&mut display, &board_info); + let _ = display.flush().await; + + loop { + match select3( + receiver.changed(), + display_commands.receive(), + Timer::after_millis(SPARK_SLOT_MS), + ) + .await + { + Either3::First(radio_status) => { + if state.disconnected { + continue; + } + if let Some(rssi) = radio_status.last_rssi { + if radio_status.rx_count != state.last_status.rx_count { + state.record_rssi(rssi); + } + } + if radio_status.tx_count != state.last_status.tx_count { + state.record_tx(); + } + state.last_status = radio_status; + + if state.display_on { + render_current(&mut display, &state, &board_info); + let _ = display.flush().await; + } + } + Either3::Second(cmd) => match cmd { + DisplayCommand::Off => { + state.disconnected = false; + state.display_on = false; + render::blank(&mut display); + let _ = display.flush().await; + } + DisplayCommand::On => { + state.disconnected = false; + state.display_on = true; + if let Some(s) = receiver.try_get() { + state.last_status = s; + } + render_current(&mut display, &state, &board_info); + let _ = display.flush().await; + } + DisplayCommand::Reset => { + state = DisplayState::new(); + render::splash(&mut display, &board_info); + let _ = display.flush().await; + } + }, + Either3::Third(()) => { + // Timer tick: advance sparkline slot + state.advance_slot(); + if state.display_on && state.is_active() { + render_current(&mut display, &state, &board_info); + let _ = display.flush().await; + } + } + } + } +} + +// ── Rendering ─────────────────────────────────────────────────────── + +mod render { + use core::fmt::Write; + + use embedded_graphics::mono_font::ascii::{FONT_6X10, FONT_9X15_BOLD}; + use embedded_graphics::mono_font::MonoTextStyle; + use embedded_graphics::pixelcolor::BinaryColor; + use embedded_graphics::prelude::*; + use embedded_graphics::primitives::{Line, PrimitiveStyle, Rectangle}; + use embedded_graphics::text::renderer::CharacterStyle; + use embedded_graphics::text::{Alignment, Text}; + use heapless::String; + + use crate::channel::{RadioState, RadioStatus}; + use crate::protocol::Bandwidth; + + /// RSSI ring buffer length — supports displays up to 256px wide (2px per bar). + pub const RSSI_HISTORY_LEN: usize = 128; + + // Font metrics (FONT_6X10) + const CHAR_W: i32 = 6; + const FONT_H: i32 = 10; + + // Mode box: 2 chars + 1px padding each side + const MODE_BOX_W: i32 = 2 * CHAR_W + 2; // 14px + + // RSSI mapping range (dBm) + const RSSI_MIN: i16 = -120; + const RSSI_MAX: i16 = 0; + + /// Static board identity info for display rendering. + pub struct BoardInfo<'a> { + pub name: &'a str, + pub version: &'a str, + pub mac: &'a str, + } + + /// Render the active dashboard (shown when radio is in RX or TX mode). + #[allow(clippy::too_many_arguments)] + pub fn dashboard( + target: &mut impl DrawTarget, + status: &RadioStatus, + rssi_history: &[i16; RSSI_HISTORY_LEN], + tx_history: &[bool; RSSI_HISTORY_LEN], + rssi_count: usize, + current_slot_rssi: i16, + current_slot_tx: bool, + board: &BoardInfo, + ) { + let bb = target.bounding_box(); + let w = bb.size.width as i32; + let h = bb.size.height as i32; + let title_x = MODE_BOX_W + 1; + let title_w = w - title_x; + let header_h = 2 * FONT_H; + let sep1_y = header_h + 3; // 3px gap below header text (clears descenders) + let info_y = sep1_y + 1; // 1px line, info text tight below separator + let sep2_y = info_y + 2 * FONT_H + 3; // 3px gap below info text (clears descenders) + let spark_top = sep2_y + 2; // 1px line + 1px gap above graph + let spark_h = h - spark_top; + let visible_bars = ((w / 2) as usize).min(RSSI_HISTORY_LEN); + + let _ = target.clear(BinaryColor::Off); + let style = MonoTextStyle::new(&FONT_6X10, BinaryColor::On); + let mut buf: String<32> = String::new(); + + // ── Mode box (left column, 2 rows tall) ───────────────────────── + let fill = PrimitiveStyle::with_fill(BinaryColor::On); + let mut inv_style = MonoTextStyle::new(&FONT_6X10, BinaryColor::Off); + inv_style.set_background_color(Some(BinaryColor::On)); + + match status.state { + RadioState::Receiving => { + // "RX" inverted on row 0 + Rectangle::new( + Point::new(0, 0), + Size::new(MODE_BOX_W as u32, FONT_H as u32), + ) + .into_styled(fill) + .draw(target) + .ok(); + Text::new("RX", Point::new(1, FONT_H - 1), inv_style) + .draw(target) + .ok(); + } + RadioState::Transmitting => { + // "TX" inverted on row 1 + Rectangle::new( + Point::new(0, FONT_H), + Size::new(MODE_BOX_W as u32, FONT_H as u32), + ) + .into_styled(fill) + .draw(target) + .ok(); + Text::new("TX", Point::new(1, 2 * FONT_H - 1), inv_style) + .draw(target) + .ok(); + } + RadioState::Idle => {} // Not shown on active screen + } + + // Vertical separator between mode box and title box + Line::new(Point::new(MODE_BOX_W, 0), Point::new(MODE_BOX_W, sep1_y)) + .into_styled(PrimitiveStyle::with_stroke(BinaryColor::On, 1)) + .draw(target) + .ok(); + + // ── Title box (right of mode box) ─────────────────────────────── + let title_center_x = title_x + title_w / 2; + + // Row 0: "DongLoRa v{version}" + buf.clear(); + let _ = write!(buf, "DongLoRa v{}", board.version); + Text::with_alignment( + &buf, + Point::new(title_center_x, FONT_H - 1), + style, + Alignment::Center, + ) + .draw(target) + .ok(); + + // Row 1: radio settings "{freq}/{bw}/{sf}/{cr}" + if let Some(cfg) = status.config { + buf.clear(); + let freq_mhz = cfg.freq_hz / 1_000_000; + let freq_khz = (cfg.freq_hz % 1_000_000) / 1_000; + let bw_str = match cfg.bw { + Bandwidth::Khz7 => "7.8", + Bandwidth::Khz10 => "10.4", + Bandwidth::Khz15 => "15.6", + Bandwidth::Khz20 => "20.8", + Bandwidth::Khz31 => "31.2", + Bandwidth::Khz41 => "41.7", + Bandwidth::Khz62 => "62.5", + Bandwidth::Khz125 => "125", + Bandwidth::Khz250 => "250", + Bandwidth::Khz500 => "500", + }; + let _ = write!( + buf, + "{}.{:03}/{}/{}/{}", + freq_mhz, freq_khz, bw_str, cfg.sf, cfg.cr + ); + Text::with_alignment( + &buf, + Point::new(title_center_x, 2 * FONT_H - 1), + style, + Alignment::Center, + ) + .draw(target) + .ok(); + } + + // ── First separator line ──────────────────────────────────────── + Line::new(Point::new(0, sep1_y), Point::new(w - 1, sep1_y)) + .into_styled(PrimitiveStyle::with_stroke(BinaryColor::On, 1)) + .draw(target) + .ok(); + + // ── Info rows (centered) ──────────────────────────────────────── + let center_x = w / 2; + + // Row 2: packet counters + buf.clear(); + let _ = write!( + buf, + "RX:{} TX:{}", + compact_count(status.rx_count), + compact_count(status.tx_count) + ); + Text::with_alignment( + &buf, + Point::new(center_x, info_y + FONT_H - 1), + style, + Alignment::Center, + ) + .draw(target) + .ok(); + + // Row 3: RSSI + SNR + buf.clear(); + match (status.last_rssi, status.last_snr) { + (Some(rssi), Some(snr)) => { + let _ = write!(buf, "RSSI:{}dBm SNR:{}dB", rssi, snr); + } + (Some(rssi), None) => { + let _ = write!(buf, "RSSI:{}dBm", rssi); + } + _ => { + let _ = write!(buf, "No signal"); + } + } + Text::with_alignment( + &buf, + Point::new(center_x, info_y + 2 * FONT_H - 1), + style, + Alignment::Center, + ) + .draw(target) + .ok(); + + // ── Second separator line ─────────────────────────────────────── + Line::new(Point::new(0, sep2_y), Point::new(w - 1, sep2_y)) + .into_styled(PrimitiveStyle::with_stroke(BinaryColor::On, 1)) + .draw(target) + .ok(); + + // ── Sparkline bar graph ───────────────────────────────────────── + if spark_h > 0 { + rssi_sparkline( + target, + rssi_history, + tx_history, + rssi_count, + current_slot_rssi, + current_slot_tx, + spark_top, + spark_h, + visible_bars, + ); + } + } + + /// Render the splash/waiting screen (shown when idle or no config). + pub fn splash(target: &mut impl DrawTarget, board: &BoardInfo) { + let bb = target.bounding_box(); + let w = bb.size.width as i32; + + let _ = target.clear(BinaryColor::Off); + let style = MonoTextStyle::new(&FONT_6X10, BinaryColor::On); + let title_style = MonoTextStyle::new(&FONT_9X15_BOLD, BinaryColor::On); + let mut buf: String<32> = String::new(); + + let center_x = w / 2; + + // Row 1: "DongLoRa" bold left, version small right + Text::new("DongLoRa", Point::new(4, 15), title_style) + .draw(target) + .ok(); + buf.clear(); + let _ = write!(buf, "v{}", board.version); + Text::with_alignment(&buf, Point::new(w - 2, 15), style, Alignment::Right) + .draw(target) + .ok(); + + // Row 2: board name + Text::with_alignment( + board.name, + Point::new(center_x, 28), + style, + Alignment::Center, + ) + .draw(target) + .ok(); + + // Row 3: MAC address + Text::with_alignment( + board.mac, + Point::new(center_x, 41), + style, + Alignment::Center, + ) + .draw(target) + .ok(); + + // Row 4: status + Text::with_alignment( + "Waiting for host...", + Point::new(center_x, 54), + style, + Alignment::Center, + ) + .draw(target) + .ok(); + } + + /// Format a u32 count compactly for display. + fn compact_count(n: u32) -> String<10> { + let mut s: String<10> = String::new(); + if n > 9_999_999 { + let _ = write!(s, "{}M", n / 1_000_000); + } else if n > 999_999 { + let _ = write!(s, "{}k", n / 1_000); + } else { + let _ = write!(s, "{}", n); + } + s + } + + /// Render the RSSI history as a bar-chart sparkline. + /// + /// The current (uncommitted) slot is rendered at the rightmost position + /// so that incoming packets appear on the graph immediately. + #[allow(clippy::too_many_arguments)] + fn rssi_sparkline( + target: &mut impl DrawTarget, + history: &[i16; RSSI_HISTORY_LEN], + tx_history: &[bool; RSSI_HISTORY_LEN], + count: usize, + current_rssi: i16, + current_tx: bool, + spark_top: i32, + spark_h: i32, + visible_bars: usize, + ) { + let live = current_rssi > RSSI_MIN || current_tx; + let committed = count.min(RSSI_HISTORY_LEN); + let hist_slots = if live { + committed.min(visible_bars.saturating_sub(1)) + } else { + committed.min(visible_bars) + }; + let total = hist_slots + if live { 1 } else { 0 }; + + if total == 0 { + return; + } + + let fill = PrimitiveStyle::with_fill(BinaryColor::On); + + // Draw committed history + for i in 0..hist_slots { + let idx = if count <= RSSI_HISTORY_LEN { + // Buffer hasn't wrapped: slots occupy indices 0..committed-1. + // Skip oldest entries that don't fit on screen. + let effective_bars = if live { visible_bars - 1 } else { visible_bars }; + i + committed.saturating_sub(effective_bars) + } else { + let start = count - RSSI_HISTORY_LEN; + let skip = + RSSI_HISTORY_LEN.saturating_sub(if live { visible_bars - 1 } else { visible_bars }); + (start + skip + i) % RSSI_HISTORY_LEN + }; + let is_tx = tx_history[idx]; + let rssi = history[idx]; + + if let Some(bar_h) = bar_height(rssi, is_tx, spark_h) { + let x = (visible_bars - total + i) as i32 * 2; + draw_bar(target, x, bar_h, is_tx, spark_top, spark_h, &fill); + } + } + + // Draw live (current) bar at the rightmost position + if live { + if let Some(bar_h) = bar_height(current_rssi, current_tx, spark_h) { + let x = (visible_bars - 1) as i32 * 2; + draw_bar(target, x, bar_h, current_tx, spark_top, spark_h, &fill); + } + } + } + + /// Compute the pixel height for a sparkline bar, or None to skip. + fn bar_height(rssi: i16, is_tx: bool, spark_h: i32) -> Option { + let h = if rssi <= RSSI_MIN { + if is_tx { + spark_h / 3 + } else { + return None; + } + } else { + let clamped = rssi.clamp(RSSI_MIN, RSSI_MAX); + ((clamped - RSSI_MIN) as i32 * spark_h) / (RSSI_MAX - RSSI_MIN) as i32 + }; + if h == 0 { + None + } else { + Some(h) + } + } + + /// Draw a single sparkline bar (solid for RX, dotted for TX). + fn draw_bar( + target: &mut impl DrawTarget, + x: i32, + bar_h: i32, + is_tx: bool, + spark_top: i32, + spark_h: i32, + fill: &PrimitiveStyle, + ) { + let y = spark_top + spark_h - bar_h; + if is_tx { + for row in 0..bar_h { + if row % 2 == 0 { + Rectangle::new(Point::new(x, y + row), Size::new(2, 1)) + .into_styled(*fill) + .draw(target) + .ok(); + } + } + } else { + Rectangle::new(Point::new(x, y), Size::new(2, bar_h as u32)) + .into_styled(*fill) + .draw(target) + .ok(); + } + } + + /// Clear the display (display-off state). + pub fn blank(target: &mut impl DrawTarget) { + let _ = target.clear(BinaryColor::Off); + } +} diff --git a/firmware/src/display/mod.rs b/firmware/src/display/mod.rs deleted file mode 100644 index a6e8de13..00000000 --- a/firmware/src/display/mod.rs +++ /dev/null @@ -1,5 +0,0 @@ -//! OLED display dashboard: radio status, sparkline, splash screen. - -mod render; -mod task; -pub use task::display_task; diff --git a/firmware/src/display/render.rs b/firmware/src/display/render.rs deleted file mode 100644 index 8914acfd..00000000 --- a/firmware/src/display/render.rs +++ /dev/null @@ -1,401 +0,0 @@ -use core::fmt::Write; - -use embedded_graphics::mono_font::ascii::{FONT_6X10, FONT_9X15_BOLD}; -use embedded_graphics::mono_font::MonoTextStyle; -use embedded_graphics::pixelcolor::BinaryColor; -use embedded_graphics::prelude::*; -use embedded_graphics::primitives::{Line, PrimitiveStyle, Rectangle}; -use embedded_graphics::text::renderer::CharacterStyle; -use embedded_graphics::text::{Alignment, Text}; -use heapless::String; - -use crate::channel::{RadioState, RadioStatus}; -use crate::protocol::Bandwidth; - -/// RSSI ring buffer length — supports displays up to 256px wide (2px per bar). -pub const RSSI_HISTORY_LEN: usize = 128; - -// Font metrics (FONT_6X10) -const CHAR_W: i32 = 6; -const FONT_H: i32 = 10; - -// Mode box: 2 chars + 1px padding each side -const MODE_BOX_W: i32 = 2 * CHAR_W + 2; // 14px - -// RSSI mapping range (dBm) -const RSSI_MIN: i16 = -120; -const RSSI_MAX: i16 = 0; - -/// Static board identity info for display rendering. -pub struct BoardInfo<'a> { - pub name: &'a str, - pub version: &'a str, - pub mac: &'a str, -} - -/// Render the active dashboard (shown when radio is in RX or TX mode). -#[allow(clippy::too_many_arguments)] -pub fn dashboard( - target: &mut impl DrawTarget, - status: &RadioStatus, - rssi_history: &[i16; RSSI_HISTORY_LEN], - tx_history: &[bool; RSSI_HISTORY_LEN], - rssi_count: usize, - current_slot_rssi: i16, - current_slot_tx: bool, - board: &BoardInfo, -) { - let bb = target.bounding_box(); - let w = bb.size.width as i32; - let h = bb.size.height as i32; - let title_x = MODE_BOX_W + 1; - let title_w = w - title_x; - let header_h = 2 * FONT_H; - let sep1_y = header_h + 3; // 3px gap below header text (clears descenders) - let info_y = sep1_y + 1; // 1px line, info text tight below separator - let sep2_y = info_y + 2 * FONT_H + 3; // 3px gap below info text (clears descenders) - let spark_top = sep2_y + 2; // 1px line + 1px gap above graph - let spark_h = h - spark_top; - let visible_bars = ((w / 2) as usize).min(RSSI_HISTORY_LEN); - - let _ = target.clear(BinaryColor::Off); - let style = MonoTextStyle::new(&FONT_6X10, BinaryColor::On); - let mut buf: String<32> = String::new(); - - // ── Mode box (left column, 2 rows tall) ───────────────────────── - let fill = PrimitiveStyle::with_fill(BinaryColor::On); - let mut inv_style = MonoTextStyle::new(&FONT_6X10, BinaryColor::Off); - inv_style.set_background_color(Some(BinaryColor::On)); - - match status.state { - RadioState::Receiving => { - // "RX" inverted on row 0 - Rectangle::new( - Point::new(0, 0), - Size::new(MODE_BOX_W as u32, FONT_H as u32), - ) - .into_styled(fill) - .draw(target) - .ok(); - Text::new("RX", Point::new(1, FONT_H - 1), inv_style) - .draw(target) - .ok(); - } - RadioState::Transmitting => { - // "TX" inverted on row 1 - Rectangle::new( - Point::new(0, FONT_H), - Size::new(MODE_BOX_W as u32, FONT_H as u32), - ) - .into_styled(fill) - .draw(target) - .ok(); - Text::new("TX", Point::new(1, 2 * FONT_H - 1), inv_style) - .draw(target) - .ok(); - } - RadioState::Idle => {} // Not shown on active screen - } - - // Vertical separator between mode box and title box - Line::new(Point::new(MODE_BOX_W, 0), Point::new(MODE_BOX_W, sep1_y)) - .into_styled(PrimitiveStyle::with_stroke(BinaryColor::On, 1)) - .draw(target) - .ok(); - - // ── Title box (right of mode box) ─────────────────────────────── - let title_center_x = title_x + title_w / 2; - - // Row 0: "DongLoRa v{version}" - buf.clear(); - let _ = write!(buf, "DongLoRa v{}", board.version); - Text::with_alignment( - &buf, - Point::new(title_center_x, FONT_H - 1), - style, - Alignment::Center, - ) - .draw(target) - .ok(); - - // Row 1: radio settings "{freq}/{bw}/{sf}/{cr}" - if let Some(cfg) = status.config { - buf.clear(); - let freq_mhz = cfg.freq_hz / 1_000_000; - let freq_khz = (cfg.freq_hz % 1_000_000) / 1_000; - let bw_str = match cfg.bw { - Bandwidth::Khz7 => "7.8", - Bandwidth::Khz10 => "10.4", - Bandwidth::Khz15 => "15.6", - Bandwidth::Khz20 => "20.8", - Bandwidth::Khz31 => "31.2", - Bandwidth::Khz41 => "41.7", - Bandwidth::Khz62 => "62.5", - Bandwidth::Khz125 => "125", - Bandwidth::Khz250 => "250", - Bandwidth::Khz500 => "500", - }; - let _ = write!( - buf, - "{}.{:03}/{}/{}/{}", - freq_mhz, freq_khz, bw_str, cfg.sf, cfg.cr - ); - Text::with_alignment( - &buf, - Point::new(title_center_x, 2 * FONT_H - 1), - style, - Alignment::Center, - ) - .draw(target) - .ok(); - } - - // ── First separator line ──────────────────────────────────────── - Line::new(Point::new(0, sep1_y), Point::new(w - 1, sep1_y)) - .into_styled(PrimitiveStyle::with_stroke(BinaryColor::On, 1)) - .draw(target) - .ok(); - - // ── Info rows (centered) ──────────────────────────────────────── - let center_x = w / 2; - - // Row 2: packet counters - buf.clear(); - let _ = write!( - buf, - "RX:{} TX:{}", - compact_count(status.rx_count), - compact_count(status.tx_count) - ); - Text::with_alignment( - &buf, - Point::new(center_x, info_y + FONT_H - 1), - style, - Alignment::Center, - ) - .draw(target) - .ok(); - - // Row 3: RSSI + SNR - buf.clear(); - match (status.last_rssi, status.last_snr) { - (Some(rssi), Some(snr)) => { - let _ = write!(buf, "RSSI:{}dBm SNR:{}dB", rssi, snr); - } - (Some(rssi), None) => { - let _ = write!(buf, "RSSI:{}dBm", rssi); - } - _ => { - let _ = write!(buf, "No signal"); - } - } - Text::with_alignment( - &buf, - Point::new(center_x, info_y + 2 * FONT_H - 1), - style, - Alignment::Center, - ) - .draw(target) - .ok(); - - // ── Second separator line ─────────────────────────────────────── - Line::new(Point::new(0, sep2_y), Point::new(w - 1, sep2_y)) - .into_styled(PrimitiveStyle::with_stroke(BinaryColor::On, 1)) - .draw(target) - .ok(); - - // ── Sparkline bar graph ───────────────────────────────────────── - if spark_h > 0 { - rssi_sparkline( - target, - rssi_history, - tx_history, - rssi_count, - current_slot_rssi, - current_slot_tx, - spark_top, - spark_h, - visible_bars, - ); - } -} - -/// Render the splash/waiting screen (shown when idle or no config). -pub fn splash(target: &mut impl DrawTarget, board: &BoardInfo) { - let bb = target.bounding_box(); - let w = bb.size.width as i32; - - let _ = target.clear(BinaryColor::Off); - let style = MonoTextStyle::new(&FONT_6X10, BinaryColor::On); - let title_style = MonoTextStyle::new(&FONT_9X15_BOLD, BinaryColor::On); - let mut buf: String<32> = String::new(); - - let center_x = w / 2; - - // Row 1: "DongLoRa" bold left, version small right - Text::new("DongLoRa", Point::new(4, 15), title_style) - .draw(target) - .ok(); - buf.clear(); - let _ = write!(buf, "v{}", board.version); - Text::with_alignment(&buf, Point::new(w - 2, 15), style, Alignment::Right) - .draw(target) - .ok(); - - // Row 2: board name - Text::with_alignment( - board.name, - Point::new(center_x, 28), - style, - Alignment::Center, - ) - .draw(target) - .ok(); - - // Row 3: MAC address - Text::with_alignment( - board.mac, - Point::new(center_x, 41), - style, - Alignment::Center, - ) - .draw(target) - .ok(); - - // Row 4: status - Text::with_alignment( - "Waiting for host...", - Point::new(center_x, 54), - style, - Alignment::Center, - ) - .draw(target) - .ok(); -} - -/// Format a u32 count compactly for display. -fn compact_count(n: u32) -> String<10> { - let mut s: String<10> = String::new(); - if n > 9_999_999 { - let _ = write!(s, "{}M", n / 1_000_000); - } else if n > 999_999 { - let _ = write!(s, "{}k", n / 1_000); - } else { - let _ = write!(s, "{}", n); - } - s -} - -/// Render the RSSI history as a bar-chart sparkline. -/// -/// The current (uncommitted) slot is rendered at the rightmost position -/// so that incoming packets appear on the graph immediately. -#[allow(clippy::too_many_arguments)] -fn rssi_sparkline( - target: &mut impl DrawTarget, - history: &[i16; RSSI_HISTORY_LEN], - tx_history: &[bool; RSSI_HISTORY_LEN], - count: usize, - current_rssi: i16, - current_tx: bool, - spark_top: i32, - spark_h: i32, - visible_bars: usize, -) { - let live = current_rssi > RSSI_MIN || current_tx; - let committed = count.min(RSSI_HISTORY_LEN); - let hist_slots = if live { - committed.min(visible_bars.saturating_sub(1)) - } else { - committed.min(visible_bars) - }; - let total = hist_slots + if live { 1 } else { 0 }; - - if total == 0 { - return; - } - - let fill = PrimitiveStyle::with_fill(BinaryColor::On); - - // Draw committed history - for i in 0..hist_slots { - let idx = if count <= RSSI_HISTORY_LEN { - // Buffer hasn't wrapped: slots occupy indices 0..committed-1. - // Skip oldest entries that don't fit on screen. - let effective_bars = if live { visible_bars - 1 } else { visible_bars }; - i + committed.saturating_sub(effective_bars) - } else { - let start = count - RSSI_HISTORY_LEN; - let skip = - RSSI_HISTORY_LEN.saturating_sub(if live { visible_bars - 1 } else { visible_bars }); - (start + skip + i) % RSSI_HISTORY_LEN - }; - let is_tx = tx_history[idx]; - let rssi = history[idx]; - - if let Some(bar_h) = bar_height(rssi, is_tx, spark_h) { - let x = (visible_bars - total + i) as i32 * 2; - draw_bar(target, x, bar_h, is_tx, spark_top, spark_h, &fill); - } - } - - // Draw live (current) bar at the rightmost position - if live { - if let Some(bar_h) = bar_height(current_rssi, current_tx, spark_h) { - let x = (visible_bars - 1) as i32 * 2; - draw_bar(target, x, bar_h, current_tx, spark_top, spark_h, &fill); - } - } -} - -/// Compute the pixel height for a sparkline bar, or None to skip. -fn bar_height(rssi: i16, is_tx: bool, spark_h: i32) -> Option { - let h = if rssi <= RSSI_MIN { - if is_tx { - spark_h / 3 - } else { - return None; - } - } else { - let clamped = rssi.clamp(RSSI_MIN, RSSI_MAX); - ((clamped - RSSI_MIN) as i32 * spark_h) / (RSSI_MAX - RSSI_MIN) as i32 - }; - if h == 0 { - None - } else { - Some(h) - } -} - -/// Draw a single sparkline bar (solid for RX, dotted for TX). -fn draw_bar( - target: &mut impl DrawTarget, - x: i32, - bar_h: i32, - is_tx: bool, - spark_top: i32, - spark_h: i32, - fill: &PrimitiveStyle, -) { - let y = spark_top + spark_h - bar_h; - if is_tx { - for row in 0..bar_h { - if row % 2 == 0 { - Rectangle::new(Point::new(x, y + row), Size::new(2, 1)) - .into_styled(*fill) - .draw(target) - .ok(); - } - } - } else { - Rectangle::new(Point::new(x, y), Size::new(2, bar_h as u32)) - .into_styled(*fill) - .draw(target) - .ok(); - } -} - -/// Clear the display (display-off state). -pub fn blank(target: &mut impl DrawTarget) { - let _ = target.clear(BinaryColor::Off); -} diff --git a/firmware/src/display/task.rs b/firmware/src/display/task.rs deleted file mode 100644 index a869c412..00000000 --- a/firmware/src/display/task.rs +++ /dev/null @@ -1,240 +0,0 @@ -use embassy_executor::task; -use embassy_futures::select::{select3, Either3}; -use embassy_time::Timer; -use embedded_graphics::draw_target::DrawTarget; -use embedded_graphics::pixelcolor::BinaryColor; - -use crate::board::DisplayParts; -use crate::channel::{DisplayCommand, DisplayCommandChannel, RadioState, RadioStatus, StatusWatch}; - -use crate::board::{Board, LoRaBoard}; - -use super::render::{self, RSSI_HISTORY_LEN}; - -const BOARD_NAME: &str = Board::NAME; - -/// Duration per sparkline slot. 128 slots * 1s = ~2 minutes of history. -const SPARK_SLOT_MS: u64 = 1000; - -/// Sentinel: no packet received in this slot. Below SX1262 sensitivity -/// floor (-120 dBm), so it cannot be confused with a real RSSI value. -const NO_SIGNAL: i16 = -121; - -struct DisplayState { - rssi_history: [i16; RSSI_HISTORY_LEN], - tx_history: [bool; RSSI_HISTORY_LEN], - rssi_count: usize, - current_slot_rssi: i16, - current_slot_tx: bool, - display_on: bool, - disconnected: bool, - last_status: RadioStatus, -} - -impl DisplayState { - fn new() -> Self { - Self { - rssi_history: [NO_SIGNAL; RSSI_HISTORY_LEN], - tx_history: [false; RSSI_HISTORY_LEN], - rssi_count: 0, - current_slot_rssi: NO_SIGNAL, - current_slot_tx: false, - display_on: true, - disconnected: true, - last_status: RadioStatus::default(), - } - } - - /// Record an RSSI sample in the current time slot (keep best). - fn record_rssi(&mut self, rssi: i16) { - if self.current_slot_rssi == NO_SIGNAL || rssi > self.current_slot_rssi { - self.current_slot_rssi = rssi; - } - } - - /// Mark the current time slot as having a transmit. - fn record_tx(&mut self) { - self.current_slot_tx = true; - } - - /// Advance to the next time slot, committing the current slot's data. - fn advance_slot(&mut self) { - let idx = self.rssi_count % RSSI_HISTORY_LEN; - self.rssi_history[idx] = self.current_slot_rssi; - self.tx_history[idx] = self.current_slot_tx; - self.rssi_count += 1; - self.current_slot_rssi = NO_SIGNAL; - self.current_slot_tx = false; - } - - /// Whether the active dashboard should be shown (RX or TX mode). - fn is_active(&self) -> bool { - matches!( - self.last_status.state, - RadioState::Receiving | RadioState::Transmitting - ) - } -} - -/// Render the appropriate screen for the current state into the display buffer. -fn render_current( - display: &mut impl DrawTarget, - state: &DisplayState, - board: &render::BoardInfo<'_>, -) { - if state.is_active() { - render::dashboard( - display, - &state.last_status, - &state.rssi_history, - &state.tx_history, - state.rssi_count, - state.current_slot_rssi, - state.current_slot_tx, - board, - ); - } else { - render::splash(display, board); - } -} - -#[task] -pub async fn display_task( - parts: DisplayParts, - status: &'static StatusWatch, - display_commands: &'static DisplayCommandChannel, -) { - // Format MAC address as "XX:XX:XX:XX:XX:XX" - let mut mac_str: heapless::String<18> = heapless::String::new(); - let m = parts.mac; - let _ = core::fmt::Write::write_fmt( - &mut mac_str, - format_args!( - "{:02X}:{:02X}:{:02X}:{:02X}:{:02X}:{:02X}", - m[0], m[1], m[2], m[3], m[4], m[5] - ), - ); - - let board_info = render::BoardInfo { - name: BOARD_NAME, - version: env!("CARGO_PKG_VERSION"), - mac: &mac_str, - }; - - cfg_if::cfg_if! { - if #[cfg(feature = "wio_tracker_l1")] { - let mut display = crate::driver::sh1106::Sh1106::new(parts.i2c, 0x3D); - - Timer::after_millis(100).await; - - if display.init().await.is_err() { - defmt::error!("SH1106 init failed — retrying"); - Timer::after_millis(500).await; - if display.init().await.is_err() { - defmt::error!("SH1106 init failed twice, giving up"); - return; - } - } - if display.set_brightness(0xFF).await.is_err() { - defmt::warn!("display brightness set failed"); - } - } else { - use ssd1306::mode::DisplayConfigAsync; - use ssd1306::prelude::DisplayRotation; - use ssd1306::size::DisplaySize128x64; - use ssd1306::{I2CDisplayInterface, Ssd1306Async}; - - let interface = I2CDisplayInterface::new(parts.i2c); - let mut display = Ssd1306Async::new(interface, DisplaySize128x64, DisplayRotation::Rotate0) - .into_buffered_graphics_mode(); - - Timer::after_millis(100).await; - - if display.init().await.is_err() { - defmt::error!("SSD1306 init failed — retrying"); - Timer::after_millis(500).await; - if display.init().await.is_err() { - defmt::error!("SSD1306 init failed twice, giving up"); - return; - } - } - if display - .set_brightness(ssd1306::prelude::Brightness::BRIGHTEST) - .await - .is_err() - { - defmt::warn!("display brightness set failed"); - } - } - } - - let mut state = DisplayState::new(); - let Some(mut receiver) = status.receiver() else { - defmt::error!("no watch receiver available for display"); - return; - }; - - // Show splash/waiting screen - render::splash(&mut display, &board_info); - let _ = display.flush().await; - - loop { - match select3( - receiver.changed(), - display_commands.receive(), - Timer::after_millis(SPARK_SLOT_MS), - ) - .await - { - Either3::First(radio_status) => { - if state.disconnected { - continue; - } - if let Some(rssi) = radio_status.last_rssi { - if radio_status.rx_count != state.last_status.rx_count { - state.record_rssi(rssi); - } - } - if radio_status.tx_count != state.last_status.tx_count { - state.record_tx(); - } - state.last_status = radio_status; - - if state.display_on { - render_current(&mut display, &state, &board_info); - let _ = display.flush().await; - } - } - Either3::Second(cmd) => match cmd { - DisplayCommand::Off => { - state.disconnected = false; - state.display_on = false; - render::blank(&mut display); - let _ = display.flush().await; - } - DisplayCommand::On => { - state.disconnected = false; - state.display_on = true; - if let Some(s) = receiver.try_get() { - state.last_status = s; - } - render_current(&mut display, &state, &board_info); - let _ = display.flush().await; - } - DisplayCommand::Reset => { - state = DisplayState::new(); - render::splash(&mut display, &board_info); - let _ = display.flush().await; - } - }, - Either3::Third(()) => { - // Timer tick: advance sparkline slot - state.advance_slot(); - if state.display_on && state.is_active() { - render_current(&mut display, &state, &board_info); - let _ = display.flush().await; - } - } - } - } -} diff --git a/firmware/src/hal/esp32s3.rs b/firmware/src/hal/esp32s3.rs index c110db23..eea960f5 100644 --- a/firmware/src/hal/esp32s3.rs +++ b/firmware/src/hal/esp32s3.rs @@ -17,7 +17,9 @@ use static_cell::StaticCell; pub type SpiBus = SpiDmaBus<'static, esp_hal::Async>; pub type I2cBus = I2c<'static, esp_hal::Async>; +#[allow(dead_code)] // Used by USB boards, not UART boards pub type UsbOtgDriver = esp_hal::otg_fs::asynch::Driver<'static>; +#[allow(dead_code)] // Used by UART boards, not USB boards pub type UartDriver = esp_hal::uart::Uart<'static, esp_hal::Async>; // ── Timer ─────────────────────────────────────────────────────────── @@ -81,6 +83,7 @@ pub fn init_i2c( // ── USB OTG ───────────────────────────────────────────────────────── /// Initialize USB OTG FS driver for CDC-ACM. +#[allow(dead_code)] // Used by USB boards, not UART boards pub fn init_usb( usb0: esp_hal::peripherals::USB0<'static>, dp: esp_hal::peripherals::GPIO20<'static>, @@ -99,6 +102,7 @@ pub fn init_usb( // ── UART ──────────────────────────────────────────────────────────── /// Initialize UART0 for boards with USB-UART bridge chips (e.g. CP2102). +#[allow(dead_code)] // Used by UART boards, not USB boards pub fn init_uart( uart0: esp_hal::peripherals::UART0<'static>, tx: esp_hal::peripherals::GPIO43<'static>, diff --git a/firmware/src/radio/task.rs b/firmware/src/radio.rs similarity index 100% rename from firmware/src/radio/task.rs rename to firmware/src/radio.rs diff --git a/firmware/src/radio/mod.rs b/firmware/src/radio/mod.rs deleted file mode 100644 index 6e5a859b..00000000 --- a/firmware/src/radio/mod.rs +++ /dev/null @@ -1,4 +0,0 @@ -//! LoRa radio control via SX1262. - -mod task; -pub use task::radio_task;