velxio/frontend/src/store/useSimulatorStore.ts

2464 lines
96 KiB
TypeScript
Raw Normal View History

import { create } from 'zustand';
import { AVRSimulator } from '../simulation/AVRSimulator';
import { RP2040Simulator } from '../simulation/RP2040Simulator';
import { Cyw43Bridge } from '../simulation/cyw43';
import { RiscVSimulator } from '../simulation/RiscVSimulator';
import { Esp32C3Simulator } from '../simulation/Esp32C3Simulator';
import { PinManager } from '../simulation/PinManager';
feat(esp32): SignalRouter — model the GPIO Matrix as first-class Replaces the per-peripheral ad-hoc `_ledc_gpio_map` cache with a proper signal-routing abstraction that mirrors the ESP32 SoC's IO_MUX + GPIO Matrix exactly. Same idea as real silicon: signal sources (LEDC channels, RMT, MCPWM, ...) → 40-entry routing table → GPIO pins. Motivation (from user bug report in velxio.dev/project/5218f9e3-136d-43b3-bba1-6cebde21e1a4): two ESP32 servos on a solar-tracker visibly oscillated between two positions instead of moving smoothly when the user changed LDR sliders. Commit 77bf897 patched it (per-channel gpio memo + broadcast guard) but the user requested a proper hardware-fidel architecture, not patches. Backend: * `app/services/signal_router.py` — SignalRouter class. Forward index (gpio → signal_id) + reverse index (signal_id → set of gpios). `replace_snapshot()` returns the diff for the polling- fallback path; future C plugin hook becomes a push without touching this code. * `app/services/esp32_signals.py` — Signal id constants from ESP32 TRM (LEDC HS 72-79, LS 80-87) + `ledc_signal_for_channel()` helper. * `app/services/esp32_worker.py` — `_ledc_gpio_map` is gone; `_refresh_ledc_gpio_map` replaced by `_refresh_signal_routing` which emits `gpio_routing {gpio, signal_id}` events on diff. The 0x5000 LEDC callback and the LEDC poll thread now emit `ledc_duty {channel, duty_pct}` (canonical, no gpio) alongside the legacy `ledc_update {channel, duty, gpio}` for back-compat during rollout. Frontend: * `simulation/SignalRouter.ts` — 1-to-1 TS mirror of the Python class. Same forward + reverse index; same `pinsForSignal` / `updateRouting` / `clearRouting` API. * `simulation/esp32-signals.ts` — Signal id constants, mirror of the Python module. * `simulation/Esp32Bridge.ts` — new `onLedcDuty`, `onGpioRouting`, `onGpioRoutingClear` callbacks; handlers for the new event types. * `store/useSimulatorStore.ts` — `makeLedcDutyHandler` looks up pins via `router.pinsForSignal(ledcSignalForChannel(channel))` and dispatches per pin. `makeGpioRoutingHandler` / `makeGpioRoutingClearHandler` keep the mirror in sync. Per-board `signalRouterMap` parallels `pinManagerMap` in lifecycle. `makeLedcUpdateHandler` (and its memo workaround from 77bf897) stays wired for back-compat during rollout; removed in a follow-up commit once prod is verified stable on the new path. Tests: * `test/backend/unit/test_signal_router.py` (20 tests) covers update/clear semantics, idempotency, multi-pin routing, snapshot diff, channel↔signal-id helpers, and the multi-servo regression scenario. * `frontend/src/__tests__/SignalRouter.test.ts` (17 tests) is the mirror — same scenarios on the TS side. * `frontend/src/__tests__/esp32-multi-servo-gpio-matrix.test.ts` (6 tests) drives the end-to-end SignalRouter handler pipeline, asserts that two servos on GPIO 13/12 via LEDC channels 0/1 move independently (no mirroring), that re-routing carries cleanly, and — critically — that `PinManager.broadcastPwm` is never called. Totals: +700 LOC, 1876 frontend tests pass (was 1853), 278 backend unit tests pass (was 259). Docs: ESP32_EMULATION.md §9.2 rewritten with the new architecture diagram + a runbook for adding future peripherals through the SignalRouter. The C plugin hook in qemu-lcgamboa that would push gpio_out_sel writes synchronously (eliminating the polling race window entirely) is the next step — kept as a follow-up because the polling-fallback path here already resolves the routing before each duty event fires, so the bug is fixed end-to-end. The plugin work removes the race condition fundamentally.
2026-05-17 10:00:53 +07:00
import { SignalRouter } from '../simulation/SignalRouter';
import { ledcSignalForChannel } from '../simulation/esp32-signals';
import {
VirtualDS1307,
VirtualTempSensor,
I2CMemoryDevice,
I2CBusManager,
nullI2CMaster,
} from '../simulation/I2CBusManager';
import type { I2CDevice } from '../simulation/I2CBusManager';
import type { RP2040I2CDevice } from '../simulation/RP2040Simulator';
import type { Wire, WireInProgress, WireEndpoint } from '../types/wire';
import type { BoardKind, BoardInstance, LanguageMode } from '../types/board';
feat(pi): Phase 3.3 — Pi Zero / Pi 1 / Pi 2 armhf simulators Closes the deferred Phase 3.3. Root-causes the Pi 2 "Attempted to kill init" panic as `mount /dev/vda` failing with EINVAL — Debian armmp does not have ext4 builtin (only fuseblk in /proc/filesystems). - qemu_manager: PI_CONFIGS gains raspberry-pi-zero / -1 / -2 entries. All three use the armmp armhf kernel + Cortex-A7 CPU + the mmio virtio transport (arm-32 virt PCI fails -75 due to missing reg DT property). Pi Zero / Pi 1 get the small 1-core / 512 MB profile; Pi 2 gets 4-core / 1 GB. QEMU command builder branches on cfg.bus for virtio-blk-pci vs virtio-blk-device (and serial likewise). - manifest.json: new `raspberry-pi-armhf` image_set wiring three assets (kernel + initramfs + zstd rootfs). - Frontend BoardKind gains the three new kinds + an isPiBoardKind() helper. Replaces the eight scattered `=== 'raspberry-pi-3' || === 'raspberry-pi-4' || === 'raspberry-pi-5'` branches in useSimulatorStore, Interconnect, loadExample, boardProtocols. ComponentRegistry gets three new picker entries. - board-kinds-coverage test: ACCEPTED_UNCOVERED gains the new kinds (backend boards have no canvas examples). The matching armhf build-pi-kernel.sh / build-pi-rootfs.sh changes live in velxio-prod's scripts/ (private overlay) — the upstream kernel build script only knows about arm64; armhf is built in the private repo because the assets ship through the license endpoint. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 04:23:48 +07:00
import { BOARD_SUPPORTS_MICROPYTHON, isPiBoardKind } from '../types/board';
import { calculatePinPosition } from '../utils/pinPositionCalculator';
import { useOscilloscopeStore } from './useOscilloscopeStore';
import { RaspberryPi3Bridge } from '../simulation/RaspberryPi3Bridge';
import { Esp32Bridge } from '../simulation/Esp32Bridge';
import { useEditorStore } from './useEditorStore';
import { useVfsStore } from './useVfsStore';
import { boardPinToNumber, isBoardComponent } from '../utils/boardPinMapping';
import { autoWireColor, DEFAULT_WIRE_COLOR } from '../utils/wireUtils';
import { createSerialBatcher } from './serialBatcher';
feat(multi-board): add wire-aware cross-board interconnect router Fixes the user-reported bug where two RPi Pico W boards wired GP0↔GP1 running SerialPassthrough don't communicate. Replaces the broken broadcast-style cross-board logic in addBoard (only routed AVR↔Pi3B, ignored wires entirely, no RP2040↔anything path) with a wire-aware Interconnect singleton. Architecture: digital pin transitions are the lowest-common-denominator abstraction. Each simulator's hardware peripherals (UART/I2C/SPI) and bit-banging libraries (SoftwareSerial, software I2C) decode the transitions naturally — propagate the pin and the protocols come for free. For cross-process boards (ESP32 backend QEMU, Pi3B QEMU) a byte-level shortcut is additionally enabled on hardware-UART pin pairs to handle high-baud links over WebSocket latency. Implementation: - New simulation/Interconnect.ts singleton subscribes to wire/board changes via the Zustand store. Handlers per tier: browser-sim → pinManager.onPinChange, ESP32 → Esp32Bridge.sendPinEvent, Pi3B → bridge.sendPinEvent. Re-entrancy guard via per-(board,pin) Set. - New utils/boardProtocols.ts classifies pins (uart-tx, i2c-sda, etc.) per board kind, used as optimization hint for the byte shortcut. - types/wire.ts: added signalType field, exports WireSignalType / WireColorMap (fixes a pre-existing TS import error in wireColors). - Deleted the bridgeMap/simulatorMap broadcast forEach blocks in addBoard. Initial board + future boards register with Interconnect via setInterconnectRuntime + store subscription. - PinManager.resetPinStates() helper for test isolation. Tests (16 new files, 96 tests, all passing): - Per-pair × per-protocol matrix: dual-arduino-digital, dual-pico-digital, arduino-pico-digital, triple-pico-digital-chain, dual-arduino-hw-uart, dual-arduino-software-serial, arduino-pico-mixed-uart, arduino-esp32-uart, dual-esp32-uart, pi3-pico-uart, arduino-pico-i2c, arduino-arduino-spi, interconnect-routing, dual-arduino-multi-protocol (UART+I2C+SPI+ digital + concurrent), dual-pico-multi-protocol (UART0+UART1 alt+ I2C0+I2C1+SPI0+digital + 3-Pico star topology) - Updated dual-pico-serial-passthrough to assert correct behaviour - Backend test/multi_board_esp32/test_dual_esp32_serial.py for two real QEMU instances (skip-graceful when lcgamboa lib absent) Verified: 1107/1107 tests pass, zero regressions, vite build OK. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-26 05:47:28 +07:00
import {
bindBoard as icBindBoard,
unbindBoard as icUnbindBoard,
updateWires as icUpdateWires,
setInterconnectRuntime,
} from '../simulation/Interconnect';
// ── Sensor pre-registration ──────────────────────────────────────────────────
// Maps component metadataId → { sensorType, dataPinName, propertyKeys }
// Used to pre-register sensors in the start_esp32 payload so the QEMU worker
// has them ready before the firmware starts executing (prevents race conditions).
const SENSOR_COMPONENT_MAP: Record<
string,
{
sensorType: string;
dataPinName: string;
propertyKeys: string[];
extraPins?: Record<string, string>; // 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<BoardType, string> = {
'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<BoardType, string> = {
'arduino-uno': 'Arduino Uno',
'arduino-nano': 'Arduino Nano',
'arduino-mega': 'Arduino Mega 2560',
'raspberry-pi-pico': 'Raspberry Pi Pico',
};
export const DEFAULT_BOARD_POSITION = { x: 50, y: 50 };
export const ARDUINO_POSITION = DEFAULT_BOARD_POSITION;
// ── Lightweight shim wrapping Esp32Bridge so component simulations (DHT22, etc.)
// can call setPinState / pinManager just like they would on a local simulator. ──
class Esp32BridgeShim {
pinManager: PinManager;
onSerialData: ((ch: string) => void) | null = null;
onPinChangeWithTime: ((pin: number, state: boolean, timeMs: number) => void) | null = null;
onBaudRateChange: ((baud: number) => void) | null = null;
private bridge: Esp32Bridge;
/**
* Cross-board I2C surface — see AVRSimulator / RP2040Simulator for
* the canonical pattern. ESP32 sketches run in backend QEMU, so the
* "primary" I2C path goes through the backend's libqemu-xtensa I2C
* slaves and reaches the frontend as `i2c_event` / `i2c_transaction`
* WebSocket messages. But virtual devices attached to the ESP32
* board on the canvas also live frontend-side as I2CDevice instances
* — and Interconnect's bridge mechanism needs to reach them when a
* peer board's master tries to read across an SDA+SCL wire. So we
* expose an I2CBusManager whose local devices mirror what
* ProtocolParts registers via `registerSensor`. The peer-master
* direction works through this bus; the ESP32-master direction
* still flows through the backend (where the firmware runs).
*/
private i2cBusInstance: I2CBusManager;
constructor(bridge: Esp32Bridge, pm: PinManager) {
this.bridge = bridge;
this.pinManager = pm;
this.i2cBusInstance = new I2CBusManager(nullI2CMaster());
feat(i2c): cross-board bridging across all velxio boards (AVR/RP2040/ESP32 xtensa+riscv) Closes the remaining gaps in cross-board I2C so any topology of supported boards (Uno↔ESP32, two ESP32s, Uno↔Uno↔Uno, ESP32-C3 connected to anything, etc.) works end-to-end with all I2C components including write-only sinks (SSD1306, PCF8574, LCD-I2C). Implementation (6 phases): 1. **BFS routing in I2CBusManager**: connectToSlave + handleExternalConnect walk the bridge graph with a visited Set so multi-hop chains (A↔B↔C with the device on C) resolve transparently. A new forwarder-device shim is installed at intermediate hops so the existing handleExternalWrite/Read/Stop machinery routes through without per-method visited tracking. 2. **Per-peer proxy ownership in Esp32BridgeShim**: replaces the global _proxiedAddrs Set with _proxiedByPeer Map so concurrent bridges to the same ESP32 (e.g. wired to both Uno and Pico) don't wipe each other's proxies on teardown. Interconnect's per-wire teardown calls clearProxiesForPeer(peerBus) instead of clearAllProxies. 3. **BFS-aware proxy sync**: syncProxyFromPeer now walks the peer bus + its transitive bridges, so an ESP32 sees devices on boards two or more hops away. _peerDeviceLookup keeps a flat addr → device map for write-forwarding and resync. 4. **Periodic resync (250 ms)**: Esp32BridgeShim runs a setInterval while any proxy is live, re-dumping each device with dumpRegisters() and pushing updateProxyI2c only when an XOR- stride hash changes. This keeps RTC time advancing visible to ESP32 firmware without flooding the WS pipe with static calibration dumps. Hash is primed during initial sync so the first tick doesn't push a redundant identical buffer. 5. **Write-forwarding ProxySlave → peer**: backend ProxySlave buffers write bytes during the transaction and emits a `proxy_i2c_complete` event on STOP / repeated-START. Frontend Esp32Bridge dispatches the event to a new onProxyI2cComplete callback; the shim replays the byte sequence on the actual peer I2CDevice via writeByte() + stop(). Makes ESP32 firmware writes to peer SSD1306 actually repaint the OLED, peer PCF8574 latch updates, peer I2CMemoryDevice register mutations propagate. 6. **ESP32-C3 routed as bridge**: Interconnect.isBrowserSim no longer claims c3/xiao-c3/c3-supermini — they were already going through Esp32Bridge per the store's ESP32_RISCV_KINDS routing, but Interconnect was treating them as browser sims which broke proxy install. isEsp32Bridge now correctly includes c3 family + ESP32-S3 + Arduino Nano ESP32. Defensive: addBoard now disposes any existing shim's proxies before overwriting simulatorMap entry so test reruns don't leak timers. Tests: - 4 BFS multi-hop tests (i2c-multi-board-slave-gap.test.ts) - 11 cross-board scenarios + per-peer + write-forward + resync (i2c-esp32-multiboard-bridge.test.ts) - 1 real-firmware E2E for write-forward via QEMU (compile + load + observe proxy_i2c_complete arriving with the byte) - New sketch fixture: esp32_i2c_write_to_peer.ino Result: 90 test files / 1295 tests pass / 0 fail. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 03:51:39 +07:00
// Wire the write-forwarding path: when the backend ProxySlave emits
// a completed write transaction (one full STOP-bounded master phase
// from the ESP32 firmware), look up the peer device on the local
// device lookup map and replay the bytes through its writeByte()
// contract. Peer `I2CDevice` implementations (I2CMemoryDevice,
// VirtualPCF8574, VirtualSSD1306, …) already encode the
// pointer-byte + data semantics; we just hand off the sequence.
bridge.onProxyI2cComplete = (addr: number, data: number[]) => {
const dev = this._peerDeviceLookup.get(addr);
if (!dev) return;
try {
for (const b of data) dev.writeByte(b);
dev.stop?.();
} catch (e) {
console.warn(
`[Esp32BridgeShim] proxy write replay failed for 0x${addr.toString(16)}`,
e,
);
}
};
}
setPinState(pin: number, state: boolean): void {
this.bridge.sendPinEvent(pin, state);
}
getCurrentCycles(): number {
return -1;
}
getClockHz(): number {
return 240_000_000;
}
isRunning(): boolean {
return this.bridge.connected;
}
serialWrite(text: string): void {
this.bridge.sendSerialBytes(Array.from(new TextEncoder().encode(text)));
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
getADC(): any {
return null;
}
/**
* Set ADC value for an ESP32 GPIO pin.
* ESP32 ADC1: GPIO 36-39 → CH0-3, GPIO 32-35 → CH4-7
* Returns true if the pin is a valid ADC pin.
*/
setAdcVoltage(pin: number, voltage: number): boolean {
let channel = -1;
if (pin >= 36 && pin <= 39)
channel = pin - 36; // GPIO 36→CH0, 37→CH1, 38→CH2, 39→CH3
else if (pin >= 32 && pin <= 35) channel = pin - 28; // GPIO 32→CH4, 33→CH5, 34→CH6, 35→CH7
if (channel < 0) return false;
const millivolts = Math.round(voltage * 1000);
this.bridge.setAdc(channel, millivolts);
return true;
}
/**
* Push a 12-bit waveform LUT to QEMU for per-read ADC interpolation.
* Call once per SPICE `.tran` solve; QEMU interpolates at every MMIO
* read against its virtual clock. See `Esp32Bridge.setAdcWaveform`.
*
* `pin` follows the same GPIO→channel mapping as `setAdcVoltage`.
* `samples` are 12-bit raw values (0-4095) aligned on a uniform grid.
*/
setAdcWaveform(pin: number, samples: Uint16Array, periodNs: number): boolean {
let channel = -1;
if (pin >= 36 && pin <= 39) channel = pin - 36;
else if (pin >= 32 && pin <= 35) channel = pin - 28;
if (channel < 0) return false;
this.bridge.setAdcWaveform(channel, samples, periodNs);
return true;
}
getMCU(): null {
return null;
}
start(): void {
/* managed by bridge */
}
stop(): void {
/* managed by bridge */
}
reset(): void {
/* managed by bridge */
}
setSpeed(_s: number): void {
/* no-op */
}
getSpeed(): number {
return 1;
}
loadHex(_hex: string): void {
/* no-op */
}
loadBinary(_b64: string): void {
/* no-op */
}
// ── Generic sensor registration (board-agnostic API) ──────────────────────
// ESP32 delegates sensor protocols to the backend QEMU.
registerSensor(type: string, pin: number, properties: Record<string, unknown>): 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;
}
refactor(spi): unify SPI bus interface across all simulators Previous fix added an ESP32-specific code path inside ili9341Simulation to subscribe to the QEMU worker's spi_event stream. That made the LCD work on ESP32-CAM but left the underlying issue unsolved: every other SPI part (custom chips, future SD-card emulators, the SSD168x ePaper already in the codebase) would also need its own per-board branching. The right shape: every simulator exposes a `.spi` member matching the SAME SpiBusLike interface, and SPI parts hook .spi.onByte without caring which board they're attached to. AVRSimulator already had this — now everything else does too. frontend/src/simulation/SpiBus.ts (new) Defines the contract — `onByte: (mosi) => void | null` plus optional `completeTransfer(miso)`. Documents the single-listener semantics that AVR has had since day one. frontend/src/store/useSimulatorStore.ts Esp32BridgeShim gets a lazy `.spi` getter that wraps bridge.onSpiByte (the per-byte WS event from the QEMU worker). completeTransfer is a no-op because the worker drives MISO via its own _spi_response global. Covers ESP32 (Xtensa), ESP32-S3, ESP32-CAM, ESP32-C3 — every kind that routes through Esp32Bridge. frontend/src/simulation/RP2040Simulator.ts Adds a lazy `.spi` getter that re-routes rp2040.spi[0].onTransmit through the adapter. Default loopback (the prior behaviour) is preserved when no part has accessed `.spi` yet — only consumers that opt in see their handler invoked. Covers Pico and Pico W. frontend/src/simulation/parts/ComplexParts.ts ili9341Simulation no longer has an ESP32 special case. Single code path: `simulator.spi.onByte = handler`. Works on AVR, RP2040, all ESP32 variants. Same pattern is now available to every future SPI part — ssd1306, sd-card, oled, etc. The Esp32Bridge.ts spi_event field-name fix from 6afa62e (msg.data.event instead of the non-existent msg.data.data) stays in place — that's what makes the per-byte stream actually arrive in the bridge. Verified: ILI9341 + ESP32-CAM gallery example renders the live webcam preview after a hard refresh. The same simulation code works on Arduino Uno + ILI9341 (the existing ili9341-test-sketch in example_zip). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 08:36:29 +07:00
/**
* Generic SPI bus adapter — same shape as AVRSimulator.spi so SPI-driven
* parts (ILI9341, SD cards, custom chips…) can hook the bus without
* caring whether they're on AVR, RP2040, or any of the ESP32 variants.
* The MOSI byte arrives via the QEMU worker's spi_event WS message
* (decoded in Esp32Bridge); MISO is driven by the worker's
* `_spi_response` global, so `completeTransfer` is a no-op on ESP32.
*
* Lazy-initialised so the bridge subscription only happens once a part
* actually accesses `.spi`.
*/
private _spiAdapter: { onByte: ((mosi: number) => void) | null;
completeTransfer: (miso: number) => void } | null = null;
get spi(): { onByte: ((mosi: number) => void) | null;
completeTransfer: (miso: number) => void } {
if (!this._spiAdapter) {
const adapter = {
onByte: null as ((mosi: number) => void) | null,
completeTransfer: (_miso: number) => {
/* ESP32 worker drives MISO via _spi_response — no-op here. */
},
};
// Forward every per-byte WS event into whichever handler the part
// installed. Single-listener channel — last writer wins.
this.bridge.onSpiByte = (mosi: number) => {
adapter.onByte?.(mosi);
};
this._spiAdapter = adapter;
}
return this._spiAdapter;
}
updateSensor(pin: number, properties: Record<string, unknown>): 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<number, (data: number[]) => void>();
addI2CTransactionListener(addr: number, fn: (data: number[]) => void): void {
this._i2cTransactionListeners.set(addr, fn);
this.bridge.onI2cTransaction = (a: number, data: number[]) => {
this._i2cTransactionListeners.get(a)?.(data);
};
}
removeI2CTransactionListener(addr: number): void {
this._i2cTransactionListeners.delete(addr);
if (this._i2cTransactionListeners.size === 0) {
this.bridge.onI2cTransaction = null;
}
}
// ── Cross-board I2C bus surface ─────────────────────────────────────────
/**
* Expose the I2CBusManager so Interconnect can install cross-board
* bridges and ProtocolParts can register frontend-side virtual
* devices. ESP32 has 2 hardware I2C buses but we collapse them
* onto a single front-end bus for now — the bus index is ignored.
* Splitting per-bus would require teaching the backend to tag
* `i2c_event` payloads with the originating bus number, which
* the lib worker already does (`bus` field) but the frontend
* shim doesn't yet route on.
*/
getI2CBus(_bus: 0 | 1 = 0): I2CBusManager {
return this.i2cBusInstance;
}
/**
* Register a frontend-side virtual I2C device. This mirrors the
* backend's QEMU-side slave (kept in sync via `registerSensor` /
* `updateSensor`) so peer boards reading across the I2C bridge
* find the device. ProtocolParts calls this on the ESP32 path
* alongside the existing `registerSensor` + `addI2CTransactionListener`.
*/
addI2CDevice(device: I2CDevice, _bus: 0 | 1 = 0): void {
this.i2cBusInstance.addDevice(device);
}
/** Remove a previously-registered virtual device. */
removeI2CDevice(addr: number, _bus: 0 | 1 = 0): void {
this.i2cBusInstance.removeDevice(addr);
}
/**
* Push register snapshots of a peer board's I2C devices into a
* backend `ProxySlave` per address. Called by Interconnect after a
* cross-board I2C bridge is installed so the ESP32 firmware's Wire
* master reads can find the peer's devices inside QEMU.
*
feat(i2c): cross-board bridging across all velxio boards (AVR/RP2040/ESP32 xtensa+riscv) Closes the remaining gaps in cross-board I2C so any topology of supported boards (Uno↔ESP32, two ESP32s, Uno↔Uno↔Uno, ESP32-C3 connected to anything, etc.) works end-to-end with all I2C components including write-only sinks (SSD1306, PCF8574, LCD-I2C). Implementation (6 phases): 1. **BFS routing in I2CBusManager**: connectToSlave + handleExternalConnect walk the bridge graph with a visited Set so multi-hop chains (A↔B↔C with the device on C) resolve transparently. A new forwarder-device shim is installed at intermediate hops so the existing handleExternalWrite/Read/Stop machinery routes through without per-method visited tracking. 2. **Per-peer proxy ownership in Esp32BridgeShim**: replaces the global _proxiedAddrs Set with _proxiedByPeer Map so concurrent bridges to the same ESP32 (e.g. wired to both Uno and Pico) don't wipe each other's proxies on teardown. Interconnect's per-wire teardown calls clearProxiesForPeer(peerBus) instead of clearAllProxies. 3. **BFS-aware proxy sync**: syncProxyFromPeer now walks the peer bus + its transitive bridges, so an ESP32 sees devices on boards two or more hops away. _peerDeviceLookup keeps a flat addr → device map for write-forwarding and resync. 4. **Periodic resync (250 ms)**: Esp32BridgeShim runs a setInterval while any proxy is live, re-dumping each device with dumpRegisters() and pushing updateProxyI2c only when an XOR- stride hash changes. This keeps RTC time advancing visible to ESP32 firmware without flooding the WS pipe with static calibration dumps. Hash is primed during initial sync so the first tick doesn't push a redundant identical buffer. 5. **Write-forwarding ProxySlave → peer**: backend ProxySlave buffers write bytes during the transaction and emits a `proxy_i2c_complete` event on STOP / repeated-START. Frontend Esp32Bridge dispatches the event to a new onProxyI2cComplete callback; the shim replays the byte sequence on the actual peer I2CDevice via writeByte() + stop(). Makes ESP32 firmware writes to peer SSD1306 actually repaint the OLED, peer PCF8574 latch updates, peer I2CMemoryDevice register mutations propagate. 6. **ESP32-C3 routed as bridge**: Interconnect.isBrowserSim no longer claims c3/xiao-c3/c3-supermini — they were already going through Esp32Bridge per the store's ESP32_RISCV_KINDS routing, but Interconnect was treating them as browser sims which broke proxy install. isEsp32Bridge now correctly includes c3 family + ESP32-S3 + Arduino Nano ESP32. Defensive: addBoard now disposes any existing shim's proxies before overwriting simulatorMap entry so test reruns don't leak timers. Tests: - 4 BFS multi-hop tests (i2c-multi-board-slave-gap.test.ts) - 11 cross-board scenarios + per-peer + write-forward + resync (i2c-esp32-multiboard-bridge.test.ts) - 1 real-firmware E2E for write-forward via QEMU (compile + load + observe proxy_i2c_complete arriving with the byte) - New sketch fixture: esp32_i2c_write_to_peer.ino Result: 90 test files / 1295 tests pass / 0 fail. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 03:51:39 +07:00
* 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 {
feat(i2c): cross-board bridging across all velxio boards (AVR/RP2040/ESP32 xtensa+riscv) Closes the remaining gaps in cross-board I2C so any topology of supported boards (Uno↔ESP32, two ESP32s, Uno↔Uno↔Uno, ESP32-C3 connected to anything, etc.) works end-to-end with all I2C components including write-only sinks (SSD1306, PCF8574, LCD-I2C). Implementation (6 phases): 1. **BFS routing in I2CBusManager**: connectToSlave + handleExternalConnect walk the bridge graph with a visited Set so multi-hop chains (A↔B↔C with the device on C) resolve transparently. A new forwarder-device shim is installed at intermediate hops so the existing handleExternalWrite/Read/Stop machinery routes through without per-method visited tracking. 2. **Per-peer proxy ownership in Esp32BridgeShim**: replaces the global _proxiedAddrs Set with _proxiedByPeer Map so concurrent bridges to the same ESP32 (e.g. wired to both Uno and Pico) don't wipe each other's proxies on teardown. Interconnect's per-wire teardown calls clearProxiesForPeer(peerBus) instead of clearAllProxies. 3. **BFS-aware proxy sync**: syncProxyFromPeer now walks the peer bus + its transitive bridges, so an ESP32 sees devices on boards two or more hops away. _peerDeviceLookup keeps a flat addr → device map for write-forwarding and resync. 4. **Periodic resync (250 ms)**: Esp32BridgeShim runs a setInterval while any proxy is live, re-dumping each device with dumpRegisters() and pushing updateProxyI2c only when an XOR- stride hash changes. This keeps RTC time advancing visible to ESP32 firmware without flooding the WS pipe with static calibration dumps. Hash is primed during initial sync so the first tick doesn't push a redundant identical buffer. 5. **Write-forwarding ProxySlave → peer**: backend ProxySlave buffers write bytes during the transaction and emits a `proxy_i2c_complete` event on STOP / repeated-START. Frontend Esp32Bridge dispatches the event to a new onProxyI2cComplete callback; the shim replays the byte sequence on the actual peer I2CDevice via writeByte() + stop(). Makes ESP32 firmware writes to peer SSD1306 actually repaint the OLED, peer PCF8574 latch updates, peer I2CMemoryDevice register mutations propagate. 6. **ESP32-C3 routed as bridge**: Interconnect.isBrowserSim no longer claims c3/xiao-c3/c3-supermini — they were already going through Esp32Bridge per the store's ESP32_RISCV_KINDS routing, but Interconnect was treating them as browser sims which broke proxy install. isEsp32Bridge now correctly includes c3 family + ESP32-S3 + Arduino Nano ESP32. Defensive: addBoard now disposes any existing shim's proxies before overwriting simulatorMap entry so test reruns don't leak timers. Tests: - 4 BFS multi-hop tests (i2c-multi-board-slave-gap.test.ts) - 11 cross-board scenarios + per-peer + write-forward + resync (i2c-esp32-multiboard-bridge.test.ts) - 1 real-firmware E2E for write-forward via QEMU (compile + load + observe proxy_i2c_complete arriving with the byte) - New sketch fixture: esp32_i2c_write_to_peer.ino Result: 90 test files / 1295 tests pass / 0 fail. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 03:51:39 +07:00
const ownedAddrs = this._proxiedByPeer.get(peerBus) ?? new Set<number>();
// 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<I2CBusManager>([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);
}
}
feat(i2c): cross-board bridging across all velxio boards (AVR/RP2040/ESP32 xtensa+riscv) Closes the remaining gaps in cross-board I2C so any topology of supported boards (Uno↔ESP32, two ESP32s, Uno↔Uno↔Uno, ESP32-C3 connected to anything, etc.) works end-to-end with all I2C components including write-only sinks (SSD1306, PCF8574, LCD-I2C). Implementation (6 phases): 1. **BFS routing in I2CBusManager**: connectToSlave + handleExternalConnect walk the bridge graph with a visited Set so multi-hop chains (A↔B↔C with the device on C) resolve transparently. A new forwarder-device shim is installed at intermediate hops so the existing handleExternalWrite/Read/Stop machinery routes through without per-method visited tracking. 2. **Per-peer proxy ownership in Esp32BridgeShim**: replaces the global _proxiedAddrs Set with _proxiedByPeer Map so concurrent bridges to the same ESP32 (e.g. wired to both Uno and Pico) don't wipe each other's proxies on teardown. Interconnect's per-wire teardown calls clearProxiesForPeer(peerBus) instead of clearAllProxies. 3. **BFS-aware proxy sync**: syncProxyFromPeer now walks the peer bus + its transitive bridges, so an ESP32 sees devices on boards two or more hops away. _peerDeviceLookup keeps a flat addr → device map for write-forwarding and resync. 4. **Periodic resync (250 ms)**: Esp32BridgeShim runs a setInterval while any proxy is live, re-dumping each device with dumpRegisters() and pushing updateProxyI2c only when an XOR- stride hash changes. This keeps RTC time advancing visible to ESP32 firmware without flooding the WS pipe with static calibration dumps. Hash is primed during initial sync so the first tick doesn't push a redundant identical buffer. 5. **Write-forwarding ProxySlave → peer**: backend ProxySlave buffers write bytes during the transaction and emits a `proxy_i2c_complete` event on STOP / repeated-START. Frontend Esp32Bridge dispatches the event to a new onProxyI2cComplete callback; the shim replays the byte sequence on the actual peer I2CDevice via writeByte() + stop(). Makes ESP32 firmware writes to peer SSD1306 actually repaint the OLED, peer PCF8574 latch updates, peer I2CMemoryDevice register mutations propagate. 6. **ESP32-C3 routed as bridge**: Interconnect.isBrowserSim no longer claims c3/xiao-c3/c3-supermini — they were already going through Esp32Bridge per the store's ESP32_RISCV_KINDS routing, but Interconnect was treating them as browser sims which broke proxy install. isEsp32Bridge now correctly includes c3 family + ESP32-S3 + Arduino Nano ESP32. Defensive: addBoard now disposes any existing shim's proxies before overwriting simulatorMap entry so test reruns don't leak timers. Tests: - 4 BFS multi-hop tests (i2c-multi-board-slave-gap.test.ts) - 11 cross-board scenarios + per-peer + write-forward + resync (i2c-esp32-multiboard-bridge.test.ts) - 1 real-firmware E2E for write-forward via QEMU (compile + load + observe proxy_i2c_complete arriving with the byte) - New sketch fixture: esp32_i2c_write_to_peer.ino Result: 90 test files / 1295 tests pass / 0 fail. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 03:51:39 +07:00
this._proxiedByPeer.delete(peerBus);
this._stopResyncTimerIfIdle();
}
feat(i2c): cross-board bridging across all velxio boards (AVR/RP2040/ESP32 xtensa+riscv) Closes the remaining gaps in cross-board I2C so any topology of supported boards (Uno↔ESP32, two ESP32s, Uno↔Uno↔Uno, ESP32-C3 connected to anything, etc.) works end-to-end with all I2C components including write-only sinks (SSD1306, PCF8574, LCD-I2C). Implementation (6 phases): 1. **BFS routing in I2CBusManager**: connectToSlave + handleExternalConnect walk the bridge graph with a visited Set so multi-hop chains (A↔B↔C with the device on C) resolve transparently. A new forwarder-device shim is installed at intermediate hops so the existing handleExternalWrite/Read/Stop machinery routes through without per-method visited tracking. 2. **Per-peer proxy ownership in Esp32BridgeShim**: replaces the global _proxiedAddrs Set with _proxiedByPeer Map so concurrent bridges to the same ESP32 (e.g. wired to both Uno and Pico) don't wipe each other's proxies on teardown. Interconnect's per-wire teardown calls clearProxiesForPeer(peerBus) instead of clearAllProxies. 3. **BFS-aware proxy sync**: syncProxyFromPeer now walks the peer bus + its transitive bridges, so an ESP32 sees devices on boards two or more hops away. _peerDeviceLookup keeps a flat addr → device map for write-forwarding and resync. 4. **Periodic resync (250 ms)**: Esp32BridgeShim runs a setInterval while any proxy is live, re-dumping each device with dumpRegisters() and pushing updateProxyI2c only when an XOR- stride hash changes. This keeps RTC time advancing visible to ESP32 firmware without flooding the WS pipe with static calibration dumps. Hash is primed during initial sync so the first tick doesn't push a redundant identical buffer. 5. **Write-forwarding ProxySlave → peer**: backend ProxySlave buffers write bytes during the transaction and emits a `proxy_i2c_complete` event on STOP / repeated-START. Frontend Esp32Bridge dispatches the event to a new onProxyI2cComplete callback; the shim replays the byte sequence on the actual peer I2CDevice via writeByte() + stop(). Makes ESP32 firmware writes to peer SSD1306 actually repaint the OLED, peer PCF8574 latch updates, peer I2CMemoryDevice register mutations propagate. 6. **ESP32-C3 routed as bridge**: Interconnect.isBrowserSim no longer claims c3/xiao-c3/c3-supermini — they were already going through Esp32Bridge per the store's ESP32_RISCV_KINDS routing, but Interconnect was treating them as browser sims which broke proxy install. isEsp32Bridge now correctly includes c3 family + ESP32-S3 + Arduino Nano ESP32. Defensive: addBoard now disposes any existing shim's proxies before overwriting simulatorMap entry so test reruns don't leak timers. Tests: - 4 BFS multi-hop tests (i2c-multi-board-slave-gap.test.ts) - 11 cross-board scenarios + per-peer + write-forward + resync (i2c-esp32-multiboard-bridge.test.ts) - 1 real-firmware E2E for write-forward via QEMU (compile + load + observe proxy_i2c_complete arriving with the byte) - New sketch fixture: esp32_i2c_write_to_peer.ino Result: 90 test files / 1295 tests pass / 0 fail. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 03:51:39 +07:00
/**
* Tear down EVERY proxy slave we've installed. Used on full board
* stop / disconnect — `clearProxiesForPeer` is preferred for
* single-wire-pair teardowns.
*/
clearAllProxies(): void {
feat(i2c): cross-board bridging across all velxio boards (AVR/RP2040/ESP32 xtensa+riscv) Closes the remaining gaps in cross-board I2C so any topology of supported boards (Uno↔ESP32, two ESP32s, Uno↔Uno↔Uno, ESP32-C3 connected to anything, etc.) works end-to-end with all I2C components including write-only sinks (SSD1306, PCF8574, LCD-I2C). Implementation (6 phases): 1. **BFS routing in I2CBusManager**: connectToSlave + handleExternalConnect walk the bridge graph with a visited Set so multi-hop chains (A↔B↔C with the device on C) resolve transparently. A new forwarder-device shim is installed at intermediate hops so the existing handleExternalWrite/Read/Stop machinery routes through without per-method visited tracking. 2. **Per-peer proxy ownership in Esp32BridgeShim**: replaces the global _proxiedAddrs Set with _proxiedByPeer Map so concurrent bridges to the same ESP32 (e.g. wired to both Uno and Pico) don't wipe each other's proxies on teardown. Interconnect's per-wire teardown calls clearProxiesForPeer(peerBus) instead of clearAllProxies. 3. **BFS-aware proxy sync**: syncProxyFromPeer now walks the peer bus + its transitive bridges, so an ESP32 sees devices on boards two or more hops away. _peerDeviceLookup keeps a flat addr → device map for write-forwarding and resync. 4. **Periodic resync (250 ms)**: Esp32BridgeShim runs a setInterval while any proxy is live, re-dumping each device with dumpRegisters() and pushing updateProxyI2c only when an XOR- stride hash changes. This keeps RTC time advancing visible to ESP32 firmware without flooding the WS pipe with static calibration dumps. Hash is primed during initial sync so the first tick doesn't push a redundant identical buffer. 5. **Write-forwarding ProxySlave → peer**: backend ProxySlave buffers write bytes during the transaction and emits a `proxy_i2c_complete` event on STOP / repeated-START. Frontend Esp32Bridge dispatches the event to a new onProxyI2cComplete callback; the shim replays the byte sequence on the actual peer I2CDevice via writeByte() + stop(). Makes ESP32 firmware writes to peer SSD1306 actually repaint the OLED, peer PCF8574 latch updates, peer I2CMemoryDevice register mutations propagate. 6. **ESP32-C3 routed as bridge**: Interconnect.isBrowserSim no longer claims c3/xiao-c3/c3-supermini — they were already going through Esp32Bridge per the store's ESP32_RISCV_KINDS routing, but Interconnect was treating them as browser sims which broke proxy install. isEsp32Bridge now correctly includes c3 family + ESP32-S3 + Arduino Nano ESP32. Defensive: addBoard now disposes any existing shim's proxies before overwriting simulatorMap entry so test reruns don't leak timers. Tests: - 4 BFS multi-hop tests (i2c-multi-board-slave-gap.test.ts) - 11 cross-board scenarios + per-peer + write-forward + resync (i2c-esp32-multiboard-bridge.test.ts) - 1 real-firmware E2E for write-forward via QEMU (compile + load + observe proxy_i2c_complete arriving with the byte) - New sketch fixture: esp32_i2c_write_to_peer.ino Result: 90 test files / 1295 tests pass / 0 fail. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 03:51:39 +07:00
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<I2CBusManager, Set<number>>();
/** Address → live frontend device, for write-forwarding & periodic resync. */
private _peerDeviceLookup = new Map<number, I2CDevice>();
/** Periodic resync timer — runs while any proxy is live. */
private _resyncTimer: ReturnType<typeof setInterval> | null = null;
/** Cheap hash of the last dumped register set per address, to skip WS pushes when unchanged. */
private _lastDumpHash = new Map<number, number>();
/**
* 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;
}
feat(i2c): cross-board bridging across all velxio boards (AVR/RP2040/ESP32 xtensa+riscv) Closes the remaining gaps in cross-board I2C so any topology of supported boards (Uno↔ESP32, two ESP32s, Uno↔Uno↔Uno, ESP32-C3 connected to anything, etc.) works end-to-end with all I2C components including write-only sinks (SSD1306, PCF8574, LCD-I2C). Implementation (6 phases): 1. **BFS routing in I2CBusManager**: connectToSlave + handleExternalConnect walk the bridge graph with a visited Set so multi-hop chains (A↔B↔C with the device on C) resolve transparently. A new forwarder-device shim is installed at intermediate hops so the existing handleExternalWrite/Read/Stop machinery routes through without per-method visited tracking. 2. **Per-peer proxy ownership in Esp32BridgeShim**: replaces the global _proxiedAddrs Set with _proxiedByPeer Map so concurrent bridges to the same ESP32 (e.g. wired to both Uno and Pico) don't wipe each other's proxies on teardown. Interconnect's per-wire teardown calls clearProxiesForPeer(peerBus) instead of clearAllProxies. 3. **BFS-aware proxy sync**: syncProxyFromPeer now walks the peer bus + its transitive bridges, so an ESP32 sees devices on boards two or more hops away. _peerDeviceLookup keeps a flat addr → device map for write-forwarding and resync. 4. **Periodic resync (250 ms)**: Esp32BridgeShim runs a setInterval while any proxy is live, re-dumping each device with dumpRegisters() and pushing updateProxyI2c only when an XOR- stride hash changes. This keeps RTC time advancing visible to ESP32 firmware without flooding the WS pipe with static calibration dumps. Hash is primed during initial sync so the first tick doesn't push a redundant identical buffer. 5. **Write-forwarding ProxySlave → peer**: backend ProxySlave buffers write bytes during the transaction and emits a `proxy_i2c_complete` event on STOP / repeated-START. Frontend Esp32Bridge dispatches the event to a new onProxyI2cComplete callback; the shim replays the byte sequence on the actual peer I2CDevice via writeByte() + stop(). Makes ESP32 firmware writes to peer SSD1306 actually repaint the OLED, peer PCF8574 latch updates, peer I2CMemoryDevice register mutations propagate. 6. **ESP32-C3 routed as bridge**: Interconnect.isBrowserSim no longer claims c3/xiao-c3/c3-supermini — they were already going through Esp32Bridge per the store's ESP32_RISCV_KINDS routing, but Interconnect was treating them as browser sims which broke proxy install. isEsp32Bridge now correctly includes c3 family + ESP32-S3 + Arduino Nano ESP32. Defensive: addBoard now disposes any existing shim's proxies before overwriting simulatorMap entry so test reruns don't leak timers. Tests: - 4 BFS multi-hop tests (i2c-multi-board-slave-gap.test.ts) - 11 cross-board scenarios + per-peer + write-forward + resync (i2c-esp32-multiboard-bridge.test.ts) - 1 real-firmware E2E for write-forward via QEMU (compile + load + observe proxy_i2c_complete arriving with the byte) - New sketch fixture: esp32_i2c_write_to_peer.ino Result: 90 test files / 1295 tests pass / 0 fail. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 03:51:39 +07:00
private _resyncTick(): void {
// Union of all proxied addresses across peers.
const seen = new Set<number>();
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);
}
}
}
refactor(esp32): retire ledc_update + broadcastPwm + channelGpioMemo The SignalRouter path has been in prod through Phase 2.5 / Phase 3.3 deploys without regressions, so the temporary fallback shipped in commit 77bf897 can come out. Closes #101. Backend (esp32_worker.py + esp32_lib_manager.py): - Stop emitting `ledc_update` from the 0x5000 LEDC callback and from the polling thread. Only `ledc_duty` (channel + duty_pct) and the GPIO matrix routing events ship now. - Drop the channel→gpio reverse-lookup that fed the legacy event. Frontend: - Delete `PinManager.broadcastPwm` and `PinManager.pwmListenerPinCount`. - Delete `makeLedcUpdateHandler` + its `channelGpioMemo`. - Delete `Esp32Bridge.onLedcUpdate` field + the `case 'ledc_update':` message handler + the `LedcUpdate` type. - Strip `this.onLedcUpdate = null` from 14 test mocks. - Rewrite the `does not call broadcastPwm` guard in esp32-multi-servo-gpio-matrix.test.ts to assert the method itself no longer exists on PinManager (stronger regression guard than the spy version, and doesn't need vi). - Remove the `PinManager.broadcastPwm fallback` describe block from esp32-servo-pot.test.ts — every test in it exercised the deleted fallback path. Docs (ESP32_EMULATION.md): - Replace `ledc_update` rows in the events / implementation tables with the SignalRouter trio (`ledc_duty`, `gpio_routing`, `gpio_routing_clear`). - Update the visual flow diagram + the "why this matters" paragraph to past-tense the broadcastPwm bug. Tests: 1886 frontend tests pass (the previously-failing board-kinds-coverage test that needed the new Pi Zero/1/2 kinds is also green). Backend unit suite: 279 pass, the 11 espidf_real_paths prereq failures are environment-dependent (need arduino-cli libs in the local shell) and unrelated. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 09:05:34 +07:00
// ── LEDC duty handler ───────────────────────────────────────────────────
feat(esp32): SignalRouter — model the GPIO Matrix as first-class Replaces the per-peripheral ad-hoc `_ledc_gpio_map` cache with a proper signal-routing abstraction that mirrors the ESP32 SoC's IO_MUX + GPIO Matrix exactly. Same idea as real silicon: signal sources (LEDC channels, RMT, MCPWM, ...) → 40-entry routing table → GPIO pins. Motivation (from user bug report in velxio.dev/project/5218f9e3-136d-43b3-bba1-6cebde21e1a4): two ESP32 servos on a solar-tracker visibly oscillated between two positions instead of moving smoothly when the user changed LDR sliders. Commit 77bf897 patched it (per-channel gpio memo + broadcast guard) but the user requested a proper hardware-fidel architecture, not patches. Backend: * `app/services/signal_router.py` — SignalRouter class. Forward index (gpio → signal_id) + reverse index (signal_id → set of gpios). `replace_snapshot()` returns the diff for the polling- fallback path; future C plugin hook becomes a push without touching this code. * `app/services/esp32_signals.py` — Signal id constants from ESP32 TRM (LEDC HS 72-79, LS 80-87) + `ledc_signal_for_channel()` helper. * `app/services/esp32_worker.py` — `_ledc_gpio_map` is gone; `_refresh_ledc_gpio_map` replaced by `_refresh_signal_routing` which emits `gpio_routing {gpio, signal_id}` events on diff. The 0x5000 LEDC callback and the LEDC poll thread now emit `ledc_duty {channel, duty_pct}` (canonical, no gpio) alongside the legacy `ledc_update {channel, duty, gpio}` for back-compat during rollout. Frontend: * `simulation/SignalRouter.ts` — 1-to-1 TS mirror of the Python class. Same forward + reverse index; same `pinsForSignal` / `updateRouting` / `clearRouting` API. * `simulation/esp32-signals.ts` — Signal id constants, mirror of the Python module. * `simulation/Esp32Bridge.ts` — new `onLedcDuty`, `onGpioRouting`, `onGpioRoutingClear` callbacks; handlers for the new event types. * `store/useSimulatorStore.ts` — `makeLedcDutyHandler` looks up pins via `router.pinsForSignal(ledcSignalForChannel(channel))` and dispatches per pin. `makeGpioRoutingHandler` / `makeGpioRoutingClearHandler` keep the mirror in sync. Per-board `signalRouterMap` parallels `pinManagerMap` in lifecycle. `makeLedcUpdateHandler` (and its memo workaround from 77bf897) stays wired for back-compat during rollout; removed in a follow-up commit once prod is verified stable on the new path. Tests: * `test/backend/unit/test_signal_router.py` (20 tests) covers update/clear semantics, idempotency, multi-pin routing, snapshot diff, channel↔signal-id helpers, and the multi-servo regression scenario. * `frontend/src/__tests__/SignalRouter.test.ts` (17 tests) is the mirror — same scenarios on the TS side. * `frontend/src/__tests__/esp32-multi-servo-gpio-matrix.test.ts` (6 tests) drives the end-to-end SignalRouter handler pipeline, asserts that two servos on GPIO 13/12 via LEDC channels 0/1 move independently (no mirroring), that re-routing carries cleanly, and — critically — that `PinManager.broadcastPwm` is never called. Totals: +700 LOC, 1876 frontend tests pass (was 1853), 278 backend unit tests pass (was 259). Docs: ESP32_EMULATION.md §9.2 rewritten with the new architecture diagram + a runbook for adding future peripherals through the SignalRouter. The C plugin hook in qemu-lcgamboa that would push gpio_out_sel writes synchronously (eliminating the polling race window entirely) is the next step — kept as a follow-up because the polling-fallback path here already resolves the routing before each duty event fires, so the bug is fixed end-to-end. The plugin work removes the race condition fundamentally.
2026-05-17 10:00:53 +07:00
//
refactor(esp32): retire ledc_update + broadcastPwm + channelGpioMemo The SignalRouter path has been in prod through Phase 2.5 / Phase 3.3 deploys without regressions, so the temporary fallback shipped in commit 77bf897 can come out. Closes #101. Backend (esp32_worker.py + esp32_lib_manager.py): - Stop emitting `ledc_update` from the 0x5000 LEDC callback and from the polling thread. Only `ledc_duty` (channel + duty_pct) and the GPIO matrix routing events ship now. - Drop the channel→gpio reverse-lookup that fed the legacy event. Frontend: - Delete `PinManager.broadcastPwm` and `PinManager.pwmListenerPinCount`. - Delete `makeLedcUpdateHandler` + its `channelGpioMemo`. - Delete `Esp32Bridge.onLedcUpdate` field + the `case 'ledc_update':` message handler + the `LedcUpdate` type. - Strip `this.onLedcUpdate = null` from 14 test mocks. - Rewrite the `does not call broadcastPwm` guard in esp32-multi-servo-gpio-matrix.test.ts to assert the method itself no longer exists on PinManager (stronger regression guard than the spy version, and doesn't need vi). - Remove the `PinManager.broadcastPwm fallback` describe block from esp32-servo-pot.test.ts — every test in it exercised the deleted fallback path. Docs (ESP32_EMULATION.md): - Replace `ledc_update` rows in the events / implementation tables with the SignalRouter trio (`ledc_duty`, `gpio_routing`, `gpio_routing_clear`). - Update the visual flow diagram + the "why this matters" paragraph to past-tense the broadcastPwm bug. Tests: 1886 frontend tests pass (the previously-failing board-kinds-coverage test that needed the new Pi Zero/1/2 kinds is also green). Backend unit suite: 279 pass, the 11 espidf_real_paths prereq failures are environment-dependent (need arduino-cli libs in the local shell) and unrelated. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 09:05:34 +07:00
// 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.
feat(esp32): SignalRouter — model the GPIO Matrix as first-class Replaces the per-peripheral ad-hoc `_ledc_gpio_map` cache with a proper signal-routing abstraction that mirrors the ESP32 SoC's IO_MUX + GPIO Matrix exactly. Same idea as real silicon: signal sources (LEDC channels, RMT, MCPWM, ...) → 40-entry routing table → GPIO pins. Motivation (from user bug report in velxio.dev/project/5218f9e3-136d-43b3-bba1-6cebde21e1a4): two ESP32 servos on a solar-tracker visibly oscillated between two positions instead of moving smoothly when the user changed LDR sliders. Commit 77bf897 patched it (per-channel gpio memo + broadcast guard) but the user requested a proper hardware-fidel architecture, not patches. Backend: * `app/services/signal_router.py` — SignalRouter class. Forward index (gpio → signal_id) + reverse index (signal_id → set of gpios). `replace_snapshot()` returns the diff for the polling- fallback path; future C plugin hook becomes a push without touching this code. * `app/services/esp32_signals.py` — Signal id constants from ESP32 TRM (LEDC HS 72-79, LS 80-87) + `ledc_signal_for_channel()` helper. * `app/services/esp32_worker.py` — `_ledc_gpio_map` is gone; `_refresh_ledc_gpio_map` replaced by `_refresh_signal_routing` which emits `gpio_routing {gpio, signal_id}` events on diff. The 0x5000 LEDC callback and the LEDC poll thread now emit `ledc_duty {channel, duty_pct}` (canonical, no gpio) alongside the legacy `ledc_update {channel, duty, gpio}` for back-compat during rollout. Frontend: * `simulation/SignalRouter.ts` — 1-to-1 TS mirror of the Python class. Same forward + reverse index; same `pinsForSignal` / `updateRouting` / `clearRouting` API. * `simulation/esp32-signals.ts` — Signal id constants, mirror of the Python module. * `simulation/Esp32Bridge.ts` — new `onLedcDuty`, `onGpioRouting`, `onGpioRoutingClear` callbacks; handlers for the new event types. * `store/useSimulatorStore.ts` — `makeLedcDutyHandler` looks up pins via `router.pinsForSignal(ledcSignalForChannel(channel))` and dispatches per pin. `makeGpioRoutingHandler` / `makeGpioRoutingClearHandler` keep the mirror in sync. Per-board `signalRouterMap` parallels `pinManagerMap` in lifecycle. `makeLedcUpdateHandler` (and its memo workaround from 77bf897) stays wired for back-compat during rollout; removed in a follow-up commit once prod is verified stable on the new path. Tests: * `test/backend/unit/test_signal_router.py` (20 tests) covers update/clear semantics, idempotency, multi-pin routing, snapshot diff, channel↔signal-id helpers, and the multi-servo regression scenario. * `frontend/src/__tests__/SignalRouter.test.ts` (17 tests) is the mirror — same scenarios on the TS side. * `frontend/src/__tests__/esp32-multi-servo-gpio-matrix.test.ts` (6 tests) drives the end-to-end SignalRouter handler pipeline, asserts that two servos on GPIO 13/12 via LEDC channels 0/1 move independently (no mirroring), that re-routing carries cleanly, and — critically — that `PinManager.broadcastPwm` is never called. Totals: +700 LOC, 1876 frontend tests pass (was 1853), 278 backend unit tests pass (was 259). Docs: ESP32_EMULATION.md §9.2 rewritten with the new architecture diagram + a runbook for adding future peripherals through the SignalRouter. The C plugin hook in qemu-lcgamboa that would push gpio_out_sel writes synchronously (eliminating the polling race window entirely) is the next step — kept as a follow-up because the polling-fallback path here already resolves the routing before each duty event fires, so the bug is fixed end-to-end. The plugin work removes the race condition fundamentally.
2026-05-17 10:00:53 +07:00
function makeLedcDutyHandler(boardId: string) {
return (duty: { channel: number; duty_pct: number }) => {
const boardPm = pinManagerMap.get(boardId);
const router = signalRouterMap.get(boardId);
if (!boardPm || !router) return;
const dutyCycle = duty.duty_pct / 100;
const signalId = ledcSignalForChannel(duty.channel);
const pins = router.pinsForSignal(signalId);
// Multi-pin routing: one LEDC channel CAN legally drive multiple
// pins via the GPIO Matrix (rare but documented in TRM). Iterate
// all of them — each gets its own updatePwm call.
for (const pin of pins) {
boardPm.updatePwm(pin, dutyCycle);
}
};
}
function makeGpioRoutingHandler(boardId: string) {
return (routing: { gpio: number; signal_id: number }) => {
signalRouterMap.get(boardId)?.updateRouting(routing.gpio, routing.signal_id);
};
}
function makeGpioRoutingClearHandler(boardId: string) {
return (gpio: number) => {
signalRouterMap.get(boardId)?.clearRouting(gpio);
};
}
// ── Runtime Maps (outside Zustand — not serialisable) ─────────────────────
const simulatorMap = new Map<
string,
AVRSimulator | RP2040Simulator | RiscVSimulator | Esp32C3Simulator | Esp32BridgeShim
>();
const pinManagerMap = new Map<string, PinManager>();
feat(esp32): SignalRouter — model the GPIO Matrix as first-class Replaces the per-peripheral ad-hoc `_ledc_gpio_map` cache with a proper signal-routing abstraction that mirrors the ESP32 SoC's IO_MUX + GPIO Matrix exactly. Same idea as real silicon: signal sources (LEDC channels, RMT, MCPWM, ...) → 40-entry routing table → GPIO pins. Motivation (from user bug report in velxio.dev/project/5218f9e3-136d-43b3-bba1-6cebde21e1a4): two ESP32 servos on a solar-tracker visibly oscillated between two positions instead of moving smoothly when the user changed LDR sliders. Commit 77bf897 patched it (per-channel gpio memo + broadcast guard) but the user requested a proper hardware-fidel architecture, not patches. Backend: * `app/services/signal_router.py` — SignalRouter class. Forward index (gpio → signal_id) + reverse index (signal_id → set of gpios). `replace_snapshot()` returns the diff for the polling- fallback path; future C plugin hook becomes a push without touching this code. * `app/services/esp32_signals.py` — Signal id constants from ESP32 TRM (LEDC HS 72-79, LS 80-87) + `ledc_signal_for_channel()` helper. * `app/services/esp32_worker.py` — `_ledc_gpio_map` is gone; `_refresh_ledc_gpio_map` replaced by `_refresh_signal_routing` which emits `gpio_routing {gpio, signal_id}` events on diff. The 0x5000 LEDC callback and the LEDC poll thread now emit `ledc_duty {channel, duty_pct}` (canonical, no gpio) alongside the legacy `ledc_update {channel, duty, gpio}` for back-compat during rollout. Frontend: * `simulation/SignalRouter.ts` — 1-to-1 TS mirror of the Python class. Same forward + reverse index; same `pinsForSignal` / `updateRouting` / `clearRouting` API. * `simulation/esp32-signals.ts` — Signal id constants, mirror of the Python module. * `simulation/Esp32Bridge.ts` — new `onLedcDuty`, `onGpioRouting`, `onGpioRoutingClear` callbacks; handlers for the new event types. * `store/useSimulatorStore.ts` — `makeLedcDutyHandler` looks up pins via `router.pinsForSignal(ledcSignalForChannel(channel))` and dispatches per pin. `makeGpioRoutingHandler` / `makeGpioRoutingClearHandler` keep the mirror in sync. Per-board `signalRouterMap` parallels `pinManagerMap` in lifecycle. `makeLedcUpdateHandler` (and its memo workaround from 77bf897) stays wired for back-compat during rollout; removed in a follow-up commit once prod is verified stable on the new path. Tests: * `test/backend/unit/test_signal_router.py` (20 tests) covers update/clear semantics, idempotency, multi-pin routing, snapshot diff, channel↔signal-id helpers, and the multi-servo regression scenario. * `frontend/src/__tests__/SignalRouter.test.ts` (17 tests) is the mirror — same scenarios on the TS side. * `frontend/src/__tests__/esp32-multi-servo-gpio-matrix.test.ts` (6 tests) drives the end-to-end SignalRouter handler pipeline, asserts that two servos on GPIO 13/12 via LEDC channels 0/1 move independently (no mirroring), that re-routing carries cleanly, and — critically — that `PinManager.broadcastPwm` is never called. Totals: +700 LOC, 1876 frontend tests pass (was 1853), 278 backend unit tests pass (was 259). Docs: ESP32_EMULATION.md §9.2 rewritten with the new architecture diagram + a runbook for adding future peripherals through the SignalRouter. The C plugin hook in qemu-lcgamboa that would push gpio_out_sel writes synchronously (eliminating the polling race window entirely) is the next step — kept as a follow-up because the polling-fallback path here already resolves the routing before each duty event fires, so the bug is fixed end-to-end. The plugin work removes the race condition fundamentally.
2026-05-17 10:00:53 +07:00
// 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<string, SignalRouter>();
const bridgeMap = new Map<string, RaspberryPi3Bridge>();
const esp32BridgeMap = new Map<string, Esp32Bridge>();
// Pico W WiFi (CYW43439) bridge — created lazily, only when boardKind === 'pi-pico-w'.
const cyw43BridgeMap = new Map<string, Cyw43Bridge>();
export const getBoardSimulator = (id: string) => simulatorMap.get(id);
export const getBoardPinManager = (id: string) => pinManagerMap.get(id);
export const getBoardBridge = (id: string) => bridgeMap.get(id);
export const getEsp32Bridge = (id: string) => esp32BridgeMap.get(id);
export const getCyw43Bridge = (id: string) => cyw43BridgeMap.get(id);
// Xtensa-based ESP32 boards — use QEMU bridge (backend)
const ESP32_KINDS = new Set<BoardKind>([
'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<BoardKind>([
'esp32-c3',
'xiao-esp32-c3',
'aitewinrobot-esp32c3-supermini',
]);
function isEsp32Kind(kind: BoardKind): boolean {
return ESP32_KINDS.has(kind) || ESP32_RISCV_KINDS.has(kind);
}
function isRiscVEsp32Kind(kind: BoardKind): boolean {
return ESP32_RISCV_KINDS.has(kind);
}
// ── Component type ────────────────────────────────────────────────────────
interface Component {
id: string;
metadataId: string;
x: number;
y: number;
properties: Record<string, unknown>;
}
feat(canvas): undo/redo command pattern + history slice Adds the foundation for canvas undo/redo. UI wiring, keyboard shortcuts, toolbar buttons and agent-tools refactor land in follow-up commits. useSimulatorStore.ts: - New CanvasCommand type — { description, execute, undo }. - HISTORY_MAX = 50 (oldest entries dropped on overflow). - New state: history[] + historyIndex (-1 = empty). - New APIs: pushCommand(cmd, {applyNow?}), undo, redo, canUndo, canRedo, clearHistory. - New "recorded" actions that wrap raw mutators with a CanvasCommand: recordAddComponent, recordRemoveComponent, recordMove, recordRotate, recordSetProperty, recordAddWire, recordRemoveWire, recordUpdateWire. - recordRemoveComponent captures both the component AND any wires that cascade with it, so undo restores both atomically. - recordMove also re-runs updateWirePositions on undo/redo so wire endpoints follow the component back/forward. - setComponents and setWires (project-load / clear paths) now call clearHistory inline — leaving stale commands pointing at IDs that no longer exist would crash on undo. Why custom Command pattern over zundo / travels: - The store has 30+ ephemeral fields (simulator instances, serialOutput growing byte-by-byte, hexEpoch counter, wireInProgress that ticks 60×/s on drag). Snapshot/diff middleware would either burn memory tracking them or need a fragile partialize allow-list. - Per-op descriptions ("Add LED", "Move resistor") for tooltips come for free with this approach; zundo/travels would need to infer them. Tests: 15/15 in src/__tests__/undo-redo.test.ts — covers cap-at-50, redo-truncation, cascade undo of remove-component, move/rotate/property round trips, bulk-setter clearing. Raw mutators (addComponent / removeComponent / updateComponent / addWire / removeWire / updateWire) are unchanged. UI handlers can keep using them during live drags for preview frames without spamming history; the record* actions are what drag-end, click-finish and agent tools should call going forward. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 22:03:17 +07:00
// ── 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;
feat: persist multi-board projects + add auto-save The project save/load pipeline only persisted a single `board_type`, so multi-board workspaces silently lost every board except the active one on save, and wires referencing the dropped boards' IDs orphaned to the canvas corner on reload. An audit of the production backup found 74/306 projects (24%) with at least one orphaned wire and 174/301 non-trivial projects whose code was still the default Blink template — strong signal that users save once and never re-save. Backend - Add `boards_json` column on `projects` with idempotent ALTER TABLE in the lifespan migration list. - New `FileGroup` schema + `file_groups` array on ProjectCreate/Update/Response. Legacy `files`/`code` kept for back-compat. - `project_files.py` now uses `{pid}/{groupId}/{filename}` subdirs via `read_groups`/`write_groups`. Legacy flat layouts are auto-promoted on read; legacy single-list `files` only updates the active group, leaving other boards' files intact. - `_persist_files_from_body` honors file_groups → files → code priority. Frontend - `useSimulatorStore.addBoard` accepts an optional `explicitId` so saved board IDs can be restored verbatim (wires reference IDs literally). - New `loadProjectState({boards, fileGroups, components, wires, activeBoardId})` action: tears down current boards, recreates from the payload, restores file groups atomically, recalculates wire positions on the next frame, and refreshes the Interconnect. - `useEditorStore.replaceFileGroups` for atomic multi-group restore. - `SaveProjectModal` and `ProjectByIdPage`/`ProjectPage` now go through `buildSavePayload` / `buildLoadPayload` (handles pre-backfill projects by synthesising a default board from `board_type`). Auto-save (#useAutoSaveProject hook) - 2.5s debounced silent PUT triggered ONLY when an authenticated user has a `currentProject` with a UUID. State hash detects real changes vs. UI-only churn; baseline is reset on project load so the just-loaded state isn't immediately re-saved. - `beforeunload` flush via `fetch keepalive: true` (supports PUT + credentials, survives unload). - Compact status indicator in `AppHeader` (idle/dirty/saving/saved/error). Backfill script (one-off, idempotent) - `backend/scripts/backfill_boards_2026_05.py` populates `boards_json` for legacy projects. Heuristic per project, based on which board IDs the wires reference: Case A — wires only ref 'arduino-uno' but board_type ≠ uno: rename id→board_type and rewrite wire endpoints. Case B — single-board normal: keep verbatim. Case C — multi-board: recreate one board per distinct ref, infer kind by stripping trailing -N suffix. Also moves any flat files into the active board's group subdir. Stdlib-only, runs from host or `docker exec`. Docker - `Dockerfile.standalone` now copies `backend/scripts/` into the image so the backfill is callable via `docker exec velxio-app python /app/scripts/backfill_boards_2026_05.py --apply`. Verified locally on the restored production backup (363 projects): 33 Case A, 316 Case B, 14 Case C, 135 wire endpoints renamed, 0 orphans. Re-running the script after apply skips all 363 (idempotent). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 23:43:33 +07:00
addBoard: (boardKind: BoardKind, x: number, y: number, explicitId?: string) => string;
removeBoard: (boardId: string) => void;
feat: persist multi-board projects + add auto-save The project save/load pipeline only persisted a single `board_type`, so multi-board workspaces silently lost every board except the active one on save, and wires referencing the dropped boards' IDs orphaned to the canvas corner on reload. An audit of the production backup found 74/306 projects (24%) with at least one orphaned wire and 174/301 non-trivial projects whose code was still the default Blink template — strong signal that users save once and never re-save. Backend - Add `boards_json` column on `projects` with idempotent ALTER TABLE in the lifespan migration list. - New `FileGroup` schema + `file_groups` array on ProjectCreate/Update/Response. Legacy `files`/`code` kept for back-compat. - `project_files.py` now uses `{pid}/{groupId}/{filename}` subdirs via `read_groups`/`write_groups`. Legacy flat layouts are auto-promoted on read; legacy single-list `files` only updates the active group, leaving other boards' files intact. - `_persist_files_from_body` honors file_groups → files → code priority. Frontend - `useSimulatorStore.addBoard` accepts an optional `explicitId` so saved board IDs can be restored verbatim (wires reference IDs literally). - New `loadProjectState({boards, fileGroups, components, wires, activeBoardId})` action: tears down current boards, recreates from the payload, restores file groups atomically, recalculates wire positions on the next frame, and refreshes the Interconnect. - `useEditorStore.replaceFileGroups` for atomic multi-group restore. - `SaveProjectModal` and `ProjectByIdPage`/`ProjectPage` now go through `buildSavePayload` / `buildLoadPayload` (handles pre-backfill projects by synthesising a default board from `board_type`). Auto-save (#useAutoSaveProject hook) - 2.5s debounced silent PUT triggered ONLY when an authenticated user has a `currentProject` with a UUID. State hash detects real changes vs. UI-only churn; baseline is reset on project load so the just-loaded state isn't immediately re-saved. - `beforeunload` flush via `fetch keepalive: true` (supports PUT + credentials, survives unload). - Compact status indicator in `AppHeader` (idle/dirty/saving/saved/error). Backfill script (one-off, idempotent) - `backend/scripts/backfill_boards_2026_05.py` populates `boards_json` for legacy projects. Heuristic per project, based on which board IDs the wires reference: Case A — wires only ref 'arduino-uno' but board_type ≠ uno: rename id→board_type and rewrite wire endpoints. Case B — single-board normal: keep verbatim. Case C — multi-board: recreate one board per distinct ref, infer kind by stripping trailing -N suffix. Also moves any flat files into the active board's group subdir. Stdlib-only, runs from host or `docker exec`. Docker - `Dockerfile.standalone` now copies `backend/scripts/` into the image so the backfill is callable via `docker exec velxio-app python /app/scripts/backfill_boards_2026_05.py --apply`. Verified locally on the restored production backup (363 projects): 33 Case A, 316 Case B, 14 Case C, 135 wire endpoints renamed, 0 orphans. Re-running the script after apply skips all 363 (idempotent). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 23:43:33 +07:00
/** 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<string, { name: string; content: string }[]>;
components: Component[];
wires: Wire[];
activeBoardId: string | null;
}) => void;
updateBoard: (boardId: string, updates: Partial<BoardInstance>) => 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<void>;
setBoardLanguageMode: (boardId: string, mode: LanguageMode) => void;
startBoard: (boardId: string) => void;
stopBoard: (boardId: string) => void;
resetBoard: (boardId: string) => void;
// ── Legacy single-board API (reads/writes activeBoardId board) ───────────
/** @deprecated use boards[]/activeBoardId directly */
boardType: BoardType;
/** @deprecated use boards[x].x/y */
boardPosition: { x: number; y: number };
/** @deprecated use getBoardSimulator(activeBoardId) */
simulator:
| AVRSimulator
| RP2040Simulator
| RiscVSimulator
| Esp32C3Simulator
| Esp32BridgeShim
| null;
/** @deprecated use getBoardPinManager(activeBoardId) */
pinManager: PinManager;
running: boolean;
compiledHex: string | null;
hexEpoch: number;
serialOutput: string;
serialBaudRate: number;
serialMonitorOpen: boolean;
/** @deprecated use getBoardBridge(activeBoardId) */
remoteConnected: boolean;
remoteSocket: WebSocket | null;
setBoardType: (type: BoardType) => void;
initSimulator: () => void;
loadHex: (hex: string) => void;
loadBinary: (base64: string) => void;
startSimulation: () => void;
stopSimulation: () => void;
resetSimulation: () => void;
setCompiledHex: (hex: string) => void;
setCompiledBinary: (base64: string) => void;
setRunning: (running: boolean) => void;
connectRemoteSimulator: (clientId: string) => void;
disconnectRemoteSimulator: () => void;
sendRemotePinEvent: (pin: string, state: number) => void;
// ── ESP32 crash notification ─────────────────────────────────────────────
esp32CrashBoardId: string | null;
dismissEsp32Crash: () => void;
// ── Components ──────────────────────────────────────────────────────────
components: Component[];
addComponent: (component: Component) => void;
removeComponent: (id: string) => void;
updateComponent: (id: string, updates: Partial<Component>) => 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<Wire>) => 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;
feat(canvas): undo/redo command pattern + history slice Adds the foundation for canvas undo/redo. UI wiring, keyboard shortcuts, toolbar buttons and agent-tools refactor land in follow-up commits. useSimulatorStore.ts: - New CanvasCommand type — { description, execute, undo }. - HISTORY_MAX = 50 (oldest entries dropped on overflow). - New state: history[] + historyIndex (-1 = empty). - New APIs: pushCommand(cmd, {applyNow?}), undo, redo, canUndo, canRedo, clearHistory. - New "recorded" actions that wrap raw mutators with a CanvasCommand: recordAddComponent, recordRemoveComponent, recordMove, recordRotate, recordSetProperty, recordAddWire, recordRemoveWire, recordUpdateWire. - recordRemoveComponent captures both the component AND any wires that cascade with it, so undo restores both atomically. - recordMove also re-runs updateWirePositions on undo/redo so wire endpoints follow the component back/forward. - setComponents and setWires (project-load / clear paths) now call clearHistory inline — leaving stale commands pointing at IDs that no longer exist would crash on undo. Why custom Command pattern over zundo / travels: - The store has 30+ ephemeral fields (simulator instances, serialOutput growing byte-by-byte, hexEpoch counter, wireInProgress that ticks 60×/s on drag). Snapshot/diff middleware would either burn memory tracking them or need a fragile partialize allow-list. - Per-op descriptions ("Add LED", "Move resistor") for tooltips come for free with this approach; zundo/travels would need to infer them. Tests: 15/15 in src/__tests__/undo-redo.test.ts — covers cap-at-50, redo-truncation, cascade undo of remove-component, move/rotate/property round trips, bulk-setter clearing. Raw mutators (addComponent / removeComponent / updateComponent / addWire / removeWire / updateWire) are unchanged. UI handlers can keep using them during live drags for preview frames without spamming history; the record* actions are what drag-end, click-finish and agent tools should call going forward. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 22:03:17 +07:00
// ── 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<Wire>,
next: Partial<Wire>,
description?: string,
) => void;
// ── Serial monitor ──────────────────────────────────────────────────────
toggleSerialMonitor: () => void;
serialWrite: (text: string) => void;
serialWriteToBoard: (boardId: string, text: string) => void;
clearSerialOutput: () => void;
clearBoardSerialOutput: (boardId: string) => void;
}
// ── Helper: create a simulator for a given board kind ─────────────────────
function createSimulator(
boardKind: BoardKind,
pm: PinManager,
onSerial: (ch: string) => void,
onBaud: (baud: number) => void,
onPinTime: (pin: number, state: boolean, t: number) => void,
): AVRSimulator | RP2040Simulator | RiscVSimulator | Esp32C3Simulator {
let sim: AVRSimulator | RP2040Simulator | RiscVSimulator | Esp32C3Simulator;
if (boardKind === 'arduino-mega') {
sim = new AVRSimulator(pm, 'mega');
} else if (boardKind === 'attiny85') {
sim = new AVRSimulator(pm, 'tiny85');
} else if (boardKind === 'raspberry-pi-pico' || boardKind === 'pi-pico-w') {
sim = new RP2040Simulator(pm);
} else if (isRiscVEsp32Kind(boardKind)) {
// ESP32-C3 / XIAO-C3 / C3 SuperMini — browser-side RV32IMC emulator
sim = new Esp32C3Simulator(pm);
} else {
// arduino-uno, arduino-nano
sim = new AVRSimulator(pm, 'uno');
}
sim.onSerialData = onSerial;
if (sim instanceof AVRSimulator) sim.onBaudRateChange = onBaud;
sim.onPinChangeWithTime = onPinTime;
return sim;
}
// ── Default initial board (Arduino Uno — same as old behaviour) ───────────
const INITIAL_BOARD_ID = 'arduino-uno';
const INITIAL_BOARD: BoardInstance = {
id: INITIAL_BOARD_ID,
boardKind: 'arduino-uno',
x: DEFAULT_BOARD_POSITION.x,
y: DEFAULT_BOARD_POSITION.y,
running: false,
compiledProgram: null,
serialOutput: '',
serialBaudRate: 0,
serialMonitorOpen: false,
activeFileGroupId: `group-${INITIAL_BOARD_ID}`,
languageMode: 'arduino' as LanguageMode,
};
// ── Serial batching ───────────────────────────────────────────────────────
// USART callbacks fire once per byte. Sketches doing `Serial.println(x)` at
// ~200 Hz emit ~600 bytes/s, and a raw `set()` per byte overwhelms React's
// useSyncExternalStore reconciliation (→ "Maximum update depth exceeded").
// The batcher coalesces chunks per animation frame (≤60 Hz), grouped by board.
const { append: appendSerial } = createSerialBatcher((perBoard) => {
useSimulatorStore.setState((s) => {
let globalOut = s.serialOutput;
const boards = s.boards.map((b) => {
const chunk = perBoard.get(b.id);
if (!chunk) return b;
if (s.activeBoardId === b.id) globalOut += chunk;
return { ...b, serialOutput: b.serialOutput + chunk };
});
return { boards, serialOutput: globalOut };
});
});
// ── Store ─────────────────────────────────────────────────────────────────
export const useSimulatorStore = create<SimulatorState>((set, get) => {
// Initialise runtime objects for the default board
const initialPm = new PinManager();
pinManagerMap.set(INITIAL_BOARD_ID, initialPm);
function getOscilloscopeCallback(boardId: string) {
return (pin: number, state: boolean, timeMs: number) => {
const { channels, pushSample } = useOscilloscopeStore.getState();
for (const ch of channels) {
if (ch.boardId === boardId && ch.pin === pin) pushSample(ch.id, timeMs, state);
}
};
}
const initialSim = createSimulator(
'arduino-uno',
initialPm,
(ch) => appendSerial(INITIAL_BOARD_ID, ch),
(baud) => {
set((s) => {
const boards = s.boards.map((b) =>
b.id === INITIAL_BOARD_ID ? { ...b, serialBaudRate: baud } : b,
);
const isActive = s.activeBoardId === INITIAL_BOARD_ID;
return { boards, ...(isActive ? { serialBaudRate: baud } : {}) };
});
},
getOscilloscopeCallback(INITIAL_BOARD_ID),
);
feat(multi-board): add wire-aware cross-board interconnect router Fixes the user-reported bug where two RPi Pico W boards wired GP0↔GP1 running SerialPassthrough don't communicate. Replaces the broken broadcast-style cross-board logic in addBoard (only routed AVR↔Pi3B, ignored wires entirely, no RP2040↔anything path) with a wire-aware Interconnect singleton. Architecture: digital pin transitions are the lowest-common-denominator abstraction. Each simulator's hardware peripherals (UART/I2C/SPI) and bit-banging libraries (SoftwareSerial, software I2C) decode the transitions naturally — propagate the pin and the protocols come for free. For cross-process boards (ESP32 backend QEMU, Pi3B QEMU) a byte-level shortcut is additionally enabled on hardware-UART pin pairs to handle high-baud links over WebSocket latency. Implementation: - New simulation/Interconnect.ts singleton subscribes to wire/board changes via the Zustand store. Handlers per tier: browser-sim → pinManager.onPinChange, ESP32 → Esp32Bridge.sendPinEvent, Pi3B → bridge.sendPinEvent. Re-entrancy guard via per-(board,pin) Set. - New utils/boardProtocols.ts classifies pins (uart-tx, i2c-sda, etc.) per board kind, used as optimization hint for the byte shortcut. - types/wire.ts: added signalType field, exports WireSignalType / WireColorMap (fixes a pre-existing TS import error in wireColors). - Deleted the bridgeMap/simulatorMap broadcast forEach blocks in addBoard. Initial board + future boards register with Interconnect via setInterconnectRuntime + store subscription. - PinManager.resetPinStates() helper for test isolation. Tests (16 new files, 96 tests, all passing): - Per-pair × per-protocol matrix: dual-arduino-digital, dual-pico-digital, arduino-pico-digital, triple-pico-digital-chain, dual-arduino-hw-uart, dual-arduino-software-serial, arduino-pico-mixed-uart, arduino-esp32-uart, dual-esp32-uart, pi3-pico-uart, arduino-pico-i2c, arduino-arduino-spi, interconnect-routing, dual-arduino-multi-protocol (UART+I2C+SPI+ digital + concurrent), dual-pico-multi-protocol (UART0+UART1 alt+ I2C0+I2C1+SPI0+digital + 3-Pico star topology) - Updated dual-pico-serial-passthrough to assert correct behaviour - Backend test/multi_board_esp32/test_dual_esp32_serial.py for two real QEMU instances (skip-graceful when lcgamboa lib absent) Verified: 1107/1107 tests pass, zero regressions, vite build OK. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-26 05:47:28 +07:00
// 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,
feat: persist multi-board projects + add auto-save The project save/load pipeline only persisted a single `board_type`, so multi-board workspaces silently lost every board except the active one on save, and wires referencing the dropped boards' IDs orphaned to the canvas corner on reload. An audit of the production backup found 74/306 projects (24%) with at least one orphaned wire and 174/301 non-trivial projects whose code was still the default Blink template — strong signal that users save once and never re-save. Backend - Add `boards_json` column on `projects` with idempotent ALTER TABLE in the lifespan migration list. - New `FileGroup` schema + `file_groups` array on ProjectCreate/Update/Response. Legacy `files`/`code` kept for back-compat. - `project_files.py` now uses `{pid}/{groupId}/{filename}` subdirs via `read_groups`/`write_groups`. Legacy flat layouts are auto-promoted on read; legacy single-list `files` only updates the active group, leaving other boards' files intact. - `_persist_files_from_body` honors file_groups → files → code priority. Frontend - `useSimulatorStore.addBoard` accepts an optional `explicitId` so saved board IDs can be restored verbatim (wires reference IDs literally). - New `loadProjectState({boards, fileGroups, components, wires, activeBoardId})` action: tears down current boards, recreates from the payload, restores file groups atomically, recalculates wire positions on the next frame, and refreshes the Interconnect. - `useEditorStore.replaceFileGroups` for atomic multi-group restore. - `SaveProjectModal` and `ProjectByIdPage`/`ProjectPage` now go through `buildSavePayload` / `buildLoadPayload` (handles pre-backfill projects by synthesising a default board from `board_type`). Auto-save (#useAutoSaveProject hook) - 2.5s debounced silent PUT triggered ONLY when an authenticated user has a `currentProject` with a UUID. State hash detects real changes vs. UI-only churn; baseline is reset on project load so the just-loaded state isn't immediately re-saved. - `beforeunload` flush via `fetch keepalive: true` (supports PUT + credentials, survives unload). - Compact status indicator in `AppHeader` (idle/dirty/saving/saved/error). Backfill script (one-off, idempotent) - `backend/scripts/backfill_boards_2026_05.py` populates `boards_json` for legacy projects. Heuristic per project, based on which board IDs the wires reference: Case A — wires only ref 'arduino-uno' but board_type ≠ uno: rename id→board_type and rewrite wire endpoints. Case B — single-board normal: keep verbatim. Case C — multi-board: recreate one board per distinct ref, infer kind by stripping trailing -N suffix. Also moves any flat files into the active board's group subdir. Stdlib-only, runs from host or `docker exec`. Docker - `Dockerfile.standalone` now copies `backend/scripts/` into the image so the backfill is callable via `docker exec velxio-app python /app/scripts/backfill_boards_2026_05.py --apply`. Verified locally on the restored production backup (363 projects): 33 Case A, 316 Case B, 14 Case C, 135 wire endpoints renamed, 0 orphans. Re-running the script after apply skips all 363 (idempotent). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 23:43:33 +07:00
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);
feat(pi): Phase 3.3 — Pi Zero / Pi 1 / Pi 2 armhf simulators Closes the deferred Phase 3.3. Root-causes the Pi 2 "Attempted to kill init" panic as `mount /dev/vda` failing with EINVAL — Debian armmp does not have ext4 builtin (only fuseblk in /proc/filesystems). - qemu_manager: PI_CONFIGS gains raspberry-pi-zero / -1 / -2 entries. All three use the armmp armhf kernel + Cortex-A7 CPU + the mmio virtio transport (arm-32 virt PCI fails -75 due to missing reg DT property). Pi Zero / Pi 1 get the small 1-core / 512 MB profile; Pi 2 gets 4-core / 1 GB. QEMU command builder branches on cfg.bus for virtio-blk-pci vs virtio-blk-device (and serial likewise). - manifest.json: new `raspberry-pi-armhf` image_set wiring three assets (kernel + initramfs + zstd rootfs). - Frontend BoardKind gains the three new kinds + an isPiBoardKind() helper. Replaces the eight scattered `=== 'raspberry-pi-3' || === 'raspberry-pi-4' || === 'raspberry-pi-5'` branches in useSimulatorStore, Interconnect, loadExample, boardProtocols. ComponentRegistry gets three new picker entries. - board-kinds-coverage test: ACCEPTED_UNCOVERED gains the new kinds (backend boards have no canvas examples). The matching armhf build-pi-kernel.sh / build-pi-rootfs.sh changes live in velxio-prod's scripts/ (private overlay) — the upstream kernel build script only knows about arm64; armhf is built in the private repo because the assets ship through the license endpoint. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 04:23:48 +07:00
if (isPiBoardKind(boardKind)) {
feat(pi3 phase 3.1+3.2): Pi 3/4/5 family via PI_CONFIGS Backend: extract per-board config into a PI_CONFIGS dict keyed by board_type. Pi 3/4/5 share the same arm64 image set (kernel + initramfs + rootfs) and differ only in QEMU -cpu and -m: raspberry-pi-3 → cortex-a53 + 1G (BCM2837, ARMv8 64-bit) raspberry-pi-4 → cortex-a72 + 2G (BCM2711, ARMv8 64-bit) raspberry-pi-5 → cortex-a76 + 2G (BCM2712, ARMv8 64-bit) PiInstance now carries board_type so the per-board lookup happens once at start_instance time. Unknown board_type falls back to DEFAULT_PI_BOARD ('raspberry-pi-3') instead of erroring out (for back-compat with older clients). Pre-warm hook walks every unique image_set in PI_CONFIGS so the provider only downloads each set once even when several Pi models are registered. Frontend: - BoardKind union gains 'raspberry-pi-4' and 'raspberry-pi-5'. - BOARD_KIND_LABELS + BOARD_KIND_FQBN entries for both new boards (FQBN null since they use the Pi VFS + Python toolchain like Pi 3). - ComponentRegistry inserts two new component metadata entries cloning the Pi 3 board art with different thumbnail colours. Tag name reused so the same velxio-raspberry-pi-3 web element draws the board on the canvas — the 40-pin GPIO layout is identical across Pi 3/4/5. - boardProtocols.ts: Pi 3/4/5 share the BCM physical→GPIO table (PI3_BCM) since the 40-pin header layout is identical. - loadExample.ts: where 'raspberry-pi-3' is special-cased (VFS ingest, .cpp vs .ino filename), now matches Pi 3/4/5 alike. - Interconnect.isPi3Bridge() recognises all three Pi family members so Arduino↔Pi serial routing keeps working. - RaspberryPi3Bridge constructor gained a boardKind parameter defaulting to 'raspberry-pi-3'. The WebSocket 'start_pi' message now ships the actual board kind so the backend knows which PI_CONFIGS entry to use. - useSimulatorStore.addBoard wires bridge construction for all three Pi family members. Pi Zero/Pi 1/Pi 2 (armhf) come in Phase 3.3 — separate kernel package + armhf rootfs build, no change here. Smoke-tested inside the prod container: Pi 4 (cortex-a72) → reached agetty login on hvc0 Pi 5 (cortex-a76) → reached agetty login on hvc0 Both show 'aarch64' in uname -m.
2026-05-18 20:41:18 +07:00
const bridge = new RaspberryPi3Bridge(id, boardKind);
bridge.onSerialData = (ch: string) => {
serialCallback(ch);
feat(multi-board): add wire-aware cross-board interconnect router Fixes the user-reported bug where two RPi Pico W boards wired GP0↔GP1 running SerialPassthrough don't communicate. Replaces the broken broadcast-style cross-board logic in addBoard (only routed AVR↔Pi3B, ignored wires entirely, no RP2040↔anything path) with a wire-aware Interconnect singleton. Architecture: digital pin transitions are the lowest-common-denominator abstraction. Each simulator's hardware peripherals (UART/I2C/SPI) and bit-banging libraries (SoftwareSerial, software I2C) decode the transitions naturally — propagate the pin and the protocols come for free. For cross-process boards (ESP32 backend QEMU, Pi3B QEMU) a byte-level shortcut is additionally enabled on hardware-UART pin pairs to handle high-baud links over WebSocket latency. Implementation: - New simulation/Interconnect.ts singleton subscribes to wire/board changes via the Zustand store. Handlers per tier: browser-sim → pinManager.onPinChange, ESP32 → Esp32Bridge.sendPinEvent, Pi3B → bridge.sendPinEvent. Re-entrancy guard via per-(board,pin) Set. - New utils/boardProtocols.ts classifies pins (uart-tx, i2c-sda, etc.) per board kind, used as optimization hint for the byte shortcut. - types/wire.ts: added signalType field, exports WireSignalType / WireColorMap (fixes a pre-existing TS import error in wireColors). - Deleted the bridgeMap/simulatorMap broadcast forEach blocks in addBoard. Initial board + future boards register with Interconnect via setInterconnectRuntime + store subscription. - PinManager.resetPinStates() helper for test isolation. Tests (16 new files, 96 tests, all passing): - Per-pair × per-protocol matrix: dual-arduino-digital, dual-pico-digital, arduino-pico-digital, triple-pico-digital-chain, dual-arduino-hw-uart, dual-arduino-software-serial, arduino-pico-mixed-uart, arduino-esp32-uart, dual-esp32-uart, pi3-pico-uart, arduino-pico-i2c, arduino-arduino-spi, interconnect-routing, dual-arduino-multi-protocol (UART+I2C+SPI+ digital + concurrent), dual-pico-multi-protocol (UART0+UART1 alt+ I2C0+I2C1+SPI0+digital + 3-Pico star topology) - Updated dual-pico-serial-passthrough to assert correct behaviour - Backend test/multi_board_esp32/test_dual_esp32_serial.py for two real QEMU instances (skip-graceful when lcgamboa lib absent) Verified: 1107/1107 tests pass, zero regressions, vite build OK. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-26 05:47:28 +07:00
// Cross-board routing now handled by Interconnect (see bind below).
};
bridge.onPinChange = (_gpioPin, _state) => {
feat(multi-board): add wire-aware cross-board interconnect router Fixes the user-reported bug where two RPi Pico W boards wired GP0↔GP1 running SerialPassthrough don't communicate. Replaces the broken broadcast-style cross-board logic in addBoard (only routed AVR↔Pi3B, ignored wires entirely, no RP2040↔anything path) with a wire-aware Interconnect singleton. Architecture: digital pin transitions are the lowest-common-denominator abstraction. Each simulator's hardware peripherals (UART/I2C/SPI) and bit-banging libraries (SoftwareSerial, software I2C) decode the transitions naturally — propagate the pin and the protocols come for free. For cross-process boards (ESP32 backend QEMU, Pi3B QEMU) a byte-level shortcut is additionally enabled on hardware-UART pin pairs to handle high-baud links over WebSocket latency. Implementation: - New simulation/Interconnect.ts singleton subscribes to wire/board changes via the Zustand store. Handlers per tier: browser-sim → pinManager.onPinChange, ESP32 → Esp32Bridge.sendPinEvent, Pi3B → bridge.sendPinEvent. Re-entrancy guard via per-(board,pin) Set. - New utils/boardProtocols.ts classifies pins (uart-tx, i2c-sda, etc.) per board kind, used as optimization hint for the byte shortcut. - types/wire.ts: added signalType field, exports WireSignalType / WireColorMap (fixes a pre-existing TS import error in wireColors). - Deleted the bridgeMap/simulatorMap broadcast forEach blocks in addBoard. Initial board + future boards register with Interconnect via setInterconnectRuntime + store subscription. - PinManager.resetPinStates() helper for test isolation. Tests (16 new files, 96 tests, all passing): - Per-pair × per-protocol matrix: dual-arduino-digital, dual-pico-digital, arduino-pico-digital, triple-pico-digital-chain, dual-arduino-hw-uart, dual-arduino-software-serial, arduino-pico-mixed-uart, arduino-esp32-uart, dual-esp32-uart, pi3-pico-uart, arduino-pico-i2c, arduino-arduino-spi, interconnect-routing, dual-arduino-multi-protocol (UART+I2C+SPI+ digital + concurrent), dual-pico-multi-protocol (UART0+UART1 alt+ I2C0+I2C1+SPI0+digital + 3-Pico star topology) - Updated dual-pico-serial-passthrough to assert correct behaviour - Backend test/multi_board_esp32/test_dual_esp32_serial.py for two real QEMU instances (skip-graceful when lcgamboa lib absent) Verified: 1107/1107 tests pass, zero regressions, vite build OK. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-26 05:47:28 +07:00
// Cross-board routing now handled by Interconnect (see bind below).
};
bridgeMap.set(id, bridge);
} else if (isEsp32Kind(boardKind)) {
const bridge = new Esp32Bridge(id, boardKind);
bridge.onSerialData = serialCallback;
bridge.onPinChange = (gpioPin, state) => {
const boardPm = pinManagerMap.get(id);
fix(spice+pipeline): LED visualization, INPUT_PULLUP, ESP32-C3, PWM fade, examples End-to-end pipeline fixes uncovered while auditing the /examples gallery. Each bug shipped past green unit + snapshot tests because none of those run firmware + render LEDs. Added scripts/visual-led-test.mjs as a CDP-driven visual harness that loads each example, runs the simulator, samples `wokwi-led.brightness`, and asserts toggle / gradient / initial-off invariants — exits non-zero on any regression. Frontend simulator - PinManager.updatePort: new optional ddrMask param. A pin is added to `outputPins` only if the DDR bit is set, so the PORTx write that enables INPUT_PULLUP (DDR=0, PORT=1) no longer falsely marks the pin as MCU output. AVRSimulator now reads DDRB/C/D (0x24/0x27/0x2A on Uno/Nano, 0x37 on ATtiny85, per-port table on Mega) and forwards it. - AVRSimulator: pass DDR mask alongside every port-listener fire. - BasicParts pushbutton{,-6mm}: seed pin HIGH in attachEvents so `digitalRead()` returns HIGH while idle. avr8js doesn't auto-simulate INPUT_PULLUP — without this the firmware reads LOW from boot and thinks the button is permanently pressed (the "LED is always on, pressing does nothing" UX bug). - connectMcuEdgesToService: suppress synthetic digital edges on pins with active PWM, AND subscribe to onPwmChange to re-tick the netlist on duty changes. Fade-LED now produces a true gradient (6 distinct brightness levels across a fade cycle) instead of a binary 0/full toggle. - CircuitSimulationService.handleMcuEdge: replace single-slot pendingMcuEdge with a per-pin Map. Multiple pins toggling during the same in-flight tick used to overwrite each other; now every pin's most-recent edge replays after the tick. Fixes Traffic-Light RED→ YELLOW→GREEN sequencing. - NetlistBuilder: new sanitizeSpiceId() helper replaces hyphens with underscores in V-source names. ngspice's interactive `alter` command treats `-` as an operator and silently no-ops on hyphenated source names, so mid-simulation MCU pin transitions stopped propagating after the first solve. MixedModeScheduler.onMcuPinChange and CircuitSimulationService self-heal use the same sanitizer so names stay consistent across emit/alter/lookup. Also added a regex-based fallback in step 2 so any board pin matching `GND.\d+` canonicalises to net "0" — ESP32-C3 dev kits expose up to 10 GND pins and the per-board `groundPinNames` list missed several, leaving wires floating instead of grounded. - collectPinStates: emit V-sources only for pins in `outputPins`, not every wired board pin. Leaves INPUT pins (analog sensors on A0, pull-down dividers, etc.) free for the SPICE solver instead of being shorted to 0 V by an ideal MCU V-source. - start.ts: extended __spiceDebug to also expose outputPinsByBoard + nodeVoltages + pinNetMapEntries for the visual harness. - ESP32 / RP2040 / RISC-V / C3 simulators: pass `'mcu'` source flag to triggerPinChange / setPinState so the new outputPins tracking fires on those boards too (was AVR-only before). - useSimulatorStore: stopBoard/resetBoard call pm.resetPinStates() so outputPins clears between runs; Esp32Bridge.onPinChange passes the `'mcu'` flag in all three places it's wired. - types/board.ts: ATtiny85 FQBN `clock=internal16mhz` → `clock=16pll` (ATTinyCore 1.5.2 renamed the option). Backend - esp-idf-template/main/CMakeLists.txt: skip the `-DLED_BUILTIN=2` fallback for esp32c3 and esp32s3 targets. Both variants already define LED_BUILTIN in pins_arduino.h via a self-define macro (`#define LED_BUILTIN LED_BUILTIN` + `static const uint8_t LED_BUILTIN = ...;`). Pre-defining the symbol from the command line expanded the static-const declaration to `static const uint8_t 2 = ...;` — a syntax error that broke every ESP32-C3 / S3 build (`expected unqualified-id before numeric constant`). Examples - examples.ts: bulk-fix 72 wire endpoints that referenced `componentId: 'nano-rp2040'` / `'esp32-c3'` etc. (boards that don't exist on the canvas). Replaced with `'arduino-uno'` (the canvas board-id convention) and converted `D<n>` pin names to `GP<n>` for Pico-style boards. Affects pico-blink, pico-i2c-scanner, pico-i2c-rtc-read, pico-spi-loopback, c3-blink and others. Tests - scripts/visual-led-test.mjs: CDP-driven harness. Default suite covers Blink (single-pin), Button (idle-OFF invariant — catches the INPUT_PULLUP regression), Traffic-Light (multi-pin sequencing), Fade-LED (PWM gradient — ≥3 distinct levels), RGB-LED (≥3 PWM pins driven). Run via `npm --prefix frontend run test:visual` against a Chrome on `:9222` + vite on `:5174` + backend on `:8001`. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-19 07:47:45 +07:00
if (boardPm) boardPm.triggerPinChange(gpioPin, state, 'mcu');
};
feat(esp32/uart): synthesize bit-level TX waveform on UART0 TX GPIO Closes the same gap as the AVR / RP2040 commits — qemu-lcgamboa's UART transmits the byte over the WebSocket as a 'serial_output' event with no GPIO toggle, so an oscilloscope on the ESP32 TX pin saw nothing while real silicon would render the 8N1 frame at the configured baud rate. Two changes inside Esp32Bridge: * New `onPinChangeWithTime: (pin, state, timeMs) => void` callback that hooks the oscilloscope at parity with AVRSimulator / RP2040Simulator. The 'gpio_change' event now also flows through it (timestamped with `performance.now()` — QEMU virtual time isn't surfaced across the wire, but at 1× sim speed the wall-clock skew is invisible on any practical sweep). This also fixes the broader issue that ESP32 boards previously couldn't show ANY digital GPIO activity on the scope. * `emitUartTxFrame(byte, uart)` synthesizes start + 8 data LSB-first + stop transitions at `this.uartBaudRate` (default 115200) on the UART0 TX pin, mapped per board variant: esp32 / esp32-devkit-c-v4 / esp32-cam / wemos-lolin32-lite: GPIO1 esp32-s3 / xiao-esp32-s3 / arduino-nano-esp32: GPIO43 esp32-c3 / xiao-esp32-c3 / aitewinrobot-esp32c3-supermini: GPIO21 Backend doesn't expose the live baud rate so we default to 115200 (the Arduino default). Override path: bridge.uartBaudRate = N once we surface Serial.begin's argument via a backend event. Wire-up: `bridge.onPinChangeWithTime = getOscilloscopeCallback(boardId)` inside the three Esp32Bridge construction sites in useSimulatorStore (setBoardType, addBoard, changeBoard).
2026-05-23 00:58:24 +07:00
// 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 } : {}) };
});
};
feat(esp32): SignalRouter — model the GPIO Matrix as first-class Replaces the per-peripheral ad-hoc `_ledc_gpio_map` cache with a proper signal-routing abstraction that mirrors the ESP32 SoC's IO_MUX + GPIO Matrix exactly. Same idea as real silicon: signal sources (LEDC channels, RMT, MCPWM, ...) → 40-entry routing table → GPIO pins. Motivation (from user bug report in velxio.dev/project/5218f9e3-136d-43b3-bba1-6cebde21e1a4): two ESP32 servos on a solar-tracker visibly oscillated between two positions instead of moving smoothly when the user changed LDR sliders. Commit 77bf897 patched it (per-channel gpio memo + broadcast guard) but the user requested a proper hardware-fidel architecture, not patches. Backend: * `app/services/signal_router.py` — SignalRouter class. Forward index (gpio → signal_id) + reverse index (signal_id → set of gpios). `replace_snapshot()` returns the diff for the polling- fallback path; future C plugin hook becomes a push without touching this code. * `app/services/esp32_signals.py` — Signal id constants from ESP32 TRM (LEDC HS 72-79, LS 80-87) + `ledc_signal_for_channel()` helper. * `app/services/esp32_worker.py` — `_ledc_gpio_map` is gone; `_refresh_ledc_gpio_map` replaced by `_refresh_signal_routing` which emits `gpio_routing {gpio, signal_id}` events on diff. The 0x5000 LEDC callback and the LEDC poll thread now emit `ledc_duty {channel, duty_pct}` (canonical, no gpio) alongside the legacy `ledc_update {channel, duty, gpio}` for back-compat during rollout. Frontend: * `simulation/SignalRouter.ts` — 1-to-1 TS mirror of the Python class. Same forward + reverse index; same `pinsForSignal` / `updateRouting` / `clearRouting` API. * `simulation/esp32-signals.ts` — Signal id constants, mirror of the Python module. * `simulation/Esp32Bridge.ts` — new `onLedcDuty`, `onGpioRouting`, `onGpioRoutingClear` callbacks; handlers for the new event types. * `store/useSimulatorStore.ts` — `makeLedcDutyHandler` looks up pins via `router.pinsForSignal(ledcSignalForChannel(channel))` and dispatches per pin. `makeGpioRoutingHandler` / `makeGpioRoutingClearHandler` keep the mirror in sync. Per-board `signalRouterMap` parallels `pinManagerMap` in lifecycle. `makeLedcUpdateHandler` (and its memo workaround from 77bf897) stays wired for back-compat during rollout; removed in a follow-up commit once prod is verified stable on the new path. Tests: * `test/backend/unit/test_signal_router.py` (20 tests) covers update/clear semantics, idempotency, multi-pin routing, snapshot diff, channel↔signal-id helpers, and the multi-servo regression scenario. * `frontend/src/__tests__/SignalRouter.test.ts` (17 tests) is the mirror — same scenarios on the TS side. * `frontend/src/__tests__/esp32-multi-servo-gpio-matrix.test.ts` (6 tests) drives the end-to-end SignalRouter handler pipeline, asserts that two servos on GPIO 13/12 via LEDC channels 0/1 move independently (no mirroring), that re-routing carries cleanly, and — critically — that `PinManager.broadcastPwm` is never called. Totals: +700 LOC, 1876 frontend tests pass (was 1853), 278 backend unit tests pass (was 259). Docs: ESP32_EMULATION.md §9.2 rewritten with the new architecture diagram + a runbook for adding future peripherals through the SignalRouter. The C plugin hook in qemu-lcgamboa that would push gpio_out_sel writes synchronously (eliminating the polling race window entirely) is the next step — kept as a follow-up because the polling-fallback path here already resolves the routing before each duty event fires, so the bug is fixed end-to-end. The plugin work removes the race condition fundamentally.
2026-05-17 10:00:53 +07:00
signalRouterMap.set(id, new SignalRouter());
bridge.onLedcDuty = makeLedcDutyHandler(id);
bridge.onGpioRouting = makeGpioRoutingHandler(id);
bridge.onGpioRoutingClear = makeGpioRoutingClearHandler(id);
bridge.onWs2812Update = (channel, pixels) => {
// Forward WS2812 pixel data to any DOM element with id=`ws2812-{id}-{channel}`
// (set by NeoPixel components rendered in SimulatorCanvas).
// We fire a custom event that NeoPixel components can listen to.
const eventTarget = document.getElementById(`ws2812-${id}-${channel}`);
if (eventTarget) {
eventTarget.dispatchEvent(new CustomEvent('ws2812-pixels', { detail: { pixels } }));
}
};
bridge.onWifiStatus = (ws) => {
set((s) => ({
boards: s.boards.map((b) => (b.id === id ? { ...b, wifiStatus: ws } : b)),
}));
};
bridge.onBleStatus = (bs) => {
set((s) => ({
boards: s.boards.map((b) => (b.id === id ? { ...b, bleStatus: bs } : b)),
}));
};
esp32BridgeMap.set(id, bridge);
// Provide a shim so PartSimulationRegistry components (DHT22, etc.)
// can call setPinState / access pinManager on ESP32 boards.
const shim = new Esp32BridgeShim(bridge, pm);
shim.onSerialData = serialCallback;
feat(i2c): cross-board bridging across all velxio boards (AVR/RP2040/ESP32 xtensa+riscv) Closes the remaining gaps in cross-board I2C so any topology of supported boards (Uno↔ESP32, two ESP32s, Uno↔Uno↔Uno, ESP32-C3 connected to anything, etc.) works end-to-end with all I2C components including write-only sinks (SSD1306, PCF8574, LCD-I2C). Implementation (6 phases): 1. **BFS routing in I2CBusManager**: connectToSlave + handleExternalConnect walk the bridge graph with a visited Set so multi-hop chains (A↔B↔C with the device on C) resolve transparently. A new forwarder-device shim is installed at intermediate hops so the existing handleExternalWrite/Read/Stop machinery routes through without per-method visited tracking. 2. **Per-peer proxy ownership in Esp32BridgeShim**: replaces the global _proxiedAddrs Set with _proxiedByPeer Map so concurrent bridges to the same ESP32 (e.g. wired to both Uno and Pico) don't wipe each other's proxies on teardown. Interconnect's per-wire teardown calls clearProxiesForPeer(peerBus) instead of clearAllProxies. 3. **BFS-aware proxy sync**: syncProxyFromPeer now walks the peer bus + its transitive bridges, so an ESP32 sees devices on boards two or more hops away. _peerDeviceLookup keeps a flat addr → device map for write-forwarding and resync. 4. **Periodic resync (250 ms)**: Esp32BridgeShim runs a setInterval while any proxy is live, re-dumping each device with dumpRegisters() and pushing updateProxyI2c only when an XOR- stride hash changes. This keeps RTC time advancing visible to ESP32 firmware without flooding the WS pipe with static calibration dumps. Hash is primed during initial sync so the first tick doesn't push a redundant identical buffer. 5. **Write-forwarding ProxySlave → peer**: backend ProxySlave buffers write bytes during the transaction and emits a `proxy_i2c_complete` event on STOP / repeated-START. Frontend Esp32Bridge dispatches the event to a new onProxyI2cComplete callback; the shim replays the byte sequence on the actual peer I2CDevice via writeByte() + stop(). Makes ESP32 firmware writes to peer SSD1306 actually repaint the OLED, peer PCF8574 latch updates, peer I2CMemoryDevice register mutations propagate. 6. **ESP32-C3 routed as bridge**: Interconnect.isBrowserSim no longer claims c3/xiao-c3/c3-supermini — they were already going through Esp32Bridge per the store's ESP32_RISCV_KINDS routing, but Interconnect was treating them as browser sims which broke proxy install. isEsp32Bridge now correctly includes c3 family + ESP32-S3 + Arduino Nano ESP32. Defensive: addBoard now disposes any existing shim's proxies before overwriting simulatorMap entry so test reruns don't leak timers. Tests: - 4 BFS multi-hop tests (i2c-multi-board-slave-gap.test.ts) - 11 cross-board scenarios + per-peer + write-forward + resync (i2c-esp32-multiboard-bridge.test.ts) - 1 real-firmware E2E for write-forward via QEMU (compile + load + observe proxy_i2c_complete arriving with the byte) - New sketch fixture: esp32_i2c_write_to_peer.ino Result: 90 test files / 1295 tests pass / 0 fail. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 03:51:39 +07:00
// If a shim already exists for this id (e.g. tests recreate the
// same kind after reset), dispose any active proxies / timers
// so the orphaned instance doesn't keep firing.
const existingShim = simulatorMap.get(id) as any;
if (existingShim?.clearAllProxies) {
try { existingShim.clearAllProxies(); } catch { /* ignore */ }
}
simulatorMap.set(id, shim);
} else {
const sim = createSimulator(
boardKind,
pm,
serialCallback,
(baud) => {
set((s) => {
const boards = s.boards.map((b) =>
b.id === id ? { ...b, serialBaudRate: baud } : b,
);
const isActive = s.activeBoardId === id;
return { boards, ...(isActive ? { serialBaudRate: baud } : {}) };
});
},
getOscilloscopeCallback(id),
);
feat(multi-board): add wire-aware cross-board interconnect router Fixes the user-reported bug where two RPi Pico W boards wired GP0↔GP1 running SerialPassthrough don't communicate. Replaces the broken broadcast-style cross-board logic in addBoard (only routed AVR↔Pi3B, ignored wires entirely, no RP2040↔anything path) with a wire-aware Interconnect singleton. Architecture: digital pin transitions are the lowest-common-denominator abstraction. Each simulator's hardware peripherals (UART/I2C/SPI) and bit-banging libraries (SoftwareSerial, software I2C) decode the transitions naturally — propagate the pin and the protocols come for free. For cross-process boards (ESP32 backend QEMU, Pi3B QEMU) a byte-level shortcut is additionally enabled on hardware-UART pin pairs to handle high-baud links over WebSocket latency. Implementation: - New simulation/Interconnect.ts singleton subscribes to wire/board changes via the Zustand store. Handlers per tier: browser-sim → pinManager.onPinChange, ESP32 → Esp32Bridge.sendPinEvent, Pi3B → bridge.sendPinEvent. Re-entrancy guard via per-(board,pin) Set. - New utils/boardProtocols.ts classifies pins (uart-tx, i2c-sda, etc.) per board kind, used as optimization hint for the byte shortcut. - types/wire.ts: added signalType field, exports WireSignalType / WireColorMap (fixes a pre-existing TS import error in wireColors). - Deleted the bridgeMap/simulatorMap broadcast forEach blocks in addBoard. Initial board + future boards register with Interconnect via setInterconnectRuntime + store subscription. - PinManager.resetPinStates() helper for test isolation. Tests (16 new files, 96 tests, all passing): - Per-pair × per-protocol matrix: dual-arduino-digital, dual-pico-digital, arduino-pico-digital, triple-pico-digital-chain, dual-arduino-hw-uart, dual-arduino-software-serial, arduino-pico-mixed-uart, arduino-esp32-uart, dual-esp32-uart, pi3-pico-uart, arduino-pico-i2c, arduino-arduino-spi, interconnect-routing, dual-arduino-multi-protocol (UART+I2C+SPI+ digital + concurrent), dual-pico-multi-protocol (UART0+UART1 alt+ I2C0+I2C1+SPI0+digital + 3-Pico star topology) - Updated dual-pico-serial-passthrough to assert correct behaviour - Backend test/multi_board_esp32/test_dual_esp32_serial.py for two real QEMU instances (skip-graceful when lcgamboa lib absent) Verified: 1107/1107 tests pass, zero regressions, vite build OK. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-26 05:47:28 +07:00
// Cross-board routing now handled by Interconnect (see bind below).
simulatorMap.set(id, sim);
// ── Pico W: attach the CYW43 chip-side emulator + WS bridge ──
// Mirrors the ESP32 path (esp32BridgeMap) so the board has the
// same capability surface the rest of the app already understands.
if (boardKind === 'pi-pico-w' && sim instanceof RP2040Simulator) {
const bridge = new Cyw43Bridge(id);
bridge.onWifiStatus = (ws) => {
set((s) => ({
boards: s.boards.map((b) => (b.id === id ? { ...b, wifiStatus: ws } : b)),
}));
};
cyw43BridgeMap.set(id, bridge);
sim.attachCyw43(bridge);
}
}
const newBoard: BoardInstance = {
id,
boardKind,
x,
y,
running: false,
compiledProgram: null,
serialOutput: '',
serialBaudRate: 0,
serialMonitorOpen: false,
activeFileGroupId: `group-${id}`,
languageMode: 'arduino',
};
set((s) => {
// If there's no current active board (or the stored id doesn't point
// to one that exists), promote the new board to active. Without this,
// an agent that does add_board → compile_sketch fails on step 2 with
// "no active board on the canvas" and has to spend a turn on
// set_active_board. Manual placements via the UI already auto-active
// through the picker; this just closes the API gap.
const stillExists = s.boards.some((b) => b.id === s.activeBoardId);
const nextActive = stillExists ? s.activeBoardId : id;
return { boards: [...s.boards, newBoard], activeBoardId: nextActive };
});
// Create the editor file group for this board
useEditorStore.getState().createFileGroup(`group-${id}`);
// Init VFS for Raspberry Pi 3 boards
feat(pi): Phase 3.3 — Pi Zero / Pi 1 / Pi 2 armhf simulators Closes the deferred Phase 3.3. Root-causes the Pi 2 "Attempted to kill init" panic as `mount /dev/vda` failing with EINVAL — Debian armmp does not have ext4 builtin (only fuseblk in /proc/filesystems). - qemu_manager: PI_CONFIGS gains raspberry-pi-zero / -1 / -2 entries. All three use the armmp armhf kernel + Cortex-A7 CPU + the mmio virtio transport (arm-32 virt PCI fails -75 due to missing reg DT property). Pi Zero / Pi 1 get the small 1-core / 512 MB profile; Pi 2 gets 4-core / 1 GB. QEMU command builder branches on cfg.bus for virtio-blk-pci vs virtio-blk-device (and serial likewise). - manifest.json: new `raspberry-pi-armhf` image_set wiring three assets (kernel + initramfs + zstd rootfs). - Frontend BoardKind gains the three new kinds + an isPiBoardKind() helper. Replaces the eight scattered `=== 'raspberry-pi-3' || === 'raspberry-pi-4' || === 'raspberry-pi-5'` branches in useSimulatorStore, Interconnect, loadExample, boardProtocols. ComponentRegistry gets three new picker entries. - board-kinds-coverage test: ACCEPTED_UNCOVERED gains the new kinds (backend boards have no canvas examples). The matching armhf build-pi-kernel.sh / build-pi-rootfs.sh changes live in velxio-prod's scripts/ (private overlay) — the upstream kernel build script only knows about arm64; armhf is built in the private repo because the assets ship through the license endpoint. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 04:23:48 +07:00
if (isPiBoardKind(boardKind)) {
useVfsStore.getState().initBoardVfs(id);
}
feat(multi-board): add wire-aware cross-board interconnect router Fixes the user-reported bug where two RPi Pico W boards wired GP0↔GP1 running SerialPassthrough don't communicate. Replaces the broken broadcast-style cross-board logic in addBoard (only routed AVR↔Pi3B, ignored wires entirely, no RP2040↔anything path) with a wire-aware Interconnect singleton. Architecture: digital pin transitions are the lowest-common-denominator abstraction. Each simulator's hardware peripherals (UART/I2C/SPI) and bit-banging libraries (SoftwareSerial, software I2C) decode the transitions naturally — propagate the pin and the protocols come for free. For cross-process boards (ESP32 backend QEMU, Pi3B QEMU) a byte-level shortcut is additionally enabled on hardware-UART pin pairs to handle high-baud links over WebSocket latency. Implementation: - New simulation/Interconnect.ts singleton subscribes to wire/board changes via the Zustand store. Handlers per tier: browser-sim → pinManager.onPinChange, ESP32 → Esp32Bridge.sendPinEvent, Pi3B → bridge.sendPinEvent. Re-entrancy guard via per-(board,pin) Set. - New utils/boardProtocols.ts classifies pins (uart-tx, i2c-sda, etc.) per board kind, used as optimization hint for the byte shortcut. - types/wire.ts: added signalType field, exports WireSignalType / WireColorMap (fixes a pre-existing TS import error in wireColors). - Deleted the bridgeMap/simulatorMap broadcast forEach blocks in addBoard. Initial board + future boards register with Interconnect via setInterconnectRuntime + store subscription. - PinManager.resetPinStates() helper for test isolation. Tests (16 new files, 96 tests, all passing): - Per-pair × per-protocol matrix: dual-arduino-digital, dual-pico-digital, arduino-pico-digital, triple-pico-digital-chain, dual-arduino-hw-uart, dual-arduino-software-serial, arduino-pico-mixed-uart, arduino-esp32-uart, dual-esp32-uart, pi3-pico-uart, arduino-pico-i2c, arduino-arduino-spi, interconnect-routing, dual-arduino-multi-protocol (UART+I2C+SPI+ digital + concurrent), dual-pico-multi-protocol (UART0+UART1 alt+ I2C0+I2C1+SPI0+digital + 3-Pico star topology) - Updated dual-pico-serial-passthrough to assert correct behaviour - Backend test/multi_board_esp32/test_dual_esp32_serial.py for two real QEMU instances (skip-graceful when lcgamboa lib absent) Verified: 1107/1107 tests pass, zero regressions, vite build OK. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-26 05:47:28 +07:00
// ── 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);
feat(esp32): SignalRouter — model the GPIO Matrix as first-class Replaces the per-peripheral ad-hoc `_ledc_gpio_map` cache with a proper signal-routing abstraction that mirrors the ESP32 SoC's IO_MUX + GPIO Matrix exactly. Same idea as real silicon: signal sources (LEDC channels, RMT, MCPWM, ...) → 40-entry routing table → GPIO pins. Motivation (from user bug report in velxio.dev/project/5218f9e3-136d-43b3-bba1-6cebde21e1a4): two ESP32 servos on a solar-tracker visibly oscillated between two positions instead of moving smoothly when the user changed LDR sliders. Commit 77bf897 patched it (per-channel gpio memo + broadcast guard) but the user requested a proper hardware-fidel architecture, not patches. Backend: * `app/services/signal_router.py` — SignalRouter class. Forward index (gpio → signal_id) + reverse index (signal_id → set of gpios). `replace_snapshot()` returns the diff for the polling- fallback path; future C plugin hook becomes a push without touching this code. * `app/services/esp32_signals.py` — Signal id constants from ESP32 TRM (LEDC HS 72-79, LS 80-87) + `ledc_signal_for_channel()` helper. * `app/services/esp32_worker.py` — `_ledc_gpio_map` is gone; `_refresh_ledc_gpio_map` replaced by `_refresh_signal_routing` which emits `gpio_routing {gpio, signal_id}` events on diff. The 0x5000 LEDC callback and the LEDC poll thread now emit `ledc_duty {channel, duty_pct}` (canonical, no gpio) alongside the legacy `ledc_update {channel, duty, gpio}` for back-compat during rollout. Frontend: * `simulation/SignalRouter.ts` — 1-to-1 TS mirror of the Python class. Same forward + reverse index; same `pinsForSignal` / `updateRouting` / `clearRouting` API. * `simulation/esp32-signals.ts` — Signal id constants, mirror of the Python module. * `simulation/Esp32Bridge.ts` — new `onLedcDuty`, `onGpioRouting`, `onGpioRoutingClear` callbacks; handlers for the new event types. * `store/useSimulatorStore.ts` — `makeLedcDutyHandler` looks up pins via `router.pinsForSignal(ledcSignalForChannel(channel))` and dispatches per pin. `makeGpioRoutingHandler` / `makeGpioRoutingClearHandler` keep the mirror in sync. Per-board `signalRouterMap` parallels `pinManagerMap` in lifecycle. `makeLedcUpdateHandler` (and its memo workaround from 77bf897) stays wired for back-compat during rollout; removed in a follow-up commit once prod is verified stable on the new path. Tests: * `test/backend/unit/test_signal_router.py` (20 tests) covers update/clear semantics, idempotency, multi-pin routing, snapshot diff, channel↔signal-id helpers, and the multi-servo regression scenario. * `frontend/src/__tests__/SignalRouter.test.ts` (17 tests) is the mirror — same scenarios on the TS side. * `frontend/src/__tests__/esp32-multi-servo-gpio-matrix.test.ts` (6 tests) drives the end-to-end SignalRouter handler pipeline, asserts that two servos on GPIO 13/12 via LEDC channels 0/1 move independently (no mirroring), that re-routing carries cleanly, and — critically — that `PinManager.broadcastPwm` is never called. Totals: +700 LOC, 1876 frontend tests pass (was 1853), 278 backend unit tests pass (was 259). Docs: ESP32_EMULATION.md §9.2 rewritten with the new architecture diagram + a runbook for adding future peripherals through the SignalRouter. The C plugin hook in qemu-lcgamboa that would push gpio_out_sel writes synchronously (eliminating the polling race window entirely) is the next step — kept as a follow-up because the polling-fallback path here already resolves the routing before each duty event fires, so the bug is fixed end-to-end. The plugin work removes the race condition fundamentally.
2026-05-17 10:00:53 +07:00
signalRouterMap.delete(boardId);
const bridge = getBoardBridge(boardId);
if (bridge) {
bridge.disconnect();
bridgeMap.delete(boardId);
}
const esp32Bridge = getEsp32Bridge(boardId);
if (esp32Bridge) {
esp32Bridge.disconnect();
esp32BridgeMap.delete(boardId);
}
const cyw43Bridge = getCyw43Bridge(boardId);
if (cyw43Bridge) {
cyw43Bridge.disconnect();
cyw43BridgeMap.delete(boardId);
}
const cyw43Sim = getBoardSimulator(boardId);
if (cyw43Sim instanceof RP2040Simulator) cyw43Sim.detachCyw43();
set((s) => {
const boards = s.boards.filter((b) => b.id !== boardId);
const activeBoardId =
s.activeBoardId === boardId ? (boards[0]?.id ?? null) : s.activeBoardId;
// Remove wires connected to this board
const wires = s.wires.filter(
(w) => w.start.componentId !== boardId && w.end.componentId !== boardId,
);
return { boards, activeBoardId, wires };
});
// Clean up file group in editor store
if (board) {
useEditorStore.getState().deleteFileGroup(board.activeFileGroupId);
}
feat(multi-board): add wire-aware cross-board interconnect router Fixes the user-reported bug where two RPi Pico W boards wired GP0↔GP1 running SerialPassthrough don't communicate. Replaces the broken broadcast-style cross-board logic in addBoard (only routed AVR↔Pi3B, ignored wires entirely, no RP2040↔anything path) with a wire-aware Interconnect singleton. Architecture: digital pin transitions are the lowest-common-denominator abstraction. Each simulator's hardware peripherals (UART/I2C/SPI) and bit-banging libraries (SoftwareSerial, software I2C) decode the transitions naturally — propagate the pin and the protocols come for free. For cross-process boards (ESP32 backend QEMU, Pi3B QEMU) a byte-level shortcut is additionally enabled on hardware-UART pin pairs to handle high-baud links over WebSocket latency. Implementation: - New simulation/Interconnect.ts singleton subscribes to wire/board changes via the Zustand store. Handlers per tier: browser-sim → pinManager.onPinChange, ESP32 → Esp32Bridge.sendPinEvent, Pi3B → bridge.sendPinEvent. Re-entrancy guard via per-(board,pin) Set. - New utils/boardProtocols.ts classifies pins (uart-tx, i2c-sda, etc.) per board kind, used as optimization hint for the byte shortcut. - types/wire.ts: added signalType field, exports WireSignalType / WireColorMap (fixes a pre-existing TS import error in wireColors). - Deleted the bridgeMap/simulatorMap broadcast forEach blocks in addBoard. Initial board + future boards register with Interconnect via setInterconnectRuntime + store subscription. - PinManager.resetPinStates() helper for test isolation. Tests (16 new files, 96 tests, all passing): - Per-pair × per-protocol matrix: dual-arduino-digital, dual-pico-digital, arduino-pico-digital, triple-pico-digital-chain, dual-arduino-hw-uart, dual-arduino-software-serial, arduino-pico-mixed-uart, arduino-esp32-uart, dual-esp32-uart, pi3-pico-uart, arduino-pico-i2c, arduino-arduino-spi, interconnect-routing, dual-arduino-multi-protocol (UART+I2C+SPI+ digital + concurrent), dual-pico-multi-protocol (UART0+UART1 alt+ I2C0+I2C1+SPI0+digital + 3-Pico star topology) - Updated dual-pico-serial-passthrough to assert correct behaviour - Backend test/multi_board_esp32/test_dual_esp32_serial.py for two real QEMU instances (skip-graceful when lcgamboa lib absent) Verified: 1107/1107 tests pass, zero regressions, vite build OK. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-26 05:47:28 +07:00
// ── Interconnect: drop board and rebuild routes ──────────────────
icUnbindBoard(boardId);
icUpdateWires(get().wires);
},
updateBoard: (boardId: string, updates: Partial<BoardInstance>) => {
set((s) => ({
boards: s.boards.map((b) => (b.id === boardId ? { ...b, ...updates } : b)),
}));
},
feat: persist multi-board projects + add auto-save The project save/load pipeline only persisted a single `board_type`, so multi-board workspaces silently lost every board except the active one on save, and wires referencing the dropped boards' IDs orphaned to the canvas corner on reload. An audit of the production backup found 74/306 projects (24%) with at least one orphaned wire and 174/301 non-trivial projects whose code was still the default Blink template — strong signal that users save once and never re-save. Backend - Add `boards_json` column on `projects` with idempotent ALTER TABLE in the lifespan migration list. - New `FileGroup` schema + `file_groups` array on ProjectCreate/Update/Response. Legacy `files`/`code` kept for back-compat. - `project_files.py` now uses `{pid}/{groupId}/{filename}` subdirs via `read_groups`/`write_groups`. Legacy flat layouts are auto-promoted on read; legacy single-list `files` only updates the active group, leaving other boards' files intact. - `_persist_files_from_body` honors file_groups → files → code priority. Frontend - `useSimulatorStore.addBoard` accepts an optional `explicitId` so saved board IDs can be restored verbatim (wires reference IDs literally). - New `loadProjectState({boards, fileGroups, components, wires, activeBoardId})` action: tears down current boards, recreates from the payload, restores file groups atomically, recalculates wire positions on the next frame, and refreshes the Interconnect. - `useEditorStore.replaceFileGroups` for atomic multi-group restore. - `SaveProjectModal` and `ProjectByIdPage`/`ProjectPage` now go through `buildSavePayload` / `buildLoadPayload` (handles pre-backfill projects by synthesising a default board from `board_type`). Auto-save (#useAutoSaveProject hook) - 2.5s debounced silent PUT triggered ONLY when an authenticated user has a `currentProject` with a UUID. State hash detects real changes vs. UI-only churn; baseline is reset on project load so the just-loaded state isn't immediately re-saved. - `beforeunload` flush via `fetch keepalive: true` (supports PUT + credentials, survives unload). - Compact status indicator in `AppHeader` (idle/dirty/saving/saved/error). Backfill script (one-off, idempotent) - `backend/scripts/backfill_boards_2026_05.py` populates `boards_json` for legacy projects. Heuristic per project, based on which board IDs the wires reference: Case A — wires only ref 'arduino-uno' but board_type ≠ uno: rename id→board_type and rewrite wire endpoints. Case B — single-board normal: keep verbatim. Case C — multi-board: recreate one board per distinct ref, infer kind by stripping trailing -N suffix. Also moves any flat files into the active board's group subdir. Stdlib-only, runs from host or `docker exec`. Docker - `Dockerfile.standalone` now copies `backend/scripts/` into the image so the backfill is callable via `docker exec velxio-app python /app/scripts/backfill_boards_2026_05.py --apply`. Verified locally on the restored production backup (363 projects): 33 Case A, 316 Case B, 14 Case C, 135 wire endpoints renamed, 0 orphans. Re-running the script after apply skips all 363 (idempotent). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 23:43:33 +07:00
loadProjectState: (payload) => {
const { stopSimulation, removeBoard, addBoard, setComponents, setWires,
setActiveBoardId, recalculateAllWirePositions } = get();
// Tear down current state
if (get().running) stopSimulation();
const oldIds = get().boards.map((b) => b.id);
oldIds.forEach((id) => removeBoard(id));
// Recreate boards with their saved ids so wire endpoints (which embed
// the literal board id) keep matching.
payload.boards.forEach((b) => {
addBoard(b.boardKind, b.x, b.y, b.id);
// Apply the rest of the saved fields that addBoard doesn't set.
if (b.languageMode && b.languageMode !== 'arduino') {
set((s) => ({
boards: s.boards.map((bb) =>
bb.id === b.id ? { ...bb, languageMode: b.languageMode } : bb,
),
}));
}
});
// Replace editor file groups atomically. Skip groups that already exist
// (createFileGroup is a no-op for existing ids) — overwrite their files.
useEditorStore.getState().replaceFileGroups(payload.fileGroups);
// Components and wires
setComponents(payload.components);
setWires(payload.wires);
// Active board: prefer the saved one, fall back to the first.
const targetActive = payload.activeBoardId &&
get().boards.find((b) => b.id === payload.activeBoardId)
? payload.activeBoardId
: (get().boards[0]?.id ?? null);
if (targetActive) setActiveBoardId(targetActive);
// Wires need a frame for the wokwi-elements to mount in the DOM before
// pinPositionCalculator can resolve their pinInfo.
requestAnimationFrame(() => {
recalculateAllWirePositions();
icUpdateWires(get().wires);
});
},
setBoardPosition: (pos: { x: number; y: number }, boardId?: string) => {
const id = boardId ?? get().activeBoardId ?? INITIAL_BOARD_ID;
set((s) => ({
boardPosition: s.activeBoardId === id ? pos : s.boardPosition,
boards: s.boards.map((b) => (b.id === id ? { ...b, x: pos.x, y: pos.y } : b)),
}));
},
setActiveBoardId: (boardId: string) => {
const board = get().boards.find((b) => b.id === boardId);
if (!board) return;
set({
activeBoardId: boardId,
// Sync legacy flat fields to this board's values
feat(pi): Phase 3.3 — Pi Zero / Pi 1 / Pi 2 armhf simulators Closes the deferred Phase 3.3. Root-causes the Pi 2 "Attempted to kill init" panic as `mount /dev/vda` failing with EINVAL — Debian armmp does not have ext4 builtin (only fuseblk in /proc/filesystems). - qemu_manager: PI_CONFIGS gains raspberry-pi-zero / -1 / -2 entries. All three use the armmp armhf kernel + Cortex-A7 CPU + the mmio virtio transport (arm-32 virt PCI fails -75 due to missing reg DT property). Pi Zero / Pi 1 get the small 1-core / 512 MB profile; Pi 2 gets 4-core / 1 GB. QEMU command builder branches on cfg.bus for virtio-blk-pci vs virtio-blk-device (and serial likewise). - manifest.json: new `raspberry-pi-armhf` image_set wiring three assets (kernel + initramfs + zstd rootfs). - Frontend BoardKind gains the three new kinds + an isPiBoardKind() helper. Replaces the eight scattered `=== 'raspberry-pi-3' || === 'raspberry-pi-4' || === 'raspberry-pi-5'` branches in useSimulatorStore, Interconnect, loadExample, boardProtocols. ComponentRegistry gets three new picker entries. - board-kinds-coverage test: ACCEPTED_UNCOVERED gains the new kinds (backend boards have no canvas examples). The matching armhf build-pi-kernel.sh / build-pi-rootfs.sh changes live in velxio-prod's scripts/ (private overlay) — the upstream kernel build script only knows about arm64; armhf is built in the private repo because the assets ship through the license endpoint. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 04:23:48 +07:00
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);
feat: ESP32-CAM emulation with webcam frame bridge First open-source end-to-end emulation of the AI-Thinker ESP32-CAM in QEMU, paired with a browser webcam → firmware bridge so users can develop camera sketches without hardware. Status: esp_camera_init() returns ESP_OK; OV2640 chip-id verifies (PID/VER/MIDH/MIDL exactly match the datasheet); GPIO 25 VSYNC NEGEDGE interrupt enabled by the upstream driver. Final piece (cam_task accepting frames) is in progress — descriptor walker fix landed in this commit. Backend (Python/FastAPI): - simulation.py: camera_attach/frame/detach WS handlers - esp32_worker.py: ctypes binding to velxio_push_camera_frame + feature-detection fallback for older DLLs - esp32_lib_manager.py: forward camera commands to the worker stdin - esp-idf-template/main/CMakeLists.txt: esp32-camera headers added via add_prebuilt_library + REQUIRES driver (resolves i2c_master_* symbols). LED_BUILTIN=2 fallback for sketches that hardcode it. Frontend (React/TS): - EditorToolbar.tsx: ESP32-CAM (and the rest of the ESP32 family) added to isQemuBoard list — Run button now starts the QEMU bridge for these boards instead of falling through to the AVR path - useWebcamFrames.ts: getUserMedia → OffscreenCanvas → toBlob('image/jpeg') → base64 → WS at ~10 fps - CameraToggle.tsx: header button with status colors + frame counter - SimulatorCanvas.tsx: render CameraToggle for esp32-cam boards - Esp32Bridge.ts: sendCameraAttach/Frame/Detach + chunked btoa - useSimulatorStore.ts: diagnostic log on compileBoardProgram - components-metadata.json: regen including esp32-cam component Submodule pointer: - wokwi-libs/qemu-lcgamboa → ff8eee0 (camera devices commit on davidmonterocrespo24/qemu-lcgamboa branch picsimlab-esp32) Investigation + tests in test/test-esp32-cam/: - 13 autosearch markdown docs (overview, SOTA, OV2640 spec, DVP/I2S spec, build blueprint, blockers resolved, descriptor walker fix) - 5 sketches (camera_init, sccb_probe, dma_smoke, frame_roundtrip, webcam_demo) + 8 live + WS regression tests - README with the user-facing flow .gitignore: - libqemu-*.dll.{pre-camera,new,bak} (rollback points, regenerated) - wokwi-libs/esp32-camera/ (clone consumed by arduino-esp32 path, not part of this repo) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 05:28:55 +07:00
if (!board) {
console.warn(`[compileBoardProgram] board not found: ${boardId}`);
return;
}
console.log(`[compileBoardProgram] ${boardId} kind=${board.boardKind} programLen=${program?.length ?? 0}`);
if (isEsp32Kind(board.boardKind)) {
// All ESP32 boards (Xtensa + RISC-V C3): send firmware to QEMU via bridge.
// Note: isEsp32Kind() includes C3 boards, so they route through Esp32Bridge
// for full WiFi/BLE emulation via qemu-system-riscv32.
const esp32Bridge = getEsp32Bridge(boardId);
if (esp32Bridge) esp32Bridge.loadFirmware(program);
} else if (isRiscVEsp32Kind(board.boardKind)) {
// Fallback: browser-only RV32IMC emulation (no WiFi/BLE support).
// Currently unreachable because isEsp32Kind() above includes C3 boards.
const sim = getBoardSimulator(boardId);
if (sim instanceof Esp32C3Simulator) {
try {
sim.loadFlashImage(program);
} catch (err) {
console.error(`[Esp32C3Simulator] loadFlashImage failed for ${boardId}:`, err);
return;
}
}
} else {
const sim = getBoardSimulator(boardId);
feat(pi): Phase 3.3 — Pi Zero / Pi 1 / Pi 2 armhf simulators Closes the deferred Phase 3.3. Root-causes the Pi 2 "Attempted to kill init" panic as `mount /dev/vda` failing with EINVAL — Debian armmp does not have ext4 builtin (only fuseblk in /proc/filesystems). - qemu_manager: PI_CONFIGS gains raspberry-pi-zero / -1 / -2 entries. All three use the armmp armhf kernel + Cortex-A7 CPU + the mmio virtio transport (arm-32 virt PCI fails -75 due to missing reg DT property). Pi Zero / Pi 1 get the small 1-core / 512 MB profile; Pi 2 gets 4-core / 1 GB. QEMU command builder branches on cfg.bus for virtio-blk-pci vs virtio-blk-device (and serial likewise). - manifest.json: new `raspberry-pi-armhf` image_set wiring three assets (kernel + initramfs + zstd rootfs). - Frontend BoardKind gains the three new kinds + an isPiBoardKind() helper. Replaces the eight scattered `=== 'raspberry-pi-3' || === 'raspberry-pi-4' || === 'raspberry-pi-5'` branches in useSimulatorStore, Interconnect, loadExample, boardProtocols. ComponentRegistry gets three new picker entries. - board-kinds-coverage test: ACCEPTED_UNCOVERED gains the new kinds (backend boards have no canvas examples). The matching armhf build-pi-kernel.sh / build-pi-rootfs.sh changes live in velxio-prod's scripts/ (private overlay) — the upstream kernel build script only knows about arm64; armhf is built in the private repo because the assets ship through the license endpoint. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 04:23:48 +07:00
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);
}
} catch (err) {
console.error(`compileBoardProgram(${boardId}):`, err);
return;
}
}
}
set((s) => {
const boards = s.boards.map((b) =>
b.id === boardId ? { ...b, compiledProgram: program } : b,
);
const isActive = s.activeBoardId === boardId;
return {
boards,
...(isActive ? { compiledHex: program, hexEpoch: s.hexEpoch + 1 } : {}),
};
});
},
loadMicroPythonProgram: async (
boardId: string,
files: Array<{ name: string; content: string }>,
) => {
const board = get().boards.find((b) => b.id === boardId);
if (!board) return;
if (!BOARD_SUPPORTS_MICROPYTHON.has(board.boardKind)) return;
if (isEsp32Kind(board.boardKind)) {
// ESP32 path: load MicroPython firmware via QEMU bridge, inject code via raw-paste REPL
const { getEsp32Firmware, padToFlashSize, uint8ArrayToBase64 } =
await import('../simulation/Esp32MicroPythonLoader');
const esp32Bridge = getEsp32Bridge(boardId);
if (!esp32Bridge) return;
const firmware = await getEsp32Firmware(board.boardKind);
const b64 = uint8ArrayToBase64(padToFlashSize(firmware, board.boardKind));
esp32Bridge.loadFirmware(b64);
// Queue code injection for after REPL boots. Multi-file projects:
// every .py file other than the entry point gets materialized to the
// MicroPython filesystem (via a prelude executed inside the same raw
// REPL paste) before main.py runs, so `import mylib` resolves.
// Without this, ESP32 projects with helper modules crashed at runtime
// with ModuleNotFoundError.
const mainFile = files.find((f) => f.name === 'main.py') ?? files[0];
if (mainFile) {
const auxFiles = files.filter(
(f) => f !== mainFile && f.name.endsWith('.py'),
);
const preludeLines = auxFiles.map((f) => {
// JSON.stringify produces an ASCII-safe Python-compatible
// string literal (both languages share the same \n \r \t \" \\
// escapes, and JSON does not emit any escape Python rejects).
const lit = JSON.stringify(f.content);
const path = JSON.stringify(f.name);
return `with open(${path},'w') as _f:\n _f.write(${lit})`;
});
const prelude = preludeLines.length
? preludeLines.join('\n') + '\n'
: '';
esp32Bridge.setPendingMicroPythonCode(prelude + mainFile.content);
}
} else {
// RP2040 path: load firmware + filesystem in browser
const sim = getBoardSimulator(boardId);
if (!(sim instanceof RP2040Simulator)) return;
await sim.loadMicroPython(files);
}
set((s) => {
const boards = s.boards.map((b) =>
b.id === boardId ? { ...b, compiledProgram: 'micropython-loaded' } : b,
);
const isActive = s.activeBoardId === boardId;
return {
boards,
...(isActive ? { compiledHex: 'micropython-loaded', hexEpoch: s.hexEpoch + 1 } : {}),
};
});
},
setBoardLanguageMode: (boardId: string, mode: LanguageMode) => {
const board = get().boards.find((b) => b.id === boardId);
if (!board) return;
// Only allow MicroPython for supported boards
if (mode === 'micropython' && !BOARD_SUPPORTS_MICROPYTHON.has(board.boardKind)) return;
// Stop any running simulation
if (board.running) get().stopBoard(boardId);
// Clear compiled program since language changed
set((s) => ({
boards: s.boards.map((b) =>
b.id === boardId ? { ...b, languageMode: mode, compiledProgram: null } : b,
),
}));
// Replace file group with appropriate default files and activate it
const editorStore = useEditorStore.getState();
editorStore.deleteFileGroup(board.activeFileGroupId);
editorStore.createFileGroup(board.activeFileGroupId, mode);
editorStore.setActiveGroup(board.activeFileGroupId);
},
startBoard: (boardId: string) => {
const board = get().boards.find((b) => b.id === boardId);
if (!board) return;
feat(pi): Phase 3.3 — Pi Zero / Pi 1 / Pi 2 armhf simulators Closes the deferred Phase 3.3. Root-causes the Pi 2 "Attempted to kill init" panic as `mount /dev/vda` failing with EINVAL — Debian armmp does not have ext4 builtin (only fuseblk in /proc/filesystems). - qemu_manager: PI_CONFIGS gains raspberry-pi-zero / -1 / -2 entries. All three use the armmp armhf kernel + Cortex-A7 CPU + the mmio virtio transport (arm-32 virt PCI fails -75 due to missing reg DT property). Pi Zero / Pi 1 get the small 1-core / 512 MB profile; Pi 2 gets 4-core / 1 GB. QEMU command builder branches on cfg.bus for virtio-blk-pci vs virtio-blk-device (and serial likewise). - manifest.json: new `raspberry-pi-armhf` image_set wiring three assets (kernel + initramfs + zstd rootfs). - Frontend BoardKind gains the three new kinds + an isPiBoardKind() helper. Replaces the eight scattered `=== 'raspberry-pi-3' || === 'raspberry-pi-4' || === 'raspberry-pi-5'` branches in useSimulatorStore, Interconnect, loadExample, boardProtocols. ComponentRegistry gets three new picker entries. - board-kinds-coverage test: ACCEPTED_UNCOVERED gains the new kinds (backend boards have no canvas examples). The matching armhf build-pi-kernel.sh / build-pi-rootfs.sh changes live in velxio-prod's scripts/ (private overlay) — the upstream kernel build script only knows about arm64; armhf is built in the private repo because the assets ship through the license endpoint. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 04:23:48 +07:00
if (isPiBoardKind(board.boardKind)) {
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<Record<string, unknown>> = [];
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<string, unknown> = {
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<string, unknown> = {
sensor_type: i2cDef.sensorType,
pin: virtualPin,
addr,
};
for (const key of i2cDef.propertyKeys ?? []) {
const val = comp.properties[key];
if (val !== undefined) props[key] = typeof val === 'string' ? parseFloat(val) : val;
}
sensors.push(props);
}
esp32Bridge.setSensors(sensors);
// Use WiFi flag set by the compiler (most reliable — avoids stale file group issues).
// Fall back to scanning the active file group if the flag hasn't been set yet.
let hasWifi = board.hasWifi;
if (hasWifi === undefined) {
const editorState = useEditorStore.getState();
const rawFiles = editorState.fileGroups[board.activeFileGroupId];
const boardFiles = rawFiles && rawFiles.length > 0 ? rawFiles : editorState.files;
hasWifi = boardFiles.some(
(f) =>
f.content.includes('#include <WiFi.h>') ||
f.content.includes('#include <esp_wifi.h>') ||
f.content.includes('#include "WiFi.h"') ||
f.content.includes('WiFi.begin('),
);
}
esp32Bridge.wifiEnabled = hasWifi;
// Ensure firmware is loaded into the bridge (handles page-refresh case
// where _pendingFirmware is lost but compiledProgram is still in store).
if (!esp32Bridge.hasFirmware() && board.compiledProgram) {
esp32Bridge.loadFirmware(board.compiledProgram);
}
esp32Bridge.connect();
}
} else {
getBoardSimulator(boardId)?.start();
// Pico W: open the network bridge here too, alongside the local
// RP2040 sim. Auto-detect WiFi from the board's source files.
if (board.boardKind === 'pi-pico-w') {
const cyw43 = getCyw43Bridge(boardId);
if (cyw43) {
const editorState = useEditorStore.getState();
const rawFiles = editorState.fileGroups[board.activeFileGroupId];
const boardFiles =
rawFiles && rawFiles.length > 0 ? rawFiles : editorState.files;
const hasWifi = boardFiles.some(
(f) =>
/import\s+network\b/.test(f.content) ||
/network\.WLAN/.test(f.content) ||
/#include\s*[<"]WiFi\.h[>"]/.test(f.content) ||
/WiFi\.begin\(/.test(f.content),
);
cyw43.wifiEnabled = hasWifi;
cyw43.connect();
}
}
}
set((s) => {
const boards = s.boards.map((b) =>
b.id === boardId ? { ...b, running: true, serialMonitorOpen: true } : b,
);
const isActive = s.activeBoardId === boardId;
return { boards, ...(isActive ? { running: true, serialMonitorOpen: true } : {}) };
});
},
stopBoard: (boardId: string) => {
const board = get().boards.find((b) => b.id === boardId);
if (!board) return;
feat(pi): Phase 3.3 — Pi Zero / Pi 1 / Pi 2 armhf simulators Closes the deferred Phase 3.3. Root-causes the Pi 2 "Attempted to kill init" panic as `mount /dev/vda` failing with EINVAL — Debian armmp does not have ext4 builtin (only fuseblk in /proc/filesystems). - qemu_manager: PI_CONFIGS gains raspberry-pi-zero / -1 / -2 entries. All three use the armmp armhf kernel + Cortex-A7 CPU + the mmio virtio transport (arm-32 virt PCI fails -75 due to missing reg DT property). Pi Zero / Pi 1 get the small 1-core / 512 MB profile; Pi 2 gets 4-core / 1 GB. QEMU command builder branches on cfg.bus for virtio-blk-pci vs virtio-blk-device (and serial likewise). - manifest.json: new `raspberry-pi-armhf` image_set wiring three assets (kernel + initramfs + zstd rootfs). - Frontend BoardKind gains the three new kinds + an isPiBoardKind() helper. Replaces the eight scattered `=== 'raspberry-pi-3' || === 'raspberry-pi-4' || === 'raspberry-pi-5'` branches in useSimulatorStore, Interconnect, loadExample, boardProtocols. ComponentRegistry gets three new picker entries. - board-kinds-coverage test: ACCEPTED_UNCOVERED gains the new kinds (backend boards have no canvas examples). The matching armhf build-pi-kernel.sh / build-pi-rootfs.sh changes live in velxio-prod's scripts/ (private overlay) — the upstream kernel build script only knows about arm64; armhf is built in the private repo because the assets ship through the license endpoint. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 04:23:48 +07:00
if (isPiBoardKind(board.boardKind)) {
getBoardBridge(boardId)?.disconnect();
} else if (isEsp32Kind(board.boardKind)) {
getEsp32Bridge(boardId)?.disconnect();
} else {
getBoardSimulator(boardId)?.stop();
}
fix(spice+pipeline): LED visualization, INPUT_PULLUP, ESP32-C3, PWM fade, examples End-to-end pipeline fixes uncovered while auditing the /examples gallery. Each bug shipped past green unit + snapshot tests because none of those run firmware + render LEDs. Added scripts/visual-led-test.mjs as a CDP-driven visual harness that loads each example, runs the simulator, samples `wokwi-led.brightness`, and asserts toggle / gradient / initial-off invariants — exits non-zero on any regression. Frontend simulator - PinManager.updatePort: new optional ddrMask param. A pin is added to `outputPins` only if the DDR bit is set, so the PORTx write that enables INPUT_PULLUP (DDR=0, PORT=1) no longer falsely marks the pin as MCU output. AVRSimulator now reads DDRB/C/D (0x24/0x27/0x2A on Uno/Nano, 0x37 on ATtiny85, per-port table on Mega) and forwards it. - AVRSimulator: pass DDR mask alongside every port-listener fire. - BasicParts pushbutton{,-6mm}: seed pin HIGH in attachEvents so `digitalRead()` returns HIGH while idle. avr8js doesn't auto-simulate INPUT_PULLUP — without this the firmware reads LOW from boot and thinks the button is permanently pressed (the "LED is always on, pressing does nothing" UX bug). - connectMcuEdgesToService: suppress synthetic digital edges on pins with active PWM, AND subscribe to onPwmChange to re-tick the netlist on duty changes. Fade-LED now produces a true gradient (6 distinct brightness levels across a fade cycle) instead of a binary 0/full toggle. - CircuitSimulationService.handleMcuEdge: replace single-slot pendingMcuEdge with a per-pin Map. Multiple pins toggling during the same in-flight tick used to overwrite each other; now every pin's most-recent edge replays after the tick. Fixes Traffic-Light RED→ YELLOW→GREEN sequencing. - NetlistBuilder: new sanitizeSpiceId() helper replaces hyphens with underscores in V-source names. ngspice's interactive `alter` command treats `-` as an operator and silently no-ops on hyphenated source names, so mid-simulation MCU pin transitions stopped propagating after the first solve. MixedModeScheduler.onMcuPinChange and CircuitSimulationService self-heal use the same sanitizer so names stay consistent across emit/alter/lookup. Also added a regex-based fallback in step 2 so any board pin matching `GND.\d+` canonicalises to net "0" — ESP32-C3 dev kits expose up to 10 GND pins and the per-board `groundPinNames` list missed several, leaving wires floating instead of grounded. - collectPinStates: emit V-sources only for pins in `outputPins`, not every wired board pin. Leaves INPUT pins (analog sensors on A0, pull-down dividers, etc.) free for the SPICE solver instead of being shorted to 0 V by an ideal MCU V-source. - start.ts: extended __spiceDebug to also expose outputPinsByBoard + nodeVoltages + pinNetMapEntries for the visual harness. - ESP32 / RP2040 / RISC-V / C3 simulators: pass `'mcu'` source flag to triggerPinChange / setPinState so the new outputPins tracking fires on those boards too (was AVR-only before). - useSimulatorStore: stopBoard/resetBoard call pm.resetPinStates() so outputPins clears between runs; Esp32Bridge.onPinChange passes the `'mcu'` flag in all three places it's wired. - types/board.ts: ATtiny85 FQBN `clock=internal16mhz` → `clock=16pll` (ATTinyCore 1.5.2 renamed the option). Backend - esp-idf-template/main/CMakeLists.txt: skip the `-DLED_BUILTIN=2` fallback for esp32c3 and esp32s3 targets. Both variants already define LED_BUILTIN in pins_arduino.h via a self-define macro (`#define LED_BUILTIN LED_BUILTIN` + `static const uint8_t LED_BUILTIN = ...;`). Pre-defining the symbol from the command line expanded the static-const declaration to `static const uint8_t 2 = ...;` — a syntax error that broke every ESP32-C3 / S3 build (`expected unqualified-id before numeric constant`). Examples - examples.ts: bulk-fix 72 wire endpoints that referenced `componentId: 'nano-rp2040'` / `'esp32-c3'` etc. (boards that don't exist on the canvas). Replaced with `'arduino-uno'` (the canvas board-id convention) and converted `D<n>` pin names to `GP<n>` for Pico-style boards. Affects pico-blink, pico-i2c-scanner, pico-i2c-rtc-read, pico-spi-loopback, c3-blink and others. Tests - scripts/visual-led-test.mjs: CDP-driven harness. Default suite covers Blink (single-pin), Button (idle-OFF invariant — catches the INPUT_PULLUP regression), Traffic-Light (multi-pin sequencing), Fade-LED (PWM gradient — ≥3 distinct levels), RGB-LED (≥3 PWM pins driven). Run via `npm --prefix frontend run test:visual` against a Chrome on `:9222` + vite on `:5174` + backend on `:8001`. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-19 07:47:45 +07:00
// Drop MCU-output classification so the next Run starts clean and
// collectPinStates doesn't emit stale V-sources before the new
// firmware has driven any pins.
getBoardPinManager(boardId)?.resetPinStates();
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);
}
feat(pi): Phase 3.3 — Pi Zero / Pi 1 / Pi 2 armhf simulators Closes the deferred Phase 3.3. Root-causes the Pi 2 "Attempted to kill init" panic as `mount /dev/vda` failing with EINVAL — Debian armmp does not have ext4 builtin (only fuseblk in /proc/filesystems). - qemu_manager: PI_CONFIGS gains raspberry-pi-zero / -1 / -2 entries. All three use the armmp armhf kernel + Cortex-A7 CPU + the mmio virtio transport (arm-32 virt PCI fails -75 due to missing reg DT property). Pi Zero / Pi 1 get the small 1-core / 512 MB profile; Pi 2 gets 4-core / 1 GB. QEMU command builder branches on cfg.bus for virtio-blk-pci vs virtio-blk-device (and serial likewise). - manifest.json: new `raspberry-pi-armhf` image_set wiring three assets (kernel + initramfs + zstd rootfs). - Frontend BoardKind gains the three new kinds + an isPiBoardKind() helper. Replaces the eight scattered `=== 'raspberry-pi-3' || === 'raspberry-pi-4' || === 'raspberry-pi-5'` branches in useSimulatorStore, Interconnect, loadExample, boardProtocols. ComponentRegistry gets three new picker entries. - board-kinds-coverage test: ACCEPTED_UNCOVERED gains the new kinds (backend boards have no canvas examples). The matching armhf build-pi-kernel.sh / build-pi-rootfs.sh changes live in velxio-prod's scripts/ (private overlay) — the upstream kernel build script only knows about arm64; armhf is built in the private repo because the assets ship through the license endpoint. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 04:23:48 +07:00
} else if (!isPiBoardKind(board.boardKind)) {
const sim = getBoardSimulator(boardId);
if (sim) {
sim.reset();
fix(spice+pipeline): LED visualization, INPUT_PULLUP, ESP32-C3, PWM fade, examples End-to-end pipeline fixes uncovered while auditing the /examples gallery. Each bug shipped past green unit + snapshot tests because none of those run firmware + render LEDs. Added scripts/visual-led-test.mjs as a CDP-driven visual harness that loads each example, runs the simulator, samples `wokwi-led.brightness`, and asserts toggle / gradient / initial-off invariants — exits non-zero on any regression. Frontend simulator - PinManager.updatePort: new optional ddrMask param. A pin is added to `outputPins` only if the DDR bit is set, so the PORTx write that enables INPUT_PULLUP (DDR=0, PORT=1) no longer falsely marks the pin as MCU output. AVRSimulator now reads DDRB/C/D (0x24/0x27/0x2A on Uno/Nano, 0x37 on ATtiny85, per-port table on Mega) and forwards it. - AVRSimulator: pass DDR mask alongside every port-listener fire. - BasicParts pushbutton{,-6mm}: seed pin HIGH in attachEvents so `digitalRead()` returns HIGH while idle. avr8js doesn't auto-simulate INPUT_PULLUP — without this the firmware reads LOW from boot and thinks the button is permanently pressed (the "LED is always on, pressing does nothing" UX bug). - connectMcuEdgesToService: suppress synthetic digital edges on pins with active PWM, AND subscribe to onPwmChange to re-tick the netlist on duty changes. Fade-LED now produces a true gradient (6 distinct brightness levels across a fade cycle) instead of a binary 0/full toggle. - CircuitSimulationService.handleMcuEdge: replace single-slot pendingMcuEdge with a per-pin Map. Multiple pins toggling during the same in-flight tick used to overwrite each other; now every pin's most-recent edge replays after the tick. Fixes Traffic-Light RED→ YELLOW→GREEN sequencing. - NetlistBuilder: new sanitizeSpiceId() helper replaces hyphens with underscores in V-source names. ngspice's interactive `alter` command treats `-` as an operator and silently no-ops on hyphenated source names, so mid-simulation MCU pin transitions stopped propagating after the first solve. MixedModeScheduler.onMcuPinChange and CircuitSimulationService self-heal use the same sanitizer so names stay consistent across emit/alter/lookup. Also added a regex-based fallback in step 2 so any board pin matching `GND.\d+` canonicalises to net "0" — ESP32-C3 dev kits expose up to 10 GND pins and the per-board `groundPinNames` list missed several, leaving wires floating instead of grounded. - collectPinStates: emit V-sources only for pins in `outputPins`, not every wired board pin. Leaves INPUT pins (analog sensors on A0, pull-down dividers, etc.) free for the SPICE solver instead of being shorted to 0 V by an ideal MCU V-source. - start.ts: extended __spiceDebug to also expose outputPinsByBoard + nodeVoltages + pinNetMapEntries for the visual harness. - ESP32 / RP2040 / RISC-V / C3 simulators: pass `'mcu'` source flag to triggerPinChange / setPinState so the new outputPins tracking fires on those boards too (was AVR-only before). - useSimulatorStore: stopBoard/resetBoard call pm.resetPinStates() so outputPins clears between runs; Esp32Bridge.onPinChange passes the `'mcu'` flag in all three places it's wired. - types/board.ts: ATtiny85 FQBN `clock=internal16mhz` → `clock=16pll` (ATTinyCore 1.5.2 renamed the option). Backend - esp-idf-template/main/CMakeLists.txt: skip the `-DLED_BUILTIN=2` fallback for esp32c3 and esp32s3 targets. Both variants already define LED_BUILTIN in pins_arduino.h via a self-define macro (`#define LED_BUILTIN LED_BUILTIN` + `static const uint8_t LED_BUILTIN = ...;`). Pre-defining the symbol from the command line expanded the static-const declaration to `static const uint8_t 2 = ...;` — a syntax error that broke every ESP32-C3 / S3 build (`expected unqualified-id before numeric constant`). Examples - examples.ts: bulk-fix 72 wire endpoints that referenced `componentId: 'nano-rp2040'` / `'esp32-c3'` etc. (boards that don't exist on the canvas). Replaced with `'arduino-uno'` (the canvas board-id convention) and converted `D<n>` pin names to `GP<n>` for Pico-style boards. Affects pico-blink, pico-i2c-scanner, pico-i2c-rtc-read, pico-spi-loopback, c3-blink and others. Tests - scripts/visual-led-test.mjs: CDP-driven harness. Default suite covers Blink (single-pin), Button (idle-OFF invariant — catches the INPUT_PULLUP regression), Traffic-Light (multi-pin sequencing), Fade-LED (PWM gradient — ≥3 distinct levels), RGB-LED (≥3 PWM pins driven). Run via `npm --prefix frontend run test:visual` against a Chrome on `:9222` + vite on `:5174` + backend on `:8001`. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-19 07:47:45 +07:00
// Drop MCU-output classification so the new program starts
// clean — no stale V-sources from the previous run.
getBoardPinManager(boardId)?.resetPinStates();
// Re-wire serial callback after reset
sim.onSerialData = (ch) => appendSerial(boardId, ch);
if (sim instanceof AVRSimulator) {
sim.onBaudRateChange = (baud) => {
set((s) => {
const boards = s.boards.map((b) =>
b.id === boardId ? { ...b, serialBaudRate: baud } : b,
);
const isActive = s.activeBoardId === boardId;
return { boards, ...(isActive ? { serialBaudRate: baud } : {}) };
});
};
}
}
}
set((s) => {
const boards = s.boards.map((b) =>
b.id === boardId ? { ...b, running: false, serialOutput: '', serialBaudRate: 0 } : b,
);
const isActive = s.activeBoardId === boardId;
return {
boards,
...(isActive ? { running: false, serialOutput: '', serialBaudRate: 0 } : {}),
};
});
},
// ── Legacy single-board API ───────────────────────────────────────────
boardType: 'arduino-uno',
boardPosition: { ...DEFAULT_BOARD_POSITION },
simulator: initialSim,
pinManager: legacyPinManager,
running: false,
compiledHex: null,
hexEpoch: 0,
serialOutput: '',
serialBaudRate: 0,
serialMonitorOpen: false,
remoteConnected: false,
remoteSocket: null,
esp32CrashBoardId: null,
dismissEsp32Crash: () => set({ esp32CrashBoardId: null }),
setBoardType: (type: BoardType) => {
const { activeBoardId, running, stopSimulation } = get();
if (running) stopSimulation();
const boardId = activeBoardId ?? INITIAL_BOARD_ID;
const pm = getBoardPinManager(boardId) ?? legacyPinManager;
// Stop and remove old simulator / bridge
getBoardSimulator(boardId)?.stop();
simulatorMap.delete(boardId);
getEsp32Bridge(boardId)?.disconnect();
esp32BridgeMap.delete(boardId);
const serialCallback = (ch: string) => appendSerial(boardId, ch);
if (isEsp32Kind(type as BoardKind)) {
// ESP32: use bridge, not AVR simulator
const bridge = new Esp32Bridge(boardId, type as BoardKind);
bridge.onSerialData = serialCallback;
bridge.onPinChange = (gpioPin, state) => {
const boardPm = pinManagerMap.get(boardId);
fix(spice+pipeline): LED visualization, INPUT_PULLUP, ESP32-C3, PWM fade, examples End-to-end pipeline fixes uncovered while auditing the /examples gallery. Each bug shipped past green unit + snapshot tests because none of those run firmware + render LEDs. Added scripts/visual-led-test.mjs as a CDP-driven visual harness that loads each example, runs the simulator, samples `wokwi-led.brightness`, and asserts toggle / gradient / initial-off invariants — exits non-zero on any regression. Frontend simulator - PinManager.updatePort: new optional ddrMask param. A pin is added to `outputPins` only if the DDR bit is set, so the PORTx write that enables INPUT_PULLUP (DDR=0, PORT=1) no longer falsely marks the pin as MCU output. AVRSimulator now reads DDRB/C/D (0x24/0x27/0x2A on Uno/Nano, 0x37 on ATtiny85, per-port table on Mega) and forwards it. - AVRSimulator: pass DDR mask alongside every port-listener fire. - BasicParts pushbutton{,-6mm}: seed pin HIGH in attachEvents so `digitalRead()` returns HIGH while idle. avr8js doesn't auto-simulate INPUT_PULLUP — without this the firmware reads LOW from boot and thinks the button is permanently pressed (the "LED is always on, pressing does nothing" UX bug). - connectMcuEdgesToService: suppress synthetic digital edges on pins with active PWM, AND subscribe to onPwmChange to re-tick the netlist on duty changes. Fade-LED now produces a true gradient (6 distinct brightness levels across a fade cycle) instead of a binary 0/full toggle. - CircuitSimulationService.handleMcuEdge: replace single-slot pendingMcuEdge with a per-pin Map. Multiple pins toggling during the same in-flight tick used to overwrite each other; now every pin's most-recent edge replays after the tick. Fixes Traffic-Light RED→ YELLOW→GREEN sequencing. - NetlistBuilder: new sanitizeSpiceId() helper replaces hyphens with underscores in V-source names. ngspice's interactive `alter` command treats `-` as an operator and silently no-ops on hyphenated source names, so mid-simulation MCU pin transitions stopped propagating after the first solve. MixedModeScheduler.onMcuPinChange and CircuitSimulationService self-heal use the same sanitizer so names stay consistent across emit/alter/lookup. Also added a regex-based fallback in step 2 so any board pin matching `GND.\d+` canonicalises to net "0" — ESP32-C3 dev kits expose up to 10 GND pins and the per-board `groundPinNames` list missed several, leaving wires floating instead of grounded. - collectPinStates: emit V-sources only for pins in `outputPins`, not every wired board pin. Leaves INPUT pins (analog sensors on A0, pull-down dividers, etc.) free for the SPICE solver instead of being shorted to 0 V by an ideal MCU V-source. - start.ts: extended __spiceDebug to also expose outputPinsByBoard + nodeVoltages + pinNetMapEntries for the visual harness. - ESP32 / RP2040 / RISC-V / C3 simulators: pass `'mcu'` source flag to triggerPinChange / setPinState so the new outputPins tracking fires on those boards too (was AVR-only before). - useSimulatorStore: stopBoard/resetBoard call pm.resetPinStates() so outputPins clears between runs; Esp32Bridge.onPinChange passes the `'mcu'` flag in all three places it's wired. - types/board.ts: ATtiny85 FQBN `clock=internal16mhz` → `clock=16pll` (ATTinyCore 1.5.2 renamed the option). Backend - esp-idf-template/main/CMakeLists.txt: skip the `-DLED_BUILTIN=2` fallback for esp32c3 and esp32s3 targets. Both variants already define LED_BUILTIN in pins_arduino.h via a self-define macro (`#define LED_BUILTIN LED_BUILTIN` + `static const uint8_t LED_BUILTIN = ...;`). Pre-defining the symbol from the command line expanded the static-const declaration to `static const uint8_t 2 = ...;` — a syntax error that broke every ESP32-C3 / S3 build (`expected unqualified-id before numeric constant`). Examples - examples.ts: bulk-fix 72 wire endpoints that referenced `componentId: 'nano-rp2040'` / `'esp32-c3'` etc. (boards that don't exist on the canvas). Replaced with `'arduino-uno'` (the canvas board-id convention) and converted `D<n>` pin names to `GP<n>` for Pico-style boards. Affects pico-blink, pico-i2c-scanner, pico-i2c-rtc-read, pico-spi-loopback, c3-blink and others. Tests - scripts/visual-led-test.mjs: CDP-driven harness. Default suite covers Blink (single-pin), Button (idle-OFF invariant — catches the INPUT_PULLUP regression), Traffic-Light (multi-pin sequencing), Fade-LED (PWM gradient — ≥3 distinct levels), RGB-LED (≥3 PWM pins driven). Run via `npm --prefix frontend run test:visual` against a Chrome on `:9222` + vite on `:5174` + backend on `:8001`. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-19 07:47:45 +07:00
if (boardPm) boardPm.triggerPinChange(gpioPin, state, 'mcu');
};
feat(esp32/uart): synthesize bit-level TX waveform on UART0 TX GPIO Closes the same gap as the AVR / RP2040 commits — qemu-lcgamboa's UART transmits the byte over the WebSocket as a 'serial_output' event with no GPIO toggle, so an oscilloscope on the ESP32 TX pin saw nothing while real silicon would render the 8N1 frame at the configured baud rate. Two changes inside Esp32Bridge: * New `onPinChangeWithTime: (pin, state, timeMs) => void` callback that hooks the oscilloscope at parity with AVRSimulator / RP2040Simulator. The 'gpio_change' event now also flows through it (timestamped with `performance.now()` — QEMU virtual time isn't surfaced across the wire, but at 1× sim speed the wall-clock skew is invisible on any practical sweep). This also fixes the broader issue that ESP32 boards previously couldn't show ANY digital GPIO activity on the scope. * `emitUartTxFrame(byte, uart)` synthesizes start + 8 data LSB-first + stop transitions at `this.uartBaudRate` (default 115200) on the UART0 TX pin, mapped per board variant: esp32 / esp32-devkit-c-v4 / esp32-cam / wemos-lolin32-lite: GPIO1 esp32-s3 / xiao-esp32-s3 / arduino-nano-esp32: GPIO43 esp32-c3 / xiao-esp32-c3 / aitewinrobot-esp32c3-supermini: GPIO21 Backend doesn't expose the live baud rate so we default to 115200 (the Arduino default). Override path: bridge.uartBaudRate = N once we surface Serial.begin's argument via a backend event. Wire-up: `bridge.onPinChangeWithTime = getOscilloscopeCallback(boardId)` inside the three Esp32Bridge construction sites in useSimulatorStore (setBoardType, addBoard, changeBoard).
2026-05-23 00:58:24 +07:00
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 } : {}) };
});
};
feat(esp32): SignalRouter — model the GPIO Matrix as first-class Replaces the per-peripheral ad-hoc `_ledc_gpio_map` cache with a proper signal-routing abstraction that mirrors the ESP32 SoC's IO_MUX + GPIO Matrix exactly. Same idea as real silicon: signal sources (LEDC channels, RMT, MCPWM, ...) → 40-entry routing table → GPIO pins. Motivation (from user bug report in velxio.dev/project/5218f9e3-136d-43b3-bba1-6cebde21e1a4): two ESP32 servos on a solar-tracker visibly oscillated between two positions instead of moving smoothly when the user changed LDR sliders. Commit 77bf897 patched it (per-channel gpio memo + broadcast guard) but the user requested a proper hardware-fidel architecture, not patches. Backend: * `app/services/signal_router.py` — SignalRouter class. Forward index (gpio → signal_id) + reverse index (signal_id → set of gpios). `replace_snapshot()` returns the diff for the polling- fallback path; future C plugin hook becomes a push without touching this code. * `app/services/esp32_signals.py` — Signal id constants from ESP32 TRM (LEDC HS 72-79, LS 80-87) + `ledc_signal_for_channel()` helper. * `app/services/esp32_worker.py` — `_ledc_gpio_map` is gone; `_refresh_ledc_gpio_map` replaced by `_refresh_signal_routing` which emits `gpio_routing {gpio, signal_id}` events on diff. The 0x5000 LEDC callback and the LEDC poll thread now emit `ledc_duty {channel, duty_pct}` (canonical, no gpio) alongside the legacy `ledc_update {channel, duty, gpio}` for back-compat during rollout. Frontend: * `simulation/SignalRouter.ts` — 1-to-1 TS mirror of the Python class. Same forward + reverse index; same `pinsForSignal` / `updateRouting` / `clearRouting` API. * `simulation/esp32-signals.ts` — Signal id constants, mirror of the Python module. * `simulation/Esp32Bridge.ts` — new `onLedcDuty`, `onGpioRouting`, `onGpioRoutingClear` callbacks; handlers for the new event types. * `store/useSimulatorStore.ts` — `makeLedcDutyHandler` looks up pins via `router.pinsForSignal(ledcSignalForChannel(channel))` and dispatches per pin. `makeGpioRoutingHandler` / `makeGpioRoutingClearHandler` keep the mirror in sync. Per-board `signalRouterMap` parallels `pinManagerMap` in lifecycle. `makeLedcUpdateHandler` (and its memo workaround from 77bf897) stays wired for back-compat during rollout; removed in a follow-up commit once prod is verified stable on the new path. Tests: * `test/backend/unit/test_signal_router.py` (20 tests) covers update/clear semantics, idempotency, multi-pin routing, snapshot diff, channel↔signal-id helpers, and the multi-servo regression scenario. * `frontend/src/__tests__/SignalRouter.test.ts` (17 tests) is the mirror — same scenarios on the TS side. * `frontend/src/__tests__/esp32-multi-servo-gpio-matrix.test.ts` (6 tests) drives the end-to-end SignalRouter handler pipeline, asserts that two servos on GPIO 13/12 via LEDC channels 0/1 move independently (no mirroring), that re-routing carries cleanly, and — critically — that `PinManager.broadcastPwm` is never called. Totals: +700 LOC, 1876 frontend tests pass (was 1853), 278 backend unit tests pass (was 259). Docs: ESP32_EMULATION.md §9.2 rewritten with the new architecture diagram + a runbook for adding future peripherals through the SignalRouter. The C plugin hook in qemu-lcgamboa that would push gpio_out_sel writes synchronously (eliminating the polling race window entirely) is the next step — kept as a follow-up because the polling-fallback path here already resolves the routing before each duty event fires, so the bug is fixed end-to-end. The plugin work removes the race condition fundamentally.
2026-05-17 10:00:53 +07:00
signalRouterMap.set(boardId, new SignalRouter());
bridge.onLedcDuty = makeLedcDutyHandler(boardId);
bridge.onGpioRouting = makeGpioRoutingHandler(boardId);
bridge.onGpioRoutingClear = makeGpioRoutingClearHandler(boardId);
bridge.onWs2812Update = (channel, pixels) => {
const eventTarget = document.getElementById(`ws2812-${boardId}-${channel}`);
if (eventTarget) {
eventTarget.dispatchEvent(new CustomEvent('ws2812-pixels', { detail: { pixels } }));
}
};
esp32BridgeMap.set(boardId, bridge);
const shim = new Esp32BridgeShim(bridge, pm);
shim.onSerialData = serialCallback;
simulatorMap.set(boardId, shim);
set((s) => ({
boardType: type,
simulator: shim as any,
compiledHex: null,
serialOutput: '',
serialBaudRate: 0,
boards: s.boards.map((b) =>
b.id === boardId
? {
...b,
boardKind: type as BoardKind,
compiledProgram: null,
serialOutput: '',
serialBaudRate: 0,
}
: b,
),
}));
} else {
const sim = createSimulator(
type as BoardKind,
pm,
serialCallback,
(baud) =>
set((s) => {
const boards = s.boards.map((b) =>
b.id === boardId ? { ...b, serialBaudRate: baud } : b,
);
return { boards, serialBaudRate: baud };
}),
getOscilloscopeCallback(boardId),
);
simulatorMap.set(boardId, sim);
set((s) => ({
boardType: type,
simulator: sim,
compiledHex: null,
serialOutput: '',
serialBaudRate: 0,
boards: s.boards.map((b) =>
b.id === boardId
? {
...b,
boardKind: type as BoardKind,
compiledProgram: null,
serialOutput: '',
serialBaudRate: 0,
}
: b,
),
}));
}
console.log(`Board switched to: ${type}`);
},
initSimulator: () => {
const { boardType, activeBoardId } = get();
const boardId = activeBoardId ?? INITIAL_BOARD_ID;
const pm = getBoardPinManager(boardId) ?? legacyPinManager;
getBoardSimulator(boardId)?.stop();
simulatorMap.delete(boardId);
getEsp32Bridge(boardId)?.disconnect();
esp32BridgeMap.delete(boardId);
const serialCallback = (ch: string) => appendSerial(boardId, ch);
if (isEsp32Kind(boardType as BoardKind)) {
// ESP32: create bridge + shim (same as setBoardType)
const bridge = new Esp32Bridge(boardId, boardType as BoardKind);
bridge.onSerialData = serialCallback;
bridge.onPinChange = (gpioPin, state) => {
const boardPm = pinManagerMap.get(boardId);
fix(spice+pipeline): LED visualization, INPUT_PULLUP, ESP32-C3, PWM fade, examples End-to-end pipeline fixes uncovered while auditing the /examples gallery. Each bug shipped past green unit + snapshot tests because none of those run firmware + render LEDs. Added scripts/visual-led-test.mjs as a CDP-driven visual harness that loads each example, runs the simulator, samples `wokwi-led.brightness`, and asserts toggle / gradient / initial-off invariants — exits non-zero on any regression. Frontend simulator - PinManager.updatePort: new optional ddrMask param. A pin is added to `outputPins` only if the DDR bit is set, so the PORTx write that enables INPUT_PULLUP (DDR=0, PORT=1) no longer falsely marks the pin as MCU output. AVRSimulator now reads DDRB/C/D (0x24/0x27/0x2A on Uno/Nano, 0x37 on ATtiny85, per-port table on Mega) and forwards it. - AVRSimulator: pass DDR mask alongside every port-listener fire. - BasicParts pushbutton{,-6mm}: seed pin HIGH in attachEvents so `digitalRead()` returns HIGH while idle. avr8js doesn't auto-simulate INPUT_PULLUP — without this the firmware reads LOW from boot and thinks the button is permanently pressed (the "LED is always on, pressing does nothing" UX bug). - connectMcuEdgesToService: suppress synthetic digital edges on pins with active PWM, AND subscribe to onPwmChange to re-tick the netlist on duty changes. Fade-LED now produces a true gradient (6 distinct brightness levels across a fade cycle) instead of a binary 0/full toggle. - CircuitSimulationService.handleMcuEdge: replace single-slot pendingMcuEdge with a per-pin Map. Multiple pins toggling during the same in-flight tick used to overwrite each other; now every pin's most-recent edge replays after the tick. Fixes Traffic-Light RED→ YELLOW→GREEN sequencing. - NetlistBuilder: new sanitizeSpiceId() helper replaces hyphens with underscores in V-source names. ngspice's interactive `alter` command treats `-` as an operator and silently no-ops on hyphenated source names, so mid-simulation MCU pin transitions stopped propagating after the first solve. MixedModeScheduler.onMcuPinChange and CircuitSimulationService self-heal use the same sanitizer so names stay consistent across emit/alter/lookup. Also added a regex-based fallback in step 2 so any board pin matching `GND.\d+` canonicalises to net "0" — ESP32-C3 dev kits expose up to 10 GND pins and the per-board `groundPinNames` list missed several, leaving wires floating instead of grounded. - collectPinStates: emit V-sources only for pins in `outputPins`, not every wired board pin. Leaves INPUT pins (analog sensors on A0, pull-down dividers, etc.) free for the SPICE solver instead of being shorted to 0 V by an ideal MCU V-source. - start.ts: extended __spiceDebug to also expose outputPinsByBoard + nodeVoltages + pinNetMapEntries for the visual harness. - ESP32 / RP2040 / RISC-V / C3 simulators: pass `'mcu'` source flag to triggerPinChange / setPinState so the new outputPins tracking fires on those boards too (was AVR-only before). - useSimulatorStore: stopBoard/resetBoard call pm.resetPinStates() so outputPins clears between runs; Esp32Bridge.onPinChange passes the `'mcu'` flag in all three places it's wired. - types/board.ts: ATtiny85 FQBN `clock=internal16mhz` → `clock=16pll` (ATTinyCore 1.5.2 renamed the option). Backend - esp-idf-template/main/CMakeLists.txt: skip the `-DLED_BUILTIN=2` fallback for esp32c3 and esp32s3 targets. Both variants already define LED_BUILTIN in pins_arduino.h via a self-define macro (`#define LED_BUILTIN LED_BUILTIN` + `static const uint8_t LED_BUILTIN = ...;`). Pre-defining the symbol from the command line expanded the static-const declaration to `static const uint8_t 2 = ...;` — a syntax error that broke every ESP32-C3 / S3 build (`expected unqualified-id before numeric constant`). Examples - examples.ts: bulk-fix 72 wire endpoints that referenced `componentId: 'nano-rp2040'` / `'esp32-c3'` etc. (boards that don't exist on the canvas). Replaced with `'arduino-uno'` (the canvas board-id convention) and converted `D<n>` pin names to `GP<n>` for Pico-style boards. Affects pico-blink, pico-i2c-scanner, pico-i2c-rtc-read, pico-spi-loopback, c3-blink and others. Tests - scripts/visual-led-test.mjs: CDP-driven harness. Default suite covers Blink (single-pin), Button (idle-OFF invariant — catches the INPUT_PULLUP regression), Traffic-Light (multi-pin sequencing), Fade-LED (PWM gradient — ≥3 distinct levels), RGB-LED (≥3 PWM pins driven). Run via `npm --prefix frontend run test:visual` against a Chrome on `:9222` + vite on `:5174` + backend on `:8001`. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-19 07:47:45 +07:00
if (boardPm) boardPm.triggerPinChange(gpioPin, state, 'mcu');
};
feat(esp32/uart): synthesize bit-level TX waveform on UART0 TX GPIO Closes the same gap as the AVR / RP2040 commits — qemu-lcgamboa's UART transmits the byte over the WebSocket as a 'serial_output' event with no GPIO toggle, so an oscilloscope on the ESP32 TX pin saw nothing while real silicon would render the 8N1 frame at the configured baud rate. Two changes inside Esp32Bridge: * New `onPinChangeWithTime: (pin, state, timeMs) => void` callback that hooks the oscilloscope at parity with AVRSimulator / RP2040Simulator. The 'gpio_change' event now also flows through it (timestamped with `performance.now()` — QEMU virtual time isn't surfaced across the wire, but at 1× sim speed the wall-clock skew is invisible on any practical sweep). This also fixes the broader issue that ESP32 boards previously couldn't show ANY digital GPIO activity on the scope. * `emitUartTxFrame(byte, uart)` synthesizes start + 8 data LSB-first + stop transitions at `this.uartBaudRate` (default 115200) on the UART0 TX pin, mapped per board variant: esp32 / esp32-devkit-c-v4 / esp32-cam / wemos-lolin32-lite: GPIO1 esp32-s3 / xiao-esp32-s3 / arduino-nano-esp32: GPIO43 esp32-c3 / xiao-esp32-c3 / aitewinrobot-esp32c3-supermini: GPIO21 Backend doesn't expose the live baud rate so we default to 115200 (the Arduino default). Override path: bridge.uartBaudRate = N once we surface Serial.begin's argument via a backend event. Wire-up: `bridge.onPinChangeWithTime = getOscilloscopeCallback(boardId)` inside the three Esp32Bridge construction sites in useSimulatorStore (setBoardType, addBoard, changeBoard).
2026-05-23 00:58:24 +07:00
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 } : {}) };
});
};
feat(esp32): SignalRouter — model the GPIO Matrix as first-class Replaces the per-peripheral ad-hoc `_ledc_gpio_map` cache with a proper signal-routing abstraction that mirrors the ESP32 SoC's IO_MUX + GPIO Matrix exactly. Same idea as real silicon: signal sources (LEDC channels, RMT, MCPWM, ...) → 40-entry routing table → GPIO pins. Motivation (from user bug report in velxio.dev/project/5218f9e3-136d-43b3-bba1-6cebde21e1a4): two ESP32 servos on a solar-tracker visibly oscillated between two positions instead of moving smoothly when the user changed LDR sliders. Commit 77bf897 patched it (per-channel gpio memo + broadcast guard) but the user requested a proper hardware-fidel architecture, not patches. Backend: * `app/services/signal_router.py` — SignalRouter class. Forward index (gpio → signal_id) + reverse index (signal_id → set of gpios). `replace_snapshot()` returns the diff for the polling- fallback path; future C plugin hook becomes a push without touching this code. * `app/services/esp32_signals.py` — Signal id constants from ESP32 TRM (LEDC HS 72-79, LS 80-87) + `ledc_signal_for_channel()` helper. * `app/services/esp32_worker.py` — `_ledc_gpio_map` is gone; `_refresh_ledc_gpio_map` replaced by `_refresh_signal_routing` which emits `gpio_routing {gpio, signal_id}` events on diff. The 0x5000 LEDC callback and the LEDC poll thread now emit `ledc_duty {channel, duty_pct}` (canonical, no gpio) alongside the legacy `ledc_update {channel, duty, gpio}` for back-compat during rollout. Frontend: * `simulation/SignalRouter.ts` — 1-to-1 TS mirror of the Python class. Same forward + reverse index; same `pinsForSignal` / `updateRouting` / `clearRouting` API. * `simulation/esp32-signals.ts` — Signal id constants, mirror of the Python module. * `simulation/Esp32Bridge.ts` — new `onLedcDuty`, `onGpioRouting`, `onGpioRoutingClear` callbacks; handlers for the new event types. * `store/useSimulatorStore.ts` — `makeLedcDutyHandler` looks up pins via `router.pinsForSignal(ledcSignalForChannel(channel))` and dispatches per pin. `makeGpioRoutingHandler` / `makeGpioRoutingClearHandler` keep the mirror in sync. Per-board `signalRouterMap` parallels `pinManagerMap` in lifecycle. `makeLedcUpdateHandler` (and its memo workaround from 77bf897) stays wired for back-compat during rollout; removed in a follow-up commit once prod is verified stable on the new path. Tests: * `test/backend/unit/test_signal_router.py` (20 tests) covers update/clear semantics, idempotency, multi-pin routing, snapshot diff, channel↔signal-id helpers, and the multi-servo regression scenario. * `frontend/src/__tests__/SignalRouter.test.ts` (17 tests) is the mirror — same scenarios on the TS side. * `frontend/src/__tests__/esp32-multi-servo-gpio-matrix.test.ts` (6 tests) drives the end-to-end SignalRouter handler pipeline, asserts that two servos on GPIO 13/12 via LEDC channels 0/1 move independently (no mirroring), that re-routing carries cleanly, and — critically — that `PinManager.broadcastPwm` is never called. Totals: +700 LOC, 1876 frontend tests pass (was 1853), 278 backend unit tests pass (was 259). Docs: ESP32_EMULATION.md §9.2 rewritten with the new architecture diagram + a runbook for adding future peripherals through the SignalRouter. The C plugin hook in qemu-lcgamboa that would push gpio_out_sel writes synchronously (eliminating the polling race window entirely) is the next step — kept as a follow-up because the polling-fallback path here already resolves the routing before each duty event fires, so the bug is fixed end-to-end. The plugin work removes the race condition fundamentally.
2026-05-17 10:00:53 +07:00
signalRouterMap.set(boardId, new SignalRouter());
bridge.onLedcDuty = makeLedcDutyHandler(boardId);
bridge.onGpioRouting = makeGpioRoutingHandler(boardId);
bridge.onGpioRoutingClear = makeGpioRoutingClearHandler(boardId);
bridge.onWs2812Update = (channel, pixels) => {
const eventTarget = document.getElementById(`ws2812-${boardId}-${channel}`);
if (eventTarget) {
eventTarget.dispatchEvent(new CustomEvent('ws2812-pixels', { detail: { pixels } }));
}
};
esp32BridgeMap.set(boardId, bridge);
const shim = new Esp32BridgeShim(bridge, pm);
shim.onSerialData = serialCallback;
simulatorMap.set(boardId, shim);
set({ simulator: shim as any, serialOutput: '', serialBaudRate: 0 });
} else {
const sim = createSimulator(
boardType as BoardKind,
pm,
serialCallback,
(baud) =>
set((s) => {
const boards = s.boards.map((b) =>
b.id === boardId ? { ...b, serialBaudRate: baud } : b,
);
return { boards, serialBaudRate: baud };
}),
getOscilloscopeCallback(boardId),
);
simulatorMap.set(boardId, sim);
set({ simulator: sim, serialOutput: '', serialBaudRate: 0 });
}
console.log(`Simulator initialized: ${boardType}`);
},
loadHex: (hex: string) => {
const { activeBoardId } = get();
const boardId = activeBoardId ?? INITIAL_BOARD_ID;
const sim = getBoardSimulator(boardId);
if (sim && sim instanceof AVRSimulator) {
try {
sim.loadHex(hex);
sim.addI2CDevice(new VirtualDS1307());
sim.addI2CDevice(new VirtualTempSensor());
sim.addI2CDevice(new I2CMemoryDevice(0x50));
set((s) => ({ compiledHex: hex, hexEpoch: s.hexEpoch + 1 }));
console.log('HEX file loaded successfully');
} catch (error) {
console.error('Failed to load HEX:', error);
}
} else {
console.warn('loadHex: simulator not initialized or wrong board type');
}
},
loadBinary: (base64: string) => {
const { activeBoardId } = get();
const boardId = activeBoardId ?? INITIAL_BOARD_ID;
const sim = getBoardSimulator(boardId);
if (sim && sim instanceof RP2040Simulator) {
try {
sim.loadBinary(base64);
sim.addI2CDevice(new VirtualDS1307() as RP2040I2CDevice);
sim.addI2CDevice(new VirtualTempSensor() as RP2040I2CDevice);
sim.addI2CDevice(new I2CMemoryDevice(0x50) as RP2040I2CDevice);
set((s) => ({ compiledHex: base64, hexEpoch: s.hexEpoch + 1 }));
console.log('Binary loaded into RP2040 successfully');
} catch (error) {
console.error('Failed to load binary:', error);
}
} else {
console.warn('loadBinary: simulator not initialized or wrong board type');
}
},
startSimulation: () => {
const { activeBoardId } = get();
const boardId = activeBoardId ?? INITIAL_BOARD_ID;
get().startBoard(boardId);
},
stopSimulation: () => {
const { activeBoardId } = get();
const boardId = activeBoardId ?? INITIAL_BOARD_ID;
get().stopBoard(boardId);
},
resetSimulation: () => {
const { activeBoardId } = get();
const boardId = activeBoardId ?? INITIAL_BOARD_ID;
get().resetBoard(boardId);
},
setCompiledHex: (hex: string) => {
set({ compiledHex: hex });
get().loadHex(hex);
},
setCompiledBinary: (base64: string) => {
set({ compiledHex: base64 });
get().loadBinary(base64);
},
setRunning: (running: boolean) => set({ running }),
connectRemoteSimulator: (clientId: string) => {
// Legacy: connect a Pi bridge for the given clientId
const boardId = clientId;
let bridge = getBoardBridge(boardId);
if (!bridge) {
bridge = new RaspberryPi3Bridge(boardId);
bridge.onSerialData = (ch) => appendSerial(boardId, ch);
bridge.onPinChange = (gpioPin, state) => {
const { wires } = get();
const sim = getBoardSimulator(get().activeBoardId ?? INITIAL_BOARD_ID);
if (!sim) return;
const wire = wires.find(
(w) =>
(w.start.componentId.includes('raspberry-pi') &&
w.start.pinName === String(gpioPin)) ||
(w.end.componentId.includes('raspberry-pi') && w.end.pinName === String(gpioPin)),
);
if (wire) {
const isArduinoStart = !wire.start.componentId.includes('raspberry-pi');
const targetEndpoint = isArduinoStart ? wire.start : wire.end;
const pinNum = parseInt(targetEndpoint.pinName, 10);
if (!isNaN(pinNum)) sim.setPinState(pinNum, state);
}
};
bridgeMap.set(boardId, bridge);
}
bridge.connect();
set({ remoteConnected: true });
},
disconnectRemoteSimulator: () => {
const { activeBoardId } = get();
const boardId = activeBoardId ?? INITIAL_BOARD_ID;
getBoardBridge(boardId)?.disconnect();
set({ remoteConnected: false, remoteSocket: null });
},
sendRemotePinEvent: (pin: string, state: number) => {
const { activeBoardId } = get();
const boardId = activeBoardId ?? INITIAL_BOARD_ID;
getBoardBridge(boardId)?.sendPinEvent(parseInt(pin, 10), state === 1);
},
// ── Components ────────────────────────────────────────────────────────
// Default canvas shown on a bare /editor visit: an external LED on
// pin 13 PROTECTED BY A 220Ω SERIES RESISTOR (the canonical Blink
// wiring textbooks teach). Without the resistor the LED is a direct
// short forward-biased between 5V and GND — real hardware blows the
// diode, and the ngspice solver returns an indeterminate / NaN branch
// current so the visual LED never lights up on the canvas either.
fix(simulator): underscore-separated component ids for SPICE safety The user reported the default editor canvas — Arduino Uno + LED + 220Ω resistor — was correctly powered (1.84 V at the LED anode, 14 mA through the diode) but the LED visual stayed dark. Only the built-in pin-13 LED on the wokwi-arduino-uno element lit up. Root cause: ngspice's WASM build truncates branch-current vector keys at the first hyphen. A sense source named V_led-builtin_sense ends up exposed under a key like v_led#branch rather than the expected v_led-builtin_sense#branch. CircuitSimulationService and BasicParts.ts both look up the FULL key, miss, and the LED's brightness update treats raw as undefined → digital-fallback path runs but the SPICE memo timestamp is fresh so HOLD keeps zero brightness. Visible symptom: a perfectly conducting LED that never lights. Fix in two places: - Default canvas (useSimulatorStore.ts): rename 'led-builtin' / 'r-builtin' to 'led_builtin' / 'r_builtin' (and the matching wire ids). - DynamicComponent.tsx makeNewComponent: the id template was 'metadata.id-timestamp-rand' producing hyphens for every user-added component too. Switched to underscores, AND replace any hyphens already in metadata.id (e.g. 'led-bar-graph') so the prefix doesn't reintroduce the bug. Existing saved projects whose ids contain hyphens are not migrated here — those will keep the visual bug until either the operator edits the components or we add a sanitisation step inside componentToSpice + BasicParts. The next follow-up commit can add that if you confirm this default-canvas fix works.
2026-05-18 20:01:38 +07:00
// 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: [
{
fix(simulator): underscore-separated component ids for SPICE safety The user reported the default editor canvas — Arduino Uno + LED + 220Ω resistor — was correctly powered (1.84 V at the LED anode, 14 mA through the diode) but the LED visual stayed dark. Only the built-in pin-13 LED on the wokwi-arduino-uno element lit up. Root cause: ngspice's WASM build truncates branch-current vector keys at the first hyphen. A sense source named V_led-builtin_sense ends up exposed under a key like v_led#branch rather than the expected v_led-builtin_sense#branch. CircuitSimulationService and BasicParts.ts both look up the FULL key, miss, and the LED's brightness update treats raw as undefined → digital-fallback path runs but the SPICE memo timestamp is fresh so HOLD keeps zero brightness. Visible symptom: a perfectly conducting LED that never lights. Fix in two places: - Default canvas (useSimulatorStore.ts): rename 'led-builtin' / 'r-builtin' to 'led_builtin' / 'r_builtin' (and the matching wire ids). - DynamicComponent.tsx makeNewComponent: the id template was 'metadata.id-timestamp-rand' producing hyphens for every user-added component too. Switched to underscores, AND replace any hyphens already in metadata.id (e.g. 'led-bar-graph') so the prefix doesn't reintroduce the bug. Existing saved projects whose ids contain hyphens are not migrated here — those will keep the visual bug until either the operator edits the components or we add a sanitisation step inside componentToSpice + BasicParts. The next follow-up commit can add that if you confirm this default-canvas fix works.
2026-05-18 20:01:38 +07:00
id: 'led_builtin',
metadataId: 'led',
x: 380,
y: 100,
properties: { color: 'red' },
},
{
fix(simulator): underscore-separated component ids for SPICE safety The user reported the default editor canvas — Arduino Uno + LED + 220Ω resistor — was correctly powered (1.84 V at the LED anode, 14 mA through the diode) but the LED visual stayed dark. Only the built-in pin-13 LED on the wokwi-arduino-uno element lit up. Root cause: ngspice's WASM build truncates branch-current vector keys at the first hyphen. A sense source named V_led-builtin_sense ends up exposed under a key like v_led#branch rather than the expected v_led-builtin_sense#branch. CircuitSimulationService and BasicParts.ts both look up the FULL key, miss, and the LED's brightness update treats raw as undefined → digital-fallback path runs but the SPICE memo timestamp is fresh so HOLD keeps zero brightness. Visible symptom: a perfectly conducting LED that never lights. Fix in two places: - Default canvas (useSimulatorStore.ts): rename 'led-builtin' / 'r-builtin' to 'led_builtin' / 'r_builtin' (and the matching wire ids). - DynamicComponent.tsx makeNewComponent: the id template was 'metadata.id-timestamp-rand' producing hyphens for every user-added component too. Switched to underscores, AND replace any hyphens already in metadata.id (e.g. 'led-bar-graph') so the prefix doesn't reintroduce the bug. Existing saved projects whose ids contain hyphens are not migrated here — those will keep the visual bug until either the operator edits the components or we add a sanitisation step inside componentToSpice + BasicParts. The next follow-up commit can add that if you confirm this default-canvas fix works.
2026-05-18 20:01:38 +07:00
id: 'r_builtin',
metadataId: 'resistor',
x: 240,
y: 130,
properties: { value: '220' },
},
],
wires: [
// Pin 13 → resistor pin 1 (current-limiting side).
{
fix(simulator): underscore-separated component ids for SPICE safety The user reported the default editor canvas — Arduino Uno + LED + 220Ω resistor — was correctly powered (1.84 V at the LED anode, 14 mA through the diode) but the LED visual stayed dark. Only the built-in pin-13 LED on the wokwi-arduino-uno element lit up. Root cause: ngspice's WASM build truncates branch-current vector keys at the first hyphen. A sense source named V_led-builtin_sense ends up exposed under a key like v_led#branch rather than the expected v_led-builtin_sense#branch. CircuitSimulationService and BasicParts.ts both look up the FULL key, miss, and the LED's brightness update treats raw as undefined → digital-fallback path runs but the SPICE memo timestamp is fresh so HOLD keeps zero brightness. Visible symptom: a perfectly conducting LED that never lights. Fix in two places: - Default canvas (useSimulatorStore.ts): rename 'led-builtin' / 'r-builtin' to 'led_builtin' / 'r_builtin' (and the matching wire ids). - DynamicComponent.tsx makeNewComponent: the id template was 'metadata.id-timestamp-rand' producing hyphens for every user-added component too. Switched to underscores, AND replace any hyphens already in metadata.id (e.g. 'led-bar-graph') so the prefix doesn't reintroduce the bug. Existing saved projects whose ids contain hyphens are not migrated here — those will keep the visual bug until either the operator edits the components or we add a sanitisation step inside componentToSpice + BasicParts. The next follow-up commit can add that if you confirm this default-canvas fix works.
2026-05-18 20:01:38 +07:00
id: 'wire_builtin_pin13',
start: { componentId: 'arduino-uno', pinName: '13', x: 0, y: 0 },
fix(simulator): underscore-separated component ids for SPICE safety The user reported the default editor canvas — Arduino Uno + LED + 220Ω resistor — was correctly powered (1.84 V at the LED anode, 14 mA through the diode) but the LED visual stayed dark. Only the built-in pin-13 LED on the wokwi-arduino-uno element lit up. Root cause: ngspice's WASM build truncates branch-current vector keys at the first hyphen. A sense source named V_led-builtin_sense ends up exposed under a key like v_led#branch rather than the expected v_led-builtin_sense#branch. CircuitSimulationService and BasicParts.ts both look up the FULL key, miss, and the LED's brightness update treats raw as undefined → digital-fallback path runs but the SPICE memo timestamp is fresh so HOLD keeps zero brightness. Visible symptom: a perfectly conducting LED that never lights. Fix in two places: - Default canvas (useSimulatorStore.ts): rename 'led-builtin' / 'r-builtin' to 'led_builtin' / 'r_builtin' (and the matching wire ids). - DynamicComponent.tsx makeNewComponent: the id template was 'metadata.id-timestamp-rand' producing hyphens for every user-added component too. Switched to underscores, AND replace any hyphens already in metadata.id (e.g. 'led-bar-graph') so the prefix doesn't reintroduce the bug. Existing saved projects whose ids contain hyphens are not migrated here — those will keep the visual bug until either the operator edits the components or we add a sanitisation step inside componentToSpice + BasicParts. The next follow-up commit can add that if you confirm this default-canvas fix works.
2026-05-18 20:01:38 +07:00
end: { componentId: 'r_builtin', pinName: '1', x: 0, y: 0 },
waypoints: [],
color: '#22c55e',
},
// Resistor pin 2 → LED anode.
{
fix(simulator): underscore-separated component ids for SPICE safety The user reported the default editor canvas — Arduino Uno + LED + 220Ω resistor — was correctly powered (1.84 V at the LED anode, 14 mA through the diode) but the LED visual stayed dark. Only the built-in pin-13 LED on the wokwi-arduino-uno element lit up. Root cause: ngspice's WASM build truncates branch-current vector keys at the first hyphen. A sense source named V_led-builtin_sense ends up exposed under a key like v_led#branch rather than the expected v_led-builtin_sense#branch. CircuitSimulationService and BasicParts.ts both look up the FULL key, miss, and the LED's brightness update treats raw as undefined → digital-fallback path runs but the SPICE memo timestamp is fresh so HOLD keeps zero brightness. Visible symptom: a perfectly conducting LED that never lights. Fix in two places: - Default canvas (useSimulatorStore.ts): rename 'led-builtin' / 'r-builtin' to 'led_builtin' / 'r_builtin' (and the matching wire ids). - DynamicComponent.tsx makeNewComponent: the id template was 'metadata.id-timestamp-rand' producing hyphens for every user-added component too. Switched to underscores, AND replace any hyphens already in metadata.id (e.g. 'led-bar-graph') so the prefix doesn't reintroduce the bug. Existing saved projects whose ids contain hyphens are not migrated here — those will keep the visual bug until either the operator edits the components or we add a sanitisation step inside componentToSpice + BasicParts. The next follow-up commit can add that if you confirm this default-canvas fix works.
2026-05-18 20:01:38 +07:00
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.
{
fix(simulator): underscore-separated component ids for SPICE safety The user reported the default editor canvas — Arduino Uno + LED + 220Ω resistor — was correctly powered (1.84 V at the LED anode, 14 mA through the diode) but the LED visual stayed dark. Only the built-in pin-13 LED on the wokwi-arduino-uno element lit up. Root cause: ngspice's WASM build truncates branch-current vector keys at the first hyphen. A sense source named V_led-builtin_sense ends up exposed under a key like v_led#branch rather than the expected v_led-builtin_sense#branch. CircuitSimulationService and BasicParts.ts both look up the FULL key, miss, and the LED's brightness update treats raw as undefined → digital-fallback path runs but the SPICE memo timestamp is fresh so HOLD keeps zero brightness. Visible symptom: a perfectly conducting LED that never lights. Fix in two places: - Default canvas (useSimulatorStore.ts): rename 'led-builtin' / 'r-builtin' to 'led_builtin' / 'r_builtin' (and the matching wire ids). - DynamicComponent.tsx makeNewComponent: the id template was 'metadata.id-timestamp-rand' producing hyphens for every user-added component too. Switched to underscores, AND replace any hyphens already in metadata.id (e.g. 'led-bar-graph') so the prefix doesn't reintroduce the bug. Existing saved projects whose ids contain hyphens are not migrated here — those will keep the visual bug until either the operator edits the components or we add a sanitisation step inside componentToSpice + BasicParts. The next follow-up commit can add that if you confirm this default-canvas fix works.
2026-05-18 20:01:38 +07:00
id: 'wire_builtin_cathode',
start: { componentId: 'led_builtin', pinName: 'C', x: 0, y: 0 },
end: { componentId: 'arduino-uno', pinName: 'GND.1', x: 0, y: 0 },
waypoints: [],
color: '#000000',
},
],
selectedWireId: null,
wireInProgress: null,
addComponent: (component) => set((state) => ({ components: [...state.components, component] })),
removeComponent: (id) =>
set((state) => ({
components: state.components.filter((c) => c.id !== id),
wires: state.wires.filter((w) => w.start.componentId !== id && w.end.componentId !== id),
})),
updateComponent: (id, updates) => {
set((state) => ({
components: state.components.map((c) => (c.id === id ? { ...c, ...updates } : c)),
}));
fix(canvas): wires follow components on rotation Rotating a part with the 90° button used to leave every wire pinned to the pre-rotation pixel coordinates — the component visually unhooked from its cables. Two paths were missing: 1. useSimulatorStore.updateComponent only triggered updateWirePositions for x/y changes. A rotation went through properties.rotation, so wires never recomputed. 2. calculatePinPosition didn't know about rotation. Even when called, it returned the unrotated offset, so the new endpoints would still have been wrong. 3. recordRotate (undo/redo) skipped updateWirePositions on both legs, so Ctrl+Z after a rotate left the canvas inconsistent. Fix: - calculatePinPosition gets a 5th `rotation` argument. When non-zero, it finds the .dynamic-component-wrapper ancestor in the DOM, reads its offsetWidth/Height (layout-only, immune to CSS transforms) to locate the wrapper centre, and applies a 2D rotation matrix around that pivot. The wrapper top-left is recovered as (componentX - 4, componentY - 6) to match the offset convention updateWirePositions already uses. - updateWirePositions and recalculateAllWirePositions read the per- component rotation and thread it through. - updateComponent recomputes wires whenever properties.rotation changes, mirroring the existing x/y path. - recordRotate.execute and .undo both call updateWirePositions so Ctrl+Z keeps the canvas coherent. Tests (pin-position-rotation.test.ts, 6 cases): unrotated identity, 90° (left edge → bottom), 180° (point reflection), 360° round-trip, negative angles, and a store-level integration that rotates a fake component and asserts wires[0].start moves to the rotated coordinate. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 04:03:32 +07:00
// Re-stamp wire endpoints when the geometry of the component changes:
// position (x/y) OR rotation. Without this, rotating a component
// leaves every wire anchored to the pre-rotation pin positions, so
// the part visually disconnects from its cables.
const rotationChanged =
updates.properties && 'rotation' in updates.properties;
if (updates.x !== undefined || updates.y !== undefined || rotationChanged) {
get().updateWirePositions(id);
}
},
updateComponentState: (id, state) => {
set((prevState) => ({
components: prevState.components.map((c) =>
c.id === id ? { ...c, properties: { ...c.properties, state, value: state } } : c,
),
}));
},
handleComponentEvent: (_componentId, _eventName, _data) => {},
feat(canvas): undo/redo command pattern + history slice Adds the foundation for canvas undo/redo. UI wiring, keyboard shortcuts, toolbar buttons and agent-tools refactor land in follow-up commits. useSimulatorStore.ts: - New CanvasCommand type — { description, execute, undo }. - HISTORY_MAX = 50 (oldest entries dropped on overflow). - New state: history[] + historyIndex (-1 = empty). - New APIs: pushCommand(cmd, {applyNow?}), undo, redo, canUndo, canRedo, clearHistory. - New "recorded" actions that wrap raw mutators with a CanvasCommand: recordAddComponent, recordRemoveComponent, recordMove, recordRotate, recordSetProperty, recordAddWire, recordRemoveWire, recordUpdateWire. - recordRemoveComponent captures both the component AND any wires that cascade with it, so undo restores both atomically. - recordMove also re-runs updateWirePositions on undo/redo so wire endpoints follow the component back/forward. - setComponents and setWires (project-load / clear paths) now call clearHistory inline — leaving stale commands pointing at IDs that no longer exist would crash on undo. Why custom Command pattern over zundo / travels: - The store has 30+ ephemeral fields (simulator instances, serialOutput growing byte-by-byte, hexEpoch counter, wireInProgress that ticks 60×/s on drag). Snapshot/diff middleware would either burn memory tracking them or need a fragile partialize allow-list. - Per-op descriptions ("Add LED", "Move resistor") for tooltips come for free with this approach; zundo/travels would need to infer them. Tests: 15/15 in src/__tests__/undo-redo.test.ts — covers cap-at-50, redo-truncation, cascade undo of remove-component, move/rotate/property round trips, bulk-setter clearing. Raw mutators (addComponent / removeComponent / updateComponent / addWire / removeWire / updateWire) are unchanged. UI handlers can keep using them during live drags for preview frames without spamming history; the record* actions are what drag-end, click-finish and agent tools should call going forward. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 22:03:17 +07:00
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 })),
feat(canvas): undo/redo command pattern + history slice Adds the foundation for canvas undo/redo. UI wiring, keyboard shortcuts, toolbar buttons and agent-tools refactor land in follow-up commits. useSimulatorStore.ts: - New CanvasCommand type — { description, execute, undo }. - HISTORY_MAX = 50 (oldest entries dropped on overflow). - New state: history[] + historyIndex (-1 = empty). - New APIs: pushCommand(cmd, {applyNow?}), undo, redo, canUndo, canRedo, clearHistory. - New "recorded" actions that wrap raw mutators with a CanvasCommand: recordAddComponent, recordRemoveComponent, recordMove, recordRotate, recordSetProperty, recordAddWire, recordRemoveWire, recordUpdateWire. - recordRemoveComponent captures both the component AND any wires that cascade with it, so undo restores both atomically. - recordMove also re-runs updateWirePositions on undo/redo so wire endpoints follow the component back/forward. - setComponents and setWires (project-load / clear paths) now call clearHistory inline — leaving stale commands pointing at IDs that no longer exist would crash on undo. Why custom Command pattern over zundo / travels: - The store has 30+ ephemeral fields (simulator instances, serialOutput growing byte-by-byte, hexEpoch counter, wireInProgress that ticks 60×/s on drag). Snapshot/diff middleware would either burn memory tracking them or need a fragile partialize allow-list. - Per-op descriptions ("Add LED", "Move resistor") for tooltips come for free with this approach; zundo/travels would need to infer them. Tests: 15/15 in src/__tests__/undo-redo.test.ts — covers cap-at-50, redo-truncation, cascade undo of remove-component, move/rotate/property round trips, bulk-setter clearing. Raw mutators (addComponent / removeComponent / updateComponent / addWire / removeWire / updateWire) are unchanged. UI handlers can keep using them during live drags for preview frames without spamming history; the record* actions are what drag-end, click-finish and agent tools should call going forward. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 22:03:17 +07:00
// Bulk replacement clears history for the same reason as setComponents.
history: [],
historyIndex: -1,
}),
startWireCreation: (endpoint, color) =>
set({
wireInProgress: {
startEndpoint: endpoint,
waypoints: [],
color,
currentX: endpoint.x,
currentY: endpoint.y,
},
}),
updateWireInProgress: (x, y) =>
set((state) => {
if (!state.wireInProgress) return state;
return { wireInProgress: { ...state.wireInProgress, currentX: x, currentY: y } };
}),
addWireWaypoint: (x, y) =>
set((state) => {
if (!state.wireInProgress) return state;
return {
wireInProgress: {
...state.wireInProgress,
waypoints: [...state.wireInProgress.waypoints, { x, y }],
},
};
}),
setWireInProgressColor: (color) =>
set((state) => {
if (!state.wireInProgress) return state;
return { wireInProgress: { ...state.wireInProgress, color } };
}),
finishWireCreation: (endpoint) => {
const state = get();
if (!state.wireInProgress) return;
const { startEndpoint, waypoints, color } = state.wireInProgress;
// Finish wire: auto-detect color from pin name
const finalColor = color === DEFAULT_WIRE_COLOR ? autoWireColor(endpoint.pinName) : color;
const newWire: Wire = {
id: `wire-${Date.now()}`,
start: startEndpoint,
end: endpoint,
waypoints,
color: finalColor,
};
set((state) => ({ wires: [...state.wires, newWire], wireInProgress: null }));
},
cancelWireCreation: () => set({ wireInProgress: null }),
updateWirePositions: (componentId) => {
set((state) => {
const component = state.components.find((c) => c.id === componentId);
// Check if this componentId matches a board id
const board = state.boards.find((b) => b.id === componentId);
// Components have a DynamicComponent wrapper with border:2px + padding:4px → offset (4,6)
// Boards are rendered directly without a wrapper, so no offset.
const compX = component ? component.x + 4 : board ? board.x : state.boardPosition.x;
const compY = component ? component.y + 6 : board ? board.y : state.boardPosition.y;
fix(canvas): wires follow components on rotation Rotating a part with the 90° button used to leave every wire pinned to the pre-rotation pixel coordinates — the component visually unhooked from its cables. Two paths were missing: 1. useSimulatorStore.updateComponent only triggered updateWirePositions for x/y changes. A rotation went through properties.rotation, so wires never recomputed. 2. calculatePinPosition didn't know about rotation. Even when called, it returned the unrotated offset, so the new endpoints would still have been wrong. 3. recordRotate (undo/redo) skipped updateWirePositions on both legs, so Ctrl+Z after a rotate left the canvas inconsistent. Fix: - calculatePinPosition gets a 5th `rotation` argument. When non-zero, it finds the .dynamic-component-wrapper ancestor in the DOM, reads its offsetWidth/Height (layout-only, immune to CSS transforms) to locate the wrapper centre, and applies a 2D rotation matrix around that pivot. The wrapper top-left is recovered as (componentX - 4, componentY - 6) to match the offset convention updateWirePositions already uses. - updateWirePositions and recalculateAllWirePositions read the per- component rotation and thread it through. - updateComponent recomputes wires whenever properties.rotation changes, mirroring the existing x/y path. - recordRotate.execute and .undo both call updateWirePositions so Ctrl+Z keeps the canvas coherent. Tests (pin-position-rotation.test.ts, 6 cases): unrotated identity, 90° (left edge → bottom), 180° (point reflection), 360° round-trip, negative angles, and a store-level integration that rotates a fake component and asserts wires[0].start moves to the rotated coordinate. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 04:03:32 +07:00
// 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) {
fix(canvas): wires follow components on rotation Rotating a part with the 90° button used to leave every wire pinned to the pre-rotation pixel coordinates — the component visually unhooked from its cables. Two paths were missing: 1. useSimulatorStore.updateComponent only triggered updateWirePositions for x/y changes. A rotation went through properties.rotation, so wires never recomputed. 2. calculatePinPosition didn't know about rotation. Even when called, it returned the unrotated offset, so the new endpoints would still have been wrong. 3. recordRotate (undo/redo) skipped updateWirePositions on both legs, so Ctrl+Z after a rotate left the canvas inconsistent. Fix: - calculatePinPosition gets a 5th `rotation` argument. When non-zero, it finds the .dynamic-component-wrapper ancestor in the DOM, reads its offsetWidth/Height (layout-only, immune to CSS transforms) to locate the wrapper centre, and applies a 2D rotation matrix around that pivot. The wrapper top-left is recovered as (componentX - 4, componentY - 6) to match the offset convention updateWirePositions already uses. - updateWirePositions and recalculateAllWirePositions read the per- component rotation and thread it through. - updateComponent recomputes wires whenever properties.rotation changes, mirroring the existing x/y path. - recordRotate.execute and .undo both call updateWirePositions so Ctrl+Z keeps the canvas coherent. Tests (pin-position-rotation.test.ts, 6 cases): unrotated identity, 90° (left edge → bottom), 180° (point reflection), 360° round-trip, negative angles, and a store-level integration that rotates a fake component and asserts wires[0].start moves to the rotated coordinate. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 04:03:32 +07:00
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) {
fix(canvas): wires follow components on rotation Rotating a part with the 90° button used to leave every wire pinned to the pre-rotation pixel coordinates — the component visually unhooked from its cables. Two paths were missing: 1. useSimulatorStore.updateComponent only triggered updateWirePositions for x/y changes. A rotation went through properties.rotation, so wires never recomputed. 2. calculatePinPosition didn't know about rotation. Even when called, it returned the unrotated offset, so the new endpoints would still have been wrong. 3. recordRotate (undo/redo) skipped updateWirePositions on both legs, so Ctrl+Z after a rotate left the canvas inconsistent. Fix: - calculatePinPosition gets a 5th `rotation` argument. When non-zero, it finds the .dynamic-component-wrapper ancestor in the DOM, reads its offsetWidth/Height (layout-only, immune to CSS transforms) to locate the wrapper centre, and applies a 2D rotation matrix around that pivot. The wrapper top-left is recovered as (componentX - 4, componentY - 6) to match the offset convention updateWirePositions already uses. - updateWirePositions and recalculateAllWirePositions read the per- component rotation and thread it through. - updateComponent recomputes wires whenever properties.rotation changes, mirroring the existing x/y path. - recordRotate.execute and .undo both call updateWirePositions so Ctrl+Z keeps the canvas coherent. Tests (pin-position-rotation.test.ts, 6 cases): unrotated identity, 90° (left edge → bottom), 180° (point reflection), 360° round-trip, negative angles, and a store-level integration that rotates a fake component and asserts wires[0].start moves to the rotated coordinate. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 04:03:32 +07:00
const pos = calculatePinPosition(
componentId, wire.end.pinName, compX, compY, rotation,
);
if (pos) updated.end = { ...wire.end, x: pos.x, y: pos.y };
}
return updated;
});
return { wires: updatedWires };
});
},
recalculateAllWirePositions: () => {
const state = get();
const updatedWires = state.wires.map((wire) => {
const updated = { ...wire };
// Resolve start — components have wrapper offset (4,6), boards do not
const startComp = state.components.find((c) => c.id === wire.start.componentId);
const startBoard = state.boards.find((b) => b.id === wire.start.componentId);
const startX = startComp
? startComp.x + 4
: startBoard
? startBoard.x
: state.boardPosition.x;
const startY = startComp
? startComp.y + 6
: startBoard
? startBoard.y
: state.boardPosition.y;
fix(canvas): wires follow components on rotation Rotating a part with the 90° button used to leave every wire pinned to the pre-rotation pixel coordinates — the component visually unhooked from its cables. Two paths were missing: 1. useSimulatorStore.updateComponent only triggered updateWirePositions for x/y changes. A rotation went through properties.rotation, so wires never recomputed. 2. calculatePinPosition didn't know about rotation. Even when called, it returned the unrotated offset, so the new endpoints would still have been wrong. 3. recordRotate (undo/redo) skipped updateWirePositions on both legs, so Ctrl+Z after a rotate left the canvas inconsistent. Fix: - calculatePinPosition gets a 5th `rotation` argument. When non-zero, it finds the .dynamic-component-wrapper ancestor in the DOM, reads its offsetWidth/Height (layout-only, immune to CSS transforms) to locate the wrapper centre, and applies a 2D rotation matrix around that pivot. The wrapper top-left is recovered as (componentX - 4, componentY - 6) to match the offset convention updateWirePositions already uses. - updateWirePositions and recalculateAllWirePositions read the per- component rotation and thread it through. - updateComponent recomputes wires whenever properties.rotation changes, mirroring the existing x/y path. - recordRotate.execute and .undo both call updateWirePositions so Ctrl+Z keeps the canvas coherent. Tests (pin-position-rotation.test.ts, 6 cases): unrotated identity, 90° (left edge → bottom), 180° (point reflection), 360° round-trip, negative angles, and a store-level integration that rotates a fake component and asserts wires[0].start moves to the rotated coordinate. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 04:03:32 +07:00
const startRotation = startComp ? Number(startComp.properties?.rotation) || 0 : 0;
const startPos = calculatePinPosition(
wire.start.componentId,
wire.start.pinName,
startX,
startY,
fix(canvas): wires follow components on rotation Rotating a part with the 90° button used to leave every wire pinned to the pre-rotation pixel coordinates — the component visually unhooked from its cables. Two paths were missing: 1. useSimulatorStore.updateComponent only triggered updateWirePositions for x/y changes. A rotation went through properties.rotation, so wires never recomputed. 2. calculatePinPosition didn't know about rotation. Even when called, it returned the unrotated offset, so the new endpoints would still have been wrong. 3. recordRotate (undo/redo) skipped updateWirePositions on both legs, so Ctrl+Z after a rotate left the canvas inconsistent. Fix: - calculatePinPosition gets a 5th `rotation` argument. When non-zero, it finds the .dynamic-component-wrapper ancestor in the DOM, reads its offsetWidth/Height (layout-only, immune to CSS transforms) to locate the wrapper centre, and applies a 2D rotation matrix around that pivot. The wrapper top-left is recovered as (componentX - 4, componentY - 6) to match the offset convention updateWirePositions already uses. - updateWirePositions and recalculateAllWirePositions read the per- component rotation and thread it through. - updateComponent recomputes wires whenever properties.rotation changes, mirroring the existing x/y path. - recordRotate.execute and .undo both call updateWirePositions so Ctrl+Z keeps the canvas coherent. Tests (pin-position-rotation.test.ts, 6 cases): unrotated identity, 90° (left edge → bottom), 180° (point reflection), 360° round-trip, negative angles, and a store-level integration that rotates a fake component and asserts wires[0].start moves to the rotated coordinate. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 04:03:32 +07:00
startRotation,
);
updated.start = startPos
? { ...wire.start, x: startPos.x, y: startPos.y }
: { ...wire.start, x: startX, y: startY };
// Resolve end — components have wrapper offset (4,6), boards do not
const endComp = state.components.find((c) => c.id === wire.end.componentId);
const endBoard = state.boards.find((b) => b.id === wire.end.componentId);
const endX = endComp ? endComp.x + 4 : endBoard ? endBoard.x : state.boardPosition.x;
const endY = endComp ? endComp.y + 6 : endBoard ? endBoard.y : state.boardPosition.y;
fix(canvas): wires follow components on rotation Rotating a part with the 90° button used to leave every wire pinned to the pre-rotation pixel coordinates — the component visually unhooked from its cables. Two paths were missing: 1. useSimulatorStore.updateComponent only triggered updateWirePositions for x/y changes. A rotation went through properties.rotation, so wires never recomputed. 2. calculatePinPosition didn't know about rotation. Even when called, it returned the unrotated offset, so the new endpoints would still have been wrong. 3. recordRotate (undo/redo) skipped updateWirePositions on both legs, so Ctrl+Z after a rotate left the canvas inconsistent. Fix: - calculatePinPosition gets a 5th `rotation` argument. When non-zero, it finds the .dynamic-component-wrapper ancestor in the DOM, reads its offsetWidth/Height (layout-only, immune to CSS transforms) to locate the wrapper centre, and applies a 2D rotation matrix around that pivot. The wrapper top-left is recovered as (componentX - 4, componentY - 6) to match the offset convention updateWirePositions already uses. - updateWirePositions and recalculateAllWirePositions read the per- component rotation and thread it through. - updateComponent recomputes wires whenever properties.rotation changes, mirroring the existing x/y path. - recordRotate.execute and .undo both call updateWirePositions so Ctrl+Z keeps the canvas coherent. Tests (pin-position-rotation.test.ts, 6 cases): unrotated identity, 90° (left edge → bottom), 180° (point reflection), 360° round-trip, negative angles, and a store-level integration that rotates a fake component and asserts wires[0].start moves to the rotated coordinate. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 04:03:32 +07:00
const endRotation = endComp ? Number(endComp.properties?.rotation) || 0 : 0;
const endPos = calculatePinPosition(
wire.end.componentId, wire.end.pinName, endX, endY, endRotation,
);
updated.end = endPos
? { ...wire.end, x: endPos.x, y: endPos.y }
: { ...wire.end, x: endX, y: endY };
return updated;
});
set({ wires: updatedWires });
},
feat(canvas): undo/redo command pattern + history slice Adds the foundation for canvas undo/redo. UI wiring, keyboard shortcuts, toolbar buttons and agent-tools refactor land in follow-up commits. useSimulatorStore.ts: - New CanvasCommand type — { description, execute, undo }. - HISTORY_MAX = 50 (oldest entries dropped on overflow). - New state: history[] + historyIndex (-1 = empty). - New APIs: pushCommand(cmd, {applyNow?}), undo, redo, canUndo, canRedo, clearHistory. - New "recorded" actions that wrap raw mutators with a CanvasCommand: recordAddComponent, recordRemoveComponent, recordMove, recordRotate, recordSetProperty, recordAddWire, recordRemoveWire, recordUpdateWire. - recordRemoveComponent captures both the component AND any wires that cascade with it, so undo restores both atomically. - recordMove also re-runs updateWirePositions on undo/redo so wire endpoints follow the component back/forward. - setComponents and setWires (project-load / clear paths) now call clearHistory inline — leaving stale commands pointing at IDs that no longer exist would crash on undo. Why custom Command pattern over zundo / travels: - The store has 30+ ephemeral fields (simulator instances, serialOutput growing byte-by-byte, hexEpoch counter, wireInProgress that ticks 60×/s on drag). Snapshot/diff middleware would either burn memory tracking them or need a fragile partialize allow-list. - Per-op descriptions ("Add LED", "Move resistor") for tooltips come for free with this approach; zundo/travels would need to infer them. Tests: 15/15 in src/__tests__/undo-redo.test.ts — covers cap-at-50, redo-truncation, cascade undo of remove-component, move/rotate/property round trips, bulk-setter clearing. Raw mutators (addComponent / removeComponent / updateComponent / addWire / removeWire / updateWire) are unchanged. UI handlers can keep using them during live drags for preview frames without spamming history; the record* actions are what drag-end, click-finish and agent tools should call going forward. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 22:03:17 +07:00
// ── Undo/redo ──────────────────────────────────────────────────────────
history: [],
historyIndex: -1,
pushCommand: (cmd, opts) => {
const applyNow = opts?.applyNow ?? true;
if (applyNow) cmd.execute();
set((state) => {
// Truncate the redo branch — once you push a new command, the
// entries you'd previously redone are abandoned.
const truncated = state.history.slice(0, state.historyIndex + 1);
let next = [...truncated, cmd];
let nextIdx = next.length - 1;
// Cap at HISTORY_MAX. When over, drop the oldest entry and shift
// the index down so it still points at the just-pushed command.
if (next.length > HISTORY_MAX) {
const overflow = next.length - HISTORY_MAX;
next = next.slice(overflow);
nextIdx = next.length - 1;
}
return { history: next, historyIndex: nextIdx };
});
},
undo: () => {
const state = get();
if (state.historyIndex < 0) return;
const cmd = state.history[state.historyIndex];
try {
cmd.undo();
} catch (err) {
// A failing undo would otherwise leave the index pointing at a
// half-applied command. Bail out cleanly.
// eslint-disable-next-line no-console
console.error('[history] undo failed:', cmd.description, err);
return;
}
set({ historyIndex: state.historyIndex - 1 });
},
redo: () => {
const state = get();
if (state.historyIndex >= state.history.length - 1) return;
const cmd = state.history[state.historyIndex + 1];
try {
cmd.execute();
} catch (err) {
// eslint-disable-next-line no-console
console.error('[history] redo failed:', cmd.description, err);
return;
}
set({ historyIndex: state.historyIndex + 1 });
},
canUndo: () => get().historyIndex >= 0,
canRedo: () => {
const s = get();
return s.historyIndex < s.history.length - 1;
},
clearHistory: () => set({ history: [], historyIndex: -1 }),
// ── Recorded canvas actions ────────────────────────────────────────────
// Each `record*` builds a CanvasCommand that captures both directions
// and pushes it. Naming intent: the user has *committed* a change
// (drag-end, click finalised, agent tool execute) — distinct from the
// raw mutators above which can be called per-frame during a drag.
recordAddComponent: (component) => {
get().pushCommand({
description: `Add ${component.metadataId}`,
execute: () =>
set((s) => ({ components: [...s.components, component] })),
undo: () =>
set((s) => ({
components: s.components.filter((c) => c.id !== component.id),
// Mirror the cascade in removeComponent so a redo→undo round
// trip of an add-then-wired pair stays consistent.
wires: s.wires.filter(
(w) =>
w.start.componentId !== component.id && w.end.componentId !== component.id,
),
})),
});
},
recordRemoveComponent: (id) => {
const state = get();
const removed = state.components.find((c) => c.id === id);
if (!removed) return;
// Capture wires that will be cascaded too — undo must restore both
// the component AND its wires together.
const removedWires = state.wires.filter(
(w) => w.start.componentId === id || w.end.componentId === id,
);
get().pushCommand({
description: `Remove ${removed.metadataId}`,
execute: () =>
set((s) => ({
components: s.components.filter((c) => c.id !== id),
wires: s.wires.filter(
(w) => w.start.componentId !== id && w.end.componentId !== id,
),
})),
undo: () =>
set((s) => ({
components: [...s.components, removed],
wires: [...s.wires, ...removedWires],
})),
});
},
recordMove: (id, from, to) => {
// The state is already at `to` (caller mutated during drag). We push
// applyNow:false so we don't redundantly re-apply on first push;
// execute()/undo() are only invoked on future redo/undo.
get().pushCommand(
{
description: 'Move component',
execute: () => {
set((s) => ({
components: s.components.map((c) =>
c.id === id ? { ...c, x: to.x, y: to.y } : c,
),
}));
get().updateWirePositions(id);
},
undo: () => {
set((s) => ({
components: s.components.map((c) =>
c.id === id ? { ...c, x: from.x, y: from.y } : c,
),
}));
get().updateWirePositions(id);
},
},
{ applyNow: false },
);
},
recordRotate: (id, prevRotation, nextRotation) => {
get().pushCommand(
{
description: 'Rotate component',
fix(canvas): wires follow components on rotation Rotating a part with the 90° button used to leave every wire pinned to the pre-rotation pixel coordinates — the component visually unhooked from its cables. Two paths were missing: 1. useSimulatorStore.updateComponent only triggered updateWirePositions for x/y changes. A rotation went through properties.rotation, so wires never recomputed. 2. calculatePinPosition didn't know about rotation. Even when called, it returned the unrotated offset, so the new endpoints would still have been wrong. 3. recordRotate (undo/redo) skipped updateWirePositions on both legs, so Ctrl+Z after a rotate left the canvas inconsistent. Fix: - calculatePinPosition gets a 5th `rotation` argument. When non-zero, it finds the .dynamic-component-wrapper ancestor in the DOM, reads its offsetWidth/Height (layout-only, immune to CSS transforms) to locate the wrapper centre, and applies a 2D rotation matrix around that pivot. The wrapper top-left is recovered as (componentX - 4, componentY - 6) to match the offset convention updateWirePositions already uses. - updateWirePositions and recalculateAllWirePositions read the per- component rotation and thread it through. - updateComponent recomputes wires whenever properties.rotation changes, mirroring the existing x/y path. - recordRotate.execute and .undo both call updateWirePositions so Ctrl+Z keeps the canvas coherent. Tests (pin-position-rotation.test.ts, 6 cases): unrotated identity, 90° (left edge → bottom), 180° (point reflection), 360° round-trip, negative angles, and a store-level integration that rotates a fake component and asserts wires[0].start moves to the rotated coordinate. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 04:03:32 +07:00
execute: () => {
feat(canvas): undo/redo command pattern + history slice Adds the foundation for canvas undo/redo. UI wiring, keyboard shortcuts, toolbar buttons and agent-tools refactor land in follow-up commits. useSimulatorStore.ts: - New CanvasCommand type — { description, execute, undo }. - HISTORY_MAX = 50 (oldest entries dropped on overflow). - New state: history[] + historyIndex (-1 = empty). - New APIs: pushCommand(cmd, {applyNow?}), undo, redo, canUndo, canRedo, clearHistory. - New "recorded" actions that wrap raw mutators with a CanvasCommand: recordAddComponent, recordRemoveComponent, recordMove, recordRotate, recordSetProperty, recordAddWire, recordRemoveWire, recordUpdateWire. - recordRemoveComponent captures both the component AND any wires that cascade with it, so undo restores both atomically. - recordMove also re-runs updateWirePositions on undo/redo so wire endpoints follow the component back/forward. - setComponents and setWires (project-load / clear paths) now call clearHistory inline — leaving stale commands pointing at IDs that no longer exist would crash on undo. Why custom Command pattern over zundo / travels: - The store has 30+ ephemeral fields (simulator instances, serialOutput growing byte-by-byte, hexEpoch counter, wireInProgress that ticks 60×/s on drag). Snapshot/diff middleware would either burn memory tracking them or need a fragile partialize allow-list. - Per-op descriptions ("Add LED", "Move resistor") for tooltips come for free with this approach; zundo/travels would need to infer them. Tests: 15/15 in src/__tests__/undo-redo.test.ts — covers cap-at-50, redo-truncation, cascade undo of remove-component, move/rotate/property round trips, bulk-setter clearing. Raw mutators (addComponent / removeComponent / updateComponent / addWire / removeWire / updateWire) are unchanged. UI handlers can keep using them during live drags for preview frames without spamming history; the record* actions are what drag-end, click-finish and agent tools should call going forward. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 22:03:17 +07:00
set((s) => ({
components: s.components.map((c) =>
c.id === id
? { ...c, properties: { ...c.properties, rotation: nextRotation } }
: c,
),
fix(canvas): wires follow components on rotation Rotating a part with the 90° button used to leave every wire pinned to the pre-rotation pixel coordinates — the component visually unhooked from its cables. Two paths were missing: 1. useSimulatorStore.updateComponent only triggered updateWirePositions for x/y changes. A rotation went through properties.rotation, so wires never recomputed. 2. calculatePinPosition didn't know about rotation. Even when called, it returned the unrotated offset, so the new endpoints would still have been wrong. 3. recordRotate (undo/redo) skipped updateWirePositions on both legs, so Ctrl+Z after a rotate left the canvas inconsistent. Fix: - calculatePinPosition gets a 5th `rotation` argument. When non-zero, it finds the .dynamic-component-wrapper ancestor in the DOM, reads its offsetWidth/Height (layout-only, immune to CSS transforms) to locate the wrapper centre, and applies a 2D rotation matrix around that pivot. The wrapper top-left is recovered as (componentX - 4, componentY - 6) to match the offset convention updateWirePositions already uses. - updateWirePositions and recalculateAllWirePositions read the per- component rotation and thread it through. - updateComponent recomputes wires whenever properties.rotation changes, mirroring the existing x/y path. - recordRotate.execute and .undo both call updateWirePositions so Ctrl+Z keeps the canvas coherent. Tests (pin-position-rotation.test.ts, 6 cases): unrotated identity, 90° (left edge → bottom), 180° (point reflection), 360° round-trip, negative angles, and a store-level integration that rotates a fake component and asserts wires[0].start moves to the rotated coordinate. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 04:03:32 +07:00
}));
// 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: () => {
feat(canvas): undo/redo command pattern + history slice Adds the foundation for canvas undo/redo. UI wiring, keyboard shortcuts, toolbar buttons and agent-tools refactor land in follow-up commits. useSimulatorStore.ts: - New CanvasCommand type — { description, execute, undo }. - HISTORY_MAX = 50 (oldest entries dropped on overflow). - New state: history[] + historyIndex (-1 = empty). - New APIs: pushCommand(cmd, {applyNow?}), undo, redo, canUndo, canRedo, clearHistory. - New "recorded" actions that wrap raw mutators with a CanvasCommand: recordAddComponent, recordRemoveComponent, recordMove, recordRotate, recordSetProperty, recordAddWire, recordRemoveWire, recordUpdateWire. - recordRemoveComponent captures both the component AND any wires that cascade with it, so undo restores both atomically. - recordMove also re-runs updateWirePositions on undo/redo so wire endpoints follow the component back/forward. - setComponents and setWires (project-load / clear paths) now call clearHistory inline — leaving stale commands pointing at IDs that no longer exist would crash on undo. Why custom Command pattern over zundo / travels: - The store has 30+ ephemeral fields (simulator instances, serialOutput growing byte-by-byte, hexEpoch counter, wireInProgress that ticks 60×/s on drag). Snapshot/diff middleware would either burn memory tracking them or need a fragile partialize allow-list. - Per-op descriptions ("Add LED", "Move resistor") for tooltips come for free with this approach; zundo/travels would need to infer them. Tests: 15/15 in src/__tests__/undo-redo.test.ts — covers cap-at-50, redo-truncation, cascade undo of remove-component, move/rotate/property round trips, bulk-setter clearing. Raw mutators (addComponent / removeComponent / updateComponent / addWire / removeWire / updateWire) are unchanged. UI handlers can keep using them during live drags for preview frames without spamming history; the record* actions are what drag-end, click-finish and agent tools should call going forward. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 22:03:17 +07:00
set((s) => ({
components: s.components.map((c) =>
c.id === id
? { ...c, properties: { ...c.properties, rotation: prevRotation } }
: c,
),
fix(canvas): wires follow components on rotation Rotating a part with the 90° button used to leave every wire pinned to the pre-rotation pixel coordinates — the component visually unhooked from its cables. Two paths were missing: 1. useSimulatorStore.updateComponent only triggered updateWirePositions for x/y changes. A rotation went through properties.rotation, so wires never recomputed. 2. calculatePinPosition didn't know about rotation. Even when called, it returned the unrotated offset, so the new endpoints would still have been wrong. 3. recordRotate (undo/redo) skipped updateWirePositions on both legs, so Ctrl+Z after a rotate left the canvas inconsistent. Fix: - calculatePinPosition gets a 5th `rotation` argument. When non-zero, it finds the .dynamic-component-wrapper ancestor in the DOM, reads its offsetWidth/Height (layout-only, immune to CSS transforms) to locate the wrapper centre, and applies a 2D rotation matrix around that pivot. The wrapper top-left is recovered as (componentX - 4, componentY - 6) to match the offset convention updateWirePositions already uses. - updateWirePositions and recalculateAllWirePositions read the per- component rotation and thread it through. - updateComponent recomputes wires whenever properties.rotation changes, mirroring the existing x/y path. - recordRotate.execute and .undo both call updateWirePositions so Ctrl+Z keeps the canvas coherent. Tests (pin-position-rotation.test.ts, 6 cases): unrotated identity, 90° (left edge → bottom), 180° (point reflection), 360° round-trip, negative angles, and a store-level integration that rotates a fake component and asserts wires[0].start moves to the rotated coordinate. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 04:03:32 +07:00
}));
get().updateWirePositions(id);
},
feat(canvas): undo/redo command pattern + history slice Adds the foundation for canvas undo/redo. UI wiring, keyboard shortcuts, toolbar buttons and agent-tools refactor land in follow-up commits. useSimulatorStore.ts: - New CanvasCommand type — { description, execute, undo }. - HISTORY_MAX = 50 (oldest entries dropped on overflow). - New state: history[] + historyIndex (-1 = empty). - New APIs: pushCommand(cmd, {applyNow?}), undo, redo, canUndo, canRedo, clearHistory. - New "recorded" actions that wrap raw mutators with a CanvasCommand: recordAddComponent, recordRemoveComponent, recordMove, recordRotate, recordSetProperty, recordAddWire, recordRemoveWire, recordUpdateWire. - recordRemoveComponent captures both the component AND any wires that cascade with it, so undo restores both atomically. - recordMove also re-runs updateWirePositions on undo/redo so wire endpoints follow the component back/forward. - setComponents and setWires (project-load / clear paths) now call clearHistory inline — leaving stale commands pointing at IDs that no longer exist would crash on undo. Why custom Command pattern over zundo / travels: - The store has 30+ ephemeral fields (simulator instances, serialOutput growing byte-by-byte, hexEpoch counter, wireInProgress that ticks 60×/s on drag). Snapshot/diff middleware would either burn memory tracking them or need a fragile partialize allow-list. - Per-op descriptions ("Add LED", "Move resistor") for tooltips come for free with this approach; zundo/travels would need to infer them. Tests: 15/15 in src/__tests__/undo-redo.test.ts — covers cap-at-50, redo-truncation, cascade undo of remove-component, move/rotate/property round trips, bulk-setter clearing. Raw mutators (addComponent / removeComponent / updateComponent / addWire / removeWire / updateWire) are unchanged. UI handlers can keep using them during live drags for preview frames without spamming history; the record* actions are what drag-end, click-finish and agent tools should call going forward. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 22:03:17 +07:00
},
{ applyNow: false },
);
},
recordSetProperty: (id, key, prevValue, nextValue) => {
get().pushCommand(
{
description: `Change ${key}`,
execute: () =>
set((s) => ({
components: s.components.map((c) =>
c.id === id
? { ...c, properties: { ...c.properties, [key]: nextValue } }
: c,
),
})),
undo: () =>
set((s) => ({
components: s.components.map((c) =>
c.id === id
? { ...c, properties: { ...c.properties, [key]: prevValue } }
: c,
),
})),
},
{ applyNow: false },
);
},
recordAddWire: (wire) => {
get().pushCommand({
description: 'Add wire',
execute: () => set((s) => ({ wires: [...s.wires, wire] })),
undo: () =>
set((s) => ({
wires: s.wires.filter((w) => w.id !== wire.id),
selectedWireId: s.selectedWireId === wire.id ? null : s.selectedWireId,
})),
});
},
recordRemoveWire: (wireId) => {
const removed = get().wires.find((w) => w.id === wireId);
if (!removed) return;
get().pushCommand({
description: 'Remove wire',
execute: () =>
set((s) => ({
wires: s.wires.filter((w) => w.id !== wireId),
selectedWireId: s.selectedWireId === wireId ? null : s.selectedWireId,
})),
undo: () => set((s) => ({ wires: [...s.wires, removed] })),
});
},
recordUpdateWire: (wireId, prev, next, description = 'Update wire') => {
get().pushCommand(
{
description,
execute: () =>
set((s) => ({
wires: s.wires.map((w) => (w.id === wireId ? { ...w, ...next } : w)),
})),
undo: () =>
set((s) => ({
wires: s.wires.map((w) => (w.id === wireId ? { ...w, ...prev } : w)),
})),
},
{ applyNow: false },
);
},
toggleSerialMonitor: () => set((s) => ({ serialMonitorOpen: !s.serialMonitorOpen })),
serialWrite: (text: string) => {
const { activeBoardId } = get();
const boardId = activeBoardId ?? INITIAL_BOARD_ID;
const board = get().boards.find((b) => b.id === boardId);
if (!board) return;
feat(pi): Phase 3.3 — Pi Zero / Pi 1 / Pi 2 armhf simulators Closes the deferred Phase 3.3. Root-causes the Pi 2 "Attempted to kill init" panic as `mount /dev/vda` failing with EINVAL — Debian armmp does not have ext4 builtin (only fuseblk in /proc/filesystems). - qemu_manager: PI_CONFIGS gains raspberry-pi-zero / -1 / -2 entries. All three use the armmp armhf kernel + Cortex-A7 CPU + the mmio virtio transport (arm-32 virt PCI fails -75 due to missing reg DT property). Pi Zero / Pi 1 get the small 1-core / 512 MB profile; Pi 2 gets 4-core / 1 GB. QEMU command builder branches on cfg.bus for virtio-blk-pci vs virtio-blk-device (and serial likewise). - manifest.json: new `raspberry-pi-armhf` image_set wiring three assets (kernel + initramfs + zstd rootfs). - Frontend BoardKind gains the three new kinds + an isPiBoardKind() helper. Replaces the eight scattered `=== 'raspberry-pi-3' || === 'raspberry-pi-4' || === 'raspberry-pi-5'` branches in useSimulatorStore, Interconnect, loadExample, boardProtocols. ComponentRegistry gets three new picker entries. - board-kinds-coverage test: ACCEPTED_UNCOVERED gains the new kinds (backend boards have no canvas examples). The matching armhf build-pi-kernel.sh / build-pi-rootfs.sh changes live in velxio-prod's scripts/ (private overlay) — the upstream kernel build script only knows about arm64; armhf is built in the private repo because the assets ship through the license endpoint. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 04:23:48 +07:00
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;
feat(pi): Phase 3.3 — Pi Zero / Pi 1 / Pi 2 armhf simulators Closes the deferred Phase 3.3. Root-causes the Pi 2 "Attempted to kill init" panic as `mount /dev/vda` failing with EINVAL — Debian armmp does not have ext4 builtin (only fuseblk in /proc/filesystems). - qemu_manager: PI_CONFIGS gains raspberry-pi-zero / -1 / -2 entries. All three use the armmp armhf kernel + Cortex-A7 CPU + the mmio virtio transport (arm-32 virt PCI fails -75 due to missing reg DT property). Pi Zero / Pi 1 get the small 1-core / 512 MB profile; Pi 2 gets 4-core / 1 GB. QEMU command builder branches on cfg.bus for virtio-blk-pci vs virtio-blk-device (and serial likewise). - manifest.json: new `raspberry-pi-armhf` image_set wiring three assets (kernel + initramfs + zstd rootfs). - Frontend BoardKind gains the three new kinds + an isPiBoardKind() helper. Replaces the eight scattered `=== 'raspberry-pi-3' || === 'raspberry-pi-4' || === 'raspberry-pi-5'` branches in useSimulatorStore, Interconnect, loadExample, boardProtocols. ComponentRegistry gets three new picker entries. - board-kinds-coverage test: ACCEPTED_UNCOVERED gains the new kinds (backend boards have no canvas examples). The matching armhf build-pi-kernel.sh / build-pi-rootfs.sh changes live in velxio-prod's scripts/ (private overlay) — the upstream kernel build script only knows about arm64; armhf is built in the private repo because the assets ship through the license endpoint. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 04:23:48 +07:00
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;
}
feat(multi-board): add wire-aware cross-board interconnect router Fixes the user-reported bug where two RPi Pico W boards wired GP0↔GP1 running SerialPassthrough don't communicate. Replaces the broken broadcast-style cross-board logic in addBoard (only routed AVR↔Pi3B, ignored wires entirely, no RP2040↔anything path) with a wire-aware Interconnect singleton. Architecture: digital pin transitions are the lowest-common-denominator abstraction. Each simulator's hardware peripherals (UART/I2C/SPI) and bit-banging libraries (SoftwareSerial, software I2C) decode the transitions naturally — propagate the pin and the protocols come for free. For cross-process boards (ESP32 backend QEMU, Pi3B QEMU) a byte-level shortcut is additionally enabled on hardware-UART pin pairs to handle high-baud links over WebSocket latency. Implementation: - New simulation/Interconnect.ts singleton subscribes to wire/board changes via the Zustand store. Handlers per tier: browser-sim → pinManager.onPinChange, ESP32 → Esp32Bridge.sendPinEvent, Pi3B → bridge.sendPinEvent. Re-entrancy guard via per-(board,pin) Set. - New utils/boardProtocols.ts classifies pins (uart-tx, i2c-sda, etc.) per board kind, used as optimization hint for the byte shortcut. - types/wire.ts: added signalType field, exports WireSignalType / WireColorMap (fixes a pre-existing TS import error in wireColors). - Deleted the bridgeMap/simulatorMap broadcast forEach blocks in addBoard. Initial board + future boards register with Interconnect via setInterconnectRuntime + store subscription. - PinManager.resetPinStates() helper for test isolation. Tests (16 new files, 96 tests, all passing): - Per-pair × per-protocol matrix: dual-arduino-digital, dual-pico-digital, arduino-pico-digital, triple-pico-digital-chain, dual-arduino-hw-uart, dual-arduino-software-serial, arduino-pico-mixed-uart, arduino-esp32-uart, dual-esp32-uart, pi3-pico-uart, arduino-pico-i2c, arduino-arduino-spi, interconnect-routing, dual-arduino-multi-protocol (UART+I2C+SPI+ digital + concurrent), dual-pico-multi-protocol (UART0+UART1 alt+ I2C0+I2C1+SPI0+digital + 3-Pico star topology) - Updated dual-pico-serial-passthrough to assert correct behaviour - Backend test/multi_board_esp32/test_dual_esp32_serial.py for two real QEMU instances (skip-graceful when lcgamboa lib absent) Verified: 1107/1107 tests pass, zero regressions, vite build OK. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-26 05:47:28 +07:00
// ── Cross-board interconnect wiring ────────────────────────────────────────
//
// The Interconnect router subscribes to wire and board changes to propagate
// digital pin transitions and UART bytes between boards. We register the
// runtime accessors once, bind the initial board, and watch for store
// mutations.
setInterconnectRuntime({
getBoardSimulator: (id: string) => simulatorMap.get(id),
getBoardPinManager: (id: string) => pinManagerMap.get(id),
getBoardBridge: (id: string) => bridgeMap.get(id),
getEsp32Bridge: (id: string) => esp32BridgeMap.get(id),
});
// Bind the initial Arduino Uno that ships with the store.
icBindBoard(INITIAL_BOARD_ID, 'arduino-uno');
icUpdateWires(useSimulatorStore.getState().wires);
// React to wire mutations from any source (drag, import, setState, ...).
let lastWiresRef: readonly Wire[] = useSimulatorStore.getState().wires;
let lastBoardsRef: readonly BoardInstance[] = useSimulatorStore.getState().boards;
useSimulatorStore.subscribe((state) => {
const wiresChanged = state.wires !== lastWiresRef;
const boardsChanged = state.boards !== lastBoardsRef;
if (boardsChanged) {
lastBoardsRef = state.boards;
// Bind any boards that appeared in state but not yet in interconnect
// (covers paths that bypass addBoard, e.g. import-from-zip, hot reload).
for (const b of state.boards) icBindBoard(b.id, b.boardKind);
}
if (wiresChanged || boardsChanged) {
lastWiresRef = state.wires;
icUpdateWires(state.wires);
}
});