fix: ack SetConfig/StartRx/StopRx, improve display layout
Protocol: - SetConfig, StartRx, StopRx now send Response::Ok on success (host was timing out waiting for acks that never came) Display: - Full frequency precision: 910.525MHz not 910.5MHz - Full bandwidth labels: 62.5kHz not 62k - RSSI/SNR row shows units (dBm, dB) - Separator line moved down 3px to give RSSI/SNR breathing room - Sparkline is now time-based: each bar = 1 second, scrolls left, showing ~1 minute of RSSI history. Empty slots = no packets. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
6 files changed
+322
-77
No files matched your search
+39
-41
@@ -18,9 +18,9 @@ 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
|
||||
const SEP_Y: i32 = 43;
|
||||
const SPARK_TOP: i32 = 45;
|
||||
const SPARK_H: i32 = 19; // y=45..63
|
||||
|
||||
// RSSI mapping range (dBm)
|
||||
const RSSI_MIN: i16 = -120;
|
||||
@@ -37,17 +37,14 @@ pub fn splash(
|
||||
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("DongLoRa", 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();
|
||||
@@ -65,21 +62,21 @@ pub fn dashboard(
|
||||
|
||||
match status.config {
|
||||
Some(cfg) => {
|
||||
// Row 0: state + frequency
|
||||
let mut buf: String<21> = String::new();
|
||||
// Row 0: state + frequency (full precision)
|
||||
let mut buf: String<32> = 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);
|
||||
let freq_mhz = cfg.freq_hz / 1_000_000;
|
||||
let freq_khz = (cfg.freq_hz % 1_000_000) / 1_000;
|
||||
let _ = write!(buf, "{:<4} {}.{:03}MHz", state_str, freq_mhz, freq_khz);
|
||||
Text::new(&buf, Point::new(0, FONT_H - 1), style)
|
||||
.draw(target)
|
||||
.ok();
|
||||
|
||||
// Row 0: state indicator — inverted box behind state text
|
||||
// Inverted state indicator when active
|
||||
if status.state != RadioState::Idle {
|
||||
let state_w = state_str.len() as i32 * 6;
|
||||
let mut inv_style = MonoTextStyle::new(&FONT_6X10, BinaryColor::Off);
|
||||
@@ -93,21 +90,21 @@ pub fn dashboard(
|
||||
.ok();
|
||||
}
|
||||
|
||||
// Row 1: BW SF CR
|
||||
// Row 1: BW (full) 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",
|
||||
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, "BW{} SF{} CR4/{}", bw_str, cfg.sf, cr_denom(cfg.cr));
|
||||
let _ = write!(buf, "{} SF{} CR4/{}", bw_str, cfg.sf, cr_denom(cfg.cr));
|
||||
Text::new(&buf, Point::new(0, FONT_H * 2 - 1), style)
|
||||
.draw(target)
|
||||
.ok();
|
||||
@@ -127,10 +124,10 @@ pub fn dashboard(
|
||||
buf.clear();
|
||||
match (status.last_rssi, status.last_snr) {
|
||||
(Some(rssi), Some(snr)) => {
|
||||
let _ = write!(buf, "RSSI:{} SNR:{}", rssi, snr);
|
||||
let _ = write!(buf, "RSSI:{} dBm SNR:{} dB", rssi, snr);
|
||||
}
|
||||
(Some(rssi), None) => {
|
||||
let _ = write!(buf, "RSSI:{}", rssi);
|
||||
let _ = write!(buf, "RSSI:{} dBm", rssi);
|
||||
}
|
||||
_ => {
|
||||
let _ = write!(buf, "No signal");
|
||||
@@ -140,7 +137,7 @@ pub fn dashboard(
|
||||
.draw(target)
|
||||
.ok();
|
||||
|
||||
// Separator line
|
||||
// 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)
|
||||
@@ -161,6 +158,10 @@ pub fn dashboard(
|
||||
}
|
||||
|
||||
/// 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. Bars with no packets in a slot show as empty.
|
||||
fn rssi_sparkline(
|
||||
target: &mut impl DrawTarget<Color = BinaryColor>,
|
||||
history: &[i16; RSSI_HISTORY_LEN],
|
||||
@@ -174,32 +175,29 @@ fn rssi_sparkline(
|
||||
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);
|
||||
let rssi = history[idx];
|
||||
if rssi <= RSSI_MIN {
|
||||
continue; // empty slot
|
||||
}
|
||||
let clamped = rssi.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;
|
||||
let bar_h = ((clamped - 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 x = (RSSI_HISTORY_LEN - n + i) as i32 * 2;
|
||||
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();
|
||||
Rectangle::new(Point::new(x, y), Size::new(2, bar_h as u32))
|
||||
.into_styled(fill)
|
||||
.draw(target)
|
||||
.ok();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+72
-36
@@ -1,5 +1,5 @@
|
||||
use embassy_executor::task;
|
||||
use embassy_futures::select::{select, Either};
|
||||
use embassy_futures::select::{select3, Either3};
|
||||
use embassy_time::Timer;
|
||||
use ssd1306::mode::DisplayConfigAsync;
|
||||
use ssd1306::prelude::DisplayRotation;
|
||||
@@ -7,7 +7,7 @@ use ssd1306::size::DisplaySize128x64;
|
||||
use ssd1306::{I2CDisplayInterface, Ssd1306Async};
|
||||
|
||||
use crate::board::DisplayParts;
|
||||
use crate::channel::{DisplayCommand, DisplayCommandChannel, StatusWatch};
|
||||
use crate::channel::{DisplayCommand, DisplayCommandChannel, RadioStatus, StatusWatch};
|
||||
|
||||
use super::render::{self, RSSI_HISTORY_LEN};
|
||||
|
||||
@@ -21,27 +21,44 @@ const BOARD_NAME: &str = if cfg!(feature = "rak_wisblock_4631") {
|
||||
"unknown"
|
||||
};
|
||||
|
||||
/// Duration per sparkline slot. 64 slots * 1s = ~1 minute of history.
|
||||
const SPARK_SLOT_MS: u64 = 1000;
|
||||
|
||||
/// Sentinel: no packet received in this slot.
|
||||
const NO_SIGNAL: i16 = -121;
|
||||
|
||||
struct DisplayState {
|
||||
rssi_history: [i16; RSSI_HISTORY_LEN],
|
||||
rssi_count: usize,
|
||||
last_rx_count: u32,
|
||||
current_slot_rssi: i16,
|
||||
display_on: bool,
|
||||
last_status: RadioStatus,
|
||||
}
|
||||
|
||||
impl DisplayState {
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
rssi_history: [0; RSSI_HISTORY_LEN],
|
||||
rssi_history: [NO_SIGNAL; RSSI_HISTORY_LEN],
|
||||
rssi_count: 0,
|
||||
last_rx_count: 0,
|
||||
current_slot_rssi: NO_SIGNAL,
|
||||
display_on: true,
|
||||
last_status: RadioStatus::default(),
|
||||
}
|
||||
}
|
||||
|
||||
fn push_rssi(&mut self, rssi: i16) {
|
||||
/// 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;
|
||||
}
|
||||
}
|
||||
|
||||
/// Advance to the next time slot, committing the current slot's RSSI.
|
||||
fn advance_slot(&mut self) {
|
||||
let idx = self.rssi_count % RSSI_HISTORY_LEN;
|
||||
self.rssi_history[idx] = rssi;
|
||||
self.rssi_history[idx] = self.current_slot_rssi;
|
||||
self.rssi_count += 1;
|
||||
self.current_slot_rssi = NO_SIGNAL;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -66,43 +83,34 @@ pub async fn display_task(
|
||||
let _ = display.flush().await;
|
||||
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().await;
|
||||
// Initial dashboard
|
||||
render_and_flush(&mut display, &state).await;
|
||||
|
||||
loop {
|
||||
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);
|
||||
match select3(
|
||||
receiver.changed(),
|
||||
display_commands.receive(),
|
||||
Timer::after_millis(SPARK_SLOT_MS),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Either3::First(radio_status) => {
|
||||
// Record RSSI from new packets
|
||||
if let Some(rssi) = radio_status.last_rssi {
|
||||
if radio_status.rx_count != state.last_status.rx_count {
|
||||
state.record_rssi(rssi);
|
||||
}
|
||||
}
|
||||
state.last_status = radio_status;
|
||||
|
||||
render::dashboard(
|
||||
&mut display,
|
||||
&radio_status,
|
||||
&state.rssi_history,
|
||||
state.rssi_count,
|
||||
);
|
||||
let _ = display.flush().await;
|
||||
if state.display_on {
|
||||
render_and_flush(&mut display, &state).await;
|
||||
}
|
||||
}
|
||||
Either::Second(cmd) => match cmd {
|
||||
Either3::Second(cmd) => match cmd {
|
||||
DisplayCommand::Off => {
|
||||
state.display_on = false;
|
||||
render::blank(&mut display);
|
||||
@@ -110,9 +118,37 @@ pub async fn display_task(
|
||||
}
|
||||
DisplayCommand::On => {
|
||||
state.display_on = true;
|
||||
// Will re-render on next status update
|
||||
render_and_flush(&mut display, &state).await;
|
||||
}
|
||||
},
|
||||
Either3::Third(()) => {
|
||||
// Timer tick: advance sparkline slot
|
||||
state.advance_slot();
|
||||
if state.display_on {
|
||||
render_and_flush(&mut display, &state).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The concrete display type is verbose but avoids making this generic
|
||||
// over both DrawTarget and the SSD1306-specific flush method.
|
||||
type Display<I> = ssd1306::Ssd1306Async<
|
||||
I,
|
||||
DisplaySize128x64,
|
||||
ssd1306::mode::BufferedGraphicsModeAsync<DisplaySize128x64>,
|
||||
>;
|
||||
|
||||
async fn render_and_flush<I>(display: &mut Display<I>, state: &DisplayState)
|
||||
where
|
||||
I: display_interface::AsyncWriteOnlyDataCommand,
|
||||
{
|
||||
render::dashboard(
|
||||
display,
|
||||
&state.last_status,
|
||||
&state.rssi_history,
|
||||
state.rssi_count,
|
||||
);
|
||||
let _ = display.flush().await;
|
||||
}
|
||||
@@ -113,6 +113,7 @@ async fn handle_cmd(
|
||||
Command::SetConfig(cfg) => {
|
||||
state.config = Some(cfg);
|
||||
status.sender().send(state.clone());
|
||||
responses.send(Response::Ok).await;
|
||||
}
|
||||
Command::StartRx => {
|
||||
if state.config.is_some() {
|
||||
@@ -120,6 +121,7 @@ async fn handle_cmd(
|
||||
Ok(()) => {
|
||||
state.state = RadioState::Receiving;
|
||||
status.sender().send(state.clone());
|
||||
responses.send(Response::Ok).await;
|
||||
}
|
||||
Err(e) => {
|
||||
warn!("StartRx failed: {}", e);
|
||||
@@ -134,6 +136,7 @@ async fn handle_cmd(
|
||||
let _ = lora.enter_standby().await;
|
||||
state.state = RadioState::Idle;
|
||||
status.sender().send(state.clone());
|
||||
responses.send(Response::Ok).await;
|
||||
}
|
||||
Command::DisplayOn | Command::DisplayOff => {}
|
||||
Command::Transmit { config, payload } => {
|
||||
|
||||
Reference in new issue
Block a user