2026-03-03 10:20:49 +07:00
|
|
|
|
import { create } from 'zustand';
|
|
|
|
|
|
import { AVRSimulator } from '../simulation/AVRSimulator';
|
2026-03-05 05:28:33 +07:00
|
|
|
|
import { RP2040Simulator } from '../simulation/RP2040Simulator';
|
2026-04-29 12:33:59 +07:00
|
|
|
|
import { Cyw43Bridge } from '../simulation/cyw43';
|
2026-03-16 01:39:26 +07:00
|
|
|
|
import { RiscVSimulator } from '../simulation/RiscVSimulator';
|
2026-03-16 04:40:08 +07:00
|
|
|
|
import { Esp32C3Simulator } from '../simulation/Esp32C3Simulator';
|
2026-03-03 10:20:49 +07:00
|
|
|
|
import { PinManager } from '../simulation/PinManager';
|
2026-03-05 16:56:14 +07:00
|
|
|
|
import { VirtualDS1307, VirtualTempSensor, I2CMemoryDevice } from '../simulation/I2CBusManager';
|
2026-03-06 07:07:03 +07:00
|
|
|
|
import type { RP2040I2CDevice } from '../simulation/RP2040Simulator';
|
Add backend and frontend test suites for Arduino compilation and simulation
- Implemented a comprehensive backend test suite in `test_compilation.py` to validate the Arduino CLI installation, AVR core presence, compilation service, and API endpoint functionality.
- Created a frontend test suite in `simulation.test.ts` to test the `PinManager` and `AVRSimulator` components, ensuring proper functionality and integration.
- Introduced new components for wire management in the simulator, including `PinOverlay`, `WireInProgressRenderer`, `WireLayer`, and `WireRenderer`, enhancing the visual wiring system.
- Developed utility functions for pin position calculations and wire color management, ensuring accurate connections and visual representation.
- Added types for wire management in `wire.ts`, defining structures for wire endpoints, control points, and signal types.
2026-03-04 02:44:07 +07:00
|
|
|
|
import type { Wire, WireInProgress, WireEndpoint } from '../types/wire';
|
2026-03-30 07:27:41 +07:00
|
|
|
|
import type { BoardKind, BoardInstance, LanguageMode } from '../types/board';
|
|
|
|
|
|
import { BOARD_SUPPORTS_MICROPYTHON } from '../types/board';
|
Add backend and frontend test suites for Arduino compilation and simulation
- Implemented a comprehensive backend test suite in `test_compilation.py` to validate the Arduino CLI installation, AVR core presence, compilation service, and API endpoint functionality.
- Created a frontend test suite in `simulation.test.ts` to test the `PinManager` and `AVRSimulator` components, ensuring proper functionality and integration.
- Introduced new components for wire management in the simulator, including `PinOverlay`, `WireInProgressRenderer`, `WireLayer`, and `WireRenderer`, enhancing the visual wiring system.
- Developed utility functions for pin position calculations and wire color management, ensuring accurate connections and visual representation.
- Added types for wire management in `wire.ts`, defining structures for wire endpoints, control points, and signal types.
2026-03-04 02:44:07 +07:00
|
|
|
|
import { calculatePinPosition } from '../utils/pinPositionCalculator';
|
2026-03-11 22:09:24 +07:00
|
|
|
|
import { useOscilloscopeStore } from './useOscilloscopeStore';
|
2026-03-13 09:39:04 +07:00
|
|
|
|
import { RaspberryPi3Bridge } from '../simulation/RaspberryPi3Bridge';
|
2026-03-14 06:35:48 +07:00
|
|
|
|
import { Esp32Bridge } from '../simulation/Esp32Bridge';
|
|
|
|
|
|
import { useEditorStore } from './useEditorStore';
|
2026-03-16 00:04:01 +07:00
|
|
|
|
import { useVfsStore } from './useVfsStore';
|
2026-03-23 04:03:17 +07:00
|
|
|
|
import { boardPinToNumber, isBoardComponent } from '../utils/boardPinMapping';
|
2026-04-21 09:56:42 +07:00
|
|
|
|
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';
|
2026-03-23 04:03:17 +07:00
|
|
|
|
|
|
|
|
|
|
// ── 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).
|
2026-04-22 02:45:45 +07:00
|
|
|
|
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' },
|
|
|
|
|
|
},
|
2026-03-23 04:03:17 +07:00
|
|
|
|
};
|
Add backend and frontend test suites for Arduino compilation and simulation
- Implemented a comprehensive backend test suite in `test_compilation.py` to validate the Arduino CLI installation, AVR core presence, compilation service, and API endpoint functionality.
- Created a frontend test suite in `simulation.test.ts` to test the `PinManager` and `AVRSimulator` components, ensuring proper functionality and integration.
- Introduced new components for wire management in the simulator, including `PinOverlay`, `WireInProgressRenderer`, `WireLayer`, and `WireRenderer`, enhancing the visual wiring system.
- Developed utility functions for pin position calculations and wire color management, ensuring accurate connections and visual representation.
- Added types for wire management in `wire.ts`, defining structures for wire endpoints, control points, and signal types.
2026-03-04 02:44:07 +07:00
|
|
|
|
|
2026-04-08 10:02:43 +07:00
|
|
|
|
// ── 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.
|
2026-04-22 02:45:45 +07:00
|
|
|
|
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' },
|
2026-04-08 10:02:43 +07:00
|
|
|
|
};
|
|
|
|
|
|
|
2026-03-13 09:39:04 +07:00
|
|
|
|
// ── Legacy type aliases (keep external consumers working) ──────────────────
|
2026-03-09 20:08:14 +07:00
|
|
|
|
export type BoardType = 'arduino-uno' | 'arduino-nano' | 'arduino-mega' | 'raspberry-pi-pico';
|
2026-03-05 05:28:33 +07:00
|
|
|
|
|
|
|
|
|
|
export const BOARD_FQBN: Record<BoardType, string> = {
|
|
|
|
|
|
'arduino-uno': 'arduino:avr:uno',
|
2026-03-08 09:14:33 +07:00
|
|
|
|
'arduino-nano': 'arduino:avr:nano:cpu=atmega328',
|
2026-03-09 20:08:14 +07:00
|
|
|
|
'arduino-mega': 'arduino:avr:mega',
|
2026-03-05 05:28:33 +07:00
|
|
|
|
'raspberry-pi-pico': 'rp2040:rp2040:rpipico',
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
export const BOARD_LABELS: Record<BoardType, string> = {
|
|
|
|
|
|
'arduino-uno': 'Arduino Uno',
|
2026-03-08 09:14:33 +07:00
|
|
|
|
'arduino-nano': 'Arduino Nano',
|
2026-03-09 20:08:14 +07:00
|
|
|
|
'arduino-mega': 'Arduino Mega 2560',
|
2026-03-05 05:28:33 +07:00
|
|
|
|
'raspberry-pi-pico': 'Raspberry Pi Pico',
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-03-08 05:29:23 +07:00
|
|
|
|
export const DEFAULT_BOARD_POSITION = { x: 50, y: 50 };
|
|
|
|
|
|
export const ARDUINO_POSITION = DEFAULT_BOARD_POSITION;
|
2026-03-03 10:20:49 +07:00
|
|
|
|
|
2026-03-22 12:25:58 +07:00
|
|
|
|
// ── 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;
|
|
|
|
|
|
|
|
|
|
|
|
constructor(bridge: Esp32Bridge, pm: PinManager) {
|
|
|
|
|
|
this.bridge = bridge;
|
|
|
|
|
|
this.pinManager = pm;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-22 02:45:45 +07:00
|
|
|
|
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;
|
|
|
|
|
|
}
|
2026-03-22 12:25:58 +07:00
|
|
|
|
serialWrite(text: string): void {
|
|
|
|
|
|
this.bridge.sendSerialBytes(Array.from(new TextEncoder().encode(text)));
|
|
|
|
|
|
}
|
|
|
|
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
2026-04-22 02:45:45 +07:00
|
|
|
|
getADC(): any {
|
|
|
|
|
|
return null;
|
|
|
|
|
|
}
|
2026-03-23 20:37:06 +07:00
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 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;
|
2026-04-22 02:45:45 +07:00
|
|
|
|
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
|
2026-03-23 20:37:06 +07:00
|
|
|
|
if (channel < 0) return false;
|
|
|
|
|
|
const millivolts = Math.round(voltage * 1000);
|
|
|
|
|
|
this.bridge.setAdc(channel, millivolts);
|
|
|
|
|
|
return true;
|
|
|
|
|
|
}
|
2026-04-21 12:17:30 +07:00
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 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;
|
|
|
|
|
|
}
|
2026-04-22 02:45:45 +07:00
|
|
|
|
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 */
|
|
|
|
|
|
}
|
2026-03-23 04:03:17 +07:00
|
|
|
|
|
|
|
|
|
|
// ── 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
|
|
|
|
|
|
}
|
feat: Add support for SSD168x ePaper panels
- Introduced EPaperPanels.ts to define configurations for various ePaper panels including dimensions, refresh rates, and controller details.
- Implemented SSD168xDecoder.ts to handle the decoding of SPI commands for the SSD168x family of ePaper displays.
- Created EPaperPart.ts to manage the simulation of ePaper panels, integrating with the existing simulator architecture and handling events.
- Added example sketches for 2.13", 2.9", 4.2", and 7.5" ePaper displays, demonstrating basic functionality and text rendering.
- Ensured compatibility with AVR, RP2040, and ESP32 platforms, with appropriate pin configurations for each.
2026-04-30 08:23:00 +07:00
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 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;
|
|
|
|
|
|
}
|
2026-03-23 04:03:17 +07:00
|
|
|
|
updateSensor(pin: number, properties: Record<string, unknown>): void {
|
|
|
|
|
|
this.bridge.sendSensorUpdate(pin, properties);
|
|
|
|
|
|
}
|
|
|
|
|
|
unregisterSensor(pin: number): void {
|
|
|
|
|
|
this.bridge.sendSensorDetach(pin);
|
|
|
|
|
|
}
|
2026-04-08 01:43:09 +07:00
|
|
|
|
|
|
|
|
|
|
// ── 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;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-03-22 12:25:58 +07:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-24 23:54:44 +07:00
|
|
|
|
// ── Shared LEDC update handler (used by addBoard, setBoardType, initSimulator) ─
|
|
|
|
|
|
function makeLedcUpdateHandler(boardId: string) {
|
|
|
|
|
|
return (update: { channel: number; duty_pct: number; gpio?: number }) => {
|
|
|
|
|
|
const boardPm = pinManagerMap.get(boardId);
|
|
|
|
|
|
if (!boardPm) return;
|
|
|
|
|
|
const dutyCycle = update.duty_pct / 100;
|
|
|
|
|
|
if (update.gpio !== undefined && update.gpio >= 0) {
|
|
|
|
|
|
boardPm.updatePwm(update.gpio, dutyCycle);
|
|
|
|
|
|
} else {
|
|
|
|
|
|
// gpio unknown (QEMU doesn't expose gpio_out_sel for LEDC):
|
|
|
|
|
|
// broadcast to ALL PWM listeners. Components filter by duty range
|
|
|
|
|
|
// (servo accepts 0.01–0.20, LEDs use 0–1.0).
|
|
|
|
|
|
boardPm.broadcastPwm(dutyCycle);
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-13 09:39:04 +07:00
|
|
|
|
// ── Runtime Maps (outside Zustand — not serialisable) ─────────────────────
|
2026-04-22 02:45:45 +07:00
|
|
|
|
const simulatorMap = new Map<
|
|
|
|
|
|
string,
|
|
|
|
|
|
AVRSimulator | RP2040Simulator | RiscVSimulator | Esp32C3Simulator | Esp32BridgeShim
|
|
|
|
|
|
>();
|
2026-03-13 09:39:04 +07:00
|
|
|
|
const pinManagerMap = new Map<string, PinManager>();
|
|
|
|
|
|
const bridgeMap = new Map<string, RaspberryPi3Bridge>();
|
2026-03-14 06:35:48 +07:00
|
|
|
|
const esp32BridgeMap = new Map<string, Esp32Bridge>();
|
2026-04-29 12:33:59 +07:00
|
|
|
|
// Pico W WiFi (CYW43439) bridge — created lazily, only when boardKind === 'pi-pico-w'.
|
|
|
|
|
|
const cyw43BridgeMap = new Map<string, Cyw43Bridge>();
|
2026-03-13 09:39:04 +07:00
|
|
|
|
|
|
|
|
|
|
export const getBoardSimulator = (id: string) => simulatorMap.get(id);
|
|
|
|
|
|
export const getBoardPinManager = (id: string) => pinManagerMap.get(id);
|
|
|
|
|
|
export const getBoardBridge = (id: string) => bridgeMap.get(id);
|
2026-03-14 06:35:48 +07:00
|
|
|
|
export const getEsp32Bridge = (id: string) => esp32BridgeMap.get(id);
|
2026-04-29 12:33:59 +07:00
|
|
|
|
export const getCyw43Bridge = (id: string) => cyw43BridgeMap.get(id);
|
2026-03-14 06:35:48 +07:00
|
|
|
|
|
2026-03-19 09:30:45 +07:00
|
|
|
|
// Xtensa-based ESP32 boards — use QEMU bridge (backend)
|
2026-03-16 01:39:26 +07:00
|
|
|
|
const ESP32_KINDS = new Set<BoardKind>([
|
2026-04-22 02:45:45 +07:00
|
|
|
|
'esp32',
|
|
|
|
|
|
'esp32-devkit-c-v4',
|
|
|
|
|
|
'esp32-cam',
|
|
|
|
|
|
'wemos-lolin32-lite',
|
|
|
|
|
|
'esp32-s3',
|
|
|
|
|
|
'xiao-esp32-s3',
|
|
|
|
|
|
'arduino-nano-esp32',
|
2026-03-16 04:40:08 +07:00
|
|
|
|
]);
|
|
|
|
|
|
|
2026-03-19 09:30:45 +07:00
|
|
|
|
// 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.
|
2026-03-16 04:40:08 +07:00
|
|
|
|
const ESP32_RISCV_KINDS = new Set<BoardKind>([
|
2026-04-22 02:45:45 +07:00
|
|
|
|
'esp32-c3',
|
|
|
|
|
|
'xiao-esp32-c3',
|
|
|
|
|
|
'aitewinrobot-esp32c3-supermini',
|
2026-03-16 01:39:26 +07:00
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
|
|
function isEsp32Kind(kind: BoardKind): boolean {
|
2026-03-19 09:30:45 +07:00
|
|
|
|
return ESP32_KINDS.has(kind) || ESP32_RISCV_KINDS.has(kind);
|
2026-03-14 06:35:48 +07:00
|
|
|
|
}
|
2026-03-13 09:39:04 +07:00
|
|
|
|
|
2026-03-16 04:40:08 +07:00
|
|
|
|
function isRiscVEsp32Kind(kind: BoardKind): boolean {
|
|
|
|
|
|
return ESP32_RISCV_KINDS.has(kind);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-13 09:39:04 +07:00
|
|
|
|
// ── Component type ────────────────────────────────────────────────────────
|
2026-03-03 10:20:49 +07:00
|
|
|
|
interface Component {
|
|
|
|
|
|
id: string;
|
2026-03-13 09:39:04 +07:00
|
|
|
|
metadataId: string;
|
2026-03-03 10:20:49 +07:00
|
|
|
|
x: number;
|
|
|
|
|
|
y: number;
|
2026-03-13 09:39:04 +07:00
|
|
|
|
properties: Record<string, unknown>;
|
2026-03-03 10:20:49 +07:00
|
|
|
|
}
|
|
|
|
|
|
|
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;
|
|
|
|
|
|
|
2026-03-13 09:39:04 +07:00
|
|
|
|
// ── Store interface ───────────────────────────────────────────────────────
|
2026-03-03 10:20:49 +07:00
|
|
|
|
interface SimulatorState {
|
2026-03-13 09:39:04 +07:00
|
|
|
|
// ── 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;
|
2026-03-13 09:39:04 +07:00
|
|
|
|
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;
|
2026-03-13 09:39:04 +07:00
|
|
|
|
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;
|
2026-04-22 02:45:45 +07:00
|
|
|
|
loadMicroPythonProgram: (
|
|
|
|
|
|
boardId: string,
|
|
|
|
|
|
files: Array<{ name: string; content: string }>,
|
|
|
|
|
|
) => Promise<void>;
|
2026-03-30 07:27:41 +07:00
|
|
|
|
setBoardLanguageMode: (boardId: string, mode: LanguageMode) => void;
|
2026-03-13 09:39:04 +07:00
|
|
|
|
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 */
|
2026-03-05 05:28:33 +07:00
|
|
|
|
boardType: BoardType;
|
2026-03-13 09:39:04 +07:00
|
|
|
|
/** @deprecated use boards[x].x/y */
|
2026-03-08 05:29:23 +07:00
|
|
|
|
boardPosition: { x: number; y: number };
|
2026-03-13 09:39:04 +07:00
|
|
|
|
/** @deprecated use getBoardSimulator(activeBoardId) */
|
2026-04-22 02:45:45 +07:00
|
|
|
|
simulator:
|
|
|
|
|
|
| AVRSimulator
|
|
|
|
|
|
| RP2040Simulator
|
|
|
|
|
|
| RiscVSimulator
|
|
|
|
|
|
| Esp32C3Simulator
|
|
|
|
|
|
| Esp32BridgeShim
|
|
|
|
|
|
| null;
|
2026-03-13 09:39:04 +07:00
|
|
|
|
/** @deprecated use getBoardPinManager(activeBoardId) */
|
2026-03-03 10:20:49 +07:00
|
|
|
|
pinManager: PinManager;
|
|
|
|
|
|
running: boolean;
|
|
|
|
|
|
compiledHex: string | null;
|
2026-03-09 12:31:04 +07:00
|
|
|
|
hexEpoch: number;
|
2026-03-05 16:56:14 +07:00
|
|
|
|
serialOutput: string;
|
2026-03-06 07:07:10 +07:00
|
|
|
|
serialBaudRate: number;
|
2026-03-05 16:56:14 +07:00
|
|
|
|
serialMonitorOpen: boolean;
|
2026-03-13 09:39:04 +07:00
|
|
|
|
/** @deprecated use getBoardBridge(activeBoardId) */
|
2026-03-12 18:17:29 +07:00
|
|
|
|
remoteConnected: boolean;
|
|
|
|
|
|
remoteSocket: WebSocket | null;
|
|
|
|
|
|
|
2026-03-13 09:39:04 +07:00
|
|
|
|
setBoardType: (type: BoardType) => void;
|
2026-03-03 10:20:49 +07:00
|
|
|
|
initSimulator: () => void;
|
|
|
|
|
|
loadHex: (hex: string) => void;
|
2026-03-05 05:28:33 +07:00
|
|
|
|
loadBinary: (base64: string) => void;
|
2026-03-03 10:20:49 +07:00
|
|
|
|
startSimulation: () => void;
|
|
|
|
|
|
stopSimulation: () => void;
|
|
|
|
|
|
resetSimulation: () => void;
|
|
|
|
|
|
setCompiledHex: (hex: string) => void;
|
2026-03-05 05:28:33 +07:00
|
|
|
|
setCompiledBinary: (base64: string) => void;
|
2026-03-03 10:20:49 +07:00
|
|
|
|
setRunning: (running: boolean) => void;
|
2026-03-13 09:39:04 +07:00
|
|
|
|
connectRemoteSimulator: (clientId: string) => void;
|
|
|
|
|
|
disconnectRemoteSimulator: () => void;
|
|
|
|
|
|
sendRemotePinEvent: (pin: string, state: number) => void;
|
2026-03-03 10:20:49 +07:00
|
|
|
|
|
2026-03-14 22:05:35 +07:00
|
|
|
|
// ── ESP32 crash notification ─────────────────────────────────────────────
|
|
|
|
|
|
esp32CrashBoardId: string | null;
|
|
|
|
|
|
dismissEsp32Crash: () => void;
|
|
|
|
|
|
|
2026-03-13 09:39:04 +07:00
|
|
|
|
// ── Components ──────────────────────────────────────────────────────────
|
|
|
|
|
|
components: Component[];
|
2026-03-03 10:20:49 +07:00
|
|
|
|
addComponent: (component: Component) => void;
|
|
|
|
|
|
removeComponent: (id: string) => void;
|
|
|
|
|
|
updateComponent: (id: string, updates: Partial<Component>) => void;
|
|
|
|
|
|
updateComponentState: (id: string, state: boolean) => void;
|
2026-03-13 09:39:04 +07:00
|
|
|
|
handleComponentEvent: (componentId: string, eventName: string, data?: unknown) => void;
|
2026-03-04 08:25:52 +07:00
|
|
|
|
setComponents: (components: Component[]) => void;
|
Add backend and frontend test suites for Arduino compilation and simulation
- Implemented a comprehensive backend test suite in `test_compilation.py` to validate the Arduino CLI installation, AVR core presence, compilation service, and API endpoint functionality.
- Created a frontend test suite in `simulation.test.ts` to test the `PinManager` and `AVRSimulator` components, ensuring proper functionality and integration.
- Introduced new components for wire management in the simulator, including `PinOverlay`, `WireInProgressRenderer`, `WireLayer`, and `WireRenderer`, enhancing the visual wiring system.
- Developed utility functions for pin position calculations and wire color management, ensuring accurate connections and visual representation.
- Added types for wire management in `wire.ts`, defining structures for wire endpoints, control points, and signal types.
2026-03-04 02:44:07 +07:00
|
|
|
|
|
2026-03-13 09:39:04 +07:00
|
|
|
|
// ── Wires ───────────────────────────────────────────────────────────────
|
|
|
|
|
|
wires: Wire[];
|
|
|
|
|
|
selectedWireId: string | null;
|
|
|
|
|
|
wireInProgress: WireInProgress | null;
|
Add backend and frontend test suites for Arduino compilation and simulation
- Implemented a comprehensive backend test suite in `test_compilation.py` to validate the Arduino CLI installation, AVR core presence, compilation service, and API endpoint functionality.
- Created a frontend test suite in `simulation.test.ts` to test the `PinManager` and `AVRSimulator` components, ensuring proper functionality and integration.
- Introduced new components for wire management in the simulator, including `PinOverlay`, `WireInProgressRenderer`, `WireLayer`, and `WireRenderer`, enhancing the visual wiring system.
- Developed utility functions for pin position calculations and wire color management, ensuring accurate connections and visual representation.
- Added types for wire management in `wire.ts`, defining structures for wire endpoints, control points, and signal types.
2026-03-04 02:44:07 +07:00
|
|
|
|
addWire: (wire: Wire) => void;
|
|
|
|
|
|
removeWire: (wireId: string) => void;
|
|
|
|
|
|
updateWire: (wireId: string, updates: Partial<Wire>) => void;
|
|
|
|
|
|
setSelectedWire: (wireId: string | null) => void;
|
2026-03-04 08:25:52 +07:00
|
|
|
|
setWires: (wires: Wire[]) => void;
|
2026-03-15 08:55:13 +07:00
|
|
|
|
startWireCreation: (endpoint: WireEndpoint, color: string) => void;
|
Add backend and frontend test suites for Arduino compilation and simulation
- Implemented a comprehensive backend test suite in `test_compilation.py` to validate the Arduino CLI installation, AVR core presence, compilation service, and API endpoint functionality.
- Created a frontend test suite in `simulation.test.ts` to test the `PinManager` and `AVRSimulator` components, ensuring proper functionality and integration.
- Introduced new components for wire management in the simulator, including `PinOverlay`, `WireInProgressRenderer`, `WireLayer`, and `WireRenderer`, enhancing the visual wiring system.
- Developed utility functions for pin position calculations and wire color management, ensuring accurate connections and visual representation.
- Added types for wire management in `wire.ts`, defining structures for wire endpoints, control points, and signal types.
2026-03-04 02:44:07 +07:00
|
|
|
|
updateWireInProgress: (x: number, y: number) => void;
|
2026-03-15 08:55:13 +07:00
|
|
|
|
addWireWaypoint: (x: number, y: number) => void;
|
|
|
|
|
|
setWireInProgressColor: (color: string) => void;
|
Add backend and frontend test suites for Arduino compilation and simulation
- Implemented a comprehensive backend test suite in `test_compilation.py` to validate the Arduino CLI installation, AVR core presence, compilation service, and API endpoint functionality.
- Created a frontend test suite in `simulation.test.ts` to test the `PinManager` and `AVRSimulator` components, ensuring proper functionality and integration.
- Introduced new components for wire management in the simulator, including `PinOverlay`, `WireInProgressRenderer`, `WireLayer`, and `WireRenderer`, enhancing the visual wiring system.
- Developed utility functions for pin position calculations and wire color management, ensuring accurate connections and visual representation.
- Added types for wire management in `wire.ts`, defining structures for wire endpoints, control points, and signal types.
2026-03-04 02:44:07 +07:00
|
|
|
|
finishWireCreation: (endpoint: WireEndpoint) => void;
|
|
|
|
|
|
cancelWireCreation: () => void;
|
|
|
|
|
|
updateWirePositions: (componentId: string) => void;
|
|
|
|
|
|
recalculateAllWirePositions: () => void;
|
2026-03-05 16:56:14 +07:00
|
|
|
|
|
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;
|
|
|
|
|
|
|
2026-03-13 09:39:04 +07:00
|
|
|
|
// ── Serial monitor ──────────────────────────────────────────────────────
|
2026-03-05 16:56:14 +07:00
|
|
|
|
toggleSerialMonitor: () => void;
|
|
|
|
|
|
serialWrite: (text: string) => void;
|
2026-03-16 00:04:01 +07:00
|
|
|
|
serialWriteToBoard: (boardId: string, text: string) => void;
|
2026-03-05 16:56:14 +07:00
|
|
|
|
clearSerialOutput: () => void;
|
2026-03-16 00:04:01 +07:00
|
|
|
|
clearBoardSerialOutput: (boardId: string) => void;
|
2026-03-03 10:20:49 +07:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-13 09:39:04 +07:00
|
|
|
|
// ── 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,
|
2026-03-16 04:40:08 +07:00
|
|
|
|
): AVRSimulator | RP2040Simulator | RiscVSimulator | Esp32C3Simulator {
|
|
|
|
|
|
let sim: AVRSimulator | RP2040Simulator | RiscVSimulator | Esp32C3Simulator;
|
2026-03-13 09:39:04 +07:00
|
|
|
|
if (boardKind === 'arduino-mega') {
|
|
|
|
|
|
sim = new AVRSimulator(pm, 'mega');
|
2026-03-16 01:39:26 +07:00
|
|
|
|
} else if (boardKind === 'attiny85') {
|
|
|
|
|
|
sim = new AVRSimulator(pm, 'tiny85');
|
|
|
|
|
|
} else if (boardKind === 'raspberry-pi-pico' || boardKind === 'pi-pico-w') {
|
2026-03-13 09:39:04 +07:00
|
|
|
|
sim = new RP2040Simulator(pm);
|
2026-03-16 04:40:08 +07:00
|
|
|
|
} else if (isRiscVEsp32Kind(boardKind)) {
|
|
|
|
|
|
// ESP32-C3 / XIAO-C3 / C3 SuperMini — browser-side RV32IMC emulator
|
|
|
|
|
|
sim = new Esp32C3Simulator(pm);
|
2026-03-13 09:39:04 +07:00
|
|
|
|
} 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}`,
|
2026-03-30 07:27:41 +07:00
|
|
|
|
languageMode: 'arduino' as LanguageMode,
|
2026-03-13 09:39:04 +07:00
|
|
|
|
};
|
|
|
|
|
|
|
2026-04-21 09:56:42 +07:00
|
|
|
|
// ── 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 };
|
|
|
|
|
|
});
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-03-13 09:39:04 +07:00
|
|
|
|
// ── Store ─────────────────────────────────────────────────────────────────
|
2026-03-03 10:20:49 +07:00
|
|
|
|
export const useSimulatorStore = create<SimulatorState>((set, get) => {
|
2026-03-13 09:39:04 +07:00
|
|
|
|
// Initialise runtime objects for the default board
|
|
|
|
|
|
const initialPm = new PinManager();
|
|
|
|
|
|
pinManagerMap.set(INITIAL_BOARD_ID, initialPm);
|
2026-03-03 10:20:49 +07:00
|
|
|
|
|
2026-03-16 20:31:02 +07:00
|
|
|
|
function getOscilloscopeCallback(boardId: string) {
|
2026-03-13 09:39:04 +07:00
|
|
|
|
return (pin: number, state: boolean, timeMs: number) => {
|
2026-03-11 22:09:24 +07:00
|
|
|
|
const { channels, pushSample } = useOscilloscopeStore.getState();
|
|
|
|
|
|
for (const ch of channels) {
|
2026-03-16 20:31:02 +07:00
|
|
|
|
if (ch.boardId === boardId && ch.pin === pin) pushSample(ch.id, timeMs, state);
|
2026-03-11 22:09:24 +07:00
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
2026-03-12 18:17:29 +07:00
|
|
|
|
|
2026-03-13 09:39:04 +07:00
|
|
|
|
const initialSim = createSimulator(
|
|
|
|
|
|
'arduino-uno',
|
|
|
|
|
|
initialPm,
|
2026-04-21 09:56:42 +07:00
|
|
|
|
(ch) => appendSerial(INITIAL_BOARD_ID, ch),
|
2026-03-13 09:39:04 +07:00
|
|
|
|
(baud) => {
|
|
|
|
|
|
set((s) => {
|
|
|
|
|
|
const boards = s.boards.map((b) =>
|
2026-04-22 02:45:45 +07:00
|
|
|
|
b.id === INITIAL_BOARD_ID ? { ...b, serialBaudRate: baud } : b,
|
2026-03-13 09:39:04 +07:00
|
|
|
|
);
|
|
|
|
|
|
const isActive = s.activeBoardId === INITIAL_BOARD_ID;
|
|
|
|
|
|
return { boards, ...(isActive ? { serialBaudRate: baud } : {}) };
|
|
|
|
|
|
});
|
|
|
|
|
|
},
|
2026-03-16 20:31:02 +07:00
|
|
|
|
getOscilloscopeCallback(INITIAL_BOARD_ID),
|
2026-03-13 09:39:04 +07:00
|
|
|
|
);
|
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).
|
2026-03-13 09:39:04 +07:00
|
|
|
|
simulatorMap.set(INITIAL_BOARD_ID, initialSim);
|
2026-03-12 18:17:29 +07:00
|
|
|
|
|
2026-03-13 09:39:04 +07:00
|
|
|
|
// ── Legacy single-board PinManager (references initial board's pm) ───────
|
|
|
|
|
|
const legacyPinManager = initialPm;
|
2026-03-12 18:26:56 +07:00
|
|
|
|
|
2026-03-13 09:39:04 +07:00
|
|
|
|
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}`;
|
|
|
|
|
|
}
|
2026-03-13 09:39:04 +07:00
|
|
|
|
|
|
|
|
|
|
const pm = new PinManager();
|
|
|
|
|
|
pinManagerMap.set(id, pm);
|
|
|
|
|
|
|
2026-04-21 09:56:42 +07:00
|
|
|
|
const serialCallback = (ch: string) => appendSerial(id, ch);
|
2026-03-14 06:35:48 +07:00
|
|
|
|
|
|
|
|
|
|
if (boardKind === 'raspberry-pi-3') {
|
|
|
|
|
|
const bridge = new RaspberryPi3Bridge(id);
|
2026-03-16 00:04:01 +07:00
|
|
|
|
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).
|
2026-03-16 00:04:01 +07:00
|
|
|
|
};
|
2026-03-14 06:35:48 +07:00
|
|
|
|
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).
|
2026-03-14 06:35:48 +07:00
|
|
|
|
};
|
|
|
|
|
|
bridgeMap.set(id, bridge);
|
|
|
|
|
|
} else if (isEsp32Kind(boardKind)) {
|
|
|
|
|
|
const bridge = new Esp32Bridge(id, boardKind);
|
|
|
|
|
|
bridge.onSerialData = serialCallback;
|
2026-03-14 22:05:35 +07:00
|
|
|
|
bridge.onPinChange = (gpioPin, state) => {
|
|
|
|
|
|
const boardPm = pinManagerMap.get(id);
|
|
|
|
|
|
if (boardPm) boardPm.triggerPinChange(gpioPin, state);
|
|
|
|
|
|
};
|
|
|
|
|
|
bridge.onCrash = () => {
|
|
|
|
|
|
set({ esp32CrashBoardId: id });
|
|
|
|
|
|
};
|
2026-03-17 12:28:08 +07:00
|
|
|
|
bridge.onDisconnected = () => {
|
|
|
|
|
|
set((s) => {
|
2026-04-22 02:45:45 +07:00
|
|
|
|
const boards = s.boards.map((b) => (b.id === id ? { ...b, running: false } : b));
|
2026-03-17 12:28:08 +07:00
|
|
|
|
const isActive = s.activeBoardId === id;
|
|
|
|
|
|
return { boards, ...(isActive ? { running: false } : {}) };
|
|
|
|
|
|
});
|
|
|
|
|
|
};
|
2026-03-24 23:54:44 +07:00
|
|
|
|
bridge.onLedcUpdate = makeLedcUpdateHandler(id);
|
2026-03-14 22:05:35 +07:00
|
|
|
|
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) {
|
2026-04-22 02:45:45 +07:00
|
|
|
|
eventTarget.dispatchEvent(new CustomEvent('ws2812-pixels', { detail: { pixels } }));
|
2026-03-14 22:05:35 +07:00
|
|
|
|
}
|
2026-03-14 06:35:48 +07:00
|
|
|
|
};
|
2026-04-01 06:53:56 +07:00
|
|
|
|
bridge.onWifiStatus = (ws) => {
|
|
|
|
|
|
set((s) => ({
|
2026-04-22 02:45:45 +07:00
|
|
|
|
boards: s.boards.map((b) => (b.id === id ? { ...b, wifiStatus: ws } : b)),
|
2026-04-01 06:53:56 +07:00
|
|
|
|
}));
|
|
|
|
|
|
};
|
|
|
|
|
|
bridge.onBleStatus = (bs) => {
|
|
|
|
|
|
set((s) => ({
|
2026-04-22 02:45:45 +07:00
|
|
|
|
boards: s.boards.map((b) => (b.id === id ? { ...b, bleStatus: bs } : b)),
|
2026-04-01 06:53:56 +07:00
|
|
|
|
}));
|
|
|
|
|
|
};
|
2026-03-14 06:35:48 +07:00
|
|
|
|
esp32BridgeMap.set(id, bridge);
|
2026-03-22 12:25:58 +07:00
|
|
|
|
// 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;
|
|
|
|
|
|
simulatorMap.set(id, shim);
|
2026-03-14 06:35:48 +07:00
|
|
|
|
} else {
|
2026-03-13 09:39:04 +07:00
|
|
|
|
const sim = createSimulator(
|
|
|
|
|
|
boardKind,
|
|
|
|
|
|
pm,
|
2026-03-14 06:35:48 +07:00
|
|
|
|
serialCallback,
|
2026-03-13 09:39:04 +07:00
|
|
|
|
(baud) => {
|
|
|
|
|
|
set((s) => {
|
|
|
|
|
|
const boards = s.boards.map((b) =>
|
2026-04-22 02:45:45 +07:00
|
|
|
|
b.id === id ? { ...b, serialBaudRate: baud } : b,
|
2026-03-13 09:39:04 +07:00
|
|
|
|
);
|
|
|
|
|
|
const isActive = s.activeBoardId === id;
|
|
|
|
|
|
return { boards, ...(isActive ? { serialBaudRate: baud } : {}) };
|
|
|
|
|
|
});
|
|
|
|
|
|
},
|
2026-03-16 20:31:02 +07:00
|
|
|
|
getOscilloscopeCallback(id),
|
2026-03-13 09:39:04 +07:00
|
|
|
|
);
|
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).
|
2026-03-13 09:39:04 +07:00
|
|
|
|
simulatorMap.set(id, sim);
|
2026-04-29 12:33:59 +07:00
|
|
|
|
|
|
|
|
|
|
// ── 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);
|
|
|
|
|
|
}
|
2026-03-13 09:39:04 +07:00
|
|
|
|
}
|
2026-03-12 18:26:56 +07:00
|
|
|
|
|
2026-03-13 09:39:04 +07:00
|
|
|
|
const newBoard: BoardInstance = {
|
2026-04-22 02:45:45 +07:00
|
|
|
|
id,
|
|
|
|
|
|
boardKind,
|
|
|
|
|
|
x,
|
|
|
|
|
|
y,
|
|
|
|
|
|
running: false,
|
|
|
|
|
|
compiledProgram: null,
|
|
|
|
|
|
serialOutput: '',
|
|
|
|
|
|
serialBaudRate: 0,
|
2026-03-13 09:39:04 +07:00
|
|
|
|
serialMonitorOpen: false,
|
|
|
|
|
|
activeFileGroupId: `group-${id}`,
|
2026-03-30 07:27:41 +07:00
|
|
|
|
languageMode: 'arduino',
|
2026-03-12 18:17:29 +07:00
|
|
|
|
};
|
|
|
|
|
|
|
2026-03-13 09:39:04 +07:00
|
|
|
|
set((s) => ({ boards: [...s.boards, newBoard] }));
|
2026-03-14 06:35:48 +07:00
|
|
|
|
// Create the editor file group for this board
|
|
|
|
|
|
useEditorStore.getState().createFileGroup(`group-${id}`);
|
2026-03-16 00:04:01 +07:00
|
|
|
|
// Init VFS for Raspberry Pi 3 boards
|
|
|
|
|
|
if (boardKind === 'raspberry-pi-3') {
|
|
|
|
|
|
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);
|
2026-03-13 09:39:04 +07:00
|
|
|
|
return id;
|
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
|
|
removeBoard: (boardId: string) => {
|
2026-03-29 11:55:10 +07:00
|
|
|
|
const board = get().boards.find((b) => b.id === boardId);
|
2026-03-13 09:39:04 +07:00
|
|
|
|
getBoardSimulator(boardId)?.stop();
|
|
|
|
|
|
simulatorMap.delete(boardId);
|
|
|
|
|
|
pinManagerMap.delete(boardId);
|
|
|
|
|
|
const bridge = getBoardBridge(boardId);
|
2026-04-22 02:45:45 +07:00
|
|
|
|
if (bridge) {
|
|
|
|
|
|
bridge.disconnect();
|
|
|
|
|
|
bridgeMap.delete(boardId);
|
|
|
|
|
|
}
|
2026-03-14 06:35:48 +07:00
|
|
|
|
const esp32Bridge = getEsp32Bridge(boardId);
|
2026-04-22 02:45:45 +07:00
|
|
|
|
if (esp32Bridge) {
|
|
|
|
|
|
esp32Bridge.disconnect();
|
|
|
|
|
|
esp32BridgeMap.delete(boardId);
|
|
|
|
|
|
}
|
2026-04-29 12:33:59 +07:00
|
|
|
|
const cyw43Bridge = getCyw43Bridge(boardId);
|
|
|
|
|
|
if (cyw43Bridge) {
|
|
|
|
|
|
cyw43Bridge.disconnect();
|
|
|
|
|
|
cyw43BridgeMap.delete(boardId);
|
|
|
|
|
|
}
|
|
|
|
|
|
const cyw43Sim = getBoardSimulator(boardId);
|
|
|
|
|
|
if (cyw43Sim instanceof RP2040Simulator) cyw43Sim.detachCyw43();
|
2026-03-13 09:39:04 +07:00
|
|
|
|
set((s) => {
|
|
|
|
|
|
const boards = s.boards.filter((b) => b.id !== boardId);
|
2026-04-22 02:45:45 +07:00
|
|
|
|
const activeBoardId =
|
|
|
|
|
|
s.activeBoardId === boardId ? (boards[0]?.id ?? null) : s.activeBoardId;
|
2026-03-29 11:55:10 +07:00
|
|
|
|
// Remove wires connected to this board
|
2026-04-22 02:45:45 +07:00
|
|
|
|
const wires = s.wires.filter(
|
|
|
|
|
|
(w) => w.start.componentId !== boardId && w.end.componentId !== boardId,
|
2026-03-29 11:55:10 +07:00
|
|
|
|
);
|
|
|
|
|
|
return { boards, activeBoardId, wires };
|
2026-03-13 09:39:04 +07:00
|
|
|
|
});
|
2026-03-29 11:55:10 +07:00
|
|
|
|
// 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);
|
2026-03-13 09:39:04 +07:00
|
|
|
|
},
|
2026-03-12 18:26:56 +07:00
|
|
|
|
|
2026-03-13 09:39:04 +07:00
|
|
|
|
updateBoard: (boardId: string, updates: Partial<BoardInstance>) => {
|
|
|
|
|
|
set((s) => ({
|
2026-04-22 02:45:45 +07:00
|
|
|
|
boards: s.boards.map((b) => (b.id === boardId ? { ...b, ...updates } : b)),
|
2026-03-13 09:39:04 +07:00
|
|
|
|
}));
|
|
|
|
|
|
},
|
2026-03-12 18:26:56 +07:00
|
|
|
|
|
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);
|
|
|
|
|
|
});
|
|
|
|
|
|
},
|
|
|
|
|
|
|
2026-03-13 09:39:04 +07:00
|
|
|
|
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,
|
2026-04-22 02:45:45 +07:00
|
|
|
|
boards: s.boards.map((b) => (b.id === id ? { ...b, x: pos.x, y: pos.y } : b)),
|
2026-03-13 09:39:04 +07:00
|
|
|
|
}));
|
|
|
|
|
|
},
|
2026-03-12 18:26:56 +07:00
|
|
|
|
|
2026-03-13 09:39:04 +07:00
|
|
|
|
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
|
2026-04-22 02:45:45 +07:00
|
|
|
|
boardType: (board.boardKind === 'raspberry-pi-3'
|
|
|
|
|
|
? 'arduino-uno'
|
|
|
|
|
|
: board.boardKind) as BoardType,
|
2026-03-13 09:39:04 +07:00
|
|
|
|
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,
|
2026-04-22 02:45:45 +07:00
|
|
|
|
remoteConnected:
|
|
|
|
|
|
bridgeMap.get(boardId)?.connected ?? esp32BridgeMap.get(boardId)?.connected ?? false,
|
2026-03-13 09:39:04 +07:00
|
|
|
|
remoteSocket: null,
|
|
|
|
|
|
});
|
2026-03-14 06:35:48 +07:00
|
|
|
|
// Switch the editor to this board's file group
|
|
|
|
|
|
useEditorStore.getState().setActiveGroup(board.activeFileGroupId);
|
2026-03-13 09:39:04 +07:00
|
|
|
|
},
|
2026-03-12 18:26:56 +07:00
|
|
|
|
|
2026-03-13 09:39:04 +07:00
|
|
|
|
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}`);
|
2026-03-13 09:39:04 +07:00
|
|
|
|
|
2026-03-14 06:35:48 +07:00
|
|
|
|
if (isEsp32Kind(board.boardKind)) {
|
2026-04-01 06:53:56 +07:00
|
|
|
|
// 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.
|
2026-03-14 06:35:48 +07:00
|
|
|
|
const esp32Bridge = getEsp32Bridge(boardId);
|
|
|
|
|
|
if (esp32Bridge) esp32Bridge.loadFirmware(program);
|
2026-03-16 04:40:08 +07:00
|
|
|
|
} else if (isRiscVEsp32Kind(board.boardKind)) {
|
2026-04-01 06:53:56 +07:00
|
|
|
|
// Fallback: browser-only RV32IMC emulation (no WiFi/BLE support).
|
|
|
|
|
|
// Currently unreachable because isEsp32Kind() above includes C3 boards.
|
2026-03-16 04:40:08 +07:00
|
|
|
|
const sim = getBoardSimulator(boardId);
|
|
|
|
|
|
if (sim instanceof Esp32C3Simulator) {
|
|
|
|
|
|
try {
|
|
|
|
|
|
sim.loadFlashImage(program);
|
|
|
|
|
|
} catch (err) {
|
|
|
|
|
|
console.error(`[Esp32C3Simulator] loadFlashImage failed for ${boardId}:`, err);
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-03-14 06:35:48 +07:00
|
|
|
|
} else {
|
|
|
|
|
|
const sim = getBoardSimulator(boardId);
|
|
|
|
|
|
if (sim && board.boardKind !== 'raspberry-pi-3') {
|
|
|
|
|
|
try {
|
|
|
|
|
|
if (sim instanceof AVRSimulator) {
|
|
|
|
|
|
sim.loadHex(program);
|
|
|
|
|
|
sim.addI2CDevice(new VirtualDS1307());
|
|
|
|
|
|
sim.addI2CDevice(new VirtualTempSensor());
|
|
|
|
|
|
sim.addI2CDevice(new I2CMemoryDevice(0x50));
|
|
|
|
|
|
} else if (sim instanceof RP2040Simulator) {
|
|
|
|
|
|
sim.loadBinary(program);
|
|
|
|
|
|
sim.addI2CDevice(new VirtualDS1307() as RP2040I2CDevice);
|
|
|
|
|
|
sim.addI2CDevice(new VirtualTempSensor() as RP2040I2CDevice);
|
|
|
|
|
|
sim.addI2CDevice(new I2CMemoryDevice(0x50) as RP2040I2CDevice);
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch (err) {
|
|
|
|
|
|
console.error(`compileBoardProgram(${boardId}):`, err);
|
|
|
|
|
|
return;
|
2026-03-12 18:17:29 +07:00
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-03-13 09:39:04 +07:00
|
|
|
|
}
|
2026-03-12 18:17:29 +07:00
|
|
|
|
|
2026-03-13 09:39:04 +07:00
|
|
|
|
set((s) => {
|
|
|
|
|
|
const boards = s.boards.map((b) =>
|
2026-04-22 02:45:45 +07:00
|
|
|
|
b.id === boardId ? { ...b, compiledProgram: program } : b,
|
2026-03-13 09:39:04 +07:00
|
|
|
|
);
|
|
|
|
|
|
const isActive = s.activeBoardId === boardId;
|
|
|
|
|
|
return {
|
|
|
|
|
|
boards,
|
|
|
|
|
|
...(isActive ? { compiledHex: program, hexEpoch: s.hexEpoch + 1 } : {}),
|
|
|
|
|
|
};
|
|
|
|
|
|
});
|
2026-03-12 18:17:29 +07:00
|
|
|
|
},
|
|
|
|
|
|
|
2026-04-22 02:45:45 +07:00
|
|
|
|
loadMicroPythonProgram: async (
|
|
|
|
|
|
boardId: string,
|
|
|
|
|
|
files: Array<{ name: string; content: string }>,
|
|
|
|
|
|
) => {
|
2026-03-30 07:27:41 +07:00
|
|
|
|
const board = get().boards.find((b) => b.id === boardId);
|
|
|
|
|
|
if (!board) return;
|
|
|
|
|
|
if (!BOARD_SUPPORTS_MICROPYTHON.has(board.boardKind)) return;
|
|
|
|
|
|
|
2026-03-30 09:12:24 +07:00
|
|
|
|
if (isEsp32Kind(board.boardKind)) {
|
|
|
|
|
|
// ESP32 path: load MicroPython firmware via QEMU bridge, inject code via raw-paste REPL
|
2026-04-22 02:45:45 +07:00
|
|
|
|
const { getEsp32Firmware, padToFlashSize, uint8ArrayToBase64 } =
|
|
|
|
|
|
await import('../simulation/Esp32MicroPythonLoader');
|
2026-03-30 09:12:24 +07:00
|
|
|
|
const esp32Bridge = getEsp32Bridge(boardId);
|
|
|
|
|
|
if (!esp32Bridge) return;
|
2026-03-30 07:27:41 +07:00
|
|
|
|
|
2026-03-30 09:12:24 +07:00
|
|
|
|
const firmware = await getEsp32Firmware(board.boardKind);
|
2026-04-13 09:55:11 +07:00
|
|
|
|
const b64 = uint8ArrayToBase64(padToFlashSize(firmware, board.boardKind));
|
2026-03-30 09:12:24 +07:00
|
|
|
|
esp32Bridge.loadFirmware(b64);
|
|
|
|
|
|
|
2026-05-10 06:42:11 +07:00
|
|
|
|
// 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.
|
2026-04-22 02:45:45 +07:00
|
|
|
|
const mainFile = files.find((f) => f.name === 'main.py') ?? files[0];
|
2026-03-30 09:12:24 +07:00
|
|
|
|
if (mainFile) {
|
2026-05-10 06:42:11 +07:00
|
|
|
|
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);
|
2026-03-30 09:12:24 +07:00
|
|
|
|
}
|
|
|
|
|
|
} else {
|
|
|
|
|
|
// RP2040 path: load firmware + filesystem in browser
|
|
|
|
|
|
const sim = getBoardSimulator(boardId);
|
|
|
|
|
|
if (!(sim instanceof RP2040Simulator)) return;
|
|
|
|
|
|
await sim.loadMicroPython(files);
|
|
|
|
|
|
}
|
2026-03-30 07:27:41 +07:00
|
|
|
|
|
|
|
|
|
|
set((s) => {
|
|
|
|
|
|
const boards = s.boards.map((b) =>
|
2026-04-22 02:45:45 +07:00
|
|
|
|
b.id === boardId ? { ...b, compiledProgram: 'micropython-loaded' } : b,
|
2026-03-30 07:27:41 +07:00
|
|
|
|
);
|
|
|
|
|
|
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) =>
|
2026-04-22 02:45:45 +07:00
|
|
|
|
b.id === boardId ? { ...b, languageMode: mode, compiledProgram: null } : b,
|
2026-03-30 07:27:41 +07:00
|
|
|
|
),
|
|
|
|
|
|
}));
|
|
|
|
|
|
|
2026-04-13 21:16:43 +07:00
|
|
|
|
// Replace file group with appropriate default files and activate it
|
2026-03-30 07:27:41 +07:00
|
|
|
|
const editorStore = useEditorStore.getState();
|
|
|
|
|
|
editorStore.deleteFileGroup(board.activeFileGroupId);
|
|
|
|
|
|
editorStore.createFileGroup(board.activeFileGroupId, mode);
|
2026-04-13 21:16:43 +07:00
|
|
|
|
editorStore.setActiveGroup(board.activeFileGroupId);
|
2026-03-30 07:27:41 +07:00
|
|
|
|
},
|
|
|
|
|
|
|
2026-03-13 09:39:04 +07:00
|
|
|
|
startBoard: (boardId: string) => {
|
|
|
|
|
|
const board = get().boards.find((b) => b.id === boardId);
|
|
|
|
|
|
if (!board) return;
|
|
|
|
|
|
|
|
|
|
|
|
if (board.boardKind === 'raspberry-pi-3') {
|
2026-03-14 06:35:48 +07:00
|
|
|
|
getBoardBridge(boardId)?.connect();
|
|
|
|
|
|
} else if (isEsp32Kind(board.boardKind)) {
|
2026-03-23 04:03:17 +07:00
|
|
|
|
// 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) {
|
2026-04-22 02:45:45 +07:00
|
|
|
|
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;
|
2026-03-23 04:03:17 +07:00
|
|
|
|
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;
|
|
|
|
|
|
}
|
2026-03-23 04:39:09 +07:00
|
|
|
|
// 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) {
|
2026-04-22 02:45:45 +07:00
|
|
|
|
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;
|
2026-03-23 04:39:09 +07:00
|
|
|
|
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;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-03-23 04:03:17 +07:00
|
|
|
|
sensors.push(props);
|
|
|
|
|
|
break; // only one data pin per sensor
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-04-08 10:02:43 +07:00
|
|
|
|
|
|
|
|
|
|
// 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) {
|
2026-04-08 10:02:54 +07:00
|
|
|
|
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 {
|
2026-04-22 02:45:45 +07:00
|
|
|
|
const parsed =
|
|
|
|
|
|
typeof rawAddr === 'string'
|
|
|
|
|
|
? rawAddr.startsWith('0x')
|
|
|
|
|
|
? parseInt(rawAddr, 16)
|
|
|
|
|
|
: parseInt(rawAddr, 10)
|
|
|
|
|
|
: Number(rawAddr);
|
2026-04-08 10:02:54 +07:00
|
|
|
|
if (!isNaN(parsed)) addr = parsed;
|
|
|
|
|
|
}
|
2026-04-08 10:02:43 +07:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
const virtualPin = 200 + addr;
|
2026-04-22 02:45:45 +07:00
|
|
|
|
const props: Record<string, unknown> = {
|
|
|
|
|
|
sensor_type: i2cDef.sensorType,
|
|
|
|
|
|
pin: virtualPin,
|
|
|
|
|
|
addr,
|
|
|
|
|
|
};
|
|
|
|
|
|
for (const key of i2cDef.propertyKeys ?? []) {
|
2026-04-08 10:02:43 +07:00
|
|
|
|
const val = comp.properties[key];
|
|
|
|
|
|
if (val !== undefined) props[key] = typeof val === 'string' ? parseFloat(val) : val;
|
|
|
|
|
|
}
|
|
|
|
|
|
sensors.push(props);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-23 04:03:17 +07:00
|
|
|
|
esp32Bridge.setSensors(sensors);
|
2026-04-01 06:53:56 +07:00
|
|
|
|
|
2026-04-06 03:14:40 +07:00
|
|
|
|
// 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];
|
2026-04-22 02:45:45 +07:00
|
|
|
|
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('),
|
2026-04-06 03:14:40 +07:00
|
|
|
|
);
|
|
|
|
|
|
}
|
2026-04-01 06:53:56 +07:00
|
|
|
|
esp32Bridge.wifiEnabled = hasWifi;
|
|
|
|
|
|
|
2026-04-01 23:47:15 +07:00
|
|
|
|
// 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);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-23 04:03:17 +07:00
|
|
|
|
esp32Bridge.connect();
|
|
|
|
|
|
}
|
2026-03-13 09:39:04 +07:00
|
|
|
|
} else {
|
2026-03-14 06:35:48 +07:00
|
|
|
|
getBoardSimulator(boardId)?.start();
|
2026-04-29 12:33:59 +07:00
|
|
|
|
// 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();
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-03-12 18:17:29 +07:00
|
|
|
|
}
|
2026-03-13 09:39:04 +07:00
|
|
|
|
|
|
|
|
|
|
set((s) => {
|
|
|
|
|
|
const boards = s.boards.map((b) =>
|
2026-04-22 02:45:45 +07:00
|
|
|
|
b.id === boardId ? { ...b, running: true, serialMonitorOpen: true } : b,
|
2026-03-13 09:39:04 +07:00
|
|
|
|
);
|
|
|
|
|
|
const isActive = s.activeBoardId === boardId;
|
2026-03-17 08:15:52 +07:00
|
|
|
|
return { boards, ...(isActive ? { running: true, serialMonitorOpen: true } : {}) };
|
2026-03-13 09:39:04 +07:00
|
|
|
|
});
|
2026-03-12 18:17:29 +07:00
|
|
|
|
},
|
|
|
|
|
|
|
2026-03-13 09:39:04 +07:00
|
|
|
|
stopBoard: (boardId: string) => {
|
|
|
|
|
|
const board = get().boards.find((b) => b.id === boardId);
|
|
|
|
|
|
if (!board) return;
|
|
|
|
|
|
|
|
|
|
|
|
if (board.boardKind === 'raspberry-pi-3') {
|
|
|
|
|
|
getBoardBridge(boardId)?.disconnect();
|
2026-03-14 06:35:48 +07:00
|
|
|
|
} else if (isEsp32Kind(board.boardKind)) {
|
|
|
|
|
|
getEsp32Bridge(boardId)?.disconnect();
|
2026-03-13 09:39:04 +07:00
|
|
|
|
} else {
|
|
|
|
|
|
getBoardSimulator(boardId)?.stop();
|
2026-03-12 18:17:29 +07:00
|
|
|
|
}
|
2026-03-13 09:39:04 +07:00
|
|
|
|
|
|
|
|
|
|
set((s) => {
|
2026-04-22 02:45:45 +07:00
|
|
|
|
const boards = s.boards.map((b) => (b.id === boardId ? { ...b, running: false } : b));
|
2026-03-13 09:39:04 +07:00
|
|
|
|
const isActive = s.activeBoardId === boardId;
|
|
|
|
|
|
return { boards, ...(isActive ? { running: false } : {}) };
|
|
|
|
|
|
});
|
2026-03-12 18:17:29 +07:00
|
|
|
|
},
|
|
|
|
|
|
|
2026-03-13 09:39:04 +07:00
|
|
|
|
resetBoard: (boardId: string) => {
|
|
|
|
|
|
const board = get().boards.find((b) => b.id === boardId);
|
|
|
|
|
|
if (!board) return;
|
|
|
|
|
|
|
2026-03-14 08:55:37 +07:00
|
|
|
|
if (isEsp32Kind(board.boardKind)) {
|
|
|
|
|
|
// Reset ESP32: disconnect then reconnect the QEMU bridge
|
|
|
|
|
|
const esp32Bridge = getEsp32Bridge(boardId);
|
|
|
|
|
|
if (esp32Bridge?.connected) {
|
|
|
|
|
|
esp32Bridge.disconnect();
|
|
|
|
|
|
setTimeout(() => esp32Bridge.connect(), 500);
|
|
|
|
|
|
}
|
|
|
|
|
|
} else if (board.boardKind !== 'raspberry-pi-3') {
|
2026-03-13 09:39:04 +07:00
|
|
|
|
const sim = getBoardSimulator(boardId);
|
|
|
|
|
|
if (sim) {
|
|
|
|
|
|
sim.reset();
|
|
|
|
|
|
// Re-wire serial callback after reset
|
2026-04-21 09:56:42 +07:00
|
|
|
|
sim.onSerialData = (ch) => appendSerial(boardId, ch);
|
2026-03-13 09:39:04 +07:00
|
|
|
|
if (sim instanceof AVRSimulator) {
|
|
|
|
|
|
sim.onBaudRateChange = (baud) => {
|
|
|
|
|
|
set((s) => {
|
|
|
|
|
|
const boards = s.boards.map((b) =>
|
2026-04-22 02:45:45 +07:00
|
|
|
|
b.id === boardId ? { ...b, serialBaudRate: baud } : b,
|
2026-03-13 09:39:04 +07:00
|
|
|
|
);
|
|
|
|
|
|
const isActive = s.activeBoardId === boardId;
|
|
|
|
|
|
return { boards, ...(isActive ? { serialBaudRate: baud } : {}) };
|
|
|
|
|
|
});
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
set((s) => {
|
|
|
|
|
|
const boards = s.boards.map((b) =>
|
2026-04-22 02:45:45 +07:00
|
|
|
|
b.id === boardId ? { ...b, running: false, serialOutput: '', serialBaudRate: 0 } : b,
|
2026-03-13 09:39:04 +07:00
|
|
|
|
);
|
|
|
|
|
|
const isActive = s.activeBoardId === boardId;
|
2026-04-22 02:45:45 +07:00
|
|
|
|
return {
|
|
|
|
|
|
boards,
|
|
|
|
|
|
...(isActive ? { running: false, serialOutput: '', serialBaudRate: 0 } : {}),
|
|
|
|
|
|
};
|
2026-03-13 09:39:04 +07:00
|
|
|
|
});
|
2026-03-08 05:29:23 +07:00
|
|
|
|
},
|
|
|
|
|
|
|
2026-03-13 09:39:04 +07:00
|
|
|
|
// ── 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,
|
|
|
|
|
|
|
2026-03-14 22:05:35 +07:00
|
|
|
|
esp32CrashBoardId: null,
|
|
|
|
|
|
dismissEsp32Crash: () => set({ esp32CrashBoardId: null }),
|
|
|
|
|
|
|
2026-03-05 05:28:33 +07:00
|
|
|
|
setBoardType: (type: BoardType) => {
|
2026-03-13 09:39:04 +07:00
|
|
|
|
const { activeBoardId, running, stopSimulation } = get();
|
|
|
|
|
|
if (running) stopSimulation();
|
|
|
|
|
|
|
|
|
|
|
|
const boardId = activeBoardId ?? INITIAL_BOARD_ID;
|
|
|
|
|
|
const pm = getBoardPinManager(boardId) ?? legacyPinManager;
|
|
|
|
|
|
|
2026-03-15 02:57:22 +07:00
|
|
|
|
// Stop and remove old simulator / bridge
|
2026-03-13 09:39:04 +07:00
|
|
|
|
getBoardSimulator(boardId)?.stop();
|
|
|
|
|
|
simulatorMap.delete(boardId);
|
2026-03-15 02:57:22 +07:00
|
|
|
|
getEsp32Bridge(boardId)?.disconnect();
|
|
|
|
|
|
esp32BridgeMap.delete(boardId);
|
2026-03-13 09:39:04 +07:00
|
|
|
|
|
2026-04-21 09:56:42 +07:00
|
|
|
|
const serialCallback = (ch: string) => appendSerial(boardId, ch);
|
2026-03-13 09:39:04 +07:00
|
|
|
|
|
2026-03-15 02:57:22 +07:00
|
|
|
|
if (isEsp32Kind(type as BoardKind)) {
|
|
|
|
|
|
// ESP32: use bridge, not AVR simulator
|
|
|
|
|
|
const bridge = new Esp32Bridge(boardId, type as BoardKind);
|
|
|
|
|
|
bridge.onSerialData = serialCallback;
|
|
|
|
|
|
bridge.onPinChange = (gpioPin, state) => {
|
|
|
|
|
|
const boardPm = pinManagerMap.get(boardId);
|
|
|
|
|
|
if (boardPm) boardPm.triggerPinChange(gpioPin, state);
|
|
|
|
|
|
};
|
2026-04-22 02:45:45 +07:00
|
|
|
|
bridge.onCrash = () => {
|
|
|
|
|
|
set({ esp32CrashBoardId: boardId });
|
|
|
|
|
|
};
|
2026-03-17 12:28:08 +07:00
|
|
|
|
bridge.onDisconnected = () => {
|
|
|
|
|
|
set((s) => {
|
2026-04-22 02:45:45 +07:00
|
|
|
|
const boards = s.boards.map((b) => (b.id === boardId ? { ...b, running: false } : b));
|
2026-03-17 12:28:08 +07:00
|
|
|
|
const isActive = s.activeBoardId === boardId;
|
|
|
|
|
|
return { boards, ...(isActive ? { running: false } : {}) };
|
|
|
|
|
|
});
|
|
|
|
|
|
};
|
2026-03-24 23:54:44 +07:00
|
|
|
|
bridge.onLedcUpdate = makeLedcUpdateHandler(boardId);
|
2026-03-15 02:57:22 +07:00
|
|
|
|
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);
|
2026-03-22 12:25:58 +07:00
|
|
|
|
const shim = new Esp32BridgeShim(bridge, pm);
|
|
|
|
|
|
shim.onSerialData = serialCallback;
|
|
|
|
|
|
simulatorMap.set(boardId, shim);
|
2026-03-15 02:57:22 +07:00
|
|
|
|
|
|
|
|
|
|
set((s) => ({
|
|
|
|
|
|
boardType: type,
|
2026-03-22 12:25:58 +07:00
|
|
|
|
simulator: shim as any,
|
2026-03-15 02:57:22 +07:00
|
|
|
|
compiledHex: null,
|
|
|
|
|
|
serialOutput: '',
|
|
|
|
|
|
serialBaudRate: 0,
|
|
|
|
|
|
boards: s.boards.map((b) =>
|
|
|
|
|
|
b.id === boardId
|
2026-04-22 02:45:45 +07:00
|
|
|
|
? {
|
|
|
|
|
|
...b,
|
|
|
|
|
|
boardKind: type as BoardKind,
|
|
|
|
|
|
compiledProgram: null,
|
|
|
|
|
|
serialOutput: '',
|
|
|
|
|
|
serialBaudRate: 0,
|
|
|
|
|
|
}
|
|
|
|
|
|
: b,
|
2026-03-15 02:57:22 +07:00
|
|
|
|
),
|
|
|
|
|
|
}));
|
|
|
|
|
|
} else {
|
|
|
|
|
|
const sim = createSimulator(
|
|
|
|
|
|
type as BoardKind,
|
|
|
|
|
|
pm,
|
|
|
|
|
|
serialCallback,
|
2026-04-22 02:45:45 +07:00
|
|
|
|
(baud) =>
|
|
|
|
|
|
set((s) => {
|
|
|
|
|
|
const boards = s.boards.map((b) =>
|
|
|
|
|
|
b.id === boardId ? { ...b, serialBaudRate: baud } : b,
|
|
|
|
|
|
);
|
|
|
|
|
|
return { boards, serialBaudRate: baud };
|
|
|
|
|
|
}),
|
2026-04-30 12:28:17 +07:00
|
|
|
|
getOscilloscopeCallback(boardId),
|
2026-03-15 02:57:22 +07:00
|
|
|
|
);
|
|
|
|
|
|
simulatorMap.set(boardId, sim);
|
|
|
|
|
|
|
|
|
|
|
|
set((s) => ({
|
|
|
|
|
|
boardType: type,
|
|
|
|
|
|
simulator: sim,
|
|
|
|
|
|
compiledHex: null,
|
|
|
|
|
|
serialOutput: '',
|
|
|
|
|
|
serialBaudRate: 0,
|
|
|
|
|
|
boards: s.boards.map((b) =>
|
|
|
|
|
|
b.id === boardId
|
2026-04-22 02:45:45 +07:00
|
|
|
|
? {
|
|
|
|
|
|
...b,
|
|
|
|
|
|
boardKind: type as BoardKind,
|
|
|
|
|
|
compiledProgram: null,
|
|
|
|
|
|
serialOutput: '',
|
|
|
|
|
|
serialBaudRate: 0,
|
|
|
|
|
|
}
|
|
|
|
|
|
: b,
|
2026-03-15 02:57:22 +07:00
|
|
|
|
),
|
|
|
|
|
|
}));
|
|
|
|
|
|
}
|
2026-03-05 05:28:33 +07:00
|
|
|
|
console.log(`Board switched to: ${type}`);
|
|
|
|
|
|
},
|
|
|
|
|
|
|
2026-03-03 10:20:49 +07:00
|
|
|
|
initSimulator: () => {
|
2026-03-13 09:39:04 +07:00
|
|
|
|
const { boardType, activeBoardId } = get();
|
|
|
|
|
|
const boardId = activeBoardId ?? INITIAL_BOARD_ID;
|
|
|
|
|
|
const pm = getBoardPinManager(boardId) ?? legacyPinManager;
|
|
|
|
|
|
|
|
|
|
|
|
getBoardSimulator(boardId)?.stop();
|
|
|
|
|
|
simulatorMap.delete(boardId);
|
2026-03-23 01:17:44 +07:00
|
|
|
|
getEsp32Bridge(boardId)?.disconnect();
|
|
|
|
|
|
esp32BridgeMap.delete(boardId);
|
2026-03-13 09:39:04 +07:00
|
|
|
|
|
2026-04-21 09:56:42 +07:00
|
|
|
|
const serialCallback = (ch: string) => appendSerial(boardId, ch);
|
2026-03-23 01:17:44 +07:00
|
|
|
|
|
|
|
|
|
|
if (isEsp32Kind(boardType as BoardKind)) {
|
|
|
|
|
|
// ESP32: create bridge + shim (same as setBoardType)
|
|
|
|
|
|
const bridge = new Esp32Bridge(boardId, boardType as BoardKind);
|
|
|
|
|
|
bridge.onSerialData = serialCallback;
|
|
|
|
|
|
bridge.onPinChange = (gpioPin, state) => {
|
|
|
|
|
|
const boardPm = pinManagerMap.get(boardId);
|
|
|
|
|
|
if (boardPm) boardPm.triggerPinChange(gpioPin, state);
|
|
|
|
|
|
};
|
2026-04-22 02:45:45 +07:00
|
|
|
|
bridge.onCrash = () => {
|
|
|
|
|
|
set({ esp32CrashBoardId: boardId });
|
|
|
|
|
|
};
|
2026-03-23 01:17:44 +07:00
|
|
|
|
bridge.onDisconnected = () => {
|
|
|
|
|
|
set((s) => {
|
2026-04-22 02:45:45 +07:00
|
|
|
|
const boards = s.boards.map((b) => (b.id === boardId ? { ...b, running: false } : b));
|
2026-03-23 01:17:44 +07:00
|
|
|
|
const isActive = s.activeBoardId === boardId;
|
|
|
|
|
|
return { boards, ...(isActive ? { running: false } : {}) };
|
|
|
|
|
|
});
|
|
|
|
|
|
};
|
2026-03-24 23:54:44 +07:00
|
|
|
|
bridge.onLedcUpdate = makeLedcUpdateHandler(boardId);
|
2026-03-25 00:38:08 +07:00
|
|
|
|
bridge.onWs2812Update = (channel, pixels) => {
|
|
|
|
|
|
const eventTarget = document.getElementById(`ws2812-${boardId}-${channel}`);
|
|
|
|
|
|
if (eventTarget) {
|
|
|
|
|
|
eventTarget.dispatchEvent(new CustomEvent('ws2812-pixels', { detail: { pixels } }));
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
2026-03-23 01:17:44 +07:00
|
|
|
|
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,
|
2026-04-22 02:45:45 +07:00
|
|
|
|
(baud) =>
|
|
|
|
|
|
set((s) => {
|
|
|
|
|
|
const boards = s.boards.map((b) =>
|
|
|
|
|
|
b.id === boardId ? { ...b, serialBaudRate: baud } : b,
|
|
|
|
|
|
);
|
|
|
|
|
|
return { boards, serialBaudRate: baud };
|
|
|
|
|
|
}),
|
2026-04-30 12:28:17 +07:00
|
|
|
|
getOscilloscopeCallback(boardId),
|
2026-03-23 01:17:44 +07:00
|
|
|
|
);
|
|
|
|
|
|
simulatorMap.set(boardId, sim);
|
|
|
|
|
|
set({ simulator: sim, serialOutput: '', serialBaudRate: 0 });
|
|
|
|
|
|
}
|
2026-03-05 05:28:33 +07:00
|
|
|
|
console.log(`Simulator initialized: ${boardType}`);
|
2026-03-03 10:20:49 +07:00
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
|
|
loadHex: (hex: string) => {
|
2026-03-13 09:39:04 +07:00
|
|
|
|
const { activeBoardId } = get();
|
|
|
|
|
|
const boardId = activeBoardId ?? INITIAL_BOARD_ID;
|
|
|
|
|
|
const sim = getBoardSimulator(boardId);
|
|
|
|
|
|
if (sim && sim instanceof AVRSimulator) {
|
2026-03-03 10:20:49 +07:00
|
|
|
|
try {
|
2026-03-13 09:39:04 +07:00
|
|
|
|
sim.loadHex(hex);
|
|
|
|
|
|
sim.addI2CDevice(new VirtualDS1307());
|
|
|
|
|
|
sim.addI2CDevice(new VirtualTempSensor());
|
|
|
|
|
|
sim.addI2CDevice(new I2CMemoryDevice(0x50));
|
2026-03-09 12:31:04 +07:00
|
|
|
|
set((s) => ({ compiledHex: hex, hexEpoch: s.hexEpoch + 1 }));
|
2026-03-03 10:20:49 +07:00
|
|
|
|
console.log('HEX file loaded successfully');
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
console.error('Failed to load HEX:', error);
|
|
|
|
|
|
}
|
|
|
|
|
|
} else {
|
2026-03-05 05:28:33 +07:00
|
|
|
|
console.warn('loadHex: simulator not initialized or wrong board type');
|
|
|
|
|
|
}
|
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
|
|
loadBinary: (base64: string) => {
|
2026-03-13 09:39:04 +07:00
|
|
|
|
const { activeBoardId } = get();
|
|
|
|
|
|
const boardId = activeBoardId ?? INITIAL_BOARD_ID;
|
|
|
|
|
|
const sim = getBoardSimulator(boardId);
|
|
|
|
|
|
if (sim && sim instanceof RP2040Simulator) {
|
2026-03-05 05:28:33 +07:00
|
|
|
|
try {
|
2026-03-13 09:39:04 +07:00
|
|
|
|
sim.loadBinary(base64);
|
|
|
|
|
|
sim.addI2CDevice(new VirtualDS1307() as RP2040I2CDevice);
|
|
|
|
|
|
sim.addI2CDevice(new VirtualTempSensor() as RP2040I2CDevice);
|
|
|
|
|
|
sim.addI2CDevice(new I2CMemoryDevice(0x50) as RP2040I2CDevice);
|
2026-03-09 12:31:04 +07:00
|
|
|
|
set((s) => ({ compiledHex: base64, hexEpoch: s.hexEpoch + 1 }));
|
2026-03-05 05:28:33 +07:00
|
|
|
|
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');
|
2026-03-03 10:20:49 +07:00
|
|
|
|
}
|
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
|
|
startSimulation: () => {
|
2026-03-13 09:39:04 +07:00
|
|
|
|
const { activeBoardId } = get();
|
|
|
|
|
|
const boardId = activeBoardId ?? INITIAL_BOARD_ID;
|
|
|
|
|
|
get().startBoard(boardId);
|
2026-03-03 10:20:49 +07:00
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
|
|
stopSimulation: () => {
|
2026-03-13 09:39:04 +07:00
|
|
|
|
const { activeBoardId } = get();
|
|
|
|
|
|
const boardId = activeBoardId ?? INITIAL_BOARD_ID;
|
|
|
|
|
|
get().stopBoard(boardId);
|
2026-03-03 10:20:49 +07:00
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
|
|
resetSimulation: () => {
|
2026-03-13 09:39:04 +07:00
|
|
|
|
const { activeBoardId } = get();
|
|
|
|
|
|
const boardId = activeBoardId ?? INITIAL_BOARD_ID;
|
|
|
|
|
|
get().resetBoard(boardId);
|
2026-03-03 10:20:49 +07:00
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
|
|
setCompiledHex: (hex: string) => {
|
|
|
|
|
|
set({ compiledHex: hex });
|
|
|
|
|
|
get().loadHex(hex);
|
|
|
|
|
|
},
|
|
|
|
|
|
|
2026-03-05 05:28:33 +07:00
|
|
|
|
setCompiledBinary: (base64: string) => {
|
2026-03-13 09:39:04 +07:00
|
|
|
|
set({ compiledHex: base64 });
|
2026-03-05 05:28:33 +07:00
|
|
|
|
get().loadBinary(base64);
|
|
|
|
|
|
},
|
|
|
|
|
|
|
2026-03-03 10:20:49 +07:00
|
|
|
|
setRunning: (running: boolean) => set({ running }),
|
|
|
|
|
|
|
2026-03-13 09:39:04 +07:00
|
|
|
|
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);
|
2026-04-21 09:56:42 +07:00
|
|
|
|
bridge.onSerialData = (ch) => appendSerial(boardId, ch);
|
2026-03-13 09:39:04 +07:00
|
|
|
|
bridge.onPinChange = (gpioPin, state) => {
|
|
|
|
|
|
const { wires } = get();
|
|
|
|
|
|
const sim = getBoardSimulator(get().activeBoardId ?? INITIAL_BOARD_ID);
|
|
|
|
|
|
if (!sim) return;
|
2026-04-22 02:45:45 +07:00
|
|
|
|
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)),
|
2026-03-13 09:39:04 +07:00
|
|
|
|
);
|
|
|
|
|
|
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 });
|
2026-03-03 10:20:49 +07:00
|
|
|
|
},
|
|
|
|
|
|
|
2026-03-13 09:39:04 +07:00
|
|
|
|
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);
|
2026-03-03 10:20:49 +07:00
|
|
|
|
},
|
|
|
|
|
|
|
2026-03-13 09:39:04 +07:00
|
|
|
|
// ── Components ────────────────────────────────────────────────────────
|
|
|
|
|
|
components: [
|
|
|
|
|
|
{
|
|
|
|
|
|
id: 'led-builtin',
|
|
|
|
|
|
metadataId: 'led',
|
|
|
|
|
|
x: 350,
|
|
|
|
|
|
y: 100,
|
2026-04-07 23:27:54 +07:00
|
|
|
|
properties: { color: 'red' },
|
2026-03-13 09:39:04 +07:00
|
|
|
|
},
|
|
|
|
|
|
],
|
|
|
|
|
|
|
|
|
|
|
|
wires: [
|
|
|
|
|
|
{
|
2026-04-07 23:27:54 +07:00
|
|
|
|
id: 'wire-builtin-anode',
|
|
|
|
|
|
start: { componentId: 'arduino-uno', pinName: '13', x: 0, y: 0 },
|
2026-03-13 09:39:04 +07:00
|
|
|
|
end: { componentId: 'led-builtin', pinName: 'A', x: 0, y: 0 },
|
2026-03-15 08:55:13 +07:00
|
|
|
|
waypoints: [],
|
2026-04-07 23:27:54 +07:00
|
|
|
|
color: '#22c55e',
|
2026-03-13 09:39:04 +07:00
|
|
|
|
},
|
|
|
|
|
|
{
|
2026-04-07 23:27:54 +07:00
|
|
|
|
id: 'wire-builtin-cathode',
|
|
|
|
|
|
start: { componentId: 'arduino-uno', pinName: 'GND.1', x: 0, y: 0 },
|
2026-03-13 09:39:04 +07:00
|
|
|
|
end: { componentId: 'led-builtin', pinName: 'C', x: 0, y: 0 },
|
2026-03-15 08:55:13 +07:00
|
|
|
|
waypoints: [],
|
2026-04-07 23:27:54 +07:00
|
|
|
|
color: '#000000',
|
2026-03-13 09:39:04 +07:00
|
|
|
|
},
|
|
|
|
|
|
],
|
|
|
|
|
|
selectedWireId: null,
|
|
|
|
|
|
wireInProgress: null,
|
|
|
|
|
|
|
|
|
|
|
|
addComponent: (component) => set((state) => ({ components: [...state.components, component] })),
|
|
|
|
|
|
|
2026-04-22 02:45:45 +07:00
|
|
|
|
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),
|
|
|
|
|
|
})),
|
2026-03-13 09:39:04 +07:00
|
|
|
|
|
2026-03-03 10:20:49 +07:00
|
|
|
|
updateComponent: (id, updates) => {
|
|
|
|
|
|
set((state) => ({
|
2026-04-22 02:45:45 +07:00
|
|
|
|
components: state.components.map((c) => (c.id === id ? { ...c, ...updates } : c)),
|
2026-03-03 10:20:49 +07:00
|
|
|
|
}));
|
Add backend and frontend test suites for Arduino compilation and simulation
- Implemented a comprehensive backend test suite in `test_compilation.py` to validate the Arduino CLI installation, AVR core presence, compilation service, and API endpoint functionality.
- Created a frontend test suite in `simulation.test.ts` to test the `PinManager` and `AVRSimulator` components, ensuring proper functionality and integration.
- Introduced new components for wire management in the simulator, including `PinOverlay`, `WireInProgressRenderer`, `WireLayer`, and `WireRenderer`, enhancing the visual wiring system.
- Developed utility functions for pin position calculations and wire color management, ensuring accurate connections and visual representation.
- Added types for wire management in `wire.ts`, defining structures for wire endpoints, control points, and signal types.
2026-03-04 02:44:07 +07:00
|
|
|
|
if (updates.x !== undefined || updates.y !== undefined) {
|
|
|
|
|
|
get().updateWirePositions(id);
|
|
|
|
|
|
}
|
2026-03-03 10:20:49 +07:00
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
|
|
updateComponentState: (id, state) => {
|
|
|
|
|
|
set((prevState) => ({
|
|
|
|
|
|
components: prevState.components.map((c) =>
|
2026-04-22 02:45:45 +07:00
|
|
|
|
c.id === id ? { ...c, properties: { ...c.properties, state, value: state } } : c,
|
2026-03-03 10:20:49 +07:00
|
|
|
|
),
|
|
|
|
|
|
}));
|
|
|
|
|
|
},
|
Add backend and frontend test suites for Arduino compilation and simulation
- Implemented a comprehensive backend test suite in `test_compilation.py` to validate the Arduino CLI installation, AVR core presence, compilation service, and API endpoint functionality.
- Created a frontend test suite in `simulation.test.ts` to test the `PinManager` and `AVRSimulator` components, ensuring proper functionality and integration.
- Introduced new components for wire management in the simulator, including `PinOverlay`, `WireInProgressRenderer`, `WireLayer`, and `WireRenderer`, enhancing the visual wiring system.
- Developed utility functions for pin position calculations and wire color management, ensuring accurate connections and visual representation.
- Added types for wire management in `wire.ts`, defining structures for wire endpoints, control points, and signal types.
2026-03-04 02:44:07 +07:00
|
|
|
|
|
2026-03-13 09:39:04 +07:00
|
|
|
|
handleComponentEvent: (_componentId, _eventName, _data) => {},
|
2026-03-04 23:36:33 +07:00
|
|
|
|
|
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 });
|
|
|
|
|
|
},
|
2026-03-04 08:25:52 +07:00
|
|
|
|
|
2026-03-13 09:39:04 +07:00
|
|
|
|
addWire: (wire) => set((state) => ({ wires: [...state.wires, wire] })),
|
Add backend and frontend test suites for Arduino compilation and simulation
- Implemented a comprehensive backend test suite in `test_compilation.py` to validate the Arduino CLI installation, AVR core presence, compilation service, and API endpoint functionality.
- Created a frontend test suite in `simulation.test.ts` to test the `PinManager` and `AVRSimulator` components, ensuring proper functionality and integration.
- Introduced new components for wire management in the simulator, including `PinOverlay`, `WireInProgressRenderer`, `WireLayer`, and `WireRenderer`, enhancing the visual wiring system.
- Developed utility functions for pin position calculations and wire color management, ensuring accurate connections and visual representation.
- Added types for wire management in `wire.ts`, defining structures for wire endpoints, control points, and signal types.
2026-03-04 02:44:07 +07:00
|
|
|
|
|
2026-04-22 02:45:45 +07:00
|
|
|
|
removeWire: (wireId) =>
|
|
|
|
|
|
set((state) => ({
|
|
|
|
|
|
wires: state.wires.filter((w) => w.id !== wireId),
|
|
|
|
|
|
selectedWireId: state.selectedWireId === wireId ? null : state.selectedWireId,
|
|
|
|
|
|
})),
|
Add backend and frontend test suites for Arduino compilation and simulation
- Implemented a comprehensive backend test suite in `test_compilation.py` to validate the Arduino CLI installation, AVR core presence, compilation service, and API endpoint functionality.
- Created a frontend test suite in `simulation.test.ts` to test the `PinManager` and `AVRSimulator` components, ensuring proper functionality and integration.
- Introduced new components for wire management in the simulator, including `PinOverlay`, `WireInProgressRenderer`, `WireLayer`, and `WireRenderer`, enhancing the visual wiring system.
- Developed utility functions for pin position calculations and wire color management, ensuring accurate connections and visual representation.
- Added types for wire management in `wire.ts`, defining structures for wire endpoints, control points, and signal types.
2026-03-04 02:44:07 +07:00
|
|
|
|
|
2026-04-22 02:45:45 +07:00
|
|
|
|
updateWire: (wireId, updates) =>
|
|
|
|
|
|
set((state) => ({
|
|
|
|
|
|
wires: state.wires.map((w) => (w.id === wireId ? { ...w, ...updates } : w)),
|
|
|
|
|
|
})),
|
Add backend and frontend test suites for Arduino compilation and simulation
- Implemented a comprehensive backend test suite in `test_compilation.py` to validate the Arduino CLI installation, AVR core presence, compilation service, and API endpoint functionality.
- Created a frontend test suite in `simulation.test.ts` to test the `PinManager` and `AVRSimulator` components, ensuring proper functionality and integration.
- Introduced new components for wire management in the simulator, including `PinOverlay`, `WireInProgressRenderer`, `WireLayer`, and `WireRenderer`, enhancing the visual wiring system.
- Developed utility functions for pin position calculations and wire color management, ensuring accurate connections and visual representation.
- Added types for wire management in `wire.ts`, defining structures for wire endpoints, control points, and signal types.
2026-03-04 02:44:07 +07:00
|
|
|
|
|
2026-03-13 09:39:04 +07:00
|
|
|
|
setSelectedWire: (wireId) => set({ selectedWireId: wireId }),
|
Add backend and frontend test suites for Arduino compilation and simulation
- Implemented a comprehensive backend test suite in `test_compilation.py` to validate the Arduino CLI installation, AVR core presence, compilation service, and API endpoint functionality.
- Created a frontend test suite in `simulation.test.ts` to test the `PinManager` and `AVRSimulator` components, ensuring proper functionality and integration.
- Introduced new components for wire management in the simulator, including `PinOverlay`, `WireInProgressRenderer`, `WireLayer`, and `WireRenderer`, enhancing the visual wiring system.
- Developed utility functions for pin position calculations and wire color management, ensuring accurate connections and visual representation.
- Added types for wire management in `wire.ts`, defining structures for wire endpoints, control points, and signal types.
2026-03-04 02:44:07 +07:00
|
|
|
|
|
2026-04-22 02:45:45 +07:00
|
|
|
|
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,
|
2026-04-22 02:45:45 +07:00
|
|
|
|
}),
|
Add backend and frontend test suites for Arduino compilation and simulation
- Implemented a comprehensive backend test suite in `test_compilation.py` to validate the Arduino CLI installation, AVR core presence, compilation service, and API endpoint functionality.
- Created a frontend test suite in `simulation.test.ts` to test the `PinManager` and `AVRSimulator` components, ensuring proper functionality and integration.
- Introduced new components for wire management in the simulator, including `PinOverlay`, `WireInProgressRenderer`, `WireLayer`, and `WireRenderer`, enhancing the visual wiring system.
- Developed utility functions for pin position calculations and wire color management, ensuring accurate connections and visual representation.
- Added types for wire management in `wire.ts`, defining structures for wire endpoints, control points, and signal types.
2026-03-04 02:44:07 +07:00
|
|
|
|
|
2026-04-22 02:45:45 +07:00
|
|
|
|
startWireCreation: (endpoint, color) =>
|
|
|
|
|
|
set({
|
2026-03-15 08:55:13 +07:00
|
|
|
|
wireInProgress: {
|
2026-04-22 02:45:45 +07:00
|
|
|
|
startEndpoint: endpoint,
|
|
|
|
|
|
waypoints: [],
|
|
|
|
|
|
color,
|
|
|
|
|
|
currentX: endpoint.x,
|
|
|
|
|
|
currentY: endpoint.y,
|
2026-03-15 08:55:13 +07:00
|
|
|
|
},
|
2026-04-22 02:45:45 +07:00
|
|
|
|
}),
|
2026-03-15 08:55:13 +07:00
|
|
|
|
|
2026-04-22 02:45:45 +07:00
|
|
|
|
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 } };
|
|
|
|
|
|
}),
|
2026-03-15 08:55:13 +07:00
|
|
|
|
|
Add backend and frontend test suites for Arduino compilation and simulation
- Implemented a comprehensive backend test suite in `test_compilation.py` to validate the Arduino CLI installation, AVR core presence, compilation service, and API endpoint functionality.
- Created a frontend test suite in `simulation.test.ts` to test the `PinManager` and `AVRSimulator` components, ensuring proper functionality and integration.
- Introduced new components for wire management in the simulator, including `PinOverlay`, `WireInProgressRenderer`, `WireLayer`, and `WireRenderer`, enhancing the visual wiring system.
- Developed utility functions for pin position calculations and wire color management, ensuring accurate connections and visual representation.
- Added types for wire management in `wire.ts`, defining structures for wire endpoints, control points, and signal types.
2026-03-04 02:44:07 +07:00
|
|
|
|
finishWireCreation: (endpoint) => {
|
|
|
|
|
|
const state = get();
|
|
|
|
|
|
if (!state.wireInProgress) return;
|
2026-03-15 08:55:13 +07:00
|
|
|
|
const { startEndpoint, waypoints, color } = state.wireInProgress;
|
Add backend and frontend test suites for Arduino compilation and simulation
- Implemented a comprehensive backend test suite in `test_compilation.py` to validate the Arduino CLI installation, AVR core presence, compilation service, and API endpoint functionality.
- Created a frontend test suite in `simulation.test.ts` to test the `PinManager` and `AVRSimulator` components, ensuring proper functionality and integration.
- Introduced new components for wire management in the simulator, including `PinOverlay`, `WireInProgressRenderer`, `WireLayer`, and `WireRenderer`, enhancing the visual wiring system.
- Developed utility functions for pin position calculations and wire color management, ensuring accurate connections and visual representation.
- Added types for wire management in `wire.ts`, defining structures for wire endpoints, control points, and signal types.
2026-03-04 02:44:07 +07:00
|
|
|
|
const newWire: Wire = {
|
|
|
|
|
|
id: `wire-${Date.now()}`,
|
|
|
|
|
|
start: startEndpoint,
|
|
|
|
|
|
end: endpoint,
|
2026-03-15 08:55:13 +07:00
|
|
|
|
waypoints,
|
|
|
|
|
|
color,
|
Add backend and frontend test suites for Arduino compilation and simulation
- Implemented a comprehensive backend test suite in `test_compilation.py` to validate the Arduino CLI installation, AVR core presence, compilation service, and API endpoint functionality.
- Created a frontend test suite in `simulation.test.ts` to test the `PinManager` and `AVRSimulator` components, ensuring proper functionality and integration.
- Introduced new components for wire management in the simulator, including `PinOverlay`, `WireInProgressRenderer`, `WireLayer`, and `WireRenderer`, enhancing the visual wiring system.
- Developed utility functions for pin position calculations and wire color management, ensuring accurate connections and visual representation.
- Added types for wire management in `wire.ts`, defining structures for wire endpoints, control points, and signal types.
2026-03-04 02:44:07 +07:00
|
|
|
|
};
|
2026-03-13 09:39:04 +07:00
|
|
|
|
set((state) => ({ wires: [...state.wires, newWire], wireInProgress: null }));
|
Add backend and frontend test suites for Arduino compilation and simulation
- Implemented a comprehensive backend test suite in `test_compilation.py` to validate the Arduino CLI installation, AVR core presence, compilation service, and API endpoint functionality.
- Created a frontend test suite in `simulation.test.ts` to test the `PinManager` and `AVRSimulator` components, ensuring proper functionality and integration.
- Introduced new components for wire management in the simulator, including `PinOverlay`, `WireInProgressRenderer`, `WireLayer`, and `WireRenderer`, enhancing the visual wiring system.
- Developed utility functions for pin position calculations and wire color management, ensuring accurate connections and visual representation.
- Added types for wire management in `wire.ts`, defining structures for wire endpoints, control points, and signal types.
2026-03-04 02:44:07 +07:00
|
|
|
|
},
|
|
|
|
|
|
|
2026-03-13 09:39:04 +07:00
|
|
|
|
cancelWireCreation: () => set({ wireInProgress: null }),
|
Add backend and frontend test suites for Arduino compilation and simulation
- Implemented a comprehensive backend test suite in `test_compilation.py` to validate the Arduino CLI installation, AVR core presence, compilation service, and API endpoint functionality.
- Created a frontend test suite in `simulation.test.ts` to test the `PinManager` and `AVRSimulator` components, ensuring proper functionality and integration.
- Introduced new components for wire management in the simulator, including `PinOverlay`, `WireInProgressRenderer`, `WireLayer`, and `WireRenderer`, enhancing the visual wiring system.
- Developed utility functions for pin position calculations and wire color management, ensuring accurate connections and visual representation.
- Added types for wire management in `wire.ts`, defining structures for wire endpoints, control points, and signal types.
2026-03-04 02:44:07 +07:00
|
|
|
|
|
|
|
|
|
|
updateWirePositions: (componentId) => {
|
|
|
|
|
|
set((state) => {
|
|
|
|
|
|
const component = state.components.find((c) => c.id === componentId);
|
2026-03-13 09:39:04 +07:00
|
|
|
|
// Check if this componentId matches a board id
|
|
|
|
|
|
const board = state.boards.find((b) => b.id === componentId);
|
2026-03-16 20:31:02 +07:00
|
|
|
|
// Components have a DynamicComponent wrapper with border:2px + padding:4px → offset (4,6)
|
|
|
|
|
|
// Boards are rendered directly without a wrapper, so no offset.
|
2026-04-22 02:45:45 +07:00
|
|
|
|
const compX = component ? component.x + 4 : board ? board.x : state.boardPosition.x;
|
|
|
|
|
|
const compY = component ? component.y + 6 : board ? board.y : state.boardPosition.y;
|
Add backend and frontend test suites for Arduino compilation and simulation
- Implemented a comprehensive backend test suite in `test_compilation.py` to validate the Arduino CLI installation, AVR core presence, compilation service, and API endpoint functionality.
- Created a frontend test suite in `simulation.test.ts` to test the `PinManager` and `AVRSimulator` components, ensuring proper functionality and integration.
- Introduced new components for wire management in the simulator, including `PinOverlay`, `WireInProgressRenderer`, `WireLayer`, and `WireRenderer`, enhancing the visual wiring system.
- Developed utility functions for pin position calculations and wire color management, ensuring accurate connections and visual representation.
- Added types for wire management in `wire.ts`, defining structures for wire endpoints, control points, and signal types.
2026-03-04 02:44:07 +07:00
|
|
|
|
|
|
|
|
|
|
const updatedWires = state.wires.map((wire) => {
|
|
|
|
|
|
const updated = { ...wire };
|
|
|
|
|
|
if (wire.start.componentId === componentId) {
|
2026-03-13 09:39:04 +07:00
|
|
|
|
const pos = calculatePinPosition(componentId, wire.start.pinName, compX, compY);
|
|
|
|
|
|
if (pos) updated.start = { ...wire.start, x: pos.x, y: pos.y };
|
Add backend and frontend test suites for Arduino compilation and simulation
- Implemented a comprehensive backend test suite in `test_compilation.py` to validate the Arduino CLI installation, AVR core presence, compilation service, and API endpoint functionality.
- Created a frontend test suite in `simulation.test.ts` to test the `PinManager` and `AVRSimulator` components, ensuring proper functionality and integration.
- Introduced new components for wire management in the simulator, including `PinOverlay`, `WireInProgressRenderer`, `WireLayer`, and `WireRenderer`, enhancing the visual wiring system.
- Developed utility functions for pin position calculations and wire color management, ensuring accurate connections and visual representation.
- Added types for wire management in `wire.ts`, defining structures for wire endpoints, control points, and signal types.
2026-03-04 02:44:07 +07:00
|
|
|
|
}
|
|
|
|
|
|
if (wire.end.componentId === componentId) {
|
2026-03-13 09:39:04 +07:00
|
|
|
|
const pos = calculatePinPosition(componentId, wire.end.pinName, compX, compY);
|
|
|
|
|
|
if (pos) updated.end = { ...wire.end, x: pos.x, y: pos.y };
|
Add backend and frontend test suites for Arduino compilation and simulation
- Implemented a comprehensive backend test suite in `test_compilation.py` to validate the Arduino CLI installation, AVR core presence, compilation service, and API endpoint functionality.
- Created a frontend test suite in `simulation.test.ts` to test the `PinManager` and `AVRSimulator` components, ensuring proper functionality and integration.
- Introduced new components for wire management in the simulator, including `PinOverlay`, `WireInProgressRenderer`, `WireLayer`, and `WireRenderer`, enhancing the visual wiring system.
- Developed utility functions for pin position calculations and wire color management, ensuring accurate connections and visual representation.
- Added types for wire management in `wire.ts`, defining structures for wire endpoints, control points, and signal types.
2026-03-04 02:44:07 +07:00
|
|
|
|
}
|
|
|
|
|
|
return updated;
|
|
|
|
|
|
});
|
|
|
|
|
|
return { wires: updatedWires };
|
|
|
|
|
|
});
|
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
|
|
recalculateAllWirePositions: () => {
|
|
|
|
|
|
const state = get();
|
|
|
|
|
|
const updatedWires = state.wires.map((wire) => {
|
|
|
|
|
|
const updated = { ...wire };
|
|
|
|
|
|
|
2026-03-16 20:31:02 +07:00
|
|
|
|
// Resolve start — components have wrapper offset (4,6), boards do not
|
2026-03-13 09:39:04 +07:00
|
|
|
|
const startComp = state.components.find((c) => c.id === wire.start.componentId);
|
|
|
|
|
|
const startBoard = state.boards.find((b) => b.id === wire.start.componentId);
|
2026-04-22 02:45:45 +07:00
|
|
|
|
const startX = startComp
|
|
|
|
|
|
? startComp.x + 4
|
|
|
|
|
|
: startBoard
|
|
|
|
|
|
? startBoard.x
|
|
|
|
|
|
: state.boardPosition.x;
|
|
|
|
|
|
const startY = startComp
|
|
|
|
|
|
? startComp.y + 6
|
|
|
|
|
|
: startBoard
|
|
|
|
|
|
? startBoard.y
|
|
|
|
|
|
: state.boardPosition.y;
|
|
|
|
|
|
const startPos = calculatePinPosition(
|
|
|
|
|
|
wire.start.componentId,
|
|
|
|
|
|
wire.start.pinName,
|
|
|
|
|
|
startX,
|
|
|
|
|
|
startY,
|
|
|
|
|
|
);
|
2026-03-13 09:39:04 +07:00
|
|
|
|
updated.start = startPos
|
|
|
|
|
|
? { ...wire.start, x: startPos.x, y: startPos.y }
|
|
|
|
|
|
: { ...wire.start, x: startX, y: startY };
|
|
|
|
|
|
|
2026-03-16 20:31:02 +07:00
|
|
|
|
// Resolve end — components have wrapper offset (4,6), boards do not
|
Add backend and frontend test suites for Arduino compilation and simulation
- Implemented a comprehensive backend test suite in `test_compilation.py` to validate the Arduino CLI installation, AVR core presence, compilation service, and API endpoint functionality.
- Created a frontend test suite in `simulation.test.ts` to test the `PinManager` and `AVRSimulator` components, ensuring proper functionality and integration.
- Introduced new components for wire management in the simulator, including `PinOverlay`, `WireInProgressRenderer`, `WireLayer`, and `WireRenderer`, enhancing the visual wiring system.
- Developed utility functions for pin position calculations and wire color management, ensuring accurate connections and visual representation.
- Added types for wire management in `wire.ts`, defining structures for wire endpoints, control points, and signal types.
2026-03-04 02:44:07 +07:00
|
|
|
|
const endComp = state.components.find((c) => c.id === wire.end.componentId);
|
2026-03-13 09:39:04 +07:00
|
|
|
|
const endBoard = state.boards.find((b) => b.id === wire.end.componentId);
|
2026-04-22 02:45:45 +07:00
|
|
|
|
const endX = endComp ? endComp.x + 4 : endBoard ? endBoard.x : state.boardPosition.x;
|
|
|
|
|
|
const endY = endComp ? endComp.y + 6 : endBoard ? endBoard.y : state.boardPosition.y;
|
2026-03-13 09:39:04 +07:00
|
|
|
|
const endPos = calculatePinPosition(wire.end.componentId, wire.end.pinName, endX, endY);
|
|
|
|
|
|
updated.end = endPos
|
|
|
|
|
|
? { ...wire.end, x: endPos.x, y: endPos.y }
|
|
|
|
|
|
: { ...wire.end, x: endX, y: endY };
|
|
|
|
|
|
|
Add backend and frontend test suites for Arduino compilation and simulation
- Implemented a comprehensive backend test suite in `test_compilation.py` to validate the Arduino CLI installation, AVR core presence, compilation service, and API endpoint functionality.
- Created a frontend test suite in `simulation.test.ts` to test the `PinManager` and `AVRSimulator` components, ensuring proper functionality and integration.
- Introduced new components for wire management in the simulator, including `PinOverlay`, `WireInProgressRenderer`, `WireLayer`, and `WireRenderer`, enhancing the visual wiring system.
- Developed utility functions for pin position calculations and wire color management, ensuring accurate connections and visual representation.
- Added types for wire management in `wire.ts`, defining structures for wire endpoints, control points, and signal types.
2026-03-04 02:44:07 +07:00
|
|
|
|
return updated;
|
|
|
|
|
|
});
|
|
|
|
|
|
set({ wires: updatedWires });
|
|
|
|
|
|
},
|
2026-03-05 16:56:14 +07:00
|
|
|
|
|
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',
|
|
|
|
|
|
execute: () =>
|
|
|
|
|
|
set((s) => ({
|
|
|
|
|
|
components: s.components.map((c) =>
|
|
|
|
|
|
c.id === id
|
|
|
|
|
|
? { ...c, properties: { ...c.properties, rotation: nextRotation } }
|
|
|
|
|
|
: c,
|
|
|
|
|
|
),
|
|
|
|
|
|
})),
|
|
|
|
|
|
undo: () =>
|
|
|
|
|
|
set((s) => ({
|
|
|
|
|
|
components: s.components.map((c) =>
|
|
|
|
|
|
c.id === id
|
|
|
|
|
|
? { ...c, properties: { ...c.properties, rotation: prevRotation } }
|
|
|
|
|
|
: c,
|
|
|
|
|
|
),
|
|
|
|
|
|
})),
|
|
|
|
|
|
},
|
|
|
|
|
|
{ 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 },
|
|
|
|
|
|
);
|
|
|
|
|
|
},
|
|
|
|
|
|
|
2026-03-13 09:39:04 +07:00
|
|
|
|
toggleSerialMonitor: () => set((s) => ({ serialMonitorOpen: !s.serialMonitorOpen })),
|
2026-03-05 16:56:14 +07:00
|
|
|
|
|
|
|
|
|
|
serialWrite: (text: string) => {
|
2026-03-13 09:39:04 +07:00
|
|
|
|
const { activeBoardId } = get();
|
|
|
|
|
|
const boardId = activeBoardId ?? INITIAL_BOARD_ID;
|
|
|
|
|
|
const board = get().boards.find((b) => b.id === boardId);
|
|
|
|
|
|
if (!board) return;
|
|
|
|
|
|
|
|
|
|
|
|
if (board.boardKind === 'raspberry-pi-3') {
|
|
|
|
|
|
const bridge = getBoardBridge(boardId);
|
|
|
|
|
|
if (bridge) {
|
|
|
|
|
|
for (let i = 0; i < text.length; i++) {
|
|
|
|
|
|
bridge.sendSerialByte(text.charCodeAt(i));
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-03-14 08:55:37 +07:00
|
|
|
|
} else if (isEsp32Kind(board.boardKind)) {
|
|
|
|
|
|
const esp32Bridge = getEsp32Bridge(boardId);
|
|
|
|
|
|
if (esp32Bridge) {
|
|
|
|
|
|
esp32Bridge.sendSerialBytes(Array.from(new TextEncoder().encode(text)));
|
|
|
|
|
|
}
|
2026-03-13 09:39:04 +07:00
|
|
|
|
} else {
|
|
|
|
|
|
getBoardSimulator(boardId)?.serialWrite(text);
|
2026-03-05 16:56:14 +07:00
|
|
|
|
}
|
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
|
|
clearSerialOutput: () => {
|
2026-03-13 09:39:04 +07:00
|
|
|
|
const { activeBoardId } = get();
|
|
|
|
|
|
const boardId = activeBoardId ?? INITIAL_BOARD_ID;
|
|
|
|
|
|
set((s) => ({
|
|
|
|
|
|
serialOutput: '',
|
2026-04-22 02:45:45 +07:00
|
|
|
|
boards: s.boards.map((b) => (b.id === boardId ? { ...b, serialOutput: '' } : b)),
|
2026-03-13 09:39:04 +07:00
|
|
|
|
}));
|
2026-03-05 16:56:14 +07:00
|
|
|
|
},
|
2026-03-16 00:04:01 +07:00
|
|
|
|
|
|
|
|
|
|
serialWriteToBoard: (boardId: string, text: string) => {
|
|
|
|
|
|
const board = get().boards.find((b) => b.id === boardId);
|
|
|
|
|
|
if (!board) return;
|
|
|
|
|
|
if (board.boardKind === 'raspberry-pi-3') {
|
|
|
|
|
|
const bridge = getBoardBridge(boardId);
|
|
|
|
|
|
if (bridge) {
|
|
|
|
|
|
for (let i = 0; i < text.length; i++) {
|
|
|
|
|
|
bridge.sendSerialByte(text.charCodeAt(i));
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
} else if (isEsp32Kind(board.boardKind)) {
|
|
|
|
|
|
const esp32Bridge = getEsp32Bridge(boardId);
|
|
|
|
|
|
if (esp32Bridge) {
|
|
|
|
|
|
esp32Bridge.sendSerialBytes(Array.from(new TextEncoder().encode(text)));
|
|
|
|
|
|
}
|
|
|
|
|
|
} else {
|
|
|
|
|
|
getBoardSimulator(boardId)?.serialWrite(text);
|
|
|
|
|
|
}
|
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
|
|
clearBoardSerialOutput: (boardId: string) => {
|
|
|
|
|
|
const isActive = get().activeBoardId === boardId;
|
|
|
|
|
|
set((s) => ({
|
|
|
|
|
|
...(isActive ? { serialOutput: '' } : {}),
|
2026-04-22 02:45:45 +07:00
|
|
|
|
boards: s.boards.map((b) => (b.id === boardId ? { ...b, serialOutput: '' } : b)),
|
2026-03-16 00:04:01 +07:00
|
|
|
|
}));
|
|
|
|
|
|
},
|
2026-03-03 10:20:49 +07:00
|
|
|
|
};
|
|
|
|
|
|
});
|
2026-03-13 09:39:04 +07:00
|
|
|
|
|
|
|
|
|
|
// ── 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);
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|