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.send-channel" => self.handle_mesh_send_channel(params).await,
|
||||||
"mesh.broadcast" => self.handle_mesh_broadcast().await,
|
"mesh.broadcast" => self.handle_mesh_broadcast().await,
|
||||||
"mesh.reboot-radio" => self.handle_mesh_reboot_radio(params).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.configure" => self.handle_mesh_configure(params).await,
|
||||||
"mesh.send-invoice" => self.handle_mesh_send_invoice(params).await,
|
"mesh.send-invoice" => self.handle_mesh_send_invoice(params).await,
|
||||||
"mesh.send-coordinate" => self.handle_mesh_send_coordinate(params).await,
|
"mesh.send-coordinate" => self.handle_mesh_send_coordinate(params).await,
|
||||||
|
|||||||
@@ -104,10 +104,115 @@ impl RpcHandler {
|
|||||||
.as_ref()
|
.as_ref()
|
||||||
.ok_or_else(|| anyhow::anyhow!("Mesh service not running. Enable mesh first."))?;
|
.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");
|
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.
|
/// mesh.configure — Enable/disable mesh and set device path.
|
||||||
|
|||||||
@@ -148,9 +148,21 @@ pub enum MeshCommand {
|
|||||||
},
|
},
|
||||||
SendAdvert,
|
SendAdvert,
|
||||||
/// Reboot the locally-connected radio firmware to recover a wedged /
|
/// 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 {
|
RebootRadio {
|
||||||
seconds: i64,
|
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.
|
/// Re-fetch contact list from the radio device.
|
||||||
RefreshContacts,
|
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 {
|
match self {
|
||||||
// Meshcore/Reticulum have no equivalent local-admin reboot in our
|
// No remote reboot in the MeshCore serial protocol — say so
|
||||||
// driver; the RX-deaf recovery this targets is Meshtastic-specific.
|
// instead of silently reporting success (the old `Ok(())` here
|
||||||
Self::Meshcore(_) => Ok(()),
|
// is why the button "did nothing" for the operator).
|
||||||
Self::Meshtastic(device) => device.reboot(seconds).await,
|
Self::Meshcore(_) => {
|
||||||
Self::Reticulum(_) => Ok(()),
|
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);
|
warn!("Failed to send NodeInfo advert: {}", e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
MeshCommand::RebootRadio { seconds } => {
|
MeshCommand::RebootRadio { seconds, reply } => {
|
||||||
if let Err(e) = device.reboot(seconds).await {
|
let outcome = device.reboot(seconds).await;
|
||||||
warn!("Failed to reboot radio: {}", e);
|
match &outcome {
|
||||||
} else {
|
Err(e) => warn!("Failed to reboot radio: {}", e),
|
||||||
info!(seconds, "Radio reboot command sent to device");
|
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 => {
|
MeshCommand::RefreshContacts => {
|
||||||
refresh_contacts(device, state).await;
|
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
|
/// RX-deaf radio (one that has stopped hearing the mesh while still able to
|
||||||
/// transmit). The device reconnects via the listener's reboot→reconnect
|
/// transmit). The device reconnects via the listener's reboot→reconnect
|
||||||
/// loop. `seconds` is the firmware reboot delay.
|
/// 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;
|
let status = self.state.status.read().await;
|
||||||
if !status.device_connected {
|
if !status.device_connected {
|
||||||
anyhow::bail!("No mesh device connected. Check USB connection.");
|
anyhow::bail!("No mesh device connected. Check USB connection.");
|
||||||
}
|
}
|
||||||
drop(status);
|
drop(status);
|
||||||
|
|
||||||
|
let (tx, rx) = tokio::sync::oneshot::channel();
|
||||||
self.state
|
self.state
|
||||||
.send_cmd(listener::MeshCommand::RebootRadio { seconds })
|
.send_cmd(listener::MeshCommand::RebootRadio {
|
||||||
|
seconds,
|
||||||
|
reply: Some(tx),
|
||||||
|
})
|
||||||
.await
|
.await
|
||||||
.map_err(|_| anyhow::anyhow!("Mesh listener not running"))?;
|
.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");
|
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).
|
/// 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
|
/// Ask the sidecar for the live RNode state and wait briefly for the
|
||||||
/// reply event. Returns the freshest `radio_state` payload, or `None`
|
/// reply event. Returns the freshest `radio_state` payload, or `None`
|
||||||
/// when the daemon didn't answer in time (dead daemon, no radio build).
|
/// when the daemon didn't answer in time (dead daemon, no radio build).
|
||||||
|
|||||||
Reference in New Issue
Block a user