Investigation into adding ESP32-P4 to Velxio via either of the two existing
emulation paths (frontend JS/WASM or backend QEMU/WebSocket). Verified the
arduino-cli toolchain works (RISC-V 32-bit ELF, RVC, single-float ABI), but
both emulation paths are blocked upstream:
- espressif/qemu has no esp32p4 machine yet (issue #127, status: To Do).
- No open-source JS/WASM ESP32 emulator exists; Wokwi's engine is closed.
Includes a smoke-test script ready for the day the Espressif QEMU machine
lands, plus a Phase A/B/C plan in autosearch/06_recommendations.md.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
crypto.randomUUID() is only exposed on secure contexts (HTTPS, localhost,
127.0.0.1, ::1). When Velxio is self-hosted and accessed via a LAN IP over
plain HTTP (e.g. http://192.168.31.139:3080/), crypto.randomUUID is
undefined and any code path that calls it throws TypeError.
This silently broke ESP32 simulation start for self-hosters: the frontend
generates a UUID for the WS client_id when Run is clicked; the throw
rejected the promise before reaching the WS connect, so the backend
never got the start request — no worker spawned, logs empty, simulation
"didn't start" with no visible error.
Same root cause would also break the multi-file editor (createFile,
createFileGroup) on the same LAN-HTTP self-host setup, just less
observably.
Add a single generateUUID() helper that:
1. Uses crypto.randomUUID() when available (secure context fast path).
2. Falls back to crypto.getRandomValues() — which IS available in
non-secure contexts — to build a v4 UUID by hand.
3. Final fallback to Math.random() if even that is missing
(defensive — Web Crypto getRandomValues has been universal for
years).
Replace all 6 crypto.randomUUID() call sites:
- frontend/src/simulation/Esp32Bridge.ts (2 sites — getTabSessionId)
- frontend/src/store/useEditorStore.ts (4 sites — file IDs)
Reported by a self-hoster on OrangePi 5B accessing Velxio via LAN IP.
DevTools console showed:
TypeError: crypto.randomUUID is not a function
at Ph (...) at wh.connect (...) at startBoard (...)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The previous hero ("Circuits + Code. / One Browser Tab. / SPICE-accurate.")
was optimised for EE engineers searching for circuit simulators. Most
visitors land here looking for an Arduino emulator they can use without
installing anything — the names of the supported boards are a stronger
hook than analog-simulation accuracy.
Restored the older "Arduino, ESP32 & Raspberry Pi. / Right in your
browser." framing and tightened the subtitle to action verbs (Write,
wire, run) plus concrete numbers (19 boards, 48+ parts). Drops:
- "SPICE-accurate" — kept on the dedicated /arduino-emulator,
/circuit-simulator etc. SEO landing pages where the audience is
actively looking for it
- "ngspice", "co-simulated", "custom chips in C or Rust" — niche, fit
better in the features section below
1. Backend test (test_arduino_cli_attinycore.py): the entrypoint script
was renamed deploy/ → docker/ in commit b736aea but this test still
pointed at the old path. Update the read_text() call + docstring.
2. Frontend CI (frontend-tests.yml): the cache key
`frontend-${{ hashFiles('frontend/package-lock.json') }}` was tied to
a file that has since been gitignored (commit eb9a3ec). hashFiles()
on a missing file returns the same empty hash forever, so every CI
run was restoring the same stale node_modules — including the
symlinks to `file:../third-party/wokwi-elements` that existed before
the npm migration in commit 531c337. On revalidation, npm tried to
run wokwi-elements' `prepare` script (`husky install && npm run
build`), which failed with "husky: not found".
Drop the cache step entirely; lock files aren't committed so cache
keys can't be made meaningful without overcomplication. Adds ~30s
per CI run, but actually correct. Also pass --no-audit --no-fund
to npm install for cleaner logs.
Forgotten in the prior commit (case-mismatch on Windows tracked the wrong
filename). Adds the public method overlays use to splice extra components
into the picker after default-metadata load. Components with an existing
id are replaced; new ones are appended.
Three small additions so private overlays can add components gated behind
a paid subscription without forking the picker:
- types/component-metadata.ts: optional pro_only?: boolean field on
ComponentMetadata. Self-hosters never set it; picker behaves identically.
- services/componentRegistry.ts: new mergeComponents() public method.
Pro overlay calls this after the default registry has loaded to splice
in extra components (replacing any with the same id).
- components/ComponentPickerModal.tsx: when a pro_only component is
clicked, the picker first calls window.__velxio_pro_gate__(component)
if defined. If the gate returns true, the click is consumed (overlay
shows an upgrade modal). If absent or returns false, the click passes
through to onSelectComponent as normal.
Net upstream change: ~25 lines, all backwards-compatible. OSS image
behaves exactly as before since no overlay sets pro_only or installs
the gate.
Two upstream additions to support private overlays implementing paid tiers
without forking client code:
- store/useAuthStore.ts: UserResponse extended with optional
is_paid_subscriber, subscription_status, subscription_period_end. The
backend now returns these in /api/auth/me; the persist middleware
serialises them automatically.
- pages/PricingPlaceholder.tsx (NEW): the /pricing route. Renders a polite
"this image is fully free" message for self-hosters plus a
data-velxio-slot="pricing-page" target where private overlays can
portal-inject a real pricing page.
- App.tsx: register the /pricing route after /about.
Self-hosted OSS image: /pricing shows the placeholder, no behavioural
change anywhere else. Production with a private overlay: /pricing shows
the overlay's full pricing UI.
Frontend build verified.
Adds 4 nullable columns to the users table so deployments that wire an
external billing system (e.g. velxio.dev with Odoo) have a place to cache
subscription status. Self-hosters never write these — defaults are safe
(no paid features unlock).
- models/user.py: is_paid_subscriber (bool, default false), subscription_status
(str|None), subscription_period_end (datetime|None), odoo_partner_id
(int|None, indexed).
- main.py: 4 ALTER TABLE statements appended to the legacy_migrations list
so existing deployments auto-migrate on next boot.
- schemas/auth.py: extend UserResponse with the 3 user-visible fields
(is_paid_subscriber, subscription_status, subscription_period_end). The
frontend useAuthStore already persists the whole UserResponse, so these
surface automatically without any frontend changes upstream.
odoo_partner_id stays internal — clients don't need it.
Zero behavioural change for existing OSS deployments.
Three small markers (each one HTML attribute) so private overlays can
portal-inject UI into well-defined places without forking the upstream
component:
- AppHeader user dropdown: data-velxio-slot="user-menu"
Lets overlays add menu items between "My projects" and "Sign out"
(e.g. a Privacy / opt-out item).
- AdminPage tab bar: data-velxio-slot="admin-tabs"
Lets overlays add extra tabs alongside Dashboard / Users / Projects /
Boards (e.g. a Pro Analytics tab).
- AdminPage tab content area: data-velxio-slot="admin-tab-content"
Sibling div where overlay tab content can portal-render.
Generic markers, no overlay-specific code in upstream. Anyone with
private extensions can use them. The OSS build is otherwise unchanged.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two upstream fixes for compiling libqemu-xtensa.so / .dylib from source:
- grep regex no longer requires space before softmmu_main
- parse link cmd from verbose stdout (no .rsp file on macOS)
Runtime behaviour unchanged — these only affect anyone who rebuilds
QEMU from source. Prebuilt .so/.dylib in the qemu-prebuilt release
are unaffected.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Three small, backwards-compatible hooks let anyone with private features
(velxio.dev's analytics, custom integrations, paid tiers, …) layer them
on top of the open-source build without forking files.
Backend (app/main.py):
- After standard router registration, try-import an optional `app.pro`
module exposing `register_pro(app)`. ImportError is silently swallowed
(the OSS image doesn't ship `app.pro`, so this is a no-op there).
Frontend:
- EditorToolbar: new optional `rightSlot` prop renders extra elements
after the built-in right-group buttons (mirrors the existing
`centerSlot` pattern).
- main.tsx: dynamic `import('@pro/index')` gated by VITE_PRO_BUILD env.
When unset (OSS build), the branch is dead-code-eliminated and no pro
chunk is emitted.
- vite.config.ts: `@pro` alias resolves to `src/__pro_stub__/` by default.
Private builds set `VITE_PRO_BUILD=true` and `PRO_OVERLAY_PATH=<path>`
to point at their real overlay tree.
- src/__pro_stub__/index.ts: 1-line no-op `mountPro` so TypeScript and
Vite resolvers stay happy in OSS builds.
Verified: `npm run build:docker` succeeds; `npm test` passes 1161/1162;
the OSS bundle (43 MB) contains zero references to `__pro_stub__`,
`@pro`, or `pro/index` (verified via `grep dist/`).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The _should_include filter inside _merge_arduino_libs_to_component
rejected every subdirectory of a library except 'utility/' when the
library lacked a src/ layout. That blocked legitimate header dirs like
Adafruit_GFX_Library/Fonts/, breaking compiles that use any GxEPD2
example with a custom font (#include <Fonts/FreeMonoBold12pt7b.h>).
The earlier filter at the top of the function already excludes
docs/examples/tests/etc. via excluded_dirs, so anything that survives
that check is presumed to be buildable source. Letting all remaining
subdirs through restores Fonts/, gfxfont/, and similar conventional
auxiliary header directories that Adafruit-style libs rely on.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Cold ESP-IDF builds (esp32, esp32-c3, esp32-cam) routinely take 5-10
minutes the first time a project is compiled. The 180s axios timeout
on POST /api/compile/ was cutting the connection long before the
backend finished, surfacing as the misleading 'No response from
server. Is the backend running on port 8001?' error.
Bumping the client timeout to 600s aligns with the nginx
proxy_read_timeout (also 600s) so the chain end-to-end is consistent.
Arduino sketches still compile in seconds — the timeout is an upper
bound, not a delay.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Cold ESP-IDF compiles (esp32, esp32-cam, etc.) can legitimately take
5–10 minutes — first time the project is built, ninja has to compile
the entire IDF + Arduino-ESP32 component graph from scratch. Nginx was
cutting the connection at 5 min, which surfaced as 'No response from
server' in the frontend even though the build was still progressing.
Worse, since the backend doesn't cancel the compile on client
disconnect, repeated user clicks pile up parallel ninja jobs that
saturate CPU and slow every concurrent build further.
10 min covers cold builds with comfortable margin. Long-term we should
make compile a job (POST → job_id, GET status) and cancel duplicates
server-side, but the timeout bump unblocks users today.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The 4 ESP32 ePaper examples (BW weather, BWR alert, UC8179 dashboard,
ACeP rainbow) wired GxEPD2 to GPIO 16 (RST) and GPIO 17 (DC), which is
the canonical pinout shown in every GxEPD2 example. But those pins are
not broken out on the DevKit V1 variant (PINS_ESP32) — they only exist
on DevKit-C-V4 (PINS_ESP32_DEVKIT_C_V4).
Result: the RST and DC wires fell back to (0,0) and rendered as a red
+ purple line shooting from the corner of the board. CLAUDE.md §6a
documents this exact symptom.
Switching boardType to 'esp32-devkit-c-v4' renders the variant whose
pinInfo includes 16 and 17. Also rename pinName 'GND' → 'GND.1' since
DevKit-C-V4 exposes three GND pins as GND.1/2/3 (not a plain 'GND').
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
A lock file pins platform-specific native binaries — Rollup, esbuild, swc.
A lock generated on Windows brings @rollup/rollup-win32-x64-msvc but no
Linux variant; a lock generated on Linux does the inverse. The Docker
build kept blowing up with MODULE_NOT_FOUND on rollup/dist/native.js
whenever the lock came from a contributor's non-Linux machine.
Trade-off: we lose npm's transitive-version pinning. Mitigated by:
- package.json caret ranges keep majors stable
- Docker image is rebuilt + retagged per release, so a deployed image
has a frozen dep set regardless of the lock
- Production uses a pinned upstream commit via velxio-prod's submodule,
not lock-driven repro
- Dependabot still flags vulnerable transitives via package.json scans
Changes:
- .gitignore: ignore package-lock.json everywhere
- .dockerignore: same (defense-in-depth — never enter build context)
- Dockerfile.standalone: keep `rm -f package-lock.json` as a safety net
for `docker build` runs from trees with a local lock
- frontend-tests.yml: `npm ci` → `npm install` (npm ci requires a lock)
- Delete the two committed locks (frontend/ + root). The test/* and
vscode-extension/* locks are left as-is — internal tooling, separate
install paths, not in the Docker build.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Lock files generated on Windows/macOS pin platform-specific Rollup native
binaries (e.g. @rollup/rollup-win32-x64-msvc) and won't bring in the Linux
ones the Docker image needs. Symptom: `MODULE_NOT_FOUND` on
`rollup/dist/native.js` during `npm run build:docker`. See npm/cli#4828.
Removing the lock inside the build forces a fresh, Linux-native dep
resolution. Side effect: a tiny loss of cross-build version pinning, which
is acceptable here — `npm install` honours the version ranges in
package.json so semver-compatible patches at most slip in.
The proper long-term fix is to regenerate package-lock.json on Linux and
commit that. Until then this rm guards every Docker build.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The transitive-include scan in _merge_arduino_libs_to_component used
glob('*.h') on the component dir, which only finds headers at the root.
Libraries with a src/ layout (GxEPD2, ArduinoJson, most modern Arduino
libs) keep their headers under src/<...>, so the scan saw zero headers
and never queued their transitive deps.
Symptom: compiling a sketch that includes GxEPD2_3C.h failed with
'Adafruit_GFX.h: No such file or directory' even though Adafruit_GFX
was installed via the Library Manager — because the BFS never reached
its header from inside GxEPD2_GFX.h.
Switching to rglob('*.h') walks the full directory tree and lets the
BFS pick up Adafruit_GFX, Adafruit_BusIO, and any other transitive
dependency that lives under src/.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Add a comparison table up top so users pick the right path quickly.
- Option A (Docker): include the arduino-libs named volume so cores
don't reinstall on every container restart (was a 5-10 min hidden cost).
- Option B (Compose): note expected first-build time (~10-15 min for
ESP-IDF + frontend) so users don't think it's stuck.
- Option C (Manual): drop --recurse-submodules (npm pulls the wokwi libs),
add ATTinyCore install, flag that ESP32 emulation needs Docker (or the
separate ESP-IDF setup) since QEMU .so files only ship in the image.
- Update Project Structure: third-party/ is reference-only, deploy/ is now
docker/.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The folder only holds the in-container nginx + entrypoint that the
standalone Dockerfile copies. Calling it "deploy" implied host-level
production glue, which now lives in github.com/velxio/velxio-prod.
"docker/" makes the build-time vs deploy-time split obvious.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Resolves several install pain points reported by users (#108, #120) and
removes the obligatory upstream-clone step that confused contributors and
slowed down every Docker build.
Install fixes:
- nginx: server_name → catch-all default_server, drop Debian's stock site
so reverse-proxied users no longer get the "Welcome to nginx" page.
- entrypoint: auto-generate SECRET_KEY at first boot, persisted under
data/.secret_key. backend/.env is now optional in docker-compose.yml.
- backend: add greenlet>=3.0.0 (SQLAlchemy async dep that was missing on
some Python builds — caused uvicorn startup failures on WSL).
Wokwi libs come from npm:
- @wokwi/elements 1.9.2, avr8js 0.21.0, rp2040js 1.3.2 are pinned in
frontend/package.json. Vite aliases removed.
- Dockerfile.standalone no longer clones avr8js / rp2040js / wokwi-elements
/ wokwi-boards. Frontend stage is just COPY + npm install + build:docker.
- Board SVGs vendored under frontend/public/boards/ (10 deduped against
existing files, 2 truly new). third-party/wokwi-* clones become reference-
only credits — generate-component-metadata.ts skips gracefully when absent.
Production config split out:
- docker-compose.prod.yml, deploy/nginx.prod.conf, nginx-host-velxio*.conf,
update-third-party.bat removed. Production deployment lives in its own
repo: https://github.com/velxio/velxio-prod (host nginx + HTTPS + backups
+ pinned upstream commit).
Verified locally: 1161 frontend tests pass, build:docker completes clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Deleted diagram.json, esp32-cam-lcd-preview.ino, esp32-cam-lcd-status.ino, and libraries.txt from the esp32-cam-lcd-preview example directory.
- Updated submodule reference for qemu-lcgamboa.
- Removed generate_examples.py script used for generating example stubs.
Without an ownership check, viewing someone else's project (admin
inspection, browsing public projects) caused the auto-save hook to
PUT the project on every store change. The backend correctly rejects
non-owner updates with 403, but the frontend surfaced these as
"save fail" to the user — misleading and noisy in logs.
The hook now stays idle unless the authenticated user matches
currentProject.ownerUsername. Manual saves through SaveProjectModal
are unaffected.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The directory grew well beyond Wokwi-only contents: it now hosts
lcgamboa's QEMU fork (qemu-lcgamboa), Espressif's esp32-camera, the
ngspice WASM build, fritzing-parts, picowi, an alternative QEMU
(qemu-esp32), the 100_Days_100_IoT_Projects examples repo, and
Wokwi's own avr8js/rp2040js/wokwi-elements/wokwi-features/wokwi-boards.
"wokwi-libs" was misleading — half the contents have nothing to do
with Wokwi. "third-party/" is the standard convention for vendored
external dependencies.
Mechanical changes:
Path rename:
wokwi-libs/ → third-party/
update-wokwi-libs.bat → update-third-party.bat
docs/WOKWI_LIBS.md → docs/THIRD_PARTY.md
Submodule reconfiguration:
.gitmodules — 4 path= and section names updated
.git/modules/wokwi-libs/ → .git/modules/third-party/
each submodule's .git file rewired to ../../.git/modules/third-party/<name>
Reference updates (~80 files): vite.config.ts aliases, Dockerfile
COPY paths, GH Actions workflow steps, build_qemu_*.sh, all
docs/* and test/*/autosearch/* entries that mention the path,
package-lock.json file: dependencies, .gitignore patterns,
sitemap.xml + index.html SEO blurbs, scripts/generate-component-*,
.dockerignore, .idea/vcs.xml. Bulk replaced both `wokwi-libs/`
(path) and bare `wokwi-libs` (textual mentions in docs/comments).
Verified:
- npx tsc -b --noEmit produces no new errors related to these paths
- vite.config.ts aliases now point at ../third-party/avr8js etc.
- All 4 git submodules (avr8js, rp2040js, wokwi-elements,
wokwi-features) are linked under third-party/ with their
worktrees re-populated and config files referencing the new path
- `grep -r wokwi-libs` returns zero hits outside node_modules,
.vite, frontend/dist, third-party/ (upstream submodule contents),
*.pyc caches, and *.dll.pre-camera rollback binaries
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
User goal: ESP32-CAM live preview that works with any webcam,
regardless of resolution, brand, or scene complexity. The previous
fixed-quality 0.28 was fragile (intermittent decode errors on
moving/textured scenes) and capped visual quality unnecessarily.
Two-layer fix; either alone is insufficient:
LAYER A — Bounded JPEG encoder (frontend, this repo)
frontend/src/hooks/useWebcamFrames.ts:
encodeBoundedJpeg() walks a quality ladder [0.6, 0.5, ..., 0.1]
until the JPEG fits in MAX_FRAME_BYTES (23 000). If even q=0.1
overshoots — extreme HD/4K scenes — falls back to a 240×180
downscaled canvas at q=0.4. Guarantees every emitted frame fits
the deliverable budget regardless of webcam hardware.
The hook now exposes lastQualityUsed + lastDownscaled so UI can
surface when auto-tuning kicks in.
frontend/src/components/simulator/CameraToggle.tsx:
Tooltip shows "(auto-tuned to q=0.X)" or "(auto-downscaled, q=0.X)"
while streaming so users see what the encoder picked.
LAYER B — Multi-lap descriptor ring walker (qemu-lcgamboa, submodule)
Bumps the QEMU per-frame deliverable cap from 8 KiB to ~32 KiB by
letting the walker reset the descriptor ring up to 4 times per
VSYNC. Submodule pointer bumped to eb8b7a5d.
Combined, the demo now supports:
- Cheap 480p webcams: q=0.6, 5-10 KiB JPEGs, sharp
- Logitech mid-range: q=0.5-0.6, 8-15 KiB JPEGs, sharp
- HD 1080p webcams: q=0.4-0.6, 15-23 KiB JPEGs, sharp
- 4K complex scenes: downscaled, still readable
Documented as bug closure in:
test/test-esp32-cam/autosearch/15_universal_webcam_compat.md
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
User reported "JPG Decompression Failed! Data format error" hitting
intermittently with quality 0.35. Worker log showed actual JPEG
payloads at 7959-8123 bytes per frame — right at the QEMU emulator's
8192-byte deliverable budget (8 EOFs × 1024 bytes from cam_hal's
default 16-descriptor ring).
The webcam JPEG encoder produces variable-size output: simple uniform
scenes compress to ~6 KiB, complex/textured/moving frames bloat to
~9-10 KiB. Anything over 8192 gets truncated mid-Huffman-scan in the
firmware framebuffer, my walker injects FF D9 at byte 8190 to keep
cam_verify_jpeg_eoi happy, but the upstream jpg2rgb565 actually
parses the structure and chokes on the truncated stream.
Quality 0.28 keeps even the worst-case complex frame comfortably
under 8 KiB. Visual quality is still much better than the 0.25
fallback — fine for a 160×120 preview where the user cares about
"is my face there" not "did the JPEG quantization tables converge".
Real long-term fix would be to bump EOFS_PER_FRAME and lift the 8 KiB
ceiling — but that touches the QEMU walker (DLL rebuild cycle) and
risks breaking the descriptor-ring math. Doing this frontend tweak
first to unblock the demo.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
User reported only 2 frames rendering after the SPI batching change.
Diagnosis: the previous flush triggers (CS-line HIGH, buffer >=4096)
were both event-driven. Adafruit_ILI9341's ESP32 backend manages CS
via digitalWrite — i.e. through the GPIO peripheral, NOT the SPI
peripheral's hardware CS pin. So the CS-line-HIGH event from
picsimlab_spi_event NEVER fires for this driver. The buffer only
flushes when it hits 4096 bytes.
Frame 1: 38 400 bytes from drawRGBBitmap → 9 flushes at 4096-byte
boundaries → last 192 bytes stay in the buffer. Status bar adds
some bytes too → maybe one more flush.
Frame 2: same. But by frame 3 the firmware is running ahead of the
flush rhythm and somehow the buffer pattern wedges in a state where
no flush completes (likely a partial buffer that sits between
transactions while the firmware briefly waits on the next fb_get).
Hard to reproduce deterministically — but the symptom matches.
Fix: add a 50 ms periodic flush thread. Independent of any event,
it acquires the lock and flushes whatever's pending. Bounds the
worst-case latency at 50 ms (= 20 fps ceiling, more than enough for
the emulator).
Triple-trigger now:
1. CS HIGH (fast path for hardware-CS drivers)
2. Buffer >= 4096 (safety for big transactions)
3. 50 ms timer (catches GPIO-CS drivers, prevents stalls)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
User reported the ESP32-CAM + ILI9341 live preview at ~1 frame/min.
Profile: 80×60 preview pushes 9600 SPI bytes per drawRGBBitmap, and
each byte was emitting a full {type:'spi_event'} JSON message over
the worker→backend→WS→frontend pipeline. Per-byte overhead ~150-200µs
in Python (json.dumps + sys.stdout.write+flush dominates) plus
asyncio + WS dispatch. Net: 1.5-2 sec/frame minimum, much worse with
GIL contention.
Fix: buffer MOSI bytes in the worker and emit a single base64-encoded
`spi_batch` message when CS goes HIGH (transaction ended) or the
buffer crosses 4 KiB. ~9600 events/frame collapse to ~3 messages.
backend/app/services/esp32_worker.py:_on_spi_event
- Add _spi_byte_buf bytearray + threading.Lock
- On op==0x00 (byte): append; flush early if buf >= 4096
- On op==0x01 (CS change): flush buffer, then emit the CS event
via the legacy spi_event channel (ePaper / custom chips that
observe CS still get it).
frontend/src/simulation/Esp32Bridge.ts
- New 'spi_batch' message handler decodes b64 and replays each
byte through the existing onSpiByte callback. Parts that
subscribed via simulator.spi.onByte don't notice the protocol
change. The 'spi_event' branch still handles CS changes plus
legacy single-byte payloads for backwards compat.
Now that 38 KB/frame is cheap, restore preview to 160×120 + JPEG
quality 0.35 in the gallery example. Real measured speedup: ~50× on
the QVGA preview demo. Real hardware was never affected — it runs
SPI at 80 MHz and pushes the bitmap in ~4 ms either way.
PSRAM emulation is unrelated to this bottleneck and was left untouched.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
User reported the live preview "looks slow" after the JPEG decode fix.
Diagnosis: each tft.drawRGBBitmap pushes width × height × 2 bytes over
SPI, and every byte takes a full QEMU → worker → backend (WS) → frontend
round-trip. At 160×120 that's 38 400 messages per frame; the bus
saturates at ~0.2 fps perceived.
Two changes shrink the per-frame SPI bandwidth:
1. Preview 160×120 → 80×60 (and JPG_SCALE_2X → JPG_SCALE_4X).
38 400 bytes/frame → 9 600 bytes/frame. Already 4× faster.
2. Status bar redraw throttled to every 10th frame instead of every
frame. The text writes (printf, fillRect, fillCircle) account for
another ~1-2 KB of SPI traffic per loop iteration. Skipping 9 of
every 10 redraws frees up a chunk more bandwidth without losing
the headline numbers (fps, frame counter) — they just refresh
once a second instead of 5x/sec.
Also dropped the trailing `delay(20)` — we don't need an artificial
throttle, the SPI bus is the throttle.
Real-hardware effect: zero. ESP32 SPI runs at 80 MHz; a full
160×120 bitmap pushes in ~4 ms either way.
Applied in two places:
- examples/esp32-cam-lcd-preview/esp32-cam-lcd-preview.ino
- frontend/src/data/examples.ts (in-app gallery copy)
Long-term plan: batch SPI bytes at the worker level (one WS message
per N bytes instead of per byte) — that's a deeper change in
qemu-lcgamboa + Esp32Bridge.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The ESP32-CAM + ILI9341 example was rendering grey-X "decode failed"
rectangles. Serial showed:
E (53868) esp_jpg_decode: JPG Decompression Failed!
Data format error
Root cause: the QEMU emulation delivers up to 8 KiB of JPEG bytes per
frame (8 EOFs × 1024 = 8192) plus a 2-byte FF D9 EOI injection at the
end of that window. Real webcam frames at quality 0.6 are ~11 KiB —
they get truncated mid-Huffman-scan in the firmware framebuffer.
cam_verify_jpeg_eoi accepts the frame (it found FF D9), but the
upstream jpg2rgb565() actually parses the JPEG and rejects the
truncated structure.
Quality 0.25 produces ~3-5 KiB JPEGs that fit the budget entirely.
The decoder finds the natural EOI well before our injection point,
parses cleanly, and renders to the TFT. Visual quality is fine for
an emulator preview — the user is seeing their webcam, not editing
print-quality photos.
Long-term fix is a smarter QEMU walker that ring-wraps to deliver
bigger JPEGs (>16 KiB possible by reusing descriptors mid-frame),
but that's a separate change in qemu-lcgamboa. This frontend tweak
unblocks the demo without another DLL rebuild cycle.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
CI's Frontend Tests workflow had been failing on master for ~15 runs.
Two pre-existing issues, neither related to the SPI refactor in 8b1433d
or the ESP32-CAM work:
1. RP2040Simulator mock missing attachCyw43 method (23 test files)
PR #126 (8e769f8 "feat(multi-board): add wire-aware cross-board
interconnect router", merged 2026-04-25) added a Pico-W-specific
`sim.attachCyw43(bridge)` call inside addBoard(). The 23 test files
that mock RP2040Simulator with vi.fn weren't updated; whenever a
test path created a Pico W board the mock threw "TypeError:
sim.attachCyw43 is not a function" and aborted addBoard.
Fix: add `this.attachCyw43 = vi.fn()` to every affected mock.
Also pre-populate `this.spi = { onByte: null, completeTransfer: vi.fn() }`
so any future SPI-part tests don't trip on the new generic .spi
adapter from 8b1433d.
2. install-libraries.test.ts payload mismatch
PR #135 (b1026ec7 "library-version-uninstall", merged 2026-04-29)
extended `installLibrary(name)` to `installLibrary(name, version?)`
and now sends `{name, version: version ?? null}` over the wire.
The test still asserted `{name}` only and failed.
Fix: assert `{name, version: null}` for the no-version call.
Verified locally: 1161 passed | 1 skipped (was 1117 passed | 44 failed).
Backend E2E "Run HC-SR04 e2e test" is a separate failure that needs
its own investigation — it downloads QEMU binaries from a release and
runs real firmware compilation, which I can't reproduce on Windows.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Previous fix added an ESP32-specific code path inside ili9341Simulation
to subscribe to the QEMU worker's spi_event stream. That made the LCD
work on ESP32-CAM but left the underlying issue unsolved: every other
SPI part (custom chips, future SD-card emulators, the SSD168x ePaper
already in the codebase) would also need its own per-board branching.
The right shape: every simulator exposes a `.spi` member matching the
SAME SpiBusLike interface, and SPI parts hook .spi.onByte without
caring which board they're attached to. AVRSimulator already had
this — now everything else does too.
frontend/src/simulation/SpiBus.ts (new)
Defines the contract — `onByte: (mosi) => void | null` plus
optional `completeTransfer(miso)`. Documents the single-listener
semantics that AVR has had since day one.
frontend/src/store/useSimulatorStore.ts
Esp32BridgeShim gets a lazy `.spi` getter that wraps
bridge.onSpiByte (the per-byte WS event from the QEMU worker).
completeTransfer is a no-op because the worker drives MISO via
its own _spi_response global. Covers ESP32 (Xtensa), ESP32-S3,
ESP32-CAM, ESP32-C3 — every kind that routes through Esp32Bridge.
frontend/src/simulation/RP2040Simulator.ts
Adds a lazy `.spi` getter that re-routes rp2040.spi[0].onTransmit
through the adapter. Default loopback (the prior behaviour) is
preserved when no part has accessed `.spi` yet — only consumers
that opt in see their handler invoked. Covers Pico and Pico W.
frontend/src/simulation/parts/ComplexParts.ts
ili9341Simulation no longer has an ESP32 special case. Single
code path: `simulator.spi.onByte = handler`. Works on AVR,
RP2040, all ESP32 variants. Same pattern is now available to
every future SPI part — ssd1306, sd-card, oled, etc.
The Esp32Bridge.ts spi_event field-name fix from 6afa62e (msg.data.event
instead of the non-existent msg.data.data) stays in place — that's what
makes the per-byte stream actually arrive in the bridge.
Verified: ILI9341 + ESP32-CAM gallery example renders the live webcam
preview after a hard refresh. The same simulation code works on Arduino
Uno + ILI9341 (the existing ili9341-test-sketch in example_zip).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The ILI9341 part simulation only hooked AVR's SPI peripheral. For
ESP32 the simulator is Esp32BridgeShim (no .spi member), so
attachEvents bailed early and the LCD stayed black even though the
firmware was driving SPI traffic correctly.
The QEMU worker already emits per-byte spi_event WS messages
(see backend/app/services/esp32_worker.py::_on_spi_event), and the
Esp32Bridge already had an onSpiEvent hook — but the bridge was
reading msg.data.data (a non-existent field) instead of decoding
the worker's {bus, event, response} format. Fixed.
Two changes:
1. Esp32Bridge.ts: decode the spi_event payload correctly. The
worker encodes byte transfers as `mosi << 8` (op = low byte = 0x00)
and CS-line changes as `((cs<<1)|level) << 8 | 0x01` (op == 0x01).
Added onSpiByte (per-byte) and onSpiCsChange callbacks alongside
the existing onSpiEvent for backwards compat.
2. ComplexParts.ts ili9341Simulation: detect Esp32BridgeShim via
`getBridge()` duck-type check. When present, subscribe to
bridge.onSpiByte and feed bytes into the same processCommand /
processData pipeline used by the AVR path. DC tracking via
pinManager.onPinChange already works for ESP32 because the bridge
fires triggerPinChange on every gpio_change WS event.
Verified end-to-end: ESP32-CAM + ILI9341 example in the gallery now
renders the live webcam preview to the simulated TFT (160×120 RGB565
centered in the 320×240 panel) at ~3-4 fps.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds two listed examples to the gallery (book icon → Examples) so
users can one-click load the new ESP32-CAM emulation:
1. ESP32-CAM: Webcam Demo (sensors / beginner)
Minimal sketch — init OV2640, verify SCCB chip-id, loop on
esp_camera_fb_get() printing frame metadata to Serial. Proves
the emulation is alive without any external components.
2. ESP32-CAM + ILI9341 Live Preview (displays / intermediate)
Full demo — decode JPEG with jpg2rgb565() (built-in to
esp32-camera/conversions, header exposed by the Velxio compile
template) and render the resulting RGB565 bitmap to a 320×240
SPI TFT. Pre-wired diagram: ILI9341 connected via VSPI to GPIOs
12-15 (the only block free after OV2640 takes over the rest of
the AI-Thinker pins).
Type changes:
- ExampleProject.boardType union extended with 'esp32-cam'
- BOARD_TABS in ExamplesGallery.tsx gets a new "ESP32-CAM" tab
(orange #d35400)
Both examples use boardFilter: 'esp32-cam' so they show under
the new tab and not the generic ESP32 one.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
First end-user demo of the new ESP32-CAM emulation capability —
shows how to wire an ESP32-CAM to an ILI9341 320×240 SPI TFT,
decode JPEG frames from the emulated webcam with jpg2rgb565()
(built into esp32-camera/conversions, header now exposed by the
Velxio compile template), and render the resulting RGB565 bitmap
to the screen at ~10 fps.
Two sketches in the same example folder:
- esp32-cam-lcd-preview.ino — full demo: decode JPEG → render the
bitmap (160×120 centered in the TFT) + status bar with fps,
frame counter, decode-fail counter, live pulse dot.
- esp32-cam-lcd-status.ino — companion that doesn't decode the
JPEG; instead it shows a status dashboard (frame counter, byte
histogram, JPEG header hex dump). Useful when the source JPEG
exceeds the deliverable byte budget and jpg2rgb565 fails on the
truncation.
diagram.json wires the two parts using velxio-esp32-cam +
wokwi-ili9341 part types over VSPI:
ILI9341 ↔ ESP32-CAM
CS ↔ GPIO 15, RST ↔ GPIO 2, D/C ↔ GPIO 14,
MOSI ↔ GPIO 13, SCK ↔ GPIO 12
libraries.txt lists Adafruit GFX + Adafruit ILI9341. esp_camera.h
and img_converters.h ship with arduino-esp32 — no extra install.
Adds examples/README.md as the index for future demos.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
User confirmed the FULL pipeline works with their laptop webcam at
QVGA quality 0.6 — frontend → WS → backend → worker → DLL → I²S →
firmware → esp_camera_fb_get() → user sketch.
[Frame #1] 8192 bytes 320x240 fmt=4
├─ SOI (FF D8 FF): ✓ at offset 0
├─ EOI (FF D9): ✓ at offset 8190
└─ First 16 bytes: FF D8 FF E0 00 10 4A 46 49 46 …
(JFIF, real webcam)
Stats: 10/10 Valid JPEGs at ~3.5 fps.
Changes:
- wokwi-libs/qemu-lcgamboa @ e4321d1 (picsimlab-esp32):
EOFS_PER_FRAME 6→8, inject_eoi_now flag for EOI injection on the
last EOF of each VSYNC burst. Handles JPEGs of arbitrary size by
forcing FF D9 at offset 8190 — JPEG decoders tolerate the
truncation gracefully.
- backend/esp32_worker.py: throttled trace log every 30 frames
(`camera_frame #N received (NNNN bytes)`) so users can confirm
the frontend → worker leg is alive without flooding the log.
- test/test-esp32-cam/autosearch/14: documented bug #9 (the 9th and
final silent bug — real webcam JPEGs exceed the deliverable byte
budget) with full forensic trace + final architecture diagram.
The emulation now handles ANY user webcam → ESP32-CAM use case end
to end. Standard upstream esp_camera_init() / esp_camera_fb_get()
sketches work without modification.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
End-to-end ESP32-CAM emulation now works. esp_camera_fb_get() in
user sketches returns valid camera_fb_t* pointers with JPEG frames
sourced from the user's webcam (or synthetic frames in tests).
Verification: webcam_demo.ino prints
frame N: 6144 bytes 320x240 fmt=4
continuously at ~10 fps under QEMU. 53 frames received in a 25 s
test window with debug logging disabled.
Bumps wokwi-libs/qemu-lcgamboa pointer to 5bbc92b (picsimlab-esp32)
which contains the final two fixes:
- eofs_remaining counter for multi-EOF-per-frame delivery
- reset_descriptor_ring() on rx_start 0→1 edge (matches hardware's
fresh-capture semantics that cam_hal relies on)
Adds:
- test/test-esp32-cam/autosearch/14_complete_emulation.md — full
forensic trace of the 8 distinct bugs found across the pipeline,
with final architecture diagram
- test/test-esp32-cam/tests/test_webcam_demo_live.py — pytest e2e
test that compiles webcam_demo.ino, boots it under the simulator
WebSocket, pushes a JPEG, and asserts fb_get returns frames
- test/test-esp32-cam/tests/debug_worker_direct.py — direct worker
bypass (no WS, no uvicorn) for dev-time tracing
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Bumps wokwi-libs/qemu-lcgamboa pointer to a96c851 (picsimlab-esp32
branch) which contains:
- pack_one_pixel fix (was discarding half the JPEG data)
- split vsync_kick_timer / eof_timer (resolves chicken-and-egg
between VSYNC and rx_start)
- multi-descriptor walker (already in previous commit, recap)
Adds test/test-esp32-cam/autosearch/13_three_remaining_bugs.md
with line refs to upstream esp32-camera and a TODO list for the
next rebuild + verification cycle.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
First open-source end-to-end emulation of the AI-Thinker ESP32-CAM
in QEMU, paired with a browser webcam → firmware bridge so users can
develop camera sketches without hardware. Status: esp_camera_init()
returns ESP_OK; OV2640 chip-id verifies (PID/VER/MIDH/MIDL exactly
match the datasheet); GPIO 25 VSYNC NEGEDGE interrupt enabled by
the upstream driver. Final piece (cam_task accepting frames) is in
progress — descriptor walker fix landed in this commit.
Backend (Python/FastAPI):
- simulation.py: camera_attach/frame/detach WS handlers
- esp32_worker.py: ctypes binding to velxio_push_camera_frame +
feature-detection fallback for older DLLs
- esp32_lib_manager.py: forward camera commands to the worker stdin
- esp-idf-template/main/CMakeLists.txt: esp32-camera headers added
via add_prebuilt_library + REQUIRES driver (resolves i2c_master_*
symbols). LED_BUILTIN=2 fallback for sketches that hardcode it.
Frontend (React/TS):
- EditorToolbar.tsx: ESP32-CAM (and the rest of the ESP32 family)
added to isQemuBoard list — Run button now starts the QEMU bridge
for these boards instead of falling through to the AVR path
- useWebcamFrames.ts: getUserMedia → OffscreenCanvas →
toBlob('image/jpeg') → base64 → WS at ~10 fps
- CameraToggle.tsx: header button with status colors + frame counter
- SimulatorCanvas.tsx: render CameraToggle for esp32-cam boards
- Esp32Bridge.ts: sendCameraAttach/Frame/Detach + chunked btoa
- useSimulatorStore.ts: diagnostic log on compileBoardProgram
- components-metadata.json: regen including esp32-cam component
Submodule pointer:
- wokwi-libs/qemu-lcgamboa → ff8eee0 (camera devices commit on
davidmonterocrespo24/qemu-lcgamboa branch picsimlab-esp32)
Investigation + tests in test/test-esp32-cam/:
- 13 autosearch markdown docs (overview, SOTA, OV2640 spec, DVP/I2S
spec, build blueprint, blockers resolved, descriptor walker fix)
- 5 sketches (camera_init, sccb_probe, dma_smoke, frame_roundtrip,
webcam_demo) + 8 live + WS regression tests
- README with the user-facing flow
.gitignore:
- libqemu-*.dll.{pre-camera,new,bak} (rollback points, regenerated)
- wokwi-libs/esp32-camera/ (clone consumed by arduino-esp32 path,
not part of this repo)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Backend:
- Add version field to InstallLibraryRequest
- Add fallback and requested_version to InstallResponse
- Add DELETE /api/libraries/uninstall endpoint
- Enhance install_library() for versioned installs (LibName@version)
- Add semver validation and fallback logic
- Add uninstall_library() method
- Fix _parse_version() to reject non-numeric version parts
Frontend:
- Update installLibrary() with optional version parameter
- Add uninstallLibrary() and resolveLibraryVersion() helpers
- Add version selector dropdown in Library Manager
- Add UNINSTALL button for installed libraries
- Show fallback messages when requested version unavailable
- Add parseLibSpec() and version badges in InstallLibrariesModal
_create_idf_component was fixed but NOT _merge_arduino_libs_to_component.
The latter was still flattening library files, causing ArduinoJson compilation to fail
with 'src/ArduinoJson.h: No such file or directory'.
Changes:
- Replace flat copy with directory-structure-preserving copy
- Add exclusion logic for non-buildable directories (examples, tests, docs)
- Generate INCLUDE_DIRS from actual directory structure
- Track files by relative path to prevent name collisions
This ensures libraries with src/ layouts (ArduinoJson, etc.) compile correctly.
- Maintain original library layout (src/, utility/) instead of flattening files
- Add exclusion logic for non-buildable directories (examples, tests, docs, CI)
- Dynamically generate INCLUDE_DIRS from actual directory structure
- Add validation to ensure buildable source files exist before proceeding
- Support both flat and src-based library layouts
- Fix path separator normalization for cross-platform compatibility
This improves compatibility with complex Arduino libraries that rely on
specific directory structures and relative includes.