observe() is called once per emulated instruction, and nearly every one of
those is a step forward inside the same code — not a backward branch, not a
long jump. It was doing the loop-close bookkeeping first anyway: two range
updates and a counter on fields of a foreign object, then the test that would
have told it none of that was needed yet.
The forward case now returns after one comparison and at most one field write.
The loop's lowest PC is only updated where it can actually change — a backward
branch IS the loop top — so nothing about what counts as an idle spin moves.
Measured on the RP2350 host (BadgeOS, MicroPython on the M33), together with a
one-read-per-turn change on the caller's side: +11% instructions per CPU
second, four rounds out of four.
IdleSpinDetector reported a spin from two signals: the same backward branch
closing over and over, and no GPIO change while it did. A bytecode interpreter
matches both — MicroPython's dispatch closes the same loop millions of times
and touches no pin — and eliding it does not skip the work, it only races the
simulated clock ahead of work that is really happening. On the RP2350 that cost
the Badger 2350 its frame budget: BadgeOS executed 500 k guest instructions per
second of guest time where the emulator can do four million.
Two more things now have to hold before a loop counts as idle: its body must be
narrower than 128 bytes, and one turn of it must take fewer than 24
instructions. A delay() that polls the timer is a handful of instructions in a
handful of bytes; a dispatch loop is neither.
Both are ceilings, so this can only ever elide LESS than before — the RP2040's
own delay() elision is unaffected.
Typing anything into the /examples search box threw "Cannot read
properties of undefined (reading 'map')" and blanked the page. The
haystack the search matches against is built from
`example.components.map(...)`, and `components` was undefined on 27
examples.
`components` is required by `ExampleProject`, so this reads as
impossible — but the gallery is assembled from overlay example modules
whose factories end in `as unknown as ExampleProject`, and the compiler
stops checking there. The field is a promise the type makes and cannot
keep, so the three places that read it now cope with its absence.
CircuitPreview is the one that matters most: it renders as the fallback
whenever an example's thumbnail 404s, so it meets exactly the examples
nobody has generated art for yet.
fqbnForLanguage landed on the single-board Run path and on
compileBoardForFlash, but compileAllBoards still read BOARD_KIND_FQBN
directly — so a canvas with more than one board, or any run that goes
through the multi-board path, still refused an ESP32-C5 kit with 'no FQBN
configured' while its ESP-IDF project built fine on its own.
BOARD_SUPPORTS_ESPIDF is a literal set of OSS board kinds and it gates
both the toolbar's ESP-IDF option and setBoardLanguageMode, so no overlay
board could ever be switched into ESP-IDF: loadExample called
setBoardLanguageMode(id, 'espidf') for an `languageMode: 'espidf'` gallery
example, the guard returned early, and the example opened in Arduino mode
with the toolbar saying "Arduino C++". It stayed invisible because the
backend sniffs `app_main` and compiles those sketches as pure IDF anyway —
until a board with no Arduino FQBN at all hit it, and the run died in the
browser at "No FQBN for board kind" before any request left the page.
- ProBoardDef.supportsEspIdf registers the kind into BOARD_SUPPORTS_ESPIDF,
the same way supportsMicroPython already did for MicroPython.
- ProBoardDef.espidfFqbn + BOARD_KIND_ESPIDF_FQBN + fqbnForLanguage give the
IDF lane a target for a board whose Arduino FQBN is null (the ESP32-C5
kits: no arduino-esp32 core supports the C5, and the backend refuses that
target for Arduino on purpose, but idf.py builds it fine). Both compile
sites resolve through it instead of reading BOARD_KIND_FQBN directly.
- The language select shows when EITHER alternative mode exists (the P4 is
ESP-IDF but not MicroPython, so it had no select at all), hides options
the board does not support, and hides Arduino for a board with no FQBN.
- addBoard no longer seeds an Arduino-less board into 'arduino', where it
could never compile and the select would show a value it does not carry.
Regression test: overlay-espidf-mode.test.ts.
Auditing the catalog for parts in the same situation as the DHT22 and the
HC-SR04 turned up three things.
Two more models own a real pad and were not declared: a matrix keypad's
COLUMNS (the QEMU worker even keeps a `_keypad_cols_owned` set, idles them
HIGH and drives them on each scan) and an ePaper panel's BUSY line. Neither is
broken with plain wiring today, only because the connector's older
`sourcedNets` gate happens to skip a net nothing else sits on — put a pull-up
on a keypad column and that gate stops holding, which is exactly how a
level-shifted HC-SR04 lost its echo. Their DC / CS / RST stay absent on
purpose: the host drives those.
Ownership is therefore no longer "is this a single-wire sensor?" but "which
fields does this model declare as pads it drives", which also lets a field
hold a LIST of pads (the keypad's columns).
And `pushbutton-6mm` did nothing at all, on every board. Three parts hand
control of their pin to the electrical solve rather than seeding it; two of
them are modelled by SPICE and the 6mm variant was not, so it stood down for a
circuit that never modelled it. It is the same switch as the full-size one, so
it now shares that mapper through a MAPPER_ALIASES table. Verified in a real
browser: on production the button visibly depresses while the sketch keeps
reading `released`; with this build it reads PRESSED and returns.
The last part is the guard: a test reads the parts themselves and fails if any
part that defers to SPICE has no model, so the next variant is covered the day
it is written. It catches pushbutton-6mm when the alias is removed.
Two additions, both for peripherals that model the far end of the wire
rather than watch it arrive bit by bit.
feedWord() now carries which state machine wrote the word, where the host
plumbing knows it, so a peripheral can tell one program from another.
consumesTxWords() is the new one that matters: it says "I have this word,
the PIO does not need to shift it out". A Pimoroni Unicorn's panel spends
about 300,000 PIO cycles per refresh in the program's BCD delay loops, and
every one of them is wasted once the pixels have been read out of the FIFO
word. Unlike inDiscardableWriteData(), the peripheral is still fed the
word — that is the whole point of it.
The cost test asserted milliseconds per pin, and a deploy gate that happened to
be running two test shards read 8.19 where an idle box reads 1.96 — so it
failed the deploy over the machine's mood rather than over the code.
Worse, the number it watched was the wrong one. Tracing a pin on a shared
ground rail costs O(node) because the answer is a property of the whole node:
a canvas with more legs on that rail HAS a bigger node, and a higher per-pin
cost, by design. The hazard the index actually removes is different — the
first cut re-scanned every wire on the canvas at every frame, so circuitry
that had nothing to do with the traced net still slowed it down.
So the test now grows the canvas with UNRELATED clusters (their own breadboard,
their own rail — a board pad is where a walk stops, so they share no node) and
asserts that tracing the same little net does not get slower. Best-of-seven, so
it measures the code and not a scheduling hiccup. Indexed it reads ~0.6-0.9x
across an eightfold canvas; with the cache disabled it reads 7.8x and fails,
which is the negative control this threshold was set from.
"Which sensors drive their own wire" was written down three times: a set of
type names in the ESP32 bridge, a wiring map in the store, and a union type
plus per-type branches in the overlay's hub. Nothing tied them together, so a
third single-wire sensor meant finding all three, and missing one is silent —
the same shape of mistake that cost an HC-SR04 its echo pin in the first place.
simulation/sensorModels is now the list: the sensor type, its data pin, its
properties and the extra pins its model also drives. The store's
SENSOR_COMPONENT_MAP IS that declaration, the bridge re-exports the ownership
predicate instead of restating the names, and the overlay's hub derives its
union from it and gates sensorFromRecord on the shared check. What stays
per-device is the MODEL — the waveform each sensor answers with — which is
real behaviour and belongs next to the code that produces it.
Ownership now walks the extra-pin fields a model DECLARES rather than any
field that happens to end in _pin, so a stray property cannot take a pad, and
a model that grows a second line is covered by its declaration alone.
BOARD_PIN_GROUPS is keyed by the BoardKind union, so a board registered by the
overlay could never appear in it and fell through to `default` — 5 V, ground
pads named GND/GND.1/GND.2, supply pads named 5V/VCC. Every RP2350 and
ESP32-family pro board is a 3.3 V part whose supply pad is called 3V3, so the
netlist powered it from the wrong rail and did not recognise its supply pad at
all. Nothing errors; the numbers are simply wrong.
Caught on the Pimoroni Pico Plus 2 W: a potentiometer across 3V3 and GND read
2.50 V at half travel instead of 1.65 V, because the divider was solving
against a 5 V rail. The same 1.5x error was sitting under every LED current,
pull-up and divider on every pro board.
boardPinGroupFor() consults the pro registry before falling back, and a board
declares its pads through ProBoardDef.power. The OSS table is untouched, so no
existing board changes behaviour.
Found while wiring the ADC seam: enabling the SPICE analog path for a pro board
is what finally made the wrong rail visible.
Review pass over 8f148e8, closing what was still copied or only half true.
- `upsertSensorRecords` is THE merge for sensor records, exported from the
ESP32 bridge: same kind on a pin merges per field (the part and the store
each know one half of an HC-SR04), a different kind replaces outright, so
a DHT22 swapped for an HC-SR04 cannot inherit a stale echo_pin that the
ownership guard would then keep. The seven overlay bridges carried their
own copy of the old replace-by-pin loop — the exact copy-six-times trap
this codebase has hit before — and now call this one.
- `sensorRecordOwnsPin` is likewise the one answer to "does this record own
that pad", so the overlay's pre-connect path and the QEMU bridge cannot
drift apart.
- connectDigitalInputsToMcu forgets its cached level for a pin a part owns.
Keeping it meant that once the part let go (rewire, unmount, detach) the
next solve could find the circuit agreeing with a stale memory and never
re-emit, leaving the guest at whatever the part last drove.
- traceBoardGpio's `boardId` was a filter applied AFTER the walk had already
picked a pad, so a net reaching two boards' GPIOs answered null for the
second board. It is now a preference the node collection honours, with a
test that fails without it.
Hardening pass on 57325aa, which fixed the reported HC-SR04 divider but did it
by returning the first answer any branch produced. Five topologies people
really draw break under that rule, and each one now has a test that fails on
57325aa and passes here:
- a ground tie point almost always carries something else (a button's
pull-down). Reaching a rail has to END the walk, or a grounded pin resolves
to whatever GPIO that pull-down hangs off. Same through a breadboard rail;
- two custom chips on one wire stopped sharing a chipbus net key, because a
nested frame answered with its own neighbour's per-endpoint key before the
root could mint the net-canonical one;
- a chip one hop away ended the search before a real GPIO on the same node
was ever looked at;
- a node first reached deep, where the hop budget cut it short, was never
re-walked when a shorter route arrived.
So the walk now models what a net IS. It collects the whole NODE (every pin
joined without crossing a component: the wires, and a breadboard strip's
holes), then judges the set: a driven pad wins outright; a supply pad means the
node IS the rail and the walk stops there; only then does it cross a passive to
the next node, where a rail is the weaker answer that lets a level-shifted
sensor resolve. Wires, components and breadboard groups are indexed per store
array, and a driven pad short-circuits collection — the common case is now
0.08 ms/pin on a 300-wire canvas, better than before this work started, with
the shared-rail worst case at ~2 ms.
Second half: a part that models its own line keeps it, on every board.
connectDigitalInputsToMcu thresholds the solved node into every input pin, and
its `sourcedNets` gate only ever protected part-driven lines by accident — it
holds while no component sits on the net, and stops the moment the user adds
the pull-up or the divider real hardware needs. Then the connector pins the
line an HC-SR04 or a DHT22 is answering on. simulation/partPinOwnership records
what each part drives (implicitly, through the simulator it is handed) and the
connector skips those pins; the ESP32 bridge answers the same question for
sensors the backend or an engine emulates, through a new optional `ownsPin`
seam. Parts SPICE can describe never claim anything, so a mis-wired button
still reads stuck — that property is why spiceDrivenInputs exists.
Also: setSensors merges per FIELD, so the coarser registration path can no
longer drop the finer one's echo_pin; the ESP32 bridge's guard is narrowed to
the single-wire sensor types, since an ePaper's DC pin or a keypad row must
stay drivable; and the Mega and Pico W pin maps get the supply-pad guard the
other boards have, where '5V' parsed as D5 and '3V3' as D3.
Verified in a browser against a local build of this tree, driving the real app:
HC-SR04 and DHT22, direct wiring and level-shifted, on Uno (avr8js), Pico
(rp2040js), ESP32 (esp32js and QEMU) and ESP32-S3 (esp32s3js) — see
scripts/sensor-wiring-matrix.mjs in velxio-prod.
An HC-SR04 on an ESP32-S3 answered "No echo" forever the moment its ECHO
went through the 1k/2k2 divider a real 5 V sensor needs, and worked again
when the divider came out (reported on Discord, project esp32-s3-distance).
Nothing about the divider is wrong: three separate resolvers could not see
past it.
1. The wire walk treated a net as a chain. Arriving at the divider's tap it
hopped straight across the lower resistor, hit GND, and answered "tied to
a rail" (-1) — never looking at the OTHER wire on that same tap, where
GPIO41 was. A rail was also allowed to end the search, so a plain pull-up
drawn before the signal wire hid the GPIO behind it the same way. The walk
now explores the whole node (same depth, one shared visited set) and
treats a supply pad as the weakest answer: remembered, returned only when
no driven pin was found anywhere.
2. The walk moved to simulation/PinTrace so the store can ask the same
question. It pre-registered backend sensors with its own single-wire
lookup — one wire, far end must be a board — so ECHO (landing on a
resistor) resolved to nothing. That entry REPLACES the part's, keyed by
pin, so the echo pin was silently dropped and the QEMU worker fell back to
TRIG+1: it pulsed GPIO22, which nobody was reading. Both the data pin and
the extra pins now resolve through the shared walk, so a sensor behind a
breadboard strip or a divider registers like any other.
3. Esp32Bridge.sendPinEvent now refuses a pad a registered sensor owns, the
guard the in-browser engines already have. connectDigitalInputsToMcu
thresholds the solved node, and `sourcedNets` does not hold it back once
real components sit on the net — the divider makes ECHO exactly that, and
it solves at ~0 V because nothing models the sensor's output.
Test covers the reported circuit end to end: the walk, and the echo_pin that
reaches the start_esp32 payload.
A Visuino-built `Generated.ino.merged.bin` was reported as failing to run in
Velxio. It runs fine. The screenshot shows the ESP32 bootloader completing and
the sketch executing; what it also shows is
[E][STA.cpp:357] connect(): STA connect failed! 0x300a: ESP_ERR_WIFI_SSID
[W][WiFiGeneric.cpp:900] setTxPower(): Neither AP or STA has been started
which is WiFi association, not firmware loading — and reads as "the emulator is
broken" to someone who has no reason to know the difference.
The emulated radio broadcasts four networks and nothing else: Velxio-GUEST,
PICSimLabWifi, Espressif, MasseyWifi, all open. Sketches compiled here never
have to know, because espidf_compiler rewrites their SSID literal on the way to
the emulator. Firmware that arrives already built skips that step by
construction — there is no compile to rewrite anything — so it hunts for the
network it was built for and never finds it.
Confirmed on the real emulator rather than reasoned about: the same sketch
built with SSID "MyHomeNet" reports FAILED, and with "Velxio-GUEST" connects on
192.168.4.x. Same board, same path, only the string differs.
So the upload path now reads the image and, when it carries the Arduino WiFi
stack but names none of the reachable networks, prints one line saying which
networks exist and why a compiled-elsewhere binary cannot be fixed up
automatically. When the image already names one it says nothing: a note nobody
needs is noise, and noise is how people learn to ignore the console. The
markers are deliberately narrow for the same reason — `esp_wifi` alone appears
in images that never touch the radio.
The SSID list moves to EMULATED_WIFI_SSIDS in types/board.ts. It was written by
hand inside the MicroPython prelude string, and a second hand-written copy is
precisely what #268 was made of.
Not closed by this: the reporter's exact error is ESP_ERR_WIFI_SSID from
esp_wifi_connect, meaning the SSID handed to the stack was invalid rather than
merely absent from the air — forcing an empty SSID here produces a different
message from a different line (STA.cpp:387, "SSID too long or missing!"). That
may be his Visuino configuration or his core version; it needs his binary to
tell, which he offered to send.
Export a project to .zip, start a new project, import the zip: the circuit
comes back and the board does not. Two reporters; the second noticed it "only
works with wokwi components", which is the shape of it — the passives are real
Wokwi part types and survive, the board was not one.
The bug was in the EXPORT, not the import. wokwiZip knew four boards and wrote
`wokwi-arduino-uno`/`uno` for anything else, while the connections kept the
board's real canvas id. The attached repro says so in six lines: parts declare
an `uno`, connections name `esp32`.
Of those four, only two round-tripped. 'arduino-mega' was missing from the
export's board-id test, so a Mega's wires named `arduino-mega` while its part
was `mega`. 'raspberry-pi-pico' was tested against 'nano-rp2040', an id nothing
has been issued since boards became instances, and the import mapped it back to
the same dead string. Nobody noticed because the module had no tests.
"Velxio has four boards" was written down five times: two tables in the export,
one in the import, a hand-copied union in the import dispatcher, and BoardType
in the store — which setBoardType already ignored, casting to BoardKind to do
its job. All five are gone, plus BOARD_FQBN and BOARD_LABELS, two more from the
same era that nothing imported.
A board may claim a Wokwi part type only when a Wokwi element draws it. That is
not a preference: connections carry PIN NAMES, and the names a wire carries are
whatever the element on the canvas calls its pins. Those four render through
wokwi-elements, so an Uno wire says `GND.2`, `A4`, `5V` and the file opens over
there. Every other board renders through a velxio-* element with our names —
ours calls a pin `34` where Wokwi's esp32-devkit-v1 calls it `D34` — so
claiming a Wokwi board for one would produce a file that loads in Wokwi and
attaches every wire to a pin that does not exist. They travel as
`board-velxio-<kind>`, which also round-trips the overlay's kinds, runtime
strings this module cannot enumerate.
Nothing is coerced any more. An unrecognised diagram returns a null board and
says so instead of answering 'arduino-uno'; a second board in the diagram is
reported rather than dropped; wires naming a part the diagram lacks are called
out, which is what the reporter's own file now does when you import it.
Two more halves surfaced only by driving the real editor:
The board was still missing after import, because setBoardType re-kinds the
ACTIVE board and a new project has none — nothing to re-kind, so the circuit
arrived without its chip exactly as reported. The importers now add a board
when there is none, activate it (addBoard promotes but does not sync the flat
legacy fields), and retarget the wires to the id the board actually got.
And the export read `boardType`, the flat mirror, which only tracks the active
board through setActiveBoardId/setBoardType and reports every Raspberry Pi as
an 'arduino-uno' on purpose. It now reads the board itself, so the kind and the
canvas id come off one object and cannot disagree.
Adversarial review of the fix caught six more, every one reproduced.
The worst was not a loss but a corruption, and it was mine: a canvas with two
boards wrote BOTH boards' wires against the exported board's part id, because
for every non-Arduino family that id IS the first-of-kind canvas id. The second
board's components came back attached to the first board's pins — a different
circuit, from a file that was internally consistent, so nothing complained. My
first attempt at a dangling-wire warning could not see it either: it allowed
the board KIND as a defined id, which is precisely the id a second board
writes. The check now reads the raw diagram, the export leaves other boards'
wires out rather than mis-addressing them, and it says what did not travel.
The rest: isKnownBoardKind used `in`, so 'toString' and '__proto__' passed a
guard whose input comes from files people send each other; the importer's
extension list admitted only the Arduino four, so a MicroPython project came
back with an empty editor; the exporter never wrote libraries.txt though the
importer has always read it and offered to install what it finds; and the
unknown-board message claimed the circuit landed on the current board without
retargeting its wires there.
41 tests, over every board kind, which is the assertion this module never had:
it was written when Velxio had four boards and nothing failed when it grew to
thirty. Verified in a real browser through the reporter's flow — build the
circuit, File > Export, new project, import — for arduino-uno (unchanged),
arduino-mega and raspberry-pi-pico (both previously broken) and esp32/esp32-s3.
Before and after are identical in all five.
Still lossy, and out of scope here: a .zip carries one board, so a multi-board
project must be saved as .vlx to survive whole. Wire waypoints, one shade of
green, and board-level state (boardOptions, SPIFFS, languageMode) do not travel
either.
ADC_PIN_MAP and ADC_PIN_TO_GPIO are keyed by the BoardKind union. An overlay
board's kind is a runtime string that can never join that union, so
connectAnalogInputsToMcu hit `if (!adcPins) continue;` at all three sites for
every pro board: a divider, an LDR, an op-amp output wired to one solved
correctly in SPICE and then had nowhere to go. Parts that inject their own value
(a potentiometer, through partUtils) were unaffected, which is exactly what made
this easy to miss — analog looked like it worked.
A pro board now declares its pads through ProBoardDef.adcPins, and pad->GPIO
resolution consults its own pinToNumber first. Both fall back to the existing
tables untouched, so no OSS board changes behaviour.
The pad NAME matters, not the GPIO: on the Pimoroni Pico Plus 2 W the analog
pads are silked 26/27/28 and tied to GP40/41/42 through 1k, so the two disagree
by design.
`WiFi.begin()` panicked the guest: `Guru Meditation Error: Core 0 panic'ed
(LoadStorePIFAddrError)`, EXCVADDR 0x60033c00. Reported against a self-hosted
dev setup, and it did not reproduce on velxio.dev — which routes every
supported ESP32 kind to the in-browser engines. The engines are not in this
repo, so anyone self-hosting from it always runs QEMU, which is where the crash
lives. It was verified away on a path the reporter cannot take.
0x60033c00 is the WiFi MAC at its APB alias, and the fork only instantiates
that device when a NIC is attached (`qemu_find_nic_info(TYPE_ESP32_WIFI)` in
hw/xtensa/esp32.c). The worker attached one only when `wifi_enabled` was true,
so a regex over the user's source decided whether the machine had a radio at
all. Reproduced against the real worker: same sketch, same firmware, only the
flag differs — true connects on 192.168.4.x, false panics exactly as reported.
Two things were wrong, and the second is why the first was fatal.
The scan was too narrow. Arduino mode read only the entry sketch while pure-IDF
mode read every file, so `#include <WiFi.h>` in a project header was judged
WiFi-less. Verified against the live backend: main sketch -> has_wifi=true, the
same include one file away -> false. Both branches read the whole project now.
The frontend also trusted a false over its own all-files scan; the two signals
are OR'd, because a stale or narrower false is the direction that hurts.
But no scan should have been load-bearing. A real ESP32 has a radio whether or
not the sketch uses it, so the emulated one does too: the NIC is attached
unconditionally on machines that model a MAC. A misread sketch now fails to
connect instead of faulting. Only those machines — the S3 machine models no
radio (hw/xtensa/esp32s3.c never looks for the NIC), so handing it one would
leave an unconsumed netdev. `wifi_enabled` still gates the host forward, which
exposes the guest's server and does belong to the sketch.
Measured before making it unconditional, on a sketch that never touches WiFi:
boot time, guest-clock-vs-real-time and container CPU unchanged over 4 paired
runs. The AP's beacon timer runs on QEMU_CLOCK_REALTIME — the reason -icount is
off for Xtensa, b0fe5b5d — but idle beacons cost nothing measurable. One host,
Linux x86-64, one sim at a time; small effects would not show.
With the radio always present, the MicroPython `network` stub loses its reason
to exist on this path: it was there because `WLAN(STA_IF)` hangs forever with no
NIC behind it. Keeping the old condition would put the stub in front of a
working device whenever the scan misread the sketch — #262, from the other
direction. It now depends only on whether the backend models a radio.
And when this class of fault does happen, the dump says "memory" and nothing
else; a week went into #260 for that reason. LoadStorePIFAddrError is always an
unmapped peripheral, so the worker now says so in the serial monitor, naming
the WiFi MAC when the address is in its page.
test_esp32_wifi_args re-implemented the arg logic in its own `_simulate_args`,
so it would have passed unchanged through this entire bug — the same trap
1800f572 called out for the detector. The decision is now `wifi_nic_arg()` and
the test calls it.
Verified end to end against the QEMU worker: the multi-file WiFi project
compiles with has_wifi=true and connects, and a WiFi-less sketch still boots and
runs with the radio attached.
The CYW43439 emulator is stateful — bus handshake, backplane window, SDPCM
sequence and credit, event mask, link state — and attachPioPeripheral is
idempotent, called once at board-add. So the chip's state SURVIVED every later
Run while the guest driver started over from scratch. The second Run had a
fresh driver talking to a chip mid-conversation: the handshake "passed" against
stale registers, IOCTL replies came back zeroed (WiFi.macAddress() read all
00s), and the first call that actually waited on the chip — WiFi.begin(), or
the second IOCTL of any kind — blocked forever.
A rebuilt MCU means a rebooted board, so the chip is power-cycled with it: the
peripheral is replaced wholesale (it owns its network bridge and its whole
register model, so there is no partial-reset path to get wrong) right before
the FIFO hooks are re-installed on the new PIO.
Also: overlay board simulators now get onSimulationStart. The Pimoroni Pico
Plus 2 W and Badger 2350 carry the same CYW43439 die on an RP2350 simulator,
which is not an RP2040Simulator, so they never had their network bridge opened
— WiFi that reached an IP and could then reach nothing.
Verified on the RP2350 side with a harness that boots a real arduino-pico
binary twice in one simulator (pro overlay); before the fix run 2 hung with a
zeroed MAC, after it both runs associate.
A MicroPython library in Velxio is a .py file copied onto the board before
main.py runs. It got there inlined into the program as ONE Python string
literal:
with open("mqtt_as.py",'w') as _f:
_f.write("...36 KB of escaped source on one line...")
so compiling the program asked for one CONTIGUOUS allocation the size of the
file. Issue #219 is that bill coming due on a PSRAM-less ESP32: 36407 bytes of
mqtt_as.py, `MemoryError: memory allocation failed, allocating 36410 bytes`.
Never the firmware version the thread suspected.
The v1.28 bump (bd1613a9) made that single allocation fit, so the reported
message is gone — but the mechanism was not, and what it costs today is worse
than the error was. Measured on the reporter's own project, on velxio.dev and
on staging: 216 s pinned in the raw REPL before the first line ran (the paste
went out at 64 bytes per 150 ms, ~426 B/s), and then `OSError: WiFi Out of
Memory`, because 36 KB of heap was still held by the compiled literal when
esp_wifi came up. The project could not work.
Nothing is inlined now. Each file is written by a sequence of small executions
(open / write(b'...') x N / close) — the shape mpremote's `fs cp` uses against
real hardware — so peak guest RAM is a few dozen bytes per step whatever the
library weighs. The program is written as a file too and started with execfile,
whose lexer streams off flash: a big sketch compiles in the same memory a small
one does, and it still runs in __main__ exactly as the pasted code did.
Flow control is the other half. One burst is in flight at a time, sized to fit
the 128-byte RX FIFO both backends model, and what releases the next is the
board's own end-of-execution marker — proof it consumed the last one, instead
of a delay chosen by hoping. A step can only overflow one burst on a
pathologically long file path, and those few bursts fall back to timed pacing
rather than corrupt the stream. The hundreds of protocol exchanges stay out of
the serial monitor; a progress line goes in their place, and a board-side
failure mid-upload is reported instead of starting a program whose library
never arrived.
One implementation, shared by the QEMU bridge and the in-browser engines. The
hand-ported second copy is why this bug lived in both paths, and the pro
overlay now re-exports this module instead of keeping its own.
Also fixes a latent corruption: sanitizeForRepl's inline-comment rule used to
run over that one-line literal, so a ` #` inside a library file made it strip
non-ASCII from everything after it. Library bytes are never tokenized now.
Verified on staging with the reporter's project (main.py + mqtt_as.py): upload
complete in 36.5 s, then "Got reliable connection / Connecting to broker /
publish 0". The WiFi OOM is gone with the heap it was competing for.
Not exercised live: the OSS QEMU MicroPython path — velxio.dev routes every
supported ESP32 kind to the in-browser engines, so staging cannot reach it. It
runs the same session and is covered by the unit tests, and its 800/200 ms
handshake timings are preserved rather than unified with the engines' 400/150.
The ESP32 and STM32 shims only offered `feedUart(uart, data: string)` and
`serialWrite(text)`, both of which run the string through TextEncoder. That
is UTF-8, so every byte >= 0x80 leaves as TWO bytes. Fine for the text
protocols the seam was written for (NMEA out of the GPS module, AT chatter);
silently fatal for anything framed.
A part answering a binary protocol has no way to spot it. A ten-byte reply of
AA 55 04 00 FF FD 01 FD 55 AA
reaches the guest as fifteen bytes,
C2 AA 55 04 00 C3 BF C3 BD 01 C3 BD 55 C2 AA
and no parser recovers from that: the sync word is gone, the length field
disagrees with the frame, the checksum covers bytes that were never sent. The
symptom is a device that answers and a sketch that times out anyway, with
nothing in the console — which is what makes it expensive to find. Protocols
whose replies happen to be ASCII pass straight through, so the seam looks
fine right up until a sync word is 0xAA.
Both shims now also expose `sendSerialBytes(bytes: number[], uart = 0)`,
forwarding to the bridge method of the same name without encoding. feedUart
keeps its text contract and its callers; its doc comment now says so.
The test pins the difference rather than the implementation: same frame down
both paths, asserting the text one mangles it and the raw one does not, plus
a round trip of all 256 byte values.
boardPinToNumber had two failure shapes across the ESP32 family, both silent.
A pad the mapper cannot name returns null, and a null pin means the wire
connects to nothing: the part sits on the canvas, the sketch drives a dead
pin, and the only trace is one console.warn from WirePin.
- DevKit V1 RX0/TX0. Unlike RX2/TX2, which have numeric twins at the same
coordinate ('16'/'17', be24243e), these have none, so they were the only
route to GPIO3 and GPIO1 and a wire to either did nothing.
- S3 DevKitC 40/41/42/45/46/47/48, rejected by a GPIO ceiling of 39 shared
with the classic ESP32. The ceiling is now per family.
- Nano ESP32 RX0/TX1/B0/B1. Its map named them D0/D1, pads the element does
not draw, leaving GPIO44 and GPIO43 unreachable.
- Reset pads (RST) on the S3, C3 and Nano, now -1 like the classic board's
EN rather than null.
The second shape is worse, because the pad does something wrong instead of
nothing: the numeric fallback is parseInt(pinName, 10), and parseInt('3V', 10)
is 3. The Wemos Lolin32 Lite's supply pad resolved to GPIO3, and the XIAO S3,
XIAO C3, Nano ESP32 and Aitewin C3 SuperMini resolved 3V3 to GPIO3 and 5V to
GPIO5 — a wire to the rail drove a real pin. Those branches had no power
guard at all; the shared POWER_PAD_RE replaces the ad-hoc startsWith checks.
Same reason the Wemos SPICE group now lists the '3V' pad it actually draws:
it declared '3V3', a name that board does not expose.
Every expected value is cross-checked against the `target` field of the
upstream wokwi-boards board.json, which is what surfaced this: the pad data
in PR #165 (naweiss) generates that field, and diffing it against our tables
turned up the whole class. Six of the ten ESP32-family boards now match
upstream exactly.
Left deliberately: the UART pad numbers (S3 TX/RX are GPIO43/44 and C3
TX/RX are GPIO21/20, not the classic 1/3) and the DevKitC V4 flash block
(D0-D3/CMD/CLK are GPIO6-11). Unlike the above, those pads resolve today, so
changing them moves an existing wire to a different pin.
The test walks every pad of every ESP32-family board and fails on either
shape. Pad lists are copied from Esp32Element.ts, as in
pin-inspector-layout.test.ts, because vitest runs in the node environment.
Co-authored-by: naweiss <naweiss@g.jct.ac.il>
An overlay board was invisible to the build-options model: its kind is not in
the OSS family sets, so isEsp32Family() said no, the Board Options menu never
appeared, and every compile went out with board_options: null - the
conservative family default of CONFIG_SPIRAM=n.
That is wrong for a module that always ships PSRAM. The ESP32-S3-EYE carries
8 MB of octal PSRAM and esp_camera allocates its frame buffer with
MALLOC_CAP_SPIRAM, so its camera examples compiled cleanly and then died at run
time with 'cam_dma_config(509): frame buffer malloc failed' - a hardware fact
surfacing as a mysterious driver error.
Boards now declare defaultBoardOptions, sent when the project saved none of its
own. ONLY the declared keys are sent: the backend backfills the rest from its
own defaults, which are deliberately not the modal's (huge_app vs min_spiffs),
so a board that gains one declared knob does not silently get repartitioned.
Boards that declare nothing keep sending null and build bit-for-bit as before.
Also route the family predicates through the registry, so overlay ESP32 boards
finally get the Tools menu they were missing.
David's kit is the ESP32-S3-EYE, not the ESP-EYE the project had been
built against - different product, and only this one has a screen. Card
art follows the real board: camera and function buttons on the main
board, the orange SUB silk band, then the 240x240 panel.
The 4-pin module's picker icon still drew the screen as a flat dark
rect, so the catalogue entry looked like a different part from the one
you get on the canvas. Same glass treatment as the element: bonded
panel with its bevel, inset active area, flex tail and sheen.
The generated components-metadata.json is synced by hand because the
generator skips without a third-party/wokwi-elements clone; the value
is copied verbatim from component-overrides.json, which stays the
source of truth, so a later regeneration reproduces it exactly.
Both SSD1306 modules are real parts and both belong in the catalogue -
the mismatch was in the docs, not the parts:
- the 8-pin Adafruit breakout was served a datasheet listing a 4-pin
I2C pinout (VCC/GND/SCL/SDA) that is not its header at all; it now
documents its own 9-way pinout with the I2C and SPI role of each pin;
- the 4-pin I2C module had NO datasheet, because docs resolve by
component id and ssd1306-i2c-4pin.md did not exist - so the part
showed nothing. It has one now.
Each points at the other so it is obvious which one you want.
The screen was a bare <rect fill='#1A1A1A'>, so the part read as a
cut-out in the board rather than one integrated piece - which is what
the module actually is: the glass panel is bonded ON TOP of the PCB and
overhangs the 128x64 active area on every side.
Now drawn as a real panel: glass body with a lit stand-off bevel, the
active area inset within it (the canvas still lands exactly on it), the
flex tail running down to the driver, and a faint specular sheen so it
reads as a screen even while dark. Body 108 -> 118 px for the overhang.
ssd1306-render tests 9/9.
The VoCat card was a rounded rectangle - the product is a round puck
with two cat-ear bumps, a mic in each and the green LED between them
(official product photo). The P4 card was maroon; the Function-EV PCB
is black.
Validated live against the ESP32 alarm-keypad project. Three fixes over
the initial implementation:
- Active-row model: the write-pin callback only fires on OUTPUT-latch
changes (after the first pass re-driving the row LOW is a no-change
write), and dir alone misses the first pass. A matrix scan drives one
row at a time with the latch LOW, so 'the row that most recently went
active (dir=OUTPUT, or latch write LOW)' IS the scanned row.
- Idempotent install: the canvas effect re-attaches on every boards[]
mutation (each serial line); reinstalling wiped the pressed-key set
mid-scan. Same wiring now keeps the existing state dict.
- Gap-free listeners: the keypad's button-press listeners were attached
on a 300ms setTimeout per effect run, opening a listener-less window
on every re-run in which a press was lost entirely. Attach
synchronously and drop the sendSensorDetach cleanup (detach+reattach
on unrelated re-renders is exactly the wipe the idempotent install
guards against).
A 4x4 membrane keypad scan is us-timing-critical: firmware drives one
row LOW and reads the columns back within the same loop pass, far
faster than the browser<->QEMU websocket round trip, so the browser-side
matrix simulation could never register a key on ESP32 boards.
Worker: new 'matrix-keypad' sensor type (rows/cols GPIO lists, pressed
key set). _on_dir_change/_on_pin_change recompute the column levels
synchronously on the QEMU thread — the pinMode(OUTPUT) transition is
the trigger on steady-state passes, since the row's output latch stays
LOW and the digitalWrite fires no change callback. Async gpio_in writes
to keypad-owned columns are ignored so the browser's generic matrix
logic can't clobber them.
Frontend: SimulatorCanvas attaches the keypad as a matrix-keypad sensor
when every R/C pin resolves to an ESP32-family board GPIO, and streams
the pressed-key set from the element's button-press/release events.
A micropython example that ships only 'code:' landed its Python in
sketch.ino (the mode switch was right, the name was not - seen on the
SensAirShuttle ws2812 example). main.py / main.c / sketch.ino by mode.
The interface requires both fields but the Docker build never
type-checks the pro overlay's example data (vite build only), so a
missing field shipped and loadExample's unguarded .filter/.map threw -
the page surfaced as a sticky 404 on a registered example (four
SensAirShuttle examples on staging). Load an armless example as having
none instead; authoring still declares the fields required.
The ESP32-C5 sensing devkit lands in the hosted editor (BME690 + BMM350
Bosch shuttles, 1.9" touch LCD, WiFi 6). Same contract as the other
Espressif cards: id matches the hosted BoardKind (esp-sensairshuttle,
in LAUNCH_ITEMS) so the ad vanishes where the real board registers and
stays suppressed during the embargo.
Two more Espressif boards land in the hosted editor; the OSS picker
advertises them like the ESP-EYE/LCDkit/C6 cards. Ids match the hosted
BoardKinds (esp-vocat, esp32-p4) so the ads vanish automatically where
the real boards register, and both ids are in LAUNCH_ITEMS so the
hosted build suppresses the ads during the embargo.
Also removes two accidental double commas that left holes in the ad
arrays.
The overlay's ESP32-P4 (and the coming C5) run ONLY on their in-browser
engines: neither chip has a QEMU machine anywhere. toQemuBoardType now
throws a clear error for those families instead of booting the wrong
machine and wedging on the first register.
Overlay boards with a camera now declare builtInCamera on their def (bool,
or { maxFrameBytes } for drivers with tight frame buffers) and the canvas
CameraToggle keys off it - the hardcoded esp32-cam / xiao-esp32s3-sense
kinds keep working unchanged.
The knob kit joins the overlay board program (Espressif partner batch);
OSS builds show the shop-window card until the pro overlay registers the
real kind.
The esp32/s3/c3 MicroPython bins were v1.20.0 (April 2023) — built by
micropython.org on IDF 4.4, three IDF generations behind the compile
pipeline and the engines' own validation corpus (both IDF 5.5), and two
behind the C6 bin (already v1.28). That drift is what broke I2C on the
S3: the engines decode the IDF 5.x driver sequences, and the v1.20 image
drove them the 4.4 way.
All four families now ship v1.28.0 (2026-04-06, IDF 5.x). Remote URLs
and cache keys move together, so a browser holding the old bin in
IndexedDB misses the new key and fetches v1.28 on its own. The new
images all declare 4 MB flash — padToFlashSize's floor exactly (the old
S3 bin declared 8 MB).
Validated cold on each engine (boot -> REPL, i2c.scan finds an attached
slave, WLAN scan/connect/DHCP; S3 additionally quad-PSRAM with a
multi-MB heap) and in the app (scan [60] on esp32/s3/c3, the ssd1306
gallery example drawing). Companion engine release: esp32s3js v1.16.0
(I2C interrupts on both cores, quad-PSRAM data plane). Pin.toggle() and
three years of upstream fixes come along; the v1.20 Pin.toggle note in
the editor seed is now history only.
Residual risk, flagged not fixed: the OSS QEMU self-host path (no JS
engines) now boots v1.28 under QEMU — unvalidated here, velxio.dev does
not exercise it.
The lux slider was linear 0-1000, but an LDR's response (and light
perception) is logarithmic: in the night-light example the LED's whole
on/off behaviour lived below ~20 lux — 2% of the slider's travel — and the
remaining 98% did nothing, reading as "the sensor barely works". The
physics were right (the divider model puts the example's 500-count
threshold at ~20 lux, sensible for a night light); the CONTROL was wrong.
The photoresistor and photodiode lux sliders now run in log position
space: value = min + 10^(p·log10(span+1)) - 1, anchored exactly on min
and max (the +1 shift gives the log axis a zero). Mid-travel lands in the
tens of lux, and the night-light toggles near the middle of the track
instead of at its first pixels. Same mapping on the component's own
on-canvas slider. The model, the examples and the reported lux values are
untouched — only where a given lux sits on the track changed.
Slider controls gain an opt-in `scale: 'log'`; everything else stays
linear.
A MicroPython library in Velxio IS a .py file in the project, copied onto
the board's filesystem before main.py runs. That mechanism worked, but
getting the file meant hunting GitHub by hand — the gap issue #214 is
about. Now the Library Manager fills it, in both halves:
Backend: /api/micropython-libs proxies the official micropython-lib
package index (the same one `mip` uses on real hardware). /search filters
the index; /fetch resolves a package plus its declared deps into .py
files. The proxy is what makes this safe to expose: only the official
index is reachable (the name must exist in index.json, files come from its
content-addressed store), source only (.py, never .mpy bytecode), path-
safe, and capped at 24 files / 256 KB per install. In-process TTL cache
over the index.
Frontend: the same Library Manager modal, mode-aware. On a MicroPython
board the search hits the index and "Add to project" writes the package's
files into the board's workspace (upsert — re-adding is the upgrade path),
visible in the explorer and travelling with the project. Before typing it
shows featured picks (ssd1306, umqtt.*, urequests) and the modules already
frozen into the firmware (neopixel, dht, onewire, ds18x20, machine,
network) labeled "built into the firmware", so searching for one answers
"just import it" instead of an empty list.
The file-materialization prelude now creates package directories: umqtt
ships as umqtt/simple.py, and MicroPython's open() does not mkdir, so the
prelude died with ENOENT on any packaged module.
Also ships a gallery example ("ESP32 + MicroPython: use an external
library") that documents the file-is-the-library mechanism with the
ssd1306 driver on the wired OLED.
"Flash to real board" no longer needs a prior Compile. The menu item is
enabled for every Arduino/ESP-IDF board (MicroPython keeps its own
gating) and the Flash dialog compiles first when the board has no program
or its code changed since the last build — compiler output streams into
the dialog's console, a red build ends in an error banner there with a
"Recompile & flash" retry, and only a green build reaches the flasher.
Web Serial's port picker needs a user gesture, so the web flow asks the
overlay to grant the port at the click (new WebFlashImpl.preparePort)
before the compile that may take minutes.
Stale detection: compileBoardProgram() now records a fingerprint of the
sources + language + build options (BoardInstance.compiledSourceHash,
utils/sourceFingerprint.ts); the dialog warns and rebuilds when the live
sources differ. utils/boardCompile.ts holds the compile request builder
shared with the toolbar (same files, same options).
The whole dialog and the menu hints are translated (editor.flash.* and
editor.canvas.flashHint.* in the 9 locales); before, every string was
hard-coded English.
Entitlement seam for #207 (desktop "upload to board"): lib/proWebFlash.ts
gains installHardwareFlashGate(); OSS default allows, and when a gate
blocks, the dialog shows a paid-license panel with a "See plans" link
instead of the port picker.