diff --git a/backend/app/services/esp32_worker.py b/backend/app/services/esp32_worker.py index be13000b..b6f4e67f 100644 --- a/backend/app/services/esp32_worker.py +++ b/backend/app/services/esp32_worker.py @@ -140,6 +140,11 @@ _RMT_EVENT = ctypes.CFUNCTYPE(None, ctypes.c_uint8, ctypes.c_uint32, # burn-in window confirms parity. Requires libqemu-{xtensa,riscv32} # 1.1.0+; older binaries omit the field and the placeholder runs. _GPIO_MATRIX_CB = ctypes.CFUNCTYPE(None, ctypes.c_int, ctypes.c_int) +# Batched write-only SPI: (id, const uint8_t *mosi, int len). Collapses the +# per-byte picsimlab_spi_event ctypes crossing for TFT-style bulk writes. +# Trailing field — older libqemu builds (without the C-side batch path) simply +# never call it and fall back to per-byte picsimlab_spi_event. +_SPI_BATCH = ctypes.CFUNCTYPE(None, ctypes.c_uint8, ctypes.POINTER(ctypes.c_uint8), ctypes.c_int) class _CallbacksT(ctypes.Structure): @@ -152,6 +157,7 @@ class _CallbacksT(ctypes.Structure): ('pinmap', ctypes.c_void_p), ('picsimlab_rmt_event', _RMT_EVENT), ('picsimlab_gpio_matrix_cb', _GPIO_MATRIX_CB), + ('picsimlab_spi_event_batch', _SPI_BATCH), ] @@ -690,6 +696,14 @@ def main() -> None: # noqa: C901 (complexity OK for inline worker) return gpio = int(_PINMAP[slot]) if 1 <= slot <= _GPIO_COUNT else slot _pin_state[gpio] = value & 1 + # Flush pending SPI bytes BEFORE announcing this pin change so the + # frontend processes them under the pin state (e.g. the ILI9341 DC line) + # that was in effect when they were sent. With CS events gated off for + # pure-display sims, this — plus the buffer cap / timer — is what keeps + # the SPI byte stream correctly ordered against the DC gpio_change on the + # single WS channel (the per-CS flush used to do it). + with _spi_buf_lock: + _flush_spi_batch_locked() _emit({'type': 'gpio_change', 'pin': gpio, 'state': value}) # Dispatch to any custom-chip runtime that has a vx_pin_watch on this @@ -1021,6 +1035,18 @@ def main() -> None: # noqa: C901 (complexity OK for inline worker) _emit({'type': 'spi_batch', 'b64': b64}) _spi_byte_buf.clear() + def _sync_cs_events(): + """Tell QEMU whether to forward SPI chip-select toggles to us. Only + ePaper / custom-chip SPI slaves consume CS; pure-display sims (DC pin + + batched data) do not, so turning CS off there removes ~9k C->Python + crossings/sec for a TFT redraw. No-op on older libqemu builds without + the symbol (CS events stay on, as before).""" + try: + lib.qemu_picsimlab_enable_spi_cs_events( + 1 if (_epaper_state or _chip_spi_runtimes) else 0) + except Exception: + pass + def _spi_flush_timer_loop(): """Background thread: flushes any pending SPI bytes every _SPI_BATCH_PERIOD_S so partial transactions reach the frontend @@ -1102,6 +1128,52 @@ def main() -> None: # noqa: C901 (complexity OK for inline worker) _emit({'type': 'spi_event', 'bus': bus_id, 'event': event, 'response': resp}) return resp + def _on_spi_batch(bus_id: int, mosi_ptr, length: int) -> None: + """Batched write-only SPI transfer — the whole MOSI buffer arrives in a + single call instead of one picsimlab_spi_event per byte. libqemu only + invokes this for rx==0 (MISO-ignored) transfers on the host SPI shim, so + nothing is returned. Mirrors the per-byte _on_spi_event side effects in + bulk: custom-chip runtimes first, then ePaper, then the spi_batch buffer. + This is the path that removes ~150k C->Python crossings/frame for TFTs.""" + if length <= 0 or _stopped.is_set(): + return + try: + data = ctypes.string_at(mosi_ptr, length) + except Exception: + return + # Custom-chip SPI runtimes get first dibs (replay per byte; the chip's + # MISO return is discarded because this transfer is write-only). + if _chip_spi_runtimes: + rt = _chip_spi_runtimes[0] + for mb in data: + try: + rt.spi_transfer_byte(mb) + except Exception as e: + _log(f'[custom-chip spi_batch] error: {e!r}') + return + # ePaper SSD168x: feed each byte under the current DC to every active + # slave (DC is constant for a write-only transaction). + if _epaper_state: + any_active = False + for st in _epaper_state.values(): + if st['cs_low']: + any_active = True + slave = st['slave'] + dc = st['dc_high'] + for mb in data: + try: + slave.feed(mb, dc) + except Exception as e: + _log(f'[epaper spi_batch] error: {e!r}') + if any_active: + return + # Fast path: bulk-append to the spi_batch buffer (same buffer/flush the + # per-byte path uses, so frontend ordering is unchanged). + with _spi_buf_lock: + _spi_byte_buf.extend(data) + if len(_spi_byte_buf) >= _SPI_BATCH_FLUSH_AT: + _flush_spi_batch_locked() + # Keep callback struct alive (prevent GC from freeing ctypes closures) _cbs_ref = _CallbacksT( picsimlab_write_pin = _WRITE_PIN(_on_pin_change), @@ -1112,6 +1184,7 @@ def main() -> None: # noqa: C901 (complexity OK for inline worker) pinmap = ctypes.cast(_PINMAP, ctypes.c_void_p).value, picsimlab_rmt_event = _RMT_EVENT(_on_rmt_event), picsimlab_gpio_matrix_cb = _GPIO_MATRIX_CB(_on_gpio_matrix), + picsimlab_spi_event_batch = _SPI_BATCH(_on_spi_batch), ) lib.qemu_picsimlab_register_callbacks(ctypes.byref(_cbs_ref)) # Log whether the new symbol is present in this libqemu build. @@ -1316,6 +1389,7 @@ def main() -> None: # noqa: C901 (complexity OK for inline worker) } _epaper_slaves[comp_id] = slave _epaper_state[comp_id] = state + _sync_cs_events() sensor_data['epaper_component_id'] = comp_id _log(f"[epaper:{ctl_family}] registered '{comp_id}' " f"({width}x{height}) " @@ -1423,6 +1497,7 @@ def main() -> None: # noqa: C901 (complexity OK for inline worker) _log("[custom-chip] UART chip registered on UART0") if runtime.spi_config is not None: _chip_spi_runtimes.append(runtime) + _sync_cs_events() _log("[custom-chip] SPI chip registered") if runtime.has_pin_watches(): _chip_pin_watch_runtimes.append(runtime) @@ -1439,6 +1514,9 @@ def main() -> None: # noqa: C901 (complexity OK for inline worker) _log(f'_i2c_slaves registered: {list(_i2c_slaves.keys())}') _emit({'type': 'system', 'event': 'booted'}) + # Now that the initial components are registered, tell QEMU whether to + # forward SPI CS toggles (only ePaper/custom-chip need them). + _sync_cs_events() _log(f'QEMU started: machine={machine} firmware={firmware_path}') _log(f'QEMU args: {[a.decode() for a in args_list]}') @@ -1706,6 +1784,7 @@ def main() -> None: # noqa: C901 (complexity OK for inline worker) } _epaper_slaves[comp_id] = slave _epaper_state[comp_id] = state + _sync_cs_events() sensor_data['epaper_component_id'] = comp_id _sensors[gpio] = sensor_data _log(f'Sensor {sensor_type} attached on GPIO {gpio}')