diff --git a/frontend/src/components/editor/EditorToolbar.tsx b/frontend/src/components/editor/EditorToolbar.tsx index f9193101..802faab4 100644 --- a/frontend/src/components/editor/EditorToolbar.tsx +++ b/frontend/src/components/editor/EditorToolbar.tsx @@ -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); diff --git a/frontend/src/components/simulator/BoardOptionsModal.css b/frontend/src/components/simulator/BoardOptionsModal.css new file mode 100644 index 00000000..5935f2bc --- /dev/null +++ b/frontend/src/components/simulator/BoardOptionsModal.css @@ -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; +} diff --git a/frontend/src/components/simulator/BoardOptionsModal.tsx b/frontend/src/components/simulator/BoardOptionsModal.tsx new file mode 100644 index 00000000..086e5e1f --- /dev/null +++ b/frontend/src/components/simulator/BoardOptionsModal.tsx @@ -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 { + 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('options'); + const [draft, setDraft] = useState(seed); + const fileInputRef = useRef(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) => 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 ( +
+
e.stopPropagation()}> +
+
+
Board Options
+
+ {BOARD_KIND_LABELS[boardKind]} - id {boardId} +
+
+ +
+ +
+ + +
+ +
+ {tab === 'options' ? ( + <> +
+

Memory

+
+ +
+ +
+
+
+ +
+ + {draft.flashSize !== '4MB' && ( + QEMU image grows + )} +
+
+
+ +
+

Speed

+
+ +
+ +
+
+
+ +
+ +
+
+
+ +
+

Flash

+
+ +
+ + {nonDio && ( + + QEMU may fail to boot + + )} +
+
+
+ + {showPsram && ( +
+

PSRAM

+
+ +
+ +
+
+
+ )} + +
+

Debug

+
+ +
+ +
+
+
+ +
+

Concurrency

+
+ +
+ +
+
+
+ +
+ + {sameCore && ( + + Both on same core + + )} +
+
+
+ +
+

Tools

+
+ +
+ update({ eraseFlashOnUpload: e.target.checked })} + /> + + Zero NVS / SPIFFS before next run + +
+
+
+ + ) : ( + fileInputRef.current?.click()} + onChange={handleFiles} + onDelete={handleDelete} + /> + )} +
+ +
+ + +
+
+
+ ); +}; + +interface SpiffsPanelProps { + files: SpiffsFile[]; + fsCapacity: number; + totalUploaded: number; + overSchemeCap: boolean; + noFsButHasFiles: boolean; + fileInputRef: React.RefObject; + 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 && ( +
+ Selected partition scheme has no filesystem. Uploaded files will be + ignored at flash time. Switch to a scheme like + default or min_spiffs to keep them. +
+ )} + {overSchemeCap && ( +
+ 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. +
+ )} + {overSoft && !overSchemeCap && ( +
+ Total upload size exceeds the recommended 4 MB cap. Save / load + operations may slow down. +
+ )} + +
+ + + {fmtBytes(totalUploaded)}{' '} + {fsCapacity > 0 ? `/ ${fmtBytes(fsCapacity)}` : '(no FS partition)'} + +
+ + { + onChange(e.target.files); + if (e.target) e.target.value = ''; + }} + /> + + {files.length === 0 ? ( +
+ No files. Click "Add file" to upload assets that will be flashed into + the SPIFFS partition. +
+ ) : ( + + + + + + + + + + {files.map((f) => ( + + + + + + ))} + +
NameSizeActions
+ /{f.name} + {f.size > MAX_FILE_BYTES && ( + + large + + )} + {fmtBytes(f.size)} + +
+ )} + + ); +}; diff --git a/frontend/src/components/simulator/SimulatorCanvas.tsx b/frontend/src/components/simulator/SimulatorCanvas.tsx index 16551091..0653eb5b 100644 --- a/frontend/src/components/simulator/SimulatorCanvas.tsx +++ b/frontend/src/components/simulator/SimulatorCanvas.tsx @@ -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(null); + // Board Options modal — id of the board whose options are being edited. + const [boardOptionsModalFor, setBoardOptionsModalFor] = useState(null); // Click vs drag detection const [clickStartTime, setClickStartTime] = useState(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 ( <>
{ 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}
+ +
+
+ + ); + } + + if (!ready) { + return ( +
+

Loading project…

+
+ ); + } + + return ; +}; + +// ── 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 & { 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 = {}; + 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, + }; +} diff --git a/frontend/src/store/useSimulatorStore.ts b/frontend/src/store/useSimulatorStore.ts index e2939206..81a07431 100644 --- a/frontend/src/store/useSimulatorStore.ts +++ b/frontend/src/store/useSimulatorStore.ts @@ -1157,6 +1157,19 @@ export const useSimulatorStore = create((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 diff --git a/frontend/src/types/board.ts b/frontend/src/types/board.ts index 574f0433..7705f423 100644 --- a/frontend/src/types/board.ts +++ b/frontend/src/types/board.ts @@ -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 = { diff --git a/frontend/src/types/boardOptions.ts b/frontend/src/types/boardOptions.ts new file mode 100644 index 00000000..484f6c85 --- /dev/null +++ b/frontend/src/types/boardOptions.ts @@ -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 = new Set([ + 'esp32-s3', + 'xiao-esp32-s3', + 'arduino-nano-esp32', +]); + +const ESP32_C3_KINDS: ReadonlySet = new Set([ + 'esp32-c3', + 'xiao-esp32-c3', + 'aitewinrobot-esp32c3-supermini', +]); + +const ESP32_XTENSA_KINDS: ReadonlySet = 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 = { + 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 = { + 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' }, +]; diff --git a/frontend/src/utils/projectPayload.ts b/frontend/src/utils/projectPayload.ts index 2647ced6..ac942f76 100644 --- a/frontend/src/utils/projectPayload.ts +++ b/frontend/src/utils/projectPayload.ts @@ -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, }; }