diff --git a/frontend/src/__tests__/fat-image.test.ts b/frontend/src/__tests__/fat-image.test.ts index 82e461a5..b8a5978f 100644 --- a/frontend/src/__tests__/fat-image.test.ts +++ b/frontend/src/__tests__/fat-image.test.ts @@ -4,7 +4,7 @@ * and we assert a full round-trip (names + bytes), plus BPB/structure checks. */ import { describe, it, expect } from 'vitest'; -import { buildFat16Image, type SdFile } from '../utils/fatImage'; +import { buildFat16Image, readFat16Image, type SdFile } from '../utils/fatImage'; import { buildProjectSdImage, bytesToB64, decodeSdFiles } from '../utils/sdCardFiles'; // ── Minimal FAT16 reader (test-only) — parses root dir + FAT chains ────────── @@ -196,3 +196,30 @@ describe('sdCardFiles upload helpers', () => { expect(f.data.every((b) => b === 0x7e)).toBe(true); }); }); + +describe('readFat16Image (the production reader feeding the SD panel)', () => { + it('round-trips names and bytes through build -> read', () => { + const jpeg = new Uint8Array(2289); + jpeg[0] = 0xff; jpeg[1] = 0xd8; + jpeg[2287] = 0xff; jpeg[2288] = 0xd9; + const files: SdFile[] = [ + { name: 'photo0.jpg', data: jpeg }, + { name: 'a-much-longer-file-name.txt', data: new TextEncoder().encode('hola') }, + ]; + const got = readFat16Image(buildFat16Image(files)); + expect(got.map((f) => f.name.toLowerCase()).sort()).toEqual([ + 'a-much-longer-file-name.txt', + 'photo0.jpg', + ]); + const photo = got.find((f) => f.name.toLowerCase() === 'photo0.jpg')!; + expect(photo.size).toBe(2289); + expect(photo.data[0]).toBe(0xff); + expect(photo.data[1]).toBe(0xd8); + expect(photo.data[2288]).toBe(0xd9); + }); + + it('returns [] for garbage instead of throwing', () => { + expect(readFat16Image(new Uint8Array(0))).toEqual([]); + expect(readFat16Image(new Uint8Array(4096).fill(0x5a))).toEqual([]); + }); +}); diff --git a/frontend/src/components/simulator/ComponentPropertyDialog.css b/frontend/src/components/simulator/ComponentPropertyDialog.css index d35fcfe5..34b76cb8 100644 --- a/frontend/src/components/simulator/ComponentPropertyDialog.css +++ b/frontend/src/components/simulator/ComponentPropertyDialog.css @@ -418,6 +418,39 @@ color: #aaa; font-variant-numeric: tabular-nums; } +.sd-card-label--live { + display: flex; + align-items: center; + justify-content: space-between; + margin-top: 10px; +} +.sd-card-refresh { + font-size: 11px; + padding: 2px 8px; + border: 1px solid #555; + border-radius: 3px; + background: transparent; + color: #bbb; + cursor: pointer; +} +.sd-card-refresh:hover { + border-color: #888; + color: #fff; +} +.sd-card-file-download { + flex: 0 0 auto; + border: none; + background: transparent; + color: var(--color-action-primary); + cursor: pointer; + font-size: 12px; + line-height: 1; + padding: 2px 6px; + border-radius: 3px; +} +.sd-card-file-download:hover { + background: rgba(0, 113, 227, 0.12); +} /* ── Keyboard binding (pushbuttons) ────────────────────────────────── */ diff --git a/frontend/src/components/simulator/PartInspectorDialog.tsx b/frontend/src/components/simulator/PartInspectorDialog.tsx index f30113dd..54897d36 100644 --- a/frontend/src/components/simulator/PartInspectorDialog.tsx +++ b/frontend/src/components/simulator/PartInspectorDialog.tsx @@ -604,11 +604,15 @@ export const PartInspectorDialog: React.FC = ({ )} {/* microSD upload panel: the standalone card and any part that - declares its own TF slot (metadata.sdSlot). */} + declares its own TF slot (metadata.sdSlot). For a board + (sdSlot) the componentId IS the board id, which lets the + panel read the live card off that board's bridge; the + standalone card falls back to the active board inside. */} {(componentMetadata.id === 'microsd-card' || componentMetadata.sdSlot === true) && ( onPropertyChange?.(componentId, 'sdFiles', next)} + boardId={componentMetadata.sdSlot === true ? componentId : undefined} /> )} diff --git a/frontend/src/components/simulator/SdCardPanel.tsx b/frontend/src/components/simulator/SdCardPanel.tsx index 31a33500..88f7b2bf 100644 --- a/frontend/src/components/simulator/SdCardPanel.tsx +++ b/frontend/src/components/simulator/SdCardPanel.tsx @@ -12,17 +12,23 @@ * (`{ name, contentB64 }[]`), so they travel with the project (.vlx) and feed * `buildProjectSdImage` on the next run. */ -import React, { useRef } from 'react'; +import React, { useCallback, useEffect, useRef, useState } from 'react'; import { bytesToB64, SD_UPLOAD_MAX_BYTES, type UploadedSdFile, } from '../../utils/sdCardFiles'; +import { readFat16Image, type FatDirFile } from '../../utils/fatImage'; import { sdCardUploadAllowed, triggerSdCardUpgradePrompt } from '../../lib/proSdCardGate'; +import { getEsp32Bridge, useSimulatorStore } from '../../store/useSimulatorStore'; interface SdCardPanelProps { files: UploadedSdFile[]; onChange: (next: UploadedSdFile[]) => void; + /** Board whose bridge mounts the card (the board inspector passes its own + * id; the standalone card component leaves it unset and the active board + * is used). Enables the live "Card contents" listing below the uploads. */ + boardId?: string | null; } function fileBytes(f: UploadedSdFile): number { @@ -37,10 +43,45 @@ function humanSize(n: number): string { return `${(n / (1024 * 1024)).toFixed(2)} MB`; } -export const SdCardPanel: React.FC = ({ files, onChange }) => { +export const SdCardPanel: React.FC = ({ files, onChange, boardId }) => { const inputRef = useRef(null); const total = files.reduce((s, f) => s + fileBytes(f), 0); + // ── Live card contents: what is ON the card right now, including files + // the running sketch wrote (photos, logs). Read back from the bridge's + // card image and parsed with the same FAT16 layout the builder emits. + // null = no card mounted yet (never run). + const [live, setLive] = useState(null); + const running = useSimulatorStore((st) => st.running); + + const refreshLive = useCallback(() => { + type SdBridge = { readSdImage?: () => Uint8Array | null }; + let bridge = boardId ? (getEsp32Bridge(boardId) as SdBridge | undefined) : undefined; + if (!bridge?.readSdImage) { + const activeId = useSimulatorStore.getState().activeBoardId; + bridge = activeId ? (getEsp32Bridge(activeId) as SdBridge | undefined) : undefined; + } + const img = bridge?.readSdImage?.() ?? null; + setLive(img ? readFat16Image(img) : null); + }, [boardId]); + + // Read on open and whenever the run state flips (start mounts the card, + // stop freezes its final contents). + useEffect(() => { + refreshLive(); + }, [refreshLive, running]); + + const download = (f: FatDirFile): void => { + // Copy into a plain ArrayBuffer-backed view: TS's BlobPart rejects + // Uint8Array (the data may sit on a SharedArrayBuffer). + const url = URL.createObjectURL(new Blob([new Uint8Array(f.data)])); + const a = document.createElement('a'); + a.href = url; + a.download = f.name; + a.click(); + setTimeout(() => URL.revokeObjectURL(url), 5000); + }; + const openPicker = (): void => { // Gate the PAID action: a non-paid user gets the upgrade prompt instead. if (!sdCardUploadAllowed()) { @@ -111,6 +152,39 @@ export const SdCardPanel: React.FC = ({ files, onChange }) => style={{ display: 'none' }} onChange={handlePick} /> + + {/* Live card contents: everything on the mounted card, sketch-written + files included, each downloadable. */} +
+ Card contents + +
+ {live === null ? ( +
+ Run the simulation to mount the card. Files the sketch writes (photos, + logs) appear here and can be downloaded. +
+ ) : live.length === 0 ? ( +
The card is empty.
+ ) : ( + live.map((f) => ( +
+ + {f.name} + + {humanSize(f.size)} + +
+ )) + )} ); }; diff --git a/frontend/src/utils/fatImage.ts b/frontend/src/utils/fatImage.ts index 54eaffda..c0d4edca 100644 --- a/frontend/src/utils/fatImage.ts +++ b/frontend/src/utils/fatImage.ts @@ -102,6 +102,117 @@ export interface BuildFatOptions { label?: string; } +/** A file listed out of a FAT16 image by `readFat16Image`. */ +export interface FatDirFile { + /** Long name when LFN entries were present, else the 8.3 name. */ + name: string; + size: number; + data: Uint8Array; +} + +function readU16(buf: Uint8Array, off: number): number { + return buf[off] | (buf[off + 1] << 8); +} +function readU32(buf: Uint8Array, off: number): number { + return (buf[off] | (buf[off + 1] << 8) | (buf[off + 2] << 16) | (buf[off + 3] << 24)) >>> 0; +} + +/** + * List the ROOT directory of a FAT16 "super-floppy" image and extract each + * file's bytes — the inverse of `buildFat16Image`, but general enough for a + * card a guest OS (FatFs / Arduino SD) has written to: it follows real + * cluster chains, honours sectors-per-cluster from the BPB, decodes VFAT + * long names and skips deleted/volume/directory entries. Subdirectories are + * not descended (the builder is root-only too). + * + * Returns [] for anything that does not look like a mountable FAT16 volume — + * the caller treats that as "no readable card", never as an error. + */ +export function readFat16Image(img: Uint8Array): FatDirFile[] { + try { + if (img.length < SEC || img[510] !== 0x55 || img[511] !== 0xaa) return []; + const bytesPerSec = readU16(img, 11); + const spc = img[13]; + const reserved = readU16(img, 14); + const numFats = img[16]; + const rootEntries = readU16(img, 17); + const fatSz = readU16(img, 22); + if (!bytesPerSec || !spc || !reserved || !numFats || !fatSz || !rootEntries) return []; + + const fatStart = reserved * bytesPerSec; + const rootStart = (reserved + numFats * fatSz) * bytesPerSec; + const rootBytes = rootEntries * 32; + const dataStart = rootStart + Math.ceil(rootBytes / bytesPerSec) * bytesPerSec; + const clusterBytes = spc * bytesPerSec; + if (dataStart >= img.length) return []; + + const out: FatDirFile[] = []; + // LFN entries accumulate (in on-disk reverse order) until their 8.3 entry. + let lfnParts: Array<{ seq: number; chars: string }> = []; + + for (let slot = 0; slot < rootEntries; slot++) { + const d = rootStart + slot * 32; + if (d + 32 > img.length) break; + const first = img[d]; + if (first === 0x00) break; // end of directory + if (first === 0xe5) { + lfnParts = []; // deleted entry + continue; + } + const attr = img[d + 11]; + if ((attr & 0x0f) === 0x0f) { + // VFAT long-name entry: 13 UTF-16 chars in fixed slots. + const slots = [1, 3, 5, 7, 9, 14, 16, 18, 20, 22, 24, 28, 30]; + let chars = ''; + for (const s of slots) { + const code = img[d + s] | (img[d + s + 1] << 8); + if (code === 0x0000 || code === 0xffff) break; + chars += String.fromCharCode(code); + } + lfnParts.push({ seq: img[d] & 0x1f, chars }); + continue; + } + if (attr & 0x08 || attr & 0x10) { + // volume label / directory + lfnParts = []; + continue; + } + + let name: string; + if (lfnParts.length) { + name = lfnParts + .sort((a, b) => a.seq - b.seq) + .map((p) => p.chars) + .join(''); + } else { + const base = String.fromCharCode(...img.subarray(d, d + 8)).trimEnd(); + const ext = String.fromCharCode(...img.subarray(d + 8, d + 11)).trimEnd(); + name = ext ? `${base}.${ext}` : base; + } + lfnParts = []; + + const size = readU32(img, d + 28); + let cluster = readU16(img, d + 26); + const data = new Uint8Array(size); + let got = 0; + // Follow the chain; guard against loops with a step cap. + for (let steps = 0; got < size && cluster >= 2 && cluster < 0xfff8; steps++) { + if (steps > 1_000_000) break; + const at = dataStart + (cluster - 2) * clusterBytes; + if (at >= img.length) break; + const take = Math.min(clusterBytes, size - got, img.length - at); + data.set(img.subarray(at, at + take), got); + got += take; + cluster = readU16(img, fatStart + cluster * 2); + } + out.push({ name, size, data: got === size ? data : data.subarray(0, got) }); + } + return out; + } catch { + return []; + } +} + /** * Build a FAT16 image containing `files` in the root directory. * Throws if the files don't fit the volume or the root directory.