feat: MeshCore decoder, Watchman, test corpus, sparkline TX indicators

Firmware:
- fix(protocol): SNR type i8→i16 to match lora_phy PacketStatus
- fix(display): combine splash + waiting into one screen
- fix(display): move TX power to row 0 right-justified
- feat(display): sparkline distinguishes TX from RX — TX bars are dotted
  (alternating pixel rows), RX bars are solid. TX-only slots show a short
  marker. TX takes precedence over RX in the same time slot.

Host tool (tools/meshcore_rx.py):
- feat: full MeshCore packet decoder with colored ANSI output
- feat: GRP_TXT decryption using 276 channels from channels.csv
- feat: Watchman loop/collision reporter to #watchman
- feat: ADVERT-informed node registry with 12h TTL persistence
- feat: transport code fallback for older MeshCore firmware
- fix: robust error handling for long-running operation
- fix: collision detection uses evidence-based reasoning from advert cache

Channel directory (tools/channels.csv): 276 channels

Test corpus (corpus/): JSON Schema, 28 hand-crafted vectors, Hypothesis generator
This commit is contained in:
swaits committed 2026-03-29 20:42:56 -06:00
1 parent eab42e9ad8
commit 3451ceb652
43 files changed
+3309 -315

No files matched your search

+1 -1
View File
@@ -33,7 +33,7 @@ pub struct RadioStatus {
pub rx_count: u32,
pub tx_count: u32,
pub last_rssi: Option<i16>,
pub last_snr: Option<i8>,
pub last_snr: Option<i16>,
}
/// Radio state machine states.
+65 -51
View File
@@ -26,49 +26,22 @@ const SPARK_H: i32 = 19; // y=45..63
const RSSI_MIN: i16 = -120;
const RSSI_MAX: i16 = 0;
/// Render the boot splash screen.
pub fn splash(target: &mut impl DrawTarget<Color = BinaryColor>, board_name: &str, version: &str) {
let _ = target.clear(BinaryColor::Off);
let title_style = MonoTextStyle::new(&FONT_9X15_BOLD, BinaryColor::On);
let sub_style = MonoTextStyle::new(&FONT_6X10, BinaryColor::On);
Text::with_alignment(
"DongLoRa",
Point::new(W / 2, 24),
title_style,
Alignment::Center,
)
.draw(target)
.ok();
Text::with_alignment(version, Point::new(W / 2, 42), sub_style, Alignment::Center)
.draw(target)
.ok();
Text::with_alignment(
board_name,
Point::new(W / 2, 54),
sub_style,
Alignment::Center,
)
.draw(target)
.ok();
}
/// Render the main status dashboard.
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,
board_name: &str,
version: &str,
) {
let _ = target.clear(BinaryColor::Off);
let style = MonoTextStyle::new(&FONT_6X10, BinaryColor::On);
match status.config {
Some(cfg) => {
// Row 0: state + frequency (full precision)
// Row 0: state + frequency, TX power right-justified
let mut buf: String<32> = String::new();
let freq_mhz = cfg.freq_hz / 1_000_000;
let freq_khz = (cfg.freq_hz % 1_000_000) / 1_000;
@@ -76,6 +49,11 @@ pub fn dashboard(
Text::new(&buf, Point::new(0, FONT_H - 1), style)
.draw(target)
.ok();
buf.clear();
let _ = write!(buf, "{}dBm", cfg.tx_power_dbm);
Text::with_alignment(&buf, Point::new(W - 1, FONT_H - 1), style, Alignment::Right)
.draw(target)
.ok();
// State indicator: inverted "RX" or "TX", plain "IDLE"
match status.state {
@@ -133,13 +111,9 @@ pub fn dashboard(
.draw(target)
.ok();
// Row 2: TX power + counters
// Row 2: packet counters
buf.clear();
let _ = write!(
buf,
"{}dBm RX:{} TX:{}",
cfg.tx_power_dbm, status.rx_count, status.tx_count
);
let _ = write!(buf, "RX:{} TX:{}", status.rx_count, status.tx_count);
Text::new(&buf, Point::new(0, FONT_H * 3 - 1), style)
.draw(target)
.ok();
@@ -167,16 +141,33 @@ pub fn dashboard(
.draw(target)
.ok();
// RSSI sparkline
rssi_sparkline(target, rssi_history, rssi_count);
// RSSI sparkline (TX slots shown as dotted bars)
rssi_sparkline(target, rssi_history, tx_history, rssi_count);
}
None => {
Text::new("IDLE", Point::new(0, FONT_H - 1), style)
let title_style = MonoTextStyle::new(&FONT_9X15_BOLD, BinaryColor::On);
Text::with_alignment(
"DongLoRa",
Point::new(W / 2, 24),
title_style,
Alignment::Center,
)
.draw(target)
.ok();
Text::with_alignment(version, Point::new(W / 2, 38), style, Alignment::Center)
.draw(target)
.ok();
Text::new("Waiting for host...", Point::new(0, FONT_H * 3 - 1), style)
Text::with_alignment(board_name, Point::new(W / 2, 50), style, Alignment::Center)
.draw(target)
.ok();
Text::with_alignment(
"Waiting for host...",
Point::new(W / 2, 63),
style,
Alignment::Center,
)
.draw(target)
.ok();
}
}
}
@@ -185,10 +176,13 @@ pub fn dashboard(
///
/// 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.
/// regardless of packet rate. RX slots draw solid bars; TX slots draw
/// dotted bars (alternating pixel rows). TX takes precedence if both
/// occurred in the same slot.
fn rssi_sparkline(
target: &mut impl DrawTarget<Color = BinaryColor>,
history: &[i16; RSSI_HISTORY_LEN],
tx_history: &[bool; RSSI_HISTORY_LEN],
count: usize,
) {
if count == 0 {
@@ -204,13 +198,20 @@ fn rssi_sparkline(
} else {
(count - RSSI_HISTORY_LEN + i) % RSSI_HISTORY_LEN
};
let is_tx = tx_history[idx];
let rssi = history[idx];
if rssi <= RSSI_MIN {
continue; // empty slot
}
let clamped = rssi.clamp(RSSI_MIN, RSSI_MAX);
let bar_h = ((clamped - RSSI_MIN) as i32 * SPARK_H) / (RSSI_MAX - RSSI_MIN) as i32;
// TX slots with no RSSI: show a short fixed-height dotted bar
let bar_h = if rssi <= RSSI_MIN {
if is_tx {
SPARK_H / 3 // small marker for TX-only slots
} else {
continue; // empty slot
}
} else {
let clamped = rssi.clamp(RSSI_MIN, RSSI_MAX);
((clamped - RSSI_MIN) as i32 * SPARK_H) / (RSSI_MAX - RSSI_MIN) as i32
};
if bar_h == 0 {
continue;
}
@@ -218,10 +219,23 @@ fn rssi_sparkline(
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();
if is_tx {
// Dotted bar: draw every other pixel row
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 {
// Solid bar for RX
Rectangle::new(Point::new(x, y), Size::new(2, bar_h as u32))
.into_styled(fill)
.draw(target)
.ok();
}
}
}
+21 -7
View File
@@ -29,8 +29,10 @@ 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,
@@ -40,8 +42,10 @@ 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(),
@@ -55,12 +59,19 @@ impl DisplayState {
}
}
/// Advance to the next time slot, committing the current slot's 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;
}
}
@@ -80,15 +91,10 @@ pub async fn display_task(
}
let _ = display.set_brightness(ssd1306::prelude::Brightness::BRIGHTEST).await;
// Splash screen
render::splash(&mut display, BOARD_NAME, env!("CARGO_PKG_VERSION"));
let _ = display.flush().await;
Timer::after_millis(1500).await;
let mut state = DisplayState::new();
let mut receiver = status.receiver().unwrap();
// Initial dashboard
// Show splash/waiting screen (dashboard with no config renders it)
render_and_flush(&mut display, &state).await;
loop {
@@ -108,6 +114,9 @@ pub async fn display_task(
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 {
@@ -133,8 +142,10 @@ pub async fn display_task(
state.disconnected = true;
state.last_status = RadioStatus::default();
state.rssi_history = [NO_SIGNAL; RSSI_HISTORY_LEN];
state.tx_history = [false; RSSI_HISTORY_LEN];
state.rssi_count = 0;
state.current_slot_rssi = NO_SIGNAL;
state.current_slot_tx = false;
render_and_flush(&mut display, &state).await;
}
},
@@ -165,7 +176,10 @@ where
display,
&state.last_status,
&state.rssi_history,
&state.tx_history,
state.rssi_count,
BOARD_NAME,
env!("CARGO_PKG_VERSION"),
);
let _ = display.flush().await;
}
+1 -1
View File
@@ -68,7 +68,7 @@ pub enum Response {
Config(RadioConfig),
RxPacket {
rssi: i16,
snr: i8,
snr: i16,
payload: Vec<u8, MAX_PAYLOAD>,
},
TxDone,
+1 -1
View File
@@ -50,7 +50,7 @@ pub async fn radio_task(
Ok((len, pkt_status)) => {
state.rx_count = state.rx_count.wrapping_add(1);
let rssi = pkt_status.rssi;
let snr = pkt_status.snr as i8;
let snr = pkt_status.snr;
state.last_rssi = Some(rssi);
state.last_snr = Some(snr);
status.sender().send(state.clone());