From 44ea1600ce48ed88166a14d52561b34aa1ba9c55 Mon Sep 17 00:00:00 2001 From: David Bishop Date: Tue, 2 Jun 2026 11:16:02 -0700 Subject: [PATCH 1/2] Add adc_ladder() factory for ADC resistor-ladder binary inputs ADC resistor-ladder buttons (input_type=2) share one ADC pin and are distinguished by voltage. adc_ladder() packs (N, id_base) + (N+1) strictly-descending LE uint16 thresholds into BinaryInputs.reserved[14], matching the firmware's registerAdcLadder decode byte-for-byte. Validation mirrors the firmware contract so this helper can't build a config the device would silently drop: - 2..6 thresholds (N = 1..5; reserved[14] holds at most N+1=6) - thresholds strictly descending, each a uint16 - button_data_byte_index 0..10 (index into the 11-byte MSD block) - id_base >= 0 and last id <= 7 (button id is a 3-bit report field) Adds BinaryInputType enum and tests pinning the wire contract plus every rejection branch. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/opendisplay/models/config.py | 55 ++++++++++++++++++++ src/opendisplay/models/enums.py | 7 +++ tests/unit/test_required_packets.py | 79 +++++++++++++++++++++++++++++ 3 files changed, 141 insertions(+) diff --git a/src/opendisplay/models/config.py b/src/opendisplay/models/config.py index c7ecca6..29c26fd 100644 --- a/src/opendisplay/models/config.py +++ b/src/opendisplay/models/config.py @@ -7,6 +7,7 @@ from __future__ import annotations import math +import struct from dataclasses import dataclass, field from typing import ClassVar @@ -14,6 +15,7 @@ from .enums import ( ActiveLevel, + BinaryInputType, BoardManufacturer, BusType, CapacityEstimator, @@ -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=2). + + 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(" BinaryInputs: diff --git a/src/opendisplay/models/enums.py b/src/opendisplay/models/enums.py index 797f047..218f020 100644 --- a/src/opendisplay/models/enums.py +++ b/src/opendisplay/models/enums.py @@ -166,6 +166,13 @@ class WifiEncryption(IntEnum): WPA3 = 4 +class BinaryInputType(IntEnum): + """Binary input acquisition methods (BinaryInputs.input_type).""" + + DIGITAL = 1 # one GPIO per button, digitalRead + edge interrupt + ADC_LADDER = 2 # buttons share one ADC pin, distinguished by voltage (polled) + + MANUFACTURER_NAMES: Final[dict[BoardManufacturer, str]] = { BoardManufacturer.DIY: "DIY", BoardManufacturer.SEEED: "Seeed Studio", diff --git a/tests/unit/test_required_packets.py b/tests/unit/test_required_packets.py index 6acfe52..870d1b3 100644 --- a/tests/unit/test_required_packets.py +++ b/tests/unit/test_required_packets.py @@ -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, @@ -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("= 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, From 3de74d53427877dfbb732ca32bcb80b74afee051 Mon Sep 17 00:00:00 2001 From: David Bishop Date: Thu, 4 Jun 2026 12:01:03 -0700 Subject: [PATCH 2/2] Move ADC_LADDER to input_type 3 (2 is reserved for switches) Matches firmware #35 review: input_type == 2 is reserved for the host-side switch feature. Add SWITCH = 2 to model the reservation and renumber ADC_LADDER to 3. Wire layout is otherwise unchanged. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/opendisplay/models/config.py | 2 +- src/opendisplay/models/enums.py | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/src/opendisplay/models/config.py b/src/opendisplay/models/config.py index 29c26fd..1d8d67b 100644 --- a/src/opendisplay/models/config.py +++ b/src/opendisplay/models/config.py @@ -547,7 +547,7 @@ def adc_ladder( thresholds: list[int], display_as: int = 0, ) -> BinaryInputs: - """Build an ADC resistor-ladder input (input_type=2). + """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``) diff --git a/src/opendisplay/models/enums.py b/src/opendisplay/models/enums.py index 218f020..1b1ff25 100644 --- a/src/opendisplay/models/enums.py +++ b/src/opendisplay/models/enums.py @@ -170,7 +170,8 @@ class BinaryInputType(IntEnum): """Binary input acquisition methods (BinaryInputs.input_type).""" DIGITAL = 1 # one GPIO per button, digitalRead + edge interrupt - ADC_LADDER = 2 # buttons share one ADC pin, distinguished by voltage (polled) + 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]] = {