The .126 LoRa panel's Rust half: - mesh::rnode_settings: RNodeRfSettings persisted at <data_dir>/rnode-rf-settings.json — every RNodeInterface parameter (enabled, port override, frequency, bandwidth, sf, cr, txpower, airtime_limit_short/long), validated against the bounds RNS itself enforces. Defaults are byte-identical to the sidecar's historical argparse defaults. - FIRST-RUN ADOPTION (operator requirement: the update must change NO device's applied settings): with no settings file yet, the node's existing RNS config (~/.archy-reticulum, else ~/.reticulum) is parsed and its RNodeInterface values adopted verbatim as the initial settings — proven by a test carrying the operator's literal "RNode LoRa Portugal" config. - Serial spawns pass the settings as explicit sidecar args (frequency/ bandwidth/txpower/sf/cr + airtime locks); the operator port override wins over auto-detect but still passes the KISS probe gate; a disabled interface refuses to open with a readable error. - ReticulumLink::query_radio_state(): asks the sidecar for the live RNodeInterface state (radio-confirmed r_* values) — the panel's apply-confirmation read-back source. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
361 lines
13 KiB
Rust
361 lines
13 KiB
Rust
//! Persisted RNode LoRa RF settings — the operator-editable half of the
|
||
//! Reticulum transport (.126 LoRa settings panel).
|
||
//!
|
||
//! The reticulum sidecar (reticulum-daemon) writes the RNS config from its
|
||
//! CLI args at every spawn; before this module those args were never passed,
|
||
//! so every node ran the sidecar's argparse defaults and nothing was
|
||
//! operator-editable. These settings persist at
|
||
//! `<data_dir>/rnode-rf-settings.json`, feed `daemon_command` as explicit
|
||
//! args, and the panel confirms application via the sidecar's `radio_state`
|
||
//! read-back (the radio-confirmed `r_*` values, not the requested ones).
|
||
//!
|
||
//! An absent file yields [`RNodeRfSettings::default`], which matches the
|
||
//! sidecar's historical argparse defaults exactly — deploying this changes
|
||
//! nothing until the operator edits something.
|
||
|
||
use anyhow::{bail, Result};
|
||
use serde::{Deserialize, Serialize};
|
||
use std::path::Path;
|
||
|
||
const SETTINGS_FILE: &str = "rnode-rf-settings.json";
|
||
|
||
/// Validation bounds mirror RNS `RNodeInterface.py` (`validate_firmware` /
|
||
/// the constructor checks) — NOT guessed: frequency 137–1020 MHz, sf 5–12,
|
||
/// cr 5–8, txpower 0–22 dBm, airtime locks 0–100 %.
|
||
const FREQ_MIN_HZ: u64 = 137_000_000;
|
||
const FREQ_MAX_HZ: u64 = 1_020_000_000;
|
||
/// The discrete bandwidths RNode firmware accepts (Hz).
|
||
const VALID_BANDWIDTHS: &[u64] = &[
|
||
7_800, 10_400, 15_600, 20_800, 31_250, 41_700, 62_500, 125_000, 250_000, 500_000,
|
||
];
|
||
|
||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||
pub struct RNodeRfSettings {
|
||
/// Interface on/off. `false` keeps the daemon from opening the radio at
|
||
/// all (the mesh service skips the serial transport).
|
||
#[serde(default = "default_true")]
|
||
pub enabled: bool,
|
||
/// Serial device override (e.g. `/dev/ttyACM0`). `None` = auto-detect,
|
||
/// which is what every node did before this existed.
|
||
#[serde(default)]
|
||
pub port: Option<String>,
|
||
#[serde(default = "default_frequency")]
|
||
pub frequency: u64,
|
||
#[serde(default = "default_bandwidth")]
|
||
pub bandwidth: u64,
|
||
#[serde(default = "default_spreading_factor")]
|
||
pub spreading_factor: u8,
|
||
#[serde(default = "default_coding_rate")]
|
||
pub coding_rate: u8,
|
||
#[serde(default = "default_txpower")]
|
||
pub txpower: u8,
|
||
/// Short-window airtime duty-cycle lock, percent (EU868: 25). `None` =
|
||
/// no software lock (RNS default).
|
||
#[serde(default)]
|
||
pub airtime_limit_short: Option<f64>,
|
||
/// Long-window airtime duty-cycle lock, percent (EU868: 10).
|
||
#[serde(default)]
|
||
pub airtime_limit_long: Option<f64>,
|
||
}
|
||
|
||
fn default_true() -> bool {
|
||
true
|
||
}
|
||
fn default_frequency() -> u64 {
|
||
869_525_000
|
||
}
|
||
fn default_bandwidth() -> u64 {
|
||
125_000
|
||
}
|
||
fn default_spreading_factor() -> u8 {
|
||
8
|
||
}
|
||
fn default_coding_rate() -> u8 {
|
||
5
|
||
}
|
||
fn default_txpower() -> u8 {
|
||
17
|
||
}
|
||
|
||
impl Default for RNodeRfSettings {
|
||
fn default() -> Self {
|
||
Self {
|
||
enabled: true,
|
||
port: None,
|
||
frequency: default_frequency(),
|
||
bandwidth: default_bandwidth(),
|
||
spreading_factor: default_spreading_factor(),
|
||
coding_rate: default_coding_rate(),
|
||
txpower: default_txpower(),
|
||
airtime_limit_short: None,
|
||
airtime_limit_long: None,
|
||
}
|
||
}
|
||
}
|
||
|
||
impl RNodeRfSettings {
|
||
pub fn validate(&self) -> Result<()> {
|
||
if !(FREQ_MIN_HZ..=FREQ_MAX_HZ).contains(&self.frequency) {
|
||
bail!(
|
||
"frequency {} Hz is outside the RNode range ({}–{} Hz)",
|
||
self.frequency,
|
||
FREQ_MIN_HZ,
|
||
FREQ_MAX_HZ
|
||
);
|
||
}
|
||
if !VALID_BANDWIDTHS.contains(&self.bandwidth) {
|
||
bail!(
|
||
"bandwidth {} Hz is not an RNode bandwidth (valid: {:?})",
|
||
self.bandwidth,
|
||
VALID_BANDWIDTHS
|
||
);
|
||
}
|
||
if !(5..=12).contains(&self.spreading_factor) {
|
||
bail!("spreading factor {} is outside 5–12", self.spreading_factor);
|
||
}
|
||
if !(5..=8).contains(&self.coding_rate) {
|
||
bail!("coding rate {} is outside 5–8", self.coding_rate);
|
||
}
|
||
if self.txpower > 22 {
|
||
bail!("tx power {} dBm is above the 22 dBm RNode maximum", self.txpower);
|
||
}
|
||
for (label, v) in [
|
||
("airtime_limit_short", self.airtime_limit_short),
|
||
("airtime_limit_long", self.airtime_limit_long),
|
||
] {
|
||
if let Some(pct) = v {
|
||
if !(0.0..=100.0).contains(&pct) || !pct.is_finite() {
|
||
bail!("{label} {pct} is not a percentage (0–100)");
|
||
}
|
||
}
|
||
}
|
||
if let Some(port) = &self.port {
|
||
// Same shape the flasher accepts: an absolute device node. Keeps
|
||
// shell-metacharacter garbage out of the sidecar's argv.
|
||
if !port.starts_with("/dev/")
|
||
|| port
|
||
.chars()
|
||
.any(|c| !(c.is_ascii_alphanumeric() || c == '/' || c == '_' || c == '-' || c == '.'))
|
||
{
|
||
bail!("port must be an absolute /dev device path");
|
||
}
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
pub async fn load(data_dir: &Path) -> Self {
|
||
let path = data_dir.join(SETTINGS_FILE);
|
||
match tokio::fs::read_to_string(&path).await {
|
||
Ok(raw) => match serde_json::from_str::<Self>(&raw) {
|
||
Ok(s) => s,
|
||
Err(e) => {
|
||
tracing::warn!(error = %e, "rnode-rf-settings.json unparseable — using defaults");
|
||
Self::default()
|
||
}
|
||
},
|
||
// First run after the update: no settings file yet. ADOPT the
|
||
// node's existing effective RF config rather than imposing
|
||
// defaults — the operator's standing requirement is that the
|
||
// update changes NO device's applied settings. For archy-managed
|
||
// radios the sidecar config equals our defaults anyway; this
|
||
// covers any node whose RNS config diverged (hand edits,
|
||
// hand-run rnsd).
|
||
Err(_) => {
|
||
let adopted = Self::adopt_existing_rns_config().await;
|
||
if let Some(adopted) = adopted {
|
||
tracing::info!(
|
||
settings = ?adopted,
|
||
"adopted existing RNS RNode config as initial RF settings"
|
||
);
|
||
if let Err(e) = adopted.save(data_dir).await {
|
||
tracing::warn!(error = %e, "could not persist adopted RF settings");
|
||
}
|
||
adopted
|
||
} else {
|
||
Self::default()
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Parse the RNodeInterface section out of an existing RNS config file
|
||
/// (the sidecar's `~/.archy-reticulum/config`, else a hand-run rnsd's
|
||
/// `~/.reticulum/config`). Returns `None` when neither exists or no
|
||
/// RNodeInterface section is found. Unparseable/absent fields keep the
|
||
/// default (which equals the sidecar's historical argparse default).
|
||
async fn adopt_existing_rns_config() -> Option<Self> {
|
||
let home = std::env::var("HOME").ok()?;
|
||
for candidate in [
|
||
format!("{home}/.archy-reticulum/config"),
|
||
format!("{home}/.reticulum/config"),
|
||
] {
|
||
let Ok(raw) = tokio::fs::read_to_string(&candidate).await else {
|
||
continue;
|
||
};
|
||
if let Some(s) = Self::parse_rnode_section(&raw) {
|
||
return Some(s);
|
||
}
|
||
}
|
||
None
|
||
}
|
||
|
||
/// Extract RNode parameters from RNS config text. Scoped to the block
|
||
/// after a `type = RNodeInterface` line so TCP interface options can
|
||
/// never bleed in; stops at the next `[[...]]` section header.
|
||
fn parse_rnode_section(raw: &str) -> Option<Self> {
|
||
let mut in_rnode = false;
|
||
let mut seen_any = false;
|
||
let mut s = Self::default();
|
||
for line in raw.lines() {
|
||
let line = line.trim();
|
||
if line.starts_with("[[") {
|
||
if in_rnode {
|
||
break; // next interface section — RNode block ended
|
||
}
|
||
continue;
|
||
}
|
||
let Some((key, value)) = line.split_once('=') else {
|
||
continue;
|
||
};
|
||
let (key, value) = (key.trim(), value.trim());
|
||
if key == "type" {
|
||
in_rnode = value == "RNodeInterface";
|
||
continue;
|
||
}
|
||
if !in_rnode {
|
||
continue;
|
||
}
|
||
seen_any = true;
|
||
match key {
|
||
"enabled" | "interface_enabled" => {
|
||
s.enabled = matches!(value.to_ascii_lowercase().as_str(), "yes" | "true" | "on")
|
||
}
|
||
"port" => s.port = Some(value.to_string()),
|
||
"frequency" => s.frequency = value.parse().unwrap_or(s.frequency),
|
||
"bandwidth" => s.bandwidth = value.parse().unwrap_or(s.bandwidth),
|
||
"txpower" => s.txpower = value.parse().unwrap_or(s.txpower),
|
||
"spreadingfactor" => {
|
||
s.spreading_factor = value.parse().unwrap_or(s.spreading_factor)
|
||
}
|
||
"codingrate" => s.coding_rate = value.parse().unwrap_or(s.coding_rate),
|
||
"airtime_limit_short" => s.airtime_limit_short = value.parse().ok(),
|
||
"airtime_limit_long" => s.airtime_limit_long = value.parse().ok(),
|
||
_ => {}
|
||
}
|
||
}
|
||
(in_rnode || seen_any).then_some(s)
|
||
}
|
||
|
||
pub async fn save(&self, data_dir: &Path) -> Result<()> {
|
||
self.validate()?;
|
||
let path = data_dir.join(SETTINGS_FILE);
|
||
let tmp = path.with_extension("json.tmp");
|
||
let raw = serde_json::to_string_pretty(self)?;
|
||
tokio::fs::write(&tmp, raw).await?;
|
||
tokio::fs::rename(&tmp, &path).await?;
|
||
Ok(())
|
||
}
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
|
||
#[test]
|
||
fn defaults_match_the_sidecar_argparse_defaults() {
|
||
// reticulum_daemon.py: --frequency 869525000 --bandwidth 125000
|
||
// --txpower 17 --spreadingfactor 8 --codingrate 5, no airtime locks.
|
||
let d = RNodeRfSettings::default();
|
||
assert_eq!(d.frequency, 869_525_000);
|
||
assert_eq!(d.bandwidth, 125_000);
|
||
assert_eq!(d.txpower, 17);
|
||
assert_eq!(d.spreading_factor, 8);
|
||
assert_eq!(d.coding_rate, 5);
|
||
assert!(d.airtime_limit_short.is_none() && d.airtime_limit_long.is_none());
|
||
assert!(d.enabled && d.port.is_none());
|
||
d.validate().unwrap();
|
||
}
|
||
|
||
#[test]
|
||
fn operator_portugal_config_validates() {
|
||
// The operator's real device config (2026-08-06).
|
||
let s = RNodeRfSettings {
|
||
enabled: true,
|
||
port: Some("/dev/ttyACM0".into()),
|
||
frequency: 869_462_500,
|
||
bandwidth: 125_000,
|
||
spreading_factor: 8,
|
||
coding_rate: 5,
|
||
txpower: 14,
|
||
airtime_limit_short: Some(25.0),
|
||
airtime_limit_long: Some(10.0),
|
||
};
|
||
s.validate().unwrap();
|
||
}
|
||
|
||
#[test]
|
||
fn adoption_preserves_the_operator_portugal_config_exactly() {
|
||
// The operator's literal RNS config (2026-08-06). The update must
|
||
// adopt these values verbatim — changing a node's applied RF
|
||
// settings is forbidden.
|
||
let raw = "\
|
||
[reticulum]
|
||
enable_transport = yes
|
||
|
||
[interfaces]
|
||
[[RNode LoRa Portugal]]
|
||
type = RNodeInterface
|
||
interface_enabled = true
|
||
port = /dev/ttyACM0
|
||
frequency = 869462500
|
||
bandwidth = 125000
|
||
spreadingfactor = 8
|
||
codingrate = 5
|
||
txpower = 14
|
||
airtime_limit_short = 25
|
||
airtime_limit_long = 10
|
||
";
|
||
let s = RNodeRfSettings::parse_rnode_section(raw).expect("section found");
|
||
assert!(s.enabled);
|
||
assert_eq!(s.port.as_deref(), Some("/dev/ttyACM0"));
|
||
assert_eq!(s.frequency, 869_462_500);
|
||
assert_eq!(s.bandwidth, 125_000);
|
||
assert_eq!(s.spreading_factor, 8);
|
||
assert_eq!(s.coding_rate, 5);
|
||
assert_eq!(s.txpower, 14);
|
||
assert_eq!(s.airtime_limit_short, Some(25.0));
|
||
assert_eq!(s.airtime_limit_long, Some(10.0));
|
||
s.validate().unwrap();
|
||
}
|
||
|
||
#[test]
|
||
fn adoption_ignores_non_rnode_sections_and_absent_config() {
|
||
let tcp_only = "\
|
||
[interfaces]
|
||
[[Reticulum TCP Server]]
|
||
type = TCPServerInterface
|
||
listen_ip = 127.0.0.1
|
||
listen_port = 4242
|
||
";
|
||
assert!(RNodeRfSettings::parse_rnode_section(tcp_only).is_none());
|
||
assert!(RNodeRfSettings::parse_rnode_section("").is_none());
|
||
}
|
||
|
||
#[test]
|
||
fn out_of_range_values_are_rejected() {
|
||
let base = RNodeRfSettings::default();
|
||
for bad in [
|
||
RNodeRfSettings { frequency: 100, ..base.clone() },
|
||
RNodeRfSettings { bandwidth: 123_456, ..base.clone() },
|
||
RNodeRfSettings { spreading_factor: 4, ..base.clone() },
|
||
RNodeRfSettings { coding_rate: 9, ..base.clone() },
|
||
RNodeRfSettings { txpower: 23, ..base.clone() },
|
||
RNodeRfSettings { airtime_limit_short: Some(180.0), ..base.clone() },
|
||
RNodeRfSettings { port: Some("ttyACM0".into()), ..base.clone() },
|
||
RNodeRfSettings { port: Some("/dev/tty; rm -rf /".into()), ..base.clone() },
|
||
] {
|
||
assert!(bad.validate().is_err(), "{bad:?} should fail validation");
|
||
}
|
||
}
|
||
}
|