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..3af7f0f --- /dev/null +++ b/components/EventDetails.vue @@ -0,0 +1,369 @@ + + + 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..4ac8775 100644 --- a/i18n/locales/en.json +++ b/i18n/locales/en.json @@ -127,6 +127,41 @@ "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_lora": "LoRa", + "section_channels": "Channels", + "section_mqtt": "MQTT", + "section_links": "Links", + "label_name": "Name", + "label_dates": "Dates", + "label_location": "Location", + "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", + "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.", "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)}` +} 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 + } +}