connectMcuEdgesToService resubscribes its per-pin listeners whenever
pinNetMap changes. The subscription swept pin NUMBERS 0..63 and
reverse-mapped them to names ('GPIO2' on ESP32, 'GPIO17' on Pi) to match
against pinNetMap's keys — but those keys are the WIRE pin names ('2',
'4', 'A0'), so after the first solve the match failed for every pin and
the resubscription attached nothing. Any mid-run pinNetMap update then
silently killed the MCU-edge → SPICE path and the canvas froze at the
last solved state while the firmware kept toggling.
Masked until now because nothing perturbed pinNetMap mid-run on the
blink examples; pure ESP-IDF mode (#139) unmasked it — gpio_reset_pin()
leaves the internal pull-up enabled, the worker reports gpio_pull, the
handler requests an electrical resolve, pinNetMap gets a new identity,
and the ESP-IDF blink example's LED froze ON.
Fix: subscribe FROM the pinNetMap names, mapped to PinManager pins with
the same pinNameToArduinoPin the netlist collector uses (STM32 via
stm32PinNameToLinear), and hand schedulePin the netlist name so
handleMcuEdge's v_<board>_<pin> lookup hits the fast alterSource path
instead of a full rebuild per edge. The 0..63 sweep remains as the
pre-first-solve fallback. Also fixed pinNameToArduinoPin's dead 'GPIO'
branch ('GP' tested first turned 'GPIO32' into parseInt('IO32') = NaN).
Adds a third entry to the board language selector next to Arduino C++
and MicroPython: ESP-IDF. In this mode the user writes a plain ESP-IDF
project — app_main() entry point, FreeRTOS + driver APIs — and the
backend compiles it through the same ESP-IDF toolchain it already uses
for ESP32 Arduino sketches, just without the arduino-esp32 component.
Backend:
- CompileRequest.language ('espidf') threaded through the sync + async
compile paths and folded into the dedup job key (language='arduino'
and omitted hash identically so old clients keep dedupping).
- espidf_compiler: pure_idf flag. User files are written into main/
as-is (no Arduino.h wrap, no velxio_compat.h, Arduino library
resolution skipped), ARDUINO_ESP32_PATH is dropped from the build env
and VELXIO_PURE_SKETCH raised so the template CMake compiles the
user's own sources via a glob branch. Pure builds get their own
persistent build-dir variant through the eff_hash fold.
- QEMU WiFi compat for IDF-style code: esp_wifi.h/esp_wifi_init
detection sets has_wifi, and literal #define SSID/PASS plus
wifi_config_t designated initializers are normalized to the QEMU AP.
- CONFIG_ARDUINO_* lines are stripped from sdkconfig.defaults in pure
mode (the symbols don't exist without the arduino component).
Frontend:
- LanguageMode gains 'espidf'; BOARD_SUPPORTS_ESPIDF covers the ESP32
family (Xtensa, S3, C3). Toolbar shows the option only for those.
- Switching modes seeds a main.c blink skeleton (app_main + gpio
driver), mirroring the MicroPython main.py flow.
- compileCode sends language='espidf'; run/stop paths are unchanged
(the QEMU worker consumes the same merged flash image).
- New gallery example: esp32-idf-blink (LED + resistor on GPIO 2).
Tests: unit coverage for the build-env switch, IDF wifi normalization,
job-key variance, file-group seeding and the new example; verified
end-to-end in a container from the prod image (pure build produces a
bootable flash image; Arduino-mode build unchanged, same variant hash).
Root cause of "el agente construye el reloj, dice que funciona, pero el
display queda en blanco hasta recargar la página" — diagnosed by driving
the live agent end-to-end and instrumenting the element:
The 7-segment part simulator caches its state (segments, digitValues,
digitEnabled) in a WeakMap keyed by the DOM element, sizing it from
element.digits at FIRST access. The agent builds incrementally: it adds
the display with the default digits=1 and only then sets digits=4 — so
the cached state was born in single-digit mode. Every later attachEvents
(compile bumps hexEpoch → re-attach with the finished wiring) kept
consulting the stale state: it subscribed COM.1/COM.2 (which don't exist
on a 4-digit part) instead of DIG1..DIG4, and because those resolvers DID
attach, the all-digits-on fallback never kicked in either. Result: no
digit ever enabled, no flush ever ran, values stayed a frozen 8-zero
array. A page reload "fixed" it because the fresh element mounted with
digits already 4.
get7SegState now compares the cached digit count against the element's
current value and rebuilds the state when they differ, so any re-attach
after a digits change subscribes the right pins.
Test: attach with digits=1 (COM subscribed), set digits=4, re-attach →
DIG1..4 subscribed, and a segment+digit pulse actually lights values[0]
in the 32-slot array.
Verified live: the exact agent prompt that produced a permanently blank
display now shows the multiplexed digits + blinking colon in-session,
no reload.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The stop-first guard in the Run button only fires when board.running is
true, but the Run button is DISABLED while a board runs — so by the time the
user can actually click Run, the board has already disconnected
(running=false) and the guard is a no-op. The failure lives one level down:
Esp32Bridge.connect() early-returned whenever a socket lingered in ANY
non-CLOSED state (CONNECTING/OPEN/CLOSING). The agent's run_simulation
leaves such a socket; when its backend QEMU session ends but the frontend
socket is still zombie, the user's Run → startBoard → connect() did nothing.
A page reload "fixed" it only by constructing a fresh bridge.
connect() now tears down any lingering socket (detaching handlers + close)
and opens a new one to the same session key — exactly what the reload does,
which is why the reload always worked. The backend already handles a new WS
replacing an existing session, so no reload is needed.
Test: connect() on an OPEN socket closes the old one and boots a fresh
start_esp32 (esp32-dht22-flow).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Fixes the reported breadboard wiring UX ("requerimos un vocabulario"):
Vocabulary implemented (breadboardOccupancy.ts, pure + unit-tested):
- 1 hole = 1 wire. A hole already holding a visible wire can't start a
new one — clicking it SELECTS that wire. This is the core fix: wires
running hole-to-hole across the board were impossible to select
because the pin overlays swallowed every click and silently started a
new wire (so the top horizontal rail wire was un-deletable).
- Same 5-hole strip / rail = one net. When a new wire end lands in an
occupied hole (a seated leg or another wire), it shifts to the
NEAREST FREE hole of the same group — electrically identical, the
real-world "bridge to the next hole in the row". Never crosses strips.
Two selection bugs behind the symptom:
- Click on a wire lying over the breadboard BODY now selects the wire
instead of opening the breadboard's 830-hole property dialog (that
list popping over everything was the "se sobrepone la lista de todos
los puntos" report). Guarded so the bubbled canvas click doesn't
re-toggle the fresh selection.
- Click on a hole occupied by a wire selects the wire (handlePinClick),
so wires anchored in holes are reachable at all.
Jumper colors (like a real kit — a board of identical green wires is
unreadable, "se ven todos verdes"):
- Power-rail holes mandate red (tp./bp. = +) / black (tn./bn. = −).
- Other breadboard holes get a random jumper-palette color on manual
draw; red and black are reserved for rails.
- jumperColorForId gives agent/deterministic callers a stable per-wire
color across reloads.
Tests: breadboard-occupancy.test.ts (12) — findWireAtHole (skips seating
wires, topmost wins), resolveFreeHole (same-strip shift, no cross-strip,
rail shift, passthrough), color policy (rails, palette determinism,
red/black reserved).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
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>
An ESP32 clock built by the agent stayed dark while QEMU was verifiably
emitting hundreds of GPIO edges per second (437/pin measured on the live
websocket). Reload did not help — this was not the seating race. Two
independent tracing bugs, reproduced from the real project circuit (fixture
included) and each sufficient to kill the display:
Boards added at runtime were invisible
--------------------------------------
isBoardComponent matches static id prefixes ('arduino-uno', ...), which only
covers the default board. Every board added at runtime gets a minted UUID id
— the agent's add_board always does — so traceDetailed treated the board
endpoint as an unknown component and resolved null, and SimulatorCanvas's
direct-wire subscription path skipped it entirely. Every Uno project happened
to work because they reuse the default board whose instance id IS the literal
'arduino-uno'. Both sites now consult the live boards list first, keeping
isBoardComponent as the legacy-id fallback.
Strip walking missed wires stacked on one hole
----------------------------------------------
The breadboard group walk continued the trace from every OTHER wired hole of
the strip, excluding the arrival hole by name. But two wires may legitimately
share one hole — the agent bridges strips straight into the seat hole (8 of
this circuit's 9 bridges land exactly on a resistor's own hole), which is
electrically identical to using a free hole of the strip. The name exclusion
made those junctions dead ends. Exclusion is now by incoming WIRE id, so
same-hole connections resolve; the depth bound already prevents ping-ponging
between two wires of one net.
With both fixes the exact saved circuit resolves every display pin to its
GPIO (A..DP -> 32,33,25,26,27,14,12,13; DIG1..4 -> 15,2,4,5; COM -> GND) and
the live project now shows 12:00 on the real QEMU simulation. traceDetailed
is exported for the regression test, which drives the real store with the
real circuit.
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>
Seating is otherwise invisible — a seated pin connects to its hole through a
zero-length `bb` wire that never renders — so a user couldn't tell a part
that merely sits ON the board from one whose pins are actually connected.
This was reported after placing parts that looked seated but gave no signal
they were wired in.
SeatedPinMarkers draws a small always-on green dot (Wokwi-style) on each pin
that has a `bb` wire, derived once per render from the store's wires
(component pin = wire start). Non-interactive layer below the wire-target
hit boxes; only breadboard-seated pins light up, so board-wired builtins stay
unmarked — exactly the "seated vs connected" distinction that was missing.
The per-pin rotation math (rotate about the wrapper centre, which the overlay
layers live outside of) is extracted from PinOverlay into a shared
`rotatePinLocal`, so the dots and the wire-target boxes can never drift apart
under rotation. A test asserts rotatePinLocal agrees with calculatePinPosition
at 0/90/180/270°.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The agent computes exact hole assignments server-side but can only send an
approximate canvas x/y, because the rotation pivot is the DOM wrapper centre
and the wrapper includes a text label the server cannot measure. Under
rotation that left seated parts off by up to ~4 px — enough that a diode
(pins 7.5 pitches apart) half-seated: computeSeating found no hole for the
far pin and it went electrically dead.
resolveSeatPosition corrects it in the browser by pure translation: read
where the anchor pin actually is from live DOM geometry (real pivot), read
where the solver put it, shift the whole part by the difference. Every other
pin follows because pin-to-pin offsets are pivot-free. It never re-solves, so
it cannot slide the part to different holes and the validated netlist holds.
The anchor target is the solver's anchor position in breadboard-element
space, WITH its sub-pitch centroid translation — not the hole centre.
Targeting the centre would re-break the diode (far pin 4.8 px out). Verified
against real rendered geometry in a browser: resistor and diode at 90° both
seat within the intrinsic lattice residual (0.6 / 2.4 px).
Applied via a `seat` payload on the move_component effect (velxio-prod
overlay); this commit is the resolver + tests.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Three changes, all driven by a real project where a 4-digit 7-segment clock
was unreadable and half its parts were not actually seated.
Labels on hover only
--------------------
Eight vertical resistors at 19 px pitch rendered eight 93 px "Resistor 220 Ω"
labels on top of each other, hiding the parts and the breadboard holes; the
SPICE overlay added ~40 more `0uV` pills. Both are now revealed on hover:
hovering a part also lights up the voltages of every wire touching it.
The label is hidden with OPACITY and stays in flow. pinPositionCalculator
derives the rotation pivot from wrapper.offsetHeight, so taking it out of
flow would move the pins of every rotated component in every saved project.
Seat-on-drop
------------
The drag-time magnet only aligned the anchor pin and assumed the rest
followed, which is how parts ended up HALF-seated: some pins in holes, the
rest dead in the air. It looks mounted in a screenshot and silently breaks
the circuit. On release we now re-solve properly — nearest position where
EVERY pin is in a free hole, sliding past occupied columns — via the new
solvePlacement/seatOnDrop. Geometry comes from the element's own pinInfo,
so there is no part whitelist.
Sub-pitch translation
---------------------
solvePlacement first assigned pins to holes at half-pitch, then translates
by the centroid of the residuals before judging fit. Pinning the anchor dead
centre refused every off-lattice footprint: a diode spans 7.5 pitches, so
one leg landed 4.8 px out. Shifted 2.4 px, BOTH legs sit inside tolerance —
what bending the leads does on a real board. Measured over the catalog this
takes seatable parts from 87 to 125 of 152; diodes, transistors, regulators,
optocouplers and flip-flops are rescued with no artwork change.
Staying under SEAT_TOLERANCE (< half pitch) keeps each pin's nearest hole
unambiguous, so computeSeating resolves the same holes and the netlist is
unaffected by the small offset.
Also: refuse a placement that would put two of a part's own pins in one
strip. A column strip — and far worse, a power rail — is a single net, so
such a seating shorts the part to itself. Without it a 7-segment happily
lays its pins across a rail. And deduplicate pin names before solving:
calculatePinPosition resolves by name and returns the first match, so a
board carrying GND x5 collided with itself and was refused outright.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
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
Hand-aligning a dragged segment could leave two parallel runs a pixel
or two apart, joined by a tiny perpendicular step, because alignment
snapping only ever targeted OTHER wires' geometry.
- Segment and bend-point drags now also snap (6 px threshold) against
the dragged wire's own points — excluding the ones being dragged —
so a run clicks into line with its neighbour and the exact
simplification fuses them into one segment on commit.
- fuseMicroJogs: parallel runs offset by under 2 px joined by a tiny
step are aligned automatically (the run not anchored to a wire
endpoint moves; shorter run yields when both are free). Applied at
render time and in renderedToWaypoints/normalizeWireWaypoints, so
already-saved crooked wires display straight without touching data.
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.
Any pushbutton (pushbutton / pushbutton-6mm) can now be driven from the
keyboard. Assign a key from the component property dialog — a keycap
control captures the next keypress (Escape cancels, modifiers alone are
rejected) — and a keycap badge next to the component label shows the
mapping on the canvas. Several buttons may share one key on purpose;
the dialog shows a hint when that happens.
At runtime a global bridge translates keydown/keyup into the same
button-press / button-release DOM events the mouse fires on the wokwi
element, so every simulation path (avr8js pin logic, SPICE-driven
inputs, the QEMU GPIO bridge, the pressed visual) behaves identically
to a mouse click. Guards: ignored while typing in inputs or the code
editor, ignored with Ctrl/Alt/Meta held, auto-repeat collapses into one
long press, and window blur releases everything so no button sticks
after Alt-Tab.
The binding is stored as the component's 'key' property, so it
round-trips through project saves and .vlx exports and is undoable like
any other property edit. Strings added to all 9 locales.
Two velxio-native passive parts, rendered as web components with
programmatic SVG + precomputed pinInfo (velxio-breadboard 830 holes,
velxio-breadboard-mini 170). Pin names follow the Wokwi convention
(holes `18t.d` / `17b.i`, rails `tp/tn/bp/bn.N`) and the metadata ids
are `breadboard` / `breadboard-mini`, so wokwi diagram.json zips
import/export with no aliasing.
Internal connectivity (5-hole column strips, full-length power rails)
is centralized in utils/breadboardNets.ts and wired into every net
consumer:
- NetlistBuilder: unionBreadboardGroups joins wired holes per group at
the union-find level in buildNetlist, buildWireNetMap and
buildBoardPinNetMap — SPICE, the circuit verifier and the voltage
overlay all see one net per strip/rail with no extra cards.
- DynamicComponent.traceDetailed: the digital trace hops through every
other wired hole of the entered group, so parts wired through a
breadboard still resolve their board pin (2-terminal
PASSIVE_PIN_PAIRS could not express N-hole groups).
Verified end-to-end in the app: Uno pin 8 -> full-board column ->
resistor -> mini-board column -> LED -> ground rail -> GND lights the
LED, and the HUD shows the 3 collapsed SPICE nets. 8 new unit tests
(breadboard-nets.test.ts); netlist-builder + circuit-verifier suites
stay green.
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.
The SSD1306 was three picker entries — a generic `ssd1306` with a protocol
selector plus `ssd1306-i2c` / `ssd1306-spi` shortcuts (issue #101) — all the
same 8-pin wokwi-ssd1306 element. That is confusing for one physical module
(issue #215). Wokwi ships a single I2C-only part; this goes one better: a
single part that auto-detects the protocol from the wiring, like a real
breadboard — CS or DC wired to a GPIO means SPI, otherwise I2C. No protocol
switch to set, just wire it up.
Works on every board with an I2C/SPI bus (AVR, RP2040, ESP32 Xtensa, STM32).
The ssd1306-i2c / ssd1306-spi ids stay as backward-compat simulation aliases
for projects saved before the merge, but are removed from the picker. Adds an
i2cAddress property (0x3c/0x3d) matching the real module and Wokwi.
Note: ESP32-C3, Raspberry Pi 3 and the bare RISC-V board do not emulate I2C/SPI
peripherals, so no I2C/SPI device (this or any other) attaches there yet.
The slide-switch SPICE model only wired pin 1 <-> pin 2 (an SPST), ignoring
pin 3. The part is really an SPDT whose common wiper (pin 2) selects pin 1 at
value=0 or pin 3 at value=1, so a switch wired GND-1 / signal-2 / VCC-3 (the
natural Wokwi hookup) could never pull its signal high. Fixes the reported
ESP32-C3 case (issue #247) where only the green LED lit and the switch never
toggled the red one.
Second cause on that board: the ESP32-C3-DevKitM-1 exposes its supply as
3V3.1/3V3.2 and 5V.1/5V.2 (there is no bare 3V3/5V pin). VCC_PIN_RE has no
numeric-suffix branch on purpose (a dual-supply pin such as L293D VCC2 must
not collapse onto the shared logic rail), so those numbered pins floated at
0 V and the switch's HIGH side was dead. List them in boardPinGroups for
esp32-c3 / esp32-s3 / esp32-cam.
- componentToSpice: SPDT emission (both throws, complementary 0.01/1e9 R).
- digitalGateEngine: both driveSwitch paths (all-digital + mixed) made SPDT to
match, so the pure-digital paint and the ngspice solve agree.
- examples-digital / examples-circuits: rewire every slide-switch so the rail
feeds pin 3 and pin 1 is the value=0 throw, preserving value=ON=HIGH.
- spice-slide-switch-spdt-repro test reproduces issue #247 at the netlist level.
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.
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.
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).
The pushbutton was modelled as a switch between only 1.l and 2.l; the
other two legs (1.r, 2.r) connected to nothing. Wiring GND/GPIO to those
legs silently produced a dead button, and the failure was invisible.
Model it like hardware: 1.l is internally shorted to 1.r and 2.l to 2.r,
and pressing bridges terminal 1 to terminal 2. Wiring to any leg now
works, and putting GPIO and GND on the same terminal is a dead short,
exactly as on a real tactile switch. Back-compat A/B variant preserved.
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.
SSD1306Core only handled horizontal/vertical addressing (0x20/0x21/0x22) and
defaulted memMode to horizontal. Page-mode drivers (Tiny4kOLED on ATtiny85,
U8g2 page buffer, classic SSD1306 libs) position the cursor with the single-byte
commands 0xB0-0xB7 (page) and 0x00-0x0F / 0x10-0x1F (column nibbles) and rely on
the SSD1306 power-on default of PAGE addressing — they never send 0x20. velxio
ignored those cursor commands and advanced in horizontal mode, so every setCursor
was a no-op and the hatching/border/text piled onto wrong rows -> garbled display.
Fix: default memMode=2 (datasheet power-on) and handle the page/column-set
commands. Adafruit_SSD1306 still works (it sends 0x20,0x00 + 0x21/0x22 explicitly).
Verified: decoded the real ATTinyCore Tiny4kOLED I2C stream renders a clean
border + '128x64'. Adds a page-addressing render test.
AVRSimulator used wrong ATtiny85 Timer0 data-space addresses: OCR0A 0x56
(=PINB), OCR0B 0x5c (=EECR), TCCR0A 0x4f (=TCNT1). analogWrite() writes
OCR0B at data 0x48, so pollPwmRegisters() read the wrong register and PWM
duty was never seen — attiny85-pwm-fade showed no fade. Corrected both
PWM_PINS_TINY85 and attiny85Timer0Config to TCCR0A=0x4A/OCR0A=0x49/OCR0B=0x48
(verified against the ATTinyCore analogWrite disassembly). delay()/millis
(overflow-based) was unaffected. Tests updated off the old 0x5c/0x56.
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).
- Power short (blocking error): a wire joining a VCC-type pin directly to a
GND-type pin shorts the supply to ground. The current-based short-circuit
rule only inspects battery/signal-generator/power-supply sources, so it
misses a board-rail-to-GND short with no such source -> name it structurally.
- Shorted-out part (warning): a 2-terminal part with both terminals on the same
node has no effect on the circuit.
Both graph-based, run before the solve. Zero false positives across the 69
gallery examples; gallery pre-flight tests still pass (no spurious blocking).
First slice of the connection ("malas conexiones") checks, graph-based and run
before the solve so they report even on circuits too incomplete to solve:
- Missing power: a rated peripheral (sensor/display) wired into the circuit but
missing its VCC or GND connection -> warning. Boards are excluded (they live
in input.boards and self-power).
- Dangling 2-terminal part: a resistor / LED / capacitor / diode / inductor
connected on only one side (the other terminal floating) -> warning.
Both non-blocking. Verified zero false positives across all 69 gallery
examples. Tests: dangling resistor warns, fully-wired doesn't, module missing
GND warns.
Extends the over-voltage rule to the two cases the previous slice deferred:
- Boards (ESP32 / Pico / Arduino / ...): a board's supply pins all collapse to
the self-driven vcc_rail net, so an external source on them makes the .op
singular rather than readable. Added a graph-based check (runs before the
solve): if a power source is wired to a board supply pin and its nominal
voltage exceeds that pin's rating, warn. Threaded boardKind into
BoardForSpice so the verifier can look up the board rating.
- Electrolytic capacitors: new `voltage` rating property (select, default 25V,
on capacitor-electrolytic + cap-elec-* presets, via component-overrides +
regenerated metadata). The verifier reads the DC voltage across the +/- pins
and warns on over-voltage (vent/burst) and on reverse polarity (a polarized
cap wired backwards). Defaults to 25V when the property is unset.
Tests: 9V battery -> ESP32 VIN warns, 1.5V doesn't; 24V across a 16V cap warns,
5V across a 25V cap doesn't; reverse-biased cap warns. All real-ngspice.
Adds a non-blocking circuit-verifier rule: a component whose supply pin sees
more than its datasheet absolute-maximum voltage warns ("X V on the VIN pin --
above its Y V maximum; not emulated accurately"). This is the "fed too much
voltage" mistake the operator asked for (a 3.3-5V module wired to a 9V battery).
- New componentRatings.ts: per-PIN abs-max table (SSD1306/ILI9341 displays,
DHT/BMP280/HC-SR04/MPU6050 sensors, NeoPixel, servo). Per-pin thresholds so a
3V3 pin (3.6V) and a VIN pin (6V) are judged separately. Unknown parts are
simply not checked; an unwired or floating supply pin is skipped.
- circuitVerifier reads each rated part's supply-vs-ground voltage from the
solved nets (via pinNetMap) and warns when it exceeds the rating.
- VCC/VDD/3V3/5V pins ride the shared vcc_rail net (NetlistBuilder convention);
VIN is a normal net. Both handled.
- Tests: 9V on a module VIN warns; 5V on VIN does not; a 3.3V pin on a 5V rail
warns.
Boards (esp32/pico/arduino) carry ratings in the table but aren't checked yet
-- BoardForSpice doesn't thread its boardKind; follow-up.
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.
AVRSimulator never instantiated avr8js's AVREEPROM peripheral, so any
EEPROM.read/write/update hung the sketch: the Arduino EEPROM library spins
on `while (EECR & (1<<EEPE))` waiting for the write-complete bit to clear,
and with no peripheral driving EECR that bit never cleared (issue #203 —
EEPROM.update(0,123) + EEPROM.read(0) hangs instead of printing 123).
Wire AVREEPROM to the CPU in both loadHex() and reset() via a new
attachEeprom() helper. The EEPROMMemoryBackend is created once per
simulator instance and reused across firmware reloads and resets, so a
value written in one run is still readable on the next boot — matching
real hardware, where re-flashing leaves EEPROM intact. Sizes per variant
(Uno 1024 B, Mega2560 4096 B, ATtiny85 512 B); ATtiny85 gets its own
register map (EECR 0x3C / EEDR 0x3D / EEARL 0x3E / EEARH 0x3F) since
avr8js's default eepromConfig targets the ATmega328P.
Adds eeprom.test.ts: drives the EEPROM register protocol against the
production AVRSimulator (loadHex + step), asserting a byte round-trips,
the EEPE poll terminates (no hang), and contents survive a reset.
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.
The Pico W WiFi showcase examples are a paid-overlay feature now (the WiFi
engine moved to the overlay). Read them through a build-time `@pro` seam so the
SSR prerender + gallery + sitemap include them when built with the overlay, and
OSS gets an empty stub.
- data/examples.ts: import { proExamples } from '@pro/data/proExamples' (static,
build-time) instead of the local examples-picow-wifi.ts; delete that file.
- src/__pro_stub__/data/proExamples.ts: OSS no-op (empty list) for the @pro alias.
- vitest.config.ts: mirror the @pro alias (stub by default / overlay when
VITE_PRO_BUILD) so tests loading examples.ts resolve it.
- scripts/generate-sitemap.mjs: also parse <PRO_OVERLAY_PATH>/data/proExamples.ts
when building with the overlay (the script reads example IDs from source text,
so it can't follow the alias).
- Tests: drop the picow-wifi import/usage from the 5 OSS example tests (they
validate the OSS set now); prune the 4 obsolete picow netlist snapshots. The
overlay's proExamples get their own coverage in pro/.../__tests__/.
The stored snapshot predated the relay-LED netlist emission (current-sense
V-source + LED diode model), so examples-netlist-snapshot failed on a clean
checkout regardless of any source change. Regenerate it to match the current
NetlistBuilder output. Unblocks the deploy test gate.
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.
The served page loads under /api/gateway/<id>/, so absolute fetches like
fetch('/on') hit velxio.dev/on instead of the chip — the LED/relay/servo
controls did nothing. Use relative paths (fetch('on')) so they resolve
under the gateway. The relay example now has real ON/OFF buttons and
wraps its blocking accept loop in try/except so a dropped browser
connection can't kill it.
e2e now fires two sequential requests (first with a browser-sized header)
against a non-resilient blocking server and asserts both are served.
- SerialMonitor linkifies http://10.13.37.x (the Pico W subnet) the same
way it already does http://192.168.4.x for the ESP32, turning the
sketch's printed URL into an 'Open IoT Gateway' link.
- SimulatorCanvas shows the clickable WiFi badge for the Pico W too
(normalizing its 'started' status, which carries the fixed IP, to
got_ip so it reuses the ESP32 badge styling + launcher).
- async-led and servo-web examples print a clickable http://<ip>/ line
so the gateway link appears (relay-web-server already did).
Gated e2e (CYW43_GATEWAY_E2E=1) drives the real emulator + a running
backend and asserts the served page comes back through /api/gateway.
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).
The CYW43439 F2 (radio frame) channel is word-oriented: the real chip
always drives frames padded up to a 4-byte boundary and the host reads
that word-aligned length, byte-swapping every 32-bit word on the way in.
encodeSdpcm built buffers of exactly 12 + payload bytes, so any frame
whose total length was not a multiple of 4 ended with a partial word.
The emulator's F2 read path (encodeFrameWords) byte-swaps whole words and
copies the leftover tail raw; the host's symmetric per-word swap then
mangles that final word, corrupting the last 1-3 bytes of the frame.
This was invisible for DHCP/ARP (UDP checksum 0 -> lwIP skips the check,
and the damage lands in trailing option padding) but silently dropped
every DNS answer and TCP segment (real checksum -> lwIP discards the
frame), so getaddrinfo()/connect() retried forever.
Pad the backing buffer to a 4-byte boundary while keeping the size header
at the true length, so the driver still parses exactly the real frame and
ignores the pad. Matches real hardware framing.
Headless test that drives the REAL RP2040Simulator (attachCyw43 +
installCyw43PioHooks + lockstep PIO stepping in runFrameForTime), boots
the Pico W firmware, injects a WiFi-connect snippet over the raw REPL, and
asserts isconnected(). Result:
PYBOOT
ACTIVE False (this fw's active() getter reports link status)
CONN_OK 192.168.4.2 (DHCP-leased IP, isconnected() == True)
MAINPY_DONE
Reaches link-up in ~31s wall — the production lockstep PIO stepping is
faster than the harness's setTimeout-cranked PIO.
Also fixes a real production bug: the RP2040 logger was
ConsoleLogger(LogLevel.Error) which THROWS on rp2040js unaligned-read
warnings — lwIP reads the IPv4 header at ethernet offset 14 on every
received packet, so WiFi would have crashed on the first DHCP reply.
Now constructed with throwOnError=false.
Gated behind CYW43_PROD_HARNESS=1 (boots real firmware, ~30s).
The Pico W now connects end to end with NO backend: status reaches
CYW43_LINK_UP (3) and network.WLAN().isconnected() returns True.
After association the STA's lwIP broadcasts DHCP DISCOVER and ARPs the
gateway over the cyw43 DATA channel. A self-contained virtual network
(new virtualNet.ts) answers them:
- DHCP DISCOVER -> OFFER, REQUEST -> ACK (Ethernet+IPv4+UDP+BOOTP, valid
IPv4 header checksum, UDP checksum 0), leasing 192.168.4.2 with gateway
192.168.4.1.
- ARP who-has the gateway -> is-at the AP MAC.
On by default (Cyw43EmulatorOptions.virtualNet); pass null when an
external packet bridge owns the network.
Also fixes injectPacket to prepend the 4-byte BDC header that chip->host
DATA frames need (same as the event-frame fix), so injected packets parse.
Boot harness now reports: STEP_CONNECT_CALLED status=3 / POLL 0 status 3
conn True / HARNESS_DONE.
Known: receiving packets triggers ~30 rp2040js unaligned-read warnings
(lwIP reads the IPv4 header at ethernet offset 14); non-fatal here, but
the production RP2040Simulator must use a non-throwing logger.
The Pico W now joins the virtual AP end to end: active(True) returns,
connect() runs the full WPA/SET_SSID sequence, and the link comes up.
Root causes fixed (each blocked the join):
- mcast_list GET returned empty, so the driver read its own request bytes
as the address count (ASCII 'mcas' ~1.9e9) and looped ~2e9 times,
hanging wifi_on. GETs now return a zero-filled buffer of the asked-for
length (count 0 / status 0), never empty.
- Async event frames lacked the 4-byte BDC header the driver expects at
SDPCM header_length, so it read the broadcast-MAC byte as data_offset
and the payload pointed out of bounds (WRONG_PAYLOAD_TYPE). Prepend BDC.
- WLC_E_LINK signalled link-up via the reason field, but the driver
checks ev->flags & 1. encodeEventFrame now takes a flags arg; LINK uses
flags=1.
- Join needs WIFI_JOIN_STATE_KEYED, which only a WLC_E_PSK_SUP(status=6)
event sets (connect(ssid, "") still configures the WPA supplicant).
Emit it on a successful join.
- Join events were raised synchronously during the SET_SSID ioctl, so the
driver processed them before cyw43_wifi_join set wifi_join_state=ACTIVE,
wiping the bits. Defer events until just after the ioctl reply.
- Event-mask stored 4 bytes misaligned vs queueEvent's read offset.
- SET/GET kind bit is 0x2 (SDPCM_SET), not 0x1.
Remaining for status UP / isconnected: DHCP (needs the packet-transport
bridge or an emulator-side DHCP responder).
Add PioBusSniffer.inDiscardableWriteData(): true while framing a large
non-F2 write (firmware/backplane bulk write the chip discards). The boot
harness drops those data words (keeping ~4 so the PIO raises TXSTALL,
which is all the driver's write path waits for) instead of bit-banging
the full ~224 KB through the PIO. F2/SDPCM IOCTL writes and every
count/command word are retained in full, so the bring-up still completes
the 23-IOCTL wifi_on sequence (F1 framing 3613 -> 97, F2 unchanged).
Also adds IPSR + PC-histogram sampling: confirmed the post-mcast_list
stall is thread-mode (no GPIO IRQ storm) inside MicroPython's host-side
cyw43_cb_tcpip_init (lwIP), above the chip emulation.