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:
swaitsandClaude Opus 4.6 committed 2026-03-29 20:42:56 -06:00
1 parent 024b03c7ad
commit 102ce417ff
6 files changed
+322 -77

No files matched your search

Generated
+1
View File
@@ -339,6 +339,7 @@ dependencies = [
"cortex-m-rt",
"critical-section",
"defmt 0.3.100",
"display-interface",
"embassy-embedded-hal",
"embassy-executor",
"embassy-futures",
+1
View File
@@ -20,6 +20,7 @@ serde = { version = "1.0", default-features = false, features = ["derive"] }
static_cell = "2.1"
# Display rendering
display-interface = "0.5"
embedded-graphics = "0.8"
ssd1306 = { version = "0.10", features = ["async"] }
+39 -41
View File
@@ -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
View File
@@ -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;
}
+3
View File
@@ -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 } => {
+206
View File
@@ -0,0 +1,206 @@
#!/usr/bin/env python3
"""DongLoRa test tool: ping, configure radio, listen for packets."""
# /// script
# requires-python = ">=3.10"
# dependencies = ["cobs", "pyserial"]
# ///
import struct
import serial
import sys
import time
from cobs import cobs
def cobs_frame(payload: bytes) -> bytes:
return cobs.encode(payload) + b"\x00"
def read_frame(ser: serial.Serial) -> bytes | None:
buf = b""
while True:
b = ser.read(1)
if not b:
return None
if b == b"\x00":
break
buf += b
return cobs.decode(buf) if buf else None
# ── Postcard serialization helpers ──────────────────────────────────
# Postcard uses varint encoding for enum variants and integers.
def varint(n: int) -> bytes:
"""Encode unsigned varint (postcard style)."""
out = []
while n >= 0x80:
out.append((n & 0x7F) | 0x80)
n >>= 7
out.append(n & 0x7F)
return bytes(out)
def encode_command(cmd: dict) -> bytes:
"""Encode a Command enum variant to postcard bytes."""
kind = cmd["type"]
if kind == "Ping":
return varint(0)
elif kind == "GetConfig":
return varint(1)
elif kind == "SetConfig":
cfg = cmd["config"]
return varint(2) + encode_radio_config(cfg)
elif kind == "StartRx":
return varint(3)
elif kind == "StopRx":
return varint(4)
elif kind == "DisplayOn":
return varint(7)
elif kind == "DisplayOff":
return varint(8)
else:
raise ValueError(f"Unknown command type: {kind}")
def encode_radio_config(cfg: dict) -> bytes:
"""Encode RadioConfig struct to postcard bytes."""
# freq_hz: u32 as varint
out = varint(cfg["freq_hz"])
# bw: Bandwidth enum (u8 repr)
out += varint(cfg["bw"])
# sf: u8
out += varint(cfg["sf"])
# cr: CodingRate enum (u8 repr)
out += varint(cfg["cr"])
# sync_word: u16 as varint
out += varint(cfg["sync_word"])
# tx_power_dbm: i8 as zigzag varint
out += zigzag(cfg["tx_power_dbm"])
return out
def zigzag(n: int) -> bytes:
"""Encode signed int as zigzag varint."""
return varint((n << 1) ^ (n >> 31) if n >= 0 else ((-n - 1) << 1) | 1)
def decode_response(data: bytes) -> dict:
"""Decode a Response enum variant from postcard bytes."""
variant = data[0]
rest = data[1:]
if variant == 0:
return {"type": "Pong"}
elif variant == 1:
return {"type": "Config", "raw": rest.hex()}
elif variant == 2:
# RxPacket: rssi(i16 zigzag), snr(i8 zigzag), payload(len-prefixed)
rssi, rest = decode_zigzag_varint(rest)
snr, rest = decode_zigzag_varint(rest)
plen, rest = decode_varint(rest)
payload = rest[:plen]
return {
"type": "RxPacket",
"rssi": rssi,
"snr": snr,
"payload": payload,
}
elif variant == 3:
return {"type": "TxDone"}
elif variant == 4:
return {"type": "Ok"}
elif variant == 5:
code = rest[0] if rest else -1
return {"type": "Error", "code": code}
else:
return {"type": f"Unknown({variant})", "raw": rest.hex()}
def decode_varint(data: bytes) -> tuple[int, bytes]:
n = 0
shift = 0
for i, b in enumerate(data):
n |= (b & 0x7F) << shift
shift += 7
if not (b & 0x80):
return n, data[i + 1 :]
return n, b""
def decode_zigzag_varint(data: bytes) -> tuple[int, bytes]:
n, rest = decode_varint(data)
return (n >> 1) ^ -(n & 1), rest
# ── Main ────────────────────────────────────────────────────────────
def send_cmd(ser: serial.Serial, cmd: dict, label: str) -> dict | None:
payload = encode_command(cmd)
frame = cobs_frame(payload)
print(f">>> {label}")
ser.write(frame)
ser.flush()
resp_data = read_frame(ser)
if resp_data is None:
print(" timeout")
return None
resp = decode_response(resp_data)
print(f"<<< {resp}")
return resp
def main():
port = sys.argv[1] if len(sys.argv) > 1 else "/dev/ttyACM0"
print(f"Opening {port}")
ser = serial.Serial(port, timeout=2)
ser.reset_input_buffer()
# Ping
send_cmd(ser, {"type": "Ping"}, "Ping")
# Configure radio: 910.525 MHz, BW 62.5k, SF7, CR 4/5
config = {
"freq_hz": 910_525_000,
"bw": 6, # Khz62 = 6
"sf": 7,
"cr": 1, # Cr4_5 = 1
"sync_word": 0x3444,
"tx_power_dbm": 14,
}
send_cmd(ser, {"type": "SetConfig", "config": config}, "SetConfig 910.525/62.5k/SF7/CR4_5")
# Start receiving
send_cmd(ser, {"type": "StartRx"}, "StartRx")
# Listen for packets
print("\nListening for packets (Ctrl+C to stop)...\n")
ser.timeout = None # block forever
try:
while True:
data = read_frame(ser)
if data is None:
continue
resp = decode_response(data)
if resp["type"] == "RxPacket":
payload = resp["payload"]
try:
text = payload.decode("utf-8", errors="replace")
except Exception:
text = payload.hex()
print(
f" RSSI:{resp['rssi']:4d} dBm "
f"SNR:{resp['snr']:3d} dB "
f"len:{len(payload):3d} "
f"payload: {text}"
)
else:
print(f" {resp}")
except KeyboardInterrupt:
print("\nStopping...")
send_cmd(ser, {"type": "StopRx"}, "StopRx")
if __name__ == "__main__":
main()