Compare commits

...
6 Commits
Author SHA1 Message Date
archipelagoandClaude Fable 5 3cef1d09f4 feat(mesh-ui): RNode settings editor with live device read-back + region presets
Demo images / Build & push demo images (push) In progress
The LoRa device panel's Reticulum section (operator .126 top priority):

- Shows the device's CURRENT settings first — the radio-confirmed r_*
  values from mesh.rnode-config (online badge, port, frequency, bw,
  SF, CR, txpower, airtime limits), with a Refresh action.
- Every RNodeInterface parameter is editable: enabled, serial port
  (auto-detect when blank), frequency, bandwidth (RNode's discrete
  set), SF 5-12, CR 4/5-4/8, txpower, airtime short/long %.
- "Set recommended for <region>" fills the fields from per-region
  plans (EU868 = the operator-validated Portugal plan incl. 25%/10%
  duty-cycle locks); driven by the existing region selector above.
- Apply & Confirm on Device: persists, restarts the radio daemon, and
  reports the radio's own confirmation (green ✓ only when the device
  read-back matches; amber/red messages say what actually happened).
- Action buttons stack in a column (operator layout request).
- Reboot Radio surfaces the backend's real acknowledgement message.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-06 09:08:19 -04:00
archipelagoandClaude Fable 5 45fa8b6c6f feat(neode-ui): seed & entropy explainer page at /entropy/ + link from Backup settings
Demo images / Build & push demo images (push) Successful in 4m19s
Standalone static guide (same pattern as /architecture/) covering how the
master seed entropy is drawn (explicit OsRng, sealed KeyGenRng allowlist,
degenerate-draw refusal, CSPRNG readiness ledger), how it is stored
(Argon2 + ChaCha20-Poly1305 envelope), the full derivation tree (HKDF
labels, NIP-06, LND aezeed one-way gate, second-order keys), what is NOT
seed-derived, every failure/fallback path, and the restore flow — in
paired layman/technical language. Linked from the Recovery-phrase card
in Settings → Backup. CSP-safe: no inline scripts.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-06 08:56:34 -04:00
archipelagoandClaude Fable 5 209a36e53c feat(mesh): rnode-config RPCs + honest reboot feedback with reply channels
- mesh.rnode-config: persisted RF settings + best-effort live radio
  state (radio-confirmed r_* values) for the LoRa panel.
- mesh.rnode-config-apply: validate → persist → restart the radio
  daemon → poll the read-back until the radio reports online, returning
  {applied, confirmed, live, message}. Failure modes report what
  actually happened instead of pretending success.
- RebootRadio carries a reply channel: Meshtastic reboots firmware,
  Reticulum restarts the sidecar (re-detect + reapply RF config),
  MeshCore honestly reports it has no remote reboot — previously the
  Reticulum/MeshCore arms returned Ok(()) doing NOTHING: the operator's
  "button gives no feedback" bug.
- MeshCommand::QueryRadioState plumbs the sidecar's radio_state to the
  service layer with a timeout instead of fire-and-forget.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-06 08:43:39 -04:00
archipelagoandClaude Fable 5 4dd8bacd0e 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>
2026-08-06 08:28:45 -04:00
archipelagoandClaude Fable 5 2afeafc92e feat(reticulum-daemon): airtime-limit args + radio_state RPC for live read-back
Groundwork for the .126 LoRa settings panel (operator top priority):

- --airtime-limit-short/--airtime-limit-long (percent duty-cycle locks,
  e.g. EU868 25/10) written into the RNode interface config when set;
  default None writes nothing — identical to older daemons.
- New socket RPC {"cmd":"radio_state"} returns the live RNodeInterface
  state: requested config values AND the radio-confirmed r_* values
  (r_frequency/r_bandwidth/r_txpower/r_sf/r_cr/r_st_alock/r_lt_alock,
  online, port, airtime utilisation). The r_* values are what the RADIO
  reported after detect/configure — the settings panel's proof that the
  device is actually using what was applied.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-06 08:04:15 -04:00
archipelagoandClaude Fable 5 44522fc94a chore(scripts): one-shot node-side companion-manifest repair script
Curl-and-pipe repair for nodes whose companion-UI manifests are stale
(no session_passthrough): fetches the four current manifests from the
public repo, installs them into /opt/archipelago/apps AND the frontend
runtime payload (which restores over apps/ at every boot), restarts,
and reports the gate probe. Long paste-blocks kept mangling in the
operator's terminal — this replaces them with one short line.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-06 07:59:34 -04:00
13 changed files with 2032 additions and 20 deletions
@@ -405,6 +405,8 @@ impl RpcHandler {
"mesh.send-channel" => self.handle_mesh_send_channel(params).await,
"mesh.broadcast" => self.handle_mesh_broadcast().await,
"mesh.reboot-radio" => self.handle_mesh_reboot_radio(params).await,
"mesh.rnode-config" => self.handle_mesh_rnode_config().await,
"mesh.rnode-config-apply" => self.handle_mesh_rnode_config_apply(params).await,
"mesh.configure" => self.handle_mesh_configure(params).await,
"mesh.send-invoice" => self.handle_mesh_send_invoice(params).await,
"mesh.send-coordinate" => self.handle_mesh_send_coordinate(params).await,
+107 -2
View File
@@ -104,10 +104,115 @@ impl RpcHandler {
.as_ref()
.ok_or_else(|| anyhow::anyhow!("Mesh service not running. Enable mesh first."))?;
svc.reboot_radio(seconds).await?;
let message = svc.reboot_radio(seconds).await?;
info!(seconds, "Mesh radio reboot requested via RPC");
Ok(serde_json::json!({ "reboot": true, "seconds": seconds }))
Ok(serde_json::json!({ "reboot": true, "seconds": seconds, "message": message }))
}
/// mesh.rnode-config — persisted RF settings + the live radio state
/// (radio-confirmed values) for the LoRa settings panel. `live` is best-
/// effort: null with `live_error` when no Reticulum radio is connected.
pub(in crate::api::rpc) async fn handle_mesh_rnode_config(&self) -> Result<serde_json::Value> {
let settings = mesh::rnode_settings::RNodeRfSettings::load(&self.config.data_dir).await;
let (live, live_error) = match self.mesh_service.read().await.as_ref() {
Some(svc) => match svc.radio_state().await {
Ok(state) => (Some(state), None),
Err(e) => (None, Some(format!("{e:#}"))),
},
None => (None, Some("Mesh service not running".to_string())),
};
Ok(serde_json::json!({
"settings": settings,
"live": live,
"live_error": live_error,
}))
}
/// mesh.rnode-config-apply — validate + persist the RF settings, restart
/// the radio daemon so they take effect, then read back the radio-
/// confirmed values as proof. Returns { applied, live, message }; a
/// failed read-back still reports the persisted settings with a clear
/// message instead of pretending success.
pub(in crate::api::rpc) async fn handle_mesh_rnode_config_apply(
&self,
params: Option<serde_json::Value>,
) -> Result<serde_json::Value> {
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
let settings: mesh::rnode_settings::RNodeRfSettings = serde_json::from_value(
params
.get("settings")
.cloned()
.ok_or_else(|| anyhow::anyhow!("Missing 'settings'"))?,
)
.map_err(|e| anyhow::anyhow!("Invalid settings: {e}"))?;
settings.validate()?;
settings.save(&self.config.data_dir).await?;
info!(?settings, "RNode RF settings persisted");
// Restart the radio daemon so the new args apply. No radio connected
// is fine — the settings apply on the next connect.
let service = self.mesh_service.read().await;
let Some(svc) = service.as_ref() else {
return Ok(serde_json::json!({
"applied": false,
"message": "Settings saved. They apply when the mesh service next connects to the radio.",
}));
};
if let Err(e) = svc.reboot_radio(2).await {
return Ok(serde_json::json!({
"applied": false,
"message": format!(
"Settings saved, but the radio daemon restart failed: {e:#}. \
They apply on the next reconnect."
),
}));
}
// Read-back: poll until the respawned daemon reports the radio online
// with our applied values (the respawn re-detects the RNode, ~15s).
let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(45);
let mut last_live = None;
while tokio::time::Instant::now() < deadline {
tokio::time::sleep(std::time::Duration::from_secs(3)).await;
if let Ok(state) = svc.radio_state().await {
let online = state
.get("online")
.and_then(|v| v.as_bool())
.unwrap_or(false);
last_live = Some(state);
if online {
break;
}
}
}
match last_live {
Some(live) => {
let confirmed = live
.get("r_frequency")
.and_then(|v| v.as_u64())
.map(|f| f == settings.frequency)
.unwrap_or(false);
Ok(serde_json::json!({
"applied": true,
"confirmed": confirmed,
"live": live,
"message": if confirmed {
"The radio confirmed it is now using the applied settings."
} else {
"Settings applied and the daemon restarted; the radio has not \
confirmed the new values yet — recheck in a few seconds."
},
}))
}
None => Ok(serde_json::json!({
"applied": true,
"confirmed": false,
"live": null,
"message": "Settings applied and the daemon restarted, but it has not \
reported the radio state yet — recheck in a few seconds.",
})),
}
}
/// mesh.configure — Enable/disable mesh and set device path.
+13 -1
View File
@@ -148,9 +148,21 @@ pub enum MeshCommand {
},
SendAdvert,
/// Reboot the locally-connected radio firmware to recover a wedged /
/// RX-deaf radio. Meshtastic-only; meshcore ignores it.
/// RX-deaf radio. Meshtastic: firmware reboot command. Reticulum: the
/// sidecar daemon is restarted (radio re-detected + reconfigured).
/// MeshCore: unsupported, and says so. `reply` (when present) carries
/// the real outcome to the RPC caller — the buttons used to be
/// fire-and-forget `warn!`s, i.e. no feedback ever reached the UI
/// (operator, 2026-08-06).
RebootRadio {
seconds: i64,
reply: Option<tokio::sync::oneshot::Sender<Result<String, String>>>,
},
/// Query the live RNode radio state (Reticulum-only): the sidecar's
/// radio-confirmed parameters, for the LoRa settings panel's current
/// values + apply read-back.
QueryRadioState {
reply: tokio::sync::oneshot::Sender<Result<serde_json::Value, String>>,
},
/// Re-fetch contact list from the radio device.
RefreshContacts,
+45 -11
View File
@@ -165,13 +165,41 @@ impl MeshRadioDevice {
}
}
async fn reboot(&mut self, seconds: i64) -> Result<()> {
async fn reboot(&mut self, seconds: i64) -> Result<String> {
match self {
// Meshcore/Reticulum have no equivalent local-admin reboot in our
// driver; the RX-deaf recovery this targets is Meshtastic-specific.
Self::Meshcore(_) => Ok(()),
Self::Meshtastic(device) => device.reboot(seconds).await,
Self::Reticulum(_) => Ok(()),
// No remote reboot in the MeshCore serial protocol — say so
// instead of silently reporting success (the old `Ok(())` here
// is why the button "did nothing" for the operator).
Self::Meshcore(_) => {
anyhow::bail!("MeshCore radios have no remote reboot — power-cycle the device")
}
Self::Meshtastic(device) => {
device.reboot(seconds).await?;
Ok(format!(
"Radio firmware reboots in {seconds}s and reconnects automatically"
))
}
// Restarting the sidecar drops the serial port, re-detects the
// RNode and reapplies the RF config — the closest thing to a
// reboot the RNS stack has, and exactly what an operator wants
// after changing settings or on a wedged radio.
Self::Reticulum(device) => {
device.restart_daemon().await?;
Ok("Radio daemon restarting — the RNode re-detects and reconnects in about 15 seconds".to_string())
}
}
}
/// Live RNode radio state — Reticulum-only (see ReticulumLink::query_radio_state).
async fn radio_state(&mut self) -> Result<serde_json::Value> {
match self {
Self::Meshcore(_) | Self::Meshtastic(_) => {
anyhow::bail!("Radio state read-back is only available for Reticulum RNode devices")
}
Self::Reticulum(device) => device
.query_radio_state(std::time::Duration::from_secs(5))
.await
.ok_or_else(|| anyhow::anyhow!("The radio daemon did not answer the state query")),
}
}
@@ -1549,12 +1577,18 @@ async fn handle_send_command(
warn!("Failed to send NodeInfo advert: {}", e);
}
}
MeshCommand::RebootRadio { seconds } => {
if let Err(e) = device.reboot(seconds).await {
warn!("Failed to reboot radio: {}", e);
} else {
info!(seconds, "Radio reboot command sent to device");
MeshCommand::RebootRadio { seconds, reply } => {
let outcome = device.reboot(seconds).await;
match &outcome {
Err(e) => warn!("Failed to reboot radio: {}", e),
Ok(_) => info!(seconds, "Radio reboot command sent to device"),
}
if let Some(reply) = reply {
let _ = reply.send(outcome.map_err(|e| format!("{e:#}")));
}
}
MeshCommand::QueryRadioState { reply } => {
let _ = reply.send(device.radio_state().await.map_err(|e| format!("{e:#}")));
}
MeshCommand::RefreshContacts => {
refresh_contacts(device, state).await;
+37 -3
View File
@@ -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;
@@ -2123,20 +2124,53 @@ impl MeshService {
/// RX-deaf radio (one that has stopped hearing the mesh while still able to
/// transmit). The device reconnects via the listener's reboot→reconnect
/// loop. `seconds` is the firmware reboot delay.
pub async fn reboot_radio(&self, seconds: i64) -> Result<()> {
pub async fn reboot_radio(&self, seconds: i64) -> Result<String> {
let status = self.state.status.read().await;
if !status.device_connected {
anyhow::bail!("No mesh device connected. Check USB connection.");
}
drop(status);
let (tx, rx) = tokio::sync::oneshot::channel();
self.state
.send_cmd(listener::MeshCommand::RebootRadio { seconds })
.send_cmd(listener::MeshCommand::RebootRadio {
seconds,
reply: Some(tx),
})
.await
.map_err(|_| anyhow::anyhow!("Mesh listener not running"))?;
// The real outcome, not fire-and-forget: the UI shows this string
// (or the error) instead of pretending success.
let outcome = tokio::time::timeout(std::time::Duration::from_secs(15), rx)
.await
.map_err(|_| anyhow::anyhow!("The radio did not acknowledge the reboot in time"))?
.map_err(|_| anyhow::anyhow!("Mesh session ended before the reboot completed"))?;
let message = outcome.map_err(|e| anyhow::anyhow!(e))?;
info!(seconds, "Mesh radio reboot triggered");
Ok(())
Ok(message)
}
/// Live RNode radio state (Reticulum-only): the sidecar's view of the
/// interface including the radio-confirmed r_* parameters. The LoRa
/// settings panel's source for "what is the device actually running".
pub async fn radio_state(&self) -> Result<serde_json::Value> {
let status = self.state.status.read().await;
if !status.device_connected {
anyhow::bail!("No mesh device connected. Check USB connection.");
}
drop(status);
let (tx, rx) = tokio::sync::oneshot::channel();
self.state
.send_cmd(listener::MeshCommand::QueryRadioState { reply: tx })
.await
.map_err(|_| anyhow::anyhow!("Mesh listener not running"))?;
let state = tokio::time::timeout(std::time::Duration::from_secs(10), rx)
.await
.map_err(|_| anyhow::anyhow!("The radio daemon did not answer the state query"))?
.map_err(|_| anyhow::anyhow!("Mesh session ended before the state query completed"))?;
state.map_err(|e| anyhow::anyhow!(e))
}
/// Current mesh-AI assistant settings (issue #50).
+86
View File
@@ -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,50 @@ impl ReticulumLink {
}
}
/// Restart the sidecar daemon: ask it to shut down cleanly and mark the
/// link dead so the session loop tears down and the outer reconnect loop
/// respawns it — re-detecting the RNode and reapplying the RF config
/// from the (possibly just-edited) persisted settings. This IS the
/// "reboot device" semantic for Reticulum radios, and the apply step of
/// the .126 LoRa settings panel.
pub async fn restart_daemon(&mut self) -> Result<()> {
// Best-effort clean shutdown (lets PyInstaller clear its _MEI dir);
// the SIGTERM path in Drop/terminate covers an already-dead socket.
let _ = self.send_rpc(serde_json::json!({"cmd": "shutdown"})).await;
self.daemon_gone = true;
Ok(())
}
/// 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")
+360
View File
@@ -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 1371020 MHz, sf 512,
/// cr 58, txpower 022 dBm, airtime locks 0100 %.
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 512", self.spreading_factor);
}
if !(5..=8).contains(&self.coding_rate) {
bail!("coding rate {} is outside 58", 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 (0100)");
}
}
}
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");
}
}
}
File diff suppressed because it is too large Load Diff
+26 -1
View File
@@ -863,12 +863,35 @@ export const useMeshStore = defineStore('mesh', () => {
}
async function rebootRadio(seconds = 2) {
return rpcClient.call<{ reboot: boolean; seconds: number }>({
// Long timeout: Reticulum reboots restart the sidecar daemon and the
// backend waits for the acknowledgement instead of fire-and-forgetting.
return rpcClient.call<{ reboot: boolean; seconds: number; message?: string }>({
method: 'mesh.reboot-radio',
params: { seconds },
timeout: 30000,
})
}
/** Persisted RNode RF settings + live radio-confirmed state (Reticulum). */
async function getRnodeConfig() {
return rpcClient.call<{
settings: Record<string, unknown>
live: Record<string, unknown> | null
live_error: string | null
}>({ method: 'mesh.rnode-config', timeout: 20000 })
}
/** Apply RNode RF settings: persists, restarts the radio daemon, waits for
* the radio's own read-back confirmation (up to ~50s). */
async function applyRnodeConfig(settings: Record<string, unknown>) {
return rpcClient.call<{
applied: boolean
confirmed?: boolean
live?: Record<string, unknown> | null
message: string
}>({ method: 'mesh.rnode-config-apply', params: { settings }, timeout: 70000 })
}
async function getOutbox() {
try {
return await rpcClient.call<{ count: number; messages?: unknown[] }>({ method: 'mesh.outbox' })
@@ -1155,6 +1178,8 @@ export const useMeshStore = defineStore('mesh', () => {
sendReply,
sendReaction,
rebootRadio,
getRnodeConfig,
applyRnodeConfig,
getOutbox,
sendReadReceipt,
forwardMessage,
+225 -2
View File
@@ -7,12 +7,17 @@ const mesh = useMeshStore()
const rebooting = ref(false)
const rebootError = ref<string | null>(null)
const rebootMessage = ref<string | null>(null)
async function handleReboot() {
rebooting.value = true
rebootError.value = null
rebootMessage.value = null
try {
await mesh.rebootRadio()
const res = await mesh.rebootRadio()
// The backend now waits for the device's acknowledgement and says what
// actually happened show it instead of silently going idle again.
rebootMessage.value = res.message || 'Reboot command acknowledged by the radio.'
} catch (e) {
rebootError.value = e instanceof Error ? e.message : 'Failed to reboot radio'
} finally {
@@ -20,6 +25,121 @@ async function handleReboot() {
}
}
// RNode (Reticulum) RF settings full round-trip with device read-back
// Recommended plans per region for Reticulum RNode radios. EU868 is the
// operator-validated Portugal plan (869.4625 MHz keeps clear of the default
// community channel while staying in the 10%-duty 869.4869.65 sub-band;
// airtime locks match EU duty-cycle law). Others use the RNS community
// conventions for the band with the region's legal power cap.
const RNODE_REGION_PLANS: Record<string, { frequency: number; bandwidth: number; spreading_factor: number; coding_rate: number; txpower: number; airtime_limit_short: number | null; airtime_limit_long: number | null }> = {
EU868: { frequency: 869462500, bandwidth: 125000, spreading_factor: 8, coding_rate: 5, txpower: 14, airtime_limit_short: 25, airtime_limit_long: 10 },
US915: { frequency: 914875000, bandwidth: 125000, spreading_factor: 8, coding_rate: 5, txpower: 17, airtime_limit_short: null, airtime_limit_long: null },
AU915: { frequency: 916800000, bandwidth: 125000, spreading_factor: 8, coding_rate: 5, txpower: 17, airtime_limit_short: null, airtime_limit_long: null },
ANZ: { frequency: 916800000, bandwidth: 125000, spreading_factor: 8, coding_rate: 5, txpower: 17, airtime_limit_short: null, airtime_limit_long: null },
AS923: { frequency: 923200000, bandwidth: 125000, spreading_factor: 8, coding_rate: 5, txpower: 13, airtime_limit_short: null, airtime_limit_long: null },
IN865: { frequency: 866000000, bandwidth: 125000, spreading_factor: 8, coding_rate: 5, txpower: 17, airtime_limit_short: null, airtime_limit_long: null },
}
const rnodeForm = ref({
enabled: true,
port: '',
frequency: '',
bandwidth: '125000',
spreading_factor: '8',
coding_rate: '5',
txpower: '17',
airtime_limit_short: '',
airtime_limit_long: '',
})
const rnodeLive = ref<Record<string, unknown> | null>(null)
const rnodeLiveError = ref<string | null>(null)
const rnodeLoading = ref(false)
const rnodeApplying = ref(false)
const rnodeResult = ref<{ ok: boolean; confirmed: boolean; message: string } | null>(null)
let rnodeSeeded = false
const rnodeRegionPlan = computed(() => (form.value.region ? RNODE_REGION_PLANS[form.value.region] : undefined))
function setRnodeRecommendedForRegion() {
const plan = rnodeRegionPlan.value
if (!plan) return
rnodeForm.value.frequency = String(plan.frequency)
rnodeForm.value.bandwidth = String(plan.bandwidth)
rnodeForm.value.spreading_factor = String(plan.spreading_factor)
rnodeForm.value.coding_rate = String(plan.coding_rate)
rnodeForm.value.txpower = String(plan.txpower)
rnodeForm.value.airtime_limit_short = plan.airtime_limit_short != null ? String(plan.airtime_limit_short) : ''
rnodeForm.value.airtime_limit_long = plan.airtime_limit_long != null ? String(plan.airtime_limit_long) : ''
}
async function loadRnodeConfig() {
rnodeLoading.value = true
try {
const res = await mesh.getRnodeConfig()
rnodeLive.value = res.live
rnodeLiveError.value = res.live_error
const s = res.settings as Record<string, unknown>
if (!rnodeSeeded && s) {
rnodeSeeded = true
rnodeForm.value.enabled = s.enabled !== false
rnodeForm.value.port = (s.port as string) ?? ''
rnodeForm.value.frequency = String(s.frequency ?? '')
rnodeForm.value.bandwidth = String(s.bandwidth ?? '125000')
rnodeForm.value.spreading_factor = String(s.spreading_factor ?? '8')
rnodeForm.value.coding_rate = String(s.coding_rate ?? '5')
rnodeForm.value.txpower = String(s.txpower ?? '17')
rnodeForm.value.airtime_limit_short = s.airtime_limit_short != null ? String(s.airtime_limit_short) : ''
rnodeForm.value.airtime_limit_long = s.airtime_limit_long != null ? String(s.airtime_limit_long) : ''
}
} catch (e) {
rnodeLiveError.value = e instanceof Error ? e.message : 'Could not load RNode settings'
} finally {
rnodeLoading.value = false
}
}
async function applyRnodeSettings() {
rnodeApplying.value = true
rnodeResult.value = null
try {
const res = await mesh.applyRnodeConfig({
enabled: rnodeForm.value.enabled,
port: rnodeForm.value.port.trim() || null,
frequency: Number(rnodeForm.value.frequency),
bandwidth: Number(rnodeForm.value.bandwidth),
spreading_factor: Number(rnodeForm.value.spreading_factor),
coding_rate: Number(rnodeForm.value.coding_rate),
txpower: Number(rnodeForm.value.txpower),
airtime_limit_short: rnodeForm.value.airtime_limit_short === '' ? null : Number(rnodeForm.value.airtime_limit_short),
airtime_limit_long: rnodeForm.value.airtime_limit_long === '' ? null : Number(rnodeForm.value.airtime_limit_long),
})
rnodeResult.value = { ok: res.applied, confirmed: !!res.confirmed, message: res.message }
if (res.live) rnodeLive.value = res.live
} catch (e) {
rnodeResult.value = { ok: false, confirmed: false, message: e instanceof Error ? e.message : 'Apply failed' }
} finally {
rnodeApplying.value = false
}
}
async function refreshRnodeLive() {
rnodeLoading.value = true
try {
const res = await mesh.getRnodeConfig()
rnodeLive.value = res.live
rnodeLiveError.value = res.live_error
} catch (e) {
rnodeLiveError.value = e instanceof Error ? e.message : 'Could not read the radio state'
} finally {
rnodeLoading.value = false
}
}
function fmtMhz(v: unknown): string {
const n = Number(v)
return Number.isFinite(n) && n > 0 ? `${(n / 1e6).toFixed(4)} MHz` : '—'
}
// Editable settings (persisted via mesh.configure)
const form = ref({
region: '',
@@ -157,6 +277,18 @@ async function saveSettings() {
saving.value = false
}
}
// Load the RNode settings + live state as soon as the panel knows a
// Reticulum radio is (or is pinned as) the device. Declared LAST: with
// `immediate: true` the source getter runs at setup, and `effectiveKind`
// must already exist (the SendBitcoinModal TDZ-crash lesson).
watch(
() => effectiveKind.value,
(kind) => {
if (kind === 'reticulum') void loadRnodeConfig()
},
{ immediate: true },
)
</script>
<template>
@@ -202,7 +334,7 @@ async function saveSettings() {
Program the radio's RF settings with the fields below — every radio on your mesh must match{{ selectedRegion ? ` (${selectedRegion.band} MHz band)` : '' }}.
</p>
<p v-else-if="effectiveKind === 'reticulum'" class="text-[11px] text-sky-300/80 mt-1">
RNode RF parameters are managed by the Reticulum daemon's interface config on this node.
Pick your region, then use "Set recommended for region" in the RNode section below.
</p>
</div>
<div>
@@ -271,6 +403,96 @@ async function saveSettings() {
Saved settings program the radio on its next connect (it reboots once to apply). Leave all four empty to keep the radio's own settings.
</p>
</div>
<!-- RNode (Reticulum) RF settings: the device's CURRENT values shown
first (radio-confirmed read-back), then every parameter editable,
with apply device confirmation. Actions stack in a column. -->
<div v-if="effectiveKind === 'reticulum'" class="mt-4">
<div class="flex items-center justify-between mb-2">
<h5 class="text-xs font-semibold text-white/80">RNode radio current device settings</h5>
<button class="text-[11px] text-sky-300/80 hover:text-sky-200 disabled:opacity-50" :disabled="rnodeLoading" @click="refreshRnodeLive">
{{ rnodeLoading ? 'Reading…' : 'Refresh' }}
</button>
</div>
<div v-if="rnodeLive" class="rounded-lg bg-white/[0.04] border border-white/10 p-3 mb-3 grid grid-cols-2 sm:grid-cols-4 gap-2 text-xs">
<div><span class="text-white/40 block">Status</span><span :class="rnodeLive.online ? 'text-green-400' : 'text-amber-400'">{{ rnodeLive.online ? 'Online' : 'Detected, not online' }}</span></div>
<div><span class="text-white/40 block">Port</span><span class="text-white/80">{{ rnodeLive.port || '—' }}</span></div>
<div><span class="text-white/40 block">Frequency</span><span class="text-white/80">{{ fmtMhz(rnodeLive.r_frequency ?? rnodeLive.frequency) }}</span></div>
<div><span class="text-white/40 block">Bandwidth</span><span class="text-white/80">{{ rnodeLive.r_bandwidth ?? rnodeLive.bandwidth ?? '—' }} Hz</span></div>
<div><span class="text-white/40 block">Spreading</span><span class="text-white/80">SF {{ rnodeLive.r_spreadingfactor ?? rnodeLive.spreadingfactor ?? '—' }}</span></div>
<div><span class="text-white/40 block">Coding rate</span><span class="text-white/80">4/{{ rnodeLive.r_codingrate ?? rnodeLive.codingrate ?? '—' }}</span></div>
<div><span class="text-white/40 block">TX power</span><span class="text-white/80">{{ rnodeLive.r_txpower ?? rnodeLive.txpower ?? '—' }} dBm</span></div>
<div><span class="text-white/40 block">Airtime limits</span><span class="text-white/80">{{ rnodeLive.r_airtime_limit_short ?? rnodeLive.airtime_limit_short ?? '—' }}% / {{ rnodeLive.r_airtime_limit_long ?? rnodeLive.airtime_limit_long ?? '' }}%</span></div>
</div>
<p v-else-if="rnodeLiveError" class="text-[11px] text-amber-400/80 mb-3">{{ rnodeLiveError }}</p>
<h5 class="text-xs font-semibold text-white/80 mb-2">RNode RF parameters</h5>
<div class="grid gap-3 grid-cols-2 sm:grid-cols-4">
<div>
<label class="block text-xs text-white/60 mb-1">Frequency (Hz)</label>
<input v-model="rnodeForm.frequency" inputmode="numeric" placeholder="869462500" class="w-full rounded-lg bg-white/[0.06] border border-white/10 text-white px-3 py-2 text-sm focus:outline-none focus:border-orange-400/60" />
</div>
<div>
<label class="block text-xs text-white/60 mb-1">Bandwidth (Hz)</label>
<select v-model="rnodeForm.bandwidth" class="w-full rounded-lg bg-white/[0.06] border border-white/10 text-white px-3 py-2 text-sm focus:outline-none focus:border-orange-400/60">
<option v-for="bw in ['7800','10400','15600','20800','31250','41700','62500','125000','250000','500000']" :key="bw" :value="bw">{{ bw }}</option>
</select>
</div>
<div>
<label class="block text-xs text-white/60 mb-1">Spreading factor</label>
<select v-model="rnodeForm.spreading_factor" class="w-full rounded-lg bg-white/[0.06] border border-white/10 text-white px-3 py-2 text-sm focus:outline-none focus:border-orange-400/60">
<option v-for="sf in [5,6,7,8,9,10,11,12]" :key="sf" :value="String(sf)">SF {{ sf }}</option>
</select>
</div>
<div>
<label class="block text-xs text-white/60 mb-1">Coding rate</label>
<select v-model="rnodeForm.coding_rate" class="w-full rounded-lg bg-white/[0.06] border border-white/10 text-white px-3 py-2 text-sm focus:outline-none focus:border-orange-400/60">
<option v-for="cr in [5,6,7,8]" :key="cr" :value="String(cr)">4/{{ cr }}</option>
</select>
</div>
<div>
<label class="block text-xs text-white/60 mb-1">TX power (dBm)</label>
<input v-model="rnodeForm.txpower" inputmode="numeric" placeholder="14" class="w-full rounded-lg bg-white/[0.06] border border-white/10 text-white px-3 py-2 text-sm focus:outline-none focus:border-orange-400/60" />
</div>
<div>
<label class="block text-xs text-white/60 mb-1">Airtime short (%)</label>
<input v-model="rnodeForm.airtime_limit_short" inputmode="decimal" placeholder="25" class="w-full rounded-lg bg-white/[0.06] border border-white/10 text-white px-3 py-2 text-sm focus:outline-none focus:border-orange-400/60" />
</div>
<div>
<label class="block text-xs text-white/60 mb-1">Airtime long (%)</label>
<input v-model="rnodeForm.airtime_limit_long" inputmode="decimal" placeholder="10" class="w-full rounded-lg bg-white/[0.06] border border-white/10 text-white px-3 py-2 text-sm focus:outline-none focus:border-orange-400/60" />
</div>
<div>
<label class="block text-xs text-white/60 mb-1">Serial port</label>
<input v-model="rnodeForm.port" placeholder="auto-detect" class="w-full rounded-lg bg-white/[0.06] border border-white/10 text-white px-3 py-2 text-sm focus:outline-none focus:border-orange-400/60" />
</div>
</div>
<label class="flex items-center gap-2 mt-3 text-sm text-white/80 cursor-pointer">
<input v-model="rnodeForm.enabled" type="checkbox" class="h-4 w-4 accent-orange-500" />
RNode interface enabled
</label>
<!-- Actions: stacked in a column on purpose (operator layout request) -->
<div class="flex flex-col gap-2 mt-4 max-w-sm">
<button
class="glass-button px-4 py-2 rounded-lg text-sm font-medium disabled:opacity-50"
:disabled="!rnodeRegionPlan || rnodeApplying"
@click="setRnodeRecommendedForRegion"
>
{{ rnodeRegionPlan ? `Set recommended for ${form.region}` : 'Pick a region above first' }}
</button>
<button
class="glass-button glass-button-warning px-4 py-2 rounded-lg text-sm font-medium disabled:opacity-50"
:disabled="rnodeApplying"
@click="applyRnodeSettings"
>
{{ rnodeApplying ? 'Applying — waiting for the radio to confirm…' : 'Apply & Confirm on Device' }}
</button>
</div>
<p v-if="rnodeResult" class="text-xs mt-2" :class="rnodeResult.ok && rnodeResult.confirmed ? 'text-green-400' : rnodeResult.ok ? 'text-amber-400' : 'text-red-400'">
<template v-if="rnodeResult.ok && rnodeResult.confirmed"> </template>{{ rnodeResult.message }}
</p>
</div>
<label class="flex items-center gap-2 mt-3 text-sm text-white/80 cursor-pointer">
<input v-model="form.broadcastIdentity" type="checkbox" class="h-4 w-4 accent-orange-500" />
Periodically broadcast this node's identity on the mesh
@@ -304,6 +526,7 @@ async function saveSettings() {
<template v-else>Reboot Radio</template>
</button>
<p class="mesh-device-reboot-hint">Use this if the device stops responding to sent messages or seems stuck.</p>
<p v-if="rebootMessage" class="text-xs text-green-400 mt-1">{{ rebootMessage }}</p>
<p v-if="rebootError" class="mesh-device-reboot-error">{{ rebootError }}</p>
</div>
</div>
@@ -289,6 +289,15 @@ defineExpose({ loadBackups })
(and 2FA code, if enabled). Only reveal it somewhere private anyone with these
words controls this node.
</p>
<a
href="/entropy/"
target="_blank"
rel="noopener"
class="inline-flex items-center gap-1 mt-2 text-sm text-orange-300/90 hover:text-orange-200 transition-colors"
>
How your seed &amp; keys work the full guide
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"/></svg>
</a>
</div>
<button
type="button"
+58
View File
@@ -108,6 +108,13 @@ def _write_rns_config(
f" spreadingfactor = {lora['spreadingfactor']}\n"
f" codingrate = {lora['codingrate']}\n"
)
# Regulatory duty-cycle limits (percent). Only written when set —
# absent keys keep RNS's own default (no software airtime lock),
# matching every daemon built before these args existed.
if lora.get("airtime_limit_short") is not None:
interfaces += f" airtime_limit_short = {lora['airtime_limit_short']}\n"
if lora.get("airtime_limit_long") is not None:
interfaces += f" airtime_limit_long = {lora['airtime_limit_long']}\n"
elif tcp_listen or tcp_connect:
parts = []
if tcp_listen:
@@ -188,6 +195,8 @@ class ReticulumDaemon:
"txpower": self.args.txpower,
"spreadingfactor": self.args.spreadingfactor,
"codingrate": self.args.codingrate,
"airtime_limit_short": self.args.airtime_limit_short,
"airtime_limit_long": self.args.airtime_limit_long,
},
no_radio=self.args.no_radio,
tcp_listen=self.args.tcp_listen,
@@ -358,6 +367,8 @@ class ReticulumDaemon:
self.announce()
elif cmd == "status":
self._broadcast(self._status())
elif cmd == "radio_state":
self._broadcast(self._radio_state())
elif cmd == "send_resource":
self._send_resource(req)
elif cmd == "shutdown":
@@ -374,6 +385,48 @@ class ReticulumDaemon:
return {"event": "status", "connected": self.router is not None,
"dest_hash": self.dest_hash_hex, "interfaces": ifaces}
def _radio_state(self) -> dict:
"""Radio-confirmed RNode parameters, straight from the live
RNodeInterface object. The r_* attributes are what the RADIO reported
after detect/configure (RNS/Interfaces/RNodeInterface.py) this is
the read-back the settings panel shows as proof the device is
actually using the applied values, as opposed to what the config
asked for. Absent radio (TCP/no-radio builds) configured=False."""
state = {"event": "radio_state", "configured": False, "online": False}
try:
import RNS
for iface in list(RNS.Transport.interfaces):
if type(iface).__name__ != "RNodeInterface":
continue
state.update({
"configured": True,
"online": bool(getattr(iface, "online", False)),
"port": getattr(iface, "port", None),
# Requested (config) values…
"frequency": getattr(iface, "frequency", None),
"bandwidth": getattr(iface, "bandwidth", None),
"txpower": getattr(iface, "txpower", None),
"spreadingfactor": getattr(iface, "sf", None),
"codingrate": getattr(iface, "cr", None),
"airtime_limit_short": getattr(iface, "st_alock", None),
"airtime_limit_long": getattr(iface, "lt_alock", None),
# …and what the radio itself confirmed it is running.
"r_frequency": getattr(iface, "r_frequency", None),
"r_bandwidth": getattr(iface, "r_bandwidth", None),
"r_txpower": getattr(iface, "r_txpower", None),
"r_spreadingfactor": getattr(iface, "r_sf", None),
"r_codingrate": getattr(iface, "r_cr", None),
"r_airtime_limit_short": getattr(iface, "r_st_alock", None),
"r_airtime_limit_long": getattr(iface, "r_lt_alock", None),
# Live utilisation, when the interface tracks it.
"airtime_short": getattr(iface, "airtime_short", None),
"airtime_long": getattr(iface, "airtime_long", None),
})
break
except Exception:
pass
return state
def _send(self, req: dict):
import RNS
import LXMF
@@ -643,6 +696,11 @@ def _parse_args(argv):
p.add_argument("--txpower", type=int, default=17)
p.add_argument("--spreadingfactor", type=int, default=8)
p.add_argument("--codingrate", type=int, default=5)
# Regulatory duty-cycle locks (percent of airtime, e.g. EU868 short=25
# long=10). None (the default) writes no config line, so RNS applies no
# software airtime lock — identical to daemons built before these existed.
p.add_argument("--airtime-limit-short", type=float, default=None)
p.add_argument("--airtime-limit-long", type=float, default=None)
p.add_argument("--enable-transport", action="store_true",
help="run as an RNS transport node: relay traffic and rebroadcast "
"announces so nodes beyond direct RF range discover each other "
+45
View File
@@ -0,0 +1,45 @@
#!/usr/bin/env bash
# One-shot node-side repair: pull the current companion-UI manifests
# (session_passthrough on the gated ports) from the public repo, install
# them into every location the daemon reads, restart, and report.
#
# Run on a node:
# curl -sf https://source.archipelago-foundation.org/lfg2025/archy/raw/branch/main/scripts/fix-companion-manifests.sh | bash
#
# Idempotent and safe to re-run. Needs passwordless sudo (fleet default).
set -u
BASE="https://source.archipelago-foundation.org/lfg2025/archy/raw/branch/main/apps"
RUNTIME="/opt/archipelago/web-ui/archipelago-runtime/apps"
updated=0
for app in lnd-ui bitcoin-ui electrs-ui fips-ui; do
tmp="/tmp/${app}-manifest.yml"
if ! curl -sf --max-time 30 "$BASE/$app/manifest.yml" -o "$tmp"; then
echo "$app: download failed"; continue
fi
if ! grep -q session_passthrough "$tmp"; then
echo "$app: fetched file missing session_passthrough — refusing"; continue
fi
sudo cp "$tmp" "/opt/archipelago/apps/$app/manifest.yml" || { echo "$app: install failed"; continue; }
# The frontend's runtime payload is restored over /opt/archipelago/apps at
# every daemon boot on nodes that carry it — update it too or the fix
# reverts on the next restart.
if [ -d "$RUNTIME/$app" ]; then
sudo cp "$tmp" "$RUNTIME/$app/manifest.yml"
fi
echo "$app updated"
updated=$((updated + 1))
done
if [ "$updated" -eq 0 ]; then
echo "Nothing updated — not restarting."
exit 1
fi
sudo systemctl restart archipelago
echo "Daemon restarted; waiting for the gate…"
sleep 15
ip=$(hostname -I | tr ' ' '\n' | grep '^100\.' | head -1)
code=$(curl -s -o /dev/null -w '%{http_code}' --max-time 5 "http://$ip:18083/" 2>/dev/null)
echo "ext :18083 -> $code (401 = gate holds the port: CORRECT)"