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';
|
feat(esp32): SignalRouter — model the GPIO Matrix as first-class
Replaces the per-peripheral ad-hoc `_ledc_gpio_map` cache with a
proper signal-routing abstraction that mirrors the ESP32 SoC's
IO_MUX + GPIO Matrix exactly. Same idea as real silicon: signal
sources (LEDC channels, RMT, MCPWM, ...) → 40-entry routing table
→ GPIO pins.
Motivation (from user bug report in
velxio.dev/project/5218f9e3-136d-43b3-bba1-6cebde21e1a4): two
ESP32 servos on a solar-tracker visibly oscillated between two
positions instead of moving smoothly when the user changed LDR
sliders. Commit 77bf897 patched it (per-channel gpio memo +
broadcast guard) but the user requested a proper hardware-fidel
architecture, not patches.
Backend:
* `app/services/signal_router.py` — SignalRouter class. Forward
index (gpio → signal_id) + reverse index (signal_id → set of
gpios). `replace_snapshot()` returns the diff for the polling-
fallback path; future C plugin hook becomes a push without
touching this code.
* `app/services/esp32_signals.py` — Signal id constants from
ESP32 TRM (LEDC HS 72-79, LS 80-87) + `ledc_signal_for_channel()`
helper.
* `app/services/esp32_worker.py` — `_ledc_gpio_map` is gone;
`_refresh_ledc_gpio_map` replaced by `_refresh_signal_routing`
which emits `gpio_routing {gpio, signal_id}` events on diff.
The 0x5000 LEDC callback and the LEDC poll thread now emit
`ledc_duty {channel, duty_pct}` (canonical, no gpio) alongside
the legacy `ledc_update {channel, duty, gpio}` for back-compat
during rollout.
Frontend:
* `simulation/SignalRouter.ts` — 1-to-1 TS mirror of the Python
class. Same forward + reverse index; same `pinsForSignal` /
`updateRouting` / `clearRouting` API.
* `simulation/esp32-signals.ts` — Signal id constants, mirror
of the Python module.
* `simulation/Esp32Bridge.ts` — new `onLedcDuty`, `onGpioRouting`,
`onGpioRoutingClear` callbacks; handlers for the new event types.
* `store/useSimulatorStore.ts` — `makeLedcDutyHandler` looks up
pins via `router.pinsForSignal(ledcSignalForChannel(channel))`
and dispatches per pin. `makeGpioRoutingHandler` /
`makeGpioRoutingClearHandler` keep the mirror in sync. Per-board
`signalRouterMap` parallels `pinManagerMap` in lifecycle.
`makeLedcUpdateHandler` (and its memo workaround from 77bf897)
stays wired for back-compat during rollout; removed in a
follow-up commit once prod is verified stable on the new path.
Tests:
* `test/backend/unit/test_signal_router.py` (20 tests) covers
update/clear semantics, idempotency, multi-pin routing,
snapshot diff, channel↔signal-id helpers, and the multi-servo
regression scenario.
* `frontend/src/__tests__/SignalRouter.test.ts` (17 tests) is the
mirror — same scenarios on the TS side.
* `frontend/src/__tests__/esp32-multi-servo-gpio-matrix.test.ts`
(6 tests) drives the end-to-end SignalRouter handler pipeline,
asserts that two servos on GPIO 13/12 via LEDC channels 0/1
move independently (no mirroring), that re-routing carries
cleanly, and — critically — that `PinManager.broadcastPwm` is
never called.
Totals: +700 LOC, 1876 frontend tests pass (was 1853), 278 backend
unit tests pass (was 259).
Docs: ESP32_EMULATION.md §9.2 rewritten with the new architecture
diagram + a runbook for adding future peripherals through the
SignalRouter.
The C plugin hook in qemu-lcgamboa that would push gpio_out_sel
writes synchronously (eliminating the polling race window entirely)
is the next step — kept as a follow-up because the polling-fallback
path here already resolves the routing before each duty event
fires, so the bug is fixed end-to-end. The plugin work removes the
race condition fundamentally.
2026-05-17 10:00:53 +07:00
|
|
|
import { SignalRouter } from '../simulation/SignalRouter';
|
|
|
|
|
import { ledcSignalForChannel } from '../simulation/esp32-signals';
|
2026-05-13 02:55:15 +07:00
|
|
|
import {
|
|
|
|
|
VirtualDS1307,
|
|
|
|
|
VirtualTempSensor,
|
|
|
|
|
I2CMemoryDevice,
|
|
|
|
|
I2CBusManager,
|
|
|
|
|
nullI2CMaster,
|
|
|
|
|
} from '../simulation/I2CBusManager';
|
|
|
|
|
import type { I2CDevice } 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';
|
2026-05-19 04:23:48 +07:00
|
|
|
import { BOARD_SUPPORTS_MICROPYTHON, isPiBoardKind } 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-05-11 12:54:56 +07:00
|
|
|
import { autoWireColor, DEFAULT_WIRE_COLOR } from '../utils/wireUtils';
|
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;
|
|
|
|
|
|
2026-05-13 02:55:15 +07:00
|
|
|
/**
|
|
|
|
|
* Cross-board I2C surface — see AVRSimulator / RP2040Simulator for
|
|
|
|
|
* the canonical pattern. ESP32 sketches run in backend QEMU, so the
|
|
|
|
|
* "primary" I2C path goes through the backend's libqemu-xtensa I2C
|
|
|
|
|
* slaves and reaches the frontend as `i2c_event` / `i2c_transaction`
|
|
|
|
|
* WebSocket messages. But virtual devices attached to the ESP32
|
|
|
|
|
* board on the canvas also live frontend-side as I2CDevice instances
|
|
|
|
|
* — and Interconnect's bridge mechanism needs to reach them when a
|
|
|
|
|
* peer board's master tries to read across an SDA+SCL wire. So we
|
|
|
|
|
* expose an I2CBusManager whose local devices mirror what
|
|
|
|
|
* ProtocolParts registers via `registerSensor`. The peer-master
|
|
|
|
|
* direction works through this bus; the ESP32-master direction
|
|
|
|
|
* still flows through the backend (where the firmware runs).
|
|
|
|
|
*/
|
|
|
|
|
private i2cBusInstance: I2CBusManager;
|
|
|
|
|
|
2026-03-22 12:25:58 +07:00
|
|
|
constructor(bridge: Esp32Bridge, pm: PinManager) {
|
|
|
|
|
this.bridge = bridge;
|
|
|
|
|
this.pinManager = pm;
|
2026-05-13 02:55:15 +07:00
|
|
|
this.i2cBusInstance = new I2CBusManager(nullI2CMaster());
|
feat(i2c): cross-board bridging across all velxio boards (AVR/RP2040/ESP32 xtensa+riscv)
Closes the remaining gaps in cross-board I2C so any topology of
supported boards (Uno↔ESP32, two ESP32s, Uno↔Uno↔Uno, ESP32-C3
connected to anything, etc.) works end-to-end with all I2C
components including write-only sinks (SSD1306, PCF8574, LCD-I2C).
Implementation (6 phases):
1. **BFS routing in I2CBusManager**: connectToSlave + handleExternalConnect
walk the bridge graph with a visited Set so multi-hop chains
(A↔B↔C with the device on C) resolve transparently. A new
forwarder-device shim is installed at intermediate hops so the
existing handleExternalWrite/Read/Stop machinery routes
through without per-method visited tracking.
2. **Per-peer proxy ownership in Esp32BridgeShim**: replaces the
global _proxiedAddrs Set with _proxiedByPeer Map so concurrent
bridges to the same ESP32 (e.g. wired to both Uno and Pico)
don't wipe each other's proxies on teardown. Interconnect's
per-wire teardown calls clearProxiesForPeer(peerBus) instead of
clearAllProxies.
3. **BFS-aware proxy sync**: syncProxyFromPeer now walks the peer
bus + its transitive bridges, so an ESP32 sees devices on
boards two or more hops away. _peerDeviceLookup keeps a flat
addr → device map for write-forwarding and resync.
4. **Periodic resync (250 ms)**: Esp32BridgeShim runs a setInterval
while any proxy is live, re-dumping each device with
dumpRegisters() and pushing updateProxyI2c only when an XOR-
stride hash changes. This keeps RTC time advancing visible to
ESP32 firmware without flooding the WS pipe with static
calibration dumps. Hash is primed during initial sync so the
first tick doesn't push a redundant identical buffer.
5. **Write-forwarding ProxySlave → peer**: backend ProxySlave
buffers write bytes during the transaction and emits a
`proxy_i2c_complete` event on STOP / repeated-START. Frontend
Esp32Bridge dispatches the event to a new onProxyI2cComplete
callback; the shim replays the byte sequence on the actual
peer I2CDevice via writeByte() + stop(). Makes ESP32 firmware
writes to peer SSD1306 actually repaint the OLED, peer PCF8574
latch updates, peer I2CMemoryDevice register mutations propagate.
6. **ESP32-C3 routed as bridge**: Interconnect.isBrowserSim no
longer claims c3/xiao-c3/c3-supermini — they were already
going through Esp32Bridge per the store's ESP32_RISCV_KINDS
routing, but Interconnect was treating them as browser sims
which broke proxy install. isEsp32Bridge now correctly
includes c3 family + ESP32-S3 + Arduino Nano ESP32.
Defensive: addBoard now disposes any existing shim's proxies
before overwriting simulatorMap entry so test reruns don't leak
timers.
Tests:
- 4 BFS multi-hop tests (i2c-multi-board-slave-gap.test.ts)
- 11 cross-board scenarios + per-peer + write-forward + resync
(i2c-esp32-multiboard-bridge.test.ts)
- 1 real-firmware E2E for write-forward via QEMU (compile +
load + observe proxy_i2c_complete arriving with the byte)
- New sketch fixture: esp32_i2c_write_to_peer.ino
Result: 90 test files / 1295 tests pass / 0 fail.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 03:51:39 +07:00
|
|
|
|
|
|
|
|
// Wire the write-forwarding path: when the backend ProxySlave emits
|
|
|
|
|
// a completed write transaction (one full STOP-bounded master phase
|
|
|
|
|
// from the ESP32 firmware), look up the peer device on the local
|
|
|
|
|
// device lookup map and replay the bytes through its writeByte()
|
|
|
|
|
// contract. Peer `I2CDevice` implementations (I2CMemoryDevice,
|
|
|
|
|
// VirtualPCF8574, VirtualSSD1306, …) already encode the
|
|
|
|
|
// pointer-byte + data semantics; we just hand off the sequence.
|
|
|
|
|
bridge.onProxyI2cComplete = (addr: number, data: number[]) => {
|
|
|
|
|
const dev = this._peerDeviceLookup.get(addr);
|
|
|
|
|
if (!dev) return;
|
|
|
|
|
try {
|
|
|
|
|
for (const b of data) dev.writeByte(b);
|
|
|
|
|
dev.stop?.();
|
|
|
|
|
} catch (e) {
|
|
|
|
|
console.warn(
|
|
|
|
|
`[Esp32BridgeShim] proxy write replay failed for 0x${addr.toString(16)}`,
|
|
|
|
|
e,
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
};
|
2026-03-22 12:25:58 +07:00
|
|
|
}
|
|
|
|
|
|
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-05-13 02:55:15 +07:00
|
|
|
|
|
|
|
|
// ── Cross-board I2C bus surface ─────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Expose the I2CBusManager so Interconnect can install cross-board
|
|
|
|
|
* bridges and ProtocolParts can register frontend-side virtual
|
|
|
|
|
* devices. ESP32 has 2 hardware I2C buses but we collapse them
|
|
|
|
|
* onto a single front-end bus for now — the bus index is ignored.
|
|
|
|
|
* Splitting per-bus would require teaching the backend to tag
|
|
|
|
|
* `i2c_event` payloads with the originating bus number, which
|
|
|
|
|
* the lib worker already does (`bus` field) but the frontend
|
|
|
|
|
* shim doesn't yet route on.
|
|
|
|
|
*/
|
|
|
|
|
getI2CBus(_bus: 0 | 1 = 0): I2CBusManager {
|
|
|
|
|
return this.i2cBusInstance;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Register a frontend-side virtual I2C device. This mirrors the
|
|
|
|
|
* backend's QEMU-side slave (kept in sync via `registerSensor` /
|
|
|
|
|
* `updateSensor`) so peer boards reading across the I2C bridge
|
|
|
|
|
* find the device. ProtocolParts calls this on the ESP32 path
|
|
|
|
|
* alongside the existing `registerSensor` + `addI2CTransactionListener`.
|
|
|
|
|
*/
|
|
|
|
|
addI2CDevice(device: I2CDevice, _bus: 0 | 1 = 0): void {
|
|
|
|
|
this.i2cBusInstance.addDevice(device);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** Remove a previously-registered virtual device. */
|
|
|
|
|
removeI2CDevice(addr: number, _bus: 0 | 1 = 0): void {
|
|
|
|
|
this.i2cBusInstance.removeDevice(addr);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Push register snapshots of a peer board's I2C devices into a
|
|
|
|
|
* backend `ProxySlave` per address. Called by Interconnect after a
|
|
|
|
|
* cross-board I2C bridge is installed so the ESP32 firmware's Wire
|
|
|
|
|
* master reads can find the peer's devices inside QEMU.
|
|
|
|
|
*
|
feat(i2c): cross-board bridging across all velxio boards (AVR/RP2040/ESP32 xtensa+riscv)
Closes the remaining gaps in cross-board I2C so any topology of
supported boards (Uno↔ESP32, two ESP32s, Uno↔Uno↔Uno, ESP32-C3
connected to anything, etc.) works end-to-end with all I2C
components including write-only sinks (SSD1306, PCF8574, LCD-I2C).
Implementation (6 phases):
1. **BFS routing in I2CBusManager**: connectToSlave + handleExternalConnect
walk the bridge graph with a visited Set so multi-hop chains
(A↔B↔C with the device on C) resolve transparently. A new
forwarder-device shim is installed at intermediate hops so the
existing handleExternalWrite/Read/Stop machinery routes
through without per-method visited tracking.
2. **Per-peer proxy ownership in Esp32BridgeShim**: replaces the
global _proxiedAddrs Set with _proxiedByPeer Map so concurrent
bridges to the same ESP32 (e.g. wired to both Uno and Pico)
don't wipe each other's proxies on teardown. Interconnect's
per-wire teardown calls clearProxiesForPeer(peerBus) instead of
clearAllProxies.
3. **BFS-aware proxy sync**: syncProxyFromPeer now walks the peer
bus + its transitive bridges, so an ESP32 sees devices on
boards two or more hops away. _peerDeviceLookup keeps a flat
addr → device map for write-forwarding and resync.
4. **Periodic resync (250 ms)**: Esp32BridgeShim runs a setInterval
while any proxy is live, re-dumping each device with
dumpRegisters() and pushing updateProxyI2c only when an XOR-
stride hash changes. This keeps RTC time advancing visible to
ESP32 firmware without flooding the WS pipe with static
calibration dumps. Hash is primed during initial sync so the
first tick doesn't push a redundant identical buffer.
5. **Write-forwarding ProxySlave → peer**: backend ProxySlave
buffers write bytes during the transaction and emits a
`proxy_i2c_complete` event on STOP / repeated-START. Frontend
Esp32Bridge dispatches the event to a new onProxyI2cComplete
callback; the shim replays the byte sequence on the actual
peer I2CDevice via writeByte() + stop(). Makes ESP32 firmware
writes to peer SSD1306 actually repaint the OLED, peer PCF8574
latch updates, peer I2CMemoryDevice register mutations propagate.
6. **ESP32-C3 routed as bridge**: Interconnect.isBrowserSim no
longer claims c3/xiao-c3/c3-supermini — they were already
going through Esp32Bridge per the store's ESP32_RISCV_KINDS
routing, but Interconnect was treating them as browser sims
which broke proxy install. isEsp32Bridge now correctly
includes c3 family + ESP32-S3 + Arduino Nano ESP32.
Defensive: addBoard now disposes any existing shim's proxies
before overwriting simulatorMap entry so test reruns don't leak
timers.
Tests:
- 4 BFS multi-hop tests (i2c-multi-board-slave-gap.test.ts)
- 11 cross-board scenarios + per-peer + write-forward + resync
(i2c-esp32-multiboard-bridge.test.ts)
- 1 real-firmware E2E for write-forward via QEMU (compile +
load + observe proxy_i2c_complete arriving with the byte)
- New sketch fixture: esp32_i2c_write_to_peer.ino
Result: 90 test files / 1295 tests pass / 0 fail.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 03:51:39 +07:00
|
|
|
* Walks the peer bus AND its transitive bridges (BFS). Each device
|
|
|
|
|
* found at any reachable hop gets a ProxySlave on the backend. All
|
|
|
|
|
* addresses discovered through `peerBus` are tracked under that key,
|
|
|
|
|
* so `clearProxiesForPeer(peerBus)` cleans up exactly what this call
|
|
|
|
|
* installed without disturbing proxies from concurrent bridges
|
|
|
|
|
* (e.g. when another wire pair also connects to this same ESP32).
|
|
|
|
|
*
|
|
|
|
|
* Devices that don't expose `dumpRegisters` (PCF8574, SSD1306,
|
|
|
|
|
* LCD-I2C) are skipped — they receive state through the
|
|
|
|
|
* write-forwarding path (proxy_i2c_complete event from the backend
|
|
|
|
|
* ProxySlave) instead.
|
2026-05-13 02:55:15 +07:00
|
|
|
*/
|
|
|
|
|
syncProxyFromPeer(peerBus: I2CBusManager): void {
|
feat(i2c): cross-board bridging across all velxio boards (AVR/RP2040/ESP32 xtensa+riscv)
Closes the remaining gaps in cross-board I2C so any topology of
supported boards (Uno↔ESP32, two ESP32s, Uno↔Uno↔Uno, ESP32-C3
connected to anything, etc.) works end-to-end with all I2C
components including write-only sinks (SSD1306, PCF8574, LCD-I2C).
Implementation (6 phases):
1. **BFS routing in I2CBusManager**: connectToSlave + handleExternalConnect
walk the bridge graph with a visited Set so multi-hop chains
(A↔B↔C with the device on C) resolve transparently. A new
forwarder-device shim is installed at intermediate hops so the
existing handleExternalWrite/Read/Stop machinery routes
through without per-method visited tracking.
2. **Per-peer proxy ownership in Esp32BridgeShim**: replaces the
global _proxiedAddrs Set with _proxiedByPeer Map so concurrent
bridges to the same ESP32 (e.g. wired to both Uno and Pico)
don't wipe each other's proxies on teardown. Interconnect's
per-wire teardown calls clearProxiesForPeer(peerBus) instead of
clearAllProxies.
3. **BFS-aware proxy sync**: syncProxyFromPeer now walks the peer
bus + its transitive bridges, so an ESP32 sees devices on
boards two or more hops away. _peerDeviceLookup keeps a flat
addr → device map for write-forwarding and resync.
4. **Periodic resync (250 ms)**: Esp32BridgeShim runs a setInterval
while any proxy is live, re-dumping each device with
dumpRegisters() and pushing updateProxyI2c only when an XOR-
stride hash changes. This keeps RTC time advancing visible to
ESP32 firmware without flooding the WS pipe with static
calibration dumps. Hash is primed during initial sync so the
first tick doesn't push a redundant identical buffer.
5. **Write-forwarding ProxySlave → peer**: backend ProxySlave
buffers write bytes during the transaction and emits a
`proxy_i2c_complete` event on STOP / repeated-START. Frontend
Esp32Bridge dispatches the event to a new onProxyI2cComplete
callback; the shim replays the byte sequence on the actual
peer I2CDevice via writeByte() + stop(). Makes ESP32 firmware
writes to peer SSD1306 actually repaint the OLED, peer PCF8574
latch updates, peer I2CMemoryDevice register mutations propagate.
6. **ESP32-C3 routed as bridge**: Interconnect.isBrowserSim no
longer claims c3/xiao-c3/c3-supermini — they were already
going through Esp32Bridge per the store's ESP32_RISCV_KINDS
routing, but Interconnect was treating them as browser sims
which broke proxy install. isEsp32Bridge now correctly
includes c3 family + ESP32-S3 + Arduino Nano ESP32.
Defensive: addBoard now disposes any existing shim's proxies
before overwriting simulatorMap entry so test reruns don't leak
timers.
Tests:
- 4 BFS multi-hop tests (i2c-multi-board-slave-gap.test.ts)
- 11 cross-board scenarios + per-peer + write-forward + resync
(i2c-esp32-multiboard-bridge.test.ts)
- 1 real-firmware E2E for write-forward via QEMU (compile +
load + observe proxy_i2c_complete arriving with the byte)
- New sketch fixture: esp32_i2c_write_to_peer.ino
Result: 90 test files / 1295 tests pass / 0 fail.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 03:51:39 +07:00
|
|
|
const ownedAddrs = this._proxiedByPeer.get(peerBus) ?? new Set<number>();
|
|
|
|
|
|
|
|
|
|
// BFS over the peer's bridge graph. Skip our own bus so we don't
|
|
|
|
|
// mirror ourselves back via the return edge.
|
|
|
|
|
const visited = new Set<I2CBusManager>([this.i2cBusInstance, peerBus]);
|
|
|
|
|
const queue: I2CBusManager[] = [peerBus];
|
|
|
|
|
|
|
|
|
|
while (queue.length > 0) {
|
|
|
|
|
const bus = queue.shift()!;
|
|
|
|
|
if (typeof bus.listDevices === 'function') {
|
|
|
|
|
for (const device of bus.listDevices()) {
|
|
|
|
|
// Track the live device reference for write-forwarding and
|
|
|
|
|
// periodic resync. Last writer wins on address collisions
|
|
|
|
|
// (rare; the user wired two devices to the same address).
|
|
|
|
|
this._peerDeviceLookup.set(device.address, device);
|
|
|
|
|
if (typeof device.dumpRegisters !== 'function') continue;
|
|
|
|
|
try {
|
|
|
|
|
const regs = device.dumpRegisters();
|
|
|
|
|
this.bridge.registerProxyI2c(device.address, regs);
|
|
|
|
|
ownedAddrs.add(device.address);
|
|
|
|
|
// Prime the resync hash so the first tick doesn't push a
|
|
|
|
|
// redundant identical dump.
|
|
|
|
|
this._lastDumpHash.set(
|
|
|
|
|
device.address,
|
|
|
|
|
Esp32BridgeShim._hashRegs(regs),
|
|
|
|
|
);
|
|
|
|
|
} catch (e) {
|
|
|
|
|
console.warn(
|
|
|
|
|
`[Esp32BridgeShim] syncProxyFromPeer dump failed for 0x${device.address.toString(16)}`,
|
|
|
|
|
e,
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if (typeof bus.getBridges === 'function') {
|
|
|
|
|
for (const next of bus.getBridges()) {
|
|
|
|
|
if (visited.has(next)) continue;
|
|
|
|
|
visited.add(next);
|
|
|
|
|
queue.push(next);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (ownedAddrs.size > 0) {
|
|
|
|
|
this._proxiedByPeer.set(peerBus, ownedAddrs);
|
|
|
|
|
this._ensureResyncTimer();
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Tear down only the proxies that `syncProxyFromPeer(peerBus)`
|
|
|
|
|
* installed. Safe to call multiple times; idempotent. Other
|
|
|
|
|
* concurrent bridges (different peer buses) retain their proxies.
|
|
|
|
|
*/
|
|
|
|
|
clearProxiesForPeer(peerBus: I2CBusManager): void {
|
|
|
|
|
const owned = this._proxiedByPeer.get(peerBus);
|
|
|
|
|
if (!owned) return;
|
|
|
|
|
for (const addr of owned) {
|
|
|
|
|
// Only unregister if no other peer also claims this address.
|
|
|
|
|
let claimedElsewhere = false;
|
|
|
|
|
for (const [other, set] of this._proxiedByPeer) {
|
|
|
|
|
if (other !== peerBus && set.has(addr)) {
|
|
|
|
|
claimedElsewhere = true;
|
|
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if (!claimedElsewhere) {
|
|
|
|
|
this.bridge.unregisterProxyI2c(addr);
|
|
|
|
|
this._peerDeviceLookup.delete(addr);
|
|
|
|
|
this._lastDumpHash.delete(addr);
|
2026-05-13 02:55:15 +07:00
|
|
|
}
|
|
|
|
|
}
|
feat(i2c): cross-board bridging across all velxio boards (AVR/RP2040/ESP32 xtensa+riscv)
Closes the remaining gaps in cross-board I2C so any topology of
supported boards (Uno↔ESP32, two ESP32s, Uno↔Uno↔Uno, ESP32-C3
connected to anything, etc.) works end-to-end with all I2C
components including write-only sinks (SSD1306, PCF8574, LCD-I2C).
Implementation (6 phases):
1. **BFS routing in I2CBusManager**: connectToSlave + handleExternalConnect
walk the bridge graph with a visited Set so multi-hop chains
(A↔B↔C with the device on C) resolve transparently. A new
forwarder-device shim is installed at intermediate hops so the
existing handleExternalWrite/Read/Stop machinery routes
through without per-method visited tracking.
2. **Per-peer proxy ownership in Esp32BridgeShim**: replaces the
global _proxiedAddrs Set with _proxiedByPeer Map so concurrent
bridges to the same ESP32 (e.g. wired to both Uno and Pico)
don't wipe each other's proxies on teardown. Interconnect's
per-wire teardown calls clearProxiesForPeer(peerBus) instead of
clearAllProxies.
3. **BFS-aware proxy sync**: syncProxyFromPeer now walks the peer
bus + its transitive bridges, so an ESP32 sees devices on
boards two or more hops away. _peerDeviceLookup keeps a flat
addr → device map for write-forwarding and resync.
4. **Periodic resync (250 ms)**: Esp32BridgeShim runs a setInterval
while any proxy is live, re-dumping each device with
dumpRegisters() and pushing updateProxyI2c only when an XOR-
stride hash changes. This keeps RTC time advancing visible to
ESP32 firmware without flooding the WS pipe with static
calibration dumps. Hash is primed during initial sync so the
first tick doesn't push a redundant identical buffer.
5. **Write-forwarding ProxySlave → peer**: backend ProxySlave
buffers write bytes during the transaction and emits a
`proxy_i2c_complete` event on STOP / repeated-START. Frontend
Esp32Bridge dispatches the event to a new onProxyI2cComplete
callback; the shim replays the byte sequence on the actual
peer I2CDevice via writeByte() + stop(). Makes ESP32 firmware
writes to peer SSD1306 actually repaint the OLED, peer PCF8574
latch updates, peer I2CMemoryDevice register mutations propagate.
6. **ESP32-C3 routed as bridge**: Interconnect.isBrowserSim no
longer claims c3/xiao-c3/c3-supermini — they were already
going through Esp32Bridge per the store's ESP32_RISCV_KINDS
routing, but Interconnect was treating them as browser sims
which broke proxy install. isEsp32Bridge now correctly
includes c3 family + ESP32-S3 + Arduino Nano ESP32.
Defensive: addBoard now disposes any existing shim's proxies
before overwriting simulatorMap entry so test reruns don't leak
timers.
Tests:
- 4 BFS multi-hop tests (i2c-multi-board-slave-gap.test.ts)
- 11 cross-board scenarios + per-peer + write-forward + resync
(i2c-esp32-multiboard-bridge.test.ts)
- 1 real-firmware E2E for write-forward via QEMU (compile +
load + observe proxy_i2c_complete arriving with the byte)
- New sketch fixture: esp32_i2c_write_to_peer.ino
Result: 90 test files / 1295 tests pass / 0 fail.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 03:51:39 +07:00
|
|
|
this._proxiedByPeer.delete(peerBus);
|
|
|
|
|
this._stopResyncTimerIfIdle();
|
2026-05-13 02:55:15 +07:00
|
|
|
}
|
|
|
|
|
|
feat(i2c): cross-board bridging across all velxio boards (AVR/RP2040/ESP32 xtensa+riscv)
Closes the remaining gaps in cross-board I2C so any topology of
supported boards (Uno↔ESP32, two ESP32s, Uno↔Uno↔Uno, ESP32-C3
connected to anything, etc.) works end-to-end with all I2C
components including write-only sinks (SSD1306, PCF8574, LCD-I2C).
Implementation (6 phases):
1. **BFS routing in I2CBusManager**: connectToSlave + handleExternalConnect
walk the bridge graph with a visited Set so multi-hop chains
(A↔B↔C with the device on C) resolve transparently. A new
forwarder-device shim is installed at intermediate hops so the
existing handleExternalWrite/Read/Stop machinery routes
through without per-method visited tracking.
2. **Per-peer proxy ownership in Esp32BridgeShim**: replaces the
global _proxiedAddrs Set with _proxiedByPeer Map so concurrent
bridges to the same ESP32 (e.g. wired to both Uno and Pico)
don't wipe each other's proxies on teardown. Interconnect's
per-wire teardown calls clearProxiesForPeer(peerBus) instead of
clearAllProxies.
3. **BFS-aware proxy sync**: syncProxyFromPeer now walks the peer
bus + its transitive bridges, so an ESP32 sees devices on
boards two or more hops away. _peerDeviceLookup keeps a flat
addr → device map for write-forwarding and resync.
4. **Periodic resync (250 ms)**: Esp32BridgeShim runs a setInterval
while any proxy is live, re-dumping each device with
dumpRegisters() and pushing updateProxyI2c only when an XOR-
stride hash changes. This keeps RTC time advancing visible to
ESP32 firmware without flooding the WS pipe with static
calibration dumps. Hash is primed during initial sync so the
first tick doesn't push a redundant identical buffer.
5. **Write-forwarding ProxySlave → peer**: backend ProxySlave
buffers write bytes during the transaction and emits a
`proxy_i2c_complete` event on STOP / repeated-START. Frontend
Esp32Bridge dispatches the event to a new onProxyI2cComplete
callback; the shim replays the byte sequence on the actual
peer I2CDevice via writeByte() + stop(). Makes ESP32 firmware
writes to peer SSD1306 actually repaint the OLED, peer PCF8574
latch updates, peer I2CMemoryDevice register mutations propagate.
6. **ESP32-C3 routed as bridge**: Interconnect.isBrowserSim no
longer claims c3/xiao-c3/c3-supermini — they were already
going through Esp32Bridge per the store's ESP32_RISCV_KINDS
routing, but Interconnect was treating them as browser sims
which broke proxy install. isEsp32Bridge now correctly
includes c3 family + ESP32-S3 + Arduino Nano ESP32.
Defensive: addBoard now disposes any existing shim's proxies
before overwriting simulatorMap entry so test reruns don't leak
timers.
Tests:
- 4 BFS multi-hop tests (i2c-multi-board-slave-gap.test.ts)
- 11 cross-board scenarios + per-peer + write-forward + resync
(i2c-esp32-multiboard-bridge.test.ts)
- 1 real-firmware E2E for write-forward via QEMU (compile +
load + observe proxy_i2c_complete arriving with the byte)
- New sketch fixture: esp32_i2c_write_to_peer.ino
Result: 90 test files / 1295 tests pass / 0 fail.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 03:51:39 +07:00
|
|
|
/**
|
|
|
|
|
* Tear down EVERY proxy slave we've installed. Used on full board
|
|
|
|
|
* stop / disconnect — `clearProxiesForPeer` is preferred for
|
|
|
|
|
* single-wire-pair teardowns.
|
|
|
|
|
*/
|
2026-05-13 02:55:15 +07:00
|
|
|
clearAllProxies(): void {
|
feat(i2c): cross-board bridging across all velxio boards (AVR/RP2040/ESP32 xtensa+riscv)
Closes the remaining gaps in cross-board I2C so any topology of
supported boards (Uno↔ESP32, two ESP32s, Uno↔Uno↔Uno, ESP32-C3
connected to anything, etc.) works end-to-end with all I2C
components including write-only sinks (SSD1306, PCF8574, LCD-I2C).
Implementation (6 phases):
1. **BFS routing in I2CBusManager**: connectToSlave + handleExternalConnect
walk the bridge graph with a visited Set so multi-hop chains
(A↔B↔C with the device on C) resolve transparently. A new
forwarder-device shim is installed at intermediate hops so the
existing handleExternalWrite/Read/Stop machinery routes
through without per-method visited tracking.
2. **Per-peer proxy ownership in Esp32BridgeShim**: replaces the
global _proxiedAddrs Set with _proxiedByPeer Map so concurrent
bridges to the same ESP32 (e.g. wired to both Uno and Pico)
don't wipe each other's proxies on teardown. Interconnect's
per-wire teardown calls clearProxiesForPeer(peerBus) instead of
clearAllProxies.
3. **BFS-aware proxy sync**: syncProxyFromPeer now walks the peer
bus + its transitive bridges, so an ESP32 sees devices on
boards two or more hops away. _peerDeviceLookup keeps a flat
addr → device map for write-forwarding and resync.
4. **Periodic resync (250 ms)**: Esp32BridgeShim runs a setInterval
while any proxy is live, re-dumping each device with
dumpRegisters() and pushing updateProxyI2c only when an XOR-
stride hash changes. This keeps RTC time advancing visible to
ESP32 firmware without flooding the WS pipe with static
calibration dumps. Hash is primed during initial sync so the
first tick doesn't push a redundant identical buffer.
5. **Write-forwarding ProxySlave → peer**: backend ProxySlave
buffers write bytes during the transaction and emits a
`proxy_i2c_complete` event on STOP / repeated-START. Frontend
Esp32Bridge dispatches the event to a new onProxyI2cComplete
callback; the shim replays the byte sequence on the actual
peer I2CDevice via writeByte() + stop(). Makes ESP32 firmware
writes to peer SSD1306 actually repaint the OLED, peer PCF8574
latch updates, peer I2CMemoryDevice register mutations propagate.
6. **ESP32-C3 routed as bridge**: Interconnect.isBrowserSim no
longer claims c3/xiao-c3/c3-supermini — they were already
going through Esp32Bridge per the store's ESP32_RISCV_KINDS
routing, but Interconnect was treating them as browser sims
which broke proxy install. isEsp32Bridge now correctly
includes c3 family + ESP32-S3 + Arduino Nano ESP32.
Defensive: addBoard now disposes any existing shim's proxies
before overwriting simulatorMap entry so test reruns don't leak
timers.
Tests:
- 4 BFS multi-hop tests (i2c-multi-board-slave-gap.test.ts)
- 11 cross-board scenarios + per-peer + write-forward + resync
(i2c-esp32-multiboard-bridge.test.ts)
- 1 real-firmware E2E for write-forward via QEMU (compile +
load + observe proxy_i2c_complete arriving with the byte)
- New sketch fixture: esp32_i2c_write_to_peer.ino
Result: 90 test files / 1295 tests pass / 0 fail.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 03:51:39 +07:00
|
|
|
for (const set of this._proxiedByPeer.values()) {
|
|
|
|
|
for (const addr of set) this.bridge.unregisterProxyI2c(addr);
|
|
|
|
|
}
|
|
|
|
|
this._proxiedByPeer.clear();
|
|
|
|
|
this._peerDeviceLookup.clear();
|
|
|
|
|
this._lastDumpHash.clear();
|
|
|
|
|
this._stopResyncTimerIfIdle();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** Per-peer set of addresses we've mirrored. Cleanup keyed by peer bus. */
|
|
|
|
|
private _proxiedByPeer = new Map<I2CBusManager, Set<number>>();
|
|
|
|
|
/** Address → live frontend device, for write-forwarding & periodic resync. */
|
|
|
|
|
private _peerDeviceLookup = new Map<number, I2CDevice>();
|
|
|
|
|
/** Periodic resync timer — runs while any proxy is live. */
|
|
|
|
|
private _resyncTimer: ReturnType<typeof setInterval> | null = null;
|
|
|
|
|
/** Cheap hash of the last dumped register set per address, to skip WS pushes when unchanged. */
|
|
|
|
|
private _lastDumpHash = new Map<number, number>();
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Periodic resync interval in ms. 250 ms strikes the balance
|
|
|
|
|
* between WS bandwidth and human-perceivable RTC freshness; see
|
|
|
|
|
* the architecture rationale in the plan file. Exposed for tests
|
|
|
|
|
* that want a faster cadence via fake timers.
|
|
|
|
|
*/
|
|
|
|
|
static RESYNC_INTERVAL_MS = 250;
|
|
|
|
|
|
|
|
|
|
private _ensureResyncTimer(): void {
|
|
|
|
|
if (this._resyncTimer !== null) return;
|
|
|
|
|
if (this._proxiedByPeer.size === 0) return;
|
|
|
|
|
this._resyncTimer = setInterval(
|
|
|
|
|
() => this._resyncTick(),
|
|
|
|
|
Esp32BridgeShim.RESYNC_INTERVAL_MS,
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private _stopResyncTimerIfIdle(): void {
|
|
|
|
|
if (this._proxiedByPeer.size === 0 && this._resyncTimer !== null) {
|
|
|
|
|
clearInterval(this._resyncTimer);
|
|
|
|
|
this._resyncTimer = null;
|
|
|
|
|
this._lastDumpHash.clear();
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Cheap XOR-stride hash over a 256-byte buffer. Detects any byte
|
|
|
|
|
* difference; collisions are theoretically possible but we don't
|
|
|
|
|
* care — a missed update on a flaky hash just delays freshness by
|
|
|
|
|
* one cycle.
|
|
|
|
|
*/
|
|
|
|
|
private static _hashRegs(regs: Uint8Array): number {
|
|
|
|
|
let h = regs.length & 0xff;
|
|
|
|
|
for (let i = 0; i < regs.length; i += 16) {
|
|
|
|
|
h = ((h << 5) - h + regs[i]) | 0;
|
|
|
|
|
}
|
|
|
|
|
for (let i = 0; i < Math.min(regs.length, 8); i++) {
|
|
|
|
|
h = ((h << 5) - h + regs[i]) | 0;
|
|
|
|
|
}
|
|
|
|
|
return h;
|
2026-05-13 02:55:15 +07:00
|
|
|
}
|
|
|
|
|
|
feat(i2c): cross-board bridging across all velxio boards (AVR/RP2040/ESP32 xtensa+riscv)
Closes the remaining gaps in cross-board I2C so any topology of
supported boards (Uno↔ESP32, two ESP32s, Uno↔Uno↔Uno, ESP32-C3
connected to anything, etc.) works end-to-end with all I2C
components including write-only sinks (SSD1306, PCF8574, LCD-I2C).
Implementation (6 phases):
1. **BFS routing in I2CBusManager**: connectToSlave + handleExternalConnect
walk the bridge graph with a visited Set so multi-hop chains
(A↔B↔C with the device on C) resolve transparently. A new
forwarder-device shim is installed at intermediate hops so the
existing handleExternalWrite/Read/Stop machinery routes
through without per-method visited tracking.
2. **Per-peer proxy ownership in Esp32BridgeShim**: replaces the
global _proxiedAddrs Set with _proxiedByPeer Map so concurrent
bridges to the same ESP32 (e.g. wired to both Uno and Pico)
don't wipe each other's proxies on teardown. Interconnect's
per-wire teardown calls clearProxiesForPeer(peerBus) instead of
clearAllProxies.
3. **BFS-aware proxy sync**: syncProxyFromPeer now walks the peer
bus + its transitive bridges, so an ESP32 sees devices on
boards two or more hops away. _peerDeviceLookup keeps a flat
addr → device map for write-forwarding and resync.
4. **Periodic resync (250 ms)**: Esp32BridgeShim runs a setInterval
while any proxy is live, re-dumping each device with
dumpRegisters() and pushing updateProxyI2c only when an XOR-
stride hash changes. This keeps RTC time advancing visible to
ESP32 firmware without flooding the WS pipe with static
calibration dumps. Hash is primed during initial sync so the
first tick doesn't push a redundant identical buffer.
5. **Write-forwarding ProxySlave → peer**: backend ProxySlave
buffers write bytes during the transaction and emits a
`proxy_i2c_complete` event on STOP / repeated-START. Frontend
Esp32Bridge dispatches the event to a new onProxyI2cComplete
callback; the shim replays the byte sequence on the actual
peer I2CDevice via writeByte() + stop(). Makes ESP32 firmware
writes to peer SSD1306 actually repaint the OLED, peer PCF8574
latch updates, peer I2CMemoryDevice register mutations propagate.
6. **ESP32-C3 routed as bridge**: Interconnect.isBrowserSim no
longer claims c3/xiao-c3/c3-supermini — they were already
going through Esp32Bridge per the store's ESP32_RISCV_KINDS
routing, but Interconnect was treating them as browser sims
which broke proxy install. isEsp32Bridge now correctly
includes c3 family + ESP32-S3 + Arduino Nano ESP32.
Defensive: addBoard now disposes any existing shim's proxies
before overwriting simulatorMap entry so test reruns don't leak
timers.
Tests:
- 4 BFS multi-hop tests (i2c-multi-board-slave-gap.test.ts)
- 11 cross-board scenarios + per-peer + write-forward + resync
(i2c-esp32-multiboard-bridge.test.ts)
- 1 real-firmware E2E for write-forward via QEMU (compile +
load + observe proxy_i2c_complete arriving with the byte)
- New sketch fixture: esp32_i2c_write_to_peer.ino
Result: 90 test files / 1295 tests pass / 0 fail.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 03:51:39 +07:00
|
|
|
private _resyncTick(): void {
|
|
|
|
|
// Union of all proxied addresses across peers.
|
|
|
|
|
const seen = new Set<number>();
|
|
|
|
|
for (const set of this._proxiedByPeer.values()) {
|
|
|
|
|
for (const addr of set) seen.add(addr);
|
|
|
|
|
}
|
|
|
|
|
for (const addr of seen) {
|
|
|
|
|
const device = this._peerDeviceLookup.get(addr);
|
|
|
|
|
if (!device || typeof device.dumpRegisters !== 'function') continue;
|
|
|
|
|
let regs: Uint8Array;
|
|
|
|
|
try {
|
|
|
|
|
regs = device.dumpRegisters();
|
|
|
|
|
} catch {
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
const h = Esp32BridgeShim._hashRegs(regs);
|
|
|
|
|
if (this._lastDumpHash.get(addr) === h) continue;
|
|
|
|
|
this._lastDumpHash.set(addr, h);
|
|
|
|
|
this.bridge.updateProxyI2c(addr, regs);
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-03-22 12:25:58 +07:00
|
|
|
}
|
|
|
|
|
|
2026-05-19 09:05:34 +07:00
|
|
|
// ── LEDC duty handler ───────────────────────────────────────────────────
|
feat(esp32): SignalRouter — model the GPIO Matrix as first-class
Replaces the per-peripheral ad-hoc `_ledc_gpio_map` cache with a
proper signal-routing abstraction that mirrors the ESP32 SoC's
IO_MUX + GPIO Matrix exactly. Same idea as real silicon: signal
sources (LEDC channels, RMT, MCPWM, ...) → 40-entry routing table
→ GPIO pins.
Motivation (from user bug report in
velxio.dev/project/5218f9e3-136d-43b3-bba1-6cebde21e1a4): two
ESP32 servos on a solar-tracker visibly oscillated between two
positions instead of moving smoothly when the user changed LDR
sliders. Commit 77bf897 patched it (per-channel gpio memo +
broadcast guard) but the user requested a proper hardware-fidel
architecture, not patches.
Backend:
* `app/services/signal_router.py` — SignalRouter class. Forward
index (gpio → signal_id) + reverse index (signal_id → set of
gpios). `replace_snapshot()` returns the diff for the polling-
fallback path; future C plugin hook becomes a push without
touching this code.
* `app/services/esp32_signals.py` — Signal id constants from
ESP32 TRM (LEDC HS 72-79, LS 80-87) + `ledc_signal_for_channel()`
helper.
* `app/services/esp32_worker.py` — `_ledc_gpio_map` is gone;
`_refresh_ledc_gpio_map` replaced by `_refresh_signal_routing`
which emits `gpio_routing {gpio, signal_id}` events on diff.
The 0x5000 LEDC callback and the LEDC poll thread now emit
`ledc_duty {channel, duty_pct}` (canonical, no gpio) alongside
the legacy `ledc_update {channel, duty, gpio}` for back-compat
during rollout.
Frontend:
* `simulation/SignalRouter.ts` — 1-to-1 TS mirror of the Python
class. Same forward + reverse index; same `pinsForSignal` /
`updateRouting` / `clearRouting` API.
* `simulation/esp32-signals.ts` — Signal id constants, mirror
of the Python module.
* `simulation/Esp32Bridge.ts` — new `onLedcDuty`, `onGpioRouting`,
`onGpioRoutingClear` callbacks; handlers for the new event types.
* `store/useSimulatorStore.ts` — `makeLedcDutyHandler` looks up
pins via `router.pinsForSignal(ledcSignalForChannel(channel))`
and dispatches per pin. `makeGpioRoutingHandler` /
`makeGpioRoutingClearHandler` keep the mirror in sync. Per-board
`signalRouterMap` parallels `pinManagerMap` in lifecycle.
`makeLedcUpdateHandler` (and its memo workaround from 77bf897)
stays wired for back-compat during rollout; removed in a
follow-up commit once prod is verified stable on the new path.
Tests:
* `test/backend/unit/test_signal_router.py` (20 tests) covers
update/clear semantics, idempotency, multi-pin routing,
snapshot diff, channel↔signal-id helpers, and the multi-servo
regression scenario.
* `frontend/src/__tests__/SignalRouter.test.ts` (17 tests) is the
mirror — same scenarios on the TS side.
* `frontend/src/__tests__/esp32-multi-servo-gpio-matrix.test.ts`
(6 tests) drives the end-to-end SignalRouter handler pipeline,
asserts that two servos on GPIO 13/12 via LEDC channels 0/1
move independently (no mirroring), that re-routing carries
cleanly, and — critically — that `PinManager.broadcastPwm` is
never called.
Totals: +700 LOC, 1876 frontend tests pass (was 1853), 278 backend
unit tests pass (was 259).
Docs: ESP32_EMULATION.md §9.2 rewritten with the new architecture
diagram + a runbook for adding future peripherals through the
SignalRouter.
The C plugin hook in qemu-lcgamboa that would push gpio_out_sel
writes synchronously (eliminating the polling race window entirely)
is the next step — kept as a follow-up because the polling-fallback
path here already resolves the routing before each duty event
fires, so the bug is fixed end-to-end. The plugin work removes the
race condition fundamentally.
2026-05-17 10:00:53 +07:00
|
|
|
//
|
2026-05-19 09:05:34 +07:00
|
|
|
// Resolves a (channel, duty_pct) event from the worker into one or more
|
|
|
|
|
// (gpio_pin, duty_cycle) updates by consulting the per-board
|
|
|
|
|
// SignalRouter mirror. Replaces the legacy `ledc_update` path that
|
|
|
|
|
// embedded the gpio in the event and needed a per-channel memo + a
|
|
|
|
|
// PinManager.broadcastPwm fallback to survive the worker's gpio=-1
|
|
|
|
|
// race window.
|
2026-03-24 23:54:44 +07:00
|
|
|
|
feat(esp32): SignalRouter — model the GPIO Matrix as first-class
Replaces the per-peripheral ad-hoc `_ledc_gpio_map` cache with a
proper signal-routing abstraction that mirrors the ESP32 SoC's
IO_MUX + GPIO Matrix exactly. Same idea as real silicon: signal
sources (LEDC channels, RMT, MCPWM, ...) → 40-entry routing table
→ GPIO pins.
Motivation (from user bug report in
velxio.dev/project/5218f9e3-136d-43b3-bba1-6cebde21e1a4): two
ESP32 servos on a solar-tracker visibly oscillated between two
positions instead of moving smoothly when the user changed LDR
sliders. Commit 77bf897 patched it (per-channel gpio memo +
broadcast guard) but the user requested a proper hardware-fidel
architecture, not patches.
Backend:
* `app/services/signal_router.py` — SignalRouter class. Forward
index (gpio → signal_id) + reverse index (signal_id → set of
gpios). `replace_snapshot()` returns the diff for the polling-
fallback path; future C plugin hook becomes a push without
touching this code.
* `app/services/esp32_signals.py` — Signal id constants from
ESP32 TRM (LEDC HS 72-79, LS 80-87) + `ledc_signal_for_channel()`
helper.
* `app/services/esp32_worker.py` — `_ledc_gpio_map` is gone;
`_refresh_ledc_gpio_map` replaced by `_refresh_signal_routing`
which emits `gpio_routing {gpio, signal_id}` events on diff.
The 0x5000 LEDC callback and the LEDC poll thread now emit
`ledc_duty {channel, duty_pct}` (canonical, no gpio) alongside
the legacy `ledc_update {channel, duty, gpio}` for back-compat
during rollout.
Frontend:
* `simulation/SignalRouter.ts` — 1-to-1 TS mirror of the Python
class. Same forward + reverse index; same `pinsForSignal` /
`updateRouting` / `clearRouting` API.
* `simulation/esp32-signals.ts` — Signal id constants, mirror
of the Python module.
* `simulation/Esp32Bridge.ts` — new `onLedcDuty`, `onGpioRouting`,
`onGpioRoutingClear` callbacks; handlers for the new event types.
* `store/useSimulatorStore.ts` — `makeLedcDutyHandler` looks up
pins via `router.pinsForSignal(ledcSignalForChannel(channel))`
and dispatches per pin. `makeGpioRoutingHandler` /
`makeGpioRoutingClearHandler` keep the mirror in sync. Per-board
`signalRouterMap` parallels `pinManagerMap` in lifecycle.
`makeLedcUpdateHandler` (and its memo workaround from 77bf897)
stays wired for back-compat during rollout; removed in a
follow-up commit once prod is verified stable on the new path.
Tests:
* `test/backend/unit/test_signal_router.py` (20 tests) covers
update/clear semantics, idempotency, multi-pin routing,
snapshot diff, channel↔signal-id helpers, and the multi-servo
regression scenario.
* `frontend/src/__tests__/SignalRouter.test.ts` (17 tests) is the
mirror — same scenarios on the TS side.
* `frontend/src/__tests__/esp32-multi-servo-gpio-matrix.test.ts`
(6 tests) drives the end-to-end SignalRouter handler pipeline,
asserts that two servos on GPIO 13/12 via LEDC channels 0/1
move independently (no mirroring), that re-routing carries
cleanly, and — critically — that `PinManager.broadcastPwm` is
never called.
Totals: +700 LOC, 1876 frontend tests pass (was 1853), 278 backend
unit tests pass (was 259).
Docs: ESP32_EMULATION.md §9.2 rewritten with the new architecture
diagram + a runbook for adding future peripherals through the
SignalRouter.
The C plugin hook in qemu-lcgamboa that would push gpio_out_sel
writes synchronously (eliminating the polling race window entirely)
is the next step — kept as a follow-up because the polling-fallback
path here already resolves the routing before each duty event
fires, so the bug is fixed end-to-end. The plugin work removes the
race condition fundamentally.
2026-05-17 10:00:53 +07:00
|
|
|
function makeLedcDutyHandler(boardId: string) {
|
|
|
|
|
return (duty: { channel: number; duty_pct: number }) => {
|
|
|
|
|
const boardPm = pinManagerMap.get(boardId);
|
|
|
|
|
const router = signalRouterMap.get(boardId);
|
|
|
|
|
if (!boardPm || !router) return;
|
|
|
|
|
const dutyCycle = duty.duty_pct / 100;
|
|
|
|
|
const signalId = ledcSignalForChannel(duty.channel);
|
|
|
|
|
const pins = router.pinsForSignal(signalId);
|
|
|
|
|
// Multi-pin routing: one LEDC channel CAN legally drive multiple
|
|
|
|
|
// pins via the GPIO Matrix (rare but documented in TRM). Iterate
|
|
|
|
|
// all of them — each gets its own updatePwm call.
|
|
|
|
|
for (const pin of pins) {
|
|
|
|
|
boardPm.updatePwm(pin, dutyCycle);
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function makeGpioRoutingHandler(boardId: string) {
|
|
|
|
|
return (routing: { gpio: number; signal_id: number }) => {
|
|
|
|
|
signalRouterMap.get(boardId)?.updateRouting(routing.gpio, routing.signal_id);
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function makeGpioRoutingClearHandler(boardId: string) {
|
|
|
|
|
return (gpio: number) => {
|
|
|
|
|
signalRouterMap.get(boardId)?.clearRouting(gpio);
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
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>();
|
feat(esp32): SignalRouter — model the GPIO Matrix as first-class
Replaces the per-peripheral ad-hoc `_ledc_gpio_map` cache with a
proper signal-routing abstraction that mirrors the ESP32 SoC's
IO_MUX + GPIO Matrix exactly. Same idea as real silicon: signal
sources (LEDC channels, RMT, MCPWM, ...) → 40-entry routing table
→ GPIO pins.
Motivation (from user bug report in
velxio.dev/project/5218f9e3-136d-43b3-bba1-6cebde21e1a4): two
ESP32 servos on a solar-tracker visibly oscillated between two
positions instead of moving smoothly when the user changed LDR
sliders. Commit 77bf897 patched it (per-channel gpio memo +
broadcast guard) but the user requested a proper hardware-fidel
architecture, not patches.
Backend:
* `app/services/signal_router.py` — SignalRouter class. Forward
index (gpio → signal_id) + reverse index (signal_id → set of
gpios). `replace_snapshot()` returns the diff for the polling-
fallback path; future C plugin hook becomes a push without
touching this code.
* `app/services/esp32_signals.py` — Signal id constants from
ESP32 TRM (LEDC HS 72-79, LS 80-87) + `ledc_signal_for_channel()`
helper.
* `app/services/esp32_worker.py` — `_ledc_gpio_map` is gone;
`_refresh_ledc_gpio_map` replaced by `_refresh_signal_routing`
which emits `gpio_routing {gpio, signal_id}` events on diff.
The 0x5000 LEDC callback and the LEDC poll thread now emit
`ledc_duty {channel, duty_pct}` (canonical, no gpio) alongside
the legacy `ledc_update {channel, duty, gpio}` for back-compat
during rollout.
Frontend:
* `simulation/SignalRouter.ts` — 1-to-1 TS mirror of the Python
class. Same forward + reverse index; same `pinsForSignal` /
`updateRouting` / `clearRouting` API.
* `simulation/esp32-signals.ts` — Signal id constants, mirror
of the Python module.
* `simulation/Esp32Bridge.ts` — new `onLedcDuty`, `onGpioRouting`,
`onGpioRoutingClear` callbacks; handlers for the new event types.
* `store/useSimulatorStore.ts` — `makeLedcDutyHandler` looks up
pins via `router.pinsForSignal(ledcSignalForChannel(channel))`
and dispatches per pin. `makeGpioRoutingHandler` /
`makeGpioRoutingClearHandler` keep the mirror in sync. Per-board
`signalRouterMap` parallels `pinManagerMap` in lifecycle.
`makeLedcUpdateHandler` (and its memo workaround from 77bf897)
stays wired for back-compat during rollout; removed in a
follow-up commit once prod is verified stable on the new path.
Tests:
* `test/backend/unit/test_signal_router.py` (20 tests) covers
update/clear semantics, idempotency, multi-pin routing,
snapshot diff, channel↔signal-id helpers, and the multi-servo
regression scenario.
* `frontend/src/__tests__/SignalRouter.test.ts` (17 tests) is the
mirror — same scenarios on the TS side.
* `frontend/src/__tests__/esp32-multi-servo-gpio-matrix.test.ts`
(6 tests) drives the end-to-end SignalRouter handler pipeline,
asserts that two servos on GPIO 13/12 via LEDC channels 0/1
move independently (no mirroring), that re-routing carries
cleanly, and — critically — that `PinManager.broadcastPwm` is
never called.
Totals: +700 LOC, 1876 frontend tests pass (was 1853), 278 backend
unit tests pass (was 259).
Docs: ESP32_EMULATION.md §9.2 rewritten with the new architecture
diagram + a runbook for adding future peripherals through the
SignalRouter.
The C plugin hook in qemu-lcgamboa that would push gpio_out_sel
writes synchronously (eliminating the polling race window entirely)
is the next step — kept as a follow-up because the polling-fallback
path here already resolves the routing before each duty event
fires, so the bug is fixed end-to-end. The plugin work removes the
race condition fundamentally.
2026-05-17 10:00:53 +07:00
|
|
|
// Per-board ESP32 GPIO Matrix mirror. Populated for boards whose kind
|
|
|
|
|
// is an ESP32 variant (others don't have a GPIO Matrix in the same
|
|
|
|
|
// sense; AVR/RP2040 wire signals to pins directly without the IO_MUX).
|
|
|
|
|
// Lifecycle parallels pinManagerMap — created in addBoard / setBoardType
|
|
|
|
|
// / initSimulator, deleted in removeBoard / cleanup.
|
|
|
|
|
const signalRouterMap = new Map<string, SignalRouter>();
|
2026-03-13 09:39:04 +07:00
|
|
|
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
|
|
|
|
2026-05-19 04:23:48 +07:00
|
|
|
if (isPiBoardKind(boardKind)) {
|
2026-05-18 20:41:18 +07:00
|
|
|
const bridge = new RaspberryPi3Bridge(id, boardKind);
|
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);
|
fix(spice+pipeline): LED visualization, INPUT_PULLUP, ESP32-C3, PWM fade, examples
End-to-end pipeline fixes uncovered while auditing the /examples gallery.
Each bug shipped past green unit + snapshot tests because none of those run
firmware + render LEDs. Added scripts/visual-led-test.mjs as a CDP-driven
visual harness that loads each example, runs the simulator, samples
`wokwi-led.brightness`, and asserts toggle / gradient / initial-off
invariants — exits non-zero on any regression.
Frontend simulator
- PinManager.updatePort: new optional ddrMask param. A pin is added to
`outputPins` only if the DDR bit is set, so the PORTx write that
enables INPUT_PULLUP (DDR=0, PORT=1) no longer falsely marks the pin
as MCU output. AVRSimulator now reads DDRB/C/D (0x24/0x27/0x2A on
Uno/Nano, 0x37 on ATtiny85, per-port table on Mega) and forwards it.
- AVRSimulator: pass DDR mask alongside every port-listener fire.
- BasicParts pushbutton{,-6mm}: seed pin HIGH in attachEvents so
`digitalRead()` returns HIGH while idle. avr8js doesn't auto-simulate
INPUT_PULLUP — without this the firmware reads LOW from boot and
thinks the button is permanently pressed (the "LED is always on,
pressing does nothing" UX bug).
- connectMcuEdgesToService: suppress synthetic digital edges on pins
with active PWM, AND subscribe to onPwmChange to re-tick the netlist
on duty changes. Fade-LED now produces a true gradient (6 distinct
brightness levels across a fade cycle) instead of a binary 0/full
toggle.
- CircuitSimulationService.handleMcuEdge: replace single-slot
pendingMcuEdge with a per-pin Map. Multiple pins toggling during the
same in-flight tick used to overwrite each other; now every pin's
most-recent edge replays after the tick. Fixes Traffic-Light RED→
YELLOW→GREEN sequencing.
- NetlistBuilder: new sanitizeSpiceId() helper replaces hyphens with
underscores in V-source names. ngspice's interactive `alter` command
treats `-` as an operator and silently no-ops on hyphenated source
names, so mid-simulation MCU pin transitions stopped propagating
after the first solve. MixedModeScheduler.onMcuPinChange and
CircuitSimulationService self-heal use the same sanitizer so names
stay consistent across emit/alter/lookup. Also added a regex-based
fallback in step 2 so any board pin matching `GND.\d+` canonicalises
to net "0" — ESP32-C3 dev kits expose up to 10 GND pins and the
per-board `groundPinNames` list missed several, leaving wires
floating instead of grounded.
- collectPinStates: emit V-sources only for pins in `outputPins`, not
every wired board pin. Leaves INPUT pins (analog sensors on A0,
pull-down dividers, etc.) free for the SPICE solver instead of being
shorted to 0 V by an ideal MCU V-source.
- start.ts: extended __spiceDebug to also expose outputPinsByBoard +
nodeVoltages + pinNetMapEntries for the visual harness.
- ESP32 / RP2040 / RISC-V / C3 simulators: pass `'mcu'` source flag to
triggerPinChange / setPinState so the new outputPins tracking fires
on those boards too (was AVR-only before).
- useSimulatorStore: stopBoard/resetBoard call pm.resetPinStates() so
outputPins clears between runs; Esp32Bridge.onPinChange passes the
`'mcu'` flag in all three places it's wired.
- types/board.ts: ATtiny85 FQBN `clock=internal16mhz` →
`clock=16pll` (ATTinyCore 1.5.2 renamed the option).
Backend
- esp-idf-template/main/CMakeLists.txt: skip the
`-DLED_BUILTIN=2` fallback for esp32c3 and esp32s3 targets. Both
variants already define LED_BUILTIN in pins_arduino.h via a
self-define macro (`#define LED_BUILTIN LED_BUILTIN` + `static const
uint8_t LED_BUILTIN = ...;`). Pre-defining the symbol from the
command line expanded the static-const declaration to
`static const uint8_t 2 = ...;` — a syntax error that broke every
ESP32-C3 / S3 build (`expected unqualified-id before numeric
constant`).
Examples
- examples.ts: bulk-fix 72 wire endpoints that referenced
`componentId: 'nano-rp2040'` / `'esp32-c3'` etc. (boards that don't
exist on the canvas). Replaced with `'arduino-uno'` (the canvas
board-id convention) and converted `D<n>` pin names to `GP<n>` for
Pico-style boards. Affects pico-blink, pico-i2c-scanner,
pico-i2c-rtc-read, pico-spi-loopback, c3-blink and others.
Tests
- scripts/visual-led-test.mjs: CDP-driven harness. Default suite covers
Blink (single-pin), Button (idle-OFF invariant — catches the
INPUT_PULLUP regression), Traffic-Light (multi-pin sequencing),
Fade-LED (PWM gradient — ≥3 distinct levels), RGB-LED (≥3 PWM pins
driven). Run via `npm --prefix frontend run test:visual` against a
Chrome on `:9222` + vite on `:5174` + backend on `:8001`.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-19 07:47:45 +07:00
|
|
|
if (boardPm) boardPm.triggerPinChange(gpioPin, state, 'mcu');
|
2026-03-14 22:05:35 +07:00
|
|
|
};
|
2026-05-23 00:58:24 +07:00
|
|
|
// Wire scope sampling for ESP32 (GPIO transitions + synthesized
|
|
|
|
|
// UART TX bits). Mirrors what AVR/RP2040 simulators get for free
|
|
|
|
|
// by passing the oscilloscope callback into createSimulator().
|
|
|
|
|
bridge.onPinChangeWithTime = getOscilloscopeCallback(id);
|
2026-03-14 22:05:35 +07:00
|
|
|
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 } : {}) };
|
|
|
|
|
});
|
|
|
|
|
};
|
feat(esp32): SignalRouter — model the GPIO Matrix as first-class
Replaces the per-peripheral ad-hoc `_ledc_gpio_map` cache with a
proper signal-routing abstraction that mirrors the ESP32 SoC's
IO_MUX + GPIO Matrix exactly. Same idea as real silicon: signal
sources (LEDC channels, RMT, MCPWM, ...) → 40-entry routing table
→ GPIO pins.
Motivation (from user bug report in
velxio.dev/project/5218f9e3-136d-43b3-bba1-6cebde21e1a4): two
ESP32 servos on a solar-tracker visibly oscillated between two
positions instead of moving smoothly when the user changed LDR
sliders. Commit 77bf897 patched it (per-channel gpio memo +
broadcast guard) but the user requested a proper hardware-fidel
architecture, not patches.
Backend:
* `app/services/signal_router.py` — SignalRouter class. Forward
index (gpio → signal_id) + reverse index (signal_id → set of
gpios). `replace_snapshot()` returns the diff for the polling-
fallback path; future C plugin hook becomes a push without
touching this code.
* `app/services/esp32_signals.py` — Signal id constants from
ESP32 TRM (LEDC HS 72-79, LS 80-87) + `ledc_signal_for_channel()`
helper.
* `app/services/esp32_worker.py` — `_ledc_gpio_map` is gone;
`_refresh_ledc_gpio_map` replaced by `_refresh_signal_routing`
which emits `gpio_routing {gpio, signal_id}` events on diff.
The 0x5000 LEDC callback and the LEDC poll thread now emit
`ledc_duty {channel, duty_pct}` (canonical, no gpio) alongside
the legacy `ledc_update {channel, duty, gpio}` for back-compat
during rollout.
Frontend:
* `simulation/SignalRouter.ts` — 1-to-1 TS mirror of the Python
class. Same forward + reverse index; same `pinsForSignal` /
`updateRouting` / `clearRouting` API.
* `simulation/esp32-signals.ts` — Signal id constants, mirror
of the Python module.
* `simulation/Esp32Bridge.ts` — new `onLedcDuty`, `onGpioRouting`,
`onGpioRoutingClear` callbacks; handlers for the new event types.
* `store/useSimulatorStore.ts` — `makeLedcDutyHandler` looks up
pins via `router.pinsForSignal(ledcSignalForChannel(channel))`
and dispatches per pin. `makeGpioRoutingHandler` /
`makeGpioRoutingClearHandler` keep the mirror in sync. Per-board
`signalRouterMap` parallels `pinManagerMap` in lifecycle.
`makeLedcUpdateHandler` (and its memo workaround from 77bf897)
stays wired for back-compat during rollout; removed in a
follow-up commit once prod is verified stable on the new path.
Tests:
* `test/backend/unit/test_signal_router.py` (20 tests) covers
update/clear semantics, idempotency, multi-pin routing,
snapshot diff, channel↔signal-id helpers, and the multi-servo
regression scenario.
* `frontend/src/__tests__/SignalRouter.test.ts` (17 tests) is the
mirror — same scenarios on the TS side.
* `frontend/src/__tests__/esp32-multi-servo-gpio-matrix.test.ts`
(6 tests) drives the end-to-end SignalRouter handler pipeline,
asserts that two servos on GPIO 13/12 via LEDC channels 0/1
move independently (no mirroring), that re-routing carries
cleanly, and — critically — that `PinManager.broadcastPwm` is
never called.
Totals: +700 LOC, 1876 frontend tests pass (was 1853), 278 backend
unit tests pass (was 259).
Docs: ESP32_EMULATION.md §9.2 rewritten with the new architecture
diagram + a runbook for adding future peripherals through the
SignalRouter.
The C plugin hook in qemu-lcgamboa that would push gpio_out_sel
writes synchronously (eliminating the polling race window entirely)
is the next step — kept as a follow-up because the polling-fallback
path here already resolves the routing before each duty event
fires, so the bug is fixed end-to-end. The plugin work removes the
race condition fundamentally.
2026-05-17 10:00:53 +07:00
|
|
|
signalRouterMap.set(id, new SignalRouter());
|
|
|
|
|
bridge.onLedcDuty = makeLedcDutyHandler(id);
|
|
|
|
|
bridge.onGpioRouting = makeGpioRoutingHandler(id);
|
|
|
|
|
bridge.onGpioRoutingClear = makeGpioRoutingClearHandler(id);
|
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;
|
feat(i2c): cross-board bridging across all velxio boards (AVR/RP2040/ESP32 xtensa+riscv)
Closes the remaining gaps in cross-board I2C so any topology of
supported boards (Uno↔ESP32, two ESP32s, Uno↔Uno↔Uno, ESP32-C3
connected to anything, etc.) works end-to-end with all I2C
components including write-only sinks (SSD1306, PCF8574, LCD-I2C).
Implementation (6 phases):
1. **BFS routing in I2CBusManager**: connectToSlave + handleExternalConnect
walk the bridge graph with a visited Set so multi-hop chains
(A↔B↔C with the device on C) resolve transparently. A new
forwarder-device shim is installed at intermediate hops so the
existing handleExternalWrite/Read/Stop machinery routes
through without per-method visited tracking.
2. **Per-peer proxy ownership in Esp32BridgeShim**: replaces the
global _proxiedAddrs Set with _proxiedByPeer Map so concurrent
bridges to the same ESP32 (e.g. wired to both Uno and Pico)
don't wipe each other's proxies on teardown. Interconnect's
per-wire teardown calls clearProxiesForPeer(peerBus) instead of
clearAllProxies.
3. **BFS-aware proxy sync**: syncProxyFromPeer now walks the peer
bus + its transitive bridges, so an ESP32 sees devices on
boards two or more hops away. _peerDeviceLookup keeps a flat
addr → device map for write-forwarding and resync.
4. **Periodic resync (250 ms)**: Esp32BridgeShim runs a setInterval
while any proxy is live, re-dumping each device with
dumpRegisters() and pushing updateProxyI2c only when an XOR-
stride hash changes. This keeps RTC time advancing visible to
ESP32 firmware without flooding the WS pipe with static
calibration dumps. Hash is primed during initial sync so the
first tick doesn't push a redundant identical buffer.
5. **Write-forwarding ProxySlave → peer**: backend ProxySlave
buffers write bytes during the transaction and emits a
`proxy_i2c_complete` event on STOP / repeated-START. Frontend
Esp32Bridge dispatches the event to a new onProxyI2cComplete
callback; the shim replays the byte sequence on the actual
peer I2CDevice via writeByte() + stop(). Makes ESP32 firmware
writes to peer SSD1306 actually repaint the OLED, peer PCF8574
latch updates, peer I2CMemoryDevice register mutations propagate.
6. **ESP32-C3 routed as bridge**: Interconnect.isBrowserSim no
longer claims c3/xiao-c3/c3-supermini — they were already
going through Esp32Bridge per the store's ESP32_RISCV_KINDS
routing, but Interconnect was treating them as browser sims
which broke proxy install. isEsp32Bridge now correctly
includes c3 family + ESP32-S3 + Arduino Nano ESP32.
Defensive: addBoard now disposes any existing shim's proxies
before overwriting simulatorMap entry so test reruns don't leak
timers.
Tests:
- 4 BFS multi-hop tests (i2c-multi-board-slave-gap.test.ts)
- 11 cross-board scenarios + per-peer + write-forward + resync
(i2c-esp32-multiboard-bridge.test.ts)
- 1 real-firmware E2E for write-forward via QEMU (compile +
load + observe proxy_i2c_complete arriving with the byte)
- New sketch fixture: esp32_i2c_write_to_peer.ino
Result: 90 test files / 1295 tests pass / 0 fail.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 03:51:39 +07:00
|
|
|
// If a shim already exists for this id (e.g. tests recreate the
|
|
|
|
|
// same kind after reset), dispose any active proxies / timers
|
|
|
|
|
// so the orphaned instance doesn't keep firing.
|
|
|
|
|
const existingShim = simulatorMap.get(id) as any;
|
|
|
|
|
if (existingShim?.clearAllProxies) {
|
|
|
|
|
try { existingShim.clearAllProxies(); } catch { /* ignore */ }
|
|
|
|
|
}
|
2026-03-22 12:25:58 +07:00
|
|
|
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-05-15 08:38:34 +07:00
|
|
|
set((s) => {
|
|
|
|
|
// If there's no current active board (or the stored id doesn't point
|
|
|
|
|
// to one that exists), promote the new board to active. Without this,
|
|
|
|
|
// an agent that does add_board → compile_sketch fails on step 2 with
|
|
|
|
|
// "no active board on the canvas" and has to spend a turn on
|
|
|
|
|
// set_active_board. Manual placements via the UI already auto-active
|
|
|
|
|
// through the picker; this just closes the API gap.
|
|
|
|
|
const stillExists = s.boards.some((b) => b.id === s.activeBoardId);
|
|
|
|
|
const nextActive = stillExists ? s.activeBoardId : id;
|
|
|
|
|
return { boards: [...s.boards, newBoard], activeBoardId: nextActive };
|
|
|
|
|
});
|
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
|
2026-05-19 04:23:48 +07:00
|
|
|
if (isPiBoardKind(boardKind)) {
|
2026-03-16 00:04:01 +07:00
|
|
|
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);
|
feat(esp32): SignalRouter — model the GPIO Matrix as first-class
Replaces the per-peripheral ad-hoc `_ledc_gpio_map` cache with a
proper signal-routing abstraction that mirrors the ESP32 SoC's
IO_MUX + GPIO Matrix exactly. Same idea as real silicon: signal
sources (LEDC channels, RMT, MCPWM, ...) → 40-entry routing table
→ GPIO pins.
Motivation (from user bug report in
velxio.dev/project/5218f9e3-136d-43b3-bba1-6cebde21e1a4): two
ESP32 servos on a solar-tracker visibly oscillated between two
positions instead of moving smoothly when the user changed LDR
sliders. Commit 77bf897 patched it (per-channel gpio memo +
broadcast guard) but the user requested a proper hardware-fidel
architecture, not patches.
Backend:
* `app/services/signal_router.py` — SignalRouter class. Forward
index (gpio → signal_id) + reverse index (signal_id → set of
gpios). `replace_snapshot()` returns the diff for the polling-
fallback path; future C plugin hook becomes a push without
touching this code.
* `app/services/esp32_signals.py` — Signal id constants from
ESP32 TRM (LEDC HS 72-79, LS 80-87) + `ledc_signal_for_channel()`
helper.
* `app/services/esp32_worker.py` — `_ledc_gpio_map` is gone;
`_refresh_ledc_gpio_map` replaced by `_refresh_signal_routing`
which emits `gpio_routing {gpio, signal_id}` events on diff.
The 0x5000 LEDC callback and the LEDC poll thread now emit
`ledc_duty {channel, duty_pct}` (canonical, no gpio) alongside
the legacy `ledc_update {channel, duty, gpio}` for back-compat
during rollout.
Frontend:
* `simulation/SignalRouter.ts` — 1-to-1 TS mirror of the Python
class. Same forward + reverse index; same `pinsForSignal` /
`updateRouting` / `clearRouting` API.
* `simulation/esp32-signals.ts` — Signal id constants, mirror
of the Python module.
* `simulation/Esp32Bridge.ts` — new `onLedcDuty`, `onGpioRouting`,
`onGpioRoutingClear` callbacks; handlers for the new event types.
* `store/useSimulatorStore.ts` — `makeLedcDutyHandler` looks up
pins via `router.pinsForSignal(ledcSignalForChannel(channel))`
and dispatches per pin. `makeGpioRoutingHandler` /
`makeGpioRoutingClearHandler` keep the mirror in sync. Per-board
`signalRouterMap` parallels `pinManagerMap` in lifecycle.
`makeLedcUpdateHandler` (and its memo workaround from 77bf897)
stays wired for back-compat during rollout; removed in a
follow-up commit once prod is verified stable on the new path.
Tests:
* `test/backend/unit/test_signal_router.py` (20 tests) covers
update/clear semantics, idempotency, multi-pin routing,
snapshot diff, channel↔signal-id helpers, and the multi-servo
regression scenario.
* `frontend/src/__tests__/SignalRouter.test.ts` (17 tests) is the
mirror — same scenarios on the TS side.
* `frontend/src/__tests__/esp32-multi-servo-gpio-matrix.test.ts`
(6 tests) drives the end-to-end SignalRouter handler pipeline,
asserts that two servos on GPIO 13/12 via LEDC channels 0/1
move independently (no mirroring), that re-routing carries
cleanly, and — critically — that `PinManager.broadcastPwm` is
never called.
Totals: +700 LOC, 1876 frontend tests pass (was 1853), 278 backend
unit tests pass (was 259).
Docs: ESP32_EMULATION.md §9.2 rewritten with the new architecture
diagram + a runbook for adding future peripherals through the
SignalRouter.
The C plugin hook in qemu-lcgamboa that would push gpio_out_sel
writes synchronously (eliminating the polling race window entirely)
is the next step — kept as a follow-up because the polling-fallback
path here already resolves the routing before each duty event
fires, so the bug is fixed end-to-end. The plugin work removes the
race condition fundamentally.
2026-05-17 10:00:53 +07:00
|
|
|
signalRouterMap.delete(boardId);
|
2026-03-13 09:39:04 +07:00
|
|
|
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-05-19 04:23:48 +07:00
|
|
|
boardType: (isPiBoardKind(board.boardKind)
|
2026-04-22 02:45:45 +07:00
|
|
|
? '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);
|
2026-05-19 04:23:48 +07:00
|
|
|
if (sim && !isPiBoardKind(board.boardKind)) {
|
2026-03-14 06:35:48 +07:00
|
|
|
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;
|
|
|
|
|
|
2026-05-19 04:23:48 +07:00
|
|
|
if (isPiBoardKind(board.boardKind)) {
|
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;
|
|
|
|
|
|
2026-05-19 04:23:48 +07:00
|
|
|
if (isPiBoardKind(board.boardKind)) {
|
2026-03-13 09:39:04 +07:00
|
|
|
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
|
|
|
|
fix(spice+pipeline): LED visualization, INPUT_PULLUP, ESP32-C3, PWM fade, examples
End-to-end pipeline fixes uncovered while auditing the /examples gallery.
Each bug shipped past green unit + snapshot tests because none of those run
firmware + render LEDs. Added scripts/visual-led-test.mjs as a CDP-driven
visual harness that loads each example, runs the simulator, samples
`wokwi-led.brightness`, and asserts toggle / gradient / initial-off
invariants — exits non-zero on any regression.
Frontend simulator
- PinManager.updatePort: new optional ddrMask param. A pin is added to
`outputPins` only if the DDR bit is set, so the PORTx write that
enables INPUT_PULLUP (DDR=0, PORT=1) no longer falsely marks the pin
as MCU output. AVRSimulator now reads DDRB/C/D (0x24/0x27/0x2A on
Uno/Nano, 0x37 on ATtiny85, per-port table on Mega) and forwards it.
- AVRSimulator: pass DDR mask alongside every port-listener fire.
- BasicParts pushbutton{,-6mm}: seed pin HIGH in attachEvents so
`digitalRead()` returns HIGH while idle. avr8js doesn't auto-simulate
INPUT_PULLUP — without this the firmware reads LOW from boot and
thinks the button is permanently pressed (the "LED is always on,
pressing does nothing" UX bug).
- connectMcuEdgesToService: suppress synthetic digital edges on pins
with active PWM, AND subscribe to onPwmChange to re-tick the netlist
on duty changes. Fade-LED now produces a true gradient (6 distinct
brightness levels across a fade cycle) instead of a binary 0/full
toggle.
- CircuitSimulationService.handleMcuEdge: replace single-slot
pendingMcuEdge with a per-pin Map. Multiple pins toggling during the
same in-flight tick used to overwrite each other; now every pin's
most-recent edge replays after the tick. Fixes Traffic-Light RED→
YELLOW→GREEN sequencing.
- NetlistBuilder: new sanitizeSpiceId() helper replaces hyphens with
underscores in V-source names. ngspice's interactive `alter` command
treats `-` as an operator and silently no-ops on hyphenated source
names, so mid-simulation MCU pin transitions stopped propagating
after the first solve. MixedModeScheduler.onMcuPinChange and
CircuitSimulationService self-heal use the same sanitizer so names
stay consistent across emit/alter/lookup. Also added a regex-based
fallback in step 2 so any board pin matching `GND.\d+` canonicalises
to net "0" — ESP32-C3 dev kits expose up to 10 GND pins and the
per-board `groundPinNames` list missed several, leaving wires
floating instead of grounded.
- collectPinStates: emit V-sources only for pins in `outputPins`, not
every wired board pin. Leaves INPUT pins (analog sensors on A0,
pull-down dividers, etc.) free for the SPICE solver instead of being
shorted to 0 V by an ideal MCU V-source.
- start.ts: extended __spiceDebug to also expose outputPinsByBoard +
nodeVoltages + pinNetMapEntries for the visual harness.
- ESP32 / RP2040 / RISC-V / C3 simulators: pass `'mcu'` source flag to
triggerPinChange / setPinState so the new outputPins tracking fires
on those boards too (was AVR-only before).
- useSimulatorStore: stopBoard/resetBoard call pm.resetPinStates() so
outputPins clears between runs; Esp32Bridge.onPinChange passes the
`'mcu'` flag in all three places it's wired.
- types/board.ts: ATtiny85 FQBN `clock=internal16mhz` →
`clock=16pll` (ATTinyCore 1.5.2 renamed the option).
Backend
- esp-idf-template/main/CMakeLists.txt: skip the
`-DLED_BUILTIN=2` fallback for esp32c3 and esp32s3 targets. Both
variants already define LED_BUILTIN in pins_arduino.h via a
self-define macro (`#define LED_BUILTIN LED_BUILTIN` + `static const
uint8_t LED_BUILTIN = ...;`). Pre-defining the symbol from the
command line expanded the static-const declaration to
`static const uint8_t 2 = ...;` — a syntax error that broke every
ESP32-C3 / S3 build (`expected unqualified-id before numeric
constant`).
Examples
- examples.ts: bulk-fix 72 wire endpoints that referenced
`componentId: 'nano-rp2040'` / `'esp32-c3'` etc. (boards that don't
exist on the canvas). Replaced with `'arduino-uno'` (the canvas
board-id convention) and converted `D<n>` pin names to `GP<n>` for
Pico-style boards. Affects pico-blink, pico-i2c-scanner,
pico-i2c-rtc-read, pico-spi-loopback, c3-blink and others.
Tests
- scripts/visual-led-test.mjs: CDP-driven harness. Default suite covers
Blink (single-pin), Button (idle-OFF invariant — catches the
INPUT_PULLUP regression), Traffic-Light (multi-pin sequencing),
Fade-LED (PWM gradient — ≥3 distinct levels), RGB-LED (≥3 PWM pins
driven). Run via `npm --prefix frontend run test:visual` against a
Chrome on `:9222` + vite on `:5174` + backend on `:8001`.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-19 07:47:45 +07:00
|
|
|
// Drop MCU-output classification so the next Run starts clean and
|
|
|
|
|
// collectPinStates doesn't emit stale V-sources before the new
|
|
|
|
|
// firmware has driven any pins.
|
|
|
|
|
getBoardPinManager(boardId)?.resetPinStates();
|
|
|
|
|
|
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);
|
|
|
|
|
}
|
2026-05-19 04:23:48 +07:00
|
|
|
} else if (!isPiBoardKind(board.boardKind)) {
|
2026-03-13 09:39:04 +07:00
|
|
|
const sim = getBoardSimulator(boardId);
|
|
|
|
|
if (sim) {
|
|
|
|
|
sim.reset();
|
fix(spice+pipeline): LED visualization, INPUT_PULLUP, ESP32-C3, PWM fade, examples
End-to-end pipeline fixes uncovered while auditing the /examples gallery.
Each bug shipped past green unit + snapshot tests because none of those run
firmware + render LEDs. Added scripts/visual-led-test.mjs as a CDP-driven
visual harness that loads each example, runs the simulator, samples
`wokwi-led.brightness`, and asserts toggle / gradient / initial-off
invariants — exits non-zero on any regression.
Frontend simulator
- PinManager.updatePort: new optional ddrMask param. A pin is added to
`outputPins` only if the DDR bit is set, so the PORTx write that
enables INPUT_PULLUP (DDR=0, PORT=1) no longer falsely marks the pin
as MCU output. AVRSimulator now reads DDRB/C/D (0x24/0x27/0x2A on
Uno/Nano, 0x37 on ATtiny85, per-port table on Mega) and forwards it.
- AVRSimulator: pass DDR mask alongside every port-listener fire.
- BasicParts pushbutton{,-6mm}: seed pin HIGH in attachEvents so
`digitalRead()` returns HIGH while idle. avr8js doesn't auto-simulate
INPUT_PULLUP — without this the firmware reads LOW from boot and
thinks the button is permanently pressed (the "LED is always on,
pressing does nothing" UX bug).
- connectMcuEdgesToService: suppress synthetic digital edges on pins
with active PWM, AND subscribe to onPwmChange to re-tick the netlist
on duty changes. Fade-LED now produces a true gradient (6 distinct
brightness levels across a fade cycle) instead of a binary 0/full
toggle.
- CircuitSimulationService.handleMcuEdge: replace single-slot
pendingMcuEdge with a per-pin Map. Multiple pins toggling during the
same in-flight tick used to overwrite each other; now every pin's
most-recent edge replays after the tick. Fixes Traffic-Light RED→
YELLOW→GREEN sequencing.
- NetlistBuilder: new sanitizeSpiceId() helper replaces hyphens with
underscores in V-source names. ngspice's interactive `alter` command
treats `-` as an operator and silently no-ops on hyphenated source
names, so mid-simulation MCU pin transitions stopped propagating
after the first solve. MixedModeScheduler.onMcuPinChange and
CircuitSimulationService self-heal use the same sanitizer so names
stay consistent across emit/alter/lookup. Also added a regex-based
fallback in step 2 so any board pin matching `GND.\d+` canonicalises
to net "0" — ESP32-C3 dev kits expose up to 10 GND pins and the
per-board `groundPinNames` list missed several, leaving wires
floating instead of grounded.
- collectPinStates: emit V-sources only for pins in `outputPins`, not
every wired board pin. Leaves INPUT pins (analog sensors on A0,
pull-down dividers, etc.) free for the SPICE solver instead of being
shorted to 0 V by an ideal MCU V-source.
- start.ts: extended __spiceDebug to also expose outputPinsByBoard +
nodeVoltages + pinNetMapEntries for the visual harness.
- ESP32 / RP2040 / RISC-V / C3 simulators: pass `'mcu'` source flag to
triggerPinChange / setPinState so the new outputPins tracking fires
on those boards too (was AVR-only before).
- useSimulatorStore: stopBoard/resetBoard call pm.resetPinStates() so
outputPins clears between runs; Esp32Bridge.onPinChange passes the
`'mcu'` flag in all three places it's wired.
- types/board.ts: ATtiny85 FQBN `clock=internal16mhz` →
`clock=16pll` (ATTinyCore 1.5.2 renamed the option).
Backend
- esp-idf-template/main/CMakeLists.txt: skip the
`-DLED_BUILTIN=2` fallback for esp32c3 and esp32s3 targets. Both
variants already define LED_BUILTIN in pins_arduino.h via a
self-define macro (`#define LED_BUILTIN LED_BUILTIN` + `static const
uint8_t LED_BUILTIN = ...;`). Pre-defining the symbol from the
command line expanded the static-const declaration to
`static const uint8_t 2 = ...;` — a syntax error that broke every
ESP32-C3 / S3 build (`expected unqualified-id before numeric
constant`).
Examples
- examples.ts: bulk-fix 72 wire endpoints that referenced
`componentId: 'nano-rp2040'` / `'esp32-c3'` etc. (boards that don't
exist on the canvas). Replaced with `'arduino-uno'` (the canvas
board-id convention) and converted `D<n>` pin names to `GP<n>` for
Pico-style boards. Affects pico-blink, pico-i2c-scanner,
pico-i2c-rtc-read, pico-spi-loopback, c3-blink and others.
Tests
- scripts/visual-led-test.mjs: CDP-driven harness. Default suite covers
Blink (single-pin), Button (idle-OFF invariant — catches the
INPUT_PULLUP regression), Traffic-Light (multi-pin sequencing),
Fade-LED (PWM gradient — ≥3 distinct levels), RGB-LED (≥3 PWM pins
driven). Run via `npm --prefix frontend run test:visual` against a
Chrome on `:9222` + vite on `:5174` + backend on `:8001`.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-19 07:47:45 +07:00
|
|
|
// Drop MCU-output classification so the new program starts
|
|
|
|
|
// clean — no stale V-sources from the previous run.
|
|
|
|
|
getBoardPinManager(boardId)?.resetPinStates();
|
2026-03-13 09:39:04 +07:00
|
|
|
// 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);
|
fix(spice+pipeline): LED visualization, INPUT_PULLUP, ESP32-C3, PWM fade, examples
End-to-end pipeline fixes uncovered while auditing the /examples gallery.
Each bug shipped past green unit + snapshot tests because none of those run
firmware + render LEDs. Added scripts/visual-led-test.mjs as a CDP-driven
visual harness that loads each example, runs the simulator, samples
`wokwi-led.brightness`, and asserts toggle / gradient / initial-off
invariants — exits non-zero on any regression.
Frontend simulator
- PinManager.updatePort: new optional ddrMask param. A pin is added to
`outputPins` only if the DDR bit is set, so the PORTx write that
enables INPUT_PULLUP (DDR=0, PORT=1) no longer falsely marks the pin
as MCU output. AVRSimulator now reads DDRB/C/D (0x24/0x27/0x2A on
Uno/Nano, 0x37 on ATtiny85, per-port table on Mega) and forwards it.
- AVRSimulator: pass DDR mask alongside every port-listener fire.
- BasicParts pushbutton{,-6mm}: seed pin HIGH in attachEvents so
`digitalRead()` returns HIGH while idle. avr8js doesn't auto-simulate
INPUT_PULLUP — without this the firmware reads LOW from boot and
thinks the button is permanently pressed (the "LED is always on,
pressing does nothing" UX bug).
- connectMcuEdgesToService: suppress synthetic digital edges on pins
with active PWM, AND subscribe to onPwmChange to re-tick the netlist
on duty changes. Fade-LED now produces a true gradient (6 distinct
brightness levels across a fade cycle) instead of a binary 0/full
toggle.
- CircuitSimulationService.handleMcuEdge: replace single-slot
pendingMcuEdge with a per-pin Map. Multiple pins toggling during the
same in-flight tick used to overwrite each other; now every pin's
most-recent edge replays after the tick. Fixes Traffic-Light RED→
YELLOW→GREEN sequencing.
- NetlistBuilder: new sanitizeSpiceId() helper replaces hyphens with
underscores in V-source names. ngspice's interactive `alter` command
treats `-` as an operator and silently no-ops on hyphenated source
names, so mid-simulation MCU pin transitions stopped propagating
after the first solve. MixedModeScheduler.onMcuPinChange and
CircuitSimulationService self-heal use the same sanitizer so names
stay consistent across emit/alter/lookup. Also added a regex-based
fallback in step 2 so any board pin matching `GND.\d+` canonicalises
to net "0" — ESP32-C3 dev kits expose up to 10 GND pins and the
per-board `groundPinNames` list missed several, leaving wires
floating instead of grounded.
- collectPinStates: emit V-sources only for pins in `outputPins`, not
every wired board pin. Leaves INPUT pins (analog sensors on A0,
pull-down dividers, etc.) free for the SPICE solver instead of being
shorted to 0 V by an ideal MCU V-source.
- start.ts: extended __spiceDebug to also expose outputPinsByBoard +
nodeVoltages + pinNetMapEntries for the visual harness.
- ESP32 / RP2040 / RISC-V / C3 simulators: pass `'mcu'` source flag to
triggerPinChange / setPinState so the new outputPins tracking fires
on those boards too (was AVR-only before).
- useSimulatorStore: stopBoard/resetBoard call pm.resetPinStates() so
outputPins clears between runs; Esp32Bridge.onPinChange passes the
`'mcu'` flag in all three places it's wired.
- types/board.ts: ATtiny85 FQBN `clock=internal16mhz` →
`clock=16pll` (ATTinyCore 1.5.2 renamed the option).
Backend
- esp-idf-template/main/CMakeLists.txt: skip the
`-DLED_BUILTIN=2` fallback for esp32c3 and esp32s3 targets. Both
variants already define LED_BUILTIN in pins_arduino.h via a
self-define macro (`#define LED_BUILTIN LED_BUILTIN` + `static const
uint8_t LED_BUILTIN = ...;`). Pre-defining the symbol from the
command line expanded the static-const declaration to
`static const uint8_t 2 = ...;` — a syntax error that broke every
ESP32-C3 / S3 build (`expected unqualified-id before numeric
constant`).
Examples
- examples.ts: bulk-fix 72 wire endpoints that referenced
`componentId: 'nano-rp2040'` / `'esp32-c3'` etc. (boards that don't
exist on the canvas). Replaced with `'arduino-uno'` (the canvas
board-id convention) and converted `D<n>` pin names to `GP<n>` for
Pico-style boards. Affects pico-blink, pico-i2c-scanner,
pico-i2c-rtc-read, pico-spi-loopback, c3-blink and others.
Tests
- scripts/visual-led-test.mjs: CDP-driven harness. Default suite covers
Blink (single-pin), Button (idle-OFF invariant — catches the
INPUT_PULLUP regression), Traffic-Light (multi-pin sequencing),
Fade-LED (PWM gradient — ≥3 distinct levels), RGB-LED (≥3 PWM pins
driven). Run via `npm --prefix frontend run test:visual` against a
Chrome on `:9222` + vite on `:5174` + backend on `:8001`.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-19 07:47:45 +07:00
|
|
|
if (boardPm) boardPm.triggerPinChange(gpioPin, state, 'mcu');
|
2026-03-15 02:57:22 +07:00
|
|
|
};
|
2026-05-23 00:58:24 +07:00
|
|
|
bridge.onPinChangeWithTime = getOscilloscopeCallback(boardId);
|
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 } : {}) };
|
|
|
|
|
});
|
|
|
|
|
};
|
feat(esp32): SignalRouter — model the GPIO Matrix as first-class
Replaces the per-peripheral ad-hoc `_ledc_gpio_map` cache with a
proper signal-routing abstraction that mirrors the ESP32 SoC's
IO_MUX + GPIO Matrix exactly. Same idea as real silicon: signal
sources (LEDC channels, RMT, MCPWM, ...) → 40-entry routing table
→ GPIO pins.
Motivation (from user bug report in
velxio.dev/project/5218f9e3-136d-43b3-bba1-6cebde21e1a4): two
ESP32 servos on a solar-tracker visibly oscillated between two
positions instead of moving smoothly when the user changed LDR
sliders. Commit 77bf897 patched it (per-channel gpio memo +
broadcast guard) but the user requested a proper hardware-fidel
architecture, not patches.
Backend:
* `app/services/signal_router.py` — SignalRouter class. Forward
index (gpio → signal_id) + reverse index (signal_id → set of
gpios). `replace_snapshot()` returns the diff for the polling-
fallback path; future C plugin hook becomes a push without
touching this code.
* `app/services/esp32_signals.py` — Signal id constants from
ESP32 TRM (LEDC HS 72-79, LS 80-87) + `ledc_signal_for_channel()`
helper.
* `app/services/esp32_worker.py` — `_ledc_gpio_map` is gone;
`_refresh_ledc_gpio_map` replaced by `_refresh_signal_routing`
which emits `gpio_routing {gpio, signal_id}` events on diff.
The 0x5000 LEDC callback and the LEDC poll thread now emit
`ledc_duty {channel, duty_pct}` (canonical, no gpio) alongside
the legacy `ledc_update {channel, duty, gpio}` for back-compat
during rollout.
Frontend:
* `simulation/SignalRouter.ts` — 1-to-1 TS mirror of the Python
class. Same forward + reverse index; same `pinsForSignal` /
`updateRouting` / `clearRouting` API.
* `simulation/esp32-signals.ts` — Signal id constants, mirror
of the Python module.
* `simulation/Esp32Bridge.ts` — new `onLedcDuty`, `onGpioRouting`,
`onGpioRoutingClear` callbacks; handlers for the new event types.
* `store/useSimulatorStore.ts` — `makeLedcDutyHandler` looks up
pins via `router.pinsForSignal(ledcSignalForChannel(channel))`
and dispatches per pin. `makeGpioRoutingHandler` /
`makeGpioRoutingClearHandler` keep the mirror in sync. Per-board
`signalRouterMap` parallels `pinManagerMap` in lifecycle.
`makeLedcUpdateHandler` (and its memo workaround from 77bf897)
stays wired for back-compat during rollout; removed in a
follow-up commit once prod is verified stable on the new path.
Tests:
* `test/backend/unit/test_signal_router.py` (20 tests) covers
update/clear semantics, idempotency, multi-pin routing,
snapshot diff, channel↔signal-id helpers, and the multi-servo
regression scenario.
* `frontend/src/__tests__/SignalRouter.test.ts` (17 tests) is the
mirror — same scenarios on the TS side.
* `frontend/src/__tests__/esp32-multi-servo-gpio-matrix.test.ts`
(6 tests) drives the end-to-end SignalRouter handler pipeline,
asserts that two servos on GPIO 13/12 via LEDC channels 0/1
move independently (no mirroring), that re-routing carries
cleanly, and — critically — that `PinManager.broadcastPwm` is
never called.
Totals: +700 LOC, 1876 frontend tests pass (was 1853), 278 backend
unit tests pass (was 259).
Docs: ESP32_EMULATION.md §9.2 rewritten with the new architecture
diagram + a runbook for adding future peripherals through the
SignalRouter.
The C plugin hook in qemu-lcgamboa that would push gpio_out_sel
writes synchronously (eliminating the polling race window entirely)
is the next step — kept as a follow-up because the polling-fallback
path here already resolves the routing before each duty event
fires, so the bug is fixed end-to-end. The plugin work removes the
race condition fundamentally.
2026-05-17 10:00:53 +07:00
|
|
|
signalRouterMap.set(boardId, new SignalRouter());
|
|
|
|
|
bridge.onLedcDuty = makeLedcDutyHandler(boardId);
|
|
|
|
|
bridge.onGpioRouting = makeGpioRoutingHandler(boardId);
|
|
|
|
|
bridge.onGpioRoutingClear = makeGpioRoutingClearHandler(boardId);
|
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);
|
fix(spice+pipeline): LED visualization, INPUT_PULLUP, ESP32-C3, PWM fade, examples
End-to-end pipeline fixes uncovered while auditing the /examples gallery.
Each bug shipped past green unit + snapshot tests because none of those run
firmware + render LEDs. Added scripts/visual-led-test.mjs as a CDP-driven
visual harness that loads each example, runs the simulator, samples
`wokwi-led.brightness`, and asserts toggle / gradient / initial-off
invariants — exits non-zero on any regression.
Frontend simulator
- PinManager.updatePort: new optional ddrMask param. A pin is added to
`outputPins` only if the DDR bit is set, so the PORTx write that
enables INPUT_PULLUP (DDR=0, PORT=1) no longer falsely marks the pin
as MCU output. AVRSimulator now reads DDRB/C/D (0x24/0x27/0x2A on
Uno/Nano, 0x37 on ATtiny85, per-port table on Mega) and forwards it.
- AVRSimulator: pass DDR mask alongside every port-listener fire.
- BasicParts pushbutton{,-6mm}: seed pin HIGH in attachEvents so
`digitalRead()` returns HIGH while idle. avr8js doesn't auto-simulate
INPUT_PULLUP — without this the firmware reads LOW from boot and
thinks the button is permanently pressed (the "LED is always on,
pressing does nothing" UX bug).
- connectMcuEdgesToService: suppress synthetic digital edges on pins
with active PWM, AND subscribe to onPwmChange to re-tick the netlist
on duty changes. Fade-LED now produces a true gradient (6 distinct
brightness levels across a fade cycle) instead of a binary 0/full
toggle.
- CircuitSimulationService.handleMcuEdge: replace single-slot
pendingMcuEdge with a per-pin Map. Multiple pins toggling during the
same in-flight tick used to overwrite each other; now every pin's
most-recent edge replays after the tick. Fixes Traffic-Light RED→
YELLOW→GREEN sequencing.
- NetlistBuilder: new sanitizeSpiceId() helper replaces hyphens with
underscores in V-source names. ngspice's interactive `alter` command
treats `-` as an operator and silently no-ops on hyphenated source
names, so mid-simulation MCU pin transitions stopped propagating
after the first solve. MixedModeScheduler.onMcuPinChange and
CircuitSimulationService self-heal use the same sanitizer so names
stay consistent across emit/alter/lookup. Also added a regex-based
fallback in step 2 so any board pin matching `GND.\d+` canonicalises
to net "0" — ESP32-C3 dev kits expose up to 10 GND pins and the
per-board `groundPinNames` list missed several, leaving wires
floating instead of grounded.
- collectPinStates: emit V-sources only for pins in `outputPins`, not
every wired board pin. Leaves INPUT pins (analog sensors on A0,
pull-down dividers, etc.) free for the SPICE solver instead of being
shorted to 0 V by an ideal MCU V-source.
- start.ts: extended __spiceDebug to also expose outputPinsByBoard +
nodeVoltages + pinNetMapEntries for the visual harness.
- ESP32 / RP2040 / RISC-V / C3 simulators: pass `'mcu'` source flag to
triggerPinChange / setPinState so the new outputPins tracking fires
on those boards too (was AVR-only before).
- useSimulatorStore: stopBoard/resetBoard call pm.resetPinStates() so
outputPins clears between runs; Esp32Bridge.onPinChange passes the
`'mcu'` flag in all three places it's wired.
- types/board.ts: ATtiny85 FQBN `clock=internal16mhz` →
`clock=16pll` (ATTinyCore 1.5.2 renamed the option).
Backend
- esp-idf-template/main/CMakeLists.txt: skip the
`-DLED_BUILTIN=2` fallback for esp32c3 and esp32s3 targets. Both
variants already define LED_BUILTIN in pins_arduino.h via a
self-define macro (`#define LED_BUILTIN LED_BUILTIN` + `static const
uint8_t LED_BUILTIN = ...;`). Pre-defining the symbol from the
command line expanded the static-const declaration to
`static const uint8_t 2 = ...;` — a syntax error that broke every
ESP32-C3 / S3 build (`expected unqualified-id before numeric
constant`).
Examples
- examples.ts: bulk-fix 72 wire endpoints that referenced
`componentId: 'nano-rp2040'` / `'esp32-c3'` etc. (boards that don't
exist on the canvas). Replaced with `'arduino-uno'` (the canvas
board-id convention) and converted `D<n>` pin names to `GP<n>` for
Pico-style boards. Affects pico-blink, pico-i2c-scanner,
pico-i2c-rtc-read, pico-spi-loopback, c3-blink and others.
Tests
- scripts/visual-led-test.mjs: CDP-driven harness. Default suite covers
Blink (single-pin), Button (idle-OFF invariant — catches the
INPUT_PULLUP regression), Traffic-Light (multi-pin sequencing),
Fade-LED (PWM gradient — ≥3 distinct levels), RGB-LED (≥3 PWM pins
driven). Run via `npm --prefix frontend run test:visual` against a
Chrome on `:9222` + vite on `:5174` + backend on `:8001`.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-19 07:47:45 +07:00
|
|
|
if (boardPm) boardPm.triggerPinChange(gpioPin, state, 'mcu');
|
2026-03-23 01:17:44 +07:00
|
|
|
};
|
2026-05-23 00:58:24 +07:00
|
|
|
bridge.onPinChangeWithTime = getOscilloscopeCallback(boardId);
|
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 } : {}) };
|
|
|
|
|
});
|
|
|
|
|
};
|
feat(esp32): SignalRouter — model the GPIO Matrix as first-class
Replaces the per-peripheral ad-hoc `_ledc_gpio_map` cache with a
proper signal-routing abstraction that mirrors the ESP32 SoC's
IO_MUX + GPIO Matrix exactly. Same idea as real silicon: signal
sources (LEDC channels, RMT, MCPWM, ...) → 40-entry routing table
→ GPIO pins.
Motivation (from user bug report in
velxio.dev/project/5218f9e3-136d-43b3-bba1-6cebde21e1a4): two
ESP32 servos on a solar-tracker visibly oscillated between two
positions instead of moving smoothly when the user changed LDR
sliders. Commit 77bf897 patched it (per-channel gpio memo +
broadcast guard) but the user requested a proper hardware-fidel
architecture, not patches.
Backend:
* `app/services/signal_router.py` — SignalRouter class. Forward
index (gpio → signal_id) + reverse index (signal_id → set of
gpios). `replace_snapshot()` returns the diff for the polling-
fallback path; future C plugin hook becomes a push without
touching this code.
* `app/services/esp32_signals.py` — Signal id constants from
ESP32 TRM (LEDC HS 72-79, LS 80-87) + `ledc_signal_for_channel()`
helper.
* `app/services/esp32_worker.py` — `_ledc_gpio_map` is gone;
`_refresh_ledc_gpio_map` replaced by `_refresh_signal_routing`
which emits `gpio_routing {gpio, signal_id}` events on diff.
The 0x5000 LEDC callback and the LEDC poll thread now emit
`ledc_duty {channel, duty_pct}` (canonical, no gpio) alongside
the legacy `ledc_update {channel, duty, gpio}` for back-compat
during rollout.
Frontend:
* `simulation/SignalRouter.ts` — 1-to-1 TS mirror of the Python
class. Same forward + reverse index; same `pinsForSignal` /
`updateRouting` / `clearRouting` API.
* `simulation/esp32-signals.ts` — Signal id constants, mirror
of the Python module.
* `simulation/Esp32Bridge.ts` — new `onLedcDuty`, `onGpioRouting`,
`onGpioRoutingClear` callbacks; handlers for the new event types.
* `store/useSimulatorStore.ts` — `makeLedcDutyHandler` looks up
pins via `router.pinsForSignal(ledcSignalForChannel(channel))`
and dispatches per pin. `makeGpioRoutingHandler` /
`makeGpioRoutingClearHandler` keep the mirror in sync. Per-board
`signalRouterMap` parallels `pinManagerMap` in lifecycle.
`makeLedcUpdateHandler` (and its memo workaround from 77bf897)
stays wired for back-compat during rollout; removed in a
follow-up commit once prod is verified stable on the new path.
Tests:
* `test/backend/unit/test_signal_router.py` (20 tests) covers
update/clear semantics, idempotency, multi-pin routing,
snapshot diff, channel↔signal-id helpers, and the multi-servo
regression scenario.
* `frontend/src/__tests__/SignalRouter.test.ts` (17 tests) is the
mirror — same scenarios on the TS side.
* `frontend/src/__tests__/esp32-multi-servo-gpio-matrix.test.ts`
(6 tests) drives the end-to-end SignalRouter handler pipeline,
asserts that two servos on GPIO 13/12 via LEDC channels 0/1
move independently (no mirroring), that re-routing carries
cleanly, and — critically — that `PinManager.broadcastPwm` is
never called.
Totals: +700 LOC, 1876 frontend tests pass (was 1853), 278 backend
unit tests pass (was 259).
Docs: ESP32_EMULATION.md §9.2 rewritten with the new architecture
diagram + a runbook for adding future peripherals through the
SignalRouter.
The C plugin hook in qemu-lcgamboa that would push gpio_out_sel
writes synchronously (eliminating the polling race window entirely)
is the next step — kept as a follow-up because the polling-fallback
path here already resolves the routing before each duty event
fires, so the bug is fixed end-to-end. The plugin work removes the
race condition fundamentally.
2026-05-17 10:00:53 +07:00
|
|
|
signalRouterMap.set(boardId, new SignalRouter());
|
|
|
|
|
bridge.onLedcDuty = makeLedcDutyHandler(boardId);
|
|
|
|
|
bridge.onGpioRouting = makeGpioRoutingHandler(boardId);
|
|
|
|
|
bridge.onGpioRoutingClear = makeGpioRoutingClearHandler(boardId);
|
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 ────────────────────────────────────────────────────────
|
2026-05-18 09:27:40 +07:00
|
|
|
// Default canvas shown on a bare /editor visit: an external LED on
|
|
|
|
|
// pin 13 PROTECTED BY A 220Ω SERIES RESISTOR (the canonical Blink
|
|
|
|
|
// wiring textbooks teach). Without the resistor the LED is a direct
|
|
|
|
|
// short forward-biased between 5V and GND — real hardware blows the
|
|
|
|
|
// diode, and the ngspice solver returns an indeterminate / NaN branch
|
|
|
|
|
// current so the visual LED never lights up on the canvas either.
|
2026-05-18 20:01:38 +07:00
|
|
|
// NOTE: component ids must NOT contain hyphens. ngspice (WASM build)
|
|
|
|
|
// truncates branch-current vector names at '-', so a sense source
|
|
|
|
|
// named V_led-builtin_sense yields the wrong key in branchCurrents
|
|
|
|
|
// and the LED's update() loop never sees the diode current — the
|
|
|
|
|
// node voltage is correct (the user sees ~1.84V on the wire) but the
|
|
|
|
|
// visual brightness stays at zero. Underscore is safe.
|
2026-03-13 09:39:04 +07:00
|
|
|
components: [
|
|
|
|
|
{
|
2026-05-18 20:01:38 +07:00
|
|
|
id: 'led_builtin',
|
2026-03-13 09:39:04 +07:00
|
|
|
metadataId: 'led',
|
2026-05-18 09:27:40 +07:00
|
|
|
x: 380,
|
2026-03-13 09:39:04 +07:00
|
|
|
y: 100,
|
2026-04-07 23:27:54 +07:00
|
|
|
properties: { color: 'red' },
|
2026-03-13 09:39:04 +07:00
|
|
|
},
|
2026-05-18 09:27:40 +07:00
|
|
|
{
|
2026-05-18 20:01:38 +07:00
|
|
|
id: 'r_builtin',
|
2026-05-18 09:27:40 +07:00
|
|
|
metadataId: 'resistor',
|
|
|
|
|
x: 240,
|
|
|
|
|
y: 130,
|
|
|
|
|
properties: { value: '220' },
|
|
|
|
|
},
|
2026-03-13 09:39:04 +07:00
|
|
|
],
|
|
|
|
|
|
|
|
|
|
wires: [
|
2026-05-18 09:27:40 +07:00
|
|
|
// Pin 13 → resistor pin 1 (current-limiting side).
|
2026-03-13 09:39:04 +07:00
|
|
|
{
|
2026-05-18 20:01:38 +07:00
|
|
|
id: 'wire_builtin_pin13',
|
2026-04-07 23:27:54 +07:00
|
|
|
start: { componentId: 'arduino-uno', pinName: '13', x: 0, y: 0 },
|
2026-05-18 20:01:38 +07:00
|
|
|
end: { componentId: 'r_builtin', pinName: '1', x: 0, y: 0 },
|
2026-05-18 09:27:40 +07:00
|
|
|
waypoints: [],
|
|
|
|
|
color: '#22c55e',
|
|
|
|
|
},
|
|
|
|
|
// Resistor pin 2 → LED anode.
|
|
|
|
|
{
|
2026-05-18 20:01:38 +07:00
|
|
|
id: 'wire_builtin_anode',
|
|
|
|
|
start: { componentId: 'r_builtin', pinName: '2', x: 0, y: 0 },
|
|
|
|
|
end: { componentId: 'led_builtin', pinName: 'A', x: 0, y: 0 },
|
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-05-18 09:27:40 +07:00
|
|
|
// LED cathode → GND.
|
2026-03-13 09:39:04 +07:00
|
|
|
{
|
2026-05-18 20:01:38 +07:00
|
|
|
id: 'wire_builtin_cathode',
|
|
|
|
|
start: { componentId: 'led_builtin', pinName: 'C', x: 0, y: 0 },
|
2026-05-18 09:27:40 +07:00
|
|
|
end: { componentId: 'arduino-uno', pinName: 'GND.1', 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
|
|
|
}));
|
fix(canvas): wires follow components on rotation
Rotating a part with the 90° button used to leave every wire pinned to
the pre-rotation pixel coordinates — the component visually unhooked
from its cables. Two paths were missing:
1. useSimulatorStore.updateComponent only triggered updateWirePositions
for x/y changes. A rotation went through properties.rotation, so
wires never recomputed.
2. calculatePinPosition didn't know about rotation. Even when called,
it returned the unrotated offset, so the new endpoints would still
have been wrong.
3. recordRotate (undo/redo) skipped updateWirePositions on both legs,
so Ctrl+Z after a rotate left the canvas inconsistent.
Fix:
- calculatePinPosition gets a 5th `rotation` argument. When non-zero,
it finds the .dynamic-component-wrapper ancestor in the DOM, reads
its offsetWidth/Height (layout-only, immune to CSS transforms) to
locate the wrapper centre, and applies a 2D rotation matrix around
that pivot. The wrapper top-left is recovered as (componentX - 4,
componentY - 6) to match the offset convention updateWirePositions
already uses.
- updateWirePositions and recalculateAllWirePositions read the per-
component rotation and thread it through.
- updateComponent recomputes wires whenever properties.rotation
changes, mirroring the existing x/y path.
- recordRotate.execute and .undo both call updateWirePositions so
Ctrl+Z keeps the canvas coherent.
Tests (pin-position-rotation.test.ts, 6 cases): unrotated identity,
90° (left edge → bottom), 180° (point reflection), 360° round-trip,
negative angles, and a store-level integration that rotates a fake
component and asserts wires[0].start moves to the rotated coordinate.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 04:03:32 +07:00
|
|
|
// Re-stamp wire endpoints when the geometry of the component changes:
|
|
|
|
|
// position (x/y) OR rotation. Without this, rotating a component
|
|
|
|
|
// leaves every wire anchored to the pre-rotation pin positions, so
|
|
|
|
|
// the part visually disconnects from its cables.
|
|
|
|
|
const rotationChanged =
|
|
|
|
|
updates.properties && 'rotation' in updates.properties;
|
|
|
|
|
if (updates.x !== undefined || updates.y !== undefined || rotationChanged) {
|
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
|
|
|
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;
|
2026-05-11 12:54:56 +07:00
|
|
|
|
|
|
|
|
// Finish wire: auto-detect color from pin name
|
|
|
|
|
const finalColor = color === DEFAULT_WIRE_COLOR ? autoWireColor(endpoint.pinName) : 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
|
|
|
const newWire: Wire = {
|
|
|
|
|
id: `wire-${Date.now()}`,
|
|
|
|
|
start: startEndpoint,
|
|
|
|
|
end: endpoint,
|
2026-03-15 08:55:13 +07:00
|
|
|
waypoints,
|
2026-05-11 12:54:56 +07:00
|
|
|
color: finalColor,
|
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;
|
fix(canvas): wires follow components on rotation
Rotating a part with the 90° button used to leave every wire pinned to
the pre-rotation pixel coordinates — the component visually unhooked
from its cables. Two paths were missing:
1. useSimulatorStore.updateComponent only triggered updateWirePositions
for x/y changes. A rotation went through properties.rotation, so
wires never recomputed.
2. calculatePinPosition didn't know about rotation. Even when called,
it returned the unrotated offset, so the new endpoints would still
have been wrong.
3. recordRotate (undo/redo) skipped updateWirePositions on both legs,
so Ctrl+Z after a rotate left the canvas inconsistent.
Fix:
- calculatePinPosition gets a 5th `rotation` argument. When non-zero,
it finds the .dynamic-component-wrapper ancestor in the DOM, reads
its offsetWidth/Height (layout-only, immune to CSS transforms) to
locate the wrapper centre, and applies a 2D rotation matrix around
that pivot. The wrapper top-left is recovered as (componentX - 4,
componentY - 6) to match the offset convention updateWirePositions
already uses.
- updateWirePositions and recalculateAllWirePositions read the per-
component rotation and thread it through.
- updateComponent recomputes wires whenever properties.rotation
changes, mirroring the existing x/y path.
- recordRotate.execute and .undo both call updateWirePositions so
Ctrl+Z keeps the canvas coherent.
Tests (pin-position-rotation.test.ts, 6 cases): unrotated identity,
90° (left edge → bottom), 180° (point reflection), 360° round-trip,
negative angles, and a store-level integration that rotates a fake
component and asserts wires[0].start moves to the rotated coordinate.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 04:03:32 +07:00
|
|
|
// Boards never rotate; components carry their angle in properties.rotation.
|
|
|
|
|
const rotation = component ? Number(component.properties?.rotation) || 0 : 0;
|
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) {
|
fix(canvas): wires follow components on rotation
Rotating a part with the 90° button used to leave every wire pinned to
the pre-rotation pixel coordinates — the component visually unhooked
from its cables. Two paths were missing:
1. useSimulatorStore.updateComponent only triggered updateWirePositions
for x/y changes. A rotation went through properties.rotation, so
wires never recomputed.
2. calculatePinPosition didn't know about rotation. Even when called,
it returned the unrotated offset, so the new endpoints would still
have been wrong.
3. recordRotate (undo/redo) skipped updateWirePositions on both legs,
so Ctrl+Z after a rotate left the canvas inconsistent.
Fix:
- calculatePinPosition gets a 5th `rotation` argument. When non-zero,
it finds the .dynamic-component-wrapper ancestor in the DOM, reads
its offsetWidth/Height (layout-only, immune to CSS transforms) to
locate the wrapper centre, and applies a 2D rotation matrix around
that pivot. The wrapper top-left is recovered as (componentX - 4,
componentY - 6) to match the offset convention updateWirePositions
already uses.
- updateWirePositions and recalculateAllWirePositions read the per-
component rotation and thread it through.
- updateComponent recomputes wires whenever properties.rotation
changes, mirroring the existing x/y path.
- recordRotate.execute and .undo both call updateWirePositions so
Ctrl+Z keeps the canvas coherent.
Tests (pin-position-rotation.test.ts, 6 cases): unrotated identity,
90° (left edge → bottom), 180° (point reflection), 360° round-trip,
negative angles, and a store-level integration that rotates a fake
component and asserts wires[0].start moves to the rotated coordinate.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 04:03:32 +07:00
|
|
|
const pos = calculatePinPosition(
|
|
|
|
|
componentId, wire.start.pinName, compX, compY, rotation,
|
|
|
|
|
);
|
2026-03-13 09:39:04 +07:00
|
|
|
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) {
|
fix(canvas): wires follow components on rotation
Rotating a part with the 90° button used to leave every wire pinned to
the pre-rotation pixel coordinates — the component visually unhooked
from its cables. Two paths were missing:
1. useSimulatorStore.updateComponent only triggered updateWirePositions
for x/y changes. A rotation went through properties.rotation, so
wires never recomputed.
2. calculatePinPosition didn't know about rotation. Even when called,
it returned the unrotated offset, so the new endpoints would still
have been wrong.
3. recordRotate (undo/redo) skipped updateWirePositions on both legs,
so Ctrl+Z after a rotate left the canvas inconsistent.
Fix:
- calculatePinPosition gets a 5th `rotation` argument. When non-zero,
it finds the .dynamic-component-wrapper ancestor in the DOM, reads
its offsetWidth/Height (layout-only, immune to CSS transforms) to
locate the wrapper centre, and applies a 2D rotation matrix around
that pivot. The wrapper top-left is recovered as (componentX - 4,
componentY - 6) to match the offset convention updateWirePositions
already uses.
- updateWirePositions and recalculateAllWirePositions read the per-
component rotation and thread it through.
- updateComponent recomputes wires whenever properties.rotation
changes, mirroring the existing x/y path.
- recordRotate.execute and .undo both call updateWirePositions so
Ctrl+Z keeps the canvas coherent.
Tests (pin-position-rotation.test.ts, 6 cases): unrotated identity,
90° (left edge → bottom), 180° (point reflection), 360° round-trip,
negative angles, and a store-level integration that rotates a fake
component and asserts wires[0].start moves to the rotated coordinate.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 04:03:32 +07:00
|
|
|
const pos = calculatePinPosition(
|
|
|
|
|
componentId, wire.end.pinName, compX, compY, rotation,
|
|
|
|
|
);
|
2026-03-13 09:39:04 +07:00
|
|
|
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;
|
fix(canvas): wires follow components on rotation
Rotating a part with the 90° button used to leave every wire pinned to
the pre-rotation pixel coordinates — the component visually unhooked
from its cables. Two paths were missing:
1. useSimulatorStore.updateComponent only triggered updateWirePositions
for x/y changes. A rotation went through properties.rotation, so
wires never recomputed.
2. calculatePinPosition didn't know about rotation. Even when called,
it returned the unrotated offset, so the new endpoints would still
have been wrong.
3. recordRotate (undo/redo) skipped updateWirePositions on both legs,
so Ctrl+Z after a rotate left the canvas inconsistent.
Fix:
- calculatePinPosition gets a 5th `rotation` argument. When non-zero,
it finds the .dynamic-component-wrapper ancestor in the DOM, reads
its offsetWidth/Height (layout-only, immune to CSS transforms) to
locate the wrapper centre, and applies a 2D rotation matrix around
that pivot. The wrapper top-left is recovered as (componentX - 4,
componentY - 6) to match the offset convention updateWirePositions
already uses.
- updateWirePositions and recalculateAllWirePositions read the per-
component rotation and thread it through.
- updateComponent recomputes wires whenever properties.rotation
changes, mirroring the existing x/y path.
- recordRotate.execute and .undo both call updateWirePositions so
Ctrl+Z keeps the canvas coherent.
Tests (pin-position-rotation.test.ts, 6 cases): unrotated identity,
90° (left edge → bottom), 180° (point reflection), 360° round-trip,
negative angles, and a store-level integration that rotates a fake
component and asserts wires[0].start moves to the rotated coordinate.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 04:03:32 +07:00
|
|
|
const startRotation = startComp ? Number(startComp.properties?.rotation) || 0 : 0;
|
2026-04-22 02:45:45 +07:00
|
|
|
const startPos = calculatePinPosition(
|
|
|
|
|
wire.start.componentId,
|
|
|
|
|
wire.start.pinName,
|
|
|
|
|
startX,
|
|
|
|
|
startY,
|
fix(canvas): wires follow components on rotation
Rotating a part with the 90° button used to leave every wire pinned to
the pre-rotation pixel coordinates — the component visually unhooked
from its cables. Two paths were missing:
1. useSimulatorStore.updateComponent only triggered updateWirePositions
for x/y changes. A rotation went through properties.rotation, so
wires never recomputed.
2. calculatePinPosition didn't know about rotation. Even when called,
it returned the unrotated offset, so the new endpoints would still
have been wrong.
3. recordRotate (undo/redo) skipped updateWirePositions on both legs,
so Ctrl+Z after a rotate left the canvas inconsistent.
Fix:
- calculatePinPosition gets a 5th `rotation` argument. When non-zero,
it finds the .dynamic-component-wrapper ancestor in the DOM, reads
its offsetWidth/Height (layout-only, immune to CSS transforms) to
locate the wrapper centre, and applies a 2D rotation matrix around
that pivot. The wrapper top-left is recovered as (componentX - 4,
componentY - 6) to match the offset convention updateWirePositions
already uses.
- updateWirePositions and recalculateAllWirePositions read the per-
component rotation and thread it through.
- updateComponent recomputes wires whenever properties.rotation
changes, mirroring the existing x/y path.
- recordRotate.execute and .undo both call updateWirePositions so
Ctrl+Z keeps the canvas coherent.
Tests (pin-position-rotation.test.ts, 6 cases): unrotated identity,
90° (left edge → bottom), 180° (point reflection), 360° round-trip,
negative angles, and a store-level integration that rotates a fake
component and asserts wires[0].start moves to the rotated coordinate.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 04:03:32 +07:00
|
|
|
startRotation,
|
2026-04-22 02:45:45 +07:00
|
|
|
);
|
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;
|
fix(canvas): wires follow components on rotation
Rotating a part with the 90° button used to leave every wire pinned to
the pre-rotation pixel coordinates — the component visually unhooked
from its cables. Two paths were missing:
1. useSimulatorStore.updateComponent only triggered updateWirePositions
for x/y changes. A rotation went through properties.rotation, so
wires never recomputed.
2. calculatePinPosition didn't know about rotation. Even when called,
it returned the unrotated offset, so the new endpoints would still
have been wrong.
3. recordRotate (undo/redo) skipped updateWirePositions on both legs,
so Ctrl+Z after a rotate left the canvas inconsistent.
Fix:
- calculatePinPosition gets a 5th `rotation` argument. When non-zero,
it finds the .dynamic-component-wrapper ancestor in the DOM, reads
its offsetWidth/Height (layout-only, immune to CSS transforms) to
locate the wrapper centre, and applies a 2D rotation matrix around
that pivot. The wrapper top-left is recovered as (componentX - 4,
componentY - 6) to match the offset convention updateWirePositions
already uses.
- updateWirePositions and recalculateAllWirePositions read the per-
component rotation and thread it through.
- updateComponent recomputes wires whenever properties.rotation
changes, mirroring the existing x/y path.
- recordRotate.execute and .undo both call updateWirePositions so
Ctrl+Z keeps the canvas coherent.
Tests (pin-position-rotation.test.ts, 6 cases): unrotated identity,
90° (left edge → bottom), 180° (point reflection), 360° round-trip,
negative angles, and a store-level integration that rotates a fake
component and asserts wires[0].start moves to the rotated coordinate.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 04:03:32 +07:00
|
|
|
const endRotation = endComp ? Number(endComp.properties?.rotation) || 0 : 0;
|
|
|
|
|
const endPos = calculatePinPosition(
|
|
|
|
|
wire.end.componentId, wire.end.pinName, endX, endY, endRotation,
|
|
|
|
|
);
|
2026-03-13 09:39:04 +07:00
|
|
|
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',
|
fix(canvas): wires follow components on rotation
Rotating a part with the 90° button used to leave every wire pinned to
the pre-rotation pixel coordinates — the component visually unhooked
from its cables. Two paths were missing:
1. useSimulatorStore.updateComponent only triggered updateWirePositions
for x/y changes. A rotation went through properties.rotation, so
wires never recomputed.
2. calculatePinPosition didn't know about rotation. Even when called,
it returned the unrotated offset, so the new endpoints would still
have been wrong.
3. recordRotate (undo/redo) skipped updateWirePositions on both legs,
so Ctrl+Z after a rotate left the canvas inconsistent.
Fix:
- calculatePinPosition gets a 5th `rotation` argument. When non-zero,
it finds the .dynamic-component-wrapper ancestor in the DOM, reads
its offsetWidth/Height (layout-only, immune to CSS transforms) to
locate the wrapper centre, and applies a 2D rotation matrix around
that pivot. The wrapper top-left is recovered as (componentX - 4,
componentY - 6) to match the offset convention updateWirePositions
already uses.
- updateWirePositions and recalculateAllWirePositions read the per-
component rotation and thread it through.
- updateComponent recomputes wires whenever properties.rotation
changes, mirroring the existing x/y path.
- recordRotate.execute and .undo both call updateWirePositions so
Ctrl+Z keeps the canvas coherent.
Tests (pin-position-rotation.test.ts, 6 cases): unrotated identity,
90° (left edge → bottom), 180° (point reflection), 360° round-trip,
negative angles, and a store-level integration that rotates a fake
component and asserts wires[0].start moves to the rotated coordinate.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 04:03:32 +07:00
|
|
|
execute: () => {
|
feat(canvas): undo/redo command pattern + history slice
Adds the foundation for canvas undo/redo. UI wiring, keyboard shortcuts,
toolbar buttons and agent-tools refactor land in follow-up commits.
useSimulatorStore.ts:
- New CanvasCommand type — { description, execute, undo }.
- HISTORY_MAX = 50 (oldest entries dropped on overflow).
- New state: history[] + historyIndex (-1 = empty).
- New APIs: pushCommand(cmd, {applyNow?}), undo, redo, canUndo, canRedo,
clearHistory.
- New "recorded" actions that wrap raw mutators with a CanvasCommand:
recordAddComponent, recordRemoveComponent, recordMove, recordRotate,
recordSetProperty, recordAddWire, recordRemoveWire, recordUpdateWire.
- recordRemoveComponent captures both the component AND any wires that
cascade with it, so undo restores both atomically.
- recordMove also re-runs updateWirePositions on undo/redo so wire
endpoints follow the component back/forward.
- setComponents and setWires (project-load / clear paths) now call
clearHistory inline — leaving stale commands pointing at IDs that no
longer exist would crash on undo.
Why custom Command pattern over zundo / travels:
- The store has 30+ ephemeral fields (simulator instances, serialOutput
growing byte-by-byte, hexEpoch counter, wireInProgress that ticks 60×/s
on drag). Snapshot/diff middleware would either burn memory tracking
them or need a fragile partialize allow-list.
- Per-op descriptions ("Add LED", "Move resistor") for tooltips come for
free with this approach; zundo/travels would need to infer them.
Tests: 15/15 in src/__tests__/undo-redo.test.ts — covers cap-at-50,
redo-truncation, cascade undo of remove-component, move/rotate/property
round trips, bulk-setter clearing.
Raw mutators (addComponent / removeComponent / updateComponent / addWire /
removeWire / updateWire) are unchanged. UI handlers can keep using them
during live drags for preview frames without spamming history; the
record* actions are what drag-end, click-finish and agent tools should
call going forward.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 22:03:17 +07:00
|
|
|
set((s) => ({
|
|
|
|
|
components: s.components.map((c) =>
|
|
|
|
|
c.id === id
|
|
|
|
|
? { ...c, properties: { ...c.properties, rotation: nextRotation } }
|
|
|
|
|
: c,
|
|
|
|
|
),
|
fix(canvas): wires follow components on rotation
Rotating a part with the 90° button used to leave every wire pinned to
the pre-rotation pixel coordinates — the component visually unhooked
from its cables. Two paths were missing:
1. useSimulatorStore.updateComponent only triggered updateWirePositions
for x/y changes. A rotation went through properties.rotation, so
wires never recomputed.
2. calculatePinPosition didn't know about rotation. Even when called,
it returned the unrotated offset, so the new endpoints would still
have been wrong.
3. recordRotate (undo/redo) skipped updateWirePositions on both legs,
so Ctrl+Z after a rotate left the canvas inconsistent.
Fix:
- calculatePinPosition gets a 5th `rotation` argument. When non-zero,
it finds the .dynamic-component-wrapper ancestor in the DOM, reads
its offsetWidth/Height (layout-only, immune to CSS transforms) to
locate the wrapper centre, and applies a 2D rotation matrix around
that pivot. The wrapper top-left is recovered as (componentX - 4,
componentY - 6) to match the offset convention updateWirePositions
already uses.
- updateWirePositions and recalculateAllWirePositions read the per-
component rotation and thread it through.
- updateComponent recomputes wires whenever properties.rotation
changes, mirroring the existing x/y path.
- recordRotate.execute and .undo both call updateWirePositions so
Ctrl+Z keeps the canvas coherent.
Tests (pin-position-rotation.test.ts, 6 cases): unrotated identity,
90° (left edge → bottom), 180° (point reflection), 360° round-trip,
negative angles, and a store-level integration that rotates a fake
component and asserts wires[0].start moves to the rotated coordinate.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 04:03:32 +07:00
|
|
|
}));
|
|
|
|
|
// Wires must follow the part on undo / redo too, otherwise a
|
|
|
|
|
// Ctrl+Z after a rotate would re-show the post-rotation pin
|
|
|
|
|
// positions against the now-restored unrotated component.
|
|
|
|
|
get().updateWirePositions(id);
|
|
|
|
|
},
|
|
|
|
|
undo: () => {
|
feat(canvas): undo/redo command pattern + history slice
Adds the foundation for canvas undo/redo. UI wiring, keyboard shortcuts,
toolbar buttons and agent-tools refactor land in follow-up commits.
useSimulatorStore.ts:
- New CanvasCommand type — { description, execute, undo }.
- HISTORY_MAX = 50 (oldest entries dropped on overflow).
- New state: history[] + historyIndex (-1 = empty).
- New APIs: pushCommand(cmd, {applyNow?}), undo, redo, canUndo, canRedo,
clearHistory.
- New "recorded" actions that wrap raw mutators with a CanvasCommand:
recordAddComponent, recordRemoveComponent, recordMove, recordRotate,
recordSetProperty, recordAddWire, recordRemoveWire, recordUpdateWire.
- recordRemoveComponent captures both the component AND any wires that
cascade with it, so undo restores both atomically.
- recordMove also re-runs updateWirePositions on undo/redo so wire
endpoints follow the component back/forward.
- setComponents and setWires (project-load / clear paths) now call
clearHistory inline — leaving stale commands pointing at IDs that no
longer exist would crash on undo.
Why custom Command pattern over zundo / travels:
- The store has 30+ ephemeral fields (simulator instances, serialOutput
growing byte-by-byte, hexEpoch counter, wireInProgress that ticks 60×/s
on drag). Snapshot/diff middleware would either burn memory tracking
them or need a fragile partialize allow-list.
- Per-op descriptions ("Add LED", "Move resistor") for tooltips come for
free with this approach; zundo/travels would need to infer them.
Tests: 15/15 in src/__tests__/undo-redo.test.ts — covers cap-at-50,
redo-truncation, cascade undo of remove-component, move/rotate/property
round trips, bulk-setter clearing.
Raw mutators (addComponent / removeComponent / updateComponent / addWire /
removeWire / updateWire) are unchanged. UI handlers can keep using them
during live drags for preview frames without spamming history; the
record* actions are what drag-end, click-finish and agent tools should
call going forward.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 22:03:17 +07:00
|
|
|
set((s) => ({
|
|
|
|
|
components: s.components.map((c) =>
|
|
|
|
|
c.id === id
|
|
|
|
|
? { ...c, properties: { ...c.properties, rotation: prevRotation } }
|
|
|
|
|
: c,
|
|
|
|
|
),
|
fix(canvas): wires follow components on rotation
Rotating a part with the 90° button used to leave every wire pinned to
the pre-rotation pixel coordinates — the component visually unhooked
from its cables. Two paths were missing:
1. useSimulatorStore.updateComponent only triggered updateWirePositions
for x/y changes. A rotation went through properties.rotation, so
wires never recomputed.
2. calculatePinPosition didn't know about rotation. Even when called,
it returned the unrotated offset, so the new endpoints would still
have been wrong.
3. recordRotate (undo/redo) skipped updateWirePositions on both legs,
so Ctrl+Z after a rotate left the canvas inconsistent.
Fix:
- calculatePinPosition gets a 5th `rotation` argument. When non-zero,
it finds the .dynamic-component-wrapper ancestor in the DOM, reads
its offsetWidth/Height (layout-only, immune to CSS transforms) to
locate the wrapper centre, and applies a 2D rotation matrix around
that pivot. The wrapper top-left is recovered as (componentX - 4,
componentY - 6) to match the offset convention updateWirePositions
already uses.
- updateWirePositions and recalculateAllWirePositions read the per-
component rotation and thread it through.
- updateComponent recomputes wires whenever properties.rotation
changes, mirroring the existing x/y path.
- recordRotate.execute and .undo both call updateWirePositions so
Ctrl+Z keeps the canvas coherent.
Tests (pin-position-rotation.test.ts, 6 cases): unrotated identity,
90° (left edge → bottom), 180° (point reflection), 360° round-trip,
negative angles, and a store-level integration that rotates a fake
component and asserts wires[0].start moves to the rotated coordinate.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 04:03:32 +07:00
|
|
|
}));
|
|
|
|
|
get().updateWirePositions(id);
|
|
|
|
|
},
|
feat(canvas): undo/redo command pattern + history slice
Adds the foundation for canvas undo/redo. UI wiring, keyboard shortcuts,
toolbar buttons and agent-tools refactor land in follow-up commits.
useSimulatorStore.ts:
- New CanvasCommand type — { description, execute, undo }.
- HISTORY_MAX = 50 (oldest entries dropped on overflow).
- New state: history[] + historyIndex (-1 = empty).
- New APIs: pushCommand(cmd, {applyNow?}), undo, redo, canUndo, canRedo,
clearHistory.
- New "recorded" actions that wrap raw mutators with a CanvasCommand:
recordAddComponent, recordRemoveComponent, recordMove, recordRotate,
recordSetProperty, recordAddWire, recordRemoveWire, recordUpdateWire.
- recordRemoveComponent captures both the component AND any wires that
cascade with it, so undo restores both atomically.
- recordMove also re-runs updateWirePositions on undo/redo so wire
endpoints follow the component back/forward.
- setComponents and setWires (project-load / clear paths) now call
clearHistory inline — leaving stale commands pointing at IDs that no
longer exist would crash on undo.
Why custom Command pattern over zundo / travels:
- The store has 30+ ephemeral fields (simulator instances, serialOutput
growing byte-by-byte, hexEpoch counter, wireInProgress that ticks 60×/s
on drag). Snapshot/diff middleware would either burn memory tracking
them or need a fragile partialize allow-list.
- Per-op descriptions ("Add LED", "Move resistor") for tooltips come for
free with this approach; zundo/travels would need to infer them.
Tests: 15/15 in src/__tests__/undo-redo.test.ts — covers cap-at-50,
redo-truncation, cascade undo of remove-component, move/rotate/property
round trips, bulk-setter clearing.
Raw mutators (addComponent / removeComponent / updateComponent / addWire /
removeWire / updateWire) are unchanged. UI handlers can keep using them
during live drags for preview frames without spamming history; the
record* actions are what drag-end, click-finish and agent tools should
call going forward.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 22:03:17 +07:00
|
|
|
},
|
|
|
|
|
{ applyNow: false },
|
|
|
|
|
);
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
recordSetProperty: (id, key, prevValue, nextValue) => {
|
|
|
|
|
get().pushCommand(
|
|
|
|
|
{
|
|
|
|
|
description: `Change ${key}`,
|
|
|
|
|
execute: () =>
|
|
|
|
|
set((s) => ({
|
|
|
|
|
components: s.components.map((c) =>
|
|
|
|
|
c.id === id
|
|
|
|
|
? { ...c, properties: { ...c.properties, [key]: nextValue } }
|
|
|
|
|
: c,
|
|
|
|
|
),
|
|
|
|
|
})),
|
|
|
|
|
undo: () =>
|
|
|
|
|
set((s) => ({
|
|
|
|
|
components: s.components.map((c) =>
|
|
|
|
|
c.id === id
|
|
|
|
|
? { ...c, properties: { ...c.properties, [key]: prevValue } }
|
|
|
|
|
: c,
|
|
|
|
|
),
|
|
|
|
|
})),
|
|
|
|
|
},
|
|
|
|
|
{ applyNow: false },
|
|
|
|
|
);
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
recordAddWire: (wire) => {
|
|
|
|
|
get().pushCommand({
|
|
|
|
|
description: 'Add wire',
|
|
|
|
|
execute: () => set((s) => ({ wires: [...s.wires, wire] })),
|
|
|
|
|
undo: () =>
|
|
|
|
|
set((s) => ({
|
|
|
|
|
wires: s.wires.filter((w) => w.id !== wire.id),
|
|
|
|
|
selectedWireId: s.selectedWireId === wire.id ? null : s.selectedWireId,
|
|
|
|
|
})),
|
|
|
|
|
});
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
recordRemoveWire: (wireId) => {
|
|
|
|
|
const removed = get().wires.find((w) => w.id === wireId);
|
|
|
|
|
if (!removed) return;
|
|
|
|
|
get().pushCommand({
|
|
|
|
|
description: 'Remove wire',
|
|
|
|
|
execute: () =>
|
|
|
|
|
set((s) => ({
|
|
|
|
|
wires: s.wires.filter((w) => w.id !== wireId),
|
|
|
|
|
selectedWireId: s.selectedWireId === wireId ? null : s.selectedWireId,
|
|
|
|
|
})),
|
|
|
|
|
undo: () => set((s) => ({ wires: [...s.wires, removed] })),
|
|
|
|
|
});
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
recordUpdateWire: (wireId, prev, next, description = 'Update wire') => {
|
|
|
|
|
get().pushCommand(
|
|
|
|
|
{
|
|
|
|
|
description,
|
|
|
|
|
execute: () =>
|
|
|
|
|
set((s) => ({
|
|
|
|
|
wires: s.wires.map((w) => (w.id === wireId ? { ...w, ...next } : w)),
|
|
|
|
|
})),
|
|
|
|
|
undo: () =>
|
|
|
|
|
set((s) => ({
|
|
|
|
|
wires: s.wires.map((w) => (w.id === wireId ? { ...w, ...prev } : w)),
|
|
|
|
|
})),
|
|
|
|
|
},
|
|
|
|
|
{ applyNow: false },
|
|
|
|
|
);
|
|
|
|
|
},
|
|
|
|
|
|
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;
|
|
|
|
|
|
2026-05-19 04:23:48 +07:00
|
|
|
if (isPiBoardKind(board.boardKind)) {
|
2026-03-13 09:39:04 +07:00
|
|
|
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;
|
2026-05-19 04:23:48 +07:00
|
|
|
if (isPiBoardKind(board.boardKind)) {
|
2026-03-16 00:04:01 +07:00
|
|
|
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);
|
|
|
|
|
}
|
|
|
|
|
});
|