Compare commits
6
Commits
v1.7.125-alpha
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3cef1d09f4 | ||
|
|
45fa8b6c6f | ||
|
|
209a36e53c | ||
|
|
4dd8bacd0e | ||
|
|
2afeafc92e | ||
|
|
44522fc94a |
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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,
|
||||
|
||||
@@ -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.4–869.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 & 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"
|
||||
|
||||
@@ -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 "
|
||||
|
||||
Executable
+45
@@ -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)"
|
||||
Reference in New Issue
Block a user