9 Commits
Author SHA1 Message Date
swaits 6fd58bc2fb chore: repoint remaining links and host-specific tooling at git.swaits.com
Covers files missed by the first pass (.svelte/.astro components, bare org and
profile links, plugin doc headers), plus content that needed rewriting rather
than a URL swap: the chezmoi bootstrap script now provisions SSH for
git.swaits.com (User git, port 2222) and initialises from Gitea.

Link labels were updated to match their new targets.
2026-08-31 18:35:22 -06:00
swaits 80f225be41 chore: repoint remaining links at git.swaits.com
Rewrites lingering github.com / *.sr.ht references to this Gitea instance,
translating GitHub and Sourcehut URL shapes to Gitea's (/blob/<ref>/ and
sr.ht /tree/<ref>/item/ -> /src/branch/<ref>/, /tree/<tag> -> /src/tag/<tag>,
/actions/workflows/X -> /actions?workflow=X, /edit/ -> /_edit/).

Left pointing at the old hosts on purpose: pull-request links (PRs were not
migrated), forks that were never migrated, and GitHub-only surfaces Gitea
lacks. OpenPGP identity proofs are untouched by design.
2026-08-31 18:32:15 -06:00
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
13 changed files with 25 additions and 7894 deletions

No files matched your search

-1
View File
@@ -1 +0,0 @@
/target
Generated
-4940
View File
File diff suppressed because it is too large. Load diff
-37
View File
@@ -1,37 +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"
iroh-gossip = "0.97"
mainline = { version = "6", features = ["async"] }
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"
futures-lite = "2"
rand = "0.9"
hex = "0.4.3"
sha2 = "0.10"
tokio-util = { version = "0.7.18", features = ["rt"] }
jiff = "0.2.23"
[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** organization](https://git.swaits.com/donglora):
**[donglora/bridge](https://git.swaits.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://git.swaits.com/donglora/firmware) |
| Rust client library | [donglora/client-rs](https://git.swaits.com/donglora/client-rs) |
| Python client library | [donglora/client-py](https://git.swaits.com/donglora/client-py) |
| Rust mux daemon | [donglora/mux-rs](https://git.swaits.com/donglora/mux-rs) |
| Python mux daemon | [donglora/mux-py](https://git.swaits.com/donglora/mux-py) |
| MeshCore AI bot | [donglora/ai-bot](https://git.swaits.com/donglora/ai-bot) |
| Example scripts | [donglora/examples](https://git.swaits.com/donglora/examples) |
This repo is archived and will not receive further updates.
-191
View File
@@ -1,191 +0,0 @@
use std::path::{Path, PathBuf};
use anyhow::{Context, Result, bail};
use donglora_client::{Bandwidth, RadioConfig, TX_POWER_MAX};
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,
}
#[derive(Debug, Clone, 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 = "default_preamble")]
pub preamble: u16,
#[serde(default = "default_true")]
pub cad: bool,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct BridgeSection {
/// Shared passphrase. All bridges with the same passphrase join the same swarm.
#[serde(default)]
pub passphrase: String,
#[serde(default = "default_dedup_window")]
pub dedup_window_secs: u64,
#[serde(default = "default_tx_queue_size")]
pub tx_queue_size: usize,
/// Optional manual override for the radio TX rate limit (packets per second).
/// If omitted, a rate is calculated from the radio config.
pub rate_limit_pps: Option<f64>,
pub log_file: Option<PathBuf>,
}
// ── Defaults (MeshCore US/Canada Recommended) ────────────────────
fn default_frequency() -> u32 {
910_525_000
}
fn default_bandwidth() -> String {
"62.5kHz".into()
}
fn default_sf() -> u8 {
7
}
fn default_cr() -> u8 {
5
}
fn default_sync_word() -> u16 {
0x3444
}
fn default_tx_power() -> String {
"22".into()
}
fn default_preamble() -> u16 {
16
}
fn default_true() -> bool {
true
}
fn default_dedup_window() -> u64 {
300
}
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: default_preamble(),
cad: true,
}
}
}
impl Default for BridgeSection {
fn default() -> Self {
Self {
passphrase: String::new(),
dedup_window_secs: default_dedup_window(),
tx_queue_size: default_tx_queue_size(),
rate_limit_pps: None,
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),
})
}
}
// ── 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 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. Returns None-passphrase error if not set.
pub fn load_config(path: &Path) -> Result<Config> {
let text = std::fs::read_to_string(path)
.with_context(|| format!("reading config: {}", path.display()))?;
let cfg: Config = toml::from_str(&text).context("parsing config TOML")?;
if cfg.bridge.passphrase.is_empty() || cfg.bridge.passphrase == "change-me" {
bail!(
"passphrase is required — run `donglora-bridge config` to set it"
);
}
Ok(cfg)
}
-659
View File
@@ -1,659 +0,0 @@
use std::collections::HashSet;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant};
use anyhow::{Context, Result};
use bytes::Bytes;
use iroh::endpoint::Incoming;
use iroh::{Endpoint, PublicKey, SecretKey};
use futures_lite::StreamExt;
use iroh_gossip::api::Event;
use iroh_gossip::net::{Gossip as GossipActor, GOSSIP_ALPN};
use iroh_gossip::TopicId;
use mainline::async_dht::AsyncDht;
use mainline::{Dht, MutableItem, SigningKey};
use sha2::{Digest, Sha256};
use tokio::sync::mpsc;
use tokio_util::sync::CancellationToken;
use tracing::{debug, info, warn};
use crate::packet::GossipFrame;
/// Maximum number of peers to store in a DHT record.
/// 28 × 32 bytes = 896 bytes raw. Bencoded: 28 × ~36 = ~1008.
/// Conservative cap to stay under BEP44's 1000-byte value limit.
const MAX_DHT_PEERS: usize = 27;
/// DHT heartbeat interval (base, before jitter).
const DHT_HEARTBEAT_SECS: u64 = 300;
/// Maximum random jitter added to heartbeat interval.
const DHT_HEARTBEAT_JITTER_SECS: u64 = 60;
// ── Key derivation ───────────────────────────────────────────────
/// Cryptographic keys derived from a shared passphrase.
pub struct PassphraseKeys {
pub topic_id: TopicId,
pub dht_signing_key: SigningKey,
pub dht_salt: [u8; 20],
}
impl PassphraseKeys {
/// Derive all keys from a passphrase.
pub fn derive(passphrase: &str) -> Self {
// Application-specific prefix to avoid DHT collisions with other apps.
const APP_PREFIX: &str = "donglora-bridge:";
// Topic ID: blake3 hash of prefixed passphrase.
let topic_bytes = *blake3::hash(format!("{APP_PREFIX}{passphrase}").as_bytes()).as_bytes();
let topic_id = TopicId::from_bytes(topic_bytes);
// DHT signing key: ed25519 from SHA-256 of prefixed passphrase + "sign".
let sign_seed = Sha256::digest(format!("{APP_PREFIX}sign:{passphrase}").as_bytes());
let seed_bytes: [u8; 32] = sign_seed.into();
let dht_signing_key = SigningKey::from_bytes(&seed_bytes);
// DHT salt: first 20 bytes of SHA-256 of prefixed passphrase + "salt".
let salt_hash = Sha256::digest(format!("{APP_PREFIX}salt:{passphrase}").as_bytes());
let mut dht_salt = [0u8; 20];
dht_salt.copy_from_slice(&salt_hash[..20]);
Self {
topic_id,
dht_signing_key,
dht_salt,
}
}
}
// ── Swarm state (for TUI) ───────────────────────────────────────
/// Observable state of the gossip swarm.
#[derive(Debug, Clone)]
pub struct SwarmState {
pub topic_hash: String,
pub neighbor_count: usize,
pub dht_status: DhtStatus,
pub last_dht_publish: Option<Instant>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DhtStatus {
Bootstrapping,
Ready,
PublishFailed,
}
impl std::fmt::Display for DhtStatus {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Bootstrapping => write!(f, "Bootstrapping"),
Self::Ready => write!(f, "Ready"),
Self::PublishFailed => write!(f, "Failed"),
}
}
}
// ── Events to router ────────────────────────────────────────────
/// Events from the gossip swarm to the router.
pub enum GossipEvent {
/// A gossip frame received from the swarm.
Frame(GossipFrame),
/// Neighbor count changed.
NeighborChanged(usize),
}
// ── Gossip handle ───────────────────────────────────────────────
/// Handle to the gossip swarm. Manages iroh endpoint, gossip subscription, and DHT.
pub struct Gossip {
endpoint: Endpoint,
gossip: GossipActor,
state: Arc<Mutex<SwarmState>>,
neighbors: Arc<Mutex<HashSet<PublicKey>>>,
dht_signing_key: SigningKey,
dht_salt: [u8; 20],
cancel: CancellationToken,
}
impl Gossip {
/// Create and start the gossip network.
///
/// Spawns background tasks for connection acceptance, gossip event forwarding,
/// gossip broadcasting, and DHT heartbeat.
///
/// Returns `(handle, event_rx, frame_tx)`:
/// - `event_rx`: channel of events from the swarm to the router
/// - `frame_tx`: channel for the router to send frames to broadcast
pub async fn new(
secret_key: SecretKey,
keys: &PassphraseKeys,
) -> Result<(Self, mpsc::Receiver<GossipEvent>, mpsc::Sender<GossipFrame>)> {
let cancel = CancellationToken::new();
// Build iroh endpoint with n0 discovery.
let endpoint = Endpoint::builder(iroh::endpoint::presets::N0)
.secret_key(secret_key)
.alpns(vec![GOSSIP_ALPN.to_vec()])
.bind()
.await
.context("binding iroh endpoint")?;
let my_id = endpoint.id();
info!("iroh endpoint bound, id: {}", my_id.fmt_short());
// Build gossip actor.
let gossip = GossipActor::builder().spawn(endpoint.clone());
// Bootstrap from DHT.
let bootstrap_peers = dht_get_peers(keys).await;
if bootstrap_peers.is_empty() {
info!("no peers found in DHT — we are the first node");
} else {
info!("found {} peer(s) in DHT", bootstrap_peers.len());
}
// Subscribe to gossip topic.
let topic = gossip
.subscribe(keys.topic_id, bootstrap_peers)
.await
.context("subscribing to gossip topic")?;
let (sender, receiver) = topic.split();
// Initial swarm state.
let topic_hash = hex::encode(&keys.topic_id.as_bytes()[..8]);
let state = Arc::new(Mutex::new(SwarmState {
topic_hash,
neighbor_count: 0,
dht_status: DhtStatus::Bootstrapping,
last_dht_publish: None,
}));
let neighbor_count = Arc::new(AtomicUsize::new(0));
let neighbors: Arc<Mutex<HashSet<PublicKey>>> = Arc::new(Mutex::new(HashSet::new()));
// Channels.
let (event_tx, event_rx) = mpsc::channel::<GossipEvent>(256);
let (frame_tx, frame_rx) = mpsc::channel::<GossipFrame>(256);
let handle = Self {
endpoint: endpoint.clone(),
gossip: gossip.clone(),
state: state.clone(),
neighbors: neighbors.clone(),
dht_signing_key: keys.dht_signing_key.clone(),
dht_salt: keys.dht_salt,
cancel: cancel.clone(),
};
// Spawn accept loop.
spawn_accept_loop(endpoint.clone(), gossip.clone(), cancel.clone());
// Spawn gossip receiver task.
spawn_receiver(receiver, event_tx, state.clone(), neighbor_count.clone(), neighbors.clone(), cancel.clone());
// Clone sender for DHT heartbeat to feed discovered peers via join_peers.
let dht_sender = sender.clone();
// Spawn gossip broadcaster task.
spawn_broadcaster(sender, frame_rx, cancel.clone());
// Spawn DHT heartbeat.
spawn_dht_heartbeat(
my_id,
keys.dht_signing_key.clone(),
keys.dht_salt,
dht_sender,
neighbors,
state,
neighbor_count,
cancel,
);
// Try to join (non-blocking — fire and forget with timeout).
// The receiver task handles events regardless.
Ok((handle, event_rx, frame_tx))
}
/// Snapshot of swarm state for TUI.
pub fn swarm_state(&self) -> SwarmState {
self.state.lock().unwrap_or_else(|e| e.into_inner()).clone()
}
/// Graceful shutdown. Publishes neighbors (without ourselves) to DHT
/// so other nodes can still bootstrap from live peers.
pub async fn shutdown(&self) {
// Publish neighbors-only to DHT before shutting down.
let neighbor_ids: Vec<[u8; 32]> = self
.neighbors
.lock()
.unwrap_or_else(|e| e.into_inner())
.iter()
.map(|id| *id.as_bytes())
.collect();
if let Ok(dht) = Dht::client() {
let dht = dht.as_async();
let value = encode_peer_list(&neighbor_ids);
let seq = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs() as i64)
.unwrap_or(0);
let item = MutableItem::new(
self.dht_signing_key.clone(),
&value,
seq,
Some(self.dht_salt.as_ref()),
);
match dht.put_mutable(item, None).await {
Ok(_) => info!("shutdown: published {} neighbor(s) to DHT (removed self)", neighbor_ids.len()),
Err(e) => debug!("shutdown: DHT publish failed: {e:#}"),
}
}
self.cancel.cancel();
let _ = self.gossip.shutdown().await;
self.endpoint.close().await;
}
}
// ── Background tasks ────────────────────────────────────────────
/// Accept incoming iroh connections and forward gossip ALPN to the gossip actor.
fn spawn_accept_loop(endpoint: Endpoint, gossip: GossipActor, cancel: CancellationToken) {
tokio::spawn(async move {
loop {
let incoming = tokio::select! {
incoming = endpoint.accept() => incoming,
() = cancel.cancelled() => break,
};
let Some(incoming) = incoming else { break };
let gossip = gossip.clone();
tokio::spawn(handle_incoming(incoming, gossip));
}
debug!("accept loop stopped");
});
}
async fn handle_incoming(incoming: Incoming, gossip: GossipActor) {
let Ok(conn) = incoming.await else { return };
let alpn = conn.alpn();
if *alpn == *GOSSIP_ALPN {
if let Err(e) = gossip.handle_connection(conn).await {
debug!("gossip handle_connection error: {e:#}");
}
} else {
debug!("unexpected ALPN: {:?}", String::from_utf8_lossy(alpn));
}
}
/// Forward gossip events to the router via channel.
fn spawn_receiver(
mut receiver: iroh_gossip::api::GossipReceiver,
event_tx: mpsc::Sender<GossipEvent>,
state: Arc<Mutex<SwarmState>>,
neighbor_count: Arc<AtomicUsize>,
neighbors: Arc<Mutex<HashSet<PublicKey>>>,
cancel: CancellationToken,
) {
tokio::spawn(async move {
loop {
let event = tokio::select! {
event = receiver.next() => event,
() = cancel.cancelled() => break,
};
let Some(Ok(event)) = event else { break };
match event {
Event::Received(msg) => {
if let Some(frame) = GossipFrame::decode(&msg.content) {
let _ = event_tx.send(GossipEvent::Frame(frame)).await;
} else {
debug!("received malformed gossip frame ({} bytes)", msg.content.len());
}
}
Event::NeighborUp(id) => {
let count = neighbor_count.fetch_add(1, Ordering::Relaxed) + 1;
info!("neighbor up: {} (total: {count})", id.fmt_short());
if let Ok(mut n) = neighbors.lock() { n.insert(id); }
if let Ok(mut s) = state.lock() { s.neighbor_count = count; }
let _ = event_tx.send(GossipEvent::NeighborChanged(count)).await;
}
Event::NeighborDown(id) => {
let count = neighbor_count.fetch_sub(1, Ordering::Relaxed).saturating_sub(1);
info!("neighbor down: {} (total: {count})", id.fmt_short());
if let Ok(mut n) = neighbors.lock() { n.remove(&id); }
if let Ok(mut s) = state.lock() { s.neighbor_count = count; }
let _ = event_tx.send(GossipEvent::NeighborChanged(count)).await;
}
Event::Lagged => {
warn!("gossip receiver lagged — some messages were dropped");
}
}
}
debug!("gossip receiver stopped");
});
}
/// Read frames from the router and broadcast them to the swarm.
fn spawn_broadcaster(
sender: iroh_gossip::api::GossipSender,
mut frame_rx: mpsc::Receiver<GossipFrame>,
cancel: CancellationToken,
) {
tokio::spawn(async move {
loop {
let frame = tokio::select! {
frame = frame_rx.recv() => frame,
() = cancel.cancelled() => break,
};
let Some(frame) = frame else { break };
let encoded = Bytes::from(frame.encode());
if let Err(e) = sender.broadcast(encoded).await {
debug!("gossip broadcast error: {e:#}");
}
}
debug!("gossip broadcaster stopped");
});
}
/// DHT poll interval while alone (read frequently, publish infrequently).
const DHT_ALONE_READ_SECS: u64 = 3;
/// How often a lonely node publishes (merge-only, never destructive).
/// Slower than reads — publishing is heavier and a lonely node only adds itself.
const DHT_ALONE_PUBLISH_SECS: u64 = 30;
/// Periodically interact with the DHT to discover and share peers.
///
/// Strategy:
/// - **Alone:** Read every 3s (cheap). Publish every 30s via merge — reads
/// existing data first, appends ourselves, writes back. Two fresh nodes
/// both doing this will eventually see each other. Never blindly overwrites.
/// - **Connected:** Read + publish on relaxed 5-min schedule. We have
/// valuable neighbor info to share.
/// - **Transition to connected:** Publish immediately.
#[allow(clippy::too_many_arguments)]
fn spawn_dht_heartbeat(
my_id: PublicKey,
signing_key: SigningKey,
salt: [u8; 20],
gossip_sender: iroh_gossip::api::GossipSender,
neighbors: Arc<Mutex<HashSet<PublicKey>>>,
state: Arc<Mutex<SwarmState>>,
neighbor_count: Arc<AtomicUsize>,
cancel: CancellationToken,
) {
tokio::spawn(async move {
let dht = match Dht::client() {
Ok(d) => d.as_async(),
Err(e) => {
warn!("failed to create DHT client: {e:#}");
return;
}
};
let pub_key_bytes = signing_key.verifying_key().to_bytes();
let mut was_alone = true;
let mut last_publish = Instant::now();
// Initial: read to discover existing peers, then merge-publish ourselves.
dht_read_and_join(&dht, &my_id, &pub_key_bytes, &salt, &gossip_sender, &state).await;
dht_publish(&dht, &my_id, &signing_key, &salt, &pub_key_bytes, &neighbors, &state).await;
loop {
let alone = neighbor_count.load(Ordering::Relaxed) == 0;
// Transition: alone → connected. Publish immediately.
if was_alone && !alone {
info!("first neighbor joined — publishing to DHT");
dht_publish(&dht, &my_id, &signing_key, &salt, &pub_key_bytes, &neighbors, &state).await;
last_publish = Instant::now();
}
was_alone = alone;
// Sleep: fast reads while alone, relaxed when connected.
let sleep_dur = if alone {
Duration::from_secs(DHT_ALONE_READ_SECS)
} else {
let jitter = rand::random::<u64>() % DHT_HEARTBEAT_JITTER_SECS.max(1);
Duration::from_secs(DHT_HEARTBEAT_SECS + jitter)
};
tokio::select! {
() = tokio::time::sleep(sleep_dur) => {}
() = cancel.cancelled() => break,
}
// Always read — cheap, discovers peers to join.
dht_read_and_join(&dht, &my_id, &pub_key_bytes, &salt, &gossip_sender, &state).await;
// Publish cadence depends on state.
let publish_interval = if alone {
Duration::from_secs(DHT_ALONE_PUBLISH_SECS)
} else {
Duration::from_secs(DHT_HEARTBEAT_SECS)
};
if last_publish.elapsed() >= publish_interval {
dht_publish(&dht, &my_id, &signing_key, &salt, &pub_key_bytes, &neighbors, &state).await;
last_publish = Instant::now();
}
}
debug!("DHT heartbeat stopped");
});
}
/// Read the DHT and feed any discovered peers into the gossip swarm.
async fn dht_read_and_join(
dht: &AsyncDht,
my_id: &PublicKey,
pub_key_bytes: &[u8; 32],
salt: &[u8; 20],
gossip_sender: &iroh_gossip::api::GossipSender,
state: &Arc<Mutex<SwarmState>>,
) {
if let Some(record) = dht.get_mutable_most_recent(pub_key_bytes, Some(salt)).await {
let peers = decode_peer_list(record.value());
let new_peers: Vec<_> = peers
.into_iter()
.filter(|id| *id != *my_id)
.collect();
if !new_peers.is_empty() {
info!("DHT discovered {} peer(s), feeding to gossip", new_peers.len());
if let Err(e) = gossip_sender.join_peers(new_peers).await {
debug!("join_peers error: {e:#}");
}
}
if let Ok(mut s) = state.lock() {
s.dht_status = DhtStatus::Ready;
}
}
}
async fn dht_publish(
dht: &AsyncDht,
my_id: &PublicKey,
signing_key: &SigningKey,
salt: &[u8; 20],
pub_key_bytes: &[u8; 32],
neighbors: &Arc<Mutex<HashSet<PublicKey>>>,
state: &Arc<Mutex<SwarmState>>,
) {
let has_neighbors = neighbors.lock().map(|n| !n.is_empty()).unwrap_or(false);
let mut seen = HashSet::new();
let mut peer_list: Vec<[u8; 32]> = Vec::with_capacity(MAX_DHT_PEERS);
// Always include ourselves.
peer_list.push(*my_id.as_bytes());
seen.insert(*my_id.as_bytes());
if has_neighbors {
// Connected: publish authoritative data — self + live neighbors only.
// This purges dead/stale peers from the DHT. We know who's alive
// because they're our active gossip neighbors.
if let Ok(n) = neighbors.lock() {
for id in n.iter() {
if seen.insert(*id.as_bytes()) && peer_list.len() < MAX_DHT_PEERS {
peer_list.push(*id.as_bytes());
}
}
}
} else {
// Alone: merge-publish — read existing DHT data first, add ourselves,
// preserve other peers. We don't know who's alive so we can't purge.
if let Some(record) = dht.get_mutable_most_recent(pub_key_bytes, Some(salt)).await {
for existing in decode_peer_list(record.value()) {
if seen.insert(*existing.as_bytes()) && peer_list.len() < MAX_DHT_PEERS {
peer_list.push(*existing.as_bytes());
}
}
}
}
let value = encode_peer_list(&peer_list);
let seq = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs() as i64)
.unwrap_or(0);
let item = MutableItem::new(signing_key.clone(), &value, seq, Some(salt.as_ref()));
match dht.put_mutable(item, None).await {
Ok(_) => {
debug!("published to DHT (seq={seq}, peers={})", peer_list.len());
if let Ok(mut s) = state.lock() {
s.dht_status = DhtStatus::Ready;
s.last_dht_publish = Some(Instant::now());
}
}
Err(e) => {
warn!("DHT publish failed: {e:#}");
if let Ok(mut s) = state.lock() {
s.dht_status = DhtStatus::PublishFailed;
}
}
}
}
// ── DHT helpers ─────────────────────────────────────────────────
/// Query the DHT for existing peers (blocking bootstrap, called once on startup).
async fn dht_get_peers(keys: &PassphraseKeys) -> Vec<PublicKey> {
let dht = match Dht::client() {
Ok(d) => d.as_async(),
Err(e) => {
warn!("failed to create DHT client for bootstrap: {e:#}");
return vec![];
}
};
let pub_key_bytes = keys.dht_signing_key.verifying_key().to_bytes();
match dht.get_mutable_most_recent(&pub_key_bytes, Some(&keys.dht_salt)).await {
Some(record) => {
let peers = decode_peer_list(record.value());
debug!("DHT bootstrap: found {} peer(s)", peers.len());
peers
}
None => {
debug!("DHT bootstrap: no existing record");
vec![]
}
}
}
/// Encode a list of 32-byte peer IDs into bytes for DHT storage.
/// Format: [count: u16 LE] [id_0: 32B] [id_1: 32B] ...
fn encode_peer_list(peers: &[[u8; 32]]) -> Vec<u8> {
let count = peers.len().min(MAX_DHT_PEERS) as u16;
let mut buf = Vec::with_capacity(2 + (count as usize) * 32);
buf.extend_from_slice(&count.to_le_bytes());
for id in peers.iter().take(MAX_DHT_PEERS) {
buf.extend_from_slice(id);
}
buf
}
/// Decode a peer list from DHT record value.
fn decode_peer_list(data: &[u8]) -> Vec<PublicKey> {
if data.len() < 2 {
return vec![];
}
let count = u16::from_le_bytes([data[0], data[1]]) as usize;
let mut peers = Vec::with_capacity(count);
let mut offset = 2;
for _ in 0..count {
if offset + 32 > data.len() {
break;
}
let bytes: [u8; 32] = match data[offset..offset + 32].try_into() {
Ok(b) => b,
Err(_) => break,
};
if let Ok(pk) = PublicKey::from_bytes(&bytes) {
peers.push(pk);
}
offset += 32;
}
peers
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn passphrase_keys_deterministic() {
let k1 = PassphraseKeys::derive("test-passphrase");
let k2 = PassphraseKeys::derive("test-passphrase");
assert_eq!(k1.topic_id, k2.topic_id);
assert_eq!(k1.dht_signing_key.to_bytes(), k2.dht_signing_key.to_bytes());
assert_eq!(k1.dht_salt, k2.dht_salt);
}
#[test]
fn passphrase_keys_different() {
let k1 = PassphraseKeys::derive("alpha");
let k2 = PassphraseKeys::derive("beta");
assert_ne!(k1.topic_id, k2.topic_id);
assert_ne!(k1.dht_signing_key.to_bytes(), k2.dht_signing_key.to_bytes());
}
#[test]
fn peer_list_roundtrip() {
// Generate valid ed25519 public keys.
let keys: Vec<_> = (0..3)
.map(|_| {
let sk = iroh::SecretKey::generate(&mut rand::rng());
*sk.public().as_bytes()
})
.collect();
let encoded = encode_peer_list(&keys);
let decoded = decode_peer_list(&encoded);
assert_eq!(decoded.len(), 3);
for (i, key) in keys.iter().enumerate() {
assert_eq!(*decoded[i].as_bytes(), *key);
}
}
#[test]
fn peer_list_empty() {
let encoded = encode_peer_list(&[]);
let decoded = decode_peer_list(&encoded);
assert!(decoded.is_empty());
}
#[test]
fn peer_list_truncated_data() {
// Only 1 byte — too short for count.
assert!(decode_peer_list(&[0]).is_empty());
// Count says 2 but only 1 peer present.
let mut data = vec![2, 0]; // count = 2
data.extend_from_slice(&[0xAA; 32]); // only 1 peer
let decoded = decode_peer_list(&data);
assert_eq!(decoded.len(), 1);
}
}
-234
View File
@@ -1,234 +0,0 @@
mod config;
mod gossip;
mod packet;
mod radio;
mod rate_limit;
mod router;
mod setup;
mod tui;
use std::sync::Arc;
use std::time::Instant;
use anyhow::{Context, Result};
use clap::{Parser, Subcommand};
use iroh::SecretKey;
use tokio::sync::mpsc;
use tokio_util::sync::CancellationToken;
use tracing::{info, warn};
use crate::gossip::PassphraseKeys;
use crate::rate_limit::RateLimiter;
use crate::router::Stats;
#[derive(Parser)]
#[command(name = "donglora-bridge", about = "LoRa gossip 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 {
/// Interactive configuration wizard
Config,
}
#[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()?,
};
// Config subcommand: run wizard with existing config as defaults.
if let Some(Commands::Config) = cli.command {
let existing = if config_path.exists() {
config::load_config(&config_path).ok()
} else {
None
};
return setup::run_wizard(&config_path, existing.as_ref());
}
// First-run: no config file → run wizard automatically.
if !config_path.exists() {
println!(" No config found. Let's set one up!\n");
return setup::run_wizard(&config_path, None);
}
// 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 {
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();
}
// Ephemeral identity — fresh keypair each launch.
let secret_key = SecretKey::generate(&mut rand::rng());
let node_id = secret_key.public();
info!("donglora-bridge starting, ephemeral id: {}", node_id.fmt_short());
// Parse radio config.
let radio_config = cfg.radio.to_radio_config()?;
// Derive passphrase keys.
let keys = PassphraseKeys::derive(&cfg.bridge.passphrase);
info!("topic: {}", hex::encode(&keys.topic_id.as_bytes()[..8]));
// Create rate limiter.
let rate_limiter = RateLimiter::from_radio_config(
radio_config.sf,
radio_config.bw,
radio_config.cr,
radio_config.preamble_len,
cfg.bridge.rate_limit_pps,
);
info!("rate limiter: {:.1} pps", rate_limiter.rate_pps());
// Create gossip network.
let (swarm, gossip_event_rx, gossip_frame_tx) =
gossip::Gossip::new(secret_key, &keys).await?;
let swarm = Arc::new(swarm);
// 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 {
active: radio_config,
requested: radio_config,
source: radio::ConfigSource::Ours,
device: String::new(),
connected: false,
},
);
// 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;
tokio::spawn(async move {
router::run(
router_our_id,
dedup_window,
tx_queue_size,
radio_config,
rate_limiter,
radio_event_rx,
radio_tx,
gossip_event_rx,
gossip_frame_tx,
&router_stats,
log_tx,
config_watch_tx,
)
.await;
router_cancel.cancel();
});
// Spawn a task that cancels on ctrl-c or SIGTERM.
{
let cancel = cancel.clone();
tokio::spawn(async move {
let sigterm = tokio::signal::unix::signal(
tokio::signal::unix::SignalKind::terminate(),
);
match sigterm {
Ok(mut sigterm) => {
tokio::select! {
_ = tokio::signal::ctrl_c() => info!("received SIGINT"),
_ = sigterm.recv() => info!("received SIGTERM"),
}
}
Err(e) => {
warn!("failed to register SIGTERM handler: {e:#}");
let _ = tokio::signal::ctrl_c().await;
info!("received SIGINT");
}
}
cancel.cancel();
});
}
// Run TUI or headless.
if cli.log_only {
info!("running in headless mode");
cancel.cancelled().await;
} else {
let terminal = tui::run(
config_watch_rx,
swarm.clone(),
stats,
log_rx,
cancel.clone(),
start_time,
)
.await;
cancel.cancel();
swarm.shutdown().await;
info!("donglora-bridge stopped");
if let Ok(mut term) = terminal {
tui::restore_terminal(&mut term);
}
return Ok(());
}
// Shutdown (headless path).
swarm.shutdown().await;
info!("donglora-bridge stopped");
Ok(())
}
-175
View File
@@ -1,175 +0,0 @@
/// Gossip frame header size: sender(32) + rssi(2) + snr(2) + payload_len(2) = 38
const FRAME_HEADER_SIZE: usize = 38;
/// 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.
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 a gossip frame.
#[derive(Debug, Clone)]
pub struct RadioPacket {
pub rssi: i16,
pub snr: i16,
pub payload: Vec<u8>,
}
/// A gossip frame — the wire format sent through the iroh-gossip swarm.
///
/// Wire format: `[sender: 32B] [rssi: i16 LE] [snr: i16 LE] [payload_len: u16 LE] [payload: NB]`
#[derive(Debug, Clone)]
pub struct GossipFrame {
/// EndpointId (PublicKey bytes) of the bridge that first heard this packet on radio.
pub sender: [u8; 32],
/// 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 GossipFrame {
/// Create a new gossip frame from a public key and radio packet data.
pub fn new(sender: &iroh::PublicKey, rssi: i16, snr: i16, payload: Vec<u8>) -> Self {
Self {
sender: *sender.as_bytes(),
rssi,
snr,
payload,
}
}
/// Encode this frame into bytes for gossip broadcast.
pub fn encode(&self) -> Vec<u8> {
let mut buf = Vec::with_capacity(FRAME_HEADER_SIZE + self.payload.len());
buf.extend_from_slice(&self.sender);
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 a gossip frame from bytes received from the swarm.
pub fn decode(data: &[u8]) -> Option<Self> {
if data.len() < FRAME_HEADER_SIZE {
return None;
}
let sender: [u8; 32] = data[..32].try_into().ok()?;
let rssi = i16::from_le_bytes(data[32..34].try_into().ok()?);
let snr = i16::from_le_bytes(data[34..36].try_into().ok()?);
let payload_len = u16::from_le_bytes(data[36..38].try_into().ok()?) as usize;
if data.len() != FRAME_HEADER_SIZE + payload_len {
return None;
}
Some(Self {
sender,
rssi,
snr,
payload: data[FRAME_HEADER_SIZE..].to_vec(),
})
}
}
/// Compute a 32-byte blake3 content hash for deduplication.
pub fn content_hash(payload: &[u8]) -> [u8; 32] {
*blake3::hash(payload).as_bytes()
}
#[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 gossip_frame_roundtrip() {
let key = iroh::SecretKey::generate(&mut rand::rng());
let frame = GossipFrame::new(&key.public(), -85, 8, vec![1, 2, 3, 4, 5]);
let encoded = frame.encode();
let decoded = GossipFrame::decode(&encoded).expect("decode should succeed");
assert_eq!(decoded.sender, *key.public().as_bytes());
assert_eq!(decoded.rssi, -85);
assert_eq!(decoded.snr, 8);
assert_eq!(decoded.payload, vec![1, 2, 3, 4, 5]);
}
#[test]
fn gossip_frame_reject_short() {
assert!(GossipFrame::decode(&[0u8; 10]).is_none());
}
#[test]
fn gossip_frame_reject_wrong_length() {
// Header says payload_len=5 but only 3 bytes follow.
let mut data = vec![0u8; 38 + 3];
data[36..38].copy_from_slice(&5u16.to_le_bytes());
assert!(GossipFrame::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);
}
}
-317
View File
@@ -1,317 +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 and device info.
Connected(RadioConfig, ConfigSource, String),
/// 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 initial_backoff = Duration::from_millis(250);
let max_backoff = Duration::from_secs(5);
let mut backoff = initial_backoff;
loop {
let started = std::time::Instant::now();
match connect_and_run(&config, &port, &event_tx, &mut tx_recv) {
Ok(()) => {
info!("radio thread exiting");
return;
}
Err(e) => {
error!("radio error: {e:#}");
let _ = event_tx.blocking_send(RadioEvent::Disconnected);
// If we were connected for a while, reset backoff so
// the next reconnect attempt is fast.
if started.elapsed() > Duration::from_secs(5) {
backoff = initial_backoff;
}
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<()> {
// Long initial timeout: the mux serializes commands through the dongle,
// so our GetConfig might have to wait behind other clients' commands.
let timeout = Duration::from_secs(10);
info!("connecting to dongle...");
let (mut client, device) = if let Some(port) = &port {
let c = donglora_client::connect(Some(port), timeout)?;
(c, shorten_path(port))
} else {
let c = donglora_client::connect_mux_auto(timeout)?;
(c, "mux".to_string())
};
info!("[radio] transport connected: {device}");
info!("[radio] sending ping...");
match client.ping() {
Ok(()) => info!("[radio] ping OK"),
Err(e) => {
info!("[radio] ping FAILED: {e:#}");
return Err(e);
}
}
info!("[radio] negotiating config...");
let (active_config, config_source) = match negotiate_config(&mut client, config) {
Ok(result) => {
info!("[radio] config negotiated: source={:?}, config={}", result.1, format_radio_config(&result.0));
result
}
Err(e) => {
info!("[radio] config negotiation FAILED: {e:#}");
return Err(e);
}
};
info!("[radio] sending start_rx...");
match client.start_rx() {
Ok(()) => info!("[radio] start_rx OK"),
Err(e) => {
info!("[radio] start_rx FAILED: {e:#}");
return Err(e);
}
}
let _ = event_tx.blocking_send(RadioEvent::Connected(active_config, config_source, device));
// Set a short timeout so we can interleave RX and TX.
client
.transport_mut()
.set_timeout(Duration::from_millis(100))?;
info!("[radio] entering main loop");
// Liveness: ping the mux periodically when idle to detect a dead connection.
// Without this, a killed mux causes recv() to silently return Ok(None) forever.
let liveness_interval = Duration::from_secs(2);
let mut last_activity = std::time::Instant::now();
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:#}");
return Err(e);
}
last_activity = std::time::Instant::now();
}
// Check for RX packets.
match client.recv() {
Ok(Some(Response::RxPacket { rssi, snr, payload })) => {
last_activity = std::time::Instant::now();
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() {
return Ok(());
}
} else {
debug!(
"RX drop {} bytes rssi={rssi} snr={snr} grade={grade}",
payload.len()
);
}
}
Ok(Some(_)) => {
last_activity = std::time::Instant::now();
}
Ok(None) => {}
Err(e) if is_timeout_error(&e) => {}
Err(e) => return Err(e),
}
// Liveness check: if no real activity for a while, ping the mux.
if last_activity.elapsed() >= liveness_interval {
// Temporarily extend the timeout for the ping command.
let _ = client.transport_mut().set_timeout(Duration::from_secs(2));
match client.ping() {
Ok(()) => {
last_activity = std::time::Instant::now();
}
Err(e) => {
warn!("liveness ping failed: {e:#}");
return Err(e);
}
}
// Restore the short timeout.
let _ = client.transport_mut().set_timeout(Duration::from_millis(100));
}
if event_tx.is_closed() {
return Ok(());
}
}
}
/// Negotiate radio config with the dongle/mux.
///
/// Strategy:
/// 1. GetConfig first to read what's active (caches the result).
/// 2. If it matches our desired config, use it — no SetConfig needed.
/// 3. If it differs, only try SetConfig if we haven't been rejected before.
/// 4. If SetConfig is rejected, set the flag IMMEDIATELY (before the mux can
/// disconnect us and lose this state), then return the cached GetConfig result.
///
/// On reconnect, the flag ensures we never retry SetConfig.
fn negotiate_config<T: Transport>(
client: &mut donglora_client::Client<T>,
desired: &RadioConfig,
) -> Result<(RadioConfig, ConfigSource)> {
info!("[negotiate] get_config...");
match client.get_config() {
Ok(cfg) => {
let source = if configs_match(&cfg, desired) {
info!("[negotiate] get_config OK (matches desired): {}", format_radio_config(&cfg));
ConfigSource::Ours
} else {
info!("[negotiate] get_config OK (differs from desired): {}", format_radio_config(&cfg));
ConfigSource::Mux
};
Ok((cfg, source))
}
Err(e) => {
info!("[negotiate] get_config FAILED: {e:#}");
// No config on radio — try to set ours.
info!("[negotiate] trying set_config...");
match client.set_config(*desired) {
Ok(()) => {
info!("[negotiate] set_config OK");
Ok((*desired, ConfigSource::Ours))
}
Err(e2) => {
info!("[negotiate] set_config FAILED: {e2:#}");
anyhow::bail!("GetConfig failed ({e:#}) and SetConfig also failed ({e2:#})");
}
}
}
}
}
/// Shorten a device path to just the filename for display.
fn shorten_path(path: &str) -> String {
std::path::Path::new(path)
.file_name()
.map(|f| f.to_string_lossy().into_owned())
.unwrap_or_else(|| path.to_string())
}
fn configs_match(a: &RadioConfig, b: &RadioConfig) -> bool {
a.freq_hz == b.freq_hz
&& a.bw == b.bw
&& a.sf == b.sf
&& a.cr == b.cr
&& a.sync_word == b.sync_word
&& a.tx_power_dbm == b.tx_power_dbm
&& a.preamble_len == b.preamble_len
&& a.cad == b.cad
}
fn is_timeout_error(e: &anyhow::Error) -> bool {
if let Some(io_err) = e.downcast_ref::<std::io::Error>() {
matches!(
io_err.kind(),
std::io::ErrorKind::WouldBlock | std::io::ErrorKind::TimedOut
)
} else {
false
}
}
/// Format a bandwidth value for display.
pub fn format_bandwidth(bw: donglora_client::Bandwidth) -> &'static str {
match bw {
donglora_client::Bandwidth::Khz7 => "7.8 kHz",
donglora_client::Bandwidth::Khz10 => "10.4 kHz",
donglora_client::Bandwidth::Khz15 => "15.6 kHz",
donglora_client::Bandwidth::Khz20 => "20.8 kHz",
donglora_client::Bandwidth::Khz31 => "31.25 kHz",
donglora_client::Bandwidth::Khz41 => "41.7 kHz",
donglora_client::Bandwidth::Khz62 => "62.5 kHz",
donglora_client::Bandwidth::Khz125 => "125 kHz",
donglora_client::Bandwidth::Khz250 => "250 kHz",
donglora_client::Bandwidth::Khz500 => "500 kHz",
}
}
/// Format a RadioConfig for display.
pub fn format_radio_config(config: &RadioConfig) -> String {
let freq_mhz = config.freq_hz as f64 / 1_000_000.0;
let bw = format_bandwidth(config.bw);
let power = if config.tx_power_dbm == donglora_client::TX_POWER_MAX {
"max".to_string()
} else {
format!("{} dBm", config.tx_power_dbm)
};
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
)
}
-174
View File
@@ -1,174 +0,0 @@
use std::time::{Duration, Instant};
use donglora_client::Bandwidth;
/// Token-bucket rate limiter for radio transmissions.
///
/// Calculates a safe transmit rate from the LoRa radio configuration (spreading factor,
/// bandwidth, coding rate) and enforces it with a token bucket that allows short bursts.
pub struct RateLimiter {
tokens: f64,
max_tokens: f64,
refill_rate: f64, // tokens per second
last_refill: Instant,
}
/// Default burst size (max tokens).
const DEFAULT_BURST: f64 = 3.0;
/// Reference payload size (bytes) used to estimate air time for rate calculation.
const REFERENCE_PAYLOAD_BYTES: usize = 50;
/// Target duty cycle fraction (0.5 = 50%).
const TARGET_DUTY_CYCLE: f64 = 0.5;
impl RateLimiter {
/// Create a rate limiter derived from radio config.
///
/// If `override_pps` is `Some`, use that rate directly instead of calculating.
pub fn from_radio_config(
sf: u8,
bw: Bandwidth,
cr: u8,
preamble: u16,
override_pps: Option<f64>,
) -> Self {
let refill_rate = if let Some(pps) = override_pps {
pps.max(0.01)
} else {
let air_time = lora_air_time(REFERENCE_PAYLOAD_BYTES, sf, bw, cr, preamble);
let secs = air_time.as_secs_f64();
if secs > 0.0 {
TARGET_DUTY_CYCLE / secs
} else {
10.0 // fallback
}
};
Self {
tokens: DEFAULT_BURST,
max_tokens: DEFAULT_BURST,
refill_rate,
last_refill: Instant::now(),
}
}
/// Try to consume one token. Returns `true` if the transmission is allowed.
pub fn try_acquire(&mut self) -> bool {
self.refill();
if self.tokens >= 1.0 {
self.tokens -= 1.0;
true
} else {
false
}
}
/// Current rate in packets per second.
pub fn rate_pps(&self) -> f64 {
self.refill_rate
}
fn refill(&mut self) {
let now = Instant::now();
let elapsed = now.duration_since(self.last_refill).as_secs_f64();
self.tokens = (self.tokens + elapsed * self.refill_rate).min(self.max_tokens);
self.last_refill = now;
}
}
/// Estimate LoRa packet air time using the Semtech SX1276 formula.
///
/// Parameters:
/// - `payload_bytes`: payload size in bytes
/// - `sf`: spreading factor (7–12)
/// - `bw`: bandwidth
/// - `cr`: coding rate denominator (5–8, meaning 4/5 to 4/8)
/// - `preamble`: preamble length in symbols (0 = firmware default of 16)
pub fn lora_air_time(payload_bytes: usize, sf: u8, bw: Bandwidth, cr: u8, preamble: u16) -> Duration {
let sf_f = f64::from(sf);
let bw_hz = bandwidth_hz(bw);
let preamble_symbols = if preamble == 0 { 16.0 } else { f64::from(preamble) };
// Symbol time in seconds.
let t_sym = 2.0_f64.powf(sf_f) / bw_hz;
// Preamble time.
let t_preamble = (preamble_symbols + 4.25) * t_sym;
// Low data rate optimize: enabled when SF >= 11 and BW <= 125 kHz.
let de: f64 = if sf >= 11 && bw_hz <= 125_000.0 {
1.0
} else {
0.0
};
// Payload symbol count (Semtech formula with CRC enabled, explicit header).
let numerator = 8.0 * payload_bytes as f64 - 4.0 * sf_f + 28.0 + 16.0;
let denominator = 4.0 * (sf_f - 2.0 * de);
let n_payload = 8.0 + (numerator / denominator).ceil().max(0.0) * f64::from(cr);
let t_payload = n_payload * t_sym;
let total = t_preamble + t_payload;
Duration::from_secs_f64(total)
}
fn bandwidth_hz(bw: Bandwidth) -> f64 {
match bw {
Bandwidth::Khz7 => 7_800.0,
Bandwidth::Khz10 => 10_400.0,
Bandwidth::Khz15 => 15_600.0,
Bandwidth::Khz20 => 20_800.0,
Bandwidth::Khz31 => 31_250.0,
Bandwidth::Khz41 => 41_700.0,
Bandwidth::Khz62 => 62_500.0,
Bandwidth::Khz125 => 125_000.0,
Bandwidth::Khz250 => 250_000.0,
Bandwidth::Khz500 => 500_000.0,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn air_time_sf7_bw125() {
let t = lora_air_time(50, 7, Bandwidth::Khz125, 5, 16);
// SF7/125kHz/CR4/5/16-preamble/50 bytes: ~100ms range
assert!(t.as_millis() > 50, "air time too short: {t:?}");
assert!(t.as_millis() < 300, "air time too long: {t:?}");
}
#[test]
fn air_time_sf12_bw125() {
let t = lora_air_time(50, 12, Bandwidth::Khz125, 5, 16);
// SF12/125kHz: should be multiple seconds
assert!(t.as_secs() >= 1, "air time too short: {t:?}");
assert!(t.as_secs() < 10, "air time too long: {t:?}");
}
#[test]
fn rate_limiter_allows_burst() {
let mut rl = RateLimiter::from_radio_config(7, Bandwidth::Khz125, 5, 16, None);
// Should allow burst of 3
assert!(rl.try_acquire());
assert!(rl.try_acquire());
assert!(rl.try_acquire());
// 4th should fail (no time for refill)
assert!(!rl.try_acquire());
}
#[test]
fn rate_limiter_override() {
let rl = RateLimiter::from_radio_config(7, Bandwidth::Khz125, 5, 16, Some(42.0));
assert!((rl.rate_pps() - 42.0).abs() < f64::EPSILON);
}
#[test]
fn air_time_zero_preamble_uses_default() {
let t_default = lora_air_time(50, 7, Bandwidth::Khz125, 5, 0);
let t_explicit = lora_air_time(50, 7, Bandwidth::Khz125, 5, 16);
assert_eq!(t_default, t_explicit);
}
}
-311
View File
@@ -1,311 +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::gossip::GossipEvent;
use crate::packet::{GossipFrame, content_hash};
use crate::radio::{ConfigSource, RadioEvent};
use crate::rate_limit::RateLimiter;
/// Active radio config info, sent to the TUI via watch channel.
#[derive(Debug, Clone)]
pub struct RadioConfigInfo {
pub active: donglora_client::RadioConfig,
pub requested: donglora_client::RadioConfig,
pub source: ConfigSource,
pub device: String,
pub connected: bool,
}
/// Packet event for the TUI log.
#[derive(Debug, Clone)]
pub struct PacketLogEntry {
pub timestamp: Instant,
pub hash: [u8; 32],
pub direction: PacketDirection,
pub size: usize,
pub snr: Option<i16>,
pub rssi: Option<i16>,
pub action: PacketAction,
}
/// Which side the packet arrived from.
#[derive(Debug, Clone, Copy)]
pub enum PacketDirection {
/// Heard on local radio → forwarded to gossip swarm.
RadioIn,
/// Received from gossip swarm → queued for radio TX.
GossipIn,
}
/// What happened to the packet.
#[derive(Debug, Clone, Copy)]
pub enum PacketAction {
/// Successfully bridged (radio→gossip or gossip→radio).
Bridged,
/// Dropped: duplicate payload.
DroppedDedup,
/// Dropped: TX queue full.
DroppedQueueFull,
/// Dropped: rate limiter.
DroppedRateLimit,
}
impl std::fmt::Display for PacketAction {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Bridged => write!(f, ""),
Self::DroppedDedup => write!(f, "DUP"),
Self::DroppedQueueFull => write!(f, "FULL"),
Self::DroppedRateLimit => write!(f, "RATE"),
}
}
}
/// Aggregate stats for the TUI.
#[derive(Debug, Default)]
pub struct Stats {
pub radio_rx: AtomicU64,
pub radio_tx: AtomicU64,
pub gossip_rx: AtomicU64,
pub gossip_tx: AtomicU64,
pub dedup_hits: AtomicU64,
pub rate_limit_drops: AtomicU64,
pub dropped_queue: AtomicU64,
pub neighbor_count: 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),
gossip_rx: self.gossip_rx.load(Ordering::Relaxed),
gossip_tx: self.gossip_tx.load(Ordering::Relaxed),
dedup_hits: self.dedup_hits.load(Ordering::Relaxed),
rate_limit_drops: self.rate_limit_drops.load(Ordering::Relaxed),
dropped_queue: self.dropped_queue.load(Ordering::Relaxed),
neighbor_count: self.neighbor_count.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 gossip_rx: u64,
pub gossip_tx: u64,
pub dedup_hits: u64,
pub rate_limit_drops: u64,
pub dropped_queue: u64,
pub neighbor_count: u64,
pub radio_connected: bool,
}
/// Time-bounded dedup cache.
struct DedupCache {
entries: HashMap<[u8; 32], Instant>,
ttl: Duration,
}
impl DedupCache {
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: [u8; 32]) -> bool {
let now = Instant::now();
if let Some(ts) = self.entries.get(&key)
&& now.duration_since(*ts) < self.ttl
{
return true;
}
self.entries.insert(key, now);
false
}
fn prune(&mut self) {
let now = Instant::now();
self.entries.retain(|_, ts| now.duration_since(*ts) < self.ttl);
}
}
/// Run the router — the central packet bus.
///
/// Bridges radio packets to/from the gossip swarm with deduplication and rate limiting.
#[allow(clippy::too_many_arguments)]
pub async fn run(
our_id: PublicKey,
dedup_window: Duration,
tx_queue_size: usize,
requested_radio_config: donglora_client::RadioConfig,
mut rate_limiter: RateLimiter,
mut radio_rx: mpsc::Receiver<RadioEvent>,
radio_tx: mpsc::Sender<Vec<u8>>,
mut gossip_rx: mpsc::Receiver<GossipEvent>,
gossip_tx: mpsc::Sender<GossipFrame>,
stats: &Stats,
log_tx: mpsc::Sender<PacketLogEntry>,
config_tx: tokio::sync::watch::Sender<RadioConfigInfo>,
) {
let mut dedup = DedupCache::new(dedup_window);
let mut tx_queue: VecDeque<Vec<u8>> = VecDeque::with_capacity(tx_queue_size);
let mut last_cleanup = Instant::now();
let cleanup_interval = Duration::from_secs(30);
loop {
// Periodic dedup cleanup.
if last_cleanup.elapsed() >= cleanup_interval {
dedup.prune();
last_cleanup = Instant::now();
}
tokio::select! {
// Radio events.
event = radio_rx.recv() => {
let Some(event) = event else { break };
match event {
RadioEvent::Packet(pkt) => {
stats.radio_rx.fetch_add(1, Ordering::Relaxed);
let hash = content_hash(&pkt.payload);
if dedup.check_and_insert(hash) {
stats.dedup_hits.fetch_add(1, Ordering::Relaxed);
debug!("radio RX dedup suppressed ({} bytes)", pkt.payload.len());
let _ = log_tx.send(PacketLogEntry {
timestamp: Instant::now(),
hash,
direction: PacketDirection::RadioIn,
size: pkt.payload.len(),
snr: Some(pkt.snr),
rssi: Some(pkt.rssi),
action: PacketAction::DroppedDedup,
}).await;
continue;
}
// Wrap and broadcast to gossip.
let frame = GossipFrame::new(&our_id, pkt.rssi, pkt.snr, pkt.payload.clone());
stats.gossip_tx.fetch_add(1, Ordering::Relaxed);
let _ = gossip_tx.send(frame).await;
let _ = log_tx.send(PacketLogEntry {
timestamp: Instant::now(),
hash,
direction: PacketDirection::RadioIn,
size: pkt.payload.len(),
snr: Some(pkt.snr),
rssi: Some(pkt.rssi),
action: PacketAction::Bridged,
}).await;
}
RadioEvent::Connected(active_config, source, device) => {
stats.radio_connected.store(1, Ordering::Relaxed);
let _ = config_tx.send(RadioConfigInfo {
active: active_config,
requested: requested_radio_config,
source,
device,
connected: true,
});
}
RadioEvent::Disconnected => {
stats.radio_connected.store(0, Ordering::Relaxed);
config_tx.send_modify(|info| info.connected = false);
}
}
}
// Gossip events.
event = gossip_rx.recv() => {
let Some(event) = event else { break };
match event {
GossipEvent::Frame(frame) => {
stats.gossip_rx.fetch_add(1, Ordering::Relaxed);
let hash = content_hash(&frame.payload);
if dedup.check_and_insert(hash) {
stats.dedup_hits.fetch_add(1, Ordering::Relaxed);
debug!("gossip RX dedup suppressed ({} bytes)", frame.payload.len());
let _ = log_tx.send(PacketLogEntry {
timestamp: Instant::now(),
hash,
direction: PacketDirection::GossipIn,
size: frame.payload.len(),
snr: Some(frame.snr),
rssi: Some(frame.rssi),
action: PacketAction::DroppedDedup,
}).await;
continue;
}
// Rate limit check.
if !rate_limiter.try_acquire() {
stats.rate_limit_drops.fetch_add(1, Ordering::Relaxed);
debug!("rate limited ({} bytes)", frame.payload.len());
let _ = log_tx.send(PacketLogEntry {
timestamp: Instant::now(),
hash,
direction: PacketDirection::GossipIn,
size: frame.payload.len(),
snr: Some(frame.snr),
rssi: Some(frame.rssi),
action: PacketAction::DroppedRateLimit,
}).await;
continue;
}
// 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)", frame.payload.len());
let _ = log_tx.send(PacketLogEntry {
timestamp: Instant::now(),
hash,
direction: PacketDirection::GossipIn,
size: frame.payload.len(),
snr: Some(frame.snr),
rssi: Some(frame.rssi),
action: PacketAction::DroppedQueueFull,
}).await;
continue;
}
tx_queue.push_back(frame.payload.clone());
let _ = log_tx.send(PacketLogEntry {
timestamp: Instant::now(),
hash,
direction: PacketDirection::GossipIn,
size: frame.payload.len(),
snr: Some(frame.snr),
rssi: Some(frame.rssi),
action: PacketAction::Bridged,
}).await;
}
GossipEvent::NeighborChanged(count) => {
stats.neighbor_count.store(count as u64, Ordering::Relaxed);
}
}
}
}
// Drain TX queue to radio.
while let Some(payload) = tx_queue.pop_front() {
stats.radio_tx.fetch_add(1, Ordering::Relaxed);
if radio_tx.send(payload).await.is_err() {
break;
}
}
}
}
-177
View File
@@ -1,177 +0,0 @@
//! Interactive console setup wizard for bridge configuration.
use std::io::{self, Write};
use std::path::Path;
use anyhow::{Context, Result};
use crate::config::{BridgeSection, Config, RadioSection};
/// ANSI escape helpers.
const BOLD: &str = "\x1b[1m";
const DIM: &str = "\x1b[2m";
const CYAN: &str = "\x1b[36m";
const GREEN: &str = "\x1b[32m";
const YELLOW: &str = "\x1b[33m";
const RESET: &str = "\x1b[0m";
/// Run the interactive setup wizard. If `existing` is Some, use those values as defaults.
pub fn run_wizard(config_path: &Path, existing: Option<&Config>) -> Result<()> {
let defaults = existing.map_or_else(
|| (RadioSection::default(), BridgeSection::default()),
|c| (c.radio.clone(), c.bridge.clone()),
);
let (radio, bridge) = defaults;
println!();
println!(" {BOLD}donglora-bridge setup{RESET}");
println!(" {DIM}Configure your LoRa gossip bridge.{RESET}");
println!();
// ── Passphrase ───────────────────────────────────────────────
let default_pass = if bridge.passphrase.is_empty() || bridge.passphrase == "change-me" {
None
} else {
Some(bridge.passphrase.as_str())
};
let passphrase = prompt_required("Passphrase", default_pass)?;
println!();
println!(" {BOLD}Radio Settings{RESET} {DIM}(Enter to keep default){RESET}");
println!(" {DIM}────────────────────────────────────{RESET}");
// ── Radio ────────────────────────────────────────────────────
let freq_default = format!("{:.3}", radio.frequency as f64 / 1_000_000.0);
let freq_mhz: f64 = prompt_parse("Frequency (MHz)", &freq_default)?;
let frequency = (freq_mhz * 1_000_000.0) as u32;
let bandwidth = prompt_default("Bandwidth", &radio.bandwidth)?;
let sf_default = format!("{}", radio.spreading_factor);
let spreading_factor: u8 = prompt_parse("Spreading Factor", &sf_default)?;
let cr_default = format!("{}", radio.coding_rate);
let coding_rate: u8 = prompt_parse("Coding Rate", &cr_default)?;
let sw_default = format!("0x{:04X}", radio.sync_word);
let sync_word_str = prompt_default("Sync Word", &sw_default)?;
let sync_word = parse_sync_word(&sync_word_str)?;
let tx_power = prompt_default("TX Power (dBm or 'max')", &radio.tx_power)?;
let pre_default = format!("{}", radio.preamble);
let preamble: u16 = prompt_parse("Preamble", &pre_default)?;
let cad_default = if radio.cad { "on" } else { "off" };
let cad_str = prompt_default("CAD", cad_default)?;
let cad = matches!(cad_str.to_lowercase().as_str(), "on" | "true" | "yes" | "1");
let port_default = radio.port.as_deref().unwrap_or("auto");
let port_str = prompt_default("Serial Port", port_default)?;
let port = if port_str == "auto" || port_str.is_empty() {
None
} else {
Some(port_str)
};
// ── Build and save ───────────────────────────────────────────
let cfg = Config {
radio: RadioSection {
port,
frequency,
bandwidth,
spreading_factor,
coding_rate,
sync_word,
tx_power,
preamble,
cad,
},
bridge: BridgeSection {
passphrase,
dedup_window_secs: bridge.dedup_window_secs,
tx_queue_size: bridge.tx_queue_size,
rate_limit_pps: bridge.rate_limit_pps,
log_file: bridge.log_file,
},
};
write_config(config_path, &cfg)?;
println!();
println!(" {GREEN}Config saved to {}{RESET}", config_path.display());
println!(" Run {CYAN}donglora-bridge{RESET} to start!");
println!();
Ok(())
}
fn write_config(path: &Path, cfg: &Config) -> Result<()> {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)
.with_context(|| format!("creating directory: {}", parent.display()))?;
}
let content = toml::to_string_pretty(cfg).context("serializing config")?;
std::fs::write(path, content)
.with_context(|| format!("writing config: {}", path.display()))?;
Ok(())
}
// ── Prompt helpers ───────────────────────────────────────────────
fn prompt_default(label: &str, default: &str) -> Result<String> {
print!(" {label} {DIM}[{default}]{RESET}: ");
io::stdout().flush()?;
let mut input = String::new();
io::stdin().read_line(&mut input)?;
let trimmed = input.trim();
if trimmed.is_empty() {
Ok(default.to_string())
} else {
Ok(trimmed.to_string())
}
}
fn prompt_required(label: &str, default: Option<&str>) -> Result<String> {
loop {
let prompt = if let Some(d) = default {
format!(" {label} {DIM}[{d}]{RESET}: ")
} else {
format!(" {label}: ")
};
print!("{prompt}");
io::stdout().flush()?;
let mut input = String::new();
io::stdin().read_line(&mut input)?;
let trimmed = input.trim();
if !trimmed.is_empty() {
return Ok(trimmed.to_string());
}
if let Some(d) = default {
return Ok(d.to_string());
}
println!(" {YELLOW}This field is required.{RESET}");
}
}
fn prompt_parse<T: std::str::FromStr>(label: &str, default: &str) -> Result<T>
where
T::Err: std::fmt::Display,
{
loop {
let input = prompt_default(label, default)?;
match input.parse::<T>() {
Ok(v) => return Ok(v),
Err(e) => println!(" {YELLOW}Invalid input: {e}{RESET}"),
}
}
}
fn parse_sync_word(s: &str) -> Result<u16> {
let s = s.trim().to_lowercase();
let s = s.strip_prefix("0x").unwrap_or(&s);
u16::from_str_radix(s, 16).with_context(|| format!("invalid sync word: {s}"))
}
-678
View File
@@ -1,678 +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 ratatui::backend::CrosstermBackend;
use ratatui::layout::{Alignment, Constraint, Direction, Layout, Rect};
use ratatui::style::{Color, Modifier, Style};
use ratatui::symbols;
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Borders, Clear, Paragraph, Sparkline};
use ratatui::Terminal;
use tokio::sync::mpsc;
use tokio_util::sync::CancellationToken;
use crate::gossip::Gossip;
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;
const BUCKET_SECS: u64 = 1;
const MAX_BUCKETS: usize = 300;
// ── Color palette (consistent across panels) ─────────────────────
const C_RADIO_RX: Color = Color::Green;
const C_RADIO_TX: Color = Color::Blue;
const C_NET_RX: Color = Color::Magenta;
const C_NET_TX: Color = Color::Cyan;
const C_DEDUP: Color = Color::DarkGray;
const C_RATE_DROP: Color = Color::Yellow;
const C_QUEUE_DROP: Color = Color::Red;
const C_SNR: Color = Color::Yellow;
const C_LABEL: Color = Color::DarkGray;
const C_VALUE: Color = Color::White;
const C_ACCENT: Color = Color::Cyan;
const C_BORDER: Color = Color::DarkGray;
const C_TITLE: Color = Color::White;
// ── Time series ──────────────────────────────────────────────────
#[derive(Default, Clone, Copy)]
struct Bucket {
radio_rx: u64,
radio_tx: u64,
snr_sum: i64,
snr_count: u64,
}
struct TimeSeries {
buckets: Vec<Bucket>,
last_snap: StatsSnapshot,
last_bucket_time: Instant,
}
impl TimeSeries {
fn new(snap: &StatsSnapshot) -> Self {
Self {
buckets: vec![Bucket::default(); MAX_BUCKETS],
last_snap: snap.clone(),
last_bucket_time: Instant::now(),
}
}
fn update(&mut self, snap: &StatsSnapshot, log_entries: &[PacketLogEntry]) {
let now = Instant::now();
if now.duration_since(self.last_bucket_time).as_secs() >= BUCKET_SECS {
let cutoff = self.last_bucket_time;
let (snr_sum, snr_count) = snr_stats_since(log_entries, cutoff);
let bucket = Bucket {
radio_rx: snap.radio_rx.saturating_sub(self.last_snap.radio_rx),
radio_tx: snap.radio_tx.saturating_sub(self.last_snap.radio_tx),
snr_sum,
snr_count,
};
self.buckets.push(bucket);
if self.buckets.len() > MAX_BUCKETS {
self.buckets.remove(0);
}
self.last_snap = snap.clone();
self.last_bucket_time = now;
}
}
fn radio_rx(&self) -> Vec<u64> {
self.buckets.iter().map(|b| b.radio_rx).collect()
}
fn radio_tx(&self) -> Vec<u64> {
self.buckets.iter().map(|b| b.radio_tx).collect()
}
fn avg_snr(&self) -> Vec<u64> {
self.buckets
.iter()
.map(|b| {
if b.snr_count > 0 {
let avg = b.snr_sum / b.snr_count as i64;
(avg + 30).max(0) as u64
} else {
0
}
})
.collect()
}
fn peak_rx(&self) -> u64 {
self.buckets.iter().map(|b| b.radio_rx).max().unwrap_or(0)
}
fn peak_tx(&self) -> u64 {
self.buckets.iter().map(|b| b.radio_tx).max().unwrap_or(0)
}
fn latest_avg_snr(&self) -> Option<i64> {
self.buckets.iter().rev().find(|b| b.snr_count > 0).map(|b| b.snr_sum / b.snr_count as i64)
}
}
fn snr_stats_since(entries: &[PacketLogEntry], since: Instant) -> (i64, u64) {
let mut sum = 0i64;
let mut count = 0u64;
for e in entries.iter().rev() {
if e.timestamp <= since {
break;
}
if let Some(s) = e.snr {
sum += i64::from(s);
count += 1;
}
}
(sum, count)
}
// ── Public API ───────────────────────────────────────────────────
type Term = Terminal<CrosstermBackend<Stdout>>;
pub async fn run(
config_watch: tokio::sync::watch::Receiver<RadioConfigInfo>,
gossip: Arc<Gossip>,
stats: Arc<Stats>,
mut log_rx: mpsc::Receiver<PacketLogEntry>,
cancel: CancellationToken,
start_time: Instant,
) -> anyhow::Result<Term> {
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 mut series = TimeSeries::new(&stats.snapshot());
let _result = tui_loop(
&mut terminal, config_watch, &gossip, &stats,
&mut log_rx, &cancel, start_time, &mut log_entries, &mut series,
).await;
terminal.draw(|frame| draw_shutdown_overlay(frame, frame.area()))?;
Ok(terminal)
}
pub fn restore_terminal(terminal: &mut Term) {
let _ = disable_raw_mode();
let _ = io::stdout().execute(LeaveAlternateScreen);
let _ = terminal.show_cursor();
}
#[allow(clippy::too_many_arguments)]
async fn tui_loop(
terminal: &mut Term,
config_watch: tokio::sync::watch::Receiver<RadioConfigInfo>,
gossip: &Gossip,
stats: &Stats,
log_rx: &mut mpsc::Receiver<PacketLogEntry>,
cancel: &CancellationToken,
start_time: Instant,
log_entries: &mut Vec<PacketLogEntry>,
series: &mut TimeSeries,
) -> anyhow::Result<()> {
let mut last_tick = Instant::now();
loop {
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();
series.update(&snap, log_entries);
let swarm_state = gossip.swarm_state();
let config_info = config_watch.borrow().clone();
terminal.draw(|frame| {
draw_ui(frame, frame.area(), &config_info, &snap, &swarm_state, log_entries, start_time, series);
})?;
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
&& matches!(
(key.code, key.modifiers),
(KeyCode::Char('q'), _)
| (KeyCode::Char('c'), crossterm::event::KeyModifiers::CONTROL)
)
{
cancel.cancel();
return Ok(());
}
if last_tick.elapsed() >= TICK_RATE {
last_tick = Instant::now();
}
}
}
// ── Layout ───────────────────────────────────────────────────────
#[allow(clippy::too_many_arguments)]
fn draw_ui(
frame: &mut ratatui::Frame,
area: Rect,
config_info: &RadioConfigInfo,
stats: &StatsSnapshot,
swarm_state: &crate::gossip::SwarmState,
log_entries: &[PacketLogEntry],
start_time: Instant,
series: &TimeSeries,
) {
// Header row + body.
let rows = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Length(1), Constraint::Min(0)])
.split(area);
draw_header(frame, rows[0], stats);
// Body: 3 columns — left panels | packet log (fixed) | activity charts (fill).
// Packet log columns: Age=5 Hash=8 RF=4 Net=4 Size=6 RSSI=6 SNR=5 Act=5 = 43 + 2 border + 1 pad = 46
let cols = Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Length(30), Constraint::Length(46), Constraint::Min(10)])
.split(rows[1]);
// Left column: Network, Radio, Stats stacked.
let left = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(6),
Constraint::Length(12),
Constraint::Min(5),
])
.split(cols[0]);
draw_network(frame, left[0], swarm_state);
draw_radio(frame, left[1], config_info);
draw_stats(frame, left[2], stats, start_time);
draw_log(frame, cols[1], log_entries);
draw_activity(frame, cols[2], series);
}
// ── Header ───────────────────────────────────────────────────────
fn draw_header(frame: &mut ratatui::Frame, area: Rect, stats: &StatsSnapshot) {
let cols = Layout::default()
.direction(Direction::Horizontal)
.constraints([
Constraint::Length(14), // pills
Constraint::Min(0), // fill
])
.split(area);
// Left: status pills.
let pills = Line::from(vec![
Span::raw(" "),
status_pill("RADIO", stats.radio_connected),
Span::raw(" "),
status_pill("NET", stats.neighbor_count > 0),
]);
frame.render_widget(Paragraph::new(pills), cols[0]);
// Right: clock with timezone.
let clock = Line::from(Span::styled(
format!("{} ", chrono_time()),
Style::default().fg(C_LABEL),
));
frame.render_widget(Paragraph::new(clock).alignment(Alignment::Right), cols[1]);
}
fn status_pill(label: &str, ok: bool) -> Span<'static> {
let (fg, bg) = if ok {
(Color::Black, Color::Green)
} else {
(Color::White, C_LABEL)
};
Span::styled(format!(" {label} "), Style::default().fg(fg).bg(bg))
}
// ── Network panel (was Swarm) ────────────────────────────────────
fn draw_network(frame: &mut ratatui::Frame, area: Rect, state: &crate::gossip::SwarmState) {
let block = panel_block(" Network ");
let dht_color = match state.dht_status {
crate::gossip::DhtStatus::Ready => Color::Green,
crate::gossip::DhtStatus::Bootstrapping => C_RATE_DROP,
crate::gossip::DhtStatus::PublishFailed => C_QUEUE_DROP,
};
let neighbor_color = if state.neighbor_count > 0 { Color::Green } else { C_RATE_DROP };
let last_pub = state
.last_dht_publish
.map(|t| format!("{}s ago", t.elapsed().as_secs()))
.unwrap_or_else(|| "-".into());
let lines = vec![
kv_line("ID", &state.topic_hash, C_ACCENT),
kv_line("Peers", &format!("{}", state.neighbor_count), neighbor_color),
kv_line("DHT", &format!("{}", state.dht_status), dht_color),
kv_line("Published", &last_pub, C_LABEL),
];
frame.render_widget(Paragraph::new(lines).block(block), area);
}
// ── Radio panel ──────────────────────────────────────────────────
fn draw_radio(frame: &mut ratatui::Frame, area: Rect, config_info: &RadioConfigInfo) {
let connected = config_info.connected;
let is_mux = matches!(config_info.source, ConfigSource::Mux);
let border_color = if !connected {
C_QUEUE_DROP
} else if is_mux {
C_RATE_DROP
} else {
C_BORDER
};
let title = if !connected {
" Radio (disconnected) "
} else if is_mux {
" Radio (mux) "
} else {
" Radio "
};
let block = Block::default()
.title(Span::styled(
title,
Style::default().fg(C_TITLE).add_modifier(Modifier::BOLD),
))
.borders(Borders::ALL)
.border_style(Style::default().fg(border_color));
let a = &config_info.active;
let r = &config_info.requested;
let freq_mhz = a.freq_hz as f64 / 1_000_000.0;
let bw_str = crate::radio::format_bandwidth(a.bw);
let power_str = if a.tx_power_dbm == donglora_client::TX_POWER_MAX {
"max".to_string()
} else {
format!("{} dBm", a.tx_power_dbm)
};
let preamble = if a.preamble_len == 0 { 16 } else { a.preamble_len };
let cad = if a.cad != 0 { "on" } else { "off" };
// Dim values when disconnected; highlight mux mismatches when connected.
let val_color = if connected { C_ACCENT } else { C_LABEL };
let cf = |label: &str, value: String, matches: bool| -> Line<'static> {
let color = if !connected {
C_LABEL
} else if !is_mux || matches {
C_ACCENT
} else {
C_RATE_DROP
};
let mark = if connected && is_mux && !matches { " !" } else { "" };
kv_line(label, &format!("{value}{mark}"), color)
};
let dev = if config_info.device.is_empty() { "-" } else { &config_info.device };
let lines = vec![
kv_line("Device", dev, val_color),
cf("Freq", format!("{freq_mhz:.3} MHz"), a.freq_hz == r.freq_hz),
cf("BW", bw_str.to_string(), a.bw == r.bw),
cf("SF", format!("{}", a.sf), a.sf == r.sf),
cf("CR", format!("4/{}", a.cr), a.cr == r.cr),
cf("Sync", format!("0x{:04X}", a.sync_word), a.sync_word == r.sync_word),
cf("TX", power_str, a.tx_power_dbm == r.tx_power_dbm),
cf("Preamble", format!("{preamble}"), a.preamble_len == r.preamble_len),
cf("CAD", cad.to_string(), a.cad == r.cad),
];
frame.render_widget(Paragraph::new(lines).block(block), area);
}
// ── Stats panel ──────────────────────────────────────────────────
fn draw_stats(frame: &mut ratatui::Frame, area: Rect, stats: &StatsSnapshot, start_time: Instant) {
let block = panel_block(" Stats ");
let s = |label: &str, value: u64, color: Color| -> Line<'static> {
Line::from(vec![
Span::raw(" "),
Span::styled(format!("{label:<9}"), Style::default().fg(C_LABEL)),
Span::styled(format!("{value:>6}"), 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(C_LABEL)),
Span::styled(format!("{uptime:>6}"), Style::default().fg(C_VALUE)),
]),
s("RF RX", stats.radio_rx, C_RADIO_RX),
s("RF TX", stats.radio_tx, C_RADIO_TX),
s("Net RX", stats.gossip_rx, C_NET_RX),
s("Net TX", stats.gossip_tx, C_NET_TX),
s("Deduped", stats.dedup_hits, C_DEDUP),
s("Rate lim", stats.rate_limit_drops, C_RATE_DROP),
s("Q drop", stats.dropped_queue, C_QUEUE_DROP),
];
frame.render_widget(Paragraph::new(lines).block(block), area);
}
// ── Packet log ───────────────────────────────────────────────────
fn draw_log(frame: &mut ratatui::Frame, area: Rect, entries: &[PacketLogEntry]) {
let block = panel_block(" Packet Log ");
let inner_height = area.height.saturating_sub(2) as usize;
let data_height = inner_height.saturating_sub(1); // header row
let start = entries.len().saturating_sub(data_height);
let visible = &entries[start..];
// Fixed column widths: Age=5 Hash=8 RF=4 Net=4 Size=6 RSSI=6 SNR=5 Act=5
// Every span (header and data) uses the same width per column.
let hdr = Style::default().fg(Color::Gray).add_modifier(Modifier::BOLD);
let header = Line::from(vec![
Span::styled(format!("{:>5}", "Age"), hdr),
Span::styled(format!("{:>8}", "Hash"), hdr),
Span::styled(format!("{:>4}", "RF"), hdr),
Span::styled(format!("{:>4}", "Net"), hdr),
Span::styled(format!("{:>6}", "Size"), hdr),
Span::styled(format!("{:>6}", "RSSI"), hdr),
Span::styled(format!("{:>5}", "SNR"), hdr),
Span::styled(format!("{:>5}", "Act"), hdr),
]);
let mut lines = vec![header];
let mut prev_hash: Option<[u8; 32]> = None;
for e in visible {
let age = format_short_duration(e.timestamp.elapsed());
let bridged = matches!(e.action, PacketAction::Bridged);
// Show short hash, or ellipsis if same as previous line.
let hash_span = if prev_hash == Some(e.hash) {
Span::styled(format!("{:>8}", "··"), Style::default().fg(C_BORDER))
} else {
Span::styled(format!("{:>8}", hex::encode(&e.hash[..3])), Style::default().fg(C_LABEL))
};
prev_hash = Some(e.hash);
// Each row shows both columns. Bridged packets light up both sides.
// Dropped packets only show the input side.
let dash = Span::styled(format!("{:>4}", "──"), Style::default().fg(C_BORDER));
let (rf_col, net_col) = match e.direction {
PacketDirection::RadioIn => (
Span::styled(format!("{:>4}", "RX"), Style::default().fg(C_RADIO_RX)),
if bridged {
Span::styled(format!("{:>4}", "TX"), Style::default().fg(C_NET_TX))
} else {
dash
},
),
PacketDirection::GossipIn => (
if bridged {
Span::styled(format!("{:>4}", "TX"), Style::default().fg(C_RADIO_TX))
} else {
dash
},
Span::styled(format!("{:>4}", "RX"), Style::default().fg(C_NET_RX)),
),
};
let action_color = match e.action {
PacketAction::Bridged => C_LABEL,
PacketAction::DroppedDedup => C_DEDUP,
PacketAction::DroppedQueueFull => C_QUEUE_DROP,
PacketAction::DroppedRateLimit => C_RATE_DROP,
};
let rssi_span = match e.rssi {
Some(r) => Span::styled(format!("{r:>6}"), Style::default().fg(rssi_color(r))),
None => Span::styled(format!("{:>6}", "-"), Style::default().fg(C_LABEL)),
};
let snr_span = match e.snr {
Some(s) => Span::styled(format!("{s:>5}"), Style::default().fg(snr_color(s))),
None => Span::styled(format!("{:>5}", "-"), Style::default().fg(C_LABEL)),
};
let action_str = match e.action {
PacketAction::Bridged => "",
PacketAction::DroppedDedup => "DUP",
PacketAction::DroppedQueueFull => "FULL",
PacketAction::DroppedRateLimit => "RATE",
};
let action_span = Span::styled(format!(" {:>4}", action_str), Style::default().fg(action_color));
let size_str = format!("{}B", e.size);
lines.push(Line::from(vec![
Span::styled(format!("{:>5}", age), Style::default().fg(C_LABEL)),
hash_span,
rf_col,
net_col,
Span::styled(format!("{:>6}", size_str), Style::default().fg(C_VALUE)),
rssi_span,
snr_span,
action_span,
]));
}
frame.render_widget(Paragraph::new(lines).block(block), area);
}
// ── Activity chart ───────────────────────────────────────────────
fn draw_activity(frame: &mut ratatui::Frame, area: Rect, series: &TimeSeries) {
let block = panel_block(" Activity (1s) ");
let inner = block.inner(area);
frame.render_widget(block, area);
if inner.height < 6 || inner.width < 6 {
return;
}
let rows = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Ratio(1, 3),
Constraint::Ratio(1, 3),
Constraint::Ratio(1, 3),
])
.split(inner);
let spark_w = inner.width as usize;
let rx_data = pad_right_align(&series.radio_rx(), spark_w);
let tx_data = pad_right_align(&series.radio_tx(), spark_w);
let snr_data = pad_right_align(&series.avg_snr(), spark_w);
let peak_rx = series.peak_rx();
let peak_tx = series.peak_tx();
let snr_label = series
.latest_avg_snr()
.map(|s| format!("{s}dB"))
.unwrap_or_else(|| "-".into());
draw_sparkline_section(frame, rows[0], "RX", &format!("pk:{peak_rx}"), &rx_data, C_RADIO_RX);
draw_sparkline_section(frame, rows[1], "TX", &format!("pk:{peak_tx}"), &tx_data, C_RADIO_TX);
draw_sparkline_section(frame, rows[2], "SNR", &snr_label, &snr_data, C_SNR);
}
/// Draw a label line on top, sparkline filling the rest below.
fn draw_sparkline_section(
frame: &mut ratatui::Frame,
area: Rect,
label: &str,
detail: &str,
data: &[u64],
color: Color,
) {
if area.height < 2 || area.width < 4 {
return;
}
let rows = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Length(1), Constraint::Min(1)])
.split(area);
let label_line = Line::from(vec![
Span::styled(format!(" {label} "), Style::default().fg(color).add_modifier(Modifier::BOLD)),
Span::styled(detail.to_string(), Style::default().fg(C_LABEL)),
]);
frame.render_widget(Paragraph::new(label_line), rows[0]);
let spark = Sparkline::default()
.data(data)
.bar_set(symbols::bar::NINE_LEVELS)
.style(Style::default().fg(color));
frame.render_widget(spark, rows[1]);
}
/// Pad data with leading zeros so values are right-aligned in `width` columns.
fn pad_right_align(data: &[u64], width: usize) -> Vec<u64> {
let tail_start = data.len().saturating_sub(width);
let tail = &data[tail_start..];
let mut padded = vec![0u64; width.saturating_sub(tail.len())];
padded.extend_from_slice(tail);
padded
}
// ── Shutdown overlay ─────────────────────────────────────────────
fn draw_shutdown_overlay(frame: &mut ratatui::Frame, area: Rect) {
let dim = Paragraph::new("").style(Style::default().bg(Color::Black));
frame.render_widget(dim, area);
let w = 36u16;
let h = 3u16;
let x = area.x + area.width.saturating_sub(w) / 2;
let y = area.y + area.height.saturating_sub(h) / 2;
let popup = Rect::new(x, y, w.min(area.width), h.min(area.height));
frame.render_widget(Clear, popup);
let block = Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(C_BORDER));
let text = Paragraph::new(Line::from(vec![
Span::styled(" Shutting down", Style::default().fg(C_RATE_DROP)),
Span::styled(" — updating DHT...", Style::default().fg(C_LABEL)),
]))
.alignment(Alignment::Center)
.block(block);
frame.render_widget(text, popup);
}
// ── Shared helpers ───────────────────────────────────────────────
fn panel_block(title: &str) -> Block<'static> {
Block::default()
.title(Span::styled(
title.to_string(),
Style::default().fg(C_TITLE).add_modifier(Modifier::BOLD),
))
.borders(Borders::ALL)
.border_style(Style::default().fg(C_BORDER))
}
fn rssi_color(rssi: i16) -> Color {
if rssi >= -70 { Color::Green } else if rssi >= -90 { C_RATE_DROP } else { C_QUEUE_DROP }
}
fn snr_color(snr: i16) -> Color {
if snr >= 5 { Color::Green } else if snr >= 0 { C_RATE_DROP } else { C_QUEUE_DROP }
}
fn kv_line(label: &str, value: &str, color: Color) -> Line<'static> {
Line::from(vec![
Span::raw(" "),
Span::styled(format!("{label:<10}"), Style::default().fg(C_LABEL)),
Span::styled(value.to_string(), Style::default().fg(color)),
])
}
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 {
jiff::Zoned::now().strftime("%H:%M:%S %Z").to_string()
}