From e72b609c2d0ffbe925033db5aeaf67863ce3608c Mon Sep 17 00:00:00 2001 From: Michael Bisbjerg Date: Thu, 7 May 2026 20:50:39 +0200 Subject: [PATCH 1/5] perf: keep drawcustom images as PIL until upload --- .../opendisplay/ble/image_upload.py | 13 +++---- .../opendisplay/imagegen/core.py | 17 +++------ custom_components/opendisplay/services.py | 24 +++++++++---- custom_components/opendisplay/upload.py | 36 +++++++++++-------- tests/drawcustom/conftest.py | 6 +++- 5 files changed, 52 insertions(+), 44 deletions(-) diff --git a/custom_components/opendisplay/ble/image_upload.py b/custom_components/opendisplay/ble/image_upload.py index 5678168..a4d15f1 100644 --- a/custom_components/opendisplay/ble/image_upload.py +++ b/custom_components/opendisplay/ble/image_upload.py @@ -1,6 +1,5 @@ """Shared BLE image upload protocol (compatible with both ATC and OpenDisplay firmware).""" import asyncio -import io import struct import zlib import logging @@ -569,7 +568,7 @@ async def _handle_response(self, data: bytes) -> bool: async def upload_image_block_based( self, - image_data: bytes, + image: Image.Image, metadata: BLEDeviceMetadata, protocol_type: str = "atc", dither: int = 2 @@ -577,7 +576,7 @@ async def upload_image_block_based( """Upload image using block-based protocol. Args: - image_data: JPEG image data + image: Rendered image metadata: Device metadata with dimensions and color support protocol_type: Protocol type ("atc" or "open_display") dither: 0=none, 1=ordered, 2=floyd-steinberg @@ -586,8 +585,6 @@ async def upload_image_block_based( tuple: (success, processed_image) - processed_image is the dithered PIL Image """ try: - # Convert JPEG to PIL Image - image = Image.open(io.BytesIO(image_data)) _LOGGER.debug("Before transpose: image size %dx%d", image.width, image.height) # Apply rotation for ATC devices only (OpenDisplay handles rotation firmware-side) @@ -729,7 +726,7 @@ async def _send_next_block_part(self): async def upload_direct_write( self, - image_data: bytes, + image: Image.Image, metadata: BLEDeviceMetadata, compressed: bool = False, dither: int = 2, @@ -738,7 +735,7 @@ async def upload_direct_write( """Upload image using direct write protocol (OpenDisplay only). Args: - image_data: JPEG image data + image: Rendered image metadata: Device metadata with dimensions and color scheme compressed: Whether to compress the data dither: 0=none, 1=ordered, 2=floyd-steinberg @@ -754,8 +751,6 @@ async def upload_direct_write( self.refresh_type = refresh_type try: - # Convert JPEG to PIL Image - image = Image.open(io.BytesIO(image_data)) _LOGGER.debug("Direct write: image size %dx%d", image.width, image.height) processed_image = process_image_for_device( diff --git a/custom_components/opendisplay/imagegen/core.py b/custom_components/opendisplay/imagegen/core.py index 0898e2e..42b90af 100644 --- a/custom_components/opendisplay/imagegen/core.py +++ b/custom_components/opendisplay/imagegen/core.py @@ -1,6 +1,5 @@ from __future__ import annotations -import io import logging from typing import Optional, Dict, Any @@ -312,11 +311,11 @@ async def generate_custom_image( width: int, height: int, accent_color: str, - ) -> bytes: + ) -> Image.Image: """Generate a custom image based on service data. Main entry point for image generation. Creates an image with the - specified elements and returns the JPEG data. + specified elements and returns the rendered image. Args: entity_id: The entity ID to generate the image for @@ -327,7 +326,7 @@ async def generate_custom_image( accent_color: Accent color name Returns: - bytes: JPEG image data + Image.Image: Rendered RGB image Raises: HomeAssistantError: If image generation fails @@ -403,12 +402,4 @@ async def generate_custom_image( if rotate: img = img.rotate(rotate, expand=True) - # Convert to RGB for JPEG - rgb_image = img.convert('RGB') - - # Create BytesIO object for the JPEG data - img_byte_arr = io.BytesIO() - rgb_image.save(img_byte_arr, format='JPEG', quality="maximum") - image_data = img_byte_arr.getvalue() - - return image_data + return img.convert('RGB') diff --git a/custom_components/opendisplay/services.py b/custom_components/opendisplay/services.py index 8225b1b..d3496ce 100644 --- a/custom_components/opendisplay/services.py +++ b/custom_components/opendisplay/services.py @@ -13,7 +13,14 @@ from .const import DOMAIN, SIGNAL_TAG_IMAGE_UPDATE from .imagegen import ImageGen from .tag_types import get_tag_types_manager -from .upload import create_upload_queues, DITHER_DEFAULT, upload_to_ble_direct, upload_to_ble_block, upload_to_hub +from .upload import ( + create_upload_queues, + DITHER_DEFAULT, + image_to_jpeg_bytes, + upload_to_ble_direct, + upload_to_ble_block, + upload_to_hub, +) from .util import is_ble_entry, get_hub_from_hass, rgb_to_rgb332, int_to_hex_string, \ is_ble_device, get_mac_from_entity_id @@ -250,7 +257,7 @@ async def drawcustom_service(service: ServiceCall, entity_id: str) -> None: width, height, accent_color = await generator.get_tag_dimensions( entity_id, is_ble=is_ble ) - image_data = await generator.generate_custom_image( + image = await generator.generate_custom_image( entity_id=entity_id, service_data=service.data, error_collector=device_errors, @@ -278,10 +285,13 @@ async def drawcustom_service(service: ServiceCall, entity_id: str) -> None: if service.data.get("dry-run", False): _LOGGER.info("Dry run completed for %s", entity_id) tag_mac = get_mac_from_entity_id(entity_id) + jpeg_bytes = await hass.async_add_executor_job( + image_to_jpeg_bytes, image, "maximum" + ) async_dispatcher_send( hass, f"{SIGNAL_TAG_IMAGE_UPDATE}_{tag_mac}", - image_data + jpeg_bytes ) return @@ -312,16 +322,16 @@ async def drawcustom_service(service: ServiceCall, entity_id: str) -> None: break metadata = BLEDeviceMetadata(device_metadata) - upload_method = metadata.get_best_upload_method(len(image_data)) + upload_method = metadata.get_best_upload_method() if upload_method == "block": - await ble_upload_queue.add_to_queue(upload_to_ble_block, hass, entity_id, image_data, dither) + await ble_upload_queue.add_to_queue(upload_to_ble_block, hass, entity_id, image, dither) else: await ble_upload_queue.add_to_queue( upload_to_ble_direct, hass, entity_id, - image_data, + image, upload_method == "direct_write_compressed", dither, refresh_type @@ -332,7 +342,7 @@ async def drawcustom_service(service: ServiceCall, entity_id: str) -> None: ap_lut_mapping = {0: 1, 1: 3, 2: 2, 3: 0} ap_lut = ap_lut_mapping.get(refresh_type, 1) # Default to 1 (full) if invalid await hub_upload_queue.add_to_queue( - upload_to_hub, hub, entity_id, image_data, dither, + upload_to_hub, hub, entity_id, image, dither, service.data.get("ttl", 60), service.data.get("preload_type", 0), service.data.get("preload_lut", 0), diff --git a/custom_components/opendisplay/upload.py b/custom_components/opendisplay/upload.py index 99725e4..415d153 100644 --- a/custom_components/opendisplay/upload.py +++ b/custom_components/opendisplay/upload.py @@ -30,6 +30,13 @@ INITIAL_BACKOFF = 2 # seconds +def image_to_jpeg_bytes(image: Image.Image, quality: int | str = 95) -> bytes: + """Encode a PIL image as JPEG bytes for AP upload or HA image preview.""" + buffer = BytesIO() + image.convert("RGB").save(buffer, format="JPEG", quality=quality) + return buffer.getvalue() + + class UploadQueueHandler: """Handle queued image uploads to the AP. @@ -212,7 +219,7 @@ async def _execute_upload(self, upload_func, args, kwargs, entity_id): _LOGGER.debug("Upload task for %s finished. %s", entity_id, self) -async def upload_to_hub(hub, entity_id: str, img: bytes, dither: int, ttl: int, +async def upload_to_hub(hub, entity_id: str, img: Image.Image, dither: int, ttl: int, preload_type: int = 0, preload_lut: int = 0, lut: int = 1) -> None: """Upload image to tag through AP. @@ -225,7 +232,7 @@ async def upload_to_hub(hub, entity_id: str, img: bytes, dither: int, ttl: int, Args: hub: Hub instance with connection details entity_id: Entity ID of the target tag - img: JPEG image data as bytes + img: Rendered image to encode and upload dither: Dithering mode (0=none, 1=Floyd-Steinberg, 2=ordered) ttl: Time-to-live in seconds preload_type: Type for image preloading (0=disabled) @@ -243,6 +250,7 @@ async def upload_to_hub(hub, entity_id: str, img: bytes, dither: int, ttl: int, # Convert TTL fom seconds to minutes for the AP ttl_minutes = max(1, ttl // 60) + jpeg_bytes = await hub.hass.async_add_executor_job(image_to_jpeg_bytes, img, "maximum") backoff_delay = INITIAL_BACKOFF # Try up to MAX_RETRIES times to upload the image, retrying on TimeoutError. @@ -256,7 +264,7 @@ async def upload_to_hub(hub, entity_id: str, img: bytes, dither: int, ttl: int, 'dither': str(dither), 'ttl': str(ttl_minutes), 'lut': str(lut), - 'image': ('image.jpg', img, 'image/jpeg'), + 'image': ('image.jpg', jpeg_bytes, 'image/jpeg'), } if preload_type > 0: @@ -314,7 +322,7 @@ async def upload_to_hub(hub, entity_id: str, img: bytes, dither: int, ttl: int, ) from err -async def upload_to_ble_block(hass: HomeAssistant, entity_id: str, img: bytes, dither: int = 2) -> None: +async def upload_to_ble_block(hass: HomeAssistant, entity_id: str, img: Image.Image, dither: int = 2) -> None: """Upload image to BLE tag using block-based protocol. Sends an image to a BLE tag using direct Bluetooth communication. @@ -326,7 +334,7 @@ async def upload_to_ble_block(hass: HomeAssistant, entity_id: str, img: bytes, d Args: hass: Home Assistant instance entity_id: Entity ID of the target tag - img: JPEG image data as bytes + img: Rendered image to prepare and upload dither: Dithering mode (0=none, 1=Burkes, 2=ordered) Raises: @@ -383,9 +391,9 @@ async def upload_to_ble_block(hass: HomeAssistant, entity_id: str, img: bytes, d if protocol_type == "atc" and metadata.rotatebuffer == 1: display_image = processed_image.transpose(Image.Transpose.ROTATE_270) - buffer = BytesIO() - display_image.save(buffer, format="JPEG", quality=95) - jpeg_bytes = buffer.getvalue() + jpeg_bytes = await hass.async_add_executor_job( + image_to_jpeg_bytes, display_image, 95 + ) async_dispatcher_send( hass, f"{SIGNAL_TAG_IMAGE_UPDATE}_{mac}", @@ -408,7 +416,7 @@ async def upload_to_ble_block(hass: HomeAssistant, entity_id: str, img: bytes, d async def upload_to_ble_direct( hass: HomeAssistant, entity_id: str, - img: bytes, + img: Image.Image, compressed: bool = False, dither: int = 2, refresh_type: int = 0, @@ -421,7 +429,7 @@ async def upload_to_ble_direct( Args: hass: Home Assistant instance entity_id: Entity ID of the target tag - img: JPEG image data as bytes + img: Rendered image to prepare and upload compressed: Whether to compress the image data dither: Dithering mode (0=none, 1=Burkes, 2=ordered) refresh_type: Display refresh mode (0=full, 1=fast, 2=partial, 3=partial2) @@ -484,9 +492,9 @@ async def upload_to_ble_direct( ) if processed_image is not None: - buffer = BytesIO() - processed_image.save(buffer, format="JPEG", quality=95) - jpeg_bytes = buffer.getvalue() + jpeg_bytes = await hass.async_add_executor_job( + image_to_jpeg_bytes, processed_image, 95 + ) async_dispatcher_send( hass, f"{SIGNAL_TAG_IMAGE_UPDATE}_{mac}", @@ -509,4 +517,4 @@ def create_upload_queues() -> tuple[UploadQueueHandler, UploadQueueHandler]: """Create BLE and Hub upload queues with appropriate settings.""" ble_queue = UploadQueueHandler(max_concurrent=1, cooldown=0.1) hub_queue = UploadQueueHandler(max_concurrent=1, cooldown=1.0) - return ble_queue, hub_queue \ No newline at end of file + return ble_queue, hub_queue diff --git a/tests/drawcustom/conftest.py b/tests/drawcustom/conftest.py index cdeacc7..6de841c 100644 --- a/tests/drawcustom/conftest.py +++ b/tests/drawcustom/conftest.py @@ -2,6 +2,7 @@ import os import sys import pytest +from io import BytesIO from unittest.mock import AsyncMock, MagicMock, patch from PIL import ImageFont @@ -173,13 +174,16 @@ def save_image(image_bytes): async def generate_test_image(image_gen: ImageGen, service_data, entity_id="opendisplay.test_tag"): """Helper to generate test images with standard dimensions.""" - return await image_gen.generate_custom_image( + image = await image_gen.generate_custom_image( entity_id=entity_id, service_data=service_data, width=296, height=128, accent_color="red" ) + buffer = BytesIO() + image.convert("RGB").save(buffer, format="JPEG", quality="maximum") + return buffer.getvalue() # Setup and cleanup code that runs before and after each test session From c6fabd1f619de2fcf0914eec33a87470ed1fb98b Mon Sep 17 00:00:00 2001 From: Michael Bisbjerg Date: Thu, 7 May 2026 20:55:38 +0200 Subject: [PATCH 2/5] perf: optimize BLE image preparation --- .../opendisplay/ble/image_processing.py | 82 +++-- .../opendisplay/ble/image_upload.py | 345 ++++++++---------- 2 files changed, 202 insertions(+), 225 deletions(-) diff --git a/custom_components/opendisplay/ble/image_processing.py b/custom_components/opendisplay/ble/image_processing.py index 591cb23..a9d6b40 100644 --- a/custom_components/opendisplay/ble/image_processing.py +++ b/custom_components/opendisplay/ble/image_processing.py @@ -3,6 +3,8 @@ from .color_scheme import ColorScheme +QUANTIZE_CHUNK_PIXELS = 262_144 + def perceptual_color_distance(c1_rgb: tuple[int, int, int], c2_rgb: tuple[int, int, int]) -> float: """ @@ -63,6 +65,56 @@ def find_closest_color(pixel_rgb: tuple[int, int, int], palette: list[tuple[int, return closest, closest_idx + +def _palette_array(color_scheme: ColorScheme) -> np.ndarray: + """Return palette RGB values in display encoding order.""" + return np.array(list(color_scheme.palette.colors.values()), dtype=np.uint8) + + +def _has_only_palette_colors(image: Image.Image, color_scheme: ColorScheme) -> bool: + """Return true when all image pixels are exact colors from the display palette.""" + rgb_image = image if image.mode == 'RGB' else image.convert('RGB') + allowed = set(color_scheme.palette.colors.values()) + colors = rgb_image.getcolors(maxcolors=len(allowed)) + return colors is not None and all(rgb in allowed for _, rgb in colors) + + +def _quantize_pixels_to_palette( + pixels: np.ndarray, + palette: np.ndarray, + *, + chunk_pixels: int = QUANTIZE_CHUNK_PIXELS, +) -> np.ndarray: + """Map RGB pixels to their closest display palette color in bounded chunks.""" + flat = pixels.reshape(-1, 3).astype(np.int16, copy=False) + palette_i16 = palette.astype(np.int16) + target_is_color = ( + (np.abs(palette_i16[:, 0] - palette_i16[:, 1]) > 20) + | (np.abs(palette_i16[:, 2] - palette_i16[:, 1]) > 20) + ) + result = np.empty((flat.shape[0], 3), dtype=np.uint8) + + for start in range(0, flat.shape[0], chunk_pixels): + end = min(start + chunk_pixels, flat.shape[0]) + chunk = flat[start:end] + diff = chunk[:, None, :] - palette_i16[None, :, :] + dist = ( + 3.0 * (diff[:, :, 0].astype(np.float32) ** 2) + + 5.47 * (diff[:, :, 1].astype(np.float32) ** 2) + + 1.53 * (diff[:, :, 2].astype(np.float32) ** 2) + ) + source_is_gray = ( + (np.abs(chunk[:, 0] - chunk[:, 1]) < 20) + & (np.abs(chunk[:, 2] - chunk[:, 1]) < 20) + ) + if np.any(source_is_gray) and np.any(target_is_color): + dist[np.ix_(source_is_gray, target_is_color)] = np.inf + + result[start:end] = palette[np.argmin(dist, axis=1)] + + return result.reshape(pixels.shape) + + def apply_direct_mapping(image: Image.Image, color_scheme: ColorScheme) -> Image.Image: """ Apply direct color mapping without dithering. @@ -77,23 +129,14 @@ def apply_direct_mapping(image: Image.Image, color_scheme: ColorScheme) -> Image Returns: Quantized PIL Image """ - # Convert to RGB if needed if image.mode != 'RGB': image = image.convert('RGB') - # Get palette as list of RGB tuples - palette = list(color_scheme.palette.colors.values()) - - pixels = np.array(image) - height, width = pixels.shape[:2] - result = np.zeros_like(pixels) - - for y in range(height): - for x in range(width): - pixel = tuple(int(x) for x in pixels[y, x]) - closest, _ = find_closest_color(pixel, palette) - result[y, x] = closest + if _has_only_palette_colors(image, color_scheme): + return image + pixels = np.asarray(image) + result = _quantize_pixels_to_palette(pixels, _palette_array(color_scheme)) return Image.fromarray(result, 'RGB') def apply_burkes_dithering(image: Image.Image, color_scheme: ColorScheme) -> Image.Image: @@ -180,9 +223,6 @@ def apply_ordered_dithering(image: Image.Image, color_scheme: ColorScheme) -> Im if image.mode != 'RGB': image = image.convert('RGB') - # Get palette as list of RGB tuples - palette = list(color_scheme.palette.colors.values()) - # 4x4 Bayer matrix (normalized to 0-1 range) bayer_4x4 = np.array([ [0, 8, 2, 10], @@ -203,14 +243,8 @@ def apply_ordered_dithering(image: Image.Image, color_scheme: ColorScheme) -> Im for c in range(3): pixels[:, :, c] += (bayer_tiled - 0.5) * scale - # Quantize each pixel to nearest palette color - result = np.zeros_like(pixels, dtype=np.uint8) - for y in range(height): - for x in range(width): - pixel = tuple(int(c) for c in np.clip(pixels[y, x], 0, 255)) - closest, _ = find_closest_color(pixel, palette) - result[y, x] = closest - + pixels = np.clip(pixels, 0, 255).astype(np.int16) + result = _quantize_pixels_to_palette(pixels, _palette_array(color_scheme)) return Image.fromarray(result, 'RGB') diff --git a/custom_components/opendisplay/ble/image_upload.py b/custom_components/opendisplay/ble/image_upload.py index a4d15f1..fce3f46 100644 --- a/custom_components/opendisplay/ble/image_upload.py +++ b/custom_components/opendisplay/ble/image_upload.py @@ -237,6 +237,31 @@ def _detect_color(r: int, g: int, b: int, color_scheme: int) -> str: return 'white' if (r + g + b) / 3 > 128 else 'black' +def _direct_write_color_values(pixel_array: np.ndarray, color_scheme: int) -> np.ndarray: + """Return direct-write palette values using the same thresholds as _detect_color.""" + flat = pixel_array.reshape(-1, 3) + r = flat[:, 0] + g = flat[:, 1] + b = flat[:, 2] + average = (r.astype(np.uint16) + g.astype(np.uint16) + b.astype(np.uint16)) / 3.0 + + values = np.where(average > 128, 1, 0).astype(np.uint8) + values[(r < 128) & (g < 128) & (b < 128)] = 0 + values[(r > 200) & (g > 200) & (b > 200)] = 1 + + if color_scheme in (1, 3, 4): + values[(r > 200) & (g < 100) & (b < 100)] = 3 + + if color_scheme in (2, 3, 4): + values[(r > 200) & (g > 200) & (b < 100)] = 2 + + if color_scheme == 4: + values[(r < 100) & (g > 200) & (b < 100)] = 6 + values[(r < 100) & (g < 100) & (b > 200)] = 5 + + return values + + def _encode_direct_write_1bpp(image: Image.Image) -> bytes: """Encode image as 1BPP for direct write (monochrome). @@ -246,34 +271,13 @@ def _encode_direct_write_1bpp(image: Image.Image) -> bytes: Returns: bytes: 1BPP encoded data (white=1, black=0, NOT inverted) """ - pixel_array = np.array(image.convert("RGB")) - height, width, _ = pixel_array.shape - - byte_data = bytearray() - current_byte = 0 - bit_position = 7 - - for y in range(height): - for x in range(width): - r, g, b = pixel_array[y, x] - # Convert to int to avoid numpy overflow warnings - gray = (int(r) + int(g) + int(b)) / 3.0 - - # White (>128) = 1, Black (<=128) = 0 - if gray > 128: - current_byte |= (1 << bit_position) - - bit_position -= 1 - if bit_position < 0: - byte_data.append(current_byte) - current_byte = 0 - bit_position = 7 - - # Handle remaining bits - if bit_position != 7: - byte_data.append(current_byte) - - return bytes(byte_data) + pixel_array = np.asarray(image.convert("RGB"), dtype=np.uint8).reshape(-1, 3) + gray = ( + pixel_array[:, 0].astype(np.uint16) + + pixel_array[:, 1].astype(np.uint16) + + pixel_array[:, 2].astype(np.uint16) + ) / 3.0 + return np.packbits(gray > 128).tobytes() def _encode_direct_write_bitplanes(image: Image.Image, color_scheme: int) -> bytes: @@ -286,48 +290,11 @@ def _encode_direct_write_bitplanes(image: Image.Image, color_scheme: int) -> byt Returns: bytes: Plane 1 (B/W, NOT inverted) + Plane 2 (R/Y) """ - pixel_array = np.array(image.convert("RGB")) - height, width, _ = pixel_array.shape - - byte_data_plane1 = bytearray() - byte_data_plane2 = bytearray() - current_byte1 = 0 - current_byte2 = 0 - bit_position = 7 - - for y in range(height): - for x in range(width): - r, g, b = pixel_array[y, x] - color = _detect_color(int(r), int(g), int(b), color_scheme) - - # Plane 1: B/W (1=white, 0=black, NOT inverted) - # Plane 2: R/Y (1=red when plane1=1, 1=yellow when plane1=0) - if color == 'white': - current_byte1 |= (1 << bit_position) # plane1 = 1 - # plane2 = 0 - elif color == 'red': - current_byte1 |= (1 << bit_position) # plane1 = 1 - current_byte2 |= (1 << bit_position) # plane2 = 1 - elif color == 'yellow': - # plane1 = 0 - current_byte2 |= (1 << bit_position) # plane2 = 1 - # black: both bits stay 0 - - bit_position -= 1 - if bit_position < 0: - byte_data_plane1.append(current_byte1) - byte_data_plane2.append(current_byte2) - current_byte1 = 0 - current_byte2 = 0 - bit_position = 7 - - # Handle remaining bits - if bit_position != 7: - byte_data_plane1.append(current_byte1) - byte_data_plane2.append(current_byte2) - - # Concatenate: plane1 + plane2 (NOT inverted for direct write) - return bytes(byte_data_plane1) + bytes(byte_data_plane2) + pixel_array = np.asarray(image.convert("RGB"), dtype=np.uint8) + colors = _direct_write_color_values(pixel_array, color_scheme) + plane1 = (colors == 1) | (colors == 3) + plane2 = (colors == 2) | (colors == 3) + return np.packbits(plane1).tobytes() + np.packbits(plane2).tobytes() def _encode_direct_write_2bpp(image: Image.Image, color_scheme: int) -> bytes: @@ -340,58 +307,33 @@ def _encode_direct_write_2bpp(image: Image.Image, color_scheme: int) -> bytes: Returns: bytes: 2BPP encoded data (4 pixels per byte) """ - pixel_array = np.array(image.convert("RGB")) - height, width, _ = pixel_array.shape - - byte_data = bytearray() - current_byte = 0 - pixel_in_byte = 0 - - for y in range(height): - for x in range(width): - r, g, b = pixel_array[y, x] - - if color_scheme == 5: - # 4 grayscale: 00=Black, 01=DarkGray, 10=LightGray, 11=White - gray = (int(r) + int(g) + int(b)) / 3.0 - if gray < 64: - gray_level = 0 # GRAY0 (Black) - elif gray < 128: - gray_level = 1 # GRAY1 (Dark Gray) - elif gray < 192: - gray_level = 2 # GRAY2 (Light Gray) - else: - gray_level = 3 # GRAY3 (White) - color_value = gray_level - else: - # BWRY: 00=Black, 01=White, 10=Yellow, 11=Red - color = _detect_color(int(r), int(g), int(b), color_scheme) - if color == 'black': - color_value = 0 # 00 - elif color == 'white': - color_value = 1 # 01 - elif color == 'yellow': - color_value = 2 # 10 - elif color == 'red': - color_value = 3 # 11 - else: - color_value = 0 # Fallback to black - - # Pack 2 bits into byte (4 pixels per byte) - # Bits are packed from MSB: pixel0 at bits 7-6, pixel1 at bits 5-4, etc. - current_byte |= (color_value << (6 - pixel_in_byte * 2)) - pixel_in_byte += 1 - - if pixel_in_byte >= 4: - byte_data.append(current_byte) - current_byte = 0 - pixel_in_byte = 0 - - # Handle remaining pixels if not a multiple of 4 - if pixel_in_byte > 0: - byte_data.append(current_byte) - - return bytes(byte_data) + pixel_array = np.asarray(image.convert("RGB"), dtype=np.uint8) + + if color_scheme == 5: + flat = pixel_array.reshape(-1, 3) + gray = ( + flat[:, 0].astype(np.uint16) + + flat[:, 1].astype(np.uint16) + + flat[:, 2].astype(np.uint16) + ) / 3.0 + values = np.where(gray < 64, 0, np.where(gray < 128, 1, np.where(gray < 192, 2, 3))).astype(np.uint8) + else: + colors = _direct_write_color_values(pixel_array, color_scheme) + values = np.zeros_like(colors) + values[colors == 1] = 1 + values[colors == 2] = 2 + values[colors == 3] = 3 + + pad = (-len(values)) % 4 + if pad: + values = np.pad(values, (0, pad)) + groups = values.reshape(-1, 4) + return ( + (groups[:, 0] << 6) + | (groups[:, 1] << 4) + | (groups[:, 2] << 2) + | groups[:, 3] + ).astype(np.uint8).tobytes() def _encode_direct_write_4bpp(image: Image.Image) -> bytes: @@ -403,50 +345,13 @@ def _encode_direct_write_4bpp(image: Image.Image) -> bytes: Returns: bytes: 4BPP encoded data (2 pixels per byte) """ - pixel_array = np.array(image.convert("RGB")) - height, width, _ = pixel_array.shape - - byte_data = bytearray() - current_byte = 0 - nibble_position = 1 # Start with high nibble (1 = high, 0 = low) - - for y in range(height): - for x in range(width): - r, g, b = pixel_array[y, x] - color = _detect_color(int(r), int(g), int(b), 4) - - # Firmware expects: black=0, white=1, yellow=2, red=3, blue=5, green=6 - if color == 'black': - color_value = 0 - elif color == 'white': - color_value = 1 - elif color == 'yellow': - color_value = 2 - elif color == 'red': - color_value = 3 - elif color == 'green': - color_value = 6 - elif color == 'blue': - color_value = 5 - else: - color_value = 0 # Fallback to black - - if nibble_position == 1: - # High nibble - current_byte = (color_value << 4) - nibble_position = 0 - else: - # Low nibble - current_byte |= color_value - byte_data.append(current_byte) - current_byte = 0 - nibble_position = 1 - - # Handle remaining nibble if odd number of pixels - if nibble_position == 0: - byte_data.append(current_byte) - - return bytes(byte_data) + pixel_array = np.asarray(image.convert("RGB"), dtype=np.uint8) + values = _direct_write_color_values(pixel_array, 4) + pad = (-len(values)) % 2 + if pad: + values = np.pad(values, (0, pad)) + pairs = values.reshape(-1, 2) + return ((pairs[:, 0] << 4) | pairs[:, 1]).astype(np.uint8).tobytes() def _encode_direct_write(image: Image.Image, color_scheme: int) -> bytes: @@ -474,6 +379,57 @@ def _encode_direct_write(image: Image.Image, color_scheme: int) -> bytes: return _encode_direct_write_1bpp(image) +def _prepare_block_upload( + image: Image.Image, + metadata: BLEDeviceMetadata, + protocol_type: str, + dither: int, +) -> tuple[Image.Image, int, bytes]: + """Prepare block-based BLE upload bytes off the event loop.""" + if protocol_type == "atc" and metadata.rotatebuffer == 1: + image = image.transpose(Image.Transpose.ROTATE_90) + + processed_image = process_image_for_device( + image, + metadata.color_scheme.value, + dither, + ) + data_type, pixel_array = _convert_image_to_bytes( + processed_image, + metadata.color_scheme.value, + compressed=True, + ) + return processed_image, data_type, pixel_array + + +def _prepare_direct_write_upload( + image: Image.Image, + metadata: BLEDeviceMetadata, + compressed: bool, + dither: int, +) -> tuple[Image.Image, bytes, list[bytes], int, int]: + """Prepare direct-write BLE upload bytes off the event loop.""" + processed_image = process_image_for_device( + image, + metadata.color_scheme.value, + dither, + ) + encoded_data = _encode_direct_write(processed_image, metadata.color_scheme.value) + + if compressed: + data_to_send = zlib.compress(encoded_data, level=9) + uncompressed_size = len(encoded_data) + else: + data_to_send = encoded_data + uncompressed_size = 0 + + chunks = [ + data_to_send[i:i + BLE_MAX_PACKET_DATA_SIZE] + for i in range(0, len(data_to_send), BLE_MAX_PACKET_DATA_SIZE) + ] + return processed_image, data_to_send, chunks, uncompressed_size, len(encoded_data) + + class BLEImageUploader: """Handles BLE image upload with block-based protocol. @@ -589,23 +545,17 @@ async def upload_image_block_based( # Apply rotation for ATC devices only (OpenDisplay handles rotation firmware-side) if protocol_type == "atc" and metadata.rotatebuffer == 1: - image = image.transpose(Image.Transpose.ROTATE_90) - _LOGGER.debug("Applied 90° rotation for ATC device: %dx%d", image.width, image.height) + _LOGGER.debug("Will apply 90° rotation for ATC device") else: _LOGGER.debug("No client-side rotation (protocol=%s, rotatebuffer=%d): %dx%d", protocol_type, metadata.rotatebuffer, image.width, image.height) - - processed_image = process_image_for_device( + processed_image, data_type, pixel_array = await self.connection.hass.async_add_executor_job( + _prepare_block_upload, image, - metadata.color_scheme.value, - dither - ) - - - # Convert image to device format - data_type, pixel_array = _convert_image_to_bytes( - processed_image, metadata.color_scheme.value, compressed=True + metadata, + protocol_type, + dither, ) _LOGGER.debug( @@ -753,28 +703,26 @@ async def upload_direct_write( try: _LOGGER.debug("Direct write: image size %dx%d", image.width, image.height) - processed_image = process_image_for_device( + ( + processed_image, + data_to_send, + chunks, + uncompressed_size, + encoded_size, + ) = await self.connection.hass.async_add_executor_job( + _prepare_direct_write_upload, image, - metadata.color_scheme.value, - dither + metadata, + compressed, + dither, ) - - # Encode image based on color scheme - encoded_data = _encode_direct_write(processed_image, metadata.color_scheme.value) - - # Compress if requested + if compressed: - compressed_data = zlib.compress(encoded_data, level=9) _LOGGER.debug( "Direct write compressed: %d bytes -> %d bytes", - len(encoded_data), - len(compressed_data) + encoded_size, + len(data_to_send) ) - data_to_send = compressed_data - uncompressed_size = len(encoded_data) - else: - data_to_send = encoded_data - uncompressed_size = 0 _LOGGER.info( "Starting direct write upload to %s (%d bytes%s, refresh type %d)", @@ -790,12 +738,7 @@ async def upload_direct_write( self._direct_write_pending_acks = 0 self._direct_write_compressed = compressed self._direct_write_uncompressed_size = uncompressed_size - - # Split into chunks (max 230 bytes per chunk) - chunk_size = BLE_MAX_PACKET_DATA_SIZE - for i in range(0, len(data_to_send), chunk_size): - chunk = data_to_send[i:i + chunk_size] - self._direct_write_chunks.append(chunk) + self._direct_write_chunks = chunks _LOGGER.debug("Split into %d chunks", len(self._direct_write_chunks)) @@ -815,7 +758,7 @@ async def upload_direct_write( self._direct_write_chunk_index = len(self._direct_write_chunks) else: # Large payload - send header + first chunk - first_chunk_size = min(max_start_payload - len(header), chunk_size) + first_chunk_size = min(max_start_payload - len(header), BLE_MAX_PACKET_DATA_SIZE) first_chunk = data_to_send[:first_chunk_size] start_payload = header + first_chunk await self.connection._write_raw( From 1233130780da1c6439c98a6273b7155709814c1d Mon Sep 17 00:00:00 2001 From: Michael Bisbjerg Date: Thu, 7 May 2026 21:31:58 +0200 Subject: [PATCH 3/5] perf: select direct write compression by payload size --- .../opendisplay/ble/image_upload.py | 58 ++++++++++++++++--- custom_components/opendisplay/ble/metadata.py | 21 +++---- custom_components/opendisplay/services.py | 2 +- custom_components/opendisplay/upload.py | 10 ++-- 4 files changed, 67 insertions(+), 24 deletions(-) diff --git a/custom_components/opendisplay/ble/image_upload.py b/custom_components/opendisplay/ble/image_upload.py index fce3f46..c91ff05 100644 --- a/custom_components/opendisplay/ble/image_upload.py +++ b/custom_components/opendisplay/ble/image_upload.py @@ -18,6 +18,8 @@ # BLE Protocol Sizes BLE_BLOCK_SIZE = 4096 BLE_MAX_PACKET_DATA_SIZE = 230 +DIRECT_WRITE_COMPRESSED_BUFFER_LIMIT = 50 * 1024 +DIRECT_WRITE_COMPRESSION_CHUNK_BYTES = 64 * 1024 class BLEResponse(Enum): @@ -405,9 +407,9 @@ def _prepare_block_upload( def _prepare_direct_write_upload( image: Image.Image, metadata: BLEDeviceMetadata, - compressed: bool, + allow_compression: bool, dither: int, -) -> tuple[Image.Image, bytes, list[bytes], int, int]: +) -> tuple[Image.Image, bytes, list[bytes], int, int, bool]: """Prepare direct-write BLE upload bytes off the event loop.""" processed_image = process_image_for_device( image, @@ -415,19 +417,53 @@ def _prepare_direct_write_upload( dither, ) encoded_data = _encode_direct_write(processed_image, metadata.color_scheme.value) + compressed_data = ( + _compress_direct_write_if_fits(encoded_data, DIRECT_WRITE_COMPRESSED_BUFFER_LIMIT) + if allow_compression + else None + ) - if compressed: - data_to_send = zlib.compress(encoded_data, level=9) + if compressed_data is not None: + data_to_send = compressed_data uncompressed_size = len(encoded_data) + compressed = True else: data_to_send = encoded_data uncompressed_size = 0 + compressed = False chunks = [ data_to_send[i:i + BLE_MAX_PACKET_DATA_SIZE] for i in range(0, len(data_to_send), BLE_MAX_PACKET_DATA_SIZE) ] - return processed_image, data_to_send, chunks, uncompressed_size, len(encoded_data) + return processed_image, data_to_send, chunks, uncompressed_size, len(encoded_data), compressed + + +def _compress_direct_write_if_fits(data: bytes, max_size: int) -> bytes | None: + """Compress direct-write data, returning None once the compressed payload is too large.""" + compressor = zlib.compressobj(level=9) + data_view = memoryview(data) + compressed_parts = [] + compressed_size = 0 + + for start in range(0, len(data_view), DIRECT_WRITE_COMPRESSION_CHUNK_BYTES): + part = compressor.compress( + data_view[start:start + DIRECT_WRITE_COMPRESSION_CHUNK_BYTES] + ) + if part: + compressed_parts.append(part) + compressed_size += len(part) + if compressed_size >= max_size: + return None + + part = compressor.flush() + if part: + compressed_parts.append(part) + compressed_size += len(part) + if compressed_size >= max_size: + return None + + return b"".join(compressed_parts) class BLEImageUploader: @@ -678,7 +714,7 @@ async def upload_direct_write( self, image: Image.Image, metadata: BLEDeviceMetadata, - compressed: bool = False, + allow_compression: bool = False, dither: int = 2, refresh_type: int = 0 ) -> tuple[bool, Image.Image | None]: @@ -687,7 +723,7 @@ async def upload_direct_write( Args: image: Rendered image metadata: Device metadata with dimensions and color scheme - compressed: Whether to compress the data + allow_compression: Whether zip compression may be used if the result fits dither: 0=none, 1=ordered, 2=floyd-steinberg refresh_type: Display refresh mode (0=full, 1=fast, 2=partial, 3=partial2) @@ -709,11 +745,12 @@ async def upload_direct_write( chunks, uncompressed_size, encoded_size, + compressed, ) = await self.connection.hass.async_add_executor_job( _prepare_direct_write_upload, image, metadata, - compressed, + allow_compression, dither, ) @@ -723,6 +760,11 @@ async def upload_direct_write( encoded_size, len(data_to_send) ) + elif allow_compression: + _LOGGER.debug( + "Direct write compression skipped: compressed payload exceeded %d bytes", + DIRECT_WRITE_COMPRESSED_BUFFER_LIMIT, + ) _LOGGER.info( "Starting direct write upload to %s (%d bytes%s, refresh type %d)", diff --git a/custom_components/opendisplay/ble/metadata.py b/custom_components/opendisplay/ble/metadata.py index 45f8120..1e3a3af 100644 --- a/custom_components/opendisplay/ble/metadata.py +++ b/custom_components/opendisplay/ble/metadata.py @@ -193,24 +193,25 @@ def transmission_modes(self) -> int: return displays[0].get("transmission_modes", 0) if displays else 0 return 0 # ATC devices don't support direct_write - def get_best_upload_method(self, image_size: int = 0) -> str: - """Determine the best upload method based on device capabilities and iamge size. + @property + def supports_zip_compression(self) -> bool: + """Return true if the device advertises zip-compressed transfer support.""" + return (self.transmission_modes & 0x02) != 0 + + def get_best_upload_method(self) -> str: + """Determine the best upload method based on device capabilities. Priority order: - 1. direct_write_compressed: If direct_write (0x08) AND zip (0x02) are supported and size < 50KB - 2. direct_write: If direct_write (0x08) is supported but zip is not - 3. block: Fallback to block-based upload (always supported) + 1. direct_write: If direct_write (0x08) is supported + 2. block: Fallback to block-based upload (always supported) Returns: - Upload method string: "direct_write_compressed", "direct_write", or "block" + Upload method string: "direct_write" or "block" """ modes = self.transmission_modes has_direct_write = (modes & 0x08) != 0 - has_zip = (modes & 0x02) != 0 - if has_direct_write and has_zip and image_size < 50 * 1024: - return "direct_write_compressed" - elif has_direct_write: + if has_direct_write: return "direct_write" else: return "block" diff --git a/custom_components/opendisplay/services.py b/custom_components/opendisplay/services.py index d3496ce..c5d8c2d 100644 --- a/custom_components/opendisplay/services.py +++ b/custom_components/opendisplay/services.py @@ -332,7 +332,7 @@ async def drawcustom_service(service: ServiceCall, entity_id: str) -> None: hass, entity_id, image, - upload_method == "direct_write_compressed", + metadata.supports_zip_compression, dither, refresh_type ) diff --git a/custom_components/opendisplay/upload.py b/custom_components/opendisplay/upload.py index 415d153..791e1e4 100644 --- a/custom_components/opendisplay/upload.py +++ b/custom_components/opendisplay/upload.py @@ -417,7 +417,7 @@ async def upload_to_ble_direct( hass: HomeAssistant, entity_id: str, img: Image.Image, - compressed: bool = False, + allow_compression: bool = False, dither: int = 2, refresh_type: int = 0, ) -> None: @@ -430,7 +430,7 @@ async def upload_to_ble_direct( hass: Home Assistant instance entity_id: Entity ID of the target tag img: Rendered image to prepare and upload - compressed: Whether to compress the image data + allow_compression: Whether zip compression may be used if the result fits dither: Dithering mode (0=none, 1=Burkes, 2=ordered) refresh_type: Display refresh mode (0=full, 1=fast, 2=partial, 3=partial2) Raises: @@ -438,10 +438,10 @@ async def upload_to_ble_direct( """ mac = entity_id.split(".")[1].upper() _LOGGER.debug( - "Preparing BLE direct write upload for %s (MAC: %s, compressed=%s, refresh_type=%d)", + "Preparing BLE direct write upload for %s (MAC: %s, allow_compression=%s, refresh_type=%d)", entity_id, mac, - compressed, + allow_compression, refresh_type ) @@ -482,7 +482,7 @@ async def upload_to_ble_direct( # Upload via BLE using direct write protocol async with BLEConnection(hass, mac, protocol.service_uuid, protocol) as conn: uploader = BLEImageUploader(conn, mac) - success, processed_image = await uploader.upload_direct_write(img, metadata, compressed, dither, refresh_type) + success, processed_image = await uploader.upload_direct_write(img, metadata, allow_compression, dither, refresh_type) if not success: raise HomeAssistantError( From 9bde2b351339f4b52a10b7f1f07ab9f7ed8d4da9 Mon Sep 17 00:00:00 2001 From: Michael Bisbjerg Date: Thu, 7 May 2026 21:36:12 +0200 Subject: [PATCH 4/5] review: address BLE upload comments --- .../opendisplay/ble/image_upload.py | 26 ++++++++++++------- 1 file changed, 17 insertions(+), 9 deletions(-) diff --git a/custom_components/opendisplay/ble/image_upload.py b/custom_components/opendisplay/ble/image_upload.py index c91ff05..c9a5048 100644 --- a/custom_components/opendisplay/ble/image_upload.py +++ b/custom_components/opendisplay/ble/image_upload.py @@ -240,7 +240,7 @@ def _detect_color(r: int, g: int, b: int, color_scheme: int) -> str: def _direct_write_color_values(pixel_array: np.ndarray, color_scheme: int) -> np.ndarray: - """Return direct-write palette values using the same thresholds as _detect_color.""" + """Return direct-write firmware color values using the same thresholds as _detect_color.""" flat = pixel_array.reshape(-1, 3) r = flat[:, 0] g = flat[:, 1] @@ -294,6 +294,7 @@ def _encode_direct_write_bitplanes(image: Image.Image, color_scheme: int) -> byt """ pixel_array = np.asarray(image.convert("RGB"), dtype=np.uint8) colors = _direct_write_color_values(pixel_array, color_scheme) + # Plane 1 is B/W: 1=white/red, 0=black/yellow. Plane 2 is color: 1=red/yellow. plane1 = (colors == 1) | (colors == 3) plane2 = (colors == 2) | (colors == 3) return np.packbits(plane1).tobytes() + np.packbits(plane2).tobytes() @@ -318,8 +319,10 @@ def _encode_direct_write_2bpp(image: Image.Image, color_scheme: int) -> bytes: + flat[:, 1].astype(np.uint16) + flat[:, 2].astype(np.uint16) ) / 3.0 + # 4 grayscale: 00=black, 01=dark gray, 10=light gray, 11=white. values = np.where(gray < 64, 0, np.where(gray < 128, 1, np.where(gray < 192, 2, 3))).astype(np.uint8) else: + # BWRY: 00=black, 01=white, 10=yellow, 11=red. colors = _direct_write_color_values(pixel_array, color_scheme) values = np.zeros_like(colors) values[colors == 1] = 1 @@ -330,6 +333,7 @@ def _encode_direct_write_2bpp(image: Image.Image, color_scheme: int) -> bytes: if pad: values = np.pad(values, (0, pad)) groups = values.reshape(-1, 4) + # Pack from MSB: pixel0 at bits 7-6, pixel1 at 5-4, pixel2 at 3-2, pixel3 at 1-0. return ( (groups[:, 0] << 6) | (groups[:, 1] << 4) @@ -348,11 +352,13 @@ def _encode_direct_write_4bpp(image: Image.Image) -> bytes: bytes: 4BPP encoded data (2 pixels per byte) """ pixel_array = np.asarray(image.convert("RGB"), dtype=np.uint8) + # Firmware expects: black=0, white=1, yellow=2, red=3, blue=5, green=6. values = _direct_write_color_values(pixel_array, 4) pad = (-len(values)) % 2 if pad: values = np.pad(values, (0, pad)) pairs = values.reshape(-1, 2) + # Pack two pixels per byte: first pixel in the high nibble, second in the low nibble. return ((pairs[:, 0] << 4) | pairs[:, 1]).astype(np.uint8).tobytes() @@ -388,8 +394,10 @@ def _prepare_block_upload( dither: int, ) -> tuple[Image.Image, int, bytes]: """Prepare block-based BLE upload bytes off the event loop.""" + # ATC stores rotated image memory client-side; OpenDisplay handles rotation firmware-side. if protocol_type == "atc" and metadata.rotatebuffer == 1: image = image.transpose(Image.Transpose.ROTATE_90) + _LOGGER.debug("Applied 90° ATC memory rotation: %dx%d", image.width, image.height) processed_image = process_image_for_device( image, @@ -577,14 +585,14 @@ async def upload_image_block_based( tuple: (success, processed_image) - processed_image is the dithered PIL Image """ try: - _LOGGER.debug("Before transpose: image size %dx%d", image.width, image.height) - - # Apply rotation for ATC devices only (OpenDisplay handles rotation firmware-side) - if protocol_type == "atc" and metadata.rotatebuffer == 1: - _LOGGER.debug("Will apply 90° rotation for ATC device") - else: - _LOGGER.debug("No client-side rotation (protocol=%s, rotatebuffer=%d): %dx%d", - protocol_type, metadata.rotatebuffer, image.width, image.height) + _LOGGER.debug( + "Block upload input for %s: %dx%d (protocol=%s, rotatebuffer=%d)", + self.mac_address, + image.width, + image.height, + protocol_type, + metadata.rotatebuffer, + ) processed_image, data_type, pixel_array = await self.connection.hass.async_add_executor_job( _prepare_block_upload, From fc034d4083513fe7e1c5ff83b49f2902632ba5f1 Mon Sep 17 00:00:00 2001 From: Michael Bisbjerg Date: Thu, 7 May 2026 21:49:29 +0200 Subject: [PATCH 5/5] perf: log drawcustom upload timings --- .../opendisplay/ble/image_upload.py | 50 +++++++++++++++---- custom_components/opendisplay/services.py | 20 ++++++-- custom_components/opendisplay/upload.py | 47 +++++++++++++++-- 3 files changed, 100 insertions(+), 17 deletions(-) diff --git a/custom_components/opendisplay/ble/image_upload.py b/custom_components/opendisplay/ble/image_upload.py index c9a5048..b239ae4 100644 --- a/custom_components/opendisplay/ble/image_upload.py +++ b/custom_components/opendisplay/ble/image_upload.py @@ -4,6 +4,7 @@ import zlib import logging from enum import Enum +from time import perf_counter import numpy as np from PIL import Image @@ -392,24 +393,26 @@ def _prepare_block_upload( metadata: BLEDeviceMetadata, protocol_type: str, dither: int, -) -> tuple[Image.Image, int, bytes]: +) -> tuple[Image.Image, int, bytes, float]: """Prepare block-based BLE upload bytes off the event loop.""" # ATC stores rotated image memory client-side; OpenDisplay handles rotation firmware-side. if protocol_type == "atc" and metadata.rotatebuffer == 1: image = image.transpose(Image.Transpose.ROTATE_90) _LOGGER.debug("Applied 90° ATC memory rotation: %dx%d", image.width, image.height) + quantize_start = perf_counter() processed_image = process_image_for_device( image, metadata.color_scheme.value, dither, ) + quantize_duration = perf_counter() - quantize_start data_type, pixel_array = _convert_image_to_bytes( processed_image, metadata.color_scheme.value, compressed=True, ) - return processed_image, data_type, pixel_array + return processed_image, data_type, pixel_array, quantize_duration def _prepare_direct_write_upload( @@ -417,13 +420,15 @@ def _prepare_direct_write_upload( metadata: BLEDeviceMetadata, allow_compression: bool, dither: int, -) -> tuple[Image.Image, bytes, list[bytes], int, int, bool]: +) -> tuple[Image.Image, bytes, list[bytes], int, int, bool, float]: """Prepare direct-write BLE upload bytes off the event loop.""" + quantize_start = perf_counter() processed_image = process_image_for_device( image, metadata.color_scheme.value, dither, ) + quantize_duration = perf_counter() - quantize_start encoded_data = _encode_direct_write(processed_image, metadata.color_scheme.value) compressed_data = ( _compress_direct_write_if_fits(encoded_data, DIRECT_WRITE_COMPRESSED_BUFFER_LIMIT) @@ -444,7 +449,7 @@ def _prepare_direct_write_upload( data_to_send[i:i + BLE_MAX_PACKET_DATA_SIZE] for i in range(0, len(data_to_send), BLE_MAX_PACKET_DATA_SIZE) ] - return processed_image, data_to_send, chunks, uncompressed_size, len(encoded_data), compressed + return processed_image, data_to_send, chunks, uncompressed_size, len(encoded_data), compressed, quantize_duration def _compress_direct_write_if_fits(data: bytes, max_size: int) -> bytes | None: @@ -571,7 +576,8 @@ async def upload_image_block_based( image: Image.Image, metadata: BLEDeviceMetadata, protocol_type: str = "atc", - dither: int = 2 + dither: int = 2, + render_duration: float | None = None, ) -> tuple[bool, Image.Image | None]: """Upload image using block-based protocol. @@ -580,6 +586,7 @@ async def upload_image_block_based( metadata: Device metadata with dimensions and color support protocol_type: Protocol type ("atc" or "open_display") dither: 0=none, 1=ordered, 2=floyd-steinberg + render_duration: Time spent rendering the image before upload, in seconds Returns: tuple: (success, processed_image) - processed_image is the dithered PIL Image @@ -594,7 +601,7 @@ async def upload_image_block_based( metadata.rotatebuffer, ) - processed_image, data_type, pixel_array = await self.connection.hass.async_add_executor_job( + processed_image, data_type, pixel_array, quantize_duration = await self.connection.hass.async_add_executor_job( _prepare_block_upload, image, metadata, @@ -619,6 +626,7 @@ async def upload_image_block_based( data_info = _create_data_info( 255, zlib.crc32(self._img_array) & 0xFFFFFFF, self._img_array_len, data_type, 0, 0 ) + send_refresh_start = perf_counter() await self.connection._write_raw(bytes.fromhex(BLECommand.DATA_INFO.value) + data_info) # Wait for responses using request-response pattern @@ -635,7 +643,16 @@ async def upload_image_block_based( raise BLEError(f"Upload failed: {self._upload_error}") # Only reach here if upload_complete was set by a success response - _LOGGER.info("BLE image upload completed successfully for %s", self.mac_address) + send_refresh_duration = perf_counter() - send_refresh_start + _LOGGER.info( + "BLE block upload completed for %s: render=%.3fs dither_quantize=%.3fs send_refresh=%.3fs bytes=%d data_type=0x%02x", + self.mac_address, + render_duration or 0.0, + quantize_duration, + send_refresh_duration, + len(pixel_array), + data_type, + ) return True, processed_image except Exception as e: @@ -724,7 +741,8 @@ async def upload_direct_write( metadata: BLEDeviceMetadata, allow_compression: bool = False, dither: int = 2, - refresh_type: int = 0 + refresh_type: int = 0, + render_duration: float | None = None, ) -> tuple[bool, Image.Image | None]: """Upload image using direct write protocol (OpenDisplay only). @@ -734,6 +752,7 @@ async def upload_direct_write( allow_compression: Whether zip compression may be used if the result fits dither: 0=none, 1=ordered, 2=floyd-steinberg refresh_type: Display refresh mode (0=full, 1=fast, 2=partial, 3=partial2) + render_duration: Time spent rendering the image before upload, in seconds Returns: bool: True if upload succeeded, False otherwise @@ -754,6 +773,7 @@ async def upload_direct_write( uncompressed_size, encoded_size, compressed, + quantize_duration, ) = await self.connection.hass.async_add_executor_job( _prepare_direct_write_upload, image, @@ -793,6 +813,7 @@ async def upload_direct_write( _LOGGER.debug("Split into %d chunks", len(self._direct_write_chunks)) # Send start command + send_refresh_start = perf_counter() if compressed: # Compressed: send 4-byte header + initial data if it fits header = struct.pack(" None: width, height, accent_color = await generator.get_tag_dimensions( entity_id, is_ble=is_ble ) + render_start = perf_counter() image = await generator.generate_custom_image( entity_id=entity_id, service_data=service.data, @@ -265,6 +267,7 @@ async def drawcustom_service(service: ServiceCall, entity_id: str) -> None: height=height, accent_color=accent_color, ) + render_duration = perf_counter() - render_start if device_errors: errors_str = "\n".join(device_errors) @@ -283,16 +286,23 @@ async def drawcustom_service(service: ServiceCall, entity_id: str) -> None: # Handle dry-run mode if service.data.get("dry-run", False): - _LOGGER.info("Dry run completed for %s", entity_id) tag_mac = get_mac_from_entity_id(entity_id) + preview_start = perf_counter() jpeg_bytes = await hass.async_add_executor_job( image_to_jpeg_bytes, image, "maximum" ) + preview_duration = perf_counter() - preview_start async_dispatcher_send( hass, f"{SIGNAL_TAG_IMAGE_UPDATE}_{tag_mac}", jpeg_bytes ) + _LOGGER.info( + "drawcustom dry run completed for %s: render=%.3fs preview_encode=%.3fs", + entity_id, + render_duration, + preview_duration, + ) return # Upload image @@ -325,7 +335,7 @@ async def drawcustom_service(service: ServiceCall, entity_id: str) -> None: upload_method = metadata.get_best_upload_method() if upload_method == "block": - await ble_upload_queue.add_to_queue(upload_to_ble_block, hass, entity_id, image, dither) + await ble_upload_queue.add_to_queue(upload_to_ble_block, hass, entity_id, image, dither, render_duration) else: await ble_upload_queue.add_to_queue( upload_to_ble_direct, @@ -334,7 +344,8 @@ async def drawcustom_service(service: ServiceCall, entity_id: str) -> None: image, metadata.supports_zip_compression, dither, - refresh_type + refresh_type, + render_duration, ) else: # Map refresh_type to AP's lut parameter @@ -346,7 +357,8 @@ async def drawcustom_service(service: ServiceCall, entity_id: str) -> None: service.data.get("ttl", 60), service.data.get("preload_type", 0), service.data.get("preload_lut", 0), - ap_lut + ap_lut, + render_duration, ) except ServiceValidationError: diff --git a/custom_components/opendisplay/upload.py b/custom_components/opendisplay/upload.py index 791e1e4..28a39cb 100644 --- a/custom_components/opendisplay/upload.py +++ b/custom_components/opendisplay/upload.py @@ -4,6 +4,7 @@ import logging from datetime import datetime from io import BytesIO +from time import perf_counter from typing import Final import async_timeout @@ -220,7 +221,8 @@ async def _execute_upload(self, upload_func, args, kwargs, entity_id): async def upload_to_hub(hub, entity_id: str, img: Image.Image, dither: int, ttl: int, - preload_type: int = 0, preload_lut: int = 0, lut: int = 1) -> None: + preload_type: int = 0, preload_lut: int = 0, lut: int = 1, + render_duration: float | None = None) -> None: """Upload image to tag through AP. Sends an image to the AP for display on a specific tag using @@ -238,6 +240,7 @@ async def upload_to_hub(hub, entity_id: str, img: Image.Image, dither: int, ttl: preload_type: Type for image preloading (0=disabled) preload_lut: Look-up table for preloading lut: Display refresh LUT mode (1=full, 3=fast, 2=fast no-reds, 0=no-repeats) + render_duration: Time spent rendering the image before upload, in seconds Raises: HomeAssistantError: If upload fails or times out """ @@ -250,9 +253,13 @@ async def upload_to_hub(hub, entity_id: str, img: Image.Image, dither: int, ttl: # Convert TTL fom seconds to minutes for the AP ttl_minutes = max(1, ttl // 60) + encode_start = perf_counter() jpeg_bytes = await hub.hass.async_add_executor_job(image_to_jpeg_bytes, img, "maximum") + jpeg_encode_duration = perf_counter() - encode_start backoff_delay = INITIAL_BACKOFF # Try up to MAX_RETRIES times to upload the image, retrying on TimeoutError. + send_start = perf_counter() + response = None for attempt in range(1, MAX_RETRIES + 1): try: @@ -321,8 +328,24 @@ async def upload_to_hub(hub, entity_id: str, img: Image.Image, dither: int, ttl: translation_placeholders={"entity_id": entity_id, "error": str(err)} ) from err + send_duration = perf_counter() - send_start + _LOGGER.info( + "AP upload completed for %s: render=%.3fs jpeg_encode=%.3fs send=%.3fs status=%s", + entity_id, + render_duration or 0.0, + jpeg_encode_duration, + send_duration, + response.status_code if response is not None else "unknown", + ) + -async def upload_to_ble_block(hass: HomeAssistant, entity_id: str, img: Image.Image, dither: int = 2) -> None: +async def upload_to_ble_block( + hass: HomeAssistant, + entity_id: str, + img: Image.Image, + dither: int = 2, + render_duration: float | None = None, +) -> None: """Upload image to BLE tag using block-based protocol. Sends an image to a BLE tag using direct Bluetooth communication. @@ -336,6 +359,7 @@ async def upload_to_ble_block(hass: HomeAssistant, entity_id: str, img: Image.Im entity_id: Entity ID of the target tag img: Rendered image to prepare and upload dither: Dithering mode (0=none, 1=Burkes, 2=ordered) + render_duration: Time spent rendering the image before upload, in seconds Raises: HomeAssistantError: If BLE upload fails @@ -376,7 +400,13 @@ async def upload_to_ble_block(hass: HomeAssistant, entity_id: str, img: Image.Im # Upload via BLE using protocol-specific service UUID async with BLEConnection(hass, mac, protocol.service_uuid, protocol) as conn: uploader = BLEImageUploader(conn, mac) - success, processed_image = await uploader.upload_image_block_based(img, metadata, protocol_type, dither) + success, processed_image = await uploader.upload_image_block_based( + img, + metadata, + protocol_type, + dither, + render_duration, + ) if not success: raise HomeAssistantError( @@ -420,6 +450,7 @@ async def upload_to_ble_direct( allow_compression: bool = False, dither: int = 2, refresh_type: int = 0, + render_duration: float | None = None, ) -> None: """Upload image to BLE tag using direct write protocol (OpenDisplay only). @@ -433,6 +464,7 @@ async def upload_to_ble_direct( allow_compression: Whether zip compression may be used if the result fits dither: Dithering mode (0=none, 1=Burkes, 2=ordered) refresh_type: Display refresh mode (0=full, 1=fast, 2=partial, 3=partial2) + render_duration: Time spent rendering the image before upload, in seconds Raises: HomeAssistantError: If BLE direct write upload fails """ @@ -482,7 +514,14 @@ async def upload_to_ble_direct( # Upload via BLE using direct write protocol async with BLEConnection(hass, mac, protocol.service_uuid, protocol) as conn: uploader = BLEImageUploader(conn, mac) - success, processed_image = await uploader.upload_direct_write(img, metadata, allow_compression, dither, refresh_type) + success, processed_image = await uploader.upload_direct_write( + img, + metadata, + allow_compression, + dither, + refresh_type, + render_duration, + ) if not success: raise HomeAssistantError(