From 977308ef80b6e1388a34c3a27a41e79020fd2e28 Mon Sep 17 00:00:00 2001 From: David Montero Crespo Date: Sun, 3 May 2026 00:31:16 -0300 Subject: [PATCH] fix(spi-batch): add 50ms safety flush so frames keep arriving MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit User reported only 2 frames rendering after the SPI batching change. Diagnosis: the previous flush triggers (CS-line HIGH, buffer >=4096) were both event-driven. Adafruit_ILI9341's ESP32 backend manages CS via digitalWrite — i.e. through the GPIO peripheral, NOT the SPI peripheral's hardware CS pin. So the CS-line-HIGH event from picsimlab_spi_event NEVER fires for this driver. The buffer only flushes when it hits 4096 bytes. Frame 1: 38 400 bytes from drawRGBBitmap → 9 flushes at 4096-byte boundaries → last 192 bytes stay in the buffer. Status bar adds some bytes too → maybe one more flush. Frame 2: same. But by frame 3 the firmware is running ahead of the flush rhythm and somehow the buffer pattern wedges in a state where no flush completes (likely a partial buffer that sits between transactions while the firmware briefly waits on the next fb_get). Hard to reproduce deterministically — but the symptom matches. Fix: add a 50 ms periodic flush thread. Independent of any event, it acquires the lock and flushes whatever's pending. Bounds the worst-case latency at 50 ms (= 20 fps ceiling, more than enough for the emulator). Triple-trigger now: 1. CS HIGH (fast path for hardware-CS drivers) 2. Buffer >= 4096 (safety for big transactions) 3. 50 ms timer (catches GPIO-CS drivers, prevents stalls) Co-Authored-By: Claude Opus 4.7 (1M context) --- backend/app/services/esp32_worker.py | 45 ++++++++++++++++++++++------ 1 file changed, 36 insertions(+), 9 deletions(-) diff --git a/backend/app/services/esp32_worker.py b/backend/app/services/esp32_worker.py index caed77c0..918f7bec 100644 --- a/backend/app/services/esp32_worker.py +++ b/backend/app/services/esp32_worker.py @@ -868,15 +868,25 @@ def main() -> None: # noqa: C901 (complexity OK for inline worker) # SPI byte batching — emitting one WS message per byte saturates the # uvicorn → frontend pipe and caps tft.drawRGBBitmap at < 1 fps even # for tiny previews. Buffer the MOSI bytes here and flush as a single - # base64-encoded `spi_batch` message when CS goes HIGH (transaction - # ended) or the buffer crosses a soft cap. The MISO response 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. ~9600 events/frame → ~3 - # batched messages/frame, ~50× faster TFT throughput in the emulator. - _spi_byte_buf = bytearray() - _spi_buf_lock = threading.Lock() - _SPI_BATCH_FLUSH_AT = 4096 # flush early if a single transaction is huge + # 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 def _flush_spi_batch_locked(): if _spi_byte_buf and not _stopped.is_set(): @@ -884,6 +894,23 @@ def main() -> None: # noqa: C901 (complexity OK for inline worker) _emit({'type': 'spi_batch', 'b64': b64}) _spi_byte_buf.clear() + def _spi_flush_timer_loop(): + """Background thread: flushes any pending SPI bytes every + _SPI_BATCH_PERIOD_S so partial transactions reach the frontend + even when the firmware drives CS via GPIO and we never see a + SPI peripheral CS-high event.""" + while not _stopped.is_set(): + _stopped.wait(_SPI_BATCH_PERIOD_S) + if _stopped.is_set(): + break + with _spi_buf_lock: + _flush_spi_batch_locked() + + threading.Thread( + target=_spi_flush_timer_loop, daemon=True, + name='esp32-spi-batch-flush', + ).start() + def _on_spi_event(bus_id: int, event: int) -> int: """Synchronous — must return immediately; called from QEMU thread.