The quota-exhausted modal (rendered by the velxio.dev pro overlay when
a free user hits the daily AI cap) was hardcoded English. The modal is
seen by users from CN/BR/MX/CO/AR/PE/IN — the audiences most likely to
bounce on English-only UX. This adds the four key languages.
Keys:
titleFree — "You've hit today's free limit"
titlePaid — "You've reached your daily limit"
bodyFree — explainer + Pro upgrade pitch (interpolates cap/proCap/multiplier)
bodyPaid — explainer for paid users who hit their own tier's cap
today / thisMonth / resets — stats labels
ctaUpgrade — primary CTA ("Upgrade to Pro — $15/mo")
ctaSeePlans — fallback CTA for non-free users
ctaWait — secondary "Wait until reset"
Upstream-only change — the velxio-prod overlay's AgentChatPanel.tsx is
wired to consume these via useTranslation in a separate commit.
🤖 Generated with Claude Code
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Same root cause as the previous test fix in 3e38397 — upstream commit
d64eebc (fix(stop): reset CPU to PC=0) added a hardResetPinStates() call
to useSimulatorStore.stopBoard. The vi.mock factories in these 6 ESP32-
adjacent test files only exposed updatePort/onPinChange/getListenersCount,
so any test path that hits stopBoard crashed with "is not a function"
once the real prod code called the new method.
Each gets a single-line addition: this.hardResetPinStates = vi.fn();
Verified with full vitest run: 127 files pass, 2,005 tests pass, 0 failures.
🤖 Generated with Claude Code
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1. multi-board-integration.test.ts — PinManager mock was missing
hardResetPinStates(). Upstream commit d64eebc (fix(stop): reset CPU
to PC=0) added that method to PinManager and useSimulatorStore.stopBoard
calls it, but this test's vi.mock factory never exposed it. Result:
"TypeError: getBoardPinManager(...)?.hardResetPinStates is not a function"
even though the optional chain looks safe — the chain only short-circuits
on null/undefined, not on a non-function property.
2. vitest.config.ts — was missing the @velxio alias that vite.config.ts
defines. defineConfig from vitest/config does NOT auto-inherit from
vite.config.ts; the alias has to be re-declared. Without it, overlay
tests importing @velxio/store/useEditorStore failed with "Cannot find
package '@velxio/...'" even though the build (which DOES inherit the
alias) resolves them fine.
Verified: full set of 3 previously-failing tests now pass cleanly
(multi-board-integration: 43 passed, snapshot: 0, pinIntrospection: 10).
🤖 Generated with Claude Code
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Inserts a Download entry between Pricing and Blog in the main nav.
Routes to the existing DesktopInstallPage in the velxio-prod pro
overlay (auth gate + platform-detect + signed-licence download flow).
i18n: header.nav.download added across all 9 shipped locales
(en/es/ja/it/de/ru/pt-br/zh-cn/fr) with native translations.
Self-hosted OSS image: the route doesn't exist there, so the link
lands on the upstream router's 404 — same fallback behaviour as
/pricing already has for self-hosters. Acceptable until the OSS
side gets its own placeholder.
Brings hardware flashing into Velxio Desktop. Per-board "Flash to
real board" entry in the canvas context menu opens a modal that
enumerates USB serial ports, lets the user pick one, then
streams arduino-cli upload output live until the board is flashed.
Backend (Phase D1) — backend/app/api/routes/flash.py (new):
POST /api/flash/upload (multipart: board_id, port, fqbn,
program_format, program)
→ SSE stream of {phase, line?, progress?} events
→ final {phase:'done', success, elapsed_ms, error?}
- Wraps `arduino-cli upload -p <port> -i <file> --fqbn <fqbn> -v`
so AVR (avrdude), ESP32 (esptool), RP2040 (picotool), SAMD
(bossac) all share one code path — arduino-cli internally
dispatches by FQBN.
- Per-port asyncio.Lock prevents two simultaneous flashes from
fighting over the same /dev/ttyACM0.
- Allow-list of FQBN prefixes (arduino:avr, ATTinyCore:avr,
rp2040:rp2040, esp32:esp32, arduino:samd) so a typo can't
cause a confusing arduino-cli error.
- Format allow-list (hex / bin / uf2 / elf) drives the temp
file extension - arduino-cli uses the extension to route to
the right uploader.
- 8MB hard cap on the uploaded program (real sketches are
well under that; protects against a runaway frontend).
- X-Accel-Buffering: no header so nginx doesn't hold the SSE
chunks until the flash completes.
Frontend (Phase D3):
- frontend/src/services/flashService.ts (new):
async generator streamFlash() yields parsed SSE events.
Handles the base64-vs-text gotcha (compile returns hex_content
as text but binary_content as base64; for binary formats we
atob() into a Uint8Array before posting so the form upload
sends actual bytes, not the base64 ASCII).
- frontend/src/components/simulator/FlashModal.tsx (new):
Three-state UI: picking (port dropdown), flashing (progress
bar + live log), success/error (verdict + retry).
Empty-ports state shows a Linux dialout-group hint.
- SimulatorCanvas.tsx: board context menu gains "Flash to real
board" entry, gated on isTauri() + presence of compiledProgram.
Hidden in web (WebSerial is a separate sprint).
- tauriBridge.ts: SerialPortInfo type + listSerialPorts() helper
that invokes the Rust shell command added in Phase D2.
The sidecar already has arduino-cli on PATH (per
`pro/desktop/sidecar/main.py::_expose_bundled_arduino_cli`), so
no installer changes are needed — flash works the moment the
0.4.x desktop bundle ships with these commits.
Plan + remaining phase tracked in project/hardware-flashing/.
D2 (Rust serial enum) committed separately as a Tauri-shell-only
concern; D4 (manual smoke matrix with real boards) requires
physical hardware so it stays a follow-up.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
#208 — stale binary executes after compile error
EditorToolbar.handleCompile: on failed compile, clear the active
board's compiledProgram so a subsequent Run can't silently execute
the previous successful build (which doesn't match the editor any
more). The Run gate already short-circuits on !compiledProgram and
forces a fresh compile.
#209 — compile terminal kept stale messages across runs
EditorToolbar.handleCompile: setCompileLogs([]) at the top of the
handler. Previously logs from the prior compile lingered, making it
hard to tell new errors / warnings apart from old ones.
#210 — desktop File > New Project did nothing
desktop/menu.ts: the menu action used to dispatch a CustomEvent
nobody listened to. Replaced with a real `newProject()` function
that stops the running simulation, removes every board (also drops
the bridges + wires touching them), clears components / wires,
loads the default Blink sketch into the editor, clears project
metadata, and wipes the compile output. Confirms first if there's
unsaved work on the canvas.
#211 — deleting the only board made every other component
unresponsive (wires still worked)
SimulatorCanvas.tsx::interactionRunning: the old expression
treated boards.length === 0 as "boardless electrical mode is
running" — which suppressed the property dialog on click and made
non-sensor components look frozen. Fixed by also requiring
useElectricalStore.submittedNetlist !== '' before flipping to the
boardless-running branch. SPICE has to have actually solved at
least once for the mode to engage.
#212 — ESP32 Support 404 with no actionable message
desktop/Esp32QemuPrompt.tsx: catch the raw "download HTTP 404" /
"not found" upstream error and reword it to "ESP32 support is not
yet available for your platform. The Velxio team is preparing
this build - try again in a few days, or use Arduino/RP2040
boards in the meantime." The real fix is server-side (the velxio
team needs to publish a qemu-xtensa.tar.gz for the user's
platform into the asset bucket and update esp32-qemu/latest.json).
Tracked in project/desktop-agent-v040/ follow-ups.
All five fixes verified with `tsc --noEmit` clean and the existing
25-test vitest suite green.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The previous fix preserved display state on Stop so Resume could pick
up the multiplexed frame seamlessly — but that's Pause semantics, not
Stop. On a real Arduino, hitting the physical Stop is cutting power:
the next Run must boot from setup(), not continue at the saved PC.
User report on https://velxio.dev/example/uno-7segment :
> empieza a contar, le doy stop en el 6, le doy run y sigue desde 6
stopBoard now:
- calls sim.reset() (was sim.stop()) — CPU back to PC=0
- calls hardResetPinStates() (was the soft resetPinStates) — clears
cached states AND notifies listeners so 7-seg / NeoPixel / LCD
blank out instead of freezing on whatever was lit.
Reset and Stop are now the same cold-boot semantics; Reset still
additionally clears serial output + baud rate. The soft
resetPinStates() helper stays for internal SPICE-classification-only
paths that don't want listener fan-out.
Replaces the native OS-modal update dialog (which blocked the editor
and looked dated) with a non-intrusive bottom-right toast that
appears 30 s after app mount when the Tauri updater finds a newer
release.
State machine:
idle → no update detected, render nothing
available → "Update available - Velxio Desktop X.Y.Z" + Install/Later
downloading → progress bar with "X.X / Y.Y MB (NN%)"
installing → "Installing X.Y.Z... will restart automatically"
error → error message + Retry/Dismiss
Click "Install and restart":
1. downloadAndInstall() streams the full signed installer (~70 MB)
2. Tauri verifies the minisign sig against the embedded pubkey
3. Replaces the install in-place
4. Auto-relaunch (the app exits and reopens on the new version)
"Later" dismisses for the rest of the session (sessionStorage flag).
A close+reopen re-checks. Manual re-check via the menu still works.
Companion change in velxio-prod flips tauri.conf.json
updater.dialog from true to false so our custom toast is the only
update UI - no double-prompting.
Files:
- frontend/src/desktop/UpdateAvailableToast.tsx (new): the component
- frontend/src/desktop/desktop.css: toast styles + slide-in animation
- frontend/src/desktop/index.ts: mount alongside GraceBanner +
Esp32QemuPrompt in the existing sidePanelRoot
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The previous fix rotated overlay hotspots but the pivot was off by
+6px in each axis, which manifested as a 12px X-offset for a 90°
rotation (because (I - R) maps (6,6) to (12, 0) for R = 90° CW).
The wrapper top-left in container-local coords is -wrapperOffsetX,
not -(6 - wrapperOffsetX). The container origin already sits INSIDE
the wrapper's padding+border by exactly wrapperOffsetX/Y; we have
to back out by that same amount, not by 6 - that amount.
Visual verification on https://velxio.dev/example/esp32-pwm-led-rgb:
overlay centers of rotated resistor now match the rotated pin tips
exactly (was 12px off in X).
Reporter on GitHub: after rotating a component the WIRES followed
the pin tips (already fixed in the (6,6) offset commit) but the
clickable connection boxes stayed in the unrotated layout —
visible misalignment between the rotated component and its
hotspots, no way to start a fresh wire from a rotated pin.
Root cause: PinOverlay renders as a SIBLING of the DynamicComponent
wrapper, not as a child. CSS rotation on the wrapper doesn't reach
the overlay div, so its child pin boxes stay at the unrotated
(pin.x, pin.y) coordinates.
Fix:
- Plumb component.properties.rotation from SimulatorCanvas into
PinOverlay as a new `rotation` prop.
- In PinOverlay, capture wrapper.offsetWidth/Height when reading
pinInfo and apply the same rotation matrix the wire calculator
uses (pivot at wrapper center, transform-origin: center center).
- Use the rotated (pinX, pinY) for both the visual `left/top` AND
the canvas-coord passed to onPinClick, so wires that get started
from the hotspot anchor at the rotated tip too.
Also align the default wrapperOffsetX from 4 to 6 (padding:4 +
border:2 on each side of the DynamicComponent wrapper). The
previous asymmetric (4, 6) was the same 2px X bias we fixed in
pinPositionCalculator a few commits back; the overlay was reading
its own copy of the bad number and putting hotspots 2 px left of
the pin tip on unrotated components too. Board paths that pass
wrapperOffsetX/Y = 0 explicitly are unaffected.
All 29 vitest tests in the rotation + simulator suites pass.
Two related changes for the v0.4.0 desktop-agent rollout:
- include glob now also matches `../../pro/frontend/src/pro/**/__tests__/`
so the agent-overlay tests in velxio-prod are discovered when this
config is used from a velxio-prod checkout. On pure-OSS clones the
glob has nothing to match - harmless.
- server.fs.allow extended to `..` and `../..` so Vite's filesystem
sandbox doesn't reject the cross-project test paths with
"Cannot find module '/@fs/...'".
No behavior change for OSS-only contributors. velxio-prod gets the
agent's `desktopAuth` unit tests picked up automatically by
`npx vitest run` in this directory.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
v0.3.x main.tsx explicitly REFUSED to load @pro when VITE_DESKTOP was
true ("desktop owns its own license + auth UI"). v0.4.0 brings the
AI agent into the desktop bundle, so the refusal needs to relax for
that one use case.
New routing inside the if(VITE_PRO_BUILD) branch:
- VITE_DESKTOP also set → load @pro/desktop_index (slim entry that
only mounts AgentChatPanel + DiagnoseCompileButton, no analytics
/ sessions / billing / admin / save overrides - those expect
velxio.dev cookies the desktop has no way to send)
- VITE_DESKTOP not set → load @pro/index (existing web behavior)
VITE_DESKTOP alone (no pro) still loads zero overlay - that's the
pure-OSS desktop build path for self-hosters who don't have the
pro source tree at $PRO_OVERLAY_PATH.
Companion commit in velxio-prod creates @pro/desktop_index, adapts
the agent client for license-key Bearer auth, and updates
build-frontend.sh to pass both flags.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Reporter feedback after 7aca3db: pressing Stop on the uno-7segment
example turned the 7-segment off, and pressing Start again left
random segments lit / no number at all. The previous fix made
resetPinStates() notify every listener with (pin, false) on both
Stop and Reset, which was right for Reset (full reboot) but wrong
for Stop:
- On Stop the AVR CPU is just paused. Internally it still has
PORTD=0xFF (or whatever the last drive was).
- resetPinStates blanked the pinStates cache + fan-out LOW
notifications. Display turns off, fine.
- On Start the CPU resumes from where it paused. avr8js's port
listener fires only for bits that CHANGED relative to its OWN
oldValue (which still holds the pre-stop value). If oldValue
matches the live register, no pinChange event fires for that
bit, and the display has no signal telling it to come back on.
Split the API into two methods:
resetPinStates() — soft cleanup, drops outputPins only. Used by
stopBoard. Cached pinStates and visual state
stay so the resume picks up where it left off.
hardResetPinStates() — full cleanup, drops outputPins + pinStates
and fan-outs (pin, false) to listeners.
Used by resetBoard (CPU starts at PC=0,
firmware re-drives every pin from setup()).
Updated the test helper clearAllPinManagerState to call
hardResetPinStates between tests so the same-state short-circuit in
triggerPinChange doesn't suppress fresh events.
All 32 vitest tests pass (AVRSimulator, interconnect-routing,
dual-arduino-software-serial, pin-position-rotation).
Two paired bugs that surfaced on the Reset button.
(1) 7-segment / NeoPixel / LCD freeze on last pattern after Reset.
resetPinStates() was wiping the pinStates cache + outputPins set
silently — no listener notifications fired, so visual components
that update on pinChange kept rendering whatever segments were
lit at the instant the user pressed Reset. Now we snapshot every
pin that was HIGH before clearing and fan out a synthetic
(pin, false) to each registered listener. Stateful displays
redraw cleanly to all-off; passive listeners (analog sensors,
debounce-only buttons) ignore the synthetic LOW and recover on
their next real write.
(2) Cross-board serial silently dies after pressing Reset. resetBoard
was unconditionally reassigning:
sim.onSerialData = (ch) => appendSerial(boardId, ch);
immediately after sim.reset(). The comment said "re-wire after
reset" but reset() does NOT clear that property — the new USART's
onByteTransmit chains through `this.onSerialData` which IS the
Interconnect wrapper. The reassignment destroyed that wrapper and
sibling-board UART forwarding (Uno TX → Nano RX) stopped working
until a full page reload. Same root pattern as the initSimulator
bug fixed in 5480052 — Interconnect's __icSerialHookInstalled
flag is on the live sim, so once the wrapper is blown away
nothing reinstalls it. Removed the reassignment and left a NOTE
so the next person doesn't reintroduce it.
Verified the AVRSimulator + dual-arduino-software-serial +
interconnect-routing test suites still pass (26 tests).
Cross-board UART forwarding silently broke for any project loaded
with > 1 board. User report: Arduino Uno → Arduino Nano serial echo
test where the Uno transmits fine but the Nano's Serial.available()
is never true.
Root cause traced live with chrome-devtools-mcp + temporary debug
logs in AVRSimulator.onSerialData setter and Interconnect:
1. loadProjectState → addBoard(uno) → createSimulator → sim.onSerialData = appendSerial
2. addBoard(nano) → same
3. setWires → Interconnect.updateWires → ensureSerialHook(uno)
wraps sim.onSerialData with a fan-out callback that ALSO pushes
to the Nano's RX queue. __icSerialHookInstalled flag set.
4. SimulatorCanvas mounts → useEffect calls store.initSimulator()
5. initSimulator unconditionally did:
simulatorMap.delete(boardId);
const sim = createSimulator(...); // ← brand-new sim
simulatorMap.set(boardId, sim); // ← Interconnect's wrapper is gone
The new sim's onSerialData is just appendSerial. The old sim
(where the wrapper lived) has been orphaned; Interconnect never
re-installs because its flag was on the discarded sim.
6. Run all boards → Uno.usart.onByteTransmit → this.onSerialData →
appendSerial (Uno's monitor shows TX) but no fan-out call →
Nano never receives anything.
initSimulator is a legacy single-board helper from the days when the
store only knew about one MCU. Multi-board flows already create
their sims in addBoard. Bail out early if a sim for the active
boardId already exists, so the legacy helper becomes a no-op when
the multi-board path has already done the work.
Verified the 3 related test suites still pass (AVRSimulator,
dual-arduino-software-serial, interconnect-routing).
User report: Arduino Nano connected to an Uno-TX wire received bytes
but displayed them poorly, and pressing Stop then Run "killed" the
serial link until the page reloaded.
Two paired bugs in the cross-board serial path:
(1) drainSerialRxQueue was only ever re-fired from usart.onRxComplete,
which itself only fires AFTER a successful delivery. If the very
first delivery attempt fails (rxEnable=false because the sketch
hasn't reached Serial.begin yet — extremely common when one board
starts emitting bytes before the receiving board's setup() runs)
nothing re-kicks the queue and every subsequent byte from the
sibling board sits in serialRxQueue indefinitely. Adding a
per-frame drain attempt (no-op when queue is empty or rxBusyValue
is set, so cost is negligible) makes the link self-heal across
cold-start races and Serial.end()/begin() toggles.
(2) stop() never cleared serialRxQueue. On Run after Stop the new
USART would re-drain the previous run's leftovers into the fresh
sketch before its setup() ran, corrupting the first bytes the
user saw on the receiving side. Clearing the queue in stop() —
same place we already clear scheduledPinChanges — keeps each Run
a clean slate.
Verified 52 existing tests still pass (dual-pico-serial-passthrough,
dual-arduino-software-serial, interconnect-routing, avr-uart-tx
-waveform, serial-batching, AVRSimulator, pin-position-rotation).
User report: "rotating components messes up their connections" — pressing R
on a placed component visibly slid every wire endpoint off its pin tip.
Root cause: the DynamicComponent wrapper has padding:4px + border:2px on
EVERY side, so the inner web-component element sits 6 px in from the
wrapper top-left on BOTH axes. The wire layer assumed an asymmetric
(4, 6) offset, baked into:
* useSimulatorStore.updateWirePositions — store.x + 4, store.y + 6
* useSimulatorStore.recalculateAllWirePositions
— start (startComp.x + 4, startComp.y + 6)
— end (endComp.x + 4, endComp.y + 6)
* pinPositionCalculator.calculatePinPosition — inverse: (componentX - 4, componentY - 6)
Unrotated the 2 px X bias was visible only as a very-slightly-off wire,
which nobody filed. When the user rotated the component, the bias
rotated WITH it — at 90° it became a 2 px Y offset (wires hanging below
the pin), at 180° a 2 px X offset on the other side, at 270° upward. UX
read as "wires disconnected".
Verified the real CSS box via chrome-devtools-mcp against several live
components on velxio.dev (RGB LED + 3 resistors + analog joystick): all
report padding-left/top = 4 px, border-left/top = 2 px, inner offset = 6
on both axes.
Fix: use (+6, +6) at every site, single source of truth in a comment
explaining padding+border arithmetic. Updated the rotation regression
test to match the corrected math (numbers shift by 2 px on every
expectation that referenced the old offset).
Pin position math, pivot derivation and the rotate-N×90° round trip
unchanged — only the offset constant moved.
Phase 4 polish: GraceBanner was rendering for state=locked/tampered
even though LockoutOverlay covers the screen for those states. The
banner leaked through the overlay's 96%-opaque background as a
faint red strip - confusing.
- GraceBanner.tsx: bannerFor() returns null for locked/tampered
(LockoutOverlay handles the messaging). Also exported bannerFor
so the new unit tests can exercise the pure decision logic.
- __tests__/GraceBanner.test.ts (new): 13 vitest cases covering
pre-expiry amber/red thresholds (trial_ends_at vs subscription_period_end),
fallback to claims.exp for legacy JWTs, soft/hard grace messaging,
dismissibility rules.
- vitest.config.ts: include also matches src/**/__tests__/ so the
desktop tests are discovered without moving them.
Runtime ~600ms vs 5-25 min for a full installer rebuild - lets
future iterations on the banner state machine skip the build cycle.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds the frontend half of the v0.3.0 desktop paid model. The Tauri
shell (in velxio-prod) emits velxio://license-required when the
license gate refuses to spawn the sidecar; this commit teaches the
OSS desktop overlay to react.
- LockoutOverlay.tsx (new): full-screen modal with three variants
(no_credential / tampered / expired). Sign-in or paste-key
resolves it via restartApp().
- DesktopWelcomePage.tsx: new grandfather variant - "you have N
days to keep using Velxio Desktop" + "Continue without signing in".
- GraceBanner.tsx: rewrite with pre-expiry tones (5d amber, 24h
red, dismissible), polling every 10 min while document visible,
separates pre/post-expiry messaging.
- Esp32QemuPrompt.tsx: signup gate for grandfather users (ESP32
binaries are not part of the grandfather grace) + inline progress
bar driven by velxio://esp32-qemu-progress events.
- index.ts: rewires on getGateInfo() at first paint to decide
welcome vs lockout vs nothing; installs license-required listener
+ 10-min foreground polling for the locked transition.
- tauriBridge.ts: adds GateInfo type, getGateInfo(), restartApp().
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Phase 7.7 follow-up. Previously the WiFi stub returned wlan.isconnected()=False
and ntptime.settime() raised OSError — sketches degraded gracefully but
features like the TIME and WEATHER screens in the smart-ui-eyes example
showed "Sync Failed" / "API Error" instead of real-looking data.
Smart stub now:
- wlan.isconnected() returns True after the first ~2 calls (simulates a
~1 second connection ramp)
- ntptime.settime() pre-loads machine.RTC() with the host's UTC datetime
(captured at code-injection time), so localtime() returns real time
- urequests.get(url) returns a stubbed Response whose .json() decodes a
payload routed by URL substring:
"openweathermap"/"weather" → fake weather dict (temp/humidity/desc)
"ipify"/"myip" → fake public IP
"worldtimeapi" → fake ISO datetime
everything else → {}
- urequests.post/head also stubbed (return {"ok": True} / {})
- Both `urequests` and `requests` aliases registered
End result: smart-ui-eyes example shows real-looking time on TIME
screen and plausible weather data on WEATHER screen, no crashes.
Still no real internet (would need Phase 7 QEMU WiFi emulation), but
visually the example demos correctly.
Inject a compat shim into the raw-REPL prelude that replaces
sys.modules["network"] and sys.modules["ntptime"] with no-op stubs
BEFORE user main.py runs.
Why: the picsimlab QEMU fork's esp32_wifi NIC emulation handles
Arduino's lightweight WiFi.h but not MicroPython's full esp_wifi_init
path. Calling network.WLAN(STA_IF) (which is what every
network-using MP sketch does) drives the firmware to wait on
peripheral status bits QEMU never sets, eventually tripping the
FreeRTOS task watchdog (TG1WDT_SYS_RESET ~26s after boot, or
TG0WDT ~14s if the NIC is partially attached).
With the stub:
network.WLAN(STA_IF).isconnected() -> False
network.WLAN(STA_IF).connect(...) -> no-op
ntptime.settime() -> raises OSError
Sketches that already have try/except around sync_time (which is
most of the 100-days examples) now degrade gracefully: WELCOME +
EYES screens run, TIME and WEATHER screens show their fallback
behaviour, no panic, no reboot.
Doesn't affect Arduino C++ — sketches that #include <WiFi.h> use
real WiFi.begin() and the existing esp32_wifi NIC handles those fine.
A proper fix is to extend the picsimlab WiFi emulation to support
the full ESP-IDF API, but that's a multi-day project. This stub
unblocks the 31 MicroPython examples shipping with network imports.
Three coordinated changes that fix the "Waiting for browser…" hang
and unblock first-launch UX on the Tauri desktop build:
1. desktop/index.ts — DON'T mountWelcome unconditionally on first
launch. Before, an empty keychain (no key yet) forced the
welcome / sign-in screen on top of the editor, gating 100% of
the app behind an account. Now the editor opens directly:
compile + run + sim + save .vlx all work for free (they're
upstream OSS features), and the license check still runs in
the background just to populate state for the GraceBanner
(which shows for invalid keys — locked, tampered, in
soft/hard grace). Pro-only features (ESP32 QEMU download,
agent IA) prompt for license at use time, where it actually
matters. Matches the "try before you buy" expectation a
desktop install creates.
2. desktop/tauriBridge.ts — rewrite `openExternal` to try every
known IPC path in cascade order and log via the desktop debug
file which one worked. The previous implementation invoked
`plugin:shell|open` with `{ path: url }`, which silently
failed (no ACL match + wrong arg shape) and fell back to
`window.open`, which inside a Tauri webview is a no-op for
external URLs — the browser never opened. New cascade:
plugin:opener|open_url (paired with tauri-plugin-opener which
ships in this revision), then plugin:shell|open with both
`{ path, with: null }` and `{ url }` shapes, then the
window.__TAURI__.shell / opener high-level wrappers that
specific Tauri 2.x flag combos expose. Each attempt logged
via the dlog helper so the next operator can see exactly
which path was used (or that all failed) without devtools.
3. desktop/menu.ts — new `navigate-route` action type. Routes
bundled in the SPA (DocsPage, ExamplesPage, AboutPage) that
used to open velxio.dev in the system browser now navigate
in-window via history.pushState + popstate (mirrors the
locale-switch handler). Respects the current locale prefix
so `/examples` from `/es/editor` lands at `/es/examples`
instead of jumping back to English.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The hasWifi auto-detection in useSimulatorStore.startBoard only matched
Arduino C++ patterns (#include <WiFi.h>, WiFi.begin). MicroPython
sketches that call `import network` or `network.WLAN(STA_IF)` were
not detected, so wifi_enabled stayed false and the backend never
attached the esp32_wifi NIC model to QEMU.
Symptom: any MicroPython ESP32 example that touches the network
module hangs in network.WLAN(STA_IF) (the constructor that triggers
esp_wifi_init internally) and the FreeRTOS task watchdog trips with
TG1WDT_SYS_RESET ~26 seconds after boot. The chip then reboot-loops.
Mirror the Pico W detector right below this one — it already handles
both Arduino and MicroPython patterns. Now ESP32 does too.
Affects 31 examples in examples-100-days.ts that use network.WLAN.
OSError: [Errno 19] ENODEV at ssd1306.SSD1306_I2C(...) on
100d-esp32-oled-smart-ui-eyes-animation-time-and-weather-micropython.
MicroPython SoftI2C bit-bangs GPIO directly. Velxio's ESP32 QEMU
bridge listens on the emulated I2C peripheral (registers slaves like
0x3C wokwi-ssd1306 against it) and doesn't decode bit-banged GPIO
toggles as I2C frames, so the OLED never sees any writes and
i2c.writeto() returns ENODEV on first use.
machine.I2C(0, ...) routes through the hardware I2C peripheral that
QEMU emulates, the registered slave receives the bytes, the OLED
panel updates. Same code path the other working SSD1306 MicroPython
examples on this repo already use.
API surface is identical to SoftI2C — only the constructor differs —
so the rest of the user sketch needs zero changes.
avr8js's usart.writeByte(value) rejects the call (returns false, drops
the byte) whenever rxBusyValue is set — and rxBusyValue stays true for
one full cyclesPerChar after each accepted call. The old serialWrite()
fed every character in a synchronous for-loop, so only the first byte
made it through and the sketch saw 'h' when the user typed 'hello\n'.
Buffer pending bytes in serialRxQueue and pump them one at a time:
- serialWrite() now just queues + kicks drainSerialRxQueue once
- drainSerialRxQueue calls writeByte on the head of the queue and only
shifts it off if writeByte returned true (avr8js accepted it)
- usart.onRxComplete is wired to drainSerialRxQueue so the next byte
ships as soon as the sketch's RX side actually consumed the previous
one — matches the cyclesPerChar pacing the real chip enforces
Same handler wired in both USART setup paths (the Uno/Nano branch and
the post-loadHex Mega/ATtiny branch). TX path (onByteTransmit +
emitUartTxFrame for the oscilloscope waveform) is unchanged.
Two related fixes for the ESP32 Arduino-compat compile path:
(a) backend/app/services/esp-idf-template/main/CMakeLists.txt:
Demote -Werror=comment / =parentheses / =sign-compare / =narrowing
/ =write-strings / =missing-field-initializers / =reorder back to
plain warnings. ESP-IDF's project defaults are stricter than what
Arduino/arduino-cli users expect, so common Arduino idioms (nested
/* */, missing field initializers in struct literals, etc.) were
failing builds that compile fine in the Arduino IDE. -Wall stays
on; we just stop the abort.
(b) examples-robot-desktop.ts (robot-desktop-eyes example):
Replace the nested /* xTaskCreatePinnedToCore( ... /* Task function. */
... */ block with `#if 0 / #endif` so the inner block comments
don't terminate the outer one. Even with -Wno-error=comment the
real-syntax-level issue (the first inner `*/` closes the outer
comment, leaving the rest of the lines as bare code) would still
bite, so this needs an actual code fix.
Drops `/.well-known/assetlinks.json` so the Trusted Web Activity APK
(dev.velxio.twa, generated by bubblewrap from this same manifest.webmanifest)
can prove to Chrome that it's allowed to claim velxio.dev as its own
origin. Without this file the TWA falls back to a Custom Tab with the
URL bar visible — losing the whole "feels native" UX that TWAs
exist for.
The sha256_cert_fingerprints entry pins the production signing key
held locally as android.keystore in the velxio-twa/ build dir (NOT
in any repo). If we ever lose that key + need to re-issue, this
file has to be updated with the new fingerprint and re-deployed
BEFORE the new APK reaches users; otherwise their previously-
installed TWA verifies against an asset link that no longer matches
the APK signature and breaks.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Sketch fails to compile out of the box with
fatal error: ESP32Servo.h: No such file or directory
because the ESP32Servo / U8g2 / DHT / Adafruit Unified Sensor libs
aren't part of arduino-esp32 and weren't declared on the example.
loadExample.ts already iterates `example.libraries` and runs
arduino-cli lib install for any missing entry before the user
touches Compile. Adding the four real deps the sketch needs gets
the example compiling cleanly on a fresh container without any
manual Library Manager dance.
When the auto-compile path in handleRun() finishes without producing a
compiledProgram, the previous code dropped the failure on the floor with
only a `console.warn` — the user clicked Run, nothing happened, and they
had no idea why. The accompanying comment also promised "always start
even if compiledProgram is empty" but the code did the opposite.
This commit replaces the dead comment + silent warn with a top-level
error toast + addLog entry, with a different copy for MicroPython mode
(suggests "click Load MicroPython to retry") vs Arduino C++ mode
(directs the user to the output console for the underlying error).
handleCompile already writes the actual cause to the compile-output
console via addLog — this fix just makes sure the user knows their
click failed and where to look.
Three small fixes to frontend/public/manifest.webmanifest so the
Bubblewrap-generated TWA (and Add-to-Home-Screen PWA installs) feel
right on a phone:
- orientation: landscape → any. Landscape-forced on a phone
locks the device in side-grip whenever Velxio is foregrounded;
the editor + simulator work fine in portrait too (the file
explorer collapses gracefully). Tablets and desktops still
default to landscape because they're naturally wider, so this
only changes behaviour where the lock would actively hurt.
- name: "Arduino Emulator" → "Circuit & Arduino Simulator".
Matches the title tag + Open Graph copy that velxio.dev uses
everywhere else and reflects the SPICE / ESP32 / RP2040 work
the project has grown into since the original name was written.
- description: was 'Free local Arduino emulator … No cloud, no
latency.' That was true for the OSS self-host but misleading
for an installed PWA that talks to velxio.dev. Rewritten to
describe the actual product surface (the boards, the SPICE
sim, "free and open source") without making a claim the live
site can't keep.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Snapshot was written when the example had components: [] and wires:
[]. Now that 5bd541e populated the circuit (OLED + 2 buttons) and
2dea3f0 renamed the OLED pins to match wokwi-ssd1306's real
pinInfo, the netlist contains the button pull-down resistors and
floating-net autopulls. Regenerated with `vitest run -u`.
robot-desktop-eyes and the day-30/100-days OLED example wired the
SSD1306 OLED with SDA/SCL/VCC, but the wokwi-ssd1306 element
exposes pinInfo as DATA/CLK/VIN/GND. The mismatched names couldn't
resolve, so all three wire endpoints fell back to (0,0) of the
component and visually attached to the corner instead of the pins.
Same class of bug on the wokwi-big-sound-sensor in
robot-desktop-eyes: the element has AOUT/DOUT (no plain OUT). The
sketch uses digitalRead(SOUND_PIN), so route to DOUT.
The COMPONENT_PIN_ALIASES map in wokwiZip.ts only normalises on
.zip import — static examples have to use the real pinInfo names.
The 100d-esp32-oled-smart-ui-eyes-animation-time-and-weather-micropython
example had components: [] and wires: [] — the MicroPython code wired
an SSD1306 OLED on I2C (GPIO 21/22) plus two buttons (GPIO 14, 27) but
the circuit had nothing on the canvas, so users saw a bare ESP32 board
and the simulation was missing every peripheral the code drives.
Adds:
- wokwi-ssd1306 on I2C (3V3 / GND / SDA=21 / SCL=22)
- two wokwi-pushbuttons wired HIGH-when-pressed (3V3 → 1.l, 2.l → GPIO
14 / 27) to match the `if pin.value(): pressed` check in main.py
Pulls https://github.com/davidmonterocrespo24/robot_desktop into the
examples gallery as a real-world ESP32 + sensors project. Cozmo-style
desktop robot: SSD1306 OLED face that blinks, looks around, and shows
emotions; DHT11 weather mode triggered after 10 min idle; PIR wakeup
from sleep; LDR-driven sleep when the room goes dark; sound-triggered
reactions; and two eyebrow servos.
Ships as 34 separate files (one .ino + 33 headers / source) rather
than the usual single-sketch flatten. The face engine
(Eye / EyeTransition / EyeVariation / FaceBehavior / FaceExpression
/ FaceEmotions / BlinkAssistant / LookAssistant / …) splits
responsibility across enough classes that flattening would obscure
the design. Velxio's multi-file `files: [{ name, content }]`
mechanism handles this cleanly — the editor mounts the .ino as the
active sketch and the rest sit in the same workspace.
Pre-placed components match the original board's pin map verbatim
from Common.h:
- SSD1306 OLED on I²C (SDA=21, SCL=22 — ESP32 default)
- DHT11 on GPIO 15
- PIR motion on GPIO 4
- Big sound sensor on GPIO 2
- Photoresistor on GPIO 34 (ADC1)
- Right eyebrow servo on GPIO 12
- Left eyebrow servo on GPIO 13
Arduino libraries (U8g2lib, DHT, ESP32Servo, Adafruit_Sensor) are
auto-installed by velxio's Library Manager on the first compile.
Category 'displays', difficulty 'advanced', tags cover both the
sensor list and the project's identity (cozmo / robot / animation /
eyes) so the gallery search surfaces it from multiple angles.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The BoardKind type and the QEMU backend already supported
raspberry-pi-4 (Cortex-A72) and raspberry-pi-5 (Cortex-A76) by reusing
the Pi 3 arm64 image set, but the frontend had no way to actually
select either: the board picker, the canvas renderer, the serial
monitor, the oscilloscope channel list, and the editor toolbar all
hard-coded "raspberry-pi-3" as the only Pi entry. ComponentRegistry
even registered Pi 4 / Pi 5 metadata pointing at the velxio-raspberry-pi-3
custom-element tag — a placeholder that meant both boards rendered as
a Pi 3 in the picker thumbnail and on the canvas.
Add dedicated boards top-to-bottom:
* `RaspberryPi4Element.ts` / `RaspberryPi5Element.ts` — Velxio-style
schematic SVG (authored from scratch, not traced). Pi 4 is the
green PCB with BCM2711 SoC, 4× USB-A, USB-C power, dual µHDMI;
Pi 5 is the darker green PCB with BCM2712 + RP1 southbridge,
2.5 GbE, USB-C 5V/5A, PCIe FFC connector, dedicated power
button. Both carry a small "velxio" mark in the corner.
* `pi40PinHeader.ts` — shared `buildPi40PinHeader()` helper that
returns the 40-pin BCM layout. Every Pi from the 1B+ onwards
uses the same physical pin positions and same BCM GPIO
assignment, so Pi 3 / Pi 4 / Pi 5 elements all consume this
helper and example wires drawn against one model transfer to
the others without re-routing.
* React wrappers `RaspberryPi4.tsx` / `RaspberryPi5.tsx` render the
custom elements at absolute positions (mirrors how
RaspberryPi3.tsx handles the Pi 3 illustration).
* Wire-up across the editor surface:
- BoardOnCanvas: BOARD_SIZE entry + switch case.
- BoardPickerModal: description, icon, kinds list.
- ComponentPickerModal: thumbnails now instantiate the dedicated
custom element (was velxio-raspberry-pi-3 fallback).
- SerialMonitor / EditorToolbar: pill labels, icons, colours.
- Oscilloscope: GPIO channel list (28 BCM pins).
- SimulatorCanvas: remote-boards filter for run/stop sync.
- SPICE boardPinGroups: same 5V / 3V3 / GND as Pi 3.
- boardPinToNumber: accepts physical pin numbers ("1"-"40"),
BCM names ("GPIO14") and power labels for any Pi 3/4/5 id.
- ComponentRegistry: dedicated tagNames + per-board thumbnails
(green for Pi 4, darker green for Pi 5).
* EditorToolbar's Pi 3 special cases (Linux/Python compile path,
Run/Stop routing) now use `isPiBoardKind()` so Pi 4 and Pi 5
inherit the same behaviour automatically, and any future Pi
family member (Zero / 1 / 2) lands in the right code paths the
moment its backend boots.
QEMU backend was already wired (qemu_manager.py:71/82 + manifest entry
'raspberry-pi-3-virt' shared across arm64 Pis), so this commit makes
both boards selectable end-to-end without any backend follow-up.
Three QoL fixes for the Tauri shell:
1. Hide the entire AppHeader strip in VITE_DESKTOP, not just the
marketing nav. The previous gate left the black bar painting
over the editor with the brand + auto-save + share + auth
slot, all of which are irrelevant in desktop (cloud Pro
features, license is handled by DesktopWelcomePage, the title
bar already says "Velxio Desktop"). Return null at the top so
the editor takes the full window height.
2. Splash screen during sidecar boot + Monaco hydration. Cold
launch was a 3-8 s black window — now there's an inline SVG
logo, "Velxio" wordmark, slogan, animated spinner, and a
"Starting local backend…" caption. Lives in index.html as a
fixed-position overlay with display:none by default; the inline
script reveals it only when `window.__TAURI__` is present, so
web users never see it. main.tsx fades it out (250 ms ease-out)
after two animation frames — guarantees React's first paint has
committed before the handoff, no black flash. Self-contained:
inline styles, inline SVG, inline CSS keyframes, zero external
requests.
3. Native locale switcher under View → Language. Emits
`velxio://menu` with action='set-locale' + the locale code; the
desktop/menu.ts handler navigates via history.pushState +
popstate so React Router picks it up without a hard reload
(Monaco + simulator state preserved). Locale list mirrors
i18n/config.ts::LOCALES.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two intertwined bugs were leaving every ESP32 ePaper example broken
end-to-end. Only the 5.65" UC8159c panel surfaced the failure
audibly ("Busy Timeout!" repeating in serial), because its inverted
busy polarity caused the firmware to hang inside `_waitBusy()`. The
SSD168x ePaper examples APPEARED to run cleanly but never actually
rendered anything to the panel — the canvas stayed at the idle paper
colour because the same registration path was broken.
Root cause #1 — `setSensors` was a full REPLACE, not a merge.
`Esp32Bridge.setSensors(sensors)` did `this._pendingSensors =
sensors`. At `startBoard()` time the store iterates components,
resolves wires for any entry in `SENSOR_COMPONENT_MAP` (DHT22 /
HC-SR04 / I²C sensors) and calls `setSensors(...)` with that list.
ePaper components live in `PartSimulationRegistry` (not in the
sensor map) and are registered via `sendSensorAttach()` AT
COMPONENT-MOUNT TIME — well before `startBoard()` runs. Full-replace
semantics blew that registration away on every Run click, so the
worker never instantiated an `Ssd168xEpaperSlave` / `Uc8159cEpaperSlave`,
no SPI bytes were decoded, no frames were latched, and BUSY was
never driven.
Fix: upsert by `pin` so pre-existing registrations from
PartSimulationRegistry handlers are preserved alongside the
startBoard-resolved sensors. Confirmed via a WebSocket spy that the
`start_esp32` payload now carries the ePaper sensor entry.
Root cause #2 — BUSY polarity was hard-coded for SSD168x only.
Verified against upstream GxEPD2 source:
* SSD168x family — constructor passes `_busy_level = HIGH`
→ BUSY=HIGH means busy, LOW means ready.
* UC8159c family — constructor passes `_busy_level = LOW`
→ BUSY=LOW means busy, HIGH means ready.
The worker only drove BUSY after a frame flush (and at the wrong
polarity for UC8159c), so the firmware's first `_waitBusy()` inside
`_PowerOn()` / `_InitDisplay()` — which fires BEFORE any frame —
blocked for the full 25 s `_busy_timeout`.
Fix: read `controller_family` from the registration payload, pick the
per-family idle level, and (a) seed the pin to IDLE at registration so
the first `_waitBusy()` sees "ready" immediately, (b) use that
polarity (idle vs. busy) when pulsing on frame flush.
Verified on https://velxio.dev/example/epaper-5in65-7c-esp32-rainbow:
the serial timeline now reads `_InitDisplay reset : 1566` /
`_PowerOn : 148` / `_PowerOff : 183` / `frame done` (all sub-2 ms
busy-waits, no timeouts). Sensor registration confirmed via the
`start_esp32` payload carrying the `epaper-ssd168x` entry.
Two new modules under the existing desktop/ subtree, both no-op
outside a Tauri runtime (tauriBridge.listen / .invoke fail gracefully).
desktop/menu.ts — listens for the `velxio://menu` event the Rust
shell emits from the native menubar (Velxio Desktop / File / Edit
/ View / Help, full menu defined in
pro/desktop/src-tauri/src/menu.rs). Internal actions handled
directly here: Save .vlx and Open .vlx via utils/vlxFile, Toggle
Serial Monitor via useSimulatorStore, Check for Updates via the
tauri-plugin-updater global. The rest (new-project, Find,
Toggle File Explorer) re-emit as window CustomEvent so the owners
of that UI state can subscribe without pulling this module in.
desktop/log.ts — `dlog(message, extra?)` round-trips a line to a
Rust `write_debug_log` command that appends to
`<app_data_dir>/desktop-debug.log`. Packaged Tauri apps have no
devtools or stdout capture, so this is the only way to see what
the webview did when a user reports a bug. Falls back to plain
console.log when the command isn't registered (older shell).
mountDesktop() now installs the menu listener and dlog's its own
start — useful as a "did the desktop module even load" smoke marker.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The marketing nav (Home/Docs/Examples/Pricing/Blog/GitHub/Discord) and
the LandingPage hero are great for velxio.dev visitors but become
clutter once the SPA ships inside a Tauri shell — the user installed
the desktop app to land in the editor, not to read about the project.
Two small VITE_DESKTOP gates handle this:
- AppHeader.tsx hides the <nav> + the mobile hamburger that toggles
it. The brand, language switcher, auto-save indicator, share
button, and the pro overlay's auth slot all stay visible — they
carry real per-session info, not navigation.
- App.tsx swaps the `/` route's element for a <Navigate to=/editor>
so first-launch (and any future `velxio://` deep-link that lands
on `/`) goes straight to the editor.
Equivalent actions for the items being hidden live on the native
menubar that the velxio-prod overlay builds via
pro/desktop/src-tauri/src/menu.rs.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Real digital storage scopes have a trigger that pins the visible window
around a detected edge — without it, sparse activity (UART bytes once
per loop, an interrupt firing every few seconds) scrolls off the screen
faster than the eye can catch. Velxio's scope was free-running only,
which made the recent UART TX waveform work effectively invisible at
fine time/div settings: the byte burst was 87 µs but the window only
showed the most recent 1 ms.
Three trigger modes, matching what you'd find on a Rigol / Tektronix:
* Auto — current free-running behaviour, window's right edge
tracks the most recent sample. Default.
* Normal — window pins around each triggering edge so the event
lands at `triggerPosition * windowMs` from the left
(default centred at 0.5). Keeps re-pinning on every
new triggering edge.
* Single — arms once, freezes the trace on the first triggering
edge by flipping `running = false`. User clicks
"Re-arm" to capture again.
Three knobs configurable per mode:
- source: which channel produces the trigger event
- edge: rising (↑) / falling (↓) / either (⇅)
- position: trigger lands at this fraction of the window
(UI hard-codes centre 0.5 for now; the store field
accepts any value if we want a draggable handle later)
UI additions in the scope header (only shown when mode != auto):
- source / edge dropdowns
- status badge (Armed / Triggered / Captured) with pulse animation
on Armed so the user knows the scope is waiting for an event
- Re-arm button in Single mode after capture
Canvas changes:
- Dashed orange "T" marker drawn at the trigger position when an
edge is latched and within the visible window.
Store changes:
- pushSample peeks at the trigger channel's previous state, detects
a matching edge, sets triggeredAtMs (and stops `running` for
Single mode). matchesTriggerEdge() exported for unit testing.
- clearSamples / setTriggerMode / setTriggerChannel / setTriggerEdge
all re-arm the trigger; rearmTrigger() explicitly resets and resumes
capture (used by the Re-arm button after a single-shot).
Covered by 11 new vitest cases (oscilloscope-trigger.test.ts) plus the
existing 1892 tests still pass.
Closes the "I set 0.1 ms/div on a Serial.print sketch and see a flat
line" UX trap reported on the Discord follow-up — at 0.1 ms/div the
window is 1 ms but bytes fire every 2 s, so without a trigger the
chance of catching the burst is < 0.05 %. With Normal trigger on
rising D1 the burst pins in the middle of the window and the user can
zoom down to bit level (8.68 µs each) without losing it.
Velxio had two parallel import paths that confused users (reported on
Discord by AgUn / dmontero):
* Toolbar "Import a project from a .zip file" → Wokwi .zip only
* File-explorer "Open .vlx file" → Velxio .vlx only
If you exported a Velxio project as .vlx and tried to bring it back via
the toolbar Import button, you bounced off "wrong format" with no hint
that the .vlx loader was hiding behind the file-explorer save-bar.
Fix: introduce `utils/importProject.ts` as the single dispatcher. It
sniffs the extension and routes:
*.vlx → importVlxFile (writes directly to stores)
*.zip → importFromWokwiZip (returns a payload the caller applies,
so the toolbar can still trigger the
install-libraries modal afterwards)
Both UI entry points now go through the dispatcher with the same
`accept=".vlx,.zip,application/json,application/zip"` filter:
* Toolbar "Import project (.vlx Velxio or .zip Wokwi)"
* File-explorer "Open project (.vlx Velxio or .zip Wokwi)"
The toolbar tooltip is i18n-driven — updated EN + 8 other locales
(es, fr, de, it, pt-br, ja, ru, zh-cn) so every user sees the same
clarification.
Wokwi compatibility kept intact — the .zip path still resolves to
`importFromWokwiZip` and the same library-install modal pops if the
imported project lists libraries we don't have locally.
Closes the same gap as the AVR / RP2040 commits — qemu-lcgamboa's UART
transmits the byte over the WebSocket as a 'serial_output' event with no
GPIO toggle, so an oscilloscope on the ESP32 TX pin saw nothing while
real silicon would render the 8N1 frame at the configured baud rate.
Two changes inside Esp32Bridge:
* New `onPinChangeWithTime: (pin, state, timeMs) => void` callback
that hooks the oscilloscope at parity with AVRSimulator /
RP2040Simulator. The 'gpio_change' event now also flows through it
(timestamped with `performance.now()` — QEMU virtual time isn't
surfaced across the wire, but at 1× sim speed the wall-clock skew
is invisible on any practical sweep). This also fixes the broader
issue that ESP32 boards previously couldn't show ANY digital GPIO
activity on the scope.
* `emitUartTxFrame(byte, uart)` synthesizes start + 8 data LSB-first
+ stop transitions at `this.uartBaudRate` (default 115200) on the
UART0 TX pin, mapped per board variant:
esp32 / esp32-devkit-c-v4 / esp32-cam / wemos-lolin32-lite: GPIO1
esp32-s3 / xiao-esp32-s3 / arduino-nano-esp32: GPIO43
esp32-c3 / xiao-esp32-c3 / aitewinrobot-esp32c3-supermini: GPIO21
Backend doesn't expose the live baud rate so we default to 115200
(the Arduino default). Override path: bridge.uartBaudRate = N
once we surface Serial.begin's argument via a backend event.
Wire-up: `bridge.onPinChangeWithTime = getOscilloscopeCallback(boardId)`
inside the three Esp32Bridge construction sites in useSimulatorStore
(setBoardType, addBoard, changeBoard).
Same gap as the AVR USART: rp2040js's UART fires `onByte(value)` per
transmitted byte but never toggles the corresponding GPIO, so an
oscilloscope on GP0 (UART0 TX, default for Arduino-Pico's Serial1) sees
nothing during `Serial.print`. Real silicon drives the pin with the
full UART frame at the configured baud rate, and Velxio should match.
`emitUartTxFrame(uartIdx, byte)` derives:
* `txPin` via FUNCSEL inspection: walk GP0 / GP12 / GP16 / GP28 (the
four candidates for UART0 TX per RP2040 datasheet) and pick the
first whose `functionSelect == 2` (FUNCTION_UART). Same for UART1.
Fall back to GP0 / GP4 when nothing is mapped (firmware hasn't
called `Serial1.begin()` properly).
* `baudRate` and `bitsPerChar` directly from the UART peripheral
(rp2040js already exposes these as live getters).
* Time from the RP2040 IClock's `nanos` counter, matching the
existing `setupGpioListeners` path — UART waveforms therefore stack
consistently with PIO / SIO traces on the same scope.
Both `uart[0].onByte` and `uart[1].onByte` get hooked. The seed-idle-
HIGH baseline is pushed once per UART per simulation run; `stop()`
clears the flag so a re-run gets a fresh seed (matching how the scope
buffer is cleared on restart).
avr8js intercepts the transmitted byte at the UDR0 register and never
toggles the corresponding GPIO. Real ATmega328P / ATmega2560 hardware
drives PD1 / PE1 with a start bit, 8 data bits LSB-first, and a stop bit
at the configured baud rate the moment TXEN is set. An oscilloscope
probe on D1 therefore showed nothing in Velxio while the same probe in
the real world would resolve the UART frame.
Synthesize the frame from the inside of `onByteTransmit`:
* Read `usart.baudRate`, `usart.bitsPerChar`, `usart.parityEnabled`,
`usart.parityOdd`, `usart.stopBits` so unusual configurations stay
accurate (avr8js already exposes these as public getters).
* Build the bit list start + data(LSB first) + parity? + stopBit(s).
* For each transition vs. previous state (initial = idle HIGH), call
`onPinChangeWithTime(1, state, timeMs)` where
`timeMs = (cpu.cycles + i * cyclesPerBit) / 16_000`. Same
simulator-time clock the existing port-listener path uses, so the
scope draws the UART waveform cycle-accurately alongside other GPIO
activity.
Also hook `onConfigurationChange` to detect TXEN flipping 0→1 and seed
the scope baseline at idle HIGH; without that, the very first byte's
start bit transition would be invisible because the scope's pre-first-
sample default is LOW.
Both USART construction sites (initial setupSimulation around line 423,
re-init after stop around line 749) get the same hook.
Covered by `__tests__/avr-uart-tx-waveform.test.ts` (5 cases): idle seed,
byte with internal transitions, 0xFF edge case, TXEN-disabled no-op,
bit-period timing.