From cad68eb94192a8ae816aa43260bc1e1a22d308f5 Mon Sep 17 00:00:00 2001 From: archipelago Date: Wed, 22 Jul 2026 19:08:18 -0400 Subject: [PATCH] =?UTF-8?q?fix(mesh):=20setup=20modal=20re-fires=20on=20EV?= =?UTF-8?q?ERY=20plug=20=E2=80=94=20dismissals=20key=20on=20plug=20time?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit detected_device_info now carries plugged_at (the /dev node's creation time; udev recreates it per plug). Dismissals store (path -> plugged_at), so swapping sticks or a fast unplug/replug between status polls can no longer be suppressed by an old 'Not Now' (v1 path-only dismissals are abandoned wholesale). Co-Authored-By: Claude Fable 5 --- core/archipelago/src/mesh/serial.rs | 13 +++++++++ neode-ui/src/stores/mesh.ts | 43 +++++++++++++++++++---------- 2 files changed, 42 insertions(+), 14 deletions(-) diff --git a/core/archipelago/src/mesh/serial.rs b/core/archipelago/src/mesh/serial.rs index f87eb86d..8ce13f32 100644 --- a/core/archipelago/src/mesh/serial.rs +++ b/core/archipelago/src/mesh/serial.rs @@ -572,6 +572,12 @@ pub struct DetectedDeviceInfo { pub pid: Option, pub product: Option, pub manufacturer: Option, + /// Unix epoch seconds of the /dev node's creation — udev recreates the + /// node on every plug, so this changes on each replug. The UI keys its + /// "Not Now" dismissals on (path, plugged_at): swapping a stick (or + /// unplug/replug faster than a status poll) invalidates old dismissals + /// and the setup modal fires again, per the hot-swap UX (2026-07-22). + pub plugged_at: Option, } /// Like `detect_serial_devices`, but with USB metadata per port. @@ -579,12 +585,19 @@ pub async fn detect_serial_devices_info() -> Vec { let mut out = Vec::new(); for path in detect_serial_devices().await { let usb = usb_info_for_tty(&path).await; + let plugged_at = tokio::fs::metadata(&path) + .await + .ok() + .and_then(|m| m.modified().ok()) + .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok()) + .map(|d| d.as_secs()); out.push(DetectedDeviceInfo { path, vid: usb.0, pid: usb.1, product: usb.2, manufacturer: usb.3, + plugged_at, }); } out diff --git a/neode-ui/src/stores/mesh.ts b/neode-ui/src/stores/mesh.ts index 6439479d..6220667e 100644 --- a/neode-ui/src/stores/mesh.ts +++ b/neode-ui/src/stores/mesh.ts @@ -34,6 +34,8 @@ export interface MeshStatus { pid?: string | null product?: string | null manufacturer?: string | null + /** Epoch seconds the /dev node appeared — changes on every replug. */ + plugged_at?: number | null }> /** Hot-swap "keep as is": false = archipelago never writes config to the radio. */ manage_radio?: boolean @@ -288,10 +290,20 @@ export const useMeshStore = defineStore('mesh', () => { // Dismissals are cleared the moment the port disappears (unplug), so the // modal re-appears on EVERY plug-in — including swapping a different stick // into the same /dev path — per the hot-swap UX (2026-07-22). - const DETECT_DISMISS_KEY = 'archipelago.mesh.detect-dismissed.v1' - const dismissedDetectedPaths = ref>(new Set( - JSON.parse(localStorage.getItem(DETECT_DISMISS_KEY) || '[]') as string[] - )) + // v2: dismissals key on (path → plugged_at). udev recreates the /dev node + // on every plug, so plugged_at changes on each replug — an old "Not Now" + // can never suppress a NEWLY plugged stick, even when the swap happens + // faster than a status poll (which absence-pruning alone missed) or when + // the same /dev path is reused. v1 (a bare path set) is intentionally + // abandoned: stale one-time dismissals from before 2026-07-22 shouldn't + // suppress anything either. + const DETECT_DISMISS_KEY = 'archipelago.mesh.detect-dismissed.v2' + const dismissedDetected = ref>( + JSON.parse(localStorage.getItem(DETECT_DISMISS_KEY) || '{}') as Record + ) + function pluggedAt(s: MeshStatus, path: string): number { + return s.detected_device_info?.find(d => d.path === path)?.plugged_at ?? 0 + } // Consecutive polls each candidate port has been present-but-not-connected. // The modal waits for 2 sightings so it doesn't flash during the couple of // seconds an ordinary reconnect (same radio, transient blip) needs. @@ -300,15 +312,15 @@ export const useMeshStore = defineStore('mesh', () => { const s = status.value if (!s) return [] return (s.detected_devices || []).filter(p => - !dismissedDetectedPaths.value.has(p) && + dismissedDetected.value[p] !== pluggedAt(s, p) && // The port the live session occupies is not a candidate… !(s.device_connected && s.device_path === p) && // …and a port only qualifies once it has survived the blip debounce. (detectSightings.value[p] ?? 0) >= 2 ) }) - /** Called after every status fetch: advance sighting counters and clear - * dismissals/counters for ports that vanished (unplug → replug reshows). */ + /** Called after every status fetch: advance sighting counters and drop + * dismissals for ports that vanished (belt-and-braces with plugged_at). */ function trackDetectedDevices(s: MeshStatus) { const present = new Set(s.detected_devices || []) const next: Record = {} @@ -319,21 +331,24 @@ export const useMeshStore = defineStore('mesh', () => { } detectSightings.value = next let dirty = false - for (const p of [...dismissedDetectedPaths.value]) { + for (const p of Object.keys(dismissedDetected.value)) { if (!present.has(p)) { - dismissedDetectedPaths.value.delete(p) + delete dismissedDetected.value[p] dirty = true } } if (dirty) { - dismissedDetectedPaths.value = new Set(dismissedDetectedPaths.value) - localStorage.setItem(DETECT_DISMISS_KEY, JSON.stringify([...dismissedDetectedPaths.value])) + dismissedDetected.value = { ...dismissedDetected.value } + localStorage.setItem(DETECT_DISMISS_KEY, JSON.stringify(dismissedDetected.value)) } } function dismissDetectedDevice(path: string) { - dismissedDetectedPaths.value.add(path) - dismissedDetectedPaths.value = new Set(dismissedDetectedPaths.value) - localStorage.setItem(DETECT_DISMISS_KEY, JSON.stringify([...dismissedDetectedPaths.value])) + const s = status.value + dismissedDetected.value = { + ...dismissedDetected.value, + [path]: s ? pluggedAt(s, path) : 0, + } + localStorage.setItem(DETECT_DISMISS_KEY, JSON.stringify(dismissedDetected.value)) } /** Read-only firmware/config probe of a detected port (hot-swap modal). */ async function probeDevice(path: string): Promise {