fix(spi-batch): add 50ms safety flush so frames keep arriving

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) <noreply@anthropic.com>
This commit is contained in:
David Montero Crespo 2026-05-03 00:31:16 -03:00
parent d4d015c25d
commit 977308ef80
1 changed files with 36 additions and 9 deletions

View File

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