fix(mesh): setup modal re-fires on EVERY plug — dismissals key on plug time
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 <noreply@anthropic.com>
This commit is contained in:
parent
197e351880
commit
cad68eb941
@ -572,6 +572,12 @@ pub struct DetectedDeviceInfo {
|
||||
pub pid: Option<String>,
|
||||
pub product: Option<String>,
|
||||
pub manufacturer: Option<String>,
|
||||
/// 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<u64>,
|
||||
}
|
||||
|
||||
/// Like `detect_serial_devices`, but with USB metadata per port.
|
||||
@ -579,12 +585,19 @@ pub async fn detect_serial_devices_info() -> Vec<DetectedDeviceInfo> {
|
||||
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
|
||||
|
||||
@ -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<Set<string>>(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<Record<string, number>>(
|
||||
JSON.parse(localStorage.getItem(DETECT_DISMISS_KEY) || '{}') as Record<string, number>
|
||||
)
|
||||
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<string, number> = {}
|
||||
@ -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<MeshDeviceProbe> {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user