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) {
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<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() {
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,
+225 -2
View File
@@ -7,12 +7,17 @@ const mesh = useMeshStore()
const rebooting = ref(false)
const rebootError = ref<string | null>(null)
const rebootMessage = ref<string | null>(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.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) ──
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 },
)
</script>
<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)` : '' }}.
</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.
Pick your region, then use "Set recommended for region" in the RNode section below.
</p>
</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.
</p>
</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">
<input v-model="form.broadcastIdentity" type="checkbox" class="h-4 w-4 accent-orange-500" />
Periodically broadcast this node's identity on the mesh
@@ -304,6 +526,7 @@ async function saveSettings() {
<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="rebootMessage" class="text-xs text-green-400 mt-1">{{ rebootMessage }}</p>
<p v-if="rebootError" class="mesh-device-reboot-error">{{ rebootError }}</p>
</div>
</div>