fix(mesh): ride out the radio restart during apply — no false errors, no setup modal
Demo images / Build & push demo images (push) Successful in 3m54s
Demo images / Build & push demo images (push) Successful in 3m54s
Applying RF settings deliberately restarts the radio daemon (~15-20s). Two things treated that healthy, expected gap as a fault (operator, 2026-08-06): - radio_state was single-shot: a query landing inside the restart window reported "The radio daemon did not answer the state query" for a restart that was working correctly. It now retries for ~30s and says the radio is restarting while it waits. A real device-level refusal (not an RNode) still returns immediately. - The device-setup modal auto-opens for any detected-but-unconnected port, so the restart looked like a newly plugged stick and interrupted the apply. Apply and Reboot now suppress auto-detect for 90s via mesh.suppressDeviceDetect(). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
53753687a5
commit
f394c02055
@@ -2155,22 +2155,51 @@ impl MeshService {
|
|||||||
/// interface including the radio-confirmed r_* parameters. The LoRa
|
/// interface including the radio-confirmed r_* parameters. The LoRa
|
||||||
/// settings panel's source for "what is the device actually running".
|
/// settings panel's source for "what is the device actually running".
|
||||||
pub async fn radio_state(&self) -> Result<serde_json::Value> {
|
pub async fn radio_state(&self) -> Result<serde_json::Value> {
|
||||||
let status = self.state.status.read().await;
|
// Retry across a reconnect window. Applying settings deliberately
|
||||||
if !status.device_connected {
|
// restarts the radio daemon (~15s), and the session is legitimately
|
||||||
anyhow::bail!("No mesh device connected. Check USB connection.");
|
// absent while it comes back — a single-shot query inside that window
|
||||||
|
// reported "the daemon did not answer" for what is a healthy,
|
||||||
|
// in-progress restart (operator, 2026-08-06).
|
||||||
|
const ATTEMPTS: u32 = 6;
|
||||||
|
let mut last_err = anyhow::anyhow!("No mesh device connected. Check USB connection.");
|
||||||
|
for attempt in 0..ATTEMPTS {
|
||||||
|
if attempt > 0 {
|
||||||
|
tokio::time::sleep(std::time::Duration::from_secs(4)).await;
|
||||||
|
}
|
||||||
|
if !self.state.status.read().await.device_connected {
|
||||||
|
last_err = anyhow::anyhow!(
|
||||||
|
"The radio is not connected right now — if settings were just applied it \
|
||||||
|
is restarting and comes back within about 20 seconds."
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let (tx, rx) = tokio::sync::oneshot::channel();
|
||||||
|
if self
|
||||||
|
.state
|
||||||
|
.send_cmd(listener::MeshCommand::QueryRadioState { reply: tx })
|
||||||
|
.await
|
||||||
|
.is_err()
|
||||||
|
{
|
||||||
|
last_err = anyhow::anyhow!("Mesh listener not running");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
match tokio::time::timeout(std::time::Duration::from_secs(10), rx).await {
|
||||||
|
Ok(Ok(Ok(state))) => return Ok(state),
|
||||||
|
Ok(Ok(Err(e))) => {
|
||||||
|
// A real device-level refusal (e.g. not an RNode radio) —
|
||||||
|
// retrying cannot change it.
|
||||||
|
return Err(anyhow::anyhow!(e));
|
||||||
|
}
|
||||||
|
Ok(Err(_)) => {
|
||||||
|
last_err =
|
||||||
|
anyhow::anyhow!("Mesh session ended before the state query completed")
|
||||||
|
}
|
||||||
|
Err(_) => {
|
||||||
|
last_err = anyhow::anyhow!("The radio daemon did not answer the state query")
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
drop(status);
|
Err(last_err)
|
||||||
|
|
||||||
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).
|
||||||
|
|||||||
@@ -356,9 +356,19 @@ export const useMeshStore = defineStore('mesh', () => {
|
|||||||
// The modal waits for 2 sightings so it doesn't flash during the couple of
|
// The modal waits for 2 sightings so it doesn't flash during the couple of
|
||||||
// seconds an ordinary reconnect (same radio, transient blip) needs.
|
// seconds an ordinary reconnect (same radio, transient blip) needs.
|
||||||
const detectSightings = ref<Record<string, number>>({})
|
const detectSightings = ref<Record<string, number>>({})
|
||||||
|
/** Epoch-ms until which the device-setup modal must NOT auto-open: an
|
||||||
|
* operator-initiated radio restart (settings apply, Reboot Radio) takes
|
||||||
|
* the radio down for ~15-20s, and the modal treated that healthy,
|
||||||
|
* expected gap as "a new stick was plugged in" and interrupted the flow
|
||||||
|
* (operator, 2026-08-06). */
|
||||||
|
const suppressDetectUntil = ref(0)
|
||||||
|
function suppressDeviceDetect(ms = 90_000) {
|
||||||
|
suppressDetectUntil.value = Date.now() + ms
|
||||||
|
}
|
||||||
const undismissedDetectedDevices = computed(() => {
|
const undismissedDetectedDevices = computed(() => {
|
||||||
const s = status.value
|
const s = status.value
|
||||||
if (!s) return []
|
if (!s) return []
|
||||||
|
if (Date.now() < suppressDetectUntil.value) return []
|
||||||
return (s.detected_devices || []).filter(p =>
|
return (s.detected_devices || []).filter(p =>
|
||||||
dismissedDetected.value[p] !== pluggedAt(s, p) &&
|
dismissedDetected.value[p] !== pluggedAt(s, p) &&
|
||||||
// The port the live session occupies is not a candidate…
|
// The port the live session occupies is not a candidate…
|
||||||
@@ -1148,6 +1158,7 @@ export const useMeshStore = defineStore('mesh', () => {
|
|||||||
latestBlockHeight,
|
latestBlockHeight,
|
||||||
fetchStatus,
|
fetchStatus,
|
||||||
undismissedDetectedDevices,
|
undismissedDetectedDevices,
|
||||||
|
suppressDeviceDetect,
|
||||||
dismissDetectedDevice,
|
dismissDetectedDevice,
|
||||||
flashFlowPath,
|
flashFlowPath,
|
||||||
openFlashFlow,
|
openFlashFlow,
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ async function handleReboot() {
|
|||||||
rebooting.value = true
|
rebooting.value = true
|
||||||
rebootError.value = null
|
rebootError.value = null
|
||||||
rebootMessage.value = null
|
rebootMessage.value = null
|
||||||
|
// Same as apply: the radio goes away on purpose for ~15-20s.
|
||||||
|
mesh.suppressDeviceDetect()
|
||||||
try {
|
try {
|
||||||
const res = await mesh.rebootRadio()
|
const res = await mesh.rebootRadio()
|
||||||
// The backend now waits for the device's acknowledgement and says what
|
// The backend now waits for the device's acknowledgement and says what
|
||||||
@@ -101,6 +103,9 @@ async function loadRnodeConfig() {
|
|||||||
async function applyRnodeSettings() {
|
async function applyRnodeSettings() {
|
||||||
rnodeApplying.value = true
|
rnodeApplying.value = true
|
||||||
rnodeResult.value = null
|
rnodeResult.value = null
|
||||||
|
// Applying deliberately restarts the radio daemon; without this the
|
||||||
|
// "new device detected" modal interrupts the flow mid-apply.
|
||||||
|
mesh.suppressDeviceDetect()
|
||||||
try {
|
try {
|
||||||
const res = await mesh.applyRnodeConfig({
|
const res = await mesh.applyRnodeConfig({
|
||||||
enabled: rnodeForm.value.enabled,
|
enabled: rnodeForm.value.enabled,
|
||||||
|
|||||||
Reference in New Issue
Block a user