import fs from "fs"; import path from "path"; 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; export type FirmwareInfo = { id: string; project: string; variant: string; name: string; git: string | null; date: string | null; board: string; flash_size: string; size: number; extras: Record; manifestUrl: 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; } /** 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); const missing = REQUIRED_BINS.filter((f) => !fs.existsSync(path.join(dir, f))); if (missing.length) continue; const id = `${projectName}/${v.name}`; if (seen.has(id)) continue; seen.add(id); let meta: Record = {}; const verPath = path.join(dir, "VERSION.txt"); if (fs.existsSync(verPath)) { try { meta = parseVersionTxt(fs.readFileSync(verPath, "utf8")); } catch { /* ignore */ } } const firmwareStat = fs.statSync(path.join(dir, "firmware.bin")); const reserved = new Set(["name", "git", "date", "board", "flash_size"]); items.push({ id, project: projectName, variant: v.name, name: meta.name || `${projectName} (${v.name})`, git: meta.git || null, date: meta.date || null, board: meta.board || "ESP32-2432S028R", flash_size: meta.flash_size || "4MB", size: firmwareStat.size, extras: Object.fromEntries( Object.entries(meta).filter(([k]) => !reserved.has(k)) ), manifestUrl: `/api/firmware/${encodeURIComponent(projectName)}/${encodeURIComponent(v.name)}/manifest.json`, }); } 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; // Bundled: root itself is the firmware mirror (contains project folders) // or root is cyd parent with project folders that contain firmware/ 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); // Sibling project: ../temperature/firmware/... items.push(...scanProjectFirmware(projectPath, ent.name, seen)); // Bundled mirror: firmware/temperature/cyd_28r/... // Here `root` is ./firmware and `ent` is project name; variants are direct children. const maybeVariantBins = REQUIRED_BINS.every((f) => fs.existsSync(path.join(projectPath, f)) ); if (maybeVariantBins) { // Unusual: project folder IS the variant — treat parent name continue; } // If root is ./firmware, children are projects whose children are variants const nestedFw = path.join(projectPath, "firmware"); if (!fs.existsSync(nestedFw)) { // Direct: firmware// 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); let meta: Record = {}; const verPath = path.join(dir, "VERSION.txt"); if (fs.existsSync(verPath)) { try { meta = parseVersionTxt(fs.readFileSync(verPath, "utf8")); } catch { /* ignore */ } } const firmwareStat = fs.statSync(path.join(dir, "firmware.bin")); const reserved = new Set(["name", "git", "date", "board", "flash_size"]); items.push({ id, project: ent.name, variant: v.name, name: meta.name || `${ent.name} (${v.name})`, git: meta.git || null, date: meta.date || null, board: meta.board || "ESP32-2432S028R", flash_size: meta.flash_size || "4MB", size: firmwareStat.size, extras: Object.fromEntries( Object.entries(meta).filter(([k]) => !reserved.has(k)) ), manifestUrl: `/api/firmware/${encodeURIComponent(ent.name)}/${encodeURIComponent(v.name)}/manifest.json`, }); } } } } 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 {}; } }