feat(board-options): per-board options modal + persistence
Adds a new BoardOptionsModal accessible from the EditorToolbar that exposes
per-board options (currently used for board-specific compile flags). Wires
the modal through:
- types/boardOptions.ts new BoardOptions shape
- types/board.ts BoardInstance gains `boardOptions` + `spiffsFiles`
- store/useSimulatorStore.ts boardOptions persisted in loadProjectState
- components/editor/EditorToolbar.tsx button to open the modal
- components/simulator/BoardOptionsModal.{tsx,css} the modal itself
- components/simulator/SimulatorCanvas.tsx passes the options through
- utils/projectPayload.ts board options serialised in saved projects
- pages/ProjectByIdPage.tsx re-includes the by-id loader needed for
project URLs that reference boards with
their persisted options.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
6a1e506db6
commit
16f168ba7f
|
|
@ -245,6 +245,12 @@ export const EditorToolbar = ({
|
|||
})),
|
||||
]);
|
||||
},
|
||||
// Per-board ESP32 build options + SPIFFS uploads. Undefined for AVR
|
||||
// / RP2040 boards (ignored on those paths by the backend).
|
||||
{
|
||||
boardOptions: activeBoard?.boardOptions,
|
||||
spiffsFiles: activeBoard?.spiffsFiles,
|
||||
},
|
||||
);
|
||||
|
||||
// After the build settles, append the structured analysis on top of
|
||||
|
|
@ -654,6 +660,7 @@ export const EditorToolbar = ({
|
|||
})),
|
||||
]);
|
||||
},
|
||||
{ boardOptions: board.boardOptions, spiffsFiles: board.spiffsFiles },
|
||||
);
|
||||
|
||||
const resultLogs = parseCompileResult(result, label);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,341 @@
|
|||
.bom-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.75);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1100;
|
||||
backdrop-filter: blur(4px);
|
||||
animation: bom-fadeIn 0.15s ease;
|
||||
}
|
||||
|
||||
@keyframes bom-fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.bom-modal {
|
||||
background: #1a1a1a;
|
||||
border: 1px solid #333;
|
||||
border-radius: 8px;
|
||||
width: 620px;
|
||||
max-width: 95vw;
|
||||
max-height: 85vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
box-shadow: 0 24px 64px rgba(0, 0, 0, 0.6);
|
||||
animation: bom-slideIn 0.2s ease;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
@keyframes bom-slideIn {
|
||||
from {
|
||||
transform: translateY(-16px);
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
transform: translateY(0);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.bom-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 12px 18px;
|
||||
background: #111;
|
||||
border-bottom: 1px solid #2a2a2a;
|
||||
}
|
||||
|
||||
.bom-title {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.bom-title-main {
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.12em;
|
||||
color: #ccc;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.bom-title-sub {
|
||||
font-size: 11px;
|
||||
color: #888;
|
||||
}
|
||||
|
||||
.bom-close {
|
||||
background: none;
|
||||
border: none;
|
||||
color: #888;
|
||||
font-size: 18px;
|
||||
cursor: pointer;
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.bom-close:hover {
|
||||
background: #2a2a2a;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.bom-tabs {
|
||||
display: flex;
|
||||
background: #111;
|
||||
border-bottom: 1px solid #2a2a2a;
|
||||
}
|
||||
|
||||
.bom-tab {
|
||||
padding: 10px 16px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-bottom: 2px solid transparent;
|
||||
color: #888;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.04em;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.bom-tab:hover {
|
||||
color: #ccc;
|
||||
}
|
||||
|
||||
.bom-tab.active {
|
||||
color: #4fc3f7;
|
||||
border-bottom-color: #4fc3f7;
|
||||
}
|
||||
|
||||
.bom-body {
|
||||
padding: 16px 18px;
|
||||
overflow-y: auto;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.bom-section {
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
|
||||
.bom-section-title {
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.16em;
|
||||
color: #888;
|
||||
text-transform: uppercase;
|
||||
margin: 0 0 8px 0;
|
||||
padding-bottom: 4px;
|
||||
border-bottom: 1px solid #2a2a2a;
|
||||
}
|
||||
|
||||
.bom-row {
|
||||
display: grid;
|
||||
grid-template-columns: 160px 1fr;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 6px 0;
|
||||
}
|
||||
|
||||
.bom-label {
|
||||
font-size: 12px;
|
||||
color: #bbb;
|
||||
}
|
||||
|
||||
.bom-control {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.bom-select {
|
||||
background: #2a2a2a;
|
||||
color: #ddd;
|
||||
border: 1px solid #444;
|
||||
border-radius: 4px;
|
||||
padding: 4px 8px;
|
||||
font-size: 12px;
|
||||
min-width: 200px;
|
||||
}
|
||||
|
||||
.bom-select:focus {
|
||||
outline: 1px solid #4fc3f7;
|
||||
}
|
||||
|
||||
.bom-check {
|
||||
accent-color: #4fc3f7;
|
||||
}
|
||||
|
||||
.bom-badge {
|
||||
display: inline-block;
|
||||
padding: 2px 6px;
|
||||
border-radius: 3px;
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.bom-badge-warn {
|
||||
background: #4a2a00;
|
||||
color: #ffb74d;
|
||||
}
|
||||
|
||||
.bom-badge-error {
|
||||
background: #5a1a1a;
|
||||
color: #ff8080;
|
||||
}
|
||||
|
||||
.bom-warning {
|
||||
margin: 8px 0 12px 0;
|
||||
padding: 8px 10px;
|
||||
background: #3a2a00;
|
||||
border: 1px solid #6a4a00;
|
||||
border-radius: 4px;
|
||||
color: #ffd07a;
|
||||
font-size: 11px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.bom-footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
padding: 12px 18px;
|
||||
background: #111;
|
||||
border-top: 1px solid #2a2a2a;
|
||||
}
|
||||
|
||||
.bom-btn {
|
||||
padding: 6px 14px;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
|
||||
.bom-btn-secondary {
|
||||
background: transparent;
|
||||
border-color: #555;
|
||||
color: #aaa;
|
||||
}
|
||||
|
||||
.bom-btn-secondary:hover {
|
||||
background: #2a2a2a;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.bom-btn-primary {
|
||||
background: #0e639c;
|
||||
border-color: #0e639c;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.bom-btn-primary:hover {
|
||||
background: #1177bd;
|
||||
}
|
||||
|
||||
/* ── SPIFFS panel ───────────────────────────────────────────────────────── */
|
||||
.bom-spiffs-empty {
|
||||
padding: 24px;
|
||||
text-align: center;
|
||||
color: #888;
|
||||
font-size: 12px;
|
||||
border: 1px dashed #333;
|
||||
border-radius: 4px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.bom-spiffs-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 12px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.bom-spiffs-table th {
|
||||
text-align: left;
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.08em;
|
||||
color: #888;
|
||||
text-transform: uppercase;
|
||||
padding: 6px 8px;
|
||||
border-bottom: 1px solid #2a2a2a;
|
||||
}
|
||||
|
||||
.bom-spiffs-table td {
|
||||
padding: 6px 8px;
|
||||
border-bottom: 1px solid #222;
|
||||
color: #ddd;
|
||||
}
|
||||
|
||||
.bom-spiffs-name {
|
||||
font-family: 'Consolas', 'Monaco', monospace;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.bom-spiffs-size {
|
||||
font-family: 'Consolas', 'Monaco', monospace;
|
||||
font-size: 11px;
|
||||
color: #aaa;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.bom-spiffs-actions {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.bom-spiffs-delete {
|
||||
background: none;
|
||||
border: 1px solid #555;
|
||||
color: #aaa;
|
||||
padding: 2px 8px;
|
||||
border-radius: 3px;
|
||||
font-size: 11px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.bom-spiffs-delete:hover {
|
||||
background: #5a1a1a;
|
||||
border-color: #a33;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.bom-spiffs-toolbar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.bom-spiffs-add {
|
||||
background: #0e639c;
|
||||
border: none;
|
||||
color: #fff;
|
||||
padding: 6px 12px;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.bom-spiffs-add:hover {
|
||||
background: #1177bd;
|
||||
}
|
||||
|
||||
.bom-spiffs-tally {
|
||||
font-size: 11px;
|
||||
color: #888;
|
||||
font-family: 'Consolas', 'Monaco', monospace;
|
||||
}
|
||||
|
||||
.bom-spiffs-tally.over {
|
||||
color: #ff8080;
|
||||
}
|
||||
|
|
@ -0,0 +1,541 @@
|
|||
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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
|
@ -43,6 +43,8 @@ import { useIsCoarsePointer } from '../../utils/useTouchDevice';
|
|||
import type { ComponentMetadata } from '../../types/component-metadata';
|
||||
import type { BoardKind } from '../../types/board';
|
||||
import { BOARD_KIND_LABELS } from '../../types/board';
|
||||
import { isEsp32Family } from '../../types/boardOptions';
|
||||
import { BoardOptionsModal } from './BoardOptionsModal';
|
||||
import { useOscilloscopeStore } from '../../store/useOscilloscopeStore';
|
||||
import {
|
||||
trackSelectBoard,
|
||||
|
|
@ -115,6 +117,7 @@ export const SimulatorCanvas = ({ headerSlot }: SimulatorCanvasProps = {}) => {
|
|||
initSimulator,
|
||||
updateComponentState,
|
||||
removeBoard,
|
||||
updateBoard,
|
||||
updateComponent,
|
||||
serialMonitorOpen,
|
||||
toggleSerialMonitor,
|
||||
|
|
@ -222,6 +225,8 @@ export const SimulatorCanvas = ({ headerSlot }: SimulatorCanvasProps = {}) => {
|
|||
} | null>(null);
|
||||
// Board removal confirmation dialog
|
||||
const [boardToRemove, setBoardToRemove] = useState<string | null>(null);
|
||||
// Board Options modal — id of the board whose options are being edited.
|
||||
const [boardOptionsModalFor, setBoardOptionsModalFor] = useState<string | null>(null);
|
||||
|
||||
// Click vs drag detection
|
||||
const [clickStartTime, setClickStartTime] = useState<number>(0);
|
||||
|
|
@ -2767,6 +2772,7 @@ export const SimulatorCanvas = ({ headerSlot }: SimulatorCanvasProps = {}) => {
|
|||
w.start.componentId === boardContextMenu.boardId ||
|
||||
w.end.componentId === boardContextMenu.boardId,
|
||||
).length;
|
||||
const supportsOptions = board ? isEsp32Family(board.boardKind) : false;
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
|
|
@ -2787,7 +2793,7 @@ export const SimulatorCanvas = ({ headerSlot }: SimulatorCanvasProps = {}) => {
|
|||
borderRadius: 6,
|
||||
padding: '4px 0',
|
||||
zIndex: 9999,
|
||||
minWidth: 180,
|
||||
minWidth: 200,
|
||||
boxShadow: '0 4px 16px rgba(0,0,0,0.4)',
|
||||
fontSize: 13,
|
||||
}}
|
||||
|
|
@ -2803,6 +2809,56 @@ export const SimulatorCanvas = ({ headerSlot }: SimulatorCanvasProps = {}) => {
|
|||
>
|
||||
{label}
|
||||
</div>
|
||||
<button
|
||||
disabled={!supportsOptions}
|
||||
title={supportsOptions ? undefined : 'ESP32 only'}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
width: '100%',
|
||||
padding: '7px 14px',
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
color: supportsOptions ? '#ddd' : '#555',
|
||||
cursor: supportsOptions ? 'pointer' : 'not-allowed',
|
||||
fontSize: 13,
|
||||
textAlign: 'left',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
if (supportsOptions) e.currentTarget.style.background = '#2a2d2e';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.background = 'none';
|
||||
}}
|
||||
onClick={() => {
|
||||
if (!supportsOptions) return;
|
||||
setBoardOptionsModalFor(boardContextMenu.boardId);
|
||||
setBoardContextMenu(null);
|
||||
}}
|
||||
>
|
||||
<svg
|
||||
width="14"
|
||||
height="14"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<circle cx="12" cy="12" r="3" />
|
||||
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z" />
|
||||
</svg>
|
||||
Board Options...
|
||||
</button>
|
||||
<div
|
||||
style={{
|
||||
height: 1,
|
||||
background: '#3c3c3c',
|
||||
margin: '4px 0',
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
style={{
|
||||
display: 'flex',
|
||||
|
|
@ -2853,6 +2909,25 @@ export const SimulatorCanvas = ({ headerSlot }: SimulatorCanvasProps = {}) => {
|
|||
);
|
||||
})()}
|
||||
|
||||
{/* Board Options modal (ESP32 only) */}
|
||||
{boardOptionsModalFor &&
|
||||
(() => {
|
||||
const b = boards.find((x) => x.id === boardOptionsModalFor);
|
||||
if (!b) return null;
|
||||
return (
|
||||
<BoardOptionsModal
|
||||
isOpen={true}
|
||||
boardId={b.id}
|
||||
boardKind={b.boardKind}
|
||||
currentOptions={b.boardOptions}
|
||||
spiffsFiles={b.spiffsFiles ?? []}
|
||||
onClose={() => setBoardOptionsModalFor(null)}
|
||||
onApply={(opts) => updateBoard(b.id, { boardOptions: opts })}
|
||||
onSpiffsChange={(files) => updateBoard(b.id, { spiffsFiles: files })}
|
||||
/>
|
||||
);
|
||||
})()}
|
||||
|
||||
{/* Board removal confirmation dialog */}
|
||||
{boardToRemove &&
|
||||
(() => {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,239 @@
|
|||
import { useEffect, useState } from 'react';
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
import { getProjectById } from '../services/projectService';
|
||||
import { useSimulatorStore } from '../store/useSimulatorStore';
|
||||
import { useProjectStore } from '../store/useProjectStore';
|
||||
import { useSEO } from '../utils/useSEO';
|
||||
import { EditorPage } from './EditorPage';
|
||||
import type { BoardInstance, BoardKind } from '../types/board';
|
||||
|
||||
const DOMAIN = 'https://velxio.dev';
|
||||
|
||||
interface ProjectMeta {
|
||||
name: string;
|
||||
description: string;
|
||||
ownerUsername: string;
|
||||
isPublic: boolean;
|
||||
}
|
||||
|
||||
export const ProjectByIdPage: React.FC = () => {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const navigate = useNavigate();
|
||||
const loadProjectState = useSimulatorStore((s) => s.loadProjectState);
|
||||
const setCurrentProject = useProjectStore((s) => s.setCurrentProject);
|
||||
const clearCurrentProject = useProjectStore((s) => s.clearCurrentProject);
|
||||
const currentProject = useProjectStore((s) => s.currentProject);
|
||||
const [ready, setReady] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [projectMeta, setProjectMeta] = useState<ProjectMeta | null>(null);
|
||||
|
||||
// SEO: update once we have real project data; use generic noindex fallback until then.
|
||||
useSEO(
|
||||
projectMeta && projectMeta.isPublic
|
||||
? {
|
||||
title: `${projectMeta.name} by ${projectMeta.ownerUsername} | Velxio`,
|
||||
description: projectMeta.description
|
||||
? `${projectMeta.description} — Simulate and remix this Arduino project on Velxio.`
|
||||
: `Arduino project by ${projectMeta.ownerUsername}. View and simulate it free on Velxio.`,
|
||||
url: `${DOMAIN}/project/${id}`,
|
||||
}
|
||||
: {
|
||||
title: 'Project — Velxio Arduino Emulator',
|
||||
description:
|
||||
'View and simulate this Arduino project on Velxio — free, open-source multi-board emulator.',
|
||||
url: `${DOMAIN}/editor`,
|
||||
noindex: true,
|
||||
},
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!id) return;
|
||||
// If this project is already loaded in the store (e.g. navigated here
|
||||
// right after saving) skip the fetch to avoid overwriting unsaved state.
|
||||
if (currentProject?.id === id && ready) return;
|
||||
|
||||
getProjectById(id)
|
||||
.then((project) => {
|
||||
const payload = buildLoadPayload(project);
|
||||
loadProjectState(payload);
|
||||
setCurrentProject({
|
||||
id: project.id,
|
||||
slug: project.slug,
|
||||
ownerUsername: project.owner_username,
|
||||
isPublic: project.is_public,
|
||||
});
|
||||
setProjectMeta({
|
||||
name: project.name ?? 'Untitled Project',
|
||||
description: project.description ?? '',
|
||||
ownerUsername: project.owner_username ?? '',
|
||||
isPublic: project.is_public ?? false,
|
||||
});
|
||||
setReady(true);
|
||||
})
|
||||
.catch((err) => {
|
||||
const s = err?.response?.status;
|
||||
if (s === 404) setError('Project not found.');
|
||||
else if (s === 403) setError('This project is private.');
|
||||
else setError('Failed to load project.');
|
||||
clearCurrentProject();
|
||||
});
|
||||
}, [id]);
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
minHeight: '100vh',
|
||||
background: '#1e1e1e',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
<div style={{ color: '#f44747', fontSize: 16, textAlign: 'center' }}>
|
||||
<p>{error}</p>
|
||||
<button
|
||||
onClick={() => navigate('/')}
|
||||
style={{
|
||||
marginTop: 12,
|
||||
background: '#0e639c',
|
||||
border: 'none',
|
||||
color: '#fff',
|
||||
padding: '8px 16px',
|
||||
borderRadius: 4,
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
Go home
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!ready) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
minHeight: '100vh',
|
||||
background: '#1e1e1e',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
<p style={{ color: '#9d9d9d' }}>Loading project…</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return <EditorPage />;
|
||||
};
|
||||
|
||||
// ── Helpers ─────────────────────────────────────────────────────────────────
|
||||
|
||||
interface RawProject {
|
||||
board_type: string;
|
||||
files: { name: string; content: string }[];
|
||||
file_groups?: { groupId: string; files: { name: string; content: string }[] }[];
|
||||
boards_json?: string;
|
||||
code: string;
|
||||
components_json: string;
|
||||
wires_json: string;
|
||||
}
|
||||
|
||||
/** Build a loadProjectState payload from a server ProjectResponse. Falls back
|
||||
* to a single default board when the project predates multi-board persistence. */
|
||||
export function buildLoadPayload(project: RawProject) {
|
||||
// Boards
|
||||
let boards: BoardInstance[] = [];
|
||||
try {
|
||||
const parsed = JSON.parse(project.boards_json || '[]');
|
||||
if (Array.isArray(parsed) && parsed.length > 0) {
|
||||
boards = parsed.map((b: Partial<BoardInstance> & { id: string; boardKind: string }) => ({
|
||||
id: b.id,
|
||||
boardKind: b.boardKind as BoardKind,
|
||||
x: b.x ?? 50,
|
||||
y: b.y ?? 50,
|
||||
running: false,
|
||||
compiledProgram: b.compiledProgram ?? null,
|
||||
serialOutput: '',
|
||||
serialBaudRate: b.serialBaudRate ?? 0,
|
||||
serialMonitorOpen: false,
|
||||
activeFileGroupId: b.activeFileGroupId ?? `group-${b.id}`,
|
||||
languageMode: b.languageMode ?? 'arduino',
|
||||
// ESP32 per-board options + uploaded SPIFFS files. Undefined for
|
||||
// pre-feature projects; the compiler falls back to its defaults.
|
||||
boardOptions: b.boardOptions,
|
||||
spiffsFiles: b.spiffsFiles,
|
||||
}));
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
if (boards.length === 0) {
|
||||
// Pre-backfill project: synthesise a single board from board_type.
|
||||
const kind = (project.board_type || 'arduino-uno') as BoardKind;
|
||||
boards = [
|
||||
{
|
||||
id: kind,
|
||||
boardKind: kind,
|
||||
x: 50,
|
||||
y: 50,
|
||||
running: false,
|
||||
compiledProgram: null,
|
||||
serialOutput: '',
|
||||
serialBaudRate: 0,
|
||||
serialMonitorOpen: false,
|
||||
activeFileGroupId: `group-${kind}`,
|
||||
languageMode: 'arduino',
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
// File groups
|
||||
const fileGroups: Record<string, { name: string; content: string }[]> = {};
|
||||
if (project.file_groups && project.file_groups.length > 0) {
|
||||
for (const g of project.file_groups) {
|
||||
fileGroups[g.groupId] = g.files.map((f) => ({ name: f.name, content: f.content }));
|
||||
}
|
||||
}
|
||||
// Ensure every board has a file group: fall back to legacy `files` for the
|
||||
// active board, or to a synthesised sketch.ino from `code`.
|
||||
for (const b of boards) {
|
||||
if (!fileGroups[b.activeFileGroupId] || fileGroups[b.activeFileGroupId].length === 0) {
|
||||
const fallback =
|
||||
project.files && project.files.length > 0
|
||||
? project.files
|
||||
: project.code
|
||||
? [{ name: 'sketch.ino', content: project.code }]
|
||||
: [{ name: 'sketch.ino', content: '' }];
|
||||
fileGroups[b.activeFileGroupId] = fallback.map((f) => ({
|
||||
name: f.name,
|
||||
content: f.content,
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
// Components and wires
|
||||
let components: unknown[] = [];
|
||||
let wires: unknown[] = [];
|
||||
try {
|
||||
components = JSON.parse(project.components_json || '[]');
|
||||
} catch {
|
||||
components = [];
|
||||
}
|
||||
try {
|
||||
wires = JSON.parse(project.wires_json || '[]');
|
||||
} catch {
|
||||
wires = [];
|
||||
}
|
||||
|
||||
return {
|
||||
boards,
|
||||
fileGroups,
|
||||
components: components as never[],
|
||||
wires: wires as never[],
|
||||
activeBoardId: boards[0]?.id ?? null,
|
||||
};
|
||||
}
|
||||
|
|
@ -1157,6 +1157,19 @@ export const useSimulatorStore = create<SimulatorState>((set, get) => {
|
|||
),
|
||||
}));
|
||||
}
|
||||
if (b.boardOptions || b.spiffsFiles) {
|
||||
set((s) => ({
|
||||
boards: s.boards.map((bb) =>
|
||||
bb.id === b.id
|
||||
? {
|
||||
...bb,
|
||||
boardOptions: b.boardOptions,
|
||||
spiffsFiles: b.spiffsFiles,
|
||||
}
|
||||
: bb,
|
||||
),
|
||||
}));
|
||||
}
|
||||
});
|
||||
|
||||
// Replace editor file groups atomically. Skip groups that already exist
|
||||
|
|
|
|||
|
|
@ -74,6 +74,12 @@ export interface BoardInstance {
|
|||
hasWifi?: boolean; // set by compiler — true when sketch uses WiFi
|
||||
wifiStatus?: WifiStatus;
|
||||
bleStatus?: BleStatus;
|
||||
// ESP32-only — populated when the user opens Board Options... on the
|
||||
// canvas context menu. Undefined for AVR / RP2040 / Pi3 and for
|
||||
// pre-feature saved projects (compiler falls back to defaults).
|
||||
// Types live in `./boardOptions` to avoid a circular import.
|
||||
boardOptions?: import('./boardOptions').ESP32BoardOptions;
|
||||
spiffsFiles?: import('./boardOptions').SpiffsFile[];
|
||||
}
|
||||
|
||||
export const BOARD_KIND_LABELS: Record<BoardKind, string> = {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,187 @@
|
|||
// Per-board ESP32 build options, mirroring the Arduino IDE Tools menu.
|
||||
// Values get serialised inside the board entry of `boards_json` (no DB
|
||||
// migration). The backend translates them into sdkconfig knobs and a
|
||||
// generated partitions.csv at compile time.
|
||||
//
|
||||
// Non-ESP32 boards (AVR / RP2040 / Pi3) leave these undefined — the
|
||||
// modal isn't exposed there.
|
||||
|
||||
import type { BoardKind } from './board';
|
||||
|
||||
export type ESP32PartitionScheme =
|
||||
| 'default' // 1.2MB APP / 1.5MB SPIFFS (OTA)
|
||||
| 'defaults_ffat' // 1.2MB APP / 1.5MB FATFS (OTA)
|
||||
| 'min_spiffs' // 1.9MB APP / 190KB SPIFFS (OTA) — current Velxio default
|
||||
| 'no_ota' // 2MB APP / 2MB SPIFFS
|
||||
| 'no_fs' // 2MB APP x2, no filesystem
|
||||
| 'huge_app' // 3MB APP / 1MB SPIFFS (no OTA)
|
||||
| 'min_ffat' // 1.9MB APP / 190KB FATFS (OTA)
|
||||
| 'large_spiffs' // 1.9MB APP / 1.4MB SPIFFS (no OTA)
|
||||
| 'rainmaker'; // 1.9MB APP x2 with rainmaker fctry (OTA)
|
||||
|
||||
export type ESP32CpuFreq = 240 | 160 | 80 | 40 | 20 | 10;
|
||||
export type ESP32FlashMode = 'qio' | 'dio' | 'qout' | 'dout';
|
||||
export type ESP32FlashSize = '4MB' | '8MB' | '16MB';
|
||||
export type ESP32FlashFreq = '80' | '40';
|
||||
export type ESP32PsramMode = 'disabled' | 'enabled' | 'opi';
|
||||
export type ESP32CoreDebugLevel = 'none' | 'error' | 'warn' | 'info' | 'debug' | 'verbose';
|
||||
export type ESP32CoreSelect = 0 | 1;
|
||||
|
||||
export interface ESP32BoardOptions {
|
||||
partitionScheme: ESP32PartitionScheme;
|
||||
cpuFreqMHz: ESP32CpuFreq;
|
||||
flashMode: ESP32FlashMode;
|
||||
flashSize: ESP32FlashSize;
|
||||
flashFreqMHz: ESP32FlashFreq;
|
||||
psram: ESP32PsramMode;
|
||||
coreDebugLevel: ESP32CoreDebugLevel;
|
||||
eraseFlashOnUpload: boolean;
|
||||
eventsRunOnCore: ESP32CoreSelect;
|
||||
arduinoRunsOnCore: ESP32CoreSelect;
|
||||
}
|
||||
|
||||
// One uploaded file destined for the SPIFFS partition. `contentB64` is a
|
||||
// raw base64 of the file bytes (no data: prefix). Used for both UI display
|
||||
// and the compile request body.
|
||||
export interface SpiffsFile {
|
||||
name: string;
|
||||
contentB64: string;
|
||||
size: number;
|
||||
}
|
||||
|
||||
// Defaults match the historical `sdkconfig.defaults` baked into Velxio
|
||||
// (DIO @ 40 MHz / 4 MB, min_spiffs, no PSRAM). Picking these as the
|
||||
// fallback means projects saved before this feature compile bit-for-bit
|
||||
// the same way after upgrade.
|
||||
export const DEFAULT_ESP32_OPTIONS: ESP32BoardOptions = {
|
||||
partitionScheme: 'min_spiffs',
|
||||
cpuFreqMHz: 240,
|
||||
flashMode: 'dio',
|
||||
flashSize: '4MB',
|
||||
flashFreqMHz: '40',
|
||||
psram: 'disabled',
|
||||
coreDebugLevel: 'none',
|
||||
eraseFlashOnUpload: false,
|
||||
eventsRunOnCore: 1,
|
||||
arduinoRunsOnCore: 1,
|
||||
};
|
||||
|
||||
const ESP32_S3_KINDS: ReadonlySet<BoardKind> = new Set([
|
||||
'esp32-s3',
|
||||
'xiao-esp32-s3',
|
||||
'arduino-nano-esp32',
|
||||
]);
|
||||
|
||||
const ESP32_C3_KINDS: ReadonlySet<BoardKind> = new Set([
|
||||
'esp32-c3',
|
||||
'xiao-esp32-c3',
|
||||
'aitewinrobot-esp32c3-supermini',
|
||||
]);
|
||||
|
||||
const ESP32_XTENSA_KINDS: ReadonlySet<BoardKind> = new Set([
|
||||
'esp32',
|
||||
'esp32-devkit-c-v4',
|
||||
'esp32-cam',
|
||||
'wemos-lolin32-lite',
|
||||
]);
|
||||
|
||||
export function isEsp32Family(kind: BoardKind): boolean {
|
||||
return (
|
||||
ESP32_XTENSA_KINDS.has(kind) ||
|
||||
ESP32_S3_KINDS.has(kind) ||
|
||||
ESP32_C3_KINDS.has(kind)
|
||||
);
|
||||
}
|
||||
|
||||
// PSRAM availability by family:
|
||||
// - Classic ESP32: optional, "enabled" only
|
||||
// - ESP32-S3: optional, "enabled" or "opi" (octal)
|
||||
// - ESP32-C3: no PSRAM support at all — section hidden in UI
|
||||
export function boardSupportsPsram(kind: BoardKind): boolean {
|
||||
return ESP32_XTENSA_KINDS.has(kind) || ESP32_S3_KINDS.has(kind);
|
||||
}
|
||||
|
||||
export function boardSupportsOpiPsram(kind: BoardKind): boolean {
|
||||
return ESP32_S3_KINDS.has(kind);
|
||||
}
|
||||
|
||||
export function getDefaultOptionsForKind(kind: BoardKind): ESP32BoardOptions {
|
||||
if (!isEsp32Family(kind)) return { ...DEFAULT_ESP32_OPTIONS };
|
||||
const defaults = { ...DEFAULT_ESP32_OPTIONS };
|
||||
// C3 has no PSRAM controller — clear the field so a stale 'enabled'
|
||||
// value from an upgraded project doesn't reach the backend.
|
||||
if (ESP32_C3_KINDS.has(kind)) defaults.psram = 'disabled';
|
||||
return defaults;
|
||||
}
|
||||
|
||||
// Human-readable label for a partition scheme. Used in the modal dropdown
|
||||
// so users see "min_spiffs - 1.9MB APP / 190KB SPIFFS (OTA)" instead of
|
||||
// the bare key.
|
||||
export const PARTITION_SCHEME_LABELS: Record<ESP32PartitionScheme, string> = {
|
||||
default: 'Default 4MB with spiffs (1.2MB APP / 1.5MB SPIFFS)',
|
||||
defaults_ffat: 'Default 4MB with ffat (1.2MB APP / 1.5MB FATFS)',
|
||||
min_spiffs: 'Minimal SPIFFS (1.9MB APP with OTA / 190KB SPIFFS)',
|
||||
min_ffat: 'Minimal FATFS (1.9MB APP with OTA / 190KB FATFS)',
|
||||
no_ota: 'No OTA (2MB APP / 2MB SPIFFS)',
|
||||
no_fs: 'No FS 4MB (2MB APP x2)',
|
||||
huge_app: 'Huge APP (3MB No OTA / 1MB SPIFFS)',
|
||||
large_spiffs: 'Large SPIFFS (1.9MB APP / 1.4MB SPIFFS)',
|
||||
rainmaker: 'RainMaker 4MB',
|
||||
};
|
||||
|
||||
// SPIFFS (or FATFS) capacity in bytes for each scheme. Used in the upload
|
||||
// panel to show "Total: X / Y KB" and to block oversize uploads.
|
||||
// Returns 0 for schemes with no filesystem partition.
|
||||
export const PARTITION_SCHEME_FS_SIZE: Record<ESP32PartitionScheme, number> = {
|
||||
default: 0x16f000, // 1.5 MB
|
||||
defaults_ffat: 0x16f000,
|
||||
min_spiffs: 0x2f000, // 190 KB
|
||||
min_ffat: 0x2f000,
|
||||
no_ota: 0x200000, // 2 MB
|
||||
no_fs: 0,
|
||||
huge_app: 0x100000, // 1 MB
|
||||
large_spiffs: 0x166000, // ~1.4 MB
|
||||
rainmaker: 0x6000, // ~24 KB nvs/fctry only
|
||||
};
|
||||
|
||||
// Display-only metadata for the CPU-frequency selector.
|
||||
export const CPU_FREQ_OPTIONS: { value: ESP32CpuFreq; label: string }[] = [
|
||||
{ value: 240, label: '240MHz (WiFi/BT)' },
|
||||
{ value: 160, label: '160MHz' },
|
||||
{ value: 80, label: '80MHz' },
|
||||
{ value: 40, label: '40MHz' },
|
||||
{ value: 20, label: '20MHz' },
|
||||
{ value: 10, label: '10MHz' },
|
||||
];
|
||||
|
||||
export const FLASH_MODE_OPTIONS: { value: ESP32FlashMode; label: string }[] = [
|
||||
{ value: 'qio', label: 'QIO' },
|
||||
{ value: 'dio', label: 'DIO' },
|
||||
{ value: 'qout', label: 'QOUT' },
|
||||
{ value: 'dout', label: 'DOUT' },
|
||||
];
|
||||
|
||||
export const FLASH_SIZE_OPTIONS: { value: ESP32FlashSize; label: string }[] = [
|
||||
{ value: '4MB', label: '4MB (32Mb)' },
|
||||
{ value: '8MB', label: '8MB (64Mb) - experimental' },
|
||||
{ value: '16MB', label: '16MB (128Mb) - experimental' },
|
||||
];
|
||||
|
||||
export const FLASH_FREQ_OPTIONS: { value: ESP32FlashFreq; label: string }[] = [
|
||||
{ value: '80', label: '80MHz' },
|
||||
{ value: '40', label: '40MHz' },
|
||||
];
|
||||
|
||||
export const DEBUG_LEVEL_OPTIONS: { value: ESP32CoreDebugLevel; label: string }[] = [
|
||||
{ value: 'none', label: 'None' },
|
||||
{ value: 'error', label: 'Error' },
|
||||
{ value: 'warn', label: 'Warn' },
|
||||
{ value: 'info', label: 'Info' },
|
||||
{ value: 'debug', label: 'Debug' },
|
||||
{ value: 'verbose', label: 'Verbose' },
|
||||
];
|
||||
|
||||
export const CORE_SELECT_OPTIONS: { value: ESP32CoreSelect; label: string }[] = [
|
||||
{ value: 0, label: 'Core 0' },
|
||||
{ value: 1, label: 'Core 1' },
|
||||
];
|
||||
|
|
@ -23,6 +23,11 @@ function serialisableBoard(b: BoardInstance) {
|
|||
languageMode: b.languageMode,
|
||||
serialBaudRate: b.serialBaudRate,
|
||||
compiledProgram: b.compiledProgram,
|
||||
// ESP32 board options + uploaded SPIFFS files. Optional, only present
|
||||
// after the user has opened Board Options... at least once. Both ride
|
||||
// inside boards_json so there's no DB migration.
|
||||
boardOptions: b.boardOptions,
|
||||
spiffsFiles: b.spiffsFiles,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue