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>
This commit is contained in:
archipelago
2026-08-06 08:43:39 -04:00
co-authored by Claude Fable 5
parent 4dd8bacd0e
commit 209a36e53c
6 changed files with 217 additions and 17 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;
+36 -3
View File
@@ -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<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).
+14
View File
@@ -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).