542 lines
18 KiB
TypeScript
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">
|
|
×
|
|
</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>
|
|
)}
|
|
</>
|
|
);
|
|
};
|