Skip to content
Open
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 @@ -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,
Expand Down
33 changes: 33 additions & 0 deletions src/opendisplay/color_scheme.py
Original file line number Diff line number Diff line change
@@ -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
47 changes: 39 additions & 8 deletions src/opendisplay/device.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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,
Expand All @@ -29,6 +35,7 @@
FIRMWARE_ZLIB_WINDOW_BITS,
compress_image_data,
encode_2bpp,
encode_4bpp,
encode_bitplanes,
encode_gray4_bitplanes,
encode_image,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
)


Expand Down Expand Up @@ -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)

Expand Down Expand Up @@ -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."""
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
7 changes: 6 additions & 1 deletion src/opendisplay/display_palettes.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
{
Expand Down Expand Up @@ -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)
Expand Down
35 changes: 25 additions & 10 deletions src/opendisplay/encoding/images.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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)

Expand All @@ -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)
8 changes: 8 additions & 0 deletions src/opendisplay/models/capabilities.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
2 changes: 2 additions & 0 deletions src/opendisplay/models/enums.py
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,7 @@ class SeeedBoardType(IntEnum):
OPENDISPLAY_73_COLOR_KIT = 11
RETERMINAL_E1001 = 12
RETERMINAL_E1002 = 13
RETERMINAL_E1004 = 14


class WaveshareBoardType(IntEnum):
Expand Down Expand Up @@ -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]] = {
Expand Down
4 changes: 4 additions & 0 deletions src/opendisplay/protocol/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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",
Expand Down
6 changes: 6 additions & 0 deletions src/opendisplay/protocol/commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
57 changes: 57 additions & 0 deletions tests/unit/test_encoding_images.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""

Expand Down
2 changes: 2 additions & 0 deletions tests/unit/test_module_enums.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""
Expand Down Expand Up @@ -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

Expand Down
Loading
Loading