From 715237e84988b68db53dd2f5bd2bdc3ef2a4465e Mon Sep 17 00:00:00 2001 From: Michael Bisbjerg Date: Sun, 26 Apr 2026 00:13:28 +0200 Subject: [PATCH 01/18] Update partial uploads for versioned 0x76/0x77 protocol --- src/opendisplay/device.py | 196 +++++++++++- src/opendisplay/partial.py | 452 +++++++++++++++++++++++++++ src/opendisplay/protocol/__init__.py | 6 + src/opendisplay/protocol/commands.py | 29 ++ tests/unit/test_partial.py | 192 ++++++++++++ 5 files changed, 874 insertions(+), 1 deletion(-) create mode 100644 src/opendisplay/partial.py create mode 100644 tests/unit/test_partial.py diff --git a/src/opendisplay/device.py b/src/opendisplay/device.py index 65d814a..eeedfb2 100644 --- a/src/opendisplay/device.py +++ b/src/opendisplay/device.py @@ -31,6 +31,17 @@ from .models.enums import BoardManufacturer, FitMode, RefreshMode, Rotation from .models.firmware import FirmwareVersion from .models.led_flash import LedFlashConfig +from .partial import ( + ERR_ETAG_MISMATCH, + SEGMENT_HEADER_SIZE, + DiffStrategy, + PartialState, + RecursiveBoundingBoxStrategy, + Segment, + _generate_etag, + pack_segments_into_packets, + parse_nack, +) from .protocol import ( CHUNK_SIZE, ENCRYPTED_CHUNK_SIZE, @@ -40,9 +51,12 @@ build_authenticate_step2, build_direct_write_data_command, build_direct_write_end_command, + build_direct_write_end_with_etag, + build_direct_write_partial_start, build_direct_write_start_compressed, build_direct_write_start_uncompressed, build_led_activate_command, + build_partial_data_packet, build_read_config_command, build_read_fw_version_command, build_reboot_command, @@ -860,6 +874,8 @@ async def upload_image( fit: FitMode = FitMode.CONTAIN, rotate: Rotation = Rotation.ROTATE_0, progress_callback: Callable[[int, int], None] | None = None, + state: PartialState | None = None, + diff_strategy: DiffStrategy | None = None, ) -> Image.Image: """Upload image to device display. @@ -906,6 +922,19 @@ async def upload_image( image_data, compressed_data, processed_image = self._prepare_image( image, dither_mode, compress and supports_compression, tone_compression, fit, rotate ) + + if state is not None: + partial_outcome = await self._maybe_upload_partial( + processed_image, image_data, refresh_mode, state, diff_strategy + ) + if partial_outcome == "skipped": + _LOGGER.info("Image upload complete (no changes; skipped transfer)") + return processed_image + if partial_outcome == "success": + _LOGGER.info("Image upload complete (partial path)") + return processed_image + # else: fall through to full-upload path; state will be refreshed below. + if compress and supports_compression and compressed_data and len(compressed_data) < MAX_COMPRESSED_SIZE: _LOGGER.info("Using compressed upload protocol (size: %d bytes)", len(compressed_data)) await self._execute_upload( @@ -928,6 +957,8 @@ async def upload_image( ) _LOGGER.info("Image upload complete") + if state is not None: + self._update_partial_state(state, processed_image, image_data) return processed_image async def upload_prepared_image( @@ -936,6 +967,8 @@ async def upload_prepared_image( refresh_mode: RefreshMode = RefreshMode.FULL, compress: bool = True, progress_callback: Callable[[int, int], None] | None = None, + state: PartialState | None = None, + diff_strategy: DiffStrategy | None = None, ) -> None: """Upload pre-computed image data to device. @@ -954,7 +987,18 @@ async def upload_prepared_image( Raises: ProtocolError: If upload fails """ - image_data, compressed_data, _ = prepared_data + image_data, compressed_data, processed_image = prepared_data + + if state is not None: + partial_outcome = await self._maybe_upload_partial( + processed_image, image_data, refresh_mode, state, diff_strategy + ) + if partial_outcome == "skipped": + _LOGGER.info("Prepared image upload complete (no changes; skipped transfer)") + return + if partial_outcome == "success": + _LOGGER.info("Prepared image upload complete (partial path)") + return supports_compression = ( self._config.displays[0].supports_zip if (self._config and self._config.displays) else True @@ -981,6 +1025,156 @@ async def upload_prepared_image( ) _LOGGER.info("Prepared image upload complete") + if state is not None: + self._update_partial_state(state, processed_image, image_data) + + def _update_partial_state( + self, + state: PartialState, + processed_image: Image.Image, + image_data: bytes, + ) -> None: + """After a successful full upload, refresh state to reflect what's now on the panel. + + Generates a fresh non-zero etag, stashes the palette pixels for diffing + on the next call. ``image_data`` is unused but kept for API symmetry. + """ + del image_data + palette_image = processed_image.convert("P") if processed_image.mode != "P" else processed_image + state.etag = _generate_etag() + state.last_image = palette_image.tobytes() + state.width, state.height = processed_image.size + state.bytes_per_pixel = 1 + + async def _maybe_upload_partial( + self, + processed_image: Image.Image, + image_data: bytes, + refresh_mode: RefreshMode, + state: PartialState, + diff_strategy: DiffStrategy | None, + ) -> str: + """Try to perform a partial upload. Return code: + + - "success": partial transfer accepted; state mutated. + - "skipped": no changes detected; nothing sent; state untouched. + - "fallback_full": caller must do a full upload (and refresh state). + """ + del image_data # full encoding is per-segment for partial path + + color_scheme = self.color_scheme + if color_scheme in (ColorScheme.BWR, ColorScheme.BWY): + # Bitplane color schemes are not supported on the partial path yet: + # encode_image() refuses them and per-segment plane extraction is + # not implemented. Force a full upload + state refresh. + _LOGGER.debug("Partial path skipped: color scheme %s requires bitplane encoding", color_scheme.name) + return "fallback_full" + + width, height = processed_image.size + if ( + state.etag == 0 + or state.last_image is None + or state.width != width + or state.height != height + ): + return "fallback_full" + + palette_image = processed_image.convert("P") if processed_image.mode != "P" else processed_image + new_palette = palette_image.tobytes() + old_palette = state.last_image + + if len(old_palette) != len(new_palette): + return "fallback_full" + + chunk_size = ENCRYPTED_CHUNK_SIZE if self._session_key is not None else CHUNK_SIZE + max_segment_pixel_bytes = chunk_size - SEGMENT_HEADER_SIZE + if max_segment_pixel_bytes <= 0: + return "fallback_full" + + strategy: DiffStrategy = diff_strategy or RecursiveBoundingBoxStrategy() + new_segments = strategy.diff(old_palette, new_palette, width, height, 1, max_segment_pixel_bytes) + if not new_segments: + return "skipped" + + # Build (Segment, wire_pixels) pairs for both planes. + # PLANE_0 = new image, PLANE_1 = old image. + old_palette_image = palette_image.copy() + old_palette_image.frombytes(old_palette) + + pairs: list[tuple[Segment, bytes]] = [] + for seg in new_segments: + new_wire = self._encode_segment_wire(palette_image, seg.x, seg.y, seg.width, seg.height, color_scheme) + old_wire = self._encode_segment_wire( + old_palette_image, seg.x, seg.y, seg.width, seg.height, color_scheme + ) + new_seg = Segment(seg.x, seg.y, seg.width, seg.height, b"", plane=0) + old_seg = Segment(seg.x, seg.y, seg.width, seg.height, b"", plane=1) + pairs.append((new_seg, new_wire)) + pairs.append((old_seg, old_wire)) + + packets = pack_segments_into_packets(pairs, mtu=chunk_size) + + new_etag = _generate_etag() + + # 1. 0x76 partial START with protocol version + old_etag + await self._write(build_direct_write_partial_start(state.etag)) + response = await self._read(self.TIMEOUT_ACK) + nack = parse_nack(response) + if nack is not None: + opcode, err = nack + if opcode == 0x76 and err == ERR_ETAG_MISMATCH: + _LOGGER.info("Partial upload: device etag mismatch — falling back to full upload") + state.etag = 0 + state.last_image = None + return "fallback_full" + raise ProtocolError(f"Partial 0x76 NACK: opcode=0x{opcode:02x} err=0x{err:02x}") + validate_ack_response(response, CommandCode.DIRECT_WRITE_PARTIAL_START) + + # 2. 0x77 packets — ACK after each + for pkt in packets: + await self._write(pkt) + ack = await self._read(self.TIMEOUT_ACK) + nack = parse_nack(ack) + if nack is not None: + opcode, err = nack + state.etag = 0 + state.last_image = None + raise ProtocolError(f"Partial 0x77 NACK: opcode=0x{opcode:02x} err=0x{err:02x}") + validate_ack_response(ack, CommandCode.DIRECT_WRITE_PARTIAL_DATA) + + # 3. 0x72 END with new_etag + await self._write(build_direct_write_end_with_etag(refresh_mode.value, new_etag)) + response = await self._read(self.TIMEOUT_ACK) + validate_ack_response(response, CommandCode.DIRECT_WRITE_END) + + # 4. Wait for refresh-complete (0x73 device→host) + response = await self._read(self.TIMEOUT_REFRESH) + command, _ = check_response_type(response) + if command == CommandCode.DIRECT_WRITE_REFRESH_TIMEOUT: + raise ProtocolError("Display refresh timed out (device sent 0x74)") + if command != CommandCode.DIRECT_WRITE_REFRESH_COMPLETE: + raise ProtocolError(f"Unexpected response waiting for refresh: {command.name} (0x{command:04x})") + + # Mutate state in place + state.etag = new_etag + state.last_image = new_palette + state.width = width + state.height = height + state.bytes_per_pixel = 1 + return "success" + + @staticmethod + def _encode_segment_wire( + palette_image: Image.Image, + x: int, + y: int, + w: int, + h: int, + color_scheme: ColorScheme, + ) -> bytes: + """Crop the palette image to (x,y,w,h) and encode to the panel's wire format.""" + cropped = palette_image.crop((x, y, x + w, y + h)) + return encode_image(cropped, color_scheme) async def _execute_upload( self, diff --git a/src/opendisplay/partial.py b/src/opendisplay/partial.py new file mode 100644 index 0000000..22d31f1 --- /dev/null +++ b/src/opendisplay/partial.py @@ -0,0 +1,452 @@ +"""Partial rendering support for OpenDisplay BLE devices. + +Provides PartialState (caller-owned mutable holder), the DiffStrategy protocol, +Segment dataclass, and built-in diff strategies. + +Serialization format: struct header (magic + version + scalar fields) followed +by a 4-byte big-endian length-prefixed ``last_image`` blob. This avoids the +pickle security surface while remaining compact and version-able. + +``last_image`` stores raw palette bytes (1 byte per pixel, from +``PIL.Image.tobytes()`` on the dithered palette image). The diff operates on +these palette bytes; the library re-encodes segments to wire format before +sending 0x77 packets. ``bytes_per_pixel`` is always 1 (palette representation) +for images stored by the library. +""" + +from __future__ import annotations + +import os +import struct +from dataclasses import dataclass +from typing import Protocol + +# --------------------------------------------------------------------------- +# Wire constants (mirrored from firmware feat/partial-rendering) +# --------------------------------------------------------------------------- + +# NACK error codes returned by the device inside {0xFF, opcode, error, 0x00} +ERR_ETAG_MISMATCH = 0x01 # on 0x76: client must fall back to full transfer +ERR_MIXED_DATA = 0x02 # on 0x71 or 0x77: aborted; etag cleared on device +ERR_SEGMENT_OOB = 0x03 # on 0x77: aborted; etag cleared on device +ERR_PARTIAL_VERSION = 0x04 # on 0x76: client protocol version unsupported + +NACK_PREFIX = 0xFF + + +def parse_nack(response: bytes) -> tuple[int, int] | None: + """Return (opcode, error_code) if response is a 4-byte {0xFF, op, err, 0x00} NACK. + + Returns None for any other response shape (caller treats as ACK / handles + via existing validators). + """ + if len(response) == 4 and response[0] == NACK_PREFIX and response[3] == 0x00: + return response[1], response[2] + return None + +# Segment header size in bytes: x(2)+y(2)+w(2)+h(2)+flags(1) = 9 +SEGMENT_HEADER_SIZE = 9 + +# Minimum region size (pixels) below which we stop recursing +_MIN_REGION_PIXELS = 16 + + +# --------------------------------------------------------------------------- +# Segment — geometry + raw palette pixels for one rectangular region +# --------------------------------------------------------------------------- + +@dataclass +class Segment: + """One rectangular region of pixel data with its location. + + ``pixels`` holds raw palette bytes (1 byte per pixel) before wire encoding. + ``plane``: 0 = PLANE_0 (new image), 1 = PLANE_1 (old image). + The library assigns plane values; diff strategies always return plane=0. + """ + + x: int + y: int + width: int + height: int + pixels: bytes # raw palette bytes (1 byte per pixel) + plane: int = 0 # 0 = PLANE_0 (new), 1 = PLANE_1 (old) + + @property + def pixel_count(self) -> int: + return self.width * self.height + + +# --------------------------------------------------------------------------- +# DiffStrategy protocol +# --------------------------------------------------------------------------- + +class DiffStrategy(Protocol): + """Protocol for pluggable diff strategies. + + Implementations receive the old and new raw palette pixel buffers (1 byte + per pixel) and return a list of Segment objects (plane=0, palette bytes). + The library duplicates each segment for PLANE_1 with old-image pixels. + + Returning an empty list means "no changes detected; skip transfer". + """ + + def diff( + self, + old: bytes, + new: bytes, + width: int, + height: int, + bytes_per_pixel: int, + max_segment_bytes: int, + ) -> list[Segment]: ... + + +# --------------------------------------------------------------------------- +# Built-in strategy: FullImageStrategy +# --------------------------------------------------------------------------- + +class FullImageStrategy: + """Kill-switch strategy that always forces a full 0x71 upload. + + ``diff()`` always returns an empty list so the library falls back to a + full-image transfer via the existing 0x71 path. + """ + + def diff( + self, + old: bytes, + new: bytes, + width: int, + height: int, + bytes_per_pixel: int, + max_segment_bytes: int, + ) -> list[Segment]: + return [] + + +# --------------------------------------------------------------------------- +# Built-in strategy: RecursiveBoundingBoxStrategy +# --------------------------------------------------------------------------- + +class RecursiveBoundingBoxStrategy: + """Recursive bounding-box diff strategy (default). + + Algorithm (per §3.3 of the partial-rendering plan): + 1. Compute the minimal bounding box of all changed pixels within the + current region. + 2. If the box's pixel data fits within ``max_segment_bytes``, emit it. + 3. Otherwise split along the longer axis of the bounding box, recompute + each child's minimal bounding box, and recurse. + 4. Stop recursing when the region is below ``min_region_pixels`` pixels + and emit as-is to avoid pathological subdivision. + + Input ``old``/``new`` are raw palette bytes (1 byte per pixel). + ``max_segment_bytes`` is the pixel-data budget per segment (segment header + not included); the library computes this from the active MTU. + """ + + def __init__(self, min_region_pixels: int = _MIN_REGION_PIXELS) -> None: + self._min_region_pixels = min_region_pixels + + # ------------------------------------------------------------------ + # Public entry point + # ------------------------------------------------------------------ + + def diff( + self, + old: bytes, + new: bytes, + width: int, + height: int, + bytes_per_pixel: int, + max_segment_bytes: int, + ) -> list[Segment]: + """Return changed segments (plane=0, raw palette pixels).""" + if old == new: + return [] + + segments: list[Segment] = [] + self._recurse( + old, new, width, height, bytes_per_pixel, + 0, 0, width, height, # initial region = full image + max_segment_bytes, segments, + ) + return segments + + # ------------------------------------------------------------------ + # Internal helpers + # ------------------------------------------------------------------ + + @staticmethod + def _bounding_box( + old: bytes, + new: bytes, + img_width: int, + bytes_per_pixel: int, + rx: int, + ry: int, + rw: int, + rh: int, + ) -> tuple[int, int, int, int] | None: + """Return minimal bounding box (x0, y0, x1_excl, y1_excl) of changed + pixels within region (rx, ry, rw, rh), or None if no changes.""" + min_x = rw + max_x = -1 + min_y = rh + max_y = -1 + + bpp = bytes_per_pixel + for dy in range(rh): + gy = ry + dy + row_changed = False + for dx in range(rw): + gx = rx + dx + old_off = (gy * img_width + gx) * bpp + new_off = old_off + if old[old_off : old_off + bpp] != new[new_off : new_off + bpp]: + if dx < min_x: + min_x = dx + if dx > max_x: + max_x = dx + row_changed = True + if row_changed: + if dy < min_y: + min_y = dy + if dy > max_y: + max_y = dy + + if max_x < 0: + return None + return (rx + min_x, ry + min_y, rx + max_x + 1, ry + max_y + 1) + + @staticmethod + def _extract_region( + buf: bytes, + img_width: int, + bytes_per_pixel: int, + x0: int, + y0: int, + x1: int, + y1: int, + ) -> bytes: + """Extract pixels from buf for rectangle [x0..x1) × [y0..y1).""" + bpp = bytes_per_pixel + row_bytes = (x1 - x0) * bpp + parts: list[bytes] = [] + for y in range(y0, y1): + off = (y * img_width + x0) * bpp + parts.append(buf[off : off + row_bytes]) + return b"".join(parts) + + def _recurse( + self, + old: bytes, + new: bytes, + img_width: int, + img_height: int, + bytes_per_pixel: int, + rx: int, + ry: int, + rw: int, + rh: int, + max_segment_bytes: int, + out: list[Segment], + ) -> None: + """Recursively find changed regions within (rx, ry, rw, rh).""" + if rw <= 0 or rh <= 0: + return + + bb = self._bounding_box(old, new, img_width, bytes_per_pixel, rx, ry, rw, rh) + if bb is None: + return # no changes in this region + + x0, y0, x1, y1 = bb + bw = x1 - x0 + bh = y1 - y0 + pixel_count = bw * bh + data_size = pixel_count * bytes_per_pixel + + if data_size <= max_segment_bytes or pixel_count <= self._min_region_pixels: + # Fits (or too small to split further) — emit as-is + pixels = self._extract_region(new, img_width, bytes_per_pixel, x0, y0, x1, y1) + out.append(Segment(x=x0, y=y0, width=bw, height=bh, pixels=pixels, plane=0)) + return + + # Split along the longer axis of the bounding box + if bw >= bh: + # Split vertically (along x) at midpoint of bounding box + mid = x0 + bw // 2 + # Left half: region from rx to mid + self._recurse(old, new, img_width, img_height, bytes_per_pixel, + rx, ry, mid - rx, rh, max_segment_bytes, out) + # Right half: region from mid to rx+rw + self._recurse(old, new, img_width, img_height, bytes_per_pixel, + mid, ry, rx + rw - mid, rh, max_segment_bytes, out) + else: + # Split horizontally (along y) at midpoint of bounding box + mid = y0 + bh // 2 + # Top half + self._recurse(old, new, img_width, img_height, bytes_per_pixel, + rx, ry, rw, mid - ry, max_segment_bytes, out) + # Bottom half + self._recurse(old, new, img_width, img_height, bytes_per_pixel, + rx, mid, rw, ry + rh - mid, max_segment_bytes, out) + + +# --------------------------------------------------------------------------- +# Wire-format segment packing +# --------------------------------------------------------------------------- + +def _build_segment_wire(seg: Segment, wire_pixels: bytes) -> bytes: + """Encode one segment to its 0x77 wire representation. + + Uses *wire_pixels* rather than *seg.pixels* (which are palette bytes); + the caller is responsible for encoding palette bytes → wire format. + + Wire format per segment: x(2BE) y(2BE) w(2BE) h(2BE) flags(1) pixels(N) + """ + header = struct.pack(">HHHHB", seg.x, seg.y, seg.width, seg.height, seg.plane & 0x01) + return header + wire_pixels + + +def pack_segments_into_packets( + segments: list[tuple[Segment, bytes]], + mtu: int, + cmd_prefix: bytes = b"\x00\x77", +) -> list[bytes]: + """Pack (segment, wire_pixels) pairs into 0x77 BLE packets. + + Uses space-filling (greedy largest-first) packing: + - Sort by wire-pixel size descending. + - For each packet, greedily pick the largest remaining segment that fits, + then continue with smaller ones until no segment fits. + - All input segments appear exactly once across all returned packets. + + Args: + segments: List of (Segment, wire_pixels) where wire_pixels is the + encoded pixel data for that segment. + mtu: Maximum total packet size in bytes (including cmd_prefix). + cmd_prefix: 2-byte opcode prefix (default 0x0077 big-endian). + + Returns: + List of complete BLE packet bytes. + """ + if not segments: + return [] + + max_payload = mtu - len(cmd_prefix) + + # Pre-compute wire representation for each (segment, wire_pixels) + wires: list[bytes] = [_build_segment_wire(seg, wp) for seg, wp in segments] + sizes: list[int] = [len(w) for w in wires] + + # Sort indices by wire size descending (largest first) + order = sorted(range(len(wires)), key=lambda i: sizes[i], reverse=True) + remaining: list[int] = list(order) + packets: list[bytes] = [] + + while remaining: + packet_parts: list[bytes] = [] + space = max_payload + still_remaining: list[int] = [] + + for idx in remaining: + w = wires[idx] + if len(w) <= space: + packet_parts.append(w) + space -= len(w) + else: + still_remaining.append(idx) + + if not packet_parts: + # Segment alone is larger than payload — emit it alone to avoid + # an infinite loop. Upper layer should not produce such segments. + idx = remaining[0] + packets.append(cmd_prefix + wires[idx]) + remaining = remaining[1:] + continue + + packets.append(cmd_prefix + b"".join(packet_parts)) + remaining = still_remaining + + return packets + + +# --------------------------------------------------------------------------- +# Etag helpers +# --------------------------------------------------------------------------- + +def _generate_etag() -> int: + """Generate a random non-zero 32-bit etag.""" + while True: + value = int.from_bytes(os.urandom(4), "big") + if value != 0: + return value + + +# --------------------------------------------------------------------------- +# PartialState +# --------------------------------------------------------------------------- + +# Serialization header: b"PDST" magic(4) + version(B) + etag(4BE) + width(4LE) +# + height(4LE) + bpp(4LE) +# Followed by: img_len(4BE) + img_bytes(img_len) +_MAGIC = b"PDST" +_VERSION = 1 +_HEADER_FMT = ">4sBIIII" # magic(4s), version(B), etag(I BE), width(I), height(I), bpp(I) +_HEADER_SIZE = struct.calcsize(_HEADER_FMT) # 4+1+4+4+4+4 = 21 bytes + + +@dataclass +class PartialState: + """Mutable, opaque state tracked by the caller across partial updates. + + Treat fields as opaque; persist only via ``to_bytes`` / ``from_bytes``. + + ``last_image`` holds raw palette bytes (1 byte per pixel) from + ``PIL.Image.tobytes()`` on the dithered palette image. + ``bytes_per_pixel`` is always 1 for library-populated instances. + """ + + etag: int = 0 # last new_etag successfully sent (0 = unknown) + last_image: bytes | None = None # raw palette pixel buffer matching etag + width: int = 0 + height: int = 0 + bytes_per_pixel: int = 0 + + # ------------------------------------------------------------------ + # Serialization + # ------------------------------------------------------------------ + + def to_bytes(self) -> bytes: + """Serialize state to bytes (struct header + length-prefixed image).""" + img = self.last_image or b"" + header = struct.pack( + _HEADER_FMT, + _MAGIC, + _VERSION, + self.etag, + self.width, + self.height, + self.bytes_per_pixel, + ) + img_len_bytes = struct.pack(">I", len(img)) + return header + img_len_bytes + img + + @classmethod + def from_bytes(cls, data: bytes) -> "PartialState": + """Deserialize state from bytes produced by ``to_bytes``.""" + min_len = _HEADER_SIZE + 4 # header + img_len field + if len(data) < min_len: + raise ValueError(f"PartialState data too short: {len(data)} bytes (need {min_len})") + magic, version, etag, width, height, bpp = struct.unpack_from(_HEADER_FMT, data, 0) + if magic != _MAGIC: + raise ValueError(f"PartialState magic mismatch: {magic!r}") + if version != _VERSION: + raise ValueError(f"PartialState version unsupported: {version}") + (img_len,) = struct.unpack_from(">I", data, _HEADER_SIZE) + offset = _HEADER_SIZE + 4 + if len(data) < offset + img_len: + raise ValueError("PartialState image data truncated") + img: bytes | None = data[offset : offset + img_len] if img_len > 0 else None + return cls(etag=etag, last_image=img, width=width, height=height, bytes_per_pixel=bpp) diff --git a/src/opendisplay/protocol/__init__.py b/src/opendisplay/protocol/__init__.py index fbc6361..d983c99 100644 --- a/src/opendisplay/protocol/__init__.py +++ b/src/opendisplay/protocol/__init__.py @@ -12,9 +12,12 @@ build_authenticate_step2, build_direct_write_data_command, build_direct_write_end_command, + build_direct_write_end_with_etag, + build_direct_write_partial_start, build_direct_write_start_compressed, build_direct_write_start_uncompressed, build_led_activate_command, + build_partial_data_packet, build_read_config_command, build_read_fw_version_command, build_reboot_command, @@ -46,8 +49,11 @@ "build_write_config_command", "build_direct_write_start_compressed", "build_direct_write_start_uncompressed", + "build_direct_write_partial_start", "build_direct_write_data_command", "build_direct_write_end_command", + "build_direct_write_end_with_etag", + "build_partial_data_packet", "build_led_activate_command", "parse_config_response", "serialize_config", diff --git a/src/opendisplay/protocol/commands.py b/src/opendisplay/protocol/commands.py index c6ced3a..6eb242a 100644 --- a/src/opendisplay/protocol/commands.py +++ b/src/opendisplay/protocol/commands.py @@ -31,6 +31,8 @@ class CommandCode(IntEnum): 0x0073 # Device→host: refresh finished (same code as LED_ACTIVATE, different direction) ) DIRECT_WRITE_REFRESH_TIMEOUT = 0x0074 # Device→host: refresh timed out + DIRECT_WRITE_PARTIAL_START = 0x0076 # Start a versioned partial update transfer + DIRECT_WRITE_PARTIAL_DATA = 0x0077 # Send partial image segments # Protocol constants @@ -137,6 +139,19 @@ def build_direct_write_start_uncompressed() -> bytes: return CommandCode.DIRECT_WRITE_START.to_bytes(2, byteorder="big") +def build_direct_write_partial_start(old_etag: int, version: int = 1) -> bytes: + """Build 0x76 partial START with protocol version and old_etag. + + Wire v1: [0x0076][version:1][old_etag:4 BE] + """ + if not 0 <= version <= 0xFF: + raise ValueError(f"partial protocol version out of uint8 range: {version}") + if not 0 <= old_etag <= 0xFFFFFFFF: + raise ValueError(f"old_etag out of uint32 range: {old_etag}") + cmd = CommandCode.DIRECT_WRITE_PARTIAL_START.to_bytes(2, byteorder="big") + return cmd + version.to_bytes(1, byteorder="big") + old_etag.to_bytes(4, byteorder="big") + + def build_direct_write_data_command(chunk_data: bytes) -> bytes: """Build command to send image data chunk. @@ -179,6 +194,20 @@ def build_direct_write_end_command(refresh_mode: int = 0) -> bytes: return cmd + refresh +def build_direct_write_end_with_etag(refresh_mode: int, new_etag: int) -> bytes: + """Build 0x72 END with a new_etag tail. Etag presence is by length only.""" + if not 0 <= new_etag <= 0xFFFFFFFF: + raise ValueError(f"new_etag out of uint32 range: {new_etag}") + cmd = CommandCode.DIRECT_WRITE_END.to_bytes(2, byteorder="big") + return cmd + refresh_mode.to_bytes(1, byteorder="big") + new_etag.to_bytes(4, byteorder="big") + + +def build_partial_data_packet(payload: bytes) -> bytes: + """Wrap a packed segment payload in the 0x77 opcode prefix.""" + cmd = CommandCode.DIRECT_WRITE_PARTIAL_DATA.to_bytes(2, byteorder="big") + return cmd + payload + + def build_led_activate_command( led_instance: int, flash_config: LedFlashConfig, diff --git a/tests/unit/test_partial.py b/tests/unit/test_partial.py new file mode 100644 index 0000000..4ecf725 --- /dev/null +++ b/tests/unit/test_partial.py @@ -0,0 +1,192 @@ +"""Unit tests for partial-rendering module (PartialState, diff, packer, NACK).""" + +from __future__ import annotations + +import pytest + +from opendisplay.partial import ( + ERR_ETAG_MISMATCH, + ERR_MIXED_DATA, + ERR_PARTIAL_VERSION, + ERR_SEGMENT_OOB, + SEGMENT_HEADER_SIZE, + FullImageStrategy, + PartialState, + RecursiveBoundingBoxStrategy, + Segment, + _generate_etag, + pack_segments_into_packets, + parse_nack, +) +from opendisplay.protocol.commands import ( + CHUNK_SIZE, + ENCRYPTED_CHUNK_SIZE, + build_direct_write_end_with_etag, + build_direct_write_partial_start, + build_partial_data_packet, +) + + +class TestPartialState: + def test_roundtrip_empty(self): + s = PartialState() + out = PartialState.from_bytes(s.to_bytes()) + assert out == s + + def test_roundtrip_populated(self): + s = PartialState( + etag=0xDEADBEEF, + last_image=bytes(range(256)) * 4, + width=480, + height=800, + bytes_per_pixel=1, + ) + out = PartialState.from_bytes(s.to_bytes()) + assert out.etag == s.etag + assert out.last_image == s.last_image + assert out.width == s.width + assert out.height == s.height + assert out.bytes_per_pixel == s.bytes_per_pixel + + def test_bad_magic_rejected(self): + with pytest.raises(ValueError, match="magic"): + PartialState.from_bytes(b"XXXX" + b"\x00" * 21) + + def test_truncated_rejected(self): + with pytest.raises(ValueError): + PartialState.from_bytes(b"PDST") + + +class TestParseNack: + def test_etag_mismatch(self): + assert parse_nack(b"\xff\x76\x01\x00") == (0x76, ERR_ETAG_MISMATCH) + + def test_mixed_data_on_partial(self): + assert parse_nack(b"\xff\x77\x02\x00") == (0x77, ERR_MIXED_DATA) + + def test_oob(self): + assert parse_nack(b"\xff\x77\x03\x00") == (0x77, ERR_SEGMENT_OOB) + + def test_bad_version(self): + assert parse_nack(b"\xff\x76\x04\x00") == (0x76, ERR_PARTIAL_VERSION) + + def test_not_nack_returns_none(self): + assert parse_nack(b"\x00\x76") is None + assert parse_nack(b"\xff\x70\x01") is None # too short + assert parse_nack(b"\xff\x70\x01\x00\x00") is None # too long + assert parse_nack(b"\xff\x70\x01\x01") is None # last byte not 0 + + +class TestGenerateEtag: + def test_nonzero_uint32(self): + for _ in range(100): + v = _generate_etag() + assert 1 <= v <= 0xFFFFFFFF + + +class TestRecursiveBoundingBoxStrategy: + def test_no_change_returns_empty(self): + buf = bytes(100 * 100) + segs = RecursiveBoundingBoxStrategy().diff(buf, buf, 100, 100, 1, 4096) + assert segs == [] + + def test_single_pixel_change(self): + old = bytearray(100 * 100) + new = bytearray(old) + new[50 * 100 + 30] = 1 + segs = RecursiveBoundingBoxStrategy().diff(bytes(old), bytes(new), 100, 100, 1, 4096) + assert len(segs) == 1 + s = segs[0] + assert s.x == 30 and s.y == 50 + assert s.width == 1 and s.height == 1 + assert s.pixels == b"\x01" + assert s.plane == 0 + + def test_full_change_tiles_image(self): + # 64x64 with every pixel different; budget 256 bytes + # → recursion must split until each tile fits. + w, h = 64, 64 + old = bytes(w * h) + new = bytes([1] * (w * h)) + segs = RecursiveBoundingBoxStrategy(min_region_pixels=4).diff( + old, new, w, h, 1, max_segment_bytes=256 + ) + # All segments must be within image bounds and fit budget + for s in segs: + assert s.x >= 0 and s.y >= 0 + assert s.x + s.width <= w + assert s.y + s.height <= h + assert len(s.pixels) <= 256 or s.width * s.height <= 4 + # Coverage check: union of segments covers every changed pixel + covered = bytearray(w * h) + for s in segs: + for dy in range(s.height): + for dx in range(s.width): + covered[(s.y + dy) * w + (s.x + dx)] = 1 + assert all(b == 1 for b in covered) + + def test_respects_chunk_size_budget(self): + # Confirm segments respect both unencrypted and encrypted MTU minus header. + w, h = 32, 32 + old = bytes(w * h) + new = bytes([1] * (w * h)) + for chunk in (CHUNK_SIZE, ENCRYPTED_CHUNK_SIZE): + budget = chunk - SEGMENT_HEADER_SIZE + segs = RecursiveBoundingBoxStrategy(min_region_pixels=1).diff( + old, new, w, h, 1, max_segment_bytes=budget + ) + for s in segs: + # tiny regions allowed to exceed via min_region_pixels guard, + # but here min_region_pixels=1 so they must all fit. + assert s.width * s.height <= budget + + +class TestFullImageStrategy: + def test_always_empty(self): + old = bytes(100) + new = bytes([1] * 100) + assert FullImageStrategy().diff(old, new, 10, 10, 1, 4096) == [] + + +class TestPackSegmentsIntoPackets: + @staticmethod + def _seg(x, y, w, h, n): + return Segment(x=x, y=y, width=w, height=h, pixels=bytes(n), plane=0), bytes([0xAA] * n) + + def test_empty(self): + assert pack_segments_into_packets([], mtu=230) == [] + + def test_each_packet_within_mtu(self): + pairs = [self._seg(0, 0, 10, 10, 100) for _ in range(5)] + pairs += [self._seg(0, 0, 5, 5, 25) for _ in range(10)] + packets = pack_segments_into_packets(pairs, mtu=230) + for p in packets: + assert len(p) <= 230 + assert p[:2] == b"\x00\x77" + + def test_every_segment_appears_once(self): + # Use unique pixel sentinels so we can detect duplicates / drops + pairs = [] + for i in range(20): + seg = Segment(x=i, y=0, width=4, height=4, pixels=b"", plane=0) + wire = bytes([i] * 30) + pairs.append((seg, wire)) + packets = pack_segments_into_packets(pairs, mtu=230) + # Sum of all bytes after 0x0077 prefix == sum of all wire+header bytes + total_payload = sum(len(p) - 2 for p in packets) + # 9-byte header per segment + 30 byte payload = 39 bytes per segment, 20 segments + assert total_payload == 20 * (SEGMENT_HEADER_SIZE + 30) + + +class TestNewBuilders: + def test_partial_start(self): + cmd = build_direct_write_partial_start(0xDEADBEEF) + assert cmd == b"\x00\x76\x01\xde\xad\xbe\xef" + + def test_end_with_etag(self): + cmd = build_direct_write_end_with_etag(refresh_mode=0, new_etag=0x01020304) + assert cmd == b"\x00\x72\x00\x01\x02\x03\x04" + + def test_partial_data_packet(self): + cmd = build_partial_data_packet(b"\x01\x02\x03") + assert cmd == b"\x00\x77\x01\x02\x03" From ee4159b3db4a682a98b9fccb76afdf74ca0b4edf Mon Sep 17 00:00:00 2001 From: Michael Bisbjerg Date: Sun, 26 Apr 2026 00:14:28 +0200 Subject: [PATCH 02/18] Remove temporary doc references from partial comments --- src/opendisplay/partial.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/opendisplay/partial.py b/src/opendisplay/partial.py index 22d31f1..e0e8bed 100644 --- a/src/opendisplay/partial.py +++ b/src/opendisplay/partial.py @@ -22,7 +22,7 @@ from typing import Protocol # --------------------------------------------------------------------------- -# Wire constants (mirrored from firmware feat/partial-rendering) +# Wire constants mirrored from the firmware partial-rendering protocol. # --------------------------------------------------------------------------- # NACK error codes returned by the device inside {0xFF, opcode, error, 0x00} @@ -131,7 +131,7 @@ def diff( class RecursiveBoundingBoxStrategy: """Recursive bounding-box diff strategy (default). - Algorithm (per §3.3 of the partial-rendering plan): + Algorithm: 1. Compute the minimal bounding box of all changed pixels within the current region. 2. If the box's pixel data fits within ``max_segment_bytes``, emit it. From 59fc7888db96685bf4b516aca5fa4a1ce6c5351c Mon Sep 17 00:00:00 2001 From: Michael Bisbjerg Date: Sun, 26 Apr 2026 00:50:11 +0200 Subject: [PATCH 03/18] Add CLI state-file support for partial uploads --- src/opendisplay/cli.py | 38 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/src/opendisplay/cli.py b/src/opendisplay/cli.py index ca5bc3f..6fb08cb 100644 --- a/src/opendisplay/cli.py +++ b/src/opendisplay/cli.py @@ -6,7 +6,9 @@ import asyncio import json import logging +import os import sys +from pathlib import Path from collections.abc import Coroutine from typing import Any, NoReturn, TypeVar @@ -21,6 +23,7 @@ from .battery import voltage_to_percent from .device import OpenDisplayDevice +from .partial import PartialState from .discovery import discover_devices_with_adv from .exceptions import ( AuthenticationFailedError, @@ -472,6 +475,25 @@ async def _info(device_kwargs: dict[str, Any], output_json: bool) -> None: # ── upload ──────────────────────────────────────────────────────────────────── +def _load_partial_state(path: str) -> PartialState: + """Load PartialState from path, or return a fresh one if the file is absent.""" + p = Path(path) + if not p.exists(): + return PartialState() + try: + return PartialState.from_bytes(p.read_bytes()) + except (OSError, ValueError) as exc: + _error(f"Failed to load --state-file {path}: {exc}") + + +def _save_partial_state(path: str, state: PartialState) -> None: + """Atomically write PartialState to path (write to .tmp then rename).""" + p = Path(path) + tmp = p.with_suffix(p.suffix + ".tmp") + tmp.write_bytes(state.to_bytes()) + os.replace(tmp, p) + + def _add_upload_parser(subparsers: argparse._SubParsersAction[argparse.ArgumentParser]) -> None: p = subparsers.add_parser("upload", help="Upload an image to the device") _add_device_options(p) @@ -507,6 +529,14 @@ def _add_upload_parser(subparsers: argparse._SubParsersAction[argparse.ArgumentP metavar="VALUE", help='Dynamic range compression: "auto" or 0.0–1.0 (default: auto)', ) + p.add_argument( + "--state-file", + metavar="PATH", + default=None, + help="Persistent partial-rendering state file. If the file exists, it's loaded and " + "the upload attempts a partial transfer; on success the file is rewritten. If it " + "does not exist, a fresh state is created (forcing a full upload first time).", + ) p.set_defaults(func=_cmd_upload) @@ -523,6 +553,7 @@ def _cmd_upload(args: argparse.Namespace) -> None: _ROTATE_CHOICES[args.rotate], not args.no_compress, tone, + args.state_file, ) ) @@ -536,6 +567,7 @@ async def _upload( rotate: Rotation, compress: bool, tone_compression: float | str, + state_file: str | None, ) -> None: try: image = Image.open(image_path) @@ -583,6 +615,8 @@ def on_progress(sent: int, total: int) -> None: spinner_progress.update(upload_task, visible=False) spinner_progress.update(refresh_task, visible=True) + state = _load_partial_state(state_file) if state_file else None + await device.upload_image( image, refresh_mode=refresh_mode, @@ -592,8 +626,12 @@ def on_progress(sent: int, total: int) -> None: fit=fit, rotate=rotate, progress_callback=on_progress, + state=state, ) + if state_file and state is not None: + _save_partial_state(state_file, state) + spinner_progress.update(refresh_task, visible=False) spinner_progress.update(upload_task, visible=True, description="[green]Done.[/green]", total=1, completed=1) except OpenDisplayError as exc: From c85453eff29e42fdce1e06c0584a99050e9e9e18 Mon Sep 17 00:00:00 2001 From: Michael Bisbjerg Date: Sun, 26 Apr 2026 00:55:49 +0200 Subject: [PATCH 04/18] Force full upload when partial state matches target --- src/opendisplay/device.py | 15 ++++++--------- 1 file changed, 6 insertions(+), 9 deletions(-) diff --git a/src/opendisplay/device.py b/src/opendisplay/device.py index eeedfb2..865cabd 100644 --- a/src/opendisplay/device.py +++ b/src/opendisplay/device.py @@ -927,13 +927,11 @@ async def upload_image( partial_outcome = await self._maybe_upload_partial( processed_image, image_data, refresh_mode, state, diff_strategy ) - if partial_outcome == "skipped": - _LOGGER.info("Image upload complete (no changes; skipped transfer)") - return processed_image if partial_outcome == "success": _LOGGER.info("Image upload complete (partial path)") return processed_image - # else: fall through to full-upload path; state will be refreshed below. + if partial_outcome == "fallback_full": + _LOGGER.info("Partial path unavailable or unnecessary; continuing with full upload") if compress and supports_compression and compressed_data and len(compressed_data) < MAX_COMPRESSED_SIZE: _LOGGER.info("Using compressed upload protocol (size: %d bytes)", len(compressed_data)) @@ -993,12 +991,11 @@ async def upload_prepared_image( partial_outcome = await self._maybe_upload_partial( processed_image, image_data, refresh_mode, state, diff_strategy ) - if partial_outcome == "skipped": - _LOGGER.info("Prepared image upload complete (no changes; skipped transfer)") - return if partial_outcome == "success": _LOGGER.info("Prepared image upload complete (partial path)") return + if partial_outcome == "fallback_full": + _LOGGER.info("Partial prepared upload unavailable or unnecessary; continuing with full upload") supports_compression = ( self._config.displays[0].supports_zip if (self._config and self._config.displays) else True @@ -1057,7 +1054,6 @@ async def _maybe_upload_partial( """Try to perform a partial upload. Return code: - "success": partial transfer accepted; state mutated. - - "skipped": no changes detected; nothing sent; state untouched. - "fallback_full": caller must do a full upload (and refresh state). """ del image_data # full encoding is per-segment for partial path @@ -1094,7 +1090,8 @@ async def _maybe_upload_partial( strategy: DiffStrategy = diff_strategy or RecursiveBoundingBoxStrategy() new_segments = strategy.diff(old_palette, new_palette, width, height, 1, max_segment_pixel_bytes) if not new_segments: - return "skipped" + _LOGGER.debug("Partial path: local state already matches target image; forcing full upload to resync") + return "fallback_full" # Build (Segment, wire_pixels) pairs for both planes. # PLANE_0 = new image, PLANE_1 = old image. From 82ade7343319a9a67a758c35b42047486a62040c Mon Sep 17 00:00:00 2001 From: Michael Bisbjerg Date: Mon, 27 Apr 2026 00:49:24 +0200 Subject: [PATCH 05/18] Add compressed partial update segments --- README.md | 3 + docs/partial-update-protocol.md | 50 +++++++++ src/opendisplay/device.py | 179 ++++++++++++++++++++++++++++---- src/opendisplay/models/enums.py | 5 +- src/opendisplay/partial.py | 143 +++++++++++++++++-------- tests/unit/test_partial.py | 140 ++++++++++++++++++++++++- 6 files changed, 451 insertions(+), 69 deletions(-) create mode 100644 docs/partial-update-protocol.md diff --git a/README.md b/README.md index 538ff18..4df72cb 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,9 @@ Python library for communicating with OpenDisplay BLE e-paper displays. +Partial update wire details are documented in +[docs/partial-update-protocol.md](docs/partial-update-protocol.md). + ## Installation ```bash diff --git a/docs/partial-update-protocol.md b/docs/partial-update-protocol.md new file mode 100644 index 0000000..171dfcd --- /dev/null +++ b/docs/partial-update-protocol.md @@ -0,0 +1,50 @@ +# Partial Update Protocol + +Partial updates use the direct-write command family with two additional +messages. The current partial protocol version is `1`. + +## `0x76` Partial Start + +```text +[0x0076][version:1][old_etag:4 BE] +``` + +The device ACKs with `0x0076` when `old_etag` matches the image currently on +the panel. A NACK `ff 76 01 00` means the client must fall back to a full +upload. + +## `0x77` Partial Data + +Each `0x77` packet contains one or more complete segments: + +```text +[0x0077][segment...] + +segment: +x:u16BE y:u16BE width:u16BE height:u16BE flags:u8 payload:N +``` + +The geometry implies the uncompressed payload size from the active display +encoding. Segments must have `x` and `width` aligned to 8 pixels. + +Segment flags: + +```text +bit 0: plane select, 0 = PLANE_0/new image, 1 = PLANE_1/old image +bit 1: payload is one complete zlib stream +bits 2-7: reserved, must be 0 +``` + +When bit 1 is clear, `payload` is the raw packed segment bytes. When bit 1 is +set, `payload` is a zlib-compressed stream whose decompressed size must exactly +match the size implied by the segment geometry. + +Known partial NACK error codes: + +```text +0x01: etag mismatch on 0x76 +0x02: mixed full/partial data in one transfer +0x03: invalid segment, out of bounds segment, truncated segment, or malformed compressed payload +0x04: unsupported partial protocol version +0x05: segment x/width alignment error +``` diff --git a/src/opendisplay/device.py b/src/opendisplay/device.py index 865cabd..2d7a01d 100644 --- a/src/opendisplay/device.py +++ b/src/opendisplay/device.py @@ -4,6 +4,7 @@ import logging import time +import zlib from collections.abc import Callable from typing import TYPE_CHECKING @@ -933,6 +934,8 @@ async def upload_image( if partial_outcome == "fallback_full": _LOGGER.info("Partial path unavailable or unnecessary; continuing with full upload") + full_upload_etag = _generate_etag() if state is not None else None + if compress and supports_compression and compressed_data and len(compressed_data) < MAX_COMPRESSED_SIZE: _LOGGER.info("Using compressed upload protocol (size: %d bytes)", len(compressed_data)) await self._execute_upload( @@ -942,6 +945,7 @@ async def upload_image( compressed_data=compressed_data, uncompressed_size=len(image_data), progress_callback=progress_callback, + new_etag=full_upload_etag, ) else: if compress and not supports_compression: @@ -951,12 +955,16 @@ async def upload_image( else: _LOGGER.info("Compression disabled or no compressed data, using uncompressed protocol") await self._execute_upload( - image_data, refresh_mode, use_compression=False, progress_callback=progress_callback + image_data, + refresh_mode, + use_compression=False, + progress_callback=progress_callback, + new_etag=full_upload_etag, ) _LOGGER.info("Image upload complete") if state is not None: - self._update_partial_state(state, processed_image, image_data) + self._update_partial_state(state, processed_image, image_data, full_upload_etag) return processed_image async def upload_prepared_image( @@ -1000,6 +1008,8 @@ async def upload_prepared_image( supports_compression = ( self._config.displays[0].supports_zip if (self._config and self._config.displays) else True ) + full_upload_etag = _generate_etag() if state is not None else None + if compress and supports_compression and compressed_data and len(compressed_data) < MAX_COMPRESSED_SIZE: _LOGGER.info("Using compressed upload protocol (size: %d bytes)", len(compressed_data)) await self._execute_upload( @@ -1009,6 +1019,7 @@ async def upload_prepared_image( compressed_data=compressed_data, uncompressed_size=len(image_data), progress_callback=progress_callback, + new_etag=full_upload_etag, ) else: if compress and not supports_compression: @@ -1018,27 +1029,33 @@ async def upload_prepared_image( else: _LOGGER.info("Compression disabled or no compressed data, using uncompressed protocol") await self._execute_upload( - image_data, refresh_mode, use_compression=False, progress_callback=progress_callback + image_data, + refresh_mode, + use_compression=False, + progress_callback=progress_callback, + new_etag=full_upload_etag, ) _LOGGER.info("Prepared image upload complete") if state is not None: - self._update_partial_state(state, processed_image, image_data) + self._update_partial_state(state, processed_image, image_data, full_upload_etag) def _update_partial_state( self, state: PartialState, processed_image: Image.Image, image_data: bytes, + etag: int | None = None, ) -> None: """After a successful full upload, refresh state to reflect what's now on the panel. - Generates a fresh non-zero etag, stashes the palette pixels for diffing - on the next call. ``image_data`` is unused but kept for API symmetry. + Stores the etag committed to the device on 0x72 (or generates one if + absent), then stashes the palette pixels for diffing on the next call. + ``image_data`` is unused but kept for API symmetry. """ del image_data palette_image = processed_image.convert("P") if processed_image.mode != "P" else processed_image - state.etag = _generate_etag() + state.etag = _generate_etag() if etag is None else etag state.last_image = palette_image.tobytes() state.width, state.height = processed_image.size state.bytes_per_pixel = 1 @@ -1057,6 +1074,7 @@ async def _maybe_upload_partial( - "fallback_full": caller must do a full upload (and refresh state). """ del image_data # full encoding is per-segment for partial path + del refresh_mode # Partial transfers always request the panel's PARTIAL refresh mode. color_scheme = self.color_scheme if color_scheme in (ColorScheme.BWR, ColorScheme.BWY): @@ -1083,35 +1101,96 @@ async def _maybe_upload_partial( return "fallback_full" chunk_size = ENCRYPTED_CHUNK_SIZE if self._session_key is not None else CHUNK_SIZE - max_segment_pixel_bytes = chunk_size - SEGMENT_HEADER_SIZE - if max_segment_pixel_bytes <= 0: + max_segment_wire_bytes = chunk_size - 2 + if max_segment_wire_bytes <= SEGMENT_HEADER_SIZE: return "fallback_full" strategy: DiffStrategy = diff_strategy or RecursiveBoundingBoxStrategy() - new_segments = strategy.diff(old_palette, new_palette, width, height, 1, max_segment_pixel_bytes) + max_raw_segment_bytes = max_segment_wire_bytes - SEGMENT_HEADER_SIZE + + old_palette_image = palette_image.copy() + old_palette_image.frombytes(old_palette) + + def segment_fits(seg: Segment) -> bool: + new_wire = self._encode_segment_wire(palette_image, seg.x, seg.y, seg.width, seg.height, color_scheme) + old_wire = self._encode_segment_wire( + old_palette_image, seg.x, seg.y, seg.width, seg.height, color_scheme + ) + return ( + self._partial_payload_fits(new_wire, max_segment_wire_bytes) + and self._partial_payload_fits(old_wire, max_segment_wire_bytes) + ) + + if isinstance(strategy, RecursiveBoundingBoxStrategy): + new_segments = strategy.diff_with_fit(old_palette, new_palette, width, height, 1, segment_fits) + else: + new_segments = strategy.diff(old_palette, new_palette, width, height, 1, max_raw_segment_bytes) + _LOGGER.debug( + "Partial path diff: old_etag=0x%08x, image=%dx%d, max_segment_wire_bytes=%d, changed_segments=%d", + state.etag, + width, + height, + max_segment_wire_bytes, + len(new_segments), + ) if not new_segments: _LOGGER.debug("Partial path: local state already matches target image; forcing full upload to resync") return "fallback_full" + for i, seg in enumerate(new_segments[:8]): + _LOGGER.debug( + "Partial segment %d: x=%d y=%d w=%d h=%d pixels=%d", + i, + seg.x, + seg.y, + seg.width, + seg.height, + seg.pixel_count, + ) + if len(new_segments) > 8: + _LOGGER.debug("Partial segment list truncated in logs (%d additional segments)", len(new_segments) - 8) + # Build (Segment, wire_pixels) pairs for both planes. # PLANE_0 = new image, PLANE_1 = old image. - old_palette_image = palette_image.copy() - old_palette_image.frombytes(old_palette) - pairs: list[tuple[Segment, bytes]] = [] + total_wire_bytes = 0 + compressed_pairs = 0 for seg in new_segments: new_wire = self._encode_segment_wire(palette_image, seg.x, seg.y, seg.width, seg.height, color_scheme) old_wire = self._encode_segment_wire( old_palette_image, seg.x, seg.y, seg.width, seg.height, color_scheme ) - new_seg = Segment(seg.x, seg.y, seg.width, seg.height, b"", plane=0) - old_seg = Segment(seg.x, seg.y, seg.width, seg.height, b"", plane=1) - pairs.append((new_seg, new_wire)) - pairs.append((old_seg, old_wire)) + new_payload, new_compressed = self._choose_partial_payload(new_wire, max_segment_wire_bytes) + old_payload, old_compressed = self._choose_partial_payload(old_wire, max_segment_wire_bytes) + if ( + SEGMENT_HEADER_SIZE + len(new_payload) > max_segment_wire_bytes + or SEGMENT_HEADER_SIZE + len(old_payload) > max_segment_wire_bytes + ): + _LOGGER.debug("Partial path skipped: custom diff produced a segment larger than the active MTU") + return "fallback_full" + new_seg = Segment(seg.x, seg.y, seg.width, seg.height, b"", plane=0, compressed=new_compressed) + old_seg = Segment(seg.x, seg.y, seg.width, seg.height, b"", plane=1, compressed=old_compressed) + pairs.append((new_seg, new_payload)) + pairs.append((old_seg, old_payload)) + total_wire_bytes += len(new_payload) + len(old_payload) + compressed_pairs += int(new_compressed) + int(old_compressed) packets = pack_segments_into_packets(pairs, mtu=chunk_size) + _LOGGER.debug( + "Partial packetization: plane_pairs=%d, compressed_pairs=%d, total_payload_bytes=%d, packet_count=%d, mtu=%d", + len(pairs), + compressed_pairs, + total_wire_bytes, + len(packets), + chunk_size, + ) + for i, pkt in enumerate(packets[:8]): + _LOGGER.debug("Partial packet %d: payload_bytes=%d", i, len(pkt) - 2) + if len(packets) > 8: + _LOGGER.debug("Partial packet list truncated in logs (%d additional packets)", len(packets) - 8) new_etag = _generate_etag() + _LOGGER.debug("Partial upload start: old_etag=0x%08x new_etag=0x%08x", state.etag, new_etag) # 1. 0x76 partial START with protocol version + old_etag await self._write(build_direct_write_partial_start(state.etag)) @@ -1128,7 +1207,8 @@ async def _maybe_upload_partial( validate_ack_response(response, CommandCode.DIRECT_WRITE_PARTIAL_START) # 2. 0x77 packets — ACK after each - for pkt in packets: + for i, pkt in enumerate(packets): + _LOGGER.debug("Sending partial packet %d/%d (%d bytes total)", i + 1, len(packets), len(pkt)) await self._write(pkt) ack = await self._read(self.TIMEOUT_ACK) nack = parse_nack(ack) @@ -1136,11 +1216,12 @@ async def _maybe_upload_partial( opcode, err = nack state.etag = 0 state.last_image = None + _LOGGER.debug("Partial packet %d NACK: opcode=0x%02x err=0x%02x", i + 1, opcode, err) raise ProtocolError(f"Partial 0x77 NACK: opcode=0x{opcode:02x} err=0x{err:02x}") validate_ack_response(ack, CommandCode.DIRECT_WRITE_PARTIAL_DATA) # 3. 0x72 END with new_etag - await self._write(build_direct_write_end_with_etag(refresh_mode.value, new_etag)) + await self._write(build_direct_write_end_with_etag(RefreshMode.PARTIAL.value, new_etag)) response = await self._read(self.TIMEOUT_ACK) validate_ack_response(response, CommandCode.DIRECT_WRITE_END) @@ -1160,6 +1241,22 @@ async def _maybe_upload_partial( state.bytes_per_pixel = 1 return "success" + @staticmethod + def _partial_payload_fits(wire_pixels: bytes, max_segment_wire_bytes: int) -> bool: + """Return whether raw or compressed 0x77 segment payload fits one packet.""" + if SEGMENT_HEADER_SIZE + len(wire_pixels) <= max_segment_wire_bytes: + return True + compressed = zlib.compress(wire_pixels, level=6) + return len(compressed) < len(wire_pixels) and SEGMENT_HEADER_SIZE + len(compressed) <= max_segment_wire_bytes + + @staticmethod + def _choose_partial_payload(wire_pixels: bytes, max_segment_wire_bytes: int) -> tuple[bytes, bool]: + """Choose raw or zlib-compressed bytes for one 0x77 segment payload.""" + compressed = zlib.compress(wire_pixels, level=6) + if len(compressed) < len(wire_pixels) and SEGMENT_HEADER_SIZE + len(compressed) <= max_segment_wire_bytes: + return compressed, True + return wire_pixels, False + @staticmethod def _encode_segment_wire( palette_image: Image.Image, @@ -1169,8 +1266,43 @@ def _encode_segment_wire( h: int, color_scheme: ColorScheme, ) -> bytes: - """Crop the palette image to (x,y,w,h) and encode to the panel's wire format.""" + """Crop the palette image to (x,y,w,h) and encode to tightly packed wire bytes. + + Partial 0x77 segments are packed over the full rectangle pixel stream with + no per-row padding. The normal full-frame encoders pad each row to a byte + boundary, which breaks the firmware's segment-length calculation for + widths that are not aligned to 8/4/2 pixels. + """ cropped = palette_image.crop((x, y, x + w, y + h)) + pixels = list(cropped.getdata()) + + if color_scheme == ColorScheme.MONO: + output = bytearray((len(pixels) + 7) // 8) + for i, palette_idx in enumerate(pixels): + if palette_idx > 0: + output[i // 8] |= 1 << (7 - (i % 8)) + return bytes(output) + + if color_scheme in (ColorScheme.BWRY, ColorScheme.GRAYSCALE_4): + output = bytearray((len(pixels) + 3) // 4) + for i, palette_idx in enumerate(pixels): + shift = (3 - (i % 4)) * 2 + output[i // 4] |= (palette_idx & 0x03) << shift + return bytes(output) + + if color_scheme in (ColorScheme.BWGBRY, ColorScheme.GRAYSCALE_16): + output = bytearray((len(pixels) + 1) // 2) + bwgbry_map = {0: 0, 1: 1, 2: 2, 3: 3, 4: 5, 5: 6} + for i, palette_idx in enumerate(pixels): + value = palette_idx & 0x0F + if color_scheme == ColorScheme.BWGBRY: + value = bwgbry_map.get(value, 0) + if i % 2 == 0: + output[i // 2] |= value << 4 + else: + output[i // 2] |= value + return bytes(output) + return encode_image(cropped, color_scheme) async def _execute_upload( @@ -1181,6 +1313,7 @@ async def _execute_upload( compressed_data: bytes | None = None, uncompressed_size: int | None = None, progress_callback: Callable[[int, int], None] | None = None, + new_etag: int | None = None, ) -> None: """Execute image upload using compressed or uncompressed protocol. @@ -1218,7 +1351,11 @@ async def _execute_upload( # 4. Send END (unless device auto-triggered refresh), then wait for 0x73 if not auto_completed: - end_cmd = build_direct_write_end_command(refresh_mode.value) + end_cmd = ( + build_direct_write_end_with_etag(refresh_mode.value, new_etag) + if new_etag is not None + else build_direct_write_end_command(refresh_mode.value) + ) await self._write(end_cmd) response = await self._read(self.TIMEOUT_ACK) diff --git a/src/opendisplay/models/enums.py b/src/opendisplay/models/enums.py index 6e2446e..6f1e8d0 100644 --- a/src/opendisplay/models/enums.py +++ b/src/opendisplay/models/enums.py @@ -9,11 +9,14 @@ class RefreshMode(IntEnum): """Display refresh modes. - Only FULL and FAST are supported by the firmware. + FULL is the normal full-screen update. + FAST is a panel-specific reduced-flash refresh. + PARTIAL requests the panel's true partial-update mode when supported. """ FULL = 0 FAST = 1 + PARTIAL = 2 class ICType(IntEnum): diff --git a/src/opendisplay/partial.py b/src/opendisplay/partial.py index e0e8bed..84d6d0e 100644 --- a/src/opendisplay/partial.py +++ b/src/opendisplay/partial.py @@ -18,6 +18,7 @@ import os import struct +from collections.abc import Callable from dataclasses import dataclass from typing import Protocol @@ -30,9 +31,15 @@ ERR_MIXED_DATA = 0x02 # on 0x71 or 0x77: aborted; etag cleared on device ERR_SEGMENT_OOB = 0x03 # on 0x77: aborted; etag cleared on device ERR_PARTIAL_VERSION = 0x04 # on 0x76: client protocol version unsupported +ERR_SEGMENT_ALIGN = 0x05 # on 0x77: segment x/width not on an 8-pixel boundary NACK_PREFIX = 0xFF +# 0x77 segment flags. +SEGMENT_FLAG_PLANE_1 = 0x01 # PLANE_1 old-image segment when set; PLANE_0 new-image when clear +SEGMENT_FLAG_COMPRESSED = 0x02 # Payload is one complete zlib stream when set +SEGMENT_FLAG_RESERVED_MASK = 0xFC + def parse_nack(response: bytes) -> tuple[int, int] | None: """Return (opcode, error_code) if response is a 4-byte {0xFF, op, err, 0x00} NACK. @@ -50,6 +57,11 @@ def parse_nack(response: bytes) -> tuple[int, int] | None: # Minimum region size (pixels) below which we stop recursing _MIN_REGION_PIXELS = 16 +# All segments emitted on the wire must have x and width aligned to this +# many pixels. SSD16xx-class controllers stream 8 horizontal pixels per byte +# and the firmware rejects misaligned segments with ERR_SEGMENT_ALIGN. +SEGMENT_PIXEL_ALIGN = 8 + # --------------------------------------------------------------------------- # Segment — geometry + raw palette pixels for one rectangular region @@ -70,6 +82,7 @@ class Segment: height: int pixels: bytes # raw palette bytes (1 byte per pixel) plane: int = 0 # 0 = PLANE_0 (new), 1 = PLANE_1 (old) + compressed: bool = False @property def pixel_count(self) -> int: @@ -169,7 +182,36 @@ def diff( self._recurse( old, new, width, height, bytes_per_pixel, 0, 0, width, height, # initial region = full image - max_segment_bytes, segments, + lambda seg: len(seg.pixels) <= max_segment_bytes, + segments, + ) + return segments + + def diff_with_fit( + self, + old: bytes, + new: bytes, + width: int, + height: int, + bytes_per_pixel: int, + segment_fits: Callable[[Segment], bool], + ) -> list[Segment]: + """Return changed segments using a caller-defined wire-fit predicate. + + This is used by the BLE upload path because actual 0x77 fit depends on + the display encoding and whether a zlib-compressed segment is smaller + than its raw wire bytes. ``segment_fits`` receives a candidate segment + with raw palette pixels. + """ + if old == new: + return [] + + segments: list[Segment] = [] + self._recurse( + old, new, width, height, bytes_per_pixel, + 0, 0, width, height, + segment_fits, + segments, ) return segments @@ -249,7 +291,7 @@ def _recurse( ry: int, rw: int, rh: int, - max_segment_bytes: int, + segment_fits: Callable[[Segment], bool], out: list[Segment], ) -> None: """Recursively find changed regions within (rx, ry, rw, rh).""" @@ -261,36 +303,54 @@ def _recurse( return # no changes in this region x0, y0, x1, y1 = bb + # Snap x0 down and x1 up to the wire-required pixel alignment, clamped + # to the image width. y bounds are not constrained by the controller. + x0 -= x0 % SEGMENT_PIXEL_ALIGN + if x1 % SEGMENT_PIXEL_ALIGN: + x1 += SEGMENT_PIXEL_ALIGN - (x1 % SEGMENT_PIXEL_ALIGN) + if x1 > img_width: + x1 = img_width + # If img_width itself is not aligned, clamp x0 too so width stays aligned. + misalign = (x1 - x0) % SEGMENT_PIXEL_ALIGN + if misalign: + x0 = max(0, x0 - (SEGMENT_PIXEL_ALIGN - misalign)) bw = x1 - x0 bh = y1 - y0 pixel_count = bw * bh - data_size = pixel_count * bytes_per_pixel + pixels = self._extract_region(new, img_width, bytes_per_pixel, x0, y0, x1, y1) + candidate = Segment(x=x0, y=y0, width=bw, height=bh, pixels=pixels, plane=0) - if data_size <= max_segment_bytes or pixel_count <= self._min_region_pixels: + if segment_fits(candidate) or pixel_count <= self._min_region_pixels: # Fits (or too small to split further) — emit as-is - pixels = self._extract_region(new, img_width, bytes_per_pixel, x0, y0, x1, y1) - out.append(Segment(x=x0, y=y0, width=bw, height=bh, pixels=pixels, plane=0)) + out.append(candidate) return # Split along the longer axis of the bounding box if bw >= bh: - # Split vertically (along x) at midpoint of bounding box + # Split vertically (along x) at midpoint of bounding box, snapped + # to the wire alignment so the two halves don't overlap after the + # bounding box of each is re-aligned. mid = x0 + bw // 2 + mid -= mid % SEGMENT_PIXEL_ALIGN + if mid <= rx or mid >= rx + rw: + # Region too narrow to split on an aligned boundary — emit as-is. + out.append(candidate) + return # Left half: region from rx to mid self._recurse(old, new, img_width, img_height, bytes_per_pixel, - rx, ry, mid - rx, rh, max_segment_bytes, out) + rx, ry, mid - rx, rh, segment_fits, out) # Right half: region from mid to rx+rw self._recurse(old, new, img_width, img_height, bytes_per_pixel, - mid, ry, rx + rw - mid, rh, max_segment_bytes, out) + mid, ry, rx + rw - mid, rh, segment_fits, out) else: # Split horizontally (along y) at midpoint of bounding box mid = y0 + bh // 2 # Top half self._recurse(old, new, img_width, img_height, bytes_per_pixel, - rx, ry, rw, mid - ry, max_segment_bytes, out) + rx, ry, rw, mid - ry, segment_fits, out) # Bottom half self._recurse(old, new, img_width, img_height, bytes_per_pixel, - rx, mid, rw, ry + rh - mid, max_segment_bytes, out) + rx, mid, rw, ry + rh - mid, segment_fits, out) # --------------------------------------------------------------------------- @@ -303,9 +363,15 @@ def _build_segment_wire(seg: Segment, wire_pixels: bytes) -> bytes: Uses *wire_pixels* rather than *seg.pixels* (which are palette bytes); the caller is responsible for encoding palette bytes → wire format. - Wire format per segment: x(2BE) y(2BE) w(2BE) h(2BE) flags(1) pixels(N) + Wire format per segment: x(2BE) y(2BE) w(2BE) h(2BE) flags(1) payload(N) + + flags bit 0 selects PLANE_1 when set; flags bit 1 marks payload as one + complete zlib stream. """ - header = struct.pack(">HHHHB", seg.x, seg.y, seg.width, seg.height, seg.plane & 0x01) + flags = SEGMENT_FLAG_PLANE_1 if (seg.plane & 0x01) else 0 + if seg.compressed: + flags |= SEGMENT_FLAG_COMPRESSED + header = struct.pack(">HHHHB", seg.x, seg.y, seg.width, seg.height, flags) return header + wire_pixels @@ -316,11 +382,7 @@ def pack_segments_into_packets( ) -> list[bytes]: """Pack (segment, wire_pixels) pairs into 0x77 BLE packets. - Uses space-filling (greedy largest-first) packing: - - Sort by wire-pixel size descending. - - For each packet, greedily pick the largest remaining segment that fits, - then continue with smaller ones until no segment fits. - - All input segments appear exactly once across all returned packets. + Preserves input order and fills packets sequentially. Args: segments: List of (Segment, wire_pixels) where wire_pixels is the @@ -338,36 +400,29 @@ def pack_segments_into_packets( # Pre-compute wire representation for each (segment, wire_pixels) wires: list[bytes] = [_build_segment_wire(seg, wp) for seg, wp in segments] - sizes: list[int] = [len(w) for w in wires] - - # Sort indices by wire size descending (largest first) - order = sorted(range(len(wires)), key=lambda i: sizes[i], reverse=True) - remaining: list[int] = list(order) packets: list[bytes] = [] - - while remaining: - packet_parts: list[bytes] = [] - space = max_payload - still_remaining: list[int] = [] - - for idx in remaining: - w = wires[idx] - if len(w) <= space: - packet_parts.append(w) - space -= len(w) - else: - still_remaining.append(idx) - - if not packet_parts: - # Segment alone is larger than payload — emit it alone to avoid - # an infinite loop. Upper layer should not produce such segments. - idx = remaining[0] - packets.append(cmd_prefix + wires[idx]) - remaining = remaining[1:] + packet_parts: list[bytes] = [] + space = max_payload + + for wire in wires: + if len(wire) > max_payload: + if packet_parts: + packets.append(cmd_prefix + b"".join(packet_parts)) + packet_parts = [] + space = max_payload + packets.append(cmd_prefix + wire) continue + if len(wire) > space: + packets.append(cmd_prefix + b"".join(packet_parts)) + packet_parts = [] + space = max_payload + + packet_parts.append(wire) + space -= len(wire) + + if packet_parts: packets.append(cmd_prefix + b"".join(packet_parts)) - remaining = still_remaining return packets diff --git a/tests/unit/test_partial.py b/tests/unit/test_partial.py index 4ecf725..06a2fa9 100644 --- a/tests/unit/test_partial.py +++ b/tests/unit/test_partial.py @@ -2,14 +2,20 @@ from __future__ import annotations +import zlib + import pytest from opendisplay.partial import ( ERR_ETAG_MISMATCH, ERR_MIXED_DATA, ERR_PARTIAL_VERSION, + ERR_SEGMENT_ALIGN, ERR_SEGMENT_OOB, SEGMENT_HEADER_SIZE, + SEGMENT_FLAG_COMPRESSED, + SEGMENT_FLAG_PLANE_1, + SEGMENT_PIXEL_ALIGN, FullImageStrategy, PartialState, RecursiveBoundingBoxStrategy, @@ -70,6 +76,9 @@ def test_oob(self): def test_bad_version(self): assert parse_nack(b"\xff\x76\x04\x00") == (0x76, ERR_PARTIAL_VERSION) + def test_segment_align(self): + assert parse_nack(b"\xff\x77\x05\x00") == (0x77, ERR_SEGMENT_ALIGN) + def test_not_nack_returns_none(self): assert parse_nack(b"\x00\x76") is None assert parse_nack(b"\xff\x70\x01") is None # too short @@ -85,6 +94,13 @@ def test_nonzero_uint32(self): class TestRecursiveBoundingBoxStrategy: + @staticmethod + def _paint_rect(buf: bytearray, width: int, x: int, y: int, w: int, h: int, value: int = 1) -> None: + for dy in range(h): + row = (y + dy) * width + for dx in range(w): + buf[row + x + dx] = value + def test_no_change_returns_empty(self): buf = bytes(100 * 100) segs = RecursiveBoundingBoxStrategy().diff(buf, buf, 100, 100, 1, 4096) @@ -97,11 +113,86 @@ def test_single_pixel_change(self): segs = RecursiveBoundingBoxStrategy().diff(bytes(old), bytes(new), 100, 100, 1, 4096) assert len(segs) == 1 s = segs[0] - assert s.x == 30 and s.y == 50 - assert s.width == 1 and s.height == 1 - assert s.pixels == b"\x01" + # Bounding box is the single pixel at (30, 50); aligned to 8-pixel + # boundary becomes x=24, width=8. + assert s.x == 24 and s.y == 50 + assert s.width == 8 and s.height == 1 + # The changed pixel is at column 30, i.e. index (30 - 24) = 6 in the segment row. + assert s.pixels == b"\x00\x00\x00\x00\x00\x00\x01\x00" assert s.plane == 0 + def test_single_filled_rectangle_returns_aligned_bounds(self): + w, h = 16, 12 + old = bytearray(w * h) + new = bytearray(old) + self._paint_rect(new, w, x=3, y=4, w=5, h=3, value=1) + + segs = RecursiveBoundingBoxStrategy().diff(bytes(old), bytes(new), w, h, 1, 4096) + + assert len(segs) == 1 + seg = segs[0] + # Original bbox (3, 4, 5, 3): x snaps down to 0, x_end (3+5=8) is already aligned. + assert (seg.x, seg.y, seg.width, seg.height) == (0, 4, 8, 3) + assert seg.pixel_count == 24 + # Pixels in cols 3..7 of each row are 1, cols 0..2 are 0. + expected_row = b"\x00\x00\x00\x01\x01\x01\x01\x01" + assert seg.pixels == expected_row * 3 + + def test_two_disjoint_rectangles_return_two_segments_when_bbox_exceeds_budget(self): + w, h = 32, 20 + old = bytearray(w * h) + new = bytearray(old) + self._paint_rect(new, w, x=1, y=2, w=4, h=3, value=1) + self._paint_rect(new, w, x=24, y=12, w=5, h=4, value=1) + + # The combined bounding box would be 28x14 = 392 pixels, forcing a split. + segs = RecursiveBoundingBoxStrategy(min_region_pixels=1).diff(bytes(old), bytes(new), w, h, 1, 64) + + actual = sorted((seg.x, seg.y, seg.width, seg.height) for seg in segs) + # (1,2,4,3) → x=0, x_end=8 → (0, 2, 8, 3) + # (24,12,5,4) → x=24, x_end=32 → (24, 12, 8, 4) + assert actual == [(0, 2, 8, 3), (24, 12, 8, 4)] + + def test_changed_hollow_rectangle_preserves_bounding_box_pixels(self): + w, h = 16, 10 + old = bytearray(w * h) + new = bytearray(old) + self._paint_rect(new, w, x=2, y=2, w=4, h=1, value=1) + self._paint_rect(new, w, x=2, y=5, w=4, h=1, value=1) + self._paint_rect(new, w, x=2, y=2, w=1, h=4, value=1) + self._paint_rect(new, w, x=5, y=2, w=1, h=4, value=1) + + segs = RecursiveBoundingBoxStrategy().diff(bytes(old), bytes(new), w, h, 1, 4096) + + assert len(segs) == 1 + seg = segs[0] + # Original bbox (2, 2, 4, 4): x snaps down to 0, x_end (2+4=6) snaps up to 8. + assert (seg.x, seg.y, seg.width, seg.height) == (0, 2, 8, 4) + # Each row is the original cols 0..7 of the new image at that y. + # Hollow rect is at cols 2..5, rows 2..5. + assert seg.pixels == ( + b"\x00\x00\x01\x01\x01\x01\x00\x00" # y=2: cols 2..5 are top edge + b"\x00\x00\x01\x00\x00\x01\x00\x00" # y=3: cols 2 and 5 (sides) + b"\x00\x00\x01\x00\x00\x01\x00\x00" # y=4: cols 2 and 5 (sides) + b"\x00\x00\x01\x01\x01\x01\x00\x00" # y=5: cols 2..5 are bottom edge + ) + + def test_segments_are_8px_aligned(self): + # Adversarial rectangles that don't naturally land on 8-pixel boundaries. + w, h = 64, 32 + old = bytes(w * h) + new_buf = bytearray(old) + for x, y, rw, rh in [(1, 1, 3, 3), (13, 5, 5, 4), (37, 20, 11, 7)]: + self._paint_rect(new_buf, w, x=x, y=y, w=rw, h=rh, value=1) + segs = RecursiveBoundingBoxStrategy(min_region_pixels=1).diff( + bytes(old), bytes(new_buf), w, h, 1, max_segment_bytes=128 + ) + assert segs, "expected at least one segment" + for s in segs: + assert s.x % SEGMENT_PIXEL_ALIGN == 0, f"x={s.x} not aligned" + assert s.width % SEGMENT_PIXEL_ALIGN == 0, f"width={s.width} not aligned" + assert s.x + s.width <= w + def test_full_change_tiles_image(self): # 64x64 with every pixel different; budget 256 bytes # → recursion must split until each tile fits. @@ -125,6 +216,37 @@ def test_full_change_tiles_image(self): covered[(s.y + dy) * w + (s.x + dx)] = 1 assert all(b == 1 for b in covered) + def test_diff_with_fit_uses_wire_fit_predicate(self): + # The old palette-byte budget would split this full-height change many + # times. The wire predicate models a mono segment: 512 pixels encode to + # 64 wire bytes, so the whole changed rectangle should be accepted. + w, h = 64, 8 + old = bytes(w * h) + new = bytes([1] * (w * h)) + + def mono_wire_fits(seg: Segment) -> bool: + wire_bytes = (seg.pixel_count + 7) // 8 + return wire_bytes <= 64 + + segs = RecursiveBoundingBoxStrategy(min_region_pixels=1).diff_with_fit(old, new, w, h, 1, mono_wire_fits) + + assert [(s.x, s.y, s.width, s.height) for s in segs] == [(0, 0, 64, 8)] + + def test_diff_with_fit_can_accept_compressed_large_region(self): + w, h = 64, 64 + old = bytes(w * h) + new = bytes([1] * (w * h)) + + def compressed_wire_fits(seg: Segment) -> bool: + raw_wire = bytes([0xFF]) * ((seg.pixel_count + 7) // 8) + return len(raw_wire) <= 64 or len(zlib.compress(raw_wire, level=6)) <= 64 + + segs = RecursiveBoundingBoxStrategy(min_region_pixels=1).diff_with_fit( + old, new, w, h, 1, compressed_wire_fits + ) + + assert [(s.x, s.y, s.width, s.height) for s in segs] == [(0, 0, 64, 64)] + def test_respects_chunk_size_budget(self): # Confirm segments respect both unencrypted and encrypted MTU minus header. w, h = 32, 32 @@ -177,6 +299,18 @@ def test_every_segment_appears_once(self): # 9-byte header per segment + 30 byte payload = 39 bytes per segment, 20 segments assert total_payload == 20 * (SEGMENT_HEADER_SIZE + 30) + def test_segment_flags_include_plane_and_compression(self): + compressed = zlib.compress(bytes([0x00]) * 64) + packets = pack_segments_into_packets( + [(Segment(x=0, y=0, width=64, height=8, pixels=b"", plane=1, compressed=True), compressed)], + mtu=230, + ) + + assert len(packets) == 1 + assert packets[0][:2] == b"\x00\x77" + assert packets[0][10] == SEGMENT_FLAG_PLANE_1 | SEGMENT_FLAG_COMPRESSED + assert zlib.decompress(packets[0][11:]) == bytes([0x00]) * 64 + class TestNewBuilders: def test_partial_start(self): From 9f5ce69f422286f85f1fc8d9a63b39cf4083cfd3 Mon Sep 17 00:00:00 2001 From: Michael Bisbjerg Date: Mon, 27 Apr 2026 17:21:23 +0200 Subject: [PATCH 06/18] Align partial & full callback methods --- src/opendisplay/device.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/src/opendisplay/device.py b/src/opendisplay/device.py index 2d7a01d..6e50d52 100644 --- a/src/opendisplay/device.py +++ b/src/opendisplay/device.py @@ -926,7 +926,7 @@ async def upload_image( if state is not None: partial_outcome = await self._maybe_upload_partial( - processed_image, image_data, refresh_mode, state, diff_strategy + processed_image, image_data, refresh_mode, state, diff_strategy, progress_callback ) if partial_outcome == "success": _LOGGER.info("Image upload complete (partial path)") @@ -997,7 +997,7 @@ async def upload_prepared_image( if state is not None: partial_outcome = await self._maybe_upload_partial( - processed_image, image_data, refresh_mode, state, diff_strategy + processed_image, image_data, refresh_mode, state, diff_strategy, progress_callback ) if partial_outcome == "success": _LOGGER.info("Prepared image upload complete (partial path)") @@ -1067,6 +1067,7 @@ async def _maybe_upload_partial( refresh_mode: RefreshMode, state: PartialState, diff_strategy: DiffStrategy | None, + progress_callback: Callable[[int, int], None] | None = None, ) -> str: """Try to perform a partial upload. Return code: @@ -1207,6 +1208,8 @@ def segment_fits(seg: Segment) -> bool: validate_ack_response(response, CommandCode.DIRECT_WRITE_PARTIAL_START) # 2. 0x77 packets — ACK after each + total_packet_bytes = sum(len(p) - 2 for p in packets) # exclude 2-byte command prefix + bytes_sent = 0 for i, pkt in enumerate(packets): _LOGGER.debug("Sending partial packet %d/%d (%d bytes total)", i + 1, len(packets), len(pkt)) await self._write(pkt) @@ -1219,6 +1222,9 @@ def segment_fits(seg: Segment) -> bool: _LOGGER.debug("Partial packet %d NACK: opcode=0x%02x err=0x%02x", i + 1, opcode, err) raise ProtocolError(f"Partial 0x77 NACK: opcode=0x{opcode:02x} err=0x{err:02x}") validate_ack_response(ack, CommandCode.DIRECT_WRITE_PARTIAL_DATA) + bytes_sent += len(pkt) - 2 + if progress_callback is not None: + progress_callback(bytes_sent, total_packet_bytes) # 3. 0x72 END with new_etag await self._write(build_direct_write_end_with_etag(RefreshMode.PARTIAL.value, new_etag)) From 94d1a20e67bae6fb366f809530a4000e5b45fc10 Mon Sep 17 00:00:00 2001 From: Michael Bisbjerg Date: Mon, 27 Apr 2026 17:21:31 +0200 Subject: [PATCH 07/18] Animation example script --- examples/animate.py | 280 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 280 insertions(+) create mode 100644 examples/animate.py diff --git a/examples/animate.py b/examples/animate.py new file mode 100644 index 0000000..6629827 --- /dev/null +++ b/examples/animate.py @@ -0,0 +1,280 @@ +"""Animate a sequence of images on an OpenDisplay device. + +Connects once, sends the first frame as a full update, then loops through +subsequent frames using partial (delta) updates. Repeats indefinitely. + +Usage: + python examples/animate.py --device AA:BB:CC:DD:EE:FF --interval 500 frame1.png frame2.png ... + python examples/animate.py --device AA:BB:CC:DD:EE:FF --interval 1000 "frames/*.png" +""" + +from __future__ import annotations + +import argparse +import asyncio +import glob +import logging +import sys +import time +from pathlib import Path +from collections.abc import Coroutine +from typing import Any, NoReturn, TypeVar + +from epaper_dithering import DitherMode +from PIL import Image, UnidentifiedImageError +from rich.console import Console +from rich.live import Live +from rich.logging import RichHandler +from rich.progress import BarColumn, Progress, SpinnerColumn, TaskProgressColumn, TextColumn + +from opendisplay.device import OpenDisplayDevice, prepare_image +from opendisplay.exceptions import ( + AuthenticationFailedError, + AuthenticationRequiredError, + BLEConnectionError, + BLETimeoutError, + OpenDisplayError, +) +from opendisplay.models.enums import RefreshMode +from opendisplay.partial import PartialState + +_T = TypeVar("_T") + +_console = Console(stderr=True) + +_DITHER_CHOICES: dict[str, DitherMode] = {m.name.lower().replace("_", "-"): m for m in DitherMode} + + +def _run(coro: Coroutine[Any, Any, _T]) -> _T: + return asyncio.run(coro) + + +def _error(msg: str) -> NoReturn: + _console.print(f"[bold red]Error:[/bold red] {msg}") + sys.exit(1) + + +def _handle_ble_error(exc: OpenDisplayError) -> NoReturn: + if isinstance(exc, AuthenticationRequiredError): + _error("Device requires an encryption key. Pass --key HEX.") + if isinstance(exc, AuthenticationFailedError): + _error("Authentication failed. Check that --key is correct.") + if isinstance(exc, BLETimeoutError): + _error(f"BLE timeout: {exc}") + if isinstance(exc, BLEConnectionError): + _error(f"BLE connection failed: {exc}") + _error(f"Device error: {exc}") + + +def _parse_hex_key(hex_str: str | None) -> bytes | None: + if hex_str is None: + return None + cleaned = hex_str.strip().replace(" ", "").replace(":", "") + if len(cleaned) != 32: + _error(f"--key must be exactly 32 hex characters (16 bytes), got {len(cleaned)}") + try: + return bytes.fromhex(cleaned) + except ValueError as exc: + _error(f"--key contains invalid hex characters: {exc}") + + +def _device_kwargs(device: str, key: bytes | None, timeout: float) -> dict[str, Any]: + kwargs: dict[str, Any] = {"timeout": timeout, "encryption_key": key} + if ":" in device or (len(device) == 36 and device.count("-") == 4): + kwargs["mac_address"] = device + else: + kwargs["device_name"] = device + return kwargs + + +def _setup_logging(verbose: bool) -> None: + level = logging.DEBUG if verbose else logging.WARNING + logging.basicConfig( + level=level, + format="%(message)s", + handlers=[RichHandler(console=_console, rich_tracebacks=True)], + force=True, + ) + logging.getLogger("bleak").setLevel(logging.INFO) + logging.getLogger("PIL").setLevel(logging.INFO) + + +def _expand_paths(patterns: list[str]) -> list[str]: + expanded: list[str] = [] + for p in patterns: + matches = sorted(glob.glob(p)) + expanded.extend(matches if matches else [p]) + return expanded + + +def _load_images(paths: list[str]) -> list[Image.Image]: + images: list[Image.Image] = [] + for p in paths: + try: + img = Image.open(p) + img.load() + images.append(img) + except FileNotFoundError: + _error(f"Image file not found: {p}") + except UnidentifiedImageError: + _error(f"Cannot open image (unsupported format): {p}") + return images + + +async def _animate( + device_kwargs: dict[str, Any], + images: list[Image.Image], + names: list[str], + interval_ms: int, + dither_mode: DitherMode, +) -> None: + total = len(images) + delay = interval_ms / 1000.0 + + spinner_progress = Progress( + SpinnerColumn(finished_text="[green]✓[/green]"), + TextColumn("{task.description}"), + console=_console, + ) + bar_progress = Progress( + BarColumn(), + TaskProgressColumn(), + console=_console, + ) + + class _Display: + def __rich_console__(self, _con, _opts): # type: ignore[no-untyped-def] + yield spinner_progress + if any(t.visible for t in bar_progress.tasks): + yield bar_progress + + try: + with Live(_Display(), console=_console, refresh_per_second=10, transient=False): + status_task = spinner_progress.add_task("Connecting...", total=None) + bar_task = bar_progress.add_task("", total=None, visible=False) + + async with OpenDisplayDevice(**device_kwargs) as device: + spinner_progress.update(status_task, description=f"Pre-processing {total} frame(s)...") + prep_times: list[float] = [] + prepared = [] + for img in images: + t0 = time.perf_counter() + prepared.append( + prepare_image(img, config=device.config, capabilities=device.capabilities, dither_mode=dither_mode) + ) + prep_times.append(time.perf_counter() - t0) + + state = PartialState() + frame_count = 0 + + try: + while True: + idx = frame_count % total + is_first = frame_count == 0 + refresh_mode = RefreshMode.FULL if is_first else RefreshMode.PARTIAL + update_type = "full" if is_first else "partial" + name = names[idx] + + def _status(phase: str, _idx: int = idx, _name: str = name, _ut: str = update_type) -> str: + return f"{phase} {_idx + 1}/{total} [dim]{_name}[/dim] ({_ut})" + + send_end: list[float] = [] + bytes_transferred: list[int] = [] + + def on_progress(sent: int, total_bytes: int) -> None: + bar_progress.update(bar_task, total=total_bytes, completed=sent, visible=True) + if sent == total_bytes: + send_end.append(time.perf_counter()) + bytes_transferred.append(total_bytes) + bar_progress.update(bar_task, visible=False) + spinner_progress.update(status_task, description=_status("Refreshing...")) + + t_upload_start = time.perf_counter() + spinner_progress.update(status_task, description=_status("Sending")) + await device.upload_prepared_image( + prepared[idx], + refresh_mode=refresh_mode, + state=state, + progress_callback=on_progress, + ) + t_upload_end = time.perf_counter() + + prep_ms = prep_times[idx] * 1000 + send_ms = (send_end[0] - t_upload_start) * 1000 + refresh_ms = (t_upload_end - send_end[0]) * 1000 + kb = bytes_transferred[0] / 1024 + stats = f"prep {prep_ms:.0f}ms · send {send_ms:.0f}ms ({kb:.1f} KB) · refresh {refresh_ms:.0f}ms" + + _console.print(f"[dim]{idx + 1}/{total} {name} ({update_type}) {stats}[/dim]") + spinner_progress.update(status_task, description=_status("Showing")) + frame_count += 1 + await asyncio.sleep(delay) + + except (KeyboardInterrupt, asyncio.CancelledError): + spinner_progress.update(status_task, description="Stopped.", total=1, completed=1) + return + + except OpenDisplayError as exc: + _handle_ble_error(exc) + + +def _cmd_animate(args: argparse.Namespace) -> None: + key = _parse_hex_key(args.key) + paths = _expand_paths(args.images) + + if len(paths) < 2: + _error(f"At least 2 images are required for animation, got {len(paths)}.") + + images = _load_images(paths) # validate and read all files before connecting + + names = [Path(p).name for p in paths] + listing = ", ".join(names) if len(names) <= 4 else f"{names[0]}, {names[1]}, ..., {names[-1]}" + _console.print(f"Found {len(paths)} image(s): {listing}") + + _run(_animate( + _device_kwargs(args.device, key, args.timeout), + images, + names, + args.interval, + _DITHER_CHOICES[args.dither_mode], + )) + + +def main() -> None: + parser = argparse.ArgumentParser( + prog="animate", + description="Animate a sequence of images on an OpenDisplay e-ink device.", + epilog='Example: python examples/animate.py --device AA:BB:CC:DD:EE:FF --interval 500 "frames/*.png"', + ) + parser.add_argument("-v", "--verbose", action="store_true", help="Enable debug logging") + parser.add_argument("--device", required=True, metavar="ADDR", help="Device MAC address or name") + parser.add_argument( + "--interval", + type=int, + default=1000, + metavar="MS", + help="Delay between frames in milliseconds (default: 1000)", + ) + parser.add_argument( + "--dither-mode", + choices=list(_DITHER_CHOICES), + default="burkes", + help="Dithering algorithm (default: burkes)", + ) + parser.add_argument("--key", default=None, metavar="HEX", help="Encryption key as 32 hex characters") + parser.add_argument( + "--timeout", + type=float, + default=10.0, + metavar="SECS", + help="BLE timeout in seconds (default: 10.0)", + ) + parser.add_argument("images", nargs="+", metavar="IMAGE", help="Image files to animate (in order)") + + args = parser.parse_args() + _setup_logging(args.verbose) + _cmd_animate(args) + + +if __name__ == "__main__": + main() From d459065b2b38da1db3d0b0f7ab78570117f43aca Mon Sep 17 00:00:00 2001 From: Michael Bisbjerg Date: Tue, 28 Apr 2026 09:10:45 +0200 Subject: [PATCH 08/18] Implement streamed partial uploads --- docs/partial-update-protocol.md | 96 ++++-- src/opendisplay/device.py | 275 +++++++-------- src/opendisplay/partial.py | 486 ++++++--------------------- src/opendisplay/protocol/__init__.py | 2 - src/opendisplay/protocol/commands.py | 54 ++- tests/unit/test_device_partial.py | 205 +++++++++++ tests/unit/test_partial.py | 367 ++++++-------------- 7 files changed, 645 insertions(+), 840 deletions(-) create mode 100644 tests/unit/test_device_partial.py diff --git a/docs/partial-update-protocol.md b/docs/partial-update-protocol.md index 171dfcd..df82910 100644 --- a/docs/partial-update-protocol.md +++ b/docs/partial-update-protocol.md @@ -1,50 +1,96 @@ # Partial Update Protocol -Partial updates use the direct-write command family with two additional -messages. The current partial protocol version is `1`. +Partial updates use a streamed single-rectangle protocol: + +```text +0x76 PARTIAL_IMAGE_START +0x71 DATA... +0x72 END + partial refresh +``` + +Full uploads continue to use `0x70`, `0x71`, and `0x72`. `0x77` is unused. ## `0x76` Partial Start ```text -[0x0076][version:1][old_etag:4 BE] +[0x0076] +[version:1 = 0x01] +[flags:2 BE] +[old_etag:4 BE] +[x:2 BE][y:2 BE][width:2 BE][height:2 BE] +[interleave_span_pixels:2 BE] +[uncompressed_size:4 LE] +[initial_stream_bytes...] ``` -The device ACKs with `0x0076` when `old_etag` matches the image currently on -the panel. A NACK `ff 76 01 00` means the client must fall back to a full -upload. +The stream bytes are zlib bytes when `flags & 0x0004` is set, otherwise raw +logical bytes. `uncompressed_size` is always `rect_bytes * 2`. + +Flags: + +```text +bits 0..1: plane order, 0 = old PLANE_1 then new PLANE_0 +bit 2: stream is zlib-compressed +bit 3: 0x72 includes new_etag to store after successful refresh +bit 4: keep panel awake hint +bits 5..15: reserved, must be 0 +``` -## `0x77` Partial Data +The rectangle must be in bounds. `x` and `width` must be aligned to the active +packed-pixel byte boundary: 8 pixels for 1 bpp, 4 for 2 bpp, 2 for 4 bpp, and +1 for 8 bpp. -Each `0x77` packet contains one or more complete segments: +## Stream Body + +The logical stream contains both old and new rectangle images. In the default +plane order: ```text -[0x0077][segment...] +old group 0 bytes for PLANE_1 +new group 0 bytes for PLANE_0 +old group 1 bytes for PLANE_1 +new group 1 bytes for PLANE_0 +... +``` + +`interleave_span_pixels` defines each group. Clients initially use row bands, +usually `width * 8` pixels, so each group maps to a simple rectangle. + +## `0x71` Data -segment: -x:u16BE y:u16BE width:u16BE height:u16BE flags:u8 payload:N +After `0x76`, `0x71` carries the remaining partial stream bytes. It has no +partial metadata: + +```text +[0x0071][stream_bytes...] ``` -The geometry implies the uncompressed payload size from the active display -encoding. Segments must have `x` and `width` aligned to 8 pixels. +Current firmware buffers compressed partial stream bytes just like compressed +full uploads, then inflates at `0x72`. Raw partial streams are consumed as +`0x76`/`0x71` bytes arrive. + +## `0x72` End -Segment flags: +When `flags & 0x0008` was set on `0x76`, the end payload is: ```text -bit 0: plane select, 0 = PLANE_0/new image, 1 = PLANE_1/old image -bit 1: payload is one complete zlib stream -bits 2-7: reserved, must be 0 +[0x0072][refresh_mode:1][new_etag:4 BE] ``` -When bit 1 is clear, `payload` is the raw packed segment bytes. When bit 1 is -set, `payload` is a zlib-compressed stream whose decompressed size must exactly -match the size implied by the segment geometry. +Firmware validates the logical byte count and per-plane byte counts, then +refreshes with a partial-capable refresh mode. The new etag is stored only +after refresh completion. -Known partial NACK error codes: +Known partial NACK error codes use `{0xFF, opcode, error, 0x00}`: ```text -0x01: etag mismatch on 0x76 -0x02: mixed full/partial data in one transfer -0x03: invalid segment, out of bounds segment, truncated segment, or malformed compressed payload +0x01: etag mismatch +0x02: mixed full/partial data +0x03: rectangle out of bounds 0x04: unsupported partial protocol version -0x05: segment x/width alignment error +0x05: rectangle alignment error +0x06: unsupported or reserved flags +0x07: uncompressed_size mismatch +0x08: invalid interleave_span_pixels +0x09: stream byte count or content error ``` diff --git a/src/opendisplay/device.py b/src/opendisplay/device.py index 6e50d52..b200766 100644 --- a/src/opendisplay/device.py +++ b/src/opendisplay/device.py @@ -26,7 +26,14 @@ encode_image, fit_image, ) -from .exceptions import AuthenticationRequiredError, AuthenticationSessionExistsError, ImageEncodingError, ProtocolError +from .exceptions import ( + AuthenticationRequiredError, + AuthenticationSessionExistsError, + BLETimeoutError, + ImageEncodingError, + InvalidResponseError, + ProtocolError, +) from .models.capabilities import DeviceCapabilities from .models.config import GlobalConfig from .models.enums import BoardManufacturer, FitMode, RefreshMode, Rotation @@ -34,13 +41,14 @@ from .models.led_flash import LedFlashConfig from .partial import ( ERR_ETAG_MISMATCH, - SEGMENT_HEADER_SIZE, - DiffStrategy, + PARTIAL_FLAG_COMPRESSED, + PARTIAL_FLAG_STORE_ETAG, PartialState, - RecursiveBoundingBoxStrategy, - Segment, _generate_etag, - pack_segments_into_packets, + _PIXELS_PER_BYTE, + align_rect, + build_partial_logical_stream, + compute_bounding_rect, parse_nack, ) from .protocol import ( @@ -57,7 +65,6 @@ build_direct_write_start_compressed, build_direct_write_start_uncompressed, build_led_activate_command, - build_partial_data_packet, build_read_config_command, build_read_fw_version_command, build_reboot_command, @@ -876,7 +883,7 @@ async def upload_image( rotate: Rotation = Rotation.ROTATE_0, progress_callback: Callable[[int, int], None] | None = None, state: PartialState | None = None, - diff_strategy: DiffStrategy | None = None, + diff_strategy: object | None = None, ) -> Image.Image: """Upload image to device display. @@ -931,16 +938,20 @@ async def upload_image( if partial_outcome == "success": _LOGGER.info("Image upload complete (partial path)") return processed_image + if partial_outcome == "no_change": + _LOGGER.info("No pixels changed; skipping upload") + return processed_image if partial_outcome == "fallback_full": - _LOGGER.info("Partial path unavailable or unnecessary; continuing with full upload") + _LOGGER.info("Partial path unavailable or etag mismatch; continuing with full upload") + upload_refresh_mode = RefreshMode.FULL if state is not None else refresh_mode full_upload_etag = _generate_etag() if state is not None else None if compress and supports_compression and compressed_data and len(compressed_data) < MAX_COMPRESSED_SIZE: _LOGGER.info("Using compressed upload protocol (size: %d bytes)", len(compressed_data)) await self._execute_upload( image_data, - refresh_mode, + upload_refresh_mode, use_compression=True, compressed_data=compressed_data, uncompressed_size=len(image_data), @@ -956,7 +967,7 @@ async def upload_image( _LOGGER.info("Compression disabled or no compressed data, using uncompressed protocol") await self._execute_upload( image_data, - refresh_mode, + upload_refresh_mode, use_compression=False, progress_callback=progress_callback, new_etag=full_upload_etag, @@ -974,7 +985,7 @@ async def upload_prepared_image( compress: bool = True, progress_callback: Callable[[int, int], None] | None = None, state: PartialState | None = None, - diff_strategy: DiffStrategy | None = None, + diff_strategy: object | None = None, ) -> None: """Upload pre-computed image data to device. @@ -1002,19 +1013,23 @@ async def upload_prepared_image( if partial_outcome == "success": _LOGGER.info("Prepared image upload complete (partial path)") return + if partial_outcome == "no_change": + _LOGGER.info("No pixels changed; skipping prepared upload") + return if partial_outcome == "fallback_full": - _LOGGER.info("Partial prepared upload unavailable or unnecessary; continuing with full upload") + _LOGGER.info("Partial prepared upload unavailable or etag mismatch; continuing with full upload") supports_compression = ( self._config.displays[0].supports_zip if (self._config and self._config.displays) else True ) + upload_refresh_mode = RefreshMode.FULL if state is not None else refresh_mode full_upload_etag = _generate_etag() if state is not None else None if compress and supports_compression and compressed_data and len(compressed_data) < MAX_COMPRESSED_SIZE: _LOGGER.info("Using compressed upload protocol (size: %d bytes)", len(compressed_data)) await self._execute_upload( image_data, - refresh_mode, + upload_refresh_mode, use_compression=True, compressed_data=compressed_data, uncompressed_size=len(image_data), @@ -1030,7 +1045,7 @@ async def upload_prepared_image( _LOGGER.info("Compression disabled or no compressed data, using uncompressed protocol") await self._execute_upload( image_data, - refresh_mode, + upload_refresh_mode, use_compression=False, progress_callback=progress_callback, new_etag=full_upload_etag, @@ -1066,22 +1081,29 @@ async def _maybe_upload_partial( image_data: bytes, refresh_mode: RefreshMode, state: PartialState, - diff_strategy: DiffStrategy | None, + diff_strategy: object | None, progress_callback: Callable[[int, int], None] | None = None, ) -> str: - """Try to perform a partial upload. Return code: + """Try a partial upload using the 0x76 single-rectangle protocol. + Return codes: - "success": partial transfer accepted; state mutated. + - "no_change": no pixels changed; caller should skip upload entirely. - "fallback_full": caller must do a full upload (and refresh state). """ - del image_data # full encoding is per-segment for partial path - del refresh_mode # Partial transfers always request the panel's PARTIAL refresh mode. + del image_data # Partial requests do not compare against full-frame transfer size. + del diff_strategy # reserved for future use + + if self._config is None or not self._config.displays: + _LOGGER.debug("Partial path skipped: device config is required to verify partial support") + return "fallback_full" + display = self._config.displays[0] + if not display.partial_update_support: + _LOGGER.debug("Partial path skipped: display does not advertise partial update support") + return "fallback_full" color_scheme = self.color_scheme if color_scheme in (ColorScheme.BWR, ColorScheme.BWY): - # Bitplane color schemes are not supported on the partial path yet: - # encode_image() refuses them and per-segment plane extraction is - # not implemented. Force a full upload + state refresh. _LOGGER.debug("Partial path skipped: color scheme %s requires bitplane encoding", color_scheme.name) return "fallback_full" @@ -1101,132 +1123,107 @@ async def _maybe_upload_partial( if len(old_palette) != len(new_palette): return "fallback_full" - chunk_size = ENCRYPTED_CHUNK_SIZE if self._session_key is not None else CHUNK_SIZE - max_segment_wire_bytes = chunk_size - 2 - if max_segment_wire_bytes <= SEGMENT_HEADER_SIZE: + # Compute bounding rect of changed pixels + bbox = compute_bounding_rect(old_palette, new_palette, width, height) + if bbox is None: + return "no_change" + + bpp = { + ColorScheme.MONO: 1, + ColorScheme.BWRY: 2, + ColorScheme.GRAYSCALE_4: 2, + ColorScheme.BWGBRY: 4, + ColorScheme.GRAYSCALE_16: 4, + }.get(color_scheme, 1) + pixels_per_byte = _PIXELS_PER_BYTE.get(bpp, 8) + + rx, ry, rw, rh = align_rect(*bbox, width, height, pixels_per_byte) + if rw == 0 or rh == 0: return "fallback_full" - strategy: DiffStrategy = diff_strategy or RecursiveBoundingBoxStrategy() - max_raw_segment_bytes = max_segment_wire_bytes - SEGMENT_HEADER_SIZE + _LOGGER.debug( + "Partial path diff: old_etag=0x%08x, image=%dx%d, bbox=(%d,%d,%d,%d), rect=(%d,%d,%d,%d)", + state.etag, width, height, *bbox, rx, ry, rw, rh, + ) old_palette_image = palette_image.copy() old_palette_image.frombytes(old_palette) - def segment_fits(seg: Segment) -> bool: - new_wire = self._encode_segment_wire(palette_image, seg.x, seg.y, seg.width, seg.height, color_scheme) - old_wire = self._encode_segment_wire( - old_palette_image, seg.x, seg.y, seg.width, seg.height, color_scheme - ) - return ( - self._partial_payload_fits(new_wire, max_segment_wire_bytes) - and self._partial_payload_fits(old_wire, max_segment_wire_bytes) - ) + old_rect_bytes = self._encode_segment_wire(old_palette_image, rx, ry, rw, rh, color_scheme) + new_rect_bytes = self._encode_segment_wire(palette_image, rx, ry, rw, rh, color_scheme) - if isinstance(strategy, RecursiveBoundingBoxStrategy): - new_segments = strategy.diff_with_fit(old_palette, new_palette, width, height, 1, segment_fits) - else: - new_segments = strategy.diff(old_palette, new_palette, width, height, 1, max_raw_segment_bytes) - _LOGGER.debug( - "Partial path diff: old_etag=0x%08x, image=%dx%d, max_segment_wire_bytes=%d, changed_segments=%d", - state.etag, - width, - height, - max_segment_wire_bytes, - len(new_segments), - ) - if not new_segments: - _LOGGER.debug("Partial path: local state already matches target image; forcing full upload to resync") - return "fallback_full" + # Row-band interleaving: 8 rows per group + band_rows = 8 + span_pixels = rw * band_rows + span_bytes = span_pixels // pixels_per_byte + + logical_stream = build_partial_logical_stream(old_rect_bytes, new_rect_bytes, span_bytes) + uncompressed_size = len(logical_stream) + + compressed_stream = zlib.compress(logical_stream, level=6) + use_compression = display.supports_zip and len(compressed_stream) < len(logical_stream) + stream_bytes = compressed_stream if use_compression else logical_stream + + flags = PARTIAL_FLAG_STORE_ETAG + if use_compression: + flags |= PARTIAL_FLAG_COMPRESSED - for i, seg in enumerate(new_segments[:8]): - _LOGGER.debug( - "Partial segment %d: x=%d y=%d w=%d h=%d pixels=%d", - i, - seg.x, - seg.y, - seg.width, - seg.height, - seg.pixel_count, - ) - if len(new_segments) > 8: - _LOGGER.debug("Partial segment list truncated in logs (%d additional segments)", len(new_segments) - 8) - - # Build (Segment, wire_pixels) pairs for both planes. - # PLANE_0 = new image, PLANE_1 = old image. - pairs: list[tuple[Segment, bytes]] = [] - total_wire_bytes = 0 - compressed_pairs = 0 - for seg in new_segments: - new_wire = self._encode_segment_wire(palette_image, seg.x, seg.y, seg.width, seg.height, color_scheme) - old_wire = self._encode_segment_wire( - old_palette_image, seg.x, seg.y, seg.width, seg.height, color_scheme - ) - new_payload, new_compressed = self._choose_partial_payload(new_wire, max_segment_wire_bytes) - old_payload, old_compressed = self._choose_partial_payload(old_wire, max_segment_wire_bytes) - if ( - SEGMENT_HEADER_SIZE + len(new_payload) > max_segment_wire_bytes - or SEGMENT_HEADER_SIZE + len(old_payload) > max_segment_wire_bytes - ): - _LOGGER.debug("Partial path skipped: custom diff produced a segment larger than the active MTU") - return "fallback_full" - new_seg = Segment(seg.x, seg.y, seg.width, seg.height, b"", plane=0, compressed=new_compressed) - old_seg = Segment(seg.x, seg.y, seg.width, seg.height, b"", plane=1, compressed=old_compressed) - pairs.append((new_seg, new_payload)) - pairs.append((old_seg, old_payload)) - total_wire_bytes += len(new_payload) + len(old_payload) - compressed_pairs += int(new_compressed) + int(old_compressed) - - packets = pack_segments_into_packets(pairs, mtu=chunk_size) _LOGGER.debug( - "Partial packetization: plane_pairs=%d, compressed_pairs=%d, total_payload_bytes=%d, packet_count=%d, mtu=%d", - len(pairs), - compressed_pairs, - total_wire_bytes, - len(packets), - chunk_size, + "Partial stream: rect=(%d,%d,%d,%d), span_pixels=%d, uncompressed=%d, wire=%d, compressed=%s", + rx, ry, rw, rh, span_pixels, uncompressed_size, len(stream_bytes), use_compression, ) - for i, pkt in enumerate(packets[:8]): - _LOGGER.debug("Partial packet %d: payload_bytes=%d", i, len(pkt) - 2) - if len(packets) > 8: - _LOGGER.debug("Partial packet list truncated in logs (%d additional packets)", len(packets) - 8) new_etag = _generate_etag() - _LOGGER.debug("Partial upload start: old_etag=0x%08x new_etag=0x%08x", state.etag, new_etag) - - # 1. 0x76 partial START with protocol version + old_etag - await self._write(build_direct_write_partial_start(state.etag)) - response = await self._read(self.TIMEOUT_ACK) - nack = parse_nack(response) - if nack is not None: - opcode, err = nack - if opcode == 0x76 and err == ERR_ETAG_MISMATCH: - _LOGGER.info("Partial upload: device etag mismatch — falling back to full upload") - state.etag = 0 - state.last_image = None - return "fallback_full" - raise ProtocolError(f"Partial 0x76 NACK: opcode=0x{opcode:02x} err=0x{err:02x}") - validate_ack_response(response, CommandCode.DIRECT_WRITE_PARTIAL_START) + _LOGGER.debug("Partial upload: old_etag=0x%08x new_etag=0x%08x", state.etag, new_etag) + + # 1. 0x76 partial START (initial stream bytes packed in where space allows) + start_pkt, remaining = build_direct_write_partial_start( + old_etag=state.etag, + flags=flags, + x=rx, y=ry, width=rw, height=rh, + interleave_span_pixels=span_pixels, + uncompressed_size=uncompressed_size, + stream_bytes=stream_bytes, + ) + await self._write(start_pkt) + try: + response = await self._read(self.TIMEOUT_ACK) + nack = parse_nack(response) + if nack is not None: + opcode, err = nack + if opcode == 0x76 and err == ERR_ETAG_MISMATCH: + _LOGGER.info("Partial upload: device etag mismatch; falling back to full upload") + state.etag = 0 + state.last_image = None + return "fallback_full" + raise ProtocolError(f"Partial 0x76 NACK: opcode=0x{opcode:02x} err=0x{err:02x}") + validate_ack_response(response, CommandCode.DIRECT_WRITE_PARTIAL_START) + except (BLETimeoutError, InvalidResponseError): + _LOGGER.info("Partial upload start was not acknowledged; falling back to full upload") + return "fallback_full" - # 2. 0x77 packets — ACK after each - total_packet_bytes = sum(len(p) - 2 for p in packets) # exclude 2-byte command prefix - bytes_sent = 0 - for i, pkt in enumerate(packets): - _LOGGER.debug("Sending partial packet %d/%d (%d bytes total)", i + 1, len(packets), len(pkt)) - await self._write(pkt) + # 2. 0x71 DATA chunks for remaining stream bytes + chunk_size = ENCRYPTED_CHUNK_SIZE if self._session_key is not None else CHUNK_SIZE + total_stream_bytes = len(stream_bytes) + bytes_sent = total_stream_bytes - len(remaining) + offset = 0 + while offset < len(remaining): + chunk = remaining[offset : offset + chunk_size] + await self._write(build_direct_write_data_command(chunk)) ack = await self._read(self.TIMEOUT_ACK) nack = parse_nack(ack) if nack is not None: opcode, err = nack state.etag = 0 state.last_image = None - _LOGGER.debug("Partial packet %d NACK: opcode=0x%02x err=0x%02x", i + 1, opcode, err) - raise ProtocolError(f"Partial 0x77 NACK: opcode=0x{opcode:02x} err=0x{err:02x}") - validate_ack_response(ack, CommandCode.DIRECT_WRITE_PARTIAL_DATA) - bytes_sent += len(pkt) - 2 + raise ProtocolError(f"Partial 0x71 NACK: opcode=0x{opcode:02x} err=0x{err:02x}") + validate_ack_response(ack, CommandCode.DIRECT_WRITE_DATA) + offset += len(chunk) + bytes_sent += len(chunk) if progress_callback is not None: - progress_callback(bytes_sent, total_packet_bytes) + progress_callback(bytes_sent, total_stream_bytes) - # 3. 0x72 END with new_etag + # 3. 0x72 END with refresh_mode + new_etag (PARTIAL_FLAG_STORE_ETAG is always set) await self._write(build_direct_write_end_with_etag(RefreshMode.PARTIAL.value, new_etag)) response = await self._read(self.TIMEOUT_ACK) validate_ack_response(response, CommandCode.DIRECT_WRITE_END) @@ -1247,22 +1244,6 @@ def segment_fits(seg: Segment) -> bool: state.bytes_per_pixel = 1 return "success" - @staticmethod - def _partial_payload_fits(wire_pixels: bytes, max_segment_wire_bytes: int) -> bool: - """Return whether raw or compressed 0x77 segment payload fits one packet.""" - if SEGMENT_HEADER_SIZE + len(wire_pixels) <= max_segment_wire_bytes: - return True - compressed = zlib.compress(wire_pixels, level=6) - return len(compressed) < len(wire_pixels) and SEGMENT_HEADER_SIZE + len(compressed) <= max_segment_wire_bytes - - @staticmethod - def _choose_partial_payload(wire_pixels: bytes, max_segment_wire_bytes: int) -> tuple[bytes, bool]: - """Choose raw or zlib-compressed bytes for one 0x77 segment payload.""" - compressed = zlib.compress(wire_pixels, level=6) - if len(compressed) < len(wire_pixels) and SEGMENT_HEADER_SIZE + len(compressed) <= max_segment_wire_bytes: - return compressed, True - return wire_pixels, False - @staticmethod def _encode_segment_wire( palette_image: Image.Image, @@ -1274,13 +1255,11 @@ def _encode_segment_wire( ) -> bytes: """Crop the palette image to (x,y,w,h) and encode to tightly packed wire bytes. - Partial 0x77 segments are packed over the full rectangle pixel stream with - no per-row padding. The normal full-frame encoders pad each row to a byte - boundary, which breaks the firmware's segment-length calculation for - widths that are not aligned to 8/4/2 pixels. + Partial rectangles are horizontally byte-aligned, so this produces the + same packed row-major bytes firmware expects for each 0x76 stream group. """ cropped = palette_image.crop((x, y, x + w, y + h)) - pixels = list(cropped.getdata()) + pixels = cropped.tobytes() if color_scheme == ColorScheme.MONO: output = bytearray((len(pixels) + 7) // 8) diff --git a/src/opendisplay/partial.py b/src/opendisplay/partial.py index 84d6d0e..5d939a4 100644 --- a/src/opendisplay/partial.py +++ b/src/opendisplay/partial.py @@ -1,430 +1,134 @@ """Partial rendering support for OpenDisplay BLE devices. -Provides PartialState (caller-owned mutable holder), the DiffStrategy protocol, -Segment dataclass, and built-in diff strategies. +Provides PartialState (caller-owned mutable holder), bounding-rect helpers, +and the logical stream builder for the 0x76 single-rectangle protocol. Serialization format: struct header (magic + version + scalar fields) followed -by a 4-byte big-endian length-prefixed ``last_image`` blob. This avoids the -pickle security surface while remaining compact and version-able. +by a 4-byte big-endian length-prefixed ``last_image`` blob. ``last_image`` stores raw palette bytes (1 byte per pixel, from -``PIL.Image.tobytes()`` on the dithered palette image). The diff operates on -these palette bytes; the library re-encodes segments to wire format before -sending 0x77 packets. ``bytes_per_pixel`` is always 1 (palette representation) -for images stored by the library. +``PIL.Image.tobytes()`` on the dithered palette image). """ from __future__ import annotations import os import struct -from collections.abc import Callable from dataclasses import dataclass -from typing import Protocol # --------------------------------------------------------------------------- # Wire constants mirrored from the firmware partial-rendering protocol. # --------------------------------------------------------------------------- # NACK error codes returned by the device inside {0xFF, opcode, error, 0x00} -ERR_ETAG_MISMATCH = 0x01 # on 0x76: client must fall back to full transfer -ERR_MIXED_DATA = 0x02 # on 0x71 or 0x77: aborted; etag cleared on device -ERR_SEGMENT_OOB = 0x03 # on 0x77: aborted; etag cleared on device -ERR_PARTIAL_VERSION = 0x04 # on 0x76: client protocol version unsupported -ERR_SEGMENT_ALIGN = 0x05 # on 0x77: segment x/width not on an 8-pixel boundary +ERR_ETAG_MISMATCH = 0x01 # on 0x76: client must fall back to full transfer +ERR_MIXED_DATA = 0x02 # aborted; etag cleared on device +ERR_RECT_OOB = 0x03 # on 0x76: rectangle out of display bounds +ERR_PARTIAL_VERSION = 0x04 # on 0x76: client protocol version unsupported +ERR_RECT_ALIGN = 0x05 # on 0x76: x or width not aligned to byte boundary +ERR_PARTIAL_FLAGS = 0x06 # on 0x76: unsupported or reserved flags set +ERR_PARTIAL_SIZE = 0x07 # on 0x76: uncompressed_size does not match geometry +ERR_PARTIAL_SPAN = 0x08 # on 0x76: invalid interleave_span_pixels +ERR_PARTIAL_STREAM = 0x09 # on 0x71/0x72: stream byte count or content error NACK_PREFIX = 0xFF -# 0x77 segment flags. -SEGMENT_FLAG_PLANE_1 = 0x01 # PLANE_1 old-image segment when set; PLANE_0 new-image when clear -SEGMENT_FLAG_COMPRESSED = 0x02 # Payload is one complete zlib stream when set -SEGMENT_FLAG_RESERVED_MASK = 0xFC +# 0x76 flag bits +PARTIAL_FLAG_COMPRESSED = 0x0004 # bit 2: stream is zlib-compressed +PARTIAL_FLAG_STORE_ETAG = 0x0008 # bit 3: 0x72 includes new_etag; store after refresh + +# pixels_per_byte for each bits_per_pixel value +_PIXELS_PER_BYTE: dict[int, int] = {1: 8, 2: 4, 4: 2, 8: 1} def parse_nack(response: bytes) -> tuple[int, int] | None: """Return (opcode, error_code) if response is a 4-byte {0xFF, op, err, 0x00} NACK. - Returns None for any other response shape (caller treats as ACK / handles - via existing validators). + Returns None for any other response shape. """ if len(response) == 4 and response[0] == NACK_PREFIX and response[3] == 0x00: return response[1], response[2] return None -# Segment header size in bytes: x(2)+y(2)+w(2)+h(2)+flags(1) = 9 -SEGMENT_HEADER_SIZE = 9 - -# Minimum region size (pixels) below which we stop recursing -_MIN_REGION_PIXELS = 16 - -# All segments emitted on the wire must have x and width aligned to this -# many pixels. SSD16xx-class controllers stream 8 horizontal pixels per byte -# and the firmware rejects misaligned segments with ERR_SEGMENT_ALIGN. -SEGMENT_PIXEL_ALIGN = 8 - - -# --------------------------------------------------------------------------- -# Segment — geometry + raw palette pixels for one rectangular region -# --------------------------------------------------------------------------- - -@dataclass -class Segment: - """One rectangular region of pixel data with its location. - - ``pixels`` holds raw palette bytes (1 byte per pixel) before wire encoding. - ``plane``: 0 = PLANE_0 (new image), 1 = PLANE_1 (old image). - The library assigns plane values; diff strategies always return plane=0. - """ - - x: int - y: int - width: int - height: int - pixels: bytes # raw palette bytes (1 byte per pixel) - plane: int = 0 # 0 = PLANE_0 (new), 1 = PLANE_1 (old) - compressed: bool = False - - @property - def pixel_count(self) -> int: - return self.width * self.height - # --------------------------------------------------------------------------- -# DiffStrategy protocol -# --------------------------------------------------------------------------- - -class DiffStrategy(Protocol): - """Protocol for pluggable diff strategies. - - Implementations receive the old and new raw palette pixel buffers (1 byte - per pixel) and return a list of Segment objects (plane=0, palette bytes). - The library duplicates each segment for PLANE_1 with old-image pixels. - - Returning an empty list means "no changes detected; skip transfer". - """ - - def diff( - self, - old: bytes, - new: bytes, - width: int, - height: int, - bytes_per_pixel: int, - max_segment_bytes: int, - ) -> list[Segment]: ... - - -# --------------------------------------------------------------------------- -# Built-in strategy: FullImageStrategy -# --------------------------------------------------------------------------- - -class FullImageStrategy: - """Kill-switch strategy that always forces a full 0x71 upload. - - ``diff()`` always returns an empty list so the library falls back to a - full-image transfer via the existing 0x71 path. - """ - - def diff( - self, - old: bytes, - new: bytes, - width: int, - height: int, - bytes_per_pixel: int, - max_segment_bytes: int, - ) -> list[Segment]: - return [] - - -# --------------------------------------------------------------------------- -# Built-in strategy: RecursiveBoundingBoxStrategy -# --------------------------------------------------------------------------- - -class RecursiveBoundingBoxStrategy: - """Recursive bounding-box diff strategy (default). - - Algorithm: - 1. Compute the minimal bounding box of all changed pixels within the - current region. - 2. If the box's pixel data fits within ``max_segment_bytes``, emit it. - 3. Otherwise split along the longer axis of the bounding box, recompute - each child's minimal bounding box, and recurse. - 4. Stop recursing when the region is below ``min_region_pixels`` pixels - and emit as-is to avoid pathological subdivision. - - Input ``old``/``new`` are raw palette bytes (1 byte per pixel). - ``max_segment_bytes`` is the pixel-data budget per segment (segment header - not included); the library computes this from the active MTU. - """ - - def __init__(self, min_region_pixels: int = _MIN_REGION_PIXELS) -> None: - self._min_region_pixels = min_region_pixels - - # ------------------------------------------------------------------ - # Public entry point - # ------------------------------------------------------------------ - - def diff( - self, - old: bytes, - new: bytes, - width: int, - height: int, - bytes_per_pixel: int, - max_segment_bytes: int, - ) -> list[Segment]: - """Return changed segments (plane=0, raw palette pixels).""" - if old == new: - return [] - - segments: list[Segment] = [] - self._recurse( - old, new, width, height, bytes_per_pixel, - 0, 0, width, height, # initial region = full image - lambda seg: len(seg.pixels) <= max_segment_bytes, - segments, - ) - return segments - - def diff_with_fit( - self, - old: bytes, - new: bytes, - width: int, - height: int, - bytes_per_pixel: int, - segment_fits: Callable[[Segment], bool], - ) -> list[Segment]: - """Return changed segments using a caller-defined wire-fit predicate. - - This is used by the BLE upload path because actual 0x77 fit depends on - the display encoding and whether a zlib-compressed segment is smaller - than its raw wire bytes. ``segment_fits`` receives a candidate segment - with raw palette pixels. - """ - if old == new: - return [] - - segments: list[Segment] = [] - self._recurse( - old, new, width, height, bytes_per_pixel, - 0, 0, width, height, - segment_fits, - segments, - ) - return segments - - # ------------------------------------------------------------------ - # Internal helpers - # ------------------------------------------------------------------ - - @staticmethod - def _bounding_box( - old: bytes, - new: bytes, - img_width: int, - bytes_per_pixel: int, - rx: int, - ry: int, - rw: int, - rh: int, - ) -> tuple[int, int, int, int] | None: - """Return minimal bounding box (x0, y0, x1_excl, y1_excl) of changed - pixels within region (rx, ry, rw, rh), or None if no changes.""" - min_x = rw - max_x = -1 - min_y = rh - max_y = -1 - - bpp = bytes_per_pixel - for dy in range(rh): - gy = ry + dy - row_changed = False - for dx in range(rw): - gx = rx + dx - old_off = (gy * img_width + gx) * bpp - new_off = old_off - if old[old_off : old_off + bpp] != new[new_off : new_off + bpp]: - if dx < min_x: - min_x = dx - if dx > max_x: - max_x = dx - row_changed = True - if row_changed: - if dy < min_y: - min_y = dy - if dy > max_y: - max_y = dy - - if max_x < 0: - return None - return (rx + min_x, ry + min_y, rx + max_x + 1, ry + max_y + 1) - - @staticmethod - def _extract_region( - buf: bytes, - img_width: int, - bytes_per_pixel: int, - x0: int, - y0: int, - x1: int, - y1: int, - ) -> bytes: - """Extract pixels from buf for rectangle [x0..x1) × [y0..y1).""" - bpp = bytes_per_pixel - row_bytes = (x1 - x0) * bpp - parts: list[bytes] = [] - for y in range(y0, y1): - off = (y * img_width + x0) * bpp - parts.append(buf[off : off + row_bytes]) - return b"".join(parts) - - def _recurse( - self, - old: bytes, - new: bytes, - img_width: int, - img_height: int, - bytes_per_pixel: int, - rx: int, - ry: int, - rw: int, - rh: int, - segment_fits: Callable[[Segment], bool], - out: list[Segment], - ) -> None: - """Recursively find changed regions within (rx, ry, rw, rh).""" - if rw <= 0 or rh <= 0: - return - - bb = self._bounding_box(old, new, img_width, bytes_per_pixel, rx, ry, rw, rh) - if bb is None: - return # no changes in this region - - x0, y0, x1, y1 = bb - # Snap x0 down and x1 up to the wire-required pixel alignment, clamped - # to the image width. y bounds are not constrained by the controller. - x0 -= x0 % SEGMENT_PIXEL_ALIGN - if x1 % SEGMENT_PIXEL_ALIGN: - x1 += SEGMENT_PIXEL_ALIGN - (x1 % SEGMENT_PIXEL_ALIGN) - if x1 > img_width: - x1 = img_width - # If img_width itself is not aligned, clamp x0 too so width stays aligned. - misalign = (x1 - x0) % SEGMENT_PIXEL_ALIGN - if misalign: - x0 = max(0, x0 - (SEGMENT_PIXEL_ALIGN - misalign)) - bw = x1 - x0 - bh = y1 - y0 - pixel_count = bw * bh - pixels = self._extract_region(new, img_width, bytes_per_pixel, x0, y0, x1, y1) - candidate = Segment(x=x0, y=y0, width=bw, height=bh, pixels=pixels, plane=0) - - if segment_fits(candidate) or pixel_count <= self._min_region_pixels: - # Fits (or too small to split further) — emit as-is - out.append(candidate) - return - - # Split along the longer axis of the bounding box - if bw >= bh: - # Split vertically (along x) at midpoint of bounding box, snapped - # to the wire alignment so the two halves don't overlap after the - # bounding box of each is re-aligned. - mid = x0 + bw // 2 - mid -= mid % SEGMENT_PIXEL_ALIGN - if mid <= rx or mid >= rx + rw: - # Region too narrow to split on an aligned boundary — emit as-is. - out.append(candidate) - return - # Left half: region from rx to mid - self._recurse(old, new, img_width, img_height, bytes_per_pixel, - rx, ry, mid - rx, rh, segment_fits, out) - # Right half: region from mid to rx+rw - self._recurse(old, new, img_width, img_height, bytes_per_pixel, - mid, ry, rx + rw - mid, rh, segment_fits, out) - else: - # Split horizontally (along y) at midpoint of bounding box - mid = y0 + bh // 2 - # Top half - self._recurse(old, new, img_width, img_height, bytes_per_pixel, - rx, ry, rw, mid - ry, segment_fits, out) - # Bottom half - self._recurse(old, new, img_width, img_height, bytes_per_pixel, - rx, mid, rw, ry + rh - mid, segment_fits, out) - - -# --------------------------------------------------------------------------- -# Wire-format segment packing -# --------------------------------------------------------------------------- - -def _build_segment_wire(seg: Segment, wire_pixels: bytes) -> bytes: - """Encode one segment to its 0x77 wire representation. - - Uses *wire_pixels* rather than *seg.pixels* (which are palette bytes); - the caller is responsible for encoding palette bytes → wire format. - - Wire format per segment: x(2BE) y(2BE) w(2BE) h(2BE) flags(1) payload(N) - - flags bit 0 selects PLANE_1 when set; flags bit 1 marks payload as one - complete zlib stream. +# Bounding-rect helpers +# --------------------------------------------------------------------------- + +def compute_bounding_rect( + old: bytes, + new: bytes, + width: int, + height: int, +) -> tuple[int, int, int, int] | None: + """Return (x0, y0, x1_excl, y1_excl) of changed pixels, or None if identical.""" + if old == new: + return None + min_x, max_x, min_y, max_y = width, -1, height, -1 + for y in range(height): + row_off = y * width + row_changed = False + for x in range(width): + if old[row_off + x] != new[row_off + x]: + if x < min_x: + min_x = x + if x > max_x: + max_x = x + row_changed = True + if row_changed: + if y < min_y: + min_y = y + if y > max_y: + max_y = y + if max_x < 0: + return None + return (min_x, min_y, max_x + 1, max_y + 1) + + +def align_rect( + x0: int, + y0: int, + x1: int, + y1: int, + display_width: int, + display_height: int, + pixels_per_byte: int, +) -> tuple[int, int, int, int]: + """Expand (x0, y0, x1, y1) to packed-byte boundaries. + + Returns (x, y, width, height) ready to send in 0x76. """ - flags = SEGMENT_FLAG_PLANE_1 if (seg.plane & 0x01) else 0 - if seg.compressed: - flags |= SEGMENT_FLAG_COMPRESSED - header = struct.pack(">HHHHB", seg.x, seg.y, seg.width, seg.height, flags) - return header + wire_pixels - - -def pack_segments_into_packets( - segments: list[tuple[Segment, bytes]], - mtu: int, - cmd_prefix: bytes = b"\x00\x77", -) -> list[bytes]: - """Pack (segment, wire_pixels) pairs into 0x77 BLE packets. - - Preserves input order and fills packets sequentially. - - Args: - segments: List of (Segment, wire_pixels) where wire_pixels is the - encoded pixel data for that segment. - mtu: Maximum total packet size in bytes (including cmd_prefix). - cmd_prefix: 2-byte opcode prefix (default 0x0077 big-endian). - - Returns: - List of complete BLE packet bytes. + aligned_x0 = (x0 // pixels_per_byte) * pixels_per_byte + aligned_x1 = x1 + if aligned_x1 % pixels_per_byte: + aligned_x1 += pixels_per_byte - (aligned_x1 % pixels_per_byte) + if aligned_x1 > display_width: + aligned_x1 = display_width + misalign = (aligned_x1 - aligned_x0) % pixels_per_byte + if misalign: + aligned_x0 = max(0, aligned_x0 - (pixels_per_byte - misalign)) + return (aligned_x0, y0, aligned_x1 - aligned_x0, y1 - y0) + + +def build_partial_logical_stream( + old_rect_bytes: bytes, + new_rect_bytes: bytes, + span_bytes: int, +) -> bytes: + """Interleave old and new rect bytes in old-first order. + + Produces: old_group_0 + new_group_0 + old_group_1 + new_group_1 + ... """ - if not segments: - return [] - - max_payload = mtu - len(cmd_prefix) - - # Pre-compute wire representation for each (segment, wire_pixels) - wires: list[bytes] = [_build_segment_wire(seg, wp) for seg, wp in segments] - packets: list[bytes] = [] - packet_parts: list[bytes] = [] - space = max_payload - - for wire in wires: - if len(wire) > max_payload: - if packet_parts: - packets.append(cmd_prefix + b"".join(packet_parts)) - packet_parts = [] - space = max_payload - packets.append(cmd_prefix + wire) - continue - - if len(wire) > space: - packets.append(cmd_prefix + b"".join(packet_parts)) - packet_parts = [] - space = max_payload - - packet_parts.append(wire) - space -= len(wire) - - if packet_parts: - packets.append(cmd_prefix + b"".join(packet_parts)) - - return packets + assert len(old_rect_bytes) == len(new_rect_bytes), "old/new rect byte lengths must match" + parts: list[bytes] = [] + total = len(old_rect_bytes) + offset = 0 + while offset < total: + chunk = min(span_bytes, total - offset) + parts.append(old_rect_bytes[offset : offset + chunk]) + parts.append(new_rect_bytes[offset : offset + chunk]) + offset += chunk + return b"".join(parts) # --------------------------------------------------------------------------- diff --git a/src/opendisplay/protocol/__init__.py b/src/opendisplay/protocol/__init__.py index d983c99..5e92643 100644 --- a/src/opendisplay/protocol/__init__.py +++ b/src/opendisplay/protocol/__init__.py @@ -17,7 +17,6 @@ build_direct_write_start_compressed, build_direct_write_start_uncompressed, build_led_activate_command, - build_partial_data_packet, build_read_config_command, build_read_fw_version_command, build_reboot_command, @@ -53,7 +52,6 @@ "build_direct_write_data_command", "build_direct_write_end_command", "build_direct_write_end_with_etag", - "build_partial_data_packet", "build_led_activate_command", "parse_config_response", "serialize_config", diff --git a/src/opendisplay/protocol/commands.py b/src/opendisplay/protocol/commands.py index 6eb242a..f7cb586 100644 --- a/src/opendisplay/protocol/commands.py +++ b/src/opendisplay/protocol/commands.py @@ -2,6 +2,7 @@ from __future__ import annotations +import struct from enum import IntEnum from ..models.led_flash import LedFlashConfig @@ -31,8 +32,7 @@ class CommandCode(IntEnum): 0x0073 # Device→host: refresh finished (same code as LED_ACTIVATE, different direction) ) DIRECT_WRITE_REFRESH_TIMEOUT = 0x0074 # Device→host: refresh timed out - DIRECT_WRITE_PARTIAL_START = 0x0076 # Start a versioned partial update transfer - DIRECT_WRITE_PARTIAL_DATA = 0x0077 # Send partial image segments + DIRECT_WRITE_PARTIAL_START = 0x0076 # Start a versioned partial update transfer (stream via 0x71) # Protocol constants @@ -139,17 +139,49 @@ def build_direct_write_start_uncompressed() -> bytes: return CommandCode.DIRECT_WRITE_START.to_bytes(2, byteorder="big") -def build_direct_write_partial_start(old_etag: int, version: int = 1) -> bytes: - """Build 0x76 partial START with protocol version and old_etag. +def build_direct_write_partial_start( + old_etag: int, + flags: int, + x: int, + y: int, + width: int, + height: int, + interleave_span_pixels: int, + uncompressed_size: int, + stream_bytes: bytes = b"", + version: int = 1, +) -> tuple[bytes, bytes]: + """Build 0x76 partial START packet. + + Fixed payload is 21 bytes; optional initial stream bytes are appended up + to MAX_START_PAYLOAD total packet size (including the 2-byte command). + + Wire v1 fixed payload: + version(1) + flags(2BE) + old_etag(4BE) + x(2BE) + y(2BE) + + width(2BE) + height(2BE) + interleave_span_pixels(2BE) + + uncompressed_size(4LE) - Wire v1: [0x0076][version:1][old_etag:4 BE] + Returns: + (start_packet, remaining_stream_bytes) — send start_packet as the + 0x76 command, then remaining_stream_bytes via 0x71 DATA chunks. """ if not 0 <= version <= 0xFF: raise ValueError(f"partial protocol version out of uint8 range: {version}") - if not 0 <= old_etag <= 0xFFFFFFFF: - raise ValueError(f"old_etag out of uint32 range: {old_etag}") + if not 1 <= old_etag <= 0xFFFFFFFF: + raise ValueError(f"old_etag must be non-zero uint32, got {old_etag}") + + fixed = ( + struct.pack(">BH", version, flags) + + struct.pack(">I", old_etag) + + struct.pack(">HHHHH", x, y, width, height, interleave_span_pixels) + + struct.pack(" bytes: @@ -202,12 +234,6 @@ def build_direct_write_end_with_etag(refresh_mode: int, new_etag: int) -> bytes: return cmd + refresh_mode.to_bytes(1, byteorder="big") + new_etag.to_bytes(4, byteorder="big") -def build_partial_data_packet(payload: bytes) -> bytes: - """Wrap a packed segment payload in the 0x77 opcode prefix.""" - cmd = CommandCode.DIRECT_WRITE_PARTIAL_DATA.to_bytes(2, byteorder="big") - return cmd + payload - - def build_led_activate_command( led_instance: int, flash_config: LedFlashConfig, diff --git a/tests/unit/test_device_partial.py b/tests/unit/test_device_partial.py new file mode 100644 index 0000000..f80fa40 --- /dev/null +++ b/tests/unit/test_device_partial.py @@ -0,0 +1,205 @@ +"""Client-flow tests for streamed partial uploads.""" + +from __future__ import annotations + +import asyncio + +from epaper_dithering import ColorScheme +from PIL import Image + +from opendisplay import OpenDisplayDevice +from opendisplay.models.capabilities import DeviceCapabilities +from opendisplay.models.config import DisplayConfig, GlobalConfig, ManufacturerData, PowerOption, SystemConfig +from opendisplay.models.enums import RefreshMode +from opendisplay.partial import ERR_ETAG_MISMATCH, PARTIAL_FLAG_COMPRESSED, PartialState + + +def _config(partial_update_support: int = 1, transmission_modes: int = 0x00) -> GlobalConfig: + return GlobalConfig( + system=SystemConfig( + ic_type=0, + communication_modes=0, + device_flags=0, + pwr_pin=0xFF, + reserved=b"\x00" * 17, + ), + manufacturer=ManufacturerData( + manufacturer_id=0, + board_type=0, + board_revision=0, + reserved=b"\x00" * 18, + ), + power=PowerOption( + power_mode=0, + battery_capacity_mah=b"\x00\x00\x00", + sleep_timeout_ms=0, + tx_power=0, + sleep_flags=0, + battery_sense_pin=0xFF, + battery_sense_enable_pin=0xFF, + battery_sense_flags=0, + capacity_estimator=0, + voltage_scaling_factor=0, + deep_sleep_current_ua=0, + deep_sleep_time_seconds=0, + reserved=b"\x00" * 12, + ), + displays=[ + DisplayConfig( + instance_number=0, + display_technology=0, + panel_ic_type=0, + pixel_width=16, + pixel_height=8, + active_width_mm=10, + active_height_mm=10, + tag_type=0, + rotation=0, + reset_pin=0xFF, + busy_pin=0xFF, + dc_pin=0xFF, + cs_pin=0xFF, + data_pin=0, + partial_update_support=partial_update_support, + color_scheme=ColorScheme.MONO.value, + transmission_modes=transmission_modes, + clk_pin=0, + reserved_pins=b"\x00" * 7, + full_update_mC=0, + reserved=b"\x00" * 13, + ) + ], + ) + + +def _device(config: GlobalConfig | None = None) -> OpenDisplayDevice: + return OpenDisplayDevice( + mac_address="AA:BB:CC:DD:EE:FF", + config=config or _config(), + capabilities=DeviceCapabilities(width=16, height=8, color_scheme=ColorScheme.MONO), + ) + + +def _image(changed: bool = False) -> Image.Image: + img = Image.new("P", (16, 8), 0) + if changed: + img.putpixel((13, 3), 1) + return img + + +def test_no_change_image_skips_transfer(monkeypatch): + device = _device() + state = PartialState(etag=0x01020304, last_image=_image().tobytes(), width=16, height=8, bytes_per_pixel=1) + + async def fail_write(data: bytes) -> None: + raise AssertionError(f"unexpected write: {data!r}") + + monkeypatch.setattr(device, "_write", fail_write) + + outcome = asyncio.run(device._maybe_upload_partial(_image(), b"\x00" * 16, RefreshMode.PARTIAL, state, None)) + + assert outcome == "no_change" + assert state.etag == 0x01020304 + + +def test_valid_partial_never_sends_0x70_and_uses_uncompressed_0x76(monkeypatch): + device = _device() + old = _image() + new = _image(changed=True) + state = PartialState(etag=0x01020304, last_image=old.tobytes(), width=16, height=8, bytes_per_pixel=1) + writes: list[bytes] = [] + responses = [b"\x00\x76", b"\x00\x72", b"\x00\x73"] + + async def capture_write(data: bytes) -> None: + writes.append(data) + + async def read_response(timeout: float) -> bytes: + return responses.pop(0) + + monkeypatch.setattr(device, "_write", capture_write) + monkeypatch.setattr(device, "_read", read_response) + + outcome = asyncio.run(device._maybe_upload_partial(new, b"\x00" * 16, RefreshMode.PARTIAL, state, None)) + + opcodes = [int.from_bytes(w[:2], "big") for w in writes] + assert outcome == "success" + assert 0x70 not in opcodes + assert opcodes == [0x76, 0x72] + assert int.from_bytes(writes[0][3:5], "big") & PARTIAL_FLAG_COMPRESSED == 0 + + +def test_empty_state_falls_back_to_full(monkeypatch): + device = _device() + state = PartialState() + full_uploads = 0 + refresh_modes: list[RefreshMode] = [] + + async def execute_upload(image_data, refresh_mode, **kwargs) -> None: + nonlocal full_uploads + full_uploads += 1 + refresh_modes.append(refresh_mode) + + monkeypatch.setattr(device, "_execute_upload", execute_upload) + + asyncio.run(device.upload_prepared_image((b"\x00" * 16, None, _image()), refresh_mode=RefreshMode.PARTIAL, state=state)) + + assert full_uploads == 1 + assert refresh_modes == [RefreshMode.FULL] + assert state.etag != 0 + assert state.last_image == _image().tobytes() + + +def test_etag_mismatch_clears_state_and_retries_full_once(monkeypatch): + device = _device() + state = PartialState(etag=0x01020304, last_image=_image().tobytes(), width=16, height=8, bytes_per_pixel=1) + full_uploads = 0 + refresh_modes: list[RefreshMode] = [] + + async def capture_write(data: bytes) -> None: + pass + + async def read_response(timeout: float) -> bytes: + return bytes([0xFF, 0x76, ERR_ETAG_MISMATCH, 0x00]) + + async def execute_upload(image_data, refresh_mode, **kwargs) -> None: + nonlocal full_uploads + full_uploads += 1 + refresh_modes.append(refresh_mode) + + monkeypatch.setattr(device, "_write", capture_write) + monkeypatch.setattr(device, "_read", read_response) + monkeypatch.setattr(device, "_execute_upload", execute_upload) + + asyncio.run(device.upload_prepared_image((b"\x00" * 16, None, _image(changed=True)), state=state)) + + assert full_uploads == 1 + assert refresh_modes == [RefreshMode.FULL] + assert state.etag != 0 + assert state.last_image == _image(changed=True).tobytes() + + +def test_partial_request_uses_partial_even_when_full_compressed_is_smaller(monkeypatch): + device = _device(_config(transmission_modes=0x02)) + old = _image() + new = Image.new("P", (16, 8), 1) + state = PartialState(etag=0x01020304, last_image=old.tobytes(), width=16, height=8, bytes_per_pixel=1) + writes: list[bytes] = [] + responses = [b"\x00\x76", b"\x00\x72", b"\x00\x73"] + + async def capture_write(data: bytes) -> None: + writes.append(data) + + async def read_response(timeout: float) -> bytes: + return responses.pop(0) + + async def fail_full_upload(*args, **kwargs) -> None: + raise AssertionError("partial request unexpectedly fell back to full upload") + + monkeypatch.setattr(device, "_write", capture_write) + monkeypatch.setattr(device, "_read", read_response) + monkeypatch.setattr(device, "_execute_upload", fail_full_upload) + + asyncio.run(device.upload_prepared_image((b"\xff" * 16, b"\x01", new), refresh_mode=RefreshMode.PARTIAL, state=state)) + + opcodes = [int.from_bytes(w[:2], "big") for w in writes] + assert opcodes == [0x76, 0x72] diff --git a/tests/unit/test_partial.py b/tests/unit/test_partial.py index 06a2fa9..0568b3d 100644 --- a/tests/unit/test_partial.py +++ b/tests/unit/test_partial.py @@ -1,58 +1,52 @@ -"""Unit tests for partial-rendering module (PartialState, diff, packer, NACK).""" +"""Unit tests for the 0x76 streamed-rectangle partial protocol.""" from __future__ import annotations -import zlib - import pytest from opendisplay.partial import ( ERR_ETAG_MISMATCH, ERR_MIXED_DATA, ERR_PARTIAL_VERSION, - ERR_SEGMENT_ALIGN, - ERR_SEGMENT_OOB, - SEGMENT_HEADER_SIZE, - SEGMENT_FLAG_COMPRESSED, - SEGMENT_FLAG_PLANE_1, - SEGMENT_PIXEL_ALIGN, - FullImageStrategy, + ERR_RECT_ALIGN, + ERR_RECT_OOB, + PARTIAL_FLAG_COMPRESSED, + PARTIAL_FLAG_STORE_ETAG, PartialState, - RecursiveBoundingBoxStrategy, - Segment, _generate_etag, - pack_segments_into_packets, + align_rect, + build_partial_logical_stream, + compute_bounding_rect, parse_nack, ) from opendisplay.protocol.commands import ( - CHUNK_SIZE, - ENCRYPTED_CHUNK_SIZE, + MAX_START_PAYLOAD, build_direct_write_end_with_etag, build_direct_write_partial_start, - build_partial_data_packet, ) class TestPartialState: def test_roundtrip_empty(self): - s = PartialState() - out = PartialState.from_bytes(s.to_bytes()) - assert out == s + state = PartialState() + assert PartialState.from_bytes(state.to_bytes()) == state def test_roundtrip_populated(self): - s = PartialState( + state = PartialState( etag=0xDEADBEEF, last_image=bytes(range(256)) * 4, width=480, height=800, bytes_per_pixel=1, ) - out = PartialState.from_bytes(s.to_bytes()) - assert out.etag == s.etag - assert out.last_image == s.last_image - assert out.width == s.width - assert out.height == s.height - assert out.bytes_per_pixel == s.bytes_per_pixel + + out = PartialState.from_bytes(state.to_bytes()) + + assert out.etag == state.etag + assert out.last_image == state.last_image + assert out.width == state.width + assert out.height == state.height + assert out.bytes_per_pixel == state.bytes_per_pixel def test_bad_magic_rejected(self): with pytest.raises(ValueError, match="magic"): @@ -64,263 +58,116 @@ def test_truncated_rejected(self): class TestParseNack: - def test_etag_mismatch(self): + def test_known_errors(self): assert parse_nack(b"\xff\x76\x01\x00") == (0x76, ERR_ETAG_MISMATCH) - - def test_mixed_data_on_partial(self): - assert parse_nack(b"\xff\x77\x02\x00") == (0x77, ERR_MIXED_DATA) - - def test_oob(self): - assert parse_nack(b"\xff\x77\x03\x00") == (0x77, ERR_SEGMENT_OOB) - - def test_bad_version(self): + assert parse_nack(b"\xff\x70\x02\x00") == (0x70, ERR_MIXED_DATA) + assert parse_nack(b"\xff\x76\x03\x00") == (0x76, ERR_RECT_OOB) assert parse_nack(b"\xff\x76\x04\x00") == (0x76, ERR_PARTIAL_VERSION) - - def test_segment_align(self): - assert parse_nack(b"\xff\x77\x05\x00") == (0x77, ERR_SEGMENT_ALIGN) + assert parse_nack(b"\xff\x76\x05\x00") == (0x76, ERR_RECT_ALIGN) def test_not_nack_returns_none(self): assert parse_nack(b"\x00\x76") is None - assert parse_nack(b"\xff\x70\x01") is None # too short - assert parse_nack(b"\xff\x70\x01\x00\x00") is None # too long - assert parse_nack(b"\xff\x70\x01\x01") is None # last byte not 0 + assert parse_nack(b"\xff\x70\x01") is None + assert parse_nack(b"\xff\x70\x01\x00\x00") is None + assert parse_nack(b"\xff\x70\x01\x01") is None class TestGenerateEtag: def test_nonzero_uint32(self): for _ in range(100): - v = _generate_etag() - assert 1 <= v <= 0xFFFFFFFF + value = _generate_etag() + assert 1 <= value <= 0xFFFFFFFF -class TestRecursiveBoundingBoxStrategy: - @staticmethod - def _paint_rect(buf: bytearray, width: int, x: int, y: int, w: int, h: int, value: int = 1) -> None: - for dy in range(h): - row = (y + dy) * width - for dx in range(w): - buf[row + x + dx] = value - - def test_no_change_returns_empty(self): - buf = bytes(100 * 100) - segs = RecursiveBoundingBoxStrategy().diff(buf, buf, 100, 100, 1, 4096) - assert segs == [] +class TestBoundingRect: + def test_no_change_returns_none(self): + buf = bytes(100) + assert compute_bounding_rect(buf, buf, 10, 10) is None def test_single_pixel_change(self): - old = bytearray(100 * 100) - new = bytearray(old) - new[50 * 100 + 30] = 1 - segs = RecursiveBoundingBoxStrategy().diff(bytes(old), bytes(new), 100, 100, 1, 4096) - assert len(segs) == 1 - s = segs[0] - # Bounding box is the single pixel at (30, 50); aligned to 8-pixel - # boundary becomes x=24, width=8. - assert s.x == 24 and s.y == 50 - assert s.width == 8 and s.height == 1 - # The changed pixel is at column 30, i.e. index (30 - 24) = 6 in the segment row. - assert s.pixels == b"\x00\x00\x00\x00\x00\x00\x01\x00" - assert s.plane == 0 - - def test_single_filled_rectangle_returns_aligned_bounds(self): - w, h = 16, 12 - old = bytearray(w * h) + old = bytearray(16 * 8) new = bytearray(old) - self._paint_rect(new, w, x=3, y=4, w=5, h=3, value=1) - - segs = RecursiveBoundingBoxStrategy().diff(bytes(old), bytes(new), w, h, 1, 4096) - - assert len(segs) == 1 - seg = segs[0] - # Original bbox (3, 4, 5, 3): x snaps down to 0, x_end (3+5=8) is already aligned. - assert (seg.x, seg.y, seg.width, seg.height) == (0, 4, 8, 3) - assert seg.pixel_count == 24 - # Pixels in cols 3..7 of each row are 1, cols 0..2 are 0. - expected_row = b"\x00\x00\x00\x01\x01\x01\x01\x01" - assert seg.pixels == expected_row * 3 - - def test_two_disjoint_rectangles_return_two_segments_when_bbox_exceeds_budget(self): - w, h = 32, 20 - old = bytearray(w * h) + new[3 * 16 + 13] = 1 + + assert compute_bounding_rect(bytes(old), bytes(new), 16, 8) == (13, 3, 14, 4) + + def test_multiple_changes_one_bounding_box(self): + old = bytearray(32 * 20) new = bytearray(old) - self._paint_rect(new, w, x=1, y=2, w=4, h=3, value=1) - self._paint_rect(new, w, x=24, y=12, w=5, h=4, value=1) + new[2 * 32 + 1] = 1 + new[15 * 32 + 28] = 1 - # The combined bounding box would be 28x14 = 392 pixels, forcing a split. - segs = RecursiveBoundingBoxStrategy(min_region_pixels=1).diff(bytes(old), bytes(new), w, h, 1, 64) + assert compute_bounding_rect(bytes(old), bytes(new), 32, 20) == (1, 2, 29, 16) - actual = sorted((seg.x, seg.y, seg.width, seg.height) for seg in segs) - # (1,2,4,3) → x=0, x_end=8 → (0, 2, 8, 3) - # (24,12,5,4) → x=24, x_end=32 → (24, 12, 8, 4) - assert actual == [(0, 2, 8, 3), (24, 12, 8, 4)] - def test_changed_hollow_rectangle_preserves_bounding_box_pixels(self): - w, h = 16, 10 - old = bytearray(w * h) - new = bytearray(old) - self._paint_rect(new, w, x=2, y=2, w=4, h=1, value=1) - self._paint_rect(new, w, x=2, y=5, w=4, h=1, value=1) - self._paint_rect(new, w, x=2, y=2, w=1, h=4, value=1) - self._paint_rect(new, w, x=5, y=2, w=1, h=4, value=1) - - segs = RecursiveBoundingBoxStrategy().diff(bytes(old), bytes(new), w, h, 1, 4096) - - assert len(segs) == 1 - seg = segs[0] - # Original bbox (2, 2, 4, 4): x snaps down to 0, x_end (2+4=6) snaps up to 8. - assert (seg.x, seg.y, seg.width, seg.height) == (0, 2, 8, 4) - # Each row is the original cols 0..7 of the new image at that y. - # Hollow rect is at cols 2..5, rows 2..5. - assert seg.pixels == ( - b"\x00\x00\x01\x01\x01\x01\x00\x00" # y=2: cols 2..5 are top edge - b"\x00\x00\x01\x00\x00\x01\x00\x00" # y=3: cols 2 and 5 (sides) - b"\x00\x00\x01\x00\x00\x01\x00\x00" # y=4: cols 2 and 5 (sides) - b"\x00\x00\x01\x01\x01\x01\x00\x00" # y=5: cols 2..5 are bottom edge - ) +class TestAlignRect: + def test_mono_expands_to_8_pixels(self): + assert align_rect(13, 3, 14, 4, 32, 20, pixels_per_byte=8) == (8, 3, 8, 1) - def test_segments_are_8px_aligned(self): - # Adversarial rectangles that don't naturally land on 8-pixel boundaries. - w, h = 64, 32 - old = bytes(w * h) - new_buf = bytearray(old) - for x, y, rw, rh in [(1, 1, 3, 3), (13, 5, 5, 4), (37, 20, 11, 7)]: - self._paint_rect(new_buf, w, x=x, y=y, w=rw, h=rh, value=1) - segs = RecursiveBoundingBoxStrategy(min_region_pixels=1).diff( - bytes(old), bytes(new_buf), w, h, 1, max_segment_bytes=128 - ) - assert segs, "expected at least one segment" - for s in segs: - assert s.x % SEGMENT_PIXEL_ALIGN == 0, f"x={s.x} not aligned" - assert s.width % SEGMENT_PIXEL_ALIGN == 0, f"width={s.width} not aligned" - assert s.x + s.width <= w - - def test_full_change_tiles_image(self): - # 64x64 with every pixel different; budget 256 bytes - # → recursion must split until each tile fits. - w, h = 64, 64 - old = bytes(w * h) - new = bytes([1] * (w * h)) - segs = RecursiveBoundingBoxStrategy(min_region_pixels=4).diff( - old, new, w, h, 1, max_segment_bytes=256 - ) - # All segments must be within image bounds and fit budget - for s in segs: - assert s.x >= 0 and s.y >= 0 - assert s.x + s.width <= w - assert s.y + s.height <= h - assert len(s.pixels) <= 256 or s.width * s.height <= 4 - # Coverage check: union of segments covers every changed pixel - covered = bytearray(w * h) - for s in segs: - for dy in range(s.height): - for dx in range(s.width): - covered[(s.y + dy) * w + (s.x + dx)] = 1 - assert all(b == 1 for b in covered) - - def test_diff_with_fit_uses_wire_fit_predicate(self): - # The old palette-byte budget would split this full-height change many - # times. The wire predicate models a mono segment: 512 pixels encode to - # 64 wire bytes, so the whole changed rectangle should be accepted. - w, h = 64, 8 - old = bytes(w * h) - new = bytes([1] * (w * h)) - - def mono_wire_fits(seg: Segment) -> bool: - wire_bytes = (seg.pixel_count + 7) // 8 - return wire_bytes <= 64 - - segs = RecursiveBoundingBoxStrategy(min_region_pixels=1).diff_with_fit(old, new, w, h, 1, mono_wire_fits) - - assert [(s.x, s.y, s.width, s.height) for s in segs] == [(0, 0, 64, 8)] - - def test_diff_with_fit_can_accept_compressed_large_region(self): - w, h = 64, 64 - old = bytes(w * h) - new = bytes([1] * (w * h)) - - def compressed_wire_fits(seg: Segment) -> bool: - raw_wire = bytes([0xFF]) * ((seg.pixel_count + 7) // 8) - return len(raw_wire) <= 64 or len(zlib.compress(raw_wire, level=6)) <= 64 - - segs = RecursiveBoundingBoxStrategy(min_region_pixels=1).diff_with_fit( - old, new, w, h, 1, compressed_wire_fits - ) + def test_2bpp_expands_to_4_pixels(self): + assert align_rect(5, 1, 7, 3, 32, 20, pixels_per_byte=4) == (4, 1, 4, 2) - assert [(s.x, s.y, s.width, s.height) for s in segs] == [(0, 0, 64, 64)] - - def test_respects_chunk_size_budget(self): - # Confirm segments respect both unencrypted and encrypted MTU minus header. - w, h = 32, 32 - old = bytes(w * h) - new = bytes([1] * (w * h)) - for chunk in (CHUNK_SIZE, ENCRYPTED_CHUNK_SIZE): - budget = chunk - SEGMENT_HEADER_SIZE - segs = RecursiveBoundingBoxStrategy(min_region_pixels=1).diff( - old, new, w, h, 1, max_segment_bytes=budget - ) - for s in segs: - # tiny regions allowed to exceed via min_region_pixels guard, - # but here min_region_pixels=1 so they must all fit. - assert s.width * s.height <= budget - - -class TestFullImageStrategy: - def test_always_empty(self): - old = bytes(100) - new = bytes([1] * 100) - assert FullImageStrategy().diff(old, new, 10, 10, 1, 4096) == [] - - -class TestPackSegmentsIntoPackets: - @staticmethod - def _seg(x, y, w, h, n): - return Segment(x=x, y=y, width=w, height=h, pixels=bytes(n), plane=0), bytes([0xAA] * n) - - def test_empty(self): - assert pack_segments_into_packets([], mtu=230) == [] - - def test_each_packet_within_mtu(self): - pairs = [self._seg(0, 0, 10, 10, 100) for _ in range(5)] - pairs += [self._seg(0, 0, 5, 5, 25) for _ in range(10)] - packets = pack_segments_into_packets(pairs, mtu=230) - for p in packets: - assert len(p) <= 230 - assert p[:2] == b"\x00\x77" - - def test_every_segment_appears_once(self): - # Use unique pixel sentinels so we can detect duplicates / drops - pairs = [] - for i in range(20): - seg = Segment(x=i, y=0, width=4, height=4, pixels=b"", plane=0) - wire = bytes([i] * 30) - pairs.append((seg, wire)) - packets = pack_segments_into_packets(pairs, mtu=230) - # Sum of all bytes after 0x0077 prefix == sum of all wire+header bytes - total_payload = sum(len(p) - 2 for p in packets) - # 9-byte header per segment + 30 byte payload = 39 bytes per segment, 20 segments - assert total_payload == 20 * (SEGMENT_HEADER_SIZE + 30) - - def test_segment_flags_include_plane_and_compression(self): - compressed = zlib.compress(bytes([0x00]) * 64) - packets = pack_segments_into_packets( - [(Segment(x=0, y=0, width=64, height=8, pixels=b"", plane=1, compressed=True), compressed)], - mtu=230, - ) + def test_4bpp_expands_to_2_pixels(self): + assert align_rect(5, 1, 6, 3, 32, 20, pixels_per_byte=2) == (4, 1, 2, 2) - assert len(packets) == 1 - assert packets[0][:2] == b"\x00\x77" - assert packets[0][10] == SEGMENT_FLAG_PLANE_1 | SEGMENT_FLAG_COMPRESSED - assert zlib.decompress(packets[0][11:]) == bytes([0x00]) * 64 + def test_right_edge_clamp_keeps_width_aligned(self): + assert align_rect(30, 1, 32, 2, 32, 20, pixels_per_byte=8) == (24, 1, 8, 1) -class TestNewBuilders: - def test_partial_start(self): - cmd = build_direct_write_partial_start(0xDEADBEEF) - assert cmd == b"\x00\x76\x01\xde\xad\xbe\xef" +class TestLogicalStream: + def test_old_new_interleaving(self): + stream = build_partial_logical_stream(b"abcdef", b"ABCDEF", span_bytes=2) + assert stream == b"abABcdCDefEF" - def test_end_with_etag(self): - cmd = build_direct_write_end_with_etag(refresh_mode=0, new_etag=0x01020304) - assert cmd == b"\x00\x72\x00\x01\x02\x03\x04" + def test_last_group_can_be_short(self): + stream = build_partial_logical_stream(b"abcde", b"ABCDE", span_bytes=4) + assert stream == b"abcdABCDeE" + + def test_stream_accounting(self): + old = bytes(range(16)) + new = bytes(range(16, 32)) + stream = build_partial_logical_stream(old, new, span_bytes=8) - def test_partial_data_packet(self): - cmd = build_partial_data_packet(b"\x01\x02\x03") - assert cmd == b"\x00\x77\x01\x02\x03" + assert len(stream) == 32 + assert stream[:8] == old[:8] + assert stream[8:16] == new[:8] + assert stream[16:24] == old[8:] + assert stream[24:32] == new[8:] + + +class TestBuilders: + def test_partial_start_fixed_fields_and_initial_bytes(self): + stream = bytes(range(200)) + packet, remaining = build_direct_write_partial_start( + old_etag=0xDEADBEEF, + flags=PARTIAL_FLAG_COMPRESSED | PARTIAL_FLAG_STORE_ETAG, + x=8, + y=9, + width=16, + height=10, + interleave_span_pixels=128, + uncompressed_size=40, + stream_bytes=stream, + ) + + assert len(packet) == MAX_START_PAYLOAD + assert packet[:2] == b"\x00\x76" + assert packet[2] == 1 + assert int.from_bytes(packet[3:5], "big") == PARTIAL_FLAG_COMPRESSED | PARTIAL_FLAG_STORE_ETAG + assert int.from_bytes(packet[5:9], "big") == 0xDEADBEEF + assert int.from_bytes(packet[9:11], "big") == 8 + assert int.from_bytes(packet[11:13], "big") == 9 + assert int.from_bytes(packet[13:15], "big") == 16 + assert int.from_bytes(packet[15:17], "big") == 10 + assert int.from_bytes(packet[17:19], "big") == 128 + assert int.from_bytes(packet[19:23], "little") == 40 + assert packet[23:] == stream[:177] + assert remaining == stream[177:] + + def test_partial_start_rejects_zero_etag(self): + with pytest.raises(ValueError, match="old_etag"): + build_direct_write_partial_start(0, 0, 0, 0, 8, 1, 8, 2) + + def test_end_with_etag(self): + cmd = build_direct_write_end_with_etag(refresh_mode=2, new_etag=0x01020304) + assert cmd == b"\x00\x72\x02\x01\x02\x03\x04" From 6fea4f9b71b3e159a85f8d26ebfa2ccdc4da61bc Mon Sep 17 00:00:00 2001 From: Michael Bisbjerg Date: Tue, 28 Apr 2026 13:17:51 +0200 Subject: [PATCH 09/18] Use plane-major partial streams --- docs/partial-update-protocol.md | 22 +++++++------------- src/opendisplay/device.py | 12 +++-------- src/opendisplay/partial.py | 18 ++++------------- src/opendisplay/protocol/commands.py | 12 +++++------ tests/unit/test_partial.py | 30 ++++++++++++---------------- 5 files changed, 32 insertions(+), 62 deletions(-) diff --git a/docs/partial-update-protocol.md b/docs/partial-update-protocol.md index df82910..0e873ca 100644 --- a/docs/partial-update-protocol.md +++ b/docs/partial-update-protocol.md @@ -18,7 +18,6 @@ Full uploads continue to use `0x70`, `0x71`, and `0x72`. `0x77` is unused. [flags:2 BE] [old_etag:4 BE] [x:2 BE][y:2 BE][width:2 BE][height:2 BE] -[interleave_span_pixels:2 BE] [uncompressed_size:4 LE] [initial_stream_bytes...] ``` @@ -29,11 +28,9 @@ logical bytes. `uncompressed_size` is always `rect_bytes * 2`. Flags: ```text -bits 0..1: plane order, 0 = old PLANE_1 then new PLANE_0 bit 2: stream is zlib-compressed bit 3: 0x72 includes new_etag to store after successful refresh -bit 4: keep panel awake hint -bits 5..15: reserved, must be 0 +all other bits: reserved, must be 0 ``` The rectangle must be in bounds. `x` and `width` must be aligned to the active @@ -42,19 +39,15 @@ packed-pixel byte boundary: 8 pixels for 1 bpp, 4 for 2 bpp, 2 for 4 bpp, and ## Stream Body -The logical stream contains both old and new rectangle images. In the default -plane order: +The logical stream contains both old and new rectangle images in this order: ```text -old group 0 bytes for PLANE_1 -new group 0 bytes for PLANE_0 -old group 1 bytes for PLANE_1 -new group 1 bytes for PLANE_0 -... +old rectangle bytes for PLANE_1 +new rectangle bytes for PLANE_0 ``` -`interleave_span_pixels` defines each group. Clients initially use row bands, -usually `width * 8` pixels, so each group maps to a simple rectangle. +Firmware writes the full old rectangle first, resets the address window to the +same rectangle, then writes the full new rectangle. ## `0x71` Data @@ -91,6 +84,5 @@ Known partial NACK error codes use `{0xFF, opcode, error, 0x00}`: 0x05: rectangle alignment error 0x06: unsupported or reserved flags 0x07: uncompressed_size mismatch -0x08: invalid interleave_span_pixels -0x09: stream byte count or content error +0x08: stream byte count or content error ``` diff --git a/src/opendisplay/device.py b/src/opendisplay/device.py index b200766..57dd33b 100644 --- a/src/opendisplay/device.py +++ b/src/opendisplay/device.py @@ -1152,12 +1152,7 @@ async def _maybe_upload_partial( old_rect_bytes = self._encode_segment_wire(old_palette_image, rx, ry, rw, rh, color_scheme) new_rect_bytes = self._encode_segment_wire(palette_image, rx, ry, rw, rh, color_scheme) - # Row-band interleaving: 8 rows per group - band_rows = 8 - span_pixels = rw * band_rows - span_bytes = span_pixels // pixels_per_byte - - logical_stream = build_partial_logical_stream(old_rect_bytes, new_rect_bytes, span_bytes) + logical_stream = build_partial_logical_stream(old_rect_bytes, new_rect_bytes) uncompressed_size = len(logical_stream) compressed_stream = zlib.compress(logical_stream, level=6) @@ -1169,8 +1164,8 @@ async def _maybe_upload_partial( flags |= PARTIAL_FLAG_COMPRESSED _LOGGER.debug( - "Partial stream: rect=(%d,%d,%d,%d), span_pixels=%d, uncompressed=%d, wire=%d, compressed=%s", - rx, ry, rw, rh, span_pixels, uncompressed_size, len(stream_bytes), use_compression, + "Partial stream: rect=(%d,%d,%d,%d), uncompressed=%d, wire=%d, compressed=%s", + rx, ry, rw, rh, uncompressed_size, len(stream_bytes), use_compression, ) new_etag = _generate_etag() @@ -1181,7 +1176,6 @@ async def _maybe_upload_partial( old_etag=state.etag, flags=flags, x=rx, y=ry, width=rw, height=rh, - interleave_span_pixels=span_pixels, uncompressed_size=uncompressed_size, stream_bytes=stream_bytes, ) diff --git a/src/opendisplay/partial.py b/src/opendisplay/partial.py index 5d939a4..b7796c8 100644 --- a/src/opendisplay/partial.py +++ b/src/opendisplay/partial.py @@ -28,8 +28,7 @@ ERR_RECT_ALIGN = 0x05 # on 0x76: x or width not aligned to byte boundary ERR_PARTIAL_FLAGS = 0x06 # on 0x76: unsupported or reserved flags set ERR_PARTIAL_SIZE = 0x07 # on 0x76: uncompressed_size does not match geometry -ERR_PARTIAL_SPAN = 0x08 # on 0x76: invalid interleave_span_pixels -ERR_PARTIAL_STREAM = 0x09 # on 0x71/0x72: stream byte count or content error +ERR_PARTIAL_STREAM = 0x08 # on 0x71/0x72: stream byte count or content error NACK_PREFIX = 0xFF @@ -113,22 +112,13 @@ def align_rect( def build_partial_logical_stream( old_rect_bytes: bytes, new_rect_bytes: bytes, - span_bytes: int, ) -> bytes: - """Interleave old and new rect bytes in old-first order. + """Build a plane-major old-then-new partial stream. - Produces: old_group_0 + new_group_0 + old_group_1 + new_group_1 + ... + Produces: old_rect + new_rect. """ assert len(old_rect_bytes) == len(new_rect_bytes), "old/new rect byte lengths must match" - parts: list[bytes] = [] - total = len(old_rect_bytes) - offset = 0 - while offset < total: - chunk = min(span_bytes, total - offset) - parts.append(old_rect_bytes[offset : offset + chunk]) - parts.append(new_rect_bytes[offset : offset + chunk]) - offset += chunk - return b"".join(parts) + return old_rect_bytes + new_rect_bytes # --------------------------------------------------------------------------- diff --git a/src/opendisplay/protocol/commands.py b/src/opendisplay/protocol/commands.py index f7cb586..c70a35d 100644 --- a/src/opendisplay/protocol/commands.py +++ b/src/opendisplay/protocol/commands.py @@ -146,20 +146,18 @@ def build_direct_write_partial_start( y: int, width: int, height: int, - interleave_span_pixels: int, uncompressed_size: int, stream_bytes: bytes = b"", version: int = 1, ) -> tuple[bytes, bytes]: """Build 0x76 partial START packet. - Fixed payload is 21 bytes; optional initial stream bytes are appended up + Fixed payload is 19 bytes; optional initial stream bytes are appended up to MAX_START_PAYLOAD total packet size (including the 2-byte command). Wire v1 fixed payload: version(1) + flags(2BE) + old_etag(4BE) + x(2BE) + y(2BE) + - width(2BE) + height(2BE) + interleave_span_pixels(2BE) + - uncompressed_size(4LE) + width(2BE) + height(2BE) + uncompressed_size(4LE) Returns: (start_packet, remaining_stream_bytes) — send start_packet as the @@ -173,12 +171,12 @@ def build_direct_write_partial_start( fixed = ( struct.pack(">BH", version, flags) + struct.pack(">I", old_etag) - + struct.pack(">HHHHH", x, y, width, height, interleave_span_pixels) + + struct.pack(">HHHH", x, y, width, height) + struct.pack(" Date: Wed, 29 Apr 2026 19:38:19 +0200 Subject: [PATCH 10/18] Adjust for smaller 0x76 header --- src/opendisplay/device.py | 6 ++--- src/opendisplay/partial.py | 13 +++++------ src/opendisplay/protocol/commands.py | 27 +++++++++++----------- tests/unit/test_device_partial.py | 2 +- tests/unit/test_partial.py | 34 ++++++++++++---------------- 5 files changed, 39 insertions(+), 43 deletions(-) diff --git a/src/opendisplay/device.py b/src/opendisplay/device.py index 57dd33b..9dd50b5 100644 --- a/src/opendisplay/device.py +++ b/src/opendisplay/device.py @@ -42,7 +42,6 @@ from .partial import ( ERR_ETAG_MISMATCH, PARTIAL_FLAG_COMPRESSED, - PARTIAL_FLAG_STORE_ETAG, PartialState, _generate_etag, _PIXELS_PER_BYTE, @@ -1159,7 +1158,7 @@ async def _maybe_upload_partial( use_compression = display.supports_zip and len(compressed_stream) < len(logical_stream) stream_bytes = compressed_stream if use_compression else logical_stream - flags = PARTIAL_FLAG_STORE_ETAG + flags = 0 if use_compression: flags |= PARTIAL_FLAG_COMPRESSED @@ -1217,7 +1216,8 @@ async def _maybe_upload_partial( if progress_callback is not None: progress_callback(bytes_sent, total_stream_bytes) - # 3. 0x72 END with refresh_mode + new_etag (PARTIAL_FLAG_STORE_ETAG is always set) + # 3. 0x72 END with refresh_mode + new_etag. old_etag was non-zero, + # so firmware expects a replacement etag on successful refresh. await self._write(build_direct_write_end_with_etag(RefreshMode.PARTIAL.value, new_etag)) response = await self._read(self.TIMEOUT_ACK) validate_ack_response(response, CommandCode.DIRECT_WRITE_END) diff --git a/src/opendisplay/partial.py b/src/opendisplay/partial.py index b7796c8..be65353 100644 --- a/src/opendisplay/partial.py +++ b/src/opendisplay/partial.py @@ -24,17 +24,16 @@ ERR_ETAG_MISMATCH = 0x01 # on 0x76: client must fall back to full transfer ERR_MIXED_DATA = 0x02 # aborted; etag cleared on device ERR_RECT_OOB = 0x03 # on 0x76: rectangle out of display bounds -ERR_PARTIAL_VERSION = 0x04 # on 0x76: client protocol version unsupported -ERR_RECT_ALIGN = 0x05 # on 0x76: x or width not aligned to byte boundary -ERR_PARTIAL_FLAGS = 0x06 # on 0x76: unsupported or reserved flags set -ERR_PARTIAL_SIZE = 0x07 # on 0x76: uncompressed_size does not match geometry -ERR_PARTIAL_STREAM = 0x08 # on 0x71/0x72: stream byte count or content error +ERR_RECT_ALIGN = 0x04 # on 0x76: x or width not aligned to byte boundary +ERR_PARTIAL_FLAGS = 0x05 # on 0x76: unsupported or reserved flags set +ERR_PARTIAL_SIZE = 0x06 # on 0x76: uncompressed_size does not match geometry +ERR_PARTIAL_STREAM = 0x07 # on 0x71/0x72: stream byte count or content error +ERR_PARTIAL_UNSUPPORTED = 0x08 # on 0x76: partial update unsupported for panel mode NACK_PREFIX = 0xFF # 0x76 flag bits -PARTIAL_FLAG_COMPRESSED = 0x0004 # bit 2: stream is zlib-compressed -PARTIAL_FLAG_STORE_ETAG = 0x0008 # bit 3: 0x72 includes new_etag; store after refresh +PARTIAL_FLAG_COMPRESSED = 0x01 # bit 0: stream is zlib-compressed # pixels_per_byte for each bits_per_pixel value _PIXELS_PER_BYTE: dict[int, int] = {1: 8, 2: 4, 4: 2, 8: 1} diff --git a/src/opendisplay/protocol/commands.py b/src/opendisplay/protocol/commands.py index c70a35d..3d54e42 100644 --- a/src/opendisplay/protocol/commands.py +++ b/src/opendisplay/protocol/commands.py @@ -32,7 +32,7 @@ class CommandCode(IntEnum): 0x0073 # Device→host: refresh finished (same code as LED_ACTIVATE, different direction) ) DIRECT_WRITE_REFRESH_TIMEOUT = 0x0074 # Device→host: refresh timed out - DIRECT_WRITE_PARTIAL_START = 0x0076 # Start a versioned partial update transfer (stream via 0x71) + DIRECT_WRITE_PARTIAL_START = 0x0076 # Start a partial update transfer (stream via 0x71) # Protocol constants @@ -148,35 +148,36 @@ def build_direct_write_partial_start( height: int, uncompressed_size: int, stream_bytes: bytes = b"", - version: int = 1, ) -> tuple[bytes, bytes]: """Build 0x76 partial START packet. - Fixed payload is 19 bytes; optional initial stream bytes are appended up + Fixed payload is 16 bytes; optional initial stream bytes are appended up to MAX_START_PAYLOAD total packet size (including the 2-byte command). Wire v1 fixed payload: - version(1) + flags(2BE) + old_etag(4BE) + x(2BE) + y(2BE) + - width(2BE) + height(2BE) + uncompressed_size(4LE) + flags(1) + old_etag(4BE) + x(2BE) + y(2BE) + + width(2BE) + height(2BE) + uncompressed_size(3BE) Returns: (start_packet, remaining_stream_bytes) — send start_packet as the 0x76 command, then remaining_stream_bytes via 0x71 DATA chunks. """ - if not 0 <= version <= 0xFF: - raise ValueError(f"partial protocol version out of uint8 range: {version}") - if not 1 <= old_etag <= 0xFFFFFFFF: - raise ValueError(f"old_etag must be non-zero uint32, got {old_etag}") + if not 0 <= flags <= 0xFF: + raise ValueError(f"partial flags out of uint8 range: {flags}") + if not 0 <= old_etag <= 0xFFFFFFFF: + raise ValueError(f"old_etag must be uint32, got {old_etag}") + if not 0 <= uncompressed_size <= 0xFFFFFF: + raise ValueError(f"partial uncompressed_size out of uint24 range: {uncompressed_size}") fixed = ( - struct.pack(">BH", version, flags) + struct.pack(">B", flags) + struct.pack(">I", old_etag) + struct.pack(">HHHH", x, y, width, height) - + struct.pack(" bytes: assert outcome == "success" assert 0x70 not in opcodes assert opcodes == [0x76, 0x72] - assert int.from_bytes(writes[0][3:5], "big") & PARTIAL_FLAG_COMPRESSED == 0 + assert writes[0][2] & PARTIAL_FLAG_COMPRESSED == 0 def test_empty_state_falls_back_to_full(monkeypatch): diff --git a/tests/unit/test_partial.py b/tests/unit/test_partial.py index ae9504e..f274be3 100644 --- a/tests/unit/test_partial.py +++ b/tests/unit/test_partial.py @@ -7,11 +7,9 @@ from opendisplay.partial import ( ERR_ETAG_MISMATCH, ERR_MIXED_DATA, - ERR_PARTIAL_VERSION, ERR_RECT_ALIGN, ERR_RECT_OOB, PARTIAL_FLAG_COMPRESSED, - PARTIAL_FLAG_STORE_ETAG, PartialState, _generate_etag, align_rect, @@ -62,8 +60,7 @@ def test_known_errors(self): assert parse_nack(b"\xff\x76\x01\x00") == (0x76, ERR_ETAG_MISMATCH) assert parse_nack(b"\xff\x70\x02\x00") == (0x70, ERR_MIXED_DATA) assert parse_nack(b"\xff\x76\x03\x00") == (0x76, ERR_RECT_OOB) - assert parse_nack(b"\xff\x76\x04\x00") == (0x76, ERR_PARTIAL_VERSION) - assert parse_nack(b"\xff\x76\x05\x00") == (0x76, ERR_RECT_ALIGN) + assert parse_nack(b"\xff\x76\x04\x00") == (0x76, ERR_RECT_ALIGN) def test_not_nack_returns_none(self): assert parse_nack(b"\x00\x76") is None @@ -138,7 +135,7 @@ def test_partial_start_fixed_fields_and_initial_bytes(self): stream = bytes(range(200)) packet, remaining = build_direct_write_partial_start( old_etag=0xDEADBEEF, - flags=PARTIAL_FLAG_COMPRESSED | PARTIAL_FLAG_STORE_ETAG, + flags=PARTIAL_FLAG_COMPRESSED, x=8, y=9, width=16, @@ -149,20 +146,19 @@ def test_partial_start_fixed_fields_and_initial_bytes(self): assert len(packet) == MAX_START_PAYLOAD assert packet[:2] == b"\x00\x76" - assert packet[2] == 1 - assert int.from_bytes(packet[3:5], "big") == PARTIAL_FLAG_COMPRESSED | PARTIAL_FLAG_STORE_ETAG - assert int.from_bytes(packet[5:9], "big") == 0xDEADBEEF - assert int.from_bytes(packet[9:11], "big") == 8 - assert int.from_bytes(packet[11:13], "big") == 9 - assert int.from_bytes(packet[13:15], "big") == 16 - assert int.from_bytes(packet[15:17], "big") == 10 - assert int.from_bytes(packet[17:21], "little") == 40 - assert packet[21:] == stream[:179] - assert remaining == stream[179:] - - def test_partial_start_rejects_zero_etag(self): - with pytest.raises(ValueError, match="old_etag"): - build_direct_write_partial_start(0, 0, 0, 0, 8, 1, 2) + assert packet[2] == PARTIAL_FLAG_COMPRESSED + assert int.from_bytes(packet[3:7], "big") == 0xDEADBEEF + assert int.from_bytes(packet[7:9], "big") == 8 + assert int.from_bytes(packet[9:11], "big") == 9 + assert int.from_bytes(packet[11:13], "big") == 16 + assert int.from_bytes(packet[13:15], "big") == 10 + assert int.from_bytes(packet[15:18], "big") == 40 + assert packet[18:] == stream[:182] + assert remaining == stream[182:] + + def test_partial_start_allows_zero_etag(self): + packet, _ = build_direct_write_partial_start(0, 0, 0, 0, 8, 1, 2) + assert packet[3:7] == b"\x00\x00\x00\x00" def test_end_with_etag(self): cmd = build_direct_write_end_with_etag(refresh_mode=2, new_etag=0x01020304) From 7ab680366003295cc83bf0944d37f94b34b98dbc Mon Sep 17 00:00:00 2001 From: Michael Bisbjerg Date: Wed, 29 Apr 2026 19:38:30 +0200 Subject: [PATCH 11/18] Remove docs, probably not the place for it. --- docs/partial-update-protocol.md | 88 --------------------------------- 1 file changed, 88 deletions(-) delete mode 100644 docs/partial-update-protocol.md diff --git a/docs/partial-update-protocol.md b/docs/partial-update-protocol.md deleted file mode 100644 index 0e873ca..0000000 --- a/docs/partial-update-protocol.md +++ /dev/null @@ -1,88 +0,0 @@ -# Partial Update Protocol - -Partial updates use a streamed single-rectangle protocol: - -```text -0x76 PARTIAL_IMAGE_START -0x71 DATA... -0x72 END + partial refresh -``` - -Full uploads continue to use `0x70`, `0x71`, and `0x72`. `0x77` is unused. - -## `0x76` Partial Start - -```text -[0x0076] -[version:1 = 0x01] -[flags:2 BE] -[old_etag:4 BE] -[x:2 BE][y:2 BE][width:2 BE][height:2 BE] -[uncompressed_size:4 LE] -[initial_stream_bytes...] -``` - -The stream bytes are zlib bytes when `flags & 0x0004` is set, otherwise raw -logical bytes. `uncompressed_size` is always `rect_bytes * 2`. - -Flags: - -```text -bit 2: stream is zlib-compressed -bit 3: 0x72 includes new_etag to store after successful refresh -all other bits: reserved, must be 0 -``` - -The rectangle must be in bounds. `x` and `width` must be aligned to the active -packed-pixel byte boundary: 8 pixels for 1 bpp, 4 for 2 bpp, 2 for 4 bpp, and -1 for 8 bpp. - -## Stream Body - -The logical stream contains both old and new rectangle images in this order: - -```text -old rectangle bytes for PLANE_1 -new rectangle bytes for PLANE_0 -``` - -Firmware writes the full old rectangle first, resets the address window to the -same rectangle, then writes the full new rectangle. - -## `0x71` Data - -After `0x76`, `0x71` carries the remaining partial stream bytes. It has no -partial metadata: - -```text -[0x0071][stream_bytes...] -``` - -Current firmware buffers compressed partial stream bytes just like compressed -full uploads, then inflates at `0x72`. Raw partial streams are consumed as -`0x76`/`0x71` bytes arrive. - -## `0x72` End - -When `flags & 0x0008` was set on `0x76`, the end payload is: - -```text -[0x0072][refresh_mode:1][new_etag:4 BE] -``` - -Firmware validates the logical byte count and per-plane byte counts, then -refreshes with a partial-capable refresh mode. The new etag is stored only -after refresh completion. - -Known partial NACK error codes use `{0xFF, opcode, error, 0x00}`: - -```text -0x01: etag mismatch -0x02: mixed full/partial data -0x03: rectangle out of bounds -0x04: unsupported partial protocol version -0x05: rectangle alignment error -0x06: unsupported or reserved flags -0x07: uncompressed_size mismatch -0x08: stream byte count or content error -``` From bfaf216787f7f1440cbe843b3505552ae313d413 Mon Sep 17 00:00:00 2001 From: Michael Bisbjerg Date: Thu, 30 Apr 2026 15:48:23 +0200 Subject: [PATCH 12/18] Cleanup --- README.md | 3 --- 1 file changed, 3 deletions(-) diff --git a/README.md b/README.md index 4df72cb..538ff18 100644 --- a/README.md +++ b/README.md @@ -10,9 +10,6 @@ Python library for communicating with OpenDisplay BLE e-paper displays. -Partial update wire details are documented in -[docs/partial-update-protocol.md](docs/partial-update-protocol.md). - ## Installation ```bash From db27138ab6364bd53e5171570294f085dd113e63 Mon Sep 17 00:00:00 2001 From: Michael Bisbjerg Date: Thu, 30 Apr 2026 15:56:48 +0200 Subject: [PATCH 13/18] Remove unused partial diff strategy --- src/opendisplay/device.py | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/src/opendisplay/device.py b/src/opendisplay/device.py index 9dd50b5..6b75213 100644 --- a/src/opendisplay/device.py +++ b/src/opendisplay/device.py @@ -882,7 +882,6 @@ async def upload_image( rotate: Rotation = Rotation.ROTATE_0, progress_callback: Callable[[int, int], None] | None = None, state: PartialState | None = None, - diff_strategy: object | None = None, ) -> Image.Image: """Upload image to device display. @@ -932,7 +931,7 @@ async def upload_image( if state is not None: partial_outcome = await self._maybe_upload_partial( - processed_image, image_data, refresh_mode, state, diff_strategy, progress_callback + processed_image, image_data, refresh_mode, state, progress_callback ) if partial_outcome == "success": _LOGGER.info("Image upload complete (partial path)") @@ -984,7 +983,6 @@ async def upload_prepared_image( compress: bool = True, progress_callback: Callable[[int, int], None] | None = None, state: PartialState | None = None, - diff_strategy: object | None = None, ) -> None: """Upload pre-computed image data to device. @@ -1007,7 +1005,7 @@ async def upload_prepared_image( if state is not None: partial_outcome = await self._maybe_upload_partial( - processed_image, image_data, refresh_mode, state, diff_strategy, progress_callback + processed_image, image_data, refresh_mode, state, progress_callback ) if partial_outcome == "success": _LOGGER.info("Prepared image upload complete (partial path)") @@ -1080,7 +1078,6 @@ async def _maybe_upload_partial( image_data: bytes, refresh_mode: RefreshMode, state: PartialState, - diff_strategy: object | None, progress_callback: Callable[[int, int], None] | None = None, ) -> str: """Try a partial upload using the 0x76 single-rectangle protocol. @@ -1091,7 +1088,6 @@ async def _maybe_upload_partial( - "fallback_full": caller must do a full upload (and refresh state). """ del image_data # Partial requests do not compare against full-frame transfer size. - del diff_strategy # reserved for future use if self._config is None or not self._config.displays: _LOGGER.debug("Partial path skipped: device config is required to verify partial support") From 8ee54709c3466b3a9757a7eb8fd7b8e9d2e9f508 Mon Sep 17 00:00:00 2001 From: Michael Bisbjerg Date: Mon, 4 May 2026 00:20:54 +0200 Subject: [PATCH 14/18] Move partial etag into start packet --- src/opendisplay/device.py | 8 ++++---- src/opendisplay/partial.py | 2 +- src/opendisplay/protocol/commands.py | 20 ++++++++++---------- tests/unit/test_device_partial.py | 4 ++++ tests/unit/test_partial.py | 18 +++++++++--------- 5 files changed, 28 insertions(+), 24 deletions(-) diff --git a/src/opendisplay/device.py b/src/opendisplay/device.py index 6b75213..dd585eb 100644 --- a/src/opendisplay/device.py +++ b/src/opendisplay/device.py @@ -1169,9 +1169,9 @@ async def _maybe_upload_partial( # 1. 0x76 partial START (initial stream bytes packed in where space allows) start_pkt, remaining = build_direct_write_partial_start( old_etag=state.etag, + new_etag=new_etag, flags=flags, x=rx, y=ry, width=rw, height=rh, - uncompressed_size=uncompressed_size, stream_bytes=stream_bytes, ) await self._write(start_pkt) @@ -1212,9 +1212,9 @@ async def _maybe_upload_partial( if progress_callback is not None: progress_callback(bytes_sent, total_stream_bytes) - # 3. 0x72 END with refresh_mode + new_etag. old_etag was non-zero, - # so firmware expects a replacement etag on successful refresh. - await self._write(build_direct_write_end_with_etag(RefreshMode.PARTIAL.value, new_etag)) + # 3. 0x72 END with refresh_mode only. The replacement etag is part of + # 0x76 for partial uploads. + await self._write(build_direct_write_end_command(RefreshMode.PARTIAL.value)) response = await self._read(self.TIMEOUT_ACK) validate_ack_response(response, CommandCode.DIRECT_WRITE_END) diff --git a/src/opendisplay/partial.py b/src/opendisplay/partial.py index be65353..61d7dc2 100644 --- a/src/opendisplay/partial.py +++ b/src/opendisplay/partial.py @@ -26,7 +26,7 @@ ERR_RECT_OOB = 0x03 # on 0x76: rectangle out of display bounds ERR_RECT_ALIGN = 0x04 # on 0x76: x or width not aligned to byte boundary ERR_PARTIAL_FLAGS = 0x05 # on 0x76: unsupported or reserved flags set -ERR_PARTIAL_SIZE = 0x06 # on 0x76: uncompressed_size does not match geometry +ERR_PARTIAL_SIZE = 0x06 # on 0x76: derived stream size does not fit ERR_PARTIAL_STREAM = 0x07 # on 0x71/0x72: stream byte count or content error ERR_PARTIAL_UNSUPPORTED = 0x08 # on 0x76: partial update unsupported for panel mode diff --git a/src/opendisplay/protocol/commands.py b/src/opendisplay/protocol/commands.py index 3d54e42..ea7013d 100644 --- a/src/opendisplay/protocol/commands.py +++ b/src/opendisplay/protocol/commands.py @@ -141,22 +141,22 @@ def build_direct_write_start_uncompressed() -> bytes: def build_direct_write_partial_start( old_etag: int, + new_etag: int, flags: int, x: int, y: int, width: int, height: int, - uncompressed_size: int, stream_bytes: bytes = b"", ) -> tuple[bytes, bytes]: """Build 0x76 partial START packet. - Fixed payload is 16 bytes; optional initial stream bytes are appended up + Fixed payload is 17 bytes; optional initial stream bytes are appended up to MAX_START_PAYLOAD total packet size (including the 2-byte command). - Wire v1 fixed payload: - flags(1) + old_etag(4BE) + x(2BE) + y(2BE) + - width(2BE) + height(2BE) + uncompressed_size(3BE) + Wire fixed payload: + flags(1) + old_etag(4BE) + new_etag(4BE) + x(2BE) + y(2BE) + + width(2BE) + height(2BE) Returns: (start_packet, remaining_stream_bytes) — send start_packet as the @@ -166,18 +166,18 @@ def build_direct_write_partial_start( raise ValueError(f"partial flags out of uint8 range: {flags}") if not 0 <= old_etag <= 0xFFFFFFFF: raise ValueError(f"old_etag must be uint32, got {old_etag}") - if not 0 <= uncompressed_size <= 0xFFFFFF: - raise ValueError(f"partial uncompressed_size out of uint24 range: {uncompressed_size}") + if not 0 <= new_etag <= 0xFFFFFFFF: + raise ValueError(f"new_etag must be uint32, got {new_etag}") fixed = ( struct.pack(">B", flags) + struct.pack(">I", old_etag) + + struct.pack(">I", new_etag) + struct.pack(">HHHH", x, y, width, height) - + uncompressed_size.to_bytes(3, byteorder="big") - ) # 1+4+2+2+2+2+3 = 16 bytes + ) # 1+4+4+2+2+2+2 = 17 bytes cmd = CommandCode.DIRECT_WRITE_PARTIAL_START.to_bytes(2, byteorder="big") - max_initial = MAX_START_PAYLOAD - 2 - len(fixed) # 200 - 2 - 16 = 182 bytes + max_initial = MAX_START_PAYLOAD - 2 - len(fixed) # 200 - 2 - 17 = 181 bytes initial = stream_bytes[:max_initial] remaining = stream_bytes[max_initial:] return cmd + fixed + initial, remaining diff --git a/tests/unit/test_device_partial.py b/tests/unit/test_device_partial.py index fb954c2..8c38666 100644 --- a/tests/unit/test_device_partial.py +++ b/tests/unit/test_device_partial.py @@ -126,6 +126,9 @@ async def read_response(timeout: float) -> bytes: assert 0x70 not in opcodes assert opcodes == [0x76, 0x72] assert writes[0][2] & PARTIAL_FLAG_COMPRESSED == 0 + assert int.from_bytes(writes[0][3:7], "big") == 0x01020304 + assert int.from_bytes(writes[0][7:11], "big") == state.etag + assert len(writes[1]) == 3 def test_empty_state_falls_back_to_full(monkeypatch): @@ -203,3 +206,4 @@ async def fail_full_upload(*args, **kwargs) -> None: opcodes = [int.from_bytes(w[:2], "big") for w in writes] assert opcodes == [0x76, 0x72] + assert len(writes[-1]) == 3 diff --git a/tests/unit/test_partial.py b/tests/unit/test_partial.py index f274be3..c7cd475 100644 --- a/tests/unit/test_partial.py +++ b/tests/unit/test_partial.py @@ -135,12 +135,12 @@ def test_partial_start_fixed_fields_and_initial_bytes(self): stream = bytes(range(200)) packet, remaining = build_direct_write_partial_start( old_etag=0xDEADBEEF, + new_etag=0x01020304, flags=PARTIAL_FLAG_COMPRESSED, x=8, y=9, width=16, height=10, - uncompressed_size=40, stream_bytes=stream, ) @@ -148,16 +148,16 @@ def test_partial_start_fixed_fields_and_initial_bytes(self): assert packet[:2] == b"\x00\x76" assert packet[2] == PARTIAL_FLAG_COMPRESSED assert int.from_bytes(packet[3:7], "big") == 0xDEADBEEF - assert int.from_bytes(packet[7:9], "big") == 8 - assert int.from_bytes(packet[9:11], "big") == 9 - assert int.from_bytes(packet[11:13], "big") == 16 - assert int.from_bytes(packet[13:15], "big") == 10 - assert int.from_bytes(packet[15:18], "big") == 40 - assert packet[18:] == stream[:182] - assert remaining == stream[182:] + assert int.from_bytes(packet[7:11], "big") == 0x01020304 + assert int.from_bytes(packet[11:13], "big") == 8 + assert int.from_bytes(packet[13:15], "big") == 9 + assert int.from_bytes(packet[15:17], "big") == 16 + assert int.from_bytes(packet[17:19], "big") == 10 + assert packet[19:] == stream[:181] + assert remaining == stream[181:] def test_partial_start_allows_zero_etag(self): - packet, _ = build_direct_write_partial_start(0, 0, 0, 0, 8, 1, 2) + packet, _ = build_direct_write_partial_start(0, 0x01020304, 0, 0, 0, 8, 1) assert packet[3:7] == b"\x00\x00\x00\x00" def test_end_with_etag(self): From 87498c91cad17175e193f778c7aac8829137e4a7 Mon Sep 17 00:00:00 2001 From: Michael Bisbjerg Date: Mon, 4 May 2026 00:22:16 +0200 Subject: [PATCH 15/18] Collapse partial size error code --- src/opendisplay/partial.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/opendisplay/partial.py b/src/opendisplay/partial.py index 61d7dc2..d06e4b4 100644 --- a/src/opendisplay/partial.py +++ b/src/opendisplay/partial.py @@ -26,9 +26,8 @@ ERR_RECT_OOB = 0x03 # on 0x76: rectangle out of display bounds ERR_RECT_ALIGN = 0x04 # on 0x76: x or width not aligned to byte boundary ERR_PARTIAL_FLAGS = 0x05 # on 0x76: unsupported or reserved flags set -ERR_PARTIAL_SIZE = 0x06 # on 0x76: derived stream size does not fit -ERR_PARTIAL_STREAM = 0x07 # on 0x71/0x72: stream byte count or content error -ERR_PARTIAL_UNSUPPORTED = 0x08 # on 0x76: partial update unsupported for panel mode +ERR_PARTIAL_STREAM = 0x06 # on 0x71/0x72: stream byte count or content error +ERR_PARTIAL_UNSUPPORTED = 0x07 # on 0x76: partial update unsupported for panel mode NACK_PREFIX = 0xFF From 5e739fe4951c3f3e3285818a739de31b58317783 Mon Sep 17 00:00:00 2001 From: Michael Bisbjerg Date: Mon, 18 May 2026 21:36:47 +0200 Subject: [PATCH 16/18] chore: apply Ruff formatting and line-wrap fixes --- examples/animate.py | 26 ++++++++++++++++---------- src/opendisplay/cli.py | 4 ++-- src/opendisplay/device.py | 31 +++++++++++++++++++++---------- src/opendisplay/partial.py | 24 +++++++++++++----------- tests/unit/test_device_partial.py | 8 ++++++-- 5 files changed, 58 insertions(+), 35 deletions(-) diff --git a/examples/animate.py b/examples/animate.py index 6629827..b2b5f5a 100644 --- a/examples/animate.py +++ b/examples/animate.py @@ -16,8 +16,8 @@ import logging import sys import time -from pathlib import Path from collections.abc import Coroutine +from pathlib import Path from typing import Any, NoReturn, TypeVar from epaper_dithering import DitherMode @@ -160,7 +160,9 @@ def __rich_console__(self, _con, _opts): # type: ignore[no-untyped-def] for img in images: t0 = time.perf_counter() prepared.append( - prepare_image(img, config=device.config, capabilities=device.capabilities, dither_mode=dither_mode) + prepare_image( + img, config=device.config, capabilities=device.capabilities, dither_mode=dither_mode + ) ) prep_times.append(time.perf_counter() - t0) @@ -203,7 +205,9 @@ def on_progress(sent: int, total_bytes: int) -> None: send_ms = (send_end[0] - t_upload_start) * 1000 refresh_ms = (t_upload_end - send_end[0]) * 1000 kb = bytes_transferred[0] / 1024 - stats = f"prep {prep_ms:.0f}ms · send {send_ms:.0f}ms ({kb:.1f} KB) · refresh {refresh_ms:.0f}ms" + stats = ( + f"prep {prep_ms:.0f}ms · send {send_ms:.0f}ms ({kb:.1f} KB) · refresh {refresh_ms:.0f}ms" + ) _console.print(f"[dim]{idx + 1}/{total} {name} ({update_type}) {stats}[/dim]") spinner_progress.update(status_task, description=_status("Showing")) @@ -231,13 +235,15 @@ def _cmd_animate(args: argparse.Namespace) -> None: listing = ", ".join(names) if len(names) <= 4 else f"{names[0]}, {names[1]}, ..., {names[-1]}" _console.print(f"Found {len(paths)} image(s): {listing}") - _run(_animate( - _device_kwargs(args.device, key, args.timeout), - images, - names, - args.interval, - _DITHER_CHOICES[args.dither_mode], - )) + _run( + _animate( + _device_kwargs(args.device, key, args.timeout), + images, + names, + args.interval, + _DITHER_CHOICES[args.dither_mode], + ) + ) def main() -> None: diff --git a/src/opendisplay/cli.py b/src/opendisplay/cli.py index 92f70d9..f66b837 100644 --- a/src/opendisplay/cli.py +++ b/src/opendisplay/cli.py @@ -8,8 +8,8 @@ import logging import os import sys -from pathlib import Path from collections.abc import Coroutine +from pathlib import Path from typing import Any, NoReturn, TypeVar from epaper_dithering import DitherMode @@ -23,7 +23,6 @@ from .battery import voltage_to_percent from .device import OpenDisplayDevice -from .partial import PartialState from .discovery import discover_devices_with_adv from .exceptions import ( AuthenticationFailedError, @@ -43,6 +42,7 @@ SensorType, WifiEncryption, ) +from .partial import PartialState _T = TypeVar("_T") diff --git a/src/opendisplay/device.py b/src/opendisplay/device.py index e2eadce..a45dd37 100644 --- a/src/opendisplay/device.py +++ b/src/opendisplay/device.py @@ -40,11 +40,11 @@ from .models.firmware import FirmwareVersion from .models.led_flash import LedFlashConfig from .partial import ( + _PIXELS_PER_BYTE, ERR_ETAG_MISMATCH, PARTIAL_FLAG_COMPRESSED, PartialState, _generate_etag, - _PIXELS_PER_BYTE, align_rect, build_partial_logical_stream, compute_bounding_rect, @@ -1167,12 +1167,7 @@ async def _maybe_upload_partial( return "fallback_full" width, height = processed_image.size - if ( - state.etag == 0 - or state.last_image is None - or state.width != width - or state.height != height - ): + if state.etag == 0 or state.last_image is None or state.width != width or state.height != height: return "fallback_full" palette_image = processed_image.convert("P") if processed_image.mode != "P" else processed_image @@ -1202,7 +1197,14 @@ async def _maybe_upload_partial( _LOGGER.debug( "Partial path diff: old_etag=0x%08x, image=%dx%d, bbox=(%d,%d,%d,%d), rect=(%d,%d,%d,%d)", - state.etag, width, height, *bbox, rx, ry, rw, rh, + state.etag, + width, + height, + *bbox, + rx, + ry, + rw, + rh, ) old_palette_image = palette_image.copy() @@ -1224,7 +1226,13 @@ async def _maybe_upload_partial( _LOGGER.debug( "Partial stream: rect=(%d,%d,%d,%d), uncompressed=%d, wire=%d, compressed=%s", - rx, ry, rw, rh, uncompressed_size, len(stream_bytes), use_compression, + rx, + ry, + rw, + rh, + uncompressed_size, + len(stream_bytes), + use_compression, ) new_etag = _generate_etag() @@ -1235,7 +1243,10 @@ async def _maybe_upload_partial( old_etag=state.etag, new_etag=new_etag, flags=flags, - x=rx, y=ry, width=rw, height=rh, + x=rx, + y=ry, + width=rw, + height=rh, stream_bytes=stream_bytes, ) await self._write(start_pkt) diff --git a/src/opendisplay/partial.py b/src/opendisplay/partial.py index d06e4b4..d1c2826 100644 --- a/src/opendisplay/partial.py +++ b/src/opendisplay/partial.py @@ -21,18 +21,18 @@ # --------------------------------------------------------------------------- # NACK error codes returned by the device inside {0xFF, opcode, error, 0x00} -ERR_ETAG_MISMATCH = 0x01 # on 0x76: client must fall back to full transfer -ERR_MIXED_DATA = 0x02 # aborted; etag cleared on device -ERR_RECT_OOB = 0x03 # on 0x76: rectangle out of display bounds -ERR_RECT_ALIGN = 0x04 # on 0x76: x or width not aligned to byte boundary -ERR_PARTIAL_FLAGS = 0x05 # on 0x76: unsupported or reserved flags set -ERR_PARTIAL_STREAM = 0x06 # on 0x71/0x72: stream byte count or content error -ERR_PARTIAL_UNSUPPORTED = 0x07 # on 0x76: partial update unsupported for panel mode +ERR_ETAG_MISMATCH = 0x01 # on 0x76: client must fall back to full transfer +ERR_MIXED_DATA = 0x02 # aborted; etag cleared on device +ERR_RECT_OOB = 0x03 # on 0x76: rectangle out of display bounds +ERR_RECT_ALIGN = 0x04 # on 0x76: x or width not aligned to byte boundary +ERR_PARTIAL_FLAGS = 0x05 # on 0x76: unsupported or reserved flags set +ERR_PARTIAL_STREAM = 0x06 # on 0x71/0x72: stream byte count or content error +ERR_PARTIAL_UNSUPPORTED = 0x07 # on 0x76: partial update unsupported for panel mode NACK_PREFIX = 0xFF # 0x76 flag bits -PARTIAL_FLAG_COMPRESSED = 0x01 # bit 0: stream is zlib-compressed +PARTIAL_FLAG_COMPRESSED = 0x01 # bit 0: stream is zlib-compressed # pixels_per_byte for each bits_per_pixel value _PIXELS_PER_BYTE: dict[int, int] = {1: 8, 2: 4, 4: 2, 8: 1} @@ -52,6 +52,7 @@ def parse_nack(response: bytes) -> tuple[int, int] | None: # Bounding-rect helpers # --------------------------------------------------------------------------- + def compute_bounding_rect( old: bytes, new: bytes, @@ -123,6 +124,7 @@ def build_partial_logical_stream( # Etag helpers # --------------------------------------------------------------------------- + def _generate_etag() -> int: """Generate a random non-zero 32-bit etag.""" while True: @@ -140,7 +142,7 @@ def _generate_etag() -> int: # Followed by: img_len(4BE) + img_bytes(img_len) _MAGIC = b"PDST" _VERSION = 1 -_HEADER_FMT = ">4sBIIII" # magic(4s), version(B), etag(I BE), width(I), height(I), bpp(I) +_HEADER_FMT = ">4sBIIII" # magic(4s), version(B), etag(I BE), width(I), height(I), bpp(I) _HEADER_SIZE = struct.calcsize(_HEADER_FMT) # 4+1+4+4+4+4 = 21 bytes @@ -155,8 +157,8 @@ class PartialState: ``bytes_per_pixel`` is always 1 for library-populated instances. """ - etag: int = 0 # last new_etag successfully sent (0 = unknown) - last_image: bytes | None = None # raw palette pixel buffer matching etag + etag: int = 0 # last new_etag successfully sent (0 = unknown) + last_image: bytes | None = None # raw palette pixel buffer matching etag width: int = 0 height: int = 0 bytes_per_pixel: int = 0 diff --git a/tests/unit/test_device_partial.py b/tests/unit/test_device_partial.py index 8c38666..d252130 100644 --- a/tests/unit/test_device_partial.py +++ b/tests/unit/test_device_partial.py @@ -144,7 +144,9 @@ async def execute_upload(image_data, refresh_mode, **kwargs) -> None: monkeypatch.setattr(device, "_execute_upload", execute_upload) - asyncio.run(device.upload_prepared_image((b"\x00" * 16, None, _image()), refresh_mode=RefreshMode.PARTIAL, state=state)) + asyncio.run( + device.upload_prepared_image((b"\x00" * 16, None, _image()), refresh_mode=RefreshMode.PARTIAL, state=state) + ) assert full_uploads == 1 assert refresh_modes == [RefreshMode.FULL] @@ -202,7 +204,9 @@ async def fail_full_upload(*args, **kwargs) -> None: monkeypatch.setattr(device, "_read", read_response) monkeypatch.setattr(device, "_execute_upload", fail_full_upload) - asyncio.run(device.upload_prepared_image((b"\xff" * 16, b"\x01", new), refresh_mode=RefreshMode.PARTIAL, state=state)) + asyncio.run( + device.upload_prepared_image((b"\xff" * 16, b"\x01", new), refresh_mode=RefreshMode.PARTIAL, state=state) + ) opcodes = [int.from_bytes(w[:2], "big") for w in writes] assert opcodes == [0x76, 0x72] From 8b969fe75baa62b659162abe358b820fa35ef55e Mon Sep 17 00:00:00 2001 From: Michael Bisbjerg Date: Mon, 18 May 2026 21:40:33 +0200 Subject: [PATCH 17/18] refactor partial update path after review comments --- src/opendisplay/device.py | 236 ++++++++++-------------------- src/opendisplay/partial.py | 138 +++++++++++++++-- tests/unit/test_device_partial.py | 4 +- 3 files changed, 207 insertions(+), 171 deletions(-) diff --git a/src/opendisplay/device.py b/src/opendisplay/device.py index a45dd37..d18adbe 100644 --- a/src/opendisplay/device.py +++ b/src/opendisplay/device.py @@ -40,14 +40,13 @@ from .models.firmware import FirmwareVersion from .models.led_flash import LedFlashConfig from .partial import ( - _PIXELS_PER_BYTE, ERR_ETAG_MISMATCH, PARTIAL_FLAG_COMPRESSED, PartialState, _generate_etag, - align_rect, build_partial_logical_stream, - compute_bounding_rect, + compute_partial_region, + encode_segment_wire, parse_nack, ) from .protocol import ( @@ -993,9 +992,7 @@ async def upload_image( ) if state is not None: - partial_outcome = await self._maybe_upload_partial( - processed_image, image_data, refresh_mode, state, progress_callback - ) + partial_outcome = await self._maybe_upload_partial(processed_image, state, progress_callback) if partial_outcome == "success": _LOGGER.info("Image upload complete (partial path)") return processed_image @@ -1049,9 +1046,7 @@ async def upload_prepared_image( image_data, compressed_data, processed_image = prepared_data if state is not None: - partial_outcome = await self._maybe_upload_partial( - processed_image, image_data, refresh_mode, state, progress_callback - ) + partial_outcome = await self._maybe_upload_partial(processed_image, state, progress_callback) if partial_outcome == "success": _LOGGER.info("Prepared image upload complete (partial path)") return @@ -1136,86 +1131,77 @@ def _update_partial_state( state.width, state.height = processed_image.size state.bytes_per_pixel = 1 + async def _send_partial_chunks( + self, + remaining: bytes, + stream_bytes: bytes, + state: PartialState, + progress_callback: Callable[[int, int], None] | None = None, + ) -> None: + """Send remaining 0x71 chunks and update upload progress.""" + chunk_size = ENCRYPTED_CHUNK_SIZE if self._session_key is not None else CHUNK_SIZE + total_stream_bytes = len(stream_bytes) + bytes_sent = total_stream_bytes - len(remaining) + offset = 0 + while offset < len(remaining): + chunk = remaining[offset : offset + chunk_size] + await self._write(build_direct_write_data_command(chunk)) + ack = await self._read(self.TIMEOUT_ACK) + nack = parse_nack(ack) + if nack is not None: + opcode, err = nack + state.etag = 0 + state.last_image = None + raise ProtocolError(f"Partial 0x71 NACK: opcode=0x{opcode:02x} err=0x{err:02x}") + validate_ack_response(ack, CommandCode.DIRECT_WRITE_DATA) + offset += len(chunk) + bytes_sent += len(chunk) + if progress_callback is not None: + progress_callback(bytes_sent, total_stream_bytes) + async def _maybe_upload_partial( self, processed_image: Image.Image, - image_data: bytes, - refresh_mode: RefreshMode, state: PartialState, progress_callback: Callable[[int, int], None] | None = None, ) -> str: - """Try a partial upload using the 0x76 single-rectangle protocol. - - Return codes: - - "success": partial transfer accepted; state mutated. - - "no_change": no pixels changed; caller should skip upload entirely. - - "fallback_full": caller must do a full upload (and refresh state). - """ - del image_data # Partial requests do not compare against full-frame transfer size. - - if self._config is None or not self._config.displays: - _LOGGER.debug("Partial path skipped: device config is required to verify partial support") - return "fallback_full" - display = self._config.displays[0] - if not display.partial_update_support: - _LOGGER.debug("Partial path skipped: display does not advertise partial update support") - return "fallback_full" - - color_scheme = self.color_scheme - if color_scheme in (ColorScheme.BWR, ColorScheme.BWY): - _LOGGER.debug("Partial path skipped: color scheme %s requires bitplane encoding", color_scheme.name) - return "fallback_full" - - width, height = processed_image.size - if state.etag == 0 or state.last_image is None or state.width != width or state.height != height: - return "fallback_full" - - palette_image = processed_image.convert("P") if processed_image.mode != "P" else processed_image - new_palette = palette_image.tobytes() - old_palette = state.last_image - - if len(old_palette) != len(new_palette): - return "fallback_full" - - # Compute bounding rect of changed pixels - bbox = compute_bounding_rect(old_palette, new_palette, width, height) - if bbox is None: - return "no_change" - - bpp = { - ColorScheme.MONO: 1, - ColorScheme.BWRY: 2, - ColorScheme.GRAYSCALE_4: 2, - ColorScheme.BWGBRY: 4, - ColorScheme.GRAYSCALE_16: 4, - }.get(color_scheme, 1) - pixels_per_byte = _PIXELS_PER_BYTE.get(bpp, 8) - - rx, ry, rw, rh = align_rect(*bbox, width, height, pixels_per_byte) - if rw == 0 or rh == 0: - return "fallback_full" + """Try a partial upload using the 0x76 single-rectangle protocol.""" + region = compute_partial_region(processed_image, state, self._config, self.color_scheme) + if isinstance(region, str): + return region + display = region.display _LOGGER.debug( - "Partial path diff: old_etag=0x%08x, image=%dx%d, bbox=(%d,%d,%d,%d), rect=(%d,%d,%d,%d)", + "Partial path diff: old_etag=0x%08x, image=%dx%d, rect=(%d,%d,%d,%d)", state.etag, - width, - height, - *bbox, - rx, - ry, - rw, - rh, + region.width, + region.height, + region.rx, + region.ry, + region.rw, + region.rh, ) - old_palette_image = palette_image.copy() - old_palette_image.frombytes(old_palette) - - old_rect_bytes = self._encode_segment_wire(old_palette_image, rx, ry, rw, rh, color_scheme) - new_rect_bytes = self._encode_segment_wire(palette_image, rx, ry, rw, rh, color_scheme) + old_palette_image = region.palette_image.copy() + old_palette_image.frombytes(region.old_palette) + old_rect_bytes = encode_segment_wire( + old_palette_image, + region.rx, + region.ry, + region.rw, + region.rh, + region.color_scheme, + ) + new_rect_bytes = encode_segment_wire( + region.palette_image, + region.rx, + region.ry, + region.rw, + region.rh, + region.color_scheme, + ) logical_stream = build_partial_logical_stream(old_rect_bytes, new_rect_bytes) - uncompressed_size = len(logical_stream) - compressed_stream = zlib.compress(logical_stream, level=6) use_compression = display.supports_zip and len(compressed_stream) < len(logical_stream) stream_bytes = compressed_stream if use_compression else logical_stream @@ -1226,11 +1212,11 @@ async def _maybe_upload_partial( _LOGGER.debug( "Partial stream: rect=(%d,%d,%d,%d), uncompressed=%d, wire=%d, compressed=%s", - rx, - ry, - rw, - rh, - uncompressed_size, + region.rx, + region.ry, + region.rw, + region.rh, + len(logical_stream), len(stream_bytes), use_compression, ) @@ -1238,15 +1224,14 @@ async def _maybe_upload_partial( new_etag = _generate_etag() _LOGGER.debug("Partial upload: old_etag=0x%08x new_etag=0x%08x", state.etag, new_etag) - # 1. 0x76 partial START (initial stream bytes packed in where space allows) start_pkt, remaining = build_direct_write_partial_start( old_etag=state.etag, new_etag=new_etag, flags=flags, - x=rx, - y=ry, - width=rw, - height=rh, + x=region.rx, + y=region.ry, + width=region.rw, + height=region.rh, stream_bytes=stream_bytes, ) await self._write(start_pkt) @@ -1256,7 +1241,7 @@ async def _maybe_upload_partial( if nack is not None: opcode, err = nack if opcode == 0x76 and err == ERR_ETAG_MISMATCH: - _LOGGER.info("Partial upload: device etag mismatch; falling back to full upload") + _LOGGER.info("Partial upload: etag mismatch; falling back to full upload") state.etag = 0 state.last_image = None return "fallback_full" @@ -1266,34 +1251,12 @@ async def _maybe_upload_partial( _LOGGER.info("Partial upload start was not acknowledged; falling back to full upload") return "fallback_full" - # 2. 0x71 DATA chunks for remaining stream bytes - chunk_size = ENCRYPTED_CHUNK_SIZE if self._session_key is not None else CHUNK_SIZE - total_stream_bytes = len(stream_bytes) - bytes_sent = total_stream_bytes - len(remaining) - offset = 0 - while offset < len(remaining): - chunk = remaining[offset : offset + chunk_size] - await self._write(build_direct_write_data_command(chunk)) - ack = await self._read(self.TIMEOUT_ACK) - nack = parse_nack(ack) - if nack is not None: - opcode, err = nack - state.etag = 0 - state.last_image = None - raise ProtocolError(f"Partial 0x71 NACK: opcode=0x{opcode:02x} err=0x{err:02x}") - validate_ack_response(ack, CommandCode.DIRECT_WRITE_DATA) - offset += len(chunk) - bytes_sent += len(chunk) - if progress_callback is not None: - progress_callback(bytes_sent, total_stream_bytes) + await self._send_partial_chunks(remaining, stream_bytes, state, progress_callback) - # 3. 0x72 END with refresh_mode only. The replacement etag is part of - # 0x76 for partial uploads. await self._write(build_direct_write_end_command(RefreshMode.PARTIAL.value)) response = await self._read(self.TIMEOUT_ACK) validate_ack_response(response, CommandCode.DIRECT_WRITE_END) - # 4. Wait for refresh-complete (0x73 device→host) response = await self._read(self.TIMEOUT_REFRESH) command, _ = check_response_type(response) if command == CommandCode.DIRECT_WRITE_REFRESH_TIMEOUT: @@ -1301,60 +1264,13 @@ async def _maybe_upload_partial( if command != CommandCode.DIRECT_WRITE_REFRESH_COMPLETE: raise ProtocolError(f"Unexpected response waiting for refresh: {command.name} (0x{command:04x})") - # Mutate state in place state.etag = new_etag - state.last_image = new_palette - state.width = width - state.height = height + state.last_image = region.new_palette + state.width = region.width + state.height = region.height state.bytes_per_pixel = 1 return "success" - @staticmethod - def _encode_segment_wire( - palette_image: Image.Image, - x: int, - y: int, - w: int, - h: int, - color_scheme: ColorScheme, - ) -> bytes: - """Crop the palette image to (x,y,w,h) and encode to tightly packed wire bytes. - - Partial rectangles are horizontally byte-aligned, so this produces the - same packed row-major bytes firmware expects for each 0x76 stream group. - """ - cropped = palette_image.crop((x, y, x + w, y + h)) - pixels = cropped.tobytes() - - if color_scheme == ColorScheme.MONO: - output = bytearray((len(pixels) + 7) // 8) - for i, palette_idx in enumerate(pixels): - if palette_idx > 0: - output[i // 8] |= 1 << (7 - (i % 8)) - return bytes(output) - - if color_scheme in (ColorScheme.BWRY, ColorScheme.GRAYSCALE_4): - output = bytearray((len(pixels) + 3) // 4) - for i, palette_idx in enumerate(pixels): - shift = (3 - (i % 4)) * 2 - output[i // 4] |= (palette_idx & 0x03) << shift - return bytes(output) - - if color_scheme in (ColorScheme.BWGBRY, ColorScheme.GRAYSCALE_16): - output = bytearray((len(pixels) + 1) // 2) - bwgbry_map = {0: 0, 1: 1, 2: 2, 3: 3, 4: 5, 5: 6} - for i, palette_idx in enumerate(pixels): - value = palette_idx & 0x0F - if color_scheme == ColorScheme.BWGBRY: - value = bwgbry_map.get(value, 0) - if i % 2 == 0: - output[i // 2] |= value << 4 - else: - output[i // 2] |= value - return bytes(output) - - return encode_image(cropped, color_scheme) - async def _execute_upload( self, image_data: bytes, diff --git a/src/opendisplay/partial.py b/src/opendisplay/partial.py index d1c2826..09ae7ae 100644 --- a/src/opendisplay/partial.py +++ b/src/opendisplay/partial.py @@ -16,6 +16,12 @@ import struct from dataclasses import dataclass +from epaper_dithering import ColorScheme +from PIL import Image + +from .encoding import encode_image +from .models.config import DisplayConfig, GlobalConfig + # --------------------------------------------------------------------------- # Wire constants mirrored from the firmware partial-rendering protocol. # --------------------------------------------------------------------------- @@ -48,6 +54,82 @@ def parse_nack(response: bytes) -> tuple[int, int] | None: return None +@dataclass +class PartialRegion: + """Container for validated partial-diff metadata before upload.""" + + display: DisplayConfig + color_scheme: ColorScheme + width: int + height: int + palette_image: Image.Image + new_palette: bytes + old_palette: bytes + rx: int + ry: int + rw: int + rh: int + + +def compute_partial_region( + processed_image: Image.Image, + state: PartialState, + config: GlobalConfig | None, + color_scheme: ColorScheme, +) -> str | PartialRegion: + """Build partial-region metadata for upload diffing.""" + if config is None or not getattr(config, "displays", None): + return "fallback_full" + + display = config.displays[0] + if not display.partial_update_support: + return "fallback_full" + + if color_scheme in (ColorScheme.BWR, ColorScheme.BWY): + return "fallback_full" + + width, height = processed_image.size + if state.etag == 0 or state.last_image is None or state.width != width or state.height != height: + return "fallback_full" + + palette_image = processed_image.convert("P") if processed_image.mode != "P" else processed_image + new_palette = palette_image.tobytes() + old_palette = state.last_image + if len(old_palette) != len(new_palette): + return "fallback_full" + + bbox = compute_bounding_rect(old_palette, new_palette, width, height) + if bbox is None: + return "no_change" + + bpp = { + ColorScheme.MONO: 1, + ColorScheme.BWRY: 2, + ColorScheme.GRAYSCALE_4: 2, + ColorScheme.BWGBRY: 4, + ColorScheme.GRAYSCALE_16: 4, + }.get(color_scheme, 1) + pixels_per_byte = _PIXELS_PER_BYTE.get(bpp, 8) + + rx, ry, rw, rh = align_rect(*bbox, width, height, pixels_per_byte) + if rw == 0 or rh == 0: + return "fallback_full" + + return PartialRegion( + display=display, + color_scheme=color_scheme, + width=width, + height=height, + palette_image=palette_image, + new_palette=new_palette, + old_palette=old_palette, + rx=rx, + ry=ry, + rw=rw, + rh=rh, + ) + + # --------------------------------------------------------------------------- # Bounding-rect helpers # --------------------------------------------------------------------------- @@ -68,16 +150,12 @@ def compute_bounding_rect( row_changed = False for x in range(width): if old[row_off + x] != new[row_off + x]: - if x < min_x: - min_x = x - if x > max_x: - max_x = x + min_x = min(min_x, x) + max_x = max(max_x, x) row_changed = True if row_changed: - if y < min_y: - min_y = y - if y > max_y: - max_y = y + min_y = min(min_y, y) + max_y = max(max_y, y) if max_x < 0: return None return (min_x, min_y, max_x + 1, max_y + 1) @@ -89,7 +167,7 @@ def align_rect( x1: int, y1: int, display_width: int, - display_height: int, + _display_height: int, pixels_per_byte: int, ) -> tuple[int, int, int, int]: """Expand (x0, y0, x1, y1) to packed-byte boundaries. @@ -108,6 +186,48 @@ def align_rect( return (aligned_x0, y0, aligned_x1 - aligned_x0, y1 - y0) +def encode_segment_wire( + palette_image: Image.Image, + x: int, + y: int, + w: int, + h: int, + color_scheme: ColorScheme, +) -> bytes: + """Encode a palette rectangle to protocol wire bytes for partial updates.""" + cropped = palette_image.crop((x, y, x + w, y + h)) + pixels = cropped.tobytes() + + if color_scheme == ColorScheme.MONO: + output = bytearray((len(pixels) + 7) // 8) + for i, palette_idx in enumerate(pixels): + if palette_idx > 0: + output[i // 8] |= 1 << (7 - (i % 8)) + return bytes(output) + + if color_scheme in (ColorScheme.BWRY, ColorScheme.GRAYSCALE_4): + output = bytearray((len(pixels) + 3) // 4) + for i, palette_idx in enumerate(pixels): + shift = (3 - (i % 4)) * 2 + output[i // 4] |= (palette_idx & 0x03) << shift + return bytes(output) + + if color_scheme in (ColorScheme.BWGBRY, ColorScheme.GRAYSCALE_16): + output = bytearray((len(pixels) + 1) // 2) + bwgbry_map = {0: 0, 1: 1, 2: 2, 3: 3, 4: 5, 5: 6} + for i, palette_idx in enumerate(pixels): + value = palette_idx & 0x0F + if color_scheme == ColorScheme.BWGBRY: + value = bwgbry_map.get(value, 0) + if i % 2 == 0: + output[i // 2] |= value << 4 + else: + output[i // 2] |= value + return bytes(output) + + return encode_image(cropped, color_scheme) + + def build_partial_logical_stream( old_rect_bytes: bytes, new_rect_bytes: bytes, diff --git a/tests/unit/test_device_partial.py b/tests/unit/test_device_partial.py index d252130..5c7a205 100644 --- a/tests/unit/test_device_partial.py +++ b/tests/unit/test_device_partial.py @@ -96,7 +96,7 @@ async def fail_write(data: bytes) -> None: monkeypatch.setattr(device, "_write", fail_write) - outcome = asyncio.run(device._maybe_upload_partial(_image(), b"\x00" * 16, RefreshMode.PARTIAL, state, None)) + outcome = asyncio.run(device._maybe_upload_partial(_image(), state, None)) assert outcome == "no_change" assert state.etag == 0x01020304 @@ -119,7 +119,7 @@ async def read_response(timeout: float) -> bytes: monkeypatch.setattr(device, "_write", capture_write) monkeypatch.setattr(device, "_read", read_response) - outcome = asyncio.run(device._maybe_upload_partial(new, b"\x00" * 16, RefreshMode.PARTIAL, state, None)) + outcome = asyncio.run(device._maybe_upload_partial(new, state, None)) opcodes = [int.from_bytes(w[:2], "big") for w in writes] assert outcome == "success" From fab5b4e2f149922487c1bbea25eee12be555f457 Mon Sep 17 00:00:00 2001 From: Michael Bisbjerg Date: Mon, 18 May 2026 21:41:52 +0200 Subject: [PATCH 18/18] restore explanatory comments in partial flow --- src/opendisplay/device.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/opendisplay/device.py b/src/opendisplay/device.py index d18adbe..5dca12c 100644 --- a/src/opendisplay/device.py +++ b/src/opendisplay/device.py @@ -1166,6 +1166,8 @@ async def _maybe_upload_partial( progress_callback: Callable[[int, int], None] | None = None, ) -> str: """Try a partial upload using the 0x76 single-rectangle protocol.""" + # Resolve all partial-update preconditions in one pass (support checks, + # state validation, diff computation, and region alignment). region = compute_partial_region(processed_image, state, self._config, self.color_scheme) if isinstance(region, str): return region @@ -1182,6 +1184,8 @@ async def _maybe_upload_partial( region.rh, ) + # Build logical stream from the changed rectangle only, then compress + # when it reduces the transfer size. old_palette_image = region.palette_image.copy() old_palette_image.frombytes(region.old_palette) old_rect_bytes = encode_segment_wire( @@ -1224,6 +1228,8 @@ async def _maybe_upload_partial( new_etag = _generate_etag() _LOGGER.debug("Partial upload: old_etag=0x%08x new_etag=0x%08x", state.etag, new_etag) + # Start partial upload (0x76), stream remaining 0x71 chunks, and finish + # with partial refresh. start_pkt, remaining = build_direct_write_partial_start( old_etag=state.etag, new_etag=new_etag,