import { create } from 'zustand'; import { decideEngine, getInstantEngine } from '../lib/instantEngine'; import { getProBoard, getGuestSetup, isProBoardSimulator, type ProBoardSimulator, } from '../lib/proBoardRegistry'; import { AVRSimulator } from '../simulation/AVRSimulator'; import { attachSlavesFromCanvas } from '../simulation/piSlaveScanner'; import { RP2040Simulator } from '../simulation/RP2040Simulator'; import { RiscVSimulator } from '../simulation/RiscVSimulator'; import { Esp32C3Simulator } from '../simulation/Esp32C3Simulator'; import { PinManager } from '../simulation/PinManager'; import { SignalRouter } from '../simulation/SignalRouter'; import { requestElectricalResolve } from '../simulation/spice/electricalResolveHook'; 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, WifiStatus } from '../types/board'; import { BOARD_SUPPORTS_ESPIDF, BOARD_SUPPORTS_MICROPYTHON, isPiBoardKind, isStm32BoardKind } from '../types/board'; import { boardGateDecision, proBoardFeatureName, triggerProUpgradePrompt } from '../lib/proBoardGate'; import { calculatePinPosition } from '../utils/pinPositionCalculator'; import { useOscilloscopeStore } from './useOscilloscopeStore'; import { RaspberryPi3Bridge } from '../simulation/RaspberryPi3Bridge'; import { Esp32Bridge } from '../simulation/Esp32Bridge'; import { createEsp32Bridge } from '../simulation/Esp32BridgeFactory'; import { Stm32Bridge, stm32PinNameToLinear } from '../simulation/Stm32Bridge'; import { STM32_LED } from '../components/velxio-components/Stm32BluePillElement'; import { useEditorStore } from './useEditorStore'; import { useVfsStore } from './useVfsStore'; import { buildProjectSdImage, decodeSdFiles, bytesToB64 } from '../utils/sdCardFiles'; import { boardPinToNumber, isBoardComponent } from '../utils/boardPinMapping'; import { autoWireColor, DEFAULT_WIRE_COLOR, normalizeWireWaypoints, previewElbow, } from '../utils/wireUtils'; import { routeAroundObstacles, collectComponentObstacles, collectComponentRects, collectWireSegments, } from '../utils/wireAutoRoute'; import { isBreadboard } from '../utils/breadboardNets'; import { computeSeating } from '../utils/breadboardSnap'; import { createSerialBatcher } from './serialBatcher'; import { reensureSerialHooks as icReensureSerialHooks, bindBoard as icBindBoard, unbindBoard as icUnbindBoard, updateWires as icUpdateWires, setInterconnectRuntime, } from '../simulation/Interconnect'; import { SENSOR_CONTROLS, getSensorControl } from '../simulation/sensorControlConfig'; import { dispatchSensorUpdate } from '../simulation/SensorUpdateRegistry'; // ── 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; // Digital input pins are driven from the SPICE solve // (connectDigitalInputsToMcu), not the part-level seed — so a button reads // the real circuit (pull-up, GND, shorts) like hardware. Parts check this // flag and skip their direct setPinState seed for this board. readonly spiceDrivenInputs = true; 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 { const channel = this.adcChannelForPin(pin); if (channel < 0) return false; const millivolts = Math.round(voltage * 1000); this.bridge.setAdc(channel, millivolts); return true; } /** GPIO -> ADC channel. The mapping is CHIP-specific, so ask the bridge * when it knows its chip (S3: GPIO1..10 -> ADC1 ch0..9; C-family differs); * fall back to the classic ESP32 map, which was the only one this shim * handled before and silently returned false for every S3 pin. */ private adcChannelForPin(pin: number): number { const b = this.bridge as unknown as { adcChannelForGpio?: (gpio: number) => number }; if (typeof b.adcChannelForGpio === 'function') return b.adcChannelForGpio(pin); if (pin >= 36 && pin <= 39) return pin - 36; // GPIO 36→CH0 .. 39→CH3 if (pin >= 32 && pin <= 35) return pin - 28; // GPIO 32→CH4 .. 35→CH7 return -1; } /** * 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 { const channel = this.adcChannelForPin(pin); 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, // MISO goes back through the bridge's setSpiResponse — every bridge // has it (QEMU forwards to the worker's _spi_response; the JS engines // set the byte their SpiForwarder returns for THIS transfer, since the // whole onByte chain runs synchronously inside the engine's transfer). // This used to be a no-op "because the worker drives MISO", which was // only true for QEMU-era parts: any SPI part that ANSWERS (an SD card // reponding to CMD0) was talking to nobody in js mode — measured as // SD.begin()=0 with sd_diskio retrying CMD0 forever. completeTransfer: (miso: number) => { (this.bridge as unknown as { setSpiResponse?: (b: number) => void }) .setSpiResponse?.(miso); }, }; // 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); // An in-browser JS-emulator substitute bridge (velxio-prod overlay) plugs // the part's real device model straight onto the engine's synchronous I2C // bus, so the firmware's own reads hit it (sensors answer, displays // render). The QEMU WebSocket bridge has no such method — reads there are // served by the backend slave from registerSensor — so this is a no-op. (this.bridge as { attachSyncI2cDevice?: (d: I2CDevice) => void }).attachSyncI2cDevice?.(device); } /** Remove a previously-registered virtual device. */ removeI2CDevice(addr: number, _bus: 0 | 1 = 0): void { this.i2cBusInstance.removeDevice(addr); (this.bridge as { detachSyncI2cDevice?: (a: number) => void }).detachSyncI2cDevice?.(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); } } } // ── LEDC duty handler ─────────────────────────────────────────────────── // // Resolves a (channel, duty_pct) event from the worker into one or more // (gpio_pin, duty_cycle) updates by consulting the per-board // SignalRouter mirror. Replaces the legacy `ledc_update` path that // embedded the gpio in the event and needed a per-channel memo + a // PinManager.broadcastPwm fallback to survive the worker's gpio=-1 // race window. 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); }; } function makePinPullHandler(boardId: string) { return (gpio: number, pull: 0 | 1 | 2) => { // Record the internal pull so the netlist stamps a weak resistor // (vcc_rail for pull-up, GND for pull-down) and request a re-solve. The // digital read itself is driven from the solved circuit by // connectDigitalInputsToMcu — we deliberately do NOT seed the pin directly // here, because that would bypass the real wiring and re-introduce the // "mis-wired button still works" bug. pinManagerMap.get(boardId)?.setPinPull(gpio, pull); requestElectricalResolve(); }; } // ── Lightweight shim wrapping Stm32Bridge so PartSimulationRegistry parts // (I2C displays, sensors, SPI panels) attach to an STM32 board the same way // they attach to ESP32. Like the STM32 firmware itself, every device model // runs in the backend QEMU worker: `registerSensor` builds the QEMU-side I2C // slave, write-only devices (SSD1306, PCF8574) stream their bytes back via // `i2c_transaction`, and SPI panels read MOSI bytes off the `spi_batch` // channel through the `.spi` adapter — identical surface to Esp32BridgeShim, // minus the ESP32-only WiFi / proxy-resync machinery. ────────────────────── class Stm32BridgeShim { // Drive digital INPUT pins from the solved circuit (connectDigitalInputsToMcu) // instead of the legacy part-seed, so digitalRead() reflects the REAL wiring. // The internal pull is reported by the backend QEMU worker via the bridge's // `gpio_pull` message (wired to makePinPullHandler in the store). Mirrors AVR // / RP2040 / ESP32; event-driven parts with no SPICE model are protected by // the `sourcedNets` gate in the connector. readonly spiceDrivenInputs = true; 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: Stm32Bridge; private i2cBusInstance: I2CBusManager; private _i2cTransactionListeners = new Map void>(); constructor(bridge: Stm32Bridge, pm: PinManager) { this.bridge = bridge; this.pinManager = pm; this.i2cBusInstance = new I2CBusManager(nullI2CMaster()); } // ── Lifecycle stubs (the store drives the real bridge via getStm32Bridge) ── start(): void {} stop(): void {} reset(): void {} setSpeed(_s: number): void {} getSpeed(): number { return 1; } loadHex(_hex: string): void {} loadBinary(_b64: string): void {} isRunning(): boolean { return this.bridge.connected; } /** Drive a GPIO input from a part. `pin` is the linear pin (port*16+pin). */ setPinState(pin: number, state: boolean): void { this.bridge.sendPinEvent(pin, state); } // ── Generic sensor registration (delegated to the backend QEMU worker) ── registerSensor(type: string, pin: number, properties: Record): boolean { this.bridge.sendSensorAttach(type, pin, properties); return true; } updateSensor(pin: number, properties: Record): void { this.bridge.sendSensorUpdate(pin, properties); } unregisterSensor(pin: number): void { this.bridge.sendSensorDetach(pin); } /** Expose the bridge so SPI/ePaper parts can subscribe to backend frames. */ getBridge(): Stm32Bridge { return this.bridge; } // ── I2C write-only device relay (SSD1306, PCF8574) ──────────────────────── 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 (for Interconnect bridges) ──────────────── getI2CBus(_bus: 0 | 1 = 0): I2CBusManager { return this.i2cBusInstance; } addI2CDevice(device: I2CDevice, _bus: 0 | 1 = 0): void { this.i2cBusInstance.addDevice(device); } removeI2CDevice(addr: number, _bus: 0 | 1 = 0): void { this.i2cBusInstance.removeDevice(addr); } // ── Generic SPI bus adapter (same shape as AVRSimulator.spi) ────────────── // SPI panels (ILI9341, SSD1306-SPI) hook `.spi.onByte`; STM32 runs SPI in // the backend, so the MOSI bytes arrive batched over `spi_batch` and we // replay them one at a time. MISO is driven by the worker, so // `completeTransfer` is a no-op (mirrors the ESP32 adapter). 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) => {}, }; this.bridge.onSpiBatch = (bytes: Uint8Array) => { for (const b of bytes) adapter.onByte?.(b); }; this._spiAdapter = adapter; } return this._spiAdapter; } } // ── Runtime Maps (outside Zustand — not serialisable) ───────────────────── const simulatorMap = new Map< string, AVRSimulator | RP2040Simulator | RiscVSimulator | Esp32C3Simulator | Esp32BridgeShim | Stm32BridgeShim >(); 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(); // STM32 bridge — created lazily, only when isStm32BoardKind(boardKind). const stm32BridgeMap = 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); /** Upload a QEMU-Linux board's editor file group into the guest home and run * its script (guestHome/autoRun overridable per overlay board). Used by the * boot auto-run and by Run on an already-booted board. */ export async function piSyncAndRunScript(boardId: string, boardKind: string): Promise { const bridge = bridgeMap.get(boardId); if (!bridge || !bridge.connected) return; const proDef = getProBoard(boardKind); const home = (proDef?.guestHome ?? '/home/pi').replace(/\/+$/, ''); const board = useSimulatorStore.getState().boards.find((b) => b.id === boardId); const groupId = board?.activeFileGroupId ?? `group-${boardId}`; const files = useEditorStore .getState() .getGroupFiles(groupId) .map((f) => ({ path: `${home}/${f.name}`, content: f.content })); try { const { uploadFilesToPi } = await import('../utils/piUpload'); await uploadFilesToPi(bridge, files); } finally { // Reveal the shell (ends quietBoot) right before the script starts, so // the user's first visible output is their own program. bridge.setQuiet(false); } const cmd = proDef?.autoRun ?? `python3 ${home}/script.py`; bridge.sendSerialText(cmd.endsWith('\n') ? cmd : cmd + '\n'); } /** Re-run on a BOOTED QEMU-Linux board without rebooting: interrupt the * running script (Ctrl-C), re-upload the file group, run again. */ export async function piRerunScript(boardId: string, boardKind: string): Promise { const bridge = bridgeMap.get(boardId); if (!bridge || !bridge.connected) return; bridge.sendSerialBytes([0x03]); await new Promise((r) => setTimeout(r, 400)); await piSyncAndRunScript(boardId, boardKind); } export const getEsp32Bridge = (id: string) => esp32BridgeMap.get(id); export const getStm32Bridge = (id: string) => stm32BridgeMap.get(id); /** Set a board's WiFi status (used by the pro PIO peripheral to surface the * Pico W's WiFi state into the canvas badge). */ export const setBoardWifiStatus = (id: string, ws: WifiStatus) => useSimulatorStore.setState((s) => ({ boards: s.boards.map((b) => (b.id === id ? { ...b, wifiStatus: ws } : b)), })); // 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 { if (ESP32_KINDS.has(kind) || ESP32_RISCV_KINDS.has(kind)) return true; // Overlay-registered ESP32-class boards route through the same bridge path. return getProBoard(kind)?.esp32Family !== undefined; } function isRiscVEsp32Kind(kind: BoardKind): boolean { const fam = getProBoard(kind)?.esp32Family; return ESP32_RISCV_KINDS.has(kind) || fam === 'esp32-c3' || fam === 'esp32-c6'; } // ── 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; /** Recreate an ESP32-family board's simulation bridge + shim through the * Esp32BridgeFactory seam. Called by the pro overlay after it installs a * factory (the overlay loads via async import, so a deep-linked example * can create boards before the factory exists). No-op (false) for * non-ESP32 kinds or while the board is running. */ rebuildEsp32Bridge: (boardId: string) => boolean; 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; /** Bumped on every Reset so the open SensorControlPanel remounts and * re-reads each interactive sensor's freshly-defaulted value. */ sensorResetNonce: number; /** Ids of components destroyed at runtime (P4 burnout) — the canvas renders * them charred. Cleared on Reset / restart. */ burntComponents: Set; /** Mark a component destroyed (called by the runtime burnout monitor). */ markComponentBurnt: (componentId: string) => void; /** Clear all runtime-destroyed components (on Reset / restart). */ clearBurntComponents: () => void; 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; /** Bump hexEpoch to force every component part to re-attach (e.g. so a * board-less custom chip picks up freshly compiled WASM). */ restartParts: () => 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; /** Recompute the breadboard seating wires (bb: true) of one component. */ reseatComponentOnBreadboard: (id: string) => 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 | ProBoardSimulator { let sim: AVRSimulator | RP2040Simulator | RiscVSimulator | Esp32C3Simulator | ProBoardSimulator; const proDef = getProBoard(boardKind); if (proDef?.createSimulator) { // Overlay-provided in-browser simulator (e.g. the RP2350/Hazard3 engine). sim = proDef.createSimulator(pm); } else 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); } }; } // Create + fully wire the simulation bridge and shim for an ESP32-family // board. Shared by addBoard and rebuildEsp32Bridge: the pro overlay rebuilds // a board's bridge when it installs an Esp32BridgeFactory AFTER the board // was already created — main.tsx loads the overlay via an async import, so a // deep-linked example can call addBoard before the factory exists and would // otherwise silently keep the stock QEMU bridge. function wireEsp32Board(id: string, boardKind: BoardKind, pm: PinManager): void { const serialCallback = (ch: string) => appendSerial(id, ch); const bridge = createEsp32Bridge(id, boardKind); bridge.onSerialData = serialCallback; bridge.onError = (message: string) => { // Surface backend/worker errors in the Serial Monitor so the user sees // a clear reason instead of a board that silently never boots. The // canonical case is an ESP32-S3 board: it compiles, but the bundled // QEMU has no esp32s3 machine, so the worker reports a clear message // here rather than crashing cryptically. Stop "running" and pop the // monitor open so the note is visible immediately. console.error(`[esp32:${id}] ${message}`); serialCallback(`\r\n[Velxio] ${message}\r\n`); set((s) => { const boards = s.boards.map((b) => (b.id === id ? { ...b, running: false } : b)); const isActive = s.activeBoardId === id; return { boards, serialMonitorOpen: true, ...(isActive ? { running: false } : {}), }; }); }; bridge.onPinChange = (gpioPin, state) => { const boardPm = pinManagerMap.get(id); if (boardPm) boardPm.triggerPinChange(gpioPin, state, 'mcu'); }; // Wire scope sampling for ESP32 (GPIO transitions + synthesized // UART TX bits). Mirrors what AVR/RP2040 simulators get for free // by passing the oscilloscope callback into createSimulator(). bridge.onPinChangeWithTime = getOscilloscopeCallback(id); 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.onLedcDuty = makeLedcDutyHandler(id); bridge.onGpioRouting = makeGpioRoutingHandler(id); bridge.onGpioRoutingClear = makeGpioRoutingClearHandler(id); bridge.onPinPull = makePinPullHandler(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, or the pro overlay rebuilds the bridge), // 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); } 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, rebuildEsp32Bridge: (boardId: string): boolean => { const board = get().boards.find((b) => b.id === boardId); const pm = pinManagerMap.get(boardId); if (!board || !pm || !isEsp32Kind(board.boardKind)) return false; if (board.running) return false; // never yank a live simulation const old = esp32BridgeMap.get(boardId); if (old) { try { old.disconnect(); } catch { /* ignore */ } } wireEsp32Board(boardId, board.boardKind, pm); return true; }, 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 (isPiBoardKind(boardKind)) { const bridge = new RaspberryPi3Bridge(id, boardKind); const piDef = getProBoard(boardKind); if (piDef?.quietBoot) { bridge.quietBootDefault = true; bridge.quietBootLabel = piDef.label; } bridge.onSerialData = (ch: string) => { serialCallback(ch); // Cross-board routing now handled by Interconnect (see bind below). }; bridge.onPinChange = (gpioPin, state) => { // Feed the guest's GPIO writes into this board's PinManager so they // reach wired components and the SPICE solver (the LED brightness // path) — same as the ESP32 branch. Without this the Pi could print // "LED on" but the canvas LEDs stayed dark. Interconnect preserves // and calls this before its own cross-board routing. const boardPm = pinManagerMap.get(id); if (boardPm) boardPm.triggerPinChange(gpioPin, state, 'mcu'); }; // Guest Linux finished booting (shell prompt reached). Flip piBooted so // the workspace swaps the "Booting…" overlay for the live terminal and // uploads know the shell is ready. Overlay boards may declare a // guestSetup line (hostname/PS1/clear) to de-brand the generic image; // it runs before piBooted flips so the VFS upload (gated on piBooted) // cannot interleave with it. bridge.onBooted = () => { const setup = getGuestSetup(boardKind); // Attach the slave models for I2C/SPI/UART components wired to // this Pi. This used to live in RaspberryPiWorkspace, which the // unified terminal replaced — leaving the scan with no caller, // so a BMP280 on the Pi's I2C pins never got its backend model. try { const st = get(); attachSlavesFromCanvas( id, bridge, st.components as never, st.wires as never, ); } catch (e) { console.warn('[pi] slave scan failed:', e); } const flip = () => set((s) => ({ boards: s.boards.map((b) => (b.id === id ? { ...b, piBooted: true } : b)), })); // After boot (+setup) the board's editor file group is uploaded // into the guest home and the run command executed, so one click // on Run boots, uploads and starts the user's script — same UX as // every other board's compile-and-run. Overlay boards can override // home/command via guestHome/autoRun. void (async () => { if (setup) { await bridge.sendAndWaitForPrompt(setup.endsWith('\n') ? setup : setup + '\n'); } flip(); try { await piSyncAndRunScript(id, boardKind); } catch (e) { console.warn(`[${boardKind}] auto-run failed:`, e); } })(); }; bridge.onDisconnected = () => { set((s) => { const boards = s.boards.map((b) => b.id === id ? { ...b, running: false, piBooted: false } : b, ); const isActive = s.activeBoardId === id; return { boards, ...(isActive ? { running: false } : {}) }; }); }; bridgeMap.set(id, bridge); // The UART routes were built at page load, when this bridge did // not exist — re-attempt the TX hook now that it does, or the // guest's header-UART bytes never reach the canvas wire. icReensureSerialHooks(id); } else if (isEsp32Kind(boardKind)) { wireEsp32Board(id, boardKind, pm); } else if (isStm32BoardKind(boardKind)) { const bridge = new Stm32Bridge(id, boardKind); // Onboard-LED pin + polarity per board kind. Blue/Black Pill drive PC13 // active-LOW; the F4 Discovery / Olimex / Netduino boards drive their LED // active-HIGH on a different port pin (see STM32_LED). const ledCfg = STM32_LED[boardKind] ?? { pin: 'PC13', activeLow: true }; const ledLinear = stm32PinNameToLinear(ledCfg.pin); bridge.onSerialData = serialCallback; bridge.onPinChange = (gpioPin, state) => { const boardPm = pinManagerMap.get(id); if (boardPm) boardPm.triggerPinChange(gpioPin, state, 'mcu'); if (gpioPin === ledLinear) { const dom = document.getElementById(id) as (HTMLElement & { led?: boolean }) | null; if (dom && 'led' in dom) dom.led = ledCfg.activeLow ? !state : !!state; } }; bridge.onPinChangeWithTime = getOscilloscopeCallback(id); // Record the guest's internal pull so NetlistBuilder stamps the weak // resistor; the connector then drives the pin from the solved circuit. bridge.onPinPull = makePinPullHandler(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 } : {}) }; }); }; stm32BridgeMap.set(id, bridge); // Shim so PartSimulationRegistry parts (I2C displays, sensors, SPI // panels) attach to this STM32 the same way they do on ESP32. simulatorMap.set(id, new Stm32BridgeShim(bridge, pm)); } 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); // ── Attach a PIO bus peripheral if a factory supports this board. // The pro overlay registers a CYW43 WiFi peripheral for 'pi-pico-w' // (paid feature); OSS has no factory, so this is a no-op and a Pico W // simulates as a plain Pico. The peripheral owns its own WS bridge and // surfaces WiFi status via setBoardWifiStatus(). if (sim instanceof RP2040Simulator) { sim.attachPioPeripheral(boardKind, id); } else if (isProBoardSimulator(sim)) { (sim as { attachPioPeripheral?: (k: string, i: string) => void }).attachPioPeripheral?.( boardKind, id, ); } } 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; // Keep `simulator` in sync with `activeBoardId`. setActiveBoardId is the // only other place that promotes a board, and it sets BOTH — if addBoard // promotes a board (first board, or the active one was removed) without // syncing the simulator, s.simulator stays pointed at the previous board. // Parts that read s.simulator (SPI displays like ILI9341, which attach // `spi.onByte` to the active simulator) then wire onto the wrong board's // bus and never receive data — the "boards[] ESP32 TFT renders black" // bug. When nextActive is unchanged this is a no-op (same reference). return { boards: [...s.boards, newBoard], activeBoardId: nextActive, simulator: simulatorMap.get(nextActive) ?? s.simulator, }; }); // Create the editor file group for this board useEditorStore.getState().createFileGroup(`group-${id}`); // If this board is now the active one (it's the first board, or the // previously-active board was removed), point the editor at its file // group too. The canvas board picker calls addBoard directly WITHOUT // setActiveBoardId (which is the only other place that syncs the editor // group), so without this the editor keeps editing the previous/deleted // board's group while compile reads THIS board's group — the code you // type is silently dropped and the board runs its default sketch. if (get().activeBoardId === id) { useEditorStore.getState().setActiveGroup(`group-${id}`); } // Init VFS for QEMU-Linux boards. Overlay boards may declare their // guest home (e.g. '/root' when the guest logs in as root); those // also drop the historic hello.sh sample. if (isPiBoardKind(boardKind)) { const home = getProBoard(boardKind)?.guestHome; useVfsStore .getState() .initBoardVfs(id, home ? { home, withShellSample: false } : undefined); } // ── 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 stm32Bridge = getStm32Bridge(boardId); if (stm32Bridge) { stm32Bridge.disconnect(); stm32BridgeMap.delete(boardId); } // Detach the PIO peripheral (it disconnects its own bridge). const rpSim = getBoardSimulator(boardId); if (rpSim instanceof RP2040Simulator) rpSim.detachPioPeripheral(); else if (isProBoardSimulator(rpSim)) rpSim.detachPioPeripheral?.(); 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, ); // Reconcile the flat `running` mirror. This flag tracks the ACTIVE // board's run state (see startBoard/stopBoard/setActiveBoardId's // `isActive` sync). Removing the active board reassigns // `activeBoardId` above, but used to leave `running` stale — so // deleting the running/active board left the UI stuck in a fake // "running" state, and SimulatorCanvas's auto-start effect (which // treats `running` as a master switch for remote boards) then spun // a sibling board up. Re-derive it from whatever board is active // now (false if none remain). const nextActive = activeBoardId ? boards.find((b) => b.id === activeBoardId) ?? null : null; const running = nextActive ? nextActive.running : false; return { boards, activeBoardId, wires, running }; }); // Clean up file group in editor store if (board) { useEditorStore.getState().deleteFileGroup(board.activeFileGroupId); } // The removed board may have been the active one; activeBoardId was just // reassigned (above) to a remaining board, or null. Re-point the editor's // active file group at whatever board is active now, so the editor never // keeps showing/editing the deleted board's group. const newActiveId = get().activeBoardId; if (newActiveId) { const nb = get().boards.find((b) => b.id === newActiveId); if (nb) useEditorStore.getState().setActiveGroup(nb.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. const patch: Partial = {}; if (b.languageMode && b.languageMode !== 'arduino') patch.languageMode = b.languageMode; if (b.name && b.name.trim()) patch.name = b.name; // P2.4 — restore per-board persisted fields that ride in boards_json. if (b.boardOptions) patch.boardOptions = b.boardOptions; if (b.spiffsFiles) patch.spiffsFiles = b.spiffsFiles; if (b.libraries && b.libraries.length) patch.libraries = b.libraries; if (Object.keys(patch).length > 0) { set((s) => ({ boards: s.boards.map((bb) => (bb.id === b.id ? { ...bb, ...patch } : 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. Normalize the retired ssd1306-i2c / ssd1306-spi // ids (merged into the single auto-detecting `ssd1306`, issues #101/#215) // so old .vlx files and pre-migration snapshots still render and simulate; // the old id's protocol is pinned so behaviour is preserved exactly. const normalizedComponents = payload.components.map((c) => c.metadataId === 'ssd1306-i2c' || c.metadataId === 'ssd1306-spi' ? { ...c, metadataId: 'ssd1306', properties: { protocol: c.metadataId === 'ssd1306-spi' ? 'spi' : 'i2c', ...(c.properties ?? {}), }, } : c, ); setComponents(normalizedComponents); 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: (isPiBoardKind(board.boardKind) ? '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 (isStm32BoardKind(board.boardKind)) { // STM32: send the compiled .elf (base64) to QEMU via the bridge. getStm32Bridge(boardId)?.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 && !isPiBoardKind(board.boardKind)) { 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); } else if (isProBoardSimulator(sim)) { // Overlay-registered board: the overlay owns the whole load // sequence (PIO/peripheral attach, binary format, demo devices). getProBoard(board.boardKind)?.loadFirmware?.(sim, program, { boardKind: board.boardKind, boardId, }); } } 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})`; }); // WiFi compat shim: replace `network`, `ntptime`, `urequests` // with smart stubs BEFORE user main.py imports them. The // picsimlab QEMU fork's esp32_wifi NIC emulation is sufficient // for Arduino's lightweight WiFi.h but not for MicroPython's // full esp_wifi_init path — calling `network.WLAN(STA_IF)` // hangs forever waiting for peripheral status bits QEMU never // sets, tripping the FreeRTOS task watchdog after ~26s. // // Smart stub behaviour (so examples like smart-ui-eyes WORK // end-to-end, not just degrade gracefully): // wlan.isconnected() → True after first 2 calls (simulates // ~1 second connection) // wlan.ifconfig() → plausible LAN IPs // ntptime.settime() → sets machine.RTC to host's current // UTC so localtime() returns real time // urequests.get(url) → returns a Response stub whose .json() // decodes a stubbed payload (weather // for openweathermap URLs, generic {} // otherwise). Backed by client-side // fixtures so the example screens show // useful data instead of "API Error". const now = new Date(); const fakeWeatherCity = 'Simulator City'; const wifiStub = [ 'import sys', 'import json as _json', 'try:', ' import machine as _machine', 'except ImportError:', ' _machine = None', '', 'class _StubWLAN:', ' def __init__(self, *a, **k):', ' self._calls = 0', ' def active(self, on=None): return True', ' def connect(self, ssid=None, pwd=None): pass', ' def disconnect(self): pass', ' def isconnected(self):', ' self._calls += 1', ' return self._calls > 2', ' def ifconfig(self, c=None): return ("10.0.2.15", "255.255.255.0", "10.0.2.2", "10.0.2.3")', ' def config(self, *a, **k): return b"velxio"', ' def status(self, *a): return 1010', ' def scan(self): return []', 'class _StubNetwork:', ' STA_IF = 0', ' AP_IF = 1', ' WLAN = _StubWLAN', 'sys.modules["network"] = _StubNetwork()', '', '# ntptime: pre-load RTC with host UTC so localtime() works.', `_VLX_BOOT_UTC = (${now.getUTCFullYear()}, ${now.getUTCMonth() + 1}, ${now.getUTCDate()}, ${now.getUTCDay() || 7}, ${now.getUTCHours()}, ${now.getUTCMinutes()}, ${now.getUTCSeconds()}, 0)`, 'class _StubNTP:', ' host = "pool.ntp.org"', ' timeout = 1', ' @staticmethod', ' def settime():', ' if _machine is not None:', ' try: _machine.RTC().datetime(_VLX_BOOT_UTC)', ' except Exception: pass', ' @staticmethod', ' def time(): return 0', 'sys.modules["ntptime"] = _StubNTP()', '', '# urequests: fake responses so examples that call HTTP APIs', '# show real-looking data on the OLED instead of "API Error".', `_VLX_WEATHER = {"main": {"temp": 22.5, "humidity": 58, "pressure": 1013}, "weather": [{"main": "Clouds", "description": "partly cloudy"}], "name": "${fakeWeatherCity}", "wind": {"speed": 3.4}}`, 'class _StubResponse:', ' def __init__(self, payload):', ' self._payload = payload', ' self.status_code = 200', ' self.text = _json.dumps(payload)', ' self.content = self.text.encode()', ' def json(self): return self._payload', ' def close(self): pass', ' def __enter__(self): return self', ' def __exit__(self, *a): pass', 'class _StubURequests:', ' @staticmethod', ' def _route(url):', ' u = url.lower()', ' if "openweathermap" in u or "weather" in u: return _VLX_WEATHER', ' if "ipify" in u or "myip" in u: return {"ip": "10.0.2.15"}', ' if "worldtimeapi" in u: return {"datetime": "2026-05-25T00:00:00+00:00"}', ' return {}', ' @staticmethod', ' def get(url, *a, **k): return _StubResponse(_StubURequests._route(url))', ' @staticmethod', ' def post(url, *a, **k): return _StubResponse({"ok": True})', ' @staticmethod', ' def head(url, *a, **k): return _StubResponse({})', 'sys.modules["urequests"] = _StubURequests()', 'sys.modules["requests"] = _StubURequests()', ].join('\n'); const prelude = wifiStub + '\n' + (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; // (Re)attach the PIO peripheral before loading firmware. An example // deep-link adds the board during render, which can race the pro // overlay's async mountPro that installs the CYW43 factory — so the // board-add attach returned null and a paid user's Pico W would boot // the plain firmware (no `network` -> ImportError). attachPioPeripheral // is idempotent; by run time the factory is installed, so a paid user // gets the W peripheral -> the RPI_PICO_W firmware variant. No-op in // OSS (no factory) and for free users (factory returns null). sim.attachPioPeripheral(board.boardKind, boardId); 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 / ESP-IDF for supported boards if (mode === 'micropython' && !BOARD_SUPPORTS_MICROPYTHON.has(board.boardKind)) return; if (mode === 'espidf' && !BOARD_SUPPORTS_ESPIDF.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; // Pro gate (run backstop): catches STM32/Pi boards that entered the // canvas via an example or a loaded project (which bypass the picker's // add gate). Non-paid web users get the upgrade prompt instead of a run. if (boardGateDecision(board.boardKind) === 'block') { triggerProUpgradePrompt(proBoardFeatureName(board.boardKind)); return; } if (isPiBoardKind(board.boardKind)) { // Engine routing: most projects are a Python script driving GPIO and // a screen, and those run in the browser in seconds instead of // booting a Linux guest (a backend process + ~90 s). The detector // lives in the overlay; `enginePinned` lets the user override it. const decision = decideEngine(boardId, board.enginePinned); // `running` is set here (not only by the toolbar): the Linux-mode // button restarts the board directly, and without this the flag // kept whatever the previous run left, so the UI showed Stop for // a board that was not running. set((s) => ({ boards: s.boards.map((b) => b.id === boardId ? { ...b, engineMode: decision.engine, running: true } : b, ), serialMonitorOpen: true, ...(s.activeBoardId === boardId ? { running: true } : {}), })); if (decision.engine === 'instant') { const instant = getInstantEngine(); void instant?.run(boardId).finally(() => { 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 } : {}) }; }); }); } else { 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); } // Built-in bridge peripherals an overlay-registered board declares // (e.g. an on-board I2C keyboard) — no canvas wiring involved. for (const builtIn of getProBoard(board.boardKind)?.builtInSensors ?? []) { sensors.push({ ...builtIn }); } 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(') || // MicroPython patterns — without these the WiFi NIC is never // passed to QEMU, and `network.WLAN(STA_IF)` hangs forever // trying to init a peripheral that doesn't exist, eventually // tripping the FreeRTOS task watchdog (TG1WDT_SYS_RESET). /import\s+network\b/.test(f.content) || /network\.WLAN/.test(f.content), ); } esp32Bridge.wifiEnabled = hasWifi; // microSD — if a card is on the canvas, build a FAT16 image (project // files, plus any paid binary uploads stored on the part) and hand // it to the bridge so the QEMU worker can attach it as an SD-over-SPI // slave. No card -> clear any stale image from a previous run. const sdCard = components.find((c) => c.metadataId === 'microsd-card'); // Overlay-registered boards can declare a BUILT-IN microSD on a // shared SPI bus: attach it even without a card component, and tell // the bridge to CS-gate it so it doesn't eat the display's pixel // stream. A standalone card owns the bus -> no gating. const builtInSdCs = getProBoard(board.boardKind)?.builtInSdCsPin; if (sdCard || builtInSdCs !== undefined) { try { const uploaded = sdCard ? decodeSdFiles(sdCard.properties.sdFiles) : []; const image = buildProjectSdImage(useEditorStore.getState().files, uploaded); esp32Bridge.sdImageB64 = bytesToB64(image); esp32Bridge.sdCsPin = sdCard ? undefined : builtInSdCs; } catch (e) { console.warn('[microsd] SD image build failed:', e); esp32Bridge.sdImageB64 = undefined; esp32Bridge.sdCsPin = undefined; } } else { esp32Bridge.sdImageB64 = undefined; esp32Bridge.sdCsPin = undefined; } // 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 if (isStm32BoardKind(board.boardKind)) { const stm32Bridge = getStm32Bridge(boardId); if (stm32Bridge) { // Pre-register I2C devices (BMP280, MPU6050, SSD1306, …) so the QEMU // worker builds each slave on the bus BEFORE the firmware's Wire // master starts probing. Address-based — no wire resolution needed // (virtual pin = 200 + i2c_addr). Mirrors the ESP32 path. const { components } = get(); const sensors: Array> = []; for (const comp of components) { const i2cDef = I2C_SENSOR_MAP[comp.metadataId]; if (!i2cDef) continue; let addr = i2cDef.defaultAddr; if (i2cDef.addrProp) { const rawAddr = comp.properties[i2cDef.addrProp]; if (rawAddr !== undefined) { if (i2cDef.addrIsBool) { 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 props: Record = { sensor_type: i2cDef.sensorType, pin: 200 + addr, 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); } stm32Bridge.setSensors(sensors); if (!stm32Bridge.hasFirmware() && board.compiledProgram) { stm32Bridge.loadFirmware(board.compiledProgram); } stm32Bridge.connect(); } } else { const rpSim = getBoardSimulator(boardId); rpSim?.start(); // Notify an attached PIO peripheral (the pro CYW43 WiFi co-processor) // that the simulation started, with the board's source files so it can // detect WiFi usage and open its network bridge. No-op in OSS. if (rpSim instanceof RP2040Simulator) { const editorState = useEditorStore.getState(); const rawFiles = editorState.fileGroups[board.activeFileGroupId]; const boardFiles = rawFiles && rawFiles.length > 0 ? rawFiles : editorState.files; rpSim.getPioPeripheral()?.onSimulationStart?.(boardFiles); } } 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 (isPiBoardKind(board.boardKind)) { if (board.engineMode === 'instant') getInstantEngine()?.stop(boardId); else getBoardBridge(boardId)?.disconnect(); } else if (isEsp32Kind(board.boardKind)) { getEsp32Bridge(boardId)?.disconnect(); } else if (isStm32BoardKind(board.boardKind)) { getStm32Bridge(boardId)?.disconnect(); } else { // Stop is "cut power": pressing Run again must boot from setup() // not resume mid-loop, so reset the CPU to PC=0 here. Without // this the AVR keeps its program counter and the next Run picks // up wherever it left off — which is fine for Pause but wrong // for the physical Stop button users expect. getBoardSimulator(boardId)?.reset(); } // Hard reset: clear cached pin states AND notify listeners so // multiplexed displays (7-segment, LED matrix, NeoPixel) clear // the frozen frame they were holding when power was cut, instead // of carrying it into the next run. getBoardPinManager(boardId)?.hardResetPinStates(); 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 (!isPiBoardKind(board.boardKind)) { const sim = getBoardSimulator(boardId); if (sim) { sim.reset(); // Hard reboot: CPU back to PC=0, every pin floats, every // output classification dropped, and listeners notified so // visual components (7-segment, NeoPixel, LCD) clear their // stale frame instead of freezing on whatever was lit. // Same semantics as Stop — both behave like cutting power. getBoardPinManager(boardId)?.hardResetPinStates(); // NOTE: do NOT reassign sim.onSerialData here. sim.reset() // recreates the USART but the new usart.onByteTransmit // already chains through `this.onSerialData`, which is the // wrapper Interconnect installed for cross-board UART. The // previous "re-wire" line was destroying that wrapper and // silently breaking sibling-board serial forwarding after // every Reset press. 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; // Bump hexEpoch so every component part re-attaches with a fresh // closure. Without this, latched per-part state (e.g. an LED's // `burnt` flag after overcurrent) would survive a Reset and the // part would stay dead even after the user fixes the circuit — // only a recompile would clear it. Mirrors restartParts(). return { boards, hexEpoch: s.hexEpoch + 1, // A Reset un-chars any runtime-destroyed parts so a fixed circuit // comes back to life (mirrors the LED's burnt-latch clearing). ...(s.burntComponents.size > 0 ? { burntComponents: new Set() } : {}), ...(isActive ? { running: false, serialOutput: '', serialBaudRate: 0 } : {}), }; }); // Reset interactive sensors (temperature / lux / gas sliders, etc.) back // to their configured defaults so a restart starts from a clean state // instead of freezing on the last slider position the user dragged to. // dispatchSensorUpdate re-injects the default into the running sim (so the // NTC's injected ADC voltage and the SPICE solve both return to 25°C / // 2.5V) and refreshes the panel's cached value; bumping sensorResetNonce // remounts the open SensorControlPanel so its slider snaps back too. const sensorComps = get().components.filter( (c) => c.metadataId && getSensorControl(c.metadataId), ); if (sensorComps.length > 0) { set((s) => ({ components: s.components.map((c) => { const def = getSensorControl(c.metadataId); return def ? { ...c, properties: { ...c.properties, ...def.defaultValues } } : c; }), sensorResetNonce: s.sensorResetNonce + 1, })); for (const c of sensorComps) { dispatchSensorUpdate(c.id, getSensorControl(c.metadataId)!.defaultValues); } } }, // ── Legacy single-board API ─────────────────────────────────────────── boardType: 'arduino-uno', boardPosition: { ...DEFAULT_BOARD_POSITION }, simulator: initialSim, pinManager: legacyPinManager, running: false, compiledHex: null, hexEpoch: 0, sensorResetNonce: 0, burntComponents: new Set(), 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 = createEsp32Bridge(boardId, type as BoardKind); bridge.onSerialData = serialCallback; bridge.onPinChange = (gpioPin, state) => { const boardPm = pinManagerMap.get(boardId); if (boardPm) boardPm.triggerPinChange(gpioPin, state, 'mcu'); }; bridge.onPinChangeWithTime = getOscilloscopeCallback(boardId); 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.onLedcDuty = makeLedcDutyHandler(boardId); bridge.onGpioRouting = makeGpioRoutingHandler(boardId); bridge.onGpioRoutingClear = makeGpioRoutingClearHandler(boardId); bridge.onPinPull = makePinPullHandler(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; // Multi-board flows (addBoard, loadProjectState) already create // sims + register them in simulatorMap, AND Interconnect wraps // sim.onSerialData for cross-board UART forwarding. SimulatorCanvas // runs initSimulator() once on mount as a legacy single-board // "make sure a sim exists for the active board" helper. If we let // it through here when a sim ALREADY exists we wipe simulatorMap, // recreate the sim, and silently drop the Interconnect wrapper — // every cross-board wire stops forwarding bytes (Nano never sees // anything the Uno sends). Skip out early in that case. const existingSim = getBoardSimulator(boardId); if (existingSim) return; 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 = createEsp32Bridge(boardId, boardType as BoardKind); bridge.onSerialData = serialCallback; bridge.onPinChange = (gpioPin, state) => { const boardPm = pinManagerMap.get(boardId); if (boardPm) boardPm.triggerPinChange(gpioPin, state, 'mcu'); }; bridge.onPinChangeWithTime = getOscilloscopeCallback(boardId); 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.onLedcDuty = makeLedcDutyHandler(boardId); bridge.onGpioRouting = makeGpioRoutingHandler(boardId); bridge.onGpioRoutingClear = makeGpioRoutingClearHandler(boardId); bridge.onPinPull = makePinPullHandler(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); }, restartParts: () => set((s) => ({ hexEpoch: s.hexEpoch + 1, burntComponents: new Set() })), markComponentBurnt: (componentId: string) => set((s) => s.burntComponents.has(componentId) ? {} : { burntComponents: new Set(s.burntComponents).add(componentId) }, ), clearBurntComponents: () => set((s) => (s.burntComponents.size === 0 ? {} : { burntComponents: new Set() })), 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) => { const before = get().components.find((c) => c.id === id); const isBbMove = !!before && isBreadboard(before.metadataId) && (updates.x !== undefined || updates.y !== undefined); const dx = isBbMove ? (updates.x ?? before.x) - before.x : 0; const dy = isBbMove ? (updates.y ?? before.y) - before.y : 0; 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); // Reseat ONLY on geometry changes (move/rotate) — the DOM pinInfo is // still valid for those. Property changes that swap the pin SET // (7segment digits, LED flip) re-render asynchronously; reseating // now would read the STALE pinout and seat ghost pins (seen live: // a digits=4 display seated with the 1-digit COM pinout). Those go // through the element's 'pininfo-change' event instead // (DynamicComponent listener). get().reseatComponentOnBreadboard(id); } // A moving breadboard carries its seated parts: shift every part // plugged into it (bb wires) by the same delta AFTER the board has // moved, so each rider's own reseat re-lands on the same holes at // their new location regardless of how fast the drag is. if (isBbMove && (dx !== 0 || dy !== 0)) { const riders = new Set(); for (const w of get().wires) { if (!w.bb) continue; if (w.start.componentId === id) riders.add(w.end.componentId); if (w.end.componentId === id) riders.add(w.start.componentId); } riders.delete(id); for (const rid of riders) { const rc = get().components.find((c) => c.id === rid); if (rc) get().updateComponent(rid, { x: rc.x + dx, y: rc.y + dy }); } } }, reseatComponentOnBreadboard: (id) => { const comp = get().components.find((c) => c.id === id); if (!comp || isBreadboard(comp.metadataId)) return; const seating = computeSeating(comp, get().components); // null = geometry unmeasurable (unmounted DOM) — keep whatever // seating exists rather than tearing out live connections. if (seating === null) return; // Nothing seated and nothing to clear: skip the store write. The // mount-time reseat (DynamicComponent) calls this for EVERY part as // its element becomes measurable — on a project load that would churn // the wires array identity once per off-board component for no change. if ( seating.length === 0 && !get().wires.some((w) => w.bb && (w.start.componentId === id || w.end.componentId === id)) ) { return; } set((state) => { const kept = state.wires.filter( (w) => !(w.bb && (w.start.componentId === id || w.end.componentId === id)), ); const seated: Wire[] = seating.map((s, i) => ({ id: `bbwire-${id}-${i}-${s.holeName}`, start: { componentId: id, pinName: s.pinName, x: s.pinX, y: s.pinY }, end: { componentId: s.bbId, pinName: s.holeName, x: s.holeX, y: s.holeY }, waypoints: [], color: '#000000', bb: true, })); return { wires: [...kept, ...seated] }; }); }, updateComponentState: (id, state) => { set((prevState) => { // No-op guard: this runs per GPIO edge for wire-connected components. // Unconditionally minting a new components array re-rendered every // subscriber (canvas, editor page, console) thousands of times per // second on a fast-toggling sketch — the main cause of the frozen // browser on the ESP32 multiplexed-clock projects. const comp = prevState.components.find((c) => c.id === id); if (!comp || (comp.properties.state === state && comp.properties.value === state)) { return prevState; } return { 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) => { const wip = state.wireInProgress; if (!wip) return state; // Live routed preview: while the wire has no user-placed waypoints, // route start -> cursor so the preview dodges components and other // wires AS THE MOUSE MOVES — the committed shape then matches what // the user was seeing instead of snapping on the final click. // Throttled: between routings the last route keeps rendering, so // the preview trails by at most one throttle window. let routedPreview = wip.routedPreview ?? null; let lastRouteAt = wip.lastRouteAt ?? 0; if (wip.waypoints.length === 0) { const now = Date.now(); if (now - lastRouteAt >= 40) { lastRouteAt = now; routedPreview = routeAroundObstacles( { x: wip.startEndpoint.x, y: wip.startEndpoint.y }, { x, y }, collectComponentObstacles(state.components, [wip.startEndpoint.componentId]), collectWireSegments(state.wires), ); } } else { routedPreview = null; // user is hand-guiding — show their path } return { wireInProgress: { ...wip, currentX: x, currentY: y, routedPreview, lastRouteAt }, }; }), 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; // First-time auto-routing: a direct pin-to-pin wire (no user-placed // waypoints) gets routed around other components AND clear of the // wires already on the canvas. The wire is marked `autoRouted`, so // the post-move pass keeps it clean when parts move; the moment the // user drags a segment the flag is cleared and the shape is theirs. let routed: { x: number; y: number }[] | null = null; if (waypoints.length === 0) { routed = routeAroundObstacles( { x: startEndpoint.x, y: startEndpoint.y }, { x: endpoint.x, y: endpoint.y }, collectComponentObstacles(state.components, [ startEndpoint.componentId, endpoint.componentId, ]), collectWireSegments(state.wires), ); } // Materialise the elbow of the final leg exactly as the live preview // drew it (longer axis first). Without this the committed wire falls // back to the implicit horizontal-first corner and visibly changes // shape the instant the user clicks the destination pin. const last = waypoints.length ? waypoints[waypoints.length - 1] : { x: startEndpoint.x, y: startEndpoint.y }; const elbow = previewElbow(last, endpoint.x, endpoint.y); const newWire: Wire = { id: `wire-${Date.now()}`, start: startEndpoint, end: endpoint, waypoints: normalizeWireWaypoints( { x: startEndpoint.x, y: startEndpoint.y }, routed ?? (elbow ? [...waypoints, elbow] : waypoints), { x: endpoint.x, y: endpoint.y }, ), color: finalColor, // System-owned shape only when the user placed no waypoints — // guided wires are hand-authored from birth. autoRouted: waypoints.length === 0, }; 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 on EVERY side → inner element sits at (+6, +6) // from the wrapper top-left. Earlier code used (+4, +6) — the // 2 px X bias rotated visibly with the component and looked // like wires came off the pins when rotated. Boards are // rendered directly without that wrapper, so no offset. const compX = component ? component.x + 6 : 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 (6,6) on // both axes (padding:4 + border:2). Boards have no wrapper. 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 + 6 : 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 — same (6,6) wrapper offset as start above. 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 + 6 : 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; }); // ── Re-route system-owned wires ────────────────────────────────── // Endpoints just moved (component drag, agent batch, mount settle): // waypoints stored earlier may now cross parts or ride other wires. // Re-route every `autoRouted` wire; hand-authored wires keep their // shape untouched, exactly where the user left them. // // This runs at drag END and on settle timers — never per drag frame // (updateWirePositions handles those and does not route). // // Sequential on purpose: each wire sees the already-re-routed shapes // of the ones before it, which is what lays parallel runs out as a // tidy side-by-side bus instead of a shuffle of overlaps. if (updatedWires.some((w) => w.autoRouted && !w.bb)) { const rectsById = collectComponentRects(state.components); for (let i = 0; i < updatedWires.length; i++) { const wire = updatedWires[i]; if (!wire.autoRouted || wire.bb) continue; const rects = rectsById .filter((r) => r.id !== wire.start.componentId && r.id !== wire.end.componentId) .map((r) => r.rect); const routed = routeAroundObstacles( { x: wire.start.x, y: wire.start.y }, { x: wire.end.x, y: wire.end.y }, rects, collectWireSegments(updatedWires, wire.id), ); // routed === null means the PREVIEW elbow (longer-axis-first) is // clear — so that exact elbow must be materialised. Storing [] // instead renders the implicit horizontal-first corner, a // DIFFERENT elbow the router never checked: three agent wires // shipped crossing a display that way while their checked route // was clean. const elbow = routed === null ? previewElbow( { x: wire.start.x, y: wire.start.y }, wire.end.x, wire.end.y, ) : null; updatedWires[i] = { ...wire, waypoints: normalizeWireWaypoints( { x: wire.start.x, y: wire.start.y }, routed ?? (elbow ? [elbow] : []), { x: wire.end.x, y: wire.end.y }, ), }; } } 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], })); // Recalc this part's wire endpoints once it re-mounts. Without this // a rotated component restored via Ctrl+Z keeps the unrotated wire // coords captured at delete time, so its wires sit off the pins // until the user rotates again (issue #232). rAF waits for the DOM // node so calculatePinPosition can read the wrapper geometry. const recalc = () => get().updateWirePositions(id); if (typeof requestAnimationFrame === 'function') requestAnimationFrame(recalc); else recalc(); }, }); }, 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') => { // applyNow defaults to true: both callers (the wire color palette and the // wire right-click menu) pass the new value and expect it applied — they // do NOT pre-apply via the raw updateWire mutator. The old `applyNow:false` // recorded the change for undo but never executed it, so changing a wire // colour from the UI was a silent no-op (only the keyboard shortcut, which // calls updateWire directly, actually worked). 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)), })), }); }, 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 (isPiBoardKind(board.boardKind)) { 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 (isPiBoardKind(board.boardKind)) { 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), getStm32Bridge: (id: string) => stm32BridgeMap.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); } });