velxio/frontend/src/components/simulator/BoardOptionsModal.tsx

542 lines
18 KiB
TypeScript

import { useCallback, useMemo, useRef, useState } from 'react';
import type { BoardKind } from '../../types/board';
import { BOARD_KIND_LABELS } from '../../types/board';
import type {
ESP32BoardOptions,
ESP32CoreSelect,
SpiffsFile,
} from '../../types/boardOptions';
import {
CORE_SELECT_OPTIONS,
CPU_FREQ_OPTIONS,
DEBUG_LEVEL_OPTIONS,
FLASH_FREQ_OPTIONS,
FLASH_MODE_OPTIONS,
FLASH_SIZE_OPTIONS,
PARTITION_SCHEME_FS_SIZE,
PARTITION_SCHEME_LABELS,
boardSupportsOpiPsram,
boardSupportsPsram,
getDefaultOptionsForKind,
} from '../../types/boardOptions';
import './BoardOptionsModal.css';
interface BoardOptionsModalProps {
isOpen: boolean;
boardId: string;
boardKind: BoardKind;
currentOptions: ESP32BoardOptions | undefined;
spiffsFiles: SpiffsFile[];
onClose: () => void;
onApply: (next: ESP32BoardOptions) => void;
onSpiffsChange: (next: SpiffsFile[]) => void;
}
type TabKey = 'options' | 'files';
const PARTITION_SCHEMES: (keyof typeof PARTITION_SCHEME_LABELS)[] = [
'default',
'defaults_ffat',
'min_spiffs',
'min_ffat',
'no_ota',
'no_fs',
'huge_app',
'large_spiffs',
'rainmaker',
];
const MAX_FILE_BYTES = 1 * 1024 * 1024; // 1 MB per file warning
const SOFT_TOTAL_CAP = 4 * 1024 * 1024; // 4 MB total guardrail
function fmtBytes(n: number): string {
if (n < 1024) return `${n} B`;
if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`;
return `${(n / (1024 * 1024)).toFixed(2)} MB`;
}
async function readFileAsBase64(file: File): Promise<string> {
const buf = await file.arrayBuffer();
const bytes = new Uint8Array(buf);
let bin = '';
// Chunk to avoid the call-stack ceiling on large files.
const CHUNK = 0x8000;
for (let i = 0; i < bytes.length; i += CHUNK) {
bin += String.fromCharCode(...bytes.subarray(i, i + CHUNK));
}
return btoa(bin);
}
export const BoardOptionsModal = ({
isOpen,
boardId,
boardKind,
currentOptions,
spiffsFiles,
onClose,
onApply,
onSpiffsChange,
}: BoardOptionsModalProps) => {
const seed = useMemo(
() => currentOptions ?? getDefaultOptionsForKind(boardKind),
[currentOptions, boardKind],
);
const [tab, setTab] = useState<TabKey>('options');
const [draft, setDraft] = useState<ESP32BoardOptions>(seed);
const fileInputRef = useRef<HTMLInputElement | null>(null);
if (!isOpen) return null;
const showPsram = boardSupportsPsram(boardKind);
const showOpi = boardSupportsOpiPsram(boardKind);
const fsCapacity = PARTITION_SCHEME_FS_SIZE[draft.partitionScheme] ?? 0;
const totalUploaded = spiffsFiles.reduce((sum, f) => sum + f.size, 0);
const overSchemeCap = fsCapacity > 0 && totalUploaded > fsCapacity;
const noFsButHasFiles = fsCapacity === 0 && spiffsFiles.length > 0;
const nonDio = draft.flashMode !== 'dio';
const sameCore = draft.eventsRunOnCore === draft.arduinoRunsOnCore;
const update = useCallback(
(patch: Partial<ESP32BoardOptions>) => setDraft((d) => ({ ...d, ...patch })),
[],
);
const handleApply = () => {
// Strip PSRAM for boards that don't support it (e.g. C3) so a stale
// value can't smuggle through to the backend.
const sanitised: ESP32BoardOptions = { ...draft };
if (!showPsram) sanitised.psram = 'disabled';
if (!showOpi && sanitised.psram === 'opi') sanitised.psram = 'enabled';
onApply(sanitised);
onClose();
};
const handleFiles = async (files: FileList | null) => {
if (!files || files.length === 0) return;
const next: SpiffsFile[] = [...spiffsFiles];
for (const f of Array.from(files)) {
if (next.some((existing) => existing.name === f.name)) {
const overwrite = window.confirm(
`A file named "${f.name}" already exists. Overwrite?`,
);
if (!overwrite) continue;
}
const contentB64 = await readFileAsBase64(f);
const entry: SpiffsFile = {
name: f.name,
contentB64,
size: f.size,
};
const i = next.findIndex((existing) => existing.name === f.name);
if (i >= 0) next[i] = entry;
else next.push(entry);
}
onSpiffsChange(next);
};
const handleDelete = (name: string) => {
onSpiffsChange(spiffsFiles.filter((f) => f.name !== name));
};
return (
<div className="bom-overlay" onClick={onClose}>
<div className="bom-modal" onClick={(e) => e.stopPropagation()}>
<div className="bom-header">
<div className="bom-title">
<div className="bom-title-main">Board Options</div>
<div className="bom-title-sub">
{BOARD_KIND_LABELS[boardKind]} - id {boardId}
</div>
</div>
<button className="bom-close" onClick={onClose} aria-label="Close">
&times;
</button>
</div>
<div className="bom-tabs">
<button
className={`bom-tab ${tab === 'options' ? 'active' : ''}`}
onClick={() => setTab('options')}
>
Board Options
</button>
<button
className={`bom-tab ${tab === 'files' ? 'active' : ''}`}
onClick={() => setTab('files')}
>
Filesystem Files ({spiffsFiles.length})
</button>
</div>
<div className="bom-body">
{tab === 'options' ? (
<>
<section className="bom-section">
<h4 className="bom-section-title">Memory</h4>
<div className="bom-row">
<label className="bom-label">Partition Scheme</label>
<div className="bom-control">
<select
className="bom-select"
value={draft.partitionScheme}
onChange={(e) =>
update({
partitionScheme: e.target.value as ESP32BoardOptions['partitionScheme'],
})
}
>
{PARTITION_SCHEMES.map((s) => (
<option key={s} value={s}>
{PARTITION_SCHEME_LABELS[s]}
</option>
))}
</select>
</div>
</div>
<div className="bom-row">
<label className="bom-label">Flash Size</label>
<div className="bom-control">
<select
className="bom-select"
value={draft.flashSize}
onChange={(e) =>
update({ flashSize: e.target.value as ESP32BoardOptions['flashSize'] })
}
>
{FLASH_SIZE_OPTIONS.map((o) => (
<option key={o.value} value={o.value}>
{o.label}
</option>
))}
</select>
{draft.flashSize !== '4MB' && (
<span className="bom-badge bom-badge-warn">QEMU image grows</span>
)}
</div>
</div>
</section>
<section className="bom-section">
<h4 className="bom-section-title">Speed</h4>
<div className="bom-row">
<label className="bom-label">CPU Frequency</label>
<div className="bom-control">
<select
className="bom-select"
value={draft.cpuFreqMHz}
onChange={(e) =>
update({
cpuFreqMHz: Number(e.target.value) as ESP32BoardOptions['cpuFreqMHz'],
})
}
>
{CPU_FREQ_OPTIONS.map((o) => (
<option key={o.value} value={o.value}>
{o.label}
</option>
))}
</select>
</div>
</div>
<div className="bom-row">
<label className="bom-label">Flash Frequency</label>
<div className="bom-control">
<select
className="bom-select"
value={draft.flashFreqMHz}
onChange={(e) =>
update({
flashFreqMHz: e.target.value as ESP32BoardOptions['flashFreqMHz'],
})
}
>
{FLASH_FREQ_OPTIONS.map((o) => (
<option key={o.value} value={o.value}>
{o.label}
</option>
))}
</select>
</div>
</div>
</section>
<section className="bom-section">
<h4 className="bom-section-title">Flash</h4>
<div className="bom-row">
<label className="bom-label">Flash Mode</label>
<div className="bom-control">
<select
className="bom-select"
value={draft.flashMode}
onChange={(e) =>
update({ flashMode: e.target.value as ESP32BoardOptions['flashMode'] })
}
>
{FLASH_MODE_OPTIONS.map((o) => (
<option key={o.value} value={o.value}>
{o.label}
</option>
))}
</select>
{nonDio && (
<span className="bom-badge bom-badge-error">
QEMU may fail to boot
</span>
)}
</div>
</div>
</section>
{showPsram && (
<section className="bom-section">
<h4 className="bom-section-title">PSRAM</h4>
<div className="bom-row">
<label className="bom-label">External PSRAM</label>
<div className="bom-control">
<select
className="bom-select"
value={draft.psram}
onChange={(e) =>
update({ psram: e.target.value as ESP32BoardOptions['psram'] })
}
>
<option value="disabled">Disabled</option>
<option value="enabled">Enabled</option>
{showOpi && <option value="opi">OPI PSRAM</option>}
</select>
</div>
</div>
</section>
)}
<section className="bom-section">
<h4 className="bom-section-title">Debug</h4>
<div className="bom-row">
<label className="bom-label">Core Debug Level</label>
<div className="bom-control">
<select
className="bom-select"
value={draft.coreDebugLevel}
onChange={(e) =>
update({
coreDebugLevel: e.target
.value as ESP32BoardOptions['coreDebugLevel'],
})
}
>
{DEBUG_LEVEL_OPTIONS.map((o) => (
<option key={o.value} value={o.value}>
{o.label}
</option>
))}
</select>
</div>
</div>
</section>
<section className="bom-section">
<h4 className="bom-section-title">Concurrency</h4>
<div className="bom-row">
<label className="bom-label">Events Run On</label>
<div className="bom-control">
<select
className="bom-select"
value={draft.eventsRunOnCore}
onChange={(e) =>
update({ eventsRunOnCore: Number(e.target.value) as ESP32CoreSelect })
}
>
{CORE_SELECT_OPTIONS.map((o) => (
<option key={o.value} value={o.value}>
{o.label}
</option>
))}
</select>
</div>
</div>
<div className="bom-row">
<label className="bom-label">Arduino Runs On</label>
<div className="bom-control">
<select
className="bom-select"
value={draft.arduinoRunsOnCore}
onChange={(e) =>
update({ arduinoRunsOnCore: Number(e.target.value) as ESP32CoreSelect })
}
>
{CORE_SELECT_OPTIONS.map((o) => (
<option key={o.value} value={o.value}>
{o.label}
</option>
))}
</select>
{sameCore && (
<span className="bom-badge bom-badge-warn">
Both on same core
</span>
)}
</div>
</div>
</section>
<section className="bom-section">
<h4 className="bom-section-title">Tools</h4>
<div className="bom-row">
<label className="bom-label">Erase Flash on Upload</label>
<div className="bom-control">
<input
className="bom-check"
type="checkbox"
checked={draft.eraseFlashOnUpload}
onChange={(e) => update({ eraseFlashOnUpload: e.target.checked })}
/>
<span style={{ fontSize: 11, color: '#888' }}>
Zero NVS / SPIFFS before next run
</span>
</div>
</div>
</section>
</>
) : (
<SpiffsPanel
files={spiffsFiles}
fsCapacity={fsCapacity}
totalUploaded={totalUploaded}
overSchemeCap={overSchemeCap}
noFsButHasFiles={noFsButHasFiles}
fileInputRef={fileInputRef}
onAdd={() => fileInputRef.current?.click()}
onChange={handleFiles}
onDelete={handleDelete}
/>
)}
</div>
<div className="bom-footer">
<button className="bom-btn bom-btn-secondary" onClick={onClose}>
Cancel
</button>
<button className="bom-btn bom-btn-primary" onClick={handleApply}>
Apply
</button>
</div>
</div>
</div>
);
};
interface SpiffsPanelProps {
files: SpiffsFile[];
fsCapacity: number;
totalUploaded: number;
overSchemeCap: boolean;
noFsButHasFiles: boolean;
fileInputRef: React.RefObject<HTMLInputElement | null>;
onAdd: () => void;
onChange: (files: FileList | null) => void;
onDelete: (name: string) => void;
}
const SpiffsPanel = ({
files,
fsCapacity,
totalUploaded,
overSchemeCap,
noFsButHasFiles,
fileInputRef,
onAdd,
onChange,
onDelete,
}: SpiffsPanelProps) => {
const overSoft = totalUploaded > SOFT_TOTAL_CAP;
return (
<>
{noFsButHasFiles && (
<div className="bom-warning">
Selected partition scheme has no filesystem. Uploaded files will be
ignored at flash time. Switch to a scheme like
<code> default </code> or <code>min_spiffs</code> to keep them.
</div>
)}
{overSchemeCap && (
<div className="bom-warning">
Total upload size ({fmtBytes(totalUploaded)}) exceeds the partition's
SPIFFS capacity ({fmtBytes(fsCapacity)}). The image will not fit -
remove files or pick a larger scheme.
</div>
)}
{overSoft && !overSchemeCap && (
<div className="bom-warning">
Total upload size exceeds the recommended 4 MB cap. Save / load
operations may slow down.
</div>
)}
<div className="bom-spiffs-toolbar">
<button className="bom-spiffs-add" onClick={onAdd}>
Add file
</button>
<span className={`bom-spiffs-tally ${overSchemeCap ? 'over' : ''}`}>
{fmtBytes(totalUploaded)}{' '}
{fsCapacity > 0 ? `/ ${fmtBytes(fsCapacity)}` : '(no FS partition)'}
</span>
</div>
<input
ref={fileInputRef}
type="file"
multiple
style={{ display: 'none' }}
onChange={(e) => {
onChange(e.target.files);
if (e.target) e.target.value = '';
}}
/>
{files.length === 0 ? (
<div className="bom-spiffs-empty">
No files. Click "Add file" to upload assets that will be flashed into
the SPIFFS partition.
</div>
) : (
<table className="bom-spiffs-table">
<thead>
<tr>
<th>Name</th>
<th style={{ textAlign: 'right' }}>Size</th>
<th style={{ textAlign: 'right' }}>Actions</th>
</tr>
</thead>
<tbody>
{files.map((f) => (
<tr key={f.name}>
<td className="bom-spiffs-name">
/{f.name}
{f.size > MAX_FILE_BYTES && (
<span
className="bom-badge bom-badge-warn"
style={{ marginLeft: 8 }}
>
large
</span>
)}
</td>
<td className="bom-spiffs-size">{fmtBytes(f.size)}</td>
<td className="bom-spiffs-actions">
<button
className="bom-spiffs-delete"
onClick={() => onDelete(f.name)}
>
Delete
</button>
</td>
</tr>
))}
</tbody>
</table>
)}
</>
);
};