Files
firmware/src/display.rs
T
swaits 2a3c2be060 feat!: migrate firmware to DongLoRa Protocol v2 wire format
Wire-incompatible break with the 0.x protocol. Every host-side path (UART, USB CDC, framing, radio dispatcher, display update channel) speaks the v2 wire format defined in PROTOCOL.md.

* Replace the host framing path with streaming FrameDecoder + encode_frame from donglora-protocol, matching the new (type_id, tag, payload) shape.

* Update src/channel.rs and src/host/{uart,usb,framing}.rs for the new frame semantics: per-request tag echo, CRC checked in the decoder, async ERR fan-out on tag 0.

* Rework src/radio.rs to emit RX / TX_DONE / OK / ERR messages in v2 shape, including RxOrigin, TxDonePayload, and SetConfigResult codes.

* Update src/protocol.rs glue to re-export v2 types from donglora-protocol and drop the legacy 0.x shim surface.

* Refresh PROTOCOL.md to the v2 normative spec (kept in sync with the donglora-protocol crate).

* Minor display and main-loop cleanups needed for the new radio event shape.
2026-04-22 09:32:06 -06:00

919 lines
31 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! OLED display dashboard, driven by a hsmc statechart.
//!
//! ```text
//! Display
//! ├── Off display blank, LED off
//! └── On
//! ├── Splash radio idle / no activity
//! │ ├── LearnMore (default) QR code + "donglora.com"
//! │ └── Info firmware version + MAC
//! └── Dashboard
//! ├── Listening (default) radio in RX
//! └── Transmitting radio in TX
//! ```
//!
//! The two Splash substates alternate on a 5 s timer via `on(after …)`
//! transitions pointing at each other. No ticker, no timer task — hsmc
//! handles the flip entirely inside the statechart.
//!
//! `CmdReset` (sent by the host task on USB disconnect) runs a reset
//! action and re-enters `Splash`. This is order-idempotent with the
//! `RadioIdle` that arrives from the radio at the same moment: whichever
//! event the machine sees first, we end up in `Splash.LearnMore` with a
//! fresh timer and a cleared run state.
//!
//! Display subscribes to [`RadioEventChannel`] (published by the radio task)
//! and [`DisplayCommandChannel`] (published by the host task). Two trivial
//! forwarder futures translate each source into [`DisplayEvent`]s on the
//! machine's own channel — the machine is the single source of truth.
use core::time::Duration;
use embassy_executor::task;
use embassy_futures::join::join3;
use embassy_sync::blocking_mutex::raw::CriticalSectionRawMutex;
use embassy_sync::channel::Channel;
use embassy_time::Timer;
use hsmc::statechart;
use crate::board::{self, Board, DisplayDriver, DisplayParts, LedDriver, LoRaBoard, RgbLed};
use crate::channel::{DisplayCommand, DisplayCommandChannel, RadioEvent, RadioEventChannel};
use crate::protocol::LoRaConfig;
use render::{BoardInfo, RSSI_HISTORY_LEN};
const BOARD_NAME: &str = Board::NAME;
const VERSION: &str = env!("CARGO_PKG_VERSION");
/// 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;
/// Which dashboard badge is in view (drives the RX/TX indicator only).
#[derive(Debug, Clone, Copy)]
pub enum Badge {
Rx,
Tx,
}
/// Fine-grained events driving the `Display` statechart.
#[derive(Debug, Clone)]
pub enum DisplayEvent {
CmdOn,
CmdOff,
CmdReset,
/// Radio moved into RX mode.
EnteredRx,
/// Radio moved into TX mode.
EnteredTx,
/// Radio returned to Idle.
RadioIdle,
/// A packet was received (data only — does not imply a mode change).
PacketRx {
rssi: i16,
snr: Option<i16>,
},
/// A packet was transmitted (data only — does not imply a mode change).
PacketTx,
ConfigChanged(LoRaConfig),
}
impl From<RadioEvent> for DisplayEvent {
fn from(ev: RadioEvent) -> Self {
match ev {
RadioEvent::EnteredRx => DisplayEvent::EnteredRx,
RadioEvent::EnteredTx => DisplayEvent::EnteredTx,
RadioEvent::Idle => DisplayEvent::RadioIdle,
RadioEvent::PacketRx { rssi, snr } => DisplayEvent::PacketRx { rssi, snr },
RadioEvent::PacketTx => DisplayEvent::PacketTx,
RadioEvent::ConfigChanged(cfg) => DisplayEvent::ConfigChanged(cfg),
}
}
}
static DISPLAY_EVENTS: Channel<CriticalSectionRawMutex, DisplayEvent, 8> = Channel::new();
// ── Context ────────────────────────────────────────────────────────
/// Peripherals the display task owns for its entire lifetime.
struct Peripherals {
display: DisplayDriver,
led: LedDriver,
mac_str: heapless::String<18>,
}
/// Everything `CmdReset` should clear — the dashboard's "view" of the radio.
struct RunState {
rssi_history: [i16; RSSI_HISTORY_LEN],
tx_history: [bool; RSSI_HISTORY_LEN],
rssi_count: usize,
current_slot_rssi: i16,
current_slot_tx: bool,
config: Option<LoRaConfig>,
rx_count: u32,
tx_count: u32,
last_rssi: Option<i16>,
last_snr: Option<i16>,
}
impl RunState {
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,
config: None,
rx_count: 0,
tx_count: 0,
last_rssi: None,
last_snr: None,
}
}
fn record_rssi(&mut self, rssi: i16) {
if self.current_slot_rssi == NO_SIGNAL || rssi > self.current_slot_rssi {
self.current_slot_rssi = rssi;
}
}
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;
}
}
pub struct DisplayContext {
peripherals: Peripherals,
run: RunState,
}
impl DisplayContext {
async fn flush_info(&mut self) {
let Peripherals {
display, mac_str, ..
} = &mut self.peripherals;
let info = BoardInfo {
name: BOARD_NAME,
version: VERSION,
mac: mac_str,
};
render::info(display, &info);
let _ = display.flush().await;
}
async fn flush_learn_more(&mut self) {
let display = &mut self.peripherals.display;
render::learn_more(display);
let _ = display.flush().await;
}
async fn flush_dashboard(&mut self, badge: Badge) {
let Peripherals {
display, mac_str, ..
} = &mut self.peripherals;
let info = BoardInfo {
name: BOARD_NAME,
version: VERSION,
mac: mac_str,
};
render::dashboard(
display,
badge,
self.run.config,
self.run.rx_count,
self.run.tx_count,
self.run.last_rssi,
self.run.last_snr,
&self.run.rssi_history,
&self.run.tx_history,
self.run.rssi_count,
self.run.current_slot_rssi,
self.run.current_slot_tx,
&info,
);
let _ = display.flush().await;
}
async fn flush_blank(&mut self) {
render::blank(&mut self.peripherals.display);
let _ = self.peripherals.display.flush().await;
}
// Boards without an LED use `LedDriver = ()`; the `RgbLed for ()` impl
// makes `set_rgb` a noop returning `()`. Clippy's unit_arg lint
// misreads method calls on unit values as "passing a unit value to a
// function" — silence it here where the noop is intentional.
#[allow(clippy::unit_arg)]
async fn led_off(&mut self) {
self.peripherals.led.set_rgb(0, 0, 0).await;
}
#[allow(clippy::unit_arg)]
async fn led_blip(&mut self, r: u8, g: u8, b: u8) {
self.peripherals.led.set_rgb(r, g, b).await;
Timer::after_millis(50).await;
self.peripherals.led.set_rgb(0, 0, 0).await;
}
}
// ── Statechart ─────────────────────────────────────────────────────
statechart! {
Display {
context: DisplayContext;
events: DisplayEvent;
default(On);
state Off {
entry: blank_display, turn_led_off;
on(CmdOn) => On;
// Stay off, but scrub any stale run state so reconnecting
// doesn't surface old counters/sparkline.
on(CmdReset) => reset_run_state;
}
state On {
default(Splash);
on(CmdOff) => Off;
// `CmdReset` + `RadioIdle` race on USB disconnect; this handler
// makes either ordering land in the same place. Reset the run
// state, turn off any blink-in-progress, and re-enter Splash —
// descend_defaults lands us in LearnMore with a fresh 5 s timer.
on(CmdReset) => reset_run_state, turn_led_off, Splash;
// Config can arrive before the dashboard is up; cache it anywhere.
on(ConfigChanged(cfg: LoRaConfig)) => save_new_config;
state Splash {
default(LearnMore);
on(EnteredRx) => Listening;
on(EnteredTx) => Transmitting;
state LearnMore {
entry: paint_learn_more;
on(after Duration::from_secs(5)) => Info;
}
state Info {
entry: paint_info;
on(after Duration::from_secs(5)) => LearnMore;
}
}
state Dashboard {
default(Listening);
on(RadioIdle) => Splash;
// Packet events are pure data + one-shot LED feedback.
on(PacketRx { rssi: i16, snr: Option<i16> }) => record_rx_packet, blink_rx_led;
on(PacketTx) => record_tx_packet, blink_tx_led;
state Listening {
entry: paint_listening;
on(EnteredTx) => Transmitting;
on(every Duration::from_millis(1000)) => advance_sparkline, paint_listening;
}
state Transmitting {
entry: paint_transmitting;
on(EnteredRx) => Listening;
on(every Duration::from_millis(1000)) => advance_sparkline, paint_transmitting;
}
}
}
}
}
// ── Actions ────────────────────────────────────────────────────────
/// Map SNR (dB) to LED brightness (4..64). Stronger signal = brighter.
fn snr_brightness(snr: Option<i16>) -> u8 {
let snr = snr.unwrap_or(-10);
let clamped = snr.clamp(-20, 15) as i32;
((clamped + 20) * 60 / 35 + 4) as u8
}
impl DisplayActions for DisplayActionContext<'_> {
async fn blank_display(&mut self) {
self.flush_blank().await;
}
async fn turn_led_off(&mut self) {
self.led_off().await;
}
async fn reset_run_state(&mut self) {
self.run = RunState::new();
}
async fn paint_info(&mut self) {
self.flush_info().await;
}
async fn paint_learn_more(&mut self) {
self.flush_learn_more().await;
}
async fn paint_listening(&mut self) {
self.flush_dashboard(Badge::Rx).await;
}
async fn paint_transmitting(&mut self) {
self.flush_dashboard(Badge::Tx).await;
}
async fn record_rx_packet(&mut self, rssi: i16, snr: Option<i16>) {
self.run.rx_count = self.run.rx_count.wrapping_add(1);
self.run.last_rssi = Some(rssi);
self.run.last_snr = snr;
self.run.record_rssi(rssi);
}
async fn record_tx_packet(&mut self) {
self.run.tx_count = self.run.tx_count.wrapping_add(1);
self.run.current_slot_tx = true;
}
async fn blink_rx_led(&mut self, _rssi: i16, snr: Option<i16>) {
let brightness = snr_brightness(snr);
self.led_blip(0, brightness, 0).await;
}
async fn blink_tx_led(&mut self) {
self.led_blip(32, 0, 0).await;
}
async fn save_new_config(&mut self, cfg: LoRaConfig) {
self.run.config = Some(cfg);
}
async fn advance_sparkline(&mut self) {
self.run.advance_slot();
}
}
// ── Task entry point ───────────────────────────────────────────────
#[task]
pub async fn display_task(
parts: DisplayParts,
mut led: LedDriver,
radio_events: &'static RadioEventChannel,
display_commands: &'static DisplayCommandChannel,
) {
let mut mac_str: heapless::String<18> = heapless::String::new();
let m = Board::mac_address();
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 Some(display) = board::create_display(parts.i2c).await else {
defmt::error!("display init failed, running LED-only loop");
loop {
match display_commands.receive().await {
DisplayCommand::Reset | DisplayCommand::Off => {
led.set_rgb(0, 0, 0).await;
}
DisplayCommand::On => {}
}
}
};
let ctx = DisplayContext {
peripherals: Peripherals {
display,
led,
mac_str,
},
run: RunState::new(),
};
let mut machine = Display::new(ctx, &DISPLAY_EVENTS);
let sender = DisplaySender::from_channel(&DISPLAY_EVENTS);
let run = async {
let _ = machine.run().await;
};
let command_forwarder = async {
loop {
let ev = match display_commands.receive().await {
DisplayCommand::On => DisplayEvent::CmdOn,
DisplayCommand::Off => DisplayEvent::CmdOff,
DisplayCommand::Reset => DisplayEvent::CmdReset,
};
let _ = sender.send(ev).await;
}
};
let radio_event_forwarder = async {
loop {
let ev = radio_events.receive().await;
let _ = sender.send(ev.into()).await;
}
};
join3(run, command_forwarder, radio_event_forwarder).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 super::Badge;
use crate::protocol::{LoRaBandwidth, LoRaConfig};
pub const RSSI_HISTORY_LEN: usize = 128;
const CHAR_W: i32 = 6;
const FONT_H: i32 = 10;
const MODE_BOX_W: i32 = 2 * CHAR_W + 2;
const RSSI_MIN: i16 = -120;
const RSSI_MAX: i16 = 0;
/// Hand-authored 64 × 64 pixel-perfect QR bitmap for the splash
/// "learn more" screen. 1 bit/pixel, MSB-first, row-major. Bit `1` is
/// "light" (OLED pixel on), bit `0` is "dark" (OLED pixel off), so
/// when drawn with `BinaryColor::On` = lit, the QR appears with the
/// traditional dark-on-light look including a proper quiet zone.
const QR_WIDTH_PX: u32 = 64;
const QR_BITMAP: [u8; 512] = [
0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF,
0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF,
0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF,
0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFE, 0x00, 0x07, 0x9F,
0xFE, 0x60, 0x00, 0x7F, 0xFE, 0x00, 0x07, 0x9F, 0xFE, 0x60, 0x00, 0x7F, 0xFE, 0x7F, 0xE6,
0x18, 0x19, 0xE7, 0xFE, 0x7F, 0xFE, 0x7F, 0xE6, 0x18, 0x19, 0xE7, 0xFE, 0x7F, 0xFE, 0x60,
0x67, 0x81, 0xE6, 0x66, 0x06, 0x7F, 0xFE, 0x60, 0x67, 0x81, 0xE6, 0x66, 0x06, 0x7F, 0xFE,
0x60, 0x66, 0x19, 0x87, 0xE6, 0x06, 0x7F, 0xFE, 0x60, 0x66, 0x19, 0x87, 0xE6, 0x06, 0x7F,
0xFE, 0x60, 0x67, 0xE6, 0x1F, 0xE6, 0x06, 0x7F, 0xFE, 0x60, 0x67, 0xE6, 0x1F, 0xE6, 0x06,
0x7F, 0xFE, 0x7F, 0xE6, 0x67, 0x80, 0x67, 0xFE, 0x7F, 0xFE, 0x7F, 0xE6, 0x67, 0x80, 0x67,
0xFE, 0x7F, 0xFE, 0x00, 0x06, 0x66, 0x66, 0x60, 0x00, 0x7F, 0xFE, 0x00, 0x06, 0x66, 0x66,
0x60, 0x00, 0x7F, 0xFF, 0xFF, 0xFF, 0xFE, 0x61, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFE,
0x61, 0xFF, 0xFF, 0xFF, 0xFE, 0x00, 0x60, 0x06, 0x01, 0x99, 0x99, 0xFF, 0xFE, 0x00, 0x60,
0x06, 0x01, 0x99, 0x99, 0xFF, 0xFE, 0x01, 0x98, 0x1F, 0x9E, 0x79, 0xF9, 0xFF, 0xFE, 0x01,
0x98, 0x1F, 0x9E, 0x79, 0xF9, 0xFF, 0xFF, 0xE0, 0x66, 0x18, 0x61, 0x9F, 0x98, 0x7F, 0xFF,
0xE0, 0x66, 0x18, 0x61, 0x9F, 0x98, 0x7F, 0xFF, 0x98, 0x1E, 0x01, 0xE7, 0x99, 0xFE, 0x7F,
0xFF, 0x98, 0x1E, 0x01, 0xE7, 0x99, 0xFE, 0x7F, 0xFF, 0xE1, 0xE6, 0x19, 0xF8, 0x06, 0x60,
0x7F, 0xFF, 0xE1, 0xE6, 0x19, 0xF8, 0x06, 0x60, 0x7F, 0xFE, 0x19, 0xF8, 0x06, 0x1E, 0x79,
0x99, 0xFF, 0xFE, 0x19, 0xF8, 0x06, 0x1E, 0x79, 0x99, 0xFF, 0xFE, 0x7E, 0x01, 0xFF, 0x80,
0x18, 0x18, 0x7F, 0xFE, 0x7E, 0x01, 0xFF, 0x80, 0x18, 0x18, 0x7F, 0xFE, 0x7E, 0x19, 0x9E,
0x1E, 0x78, 0x7E, 0x7F, 0xFE, 0x7E, 0x19, 0x9E, 0x1E, 0x78, 0x7E, 0x7F, 0xFE, 0x60, 0x06,
0x66, 0x66, 0x00, 0x67, 0xFF, 0xFE, 0x60, 0x06, 0x66, 0x66, 0x00, 0x67, 0xFF, 0xFF, 0xFF,
0xFE, 0x67, 0xF8, 0x7E, 0x1F, 0xFF, 0xFF, 0xFF, 0xFE, 0x67, 0xF8, 0x7E, 0x1F, 0xFF, 0xFE,
0x00, 0x06, 0x00, 0x7E, 0x66, 0x60, 0x7F, 0xFE, 0x00, 0x06, 0x00, 0x7E, 0x66, 0x60, 0x7F,
0xFE, 0x7F, 0xE7, 0x99, 0xE6, 0x7E, 0x1E, 0x7F, 0xFE, 0x7F, 0xE7, 0x99, 0xE6, 0x7E, 0x1E,
0x7F, 0xFE, 0x60, 0x66, 0x61, 0xF8, 0x00, 0x60, 0x7F, 0xFE, 0x60, 0x66, 0x61, 0xF8, 0x00,
0x60, 0x7F, 0xFE, 0x60, 0x66, 0x06, 0x01, 0x86, 0x00, 0x7F, 0xFE, 0x60, 0x66, 0x06, 0x01,
0x86, 0x00, 0x7F, 0xFE, 0x60, 0x66, 0x67, 0x86, 0x7F, 0x86, 0x7F, 0xFE, 0x60, 0x66, 0x67,
0x86, 0x7F, 0x86, 0x7F, 0xFE, 0x7F, 0xE6, 0x66, 0x18, 0x00, 0x1E, 0x7F, 0xFE, 0x7F, 0xE6,
0x66, 0x18, 0x00, 0x1E, 0x7F, 0xFE, 0x00, 0x06, 0x66, 0x67, 0x98, 0x00, 0x7F, 0xFE, 0x00,
0x06, 0x66, 0x67, 0x98, 0x00, 0x7F, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF,
0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF,
0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF,
0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF,
0xFF, 0xFF,
];
pub struct BoardInfo<'a> {
pub name: &'a str,
pub version: &'a str,
pub mac: &'a str,
}
#[allow(clippy::too_many_arguments)]
pub fn dashboard(
target: &mut impl DrawTarget<Color = BinaryColor>,
badge: Badge,
config: Option<LoRaConfig>,
rx_count: u32,
tx_count: u32,
last_rssi: Option<i16>,
last_snr: Option<i16>,
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;
let info_y = sep1_y + 1;
let sep2_y = info_y + 2 * FONT_H + 3;
let spark_top = sep2_y + 2;
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();
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 badge {
Badge::Rx => {
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();
}
Badge::Tx => {
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();
}
}
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();
let title_center_x = title_x + title_w / 2;
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();
if let Some(cfg) = 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 {
LoRaBandwidth::Khz7 => "7.8",
LoRaBandwidth::Khz10 => "10.4",
LoRaBandwidth::Khz15 => "15.6",
LoRaBandwidth::Khz20 => "20.8",
LoRaBandwidth::Khz31 => "31.2",
LoRaBandwidth::Khz41 => "41.7",
LoRaBandwidth::Khz62 => "62.5",
LoRaBandwidth::Khz125 => "125",
LoRaBandwidth::Khz250 => "250",
LoRaBandwidth::Khz500 => "500",
LoRaBandwidth::Khz200 => "200",
LoRaBandwidth::Khz400 => "400",
LoRaBandwidth::Khz800 => "800",
LoRaBandwidth::Khz1600 => "1600",
};
// Show only the CR denominator; the "4/" numerator is implied for
// every LoRa coding rate and just clutters the line.
let cr_str = match cfg.cr {
crate::protocol::LoRaCodingRate::Cr4_5 => "5",
crate::protocol::LoRaCodingRate::Cr4_6 => "6",
crate::protocol::LoRaCodingRate::Cr4_7 => "7",
crate::protocol::LoRaCodingRate::Cr4_8 => "8",
};
let _ = write!(
buf,
"{}.{:03}/{}/{}/{}",
freq_mhz, freq_khz, bw_str, cfg.sf, cr_str
);
Text::with_alignment(
&buf,
Point::new(title_center_x, 2 * FONT_H - 1),
style,
Alignment::Center,
)
.draw(target)
.ok();
}
Line::new(Point::new(0, sep1_y), Point::new(w - 1, sep1_y))
.into_styled(PrimitiveStyle::with_stroke(BinaryColor::On, 1))
.draw(target)
.ok();
let center_x = w / 2;
buf.clear();
let _ = write!(
buf,
"RX:{} TX:{}",
compact_count(rx_count),
compact_count(tx_count)
);
Text::with_alignment(
&buf,
Point::new(center_x, info_y + FONT_H - 1),
style,
Alignment::Center,
)
.draw(target)
.ok();
buf.clear();
match (last_rssi, 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();
Line::new(Point::new(0, sep2_y), Point::new(w - 1, sep2_y))
.into_styled(PrimitiveStyle::with_stroke(BinaryColor::On, 1))
.draw(target)
.ok();
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,
);
}
}
pub fn info(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;
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();
Text::with_alignment(
board.name,
Point::new(center_x, 28),
style,
Alignment::Center,
)
.draw(target)
.ok();
Text::with_alignment(
board.mac,
Point::new(center_x, 41),
style,
Alignment::Center,
)
.draw(target)
.ok();
Text::with_alignment(
"Waiting for host...",
Point::new(center_x, 54),
style,
Alignment::Center,
)
.draw(target)
.ok();
}
/// Alternate splash screen: 64 × 64 pixel-perfect QR bitmap on the
/// right (with its own quiet zone baked in), centered text column on
/// the left inviting the user to visit the URL.
pub fn learn_more(target: &mut impl DrawTarget<Color = BinaryColor>) {
use embedded_graphics::image::{Image, ImageRaw};
use embedded_graphics::mono_font::ascii::{FONT_5X8, FONT_7X14_BOLD};
let bb = target.bounding_box();
let w = bb.size.width as i32;
let _ = target.clear(BinaryColor::Off);
// ── QR code: flush to the right edge, 1:1 pixel rendering ───
let qr_x = w - QR_WIDTH_PX as i32;
let qr_raw: ImageRaw<BinaryColor> = ImageRaw::new(&QR_BITMAP, QR_WIDTH_PX);
Image::new(&qr_raw, Point::new(qr_x, 0)).draw(target).ok();
// ── Text column (everything left of the QR) ─────────────────
let col_w = qr_x;
let col_center = col_w / 2;
let title_style = MonoTextStyle::new(&FONT_7X14_BOLD, BinaryColor::On);
let text_style = MonoTextStyle::new(&FONT_5X8, BinaryColor::On);
// Layout on a 64 px display:
// blank
// DongLoRa baseline 22
// blank
// Learn more: baseline 42
// donglora.com baseline 52
// blank
Text::with_alignment(
"DongLoRa",
Point::new(col_center, 22),
title_style,
Alignment::Center,
)
.draw(target)
.ok();
Text::with_alignment(
"Learn more:",
Point::new(col_center, 42),
text_style,
Alignment::Center,
)
.draw(target)
.ok();
Text::with_alignment(
"donglora.com",
Point::new(col_center, 52),
text_style,
Alignment::Center,
)
.draw(target)
.ok();
}
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
}
#[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);
for i in 0..hist_slots {
let idx = if count <= RSSI_HISTORY_LEN {
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);
}
}
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);
}
}
}
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)
}
}
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();
}
}
pub fn blank(target: &mut impl DrawTarget<Color = BinaryColor>) {
let _ = target.clear(BinaryColor::Off);
}
}