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.
This commit is contained in:
David Montero 2026-05-22 23:32:33 +02:00
parent 30d0da430b
commit 8c58d2a1a7
2 changed files with 57 additions and 11 deletions

View File

@ -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

View File

@ -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<Record<string, unknown>>): 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. */