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.
One Semaphore(2) gated every compile, so while two ESP32 builds ran (cmake +
ninja, minutes on a cold cache) an Arduino Uno blink sat 'compiling' for as
long as they took. Two lanes now: heavy (ESP-IDF, still 2 — the VPS load
constraint is real) and light (arduino-cli boards, 3), plus a console line
when a job is queued so the wait is visible instead of looking like a hung
build. The per-target lock that keeps concurrent builds out of one ESP-IDF
build dir is unchanged.
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.
Two places where the editor bar spent width on nothing.
The file explorer put its label and its buttons on one line: WORKSPACE next
to new/open/save, and every board section's name next to rename / new file /
new folder plus the status dot. Between them those set a floor under the
panel's width, and the board name — the one thing a user reads there — got
whatever was left ("M5 Cardput..." in a 150px pane). Both headers now stack:
the label owns its row, the actions sit above (board) or below (workspace),
and the status dot rides in the actions row, where it costs the name
nothing. The actions row keeps its height reserved whether or not the
buttons are showing, so hovering a section never shoves the files below it
down. Default pane width 150 -> 124px; the freed px go to the code editor.
The toolbar's center slot is a flexible spacer that pins the right icons to
the far right, and nothing renders into it (no caller passes centerSlot). So
whenever the bar had width to spare — closing the docked AI chat is exactly
that — the spacer ate it: 191px of black between Reset and Libraries on a
1600px window. Empty, it no longer grows, so the editor controls stay packed
and the surplus collects at the one boundary that already carries a divider,
in front of the canvas controls (which belong over the canvas pane).
Now that SSR bodies are actually injected, two more prerender defects
showed: every t() rendered as its key (i18next was never initialised in
entry-server — the English bundle is imported now, plus the overlay's
"pro" namespace on pro builds, with an OSS stub), and every example
detail page rendered "404 Example not found" because ExampleDetailPage
was mounted without a Route so useParams() was empty. Verified locally:
/examples/blink-led SSRs breadcrumb, h1, description and JSON-LD; the
gallery and editor summary render translated copy.
replaceRootSeo() swaps the static #root-seo fallback for the page's SSR
body by string search. The old regex required a <script> right after the
block, but Vite hoists the module script into <head> in the built
index.html, so it never matched: every prerendered page (landings, /v3,
/about, all 171 example pages) shipped the HOMEPAGE's fallback body —
same <h1>, same nav — under its own <head>. Two hundred near-duplicate
bodies, and none of the landings' actual content (h1, sections, FAQ)
ever reached the crawler.
ExamplesGallery passes a server snapshot to useSyncExternalStore so
/examples SSRs instead of falling back to meta-only.
data/starters.ts maps eight free boards to the gallery example that opens
the editor with the board and a working blink (blink-led, nano-blink,
mega-blink, esp32-blink-led, esp32s3-blink-led, c3-blink, pico-blink,
attiny85-blink). ExampleEditorPage gives those ids an action-shaped
title/description ("New ESP32 project — free online ESP32 simulator")
while the gallery keeps the sketch title; the prerender writes
dist/starters.json so the prod app shell can serve the same head in the
raw HTML. This is the Wokwi "New ESP32 Project" shape: a URL of its own,
canonical of its own, that lands inside the product with the board loaded
— a candidate for brand sitelinks with usage intent.
Superseded release pages that were taking two brand sitelink slots (5.9k
impressions, 29 clicks). They still render for old links; hreflang is
only emitted for a page that is its own canonical (prerender + useSEO).
A fresh load of /editor is 301'd to /editor/ by nginx (prerendered dir),
while client-side navigation still lands on /editor. The three exact
pathname comparisons (header active state, header share button, starter
dialog gate) now ignore the trailing slash, and the header nav links to
/editor/ so crawled internal links match the canonical with no redirect
hop.
So the prod app shell can give a route that has metadata but (yet) no
prerendered file its own head, self canonical and hreflang instead of the
homepage's.
- useSEO: for the static SEO routes (marketing pages, examples gallery,
editor, home) the canonical is now the page's OWN locale variant and the
head lists every locale variant as rel=alternate hreflang plus x-default
on the locale-less English URL. /es/ and /zh-cn/ were declaring
canonical=/ while Google indexed and ranked them on their own — an
unstable contradiction. UGC pages (projects, profiles, example detail
pages) are the same English content under every prefix and keep a single
locale-less canonical with no hreflang.
- prerender-seo.mjs emits the same hreflang set into each prerendered page
and writes dist/seo-routes.json (path/title/description) so the prod app
shell can serve the /<locale>/<route> variants (which have no static file)
with the route's own head.
- /editor gets seoMeta and a static SSR summary component, so it is
prerendered: nginx now serves /editor/ as a real page and 301s /editor to
it. Both forms answered 200 with the homepage head before, and Google
indexed /editor and /editor/ as two pages.
- trackClickCTA sent the originating page as the event parameter `source`,
a reserved traffic-source name in GA4: every landing CTA click rewrote the
session's acquisition to landing / arduino-simulator / rpi-simulator with
medium (not set), which put 16% of sessions in the Unassigned channel.
Renamed to cta_source (register it as a custom dimension in GA4 if the
click_cta report should keep breaking down by page).
- robots.txt is one group with no blank lines (some parsers end a group at
a blank line, orphaning the Disallow rules); /login, /register and /admin
are no longer disallowed — they carry noindex, and a page must be
crawlable for that tag to take effect. /project/ and /api/ stay out.
- The static sitemap no longer stamps every URL with the build date as
<lastmod>; an all-identical, ever-changing lastmod is discarded as
unreliable, so omitting it is the honest option.
- The GA4 tag loads only on velxio.dev / www.velxio.dev / tauri.localhost:
self-hosted copies of the app (there are >180 hostnames reporting into
the property) no longer pollute velxio.dev's analytics.
The editor seeded a freshly placed board from its FAMILY: the Arduino
LED_BUILTIN blink, the Pico Pin(25) MicroPython blink, or the Raspberry
Pi RPi.GPIO script. None of those run on a board that has no such LED,
no RPi.GPIO and its own vendor library, so the first Run failed on a
board the user had not touched yet.
ProBoardDef gains defaultFiles (per language mode, plus 'python' for the
QEMU-Linux guests) and defaultLibraries; addBoard and setBoardLanguageMode
prefer the board's own seed over the family default, and the seeded
libraries land in the board's compile manifest.
Also fix the MicroPython family fallback: it recognised an ESP32 by
'esp32' appearing in the group id, so an overlay ESP32 whose kind carries
no such substring (m5stack-core, cardputer-adv) was seeded the Pico's
Pin(25) blink - a dead pin on both. It now consults the board's declared
esp32Family too.
Follow-up to 534b2ff. Dropping the strip to its own bar was now measured,
but two things still produced a wrapped bar on the XIAO example at 1440
with the chat docked:
- The first collapse tier (Serial / Camera / Mic / Scope / Add / Libraries
labels -> icons) was still a fixed 1010px container query, so on the
1060px own-bar the labels stayed and the bar wrapped in two. That tier is
now the unified-toolbar--compact class, set by the same measurement:
labels on the brand row if they fit, icons on the brand row if those fit,
else the own bar — labelled if the full bar holds it, icons otherwise.
The finer tiers (board pill 905, count/zoom 835, view toggle 760/1140)
stay as container queries.
- Each candidate layout is measured IN that layout: the zones carry their
own container queries (the view-mode toggle hides on a narrow row and
reappears on the wide bar), so a width read on the brand row said the
bar would fit when it would not.
With the AI chat docked at 1440px, a board with many canvas controls (XIAO:
display + camera + mic + scope) wrapped the strip INSIDE the header — the
canvas controls orphaned on a second line next to a black hole — because
the drop-to-own-bar fallback fired on media-query thresholds (1040 / 1060 /
1400px by chat state) that were only right for one board's strip width.
AppHeader now measures: natural single-row width of the strip (each zone at
its content width) against the room left next to the brand + menus, and
sets app-header--strip-below when it does not fit. Always measured in the
inline layout (class removed, measured, restored before paint) so the
below-state's margins and uncollapsed labels never bias the decision, and
there is nothing to oscillate. Re-measured via ResizeObserver on the strip
host, the brand block and the zones (plus a MutationObserver for the
portaled canvas controls), deferred a frame so a toggle never re-enters
delivery. The three media-query blocks collapse into one class rule; a
1000px window without the chat now keeps one row when the collapsed strip
fits, instead of being forced to two.
The explorer rows carried a 26px indent plus 22px file icons, 5px vertical
padding and 12px names in a 165px pane: a lot of margin for a sidebar
whose only job is listing a handful of files, and every px of it was taken
from the code editor. Now: root files indent 16px (12px per nested level),
file icons render at 16px like the folder icon, rows are 3px/6px with an
11px name, the board header actions are 18px, and the header buttons 20px
with a title that truncates before the actions do. The pane defaults to
150px (min 100) and the account footer keeps the first name visible at that
width.
Boards were placed 420 world-px right of the ACTIVE board plus 60/30 px per
board already on the canvas: a fixed world offset that ignored pan/zoom (the
new board could land off-screen) and kept marching further out with every
board added, even after the earlier ones had been moved away or removed.
Components had already been fixed to start at the visible top-left and take
the first free slot (utils/dropSlot); boards now go through the same rule,
and both count each other as parked items so they cascade over the same
slots.
The header rendered a "Saved" / "Unsaved changes" text pill next to the
menus whenever a project was loaded. It ate width in the one row that has
to hold the whole editor toolbar, and it duplicated a signal the Save
button in the workspace panel can carry by colour alone.
The pill is gone. FileExplorer takes the AutoSaveState and colours its Save
button: green saved, orange unsaved changes, pulsing while saving, red on
failure; grey (unchanged) with no project loaded. The tooltip carries what
the pill used to say plus the last-save time. Nine locales get the four new
strings under editor.fileExplorer with English fallbacks.
AppHeader no longer takes autoSave; EditorPage passes it to FileExplorer
instead. No pro overlay code referenced the pill or the prop.
captureDisplay.test.ts runs under @vitest-environment jsdom and says in its own
header that "jsdom + the `canvas` package give a real 2D context here" — but
canvas was never a direct devDependency. jsdom lists it as optional, so npm
skipped it, getContext('2d') returned null, and five tests died on
`ctx.fillStyle` with a TypeError. That aborted the pre-deploy test gate.
Declaring it directly makes the install deterministic; the lockfile already
carried the entry. All six tests pass with it present.
On the landing's dark board tiles they rendered as a white slab around the
board. Keyed the near-white out to transparent and cropped to the board:
Pi 4 goes 1024x1024 -> 922x599. Pi 5 kept its framing (it had no margin) and
just gained the alpha.
The supported-hardware section listed 19 boards across 5 architectures. It now
covers STM32 (Cortex-M3/M4), Raspberry Pi 4 and 5, and the partner boards from
Pimoroni, M5Stack, Seeed Studio and DFRobot — 35 in total across 6. Updated in
all nine locales so no language advertises the old count.
arduino-cli reports errors as plain text or, with --format json, as a JSON
object on stderr. We forwarded that blob verbatim, so on a fresh install
with no library index yet (and no way through the firewall/proxy to
downloads.arduino.cc) the in-app agent told the user "the library server
returns a JSON error" ten times in a row.
humanize_cli_error() maps the known shapes — index download failed,
library zip download failed, library not found — to a single sentence
that says what to check, and keeps the raw text under raw_error. Applied
to search_libraries (rc != 0 and non-JSON stdout) and install_library.
Tests use the real arduino-cli 1.0.4 output captured offline.
Two things went wrong when the window was small and the AI chat docked:
1. Libraries / Output painted over the board selector. The editor zone
has min-width: max-content, but its child .editor-toolbar-wrapper is
an inline-size query container, and size containment makes it
contribute zero intrinsic width — so the floor resolved to nothing,
flexbox squeezed the zone under its buttons and the bar never got to
wrap. Containment is now off for the in-bar mount (the wrapper keeps
it for its standalone mobile mount); the two label collapses that
depended on it are re-keyed to the unified-toolbar container at the
same thresholds the canvas controls use (1010 / 905px), so the row
degrades in step and, when it truly cannot fit, wraps instead.
2. The explorer / Code / Both / Circuit segmented toggle now hides once
the shared bar is under 760px (its inline display moved to CSS so a
container query can do that). Every switch it had is a checkmarked
row in the View menu: File Explorer, and Code / Both / Circuit as a
radio group under a View mode label. explorerOpen moves from
EditorPage state into useEditorStore so the menu can read it.
The previous GIF was a tight crop of the ILI9341 panel and rendered huge
and cut off at the top of Screenshots. This one is the landing's
estacion-meteorologica GIF cropped to the circuit (ESP32 + ILI9341 +
BMP280 + DHT22) and displayed at 520px.
- Screenshots and board art come from the velxio_docs portal (fresh
captures of the current UI); the six 2025-era img1..6.png are gone.
- Supported Boards now covers all seven families (Arduino, Pico, ESP32,
ESP32-S3/C3, STM32, Raspberry Pi Linux, branded boards) and says
where each one runs: self-hosted, velxio.dev, or paid plan.
- Trim the deep-dive sections that the docs portal now owns: the ESP32
/ RISC-V / Pi 3 emulation bullets, Serial Monitor, Component System,
Wire System, Library Manager, Portable Persistence, Project Structure,
Technologies, the doc table and Troubleshooting. Documentation is a
single pointer to velxio.dev/docs and github.com/velxio/velxio_docs.
- Hardware Partners gains a line naming the hardware each partner
contributed to the catalog.
Every Pico W WiFi example in the gallery hangs at `while not
wlan.isconnected()`, with `wlan.status()` stuck at 1 (CONNECTING) and
`wlan.active()` reading True.
The CYW43439 sits on GP23/24/25/29, inside the module — there is no canvas net
for those pins. attachPioPeripheral() drives WL_HOST_WAKE (GPIO24) with
setInputValue() straight from the chip's hostWakeLevel(). When RP2040 gained
spice-driven digital inputs, the GPIO listener began seeding a pull level onto
every input pin, GPIO24 included: the CYW43 driver configures that pad as an
input, the seed landed on top of host-wake, the host stopped polling the chip,
and no association event was ever delivered.
The fault is invisible from the CYW43 side — its own harness drives a raw
rp2040js with its own FIFO/PIO plumbing and passes — which is why this looked
like a WiFi bug rather than a GPIO one.
Diagnosed by patching the sketch in the editor to print wlan.status() each
iteration: "WAIT 1 status 1 active True" repeating forever, i.e. the join was
accepted and the link never came up.
cardputer-adv-m5-button, m5stack-core-hello and c6-oled-i2c were the first
slug of their capture run and got the day's announcement overlay in the shot.
The capture script (velxio-prod) now pre-marks the day's post as shown and
strips any announcement overlay before the screenshot.
ESP-IDF compiles with -ggdb, and for debug builds ccache folds the cwd into
every cache key by default — CCACHE_BASEDIR does not exempt it (verified with
CCACHE_DEBUG on 4.11: 'Hashing CWD /var/lib/velxio-build/esp32s3/v_<hash>/
project/build'). Each build variant lives in its own dir, so every variant had
a private key space: eight esp32 variants of the SAME board each recompiled
all ~2100 IDF objects from scratch, the cache filled with dead per-directory
entries, and a cold Cardputer/S3 build (2263 steps) overran the 600s ninja
timeout on a busy box. CCACHE_NOHASHDIR=1 makes variants of the same
board/sdkconfig share objects; a cold variant becomes mostly cache hits. Only
DW_AT_comp_dir in .debug_info of a reused object can differ, which never
reaches the flashed .bin.
The timeout paths were silent server-side; they now log the build dir and how
many ninja steps completed, and the user-facing message says the partial
build is kept and a retry resumes.
Measured against the user corpus, the by-resolved-type long tail was
losing what the old catch-all happened to include: for `Adafruit_SH1106G
display(...); display.setTextSize(` the class itself declares one method
(begin) and everything people call lives four levels up (Adafruit_SH110X ->
Adafruit_GrayOLED -> Adafruit_GFX -> Print). Same story for MCUFRIEND_kbv,
Adafruit_NeoMatrix and every GFX-derived display driver.
Three changes:
- The scanner records `bases` on class symbols (access/virtual keywords
dropped, `ns::Base` -> Base, template args cut). 2,529 classes across the
production cache now carry their bases.
- /type/{name} walks the chain breadth-first through the type index (bases
routinely live in a different library), re-owns inherited members to the
requested type so the editor sees one flat class, tags them with
`inheritedFrom`, and lists the chain in `bases` -- including bases NOT in
the cache (Print, Stream, or an embedded one like Adafruit_GFX) so the
host can supply those itself. Depth capped at 8. Adafruit_SH1106G goes
from 1 member to 64.
- `void a(int), b(), c(char);` declares three methods; the extractor took
only the first. TFT_eSPI declares setTextColor/setTextSize and
setCursor/getCursorX/... this way. Split on top-level commas after a
complete `name(args)` group. TFT_eSPI 118 -> 173 members.
Rescan of the 1,125-entry production cache: +274 member symbols, 15
libraries gain, none lose.
The completion-safe long-tail path. Feeding whole library symbol sets into
the editor's candidate pool was measured to make ranking worse (U8g2 alone
injects ~250 module-level class names; ArduinoJson dozens of ARDUINOJSON_*
macros). What does help is answering `client.` once tree-sitter has resolved
`client` to a declared type the embedded catalog does not know -- and for
that the engine needs "the members of type T", not "the symbols of library
L". This endpoint returns exactly that: symbols whose owner is the requested
class, from whichever cached library declares it, in the same payload shape
as /symbols/ so the engine's RemoteSymbolCache stores it unchanged.
Resolution: a class-name -> cache-entry index built once over the library
cache (each per-entry scan is already disk-cached), persisted under
.symbols/_types.json (3,818 classes / 199 KB over the 1,125-entry
production cache; ~1 s to rebuild from cached scans, ~60 s cold), refreshed
after a few hours or when the cache grows. Many classes are declared by more
than one library -- the real one plus forks and bundles vendoring a copy
(PubSubClient inside "ESP8266 Microgear", NTPClient inside "ESP8266 Weather
Station", AccelStepper inside "Arduino Learning Board") -- so the pick is:
the library named after the class, else the one declaring the most members
of it (vendored copies are trimmed), else alphabetical.
Generic, not pro-specific: any host with a library cache can use it.
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.
_scan_text applied `public:` / `private:` / `protected:` only inside
handle_statement, which the `{` branch skipped whenever the statement opened
a nested enum, struct or class. So `public:\n typedef enum { ... }` -- the
first thing in AccelStepper's class body, and a common idiom -- took the enum
branch, the label was never seen, the class stayed in its default private
access, and every method after the enum was dropped. AccelStepper shipped 4
of its public methods; DallasTemperature, SoftWire, MFRC522_I2C, RTCZero
shipped none.
Labels are now consumed before the branch. Rescanned all 1,125 entries of
the production library cache: 82 libraries gain members (+1,532 total,
DallasTemperature 0 -> 57), and the 20 that "lose" one or two were leaking
private/protected members past the old check (PMS::loop is under private:,
hd44780::waitReady under protected:) -- precision, not regression.
It printed "Bluetooth Control Ready" and then did nothing, no matter what
you sent — David typed `on` and `off` into the Serial Monitor and neither
reached it.
It reads UART(0), and on this port UART0 IS the REPL's channel: the code
runs through the raw REPL, so bytes typed into the monitor are consumed
there and uart.read() never sees them. `UART(0, 9600)` also retunes the
very port the monitor is talking to at 115200.
Upstream drives this from a phone through an HC-05, where "Bluetooth" is
only the transport and the ESP32 just sees serial. Velxio has no HC-05 on
the canvas, so the Serial Monitor is that link — read sys.stdin through a
select.poll (100 ms timeout, so the loop yields instead of spinning).
Verified in the app: `on` -> "Received: on / LED ON" with the LED lit,
`off` -> "Received: off / LED OFF" with it dark.
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.
`_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.
A pro test lives at ../../pro/frontend/src/pro/..., outside this package,
so Node resolution walks up from THERE and never reaches this project's
node_modules — importing 'esp32js' dies with "Cannot find package". The
config already carries the other wiring overlay tests need (the include
glob, the fs.allow, @pro); this is the missing piece for one that drives
a real engine.
Guarded by existsSync per package: a pure-OSS clone has none of them
installed and no test imports them (the pro glob matches nothing there),
so each missing entry is simply left unaliased instead of pointing at a
path that doesn't exist.
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.
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.
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).