diff --git a/neode-ui/src/stores/mesh.ts b/neode-ui/src/stores/mesh.ts index a7ddc846..b8a80a56 100644 --- a/neode-ui/src/stores/mesh.ts +++ b/neode-ui/src/stores/mesh.ts @@ -863,12 +863,35 @@ export const useMeshStore = defineStore('mesh', () => { } async function rebootRadio(seconds = 2) { - return rpcClient.call<{ reboot: boolean; seconds: number }>({ + // Long timeout: Reticulum reboots restart the sidecar daemon and the + // backend waits for the acknowledgement instead of fire-and-forgetting. + return rpcClient.call<{ reboot: boolean; seconds: number; message?: string }>({ method: 'mesh.reboot-radio', params: { seconds }, + timeout: 30000, }) } + /** Persisted RNode RF settings + live radio-confirmed state (Reticulum). */ + async function getRnodeConfig() { + return rpcClient.call<{ + settings: Record + live: Record | null + live_error: string | null + }>({ method: 'mesh.rnode-config', timeout: 20000 }) + } + + /** Apply RNode RF settings: persists, restarts the radio daemon, waits for + * the radio's own read-back confirmation (up to ~50s). */ + async function applyRnodeConfig(settings: Record) { + return rpcClient.call<{ + applied: boolean + confirmed?: boolean + live?: Record | null + message: string + }>({ method: 'mesh.rnode-config-apply', params: { settings }, timeout: 70000 }) + } + async function getOutbox() { try { return await rpcClient.call<{ count: number; messages?: unknown[] }>({ method: 'mesh.outbox' }) @@ -1155,6 +1178,8 @@ export const useMeshStore = defineStore('mesh', () => { sendReply, sendReaction, rebootRadio, + getRnodeConfig, + applyRnodeConfig, getOutbox, sendReadReceipt, forwardMessage, diff --git a/neode-ui/src/views/mesh/MeshDevicePanel.vue b/neode-ui/src/views/mesh/MeshDevicePanel.vue index 46b0b768..1345b321 100644 --- a/neode-ui/src/views/mesh/MeshDevicePanel.vue +++ b/neode-ui/src/views/mesh/MeshDevicePanel.vue @@ -7,12 +7,17 @@ const mesh = useMeshStore() const rebooting = ref(false) const rebootError = ref(null) +const rebootMessage = ref(null) async function handleReboot() { rebooting.value = true rebootError.value = null + rebootMessage.value = null try { - await mesh.rebootRadio() + const res = await mesh.rebootRadio() + // The backend now waits for the device's acknowledgement and says what + // actually happened — show it instead of silently going idle again. + rebootMessage.value = res.message || 'Reboot command acknowledged by the radio.' } catch (e) { rebootError.value = e instanceof Error ? e.message : 'Failed to reboot radio' } finally { @@ -20,6 +25,121 @@ async function handleReboot() { } } +// ── RNode (Reticulum) RF settings — full round-trip with device read-back ── +// Recommended plans per region for Reticulum RNode radios. EU868 is the +// operator-validated Portugal plan (869.4625 MHz keeps clear of the default +// community channel while staying in the 10%-duty 869.4–869.65 sub-band; +// airtime locks match EU duty-cycle law). Others use the RNS community +// conventions for the band with the region's legal power cap. +const RNODE_REGION_PLANS: Record = { + EU868: { frequency: 869462500, bandwidth: 125000, spreading_factor: 8, coding_rate: 5, txpower: 14, airtime_limit_short: 25, airtime_limit_long: 10 }, + US915: { frequency: 914875000, bandwidth: 125000, spreading_factor: 8, coding_rate: 5, txpower: 17, airtime_limit_short: null, airtime_limit_long: null }, + AU915: { frequency: 916800000, bandwidth: 125000, spreading_factor: 8, coding_rate: 5, txpower: 17, airtime_limit_short: null, airtime_limit_long: null }, + ANZ: { frequency: 916800000, bandwidth: 125000, spreading_factor: 8, coding_rate: 5, txpower: 17, airtime_limit_short: null, airtime_limit_long: null }, + AS923: { frequency: 923200000, bandwidth: 125000, spreading_factor: 8, coding_rate: 5, txpower: 13, airtime_limit_short: null, airtime_limit_long: null }, + IN865: { frequency: 866000000, bandwidth: 125000, spreading_factor: 8, coding_rate: 5, txpower: 17, airtime_limit_short: null, airtime_limit_long: null }, +} + +const rnodeForm = ref({ + enabled: true, + port: '', + frequency: '', + bandwidth: '125000', + spreading_factor: '8', + coding_rate: '5', + txpower: '17', + airtime_limit_short: '', + airtime_limit_long: '', +}) +const rnodeLive = ref | null>(null) +const rnodeLiveError = ref(null) +const rnodeLoading = ref(false) +const rnodeApplying = ref(false) +const rnodeResult = ref<{ ok: boolean; confirmed: boolean; message: string } | null>(null) +let rnodeSeeded = false + +const rnodeRegionPlan = computed(() => (form.value.region ? RNODE_REGION_PLANS[form.value.region] : undefined)) + +function setRnodeRecommendedForRegion() { + const plan = rnodeRegionPlan.value + if (!plan) return + rnodeForm.value.frequency = String(plan.frequency) + rnodeForm.value.bandwidth = String(plan.bandwidth) + rnodeForm.value.spreading_factor = String(plan.spreading_factor) + rnodeForm.value.coding_rate = String(plan.coding_rate) + rnodeForm.value.txpower = String(plan.txpower) + rnodeForm.value.airtime_limit_short = plan.airtime_limit_short != null ? String(plan.airtime_limit_short) : '' + rnodeForm.value.airtime_limit_long = plan.airtime_limit_long != null ? String(plan.airtime_limit_long) : '' +} + +async function loadRnodeConfig() { + rnodeLoading.value = true + try { + const res = await mesh.getRnodeConfig() + rnodeLive.value = res.live + rnodeLiveError.value = res.live_error + const s = res.settings as Record + if (!rnodeSeeded && s) { + rnodeSeeded = true + rnodeForm.value.enabled = s.enabled !== false + rnodeForm.value.port = (s.port as string) ?? '' + rnodeForm.value.frequency = String(s.frequency ?? '') + rnodeForm.value.bandwidth = String(s.bandwidth ?? '125000') + rnodeForm.value.spreading_factor = String(s.spreading_factor ?? '8') + rnodeForm.value.coding_rate = String(s.coding_rate ?? '5') + rnodeForm.value.txpower = String(s.txpower ?? '17') + rnodeForm.value.airtime_limit_short = s.airtime_limit_short != null ? String(s.airtime_limit_short) : '' + rnodeForm.value.airtime_limit_long = s.airtime_limit_long != null ? String(s.airtime_limit_long) : '' + } + } catch (e) { + rnodeLiveError.value = e instanceof Error ? e.message : 'Could not load RNode settings' + } finally { + rnodeLoading.value = false + } +} + +async function applyRnodeSettings() { + rnodeApplying.value = true + rnodeResult.value = null + try { + const res = await mesh.applyRnodeConfig({ + enabled: rnodeForm.value.enabled, + port: rnodeForm.value.port.trim() || null, + frequency: Number(rnodeForm.value.frequency), + bandwidth: Number(rnodeForm.value.bandwidth), + spreading_factor: Number(rnodeForm.value.spreading_factor), + coding_rate: Number(rnodeForm.value.coding_rate), + txpower: Number(rnodeForm.value.txpower), + airtime_limit_short: rnodeForm.value.airtime_limit_short === '' ? null : Number(rnodeForm.value.airtime_limit_short), + airtime_limit_long: rnodeForm.value.airtime_limit_long === '' ? null : Number(rnodeForm.value.airtime_limit_long), + }) + rnodeResult.value = { ok: res.applied, confirmed: !!res.confirmed, message: res.message } + if (res.live) rnodeLive.value = res.live + } catch (e) { + rnodeResult.value = { ok: false, confirmed: false, message: e instanceof Error ? e.message : 'Apply failed' } + } finally { + rnodeApplying.value = false + } +} + +async function refreshRnodeLive() { + rnodeLoading.value = true + try { + const res = await mesh.getRnodeConfig() + rnodeLive.value = res.live + rnodeLiveError.value = res.live_error + } catch (e) { + rnodeLiveError.value = e instanceof Error ? e.message : 'Could not read the radio state' + } finally { + rnodeLoading.value = false + } +} + +function fmtMhz(v: unknown): string { + const n = Number(v) + return Number.isFinite(n) && n > 0 ? `${(n / 1e6).toFixed(4)} MHz` : '—' +} + // ── Editable settings (persisted via mesh.configure) ── const form = ref({ region: '', @@ -157,6 +277,18 @@ async function saveSettings() { saving.value = false } } + +// Load the RNode settings + live state as soon as the panel knows a +// Reticulum radio is (or is pinned as) the device. Declared LAST: with +// `immediate: true` the source getter runs at setup, and `effectiveKind` +// must already exist (the SendBitcoinModal TDZ-crash lesson). +watch( + () => effectiveKind.value, + (kind) => { + if (kind === 'reticulum') void loadRnodeConfig() + }, + { immediate: true }, +)