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
+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).