From 3d0af3737da2e388ab5b4741100a5a7ba2c6162c Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Sun, 5 Jul 2026 17:32:04 -0400 Subject: [PATCH] Offload image prepare to executor, keep BLE transfer on the loop _async_send_image awaited device.upload_image(), which runs the library's _prepare_image synchronously on the event loop: rotate + fit + dither + encode + zlib on a full frame. Every upload_image/drawcustom call blocked the HA event loop for the whole CPU stage before any BLE I/O started. py-opendisplay 7.9.0 exposes the split deliberately: prepare_image() is the heavy CPU work and needs only the device config (no BLE), and device.upload_prepared_image() does the BLE transfer. Run prepare_image() in the executor via async_add_executor_job and pass the same dither/tone/fit/rotate options; then upload_prepared_image() on the loop. compress is gated on display.supports_zip to mirror upload_image() (which passes `compress and supports_compression` into _prepare_image); when the panel has no zip support prepare_image() returns compressed_data=None and upload_prepared_image() falls back to the uncompressed protocol, unchanged. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_012g2e8mr132vcizx92WsgiR --- custom_components/opendisplay/services.py | 26 ++++++++++++++++++++--- 1 file changed, 23 insertions(+), 3 deletions(-) diff --git a/custom_components/opendisplay/services.py b/custom_components/opendisplay/services.py index 6ca5651..b4d36d9 100644 --- a/custom_components/opendisplay/services.py +++ b/custom_components/opendisplay/services.py @@ -5,6 +5,7 @@ import contextlib from datetime import timedelta from enum import IntEnum +import functools import io import logging import os @@ -25,6 +26,7 @@ OpenDisplayError, RefreshMode, Rotation, + prepare_image, ) from PIL import Image as PILImage, ImageOps import voluptuous as vol @@ -344,15 +346,33 @@ async def _async_send_image( rotate: Rotation = Rotation.ROTATE_0, ) -> None: """Upload a PIL image to the device.""" - async def _upload(device: OpenDisplayDevice) -> None: - await device.upload_image( + # Split the upload into its heavy CPU half and its BLE-I/O half. The CPU + # work (rotate + fit + dither + encode + zlib on a full frame) is offloaded + # to an executor thread so it never blocks the event loop; only the BLE + # transfer runs on the loop. This mirrors what device.upload_image() does + # internally, but that call ran _prepare_image synchronously on the loop. + config = entry.runtime_data.device_config + display_cfg = config.displays[0] if config and config.displays else None + # Match upload_image(): only ask prepare_image() to build compressed data + # when the panel actually supports zip. upload_prepared_image() then falls + # back to the uncompressed protocol when compressed_data is None. + supports_compression = display_cfg.supports_zip if display_cfg else True + prepared = await hass.async_add_executor_job( + functools.partial( + prepare_image, img, - refresh_mode=refresh_mode, + config=config, dither_mode=dither_mode, + compress=supports_compression, tone=tone, fit=fit, rotate=rotate, ) + ) + + async def _upload(device: OpenDisplayDevice) -> None: + await device.upload_prepared_image(prepared, refresh_mode=refresh_mode) + await _async_connect_and_run(hass, entry, _upload) jpeg = await hass.async_add_executor_job(_pil_to_jpeg, img) async_dispatcher_send(hass, f"{SIGNAL_IMAGE_UPDATED}_{entry.unique_id}", jpeg)