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