feat(mesh): device-detected setup modal with board images + full radio settings
Plugging in a LoRa radio now pops a global two-step setup modal on any page: step 1 shows the ACTUAL detected board (25 board SVGs vendored from the Meshtastic web-flasher, GPL-3.0; matched by USB product string on native-USB boards, vid:pid heuristics for bridge chips, animated fallback otherwise); step 2 configures with Archipelago presets — the LoRa region pre-selected from the node's location (new lat/lon→region mapping), channel 'archipelago', node name, and an advanced firmware pin. Options adapt per protocol: region+channel program Meshtastic; MeshCore shows its community frequency plan (firmware owns RF); RNode defers to the Reticulum daemon config. Backend: mesh.configure now accepts lora_region (validated against the driver's region table) and device_kind (probe pin); mesh.status returns the persisted values plus per-port USB identity (sysfs vid/pid/product) for board identification. The Mesh Device tab gains a full settings form (region with duty-cycle/legality hints, firmware pin, channel, name, identity broadcast) replacing the read-only panel; the old Mesh-page-only onboarding modal is superseded by the global flow. Mock backend: stateful mesh config so the dev UI demos the full detect→configure round-trip (disable mesh → modal fires with Heltec V3). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
c014c4b3ee
commit
7b36b5cc34
@@ -40,13 +40,7 @@ const sendError = ref('')
|
||||
const broadcasting = ref(false)
|
||||
const configuring = ref(false)
|
||||
const connectingDevice = ref<string | null>(null)
|
||||
// Onboarding modal (#6): guides a first-time connect for a freshly-detected,
|
||||
// not-yet-connected device — a friendlier wrapper around the same Connect
|
||||
// action the "Detected USB devices" list already offers, not a new setup
|
||||
// engine. `onboardingDismissed` remembers paths the user closed without
|
||||
// connecting, so it doesn't reappear every poll tick for the same device.
|
||||
const showOnboardingModal = ref(false)
|
||||
const onboardingDismissed = ref<Set<string>>(new Set())
|
||||
// Device-detected onboarding now lives in the global MeshDeviceSetupModal (App.vue).
|
||||
const chatScrollEl = ref<HTMLElement | null>(null)
|
||||
const messageInputRef = ref<HTMLInputElement | null>(null)
|
||||
const mobileShowChat = ref(false)
|
||||
@@ -1041,35 +1035,12 @@ async function handleToggleEnabled() {
|
||||
async function handleConnectDevice(devicePath: string) {
|
||||
connectingDevice.value = devicePath
|
||||
try {
|
||||
await mesh.configure({ enabled: true, device_path: devicePath } as Partial<import('@/stores/mesh').MeshStatus>)
|
||||
showOnboardingModal.value = false
|
||||
await mesh.configure({ enabled: true, device_path: devicePath })
|
||||
} finally {
|
||||
connectingDevice.value = null
|
||||
}
|
||||
}
|
||||
|
||||
const undismissedDetectedDevices = computed(() =>
|
||||
(mesh.status?.detected_devices ?? []).filter((d) => !onboardingDismissed.value.has(d))
|
||||
)
|
||||
|
||||
function dismissOnboarding() {
|
||||
for (const d of undismissedDetectedDevices.value) onboardingDismissed.value.add(d)
|
||||
showOnboardingModal.value = false
|
||||
}
|
||||
|
||||
// Pop the onboarding modal the moment a device is detected but not yet
|
||||
// connected — same trigger condition the inline "Detected USB devices" list
|
||||
// already uses (mesh.status.detected_devices non-empty + not connected),
|
||||
// just surfaced as a guided prompt instead of requiring the user to notice
|
||||
// the collapsed Device card.
|
||||
watch(
|
||||
() => [mesh.status?.device_connected, undismissedDetectedDevices.value.length] as const,
|
||||
([connected, count]) => {
|
||||
if (!connected && count > 0) showOnboardingModal.value = true
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
function signalBars(rssi: number | null, snr: number | null = null): number {
|
||||
if (rssi !== null) {
|
||||
if (rssi > -60) return 4
|
||||
@@ -2448,31 +2419,9 @@ async function downloadAttachment(payload: MeshAttachmentPayload) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Onboarding modal (#6): guided first-connect prompt for a freshly
|
||||
detected, not-yet-connected mesh device — wraps the same Connect
|
||||
action the inline "Detected USB devices" list already offers. -->
|
||||
<div v-if="showOnboardingModal" class="mesh-transport-modal-backdrop" @click.self="dismissOnboarding">
|
||||
<div class="glass-card mesh-transport-modal">
|
||||
<h3 class="mesh-transport-title">📡 Mesh Device Found</h3>
|
||||
<p class="mesh-transport-sub">
|
||||
A radio was detected but isn't connected yet. Connect it to start using off-grid mesh chat.
|
||||
</p>
|
||||
<div class="mesh-transport-options">
|
||||
<button
|
||||
v-for="dev in undismissedDetectedDevices"
|
||||
:key="dev"
|
||||
class="mesh-transport-option"
|
||||
:disabled="connectingDevice !== null"
|
||||
@click="handleConnectDevice(dev)"
|
||||
>
|
||||
<span class="mesh-transport-icon">📡</span>
|
||||
<span class="mesh-transport-label">{{ dev }}</span>
|
||||
<span class="mesh-transport-meta">{{ connectingDevice === dev ? 'Connecting…' : 'Connect' }}</span>
|
||||
</button>
|
||||
</div>
|
||||
<button class="mesh-transport-cancel" @click="dismissOnboarding">Not now</button>
|
||||
</div>
|
||||
</div>
|
||||
<!-- The "mesh device detected" setup flow is now the global
|
||||
MeshDeviceSetupModal mounted in App.vue (fires on every page,
|
||||
board image + region presets), so no local modal here. -->
|
||||
|
||||
<MediaLightbox
|
||||
:items="lightboxItems"
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import { useMeshStore } from '@/stores/mesh'
|
||||
import { LORA_REGIONS, regionByCode, meshcorePlanFor } from '@/utils/loraRegions'
|
||||
|
||||
const mesh = useMeshStore()
|
||||
|
||||
@@ -18,6 +19,64 @@ async function handleReboot() {
|
||||
rebooting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// ── Editable settings (persisted via mesh.configure) ──
|
||||
const form = ref({
|
||||
region: '',
|
||||
deviceKind: 'auto',
|
||||
channel: 'archipelago',
|
||||
name: '',
|
||||
broadcastIdentity: true,
|
||||
})
|
||||
const saving = ref(false)
|
||||
const saveError = ref<string | null>(null)
|
||||
const saveDone = ref(false)
|
||||
let seeded = false
|
||||
|
||||
// Seed the form once from status (don't clobber in-progress edits on poll)
|
||||
watch(
|
||||
() => mesh.status,
|
||||
(s) => {
|
||||
if (!s || seeded) return
|
||||
seeded = true
|
||||
form.value.region = s.lora_region ?? ''
|
||||
form.value.deviceKind = s.device_kind ?? 'auto'
|
||||
form.value.channel = s.channel_name || 'archipelago'
|
||||
form.value.name = s.self_advert_name ?? ''
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
const selectedRegion = computed(() => regionByCode(form.value.region))
|
||||
const deviceType = computed(() => mesh.status?.device_type ?? 'unknown')
|
||||
// Firmware whose options apply: explicit pin wins, else the connected type.
|
||||
const effectiveKind = computed(() => {
|
||||
if (form.value.deviceKind !== 'auto') return form.value.deviceKind
|
||||
const t = deviceType.value.toLowerCase()
|
||||
return t === 'meshcore' || t === 'meshtastic' || t === 'reticulum' ? t : 'auto'
|
||||
})
|
||||
const meshcorePlan = computed(() => meshcorePlanFor(form.value.region))
|
||||
|
||||
async function saveSettings() {
|
||||
saving.value = true
|
||||
saveError.value = null
|
||||
saveDone.value = false
|
||||
try {
|
||||
await mesh.configure({
|
||||
lora_region: form.value.region,
|
||||
device_kind: form.value.deviceKind,
|
||||
channel_name: form.value.channel.trim() || 'archipelago',
|
||||
...(form.value.name.trim() ? { advert_name: form.value.name.trim() } : {}),
|
||||
broadcast_identity: form.value.broadcastIdentity,
|
||||
})
|
||||
saveDone.value = true
|
||||
setTimeout(() => { saveDone.value = false }, 3000)
|
||||
} catch (e) {
|
||||
saveError.value = e instanceof Error ? e.message : 'Failed to save mesh settings'
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -39,7 +98,7 @@ async function handleReboot() {
|
||||
<span class="mesh-stat-value">{{ mesh.status.self_advert_name ?? '—' }}</span>
|
||||
</div>
|
||||
<div class="mesh-stat">
|
||||
<span class="mesh-stat-label">Region</span>
|
||||
<span class="mesh-stat-label">Region (radio)</span>
|
||||
<span class="mesh-stat-value">{{ mesh.status.region ?? 'Not set' }}</span>
|
||||
</div>
|
||||
<div class="mesh-stat">
|
||||
@@ -48,7 +107,77 @@ async function handleReboot() {
|
||||
</div>
|
||||
<div class="mesh-stat">
|
||||
<span class="mesh-stat-label">Type</span>
|
||||
<span class="mesh-stat-value">{{ mesh.status.device_type === 'unknown' ? '—' : mesh.status.device_type }}</span>
|
||||
<span class="mesh-stat-value">{{ deviceType === 'unknown' ? '—' : deviceType }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Radio settings -->
|
||||
<div class="mt-5 pt-4 border-t border-white/10">
|
||||
<h4 class="text-sm font-semibold text-white mb-3">Radio Settings</h4>
|
||||
<div class="grid gap-3 sm:grid-cols-2">
|
||||
<div>
|
||||
<label class="block text-xs text-white/60 mb-1">LoRa region / frequency plan</label>
|
||||
<select v-model="form.region" 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="">Keep the radio's current region</option>
|
||||
<option v-for="r in LORA_REGIONS" :key="r.code" :value="r.code">{{ r.label }}</option>
|
||||
</select>
|
||||
<p v-if="selectedRegion && selectedRegion.dutyCyclePct < 100" class="text-[11px] text-amber-400/80 mt-1">
|
||||
{{ selectedRegion.code }}: {{ selectedRegion.dutyCyclePct }}% duty-cycle limit, {{ selectedRegion.band }} MHz, max {{ selectedRegion.maxPowerDbm }} dBm
|
||||
</p>
|
||||
<p v-if="effectiveKind === 'meshtastic' || effectiveKind === 'auto'" class="text-[11px] text-white/40 mt-1">
|
||||
Applied to fresh (region-unset) Meshtastic radios; a radio that already has a region keeps it.
|
||||
</p>
|
||||
<p v-else-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 }} — the radio's flashed RF settings apply; adjust via a MeshCore client if they differ.
|
||||
</p>
|
||||
<p v-else-if="effectiveKind === 'meshcore'" class="text-[11px] text-sky-300/80 mt-1">
|
||||
MeshCore radios keep their flashed RF settings — verify the radio matches your region's band{{ selectedRegion ? ` (${selectedRegion.band} MHz)` : '' }}.
|
||||
</p>
|
||||
<p v-else-if="effectiveKind === 'reticulum'" class="text-[11px] text-sky-300/80 mt-1">
|
||||
RNode RF parameters are managed by the Reticulum daemon's interface config on this node.
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs text-white/60 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 the flashed firmware so no other protocol's probe bytes touch the port.
|
||||
</p>
|
||||
</div>
|
||||
<div v-if="effectiveKind !== 'reticulum'">
|
||||
<label class="block text-xs text-white/60 mb-1">Channel</label>
|
||||
<input v-model="form.channel" maxlength="11" 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" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs text-white/60 mb-1">Name on the mesh</label>
|
||||
<input v-model="form.name" maxlength="24" placeholder="node name" 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" />
|
||||
</div>
|
||||
</div>
|
||||
<label class="flex items-center gap-2 mt-3 text-sm text-white/80 cursor-pointer">
|
||||
<input v-model="form.broadcastIdentity" type="checkbox" class="h-4 w-4 accent-orange-500" />
|
||||
Periodically broadcast this node's identity on the mesh
|
||||
</label>
|
||||
|
||||
<p v-if="effectiveKind === 'auto'" class="text-[11px] text-white/40 mt-3">
|
||||
Options adapt to the detected firmware: region + channel program Meshtastic radios;
|
||||
MeshCore and RNode own their RF parameters in firmware/daemon config; name and identity apply to all.
|
||||
</p>
|
||||
|
||||
<div class="flex items-center gap-3 mt-4">
|
||||
<button
|
||||
class="glass-button glass-button-warning px-4 py-2 rounded-lg text-sm font-medium disabled:opacity-50"
|
||||
:disabled="saving"
|
||||
@click="saveSettings"
|
||||
>
|
||||
{{ saving ? 'Saving…' : 'Save Settings' }}
|
||||
</button>
|
||||
<span v-if="saveDone" class="text-xs text-green-400">Saved — applies on next radio session</span>
|
||||
<span v-if="saveError" class="text-xs text-red-400">{{ saveError }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user