velxio/frontend/src/components/editor/EditorToolbar.tsx

1807 lines
75 KiB
TypeScript
Raw Normal View History

import { useState, useCallback, useRef, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
feat(custom-chip): program lives in its own editor group, not the board sketch A programmable custom-chip (a CPU emulator that runs a ROM/program, e.g. the Z80 or 8080) now keeps its program (larson.s, chaser.c, ...) in a dedicated editor file group — group-chip-<chipId> — rendered as its own collapsible section in the file explorer, exactly like each board owns its sketch group. Behaviour/driver chips and predefined chips carry no programFile and get no group; they stay editable only in the chip designer. Fixes two reported issues on the Z80 examples: - /example/z80-larson-no-board: the board-less chip example now opens its program (larson.s) as the active group, editable on the left — previously the editor showed but no file appeared. - /example/z80-led-chaser-c: the chip program (chaser.c) no longer shows as a sibling tab inside the Arduino sketch group; it sits in its own chip section instead. The board group shows only sketch.ino. Details: - useEditorStore: chipFileGroupId()/CHIP_GROUP_PREFIX helpers. - loadExample: seedChipProgramGroups() routes each chip's programFile into its own group (seeded from the example files), sweeps stale chip groups, keeps the program OUT of the board group, and for a board-less chip example makes the chip group active so the program is the editable file shown. - EditorToolbar.prepareCustomChips: resolves the program from the chip's own group (falls back to board files for older projects) before assembling ROM. - FileExplorer: renders one collapsible section per programmable chip with an IC icon; clicking switches the editor to the chip group. Lazy-creates a group for chips dropped on the canvas. - projectPayload + vlxFile: serialise chip groups alongside board groups and include them in the dirty-check hash, so chip-program edits persist on save / autosave / .vlx export and round-trip via replaceFileGroups on load. - Regression tests for board-less + board+chip routing and stale-group sweep. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 03:07:56 +07:00
import { useEditorStore, chipFileGroupId } from '../../store/useEditorStore';
fix(simulator): circuitVerifier worst-case GPIO + LED NaN guard Two related correctness fixes that make the simulator's realism match what users actually see. 1. circuitVerifier was running pre-flight against the IDLE circuit (every pin LOW). A Blink sketch is going to write pin 13 HIGH eventually — at which point a missing series resistor produces a ~500 mA spike through the diode. But because pre-flight ran with pin 13 LOW the led-overcurrent rule never fired, and the user sailed through Run only to see the LED stay mysteriously dark on the canvas. The verifier now forces every digital pin connected to a load to HIGH = vcc, the worst case any well-defined sketch will eventually impose. The existing rules (led-overcurrent, resistor-overpower, short-circuit) now fire correctly and the existing CircuitVerificationModal blocks Run until the user adds a proper current limiter or chooses Run Anyway. Pins that are inputs-only (a pull-up + button) get over-driven here too, but the rules tolerate that — a pull-up at 5 V draws ~0.5 mA, well below all thresholds. A circuit that would actually fault under HIGH is flagged. 2. LED simulator was crashing visually on non-finite ngspice branch currents. A degenerate diode (no series R) makes ngspice return NaN, which fell through 'raw !== undefined && current > 1e-6' as false and never triggered the digital fallback. Now we check Number.isFinite(raw) before trusting it — non-finite returns route to the digital fallback so the LED at least lights visually when its driver pin is HIGH (the user still sees the verifier warning that the real-world circuit is wrong, but Run Anyway is not a black screen).
2026-05-18 09:41:19 +07:00
import { useSimulatorStore } from '../../store/useSimulatorStore';
import { useElectricalStore } from '../../store/useElectricalStore';
import { verifyCircuit, type VerificationResult } from '../../simulation/verify/circuitVerifier';
import { buildInputFromStore } from '../../simulation/spice/storeAdapter';
import { BOARD_PIN_GROUPS } from '../../simulation/spice/boardPinGroups';
import { CircuitVerificationModal } from '../simulator/CircuitVerificationModal';
import type { PinSourceState } from '../../simulation/spice/types';
import type { BoardKind, LanguageMode } from '../../types/board';
feat(editor): rename boards & custom chips; show which target owns each file Phase 2 of the run-system/UX work. - BoardInstance gains an optional user ; boardDisplayName(board) resolver (name || kind label) routes every INSTANCE-label surface: file-explorer section header, compile console (EditorToolbar), canvas selector/tooltip/ context-menu, Serial Monitor tabs, Oscilloscope board picker, Board Options subtitle. Board/component pickers keep the KIND label (they pick new boards). - Inline rename on board AND chip section headers (double-click the name, or a hover pencil button). Board -> updateBoard(id,{name}); chip -> chipName in properties. Enter commits, Escape cancels (cancel-flag ref guards the unmount-fires-onBlur footgun), empty clears to the kind / 'Custom Chip'. - FileTabs shows an owner badge naming the board/chip whose files are shown (resolved as a selector so it doesn't re-render on every sim pin toggle). - CustomChipDialog no longer clobbers a user-given chipName: chip.json's name only seeds the blank defaults (My Chip / Custom Chip); loading an example relabels explicitly. - Persistence: board name round-trips via projectPayload (+ dirty hash), vlxFile, ProjectByIdPage load + loadProjectState; chipName rides components_json. - Drive-by: fixed a pre-existing rules-of-hooks violation in BoardOptionsModal (early return before a useCallback). Reviewed by a 3-agent adversarial pass (completeness / persistence / correctness); all major findings folded in. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 11:22:25 +07:00
import { BOARD_KIND_FQBN, BOARD_SUPPORTS_MICROPYTHON, isPiBoardKind, boardDisplayName } from '../../types/board';
import { compileCode } from '../../services/compilation';
feat(chips): programmable retro CPU chips with external ROM Adds a new way to use the retro CPU chips: write your program in a project file (.s / .asm / .hex / .bin), click Compile, click Run, and the same chip emulates whatever you wrote. Same chip + different ROMs = mini PC, calculator, LED demo, Kill-the-Bit game, etc. SDK: - velxio-chip.h gets two new host imports: uint32_t vx_rom_size(void); void vx_rom_read(uint32_t off, uint8_t* dst, uint32_t len); CPU-emulator chips call these in chip_setup to pull their program out of the host's romBytes property. Frontend runtime: - ChipRuntime accepts opts.romBytes (Uint8Array) and exposes the new imports, copying bytes into chip memory on vx_rom_read. - CustomChipPart pulls component.properties.romBytes (base64) and passes it through. - Component registry declares three new custom-chip properties: romBytes (base64), programFile (matching project filename), and programTarget (cpu name). New programmable bundled chip: - frontend/src/components/customChips/examples/intel/i8080-cpu.{c,chip.json} Same clean-room 8080 emulator as i8080-repl/i8080-counter, but ROM is loaded externally via vx_rom_*. Has 8 LEDs, 8 buttons, UART, 16 KB RAM, 32 KB of external ROM. Backend: - New /api/compile-rom endpoint and rom_compile service that turns chip-program source into ROM bytes. 8080 ASM is assembled by the in-tree two-pass assembler (moved to backend/app/services/asm8080.py). Intel HEX records are parsed; raw .bin is passed through. Future targets (z80, 8086, 4004) are scaffolded but not wired yet. EditorToolbar: - Compile button detects when the active file is .s/.asm/.hex/.bin and routes to compile-rom instead of arduino-cli. The compiled bytes are injected into every custom-chip on the canvas whose programFile property matches the active filename (or is empty). Example: - /examples/i8080-killbits loads Dean McDaniel's 1975 Kill-the-Bit on the programmable i8080-cpu chip. killbits.s is shipped as a project file alongside sketch.ino; the user clicks Compile then Run and the LED walks across 8 outputs, buttons kill it. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 09:38:18 +07:00
import {
compileRom,
isChipProgramFile,
formatForFile,
targetForChip,
} from '../../services/romCompileService';
import { compileChip } from '../../services/chipCompileService';
feat(sim): Stop halts custom chips + LEDs go dark; retro chip examples go board-less Phase 1 of the run-system/UX work. Stop bug: a programmable chip kept running after Stop when a board was present. The chip rAF tick gated only on board presence (!boardless), so with a board it ticked forever. Now it gates on the actual run state: board-less -> electrical paused flag; with board(s) -> board.running. handleStop also clears every chip's output drives (clearAllChipDrives) and re-solves so chip-driven LEDs go dark on Stop instead of freezing at their last frame. Examples to board-less (regulated power supply, no Arduino — the Arduino only ever supplied 5V): - z80-larson-scanner -> 'Z80 Comet Scanner': board-less, a faster TWO-LED comet (scanner.s) so it's visually distinct from z80-larson-no-board's single-bit walk; green/blue LEDs. - i8080-killbits -> board-less (psu + resistors), keeps killbits.s as the chip's editable program; buttons re-powered from the supply. - i8080-button-counter -> board-less (psu + resistors); behaviour chip, program baked in, so it shows a note (no editable file) and runs standalone. banner-streamer stays Arduino-based (its TX/RX go through the AVR USART bridge). - CustomChipPart: run-state-aware tick gate. - EditorToolbar: clearAllChipDrives() helper + handleStop clears chip drives. - examples-retro-intel: 3 conversions; drop now-unused sketch consts; add the larsonScannerAsm comet program. - Tests: board+chip routing now uses an inline synthetic example (gallery chip examples are all board-less). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 09:32:35 +07:00
import { clearChipDrives } from '../../simulation/customChips/chipPinDrives';
import { requestElectricalResolve } from '../../simulation/spice/electricalResolveHook';
2026-04-26 05:46:52 +07:00
import { reportRunEvent } from '../../services/metricsService';
import { useProjectStore } from '../../store/useProjectStore';
import { LibraryManagerModal } from '../simulator/LibraryManagerModal';
import { InstallLibrariesModal } from '../simulator/InstallLibrariesModal';
import { parseCompileResult } from '../../utils/compilationLogger';
import type { CompilationLog, CompileTarget } from '../../utils/compilationLogger';
import { exportToWokwiZip } from '../../utils/wokwiZip';
import { importProjectFile, PROJECT_FILE_ACCEPT } from '../../utils/importProject';
import { readFirmwareFile } from '../../utils/firmwareLoader';
import {
trackCompileCode,
trackRunSimulation,
trackStopSimulation,
trackResetSimulation,
trackOpenLibraryManager,
} from '../../utils/analytics';
import './EditorToolbar.css';
feat(sim): Stop halts custom chips + LEDs go dark; retro chip examples go board-less Phase 1 of the run-system/UX work. Stop bug: a programmable chip kept running after Stop when a board was present. The chip rAF tick gated only on board presence (!boardless), so with a board it ticked forever. Now it gates on the actual run state: board-less -> electrical paused flag; with board(s) -> board.running. handleStop also clears every chip's output drives (clearAllChipDrives) and re-solves so chip-driven LEDs go dark on Stop instead of freezing at their last frame. Examples to board-less (regulated power supply, no Arduino — the Arduino only ever supplied 5V): - z80-larson-scanner -> 'Z80 Comet Scanner': board-less, a faster TWO-LED comet (scanner.s) so it's visually distinct from z80-larson-no-board's single-bit walk; green/blue LEDs. - i8080-killbits -> board-less (psu + resistors), keeps killbits.s as the chip's editable program; buttons re-powered from the supply. - i8080-button-counter -> board-less (psu + resistors); behaviour chip, program baked in, so it shows a note (no editable file) and runs standalone. banner-streamer stays Arduino-based (its TX/RX go through the AVR USART bridge). - CustomChipPart: run-state-aware tick gate. - EditorToolbar: clearAllChipDrives() helper + handleStop clears chip drives. - examples-retro-intel: 3 conversions; drop now-unused sketch consts; add the larsonScannerAsm comet program. - Tests: board+chip routing now uses an inline synthetic example (gallery chip examples are all board-less). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 09:32:35 +07:00
/**
* Clear the output drives of every custom chip on the canvas and re-solve, so
* chip-driven LEDs go dark on Stop. A chip drives its nets via its own SPICE
* voltage sources (registered in chipPinDrives); stopBoard / electrical-pause
* don't touch those, so without this the LEDs would freeze at their last frame.
*/
function clearAllChipDrives(): void {
const comps = useSimulatorStore.getState().components;
let any = false;
for (const c of comps) {
if (c.metadataId === 'custom-chip') {
clearChipDrives(c.id);
any = true;
}
}
if (any) requestElectricalResolve();
}
/**
* Boards whose firmware runs in a QEMU worker rather than a client-side AVR
* core. They can start without a pre-stored `compiledProgram`. Shared by
* handleRun and handleRunAll so the two paths can't drift.
*/
function isQemuBoardKind(kind: BoardKind | undefined): boolean {
if (!kind) return false;
return (
isPiBoardKind(kind) ||
kind === 'esp32' ||
kind === 'esp32-s3' ||
kind === 'esp32-cam' ||
kind === 'esp32-c3' ||
kind === 'esp32-devkit-c-v4' ||
kind === 'wemos-lolin32-lite' ||
kind === 'xiao-esp32-s3' ||
kind === 'arduino-nano-esp32' ||
kind === 'xiao-esp32-c3' ||
kind === 'aitewinrobot-esp32c3-supermini'
);
}
interface EditorToolbarProps {
consoleOpen: boolean;
setConsoleOpen: (open: boolean | ((v: boolean) => boolean)) => void;
compileLogs: CompilationLog[];
setCompileLogs: (logs: CompilationLog[] | ((prev: CompilationLog[]) => CompilationLog[])) => void;
/**
* Optional element rendered between the left action group and the right
* action group. The editor passes <FileTabs /> here so the tabs share the
* same row as the toolbar keeping every action icon pinned and visible
* regardless of how narrow the editor pane gets.
*/
centerSlot?: React.ReactNode;
/**
* Optional extra elements rendered after the built-in right-group buttons
* (Libraries / Import-Export / Output Console). Used by private overlays
* to add deployment-specific actions without forking the toolbar.
*/
rightSlot?: React.ReactNode;
}
const BOARD_PILL_ICON: Record<BoardKind, string> = {
'arduino-uno': '⬤',
'arduino-nano': '▪',
'arduino-mega': '▬',
'raspberry-pi-pico': '◆',
'raspberry-pi-3': '⬛',
feat(boards): expose Raspberry Pi 4 and Pi 5 in the picker (UI + pin wiring) The BoardKind type and the QEMU backend already supported raspberry-pi-4 (Cortex-A72) and raspberry-pi-5 (Cortex-A76) by reusing the Pi 3 arm64 image set, but the frontend had no way to actually select either: the board picker, the canvas renderer, the serial monitor, the oscilloscope channel list, and the editor toolbar all hard-coded "raspberry-pi-3" as the only Pi entry. ComponentRegistry even registered Pi 4 / Pi 5 metadata pointing at the velxio-raspberry-pi-3 custom-element tag — a placeholder that meant both boards rendered as a Pi 3 in the picker thumbnail and on the canvas. Add dedicated boards top-to-bottom: * `RaspberryPi4Element.ts` / `RaspberryPi5Element.ts` — Velxio-style schematic SVG (authored from scratch, not traced). Pi 4 is the green PCB with BCM2711 SoC, 4× USB-A, USB-C power, dual µHDMI; Pi 5 is the darker green PCB with BCM2712 + RP1 southbridge, 2.5 GbE, USB-C 5V/5A, PCIe FFC connector, dedicated power button. Both carry a small "velxio" mark in the corner. * `pi40PinHeader.ts` — shared `buildPi40PinHeader()` helper that returns the 40-pin BCM layout. Every Pi from the 1B+ onwards uses the same physical pin positions and same BCM GPIO assignment, so Pi 3 / Pi 4 / Pi 5 elements all consume this helper and example wires drawn against one model transfer to the others without re-routing. * React wrappers `RaspberryPi4.tsx` / `RaspberryPi5.tsx` render the custom elements at absolute positions (mirrors how RaspberryPi3.tsx handles the Pi 3 illustration). * Wire-up across the editor surface: - BoardOnCanvas: BOARD_SIZE entry + switch case. - BoardPickerModal: description, icon, kinds list. - ComponentPickerModal: thumbnails now instantiate the dedicated custom element (was velxio-raspberry-pi-3 fallback). - SerialMonitor / EditorToolbar: pill labels, icons, colours. - Oscilloscope: GPIO channel list (28 BCM pins). - SimulatorCanvas: remote-boards filter for run/stop sync. - SPICE boardPinGroups: same 5V / 3V3 / GND as Pi 3. - boardPinToNumber: accepts physical pin numbers ("1"-"40"), BCM names ("GPIO14") and power labels for any Pi 3/4/5 id. - ComponentRegistry: dedicated tagNames + per-board thumbnails (green for Pi 4, darker green for Pi 5). * EditorToolbar's Pi 3 special cases (Linux/Python compile path, Run/Stop routing) now use `isPiBoardKind()` so Pi 4 and Pi 5 inherit the same behaviour automatically, and any future Pi family member (Zero / 1 / 2) lands in the right code paths the moment its backend boots. QEMU backend was already wired (qemu_manager.py:71/82 + manifest entry 'raspberry-pi-3-virt' shared across arm64 Pis), so this commit makes both boards selectable end-to-end without any backend follow-up.
2026-05-23 09:46:59 +07:00
'raspberry-pi-4': '⬛',
'raspberry-pi-5': '⬛',
esp32: '⬡',
'esp32-s3': '⬡',
'esp32-c3': '⬡',
'stm32-bluepill': '◈',
'stm32-blackpill': '◈',
'stm32-bluepill-f103cb': '◈',
'stm32-blackpill-f401': '◈',
'stm32-f4-discovery': '◈',
'stm32-olimex-h405': '◈',
'stm32-netduino-plus2': '◈',
'stm32-netduino2': '◈',
};
const BOARD_PILL_COLOR: Record<BoardKind, string> = {
'arduino-uno': '#4fc3f7',
'arduino-nano': '#4fc3f7',
'arduino-mega': '#4fc3f7',
'raspberry-pi-pico': '#ce93d8',
'raspberry-pi-3': '#ef9a9a',
feat(boards): expose Raspberry Pi 4 and Pi 5 in the picker (UI + pin wiring) The BoardKind type and the QEMU backend already supported raspberry-pi-4 (Cortex-A72) and raspberry-pi-5 (Cortex-A76) by reusing the Pi 3 arm64 image set, but the frontend had no way to actually select either: the board picker, the canvas renderer, the serial monitor, the oscilloscope channel list, and the editor toolbar all hard-coded "raspberry-pi-3" as the only Pi entry. ComponentRegistry even registered Pi 4 / Pi 5 metadata pointing at the velxio-raspberry-pi-3 custom-element tag — a placeholder that meant both boards rendered as a Pi 3 in the picker thumbnail and on the canvas. Add dedicated boards top-to-bottom: * `RaspberryPi4Element.ts` / `RaspberryPi5Element.ts` — Velxio-style schematic SVG (authored from scratch, not traced). Pi 4 is the green PCB with BCM2711 SoC, 4× USB-A, USB-C power, dual µHDMI; Pi 5 is the darker green PCB with BCM2712 + RP1 southbridge, 2.5 GbE, USB-C 5V/5A, PCIe FFC connector, dedicated power button. Both carry a small "velxio" mark in the corner. * `pi40PinHeader.ts` — shared `buildPi40PinHeader()` helper that returns the 40-pin BCM layout. Every Pi from the 1B+ onwards uses the same physical pin positions and same BCM GPIO assignment, so Pi 3 / Pi 4 / Pi 5 elements all consume this helper and example wires drawn against one model transfer to the others without re-routing. * React wrappers `RaspberryPi4.tsx` / `RaspberryPi5.tsx` render the custom elements at absolute positions (mirrors how RaspberryPi3.tsx handles the Pi 3 illustration). * Wire-up across the editor surface: - BoardOnCanvas: BOARD_SIZE entry + switch case. - BoardPickerModal: description, icon, kinds list. - ComponentPickerModal: thumbnails now instantiate the dedicated custom element (was velxio-raspberry-pi-3 fallback). - SerialMonitor / EditorToolbar: pill labels, icons, colours. - Oscilloscope: GPIO channel list (28 BCM pins). - SimulatorCanvas: remote-boards filter for run/stop sync. - SPICE boardPinGroups: same 5V / 3V3 / GND as Pi 3. - boardPinToNumber: accepts physical pin numbers ("1"-"40"), BCM names ("GPIO14") and power labels for any Pi 3/4/5 id. - ComponentRegistry: dedicated tagNames + per-board thumbnails (green for Pi 4, darker green for Pi 5). * EditorToolbar's Pi 3 special cases (Linux/Python compile path, Run/Stop routing) now use `isPiBoardKind()` so Pi 4 and Pi 5 inherit the same behaviour automatically, and any future Pi family member (Zero / 1 / 2) lands in the right code paths the moment its backend boots. QEMU backend was already wired (qemu_manager.py:71/82 + manifest entry 'raspberry-pi-3-virt' shared across arm64 Pis), so this commit makes both boards selectable end-to-end without any backend follow-up.
2026-05-23 09:46:59 +07:00
'raspberry-pi-4': '#ef9a9a',
'raspberry-pi-5': '#ef9a9a',
esp32: '#a5d6a7',
'esp32-s3': '#a5d6a7',
'esp32-c3': '#a5d6a7',
'stm32-bluepill': '#80cbc4',
'stm32-blackpill': '#b0bec5',
'stm32-bluepill-f103cb': '#80cbc4',
'stm32-blackpill-f401': '#b0bec5',
'stm32-f4-discovery': '#90caf9',
'stm32-olimex-h405': '#a5d6a7',
'stm32-netduino-plus2': '#ce93d8',
'stm32-netduino2': '#ce93d8',
};
export const EditorToolbar = ({
consoleOpen,
setConsoleOpen,
compileLogs: _compileLogs,
setCompileLogs,
centerSlot,
rightSlot,
}: EditorToolbarProps) => {
const { t } = useTranslation();
const { files, codeChangedSinceLastCompile, markCompiled } = useEditorStore();
const {
boards,
activeBoardId,
compileBoardProgram,
loadMicroPythonProgram,
setBoardLanguageMode,
updateBoard,
startBoard,
stopBoard,
resetBoard,
// legacy compat
startSimulation,
stopSimulation,
resetSimulation,
running,
compiledHex,
} = useSimulatorStore();
const activeBoard = boards.find((b) => b.id === activeBoardId) ?? boards[0];
2026-04-26 05:46:52 +07:00
const currentProject = useProjectStore((s) => s.currentProject);
// Board-less mode: digital / analog SPICE-only circuits. The Run / Stop
// buttons toggle the SPICE solver's `paused` flag — pausing freezes every
// LED at its current brightness so the user can inspect the state, and
// resuming flushes the most recent switch toggle through the engine.
const electricalPaused = useElectricalStore((s) => s.paused);
const setElectricalPaused = useElectricalStore((s) => s.setPaused);
const isBoardless = boards.length === 0;
const digitalRunning = isBoardless && !electricalPaused;
// Any board actually running — the correct multi-target signal for the
// Run-All / Stop buttons (the flat `running` flag only tracks the ACTIVE
// board, so it misreports a multi-board or non-active-board run).
const anyBoardRunning = boards.some((b) => b.running);
// A "run target" is a board OR a programmable custom-chip (a CPU that runs a
// ROM). When there is more than one target — two boards, a board + a chip, or
// several chips — the unified Compile-All / Run-All buttons appear and act on
// every target, the same way multiple Arduinos behave. Resolved as a number
// so the toolbar only re-renders when the count changes. The predicate is a
// cheap string test (no JSON.parse) since this selector runs on every store
// change, including high-frequency simulation churn. (The compile/run paths
// deliberately act on ALL custom chips, not just programmable ones.)
const targetCount = useSimulatorStore((s) => {
let chips = 0;
for (const c of s.components) {
if (c.metadataId !== 'custom-chip') continue;
const p = c.properties as Record<string, unknown>;
if (String(p?.programFile ?? '').trim() || String(p?.chipJson ?? '').includes('"programTargets"'))
chips++;
}
return s.boards.length + chips;
});
// Circuit-verification modal state. When `pendingRun` is non-null we've
// already paid the cost of solving + analysing — the user can either
// bail out or proceed by running `pendingRun()`.
const [verification, setVerification] = useState<VerificationResult | null>(null);
const pendingRunRef = useRef<(() => void) | null>(null);
2026-04-26 05:46:52 +07:00
// Helper: report a Run event to the backend for analytics. Resolves the
// FQBN from the board kind so the backend can group by family/fqbn.
const reportRun = useCallback(
(boardKind: BoardKind | undefined) => {
const fqbn = boardKind ? BOARD_KIND_FQBN[boardKind] : null;
void reportRunEvent({
project_id: currentProject?.id ?? null,
board_fqbn: fqbn ?? null,
});
},
[currentProject],
);
const [compiling, setCompiling] = useState(false);
const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null);
const [libManagerOpen, setLibManagerOpen] = useState(false);
const [pendingLibraries, setPendingLibraries] = useState<string[]>([]);
const [installModalOpen, setInstallModalOpen] = useState(false);
const importInputRef = useRef<HTMLInputElement>(null);
const firmwareInputRef = useRef<HTMLInputElement>(null);
const toolbarRef = useRef<HTMLDivElement>(null);
const [missingLibHint, setMissingLibHint] = useState(false);
const [moreMenuOpen, setMoreMenuOpen] = useState(false);
const moreMenuRef = useRef<HTMLDivElement>(null);
// Open the Library Manager when another component (e.g. the velxio.json entry
// in the FileExplorer) asks for it via a window event. Avoids prop-drilling
// the modal state down to the explorer.
useEffect(() => {
const open = () => setLibManagerOpen(true);
window.addEventListener('velxio-open-library-manager', open);
return () => window.removeEventListener('velxio-open-library-manager', open);
}, []);
useEffect(() => {
if (!moreMenuOpen) return;
const onClickOutside = (e: MouseEvent) => {
if (moreMenuRef.current && !moreMenuRef.current.contains(e.target as Node)) {
setMoreMenuOpen(false);
}
};
const onEsc = (e: KeyboardEvent) => {
if (e.key === 'Escape') setMoreMenuOpen(false);
};
document.addEventListener('mousedown', onClickOutside);
document.addEventListener('keydown', onEsc);
return () => {
document.removeEventListener('mousedown', onClickOutside);
document.removeEventListener('keydown', onEsc);
};
}, [moreMenuOpen]);
// Compile All / Run All — runs sequentially, logs to console (no dialog)
const [compileAllRunning, setCompileAllRunning] = useState(false);
const addLog = useCallback(
(log: CompilationLog) => {
setCompileLogs((prev: CompilationLog[]) => [...prev, log]);
},
[setCompileLogs],
);
/**
* Make every custom-chip on the canvas runnable: compile its C source to
* WASM (when it has none yet) and, for programmable CPU chips, assemble or
* compile the program file it references into ROM bytes stashing both on
* the chip component's `properties` so the next simulation start picks them
* up. Non-fatal by design: a chip that fails to compile is logged and
* skipped so the board itself still runs.
*/
const prepareCustomChips = useCallback(
async (
chips: { id: string; properties: Record<string, unknown> }[],
boardFiles: { name: string; content: string }[],
) => {
const codeChanged = useEditorStore.getState().codeChangedSinceLastCompile;
const updateComponent = useSimulatorStore.getState().updateComponent;
let failed = 0;
for (const chip of chips) {
// Re-read the freshest properties each iteration (an earlier chip's
// update doesn't touch this one, but be defensive).
const live = useSimulatorStore.getState().components.find((c) => c.id === chip.id);
const props = { ...(live?.properties ?? chip.properties) } as Record<string, unknown>;
const chipLabel = String(props.chipName ?? 'custom chip');
const sourceC = String(props.sourceC ?? '');
const chipJson = String(props.chipJson ?? '{}');
let changed = false;
// Stamp every line for this chip with its target so the console groups
// it under its own section (alongside the boards).
const chipTarget: CompileTarget = { id: chip.id, label: chipLabel, kind: 'chip' };
const clog = (type: CompilationLog['type'], message: string) =>
addLog({ timestamp: new Date(), type, message, target: chipTarget });
// 1. C -> WASM. Only when missing — the chip designer fills this too.
if (!String(props.wasmBase64 ?? '') && sourceC) {
clog('info', `Compiling chip "${chipLabel}" to WASM...`);
try {
const r = await compileChip(sourceC, chipJson);
if (r.success && r.wasm_base64) {
props.wasmBase64 = r.wasm_base64;
changed = true;
clog('success', `Chip "${chipLabel}" compiled (${r.byte_size} B WASM).`);
} else {
clog(
'error',
`Chip "${chipLabel}" WASM compile failed: ${r.error || r.stderr || 'unknown error'}`,
);
failed++;
}
} catch (e) {
clog(
'error',
`Chip "${chipLabel}" WASM compile error: ${e instanceof Error ? e.message : String(e)}`,
);
failed++;
}
}
// 2. program file -> ROM bytes (programmable CPU chips). Recompile
// when there's no ROM yet or the user edited code since last build.
const programFile = String(props.programFile ?? '').trim();
if (programFile && (!String(props.romBytes ?? '') || codeChanged)) {
feat(custom-chip): program lives in its own editor group, not the board sketch A programmable custom-chip (a CPU emulator that runs a ROM/program, e.g. the Z80 or 8080) now keeps its program (larson.s, chaser.c, ...) in a dedicated editor file group — group-chip-<chipId> — rendered as its own collapsible section in the file explorer, exactly like each board owns its sketch group. Behaviour/driver chips and predefined chips carry no programFile and get no group; they stay editable only in the chip designer. Fixes two reported issues on the Z80 examples: - /example/z80-larson-no-board: the board-less chip example now opens its program (larson.s) as the active group, editable on the left — previously the editor showed but no file appeared. - /example/z80-led-chaser-c: the chip program (chaser.c) no longer shows as a sibling tab inside the Arduino sketch group; it sits in its own chip section instead. The board group shows only sketch.ino. Details: - useEditorStore: chipFileGroupId()/CHIP_GROUP_PREFIX helpers. - loadExample: seedChipProgramGroups() routes each chip's programFile into its own group (seeded from the example files), sweeps stale chip groups, keeps the program OUT of the board group, and for a board-less chip example makes the chip group active so the program is the editable file shown. - EditorToolbar.prepareCustomChips: resolves the program from the chip's own group (falls back to board files for older projects) before assembling ROM. - FileExplorer: renders one collapsible section per programmable chip with an IC icon; clicking switches the editor to the chip group. Lazy-creates a group for chips dropped on the canvas. - projectPayload + vlxFile: serialise chip groups alongside board groups and include them in the dirty-check hash, so chip-program edits persist on save / autosave / .vlx export and round-trip via replaceFileGroups on load. - Regression tests for board-less + board+chip routing and stale-group sweep. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 03:07:56 +07:00
// The program lives in the chip's OWN editor group (its collapsible
// section in the file explorer), separate from the board sketch.
// Fall back to the board files for older projects that still carried
// the program alongside sketch.ino in the board group.
const chipGroupFiles = useEditorStore
.getState()
.getGroupFiles(chipFileGroupId(chip.id));
const file =
chipGroupFiles.find((f) => f.name === programFile) ??
boardFiles.find((f) => f.name === programFile);
if (!file) {
clog('error', `Chip "${chipLabel}": program file "${programFile}" not found in the chip's files.`);
failed++;
} else {
const target = targetForChip(chipJson);
const fmt = formatForFile(programFile);
clog(
'info',
`Assembling "${programFile}" (target=${target}, format=${fmt}) for chip "${chipLabel}"...`,
);
try {
const rr = await compileRom(file.content, target, fmt);
if (rr.success && rr.rom_base64) {
props.romBytes = rr.rom_base64;
props.programFile = programFile;
changed = true;
clog('success', `ROM ready: ${rr.byte_size} B injected into "${chipLabel}".`);
} else {
clog(
'error',
`ROM compile failed for "${programFile}": ${rr.error || rr.stderr || 'unknown error'}`,
);
failed++;
}
} catch (e) {
clog(
'error',
`ROM compile error for "${programFile}": ${e instanceof Error ? e.message : String(e)}`,
);
failed++;
}
}
}
if (changed) {
updateComponent(chip.id, { properties: props } as any);
}
}
return { failed };
},
[addLog],
);
const handleCompile = async () => {
setCompiling(true);
setMessage(null);
setConsoleOpen(true);
fix: 5 user-reported issues (#208 #209 #210 #211 #212) #208 — stale binary executes after compile error EditorToolbar.handleCompile: on failed compile, clear the active board's compiledProgram so a subsequent Run can't silently execute the previous successful build (which doesn't match the editor any more). The Run gate already short-circuits on !compiledProgram and forces a fresh compile. #209 — compile terminal kept stale messages across runs EditorToolbar.handleCompile: setCompileLogs([]) at the top of the handler. Previously logs from the prior compile lingered, making it hard to tell new errors / warnings apart from old ones. #210 — desktop File > New Project did nothing desktop/menu.ts: the menu action used to dispatch a CustomEvent nobody listened to. Replaced with a real `newProject()` function that stops the running simulation, removes every board (also drops the bridges + wires touching them), clears components / wires, loads the default Blink sketch into the editor, clears project metadata, and wipes the compile output. Confirms first if there's unsaved work on the canvas. #211 — deleting the only board made every other component unresponsive (wires still worked) SimulatorCanvas.tsx::interactionRunning: the old expression treated boards.length === 0 as "boardless electrical mode is running" — which suppressed the property dialog on click and made non-sensor components look frozen. Fixed by also requiring useElectricalStore.submittedNetlist !== '' before flipping to the boardless-running branch. SPICE has to have actually solved at least once for the mode to engage. #212 — ESP32 Support 404 with no actionable message desktop/Esp32QemuPrompt.tsx: catch the raw "download HTTP 404" / "not found" upstream error and reword it to "ESP32 support is not yet available for your platform. The Velxio team is preparing this build - try again in a few days, or use Arduino/RP2040 boards in the meantime." The real fix is server-side (the velxio team needs to publish a qemu-xtensa.tar.gz for the user's platform into the asset bucket and update esp32-qemu/latest.json). Tracked in project/desktop-agent-v040/ follow-ups. All five fixes verified with `tsc --noEmit` clean and the existing 25-test vitest suite green. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 08:06:44 +07:00
// Wipe the previous build's output before we append anything new.
// Issue #209: lingering logs from prior compiles made it impossible
// to tell the latest errors / warnings apart from stale ones.
setCompileLogs([]);
trackCompileCode();
// ── Custom-chip preparation ─────────────────────────────────────────
// Any custom-chip on the canvas is made "live" here so a single
// Compile / Run is enough — no separate trip through the chip designer
// or a manual ROM compile. For every custom-chip we:
// 1. compile its C source to WASM (when it has none yet), and
// 2. for programmable CPU chips, assemble/compile the program file it
// points at (larson.s, chaser.c, …) into ROM bytes.
// Both artefacts are stashed on the chip component's `properties`;
// CustomChipPart reads wasmBase64 + romBytes at simulation start.
feat(chips): C-to-Z80 compile via SDCC + LED chaser example Adds a third format to /api/compile-rom: `c` (C source compiled by SDCC to Z80 bytes). Same chip-program flow as 8080/Z80 asm — write C in a project file, click Compile, click Run. Backend: - backend/app/services/c_compile.py — async SDCC wrapper. Locates the sdcc binary on PATH (or via SDCC env var, or common Windows install paths) and shells out with target=mz80 + --code-loc 0x100 --data-loc 0x8000. Parses the resulting Intel HEX into raw ROM bytes. Pure 8080 is rejected with a clear error (SDCC has no 8080 backend; Z80 ROMs also run on the i8080-cpu chip if you avoid Z80-only ops). - rom_compile.py: compile_rom is now async; the new c branch delegates to c_compile. compile_rom_endpoint awaits it. Frontend: - romCompileService: RomFormat gains 'c'; formatForFile maps .c/.cpp to 'c'. isChipProgramFile intentionally still excludes .c — disambiguation happens at the EditorToolbar level. - EditorToolbar: the chip-program path also fires when a custom-chip has programFile === activeFile.name (regardless of extension). That lets .c files route to /api/compile-rom (SDCC) when bound to a CPU chip, while .c files NOT bound to any chip continue to route to arduino-cli as before. Docker: - Dockerfile.standalone adds `sdcc` to the apt-get install list, so the prod image ships with SDCC out of the box. Example: - /examples/z80-led-chaser-c — z80-cpu chip + chaser.c (a Larson scanner written in C with __at() MMIO definitions). Compiles cleanly with SDCC's --code-loc 0x100 default crt0. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 10:31:10 +07:00
//
// The chip program files are ALSO kept out of the Arduino sketch compile
// below (see `chipProgramFiles`) — otherwise arduino-cli/avr-gcc would
// try to build e.g. chaser.c and choke on SDCC-only syntax such as
// `__at(0xC000)`, which is exactly what broke the Z80 examples.
feat(chips): C-to-Z80 compile via SDCC + LED chaser example Adds a third format to /api/compile-rom: `c` (C source compiled by SDCC to Z80 bytes). Same chip-program flow as 8080/Z80 asm — write C in a project file, click Compile, click Run. Backend: - backend/app/services/c_compile.py — async SDCC wrapper. Locates the sdcc binary on PATH (or via SDCC env var, or common Windows install paths) and shells out with target=mz80 + --code-loc 0x100 --data-loc 0x8000. Parses the resulting Intel HEX into raw ROM bytes. Pure 8080 is rejected with a clear error (SDCC has no 8080 backend; Z80 ROMs also run on the i8080-cpu chip if you avoid Z80-only ops). - rom_compile.py: compile_rom is now async; the new c branch delegates to c_compile. compile_rom_endpoint awaits it. Frontend: - romCompileService: RomFormat gains 'c'; formatForFile maps .c/.cpp to 'c'. isChipProgramFile intentionally still excludes .c — disambiguation happens at the EditorToolbar level. - EditorToolbar: the chip-program path also fires when a custom-chip has programFile === activeFile.name (regardless of extension). That lets .c files route to /api/compile-rom (SDCC) when bound to a CPU chip, while .c files NOT bound to any chip continue to route to arduino-cli as before. Docker: - Dockerfile.standalone adds `sdcc` to the apt-get install list, so the prod image ships with SDCC out of the box. Example: - /examples/z80-led-chaser-c — z80-cpu chip + chaser.c (a Larson scanner written in C with __at() MMIO definitions). Compiles cleanly with SDCC's --code-loc 0x100 default crt0. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 10:31:10 +07:00
const componentsForCompile = useSimulatorStore.getState().components;
const customChips = componentsForCompile.filter((c) => c.metadataId === 'custom-chip');
const chipProgramFiles = new Set<string>();
for (const chip of customChips) {
const pf = String((chip.properties as any)?.programFile ?? '').trim();
if (pf) chipProgramFiles.add(pf);
feat(chips): programmable retro CPU chips with external ROM Adds a new way to use the retro CPU chips: write your program in a project file (.s / .asm / .hex / .bin), click Compile, click Run, and the same chip emulates whatever you wrote. Same chip + different ROMs = mini PC, calculator, LED demo, Kill-the-Bit game, etc. SDK: - velxio-chip.h gets two new host imports: uint32_t vx_rom_size(void); void vx_rom_read(uint32_t off, uint8_t* dst, uint32_t len); CPU-emulator chips call these in chip_setup to pull their program out of the host's romBytes property. Frontend runtime: - ChipRuntime accepts opts.romBytes (Uint8Array) and exposes the new imports, copying bytes into chip memory on vx_rom_read. - CustomChipPart pulls component.properties.romBytes (base64) and passes it through. - Component registry declares three new custom-chip properties: romBytes (base64), programFile (matching project filename), and programTarget (cpu name). New programmable bundled chip: - frontend/src/components/customChips/examples/intel/i8080-cpu.{c,chip.json} Same clean-room 8080 emulator as i8080-repl/i8080-counter, but ROM is loaded externally via vx_rom_*. Has 8 LEDs, 8 buttons, UART, 16 KB RAM, 32 KB of external ROM. Backend: - New /api/compile-rom endpoint and rom_compile service that turns chip-program source into ROM bytes. 8080 ASM is assembled by the in-tree two-pass assembler (moved to backend/app/services/asm8080.py). Intel HEX records are parsed; raw .bin is passed through. Future targets (z80, 8086, 4004) are scaffolded but not wired yet. EditorToolbar: - Compile button detects when the active file is .s/.asm/.hex/.bin and routes to compile-rom instead of arduino-cli. The compiled bytes are injected into every custom-chip on the canvas whose programFile property matches the active filename (or is empty). Example: - /examples/i8080-killbits loads Dean McDaniel's 1975 Kill-the-Bit on the programmable i8080-cpu chip. killbits.s is shipped as a project file alongside sketch.ino; the user clicks Compile then Run and the LED walks across 8 outputs, buttons kill it. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 09:38:18 +07:00
}
if (customChips.length > 0) {
const boardFiles = activeBoard?.activeFileGroupId
? useEditorStore.getState().getGroupFiles(activeBoard.activeFileGroupId)
: files;
await prepareCustomChips(customChips, boardFiles);
}
// ── End custom-chip preparation ─────────────────────────────────────
feat(chips): programmable retro CPU chips with external ROM Adds a new way to use the retro CPU chips: write your program in a project file (.s / .asm / .hex / .bin), click Compile, click Run, and the same chip emulates whatever you wrote. Same chip + different ROMs = mini PC, calculator, LED demo, Kill-the-Bit game, etc. SDK: - velxio-chip.h gets two new host imports: uint32_t vx_rom_size(void); void vx_rom_read(uint32_t off, uint8_t* dst, uint32_t len); CPU-emulator chips call these in chip_setup to pull their program out of the host's romBytes property. Frontend runtime: - ChipRuntime accepts opts.romBytes (Uint8Array) and exposes the new imports, copying bytes into chip memory on vx_rom_read. - CustomChipPart pulls component.properties.romBytes (base64) and passes it through. - Component registry declares three new custom-chip properties: romBytes (base64), programFile (matching project filename), and programTarget (cpu name). New programmable bundled chip: - frontend/src/components/customChips/examples/intel/i8080-cpu.{c,chip.json} Same clean-room 8080 emulator as i8080-repl/i8080-counter, but ROM is loaded externally via vx_rom_*. Has 8 LEDs, 8 buttons, UART, 16 KB RAM, 32 KB of external ROM. Backend: - New /api/compile-rom endpoint and rom_compile service that turns chip-program source into ROM bytes. 8080 ASM is assembled by the in-tree two-pass assembler (moved to backend/app/services/asm8080.py). Intel HEX records are parsed; raw .bin is passed through. Future targets (z80, 8086, 4004) are scaffolded but not wired yet. EditorToolbar: - Compile button detects when the active file is .s/.asm/.hex/.bin and routes to compile-rom instead of arduino-cli. The compiled bytes are injected into every custom-chip on the canvas whose programFile property matches the active filename (or is empty). Example: - /examples/i8080-killbits loads Dean McDaniel's 1975 Kill-the-Bit on the programmable i8080-cpu chip. killbits.s is shipped as a project file alongside sketch.ino; the user clicks Compile then Run and the LED walks across 8 outputs, buttons kill it. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 09:38:18 +07:00
const kind = activeBoard?.boardKind;
// The active board's console target, defined up front so EVERY board path
// (Pi, MicroPython, arduino-cli, errors) groups its lines under one section.
const boardLabel = activeBoard ? boardDisplayName(activeBoard) : 'Unknown';
const boardTarget: CompileTarget | undefined = activeBoardId
? { id: activeBoardId, label: boardLabel, kind: 'board' }
: undefined;
const blog = (type: CompilationLog['type'], message: string) =>
addLog({ timestamp: new Date(), type, message, target: boardTarget });
// Raspberry Pi 3B doesn't need arduino-cli compilation
feat(boards): expose Raspberry Pi 4 and Pi 5 in the picker (UI + pin wiring) The BoardKind type and the QEMU backend already supported raspberry-pi-4 (Cortex-A72) and raspberry-pi-5 (Cortex-A76) by reusing the Pi 3 arm64 image set, but the frontend had no way to actually select either: the board picker, the canvas renderer, the serial monitor, the oscilloscope channel list, and the editor toolbar all hard-coded "raspberry-pi-3" as the only Pi entry. ComponentRegistry even registered Pi 4 / Pi 5 metadata pointing at the velxio-raspberry-pi-3 custom-element tag — a placeholder that meant both boards rendered as a Pi 3 in the picker thumbnail and on the canvas. Add dedicated boards top-to-bottom: * `RaspberryPi4Element.ts` / `RaspberryPi5Element.ts` — Velxio-style schematic SVG (authored from scratch, not traced). Pi 4 is the green PCB with BCM2711 SoC, 4× USB-A, USB-C power, dual µHDMI; Pi 5 is the darker green PCB with BCM2712 + RP1 southbridge, 2.5 GbE, USB-C 5V/5A, PCIe FFC connector, dedicated power button. Both carry a small "velxio" mark in the corner. * `pi40PinHeader.ts` — shared `buildPi40PinHeader()` helper that returns the 40-pin BCM layout. Every Pi from the 1B+ onwards uses the same physical pin positions and same BCM GPIO assignment, so Pi 3 / Pi 4 / Pi 5 elements all consume this helper and example wires drawn against one model transfer to the others without re-routing. * React wrappers `RaspberryPi4.tsx` / `RaspberryPi5.tsx` render the custom elements at absolute positions (mirrors how RaspberryPi3.tsx handles the Pi 3 illustration). * Wire-up across the editor surface: - BoardOnCanvas: BOARD_SIZE entry + switch case. - BoardPickerModal: description, icon, kinds list. - ComponentPickerModal: thumbnails now instantiate the dedicated custom element (was velxio-raspberry-pi-3 fallback). - SerialMonitor / EditorToolbar: pill labels, icons, colours. - Oscilloscope: GPIO channel list (28 BCM pins). - SimulatorCanvas: remote-boards filter for run/stop sync. - SPICE boardPinGroups: same 5V / 3V3 / GND as Pi 3. - boardPinToNumber: accepts physical pin numbers ("1"-"40"), BCM names ("GPIO14") and power labels for any Pi 3/4/5 id. - ComponentRegistry: dedicated tagNames + per-board thumbnails (green for Pi 4, darker green for Pi 5). * EditorToolbar's Pi 3 special cases (Linux/Python compile path, Run/Stop routing) now use `isPiBoardKind()` so Pi 4 and Pi 5 inherit the same behaviour automatically, and any future Pi family member (Zero / 1 / 2) lands in the right code paths the moment its backend boots. QEMU backend was already wired (qemu_manager.py:71/82 + manifest entry 'raspberry-pi-3-virt' shared across arm64 Pis), so this commit makes both boards selectable end-to-end without any backend follow-up.
2026-05-23 09:46:59 +07:00
if (isPiBoardKind(kind)) {
blog('info', 'Raspberry Pi 3B: no compilation needed — run Python scripts directly.');
setMessage({ type: 'success', text: 'Ready (no compilation needed)' });
setCompiling(false);
return;
}
// MicroPython mode — no backend compilation needed
if (activeBoard?.languageMode === 'micropython' && activeBoardId) {
blog('info', 'MicroPython: loading firmware and user files...');
try {
const groupFiles = useEditorStore.getState().getGroupFiles(activeBoard.activeFileGroupId);
const pyFiles = groupFiles.map((f) => ({ name: f.name, content: f.content }));
await loadMicroPythonProgram(activeBoardId, pyFiles);
blog('success', 'MicroPython firmware loaded successfully');
setMessage({ type: 'success', text: 'MicroPython ready' });
} catch (err) {
const errMsg = err instanceof Error ? err.message : 'Failed to load MicroPython';
blog('error', errMsg);
setMessage({ type: 'error', text: errMsg });
} finally {
setCompiling(false);
}
return;
}
const fqbn = kind ? BOARD_KIND_FQBN[kind] : null;
if (!fqbn) {
blog('error', `No FQBN for board kind: ${kind}`);
setMessage({ type: 'error', text: 'Unknown board' });
setCompiling(false);
return;
}
blog('info', `Starting compilation for ${boardLabel} (${fqbn})...`);
try {
const groupFiles = activeBoard?.activeFileGroupId
? useEditorStore.getState().getGroupFiles(activeBoard.activeFileGroupId)
: files;
const sketchFiles = (groupFiles.length > 0 ? groupFiles : files)
// Keep chip-program files (a chip's programFile, or .s/.asm/.hex/.bin)
// out of the arduino-cli build — they're compiled to ROM above, not
// Arduino sources, and avr-gcc chokes on e.g. SDCC's __at().
.filter((f) => !chipProgramFiles.has(f.name) && !isChipProgramFile(f.name))
.map((f) => ({
name: f.name,
content: f.content,
}));
feat(compile): stream live ESP-IDF cmake + ninja output to the console A user reported on Discord: "the Velxio Console doesn't update anything, it just waits until the very end and displays everything in one go". True for the async compile path — /compile/status only carried `state` and the final `result`, so the editor's CompilationConsole stayed empty during the 5-7 minute cold ESP-IDF builds and dumped 1500 lines at once when the build finished. This wires live build output through the whole stack. Backend (espidf_compiler.py) - New _run_with_streaming() helper. When a progress_callback is provided it spawns the subprocess via Popen + stdout/stderr drain threads and invokes the callback line-by-line. When None it falls back to the existing subprocess.run(capture_output=True) one-shot path so the unit-test code that doesn't care about live output is unaffected. - compile() and _compile_in_dir() take an optional ProgressCallback. - _run_cmake / _run_ninja closures now go through _run_with_streaming with that callback. cmake configure (~2-5 s) + ninja (~5-300+ s) both stream now; the ninja output is the one users actually want to watch. Backend (compile.py) - _compile_job seeds COMPILE_JOBS[id]['stdout_buffer'] = '' and defines on_progress_line(line) which appends to it. Buffer capped at 256 KB (tail kept) so a runaway build can't OOM the FastAPI process. - The buffer is preserved on both the success and the error path so late polls still see the log even after state transitions to done/error. - /compile/status now returns the buffer as a `stdout` field. CompileStatusResponse gains the field with default '' so old clients that don't read it still work. Frontend (compilation.ts) - compileCode() takes a 4th argument: optional CompileProgress callback fired every poll while state ∈ {pending, running}. Carries the cumulative stdout (caller computes deltas) plus elapsed seconds. - Surfaces the new `stdout` field of /compile/status and forwards it to the callback. Errors thrown from the callback are swallowed — a faulty UI hook must never break the polling loop. Frontend (EditorToolbar.tsx) - Both compileCode() call sites (Run and Compile-All) now pass an onProgress callback. It tracks `lastStreamedLen` per-compile, splits each new delta on newlines, and appends them as `info`-typed CompilationLog entries via setCompileLogs. The Compile-All flow prefixes each line with the board label so multi-board builds stay readable. - After the build settles, the existing parseCompileResult call still runs and appends the structured analysis on top of the live stream — that's where FAILED-block detection + the `error`-typed entries that drive the auto-switch-to-errors filter live. Net effect on the user complaint: cold ESP-IDF builds now show the ninja [N/1483] progress lines streaming into the console as they happen, instead of staring at an empty panel for 5-7 minutes. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 04:36:58 +07:00
// Stream live cmake + ninja output into the compilation console as
// it arrives, instead of waiting for the whole build to finish.
// Each poll the backend returns the cumulative stdout buffer; we
// append only the delta since the previous call as 'info' lines.
let lastStreamedLen = 0;
const result = await compileCode(
sketchFiles,
fqbn,
currentProject?.id ?? null,
({ stdout }) => {
if (stdout.length <= lastStreamedLen) return;
const delta = stdout.slice(lastStreamedLen);
lastStreamedLen = stdout.length;
const newLines = delta.split('\n').filter((s) => s.trim());
if (!newLines.length) return;
const now = new Date();
setCompileLogs((prev: CompilationLog[]) => [
...prev,
...newLines.map((line) => ({
timestamp: now,
type: 'info' as const,
message: line,
target: boardTarget,
feat(compile): stream live ESP-IDF cmake + ninja output to the console A user reported on Discord: "the Velxio Console doesn't update anything, it just waits until the very end and displays everything in one go". True for the async compile path — /compile/status only carried `state` and the final `result`, so the editor's CompilationConsole stayed empty during the 5-7 minute cold ESP-IDF builds and dumped 1500 lines at once when the build finished. This wires live build output through the whole stack. Backend (espidf_compiler.py) - New _run_with_streaming() helper. When a progress_callback is provided it spawns the subprocess via Popen + stdout/stderr drain threads and invokes the callback line-by-line. When None it falls back to the existing subprocess.run(capture_output=True) one-shot path so the unit-test code that doesn't care about live output is unaffected. - compile() and _compile_in_dir() take an optional ProgressCallback. - _run_cmake / _run_ninja closures now go through _run_with_streaming with that callback. cmake configure (~2-5 s) + ninja (~5-300+ s) both stream now; the ninja output is the one users actually want to watch. Backend (compile.py) - _compile_job seeds COMPILE_JOBS[id]['stdout_buffer'] = '' and defines on_progress_line(line) which appends to it. Buffer capped at 256 KB (tail kept) so a runaway build can't OOM the FastAPI process. - The buffer is preserved on both the success and the error path so late polls still see the log even after state transitions to done/error. - /compile/status now returns the buffer as a `stdout` field. CompileStatusResponse gains the field with default '' so old clients that don't read it still work. Frontend (compilation.ts) - compileCode() takes a 4th argument: optional CompileProgress callback fired every poll while state ∈ {pending, running}. Carries the cumulative stdout (caller computes deltas) plus elapsed seconds. - Surfaces the new `stdout` field of /compile/status and forwards it to the callback. Errors thrown from the callback are swallowed — a faulty UI hook must never break the polling loop. Frontend (EditorToolbar.tsx) - Both compileCode() call sites (Run and Compile-All) now pass an onProgress callback. It tracks `lastStreamedLen` per-compile, splits each new delta on newlines, and appends them as `info`-typed CompilationLog entries via setCompileLogs. The Compile-All flow prefixes each line with the board label so multi-board builds stay readable. - After the build settles, the existing parseCompileResult call still runs and appends the structured analysis on top of the live stream — that's where FAILED-block detection + the `error`-typed entries that drive the auto-switch-to-errors filter live. Net effect on the user complaint: cold ESP-IDF builds now show the ninja [N/1483] progress lines streaming into the console as they happen, instead of staring at an empty panel for 5-7 minutes. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 04:36:58 +07:00
})),
]);
},
// 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,
// P2.4 — THIS board's declared manifest (compile scope). Per-board so
// two boards can use different libraries without clashing.
libraries: activeBoard?.libraries?.length ? activeBoard.libraries : null,
},
feat(compile): stream live ESP-IDF cmake + ninja output to the console A user reported on Discord: "the Velxio Console doesn't update anything, it just waits until the very end and displays everything in one go". True for the async compile path — /compile/status only carried `state` and the final `result`, so the editor's CompilationConsole stayed empty during the 5-7 minute cold ESP-IDF builds and dumped 1500 lines at once when the build finished. This wires live build output through the whole stack. Backend (espidf_compiler.py) - New _run_with_streaming() helper. When a progress_callback is provided it spawns the subprocess via Popen + stdout/stderr drain threads and invokes the callback line-by-line. When None it falls back to the existing subprocess.run(capture_output=True) one-shot path so the unit-test code that doesn't care about live output is unaffected. - compile() and _compile_in_dir() take an optional ProgressCallback. - _run_cmake / _run_ninja closures now go through _run_with_streaming with that callback. cmake configure (~2-5 s) + ninja (~5-300+ s) both stream now; the ninja output is the one users actually want to watch. Backend (compile.py) - _compile_job seeds COMPILE_JOBS[id]['stdout_buffer'] = '' and defines on_progress_line(line) which appends to it. Buffer capped at 256 KB (tail kept) so a runaway build can't OOM the FastAPI process. - The buffer is preserved on both the success and the error path so late polls still see the log even after state transitions to done/error. - /compile/status now returns the buffer as a `stdout` field. CompileStatusResponse gains the field with default '' so old clients that don't read it still work. Frontend (compilation.ts) - compileCode() takes a 4th argument: optional CompileProgress callback fired every poll while state ∈ {pending, running}. Carries the cumulative stdout (caller computes deltas) plus elapsed seconds. - Surfaces the new `stdout` field of /compile/status and forwards it to the callback. Errors thrown from the callback are swallowed — a faulty UI hook must never break the polling loop. Frontend (EditorToolbar.tsx) - Both compileCode() call sites (Run and Compile-All) now pass an onProgress callback. It tracks `lastStreamedLen` per-compile, splits each new delta on newlines, and appends them as `info`-typed CompilationLog entries via setCompileLogs. The Compile-All flow prefixes each line with the board label so multi-board builds stay readable. - After the build settles, the existing parseCompileResult call still runs and appends the structured analysis on top of the live stream — that's where FAILED-block detection + the `error`-typed entries that drive the auto-switch-to-errors filter live. Net effect on the user complaint: cold ESP-IDF builds now show the ninja [N/1483] progress lines streaming into the console as they happen, instead of staring at an empty panel for 5-7 minutes. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 04:36:58 +07:00
);
// After the build settles, append the structured analysis on top of
// the live stream — parseCompileResult highlights FAILED blocks and
// tags compiler errors with type='error', which the console uses for
// colour + the auto-switch-to-errors filter.
const resultLogs = parseCompileResult(result, boardLabel, boardTarget);
setCompileLogs((prev: CompilationLog[]) => [...prev, ...resultLogs]);
if (result.success) {
const program = result.hex_content ?? result.binary_content ?? null;
if (program && activeBoardId) {
compileBoardProgram(activeBoardId, program);
if (result.has_wifi !== undefined) {
updateBoard(activeBoardId, { hasWifi: result.has_wifi });
}
}
setMessage({ type: 'success', text: 'Compiled successfully' });
markCompiled();
setMissingLibHint(false);
} else {
const errText = result.error || result.stderr || 'Compile failed';
setMessage({ type: 'error', text: errText });
fix: 5 user-reported issues (#208 #209 #210 #211 #212) #208 — stale binary executes after compile error EditorToolbar.handleCompile: on failed compile, clear the active board's compiledProgram so a subsequent Run can't silently execute the previous successful build (which doesn't match the editor any more). The Run gate already short-circuits on !compiledProgram and forces a fresh compile. #209 — compile terminal kept stale messages across runs EditorToolbar.handleCompile: setCompileLogs([]) at the top of the handler. Previously logs from the prior compile lingered, making it hard to tell new errors / warnings apart from old ones. #210 — desktop File > New Project did nothing desktop/menu.ts: the menu action used to dispatch a CustomEvent nobody listened to. Replaced with a real `newProject()` function that stops the running simulation, removes every board (also drops the bridges + wires touching them), clears components / wires, loads the default Blink sketch into the editor, clears project metadata, and wipes the compile output. Confirms first if there's unsaved work on the canvas. #211 — deleting the only board made every other component unresponsive (wires still worked) SimulatorCanvas.tsx::interactionRunning: the old expression treated boards.length === 0 as "boardless electrical mode is running" — which suppressed the property dialog on click and made non-sensor components look frozen. Fixed by also requiring useElectricalStore.submittedNetlist !== '' before flipping to the boardless-running branch. SPICE has to have actually solved at least once for the mode to engage. #212 — ESP32 Support 404 with no actionable message desktop/Esp32QemuPrompt.tsx: catch the raw "download HTTP 404" / "not found" upstream error and reword it to "ESP32 support is not yet available for your platform. The Velxio team is preparing this build - try again in a few days, or use Arduino/RP2040 boards in the meantime." The real fix is server-side (the velxio team needs to publish a qemu-xtensa.tar.gz for the user's platform into the asset bucket and update esp32-qemu/latest.json). Tracked in project/desktop-agent-v040/ follow-ups. All five fixes verified with `tsc --noEmit` clean and the existing 25-test vitest suite green. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 08:06:44 +07:00
// Issue #208: drop the previous successful program from this
// board so a subsequent Run cannot silently execute stale code
// that doesn't match the editor any more. The Run button gates
// on `!compiledProgram` and will refuse + force a re-compile.
if (activeBoardId) {
updateBoard(activeBoardId, { compiledProgram: null });
}
// Detect missing library errors — common patterns:
// "No such file or directory" for #include, "fatal error: XXX.h"
const looksLikeMissingLib =
/No such file or directory|fatal error:.*\.h|library not found/i.test(errText);
setMissingLibHint(looksLikeMissingLib);
}
} catch (err) {
const errMsg = err instanceof Error ? err.message : 'Compile failed';
blog('error', errMsg);
setMessage({ type: 'error', text: errMsg });
} finally {
setCompiling(false);
}
};
// Track whether we should auto-run after compilation completes
const autoRunAfterCompile = useRef(false);
/**
* Pre-flight safety check: solves the current circuit and flags shorts,
* LED over-current and resistor over-power. Returns the result. When the
* solver fails to converge (degenerate netlist, no power source, ) we
* silently report a clean result so the user isn't blocked on circuits
* that aren't physically meaningful yet.
*/
const runVerification = useCallback(async (): Promise<VerificationResult | null> => {
try {
const sim = useSimulatorStore.getState();
// Skip if the circuit hasn't got anything analysable on it yet.
const hasSource = sim.components.some(
(c) => c.metadataId.startsWith('signal-generator') || c.metadataId.startsWith('battery'),
);
if (!hasSource && sim.boards.length === 0) return null;
const snap = {
components: sim.components.map((c) => ({
id: c.id,
metadataId: c.metadataId,
properties: c.properties,
})),
wires: sim.wires,
boards: sim.boards.map((b) => {
fix(simulator): circuitVerifier worst-case GPIO + LED NaN guard Two related correctness fixes that make the simulator's realism match what users actually see. 1. circuitVerifier was running pre-flight against the IDLE circuit (every pin LOW). A Blink sketch is going to write pin 13 HIGH eventually — at which point a missing series resistor produces a ~500 mA spike through the diode. But because pre-flight ran with pin 13 LOW the led-overcurrent rule never fired, and the user sailed through Run only to see the LED stay mysteriously dark on the canvas. The verifier now forces every digital pin connected to a load to HIGH = vcc, the worst case any well-defined sketch will eventually impose. The existing rules (led-overcurrent, resistor-overpower, short-circuit) now fire correctly and the existing CircuitVerificationModal blocks Run until the user adds a proper current limiter or chooses Run Anyway. Pins that are inputs-only (a pull-up + button) get over-driven here too, but the rules tolerate that — a pull-up at 5 V draws ~0.5 mA, well below all thresholds. A circuit that would actually fault under HIGH is flagged. 2. LED simulator was crashing visually on non-finite ngspice branch currents. A degenerate diode (no series R) makes ngspice return NaN, which fell through 'raw !== undefined && current > 1e-6' as false and never triggered the digital fallback. Now we check Number.isFinite(raw) before trusting it — non-finite returns route to the digital fallback so the LED at least lights visually when its driver pin is HIGH (the user still sees the verifier warning that the real-world circuit is wrong, but Run Anyway is not a black screen).
2026-05-18 09:41:19 +07:00
// Realistic pre-flight: simulate the WORST CASE — every digital
// pin connected to a load is forced HIGH at the board's vcc.
// This is what we want because the user's sketch WILL eventually
// do `digitalWrite(pin, HIGH)` (otherwise why is the LED wired?).
// Testing idle state would never flag a missing series resistor
// because the LED draws zero current when its pin is LOW.
//
// Caveat: pins wired only to inputs (e.g. a pull-up resistor +
// button) get over-driven here too. The verifier rules are
// already tolerant — a properly-spec'd pull-up sees minimal
// current and doesn't trip overcurrent / overpower. A circuit
// that would actually fault under HIGH is flagged correctly.
const pinStates: Record<string, PinSourceState> = {};
const group = BOARD_PIN_GROUPS[b.boardKind] ?? BOARD_PIN_GROUPS.default;
fix(simulator): circuitVerifier worst-case GPIO + LED NaN guard Two related correctness fixes that make the simulator's realism match what users actually see. 1. circuitVerifier was running pre-flight against the IDLE circuit (every pin LOW). A Blink sketch is going to write pin 13 HIGH eventually — at which point a missing series resistor produces a ~500 mA spike through the diode. But because pre-flight ran with pin 13 LOW the led-overcurrent rule never fired, and the user sailed through Run only to see the LED stay mysteriously dark on the canvas. The verifier now forces every digital pin connected to a load to HIGH = vcc, the worst case any well-defined sketch will eventually impose. The existing rules (led-overcurrent, resistor-overpower, short-circuit) now fire correctly and the existing CircuitVerificationModal blocks Run until the user adds a proper current limiter or chooses Run Anyway. Pins that are inputs-only (a pull-up + button) get over-driven here too, but the rules tolerate that — a pull-up at 5 V draws ~0.5 mA, well below all thresholds. A circuit that would actually fault under HIGH is flagged. 2. LED simulator was crashing visually on non-finite ngspice branch currents. A degenerate diode (no series R) makes ngspice return NaN, which fell through 'raw !== undefined && current > 1e-6' as false and never triggered the digital fallback. Now we check Number.isFinite(raw) before trusting it — non-finite returns route to the digital fallback so the LED at least lights visually when its driver pin is HIGH (the user still sees the verifier warning that the real-world circuit is wrong, but Run Anyway is not a black screen).
2026-05-18 09:41:19 +07:00
const wiredPinNames = new Set<string>();
for (const w of sim.wires) {
if (w.start.componentId === b.id) wiredPinNames.add(w.start.pinName);
if (w.end.componentId === b.id) wiredPinNames.add(w.end.pinName);
}
for (const pinName of wiredPinNames) {
// Skip GND / power-rail pin names — they belong to the rail
// groups and don't need to be re-asserted as digital sources.
if (group.gnd.includes(pinName)) continue;
if (group.vcc_pins.includes(pinName)) continue;
const arduinoPin = Number.parseInt(pinName, 10);
// Skip pins we can't identify as a digital GPIO (e.g.
// 'AREF', 'RESET', 'TX', 'RX' on some boards). Those are
// either rail-ish or non-driven by the sketch.
if (Number.isNaN(arduinoPin)) continue;
pinStates[pinName] = { type: 'digital', v: group.vcc };
}
return { id: b.id, boardKind: b.boardKind, pinStates };
}),
};
const input = buildInputFromStore(snap);
return await verifyCircuit(input);
} catch (err) {
console.warn('[verifyCircuit] failed', err);
return null;
}
}, []);
/**
* Returns true if the caller should proceed inline. If the verifier finds
* errors we stash a resume callback in `pendingRunRef` and pop the
* verification modal; the resume callback re-enters `handleRun` with
* `skipVerify = true` so we don't loop. Warnings-only results don't
* block they surface inline via `setMessage` and the run continues.
*/
const checkOrBlock = useCallback(
async (resume: () => void): Promise<boolean> => {
const result = await runVerification();
if (!result) return true;
if (result.errors.length === 0 && result.warnings.length === 0) return true;
if (result.errors.length === 0) {
// Warnings only — non-blocking. Surface inline and continue.
const summary = result.warnings
.slice(0, 3)
.map((w) => w.message)
.join(' • ');
const more = result.warnings.length > 3 ? ` (+${result.warnings.length - 3} more)` : '';
setMessage({
type: 'error',
text: `${result.warnings.length} circuit warning${result.warnings.length === 1 ? '' : 's'}: ${summary}${more}`,
});
return true;
}
// Errors → block until the user explicitly chooses Run Anyway.
pendingRunRef.current = resume;
setVerification(result);
return false;
},
[runVerification],
);
const handleRun = async (skipVerify = false) => {
feat: ESP32-CAM emulation with webcam frame bridge First open-source end-to-end emulation of the AI-Thinker ESP32-CAM in QEMU, paired with a browser webcam → firmware bridge so users can develop camera sketches without hardware. Status: esp_camera_init() returns ESP_OK; OV2640 chip-id verifies (PID/VER/MIDH/MIDL exactly match the datasheet); GPIO 25 VSYNC NEGEDGE interrupt enabled by the upstream driver. Final piece (cam_task accepting frames) is in progress — descriptor walker fix landed in this commit. Backend (Python/FastAPI): - simulation.py: camera_attach/frame/detach WS handlers - esp32_worker.py: ctypes binding to velxio_push_camera_frame + feature-detection fallback for older DLLs - esp32_lib_manager.py: forward camera commands to the worker stdin - esp-idf-template/main/CMakeLists.txt: esp32-camera headers added via add_prebuilt_library + REQUIRES driver (resolves i2c_master_* symbols). LED_BUILTIN=2 fallback for sketches that hardcode it. Frontend (React/TS): - EditorToolbar.tsx: ESP32-CAM (and the rest of the ESP32 family) added to isQemuBoard list — Run button now starts the QEMU bridge for these boards instead of falling through to the AVR path - useWebcamFrames.ts: getUserMedia → OffscreenCanvas → toBlob('image/jpeg') → base64 → WS at ~10 fps - CameraToggle.tsx: header button with status colors + frame counter - SimulatorCanvas.tsx: render CameraToggle for esp32-cam boards - Esp32Bridge.ts: sendCameraAttach/Frame/Detach + chunked btoa - useSimulatorStore.ts: diagnostic log on compileBoardProgram - components-metadata.json: regen including esp32-cam component Submodule pointer: - wokwi-libs/qemu-lcgamboa → ff8eee0 (camera devices commit on davidmonterocrespo24/qemu-lcgamboa branch picsimlab-esp32) Investigation + tests in test/test-esp32-cam/: - 13 autosearch markdown docs (overview, SOTA, OV2640 spec, DVP/I2S spec, build blueprint, blockers resolved, descriptor walker fix) - 5 sketches (camera_init, sccb_probe, dma_smoke, frame_roundtrip, webcam_demo) + 8 live + WS regression tests - README with the user-facing flow .gitignore: - libqemu-*.dll.{pre-camera,new,bak} (rollback points, regenerated) - wokwi-libs/esp32-camera/ (clone consumed by arduino-esp32 path, not part of this repo) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 05:28:55 +07:00
console.log('[handleRun] click', { activeBoardId, running, codeChangedSinceLastCompile });
// Pre-flight: solve the circuit and check for shorts / overcurrent /
// overpower. If anything trips we hand control to the modal, which
// resumes by calling `handleRun(true)` for "Run anyway".
if (!skipVerify) {
const ok = await checkOrBlock(() => handleRun(true));
if (!ok) return;
}
// Board-less circuits have no MCU to start. If there are custom-chip CPUs
// on the canvas, compile them (WASM + ROM) and re-attach so they pick up
// the fresh WASM — Velxio runs custom chips with no Arduino/ESP32 board,
// as a general-purpose electronics simulator. Then resume the electrical
// solver (replays any switch toggles captured while paused).
if (isBoardless) {
const customChips = useSimulatorStore
.getState()
.components.filter((c) => c.metadataId === 'custom-chip');
if (customChips.length > 0) {
setCompiling(true);
setConsoleOpen(true);
setCompileLogs([]);
try {
await prepareCustomChips(customChips, files);
} catch (e) {
addLog({
timestamp: new Date(),
type: 'error',
message: e instanceof Error ? e.message : String(e),
});
}
setCompiling(false);
// Force the chip parts to re-attach with their freshly compiled WASM.
useSimulatorStore.getState().restartParts();
}
setElectricalPaused(false);
setMessage(null);
return;
}
if (activeBoardId) {
const board = boards.find((b) => b.id === activeBoardId);
feat: ESP32-CAM emulation with webcam frame bridge First open-source end-to-end emulation of the AI-Thinker ESP32-CAM in QEMU, paired with a browser webcam → firmware bridge so users can develop camera sketches without hardware. Status: esp_camera_init() returns ESP_OK; OV2640 chip-id verifies (PID/VER/MIDH/MIDL exactly match the datasheet); GPIO 25 VSYNC NEGEDGE interrupt enabled by the upstream driver. Final piece (cam_task accepting frames) is in progress — descriptor walker fix landed in this commit. Backend (Python/FastAPI): - simulation.py: camera_attach/frame/detach WS handlers - esp32_worker.py: ctypes binding to velxio_push_camera_frame + feature-detection fallback for older DLLs - esp32_lib_manager.py: forward camera commands to the worker stdin - esp-idf-template/main/CMakeLists.txt: esp32-camera headers added via add_prebuilt_library + REQUIRES driver (resolves i2c_master_* symbols). LED_BUILTIN=2 fallback for sketches that hardcode it. Frontend (React/TS): - EditorToolbar.tsx: ESP32-CAM (and the rest of the ESP32 family) added to isQemuBoard list — Run button now starts the QEMU bridge for these boards instead of falling through to the AVR path - useWebcamFrames.ts: getUserMedia → OffscreenCanvas → toBlob('image/jpeg') → base64 → WS at ~10 fps - CameraToggle.tsx: header button with status colors + frame counter - SimulatorCanvas.tsx: render CameraToggle for esp32-cam boards - Esp32Bridge.ts: sendCameraAttach/Frame/Detach + chunked btoa - useSimulatorStore.ts: diagnostic log on compileBoardProgram - components-metadata.json: regen including esp32-cam component Submodule pointer: - wokwi-libs/qemu-lcgamboa → ff8eee0 (camera devices commit on davidmonterocrespo24/qemu-lcgamboa branch picsimlab-esp32) Investigation + tests in test/test-esp32-cam/: - 13 autosearch markdown docs (overview, SOTA, OV2640 spec, DVP/I2S spec, build blueprint, blockers resolved, descriptor walker fix) - 5 sketches (camera_init, sccb_probe, dma_smoke, frame_roundtrip, webcam_demo) + 8 live + WS regression tests - README with the user-facing flow .gitignore: - libqemu-*.dll.{pre-camera,new,bak} (rollback points, regenerated) - wokwi-libs/esp32-camera/ (clone consumed by arduino-esp32 path, not part of this repo) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 05:28:55 +07:00
console.log('[handleRun] active board', {
id: board?.id,
kind: board?.boardKind,
hasCompiledProgram: !!board?.compiledProgram,
compiledProgramLen: board?.compiledProgram?.length ?? 0,
});
// MicroPython mode: stop any running session first, then reload firmware + start
if (board?.languageMode === 'micropython') {
trackRunSimulation(board.boardKind);
2026-04-26 05:46:52 +07:00
reportRun(board.boardKind);
// Always stop the current session so the new run gets a clean QEMU boot.
// This also prevents the double start_esp32 that occurs when the bridge
// is already connected and startBoard() is called again.
if (board.running) {
stopBoard(activeBoardId);
// Give the WebSocket a moment to close cleanly before reconnecting.
await new Promise((resolve) => setTimeout(resolve, 300));
}
setCompiling(true);
setMessage(null);
const mpyTarget: CompileTarget = {
id: activeBoardId,
label: boardDisplayName(board),
kind: 'board',
};
const mlog = (type: CompilationLog['type'], message: string) =>
addLog({ timestamp: new Date(), type, message, target: mpyTarget });
mlog('info', 'MicroPython: loading firmware and user files...');
try {
const groupFiles = useEditorStore.getState().getGroupFiles(board.activeFileGroupId);
const pyFiles = groupFiles.map((f) => ({ name: f.name, content: f.content }));
await loadMicroPythonProgram(activeBoardId, pyFiles);
mlog('success', 'MicroPython firmware loaded');
} catch (err) {
const errMsg = err instanceof Error ? err.message : 'Failed to load MicroPython';
mlog('error', errMsg);
setMessage({ type: 'error', text: errMsg });
setCompiling(false);
return;
}
setCompiling(false);
startBoard(activeBoardId);
setMessage(null);
return;
}
const isQemuBoard = isQemuBoardKind(board?.boardKind);
// QEMU boards: auto-compile if no firmware available yet
if (isQemuBoard) {
feat: ESP32-CAM emulation with webcam frame bridge First open-source end-to-end emulation of the AI-Thinker ESP32-CAM in QEMU, paired with a browser webcam → firmware bridge so users can develop camera sketches without hardware. Status: esp_camera_init() returns ESP_OK; OV2640 chip-id verifies (PID/VER/MIDH/MIDL exactly match the datasheet); GPIO 25 VSYNC NEGEDGE interrupt enabled by the upstream driver. Final piece (cam_task accepting frames) is in progress — descriptor walker fix landed in this commit. Backend (Python/FastAPI): - simulation.py: camera_attach/frame/detach WS handlers - esp32_worker.py: ctypes binding to velxio_push_camera_frame + feature-detection fallback for older DLLs - esp32_lib_manager.py: forward camera commands to the worker stdin - esp-idf-template/main/CMakeLists.txt: esp32-camera headers added via add_prebuilt_library + REQUIRES driver (resolves i2c_master_* symbols). LED_BUILTIN=2 fallback for sketches that hardcode it. Frontend (React/TS): - EditorToolbar.tsx: ESP32-CAM (and the rest of the ESP32 family) added to isQemuBoard list — Run button now starts the QEMU bridge for these boards instead of falling through to the AVR path - useWebcamFrames.ts: getUserMedia → OffscreenCanvas → toBlob('image/jpeg') → base64 → WS at ~10 fps - CameraToggle.tsx: header button with status colors + frame counter - SimulatorCanvas.tsx: render CameraToggle for esp32-cam boards - Esp32Bridge.ts: sendCameraAttach/Frame/Detach + chunked btoa - useSimulatorStore.ts: diagnostic log on compileBoardProgram - components-metadata.json: regen including esp32-cam component Submodule pointer: - wokwi-libs/qemu-lcgamboa → ff8eee0 (camera devices commit on davidmonterocrespo24/qemu-lcgamboa branch picsimlab-esp32) Investigation + tests in test/test-esp32-cam/: - 13 autosearch markdown docs (overview, SOTA, OV2640 spec, DVP/I2S spec, build blueprint, blockers resolved, descriptor walker fix) - 5 sketches (camera_init, sccb_probe, dma_smoke, frame_roundtrip, webcam_demo) + 8 live + WS regression tests - README with the user-facing flow .gitignore: - libqemu-*.dll.{pre-camera,new,bak} (rollback points, regenerated) - wokwi-libs/esp32-camera/ (clone consumed by arduino-esp32 path, not part of this repo) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 05:28:55 +07:00
console.log('[handleRun] QEMU path');
if (!board?.compiledProgram || codeChangedSinceLastCompile) {
feat: ESP32-CAM emulation with webcam frame bridge First open-source end-to-end emulation of the AI-Thinker ESP32-CAM in QEMU, paired with a browser webcam → firmware bridge so users can develop camera sketches without hardware. Status: esp_camera_init() returns ESP_OK; OV2640 chip-id verifies (PID/VER/MIDH/MIDL exactly match the datasheet); GPIO 25 VSYNC NEGEDGE interrupt enabled by the upstream driver. Final piece (cam_task accepting frames) is in progress — descriptor walker fix landed in this commit. Backend (Python/FastAPI): - simulation.py: camera_attach/frame/detach WS handlers - esp32_worker.py: ctypes binding to velxio_push_camera_frame + feature-detection fallback for older DLLs - esp32_lib_manager.py: forward camera commands to the worker stdin - esp-idf-template/main/CMakeLists.txt: esp32-camera headers added via add_prebuilt_library + REQUIRES driver (resolves i2c_master_* symbols). LED_BUILTIN=2 fallback for sketches that hardcode it. Frontend (React/TS): - EditorToolbar.tsx: ESP32-CAM (and the rest of the ESP32 family) added to isQemuBoard list — Run button now starts the QEMU bridge for these boards instead of falling through to the AVR path - useWebcamFrames.ts: getUserMedia → OffscreenCanvas → toBlob('image/jpeg') → base64 → WS at ~10 fps - CameraToggle.tsx: header button with status colors + frame counter - SimulatorCanvas.tsx: render CameraToggle for esp32-cam boards - Esp32Bridge.ts: sendCameraAttach/Frame/Detach + chunked btoa - useSimulatorStore.ts: diagnostic log on compileBoardProgram - components-metadata.json: regen including esp32-cam component Submodule pointer: - wokwi-libs/qemu-lcgamboa → ff8eee0 (camera devices commit on davidmonterocrespo24/qemu-lcgamboa branch picsimlab-esp32) Investigation + tests in test/test-esp32-cam/: - 13 autosearch markdown docs (overview, SOTA, OV2640 spec, DVP/I2S spec, build blueprint, blockers resolved, descriptor walker fix) - 5 sketches (camera_init, sccb_probe, dma_smoke, frame_roundtrip, webcam_demo) + 8 live + WS regression tests - README with the user-facing flow .gitignore: - libqemu-*.dll.{pre-camera,new,bak} (rollback points, regenerated) - wokwi-libs/esp32-camera/ (clone consumed by arduino-esp32 path, not part of this repo) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 05:28:55 +07:00
console.log('[handleRun] auto-compile + run');
autoRunAfterCompile.current = true;
await handleCompile();
const updatedBoard = useSimulatorStore
.getState()
.boards.find((b) => b.id === activeBoardId);
feat: ESP32-CAM emulation with webcam frame bridge First open-source end-to-end emulation of the AI-Thinker ESP32-CAM in QEMU, paired with a browser webcam → firmware bridge so users can develop camera sketches without hardware. Status: esp_camera_init() returns ESP_OK; OV2640 chip-id verifies (PID/VER/MIDH/MIDL exactly match the datasheet); GPIO 25 VSYNC NEGEDGE interrupt enabled by the upstream driver. Final piece (cam_task accepting frames) is in progress — descriptor walker fix landed in this commit. Backend (Python/FastAPI): - simulation.py: camera_attach/frame/detach WS handlers - esp32_worker.py: ctypes binding to velxio_push_camera_frame + feature-detection fallback for older DLLs - esp32_lib_manager.py: forward camera commands to the worker stdin - esp-idf-template/main/CMakeLists.txt: esp32-camera headers added via add_prebuilt_library + REQUIRES driver (resolves i2c_master_* symbols). LED_BUILTIN=2 fallback for sketches that hardcode it. Frontend (React/TS): - EditorToolbar.tsx: ESP32-CAM (and the rest of the ESP32 family) added to isQemuBoard list — Run button now starts the QEMU bridge for these boards instead of falling through to the AVR path - useWebcamFrames.ts: getUserMedia → OffscreenCanvas → toBlob('image/jpeg') → base64 → WS at ~10 fps - CameraToggle.tsx: header button with status colors + frame counter - SimulatorCanvas.tsx: render CameraToggle for esp32-cam boards - Esp32Bridge.ts: sendCameraAttach/Frame/Detach + chunked btoa - useSimulatorStore.ts: diagnostic log on compileBoardProgram - components-metadata.json: regen including esp32-cam component Submodule pointer: - wokwi-libs/qemu-lcgamboa → ff8eee0 (camera devices commit on davidmonterocrespo24/qemu-lcgamboa branch picsimlab-esp32) Investigation + tests in test/test-esp32-cam/: - 13 autosearch markdown docs (overview, SOTA, OV2640 spec, DVP/I2S spec, build blueprint, blockers resolved, descriptor walker fix) - 5 sketches (camera_init, sccb_probe, dma_smoke, frame_roundtrip, webcam_demo) + 8 live + WS regression tests - README with the user-facing flow .gitignore: - libqemu-*.dll.{pre-camera,new,bak} (rollback points, regenerated) - wokwi-libs/esp32-camera/ (clone consumed by arduino-esp32 path, not part of this repo) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 05:28:55 +07:00
console.log('[handleRun] after compile', {
hasCompiledProgram: !!updatedBoard?.compiledProgram,
compiledProgramLen: updatedBoard?.compiledProgram?.length ?? 0,
autoRunFlag: autoRunAfterCompile.current,
});
if (autoRunAfterCompile.current) {
autoRunAfterCompile.current = false;
feat: ESP32-CAM emulation with webcam frame bridge First open-source end-to-end emulation of the AI-Thinker ESP32-CAM in QEMU, paired with a browser webcam → firmware bridge so users can develop camera sketches without hardware. Status: esp_camera_init() returns ESP_OK; OV2640 chip-id verifies (PID/VER/MIDH/MIDL exactly match the datasheet); GPIO 25 VSYNC NEGEDGE interrupt enabled by the upstream driver. Final piece (cam_task accepting frames) is in progress — descriptor walker fix landed in this commit. Backend (Python/FastAPI): - simulation.py: camera_attach/frame/detach WS handlers - esp32_worker.py: ctypes binding to velxio_push_camera_frame + feature-detection fallback for older DLLs - esp32_lib_manager.py: forward camera commands to the worker stdin - esp-idf-template/main/CMakeLists.txt: esp32-camera headers added via add_prebuilt_library + REQUIRES driver (resolves i2c_master_* symbols). LED_BUILTIN=2 fallback for sketches that hardcode it. Frontend (React/TS): - EditorToolbar.tsx: ESP32-CAM (and the rest of the ESP32 family) added to isQemuBoard list — Run button now starts the QEMU bridge for these boards instead of falling through to the AVR path - useWebcamFrames.ts: getUserMedia → OffscreenCanvas → toBlob('image/jpeg') → base64 → WS at ~10 fps - CameraToggle.tsx: header button with status colors + frame counter - SimulatorCanvas.tsx: render CameraToggle for esp32-cam boards - Esp32Bridge.ts: sendCameraAttach/Frame/Detach + chunked btoa - useSimulatorStore.ts: diagnostic log on compileBoardProgram - components-metadata.json: regen including esp32-cam component Submodule pointer: - wokwi-libs/qemu-lcgamboa → ff8eee0 (camera devices commit on davidmonterocrespo24/qemu-lcgamboa branch picsimlab-esp32) Investigation + tests in test/test-esp32-cam/: - 13 autosearch markdown docs (overview, SOTA, OV2640 spec, DVP/I2S spec, build blueprint, blockers resolved, descriptor walker fix) - 5 sketches (camera_init, sccb_probe, dma_smoke, frame_roundtrip, webcam_demo) + 8 live + WS regression tests - README with the user-facing flow .gitignore: - libqemu-*.dll.{pre-camera,new,bak} (rollback points, regenerated) - wokwi-libs/esp32-camera/ (clone consumed by arduino-esp32 path, not part of this repo) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 05:28:55 +07:00
if (updatedBoard?.compiledProgram) {
trackRunSimulation(updatedBoard.boardKind);
reportRun(updatedBoard.boardKind);
console.log('[handleRun] → startBoard', activeBoardId);
startBoard(activeBoardId);
setMessage(null);
} else {
// handleCompile returned without producing a firmware/program.
// Most common causes: arduino-cli unreachable, ESP-IDF compile
// error in the user's sketch, MicroPython firmware download
// failed, or the bridge rejected the load. handleCompile has
// already addLog'd the underlying error — surface a top-level
// toast too so the user knows their Run click didn't silently
// succeed.
const isMicropython = updatedBoard?.languageMode === 'micropython';
const errText = isMicropython
? 'MicroPython firmware did not load. Click "Load MicroPython" to retry, or check the console for the underlying error.'
: 'Compilation produced no firmware. Check the output console for the underlying error.';
feat: ESP32-CAM emulation with webcam frame bridge First open-source end-to-end emulation of the AI-Thinker ESP32-CAM in QEMU, paired with a browser webcam → firmware bridge so users can develop camera sketches without hardware. Status: esp_camera_init() returns ESP_OK; OV2640 chip-id verifies (PID/VER/MIDH/MIDL exactly match the datasheet); GPIO 25 VSYNC NEGEDGE interrupt enabled by the upstream driver. Final piece (cam_task accepting frames) is in progress — descriptor walker fix landed in this commit. Backend (Python/FastAPI): - simulation.py: camera_attach/frame/detach WS handlers - esp32_worker.py: ctypes binding to velxio_push_camera_frame + feature-detection fallback for older DLLs - esp32_lib_manager.py: forward camera commands to the worker stdin - esp-idf-template/main/CMakeLists.txt: esp32-camera headers added via add_prebuilt_library + REQUIRES driver (resolves i2c_master_* symbols). LED_BUILTIN=2 fallback for sketches that hardcode it. Frontend (React/TS): - EditorToolbar.tsx: ESP32-CAM (and the rest of the ESP32 family) added to isQemuBoard list — Run button now starts the QEMU bridge for these boards instead of falling through to the AVR path - useWebcamFrames.ts: getUserMedia → OffscreenCanvas → toBlob('image/jpeg') → base64 → WS at ~10 fps - CameraToggle.tsx: header button with status colors + frame counter - SimulatorCanvas.tsx: render CameraToggle for esp32-cam boards - Esp32Bridge.ts: sendCameraAttach/Frame/Detach + chunked btoa - useSimulatorStore.ts: diagnostic log on compileBoardProgram - components-metadata.json: regen including esp32-cam component Submodule pointer: - wokwi-libs/qemu-lcgamboa → ff8eee0 (camera devices commit on davidmonterocrespo24/qemu-lcgamboa branch picsimlab-esp32) Investigation + tests in test/test-esp32-cam/: - 13 autosearch markdown docs (overview, SOTA, OV2640 spec, DVP/I2S spec, build blueprint, blockers resolved, descriptor walker fix) - 5 sketches (camera_init, sccb_probe, dma_smoke, frame_roundtrip, webcam_demo) + 8 live + WS regression tests - README with the user-facing flow .gitignore: - libqemu-*.dll.{pre-camera,new,bak} (rollback points, regenerated) - wokwi-libs/esp32-camera/ (clone consumed by arduino-esp32 path, not part of this repo) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 05:28:55 +07:00
console.warn('[handleRun] compile finished but no compiledProgram — not starting');
setMessage({ type: 'error', text: errText });
addLog({ timestamp: new Date(), type: 'error', message: errText });
feat: ESP32-CAM emulation with webcam frame bridge First open-source end-to-end emulation of the AI-Thinker ESP32-CAM in QEMU, paired with a browser webcam → firmware bridge so users can develop camera sketches without hardware. Status: esp_camera_init() returns ESP_OK; OV2640 chip-id verifies (PID/VER/MIDH/MIDL exactly match the datasheet); GPIO 25 VSYNC NEGEDGE interrupt enabled by the upstream driver. Final piece (cam_task accepting frames) is in progress — descriptor walker fix landed in this commit. Backend (Python/FastAPI): - simulation.py: camera_attach/frame/detach WS handlers - esp32_worker.py: ctypes binding to velxio_push_camera_frame + feature-detection fallback for older DLLs - esp32_lib_manager.py: forward camera commands to the worker stdin - esp-idf-template/main/CMakeLists.txt: esp32-camera headers added via add_prebuilt_library + REQUIRES driver (resolves i2c_master_* symbols). LED_BUILTIN=2 fallback for sketches that hardcode it. Frontend (React/TS): - EditorToolbar.tsx: ESP32-CAM (and the rest of the ESP32 family) added to isQemuBoard list — Run button now starts the QEMU bridge for these boards instead of falling through to the AVR path - useWebcamFrames.ts: getUserMedia → OffscreenCanvas → toBlob('image/jpeg') → base64 → WS at ~10 fps - CameraToggle.tsx: header button with status colors + frame counter - SimulatorCanvas.tsx: render CameraToggle for esp32-cam boards - Esp32Bridge.ts: sendCameraAttach/Frame/Detach + chunked btoa - useSimulatorStore.ts: diagnostic log on compileBoardProgram - components-metadata.json: regen including esp32-cam component Submodule pointer: - wokwi-libs/qemu-lcgamboa → ff8eee0 (camera devices commit on davidmonterocrespo24/qemu-lcgamboa branch picsimlab-esp32) Investigation + tests in test/test-esp32-cam/: - 13 autosearch markdown docs (overview, SOTA, OV2640 spec, DVP/I2S spec, build blueprint, blockers resolved, descriptor walker fix) - 5 sketches (camera_init, sccb_probe, dma_smoke, frame_roundtrip, webcam_demo) + 8 live + WS regression tests - README with the user-facing flow .gitignore: - libqemu-*.dll.{pre-camera,new,bak} (rollback points, regenerated) - wokwi-libs/esp32-camera/ (clone consumed by arduino-esp32 path, not part of this repo) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 05:28:55 +07:00
}
}
return;
}
trackRunSimulation(board?.boardKind);
2026-04-26 05:46:52 +07:00
reportRun(board?.boardKind);
feat: ESP32-CAM emulation with webcam frame bridge First open-source end-to-end emulation of the AI-Thinker ESP32-CAM in QEMU, paired with a browser webcam → firmware bridge so users can develop camera sketches without hardware. Status: esp_camera_init() returns ESP_OK; OV2640 chip-id verifies (PID/VER/MIDH/MIDL exactly match the datasheet); GPIO 25 VSYNC NEGEDGE interrupt enabled by the upstream driver. Final piece (cam_task accepting frames) is in progress — descriptor walker fix landed in this commit. Backend (Python/FastAPI): - simulation.py: camera_attach/frame/detach WS handlers - esp32_worker.py: ctypes binding to velxio_push_camera_frame + feature-detection fallback for older DLLs - esp32_lib_manager.py: forward camera commands to the worker stdin - esp-idf-template/main/CMakeLists.txt: esp32-camera headers added via add_prebuilt_library + REQUIRES driver (resolves i2c_master_* symbols). LED_BUILTIN=2 fallback for sketches that hardcode it. Frontend (React/TS): - EditorToolbar.tsx: ESP32-CAM (and the rest of the ESP32 family) added to isQemuBoard list — Run button now starts the QEMU bridge for these boards instead of falling through to the AVR path - useWebcamFrames.ts: getUserMedia → OffscreenCanvas → toBlob('image/jpeg') → base64 → WS at ~10 fps - CameraToggle.tsx: header button with status colors + frame counter - SimulatorCanvas.tsx: render CameraToggle for esp32-cam boards - Esp32Bridge.ts: sendCameraAttach/Frame/Detach + chunked btoa - useSimulatorStore.ts: diagnostic log on compileBoardProgram - components-metadata.json: regen including esp32-cam component Submodule pointer: - wokwi-libs/qemu-lcgamboa → ff8eee0 (camera devices commit on davidmonterocrespo24/qemu-lcgamboa branch picsimlab-esp32) Investigation + tests in test/test-esp32-cam/: - 13 autosearch markdown docs (overview, SOTA, OV2640 spec, DVP/I2S spec, build blueprint, blockers resolved, descriptor walker fix) - 5 sketches (camera_init, sccb_probe, dma_smoke, frame_roundtrip, webcam_demo) + 8 live + WS regression tests - README with the user-facing flow .gitignore: - libqemu-*.dll.{pre-camera,new,bak} (rollback points, regenerated) - wokwi-libs/esp32-camera/ (clone consumed by arduino-esp32 path, not part of this repo) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 05:28:55 +07:00
console.log('[handleRun] → startBoard (already compiled)', activeBoardId);
startBoard(activeBoardId);
setMessage(null);
return;
}
// Auto-compile if no program or code changed since last compile
if (!board?.compiledProgram || codeChangedSinceLastCompile) {
autoRunAfterCompile.current = true;
await handleCompile();
// After compile, check if it succeeded and run
const updatedBoard = useSimulatorStore
.getState()
.boards.find((b) => b.id === activeBoardId);
if (autoRunAfterCompile.current && updatedBoard?.compiledProgram) {
autoRunAfterCompile.current = false;
trackRunSimulation(updatedBoard.boardKind);
2026-04-26 05:46:52 +07:00
reportRun(updatedBoard.boardKind);
startBoard(activeBoardId);
setMessage(null);
} else {
autoRunAfterCompile.current = false;
}
return;
}
trackRunSimulation(board?.boardKind);
2026-04-26 05:46:52 +07:00
reportRun(board?.boardKind);
startBoard(activeBoardId);
setMessage(null);
return;
}
// Legacy fallback
if (!compiledHex || codeChangedSinceLastCompile) {
autoRunAfterCompile.current = true;
await handleCompile();
const hex = useSimulatorStore.getState().compiledHex;
if (autoRunAfterCompile.current && hex) {
autoRunAfterCompile.current = false;
trackRunSimulation();
2026-04-26 05:46:52 +07:00
reportRun(undefined);
startSimulation();
setMessage(null);
} else {
autoRunAfterCompile.current = false;
}
} else {
trackRunSimulation();
2026-04-26 05:46:52 +07:00
reportRun(undefined);
startSimulation();
setMessage(null);
}
};
const handleStop = () => {
trackStopSimulation();
if (isBoardless) {
feat(sim): Stop halts custom chips + LEDs go dark; retro chip examples go board-less Phase 1 of the run-system/UX work. Stop bug: a programmable chip kept running after Stop when a board was present. The chip rAF tick gated only on board presence (!boardless), so with a board it ticked forever. Now it gates on the actual run state: board-less -> electrical paused flag; with board(s) -> board.running. handleStop also clears every chip's output drives (clearAllChipDrives) and re-solves so chip-driven LEDs go dark on Stop instead of freezing at their last frame. Examples to board-less (regulated power supply, no Arduino — the Arduino only ever supplied 5V): - z80-larson-scanner -> 'Z80 Comet Scanner': board-less, a faster TWO-LED comet (scanner.s) so it's visually distinct from z80-larson-no-board's single-bit walk; green/blue LEDs. - i8080-killbits -> board-less (psu + resistors), keeps killbits.s as the chip's editable program; buttons re-powered from the supply. - i8080-button-counter -> board-less (psu + resistors); behaviour chip, program baked in, so it shows a note (no editable file) and runs standalone. banner-streamer stays Arduino-based (its TX/RX go through the AVR USART bridge). - CustomChipPart: run-state-aware tick gate. - EditorToolbar: clearAllChipDrives() helper + handleStop clears chip drives. - examples-retro-intel: 3 conversions; drop now-unused sketch consts; add the larsonScannerAsm comet program. - Tests: board+chip routing now uses an inline synthetic example (gallery chip examples are all board-less). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 09:32:35 +07:00
// Freeze the chip tick (the paused flag) AND clear the chip's output
// drives so its LEDs go dark on Stop — not frozen at their last frame.
setElectricalPaused(true);
feat(sim): Stop halts custom chips + LEDs go dark; retro chip examples go board-less Phase 1 of the run-system/UX work. Stop bug: a programmable chip kept running after Stop when a board was present. The chip rAF tick gated only on board presence (!boardless), so with a board it ticked forever. Now it gates on the actual run state: board-less -> electrical paused flag; with board(s) -> board.running. handleStop also clears every chip's output drives (clearAllChipDrives) and re-solves so chip-driven LEDs go dark on Stop instead of freezing at their last frame. Examples to board-less (regulated power supply, no Arduino — the Arduino only ever supplied 5V): - z80-larson-scanner -> 'Z80 Comet Scanner': board-less, a faster TWO-LED comet (scanner.s) so it's visually distinct from z80-larson-no-board's single-bit walk; green/blue LEDs. - i8080-killbits -> board-less (psu + resistors), keeps killbits.s as the chip's editable program; buttons re-powered from the supply. - i8080-button-counter -> board-less (psu + resistors); behaviour chip, program baked in, so it shows a note (no editable file) and runs standalone. banner-streamer stays Arduino-based (its TX/RX go through the AVR USART bridge). - CustomChipPart: run-state-aware tick gate. - EditorToolbar: clearAllChipDrives() helper + handleStop clears chip drives. - examples-retro-intel: 3 conversions; drop now-unused sketch consts; add the larsonScannerAsm comet program. - Tests: board+chip routing now uses an inline synthetic example (gallery chip examples are all board-less). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 09:32:35 +07:00
clearAllChipDrives();
setMessage(null);
return;
}
// Stop EVERY running board — Run-All can start several, and leaving any
// running keeps chips ticking (their gate is boards.some(running)).
const runningBoards = useSimulatorStore.getState().boards.filter((b) => b.running);
if (runningBoards.length > 0) runningBoards.forEach((b) => stopBoard(b.id));
else if (activeBoardId) stopBoard(activeBoardId);
else stopSimulation();
feat(sim): Stop halts custom chips + LEDs go dark; retro chip examples go board-less Phase 1 of the run-system/UX work. Stop bug: a programmable chip kept running after Stop when a board was present. The chip rAF tick gated only on board presence (!boardless), so with a board it ticked forever. Now it gates on the actual run state: board-less -> electrical paused flag; with board(s) -> board.running. handleStop also clears every chip's output drives (clearAllChipDrives) and re-solves so chip-driven LEDs go dark on Stop instead of freezing at their last frame. Examples to board-less (regulated power supply, no Arduino — the Arduino only ever supplied 5V): - z80-larson-scanner -> 'Z80 Comet Scanner': board-less, a faster TWO-LED comet (scanner.s) so it's visually distinct from z80-larson-no-board's single-bit walk; green/blue LEDs. - i8080-killbits -> board-less (psu + resistors), keeps killbits.s as the chip's editable program; buttons re-powered from the supply. - i8080-button-counter -> board-less (psu + resistors); behaviour chip, program baked in, so it shows a note (no editable file) and runs standalone. banner-streamer stays Arduino-based (its TX/RX go through the AVR USART bridge). - CustomChipPart: run-state-aware tick gate. - EditorToolbar: clearAllChipDrives() helper + handleStop clears chip drives. - examples-retro-intel: 3 conversions; drop now-unused sketch consts; add the larsonScannerAsm comet program. - Tests: board+chip routing now uses an inline synthetic example (gallery chip examples are all board-less). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 09:32:35 +07:00
// A chip wired to a board drives its LEDs via its own SPICE sources, which
// stopBoard doesn't touch — clear them so those LEDs also go dark.
clearAllChipDrives();
setMessage(null);
};
const handleReset = () => {
trackResetSimulation();
if (activeBoardId) resetBoard(activeBoardId);
else resetSimulation();
setMessage(null);
};
/**
* Compile every board on the canvas sequentially. Progress + per-board
* results stream to the existing compilation console no separate dialog.
* Returns the count of boards that ended up with a runnable program (so
* Run All can use it to decide whether to proceed to start them).
*/
const compileAllBoards = async (): Promise<{ ok: number; failed: number }> => {
const boardsList = useSimulatorStore.getState().boards;
// Every custom-chip is a target too — Compile-All / Run-All build chips
// (WASM + ROM) alongside boards, so the flow works for a board + chip, for
// several chips with no board, etc.
const allCustomChips = useSimulatorStore
.getState()
.components.filter((c) => c.metadataId === 'custom-chip');
if (boardsList.length === 0 && allCustomChips.length === 0) return { ok: 0, failed: 0 };
setCompileAllRunning(true);
setConsoleOpen(true);
const targetSummary = [
boardsList.length ? `${boardsList.length} board${boardsList.length === 1 ? '' : 's'}` : '',
allCustomChips.length ? `${allCustomChips.length} chip${allCustomChips.length === 1 ? '' : 's'}` : '',
]
.filter(Boolean)
.join(' + ');
addLog({
timestamp: new Date(),
type: 'info',
message: `Compiling all targets (${targetSummary})...`,
});
// Make every custom-chip live (WASM + ROM) before compiling the boards,
// mirroring the single-board Compile path, and collect their program file
// names so they stay out of the arduino-cli builds below.
const chipProgramFiles = new Set<string>();
for (const chip of allCustomChips) {
const pf = String((chip.properties as any)?.programFile ?? '').trim();
if (pf) chipProgramFiles.add(pf);
}
let chipFailed = 0;
if (allCustomChips.length > 0) {
const everyFile = boardsList.flatMap((b) =>
useEditorStore.getState().getGroupFiles(b.activeFileGroupId),
);
chipFailed = (await prepareCustomChips(allCustomChips, everyFile)).failed;
}
let ok = 0;
let boardFailed = 0;
for (const board of boardsList) {
feat(editor): rename boards & custom chips; show which target owns each file Phase 2 of the run-system/UX work. - BoardInstance gains an optional user ; boardDisplayName(board) resolver (name || kind label) routes every INSTANCE-label surface: file-explorer section header, compile console (EditorToolbar), canvas selector/tooltip/ context-menu, Serial Monitor tabs, Oscilloscope board picker, Board Options subtitle. Board/component pickers keep the KIND label (they pick new boards). - Inline rename on board AND chip section headers (double-click the name, or a hover pencil button). Board -> updateBoard(id,{name}); chip -> chipName in properties. Enter commits, Escape cancels (cancel-flag ref guards the unmount-fires-onBlur footgun), empty clears to the kind / 'Custom Chip'. - FileTabs shows an owner badge naming the board/chip whose files are shown (resolved as a selector so it doesn't re-render on every sim pin toggle). - CustomChipDialog no longer clobbers a user-given chipName: chip.json's name only seeds the blank defaults (My Chip / Custom Chip); loading an example relabels explicitly. - Persistence: board name round-trips via projectPayload (+ dirty hash), vlxFile, ProjectByIdPage load + loadProjectState; chipName rides components_json. - Drive-by: fixed a pre-existing rules-of-hooks violation in BoardOptionsModal (early return before a useCallback). Reviewed by a 3-agent adversarial pass (completeness / persistence / correctness); all major findings folded in. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 11:22:25 +07:00
const label = boardDisplayName(board);
// Stamp this board's lines so the console groups them under its section.
const boardTarget: CompileTarget = { id: board.id, label, kind: 'board' };
const blog = (type: CompilationLog['type'], message: string) =>
addLog({ timestamp: new Date(), type, message, target: boardTarget });
feat(boards): expose Raspberry Pi 4 and Pi 5 in the picker (UI + pin wiring) The BoardKind type and the QEMU backend already supported raspberry-pi-4 (Cortex-A72) and raspberry-pi-5 (Cortex-A76) by reusing the Pi 3 arm64 image set, but the frontend had no way to actually select either: the board picker, the canvas renderer, the serial monitor, the oscilloscope channel list, and the editor toolbar all hard-coded "raspberry-pi-3" as the only Pi entry. ComponentRegistry even registered Pi 4 / Pi 5 metadata pointing at the velxio-raspberry-pi-3 custom-element tag — a placeholder that meant both boards rendered as a Pi 3 in the picker thumbnail and on the canvas. Add dedicated boards top-to-bottom: * `RaspberryPi4Element.ts` / `RaspberryPi5Element.ts` — Velxio-style schematic SVG (authored from scratch, not traced). Pi 4 is the green PCB with BCM2711 SoC, 4× USB-A, USB-C power, dual µHDMI; Pi 5 is the darker green PCB with BCM2712 + RP1 southbridge, 2.5 GbE, USB-C 5V/5A, PCIe FFC connector, dedicated power button. Both carry a small "velxio" mark in the corner. * `pi40PinHeader.ts` — shared `buildPi40PinHeader()` helper that returns the 40-pin BCM layout. Every Pi from the 1B+ onwards uses the same physical pin positions and same BCM GPIO assignment, so Pi 3 / Pi 4 / Pi 5 elements all consume this helper and example wires drawn against one model transfer to the others without re-routing. * React wrappers `RaspberryPi4.tsx` / `RaspberryPi5.tsx` render the custom elements at absolute positions (mirrors how RaspberryPi3.tsx handles the Pi 3 illustration). * Wire-up across the editor surface: - BoardOnCanvas: BOARD_SIZE entry + switch case. - BoardPickerModal: description, icon, kinds list. - ComponentPickerModal: thumbnails now instantiate the dedicated custom element (was velxio-raspberry-pi-3 fallback). - SerialMonitor / EditorToolbar: pill labels, icons, colours. - Oscilloscope: GPIO channel list (28 BCM pins). - SimulatorCanvas: remote-boards filter for run/stop sync. - SPICE boardPinGroups: same 5V / 3V3 / GND as Pi 3. - boardPinToNumber: accepts physical pin numbers ("1"-"40"), BCM names ("GPIO14") and power labels for any Pi 3/4/5 id. - ComponentRegistry: dedicated tagNames + per-board thumbnails (green for Pi 4, darker green for Pi 5). * EditorToolbar's Pi 3 special cases (Linux/Python compile path, Run/Stop routing) now use `isPiBoardKind()` so Pi 4 and Pi 5 inherit the same behaviour automatically, and any future Pi family member (Zero / 1 / 2) lands in the right code paths the moment its backend boots. QEMU backend was already wired (qemu_manager.py:71/82 + manifest entry 'raspberry-pi-3-virt' shared across arm64 Pis), so this commit makes both boards selectable end-to-end without any backend follow-up.
2026-05-23 09:46:59 +07:00
if (isPiBoardKind(board.boardKind)) {
blog('info', 'skipped (no compilation needed)');
ok++;
continue;
}
const fqbn = BOARD_KIND_FQBN[board.boardKind];
if (!fqbn) {
blog('error', 'no FQBN configured');
boardFailed++;
continue;
}
blog('info', 'compiling...');
try {
const groupFiles = useEditorStore.getState().getGroupFiles(board.activeFileGroupId);
const sketchFiles = groupFiles
.filter((f) => !chipProgramFiles.has(f.name) && !isChipProgramFile(f.name))
.map((f) => ({ name: f.name, content: f.content }));
feat(compile): stream live ESP-IDF cmake + ninja output to the console A user reported on Discord: "the Velxio Console doesn't update anything, it just waits until the very end and displays everything in one go". True for the async compile path — /compile/status only carried `state` and the final `result`, so the editor's CompilationConsole stayed empty during the 5-7 minute cold ESP-IDF builds and dumped 1500 lines at once when the build finished. This wires live build output through the whole stack. Backend (espidf_compiler.py) - New _run_with_streaming() helper. When a progress_callback is provided it spawns the subprocess via Popen + stdout/stderr drain threads and invokes the callback line-by-line. When None it falls back to the existing subprocess.run(capture_output=True) one-shot path so the unit-test code that doesn't care about live output is unaffected. - compile() and _compile_in_dir() take an optional ProgressCallback. - _run_cmake / _run_ninja closures now go through _run_with_streaming with that callback. cmake configure (~2-5 s) + ninja (~5-300+ s) both stream now; the ninja output is the one users actually want to watch. Backend (compile.py) - _compile_job seeds COMPILE_JOBS[id]['stdout_buffer'] = '' and defines on_progress_line(line) which appends to it. Buffer capped at 256 KB (tail kept) so a runaway build can't OOM the FastAPI process. - The buffer is preserved on both the success and the error path so late polls still see the log even after state transitions to done/error. - /compile/status now returns the buffer as a `stdout` field. CompileStatusResponse gains the field with default '' so old clients that don't read it still work. Frontend (compilation.ts) - compileCode() takes a 4th argument: optional CompileProgress callback fired every poll while state ∈ {pending, running}. Carries the cumulative stdout (caller computes deltas) plus elapsed seconds. - Surfaces the new `stdout` field of /compile/status and forwards it to the callback. Errors thrown from the callback are swallowed — a faulty UI hook must never break the polling loop. Frontend (EditorToolbar.tsx) - Both compileCode() call sites (Run and Compile-All) now pass an onProgress callback. It tracks `lastStreamedLen` per-compile, splits each new delta on newlines, and appends them as `info`-typed CompilationLog entries via setCompileLogs. The Compile-All flow prefixes each line with the board label so multi-board builds stay readable. - After the build settles, the existing parseCompileResult call still runs and appends the structured analysis on top of the live stream — that's where FAILED-block detection + the `error`-typed entries that drive the auto-switch-to-errors filter live. Net effect on the user complaint: cold ESP-IDF builds now show the ninja [N/1483] progress lines streaming into the console as they happen, instead of staring at an empty panel for 5-7 minutes. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 04:36:58 +07:00
// Stream live cmake + ninja output per-board (Compile-All flow).
let lastStreamedLen = 0;
const result = await compileCode(
sketchFiles,
fqbn,
currentProject?.id ?? null,
({ stdout }) => {
if (stdout.length <= lastStreamedLen) return;
const delta = stdout.slice(lastStreamedLen);
lastStreamedLen = stdout.length;
const newLines = delta.split('\n').filter((s) => s.trim());
if (!newLines.length) return;
const now = new Date();
setCompileLogs((prev: CompilationLog[]) => [
...prev,
// No `${label}: ` prefix — the target section header carries it.
feat(compile): stream live ESP-IDF cmake + ninja output to the console A user reported on Discord: "the Velxio Console doesn't update anything, it just waits until the very end and displays everything in one go". True for the async compile path — /compile/status only carried `state` and the final `result`, so the editor's CompilationConsole stayed empty during the 5-7 minute cold ESP-IDF builds and dumped 1500 lines at once when the build finished. This wires live build output through the whole stack. Backend (espidf_compiler.py) - New _run_with_streaming() helper. When a progress_callback is provided it spawns the subprocess via Popen + stdout/stderr drain threads and invokes the callback line-by-line. When None it falls back to the existing subprocess.run(capture_output=True) one-shot path so the unit-test code that doesn't care about live output is unaffected. - compile() and _compile_in_dir() take an optional ProgressCallback. - _run_cmake / _run_ninja closures now go through _run_with_streaming with that callback. cmake configure (~2-5 s) + ninja (~5-300+ s) both stream now; the ninja output is the one users actually want to watch. Backend (compile.py) - _compile_job seeds COMPILE_JOBS[id]['stdout_buffer'] = '' and defines on_progress_line(line) which appends to it. Buffer capped at 256 KB (tail kept) so a runaway build can't OOM the FastAPI process. - The buffer is preserved on both the success and the error path so late polls still see the log even after state transitions to done/error. - /compile/status now returns the buffer as a `stdout` field. CompileStatusResponse gains the field with default '' so old clients that don't read it still work. Frontend (compilation.ts) - compileCode() takes a 4th argument: optional CompileProgress callback fired every poll while state ∈ {pending, running}. Carries the cumulative stdout (caller computes deltas) plus elapsed seconds. - Surfaces the new `stdout` field of /compile/status and forwards it to the callback. Errors thrown from the callback are swallowed — a faulty UI hook must never break the polling loop. Frontend (EditorToolbar.tsx) - Both compileCode() call sites (Run and Compile-All) now pass an onProgress callback. It tracks `lastStreamedLen` per-compile, splits each new delta on newlines, and appends them as `info`-typed CompilationLog entries via setCompileLogs. The Compile-All flow prefixes each line with the board label so multi-board builds stay readable. - After the build settles, the existing parseCompileResult call still runs and appends the structured analysis on top of the live stream — that's where FAILED-block detection + the `error`-typed entries that drive the auto-switch-to-errors filter live. Net effect on the user complaint: cold ESP-IDF builds now show the ninja [N/1483] progress lines streaming into the console as they happen, instead of staring at an empty panel for 5-7 minutes. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 04:36:58 +07:00
...newLines.map((line) => ({
timestamp: now,
type: 'info' as const,
message: line,
target: boardTarget,
feat(compile): stream live ESP-IDF cmake + ninja output to the console A user reported on Discord: "the Velxio Console doesn't update anything, it just waits until the very end and displays everything in one go". True for the async compile path — /compile/status only carried `state` and the final `result`, so the editor's CompilationConsole stayed empty during the 5-7 minute cold ESP-IDF builds and dumped 1500 lines at once when the build finished. This wires live build output through the whole stack. Backend (espidf_compiler.py) - New _run_with_streaming() helper. When a progress_callback is provided it spawns the subprocess via Popen + stdout/stderr drain threads and invokes the callback line-by-line. When None it falls back to the existing subprocess.run(capture_output=True) one-shot path so the unit-test code that doesn't care about live output is unaffected. - compile() and _compile_in_dir() take an optional ProgressCallback. - _run_cmake / _run_ninja closures now go through _run_with_streaming with that callback. cmake configure (~2-5 s) + ninja (~5-300+ s) both stream now; the ninja output is the one users actually want to watch. Backend (compile.py) - _compile_job seeds COMPILE_JOBS[id]['stdout_buffer'] = '' and defines on_progress_line(line) which appends to it. Buffer capped at 256 KB (tail kept) so a runaway build can't OOM the FastAPI process. - The buffer is preserved on both the success and the error path so late polls still see the log even after state transitions to done/error. - /compile/status now returns the buffer as a `stdout` field. CompileStatusResponse gains the field with default '' so old clients that don't read it still work. Frontend (compilation.ts) - compileCode() takes a 4th argument: optional CompileProgress callback fired every poll while state ∈ {pending, running}. Carries the cumulative stdout (caller computes deltas) plus elapsed seconds. - Surfaces the new `stdout` field of /compile/status and forwards it to the callback. Errors thrown from the callback are swallowed — a faulty UI hook must never break the polling loop. Frontend (EditorToolbar.tsx) - Both compileCode() call sites (Run and Compile-All) now pass an onProgress callback. It tracks `lastStreamedLen` per-compile, splits each new delta on newlines, and appends them as `info`-typed CompilationLog entries via setCompileLogs. The Compile-All flow prefixes each line with the board label so multi-board builds stay readable. - After the build settles, the existing parseCompileResult call still runs and appends the structured analysis on top of the live stream — that's where FAILED-block detection + the `error`-typed entries that drive the auto-switch-to-errors filter live. Net effect on the user complaint: cold ESP-IDF builds now show the ninja [N/1483] progress lines streaming into the console as they happen, instead of staring at an empty panel for 5-7 minutes. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 04:36:58 +07:00
})),
]);
},
{ boardOptions: board.boardOptions, spiffsFiles: board.spiffsFiles, libraries: board.libraries?.length ? board.libraries : null },
feat(compile): stream live ESP-IDF cmake + ninja output to the console A user reported on Discord: "the Velxio Console doesn't update anything, it just waits until the very end and displays everything in one go". True for the async compile path — /compile/status only carried `state` and the final `result`, so the editor's CompilationConsole stayed empty during the 5-7 minute cold ESP-IDF builds and dumped 1500 lines at once when the build finished. This wires live build output through the whole stack. Backend (espidf_compiler.py) - New _run_with_streaming() helper. When a progress_callback is provided it spawns the subprocess via Popen + stdout/stderr drain threads and invokes the callback line-by-line. When None it falls back to the existing subprocess.run(capture_output=True) one-shot path so the unit-test code that doesn't care about live output is unaffected. - compile() and _compile_in_dir() take an optional ProgressCallback. - _run_cmake / _run_ninja closures now go through _run_with_streaming with that callback. cmake configure (~2-5 s) + ninja (~5-300+ s) both stream now; the ninja output is the one users actually want to watch. Backend (compile.py) - _compile_job seeds COMPILE_JOBS[id]['stdout_buffer'] = '' and defines on_progress_line(line) which appends to it. Buffer capped at 256 KB (tail kept) so a runaway build can't OOM the FastAPI process. - The buffer is preserved on both the success and the error path so late polls still see the log even after state transitions to done/error. - /compile/status now returns the buffer as a `stdout` field. CompileStatusResponse gains the field with default '' so old clients that don't read it still work. Frontend (compilation.ts) - compileCode() takes a 4th argument: optional CompileProgress callback fired every poll while state ∈ {pending, running}. Carries the cumulative stdout (caller computes deltas) plus elapsed seconds. - Surfaces the new `stdout` field of /compile/status and forwards it to the callback. Errors thrown from the callback are swallowed — a faulty UI hook must never break the polling loop. Frontend (EditorToolbar.tsx) - Both compileCode() call sites (Run and Compile-All) now pass an onProgress callback. It tracks `lastStreamedLen` per-compile, splits each new delta on newlines, and appends them as `info`-typed CompilationLog entries via setCompileLogs. The Compile-All flow prefixes each line with the board label so multi-board builds stay readable. - After the build settles, the existing parseCompileResult call still runs and appends the structured analysis on top of the live stream — that's where FAILED-block detection + the `error`-typed entries that drive the auto-switch-to-errors filter live. Net effect on the user complaint: cold ESP-IDF builds now show the ninja [N/1483] progress lines streaming into the console as they happen, instead of staring at an empty panel for 5-7 minutes. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 04:36:58 +07:00
);
const resultLogs = parseCompileResult(result, label, boardTarget);
setCompileLogs((prev: CompilationLog[]) => [...prev, ...resultLogs]);
if (result.success) {
const program = result.hex_content ?? result.binary_content ?? null;
if (program) {
compileBoardProgram(board.id, program);
if (result.has_wifi !== undefined) {
updateBoard(board.id, { hasWifi: result.has_wifi });
}
}
ok++;
} else {
boardFailed++;
}
} catch (err) {
blog('error', err instanceof Error ? err.message : String(err));
boardFailed++;
}
}
const failed = boardFailed + chipFailed;
const chipOk = allCustomChips.length - chipFailed;
const doneParts = [];
if (boardsList.length)
doneParts.push(`${ok} board${ok === 1 ? '' : 's'} ok${boardFailed > 0 ? `, ${boardFailed} failed` : ''}`);
if (allCustomChips.length)
doneParts.push(`${chipOk} chip${chipOk === 1 ? '' : 's'} ok${chipFailed > 0 ? `, ${chipFailed} failed` : ''}`);
addLog({
timestamp: new Date(),
type: failed > 0 ? 'error' : 'success',
message: `Done — ${doneParts.join('; ')}`,
});
if (failed === 0) markCompiled();
setCompileAllRunning(false);
return { ok, failed };
};
const handleCompileAll = () => {
trackCompileCode();
void compileAllBoards();
};
/**
* Run All = compile every target (boards + chips) if needed, then start every
* one: boards via startBoard, chips via restartParts (re-attach with the
* fresh WASM/ROM) + resuming the electrical solver when there's no board.
* Mirrors single Run, generalised across all targets.
*/
const handleRunAll = async () => {
const sim = useSimulatorStore.getState();
const boardsList = sim.boards;
const chips = sim.components.filter((c) => c.metadataId === 'custom-chip');
if (boardsList.length === 0 && chips.length === 0) return;
// A chip needs compiling when it has no WASM yet, or it references a program
// file but hasn't been assembled to ROM.
const chipNeedsCompile = chips.some((c) => {
const p = c.properties as Record<string, unknown>;
const programFile = String(p?.programFile ?? '').trim();
return !String(p?.wasmBase64 ?? '') || (programFile && !String(p?.romBytes ?? ''));
});
const needsCompile =
codeChangedSinceLastCompile ||
chipNeedsCompile ||
boardsList.some(
(b) =>
feat(boards): expose Raspberry Pi 4 and Pi 5 in the picker (UI + pin wiring) The BoardKind type and the QEMU backend already supported raspberry-pi-4 (Cortex-A72) and raspberry-pi-5 (Cortex-A76) by reusing the Pi 3 arm64 image set, but the frontend had no way to actually select either: the board picker, the canvas renderer, the serial monitor, the oscilloscope channel list, and the editor toolbar all hard-coded "raspberry-pi-3" as the only Pi entry. ComponentRegistry even registered Pi 4 / Pi 5 metadata pointing at the velxio-raspberry-pi-3 custom-element tag — a placeholder that meant both boards rendered as a Pi 3 in the picker thumbnail and on the canvas. Add dedicated boards top-to-bottom: * `RaspberryPi4Element.ts` / `RaspberryPi5Element.ts` — Velxio-style schematic SVG (authored from scratch, not traced). Pi 4 is the green PCB with BCM2711 SoC, 4× USB-A, USB-C power, dual µHDMI; Pi 5 is the darker green PCB with BCM2712 + RP1 southbridge, 2.5 GbE, USB-C 5V/5A, PCIe FFC connector, dedicated power button. Both carry a small "velxio" mark in the corner. * `pi40PinHeader.ts` — shared `buildPi40PinHeader()` helper that returns the 40-pin BCM layout. Every Pi from the 1B+ onwards uses the same physical pin positions and same BCM GPIO assignment, so Pi 3 / Pi 4 / Pi 5 elements all consume this helper and example wires drawn against one model transfer to the others without re-routing. * React wrappers `RaspberryPi4.tsx` / `RaspberryPi5.tsx` render the custom elements at absolute positions (mirrors how RaspberryPi3.tsx handles the Pi 3 illustration). * Wire-up across the editor surface: - BoardOnCanvas: BOARD_SIZE entry + switch case. - BoardPickerModal: description, icon, kinds list. - ComponentPickerModal: thumbnails now instantiate the dedicated custom element (was velxio-raspberry-pi-3 fallback). - SerialMonitor / EditorToolbar: pill labels, icons, colours. - Oscilloscope: GPIO channel list (28 BCM pins). - SimulatorCanvas: remote-boards filter for run/stop sync. - SPICE boardPinGroups: same 5V / 3V3 / GND as Pi 3. - boardPinToNumber: accepts physical pin numbers ("1"-"40"), BCM names ("GPIO14") and power labels for any Pi 3/4/5 id. - ComponentRegistry: dedicated tagNames + per-board thumbnails (green for Pi 4, darker green for Pi 5). * EditorToolbar's Pi 3 special cases (Linux/Python compile path, Run/Stop routing) now use `isPiBoardKind()` so Pi 4 and Pi 5 inherit the same behaviour automatically, and any future Pi family member (Zero / 1 / 2) lands in the right code paths the moment its backend boots. QEMU backend was already wired (qemu_manager.py:71/82 + manifest entry 'raspberry-pi-3-virt' shared across arm64 Pis), so this commit makes both boards selectable end-to-end without any backend follow-up.
2026-05-23 09:46:59 +07:00
!isPiBoardKind(b.boardKind) &&
b.languageMode !== 'micropython' &&
!b.compiledProgram,
);
if (needsCompile) {
const { failed } = await compileAllBoards();
if (failed > 0) return; // a board failed — don't start anything
}
// Start every board (compiledProgram may have changed during compile).
const refreshed = useSimulatorStore.getState().boards;
for (const board of refreshed) {
if (board.running) continue;
if (isQemuBoardKind(board.boardKind) || board.compiledProgram || board.languageMode === 'micropython') {
trackRunSimulation(board.boardKind);
2026-04-26 05:46:52 +07:00
reportRun(board.boardKind);
startBoard(board.id);
}
}
// Run the chips: re-attach so they pick up the freshly compiled WASM/ROM.
// The chip tick gates on a running board, so when NO board actually started
// (board-less, or a board that compiled to nothing) resume the electrical
// solver instead, otherwise the chips would stay frozen.
if (chips.length > 0) {
useSimulatorStore.getState().restartParts();
const anyBoardRunning = useSimulatorStore.getState().boards.some((b) => b.running);
if (!anyBoardRunning) setElectricalPaused(false);
}
};
const handleExport = async () => {
try {
const {
components,
wires,
boardPosition,
boardType: legacyBoardType,
} = useSimulatorStore.getState();
const projectName =
files.find((f) => f.name.endsWith('.ino'))?.name.replace('.ino', '') || 'velxio-project';
await exportToWokwiZip(files, components, wires, legacyBoardType, projectName, boardPosition);
} catch (err) {
setMessage({ type: 'error', text: 'Export failed.' });
}
};
// Phase 3 D3.2 — Schematic screenshot. Pro-tier-gated by the backend.
// Same UX pattern as BOM export: everyone can click; 402 redirects to
// /pricing. The server-side headless chromium renders the canvas and
// returns a PNG, which we trigger a download for.
const handleExportScreenshot = async () => {
const projectId = currentProject?.id;
if (!projectId) {
setMessage({ type: 'error', text: 'Save the project before exporting an image.' });
return;
}
setMessage({ type: 'info', text: 'Rendering screenshot — may take 5-10 seconds…' });
try {
const resp = await fetch(`/api/pro/projects/${projectId}/screenshot.png`, {
credentials: 'include',
});
if (resp.status === 402) {
// Fire the in-place upgrade modal instead of bouncing to /pricing —
// keeps the user in the editor with full context. The pro overlay's
// UpgradeGate listens for this event and opens UpgradePromptModal.
window.dispatchEvent(new CustomEvent('velxio-pro-upgrade-prompt', {
detail: { componentName: 'Schematic screenshot export' },
}));
return;
}
if (resp.status === 401) {
window.location.href = `/login?redirect=${encodeURIComponent(window.location.pathname)}`;
return;
}
if (resp.status === 422) {
setMessage({ type: 'error', text: 'Add at least one component to export an image.' });
return;
}
if (!resp.ok) {
setMessage({ type: 'error', text: 'Screenshot export failed.' });
return;
}
const blob = await resp.blob();
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
const cd = resp.headers.get('Content-Disposition') || '';
const m = /filename="?([^"]+)"?/.exec(cd);
a.download = m ? m[1] : `velxio-${projectId}.png`;
document.body.appendChild(a);
a.click();
a.remove();
URL.revokeObjectURL(url);
setMessage({ type: 'success', text: 'Screenshot downloaded.' });
} catch {
setMessage({ type: 'error', text: 'Screenshot export failed.' });
}
};
// Phase 3 D3.1 — BOM export. Pro-tier-gated by the backend (402 if not pro).
// We let everyone click; the 402 response feeds the upgrade prompt below
// so free/maker users hit the funnel naturally instead of an obviously-
// locked button (which they'd just dismiss).
const handleExportBom = async () => {
const projectId = currentProject?.id;
if (!projectId) {
setMessage({ type: 'error', text: 'Save the project before exporting a BOM.' });
return;
}
try {
const resp = await fetch(`/api/pro/projects/${projectId}/bom.csv`, {
credentials: 'include',
});
if (resp.status === 402) {
// Fire the in-place upgrade modal instead of bouncing to /pricing —
// keeps the user in the editor with full context. The pro overlay's
// UpgradeGate listens for this event and opens UpgradePromptModal.
window.dispatchEvent(new CustomEvent('velxio-pro-upgrade-prompt', {
detail: { componentName: 'BOM export' },
}));
return;
}
if (resp.status === 401) {
window.location.href = `/login?redirect=${encodeURIComponent(window.location.pathname)}`;
return;
}
if (!resp.ok) {
setMessage({ type: 'error', text: 'BOM export failed.' });
return;
}
const blob = await resp.blob();
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
// Filename comes from Content-Disposition; pick a fallback.
const cd = resp.headers.get('Content-Disposition') || '';
const m = /filename="?([^"]+)"?/.exec(cd);
a.download = m ? m[1] : `bom-${projectId}.csv`;
document.body.appendChild(a);
a.click();
a.remove();
URL.revokeObjectURL(url);
} catch {
setMessage({ type: 'error', text: 'BOM export failed.' });
}
};
const handleFirmwareUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (firmwareInputRef.current) firmwareInputRef.current.value = '';
if (!file) return;
setConsoleOpen(true);
addLog({ timestamp: new Date(), type: 'info', message: `Loading firmware: ${file.name}...` });
try {
const boardKind = activeBoard?.boardKind;
if (!boardKind) {
setMessage({ type: 'error', text: 'No board selected' });
return;
}
const result = await readFirmwareFile(file, boardKind);
// Architecture mismatch warning for ELF files
if (result.elfInfo?.suggestedBoard && result.elfInfo.suggestedBoard !== boardKind) {
const detected = result.elfInfo.architectureName;
feat(editor): rename boards & custom chips; show which target owns each file Phase 2 of the run-system/UX work. - BoardInstance gains an optional user ; boardDisplayName(board) resolver (name || kind label) routes every INSTANCE-label surface: file-explorer section header, compile console (EditorToolbar), canvas selector/tooltip/ context-menu, Serial Monitor tabs, Oscilloscope board picker, Board Options subtitle. Board/component pickers keep the KIND label (they pick new boards). - Inline rename on board AND chip section headers (double-click the name, or a hover pencil button). Board -> updateBoard(id,{name}); chip -> chipName in properties. Enter commits, Escape cancels (cancel-flag ref guards the unmount-fires-onBlur footgun), empty clears to the kind / 'Custom Chip'. - FileTabs shows an owner badge naming the board/chip whose files are shown (resolved as a selector so it doesn't re-render on every sim pin toggle). - CustomChipDialog no longer clobbers a user-given chipName: chip.json's name only seeds the blank defaults (My Chip / Custom Chip); loading an example relabels explicitly. - Persistence: board name round-trips via projectPayload (+ dirty hash), vlxFile, ProjectByIdPage load + loadProjectState; chipName rides components_json. - Drive-by: fixed a pre-existing rules-of-hooks violation in BoardOptionsModal (early return before a useCallback). Reviewed by a 3-agent adversarial pass (completeness / persistence / correctness); all major findings folded in. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 11:22:25 +07:00
const current = activeBoard ? boardDisplayName(activeBoard) : boardKind;
addLog({
timestamp: new Date(),
type: 'info',
message: `Note: Detected ${detected} architecture, but current board is ${current}. Loading anyway.`,
});
}
if (activeBoardId) {
compileBoardProgram(activeBoardId, result.program);
markCompiled();
addLog({ timestamp: new Date(), type: 'info', message: result.message });
setMessage({ type: 'success', text: `Firmware loaded: ${file.name}` });
}
} catch (err) {
const errMsg = err instanceof Error ? err.message : 'Failed to load firmware';
addLog({ timestamp: new Date(), type: 'error', message: errMsg });
setMessage({ type: 'error', text: errMsg });
}
};
const handleImportFile = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!importInputRef.current) return;
importInputRef.current.value = '';
if (!file) return;
try {
const result = await importProjectFile(file);
if (result.kind === 'vlx') {
// importVlxFile already wrote into the stores.
setMessage({ type: 'success', text: `Imported ${file.name}` });
return;
}
// .zip path: apply the parsed payload to the stores ourselves, then
// surface any missing libraries via the existing install modal.
const { loadFiles } = useEditorStore.getState();
const { setComponents, setWires, setBoardType, setBoardPosition, stopSimulation } =
useSimulatorStore.getState();
stopSimulation();
if (result.boardType) setBoardType(result.boardType);
setBoardPosition(result.boardPosition);
setComponents(result.components);
setWires(result.wires);
if (result.files.length > 0) loadFiles(result.files);
setMessage({ type: 'success', text: `Imported ${file.name}` });
if (result.libraries.length > 0) {
setPendingLibraries(result.libraries);
setInstallModalOpen(true);
}
} catch (err: any) {
setMessage({ type: 'error', text: err?.message || 'Import failed.' });
}
};
return (
<>
<div className="editor-toolbar-wrapper" style={{ position: 'relative' }}>
<div className="editor-toolbar" ref={toolbarRef}>
feat(editor): view-mode toggle, agent-chat slot, toolbar polish Changes that ship to OSS — all benign for self-hosters, but most are extension points the velxio-prod overlay (and any private fork) needs to plug an in-editor AI chat into the page. Editor: - 3-way view-mode toggle (code / both / circuit) in the unified toolbar. Lets users hide a pane to give a right-docked sidebar (e.g. the AI chat overlay) more breathing room. Persisted in useEditorStore. - Default file explorer narrower (210 → 165 px); min 110. - Removed the redundant `tb-board-pill` (icon + "Editing: X" tooltip); the BoardSelector dropdown elsewhere already shows the active board. - Inlined Import/Export/Upload-firmware buttons; the 3-dot overflow menu gave up too much discoverability. Removed dead overflow state. Simulator: - Fix: global Delete/Backspace handler in SimulatorCanvas no longer fires when the event target is an INPUT/TEXTAREA/SELECT/contentEditable — affected any in-page text field, not just the chat overlay. Overlay extensibility: - New `data-velxio-slot="agent-chat"` at the bottom of EditorPage so pro overlays can portal a chat panel into the editor without forking the page. - vite.config.ts: preserveSymlinks=true when VITE_PRO_BUILD is set. Lets local-dev junctions (overlay tree → frontend/src/pro) resolve bare imports back to the OSS node_modules without resolving symlinks. Deps: - Added react-markdown + remark-gfm (rendered chat output) and @google/genai + zod (overlay agent loop). Tree-shaken from the OSS bundle when no pro code imports them. gitignore: - Ignore backend/app/pro/ and frontend/src/pro/ junctions used by developers running a private overlay against the OSS dev server. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 10:53:27 +07:00
{/* MicroPython language selector only when active board supports it.
The board context pill that used to live here was removed: it
duplicated the BoardSelector dropdown elsewhere in the toolbar. */}
{activeBoard && BOARD_SUPPORTS_MICROPYTHON.has(activeBoard.boardKind) && (
<select
className="tb-lang-select"
value={activeBoard.languageMode ?? 'arduino'}
onChange={(e) => {
if (activeBoardId)
setBoardLanguageMode(activeBoardId, e.target.value as LanguageMode);
}}
title={t('editor.toolbar.languageMode')}
feat(editor): view-mode toggle, agent-chat slot, toolbar polish Changes that ship to OSS — all benign for self-hosters, but most are extension points the velxio-prod overlay (and any private fork) needs to plug an in-editor AI chat into the page. Editor: - 3-way view-mode toggle (code / both / circuit) in the unified toolbar. Lets users hide a pane to give a right-docked sidebar (e.g. the AI chat overlay) more breathing room. Persisted in useEditorStore. - Default file explorer narrower (210 → 165 px); min 110. - Removed the redundant `tb-board-pill` (icon + "Editing: X" tooltip); the BoardSelector dropdown elsewhere already shows the active board. - Inlined Import/Export/Upload-firmware buttons; the 3-dot overflow menu gave up too much discoverability. Removed dead overflow state. Simulator: - Fix: global Delete/Backspace handler in SimulatorCanvas no longer fires when the event target is an INPUT/TEXTAREA/SELECT/contentEditable — affected any in-page text field, not just the chat overlay. Overlay extensibility: - New `data-velxio-slot="agent-chat"` at the bottom of EditorPage so pro overlays can portal a chat panel into the editor without forking the page. - vite.config.ts: preserveSymlinks=true when VITE_PRO_BUILD is set. Lets local-dev junctions (overlay tree → frontend/src/pro) resolve bare imports back to the OSS node_modules without resolving symlinks. Deps: - Added react-markdown + remark-gfm (rendered chat output) and @google/genai + zod (overlay agent loop). Tree-shaken from the OSS bundle when no pro code imports them. gitignore: - Ignore backend/app/pro/ and frontend/src/pro/ junctions used by developers running a private overlay against the OSS dev server. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 10:53:27 +07:00
style={{
background: '#2d2d2d',
color: '#ccc',
border: '1px solid #444',
borderRadius: 4,
padding: '2px 4px',
fontSize: 11,
cursor: 'pointer',
outline: 'none',
marginRight: 4,
}}
>
<option value="arduino">Arduino C++</option>
<option value="micropython">MicroPython</option>
</select>
)}
<div className="toolbar-group">
{/* Compile */}
<button
onClick={handleCompile}
disabled={compiling || !activeBoard}
className="tb-btn tb-btn-compile"
title={
!activeBoard
? t('editor.toolbar.compile.addBoard')
: compiling
? t('editor.toolbar.compile.loading')
: activeBoard?.languageMode === 'micropython'
? t('editor.toolbar.compile.loadMicropython')
: t('editor.toolbar.compile.compile')
}
>
{compiling ? (
<svg
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className="spin"
>
<path d="M21 12a9 9 0 1 1-6.219-8.56" />
</svg>
) : (
<svg
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z" />
</svg>
)}
</button>
<div className="tb-divider" />
{/* Run */}
<button
onClick={handleRun}
disabled={
isBoardless
? digitalRunning
: running || compiling || !activeBoard
}
className="tb-btn tb-btn-run"
title={
isBoardless
? digitalRunning
? 'Digital simulation running'
: 'Resume digital simulation'
: !activeBoard
? t('editor.toolbar.run.addBoard')
: activeBoard?.languageMode === 'micropython'
? t('editor.toolbar.run.runMicropython')
: t('editor.toolbar.run.run')
}
>
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor" stroke="none">
<polygon points="5,3 19,12 5,21" />
</svg>
</button>
{/* Stop */}
<button
onClick={handleStop}
disabled={isBoardless ? !digitalRunning : !anyBoardRunning}
className="tb-btn tb-btn-stop"
title={isBoardless ? 'Freeze digital simulation' : t('editor.toolbar.stop')}
>
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor" stroke="none">
<rect x="3" y="3" width="18" height="18" rx="2" />
</svg>
</button>
{/* Reset */}
<button
onClick={handleReset}
disabled={!compiledHex && !activeBoard?.compiledProgram}
className="tb-btn tb-btn-reset"
title={t('editor.toolbar.reset')}
>
<svg
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8" />
<path d="M3 3v5h5" />
</svg>
</button>
{targetCount > 1 && (
<>
<div className="tb-divider" />
{/* Compile All — boards + programmable chips */}
<button
onClick={handleCompileAll}
disabled={compileAllRunning}
className="tb-btn tb-btn-compile-all"
title={t('editor.toolbar.compileAll')}
>
<svg
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z" />
<path d="M6 20h4M14 4l4 4" strokeDasharray="2 2" />
</svg>
</button>
{/* Run All */}
<button
onClick={handleRunAll}
disabled={compileAllRunning || anyBoardRunning || digitalRunning}
className="tb-btn tb-btn-run-all"
title={t('editor.toolbar.runAll')}
>
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor" stroke="none">
<polygon points="3,3 11,12 3,21" />
<polygon points="13,3 21,12 13,21" />
</svg>
</button>
</>
)}
</div>
{/* Center slot — file tabs share the row so action icons stay pinned. */}
{centerSlot && <div className="toolbar-center-slot">{centerSlot}</div>}
<div className="toolbar-group toolbar-group-right">
{/* Hidden file input for project import. Accepts both .vlx
(Velxio native) and .zip (Wokwi bundle); the dispatcher in
utils/importProject.ts picks the right loader by extension. */}
<input
ref={importInputRef}
type="file"
accept={PROJECT_FILE_ACCEPT}
style={{ display: 'none' }}
onChange={handleImportFile}
/>
{/* Hidden file input for firmware upload */}
<input
ref={firmwareInputRef}
type="file"
accept=".hex,.bin,.elf,.ihex"
style={{ display: 'none' }}
onChange={handleFirmwareUpload}
/>
{/* Library Manager — always visible with label */}
<button
onClick={() => {
trackOpenLibraryManager();
setLibManagerOpen(true);
}}
className="tb-btn-libraries"
title={t('editor.toolbar.libraries.title')}
>
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M21 8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16Z" />
<path d="m3.3 7 8.7 5 8.7-5" />
<path d="M12 22V12" />
</svg>
<span className="tb-libraries-label">{t('editor.toolbar.libraries.label')}</span>
</button>
{/* Import zip inline by default; container query at narrow
widths swaps this for the corresponding overflow-menu item. */}
feat(editor): view-mode toggle, agent-chat slot, toolbar polish Changes that ship to OSS — all benign for self-hosters, but most are extension points the velxio-prod overlay (and any private fork) needs to plug an in-editor AI chat into the page. Editor: - 3-way view-mode toggle (code / both / circuit) in the unified toolbar. Lets users hide a pane to give a right-docked sidebar (e.g. the AI chat overlay) more breathing room. Persisted in useEditorStore. - Default file explorer narrower (210 → 165 px); min 110. - Removed the redundant `tb-board-pill` (icon + "Editing: X" tooltip); the BoardSelector dropdown elsewhere already shows the active board. - Inlined Import/Export/Upload-firmware buttons; the 3-dot overflow menu gave up too much discoverability. Removed dead overflow state. Simulator: - Fix: global Delete/Backspace handler in SimulatorCanvas no longer fires when the event target is an INPUT/TEXTAREA/SELECT/contentEditable — affected any in-page text field, not just the chat overlay. Overlay extensibility: - New `data-velxio-slot="agent-chat"` at the bottom of EditorPage so pro overlays can portal a chat panel into the editor without forking the page. - vite.config.ts: preserveSymlinks=true when VITE_PRO_BUILD is set. Lets local-dev junctions (overlay tree → frontend/src/pro) resolve bare imports back to the OSS node_modules without resolving symlinks. Deps: - Added react-markdown + remark-gfm (rendered chat output) and @google/genai + zod (overlay agent loop). Tree-shaken from the OSS bundle when no pro code imports them. gitignore: - Ignore backend/app/pro/ and frontend/src/pro/ junctions used by developers running a private overlay against the OSS dev server. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 10:53:27 +07:00
<button
onClick={() => importInputRef.current?.click()}
className="tb-btn tb-btn-import-inline"
title={t('editor.toolbar.import')}
feat(editor): view-mode toggle, agent-chat slot, toolbar polish Changes that ship to OSS — all benign for self-hosters, but most are extension points the velxio-prod overlay (and any private fork) needs to plug an in-editor AI chat into the page. Editor: - 3-way view-mode toggle (code / both / circuit) in the unified toolbar. Lets users hide a pane to give a right-docked sidebar (e.g. the AI chat overlay) more breathing room. Persisted in useEditorStore. - Default file explorer narrower (210 → 165 px); min 110. - Removed the redundant `tb-board-pill` (icon + "Editing: X" tooltip); the BoardSelector dropdown elsewhere already shows the active board. - Inlined Import/Export/Upload-firmware buttons; the 3-dot overflow menu gave up too much discoverability. Removed dead overflow state. Simulator: - Fix: global Delete/Backspace handler in SimulatorCanvas no longer fires when the event target is an INPUT/TEXTAREA/SELECT/contentEditable — affected any in-page text field, not just the chat overlay. Overlay extensibility: - New `data-velxio-slot="agent-chat"` at the bottom of EditorPage so pro overlays can portal a chat panel into the editor without forking the page. - vite.config.ts: preserveSymlinks=true when VITE_PRO_BUILD is set. Lets local-dev junctions (overlay tree → frontend/src/pro) resolve bare imports back to the OSS node_modules without resolving symlinks. Deps: - Added react-markdown + remark-gfm (rendered chat output) and @google/genai + zod (overlay agent loop). Tree-shaken from the OSS bundle when no pro code imports them. gitignore: - Ignore backend/app/pro/ and frontend/src/pro/ junctions used by developers running a private overlay against the OSS dev server. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 10:53:27 +07:00
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
<polyline points="7 10 12 15 17 10" />
<line x1="12" y1="15" x2="12" y2="3" />
</svg>
</button>
<button
onClick={() => handleExport()}
className="tb-btn tb-btn-export-inline"
title={t('editor.toolbar.export')}
feat(editor): view-mode toggle, agent-chat slot, toolbar polish Changes that ship to OSS — all benign for self-hosters, but most are extension points the velxio-prod overlay (and any private fork) needs to plug an in-editor AI chat into the page. Editor: - 3-way view-mode toggle (code / both / circuit) in the unified toolbar. Lets users hide a pane to give a right-docked sidebar (e.g. the AI chat overlay) more breathing room. Persisted in useEditorStore. - Default file explorer narrower (210 → 165 px); min 110. - Removed the redundant `tb-board-pill` (icon + "Editing: X" tooltip); the BoardSelector dropdown elsewhere already shows the active board. - Inlined Import/Export/Upload-firmware buttons; the 3-dot overflow menu gave up too much discoverability. Removed dead overflow state. Simulator: - Fix: global Delete/Backspace handler in SimulatorCanvas no longer fires when the event target is an INPUT/TEXTAREA/SELECT/contentEditable — affected any in-page text field, not just the chat overlay. Overlay extensibility: - New `data-velxio-slot="agent-chat"` at the bottom of EditorPage so pro overlays can portal a chat panel into the editor without forking the page. - vite.config.ts: preserveSymlinks=true when VITE_PRO_BUILD is set. Lets local-dev junctions (overlay tree → frontend/src/pro) resolve bare imports back to the OSS node_modules without resolving symlinks. Deps: - Added react-markdown + remark-gfm (rendered chat output) and @google/genai + zod (overlay agent loop). Tree-shaken from the OSS bundle when no pro code imports them. gitignore: - Ignore backend/app/pro/ and frontend/src/pro/ junctions used by developers running a private overlay against the OSS dev server. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 10:53:27 +07:00
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
<polyline points="17 8 12 3 7 8" />
<line x1="12" y1="3" x2="12" y2="15" />
</svg>
</button>
{/* Overflow "More" menu collects the secondary actions
(BOM, Schematic image, Upload firmware) so the toolbar no
longer overflows on narrow widths. The two Pro items show
a small "PRO" pill in the menu so users know they're
premium BEFORE clicking, instead of being surprised by an
upgrade prompt. */}
<div className="tb-overflow-wrap" ref={moreMenuRef}>
<button
onClick={() => setMoreMenuOpen((v) => !v)}
className={`tb-btn tb-btn-overflow${moreMenuOpen ? ' tb-btn-overflow-active' : ''}`}
title={t('editor.toolbar.more', 'More')}
aria-haspopup="true"
aria-expanded={moreMenuOpen}
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
<circle cx="5" cy="12" r="1.8" />
<circle cx="12" cy="12" r="1.8" />
<circle cx="19" cy="12" r="1.8" />
</svg>
</button>
{moreMenuOpen && (
<div className="tb-overflow-menu" role="menu">
{/* Responsive items hidden by default, shown via
container query when the toolbar is too narrow to
keep their inline twins. Keeps mobile users from
losing access to Import / Export entirely. */}
<button
className="tb-overflow-item tb-overflow-import"
role="menuitem"
onClick={() => {
setMoreMenuOpen(false);
importInputRef.current?.click();
}}
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
<polyline points="7 10 12 15 17 10" />
<line x1="12" y1="15" x2="12" y2="3" />
</svg>
<span className="tb-overflow-label">{t('editor.toolbar.importLabel', 'Import project')}</span>
</button>
<button
className="tb-overflow-item tb-overflow-export"
role="menuitem"
onClick={() => {
setMoreMenuOpen(false);
handleExport();
}}
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
<polyline points="17 8 12 3 7 8" />
<line x1="12" y1="3" x2="12" y2="15" />
</svg>
<span className="tb-overflow-label">{t('editor.toolbar.exportLabel', 'Export project (.zip)')}</span>
</button>
<button
className="tb-overflow-item"
role="menuitem"
onClick={() => {
setMoreMenuOpen(false);
handleExportBom();
}}
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<rect x="3" y="4" width="18" height="16" rx="2" />
<line x1="3" y1="10" x2="21" y2="10" />
<line x1="9" y1="4" x2="9" y2="20" />
</svg>
<span className="tb-overflow-label">{t('editor.toolbar.exportBomLabel', 'Bill of Materials (CSV)')}</span>
<span className="tb-overflow-pro">PRO</span>
</button>
<button
className="tb-overflow-item"
role="menuitem"
onClick={() => {
setMoreMenuOpen(false);
handleExportScreenshot();
}}
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M23 19a2 2 0 0 1-2 2H3a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h4l2-3h6l2 3h4a2 2 0 0 1 2 2z" />
<circle cx="12" cy="13" r="4" />
</svg>
<span className="tb-overflow-label">{t('editor.toolbar.exportScreenshotLabel', 'Schematic image (PNG)')}</span>
<span className="tb-overflow-pro">PRO</span>
</button>
<button
className="tb-overflow-item"
role="menuitem"
onClick={() => {
setMoreMenuOpen(false);
firmwareInputRef.current?.click();
}}
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z" />
<line x1="12" y1="15" x2="12" y2="22" />
<polyline points="8 18 12 22 16 18" />
</svg>
<span className="tb-overflow-label">{t('editor.toolbar.uploadFirmwareLabel', 'Upload firmware')}</span>
</button>
{/* Sync to GitHub Pro feature. Fires a window event the
pro overlay listens for; if no overlay is loaded (OSS
build) the click is a silent no-op which is fine
OSS users can't have linked repos anyway. */}
<button
className="tb-overflow-item"
role="menuitem"
onClick={() => {
setMoreMenuOpen(false);
window.dispatchEvent(new CustomEvent('velxio-pro-github-sync-prompt', {
detail: { projectId: currentProject?.id ?? null },
}));
}}
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
<path d="M12 0C5.37 0 0 5.37 0 12c0 5.3 3.44 9.8 8.21 11.39.6.11.82-.26.82-.58 0-.29-.01-1.05-.02-2.06-3.34.72-4.04-1.61-4.04-1.61-.55-1.38-1.33-1.75-1.33-1.75-1.09-.74.08-.72.08-.72 1.2.08 1.84 1.24 1.84 1.24 1.07 1.84 2.81 1.31 3.5 1 .11-.78.42-1.31.76-1.62-2.66-.3-5.47-1.33-5.47-5.93 0-1.31.47-2.38 1.24-3.22-.12-.3-.54-1.52.11-3.18 0 0 1.01-.32 3.3 1.23A11.5 11.5 0 0 1 12 5.8c1.02.01 2.05.14 3.01.4 2.29-1.55 3.3-1.23 3.3-1.23.65 1.66.24 2.88.12 3.18.77.84 1.24 1.91 1.24 3.22 0 4.61-2.81 5.62-5.49 5.92.43.37.82 1.1.82 2.22 0 1.6-.02 2.89-.02 3.29 0 .32.22.7.83.58A12 12 0 0 0 24 12c0-6.63-5.37-12-12-12z" />
</svg>
<span className="tb-overflow-label">{t('editor.toolbar.githubSyncLabel', 'Sync to GitHub')}</span>
<span className="tb-overflow-pro">PRO</span>
</button>
{/* Share / Embed free for all users with a public project.
Watermark removal on the embed is the Pro perk; the
Share modal itself is open to everyone so they can
copy the link / iframe snippet. */}
<button
className="tb-overflow-item"
role="menuitem"
onClick={() => {
setMoreMenuOpen(false);
window.dispatchEvent(new CustomEvent('velxio-pro-share-prompt', {
detail: { projectId: currentProject?.id ?? null },
}));
}}
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<circle cx="18" cy="5" r="3" />
<circle cx="6" cy="12" r="3" />
<circle cx="18" cy="19" r="3" />
<line x1="8.59" y1="13.51" x2="15.42" y2="17.49" />
<line x1="15.41" y1="6.51" x2="8.59" y2="10.49" />
</svg>
<span className="tb-overflow-label">{t('editor.toolbar.shareLabel', 'Share / Embed')}</span>
</button>
{/* Record simulation Pro feature. Dispatches a toggle the
pro overlay handles (plan check, board-type check,
start/stop the recorder). OSS build no listener
silent no-op. */}
<button
className="tb-overflow-item"
role="menuitem"
onClick={() => {
setMoreMenuOpen(false);
window.dispatchEvent(new CustomEvent('velxio-pro-replay-record-toggle', {
detail: { projectId: currentProject?.id ?? null },
}));
}}
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
<circle cx="12" cy="12" r="7" />
</svg>
<span className="tb-overflow-label">{t('editor.toolbar.recordLabel', 'Record simulation')}</span>
<span className="tb-overflow-pro">PRO</span>
</button>
</div>
)}
</div>
<div className="tb-divider" />
{/* Output Console toggle */}
<button
onClick={() => setConsoleOpen((v) => !v)}
className={`tb-btn tb-btn-output${consoleOpen ? ' tb-btn-output-active' : ''}`}
title={t('editor.toolbar.toggleConsole')}
>
<svg
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<polyline points="4 17 10 11 4 5" />
<line x1="12" y1="19" x2="20" y2="19" />
</svg>
</button>
{rightSlot}
</div>
</div>
</div>
{/* Error detail bar */}
{message?.type === 'error' && message.text.length > 40 && !consoleOpen && (
<div className="toolbar-error-detail">{message.text}</div>
)}
{/* Missing library hint */}
{missingLibHint && (
<div className="tb-lib-hint">
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<circle cx="12" cy="12" r="10" />
<line x1="12" y1="8" x2="12" y2="12" />
<line x1="12" y1="16" x2="12.01" y2="16" />
</svg>
<span>{t('editor.toolbar.libHint.message')}</span>
<button
className="tb-lib-hint-btn"
onClick={() => {
trackOpenLibraryManager();
setLibManagerOpen(true);
setMissingLibHint(false);
}}
>
{t('editor.toolbar.libHint.cta')}
</button>
<button
className="tb-lib-hint-close"
onClick={() => setMissingLibHint(false)}
title={t('editor.toolbar.libHint.dismiss')}
>
&times;
</button>
</div>
)}
<LibraryManagerModal isOpen={libManagerOpen} onClose={() => setLibManagerOpen(false)} />
<InstallLibrariesModal
isOpen={installModalOpen}
onClose={() => setInstallModalOpen(false)}
libraries={pendingLibraries}
/>
{verification && (
<CircuitVerificationModal
result={verification}
onCancel={() => {
pendingRunRef.current = null;
setVerification(null);
}}
onRunAnyway={() => {
const resume = pendingRunRef.current;
pendingRunRef.current = null;
setVerification(null);
resume?.();
}}
/>
)}
</>
);
};