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:
parent
30d0da430b
commit
8c58d2a1a7
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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. */
|
||||
|
|
|
|||
Loading…
Reference in New Issue