import { create } from 'zustand'; import { AVRSimulator } from '../simulation/AVRSimulator'; import { RP2040Simulator } from '../simulation/RP2040Simulator'; import { Cyw43Bridge } from '../simulation/cyw43'; import { RiscVSimulator } from '../simulation/RiscVSimulator'; import { Esp32C3Simulator } from '../simulation/Esp32C3Simulator'; import { PinManager } from '../simulation/PinManager'; import { SignalRouter } from '../simulation/SignalRouter'; import { ledcSignalForChannel } from '../simulation/esp32-signals'; import { VirtualDS1307, VirtualTempSensor, I2CMemoryDevice, I2CBusManager, nullI2CMaster, } from '../simulation/I2CBusManager'; import type { I2CDevice } from '../simulation/I2CBusManager'; import type { RP2040I2CDevice } from '../simulation/RP2040Simulator'; import type { Wire, WireInProgress, WireEndpoint } from '../types/wire'; import type { BoardKind, BoardInstance, LanguageMode } from '../types/board'; import { BOARD_SUPPORTS_MICROPYTHON } from '../types/board'; import { calculatePinPosition } from '../utils/pinPositionCalculator'; import { useOscilloscopeStore } from './useOscilloscopeStore'; import { RaspberryPi3Bridge } from '../simulation/RaspberryPi3Bridge'; import { Esp32Bridge } from '../simulation/Esp32Bridge'; import { useEditorStore } from './useEditorStore'; import { useVfsStore } from './useVfsStore'; import { boardPinToNumber, isBoardComponent } from '../utils/boardPinMapping'; import { autoWireColor, DEFAULT_WIRE_COLOR } from '../utils/wireUtils'; import { createSerialBatcher } from './serialBatcher'; import { bindBoard as icBindBoard, unbindBoard as icUnbindBoard, updateWires as icUpdateWires, setInterconnectRuntime, } from '../simulation/Interconnect'; // ── Sensor pre-registration ────────────────────────────────────────────────── // Maps component metadataId → { sensorType, dataPinName, propertyKeys } // Used to pre-register sensors in the start_esp32 payload so the QEMU worker // has them ready before the firmware starts executing (prevents race conditions). const SENSOR_COMPONENT_MAP: Record< string, { sensorType: string; dataPinName: string; propertyKeys: string[]; extraPins?: Record; // extra pin mappings: prop name → component pin name } > = { dht22: { sensorType: 'dht22', dataPinName: 'SDA', propertyKeys: ['temperature', 'humidity'] }, 'hc-sr04': { sensorType: 'hc-sr04', dataPinName: 'TRIG', propertyKeys: ['distance'], extraPins: { echo_pin: 'ECHO' }, }, }; // ── I2C sensor pre-registration ─────────────────────────────────────────────── // I2C sensors use virtual pins (200 + i2c_addr) instead of real GPIO pins. // They are identified by I2C address and do not need wire-resolution. // `addrProp` is the component property that overrides the default address. const I2C_SENSOR_MAP: Record< string, { sensorType: string; defaultAddr: number; addrProp?: string; // property key that holds the I2C address (e.g. 'address') addrIsBool?: boolean; // true when addrProp is a boolean flag (e.g. AD0 → 0x68/0x69) addrBoolHigh?: number; // address when the boolean flag is truthy propertyKeys?: string[]; // additional sensor values to forward (e.g. temperature, pressure) } > = { mpu6050: { sensorType: 'mpu6050', defaultAddr: 0x68, addrProp: 'ad0', addrIsBool: true, addrBoolHigh: 0x69, }, bmp280: { sensorType: 'bmp280', defaultAddr: 0x76, addrProp: 'address', propertyKeys: ['temperature', 'pressure'], }, ds1307: { sensorType: 'ds1307', defaultAddr: 0x68 }, ds3231: { sensorType: 'ds3231', defaultAddr: 0x68, propertyKeys: ['temperature'] }, ssd1306: { sensorType: 'ssd1306', defaultAddr: 0x3c }, pcf8574: { sensorType: 'pcf8574', defaultAddr: 0x27, addrProp: 'i2cAddress' }, }; // ── Legacy type aliases (keep external consumers working) ────────────────── export type BoardType = 'arduino-uno' | 'arduino-nano' | 'arduino-mega' | 'raspberry-pi-pico'; export const BOARD_FQBN: Record = { 'arduino-uno': 'arduino:avr:uno', 'arduino-nano': 'arduino:avr:nano:cpu=atmega328', 'arduino-mega': 'arduino:avr:mega', 'raspberry-pi-pico': 'rp2040:rp2040:rpipico', }; export const BOARD_LABELS: Record = { 'arduino-uno': 'Arduino Uno', 'arduino-nano': 'Arduino Nano', 'arduino-mega': 'Arduino Mega 2560', 'raspberry-pi-pico': 'Raspberry Pi Pico', }; export const DEFAULT_BOARD_POSITION = { x: 50, y: 50 }; export const ARDUINO_POSITION = DEFAULT_BOARD_POSITION; // ── Lightweight shim wrapping Esp32Bridge so component simulations (DHT22, etc.) // can call setPinState / pinManager just like they would on a local simulator. ── class Esp32BridgeShim { pinManager: PinManager; onSerialData: ((ch: string) => void) | null = null; onPinChangeWithTime: ((pin: number, state: boolean, timeMs: number) => void) | null = null; onBaudRateChange: ((baud: number) => void) | null = null; private bridge: Esp32Bridge; /** * Cross-board I2C surface — see AVRSimulator / RP2040Simulator for * the canonical pattern. ESP32 sketches run in backend QEMU, so the * "primary" I2C path goes through the backend's libqemu-xtensa I2C * slaves and reaches the frontend as `i2c_event` / `i2c_transaction` * WebSocket messages. But virtual devices attached to the ESP32 * board on the canvas also live frontend-side as I2CDevice instances * — and Interconnect's bridge mechanism needs to reach them when a * peer board's master tries to read across an SDA+SCL wire. So we * expose an I2CBusManager whose local devices mirror what * ProtocolParts registers via `registerSensor`. The peer-master * direction works through this bus; the ESP32-master direction * still flows through the backend (where the firmware runs). */ private i2cBusInstance: I2CBusManager; constructor(bridge: Esp32Bridge, pm: PinManager) { this.bridge = bridge; this.pinManager = pm; this.i2cBusInstance = new I2CBusManager(nullI2CMaster()); // Wire the write-forwarding path: when the backend ProxySlave emits // a completed write transaction (one full STOP-bounded master phase // from the ESP32 firmware), look up the peer device on the local // device lookup map and replay the bytes through its writeByte() // contract. Peer `I2CDevice` implementations (I2CMemoryDevice, // VirtualPCF8574, VirtualSSD1306, …) already encode the // pointer-byte + data semantics; we just hand off the sequence. bridge.onProxyI2cComplete = (addr: number, data: number[]) => { const dev = this._peerDeviceLookup.get(addr); if (!dev) return; try { for (const b of data) dev.writeByte(b); dev.stop?.(); } catch (e) { console.warn( `[Esp32BridgeShim] proxy write replay failed for 0x${addr.toString(16)}`, e, ); } }; } setPinState(pin: number, state: boolean): void { this.bridge.sendPinEvent(pin, state); } getCurrentCycles(): number { return -1; } getClockHz(): number { return 240_000_000; } isRunning(): boolean { return this.bridge.connected; } serialWrite(text: string): void { this.bridge.sendSerialBytes(Array.from(new TextEncoder().encode(text))); } // eslint-disable-next-line @typescript-eslint/no-explicit-any getADC(): any { return null; } /** * Set ADC value for an ESP32 GPIO pin. * ESP32 ADC1: GPIO 36-39 → CH0-3, GPIO 32-35 → CH4-7 * Returns true if the pin is a valid ADC pin. */ setAdcVoltage(pin: number, voltage: number): boolean { let channel = -1; if (pin >= 36 && pin <= 39) channel = pin - 36; // GPIO 36→CH0, 37→CH1, 38→CH2, 39→CH3 else if (pin >= 32 && pin <= 35) channel = pin - 28; // GPIO 32→CH4, 33→CH5, 34→CH6, 35→CH7 if (channel < 0) return false; const millivolts = Math.round(voltage * 1000); this.bridge.setAdc(channel, millivolts); return true; } /** * Push a 12-bit waveform LUT to QEMU for per-read ADC interpolation. * Call once per SPICE `.tran` solve; QEMU interpolates at every MMIO * read against its virtual clock. See `Esp32Bridge.setAdcWaveform`. * * `pin` follows the same GPIO→channel mapping as `setAdcVoltage`. * `samples` are 12-bit raw values (0-4095) aligned on a uniform grid. */ setAdcWaveform(pin: number, samples: Uint16Array, periodNs: number): boolean { let channel = -1; if (pin >= 36 && pin <= 39) channel = pin - 36; else if (pin >= 32 && pin <= 35) channel = pin - 28; if (channel < 0) return false; this.bridge.setAdcWaveform(channel, samples, periodNs); return true; } getMCU(): null { return null; } start(): void { /* managed by bridge */ } stop(): void { /* managed by bridge */ } reset(): void { /* managed by bridge */ } setSpeed(_s: number): void { /* no-op */ } getSpeed(): number { return 1; } loadHex(_hex: string): void { /* no-op */ } loadBinary(_b64: string): void { /* no-op */ } // ── Generic sensor registration (board-agnostic API) ────────────────────── // ESP32 delegates sensor protocols to the backend QEMU. registerSensor(type: string, pin: number, properties: Record): boolean { this.bridge.sendSensorAttach(type, pin, properties); return true; // backend handles the protocol } /** * Expose the underlying Esp32Bridge so simulation parts can subscribe to * board-specific WS events (e.g. `onEpaperUpdate` for the ePaper backend * rendering path). Hooks should restore any handler they overwrite. */ getBridge(): Esp32Bridge { return this.bridge; } /** * Generic SPI bus adapter — same shape as AVRSimulator.spi so SPI-driven * parts (ILI9341, SD cards, custom chips…) can hook the bus without * caring whether they're on AVR, RP2040, or any of the ESP32 variants. * The MOSI byte arrives via the QEMU worker's spi_event WS message * (decoded in Esp32Bridge); MISO is driven by the worker's * `_spi_response` global, so `completeTransfer` is a no-op on ESP32. * * Lazy-initialised so the bridge subscription only happens once a part * actually accesses `.spi`. */ private _spiAdapter: { onByte: ((mosi: number) => void) | null; completeTransfer: (miso: number) => void } | null = null; get spi(): { onByte: ((mosi: number) => void) | null; completeTransfer: (miso: number) => void } { if (!this._spiAdapter) { const adapter = { onByte: null as ((mosi: number) => void) | null, completeTransfer: (_miso: number) => { /* ESP32 worker drives MISO via _spi_response — no-op here. */ }, }; // Forward every per-byte WS event into whichever handler the part // installed. Single-listener channel — last writer wins. this.bridge.onSpiByte = (mosi: number) => { adapter.onByte?.(mosi); }; this._spiAdapter = adapter; } return this._spiAdapter; } updateSensor(pin: number, properties: Record): void { this.bridge.sendSensorUpdate(pin, properties); } unregisterSensor(pin: number): void { this.bridge.sendSensorDetach(pin); } // ── I2C write-only device relay (SSD1306, PCF8574) ─────────────────────── private _i2cTransactionListeners = new Map void>(); addI2CTransactionListener(addr: number, fn: (data: number[]) => void): void { this._i2cTransactionListeners.set(addr, fn); this.bridge.onI2cTransaction = (a: number, data: number[]) => { this._i2cTransactionListeners.get(a)?.(data); }; } removeI2CTransactionListener(addr: number): void { this._i2cTransactionListeners.delete(addr); if (this._i2cTransactionListeners.size === 0) { this.bridge.onI2cTransaction = null; } } // ── Cross-board I2C bus surface ───────────────────────────────────────── /** * Expose the I2CBusManager so Interconnect can install cross-board * bridges and ProtocolParts can register frontend-side virtual * devices. ESP32 has 2 hardware I2C buses but we collapse them * onto a single front-end bus for now — the bus index is ignored. * Splitting per-bus would require teaching the backend to tag * `i2c_event` payloads with the originating bus number, which * the lib worker already does (`bus` field) but the frontend * shim doesn't yet route on. */ getI2CBus(_bus: 0 | 1 = 0): I2CBusManager { return this.i2cBusInstance; } /** * Register a frontend-side virtual I2C device. This mirrors the * backend's QEMU-side slave (kept in sync via `registerSensor` / * `updateSensor`) so peer boards reading across the I2C bridge * find the device. ProtocolParts calls this on the ESP32 path * alongside the existing `registerSensor` + `addI2CTransactionListener`. */ addI2CDevice(device: I2CDevice, _bus: 0 | 1 = 0): void { this.i2cBusInstance.addDevice(device); } /** Remove a previously-registered virtual device. */ removeI2CDevice(addr: number, _bus: 0 | 1 = 0): void { this.i2cBusInstance.removeDevice(addr); } /** * Push register snapshots of a peer board's I2C devices into a * backend `ProxySlave` per address. Called by Interconnect after a * cross-board I2C bridge is installed so the ESP32 firmware's Wire * master reads can find the peer's devices inside QEMU. * * Walks the peer bus AND its transitive bridges (BFS). Each device * found at any reachable hop gets a ProxySlave on the backend. All * addresses discovered through `peerBus` are tracked under that key, * so `clearProxiesForPeer(peerBus)` cleans up exactly what this call * installed without disturbing proxies from concurrent bridges * (e.g. when another wire pair also connects to this same ESP32). * * Devices that don't expose `dumpRegisters` (PCF8574, SSD1306, * LCD-I2C) are skipped — they receive state through the * write-forwarding path (proxy_i2c_complete event from the backend * ProxySlave) instead. */ syncProxyFromPeer(peerBus: I2CBusManager): void { const ownedAddrs = this._proxiedByPeer.get(peerBus) ?? new Set(); // BFS over the peer's bridge graph. Skip our own bus so we don't // mirror ourselves back via the return edge. const visited = new Set([this.i2cBusInstance, peerBus]); const queue: I2CBusManager[] = [peerBus]; while (queue.length > 0) { const bus = queue.shift()!; if (typeof bus.listDevices === 'function') { for (const device of bus.listDevices()) { // Track the live device reference for write-forwarding and // periodic resync. Last writer wins on address collisions // (rare; the user wired two devices to the same address). this._peerDeviceLookup.set(device.address, device); if (typeof device.dumpRegisters !== 'function') continue; try { const regs = device.dumpRegisters(); this.bridge.registerProxyI2c(device.address, regs); ownedAddrs.add(device.address); // Prime the resync hash so the first tick doesn't push a // redundant identical dump. this._lastDumpHash.set( device.address, Esp32BridgeShim._hashRegs(regs), ); } catch (e) { console.warn( `[Esp32BridgeShim] syncProxyFromPeer dump failed for 0x${device.address.toString(16)}`, e, ); } } } if (typeof bus.getBridges === 'function') { for (const next of bus.getBridges()) { if (visited.has(next)) continue; visited.add(next); queue.push(next); } } } if (ownedAddrs.size > 0) { this._proxiedByPeer.set(peerBus, ownedAddrs); this._ensureResyncTimer(); } } /** * Tear down only the proxies that `syncProxyFromPeer(peerBus)` * installed. Safe to call multiple times; idempotent. Other * concurrent bridges (different peer buses) retain their proxies. */ clearProxiesForPeer(peerBus: I2CBusManager): void { const owned = this._proxiedByPeer.get(peerBus); if (!owned) return; for (const addr of owned) { // Only unregister if no other peer also claims this address. let claimedElsewhere = false; for (const [other, set] of this._proxiedByPeer) { if (other !== peerBus && set.has(addr)) { claimedElsewhere = true; break; } } if (!claimedElsewhere) { this.bridge.unregisterProxyI2c(addr); this._peerDeviceLookup.delete(addr); this._lastDumpHash.delete(addr); } } this._proxiedByPeer.delete(peerBus); this._stopResyncTimerIfIdle(); } /** * Tear down EVERY proxy slave we've installed. Used on full board * stop / disconnect — `clearProxiesForPeer` is preferred for * single-wire-pair teardowns. */ clearAllProxies(): void { for (const set of this._proxiedByPeer.values()) { for (const addr of set) this.bridge.unregisterProxyI2c(addr); } this._proxiedByPeer.clear(); this._peerDeviceLookup.clear(); this._lastDumpHash.clear(); this._stopResyncTimerIfIdle(); } /** Per-peer set of addresses we've mirrored. Cleanup keyed by peer bus. */ private _proxiedByPeer = new Map>(); /** Address → live frontend device, for write-forwarding & periodic resync. */ private _peerDeviceLookup = new Map(); /** Periodic resync timer — runs while any proxy is live. */ private _resyncTimer: ReturnType | null = null; /** Cheap hash of the last dumped register set per address, to skip WS pushes when unchanged. */ private _lastDumpHash = new Map(); /** * Periodic resync interval in ms. 250 ms strikes the balance * between WS bandwidth and human-perceivable RTC freshness; see * the architecture rationale in the plan file. Exposed for tests * that want a faster cadence via fake timers. */ static RESYNC_INTERVAL_MS = 250; private _ensureResyncTimer(): void { if (this._resyncTimer !== null) return; if (this._proxiedByPeer.size === 0) return; this._resyncTimer = setInterval( () => this._resyncTick(), Esp32BridgeShim.RESYNC_INTERVAL_MS, ); } private _stopResyncTimerIfIdle(): void { if (this._proxiedByPeer.size === 0 && this._resyncTimer !== null) { clearInterval(this._resyncTimer); this._resyncTimer = null; this._lastDumpHash.clear(); } } /** * Cheap XOR-stride hash over a 256-byte buffer. Detects any byte * difference; collisions are theoretically possible but we don't * care — a missed update on a flaky hash just delays freshness by * one cycle. */ private static _hashRegs(regs: Uint8Array): number { let h = regs.length & 0xff; for (let i = 0; i < regs.length; i += 16) { h = ((h << 5) - h + regs[i]) | 0; } for (let i = 0; i < Math.min(regs.length, 8); i++) { h = ((h << 5) - h + regs[i]) | 0; } return h; } private _resyncTick(): void { // Union of all proxied addresses across peers. const seen = new Set(); for (const set of this._proxiedByPeer.values()) { for (const addr of set) seen.add(addr); } for (const addr of seen) { const device = this._peerDeviceLookup.get(addr); if (!device || typeof device.dumpRegisters !== 'function') continue; let regs: Uint8Array; try { regs = device.dumpRegisters(); } catch { continue; } const h = Esp32BridgeShim._hashRegs(regs); if (this._lastDumpHash.get(addr) === h) continue; this._lastDumpHash.set(addr, h); this.bridge.updateProxyI2c(addr, regs); } } } // ── Shared LEDC update handler (used by addBoard, setBoardType, initSimulator) ─ // // Two handlers ship side by side during the SignalRouter rollout: // // * makeLedcUpdateHandler — legacy, consumes the old `ledc_update` // event (with embedded gpio). Drops the update when gpio=-1 and // multiple PWM consumers are registered, to avoid the multi-servo // blink symptom (see commit 77bf897). Kept until the SignalRouter // path has logged a full prod cycle without regressions. // // * makeLedcDutyHandler — canonical, consumes the new `ledc_duty` // event (channel + duty only) and resolves channel → pins via the // per-board SignalRouter mirror. Zero broadcasts, zero memos, // multi-pin routing supported. // // Both run unconditionally; updatePwm is idempotent so two handlers // firing for the same (pin, duty) are a no-op. Once the legacy path // is retired, makeLedcUpdateHandler + broadcastPwm + pwmListenerPinCount // are deleted in the same commit. function makeLedcUpdateHandler(boardId: string) { // Per-channel gpio memo: when the backend's _ledc_gpio_map emits // gpio=-1 (race window during attach), use the last-known gpio for // this channel to avoid corrupting multi-servo setups. const channelGpioMemo = new Map(); return (update: { channel: number; duty_pct: number; gpio?: number }) => { const boardPm = pinManagerMap.get(boardId); if (!boardPm) return; const dutyCycle = update.duty_pct / 100; if (update.gpio !== undefined && update.gpio >= 0) { channelGpioMemo.set(update.channel, update.gpio); boardPm.updatePwm(update.gpio, dutyCycle); return; } const rememberedGpio = channelGpioMemo.get(update.channel); if (rememberedGpio !== undefined) { boardPm.updatePwm(rememberedGpio, dutyCycle); return; } if (boardPm.pwmListenerPinCount() <= 1) { boardPm.broadcastPwm(dutyCycle); } }; } function makeLedcDutyHandler(boardId: string) { return (duty: { channel: number; duty_pct: number }) => { const boardPm = pinManagerMap.get(boardId); const router = signalRouterMap.get(boardId); if (!boardPm || !router) return; const dutyCycle = duty.duty_pct / 100; const signalId = ledcSignalForChannel(duty.channel); const pins = router.pinsForSignal(signalId); // Multi-pin routing: one LEDC channel CAN legally drive multiple // pins via the GPIO Matrix (rare but documented in TRM). Iterate // all of them — each gets its own updatePwm call. for (const pin of pins) { boardPm.updatePwm(pin, dutyCycle); } }; } function makeGpioRoutingHandler(boardId: string) { return (routing: { gpio: number; signal_id: number }) => { signalRouterMap.get(boardId)?.updateRouting(routing.gpio, routing.signal_id); }; } function makeGpioRoutingClearHandler(boardId: string) { return (gpio: number) => { signalRouterMap.get(boardId)?.clearRouting(gpio); }; } // ── Runtime Maps (outside Zustand — not serialisable) ───────────────────── const simulatorMap = new Map< string, AVRSimulator | RP2040Simulator | RiscVSimulator | Esp32C3Simulator | Esp32BridgeShim >(); const pinManagerMap = new Map(); // Per-board ESP32 GPIO Matrix mirror. Populated for boards whose kind // is an ESP32 variant (others don't have a GPIO Matrix in the same // sense; AVR/RP2040 wire signals to pins directly without the IO_MUX). // Lifecycle parallels pinManagerMap — created in addBoard / setBoardType // / initSimulator, deleted in removeBoard / cleanup. const signalRouterMap = new Map(); const bridgeMap = new Map(); const esp32BridgeMap = new Map(); // Pico W WiFi (CYW43439) bridge — created lazily, only when boardKind === 'pi-pico-w'. const cyw43BridgeMap = new Map(); export const getBoardSimulator = (id: string) => simulatorMap.get(id); export const getBoardPinManager = (id: string) => pinManagerMap.get(id); export const getBoardBridge = (id: string) => bridgeMap.get(id); export const getEsp32Bridge = (id: string) => esp32BridgeMap.get(id); export const getCyw43Bridge = (id: string) => cyw43BridgeMap.get(id); // Xtensa-based ESP32 boards — use QEMU bridge (backend) const ESP32_KINDS = new Set([ 'esp32', 'esp32-devkit-c-v4', 'esp32-cam', 'wemos-lolin32-lite', 'esp32-s3', 'xiao-esp32-s3', 'arduino-nano-esp32', ]); // RISC-V ESP32 boards — also use QEMU bridge (qemu-system-riscv32 -M esp32c3) // The browser-side Esp32C3Simulator cannot handle the 150+ ROM functions ESP-IDF needs. const ESP32_RISCV_KINDS = new Set([ 'esp32-c3', 'xiao-esp32-c3', 'aitewinrobot-esp32c3-supermini', ]); function isEsp32Kind(kind: BoardKind): boolean { return ESP32_KINDS.has(kind) || ESP32_RISCV_KINDS.has(kind); } function isRiscVEsp32Kind(kind: BoardKind): boolean { return ESP32_RISCV_KINDS.has(kind); } // ── Component type ──────────────────────────────────────────────────────── interface Component { id: string; metadataId: string; x: number; y: number; properties: Record; } // ── Undo/redo history ──────────────────────────────────────────────────── /** * One entry on the canvas undo/redo stack. * * description — human-readable label shown as the undo/redo button * tooltip ("Undo: Move LED"). * execute() — applied on redo. Should be idempotent against the * current state at redo time (the user may have undone * several steps then started a new branch). * undo() — reverts the change. Same idempotency contract. * * Commands that capture the inverse on construction (e.g. `recordMove` * captures fromX/fromY) are pushed with `applyNow:false` because the * mutation already happened — the command only needs to remember how to * undo/redo it later. Commands that ARE the canonical mutation (e.g. * `recordAddComponent`) are pushed with `applyNow:true` so a single call * both performs the action and stores the undo path. */ export interface CanvasCommand { description: string; execute(): void; undo(): void; } const HISTORY_MAX = 50; // ── Store interface ─────────────────────────────────────────────────────── interface SimulatorState { // ── Multi-board state ─────────────────────────────────────────────────── boards: BoardInstance[]; activeBoardId: string | null; addBoard: (boardKind: BoardKind, x: number, y: number, explicitId?: string) => string; removeBoard: (boardId: string) => void; /** Reload the entire workspace from a saved project payload. Tears down * all current boards, recreates them with their saved IDs (so wire * endpoints remain valid), restores file groups, components, wires. */ loadProjectState: (payload: { boards: BoardInstance[]; fileGroups: Record; components: Component[]; wires: Wire[]; activeBoardId: string | null; }) => void; updateBoard: (boardId: string, updates: Partial) => void; setBoardPosition: (pos: { x: number; y: number }, boardId?: string) => void; setActiveBoardId: (boardId: string) => void; compileBoardProgram: (boardId: string, program: string) => void; loadMicroPythonProgram: ( boardId: string, files: Array<{ name: string; content: string }>, ) => Promise; setBoardLanguageMode: (boardId: string, mode: LanguageMode) => void; startBoard: (boardId: string) => void; stopBoard: (boardId: string) => void; resetBoard: (boardId: string) => void; // ── Legacy single-board API (reads/writes activeBoardId board) ─────────── /** @deprecated use boards[]/activeBoardId directly */ boardType: BoardType; /** @deprecated use boards[x].x/y */ boardPosition: { x: number; y: number }; /** @deprecated use getBoardSimulator(activeBoardId) */ simulator: | AVRSimulator | RP2040Simulator | RiscVSimulator | Esp32C3Simulator | Esp32BridgeShim | null; /** @deprecated use getBoardPinManager(activeBoardId) */ pinManager: PinManager; running: boolean; compiledHex: string | null; hexEpoch: number; serialOutput: string; serialBaudRate: number; serialMonitorOpen: boolean; /** @deprecated use getBoardBridge(activeBoardId) */ remoteConnected: boolean; remoteSocket: WebSocket | null; setBoardType: (type: BoardType) => void; initSimulator: () => void; loadHex: (hex: string) => void; loadBinary: (base64: string) => void; startSimulation: () => void; stopSimulation: () => void; resetSimulation: () => void; setCompiledHex: (hex: string) => void; setCompiledBinary: (base64: string) => void; setRunning: (running: boolean) => void; connectRemoteSimulator: (clientId: string) => void; disconnectRemoteSimulator: () => void; sendRemotePinEvent: (pin: string, state: number) => void; // ── ESP32 crash notification ───────────────────────────────────────────── esp32CrashBoardId: string | null; dismissEsp32Crash: () => void; // ── Components ────────────────────────────────────────────────────────── components: Component[]; addComponent: (component: Component) => void; removeComponent: (id: string) => void; updateComponent: (id: string, updates: Partial) => void; updateComponentState: (id: string, state: boolean) => void; handleComponentEvent: (componentId: string, eventName: string, data?: unknown) => void; setComponents: (components: Component[]) => void; // ── Wires ─────────────────────────────────────────────────────────────── wires: Wire[]; selectedWireId: string | null; wireInProgress: WireInProgress | null; addWire: (wire: Wire) => void; removeWire: (wireId: string) => void; updateWire: (wireId: string, updates: Partial) => void; setSelectedWire: (wireId: string | null) => void; setWires: (wires: Wire[]) => void; startWireCreation: (endpoint: WireEndpoint, color: string) => void; updateWireInProgress: (x: number, y: number) => void; addWireWaypoint: (x: number, y: number) => void; setWireInProgressColor: (color: string) => void; finishWireCreation: (endpoint: WireEndpoint) => void; cancelWireCreation: () => void; updateWirePositions: (componentId: string) => void; recalculateAllWirePositions: () => void; // ── Undo/redo ──────────────────────────────────────────────────────────── /** Bounded ring buffer of canvas mutations (HISTORY_MAX = 50). */ history: CanvasCommand[]; /** Index of the last APPLIED command. -1 = empty / fully undone. */ historyIndex: number; /** Push a command and (by default) execute it. Truncates the redo stack. */ pushCommand: (cmd: CanvasCommand, opts?: { applyNow?: boolean }) => void; undo: () => void; redo: () => void; canUndo: () => boolean; canRedo: () => boolean; /** Wipe the stack (called on project load / clear). */ clearHistory: () => void; /** * Recorded canvas actions — these are the public API the UI and agent * tools should use to mutate the canvas. Each one wraps a raw mutator * with a CanvasCommand so the change is undoable. Drag-preview frames * still use the raw mutators (addComponent / updateComponent / addWire * / removeWire / updateWire) which DO NOT touch history. */ recordAddComponent: (component: Component) => void; recordRemoveComponent: (id: string) => void; recordMove: ( id: string, from: { x: number; y: number }, to: { x: number; y: number }, ) => void; recordRotate: (id: string, prevRotation: number, nextRotation: number) => void; recordSetProperty: (id: string, key: string, prevValue: unknown, nextValue: unknown) => void; recordAddWire: (wire: Wire) => void; recordRemoveWire: (wireId: string) => void; recordUpdateWire: ( wireId: string, prev: Partial, next: Partial, description?: string, ) => void; // ── Serial monitor ────────────────────────────────────────────────────── toggleSerialMonitor: () => void; serialWrite: (text: string) => void; serialWriteToBoard: (boardId: string, text: string) => void; clearSerialOutput: () => void; clearBoardSerialOutput: (boardId: string) => void; } // ── Helper: create a simulator for a given board kind ───────────────────── function createSimulator( boardKind: BoardKind, pm: PinManager, onSerial: (ch: string) => void, onBaud: (baud: number) => void, onPinTime: (pin: number, state: boolean, t: number) => void, ): AVRSimulator | RP2040Simulator | RiscVSimulator | Esp32C3Simulator { let sim: AVRSimulator | RP2040Simulator | RiscVSimulator | Esp32C3Simulator; if (boardKind === 'arduino-mega') { sim = new AVRSimulator(pm, 'mega'); } else if (boardKind === 'attiny85') { sim = new AVRSimulator(pm, 'tiny85'); } else if (boardKind === 'raspberry-pi-pico' || boardKind === 'pi-pico-w') { sim = new RP2040Simulator(pm); } else if (isRiscVEsp32Kind(boardKind)) { // ESP32-C3 / XIAO-C3 / C3 SuperMini — browser-side RV32IMC emulator sim = new Esp32C3Simulator(pm); } else { // arduino-uno, arduino-nano sim = new AVRSimulator(pm, 'uno'); } sim.onSerialData = onSerial; if (sim instanceof AVRSimulator) sim.onBaudRateChange = onBaud; sim.onPinChangeWithTime = onPinTime; return sim; } // ── Default initial board (Arduino Uno — same as old behaviour) ─────────── const INITIAL_BOARD_ID = 'arduino-uno'; const INITIAL_BOARD: BoardInstance = { id: INITIAL_BOARD_ID, boardKind: 'arduino-uno', x: DEFAULT_BOARD_POSITION.x, y: DEFAULT_BOARD_POSITION.y, running: false, compiledProgram: null, serialOutput: '', serialBaudRate: 0, serialMonitorOpen: false, activeFileGroupId: `group-${INITIAL_BOARD_ID}`, languageMode: 'arduino' as LanguageMode, }; // ── Serial batching ─────────────────────────────────────────────────────── // USART callbacks fire once per byte. Sketches doing `Serial.println(x)` at // ~200 Hz emit ~600 bytes/s, and a raw `set()` per byte overwhelms React's // useSyncExternalStore reconciliation (→ "Maximum update depth exceeded"). // The batcher coalesces chunks per animation frame (≤60 Hz), grouped by board. const { append: appendSerial } = createSerialBatcher((perBoard) => { useSimulatorStore.setState((s) => { let globalOut = s.serialOutput; const boards = s.boards.map((b) => { const chunk = perBoard.get(b.id); if (!chunk) return b; if (s.activeBoardId === b.id) globalOut += chunk; return { ...b, serialOutput: b.serialOutput + chunk }; }); return { boards, serialOutput: globalOut }; }); }); // ── Store ───────────────────────────────────────────────────────────────── export const useSimulatorStore = create((set, get) => { // Initialise runtime objects for the default board const initialPm = new PinManager(); pinManagerMap.set(INITIAL_BOARD_ID, initialPm); function getOscilloscopeCallback(boardId: string) { return (pin: number, state: boolean, timeMs: number) => { const { channels, pushSample } = useOscilloscopeStore.getState(); for (const ch of channels) { if (ch.boardId === boardId && ch.pin === pin) pushSample(ch.id, timeMs, state); } }; } const initialSim = createSimulator( 'arduino-uno', initialPm, (ch) => appendSerial(INITIAL_BOARD_ID, ch), (baud) => { set((s) => { const boards = s.boards.map((b) => b.id === INITIAL_BOARD_ID ? { ...b, serialBaudRate: baud } : b, ); const isActive = s.activeBoardId === INITIAL_BOARD_ID; return { boards, ...(isActive ? { serialBaudRate: baud } : {}) }; }); }, getOscilloscopeCallback(INITIAL_BOARD_ID), ); // Cross-board routing for the initial board is handled by the Interconnect // (registered after the store is created — see bottom of this file). simulatorMap.set(INITIAL_BOARD_ID, initialSim); // ── Legacy single-board PinManager (references initial board's pm) ─────── const legacyPinManager = initialPm; return { // ── Multi-board state ───────────────────────────────────────────────── boards: [INITIAL_BOARD], activeBoardId: INITIAL_BOARD_ID, addBoard: (boardKind: BoardKind, x: number, y: number, explicitId?: string) => { let id: string; if (explicitId) { id = explicitId; } else { const existing = get().boards.filter((b) => b.boardKind === boardKind); id = existing.length === 0 ? boardKind : `${boardKind}-${existing.length + 1}`; } const pm = new PinManager(); pinManagerMap.set(id, pm); const serialCallback = (ch: string) => appendSerial(id, ch); if ( boardKind === 'raspberry-pi-3' || boardKind === 'raspberry-pi-4' || boardKind === 'raspberry-pi-5' ) { const bridge = new RaspberryPi3Bridge(id, boardKind); bridge.onSerialData = (ch: string) => { serialCallback(ch); // Cross-board routing now handled by Interconnect (see bind below). }; bridge.onPinChange = (_gpioPin, _state) => { // Cross-board routing now handled by Interconnect (see bind below). }; bridgeMap.set(id, bridge); } else if (isEsp32Kind(boardKind)) { const bridge = new Esp32Bridge(id, boardKind); bridge.onSerialData = serialCallback; bridge.onPinChange = (gpioPin, state) => { const boardPm = pinManagerMap.get(id); if (boardPm) boardPm.triggerPinChange(gpioPin, state); }; bridge.onCrash = () => { set({ esp32CrashBoardId: id }); }; bridge.onDisconnected = () => { set((s) => { const boards = s.boards.map((b) => (b.id === id ? { ...b, running: false } : b)); const isActive = s.activeBoardId === id; return { boards, ...(isActive ? { running: false } : {}) }; }); }; signalRouterMap.set(id, new SignalRouter()); bridge.onLedcUpdate = makeLedcUpdateHandler(id); bridge.onLedcDuty = makeLedcDutyHandler(id); bridge.onGpioRouting = makeGpioRoutingHandler(id); bridge.onGpioRoutingClear = makeGpioRoutingClearHandler(id); bridge.onWs2812Update = (channel, pixels) => { // Forward WS2812 pixel data to any DOM element with id=`ws2812-{id}-{channel}` // (set by NeoPixel components rendered in SimulatorCanvas). // We fire a custom event that NeoPixel components can listen to. const eventTarget = document.getElementById(`ws2812-${id}-${channel}`); if (eventTarget) { eventTarget.dispatchEvent(new CustomEvent('ws2812-pixels', { detail: { pixels } })); } }; bridge.onWifiStatus = (ws) => { set((s) => ({ boards: s.boards.map((b) => (b.id === id ? { ...b, wifiStatus: ws } : b)), })); }; bridge.onBleStatus = (bs) => { set((s) => ({ boards: s.boards.map((b) => (b.id === id ? { ...b, bleStatus: bs } : b)), })); }; esp32BridgeMap.set(id, bridge); // Provide a shim so PartSimulationRegistry components (DHT22, etc.) // can call setPinState / access pinManager on ESP32 boards. const shim = new Esp32BridgeShim(bridge, pm); shim.onSerialData = serialCallback; // If a shim already exists for this id (e.g. tests recreate the // same kind after reset), dispose any active proxies / timers // so the orphaned instance doesn't keep firing. const existingShim = simulatorMap.get(id) as any; if (existingShim?.clearAllProxies) { try { existingShim.clearAllProxies(); } catch { /* ignore */ } } simulatorMap.set(id, shim); } else { const sim = createSimulator( boardKind, pm, serialCallback, (baud) => { set((s) => { const boards = s.boards.map((b) => b.id === id ? { ...b, serialBaudRate: baud } : b, ); const isActive = s.activeBoardId === id; return { boards, ...(isActive ? { serialBaudRate: baud } : {}) }; }); }, getOscilloscopeCallback(id), ); // Cross-board routing now handled by Interconnect (see bind below). simulatorMap.set(id, sim); // ── Pico W: attach the CYW43 chip-side emulator + WS bridge ── // Mirrors the ESP32 path (esp32BridgeMap) so the board has the // same capability surface the rest of the app already understands. if (boardKind === 'pi-pico-w' && sim instanceof RP2040Simulator) { const bridge = new Cyw43Bridge(id); bridge.onWifiStatus = (ws) => { set((s) => ({ boards: s.boards.map((b) => (b.id === id ? { ...b, wifiStatus: ws } : b)), })); }; cyw43BridgeMap.set(id, bridge); sim.attachCyw43(bridge); } } const newBoard: BoardInstance = { id, boardKind, x, y, running: false, compiledProgram: null, serialOutput: '', serialBaudRate: 0, serialMonitorOpen: false, activeFileGroupId: `group-${id}`, languageMode: 'arduino', }; set((s) => { // If there's no current active board (or the stored id doesn't point // to one that exists), promote the new board to active. Without this, // an agent that does add_board → compile_sketch fails on step 2 with // "no active board on the canvas" and has to spend a turn on // set_active_board. Manual placements via the UI already auto-active // through the picker; this just closes the API gap. const stillExists = s.boards.some((b) => b.id === s.activeBoardId); const nextActive = stillExists ? s.activeBoardId : id; return { boards: [...s.boards, newBoard], activeBoardId: nextActive }; }); // Create the editor file group for this board useEditorStore.getState().createFileGroup(`group-${id}`); // Init VFS for Raspberry Pi 3 boards if (boardKind === 'raspberry-pi-3') { useVfsStore.getState().initBoardVfs(id); } // ── Interconnect: register the board and rebuild routes ────────── icBindBoard(id, boardKind); icUpdateWires(get().wires); return id; }, removeBoard: (boardId: string) => { const board = get().boards.find((b) => b.id === boardId); getBoardSimulator(boardId)?.stop(); simulatorMap.delete(boardId); pinManagerMap.delete(boardId); signalRouterMap.delete(boardId); const bridge = getBoardBridge(boardId); if (bridge) { bridge.disconnect(); bridgeMap.delete(boardId); } const esp32Bridge = getEsp32Bridge(boardId); if (esp32Bridge) { esp32Bridge.disconnect(); esp32BridgeMap.delete(boardId); } const cyw43Bridge = getCyw43Bridge(boardId); if (cyw43Bridge) { cyw43Bridge.disconnect(); cyw43BridgeMap.delete(boardId); } const cyw43Sim = getBoardSimulator(boardId); if (cyw43Sim instanceof RP2040Simulator) cyw43Sim.detachCyw43(); set((s) => { const boards = s.boards.filter((b) => b.id !== boardId); const activeBoardId = s.activeBoardId === boardId ? (boards[0]?.id ?? null) : s.activeBoardId; // Remove wires connected to this board const wires = s.wires.filter( (w) => w.start.componentId !== boardId && w.end.componentId !== boardId, ); return { boards, activeBoardId, wires }; }); // Clean up file group in editor store if (board) { useEditorStore.getState().deleteFileGroup(board.activeFileGroupId); } // ── Interconnect: drop board and rebuild routes ────────────────── icUnbindBoard(boardId); icUpdateWires(get().wires); }, updateBoard: (boardId: string, updates: Partial) => { set((s) => ({ boards: s.boards.map((b) => (b.id === boardId ? { ...b, ...updates } : b)), })); }, loadProjectState: (payload) => { const { stopSimulation, removeBoard, addBoard, setComponents, setWires, setActiveBoardId, recalculateAllWirePositions } = get(); // Tear down current state if (get().running) stopSimulation(); const oldIds = get().boards.map((b) => b.id); oldIds.forEach((id) => removeBoard(id)); // Recreate boards with their saved ids so wire endpoints (which embed // the literal board id) keep matching. payload.boards.forEach((b) => { addBoard(b.boardKind, b.x, b.y, b.id); // Apply the rest of the saved fields that addBoard doesn't set. if (b.languageMode && b.languageMode !== 'arduino') { set((s) => ({ boards: s.boards.map((bb) => bb.id === b.id ? { ...bb, languageMode: b.languageMode } : bb, ), })); } }); // Replace editor file groups atomically. Skip groups that already exist // (createFileGroup is a no-op for existing ids) — overwrite their files. useEditorStore.getState().replaceFileGroups(payload.fileGroups); // Components and wires setComponents(payload.components); setWires(payload.wires); // Active board: prefer the saved one, fall back to the first. const targetActive = payload.activeBoardId && get().boards.find((b) => b.id === payload.activeBoardId) ? payload.activeBoardId : (get().boards[0]?.id ?? null); if (targetActive) setActiveBoardId(targetActive); // Wires need a frame for the wokwi-elements to mount in the DOM before // pinPositionCalculator can resolve their pinInfo. requestAnimationFrame(() => { recalculateAllWirePositions(); icUpdateWires(get().wires); }); }, setBoardPosition: (pos: { x: number; y: number }, boardId?: string) => { const id = boardId ?? get().activeBoardId ?? INITIAL_BOARD_ID; set((s) => ({ boardPosition: s.activeBoardId === id ? pos : s.boardPosition, boards: s.boards.map((b) => (b.id === id ? { ...b, x: pos.x, y: pos.y } : b)), })); }, setActiveBoardId: (boardId: string) => { const board = get().boards.find((b) => b.id === boardId); if (!board) return; set({ activeBoardId: boardId, // Sync legacy flat fields to this board's values boardType: (board.boardKind === 'raspberry-pi-3' ? 'arduino-uno' : board.boardKind) as BoardType, boardPosition: { x: board.x, y: board.y }, simulator: simulatorMap.get(boardId) ?? null, pinManager: pinManagerMap.get(boardId) ?? legacyPinManager, running: board.running, compiledHex: board.compiledProgram, serialOutput: board.serialOutput, serialBaudRate: board.serialBaudRate, serialMonitorOpen: board.serialMonitorOpen, remoteConnected: bridgeMap.get(boardId)?.connected ?? esp32BridgeMap.get(boardId)?.connected ?? false, remoteSocket: null, }); // Switch the editor to this board's file group useEditorStore.getState().setActiveGroup(board.activeFileGroupId); }, compileBoardProgram: (boardId: string, program: string) => { const board = get().boards.find((b) => b.id === boardId); if (!board) { console.warn(`[compileBoardProgram] board not found: ${boardId}`); return; } console.log(`[compileBoardProgram] ${boardId} kind=${board.boardKind} programLen=${program?.length ?? 0}`); if (isEsp32Kind(board.boardKind)) { // All ESP32 boards (Xtensa + RISC-V C3): send firmware to QEMU via bridge. // Note: isEsp32Kind() includes C3 boards, so they route through Esp32Bridge // for full WiFi/BLE emulation via qemu-system-riscv32. const esp32Bridge = getEsp32Bridge(boardId); if (esp32Bridge) esp32Bridge.loadFirmware(program); } else if (isRiscVEsp32Kind(board.boardKind)) { // Fallback: browser-only RV32IMC emulation (no WiFi/BLE support). // Currently unreachable because isEsp32Kind() above includes C3 boards. const sim = getBoardSimulator(boardId); if (sim instanceof Esp32C3Simulator) { try { sim.loadFlashImage(program); } catch (err) { console.error(`[Esp32C3Simulator] loadFlashImage failed for ${boardId}:`, err); return; } } } else { const sim = getBoardSimulator(boardId); if (sim && board.boardKind !== 'raspberry-pi-3') { try { if (sim instanceof AVRSimulator) { sim.loadHex(program); sim.addI2CDevice(new VirtualDS1307()); sim.addI2CDevice(new VirtualTempSensor()); sim.addI2CDevice(new I2CMemoryDevice(0x50)); } else if (sim instanceof RP2040Simulator) { sim.loadBinary(program); sim.addI2CDevice(new VirtualDS1307() as RP2040I2CDevice); sim.addI2CDevice(new VirtualTempSensor() as RP2040I2CDevice); sim.addI2CDevice(new I2CMemoryDevice(0x50) as RP2040I2CDevice); } } catch (err) { console.error(`compileBoardProgram(${boardId}):`, err); return; } } } set((s) => { const boards = s.boards.map((b) => b.id === boardId ? { ...b, compiledProgram: program } : b, ); const isActive = s.activeBoardId === boardId; return { boards, ...(isActive ? { compiledHex: program, hexEpoch: s.hexEpoch + 1 } : {}), }; }); }, loadMicroPythonProgram: async ( boardId: string, files: Array<{ name: string; content: string }>, ) => { const board = get().boards.find((b) => b.id === boardId); if (!board) return; if (!BOARD_SUPPORTS_MICROPYTHON.has(board.boardKind)) return; if (isEsp32Kind(board.boardKind)) { // ESP32 path: load MicroPython firmware via QEMU bridge, inject code via raw-paste REPL const { getEsp32Firmware, padToFlashSize, uint8ArrayToBase64 } = await import('../simulation/Esp32MicroPythonLoader'); const esp32Bridge = getEsp32Bridge(boardId); if (!esp32Bridge) return; const firmware = await getEsp32Firmware(board.boardKind); const b64 = uint8ArrayToBase64(padToFlashSize(firmware, board.boardKind)); esp32Bridge.loadFirmware(b64); // Queue code injection for after REPL boots. Multi-file projects: // every .py file other than the entry point gets materialized to the // MicroPython filesystem (via a prelude executed inside the same raw // REPL paste) before main.py runs, so `import mylib` resolves. // Without this, ESP32 projects with helper modules crashed at runtime // with ModuleNotFoundError. const mainFile = files.find((f) => f.name === 'main.py') ?? files[0]; if (mainFile) { const auxFiles = files.filter( (f) => f !== mainFile && f.name.endsWith('.py'), ); const preludeLines = auxFiles.map((f) => { // JSON.stringify produces an ASCII-safe Python-compatible // string literal (both languages share the same \n \r \t \" \\ // escapes, and JSON does not emit any escape Python rejects). const lit = JSON.stringify(f.content); const path = JSON.stringify(f.name); return `with open(${path},'w') as _f:\n _f.write(${lit})`; }); const prelude = preludeLines.length ? preludeLines.join('\n') + '\n' : ''; esp32Bridge.setPendingMicroPythonCode(prelude + mainFile.content); } } else { // RP2040 path: load firmware + filesystem in browser const sim = getBoardSimulator(boardId); if (!(sim instanceof RP2040Simulator)) return; await sim.loadMicroPython(files); } set((s) => { const boards = s.boards.map((b) => b.id === boardId ? { ...b, compiledProgram: 'micropython-loaded' } : b, ); const isActive = s.activeBoardId === boardId; return { boards, ...(isActive ? { compiledHex: 'micropython-loaded', hexEpoch: s.hexEpoch + 1 } : {}), }; }); }, setBoardLanguageMode: (boardId: string, mode: LanguageMode) => { const board = get().boards.find((b) => b.id === boardId); if (!board) return; // Only allow MicroPython for supported boards if (mode === 'micropython' && !BOARD_SUPPORTS_MICROPYTHON.has(board.boardKind)) return; // Stop any running simulation if (board.running) get().stopBoard(boardId); // Clear compiled program since language changed set((s) => ({ boards: s.boards.map((b) => b.id === boardId ? { ...b, languageMode: mode, compiledProgram: null } : b, ), })); // Replace file group with appropriate default files and activate it const editorStore = useEditorStore.getState(); editorStore.deleteFileGroup(board.activeFileGroupId); editorStore.createFileGroup(board.activeFileGroupId, mode); editorStore.setActiveGroup(board.activeFileGroupId); }, startBoard: (boardId: string) => { const board = get().boards.find((b) => b.id === boardId); if (!board) return; if (board.boardKind === 'raspberry-pi-3') { getBoardBridge(boardId)?.connect(); } else if (isEsp32Kind(board.boardKind)) { // Pre-register sensors connected to this board so the QEMU worker // has them ready before the firmware starts executing. const esp32Bridge = getEsp32Bridge(boardId); if (esp32Bridge) { const { components, wires } = get(); const sensors: Array> = []; for (const comp of components) { const sensorDef = SENSOR_COMPONENT_MAP[comp.metadataId]; if (!sensorDef) continue; // Find the wire connecting this component's data pin to the board for (const w of wires) { const compEndpoint = w.start.componentId === comp.id && w.start.pinName === sensorDef.dataPinName ? w.start : w.end.componentId === comp.id && w.end.pinName === sensorDef.dataPinName ? w.end : null; if (!compEndpoint) continue; const boardEndpoint = compEndpoint === w.start ? w.end : w.start; if (!isBoardComponent(boardEndpoint.componentId)) continue; // Resolve GPIO pin number const gpioPin = boardPinToNumber(board.boardKind, boardEndpoint.pinName); if (gpioPin === null || gpioPin < 0) continue; // Collect sensor properties from the component const props: Record = { sensor_type: sensorDef.sensorType, pin: gpioPin, }; for (const key of sensorDef.propertyKeys) { const val = comp.properties[key]; if (val !== undefined) props[key] = typeof val === 'string' ? parseFloat(val) : val; } // Resolve extra pins (e.g. echo_pin for HC-SR04) from wires if (sensorDef.extraPins) { for (const [propName, compPinName] of Object.entries(sensorDef.extraPins)) { for (const ew of wires) { const epComp = ew.start.componentId === comp.id && ew.start.pinName === compPinName ? ew.start : ew.end.componentId === comp.id && ew.end.pinName === compPinName ? ew.end : null; if (!epComp) continue; const epBoard = epComp === ew.start ? ew.end : ew.start; if (!isBoardComponent(epBoard.componentId)) continue; const extraGpio = boardPinToNumber(board.boardKind, epBoard.pinName); if (extraGpio !== null && extraGpio >= 0) { props[propName] = extraGpio; } break; } } } sensors.push(props); break; // only one data pin per sensor } } // Pre-register I2C sensors (virtual pin = 200 + i2c_addr, no wire resolution needed) for (const comp of components) { const i2cDef = I2C_SENSOR_MAP[comp.metadataId]; if (!i2cDef) continue; // Resolve I2C address from component property or use default let addr = i2cDef.defaultAddr; if (i2cDef.addrProp) { const rawAddr = comp.properties[i2cDef.addrProp]; if (rawAddr !== undefined) { if (i2cDef.addrIsBool) { // Boolean flag (e.g. AD0 on MPU-6050): truthy → high address if (rawAddr === true || rawAddr === 'true' || rawAddr === '1') { addr = i2cDef.addrBoolHigh ?? i2cDef.defaultAddr; } } else { const parsed = typeof rawAddr === 'string' ? rawAddr.startsWith('0x') ? parseInt(rawAddr, 16) : parseInt(rawAddr, 10) : Number(rawAddr); if (!isNaN(parsed)) addr = parsed; } } } const virtualPin = 200 + addr; const props: Record = { sensor_type: i2cDef.sensorType, pin: virtualPin, addr, }; for (const key of i2cDef.propertyKeys ?? []) { const val = comp.properties[key]; if (val !== undefined) props[key] = typeof val === 'string' ? parseFloat(val) : val; } sensors.push(props); } esp32Bridge.setSensors(sensors); // Use WiFi flag set by the compiler (most reliable — avoids stale file group issues). // Fall back to scanning the active file group if the flag hasn't been set yet. let hasWifi = board.hasWifi; if (hasWifi === undefined) { const editorState = useEditorStore.getState(); const rawFiles = editorState.fileGroups[board.activeFileGroupId]; const boardFiles = rawFiles && rawFiles.length > 0 ? rawFiles : editorState.files; hasWifi = boardFiles.some( (f) => f.content.includes('#include ') || f.content.includes('#include ') || f.content.includes('#include "WiFi.h"') || f.content.includes('WiFi.begin('), ); } esp32Bridge.wifiEnabled = hasWifi; // Ensure firmware is loaded into the bridge (handles page-refresh case // where _pendingFirmware is lost but compiledProgram is still in store). if (!esp32Bridge.hasFirmware() && board.compiledProgram) { esp32Bridge.loadFirmware(board.compiledProgram); } esp32Bridge.connect(); } } else { getBoardSimulator(boardId)?.start(); // Pico W: open the network bridge here too, alongside the local // RP2040 sim. Auto-detect WiFi from the board's source files. if (board.boardKind === 'pi-pico-w') { const cyw43 = getCyw43Bridge(boardId); if (cyw43) { const editorState = useEditorStore.getState(); const rawFiles = editorState.fileGroups[board.activeFileGroupId]; const boardFiles = rawFiles && rawFiles.length > 0 ? rawFiles : editorState.files; const hasWifi = boardFiles.some( (f) => /import\s+network\b/.test(f.content) || /network\.WLAN/.test(f.content) || /#include\s*[<"]WiFi\.h[>"]/.test(f.content) || /WiFi\.begin\(/.test(f.content), ); cyw43.wifiEnabled = hasWifi; cyw43.connect(); } } } set((s) => { const boards = s.boards.map((b) => b.id === boardId ? { ...b, running: true, serialMonitorOpen: true } : b, ); const isActive = s.activeBoardId === boardId; return { boards, ...(isActive ? { running: true, serialMonitorOpen: true } : {}) }; }); }, stopBoard: (boardId: string) => { const board = get().boards.find((b) => b.id === boardId); if (!board) return; if (board.boardKind === 'raspberry-pi-3') { getBoardBridge(boardId)?.disconnect(); } else if (isEsp32Kind(board.boardKind)) { getEsp32Bridge(boardId)?.disconnect(); } else { getBoardSimulator(boardId)?.stop(); } set((s) => { const boards = s.boards.map((b) => (b.id === boardId ? { ...b, running: false } : b)); const isActive = s.activeBoardId === boardId; return { boards, ...(isActive ? { running: false } : {}) }; }); }, resetBoard: (boardId: string) => { const board = get().boards.find((b) => b.id === boardId); if (!board) return; if (isEsp32Kind(board.boardKind)) { // Reset ESP32: disconnect then reconnect the QEMU bridge const esp32Bridge = getEsp32Bridge(boardId); if (esp32Bridge?.connected) { esp32Bridge.disconnect(); setTimeout(() => esp32Bridge.connect(), 500); } } else if (board.boardKind !== 'raspberry-pi-3') { const sim = getBoardSimulator(boardId); if (sim) { sim.reset(); // Re-wire serial callback after reset sim.onSerialData = (ch) => appendSerial(boardId, ch); if (sim instanceof AVRSimulator) { sim.onBaudRateChange = (baud) => { set((s) => { const boards = s.boards.map((b) => b.id === boardId ? { ...b, serialBaudRate: baud } : b, ); const isActive = s.activeBoardId === boardId; return { boards, ...(isActive ? { serialBaudRate: baud } : {}) }; }); }; } } } set((s) => { const boards = s.boards.map((b) => b.id === boardId ? { ...b, running: false, serialOutput: '', serialBaudRate: 0 } : b, ); const isActive = s.activeBoardId === boardId; return { boards, ...(isActive ? { running: false, serialOutput: '', serialBaudRate: 0 } : {}), }; }); }, // ── Legacy single-board API ─────────────────────────────────────────── boardType: 'arduino-uno', boardPosition: { ...DEFAULT_BOARD_POSITION }, simulator: initialSim, pinManager: legacyPinManager, running: false, compiledHex: null, hexEpoch: 0, serialOutput: '', serialBaudRate: 0, serialMonitorOpen: false, remoteConnected: false, remoteSocket: null, esp32CrashBoardId: null, dismissEsp32Crash: () => set({ esp32CrashBoardId: null }), setBoardType: (type: BoardType) => { const { activeBoardId, running, stopSimulation } = get(); if (running) stopSimulation(); const boardId = activeBoardId ?? INITIAL_BOARD_ID; const pm = getBoardPinManager(boardId) ?? legacyPinManager; // Stop and remove old simulator / bridge getBoardSimulator(boardId)?.stop(); simulatorMap.delete(boardId); getEsp32Bridge(boardId)?.disconnect(); esp32BridgeMap.delete(boardId); const serialCallback = (ch: string) => appendSerial(boardId, ch); if (isEsp32Kind(type as BoardKind)) { // ESP32: use bridge, not AVR simulator const bridge = new Esp32Bridge(boardId, type as BoardKind); bridge.onSerialData = serialCallback; bridge.onPinChange = (gpioPin, state) => { const boardPm = pinManagerMap.get(boardId); if (boardPm) boardPm.triggerPinChange(gpioPin, state); }; bridge.onCrash = () => { set({ esp32CrashBoardId: boardId }); }; bridge.onDisconnected = () => { set((s) => { const boards = s.boards.map((b) => (b.id === boardId ? { ...b, running: false } : b)); const isActive = s.activeBoardId === boardId; return { boards, ...(isActive ? { running: false } : {}) }; }); }; signalRouterMap.set(boardId, new SignalRouter()); bridge.onLedcUpdate = makeLedcUpdateHandler(boardId); bridge.onLedcDuty = makeLedcDutyHandler(boardId); bridge.onGpioRouting = makeGpioRoutingHandler(boardId); bridge.onGpioRoutingClear = makeGpioRoutingClearHandler(boardId); bridge.onWs2812Update = (channel, pixels) => { const eventTarget = document.getElementById(`ws2812-${boardId}-${channel}`); if (eventTarget) { eventTarget.dispatchEvent(new CustomEvent('ws2812-pixels', { detail: { pixels } })); } }; esp32BridgeMap.set(boardId, bridge); const shim = new Esp32BridgeShim(bridge, pm); shim.onSerialData = serialCallback; simulatorMap.set(boardId, shim); set((s) => ({ boardType: type, simulator: shim as any, compiledHex: null, serialOutput: '', serialBaudRate: 0, boards: s.boards.map((b) => b.id === boardId ? { ...b, boardKind: type as BoardKind, compiledProgram: null, serialOutput: '', serialBaudRate: 0, } : b, ), })); } else { const sim = createSimulator( type as BoardKind, pm, serialCallback, (baud) => set((s) => { const boards = s.boards.map((b) => b.id === boardId ? { ...b, serialBaudRate: baud } : b, ); return { boards, serialBaudRate: baud }; }), getOscilloscopeCallback(boardId), ); simulatorMap.set(boardId, sim); set((s) => ({ boardType: type, simulator: sim, compiledHex: null, serialOutput: '', serialBaudRate: 0, boards: s.boards.map((b) => b.id === boardId ? { ...b, boardKind: type as BoardKind, compiledProgram: null, serialOutput: '', serialBaudRate: 0, } : b, ), })); } console.log(`Board switched to: ${type}`); }, initSimulator: () => { const { boardType, activeBoardId } = get(); const boardId = activeBoardId ?? INITIAL_BOARD_ID; const pm = getBoardPinManager(boardId) ?? legacyPinManager; getBoardSimulator(boardId)?.stop(); simulatorMap.delete(boardId); getEsp32Bridge(boardId)?.disconnect(); esp32BridgeMap.delete(boardId); const serialCallback = (ch: string) => appendSerial(boardId, ch); if (isEsp32Kind(boardType as BoardKind)) { // ESP32: create bridge + shim (same as setBoardType) const bridge = new Esp32Bridge(boardId, boardType as BoardKind); bridge.onSerialData = serialCallback; bridge.onPinChange = (gpioPin, state) => { const boardPm = pinManagerMap.get(boardId); if (boardPm) boardPm.triggerPinChange(gpioPin, state); }; bridge.onCrash = () => { set({ esp32CrashBoardId: boardId }); }; bridge.onDisconnected = () => { set((s) => { const boards = s.boards.map((b) => (b.id === boardId ? { ...b, running: false } : b)); const isActive = s.activeBoardId === boardId; return { boards, ...(isActive ? { running: false } : {}) }; }); }; signalRouterMap.set(boardId, new SignalRouter()); bridge.onLedcUpdate = makeLedcUpdateHandler(boardId); bridge.onLedcDuty = makeLedcDutyHandler(boardId); bridge.onGpioRouting = makeGpioRoutingHandler(boardId); bridge.onGpioRoutingClear = makeGpioRoutingClearHandler(boardId); bridge.onWs2812Update = (channel, pixels) => { const eventTarget = document.getElementById(`ws2812-${boardId}-${channel}`); if (eventTarget) { eventTarget.dispatchEvent(new CustomEvent('ws2812-pixels', { detail: { pixels } })); } }; esp32BridgeMap.set(boardId, bridge); const shim = new Esp32BridgeShim(bridge, pm); shim.onSerialData = serialCallback; simulatorMap.set(boardId, shim); set({ simulator: shim as any, serialOutput: '', serialBaudRate: 0 }); } else { const sim = createSimulator( boardType as BoardKind, pm, serialCallback, (baud) => set((s) => { const boards = s.boards.map((b) => b.id === boardId ? { ...b, serialBaudRate: baud } : b, ); return { boards, serialBaudRate: baud }; }), getOscilloscopeCallback(boardId), ); simulatorMap.set(boardId, sim); set({ simulator: sim, serialOutput: '', serialBaudRate: 0 }); } console.log(`Simulator initialized: ${boardType}`); }, loadHex: (hex: string) => { const { activeBoardId } = get(); const boardId = activeBoardId ?? INITIAL_BOARD_ID; const sim = getBoardSimulator(boardId); if (sim && sim instanceof AVRSimulator) { try { sim.loadHex(hex); sim.addI2CDevice(new VirtualDS1307()); sim.addI2CDevice(new VirtualTempSensor()); sim.addI2CDevice(new I2CMemoryDevice(0x50)); set((s) => ({ compiledHex: hex, hexEpoch: s.hexEpoch + 1 })); console.log('HEX file loaded successfully'); } catch (error) { console.error('Failed to load HEX:', error); } } else { console.warn('loadHex: simulator not initialized or wrong board type'); } }, loadBinary: (base64: string) => { const { activeBoardId } = get(); const boardId = activeBoardId ?? INITIAL_BOARD_ID; const sim = getBoardSimulator(boardId); if (sim && sim instanceof RP2040Simulator) { try { sim.loadBinary(base64); sim.addI2CDevice(new VirtualDS1307() as RP2040I2CDevice); sim.addI2CDevice(new VirtualTempSensor() as RP2040I2CDevice); sim.addI2CDevice(new I2CMemoryDevice(0x50) as RP2040I2CDevice); set((s) => ({ compiledHex: base64, hexEpoch: s.hexEpoch + 1 })); console.log('Binary loaded into RP2040 successfully'); } catch (error) { console.error('Failed to load binary:', error); } } else { console.warn('loadBinary: simulator not initialized or wrong board type'); } }, startSimulation: () => { const { activeBoardId } = get(); const boardId = activeBoardId ?? INITIAL_BOARD_ID; get().startBoard(boardId); }, stopSimulation: () => { const { activeBoardId } = get(); const boardId = activeBoardId ?? INITIAL_BOARD_ID; get().stopBoard(boardId); }, resetSimulation: () => { const { activeBoardId } = get(); const boardId = activeBoardId ?? INITIAL_BOARD_ID; get().resetBoard(boardId); }, setCompiledHex: (hex: string) => { set({ compiledHex: hex }); get().loadHex(hex); }, setCompiledBinary: (base64: string) => { set({ compiledHex: base64 }); get().loadBinary(base64); }, setRunning: (running: boolean) => set({ running }), connectRemoteSimulator: (clientId: string) => { // Legacy: connect a Pi bridge for the given clientId const boardId = clientId; let bridge = getBoardBridge(boardId); if (!bridge) { bridge = new RaspberryPi3Bridge(boardId); bridge.onSerialData = (ch) => appendSerial(boardId, ch); bridge.onPinChange = (gpioPin, state) => { const { wires } = get(); const sim = getBoardSimulator(get().activeBoardId ?? INITIAL_BOARD_ID); if (!sim) return; const wire = wires.find( (w) => (w.start.componentId.includes('raspberry-pi') && w.start.pinName === String(gpioPin)) || (w.end.componentId.includes('raspberry-pi') && w.end.pinName === String(gpioPin)), ); if (wire) { const isArduinoStart = !wire.start.componentId.includes('raspberry-pi'); const targetEndpoint = isArduinoStart ? wire.start : wire.end; const pinNum = parseInt(targetEndpoint.pinName, 10); if (!isNaN(pinNum)) sim.setPinState(pinNum, state); } }; bridgeMap.set(boardId, bridge); } bridge.connect(); set({ remoteConnected: true }); }, disconnectRemoteSimulator: () => { const { activeBoardId } = get(); const boardId = activeBoardId ?? INITIAL_BOARD_ID; getBoardBridge(boardId)?.disconnect(); set({ remoteConnected: false, remoteSocket: null }); }, sendRemotePinEvent: (pin: string, state: number) => { const { activeBoardId } = get(); const boardId = activeBoardId ?? INITIAL_BOARD_ID; getBoardBridge(boardId)?.sendPinEvent(parseInt(pin, 10), state === 1); }, // ── Components ──────────────────────────────────────────────────────── // Default canvas shown on a bare /editor visit: an external LED on // pin 13 PROTECTED BY A 220Ω SERIES RESISTOR (the canonical Blink // wiring textbooks teach). Without the resistor the LED is a direct // short forward-biased between 5V and GND — real hardware blows the // diode, and the ngspice solver returns an indeterminate / NaN branch // current so the visual LED never lights up on the canvas either. // NOTE: component ids must NOT contain hyphens. ngspice (WASM build) // truncates branch-current vector names at '-', so a sense source // named V_led-builtin_sense yields the wrong key in branchCurrents // and the LED's update() loop never sees the diode current — the // node voltage is correct (the user sees ~1.84V on the wire) but the // visual brightness stays at zero. Underscore is safe. components: [ { id: 'led_builtin', metadataId: 'led', x: 380, y: 100, properties: { color: 'red' }, }, { id: 'r_builtin', metadataId: 'resistor', x: 240, y: 130, properties: { value: '220' }, }, ], wires: [ // Pin 13 → resistor pin 1 (current-limiting side). { id: 'wire_builtin_pin13', start: { componentId: 'arduino-uno', pinName: '13', x: 0, y: 0 }, end: { componentId: 'r_builtin', pinName: '1', x: 0, y: 0 }, waypoints: [], color: '#22c55e', }, // Resistor pin 2 → LED anode. { id: 'wire_builtin_anode', start: { componentId: 'r_builtin', pinName: '2', x: 0, y: 0 }, end: { componentId: 'led_builtin', pinName: 'A', x: 0, y: 0 }, waypoints: [], color: '#22c55e', }, // LED cathode → GND. { id: 'wire_builtin_cathode', start: { componentId: 'led_builtin', pinName: 'C', x: 0, y: 0 }, end: { componentId: 'arduino-uno', pinName: 'GND.1', x: 0, y: 0 }, waypoints: [], color: '#000000', }, ], selectedWireId: null, wireInProgress: null, addComponent: (component) => set((state) => ({ components: [...state.components, component] })), removeComponent: (id) => set((state) => ({ components: state.components.filter((c) => c.id !== id), wires: state.wires.filter((w) => w.start.componentId !== id && w.end.componentId !== id), })), updateComponent: (id, updates) => { set((state) => ({ components: state.components.map((c) => (c.id === id ? { ...c, ...updates } : c)), })); // Re-stamp wire endpoints when the geometry of the component changes: // position (x/y) OR rotation. Without this, rotating a component // leaves every wire anchored to the pre-rotation pin positions, so // the part visually disconnects from its cables. const rotationChanged = updates.properties && 'rotation' in updates.properties; if (updates.x !== undefined || updates.y !== undefined || rotationChanged) { get().updateWirePositions(id); } }, updateComponentState: (id, state) => { set((prevState) => ({ components: prevState.components.map((c) => c.id === id ? { ...c, properties: { ...c.properties, state, value: state } } : c, ), })); }, handleComponentEvent: (_componentId, _eventName, _data) => {}, setComponents: (components) => { // Bulk replacement (project load / clear) — any pending undo/redo // would point at component IDs that no longer exist after this. set({ components, history: [], historyIndex: -1 }); }, addWire: (wire) => set((state) => ({ wires: [...state.wires, wire] })), removeWire: (wireId) => set((state) => ({ wires: state.wires.filter((w) => w.id !== wireId), selectedWireId: state.selectedWireId === wireId ? null : state.selectedWireId, })), updateWire: (wireId, updates) => set((state) => ({ wires: state.wires.map((w) => (w.id === wireId ? { ...w, ...updates } : w)), })), setSelectedWire: (wireId) => set({ selectedWireId: wireId }), setWires: (wires) => set({ // Ensure every wire has waypoints (backwards-compatible with saved projects) wires: wires.map((w) => ({ waypoints: [], ...w })), // Bulk replacement clears history for the same reason as setComponents. history: [], historyIndex: -1, }), startWireCreation: (endpoint, color) => set({ wireInProgress: { startEndpoint: endpoint, waypoints: [], color, currentX: endpoint.x, currentY: endpoint.y, }, }), updateWireInProgress: (x, y) => set((state) => { if (!state.wireInProgress) return state; return { wireInProgress: { ...state.wireInProgress, currentX: x, currentY: y } }; }), addWireWaypoint: (x, y) => set((state) => { if (!state.wireInProgress) return state; return { wireInProgress: { ...state.wireInProgress, waypoints: [...state.wireInProgress.waypoints, { x, y }], }, }; }), setWireInProgressColor: (color) => set((state) => { if (!state.wireInProgress) return state; return { wireInProgress: { ...state.wireInProgress, color } }; }), finishWireCreation: (endpoint) => { const state = get(); if (!state.wireInProgress) return; const { startEndpoint, waypoints, color } = state.wireInProgress; // Finish wire: auto-detect color from pin name const finalColor = color === DEFAULT_WIRE_COLOR ? autoWireColor(endpoint.pinName) : color; const newWire: Wire = { id: `wire-${Date.now()}`, start: startEndpoint, end: endpoint, waypoints, color: finalColor, }; set((state) => ({ wires: [...state.wires, newWire], wireInProgress: null })); }, cancelWireCreation: () => set({ wireInProgress: null }), updateWirePositions: (componentId) => { set((state) => { const component = state.components.find((c) => c.id === componentId); // Check if this componentId matches a board id const board = state.boards.find((b) => b.id === componentId); // Components have a DynamicComponent wrapper with border:2px + padding:4px → offset (4,6) // Boards are rendered directly without a wrapper, so no offset. const compX = component ? component.x + 4 : board ? board.x : state.boardPosition.x; const compY = component ? component.y + 6 : board ? board.y : state.boardPosition.y; // Boards never rotate; components carry their angle in properties.rotation. const rotation = component ? Number(component.properties?.rotation) || 0 : 0; const updatedWires = state.wires.map((wire) => { const updated = { ...wire }; if (wire.start.componentId === componentId) { const pos = calculatePinPosition( componentId, wire.start.pinName, compX, compY, rotation, ); if (pos) updated.start = { ...wire.start, x: pos.x, y: pos.y }; } if (wire.end.componentId === componentId) { const pos = calculatePinPosition( componentId, wire.end.pinName, compX, compY, rotation, ); if (pos) updated.end = { ...wire.end, x: pos.x, y: pos.y }; } return updated; }); return { wires: updatedWires }; }); }, recalculateAllWirePositions: () => { const state = get(); const updatedWires = state.wires.map((wire) => { const updated = { ...wire }; // Resolve start — components have wrapper offset (4,6), boards do not const startComp = state.components.find((c) => c.id === wire.start.componentId); const startBoard = state.boards.find((b) => b.id === wire.start.componentId); const startX = startComp ? startComp.x + 4 : startBoard ? startBoard.x : state.boardPosition.x; const startY = startComp ? startComp.y + 6 : startBoard ? startBoard.y : state.boardPosition.y; const startRotation = startComp ? Number(startComp.properties?.rotation) || 0 : 0; const startPos = calculatePinPosition( wire.start.componentId, wire.start.pinName, startX, startY, startRotation, ); updated.start = startPos ? { ...wire.start, x: startPos.x, y: startPos.y } : { ...wire.start, x: startX, y: startY }; // Resolve end — components have wrapper offset (4,6), boards do not const endComp = state.components.find((c) => c.id === wire.end.componentId); const endBoard = state.boards.find((b) => b.id === wire.end.componentId); const endX = endComp ? endComp.x + 4 : endBoard ? endBoard.x : state.boardPosition.x; const endY = endComp ? endComp.y + 6 : endBoard ? endBoard.y : state.boardPosition.y; const endRotation = endComp ? Number(endComp.properties?.rotation) || 0 : 0; const endPos = calculatePinPosition( wire.end.componentId, wire.end.pinName, endX, endY, endRotation, ); updated.end = endPos ? { ...wire.end, x: endPos.x, y: endPos.y } : { ...wire.end, x: endX, y: endY }; return updated; }); set({ wires: updatedWires }); }, // ── Undo/redo ────────────────────────────────────────────────────────── history: [], historyIndex: -1, pushCommand: (cmd, opts) => { const applyNow = opts?.applyNow ?? true; if (applyNow) cmd.execute(); set((state) => { // Truncate the redo branch — once you push a new command, the // entries you'd previously redone are abandoned. const truncated = state.history.slice(0, state.historyIndex + 1); let next = [...truncated, cmd]; let nextIdx = next.length - 1; // Cap at HISTORY_MAX. When over, drop the oldest entry and shift // the index down so it still points at the just-pushed command. if (next.length > HISTORY_MAX) { const overflow = next.length - HISTORY_MAX; next = next.slice(overflow); nextIdx = next.length - 1; } return { history: next, historyIndex: nextIdx }; }); }, undo: () => { const state = get(); if (state.historyIndex < 0) return; const cmd = state.history[state.historyIndex]; try { cmd.undo(); } catch (err) { // A failing undo would otherwise leave the index pointing at a // half-applied command. Bail out cleanly. // eslint-disable-next-line no-console console.error('[history] undo failed:', cmd.description, err); return; } set({ historyIndex: state.historyIndex - 1 }); }, redo: () => { const state = get(); if (state.historyIndex >= state.history.length - 1) return; const cmd = state.history[state.historyIndex + 1]; try { cmd.execute(); } catch (err) { // eslint-disable-next-line no-console console.error('[history] redo failed:', cmd.description, err); return; } set({ historyIndex: state.historyIndex + 1 }); }, canUndo: () => get().historyIndex >= 0, canRedo: () => { const s = get(); return s.historyIndex < s.history.length - 1; }, clearHistory: () => set({ history: [], historyIndex: -1 }), // ── Recorded canvas actions ──────────────────────────────────────────── // Each `record*` builds a CanvasCommand that captures both directions // and pushes it. Naming intent: the user has *committed* a change // (drag-end, click finalised, agent tool execute) — distinct from the // raw mutators above which can be called per-frame during a drag. recordAddComponent: (component) => { get().pushCommand({ description: `Add ${component.metadataId}`, execute: () => set((s) => ({ components: [...s.components, component] })), undo: () => set((s) => ({ components: s.components.filter((c) => c.id !== component.id), // Mirror the cascade in removeComponent so a redo→undo round // trip of an add-then-wired pair stays consistent. wires: s.wires.filter( (w) => w.start.componentId !== component.id && w.end.componentId !== component.id, ), })), }); }, recordRemoveComponent: (id) => { const state = get(); const removed = state.components.find((c) => c.id === id); if (!removed) return; // Capture wires that will be cascaded too — undo must restore both // the component AND its wires together. const removedWires = state.wires.filter( (w) => w.start.componentId === id || w.end.componentId === id, ); get().pushCommand({ description: `Remove ${removed.metadataId}`, execute: () => set((s) => ({ components: s.components.filter((c) => c.id !== id), wires: s.wires.filter( (w) => w.start.componentId !== id && w.end.componentId !== id, ), })), undo: () => set((s) => ({ components: [...s.components, removed], wires: [...s.wires, ...removedWires], })), }); }, recordMove: (id, from, to) => { // The state is already at `to` (caller mutated during drag). We push // applyNow:false so we don't redundantly re-apply on first push; // execute()/undo() are only invoked on future redo/undo. get().pushCommand( { description: 'Move component', execute: () => { set((s) => ({ components: s.components.map((c) => c.id === id ? { ...c, x: to.x, y: to.y } : c, ), })); get().updateWirePositions(id); }, undo: () => { set((s) => ({ components: s.components.map((c) => c.id === id ? { ...c, x: from.x, y: from.y } : c, ), })); get().updateWirePositions(id); }, }, { applyNow: false }, ); }, recordRotate: (id, prevRotation, nextRotation) => { get().pushCommand( { description: 'Rotate component', execute: () => { set((s) => ({ components: s.components.map((c) => c.id === id ? { ...c, properties: { ...c.properties, rotation: nextRotation } } : c, ), })); // Wires must follow the part on undo / redo too, otherwise a // Ctrl+Z after a rotate would re-show the post-rotation pin // positions against the now-restored unrotated component. get().updateWirePositions(id); }, undo: () => { set((s) => ({ components: s.components.map((c) => c.id === id ? { ...c, properties: { ...c.properties, rotation: prevRotation } } : c, ), })); get().updateWirePositions(id); }, }, { applyNow: false }, ); }, recordSetProperty: (id, key, prevValue, nextValue) => { get().pushCommand( { description: `Change ${key}`, execute: () => set((s) => ({ components: s.components.map((c) => c.id === id ? { ...c, properties: { ...c.properties, [key]: nextValue } } : c, ), })), undo: () => set((s) => ({ components: s.components.map((c) => c.id === id ? { ...c, properties: { ...c.properties, [key]: prevValue } } : c, ), })), }, { applyNow: false }, ); }, recordAddWire: (wire) => { get().pushCommand({ description: 'Add wire', execute: () => set((s) => ({ wires: [...s.wires, wire] })), undo: () => set((s) => ({ wires: s.wires.filter((w) => w.id !== wire.id), selectedWireId: s.selectedWireId === wire.id ? null : s.selectedWireId, })), }); }, recordRemoveWire: (wireId) => { const removed = get().wires.find((w) => w.id === wireId); if (!removed) return; get().pushCommand({ description: 'Remove wire', execute: () => set((s) => ({ wires: s.wires.filter((w) => w.id !== wireId), selectedWireId: s.selectedWireId === wireId ? null : s.selectedWireId, })), undo: () => set((s) => ({ wires: [...s.wires, removed] })), }); }, recordUpdateWire: (wireId, prev, next, description = 'Update wire') => { get().pushCommand( { description, execute: () => set((s) => ({ wires: s.wires.map((w) => (w.id === wireId ? { ...w, ...next } : w)), })), undo: () => set((s) => ({ wires: s.wires.map((w) => (w.id === wireId ? { ...w, ...prev } : w)), })), }, { applyNow: false }, ); }, toggleSerialMonitor: () => set((s) => ({ serialMonitorOpen: !s.serialMonitorOpen })), serialWrite: (text: string) => { const { activeBoardId } = get(); const boardId = activeBoardId ?? INITIAL_BOARD_ID; const board = get().boards.find((b) => b.id === boardId); if (!board) return; if (board.boardKind === 'raspberry-pi-3') { const bridge = getBoardBridge(boardId); if (bridge) { for (let i = 0; i < text.length; i++) { bridge.sendSerialByte(text.charCodeAt(i)); } } } else if (isEsp32Kind(board.boardKind)) { const esp32Bridge = getEsp32Bridge(boardId); if (esp32Bridge) { esp32Bridge.sendSerialBytes(Array.from(new TextEncoder().encode(text))); } } else { getBoardSimulator(boardId)?.serialWrite(text); } }, clearSerialOutput: () => { const { activeBoardId } = get(); const boardId = activeBoardId ?? INITIAL_BOARD_ID; set((s) => ({ serialOutput: '', boards: s.boards.map((b) => (b.id === boardId ? { ...b, serialOutput: '' } : b)), })); }, serialWriteToBoard: (boardId: string, text: string) => { const board = get().boards.find((b) => b.id === boardId); if (!board) return; if (board.boardKind === 'raspberry-pi-3') { const bridge = getBoardBridge(boardId); if (bridge) { for (let i = 0; i < text.length; i++) { bridge.sendSerialByte(text.charCodeAt(i)); } } } else if (isEsp32Kind(board.boardKind)) { const esp32Bridge = getEsp32Bridge(boardId); if (esp32Bridge) { esp32Bridge.sendSerialBytes(Array.from(new TextEncoder().encode(text))); } } else { getBoardSimulator(boardId)?.serialWrite(text); } }, clearBoardSerialOutput: (boardId: string) => { const isActive = get().activeBoardId === boardId; set((s) => ({ ...(isActive ? { serialOutput: '' } : {}), boards: s.boards.map((b) => (b.id === boardId ? { ...b, serialOutput: '' } : b)), })); }, }; }); // ── Helper: get the active board instance (convenience for consumers) ───── export function getActiveBoard(): BoardInstance | null { const { boards, activeBoardId } = useSimulatorStore.getState(); return boards.find((b) => b.id === activeBoardId) ?? null; } // ── Cross-board interconnect wiring ──────────────────────────────────────── // // The Interconnect router subscribes to wire and board changes to propagate // digital pin transitions and UART bytes between boards. We register the // runtime accessors once, bind the initial board, and watch for store // mutations. setInterconnectRuntime({ getBoardSimulator: (id: string) => simulatorMap.get(id), getBoardPinManager: (id: string) => pinManagerMap.get(id), getBoardBridge: (id: string) => bridgeMap.get(id), getEsp32Bridge: (id: string) => esp32BridgeMap.get(id), }); // Bind the initial Arduino Uno that ships with the store. icBindBoard(INITIAL_BOARD_ID, 'arduino-uno'); icUpdateWires(useSimulatorStore.getState().wires); // React to wire mutations from any source (drag, import, setState, ...). let lastWiresRef: readonly Wire[] = useSimulatorStore.getState().wires; let lastBoardsRef: readonly BoardInstance[] = useSimulatorStore.getState().boards; useSimulatorStore.subscribe((state) => { const wiresChanged = state.wires !== lastWiresRef; const boardsChanged = state.boards !== lastBoardsRef; if (boardsChanged) { lastBoardsRef = state.boards; // Bind any boards that appeared in state but not yet in interconnect // (covers paths that bypass addBoard, e.g. import-from-zip, hot reload). for (const b of state.boards) icBindBoard(b.id, b.boardKind); } if (wiresChanged || boardsChanged) { lastWiresRef = state.wires; icUpdateWires(state.wires); } });