feat: replace direct P2P networking with iroh-gossip swarm and DHT peer discovery

Replace manually configured peer connections with a passphrase-based gossip
swarm. Peers sharing the same passphrase automatically discover each other
via mainline DHT and join an iroh-gossip topic.

Key changes:
- Add gossip.rs: iroh-gossip swarm with DHT bootstrap, heartbeat, and
  graceful shutdown that publishes neighbor list
- Add setup.rs: interactive config wizard replacing generate-config
- Add rate_limit.rs: token-bucket TX rate limiter derived from LoRa air time
- Remove network.rs: old direct P2P connection management
- Config: remove [[peers]] section, add passphrase to [bridge], update
  radio defaults to MeshCore US/Canada recommended values
- Packet: rename Envelope to GossipFrame, simplify wire format (drop
  version byte and seq number)
- Router: unify dedup into single content-hash cache, add rate limiting
- Identity: use ephemeral keypair per launch instead of persistent secret key
- Radio: improve config negotiation (GetConfig-first strategy), add device
  info tracking
- TUI: complete redesign with network/swarm panel, sparkline activity
  charts, color-coded RSSI/SNR, and shutdown overlay
This commit is contained in:
swaits committed 2026-04-05 19:00:26 -06:00
1 parent 4387549e7f
commit 4508f317be
12 files changed
+2025 -1229

No files matched your search

+119 -62
View File
@@ -20,8 +20,8 @@ pub enum ConfigSource {
pub enum RadioEvent {
/// A valid packet received from the radio (already SNR-filtered).
Packet(RadioPacket),
/// Radio connected (or reconnected) with the active config.
Connected(RadioConfig, ConfigSource),
/// Radio connected (or reconnected) with the active config and device info.
Connected(RadioConfig, ConfigSource, String),
/// Radio disconnected — attempting reconnect.
Disconnected,
}
@@ -56,7 +56,6 @@ fn radio_loop(
loop {
match connect_and_run(&config, &port, &event_tx, &mut tx_recv) {
Ok(()) => {
// Clean shutdown (channel closed).
info!("radio thread exiting");
return;
}
@@ -77,51 +76,57 @@ fn connect_and_run(
event_tx: &mpsc::Sender<RadioEvent>,
tx_recv: &mut mpsc::Receiver<Vec<u8>>,
) -> Result<()> {
let timeout = Duration::from_secs(2);
// Long initial timeout: the mux serializes commands through the dongle,
// so our GetConfig might have to wait behind other clients' commands.
let timeout = Duration::from_secs(10);
info!("connecting to dongle...");
let mut client = if let Some(port) = &port {
donglora_client::connect(Some(port), timeout)?
let (mut client, device) = if let Some(port) = &port {
let c = donglora_client::connect(Some(port), timeout)?;
(c, shorten_path(port))
} else {
donglora_client::connect_default()?
let c = donglora_client::connect_default()?;
(c, "mux".to_string())
};
info!("[radio] transport connected: {device}");
client.ping()?;
info!("dongle connected, configuring radio");
info!("[radio] sending ping...");
match client.ping() {
Ok(()) => info!("[radio] ping OK"),
Err(e) => {
info!("[radio] ping FAILED: {e:#}");
return Err(e);
}
}
// Try to set our config. If the mux rejects it (config already locked by
// another client), fall back to reading whatever config is active.
let (active_config, config_source) = match client.set_config(*config) {
Ok(()) => {
info!("radio configured with our settings");
(*config, ConfigSource::Ours)
info!("[radio] negotiating config...");
let (active_config, config_source) = match negotiate_config(&mut client, config) {
Ok(result) => {
info!("[radio] config negotiated: source={:?}, config={}", result.1, format_radio_config(&result.0));
result
}
Err(e) => {
warn!("SetConfig failed ({e:#}), reading active config from mux");
match client.get_config() {
Ok(mux_config) => {
info!(
"using mux config: {}",
format_radio_config(&mux_config)
);
(mux_config, ConfigSource::Mux)
}
Err(e2) => {
anyhow::bail!("SetConfig failed ({e:#}) and GetConfig also failed ({e2:#})");
}
}
info!("[radio] config negotiation FAILED: {e:#}");
return Err(e);
}
};
client.start_rx()?;
info!("radio receiving");
info!("[radio] sending start_rx...");
match client.start_rx() {
Ok(()) => info!("[radio] start_rx OK"),
Err(e) => {
info!("[radio] start_rx FAILED: {e:#}");
return Err(e);
}
}
let _ = event_tx.blocking_send(RadioEvent::Connected(active_config, config_source));
let _ = event_tx.blocking_send(RadioEvent::Connected(active_config, config_source, device));
// Set a short timeout so we can interleave RX and TX.
client
.transport_mut()
.set_timeout(Duration::from_millis(100))?;
info!("[radio] entering main loop");
loop {
// Check for TX requests (non-blocking).
@@ -143,7 +148,6 @@ fn connect_and_run(
);
let pkt = RadioPacket { rssi, snr, payload };
if event_tx.blocking_send(RadioEvent::Packet(pkt)).is_err() {
// Router dropped — shut down.
return Ok(());
}
} else {
@@ -153,33 +157,81 @@ fn connect_and_run(
);
}
}
Ok(Some(_)) => {
// Non-RxPacket unsolicited response — ignore.
}
Ok(None) => {
// Timeout — no packet, loop back to check TX.
}
Err(e) if is_timeout_error(&e) => {
// Mux sockets return WouldBlock on timeout instead of Ok(0).
// Treat the same as Ok(None) — just a timeout, not a real error.
}
Ok(Some(_)) => {}
Ok(None) => {}
Err(e) if is_timeout_error(&e) => {}
Err(e) => return Err(e),
}
// If the event channel is closed, the router is gone — exit cleanly.
if event_tx.is_closed() {
return Ok(());
}
}
}
/// Check if an anyhow::Error wraps a timeout-like I/O error.
/// Negotiate radio config with the dongle/mux.
///
/// Workaround: donglora-client's `read_frame` handles `TimedOut` but not
/// `WouldBlock`. On Linux, socket read timeouts return EAGAIN (WouldBlock),
/// not TimedOut. Serial ports return Ok(0) so they work fine, but mux
/// sockets surface this as an error. We catch it here instead of letting
/// it trigger the reconnect loop.
/// Strategy:
/// 1. GetConfig first to read what's active (caches the result).
/// 2. If it matches our desired config, use it — no SetConfig needed.
/// 3. If it differs, only try SetConfig if we haven't been rejected before.
/// 4. If SetConfig is rejected, set the flag IMMEDIATELY (before the mux can
/// disconnect us and lose this state), then return the cached GetConfig result.
///
/// On reconnect, the flag ensures we never retry SetConfig.
fn negotiate_config<T: Transport>(
client: &mut donglora_client::Client<T>,
desired: &RadioConfig,
) -> Result<(RadioConfig, ConfigSource)> {
info!("[negotiate] get_config...");
match client.get_config() {
Ok(cfg) => {
let source = if configs_match(&cfg, desired) {
info!("[negotiate] get_config OK (matches desired): {}", format_radio_config(&cfg));
ConfigSource::Ours
} else {
info!("[negotiate] get_config OK (differs from desired): {}", format_radio_config(&cfg));
ConfigSource::Mux
};
Ok((cfg, source))
}
Err(e) => {
info!("[negotiate] get_config FAILED: {e:#}");
// No config on radio — try to set ours.
info!("[negotiate] trying set_config...");
match client.set_config(*desired) {
Ok(()) => {
info!("[negotiate] set_config OK");
Ok((*desired, ConfigSource::Ours))
}
Err(e2) => {
info!("[negotiate] set_config FAILED: {e2:#}");
anyhow::bail!("GetConfig failed ({e:#}) and SetConfig also failed ({e2:#})");
}
}
}
}
}
/// Shorten a device path to just the filename for display.
fn shorten_path(path: &str) -> String {
std::path::Path::new(path)
.file_name()
.map(|f| f.to_string_lossy().into_owned())
.unwrap_or_else(|| path.to_string())
}
fn configs_match(a: &RadioConfig, b: &RadioConfig) -> bool {
a.freq_hz == b.freq_hz
&& a.bw == b.bw
&& a.sf == b.sf
&& a.cr == b.cr
&& a.sync_word == b.sync_word
&& a.tx_power_dbm == b.tx_power_dbm
&& a.preamble_len == b.preamble_len
&& a.cad == b.cad
}
fn is_timeout_error(e: &anyhow::Error) -> bool {
if let Some(io_err) = e.downcast_ref::<std::io::Error>() {
matches!(
@@ -191,25 +243,30 @@ fn is_timeout_error(e: &anyhow::Error) -> bool {
}
}
/// Format a bandwidth value for display.
pub fn format_bandwidth(bw: donglora_client::Bandwidth) -> &'static str {
match bw {
donglora_client::Bandwidth::Khz7 => "7.8 kHz",
donglora_client::Bandwidth::Khz10 => "10.4 kHz",
donglora_client::Bandwidth::Khz15 => "15.6 kHz",
donglora_client::Bandwidth::Khz20 => "20.8 kHz",
donglora_client::Bandwidth::Khz31 => "31.25 kHz",
donglora_client::Bandwidth::Khz41 => "41.7 kHz",
donglora_client::Bandwidth::Khz62 => "62.5 kHz",
donglora_client::Bandwidth::Khz125 => "125 kHz",
donglora_client::Bandwidth::Khz250 => "250 kHz",
donglora_client::Bandwidth::Khz500 => "500 kHz",
}
}
/// Format a RadioConfig for display.
pub fn format_radio_config(config: &RadioConfig) -> String {
let freq_mhz = config.freq_hz as f64 / 1_000_000.0;
let bw = match config.bw {
donglora_client::Bandwidth::Khz7 => "7.8kHz",
donglora_client::Bandwidth::Khz10 => "10.4kHz",
donglora_client::Bandwidth::Khz15 => "15.6kHz",
donglora_client::Bandwidth::Khz20 => "20.8kHz",
donglora_client::Bandwidth::Khz31 => "31.25kHz",
donglora_client::Bandwidth::Khz41 => "41.7kHz",
donglora_client::Bandwidth::Khz62 => "62.5kHz",
donglora_client::Bandwidth::Khz125 => "125kHz",
donglora_client::Bandwidth::Khz250 => "250kHz",
donglora_client::Bandwidth::Khz500 => "500kHz",
};
let bw = format_bandwidth(config.bw);
let power = if config.tx_power_dbm == donglora_client::TX_POWER_MAX {
"max".to_string()
} else {
format!("{}dBm", config.tx_power_dbm)
format!("{} dBm", config.tx_power_dbm)
};
let preamble = if config.preamble_len == 0 {
16