Skip to content

Commit 9c65d7f

Browse files
committed
Show the event build's radio settings in the details sheet
Event firmware is compiled from userPrefs.jsonc on the matching event/<slug> branch of meshtastic/firmware. The Event Details sheet now fetches that file lazily on first open (keyed off the edition's firmware slug) and renders the settings flashers actually care about: LoRa region, modem preset, frequency slot, hop limit and ignore-MQTT; each channel with its name, base64 PSK, uplink flag and position precision; and MQTT server settings — plus a link to the source file on GitHub. A new utils/eventUserPrefs.ts holds the pure logic (string-aware JSONC comment stripping, C byte-array → base64 PSK conversion, protobuf enum prettifying, structured extraction) with colocated Vitest coverage, verified against the real event/defcon34 file. Fetch failures degrade to a muted "unavailable" note; static-fallback events without a firmware slug skip the settings section entirely. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WWbctVTeY3iySKcELxhcR5
1 parent 8f4b0ab commit 9c65d7f

4 files changed

Lines changed: 521 additions & 1 deletion

File tree

components/EventDetails.vue

Lines changed: 187 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,167 @@
108108
</dl>
109109
</div>
110110

111+
<!-- LoRa (from the event branch's userPrefs.jsonc) -->
112+
<div
113+
v-if="hasLora"
114+
class="rounded-lg border border-theme bg-surface-primary p-3 text-sm"
115+
>
116+
<p class="mb-2 text-xs font-semibold uppercase tracking-wide text-theme-muted">
117+
{{ $t('event.section_lora') }}
118+
</p>
119+
<dl class="grid grid-cols-[auto,1fr] gap-x-3 gap-y-1">
120+
<template v-if="prefs?.region">
121+
<dt class="text-theme-muted">
122+
{{ $t('event.label_region') }}
123+
</dt>
124+
<dd>{{ prefs.region }}</dd>
125+
</template>
126+
<template v-if="prefs?.modemPreset">
127+
<dt class="text-theme-muted">
128+
{{ $t('event.label_modem_preset') }}
129+
</dt>
130+
<dd>{{ prefs.modemPreset }}</dd>
131+
</template>
132+
<template v-if="prefs?.frequencySlot">
133+
<dt class="text-theme-muted">
134+
{{ $t('event.label_frequency_slot') }}
135+
</dt>
136+
<dd>{{ prefs.frequencySlot }}</dd>
137+
</template>
138+
<template v-if="prefs?.hopLimit">
139+
<dt class="text-theme-muted">
140+
{{ $t('event.label_hop_limit') }}
141+
</dt>
142+
<dd>{{ prefs.hopLimit }}</dd>
143+
</template>
144+
<template v-if="prefs?.ignoreMqtt !== undefined">
145+
<dt class="text-theme-muted">
146+
{{ $t('event.label_ignore_mqtt') }}
147+
</dt>
148+
<dd>{{ $t(prefs?.ignoreMqtt ? 'event.value_yes' : 'event.value_no') }}</dd>
149+
</template>
150+
</dl>
151+
</div>
152+
153+
<!-- Channels -->
154+
<div
155+
v-if="prefs?.channels.length"
156+
class="rounded-lg border border-theme bg-surface-primary p-3 text-sm"
157+
>
158+
<p class="mb-2 text-xs font-semibold uppercase tracking-wide text-theme-muted">
159+
{{ $t('event.section_channels') }}
160+
</p>
161+
<div
162+
v-for="channel in prefs.channels"
163+
:key="channel.index"
164+
:class="channel.index > 0 ? 'mt-2 border-t border-[var(--border-default)] pt-2' : ''"
165+
>
166+
<p class="mb-1 font-medium text-theme">
167+
{{ channel.name || '#' + channel.index }}
168+
</p>
169+
<dl class="grid grid-cols-[auto,1fr] gap-x-3 gap-y-1">
170+
<template v-if="channel.psk">
171+
<dt class="text-theme-muted">
172+
{{ $t('event.label_psk') }}
173+
</dt>
174+
<dd class="break-all font-mono text-theme-accent">
175+
{{ channel.psk }}
176+
</dd>
177+
</template>
178+
<template v-if="channel.uplinkEnabled !== undefined">
179+
<dt class="text-theme-muted">
180+
{{ $t('event.label_uplink') }}
181+
</dt>
182+
<dd>{{ $t(channel.uplinkEnabled ? 'event.value_yes' : 'event.value_no') }}</dd>
183+
</template>
184+
<template v-if="channel.positionPrecision">
185+
<dt class="text-theme-muted">
186+
{{ $t('event.label_precision') }}
187+
</dt>
188+
<dd>{{ channel.positionPrecision }}</dd>
189+
</template>
190+
</dl>
191+
</div>
192+
</div>
193+
194+
<!-- MQTT -->
195+
<div
196+
v-if="hasMqtt"
197+
class="rounded-lg border border-theme bg-surface-primary p-3 text-sm"
198+
>
199+
<p class="mb-2 text-xs font-semibold uppercase tracking-wide text-theme-muted">
200+
{{ $t('event.section_mqtt') }}
201+
</p>
202+
<dl class="grid grid-cols-[auto,1fr] gap-x-3 gap-y-1">
203+
<template v-if="prefs?.mqtt.address">
204+
<dt class="text-theme-muted">
205+
{{ $t('event.label_mqtt_address') }}
206+
</dt>
207+
<dd class="break-all">
208+
{{ prefs.mqtt.address }}
209+
</dd>
210+
</template>
211+
<template v-if="prefs?.mqtt.username">
212+
<dt class="text-theme-muted">
213+
{{ $t('event.label_mqtt_username') }}
214+
</dt>
215+
<dd>{{ prefs.mqtt.username }}</dd>
216+
</template>
217+
<template v-if="prefs?.mqtt.password">
218+
<dt class="text-theme-muted">
219+
{{ $t('event.label_mqtt_password') }}
220+
</dt>
221+
<dd class="break-all font-mono">
222+
{{ prefs.mqtt.password }}
223+
</dd>
224+
</template>
225+
<template v-if="prefs?.mqtt.rootTopic">
226+
<dt class="text-theme-muted">
227+
{{ $t('event.label_mqtt_root_topic') }}
228+
</dt>
229+
<dd class="break-all">
230+
{{ prefs.mqtt.rootTopic }}
231+
</dd>
232+
</template>
233+
<template v-if="prefs?.mqtt.encryptionEnabled !== undefined">
234+
<dt class="text-theme-muted">
235+
{{ $t('event.label_mqtt_encryption') }}
236+
</dt>
237+
<dd>{{ $t(prefs?.mqtt.encryptionEnabled ? 'event.value_yes' : 'event.value_no') }}</dd>
238+
</template>
239+
<template v-if="prefs?.mqtt.tlsEnabled !== undefined">
240+
<dt class="text-theme-muted">
241+
{{ $t('event.label_mqtt_tls') }}
242+
</dt>
243+
<dd>{{ $t(prefs?.mqtt.tlsEnabled ? 'event.value_yes' : 'event.value_no') }}</dd>
244+
</template>
245+
</dl>
246+
</div>
247+
248+
<!-- Settings fetch status + source link -->
249+
<p
250+
v-if="prefsState === 'loading'"
251+
class="text-sm text-theme-muted"
252+
>
253+
{{ $t('event.settings_loading') }}
254+
</p>
255+
<p
256+
v-else-if="prefsState === 'error'"
257+
class="text-sm text-theme-muted"
258+
>
259+
{{ $t('event.settings_unavailable') }}
260+
</p>
261+
<a
262+
v-if="prefsSourceUrl"
263+
:href="prefsSourceUrl"
264+
target="_blank"
265+
rel="noopener noreferrer"
266+
class="inline-flex items-center gap-1.5 text-sm text-meshtastic underline underline-offset-2"
267+
>
268+
{{ $t('event.view_userprefs') }}
269+
<ExternalLink class="h-3.5 w-3.5 shrink-0" />
270+
</a>
271+
111272
<!-- Theme -->
112273
<div
113274
v-if="eventMode.theme"
@@ -184,6 +345,7 @@ import { computed, nextTick, ref, watch } from 'vue'
184345
import { onKeyStroke } from '@vueuse/core'
185346
import { ExternalLink, Info, X } from 'lucide-vue-next'
186347
import { useI18n } from 'vue-i18n'
348+
import type { EventUserPrefs } from '~/utils/eventUserPrefs'
187349
import { useSerialMonitorStore } from '~/stores/serialMonitorStore'
188350
189351
const { eventMode } = useEventMode()
@@ -198,8 +360,33 @@ const closeButton = ref<HTMLButtonElement | null>(null)
198360
const dateRange = computed(() => formatEventDateRange(edition.value?.eventStart, edition.value?.eventEnd, locale.value))
199361
const firmwareVersion = computed(() => edition.value?.firmware?.version || eventMode.value.firmware.id)
200362
363+
// Radio settings from the event branch's userPrefs.jsonc, fetched lazily on
364+
// first open so non-event visitors and closed sheets cost nothing.
365+
const prefs = ref<EventUserPrefs | null>(null)
366+
const prefsState = ref<'idle' | 'loading' | 'loaded' | 'error'>('idle')
367+
const firmwareSlug = computed(() => edition.value?.firmware?.slug)
368+
const prefsSourceUrl = computed(() => firmwareSlug.value ? eventUserPrefsSourceUrl(firmwareSlug.value) : undefined)
369+
const hasLora = computed(() => !!prefs.value && (
370+
prefs.value.region !== undefined
371+
|| prefs.value.modemPreset !== undefined
372+
|| prefs.value.frequencySlot !== undefined
373+
|| prefs.value.hopLimit !== undefined
374+
|| prefs.value.ignoreMqtt !== undefined
375+
))
376+
const hasMqtt = computed(() => !!prefs.value && Object.values(prefs.value.mqtt).some(v => v !== undefined))
377+
378+
async function loadPrefs() {
379+
const slug = firmwareSlug.value
380+
if (prefsState.value !== 'idle' || !slug) return
381+
prefsState.value = 'loading'
382+
const result = await fetchEventUserPrefs(slug)
383+
prefs.value = result
384+
prefsState.value = result ? 'loaded' : 'error'
385+
}
386+
201387
function openPanel() {
202388
open.value = true
389+
loadPrefs()
203390
nextTick(() => closeButton.value?.focus())
204391
}
205392

i18n/locales/en.json

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -132,6 +132,9 @@
132132
"details_title": "Event Firmware Details",
133133
"section_event": "Event",
134134
"section_firmware": "Firmware",
135+
"section_lora": "LoRa",
136+
"section_channels": "Channels",
137+
"section_mqtt": "MQTT",
135138
"section_theme": "Theme",
136139
"section_links": "Links",
137140
"label_name": "Name",
@@ -140,9 +143,28 @@
140143
"label_timezone": "Time zone",
141144
"label_build": "Build",
142145
"label_version": "Version",
146+
"label_region": "Region",
147+
"label_modem_preset": "Modem preset",
148+
"label_frequency_slot": "Frequency slot",
149+
"label_hop_limit": "Hop limit",
150+
"label_ignore_mqtt": "Ignore MQTT",
151+
"label_psk": "PSK",
152+
"label_uplink": "Uplink",
153+
"label_precision": "Position precision",
154+
"label_mqtt_address": "Address",
155+
"label_mqtt_username": "Username",
156+
"label_mqtt_password": "Password",
157+
"label_mqtt_root_topic": "Root topic",
158+
"label_mqtt_encryption": "Encryption",
159+
"label_mqtt_tls": "TLS",
143160
"label_theme_name": "Theme",
144161
"label_tagline": "Tagline",
145-
"label_palette": "Palette"
162+
"label_palette": "Palette",
163+
"settings_loading": "Loading radio settings…",
164+
"settings_unavailable": "Radio settings are unavailable right now.",
165+
"view_userprefs": "View userPrefs.jsonc on GitHub",
166+
"value_yes": "Yes",
167+
"value_no": "No"
146168
},
147169
"serial": {
148170
"instructions": "If the disconnect process is taking too long, you can manually unplug the device.",

utils/eventUserPrefs.test.ts

Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
import { describe, expect, it } from 'vitest'
2+
3+
import { byteArrayToBase64, eventUserPrefsRawUrl, eventUserPrefsSourceUrl, parseEventUserPrefs, parseJsonc, prettyEnumValue } from './eventUserPrefs'
4+
5+
// Trimmed copy of the real event/defcon34 userPrefs.jsonc shape.
6+
const FIXTURE = `{
7+
// "USERPREFS_BUTTON_PIN": "36",
8+
"USERPREFS_CHANNELS_TO_WRITE": "2",
9+
"USERPREFS_CHANNEL_0_NAME": "DEFCONnect",
10+
"USERPREFS_CHANNEL_0_PRECISION": "14",
11+
"USERPREFS_CHANNEL_0_PSK": "{ 0x38, 0x4b, 0xbc, 0xc0, 0x1d, 0xc0, 0x22, 0xd1, 0x81, 0xbf, 0x36, 0xb8, 0x61, 0x21, 0xe1, 0xfb, 0x96, 0xb7, 0x2e, 0x55, 0xbf, 0x74, 0x22, 0x7e, 0x9d, 0x6a, 0xfb, 0x48, 0xd6, 0x4c, 0xb1, 0xa1 }",
12+
"USERPREFS_CHANNEL_0_UPLINK_ENABLED": "true",
13+
"USERPREFS_CHANNEL_1_NAME": "HackerComms",
14+
"USERPREFS_CHANNEL_1_PSK": "{ 0xe8, 0x8c, 0xec, 0x6a, 0x85, 0x61, 0xc7, 0x51, 0x13, 0x59, 0xe5, 0xae, 0xbb, 0x47, 0x54, 0x58, 0xc2, 0xea, 0x22, 0xdb, 0xd8, 0x24, 0xb6, 0xd1, 0xcf, 0x08, 0x13, 0x00, 0xa0, 0x9f, 0xbe, 0xd6 }",
15+
"USERPREFS_CONFIG_LORA_IGNORE_MQTT": "true",
16+
"USERPREFS_CONFIG_LORA_REGION": "meshtastic_Config_LoRaConfig_RegionCode_US",
17+
"USERPREFS_EVENT_MODE": "1",
18+
"USERPREFS_EVENT_MODE_HOP_LIMIT": "4", // Event-mode default hop cap (0-7; default 3)
19+
"USERPREFS_LORACONFIG_CHANNEL_NUM": "31",
20+
"USERPREFS_LORACONFIG_MODEM_PRESET": "meshtastic_Config_LoRaConfig_ModemPreset_SHORT_TURBO",
21+
"USERPREFS_MQTT_ADDRESS": "'mqtt.defcon.org'",
22+
"USERPREFS_MQTT_USERNAME": "public",
23+
"USERPREFS_MQTT_TLS_ENABLED": "true",
24+
"USERPREFS_MQTT_ROOT_TOPIC": "event/defcon34"
25+
}`
26+
27+
describe('parseJsonc', () => {
28+
it('strips line comments, keeps active entries', () => {
29+
const raw = parseJsonc(FIXTURE)
30+
expect(raw.USERPREFS_BUTTON_PIN).toBeUndefined()
31+
expect(raw.USERPREFS_CHANNEL_0_NAME).toBe('DEFCONnect')
32+
})
33+
34+
it('ignores inline comments after values but not slashes inside strings', () => {
35+
const raw = parseJsonc(FIXTURE)
36+
expect(raw.USERPREFS_EVENT_MODE_HOP_LIMIT).toBe('4')
37+
expect(raw.USERPREFS_MQTT_ROOT_TOPIC).toBe('event/defcon34')
38+
})
39+
40+
it('handles block comments and trailing commas', () => {
41+
const raw = parseJsonc('{ /* note */ "A": "1", }')
42+
expect(raw).toEqual({ A: '1' })
43+
})
44+
})
45+
46+
describe('byteArrayToBase64', () => {
47+
it('converts a C byte-array initializer to base64', () => {
48+
expect(byteArrayToBase64('{ 0x38, 0x4b }')).toBe('OEs=')
49+
})
50+
51+
it('returns undefined for empty or invalid input', () => {
52+
expect(byteArrayToBase64('{}')).toBeUndefined()
53+
expect(byteArrayToBase64('')).toBeUndefined()
54+
expect(byteArrayToBase64('not bytes')).toBeUndefined()
55+
expect(byteArrayToBase64('{ 0x100 }')).toBeUndefined()
56+
})
57+
})
58+
59+
describe('prettyEnumValue', () => {
60+
it('extracts and title-cases the enum constant', () => {
61+
expect(prettyEnumValue('meshtastic_Config_LoRaConfig_ModemPreset_SHORT_TURBO')).toBe('Short Turbo')
62+
})
63+
64+
it('keeps short and numeric words uppercase', () => {
65+
expect(prettyEnumValue('meshtastic_Config_LoRaConfig_RegionCode_US')).toBe('US')
66+
expect(prettyEnumValue('meshtastic_Config_LoRaConfig_RegionCode_EU_868')).toBe('EU 868')
67+
})
68+
69+
it('passes through non-enum values', () => {
70+
expect(prettyEnumValue('plain')).toBe('plain')
71+
expect(prettyEnumValue(undefined)).toBeUndefined()
72+
})
73+
})
74+
75+
describe('parseEventUserPrefs', () => {
76+
const prefs = parseEventUserPrefs(FIXTURE)
77+
78+
it('extracts LoRa settings', () => {
79+
expect(prefs.region).toBe('US')
80+
expect(prefs.modemPreset).toBe('Short Turbo')
81+
expect(prefs.frequencySlot).toBe('31')
82+
expect(prefs.hopLimit).toBe('4')
83+
expect(prefs.ignoreMqtt).toBe(true)
84+
})
85+
86+
it('extracts the declared channels with base64 PSKs', () => {
87+
expect(prefs.channels).toHaveLength(2)
88+
expect(prefs.channels[0]).toEqual({
89+
index: 0,
90+
name: 'DEFCONnect',
91+
psk: 'OEu8wB3AItGBvza4YSHh+5a3LlW/dCJ+nWr7SNZMsaE=',
92+
uplinkEnabled: true,
93+
positionPrecision: '14',
94+
})
95+
expect(prefs.channels[1].name).toBe('HackerComms')
96+
expect(prefs.channels[1].psk).toBe('6IzsaoVhx1ETWeWuu0dUWMLqItvYJLbRzwgTAKCfvtY=')
97+
expect(prefs.channels[1].uplinkEnabled).toBeUndefined()
98+
})
99+
100+
it('extracts MQTT settings, unquoting C string literals', () => {
101+
expect(prefs.mqtt.address).toBe('mqtt.defcon.org')
102+
expect(prefs.mqtt.username).toBe('public')
103+
expect(prefs.mqtt.password).toBeUndefined()
104+
expect(prefs.mqtt.rootTopic).toBe('event/defcon34')
105+
expect(prefs.mqtt.tlsEnabled).toBe(true)
106+
expect(prefs.mqtt.encryptionEnabled).toBeUndefined()
107+
})
108+
109+
it('returns no channels when the count is absent', () => {
110+
expect(parseEventUserPrefs('{}').channels).toEqual([])
111+
})
112+
})
113+
114+
describe('url builders', () => {
115+
it('builds the raw and source URLs from the firmware slug', () => {
116+
expect(eventUserPrefsRawUrl('defcon34')).toBe('https://raw.githubusercontent.com/meshtastic/firmware/event/defcon34/userPrefs.jsonc')
117+
expect(eventUserPrefsSourceUrl('defcon34')).toBe('https://github.com/meshtastic/firmware/blob/event/defcon34/userPrefs.jsonc')
118+
})
119+
})

0 commit comments

Comments
 (0)