feat: redesign active display layout with mode box and title box

New layout:
- Mode box (left): RX/TX indicator with vertical separator
- Title box (right): 'DongLoRa v{ver}' + radio settings (freq/bw/sf/cr)
- Centered info rows: packet counters + RSSI/SNR with labels
- Sparkline bar graph fills remaining height
- Idle state returns to splash screen (active screen only for RX/TX)
- Layout positions derived from display dimensions (h/w agnostic)
- RSSI history buffer doubled to 128 slots (~2 min at 1s/slot)
- Smart compact_count() formatting for large RX/TX counters (k/M)
This commit is contained in:
swaits committed 2026-03-30 15:32:45 -06:00
1 parent 466acfc0b6
commit b11fcac121
3 files changed
+336 -180

No files matched your search

+261 -169
View File
@@ -12,15 +12,15 @@ 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;
/// RSSI ring buffer length — supports displays up to 256px wide (2px per bar).
pub const RSSI_HISTORY_LEN: usize = 128;
// Layout constants
const W: i32 = 128;
// Font metrics (FONT_6X10)
const CHAR_W: i32 = 6;
const FONT_H: i32 = 10;
const SEP_Y: i32 = 43;
const SPARK_TOP: i32 = 45;
const SPARK_H: i32 = 19; // y=45..63
// 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;
@@ -33,7 +33,7 @@ pub struct BoardInfo<'a> {
pub mac: &'a str,
}
/// Render the main status dashboard.
/// Render the active dashboard (shown when radio is in RX or TX mode).
pub fn dashboard(
target: &mut impl DrawTarget<Color = BinaryColor>,
status: &RadioStatus,
@@ -44,163 +44,251 @@ pub fn dashboard(
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();
match status.config {
Some(cfg) => {
let freq_mhz = cfg.freq_hz / 1_000_000;
let freq_khz = (cfg.freq_hz % 1_000_000) / 1_000;
let _ = write!(buf, " {}.{:03}MHz", freq_mhz, freq_khz);
Text::new(&buf, Point::new(0, FONT_H - 1), style)
// ── 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();
buf.clear();
let _ = write!(buf, "{}dBm", cfg.tx_power_dbm);
Text::with_alignment(&buf, Point::new(W - 1, FONT_H - 1), style, Alignment::Right)
Text::new("RX", Point::new(1, FONT_H - 1), inv_style)
.draw(target)
.ok();
// State indicator: inverted "RX" or "TX", plain "IDLE"
match status.state {
RadioState::Idle => {
Text::new("IDLE", Point::new(0, FONT_H - 1), style)
.draw(target)
.ok();
}
RadioState::Receiving => {
let x = 0;
let char_w = 6;
let text_w = 2 * char_w; // "RX" = 2 chars
let mut inv_style = MonoTextStyle::new(&FONT_6X10, BinaryColor::Off);
inv_style.set_background_color(Some(BinaryColor::On));
Rectangle::new(Point::new(x, 0), Size::new(text_w as u32, FONT_H as u32))
.into_styled(PrimitiveStyle::with_fill(BinaryColor::On))
.draw(target)
.ok();
Text::new("RX", Point::new(x, FONT_H - 1), inv_style)
.draw(target)
.ok();
}
RadioState::Transmitting => {
let char_w = 6;
let x = 2 * char_w; // start at column 2 (third character position)
let text_w = 2 * char_w; // "TX" = 2 chars
let mut inv_style = MonoTextStyle::new(&FONT_6X10, BinaryColor::Off);
inv_style.set_background_color(Some(BinaryColor::On));
Rectangle::new(Point::new(x, 0), Size::new(text_w as u32, FONT_H as u32))
.into_styled(PrimitiveStyle::with_fill(BinaryColor::On))
.draw(target)
.ok();
Text::new("TX", Point::new(x, FONT_H - 1), inv_style)
.draw(target)
.ok();
}
}
// Row 1: BW (full) SF CR
buf.clear();
let bw_str = match cfg.bw {
Bandwidth::Khz7 => "7.8kHz",
Bandwidth::Khz10 => "10.4kHz",
Bandwidth::Khz15 => "15.6kHz",
Bandwidth::Khz20 => "20.8kHz",
Bandwidth::Khz31 => "31.2kHz",
Bandwidth::Khz41 => "41.7kHz",
Bandwidth::Khz62 => "62.5kHz",
Bandwidth::Khz125 => "125kHz",
Bandwidth::Khz250 => "250kHz",
Bandwidth::Khz500 => "500kHz",
};
let _ = write!(buf, "{} SF{} CR4/{}", bw_str, cfg.sf, cfg.cr);
Text::new(&buf, Point::new(0, FONT_H * 2 - 1), style)
.draw(target)
.ok();
// Row 2: packet counters
buf.clear();
let _ = write!(buf, "RX:{} TX:{}", status.rx_count, status.tx_count);
Text::new(&buf, Point::new(0, FONT_H * 3 - 1), style)
.draw(target)
.ok();
// Row 3: RSSI + SNR (compact to fit 21 chars)
buf.clear();
match (status.last_rssi, status.last_snr) {
(Some(rssi), Some(snr)) => {
let _ = write!(buf, "{}dBm SNR:{}dB", rssi, snr);
}
(Some(rssi), None) => {
let _ = write!(buf, "{}dBm", rssi);
}
_ => {
let _ = write!(buf, "No signal");
}
}
Text::new(&buf, Point::new(0, FONT_H * 4 - 1), style)
.draw(target)
.ok();
// Separator line (moved down to give RSSI/SNR breathing room)
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 (TX slots shown as dotted bars)
rssi_sparkline(
target,
rssi_history,
tx_history,
rssi_count,
current_slot_rssi,
current_slot_tx,
);
}
None => {
// Row 1: "DongLoRa" bold left, version small right
let title_style = MonoTextStyle::new(&FONT_9X15_BOLD, BinaryColor::On);
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(W / 2, 28), style, Alignment::Center)
.draw(target)
.ok();
// Row 3: MAC address
Text::with_alignment(board.mac, Point::new(W / 2, 41), style, Alignment::Center)
.draw(target)
.ok();
// Row 4: status
Text::with_alignment(
"Waiting for host...",
Point::new(W / 2, 54),
style,
Alignment::Center,
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<Color = BinaryColor>,
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.
///
/// Each bar represents one time slot. The display task advances the
/// slot index on a fixed timer, so each bar covers a constant duration
/// regardless of packet rate. RX slots draw solid bars; TX slots draw
/// dotted bars (alternating pixel rows). TX takes precedence if both
/// occurred in the same slot.
///
/// The current (uncommitted) slot is rendered at the rightmost position
/// so that incoming packets appear on the graph immediately, before the
/// slot timer commits them to history.
/// so that incoming packets appear on the graph immediately.
fn rssi_sparkline(
target: &mut impl DrawTarget<Color = BinaryColor>,
history: &[i16; RSSI_HISTORY_LEN],
@@ -208,15 +296,16 @@ fn rssi_sparkline(
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;
// Total bars: committed history + optional live bar
let committed = count.min(RSSI_HISTORY_LEN);
// If live bar is active, reserve the rightmost slot for it
let hist_slots = if live {
committed.min(RSSI_HISTORY_LEN - 1)
committed.min(visible_bars.saturating_sub(1))
} else {
committed
committed.min(visible_bars)
};
let total = hist_slots + if live { 1 } else { 0 };
@@ -226,51 +315,53 @@ fn rssi_sparkline(
let fill = PrimitiveStyle::with_fill(BinaryColor::On);
// Draw committed history (shifted left by 1 when live bar is present)
// Draw committed history
for i in 0..hist_slots {
let idx = if count <= RSSI_HISTORY_LEN {
// Buffer hasn't wrapped yet — if live bar is active, skip
// the oldest slot if we've hit the display limit.
if live && committed == RSSI_HISTORY_LEN {
// Full buffer + live: drop oldest committed slot
i + 1
if live && committed >= visible_bars {
let skip = committed - (visible_bars - 1);
i + skip
} else {
i
}
} else {
let start = count - RSSI_HISTORY_LEN;
let start = if live { start + 1 } else { start };
(start + i) % 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) {
let x = (RSSI_HISTORY_LEN - total + i) as i32 * 2;
draw_bar(target, x, bar_h, is_tx, &fill);
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) {
let x = (RSSI_HISTORY_LEN - 1) as i32 * 2;
draw_bar(target, x, bar_h, current_tx, &fill);
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) -> Option<i32> {
fn bar_height(rssi: i16, is_tx: bool, spark_h: i32) -> Option<i32> {
let h = if rssi <= RSSI_MIN {
if is_tx {
SPARK_H / 3
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
((clamped - RSSI_MIN) as i32 * spark_h) / (RSSI_MAX - RSSI_MIN) as i32
};
if h == 0 { None } else { Some(h) }
}
@@ -281,9 +372,11 @@ fn draw_bar(
x: i32,
bar_h: i32,
is_tx: bool,
spark_top: i32,
spark_h: i32,
fill: &PrimitiveStyle<BinaryColor>,
) {
let y = SPARK_TOP + SPARK_H - bar_h;
let y = spark_top + spark_h - bar_h;
if is_tx {
for row in 0..bar_h {
if row % 2 == 0 {
@@ -305,4 +398,3 @@ fn draw_bar(
pub fn blank(target: &mut impl DrawTarget<Color = BinaryColor>) {
let _ = target.clear(BinaryColor::Off);
}
+49 -11
View File
@@ -7,7 +7,9 @@ use ssd1306::size::DisplaySize128x64;
use ssd1306::{I2CDisplayInterface, Ssd1306Async};
use crate::board::DisplayParts;
use crate::channel::{DisplayCommand, DisplayCommandChannel, RadioStatus, StatusWatch};
use crate::channel::{
DisplayCommand, DisplayCommandChannel, RadioState, RadioStatus, StatusWatch,
};
use crate::board::{Board, LoRaBoard};
@@ -15,7 +17,7 @@ use super::render::{self, RSSI_HISTORY_LEN};
const BOARD_NAME: &str = Board::NAME;
/// Duration per sparkline slot. 64 slots * 1s = ~1 minute of history.
/// 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
@@ -68,6 +70,14 @@ impl DisplayState {
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
)
}
}
#[task]
@@ -115,8 +125,8 @@ pub async fn display_task(
return;
};
// Show splash/waiting screen (dashboard with no config renders it)
render_and_flush(&mut display, &state, &board_info).await;
// Show splash/waiting screen
render_splash(&mut display, &board_info).await;
loop {
match select3(
@@ -141,7 +151,7 @@ pub async fn display_task(
state.last_status = radio_status;
if state.display_on {
render_and_flush(&mut display, &state, &board_info).await;
render_current(&mut display, &state, &board_info).await;
}
}
Either3::Second(cmd) => match cmd {
@@ -157,18 +167,18 @@ pub async fn display_task(
if let Some(s) = receiver.try_get() {
state.last_status = s;
}
render_and_flush(&mut display, &state, &board_info).await;
render_current(&mut display, &state, &board_info).await;
}
DisplayCommand::Reset => {
state = DisplayState::new();
render_and_flush(&mut display, &state, &board_info).await;
render_splash(&mut display, &board_info).await;
}
},
Either3::Third(()) => {
// Timer tick: advance sparkline slot
state.advance_slot();
if state.display_on {
render_and_flush(&mut display, &state, &board_info).await;
if state.display_on && state.is_active() {
render_dashboard(&mut display, &state, &board_info).await;
}
}
}
@@ -183,8 +193,27 @@ type Display<I> = ssd1306::Ssd1306Async<
ssd1306::mode::BufferedGraphicsModeAsync<DisplaySize128x64>,
>;
async fn render_and_flush<I>(display: &mut Display<I>, state: &DisplayState, board: &render::BoardInfo<'_>)
where
/// Render the appropriate screen (active dashboard or splash) and flush.
async fn render_current<I>(
display: &mut Display<I>,
state: &DisplayState,
board: &render::BoardInfo<'_>,
) where
I: display_interface::AsyncWriteOnlyDataCommand,
{
if state.is_active() {
render_dashboard(display, state, board).await;
} else {
render_splash(display, board).await;
}
}
/// Render the active dashboard and flush.
async fn render_dashboard<I>(
display: &mut Display<I>,
state: &DisplayState,
board: &render::BoardInfo<'_>,
) where
I: display_interface::AsyncWriteOnlyDataCommand,
{
render::dashboard(
@@ -199,3 +228,12 @@ where
);
let _ = display.flush().await;
}
/// Render the splash/waiting screen and flush.
async fn render_splash<I>(display: &mut Display<I>, board: &render::BoardInfo<'_>)
where
I: display_interface::AsyncWriteOnlyDataCommand,
{
render::splash(display, board);
let _ = display.flush().await;
}