Commit Graph

248 Commits

Author SHA1 Message Date
David Montero 0e107fafe6 feat(editor): M5Stack section (Cardputer ADV, Core) ahead of STM32 in the starter dialog
The New Project dialog now lists an M5Stack section — M5 Cardputer ADV first,
then M5Stack Core — between ESP32 and STM32. Both kinds are overlay-registered,
so the section only renders when the overlay mounted (an OSS build shows no
empty block). Selecting a card loads the board's on-LCD hello example
(cardputer-adv-hello / m5stack-core-m5-helloworld), which also provides the
card thumbnail.

The sections builder is extracted as buildStarterSections(defs) and covered
by a unit test that pins the display order.
2026-08-17 18:50:37 +02:00
David Montero Crespo 08c9459c71 fix(examples): the common-emitter amplifier never left cutoff
analogRead printed a flat 1021-1023 on every loop — the collector pinned at
Vcc. Measured in the running app:

  base 0.481 V   emitter 0.001 V   collector 4.994 V

0.481 V is below the ~0.65 V a 2N2222 needs, so the transistor never
conducted and the "amplifier" was an open switch.

The bias divider was fine on paper (47k/10k -> 0.88 V). What broke it was
the 10k "coupling resistor" from the signal generator, whose own comment
claimed it let the generator "wiggle the biased base without DC-shifting
it". It does the opposite: a generator with 0 V offset is ground at DC, so
that resistor sits in parallel with Rb2 —

  10k || 10k = 5k  ->  Vb = 5 * 5/(47+5) = 0.481 V

which is exactly the measured value. Replace it with a 1 uF coupling
capacitor (159 ohm at the generator's 1 kHz: passes the signal, blocks the
DC that was wrecking the bias), and take Rb2 to 15k so the collector sits
near mid-rail — the operating point the sketch's own comment promises and
the only one with room to swing both ways.

The netlist snapshot could never have caught this: the text was exactly
what the author intended. So the new test asserts the PHYSICS — bias in the
active region, collector off both rails, and a collector that actually
swings — resolving node names through the builder's pin map rather than
hardcoding `n2`, and adding the 5 V rail the board supplies in the app.
2026-08-17 15:21:24 +02:00
David Montero Crespo 5545089389 fix(compile): lock the build DIRECTORY, not the FQBN — concurrent compiles swapped binaries
`_prepare_persistent_project_dir` keys the persistent build dir on
(idf_target, variant), but the lock was keyed on the FQBN. Several FQBNs
resolve to one variant — arduino-esp32's boards.txt gives both `esp32` and
`esp32cam` build.variant=esp32 — so those two were free to build in the SAME
directory at the same time.

Measured against staging, compiling both concurrently:

  esp32     285504 bytes  sha f65ca909…  contains its own marker
  esp32cam  285504 bytes  sha f65ca909…  contains the OTHER sketch's marker

Byte-identical firmware, and the esp32cam caller got the esp32 sketch's
binary. In the product that is two gallery tabs open at once: the first tab
prints the second example's serial log and does nothing itself. It made
esp32-pwm-led-rgb look broken (dark LED, every SPICE node 0 V) when the
example is fine — it was running someone else's program.

Key the lock on the same identity as the directory. Boards with genuinely
separate build dirs still compile in parallel up to the semaphore cap; an
unresolvable FQBN falls back to the default pair, which is the safe
direction (serialising more only costs throughput).

Also in this commit: battery-voltage-monitor ships the 9 V battery its own
description promises. The divider's top end was wired to nothing, so A0 read
0 V and the sketch printed "Battery: 0.0 V / WARNING: Battery low!" forever.
2026-08-17 09:12:26 +02:00
David Montero Crespo 979a95fef9 test(examples): audit every shipped component value against physical ranges
The night-light LDR shipped a 0.001 ohm dark resistance for months and
nothing complained: the netlist built, ngspice converged, the part
rendered, and the example just quietly read 4095 forever. A wrong value
is invisible, so check them all.

Parses every resistor / pot / photoresistor / capacitor / inductor value
in the gallery with the real parser and asserts it lands in a physically
sane range. The failure message names both SPICE suffix traps, because
they are the ones that bite: M is milli (mega is "Meg"), and F is femto
and is deliberately not stripped, so "10uF" would parse as 10 fF rather
than the 10 uF intended.

Verified the check actually bites: putting "1M" back makes it fail with
`dark = "1M" -> 0.001 — LDR dark resistance (ohm) out of range`. A second
case asserts the audit visits a meaningful number of components, so the
test can't silently degrade into a no-op.

Swept the rest of the catalogue while here (all example files, the pro
overlay, and the 49 100-days diagrams): no other value hits either trap.
2026-08-17 03:47:39 +02:00
David Montero Crespo 9b0b4bc038 fix(examples): night-light LDR shorted AO to VCC — "1M" is milliohms in SPICE
100d-auto-night-light shipped `dark: "1M"` meaning 1 megaohm. The value
parser follows the SPICE convention where M is MILLI and mega is `Meg`,
so the dark resistance became 0.001 ohm. With the divider's top leg a
short, AO sat at VCC: `ldr.read()` returned 4095 on every sample, the
sketch's `light < 500` branch never ran, and the lamp LED never lit.
Dragging the lux control changed nothing either, because every lux value
still divides a short (measured n1 = 3.299999996 V, which is exactly what
100k / (100k + 0.00011) predicts).

The netlist snapshot had frozen the bug in place:

  - R_night-ldr_ldr vcc_rail n1 0.00011111111111111112
  + R_night-ldr_ldr vcc_rail n1 111111.11111111111

Diagram now says "1Meg" (regenerated from test/test_100_days), and the
mapper clamps a sub-1k dark resistance to the documented 1Meg default
with a console warning naming the milli/mega trap — a photoresistor that
reads as a short is always this mistake, and it fails in the most
confusing way possible: the part looks alive, it just never moves.
2026-08-17 03:39:47 +02:00
David Montero Crespo 5c95ade35f fix(sim): stop dropping sibling pin edges — only bit 0 of a multi-pin write reached the canvas
A sketch that drives several pins between yields lost every pin but the
first. The 4-bit binary counter example (the most-viewed one in the
gallery) blinked bit 0 and left bits 1-3 dark while the firmware counted
0-15 correctly on serial.

Sequence: pin 2's edge arrives first, finds no V-source (the pin was not
in outputPins when the netlist was built), self-heals via handleMcuEdge
and rebuilds. The pin 3/4/5 edges of that same iteration land while the
rebuild solve is in flight, so they queue in pendingMcuEdges. The drain
then looked them up in the loaded context, found no V_uno_3/4/5, and
dropped them. Those pins never got a source, so their LEDs kept the
build-time current (~0) forever — and since the LED part prefers SPICE
current over the digital fallback, they stayed dark for good.

The drop was deliberate: it stops the self-heal -> tick -> self-heal loop
for a pin wired to nothing. Replace the blanket drop with a per-pin
guard: heal a pin at most once per netlist, then drop as before. The
guard re-arms whenever a rebuild changes the V-source list, so a pin that
becomes drivable later still heals.

Verified in the browser on binary-counter-leds; two regression tests
cover both sides (sibling pin heals, unwired pin does not loop).
2026-08-16 20:02:58 +02:00
David Montero 11f1a7ef53 fix(examples): series resistors on the RGB mixer, refreshed netlist snapshots
The RGB colour mixer wired the LED's three legs straight to GPIO25/26/27 —
examples-led-resistor.test.ts fails the build over exactly that, and it is
right to: a bare LED on a pin is a short through the die. Each leg now has
its own 220R.

The 100-days netlist snapshots were recorded when 36 of those examples had
no canvas at all, so every one of them snapshotted as an empty circuit.
Re-recorded now that they have real parts.
2026-08-16 05:48:59 +02:00
David Montero 44782a419a fix(spice): per-voltage supply rails — VIN/5V no longer clamped to the 3.3V logic rail
Board supply pins split into the main logic rail plus aux rails by real
voltage (ESP32/XIAO VIN,5V -> aux_rail_5 at 5 V; 5V-Arduino 3.3V pin ->
aux_rail_3v3; Pico VBUS/VSYS -> 5 V). An external 5 V supply wired to VIN
used to read 3.3 V (ideal vcc_rail source clamped the whole net — customer
isolated it with a voltmeter). Aux rails join positiveSourceNets and the
relay nominal-voltage check; the voltmeter and hover overlay read them.
Also: ratings entry for the 4-pin ssd1306-i2c-4pin (supply pin VCC) and a
VCC alias on the 8-pin entry, so its power check works at all.
2026-08-15 08:29:39 +02:00
David Montero 34001f4748 test(examples): 100-days netlist snapshots follow the batch repair
2c4c93a2 gave 13 of the repaired 100-days examples real canvases, so
their netlists are no longer empty. Regenerated with vitest -u; every
change is the expected kind (components and rails appearing, plus the
micropython-watch button rewiring).
2026-08-14 17:28:27 +02:00
David Montero Crespo cc3b67f724 feat(m5stack-core): header pins resolve, and PWM tones carry their real pitch
Two faults from the same audit. Every wire from the Core's M-Bus header
resolved to null: the kind does not start with 'esp32', so boardPinToNumber
fell through every branch and a part wired to the header never saw a signal,
with nothing anywhere saying why. A dedicated branch (like the Cardputer's)
maps the bare-number pads, sends 5V/GND/3V3/BAT to -1, and three new tests
pin it — the board had zero test coverage before.

And LEDC events now carry the carrier frequency. The engines have always
known it; LedcDuty just had no field for it, so a speaker listener could only
ever play a canned note — the Core's piano spent its life on 660 Hz. The duty
handler stashes it per pin in the PinManager (getPwmFreq), a side table
rather than a new callback parameter so the existing two- and three-arg PWM
listeners stay untouched.
2026-08-12 15:19:30 +02:00
David Montero Crespo e3a71e0958 feat(flash): MicroPython web-flash seam (firmware install + raw-REPL upload)
The proWebFlash seam gains an optional flashMicroPython(files) method
plus an 'uploading' progress phase. MicroPython boards enable the
Flash menu when the overlay implements it — no compile step needed,
the modal collects the workspace group's files (same path as the Run
flow) and explains the two-step install+upload. toFirmwareVariant is
exported so the flasher shares the emulator's per-chip offset table.
2026-08-11 22:55:47 +02:00
David Montero Crespo 1980d70942 feat(serial): hardware-serial seams for the pro Web Serial monitor
appendHardwareSerial pushes real-UART bytes through the existing
per-frame serial batcher (no SerialMonitor changes), and
lib/proHardwareSerial lets the overlay intercept a board's serial
input while a physical monitor is attached — TX and RX switch to the
hardware together, detaching hands the tab back to the simulator.
Also adds @types/w3c-web-serial (esptool-js references it but only
dev-depends on it).
2026-08-11 21:23:56 +02:00
David Montero Crespo def450f4f1 feat(flash): seam for browser Web Serial flashing (pro overlay hook)
The board context menu's Flash item and FlashModal gain a second
backend: a Web Serial flasher the pro overlay can install via
lib/proWebFlash.ts (installWebFlashImpl). Without an overlay the seam
is inert — web builds keep the desktop-only behavior. esptool-js is
declared here because the overlay has no package.json of its own; OSS
code never imports it (verified absent from the vite bundle).
2026-08-11 21:23:56 +02:00
David Montero 49ac373068 fix(sim): drive the slave ACK in the USI I2C bridge (blank SSD1306 on ATtiny85)
The bridge was a passive sniffer that never drove the ACK bit. That
worked by accident: avr8js seeds a pin's external input value to 0, so
when TinyWireM released SDA for the ACK slot the master read 0 (ACK)
and kept transmitting. Commit eb3b04ef (seed INPUT_PULLUP pins to their
resting level) now sets that external value HIGH at init, so every ACK
slot read back NACK and TinyWireM aborted each transmission right after
the address byte: 239 address frames, zero data bytes, zero STOPs, and
a permanently blank SSD1306 on every ATtiny85 + Tiny4kOLED project.

Fix: capture each byte's ACK through the bus master callbacks (the
bridge owns the master slot on the ATtiny85) and hold SDA's external
input low for the whole ACK slot. Timing matters: TinyWireM strobes the
USI with USICS1|USICLK (avr8js clockSrc 3), which samples DI at the top
of the FALLING USITC write, one toggle after the 9th rising edge, so
the drive is released only on the falling edge that closes the slot.
Unowned addresses now read back NACK like real hardware.

Regression test replays TinyWireM's exact register sequence (from the
ATTinyCore disassembly) against a real AVRSimulator.
2026-08-11 05:19:31 +02:00
David Montero Crespo 1800f57225 fix(micropython): stop shadowing the real WiFi driver when a NIC exists
Reported in #262: on an emulated ESP32, MicroPython's WLAN.scan() always
returned [], ifconfig() reported 10.0.2.15/10.0.2.2/10.0.2.3, and
outbound sockets raised EHOSTUNREACH — while Arduino WiFi.h on the same
board and container scanned, connected on 192.168.4.x and completed real
HTTP requests. Two independent faults, both needed for the failure.

1. The MicroPython prelude injected a stub `network` module into
   sys.modules unconditionally, so user code never reached the firmware
   driver. The stub's hardcoded ifconfig() happens to be QEMU's SLIRP
   defaults, which is why the symptom read so convincingly as a routing
   bug.

2. hasWifi survived a language switch. It is a property of the compiled
   sketch, set only by arduino-cli, but setBoardLanguageMode kept it — so
   a board compiled once as Arduino carried that verdict into a later
   MicroPython run. The source-scanning fallback only fires when hasWifi
   is undefined, so it never got a chance, and the backend attaches the
   `-nic` device only when wifi_enabled is true.

The stub is NOT removed, which is where this departs from the patch in
the issue. It exists for a reason the report didn't have: without a NIC,
`network.WLAN(STA_IF)` hangs forever waiting on peripheral status bits
QEMU never sets, tripping the FreeRTOS watchdog after ~26s. Deleting it
would trade a fake-but-working network for a hung board. It is now
conditional — real driver when a NIC is attached, stub when none is —
so both paths are honest.

Also kept: the ntptime and urequests stubs, which the issue's diff would
have dropped along with `network` (it removed the whole prelude block).
Those back 29 call sites in the 100-days gallery examples, which have no
emulated internet to reach regardless of the NIC.

The detector is now one shared function instead of two copies that could
disagree — the stub decision and the -nic decision MUST match. Folded in
`from network import WLAN`, which matched neither previous pattern: such
a sketch was judged WiFi-less and hung on the missing peripheral. The
existing tests re-implemented the detection inline, so they only ever
proved that copy self-consistent; the new ones call the shipped function.
2026-08-10 21:40:58 +02:00
David Montero Crespo 0b875d4440 Merge remote-tracking branch 'origin/master' into v3.2-worktree
# Conflicts:
#	backend/app/services/espidf_compiler.py
#	frontend/src/components/editor/EditorToolbar.tsx
#	frontend/src/utils/vlxFile.ts
2026-08-10 17:00:24 +02:00
David Montero Crespo 7a7c9f93f7 feat(simulator): tilt and battery controls for boards that carry an IMU or a gauge
The emulated BMI270 and battery gauge were faithful and inert: with nothing
driving them the IMU reported the board lying flat for the whole run and the
gauge reported the single voltage it was seeded with, so a sketch that reacts
to tilt or warns on low charge could not be exercised at all.

A Sensors popover on the canvas header supplies the missing half. Tilt is
entered as roll/pitch on a drag pad rather than three raw axes — what a user
means is "lean it left" — and gravity in the board's frame is that rotation
applied to (0, 0, 1) g, which is also why the vector stays at 1 g whatever the
attitude. Turning the board spins the gyro for as long as the drag lasts and
parks it back at zero shortly after, matching a real part: a held tilt reads no
rotation. Battery is a slider over the LiPo working range.

Gated per board by builtInImu / builtInBattery on the pro board def, the same
way the Mic toggle is gated, and each section renders only if its flag is set.

The microphone stream also learns to resample. The engine now tells the source
which rate the guest programmed, so a sketch recording at 8 kHz consumes two
captured samples per read instead of playing back at half speed.
2026-08-09 23:48:56 +02:00
David Montero Crespo 6442217893 feat(epaper): 4-level greyscale rendering in SSD168xDecoder
Panels that upload a custom waveform LUT (0x32) AND write both RAM planes
(0x24 + 0x26) in a frame drive 4-level greyscale: each pixel is 2 bits (one
per plane) composited by the custom LUT into 4 grey levels. Auto-detect this
from the command stream (custom LUT + red-plane written; never key off a panel
name) and expose an optional Frame.grey (0..3) alongside the existing 1-bit
`pixels` (kept as a level>=2 -> white projection so 1-bit and BWR consumers -
EPaperPart, GxEPD2 - are byte-identical). composeFrame builds a parallel grey
plane and rotates it through the same transpose as the palette plane.

The combo (bw<<1 | red) -> level map is [3,2,1,0] (white/light/dark/black):
bw is the dominant luminance bit (bw=0 -> light pair, bw=1 -> dark pair), red
refines one step within each half; verified against the captured menu's plane
populations. Exported DEFAULT_GREY_COMBO_TO_LEVEL + an optional greyComboToLevel
option (tunes ordering only, never forces greyscale on).

Standard SSD1680 paths (raw badge examples, GxEPD2 full refresh) load the OTP
LUT, never send 0x32, never write 0x26 -> unchanged. 23 unit tests pass
(ssd168x-decoder + epaper-part-integration, incl. new greyscale + no-regression
cases).
2026-08-06 03:56:02 +02:00
David Montero Crespo 36c414df8f feat(sd): source files stay off the card - only project DATA files auto-copy
On real hardware your code lives in flash, not on the microSD; a sketch
listing the card should see its data (csv, json, txt, wav...), not its
own source. buildProjectSdImage now skips .ino/.h/.hpp/.c/.cpp/.py from
the workspace auto-copy. Explicit uploads are exempt - an upload always
lands whatever its extension. Panel hint updated; tests cover both the
exclusion and the upload exemption (15 green).
2026-08-04 19:58:31 +02:00
David Montero Crespo baa5925f95 feat(sd): live Card contents in the SD panel - list and download what the sketch wrote
The SD panel only knew about uploads; everything the GUEST wrote to the
FAT card (photos, logs) stayed invisible inside the bridge. New
readFat16Image() parses the card image back (BPB-driven, real cluster
chains, VFAT long names, root dir - the inverse of buildFat16Image but
general enough for FatFs-written cards), and the panel gains a live
'Card contents' section reading the bridge's readSdImage() with a
per-file Save (download) button and a Refresh. Round-trip covered in
fat-image.test.ts (16 green).
2026-08-04 16:29:54 +02:00
David Montero cd67362312 feat(libraries): manifest auto-migration + name-affinity ranking + install-declares
Three-part fix for the library-contamination class of ESP32 compile
failures (a user's project kept pulling unrelated libraries because 79%
of projects compile with an empty manifest, forcing scan-all resolution
over ~1000 shared libraries):

1. Name-affinity ranking in _find_library_for_header: the provider whose
   name matches the header stem wins (exact normalized match > esp32
   arch declaration > contains-stem, alphabetical only as tie-break).
   Previously first-alphabetical won, so any stray library shipping a
   generic header (Servo.h has 6 providers) could hijack the build.

2. Manifest auto-migration: scan-all compiles now report the libraries
   the build really used (manifest_incomplete +
   manifest_suggested_libraries on the compile result); the editor folds
   single-candidate suggestions into the board's declared manifest after
   a green build, so the NEXT compile runs scoped. One green build = one
   migrated project; empty-manifest scan-all behaviour is untouched.

3. Install-declares: the Wokwi import library modal records successful
   installs in the active board's manifest, and .vlx export/import now
   round-trips board.libraries.

Tests: test_espidf_library_ranking.py (9) + library-manifest.test.ts (19).
2026-08-04 06:03:32 +02:00
David Montero Crespo 7dd9b90cf9 fix(ui): a column is a RUN of pads, not two opposite ends of the board
The Arduino Uno came out wrong: 18 pins on the top header, 13 on the bottom,
and SIX x values shared between the two. The column detector accepted any two
pads sharing an x, so those pairs were read as columns and most of the board
was thrown into the side gutters with leader lines fanning across the art.

A column now needs three pads AND small gaps between them (no more than 40%
of the body), which is what a real header looks like; rows get the same guard
on x. The Uno lands as two headers, top and bottom, with no side gutters at
all - covered by a test using its real 31-pin table.
2026-08-04 03:01:21 +02:00
David Montero Crespo 4be8ddd6c3 fix(ui): a pin stack taller than the part was clipped by the preview
The ReSpeaker's left side carries 13 labels: at the minimum spacing that is
208px against a 190px body, so the column ran past both ends and the first
and last labels were cut off by the preview's clip. The gutters now grow to
whatever the labels actually occupy - the box follows the content.
2026-08-03 23:20:49 +02:00
David Montero Crespo cbc74d8409 fix(ui): the inspector dropped labels for inset pin columns, and flickered
Three things the dialog got wrong in use:

1. Missing pins. Classification was nearest-edge with an "interior" bucket,
   and interior pins printed no label - only a dot with a tooltip. The
   ReSpeaker Lite showed it: its XIAO socket is two INSET columns (x=50.5 and
   126.5 on a 178-wide body) plus two breakout columns, so most of its pins
   were called interior and the part looked like it was missing them. The
   classifier now finds the LINES first - pins sharing an x are a column,
   pins sharing a y a row - assigns each line to the side of the body it sits
   on however far inset it is, and joins label to dot with a leader. Only a
   lone pad falls back to nearest-edge. Every pin gets a printed label; that
   is now the contract, covered by a ReSpeaker test with its real table.

2. The panel was cut off on the Datasheet tab until some unrelated event
   re-rendered it. The datasheet loads asynchronously, so the one-shot
   measure placed the dialog while it was still short and it then grew off
   screen. A ResizeObserver repositions on any size change, and the tab body
   caps its own height so long markdown scrolls inside the tab.

3. Flicker when the pointer left and re-entered. The preview effect depended
   on componentProperties and pinInfo by identity - both are rebuilt on every
   parent render (pinInfo is a getter) - so the element was torn down and
   remounted constantly. It now depends on stable digests of them.
2026-08-03 23:04:37 +02:00
David Montero Crespo b02aab9a47 fix(ui): crowded pin headers escaped the inspector - draw those labels vertically
An SSD1306 has 8 pins along a 240px header. Horizontal labels need ~36px
each = 288px, so they overflowed the art box and rendered outside the dialog,
over the header and the canvas. Top/bottom labels are now vertical text - what
printed pinout diagrams do - which needs only the side spacing, and the gutter
grows to fit. The preview also clips, so nothing can escape again.
2026-08-03 22:04:43 +02:00
David Montero Crespo d4a42a4e75 feat(ui): the Part Inspector - right-click dialog merged with the picker card
The right-click dialog was a 250px column: every pin as a text row (an ESP32
means 30 rows of scrolling), a few editors, Rotate/Delete. The picker's info
card (datasheet, brand, Buy) was a separate surface you could only reach by
hovering the catalogue. One dialog now serves both:

- ~560px, two panes: live preview + actions on the left, Properties/Datasheet
  tabs on the right. The Datasheet tab IS the picker card's body -
  ComponentInfoPanel was split into an embeddable ComponentInfoBody (+
  useComponentDoc, and its .cip-* styles moved to their own stylesheet) so
  both surfaces render one implementation.
- "Show pins" (off by default): the preview is the REAL web component,
  mounted and scaled, with pin markers at their true pinInfo positions and
  labels flanking the side each pin lives on. pinInspectorLayout is pure
  geometry - nearest-edge classification, per-side label stacking with a
  minimum spacing and leader lines when nudged, interior pins as
  dot+tooltip - driven only by pinInfo plus the measured element size, so
  every part and board, present or future, works with no per-part data.
  Dots are tinted by the pin's declared signal (i2c/spi/power/...).
- Clicking a pin keeps the old contract (onPinSelect -> handlePinClick), so
  wires start with the same undo, colors and running-state gating.
- The two open gestures finally agree on coordinates: right-click always
  passed viewport coords while touch passed canvas-local ones; the dialog is
  now portaled to body in viewport space and the touch site converts.
- Keybind capture, the SD Card panel, the two-step delete, Escape and
  click-outside all migrate unchanged. Mobile keeps the full-sheet layout.

Geometry covered by pin-inspector-layout.test.ts with coordinate tables
copied verbatim from the shipped parts (ESP32 two-column board, LED, an
interior-pin case).
2026-08-03 21:44:46 +02:00
David Montero Crespo d4ec4fe04d fix(ntc): the temperature sensor read wrong on 3.3V boards, and three examples decoded backwards
Two independent defects, both visible as "the NTC reports the wrong
temperature" (issue #233).

1. The part built its divider against a hard-coded 5 V rail, while the SPICE
   path used the board's real rail. They only agreed on AVR. On a 3.3 V board
   the injected node sat above the ADC's full scale: the slider at 25 C read
   ~1.4 C, and every slider value below ~11 C saturated to the same number, so
   -40, 0 and 10 C were indistinguishable. The rail now comes from the board
   (analogRailVolts), matching setAdcVoltage's own AVR/RP2040/ESP32 split.

2. The divider convention was corrected in 608c2538 (VCC -> R_pull -> OUT ->
   R_NTC -> GND) but data/examples.ts was not updated with it, so three of the
   four bundled NTC sketches still decoded the OTHER topology. Their readings
   came out mirrored about 25 C: the uno-ntc example printed -3.42 C with the
   slider at 60 C - the exact symptom dd22bcfe had recorded as the bug. Fixed
   in uno-ntc, pico-ntc and attiny85-ntc-sensor; examples-circuits.ts was
   already right.

The sibling analog sensors (gas, flame, heart-beat, sound) map their sliders
0..1023 onto the same hard-coded 5 V and will saturate the same way on 3.3 V
boards. Left alone here: that is not what #233 reports, and each one needs its
examples re-checked before changing.
2026-08-03 07:33:20 +02:00
David Montero Crespo cd2dc2f54b Merge remote-tracking branch 'origin/master' into v3.2-worktree 2026-08-01 04:23:55 +02:00
David Montero fc048ff941 Merge branch 'feat/circuit-safety-p1' 2026-07-31 20:43:10 +02:00
David Montero c97a43fd7c verify: catch unpowered nets, open source loops, and relay coil voltage mismatch
Audited circuits (2026-07) passed pre-flight with zero findings while being
electrically dead or mis-supplied: a battery with one pole wired, a MOSFET
switching a rail nothing powers, a 12V-coil relay fed from 5V, and VCC pins
tied together with no source. Three new graph-based rules (no solve needed,
so they fire even when ngspice cannot converge; all non-blocking warnings):

- unpowered-net: power-input pins (VCC/VIN/V+/COIL+/rated supply pins) and
  whole conduction regions that no battery, power supply, or board pin
  reaches. Ground and source-negative nets do not bridge power; MOSFET/BJT
  conduct only through their channel; a relay coil is isolated from its
  contacts; custom chips and boards are self-powered.
- no-return-path: a source with a single pole wired, or whose + side never
  reconnects to its - side (return analysis joins all pins, so voltage-driven
  gates/bases are not false positives).
- voltage-mismatch: relay coil_voltage vs the nominal supply on the coil
  nets (region fallback for coils fed through a switch/transistor): below
  the 60% pull-in threshold it never actuates, above 1.5x it burns out.

verifyFromStore now also verifies circuits whose only source is a
power-supply component. Gallery sweep extended to fail on the new codes:
zero findings across all 228 wired examples.
2026-07-31 20:38:23 +02:00
David Montero 1ad1fd4137 feat(components): DS3231 RTC module + GPS NEO-6M NMEA talker
DS3231 (I2C 0x68):
- New velxio-ds3231 Web Component (4-pin module: GND/VCC/SDA/SCL) with
  pinInfo per CLAUDE.md 6a; registered in elements-register + main.tsx.
- Catalog entry in scripts/component-overrides.json (_customComponents)
  mirrored into components-metadata.json exactly as the generator emits it
  (verified by cloning wokwi-elements and running generate:metadata: zero drift).
- The existing VirtualDS3231 device sim (DS1307-compatible time regs
  0x00-0x06 + control 0x0E / status 0x0F / temperature 0x11-0x12 at 0.25 C
  steps) is now user-reachable from the picker; the AVR/RP2040 attach path
  gains live temperature updates via the SensorControlPanel (new ds3231
  slider in sensorControlConfig).

GPS NEO-6M (UART talker, 9600 baud):
- New velxio-gps-neo6m Web Component (VCC/RX/TX/GND, pulsing PPS LED).
- New part sim GpsParts.ts: emits a valid GPGGA+GPRMC cycle per second
  (correct XOR checksums, ddmm.mmmmm coords, UTC clock advancing 1 s/cycle)
  with lat/lng/altitude/speed configurable via property dialog and live
  via a new SensorControlPanel entry. Defaults: Madrid 40.4168 N 3.7038 W.
- Injection routes, chosen by classifying the wired board pin:
  1) hardware UART RX -> new uniform sim.feedUart(uart, data) seam
     (AVR USART0, RP2040 uart0/1 via rp2040js feedByte, ESP32 uart0/2 and
     STM32 USART1/2 via their bridge shims); byte feed is paced in 8-byte
     wall-clock chunks so 32-byte RX FIFOs never overflow;
  2) any other digital pin on a cycle-accurate sim -> real 8N1 bit-banged
     waveform via schedulePinChange, so SoftwareSerial decodes it;
  3) feedUart rejection (e.g. Mega USART1-3, not modelled by avr8js)
     degrades permanently to the bit-banged path.
- Interconnect already probed sim.feedUart; implementing it also upgrades
  cross-board UART byte shortcuts for RP2040 uart1.

Tests: new gps-neo6m.test.ts (16 tests: NMEA builders, byte injection on
Uno RX0 / ESP32 RX2, Mega RX1 fallback, waveform decode back to $GPGGA,
live position updates, PPS pulse) + 3 new ds3231 AVR-path tests in
protocol-parts.test.ts. Full frontend suite: 2294 passed, 2 pre-existing
Galaksija Z80 timeouts that pass in isolation. npx tsc --noEmit clean.
2026-07-31 20:38:06 +02:00
David Montero Crespo 3cc1510cae fix(examples): el enlace directo a un ejemplo pro ya no parpadea un 404
Reportado en /example/pi5-opencv-vision: al recargar aparecia un instante
el 404 con el header completo (menus incluidos) y despues cargaba el
editor. La carrera: los ejemplos pro se registran cuando aterriza el
import dinamico del overlay, y la pagina resolvia la galeria ANTES,
concluia "no existe", pintaba el 404 y al llegar el registro re-renderizaba
al editor.

"No esta en la galeria" y "no esta TODAVIA" son respuestas distintas, asi
que el registro gana una senal de asentado: main.tsx la activa cuando el
import del overlay resuelve (o falla — finally), e inmediatamente en la
build OSS, donde no viene overlay y un 404 debe ser instantaneo. La pagina
se queda en "Loading example..." hasta que el registro asienta y solo
entonces un id ausente es de verdad un 404.

markProExamplesSettled es idempotente y solo notifica una vez, con test —
el sintoma de romper eso seria una tormenta de re-renders por hot-reload
del overlay.
2026-07-30 17:06:05 +02:00
David Montero Crespo 03afa31b5f feat(editor): menus File/Edit en la cabecera y editor sin nav de marketing
La queja: demasiados botones en una sola fila, y en pantallas pequenas se
solapan — medido, no impresion: a 1024px Code/Both/Circuit pisaban a
Compile/Run/Stop por 9-20px y Add pisaba el chat por 29px, con el idioma y
el usuario cortados fuera del viewport.

Tres piezas:

1. En el editor, el nav de marketing (Home/Docs/Pricing/...) sobra: ya
   estas dentro, y cuesta exactamente el ancho que le falta a la toolbar.
   AppHeader gana la variante editorMenu — mismo mecanismo que ya usa la
   build de escritorio (VITE_DESKTOP) — que oculta el nav y pinta un menu
   File/Edit junto al logo. El logo sigue llevando a inicio; idioma,
   autoguardado, Share y usuario se quedan.

2. File/Edit al estilo escritorio: lo que se usa cada minuto sigue siendo
   boton; lo que se usa unas veces por sesion va al menu. File: nuevo
   workspace, nuevo fichero, abrir, guardar, importar, exportar, BOM,
   imagen del esquema. Edit: undo/redo (con su estado real del historial),
   centrar vista, zoom. Las acciones viven en cierres de cuatro
   componentes distintos, asi que hay un registro id->handler
   (lib/editorCommands, mismo patron de seam que registerProExamples o
   registerBoardBuiltins): cada dueno registra al montar y el menu invoca
   por id; un item sin dueno montado se pinta deshabilitado, que ademas es
   la verdad. El registro sobrevive al remontaje StrictMode (el cleanup
   viejo no borra al sucesor) y tiene test de eso.

3. La toolbar adelgaza: fuera Undo/Redo (Ctrl+Z/Y siguen), fuera los
   botones inline de Import/Export y sus gemelos responsivos del More
   (~70px recuperados); el More queda solo con los extras pro (firmware,
   GitHub, share, grabacion). El CSS responsivo de los botones retirados
   se va con ellos.

Suite en verde (2300) y build del overlay verificado desde este arbol.
2026-07-30 16:26:36 +02:00
David Montero Crespo fdcf0d19d2 feat(canvas): ver donde cae lo que anades, y clicks con sentido
Tres cambios que van juntos porque atacan la misma queja: "anado un
elemento y a veces ni veo donde se anadio".

1. Donde cae. Ya se anclaba a la esquina visible, pero la cascada que evita
   que se apilen iba indexada por components.length: seguia avanzando
   aunque movieras o borraras piezas, asi que las caidas se alejaban cada
   vez mas de donde estabas mirando. Ahora toma el primer hueco LIBRE desde
   la esquina, bajando en diagonal; si apartas la ultima, la siguiente
   recupera su sitio. Extraido a utils/dropSlot con 8 tests, incluido el
   caso de "la apartaron" y el tope para no salirse de la vista.

2. Que se vea. El recien anadido queda seleccionado, y la seleccion pasa de
   un borde discontinuo quieto a un caminito de hormigas. El movimiento es
   lo que capta el ojo en un canvas lleno; un borde fijo se pierde. Va en
   un pseudo-elemento por fuera del cuerpo, sin robar clicks ni tapar el
   dibujo, y se queda quieto si el sistema pide menos animacion.

3. Clicks. El izquierdo SELECCIONA y ya esta; antes abria el panel de
   propiedades, o sea que no podias ni senalar una pieza sin comerte un
   popup que luego habia que cerrar. Propiedades y pines pasan al click
   derecho, que es donde va lo deliberado. En tactil se mantiene tocar ->
   panel, que ahi no hay boton derecho.
2026-07-30 15:40:52 +02:00
David Montero Crespo 31c722b593 feat(pi): salida de pantalla para las Pi + arreglo del guard de connect
Seam nuevo registerBoardBuiltins/getBoardBuiltins: una placa que el arbol
OSS ya dibuja (la familia Pi) puede recibir perifericos del overlay sin
registrar un ProBoardDef, que es lo que secuestraba el arte de la placa y
dejaba los cables en la esquina. El overlay lo usa para llevar los frames
del guest (cv2.imshow) al panel cableado en el canvas.

Arregla ademas el guard de connect() del bridge: comparaba readyState con
WebSocket.OPEN leido del global, asi que cuando esas constantes no estaban
`undefined === undefined` era cierto con socket a null y connect() volvia
sin abrir nada — el bridge se quedaba muerto. Ahora comprueba que el socket
exista y usa el valor numerico. Recupera los 12 tests de
multi-board-integration que esto habia roto.

El test del UART Pi->Uno pasa a comprobar el contrato vigente: por el cable
va onUartTx (el UART del header), no la consola del guest; y se anade el
caso que fija que la charla de arranque NO se filtra al vecino.
2026-07-30 01:09:04 +02:00
David Montero Crespo d98617f1d2 fix(pi): arrancar sin bridge deja de fallar en silencio + test del reinicio
startBoard llamaba `getBoardBridge(id)?.connect()`: si la placa no tenia
bridge, el encadenamiento opcional se lo tragaba y el usuario se quedaba
sin guest, sin error y con la barra mostrando Stop. Ahora avisa por
consola y baja el flag de running, que es lo unico honesto que se puede
hacer ahi.

Test nuevo del camino que usa el boton de modo Linux: fijar el modo,
parar y arrancar tiene que abrir el WebSocket del guest, dejar
engineMode en linux y running en true; y un socket en CLOSING no puede
impedir la reconexion.
2026-07-29 20:10:57 +02:00
David Montero Crespo 9160624da4 fix(uart): la Pi puede hablar por serie con la placa de al lado
Dos piezas que faltaban para que pi-to-arduino-led-control fuera algo
mas que un guion imprimiendo lo que "habria enviado".

1) classifyPin no reconocia los pads del header por su nombre. Se llaman
   GPIO14 / GPIO15 en el dibujo de la placa y en todos los cables de los
   ejemplos, pero solo se aceptaban numeros fisicos: parseInt('GPIO14')
   daba NaN, el pin no clasificaba como nada y el Interconnect nunca
   construia la ruta. Ahora se acepta el prefijo GPIO/BCM y la numeracion
   fisica sigue funcionando.

2) Seam de serie para placas que no tienen ni simulador ni bridge: el
   motor de navegador corre el Python de la Pi en la propia pestana.
   registerSerialSink(placa, fn) recibe los bytes que le llegan y
   feedBoardSerialOut(placa, ch) anuncia los que envia, que es lo que el
   enrutado por cables ya sabia repartir.
2026-07-29 07:40:13 +02:00
David Montero Crespo 5a79836fdc test(examples): ningun LED de la galeria puede colgar directo de un pin
Guarda de regresion para lo que se acaba de arreglar. Recorre el grafo de
cableado (no solo el vecino inmediato del LED, porque la resistencia
protege igual desde el catodo o desde el otro lado de un transistor o un
rele) y falla si algun LED llega a un pin sin limitar la corriente.

Es el peor tipo de ejemplo roto: el sketch imprime "LED ON", el runtime
quema el LED y no aparece ningun error, solo un LED oscuro.
2026-07-29 07:24:25 +02:00
David Montero Crespo a13eb3e7e2 fix(adc,examples): mapa ADC por chip, entradas flotantes y una auditoria de la galeria
Tres cosas que salieron al tirar del hilo de los pull-ups.

1) ESP32_ADC_PIN_MAP era la tabla del ESP32 CLASICO aplicada a toda la familia, y
   fallaba de dos maneras:
     - S3: sus pines ADC son GPIO1..20, ninguno esta en esa tabla, asi que la
       busqueda devolvia undefined y el listener del potenciometro no llegaba a
       engancharse. El mando no hacia nada.
     - C3/C6: GPIO0 SI esta en la tabla clasica, como ADC2_CH1 -> canal 9. Pero en
       esos chips GPIO0 es ADC1_CH0 y sus motores toman un indice de canal de ADC1
       (0..4). El valor se empujaba al canal 9, fuera de rango, descartado en
       silencio mientras el firmware leia el 0. Una respuesta equivocada en vez de
       ninguna, que es peor.
   adcPinMapFor(boardKind) devuelve la tabla del chip.

2) El ejemplo de las gafas OLED en MicroPython declaraba sus botones con Pin.IN a
   secas. Van entre 3V3 y el pin, asi que sin pull-down el pin queda FLOTANDO con
   el boton abierto. El propio ejemplo ya lo avisaba en un comentario ("Add
   Pin.PULL_DOWN if the pin floats") — ahora lo hace. Es un fallo de hardware de
   verdad, no un artefacto del emulador.

3) gallery-run-gate.audit.test.ts: EditorToolbar bloquea el Run cuando el
   verificador de circuito saca errores, asi que un circuito invalido no es
   cosmetico, es la diferencia entre un ejemplo que arranca y uno que parece
   muerto. Y esos defectos se esconden hasta que el circuito resuelve DE PUNTA A
   PUNTA: c3-button llevaba un LED de 506 mA que nadie veia porque sus cables
   apuntaban a pines inexistentes.

   El test reproduce la puerta del Run tal cual (mismo snapshot de peor caso,
   mismo buildInputFromStore, mismo verifyCircuit, ngspice de verdad) sobre los
   227 ejemplos. Encontro tres mas sin resistencia en serie —nano-button-led,
   mega-led-chase y mega-serial-control, 17 LEDs en total— y ahora quedan 0.
   A partir de aqui, un ejemplo nuevo con un LED colgado del GPIO salta en CI.
2026-07-27 05:01:49 +02:00
David Montero Crespo a698534b7b fix(pins): la cabecera del M5 Cardputer ya resuelve a numeros de GPIO
boardPinToNumber no tenia rama para 'cardputer-adv', asi que caia al return
null del final: cada cable a su cabecera EXT o al Grove Port A se conectaba a
nada. La pieza quedaba en el lienzo, el sketch leia un pin muerto y nadie
avisaba de nada.

La rama compartida de esp32 tampoco habria servido: recorta en 39 y este board
saca G40 en la cabecera. El S3 tiene 48 GPIOs.
2026-07-26 02:47:05 +02:00
David Montero 4ff281bd40 fix(spice): MCU-edge listeners detach on resubscription for QEMU boards (frozen LEDs)
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).
2026-07-24 07:26:09 +02:00
David Montero 734b7d0487 feat(esp32): pure ESP-IDF language mode for the ESP32 family (#139)
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).
2026-07-24 06:37:01 +02:00
David Montero Crespo 8c1bbbc1a7 fix(7segment): rebuild per-element sim state when the digit count changes
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>
2026-07-21 15:16:56 +02:00
David Montero Crespo fce1cdafdc fix(esp32): force clean reconnect in Esp32Bridge.connect() — the real run-after-agent fix
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>
2026-07-21 07:01:05 +02:00
David Montero Crespo d726139946 feat(breadboard): one-hole-one-wire selection rules + jumper colors
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>
2026-07-21 05:49:23 +02:00
David Montero Crespo 701042fa22 fix(perf): un-freeze the editor during fast-toggling simulations (ESP32 clock)
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>
2026-07-21 01:15:59 +02:00
David Montero Crespo 0635e15e7a fix(router): escape corridors for endpoint-in-obstacle + checked-elbow parity
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>
2026-07-20 21:35:58 +02:00
David Montero Crespo 651161559a feat(wires): avoid other wires + live routed preview + system-owned shapes
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>
2026-07-20 20:08:25 +02:00
David Montero Crespo 3ac00ae5ff fix(trace): recognise runtime boards and same-hole junctions in pin tracing
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>
2026-07-20 18:46:23 +02:00
David Montero Crespo 9e5ae8baf6 fix(breadboard): derive seating at element mount — closes run-before-seating race
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>
2026-07-20 17:39:29 +02:00