diff --git a/data/eventFirmware.json b/data/eventFirmware.json new file mode 100644 index 0000000..c87604d --- /dev/null +++ b/data/eventFirmware.json @@ -0,0 +1,133 @@ +{ + "version": 2, + "generatedAt": "2026-06-30T00:00:00Z", + "source": "bundled", + "editions": [ + { + "edition": "HAMVENTION", + "displayName": "Dayton Hamvention 2026", + "welcomeMessage": "Welcome to Hamvention! 🍖📻", + "tag": "Hamvention", + "eventStart": "2026-05-15", + "eventEnd": "2026-05-17", + "timeZone": "America/New_York", + "location": "Xenia, Ohio, USA", + "iconUrl": "https://api.meshtastic.org/resource/eventFirmware/hamvention.png", + "accentColor": "#BF1E2E", + "domain": "hamvention.meshtastic.org", + "links": [ + { "label": "Event Website", "url": "https://hamvention.org" } + ], + "theme": { + "name": "Radio Adventure", + "tagline": "Exploring the many avenues amateur radio has to offer.", + "colors": { "primary": "#BF1E2E", "secondary": null, "accent": null }, + "palette": ["#BF1E2E"], + "fonts": null + }, + "firmware": { + "slug": "dayton2026", + "version": "2.7.23.07741e6", + "id": "v2.7.23.07741e6", + "title": "Meshtastic Firmware 2.7.23.07741e6", + "zipUrl": "https://github.com/meshtastic/meshtastic.github.io/raw/master/event/dayton2026/firmware-2.7.23.07741e6.zip", + "releaseNotes": "## Welcome to Dayton Hamvention 2026!\n\nThis firmware has been customized for Hamvention with factory default configurations.\n\n### ⚠️ Important: Backup Before Flashing\n\nIf your device has existing settings or encryption keys, **backup your keys / configurations** before proceeding. Flashing will reset your device to factory settings for the event.\n\n### Quick Start\n\n1. Ensure a **data-capable USB cable** is connected\n2. Select your device type\n3. Choose \"Full Erase and Install\"\n4. After flashing, download the Meshtastic app and pair via Bluetooth\n5. If you updated from a previous version or installed a UF2 on an NRF52 device, you will need to perform a factory reset on the device to activate the Hamvention mode.\n\n**73 and happy meshing from Dayton!**" + } + }, + { + "edition": "OPEN_SAUCE", + "displayName": "Open Sauce 2026", + "welcomeMessage": "Welcome to Open Sauce! 🔧", + "tag": "Open Sauce", + "eventStart": "2026-07-17", + "eventEnd": "2026-07-19", + "timeZone": "America/Los_Angeles", + "location": "San Mateo, California, USA", + "iconUrl": null, + "accentColor": "#E94F1D", + "domain": "opensauce.meshtastic.org", + "links": [ + { "label": "Event Website", "url": "https://opensauce.com" } + ], + "theme": { + "name": null, + "tagline": null, + "colors": { "primary": "#E94F1D", "secondary": null, "accent": null }, + "palette": ["#E94F1D"], + "fonts": null + }, + "firmware": { + "slug": "opensauce2026", + "version": null, + "id": null, + "title": null, + "zipUrl": null, + "releaseNotes": null + } + }, + { + "edition": "DEFCON", + "displayName": "DEF CON 34", + "welcomeMessage": "Welcome to DEF CON 34! 💀", + "tag": "DEFCON", + "eventStart": "2026-08-06", + "eventEnd": "2026-08-09", + "timeZone": "America/Los_Angeles", + "location": "Las Vegas Convention Center, Las Vegas, Nevada, USA", + "iconUrl": null, + "accentColor": "#0D294A", + "domain": "defcon.meshtastic.org", + "links": [ + { "label": "Event Website", "url": "https://defcon.org" }, + { "label": "DEF CON 34 Theme", "url": "https://defcon.org/html/defcon-34/dc-34-theme.html" }, + { "label": "Mastodon", "url": "https://defcon.social" } + ], + "theme": { + "name": "Agency", + "tagline": "Agency is self-determination. It's about making choices that increase yours, AND helping others to control theirs.", + "colors": { "primary": "#0D294A", "secondary": "#017FA4", "accent": "#E0004E" }, + "palette": ["#0D294A", "#017FA4", "#105F66", "#6CCDB8", "#F1B435", "#E0004E"], + "fonts": { "heading": "Lato", "body": "Atkinson Hyperlegible" } + }, + "firmware": { + "slug": "defcon2026", + "version": null, + "id": null, + "title": null, + "zipUrl": null, + "releaseNotes": null + } + }, + { + "edition": "BURNING_MAN", + "displayName": "Burning Man 2026", + "welcomeMessage": "Welcome to Burning Man! 🔥", + "tag": "Burning Man", + "eventStart": "2026-08-30", + "eventEnd": "2026-09-07", + "timeZone": "America/Los_Angeles", + "location": "Black Rock City, Nevada, USA", + "iconUrl": null, + "accentColor": "#EC8819", + "domain": "burningman.meshtastic.org", + "links": [ + { "label": "Event Website", "url": "https://burningman.org" } + ], + "theme": { + "name": "Axis Mundi", + "tagline": "The axis of the world — a celestial column connecting earth, sky, and the realms between.", + "colors": { "primary": "#EC8819", "secondary": null, "accent": null }, + "palette": ["#EC8819"], + "fonts": null + }, + "firmware": { + "slug": "burningman2026", + "version": null, + "id": null, + "title": null, + "zipUrl": null, + "releaseNotes": null + } + } + ] +} diff --git a/src/index.ts b/src/index.ts index 4d4a828..e486fc6 100644 --- a/src/index.ts +++ b/src/index.ts @@ -8,6 +8,8 @@ import { logger } from "@tinyhttp/logger"; import { RegisterMqttClient } from "./lib/index.js"; import { DeviceLinksRoutes, + EventFirmwareIconRoutes, + EventFirmwareRoutes, FirmwareRoutes, GithubRoutes, MqttRoutes, @@ -83,6 +85,8 @@ FirmwareRoutes(); GithubRoutes(); ResourceRoutes(); DeviceLinksRoutes(); +EventFirmwareRoutes(); +EventFirmwareIconRoutes(); UpdaterRoutes(); MqttRoutes(); diff --git a/src/lib/eventFirmware.ts b/src/lib/eventFirmware.ts new file mode 100644 index 0000000..5f3b708 --- /dev/null +++ b/src/lib/eventFirmware.ts @@ -0,0 +1,79 @@ +import { readFileSync } from "node:fs"; + +// Event-firmware metadata. The envelope originates from the contract in +// meshtastic/Meshtastic-Android#5920 (schemas/event_firmware.schema.json); version 2 adds the +// tag/domain/theme/firmware fields the web-flasher needs (Android's parser ignores unknown fields). +// The committed file is already in the response envelope shape, so unlike deviceLinks there is +// nothing to transform — we parse and serve it verbatim. This repo is the source of truth the +// Android app and web-flasher sync from. +// The path resolves the same in dev (src/lib) and prod (dist/lib), both two levels below the root. +const DATA_PATH = new URL("../../data/eventFirmware.json", import.meta.url); + +export interface EventFirmwareLink { + label: string; + url: string; +} + +// Brand/theme metadata for ambient event styling (web-flasher tints its whole +// UI from this; Android may adopt it later). accentColor stays the single +// primary swatch for clients that only want one color; theme.colors.primary +// mirrors it. All optional — only DEFCON ships a full published style guide. +export interface EventFirmwareTheme { + name?: string | null; // theme title, e.g. "Agency" + tagline?: string | null; // one-line theme statement + colors?: { + primary?: string | null; + secondary?: string | null; + accent?: string | null; + } | null; + palette?: string[] | null; // full brand swatch list (#RRGGBB) + fonts?: { heading?: string | null; body?: string | null } | null; +} + +// Pointer to the event's firmware build. The binary lives at +// meshtastic.github.io/event/{slug}/firmware-{version}.zip — slug is owned here, +// version/id/zipUrl are null until that build ships. +export interface EventFirmwareBuild { + slug: string; // event path segment, e.g. "defcon2026" + version?: string | null; // e.g. "2.7.23.07741e6" + id?: string | null; // e.g. "v2.7.23.07741e6" + title?: string | null; // e.g. "Meshtastic Firmware 2.7.23.07741e6" + zipUrl?: string | null; // full event firmware zip URL + releaseNotes?: string | null; // markdown +} + +export interface EventFirmwareEdition { + edition: string; // FirmwareEdition proto enum name, e.g. "HAMVENTION" + displayName: string; + welcomeMessage: string; + tag?: string | null; // short label (web-flasher eventTag), e.g. "DEFCON" + eventStart?: string | null; + eventEnd?: string | null; + timeZone?: string | null; + location?: string | null; + iconUrl?: string | null; + accentColor?: string | null; + domain?: string | null; // host the web-flasher matches, e.g. "defcon.meshtastic.org" + links?: EventFirmwareLink[]; + theme?: EventFirmwareTheme | null; + firmware?: EventFirmwareBuild | null; +} + +export interface EventFirmwareResponse { + version: number; + generatedAt?: string; + source?: string; + editions: EventFirmwareEdition[]; +} + +// Parse once per process — the file only changes via a committed edit + redeploy. +let cached: EventFirmwareResponse | null = null; + +export const getEventFirmware = (): EventFirmwareResponse => { + if (!cached) { + cached = JSON.parse( + readFileSync(DATA_PATH, "utf8"), + ) as EventFirmwareResponse; + } + return cached; +}; diff --git a/src/routes/eventFirmware.ts b/src/routes/eventFirmware.ts new file mode 100644 index 0000000..c22cebf --- /dev/null +++ b/src/routes/eventFirmware.ts @@ -0,0 +1,58 @@ +import type { Request } from "@tinyhttp/app"; +import { app } from "../index.js"; +import { getEventFirmware } from "../lib/eventFirmware.js"; + +// Icons hosted by this API's own icon route — the only iconUrls we re-origin. +const HOSTED_ICON_ORIGIN = "https://api.meshtastic.org"; +const HOSTED_ICON_PREFIX = "/resource/eventFirmware/"; + +const firstHeader = (value?: string | string[]): string | undefined => + (Array.isArray(value) ? value[0] : value)?.split(",")[0]?.trim(); + +// Origin that served this request, honoring the reverse proxy in front of +// api.meshtastic.org (X-Forwarded-*), so the response self-references the +// current deployment rather than the production host baked into the data file. +const requestOrigin = (req: Request): string => { + const proto = + firstHeader(req.headers["x-forwarded-proto"]) ?? + ((req.socket as { encrypted?: boolean }).encrypted ? "https" : "http"); + const host = firstHeader(req.headers["x-forwarded-host"]) ?? req.headers.host; + return `${proto}://${host}`; +}; + +export const EventFirmwareRoutes = () => + app.get("resource/eventFirmware", (req, res) => { + try { + const data = getEventFirmware(); + const origin = requestOrigin(req); + // Hosted icons live on this same server; rewrite their origin to the one + // that served the manifest so staging/local/forked deployments point at + // their own icon route instead of api.meshtastic.org. External or null + // iconUrls are left untouched. The cached payload is never mutated. + const editions = data.editions.map((edition) => { + if (!edition.iconUrl) return edition; + let parsed: URL; + try { + parsed = new URL(edition.iconUrl); + } catch { + return edition; + } + // Only rewrite icons we host on this server's icon route; leave external + // (e.g. third-party CDN) URLs untouched. + if ( + parsed.origin !== HOSTED_ICON_ORIGIN || + !parsed.pathname.startsWith(HOSTED_ICON_PREFIX) + ) { + return edition; + } + return { + ...edition, + iconUrl: `${origin}${parsed.pathname}${parsed.search}${parsed.hash}`, + }; + }); + res.json({ ...data, editions }); + } catch (err) { + console.error("eventFirmware", err); + res.sendStatus(502); + } + }); diff --git a/src/routes/eventFirmwareIcon.ts b/src/routes/eventFirmwareIcon.ts new file mode 100644 index 0000000..a650448 --- /dev/null +++ b/src/routes/eventFirmwareIcon.ts @@ -0,0 +1,39 @@ +import { readFileSync } from "node:fs"; +import { app } from "../index.js"; + +// Event edition icons, served under resource/ alongside the metadata (mirroring +// how data/ files map to resource/* routes). The manifest's iconUrl points here +// per edition; only editions with bundled art resolve (hamvention.png today), +// the rest 404 until original art ships. The :slug is constrained to +// [a-z0-9-] so it can never escape the static/eventFirmware/ directory. +const ICON_DIR = new URL("../../static/eventFirmware/", import.meta.url); +const SLUG_RE = /^([a-z0-9-]+)\.png$/; + +// Cache each icon buffer by slug on first request — files only change on redeploy. +const cache = new Map(); + +export const EventFirmwareIconRoutes = () => + app.get("resource/eventFirmware/:file", (req, res) => { + const match = SLUG_RE.exec(req.params.file ?? ""); + if (!match) return res.sendStatus(404); + const slug = match[1]; + + try { + let icon = cache.get(slug); + if (!icon) { + icon = readFileSync(new URL(`${slug}.png`, ICON_DIR)); + cache.set(slug, icon); + } + res.setHeader("Content-Type", "image/png"); + return res.send(icon); + } catch (err) { + // A missing icon is a genuine 404; permission/I/O errors mean the icon + // should exist and storage is unhealthy — surface those as 502, don't + // hide them behind a cacheable 404. + if ((err as NodeJS.ErrnoException).code === "ENOENT") { + return res.sendStatus(404); + } + console.error("eventFirmwareIcon", err); + return res.sendStatus(502); + } + }); diff --git a/src/routes/index.ts b/src/routes/index.ts index b5fd7f6..482c8a4 100644 --- a/src/routes/index.ts +++ b/src/routes/index.ts @@ -1,4 +1,6 @@ export { DeviceLinksRoutes } from "./deviceLinks.js"; +export { EventFirmwareRoutes } from "./eventFirmware.js"; +export { EventFirmwareIconRoutes } from "./eventFirmwareIcon.js"; export { FirmwareRoutes } from "./firmware.js"; export { GithubRoutes } from "./github.js"; export { MqttRoutes } from "./mqtt.js"; diff --git a/static/eventFirmware/hamvention.png b/static/eventFirmware/hamvention.png new file mode 100644 index 0000000..8f33121 Binary files /dev/null and b/static/eventFirmware/hamvention.png differ