diff --git a/Cargo.lock b/Cargo.lock index 677a6663..7fc8800b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -339,6 +339,7 @@ dependencies = [ "cortex-m-rt", "critical-section", "defmt 0.3.100", + "display-interface", "embassy-embedded-hal", "embassy-executor", "embassy-futures", diff --git a/Cargo.toml b/Cargo.toml index e40b81b7..7062d7b7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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"] } diff --git a/src/display/render.rs b/src/display/render.rs index 8d3a9e42..67bfab16 100644 --- a/src/display/render.rs +++ b/src/display/render.rs @@ -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, 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(); } } diff --git a/src/display/task.rs b/src/display/task.rs index 77c3b457..fc64a8f6 100644 --- a/src/display/task.rs +++ b/src/display/task.rs @@ -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 = ssd1306::Ssd1306Async< + I, + DisplaySize128x64, + ssd1306::mode::BufferedGraphicsModeAsync, +>; + +async fn render_and_flush(display: &mut Display, state: &DisplayState) +where + I: display_interface::AsyncWriteOnlyDataCommand, +{ + render::dashboard( + display, + &state.last_status, + &state.rssi_history, + state.rssi_count, + ); + let _ = display.flush().await; +} diff --git a/src/radio/task.rs b/src/radio/task.rs index 17f92274..c0c1192a 100644 --- a/src/radio/task.rs +++ b/src/radio/task.rs @@ -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 } => { diff --git a/tools/test_ping.py b/tools/test_ping.py new file mode 100644 index 00000000..d25823cc --- /dev/null +++ b/tools/test_ping.py @@ -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()