diff --git a/src/opendisplay/cli.py b/src/opendisplay/cli.py index a5d135b..af9dcff 100644 --- a/src/opendisplay/cli.py +++ b/src/opendisplay/cli.py @@ -433,7 +433,7 @@ async def _info(device_kwargs: dict[str, Any], output_json: bool) -> None: "fw": fw, "width": device.width, "height": device.height, - "color_str": device.color_scheme.name, + "color_str": device.color_scheme_name, "rotation": display.rotation_enum if display else device.rotation, "active_w_mm": display.active_width_mm if display else None, "active_h_mm": display.active_height_mm if display else None, diff --git a/src/opendisplay/color_scheme.py b/src/opendisplay/color_scheme.py new file mode 100644 index 0000000..002dd24 --- /dev/null +++ b/src/opendisplay/color_scheme.py @@ -0,0 +1,33 @@ +"""Firmware color-scheme helpers beyond epaper-dithering's enum. + +``COLOR_SCHEME_BWGBRY_SPLIT`` (8) uses the same Spectra 6 dither palette as +``BWGBRY`` (4), but packs left-half then right-half planes for dual-CS panels +(e.g. reTerminal E1004). The Rust dither core does not know value 8, so we +resolve it to ``ColorScheme.BWGBRY`` for dithering and keep the wire value +separately for encoding. +""" + +from __future__ import annotations + +from epaper_dithering import ColorScheme + +# Mirrors Firmware COLOR_SCHEME_BWGBRY_SPLIT / config.yaml color_scheme 8. +COLOR_SCHEME_BWGBRY_SPLIT: int = 8 + + +def resolve_firmware_color_scheme(value: int) -> tuple[ColorScheme, int]: + """Map a firmware color_scheme byte to (dither palette scheme, wire value). + + Raises: + ValueError: If the value is not a known firmware color scheme. + """ + if value == COLOR_SCHEME_BWGBRY_SPLIT: + return ColorScheme.BWGBRY, COLOR_SCHEME_BWGBRY_SPLIT + return ColorScheme.from_value(value), value + + +def color_scheme_display_name(scheme: ColorScheme, wire_value: int | None = None) -> str: + """Human-readable name, including BWGBRY_SPLIT when the wire value is 8.""" + if wire_value == COLOR_SCHEME_BWGBRY_SPLIT: + return "BWGBRY_SPLIT" + return scheme.name diff --git a/src/opendisplay/device.py b/src/opendisplay/device.py index 9ac8b88..d6d4fbc 100644 --- a/src/opendisplay/device.py +++ b/src/opendisplay/device.py @@ -7,6 +7,7 @@ import functools import hmac import logging +import math import time from collections.abc import AsyncIterator, Awaitable, Callable from contextlib import asynccontextmanager, suppress @@ -15,6 +16,11 @@ from epaper_dithering import ColorScheme, DitherMode, dither_image from PIL import Image +from .color_scheme import ( + COLOR_SCHEME_BWGBRY_SPLIT, + color_scheme_display_name, + resolve_firmware_color_scheme, +) from .crypto import ( compute_challenge_response, compute_server_proof, @@ -29,6 +35,7 @@ FIRMWARE_ZLIB_WINDOW_BITS, compress_image_data, encode_2bpp, + encode_4bpp, encode_bitplanes, encode_gray4_bitplanes, encode_image, @@ -78,6 +85,8 @@ NFC_WRITE_MAX_TOTAL, PIPE_FLAG_PARTIAL, PIPE_FRAME_OVERHEAD, + PIPE_MAX_RETX_FRACTION, + PIPE_RETX_ACK_SPACING, TIMEOUT_PIPE_START, CommandCode, PipeParams, @@ -204,11 +213,13 @@ def _capabilities_from_config(config: GlobalConfig) -> DeviceCapabilities: display = config.displays[0] rotation = display.rotation_enum + dither_scheme, wire_scheme = resolve_firmware_color_scheme(display.color_scheme) return DeviceCapabilities( width=display.pixel_width, height=display.pixel_height, - color_scheme=ColorScheme.from_value(display.color_scheme), + color_scheme=dither_scheme, rotation=rotation.value if isinstance(rotation, Rotation) else 0, + wire_color_scheme=wire_scheme, ) @@ -362,6 +373,12 @@ def prepare_image( # yellow/red swapped relative to the dither palette; apply the per-panel # code table so the firmware's raw-nibble direct write shows the right color. image_data = encode_2bpp(dithered, codes=get_bwry_codes(panel_ic_type)) + elif color_scheme == ColorScheme.BWGBRY: + image_data = encode_4bpp( + dithered, + bwgbry_mapping=True, + half_planes=(capabilities.wire_scheme == COLOR_SCHEME_BWGBRY_SPLIT), + ) else: image_data = encode_image(dithered, color_scheme) @@ -871,9 +888,15 @@ def height(self) -> int: @property def color_scheme(self) -> ColorScheme: - """Get display color scheme.""" + """Get display color scheme (dither palette; BWGBRY for scheme 8).""" return self._ensure_capabilities().color_scheme + @property + def color_scheme_name(self) -> str: + """Firmware-facing color scheme name (BWGBRY_SPLIT when wire value is 8).""" + caps = self._ensure_capabilities() + return color_scheme_display_name(caps.color_scheme, caps.wire_scheme) + @property def rotation(self) -> int: """Get display rotation in degrees.""" @@ -2566,7 +2589,10 @@ async def _send_pipe_chunks( # pylint: disable=too-many-locals,too-many-branche # Partial transfers never auto-complete (Part 1 §1.5), so they use the # same explicit-END completion contract compressed transfers use today. explicit_end = params.compressed or params.partial - max_retx = 3 * window + # Flat 3*W is fine for small transfers but starves multi-thousand-chunk + # uploads (E1004 ~960KB): BLE write-without-response drops are normal and + # legitimately need more repairs. Match the web client's scaled budget. + max_retx = max(3 * window, math.ceil(n * PIPE_MAX_RETX_FRACTION)) acked: set[int] = set() window_base = 0 # lowest unacked next_to_send = 0 @@ -2674,12 +2700,17 @@ async def _send(idx: int) -> None: continue if params.selective: + # Selective repeat, oldest first. pending_retx counts ACKs seen + # since a chunk was last (re)sent — wait PIPE_RETX_ACK_SPACING + # ACKs before repeating so an in-flight repair isn't double-counted. for m in missing: # oldest first - if m not in pending_retx: - do_retx = True # newly detected + seen = pending_retx.get(m) + if seen is None: + do_retx = True # newly detected hole else: - pending_retx[m] += 1 # a new ACK still shows it missing - do_retx = pending_retx[m] >= 1 # one implicit RTT of spacing + seen += 1 + pending_retx[m] = seen + do_retx = seen >= PIPE_RETX_ACK_SPACING if do_retx: await _send(m) pending_retx[m] = 0 @@ -2759,5 +2790,5 @@ def _extract_capabilities_from_config(self) -> DeviceCapabilities: display = self._config.displays[0] raise ImageEncodingError( f"Device uses unsupported color scheme value {display.color_scheme}. " - "Reconfigure the device to a supported color scheme (0–5)." + "Reconfigure the device to a supported color scheme (0–8)." ) from exc diff --git a/src/opendisplay/display_palettes.py b/src/opendisplay/display_palettes.py index 670d61a..850ab5b 100644 --- a/src/opendisplay/display_palettes.py +++ b/src/opendisplay/display_palettes.py @@ -9,6 +9,8 @@ ColorScheme, ) +from .color_scheme import resolve_firmware_color_scheme + # Panel IDs that support 4-gray mode (from firmware mapEpd) PANELS_4GRAY: frozenset[int] = frozenset( { @@ -105,7 +107,10 @@ def get_palette_for_display( Returns: ColorPalette if measured data exists and use_measured=True, otherwise ColorScheme enum """ - scheme = color_scheme if isinstance(color_scheme, ColorScheme) else ColorScheme.from_value(color_scheme) + if isinstance(color_scheme, ColorScheme): + scheme = color_scheme + else: + scheme, _wire = resolve_firmware_color_scheme(color_scheme) if use_measured and panel_ic_type is not None: key = (panel_ic_type, scheme) diff --git a/src/opendisplay/encoding/images.py b/src/opendisplay/encoding/images.py index d33890c..ed99d5c 100644 --- a/src/opendisplay/encoding/images.py +++ b/src/opendisplay/encoding/images.py @@ -95,7 +95,7 @@ def encode_image( if color_scheme == ColorScheme.BWGBRY: # 6-color Spectra 6 display uses 4bpp with special firmware values # Palette indices 0-5 map to firmware values 0,1,2,3,5,6 (4 is skipped!) - return encode_4bpp(image, bwgbry_mapping=True) + return encode_4bpp(image, bwgbry_mapping=True, half_planes=False) if color_scheme == ColorScheme.GRAYSCALE_4: # 4-gray needs two 1-bit controller planes, not packed 2bpp; the packed # form is not accepted by any firmware path. prepare_image routes 4-gray @@ -166,7 +166,11 @@ def encode_2bpp(image: Image.Image, codes: tuple[int, int, int, int] | None = No return packed.astype(np.uint8).tobytes() -def encode_4bpp(image: Image.Image, bwgbry_mapping: bool = False) -> bytes: +def encode_4bpp( + image: Image.Image, + bwgbry_mapping: bool = False, + half_planes: bool = False, +) -> bytes: """Encode image to 4-bits-per-pixel format (16 colors). Format: 2 pixels per byte, MSB first @@ -180,6 +184,9 @@ def encode_4bpp(image: Image.Image, bwgbry_mapping: bool = False) -> bytes: image: Palette image (mode 'P') bwgbry_mapping: If True, remap palette indices for BWGBRY firmware (0→0, 1→1, 2→2, 3→3, 4→5, 5→6) + half_planes: If True (firmware color_scheme 8 / BWGBRY_SPLIT), pack the + left half-plane first (all rows), then the right half-plane, so + dual-CS panels can stream CS1 then CS2 without a framebuffer. Returns: Encoded bytes @@ -188,7 +195,7 @@ def encode_4bpp(image: Image.Image, bwgbry_mapping: bool = False) -> bytes: raise ValueError(f"Expected palette image, got {image.mode}") pixels = np.asarray(image) - height, width = pixels.shape + _height, width = pixels.shape idx = (pixels & 0x0F).astype(np.uint8) @@ -199,10 +206,18 @@ def encode_4bpp(image: Image.Image, bwgbry_mapping: bool = False) -> bytes: lut = np.array([0, 1, 2, 3, 5, 6] + [0] * 10, dtype=np.uint8) idx = lut[idx] - # Zero-pad width to an even number (matches the per-row byte boundary), - # then pack 2 pixels per byte, high nibble first. - if width & 1: - idx = np.pad(idx, ((0, 0), (0, 1))) - idx = idx.reshape(height, -1, 2) - packed = (idx[:, :, 0] << 4) | idx[:, :, 1] - return packed.astype(np.uint8).tobytes() + def _pack_plane(plane: np.ndarray) -> bytes: + # Zero-pad width to an even number (per-row byte boundary), then pack + # 2 pixels per byte, high nibble first. + h, w = plane.shape + p = plane + if w & 1: + p = np.pad(p, ((0, 0), (0, 1))) + p = p.reshape(h, -1, 2) + packed = (p[:, :, 0] << 4) | p[:, :, 1] + return packed.astype(np.uint8).tobytes() + + if half_planes: + mid = width // 2 + return _pack_plane(idx[:, :mid]) + _pack_plane(idx[:, mid:]) + return _pack_plane(idx) diff --git a/src/opendisplay/models/capabilities.py b/src/opendisplay/models/capabilities.py index 785d693..b918159 100644 --- a/src/opendisplay/models/capabilities.py +++ b/src/opendisplay/models/capabilities.py @@ -15,3 +15,11 @@ class DeviceCapabilities: height: int color_scheme: ColorScheme rotation: int = 0 + # Raw firmware color_scheme byte. None means color_scheme.value. + # Set to 8 (BWGBRY_SPLIT) for dual-CS Spectra panels that need half-plane packing. + wire_color_scheme: int | None = None + + @property + def wire_scheme(self) -> int: + """Firmware color_scheme value used on the wire / for packing.""" + return self.color_scheme.value if self.wire_color_scheme is None else self.wire_color_scheme diff --git a/src/opendisplay/models/enums.py b/src/opendisplay/models/enums.py index 0e55a56..63b14d8 100644 --- a/src/opendisplay/models/enums.py +++ b/src/opendisplay/models/enums.py @@ -81,6 +81,7 @@ class SeeedBoardType(IntEnum): OPENDISPLAY_73_COLOR_KIT = 11 RETERMINAL_E1001 = 12 RETERMINAL_E1002 = 13 + RETERMINAL_E1004 = 14 class WaveshareBoardType(IntEnum): @@ -236,6 +237,7 @@ class BinaryInputType(IntEnum): SeeedBoardType.OPENDISPLAY_73_COLOR_KIT: 'OpenDisplay 7.3" Color Kit', SeeedBoardType.RETERMINAL_E1001: "reTerminal E1001", SeeedBoardType.RETERMINAL_E1002: "reTerminal E1002", + SeeedBoardType.RETERMINAL_E1004: "reTerminal E1004", } _BOARD_TYPE_NAMES_WAVESHARE: Final[dict[WaveshareBoardType, str]] = { diff --git a/src/opendisplay/protocol/__init__.py b/src/opendisplay/protocol/__init__.py index e544ed3..f8a34d9 100644 --- a/src/opendisplay/protocol/__init__.py +++ b/src/opendisplay/protocol/__init__.py @@ -19,6 +19,8 @@ PIPE_FLAG_COMPRESSED, PIPE_FLAG_PARTIAL, PIPE_FRAME_OVERHEAD, + PIPE_MAX_RETX_FRACTION, + PIPE_RETX_ACK_SPACING, PIPE_VERSION, SERVICE_UUID, TIMEOUT_PIPE_START, @@ -79,6 +81,8 @@ "PIPE_FLAG_COMPRESSED", "PIPE_FLAG_PARTIAL", "PIPE_FRAME_OVERHEAD", + "PIPE_MAX_RETX_FRACTION", + "PIPE_RETX_ACK_SPACING", "PIPE_VERSION", "PipePartialRequest", "TIMEOUT_PIPE_START", diff --git a/src/opendisplay/protocol/commands.py b/src/opendisplay/protocol/commands.py index a7d26f0..bf4fc5c 100644 --- a/src/opendisplay/protocol/commands.py +++ b/src/opendisplay/protocol/commands.py @@ -78,6 +78,12 @@ class CommandCode(IntEnum): # paid at most once per connection thanks to the probe cache. TIMEOUT_PIPE_START = 30.0 MAX_PTO = 3 # Consecutive silent probe timeouts before aborting a pipe transfer +# Match the web client (ble-common.js): a flat 3*W retx budget starves multi-thousand- +# chunk uploads when the BLE controller silently drops write-without-response frames. +PIPE_MAX_RETX_FRACTION = 0.5 # per-transfer retx budget as a fraction of chunk count +# ACKs a retransmit gets to land before repeating it. Spacing 1 burns the budget on +# in-flight holes; the web client uses 2 (~one RTT at typical N_eff). +PIPE_RETX_ACK_SPACING = 2 # NFC write endpoint (0x0083) sub-opcodes and size limits NFC_SUB_READ = 0x00 # Read the current NDEF record (not built here) diff --git a/tests/unit/test_encoding_images.py b/tests/unit/test_encoding_images.py index 1286eeb..7696918 100644 --- a/tests/unit/test_encoding_images.py +++ b/tests/unit/test_encoding_images.py @@ -63,6 +63,63 @@ def test_output_length(self) -> None: assert len(result) == 4 # ceil(3/2)=2 bytes/row × 2 rows +class TestEncodeBwgbrySplit: + """BWGBRY_SPLIT (wire scheme 8): left half-plane then right half-plane.""" + + def test_half_planes_pack_left_then_right(self) -> None: + """4×2 image: left 2 cols then right 2 cols, with BWGBRY nibble remap.""" + from opendisplay.encoding.images import encode_4bpp + + # Row0: L0=0,L1=1 | R0=2,R1=3 → left 0x01, right 0x23 + # Row1: L0=4,L1=5 | R0=1,R1=0 → left 0x56 (4→5,5→6), right 0x10 + img = _palette_image([[0, 1, 2, 3], [4, 5, 1, 0]]) + row_major = encode_4bpp(img, bwgbry_mapping=True, half_planes=False) + split = encode_4bpp(img, bwgbry_mapping=True, half_planes=True) + assert row_major == bytes([0x01, 0x23, 0x56, 0x10]) + assert split == bytes([0x01, 0x56, 0x23, 0x10]) + assert len(split) == len(row_major) + + def test_prepare_image_uses_split_for_wire_scheme_8(self) -> None: + from epaper_dithering import DitherMode + + from opendisplay.color_scheme import COLOR_SCHEME_BWGBRY_SPLIT + from opendisplay.device import prepare_image + from opendisplay.encoding.images import encode_4bpp + from opendisplay.models.capabilities import DeviceCapabilities + + caps = DeviceCapabilities( + width=4, + height=2, + color_scheme=ColorScheme.BWGBRY, + wire_color_scheme=COLOR_SCHEME_BWGBRY_SPLIT, + ) + # Already-quantized solid black avoids dither noise in the assert. + img = Image.new("RGB", (4, 2), (0, 0, 0)) + data, _compressed, dithered = prepare_image( + img, + capabilities=caps, + use_measured_palettes=False, + dither_mode=DitherMode.NONE, + compress=False, + ) + expected = encode_4bpp(dithered, bwgbry_mapping=True, half_planes=True) + assert data == expected + + def test_resolve_firmware_color_scheme_8(self) -> None: + from opendisplay.color_scheme import ( + COLOR_SCHEME_BWGBRY_SPLIT, + color_scheme_display_name, + resolve_firmware_color_scheme, + ) + + scheme, wire = resolve_firmware_color_scheme(COLOR_SCHEME_BWGBRY_SPLIT) + assert scheme is ColorScheme.BWGBRY + assert wire == 8 + assert color_scheme_display_name(scheme, wire) == "BWGBRY_SPLIT" + assert color_scheme_display_name(scheme, scheme.value) == "BWGBRY" + assert color_scheme_display_name(ColorScheme.MONO) == "MONO" + + class TestFitImage: """Tests for fit_image covering mode preservation and P-mode conversion.""" diff --git a/tests/unit/test_module_enums.py b/tests/unit/test_module_enums.py index 548b87b..b6da988 100644 --- a/tests/unit/test_module_enums.py +++ b/tests/unit/test_module_enums.py @@ -101,6 +101,7 @@ def test_seeed_board_type_values(self): assert SeeedBoardType.OPENDISPLAY_73_COLOR_KIT == 11 assert SeeedBoardType.RETERMINAL_E1001 == 12 assert SeeedBoardType.RETERMINAL_E1002 == 13 + assert SeeedBoardType.RETERMINAL_E1004 == 14 def test_waveshare_board_type_values(self): """Test Waveshare board type values.""" @@ -138,6 +139,7 @@ def test_board_type_name_helpers(self): assert get_board_type_name(BoardManufacturer.SEEED, 11) == 'OpenDisplay 7.3" Color Kit' assert get_board_type_name(BoardManufacturer.SEEED, 12) == "reTerminal E1001" assert get_board_type_name(BoardManufacturer.SEEED, 13) == "reTerminal E1002" + assert get_board_type_name(BoardManufacturer.SEEED, 14) == "reTerminal E1004" assert get_board_type_name(BoardManufacturer.SEEED, 99) is None assert get_board_type_name(99, 0) is None diff --git a/tests/unit/test_pipe_write_sender.py b/tests/unit/test_pipe_write_sender.py index 9eaed7d..f06ed24 100644 --- a/tests/unit/test_pipe_write_sender.py +++ b/tests/unit/test_pipe_write_sender.py @@ -189,15 +189,18 @@ async def test_rewind_fallback_when_bit0_clear() -> None: @pytest.mark.asyncio async def test_retransmit_repaced_per_new_ack() -> None: - """Two successive ACKs still showing the hole → chunk retransmitted each time.""" + """Hole still missing after PIPE_RETX_ACK_SPACING ACKs → retransmit again.""" + # Newly detected hole retransmits immediately; the next ACK (spacing=2) must + # elapse before a second retransmit — matches the web client's pacing. ack1 = data_ack({0, 2, 3}) - ack2 = data_ack({0, 2, 3}) # still missing 1 - ack3 = data_ack({0, 1, 2, 3}) - dev, conn = make_device([ack1, ack2, ack3], max_queue_size=4) + ack2 = data_ack({0, 2, 3}) # still missing 1; spacing not met yet + ack3 = data_ack({0, 2, 3}) # spacing met → second retransmit + ack4 = data_ack({0, 1, 2, 3}) + dev, conn = make_device([ack1, ack2, ack3, ack4], max_queue_size=4) chunks = [b"a", b"b", b"c", b"d"] await dev._send_pipe_chunks(chunks, _params(window=4), chunk_timeout=5.0) seqs = _seqs(conn) - # Initial 0,1,2,3 + retransmit on ack1 + retransmit on ack2 = chunk 1 sent 3x. + # Initial 0,1,2,3 + retx on ack1 + retx on ack3 = chunk 1 sent 3x. assert seqs.count(1) == 3 @@ -236,15 +239,30 @@ async def test_max_pto_aborts() -> None: @pytest.mark.asyncio async def test_max_retx_aborts() -> None: - """ACKs perpetually reporting the same hole → abort after 3*W retransmits.""" - # W=2 → MAX_RETX=6. Deliver many ACKs still missing chunk 1. - acks = [data_ack({0, 2})] * 20 # chunk 1 always missing (2 received, 3 not sent yet) + """ACKs perpetually reporting the same hole → abort after scaled MAX_RETX.""" + # W=2, n=3 → max_retx = max(6, ceil(1.5)) = 6. Deliver many ACKs still missing chunk 1. + acks = [data_ack({0, 2})] * 30 # chunk 1 always missing (2 received, 3 not sent yet) dev, conn = make_device(acks, max_queue_size=2) chunks = [b"a", b"b", b"c"] with pytest.raises(ProtocolError, match="MAX_RETX"): await dev._send_pipe_chunks(chunks, _params(window=2), chunk_timeout=5.0) +def test_max_retx_budget_scales_with_chunk_count() -> None: + """Large transfers get ceil(n * 0.5), not a flat 3*W (web-client parity).""" + import math + + from opendisplay.protocol import PIPE_MAX_RETX_FRACTION + + def budget(n: int, window: int) -> int: + return max(3 * window, math.ceil(n * PIPE_MAX_RETX_FRACTION)) + + assert budget(3, 16) == 48 # small transfer: floor wins + assert budget(4000, 16) == 2000 # E1004-scale: fraction wins (was aborting at 48) + assert budget(100, 2) == 50 + + + # ─── Compressed tail-flush (regression: MAJOR 1) ─────────────────────────────