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:
parent
57e04b60e8
commit
baa5925f95
|
|
@ -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([]);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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) ────────────────────────────────── */
|
||||
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
Loading…
Reference in New Issue