feat(mesh): persisted RNode RF settings with adopt-don't-clobber migration
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>
This commit is contained in:
co-authored by
Claude Fable 5
parent
2afeafc92e
commit
4dd8bacd0e
@@ -16,6 +16,7 @@ pub mod outbox;
|
||||
pub mod protocol;
|
||||
pub mod ratchet;
|
||||
pub mod reticulum;
|
||||
pub mod rnode_settings;
|
||||
pub mod scheduler;
|
||||
pub mod serial;
|
||||
pub mod session;
|
||||
|
||||
@@ -176,6 +176,7 @@ fn daemon_command(
|
||||
archy_x25519_pubkey_hex: Option<&str>,
|
||||
display_name: Option<&str>,
|
||||
enable_transport: bool,
|
||||
rf: Option<&super::rnode_settings::RNodeRfSettings>,
|
||||
) -> Command {
|
||||
let (program, script) = daemon_program();
|
||||
let mut cmd = Command::new(program);
|
||||
@@ -189,6 +190,24 @@ fn daemon_command(
|
||||
match iface {
|
||||
ReticulumInterface::Serial(path) => {
|
||||
cmd.arg("--serial-port").arg(path);
|
||||
// Operator-editable RF parameters (.126 LoRa panel). Passed
|
||||
// explicitly on every spawn so the sidecar's argparse defaults
|
||||
// stop being the silent source of truth. `rf` is None only for
|
||||
// non-serial interfaces, where these have no meaning.
|
||||
if let Some(rf) = rf {
|
||||
cmd.arg("--frequency").arg(rf.frequency.to_string());
|
||||
cmd.arg("--bandwidth").arg(rf.bandwidth.to_string());
|
||||
cmd.arg("--txpower").arg(rf.txpower.to_string());
|
||||
cmd.arg("--spreadingfactor")
|
||||
.arg(rf.spreading_factor.to_string());
|
||||
cmd.arg("--codingrate").arg(rf.coding_rate.to_string());
|
||||
if let Some(pct) = rf.airtime_limit_short {
|
||||
cmd.arg("--airtime-limit-short").arg(pct.to_string());
|
||||
}
|
||||
if let Some(pct) = rf.airtime_limit_long {
|
||||
cmd.arg("--airtime-limit-long").arg(pct.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
ReticulumInterface::TcpServer(bind) => {
|
||||
cmd.arg("--tcp-listen").arg(bind);
|
||||
@@ -318,6 +337,10 @@ pub struct ReticulumLink {
|
||||
/// down and the outer reconnect loop respawns the daemon — without this
|
||||
/// a dead daemon was invisible until the 30-minute RX-stall watchdog.
|
||||
daemon_gone: bool,
|
||||
/// Latest `radio_state` event from the sidecar (the live RNodeInterface
|
||||
/// values, radio-confirmed `r_*` included). Refreshed by
|
||||
/// [`Self::query_radio_state`]; the .126 LoRa panel's read-back source.
|
||||
last_radio_state: Option<Value>,
|
||||
}
|
||||
|
||||
impl ReticulumLink {
|
||||
@@ -344,6 +367,16 @@ impl ReticulumLink {
|
||||
our_x25519_pubkey_hex: Option<&str>,
|
||||
display_name: Option<&str>,
|
||||
) -> Result<Self> {
|
||||
let rf = super::rnode_settings::RNodeRfSettings::load(data_dir).await;
|
||||
if !rf.enabled {
|
||||
anyhow::bail!(
|
||||
"RNode interface is disabled in the LoRa settings — enable it to connect"
|
||||
);
|
||||
}
|
||||
// Operator port override wins over the auto-detected path (.126 LoRa
|
||||
// panel). The probe below still gates: a wrong override fails with
|
||||
// the detect error instead of a silent dead transport.
|
||||
let path = rf.port.as_deref().unwrap_or(path);
|
||||
probe_rnode(path)
|
||||
.await
|
||||
.context("RNode KISS detect failed")?;
|
||||
@@ -454,6 +487,15 @@ impl ReticulumLink {
|
||||
}
|
||||
|
||||
let enable_transport = daemon_supports_enable_transport().await;
|
||||
// Operator RF settings ride every serial spawn; loaded here (not by
|
||||
// callers) so a settings apply only needs a transport restart to take
|
||||
// effect. Non-serial interfaces carry no RF.
|
||||
let rf = match iface {
|
||||
ReticulumInterface::Serial(_) => {
|
||||
Some(super::rnode_settings::RNodeRfSettings::load(data_dir).await)
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
let mut cmd = daemon_command(
|
||||
&socket_path,
|
||||
&iface,
|
||||
@@ -462,6 +504,7 @@ impl ReticulumLink {
|
||||
our_x25519_pubkey_hex,
|
||||
display_name,
|
||||
enable_transport,
|
||||
rf.as_ref(),
|
||||
);
|
||||
cmd.env("TMPDIR", &tmp_dir);
|
||||
let child = cmd
|
||||
@@ -534,6 +577,7 @@ impl ReticulumLink {
|
||||
inbound: std::collections::VecDeque::new(),
|
||||
resource_id_counter: 0,
|
||||
daemon_gone: false,
|
||||
last_radio_state: None,
|
||||
};
|
||||
link.load_persisted_peers();
|
||||
Ok(link)
|
||||
@@ -896,8 +940,36 @@ impl ReticulumLink {
|
||||
}
|
||||
}
|
||||
|
||||
/// Ask the sidecar for the live RNode state and wait briefly for the
|
||||
/// reply event. Returns the freshest `radio_state` payload, or `None`
|
||||
/// when the daemon didn't answer in time (dead daemon, no radio build).
|
||||
pub async fn query_radio_state(&mut self, timeout: Duration) -> Option<Value> {
|
||||
self.last_radio_state = None;
|
||||
if self
|
||||
.send_rpc(serde_json::json!({"cmd": "radio_state"}))
|
||||
.await
|
||||
.is_err()
|
||||
{
|
||||
return None;
|
||||
}
|
||||
let deadline = tokio::time::Instant::now() + timeout;
|
||||
loop {
|
||||
self.drain_events().await;
|
||||
if let Some(state) = &self.last_radio_state {
|
||||
return Some(state.clone());
|
||||
}
|
||||
if self.daemon_gone || tokio::time::Instant::now() >= deadline {
|
||||
return None;
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(50)).await;
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_event(&mut self, ev: Value) {
|
||||
match ev.get("event").and_then(Value::as_str) {
|
||||
Some("radio_state") => {
|
||||
self.last_radio_state = Some(ev);
|
||||
}
|
||||
Some("announce") => {
|
||||
let Some(hash) = ev
|
||||
.get("dest_hash")
|
||||
|
||||
@@ -0,0 +1,360 @@
|
||||
//! 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");
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user