From 209a36e53cc6bbdcd12d0b253e49f643ad601576 Mon Sep 17 00:00:00 2001 From: archipelago Date: Thu, 6 Aug 2026 08:43:39 -0400 Subject: [PATCH] feat(mesh): rnode-config RPCs + honest reboot feedback with reply channels MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- core/archipelago/src/api/rpc/dispatcher.rs | 2 + .../archipelago/src/api/rpc/mesh/messaging.rs | 109 +++++++++++++++++- core/archipelago/src/mesh/listener/mod.rs | 14 ++- core/archipelago/src/mesh/listener/session.rs | 56 +++++++-- core/archipelago/src/mesh/mod.rs | 39 ++++++- core/archipelago/src/mesh/reticulum.rs | 14 +++ 6 files changed, 217 insertions(+), 17 deletions(-) diff --git a/core/archipelago/src/api/rpc/dispatcher.rs b/core/archipelago/src/api/rpc/dispatcher.rs index 48e0dbfa..34435908 100644 --- a/core/archipelago/src/api/rpc/dispatcher.rs +++ b/core/archipelago/src/api/rpc/dispatcher.rs @@ -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, diff --git a/core/archipelago/src/api/rpc/mesh/messaging.rs b/core/archipelago/src/api/rpc/mesh/messaging.rs index 23facdc3..d66e529c 100644 --- a/core/archipelago/src/api/rpc/mesh/messaging.rs +++ b/core/archipelago/src/api/rpc/mesh/messaging.rs @@ -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 { + 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, + ) -> Result { + 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. diff --git a/core/archipelago/src/mesh/listener/mod.rs b/core/archipelago/src/mesh/listener/mod.rs index 55674d0d..f5637115 100644 --- a/core/archipelago/src/mesh/listener/mod.rs +++ b/core/archipelago/src/mesh/listener/mod.rs @@ -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>>, + }, + /// 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>, }, /// Re-fetch contact list from the radio device. RefreshContacts, diff --git a/core/archipelago/src/mesh/listener/session.rs b/core/archipelago/src/mesh/listener/session.rs index cca4b035..9589da7a 100644 --- a/core/archipelago/src/mesh/listener/session.rs +++ b/core/archipelago/src/mesh/listener/session.rs @@ -165,13 +165,41 @@ impl MeshRadioDevice { } } - async fn reboot(&mut self, seconds: i64) -> Result<()> { + async fn reboot(&mut self, seconds: i64) -> Result { 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 { + 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; diff --git a/core/archipelago/src/mesh/mod.rs b/core/archipelago/src/mesh/mod.rs index f60f27a3..629bae36 100644 --- a/core/archipelago/src/mesh/mod.rs +++ b/core/archipelago/src/mesh/mod.rs @@ -2124,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 { 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 { + 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). diff --git a/core/archipelago/src/mesh/reticulum.rs b/core/archipelago/src/mesh/reticulum.rs index 6099d904..9b7f3dfc 100644 --- a/core/archipelago/src/mesh/reticulum.rs +++ b/core/archipelago/src/mesh/reticulum.rs @@ -940,6 +940,20 @@ 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).