feat!: bridge on DongLoRa Protocol v2 and async client
The 1.0 bridge running on the async Dongle API from donglora-client 1.0.0 and the Protocol v2 wire format. * Radio loop is now a tokio task, not a dedicated std::thread. Everything is async end to end. * [radio] config schema: tx_power renamed to tx_power_dbm (plain i8, no "max" keyword), cad field removed (CAD is a per-TX flag in v1.0), default sync_word changed 0x3444 to 0x1424 to match the SX126x register encoding of MeshCore private sync byte. * TX retry with backoff (new [tx] config section). Every TX runs through Dongle::tx_with_retry, retrying TX_DONE(CHANNEL_BUSY) and ERR(EBUSY) per PROTOCOL.md §6.10 / §C.5.5. Defaults: 3 attempts, 20-100 ms randomized backoff, doubling to 500 ms cap, 5 s per-attempt timeout. Configurable via max_attempts, backoff_min_ms, backoff_max_ms, backoff_cap_ms, backoff_multiplier, per_attempt_timeout_secs. * TUI packet log shows retry state: in-flight retries render with a cycle marker in yellow and an N/M attempt counter, terminal TX failures render in red, post-retry success shows final attempt count. * Stats gain tx_retries and tx_failures counters surfaced in StatsSnapshot. * Measured airtime from TX_DONE replaces the theoretical estimate, feeding the rate limiter with the real on-air duration. * The explicit 2 s liveness ping loop is gone. The Dongle runs a 500 ms internal keepalive, matching spec §3.4 inactivity window with 2x margin.
This commit is contained in:
11 files changed
+1010
-669
No files matched your search
+294
-295
@@ -1,13 +1,24 @@
|
||||
//! `DongLoRa` hardware communication on a blocking thread.
|
||||
//! Async radio task — drives the `donglora-client` [`Dongle`] session.
|
||||
//!
|
||||
//! Handles connection, config negotiation, RX/TX, liveness pings, and
|
||||
//! exponential backoff reconnection.
|
||||
//! Owns a single tokio task that connects (with exponential backoff reconnect),
|
||||
//! negotiates config, starts RX, and multiplexes TX requests from the router
|
||||
//! against the RX stream. TX is dispatched via [`Dongle::tx_with_retry`] so
|
||||
//! `TX_DONE(CHANNEL_BUSY)` / `EBUSY` errors are retried per spec §6.10 with
|
||||
//! randomized backoff, and per-attempt state is surfaced to the router (and
|
||||
//! onwards to the TUI packet log) via the [`RadioEvent::Tx*`] variants.
|
||||
//!
|
||||
//! Keepalive pings are handled inside the [`Dongle`] itself — no explicit
|
||||
//! liveness loop here.
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use donglora_client::{AnyTransport, RadioConfig, Response, Transport};
|
||||
use donglora_client::{
|
||||
ConnectOptions, Dongle, LoRaBandwidth, LoRaConfig, Modulation, RetryPolicy, RxPayload, connect_mux_auto_with,
|
||||
connect_with,
|
||||
};
|
||||
use tokio::sync::mpsc;
|
||||
use tokio::task::JoinHandle;
|
||||
use tracing::{debug, error, info, warn};
|
||||
|
||||
use crate::packet::{RadioPacket, snr_grade};
|
||||
@@ -21,253 +32,295 @@ pub enum ConfigSource {
|
||||
Mux,
|
||||
}
|
||||
|
||||
/// Messages from the radio thread to the router.
|
||||
/// Messages from the radio task to the router.
|
||||
#[derive(Debug)]
|
||||
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),
|
||||
Connected(LoRaConfig, ConfigSource, String),
|
||||
/// Radio disconnected — attempting reconnect.
|
||||
Disconnected,
|
||||
/// TX attempt started. `attempt` is 1-indexed.
|
||||
TxAttempt { seq: u64, attempt: u8, total_attempts: u8, size: usize },
|
||||
/// Previous TX attempt failed and a retry is scheduled.
|
||||
TxRetry { seq: u64, attempt_that_failed: u8, total_attempts: u8, reason: TxRetryReason, backoff_ms: u32 },
|
||||
/// TX completed successfully (perhaps after retries).
|
||||
TxSucceeded { seq: u64, attempts_used: u8, final_airtime_us: u32, size: usize },
|
||||
/// TX policy exhausted or errored terminally.
|
||||
TxFailed { seq: u64, attempts_used: u8, reason: String, size: usize },
|
||||
}
|
||||
|
||||
/// Spawn the blocking radio thread.
|
||||
/// Why a TX attempt was retried — carried alongside the retry event for
|
||||
/// the TUI.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum TxRetryReason {
|
||||
/// CAD detected channel activity before TX.
|
||||
ChannelBusy,
|
||||
/// Firmware TX queue was full.
|
||||
QueueFull,
|
||||
/// Other retryable condition (future-compat).
|
||||
Other,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for TxRetryReason {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
Self::ChannelBusy => write!(f, "channel_busy"),
|
||||
Self::QueueFull => write!(f, "queue_full"),
|
||||
Self::Other => write!(f, "other"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One outbound TX request from the router: the payload plus a sequence id so
|
||||
/// the event stream can correlate back to the packet log entry.
|
||||
#[derive(Debug)]
|
||||
pub struct TxRequest {
|
||||
pub seq: u64,
|
||||
pub data: Vec<u8>,
|
||||
}
|
||||
|
||||
/// Spawn the async radio task.
|
||||
///
|
||||
/// Returns a receiver for radio events and a sender for TX payloads.
|
||||
pub fn spawn(config: RadioConfig, port: Option<String>) -> Result<(mpsc::Receiver<RadioEvent>, mpsc::Sender<Vec<u8>>)> {
|
||||
/// Returns a receiver for radio events and a sender for [`TxRequest`]s.
|
||||
pub fn spawn(
|
||||
config: LoRaConfig,
|
||||
port: Option<String>,
|
||||
retry_policy: RetryPolicy,
|
||||
) -> Result<(mpsc::Receiver<RadioEvent>, mpsc::Sender<TxRequest>, JoinHandle<()>)> {
|
||||
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))
|
||||
.context("failed to spawn radio thread")?;
|
||||
|
||||
Ok((event_rx, tx_send))
|
||||
let (tx_send, tx_recv) = mpsc::channel::<TxRequest>(64);
|
||||
let handle = tokio::spawn(radio_loop(config, port, retry_policy, event_tx, tx_recv));
|
||||
Ok((event_rx, tx_send, handle))
|
||||
}
|
||||
|
||||
#[allow(clippy::needless_pass_by_value)] // values moved across thread boundary in spawn()
|
||||
fn radio_loop(
|
||||
config: RadioConfig,
|
||||
async fn radio_loop(
|
||||
config: LoRaConfig,
|
||||
port: Option<String>,
|
||||
retry_policy: RetryPolicy,
|
||||
event_tx: mpsc::Sender<RadioEvent>,
|
||||
mut tx_recv: mpsc::Receiver<Vec<u8>>,
|
||||
mut tx_recv: mpsc::Receiver<TxRequest>,
|
||||
) {
|
||||
let initial_backoff = Duration::from_millis(250);
|
||||
let max_backoff = Duration::from_secs(5);
|
||||
let mut backoff = initial_backoff;
|
||||
let mut use_mux_only = false;
|
||||
let mut stick_to_mux = false;
|
||||
|
||||
loop {
|
||||
let started = std::time::Instant::now();
|
||||
match connect_and_run(&config, port.as_deref(), use_mux_only, &event_tx, &mut tx_recv) {
|
||||
Ok(_was_mux) => {
|
||||
info!("radio thread exiting");
|
||||
return;
|
||||
match connect_and_run(&config, port.as_deref(), stick_to_mux, &retry_policy, &event_tx, &mut tx_recv).await {
|
||||
Ok(was_mux) => {
|
||||
if event_tx.is_closed() {
|
||||
info!("radio task exiting (router closed)");
|
||||
return;
|
||||
}
|
||||
// We had a clean run_session exit but not shutdown — loop back.
|
||||
if was_mux {
|
||||
stick_to_mux = true;
|
||||
}
|
||||
debug!("radio session exited cleanly; reconnecting");
|
||||
}
|
||||
Err((was_mux, e)) => {
|
||||
// Session ended with error. Remember transport type for reconnect.
|
||||
if was_mux {
|
||||
use_mux_only = true;
|
||||
stick_to_mux = true;
|
||||
}
|
||||
error!("radio error: {e:#}");
|
||||
let _ = event_tx.blocking_send(RadioEvent::Disconnected);
|
||||
let _ = event_tx.send(RadioEvent::Disconnected).await;
|
||||
|
||||
// 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);
|
||||
tokio::time::sleep(backoff).await;
|
||||
backoff = (backoff * 2).min(max_backoff);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Connect to the dongle and run the radio loop.
|
||||
///
|
||||
/// Returns `Ok(was_mux)` on clean exit, `Err((was_mux, error))` on failure.
|
||||
/// The `was_mux` flag lets the caller know which transport was used so it can
|
||||
/// stick to the same transport type on reconnect.
|
||||
fn connect_and_run(
|
||||
config: &RadioConfig,
|
||||
async fn connect_and_run(
|
||||
config: &LoRaConfig,
|
||||
port: Option<&str>,
|
||||
use_mux_only: bool,
|
||||
stick_to_mux: bool,
|
||||
retry_policy: &RetryPolicy,
|
||||
event_tx: &mpsc::Sender<RadioEvent>,
|
||||
tx_recv: &mut mpsc::Receiver<Vec<u8>>,
|
||||
tx_recv: &mut mpsc::Receiver<TxRequest>,
|
||||
) -> std::result::Result<bool, (bool, anyhow::Error)> {
|
||||
// Long initial timeout: the mux serializes commands through the dongle,
|
||||
// so our `GetConfig` might have to wait behind other clients' commands.
|
||||
let timeout = Duration::from_secs(10);
|
||||
|
||||
info!("connecting to dongle...");
|
||||
let mut client = if use_mux_only {
|
||||
debug!("reconnecting via mux only (previously connected via mux)");
|
||||
donglora_client::connect_mux_auto(timeout).map_err(|e| (true, e))?
|
||||
} else {
|
||||
donglora_client::connect(port, timeout).map_err(|e| (false, e))?
|
||||
};
|
||||
let is_mux = matches!(client.transport(), AnyTransport::Mux(_));
|
||||
let device = if is_mux { "mux".to_string() } else { port.map_or_else(|| "usb".to_string(), shorten_path) };
|
||||
info!("[radio] connected and validated: {device}");
|
||||
|
||||
// Run the session. Map Result<()> to include the is_mux flag.
|
||||
run_session(&mut client, config, is_mux, &device, event_tx, tx_recv).map(|()| is_mux).map_err(|e| (is_mux, e))
|
||||
}
|
||||
|
||||
fn run_session(
|
||||
client: &mut donglora_client::Client<AnyTransport>,
|
||||
config: &RadioConfig,
|
||||
is_mux: bool,
|
||||
device: &str,
|
||||
event_tx: &mpsc::Sender<RadioEvent>,
|
||||
tx_recv: &mut mpsc::Receiver<Vec<u8>>,
|
||||
) -> Result<()> {
|
||||
info!("[radio] negotiating config...");
|
||||
let (active_config, config_source) = match negotiate_config(client, config, is_mux) {
|
||||
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 mut opts = ConnectOptions::new().config(Modulation::LoRa(*config));
|
||||
if let Some(p) = port {
|
||||
opts = opts.port(p);
|
||||
}
|
||||
|
||||
let _ = event_tx.blocking_send(RadioEvent::Connected(active_config, config_source, device.to_string()));
|
||||
// Attempt mux-only if we've committed to it, otherwise the normal
|
||||
// auto-discovery chain.
|
||||
let dongle = if stick_to_mux {
|
||||
debug!("reconnecting via mux only (previously connected via mux)");
|
||||
connect_mux_auto_with(opts).await.map_err(|e| (true, anyhow::Error::from(e)))?
|
||||
} else {
|
||||
connect_with(opts).await.map_err(|e| (false, anyhow::Error::from(e)))?
|
||||
};
|
||||
|
||||
// Ask the Dongle which transport it actually ended up on. That's
|
||||
// authoritative — don't infer from firmware capability bits.
|
||||
let kind = dongle.transport_kind().clone();
|
||||
let is_mux = kind.is_mux();
|
||||
let device = kind.short_label();
|
||||
info!("[radio] connected: {device}");
|
||||
|
||||
let outcome = run_session(dongle, config, is_mux, &device, retry_policy, event_tx, tx_recv).await;
|
||||
outcome.map(|()| is_mux).map_err(|e| (is_mux, e))
|
||||
}
|
||||
|
||||
async fn run_session(
|
||||
dongle: Dongle,
|
||||
config: &LoRaConfig,
|
||||
is_mux: bool,
|
||||
device: &str,
|
||||
retry_policy: &RetryPolicy,
|
||||
event_tx: &mpsc::Sender<RadioEvent>,
|
||||
tx_recv: &mut mpsc::Receiver<TxRequest>,
|
||||
) -> Result<()> {
|
||||
// The Dongle's auto-configure already applied our requested config at
|
||||
// connect time (via `ConnectOptions::config`). Read back the active
|
||||
// config — on the mux, another client may have locked a different one.
|
||||
let active_config = match dongle.config().await {
|
||||
Some(Modulation::LoRa(c)) => c,
|
||||
Some(other) => {
|
||||
warn!("[radio] mux has a non-LoRa config locked ({other:?}); using it anyway");
|
||||
// This branch is unreachable today since the bridge only supports
|
||||
// LoRa, but we surface a warning rather than hard-fail so the
|
||||
// mux doesn't brick the bridge.
|
||||
*config
|
||||
}
|
||||
None => {
|
||||
warn!("[radio] Dongle::config() returned None after connect; falling back to requested");
|
||||
*config
|
||||
}
|
||||
};
|
||||
let source = if lora_configs_match(&active_config, config) { ConfigSource::Ours } else { ConfigSource::Mux };
|
||||
info!("[radio] config: source={source:?}, {}", format_lora_config(&active_config));
|
||||
|
||||
dongle.rx_start().await.context("rx_start failed")?;
|
||||
if event_tx.send(RadioEvent::Connected(active_config, source, device.to_string())).await.is_err() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// 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();
|
||||
|
||||
// TX requests from the router and RX packets from the radio both drive
|
||||
// the loop; use tokio::select! to multiplex them.
|
||||
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);
|
||||
tokio::select! {
|
||||
tx = tx_recv.recv() => {
|
||||
let Some(req) = tx else { return Ok(()); };
|
||||
handle_tx(&dongle, req, retry_policy, event_tx).await;
|
||||
}
|
||||
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() {
|
||||
rx = dongle.next_rx() => {
|
||||
match rx {
|
||||
Some(pkt) => {
|
||||
handle_rx(pkt, &active_config, event_tx).await;
|
||||
}
|
||||
None => {
|
||||
// Dongle's RX stream ended — session closed.
|
||||
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(());
|
||||
}
|
||||
|
||||
// Pull any async errors the Dongle accumulated (EFRAME / async
|
||||
// ERADIO). These get logged but don't kill the session.
|
||||
for err in dongle.drain_async_errors().await {
|
||||
warn!("[radio] async error: {err}");
|
||||
}
|
||||
|
||||
// Intentionally unused so callers don't think is_mux matters here.
|
||||
let _ = is_mux;
|
||||
}
|
||||
}
|
||||
|
||||
/// Negotiate radio config with the dongle/mux.
|
||||
///
|
||||
/// Strategy:
|
||||
/// 1. `GetConfig` first to read what's active.
|
||||
/// 2. If it matches our desired config, use it — no `SetConfig` needed.
|
||||
/// 3. If it differs and we're on the mux, accept the mux's config.
|
||||
/// 4. If it differs and we're on direct serial, `SetConfig` to push ours.
|
||||
/// 5. If `GetConfig` fails, try `SetConfig` with our desired config.
|
||||
fn negotiate_config<T: Transport>(
|
||||
client: &mut donglora_client::Client<T>,
|
||||
desired: &RadioConfig,
|
||||
is_mux: bool,
|
||||
) -> Result<(RadioConfig, ConfigSource)> {
|
||||
info!("[negotiate] get_config...");
|
||||
match client.get_config() {
|
||||
Ok(cfg) => {
|
||||
if configs_match(&cfg, desired) {
|
||||
info!("[negotiate] get_config OK (matches desired): {}", format_radio_config(&cfg));
|
||||
Ok((cfg, ConfigSource::Ours))
|
||||
} else if is_mux {
|
||||
info!("[negotiate] get_config OK (mux config differs, accepting): {}", format_radio_config(&cfg));
|
||||
Ok((cfg, ConfigSource::Mux))
|
||||
} else {
|
||||
info!("[negotiate] get_config OK (serial config differs, setting ours)");
|
||||
client.set_config(*desired)?;
|
||||
info!("[negotiate] set_config OK");
|
||||
Ok((*desired, ConfigSource::Ours))
|
||||
async fn handle_tx(dongle: &Dongle, req: TxRequest, retry_policy: &RetryPolicy, event_tx: &mpsc::Sender<RadioEvent>) {
|
||||
let size = req.data.len();
|
||||
let _ = event_tx
|
||||
.send(RadioEvent::TxAttempt { seq: req.seq, attempt: 1, total_attempts: retry_policy.max_attempts, size })
|
||||
.await;
|
||||
|
||||
let result = dongle.tx_with_retry(&req.data, retry_policy).await;
|
||||
match result {
|
||||
Ok(outcome) => {
|
||||
// Fire a TxRetry event for every attempt beyond the first, so the
|
||||
// TUI log can show the escalation.
|
||||
let mut prev_attempt: u8 = 0;
|
||||
for att in &outcome.attempts {
|
||||
if prev_attempt != 0 {
|
||||
let reason = match &outcome.attempts[(prev_attempt - 1) as usize].result {
|
||||
Err(donglora_client::ClientError::ChannelBusy) => TxRetryReason::ChannelBusy,
|
||||
Err(donglora_client::ClientError::Busy) => TxRetryReason::QueueFull,
|
||||
_ => TxRetryReason::Other,
|
||||
};
|
||||
let _ = event_tx
|
||||
.send(RadioEvent::TxRetry {
|
||||
seq: req.seq,
|
||||
attempt_that_failed: prev_attempt,
|
||||
total_attempts: retry_policy.max_attempts,
|
||||
reason,
|
||||
backoff_ms: retry_policy.backoff_ms_min,
|
||||
})
|
||||
.await;
|
||||
}
|
||||
prev_attempt = att.attempt;
|
||||
}
|
||||
let _ = event_tx
|
||||
.send(RadioEvent::TxSucceeded {
|
||||
seq: req.seq,
|
||||
attempts_used: outcome.attempts_used(),
|
||||
final_airtime_us: outcome.final_airtime_us,
|
||||
size,
|
||||
})
|
||||
.await;
|
||||
}
|
||||
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:#})");
|
||||
}
|
||||
}
|
||||
warn!("TX #{} failed after retries: {e:#}", req.seq);
|
||||
let _ = event_tx
|
||||
.send(RadioEvent::TxFailed {
|
||||
seq: req.seq,
|
||||
attempts_used: retry_policy.max_attempts,
|
||||
reason: format!("{e}"),
|
||||
size,
|
||||
})
|
||||
.await;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Shorten a device path to just the filename for display.
|
||||
fn shorten_path(path: &str) -> String {
|
||||
std::path::Path::new(path).file_name().map_or_else(|| path.to_string(), |f| f.to_string_lossy().into_owned())
|
||||
async fn handle_rx(pkt: RxPayload, active_config: &LoRaConfig, event_tx: &mpsc::Sender<RadioEvent>) {
|
||||
// Tenths-of-dB → whole dB for the existing bridge packet format.
|
||||
// `rssi_tenths_dbm` and `snr_tenths_db` are i16 and the values stay
|
||||
// within i16 after the /10 rounding; the `as i16` is safe.
|
||||
#[allow(clippy::cast_possible_truncation)]
|
||||
let rssi = (f32::from(pkt.rssi_tenths_dbm) / 10.0).round() as i16;
|
||||
#[allow(clippy::cast_possible_truncation)]
|
||||
let snr = (f32::from(pkt.snr_tenths_db) / 10.0).round() as i16;
|
||||
|
||||
let grade = snr_grade(snr, active_config.sf);
|
||||
if !grade.should_forward() {
|
||||
debug!("RX drop {} bytes rssi={rssi} snr={snr} grade={grade}", pkt.data.len());
|
||||
return;
|
||||
}
|
||||
debug!("RX {} bytes rssi={rssi} snr={snr} grade={grade}", pkt.data.len());
|
||||
let radio_pkt = RadioPacket { rssi, snr, payload: pkt.data.to_vec() };
|
||||
let _ = event_tx.send(RadioEvent::Packet(radio_pkt)).await;
|
||||
}
|
||||
|
||||
fn configs_match(a: &RadioConfig, b: &RadioConfig) -> bool {
|
||||
// ── Display helpers ───────────────────────────────────────────────
|
||||
|
||||
fn lora_configs_match(a: &LoRaConfig, b: &LoRaConfig) -> bool {
|
||||
a.freq_hz == b.freq_hz
|
||||
&& a.bw == b.bw
|
||||
&& a.sf == b.sf
|
||||
@@ -275,46 +328,47 @@ fn configs_match(a: &RadioConfig, b: &RadioConfig) -> bool {
|
||||
&& 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 {
|
||||
e.downcast_ref::<std::io::Error>()
|
||||
.is_some_and(|io_err| matches!(io_err.kind(), std::io::ErrorKind::WouldBlock | std::io::ErrorKind::TimedOut))
|
||||
&& a.header_mode == b.header_mode
|
||||
&& a.payload_crc == b.payload_crc
|
||||
&& a.iq_invert == b.iq_invert
|
||||
}
|
||||
|
||||
/// Format a bandwidth value for display.
|
||||
#[must_use]
|
||||
pub const fn format_bandwidth(bw: donglora_client::Bandwidth) -> &'static str {
|
||||
pub const fn format_bandwidth(bw: LoRaBandwidth) -> &'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",
|
||||
LoRaBandwidth::Khz7 => "7.8 kHz",
|
||||
LoRaBandwidth::Khz10 => "10.4 kHz",
|
||||
LoRaBandwidth::Khz15 => "15.6 kHz",
|
||||
LoRaBandwidth::Khz20 => "20.8 kHz",
|
||||
LoRaBandwidth::Khz31 => "31.25 kHz",
|
||||
LoRaBandwidth::Khz41 => "41.7 kHz",
|
||||
LoRaBandwidth::Khz62 => "62.5 kHz",
|
||||
LoRaBandwidth::Khz125 => "125 kHz",
|
||||
LoRaBandwidth::Khz250 => "250 kHz",
|
||||
LoRaBandwidth::Khz500 => "500 kHz",
|
||||
LoRaBandwidth::Khz200 => "200 kHz",
|
||||
LoRaBandwidth::Khz400 => "400 kHz",
|
||||
LoRaBandwidth::Khz800 => "800 kHz",
|
||||
LoRaBandwidth::Khz1600 => "1600 kHz",
|
||||
}
|
||||
}
|
||||
|
||||
/// Format a `RadioConfig` for display.
|
||||
/// Format a `LoRaConfig` for display.
|
||||
#[must_use]
|
||||
pub fn format_radio_config(config: &RadioConfig) -> String {
|
||||
pub fn format_lora_config(config: &LoRaConfig) -> String {
|
||||
let freq_mhz = f64::from(config.freq_hz) / 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 cr_denom: u8 = match config.cr {
|
||||
donglora_client::LoRaCodingRate::Cr4_5 => 5,
|
||||
donglora_client::LoRaCodingRate::Cr4_6 => 6,
|
||||
donglora_client::LoRaCodingRate::Cr4_7 => 7,
|
||||
donglora_client::LoRaCodingRate::Cr4_8 => 8,
|
||||
};
|
||||
let preamble = if config.preamble_len == 0 { 16 } else { config.preamble_len };
|
||||
let cad = if config.cad != 0 { "on" } else { "off" };
|
||||
let preamble = config.preamble_len;
|
||||
format!(
|
||||
"{freq_mhz:.3}MHz SF{} BW{bw} CR4/{} SW0x{:04X} TX:{power} Pre:{preamble} CAD:{cad}",
|
||||
config.sf, config.cr, config.sync_word
|
||||
"{freq_mhz:.3}MHz SF{} BW{bw} CR4/{cr_denom} SW0x{:04X} TX:{} dBm Pre:{preamble}",
|
||||
config.sf, config.sync_word, config.tx_power_dbm,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -322,134 +376,79 @@ pub fn format_radio_config(config: &RadioConfig) -> String {
|
||||
#[allow(clippy::unwrap_used, clippy::expect_used)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use donglora_client::{LoRaCodingRate, LoRaHeaderMode};
|
||||
|
||||
fn default_config() -> RadioConfig {
|
||||
RadioConfig {
|
||||
fn default_config() -> LoRaConfig {
|
||||
LoRaConfig {
|
||||
freq_hz: 910_525_000,
|
||||
bw: donglora_client::Bandwidth::Khz62,
|
||||
bw: LoRaBandwidth::Khz62,
|
||||
sf: 7,
|
||||
cr: 5,
|
||||
sync_word: 0x3444,
|
||||
tx_power_dbm: 22,
|
||||
cr: LoRaCodingRate::Cr4_5,
|
||||
sync_word: 0x1424,
|
||||
tx_power_dbm: 20,
|
||||
preamble_len: 16,
|
||||
cad: 1,
|
||||
header_mode: LoRaHeaderMode::Explicit,
|
||||
payload_crc: true,
|
||||
iq_invert: false,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn configs_match_identical() {
|
||||
let a = default_config();
|
||||
let b = default_config();
|
||||
assert!(configs_match(&a, &b));
|
||||
fn lora_configs_match_identical() {
|
||||
assert!(lora_configs_match(&default_config(), &default_config()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn configs_match_differs_each_field() {
|
||||
fn lora_configs_match_differs_each_field() {
|
||||
let base = default_config();
|
||||
|
||||
let mut c = base;
|
||||
c.freq_hz = 915_000_000;
|
||||
assert!(!configs_match(&base, &c));
|
||||
assert!(!lora_configs_match(&base, &c));
|
||||
|
||||
let mut c = base;
|
||||
c.sf = 12;
|
||||
assert!(!configs_match(&base, &c));
|
||||
assert!(!lora_configs_match(&base, &c));
|
||||
|
||||
let mut c = base;
|
||||
c.cr = 8;
|
||||
assert!(!configs_match(&base, &c));
|
||||
c.cr = LoRaCodingRate::Cr4_8;
|
||||
assert!(!lora_configs_match(&base, &c));
|
||||
|
||||
let mut c = base;
|
||||
c.sync_word = 0x1234;
|
||||
assert!(!configs_match(&base, &c));
|
||||
assert!(!lora_configs_match(&base, &c));
|
||||
|
||||
let mut c = base;
|
||||
c.tx_power_dbm = 10;
|
||||
assert!(!configs_match(&base, &c));
|
||||
assert!(!lora_configs_match(&base, &c));
|
||||
|
||||
let mut c = base;
|
||||
c.preamble_len = 8;
|
||||
assert!(!configs_match(&base, &c));
|
||||
|
||||
let mut c = base;
|
||||
c.cad = 0;
|
||||
assert!(!configs_match(&base, &c));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_timeout_would_block() {
|
||||
let e = anyhow::Error::from(std::io::Error::new(std::io::ErrorKind::WouldBlock, "test"));
|
||||
assert!(is_timeout_error(&e));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_timeout_timed_out() {
|
||||
let e = anyhow::Error::from(std::io::Error::new(std::io::ErrorKind::TimedOut, "test"));
|
||||
assert!(is_timeout_error(&e));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_timeout_false_for_other() {
|
||||
let e = anyhow::Error::from(std::io::Error::new(std::io::ErrorKind::ConnectionReset, "test"));
|
||||
assert!(!is_timeout_error(&e));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shorten_path_extracts_filename() {
|
||||
assert_eq!(shorten_path("/dev/ttyACM0"), "ttyACM0");
|
||||
assert_eq!(shorten_path("ttyACM0"), "ttyACM0");
|
||||
assert!(!lora_configs_match(&base, &c));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn format_bandwidth_all_variants() {
|
||||
assert_eq!(format_bandwidth(donglora_client::Bandwidth::Khz7), "7.8 kHz");
|
||||
assert_eq!(format_bandwidth(donglora_client::Bandwidth::Khz125), "125 kHz");
|
||||
assert_eq!(format_bandwidth(donglora_client::Bandwidth::Khz500), "500 kHz");
|
||||
assert_eq!(format_bandwidth(LoRaBandwidth::Khz7), "7.8 kHz");
|
||||
assert_eq!(format_bandwidth(LoRaBandwidth::Khz125), "125 kHz");
|
||||
assert_eq!(format_bandwidth(LoRaBandwidth::Khz500), "500 kHz");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn format_radio_config_typical() {
|
||||
fn format_lora_config_typical() {
|
||||
let cfg = default_config();
|
||||
let s = format_radio_config(&cfg);
|
||||
let s = format_lora_config(&cfg);
|
||||
assert!(s.contains("910.525MHz"));
|
||||
assert!(s.contains("SF7"));
|
||||
assert!(s.contains("CR4/5"));
|
||||
assert!(s.contains("0x3444"));
|
||||
assert!(s.contains("CAD:on"), "cad=1 should show CAD:on: {s}");
|
||||
assert!(s.contains("0x1424"));
|
||||
assert!(s.contains("20 dBm"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn format_radio_config_cad_off() {
|
||||
let mut cfg = default_config();
|
||||
cfg.cad = 0;
|
||||
let s = format_radio_config(&cfg);
|
||||
assert!(s.contains("CAD:off"), "cad=0 should show CAD:off: {s}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn format_radio_config_max_power() {
|
||||
let mut cfg = default_config();
|
||||
cfg.tx_power_dbm = donglora_client::TX_POWER_MAX;
|
||||
let s = format_radio_config(&cfg);
|
||||
assert!(s.contains("TX:max"));
|
||||
assert!(!s.contains("dBm"), "max power should not contain 'dBm': {s}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn format_radio_config_non_max_power() {
|
||||
let mut cfg = default_config();
|
||||
cfg.tx_power_dbm = 22;
|
||||
assert_ne!(cfg.tx_power_dbm, donglora_client::TX_POWER_MAX);
|
||||
let s = format_radio_config(&cfg);
|
||||
assert!(s.contains("22 dBm"), "non-max power should contain '22 dBm': {s}");
|
||||
assert!(!s.contains("max"), "non-max power should not contain 'max': {s}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn format_radio_config_zero_preamble() {
|
||||
let mut cfg = default_config();
|
||||
cfg.preamble_len = 0;
|
||||
let s = format_radio_config(&cfg);
|
||||
assert!(s.contains("Pre:16"));
|
||||
fn tx_retry_reason_display() {
|
||||
assert_eq!(format!("{}", TxRetryReason::ChannelBusy), "channel_busy");
|
||||
assert_eq!(format!("{}", TxRetryReason::QueueFull), "queue_full");
|
||||
assert_eq!(format!("{}", TxRetryReason::Other), "other");
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user