Archived
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6fd58bc2fb
|
||
|
|
80f225be41
|
||
|
|
bbe0239ab9
|
||
|
|
06ed2fa449
|
||
|
|
521707a3c2
|
||
|
|
943f2615f3
|
||
|
|
ae284d571a
|
||
|
|
ca26368136
|
||
|
|
6bae65c464
|
No files matched your search
@@ -1 +0,0 @@
|
|||||||
/target
|
|
||||||
Generated
-4940
File diff suppressed because it is too large.
Load diff
-37
@@ -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"
|
|
||||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -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
@@ -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
@@ -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
@@ -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()
|
|
||||||
}
|
|
||||||
Reference in new issue
Block a user