242 lines
10 KiB
Vue
242 lines
10 KiB
Vue
<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)
|
|
// The Mesh view lives under the dashboard shell — a bare /mesh 404s.
|
|
void router.push('/dashboard/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>
|