velxio/backend/app/services/esp32_worker.py

1944 lines
96 KiB
Python
Raw Normal View History

#!/usr/bin/env python3
"""
esp32_worker.py Standalone ESP32 QEMU subprocess worker.
Runs as a child process of esp32_lib_manager. Loads libqemu-xtensa in its
own process address space so multiple instances can coexist without DLL state
conflicts.
stdin line 1 : JSON config
{"lib_path": "...", "firmware_b64": "...", "machine": "..."}
stdin line 2+: JSON commands
{"cmd": "set_pin", "pin": N, "value": V}
{"cmd": "set_adc", "channel": N, "millivolts": V}
{"cmd": "set_adc_raw", "channel": N, "raw": V}
{"cmd": "set_adc_waveform", "channel": N, "samples_u12_b64": "<base64-LE-uint16>", "period_ns": P}
{"cmd": "uart_send", "uart": N, "data": "<base64>"}
{"cmd": "set_i2c_response", "addr": N, "response": V}
{"cmd": "set_spi_response", "response": V}
{"cmd": "stop"}
stdout : JSON event lines (one per line, flushed immediately)
{"type": "system", "event": "booted"}
{"type": "system", "event": "crash", "reason": "...", ...}
{"type": "system", "event": "reboot", "count": N}
{"type": "gpio_change", "pin": N, "state": V}
{"type": "gpio_dir", "pin": N, "dir": V}
{"type": "uart_tx", "uart": N, "byte": V}
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 09:05:34 +07:00
{"type": "ledc_duty", "channel": N, "duty_pct": F}
{"type": "rmt_event", "channel": N, ...}
{"type": "ws2812_update","channel": N, "pixels": [...]}
{"type": "i2c_event", "bus": N, "addr": N, "event": N, "response": N}
{"type": "spi_event", "bus": N, "event": N, "response": N}
{"type": "error", "message": "..."}
stderr : debug logs (never part of the JSON protocol)
"""
import base64
import ctypes
import json
import os
import sys
import tempfile
import threading
import time
# I2C slave state machines — extracted to a standalone module for testability
try:
from app.services.esp32_i2c_slaves import (
MPU6050Slave as _MPU6050Slave,
BMP280Slave as _BMP280Slave,
DS1307Slave as _DS1307Slave,
DS3231Slave as _DS3231Slave,
I2CWriteSink as _I2CWriteSink,
ProxySlave as _ProxySlave,
)
except ImportError:
# Fallback: direct import when running from backend/ directory as subprocess
feat: ESP32-CAM emulation with webcam frame bridge First open-source end-to-end emulation of the AI-Thinker ESP32-CAM in QEMU, paired with a browser webcam → firmware bridge so users can develop camera sketches without hardware. Status: esp_camera_init() returns ESP_OK; OV2640 chip-id verifies (PID/VER/MIDH/MIDL exactly match the datasheet); GPIO 25 VSYNC NEGEDGE interrupt enabled by the upstream driver. Final piece (cam_task accepting frames) is in progress — descriptor walker fix landed in this commit. Backend (Python/FastAPI): - simulation.py: camera_attach/frame/detach WS handlers - esp32_worker.py: ctypes binding to velxio_push_camera_frame + feature-detection fallback for older DLLs - esp32_lib_manager.py: forward camera commands to the worker stdin - esp-idf-template/main/CMakeLists.txt: esp32-camera headers added via add_prebuilt_library + REQUIRES driver (resolves i2c_master_* symbols). LED_BUILTIN=2 fallback for sketches that hardcode it. Frontend (React/TS): - EditorToolbar.tsx: ESP32-CAM (and the rest of the ESP32 family) added to isQemuBoard list — Run button now starts the QEMU bridge for these boards instead of falling through to the AVR path - useWebcamFrames.ts: getUserMedia → OffscreenCanvas → toBlob('image/jpeg') → base64 → WS at ~10 fps - CameraToggle.tsx: header button with status colors + frame counter - SimulatorCanvas.tsx: render CameraToggle for esp32-cam boards - Esp32Bridge.ts: sendCameraAttach/Frame/Detach + chunked btoa - useSimulatorStore.ts: diagnostic log on compileBoardProgram - components-metadata.json: regen including esp32-cam component Submodule pointer: - wokwi-libs/qemu-lcgamboa → ff8eee0 (camera devices commit on davidmonterocrespo24/qemu-lcgamboa branch picsimlab-esp32) Investigation + tests in test/test-esp32-cam/: - 13 autosearch markdown docs (overview, SOTA, OV2640 spec, DVP/I2S spec, build blueprint, blockers resolved, descriptor walker fix) - 5 sketches (camera_init, sccb_probe, dma_smoke, frame_roundtrip, webcam_demo) + 8 live + WS regression tests - README with the user-facing flow .gitignore: - libqemu-*.dll.{pre-camera,new,bak} (rollback points, regenerated) - wokwi-libs/esp32-camera/ (clone consumed by arduino-esp32 path, not part of this repo) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 05:28:55 +07:00
import importlib.util, pathlib, sys as _sys
_here = pathlib.Path(__file__).parent
_spec = importlib.util.spec_from_file_location('esp32_i2c_slaves', _here / 'esp32_i2c_slaves.py')
_mod = importlib.util.module_from_spec(_spec) # type: ignore[arg-type]
feat: ESP32-CAM emulation with webcam frame bridge First open-source end-to-end emulation of the AI-Thinker ESP32-CAM in QEMU, paired with a browser webcam → firmware bridge so users can develop camera sketches without hardware. Status: esp_camera_init() returns ESP_OK; OV2640 chip-id verifies (PID/VER/MIDH/MIDL exactly match the datasheet); GPIO 25 VSYNC NEGEDGE interrupt enabled by the upstream driver. Final piece (cam_task accepting frames) is in progress — descriptor walker fix landed in this commit. Backend (Python/FastAPI): - simulation.py: camera_attach/frame/detach WS handlers - esp32_worker.py: ctypes binding to velxio_push_camera_frame + feature-detection fallback for older DLLs - esp32_lib_manager.py: forward camera commands to the worker stdin - esp-idf-template/main/CMakeLists.txt: esp32-camera headers added via add_prebuilt_library + REQUIRES driver (resolves i2c_master_* symbols). LED_BUILTIN=2 fallback for sketches that hardcode it. Frontend (React/TS): - EditorToolbar.tsx: ESP32-CAM (and the rest of the ESP32 family) added to isQemuBoard list — Run button now starts the QEMU bridge for these boards instead of falling through to the AVR path - useWebcamFrames.ts: getUserMedia → OffscreenCanvas → toBlob('image/jpeg') → base64 → WS at ~10 fps - CameraToggle.tsx: header button with status colors + frame counter - SimulatorCanvas.tsx: render CameraToggle for esp32-cam boards - Esp32Bridge.ts: sendCameraAttach/Frame/Detach + chunked btoa - useSimulatorStore.ts: diagnostic log on compileBoardProgram - components-metadata.json: regen including esp32-cam component Submodule pointer: - wokwi-libs/qemu-lcgamboa → ff8eee0 (camera devices commit on davidmonterocrespo24/qemu-lcgamboa branch picsimlab-esp32) Investigation + tests in test/test-esp32-cam/: - 13 autosearch markdown docs (overview, SOTA, OV2640 spec, DVP/I2S spec, build blueprint, blockers resolved, descriptor walker fix) - 5 sketches (camera_init, sccb_probe, dma_smoke, frame_roundtrip, webcam_demo) + 8 live + WS regression tests - README with the user-facing flow .gitignore: - libqemu-*.dll.{pre-camera,new,bak} (rollback points, regenerated) - wokwi-libs/esp32-camera/ (clone consumed by arduino-esp32 path, not part of this repo) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 05:28:55 +07:00
# Register in sys.modules BEFORE exec — @dataclass looks up cls.__module__
# there, and crashes with AttributeError on None when missing.
_sys.modules['esp32_i2c_slaves'] = _mod
_spec.loader.exec_module(_mod) # type: ignore[union-attr]
_MPU6050Slave = _mod.MPU6050Slave # type: ignore[assignment]
_BMP280Slave = _mod.BMP280Slave # type: ignore[assignment]
_DS1307Slave = _mod.DS1307Slave # type: ignore[assignment]
_DS3231Slave = _mod.DS3231Slave # type: ignore[assignment]
_I2CWriteSink = _mod.I2CWriteSink # type: ignore[assignment]
_ProxySlave = _mod.ProxySlave # type: ignore[assignment]
# SPI slaves (Phase 1: SSD168x ePaper). Same fallback dance — when the worker
# runs as a subprocess from backend/ the package import won't resolve.
try:
from app.services.esp32_spi_slaves import (
Ssd168xEpaperSlave as _Ssd168xEpaperSlave,
Uc8159cEpaperSlave as _Uc8159cEpaperSlave,
feat(epaper): decode the UC8179/GD7965 7.5" panel + fix its BUSY polarity The 7.5" 800x480 dashboard (GxEPD2_750_T7) rendered blank: it is a UC8179 / GD7965 controller, but the panel config claimed controllerFamily 'ssd168x', so the SSD168x decoder (which only reads 0x24/0x26/0x44/0x45) ignored its 0x10/0x13 DTM stream. - Add a Uc8179 decoder (worker Uc8179EpaperSlave + browser Uc8179Decoder). UC8179 is the same UltraChip command family as the UC8159c (0x10/0x13 DTM, 0x12 refresh) but mono (1 bit/px). GxEPD2 writes the visible image to 0x13 (DTM2 "current"; 0x10 is the ignored "previous"), framed by 0x91/0x90 (partial window, pixel coords MSB-first)/0x13 data/0x92. Data lands at absolute pixel coords inside the window, so compose is just the RAM. The Frame reuses the SSD168x palette (0=black, 1=white) so paintFrame renders it. - EPaperPanels.ts: add the 'uc8179' family and point epaper-7in5-bw at it. EPaperPart.ts + esp32_worker.py dispatch 'uc8179' to the new decoder. - Fix the BUSY polarity: UC8179 (like the UC8159c) idles BUSY HIGH, not LOW. The worker seeded BUSY LOW for every non-uc8159c panel, so GxEPD2_750_T7's _PowerOn()/_InitDisplay() busy-wait timed out (~10 s, "Busy Timeout!") on every refresh. Now _PowerOn returns in ~129 us. - esp32_worker.py: the runtime sensor_attach epaper path still emitted the epaper_update payload nested under 'data' (the old double-wrap bug); emit it flat like the init path. The 5.65" ACeP UC8159c example already rendered (it has its own decoder and got the WS-plumbing fix); verified the 7 colour bars are correct.
2026-06-05 09:45:37 +07:00
Uc8179EpaperSlave as _Uc8179EpaperSlave,
)
except ImportError:
feat: ESP32-CAM emulation with webcam frame bridge First open-source end-to-end emulation of the AI-Thinker ESP32-CAM in QEMU, paired with a browser webcam → firmware bridge so users can develop camera sketches without hardware. Status: esp_camera_init() returns ESP_OK; OV2640 chip-id verifies (PID/VER/MIDH/MIDL exactly match the datasheet); GPIO 25 VSYNC NEGEDGE interrupt enabled by the upstream driver. Final piece (cam_task accepting frames) is in progress — descriptor walker fix landed in this commit. Backend (Python/FastAPI): - simulation.py: camera_attach/frame/detach WS handlers - esp32_worker.py: ctypes binding to velxio_push_camera_frame + feature-detection fallback for older DLLs - esp32_lib_manager.py: forward camera commands to the worker stdin - esp-idf-template/main/CMakeLists.txt: esp32-camera headers added via add_prebuilt_library + REQUIRES driver (resolves i2c_master_* symbols). LED_BUILTIN=2 fallback for sketches that hardcode it. Frontend (React/TS): - EditorToolbar.tsx: ESP32-CAM (and the rest of the ESP32 family) added to isQemuBoard list — Run button now starts the QEMU bridge for these boards instead of falling through to the AVR path - useWebcamFrames.ts: getUserMedia → OffscreenCanvas → toBlob('image/jpeg') → base64 → WS at ~10 fps - CameraToggle.tsx: header button with status colors + frame counter - SimulatorCanvas.tsx: render CameraToggle for esp32-cam boards - Esp32Bridge.ts: sendCameraAttach/Frame/Detach + chunked btoa - useSimulatorStore.ts: diagnostic log on compileBoardProgram - components-metadata.json: regen including esp32-cam component Submodule pointer: - wokwi-libs/qemu-lcgamboa → ff8eee0 (camera devices commit on davidmonterocrespo24/qemu-lcgamboa branch picsimlab-esp32) Investigation + tests in test/test-esp32-cam/: - 13 autosearch markdown docs (overview, SOTA, OV2640 spec, DVP/I2S spec, build blueprint, blockers resolved, descriptor walker fix) - 5 sketches (camera_init, sccb_probe, dma_smoke, frame_roundtrip, webcam_demo) + 8 live + WS regression tests - README with the user-facing flow .gitignore: - libqemu-*.dll.{pre-camera,new,bak} (rollback points, regenerated) - wokwi-libs/esp32-camera/ (clone consumed by arduino-esp32 path, not part of this repo) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 05:28:55 +07:00
import importlib.util, pathlib, sys as _sys
_here = pathlib.Path(__file__).parent
_spec = importlib.util.spec_from_file_location('esp32_spi_slaves', _here / 'esp32_spi_slaves.py')
_mod = importlib.util.module_from_spec(_spec) # type: ignore[arg-type]
feat: ESP32-CAM emulation with webcam frame bridge First open-source end-to-end emulation of the AI-Thinker ESP32-CAM in QEMU, paired with a browser webcam → firmware bridge so users can develop camera sketches without hardware. Status: esp_camera_init() returns ESP_OK; OV2640 chip-id verifies (PID/VER/MIDH/MIDL exactly match the datasheet); GPIO 25 VSYNC NEGEDGE interrupt enabled by the upstream driver. Final piece (cam_task accepting frames) is in progress — descriptor walker fix landed in this commit. Backend (Python/FastAPI): - simulation.py: camera_attach/frame/detach WS handlers - esp32_worker.py: ctypes binding to velxio_push_camera_frame + feature-detection fallback for older DLLs - esp32_lib_manager.py: forward camera commands to the worker stdin - esp-idf-template/main/CMakeLists.txt: esp32-camera headers added via add_prebuilt_library + REQUIRES driver (resolves i2c_master_* symbols). LED_BUILTIN=2 fallback for sketches that hardcode it. Frontend (React/TS): - EditorToolbar.tsx: ESP32-CAM (and the rest of the ESP32 family) added to isQemuBoard list — Run button now starts the QEMU bridge for these boards instead of falling through to the AVR path - useWebcamFrames.ts: getUserMedia → OffscreenCanvas → toBlob('image/jpeg') → base64 → WS at ~10 fps - CameraToggle.tsx: header button with status colors + frame counter - SimulatorCanvas.tsx: render CameraToggle for esp32-cam boards - Esp32Bridge.ts: sendCameraAttach/Frame/Detach + chunked btoa - useSimulatorStore.ts: diagnostic log on compileBoardProgram - components-metadata.json: regen including esp32-cam component Submodule pointer: - wokwi-libs/qemu-lcgamboa → ff8eee0 (camera devices commit on davidmonterocrespo24/qemu-lcgamboa branch picsimlab-esp32) Investigation + tests in test/test-esp32-cam/: - 13 autosearch markdown docs (overview, SOTA, OV2640 spec, DVP/I2S spec, build blueprint, blockers resolved, descriptor walker fix) - 5 sketches (camera_init, sccb_probe, dma_smoke, frame_roundtrip, webcam_demo) + 8 live + WS regression tests - README with the user-facing flow .gitignore: - libqemu-*.dll.{pre-camera,new,bak} (rollback points, regenerated) - wokwi-libs/esp32-camera/ (clone consumed by arduino-esp32 path, not part of this repo) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 05:28:55 +07:00
# Same dataclass-needs-sys.modules fix as the i2c fallback above.
_sys.modules['esp32_spi_slaves'] = _mod
_spec.loader.exec_module(_mod) # type: ignore[union-attr]
_Ssd168xEpaperSlave = _mod.Ssd168xEpaperSlave # type: ignore[assignment]
_Uc8159cEpaperSlave = _mod.Uc8159cEpaperSlave # type: ignore[assignment]
feat(epaper): decode the UC8179/GD7965 7.5" panel + fix its BUSY polarity The 7.5" 800x480 dashboard (GxEPD2_750_T7) rendered blank: it is a UC8179 / GD7965 controller, but the panel config claimed controllerFamily 'ssd168x', so the SSD168x decoder (which only reads 0x24/0x26/0x44/0x45) ignored its 0x10/0x13 DTM stream. - Add a Uc8179 decoder (worker Uc8179EpaperSlave + browser Uc8179Decoder). UC8179 is the same UltraChip command family as the UC8159c (0x10/0x13 DTM, 0x12 refresh) but mono (1 bit/px). GxEPD2 writes the visible image to 0x13 (DTM2 "current"; 0x10 is the ignored "previous"), framed by 0x91/0x90 (partial window, pixel coords MSB-first)/0x13 data/0x92. Data lands at absolute pixel coords inside the window, so compose is just the RAM. The Frame reuses the SSD168x palette (0=black, 1=white) so paintFrame renders it. - EPaperPanels.ts: add the 'uc8179' family and point epaper-7in5-bw at it. EPaperPart.ts + esp32_worker.py dispatch 'uc8179' to the new decoder. - Fix the BUSY polarity: UC8179 (like the UC8159c) idles BUSY HIGH, not LOW. The worker seeded BUSY LOW for every non-uc8159c panel, so GxEPD2_750_T7's _PowerOn()/_InitDisplay() busy-wait timed out (~10 s, "Busy Timeout!") on every refresh. Now _PowerOn returns in ~129 us. - esp32_worker.py: the runtime sensor_attach epaper path still emitted the epaper_update payload nested under 'data' (the old double-wrap bug); emit it flat like the init path. The 5.65" ACeP UC8159c example already rendered (it has its own decoder and got the WS-plumbing fix); verified the 7 colour bars are correct.
2026-06-05 09:45:37 +07:00
_Uc8179EpaperSlave = _mod.Uc8179EpaperSlave # type: ignore[assignment]
# ─── stdout helpers ──────────────────────────────────────────────────────────
_stdout_lock = threading.Lock()
def _emit(obj: dict) -> None:
"""Write one JSON event line to stdout (thread-safe, always flushed)."""
with _stdout_lock:
sys.stdout.write(json.dumps(obj) + '\n')
sys.stdout.flush()
def _log(msg: str) -> None:
"""Write a debug message to stderr (invisible to parent's stdout reader)."""
sys.stderr.write(f'[esp32_worker] {msg}\n')
sys.stderr.flush()
# ─── GPIO pinmap (identity: slot i → GPIO i-1) ──────────────────────────────
# ESP32 has 40 GPIOs (0-39), ESP32-C3 only has 22 (0-21).
# The pinmap is rebuilt after reading config (see main()), defaulting to ESP32.
_GPIO_COUNT = 40
_PINMAP = (ctypes.c_int16 * (_GPIO_COUNT + 1))(
_GPIO_COUNT,
*range(_GPIO_COUNT),
)
def _build_pinmap(gpio_count: int):
"""Build a pinmap array for the given GPIO count."""
global _GPIO_COUNT, _PINMAP
_GPIO_COUNT = gpio_count
_PINMAP = (ctypes.c_int16 * (gpio_count + 1))(
gpio_count,
*range(gpio_count),
)
# ─── ctypes callback types ───────────────────────────────────────────────────
_WRITE_PIN = ctypes.CFUNCTYPE(None, ctypes.c_int, ctypes.c_int)
_DIR_PIN = ctypes.CFUNCTYPE(None, ctypes.c_int, ctypes.c_int)
_I2C_EVENT = ctypes.CFUNCTYPE(ctypes.c_int, ctypes.c_uint8, ctypes.c_uint8, ctypes.c_uint16)
_SPI_EVENT = ctypes.CFUNCTYPE(ctypes.c_uint8, ctypes.c_uint8, ctypes.c_uint16)
_UART_TX = ctypes.CFUNCTYPE(None, ctypes.c_uint8, ctypes.c_uint8)
_RMT_EVENT = ctypes.CFUNCTYPE(None, ctypes.c_uint8, ctypes.c_uint32, ctypes.c_uint32)
# Synchronous GPIO Matrix routing callback. Fires on every guest write
# to GPIO_FUNCx_OUT_SEL_CFG_REG with the full 9-bit signal_id; replaces
# the 100 ms poll path in _refresh_signal_routing() once the prod
# burn-in window confirms parity. Requires libqemu-{xtensa,riscv32}
# 1.1.0+; older binaries omit the field and the placeholder runs.
_GPIO_MATRIX_CB = ctypes.CFUNCTYPE(None, ctypes.c_int, ctypes.c_int)
# Batched write-only SPI: (id, const uint8_t *mosi, int len). Collapses the
# per-byte picsimlab_spi_event ctypes crossing for TFT-style bulk writes.
# Trailing field — older libqemu builds (without the C-side batch path) simply
# never call it and fall back to per-byte picsimlab_spi_event.
_SPI_BATCH = ctypes.CFUNCTYPE(None, ctypes.c_uint8, ctypes.POINTER(ctypes.c_uint8), ctypes.c_int)
class _CallbacksT(ctypes.Structure):
_fields_ = [
('picsimlab_write_pin', _WRITE_PIN),
('picsimlab_dir_pin', _DIR_PIN),
('picsimlab_i2c_event', _I2C_EVENT),
('picsimlab_spi_event', _SPI_EVENT),
('picsimlab_uart_tx_event', _UART_TX),
('pinmap', ctypes.c_void_p),
('picsimlab_rmt_event', _RMT_EVENT),
('picsimlab_gpio_matrix_cb', _GPIO_MATRIX_CB),
('picsimlab_spi_event_batch', _SPI_BATCH),
]
# ─── RMT / WS2812 NeoPixel decoder ───────────────────────────────────────────
_WS2812_HIGH_THRESHOLD = 48 # RMT ticks; high pulse > threshold → bit 1
def _decode_rmt_item(value: int) -> tuple[int, int, int, int]:
"""Unpack a 32-bit RMT item → (level0, duration0, level1, duration1)."""
level0 = (value >> 31) & 1
duration0 = (value >> 16) & 0x7FFF
level1 = (value >> 15) & 1
duration1 = value & 0x7FFF
return level0, duration0, level1, duration1
class _RmtDecoder:
"""Accumulate RMT items for one channel; flush complete WS2812 frames."""
def __init__(self, channel: int):
self.channel = channel
self._bits: list[int] = []
self._pixels: list[dict] = []
@staticmethod
def _bits_to_byte(bits: list[int], offset: int) -> int:
val = 0
for i in range(8):
val = (val << 1) | bits[offset + i]
return val
def feed(self, value: int) -> list[dict] | None:
"""
Process one RMT item.
Returns a list of {r, g, b} pixel dicts on end-of-frame, else None.
"""
level0, dur0, _, dur1 = _decode_rmt_item(value)
# Reset pulse (both durations zero) signals end of frame
if dur0 == 0 and dur1 == 0:
pix = list(self._pixels)
self._pixels.clear()
self._bits.clear()
return pix or None
# Classify the high pulse → bit 1 or bit 0
if level0 == 1 and dur0 > 0:
self._bits.append(1 if dur0 > _WS2812_HIGH_THRESHOLD else 0)
# Every 24 bits → one GRB pixel → convert to RGB
while len(self._bits) >= 24:
g = self._bits_to_byte(self._bits, 0)
r = self._bits_to_byte(self._bits, 8)
b = self._bits_to_byte(self._bits, 16)
self._pixels.append({'r': r, 'g': g, 'b': b})
self._bits = self._bits[24:]
return None
# ─── Main ─────────────────────────────────────────────────────────────────────
def main() -> None: # noqa: C901 (complexity OK for inline worker)
# ── 1. Read config from stdin ─────────────────────────────────────────────
raw_cfg = sys.stdin.readline()
if not raw_cfg.strip():
_log('No config received on stdin — exiting')
os._exit(1)
try:
cfg = json.loads(raw_cfg)
except Exception as exc:
_log(f'Bad config JSON: {exc}')
os._exit(1)
lib_path = cfg['lib_path']
firmware_b64 = cfg['firmware_b64']
machine = cfg.get('machine', 'esp32-picsimlab')
initial_sensors = cfg.get('sensors', [])
wifi_enabled = cfg.get('wifi_enabled', False)
wifi_hostfwd_port = cfg.get('wifi_hostfwd_port', 0)
# Adjust GPIO pinmap based on chip: ESP32-C3 has only 22 GPIOs
if 'c3' in machine:
_build_pinmap(22)
# ── 2. Load DLL ───────────────────────────────────────────────────────────
_MINGW64_BIN = r'C:\msys64\mingw64\bin'
if os.name == 'nt' and os.path.isdir(_MINGW64_BIN):
os.add_dll_directory(_MINGW64_BIN)
try:
lib_size = os.path.getsize(lib_path) if os.path.isfile(lib_path) else 0
_log(f'Loading library: {lib_path} ({lib_size} bytes)')
lib = ctypes.CDLL(lib_path)
except Exception as exc:
_emit({'type': 'error', 'message': f'Cannot load DLL: {exc}'})
os._exit(1)
lib.qemu_picsimlab_get_internals.restype = ctypes.c_void_p
# qemu_picsimlab_uart_receive() injects a UART-RX interrupt into the guest CPU.
# QEMU asserts qemu_mutex_iothread_locked() at that point, so the caller MUST
# hold the IO-thread lock. Acquire it before every uart_receive call and
# release immediately after. The functions are exported as:
# qemu_mutex_lock_iothread_impl(const char *file, int line)
# qemu_mutex_unlock_iothread()
try:
_lock_iothread = lib.qemu_mutex_lock_iothread_impl
_lock_iothread.restype = None
_lock_iothread.argtypes = [ctypes.c_char_p, ctypes.c_int]
_unlock_iothread = lib.qemu_mutex_unlock_iothread
_unlock_iothread.restype = None
_unlock_iothread.argtypes = []
except AttributeError:
_lock_iothread = None
_unlock_iothread = None
# Predicate: is the iothread lock currently held by this thread?
# Used to avoid re-acquiring when we're already inside a QEMU callback
# (e.g., chip's vx_uart_write fired from inside _on_uart_tx).
try:
_iothread_locked = lib.qemu_mutex_iothread_locked
_iothread_locked.restype = ctypes.c_bool
_iothread_locked.argtypes = []
except AttributeError:
_iothread_locked = None
# qemu_system_shutdown_request() schedules a clean shutdown from inside
# the QEMU main-loop thread (which owns the AIO context). Calling
# qemu_cleanup() directly from a Python thread (the command loop) triggers
# the "blk_exp_close_all_type: in_aio_context_home_thread" assertion
# because the block device teardown happens on the wrong thread.
# SHUTDOWN_CAUSE_HOST_SIGNAL = 3 (matches the constant in qapi/run-state.json)
try:
_shutdown_request = lib.qemu_system_shutdown_request
_shutdown_request.restype = None
_shutdown_request.argtypes = [ctypes.c_int]
except AttributeError:
_shutdown_request = None
feat: ESP32-CAM emulation with webcam frame bridge First open-source end-to-end emulation of the AI-Thinker ESP32-CAM in QEMU, paired with a browser webcam → firmware bridge so users can develop camera sketches without hardware. Status: esp_camera_init() returns ESP_OK; OV2640 chip-id verifies (PID/VER/MIDH/MIDL exactly match the datasheet); GPIO 25 VSYNC NEGEDGE interrupt enabled by the upstream driver. Final piece (cam_task accepting frames) is in progress — descriptor walker fix landed in this commit. Backend (Python/FastAPI): - simulation.py: camera_attach/frame/detach WS handlers - esp32_worker.py: ctypes binding to velxio_push_camera_frame + feature-detection fallback for older DLLs - esp32_lib_manager.py: forward camera commands to the worker stdin - esp-idf-template/main/CMakeLists.txt: esp32-camera headers added via add_prebuilt_library + REQUIRES driver (resolves i2c_master_* symbols). LED_BUILTIN=2 fallback for sketches that hardcode it. Frontend (React/TS): - EditorToolbar.tsx: ESP32-CAM (and the rest of the ESP32 family) added to isQemuBoard list — Run button now starts the QEMU bridge for these boards instead of falling through to the AVR path - useWebcamFrames.ts: getUserMedia → OffscreenCanvas → toBlob('image/jpeg') → base64 → WS at ~10 fps - CameraToggle.tsx: header button with status colors + frame counter - SimulatorCanvas.tsx: render CameraToggle for esp32-cam boards - Esp32Bridge.ts: sendCameraAttach/Frame/Detach + chunked btoa - useSimulatorStore.ts: diagnostic log on compileBoardProgram - components-metadata.json: regen including esp32-cam component Submodule pointer: - wokwi-libs/qemu-lcgamboa → ff8eee0 (camera devices commit on davidmonterocrespo24/qemu-lcgamboa branch picsimlab-esp32) Investigation + tests in test/test-esp32-cam/: - 13 autosearch markdown docs (overview, SOTA, OV2640 spec, DVP/I2S spec, build blueprint, blockers resolved, descriptor walker fix) - 5 sketches (camera_init, sccb_probe, dma_smoke, frame_roundtrip, webcam_demo) + 8 live + WS regression tests - README with the user-facing flow .gitignore: - libqemu-*.dll.{pre-camera,new,bak} (rollback points, regenerated) - wokwi-libs/esp32-camera/ (clone consumed by arduino-esp32 path, not part of this repo) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 05:28:55 +07:00
# ── ESP32-CAM frame injection ─────────────────────────────────────────
# Exported by hw/misc/esp32_i2s_cam.c (the OV2640+I²S patch). When the
# symbol is absent (= stock library, no camera patch yet), we keep a
# no-op so the worker stays compatible with un-patched libraries.
try:
_push_camera_frame_c = lib.velxio_push_camera_frame
_push_camera_frame_c.restype = None
_push_camera_frame_c.argtypes = [ctypes.c_char_p, ctypes.c_size_t]
def _push_camera_frame(payload: bytes) -> None:
buf = ctypes.c_char_p(payload) if payload else None
n = len(payload) if payload else 0
if _lock_iothread:
_lock_iothread(b'esp32_worker.py:camera', 0)
try:
_push_camera_frame_c(buf, n)
finally:
if _unlock_iothread:
_unlock_iothread()
except AttributeError:
def _push_camera_frame(payload: bytes) -> None:
# Stock library — no camera support compiled in. The first
# time we hit this path we emit a warning so the user
# understands why fb_get returns nothing; subsequent calls
# are silent.
if not getattr(_push_camera_frame, '_warned', False):
_log('camera_frame: velxio_push_camera_frame symbol '
'missing — rebuild libqemu-xtensa with the '
'OV2640+I²S patch (test/test-esp32-cam/autosearch).')
_push_camera_frame._warned = True # type: ignore[attr-defined]
# ── 3. Write firmware to a temp file ──────────────────────────────────────
try:
fix(esp32): trim flash image before serializing, pad on QEMU attach Issue #101 reproducer: an ESP32 sketch that pulls in Adafruit_SSD1306 + Adafruit_GFX produced a "No response from server. Is the backend running on port 8001?" error in the browser. The compile actually succeeded backend-side, but the JSON response carrying the firmware was ~5.5 MB of base64 — the ESP-IDF compiler builds a full 4 MB merged flash image (mostly 0xFF padding), encodes it whole, and ships it. In prod that response goes through nginx + Cloudflare, which buffer-fail or RST the connection on payloads that big — axios then lands in the "no response" branch with no HTTP status to surface. Fix: trim the trailing 0xFF padding before serializing, re-pad to a valid QEMU flash size (2/4/8/16 MB) just before mtd attach. Lossless: bytes after `last_used` in the merge are 0xFF by construction, so trim → pad reproduces the original image byte-for-byte. Numbers from the reproducer (Adafruit_SSD1306 + Adafruit_GFX, esp32:esp32:esp32 board): before: ~5.5 MB JSON response after: 539 KB JSON response (10× smaller) backend/app/services/espidf_compiler.py _merge_flash_image now tracks `last_used` across the three placed sections (bootloader / partitions / app) and writes only flash[:last_used] to merged_flash.bin. backend/app/services/esp32_flash_image.py (new) Shared `pad_to_flash_size(bytes) -> bytes` helper. Rounds up to the next valid QEMU flash size with a 4 MB minimum, matches the frontend's existing padToFlashSize logic in Esp32MicroPythonLoader. Raises ValueError on >16 MB inputs (would indicate a broken upstream merge, not anything user-recoverable). backend/app/services/esp32_lib_bridge.py backend/app/services/esp32_worker.py Both QEMU consumer paths (in-process and subprocess) call pad_to_flash_size right after base64.b64decode, before writing the tmp .bin that QEMU attaches with `-drive if=mtd,format=raw`. Verified: smoke test confirms trim → pad → original is byte-exact. Edge cases covered: small payloads pad up to the 4 MB minimum; firmwares >16 MB are rejected loudly. Closes #101 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 00:36:34 +07:00
# The compiler trims trailing 0xFF padding before serializing (issue
# #101 — full 4 MB images blew nginx buffers). Re-pad here so QEMU's
# MTD layer sees a valid power-of-2 flash size.
# Imported via fallback because this file runs as a subprocess and
# `app.*` is not on sys.path; mirrors the esp32_i2c_slaves pattern
# at the top of the file.
try:
from app.services.esp32_flash_image import pad_to_flash_size # type: ignore[import-not-found]
except ImportError:
import importlib.util as _ilu, pathlib as _pl
_spec = _ilu.spec_from_file_location(
'esp32_flash_image',
_pl.Path(__file__).parent / 'esp32_flash_image.py',
)
_mod = _ilu.module_from_spec(_spec) # type: ignore[arg-type]
_spec.loader.exec_module(_mod) # type: ignore[union-attr]
pad_to_flash_size = _mod.pad_to_flash_size
fix(esp32): trim flash image before serializing, pad on QEMU attach Issue #101 reproducer: an ESP32 sketch that pulls in Adafruit_SSD1306 + Adafruit_GFX produced a "No response from server. Is the backend running on port 8001?" error in the browser. The compile actually succeeded backend-side, but the JSON response carrying the firmware was ~5.5 MB of base64 — the ESP-IDF compiler builds a full 4 MB merged flash image (mostly 0xFF padding), encodes it whole, and ships it. In prod that response goes through nginx + Cloudflare, which buffer-fail or RST the connection on payloads that big — axios then lands in the "no response" branch with no HTTP status to surface. Fix: trim the trailing 0xFF padding before serializing, re-pad to a valid QEMU flash size (2/4/8/16 MB) just before mtd attach. Lossless: bytes after `last_used` in the merge are 0xFF by construction, so trim → pad reproduces the original image byte-for-byte. Numbers from the reproducer (Adafruit_SSD1306 + Adafruit_GFX, esp32:esp32:esp32 board): before: ~5.5 MB JSON response after: 539 KB JSON response (10× smaller) backend/app/services/espidf_compiler.py _merge_flash_image now tracks `last_used` across the three placed sections (bootloader / partitions / app) and writes only flash[:last_used] to merged_flash.bin. backend/app/services/esp32_flash_image.py (new) Shared `pad_to_flash_size(bytes) -> bytes` helper. Rounds up to the next valid QEMU flash size with a 4 MB minimum, matches the frontend's existing padToFlashSize logic in Esp32MicroPythonLoader. Raises ValueError on >16 MB inputs (would indicate a broken upstream merge, not anything user-recoverable). backend/app/services/esp32_lib_bridge.py backend/app/services/esp32_worker.py Both QEMU consumer paths (in-process and subprocess) call pad_to_flash_size right after base64.b64decode, before writing the tmp .bin that QEMU attaches with `-drive if=mtd,format=raw`. Verified: smoke test confirms trim → pad → original is byte-exact. Edge cases covered: small payloads pad up to the 4 MB minimum; firmwares >16 MB are rejected loudly. Closes #101 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 00:36:34 +07:00
fw_bytes = pad_to_flash_size(base64.b64decode(firmware_b64))
tmp = tempfile.NamedTemporaryFile(suffix='.bin', delete=False)
tmp.write(fw_bytes)
tmp.close()
firmware_path: str | None = tmp.name
except Exception as exc:
_emit({'type': 'error', 'message': f'Firmware decode error: {exc}'})
os._exit(1)
rom_dir = os.path.dirname(lib_path).encode()
args_list = [
b'qemu',
b'-M', machine.encode(),
b'-nographic',
b'-L', rom_dir,
b'-drive', f'file={firmware_path},if=mtd,format=raw'.encode(),
]
# Deterministic instruction counting for stable timers.
# Required for ESP32-C3 boot (RISC-V needs deterministic timing).
# For ESP32 (Xtensa), -icount is NOT used: the WiFi AP beacon timer
# runs on QEMU_CLOCK_REALTIME, so decoupling virtual time from real
# time can cause beacon delivery issues on slow/virtualized hosts.
if 'c3' in machine:
args_list.extend([b'-icount', b'3'])
# ── WiFi NIC (slirp user-mode networking) ──────────────────────────────
if wifi_enabled:
nic_model = 'esp32c3_wifi' if 'c3' in machine else 'esp32_wifi'
nic_arg = f'user,model={nic_model},net=192.168.4.0/24'
if wifi_hostfwd_port:
nic_arg += f',hostfwd=tcp::{wifi_hostfwd_port}-192.168.4.15:80'
args_list.extend([b'-nic', nic_arg.encode()])
_log(f'WiFi enabled: -nic {nic_arg}')
argc = len(args_list)
argv = (ctypes.c_char_p * argc)(*args_list)
# ── 4. Shared mutable state ───────────────────────────────────────────────
_stopped = threading.Event() # set on "stop" command
_init_done = threading.Event() # set when qemu_init() returns
_sensors_ready = threading.Event() # set after pre-registering initial sensors
_i2c_responses: dict[int, int] = {} # 7-bit addr → response byte (simple)
_i2c_slaves: dict = {} # 7-bit addr → I2C slave/sink instance
_spi_response = [0xFF] # MISO byte for SPI transfers (default)
# Custom-chip runtimes that registered their respective protocols at chip_setup.
# Mutated when sensor_type=='custom-chip' is processed in initial_sensors.
_chip_uart_runtimes: list = [] # runtimes that called vx_uart_attach
_chip_spi_runtimes: list = [] # runtimes that called vx_spi_attach
_chip_timer_runtimes: list = [] # runtimes with active timers
_chip_pin_watch_runtimes: list = [] # runtimes that called vx_pin_watch
# ePaper SSD168x slaves keyed by frontend component_id. The slave decodes
# SPI bytes; on MASTER_ACTIVATION it emits an `epaper_update` WS frame.
# `dc_pin` / `cs_pin` / `rst_pin` (gpio numbers) are tracked via
# `_on_pin_change`; an active slave is one whose `cs_low` is True.
_epaper_slaves: dict = {}
# Per-slave runtime state keyed identically: dict with keys
# 'slave', 'dc_pin', 'cs_pin', 'rst_pin', 'busy_pin', 'cs_low',
# 'dc_high', 'refresh_ms'.
_epaper_state: dict = {}
# Live GPIO state tracked from QEMU's _on_pin_change callback. Custom-chip
# runtimes' vx_pin_read consults this to see what the firmware just drove.
_pin_state: dict[int, int] = {}
_rmt_decoders: dict[int, _RmtDecoder] = {}
_uart0_buf = bytearray() # accumulate UART0 for crash detection
_reboot_count = [0]
_crashed = [False]
_camera_frame_count = [0] # ESP32-CAM frame trace counter
_CRASH_STR = b'Cache disabled but cached memory region accessed'
_REBOOT_STR = b'Rebooting...'
feat(esp32): SignalRouter — model the GPIO Matrix as first-class Replaces the per-peripheral ad-hoc `_ledc_gpio_map` cache with a proper signal-routing abstraction that mirrors the ESP32 SoC's IO_MUX + GPIO Matrix exactly. Same idea as real silicon: signal sources (LEDC channels, RMT, MCPWM, ...) → 40-entry routing table → GPIO pins. Motivation (from user bug report in velxio.dev/project/5218f9e3-136d-43b3-bba1-6cebde21e1a4): two ESP32 servos on a solar-tracker visibly oscillated between two positions instead of moving smoothly when the user changed LDR sliders. Commit 77bf897 patched it (per-channel gpio memo + broadcast guard) but the user requested a proper hardware-fidel architecture, not patches. Backend: * `app/services/signal_router.py` — SignalRouter class. Forward index (gpio → signal_id) + reverse index (signal_id → set of gpios). `replace_snapshot()` returns the diff for the polling- fallback path; future C plugin hook becomes a push without touching this code. * `app/services/esp32_signals.py` — Signal id constants from ESP32 TRM (LEDC HS 72-79, LS 80-87) + `ledc_signal_for_channel()` helper. * `app/services/esp32_worker.py` — `_ledc_gpio_map` is gone; `_refresh_ledc_gpio_map` replaced by `_refresh_signal_routing` which emits `gpio_routing {gpio, signal_id}` events on diff. The 0x5000 LEDC callback and the LEDC poll thread now emit `ledc_duty {channel, duty_pct}` (canonical, no gpio) alongside the legacy `ledc_update {channel, duty, gpio}` for back-compat during rollout. Frontend: * `simulation/SignalRouter.ts` — 1-to-1 TS mirror of the Python class. Same forward + reverse index; same `pinsForSignal` / `updateRouting` / `clearRouting` API. * `simulation/esp32-signals.ts` — Signal id constants, mirror of the Python module. * `simulation/Esp32Bridge.ts` — new `onLedcDuty`, `onGpioRouting`, `onGpioRoutingClear` callbacks; handlers for the new event types. * `store/useSimulatorStore.ts` — `makeLedcDutyHandler` looks up pins via `router.pinsForSignal(ledcSignalForChannel(channel))` and dispatches per pin. `makeGpioRoutingHandler` / `makeGpioRoutingClearHandler` keep the mirror in sync. Per-board `signalRouterMap` parallels `pinManagerMap` in lifecycle. `makeLedcUpdateHandler` (and its memo workaround from 77bf897) stays wired for back-compat during rollout; removed in a follow-up commit once prod is verified stable on the new path. Tests: * `test/backend/unit/test_signal_router.py` (20 tests) covers update/clear semantics, idempotency, multi-pin routing, snapshot diff, channel↔signal-id helpers, and the multi-servo regression scenario. * `frontend/src/__tests__/SignalRouter.test.ts` (17 tests) is the mirror — same scenarios on the TS side. * `frontend/src/__tests__/esp32-multi-servo-gpio-matrix.test.ts` (6 tests) drives the end-to-end SignalRouter handler pipeline, asserts that two servos on GPIO 13/12 via LEDC channels 0/1 move independently (no mirroring), that re-routing carries cleanly, and — critically — that `PinManager.broadcastPwm` is never called. Totals: +700 LOC, 1876 frontend tests pass (was 1853), 278 backend unit tests pass (was 259). Docs: ESP32_EMULATION.md §9.2 rewritten with the new architecture diagram + a runbook for adding future peripherals through the SignalRouter. The C plugin hook in qemu-lcgamboa that would push gpio_out_sel writes synchronously (eliminating the polling race window entirely) is the next step — kept as a follow-up because the polling-fallback path here already resolves the routing before each duty event fires, so the bug is fixed end-to-end. The plugin work removes the race condition fundamentally.
2026-05-17 10:00:53 +07:00
# ── Signal routing (GPIO Matrix mirror) ───────────────────────────────
# The SignalRouter owns the per-GPIO routing table that the firmware
# writes through `GPIO_FUNCx_OUT_SEL_CFG_REG[x]`. We currently fill it
# by polling `gpio_out_sel[40]` once every 100 ms in the LEDC poll
# thread (and diffing); the C-side plugin will gain a synchronous
# callback in a future bump that turns the poll into a push without
# touching this code path.
#
# The old `_ledc_gpio_map: dict[int, int]` (channel → gpio) has been
# subsumed by the router's reverse index — call
# `_signal_router.pins_for_signal(SIG_LEDC_*+channel)` instead.
#
# `app.*` is not on sys.path inside this subprocess; mirror the same
# importlib fallback pattern used for esp32_flash_image (further down
# this file) so the worker can find its sibling modules without
# depending on the backend's package layout.
try:
from app.services.signal_router import SignalRouter # type: ignore[import-not-found] # noqa: E402
from app.services.esp32_signals import ( # type: ignore[import-not-found] # noqa: E402
ledc_signal_for_channel,
SIG_LEDC_HS_CH0_OUT_IDX,
SIG_LEDC_LS_CH_LAST,
)
except ImportError:
import importlib.util as _ilu, pathlib as _pl
_here = _pl.Path(__file__).parent
for _name in ('signal_router', 'esp32_signals'):
_spec = _ilu.spec_from_file_location(_name, _here / f'{_name}.py')
_mod = _ilu.module_from_spec(_spec) # type: ignore[arg-type]
_spec.loader.exec_module(_mod) # type: ignore[union-attr]
sys.modules[_name] = _mod
SignalRouter = sys.modules['signal_router'].SignalRouter
ledc_signal_for_channel = sys.modules['esp32_signals'].ledc_signal_for_channel
SIG_LEDC_HS_CH0_OUT_IDX = sys.modules['esp32_signals'].SIG_LEDC_HS_CH0_OUT_IDX
SIG_LEDC_LS_CH_LAST = sys.modules['esp32_signals'].SIG_LEDC_LS_CH_LAST
feat(esp32): SignalRouter — model the GPIO Matrix as first-class Replaces the per-peripheral ad-hoc `_ledc_gpio_map` cache with a proper signal-routing abstraction that mirrors the ESP32 SoC's IO_MUX + GPIO Matrix exactly. Same idea as real silicon: signal sources (LEDC channels, RMT, MCPWM, ...) → 40-entry routing table → GPIO pins. Motivation (from user bug report in velxio.dev/project/5218f9e3-136d-43b3-bba1-6cebde21e1a4): two ESP32 servos on a solar-tracker visibly oscillated between two positions instead of moving smoothly when the user changed LDR sliders. Commit 77bf897 patched it (per-channel gpio memo + broadcast guard) but the user requested a proper hardware-fidel architecture, not patches. Backend: * `app/services/signal_router.py` — SignalRouter class. Forward index (gpio → signal_id) + reverse index (signal_id → set of gpios). `replace_snapshot()` returns the diff for the polling- fallback path; future C plugin hook becomes a push without touching this code. * `app/services/esp32_signals.py` — Signal id constants from ESP32 TRM (LEDC HS 72-79, LS 80-87) + `ledc_signal_for_channel()` helper. * `app/services/esp32_worker.py` — `_ledc_gpio_map` is gone; `_refresh_ledc_gpio_map` replaced by `_refresh_signal_routing` which emits `gpio_routing {gpio, signal_id}` events on diff. The 0x5000 LEDC callback and the LEDC poll thread now emit `ledc_duty {channel, duty_pct}` (canonical, no gpio) alongside the legacy `ledc_update {channel, duty, gpio}` for back-compat during rollout. Frontend: * `simulation/SignalRouter.ts` — 1-to-1 TS mirror of the Python class. Same forward + reverse index; same `pinsForSignal` / `updateRouting` / `clearRouting` API. * `simulation/esp32-signals.ts` — Signal id constants, mirror of the Python module. * `simulation/Esp32Bridge.ts` — new `onLedcDuty`, `onGpioRouting`, `onGpioRoutingClear` callbacks; handlers for the new event types. * `store/useSimulatorStore.ts` — `makeLedcDutyHandler` looks up pins via `router.pinsForSignal(ledcSignalForChannel(channel))` and dispatches per pin. `makeGpioRoutingHandler` / `makeGpioRoutingClearHandler` keep the mirror in sync. Per-board `signalRouterMap` parallels `pinManagerMap` in lifecycle. `makeLedcUpdateHandler` (and its memo workaround from 77bf897) stays wired for back-compat during rollout; removed in a follow-up commit once prod is verified stable on the new path. Tests: * `test/backend/unit/test_signal_router.py` (20 tests) covers update/clear semantics, idempotency, multi-pin routing, snapshot diff, channel↔signal-id helpers, and the multi-servo regression scenario. * `frontend/src/__tests__/SignalRouter.test.ts` (17 tests) is the mirror — same scenarios on the TS side. * `frontend/src/__tests__/esp32-multi-servo-gpio-matrix.test.ts` (6 tests) drives the end-to-end SignalRouter handler pipeline, asserts that two servos on GPIO 13/12 via LEDC channels 0/1 move independently (no mirroring), that re-routing carries cleanly, and — critically — that `PinManager.broadcastPwm` is never called. Totals: +700 LOC, 1876 frontend tests pass (was 1853), 278 backend unit tests pass (was 259). Docs: ESP32_EMULATION.md §9.2 rewritten with the new architecture diagram + a runbook for adding future peripherals through the SignalRouter. The C plugin hook in qemu-lcgamboa that would push gpio_out_sel writes synchronously (eliminating the polling race window entirely) is the next step — kept as a follow-up because the polling-fallback path here already resolves the routing before each duty event fires, so the bug is fixed end-to-end. The plugin work removes the race condition fundamentally.
2026-05-17 10:00:53 +07:00
_signal_router = SignalRouter()
def _refresh_signal_routing() -> None:
"""Scan `gpio_out_sel[40]` and reconcile the SignalRouter.
feat(esp32): SignalRouter — model the GPIO Matrix as first-class Replaces the per-peripheral ad-hoc `_ledc_gpio_map` cache with a proper signal-routing abstraction that mirrors the ESP32 SoC's IO_MUX + GPIO Matrix exactly. Same idea as real silicon: signal sources (LEDC channels, RMT, MCPWM, ...) → 40-entry routing table → GPIO pins. Motivation (from user bug report in velxio.dev/project/5218f9e3-136d-43b3-bba1-6cebde21e1a4): two ESP32 servos on a solar-tracker visibly oscillated between two positions instead of moving smoothly when the user changed LDR sliders. Commit 77bf897 patched it (per-channel gpio memo + broadcast guard) but the user requested a proper hardware-fidel architecture, not patches. Backend: * `app/services/signal_router.py` — SignalRouter class. Forward index (gpio → signal_id) + reverse index (signal_id → set of gpios). `replace_snapshot()` returns the diff for the polling- fallback path; future C plugin hook becomes a push without touching this code. * `app/services/esp32_signals.py` — Signal id constants from ESP32 TRM (LEDC HS 72-79, LS 80-87) + `ledc_signal_for_channel()` helper. * `app/services/esp32_worker.py` — `_ledc_gpio_map` is gone; `_refresh_ledc_gpio_map` replaced by `_refresh_signal_routing` which emits `gpio_routing {gpio, signal_id}` events on diff. The 0x5000 LEDC callback and the LEDC poll thread now emit `ledc_duty {channel, duty_pct}` (canonical, no gpio) alongside the legacy `ledc_update {channel, duty, gpio}` for back-compat during rollout. Frontend: * `simulation/SignalRouter.ts` — 1-to-1 TS mirror of the Python class. Same forward + reverse index; same `pinsForSignal` / `updateRouting` / `clearRouting` API. * `simulation/esp32-signals.ts` — Signal id constants, mirror of the Python module. * `simulation/Esp32Bridge.ts` — new `onLedcDuty`, `onGpioRouting`, `onGpioRoutingClear` callbacks; handlers for the new event types. * `store/useSimulatorStore.ts` — `makeLedcDutyHandler` looks up pins via `router.pinsForSignal(ledcSignalForChannel(channel))` and dispatches per pin. `makeGpioRoutingHandler` / `makeGpioRoutingClearHandler` keep the mirror in sync. Per-board `signalRouterMap` parallels `pinManagerMap` in lifecycle. `makeLedcUpdateHandler` (and its memo workaround from 77bf897) stays wired for back-compat during rollout; removed in a follow-up commit once prod is verified stable on the new path. Tests: * `test/backend/unit/test_signal_router.py` (20 tests) covers update/clear semantics, idempotency, multi-pin routing, snapshot diff, channel↔signal-id helpers, and the multi-servo regression scenario. * `frontend/src/__tests__/SignalRouter.test.ts` (17 tests) is the mirror — same scenarios on the TS side. * `frontend/src/__tests__/esp32-multi-servo-gpio-matrix.test.ts` (6 tests) drives the end-to-end SignalRouter handler pipeline, asserts that two servos on GPIO 13/12 via LEDC channels 0/1 move independently (no mirroring), that re-routing carries cleanly, and — critically — that `PinManager.broadcastPwm` is never called. Totals: +700 LOC, 1876 frontend tests pass (was 1853), 278 backend unit tests pass (was 259). Docs: ESP32_EMULATION.md §9.2 rewritten with the new architecture diagram + a runbook for adding future peripherals through the SignalRouter. The C plugin hook in qemu-lcgamboa that would push gpio_out_sel writes synchronously (eliminating the polling race window entirely) is the next step — kept as a follow-up because the polling-fallback path here already resolves the routing before each duty event fires, so the bug is fixed end-to-end. The plugin work removes the race condition fundamentally.
2026-05-17 10:00:53 +07:00
Emits `gpio_routing` for every routing that changed since the
last scan and `gpio_routing_clear` for routings that disappeared,
so the frontend's mirror stays in lock-step without re-sending
the whole table. Idempotent: a scan with no changes emits no
events.
Called every 100 ms from the LEDC poll thread. Also called
eagerly from the 0x5000 LEDC duty callback so the first duty
write after `ledcAttachPin` doesn't race the periodic poll.
"""
try:
out_sel_ptr = lib.qemu_picsimlab_get_internals(2)
if not out_sel_ptr:
return
out_sel = (ctypes.c_uint32 * 40).from_address(out_sel_ptr)
feat(esp32): SignalRouter — model the GPIO Matrix as first-class Replaces the per-peripheral ad-hoc `_ledc_gpio_map` cache with a proper signal-routing abstraction that mirrors the ESP32 SoC's IO_MUX + GPIO Matrix exactly. Same idea as real silicon: signal sources (LEDC channels, RMT, MCPWM, ...) → 40-entry routing table → GPIO pins. Motivation (from user bug report in velxio.dev/project/5218f9e3-136d-43b3-bba1-6cebde21e1a4): two ESP32 servos on a solar-tracker visibly oscillated between two positions instead of moving smoothly when the user changed LDR sliders. Commit 77bf897 patched it (per-channel gpio memo + broadcast guard) but the user requested a proper hardware-fidel architecture, not patches. Backend: * `app/services/signal_router.py` — SignalRouter class. Forward index (gpio → signal_id) + reverse index (signal_id → set of gpios). `replace_snapshot()` returns the diff for the polling- fallback path; future C plugin hook becomes a push without touching this code. * `app/services/esp32_signals.py` — Signal id constants from ESP32 TRM (LEDC HS 72-79, LS 80-87) + `ledc_signal_for_channel()` helper. * `app/services/esp32_worker.py` — `_ledc_gpio_map` is gone; `_refresh_ledc_gpio_map` replaced by `_refresh_signal_routing` which emits `gpio_routing {gpio, signal_id}` events on diff. The 0x5000 LEDC callback and the LEDC poll thread now emit `ledc_duty {channel, duty_pct}` (canonical, no gpio) alongside the legacy `ledc_update {channel, duty, gpio}` for back-compat during rollout. Frontend: * `simulation/SignalRouter.ts` — 1-to-1 TS mirror of the Python class. Same forward + reverse index; same `pinsForSignal` / `updateRouting` / `clearRouting` API. * `simulation/esp32-signals.ts` — Signal id constants, mirror of the Python module. * `simulation/Esp32Bridge.ts` — new `onLedcDuty`, `onGpioRouting`, `onGpioRoutingClear` callbacks; handlers for the new event types. * `store/useSimulatorStore.ts` — `makeLedcDutyHandler` looks up pins via `router.pinsForSignal(ledcSignalForChannel(channel))` and dispatches per pin. `makeGpioRoutingHandler` / `makeGpioRoutingClearHandler` keep the mirror in sync. Per-board `signalRouterMap` parallels `pinManagerMap` in lifecycle. `makeLedcUpdateHandler` (and its memo workaround from 77bf897) stays wired for back-compat during rollout; removed in a follow-up commit once prod is verified stable on the new path. Tests: * `test/backend/unit/test_signal_router.py` (20 tests) covers update/clear semantics, idempotency, multi-pin routing, snapshot diff, channel↔signal-id helpers, and the multi-servo regression scenario. * `frontend/src/__tests__/SignalRouter.test.ts` (17 tests) is the mirror — same scenarios on the TS side. * `frontend/src/__tests__/esp32-multi-servo-gpio-matrix.test.ts` (6 tests) drives the end-to-end SignalRouter handler pipeline, asserts that two servos on GPIO 13/12 via LEDC channels 0/1 move independently (no mirroring), that re-routing carries cleanly, and — critically — that `PinManager.broadcastPwm` is never called. Totals: +700 LOC, 1876 frontend tests pass (was 1853), 278 backend unit tests pass (was 259). Docs: ESP32_EMULATION.md §9.2 rewritten with the new architecture diagram + a runbook for adding future peripherals through the SignalRouter. The C plugin hook in qemu-lcgamboa that would push gpio_out_sel writes synchronously (eliminating the polling race window entirely) is the next step — kept as a follow-up because the polling-fallback path here already resolves the routing before each duty event fires, so the bug is fixed end-to-end. The plugin work removes the race condition fundamentally.
2026-05-17 10:00:53 +07:00
snapshot: dict[int, int] = {}
for gpio_pin in range(40):
feat(esp32): SignalRouter — model the GPIO Matrix as first-class Replaces the per-peripheral ad-hoc `_ledc_gpio_map` cache with a proper signal-routing abstraction that mirrors the ESP32 SoC's IO_MUX + GPIO Matrix exactly. Same idea as real silicon: signal sources (LEDC channels, RMT, MCPWM, ...) → 40-entry routing table → GPIO pins. Motivation (from user bug report in velxio.dev/project/5218f9e3-136d-43b3-bba1-6cebde21e1a4): two ESP32 servos on a solar-tracker visibly oscillated between two positions instead of moving smoothly when the user changed LDR sliders. Commit 77bf897 patched it (per-channel gpio memo + broadcast guard) but the user requested a proper hardware-fidel architecture, not patches. Backend: * `app/services/signal_router.py` — SignalRouter class. Forward index (gpio → signal_id) + reverse index (signal_id → set of gpios). `replace_snapshot()` returns the diff for the polling- fallback path; future C plugin hook becomes a push without touching this code. * `app/services/esp32_signals.py` — Signal id constants from ESP32 TRM (LEDC HS 72-79, LS 80-87) + `ledc_signal_for_channel()` helper. * `app/services/esp32_worker.py` — `_ledc_gpio_map` is gone; `_refresh_ledc_gpio_map` replaced by `_refresh_signal_routing` which emits `gpio_routing {gpio, signal_id}` events on diff. The 0x5000 LEDC callback and the LEDC poll thread now emit `ledc_duty {channel, duty_pct}` (canonical, no gpio) alongside the legacy `ledc_update {channel, duty, gpio}` for back-compat during rollout. Frontend: * `simulation/SignalRouter.ts` — 1-to-1 TS mirror of the Python class. Same forward + reverse index; same `pinsForSignal` / `updateRouting` / `clearRouting` API. * `simulation/esp32-signals.ts` — Signal id constants, mirror of the Python module. * `simulation/Esp32Bridge.ts` — new `onLedcDuty`, `onGpioRouting`, `onGpioRoutingClear` callbacks; handlers for the new event types. * `store/useSimulatorStore.ts` — `makeLedcDutyHandler` looks up pins via `router.pinsForSignal(ledcSignalForChannel(channel))` and dispatches per pin. `makeGpioRoutingHandler` / `makeGpioRoutingClearHandler` keep the mirror in sync. Per-board `signalRouterMap` parallels `pinManagerMap` in lifecycle. `makeLedcUpdateHandler` (and its memo workaround from 77bf897) stays wired for back-compat during rollout; removed in a follow-up commit once prod is verified stable on the new path. Tests: * `test/backend/unit/test_signal_router.py` (20 tests) covers update/clear semantics, idempotency, multi-pin routing, snapshot diff, channel↔signal-id helpers, and the multi-servo regression scenario. * `frontend/src/__tests__/SignalRouter.test.ts` (17 tests) is the mirror — same scenarios on the TS side. * `frontend/src/__tests__/esp32-multi-servo-gpio-matrix.test.ts` (6 tests) drives the end-to-end SignalRouter handler pipeline, asserts that two servos on GPIO 13/12 via LEDC channels 0/1 move independently (no mirroring), that re-routing carries cleanly, and — critically — that `PinManager.broadcastPwm` is never called. Totals: +700 LOC, 1876 frontend tests pass (was 1853), 278 backend unit tests pass (was 259). Docs: ESP32_EMULATION.md §9.2 rewritten with the new architecture diagram + a runbook for adding future peripherals through the SignalRouter. The C plugin hook in qemu-lcgamboa that would push gpio_out_sel writes synchronously (eliminating the polling race window entirely) is the next step — kept as a follow-up because the polling-fallback path here already resolves the routing before each duty event fires, so the bug is fixed end-to-end. The plugin work removes the race condition fundamentally.
2026-05-17 10:00:53 +07:00
signal_id = int(out_sel[gpio_pin]) & 0xFF
# 0..71 / 88..255 are signal sources velxio doesn't
# model yet; include them in the snapshot only if the
# firmware actively routed them so future peripherals
# can opt in without code changes here.
if SIG_LEDC_HS_CH0_OUT_IDX <= signal_id <= SIG_LEDC_LS_CH_LAST:
snapshot[gpio_pin] = signal_id
changed, cleared = _signal_router.replace_snapshot(snapshot)
for gpio_pin, signal_id in changed:
_emit({'type': 'gpio_routing',
'gpio': gpio_pin,
'signal_id': signal_id})
for gpio_pin in cleared:
_emit({'type': 'gpio_routing_clear', 'gpio': gpio_pin})
except Exception:
pass
# Sensor state: gpio_pin → {type, properties..., saw_low, responding}
_sensors: dict[int, dict] = {}
_sensors_lock = threading.Lock()
# ── Generic sync-handler registry ────────────────────────────────────────
# Each entry implements step() -> bool. step() is called once per
# GPIO_IN read sync (every digitalRead() / pulseIn() iteration in firmware).
# Returning True signals completion; the dispatcher removes the handler.
# All mutations happen exclusively on the QEMU thread — no locks needed.
#
# To add a new GPIO-timed sensor:
# 1. Write a class with a step() -> bool method
# 2. Append an instance to _sync_handlers from _on_pin_change or _on_dir_change
# 3. No changes to the dispatcher are needed
_sync_handlers: list = []
def _dht22_build_payload(temperature: float, humidity: float) -> list[int]:
"""Build 5-byte DHT22 data payload: [hum_H, hum_L, temp_H, temp_L, checksum]."""
hum = round(humidity * 10)
tmp = round(temperature * 10)
h_H = (hum >> 8) & 0xFF
h_L = hum & 0xFF
raw_t = ((-tmp) & 0x7FFF) | 0x8000 if tmp < 0 else tmp & 0x7FFF
t_H = (raw_t >> 8) & 0xFF
t_L = raw_t & 0xFF
chk = (h_H + h_L + t_H + t_L) & 0xFF
return [h_H, h_L, t_H, t_L, chk]
def _dht22_build_sync_phases(payload: list[int]) -> list[tuple[int, int]]:
"""Build list of (sync_count, pin_value) phase transitions for DHT22.
Each entry means: after sync_count digitalRead() calls in this phase,
drive the pin to pin_value and advance to the next phase.
The Adafruit DHT library decodes bits by comparing
highCycles > lowCycles only RATIOS matter, not absolute values.
We use the raw µs values as sync counts to preserve correct ratios.
After the last data bit (40th bit HIGHLOW), the firmware's
expectPulse() loop ends no more syncs will arrive. So we do
NOT add a trailing phase; cleanup happens immediately after the
last phase transition fires.
"""
phases: list[tuple[int, int]] = []
# Preamble: LOW 80 syncs → drive HIGH
phases.append((80, 1))
# Preamble: HIGH 80 syncs → drive LOW
phases.append((80, 0))
# 40 data bits: LOW 50 syncs → HIGH, then HIGH (26 or 70) → LOW
for byte_val in payload:
for b in range(7, -1, -1):
bit = (byte_val >> b) & 1
phases.append((50, 1)) # LOW phase → drive HIGH
phases.append((70 if bit else 26, 0)) # HIGH phase → drive LOW
return phases
class DHT22SyncHandler:
"""Drives the DHT22 waveform synchronously, one GPIO_IN read sync at a time.
Uses phase-based counting: each phase defines how many syncs to wait before
driving the pin to a new value. The Adafruit DHT library decodes bits by
comparing highCycles vs lowCycles only RATIOS matter, so raw µs values used
as sync counts preserve the correct bit decoding.
"""
def __init__(self, gpio: int, slot: int, phases: list[tuple[int, int]]) -> None:
self._gpio = gpio
self._slot = slot
self._phases = phases
self._phase_idx = 0
self._count = 0
self._total_syncs = 0
def step(self) -> bool:
"""Advance one sync tick. Returns True when the handler is done."""
self._count += 1
if self._phase_idx >= len(self._phases):
return self._finish()
target, pin_value = self._phases[self._phase_idx]
if self._count >= target:
lib.qemu_picsimlab_set_pin(self._slot, pin_value)
self._total_syncs += self._count
self._count = 0
self._phase_idx += 1
if self._phase_idx >= len(self._phases):
return self._finish()
return False
def _finish(self) -> bool:
with _sensors_lock:
sensor = _sensors.get(self._gpio)
if sensor:
sensor['responding'] = False
_log(f'DHT22 sync respond done gpio={self._gpio} '
f'total_syncs={self._total_syncs} phases={len(self._phases)}')
_emit({'type': 'system', 'event': 'dht22_diag', 'gpio': self._gpio,
'status': 'ok', 'total_syncs': self._total_syncs})
return True
class HCSR04SyncHandler:
"""Drives HC-SR04 ECHO pin synchronously from the QEMU GPIO_IN read callback.
_on_dir_change(-1, -1) fires for EVERY gpio_get_level() call in the
firmware including pulseIn()'s busy-wait loops. The state machine:
Phase 1 of pulseIn() (wait for !HIGH = wait for LOW):
ECHO is LOW so the condition is immediately false. One
gpio_get_level() call fires. We skip it.
Phase 2 of pulseIn() (wait for HIGH):
After skipping _SKIP_COUNT pre-phase2 callbacks, the next
gpio_get_level() fires. We set ECHO HIGH here. pulseIn() sees HIGH
immediately (qemu_picsimlab_set_pin is synchronous) and exits phase 2.
Phase 3 of pulseIn() (measure HIGH duration):
Subsequent gpio_get_level() calls fire step(). We hold ECHO HIGH
until echo_us wall-clock µs have elapsed (perf_counter_ns), then
set LOW. Virtual time wall-clock time (confirmed: 30 000 µs
pulseIn timeout = 30 ms wall-clock), so pulseIn() measures echo_us
virtual µs correct distance.
Guard: wall-clock timeouts replace step-count limits. A step-count
guard is wrong because steps fire at rates that vary with QEMU load;
using a fixed count would cut the pulse short for longer distances
(100 cm = 5 800 µs, 200 cm = 11 600 µs) before elapsed_us is reached.
"""
_SKIP_COUNT = 2 # pre-phase2 callbacks to skip
_ARMED_TIMEOUT_US = 40_000 # µs; give up if we never enter 'high'
_HIGH_TIMEOUT_US = 32_000 # µs; pulseIn() timeout is 30 000 µs
def __init__(self, trig_gpio: int, echo_slot: int, echo_us: int) -> None:
self._trig_gpio = trig_gpio
self._echo_slot = echo_slot
self._echo_us = echo_us
self._state = 'armed'
self._total_steps = 0
self._arm_start_ns = time.perf_counter_ns()
self._echo_start_ns = 0
def step(self) -> bool:
self._total_steps += 1
if self._state == 'armed':
if self._total_steps <= self._SKIP_COUNT:
return False
# Check armed timeout (handler never entered 'high')
arm_us = (time.perf_counter_ns() - self._arm_start_ns) // 1000
if arm_us > self._ARMED_TIMEOUT_US:
_log(f'HCSR04 armed timeout trig={self._trig_gpio} '
f'arm_us={arm_us} steps={self._total_steps} — releasing')
with _sensors_lock:
sensor = _sensors.get(self._trig_gpio)
if sensor:
sensor['responding'] = False
return True
# pulseIn() is now in phase 2 (waiting for HIGH) → raise ECHO
lib.qemu_picsimlab_set_pin(self._echo_slot, 1)
_emit({'type': 'system', 'event': 'hcsr04_echo_high',
'gpio': self._trig_gpio, 'echo_us': self._echo_us})
_log(f'HCSR04 ECHO HIGH (sync) trig={self._trig_gpio} '
f'slot={self._echo_slot} echo_us={self._echo_us} '
f'armed_us={arm_us} skip={self._total_steps - 1}')
self._echo_start_ns = time.perf_counter_ns()
self._state = 'high'
return False
elif self._state == 'high':
elapsed_us = (time.perf_counter_ns() - self._echo_start_ns) // 1000
if elapsed_us >= self._echo_us:
return self._finish(elapsed_us)
# Safety: don't hold ECHO past pulseIn() timeout
if elapsed_us >= self._HIGH_TIMEOUT_US:
_log(f'HCSR04 high timeout trig={self._trig_gpio} '
f'elapsed_us={elapsed_us} echo_us={self._echo_us}')
lib.qemu_picsimlab_set_pin(self._echo_slot, 0)
with _sensors_lock:
sensor = _sensors.get(self._trig_gpio)
if sensor:
sensor['responding'] = False
return True
return False
def _finish(self, elapsed_us: int) -> bool:
lib.qemu_picsimlab_set_pin(self._echo_slot, 0)
_emit({'type': 'system', 'event': 'hcsr04_echo_low',
'gpio': self._trig_gpio})
_log(f'HCSR04 ECHO LOW (sync) trig={self._trig_gpio} '
f'elapsed_us={elapsed_us} echo_us={self._echo_us} '
f'steps={self._total_steps}')
with _sensors_lock:
sensor = _sensors.get(self._trig_gpio)
if sensor:
sensor['responding'] = False
return True
# ── 5. ctypes callbacks (called from QEMU thread) ─────────────────────────
def _on_pin_change(slot: int, value: int) -> None:
if _stopped.is_set():
return
gpio = int(_PINMAP[slot]) if 1 <= slot <= _GPIO_COUNT else slot
_pin_state[gpio] = value & 1
# Flush pending SPI bytes BEFORE announcing this pin change so the
# frontend processes them under the pin state (e.g. the ILI9341 DC line)
# that was in effect when they were sent. With CS events gated off for
# pure-display sims, this — plus the buffer cap / timer — is what keeps
# the SPI byte stream correctly ordered against the DC gpio_change on the
# single WS channel (the per-CS flush used to do it).
with _spi_buf_lock:
_flush_spi_batch_locked()
_emit({'type': 'gpio_change', 'pin': gpio, 'state': value})
# Dispatch to any custom-chip runtime that has a vx_pin_watch on this
# GPIO. We're called from QEMU's GPIO state-change path which holds the
# IO-thread lock, so the chip's callback can safely call vx_pin_write
# (which goes back into picsimlab and requires the same lock).
if _chip_pin_watch_runtimes:
for rt in _chip_pin_watch_runtimes:
try:
rt.notify_pin_change(gpio, value)
except Exception as e:
_log(f'[custom-chip pin_watch] error: {e!r}')
# ePaper SSD168x: track DC / CS / RST pin states for every slave.
# CS rising re-arms the next byte; CS falling activates the slave.
# RST falling clears the controller's RAM (active LOW).
if _epaper_state:
for st in _epaper_state.values():
if gpio == st['dc_pin']:
st['dc_high'] = bool(value & 1)
elif gpio == st['cs_pin']:
st['cs_low'] = (value & 1) == 0
elif gpio == st['rst_pin']:
if (value & 1) == 0:
st['slave'].reset()
# Sensor protocol dispatch by type
with _sensors_lock:
sensor = _sensors.get(gpio)
if sensor is None:
return
stype = sensor.get('type', '')
if stype == 'dht22':
# Record that the firmware drove the pin LOW (start signal).
# The actual response is triggered from _on_dir_change when the
# firmware switches the pin to INPUT mode.
if value == 0 and not sensor.get('responding', False):
sensor['saw_low'] = True
elif stype == 'hc-sr04':
# HC-SR04 trigger:
# TRIG HIGH → arm: save echo params
# TRIG LOW → add HCSR04SyncHandler to _sync_handlers
#
# The sync handler drives ECHO from within the QEMU GPIO_IN read
# callback (_on_dir_change slot=-1 direction=-1), which fires for
# every gpio_get_level() call in the firmware — including pulseIn().
# This is 100% synchronous with the QEMU thread, eliminating the
# non-deterministic visibility issue that plagued the background-thread
# approach (only ~33% success rate due to cross-thread pin propagation).
if value == 1 and not sensor.get('responding', False):
echo_pin = int(sensor.get('echo_pin', gpio + 1))
distance = float(sensor.get('distance', 40.0))
echo_us = max(100, int(distance * 58))
sensor['_trig_armed'] = {'echo_slot': echo_pin + 1, 'echo_us': echo_us}
_log(f'HCSR04 TRIG HIGH (armed) gpio={gpio} echo_slot={echo_pin + 1} '
f'echo_us={echo_us} dist={distance}cm')
elif value == 0 and sensor.get('_trig_armed') and not sensor.get('responding', False):
armed = sensor.pop('_trig_armed')
echo_slot = armed['echo_slot']
echo_us = armed['echo_us']
sensor['responding'] = True
_sync_handlers.append(HCSR04SyncHandler(gpio, echo_slot, echo_us))
_log(f'HCSR04 TRIG LOW → sync handler armed gpio={gpio} '
f'echo_slot={echo_slot} echo_us={echo_us}')
def _on_dir_change(slot: int, direction: int) -> None:
if _stopped.is_set():
return
# ── GPIO_IN read sync (slot == -1, direction == -1) ──────────────
# Every digitalRead() in the firmware triggers this sync. We use
# it to drive DHT22 pin transitions synchronously on the QEMU
# thread, perfectly synchronized with the firmware's expectPulse()
# loop iterations.
if slot == -1:
if direction == -1:
# GPIO_IN read sync — advance all active sync handlers.
# step() returns True when done; list-comp removes finished handlers.
if _sync_handlers:
_sync_handlers[:] = [h for h in _sync_handlers if not h.step()]
return # always return for GPIO_IN syncs (fast path)
marker = direction & 0xF000
if marker == 0x5000: # LEDC duty change (from esp32_ledc.c)
ledc_ch = (direction >> 8) & 0x0F
intensity = direction & 0xFF # 0-100 percentage
feat(esp32): SignalRouter — model the GPIO Matrix as first-class Replaces the per-peripheral ad-hoc `_ledc_gpio_map` cache with a proper signal-routing abstraction that mirrors the ESP32 SoC's IO_MUX + GPIO Matrix exactly. Same idea as real silicon: signal sources (LEDC channels, RMT, MCPWM, ...) → 40-entry routing table → GPIO pins. Motivation (from user bug report in velxio.dev/project/5218f9e3-136d-43b3-bba1-6cebde21e1a4): two ESP32 servos on a solar-tracker visibly oscillated between two positions instead of moving smoothly when the user changed LDR sliders. Commit 77bf897 patched it (per-channel gpio memo + broadcast guard) but the user requested a proper hardware-fidel architecture, not patches. Backend: * `app/services/signal_router.py` — SignalRouter class. Forward index (gpio → signal_id) + reverse index (signal_id → set of gpios). `replace_snapshot()` returns the diff for the polling- fallback path; future C plugin hook becomes a push without touching this code. * `app/services/esp32_signals.py` — Signal id constants from ESP32 TRM (LEDC HS 72-79, LS 80-87) + `ledc_signal_for_channel()` helper. * `app/services/esp32_worker.py` — `_ledc_gpio_map` is gone; `_refresh_ledc_gpio_map` replaced by `_refresh_signal_routing` which emits `gpio_routing {gpio, signal_id}` events on diff. The 0x5000 LEDC callback and the LEDC poll thread now emit `ledc_duty {channel, duty_pct}` (canonical, no gpio) alongside the legacy `ledc_update {channel, duty, gpio}` for back-compat during rollout. Frontend: * `simulation/SignalRouter.ts` — 1-to-1 TS mirror of the Python class. Same forward + reverse index; same `pinsForSignal` / `updateRouting` / `clearRouting` API. * `simulation/esp32-signals.ts` — Signal id constants, mirror of the Python module. * `simulation/Esp32Bridge.ts` — new `onLedcDuty`, `onGpioRouting`, `onGpioRoutingClear` callbacks; handlers for the new event types. * `store/useSimulatorStore.ts` — `makeLedcDutyHandler` looks up pins via `router.pinsForSignal(ledcSignalForChannel(channel))` and dispatches per pin. `makeGpioRoutingHandler` / `makeGpioRoutingClearHandler` keep the mirror in sync. Per-board `signalRouterMap` parallels `pinManagerMap` in lifecycle. `makeLedcUpdateHandler` (and its memo workaround from 77bf897) stays wired for back-compat during rollout; removed in a follow-up commit once prod is verified stable on the new path. Tests: * `test/backend/unit/test_signal_router.py` (20 tests) covers update/clear semantics, idempotency, multi-pin routing, snapshot diff, channel↔signal-id helpers, and the multi-servo regression scenario. * `frontend/src/__tests__/SignalRouter.test.ts` (17 tests) is the mirror — same scenarios on the TS side. * `frontend/src/__tests__/esp32-multi-servo-gpio-matrix.test.ts` (6 tests) drives the end-to-end SignalRouter handler pipeline, asserts that two servos on GPIO 13/12 via LEDC channels 0/1 move independently (no mirroring), that re-routing carries cleanly, and — critically — that `PinManager.broadcastPwm` is never called. Totals: +700 LOC, 1876 frontend tests pass (was 1853), 278 backend unit tests pass (was 259). Docs: ESP32_EMULATION.md §9.2 rewritten with the new architecture diagram + a runbook for adding future peripherals through the SignalRouter. The C plugin hook in qemu-lcgamboa that would push gpio_out_sel writes synchronously (eliminating the polling race window entirely) is the next step — kept as a follow-up because the polling-fallback path here already resolves the routing before each duty event fires, so the bug is fixed end-to-end. The plugin work removes the race condition fundamentally.
2026-05-17 10:00:53 +07:00
# Refresh the GPIO Matrix snapshot first so the routing
# is current — emits any gpio_routing events the
# frontend needs to update its SignalRouter mirror
# BEFORE the duty arrives.
_refresh_signal_routing()
# New canonical event (SignalRouter consumer): channel +
# duty only, no gpio. The frontend resolves channel →
# signal_id → pins via its mirror.
_emit({'type': 'ledc_duty',
'channel': ledc_ch,
'duty_pct': intensity})
return
# ── DHT22: track direction changes + trigger sync response ───────
if slot >= 1:
gpio = int(_PINMAP[slot]) if slot <= _GPIO_COUNT else slot
with _sensors_lock:
sensor = _sensors.get(gpio)
if sensor is not None and sensor.get('type') == 'dht22':
if direction == 1:
# OUTPUT mode — record timestamp for diagnostics
sensor['dir_out_ns'] = time.perf_counter_ns()
elif direction == 0:
# INPUT mode — trigger DHT22 sync-based response
if sensor.get('saw_low', False) and not sensor.get('responding', False):
sensor['saw_low'] = False
sensor['responding'] = True
# Build the response waveform phases
temp = sensor.get('temperature', 25.0)
hum = sensor.get('humidity', 50.0)
payload = _dht22_build_payload(temp, hum)
phases = _dht22_build_sync_phases(payload)
# Drive pin LOW synchronously — firmware sees LOW
# at its first digitalRead() in expectPulse().
lib.qemu_picsimlab_set_pin(slot, 0)
# Arm the sync-based response state machine
_sync_handlers.append(DHT22SyncHandler(gpio, slot, phases))
_log(f'DHT22 sync armed gpio={gpio} '
f'temp={temp} hum={hum} '
f'phases={len(phases)} payload={payload}')
gpio = int(_PINMAP[slot]) if 1 <= slot <= _GPIO_COUNT else slot
_emit({'type': 'gpio_dir', 'pin': gpio, 'dir': direction})
def _on_uart_tx(uart_id: int, byte_val: int) -> None:
if _stopped.is_set():
return
_emit({'type': 'uart_tx', 'uart': uart_id, 'byte': byte_val})
# Dispatch to any custom-chip runtimes that declared a UART.
# The chip's on_rx_byte callback runs synchronously in this thread.
for rt in _chip_uart_runtimes:
try:
rt.feed_uart_byte(byte_val)
except Exception as e:
_log(f'[custom-chip uart_tx] error: {e!r}')
# Crash / reboot detection on UART0 only
if uart_id == 0:
_uart0_buf.append(byte_val)
if byte_val == ord('\n') or len(_uart0_buf) >= 512:
chunk = bytes(_uart0_buf)
_uart0_buf.clear()
if _CRASH_STR in chunk and not _crashed[0]:
_crashed[0] = True
_emit({'type': 'system', 'event': 'crash',
'reason': 'cache_error', 'reboot': _reboot_count[0]})
if _REBOOT_STR in chunk:
_crashed[0] = False
_reboot_count[0] += 1
_emit({'type': 'system', 'event': 'reboot',
'count': _reboot_count[0]})
# WiFi progress logging (only in debug — helps diagnose prod issues)
if wifi_enabled:
line = chunk.decode('utf-8', errors='replace').strip()
if any(kw in line.lower() for kw in (
'wifi', 'connect', 'ip address', 'wl_connected',
'dhcp', 'sta_start', 'sta_got_ip', 'sta_disconnect',
)):
_log(f'[wifi-uart] {line}')
def _on_rmt_event(channel: int, config0: int, value: int) -> None:
if _stopped.is_set():
return
level0, dur0, level1, dur1 = _decode_rmt_item(value)
_emit({'type': 'rmt_event', 'channel': channel, 'config0': config0,
'value': value, 'level0': level0, 'dur0': dur0,
'level1': level1, 'dur1': dur1})
if channel not in _rmt_decoders:
_rmt_decoders[channel] = _RmtDecoder(channel)
pixels = _rmt_decoders[channel].feed(value)
if pixels:
_emit({'type': 'ws2812_update', 'channel': channel, 'pixels': pixels})
def _on_gpio_matrix(gpio: int, signal_id: int) -> None:
"""Synchronous GPIO Matrix routing event from libqemu 1.1.0+.
fix(ci+esp32): unblock backend e2e + bump frontend node heap Two CI failures landed after PR #196 (esp32-gpio-matrix-cb-callback) merged. Both are independent and fixed here together. 1) **Backend E2E: ESP32 hangs at bootloader handoff.** PR #196 added picsimlab_gpio_matrix_cb which fires on QEMU's iothread. The handler did `_emit({...})` for every routing change — and the ESP-IDF bootloader writes to gpio_out_sel *hundreds* of times during early boot (each peripheral init configures its matrix slot). Each emit acquires _stdout_lock and writes to the worker→manager pipe. If the manager drains even briefly slow, the pipe fills, write blocks, and the iothread stalls — symptom: ESP32 reports `entry 0x400805e4` then no Arduino setup() output for 75 s. Fix: the iothread callback now ONLY mutates the SignalRouter snapshot. It never emits. The 10 Hz poll thread (_refresh_signal_routing) stays as the sole emitter, so the wire-format event stream is unchanged. Benefit of having the callback over poll-only is reduced worst-case routing-emit latency (next poll tick vs up to 100 ms) and a warmer snapshot dict for cheaper poll diffs. 2) **Frontend Tests: Node OOM at end of suite.** 117 test files run in one forks-pool worker. Several lazy-load the ngspice emscripten module (~30 MB), the MixedModeScheduler singleton, and other heavy modules whose dispose hooks aren't reached because singletons leak across files. Cumulative heap pressure exceeds Node's 4 GB default; the worker hits "Ineffective mark-compacts near heap limit" AFTER all 1881 tests pass and the OOM kill is reported by vitest as "Worker exited unexpectedly / Timeout terminating forks worker". This is not a real test failure — every individual test passes. Quick fix: pass NODE_OPTIONS=--max-old-space-size=8192 to the `npm test` step. Long-term, the singletons should add dispose hooks that test fixtures call in afterAll(), or the suite should shard into multiple `vitest run --shard` invocations. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 21:04:50 +07:00
Critical: this fires on QEMU's iothread, hundreds of times during
early boot (bootloader + IDF init configure every GPIO Matrix
slot). It MUST NOT do anything that can block the iothread
most importantly NOT _emit() over the stdout pipe, because if
the manager's reader is even briefly stalled, the pipe fills,
write() blocks, the iothread freezes, and the entire guest
stops (symptom: ESP32 boot stops at "entry 0x400805e4" with no
Arduino setup() output).
So this callback ONLY mutates the in-memory SignalRouter
snapshot. The 10 Hz poll thread (_refresh_signal_routing) is
the sole emitter of gpio_routing / gpio_routing_clear events.
The callback's only benefit over the poll alone is reducing
the worst-case routing-to-emit latency from ~100 ms to one
poll tick, AND keeping the snapshot dict warm so the next
poll's diff is cheaper.
"""
if _stopped.is_set():
return
try:
sid_lo = signal_id & 0xFF
fix(ci+esp32): unblock backend e2e + bump frontend node heap Two CI failures landed after PR #196 (esp32-gpio-matrix-cb-callback) merged. Both are independent and fixed here together. 1) **Backend E2E: ESP32 hangs at bootloader handoff.** PR #196 added picsimlab_gpio_matrix_cb which fires on QEMU's iothread. The handler did `_emit({...})` for every routing change — and the ESP-IDF bootloader writes to gpio_out_sel *hundreds* of times during early boot (each peripheral init configures its matrix slot). Each emit acquires _stdout_lock and writes to the worker→manager pipe. If the manager drains even briefly slow, the pipe fills, write blocks, and the iothread stalls — symptom: ESP32 reports `entry 0x400805e4` then no Arduino setup() output for 75 s. Fix: the iothread callback now ONLY mutates the SignalRouter snapshot. It never emits. The 10 Hz poll thread (_refresh_signal_routing) stays as the sole emitter, so the wire-format event stream is unchanged. Benefit of having the callback over poll-only is reduced worst-case routing-emit latency (next poll tick vs up to 100 ms) and a warmer snapshot dict for cheaper poll diffs. 2) **Frontend Tests: Node OOM at end of suite.** 117 test files run in one forks-pool worker. Several lazy-load the ngspice emscripten module (~30 MB), the MixedModeScheduler singleton, and other heavy modules whose dispose hooks aren't reached because singletons leak across files. Cumulative heap pressure exceeds Node's 4 GB default; the worker hits "Ineffective mark-compacts near heap limit" AFTER all 1881 tests pass and the OOM kill is reported by vitest as "Worker exited unexpectedly / Timeout terminating forks worker". This is not a real test failure — every individual test passes. Quick fix: pass NODE_OPTIONS=--max-old-space-size=8192 to the `npm test` step. Long-term, the singletons should add dispose hooks that test fixtures call in afterAll(), or the suite should shard into multiple `vitest run --shard` invocations. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 21:04:50 +07:00
if signal_id == 0x100:
_signal_router.clear_routing(gpio)
elif SIG_LEDC_HS_CH0_OUT_IDX <= sid_lo <= SIG_LEDC_LS_CH_LAST:
_signal_router.update_routing(gpio, sid_lo)
# Other signal_id values fall outside what the frontend
# SignalRouter currently cares about; future peripherals
# extend the range above.
except Exception:
fix(ci+esp32): unblock backend e2e + bump frontend node heap Two CI failures landed after PR #196 (esp32-gpio-matrix-cb-callback) merged. Both are independent and fixed here together. 1) **Backend E2E: ESP32 hangs at bootloader handoff.** PR #196 added picsimlab_gpio_matrix_cb which fires on QEMU's iothread. The handler did `_emit({...})` for every routing change — and the ESP-IDF bootloader writes to gpio_out_sel *hundreds* of times during early boot (each peripheral init configures its matrix slot). Each emit acquires _stdout_lock and writes to the worker→manager pipe. If the manager drains even briefly slow, the pipe fills, write blocks, and the iothread stalls — symptom: ESP32 reports `entry 0x400805e4` then no Arduino setup() output for 75 s. Fix: the iothread callback now ONLY mutates the SignalRouter snapshot. It never emits. The 10 Hz poll thread (_refresh_signal_routing) stays as the sole emitter, so the wire-format event stream is unchanged. Benefit of having the callback over poll-only is reduced worst-case routing-emit latency (next poll tick vs up to 100 ms) and a warmer snapshot dict for cheaper poll diffs. 2) **Frontend Tests: Node OOM at end of suite.** 117 test files run in one forks-pool worker. Several lazy-load the ngspice emscripten module (~30 MB), the MixedModeScheduler singleton, and other heavy modules whose dispose hooks aren't reached because singletons leak across files. Cumulative heap pressure exceeds Node's 4 GB default; the worker hits "Ineffective mark-compacts near heap limit" AFTER all 1881 tests pass and the OOM kill is reported by vitest as "Worker exited unexpectedly / Timeout terminating forks worker". This is not a real test failure — every individual test passes. Quick fix: pass NODE_OPTIONS=--max-old-space-size=8192 to the `npm test` step. Long-term, the singletons should add dispose hooks that test fixtures call in afterAll(), or the suite should shard into multiple `vitest run --shard` invocations. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 21:04:50 +07:00
# Iothread callback — never raise, never block.
pass
# ── Per-slave I2C event counter (for logging) ─────────────────────────────
_i2c_event_seq: dict = {} # addr → event count
_I2C_OP_NAME = {0x00: 'START_RECV', 0x01: 'START_SEND', 0x02: 'START_ASYNC',
0x03: 'FINISH', 0x04: 'NACK',
0x05: 'WRITE', 0x06: 'READ'}
_MPU_REG_NAME = {
0x19: 'SMPRT_DIV', 0x1A: 'CONFIG', 0x1B: 'GYRO_CFG', 0x1C: 'ACCEL_CFG',
0x3B: 'AX_H', 0x3C: 'AX_L', 0x3D: 'AY_H', 0x3E: 'AY_L',
0x3F: 'AZ_H', 0x40: 'AZ_L', 0x41: 'T_H', 0x42: 'T_L',
0x43: 'GX_H', 0x44: 'GX_L', 0x45: 'GY_H', 0x46: 'GY_L',
0x47: 'GZ_H', 0x48: 'GZ_L',
0x6B: 'PWR_MGMT1', 0x68: 'SIG_RST', 0x75: 'WHO_AM_I',
}
def _on_i2c_event(bus_id: int, addr: int, event: int) -> int:
"""Synchronous — must return immediately; called from QEMU thread."""
slave = _i2c_slaves.get(addr)
op = event & 0xFF
data = (event >> 8) & 0xFF
op_name = _I2C_OP_NAME.get(op, f'0x{op:02x}')
if slave is not None:
result = slave.handle_event(event)
reg_ptr = getattr(slave, 'reg_ptr', 0)
# Build descriptive annotation
if op in (0x00, 0x01): # START_RECV / START_SEND
note = f'→ reg_ptr=0x{reg_ptr:02x}'
elif op == 0x06: # READ byte (actual data delivery to firmware)
reg_nm = _MPU_REG_NAME.get((reg_ptr - 1) & 0xFF, f'0x{(reg_ptr-1)&0xFF:02x}')
note = f'{reg_nm}=0x{result:02x}'
elif op == 0x05: # WRITE byte
note = f'byte=0x{data:02x} → reg_ptr=0x{reg_ptr:02x}'
else:
note = ''
slave_type_name = type(slave).__name__
if slave_type_name == 'MPU6050Slave':
seq = _i2c_event_seq
n = seq[addr] = seq.get(addr, 0) + 1
_log(f'I2C #{n:03d} bus={bus_id} addr=0x{addr:02x} {op_name} {note}')
elif slave_type_name != 'I2CWriteSink':
# I2CWriteSink fires per-byte for display drivers (SSD1306,
# PCF8574). A 1024-byte writevto (oled.show()) generates
# ~1025 events. Logging each one + emitting a WS message
# blocks the QEMU thread long enough that the firmware's
# ESP-IDF I2C ISR re-enters and trips IWDT on the SECOND
# consecutive show() call. Skip the verbose per-event log
# and WS trace for write-only sinks — the user-visible
# OLED render is what matters, not byte-level tracing.
_log(f'I2C bus={bus_id} addr=0x{addr:02x} event=0x{event:04x} '
f'op={op_name} result=0x{result:02x} slave={slave_type_name}')
# Emit trace event to WebSocket so JS test can observe I2C traffic.
# Skip for I2CWriteSink (display data dumps) — see comment above.
if not _stopped.is_set() and slave_type_name != 'I2CWriteSink':
_emit({'type': 'i2c_trace', 'bus': bus_id, 'addr': addr,
'event': event, 'op': op_name, 'result': result,
'reg_ptr': reg_ptr})
return result
_log(f'I2C bus={bus_id} addr=0x{addr:02x} event=0x{event:04x} op={op_name} '
f'NO_SLAVE registered={list(_i2c_slaves.keys())}')
resp = _i2c_responses.get(addr, 0)
if not _stopped.is_set():
_emit({'type': 'i2c_event', 'bus': bus_id, 'addr': addr,
'event': event, 'response': resp})
feat: ESP32-CAM emulation with webcam frame bridge First open-source end-to-end emulation of the AI-Thinker ESP32-CAM in QEMU, paired with a browser webcam → firmware bridge so users can develop camera sketches without hardware. Status: esp_camera_init() returns ESP_OK; OV2640 chip-id verifies (PID/VER/MIDH/MIDL exactly match the datasheet); GPIO 25 VSYNC NEGEDGE interrupt enabled by the upstream driver. Final piece (cam_task accepting frames) is in progress — descriptor walker fix landed in this commit. Backend (Python/FastAPI): - simulation.py: camera_attach/frame/detach WS handlers - esp32_worker.py: ctypes binding to velxio_push_camera_frame + feature-detection fallback for older DLLs - esp32_lib_manager.py: forward camera commands to the worker stdin - esp-idf-template/main/CMakeLists.txt: esp32-camera headers added via add_prebuilt_library + REQUIRES driver (resolves i2c_master_* symbols). LED_BUILTIN=2 fallback for sketches that hardcode it. Frontend (React/TS): - EditorToolbar.tsx: ESP32-CAM (and the rest of the ESP32 family) added to isQemuBoard list — Run button now starts the QEMU bridge for these boards instead of falling through to the AVR path - useWebcamFrames.ts: getUserMedia → OffscreenCanvas → toBlob('image/jpeg') → base64 → WS at ~10 fps - CameraToggle.tsx: header button with status colors + frame counter - SimulatorCanvas.tsx: render CameraToggle for esp32-cam boards - Esp32Bridge.ts: sendCameraAttach/Frame/Detach + chunked btoa - useSimulatorStore.ts: diagnostic log on compileBoardProgram - components-metadata.json: regen including esp32-cam component Submodule pointer: - wokwi-libs/qemu-lcgamboa → ff8eee0 (camera devices commit on davidmonterocrespo24/qemu-lcgamboa branch picsimlab-esp32) Investigation + tests in test/test-esp32-cam/: - 13 autosearch markdown docs (overview, SOTA, OV2640 spec, DVP/I2S spec, build blueprint, blockers resolved, descriptor walker fix) - 5 sketches (camera_init, sccb_probe, dma_smoke, frame_roundtrip, webcam_demo) + 8 live + WS regression tests - README with the user-facing flow .gitignore: - libqemu-*.dll.{pre-camera,new,bak} (rollback points, regenerated) - wokwi-libs/esp32-camera/ (clone consumed by arduino-esp32 path, not part of this repo) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 05:28:55 +07:00
# NACK on START_SEND / START_RECV when no slave responds. Real
# I²C hardware NACKs by leaving SDA high during the ack slot;
# the picsimlab_i2c bridge has been claiming every address and
# returning ACK by default, which fooled drivers (notably the
# esp32-camera SCCB auto-probe — it kept thinking 0x21 was a
# valid OV7725 sensor and never advanced to 0x30 / OV2640).
# Returning non-zero from the I2CSlave.event callback is the
# QEMU convention for "I don't recognise this address".
# An explicit override via _i2c_responses still wins so test
# harnesses that register a fake response keep working.
if op in (0x00, 0x01) and resp == 0 and addr not in _i2c_responses:
return 1
return resp
perf(spi): batch SPI bytes per WS message — ~50× faster TFT in emulator User reported the ESP32-CAM + ILI9341 live preview at ~1 frame/min. Profile: 80×60 preview pushes 9600 SPI bytes per drawRGBBitmap, and each byte was emitting a full {type:'spi_event'} JSON message over the worker→backend→WS→frontend pipeline. Per-byte overhead ~150-200µs in Python (json.dumps + sys.stdout.write+flush dominates) plus asyncio + WS dispatch. Net: 1.5-2 sec/frame minimum, much worse with GIL contention. Fix: buffer MOSI bytes in the worker and emit a single base64-encoded `spi_batch` message when CS goes HIGH (transaction ended) or the buffer crosses 4 KiB. ~9600 events/frame collapse to ~3 messages. backend/app/services/esp32_worker.py:_on_spi_event - Add _spi_byte_buf bytearray + threading.Lock - On op==0x00 (byte): append; flush early if buf >= 4096 - On op==0x01 (CS change): flush buffer, then emit the CS event via the legacy spi_event channel (ePaper / custom chips that observe CS still get it). frontend/src/simulation/Esp32Bridge.ts - New 'spi_batch' message handler decodes b64 and replays each byte through the existing onSpiByte callback. Parts that subscribed via simulator.spi.onByte don't notice the protocol change. The 'spi_event' branch still handles CS changes plus legacy single-byte payloads for backwards compat. Now that 38 KB/frame is cheap, restore preview to 160×120 + JPEG quality 0.35 in the gallery example. Real measured speedup: ~50× on the QVGA preview demo. Real hardware was never affected — it runs SPI at 80 MHz and pushes the bitmap in ~4 ms either way. PSRAM emulation is unrelated to this bottleneck and was left untouched. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 10:25:06 +07:00
# SPI byte batching — emitting one WS message per byte saturates the
# uvicorn → frontend pipe and caps tft.drawRGBBitmap at < 1 fps even
# for tiny previews. Buffer the MOSI bytes here and flush as a single
# base64-encoded `spi_batch` message when:
# 1. CS goes HIGH (transaction ended) — only fires when the firmware
# uses the SPI peripheral's hardware CS line. If CS is bit-banged
# via digitalWrite (the default for many Adafruit-style drivers
# on ESP32), this trigger never fires and we fall back to (2)+(3).
# 2. Buffer crosses _SPI_BATCH_FLUSH_AT bytes (safety cap for big
# transactions).
# 3. _spi_flush_timer fires every _SPI_BATCH_PERIOD_MS regardless —
# catches the GPIO-CS case so partial batches don't sit in the
# buffer forever between transactions. Without this, after a few
# drawRGBBitmap calls the firmware advances faster than the
# buffer fills, and frames stop appearing on the screen.
# MISO is still returned synchronously per byte from _spi_response[0]
# because the QEMU master writes can't wait. Frontend Esp32Bridge
# unpacks the batch and replays each byte through onSpiByte.
_spi_byte_buf = bytearray()
_spi_buf_lock = threading.Lock()
_SPI_BATCH_FLUSH_AT = 4096
_SPI_BATCH_PERIOD_S = 0.05 # 50 ms → 20 fps cadence ceiling
perf(spi): batch SPI bytes per WS message — ~50× faster TFT in emulator User reported the ESP32-CAM + ILI9341 live preview at ~1 frame/min. Profile: 80×60 preview pushes 9600 SPI bytes per drawRGBBitmap, and each byte was emitting a full {type:'spi_event'} JSON message over the worker→backend→WS→frontend pipeline. Per-byte overhead ~150-200µs in Python (json.dumps + sys.stdout.write+flush dominates) plus asyncio + WS dispatch. Net: 1.5-2 sec/frame minimum, much worse with GIL contention. Fix: buffer MOSI bytes in the worker and emit a single base64-encoded `spi_batch` message when CS goes HIGH (transaction ended) or the buffer crosses 4 KiB. ~9600 events/frame collapse to ~3 messages. backend/app/services/esp32_worker.py:_on_spi_event - Add _spi_byte_buf bytearray + threading.Lock - On op==0x00 (byte): append; flush early if buf >= 4096 - On op==0x01 (CS change): flush buffer, then emit the CS event via the legacy spi_event channel (ePaper / custom chips that observe CS still get it). frontend/src/simulation/Esp32Bridge.ts - New 'spi_batch' message handler decodes b64 and replays each byte through the existing onSpiByte callback. Parts that subscribed via simulator.spi.onByte don't notice the protocol change. The 'spi_event' branch still handles CS changes plus legacy single-byte payloads for backwards compat. Now that 38 KB/frame is cheap, restore preview to 160×120 + JPEG quality 0.35 in the gallery example. Real measured speedup: ~50× on the QVGA preview demo. Real hardware was never affected — it runs SPI at 80 MHz and pushes the bitmap in ~4 ms either way. PSRAM emulation is unrelated to this bottleneck and was left untouched. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 10:25:06 +07:00
def _flush_spi_batch_locked():
if _spi_byte_buf and not _stopped.is_set():
b64 = base64.b64encode(bytes(_spi_byte_buf)).decode('ascii')
_emit({'type': 'spi_batch', 'b64': b64})
_spi_byte_buf.clear()
def _sync_cs_events():
"""Tell QEMU whether to forward SPI chip-select toggles to us. Only
ePaper / custom-chip SPI slaves consume CS; pure-display sims (DC pin +
batched data) do not, so turning CS off there removes ~9k C->Python
crossings/sec for a TFT redraw. No-op on older libqemu builds without
the symbol (CS events stay on, as before)."""
try:
lib.qemu_picsimlab_enable_spi_cs_events(
1 if (_epaper_state or _chip_spi_runtimes) else 0)
except Exception:
pass
def _spi_flush_timer_loop():
"""Background thread: flushes any pending SPI bytes every
_SPI_BATCH_PERIOD_S so partial transactions reach the frontend
even when the firmware drives CS via GPIO and we never see a
SPI peripheral CS-high event."""
while not _stopped.is_set():
_stopped.wait(_SPI_BATCH_PERIOD_S)
if _stopped.is_set():
break
with _spi_buf_lock:
_flush_spi_batch_locked()
threading.Thread(
target=_spi_flush_timer_loop, daemon=True,
name='esp32-spi-batch-flush',
).start()
def _on_spi_event(bus_id: int, event: int) -> int:
"""Synchronous — must return immediately; called from QEMU thread.
Event encoding (picsimlab see hw/ssi/picsimlab_spi.c and the CS irq
handler in esp32_picsimlab.c):
event = data << 8 SPI byte transfer
(op = low byte = 0x00,
MOSI = high byte)
event = ((((cs_idx & 3) << 1) | level) << 8) | 0x01 CS line change
(op = 0x01,
ignored by chips
that drive their own
CS via pin_watch)
"""
# Custom-chip SPI runtimes get first dibs on byte transfers. The chip's
# pre-armed buffer holds the next MISO byte; the runtime overwrites it
# with the master's MOSI byte and advances. on_done fires when count is
# reached.
op = event & 0xFF
mosi = (event >> 8) & 0xFF
if _chip_spi_runtimes and op == 0x00:
for rt in _chip_spi_runtimes:
try:
return rt.spi_transfer_byte(mosi) & 0xFF
except Exception as e:
_log(f'[custom-chip spi_event] error: {e!r}')
# ePaper SSD168x panels — feed every byte to the active slave (CS LOW).
# ePaper is write-only on MOSI; the panel uses BUSY for status, so we
# always respond 0xFF on MISO. Multiple panels on the same bus would
# both receive the byte, but the user's wiring + CS gating decide
# which slave's `cs_low` is True.
if _epaper_state and op == 0x00:
any_active = False
for st in _epaper_state.values():
if st['cs_low']:
any_active = True
try:
st['slave'].feed(mosi, st['dc_high'])
except Exception as e:
_log(f'[epaper spi_event] error: {e!r}')
if any_active:
return 0xFF
resp = _spi_response[0]
perf(spi): batch SPI bytes per WS message — ~50× faster TFT in emulator User reported the ESP32-CAM + ILI9341 live preview at ~1 frame/min. Profile: 80×60 preview pushes 9600 SPI bytes per drawRGBBitmap, and each byte was emitting a full {type:'spi_event'} JSON message over the worker→backend→WS→frontend pipeline. Per-byte overhead ~150-200µs in Python (json.dumps + sys.stdout.write+flush dominates) plus asyncio + WS dispatch. Net: 1.5-2 sec/frame minimum, much worse with GIL contention. Fix: buffer MOSI bytes in the worker and emit a single base64-encoded `spi_batch` message when CS goes HIGH (transaction ended) or the buffer crosses 4 KiB. ~9600 events/frame collapse to ~3 messages. backend/app/services/esp32_worker.py:_on_spi_event - Add _spi_byte_buf bytearray + threading.Lock - On op==0x00 (byte): append; flush early if buf >= 4096 - On op==0x01 (CS change): flush buffer, then emit the CS event via the legacy spi_event channel (ePaper / custom chips that observe CS still get it). frontend/src/simulation/Esp32Bridge.ts - New 'spi_batch' message handler decodes b64 and replays each byte through the existing onSpiByte callback. Parts that subscribed via simulator.spi.onByte don't notice the protocol change. The 'spi_event' branch still handles CS changes plus legacy single-byte payloads for backwards compat. Now that 38 KB/frame is cheap, restore preview to 160×120 + JPEG quality 0.35 in the gallery example. Real measured speedup: ~50× on the QVGA preview demo. Real hardware was never affected — it runs SPI at 80 MHz and pushes the bitmap in ~4 ms either way. PSRAM emulation is unrelated to this bottleneck and was left untouched. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 10:25:06 +07:00
if _stopped.is_set():
return resp
# ── Batching path (replaces the per-byte _emit) ─────────────────
if op == 0x00:
# Byte transfer — append to buffer, flush if oversized.
with _spi_buf_lock:
_spi_byte_buf.append(mosi)
if len(_spi_byte_buf) >= _SPI_BATCH_FLUSH_AT:
_flush_spi_batch_locked()
else:
# CS-line change. Flush any pending bytes from the previous
# transaction so the frontend processes them before the
# (rare) CS-state event itself. Then forward the CS event
# via the legacy spi_event channel for chips that observe
# CS state (e.g. ePaper, custom chips that subscribe to it).
with _spi_buf_lock:
_flush_spi_batch_locked()
_emit({'type': 'spi_event', 'bus': bus_id, 'event': event, 'response': resp})
return resp
def _on_spi_batch(bus_id: int, mosi_ptr, length: int) -> None:
"""Batched write-only SPI transfer — the whole MOSI buffer arrives in a
single call instead of one picsimlab_spi_event per byte. libqemu only
invokes this for rx==0 (MISO-ignored) transfers on the host SPI shim, so
nothing is returned. Mirrors the per-byte _on_spi_event side effects in
bulk: custom-chip runtimes first, then ePaper, then the spi_batch buffer.
This is the path that removes ~150k C->Python crossings/frame for TFTs."""
if length <= 0 or _stopped.is_set():
return
try:
data = ctypes.string_at(mosi_ptr, length)
except Exception:
return
# Custom-chip SPI runtimes get first dibs (replay per byte; the chip's
# MISO return is discarded because this transfer is write-only).
if _chip_spi_runtimes:
rt = _chip_spi_runtimes[0]
for mb in data:
try:
rt.spi_transfer_byte(mb)
except Exception as e:
_log(f'[custom-chip spi_batch] error: {e!r}')
return
# ePaper SSD168x: feed each byte under the current DC to every active
# slave (DC is constant for a write-only transaction).
if _epaper_state:
any_active = False
for st in _epaper_state.values():
if st['cs_low']:
any_active = True
slave = st['slave']
dc = st['dc_high']
for mb in data:
try:
slave.feed(mb, dc)
except Exception as e:
_log(f'[epaper spi_batch] error: {e!r}')
if any_active:
return
# Fast path: bulk-append to the spi_batch buffer (same buffer/flush the
# per-byte path uses, so frontend ordering is unchanged).
with _spi_buf_lock:
_spi_byte_buf.extend(data)
if len(_spi_byte_buf) >= _SPI_BATCH_FLUSH_AT:
_flush_spi_batch_locked()
# Keep callback struct alive (prevent GC from freeing ctypes closures)
_cbs_ref = _CallbacksT(
picsimlab_write_pin = _WRITE_PIN(_on_pin_change),
picsimlab_dir_pin = _DIR_PIN(_on_dir_change),
picsimlab_i2c_event = _I2C_EVENT(_on_i2c_event),
picsimlab_spi_event = _SPI_EVENT(_on_spi_event),
picsimlab_uart_tx_event = _UART_TX(_on_uart_tx),
pinmap = ctypes.cast(_PINMAP, ctypes.c_void_p).value,
picsimlab_rmt_event = _RMT_EVENT(_on_rmt_event),
picsimlab_gpio_matrix_cb = _GPIO_MATRIX_CB(_on_gpio_matrix),
picsimlab_spi_event_batch = _SPI_BATCH(_on_spi_batch),
)
lib.qemu_picsimlab_register_callbacks(ctypes.byref(_cbs_ref))
# Log whether the new symbol is present in this libqemu build.
# Older binaries (pre-1.1.0) silently fall back to the 100 ms
# poll path; the WS event shape is identical either way.
try:
if hasattr(lib, 'picsimlab_gpio_matrix_cb'):
_log('[gpio-matrix] libqemu 1.1.0+ detected; callback path active '
'(poll thread runs as a safety net during burn-in)')
else:
_log('[gpio-matrix] libqemu <1.1.0; using poll path only')
except Exception:
pass
# ── 6. QEMU thread ────────────────────────────────────────────────────────
def _qemu_thread() -> None:
try:
lib.qemu_init(argc, argv, None)
except Exception as exc:
_emit({'type': 'error', 'message': f'qemu_init failed: {exc}'})
finally:
_init_done.set()
# Wait for initial sensors to be pre-registered before executing firmware.
# This prevents race conditions where the firmware tries to read a sensor
# (e.g. DHT22 pulseIn) before the sensor handler is registered.
_sensors_ready.wait(timeout=5.0)
lib.qemu_main_loop()
# With -nographic, qemu_init registers the stdio mux chardev which reads
# from fd 0. If we leave fd 0 as the JSON-command pipe from the parent,
# QEMU's mux will consume those bytes and forward them to UART0 RX,
# corrupting user-sent serial data. Redirect fd 0 to /dev/null before
# qemu_init runs so the mux gets EOF and leaves our command pipe alone.
# Save the original pipe fd for the command loop below.
_orig_stdin_fd = os.dup(0)
_nul = os.open(os.devnull, os.O_RDONLY)
os.dup2(_nul, 0)
os.close(_nul)
# Also redirect fd 1 (stdout) to /dev/null so QEMU's -nographic UART mux
# doesn't write raw UART bytes onto our JSON event pipe. Without this:
# 1. Raw UART bytes prefix each JSON line, corrupting the protocol.
# 2. On a busy host the pipe fills up, causing _on_uart_tx (called
# synchronously from qemu_main_loop) to block inside sys.stdout.flush(),
# which stalls qemu_main_loop() and prevents QEMU_CLOCK_REALTIME timers
# (including Esp32_WLAN_beacon_timer) from firing → WiFi never connects.
# Save the real pipe fd and rebind sys.stdout so _emit() keeps working.
import io as _io
_orig_stdout_fd = os.dup(1)
_nul_w = os.open(os.devnull, os.O_WRONLY)
os.dup2(_nul_w, 1)
os.close(_nul_w)
sys.stdout = _io.TextIOWrapper(
_io.FileIO(_orig_stdout_fd, mode='w', closefd=True),
line_buffering=True,
write_through=True,
)
qemu_t = threading.Thread(target=_qemu_thread, daemon=True, name=f'qemu-{machine}')
qemu_t.start()
if not _init_done.wait(timeout=30.0):
_emit({'type': 'error', 'message': 'qemu_init timed out after 30 s'})
os._exit(1)
# Pre-register initial sensors before letting QEMU execute firmware.
for s in initial_sensors:
gpio = int(s.get('pin', 0))
sensor_type = s.get('sensor_type', '')
with _sensors_lock:
sensor_data: dict = {
'type': sensor_type,
**{k: v for k, v in s.items() if k not in ('sensor_type', 'pin')},
'saw_low': False,
'responding': False,
}
# For I2C sensors, also create the slave state machine immediately
# so _on_i2c_event can find it when the firmware's Wire.begin() runs.
if sensor_type == 'mpu6050':
i2c_addr = int(s.get('addr', 0x68))
slave = _MPU6050Slave(i2c_addr)
_i2c_slaves[i2c_addr] = slave
sensor_data['i2c_addr'] = i2c_addr
sensor_data['slave'] = slave
elif sensor_type == 'bmp280':
i2c_addr = int(s.get('addr', 0x76))
slave = _BMP280Slave(i2c_addr)
if 'temperature' in s: slave.update(float(s['temperature']), slave._press_hpa)
if 'pressure' in s: slave.update(slave._temp_c, float(s['pressure']))
_i2c_slaves[i2c_addr] = slave
sensor_data['i2c_addr'] = i2c_addr
sensor_data['slave'] = slave
elif sensor_type in ('ds1307', 'ds3231'):
i2c_addr = int(s.get('addr', 0x68))
slave = _DS3231Slave() if sensor_type == 'ds3231' else _DS1307Slave()
_i2c_slaves[i2c_addr] = slave
sensor_data['i2c_addr'] = i2c_addr
sensor_data['slave'] = slave
elif sensor_type == 'epaper-ssd168x':
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-23 04:32:33 +07:00
# ePaper panel: backend decodes SPI traffic and emits
# `epaper_update` events with the latched framebuffer. The
# `controller_family` payload field selects the decoder
# ('ssd168x' or 'uc8159c') and ALSO determines the BUSY
# polarity, because the two controller families use opposite
# active levels in GxEPD2:
#
feat(epaper): decode the UC8179/GD7965 7.5" panel + fix its BUSY polarity The 7.5" 800x480 dashboard (GxEPD2_750_T7) rendered blank: it is a UC8179 / GD7965 controller, but the panel config claimed controllerFamily 'ssd168x', so the SSD168x decoder (which only reads 0x24/0x26/0x44/0x45) ignored its 0x10/0x13 DTM stream. - Add a Uc8179 decoder (worker Uc8179EpaperSlave + browser Uc8179Decoder). UC8179 is the same UltraChip command family as the UC8159c (0x10/0x13 DTM, 0x12 refresh) but mono (1 bit/px). GxEPD2 writes the visible image to 0x13 (DTM2 "current"; 0x10 is the ignored "previous"), framed by 0x91/0x90 (partial window, pixel coords MSB-first)/0x13 data/0x92. Data lands at absolute pixel coords inside the window, so compose is just the RAM. The Frame reuses the SSD168x palette (0=black, 1=white) so paintFrame renders it. - EPaperPanels.ts: add the 'uc8179' family and point epaper-7in5-bw at it. EPaperPart.ts + esp32_worker.py dispatch 'uc8179' to the new decoder. - Fix the BUSY polarity: UC8179 (like the UC8159c) idles BUSY HIGH, not LOW. The worker seeded BUSY LOW for every non-uc8159c panel, so GxEPD2_750_T7's _PowerOn()/_InitDisplay() busy-wait timed out (~10 s, "Busy Timeout!") on every refresh. Now _PowerOn returns in ~129 us. - esp32_worker.py: the runtime sensor_attach epaper path still emitted the epaper_update payload nested under 'data' (the old double-wrap bug); emit it flat like the init path. The 5.65" ACeP UC8159c example already rendered (it has its own decoder and got the WS-plumbing fix); verified the 7 colour bars are correct.
2026-06-05 09:45:37 +07:00
# SSD168x family (1.54 / 2.13 / 2.9 / 4.2"):
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-23 04:32:33 +07:00
# `_busy_level = HIGH` → BUSY=HIGH means "busy",
# BUSY=LOW means "ready".
#
feat(epaper): decode the UC8179/GD7965 7.5" panel + fix its BUSY polarity The 7.5" 800x480 dashboard (GxEPD2_750_T7) rendered blank: it is a UC8179 / GD7965 controller, but the panel config claimed controllerFamily 'ssd168x', so the SSD168x decoder (which only reads 0x24/0x26/0x44/0x45) ignored its 0x10/0x13 DTM stream. - Add a Uc8179 decoder (worker Uc8179EpaperSlave + browser Uc8179Decoder). UC8179 is the same UltraChip command family as the UC8159c (0x10/0x13 DTM, 0x12 refresh) but mono (1 bit/px). GxEPD2 writes the visible image to 0x13 (DTM2 "current"; 0x10 is the ignored "previous"), framed by 0x91/0x90 (partial window, pixel coords MSB-first)/0x13 data/0x92. Data lands at absolute pixel coords inside the window, so compose is just the RAM. The Frame reuses the SSD168x palette (0=black, 1=white) so paintFrame renders it. - EPaperPanels.ts: add the 'uc8179' family and point epaper-7in5-bw at it. EPaperPart.ts + esp32_worker.py dispatch 'uc8179' to the new decoder. - Fix the BUSY polarity: UC8179 (like the UC8159c) idles BUSY HIGH, not LOW. The worker seeded BUSY LOW for every non-uc8159c panel, so GxEPD2_750_T7's _PowerOn()/_InitDisplay() busy-wait timed out (~10 s, "Busy Timeout!") on every refresh. Now _PowerOn returns in ~129 us. - esp32_worker.py: the runtime sensor_attach epaper path still emitted the epaper_update payload nested under 'data' (the old double-wrap bug); emit it flat like the init path. The 5.65" ACeP UC8159c example already rendered (it has its own decoder and got the WS-plumbing fix); verified the 7 colour bars are correct.
2026-06-05 09:45:37 +07:00
# UltraChip family — UC8159c (5.65" ACeP) and UC8179/GD7965
# (7.5" 800x480): `_busy_level = LOW` → BUSY=LOW means "busy",
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-23 04:32:33 +07:00
# BUSY=HIGH means "ready".
#
# Pick the IDLE level per family and (a) seed the pin to IDLE
# at registration so the firmware's first `_waitBusy()` —
# which runs inside `_PowerOn()` / `_InitDisplay()` BEFORE any
# frame is sent — sees "ready" and proceeds, and (b) use that
# polarity when pulsing on frame flush below.
comp_id = str(s.get('component_id', f'epaper-{gpio}'))
width = int(s.get('width', 200))
height = int(s.get('height', 200))
refresh_ms = int(s.get('refresh_ms', 50))
busy_pin = int(s.get('busy_pin', -1))
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-23 04:32:33 +07:00
# Read controller_family early; default to ssd168x for
# back-compat with old frontends that didn't send it.
ctl_family_early = str(s.get('controller_family', 'ssd168x'))
feat(epaper): decode the UC8179/GD7965 7.5" panel + fix its BUSY polarity The 7.5" 800x480 dashboard (GxEPD2_750_T7) rendered blank: it is a UC8179 / GD7965 controller, but the panel config claimed controllerFamily 'ssd168x', so the SSD168x decoder (which only reads 0x24/0x26/0x44/0x45) ignored its 0x10/0x13 DTM stream. - Add a Uc8179 decoder (worker Uc8179EpaperSlave + browser Uc8179Decoder). UC8179 is the same UltraChip command family as the UC8159c (0x10/0x13 DTM, 0x12 refresh) but mono (1 bit/px). GxEPD2 writes the visible image to 0x13 (DTM2 "current"; 0x10 is the ignored "previous"), framed by 0x91/0x90 (partial window, pixel coords MSB-first)/0x13 data/0x92. Data lands at absolute pixel coords inside the window, so compose is just the RAM. The Frame reuses the SSD168x palette (0=black, 1=white) so paintFrame renders it. - EPaperPanels.ts: add the 'uc8179' family and point epaper-7in5-bw at it. EPaperPart.ts + esp32_worker.py dispatch 'uc8179' to the new decoder. - Fix the BUSY polarity: UC8179 (like the UC8159c) idles BUSY HIGH, not LOW. The worker seeded BUSY LOW for every non-uc8159c panel, so GxEPD2_750_T7's _PowerOn()/_InitDisplay() busy-wait timed out (~10 s, "Busy Timeout!") on every refresh. Now _PowerOn returns in ~129 us. - esp32_worker.py: the runtime sensor_attach epaper path still emitted the epaper_update payload nested under 'data' (the old double-wrap bug); emit it flat like the init path. The 5.65" ACeP UC8159c example already rendered (it has its own decoder and got the WS-plumbing fix); verified the 7 colour bars are correct.
2026-06-05 09:45:37 +07:00
# UltraChip controllers (uc8159c, uc8179) idle BUSY HIGH; the
# SSD168x family idles BUSY LOW.
busy_idle_level = 1 if ctl_family_early in ('uc8159c', 'uc8179') else 0
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-23 04:32:33 +07:00
busy_busy_level = 1 - busy_idle_level
if busy_pin is not None and busy_pin >= 0:
try:
lib.qemu_picsimlab_set_pin(busy_pin + 1, busy_idle_level)
except Exception:
pass
def _flush_factory(_comp_id=comp_id,
_w=width, _h=height,
_refresh=refresh_ms,
_busy=busy_pin,
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-23 04:32:33 +07:00
_busy_busy=busy_busy_level,
_busy_idle=busy_idle_level,
_lib=lib):
"""Build an on_flush callback bound to this slave's
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-23 04:32:33 +07:00
component_id so the WS event can route to the right panel.
Pulses BUSY to its "busy" level for refresh_ms, then back
to "ready" polarity per controller family (see above)."""
def _on_flush(frame):
try:
frame_b64 = base64.b64encode(frame.pixels).decode('ascii')
except Exception:
return
# NOTE: emit FLAT (fields at top level), like every other
# worker event. The backend's qemu_callback re-wraps the
# post-'type' payload under 'data' (simulation.py), so a
# nested 'data' here would double-wrap and the frontend's
# msg.data.component_id would be undefined (panel never
# renders). This was the long-standing "ESP32 ePaper is
# blank" bug.
_emit({
'type': 'epaper_update',
'component_id': _comp_id,
'width': _w,
'height': _h,
'frame_b64': frame_b64,
'refresh_ms': _refresh,
})
if _busy is not None and _busy >= 0:
try:
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-23 04:32:33 +07:00
_lib.qemu_picsimlab_set_pin(_busy + 1, _busy_busy)
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-23 04:32:33 +07:00
def _busy_idle_cb(_b=_busy, _lvl=_busy_idle):
try:
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-23 04:32:33 +07:00
_lib.qemu_picsimlab_set_pin(_b + 1, _lvl)
except Exception:
pass
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-23 04:32:33 +07:00
threading.Timer(_refresh / 1000.0, _busy_idle_cb).start()
except Exception:
pass
return _on_flush
# Pick the decoder family from the payload. Defaults to
# SSD168x for backward compatibility (initial frontends only
# sent SSD168x); the UC8159c value is sent for ACeP panels.
ctl_family = str(s.get('controller_family', 'ssd168x'))
if ctl_family == 'uc8159c':
slave = _Uc8159cEpaperSlave(
component_id=comp_id, width=width, height=height,
on_flush=_flush_factory(),
)
feat(epaper): decode the UC8179/GD7965 7.5" panel + fix its BUSY polarity The 7.5" 800x480 dashboard (GxEPD2_750_T7) rendered blank: it is a UC8179 / GD7965 controller, but the panel config claimed controllerFamily 'ssd168x', so the SSD168x decoder (which only reads 0x24/0x26/0x44/0x45) ignored its 0x10/0x13 DTM stream. - Add a Uc8179 decoder (worker Uc8179EpaperSlave + browser Uc8179Decoder). UC8179 is the same UltraChip command family as the UC8159c (0x10/0x13 DTM, 0x12 refresh) but mono (1 bit/px). GxEPD2 writes the visible image to 0x13 (DTM2 "current"; 0x10 is the ignored "previous"), framed by 0x91/0x90 (partial window, pixel coords MSB-first)/0x13 data/0x92. Data lands at absolute pixel coords inside the window, so compose is just the RAM. The Frame reuses the SSD168x palette (0=black, 1=white) so paintFrame renders it. - EPaperPanels.ts: add the 'uc8179' family and point epaper-7in5-bw at it. EPaperPart.ts + esp32_worker.py dispatch 'uc8179' to the new decoder. - Fix the BUSY polarity: UC8179 (like the UC8159c) idles BUSY HIGH, not LOW. The worker seeded BUSY LOW for every non-uc8159c panel, so GxEPD2_750_T7's _PowerOn()/_InitDisplay() busy-wait timed out (~10 s, "Busy Timeout!") on every refresh. Now _PowerOn returns in ~129 us. - esp32_worker.py: the runtime sensor_attach epaper path still emitted the epaper_update payload nested under 'data' (the old double-wrap bug); emit it flat like the init path. The 5.65" ACeP UC8159c example already rendered (it has its own decoder and got the WS-plumbing fix); verified the 7 colour bars are correct.
2026-06-05 09:45:37 +07:00
elif ctl_family == 'uc8179':
slave = _Uc8179EpaperSlave(
component_id=comp_id, width=width, height=height,
on_flush=_flush_factory(),
)
else:
_is_bwr = 'bwr' in str(s.get('panel_kind', '')).lower()
slave = _Ssd168xEpaperSlave(
component_id=comp_id, width=width, height=height,
on_flush=_flush_factory(), is_bwr=_is_bwr,
)
state = {
'slave': slave,
'dc_pin': int(s.get('dc_pin', -1)),
'cs_pin': int(s.get('cs_pin', -1)),
'rst_pin': int(s.get('rst_pin', -1)),
'busy_pin': busy_pin,
'cs_low': False,
'dc_high': False,
'refresh_ms': refresh_ms,
'controller_family': ctl_family,
}
_epaper_slaves[comp_id] = slave
_epaper_state[comp_id] = state
_sync_cs_events()
sensor_data['epaper_component_id'] = comp_id
_log(f"[epaper:{ctl_family}] registered '{comp_id}' "
f"({width}x{height}) "
f"DC={state['dc_pin']} CS={state['cs_pin']} "
f"RST={state['rst_pin']} BUSY={state['busy_pin']}")
elif sensor_type in ('ssd1306', 'pcf8574'):
default_addr = 0x3C if sensor_type == 'ssd1306' else 0x27
i2c_addr = int(s.get('addr', default_addr))
sink = _I2CWriteSink(i2c_addr, _emit)
_i2c_slaves[i2c_addr] = sink
sensor_data['i2c_addr'] = i2c_addr
sensor_data['slave'] = sink
elif sensor_type == 'custom-chip':
# User-supplied chip compiled to WASM. The runtime loads the
# binary in this same Python process so I2C callbacks fire
# synchronously when QEMU calls _on_i2c_event — same fidelity
# as the hardcoded slaves above.
# See docs/wiki/custom-chips-esp32-backend-runtime.md
try:
from app.services.wasm_chip_runtime import WasmChipRuntime
from app.services.wasm_chip_slave import WasmChipI2CSlave
except ImportError:
# Fallback: same pattern as esp32_i2c_slaves at the top of
# this file. The worker subprocess may run from a cwd that
# doesn't have `app.services` on sys.path.
import importlib.util, pathlib as _pl
_here = _pl.Path(__file__).parent
_spec_rt = importlib.util.spec_from_file_location(
'wasm_chip_runtime', _here / 'wasm_chip_runtime.py'
)
_mod_rt = importlib.util.module_from_spec(_spec_rt)
_spec_rt.loader.exec_module(_mod_rt)
WasmChipRuntime = _mod_rt.WasmChipRuntime
_spec_sl = importlib.util.spec_from_file_location(
'wasm_chip_slave', _here / 'wasm_chip_slave.py'
)
_mod_sl = importlib.util.module_from_spec(_spec_sl)
# The slave module imports from app.services.wasm_chip_runtime;
# patch sys.modules so that import resolves to our loaded module.
sys.modules['app.services.wasm_chip_runtime'] = _mod_rt
_spec_sl.loader.exec_module(_mod_sl)
WasmChipI2CSlave = _mod_sl.WasmChipI2CSlave
wasm_b64 = s.get('wasm_b64', '')
if not wasm_b64:
_log("[custom-chip] missing wasm_b64 in sensor payload")
else:
try:
wasm_bytes = base64.b64decode(wasm_b64)
attrs = s.get('attrs', {}) or {}
pin_map = s.get('pin_map', {}) or {}
# ── Plumbing: hook the runtime to QEMU's live peripherals ──
# GPIO output: chip's vx_pin_write → qemu_picsimlab_set_pin
def _chip_pin_writer(gpio: int, value: int, _lib=lib):
_lib.qemu_picsimlab_set_pin(gpio + 1, value)
# GPIO input: chip reads current QEMU pin state.
def _chip_pin_reader(gpio: int, _store=_pin_state):
return int(_store.get(gpio, 0)) & 1
# UART RX: chip's vx_uart_write → inject bytes into firmware UART.
# Acquire the iothread lock ONLY if we don't already hold it
# (typical case: the chip's vx_uart_write is fired from inside
# _on_uart_tx, which is already in the QEMU thread holding the
# lock — re-acquiring there triggers an assertion).
def _chip_uart_writer(uart_id: int, data: bytes,
_lib=lib,
_lock=_lock_iothread,
_unlock=_unlock_iothread,
_is_locked=_iothread_locked):
buf = (ctypes.c_uint8 * len(data))(*data)
need_lock = bool(_lock) and (not _is_locked or not _is_locked())
if need_lock:
_lock(b'esp32_worker.py:custom-chip', 0)
try:
_lib.qemu_picsimlab_uart_receive(int(uart_id), buf, len(data))
finally:
if need_lock and _unlock:
_unlock()
# Timer arm: just track this runtime so the scheduler thread
# picks up the new deadline on its next iteration.
def _chip_timer_scheduler(rt):
if rt not in _chip_timer_runtimes:
_chip_timer_runtimes.append(rt)
runtime = WasmChipRuntime(
wasm_bytes, attrs, _emit,
pin_map=pin_map,
pin_writer=_chip_pin_writer,
pin_reader=_chip_pin_reader,
uart_writer=_chip_uart_writer,
timer_scheduler=_chip_timer_scheduler,
)
runtime.run_chip_setup()
if runtime.i2c_address is not None:
slave = WasmChipI2CSlave(runtime.i2c_address, runtime)
_i2c_slaves[runtime.i2c_address] = slave
sensor_data['i2c_addr'] = runtime.i2c_address
sensor_data['slave'] = slave
_log(f"[custom-chip] I2C slave registered at 0x{runtime.i2c_address:02x}")
if runtime.uart_config is not None:
_chip_uart_runtimes.append(runtime)
_log("[custom-chip] UART chip registered on UART0")
if runtime.spi_config is not None:
_chip_spi_runtimes.append(runtime)
_sync_cs_events()
_log("[custom-chip] SPI chip registered")
if runtime.has_pin_watches():
_chip_pin_watch_runtimes.append(runtime)
_log(f"[custom-chip] pin watches registered: {list(runtime._pin_watches.keys())}")
if (runtime.i2c_address is None and runtime.uart_config is None
and runtime.spi_config is None):
_log("[custom-chip] WASM loaded but no I2C/UART/SPI peripherals declared "
"— chip is GPIO-only")
sensor_data['runtime'] = runtime
except Exception as e:
_log(f"[custom-chip] failed to load: {e!r}")
_sensors[gpio] = sensor_data
_sensors_ready.set()
_log(f'_i2c_slaves registered: {list(_i2c_slaves.keys())}')
_emit({'type': 'system', 'event': 'booted'})
# Now that the initial components are registered, tell QEMU whether to
# forward SPI CS toggles (only ePaper/custom-chip need them).
_sync_cs_events()
_log(f'QEMU started: machine={machine} firmware={firmware_path}')
_log(f'QEMU args: {[a.decode() for a in args_list]}')
# ── 6.5 Custom-chip timer thread ──────────────────────────────────────────
# Wakes on each chip's next_timer_deadline. Acquires the QEMU IO-thread lock
# before firing callbacks because vx_pin_write inside a timer would touch
# picsimlab_set_pin which requires the lock.
def _chip_timer_thread() -> None:
while not _stopped.is_set():
# Find the soonest deadline across all chips with active timers.
soonest_ns: int | None = None
for rt in list(_chip_timer_runtimes):
d = rt.next_timer_deadline()
if d is not None and (soonest_ns is None or d < soonest_ns):
soonest_ns = d
if soonest_ns is None:
# No active timers — sleep a bit and re-check.
_stopped.wait(0.050)
continue
now_ns = time.monotonic_ns() - _t0_ref[0]
wait_ns = max(0, soonest_ns - now_ns)
if wait_ns > 0:
_stopped.wait(wait_ns / 1e9)
if _stopped.is_set():
break
# Fire under the IO-thread lock so any pin_write the timer triggers is safe.
if _lock_iothread:
_lock_iothread(b'esp32_worker.py:chip_timer', 0)
try:
for rt in list(_chip_timer_runtimes):
try:
rt.fire_due_timers()
except Exception as e:
_log(f'[custom-chip timer] error: {e!r}')
finally:
if _unlock_iothread:
_unlock_iothread()
_t0_ref = [time.monotonic_ns()] # used so the timer thread can compute "now"
_timer_t = threading.Thread(target=_chip_timer_thread, daemon=True, name='chip-timer')
_timer_t.start()
# ── 7. LEDC polling thread (100 ms interval) ──────────────────────────────
def _ledc_poll_thread() -> None:
# Track last-emitted duty to avoid flooding identical updates
_last_duty = [0.0] * 16
while not _stopped.wait(0.1):
try:
ptr = lib.qemu_picsimlab_get_internals(6) # LEDC_CHANNEL_DUTY
if ptr is None or ptr == 0:
continue
arr = (ctypes.c_float * 16).from_address(ptr)
feat(esp32): SignalRouter — model the GPIO Matrix as first-class Replaces the per-peripheral ad-hoc `_ledc_gpio_map` cache with a proper signal-routing abstraction that mirrors the ESP32 SoC's IO_MUX + GPIO Matrix exactly. Same idea as real silicon: signal sources (LEDC channels, RMT, MCPWM, ...) → 40-entry routing table → GPIO pins. Motivation (from user bug report in velxio.dev/project/5218f9e3-136d-43b3-bba1-6cebde21e1a4): two ESP32 servos on a solar-tracker visibly oscillated between two positions instead of moving smoothly when the user changed LDR sliders. Commit 77bf897 patched it (per-channel gpio memo + broadcast guard) but the user requested a proper hardware-fidel architecture, not patches. Backend: * `app/services/signal_router.py` — SignalRouter class. Forward index (gpio → signal_id) + reverse index (signal_id → set of gpios). `replace_snapshot()` returns the diff for the polling- fallback path; future C plugin hook becomes a push without touching this code. * `app/services/esp32_signals.py` — Signal id constants from ESP32 TRM (LEDC HS 72-79, LS 80-87) + `ledc_signal_for_channel()` helper. * `app/services/esp32_worker.py` — `_ledc_gpio_map` is gone; `_refresh_ledc_gpio_map` replaced by `_refresh_signal_routing` which emits `gpio_routing {gpio, signal_id}` events on diff. The 0x5000 LEDC callback and the LEDC poll thread now emit `ledc_duty {channel, duty_pct}` (canonical, no gpio) alongside the legacy `ledc_update {channel, duty, gpio}` for back-compat during rollout. Frontend: * `simulation/SignalRouter.ts` — 1-to-1 TS mirror of the Python class. Same forward + reverse index; same `pinsForSignal` / `updateRouting` / `clearRouting` API. * `simulation/esp32-signals.ts` — Signal id constants, mirror of the Python module. * `simulation/Esp32Bridge.ts` — new `onLedcDuty`, `onGpioRouting`, `onGpioRoutingClear` callbacks; handlers for the new event types. * `store/useSimulatorStore.ts` — `makeLedcDutyHandler` looks up pins via `router.pinsForSignal(ledcSignalForChannel(channel))` and dispatches per pin. `makeGpioRoutingHandler` / `makeGpioRoutingClearHandler` keep the mirror in sync. Per-board `signalRouterMap` parallels `pinManagerMap` in lifecycle. `makeLedcUpdateHandler` (and its memo workaround from 77bf897) stays wired for back-compat during rollout; removed in a follow-up commit once prod is verified stable on the new path. Tests: * `test/backend/unit/test_signal_router.py` (20 tests) covers update/clear semantics, idempotency, multi-pin routing, snapshot diff, channel↔signal-id helpers, and the multi-servo regression scenario. * `frontend/src/__tests__/SignalRouter.test.ts` (17 tests) is the mirror — same scenarios on the TS side. * `frontend/src/__tests__/esp32-multi-servo-gpio-matrix.test.ts` (6 tests) drives the end-to-end SignalRouter handler pipeline, asserts that two servos on GPIO 13/12 via LEDC channels 0/1 move independently (no mirroring), that re-routing carries cleanly, and — critically — that `PinManager.broadcastPwm` is never called. Totals: +700 LOC, 1876 frontend tests pass (was 1853), 278 backend unit tests pass (was 259). Docs: ESP32_EMULATION.md §9.2 rewritten with the new architecture diagram + a runbook for adding future peripherals through the SignalRouter. The C plugin hook in qemu-lcgamboa that would push gpio_out_sel writes synchronously (eliminating the polling race window entirely) is the next step — kept as a follow-up because the polling-fallback path here already resolves the routing before each duty event fires, so the bug is fixed end-to-end. The plugin work removes the race condition fundamentally.
2026-05-17 10:00:53 +07:00
# Reconcile the GPIO Matrix mirror first; any routing
# changes since the last poll are emitted as
# `gpio_routing` events so frontend's SignalRouter is
# in sync before duty updates land.
_refresh_signal_routing()
for ch in range(16):
duty_pct = float(arr[ch])
if abs(duty_pct - _last_duty[ch]) < 0.01:
continue
_last_duty[ch] = duty_pct
if duty_pct > 0:
feat(esp32): SignalRouter — model the GPIO Matrix as first-class Replaces the per-peripheral ad-hoc `_ledc_gpio_map` cache with a proper signal-routing abstraction that mirrors the ESP32 SoC's IO_MUX + GPIO Matrix exactly. Same idea as real silicon: signal sources (LEDC channels, RMT, MCPWM, ...) → 40-entry routing table → GPIO pins. Motivation (from user bug report in velxio.dev/project/5218f9e3-136d-43b3-bba1-6cebde21e1a4): two ESP32 servos on a solar-tracker visibly oscillated between two positions instead of moving smoothly when the user changed LDR sliders. Commit 77bf897 patched it (per-channel gpio memo + broadcast guard) but the user requested a proper hardware-fidel architecture, not patches. Backend: * `app/services/signal_router.py` — SignalRouter class. Forward index (gpio → signal_id) + reverse index (signal_id → set of gpios). `replace_snapshot()` returns the diff for the polling- fallback path; future C plugin hook becomes a push without touching this code. * `app/services/esp32_signals.py` — Signal id constants from ESP32 TRM (LEDC HS 72-79, LS 80-87) + `ledc_signal_for_channel()` helper. * `app/services/esp32_worker.py` — `_ledc_gpio_map` is gone; `_refresh_ledc_gpio_map` replaced by `_refresh_signal_routing` which emits `gpio_routing {gpio, signal_id}` events on diff. The 0x5000 LEDC callback and the LEDC poll thread now emit `ledc_duty {channel, duty_pct}` (canonical, no gpio) alongside the legacy `ledc_update {channel, duty, gpio}` for back-compat during rollout. Frontend: * `simulation/SignalRouter.ts` — 1-to-1 TS mirror of the Python class. Same forward + reverse index; same `pinsForSignal` / `updateRouting` / `clearRouting` API. * `simulation/esp32-signals.ts` — Signal id constants, mirror of the Python module. * `simulation/Esp32Bridge.ts` — new `onLedcDuty`, `onGpioRouting`, `onGpioRoutingClear` callbacks; handlers for the new event types. * `store/useSimulatorStore.ts` — `makeLedcDutyHandler` looks up pins via `router.pinsForSignal(ledcSignalForChannel(channel))` and dispatches per pin. `makeGpioRoutingHandler` / `makeGpioRoutingClearHandler` keep the mirror in sync. Per-board `signalRouterMap` parallels `pinManagerMap` in lifecycle. `makeLedcUpdateHandler` (and its memo workaround from 77bf897) stays wired for back-compat during rollout; removed in a follow-up commit once prod is verified stable on the new path. Tests: * `test/backend/unit/test_signal_router.py` (20 tests) covers update/clear semantics, idempotency, multi-pin routing, snapshot diff, channel↔signal-id helpers, and the multi-servo regression scenario. * `frontend/src/__tests__/SignalRouter.test.ts` (17 tests) is the mirror — same scenarios on the TS side. * `frontend/src/__tests__/esp32-multi-servo-gpio-matrix.test.ts` (6 tests) drives the end-to-end SignalRouter handler pipeline, asserts that two servos on GPIO 13/12 via LEDC channels 0/1 move independently (no mirroring), that re-routing carries cleanly, and — critically — that `PinManager.broadcastPwm` is never called. Totals: +700 LOC, 1876 frontend tests pass (was 1853), 278 backend unit tests pass (was 259). Docs: ESP32_EMULATION.md §9.2 rewritten with the new architecture diagram + a runbook for adding future peripherals through the SignalRouter. The C plugin hook in qemu-lcgamboa that would push gpio_out_sel writes synchronously (eliminating the polling race window entirely) is the next step — kept as a follow-up because the polling-fallback path here already resolves the routing before each duty event fires, so the bug is fixed end-to-end. The plugin work removes the race condition fundamentally.
2026-05-17 10:00:53 +07:00
rounded = round(duty_pct, 2)
_emit({'type': 'ledc_duty',
'channel': ch,
'duty_pct': rounded})
except Exception:
pass
threading.Thread(target=_ledc_poll_thread, daemon=True, name='ledc-poll').start()
# ── 8. Command loop (main thread reads original stdin pipe) ───────────────
for raw_line in os.fdopen(_orig_stdin_fd, 'r'):
raw_line = raw_line.strip()
if not raw_line:
continue
try:
cmd = json.loads(raw_line)
except Exception:
continue
c = cmd.get('cmd', '')
if c == 'set_pin':
# Identity pinmap: slot = gpio_num + 1
lib.qemu_picsimlab_set_pin(int(cmd['pin']) + 1, int(cmd['value']))
elif c == 'set_adc':
raw_v = int(int(cmd['millivolts']) * 4095 / 3300)
ch = int(cmd['channel'])
clamped = max(0, min(4095, raw_v))
lib.qemu_picsimlab_set_apin(ch, clamped)
elif c == 'set_adc_raw':
lib.qemu_picsimlab_set_apin(int(cmd['channel']),
max(0, min(4095, int(cmd['raw']))))
elif c == 'set_adc_waveform':
# Push a periodic 12-bit waveform LUT to QEMU so the SAR ADC
# peripheral can interpolate against its virtual clock on every
# MMIO read. Matches the per-read fidelity of AVR/RP2040.
#
# libqemu-xtensa must export `qemu_picsimlab_set_apin_waveform`;
# if not (older binary), silently downgrade to a single-sample
# `set_apin` so circuits with waveforms still produce *something*.
try:
ch = int(cmd['channel'])
b64 = cmd.get('samples_u12_b64', '') or ''
period_ns = int(cmd.get('period_ns', 0))
if b64 and period_ns > 0 and hasattr(lib, 'qemu_picsimlab_set_apin_waveform'):
raw = base64.b64decode(b64)
# Samples are little-endian uint16. Allocate a C buffer and
# hand QEMU a pointer; the waveform setter copies the data
# internally.
n = len(raw) // 2
if n > 0:
arr_type = ctypes.c_uint16 * n
arr = arr_type.from_buffer_copy(raw[:n * 2])
lib.qemu_picsimlab_set_apin_waveform(
ch,
arr,
ctypes.c_int(n),
ctypes.c_uint64(period_ns),
)
else:
# Clear waveform: fall back to last-known DC value (no-op if
# the API isn't available — QEMU just keeps whatever was
# last written via `set_apin`).
if hasattr(lib, 'qemu_picsimlab_set_apin_waveform'):
lib.qemu_picsimlab_set_apin_waveform(
ch, None, ctypes.c_int(0), ctypes.c_uint64(0)
)
except Exception as err:
# Never let an ADC-waveform failure kill the worker — log to
# stderr and keep the guest running with its last DC sample.
print(f'[esp32_worker] set_adc_waveform failed: {err}',
file=sys.stderr, flush=True)
elif c == 'uart_send':
data = base64.b64decode(cmd['data'])
buf = (ctypes.c_uint8 * len(data))(*data)
# Must hold the QEMU IO-thread lock: uart_receive injects a UART-RX
# interrupt into the guest CPU and QEMU asserts the lock is held.
if _lock_iothread:
_lock_iothread(b'esp32_worker.py', 0)
try:
lib.qemu_picsimlab_uart_receive(
int(cmd.get('uart', 0)), buf, len(data)
)
finally:
if _unlock_iothread:
_unlock_iothread()
elif c == 'set_i2c_response':
_i2c_responses[int(cmd['addr'])] = int(cmd['response']) & 0xFF
elif c == 'set_spi_response':
_spi_response[0] = int(cmd['response']) & 0xFF
elif c == 'sensor_attach':
gpio = int(cmd['pin'])
sensor_type = cmd.get('sensor_type', '')
with _sensors_lock:
sensor_data: dict = {
'type': sensor_type,
**{k: v for k, v in cmd.items()
if k not in ('cmd', 'pin', 'sensor_type')},
'saw_low': False,
'responding': False,
}
if sensor_type == 'mpu6050':
i2c_addr = int(cmd.get('addr', 0x68))
slave = _MPU6050Slave(i2c_addr)
_i2c_slaves[i2c_addr] = slave
sensor_data['i2c_addr'] = i2c_addr
sensor_data['slave'] = slave
elif sensor_type == 'bmp280':
i2c_addr = int(cmd.get('addr', 0x76))
slave = _BMP280Slave(i2c_addr)
_i2c_slaves[i2c_addr] = slave
sensor_data['i2c_addr'] = i2c_addr
sensor_data['slave'] = slave
elif sensor_type in ('ds1307', 'ds3231'):
i2c_addr = int(cmd.get('addr', 0x68))
slave = _DS3231Slave() if sensor_type == 'ds3231' else _DS1307Slave()
_i2c_slaves[i2c_addr] = slave
sensor_data['i2c_addr'] = i2c_addr
sensor_data['slave'] = slave
elif sensor_type in ('ssd1306', 'pcf8574'):
default_addr = 0x3C if sensor_type == 'ssd1306' else 0x27
i2c_addr = int(cmd.get('addr', default_addr))
sink = _I2CWriteSink(i2c_addr, _emit)
_i2c_slaves[i2c_addr] = sink
sensor_data['i2c_addr'] = i2c_addr
sensor_data['slave'] = sink
elif sensor_type == 'epaper-ssd168x':
# Runtime registration of an SSD168x ePaper panel. Mirrors
# the `_init_sensors` branch above. Component-id keyed so
# multiple panels on the same board route correctly.
comp_id = str(cmd.get('component_id', f'epaper-{gpio}'))
width = int(cmd.get('width', 200))
height = int(cmd.get('height', 200))
refresh_ms = int(cmd.get('refresh_ms', 50))
busy_pin = int(cmd.get('busy_pin', -1))
def _flush_factory_rt(_comp_id=comp_id,
_w=width, _h=height,
_refresh=refresh_ms,
_busy=busy_pin,
_lib=lib):
def _on_flush(frame):
try:
frame_b64 = base64.b64encode(frame.pixels).decode('ascii')
except Exception:
return
feat(epaper): decode the UC8179/GD7965 7.5" panel + fix its BUSY polarity The 7.5" 800x480 dashboard (GxEPD2_750_T7) rendered blank: it is a UC8179 / GD7965 controller, but the panel config claimed controllerFamily 'ssd168x', so the SSD168x decoder (which only reads 0x24/0x26/0x44/0x45) ignored its 0x10/0x13 DTM stream. - Add a Uc8179 decoder (worker Uc8179EpaperSlave + browser Uc8179Decoder). UC8179 is the same UltraChip command family as the UC8159c (0x10/0x13 DTM, 0x12 refresh) but mono (1 bit/px). GxEPD2 writes the visible image to 0x13 (DTM2 "current"; 0x10 is the ignored "previous"), framed by 0x91/0x90 (partial window, pixel coords MSB-first)/0x13 data/0x92. Data lands at absolute pixel coords inside the window, so compose is just the RAM. The Frame reuses the SSD168x palette (0=black, 1=white) so paintFrame renders it. - EPaperPanels.ts: add the 'uc8179' family and point epaper-7in5-bw at it. EPaperPart.ts + esp32_worker.py dispatch 'uc8179' to the new decoder. - Fix the BUSY polarity: UC8179 (like the UC8159c) idles BUSY HIGH, not LOW. The worker seeded BUSY LOW for every non-uc8159c panel, so GxEPD2_750_T7's _PowerOn()/_InitDisplay() busy-wait timed out (~10 s, "Busy Timeout!") on every refresh. Now _PowerOn returns in ~129 us. - esp32_worker.py: the runtime sensor_attach epaper path still emitted the epaper_update payload nested under 'data' (the old double-wrap bug); emit it flat like the init path. The 5.65" ACeP UC8159c example already rendered (it has its own decoder and got the WS-plumbing fix); verified the 7 colour bars are correct.
2026-06-05 09:45:37 +07:00
# Emit FLAT (see the _init_sensors path) — the backend
# re-wraps under 'data', so a nested 'data' here would
# double-wrap and the frontend would never render.
_emit({
'type': 'epaper_update',
feat(epaper): decode the UC8179/GD7965 7.5" panel + fix its BUSY polarity The 7.5" 800x480 dashboard (GxEPD2_750_T7) rendered blank: it is a UC8179 / GD7965 controller, but the panel config claimed controllerFamily 'ssd168x', so the SSD168x decoder (which only reads 0x24/0x26/0x44/0x45) ignored its 0x10/0x13 DTM stream. - Add a Uc8179 decoder (worker Uc8179EpaperSlave + browser Uc8179Decoder). UC8179 is the same UltraChip command family as the UC8159c (0x10/0x13 DTM, 0x12 refresh) but mono (1 bit/px). GxEPD2 writes the visible image to 0x13 (DTM2 "current"; 0x10 is the ignored "previous"), framed by 0x91/0x90 (partial window, pixel coords MSB-first)/0x13 data/0x92. Data lands at absolute pixel coords inside the window, so compose is just the RAM. The Frame reuses the SSD168x palette (0=black, 1=white) so paintFrame renders it. - EPaperPanels.ts: add the 'uc8179' family and point epaper-7in5-bw at it. EPaperPart.ts + esp32_worker.py dispatch 'uc8179' to the new decoder. - Fix the BUSY polarity: UC8179 (like the UC8159c) idles BUSY HIGH, not LOW. The worker seeded BUSY LOW for every non-uc8159c panel, so GxEPD2_750_T7's _PowerOn()/_InitDisplay() busy-wait timed out (~10 s, "Busy Timeout!") on every refresh. Now _PowerOn returns in ~129 us. - esp32_worker.py: the runtime sensor_attach epaper path still emitted the epaper_update payload nested under 'data' (the old double-wrap bug); emit it flat like the init path. The 5.65" ACeP UC8159c example already rendered (it has its own decoder and got the WS-plumbing fix); verified the 7 colour bars are correct.
2026-06-05 09:45:37 +07:00
'component_id': _comp_id,
'width': _w,
'height': _h,
'frame_b64': frame_b64,
'refresh_ms': _refresh,
})
if _busy is not None and _busy >= 0:
try:
_lib.qemu_picsimlab_set_pin(_busy + 1, 1)
def _busy_low(_b=_busy):
try:
_lib.qemu_picsimlab_set_pin(_b + 1, 0)
except Exception:
pass
threading.Timer(_refresh / 1000.0, _busy_low).start()
except Exception:
pass
return _on_flush
ctl_family = str(cmd.get('controller_family', 'ssd168x'))
if ctl_family == 'uc8159c':
slave = _Uc8159cEpaperSlave(
component_id=comp_id, width=width, height=height,
on_flush=_flush_factory_rt(),
)
feat(epaper): decode the UC8179/GD7965 7.5" panel + fix its BUSY polarity The 7.5" 800x480 dashboard (GxEPD2_750_T7) rendered blank: it is a UC8179 / GD7965 controller, but the panel config claimed controllerFamily 'ssd168x', so the SSD168x decoder (which only reads 0x24/0x26/0x44/0x45) ignored its 0x10/0x13 DTM stream. - Add a Uc8179 decoder (worker Uc8179EpaperSlave + browser Uc8179Decoder). UC8179 is the same UltraChip command family as the UC8159c (0x10/0x13 DTM, 0x12 refresh) but mono (1 bit/px). GxEPD2 writes the visible image to 0x13 (DTM2 "current"; 0x10 is the ignored "previous"), framed by 0x91/0x90 (partial window, pixel coords MSB-first)/0x13 data/0x92. Data lands at absolute pixel coords inside the window, so compose is just the RAM. The Frame reuses the SSD168x palette (0=black, 1=white) so paintFrame renders it. - EPaperPanels.ts: add the 'uc8179' family and point epaper-7in5-bw at it. EPaperPart.ts + esp32_worker.py dispatch 'uc8179' to the new decoder. - Fix the BUSY polarity: UC8179 (like the UC8159c) idles BUSY HIGH, not LOW. The worker seeded BUSY LOW for every non-uc8159c panel, so GxEPD2_750_T7's _PowerOn()/_InitDisplay() busy-wait timed out (~10 s, "Busy Timeout!") on every refresh. Now _PowerOn returns in ~129 us. - esp32_worker.py: the runtime sensor_attach epaper path still emitted the epaper_update payload nested under 'data' (the old double-wrap bug); emit it flat like the init path. The 5.65" ACeP UC8159c example already rendered (it has its own decoder and got the WS-plumbing fix); verified the 7 colour bars are correct.
2026-06-05 09:45:37 +07:00
elif ctl_family == 'uc8179':
slave = _Uc8179EpaperSlave(
component_id=comp_id, width=width, height=height,
on_flush=_flush_factory_rt(),
)
else:
fix(epaper): correct orientation across all boards + Pico VCC wire ePaper panels rendered rotated/misaligned on AVR and RP2040 (e.g. the 2.13" Pico clock came out sideways and clipped). The ESP32 worker decoder was just taught to compose in the controller's native RAM geometry and rotate to the display orientation, but the browser-side SSD168xDecoder (used by AVR/RP2040) still composed at display dims with no rotation, so the two diverged. - SSD168xDecoder.ts: port the worker's native-window compose + rotation. * Size RAM to the longer side both ways so a rotated native layout (128x296 behind a 296x128 panel) isn't truncated. * Compose in the active RAM window, then rotate via the inverse of Adafruit_GFX setRotation(1). Detect orientation by BYTE width so a non-multiple-of-8 native width (the 2.13" panel is 122 px) is handled. * Track the UNION of windows per frame: paged drivers (GxEPD2 page height < panel) set one partial window per page, so compose must use the full native area, not just the last page's strip. Fixes the all-white render on paged panels (1.54" Uno, 4.2" Pico, 7.5" ESP32). * Add an isBwr option: B/W panels treat 0x26 as a 2nd mono plane (white only if both planes white), tri-colour panels keep red-wins. * Default the active window to display geometry; the firmware overrides it. - EPaperPart.ts: pass isBwr = cfg.palette === 'bwr' to the decoder. - esp32_spi_slaves.py / esp32_worker.py: mirror the byte-aware rotation + window-union in the worker, and derive is_bwr from panel_kind on the runtime sensor_attach path too (fixes the tri-colour ESP32 alert badge). - test_epaper/ssd168x_decoder.py: re-port the golden reference to match (keeps the 3-way TS/Python/worker identity invariant). Tests updated to construct tri-colour cases with is_bwr/palette='bwr'. - examples-displays-epaper.ts: the Pico VCC wire referenced '3V3(OUT)', which the velxio-pi-pico-w element doesn't expose (it has '3V3'), so the wire snapped to the board corner. Use '3V3'.
2026-06-04 13:32:02 +07:00
_is_bwr = 'bwr' in str(cmd.get('panel_kind', '')).lower()
slave = _Ssd168xEpaperSlave(
component_id=comp_id, width=width, height=height,
fix(epaper): correct orientation across all boards + Pico VCC wire ePaper panels rendered rotated/misaligned on AVR and RP2040 (e.g. the 2.13" Pico clock came out sideways and clipped). The ESP32 worker decoder was just taught to compose in the controller's native RAM geometry and rotate to the display orientation, but the browser-side SSD168xDecoder (used by AVR/RP2040) still composed at display dims with no rotation, so the two diverged. - SSD168xDecoder.ts: port the worker's native-window compose + rotation. * Size RAM to the longer side both ways so a rotated native layout (128x296 behind a 296x128 panel) isn't truncated. * Compose in the active RAM window, then rotate via the inverse of Adafruit_GFX setRotation(1). Detect orientation by BYTE width so a non-multiple-of-8 native width (the 2.13" panel is 122 px) is handled. * Track the UNION of windows per frame: paged drivers (GxEPD2 page height < panel) set one partial window per page, so compose must use the full native area, not just the last page's strip. Fixes the all-white render on paged panels (1.54" Uno, 4.2" Pico, 7.5" ESP32). * Add an isBwr option: B/W panels treat 0x26 as a 2nd mono plane (white only if both planes white), tri-colour panels keep red-wins. * Default the active window to display geometry; the firmware overrides it. - EPaperPart.ts: pass isBwr = cfg.palette === 'bwr' to the decoder. - esp32_spi_slaves.py / esp32_worker.py: mirror the byte-aware rotation + window-union in the worker, and derive is_bwr from panel_kind on the runtime sensor_attach path too (fixes the tri-colour ESP32 alert badge). - test_epaper/ssd168x_decoder.py: re-port the golden reference to match (keeps the 3-way TS/Python/worker identity invariant). Tests updated to construct tri-colour cases with is_bwr/palette='bwr'. - examples-displays-epaper.ts: the Pico VCC wire referenced '3V3(OUT)', which the velxio-pi-pico-w element doesn't expose (it has '3V3'), so the wire snapped to the board corner. Use '3V3'.
2026-06-04 13:32:02 +07:00
on_flush=_flush_factory_rt(), is_bwr=_is_bwr,
)
state = {
'slave': slave,
'dc_pin': int(cmd.get('dc_pin', -1)),
'cs_pin': int(cmd.get('cs_pin', -1)),
'rst_pin': int(cmd.get('rst_pin', -1)),
'busy_pin': busy_pin,
'cs_low': False,
'dc_high': False,
'refresh_ms': refresh_ms,
'controller_family': ctl_family,
}
_epaper_slaves[comp_id] = slave
_epaper_state[comp_id] = state
_sync_cs_events()
sensor_data['epaper_component_id'] = comp_id
_sensors[gpio] = sensor_data
_log(f'Sensor {sensor_type} attached on GPIO {gpio}')
elif c == 'sensor_update':
gpio = int(cmd['pin'])
with _sensors_lock:
sensor = _sensors.get(gpio)
if sensor:
for k, v in cmd.items():
if k not in ('cmd', 'pin'):
sensor[k] = v
stype = sensor.get('type')
slave = sensor.get('slave')
if stype == 'mpu6050' and slave is not None:
slave.update(
accel_x=float(sensor.get('accelX', 0)),
accel_y=float(sensor.get('accelY', 0)),
accel_z=float(sensor.get('accelZ', 1)),
gyro_x =float(sensor.get('gyroX', 0)),
gyro_y =float(sensor.get('gyroY', 0)),
gyro_z =float(sensor.get('gyroZ', 0)),
temp =float(sensor.get('temp', 25.0)),
)
elif stype == 'bmp280' and slave is not None:
slave.update(
temperature_c =float(sensor.get('temperature', 25.0)),
pressure_hpa =float(sensor.get('pressure', 1013.25)),
)
elif stype == 'ds3231' and slave is not None:
slave.temperatureC = float(sensor.get('temperature', 25.0))
elif c == 'sensor_detach':
gpio = int(cmd['pin'])
with _sensors_lock:
sensor = _sensors.pop(gpio, None)
if sensor and 'i2c_addr' in sensor:
_i2c_slaves.pop(sensor['i2c_addr'], None)
if sensor and 'epaper_component_id' in sensor:
cid = sensor['epaper_component_id']
_epaper_slaves.pop(cid, None)
_epaper_state.pop(cid, None)
_log(f'Sensor detached from GPIO {gpio}')
# ── Cross-board I2C proxy slave ──────────────────────────────────
# Installed by the frontend when an ESP32 board is wired across
# the I2C bus to a peer board (Uno, Pico, …) that owns a virtual
# device. The frontend snapshots the device's register state and
# pushes it here; we install a ProxySlave at the address so the
# ESP32 firmware's Wire master reads succeed inside QEMU.
elif c == 'proxy_i2c_register':
i2c_addr = int(cmd.get('addr', 0)) & 0x7F
try:
regs = base64.b64decode(cmd.get('regs_b64', ''))
except Exception as exc:
_log(f'proxy_i2c_register: bad base64: {exc}')
regs = b''
feat(i2c): cross-board bridging across all velxio boards (AVR/RP2040/ESP32 xtensa+riscv) Closes the remaining gaps in cross-board I2C so any topology of supported boards (Uno↔ESP32, two ESP32s, Uno↔Uno↔Uno, ESP32-C3 connected to anything, etc.) works end-to-end with all I2C components including write-only sinks (SSD1306, PCF8574, LCD-I2C). Implementation (6 phases): 1. **BFS routing in I2CBusManager**: connectToSlave + handleExternalConnect walk the bridge graph with a visited Set so multi-hop chains (A↔B↔C with the device on C) resolve transparently. A new forwarder-device shim is installed at intermediate hops so the existing handleExternalWrite/Read/Stop machinery routes through without per-method visited tracking. 2. **Per-peer proxy ownership in Esp32BridgeShim**: replaces the global _proxiedAddrs Set with _proxiedByPeer Map so concurrent bridges to the same ESP32 (e.g. wired to both Uno and Pico) don't wipe each other's proxies on teardown. Interconnect's per-wire teardown calls clearProxiesForPeer(peerBus) instead of clearAllProxies. 3. **BFS-aware proxy sync**: syncProxyFromPeer now walks the peer bus + its transitive bridges, so an ESP32 sees devices on boards two or more hops away. _peerDeviceLookup keeps a flat addr → device map for write-forwarding and resync. 4. **Periodic resync (250 ms)**: Esp32BridgeShim runs a setInterval while any proxy is live, re-dumping each device with dumpRegisters() and pushing updateProxyI2c only when an XOR- stride hash changes. This keeps RTC time advancing visible to ESP32 firmware without flooding the WS pipe with static calibration dumps. Hash is primed during initial sync so the first tick doesn't push a redundant identical buffer. 5. **Write-forwarding ProxySlave → peer**: backend ProxySlave buffers write bytes during the transaction and emits a `proxy_i2c_complete` event on STOP / repeated-START. Frontend Esp32Bridge dispatches the event to a new onProxyI2cComplete callback; the shim replays the byte sequence on the actual peer I2CDevice via writeByte() + stop(). Makes ESP32 firmware writes to peer SSD1306 actually repaint the OLED, peer PCF8574 latch updates, peer I2CMemoryDevice register mutations propagate. 6. **ESP32-C3 routed as bridge**: Interconnect.isBrowserSim no longer claims c3/xiao-c3/c3-supermini — they were already going through Esp32Bridge per the store's ESP32_RISCV_KINDS routing, but Interconnect was treating them as browser sims which broke proxy install. isEsp32Bridge now correctly includes c3 family + ESP32-S3 + Arduino Nano ESP32. Defensive: addBoard now disposes any existing shim's proxies before overwriting simulatorMap entry so test reruns don't leak timers. Tests: - 4 BFS multi-hop tests (i2c-multi-board-slave-gap.test.ts) - 11 cross-board scenarios + per-peer + write-forward + resync (i2c-esp32-multiboard-bridge.test.ts) - 1 real-firmware E2E for write-forward via QEMU (compile + load + observe proxy_i2c_complete arriving with the byte) - New sketch fixture: esp32_i2c_write_to_peer.ino Result: 90 test files / 1295 tests pass / 0 fail. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 03:51:39 +07:00
# Pass _emit so writes from the ESP32 firmware get forwarded
# back to the frontend as `proxy_i2c_complete` events. The
# frontend then replays the byte sequence on the actual
# peer I2CDevice so its state (PCF8574 latch, SSD1306
# GDDRAM, memory device registers …) stays in sync.
_i2c_slaves[i2c_addr] = _ProxySlave(i2c_addr, regs, emit_fn=_emit)
_log(f'proxy_i2c registered at 0x{i2c_addr:02x} ({len(regs)} bytes)')
elif c == 'proxy_i2c_update':
i2c_addr = int(cmd.get('addr', 0)) & 0x7F
try:
regs = base64.b64decode(cmd.get('regs_b64', ''))
except Exception as exc:
_log(f'proxy_i2c_update: bad base64: {exc}')
regs = b''
slave = _i2c_slaves.get(i2c_addr)
if slave is not None and hasattr(slave, 'update_registers'):
slave.update_registers(regs)
_log(f'proxy_i2c updated at 0x{i2c_addr:02x} ({len(regs)} bytes)')
elif c == 'proxy_i2c_unregister':
i2c_addr = int(cmd.get('addr', 0)) & 0x7F
popped = _i2c_slaves.pop(i2c_addr, None)
if popped is not None:
_log(f'proxy_i2c unregistered at 0x{i2c_addr:02x}')
feat: ESP32-CAM emulation with webcam frame bridge First open-source end-to-end emulation of the AI-Thinker ESP32-CAM in QEMU, paired with a browser webcam → firmware bridge so users can develop camera sketches without hardware. Status: esp_camera_init() returns ESP_OK; OV2640 chip-id verifies (PID/VER/MIDH/MIDL exactly match the datasheet); GPIO 25 VSYNC NEGEDGE interrupt enabled by the upstream driver. Final piece (cam_task accepting frames) is in progress — descriptor walker fix landed in this commit. Backend (Python/FastAPI): - simulation.py: camera_attach/frame/detach WS handlers - esp32_worker.py: ctypes binding to velxio_push_camera_frame + feature-detection fallback for older DLLs - esp32_lib_manager.py: forward camera commands to the worker stdin - esp-idf-template/main/CMakeLists.txt: esp32-camera headers added via add_prebuilt_library + REQUIRES driver (resolves i2c_master_* symbols). LED_BUILTIN=2 fallback for sketches that hardcode it. Frontend (React/TS): - EditorToolbar.tsx: ESP32-CAM (and the rest of the ESP32 family) added to isQemuBoard list — Run button now starts the QEMU bridge for these boards instead of falling through to the AVR path - useWebcamFrames.ts: getUserMedia → OffscreenCanvas → toBlob('image/jpeg') → base64 → WS at ~10 fps - CameraToggle.tsx: header button with status colors + frame counter - SimulatorCanvas.tsx: render CameraToggle for esp32-cam boards - Esp32Bridge.ts: sendCameraAttach/Frame/Detach + chunked btoa - useSimulatorStore.ts: diagnostic log on compileBoardProgram - components-metadata.json: regen including esp32-cam component Submodule pointer: - wokwi-libs/qemu-lcgamboa → ff8eee0 (camera devices commit on davidmonterocrespo24/qemu-lcgamboa branch picsimlab-esp32) Investigation + tests in test/test-esp32-cam/: - 13 autosearch markdown docs (overview, SOTA, OV2640 spec, DVP/I2S spec, build blueprint, blockers resolved, descriptor walker fix) - 5 sketches (camera_init, sccb_probe, dma_smoke, frame_roundtrip, webcam_demo) + 8 live + WS regression tests - README with the user-facing flow .gitignore: - libqemu-*.dll.{pre-camera,new,bak} (rollback points, regenerated) - wokwi-libs/esp32-camera/ (clone consumed by arduino-esp32 path, not part of this repo) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 05:28:55 +07:00
# ── ESP32-CAM frame injection ────────────────────────────────────
# Pushes a JPEG (or other format) into the QEMU OV2640 device's
# frame buffer via the velxio_push_camera_frame() symbol exported
# by the rebuilt libqemu-xtensa. Feature-detected at runtime so
# this branch is a no-op on a stock library.
elif c == 'camera_attach':
_log('camera_attach received (frame source ready)')
elif c == 'camera_frame':
try:
payload = base64.b64decode(cmd.get('b64', ''))
except Exception as exc:
_log(f'camera_frame: bad base64: {exc}')
payload = b''
# Throttled trace — log every 30th frame so noisy streaming
# leaves a footprint in the lib_manager log without spamming.
_camera_frame_count[0] += 1
n = _camera_frame_count[0]
if n == 1 or n % 30 == 0:
_log(f'camera_frame #{n} received ({len(payload)} bytes payload)')
feat: ESP32-CAM emulation with webcam frame bridge First open-source end-to-end emulation of the AI-Thinker ESP32-CAM in QEMU, paired with a browser webcam → firmware bridge so users can develop camera sketches without hardware. Status: esp_camera_init() returns ESP_OK; OV2640 chip-id verifies (PID/VER/MIDH/MIDL exactly match the datasheet); GPIO 25 VSYNC NEGEDGE interrupt enabled by the upstream driver. Final piece (cam_task accepting frames) is in progress — descriptor walker fix landed in this commit. Backend (Python/FastAPI): - simulation.py: camera_attach/frame/detach WS handlers - esp32_worker.py: ctypes binding to velxio_push_camera_frame + feature-detection fallback for older DLLs - esp32_lib_manager.py: forward camera commands to the worker stdin - esp-idf-template/main/CMakeLists.txt: esp32-camera headers added via add_prebuilt_library + REQUIRES driver (resolves i2c_master_* symbols). LED_BUILTIN=2 fallback for sketches that hardcode it. Frontend (React/TS): - EditorToolbar.tsx: ESP32-CAM (and the rest of the ESP32 family) added to isQemuBoard list — Run button now starts the QEMU bridge for these boards instead of falling through to the AVR path - useWebcamFrames.ts: getUserMedia → OffscreenCanvas → toBlob('image/jpeg') → base64 → WS at ~10 fps - CameraToggle.tsx: header button with status colors + frame counter - SimulatorCanvas.tsx: render CameraToggle for esp32-cam boards - Esp32Bridge.ts: sendCameraAttach/Frame/Detach + chunked btoa - useSimulatorStore.ts: diagnostic log on compileBoardProgram - components-metadata.json: regen including esp32-cam component Submodule pointer: - wokwi-libs/qemu-lcgamboa → ff8eee0 (camera devices commit on davidmonterocrespo24/qemu-lcgamboa branch picsimlab-esp32) Investigation + tests in test/test-esp32-cam/: - 13 autosearch markdown docs (overview, SOTA, OV2640 spec, DVP/I2S spec, build blueprint, blockers resolved, descriptor walker fix) - 5 sketches (camera_init, sccb_probe, dma_smoke, frame_roundtrip, webcam_demo) + 8 live + WS regression tests - README with the user-facing flow .gitignore: - libqemu-*.dll.{pre-camera,new,bak} (rollback points, regenerated) - wokwi-libs/esp32-camera/ (clone consumed by arduino-esp32 path, not part of this repo) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 05:28:55 +07:00
if payload:
_push_camera_frame(payload)
elif c == 'camera_detach':
_push_camera_frame(b'') # NULL/0 detaches in the C side
elif c == 'stop':
_stopped.set()
# Request a clean shutdown via the QEMU main-loop thread.
# qemu_system_shutdown_request() is safe to call from any thread:
# it posts an event to the main loop which then tears down block
# devices in the correct AIO context, avoiding the
# "blk_exp_close_all_type: in_aio_context_home_thread" assertion
# that fires when qemu_cleanup() is called directly from here.
if _shutdown_request:
try:
_shutdown_request(3) # SHUTDOWN_CAUSE_HOST_SIGNAL = 3
except Exception:
pass
qemu_t.join(timeout=5.0)
# Clean up temp firmware file
if firmware_path:
try:
os.unlink(firmware_path)
except OSError:
pass
os._exit(0)
if __name__ == '__main__':
main()