Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion src/opendisplay/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -415,7 +415,7 @@ async def _info(device_kwargs: dict[str, Any], output_json: bool) -> None:
transmission_modes: list[str] = []
if display:
for flag, label in [
(display.supports_zipxl, "ZIPXL"),
(display.supports_streaming_decompression, "STREAMING"),
(display.supports_zip, "ZIP"),
(display.supports_g5, "G5"),
(display.supports_direct_write, "DIRECT_WRITE"),
Expand Down
44 changes: 28 additions & 16 deletions src/opendisplay/device.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@
)
from .display_palettes import PANELS_4GRAY, get_bwry_codes, get_gray4_codes, get_palette_for_display
from .encoding import (
ZIPXL_ZLIB_WINDOW_BITS,
FIRMWARE_ZLIB_WINDOW_BITS,
compress_image_data,
encode_2bpp,
encode_bitplanes,
Expand Down Expand Up @@ -311,9 +311,9 @@ def prepare_image(
if compress:
# Current firmware compiles uzlib with a 9-bit window and hard-rejects any
# zlib header advertising more, so always compress with a 9-bit window
# regardless of ZIPXL: a 9-bit stream decodes fine on any firmware whose
# regardless of transmission_modes: a 9-bit stream decodes fine on any firmware whose
# window is >= 9 (the firmware check is <=).
compressed_data = compress_image_data(image_data, level=6, window_bits=ZIPXL_ZLIB_WINDOW_BITS)
compressed_data = compress_image_data(image_data, level=6, window_bits=FIRMWARE_ZLIB_WINDOW_BITS)

return image_data, compressed_data, dithered

Expand Down Expand Up @@ -1275,9 +1275,15 @@ async def upload_image(
self.color_scheme.name,
)

# Check compression support early to avoid wasted CPU in _prepare_image
# Check compression support early to avoid wasted CPU in _prepare_image.
# Post-2.0 configs may advertise only streaming decompression (bit 0x01,
# historically ZIPXL) without the plain ZIP bit; firmware 2.0 accepts
# compressed uploads either way (<=1.81 NACKs without the ZIP bit and
# the upload falls back to uncompressed).
display_cfg = self._config.displays[0] if (self._config and self._config.displays) else None
supports_compression = display_cfg.supports_zip if display_cfg else True
supports_compression = (
(display_cfg.supports_zip or display_cfg.supports_streaming_decompression) if display_cfg else True
)

# When a partial upload may succeed, defer full-frame compression: it is
# pure waste if the partial path handles the update. _dispatch_upload
Expand Down Expand Up @@ -1395,23 +1401,27 @@ async def _dispatch_upload(
sent), False if the firmware auto-completed the upload.
"""
display_cfg = self._config.displays[0] if (self._config and self._config.displays) else None
supports_compression = display_cfg.supports_zip if display_cfg else True
uses_zipxl_window = bool(display_cfg and display_cfg.supports_zipxl)
supports_compression = (
(display_cfg.supports_zip or display_cfg.supports_streaming_decompression) if display_cfg else True
)
streaming_decompression = bool(display_cfg and display_cfg.supports_streaming_decompression)
if (
compress
and supports_compression
and (
compressed_data is None
or (uses_zipxl_window and zlib_window_bits(compressed_data) != ZIPXL_ZLIB_WINDOW_BITS)
or (streaming_decompression and zlib_window_bits(compressed_data) != FIRMWARE_ZLIB_WINDOW_BITS)
)
):
# Firmware only accepts zlib streams with a <=9-bit window regardless
# of ZIPXL (see prepare_image), so the lazy deferred/partial-fallback
# compression must use it too.
compressed_data = compress_image_data(image_data, level=6, window_bits=ZIPXL_ZLIB_WINDOW_BITS)
# Firmware only accepts zlib streams with a <=9-bit window (see
# prepare_image), so the lazy deferred/partial-fallback compression
# must use it too.
compressed_data = compress_image_data(image_data, level=6, window_bits=FIRMWARE_ZLIB_WINDOW_BITS)

within_compressed_limit = compressed_data is not None and (
uses_zipxl_window or len(compressed_data) < MAX_COMPRESSED_SIZE
# The 50 KB cap protects the old buffered decompressor; streaming
# decompression (bit 0x01) has no whole-blob buffer.
streaming_decompression or len(compressed_data) < MAX_COMPRESSED_SIZE
)
if compress and supports_compression and compressed_data and within_compressed_limit:
_LOGGER.info(
Expand Down Expand Up @@ -1562,9 +1572,11 @@ async def _maybe_upload_partial(
logical_stream = build_partial_logical_stream(old_rect_bytes, new_rect_bytes)
# A partial stream rides inside the 0x76 initial bytes; firmware only
# accepts a <= 9-bit zlib window, so always use a 9-bit window (a 15-bit
# window would be NACKed with ERR_PARTIAL_STREAM on non-ZIPXL devices).
compressed_stream = compress_image_data(logical_stream, level=6, window_bits=ZIPXL_ZLIB_WINDOW_BITS)
use_compression = display.supports_zip and len(compressed_stream) < len(logical_stream)
# window would be NACKed with ERR_PARTIAL_STREAM by 9-bit firmware).
compressed_stream = compress_image_data(logical_stream, level=6, window_bits=FIRMWARE_ZLIB_WINDOW_BITS)
use_compression = (display.supports_zip or display.supports_streaming_decompression) and len(
compressed_stream
) < len(logical_stream)
stream_bytes = compressed_stream if use_compression else logical_stream

flags = 0
Expand Down
2 changes: 2 additions & 0 deletions src/opendisplay/encoding/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
from .bitplanes import encode_bitplanes, encode_gray4_bitplanes
from .compression import (
DEFAULT_ZLIB_WINDOW_BITS,
FIRMWARE_ZLIB_WINDOW_BITS,
ZIPXL_ZLIB_WINDOW_BITS,
compress_image_data,
decompress_image_data,
Expand All @@ -19,6 +20,7 @@
"encode_bitplanes",
"encode_gray4_bitplanes",
"DEFAULT_ZLIB_WINDOW_BITS",
"FIRMWARE_ZLIB_WINDOW_BITS",
"ZIPXL_ZLIB_WINDOW_BITS",
"compress_image_data",
"decompress_image_data",
Expand Down
10 changes: 9 additions & 1 deletion src/opendisplay/encoding/compression.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,15 @@
_LOGGER = logging.getLogger(__name__)

DEFAULT_ZLIB_WINDOW_BITS = zlib.MAX_WBITS
ZIPXL_ZLIB_WINDOW_BITS = 9

# Largest DEFLATE window current firmware accepts: uzlib is compiled with
# OPENDISPLAY_ZLIB_WINDOW_BITS=9 and rejects zlib headers advertising more.
FIRMWARE_ZLIB_WINDOW_BITS = 9

# Deprecated alias: bit 0x01 of transmission_modes was historically named
# ZIPXL; it now means "streaming decompression" and the window limit applies
# to all compressed uploads, not just those devices.
ZIPXL_ZLIB_WINDOW_BITS = FIRMWARE_ZLIB_WINDOW_BITS


def compress_image_data(data: bytes, level: int = 6, window_bits: int = DEFAULT_ZLIB_WINDOW_BITS) -> bytes:
Expand Down
28 changes: 23 additions & 5 deletions src/opendisplay/models/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -288,18 +288,36 @@ class DisplayConfig:
reserved: bytes # 13 bytes

@property
def supports_zipxl(self) -> bool:
"""Check if display supports ZIP-XL (compressed streams use a 512-byte zlib window)."""
def supports_streaming_decompression(self) -> bool:
"""Check if display supports streaming decompression (bit 0x01).

Bit 0x01 has been repurposed over time: RAW → ZIPXL → streaming
decompression. On current devices it means the firmware inflates
compressed uploads through a streaming decompressor (no whole-blob
buffer, so no 50 KB size cap; zlib window <= 9 bits). Post-2.0
device configs may set only this bit, without TRANSMISSION_MODE_ZIP.
"""
return bool(self.transmission_modes & 0x01)

@property
def supports_zipxl(self) -> bool:
"""Deprecated alias for supports_streaming_decompression (bit 0x01 was previously named ZIPXL)."""
return self.supports_streaming_decompression

@property
def supports_raw(self) -> bool:
"""Legacy alias for supports_zipxl (bit 0x01 was previously named TRANSMISSION_MODE_RAW)."""
return self.supports_zipxl
"""Deprecated alias for supports_streaming_decompression (bit 0x01 was originally named RAW)."""
return self.supports_streaming_decompression

@property
def supports_zip(self) -> bool:
"""Check if display supports ZIP compressed transmission (TRANSMISSION_MODE_ZIP)."""
"""Check if display supports ZIP compressed transmission (bit 0x02).

The pre-2.0 convention: firmware <= 1.81 only accepts a compressed
START when this bit is set (NACKs it otherwise), and its buffered
decompressor holds the whole compressed blob in RAM (hence the 50 KB
cap). Pre-2.0 device configs may set only this bit.
"""
return bool(self.transmission_modes & 0x02)

@property
Expand Down
22 changes: 20 additions & 2 deletions tests/unit/test_device_upload_compression.py
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,24 @@ async def test_uses_compression_when_device_supports_zip(self, monkeypatch: pyte

assert captured["use_compression"] is True

@pytest.mark.asyncio
async def test_uses_compression_when_streaming_only(self, monkeypatch: pytest.MonkeyPatch) -> None:
"""Post-2.0 configs may set only bit 0x01 (streaming decompression), no ZIP bit.

Verified on hardware (EN05, transmission_modes=0x09): firmware 2.0 accepts
compressed uploads; firmware <= 1.81 NACKs the compressed START and the
library falls back to uncompressed.
"""
device = _make_device(config=_config(transmission_modes=0x09))
raw, compressed = b"\x01" * 100, b"\x02" * 10
monkeypatch.setattr(device, "_prepare_image", self._fake_prepare(raw, compressed))
captured, fake_execute = self._capture_execute()
monkeypatch.setattr(device, "_execute_upload", fake_execute)

await device.upload_image(Image.new("RGB", (2, 2)))

assert captured["use_compression"] is True

@pytest.mark.asyncio
async def test_skips_compression_when_device_does_not_support_zip(self, monkeypatch: pytest.MonkeyPatch) -> None:
device = _make_device(config=_config(transmission_modes=0x00))
Expand Down Expand Up @@ -235,7 +253,7 @@ def test_prepare_image_always_uses_9bit_zlib_window(transmission_modes: int) ->
"""Firmware only accepts a <=9-bit zlib window, so full-frame compression must
use a 9-bit window for both ZIP (0x02) and ZIPXL (0x03) devices (C3)."""
from opendisplay import prepare_image
from opendisplay.encoding import ZIPXL_ZLIB_WINDOW_BITS, zlib_window_bits
from opendisplay.encoding import FIRMWARE_ZLIB_WINDOW_BITS, zlib_window_bits

image = Image.new("RGB", (64, 64), (0, 0, 0))
_, compressed, _ = prepare_image(
Expand All @@ -244,4 +262,4 @@ def test_prepare_image_always_uses_9bit_zlib_window(transmission_modes: int) ->
compress=True,
)
assert compressed is not None
assert zlib_window_bits(compressed) == ZIPXL_ZLIB_WINDOW_BITS
assert zlib_window_bits(compressed) == FIRMWARE_ZLIB_WINDOW_BITS
30 changes: 16 additions & 14 deletions tests/unit/test_device_upload_flow.py
Original file line number Diff line number Diff line change
Expand Up @@ -341,11 +341,11 @@ async def test_after_fallback_data_chunks_use_uncompressed_timeout() -> None:


@pytest.mark.asyncio
async def test_dispatch_zipxl_accepts_large_compressed_data(monkeypatch: pytest.MonkeyPatch) -> None:
"""ZIPXL devices (bit 0x01) do not apply the legacy 50KB compressed-size limit."""
async def test_dispatch_streaming_accepts_large_compressed_data(monkeypatch: pytest.MonkeyPatch) -> None:
"""Streaming-decompression devices (bit 0x01) do not apply the legacy 50KB compressed-size limit."""
import random

from opendisplay.encoding import ZIPXL_ZLIB_WINDOW_BITS, compress_image_data, zlib_window_bits
from opendisplay.encoding import FIRMWARE_ZLIB_WINDOW_BITS, compress_image_data, zlib_window_bits
from opendisplay.protocol.commands import MAX_COMPRESSED_SIZE

# transmission_modes=0x03 → ZIPXL (bit 0) + ZIP (bit 1)
Expand All @@ -358,21 +358,21 @@ async def fake_execute(image_data, refresh_mode, use_compression=False, **kwargs

monkeypatch.setattr(device, "_execute_upload", fake_execute)
image_data = random.Random(0).randbytes(MAX_COMPRESSED_SIZE + 10 * 1024)
compressed_data = compress_image_data(image_data, window_bits=ZIPXL_ZLIB_WINDOW_BITS)
compressed_data = compress_image_data(image_data, window_bits=FIRMWARE_ZLIB_WINDOW_BITS)
assert len(compressed_data) > MAX_COMPRESSED_SIZE
await device._dispatch_upload(image_data, RefreshMode.FULL, True, compressed_data, None)
assert captured["use_compression"] is True
assert zlib_window_bits(captured["compressed_data"]) == ZIPXL_ZLIB_WINDOW_BITS
assert zlib_window_bits(captured["compressed_data"]) == FIRMWARE_ZLIB_WINDOW_BITS


@pytest.mark.asyncio
async def test_dispatch_zipxl_recompresses_prepared_data_with_512_byte_window(
async def test_dispatch_streaming_recompresses_prepared_data_with_512_byte_window(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Prepared data for ZIPXL is repaired to the firmware's 512-byte zlib window."""
"""Prepared data for streaming devices is repaired to the firmware's 512-byte zlib window."""
from opendisplay.encoding import (
DEFAULT_ZLIB_WINDOW_BITS,
ZIPXL_ZLIB_WINDOW_BITS,
FIRMWARE_ZLIB_WINDOW_BITS,
compress_image_data,
zlib_window_bits,
)
Expand All @@ -390,15 +390,15 @@ async def fake_execute(image_data, refresh_mode, use_compression=False, **kwargs

await device._dispatch_upload(image_data, RefreshMode.FULL, True, compressed_data, None)

assert zlib_window_bits(captured["compressed_data"]) == ZIPXL_ZLIB_WINDOW_BITS
assert zlib_window_bits(captured["compressed_data"]) == FIRMWARE_ZLIB_WINDOW_BITS


@pytest.mark.asyncio
async def test_dispatch_non_zipxl_lazy_compression_uses_9bit_window(
async def test_dispatch_zip_only_lazy_compression_uses_9bit_window(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Deferred compression (compressed_data=None) on a plain-ZIP device uses the 9-bit window."""
from opendisplay.encoding import ZIPXL_ZLIB_WINDOW_BITS, zlib_window_bits
from opendisplay.encoding import FIRMWARE_ZLIB_WINDOW_BITS, zlib_window_bits

# transmission_modes=0x02 → ZIP only, no ZIPXL
device = _make_device(transmission_modes=0x02)
Expand All @@ -414,7 +414,7 @@ async def fake_execute(image_data, refresh_mode, use_compression=False, **kwargs
await device._dispatch_upload(image_data, RefreshMode.FULL, True, None, None)

assert captured["use_compression"] is True
assert zlib_window_bits(captured["compressed_data"]) == ZIPXL_ZLIB_WINDOW_BITS
assert zlib_window_bits(captured["compressed_data"]) == FIRMWARE_ZLIB_WINDOW_BITS


# ─── _execute_upload: error paths ────────────────────────────────────────────
Expand Down Expand Up @@ -470,15 +470,17 @@ async def test_execute_upload_raises_on_unexpected_refresh_response() -> None:
# ─── DisplayConfig.supports_raw alias ────────────────────────────────────────


def test_display_config_supports_raw_aliases_supports_zipxl() -> None:
"""supports_raw is a legacy alias for supports_zipxl (bit 0x01)."""
def test_display_config_bit0_alias_properties() -> None:
"""supports_zipxl and supports_raw are deprecated aliases for supports_streaming_decompression."""
config = _make_config(transmission_modes=0x01)
display_cfg = config.displays[0]
assert display_cfg.supports_streaming_decompression is True
assert display_cfg.supports_zipxl is True
assert display_cfg.supports_raw is True

config_no = _make_config(transmission_modes=0x02)
display_cfg_no = config_no.displays[0]
assert display_cfg_no.supports_streaming_decompression is False
assert display_cfg_no.supports_zipxl is False
assert display_cfg_no.supports_raw is False

Expand Down
8 changes: 4 additions & 4 deletions tests/unit/test_encoding_compression.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@

from opendisplay.encoding import (
DEFAULT_ZLIB_WINDOW_BITS,
ZIPXL_ZLIB_WINDOW_BITS,
FIRMWARE_ZLIB_WINDOW_BITS,
compress_image_data,
decompress_image_data,
zlib_window_bits,
Expand All @@ -22,12 +22,12 @@ def test_compress_image_data_defaults_to_standard_zlib_window() -> None:
assert decompress_image_data(compressed) == data


def test_compress_image_data_supports_zipxl_512_byte_window() -> None:
def test_compress_image_data_supports_512_byte_firmware_window() -> None:
data = b"abc123" * 100

compressed = compress_image_data(data, window_bits=ZIPXL_ZLIB_WINDOW_BITS)
compressed = compress_image_data(data, window_bits=FIRMWARE_ZLIB_WINDOW_BITS)

assert zlib_window_bits(compressed) == ZIPXL_ZLIB_WINDOW_BITS
assert zlib_window_bits(compressed) == FIRMWARE_ZLIB_WINDOW_BITS
assert decompress_image_data(compressed) == data


Expand Down