diff --git a/src/opendisplay/cli.py b/src/opendisplay/cli.py index 7ee0f71..68165d3 100644 --- a/src/opendisplay/cli.py +++ b/src/opendisplay/cli.py @@ -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"), diff --git a/src/opendisplay/device.py b/src/opendisplay/device.py index 6605f0c..b7e41fa 100644 --- a/src/opendisplay/device.py +++ b/src/opendisplay/device.py @@ -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, @@ -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 @@ -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 @@ -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( @@ -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 diff --git a/src/opendisplay/encoding/__init__.py b/src/opendisplay/encoding/__init__.py index 41bc71f..3668f75 100644 --- a/src/opendisplay/encoding/__init__.py +++ b/src/opendisplay/encoding/__init__.py @@ -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, @@ -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", diff --git a/src/opendisplay/encoding/compression.py b/src/opendisplay/encoding/compression.py index 86bd220..0dccda9 100644 --- a/src/opendisplay/encoding/compression.py +++ b/src/opendisplay/encoding/compression.py @@ -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: diff --git a/src/opendisplay/models/config.py b/src/opendisplay/models/config.py index 34e04a2..4389ad3 100644 --- a/src/opendisplay/models/config.py +++ b/src/opendisplay/models/config.py @@ -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 diff --git a/tests/unit/test_device_upload_compression.py b/tests/unit/test_device_upload_compression.py index 0406134..db2c685 100644 --- a/tests/unit/test_device_upload_compression.py +++ b/tests/unit/test_device_upload_compression.py @@ -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)) @@ -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( @@ -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 diff --git a/tests/unit/test_device_upload_flow.py b/tests/unit/test_device_upload_flow.py index a6347ea..f3bbad6 100644 --- a/tests/unit/test_device_upload_flow.py +++ b/tests/unit/test_device_upload_flow.py @@ -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) @@ -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, ) @@ -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) @@ -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 ──────────────────────────────────────────── @@ -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 diff --git a/tests/unit/test_encoding_compression.py b/tests/unit/test_encoding_compression.py index 4115b70..023420c 100644 --- a/tests/unit/test_encoding_compression.py +++ b/tests/unit/test_encoding_compression.py @@ -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, @@ -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