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:
archipelago
2026-07-14 08:40:18 -04:00
co-authored by Claude Fable 5
parent c014c4b3ee
commit 7b36b5cc34
38 changed files with 5210 additions and 71 deletions
+8
View File
@@ -37,6 +37,9 @@
<!-- PWA Install Prompt (Install app, not just Add to Home Screen) -->
<PWAInstallPrompt />
<!-- Global "mesh device detected" setup flow (fires on any page) -->
<MeshDeviceSetupModal />
<!-- Global persistent audio player (bottom bar) -->
<GlobalAudioPlayer />
@@ -88,6 +91,8 @@ import ToastStack from './components/ToastStack.vue'
import Screensaver from './components/Screensaver.vue'
import HelpGuideModal from './components/HelpGuideModal.vue'
import GlobalAudioPlayer from './components/GlobalAudioPlayer.vue'
import MeshDeviceSetupModal from './components/mesh/MeshDeviceSetupModal.vue'
import { useMeshStore } from './stores/mesh'
import { useControllerNav } from '@/composables/useControllerNav'
import { playKeyboardTypingSound } from '@/composables/useLoginSounds'
@@ -360,6 +365,9 @@ function onVisibilityChange() {
onMounted(async () => {
syncKioskSafeArea()
// Light app-wide mesh poll so a freshly plugged-in radio surfaces the
// setup modal on any page (the Mesh view's own poll takes over there).
useMeshStore().startGlobalDetection()
document.addEventListener('visibilitychange', onVisibilityChange)
window.addEventListener('keydown', onKeyDown, true)
window.addEventListener('mousemove', onUserActivity)
@@ -0,0 +1,240 @@
<template>
<BaseModal
:show="show"
:title="step === 1 ? 'Mesh Device Detected' : 'Set Up Your Mesh Radio'"
max-width="max-w-lg"
content-class="max-h-[90vh] overflow-y-auto"
@close="dismiss"
>
<!-- Step 1: detection + hardware graphic -->
<div v-if="step === 1" class="text-center">
<div class="mx-auto my-2 w-44 h-44 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">
<path d="M130 44 a30 30 0 0 1 0 28" />
<path d="M138 36 a42 42 0 0 1 0 44" />
<path d="M146 28 a54 54 0 0 1 0 60" />
</g>
</svg>
<!-- actual board image (vendored from the Meshtastic web flasher) -->
<img
:src="deviceImage.image"
:alt="deviceImage.label"
class="relative w-full h-full object-contain drop-shadow-[0_8px_24px_rgba(251,146,60,0.25)]"
@error="imageFailed = true"
v-if="!imageFailed"
/>
<div v-else class="relative w-full h-full flex items-center justify-center text-5xl">📡</div>
</div>
<p class="text-white text-base font-medium">{{ deviceImage.label }}</p>
<p class="text-white/60 text-xs mt-1">
{{ 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
</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>
</div>
</div>
<!-- Step 2: configuration with presets -->
<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>
<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
the firmware/daemon, so the region only drives displayed guidance. -->
<div>
<label class="block text-sm text-white/80 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="suggestedRegion && form.region === suggestedRegion.code" class="text-[11px] text-green-400/80 mt-1">
Suggested from your node's location
</p>
<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>
<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.
</p>
</div>
<!-- Node name -->
<div>
<label class="block text-sm text-white/80 mb-1">Name on the mesh</label>
<input v-model="form.name" maxlength="24" placeholder="e.g. basement-node"
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>
<!-- Channel (Meshtastic secondary channel / MeshCore group channel;
Reticulum has no channel concept IFAC lives in the daemon config) -->
<div v-if="effectiveKind !== 'reticulum'">
<label class="block text-sm text-white/80 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" />
<p class="text-[11px] text-white/40 mt-1">
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>
<div class="flex gap-2 mt-6">
<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"
>
{{ connecting ? 'Connecting' : 'Connect to Mesh' }}
</button>
</div>
</div>
</BaseModal>
</template>
<script setup lang="ts">
import { ref, computed, watch } from 'vue'
import { useRouter } from 'vue-router'
import BaseModal from '@/components/BaseModal.vue'
import { useMeshStore } from '@/stores/mesh'
import { useAppStore } from '@/stores/app'
import { LORA_REGIONS, regionByCode, suggestRegionFromLatLon, meshcorePlanFor } from '@/utils/loraRegions'
import { resolveMeshDeviceImage } from '@/utils/meshDeviceImages'
const mesh = useMeshStore()
const appStore = useAppStore()
const router = useRouter()
const step = ref<1 | 2>(1)
const showAdvanced = ref(false)
const connecting = ref(false)
const error = ref('')
const devicePath = computed(() => mesh.undismissedDetectedDevices[0] ?? '')
const show = computed(() => !!devicePath.value)
const imageFailed = ref(false)
const deviceImage = computed(() =>
resolveMeshDeviceImage(
mesh.status?.detected_device_info?.find(d => d.path === devicePath.value)
)
)
const suggestedRegion = computed(() => {
const info = appStore.serverInfo as { lat?: number | null; lon?: number | null } | undefined
if (info?.lat != null && info?.lon != null) {
return suggestRegionFromLatLon(info.lat, info.lon)
}
return undefined
})
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).
const effectiveKind = computed(() => {
if (form.value.deviceKind !== 'auto') return form.value.deviceKind
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'
}
})
function dismiss() {
if (devicePath.value) mesh.dismissDetectedDevice(devicePath.value)
}
async function connect() {
connecting.value = true
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,
})
mesh.dismissDetectedDevice(path)
void router.push('/mesh')
} catch (e) {
error.value = e instanceof Error ? e.message : 'Failed to configure the mesh radio'
} finally {
connecting.value = false
}
}
</script>
<style scoped>
.mesh-detect-waves path {
animation: mesh-wave-pulse 2.2s ease-out infinite;
opacity: 0;
}
.mesh-detect-waves path:nth-child(2) { animation-delay: 0.35s; }
.mesh-detect-waves path:nth-child(3) { animation-delay: 0.7s; }
@keyframes mesh-wave-pulse {
0% { opacity: 0; }
25% { opacity: 1; }
100% { opacity: 0; }
}
</style>
+57 -2
View File
@@ -23,6 +23,31 @@ export interface MeshStatus {
receive_block_headers?: boolean
/** Operator-configured LoRa region (e.g. "EU_868"), for the Device tab. */
region?: string | null
/** Persisted config: LoRa region the driver programs on fresh radios. */
lora_region?: string | null
/** Persisted config: firmware pin (meshcore|meshtastic|reticulum), null = auto-detect. */
device_kind?: string | null
/** USB identity per detected port (for the setup modal's board image). */
detected_device_info?: Array<{
path: string
vid?: string | null
pid?: string | null
product?: string | null
manufacturer?: string | null
}>
}
/** Params accepted by mesh.configure (superset of the status fields). */
export interface MeshConfigureParams {
enabled?: boolean
device_path?: string
channel_name?: string
broadcast_identity?: boolean
advert_name?: string
announce_block_headers?: boolean
receive_block_headers?: boolean
lora_region?: string
device_kind?: string
}
export interface MeshPeer {
@@ -234,6 +259,33 @@ 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.
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[]
))
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))
})
function dismissDetectedDevice(path: string) {
dismissedDetectedPaths.value.add(path)
dismissedDetectedPaths.value = new Set(dismissedDetectedPaths.value)
localStorage.setItem(DETECT_DISMISS_KEY, JSON.stringify([...dismissedDetectedPaths.value]))
}
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). */
function startGlobalDetection(intervalMs = 15000) {
if (globalDetectTimer) return
void fetchStatus()
globalDetectTimer = setInterval(() => {
if (document.visibilityState === 'visible') void fetchStatus()
}, intervalMs)
}
async function fetchPeers() {
try {
const res = await rpcClient.call<{ peers: MeshPeer[]; count: number }>({
@@ -455,12 +507,12 @@ export const useMeshStore = defineStore('mesh', () => {
}
}
async function configure(config: Partial<MeshStatus>) {
async function configure(config: MeshConfigureParams) {
try {
error.value = null
await rpcClient.call<{ configured: boolean }>({
method: 'mesh.configure',
params: config,
params: config as Record<string, unknown>,
})
await fetchStatus()
} catch (err: unknown) {
@@ -851,6 +903,9 @@ export const useMeshStore = defineStore('mesh', () => {
blockHeaders,
latestBlockHeight,
fetchStatus,
undismissedDetectedDevices,
dismissDetectedDevice,
startGlobalDetection,
fetchPeers,
fetchMessages,
sendMessage,
+102
View File
@@ -0,0 +1,102 @@
/**
* LoRa regional frequency plans — the canonical region list the backend's
* Meshtastic driver can program (core/.../mesh/meshtastic.rs region table),
* annotated with band / duty-cycle / power constraints from the Meshtastic
* firmware `regions[]` table so the UI can surface legality hints.
*
* Also used to derive suggested radio parameters for MeshCore and RNode
* presets (those firmwares take raw frequency/BW/SF/CR, not a region enum).
*/
export interface LoraRegion {
/** Wire value for mesh.configure { lora_region } */
code: string
label: string
/** Frequency range, MHz (display) */
band: string
/** Regional duty-cycle cap, percent (100 = none) */
dutyCyclePct: number
/** Max TX power, dBm */
maxPowerDbm: number
}
export const LORA_REGIONS: LoraRegion[] = [
{ code: 'US', label: 'United States / Americas (915 MHz)', band: '902928', dutyCyclePct: 100, maxPowerDbm: 30 },
{ code: 'EU_868', label: 'Europe (868 MHz)', band: '869.4869.65', dutyCyclePct: 10, maxPowerDbm: 27 },
{ code: 'EU_433', label: 'Europe (433 MHz)', band: '433434', dutyCyclePct: 10, maxPowerDbm: 10 },
{ code: 'ANZ', label: 'Australia / New Zealand (915 MHz)', band: '915928', dutyCyclePct: 100, maxPowerDbm: 30 },
{ code: 'ANZ_433', label: 'Australia / New Zealand (433 MHz)', band: '433.05434.79', dutyCyclePct: 100, maxPowerDbm: 14 },
{ code: 'NZ_865', label: 'New Zealand (865 MHz)', band: '864868', dutyCyclePct: 100, maxPowerDbm: 36 },
{ code: 'CN', label: 'China (470 MHz)', band: '470510', dutyCyclePct: 100, maxPowerDbm: 19 },
{ code: 'JP', label: 'Japan (920 MHz)', band: '920.5923.5', dutyCyclePct: 100, maxPowerDbm: 13 },
{ code: 'KR', label: 'South Korea (920 MHz)', band: '920923', dutyCyclePct: 100, maxPowerDbm: 23 },
{ code: 'TW', label: 'Taiwan (920 MHz)', band: '920925', dutyCyclePct: 100, maxPowerDbm: 27 },
{ code: 'RU', label: 'Russia (868 MHz)', band: '868.7869.2', dutyCyclePct: 100, maxPowerDbm: 20 },
{ code: 'IN', label: 'India (865 MHz)', band: '865867', dutyCyclePct: 100, maxPowerDbm: 30 },
{ code: 'TH', label: 'Thailand (920 MHz)', band: '920925', dutyCyclePct: 10, maxPowerDbm: 27 },
{ code: 'UA_868', label: 'Ukraine (868 MHz)', band: '868868.6', dutyCyclePct: 1, maxPowerDbm: 14 },
{ code: 'UA_433', label: 'Ukraine (433 MHz)', band: '433434.7', dutyCyclePct: 10, maxPowerDbm: 10 },
{ code: 'MY_919', label: 'Malaysia (919 MHz)', band: '919924', dutyCyclePct: 100, maxPowerDbm: 27 },
{ code: 'MY_433', label: 'Malaysia (433 MHz)', band: '433435', dutyCyclePct: 100, maxPowerDbm: 20 },
{ code: 'SG_923', label: 'Singapore (923 MHz)', band: '917925', dutyCyclePct: 100, maxPowerDbm: 20 },
{ code: 'PH_915', label: 'Philippines (915 MHz)', band: '915918', dutyCyclePct: 100, maxPowerDbm: 24 },
{ code: 'PH_868', label: 'Philippines (868 MHz)', band: '868869.4', dutyCyclePct: 100, maxPowerDbm: 14 },
{ code: 'PH_433', label: 'Philippines (433 MHz)', band: '433434.7', dutyCyclePct: 100, maxPowerDbm: 10 },
{ code: 'LORA_24', label: 'Worldwide (2.4 GHz, SX1280 only)', band: '24002483.5', dutyCyclePct: 100, maxPowerDbm: 10 },
]
export function regionByCode(code: string | null | undefined): LoraRegion | undefined {
if (!code) return undefined
return LORA_REGIONS.find(r => r.code === code.toUpperCase())
}
/**
* Coarse lat/lon → LoRa region suggestion. Bounding boxes, most-specific
* first — good enough to preselect the dropdown; the user confirms.
*/
export function suggestRegionFromLatLon(lat: number, lon: number): LoraRegion | undefined {
const boxes: Array<{ code: string; latMin: number; latMax: number; lonMin: number; lonMax: number }> = [
{ code: 'JP', latMin: 24, latMax: 46, lonMin: 123, lonMax: 146 },
{ code: 'KR', latMin: 33, latMax: 39, lonMin: 124, lonMax: 132 },
{ code: 'TW', latMin: 21.5, latMax: 25.5, lonMin: 119.5, lonMax: 122.5 },
{ code: 'PH_915', latMin: 4, latMax: 21, lonMin: 116, lonMax: 127 },
{ code: 'SG_923', latMin: 1, latMax: 1.6, lonMin: 103.5, lonMax: 104.2 },
{ code: 'MY_919', latMin: 0.8, latMax: 7.5, lonMin: 99, lonMax: 119.5 },
{ code: 'TH', latMin: 5.5, latMax: 20.5, lonMin: 97, lonMax: 106 },
{ code: 'IN', latMin: 6, latMax: 36, lonMin: 68, lonMax: 97.5 },
{ code: 'CN', latMin: 18, latMax: 54, lonMin: 73, lonMax: 135 },
{ code: 'UA_868', latMin: 44, latMax: 52.5, lonMin: 22, lonMax: 40.5 },
{ code: 'RU', latMin: 41, latMax: 82, lonMin: 27, lonMax: 180 },
{ code: 'ANZ', latMin: -48, latMax: -9, lonMin: 112, lonMax: 180 },
{ code: 'EU_868', latMin: 34, latMax: 72, lonMin: -25, lonMax: 45 },
// Americas
{ code: 'US', latMin: -56, latMax: 72, lonMin: -170, lonMax: -30 },
]
for (const b of boxes) {
if (lat >= b.latMin && lat <= b.latMax && lon >= b.lonMin && lon <= b.lonMax) {
return regionByCode(b.code)
}
}
return undefined
}
/** Community MeshCore radio plan for a region, where one is well established.
* MeshCore has no region enum — the firmware takes raw freq/BW/SF/CR — so
* these are display-level suggestions (EU is the verified community default;
* other regions show the legal band and defer to the local community plan). */
export interface MeshcorePlan {
freqMhz: number
bwKhz: number
sf: number
cr: number
}
export const MESHCORE_PLANS: Record<string, MeshcorePlan> = {
EU_868: { freqMhz: 869.525, bwKhz: 250, sf: 11, cr: 5 },
EU_433: { freqMhz: 433.65, bwKhz: 250, sf: 11, cr: 5 },
}
export function meshcorePlanFor(code: string | null | undefined): MeshcorePlan | undefined {
if (!code) return undefined
return MESHCORE_PLANS[code.toUpperCase()]
}
+90
View File
@@ -0,0 +1,90 @@
/**
* Best-effort mapping from a detected serial port's USB identity to a board
* image (SVGs vendored from the Meshtastic web-flasher, GPL-3.0 —
* github.com/meshtastic/web-flasher, public/img/devices/).
*
* Native-USB boards (T-Deck, RAK4631, T1000-E…) report their name in the USB
* product string, so those match precisely. UART-bridge boards only identify
* the bridge chip (CP2102/CH340), so vid:pid picks the most common board for
* that chip and the label stays honest ("LoRa radio").
*/
export interface DetectedDeviceInfo {
path: string
vid?: string | null
pid?: string | null
product?: string | null
manufacturer?: string | null
}
export interface MeshDeviceImage {
/** Path under public/ */
image: string
/** Human name shown in the modal */
label: string
/** True when the match is exact (product string), false for chip-level guesses */
exact: boolean
}
const IMG = '/assets/img/mesh-devices'
/** product-string keyword → image (checked in order, case-insensitive) */
const PRODUCT_MATCHES: Array<{ match: RegExp; image: string; label: string }> = [
{ match: /t-?deck/i, image: `${IMG}/t-deck.svg`, label: 'LILYGO T-Deck' },
{ match: /t-?echo\s*plus/i, image: `${IMG}/t-echo_plus.svg`, label: 'LILYGO T-Echo Plus' },
{ match: /t-?echo/i, image: `${IMG}/t-echo.svg`, label: 'LILYGO T-Echo' },
{ match: /t-?beam\s*s3/i, image: `${IMG}/tbeam-s3-core.svg`, label: 'LILYGO T-Beam S3' },
{ match: /t-?beam/i, image: `${IMG}/tbeam.svg`, label: 'LILYGO T-Beam' },
{ match: /t3.?s3/i, image: `${IMG}/tlora-t3s3-v1.svg`, label: 'LILYGO T3-S3' },
{ match: /t-?lora|tlora/i, image: `${IMG}/tlora-v2-1-1_6.svg`, label: 'LILYGO T-LoRa' },
{ match: /rak.?4631|wisblock/i, image: `${IMG}/rak4631.svg`, label: 'RAK WisBlock 4631' },
{ match: /wismesh|wistap/i, image: `${IMG}/rak-wismeshtap.svg`, label: 'RAK WisMesh Tap' },
{ match: /t1000|tracker.?t1000/i, image: `${IMG}/tracker-t1000-e.svg`, label: 'Seeed Card Tracker T1000-E' },
{ match: /xiao/i, image: `${IMG}/seeed-xiao-s3.svg`, label: 'Seeed XIAO S3' },
{ match: /wio.?tracker|wm1110/i, image: `${IMG}/wio-tracker-wm1110.svg`, label: 'Seeed Wio Tracker' },
{ match: /station\s*g2/i, image: `${IMG}/station-g2.svg`, label: 'B&Q Station G2' },
{ match: /nano\s*g2/i, image: `${IMG}/nano-g2-ultra.svg`, label: 'B&Q Nano G2 Ultra' },
{ match: /t114/i, image: `${IMG}/heltec-mesh-node-t114.svg`, label: 'Heltec Mesh Node T114' },
{ match: /wireless\s*stick|wsl/i, image: `${IMG}/heltec-wsl-v3.svg`, label: 'Heltec Wireless Stick Lite V3' },
{ match: /heltec.*v4/i, image: `${IMG}/heltec_v4.svg`, label: 'Heltec V4' },
{ match: /heltec/i, image: `${IMG}/heltec-v3.svg`, label: 'Heltec LoRa 32 V3' },
{ match: /thinknode/i, image: `${IMG}/thinknode_m1.svg`, label: 'Elecrow ThinkNode' },
{ match: /pico/i, image: `${IMG}/rpipicow.svg`, label: 'Raspberry Pi Pico' },
{ match: /rnode/i, image: `${IMG}/diy.svg`, label: 'RNode' },
]
/** vid:pid → most-likely board (bridge chips = chip-level guess) */
const VIDPID_MATCHES: Record<string, { image: string; label: string; exact: boolean }> = {
// Silicon Labs CP210x — Heltec V3 family ships this bridge
'10c4:ea60': { image: `${IMG}/heltec-v3.svg`, label: 'LoRa radio (CP2102 serial)', exact: false },
// WCH CH340 — most T-Beam / T-LoRa boards
'1a86:7523': { image: `${IMG}/tbeam.svg`, label: 'LoRa radio (CH340 serial)', exact: false },
'1a86:55d4': { image: `${IMG}/tbeam.svg`, label: 'LoRa radio (CH9102 serial)', exact: false },
// Espressif native USB (S3 boards: T3-S3, T-Deck without product str)
'303a:1001': { image: `${IMG}/tlora-t3s3-v1.svg`, label: 'ESP32-S3 LoRa board', exact: false },
// RAK4631 nRF52 native USB
'239a:8029': { image: `${IMG}/rak4631.svg`, label: 'RAK WisBlock 4631', exact: true },
// Raspberry Pi (Pico W)
'2e8a:0005': { image: `${IMG}/rpipicow.svg`, label: 'Raspberry Pi Pico', exact: false },
}
const FALLBACK: MeshDeviceImage = {
image: `${IMG}/unknown-new.svg`,
label: 'LoRa mesh radio',
exact: false,
}
export function resolveMeshDeviceImage(info: DetectedDeviceInfo | undefined): MeshDeviceImage {
if (!info) return FALLBACK
const product = `${info.manufacturer ?? ''} ${info.product ?? ''}`.trim()
if (product) {
for (const m of PRODUCT_MATCHES) {
if (m.match.test(product)) return { image: m.image, label: m.label, exact: true }
}
}
if (info.vid && info.pid) {
const hit = VIDPID_MATCHES[`${info.vid}:${info.pid}`.toLowerCase()]
if (hit) return hit
}
return FALLBACK
}
+5 -56
View File
@@ -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"
+132 -3
View File
@@ -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>