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>
198 lines
8.9 KiB
Vue
198 lines
8.9 KiB
Vue
<script setup lang="ts">
|
|
import { ref, computed, watch } from 'vue'
|
|
import { useMeshStore } from '@/stores/mesh'
|
|
import { LORA_REGIONS, regionByCode, meshcorePlanFor } from '@/utils/loraRegions'
|
|
|
|
const mesh = useMeshStore()
|
|
|
|
const rebooting = ref(false)
|
|
const rebootError = ref<string | null>(null)
|
|
|
|
async function handleReboot() {
|
|
rebooting.value = true
|
|
rebootError.value = null
|
|
try {
|
|
await mesh.rebootRadio()
|
|
} catch (e) {
|
|
rebootError.value = e instanceof Error ? e.message : 'Failed to reboot radio'
|
|
} finally {
|
|
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>
|
|
<div class="glass-card mesh-device-panel">
|
|
<h3 class="mesh-panel-title">Device</h3>
|
|
<p class="mesh-panel-sub">Firmware, identity, and radio controls for the connected mesh device</p>
|
|
|
|
<div v-if="mesh.status" class="mesh-device-panel-grid">
|
|
<div class="mesh-stat">
|
|
<span class="mesh-stat-label">Firmware</span>
|
|
<span class="mesh-stat-value">{{ mesh.status.firmware_version ?? '—' }}</span>
|
|
</div>
|
|
<div class="mesh-stat">
|
|
<span class="mesh-stat-label">Node ID</span>
|
|
<span class="mesh-stat-value">{{ mesh.status.self_node_id != null ? `!${mesh.status.self_node_id.toString(16).padStart(8, '0')}` : '—' }}</span>
|
|
</div>
|
|
<div class="mesh-stat">
|
|
<span class="mesh-stat-label">Name</span>
|
|
<span class="mesh-stat-value">{{ mesh.status.self_advert_name ?? '—' }}</span>
|
|
</div>
|
|
<div class="mesh-stat">
|
|
<span class="mesh-stat-label">Region (radio)</span>
|
|
<span class="mesh-stat-value">{{ mesh.status.region ?? 'Not set' }}</span>
|
|
</div>
|
|
<div class="mesh-stat">
|
|
<span class="mesh-stat-label">Channel</span>
|
|
<span class="mesh-stat-value">{{ mesh.status.channel_name }}</span>
|
|
</div>
|
|
<div class="mesh-stat">
|
|
<span class="mesh-stat-label">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>
|
|
|
|
<div class="mesh-device-panel-actions">
|
|
<button
|
|
class="glass-button mesh-device-reboot-btn"
|
|
:disabled="rebooting || !mesh.status?.device_connected"
|
|
@click="handleReboot"
|
|
>
|
|
<span v-if="rebooting" class="mesh-spinner" aria-hidden="true"></span>
|
|
<template v-else>Reboot Radio</template>
|
|
</button>
|
|
<p class="mesh-device-reboot-hint">Use this if the device stops responding to sent messages or seems stuck.</p>
|
|
<p v-if="rebootError" class="mesh-device-reboot-error">{{ rebootError }}</p>
|
|
</div>
|
|
</div>
|
|
</template>
|