Archived
- Enable clippy pedantic + nursery lints; fix all warnings - Add config validation (SF, CR, frequency range checks) - Zeroize passphrase intermediates after key derivation - Convert to lib+bin crate for fuzz target accessibility - Replace Vec with VecDeque for O(1) log/bucket rotation - Add TUI minimum terminal size guard Testing: - 145 unit + property tests (was 15) - 6 proptest groups (frame roundtrip, hash, SNR, air time, peer list, padding) - 2 cargo-fuzz targets (gossip frame decode, peer list decode) - cargo-mutants: 0 missed, 241 caught, 54 unviable Documentation: - README with prerequisites, quick start, config reference, architecture - Module-level and item-level rustdoc on all public items - LICENSE (MIT), rustfmt.toml, .gitignore Infrastructure: - GitHub Actions CI (fmt, clippy, test, audit) - cargo-mutants config (.cargo/mutants.toml) - justfile for common tasks
344 lines
13 KiB
Rust
344 lines
13 KiB
Rust
//! `LoRa` air-time-aware token bucket rate limiter.
|
||
//!
|
||
//! Calculates transmit rate from the Semtech `LoRa` air time formula and
|
||
//! enforces it with a configurable burst allowance.
|
||
|
||
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). Allows short bursts of 3 back-to-back packets
|
||
/// to handle bursty mesh traffic without triggering rate limiting on every packet.
|
||
const DEFAULT_BURST: f64 = 3.0;
|
||
|
||
/// Reference payload size (bytes) used to estimate air time for rate calculation.
|
||
/// 50 bytes is a representative `MeshCore` packet size (header + small payload).
|
||
const REFERENCE_PAYLOAD_BYTES: usize = 50;
|
||
|
||
/// Target duty cycle fraction. 50% leaves headroom for other radio traffic sharing
|
||
/// the channel while still forwarding bridge traffic aggressively.
|
||
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.
|
||
#[must_use]
|
||
pub fn from_radio_config(sf: u8, bw: Bandwidth, cr: u8, preamble: u16, override_pps: Option<f64>) -> Self {
|
||
let refill_rate = override_pps.map_or_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 }
|
||
},
|
||
|pps| pps.max(0.01),
|
||
);
|
||
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.
|
||
#[must_use]
|
||
pub const 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 = elapsed.mul_add(self.refill_rate, self.tokens).min(self.max_tokens);
|
||
self.last_refill = now;
|
||
}
|
||
}
|
||
|
||
/// Estimate `LoRa` packet air time using the Semtech `LoRa` time-on-air 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)
|
||
#[must_use]
|
||
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 = sf_f.exp2() / 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).
|
||
#[allow(clippy::cast_precision_loss)] // payload_bytes is always < 256
|
||
let payload_f = payload_bytes as f64;
|
||
let numerator = 8.0f64.mul_add(payload_f, (-4.0f64).mul_add(sf_f, 44.0));
|
||
let denominator = 4.0f64.mul_add(sf_f, -8.0 * de);
|
||
let n_payload = (numerator / denominator).ceil().max(0.0).mul_add(f64::from(cr), 8.0);
|
||
|
||
let t_payload = n_payload * t_sym;
|
||
let total = t_preamble + t_payload;
|
||
|
||
Duration::from_secs_f64(total)
|
||
}
|
||
|
||
const 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);
|
||
}
|
||
|
||
// ── Mutant-killing tests for from_radio_config (items 1-5) ────────
|
||
|
||
#[test]
|
||
fn from_radio_config_calculates_reasonable_rate() {
|
||
// SF7/BW125/CR5/preamble16 with 50-byte reference payload:
|
||
// air time ~105.7ms, so rate = 0.5 / 0.1057 ~= 4.73 pps.
|
||
let rl = RateLimiter::from_radio_config(7, Bandwidth::Khz125, 5, 16, None);
|
||
let rate = rl.rate_pps();
|
||
// Must be between 0.1 and 100 (rules out fallback of 10.0 indirectly,
|
||
// and catches if secs<=0 path is taken or division is wrong).
|
||
assert!(rate > 0.1, "rate too low: {rate}");
|
||
assert!(rate < 100.0, "rate too high: {rate}");
|
||
// Specifically verify it is NOT the 10.0 fallback, proving secs > 0.0 was true.
|
||
assert!((rate - 10.0).abs() > 0.5, "rate should not be the 10.0 fallback: {rate}");
|
||
// Tight check around the expected ~4.73 pps.
|
||
assert!(rate > 4.0, "rate should be near 4.73: {rate}");
|
||
assert!(rate < 5.5, "rate should be near 4.73: {rate}");
|
||
}
|
||
|
||
#[test]
|
||
fn from_radio_config_sf12_rate_much_slower() {
|
||
// SF12/BW125: air time ~2564ms, rate = 0.5 / 2.564 ~= 0.195 pps.
|
||
let rl = RateLimiter::from_radio_config(12, Bandwidth::Khz125, 5, 16, None);
|
||
let rate = rl.rate_pps();
|
||
assert!(rate > 0.1, "rate too low: {rate}");
|
||
assert!(rate < 0.5, "rate too high: {rate}");
|
||
// Definitely not 10.0 fallback.
|
||
assert!((rate - 10.0).abs() > 1.0, "rate should not be the fallback: {rate}");
|
||
}
|
||
|
||
// ── Mutant-killing tests for refill (item 6) ───────────────────
|
||
|
||
#[test]
|
||
fn rate_limiter_refills_after_waiting() {
|
||
// Use override_pps for predictable timing: 10 pps = 1 token per 100ms.
|
||
let mut rl = RateLimiter::from_radio_config(7, Bandwidth::Khz125, 5, 16, Some(10.0));
|
||
// Exhaust all burst tokens.
|
||
assert!(rl.try_acquire());
|
||
assert!(rl.try_acquire());
|
||
assert!(rl.try_acquire());
|
||
assert!(!rl.try_acquire(), "burst should be exhausted");
|
||
|
||
// Sleep enough for at least 1 token to refill (10 pps -> 100ms per token).
|
||
std::thread::sleep(Duration::from_millis(200));
|
||
|
||
// After sleeping, refill should have added tokens, so acquire succeeds.
|
||
assert!(rl.try_acquire(), "should succeed after refill");
|
||
}
|
||
|
||
// ── Mutant-killing tests for lora_air_time (items 7-14) ────────
|
||
|
||
#[test]
|
||
fn air_time_exact_sf7_bw125() {
|
||
// SF7, BW125kHz, CR4/5, preamble=16, payload=50 bytes.
|
||
// Expected: 105.728 ms (computed from Semtech formula).
|
||
let t = lora_air_time(50, 7, Bandwidth::Khz125, 5, 16);
|
||
let ms = t.as_secs_f64() * 1000.0;
|
||
assert!((ms - 105.728).abs() < 0.01, "SF7/BW125 air time should be ~105.728ms, got {ms:.3}ms");
|
||
}
|
||
|
||
#[test]
|
||
fn air_time_exact_sf12_bw125() {
|
||
// SF12, BW125kHz, CR4/5, preamble=16, payload=50 bytes.
|
||
// Expected: 2564.096 ms.
|
||
let t = lora_air_time(50, 12, Bandwidth::Khz125, 5, 16);
|
||
let ms = t.as_secs_f64() * 1000.0;
|
||
assert!((ms - 2564.096).abs() < 0.01, "SF12/BW125 air time should be ~2564.096ms, got {ms:.3}ms");
|
||
}
|
||
|
||
#[test]
|
||
fn air_time_ldro_sf11_bw125_vs_sf10_bw125() {
|
||
// SF>=11 && BW<=125kHz triggers LDRO. SF10 does not.
|
||
// SF11/BW125: 1445.888 ms (de=1), SF10/BW125: 681.984 ms (de=0).
|
||
let t11 = lora_air_time(50, 11, Bandwidth::Khz125, 5, 16);
|
||
let t10 = lora_air_time(50, 10, Bandwidth::Khz125, 5, 16);
|
||
|
||
let ms11 = t11.as_secs_f64() * 1000.0;
|
||
let ms10 = t10.as_secs_f64() * 1000.0;
|
||
|
||
// Verify exact values.
|
||
assert!((ms11 - 1445.888).abs() < 0.01, "SF11/BW125 should be ~1445.888ms, got {ms11:.3}ms");
|
||
assert!((ms10 - 681.984).abs() < 0.01, "SF10/BW125 should be ~681.984ms, got {ms10:.3}ms");
|
||
|
||
// SF11 must be more than double SF10 due to LDRO + SF increase.
|
||
// (Kills mutant: replace >= with < in sf >= 11 check.)
|
||
assert!(t11 > t10 * 2, "SF11 with LDRO should be >2x SF10");
|
||
}
|
||
|
||
#[test]
|
||
fn air_time_ldro_not_triggered_high_bw() {
|
||
// SF11/BW250: LDRO should NOT be active (BW > 125kHz).
|
||
// Expected: 641.024 ms (de=0).
|
||
let t11_250 = lora_air_time(50, 11, Bandwidth::Khz250, 5, 16);
|
||
let ms = t11_250.as_secs_f64() * 1000.0;
|
||
assert!((ms - 641.024).abs() < 0.01, "SF11/BW250 should be ~641.024ms (no LDRO), got {ms:.3}ms");
|
||
|
||
// Compare with SF11/BW125 which HAS LDRO.
|
||
let t11_125 = lora_air_time(50, 11, Bandwidth::Khz125, 5, 16);
|
||
// Even accounting for halved BW doubling the base time, the LDRO effect
|
||
// on SF11/BW125 makes it much longer than simply 2x SF11/BW250.
|
||
assert!(t11_125 > t11_250 * 2, "SF11/BW125 (LDRO on) should be >2x SF11/BW250 (LDRO off)");
|
||
}
|
||
|
||
#[test]
|
||
fn air_time_preamble_scaling() {
|
||
// Changing preamble by N symbols changes air time by N * t_sym.
|
||
// t_sym for SF7/BW125 = 2^7 / 125000 = 1.024 ms.
|
||
let t8 = lora_air_time(50, 7, Bandwidth::Khz125, 5, 8);
|
||
let t16 = lora_air_time(50, 7, Bandwidth::Khz125, 5, 16);
|
||
let t32 = lora_air_time(50, 7, Bandwidth::Khz125, 5, 32);
|
||
|
||
let diff_8_to_16 = t16.as_secs_f64() - t8.as_secs_f64();
|
||
let diff_16_to_32 = t32.as_secs_f64() - t16.as_secs_f64();
|
||
|
||
let t_sym = 128.0 / 125_000.0; // 1.024 ms
|
||
|
||
// 8 -> 16: 8 symbols difference.
|
||
let expected_8 = 8.0f64.mul_add(t_sym, -diff_8_to_16).abs();
|
||
assert!(expected_8 < 1e-9, "preamble 8->16 should add 8*t_sym, got {diff_8_to_16:.6}s");
|
||
|
||
// 16 -> 32: 16 symbols difference.
|
||
let expected_16 = 16.0f64.mul_add(t_sym, -diff_16_to_32).abs();
|
||
assert!(expected_16 < 1e-9, "preamble 16->32 should add 16*t_sym, got {diff_16_to_32:.6}s");
|
||
}
|
||
|
||
#[test]
|
||
fn air_time_sensible_range_all_sf_bw() {
|
||
// Verify reasonable bounds for all SF/BW combos with 50-byte payload.
|
||
// This catches gross formula errors (e.g. division replaced with modulo).
|
||
let bandwidths = [
|
||
Bandwidth::Khz7,
|
||
Bandwidth::Khz10,
|
||
Bandwidth::Khz15,
|
||
Bandwidth::Khz20,
|
||
Bandwidth::Khz31,
|
||
Bandwidth::Khz41,
|
||
Bandwidth::Khz62,
|
||
Bandwidth::Khz125,
|
||
Bandwidth::Khz250,
|
||
Bandwidth::Khz500,
|
||
];
|
||
for sf in 7..=12u8 {
|
||
for &bw in &bandwidths {
|
||
let t = lora_air_time(50, sf, bw, 5, 16);
|
||
let ms = t.as_secs_f64() * 1000.0;
|
||
// Fastest: SF7/BW500 ~26ms. Slowest: SF12/BW7.8kHz ~many seconds.
|
||
// All should be > 1ms and < 300s.
|
||
assert!(ms > 1.0, "SF{sf}/BW{bw:?}: air time too short: {ms:.3}ms");
|
||
assert!(ms < 300_000.0, "SF{sf}/BW{bw:?}: air time too long: {ms:.3}ms");
|
||
}
|
||
}
|
||
}
|
||
|
||
mod proptests {
|
||
use super::*;
|
||
use proptest::prelude::*;
|
||
|
||
proptest! {
|
||
#[test]
|
||
fn air_time_always_positive(
|
||
payload in 0usize..256,
|
||
sf in 7u8..=12u8,
|
||
cr in 5u8..=8u8,
|
||
preamble in 0u16..=64u16,
|
||
) {
|
||
for bw in [Bandwidth::Khz7, Bandwidth::Khz62, Bandwidth::Khz125, Bandwidth::Khz500] {
|
||
let t = lora_air_time(payload, sf, bw, cr, preamble);
|
||
prop_assert!(t > Duration::ZERO, "air time must be positive");
|
||
prop_assert!(t < Duration::from_secs(600), "air time should be < 600s");
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|