feat(sd): live Card contents in the SD panel - list and download what the sketch wrote

The SD panel only knew about uploads; everything the GUEST wrote to the
FAT card (photos, logs) stayed invisible inside the bridge. New
readFat16Image() parses the card image back (BPB-driven, real cluster
chains, VFAT long names, root dir - the inverse of buildFat16Image but
general enough for FatFs-written cards), and the panel gains a live
'Card contents' section reading the bridge's readSdImage() with a
per-file Save (download) button and a Refresh. Round-trip covered in
fat-image.test.ts (16 green).
This commit is contained in:
David Montero Crespo 2026-08-04 16:29:54 +02:00
parent 57e04b60e8
commit baa5925f95
5 changed files with 253 additions and 4 deletions

View File

@ -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([]);
});
});

View File

@ -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) ────────────────────────────────── */

View File

@ -604,11 +604,15 @@ export const PartInspectorDialog: React.FC<PartInspectorDialogProps> = ({
)}
{/* 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) && (
<SdCardPanel
files={(componentProperties.sdFiles as UploadedSdFile[] | undefined) ?? []}
onChange={(next) => onPropertyChange?.(componentId, 'sdFiles', next)}
boardId={componentMetadata.sdSlot === true ? componentId : undefined}
/>
)}

View File

@ -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<SdCardPanelProps> = ({ files, onChange }) => {
export const SdCardPanel: React.FC<SdCardPanelProps> = ({ files, onChange, boardId }) => {
const inputRef = useRef<HTMLInputElement>(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<FatDirFile[] | null>(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<ArrayBufferLike> (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<SdCardPanelProps> = ({ files, onChange }) =>
style={{ display: 'none' }}
onChange={handlePick}
/>
{/* Live card contents: everything on the mounted card, sketch-written
files included, each downloadable. */}
<div className="sd-card-label sd-card-label--live">
Card contents
<button className="sd-card-refresh" onClick={refreshLive} title="Re-read the card">
Refresh
</button>
</div>
{live === null ? (
<div className="sd-card-hint">
Run the simulation to mount the card. Files the sketch writes (photos,
logs) appear here and can be downloaded.
</div>
) : live.length === 0 ? (
<div className="sd-card-hint">The card is empty.</div>
) : (
live.map((f) => (
<div key={f.name} className="sd-card-file">
<span className="sd-card-file-name" title={f.name}>
{f.name}
</span>
<span className="sd-card-file-size">{humanSize(f.size)}</span>
<button
className="sd-card-file-download"
title={`Download ${f.name}`}
onClick={() => download(f)}
>
Save
</button>
</div>
))
)}
</div>
);
};

View File

@ -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.