feat: add display UI with splash screen, dashboard, and RSSI sparkline

Implement full SSD1306 OLED display support:
- Splash screen on boot (1.5s) with project name, version, board
- Dashboard showing radio state, config, counters, RSSI/SNR
- RSSI history sparkline (64-sample ring buffer, 2px-wide bars)
- DisplayOn/DisplayOff host commands routed via dedicated channel
- Pure render functions against generic DrawTarget (no hardware coupling)
- Display on/off control bypasses radio task entirely

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
swaitsandClaude Opus 4.6 committed 2026-03-29 20:42:54 -06:00
1 parent 7064110293
commit 3102fc747a
17624 files changed
+4564928 -38

No files matched your search

+11
View File
@@ -4,6 +4,17 @@ use embassy_sync::watch::Watch;
use crate::protocol::{Command, RadioConfig, Response};
/// Display commands routed from usb_task to display_task.
#[derive(Debug, Clone, Copy, defmt::Format)]
#[allow(dead_code)] // Constructed by usb_task once CDC-ACM is wired up
pub enum DisplayCommand {
On,
Off,
}
/// USB-to-display command channel.
pub type DisplayCommandChannel = Channel<CriticalSectionRawMutex, DisplayCommand, 4>;
/// Host-to-radio command channel.
pub type CommandChannel = Channel<CriticalSectionRawMutex, Command, 16>;
+217 -13
View File
@@ -1,15 +1,219 @@
use crate::channel::RadioStatus;
use core::fmt::Write;
/// Render the status dashboard to a framebuffer.
///
/// Pure logic — no hardware dependency. Takes a status snapshot and produces
/// draw calls that the display task flushes to the screen.
pub fn dashboard(status: &RadioStatus) {
// TODO: use embedded-graphics to render:
// Line 1: board name + radio state
// Line 2: freq / BW / SF
// Line 3: TX power / coding rate
// Line 4: RX count / TX count
// Line 5: last RSSI / SNR
let _ = status;
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 — one bar per 2px across 128px.
pub const RSSI_HISTORY_LEN: usize = 64;
// Layout constants
const W: i32 = 128;
const FONT_H: i32 = 10;
const SEP_Y: i32 = 40;
const SPARK_TOP: i32 = 42;
const SPARK_H: i32 = 22; // y=42..63
// RSSI mapping range (dBm)
const RSSI_MIN: i16 = -120;
const RSSI_MAX: i16 = 0;
/// Render the boot splash screen.
pub fn splash(
target: &mut impl DrawTarget<Color = BinaryColor>,
board_name: &str,
version: &str,
) {
let _ = target.clear(BinaryColor::Off);
let title_style = MonoTextStyle::new(&FONT_9X15_BOLD, BinaryColor::On);
let sub_style = MonoTextStyle::new(&FONT_6X10, BinaryColor::On);
// Title centered vertically and horizontally
Text::with_alignment("LoRa Dongle", Point::new(W / 2, 24), title_style, Alignment::Center)
.draw(target)
.ok();
// Version
Text::with_alignment(version, Point::new(W / 2, 42), sub_style, Alignment::Center)
.draw(target)
.ok();
// Board name
Text::with_alignment(board_name, Point::new(W / 2, 54), sub_style, Alignment::Center)
.draw(target)
.ok();
}
/// Render the main status dashboard.
pub fn dashboard(
target: &mut impl DrawTarget<Color = BinaryColor>,
status: &RadioStatus,
rssi_history: &[i16; RSSI_HISTORY_LEN],
rssi_count: usize,
) {
let _ = target.clear(BinaryColor::Off);
let style = MonoTextStyle::new(&FONT_6X10, BinaryColor::On);
match status.config {
Some(cfg) => {
// Row 0: state + frequency
let mut buf: String<21> = String::new();
let state_str = match status.state {
RadioState::Idle => "IDLE",
RadioState::Receiving => "RX",
RadioState::Transmitting => "TX",
};
let freq_whole = cfg.freq_hz / 1_000_000;
let freq_frac = (cfg.freq_hz % 1_000_000) / 100_000;
let _ = write!(buf, "{:<4} {}.{}MHz", state_str, freq_whole, freq_frac);
Text::new(&buf, Point::new(0, FONT_H - 1), style)
.draw(target)
.ok();
// Row 0: state indicator — inverted box behind state text
if status.state != RadioState::Idle {
let state_w = state_str.len() as i32 * 6;
let mut inv_style = MonoTextStyle::new(&FONT_6X10, BinaryColor::Off);
inv_style.set_background_color(Some(BinaryColor::On));
Rectangle::new(Point::new(0, 0), Size::new(state_w as u32, FONT_H as u32))
.into_styled(PrimitiveStyle::with_fill(BinaryColor::On))
.draw(target)
.ok();
Text::new(state_str, Point::new(0, FONT_H - 1), inv_style)
.draw(target)
.ok();
}
// Row 1: BW SF CR
buf.clear();
let bw_str = match cfg.bw {
Bandwidth::Khz7 => "7k",
Bandwidth::Khz10 => "10k",
Bandwidth::Khz15 => "15k",
Bandwidth::Khz20 => "20k",
Bandwidth::Khz31 => "31k",
Bandwidth::Khz41 => "41k",
Bandwidth::Khz62 => "62k",
Bandwidth::Khz125 => "125k",
Bandwidth::Khz250 => "250k",
Bandwidth::Khz500 => "500k",
};
let _ = write!(buf, "BW{} SF{} CR4/{}", bw_str, cfg.sf, cr_denom(cfg.cr));
Text::new(&buf, Point::new(0, FONT_H * 2 - 1), style)
.draw(target)
.ok();
// Row 2: TX power + counters
buf.clear();
let _ = write!(
buf,
"{}dBm RX:{} TX:{}",
cfg.tx_power_dbm, status.rx_count, status.tx_count
);
Text::new(&buf, Point::new(0, FONT_H * 3 - 1), style)
.draw(target)
.ok();
// Row 3: RSSI + SNR
buf.clear();
match (status.last_rssi, status.last_snr) {
(Some(rssi), Some(snr)) => {
let _ = write!(buf, "RSSI:{} SNR:{}", rssi, snr);
}
(Some(rssi), None) => {
let _ = write!(buf, "RSSI:{}", rssi);
}
_ => {
let _ = write!(buf, "No signal");
}
}
Text::new(&buf, Point::new(0, FONT_H * 4 - 1), style)
.draw(target)
.ok();
// Separator line
Line::new(Point::new(0, SEP_Y), Point::new(W - 1, SEP_Y))
.into_styled(PrimitiveStyle::with_stroke(BinaryColor::On, 1))
.draw(target)
.ok();
// RSSI sparkline
rssi_sparkline(target, rssi_history, rssi_count);
}
None => {
Text::new("IDLE", Point::new(0, FONT_H - 1), style)
.draw(target)
.ok();
Text::new("Waiting for host...", Point::new(0, FONT_H * 3 - 1), style)
.draw(target)
.ok();
}
}
}
/// Render the RSSI history as a bar-chart sparkline.
fn rssi_sparkline(
target: &mut impl DrawTarget<Color = BinaryColor>,
history: &[i16; RSSI_HISTORY_LEN],
count: usize,
) {
if count == 0 {
return;
}
let n = count.min(RSSI_HISTORY_LEN);
let fill = PrimitiveStyle::with_fill(BinaryColor::On);
for i in 0..n {
// Read from oldest to newest for left-to-right display.
// history is stored with newest at index count-1 (modular).
// We want bar 0 = oldest visible, bar n-1 = newest.
let idx = if count <= RSSI_HISTORY_LEN {
i
} else {
(count - RSSI_HISTORY_LEN + i) % RSSI_HISTORY_LEN
};
let rssi = history[idx].clamp(RSSI_MIN, RSSI_MAX);
// Map to bar height: 0 at RSSI_MIN, SPARK_H at RSSI_MAX
let bar_h = ((rssi - RSSI_MIN) as i32 * SPARK_H) / (RSSI_MAX - RSSI_MIN) as i32;
if bar_h == 0 {
continue;
}
let x = (RSSI_HISTORY_LEN - n + i) as i32 * 2; // right-align bars
let y = SPARK_TOP + SPARK_H - bar_h;
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<Color = BinaryColor>) {
let _ = target.clear(BinaryColor::Off);
}
fn cr_denom(cr: crate::protocol::CodingRate) -> u8 {
use crate::protocol::CodingRate;
match cr {
CodingRate::Cr4_5 => 5,
CodingRate::Cr4_6 => 6,
CodingRate::Cr4_7 => 7,
CodingRate::Cr4_8 => 8,
}
}
+104 -11
View File
@@ -1,22 +1,115 @@
use embassy_executor::task;
use embassy_futures::select::{select, Either};
use embassy_time::Timer;
use ssd1306::mode::DisplayConfig;
use ssd1306::prelude::DisplayRotation;
use ssd1306::size::DisplaySize128x64;
use ssd1306::{I2CDisplayInterface, Ssd1306};
use crate::board::DisplayParts;
use crate::channel::StatusWatch;
use crate::channel::{DisplayCommand, DisplayCommandChannel, StatusWatch};
use super::render;
use super::render::{self, RSSI_HISTORY_LEN};
#[task]
pub async fn display_task(parts: DisplayParts, status: &'static StatusWatch) {
let _i2c = parts.i2c;
const BOARD_NAME: &str = if cfg!(feature = "rak_4631") {
"rak_4631"
} else if cfg!(feature = "heltec_v3") {
"heltec_v3"
} else {
"unknown"
};
// TODO: init SSD1306 driver from I2C handle
// TODO: initial render of empty dashboard
struct DisplayState {
rssi_history: [i16; RSSI_HISTORY_LEN],
rssi_count: usize,
last_rx_count: u32,
display_on: bool,
}
let mut receiver = status.receiver().unwrap();
impl DisplayState {
fn new() -> Self {
Self {
rssi_history: [0; RSSI_HISTORY_LEN],
rssi_count: 0,
last_rx_count: 0,
display_on: true,
}
}
fn push_rssi(&mut self, rssi: i16) {
let idx = self.rssi_count % RSSI_HISTORY_LEN;
self.rssi_history[idx] = rssi;
self.rssi_count += 1;
}
}
#[task]
pub async fn display_task(
parts: DisplayParts,
status: &'static StatusWatch,
display_commands: &'static DisplayCommandChannel,
) {
let interface = I2CDisplayInterface::new(parts.i2c);
let mut display = Ssd1306::new(interface, DisplaySize128x64, DisplayRotation::Rotate0)
.into_buffered_graphics_mode();
if display.init().is_err() {
defmt::error!("SSD1306 init failed");
return;
}
// Splash screen
render::splash(&mut display, BOARD_NAME, env!("CARGO_PKG_VERSION"));
let _ = display.flush();
Timer::after_millis(1500).await;
// Dashboard
let mut state = DisplayState::new();
let mut receiver = status.receiver().unwrap();
// Initial empty dashboard
render::dashboard(
&mut display,
&crate::channel::RadioStatus::default(),
&state.rssi_history,
state.rssi_count,
);
let _ = display.flush();
loop {
let state = receiver.changed().await;
render::dashboard(&state);
// TODO: flush framebuffer to display
match select(receiver.changed(), display_commands.receive()).await {
Either::First(radio_status) => {
if !state.display_on {
continue;
}
// Push new RSSI sample only when rx_count advances
if radio_status.rx_count != state.last_rx_count {
state.last_rx_count = radio_status.rx_count;
if let Some(rssi) = radio_status.last_rssi {
state.push_rssi(rssi);
}
}
render::dashboard(
&mut display,
&radio_status,
&state.rssi_history,
state.rssi_count,
);
let _ = display.flush();
}
Either::Second(cmd) => match cmd {
DisplayCommand::Off => {
state.display_on = false;
render::blank(&mut display);
let _ = display.flush();
}
DisplayCommand::On => {
state.display_on = true;
// Will re-render on next status update
}
},
}
}
}
+6 -3
View File
@@ -10,7 +10,7 @@ mod usb;
use embassy_executor::Spawner;
use crate::channel::{CommandChannel, ResponseChannel, StatusWatch};
use crate::channel::{CommandChannel, DisplayCommandChannel, ResponseChannel, StatusWatch};
cfg_if::cfg_if! {
if #[cfg(feature = "rak_4631")] {
@@ -23,16 +23,19 @@ cfg_if::cfg_if! {
static COMMANDS: CommandChannel = CommandChannel::new();
static RESPONSES: ResponseChannel = ResponseChannel::new();
static STATUS: StatusWatch = StatusWatch::new();
static DISPLAY_COMMANDS: DisplayCommandChannel = DisplayCommandChannel::new();
#[embassy_executor::main]
async fn main(spawner: Spawner) {
let board = board::Board::init();
let (radio, usb, display) = board.into_parts();
let has_display = display.is_some();
spawner.spawn(radio::radio_task(radio, &COMMANDS, &RESPONSES, &STATUS)).unwrap();
spawner.spawn(usb::usb_task(usb, &COMMANDS, &RESPONSES)).unwrap();
spawner.spawn(usb::usb_task(usb, &COMMANDS, &RESPONSES, &DISPLAY_COMMANDS, has_display)).unwrap();
if let Some(dp) = display {
spawner.spawn(display::display_task(dp, &STATUS)).unwrap();
spawner.spawn(display::display_task(dp, &STATUS, &DISPLAY_COMMANDS)).unwrap();
}
}
+4
View File
@@ -55,6 +55,8 @@ pub enum Command {
config: Option<RadioConfig>,
payload: Vec<u8, MAX_PAYLOAD>,
},
DisplayOn,
DisplayOff,
}
/// Firmware → host responses.
@@ -70,6 +72,7 @@ pub enum Response {
payload: Vec<u8, MAX_PAYLOAD>,
},
TxDone,
Ok,
Error(ErrorCode),
}
@@ -82,4 +85,5 @@ pub enum ErrorCode {
TxTimeout = 3,
CrcError = 4,
NotConfigured = 5,
NoDisplay = 6,
}
+3
View File
@@ -66,6 +66,9 @@ pub async fn radio_task(
// TODO: put lora radio into standby
status.sender().send(state.clone());
}
Command::DisplayOn | Command::DisplayOff => {
// Routed by usb_task to display — should never reach radio.
}
Command::Transmit { config, payload } => {
let tx_config = config.or(state.config);
if let Some(_cfg) = tx_config {
+24 -3
View File
@@ -1,23 +1,44 @@
use embassy_executor::task;
use crate::board::UsbParts;
use crate::channel::{CommandChannel, ResponseChannel};
use crate::channel::{CommandChannel, DisplayCommandChannel, ResponseChannel};
#[task]
pub async fn usb_task(
parts: UsbParts,
commands: &'static CommandChannel,
responses: &'static ResponseChannel,
display_commands: &'static DisplayCommandChannel,
has_display: bool,
) {
let _driver = parts.driver;
let _ = commands;
let _ = responses;
let _ = display_commands;
let _ = has_display;
// TODO: build embassy_usb CDC-ACM class from driver
// TODO: COBS-framed postcard ser/de loop:
// select! {
// bytes = cdc_read() => deserialize Command → commands.send()
// resp = responses.receive() => serialize Response → cdc_write()
// bytes = cdc_read() => {
// let cmd: Command = deserialize(bytes);
// match cmd {
// Command::DisplayOn => route_display(DisplayCommand::On),
// Command::DisplayOff => route_display(DisplayCommand::Off),
// other => commands.send(other).await,
// }
// }
// resp = responses.receive() => serialize Response → cdc_write()
// }
//
// Display command routing:
// async fn route_display(cmd, display_commands, has_display, responses) {
// if has_display {
// display_commands.send(cmd).await;
// responses.send(Response::Ok).await;
// } else {
// responses.send(Response::Error(ErrorCode::NoDisplay)).await;
// }
// }
loop {