Commit Graph

484 Commits

Author SHA1 Message Date
David Montero 467ca4455f fix(canvas): wires off pins after rotation — wrapper offset was (4,6) instead of (6,6)
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.
2026-05-26 15:16:04 +02:00
David Montero Crespo 161335a5cf test(desktop): unit tests for bannerFor + suppress redundant banner in lockout
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>
2026-05-25 12:05:39 -03:00
David Montero Crespo 6f63fceb20 feat(desktop): paywall v0.3.0 - LockoutOverlay + staged expiry banners
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>
2026-05-24 21:16:48 -03:00
David Montero 9e9a3e7800 fix(esp32): use JS template literals (backticks) for stub interpolation 2026-05-25 00:17:47 +02:00
David Montero 8a89322808 feat(esp32): smart WiFi/HTTP stubs so MicroPython examples actually work
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.
2026-05-25 00:15:47 +02:00
David Montero 835ca6d7a8 fix(esp32): stub network + ntptime modules for MicroPython in QEMU
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.
2026-05-23 23:13:20 +02:00
davidmonterocrespo24 e4ecefe46a feat(desktop): skip welcome screen, robust openExternal, in-app nav
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>
2026-05-23 17:57:49 -03:00
David Montero cf28b3b5ea fix(esp32): auto-enable WiFi NIC for MicroPython sketches using network module
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.
2026-05-23 22:54:10 +02:00
David Montero b09c8339ac fix(examples): use hardware I2C (not SoftI2C) in smart-ui-eyes
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.
2026-05-23 18:06:42 +02:00
David Montero 76f6cd9a37 fix(avr/serial): queue RX bytes so Serial.readStringUntil sees the whole input
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.
2026-05-23 17:50:19 +02:00
David Montero 0d43c5f892 fix: relax -Werror for user sketches + un-nest /* */ in robot-desktop-eyes
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.
2026-05-23 16:19:56 +02:00
David Montero 1a6e2a23a7 fix(examples): auto-install libs for robot-desktop-eyes
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.
2026-05-23 08:49:26 +02:00
David Montero 2f60e3816a fix(editor): surface QEMU compile failure to the user instead of silent warning
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.
2026-05-23 08:39:26 +02:00
David Montero dbab794ddb test(snapshots): refresh smart-ui-eyes netlist after circuit was wired
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`.
2026-05-23 08:28:08 +02:00
David Montero 2dea3f0448 fix(examples): correct SSD1306 + big-sound-sensor pin names
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.
2026-05-23 08:23:15 +02:00
David Montero 5bd541e56f fix(examples): add OLED + buttons circuit to ESP32 smart-ui-eyes example
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
2026-05-23 08:16:13 +02:00
davidmonterocrespo24 28826e928e feat(examples): import robot_desktop — ESP32 animated-eyes face
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>
2026-05-23 00:29:25 -03:00
David Montero f619a2cc7e feat(boards): expose Raspberry Pi 4 and Pi 5 in the picker (UI + pin wiring)
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.
2026-05-23 04:46:59 +02:00
davidmonterocrespo24 2146b09c29 feat(desktop): hide header strip, splash screen, native locale switcher
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>
2026-05-22 19:39:22 -03:00
David Montero 8c58d2a1a7 fix(epaper/esp32): preserve PartSimulationRegistry sensors in setSensors + correct BUSY polarity per controller family
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.
2026-05-22 23:32:33 +02:00
davidmonterocrespo24 30d0da430b feat(desktop): native menubar bridge + best-effort file logger
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>
2026-05-22 18:08:46 -03:00
davidmonterocrespo24 54adac3ae3 feat(desktop): hide web nav + redirect / → /editor in Tauri builds
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>
2026-05-22 18:08:33 -03:00
David Montero aab380e80b feat(scope/trigger): add Auto / Normal / Single-shot trigger modes
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.
2026-05-22 21:11:03 +02:00
David Montero 737ec5c6eb feat(import): unify project import — accept .vlx and .zip in both entry points
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.
2026-05-22 20:55:06 +02:00
David Montero 1a0877f2af feat(esp32/uart): synthesize bit-level TX waveform on UART0 TX GPIO
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).
2026-05-22 19:58:24 +02:00
David Montero 6584a49a8f feat(rp2040/uart): synthesize bit-level TX waveform on GP0 / GP4
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).
2026-05-22 19:54:55 +02:00
David Montero b587faf1b0 feat(avr/uart): synthesize bit-level TX waveform on PD1/PE1
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.
2026-05-22 19:49:43 +02:00
David Montero 2eb195d671 test(joystick): update fixture to direction-style xValue / yValue
The previous test fed `xValue: 0, yValue: 1023` to the analog-joystick
handler and asserted X=0V, Y=5V. That was wrong for two reasons:

  1. wokwi-analog-joystick emits direction (-1/0/+1), never 0..1023, so
     the fixture didn't match how the real component behaves.
  2. The OLD `(value/1023) * vcc` mapping happened to produce 0V for 0
     and 5V for 1023, so the broken test still passed against the
     broken handler — and now breaks against the correct one.

Switch the fixture to `xValue: -1, yValue: 1` so the expectations line up
with the corrected handler (-1 → 0V, +1 → Vcc) and exercise the same
voltage rails the real component will produce.
2026-05-22 18:59:49 +02:00
David Montero e6fdd54ba3 fix(joystick): map wokwi-analog-joystick direction (-1/0/+1) to ADC voltage
The PartSimulationRegistry handler for 'analog-joystick' was reading
`el.xValue` / `el.yValue` and computing `(value / 1023) * vcc` as if the
component were a potentiometer producing a raw 0..1023 reading.  It is
not — `@wokwi/elements/analog-joystick-element` emits xValue / yValue as
a tri-state DIRECTION signal:

  *   xValue = -1  → "left"   (mousedown on left zone)
  *   xValue =  0  → centered (mouseup snap-back)
  *   xValue = +1  → "right"  (mousedown on right zone)
  (same for yValue with up/down)

`(±1) / 1023 ≈ ±0.001`, so the ADC channel sat at ~0 V no matter which
directional zone was clicked.  Center-button clicks worked because that
path is digital (`setPinState(SEL, …)`) and bypasses the analog map.

Fix:
  * Tri-state → voltage with explicit map: -1 → 0V, 0 → Vcc/2, +1 → Vcc.
  * Vcc was hardcoded to 5V for "not RP2040" — wrong for ESP32 / S3 /
    Nano-ESP32 / etc., which all run at 3.3V like the Pi Pico.  Detect
    ESP32 via the BridgeShim's `setAdcVoltage` method and select 3.3V
    for everything that isn't pure AVR.

Reported on /example/esp32-joystick where center-button-only worked but
directional zones did nothing.  Verification via Chrome MCP after deploy.
2026-05-22 18:57:26 +02:00
davidmonterocrespo24 0dcb504c1b feat(landing): "Try Simulator Free Online" + download CTA slot below
Two coordinated copy/layout changes on the landing hero:

  - Primary CTA label gets "Online" added across the 9 supported
    locales — "Try Simulator Free Online →" / "Probar el simulador
    online gratis →" / etc. Reason: with the Velxio Desktop
    download path now live, users should immediately understand
    that the green button is the BROWSER version, and there's a
    local install option for people who want it faster offline.

  - The pro-overlay slot for the desktop download CTA was
    `landing-hero-primary-cta` and sat ABOVE the hero CTAs. Renamed
    to `landing-hero-download-cta` and moved BELOW so the funnel
    reads "try online (primary) → or install locally (secondary)"
    instead of "install locally (primary) → try online (secondary)".

OSS layout is unchanged — the slot is still empty in pure builds.

Also picks up the auto-regenerated sitemap.xml lastmod dates from a
recent deploy (every URL bumped 2026-05-19 → 2026-05-21).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 03:33:21 +02:00
davidmonterocrespo24 04d93d4f46 feat(frontend): desktop overlay (welcome, grace banner, ESP32 prompt)
New `frontend/src/desktop/` module loaded only when VITE_DESKTOP=true.
Hosts the Tauri-bound UI that pure OSS doesn't need:

  - tauriBridge.ts   typed invoke / listen / openExternal / beginSignIn
                     wrappers with no-op fallbacks for `vite dev` outside
                     the Tauri webview.
  - DesktopWelcomePage.tsx   sign-in flow with browser handoff +
                              paste-key fallback; listens for
                              `velxio://auth-completed` from the shell.
  - GraceBanner.tsx          renders soft/hard grace banners driven by
                              `license_status` + `velxio://license-status`
                              emits from the background checkin loop.
                              Toggles `body.vlx-desktop-readonly` so the
                              editor's Save / Compile buttons disable
                              themselves via CSS in hard-grace.
  - Esp32QemuPrompt.tsx      one-time download modal when the user picks
                              an ESP32 board on a fresh install.
  - index.ts                 mounts welcome conditionally and the side
                              panels unconditionally.
  - desktop.css              shared styles.

All entry points are no-ops outside Tauri so the existence of the
folder has zero effect on the OSS build.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 04:50:51 +02:00
davidmonterocrespo24 24f84442e8 feat(frontend): runtime API base + desktop overlay extension points
Adds `lib/apiBase.ts` so the SPA can be repointed at a non-default backend
at runtime (via `window.__VELXIO_API_BASE__`) without losing the existing
`VITE_API_BASE` build-time override or the default `/api` reverse-proxy
behaviour. compilation / libraryService / projectService / metricsService
all flow through it now; axios clients use a request interceptor so the
base resolves per-request rather than at module-load time.

main.tsx grows a `VITE_DESKTOP` flag: when set, the @pro overlay is
skipped (the desktop shell handles license + auth natively) and a
small `./desktop/index` module is dynamic-imported in its place. OSS
builds tree-shake both branches.

LandingPage gets a `data-velxio-slot="landing-hero-primary-cta"` marker
above the existing hero CTAs so velxio.dev can inject an OS-detect
"Download Velxio Desktop" button as the visual primary. The slot is
empty in pure OSS.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 04:50:19 +02:00
David Montero be24243e4b fix(esp32): add GPIO 16/17 pin aliases on DevKit V1 (RX2/TX2)
On the ESP32 DevKit V1 the silkscreen labels GPIO 16 / 17 as RX2 / TX2,
and Esp32Element.PINS_ESP32 only exposed the silkscreen names. Examples
that wire to numeric pin "16" or "17" (e.g. ledcAttach(16, 5000, 8) on
esp32-pwm-led-rgb) couldn't resolve those names — pinPositionCalculator
failed lookups, the wire endpoint fell back to (0,0)/(50,50) and the
LED component visually floated off the board, breaking the SPICE
netlist for the example.

Add "16" and "17" as aliases pointing to the same (134,143) / (134,131)
coordinates as RX2 / TX2 so both naming conventions resolve to the same
physical pin tip.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 21:16:33 +02:00
David Montero Crespo 4ff71765e7
Merge pull request #202 from davidmonterocrespo24/fix/circuit-sim-service-stop
fix(circuit-sim): stop() guard + drop edges with no V-source post-reb…
2026-05-19 22:52:08 -03:00
David Montero Crespo f5ae4853eb fix(circuit-sim): stop() guard + drop edges with no V-source post-rebuild
Two related bugs that surfaced as "Vitest worker exited unexpectedly /
Timeout terminating forks worker" on the circuit-simulation-service
test file.

Bug 1 — tick() recursively re-schedules itself in its finally block.
After afterEach disposes the scheduler via __resetMixedModeScheduler(),
those re-scheduled ticks throw "call loadCircuit first", get caught by
the console.warn, and the finally schedules ANOTHER tick. Infinite
Promise loop survives until the worker OOMs.

  Fix: add CircuitSimulationService.stop() that flips a `stopped` flag
  short-circuiting tick() + handleMcuEdge(). The test harness now
  tracks each started service in _activeServices and calls stop() in
  afterEach alongside the existing unsubscribe sweep.

Bug 2 — when an MCU edge fires on a pin that's NOT wired into any net
(buildNetlist skips it because netLookup returns null), handleMcuEdge
sees hasSource=false, self-heals by queueing the edge + tick().  The
rebuild still doesn't emit the V-source (no wire), so tick.finally
replays the edge → self-heal again → tick again → infinite loop AT
RUNTIME, not just in tests. A user toggling a digital pin without a
wire freezes the whole circuit simulation.

  Fix: in tick.finally's pendingMcuEdges replay loop, check whether
  the rebuilt netlist now contains a V-source for each pending edge's
  pin. If not, drop the edge silently — a future canvas tick triggered
  by adding the wire will pick it up via the normal subscription path.

Also fix the "coalesces an edge with an in-flight full solve" test
fixture: simpleBoardWithBoard leaves pin 9 unwired, so V_uno_9 was
never emitted and the test was racing the (now-bounded) self-heal
rebuild. Replaced with an inline fixture wiring pin 9 → resistor →
GND, mirroring the wired fixture used by the alter+republish test
right above it.

Full vitest --shard 1/2 + 2/2 pass cleanly (1886 tests, 22-29s per
shard) with no worker-exit warnings.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 03:49:29 +02:00
David Montero Crespo 95a60e1d01 test: align photoresistor-sensor fixtures + snapshots with 603b791 alias
Three failures introduced by 603b791 (which added
MAPPERS['photoresistor-sensor'] = MAPPERS['photoresistor'] so the
metadata-id 'photoresistor-sensor' resolves to a SPICE mapper):

- component-to-spice.test.ts "every mapped metadataId has a test fixture"
  flagged photoresistor-sensor as missing. Added a fixture entry that
  mirrors the photoresistor one — the part is electrically identical.

- examples-netlist-snapshot.test.ts > photoresistor-light and
  > nano-sensor-station snapshots now contain R_ldr_ldr + R_ldr_pull
  cards (correct LDR + 10k pull) instead of the previous
  R_autopull_n0 100M stub. This is the intended behaviour change:
  before the alias the LDR was unmapped and the netlist autopulled the
  net to ground with a 100M dummy; after the alias the SPICE deck
  carries the real divider topology. Regenerated only these two
  snapshot entries (vitest -u on the single file).

No production code changes.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-19 18:29:21 -03:00
David Montero Crespo 603b791daa feat(attiny85+customchip): full ATtiny85 ADC/Timer0 + custom-chip pipeline fixes
ATtiny85 (AVRSimulator + collectPinStates + connectAnalogInputsToMcu + SimulatorCanvas + Attiny85Element + examples):
- Add attiny85AdcConfig with correct register addresses (ADMUX=0x27,
  ADCSRA=0x26, ADCSRB=0x23, ADCL=0x24, ADCH=0x25, DIDR0=0x34, adcInterrupt=0x08).
  Without this, analogRead() polled the wrong address forever and the
  firmware hung on first ADC read.
- Add attiny85Timer0Config + instantiate AVRTimer so OVF fires at the
  ATTinyCore-expected ~1.024 ms cadence. delay() advance is still blocked
  on avr8js TIFR auto-clear semantics (separate upstream issue, see
  ATTINY85_TIMER0_UPSTREAM_ISSUE.md in velxio-prod test plan).
- Map ATtiny85 ADC channels to PB-style pin names (PB5/PB2/PB4/PB3 -> 0..3)
  in connectAnalogInputsToMcu so SPICE node voltages reach the right ADC
  channel.
- Recognise /^PB\d+$/ in collectPinStates.pinNameToArduinoPin so wires
  named "PB1" emit v_attiny85_pb1 V-source and the LED responds to MCU
  writes. Previously every PB-wire returned -1 and SPICE saw no source.
- SimulatorCanvas: subscribe pin 1 (PB1) for the built-in LED on the
  attiny85 board kind (Digispark convention), instead of falling through
  to the pin-13 default.
- Attiny85Element: remove the hand-drawn "yellow LED" circle that was
  floating above the chip. The bare DIP-8 has no on-board LED; examples
  wire a real wokwi-led + resistor instead.
- examples.ts: add a real wokwi-led + 220 Ohm wokwi-resistor + wires to
  attiny85-blink, and add missing series resistors to attiny85-button-led
  and attiny85-ntc-sensor. attiny85-pwm-fade was already correct.

Custom-chip pipeline (CustomChipPart + simulatorBridges):
- Add a requestAnimationFrame loop that calls instance.tickTimers() every
  frame in CustomChipPart. Chips that register vx_timer_create (e.g. an
  i8080 stepping its core, or a sensor publishing samples) had timers
  added to the queue but nothing fired them; tickTimers was dead code.
- Gate the ESP32 backend path with detectSimulatorKind(sim)==='esp32'.
  The previous `typeof sim.registerSensor === 'function'` check matched
  AVR and RP2040 simulators too (they expose registerSensor for I2C
  sensor proxies), routing client-side chips to a non-existent ESP32
  worker on those boards.
- Replace direct simulator.usart.writeByte calls in avrUartTx with a
  JS-level FIFO + setTimeout(1ms) drainer. avr8js writeByte drops bytes
  under burst load (a chip emitting print_string lost ~99% of bytes via
  non-immediate, or kept only the last byte via immediate). The drainer
  attempts one non-immediate write per tick and retries on RXC busy /
  RXEN off. Added a guard for ATtiny85 (no USART -> would queue forever).

End-to-end verified: i8080-banner-streamer now prints the boot banner
followed by "uptime ticks: 0xNN" lines stepping every ~50 ms, executing
real Intel 8080 instructions inside the WASM chip.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-19 18:10:08 -03:00
David Montero Crespo a9285c6698 fix(ui): narrow pointer-passthrough whitelist; restore DHT22/HC-SR04 dialog
The previous fix (dd22bcf) used `isInteractive` to decide whether to let
the wokwi component own the pointerdown. That heuristic was too broad —
DHT22, HC-SR04, NTC, photoresistor, LED all register `attachEvents` for
the SPICE/sensor-update bridge but have NO internal pointer handlers, so
clicks on them got silently swallowed by the wokwi shadow DOM and the
property dialog never opened.

Replace with an explicit whitelist of wokwi tags that ACTUALLY own
pointerdown (rotary knobs, pushbuttons, slide switches, joysticks,
keypads, encoders, rotary dialer). Every other component, including
sensors/displays/LEDs with attachEvents, falls through to the canvas
which decides between drag-to-rearrange and click-to-open-dialog.

Documented the model in docs/wiki/component-interaction.md.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-19 15:16:47 -03:00
David Montero Crespo dd22bcfe50 fix(ui+spice+example): interactive wokwi components, NTC formula, photoresistor alias
Three independent fixes uncovered during a systematic example-by-example
audit (plan/full_test_plan/):

1. DynamicComponent.handleMouseDown was calling e.stopPropagation()
   unconditionally in the capture phase. That swallowed pointerdown
   BEFORE wokwi-potentiometer / pushbutton / slide-switch / joystick
   could see it, so the rotary knob would not rotate and buttons
   wouldn't press even with a real OS mouse. Now we skip the swallow
   when the click target is an inner wokwi-* element during a live
   simulation, letting the wokwi component own its own pointerdown
   while still allowing the canvas drag-to-rearrange flow on the
   wrapper / non-interactive surface.

2. examples.ts uno-ntc (and pico-ntc) sketch had the NTC divider
   formula inverted relative to both the SPICE mapper topology
   (VCC -> R_NTC -> A1 -> R_pull -> GND, the standard module wiring)
   and real wokwi-ntc-temperature-sensor modules. Moving the slider
   to 60 C made the firmware print -3.42 C. Flipped the formula to
   r = SERIES_R * (VCC - v) / v. Now slider 60 C -> Serial reports
   60.12 C and A1 voltmeter shows 4.00 V.

3. componentToSpice.ts photoresistor mapper was only registered under
   the bare key `photoresistor`, but example components use the
   metadataId `photoresistor-sensor`. Added an alias so the LDR +
   pull-down divider gets emitted for the real component instance.

All three reproduce visually in seconds; documented per-example in
plan/full_test_plan/examples/.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-19 15:10:42 -03:00
davidmonterocrespo24 7f0f72862c fix: regenerate components-metadata + plug vitest worker leak
Two CI failures landed together on master after PR #194 merged:

1. **components-metadata.json stale.** The `power-supply` thumbnail
   in scripts/component-overrides.json was updated (grey placeholder
   → branded PSU SVG with voltage/current labels) but the generated
   JSON wasn't regenerated. The pre-merge check
   `git diff --quiet frontend/public/components-metadata.json` now
   fails on master. Fix: `cd frontend && npm run generate:metadata`,
   commit the result.

2. **Frontend Tests > test (20/22): vitest worker hang.**
   `circuit-simulation-service.test.ts` had been calling
   `service.start()` in ~10 tests without storing the returned
   unsubscribe handle. Each call subscribes the service to the
   simStore; the listener captures the service + scheduler in
   its closure. After all tests complete, vitest's forks pool
   tries to terminate the worker but the still-active listeners
   keep the event loop pinned, producing:
       "Worker exited unexpectedly / Timeout terminating forks worker"
   All assertions actually pass — only the worker shutdown hangs.

   Fix: introduce a `startTracked(service)` helper that records
   the unsubscribe in a module-level array, plus an `afterEach`
   that drains the array. `__resetMixedModeScheduler()` still runs
   after to dispose the scheduler singleton. Replaced all 9 raw
   `service.start()` callsites.

Both are independent of any production code change. The fix is
test/scaffolding only.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 15:38:22 +02:00
David Montero Crespo bd23a05d23
Merge pull request #195 from davidmonterocrespo24/feat/chip-programmable-rom
Feat/chip programmable rom
2026-05-19 02:28:49 -03:00
David Montero Crespo 321997715d feat(editor): Monaco syntax highlight for 8080/Z80 assembly
When the editor opens a .s or .asm file (the chip-program files routed
to /api/compile-rom), Monaco now colorizes 8080/Z80 mnemonics, registers,
hex/binary literals, comments, and directives. Same highlighter covers
both ISAs since most mnemonics overlap.

- frontend/src/components/editor/retroAsmLanguage.ts: a Monarch tokenizer
  + LanguageConfiguration + idempotent registration helper. Recognises
  the full 8080 ISA, all the Z80 additions (LD/JR/DJNZ/EXX/EX/IM/LDIR/
  bit ops/index ops), the directives ORG/DB/DW/EQU/END, and registers
  including condition codes (NZ/Z/NC/etc.) and IX/IY.

- CodeEditor.tsx: maps `.s` and `.asm` to the new `retro-asm` language
  and calls `registerRetroAsm(monaco)` in beforeMount so the language
  exists by the time the editor first paints. Other extensions
  (.ino/.cpp/.c/.py/.json/.md) behave exactly as before.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 00:49:51 -03:00
David Montero Crespo 0e2f0790db feat(chips): C-to-Z80 compile via SDCC + LED chaser example
Adds a third format to /api/compile-rom: `c` (C source compiled by SDCC
to Z80 bytes). Same chip-program flow as 8080/Z80 asm — write C in a
project file, click Compile, click Run.

Backend:
- backend/app/services/c_compile.py — async SDCC wrapper. Locates the
  sdcc binary on PATH (or via SDCC env var, or common Windows install
  paths) and shells out with target=mz80 + --code-loc 0x100 --data-loc
  0x8000. Parses the resulting Intel HEX into raw ROM bytes. Pure 8080
  is rejected with a clear error (SDCC has no 8080 backend; Z80 ROMs
  also run on the i8080-cpu chip if you avoid Z80-only ops).
- rom_compile.py: compile_rom is now async; the new c branch delegates
  to c_compile. compile_rom_endpoint awaits it.

Frontend:
- romCompileService: RomFormat gains 'c'; formatForFile maps .c/.cpp to
  'c'. isChipProgramFile intentionally still excludes .c — disambiguation
  happens at the EditorToolbar level.
- EditorToolbar: the chip-program path also fires when a custom-chip
  has programFile === activeFile.name (regardless of extension). That
  lets .c files route to /api/compile-rom (SDCC) when bound to a CPU
  chip, while .c files NOT bound to any chip continue to route to
  arduino-cli as before.

Docker:
- Dockerfile.standalone adds `sdcc` to the apt-get install list, so the
  prod image ships with SDCC out of the box.

Example:
- /examples/z80-led-chaser-c — z80-cpu chip + chaser.c (a Larson
  scanner written in C with __at() MMIO definitions). Compiles cleanly
  with SDCC's --code-loc 0x100 default crt0.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 00:31:10 -03:00
David Montero Crespo 96ef12b585 feat(chips): programmable Z80 chip + Larson scanner example
Adds the Zilog Z80 to the programmable-retro-CPU lineup. Same compile-rom
flow that landed for the 8080 in PR #189: write Z80 asm in a project
file, click Compile (backend assembles via in-tree two-pass asm-z80),
click Run, the chip emulator boots from the resulting ROM bytes.

Backend:
- backend/app/services/asmz80.py — two-pass Z80 assembler covering the
  practical demo subset: LD r,n / r,r' / rp,nn / (nn),A / A,(nn) +
  ALU r/n + INC/DEC + JP/JR/DJNZ/CALL/RET + PUSH/POP + IN/OUT +
  EX/EXX + LDIR/LDDR/IM/NEG + RLCA/RRCA/RLA/RRA + the simple
  ED-prefix variants. Not yet: CB-prefix bit ops, DD/FD index ops.
- rom_compile.py routes target=z80 through the new assembler.

Chip:
- frontend/src/components/customChips/examples/intel/z80-cpu.{c,chip.json}
  Generated by scripts/make-z80-cpu.py from the existing z80.c emulator
  (same clean-room implementation that passes ZEXDOC end-to-end). The
  external pin/bus protocol is replaced with internal RAM + ROM + MMIO
  for LED/BTN/UART. 35 KB WASM.

Example:
- /examples/z80-larson-scanner — Knight-Rider-style walking LED.
  Demonstrates JR/DJNZ/RLCA which the 8080 can't run.

Plus a small Z80 smoke-test asm under scripts/.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 00:21:08 -03:00
davidmonterocrespo24 666f9c4008 fix(tests): update mocks + assertions for PinManager API changes
PR #192 (spice-led-pipeline) added two PinManager changes that were
not reflected in the test mocks / assertions:

- `setPinState(pin, state)` gained an optional `source: 'mcu' |
  'external'` third arg. Production ESP32-C3 / RISC-V simulators
  now pass `'mcu'` to mark the call as an MCU output (so the SPICE
  collector emits a V-source). The esp32c3-blink and esp32c3-simulation
  tests asserted on the old 2-arg shape.
- `resetPinStates()` is a new public method on PinManager called by
  `stopBoard` / `resetBoard` to clear cached pin states. The mocks in
  esp32-integration.test.ts and multi-board-integration.test.ts did
  not add it, so any test that ran stopBoard hit
  `TypeError: getBoardPinManager(...)?.resetPinStates is not a function`.

This commit:
- Adds `'mcu'` to the two ESP32-C3 setPinState assertions.
- Adds `this.resetPinStates = vi.fn()` to both integration mocks.

These are pure test fixups — no production code touched. The
`circuit-simulation-service.test.ts > handleMcuEdge` failure
(`expected 1 to be 2`) is a separate regression in production code
introduced by PR #192 and is NOT fixed here.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 04:52:30 +02:00
David Montero Crespo bbf8cd0303 feat(chips): programmable retro CPU chips with external ROM
Adds a new way to use the retro CPU chips: write your program in a
project file (.s / .asm / .hex / .bin), click Compile, click Run, and
the same chip emulates whatever you wrote. Same chip + different ROMs =
mini PC, calculator, LED demo, Kill-the-Bit game, etc.

SDK:
- velxio-chip.h gets two new host imports:
    uint32_t vx_rom_size(void);
    void     vx_rom_read(uint32_t off, uint8_t* dst, uint32_t len);
  CPU-emulator chips call these in chip_setup to pull their program out
  of the host's romBytes property.

Frontend runtime:
- ChipRuntime accepts opts.romBytes (Uint8Array) and exposes the new
  imports, copying bytes into chip memory on vx_rom_read.
- CustomChipPart pulls component.properties.romBytes (base64) and passes
  it through.
- Component registry declares three new custom-chip properties:
  romBytes (base64), programFile (matching project filename), and
  programTarget (cpu name).

New programmable bundled chip:
- frontend/src/components/customChips/examples/intel/i8080-cpu.{c,chip.json}
  Same clean-room 8080 emulator as i8080-repl/i8080-counter, but ROM is
  loaded externally via vx_rom_*. Has 8 LEDs, 8 buttons, UART, 16 KB RAM,
  32 KB of external ROM.

Backend:
- New /api/compile-rom endpoint and rom_compile service that turns
  chip-program source into ROM bytes. 8080 ASM is assembled by the
  in-tree two-pass assembler (moved to backend/app/services/asm8080.py).
  Intel HEX records are parsed; raw .bin is passed through. Future targets
  (z80, 8086, 4004) are scaffolded but not wired yet.

EditorToolbar:
- Compile button detects when the active file is .s/.asm/.hex/.bin and
  routes to compile-rom instead of arduino-cli. The compiled bytes are
  injected into every custom-chip on the canvas whose programFile property
  matches the active filename (or is empty).

Example:
- /examples/i8080-killbits loads Dean McDaniel's 1975 Kill-the-Bit on
  the programmable i8080-cpu chip. killbits.s is shipped as a project
  file alongside sketch.ino; the user clicks Compile then Run and the
  LED walks across 8 outputs, buttons kill it.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 23:38:18 -03:00
David Montero Crespo ca1bf00597
Merge pull request #193 from davidmonterocrespo24/esp32-cleanup-broadcast-pwm
refactor(esp32): retire ledc_update + broadcastPwm + channelGpioMemo
2026-05-18 23:23:05 -03:00
davidmonterocrespo24 ba59fd4b4a refactor(esp32): retire ledc_update + broadcastPwm + channelGpioMemo
The SignalRouter path has been in prod through Phase 2.5 / Phase 3.3
deploys without regressions, so the temporary fallback shipped in
commit 77bf897 can come out. Closes #101.

Backend (esp32_worker.py + esp32_lib_manager.py):
- Stop emitting `ledc_update` from the 0x5000 LEDC callback and from
  the polling thread. Only `ledc_duty` (channel + duty_pct) and the
  GPIO matrix routing events ship now.
- Drop the channel→gpio reverse-lookup that fed the legacy event.

Frontend:
- Delete `PinManager.broadcastPwm` and `PinManager.pwmListenerPinCount`.
- Delete `makeLedcUpdateHandler` + its `channelGpioMemo`.
- Delete `Esp32Bridge.onLedcUpdate` field + the `case 'ledc_update':`
  message handler + the `LedcUpdate` type.
- Strip `this.onLedcUpdate = null` from 14 test mocks.
- Rewrite the `does not call broadcastPwm` guard in
  esp32-multi-servo-gpio-matrix.test.ts to assert the method itself
  no longer exists on PinManager (stronger regression guard than the
  spy version, and doesn't need vi).
- Remove the `PinManager.broadcastPwm fallback` describe block from
  esp32-servo-pot.test.ts — every test in it exercised the deleted
  fallback path.

Docs (ESP32_EMULATION.md):
- Replace `ledc_update` rows in the events / implementation tables
  with the SignalRouter trio (`ledc_duty`, `gpio_routing`,
  `gpio_routing_clear`).
- Update the visual flow diagram + the "why this matters" paragraph
  to past-tense the broadcastPwm bug.

Tests: 1886 frontend tests pass (the previously-failing
board-kinds-coverage test that needed the new Pi Zero/1/2 kinds is
also green). Backend unit suite: 279 pass, the 11 espidf_real_paths
prereq failures are environment-dependent (need arduino-cli libs in
the local shell) and unrelated.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 04:05:34 +02:00
David Montero Crespo 55b3dd29e5 fix(DynamicComponent): pass componentId through to PinTracer
`PinTracer` signature is `(componentId, componentPinName) => number | null`
but the local `getArduinoPin` lambda only accepted one arg and used the
closure-captured `id`. When `createDefaultPinResolver` passed both args
(per the typed signature), JS bound the FIRST arg (the componentId) into
the lambda's single `componentPinName` parameter. `traceDetailed` then
looked up a pin literally named "rgb-led-1" on component "rgb-led-1",
returned null, and the resolver locked itself into 'FLOATING' state —
its onChange path never subscribed and the wokwi-rgb-led element's
ledRed/ledGreen/ledBlue stayed at 0 forever even as the SPICE side
correctly cycled through R, G, B, Y, C, M, W via analogWrite().

Same bug latent for any multi-pin component that goes through the
PinResolver path (multi-pin LEDs, RGB strips, 7-seg drivers, anything
that calls `getPinResolver(<pinName>)` for several pin names).

Fix: lambda now accepts both shapes — `getArduinoPin(pinName)` (legacy
single-arg used by every PartSimulationRegistry handler) AND
`getArduinoPin(componentId, pinName)` (PinTracer 2-arg form used by
createDefaultPinResolver / createSpiceResolvedPinResolver). Picks the
right componentId in either case.

Verified via the rgb-led example: ledRed/ledGreen/ledBlue now cycle
0→255→0 in sync with the SPICE node voltages on pins 9/10/11.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-18 22:08:06 -03:00
David Montero Crespo 81837eedb9 fix(spice+pipeline): LED visualization, INPUT_PULLUP, ESP32-C3, PWM fade, examples
End-to-end pipeline fixes uncovered while auditing the /examples gallery.
Each bug shipped past green unit + snapshot tests because none of those run
firmware + render LEDs. Added scripts/visual-led-test.mjs as a CDP-driven
visual harness that loads each example, runs the simulator, samples
`wokwi-led.brightness`, and asserts toggle / gradient / initial-off
invariants — exits non-zero on any regression.

Frontend simulator
- PinManager.updatePort: new optional ddrMask param. A pin is added to
  `outputPins` only if the DDR bit is set, so the PORTx write that
  enables INPUT_PULLUP (DDR=0, PORT=1) no longer falsely marks the pin
  as MCU output. AVRSimulator now reads DDRB/C/D (0x24/0x27/0x2A on
  Uno/Nano, 0x37 on ATtiny85, per-port table on Mega) and forwards it.
- AVRSimulator: pass DDR mask alongside every port-listener fire.
- BasicParts pushbutton{,-6mm}: seed pin HIGH in attachEvents so
  `digitalRead()` returns HIGH while idle. avr8js doesn't auto-simulate
  INPUT_PULLUP — without this the firmware reads LOW from boot and
  thinks the button is permanently pressed (the "LED is always on,
  pressing does nothing" UX bug).
- connectMcuEdgesToService: suppress synthetic digital edges on pins
  with active PWM, AND subscribe to onPwmChange to re-tick the netlist
  on duty changes. Fade-LED now produces a true gradient (6 distinct
  brightness levels across a fade cycle) instead of a binary 0/full
  toggle.
- CircuitSimulationService.handleMcuEdge: replace single-slot
  pendingMcuEdge with a per-pin Map. Multiple pins toggling during the
  same in-flight tick used to overwrite each other; now every pin's
  most-recent edge replays after the tick. Fixes Traffic-Light RED→
  YELLOW→GREEN sequencing.
- NetlistBuilder: new sanitizeSpiceId() helper replaces hyphens with
  underscores in V-source names. ngspice's interactive `alter` command
  treats `-` as an operator and silently no-ops on hyphenated source
  names, so mid-simulation MCU pin transitions stopped propagating
  after the first solve. MixedModeScheduler.onMcuPinChange and
  CircuitSimulationService self-heal use the same sanitizer so names
  stay consistent across emit/alter/lookup. Also added a regex-based
  fallback in step 2 so any board pin matching `GND.\d+` canonicalises
  to net "0" — ESP32-C3 dev kits expose up to 10 GND pins and the
  per-board `groundPinNames` list missed several, leaving wires
  floating instead of grounded.
- collectPinStates: emit V-sources only for pins in `outputPins`, not
  every wired board pin. Leaves INPUT pins (analog sensors on A0,
  pull-down dividers, etc.) free for the SPICE solver instead of being
  shorted to 0 V by an ideal MCU V-source.
- start.ts: extended __spiceDebug to also expose outputPinsByBoard +
  nodeVoltages + pinNetMapEntries for the visual harness.
- ESP32 / RP2040 / RISC-V / C3 simulators: pass `'mcu'` source flag to
  triggerPinChange / setPinState so the new outputPins tracking fires
  on those boards too (was AVR-only before).
- useSimulatorStore: stopBoard/resetBoard call pm.resetPinStates() so
  outputPins clears between runs; Esp32Bridge.onPinChange passes the
  `'mcu'` flag in all three places it's wired.
- types/board.ts: ATtiny85 FQBN `clock=internal16mhz` →
  `clock=16pll` (ATTinyCore 1.5.2 renamed the option).

Backend
- esp-idf-template/main/CMakeLists.txt: skip the
  `-DLED_BUILTIN=2` fallback for esp32c3 and esp32s3 targets. Both
  variants already define LED_BUILTIN in pins_arduino.h via a
  self-define macro (`#define LED_BUILTIN LED_BUILTIN` + `static const
  uint8_t LED_BUILTIN = ...;`). Pre-defining the symbol from the
  command line expanded the static-const declaration to
  `static const uint8_t 2 = ...;` — a syntax error that broke every
  ESP32-C3 / S3 build (`expected unqualified-id before numeric
  constant`).

Examples
- examples.ts: bulk-fix 72 wire endpoints that referenced
  `componentId: 'nano-rp2040'` / `'esp32-c3'` etc. (boards that don't
  exist on the canvas). Replaced with `'arduino-uno'` (the canvas
  board-id convention) and converted `D<n>` pin names to `GP<n>` for
  Pico-style boards. Affects pico-blink, pico-i2c-scanner,
  pico-i2c-rtc-read, pico-spi-loopback, c3-blink and others.

Tests
- scripts/visual-led-test.mjs: CDP-driven harness. Default suite covers
  Blink (single-pin), Button (idle-OFF invariant — catches the
  INPUT_PULLUP regression), Traffic-Light (multi-pin sequencing),
  Fade-LED (PWM gradient — ≥3 distinct levels), RGB-LED (≥3 PWM pins
  driven). Run via `npm --prefix frontend run test:visual` against a
  Chrome on `:9222` + vite on `:5174` + backend on `:8001`.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-18 21:52:07 -03:00