10 Commits
Author SHA1 Message Date
swaits bbe0239ab9 replace repo contents with redirect to donglora org 2026-04-07 14:57:17 -06:00
swaits 06ed2fa449 chore: add consistent mise.toml, justfile, and tooling config 2026-04-07 14:41:27 -06:00
swaits 521707a3c2 docs: clarify that bridge relays raw LoRa packets, protocol-agnostic 2026-04-06 17:16:09 -06:00
swaits 943f2615f3 fix: reduce TUI minimum width from 86 to 80 columns 2026-04-06 17:08:56 -06:00
swaits ae284d571a fix: remove stale sibling-clone CI step (donglora-client is on crates.io) 2026-04-06 17:05:22 -06:00
swaits ca26368136 feat: auto-detect connection — fall back to direct USB serial when mux is unavailable 2026-04-06 16:17:39 -06:00
swaits 6bae65c464 feat: publish-ready overhaul — lints, tests, docs, CI, fuzzing, mutant testing
- Enable clippy pedantic + nursery lints; fix all warnings
- Add config validation (SF, CR, frequency range checks)
- Zeroize passphrase intermediates after key derivation
- Convert to lib+bin crate for fuzz target accessibility
- Replace Vec with VecDeque for O(1) log/bucket rotation
- Add TUI minimum terminal size guard

Testing:
- 145 unit + property tests (was 15)
- 6 proptest groups (frame roundtrip, hash, SNR, air time, peer list, padding)
- 2 cargo-fuzz targets (gossip frame decode, peer list decode)
- cargo-mutants: 0 missed, 241 caught, 54 unviable

Documentation:
- README with prerequisites, quick start, config reference, architecture
- Module-level and item-level rustdoc on all public items
- LICENSE (MIT), rustfmt.toml, .gitignore

Infrastructure:
- GitHub Actions CI (fmt, clippy, test, audit)
- cargo-mutants config (.cargo/mutants.toml)
- justfile for common tasks
2026-04-06 14:52:18 -06:00
swaits b3bd92c5f8 fix: replace expect() with match in SIGTERM handler to satisfy clippy 2026-04-06 07:03:17 -06:00
swaits b4026ace34 feat: overhaul radio reconnection, TUI layout, DHT strategy, and signal handling
Radio reconnection:
- Add connect_mux_auto() to donglora-client — mux-only connect that never
  silently falls back to USB serial (matches Python client's sticky mux)
- Fix codec.rs read_frame() to handle WouldBlock alongside TimedOut
- Add liveness ping (2s) to detect dead mux connections
- Treat transmit errors as fatal (trigger reconnect)
- Faster backoff: 250ms initial, 5s max, resets after stable connection

TUI improvements:
- Redesign packet log: RF+Net columns show full packet path (RX→TX),
  hash column with dedup ellipsis, fixed-width columns throughout
- Move activity charts to right column (fills remaining width)
- 1-second sparkline buckets instead of 10-second
- Radio panel shows "(disconnected)" with red border when mux is down
- Header: status pills left, clock with TZ right-justified, Ctrl-C quits
- Local time via jiff crate with proper timezone abbreviation

DHT strategy:
- Alone: read every 3s, merge-publish every 30s (preserves existing peers)
- Connected: publish authoritative [self + neighbors], purging stale peers
- Publish immediately on first neighbor join

Signal handling:
- SIGINT and SIGTERM both trigger graceful shutdown via cancellation token
- All paths go through swarm.shutdown() to update DHT before exit
2026-04-05 21:43:36 -06:00
swaits 4508f317be 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
2026-04-05 19:00:26 -06:00
11 changed files with 25 additions and 6891 deletions

No files matched your search

-1
View File
@@ -1 +0,0 @@
/target
Generated
-4705
View File
File diff suppressed because it is too large. Load diff
-32
View File
@@ -1,32 +0,0 @@
[package]
name = "donglora-bridge"
version = "0.1.0"
edition = "2024"
authors = ["Stephen Waits <steve@waits.net>"]
license = "MIT"
description = "Peer-to-peer LoRa bridge using iroh and DongLoRa"
[dependencies]
donglora-client = { path = "../donglora/clients/rust" }
iroh = "0.97"
tokio = { version = "1", features = ["full"] }
clap = { version = "4", features = ["derive"] }
serde = { version = "1", features = ["derive"] }
toml = "0.8"
blake3 = "1"
ratatui = "0.29"
crossterm = "0.28"
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
tracing-appender = "0.2"
anyhow = "1"
dirs = "6"
bytes = "1"
rand = "0.9"
hex = "0.4.3"
tokio-util = { version = "0.7.18", features = ["rt"] }
[lints.clippy]
unwrap_used = "deny"
expect_used = "deny"
panic = "deny"
+25
View File
@@ -0,0 +1,25 @@
# donglora-bridge has moved
This repository has moved to the
[**donglora** GitHub organization](https://github.com/donglora):
**[donglora/bridge](https://github.com/donglora/bridge)**
donglora-bridge connects LoRa radios to the internet so packets heard by
one radio can be retransmitted by another radio anywhere in the world.
Bridges discover each other via the Mainline DHT and communicate through
an encrypted peer-to-peer gossip swarm — no central server required.
## Other DongLoRa repos
| What | Location |
|------|----------|
| Firmware | [donglora/firmware](https://github.com/donglora/firmware) |
| Rust client library | [donglora/client-rs](https://github.com/donglora/client-rs) |
| Python client library | [donglora/client-py](https://github.com/donglora/client-py) |
| Rust mux daemon | [donglora/mux-rs](https://github.com/donglora/mux-rs) |
| Python mux daemon | [donglora/mux-py](https://github.com/donglora/mux-py) |
| MeshCore AI bot | [donglora/ai-bot](https://github.com/donglora/ai-bot) |
| Example scripts | [donglora/examples](https://github.com/donglora/examples) |
This repo is archived and will not receive further updates.
-286
View File
@@ -1,286 +0,0 @@
use std::path::{Path, PathBuf};
use anyhow::{Context, Result, bail};
use donglora_client::{Bandwidth, RadioConfig, TX_POWER_MAX};
use iroh::{PublicKey, SecretKey};
use serde::{Deserialize, Serialize};
/// Top-level bridge configuration.
#[derive(Debug, Deserialize, Serialize)]
pub struct Config {
#[serde(default)]
pub radio: RadioSection,
#[serde(default)]
pub bridge: BridgeSection,
#[serde(default)]
pub peers: Vec<PeerEntry>,
}
#[derive(Debug, Deserialize, Serialize)]
pub struct RadioSection {
/// Serial port path (auto-detect if omitted).
pub port: Option<String>,
#[serde(default = "default_frequency")]
pub frequency: u32,
#[serde(default = "default_bandwidth")]
pub bandwidth: String,
#[serde(default = "default_sf")]
pub spreading_factor: u8,
#[serde(default = "default_cr")]
pub coding_rate: u8,
#[serde(default = "default_sync_word")]
pub sync_word: u16,
#[serde(default = "default_tx_power")]
pub tx_power: String,
#[serde(default)]
pub preamble: u16,
#[serde(default = "default_true")]
pub cad: bool,
}
#[derive(Debug, Deserialize, Serialize)]
pub struct BridgeSection {
#[serde(default = "default_dedup_window")]
pub dedup_window_secs: u64,
#[serde(default = "default_tx_queue_size")]
pub tx_queue_size: usize,
pub log_file: Option<PathBuf>,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct PeerEntry {
pub node_id: String,
pub name: Option<String>,
#[serde(default)]
pub addresses: Vec<String>,
}
// ── Defaults ──────────────────────────────────────────────────────
fn default_frequency() -> u32 {
915_000_000
}
fn default_bandwidth() -> String {
"125kHz".into()
}
fn default_sf() -> u8 {
7
}
fn default_cr() -> u8 {
5
}
fn default_sync_word() -> u16 {
0x1424
}
fn default_tx_power() -> String {
"max".into()
}
fn default_true() -> bool {
true
}
fn default_dedup_window() -> u64 {
30
}
fn default_tx_queue_size() -> usize {
32
}
impl Default for RadioSection {
fn default() -> Self {
Self {
port: None,
frequency: default_frequency(),
bandwidth: default_bandwidth(),
spreading_factor: default_sf(),
coding_rate: default_cr(),
sync_word: default_sync_word(),
tx_power: default_tx_power(),
preamble: 0,
cad: true,
}
}
}
impl Default for BridgeSection {
fn default() -> Self {
Self {
dedup_window_secs: default_dedup_window(),
tx_queue_size: default_tx_queue_size(),
log_file: None,
}
}
}
// ── Conversion ────────────────────────────────────────────────────
fn parse_bandwidth(s: &str) -> Result<Bandwidth> {
match s.to_lowercase().as_str() {
"7.8khz" | "7khz" | "khz7" => Ok(Bandwidth::Khz7),
"10.4khz" | "10khz" | "khz10" => Ok(Bandwidth::Khz10),
"15.6khz" | "15khz" | "khz15" => Ok(Bandwidth::Khz15),
"20.8khz" | "20khz" | "khz20" => Ok(Bandwidth::Khz20),
"31.25khz" | "31khz" | "khz31" => Ok(Bandwidth::Khz31),
"41.7khz" | "41khz" | "khz41" => Ok(Bandwidth::Khz41),
"62.5khz" | "62khz" | "khz62" => Ok(Bandwidth::Khz62),
"125khz" | "khz125" => Ok(Bandwidth::Khz125),
"250khz" | "khz250" => Ok(Bandwidth::Khz250),
"500khz" | "khz500" => Ok(Bandwidth::Khz500),
_ => bail!("unknown bandwidth: {s}"),
}
}
fn parse_tx_power(s: &str) -> Result<i8> {
if s.eq_ignore_ascii_case("max") {
Ok(TX_POWER_MAX)
} else {
s.parse::<i8>().context("invalid tx_power (use 'max' or a dBm integer)")
}
}
impl RadioSection {
/// Convert to the donglora-client RadioConfig.
pub fn to_radio_config(&self) -> Result<RadioConfig> {
Ok(RadioConfig {
freq_hz: self.frequency,
bw: parse_bandwidth(&self.bandwidth)?,
sf: self.spreading_factor,
cr: self.coding_rate,
sync_word: self.sync_word,
tx_power_dbm: parse_tx_power(&self.tx_power)?,
preamble_len: self.preamble,
cad: u8::from(self.cad),
})
}
}
// ── Peer parsing ──────────────────────────────────────────────────
impl PeerEntry {
/// Parse the node_id string into an iroh PublicKey.
pub fn public_key(&self) -> Result<PublicKey> {
self.node_id
.parse::<PublicKey>()
.map_err(|e| anyhow::anyhow!("invalid node_id '{}': {e}", self.node_id))
}
/// Display name: the configured name, or a truncated NodeId.
pub fn display_name(&self) -> String {
if let Some(name) = &self.name {
name.clone()
} else {
let id = &self.node_id;
if id.len() > 12 {
format!("{}...", &id[..12])
} else {
id.clone()
}
}
}
}
// ── File I/O ──────────────────────────────────────────────────────
/// Default config directory: `$XDG_CONFIG_HOME/donglora-bridge/` or `~/.config/donglora-bridge/`.
pub fn config_dir() -> Result<PathBuf> {
let dir = dirs::config_dir()
.context("cannot determine config directory")?
.join("donglora-bridge");
Ok(dir)
}
/// Default config file path.
pub fn config_path() -> Result<PathBuf> {
Ok(config_dir()?.join("config.toml"))
}
/// Default secret key file path (alongside default config).
#[allow(dead_code)]
pub fn secret_key_path() -> Result<PathBuf> {
Ok(config_dir()?.join("config.key"))
}
/// Default log file path: `$XDG_STATE_HOME/donglora-bridge/bridge.log`.
pub fn default_log_path() -> Result<PathBuf> {
let dir = dirs::state_dir()
.or_else(dirs::data_local_dir)
.context("cannot determine state directory")?
.join("donglora-bridge");
Ok(dir.join("bridge.log"))
}
/// Load config from a TOML file.
pub fn load_config(path: &Path) -> Result<Config> {
let text = std::fs::read_to_string(path)
.with_context(|| format!("reading config: {}", path.display()))?;
toml::from_str(&text).context("parsing config TOML")
}
/// Load or generate the persistent secret key.
pub fn load_or_create_secret_key(path: &Path) -> Result<SecretKey> {
if path.exists() {
let hex = std::fs::read_to_string(path)
.with_context(|| format!("reading secret key: {}", path.display()))?;
let hex = hex.trim();
let bytes = hex::decode(hex).context("decoding secret key hex")?;
let arr: [u8; 32] = bytes
.try_into()
.map_err(|_| anyhow::anyhow!("secret key must be 32 bytes"))?;
Ok(SecretKey::from_bytes(&arr))
} else {
let key = SecretKey::generate(&mut rand::rng());
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)
.with_context(|| format!("creating directory: {}", parent.display()))?;
}
let hex = hex::encode(key.to_bytes());
std::fs::write(path, &hex)
.with_context(|| format!("writing secret key: {}", path.display()))?;
Ok(key)
}
}
/// Generate a default config file with the given NodeId embedded as a comment.
pub fn generate_default_config(node_id: &str) -> String {
format!(
r#"# donglora-bridge configuration
# Auto-generated — add peers to get started!
# Your NodeId: {node_id}
[radio]
# port = "/dev/ttyACM0" # auto-detect if omitted
frequency = 915_000_000
bandwidth = "125kHz"
spreading_factor = 7
coding_rate = 5
sync_word = 0x1424
tx_power = "max"
preamble = 0 # 0 = firmware default (16 symbols)
cad = true
[bridge]
dedup_window_secs = 30
tx_queue_size = 32
# log_file = "/tmp/donglora-bridge.log"
# Add peers below. Each peer needs the remote bridge's NodeId.
# Share your NodeId (above) with peers so they can add you too.
#
# [[peers]]
# node_id = "paste-node-id-here"
# name = "My Friend's Bridge"
# addresses = ["1.2.3.4:1234"] # optional direct address hints
"#
)
}
/// Write the default config to disk (first-run flow).
pub fn write_default_config(path: &Path, node_id: &str) -> Result<()> {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)
.with_context(|| format!("creating directory: {}", parent.display()))?;
}
let content = generate_default_config(node_id);
std::fs::write(path, content)
.with_context(|| format!("writing config: {}", path.display()))?;
Ok(())
}
-234
View File
@@ -1,234 +0,0 @@
mod config;
mod network;
mod packet;
mod radio;
mod router;
mod tui;
use std::collections::HashMap;
use std::sync::Arc;
use std::time::Instant;
use anyhow::{Context, Result};
use clap::{Parser, Subcommand};
use tokio::sync::mpsc;
use tokio_util::sync::CancellationToken;
use tracing::info;
use crate::network::{Network, PeerConfig};
use crate::router::Stats;
#[derive(Parser)]
#[command(name = "donglora-bridge", about = "Peer-to-peer LoRa bridge using iroh")]
struct Cli {
/// Path to config file (default: ~/.config/donglora-bridge/config.toml)
#[arg(long)]
config: Option<String>,
/// Run in headless mode (structured logs to stdout, no TUI)
#[arg(long)]
log_only: bool,
#[command(subcommand)]
command: Option<Commands>,
}
#[derive(Subcommand)]
enum Commands {
/// Print this bridge's NodeId and exit
ShowId,
/// Generate an example config and print to stdout
GenerateConfig,
}
#[tokio::main]
async fn main() -> Result<()> {
let cli = Cli::parse();
// Resolve config path.
let config_path = match &cli.config {
Some(p) => std::path::PathBuf::from(p),
None => config::config_path()?,
};
// Load or create secret key.
// Secret key lives alongside the config file (same dir, .key extension).
let key_path = config_path.with_extension("key");
let secret_key = config::load_or_create_secret_key(&key_path)?;
let node_id = secret_key.public();
let node_id_str = node_id.to_string();
// Handle subcommands.
match cli.command {
Some(Commands::ShowId) => {
println!("{node_id_str}");
return Ok(());
}
Some(Commands::GenerateConfig) => {
print!("{}", config::generate_default_config(&node_id_str));
return Ok(());
}
None => {}
}
// First-run: if config doesn't exist, generate it and exit.
if !config_path.exists() {
config::write_default_config(&config_path, &node_id_str)?;
println!("Generated config at {}", config_path.display());
println!("Your NodeId: {node_id_str}");
println!();
println!("Add peers to your config and run again!");
return Ok(());
}
// Load config.
let cfg = config::load_config(&config_path)
.with_context(|| format!("loading config from {}", config_path.display()))?;
// Set up logging.
if cli.log_only {
tracing_subscriber::fmt()
.with_env_filter(
tracing_subscriber::EnvFilter::try_from_default_env()
.unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info")),
)
.init();
} else {
// Log to file in TUI mode.
let log_path = cfg
.bridge
.log_file
.clone()
.unwrap_or_else(|| config::default_log_path().unwrap_or_else(|_| "/tmp/donglora-bridge.log".into()));
if let Some(parent) = log_path.parent() {
std::fs::create_dir_all(parent).ok();
}
let file_appender = tracing_appender::rolling::never(
log_path.parent().unwrap_or(std::path::Path::new("/tmp")),
log_path.file_name().unwrap_or(std::ffi::OsStr::new("donglora-bridge.log")),
);
tracing_subscriber::fmt()
.with_env_filter(
tracing_subscriber::EnvFilter::try_from_default_env()
.unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info")),
)
.with_writer(file_appender)
.with_ansi(false)
.init();
}
info!("donglora-bridge starting, NodeId: {node_id_str}");
// Parse radio config.
let radio_config = cfg.radio.to_radio_config()?;
let radio_config_str = radio::format_radio_config(&radio_config);
// Parse peer configs.
let mut peer_configs = Vec::new();
let mut peer_names: HashMap<iroh::PublicKey, String> = HashMap::new();
for entry in &cfg.peers {
let pk = entry.public_key()?;
let name = entry.display_name();
peer_names.insert(pk, name.clone());
peer_configs.push(PeerConfig {
public_key: pk,
name,
addresses: entry.addresses.clone(),
});
}
if peer_configs.is_empty() && !cli.log_only {
println!("No peers configured. Add peers to {} and run again.", config_path.display());
println!("Your NodeId: {node_id_str}");
return Ok(());
}
// Create network.
let network = Arc::new(Network::new(secret_key, peer_configs).await?);
info!("iroh endpoint bound, id: {node_id_str}");
// Start network.
let (net_event_rx, net_send_tx) = network.start();
// Start radio.
let (radio_event_rx, radio_tx) = radio::spawn(radio_config, cfg.radio.port.clone());
// Stats + log channel + radio config watch.
let stats = Arc::new(Stats::default());
let (log_tx, log_rx) = mpsc::channel(512);
let (config_watch_tx, config_watch_rx) = tokio::sync::watch::channel(
router::RadioConfigInfo {
config_str: radio_config_str,
source: radio::ConfigSource::Ours,
},
);
// Cancellation.
let cancel = CancellationToken::new();
let start_time = Instant::now();
// Spawn router.
let router_stats = stats.clone();
let router_cancel = cancel.clone();
let router_our_id = node_id;
let dedup_window = std::time::Duration::from_secs(cfg.bridge.dedup_window_secs);
let tx_queue_size = cfg.bridge.tx_queue_size;
let router_peer_names = peer_names;
tokio::spawn(async move {
router::run(
router_our_id,
dedup_window,
tx_queue_size,
router_peer_names,
radio_event_rx,
radio_tx,
net_event_rx,
net_send_tx,
&router_stats,
log_tx,
config_watch_tx,
)
.await;
router_cancel.cancel();
});
// Run TUI or headless.
if cli.log_only {
info!("running in headless mode");
// Just wait for cancellation or ctrl-c.
tokio::select! {
() = cancel.cancelled() => {},
result = tokio::signal::ctrl_c() => {
if let Err(e) = result {
tracing::error!("signal handler error: {e}");
}
info!("received ctrl-c, shutting down");
}
}
} else {
// Run TUI (blocks until q or cancel).
if let Err(e) = tui::run(
node_id,
config_watch_rx,
network.clone(),
stats,
log_rx,
cancel.clone(),
start_time,
)
.await
{
// Restore terminal before printing error.
eprintln!("TUI error: {e:#}");
}
}
// Shutdown.
cancel.cancel();
network.shutdown().await;
info!("donglora-bridge stopped");
Ok(())
}
-411
View File
@@ -1,411 +0,0 @@
use std::collections::HashMap;
use std::sync::Arc;
use std::time::Instant;
use anyhow::{Context, Result};
use bytes::Bytes;
use iroh::endpoint::Connection;
use iroh::{Endpoint, PublicKey, SecretKey, endpoint::presets};
use tokio::sync::{Mutex, mpsc};
use tokio_util::sync::CancellationToken;
use tracing::{debug, info, warn};
use crate::packet::Envelope;
/// ALPN protocol identifier for donglora-bridge.
const ALPN: &[u8] = b"donglora-bridge/0";
/// Info about a configured peer.
#[derive(Debug, Clone)]
pub struct PeerConfig {
pub public_key: PublicKey,
pub name: String,
pub addresses: Vec<String>,
}
/// Observable state of a peer connection.
#[derive(Debug, Clone)]
pub struct PeerState {
pub name: String,
pub public_key: PublicKey,
pub connected: bool,
pub last_seen: Option<Instant>,
pub packets_in: u64,
pub packets_out: u64,
}
/// Event from the network to the router.
#[allow(dead_code)]
pub enum NetworkEvent {
/// A packet received from an iroh peer.
Packet {
from: PublicKey,
envelope: Envelope,
},
/// A peer connected.
PeerConnected(PublicKey),
/// A peer disconnected.
PeerDisconnected(PublicKey),
}
/// Shared peer connection map.
type PeerConnections = Arc<Mutex<HashMap<PublicKey, Connection>>>;
/// Shared peer state map.
type PeerStates = Arc<Mutex<HashMap<PublicKey, PeerState>>>;
/// The network layer: manages the iroh endpoint and peer connections.
pub struct Network {
endpoint: Endpoint,
peer_configs: Vec<PeerConfig>,
connections: PeerConnections,
states: PeerStates,
cancel: CancellationToken,
}
impl Network {
/// Create a new network layer with the given identity and peer configuration.
pub async fn new(secret_key: SecretKey, peer_configs: Vec<PeerConfig>) -> Result<Self> {
let endpoint = Endpoint::builder(presets::N0)
.secret_key(secret_key)
.alpns(vec![ALPN.to_vec()])
.bind()
.await
.context("binding iroh endpoint")?;
// Wait for relay connection before trying to reach peers.
info!("waiting for iroh relay connection...");
endpoint.online().await;
let addr = endpoint.addr();
info!("iroh online, addr: {addr:?}");
let states = Arc::new(Mutex::new(HashMap::new()));
{
let mut s = states.lock().await;
for pc in &peer_configs {
s.insert(
pc.public_key,
PeerState {
name: pc.name.clone(),
public_key: pc.public_key,
connected: false,
last_seen: None,
packets_in: 0,
packets_out: 0,
},
);
}
}
Ok(Self {
endpoint,
peer_configs,
connections: Arc::new(Mutex::new(HashMap::new())),
states,
cancel: CancellationToken::new(),
})
}
/// Get a snapshot of all peer states.
pub async fn peer_states(&self) -> Vec<PeerState> {
self.states.lock().await.values().cloned().collect()
}
/// Start the network: spawn outbound connection tasks + inbound accept loop.
///
/// Returns a receiver for network events and a sender for outbound envelopes.
pub fn start(
&self,
) -> (mpsc::Receiver<NetworkEvent>, mpsc::Sender<(Envelope, Option<PublicKey>)>) {
let (event_tx, event_rx) = mpsc::channel::<NetworkEvent>(256);
let (send_tx, send_rx) = mpsc::channel::<(Envelope, Option<PublicKey>)>(256);
// Spawn outbound connection tasks for each configured peer.
for pc in &self.peer_configs {
let endpoint = self.endpoint.clone();
let pk = pc.public_key;
let addrs = pc.addresses.clone();
let connections = self.connections.clone();
let states = self.states.clone();
let event_tx = event_tx.clone();
let cancel = self.cancel.clone();
tokio::spawn(async move {
outbound_connection_loop(endpoint, pk, addrs, connections, states, event_tx, cancel).await;
});
}
// Spawn inbound accept loop.
{
let endpoint = self.endpoint.clone();
let connections = self.connections.clone();
let states = self.states.clone();
let event_tx = event_tx.clone();
let cancel = self.cancel.clone();
let known_peers: Vec<PublicKey> =
self.peer_configs.iter().map(|pc| pc.public_key).collect();
tokio::spawn(async move {
accept_loop(endpoint, known_peers, connections, states, event_tx, cancel).await;
});
}
// Spawn broadcast sender task.
{
let connections = self.connections.clone();
let states = self.states.clone();
let cancel = self.cancel.clone();
tokio::spawn(async move {
broadcast_loop(connections, states, send_rx, cancel).await;
});
}
(event_rx, send_tx)
}
/// Shut down the network layer.
pub async fn shutdown(&self) {
self.cancel.cancel();
self.endpoint.close().await;
}
}
/// Maintain a persistent outbound connection to a peer, reconnecting on failure.
async fn outbound_connection_loop(
endpoint: Endpoint,
peer_key: PublicKey,
addresses: Vec<String>,
connections: PeerConnections,
states: PeerStates,
event_tx: mpsc::Sender<NetworkEvent>,
cancel: CancellationToken,
) {
let mut backoff = std::time::Duration::from_secs(1);
let max_backoff = std::time::Duration::from_secs(30);
loop {
if cancel.is_cancelled() {
return;
}
// Skip if we already have a connection (e.g., from inbound accept).
{
let conns = connections.lock().await;
if let Some(c) = conns.get(&peer_key)
&& c.stable_id() != 0
{
// Connection exists and looks alive — wait and retry check.
drop(conns);
tokio::select! {
() = cancel.cancelled() => return,
() = tokio::time::sleep(std::time::Duration::from_secs(5)) => continue,
}
}
}
info!("connecting to peer {}", fmt_key(&peer_key));
let mut addr = iroh::EndpointAddr::new(peer_key);
// Add our relay URL as a hint — the peer is likely on the same relay network.
for relay_url in endpoint.addr().relay_urls() {
addr = addr.with_relay_url(relay_url.clone());
}
for a in &addresses {
if let Ok(sock) = a.parse::<std::net::SocketAddr>() {
addr = addr.with_ip_addr(sock);
}
}
debug!("connect addr: {addr:?}");
match endpoint.connect(addr, ALPN).await {
Ok(conn) => {
backoff = std::time::Duration::from_secs(1);
let remote = conn.remote_id();
info!("connected to peer {}", fmt_key(&remote));
{
let mut conns = connections.lock().await;
conns.insert(remote, conn.clone());
}
update_peer_connected(&states, &remote, true).await;
let _ = event_tx.send(NetworkEvent::PeerConnected(remote)).await;
// Read datagrams from this connection.
read_datagrams(conn, remote, connections.clone(), states.clone(), event_tx.clone(), cancel.clone()).await;
update_peer_connected(&states, &remote, false).await;
let _ = event_tx
.send(NetworkEvent::PeerDisconnected(remote))
.await;
}
Err(e) => {
warn!("connect to {} failed: {e:#}", fmt_key(&peer_key));
}
}
tokio::select! {
() = cancel.cancelled() => return,
() = tokio::time::sleep(backoff) => {},
}
backoff = (backoff * 2).min(max_backoff);
}
}
/// Accept inbound connections from known peers.
async fn accept_loop(
endpoint: Endpoint,
known_peers: Vec<PublicKey>,
connections: PeerConnections,
states: PeerStates,
event_tx: mpsc::Sender<NetworkEvent>,
cancel: CancellationToken,
) {
loop {
let incoming = tokio::select! {
() = cancel.cancelled() => return,
inc = endpoint.accept() => match inc {
Some(i) => i,
None => return,
},
};
let conn = match incoming.await {
Ok(c) => c,
Err(e) => {
warn!("inbound connection failed: {e:#}");
continue;
}
};
let remote = conn.remote_id();
if !known_peers.contains(&remote) {
warn!("rejecting unknown peer {}", fmt_key(&remote));
conn.close(0u32.into(), b"unknown peer");
continue;
}
info!("accepted connection from peer {}", fmt_key(&remote));
{
let mut conns = connections.lock().await;
conns.insert(remote, conn.clone());
}
update_peer_connected(&states, &remote, true).await;
let _ = event_tx.send(NetworkEvent::PeerConnected(remote)).await;
// Spawn a reader for this inbound connection.
let conns = connections.clone();
let sts = states.clone();
let etx = event_tx.clone();
let ct = cancel.clone();
tokio::spawn(async move {
read_datagrams(conn, remote, conns.clone(), sts.clone(), etx.clone(), ct).await;
update_peer_connected(&sts, &remote, false).await;
let _ = etx.send(NetworkEvent::PeerDisconnected(remote)).await;
});
}
}
/// Read datagrams from a connection until it closes.
async fn read_datagrams(
conn: Connection,
remote: PublicKey,
connections: PeerConnections,
states: PeerStates,
event_tx: mpsc::Sender<NetworkEvent>,
cancel: CancellationToken,
) {
loop {
let data = tokio::select! {
() = cancel.cancelled() => break,
result = conn.read_datagram() => match result {
Ok(d) => d,
Err(e) => {
debug!("datagram read from {} ended: {e}", fmt_key(&remote));
break;
}
},
};
if let Some(envelope) = Envelope::decode(&data) {
{
let mut sts = states.lock().await;
if let Some(ps) = sts.get_mut(&remote) {
ps.packets_in += 1;
ps.last_seen = Some(Instant::now());
}
}
let _ = event_tx
.send(NetworkEvent::Packet {
from: remote,
envelope,
})
.await;
} else {
warn!("malformed envelope from {}", fmt_key(&remote));
}
}
// Clean up connection.
let mut conns = connections.lock().await;
if let Some(existing) = conns.get(&remote)
&& existing.stable_id() == conn.stable_id()
{
conns.remove(&remote);
}
}
/// Broadcast envelopes to all connected peers (except the optional skip key).
async fn broadcast_loop(
connections: PeerConnections,
states: PeerStates,
mut send_rx: mpsc::Receiver<(Envelope, Option<PublicKey>)>,
cancel: CancellationToken,
) {
loop {
let (envelope, skip) = tokio::select! {
() = cancel.cancelled() => return,
msg = send_rx.recv() => match msg {
Some(m) => m,
None => return,
},
};
let data = Bytes::from(envelope.encode());
let conns = connections.lock().await;
for (pk, conn) in conns.iter() {
if Some(*pk) == skip {
continue;
}
match conn.send_datagram(data.clone()) {
Ok(()) => {
let mut sts = states.lock().await;
if let Some(ps) = sts.get_mut(pk) {
ps.packets_out += 1;
}
}
Err(e) => {
debug!("send to {} failed: {e}", fmt_key(pk));
}
}
}
}
}
async fn update_peer_connected(states: &PeerStates, pk: &PublicKey, connected: bool) {
let mut sts = states.lock().await;
if let Some(ps) = sts.get_mut(pk) {
ps.connected = connected;
if connected {
ps.last_seen = Some(Instant::now());
}
}
}
fn fmt_key(pk: &PublicKey) -> String {
let s = pk.to_string();
if s.len() > 12 {
format!("{}...", &s[..12])
} else {
s
}
}
-187
View File
@@ -1,187 +0,0 @@
use iroh::PublicKey;
/// Wire protocol version.
const VERSION: u8 = 0;
/// Envelope header size: version(1) + origin(32) + seq(8) + rssi(2) + snr(2) + payload_len(2) = 47
const HEADER_SIZE: usize = 47;
/// SNR quality grade for a received LoRa packet.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Grade {
/// SNR well above demodulation floor (margin >= 3 dB).
Good,
/// SNR near demodulation floor (0 <= margin < 3 dB).
Marginal,
/// SNR below demodulation floor — likely corrupt.
Unreliable,
/// SNR outside valid range (-32..32) — firmware/decode error.
Invalid,
}
impl Grade {
/// Returns true if the packet is reliable enough to forward.
pub fn should_forward(self) -> bool {
matches!(self, Self::Good | Self::Marginal)
}
}
impl std::fmt::Display for Grade {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Good => write!(f, "GOOD"),
Self::Marginal => write!(f, "MARG"),
Self::Unreliable => write!(f, "UNRL"),
Self::Invalid => write!(f, "INVL"),
}
}
}
/// Grade a received packet's SNR against the demodulation floor for the given
/// spreading factor. See PROTOCOL.md for the formula.
pub fn snr_grade(snr: i16, sf: u8) -> Grade {
if !(-32..=32).contains(&snr) {
return Grade::Invalid;
}
let min_snr = -2.5 * (f32::from(sf) - 4.0);
let margin = f32::from(snr) - min_snr;
if margin < 0.0 {
Grade::Unreliable
} else if margin < 3.0 {
Grade::Marginal
} else {
Grade::Good
}
}
/// A packet received from the radio, before wrapping in an envelope.
#[derive(Debug, Clone)]
pub struct RadioPacket {
pub rssi: i16,
pub snr: i16,
pub payload: Vec<u8>,
}
/// A bridge envelope — the wire format sent between iroh peers.
#[derive(Debug, Clone)]
pub struct Envelope {
/// NodeId of the bridge that first heard this packet on radio.
pub origin: PublicKey,
/// Per-origin sequence number (monotonically increasing).
pub seq: u64,
/// RSSI at the originating bridge (informational).
pub rssi: i16,
/// SNR at the originating bridge (informational).
pub snr: i16,
/// Raw LoRa payload.
pub payload: Vec<u8>,
}
impl Envelope {
/// Encode this envelope into bytes for sending as a QUIC datagram.
pub fn encode(&self) -> Vec<u8> {
let mut buf = Vec::with_capacity(HEADER_SIZE + self.payload.len());
buf.push(VERSION);
buf.extend_from_slice(self.origin.as_bytes());
buf.extend_from_slice(&self.seq.to_le_bytes());
buf.extend_from_slice(&self.rssi.to_le_bytes());
buf.extend_from_slice(&self.snr.to_le_bytes());
let len = self.payload.len() as u16;
buf.extend_from_slice(&len.to_le_bytes());
buf.extend_from_slice(&self.payload);
buf
}
/// Decode an envelope from bytes received as a QUIC datagram.
pub fn decode(data: &[u8]) -> Option<Self> {
if data.len() < HEADER_SIZE {
return None;
}
if data[0] != VERSION {
return None;
}
let origin = PublicKey::from_bytes(data[1..33].try_into().ok()?).ok()?;
let seq = u64::from_le_bytes(data[33..41].try_into().ok()?);
let rssi = i16::from_le_bytes(data[41..43].try_into().ok()?);
let snr = i16::from_le_bytes(data[43..45].try_into().ok()?);
let payload_len = u16::from_le_bytes(data[45..47].try_into().ok()?) as usize;
if data.len() != HEADER_SIZE + payload_len {
return None;
}
Some(Self {
origin,
seq,
rssi,
snr,
payload: data[HEADER_SIZE..].to_vec(),
})
}
}
/// Compute a 16-byte content hash for deduplication.
pub fn content_hash(payload: &[u8]) -> [u8; 16] {
let hash = blake3::hash(payload);
let bytes = hash.as_bytes();
let mut out = [0u8; 16];
out.copy_from_slice(&bytes[..16]);
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn snr_grading() {
// SF7: min_snr = -2.5 * (7 - 4) = -7.5
assert_eq!(snr_grade(10, 7), Grade::Good); // margin = 17.5
assert_eq!(snr_grade(-5, 7), Grade::Marginal); // margin = 2.5 < 3
assert_eq!(snr_grade(-8, 7), Grade::Unreliable); // margin = -0.5
assert_eq!(snr_grade(-50, 7), Grade::Invalid); // out of range
// SF12: min_snr = -2.5 * (12 - 4) = -20.0
assert_eq!(snr_grade(-15, 12), Grade::Good); // margin = 5.0
assert_eq!(snr_grade(-18, 12), Grade::Marginal); // margin = 2.0
assert_eq!(snr_grade(-22, 12), Grade::Unreliable); // margin = -2.0
}
#[test]
fn envelope_roundtrip() {
let key = iroh::SecretKey::generate(&mut rand::rng());
let env = Envelope {
origin: key.public(),
seq: 42,
rssi: -85,
snr: 8,
payload: vec![1, 2, 3, 4, 5],
};
let encoded = env.encode();
let decoded = Envelope::decode(&encoded).unwrap();
assert_eq!(decoded.origin, env.origin);
assert_eq!(decoded.seq, env.seq);
assert_eq!(decoded.rssi, env.rssi);
assert_eq!(decoded.snr, env.snr);
assert_eq!(decoded.payload, env.payload);
}
#[test]
fn envelope_reject_short() {
assert!(Envelope::decode(&[0u8; 10]).is_none());
}
#[test]
fn envelope_reject_bad_version() {
let mut data = vec![1u8]; // version 1
data.extend_from_slice(&[0u8; 46]);
assert!(Envelope::decode(&data).is_none());
}
#[test]
fn content_hash_deterministic() {
let h1 = content_hash(b"hello");
let h2 = content_hash(b"hello");
assert_eq!(h1, h2);
let h3 = content_hash(b"world");
assert_ne!(h1, h3);
}
}
-224
View File
@@ -1,224 +0,0 @@
use std::time::Duration;
use anyhow::Result;
use donglora_client::{RadioConfig, Response, Transport};
use tokio::sync::mpsc;
use tracing::{debug, error, info, warn};
use crate::packet::{RadioPacket, snr_grade};
/// How the radio config was resolved.
#[derive(Debug, Clone, Copy)]
pub enum ConfigSource {
/// We set the config ourselves.
Ours,
/// The mux already had a locked config; we adopted it.
Mux,
}
/// Messages from the radio thread to the router.
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 disconnected — attempting reconnect.
Disconnected,
}
/// Spawn the blocking radio thread.
///
/// Returns a receiver for radio events and a sender for TX payloads.
pub fn spawn(
config: RadioConfig,
port: Option<String>,
) -> (mpsc::Receiver<RadioEvent>, mpsc::Sender<Vec<u8>>) {
let (event_tx, event_rx) = mpsc::channel::<RadioEvent>(256);
let (tx_send, tx_recv) = mpsc::channel::<Vec<u8>>(64);
std::thread::Builder::new()
.name("radio".into())
.spawn(move || radio_loop(config, port, event_tx, tx_recv))
.ok();
(event_rx, tx_send)
}
fn radio_loop(
config: RadioConfig,
port: Option<String>,
event_tx: mpsc::Sender<RadioEvent>,
mut tx_recv: mpsc::Receiver<Vec<u8>>,
) {
let mut backoff = Duration::from_secs(1);
let max_backoff = Duration::from_secs(30);
loop {
match connect_and_run(&config, &port, &event_tx, &mut tx_recv) {
Ok(()) => {
// Clean shutdown (channel closed).
info!("radio thread exiting");
return;
}
Err(e) => {
error!("radio error: {e:#}");
let _ = event_tx.blocking_send(RadioEvent::Disconnected);
info!("reconnecting in {backoff:?}");
std::thread::sleep(backoff);
backoff = (backoff * 2).min(max_backoff);
}
}
}
}
fn connect_and_run(
config: &RadioConfig,
port: &Option<String>,
event_tx: &mpsc::Sender<RadioEvent>,
tx_recv: &mut mpsc::Receiver<Vec<u8>>,
) -> Result<()> {
let timeout = Duration::from_secs(2);
info!("connecting to dongle...");
let mut client = if let Some(port) = &port {
donglora_client::connect(Some(port), timeout)?
} else {
donglora_client::connect_default()?
};
client.ping()?;
info!("dongle connected, configuring radio");
// 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)
}
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:#})");
}
}
}
};
client.start_rx()?;
info!("radio receiving");
let _ = event_tx.blocking_send(RadioEvent::Connected(active_config, config_source));
// Set a short timeout so we can interleave RX and TX.
client
.transport_mut()
.set_timeout(Duration::from_millis(100))?;
loop {
// Check for TX requests (non-blocking).
while let Ok(payload) = tx_recv.try_recv() {
debug!("TX {} bytes", payload.len());
if let Err(e) = client.transmit(&payload, None) {
warn!("transmit error: {e:#}");
}
}
// Check for RX packets.
match client.recv() {
Ok(Some(Response::RxPacket { rssi, snr, payload })) => {
let grade = snr_grade(snr, active_config.sf);
if grade.should_forward() {
debug!(
"RX {} bytes rssi={rssi} snr={snr} grade={grade}",
payload.len()
);
let pkt = RadioPacket { rssi, snr, payload };
if event_tx.blocking_send(RadioEvent::Packet(pkt)).is_err() {
// Router dropped — shut down.
return Ok(());
}
} else {
debug!(
"RX drop {} bytes rssi={rssi} snr={snr} grade={grade}",
payload.len()
);
}
}
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.
}
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.
///
/// 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.
fn is_timeout_error(e: &anyhow::Error) -> bool {
if let Some(io_err) = e.downcast_ref::<std::io::Error>() {
matches!(
io_err.kind(),
std::io::ErrorKind::WouldBlock | std::io::ErrorKind::TimedOut
)
} else {
false
}
}
/// 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 power = if config.tx_power_dbm == donglora_client::TX_POWER_MAX {
"max".to_string()
} else {
format!("{}dBm", config.tx_power_dbm)
};
let preamble = if config.preamble_len == 0 {
16
} else {
config.preamble_len
};
let cad = if config.cad != 0 { "on" } else { "off" };
format!(
"{freq_mhz:.3}MHz SF{} BW{bw} CR4/{} SW0x{:04X} TX:{power} Pre:{preamble} CAD:{cad}",
config.sf, config.cr, config.sync_word
)
}
-370
View File
@@ -1,370 +0,0 @@
use std::collections::{HashMap, VecDeque};
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::{Duration, Instant};
use iroh::PublicKey;
use tokio::sync::mpsc;
use tracing::{debug, warn};
use crate::network::NetworkEvent;
use crate::packet::{Envelope, RadioPacket, content_hash};
use crate::radio::{ConfigSource, RadioEvent, format_radio_config};
/// Active radio config info, sent to the TUI via watch channel.
#[derive(Debug, Clone)]
pub struct RadioConfigInfo {
pub config_str: String,
pub source: ConfigSource,
}
/// Packet event for the TUI log.
#[derive(Debug, Clone)]
pub struct PacketLogEntry {
pub timestamp: Instant,
pub direction: PacketDirection,
pub size: usize,
pub snr: Option<i16>,
pub rssi: Option<i16>,
pub peer_name: Option<String>,
pub action: PacketAction,
}
#[derive(Debug, Clone, Copy)]
pub enum PacketDirection {
RadioRx,
RadioTx,
NetRx,
}
#[derive(Debug, Clone, Copy)]
pub enum PacketAction {
Forwarded,
Transmitted,
DroppedDedup,
DroppedQueueFull,
}
impl std::fmt::Display for PacketDirection {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::RadioRx => write!(f, "RX "),
Self::RadioTx => write!(f, "TX "),
Self::NetRx => write!(f, "NET"),
}
}
}
impl std::fmt::Display for PacketAction {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Forwarded => write!(f, "FWD"),
Self::Transmitted => write!(f, "TX"),
Self::DroppedDedup => write!(f, "DUP"),
Self::DroppedQueueFull => write!(f, "FULL"),
}
}
}
/// Aggregate stats for the TUI.
#[derive(Debug, Default)]
pub struct Stats {
pub radio_rx: AtomicU64,
pub radio_tx: AtomicU64,
pub forwarded: AtomicU64,
pub dropped_snr: AtomicU64,
pub dropped_dedup: AtomicU64,
pub dropped_queue: AtomicU64,
pub radio_connected: AtomicU64, // 0 = disconnected, 1 = connected
}
impl Stats {
pub fn snapshot(&self) -> StatsSnapshot {
StatsSnapshot {
radio_rx: self.radio_rx.load(Ordering::Relaxed),
radio_tx: self.radio_tx.load(Ordering::Relaxed),
forwarded: self.forwarded.load(Ordering::Relaxed),
dropped_snr: self.dropped_snr.load(Ordering::Relaxed),
dropped_dedup: self.dropped_dedup.load(Ordering::Relaxed),
dropped_queue: self.dropped_queue.load(Ordering::Relaxed),
radio_connected: self.radio_connected.load(Ordering::Relaxed) != 0,
}
}
}
#[derive(Debug, Clone)]
pub struct StatsSnapshot {
pub radio_rx: u64,
pub radio_tx: u64,
pub forwarded: u64,
pub dropped_snr: u64,
pub dropped_dedup: u64,
pub dropped_queue: u64,
pub radio_connected: bool,
}
/// Time-bounded dedup cache.
struct DedupCache<K: std::hash::Hash + Eq> {
entries: HashMap<K, Instant>,
ttl: Duration,
}
impl<K: std::hash::Hash + Eq> DedupCache<K> {
fn new(ttl: Duration) -> Self {
Self {
entries: HashMap::new(),
ttl,
}
}
/// Returns true if the key was already present (i.e., duplicate).
fn check_and_insert(&mut self, key: K) -> bool {
let now = Instant::now();
self.prune(now);
if self.entries.contains_key(&key) {
return true;
}
self.entries.insert(key, now);
false
}
fn insert(&mut self, key: K) {
self.entries.insert(key, Instant::now());
}
fn prune(&mut self, now: Instant) {
self.entries.retain(|_, ts| now.duration_since(*ts) < self.ttl);
}
}
/// Peer name lookup for log entries.
struct PeerNames {
names: HashMap<PublicKey, String>,
}
impl PeerNames {
fn get(&self, pk: &PublicKey) -> Option<String> {
self.names.get(pk).cloned()
}
}
/// Run the router — the central packet bus.
///
/// This is an async task that mediates between the radio thread, the iroh
/// network layer, and the TUI.
#[allow(clippy::too_many_arguments)]
pub async fn run(
our_id: PublicKey,
dedup_window: Duration,
tx_queue_size: usize,
peer_names: HashMap<PublicKey, String>,
mut radio_rx: mpsc::Receiver<RadioEvent>,
radio_tx: mpsc::Sender<Vec<u8>>,
mut net_rx: mpsc::Receiver<NetworkEvent>,
net_tx: mpsc::Sender<(Envelope, Option<PublicKey>)>,
stats: &Stats,
log_tx: mpsc::Sender<PacketLogEntry>,
config_tx: tokio::sync::watch::Sender<RadioConfigInfo>,
) {
let names = PeerNames { names: peer_names };
let mut seq: u64 = 0;
let mut iroh_dedup: DedupCache<(PublicKey, u64)> = DedupCache::new(dedup_window);
let mut echo_dedup: DedupCache<[u8; 16]> = DedupCache::new(dedup_window);
let mut tx_queue: VecDeque<Vec<u8>> = VecDeque::with_capacity(tx_queue_size);
loop {
tokio::select! {
// Radio events.
event = radio_rx.recv() => {
let Some(event) = event else { break };
match event {
RadioEvent::Packet(pkt) => {
handle_radio_packet(
&our_id, &mut seq, &pkt,
&mut echo_dedup, &net_tx,
stats, &log_tx, &names,
).await;
}
RadioEvent::Connected(active_config, source) => {
stats.radio_connected.store(1, Ordering::Relaxed);
let _ = config_tx.send(RadioConfigInfo {
config_str: format_radio_config(&active_config),
source,
});
}
RadioEvent::Disconnected => {
stats.radio_connected.store(0, Ordering::Relaxed);
}
}
}
// Network events.
event = net_rx.recv() => {
let Some(event) = event else { break };
match event {
NetworkEvent::Packet { from, envelope } => {
handle_net_packet(
&from, envelope, &mut iroh_dedup, &mut echo_dedup,
&mut tx_queue, tx_queue_size,
stats, &log_tx, &names,
).await;
}
NetworkEvent::PeerConnected(_) | NetworkEvent::PeerDisconnected(_) => {
// State tracked in network layer; TUI reads from there.
}
}
}
}
// Drain TX queue to radio.
while let Some(payload) = tx_queue.pop_front() {
let size = payload.len();
stats.radio_tx.fetch_add(1, Ordering::Relaxed);
let _ = log_tx
.send(PacketLogEntry {
timestamp: Instant::now(),
direction: PacketDirection::RadioTx,
size,
snr: None,
rssi: None,
peer_name: None,
action: PacketAction::Transmitted,
})
.await;
if radio_tx.send(payload).await.is_err() {
break;
}
}
}
}
#[allow(clippy::too_many_arguments)]
async fn handle_radio_packet(
our_id: &PublicKey,
seq: &mut u64,
pkt: &RadioPacket,
echo_dedup: &mut DedupCache<[u8; 16]>,
net_tx: &mpsc::Sender<(Envelope, Option<PublicKey>)>,
stats: &Stats,
log_tx: &mpsc::Sender<PacketLogEntry>,
names: &PeerNames,
) {
stats.radio_rx.fetch_add(1, Ordering::Relaxed);
// Check echo suppression.
let hash = content_hash(&pkt.payload);
if echo_dedup.check_and_insert(hash) {
stats.dropped_dedup.fetch_add(1, Ordering::Relaxed);
debug!("radio RX echo suppressed ({} bytes)", pkt.payload.len());
let _ = log_tx
.send(PacketLogEntry {
timestamp: Instant::now(),
direction: PacketDirection::RadioRx,
size: pkt.payload.len(),
snr: Some(pkt.snr),
rssi: Some(pkt.rssi),
peer_name: None,
action: PacketAction::DroppedDedup,
})
.await;
return;
}
// Wrap and forward to all peers.
*seq += 1;
let envelope = Envelope {
origin: *our_id,
seq: *seq,
rssi: pkt.rssi,
snr: pkt.snr,
payload: pkt.payload.clone(),
};
stats.forwarded.fetch_add(1, Ordering::Relaxed);
let _ = net_tx.send((envelope, None)).await;
let _ = log_tx
.send(PacketLogEntry {
timestamp: Instant::now(),
direction: PacketDirection::RadioRx,
size: pkt.payload.len(),
snr: Some(pkt.snr),
rssi: Some(pkt.rssi),
peer_name: names.get(our_id),
action: PacketAction::Forwarded,
})
.await;
}
#[allow(clippy::too_many_arguments)]
async fn handle_net_packet(
from: &PublicKey,
envelope: Envelope,
iroh_dedup: &mut DedupCache<(PublicKey, u64)>,
echo_dedup: &mut DedupCache<[u8; 16]>,
tx_queue: &mut VecDeque<Vec<u8>>,
tx_queue_size: usize,
stats: &Stats,
log_tx: &mpsc::Sender<PacketLogEntry>,
names: &PeerNames,
) {
// Check iroh-level dedup.
if iroh_dedup.check_and_insert((envelope.origin, envelope.seq)) {
stats.dropped_dedup.fetch_add(1, Ordering::Relaxed);
debug!("net dedup ({} bytes from {})", envelope.payload.len(), fmt_key(from));
let _ = log_tx
.send(PacketLogEntry {
timestamp: Instant::now(),
direction: PacketDirection::NetRx,
size: envelope.payload.len(),
snr: Some(envelope.snr),
rssi: Some(envelope.rssi),
peer_name: names.get(from),
action: PacketAction::DroppedDedup,
})
.await;
return;
}
// Insert content hash for radio echo suppression.
let hash = content_hash(&envelope.payload);
echo_dedup.insert(hash);
// Enqueue for radio TX.
if tx_queue.len() >= tx_queue_size {
stats.dropped_queue.fetch_add(1, Ordering::Relaxed);
warn!("TX queue full, dropping packet ({} bytes)", envelope.payload.len());
let _ = log_tx
.send(PacketLogEntry {
timestamp: Instant::now(),
direction: PacketDirection::NetRx,
size: envelope.payload.len(),
snr: Some(envelope.snr),
rssi: Some(envelope.rssi),
peer_name: names.get(from),
action: PacketAction::DroppedQueueFull,
})
.await;
return;
}
tx_queue.push_back(envelope.payload);
let _ = log_tx
.send(PacketLogEntry {
timestamp: Instant::now(),
direction: PacketDirection::NetRx,
size: tx_queue.back().map(Vec::len).unwrap_or(0),
snr: Some(envelope.snr),
rssi: Some(envelope.rssi),
peer_name: names.get(from),
action: PacketAction::Transmitted,
})
.await;
}
fn fmt_key(pk: &PublicKey) -> String {
let s = pk.to_string();
if s.len() > 12 {
format!("{}...", &s[..12])
} else {
s
}
}
-441
View File
@@ -1,441 +0,0 @@
use std::io::{self, Stdout};
use std::sync::Arc;
use std::time::{Duration, Instant};
use crossterm::event::{self, Event, KeyCode, KeyEventKind};
use crossterm::terminal::{
EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode,
};
use crossterm::ExecutableCommand;
use iroh::PublicKey;
use ratatui::backend::CrosstermBackend;
use ratatui::layout::{Constraint, Direction, Layout, Rect};
use ratatui::style::{Color, Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Borders, Paragraph, Wrap};
use ratatui::Terminal;
use tokio::sync::mpsc;
use tokio_util::sync::CancellationToken;
use crate::network::Network;
use crate::radio::ConfigSource;
use crate::router::{PacketAction, PacketDirection, PacketLogEntry, RadioConfigInfo, Stats, StatsSnapshot};
const TICK_RATE: Duration = Duration::from_millis(250);
const MAX_LOG_ENTRIES: usize = 200;
/// Run the TUI event loop. Blocks until `q` is pressed or cancel is triggered.
pub async fn run(
node_id: PublicKey,
config_watch: tokio::sync::watch::Receiver<RadioConfigInfo>,
network: Arc<Network>,
stats: Arc<Stats>,
mut log_rx: mpsc::Receiver<PacketLogEntry>,
cancel: CancellationToken,
start_time: Instant,
) -> anyhow::Result<()> {
enable_raw_mode()?;
io::stdout().execute(EnterAlternateScreen)?;
let backend = CrosstermBackend::new(io::stdout());
let mut terminal = Terminal::new(backend)?;
let mut log_entries: Vec<PacketLogEntry> = Vec::with_capacity(MAX_LOG_ENTRIES);
let result = tui_loop(
&mut terminal,
node_id,
config_watch,
&network,
&stats,
&mut log_rx,
&cancel,
start_time,
&mut log_entries,
)
.await;
disable_raw_mode()?;
io::stdout().execute(LeaveAlternateScreen)?;
result
}
#[allow(clippy::too_many_arguments)]
async fn tui_loop(
terminal: &mut Terminal<CrosstermBackend<Stdout>>,
node_id: PublicKey,
config_watch: tokio::sync::watch::Receiver<RadioConfigInfo>,
network: &Network,
stats: &Stats,
log_rx: &mut mpsc::Receiver<PacketLogEntry>,
cancel: &CancellationToken,
start_time: Instant,
log_entries: &mut Vec<PacketLogEntry>,
) -> anyhow::Result<()> {
let mut last_tick = Instant::now();
loop {
// Drain log entries.
while let Ok(entry) = log_rx.try_recv() {
if log_entries.len() >= MAX_LOG_ENTRIES {
log_entries.remove(0);
}
log_entries.push(entry);
}
if cancel.is_cancelled() {
return Ok(());
}
let snap = stats.snapshot();
let peers = network.peer_states().await;
let config_info = config_watch.borrow().clone();
terminal.draw(|frame| {
let area = frame.area();
draw_ui(
frame,
area,
&node_id,
&config_info,
&snap,
&peers,
log_entries,
start_time,
);
})?;
// Handle input with timeout for tick rate.
let timeout = TICK_RATE
.checked_sub(last_tick.elapsed())
.unwrap_or(Duration::ZERO);
if event::poll(timeout)?
&& let Event::Key(key) = event::read()?
&& key.kind == KeyEventKind::Press
&& key.code == KeyCode::Char('q')
{
cancel.cancel();
return Ok(());
}
if last_tick.elapsed() >= TICK_RATE {
last_tick = Instant::now();
}
}
}
#[allow(clippy::too_many_arguments)]
fn draw_ui(
frame: &mut ratatui::Frame,
area: Rect,
node_id: &PublicKey,
config_info: &RadioConfigInfo,
stats: &StatsSnapshot,
peers: &[crate::network::PeerState],
log_entries: &[PacketLogEntry],
start_time: Instant,
) {
// Outer layout: header + body.
let outer = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Length(1), Constraint::Min(0)])
.split(area);
draw_header(frame, outer[0], node_id, start_time, stats);
// Body: 2x2 grid.
let rows = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Percentage(40), Constraint::Percentage(60)])
.split(outer[1]);
let top_cols = Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Percentage(50), Constraint::Percentage(50)])
.split(rows[0]);
let bot_cols = Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Percentage(35), Constraint::Percentage(65)])
.split(rows[1]);
draw_peers(frame, top_cols[0], peers);
draw_radio(frame, top_cols[1], config_info);
draw_stats(frame, bot_cols[0], stats, start_time);
draw_log(frame, bot_cols[1], log_entries);
}
fn draw_header(
frame: &mut ratatui::Frame,
area: Rect,
node_id: &PublicKey,
start_time: Instant,
stats: &StatsSnapshot,
) {
let id_str = node_id.to_string();
let id_short = if id_str.len() > 16 {
format!("{}...", &id_str[..16])
} else {
id_str
};
let radio_indicator = if stats.radio_connected {
Span::styled(" RADIO ", Style::default().fg(Color::Black).bg(Color::Green))
} else {
Span::styled(" RADIO ", Style::default().fg(Color::Black).bg(Color::Red))
};
let uptime = format_duration(start_time.elapsed());
let now = chrono_time();
let line = Line::from(vec![
Span::styled(
" donglora-bridge ",
Style::default()
.fg(Color::White)
.add_modifier(Modifier::BOLD),
),
radio_indicator,
Span::raw(" "),
Span::styled("ID: ", Style::default().fg(Color::DarkGray)),
Span::styled(id_short, Style::default().fg(Color::Cyan)),
Span::raw(" "),
Span::styled(format!("up {uptime}"), Style::default().fg(Color::DarkGray)),
Span::raw(" "),
Span::styled(now, Style::default().fg(Color::DarkGray)),
Span::styled(" q=quit", Style::default().fg(Color::DarkGray)),
]);
frame.render_widget(Paragraph::new(line), area);
}
fn draw_peers(frame: &mut ratatui::Frame, area: Rect, peers: &[crate::network::PeerState]) {
let block = Block::default()
.title(" Peers ")
.borders(Borders::ALL)
.border_style(Style::default().fg(Color::DarkGray));
let mut lines: Vec<Line> = Vec::new();
if peers.is_empty() {
lines.push(Line::styled(
" No peers configured",
Style::default().fg(Color::DarkGray),
));
}
for peer in peers {
let (dot, color) = if peer.connected {
("●", Color::Green)
} else {
("○", Color::Red)
};
let status = if peer.connected {
let ago = peer
.last_seen
.map(|t| format_short_duration(t.elapsed()))
.unwrap_or_default();
format!(" {ago}")
} else {
" reconnecting".into()
};
let pk_short = {
let s = peer.public_key.to_string();
if s.len() > 8 { format!(" ({}...)", &s[..8]) } else { format!(" ({s})") }
};
let pkts = format!(" in:{} out:{}", peer.packets_in, peer.packets_out);
lines.push(Line::from(vec![
Span::raw(" "),
Span::styled(dot, Style::default().fg(color)),
Span::raw(" "),
Span::styled(
peer.name.clone(),
Style::default().fg(Color::White).add_modifier(Modifier::BOLD),
),
Span::styled(pk_short, Style::default().fg(Color::DarkGray)),
Span::styled(status, Style::default().fg(Color::DarkGray)),
Span::styled(pkts, Style::default().fg(Color::DarkGray)),
]));
}
frame.render_widget(Paragraph::new(lines).block(block), area);
}
fn draw_radio(frame: &mut ratatui::Frame, area: Rect, config_info: &RadioConfigInfo) {
let title = match config_info.source {
ConfigSource::Ours => " Radio ",
ConfigSource::Mux => " Radio (mux config) ",
};
let border_color = match config_info.source {
ConfigSource::Ours => Color::DarkGray,
ConfigSource::Mux => Color::Yellow,
};
let block = Block::default()
.title(title)
.borders(Borders::ALL)
.border_style(Style::default().fg(border_color));
let mut lines: Vec<Line> = Vec::new();
if matches!(config_info.source, ConfigSource::Mux) {
lines.push(Line::styled(
" Using mux locked config",
Style::default().fg(Color::Yellow),
));
}
lines.extend(config_info.config_str.split_whitespace().map(|part| {
let (label, value) = part.split_once(':').unwrap_or(("", part));
if label.is_empty() {
Line::from(vec![
Span::raw(" "),
Span::styled(value, Style::default().fg(Color::Cyan)),
])
} else {
Line::from(vec![
Span::raw(" "),
Span::styled(format!("{label}: "), Style::default().fg(Color::DarkGray)),
Span::styled(value, Style::default().fg(Color::Cyan)),
])
}
}));
frame.render_widget(Paragraph::new(lines).block(block).wrap(Wrap { trim: false }), area);
}
fn draw_stats(
frame: &mut ratatui::Frame,
area: Rect,
stats: &StatsSnapshot,
start_time: Instant,
) {
let block = Block::default()
.title(" Stats ")
.borders(Borders::ALL)
.border_style(Style::default().fg(Color::DarkGray));
let stat_line = |label: &str, value: u64, color: Color| -> Line {
Line::from(vec![
Span::raw(" "),
Span::styled(
format!("{label:<14}"),
Style::default().fg(Color::DarkGray),
),
Span::styled(format!("{value:>8}"), Style::default().fg(color)),
])
};
let uptime = format_duration(start_time.elapsed());
let lines = vec![
Line::from(vec![
Span::raw(" "),
Span::styled("Uptime ", Style::default().fg(Color::DarkGray)),
Span::styled(format!("{uptime:>8}"), Style::default().fg(Color::White)),
]),
stat_line("Radio RX", stats.radio_rx, Color::Green),
stat_line("Radio TX", stats.radio_tx, Color::Blue),
stat_line("Forwarded", stats.forwarded, Color::Cyan),
stat_line("Dropped SNR", stats.dropped_snr, Color::Yellow),
stat_line("Deduped", stats.dropped_dedup, Color::DarkGray),
stat_line("Queue drops", stats.dropped_queue, Color::Red),
];
frame.render_widget(Paragraph::new(lines).block(block), area);
}
fn draw_log(frame: &mut ratatui::Frame, area: Rect, entries: &[PacketLogEntry]) {
let block = Block::default()
.title(" Packet Log ")
.borders(Borders::ALL)
.border_style(Style::default().fg(Color::DarkGray));
let inner_height = area.height.saturating_sub(2) as usize;
let start = entries.len().saturating_sub(inner_height);
let visible = &entries[start..];
let lines: Vec<Line> = visible
.iter()
.map(|e| {
let age = format_short_duration(e.timestamp.elapsed());
let dir_color = match e.direction {
PacketDirection::RadioRx => Color::Green,
PacketDirection::RadioTx => Color::Blue,
PacketDirection::NetRx => Color::Magenta,
};
let action_color = match e.action {
PacketAction::Forwarded => Color::Cyan,
PacketAction::Transmitted => Color::Blue,
PacketAction::DroppedDedup => Color::DarkGray,
PacketAction::DroppedQueueFull => Color::Red,
};
let rssi_str = e
.rssi
.map(|r| format!(" {r}dBm"))
.unwrap_or_default();
let snr_str = e
.snr
.map(|s| format!(" snr:{s}"))
.unwrap_or_default();
let peer_str = e
.peer_name
.as_deref()
.map(|n| format!(" {n}"))
.unwrap_or_default();
Line::from(vec![
Span::styled(
format!(" {age:>4} "),
Style::default().fg(Color::DarkGray),
),
Span::styled(
format!("{}", e.direction),
Style::default().fg(dir_color),
),
Span::styled(
format!(" {:>4}B", e.size),
Style::default().fg(Color::White),
),
Span::styled(rssi_str, Style::default().fg(Color::DarkGray)),
Span::styled(snr_str, Style::default().fg(Color::DarkGray)),
Span::styled(peer_str, Style::default().fg(Color::DarkGray)),
Span::raw(" "),
Span::styled(
format!("{}", e.action),
Style::default().fg(action_color),
),
])
})
.collect();
frame.render_widget(Paragraph::new(lines).block(block), area);
}
fn format_duration(d: Duration) -> String {
let secs = d.as_secs();
if secs < 60 {
format!("{secs}s")
} else if secs < 3600 {
format!("{}m {}s", secs / 60, secs % 60)
} else {
format!("{}h {}m", secs / 3600, (secs % 3600) / 60)
}
}
fn format_short_duration(d: Duration) -> String {
let secs = d.as_secs();
if secs < 60 {
format!("{secs}s")
} else if secs < 3600 {
format!("{}m", secs / 60)
} else {
format!("{}h", secs / 3600)
}
}
fn chrono_time() -> String {
// Use a simple approach without chrono dependency.
let now = std::time::SystemTime::now();
let since_epoch = now
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default();
let secs = since_epoch.as_secs();
let hours = (secs / 3600) % 24;
let minutes = (secs % 3600) / 60;
let seconds = secs % 60;
format!("{hours:02}:{minutes:02}:{seconds:02}")
}