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:
43 files changed
+3309
-315
No files matched your search
+1
-1
@@ -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
@@ -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
@@ -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
@@ -68,7 +68,7 @@ pub enum Response {
|
||||
Config(RadioConfig),
|
||||
RxPacket {
|
||||
rssi: i16,
|
||||
snr: i8,
|
||||
snr: i16,
|
||||
payload: Vec<u8, MAX_PAYLOAD>,
|
||||
},
|
||||
TxDone,
|
||||
|
||||
+1
-1
@@ -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());
|
||||
|
||||
Reference in new issue
Block a user