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
55 changes: 55 additions & 0 deletions src/opendisplay/models/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,15 @@
from __future__ import annotations

import math
import struct
from dataclasses import dataclass, field
from typing import ClassVar

from epaper_dithering import ColorScheme

from .enums import (
ActiveLevel,
BinaryInputType,
BoardManufacturer,
BusType,
CapacityEstimator,
Expand Down Expand Up @@ -529,6 +531,59 @@ class BinaryInputs:
button_data_byte_index: int = 0 # uint8 (v1+): dynamic return byte index (0-10)

SIZE: ClassVar[int] = 30
# ADC ladder packs (N, id_base) + (N+1) LE uint16 thresholds into reserved[14].
MAX_LADDER_BUTTONS: ClassVar[int] = 5
MAX_BUTTON_ID: ClassVar[int] = 7 # button id is a 3-bit field in the report byte
MAX_BUTTON_DATA_BYTE_INDEX: ClassVar[int] = 10 # index into the 11-byte MSD block

@classmethod
def adc_ladder(
cls,
*,
instance_number: int,
adc_pin: int,
id_base: int,
button_data_byte_index: int,
thresholds: list[int],
display_as: int = 0,
) -> BinaryInputs:
"""Build an ADC resistor-ladder input (input_type=3).

Several buttons share ``adc_pin``, distinguished by voltage. ``thresholds``
is N+1 strictly-descending ADC values: button i (reporting ``id_base + i``)
is pressed when ``thresholds[i+1] < adc <= thresholds[i]``; idle above
``thresholds[0]``. ``thresholds[N]`` is the bottom floor (use 0).
"""
if not 0 <= button_data_byte_index <= cls.MAX_BUTTON_DATA_BYTE_INDEX:
raise ValueError(
f"button_data_byte_index must be 0..{cls.MAX_BUTTON_DATA_BYTE_INDEX}, got {button_data_byte_index}"
)
button_count = len(thresholds) - 1
if not 1 <= button_count <= cls.MAX_LADDER_BUTTONS:
raise ValueError(
f"ADC ladder needs 2..{cls.MAX_LADDER_BUTTONS + 1} thresholds (N+1), got {len(thresholds)}"
)
last_id = id_base + button_count - 1
if id_base < 0 or last_id > cls.MAX_BUTTON_ID:
raise ValueError(f"button ids {id_base}..{last_id} exceed the 3-bit id space (0..{cls.MAX_BUTTON_ID})")
if any(not 0 <= t <= 0xFFFF for t in thresholds):
raise ValueError("ADC thresholds must be uint16 (0..65535)")
if any(a <= b for a, b in zip(thresholds, thresholds[1:])):
raise ValueError(f"ADC thresholds must be strictly descending, got {thresholds}")

reserved = struct.pack("<BB", button_count, id_base) + b"".join(struct.pack("<H", t) for t in thresholds)
return cls(
instance_number=instance_number,
input_type=BinaryInputType.ADC_LADDER,
display_as=display_as,
reserved_pins=bytes([adc_pin]) + bytes(7),
input_flags=0,
invert=0,
pullups=0,
pulldowns=0,
button_data_byte_index=button_data_byte_index,
reserved=reserved.ljust(14, b"\x00"),
)

@classmethod
def from_bytes(cls, data: bytes) -> BinaryInputs:
Expand Down
8 changes: 8 additions & 0 deletions src/opendisplay/models/enums.py
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,14 @@ class WifiEncryption(IntEnum):
WPA3 = 4


class BinaryInputType(IntEnum):
"""Binary input acquisition methods (BinaryInputs.input_type)."""

DIGITAL = 1 # one GPIO per button, digitalRead + edge interrupt
SWITCH = 2 # reserved for the host-side switch feature
ADC_LADDER = 3 # buttons share one ADC pin, distinguished by voltage (polled)


MANUFACTURER_NAMES: Final[dict[BoardManufacturer, str]] = {
BoardManufacturer.DIY: "DIY",
BoardManufacturer.SEEED: "Seeed Studio",
Expand Down
79 changes: 79 additions & 0 deletions tests/unit/test_required_packets.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
PowerOption,
SystemConfig,
)
from opendisplay.models.enums import BinaryInputType
from opendisplay.protocol.config_parser import parse_config_response, parse_tlv_config
from opendisplay.protocol.config_serializer import (
serialize_binary_inputs,
Expand Down Expand Up @@ -295,6 +296,84 @@ def test_serialize_binary_inputs_writes_button_data_byte_index_byte() -> None:
assert payload[15] == 6


def test_adc_ladder_serializes_to_firmware_wire_contract() -> None:
"""adc_ladder() must pack the X4 GPIO1 ladder exactly as the firmware decodes it."""
# XTEINK X4 GPIO1: 4 buttons, ids 0..3, calibrated descending ADC thresholds.
ladder = BinaryInputs.adc_ladder(
instance_number=0,
adc_pin=1,
id_base=0,
button_data_byte_index=5,
thresholds=[3850, 3163, 2132, 761, 0],
)

payload = serialize_binary_inputs(ladder)

assert ladder.input_type == BinaryInputType.ADC_LADDER
assert len(payload) == 30
assert payload[1] == BinaryInputType.ADC_LADDER # input_type
assert payload[3] == 1 # reserved_pin_1 = ADC GPIO
assert payload[15] == 5 # button_data_byte_index
# reserved[16:30] = N, id_base, then N+1 LE uint16 thresholds, zero-padded.
expected_reserved = struct.pack("<BB", 4, 0) + struct.pack("<5H", 3850, 3163, 2132, 761, 0)
assert payload[16:30] == expected_reserved.ljust(14, b"\x00")


@pytest.mark.parametrize(
"thresholds",
[
[0], # too few (N+1 must be >= 2)
[1, 2, 3, 4, 5, 6, 7], # too many (N > MAX_LADDER_BUTTONS)
[100, 100, 0], # not strictly descending
[100, 200, 0], # ascending
[70000, 0], # threshold exceeds uint16
],
)
def test_adc_ladder_rejects_malformed_thresholds(thresholds: list[int]) -> None:
"""adc_ladder() must reject degenerate threshold sets at construction time."""
with pytest.raises(ValueError):
BinaryInputs.adc_ladder(
instance_number=0,
adc_pin=1,
id_base=0,
button_data_byte_index=5,
thresholds=thresholds,
)


@pytest.mark.parametrize(
("id_base", "thresholds"),
[
(8, [100, 0]), # id_base alone past the 3-bit id space
(5, [100, 80, 60, 40, 20, 0]), # last id 5+5-1=9 > 7
(-1, [100, 0]), # negative id_base
],
)
def test_adc_ladder_rejects_id_base_overflow(id_base: int, thresholds: list[int]) -> None:
"""Button ids must fit the firmware's 3-bit report field (0..7); the firmware rejects, not masks."""
with pytest.raises(ValueError):
BinaryInputs.adc_ladder(
instance_number=0,
adc_pin=1,
id_base=id_base,
button_data_byte_index=5,
thresholds=thresholds,
)


@pytest.mark.parametrize("byte_index", [-1, 11, 255])
def test_adc_ladder_rejects_byte_index_out_of_range(byte_index: int) -> None:
"""button_data_byte_index must index the 11-byte MSD block (0..10); firmware drops anything past it."""
with pytest.raises(ValueError):
BinaryInputs.adc_ladder(
instance_number=0,
adc_pin=1,
id_base=0,
button_data_byte_index=byte_index,
thresholds=[100, 0],
)


def _minimal_system() -> SystemConfig:
return SystemConfig(
ic_type=1,
Expand Down