From 8f4b0ab978e2a8f11b07ad56799fb3cd261dfa67 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 29 Jul 2026 21:03:50 +0000 Subject: [PATCH 1/4] Add event firmware details sheet to the event flasher Adds an "Event Details" button to the main button row (event mode only, e.g. the DEF CON flasher) that opens a dismissible sheet showing the event firmware's details: event name, dates, location and time zone, firmware build and version, theme name/tagline/palette, and official event links. The sheet follows the existing pure-Vue dialog pattern (AlphanautFeedback) and closes via the X button, backdrop click, or Escape with focus restored to the trigger. The full manifest edition (dates, location, links, firmware version) was previously discarded after manifestEditionToEventMode(); it is now kept in a new useEventEdition composable, set only alongside setActiveEventMode so the displayed details never diverge from the applied event mode. Event dates are date-only strings, so a small formatEventDateRange util parses them as local dates (avoiding the UTC off-by-one) and is covered by colocated Vitest tests. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01WWbctVTeY3iySKcELxhcR5 --- app.vue | 1 + components/EventDetails.vue | 220 +++++++++++++++++++++++++++++++++ composables/useEventEdition.ts | 15 +++ i18n/locales/en.json | 17 +++ plugins/eventMode.client.ts | 4 + utils/eventDates.test.ts | 34 +++++ utils/eventDates.ts | 21 ++++ 7 files changed, 312 insertions(+) create mode 100644 components/EventDetails.vue create mode 100644 composables/useEventEdition.ts create mode 100644 utils/eventDates.test.ts create mode 100644 utils/eventDates.ts diff --git a/app.vue b/app.vue index 7ee89f4..264fd19 100644 --- a/app.vue +++ b/app.vue @@ -150,6 +150,7 @@ {{ $t('buttons.contribute') }} + diff --git a/components/EventDetails.vue b/components/EventDetails.vue new file mode 100644 index 0000000..c821460 --- /dev/null +++ b/components/EventDetails.vue @@ -0,0 +1,220 @@ + + + diff --git a/composables/useEventEdition.ts b/composables/useEventEdition.ts new file mode 100644 index 0000000..3d07491 --- /dev/null +++ b/composables/useEventEdition.ts @@ -0,0 +1,15 @@ +import { ref } from 'vue' +import type { EventFirmwareEdition } from '~/types/eventFirmware' + +// The full manifest edition resolved by plugins/eventMode.client.ts. The +// eventMode singleton (types/resources.ts) only carries the subset needed to +// drive the flasher, so the edition's extra detail (dates, location, links, +// firmware version) is kept here for display. Null when no manifest edition is +// active (non-event host, or a static fallback event like Hamcation). +const activeEventEdition = ref(null) + +export function setActiveEventEdition(edition: EventFirmwareEdition): void { + activeEventEdition.value = edition +} + +export const useEventEdition = () => activeEventEdition diff --git a/i18n/locales/en.json b/i18n/locales/en.json index 5b2bdcd..445e1a5 100644 --- a/i18n/locales/en.json +++ b/i18n/locales/en.json @@ -127,6 +127,23 @@ "meshtastic_docs": "Meshtastic Docs", "contribute": "Contribute on GitHub" }, + "event": { + "details_button": "Event Details", + "details_title": "Event Firmware Details", + "section_event": "Event", + "section_firmware": "Firmware", + "section_theme": "Theme", + "section_links": "Links", + "label_name": "Name", + "label_dates": "Dates", + "label_location": "Location", + "label_timezone": "Time zone", + "label_build": "Build", + "label_version": "Version", + "label_theme_name": "Theme", + "label_tagline": "Tagline", + "label_palette": "Palette" + }, "serial": { "instructions": "If the disconnect process is taking too long, you can manually unplug the device.", "disconnect": "Disconnect", diff --git a/plugins/eventMode.client.ts b/plugins/eventMode.client.ts index 91bed15..f484c9c 100644 --- a/plugins/eventMode.client.ts +++ b/plugins/eventMode.client.ts @@ -1,6 +1,7 @@ import { fetchApiManifest, fetchBundledManifest, hostMatches, isFirmwareDowngrade, manifestEditionToEventMode, resolveActiveEdition, applyEventTheme } from '~/utils/eventManifest' import type { EventFirmwareResponse } from '~/types/eventFirmware' import { eventMode, setActiveEventMode, staticEventModes } from '~/types/resources' +import { setActiveEventEdition } from '~/composables/useEventEdition' // Resolve the active event edition for the current host. ssr:false guarantees // window is available, and Nuxt awaits async plugins — so we gate first paint @@ -25,6 +26,9 @@ export default defineNuxtPlugin(async () => { return true } setActiveEventMode(next) + // Keep the full edition (dates, location, links, firmware version) for + // display — set only alongside setActiveEventMode so the two never diverge. + setActiveEventEdition(edition) applyEventTheme(edition.theme) return true } diff --git a/utils/eventDates.test.ts b/utils/eventDates.test.ts new file mode 100644 index 0000000..8228fa7 --- /dev/null +++ b/utils/eventDates.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from 'vitest' + +import { formatEventDateRange } from './eventDates' + +describe('formatEventDateRange', () => { + it('formats a start/end range', () => { + expect(formatEventDateRange('2026-08-06', '2026-08-09')).toBe('Aug 6, 2026 – Aug 9, 2026') + }) + + it('formats a start-only date', () => { + expect(formatEventDateRange('2026-08-06', null)).toBe('Aug 6, 2026') + }) + + it('collapses a same-day range to a single date', () => { + expect(formatEventDateRange('2026-08-06', '2026-08-06')).toBe('Aug 6, 2026') + }) + + it('returns an empty string without a valid start date', () => { + expect(formatEventDateRange(null, '2026-08-09')).toBe('') + expect(formatEventDateRange(undefined)).toBe('') + expect(formatEventDateRange('', '')).toBe('') + expect(formatEventDateRange('not-a-date')).toBe('') + expect(formatEventDateRange('2026-8-6')).toBe('') + }) + + it('keeps the calendar day regardless of the local time zone', () => { + // A UTC-midnight parse would render Aug 6 as Aug 5 anywhere west of UTC. + expect(formatEventDateRange('2026-08-06')).toContain('6') + }) + + it('respects the requested locale', () => { + expect(formatEventDateRange('2026-08-06', '2026-08-09', 'de')).toContain('Aug') + }) +}) diff --git a/utils/eventDates.ts b/utils/eventDates.ts new file mode 100644 index 0000000..d8bdd7e --- /dev/null +++ b/utils/eventDates.ts @@ -0,0 +1,21 @@ +// Event dates in the eventFirmware manifest are date-only strings +// ("2026-08-06"). Parsing those with `new Date(str)` yields UTC midnight, +// which renders as the previous day in negative-offset time zones — so parse +// the parts explicitly into a local Date instead. +function parseDateOnly(value?: string | null): Date | null { + if (!value) return null + const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(value.trim()) + if (!match) return null + const date = new Date(Number(match[1]), Number(match[2]) - 1, Number(match[3])) + return Number.isNaN(date.getTime()) ? null : date +} + +export function formatEventDateRange(start?: string | null, end?: string | null, locale = 'en'): string { + const startDate = parseDateOnly(start) + if (!startDate) return '' + const options: Intl.DateTimeFormatOptions = { year: 'numeric', month: 'short', day: 'numeric' } + const startText = startDate.toLocaleDateString(locale, options) + const endDate = parseDateOnly(end) + if (!endDate || endDate.getTime() === startDate.getTime()) return startText + return `${startText} – ${endDate.toLocaleDateString(locale, options)}` +} From 9c65d7fa24f7d1f62c330c8a3b0280b78dc8c255 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 29 Jul 2026 21:57:40 +0000 Subject: [PATCH 2/4] Show the event build's radio settings in the details sheet MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Event firmware is compiled from userPrefs.jsonc on the matching event/ 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 Claude-Session: https://claude.ai/code/session_01WWbctVTeY3iySKcELxhcR5 --- components/EventDetails.vue | 187 ++++++++++++++++++++++++++++++++++ i18n/locales/en.json | 24 ++++- utils/eventUserPrefs.test.ts | 119 ++++++++++++++++++++++ utils/eventUserPrefs.ts | 192 +++++++++++++++++++++++++++++++++++ 4 files changed, 521 insertions(+), 1 deletion(-) create mode 100644 utils/eventUserPrefs.test.ts create mode 100644 utils/eventUserPrefs.ts diff --git a/components/EventDetails.vue b/components/EventDetails.vue index c821460..785d326 100644 --- a/components/EventDetails.vue +++ b/components/EventDetails.vue @@ -108,6 +108,167 @@ + +
+

+ {{ $t('event.section_lora') }} +

+
+ + + + + +
+
+ + +
+

+ {{ $t('event.section_channels') }} +

+
+

+ {{ channel.name || '#' + channel.index }} +

+
+ + + +
+
+
+ + +
+

+ {{ $t('event.section_mqtt') }} +

+
+ + + + + + +
+
+ + +

+ {{ $t('event.settings_loading') }} +

+

+ {{ $t('event.settings_unavailable') }} +

+ + {{ $t('event.view_userprefs') }} + + +
(null) const dateRange = computed(() => formatEventDateRange(edition.value?.eventStart, edition.value?.eventEnd, locale.value)) const firmwareVersion = computed(() => edition.value?.firmware?.version || eventMode.value.firmware.id) +// Radio settings from the event branch's userPrefs.jsonc, fetched lazily on +// first open so non-event visitors and closed sheets cost nothing. +const prefs = ref(null) +const prefsState = ref<'idle' | 'loading' | 'loaded' | 'error'>('idle') +const firmwareSlug = computed(() => edition.value?.firmware?.slug) +const prefsSourceUrl = computed(() => firmwareSlug.value ? eventUserPrefsSourceUrl(firmwareSlug.value) : undefined) +const hasLora = computed(() => !!prefs.value && ( + prefs.value.region !== undefined + || prefs.value.modemPreset !== undefined + || prefs.value.frequencySlot !== undefined + || prefs.value.hopLimit !== undefined + || prefs.value.ignoreMqtt !== undefined +)) +const hasMqtt = computed(() => !!prefs.value && Object.values(prefs.value.mqtt).some(v => v !== undefined)) + +async function loadPrefs() { + const slug = firmwareSlug.value + if (prefsState.value !== 'idle' || !slug) return + prefsState.value = 'loading' + const result = await fetchEventUserPrefs(slug) + prefs.value = result + prefsState.value = result ? 'loaded' : 'error' +} + function openPanel() { open.value = true + loadPrefs() nextTick(() => closeButton.value?.focus()) } diff --git a/i18n/locales/en.json b/i18n/locales/en.json index 445e1a5..ebd1fcd 100644 --- a/i18n/locales/en.json +++ b/i18n/locales/en.json @@ -132,6 +132,9 @@ "details_title": "Event Firmware Details", "section_event": "Event", "section_firmware": "Firmware", + "section_lora": "LoRa", + "section_channels": "Channels", + "section_mqtt": "MQTT", "section_theme": "Theme", "section_links": "Links", "label_name": "Name", @@ -140,9 +143,28 @@ "label_timezone": "Time zone", "label_build": "Build", "label_version": "Version", + "label_region": "Region", + "label_modem_preset": "Modem preset", + "label_frequency_slot": "Frequency slot", + "label_hop_limit": "Hop limit", + "label_ignore_mqtt": "Ignore MQTT", + "label_psk": "PSK", + "label_uplink": "Uplink", + "label_precision": "Position precision", + "label_mqtt_address": "Address", + "label_mqtt_username": "Username", + "label_mqtt_password": "Password", + "label_mqtt_root_topic": "Root topic", + "label_mqtt_encryption": "Encryption", + "label_mqtt_tls": "TLS", "label_theme_name": "Theme", "label_tagline": "Tagline", - "label_palette": "Palette" + "label_palette": "Palette", + "settings_loading": "Loading radio settings…", + "settings_unavailable": "Radio settings are unavailable right now.", + "view_userprefs": "View userPrefs.jsonc on GitHub", + "value_yes": "Yes", + "value_no": "No" }, "serial": { "instructions": "If the disconnect process is taking too long, you can manually unplug the device.", diff --git a/utils/eventUserPrefs.test.ts b/utils/eventUserPrefs.test.ts new file mode 100644 index 0000000..2c3d57a --- /dev/null +++ b/utils/eventUserPrefs.test.ts @@ -0,0 +1,119 @@ +import { describe, expect, it } from 'vitest' + +import { byteArrayToBase64, eventUserPrefsRawUrl, eventUserPrefsSourceUrl, parseEventUserPrefs, parseJsonc, prettyEnumValue } from './eventUserPrefs' + +// Trimmed copy of the real event/defcon34 userPrefs.jsonc shape. +const FIXTURE = `{ + // "USERPREFS_BUTTON_PIN": "36", + "USERPREFS_CHANNELS_TO_WRITE": "2", + "USERPREFS_CHANNEL_0_NAME": "DEFCONnect", + "USERPREFS_CHANNEL_0_PRECISION": "14", + "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 }", + "USERPREFS_CHANNEL_0_UPLINK_ENABLED": "true", + "USERPREFS_CHANNEL_1_NAME": "HackerComms", + "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 }", + "USERPREFS_CONFIG_LORA_IGNORE_MQTT": "true", + "USERPREFS_CONFIG_LORA_REGION": "meshtastic_Config_LoRaConfig_RegionCode_US", + "USERPREFS_EVENT_MODE": "1", + "USERPREFS_EVENT_MODE_HOP_LIMIT": "4", // Event-mode default hop cap (0-7; default 3) + "USERPREFS_LORACONFIG_CHANNEL_NUM": "31", + "USERPREFS_LORACONFIG_MODEM_PRESET": "meshtastic_Config_LoRaConfig_ModemPreset_SHORT_TURBO", + "USERPREFS_MQTT_ADDRESS": "'mqtt.defcon.org'", + "USERPREFS_MQTT_USERNAME": "public", + "USERPREFS_MQTT_TLS_ENABLED": "true", + "USERPREFS_MQTT_ROOT_TOPIC": "event/defcon34" +}` + +describe('parseJsonc', () => { + it('strips line comments, keeps active entries', () => { + const raw = parseJsonc(FIXTURE) + expect(raw.USERPREFS_BUTTON_PIN).toBeUndefined() + expect(raw.USERPREFS_CHANNEL_0_NAME).toBe('DEFCONnect') + }) + + it('ignores inline comments after values but not slashes inside strings', () => { + const raw = parseJsonc(FIXTURE) + expect(raw.USERPREFS_EVENT_MODE_HOP_LIMIT).toBe('4') + expect(raw.USERPREFS_MQTT_ROOT_TOPIC).toBe('event/defcon34') + }) + + it('handles block comments and trailing commas', () => { + const raw = parseJsonc('{ /* note */ "A": "1", }') + expect(raw).toEqual({ A: '1' }) + }) +}) + +describe('byteArrayToBase64', () => { + it('converts a C byte-array initializer to base64', () => { + expect(byteArrayToBase64('{ 0x38, 0x4b }')).toBe('OEs=') + }) + + it('returns undefined for empty or invalid input', () => { + expect(byteArrayToBase64('{}')).toBeUndefined() + expect(byteArrayToBase64('')).toBeUndefined() + expect(byteArrayToBase64('not bytes')).toBeUndefined() + expect(byteArrayToBase64('{ 0x100 }')).toBeUndefined() + }) +}) + +describe('prettyEnumValue', () => { + it('extracts and title-cases the enum constant', () => { + expect(prettyEnumValue('meshtastic_Config_LoRaConfig_ModemPreset_SHORT_TURBO')).toBe('Short Turbo') + }) + + it('keeps short and numeric words uppercase', () => { + expect(prettyEnumValue('meshtastic_Config_LoRaConfig_RegionCode_US')).toBe('US') + expect(prettyEnumValue('meshtastic_Config_LoRaConfig_RegionCode_EU_868')).toBe('EU 868') + }) + + it('passes through non-enum values', () => { + expect(prettyEnumValue('plain')).toBe('plain') + expect(prettyEnumValue(undefined)).toBeUndefined() + }) +}) + +describe('parseEventUserPrefs', () => { + const prefs = parseEventUserPrefs(FIXTURE) + + it('extracts LoRa settings', () => { + expect(prefs.region).toBe('US') + expect(prefs.modemPreset).toBe('Short Turbo') + expect(prefs.frequencySlot).toBe('31') + expect(prefs.hopLimit).toBe('4') + expect(prefs.ignoreMqtt).toBe(true) + }) + + it('extracts the declared channels with base64 PSKs', () => { + expect(prefs.channels).toHaveLength(2) + expect(prefs.channels[0]).toEqual({ + index: 0, + name: 'DEFCONnect', + psk: 'OEu8wB3AItGBvza4YSHh+5a3LlW/dCJ+nWr7SNZMsaE=', + uplinkEnabled: true, + positionPrecision: '14', + }) + expect(prefs.channels[1].name).toBe('HackerComms') + expect(prefs.channels[1].psk).toBe('6IzsaoVhx1ETWeWuu0dUWMLqItvYJLbRzwgTAKCfvtY=') + expect(prefs.channels[1].uplinkEnabled).toBeUndefined() + }) + + it('extracts MQTT settings, unquoting C string literals', () => { + expect(prefs.mqtt.address).toBe('mqtt.defcon.org') + expect(prefs.mqtt.username).toBe('public') + expect(prefs.mqtt.password).toBeUndefined() + expect(prefs.mqtt.rootTopic).toBe('event/defcon34') + expect(prefs.mqtt.tlsEnabled).toBe(true) + expect(prefs.mqtt.encryptionEnabled).toBeUndefined() + }) + + it('returns no channels when the count is absent', () => { + expect(parseEventUserPrefs('{}').channels).toEqual([]) + }) +}) + +describe('url builders', () => { + it('builds the raw and source URLs from the firmware slug', () => { + expect(eventUserPrefsRawUrl('defcon34')).toBe('https://raw.githubusercontent.com/meshtastic/firmware/event/defcon34/userPrefs.jsonc') + expect(eventUserPrefsSourceUrl('defcon34')).toBe('https://github.com/meshtastic/firmware/blob/event/defcon34/userPrefs.jsonc') + }) +}) diff --git a/utils/eventUserPrefs.ts b/utils/eventUserPrefs.ts new file mode 100644 index 0000000..6bc6ef7 --- /dev/null +++ b/utils/eventUserPrefs.ts @@ -0,0 +1,192 @@ +// Event firmware builds are compiled from a userPrefs.jsonc on the matching +// `event/` branch of meshtastic/firmware (e.g. event/defcon34). That +// file is the source of truth for the radio settings baked into the build — +// LoRa region/preset, channels and their PSKs, MQTT, etc. — so the Event +// Details sheet fetches and parses it to show flashers what they'll get. + +export interface EventChannelPrefs { + index: number + name?: string + // PSK rendered as base64, matching how the Meshtastic apps display keys. + psk?: string + uplinkEnabled?: boolean + positionPrecision?: string +} + +export interface EventMqttPrefs { + address?: string + username?: string + password?: string + rootTopic?: string + encryptionEnabled?: boolean + tlsEnabled?: boolean +} + +export interface EventUserPrefs { + region?: string + modemPreset?: string + frequencySlot?: string + hopLimit?: string + ignoreMqtt?: boolean + channels: EventChannelPrefs[] + mqtt: EventMqttPrefs +} + +export function eventUserPrefsRawUrl(slug: string): string { + return `https://raw.githubusercontent.com/meshtastic/firmware/event/${encodeURIComponent(slug)}/userPrefs.jsonc` +} + +export function eventUserPrefsSourceUrl(slug: string): string { + return `https://github.com/meshtastic/firmware/blob/event/${encodeURIComponent(slug)}/userPrefs.jsonc` +} + +// Strip // and /* */ comments (string-aware) plus trailing commas, so the +// .jsonc file can go through JSON.parse. Commented-out prefs are inactive in +// the build, so dropping them here is exactly right. +export function parseJsonc(text: string): Record { + let out = '' + let inString = false + let inLineComment = false + let inBlockComment = false + for (let i = 0; i < text.length; i++) { + const ch = text[i] + const next = text[i + 1] + if (inLineComment) { + if (ch === '\n') { + inLineComment = false + out += ch + } + continue + } + if (inBlockComment) { + if (ch === '*' && next === '/') { + inBlockComment = false + i++ + } + continue + } + if (inString) { + out += ch + if (ch === '\\') { + out += next ?? '' + i++ + } + else if (ch === '"') { + inString = false + } + continue + } + if (ch === '"') { + inString = true + out += ch + continue + } + if (ch === '/' && next === '/') { + inLineComment = true + i++ + continue + } + if (ch === '/' && next === '*') { + inBlockComment = true + i++ + continue + } + out += ch + } + const parsed = JSON.parse(out.replace(/,(\s*[}\]])/g, '$1')) as Record + const result: Record = {} + for (const [key, value] of Object.entries(parsed)) { + if (typeof value === 'string') result[key] = value + } + return result +} + +// "{ 0x38, 0x4b, ... }" (a C byte-array initializer) -> base64, the format the +// Meshtastic apps use for keys. Returns undefined for empty/invalid arrays. +export function byteArrayToBase64(value?: string): string | undefined { + if (!value) return undefined + const match = /^\s*\{([\s\S]*)\}\s*$/.exec(value) + if (!match) return undefined + const parts = match[1].split(',').map(p => p.trim()).filter(Boolean) + if (!parts.length) return undefined + const bytes: number[] = [] + for (const part of parts) { + const byte = Number(part) + if (!Number.isInteger(byte) || byte < 0 || byte > 255) return undefined + bytes.push(byte) + } + let binary = '' + for (const byte of bytes) binary += String.fromCharCode(byte) + return btoa(binary) +} + +// "meshtastic_Config_LoRaConfig_ModemPreset_SHORT_TURBO" -> "Short Turbo", +// "meshtastic_Config_LoRaConfig_RegionCode_US" -> "US". The enum constant is +// the trailing ALL_CAPS run; short/numeric words (US, EU, 868) stay uppercase. +export function prettyEnumValue(value?: string): string | undefined { + if (!value) return undefined + const match = /^meshtastic_\w*?_([A-Z][A-Z0-9]*(?:_[A-Z0-9]+)*)$/.exec(value) + const constant = match ? match[1] : value + return constant + .split('_') + .map(word => word.length > 3 && !/\d/.test(word) + ? word.charAt(0) + word.slice(1).toLowerCase() + : word) + .join(' ') +} + +// C string values sometimes carry their single quotes ("'mqtt.defcon.org'"). +function unquote(value?: string): string | undefined { + if (value === undefined) return undefined + const trimmed = value.trim() + return trimmed.startsWith('\'') && trimmed.endsWith('\'') && trimmed.length >= 2 + ? trimmed.slice(1, -1) + : trimmed +} + +function toBool(value?: string): boolean | undefined { + if (value === undefined) return undefined + return value === 'true' || value === '1' +} + +export function parseEventUserPrefs(text: string): EventUserPrefs { + const raw = parseJsonc(text) + const channels: EventChannelPrefs[] = [] + const count = Number(raw.USERPREFS_CHANNELS_TO_WRITE) || 0 + for (let i = 0; i < count; i++) { + channels.push({ + index: i, + name: raw[`USERPREFS_CHANNEL_${i}_NAME`], + psk: byteArrayToBase64(raw[`USERPREFS_CHANNEL_${i}_PSK`]), + uplinkEnabled: toBool(raw[`USERPREFS_CHANNEL_${i}_UPLINK_ENABLED`]), + positionPrecision: raw[`USERPREFS_CHANNEL_${i}_PRECISION`], + }) + } + return { + region: prettyEnumValue(raw.USERPREFS_CONFIG_LORA_REGION), + modemPreset: prettyEnumValue(raw.USERPREFS_LORACONFIG_MODEM_PRESET), + frequencySlot: raw.USERPREFS_LORACONFIG_CHANNEL_NUM, + hopLimit: raw.USERPREFS_EVENT_MODE_HOP_LIMIT, + ignoreMqtt: toBool(raw.USERPREFS_CONFIG_LORA_IGNORE_MQTT), + channels, + mqtt: { + address: unquote(raw.USERPREFS_MQTT_ADDRESS), + username: unquote(raw.USERPREFS_MQTT_USERNAME), + password: unquote(raw.USERPREFS_MQTT_PASSWORD), + rootTopic: unquote(raw.USERPREFS_MQTT_ROOT_TOPIC), + encryptionEnabled: toBool(raw.USERPREFS_MQTT_ENCRYPTION_ENABLED), + tlsEnabled: toBool(raw.USERPREFS_MQTT_TLS_ENABLED), + }, + } +} + +export async function fetchEventUserPrefs(slug: string): Promise { + try { + const response = await fetch(eventUserPrefsRawUrl(slug), { signal: AbortSignal.timeout(5000) }) + if (!response.ok) return null + return parseEventUserPrefs(await response.text()) + } + catch { + return null + } +} From 66e95de4b9dd683295cd0b73045721e84d08cd30 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 29 Jul 2026 22:07:46 +0000 Subject: [PATCH 3/4] Trim the sheet to radio settings only MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Drop the event, firmware-build, theme and links cards — the sheet now shows just the settings baked into the event build (LoRa, channels with PSKs, MQTT) plus the userPrefs.jsonc source link. Button and dialog are retitled "Firmware Settings" / "Event Firmware Settings" with a settings icon, and the now-unused date-range util and i18n keys are removed. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01WWbctVTeY3iySKcELxhcR5 --- components/EventDetails.vue | 146 ++---------------------------------- i18n/locales/en.json | 17 +---- utils/eventDates.test.ts | 34 --------- utils/eventDates.ts | 21 ------ 4 files changed, 9 insertions(+), 209 deletions(-) delete mode 100644 utils/eventDates.test.ts delete mode 100644 utils/eventDates.ts diff --git a/components/EventDetails.vue b/components/EventDetails.vue index 785d326..4b2f3c2 100644 --- a/components/EventDetails.vue +++ b/components/EventDetails.vue @@ -6,7 +6,7 @@ class="btn-secondary" @click="openPanel" > - {{ $t('event.details_button') }} + {{ $t('event.details_button') }} @@ -29,7 +29,7 @@ id="event-details-title" class="flex items-center gap-2 text-lg font-semibold text-theme" > - + {{ $t('event.details_title') }}
- +
- -
-

- {{ $t('event.section_event') }} -

-
-
- {{ $t('event.label_name') }} -
-
{{ eventMode.eventName }}
- - - -
-

- {{ edition.welcomeMessage }} -

-
- - -
-

- {{ $t('event.section_firmware') }} -

-
- - -
-
- - +
- +

{{ $t('event.settings_unavailable') }} @@ -268,70 +205,6 @@ {{ $t('event.view_userprefs') }} - - -

-

- {{ $t('event.section_theme') }} -

-
- - - -
-
- - -
-

- {{ $t('event.section_links') }} -

- -
@@ -343,23 +216,18 @@