refactor: flatten radio.rs and display.rs, zero warnings
- Flattened radio/ directory to radio.rs (was just one file) - Flattened display/ to display.rs (combined task + rendering as inline module) - Moved SH1106 display driver reference from display/ to driver/ (done in Phase 2) - Suppressed dead_code warnings for conditional USB/UART types in hal - All boards: zero warnings, zero cfg in application code
This commit is contained in:
1 parent
2c852b9f45
commit
622684cdde
9 files changed
+655
-651
No files matched your search
@@ -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 ───────────────────────────────────────
|
||||
|
||||
+2
-1
@@ -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,
|
||||
|
||||
+648
@@ -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<Color = BinaryColor>,
|
||||
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<Color = BinaryColor>,
|
||||
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<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.
|
||||
///
|
||||
/// 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<Color = BinaryColor>,
|
||||
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<i32> {
|
||||
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<Color = BinaryColor>,
|
||||
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;
|
||||
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<Color = BinaryColor>) {
|
||||
let _ = target.clear(BinaryColor::Off);
|
||||
}
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
//! OLED display dashboard: radio status, sparkline, splash screen.
|
||||
|
||||
mod render;
|
||||
mod task;
|
||||
pub use task::display_task;
|
||||
@@ -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<Color = BinaryColor>,
|
||||
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<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.
|
||||
///
|
||||
/// 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<Color = BinaryColor>,
|
||||
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<i32> {
|
||||
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<Color = BinaryColor>,
|
||||
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;
|
||||
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<Color = BinaryColor>) {
|
||||
let _ = target.clear(BinaryColor::Off);
|
||||
}
|
||||
@@ -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<Color = BinaryColor>,
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>,
|
||||
|
||||
File renamed without changes.
@@ -1,4 +0,0 @@
|
||||
//! LoRa radio control via SX1262.
|
||||
|
||||
mod task;
|
||||
pub use task::radio_task;
|
||||
Reference in new issue
Block a user