From 8c58d2a1a7da97c3fee0825af06822c8435672ae Mon Sep 17 00:00:00 2001 From: David Montero Date: Fri, 22 May 2026 23:32:33 +0200 Subject: [PATCH] fix(epaper/esp32): preserve PartSimulationRegistry sensors in setSensors + correct BUSY polarity per controller family MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- backend/app/services/esp32_worker.py | 49 ++++++++++++++++++++------ frontend/src/simulation/Esp32Bridge.ts | 19 +++++++++- 2 files changed, 57 insertions(+), 11 deletions(-) diff --git a/backend/app/services/esp32_worker.py b/backend/app/services/esp32_worker.py index 132b5a58..0b39edc2 100644 --- a/backend/app/services/esp32_worker.py +++ b/backend/app/services/esp32_worker.py @@ -1202,21 +1202,53 @@ def main() -> None: # noqa: C901 (complexity OK for inline worker) sensor_data['i2c_addr'] = i2c_addr sensor_data['slave'] = slave elif sensor_type == 'epaper-ssd168x': - # ePaper SSD168x panel: backend decodes SPI traffic and emits - # `epaper_update` events with the latched framebuffer. + # 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: + # + # SSD168x family (1.54 / 2.13 / 2.9 / 4.2 / 7.5"): + # `_busy_level = HIGH` → BUSY=HIGH means "busy", + # BUSY=LOW means "ready". + # + # UC8159c family (5.65" 7-colour ACeP GDEP0565D90): + # `_busy_level = LOW` → BUSY=LOW means "busy", + # BUSY=HIGH means "ready". + # + # Pick the IDLE level per family and (a) seed the pin to IDLE + # at registration so the firmware's first `_waitBusy()` — + # which runs inside `_PowerOn()` / `_InitDisplay()` BEFORE any + # frame is sent — sees "ready" and proceeds, and (b) use that + # polarity when pulsing on frame flush below. comp_id = str(s.get('component_id', f'epaper-{gpio}')) width = int(s.get('width', 200)) height = int(s.get('height', 200)) refresh_ms = int(s.get('refresh_ms', 50)) busy_pin = int(s.get('busy_pin', -1)) + # 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')) + busy_idle_level = 1 if ctl_family_early == 'uc8159c' else 0 + busy_busy_level = 1 - busy_idle_level + if busy_pin is not None and busy_pin >= 0: + try: + lib.qemu_picsimlab_set_pin(busy_pin + 1, busy_idle_level) + except Exception: + pass def _flush_factory(_comp_id=comp_id, _w=width, _h=height, _refresh=refresh_ms, _busy=busy_pin, + _busy_busy=busy_busy_level, + _busy_idle=busy_idle_level, _lib=lib): """Build an on_flush callback bound to this slave's - component_id so the WS event can route to the right panel.""" + component_id so the WS event can route to the right panel. + Pulses BUSY to its "busy" level for refresh_ms, then back + to "ready" — polarity per controller family (see above).""" def _on_flush(frame): try: frame_b64 = base64.b64encode(frame.pixels).decode('ascii') @@ -1232,20 +1264,17 @@ def main() -> None: # noqa: C901 (complexity OK for inline worker) 'refresh_ms': _refresh, }, }) - # Drive BUSY high on the wired GPIO so firmware - # busy-wait loops see realistic timing. Falls LOW - # again after refresh_ms via a short timer. if _busy is not None and _busy >= 0: try: - _lib.qemu_picsimlab_set_pin(_busy + 1, 1) + _lib.qemu_picsimlab_set_pin(_busy + 1, _busy_busy) - def _busy_low(_b=_busy): + def _busy_idle_cb(_b=_busy, _lvl=_busy_idle): try: - _lib.qemu_picsimlab_set_pin(_b + 1, 0) + _lib.qemu_picsimlab_set_pin(_b + 1, _lvl) except Exception: pass - threading.Timer(_refresh / 1000.0, _busy_low).start() + threading.Timer(_refresh / 1000.0, _busy_idle_cb).start() except Exception: pass return _on_flush diff --git a/frontend/src/simulation/Esp32Bridge.ts b/frontend/src/simulation/Esp32Bridge.ts index c1ce7c30..da9f34e1 100644 --- a/frontend/src/simulation/Esp32Bridge.ts +++ b/frontend/src/simulation/Esp32Bridge.ts @@ -541,9 +541,26 @@ export class Esp32Bridge { * This ensures sensors are ready in the QEMU worker BEFORE the firmware * begins executing, preventing race conditions where pulseIn() times out * because the sensor handler hasn't been registered yet. + * + * MERGE semantics (upsert by `pin`): pre-existing entries with a different + * pin are kept, entries with the same pin are replaced. An earlier + * implementation did `this._pendingSensors = sensors` (full replace) which + * blew away anything PartSimulationRegistry handlers had already + * registered via `sendSensorAttach` (e.g. the ePaper SPI slaves on + * virtual pins) the moment `startBoard` later called `setSensors` with + * only the wire-resolved sensors it knew about (DHT22, HC-SR04, …). + * That dropped the ePaper slave registration on every Run click, and the + * 5.65" UC8159c panel sat unresponsive while its firmware busy-waited. */ setSensors(sensors: Array>): void { - this._pendingSensors = sensors; + const merged = this._pendingSensors.slice(); + for (const s of sensors) { + const pin = s['pin']; + const idx = merged.findIndex((e) => e['pin'] === pin); + if (idx >= 0) merged[idx] = s; + else merged.push(s); + } + this._pendingSensors = merged; } /** Returns true if a firmware has been loaded and is ready to send. */