import fs from "fs"; import path from "path"; import { DEFAULT_BOARD_ID, resolveBoardId } from "@/lib/boards"; export const REQUIRED_BINS = [ "bootloader.bin", "partitions.bin", "boot_app0.bin", "firmware.bin", ] as const; export const FLASH_PARTS = [ { file: "bootloader.bin", offset: 0x1000 }, { file: "partitions.bin", offset: 0x8000 }, { file: "boot_app0.bin", offset: 0xe000 }, { file: "firmware.bin", offset: 0x10000 }, ] as const; /** Optional rich metadata: place meta.json next to the .bin files. */ export type FirmwareMedia = { /** HTML UI mock of the on-device screen (preferred card preview). */ preview: string | null; cover: string | null; animation: string | null; video: string | null; docs: string | null; }; export type FirmwareInfo = { id: string; project: string; variant: string; name: string; summary: string | null; description: string | null; tags: string[]; git: string | null; date: string | null; board: string; boardId: string; flash_size: string; size: number; extras: Record; media: FirmwareMedia; manifestUrl: string; }; type MetaJson = { boardId?: string; name?: string; summary?: string; description?: string; tags?: string[]; git?: string; date?: string; board?: string; flash_size?: string; media?: { preview?: string; cover?: string; animation?: string; video?: string; docs?: string; }; }; function parseVersionTxt(text: string): Record { const meta: Record = {}; for (const raw of text.split(/\r?\n/)) { const line = raw.trim(); if (!line || line.startsWith(" ") || line.endsWith(":")) continue; const idx = line.indexOf(":"); if (idx <= 0) continue; const key = line.slice(0, idx).trim(); const val = line.slice(idx + 1).trim(); if (key === "offsets" || key === "sha256") continue; meta[key] = val; } return meta; } function readMetaJson(dir: string): MetaJson | null { const p = path.join(dir, "meta.json"); if (!fs.existsSync(p)) return null; try { return JSON.parse(fs.readFileSync(p, "utf8")) as MetaJson; } catch { return null; } } function mediaUrl( project: string, variant: string, dir: string, rel: string | undefined ): string | null { if (!rel) return null; if (/^https?:\/\//i.test(rel)) return rel; const clean = rel.replace(/^[/\\]+/, "").replace(/\\/g, "/"); const abs = path.join(dir, ...clean.split("/")); if (!fs.existsSync(abs) || !fs.statSync(abs).isFile()) return null; return `/api/firmware/${encodeURIComponent(project)}/${encodeURIComponent(variant)}/media/${clean .split("/") .map(encodeURIComponent) .join("/")}`; } function buildFirmwareInfo( project: string, variant: string, dir: string ): FirmwareInfo { const versionMeta = (() => { const verPath = path.join(dir, "VERSION.txt"); if (!fs.existsSync(verPath)) return {} as Record; try { return parseVersionTxt(fs.readFileSync(verPath, "utf8")); } catch { return {}; } })(); const json = readMetaJson(dir); const boardField = json?.board || versionMeta.board || "ESP32-2432S028R"; const boardId = json?.boardId || resolveBoardId(boardField) || DEFAULT_BOARD_ID; const tags = json?.tags?.filter(Boolean) || Object.keys(versionMeta) .filter( (k) => !["name", "git", "date", "board", "flash_size"].includes(k) && versionMeta[k] ) .slice(0, 4); const features = versionMeta.features; const summary = json?.summary || features || Object.entries(versionMeta) .filter(([k]) => !["name", "git", "date", "board", "flash_size"].includes(k)) .map(([k, v]) => `${k}: ${v}`) .slice(0, 2) .join(" ยท ") || null; const reserved = new Set(["name", "git", "date", "board", "flash_size", "features"]); const firmwareStat = fs.statSync(path.join(dir, "firmware.bin")); return { id: `${project}/${variant}`, project, variant, name: json?.name || versionMeta.name || `${project} (${variant})`, summary, description: json?.description || null, tags, git: json?.git || versionMeta.git || null, date: json?.date || versionMeta.date || null, board: boardField, boardId, flash_size: json?.flash_size || versionMeta.flash_size || "4MB", size: firmwareStat.size, extras: Object.fromEntries( Object.entries(versionMeta).filter(([k]) => !reserved.has(k)) ), media: { preview: mediaUrl(project, variant, dir, json?.media?.preview) || mediaUrl(project, variant, dir, "media/preview.html"), cover: mediaUrl(project, variant, dir, json?.media?.cover), animation: mediaUrl(project, variant, dir, json?.media?.animation), video: mediaUrl(project, variant, dir, json?.media?.video), docs: json?.media?.docs ? /^https?:\/\//i.test(json.media.docs) ? json.media.docs : mediaUrl(project, variant, dir, json.media.docs) : null, }, manifestUrl: `/api/firmware/${encodeURIComponent(project)}/${encodeURIComponent(variant)}/manifest.json`, }; } /** Search roots: env override, bundled ./firmware, sibling projects (..). */ export function getFirmwareSearchRoots(): string[] { const roots: string[] = []; if (process.env.CYD_FIRMWARE_ROOT) { roots.push(path.resolve(process.env.CYD_FIRMWARE_ROOT)); } roots.push(path.resolve(process.cwd(), "firmware")); roots.push(path.resolve(process.cwd(), "..")); return [...new Set(roots)]; } function isSafeUnder(root: string, candidate: string): boolean { const absRoot = path.resolve(root); const abs = path.resolve(candidate); return abs === absRoot || abs.startsWith(absRoot + path.sep); } function scanProjectFirmware( projectRoot: string, projectName: string, seen: Set ): FirmwareInfo[] { const items: FirmwareInfo[] = []; const fwRoot = path.join(projectRoot, "firmware"); if (!fs.existsSync(fwRoot)) return items; let variants: fs.Dirent[]; try { variants = fs.readdirSync(fwRoot, { withFileTypes: true }); } catch { return items; } for (const v of variants) { if (!v.isDirectory()) continue; const dir = path.join(fwRoot, v.name); if (REQUIRED_BINS.some((f) => !fs.existsSync(path.join(dir, f)))) continue; const id = `${projectName}/${v.name}`; if (seen.has(id)) continue; seen.add(id); items.push(buildFirmwareInfo(projectName, v.name, dir)); } return items; } /** * Discover firmwares. * - Bundled layout: `firmware///*.bin` * - Sibling layout: `..//firmware//*.bin` */ export function discoverFirmware(): FirmwareInfo[] { const seen = new Set(); const items: FirmwareInfo[] = []; for (const root of getFirmwareSearchRoots()) { if (!fs.existsSync(root)) continue; let entries: fs.Dirent[]; try { entries = fs.readdirSync(root, { withFileTypes: true }); } catch { continue; } for (const ent of entries) { if (!ent.isDirectory()) continue; if ( ent.name === "cydweb" || ent.name === "node_modules" || ent.name.startsWith(".") ) { continue; } const projectPath = path.join(root, ent.name); items.push(...scanProjectFirmware(projectPath, ent.name, seen)); const nestedFw = path.join(projectPath, "firmware"); if (!fs.existsSync(nestedFw)) { let variants: fs.Dirent[]; try { variants = fs.readdirSync(projectPath, { withFileTypes: true }); } catch { continue; } for (const v of variants) { if (!v.isDirectory()) continue; const dir = path.join(projectPath, v.name); if (REQUIRED_BINS.some((f) => !fs.existsSync(path.join(dir, f)))) { continue; } const id = `${ent.name}/${v.name}`; if (seen.has(id)) continue; seen.add(id); items.push(buildFirmwareInfo(ent.name, v.name, dir)); } } } } items.sort((a, b) => a.name.localeCompare(b.name, "en")); return items; } export function resolveFirmwareDir( project: string, variant: string ): string | null { for (const root of getFirmwareSearchRoots()) { const candidates = [ path.join(root, project, "firmware", variant), path.join(root, project, variant), ]; for (const dir of candidates) { if (!isSafeUnder(root, dir)) continue; if ( fs.existsSync(dir) && REQUIRED_BINS.every((f) => fs.existsSync(path.join(dir, f))) ) { return dir; } } } return null; } export function readFirmwareMeta(dir: string): Record { const verPath = path.join(dir, "VERSION.txt"); if (!fs.existsSync(verPath)) return {}; try { return parseVersionTxt(fs.readFileSync(verPath, "utf8")); } catch { return {}; } } /** Resolve a media file under a firmware dir (path traversal safe). */ export function resolveMediaFile( project: string, variant: string, relPath: string ): string | null { const dir = resolveFirmwareDir(project, variant); if (!dir) return null; const normalized = path.normalize(relPath).replace(/^([/\\])+/, ""); if (normalized.startsWith("..") || path.isAbsolute(normalized)) return null; const abs = path.join(dir, normalized); if (!abs.startsWith(dir + path.sep) && abs !== dir) return null; if (!fs.existsSync(abs) || !fs.statSync(abs).isFile()) return null; return abs; }