Generic platform work ported from the internal line:
- Esp32BridgeFactory seam + rebuildEsp32Bridge + sync-I2C seam: a
substitute simulation bridge (e.g. the hosted editor's in-browser JS
emulators) can be installed without touching OSS code
- Component datasheets: hover popover (ComponentInfoPanel) + markdown
docs for common parts
- Per-chip S3/C3 basics examples for the gallery
- .gitignore: never allow pro emulator mask ROMs into the OSS repo
New: online-only board showcase. Boards implemented by the hosted editor
(ESP32-C6, M5Stack Core, Cardputer ADV, Pimoroni RP2350 family) appear
in the picker as advertisement cards with an ONLINE badge linking to
velxio.com, where they are free to use. Ads auto-hide in any build that
registers the real BoardKind.
Running a multiplexed 4-digit 7-segment clock on ESP32/QEMU froze the
browser for minutes after Run — evaluate probes waited 40-90 s, and before
the first fixes the sim WebSocket eventually died (code 1006) with the page
never recovering. CPU-profiled on staging; four compounding per-GPIO-edge
costs, in profile order:
updateComponentState minted a new components array per edge
------------------------------------------------------------
The store setter rebuilt `components` (and one properties object) on EVERY
edge even when the state didn't change. The breadboard is direct-wired to
13 board pins, so segment toggles produced thousands of store sets per
second; every subscriber re-rendered each time, and the canvas subscription
effect (deps: [components, ...]) re-subscribed all pin listeners in a loop.
Now a no-op guard returns prevState unchanged, and breadboards are treated
as self-managed (they have no visual on/off state to echo).
CompilationConsole re-rendered every log line per editor render
----------------------------------------------------------------
The post-compile console holds hundreds of lines; each render called
Date.toLocaleTimeString per line (~0.2 ms each — it builds a fresh Intl
formatter every call). Profile: 162 s of self time in LogLine over a 337 s
window, in ~150 ms tasks. LogLine is now memoized (entries are immutable),
timestamps go through one shared Intl.DateTimeFormat, and the console
itself is React.memo'd against parent re-renders.
Per-edge full SPICE re-solves
------------------------------
PinManager requested a FULL netlist rebuild+solve on every 'mcu' edge.
Now only the edge that newly classifies a pin as MCU-output triggers the
rebuild (that's what emits the pin's V-source); steady-state updates flow
through connectMcuEdgesToService's per-pin coalesced alterSource path.
The start.ts resolve hook is trailing-throttled (33 ms) for the other
per-edge callers (RP2040, custom chips), the service's pending-edge queue
drains on a 33 ms gap timer instead of replaying back-to-back, and new
edges arriving inside the gap queue instead of soloing a solve.
STM32 / Pi reverse pin-name mappings added to connectMcuEdgesToService so
those boards keep fine-grained updates now that the full-tick storm is
gone (PA0/PC13-style and GPIO-style names never matched before).
wokwi-7segment re-rendered per segment write
---------------------------------------------
element.values now flushes at most every 8 ms per display (trailing write
guaranteed), instead of re-rendering the 32-shape SVG per edge.
Also: CLN (colon) pin support for 7-segment clock faces — wired CLN now
drives colon/colonValue in both the attachEvents path and the QEMU
onPinStateChange path; it was silently ignored, so clock colons never lit.
Verified on staging with the failing project: main-thread probes drop from
40-90 s waits (324 long tasks, 52.6 s blocked in 150 s) to 5-11 ms
(2 long tasks, 179 ms), display shows 12:00 with the colon blinking at
1 Hz from the first seconds after Run.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Three router bugs found by replaying a real agent session (reloj_3333) where
wires ran straight across a seated 4-digit display. Each fix is covered by a
regression test built from the failing geometry.
Endpoint inside an obstacle no longer drops the whole obstacle
--------------------------------------------------------------
Breadboard strips under a seated display start INSIDE its inflated bbox, so
the "rects containing an endpoint are dropped" rule deleted the display as
an obstacle for every wire leaving those strips — 15 wires crossed it end to
end. The rect is now carved instead: an escape corridor (ROUTE_MARGIN wide)
from the endpoint to the chosen edge, with the rest of the body still
blocking. Side blocks overlap the endpoint's row by 1px, or the strict
segment-hit test leaves the row as a free seam straight across the body.
Overlapping rects escape in ONE shared direction
------------------------------------------------
Seated resistors overlap heavily (19px pitch, ~66px inflated boxes). When
each containing rect picked its own nearest edge, the corridors pointed
different ways and walled each other off — A* found no exit, fell back to
the direct elbow, and the wire crossed the display anyway. The escape
direction is now chosen once against the UNION of containing rects and
every carve uses it, so the corridors chain into a continuous exit.
Null route materialises the CHECKED elbow
------------------------------------------
routeAroundObstacles returns null when the PREVIEW elbow (longer-axis-first)
is clear — but the re-route pass stored empty waypoints, which the renderer
expands as the horizontal-first corner: a DIFFERENT elbow the router never
validated. Three wires shipped crossing a display whose checked route was
clean. The pass now materialises previewElbow explicitly, exactly like
finishWireCreation always did.
Verified E2E: the same agent prompt that produced 15 crossings now builds
the ESP32 clock with ZERO wire segments crossing the display body.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Extends the existing component-avoiding A* (wireAutoRoute.ts) into the full
auto-router the canvas was missing. Three pieces:
Wire avoidance with soft costs
------------------------------
Component bodies stay hard-blocked, but wires get graded costs: running
parallel on top of another wire (within an 8px corridor) is charged per px,
a perpendicular crossing costs a small fixed amount, and bends keep their
existing penalty. Crossings must stay possible — hard-blocking wires makes
dense boards unroutable and everything would degrade to the default elbow.
The compressed grid gains "corridor" coordinates 8px to each side of every
wire segment, so the router actually has a lane to run BESIDE a wire; that
is also what lays multi-wire runs out as a tidy side-by-side bus, since
each new wire routes seeing the previous ones. Wires sharing an endpoint
with the route are exempt (wires meeting on a pin must touch there), and
only wires within 120px of the route's bbox participate, keeping the grid
under the coordinate cap on dense canvases.
autoRouted: the system owns the shape until the user takes it
-------------------------------------------------------------
New Wire flag, set by pin-to-pin creation and by agent add_wire. Every
shape-editing gesture (segment drag, waypoint drag, waypoint insert — five
call sites) clears it: from that moment the wire is hand-authored and is
NEVER re-shaped, exactly where the user put it. Wires from older projects
have no flag and are treated as hand-authored.
recalculateAllWirePositions re-routes flagged wires after endpoints move
(component drag end, agent batches, mount settle — never per drag frame).
This is also what routes agent wires at all: they are created before their
elements mount and before pin coords are final, so creation-time routing
is impossible; the settle-timer recalc routes them once geometry is real.
Live routed preview
-------------------
updateWireInProgress routes start->cursor (throttled to 40ms) and the
preview renders that path, so the wire dodges components and wires AS THE
MOUSE MOVES instead of snapping into shape on the final click. Hand-guided
previews (user-placed waypoints) keep the classic path untouched.
Verified in the live app: an agent-built breadboard circuit shows 0 wire
overlap px and 0 body crossings across all wires, and a hand-started wire
aimed collinear with an existing run previews 21px beside it, overlap 0.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A part can land in the store at its FINAL position before its element
mounts: the agent streams add_component and the seating move in one batch,
and updateComponent's reseat then finds no DOM (computeSeating null) and
keeps the empty seating. Nothing re-derived it afterwards — the agent-side
seat correction skips when the position needs no nudge, and 'pininfo-change'
only fires on pin-SET swaps, not on plain init. Meanwhile run_simulation
executes right after the SSE round, before the correction's animation frame.
Net effect, reported by a user as a suspicion that turned out exactly right:
a clock the agent built and ran in one turn showed a dead display, while
reloading the project and running it worked — bb seating wires are persisted,
so on reload they exist before Run is pressed.
DynamicComponent now reseats once the element's pinInfo first becomes
measurable (same polling cadence as the pinInfo-ready effect), which closes
the hole for every path that stores a final position before mount: agent
batches, project load, undo. To keep that free on load,
reseatComponentOnBreadboard skips the store write when there is nothing
seated and nothing to clear — otherwise every off-board part would churn the
wires array identity once per mount.
Verified live end-to-end: agent adds + seats + wires + compiles + RUNS in a
single turn; the seated LED blinks immediately (4 transitions sampled), with
all 4 seated-pin markers present — no reload needed.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Root cause of the 'digits=4 display seated with the 1-digit COM pinout'
bug: property values arrive as STRINGS (agent set_component_property,
the property dialog's text inputs) and were assigned to the web
component verbatim — wokwi's 7segment does switch(this.digits) with
numeric cases, so el.digits='4' silently fell back to the 1-digit
pinout (and 'false' stayed truthy for boolean props like colon).
- DynamicComponent now coerces string values to the TYPE of the
metadata default for that key (number/boolean) before assigning.
- New pininfo-change listener: when a property swaps the element's pin
set (digits, flip, pins edge), the elements announce it — re-derive
the breadboard seating then, with the fresh pinout, instead of never.
Parts now plug INTO the breadboard instead of using it as a junction box:
- Drag magnetism: while dragging, the part's anchor pin snaps to the
nearest hole center (9 px range, 9.6 px grid) so parts land perfectly
aligned, like Wokwi.
- Seating: every pin within 4 px of a hole gets an invisible zero-length
wire (Wire.bb) from pin to hole — the exact model Wokwi persists as
["r1:1","bb1:6t.b","",["$bb"]]. Electrically they are ordinary
wires, so the netlist builder, digital trace and SPICE need zero
changes; they are simply not rendered and not hit-testable. Seating
re-computes on every move/rotation (updateComponent), and moving the
breadboard carries its seated parts along.
- Resistors auto-rotate to vertical when dragged over a breadboard
(their 58.8 px pin span bridges the center trench rows b-f exactly).
- Seat tolerance 4 px: absorbs the worst element pin-spacing residual
(~1.6 px) while staying under half the hole pitch, so a pin is never
ambiguous between holes.
Wokwi interchange fixes that fell out of the diagram.json research:
- import maps the top-level rotate attr onto properties.rotation
(previously every rotated part imported flat) and export emits it
back as rotate instead of leaking it into attrs;
- $bb / empty-color connections import as bb seating wires and export
back as ["$bb"] entries, so parts-on-breadboard projects round-trip;
- wokwi-breadboard-half aliases to the full breadboard (hole names are
a strict superset, so every connection stays valid).
Breadboard elements now export their pure hole grids and import cleanly
without a DOM (node tests); geometry + store seating covered by
breadboard-snap.test.ts and breadboard-seating.test.ts.
Creating a wire with a direct pin-to-pin click (no user waypoints) now
routes around other components' bounding boxes instead of crossing
them. Routing happens exactly once, at creation: the routed corners are
stored as ordinary waypoints, so every later manual edit stays where
the user puts it — never re-routed.
Router (utils/wireAutoRoute.ts):
- tries the preview elbow first (clear -> keep existing behavior and
the WYSIWYG shape), then the opposite elbow, then A* over the
compressed grid spanned by pin coordinates and obstacle edges
inflated by an 8 px clearance, with a 40 px per-bend penalty so
straighter routes win
- obstacles are component boxes only (never boards — pins sit on both
board edges and detouring around a board produces absurd routes),
excluding the wire's own endpoint components, measured from the
rendered DOM; rects containing an endpoint are dropped
- any failure (walled-off target, oversized grid, no DOM) falls back
to the previous direct-elbow behavior
Three wiring quality fixes:
- Rounded corners: every bend now renders as a quadratic curve
(radius 7, clamped to half the shorter adjacent segment), with
round line caps/joins. Segment/waypoint drag previews and the
in-progress preview use the same path builder so the look is
consistent everywhere.
- Degenerate geometry cleanup at render time: the expanded polyline
is simplified (duplicates, collinear runs, U-turns) before the
path is emitted, so wires saved with junk waypoints no longer
render on top of themselves. Stored data is untouched until the
user edits the wire.
- WYSIWYG commit: finishWireCreation materialises the final-leg
elbow exactly as the live preview drew it (longer axis first) and
normalises the stored waypoints. Previously the committed wire
fell back to horizontal-first and visibly changed shape on click.
simplifyOrthogonalPath moved to wireUtils (re-exported from
wireHitDetection for existing imports); the duplicated inline
expansions in SimulatorCanvas now use the shared helper. Waypoint
dots on idle wires removed (visual noise); endpoint dots stay.
Convert the remaining window.confirm() call sites to the in-app
MessageDialogHost, extended with a new confirm mode (Cancel + Confirm
buttons, optional danger styling) via showConfirmDialog().
Sites converted:
- New workspace (EditorPage)
- Load project / delete file (FileExplorer)
- Overwrite SPIFFS file (BoardOptionsModal)
- Delete VFS node (VirtualFileSystem)
All dialog strings are internationalized across the 9 supported locales
(en, es, pt-br, it, fr, zh-cn, de, ja, ru); the two previously
English-only modals now pull from i18n too.
useMessageDialogStore + <MessageDialogHost /> (mounted once in App.tsx)
give a themed in-app dialog callable from anywhere — React components
and plain .ts modules alike via showMessageDialog(msg, {kind}). Swaps
the native alert() calls in FileExplorer (import errors) and the
desktop menu (.vlx open errors, updater status) for it; the pro overlay
can reuse the same store.
When addBoard promotes a board to active (first board, or the previously
active one was removed) it set activeBoardId without syncing s.simulator,
unlike setActiveBoardId which sets both. Parts that read s.simulator - SPI
displays (ILI9341) attach spi.onByte to the active simulator - then wired
onto the previous board's bus and never received data, so a boards[] ESP32
example with a TFT rendered black. Sync simulator to the promoted board
(no-op when the active board is unchanged).
Follow-up to the SSD1306 picker consolidation. All 68 saved projects that used
the retired ssd1306-i2c / ssd1306-spi ids have been migrated to the single
`ssd1306` (metadataId rewritten, protocol pinned), so the simulation aliases
are no longer needed and are removed.
- Auto-detect refined to CS-only: chip-select is the SPI-exclusive signal;
DC does NOT imply SPI (on the 8-pin module DC doubles as the I2C address /
SA0 line, so many I2C circuits wire it). Fixes false-SPI on those circuits.
- The `ssd1306` part honors an explicit `protocol` property when present
(migrated legacy projects carry it) and auto-detects otherwise.
- loadProjectState normalizes any lingering ssd1306-i2c/spi ids (old .vlx
files, pre-migration snapshots) to `ssd1306` + the matching protocol, so
removing the aliases can never blank an old import.
After deleting the default board and adding a different one via the canvas
picker, the editor kept editing the removed board's (now deleted) file group
while compile read the NEW board's default group — so code typed into the
editor was silently dropped and the board ran its default sketch ("compiles
fine but runs the old code"). addBoard now points the editor at the new
board's group when it becomes active, and removeBoard re-points it at whatever
board is active afterwards. setActiveBoardId already did this; the canvas
picker calls addBoard directly. Adds a regression test.
Extend the spice-driven input path (already live for AVR/ESP32) to RP2040 and
STM32 so digitalRead() of an INPUT pin reflects the actual wiring: a pin tied
to a rail reads that rail, and an INPUT_PULLUP button-to-GND reads idle-HIGH /
pressed-LOW instead of floating or inverted.
RP2040 (rp2040js, frontend-only): the GPIO listener now splits input vs output
mode. Input pins report their pad pull (InputPullUp/Down) via setPinPull and
seed the pull's idle level (rp2040js does not auto-apply the pad pull to the
readable input register); the SPICE solve then overrides via connectDigital-
InputsToMcu when the net is actually sourced. Output pins drive as before.
spiceDrivenInputs = true.
STM32 (backend QEMU): the worker now forwards a new gpio_pull event (from the
libqemu-arm picsimlab_pull_pin callback) so the netlist stamps the matching
weak resistor; Stm32Bridge surfaces it, Stm32BridgeShim opts into
spiceDrivenInputs, and collectPinStates maps PA0/PC13 names to the linear pin
so the pull is read. STM32 outputs stay on the part layer (unchanged).
Event-driven parts with no SPICE model (rotary encoder, keypad) remain
protected by the existing sourcedNets gate in the connector.
Re-do the AVR spice-driven digital inputs (reverted in c11c195) the right way so
INPUT_PULLUP buttons keep working. PinManager.updatePort now detects the AVR
internal pull-up (input DDR bit + PORT bit high) and sets the pin pull, so the
netlist stamps the 45k pull-up and an INPUT_PULLUP input reads HIGH at idle.
connectDigitalInputsToMcu drives a pin from the solve only when its net is
source-backed by a RAIL or a COMPONENT card (button switch, divider, cross-board
output) — NOT by the internal pull alone — so INPUT_PULLUP pins wired to
event-driven parts with no SPICE model (rotary encoder, keypad) are left to the
part layer and never clobbered. AVR only; RP2040/STM32 stay on the part-seed
until their pulls are modeled.
The spiceDrivenInputs change (e81450e + f4401cc) fixed plain-INPUT-wired-to-rail
reads but BROKE the far more common INPUT_PULLUP + button-to-GND pattern: the
internal pull-up is not modeled in the netlist, so the input floated LOW and read
as permanently pressed (verified live on the stm32-bluepill-button example).
Revert all the spice-driven-input changes to the pre-fix part-seed behaviour,
which handles INPUT_PULLUP correctly. Proper fix (model the internal pull-up per
board so BOTH patterns work) is a follow-up. Keeps the Pi LED fix.
Extend the source-backed SPICE-driven input fix to the Pico (RP2040) and STM32:
a GP/PA pin wired to a rail or button now reads the right level from the solve,
while floating event-part nets (encoder/keypad/dialer/dip/stepper) stay on the
part layer. RP2040 just opts in (spiceDrivenInputs); STM32 opts in via the
Stm32BridgeShim and connectDigitalInputsToMcu maps PA0/PC13 names to the linear
pin setPinState expects (stm32PinNameToLinear).
An Arduino input wired to a power rail read the wrong level: a pin tied to 5V
read LOW, and a button-to-5V read idle-HIGH / pressed-LOW. AVR inputs were never
fed the solved circuit voltage (only the ESP32 had spiceDrivenInputs), so a
bare-rail input had no driver and buttons fell back to a hardcoded active-low
pull-up seed that ignored the wiring.
Enable spiceDrivenInputs on AVRSimulator, and gate connectDigitalInputsToMcu on
a new NetlistBuilder sourcedNets set (rails, GPIO V-sources, pulls, and any net
a component card touches). Only source-backed input pins are driven from the
solve; floating nets are left to the part layer, so event-driven parts with no
SPICE model (rotary encoder, keypad, dialer, dip-switch, stepper) keep driving
their own pins instead of being forced LOW.
ESP32 digitalRead now reflects the actual circuit instead of a part-level
seed, so a button behaves like hardware — including breaking when it's
mis-wired.
- connectDigitalInputsToMcu: after each SPICE solve, threshold every ESP32
input pin's net voltage (3.3 V LVCMOS, hysteresis) and push the level into
QEMU. Only pins the MCU isn't driving as outputs are injected.
- Esp32BridgeShim advertises spiceDrivenInputs; the pushbutton / 6mm-button /
slide-switch parts skip their direct setPinState seed for such boards and
only flip the component property (pressed/value), which re-solves the
circuit. The connector then decides the level from the real wiring.
- makePinPullHandler no longer seeds the pin; it only records the pull
(netlist resistor) + requests a re-solve, so the read stays circuit-driven.
- GROUND_PIN_RE now matches bare numbered grounds (GND2, GND3) — the ESP32
DevKit element labels its second pad 'GND2', which previously floated.
Net effect: a correctly-wired INPUT_PULLUP button idles HIGH and reads LOW
pressed; a button mis-wired with GND on the wrong terminal reads stuck-LOW,
matching real silicon. AVR / RP2040 keep the legacy part-seed path.
The Pi bridge onPinChange was a no-op, so guest GPIO writes never reached the
PinManager / SPICE solver and wired LEDs stayed dark even though user scripts
printed 'LED on'. Mirror the ESP32 branch: forward to pm.triggerPinChange so
GPIO drives the canvas. Interconnect still preserves and calls this before its
own cross-board routing.
makePinPullHandler drove the post-boot INPUT_PULLUP seed through
getBoardBridge(), which only indexes the Pi bridge map (bridgeMap) — for
an ESP32 it returned undefined and the sendPinEvent seed silently no-op'd,
so the digital input stayed LOW even though the pull config was read and
the SPICE net showed the pulled voltage. ESP32 bridges live in
esp32BridgeMap; use getEsp32Bridge().
Completes the internal-pull emulation for the common case (a button on an
RTC-capable GPIO like 4/15/25/... with INPUT_PULLUP):
- Backend reads the RTC_IO pad RUE/RDE bits via the new
get_internals(QEMU_INTERNAL_RTCIO) and emits gpio_pull for RTC pins, so
pull-up/down on those pads is finally visible (it lives in RTC_IO, not
IO_MUX). IO_MUX path still covers non-RTC pins.
- The digitalRead path is driven by seeding the GPIO input level, not by
SPICE. The part-level INPUT_PULLUP seed (BasicParts) is sent at attach,
before the multi-second QEMU boot finishes, so it is lost and the pin
reads LOW. makePinPullHandler now drives the pin to the pull's idle level
via sendPinEvent when the guest programs the pull (post-boot), so it
sticks. A real button press/release still overrides it.
INPUT_PULLUP / INPUT_PULLDOWN had no effect in simulation: the ESP32's
internal pull resistors live inside QEMU and were invisible to the SPICE
solver, so an input wired to a button-to-GND floated to 0 V and read LOW
even at idle. The canonical active-low button never worked.
Read the pull config straight out of the running guest: the IO_MUX
register (FUN_PU bit 8 / FUN_PD bit 7) is already exposed read-only via
qemu_picsimlab_get_internals(3), so no QEMU rebuild is needed. The worker
scans it on the 100 ms poll thread and emits gpio_pull; the bridge feeds
it to PinManager; the netlist stamps a weak 45k resistor to the rail so
idle inputs read the correct level. 45k matches the real internal pull
and is weak enough that any external driver/pull dominates.
Verified with ngspice: idle ~3.3 V (HIGH), pressed ~0 V (LOW).
- boot_images manifest: bump arm64 rootfs (gpiozero/colorzero baked in,
hostname applied at boot, reworded MOTD)
- RaspberryPi3Bridge: onBooted shell-ready detector + sendAndWaitForPrompt
flow control (resets on disconnect)
- RaspberryPiWorkspace: distinct Booting overlay + piBooted-driven status,
inline SVG icons replacing emoji glyphs
- SerialMonitor: strip CSI/DSR escapes so the dumb console no longer shows
a literal [6n next to the prompt
- VirtualFileSystem: upload auto-starts the Pi and waits for the shell, then
flow-controls each command (no more dropped lines on large files)
- i18n: bootingTitle/bootingNote + reworded offlineNote2 across 9 locales
recordUpdateWire pushed its command with { applyNow: false }, so it recorded the
change for undo but never executed it. Its only callers (the wire colour palette
and the new right-click menu) pass the new colour and expect it applied — neither
pre-applies via the raw updateWire mutator. Net result: changing a wire colour
from the UI did nothing (only the 0-9/c/l/m/p/y keyboard shortcut, which calls
updateWire directly, worked). Drop applyNow:false so it applies like every other
record* command (recordRemoveWire etc.). Adds an undo/redo regression test.
Generalizes the LED's burnout to passive parts via a centralized monitor that
watches the live electrical solve. When a part is stressed past its rating for
a sustained moment it's marked "destroyed": the canvas renders it charred with a
smoke badge and a fault is logged to the output console. Clears on Reset.
Follows the Fritzing-simulator precedent (smoke-on-component) wrapped in a
first-order thermal delay so a brief inrush spike doesn't destroy a part — only
sustained overload (or a catastrophic >=3x overload, instant) does.
- runtimeBurnout.ts: pure stress (resistor power, cap voltage / reverse) + a
thermal-delay burn decision, plus a monitor subscribed to the electrical +
simulator stores. Resistor burns past 2x rated (the verifier already warns at
1x for intentional teaching over-power); a cap bursts over its voltage rating
or on reverse polarity.
- useSimulatorStore: burntComponents set + mark/clear actions; cleared on
Reset / restartParts.
- DynamicComponent + SimulatorCanvas.css: charred filter + smoke badge.
Tests: thermal-delay decision (instant / sustained / spike / cooldown) + stress
computation (resistor power, cap over-voltage, reverse, unwired -> null).
The pre-flight circuit verifier reads branch currents via runNetlist ->
readAllCurrentVectors() (ngSpice_AllVecs enumeration). The production
Web-Worker ngspice WASM build does not surface voltage-source #branch
vectors through that enumeration for an .op plot, so branchCurrents came
back empty and every current rule (short-circuit, LED over-current) read
?? 0 -> no fault. The live solver avoided this by requesting each current
explicitly by name; the Node test build enumerates them, so the gap was
invisible to the suite. Net effect: a 9V battery wired straight to an LED
ran with no warning (reported on project 2840fd12).
- runNetlist: request every V_* source branch current explicitly by name
and merge with the enumeration, so source/LED currents are always present
regardless of the worker WASM's AllVecs behaviour.
- circuitVerifier: non-finite source/LED current -> blocking unstable-solve
fault ("could not solve a stable current - likely a short or a part with
no current limit, e.g. an LED with no series resistor").
- LED runtime (BasicParts): burn out on a non-finite current instead of
falling through to the digital fallback and glowing; raise burnout
threshold 20mA -> 100mA so high-power/RGB channels are not falsely
destroyed; clear the burnt latch on Reset (resetBoard bumps hexEpoch).
Tests: real-data repro, mocked non-finite verifier test, runtime
non-finite / high-power / latch-recovery tests.
All three bugs are rotated components whose pin geometry is computed in a
path that ignores the rotation, so pins/wire-starts land tens of pixels off
the visual pin tips. The live rotate action already recalculates correctly;
these are the paths that didn't.
#231 (context-menu 'Tap a pin to wire'): both onPinSelect handlers in
SimulatorCanvas computed the wire start as getBoundingClientRect().left +
pin.x — adding the UNROTATED pin offset to the ROTATED bounding-box corner.
On a 90-deg HC-SR04 that put the start ~70-100px off (measured). Replaced
with calculatePinPosition(id, x+6, y+6, rotation), the same rotation-aware
helper wires and the pin overlay use.
#232 (rotate -> delete -> undo): recordRemoveComponent's undo restored the
component + wires but never recalculated wire endpoints, so a rotated part's
wires kept the unrotated coords captured at delete time. Added a
requestAnimationFrame updateWirePositions(id) after restore.
#230 + #232 (pin boxes wrong after import / undo / load, 'fixes if rotated
again'): PinOverlay captured the wrapper's layout box (the rotation pivot)
once at mount. On import/undo/load the component mounts already-rotated and
its wokwi-element may not be sized on the mount tick, baking a wrong pivot
that only refreshed when rotation changed. PinOverlay now re-measures after
layout (rAF) and whenever it is about to become visible (showPins dep).
The NTC breakout's SPICE topology was inverted relative to the example
sketch's decode formula (rNtc = R_PULL * v / (5 - v)), which assumes a 10k
pull-up from VCC to OUT and the NTC from OUT to GND. The mapper had the NTC
on top (VCC->OUT) and the pull-down on the bottom, so the recovered
temperature ran backwards: dragging the slider to 100C made the sketch
print -25C. Swap the two resistors so V_OUT = 5 * Rntc / (Rntc + Rpull),
matching the sketch and the hand-built reference netlist in
spice-avr-mixed.test.ts (T=0 -> ADC 789, T=25 -> 511, T=50 -> 270).
Also replace the SensorParts linear approximation (2.5 - (t-25)*0.02) with
the same beta-model divider so the non-SPICE ADC injection decodes back to
the slider value, and drop the dead onInput path that treated the element's
value as a raw ADC count.
Reset now restores interactive sensors (temperature/lux/gas sliders) to
their configured defaults: resetBoard re-dispatches each sensor's default
into the running sim and bumps sensorResetNonce so the open
SensorControlPanel remounts and the slider snaps back. Previously a restart
left the NTC frozen at the last dragged temperature.
Updated the examples netlist snapshot for the swapped NTC cards.
Commit 9360f95 deleted lib/proWifiGate.ts but left useSimulatorStore importing
it (the store edits weren't staged), so a clean checkout of master failed to
build (import of a deleted module). velxio.dev was unaffected — deploy.sh builds
from the working tree, which had the removal applied. Commit the removal so HEAD
is consistent.
Two robustness fixes for the paid-WiFi open-core split:
1. A pi-pico-w board now boots the RPI_PICO_W firmware variant (which has the
`network` module) based on its BOARD KIND, not on whether the WiFi
peripheral happens to be attached. Previously the variant was
`pioPeripheral ? 'pico-w' : 'pico'`, so any moment the peripheral was
absent (see #2) booted the plain Pico firmware and a Pico W sketch crashed
with "ImportError: no module named 'network'". Store boardKind in
attachPioPeripheral and pick the variant from it. (OSS: 'pico-w' isn't
registered, so firmwareConfig falls back to 'pico' — a self-hosted Pico W
has no WiFi engine anyway.)
2. Re-attach the PIO peripheral in loadMicroPythonProgram before loading
firmware. An example deep-link adds the board during render, which races the
pro overlay's async mountPro that installs the CYW43 factory — so the
board-add attach returned null and a PAID user's Pico W booted plain
firmware too. attachPioPeripheral is idempotent; by run time the factory is
installed, so a paid user gets the W peripheral and real WiFi.
Pico W WiFi is a paid overlay feature. A free/web user running a Pico W sketch
that uses WiFi had no peripheral attached -> the simulator picked the plain Pico
firmware (no `network` module) -> the run crashed on `import network` with a
raw Python traceback (on a public example page, no less).
Add lib/proWifiGate.ts (mirrors proBoardGate): a stable doorbell the overlay
fills in. useSimulatorStore gates both loadMicroPythonProgram (before loading
firmware) and startBoard (run backstop for example/loaded boards): if the gate
blocks, fire the upgrade prompt and skip the run. Non-WiFi Pico W sketches still
run for free. No-op in OSS (default 'allow' -> a Pico W runs as a plain Pico).
Move the CYW43439 (Pico W) WiFi emulation out of the open-source tree so it
can ship as a paid feature in a private overlay. OSS keeps a plain Pico W
(no WiFi); the overlay registers the cyw43 protocol + backend network stack
at runtime via generic seams.
Frontend:
- Add simulation/PioPeripheral.ts: a generic "PIO bus peripheral" seam
(feedWord / inDiscardableWriteData / resetFraming / hostWakeLevel /
onHostWake / onSimulationStart). No factory is installed in OSS, so
createPioPeripheral() returns null and a Pico W simulates as a plain Pico.
- RP2040Simulator: keep the fragile PIO-FIFO plumbing (it must re-run after
loadMicroPython swaps the chip) but drive it through PioPeripheral instead
of an inlined cyw43 import (attachCyw43 -> attachPioPeripheral, etc.).
- useSimulatorStore: generic attach/detach + setBoardWifiStatus; drop the
cyw43 bridge map.
- MicroPythonLoader: add registerFirmwareVariant() so an overlay can add the
RPI_PICO_W build; remove the OSS pico-w config + bundled .uf2.
- Delete simulation/cyw43/ (moved to the overlay).
Backend:
- core/hooks.py: add generic register_ws_sim_handler / dispatch_ws_sim_message
and register_gateway_proxy / dispatch_gateway_proxy seams.
- simulation.py: route start_picow / stop_picow / picow_packet_out through the
ws_sim_handler hook (the overlay handles + gates them).
- iot_gateway.py: resolve the Pico W gateway through the gateway_proxy hook.
- Delete services/picow_net/ + picow_net_bridge.py (moved to the overlay).
Tests: move the cyw43/picow suites to the overlay; update RP2040Simulator
mock stubs to attachPioPeripheral.
Wi-Fi sketches on the emulated Pico W associate via the chip's built-in
virtual net (DHCP/ARP answered locally), but outbound traffic had no
route, so DNS/MQTT/HTTP failed with OSError -2.
Wire the emulator's outbound DATA path to the backend picow_net bridge:
- Cyw43Emulator forwards every outbound Ethernet frame EXCEPT DHCP/ARP
(still answered locally) to firePacketOut -> the WS bridge, which NATs
DNS/TCP/UDP to the real internet and injects replies back.
- The virtual net stays ON unconditionally and shares the backend's
subnet, gateway and gateway MAC (10.13.37.0/24, gw 10.13.37.1). Nothing
is mutually exclusive, so an absent or flaky bridge can never break the
Wi-Fi association -- it just falls back to no-internet, as before.
- useSimulatorStore opens the bridge (cyw43.connect()) for Wi-Fi sketches.
Validated end to end against a running backend: WiFi connect + DHCP, DNS
resolves example.com, TCP connect + HTTP GET returns 200 OK. Gated e2e in
picow-bridge-e2e.investigate.test.ts (CYW43_BRIDGE_E2E=1).
For the first deploy, keep the chip emulator's built-in virtual DHCP/ARP
net ON and leave the backend internet bridge dormant (not validated end
to end yet). A Pico W board now associates and gets a link-local IP
locally (isconnected True); outbound internet (MQTT/HTTP) has no route
until the picow_net bridge is wired. Revert is a one-liner in the store
(cyw43.wifiEnabled = hasWifi; cyw43.connect()) + setVirtualNet(null).
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).
(1) The explorer's per-board manifest entry is renamed velxio.json -> libraries.json
and clicking it now opens a READ-ONLY JSON view of that board's declared libraries
(board.libraries) in the editor, instead of the modal. New editor state
manifestViewBoardId: when set, CodeEditor renders a read-only Monaco showing
{libraries:[...]} live; opening/activating any real file clears it. No file is
added to the workspace, so nothing touches compile or save. Library actions are
done in the Library Manager modal (toolbar button).
(2) Drop the 'Uninstall' button for shared index/cache libraries — you can't
uninstall a copy everyone shares (content-addressed cache). Only your own custom
.zip uploads keep a 'Remove' (per-user store). Index libs: just Add to / In project.
Library manifests are now PER-BOARD (each board carries its own velxio.json),
so two boards in one project can use different (even conflicting) libraries
without clashing — the multi-board extension of the no-clash guarantee.
- board.libraries on BoardInstance + serialisableBoard: rides in boards_json,
so it round-trips, dirty-checks, autosaves and restores natively. This also
removes the load-restore hacks (useLibraryManifestStore + applyProjectManifest
deleted): the manifest is plain board state.
- loadProjectState now restores per-board boardOptions/spiffsFiles/libraries
(it previously dropped them).
- EditorToolbar single + compile-all send the COMPILING board's libraries.
- Backend compile.py prefers the client's per-board request.libraries; the
project-level libraries_json (now the union of all boards) is the fallback.
- buildLoadPayload migrates pre-per-board projects: seed each board with the
project union so they keep compiling scoped.
- Library Manager 'In project' tab edits the ACTIVE board's velxio.json (shows
the board name) and the add field is now an autocomplete (installed libs +
index search) so users pick from a list instead of typing names.
Deletes useLibraryManifestStore.ts + applyProjectManifest.ts.
Activates manifest-scoped ESP-IDF resolution for the gallery. loadExample now
records the example's declared libraries in useLibraryManifestStore; EditorToolbar
passes them to compileCode, which sends them as `libraries` in the compile
request. The backend then merges exactly those libraries (P2.0 scope) instead of
picking a stray same-named lib from the shared dir.
Safe: a core-only example sends null (legacy scan-all); a stale/incomplete
manifest degrades to scan-all via the backend graceful fallback, never a wrong
build. Ignored by the backend for non-ESP32 (arduino-cli) boards. Example
manifests were completed (incl. transitive deps) in c671c9b.
Phase 2 of the run-system/UX work.
- BoardInstance gains an optional user ; boardDisplayName(board) resolver
(name || kind label) routes every INSTANCE-label surface: file-explorer
section header, compile console (EditorToolbar), canvas selector/tooltip/
context-menu, Serial Monitor tabs, Oscilloscope board picker, Board Options
subtitle. Board/component pickers keep the KIND label (they pick new boards).
- Inline rename on board AND chip section headers (double-click the name, or a
hover pencil button). Board -> updateBoard(id,{name}); chip -> chipName in
properties. Enter commits, Escape cancels (cancel-flag ref guards the
unmount-fires-onBlur footgun), empty clears to the kind / 'Custom Chip'.
- FileTabs shows an owner badge naming the board/chip whose files are shown
(resolved as a selector so it doesn't re-render on every sim pin toggle).
- CustomChipDialog no longer clobbers a user-given chipName: chip.json's name
only seeds the blank defaults (My Chip / Custom Chip); loading an example
relabels explicitly.
- Persistence: board name round-trips via projectPayload (+ dirty hash),
vlxFile, ProjectByIdPage load + loadProjectState; chipName rides components_json.
- Drive-by: fixed a pre-existing rules-of-hooks violation in BoardOptionsModal
(early return before a useCallback).
Reviewed by a 3-agent adversarial pass (completeness / persistence / correctness);
all major findings folded in.
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>
Velxio can now simulate one or more custom-chip CPUs with NO Arduino/ESP32
board on the canvas — a general-purpose electronics simulator, not an
MCU-only one.
- DynamicComponent: board-less parts get the real shared flat PinManager
(instead of a no-op stub) so a custom chip's digital pin writes/reads reach
the LEDs/inputs wired to it.
- CustomChipPart: the rAF tick respects board-less Run/Stop (freezes while
the electrical sim is paused); board behaviour is unchanged.
- EditorToolbar.handleRun: board-less Run compiles each chip's WASM/ROM and
re-attaches the parts (restartParts) so they pick up the fresh WASM, then
resumes the solver.
- useSimulatorStore.restartParts(): bump hexEpoch to force part re-attach.
- New example "Z80 Larson Scanner (no board)": a programmable Z80 + 8 LEDs +
the adjustable power-supply component, no MCU. The chip drives the LEDs
through the synthetic-pin + ngspice path added earlier.
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>
Adds stm32-f4-discovery, stm32-olimex-h405, stm32-netduino-plus2, stm32-netduino2, stm32-blackpill-f401 and stm32-bluepill-f103cb, mapped to existing qemu-lcgamboa machines (netduinoplus2, olimex-stm32-h405, netduino2, stm32vldiscovery). A generic inline board renderer (no SVG) draws the Discovery/Olimex/Netduino boards from a header pin layout; the Pill variants reuse the Blue/Black Pill SVGs. Per-board onboard-LED pin and polarity via STM32_LED. One blink+serial example per board.
tsc --noEmit clean; all new FQBN pnum variants present in STM32 core 2.12.0; worker smoke tests pass for the new machines.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
STM32 emulation (open-core, runs via libqemu-arm in the backend worker):
- backend: stm32_lib_manager + stm32_worker (GPIO, USART, I2C/SPI device models
reusing the ESP32 slaves, live sensor updates), arduino_cli STM32 branch,
start_stm32 simulation route.
- frontend: Stm32Bridge + Stm32BluePill(/BlackPill) web components (Wokwi SVGs),
board kinds, Interconnect/boardPinMapping/boardProtocols wiring, example
projects (blink, serial, I2C BMP280/MPU6050/DS1307/SSD1306/weather, 7-seg,
RGB, button, switch, stepper, cross-board interconnect).
- Raspberry Pi 4/5 board elements + thumbnails.
Pro board gating (generic OSS->Pro seam; entitlement logic lives in the overlay):
- lib/proBoardGate.ts: isProBoardKind (STM32 + every QEMU Raspberry Pi),
installBoardGateImpl/boardGateDecision, triggerProUpgradePrompt.
- PRO badge on those boards in the component picker; gate at the picker add +
the run backstop (startBoard).
- backend/app/services/board_access.py: server-side enforcement seam for the
simulation WebSocket; STM32/Pi unavailable -> Pro-framed message.
- desktop: generic QemuDownloadPrompt + Stm32QemuPrompt (download-behind-license,
mirrors the ESP32 prompt).
- .gitignore: never ship libqemu-* binaries in the public image.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Phase 1 D1.4 — replaces the binary public/private toggle in ShareModal
with three radio-button-styled options. Optimistic UI: every option
renders for every user; the backend's 403 (with structured
visibility_not_allowed detail) redirects to /pricing?from=visibility_X
so the pricing page can lead the right pitch.
Why optimistic-then-redirect instead of hiding/locking options:
1. Discovery — Free / Maker users SEE Pro unlocks Private. That's the
exact conversion signal the pricing page is trying to surface.
2. Discovery without surprise — the locked click goes to /pricing
with a hint, not a dead modal.
3. Less plan-coupling — this upstream component doesn't need to know
about the pro overlay's plan store. Backend is the only source of
truth for what's allowed.
Touched:
- ShareModal.tsx: full rewrite as a 3-option picker with badges
(Maker / Pro) on the gated options.
- projectService.ts: ProjectResponse / ProjectSaveData now declare
`visibility?: 'public' | 'unlisted' | 'private'`. is_public stays
declared for backward compat with old callers.
- useProjectStore.ts: CurrentProject gains `visibility?`; setVisibility
accepts EITHER the legacy boolean OR the new enum and keeps both
fields coherent.
- common.json (4 locales): new editor.share.visibility.{publicLabel,
publicHint, unlistedLabel, unlistedHint, privateLabel, privateHint}
+ editor.share.updateFailed.
Backend gating + DB migration are in the velxio-prod pro overlay
(commit referencing this submodule pointer).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The previous fix preserved display state on Stop so Resume could pick
up the multiplexed frame seamlessly — but that's Pause semantics, not
Stop. On a real Arduino, hitting the physical Stop is cutting power:
the next Run must boot from setup(), not continue at the saved PC.
User report on https://velxio.dev/example/uno-7segment :
> empieza a contar, le doy stop en el 6, le doy run y sigue desde 6
stopBoard now:
- calls sim.reset() (was sim.stop()) — CPU back to PC=0
- calls hardResetPinStates() (was the soft resetPinStates) — clears
cached states AND notifies listeners so 7-seg / NeoPixel / LCD
blank out instead of freezing on whatever was lit.
Reset and Stop are now the same cold-boot semantics; Reset still
additionally clears serial output + baud rate. The soft
resetPinStates() helper stays for internal SPICE-classification-only
paths that don't want listener fan-out.
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).
Two paired bugs that surfaced on the Reset button.
(1) 7-segment / NeoPixel / LCD freeze on last pattern after Reset.
resetPinStates() was wiping the pinStates cache + outputPins set
silently — no listener notifications fired, so visual components
that update on pinChange kept rendering whatever segments were
lit at the instant the user pressed Reset. Now we snapshot every
pin that was HIGH before clearing and fan out a synthetic
(pin, false) to each registered listener. Stateful displays
redraw cleanly to all-off; passive listeners (analog sensors,
debounce-only buttons) ignore the synthetic LOW and recover on
their next real write.
(2) Cross-board serial silently dies after pressing Reset. resetBoard
was unconditionally reassigning:
sim.onSerialData = (ch) => appendSerial(boardId, ch);
immediately after sim.reset(). The comment said "re-wire after
reset" but reset() does NOT clear that property — the new USART's
onByteTransmit chains through `this.onSerialData` which IS the
Interconnect wrapper. The reassignment destroyed that wrapper and
sibling-board UART forwarding (Uno TX → Nano RX) stopped working
until a full page reload. Same root pattern as the initSimulator
bug fixed in 5480052 — Interconnect's __icSerialHookInstalled
flag is on the live sim, so once the wrapper is blown away
nothing reinstalls it. Removed the reassignment and left a NOTE
so the next person doesn't reintroduce it.
Verified the AVRSimulator + dual-arduino-software-serial +
interconnect-routing test suites still pass (26 tests).
Cross-board UART forwarding silently broke for any project loaded
with > 1 board. User report: Arduino Uno → Arduino Nano serial echo
test where the Uno transmits fine but the Nano's Serial.available()
is never true.
Root cause traced live with chrome-devtools-mcp + temporary debug
logs in AVRSimulator.onSerialData setter and Interconnect:
1. loadProjectState → addBoard(uno) → createSimulator → sim.onSerialData = appendSerial
2. addBoard(nano) → same
3. setWires → Interconnect.updateWires → ensureSerialHook(uno)
wraps sim.onSerialData with a fan-out callback that ALSO pushes
to the Nano's RX queue. __icSerialHookInstalled flag set.
4. SimulatorCanvas mounts → useEffect calls store.initSimulator()
5. initSimulator unconditionally did:
simulatorMap.delete(boardId);
const sim = createSimulator(...); // ← brand-new sim
simulatorMap.set(boardId, sim); // ← Interconnect's wrapper is gone
The new sim's onSerialData is just appendSerial. The old sim
(where the wrapper lived) has been orphaned; Interconnect never
re-installs because its flag was on the discarded sim.
6. Run all boards → Uno.usart.onByteTransmit → this.onSerialData →
appendSerial (Uno's monitor shows TX) but no fan-out call →
Nano never receives anything.
initSimulator is a legacy single-board helper from the days when the
store only knew about one MCU. Multi-board flows already create
their sims in addBoard. Bail out early if a sim for the active
boardId already exists, so the legacy helper becomes a no-op when
the multi-board path has already done the work.
Verified the 3 related test suites still pass (AVRSimulator,
dual-arduino-software-serial, interconnect-routing).
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.
Phase 7.7 follow-up. Previously the WiFi stub returned wlan.isconnected()=False
and ntptime.settime() raised OSError — sketches degraded gracefully but
features like the TIME and WEATHER screens in the smart-ui-eyes example
showed "Sync Failed" / "API Error" instead of real-looking data.
Smart stub now:
- wlan.isconnected() returns True after the first ~2 calls (simulates a
~1 second connection ramp)
- ntptime.settime() pre-loads machine.RTC() with the host's UTC datetime
(captured at code-injection time), so localtime() returns real time
- urequests.get(url) returns a stubbed Response whose .json() decodes a
payload routed by URL substring:
"openweathermap"/"weather" → fake weather dict (temp/humidity/desc)
"ipify"/"myip" → fake public IP
"worldtimeapi" → fake ISO datetime
everything else → {}
- urequests.post/head also stubbed (return {"ok": True} / {})
- Both `urequests` and `requests` aliases registered
End result: smart-ui-eyes example shows real-looking time on TIME
screen and plausible weather data on WEATHER screen, no crashes.
Still no real internet (would need Phase 7 QEMU WiFi emulation), but
visually the example demos correctly.
Inject a compat shim into the raw-REPL prelude that replaces
sys.modules["network"] and sys.modules["ntptime"] with no-op stubs
BEFORE user main.py runs.
Why: the picsimlab QEMU fork's esp32_wifi NIC emulation handles
Arduino's lightweight WiFi.h but not MicroPython's full esp_wifi_init
path. Calling network.WLAN(STA_IF) (which is what every
network-using MP sketch does) drives the firmware to wait on
peripheral status bits QEMU never sets, eventually tripping the
FreeRTOS task watchdog (TG1WDT_SYS_RESET ~26s after boot, or
TG0WDT ~14s if the NIC is partially attached).
With the stub:
network.WLAN(STA_IF).isconnected() -> False
network.WLAN(STA_IF).connect(...) -> no-op
ntptime.settime() -> raises OSError
Sketches that already have try/except around sync_time (which is
most of the 100-days examples) now degrade gracefully: WELCOME +
EYES screens run, TIME and WEATHER screens show their fallback
behaviour, no panic, no reboot.
Doesn't affect Arduino C++ — sketches that #include <WiFi.h> use
real WiFi.begin() and the existing esp32_wifi NIC handles those fine.
A proper fix is to extend the picsimlab WiFi emulation to support
the full ESP-IDF API, but that's a multi-day project. This stub
unblocks the 31 MicroPython examples shipping with network imports.
The hasWifi auto-detection in useSimulatorStore.startBoard only matched
Arduino C++ patterns (#include <WiFi.h>, WiFi.begin). MicroPython
sketches that call `import network` or `network.WLAN(STA_IF)` were
not detected, so wifi_enabled stayed false and the backend never
attached the esp32_wifi NIC model to QEMU.
Symptom: any MicroPython ESP32 example that touches the network
module hangs in network.WLAN(STA_IF) (the constructor that triggers
esp_wifi_init internally) and the FreeRTOS task watchdog trips with
TG1WDT_SYS_RESET ~26 seconds after boot. The chip then reboot-loops.
Mirror the Pico W detector right below this one — it already handles
both Arduino and MicroPython patterns. Now ESP32 does too.
Affects 31 examples in examples-100-days.ts that use network.WLAN.
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.
Closes the same gap as the AVR / RP2040 commits — qemu-lcgamboa's UART
transmits the byte over the WebSocket as a 'serial_output' event with no
GPIO toggle, so an oscilloscope on the ESP32 TX pin saw nothing while
real silicon would render the 8N1 frame at the configured baud rate.
Two changes inside Esp32Bridge:
* New `onPinChangeWithTime: (pin, state, timeMs) => void` callback
that hooks the oscilloscope at parity with AVRSimulator /
RP2040Simulator. The 'gpio_change' event now also flows through it
(timestamped with `performance.now()` — QEMU virtual time isn't
surfaced across the wire, but at 1× sim speed the wall-clock skew
is invisible on any practical sweep). This also fixes the broader
issue that ESP32 boards previously couldn't show ANY digital GPIO
activity on the scope.
* `emitUartTxFrame(byte, uart)` synthesizes start + 8 data LSB-first
+ stop transitions at `this.uartBaudRate` (default 115200) on the
UART0 TX pin, mapped per board variant:
esp32 / esp32-devkit-c-v4 / esp32-cam / wemos-lolin32-lite: GPIO1
esp32-s3 / xiao-esp32-s3 / arduino-nano-esp32: GPIO43
esp32-c3 / xiao-esp32-c3 / aitewinrobot-esp32c3-supermini: GPIO21
Backend doesn't expose the live baud rate so we default to 115200
(the Arduino default). Override path: bridge.uartBaudRate = N
once we surface Serial.begin's argument via a backend event.
Wire-up: `bridge.onPinChangeWithTime = getOscilloscopeCallback(boardId)`
inside the three Esp32Bridge construction sites in useSimulatorStore
(setBoardType, addBoard, changeBoard).
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>
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>
Adds a new BoardOptionsModal accessible from the EditorToolbar that exposes
per-board options (currently used for board-specific compile flags). Wires
the modal through:
- types/boardOptions.ts new BoardOptions shape
- types/board.ts BoardInstance gains `boardOptions` + `spiffsFiles`
- store/useSimulatorStore.ts boardOptions persisted in loadProjectState
- components/editor/EditorToolbar.tsx button to open the modal
- components/simulator/BoardOptionsModal.{tsx,css} the modal itself
- components/simulator/SimulatorCanvas.tsx passes the options through
- utils/projectPayload.ts board options serialised in saved projects
- pages/ProjectByIdPage.tsx re-includes the by-id loader needed for
project URLs that reference boards with
their persisted options.
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>
Backend: extract per-board config into a PI_CONFIGS dict keyed by
board_type. Pi 3/4/5 share the same arm64 image set (kernel +
initramfs + rootfs) and differ only in QEMU -cpu and -m:
raspberry-pi-3 → cortex-a53 + 1G (BCM2837, ARMv8 64-bit)
raspberry-pi-4 → cortex-a72 + 2G (BCM2711, ARMv8 64-bit)
raspberry-pi-5 → cortex-a76 + 2G (BCM2712, ARMv8 64-bit)
PiInstance now carries board_type so the per-board lookup happens
once at start_instance time. Unknown board_type falls back to
DEFAULT_PI_BOARD ('raspberry-pi-3') instead of erroring out (for
back-compat with older clients).
Pre-warm hook walks every unique image_set in PI_CONFIGS so the
provider only downloads each set once even when several Pi models
are registered.
Frontend:
- BoardKind union gains 'raspberry-pi-4' and 'raspberry-pi-5'.
- BOARD_KIND_LABELS + BOARD_KIND_FQBN entries for both new boards
(FQBN null since they use the Pi VFS + Python toolchain like Pi 3).
- ComponentRegistry inserts two new component metadata entries
cloning the Pi 3 board art with different thumbnail colours.
Tag name reused so the same velxio-raspberry-pi-3 web element
draws the board on the canvas — the 40-pin GPIO layout is
identical across Pi 3/4/5.
- boardProtocols.ts: Pi 3/4/5 share the BCM physical→GPIO table
(PI3_BCM) since the 40-pin header layout is identical.
- loadExample.ts: where 'raspberry-pi-3' is special-cased (VFS
ingest, .cpp vs .ino filename), now matches Pi 3/4/5 alike.
- Interconnect.isPi3Bridge() recognises all three Pi family members
so Arduino↔Pi serial routing keeps working.
- RaspberryPi3Bridge constructor gained a boardKind parameter
defaulting to 'raspberry-pi-3'. The WebSocket 'start_pi' message
now ships the actual board kind so the backend knows which
PI_CONFIGS entry to use.
- useSimulatorStore.addBoard wires bridge construction for all
three Pi family members.
Pi Zero/Pi 1/Pi 2 (armhf) come in Phase 3.3 — separate kernel
package + armhf rootfs build, no change here.
Smoke-tested inside the prod container:
Pi 4 (cortex-a72) → reached agetty login on hvc0
Pi 5 (cortex-a76) → reached agetty login on hvc0
Both show 'aarch64' in uname -m.
The user reported the default editor canvas — Arduino Uno + LED +
220Ω resistor — was correctly powered (1.84 V at the LED anode,
14 mA through the diode) but the LED visual stayed dark. Only the
built-in pin-13 LED on the wokwi-arduino-uno element lit up.
Root cause: ngspice's WASM build truncates branch-current vector
keys at the first hyphen. A sense source named V_led-builtin_sense
ends up exposed under a key like v_led#branch rather than the
expected v_led-builtin_sense#branch. CircuitSimulationService and
BasicParts.ts both look up the FULL key, miss, and the LED's
brightness update treats raw as undefined → digital-fallback path
runs but the SPICE memo timestamp is fresh so HOLD keeps zero
brightness. Visible symptom: a perfectly conducting LED that never
lights.
Fix in two places:
- Default canvas (useSimulatorStore.ts): rename 'led-builtin' /
'r-builtin' to 'led_builtin' / 'r_builtin' (and the matching
wire ids).
- DynamicComponent.tsx makeNewComponent: the id template was
'metadata.id-timestamp-rand' producing hyphens for every
user-added component too. Switched to underscores, AND replace
any hyphens already in metadata.id (e.g. 'led-bar-graph') so
the prefix doesn't reintroduce the bug.
Existing saved projects whose ids contain hyphens are not migrated
here — those will keep the visual bug until either the operator
edits the components or we add a sanitisation step inside
componentToSpice + BasicParts. The next follow-up commit can add
that if you confirm this default-canvas fix works.
The default canvas (Arduino Uno + LED on pin 13) wired the LED
directly between pin 13 and GND. Two consequences:
1. Real-world: that's a short across a forward-biased diode,
blowing the LED in seconds.
2. Simulator: ngspice can't find a steady-state branch current
for an unprotected diode (returns NaN / indeterminate), so
the LED visual never lights up. Only the wokwi-arduino-uno
element's BUILT-IN LED (rendered internally by the element,
not via wire+pinManager) was visible.
Fix: insert a 220Ω resistor between pin 13 and the LED anode,
cathode straight to GND. Same circuit every introductory Arduino
book teaches. SPICE converges, LED blinks visually on the canvas.
Reported by a user trying Blink on a fresh /editor visit.
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.
User-reported bug (project 5218f9e3, solar-tracker with 2× ESP32
servos): when LDR values change the servos visibly oscillate between
two positions instead of moving smoothly.
Root cause in useSimulatorStore.makeLedcUpdateHandler. When the
backend emits a ledc_update with gpio=-1 (the per-channel gpio_out_sel
map isn't populated yet on the very first duty change after attach),
the handler called PinManager.broadcastPwm(duty). broadcastPwm fans
the same duty out to ALL registered PWM consumers — for a project
with two servos both subscribed in the 0.01-0.20 duty range, each
broadcast made BOTH servos mirror whichever channel was last
written. Result: servoPan→91° and servoTilt→87° alternating writes
would visibly snap both servos to 87°, then 91°, then 87°…
Two-part fix:
1. PinManager grows `pwmListenerPinCount()` — number of distinct
pins with at least one PWM consumer registered.
2. makeLedcUpdateHandler now keeps a per-board memo of
{ledc_channel → last-known-good-gpio}. On a gpio=-1 update:
- if the channel has a remembered gpio, route there;
- else, only broadcast when there's at most ONE consumer
(single-LED / single-servo setups still work);
- otherwise drop the update — the backend's GPIO out_sel poll
repopulates the map within a few ms and the next ledc_update
arrives with a real gpio.
The drop is correct because the same LEDC channel keeps emitting
duty changes every Servo.write() call (~33 Hz at 30 ms loop delay),
so missing one transient gpio=-1 frame is invisible.
Tests: 1853 pass. The existing esp32-servo-pot tests already cover
the gpio>=0 happy path; the new memo path is exercised indirectly
through that handler.
The mixed-mode migration's endgame. After this commit there is ONE
SPICE solver path in the codebase — the vendored ngspice WASM via
SolverPort, behind both NgSpiceWorkerAdapter (production browser) and
NgSpiceNodeAdapter (Vitest Node). Zero hybrids; zero legacy left to
maintain.
Deleted production files:
• simulation/spice/CircuitScheduler.ts (200ms-poll legacy)
• simulation/spice/SpiceEngine.ts (eecircuit-engine wrap)
• simulation/spice/SpiceEngine.lazy.ts (lazy code-split)
• simulation/spice/subscribeToStore.ts (legacy solve loop)
• simulation/spice/connectLegacySolverToMixedMode.ts (bridge)
• simulation/spice/connectMixedModeSchedulerToStore.ts (feature flag)
Deleted tests (no longer cover any live code):
• connect-legacy-solver-to-mixed-mode.test.ts
• connect-mixed-mode-scheduler-to-store.test.ts
• spice-rectifier-live-bootstrap.test.ts
Migrated 6 tests off the deleted `circuitScheduler.solveNow` API to
the new `__tests__/helpers/solveInput.ts` (same shape, backed by
NgSpiceNodeAdapter).
`useElectricalStore` rewritten as a pure state container:
• setSolveResult(snapshot) — atomic publish from the service
• paused / setPaused — UI control unchanged
• reset — project unload
• REMOVED: triggerSolve, solveNow, setDebounceMs, scheduler hook
• REMOVED: dependency on SpiceEngine.lazy preload
EditorPage now mounts a single `startSimulation()` from
`simulation/spice/start.ts`, which constructs
CircuitSimulationService + ADC bridge + MCU edge bridge. Four
useEffect calls collapsed to one.
`circuitVerifier.ts` (production) and `runNetlist.ts` use an
environment-aware factory: Web Worker in browser, in-proc WASM in
Node tests. `/* @vite-ignore */` keeps the Node adapter chain
(node:fs, node:url) out of the browser bundle while still letting
Node resolve it dynamically.
Removed `eecircuit-engine` from package.json dependencies.
`collectPinStates` extracted to its own module so the service doesn't
depend on the (now deleted) subscribeToStore.ts.
Verification:
• 1392/1392 tests pass across 103 files (28 pre-existing skips).
• `tsc --noEmit` clean.
• `vite build` succeeds (27 s, only the existing chunk-size
warning that pre-dates this work).
Phase 1c — COMPLETE.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
addBoard appended the new board to the boards[] array but never
touched activeBoardId. The default INITIAL_BOARD_ID points at a
board the picker injects on first load — but a fresh anonymous
session (or a project that landed in a state without that initial
board) can have activeBoardId pointing at nothing.
When the agent does add_board('arduino-uno') → compile_sketch, step
2 then fails with "no active board on the canvas" and the model
burns a turn on set_active_board.
The fix: at addBoard time, if activeBoardId doesn't resolve to any
existing board, promote the new board to active. If there IS a
valid active board, leave it alone — manual placements of additional
boards via the picker still keep focus on whatever the user was
working on.
Phase 3 of the OSS / pro split — frontend side. Phase 2 already moved
the auth/DB stack out of the OSS backend; this commit does the same
for the React app. After this, the OSS image is editor + simulator
+ landing + docs only.
What moved to the private overlay (pro/frontend/src/pro/):
pages/{Login,Register,ForgotPassword,ResetPassword}Page.tsx
pages/{Admin,UserProfile,Project,ProjectById}Page.tsx
components/admin/{AdminBoardsTab,AdminDashboardTab,UserActivityModal}.tsx
components/layout/{SaveProjectModal,LoginPromptModal}.tsx
services/{authService,adminService}.ts
store/useAuthStore.ts
hooks/autoSaveImpl.ts
New seams added so OSS components stay decoupled:
* lib/proRoutes.ts — registerProRoutes()/useProRoutes() via
useSyncExternalStore. mountPro() injects the moved pages at runtime;
App.tsx subscribes to the registry, so registration after the
initial render re-renders without a Not-Found flash.
* lib/proSession.ts — registerSessionCheck()/triggerSessionCheck().
App.tsx fires this on mount instead of useAuthStore.checkSession();
pure OSS no-ops.
* lib/proSaveAction.ts — installSaveActionImpl()/triggerSaveAction().
EditorPage's Save button dispatches through this; the overlay
decides whether to show SaveProjectModal or LoginPromptModal based
on auth state. In OSS without an overlay it's a no-op today; in
Phase 4 of the split it becomes the .vlx Export entry point.
OSS-side rewrites:
* App.tsx drops the 8 page imports + 8 route entries; uses
triggerSessionCheck() instead of useAuthStore directly.
* AppHeader.tsx drops the user/login/register block entirely. The
header-auth slot (introduced in Phase 1) now stays empty in OSS
and gets filled by the overlay's portal mount.
* EditorPage.tsx drops useAuthStore + SaveProjectModal +
LoginPromptModal imports. The Save handler is now triggerSaveAction().
* LandingPage.tsx drops the dead UserMenu component (defined but
never rendered) + its useAuthStore imports.
* main.tsx drops the side-effect import of hooks/autoSaveImpl — the
impl lives in pro now and self-registers via mountPro().
Build config:
* vite.config.ts adds @velxio alias → src/. Lets the overlay import
upstream modules (lib/proRoutes etc.) by stable name regardless of
whether it's symlinked (local dev) or COPYed (Docker).
* preserveSymlinks now gated on VITE_PRO_BUILD only (not on serve
mode). Needed so Rollup keeps the overlay logically inside src/pro/
during local junction-based builds.
Build verification:
* OSS-only: 20-ish routes, no /login, /admin, /:username — 285 SEO
pages prerendered. Bundle drops ~80-120 KB.
* OSS + overlay: full 38 routes (30 upstream + 8 from registerProRoutes),
HeaderAuth dropdown injected via slot, save action wired to the
overlay's modal flow.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two minimal hooks so the velxio-pro agent overlay can offer a 'Diagnose
this compile failure with AI' affordance without touching upstream
component internals:
- New store/useCompileLogsStore: holds the editor's compile output as
Zustand state instead of local React useState in EditorPage. The
setter accepts both a value and an updater fn so the EditorToolbar
callers that used setCompileLogs(prev => [...prev, log]) keep
working without changes.
- CompilationConsole header now renders a
<div data-velxio-slot='compile-console-actions' /> when errorCount
> 0. The pro overlay mounts a 'Diagnose with AI' button into this
slot via slotMounter. Empty in the OSS image — no behaviour change.
EditorPage replaces its local useState<CompilationLog[]> with the store
selector. The downstream prop-drilled setCompileLogs callers (toolbar,
sub-toolbars) keep their signature.
Companion commit lands the button + diagnostic prompt builder in the
velxio-prod overlay.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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>
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>
- Implemented `i2c-esp32-real-firmware.test.ts` to test ESP32 I2C communication via backend and WebSocket.
- Created `load-example-transitions.test.ts` to ensure proper loading of examples between board-less and board-based contexts.
- Added `CircuitVerificationModal.tsx` to display circuit verification results before running simulations.
- Developed `circuitVerifier.ts` to perform pre-flight checks for circuit safety, identifying potential issues like short circuits and component overloads.
- Introduced minimal ESP32 I2C master sketch `esp32_i2c_writer.ino` for testing I2C transactions.
- Implement HD44780Decoder for decoding I2C commands to HD44780-compatible LCDs.
- Add bmp280_bridge_reader.ino to read BMP280 chip_id and status registers via I2C.
- Create i2c_scanner_multi.ino to scan I2C addresses and report responding devices.
- Introduce lcd_i2c_hello.ino to demonstrate basic LCD functionality with I2C.
- Implement pcf8574_bidirectional.ino to test bidirectional communication with PCF8574.
- Add pico_i2c_master_reader.ino for reading BMP280 from a Raspberry Pi Pico.
- Create rtc_lcd_clock.ino to display time from a DS1307 RTC on an I2C LCD.
loadMicroPythonProgram only forwarded main.py (or files[0]) to the
bridge for raw-paste injection. Any auxiliary module the project
imported (mylib.py, drivers, etc.) never reached the device, so
`import mylib` died with ModuleNotFoundError.
Build a Python prelude that writes every other .py file to the
MicroPython filesystem via raw REPL, then runs main.py in the same
paste. JSON.stringify produces an ASCII-safe Python-compatible string
literal for the file body, which keeps the prelude inside the existing
chunked-UART path Esp32Bridge already uses to feed the 128-byte FIFO.
The RP2040 path was already multi-file via sim.loadMicroPython(files),
so it stays untouched.
Reproduces with the project shared in the bug report:
https://velxio.dev/project/ac7e285c-8dc3-4d51-8751-b4aba9912f9e
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>
Changes that ship to OSS — all benign for self-hosters, but most are
extension points the velxio-prod overlay (and any private fork) needs to
plug an in-editor AI chat into the page.
Editor:
- 3-way view-mode toggle (code / both / circuit) in the unified toolbar.
Lets users hide a pane to give a right-docked sidebar (e.g. the AI
chat overlay) more breathing room. Persisted in useEditorStore.
- Default file explorer narrower (210 → 165 px); min 110.
- Removed the redundant `tb-board-pill` (icon + "Editing: X" tooltip);
the BoardSelector dropdown elsewhere already shows the active board.
- Inlined Import/Export/Upload-firmware buttons; the 3-dot overflow
menu gave up too much discoverability. Removed dead overflow state.
Simulator:
- Fix: global Delete/Backspace handler in SimulatorCanvas no longer
fires when the event target is an INPUT/TEXTAREA/SELECT/contentEditable
— affected any in-page text field, not just the chat overlay.
Overlay extensibility:
- New `data-velxio-slot="agent-chat"` at the bottom of EditorPage so
pro overlays can portal a chat panel into the editor without
forking the page.
- vite.config.ts: preserveSymlinks=true when VITE_PRO_BUILD is set.
Lets local-dev junctions (overlay tree → frontend/src/pro) resolve
bare imports back to the OSS node_modules without resolving symlinks.
Deps:
- Added react-markdown + remark-gfm (rendered chat output) and
@google/genai + zod (overlay agent loop). Tree-shaken from the OSS
bundle when no pro code imports them.
gitignore:
- Ignore backend/app/pro/ and frontend/src/pro/ junctions used by
developers running a private overlay against the OSS dev server.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
crypto.randomUUID() is only exposed on secure contexts (HTTPS, localhost,
127.0.0.1, ::1). When Velxio is self-hosted and accessed via a LAN IP over
plain HTTP (e.g. http://192.168.31.139:3080/), crypto.randomUUID is
undefined and any code path that calls it throws TypeError.
This silently broke ESP32 simulation start for self-hosters: the frontend
generates a UUID for the WS client_id when Run is clicked; the throw
rejected the promise before reaching the WS connect, so the backend
never got the start request — no worker spawned, logs empty, simulation
"didn't start" with no visible error.
Same root cause would also break the multi-file editor (createFile,
createFileGroup) on the same LAN-HTTP self-host setup, just less
observably.
Add a single generateUUID() helper that:
1. Uses crypto.randomUUID() when available (secure context fast path).
2. Falls back to crypto.getRandomValues() — which IS available in
non-secure contexts — to build a v4 UUID by hand.
3. Final fallback to Math.random() if even that is missing
(defensive — Web Crypto getRandomValues has been universal for
years).
Replace all 6 crypto.randomUUID() call sites:
- frontend/src/simulation/Esp32Bridge.ts (2 sites — getTabSessionId)
- frontend/src/store/useEditorStore.ts (4 sites — file IDs)
Reported by a self-hoster on OrangePi 5B accessing Velxio via LAN IP.
DevTools console showed:
TypeError: crypto.randomUUID is not a function
at Ph (...) at wh.connect (...) at startBoard (...)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two upstream additions to support private overlays implementing paid tiers
without forking client code:
- store/useAuthStore.ts: UserResponse extended with optional
is_paid_subscriber, subscription_status, subscription_period_end. The
backend now returns these in /api/auth/me; the persist middleware
serialises them automatically.
- pages/PricingPlaceholder.tsx (NEW): the /pricing route. Renders a polite
"this image is fully free" message for self-hosters plus a
data-velxio-slot="pricing-page" target where private overlays can
portal-inject a real pricing page.
- App.tsx: register the /pricing route after /about.
Self-hosted OSS image: /pricing shows the placeholder, no behavioural
change anywhere else. Production with a private overlay: /pricing shows
the overlay's full pricing UI.
Frontend build verified.
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>
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>
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>
- 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.
- Introduced SVG layout dimensions for Phase 1 (B/W mono) and Phase 2 (colour) ePaper panels, detailing active areas, bezels, and pin layouts.
- Developed a phased emulation plan outlining the architecture and deliverables for different panel types, including SSD168x and UC81xx.
- Created a canonical "Hello, World!" sketch for the 1.54" ePaper panel, ensuring compatibility across ESP32, Raspberry Pi Pico, and Arduino Uno.
- Implemented a pure Python SSD168x decoder to validate SPI command sets and framebuffers against specifications.
- Added tests for compiling the hello-world sketch across supported boards and for the SSD168x protocol to ensure correct framebuffer behavior.
- Implemented handshake tests to validate initial bus state and register responses.
- Created end-to-end tests for Pico W LED blinking using MicroPython firmware.
- Added SDPCM framing tests to ensure proper encoding and decoding of control frames.
- Developed IOCTL tests to verify command responses and state changes in the emulator.
- Established a full lifecycle test for WiFi operations, including scanning, connecting, and packet handling.
- Introduced TypeScript configuration for test files to ensure compatibility and strict type checking.
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>
- Modified the index file to reflect the new naming convention for Velxio components.
- Changed JSX declarations to use 'velxio-' prefix for various components.
- Updated component overrides to replace 'wokwi-' with 'velxio-' for logic gates and other components.
- Adjusted SVG generation script to use 'velxio-' prefix for BMP280 and Raspberry Pi components.
- Marked submodules as dirty in QEMU and RP2040 libraries.
- Added .prettierignore and .prettierrc.json for consistent code formatting.
- Introduced InstrumentComponent with support for Voltmeter and Ammeter, including pin information handling.
- Implement `ammeter-waveform.test.ts` to validate AC readings from a sine wave source.
- Create `capacitor-charge-transient.test.ts` to test the charging response of an RC circuit driven by a microcontroller pin.
- Introduce `esp32-rectifier-integration.test.ts` for testing rectifier behavior using QEMU and ESP32.
- Add helper functions in `esp32RectifierE2E.ts` for the rectifier test harness.
- Develop `voltmeter-waveform.test.ts` to ensure correct AC and DC readings from a sine wave source.
- Implement unit tests for waveform statistics in `waveform-stats.test.ts` to validate RMS, mean, peak, and interpolation functions.
- Create `waveformStats.ts` to provide statistical functions for time-domain waveform analysis.
- Implement `serial-batching.test.ts` to verify the behavior of `createSerialBatcher`, ensuring it coalesces multiple appends into a single flush, preserves byte order, and groups by board.
- Create `spice-rectifier-integration.test.ts` to test the end-to-end functionality of the Half-Wave Rectifier example, covering the entire simulation pipeline from input building to circuit solving.
- Add `spice-rectifier-live-repro.test.ts` to reproduce a live-app failure scenario, tracing through each layer of the simulation to identify potential failure points.
- Introduce `spice-signal-generator-tran.test.ts` to validate the behavior of the signal generator and ensure correct analysis type switching based on circuit components.
- Establish `serialBatcher.ts` to implement a batching mechanism for USART output, reducing the frequency of store updates and preventing React's maximum update depth error.
- Decoupled electrical simulation from the simulator store, ensuring SPICE is always active for accurate circuit analysis.
- Removed feature flag for electrical simulation, simplifying the state management.
- Preloaded SPICE engine at app start to eliminate latency during the first solve.
- Added comprehensive tests for MOSFET PWM LED behavior and NPN transistor switch functionality, ensuring correct current flow and response to pin states.
- Implemented diagnostics for floating input nodes in RC low-pass filter circuits, addressing singular matrix issues in SPICE simulations.
- Introduced active semiconductor metadata registry for better component management and simulation fidelity.
- Updated Vite configuration to force re-bundling of local wokwi-elements after component additions.
Electrical simulation is now active by default (mode='spice' instead of
'off') — users no longer need to toggle the mode on manually. The engine
lazy-loads on first solve, so there is no startup cost penalty.
Changes:
- useElectricalStore: default mode = 'spice' when ELECTRICAL_SIM_ENABLED
- subscribeToStore: ADC_PIN_MAP expanded to all 18 board types (Uno, Nano,
Mega with 16 ADC channels, ATtiny85, RP2040 GP26-29, ESP32/S3/C3 GPIO
ADCs). Voltages from SPICE solutions now inject into MCU ADC peripherals
for all boards.
- BasicParts LED: reads branchCurrents from useElectricalStore when SPICE
is active. Brightness = clamp(|I_led| / 20mA, 0, 1) instead of boolean.
Subscribes to store changes to update in real time.
- ElectricalOverlay: shows per-wire voltage labels (gold monospace on dark
pill) using buildWireNetMap() which replicates the NetlistBuilder's
Union-Find to map wireId -> netName -> nodeVoltage. Summary pill shows
net count + solve time.
- NetlistBuilder: new export buildWireNetMap() for lightweight wire-to-net
resolution without running ngspice.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>