2026-06-30 23:03:09 -04:00
< script setup lang = "ts" >
2026-07-14 08:40:18 -04:00
import { ref , computed , watch } from 'vue'
2026-06-30 23:03:09 -04:00
import { useMeshStore } from '@/stores/mesh'
2026-07-14 08:40:18 -04:00
import { LORA_REGIONS , regionByCode , meshcorePlanFor } from '@/utils/loraRegions'
2026-06-30 23:03:09 -04:00
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
}
}
2026-07-14 08:40:18 -04:00
// ── Editable settings (persisted via mesh.configure) ──
const form = ref ({
region : '' ,
deviceKind : 'auto' ,
channel : 'archipelago' ,
name : '' ,
broadcastIdentity : true ,
2026-07-21 06:53:10 -04:00
// 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 : '' ,
2026-07-14 08:40:18 -04:00
})
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 ?? ''
2026-07-21 06:53:10 -04:00
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 ) {
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 )
}
2026-07-14 08:40:18 -04:00
},
{ 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 {
2026-07-21 06:53:10 -04:00
// MeshCore RF params: send only when all four are filled (partial input
// is a user error surfaced by the API's range validation; all-empty
// means "leave the radio's flashed settings alone").
const rf = [ form . value . rfFreqMhz , form . value . rfBwKhz , form . value . rfSf , form . value . rfCr ]
const rfAll = rf . every (( v ) => String ( v ). trim () !== '' )
const rfAny = rf . some (( v ) => String ( v ). trim () !== '' )
if ( rfAny && ! rfAll ) {
throw new Error ( 'Fill in all four RF fields (frequency, bandwidth, SF, CR) or leave all empty' )
}
2026-07-14 08:40:18 -04:00
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 ,
2026-07-21 06:53:10 -04:00
...( rfAll
? {
lora_radio_params : {
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 ),
},
}
: {}),
2026-07-14 08:40:18 -04:00
})
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
}
}
2026-06-30 23:03:09 -04:00
</ 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 >
2026-07-14 08:40:18 -04:00
< 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">
2026-07-21 06:53:10 -04:00
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.
2026-07-14 08:40:18 -04:00
</p>
<p v-else-if="effectiveKind === ' meshcore '" class="text-[11px] text-sky-300/80 mt-1">
2026-07-21 06:53:10 -04:00
Program the radio' s RF settings with the fields below — every radio on your mesh must match {{ selectedRegion ? ` ( ${ selectedRegion . band } MHz band)` : '' }}.
2026-07-14 08:40:18 -04:00
</ 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>
2026-07-21 06:53:10 -04:00
<!-- 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 = "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>
2026-07-14 08:40:18 -04:00
<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 >
2026-06-30 23:03:09 -04:00
</ 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 >