feat(mesh-ui): RNode settings editor with live device read-back + region presets
Demo images / Build & push demo images (push) Successful in 3m49s

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 <region>" 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 <noreply@anthropic.com>
This commit is contained in:
archipelago
2026-08-06 09:08:19 -04:00
co-authored by Claude Fable 5
parent 45fa8b6c6f
commit 3cef1d09f4
2 changed files with 251 additions and 3 deletions
+26 -1
View File
@@ -863,12 +863,35 @@ export const useMeshStore = defineStore('mesh', () => {
} }
async function rebootRadio(seconds = 2) { 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', method: 'mesh.reboot-radio',
params: { seconds }, params: { seconds },
timeout: 30000,
}) })
} }
/** Persisted RNode RF settings + live radio-confirmed state (Reticulum). */
async function getRnodeConfig() {
return rpcClient.call<{
settings: Record<string, unknown>
live: Record<string, unknown> | 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<string, unknown>) {
return rpcClient.call<{
applied: boolean
confirmed?: boolean
live?: Record<string, unknown> | null
message: string
}>({ method: 'mesh.rnode-config-apply', params: { settings }, timeout: 70000 })
}
async function getOutbox() { async function getOutbox() {
try { try {
return await rpcClient.call<{ count: number; messages?: unknown[] }>({ method: 'mesh.outbox' }) return await rpcClient.call<{ count: number; messages?: unknown[] }>({ method: 'mesh.outbox' })
@@ -1155,6 +1178,8 @@ export const useMeshStore = defineStore('mesh', () => {
sendReply, sendReply,
sendReaction, sendReaction,
rebootRadio, rebootRadio,
getRnodeConfig,
applyRnodeConfig,
getOutbox, getOutbox,
sendReadReceipt, sendReadReceipt,
forwardMessage, forwardMessage,
+225 -2
View File
@@ -7,12 +7,17 @@ const mesh = useMeshStore()
const rebooting = ref(false) const rebooting = ref(false)
const rebootError = ref<string | null>(null) const rebootError = ref<string | null>(null)
const rebootMessage = ref<string | null>(null)
async function handleReboot() { async function handleReboot() {
rebooting.value = true rebooting.value = true
rebootError.value = null rebootError.value = null
rebootMessage.value = null
try { 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) { } catch (e) {
rebootError.value = e instanceof Error ? e.message : 'Failed to reboot radio' rebootError.value = e instanceof Error ? e.message : 'Failed to reboot radio'
} finally { } 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.4869.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<string, { frequency: number; bandwidth: number; spreading_factor: number; coding_rate: number; txpower: number; airtime_limit_short: number | null; airtime_limit_long: number | null }> = {
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<Record<string, unknown> | null>(null)
const rnodeLiveError = ref<string | null>(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<string, unknown>
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) ── // ── Editable settings (persisted via mesh.configure) ──
const form = ref({ const form = ref({
region: '', region: '',
@@ -157,6 +277,18 @@ async function saveSettings() {
saving.value = false 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 },
)
</script> </script>
<template> <template>
@@ -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)` : '' }}. Program the radio's RF settings with the fields below every radio on your mesh must match{{ selectedRegion ? ` (${selectedRegion.band} MHz band)` : '' }}.
</p> </p>
<p v-else-if="effectiveKind === 'reticulum'" class="text-[11px] text-sky-300/80 mt-1"> <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. Pick your region, then use "Set recommended for region" in the RNode section below.
</p> </p>
</div> </div>
<div> <div>
@@ -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. 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> </p>
</div> </div>
<!-- RNode (Reticulum) RF settings: the device's CURRENT values shown
first (radio-confirmed read-back), then every parameter editable,
with apply → device confirmation. Actions stack in a column. -->
<div v-if="effectiveKind === 'reticulum'" class="mt-4">
<div class="flex items-center justify-between mb-2">
<h5 class="text-xs font-semibold text-white/80">RNode radio — current device settings</h5>
<button class="text-[11px] text-sky-300/80 hover:text-sky-200 disabled:opacity-50" :disabled="rnodeLoading" @click="refreshRnodeLive">
{{ rnodeLoading ? 'Reading' : 'Refresh' }}
</button>
</div>
<div v-if="rnodeLive" class="rounded-lg bg-white/[0.04] border border-white/10 p-3 mb-3 grid grid-cols-2 sm:grid-cols-4 gap-2 text-xs">
<div><span class="text-white/40 block">Status</span><span :class="rnodeLive.online ? 'text-green-400' : 'text-amber-400'">{{ rnodeLive.online ? 'Online' : 'Detected, not online' }}</span></div>
<div><span class="text-white/40 block">Port</span><span class="text-white/80">{{ rnodeLive.port || '' }}</span></div>
<div><span class="text-white/40 block">Frequency</span><span class="text-white/80">{{ fmtMhz(rnodeLive.r_frequency ?? rnodeLive.frequency) }}</span></div>
<div><span class="text-white/40 block">Bandwidth</span><span class="text-white/80">{{ rnodeLive.r_bandwidth ?? rnodeLive.bandwidth ?? '' }} Hz</span></div>
<div><span class="text-white/40 block">Spreading</span><span class="text-white/80">SF {{ rnodeLive.r_spreadingfactor ?? rnodeLive.spreadingfactor ?? '' }}</span></div>
<div><span class="text-white/40 block">Coding rate</span><span class="text-white/80">4/{{ rnodeLive.r_codingrate ?? rnodeLive.codingrate ?? '' }}</span></div>
<div><span class="text-white/40 block">TX power</span><span class="text-white/80">{{ rnodeLive.r_txpower ?? rnodeLive.txpower ?? '' }} dBm</span></div>
<div><span class="text-white/40 block">Airtime limits</span><span class="text-white/80">{{ rnodeLive.r_airtime_limit_short ?? rnodeLive.airtime_limit_short ?? '' }}% / {{ rnodeLive.r_airtime_limit_long ?? rnodeLive.airtime_limit_long ?? '' }}%</span></div>
</div>
<p v-else-if="rnodeLiveError" class="text-[11px] text-amber-400/80 mb-3">{{ rnodeLiveError }}</p>
<h5 class="text-xs font-semibold text-white/80 mb-2">RNode 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 (Hz)</label>
<input v-model="rnodeForm.frequency" inputmode="numeric" placeholder="869462500" 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 (Hz)</label>
<select v-model="rnodeForm.bandwidth" 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 v-for="bw in ['7800','10400','15600','20800','31250','41700','62500','125000','250000','500000']" :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="rnodeForm.spreading_factor" 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 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="rnodeForm.coding_rate" 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 v-for="cr in [5,6,7,8]" :key="cr" :value="String(cr)">4/{{ cr }}</option>
</select>
</div>
<div>
<label class="block text-xs text-white/60 mb-1">TX power (dBm)</label>
<input v-model="rnodeForm.txpower" inputmode="numeric" placeholder="14" 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">Airtime short (%)</label>
<input v-model="rnodeForm.airtime_limit_short" inputmode="decimal" placeholder="25" 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">Airtime long (%)</label>
<input v-model="rnodeForm.airtime_limit_long" inputmode="decimal" placeholder="10" 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">Serial port</label>
<input v-model="rnodeForm.port" placeholder="auto-detect" 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="flex items-center gap-2 mt-3 text-sm text-white/80 cursor-pointer">
<input v-model="rnodeForm.enabled" type="checkbox" class="h-4 w-4 accent-orange-500" />
RNode interface enabled
</label>
<!-- Actions: stacked in a column on purpose (operator layout request) -->
<div class="flex flex-col gap-2 mt-4 max-w-sm">
<button
class="glass-button px-4 py-2 rounded-lg text-sm font-medium disabled:opacity-50"
:disabled="!rnodeRegionPlan || rnodeApplying"
@click="setRnodeRecommendedForRegion"
>
{{ rnodeRegionPlan ? `Set recommended for ${form.region}` : 'Pick a region above first' }}
</button>
<button
class="glass-button glass-button-warning px-4 py-2 rounded-lg text-sm font-medium disabled:opacity-50"
:disabled="rnodeApplying"
@click="applyRnodeSettings"
>
{{ rnodeApplying ? 'Applying waiting for the radio to confirm' : 'Apply & Confirm on Device' }}
</button>
</div>
<p v-if="rnodeResult" class="text-xs mt-2" :class="rnodeResult.ok && rnodeResult.confirmed ? 'text-green-400' : rnodeResult.ok ? 'text-amber-400' : 'text-red-400'">
<template v-if="rnodeResult.ok && rnodeResult.confirmed">✓ </template>{{ rnodeResult.message }}
</p>
</div>
<label class="flex items-center gap-2 mt-3 text-sm text-white/80 cursor-pointer"> <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" /> <input v-model="form.broadcastIdentity" type="checkbox" class="h-4 w-4 accent-orange-500" />
Periodically broadcast this node's identity on the mesh Periodically broadcast this node's identity on the mesh
@@ -304,6 +526,7 @@ async function saveSettings() {
<template v-else>Reboot Radio</template> <template v-else>Reboot Radio</template>
</button> </button>
<p class="mesh-device-reboot-hint">Use this if the device stops responding to sent messages or seems stuck.</p> <p class="mesh-device-reboot-hint">Use this if the device stops responding to sent messages or seems stuck.</p>
<p v-if="rebootMessage" class="text-xs text-green-400 mt-1">{{ rebootMessage }}</p>
<p v-if="rebootError" class="mesh-device-reboot-error">{{ rebootError }}</p> <p v-if="rebootError" class="mesh-device-reboot-error">{{ rebootError }}</p>
</div> </div>
</div> </div>