Add a working microSD card part backed by a FAT16 image, following the
Wokwi storage model: the project's own workspace files are auto-copied
onto the card (free), and an optional "SD Card" panel uploads extra
files (gated as a paid feature by the velxio.dev overlay; OSS default
allows it).
Frontend (in-browser AVR / RP2040):
- ProtocolParts.ts: rewrite the microsd-card part from a handshake stub
into a real SD-over-SPI device (reply-first Ncr timing, SDSC byte
addressing, single/multi-block read+write, CSD/CID, full CMD set).
- utils/fatImage.ts: dependency-free FAT16 super-floppy builder (8.3 + LFN).
- utils/sdCardFiles.ts: assemble the card image from workspace files plus
uploaded files; base64 helpers.
- components/simulator/SdCardPanel.tsx + ComponentPropertyDialog: upload UI.
- DynamicComponent + useSimulatorStore: build and inject the image on run.
- lib/proSdCardGate.ts: overlay-installable gate for the upload action.
- data/examples-storage-microsd.ts: Arduino Uno + ESP32 gallery examples.
Backend (ESP32 via QEMU):
- services/esp32_sd_slave.py: synchronous SD-over-SPI slave (Python port of
the browser part) with a sparse backing store, idle-state R1 tracking and
real CRC16 on data blocks when the host enables CRC (CMD59) -- both
required by ESP-IDF's sdspi driver.
- esp32_worker.py: route SPI bytes to the slave (returns MISO synchronously)
and feed write-only bulk transfers.
- esp32_lib_manager.py + routes/simulation.py: forward the FAT image
(sd_card.image_b64) from the start config into the worker.
Tested:
- frontend: protocol-parts, fat-image, sd-card-gate and microsd-real-firmware
(real Arduino SD.h on avr8js) -- 86 passing.
- backend: test_esp32_sd_slave (10) covering the ESP-IDF init sequence and
CRC16; validated end to end by running a real SD.h sketch in libqemu-xtensa
(mount, directory listing, read and write-readback).
The RP2040 core (125 MHz Cortex-M0) is ~8x heavier to emulate than the
AVR. The run loop used a FIXED per-frame cycle budget, and arduino-pico
delay() busy-waits the timer (no WFI), so a host that cannot sustain
125M instr/s rendered a 1s blink every 4-5s (sim ran in slow motion).
- Derive the frame budget from the MEASURED wall-clock delta (mirrors
AVRSimulator) instead of assuming a perfect 60fps.
- Add IdleSpinDetector: recognise a side-effect-free busy-wait spin and
advance the clock over it (capped at the next timer alarm / scheduled
pin change) instead of executing every idle cycle - the same idea the
WFI fast-path already uses for sleep(). Conservative: a bit-bang loop,
an input-poll that just saw its pin move, or a loop that calls out are
never elided; a false positive only ever advances time up to the
wall-clock budget, never past the next event.
- Bound WFI sleeps to the wall-clock budget so they advance in real
time across frames rather than leaping ahead.
Cuts emulation work for a delay-bound sketch ~1900x (125M -> ~65k
instructions per simulated second) so it tracks wall-time even on hosts
that cannot emulate 125 MHz in real time. Public API unchanged;
step()/stepCycles() untouched.
Adds rp2040-realtime.test.ts: IdleSpinDetector unit tests plus
end-to-end scheduler tests driving a real rp2040js core through a
hand-assembled busy-wait loop (no firmware fixture needed).
A board example proving digital and analog coexist in ONE circuit: the Arduino
drives two logic levels, a physical AND gate combines them, and the AND output
switches an NPN 2N2222 transistor that drives the "motor" LED. Verified live: it
compiles, the MCU drives the AND gate (5 V), the transistor conducts and the LED
lights — MCU -> logic gate -> transistor -> load works across the digital and
ngspice motors together.
Known limitation (sim-mixedmode step 2, pending): the ngspice side does not
re-solve on every MCU pin edge, so a fast (1 Hz) blink does not track in real
time — the analog output changes on a slower cadence. User-driven / slow changes
track fine. Snapshot updated for the new example.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The AND Gate Alarm needed BOTH inputs HIGH at once, but it used momentary
pushbuttons buffered through an Arduino — with one mouse you can only hold one
button at a time, so the AND never fired and the alarm could never be
demonstrated.
Rebuilt it as a board-less digital circuit: two SLIDE switches (they latch) feed
a real AND gate that drives the alarm LED. Slide both switches ON and they stay,
so the alarm arms. No MCU / compilation — it runs on the digital gate engine.
Verified live: the LED lights only on 11 (00/01/10 -> off, 11 -> on).
Snapshot updated: the new board-less and-gate-alarm netlist, plus the digital
bucket count label (38 -> 39) from the earlier ripple-counter example.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Adds the first board-less SEQUENTIAL gallery example (digital-ripple-counter-4bit):
four T flip-flops chained into a ripple counter, LEDs showing the binary count,
clocked by a slide switch. Impossible on the SPICE engine (no edge detection at
DC) - it runs on the digital gate engine.
Controller fix (found by testing the counter live): the controller rebuilt the
network on every change, which reset flip-flop state so a counter never counted.
Now the network is built once and KEPT ALIVE; a switch toggle applies
incrementally via setSwitch (preserving sequential state), and a rebuild happens
only on a structural change (components/wires). Correct for combinational AND
sequential circuits.
examples-digital.test.ts: flip-flop examples are digital-engine-only, so they are
exempt from the SPICE-mapping / has-a-gate / netlist checks (the "logic" check
now accepts a gate OR a flip-flop). digitalgate-engine-examples: a correctness
test clocks the real counter example and asserts it counts 1..15,0 in binary.
Verified live (?digitalgates default ON): the counter counts 0..6 on the canvas;
and the complex examples all work - comparator-4bit (A=B correct), decoder-3to8
(perfect one-hot x8), alu-slice-1bit (32 combos deterministic), multiplier-2x2
(3*3=9, 7 distinct products), adder-subtractor-4bit (5+3=8).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Flip-flops are edge-triggered and hold state, which the combinational settle
kernel cannot model alone. buildDigitalNetwork now gives each flip-flop explicit
state + rising-CLK-edge detection (reusing the LogicGateParts sample semantics):
sample the data nets on the edge, drive Q + Qbar. Because a flip-flop only
updates on the clock edge, a Q->D / Q->CLK feedback (counter / shift register)
does not oscillate the settle loop. isAllDigital now accepts a gate OR a
flip-flop, so pure sequential circuits qualify.
Test digitalgate-sequential (4): D (capture + hold), T (toggle), JK
(hold/set/reset/toggle), and a 2-bit ripple counter (FF0.Qbar clocks FF1)
counting 1,2,3,0,1 - impossible on the SPICE path (no edge detection at DC, no
SPICE mapper). The controller already routes all-digital circuits through
buildDigitalNetwork, so a board-less counter/shift-register example would run
live; authoring those gallery examples is the only follow-up. Full digitalgate
+ examples-digital + circuit-simulation-service suites green (127 tests).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
buildMixedNetwork evaluates the gate (digital) side of a MIXED circuit on the
settle kernel and exposes the boundary with the analog (ngspice) domain. Unlike
buildDigitalNetwork it does not bail on non-primitive components - those are the
analog side; their pins mark the nets they touch as boundary. Exposes
boundaryNets, readBoundary(net) (digital->analog: the gate-driven level to seed
an ngspice voltage source) and setBoundaryInput(net, level) (analog->digital:
ngspice's solved+thresholded level, which re-evaluates downstream gates).
Test digitalgate-mixed-boundary (4): the boundary nets are exactly the
digital/analog bridges; both directions track; a digital->analog->digital
coupler loop converges. No ngspice needed - the analog side is supplied by the
test. Wiring the handoff to the live ngspice netlist (0/Vcc sources + threshold
+ settle<->solve iteration) is the remaining step; it needs the running solver
(the node loader is broken by a pre-existing path bug) and a mixed example.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Phase 4 (brought forward before the mixed-mode boundary). digitalgate-sweep
proves the engine handles 38/38 gallery digital examples: every one builds,
resolves every LED, and never oscillates. Tightened isAllDigital to also require
at least one logic gate, so a degenerate analog {source, resistor, LED} circuit
stays on ngspice rather than being claimed by the digital path. Flipped
digitalGatesEnabled() default to ON (override with ?digitalgates=off).
Full frontend suite 2120 pass / 5 fail — the 5 are the same pre-existing
unrelated failures (ngspice node-path, attiny85 arduino-cli, component-to-spice
catalog); the default flip adds no new breakage and examples-digital +
circuit-simulation-service stay green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Board-less digital circuits (logic gates + switches + LEDs) run today as ngspice
analog B-sources, which is fragile for deep logic: a 4-bit ripple adder re-solves
but never lights its result LEDs live. This adds an event-driven digital motor
that reuses the multichip-bus settle kernel, so the same engine that boots a Z80
over a chip bus evaluates a gate network exactly and instantly.
Phases 0-2 (project/digital-gate-engine/), all behind ?digitalgates=on (default
OFF — flag off is byte-for-byte the old behaviour):
- digitalGateEngine.ts: buildDigitalNetwork(components, wires) does union-find
over the wires (merging pass-through resistors), identifies the rail/gnd from
the signal-generator, registers drivers (rail STRONG-1, gnd 0, pull resistors
PULL, slide-switch as a pass-gate) and event-driven gates (reusing the
LogicGateParts boolean semantics), settles on busKernel, and exposes
setSwitch / readLed / netOf. Tolerant of both the raw example `type` and the
store `metadataId`. Returns {ok:false} for any non-primitive, so mixed/analog
circuits stay entirely on ngspice.
- digitalGateController.ts + a SimulatorCanvas useEffect: when the flag is on and
the circuit is all-digital, rebuild from the store on switch-toggle / load
(rAF-coalesced) and paint the wokwi-led DOM. CircuitSimulationService.tick()
skips the SPICE solve for all-digital circuits when the flag is on, so the two
motors never fight over the LEDs.
Tests: digitalgate-kernel (22 — single gates -> half/full adder -> 4-bit
adder/subtractor -> exhaustive ADD 256 -> mux/decoder/comparator/parity/
multiplier) and digitalgate-engine-examples (6 — the real gallery data for
and/or/xor/not + the full adder/subtractor). Verified live: ?digitalgates=on
lights the adder's result LEDs that the SPICE path leaves dark. Full suite
2117 pass / 5 pre-existing unrelated fails.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The gallery example loaded but the Z80 never visibly ran: the screen stayed
frozen on garbage. Two multi-chip async-load races, neither caught by the
existing headless tests (which drive RESET manually and attach the display
before boot):
1. RESET edge-vs-level race. The Z80 only left reset on the RISING edge of
RESET (a pin watch). In the browser the 7 chips instantiate asynchronously,
so the small power-on-reset chip releases RESET before the larger Z80 has
registered its watch -> the edge is lost and the CPU stays in reset forever.
Fix: on_clock samples the RESET level (hardware-accurate; RESET is
level-sensitive) so a missed edge self-corrects. An undriven RESET reads low,
so the CPU safely stays in reset until something drives it high.
Repro/guard: chipbus-galaksija-reset-race (race ordering must still boot).
2. Display-snoop load-order race. galaksija-display was a passive write-snoop;
the ROM paints the screen ONCE at boot then idles, so a display that comes up
late misses every write and shows stale content forever. A snoop cannot
recover writes it never saw. Fix: fold the screen into the RAM chip
(galaksija-ram-display) and render from the ACTUAL video RAM (0x2800-0x2BFF,
internal 0x0800 with A0-A12 wiring) on a ~30 fps timer - correct regardless
of load order, exactly how the real machine scans video RAM.
Repro/guard: chipbus-galaksija-display-snoop-race (late snoop shows nothing)
+ chipbus-galaksija-ram-display (renders even when first paint is post-boot).
The example now has 6 chips (RAM+display merged, gdisp dropped), 76 wires.
Verified live in the browser: boots to "@'READY", shows the ">" prompt, and
pressing A echoes ">A_" through keyboard -> Z80 -> video RAM -> display. The
full chipbus suite is 45/45.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Adds a memory-mapped keyboard so you can type into the Galaksija. Based on
the libretro Galaksija core's scheme (not guessed): reading 0x2000+offset
returns 0xFE when the key at that matrix offset is held, 0xFF otherwise;
the keyMap gives the offset per key ('A'=1 ... Enter=48, Space=31, etc.).
- galaksija-keyboard.c: drives reads of 0x2000-0x203F from a keys[] table and
exports set_key(offset, down) for the host to push key events. Never drives
outside the keyboard range.
- galaksija-ram.c: ram-64k variant that yields reads of 0x2000-0x203F to the
keyboard (writes still go to RAM), so the two never fight for the bus.
- ChipRuntime: ChipInstance.hasKeyboard + setKey() expose the chip's set_key.
- CustomChipPart: bridges browser keydown/keyup (by KeyboardEvent.code, via
GALAKSIJA_KEY_OFFSET) into the chip, ignoring keystrokes while the code
editor or an input is focused so typing code is never hijacked.
- The gallery example gains the keyboard chip (now 7 chips, 99 wires) and uses
galaksija-ram.
Test chipbus-galaksija-keyboard: pressing 'A' (offset 1) makes the BASIC
monitor echo "A" after its ">" prompt and advances the cursor. 41 chipbus
tests pass.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Ships the full Galaksija (1983 Z80 home computer) as a runnable Retro
gallery example, plus the pieces needed to run a multi-chip bus live in the
browser.
Gallery example (examples-retro-intel.ts, id 'galaksija-z80-computer'):
Z80 + galaksija-rom (public-domain ROM A+B) + ram-64k + inverter (A13
decode) + galaksija-display + a power-on reset chip, wired chip-to-chip
over the bus (76 wires), no board. Click Resume and it boots the real ROM
to the "READY" prompt on the green display. Chip wasm is embedded
(wasmBase64) so it runs without a backend compile.
- ChipRuntime.tickTimers gains a wall-clock budget (CustomChipPart passes
6 ms): a faithful-but-slow event-driven bus can't run a real-time CPU in
one animation frame, so without a cap a Z80 fetching over the settle
kernel froze the tab. With the budget the sim advances slower than real
time (boots over a few seconds) and the UI stays responsive; fast
single-chip examples finish under budget and are unaffected.
- galaksija-display: blits its framebuffer on a ~30 fps timer instead of on
every character write, so a clear-screen burst doesn't flood the canvas.
- reset-gen: power-on reset (pulses RESET high, ties WAIT/BUSREQ/INT/NMI
high) so the machine boots on Resume without a manual reset.
- chipbus flag now defaults ON (override with ?chipbus=off): chip-to-chip
buses are a core capability; single-chip and board nets never take this
path, so the only thing enabled is multi-chip buses, previously broken.
Verified live in the browser: the example boots and renders "@'READY" with
the ">_" prompt, responsive. Full suite 2084 pass (5 pre-existing,
unrelated env failures).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Galaksija stores ASCII codes in its 0x2800 video RAM (verified by snooping
the boot: it writes "@'READY" + ">_" prompt). The original CHRGEN ROM uses
a hardware-specific addressing that does not map char-code*8 to a glyph, so
rendering through it produced garbled output. Render the ASCII codes with
the public-domain IBM/VGA 8x8 font (font8x8 by Daniel Hepper / Marcel
Sondaar) instead -- legible green-on-black phosphor text. The boot screen
now reads "@'READY" with the ">_" input prompt, exactly like a real
Galaksija. Tests updated to check the bright-green channel.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
galaksija-display.c: a 32x16 text video chip that renders the Galaksija
video RAM. It is a passive bus snoop -- watches WR + address + data, and on
a write into the 0x2800 video region stores the character and renders that
cell into a 256x128 framebuffer using the public-domain CHRGEN font (code*8,
bit 0 = lit). It never drives the bus. The host blits the framebuffer to the
chip canvas (vx_framebuffer_init / vx_buffer_write).
Two tests:
- chipbus-galaksija-display: snoop+render smoke test (a write of 'R' to
0x2802 lights its cell; unwritten cells stay blank).
- chipbus-galaksija-computer: the COMPLETE machine over the chip-to-chip bus
(Z80 + galaksija-rom + ram-64k + inverter decode + galaksija-display) boots
the public-domain ROM and renders the monitor's "READY" prompt on screen.
40 chipbus tests across 10 files pass.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The public-domain Galaksija ROM (Voja Antonic; ROM A monitor + integer
BASIC, ROM B float BASIC, 8 KB) runs on a standalone Z80 + external ROM +
RAM + an inverter for address decode, all chip-to-chip over the shared bus,
no board:
ROM 0x0000-0x1FFF rom.CE = A13
RAM 0x2000-0x3FFF ram.CE = NOT A13 (the inverter chip)
RD -> both OE ; WR -> RAM WE
Pin-level boot proof (mirrors test_intel/test_z80/galaksija.test.js): watch
M1, read the address bus on each opcode fetch, and confirm the Z80 leaves
the reset vector (DI; SUB A; JP 0x03DA), reaches the init routine at 0x03DA,
and runs 1000+ fetches across 50+ distinct ROM addresses -- the real
firmware executing end-to-end through the settle-kernel bus. The on-screen
"READY" prompt is the next milestone (needs the video display chip
rendering the 0x2800 video RAM).
galaksija-rom.c embeds the public-domain ROM A+B image. 38 chipbus tests
across 8 files pass.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The architectural heart of the retro computer, proven on real chips. A Z80,
a 32K ROM, a 64K RAM and an inverter (address-decode glue) are wired
chip-to-chip over a shared address + data bus, no board:
ROM at 0x0000-0x7FFF rom.CE = A15
RAM at 0x8000-0xFFFF ram.CE = NOT A15 (the inverter chip)
RD -> both OE ; WR -> RAM WE
The ROM program writes 0x5A to RAM at 0x8000, clears A, reads it back, and
HALTs only if the byte survived. HALT going low proves the full core works:
the Z80 runs from ROM, the inverter decodes A15 to select RAM (the settle
kernel drives the combinational glue across hops), and the RAM latches a
write and returns it on a read over the shared tri-state bus, all within
synchronous bus cycles. Adds z80-ram-rom.c (boot image) + ram-64k/inverter
fixtures. 37 chipbus tests across 7 files pass.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
End-to-end validation of Phases 0-2 on an actual CPU. The real Z80
(examples/intel/z80.c) and a 32K EPROM (z80-boot-rom.c, a rom-32k variant
holding JP 0x0006 / HALT) are wired chip-to-chip over a shared address +
data bus with no board. RD drives the ROM's OE; CE is left enabled.
Booting exercises all three phases at once: the Z80 drives the address ->
the ROM reacts on the shared net key (Phase 0); asserts RD -> the ROM
tri-state-drives the data bus while the Z80 released it (Phase 1); and reads
the data bus in the SAME tickTimers step, getting the settled byte
(Phase 2 settle-before-read). The Z80 fetches C3,06,00, jumps to 0x0006,
fetches 76, and HALTs -> drives HALT low, which the test observes.
z80.wasm is compiled from the committed examples/intel/z80.c; the boot ROM
source + chip.json live in test_custom_chips/sdk/examples. All 36 chipbus
tests pass.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Fixes root cause B: a CPU bus cycle drives address+strobe then reads the
data bus in the same tickTimers call, so the memory chip must react before
the read. Phase 0/1 applied each net change by firing PinManager listeners
immediately, which recurses one JS frame per hop - deep glue chains are
deep recursion and a combinational loop overflows the stack.
- busKernel.ts: a delta-cycle settle loop. A net change is recorded in a
pending set, not applied recursively; settle() drains it in batches
(deltas), applying each and letting the driven chips re-dirty the next,
until a fixed point or DELTA_CAP trips (oscillation -> warn, not hang).
Two-phase: a drive lands in pending and is applied on the next delta, so
a chip evaluating mid-settle reads last-stable nets. The first drive of a
cycle settles synchronously before returning to the chip's C code, so the
in-cycle vx_pin_read sees settled data.
- busNets: publishes resolved levels through the kernel instead of calling
triggerPinChange directly.
Tests (chipbus-buskernel): multi-hop chain settles; settle-before-read; a
5000-hop chain settles without stack overflow; a ring oscillator trips the
cap and warns instead of hanging. The two-real-chip integration still
exchanges 0xA5 through the kernel. Full suite 2079 pass flag-off.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A chip-to-chip net is now resolved by (value, strength), not last-writer-
wins, so a real multi-driver bus works: many chips on one data line, only
the enabled one drives, the rest release to Hi-Z.
- busLogic.ts: 4-valued (0/1/Z/X) + drive-strength resolution. Strongest
driver wins; equal strength + opposite = X (contention); no driver = Z;
pull resistor = pull strength. modeToDrive maps VX_OUTPUT -> strong,
VX_INPUT -> Hi-Z (the rom/ram/8255 "release by input" idiom becomes real
tri-state), VX_INPUT_PULLUP/DOWN -> pull.
- busNets.ts: per-net driver registry; resolves and pushes the resolved
level into PinManager; warns once on contention.
- syntheticPins.ts: isSyntheticNetPin distinguishes bus net keys.
- ChipRuntime.ts: pin register/write/set_mode route bus-net pins through
busNets (gated by chipBusEnabled + isSyntheticNetPin); non-bus pins keep
the legacy path; dispose releases the chip's bus drivers. SPICE source
emission is skipped for bus pins (digital fast path beside SPICE).
Tests: busLogic (14), busNets (6, incl. tri-state hand-off + contention),
and the two-real-chip integration now exchanges 0xA5 through the registry.
Full suite 2074 pass with the flag off.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
End-to-end proof of the chip-to-chip net-key fix through the real
ChipRuntime + PinManager (not a unit stub). Two chips compiled from C
with wasi-sdk:
- bus-driver.c: drives 0xA5 onto D0..D7 at setup.
- bus-reader.c: polls D0..D7 on a 1ms timer, mirrors onto OUT0..OUT7.
Wired chip-to-chip with no board; with the chipbus flag both chips' Dn
pins resolve to one shared net key, so the reader reproduces 0xA5.
- sdk/examples/bus-{driver,reader}.{c,chip.json}: the proof chips.
- __tests__/fixtures/chipbus/*.wasm: committed fixtures (regenerate with
the test_intel/scripts/compile-chip.sh flags).
- __tests__/chipbus-twochip-integration.test.ts: loads the fixtures via a
relative path; skipIf they are absent.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Fixes root cause A of the multi-chip digital bus track
(project/multichip-bus/): chip-to-chip nets were keyed per-endpoint by
syntheticChipPin(chipId, pinName), so two chips on one wire resolved to
two different PinManager keys and never shared a net.
- chipNets.ts: union-find over the wire graph mints one canonical
syntheticNetPin per net; resolveChipNetKey returns it only for pure
chip-to-chip nets (>=2 chip endpoints, no board pin). Reuses the
existing spice/unionFind.ts.
- syntheticPins.ts: add syntheticNetPin(netId), same allocator/space.
- DynamicComponent.tsx: traceDetailed consults resolveChipNetKey at
depth 0 before the chipNeighbour fallback. Board priority (rule 1) and
chip-to-component (rules 2/3) are unchanged.
- Gated behind ?chipbus=on / localStorage.velxio.chipbus (off by default).
Proof (D-008 go/no-go): __tests__/chipbus-netkey.test.ts - a byte written
on one chip's keys is visible synchronously to another via PinManager.
9 new tests; 85 resolver/PinManager/parts regression tests green flag-off.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
ePaper panels rendered rotated/misaligned on AVR and RP2040 (e.g. the 2.13"
Pico clock came out sideways and clipped). The ESP32 worker decoder was just
taught to compose in the controller's native RAM geometry and rotate to the
display orientation, but the browser-side SSD168xDecoder (used by AVR/RP2040)
still composed at display dims with no rotation, so the two diverged.
- SSD168xDecoder.ts: port the worker's native-window compose + rotation.
* Size RAM to the longer side both ways so a rotated native layout
(128x296 behind a 296x128 panel) isn't truncated.
* Compose in the active RAM window, then rotate via the inverse of
Adafruit_GFX setRotation(1). Detect orientation by BYTE width so a
non-multiple-of-8 native width (the 2.13" panel is 122 px) is handled.
* Track the UNION of windows per frame: paged drivers (GxEPD2 page height
< panel) set one partial window per page, so compose must use the full
native area, not just the last page's strip. Fixes the all-white render
on paged panels (1.54" Uno, 4.2" Pico, 7.5" ESP32).
* Add an isBwr option: B/W panels treat 0x26 as a 2nd mono plane (white
only if both planes white), tri-colour panels keep red-wins.
* Default the active window to display geometry; the firmware overrides it.
- EPaperPart.ts: pass isBwr = cfg.palette === 'bwr' to the decoder.
- esp32_spi_slaves.py / esp32_worker.py: mirror the byte-aware rotation +
window-union in the worker, and derive is_bwr from panel_kind on the
runtime sensor_attach path too (fixes the tri-colour ESP32 alert badge).
- test_epaper/ssd168x_decoder.py: re-port the golden reference to match
(keeps the 3-way TS/Python/worker identity invariant). Tests updated to
construct tri-colour cases with is_bwr/palette='bwr'.
- examples-displays-epaper.ts: the Pico VCC wire referenced '3V3(OUT)',
which the velxio-pi-pico-w element doesn't expose (it has '3V3'), so the
wire snapped to the board corner. Use '3V3'.
Phase 1 of the run-system/UX work.
Stop bug: a programmable chip kept running after Stop when a board was present.
The chip rAF tick gated only on board presence (!boardless), so with a board it
ticked forever. Now it gates on the actual run state: board-less -> electrical
paused flag; with board(s) -> board.running. handleStop also clears every chip's
output drives (clearAllChipDrives) and re-solves so chip-driven LEDs go dark on
Stop instead of freezing at their last frame.
Examples to board-less (regulated power supply, no Arduino — the Arduino only
ever supplied 5V):
- z80-larson-scanner -> 'Z80 Comet Scanner': board-less, a faster TWO-LED comet
(scanner.s) so it's visually distinct from z80-larson-no-board's single-bit
walk; green/blue LEDs.
- i8080-killbits -> board-less (psu + resistors), keeps killbits.s as the chip's
editable program; buttons re-powered from the supply.
- i8080-button-counter -> board-less (psu + resistors); behaviour chip, program
baked in, so it shows a note (no editable file) and runs standalone.
banner-streamer stays Arduino-based (its TX/RX go through the AVR USART bridge).
- CustomChipPart: run-state-aware tick gate.
- EditorToolbar: clearAllChipDrives() helper + handleStop clears chip drives.
- examples-retro-intel: 3 conversions; drop now-unused sketch consts; add the
larsonScannerAsm comet program.
- Tests: board+chip routing now uses an inline synthetic example (gallery chip
examples are all board-less).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Two fixes from live testing feedback:
1. Adding a programmable chip (Z80/8080) from the gallery created NO program
group — only the chip(s) from the example had one. Root cause: 'programmable'
was detected by a non-empty programFile, but a fresh chip's programFile is
empty until the user writes one. Now detection uses the canonical signal —
chip.json's programTargets — via isProgrammableChip(). When such a chip
lands with no program yet, the file explorer seeds an editable program.c
(DEFAULT_CHIP_PROGRAM_C, a working walking-LED skeleton) into its own group
and stamps programFile/programTarget onto the component so Compile/Run can
build it. Behaviour/driver and predefined chips (no programTargets) still
get no group — edited in the chip designer.
2. z80-led-chaser-c now runs board-less on a regulated power supply (no Arduino,
mirroring z80-larson-no-board) — the Arduino only ever supplied 5V and added
confusion. chaser.c stays the chip's editable program in its own section.
- romCompileService: isProgrammableChip(), DEFAULT_CHIP_PROGRAM_FILE/_C.
- FileExplorer: detect by programTargets; auto-seed program.c + persist
programFile/programTarget for fresh chips.
- examples-retro-intel: chaser-c -> board-less (psu + 8 resistors + 8 LEDs),
drop the now-unused Arduino sketch const; fix a stale sdcc --code-loc comment.
- Tests: board+chip case moved to z80-larson-scanner (still board-based);
isProgrammableChip unit tests.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A programmable custom-chip (a CPU emulator that runs a ROM/program, e.g. the
Z80 or 8080) now keeps its program (larson.s, chaser.c, ...) in a dedicated
editor file group — group-chip-<chipId> — rendered as its own collapsible
section in the file explorer, exactly like each board owns its sketch group.
Behaviour/driver chips and predefined chips carry no programFile and get no
group; they stay editable only in the chip designer.
Fixes two reported issues on the Z80 examples:
- /example/z80-larson-no-board: the board-less chip example now opens its
program (larson.s) as the active group, editable on the left — previously
the editor showed but no file appeared.
- /example/z80-led-chaser-c: the chip program (chaser.c) no longer shows as
a sibling tab inside the Arduino sketch group; it sits in its own chip
section instead. The board group shows only sketch.ino.
Details:
- useEditorStore: chipFileGroupId()/CHIP_GROUP_PREFIX helpers.
- loadExample: seedChipProgramGroups() routes each chip's programFile into its
own group (seeded from the example files), sweeps stale chip groups, keeps
the program OUT of the board group, and for a board-less chip example makes
the chip group active so the program is the editable file shown.
- EditorToolbar.prepareCustomChips: resolves the program from the chip's own
group (falls back to board files for older projects) before assembling ROM.
- FileExplorer: renders one collapsible section per programmable chip with an
IC icon; clicking switches the editor to the chip group. Lazy-creates a
group for chips dropped on the canvas.
- projectPayload + vlxFile: serialise chip groups alongside board groups and
include them in the dirty-check hash, so chip-program edits persist on
save / autosave / .vlx export and round-trip via replaceFileGroups on load.
- Regression tests for board-less + board+chip routing and stale-group sweep.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Six gpiozero (Python) examples to exercise the Pi 3/4/5 QEMU Linux boards
with different sensors/actuators. All strictly digital — the Pi has no ADC
and PWM is not simulated, so this covers the GPIO in/out paths that work:
- [Pi 3] Blink an LED
- [Pi 3] Running Lights (5 LEDs)
- [Pi 4] Button Toggles LED
- [Pi 4] RGB LED Color Cycle (digital, 7 colors, pwm=False)
- [Pi 5] PIR Motion Alarm
- [Pi 5] Traffic Light
Structure mirrors the existing Pi example (boards[] + vfsFiles['script.py'],
run via 'python3 /home/pi/script.py'); LEDs wired directly like
nano-button-led. gpiozero is used because it works across Pi 3/4/5 (RPi.GPIO
doesn't on Pi 5). Adds a smoke test loading all six (board kind, components,
wiring consistency, gpiozero script present in the VFS).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Cleaner follow-up to the multi-board residue fix. Instead of removing the
extra boards and retyping the surviving one (which left a stale id such as
"stm32-bluepill" on what was now an Arduino Uno), the single-board path now
tears every board down and adds exactly one fresh board of the target kind.
This mirrors the multi-board and board-less paths and guarantees the
surviving board's id matches its kind.
Drops the now-unused setBoardType/activeBoardId destructures and tightens
the boardFilter cast off `any`. Strengthens the regression test to assert
the surviving board's id and kind.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Three reported circuit bugs:
- Deleting the active/running board left the global `running` flag stale
at true. That flag mirrors the active board, but removeBoard reassigned
activeBoardId without re-deriving running, so the circuit looked
"running" (toolbar stuck on Stop, canvas locked) and SimulatorCanvas's
master-switch effect auto-started sibling remote boards. New Project
hits the same path (it removes every board in a loop). removeBoard now
re-derives running from the new active board (false if none remain).
- loadExample's single-board path called setBoardType when boards already
existed but never dropped the extra boards a previous multi-board
example had added, so they lingered as residue. It now removes every
board past the first before retyping, matching the multi-board and
board-less paths.
Adds board-removal-running-reconcile.test.ts (6 regression tests; full
suite 1917 passing).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The boards added in 6813891 (F103CB / F401 pill variants, F4 Discovery,
Olimex H405, Netduino 2/+2) run on the libqemu-arm backend with no
in-browser canvas example, like the existing Blue/Black Pill. Add them to
ACCEPTED_UNCOVERED so the coverage matrix passes.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
stm32-bluepill and stm32-blackpill are Pro features emulated on the backend
via the licensed libqemu-arm QEMU lib (no in-browser canvas engine, same as
the Raspberry Pi boards), and their gallery examples are intentionally not
shipped to the free tier. Add them to ACCEPTED_UNCOVERED so the board-kind
coverage matrix passes — this was missed when the boards were introduced.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Same root cause as the previous test fix in 3e38397 — upstream commit
d64eebc (fix(stop): reset CPU to PC=0) added a hardResetPinStates() call
to useSimulatorStore.stopBoard. The vi.mock factories in these 6 ESP32-
adjacent test files only exposed updatePort/onPinChange/getListenersCount,
so any test path that hits stopBoard crashed with "is not a function"
once the real prod code called the new method.
Each gets a single-line addition: this.hardResetPinStates = vi.fn();
Verified with full vitest run: 127 files pass, 2,005 tests pass, 0 failures.
🤖 Generated with Claude Code
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1. multi-board-integration.test.ts — PinManager mock was missing
hardResetPinStates(). Upstream commit d64eebc (fix(stop): reset CPU
to PC=0) added that method to PinManager and useSimulatorStore.stopBoard
calls it, but this test's vi.mock factory never exposed it. Result:
"TypeError: getBoardPinManager(...)?.hardResetPinStates is not a function"
even though the optional chain looks safe — the chain only short-circuits
on null/undefined, not on a non-function property.
2. vitest.config.ts — was missing the @velxio alias that vite.config.ts
defines. defineConfig from vitest/config does NOT auto-inherit from
vite.config.ts; the alias has to be re-declared. Without it, overlay
tests importing @velxio/store/useEditorStore failed with "Cannot find
package '@velxio/...'" even though the build (which DOES inherit the
alias) resolves them fine.
Verified: full set of 3 previously-failing tests now pass cleanly
(multi-board-integration: 43 passed, snapshot: 0, pinIntrospection: 10).
🤖 Generated with Claude Code
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Reporter feedback after 7aca3db: pressing Stop on the uno-7segment
example turned the 7-segment off, and pressing Start again left
random segments lit / no number at all. The previous fix made
resetPinStates() notify every listener with (pin, false) on both
Stop and Reset, which was right for Reset (full reboot) but wrong
for Stop:
- On Stop the AVR CPU is just paused. Internally it still has
PORTD=0xFF (or whatever the last drive was).
- resetPinStates blanked the pinStates cache + fan-out LOW
notifications. Display turns off, fine.
- On Start the CPU resumes from where it paused. avr8js's port
listener fires only for bits that CHANGED relative to its OWN
oldValue (which still holds the pre-stop value). If oldValue
matches the live register, no pinChange event fires for that
bit, and the display has no signal telling it to come back on.
Split the API into two methods:
resetPinStates() — soft cleanup, drops outputPins only. Used by
stopBoard. Cached pinStates and visual state
stay so the resume picks up where it left off.
hardResetPinStates() — full cleanup, drops outputPins + pinStates
and fan-outs (pin, false) to listeners.
Used by resetBoard (CPU starts at PC=0,
firmware re-drives every pin from setup()).
Updated the test helper clearAllPinManagerState to call
hardResetPinStates between tests so the same-state short-circuit in
triggerPinChange doesn't suppress fresh events.
All 32 vitest tests pass (AVRSimulator, interconnect-routing,
dual-arduino-software-serial, pin-position-rotation).
User report: "rotating components messes up their connections" — pressing R
on a placed component visibly slid every wire endpoint off its pin tip.
Root cause: the DynamicComponent wrapper has padding:4px + border:2px on
EVERY side, so the inner web-component element sits 6 px in from the
wrapper top-left on BOTH axes. The wire layer assumed an asymmetric
(4, 6) offset, baked into:
* useSimulatorStore.updateWirePositions — store.x + 4, store.y + 6
* useSimulatorStore.recalculateAllWirePositions
— start (startComp.x + 4, startComp.y + 6)
— end (endComp.x + 4, endComp.y + 6)
* pinPositionCalculator.calculatePinPosition — inverse: (componentX - 4, componentY - 6)
Unrotated the 2 px X bias was visible only as a very-slightly-off wire,
which nobody filed. When the user rotated the component, the bias
rotated WITH it — at 90° it became a 2 px Y offset (wires hanging below
the pin), at 180° a 2 px X offset on the other side, at 270° upward. UX
read as "wires disconnected".
Verified the real CSS box via chrome-devtools-mcp against several live
components on velxio.dev (RGB LED + 3 resistors + analog joystick): all
report padding-left/top = 4 px, border-left/top = 2 px, inner offset = 6
on both axes.
Fix: use (+6, +6) at every site, single source of truth in a comment
explaining padding+border arithmetic. Updated the rotation regression
test to match the corrected math (numbers shift by 2 px on every
expectation that referenced the old offset).
Pin position math, pivot derivation and the rotate-N×90° round trip
unchanged — only the offset constant moved.
Snapshot was written when the example had components: [] and wires:
[]. Now that 5bd541e populated the circuit (OLED + 2 buttons) and
2dea3f0 renamed the OLED pins to match wokwi-ssd1306's real
pinInfo, the netlist contains the button pull-down resistors and
floating-net autopulls. Regenerated with `vitest run -u`.
Real digital storage scopes have a trigger that pins the visible window
around a detected edge — without it, sparse activity (UART bytes once
per loop, an interrupt firing every few seconds) scrolls off the screen
faster than the eye can catch. Velxio's scope was free-running only,
which made the recent UART TX waveform work effectively invisible at
fine time/div settings: the byte burst was 87 µs but the window only
showed the most recent 1 ms.
Three trigger modes, matching what you'd find on a Rigol / Tektronix:
* Auto — current free-running behaviour, window's right edge
tracks the most recent sample. Default.
* Normal — window pins around each triggering edge so the event
lands at `triggerPosition * windowMs` from the left
(default centred at 0.5). Keeps re-pinning on every
new triggering edge.
* Single — arms once, freezes the trace on the first triggering
edge by flipping `running = false`. User clicks
"Re-arm" to capture again.
Three knobs configurable per mode:
- source: which channel produces the trigger event
- edge: rising (↑) / falling (↓) / either (⇅)
- position: trigger lands at this fraction of the window
(UI hard-codes centre 0.5 for now; the store field
accepts any value if we want a draggable handle later)
UI additions in the scope header (only shown when mode != auto):
- source / edge dropdowns
- status badge (Armed / Triggered / Captured) with pulse animation
on Armed so the user knows the scope is waiting for an event
- Re-arm button in Single mode after capture
Canvas changes:
- Dashed orange "T" marker drawn at the trigger position when an
edge is latched and within the visible window.
Store changes:
- pushSample peeks at the trigger channel's previous state, detects
a matching edge, sets triggeredAtMs (and stops `running` for
Single mode). matchesTriggerEdge() exported for unit testing.
- clearSamples / setTriggerMode / setTriggerChannel / setTriggerEdge
all re-arm the trigger; rearmTrigger() explicitly resets and resumes
capture (used by the Re-arm button after a single-shot).
Covered by 11 new vitest cases (oscilloscope-trigger.test.ts) plus the
existing 1892 tests still pass.
Closes the "I set 0.1 ms/div on a Serial.print sketch and see a flat
line" UX trap reported on the Discord follow-up — at 0.1 ms/div the
window is 1 ms but bytes fire every 2 s, so without a trigger the
chance of catching the burst is < 0.05 %. With Normal trigger on
rising D1 the burst pins in the middle of the window and the user can
zoom down to bit level (8.68 µs each) without losing it.
avr8js intercepts the transmitted byte at the UDR0 register and never
toggles the corresponding GPIO. Real ATmega328P / ATmega2560 hardware
drives PD1 / PE1 with a start bit, 8 data bits LSB-first, and a stop bit
at the configured baud rate the moment TXEN is set. An oscilloscope
probe on D1 therefore showed nothing in Velxio while the same probe in
the real world would resolve the UART frame.
Synthesize the frame from the inside of `onByteTransmit`:
* Read `usart.baudRate`, `usart.bitsPerChar`, `usart.parityEnabled`,
`usart.parityOdd`, `usart.stopBits` so unusual configurations stay
accurate (avr8js already exposes these as public getters).
* Build the bit list start + data(LSB first) + parity? + stopBit(s).
* For each transition vs. previous state (initial = idle HIGH), call
`onPinChangeWithTime(1, state, timeMs)` where
`timeMs = (cpu.cycles + i * cyclesPerBit) / 16_000`. Same
simulator-time clock the existing port-listener path uses, so the
scope draws the UART waveform cycle-accurately alongside other GPIO
activity.
Also hook `onConfigurationChange` to detect TXEN flipping 0→1 and seed
the scope baseline at idle HIGH; without that, the very first byte's
start bit transition would be invisible because the scope's pre-first-
sample default is LOW.
Both USART construction sites (initial setupSimulation around line 423,
re-init after stop around line 749) get the same hook.
Covered by `__tests__/avr-uart-tx-waveform.test.ts` (5 cases): idle seed,
byte with internal transitions, 0xFF edge case, TXEN-disabled no-op,
bit-period timing.
The previous test fed `xValue: 0, yValue: 1023` to the analog-joystick
handler and asserted X=0V, Y=5V. That was wrong for two reasons:
1. wokwi-analog-joystick emits direction (-1/0/+1), never 0..1023, so
the fixture didn't match how the real component behaves.
2. The OLD `(value/1023) * vcc` mapping happened to produce 0V for 0
and 5V for 1023, so the broken test still passed against the
broken handler — and now breaks against the correct one.
Switch the fixture to `xValue: -1, yValue: 1` so the expectations line up
with the corrected handler (-1 → 0V, +1 → Vcc) and exercise the same
voltage rails the real component will produce.
Two related bugs that surfaced as "Vitest worker exited unexpectedly /
Timeout terminating forks worker" on the circuit-simulation-service
test file.
Bug 1 — tick() recursively re-schedules itself in its finally block.
After afterEach disposes the scheduler via __resetMixedModeScheduler(),
those re-scheduled ticks throw "call loadCircuit first", get caught by
the console.warn, and the finally schedules ANOTHER tick. Infinite
Promise loop survives until the worker OOMs.
Fix: add CircuitSimulationService.stop() that flips a `stopped` flag
short-circuiting tick() + handleMcuEdge(). The test harness now
tracks each started service in _activeServices and calls stop() in
afterEach alongside the existing unsubscribe sweep.
Bug 2 — when an MCU edge fires on a pin that's NOT wired into any net
(buildNetlist skips it because netLookup returns null), handleMcuEdge
sees hasSource=false, self-heals by queueing the edge + tick(). The
rebuild still doesn't emit the V-source (no wire), so tick.finally
replays the edge → self-heal again → tick again → infinite loop AT
RUNTIME, not just in tests. A user toggling a digital pin without a
wire freezes the whole circuit simulation.
Fix: in tick.finally's pendingMcuEdges replay loop, check whether
the rebuilt netlist now contains a V-source for each pending edge's
pin. If not, drop the edge silently — a future canvas tick triggered
by adding the wire will pick it up via the normal subscription path.
Also fix the "coalesces an edge with an in-flight full solve" test
fixture: simpleBoardWithBoard leaves pin 9 unwired, so V_uno_9 was
never emitted and the test was racing the (now-bounded) self-heal
rebuild. Replaced with an inline fixture wiring pin 9 → resistor →
GND, mirroring the wired fixture used by the alter+republish test
right above it.
Full vitest --shard 1/2 + 2/2 pass cleanly (1886 tests, 22-29s per
shard) with no worker-exit warnings.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Three failures introduced by 603b791 (which added
MAPPERS['photoresistor-sensor'] = MAPPERS['photoresistor'] so the
metadata-id 'photoresistor-sensor' resolves to a SPICE mapper):
- component-to-spice.test.ts "every mapped metadataId has a test fixture"
flagged photoresistor-sensor as missing. Added a fixture entry that
mirrors the photoresistor one — the part is electrically identical.
- examples-netlist-snapshot.test.ts > photoresistor-light and
> nano-sensor-station snapshots now contain R_ldr_ldr + R_ldr_pull
cards (correct LDR + 10k pull) instead of the previous
R_autopull_n0 100M stub. This is the intended behaviour change:
before the alias the LDR was unmapped and the netlist autopulled the
net to ground with a 100M dummy; after the alias the SPICE deck
carries the real divider topology. Regenerated only these two
snapshot entries (vitest -u on the single file).
No production code changes.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Two CI failures landed together on master after PR #194 merged:
1. **components-metadata.json stale.** The `power-supply` thumbnail
in scripts/component-overrides.json was updated (grey placeholder
→ branded PSU SVG with voltage/current labels) but the generated
JSON wasn't regenerated. The pre-merge check
`git diff --quiet frontend/public/components-metadata.json` now
fails on master. Fix: `cd frontend && npm run generate:metadata`,
commit the result.
2. **Frontend Tests > test (20/22): vitest worker hang.**
`circuit-simulation-service.test.ts` had been calling
`service.start()` in ~10 tests without storing the returned
unsubscribe handle. Each call subscribes the service to the
simStore; the listener captures the service + scheduler in
its closure. After all tests complete, vitest's forks pool
tries to terminate the worker but the still-active listeners
keep the event loop pinned, producing:
"Worker exited unexpectedly / Timeout terminating forks worker"
All assertions actually pass — only the worker shutdown hangs.
Fix: introduce a `startTracked(service)` helper that records
the unsubscribe in a module-level array, plus an `afterEach`
that drains the array. `__resetMixedModeScheduler()` still runs
after to dispose the scheduler singleton. Replaced all 9 raw
`service.start()` callsites.
Both are independent of any production code change. The fix is
test/scaffolding only.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
PR #192 (spice-led-pipeline) added two PinManager changes that were
not reflected in the test mocks / assertions:
- `setPinState(pin, state)` gained an optional `source: 'mcu' |
'external'` third arg. Production ESP32-C3 / RISC-V simulators
now pass `'mcu'` to mark the call as an MCU output (so the SPICE
collector emits a V-source). The esp32c3-blink and esp32c3-simulation
tests asserted on the old 2-arg shape.
- `resetPinStates()` is a new public method on PinManager called by
`stopBoard` / `resetBoard` to clear cached pin states. The mocks in
esp32-integration.test.ts and multi-board-integration.test.ts did
not add it, so any test that ran stopBoard hit
`TypeError: getBoardPinManager(...)?.resetPinStates is not a function`.
This commit:
- Adds `'mcu'` to the two ESP32-C3 setPinState assertions.
- Adds `this.resetPinStates = vi.fn()` to both integration mocks.
These are pure test fixups — no production code touched. The
`circuit-simulation-service.test.ts > handleMcuEdge` failure
(`expected 1 to be 2`) is a separate regression in production code
introduced by PR #192 and is NOT fixed here.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The SignalRouter path has been in prod through Phase 2.5 / Phase 3.3
deploys without regressions, so the temporary fallback shipped in
commit 77bf897 can come out. Closes#101.
Backend (esp32_worker.py + esp32_lib_manager.py):
- Stop emitting `ledc_update` from the 0x5000 LEDC callback and from
the polling thread. Only `ledc_duty` (channel + duty_pct) and the
GPIO matrix routing events ship now.
- Drop the channel→gpio reverse-lookup that fed the legacy event.
Frontend:
- Delete `PinManager.broadcastPwm` and `PinManager.pwmListenerPinCount`.
- Delete `makeLedcUpdateHandler` + its `channelGpioMemo`.
- Delete `Esp32Bridge.onLedcUpdate` field + the `case 'ledc_update':`
message handler + the `LedcUpdate` type.
- Strip `this.onLedcUpdate = null` from 14 test mocks.
- Rewrite the `does not call broadcastPwm` guard in
esp32-multi-servo-gpio-matrix.test.ts to assert the method itself
no longer exists on PinManager (stronger regression guard than the
spy version, and doesn't need vi).
- Remove the `PinManager.broadcastPwm fallback` describe block from
esp32-servo-pot.test.ts — every test in it exercised the deleted
fallback path.
Docs (ESP32_EMULATION.md):
- Replace `ledc_update` rows in the events / implementation tables
with the SignalRouter trio (`ledc_duty`, `gpio_routing`,
`gpio_routing_clear`).
- Update the visual flow diagram + the "why this matters" paragraph
to past-tense the broadcastPwm bug.
Tests: 1886 frontend tests pass (the previously-failing
board-kinds-coverage test that needed the new Pi Zero/1/2 kinds is
also green). Backend unit suite: 279 pass, the 11 espidf_real_paths
prereq failures are environment-dependent (need arduino-cli libs in
the local shell) and unrelated.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Closes the deferred Phase 3.3. Root-causes the Pi 2 "Attempted to
kill init" panic as `mount /dev/vda` failing with EINVAL — Debian
armmp does not have ext4 builtin (only fuseblk in /proc/filesystems).
- qemu_manager: PI_CONFIGS gains raspberry-pi-zero / -1 / -2 entries.
All three use the armmp armhf kernel + Cortex-A7 CPU + the mmio
virtio transport (arm-32 virt PCI fails -75 due to missing reg DT
property). Pi Zero / Pi 1 get the small 1-core / 512 MB profile;
Pi 2 gets 4-core / 1 GB. QEMU command builder branches on cfg.bus
for virtio-blk-pci vs virtio-blk-device (and serial likewise).
- manifest.json: new `raspberry-pi-armhf` image_set wiring three
assets (kernel + initramfs + zstd rootfs).
- Frontend BoardKind gains the three new kinds + an isPiBoardKind()
helper. Replaces the eight scattered `=== 'raspberry-pi-3' ||
=== 'raspberry-pi-4' || === 'raspberry-pi-5'` branches in
useSimulatorStore, Interconnect, loadExample, boardProtocols.
ComponentRegistry gets three new picker entries.
- board-kinds-coverage test: ACCEPTED_UNCOVERED gains the new kinds
(backend boards have no canvas examples).
The matching armhf build-pi-kernel.sh / build-pi-rootfs.sh changes
live in velxio-prod's scripts/ (private overlay) — the upstream
kernel build script only knows about arm64; armhf is built in the
private repo because the assets ship through the license endpoint.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds the public extension points the velxio-prod overlay uses to bind
real canvas-side I2C/SPI/UART models (BME280, future MCP23017, etc.)
to a running Pi guest's protocol shims:
- qemu_manager: set_pi_slave_handler(fn) / get_pi_slave_handler() for
pi_attach_slave + pi_detach_slave WebSocket messages. OSS image
leaves the hook unset so the messages are silently dropped.
- simulation route: parses the two new WS message types and forwards
them to the registered handler when present.
- RaspberryPi3Bridge: attachSlave(spec) / detachSlave(spec) frontend
side of the protocol.
- piSlaveScanner: at simulation start walks components + wires,
identifies I2C/SPI/UART peers wired to Pi protocol pins (40-pin
header physical-pin numbering), and emits one attach per
bus/address pair (deduped across SDA+SCL wires).
- RaspberryPiWorkspace: invokes the scanner once the bridge is open,
with retries to ride out the WS-still-connecting race.
- integration test: pi3_bme280_attach.py boots the Pi, pre-attaches a
BME280 via the slave handler, runs a host-side proto loop, runs
guest python smbus2.read_byte_data(0x76, 0xD0) and asserts the
console reads back CHIP=0x60.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Pi 4 and Pi 5 were added to the BoardKind union in db5e3a8 ("feat(pi3
phase 3.1+3.2): Pi 3/4/5 family via PI_CONFIGS") but no gallery example
ships for them — same situation as Pi 3, which is already accepted.
All three boot a full Linux image under QEMU on the backend; there is
no in-browser canvas demo to register.
Two follow-ups to the realistic-simulator sprint:
1. component-to-spice.test.ts requires every mapped metadataId in
componentToSpice.ts to have a corresponding MINIMAL_FIXTURES entry
so the catalog-completeness assertion passes. Adds the power-supply
fixture (2 pins, default DC 5V/1A topology).
2. examples-netlist-snapshot.test.ts snapshot for mega-multi-led now
includes the new 8x 220Ω series resistors and the matching
autopull nodes. Net IDs shift from n0..n7 -> n9..n16 because the
resistor adds an intermediate node per LED. Verified the diff is
correct (every added R_r* card is a series resistor between the
board pin and the LED anode) before applying.