From 3cef1d09f49d74db8e6ec95258978adbcbb25f65 Mon Sep 17 00:00:00 2001
From: archipelago
Date: Thu, 6 Aug 2026 09:08:19 -0400
Subject: [PATCH] feat(mesh-ui): RNode settings editor with live device
read-back + region presets
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The LoRa device panel's Reticulum section (operator .126 top priority):
- Shows the device's CURRENT settings first — the radio-confirmed r_*
values from mesh.rnode-config (online badge, port, frequency, bw,
SF, CR, txpower, airtime limits), with a Refresh action.
- Every RNodeInterface parameter is editable: enabled, serial port
(auto-detect when blank), frequency, bandwidth (RNode's discrete
set), SF 5-12, CR 4/5-4/8, txpower, airtime short/long %.
- "Set recommended for " fills the fields from per-region
plans (EU868 = the operator-validated Portugal plan incl. 25%/10%
duty-cycle locks); driven by the existing region selector above.
- Apply & Confirm on Device: persists, restarts the radio daemon, and
reports the radio's own confirmation (green ✓ only when the device
read-back matches; amber/red messages say what actually happened).
- Action buttons stack in a column (operator layout request).
- Reboot Radio surfaces the backend's real acknowledgement message.
Co-Authored-By: Claude Fable 5
---
neode-ui/src/stores/mesh.ts | 27 ++-
neode-ui/src/views/mesh/MeshDevicePanel.vue | 227 +++++++++++++++++++-
2 files changed, 251 insertions(+), 3 deletions(-)
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 },
+)
@@ -202,7 +334,7 @@ async function saveSettings() {
Program the radio's RF settings with the fields below — every radio on your mesh must match{{ selectedRegion ? ` (${selectedRegion.band} MHz band)` : '' }}.
- RNode RF parameters are managed by the Reticulum daemon's interface config on this node.
+ Pick your region, then use "Set recommended for region" in the RNode section below.
@@ -271,6 +403,96 @@ async function saveSettings() {
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.
+
+
+
+
RNode radio — current device settings
+
+
+
+
Status{{ rnodeLive.online ? 'Online' : 'Detected, not online' }}