From 57597be8af0293e6b372035da7a0a3dec8da3b4b Mon Sep 17 00:00:00 2001 From: Ben Stull Date: Fri, 5 Jun 2026 18:06:27 -0700 Subject: [PATCH] feat(player): Controls panel model (serial-contract data shape) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sub-project 3 slice 1, per ROADMAP.md §3 and design §6/§7. --- player/__init__.py | 0 player/controls.py | 68 +++++++++++++++++++++++++++++++++++ tests/test_player_controls.py | 53 +++++++++++++++++++++++++++ 3 files changed, 121 insertions(+) create mode 100644 player/__init__.py create mode 100644 player/controls.py create mode 100644 tests/test_player_controls.py diff --git a/player/__init__.py b/player/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/player/controls.py b/player/controls.py new file mode 100644 index 0000000..9e66b43 --- /dev/null +++ b/player/controls.py @@ -0,0 +1,68 @@ +"""Control-panel state: the data shape read from the Arduino over serial. + +This is the serial-contract payload shared with sub-project 4 (firmware). It +models the full panel from design §6/§7: the 7-way content dial, the four +experience knobs (0..4), and the two intensity levels (volume, brightness). +The wire framing itself is the separate 3<->4 serial contract; this module is +the *decoded* form. +""" + +from __future__ import annotations + +from dataclasses import dataclass, fields + +# The seven positions of the content dial (design §6). +CONTENT_POSITIONS = frozenset( + {"off", "white_noise", "music", "audio_track", "video", "music_video", "audio_video"} +) + +KNOB_FIELDS = ("left", "right", "dark", "light", "volume", "brightness") +KNOB_MIN = 0 +KNOB_MAX = 4 + + +class ControlsError(ValueError): + """Raised when a Controls payload is structurally invalid.""" + + +@dataclass(frozen=True) +class Controls: + content: str + left: int + right: int + dark: int + light: int + volume: int + brightness: int + + +def validate_controls(c: Controls) -> None: + """Raise ControlsError if the payload is structurally invalid.""" + if c.content not in CONTENT_POSITIONS: + raise ControlsError( + f"invalid content position {c.content!r}; " + f"expected one of {sorted(CONTENT_POSITIONS)}" + ) + for name in KNOB_FIELDS: + value = getattr(c, name) + if isinstance(value, bool) or not isinstance(value, int): + raise ControlsError(f"knob {name} must be an int, got {value!r}") + if not (KNOB_MIN <= value <= KNOB_MAX): + raise ControlsError( + f"knob {name}={value} out of range {KNOB_MIN}..{KNOB_MAX}" + ) + + +def parse_controls(data: dict) -> Controls: + """Build a validated Controls from a decoded mapping, rejecting unknown or + missing keys.""" + known = {f.name for f in fields(Controls)} + unknown = set(data) - known + if unknown: + raise ControlsError(f"unknown keys: {sorted(unknown)}") + missing = known - set(data) + if missing: + raise ControlsError(f"missing keys: {sorted(missing)}") + c = Controls(**data) + validate_controls(c) + return c diff --git a/tests/test_player_controls.py b/tests/test_player_controls.py new file mode 100644 index 0000000..1c9f910 --- /dev/null +++ b/tests/test_player_controls.py @@ -0,0 +1,53 @@ +import pytest + +from player.controls import ( + Controls, + CONTENT_POSITIONS, + ControlsError, + validate_controls, + parse_controls, +) + + +def test_content_positions_are_the_seven_from_the_spec(): + assert CONTENT_POSITIONS == frozenset( + {"off", "white_noise", "music", "audio_track", "video", "music_video", "audio_video"} + ) + + +def test_valid_controls_pass_validation(): + c = Controls(content="video", left=0, right=4, dark=2, light=2, volume=3, brightness=4) + validate_controls(c) # does not raise + + +def test_invalid_content_position_rejected(): + c = Controls(content="bogus", left=0, right=0, dark=0, light=0, volume=0, brightness=0) + with pytest.raises(ControlsError): + validate_controls(c) + + +@pytest.mark.parametrize("field", ["left", "right", "dark", "light", "volume", "brightness"]) +@pytest.mark.parametrize("bad", [-1, 5, True]) +def test_out_of_range_or_non_int_knob_rejected(field, bad): + kwargs = dict(content="off", left=0, right=0, dark=0, light=0, volume=0, brightness=0) + kwargs[field] = bad + with pytest.raises(ControlsError): + validate_controls(Controls(**kwargs)) + + +def test_parse_controls_from_mapping(): + c = parse_controls( + {"content": "music_video", "left": 1, "right": 2, "dark": 3, "light": 0, "volume": 2, "brightness": 1} + ) + assert c == Controls("music_video", 1, 2, 3, 0, 2, 1) + + +def test_parse_controls_rejects_unknown_keys(): + with pytest.raises(ControlsError): + parse_controls({"content": "off", "left": 0, "right": 0, "dark": 0, + "light": 0, "volume": 0, "brightness": 0, "bogus": 1}) + + +def test_parse_controls_rejects_missing_keys(): + with pytest.raises(ControlsError): + parse_controls({"content": "off", "left": 0})