2026-03-15 08:55:13 +07:00
|
|
|
#!/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}
|
2026-04-21 12:17:30 +07:00
|
|
|
{"cmd": "set_adc_waveform", "channel": N, "samples_u12_b64": "<base64-LE-uint16>", "period_ns": P}
|
2026-03-15 08:55:13 +07:00
|
|
|
{"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}
|
2026-05-19 09:05:34 +07:00
|
|
|
{"type": "ledc_duty", "channel": N, "duty_pct": F}
|
2026-03-15 08:55:13 +07:00
|
|
|
{"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
|
2026-03-23 01:17:44 +07:00
|
|
|
import time
|
2026-03-15 08:55:13 +07:00
|
|
|
|
2026-04-08 10:02:43 +07:00
|
|
|
# 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,
|
2026-05-13 02:55:15 +07:00
|
|
|
ProxySlave as _ProxySlave,
|
2026-04-08 10:02:43 +07:00
|
|
|
)
|
|
|
|
|
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
|
2026-04-08 10:02:43 +07:00
|
|
|
_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
|
2026-04-08 10:02:43 +07:00
|
|
|
_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]
|
2026-05-13 02:55:15 +07:00
|
|
|
_ProxySlave = _mod.ProxySlave # type: ignore[assignment]
|
2026-04-08 10:02:43 +07:00
|
|
|
|
feat: Add support for SSD168x ePaper panels
- Introduced EPaperPanels.ts to define configurations for various ePaper panels including dimensions, refresh rates, and controller details.
- Implemented SSD168xDecoder.ts to handle the decoding of SPI commands for the SSD168x family of ePaper displays.
- Created EPaperPart.ts to manage the simulation of ePaper panels, integrating with the existing simulator architecture and handling events.
- Added example sketches for 2.13", 2.9", 4.2", and 7.5" ePaper displays, demonstrating basic functionality and text rendering.
- Ensured compatibility with AVR, RP2040, and ESP32 platforms, with appropriate pin configurations for each.
2026-04-30 08:23:00 +07:00
|
|
|
# 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:
|
2026-04-30 10:24:20 +07:00
|
|
|
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,
|
2026-04-30 10:24:20 +07:00
|
|
|
)
|
feat: Add support for SSD168x ePaper panels
- Introduced EPaperPanels.ts to define configurations for various ePaper panels including dimensions, refresh rates, and controller details.
- Implemented SSD168xDecoder.ts to handle the decoding of SPI commands for the SSD168x family of ePaper displays.
- Created EPaperPart.ts to manage the simulation of ePaper panels, integrating with the existing simulator architecture and handling events.
- Added example sketches for 2.13", 2.9", 4.2", and 7.5" ePaper displays, demonstrating basic functionality and text rendering.
- Ensured compatibility with AVR, RP2040, and ESP32 platforms, with appropriate pin configurations for each.
2026-04-30 08:23:00 +07:00
|
|
|
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
|
feat: Add support for SSD168x ePaper panels
- Introduced EPaperPanels.ts to define configurations for various ePaper panels including dimensions, refresh rates, and controller details.
- Implemented SSD168xDecoder.ts to handle the decoding of SPI commands for the SSD168x family of ePaper displays.
- Created EPaperPart.ts to manage the simulation of ePaper panels, integrating with the existing simulator architecture and handling events.
- Added example sketches for 2.13", 2.9", 4.2", and 7.5" ePaper displays, demonstrating basic functionality and text rendering.
- Ensured compatibility with AVR, RP2040, and ESP32 platforms, with appropriate pin configurations for each.
2026-04-30 08:23:00 +07:00
|
|
|
_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
|
feat: Add support for SSD168x ePaper panels
- Introduced EPaperPanels.ts to define configurations for various ePaper panels including dimensions, refresh rates, and controller details.
- Implemented SSD168xDecoder.ts to handle the decoding of SPI commands for the SSD168x family of ePaper displays.
- Created EPaperPart.ts to manage the simulation of ePaper panels, integrating with the existing simulator architecture and handling events.
- Added example sketches for 2.13", 2.9", 4.2", and 7.5" ePaper displays, demonstrating basic functionality and text rendering.
- Ensured compatibility with AVR, RP2040, and ESP32 platforms, with appropriate pin configurations for each.
2026-04-30 08:23:00 +07:00
|
|
|
_spec.loader.exec_module(_mod) # type: ignore[union-attr]
|
|
|
|
|
_Ssd168xEpaperSlave = _mod.Ssd168xEpaperSlave # type: ignore[assignment]
|
2026-04-30 10:24:20 +07:00
|
|
|
_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]
|
feat: Add support for SSD168x ePaper panels
- Introduced EPaperPanels.ts to define configurations for various ePaper panels including dimensions, refresh rates, and controller details.
- Implemented SSD168xDecoder.ts to handle the decoding of SPI commands for the SSD168x family of ePaper displays.
- Created EPaperPart.ts to manage the simulation of ePaper panels, integrating with the existing simulator architecture and handling events.
- Added example sketches for 2.13", 2.9", 4.2", and 7.5" ePaper displays, demonstrating basic functionality and text rendering.
- Ensured compatibility with AVR, RP2040, and ESP32 platforms, with appropriate pin configurations for each.
2026-04-30 08:23:00 +07:00
|
|
|
|
2026-03-15 08:55:13 +07:00
|
|
|
# ─── 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) ──────────────────────────────
|
2026-03-19 09:30:45 +07:00
|
|
|
# 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.
|
2026-03-15 08:55:13 +07:00
|
|
|
|
|
|
|
|
_GPIO_COUNT = 40
|
|
|
|
|
_PINMAP = (ctypes.c_int16 * (_GPIO_COUNT + 1))(
|
|
|
|
|
_GPIO_COUNT,
|
|
|
|
|
*range(_GPIO_COUNT),
|
|
|
|
|
)
|
|
|
|
|
|
2026-03-19 09:30:45 +07:00
|
|
|
|
|
|
|
|
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),
|
|
|
|
|
)
|
|
|
|
|
|
2026-03-15 08:55:13 +07:00
|
|
|
# ─── 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)
|
2026-05-19 13:24:32 +07:00
|
|
|
# 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)
|
2026-06-04 09:22:29 +07:00
|
|
|
# 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)
|
2026-03-15 08:55:13 +07:00
|
|
|
|
|
|
|
|
|
|
|
|
|
class _CallbacksT(ctypes.Structure):
|
|
|
|
|
_fields_ = [
|
2026-05-19 13:24:32 +07:00
|
|
|
('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),
|
2026-06-04 09:22:29 +07:00
|
|
|
('picsimlab_spi_event_batch', _SPI_BATCH),
|
2026-03-15 08:55:13 +07:00
|
|
|
]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ─── 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)
|
|
|
|
|
|
2026-04-01 06:53:56 +07:00
|
|
|
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)
|
2026-03-15 08:55:13 +07:00
|
|
|
|
2026-03-19 09:30:45 +07:00
|
|
|
# Adjust GPIO pinmap based on chip: ESP32-C3 has only 22 GPIOs
|
|
|
|
|
if 'c3' in machine:
|
|
|
|
|
_build_pinmap(22)
|
|
|
|
|
|
2026-03-15 08:55:13 +07:00
|
|
|
# ── 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:
|
2026-04-05 10:49:25 +07:00
|
|
|
lib_size = os.path.getsize(lib_path) if os.path.isfile(lib_path) else 0
|
|
|
|
|
_log(f'Loading library: {lib_path} ({lib_size} bytes)')
|
2026-03-15 08:55:13 +07:00
|
|
|
lib = ctypes.CDLL(lib_path)
|
|
|
|
|
except Exception as exc:
|
|
|
|
|
_emit({'type': 'error', 'message': f'Cannot load DLL: {exc}'})
|
|
|
|
|
os._exit(1)
|
2026-03-24 23:54:44 +07:00
|
|
|
lib.qemu_picsimlab_get_internals.restype = ctypes.c_void_p
|
2026-03-15 08:55:13 +07:00
|
|
|
|
2026-04-13 09:55:11 +07:00
|
|
|
# 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
|
|
|
|
|
|
2026-04-29 05:24:39 +07:00
|
|
|
# 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
|
|
|
|
|
|
2026-04-13 09:55:11 +07:00
|
|
|
# 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]
|
|
|
|
|
|
2026-03-15 08:55:13 +07:00
|
|
|
# ── 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.
|
2026-05-09 08:22:53 +07:00
|
|
|
# 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))
|
2026-03-15 08:55:13 +07:00
|
|
|
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(),
|
|
|
|
|
]
|
2026-04-01 06:53:56 +07:00
|
|
|
|
2026-04-05 10:49:25 +07:00
|
|
|
# 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:
|
2026-04-01 06:53:56 +07:00
|
|
|
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}')
|
|
|
|
|
|
2026-03-15 08:55:13 +07:00
|
|
|
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
|
2026-03-23 04:03:17 +07:00
|
|
|
_sensors_ready = threading.Event() # set after pre-registering initial sensors
|
2026-04-08 01:43:09 +07:00
|
|
|
_i2c_responses: dict[int, int] = {} # 7-bit addr → response byte (simple)
|
|
|
|
|
_i2c_slaves: dict = {} # 7-bit addr → I2C slave/sink instance
|
2026-04-29 05:24:39 +07:00
|
|
|
_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
|
|
|
|
|
|
feat: Add support for SSD168x ePaper panels
- Introduced EPaperPanels.ts to define configurations for various ePaper panels including dimensions, refresh rates, and controller details.
- Implemented SSD168xDecoder.ts to handle the decoding of SPI commands for the SSD168x family of ePaper displays.
- Created EPaperPart.ts to manage the simulation of ePaper panels, integrating with the existing simulator architecture and handling events.
- Added example sketches for 2.13", 2.9", 4.2", and 7.5" ePaper displays, demonstrating basic functionality and text rendering.
- Ensured compatibility with AVR, RP2040, and ESP32 platforms, with appropriate pin configurations for each.
2026-04-30 08:23:00 +07:00
|
|
|
# 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 = {}
|
|
|
|
|
|
2026-04-29 05:24:39 +07:00
|
|
|
# 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] = {}
|
2026-03-15 08:55:13 +07:00
|
|
|
_rmt_decoders: dict[int, _RmtDecoder] = {}
|
|
|
|
|
_uart0_buf = bytearray() # accumulate UART0 for crash detection
|
|
|
|
|
_reboot_count = [0]
|
|
|
|
|
_crashed = [False]
|
2026-05-03 08:05:37 +07:00
|
|
|
_camera_frame_count = [0] # ESP32-CAM frame trace counter
|
2026-03-15 08:55:13 +07:00
|
|
|
_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.
|
2026-05-17 10:26:12 +07:00
|
|
|
#
|
|
|
|
|
# `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.
|
2026-03-24 23:54:44 +07:00
|
|
|
|
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.
|
2026-03-24 23:54:44 +07:00
|
|
|
"""
|
|
|
|
|
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] = {}
|
2026-03-24 23:54:44 +07:00
|
|
|
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})
|
2026-03-25 00:38:08 +07:00
|
|
|
except Exception:
|
|
|
|
|
pass
|
2026-03-24 23:54:44 +07:00
|
|
|
|
2026-03-23 04:03:17 +07:00
|
|
|
# Sensor state: gpio_pin → {type, properties..., saw_low, responding}
|
|
|
|
|
_sensors: dict[int, dict] = {}
|
|
|
|
|
_sensors_lock = threading.Lock()
|
2026-03-23 01:17:44 +07:00
|
|
|
|
2026-04-11 08:51:56 +07:00
|
|
|
# ── 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 = []
|
2026-03-23 01:17:44 +07:00
|
|
|
|
|
|
|
|
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]
|
|
|
|
|
|
2026-03-23 11:03:01 +07:00
|
|
|
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.
|
2026-03-23 20:37:06 +07:00
|
|
|
|
|
|
|
|
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 HIGH→LOW), 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.
|
2026-03-23 11:03:01 +07:00
|
|
|
"""
|
|
|
|
|
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
|
|
|
|
|
|
2026-04-11 08:51:56 +07:00
|
|
|
class DHT22SyncHandler:
|
|
|
|
|
"""Drives the DHT22 waveform synchronously, one GPIO_IN read sync at a time.
|
2026-03-23 11:03:01 +07:00
|
|
|
|
2026-04-11 08:51:56 +07:00
|
|
|
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.
|
2026-03-23 08:17:51 +07:00
|
|
|
"""
|
2026-04-11 08:51:56 +07:00
|
|
|
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}')
|
2026-03-23 04:39:09 +07:00
|
|
|
with _sensors_lock:
|
2026-04-11 08:51:56 +07:00
|
|
|
sensor = _sensors.get(self._trig_gpio)
|
2026-03-23 04:39:09 +07:00
|
|
|
if sensor:
|
|
|
|
|
sensor['responding'] = False
|
2026-04-11 08:51:56 +07:00
|
|
|
return True
|
2026-03-23 04:39:09 +07:00
|
|
|
|
2026-03-15 08:55:13 +07:00
|
|
|
# ── 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
|
2026-04-29 05:24:39 +07:00
|
|
|
_pin_state[gpio] = value & 1
|
2026-06-04 09:22:29 +07:00
|
|
|
# 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()
|
2026-03-15 08:55:13 +07:00
|
|
|
_emit({'type': 'gpio_change', 'pin': gpio, 'state': value})
|
|
|
|
|
|
2026-04-29 05:24:39 +07:00
|
|
|
# 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}')
|
|
|
|
|
|
feat: Add support for SSD168x ePaper panels
- Introduced EPaperPanels.ts to define configurations for various ePaper panels including dimensions, refresh rates, and controller details.
- Implemented SSD168xDecoder.ts to handle the decoding of SPI commands for the SSD168x family of ePaper displays.
- Created EPaperPart.ts to manage the simulation of ePaper panels, integrating with the existing simulator architecture and handling events.
- Added example sketches for 2.13", 2.9", 4.2", and 7.5" ePaper displays, demonstrating basic functionality and text rendering.
- Ensured compatibility with AVR, RP2040, and ESP32 platforms, with appropriate pin configurations for each.
2026-04-30 08:23:00 +07:00
|
|
|
# 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()
|
|
|
|
|
|
2026-03-23 04:03:17 +07:00
|
|
|
# Sensor protocol dispatch by type
|
|
|
|
|
with _sensors_lock:
|
|
|
|
|
sensor = _sensors.get(gpio)
|
2026-03-23 04:39:09 +07:00
|
|
|
if sensor is None:
|
|
|
|
|
return
|
|
|
|
|
|
|
|
|
|
stype = sensor.get('type', '')
|
|
|
|
|
|
|
|
|
|
if stype == 'dht22':
|
2026-03-23 08:17:51 +07:00
|
|
|
# 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.
|
2026-03-23 01:17:44 +07:00
|
|
|
if value == 0 and not sensor.get('responding', False):
|
2026-03-23 08:17:51 +07:00
|
|
|
sensor['saw_low'] = True
|
2026-03-23 01:17:44 +07:00
|
|
|
|
2026-03-23 04:39:09 +07:00
|
|
|
elif stype == 'hc-sr04':
|
2026-04-11 08:51:56 +07:00
|
|
|
# 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).
|
2026-03-23 04:39:09 +07:00
|
|
|
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))
|
2026-04-11 08:51:56 +07:00
|
|
|
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}')
|
2026-03-23 04:39:09 +07:00
|
|
|
|
2026-03-15 08:55:13 +07:00
|
|
|
def _on_dir_change(slot: int, direction: int) -> None:
|
|
|
|
|
if _stopped.is_set():
|
|
|
|
|
return
|
2026-03-23 08:17:51 +07:00
|
|
|
|
2026-03-23 11:03:01 +07:00
|
|
|
# ── 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:
|
2026-03-23 20:37:06 +07:00
|
|
|
if direction == -1:
|
2026-04-11 08:51:56 +07:00
|
|
|
# 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()]
|
2026-03-23 20:37:06 +07:00
|
|
|
return # always return for GPIO_IN syncs (fast path)
|
2026-03-23 11:03:01 +07:00
|
|
|
marker = direction & 0xF000
|
2026-03-23 21:23:37 +07:00
|
|
|
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})
|
2026-03-23 11:03:01 +07:00
|
|
|
return
|
|
|
|
|
|
|
|
|
|
# ── DHT22: track direction changes + trigger sync response ───────
|
2026-03-23 08:17:51 +07:00
|
|
|
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:
|
2026-03-23 11:03:01 +07:00
|
|
|
# OUTPUT mode — record timestamp for diagnostics
|
2026-03-23 08:17:51 +07:00
|
|
|
sensor['dir_out_ns'] = time.perf_counter_ns()
|
|
|
|
|
elif direction == 0:
|
2026-03-23 11:03:01 +07:00
|
|
|
# INPUT mode — trigger DHT22 sync-based response
|
2026-03-23 08:17:51 +07:00
|
|
|
if sensor.get('saw_low', False) and not sensor.get('responding', False):
|
|
|
|
|
sensor['saw_low'] = False
|
|
|
|
|
sensor['responding'] = True
|
|
|
|
|
|
2026-03-23 11:03:01 +07:00
|
|
|
# Build the response waveform phases
|
2026-03-23 20:37:06 +07:00
|
|
|
temp = sensor.get('temperature', 25.0)
|
|
|
|
|
hum = sensor.get('humidity', 50.0)
|
|
|
|
|
payload = _dht22_build_payload(temp, hum)
|
2026-03-23 11:03:01 +07:00
|
|
|
phases = _dht22_build_sync_phases(payload)
|
2026-03-23 08:17:51 +07:00
|
|
|
|
2026-03-23 11:03:01 +07:00
|
|
|
# Drive pin LOW synchronously — firmware sees LOW
|
|
|
|
|
# at its first digitalRead() in expectPulse().
|
|
|
|
|
lib.qemu_picsimlab_set_pin(slot, 0)
|
2026-03-23 08:17:51 +07:00
|
|
|
|
2026-03-23 11:03:01 +07:00
|
|
|
# Arm the sync-based response state machine
|
2026-04-11 08:51:56 +07:00
|
|
|
_sync_handlers.append(DHT22SyncHandler(gpio, slot, phases))
|
2026-03-23 11:03:01 +07:00
|
|
|
_log(f'DHT22 sync armed gpio={gpio} '
|
2026-03-23 20:37:06 +07:00
|
|
|
f'temp={temp} hum={hum} '
|
2026-03-23 11:03:01 +07:00
|
|
|
f'phases={len(phases)} payload={payload}')
|
2026-03-15 08:55:13 +07:00
|
|
|
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})
|
2026-04-29 05:24:39 +07:00
|
|
|
# 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}')
|
2026-03-15 08:55:13 +07:00
|
|
|
# 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]})
|
2026-04-05 10:49:25 +07:00
|
|
|
# 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}')
|
2026-03-15 08:55:13 +07:00
|
|
|
|
|
|
|
|
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})
|
|
|
|
|
|
2026-05-19 13:24:32 +07:00
|
|
|
def _on_gpio_matrix(gpio: int, signal_id: int) -> None:
|
|
|
|
|
"""Synchronous GPIO Matrix routing event from libqemu 1.1.0+.
|
|
|
|
|
|
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.
|
2026-05-19 13:24:32 +07:00
|
|
|
"""
|
|
|
|
|
if _stopped.is_set():
|
|
|
|
|
return
|
|
|
|
|
try:
|
|
|
|
|
sid_lo = signal_id & 0xFF
|
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.
|
2026-05-19 13:24:32 +07:00
|
|
|
except Exception:
|
2026-05-19 21:04:50 +07:00
|
|
|
# Iothread callback — never raise, never block.
|
2026-05-19 13:24:32 +07:00
|
|
|
pass
|
|
|
|
|
|
2026-04-10 01:06:39 +07:00
|
|
|
# ── 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',
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-15 08:55:13 +07:00
|
|
|
def _on_i2c_event(bus_id: int, addr: int, event: int) -> int:
|
|
|
|
|
"""Synchronous — must return immediately; called from QEMU thread."""
|
2026-04-08 01:43:09 +07:00
|
|
|
slave = _i2c_slaves.get(addr)
|
2026-04-10 01:06:39 +07:00
|
|
|
op = event & 0xFF
|
|
|
|
|
data = (event >> 8) & 0xFF
|
|
|
|
|
op_name = _I2C_OP_NAME.get(op, f'0x{op:02x}')
|
|
|
|
|
|
2026-04-08 01:43:09 +07:00
|
|
|
if slave is not None:
|
2026-04-10 01:06:39 +07:00
|
|
|
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 = ''
|
|
|
|
|
|
2026-05-24 03:26:19 +07:00
|
|
|
slave_type_name = type(slave).__name__
|
|
|
|
|
if slave_type_name == 'MPU6050Slave':
|
2026-04-10 01:06:39 +07:00
|
|
|
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}')
|
2026-05-24 03:26:19 +07:00
|
|
|
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.
|
2026-04-10 01:06:39 +07:00
|
|
|
_log(f'I2C bus={bus_id} addr=0x{addr:02x} event=0x{event:04x} '
|
2026-05-24 03:26:19 +07:00
|
|
|
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':
|
2026-04-10 01:06:39 +07:00
|
|
|
_emit({'type': 'i2c_trace', 'bus': bus_id, 'addr': addr,
|
|
|
|
|
'event': event, 'op': op_name, 'result': result,
|
|
|
|
|
'reg_ptr': reg_ptr})
|
2026-04-08 10:02:54 +07:00
|
|
|
return result
|
2026-04-10 01:06:39 +07:00
|
|
|
|
|
|
|
|
_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())}')
|
2026-03-15 08:55:13 +07:00
|
|
|
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
|
2026-03-15 08:55:13 +07:00
|
|
|
return resp
|
|
|
|
|
|
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
|
2026-05-03 10:31:16 +07:00
|
|
|
# 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
|
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()
|
|
|
|
|
|
2026-06-04 09:22:29 +07:00
|
|
|
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
|
|
|
|
|
|
2026-05-03 10:31:16 +07:00
|
|
|
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()
|
|
|
|
|
|
2026-03-15 08:55:13 +07:00
|
|
|
def _on_spi_event(bus_id: int, event: int) -> int:
|
2026-04-29 05:24:39 +07:00
|
|
|
"""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}')
|
feat: Add support for SSD168x ePaper panels
- Introduced EPaperPanels.ts to define configurations for various ePaper panels including dimensions, refresh rates, and controller details.
- Implemented SSD168xDecoder.ts to handle the decoding of SPI commands for the SSD168x family of ePaper displays.
- Created EPaperPart.ts to manage the simulation of ePaper panels, integrating with the existing simulator architecture and handling events.
- Added example sketches for 2.13", 2.9", 4.2", and 7.5" ePaper displays, demonstrating basic functionality and text rendering.
- Ensured compatibility with AVR, RP2040, and ESP32 platforms, with appropriate pin configurations for each.
2026-04-30 08:23:00 +07:00
|
|
|
|
|
|
|
|
# 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
|
2026-03-15 08:55:13 +07:00
|
|
|
resp = _spi_response[0]
|
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()
|
2026-03-15 08:55:13 +07:00
|
|
|
_emit({'type': 'spi_event', 'bus': bus_id, 'event': event, 'response': resp})
|
|
|
|
|
return resp
|
|
|
|
|
|
2026-06-04 09:22:29 +07:00
|
|
|
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()
|
|
|
|
|
|
2026-03-15 08:55:13 +07:00
|
|
|
# Keep callback struct alive (prevent GC from freeing ctypes closures)
|
|
|
|
|
_cbs_ref = _CallbacksT(
|
2026-05-19 13:24:32 +07:00
|
|
|
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),
|
2026-06-04 09:22:29 +07:00
|
|
|
picsimlab_spi_event_batch = _SPI_BATCH(_on_spi_batch),
|
2026-03-15 08:55:13 +07:00
|
|
|
)
|
|
|
|
|
lib.qemu_picsimlab_register_callbacks(ctypes.byref(_cbs_ref))
|
2026-05-19 13:24:32 +07:00
|
|
|
# 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
|
2026-03-15 08:55:13 +07:00
|
|
|
|
|
|
|
|
# ── 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()
|
2026-03-23 04:03:17 +07:00
|
|
|
# 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)
|
2026-03-15 08:55:13 +07:00
|
|
|
lib.qemu_main_loop()
|
|
|
|
|
|
2026-03-17 20:08:06 +07:00
|
|
|
# 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)
|
|
|
|
|
|
2026-04-06 11:11:29 +07:00
|
|
|
# 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,
|
|
|
|
|
)
|
|
|
|
|
|
2026-03-15 08:55:13 +07:00
|
|
|
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)
|
|
|
|
|
|
2026-03-23 04:03:17 +07:00
|
|
|
# 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:
|
2026-04-08 10:02:43 +07:00
|
|
|
sensor_data: dict = {
|
2026-03-23 04:03:17 +07:00
|
|
|
'type': sensor_type,
|
|
|
|
|
**{k: v for k, v in s.items() if k not in ('sensor_type', 'pin')},
|
|
|
|
|
'saw_low': False,
|
|
|
|
|
'responding': False,
|
|
|
|
|
}
|
2026-04-08 10:02:43 +07:00
|
|
|
# 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
|
feat: Add support for SSD168x ePaper panels
- Introduced EPaperPanels.ts to define configurations for various ePaper panels including dimensions, refresh rates, and controller details.
- Implemented SSD168xDecoder.ts to handle the decoding of SPI commands for the SSD168x family of ePaper displays.
- Created EPaperPart.ts to manage the simulation of ePaper panels, integrating with the existing simulator architecture and handling events.
- Added example sketches for 2.13", 2.9", 4.2", and 7.5" ePaper displays, demonstrating basic functionality and text rendering.
- Ensured compatibility with AVR, RP2040, and ESP32 platforms, with appropriate pin configurations for each.
2026-04-30 08:23:00 +07:00
|
|
|
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.
|
feat: Add support for SSD168x ePaper panels
- Introduced EPaperPanels.ts to define configurations for various ePaper panels including dimensions, refresh rates, and controller details.
- Implemented SSD168xDecoder.ts to handle the decoding of SPI commands for the SSD168x family of ePaper displays.
- Created EPaperPart.ts to manage the simulation of ePaper panels, integrating with the existing simulator architecture and handling events.
- Added example sketches for 2.13", 2.9", 4.2", and 7.5" ePaper displays, demonstrating basic functionality and text rendering.
- Ensured compatibility with AVR, RP2040, and ESP32 platforms, with appropriate pin configurations for each.
2026-04-30 08:23:00 +07:00
|
|
|
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
|
feat: Add support for SSD168x ePaper panels
- Introduced EPaperPanels.ts to define configurations for various ePaper panels including dimensions, refresh rates, and controller details.
- Implemented SSD168xDecoder.ts to handle the decoding of SPI commands for the SSD168x family of ePaper displays.
- Created EPaperPart.ts to manage the simulation of ePaper panels, integrating with the existing simulator architecture and handling events.
- Added example sketches for 2.13", 2.9", 4.2", and 7.5" ePaper displays, demonstrating basic functionality and text rendering.
- Ensured compatibility with AVR, RP2040, and ESP32 platforms, with appropriate pin configurations for each.
2026-04-30 08:23:00 +07:00
|
|
|
|
|
|
|
|
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,
|
feat: Add support for SSD168x ePaper panels
- Introduced EPaperPanels.ts to define configurations for various ePaper panels including dimensions, refresh rates, and controller details.
- Implemented SSD168xDecoder.ts to handle the decoding of SPI commands for the SSD168x family of ePaper displays.
- Created EPaperPart.ts to manage the simulation of ePaper panels, integrating with the existing simulator architecture and handling events.
- Added example sketches for 2.13", 2.9", 4.2", and 7.5" ePaper displays, demonstrating basic functionality and text rendering.
- Ensured compatibility with AVR, RP2040, and ESP32 platforms, with appropriate pin configurations for each.
2026-04-30 08:23:00 +07:00
|
|
|
_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)."""
|
feat: Add support for SSD168x ePaper panels
- Introduced EPaperPanels.ts to define configurations for various ePaper panels including dimensions, refresh rates, and controller details.
- Implemented SSD168xDecoder.ts to handle the decoding of SPI commands for the SSD168x family of ePaper displays.
- Created EPaperPart.ts to manage the simulation of ePaper panels, integrating with the existing simulator architecture and handling events.
- Added example sketches for 2.13", 2.9", 4.2", and 7.5" ePaper displays, demonstrating basic functionality and text rendering.
- Ensured compatibility with AVR, RP2040, and ESP32 platforms, with appropriate pin configurations for each.
2026-04-30 08:23:00 +07:00
|
|
|
def _on_flush(frame):
|
|
|
|
|
try:
|
|
|
|
|
frame_b64 = base64.b64encode(frame.pixels).decode('ascii')
|
|
|
|
|
except Exception:
|
|
|
|
|
return
|
2026-06-04 12:47:17 +07:00
|
|
|
# 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.
|
feat: Add support for SSD168x ePaper panels
- Introduced EPaperPanels.ts to define configurations for various ePaper panels including dimensions, refresh rates, and controller details.
- Implemented SSD168xDecoder.ts to handle the decoding of SPI commands for the SSD168x family of ePaper displays.
- Created EPaperPart.ts to manage the simulation of ePaper panels, integrating with the existing simulator architecture and handling events.
- Added example sketches for 2.13", 2.9", 4.2", and 7.5" ePaper displays, demonstrating basic functionality and text rendering.
- Ensured compatibility with AVR, RP2040, and ESP32 platforms, with appropriate pin configurations for each.
2026-04-30 08:23:00 +07:00
|
|
|
_emit({
|
|
|
|
|
'type': 'epaper_update',
|
2026-06-04 12:47:17 +07:00
|
|
|
'component_id': _comp_id,
|
|
|
|
|
'width': _w,
|
|
|
|
|
'height': _h,
|
|
|
|
|
'frame_b64': frame_b64,
|
|
|
|
|
'refresh_ms': _refresh,
|
feat: Add support for SSD168x ePaper panels
- Introduced EPaperPanels.ts to define configurations for various ePaper panels including dimensions, refresh rates, and controller details.
- Implemented SSD168xDecoder.ts to handle the decoding of SPI commands for the SSD168x family of ePaper displays.
- Created EPaperPart.ts to manage the simulation of ePaper panels, integrating with the existing simulator architecture and handling events.
- Added example sketches for 2.13", 2.9", 4.2", and 7.5" ePaper displays, demonstrating basic functionality and text rendering.
- Ensured compatibility with AVR, RP2040, and ESP32 platforms, with appropriate pin configurations for each.
2026-04-30 08:23:00 +07:00
|
|
|
})
|
|
|
|
|
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)
|
feat: Add support for SSD168x ePaper panels
- Introduced EPaperPanels.ts to define configurations for various ePaper panels including dimensions, refresh rates, and controller details.
- Implemented SSD168xDecoder.ts to handle the decoding of SPI commands for the SSD168x family of ePaper displays.
- Created EPaperPart.ts to manage the simulation of ePaper panels, integrating with the existing simulator architecture and handling events.
- Added example sketches for 2.13", 2.9", 4.2", and 7.5" ePaper displays, demonstrating basic functionality and text rendering.
- Ensured compatibility with AVR, RP2040, and ESP32 platforms, with appropriate pin configurations for each.
2026-04-30 08:23:00 +07:00
|
|
|
|
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):
|
feat: Add support for SSD168x ePaper panels
- Introduced EPaperPanels.ts to define configurations for various ePaper panels including dimensions, refresh rates, and controller details.
- Implemented SSD168xDecoder.ts to handle the decoding of SPI commands for the SSD168x family of ePaper displays.
- Created EPaperPart.ts to manage the simulation of ePaper panels, integrating with the existing simulator architecture and handling events.
- Added example sketches for 2.13", 2.9", 4.2", and 7.5" ePaper displays, demonstrating basic functionality and text rendering.
- Ensured compatibility with AVR, RP2040, and ESP32 platforms, with appropriate pin configurations for each.
2026-04-30 08:23:00 +07:00
|
|
|
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)
|
feat: Add support for SSD168x ePaper panels
- Introduced EPaperPanels.ts to define configurations for various ePaper panels including dimensions, refresh rates, and controller details.
- Implemented SSD168xDecoder.ts to handle the decoding of SPI commands for the SSD168x family of ePaper displays.
- Created EPaperPart.ts to manage the simulation of ePaper panels, integrating with the existing simulator architecture and handling events.
- Added example sketches for 2.13", 2.9", 4.2", and 7.5" ePaper displays, demonstrating basic functionality and text rendering.
- Ensured compatibility with AVR, RP2040, and ESP32 platforms, with appropriate pin configurations for each.
2026-04-30 08:23:00 +07:00
|
|
|
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()
|
feat: Add support for SSD168x ePaper panels
- Introduced EPaperPanels.ts to define configurations for various ePaper panels including dimensions, refresh rates, and controller details.
- Implemented SSD168xDecoder.ts to handle the decoding of SPI commands for the SSD168x family of ePaper displays.
- Created EPaperPart.ts to manage the simulation of ePaper panels, integrating with the existing simulator architecture and handling events.
- Added example sketches for 2.13", 2.9", 4.2", and 7.5" ePaper displays, demonstrating basic functionality and text rendering.
- Ensured compatibility with AVR, RP2040, and ESP32 platforms, with appropriate pin configurations for each.
2026-04-30 08:23:00 +07:00
|
|
|
except Exception:
|
|
|
|
|
pass
|
|
|
|
|
return _on_flush
|
|
|
|
|
|
2026-04-30 10:24:20 +07:00
|
|
|
# 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(),
|
|
|
|
|
)
|
2026-04-30 10:24:20 +07:00
|
|
|
else:
|
2026-06-04 12:47:17 +07:00
|
|
|
_is_bwr = 'bwr' in str(s.get('panel_kind', '')).lower()
|
2026-04-30 10:24:20 +07:00
|
|
|
slave = _Ssd168xEpaperSlave(
|
|
|
|
|
component_id=comp_id, width=width, height=height,
|
2026-06-04 12:47:17 +07:00
|
|
|
on_flush=_flush_factory(), is_bwr=_is_bwr,
|
2026-04-30 10:24:20 +07:00
|
|
|
)
|
feat: Add support for SSD168x ePaper panels
- Introduced EPaperPanels.ts to define configurations for various ePaper panels including dimensions, refresh rates, and controller details.
- Implemented SSD168xDecoder.ts to handle the decoding of SPI commands for the SSD168x family of ePaper displays.
- Created EPaperPart.ts to manage the simulation of ePaper panels, integrating with the existing simulator architecture and handling events.
- Added example sketches for 2.13", 2.9", 4.2", and 7.5" ePaper displays, demonstrating basic functionality and text rendering.
- Ensured compatibility with AVR, RP2040, and ESP32 platforms, with appropriate pin configurations for each.
2026-04-30 08:23:00 +07:00
|
|
|
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,
|
2026-04-30 10:24:20 +07:00
|
|
|
'controller_family': ctl_family,
|
feat: Add support for SSD168x ePaper panels
- Introduced EPaperPanels.ts to define configurations for various ePaper panels including dimensions, refresh rates, and controller details.
- Implemented SSD168xDecoder.ts to handle the decoding of SPI commands for the SSD168x family of ePaper displays.
- Created EPaperPart.ts to manage the simulation of ePaper panels, integrating with the existing simulator architecture and handling events.
- Added example sketches for 2.13", 2.9", 4.2", and 7.5" ePaper displays, demonstrating basic functionality and text rendering.
- Ensured compatibility with AVR, RP2040, and ESP32 platforms, with appropriate pin configurations for each.
2026-04-30 08:23:00 +07:00
|
|
|
}
|
|
|
|
|
_epaper_slaves[comp_id] = slave
|
|
|
|
|
_epaper_state[comp_id] = state
|
2026-06-04 09:22:29 +07:00
|
|
|
_sync_cs_events()
|
feat: Add support for SSD168x ePaper panels
- Introduced EPaperPanels.ts to define configurations for various ePaper panels including dimensions, refresh rates, and controller details.
- Implemented SSD168xDecoder.ts to handle the decoding of SPI commands for the SSD168x family of ePaper displays.
- Created EPaperPart.ts to manage the simulation of ePaper panels, integrating with the existing simulator architecture and handling events.
- Added example sketches for 2.13", 2.9", 4.2", and 7.5" ePaper displays, demonstrating basic functionality and text rendering.
- Ensured compatibility with AVR, RP2040, and ESP32 platforms, with appropriate pin configurations for each.
2026-04-30 08:23:00 +07:00
|
|
|
sensor_data['epaper_component_id'] = comp_id
|
2026-04-30 10:24:20 +07:00
|
|
|
_log(f"[epaper:{ctl_family}] registered '{comp_id}' "
|
feat: Add support for SSD168x ePaper panels
- Introduced EPaperPanels.ts to define configurations for various ePaper panels including dimensions, refresh rates, and controller details.
- Implemented SSD168xDecoder.ts to handle the decoding of SPI commands for the SSD168x family of ePaper displays.
- Created EPaperPart.ts to manage the simulation of ePaper panels, integrating with the existing simulator architecture and handling events.
- Added example sketches for 2.13", 2.9", 4.2", and 7.5" ePaper displays, demonstrating basic functionality and text rendering.
- Ensured compatibility with AVR, RP2040, and ESP32 platforms, with appropriate pin configurations for each.
2026-04-30 08:23:00 +07:00
|
|
|
f"({width}x{height}) "
|
|
|
|
|
f"DC={state['dc_pin']} CS={state['cs_pin']} "
|
|
|
|
|
f"RST={state['rst_pin']} BUSY={state['busy_pin']}")
|
2026-04-08 10:02:43 +07:00
|
|
|
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
|
2026-04-29 05:24:39 +07:00
|
|
|
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)
|
2026-06-04 09:22:29 +07:00
|
|
|
_sync_cs_events()
|
2026-04-29 05:24:39 +07:00
|
|
|
_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}")
|
2026-04-08 10:02:43 +07:00
|
|
|
_sensors[gpio] = sensor_data
|
2026-03-23 04:03:17 +07:00
|
|
|
_sensors_ready.set()
|
2026-04-08 10:02:54 +07:00
|
|
|
_log(f'_i2c_slaves registered: {list(_i2c_slaves.keys())}')
|
2026-03-23 04:03:17 +07:00
|
|
|
|
2026-03-15 08:55:13 +07:00
|
|
|
_emit({'type': 'system', 'event': 'booted'})
|
2026-06-04 09:22:29 +07:00
|
|
|
# Now that the initial components are registered, tell QEMU whether to
|
|
|
|
|
# forward SPI CS toggles (only ePaper/custom-chip need them).
|
|
|
|
|
_sync_cs_events()
|
2026-03-15 08:55:13 +07:00
|
|
|
_log(f'QEMU started: machine={machine} firmware={firmware_path}')
|
2026-04-05 10:49:25 +07:00
|
|
|
_log(f'QEMU args: {[a.decode() for a in args_list]}')
|
2026-03-15 08:55:13 +07:00
|
|
|
|
2026-04-29 05:24:39 +07:00
|
|
|
# ── 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()
|
|
|
|
|
|
2026-03-15 08:55:13 +07:00
|
|
|
# ── 7. LEDC polling thread (100 ms interval) ──────────────────────────────
|
|
|
|
|
|
|
|
|
|
def _ledc_poll_thread() -> None:
|
2026-03-23 20:37:06 +07:00
|
|
|
# Track last-emitted duty to avoid flooding identical updates
|
|
|
|
|
_last_duty = [0.0] * 16
|
2026-03-15 08:55:13 +07:00
|
|
|
while not _stopped.wait(0.1):
|
|
|
|
|
try:
|
2026-03-23 20:37:06 +07:00
|
|
|
ptr = lib.qemu_picsimlab_get_internals(6) # LEDC_CHANNEL_DUTY
|
|
|
|
|
if ptr is None or ptr == 0:
|
2026-03-15 08:55:13 +07:00
|
|
|
continue
|
2026-03-23 20:37:06 +07:00
|
|
|
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()
|
2026-03-15 08:55:13 +07:00
|
|
|
for ch in range(16):
|
2026-03-23 20:37:06 +07:00
|
|
|
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})
|
2026-03-25 00:38:08 +07:00
|
|
|
except Exception:
|
|
|
|
|
pass
|
2026-03-15 08:55:13 +07:00
|
|
|
|
|
|
|
|
threading.Thread(target=_ledc_poll_thread, daemon=True, name='ledc-poll').start()
|
|
|
|
|
|
2026-03-17 20:08:06 +07:00
|
|
|
# ── 8. Command loop (main thread reads original stdin pipe) ───────────────
|
2026-03-15 08:55:13 +07:00
|
|
|
|
2026-03-17 20:08:06 +07:00
|
|
|
for raw_line in os.fdopen(_orig_stdin_fd, 'r'):
|
2026-03-15 08:55:13 +07:00
|
|
|
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)
|
2026-03-24 23:54:44 +07:00
|
|
|
ch = int(cmd['channel'])
|
|
|
|
|
clamped = max(0, min(4095, raw_v))
|
|
|
|
|
lib.qemu_picsimlab_set_apin(ch, clamped)
|
2026-03-15 08:55:13 +07:00
|
|
|
|
|
|
|
|
elif c == 'set_adc_raw':
|
|
|
|
|
lib.qemu_picsimlab_set_apin(int(cmd['channel']),
|
|
|
|
|
max(0, min(4095, int(cmd['raw']))))
|
|
|
|
|
|
2026-04-21 12:17:30 +07:00
|
|
|
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)
|
|
|
|
|
|
2026-03-15 08:55:13 +07:00
|
|
|
elif c == 'uart_send':
|
|
|
|
|
data = base64.b64decode(cmd['data'])
|
|
|
|
|
buf = (ctypes.c_uint8 * len(data))(*data)
|
2026-04-13 09:55:11 +07:00
|
|
|
# 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()
|
2026-03-15 08:55:13 +07:00
|
|
|
|
|
|
|
|
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
|
|
|
|
|
|
2026-03-23 04:03:17 +07:00
|
|
|
elif c == 'sensor_attach':
|
2026-03-23 01:17:44 +07:00
|
|
|
gpio = int(cmd['pin'])
|
2026-03-23 04:03:17 +07:00
|
|
|
sensor_type = cmd.get('sensor_type', '')
|
|
|
|
|
with _sensors_lock:
|
2026-04-08 01:43:09 +07:00
|
|
|
sensor_data: dict = {
|
2026-03-23 04:03:17 +07:00
|
|
|
'type': sensor_type,
|
|
|
|
|
**{k: v for k, v in cmd.items()
|
|
|
|
|
if k not in ('cmd', 'pin', 'sensor_type')},
|
2026-03-23 01:17:44 +07:00
|
|
|
'saw_low': False,
|
|
|
|
|
'responding': False,
|
|
|
|
|
}
|
2026-04-08 01:43:09 +07:00
|
|
|
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'):
|
2026-04-08 10:02:54 +07:00
|
|
|
i2c_addr = int(cmd.get('addr', 0x68))
|
2026-04-08 01:43:09 +07:00
|
|
|
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
|
2026-04-30 10:24:20 +07:00
|
|
|
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.
|
2026-04-30 10:24:20 +07:00
|
|
|
_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,
|
2026-04-30 10:24:20 +07:00
|
|
|
})
|
|
|
|
|
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(),
|
|
|
|
|
)
|
2026-04-30 10:24:20 +07:00
|
|
|
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()
|
2026-04-30 10:24:20 +07:00
|
|
|
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,
|
2026-04-30 10:24:20 +07:00
|
|
|
)
|
|
|
|
|
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
|
2026-06-04 09:22:29 +07:00
|
|
|
_sync_cs_events()
|
2026-04-30 10:24:20 +07:00
|
|
|
sensor_data['epaper_component_id'] = comp_id
|
2026-04-08 01:43:09 +07:00
|
|
|
_sensors[gpio] = sensor_data
|
2026-03-23 04:03:17 +07:00
|
|
|
_log(f'Sensor {sensor_type} attached on GPIO {gpio}')
|
2026-03-23 01:17:44 +07:00
|
|
|
|
2026-03-23 04:03:17 +07:00
|
|
|
elif c == 'sensor_update':
|
2026-03-23 01:17:44 +07:00
|
|
|
gpio = int(cmd['pin'])
|
2026-03-23 04:03:17 +07:00
|
|
|
with _sensors_lock:
|
|
|
|
|
sensor = _sensors.get(gpio)
|
2026-03-23 01:17:44 +07:00
|
|
|
if sensor:
|
2026-03-23 04:03:17 +07:00
|
|
|
for k, v in cmd.items():
|
|
|
|
|
if k not in ('cmd', 'pin'):
|
|
|
|
|
sensor[k] = v
|
2026-04-08 01:43:09 +07:00
|
|
|
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))
|
2026-03-23 01:17:44 +07:00
|
|
|
|
2026-03-23 04:03:17 +07:00
|
|
|
elif c == 'sensor_detach':
|
2026-03-23 01:17:44 +07:00
|
|
|
gpio = int(cmd['pin'])
|
2026-03-23 04:03:17 +07:00
|
|
|
with _sensors_lock:
|
2026-04-08 01:43:09 +07:00
|
|
|
sensor = _sensors.pop(gpio, None)
|
|
|
|
|
if sensor and 'i2c_addr' in sensor:
|
|
|
|
|
_i2c_slaves.pop(sensor['i2c_addr'], None)
|
2026-04-30 10:24:20 +07:00
|
|
|
if sensor and 'epaper_component_id' in sensor:
|
|
|
|
|
cid = sensor['epaper_component_id']
|
|
|
|
|
_epaper_slaves.pop(cid, None)
|
|
|
|
|
_epaper_state.pop(cid, None)
|
2026-03-23 04:03:17 +07:00
|
|
|
_log(f'Sensor detached from GPIO {gpio}')
|
2026-03-23 01:17:44 +07:00
|
|
|
|
2026-05-13 02:55:15 +07:00
|
|
|
# ── 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)
|
2026-05-13 02:55:15 +07:00
|
|
|
_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''
|
2026-05-03 08:05:37 +07:00
|
|
|
# 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
|
|
|
|
|
|
2026-03-15 08:55:13 +07:00
|
|
|
elif c == 'stop':
|
|
|
|
|
_stopped.set()
|
2026-04-13 09:55:11 +07:00
|
|
|
# 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
|
2026-03-15 08:55:13 +07:00
|
|
|
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()
|