Archived
feat: initial working P2P LoRa bridge
iroh QUIC datagrams for peer-to-peer networking with NAT traversal, donglora-client for radio access (direct USB or mux), ratatui TUI with four-panel dashboard (peers, radio config, stats, packet log). - Packet envelope with origin tracking, sequence numbers, RSSI/SNR - Dual dedup: iroh-level (origin+seq) and radio echo (blake3 hash) - SNR filtering per PROTOCOL.md (forward GOOD+MARGINAL, drop rest) - Auto-generate config + identity on first run - Graceful mux config fallback (adopts locked config if SetConfig rejected) - WouldBlock workaround for donglora-client socket timeout bug - Reconnect with exponential backoff on dongle disconnect - CLI: show-id, generate-config subcommands, --log-only headless mode
This commit is contained in:
10 files changed
+6891
No files matched your search
+286
@@ -0,0 +1,286 @@
|
||||
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
@@ -0,0 +1,234 @@
|
||||
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
@@ -0,0 +1,411 @@
|
||||
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
@@ -0,0 +1,187 @@
|
||||
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
@@ -0,0 +1,224 @@
|
||||
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
@@ -0,0 +1,370 @@
|
||||
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
@@ -0,0 +1,441 @@
|
||||
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}")
|
||||
}
|
||||
Reference in new issue
Block a user