feat(mesh): radio hot-swap — probe on plug-in, keep-as-is vs apply-our-settings
Demo images / Build & push demo images (push) Failing after 1m49s

Plugging in any LoRa radio now surfaces the device setup modal EVERY time
(dismissals clear on unplug; works while mesh is enabled; 2-poll debounce
so ordinary reconnect blips don't flash it), showing what's currently on
the stick before anything is written:

- mesh.probe-device RPC: identifies the firmware read-only in the same
  Reticulum->Meshcore->Meshtastic order as auto-detect. Meshtastic yields
  region/modem-preset/channels (want_config is a read); MeshCore yields
  name/node-id + firmware version via the previously-unused
  CMD_DEVICE_QUERY; RNode via the bare KISS DETECT (no daemon spawn).
  Path must be a detected candidate port; the live session's port refuses.
- "Keep As Is": manage_radio=false persists in MeshConfig and the session
  skips every on-connect config write (region, channel, PHY params, advert
  name) — the radio runs exactly as flashed, hot-swappable.
- "Set Up with Archipelago Settings": second screen shows the params that
  will be applied (channel, region, the node's persisted RF params - e.g.
  the validated Portugal preset - with the per-region community plan as
  fallback) before writing anything.
- Stale-type fix: disconnect now resets device_type/firmware_version, so
  a swapped stick no longer shows the previous radio's firmware; probed
  kind pins device_kind so the right probe runs first on connect.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
archipelago
2026-07-22 16:25:19 -04:00
co-authored by Claude Fable 5
parent 2f26cb2bc4
commit 8b744d377c
11 changed files with 550 additions and 93 deletions
@@ -1,14 +1,14 @@
<template>
<BaseModal
:show="show"
:title="step === 1 ? 'Mesh Device Detected' : 'Set Up Your Mesh Radio'"
:title="step === 1 ? 'Mesh Radio Detected' : 'Apply Archipelago Settings'"
max-width="max-w-lg"
content-class="max-h-[90vh] overflow-y-auto"
@close="dismiss"
>
<!-- Step 1: detection + hardware graphic -->
<!-- Step 1: detection graphic + what's currently on the radio -->
<div v-if="step === 1" class="text-center">
<div class="mx-auto my-2 w-44 h-44 relative">
<div class="mx-auto my-2 w-40 h-40 relative">
<!-- pulsing signal waves behind the board image -->
<svg viewBox="0 0 160 160" class="absolute inset-0 w-full h-full" aria-hidden="true">
<g class="mesh-detect-waves" stroke="rgba(251,146,60,0.5)" fill="none" stroke-width="2.5" stroke-linecap="round">
@@ -32,26 +32,96 @@
{{ deviceImage.exact ? 'Detected' : 'Detected LoRa radio' }} on
<span class="font-mono text-orange-300">{{ devicePath }}</span>
</p>
<p class="text-white/50 text-xs mt-2">
Connect it to join the off-grid mesh chat, block headers, and payments
keep flowing even without internet.
</p>
<div class="flex gap-2 mt-6">
<button class="flex-1 glass-button px-4 py-2 rounded-lg text-sm" @click="dismiss">
Not Now
<!-- What's currently flashed / configured on it -->
<div class="mt-4 text-left rounded-xl bg-white/[0.05] border border-white/10 p-3">
<div v-if="probing" class="flex items-center gap-2 text-white/60 text-sm py-1">
<span class="inline-block w-3.5 h-3.5 rounded-full border-2 border-orange-300/70 border-t-transparent animate-spin"></span>
Reading what's on the radio…
</div>
<template v-else-if="probe">
<div class="flex items-center gap-2">
<span class="px-2 py-0.5 rounded-md text-[11px] font-semibold"
:class="{
'bg-emerald-400/15 text-emerald-300': probe.kind === 'meshcore',
'bg-sky-400/15 text-sky-300': probe.kind === 'meshtastic',
'bg-violet-400/15 text-violet-300': probe.kind === 'reticulum',
}">{{ kindLabel }}</span>
<span v-if="probe.firmware_version" class="text-white/50 text-xs truncate">{{ probe.firmware_version }}</span>
</div>
<dl class="mt-2 grid grid-cols-[auto_1fr] gap-x-3 gap-y-1 text-xs">
<template v-if="probe.advert_name">
<dt class="text-white/40">Name</dt><dd class="text-white/80">{{ probe.advert_name }}</dd>
</template>
<template v-if="probe.node_id != null">
<dt class="text-white/40">Node ID</dt><dd class="text-white/80 font-mono">{{ probe.node_id }}</dd>
</template>
<template v-if="probe.region">
<dt class="text-white/40">Region</dt><dd class="text-white/80">{{ probe.region }}</dd>
</template>
<template v-if="probe.modem_preset">
<dt class="text-white/40">Preset</dt><dd class="text-white/80">{{ probe.modem_preset }}</dd>
</template>
<template v-if="probe.primary_channel">
<dt class="text-white/40">Channel</dt><dd class="text-white/80">{{ probe.primary_channel }}</dd>
</template>
<template v-if="probe.secondary_channel">
<dt class="text-white/40">2nd channel</dt><dd class="text-white/80">{{ probe.secondary_channel }}</dd>
</template>
</dl>
</template>
<p v-else class="text-white/50 text-xs py-1">
Couldn't identify the firmware on this radio{{ probeError ? ` (${probeError})` : '' }}
you can still set it up (auto-detect will keep trying) or use it as-is.
</p>
</div>
<div class="flex gap-2 mt-5">
<button
class="flex-1 glass-button px-4 py-2 rounded-lg text-sm disabled:opacity-50"
:disabled="!!connecting"
@click="keepAsIs"
>
{{ connecting === 'keep' ? 'Connecting…' : 'Keep As Is' }}
</button>
<button class="flex-1 glass-button glass-button-warning px-4 py-2 rounded-lg text-sm font-medium" @click="step = 2">
Set Up
<button
class="flex-1 glass-button glass-button-warning px-4 py-2 rounded-lg text-sm font-medium disabled:opacity-50"
:disabled="!!connecting"
@click="step = 2"
>
Set Up with Archipelago Settings
</button>
</div>
<p class="text-white/40 text-[11px] mt-3">
"Keep As Is" uses the radio exactly as it is nothing on it is changed,
and you can hot-swap radios any time.
</p>
<p v-if="error" class="text-xs text-red-400 mt-2">{{ error }}</p>
</div>
<!-- Step 2: configuration with presets -->
<!-- Step 2: our latest parameters, shown before anything is written -->
<div v-else>
<p class="text-white/60 text-xs mb-4">
Archipelago presets are pre-selected confirm the region and you're on the mesh.
<p class="text-white/60 text-xs mb-3">
These are the latest Archipelago settings nothing is written to the
radio until you confirm.
</p>
<!-- Summary of what will be applied -->
<div class="rounded-xl bg-white/[0.05] border border-white/10 p-3 mb-4">
<dl class="grid grid-cols-[auto_1fr] gap-x-3 gap-y-1 text-xs">
<dt class="text-white/40">Channel</dt>
<dd class="text-white/80">{{ form.channel || 'archipelago' }} <span class="text-white/40">(+ public default kept for interop)</span></dd>
<dt class="text-white/40">Name</dt>
<dd class="text-white/80">{{ form.name || '(radio keeps its name)' }}</dd>
<dt class="text-white/40">Region</dt>
<dd class="text-white/80">{{ form.region || "(radio keeps its region)" }}</dd>
<template v-if="effectiveKind === 'meshcore' && rfPreset">
<dt class="text-white/40">RF params</dt>
<dd class="text-white/80">{{ rfPreset.freqMhz }} MHz · {{ rfPreset.bwKhz }} kHz · SF{{ rfPreset.sf }} · CR4/{{ rfPreset.cr }}</dd>
</template>
</dl>
</div>
<div class="space-y-4">
<!-- Region the meaning adapts to the firmware: Meshtastic takes a
region enum directly; MeshCore/RNode take raw RF params owned by
@@ -68,11 +138,8 @@
<p v-if="selectedRegion && selectedRegion.dutyCyclePct < 100" class="text-[11px] text-amber-400/80 mt-1">
{{ selectedRegion.code }} limits airtime to {{ selectedRegion.dutyCyclePct }}% duty cycle ({{ selectedRegion.band }} MHz) the radio enforces this automatically.
</p>
<p v-if="effectiveKind === 'meshcore' && meshcorePlan" class="text-[11px] text-sky-300/80 mt-1">
MeshCore community plan for {{ selectedRegion?.code }}: {{ meshcorePlan.freqMhz }} MHz, {{ meshcorePlan.bwKhz }} kHz, SF{{ meshcorePlan.sf }}, CR4/{{ meshcorePlan.cr }} set on the radio via its MeshCore app if it differs.
</p>
<p v-else-if="effectiveKind === 'meshcore' && selectedRegion" class="text-[11px] text-sky-300/80 mt-1">
MeshCore radios keep their flashed RF settings make sure the radio is on the {{ selectedRegion.band }} MHz band for {{ selectedRegion.code }}.
<p v-if="effectiveKind === 'meshcore' && rfPreset" class="text-[11px] text-sky-300/80 mt-1">
MeshCore RF params for {{ selectedRegion?.code }} are applied to the radio automatically on connect.
</p>
<p v-if="effectiveKind === 'reticulum'" class="text-[11px] text-sky-300/80 mt-1">
RNode radio parameters (frequency / bandwidth / SF / CR) are managed by the Reticulum daemon's interface config on this node.
@@ -96,25 +163,6 @@
Archipelago nodes find each other on the "archipelago" channel; the public default channel stays active for interop.
</p>
</div>
<!-- Firmware (advanced) -->
<div>
<button class="text-xs text-orange-300 hover:text-orange-200" @click="showAdvanced = !showAdvanced">
{{ showAdvanced ? '' : '' }} Advanced
</button>
<div v-if="showAdvanced" class="mt-2">
<label class="block text-sm text-white/80 mb-1">Radio firmware</label>
<select v-model="form.deviceKind" class="w-full rounded-lg bg-white/[0.06] border border-white/10 text-white px-3 py-2 text-sm focus:outline-none focus:border-orange-400/60">
<option value="auto">Auto-detect (Meshcore Meshtastic RNode)</option>
<option value="meshcore">MeshCore</option>
<option value="meshtastic">Meshtastic</option>
<option value="reticulum">Reticulum / RNode</option>
</select>
<p class="text-[11px] text-white/40 mt-1">
Pin this if you know what's flashed on the board — auto-detect probes each firmware in order.
</p>
</div>
</div>
</div>
<p v-if="error" class="text-xs text-red-400 mt-3">{{ error }}</p>
@@ -123,10 +171,10 @@
<button class="glass-button px-4 py-2 rounded-lg text-sm" @click="step = 1">Back</button>
<button
class="flex-1 glass-button glass-button-warning px-4 py-2 rounded-lg text-sm font-medium disabled:opacity-50"
:disabled="connecting"
@click="connect"
:disabled="!!connecting"
@click="applySetup"
>
{{ connecting ? 'Connecting' : 'Connect to Mesh' }}
{{ connecting === 'setup' ? 'Applying…' : 'Apply Settings & Connect' }}
</button>
</div>
</div>
@@ -137,7 +185,7 @@
import { ref, computed, watch } from 'vue'
import { useRouter } from 'vue-router'
import BaseModal from '@/components/BaseModal.vue'
import { useMeshStore } from '@/stores/mesh'
import { useMeshStore, type MeshDeviceProbe, type MeshConfigureParams } from '@/stores/mesh'
import { useAppStore } from '@/stores/app'
import { LORA_REGIONS, regionByCode, suggestRegionFromLatLon, meshcorePlanFor } from '@/utils/loraRegions'
import { resolveMeshDeviceImage } from '@/utils/meshDeviceImages'
@@ -147,9 +195,11 @@ const appStore = useAppStore()
const router = useRouter()
const step = ref<1 | 2>(1)
const showAdvanced = ref(false)
const connecting = ref(false)
const connecting = ref<false | 'keep' | 'setup'>(false)
const error = ref('')
const probing = ref(false)
const probe = ref<MeshDeviceProbe | null>(null)
const probeError = ref('')
const devicePath = computed(() => mesh.undismissedDetectedDevices[0] ?? '')
const show = computed(() => !!devicePath.value)
@@ -160,6 +210,15 @@ const deviceImage = computed(() =>
)
)
const kindLabel = computed(() => {
switch (probe.value?.kind) {
case 'meshcore': return 'MeshCore'
case 'meshtastic': return 'Meshtastic'
case 'reticulum': return 'Reticulum RNode'
default: return ''
}
})
const suggestedRegion = computed(() => {
const info = appStore.serverInfo as { lat?: number | null; lon?: number | null } | undefined
if (info?.lat != null && info?.lon != null) {
@@ -172,52 +231,113 @@ const form = ref({
region: '',
name: '',
channel: 'archipelago',
deviceKind: 'auto',
})
const selectedRegion = computed(() => regionByCode(form.value.region))
// The firmware whose options we surface: the explicit pin wins, else the
// last detected/connected type, else meshtastic-style (where presets apply).
// The firmware whose options we surface: the probe result wins, else the
// last connected type, else meshtastic-style (where presets apply).
const effectiveKind = computed(() => {
if (form.value.deviceKind !== 'auto') return form.value.deviceKind
if (probe.value) return probe.value.kind
const t = (mesh.status?.device_type ?? '').toLowerCase()
return t === 'meshcore' || t === 'meshtastic' || t === 'reticulum' ? t : 'auto'
})
const meshcorePlan = computed(() => meshcorePlanFor(form.value.region))
// (Re)apply presets each time a new device surfaces the modal
watch(show, (visible) => {
if (visible) {
step.value = 1
error.value = ''
imageFailed.value = false
form.value.region = suggestedRegion.value?.code ?? mesh.status?.lora_region ?? ''
form.value.name = mesh.status?.self_advert_name ?? appStore.serverName ?? ''
form.value.channel = mesh.status?.channel_name || 'archipelago'
form.value.deviceKind = mesh.status?.device_kind ?? 'auto'
// The node's persisted RF params (e.g. the validated Portugal preset) ARE
// "our latest parameters" — the generic per-region community plan is only
// the fallback for nodes that never configured RF params.
const rfPreset = computed(() => {
const cfg = mesh.status?.lora_radio_params
if (cfg) {
return {
freqMhz: cfg.freq_khz / 1000,
bwKhz: cfg.bw_hz / 1000,
sf: cfg.sf,
cr: cfg.cr,
}
}
return meshcorePlanFor(form.value.region)
})
// (Re)probe + (re)apply presets each time a new device surfaces the modal
watch([show, devicePath], async ([visible]) => {
if (!visible) return
step.value = 1
error.value = ''
imageFailed.value = false
form.value.region = suggestedRegion.value?.code ?? mesh.status?.lora_region ?? ''
form.value.name = mesh.status?.self_advert_name ?? appStore.serverName ?? ''
form.value.channel = mesh.status?.channel_name || 'archipelago'
// Read-only probe of what's on the stick — drives the details card.
probe.value = null
probeError.value = ''
probing.value = true
const path = devicePath.value
try {
const res = await mesh.probeDevice(path)
if (devicePath.value === path) probe.value = res
} catch (e) {
if (devicePath.value === path) {
probeError.value = e instanceof Error ? e.message : String(e)
}
} finally {
if (devicePath.value === path) probing.value = false
}
}, { immediate: false })
function dismiss() {
if (devicePath.value) mesh.dismissDetectedDevice(devicePath.value)
}
async function connect() {
connecting.value = true
/** Use the radio exactly as flashed: no config writes, firmware pinned to
* what the probe saw (auto if the probe failed), hot-swappable from here. */
async function keepAsIs() {
connecting.value = 'keep'
error.value = ''
try {
const path = devicePath.value
await mesh.configure({
enabled: true,
device_path: path,
channel_name: form.value.channel.trim() || 'archipelago',
...(form.value.name.trim() ? { advert_name: form.value.name.trim() } : {}),
...(form.value.region ? { lora_region: form.value.region } : {}),
device_kind: form.value.deviceKind,
device_kind: probe.value?.kind ?? 'auto',
manage_radio: false,
})
mesh.dismissDetectedDevice(path)
// The Mesh view lives under the dashboard shell — a bare /mesh 404s.
void router.push('/dashboard/mesh')
} catch (e) {
error.value = e instanceof Error ? e.message : 'Failed to connect the mesh radio'
} finally {
connecting.value = false
}
}
/** Apply the Archipelago parameters shown on this screen, then connect. */
async function applySetup() {
connecting.value = 'setup'
error.value = ''
try {
const path = devicePath.value
const params: MeshConfigureParams = {
enabled: true,
device_path: path,
channel_name: form.value.channel.trim() || 'archipelago',
...(form.value.name.trim() ? { advert_name: form.value.name.trim() } : {}),
...(form.value.region ? { lora_region: form.value.region } : {}),
device_kind: probe.value?.kind ?? 'auto',
manage_radio: true,
}
// MeshCore radios get their RF params written directly (freq/bw/sf/cr in
// firmware units) — same conversion as the Mesh settings panel.
if (effectiveKind.value === 'meshcore' && rfPreset.value) {
params.lora_radio_params = {
freq_khz: Math.round(rfPreset.value.freqMhz * 1000),
bw_hz: Math.round(rfPreset.value.bwKhz * 1000),
sf: rfPreset.value.sf,
cr: rfPreset.value.cr,
}
}
await mesh.configure(params)
mesh.dismissDetectedDevice(path)
void router.push('/dashboard/mesh')
} catch (e) {
error.value = e instanceof Error ? e.message : 'Failed to configure the mesh radio'
} finally {
+68 -2
View File
@@ -35,6 +35,24 @@ export interface MeshStatus {
product?: string | null
manufacturer?: string | null
}>
/** Hot-swap "keep as is": false = archipelago never writes config to the radio. */
manage_radio?: boolean
/** Persisted config: MeshCore LoRa PHY params (firmware units). */
lora_radio_params?: { freq_khz: number; bw_hz: number; sf: number; cr: number } | null
}
/** What mesh.probe-device read off a just-plugged radio (no provisioning). */
export interface MeshDeviceProbe {
path: string
kind: 'reticulum' | 'meshcore' | 'meshtastic'
firmware_version: string | null
node_id: number | null
advert_name: string | null
region: string | null
modem_preset: string | null
primary_channel: string | null
secondary_channel: string | null
max_contacts: number | null
}
/** Params accepted by mesh.configure (superset of the status fields). */
@@ -51,6 +69,8 @@ export interface MeshConfigureParams {
/** MeshCore LoRa PHY params in firmware field units (freq_khz = MHz×1000,
* bw_hz = kHz×1000). null clears; omitted leaves unchanged. */
lora_radio_params?: { freq_khz: number; bw_hz: number; sf: number; cr: number } | null
/** Hot-swap "keep as is": false = use the radio exactly as flashed. */
manage_radio?: boolean
}
export interface MeshPeer {
@@ -255,6 +275,7 @@ export const useMeshStore = defineStore('mesh', () => {
error.value = null
const res = await rpcClient.call<MeshStatus>({ method: 'mesh.status' })
status.value = res
trackDetectedDevices(res)
} catch (err: unknown) {
error.value = err instanceof Error ? err.message : 'Failed to fetch mesh status'
} finally {
@@ -264,20 +285,64 @@ export const useMeshStore = defineStore('mesh', () => {
// ── Global device-detection (for the app-wide "device detected" modal) ──
// Ports the user dismissed the setup modal for, persisted per browser.
// 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[]
))
// 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.
const detectSightings = ref<Record<string, number>>({})
const undismissedDetectedDevices = computed(() => {
const s = status.value
if (!s || s.device_connected || s.enabled) return []
return (s.detected_devices || []).filter(p => !dismissedDetectedPaths.value.has(p))
if (!s) return []
return (s.detected_devices || []).filter(p =>
!dismissedDetectedPaths.value.has(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). */
function trackDetectedDevices(s: MeshStatus) {
const present = new Set(s.detected_devices || [])
const next: Record<string, number> = {}
for (const p of present) {
next[p] = s.device_connected && s.device_path === p
? 0
: (detectSightings.value[p] ?? 0) + 1
}
detectSightings.value = next
let dirty = false
for (const p of [...dismissedDetectedPaths.value]) {
if (!present.has(p)) {
dismissedDetectedPaths.value.delete(p)
dirty = true
}
}
if (dirty) {
dismissedDetectedPaths.value = new Set(dismissedDetectedPaths.value)
localStorage.setItem(DETECT_DISMISS_KEY, JSON.stringify([...dismissedDetectedPaths.value]))
}
}
function dismissDetectedDevice(path: string) {
dismissedDetectedPaths.value.add(path)
dismissedDetectedPaths.value = new Set(dismissedDetectedPaths.value)
localStorage.setItem(DETECT_DISMISS_KEY, JSON.stringify([...dismissedDetectedPaths.value]))
}
/** Read-only firmware/config probe of a detected port (hot-swap modal). */
async function probeDevice(path: string): Promise<MeshDeviceProbe> {
return rpcClient.call<MeshDeviceProbe>({
method: 'mesh.probe-device',
params: { path },
timeout: 45000, // serial probes are slow (multi-firmware handshakes)
})
}
let globalDetectTimer: ReturnType<typeof setInterval> | null = null
/** App-wide light poll so the detected-device modal works on every page
* (the Mesh view's own 5s poll takes over while it is mounted). */
@@ -913,6 +978,7 @@ export const useMeshStore = defineStore('mesh', () => {
fetchStatus,
undismissedDetectedDevices,
dismissDetectedDevice,
probeDevice,
startGlobalDetection,
fetchPeers,
fetchMessages,