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:
co-authored by
Claude Fable 5
parent
4dd8bacd0e
commit
209a36e53c
@@ -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;
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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).
|
||||
|
||||
Reference in New Issue
Block a user