archy/neode-ui/src/views/mesh/MeshDevicePanel.vue
archipelago 918b0275a9
All checks were successful
Demo images / Build & push demo images (push) Successful in 2m47s
feat(ui): MeshCore RF frequency-plan presets by country + Custom entry
The RF section shipped as four bare fields; restore a prepopulated plan
list (dropdown) with Custom unlocking free entry, per operator direction.
Preset labels carry the full values, so the number fields render only in
Custom mode — no duplicated display. Presets limited to verifiable
sources: the repo's MeshCore community plans (EU 868/433), the MeshCore
firmware's own build defaults (platformio.ini arduino_base 869.618/62.5/
SF8 CR5; companion example 915.0/250/SF10/CR5), and the operator-attested
Portugal plan (869.618/62.5/SF8/CR8). Stored params are matched back to a
named preset on load; hand-editing flips the dropdown to Custom.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-21 08:36:59 -04:00

308 lines
14 KiB
Vue

<script setup lang="ts">
import { ref, computed, watch } from 'vue'
import { useMeshStore } from '@/stores/mesh'
import { LORA_REGIONS, regionByCode, meshcorePlanFor, MESHCORE_RF_PRESETS } 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,
// MeshCore LoRa PHY params (human units; converted to firmware units on
// save). All four empty = leave the radio's flashed settings untouched.
rfFreqMhz: '',
rfBwKhz: '',
rfSf: '',
rfCr: '',
})
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 ?? ''
const rp = (s as Record<string, unknown>).lora_radio_params as
| { freq_khz: number; bw_hz: number; sf: number; cr: number }
| null
| undefined
if (rp) {
applyingPreset = true
form.value.rfFreqMhz = String(rp.freq_khz / 1000)
form.value.rfBwKhz = String(rp.bw_hz / 1000)
form.value.rfSf = String(rp.sf)
form.value.rfCr = String(rp.cr)
// Show the matching named preset if the stored values are one; else Custom.
const match = MESHCORE_RF_PRESETS.find(
(p) =>
Math.round(p.freqMhz * 1000) === rp.freq_khz &&
Math.round(p.bwKhz * 1000) === rp.bw_hz &&
p.sf === rp.sf &&
p.cr === rp.cr,
)
rfPreset.value = match ? match.id : 'custom'
applyingPreset = false
}
},
{ immediate: true },
)
// RF preset dropdown: picking a named plan fills the four fields; hand-editing
// any field flips the dropdown to Custom so it never misrepresents the values.
const rfPreset = ref('')
let applyingPreset = false
function selectRfPreset(id: string) {
rfPreset.value = id
if (id === 'custom' || id === '') return
const p = MESHCORE_RF_PRESETS.find((x) => x.id === id)
if (!p) return
applyingPreset = true
form.value.rfFreqMhz = String(p.freqMhz)
form.value.rfBwKhz = String(p.bwKhz)
form.value.rfSf = String(p.sf)
form.value.rfCr = String(p.cr)
applyingPreset = false
}
watch(
() => [form.value.rfFreqMhz, form.value.rfBwKhz, form.value.rfSf, form.value.rfCr],
() => {
if (!applyingPreset && rfPreset.value && rfPreset.value !== 'custom') rfPreset.value = 'custom'
},
)
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 {
// MeshCore RF params: a named preset sends its plan; Custom sends the
// four fields (all required — partial input is an error); "Keep the
// radio's current settings" omits the key so the config is untouched.
let rfParams: { freq_khz: number; bw_hz: number; sf: number; cr: number } | undefined
if (rfPreset.value && rfPreset.value !== 'custom') {
const p = MESHCORE_RF_PRESETS.find((x) => x.id === rfPreset.value)
if (p) {
rfParams = {
freq_khz: Math.round(p.freqMhz * 1000),
bw_hz: Math.round(p.bwKhz * 1000),
sf: p.sf,
cr: p.cr,
}
}
} else if (rfPreset.value === 'custom') {
const rf = [form.value.rfFreqMhz, form.value.rfBwKhz, form.value.rfSf, form.value.rfCr]
if (!rf.every((v) => String(v).trim() !== '')) {
throw new Error('Fill in all four RF fields (frequency, bandwidth, SF, CR)')
}
rfParams = {
freq_khz: Math.round(parseFloat(form.value.rfFreqMhz) * 1000),
bw_hz: Math.round(parseFloat(form.value.rfBwKhz) * 1000),
sf: parseInt(form.value.rfSf, 10),
cr: parseInt(form.value.rfCr, 10),
}
}
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,
...(rfParams ? { lora_radio_params: rfParams } : {}),
})
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">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 }} — set the RF fields below to program the radio.
</p>
<p v-else-if="effectiveKind === 'meshcore'" class="text-[11px] text-sky-300/80 mt-1">
Program the radio's RF settings with the fields below — every radio on your mesh must match{{ selectedRegion ? ` (${selectedRegion.band} MHz band)` : '' }}.
</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>
<!-- MeshCore LoRa PHY params — every radio on the local mesh must match
or it hears RF energy but decodes nothing. Empty = leave the radio's
flashed settings untouched. -->
<div v-if="effectiveKind === 'meshcore'" class="mt-4">
<h5 class="text-xs font-semibold text-white/80 mb-2">MeshCore RF parameters</h5>
<div class="mb-3">
<label class="block text-xs text-white/60 mb-1">Frequency plan</label>
<select :value="rfPreset" 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" @change="selectRfPreset(($event.target as HTMLSelectElement).value)">
<option value="">Keep the radio's current settings</option>
<option v-for="p in MESHCORE_RF_PRESETS" :key="p.id" :value="p.id">{{ p.label }}</option>
<option value="custom">Custom…</option>
</select>
</div>
<div v-if="rfPreset === 'custom'" class="grid gap-3 grid-cols-2 sm:grid-cols-4">
<div>
<label class="block text-xs text-white/60 mb-1">Frequency (MHz)</label>
<input v-model="form.rfFreqMhz" inputmode="decimal" placeholder="869.618" 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">Bandwidth (kHz)</label>
<select v-model="form.rfBwKhz" 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="">—</option>
<option v-for="bw in ['62.5', '125', '250', '500']" :key="bw" :value="bw">{{ bw }}</option>
</select>
</div>
<div>
<label class="block text-xs text-white/60 mb-1">Spreading factor</label>
<select v-model="form.rfSf" 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=""></option>
<option v-for="sf in [5, 6, 7, 8, 9, 10, 11, 12]" :key="sf" :value="String(sf)">SF {{ sf }}</option>
</select>
</div>
<div>
<label class="block text-xs text-white/60 mb-1">Coding rate</label>
<select v-model="form.rfCr" 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=""></option>
<option v-for="cr in [5, 6, 7, 8]" :key="cr" :value="String(cr)">4/{{ cr }}</option>
</select>
</div>
</div>
<p class="text-[11px] text-white/40 mt-1">
Saved settings program the radio on its next connect (it reboots once to apply). Leave all four empty to keep the radio's own settings.
</p>
</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>