Full rearchitecture using Claude

- event bus systen
- all components are independent
- preparation for web frontend
This commit is contained in:
2026-08-26 13:22:28 +02:00
parent 55bcc9448c
commit d44c24ec97
70 changed files with 7591 additions and 1011 deletions

View File

View File

@@ -0,0 +1,39 @@
from __future__ import annotations
from pathlib import Path
from typing import Any
import pytest
from ruamel.yaml import YAML
VALID_CONFIG: dict[str, Any] = {
"general": {
"figure_folder": "music",
"serial_port": "/dev/ttyUSB0",
"min_volume": 0,
"max_volume": 60,
"initial_volume": 40,
},
"figures": {
"fuchs": {"id": "04a1b2c3d4", "colors": ["#ff6600", "#ffcc00", "#331100", "wff"]},
"eule": {"id": "04b2c3d4e5", "colors": ["#3355ff", "#66aaff", "#001133", "#ffffff"]},
},
}
@pytest.fixture
def config_dir(tmp_path: Path) -> Path:
"""A directory with a valid ``music/`` tree and two figures' worth of tracks."""
for figure, tracks in (("fuchs", 3), ("eule", 2)):
folder = tmp_path / "music" / figure
folder.mkdir(parents=True)
for index in range(tracks):
(folder / f"{index:02d} - track.mp3").write_bytes(b"")
return tmp_path
def write_config(directory: Path, data: dict[str, Any], name: str = "config.yml") -> Path:
path = directory / name
with path.open("w", encoding="utf-8") as handle:
YAML(typ="safe").dump(data, handle)
return path

View File

@@ -0,0 +1,178 @@
from __future__ import annotations
import asyncio
import logging
from collections.abc import AsyncIterator
import pytest
from musicmouse.bus import EventBus
from musicmouse.events import (
ButtonEvent,
Event,
InputEvent,
NextTrackRequested,
RfidTokenRead,
VolumeChanged,
)
from musicmouse.hardware import Button, ButtonAction
@pytest.fixture
async def bus() -> AsyncIterator[EventBus]:
async with EventBus() as running_bus:
yield running_bus
def _rfid(tag: str = "04a1b2c3d4") -> RfidTokenRead:
return RfidTokenRead(tag_id=bytes.fromhex(tag), figure="fuchs", source="device")
async def test_handler_receives_its_event(bus: EventBus) -> None:
seen: list[Event] = []
bus.subscribe(RfidTokenRead, seen.append)
event = _rfid()
await bus.emit_and_wait(event)
assert seen == [event]
async def test_unrelated_handlers_are_not_called(bus: EventBus) -> None:
seen: list[Event] = []
bus.subscribe(NextTrackRequested, seen.append)
await bus.emit_and_wait(_rfid())
assert seen == []
async def test_subscribing_to_a_base_class_catches_subclasses(bus: EventBus) -> None:
seen: list[Event] = []
bus.subscribe(InputEvent, seen.append)
await bus.emit_and_wait(_rfid())
await bus.emit_and_wait(VolumeChanged(volume=30))
assert [type(e) for e in seen] == [RfidTokenRead]
async def test_subscribe_all_sees_everything(bus: EventBus) -> None:
seen: list[Event] = []
bus.subscribe_all(seen.append)
await bus.emit_and_wait(_rfid())
await bus.emit_and_wait(VolumeChanged(volume=30))
assert [type(e) for e in seen] == [RfidTokenRead, VolumeChanged]
async def test_async_handlers_are_awaited(bus: EventBus) -> None:
seen: list[str] = []
async def slow(_: Event) -> None:
await asyncio.sleep(0.01)
seen.append("slow")
bus.subscribe(RfidTokenRead, slow)
bus.subscribe(RfidTokenRead, lambda _: seen.append("fast"))
await bus.emit_and_wait(_rfid())
assert seen == ["slow", "fast"]
async def test_events_are_dispatched_in_emission_order(bus: EventBus) -> None:
"""Ordering is what makes 'last event wins' meaningful for LED arbitration."""
seen: list[int] = []
async def record(event: VolumeChanged) -> None:
await asyncio.sleep(0)
seen.append(event.volume)
bus.subscribe(VolumeChanged, record)
for volume in range(5):
bus.emit(VolumeChanged(volume=volume))
await bus.drain()
assert seen == [0, 1, 2, 3, 4]
async def test_events_emitted_from_a_handler_are_handled_before_drain_returns(
bus: EventBus,
) -> None:
seen: list[str] = []
def on_button(_: ButtonEvent) -> None:
seen.append("button")
bus.emit(NextTrackRequested(source="device"))
bus.subscribe(ButtonEvent, on_button)
bus.subscribe(NextTrackRequested, lambda _: seen.append("next"))
await bus.emit_and_wait(
ButtonEvent(button=Button.RIGHT, action=ButtonAction.PRESSED, source="device")
)
assert seen == ["button", "next"]
async def test_a_raising_handler_does_not_stop_the_others(
bus: EventBus, caplog: pytest.LogCaptureFixture
) -> None:
seen: list[str] = []
def boom(_: Event) -> None:
raise RuntimeError("handler is broken")
bus.subscribe(RfidTokenRead, boom)
bus.subscribe(RfidTokenRead, lambda _: seen.append("survivor"))
with caplog.at_level(logging.ERROR):
await bus.emit_and_wait(_rfid())
await bus.emit_and_wait(_rfid())
assert seen == ["survivor", "survivor"]
assert "handler is broken" in caplog.text
async def test_unsubscribe(bus: EventBus) -> None:
seen: list[Event] = []
unsubscribe = bus.subscribe(RfidTokenRead, seen.append)
await bus.emit_and_wait(_rfid())
unsubscribe()
await bus.emit_and_wait(_rfid())
assert len(seen) == 1
async def test_emit_is_safe_from_another_thread(bus: EventBus) -> None:
"""libVLC fires its callbacks off-loop; this is the crossing that must be safe."""
seen: list[VolumeChanged] = []
done = asyncio.Event()
def record(event: VolumeChanged) -> None:
seen.append(event)
done.set()
bus.subscribe(VolumeChanged, record)
await asyncio.to_thread(bus.emit, VolumeChanged(volume=42, source="player"))
async with asyncio.timeout(2):
await done.wait()
assert [e.volume for e in seen] == [42]
async def test_emit_before_start_is_an_error() -> None:
with pytest.raises(RuntimeError, match="before start"):
EventBus().emit(VolumeChanged(volume=1))
async def test_stop_is_idempotent() -> None:
stopped = EventBus()
await stopped.start()
await stopped.stop()
await stopped.stop()

View File

@@ -0,0 +1,108 @@
from __future__ import annotations
import asyncio
from musicmouse.clock import FakeClock, RealClock
async def test_fake_clock_starts_at_zero_and_advances() -> None:
clock = FakeClock()
assert clock.now() == 0.0
await clock.advance(2.5)
assert clock.now() == 2.5
async def test_sleep_blocks_until_time_is_advanced_past_the_deadline() -> None:
clock = FakeClock()
woken = asyncio.Event()
async def sleeper() -> None:
await clock.sleep(10)
woken.set()
task = asyncio.create_task(sleeper())
await clock.advance(9)
assert not woken.is_set()
await clock.advance(2)
assert woken.is_set()
await task
async def test_sleepers_wake_in_deadline_order_regardless_of_start_order() -> None:
clock = FakeClock()
woken: list[str] = []
async def sleeper(name: str, delay: float) -> None:
await clock.sleep(delay)
woken.append(name)
tasks = [
asyncio.create_task(sleeper("late", 30)),
asyncio.create_task(sleeper("early", 10)),
asyncio.create_task(sleeper("middle", 20)),
]
await clock.advance(60)
assert woken == ["early", "middle", "late"]
await asyncio.gather(*tasks)
async def test_time_at_wake_up_is_the_deadline_not_the_target() -> None:
clock = FakeClock()
observed: list[float] = []
async def sleeper() -> None:
await clock.sleep(5)
observed.append(clock.now())
task = asyncio.create_task(sleeper())
await clock.advance(100)
assert observed == [5.0]
assert clock.now() == 100.0
await task
async def test_idle_hook_runs_after_each_wake_up() -> None:
calls: list[int] = []
async def idle() -> None:
calls.append(1)
clock = FakeClock(idle=idle)
async def sleeper() -> None:
await clock.sleep(1)
task = asyncio.create_task(sleeper())
await clock.advance(2)
assert calls # the bus got a chance to drain between virtual ticks
await task
async def test_pending_timers_is_visible() -> None:
clock = FakeClock()
task = asyncio.create_task(clock.sleep(5))
await asyncio.sleep(0)
assert clock.pending_timers == 1
await clock.advance(5)
assert clock.pending_timers == 0
await task
async def test_zero_sleep_just_yields() -> None:
clock = FakeClock()
await clock.sleep(0)
assert clock.now() == 0.0
async def test_real_clock_measures_real_elapsed_time() -> None:
clock = RealClock()
before = clock.now()
await clock.advance(0.01)
assert clock.now() - before >= 0.005

View File

@@ -0,0 +1,202 @@
from __future__ import annotations
import copy
from pathlib import Path
from typing import Any
import pytest
from musicmouse.color import ColorRGBW, parse_color
from musicmouse.config import ConfigError, build_playlists, load_config
from tests.conftest import VALID_CONFIG, write_config
def _config(**general: Any) -> dict[str, Any]:
data = copy.deepcopy(VALID_CONFIG)
data["general"].update(general)
return data
def test_loads_valid_config(config_dir: Path) -> None:
config = load_config(write_config(config_dir, VALID_CONFIG))
assert set(config.figures) == {"fuchs", "eule"}
assert config.figures["fuchs"].id == bytes.fromhex("04a1b2c3d4")
assert config.figures["fuchs"].colors.primary == ColorRGBW(1.0, 0.4, 0.0, 0)
assert config.figures["fuchs"].colors.accent == ColorRGBW(0, 0, 0, 1.0)
assert config.general.max_volume == 60
def test_figure_folder_resolves_relative_to_the_config_file(config_dir: Path) -> None:
config = load_config(write_config(config_dir, VALID_CONFIG))
assert config.general.figure_folder == (config_dir / "music").resolve()
def test_tag_map_and_playlists(config_dir: Path) -> None:
config = load_config(write_config(config_dir, VALID_CONFIG))
assert config.tag_map == {
bytes.fromhex("04a1b2c3d4"): "fuchs",
bytes.fromhex("04b2c3d4e5"): "eule",
}
playlists = build_playlists(config)
assert [t.path.name for t in playlists["fuchs"].tracks] == [
"00 - track.mp3",
"01 - track.mp3",
"02 - track.mp3",
]
assert len(playlists["eule"]) == 2
def test_playlist_is_alphabetical_regardless_of_creation_order(config_dir: Path) -> None:
folder = config_dir / "music" / "fuchs"
for name in ("zz last.mp3", "aa first.mp3"):
(folder / name).write_bytes(b"")
playlists = build_playlists(load_config(write_config(config_dir, VALID_CONFIG)))
names = [t.path.name for t in playlists["fuchs"].tracks]
assert names == sorted(names)
assert names[0] == "00 - track.mp3"
def test_non_audio_files_are_ignored(config_dir: Path) -> None:
(config_dir / "music" / "eule" / "cover.jpg").write_bytes(b"")
(config_dir / "music" / "eule" / "notes.txt").write_bytes(b"")
playlists = build_playlists(load_config(write_config(config_dir, VALID_CONFIG)))
assert len(playlists["eule"]) == 2
def test_missing_figure_folder_warns_but_does_not_fail(
config_dir: Path, caplog: pytest.LogCaptureFixture
) -> None:
data = copy.deepcopy(VALID_CONFIG)
data["figures"]["neu"] = {"id": "0400000001", "colors": ["#111111"] * 4}
config = load_config(write_config(config_dir, data))
playlists = build_playlists(config)
assert len(playlists["neu"]) == 0
assert "no media folder" in caplog.text
# --------------------------------------------------------------------- error paths
def _error(directory: Path, data: dict[str, Any]) -> str:
with pytest.raises(ConfigError) as excinfo:
load_config(write_config(directory, data))
return str(excinfo.value)
def test_unknown_option_is_rejected_with_its_path(config_dir: Path) -> None:
message = _error(config_dir, _config(buton_leds_brightness=0.5))
assert "general.buton_leds_brightness" in message
assert "unknown option" in message
def test_bad_color_names_the_figure_and_the_position(config_dir: Path) -> None:
data = copy.deepcopy(VALID_CONFIG)
data["figures"]["fuchs"]["colors"] = ["#ff6600", "not-a-color", "#331100", "wff"]
message = _error(config_dir, data)
assert "figures.fuchs.colors.secondary" in message
assert "'#rrggbb' or 'wNN'" in message
def test_wrong_number_of_colors(config_dir: Path) -> None:
data = copy.deepcopy(VALID_CONFIG)
data["figures"]["eule"]["colors"] = ["#ffffff", "#000000"]
message = _error(config_dir, data)
assert "figures.eule.colors" in message
assert "exactly 4 colors" in message
def test_duplicate_tag_ids_are_rejected(config_dir: Path) -> None:
data = copy.deepcopy(VALID_CONFIG)
data["figures"]["eule"]["id"] = data["figures"]["fuchs"]["id"]
message = _error(config_dir, data)
assert "both use tag id 04a1b2c3d4" in message
def test_tag_id_length_is_checked(config_dir: Path) -> None:
data = copy.deepcopy(VALID_CONFIG)
data["figures"]["fuchs"]["id"] = "04a1b2"
message = _error(config_dir, data)
assert "figures.fuchs.id" in message
assert "expected 5 bytes" in message
def test_all_zero_tag_id_is_reserved(config_dir: Path) -> None:
data = copy.deepcopy(VALID_CONFIG)
data["figures"]["fuchs"]["id"] = "0000000000"
assert "reserved" in _error(config_dir, data)
def test_volume_range_is_checked(config_dir: Path) -> None:
message = _error(config_dir, _config(min_volume=50, max_volume=20, initial_volume=30))
assert "must not exceed max_volume" in message
def test_initial_volume_must_lie_in_range(config_dir: Path) -> None:
message = _error(config_dir, _config(min_volume=10, max_volume=20, initial_volume=90))
assert "must lie between" in message
def test_missing_figure_folder_is_an_error(config_dir: Path) -> None:
message = _error(config_dir, _config(figure_folder="does-not-exist"))
assert "general.figure_folder" in message
assert "no such directory" in message
def test_every_problem_is_reported_at_once(config_dir: Path) -> None:
data = _config(volume_increment=0)
data["figures"]["fuchs"]["colors"] = ["#ff6600", "nope", "#331100", "wff"]
message = _error(config_dir, data)
assert "2 problems" in message
assert "general.volume_increment" in message
assert "figures.fuchs.colors.secondary" in message
def test_directory_instead_of_file_says_so(config_dir: Path) -> None:
with pytest.raises(ConfigError, match="Pass the config file itself"):
load_config(config_dir)
def test_missing_file(tmp_path: Path) -> None:
with pytest.raises(ConfigError, match="Cannot read config file"):
load_config(tmp_path / "nope.yml")
def test_malformed_yaml(tmp_path: Path) -> None:
path = tmp_path / "config.yml"
path.write_text("general: [unclosed\n", encoding="utf-8")
with pytest.raises(ConfigError, match="not valid YAML"):
load_config(path)
# --------------------------------------------------------------------------- colors
@pytest.mark.parametrize(
("text", "expected"),
[
("#000000", ColorRGBW(0, 0, 0, 0)),
("#ffffff", ColorRGBW(1, 1, 1, 0)),
("w00", ColorRGBW(0, 0, 0, 0)),
("wff", ColorRGBW(0, 0, 0, 1)),
],
)
def test_parse_color(text: str, expected: ColorRGBW) -> None:
assert parse_color(text) == expected
@pytest.mark.parametrize("text", ["#fff", "#gggggg", "orange", "", "#ff66000"])
def test_parse_color_rejects(text: str) -> None:
with pytest.raises(ValueError, match="unrecognized color format"):
parse_color(text)

View File

@@ -0,0 +1,141 @@
"""Golden-byte tests for the LED effect payloads.
These pin the exact layout the firmware reads back into its C++ structs. If one of
them fails, either the firmware struct changed or an effect field was reordered - both
would otherwise show up only as garbled LEDs on the real device.
"""
from __future__ import annotations
import pytest
from musicmouse.color import ColorHSV, ColorRGBW
from musicmouse.effects import (
EffectAlexaSwipeConfig,
EffectCircularConfig,
EffectRandomTwoColorInterpolationConfig,
EffectReverseSwipe,
EffectStaticConfig,
EffectStaticDetailedConfig,
EffectSwipeAndChange,
LedEffect,
)
def test_color_rgbw_is_four_bytes() -> None:
assert ColorRGBW(1.0, 0.5, 0.0, 0.25).as_bytes().hex() == "ff7f003f"
def test_color_rgbw_rejects_out_of_range_channels() -> None:
with pytest.raises(ValueError, match=r"within 0\.\.1"):
ColorRGBW(1.5, 0, 0, 0).as_bytes()
def test_color_hsv_is_three_floats() -> None:
# h=180.0, s=1.0, v=0.5
assert ColorHSV(180.0, 1.0, 0.5).as_bytes().hex() == "00003443" "0000803f" "0000003f"
def test_color_hsv_from_rgb() -> None:
assert ColorHSV.from_rgb(ColorRGBW(1, 0, 0, 0)) == ColorHSV(0.0, 1.0, 1.0)
assert ColorHSV.from_rgb(ColorRGBW(0, 1, 0, 0)) == ColorHSV(120.0, 1.0, 1.0)
def test_static() -> None:
effect = EffectStaticConfig(ColorRGBW(1.0, 0.5, 0.0, 0.25), begin=3, end=45)
assert effect.as_bytes().hex() == "ff7f003f" "0300" "2d00"
def test_static_detailed() -> None:
effect = EffectStaticDetailedConfig(
ColorRGBW(0, 0, 0, 1.0), increment=4, begin=0.25, end=0.75, transition_time_in_ms=500
)
assert effect.as_bytes().hex() == (
"000000ff" # color
"0400" # increment (uint16)
"0000803e" # begin 0.25f
"0000403f" # end 0.75f
"0000fa43" # transition 500.0f
)
def test_circular() -> None:
effect = EffectCircularConfig(speed=360, width=180, color=ColorRGBW(0, 0, 1, 0))
assert effect.as_bytes().hex() == "0000b443" "00003443" "0000ff00"
def test_reverse_swipe() -> None:
effect = EffectReverseSwipe(swipe_speed=720, bell_curve_width_in_leds=3, start_position=180)
assert effect.as_bytes().hex() == "00003444" "00004040" "00003443"
def test_alexa_swipe() -> None:
effect = EffectAlexaSwipeConfig(
primary_color_width=180,
transition_width=180,
swipe_speed=720,
bell_curve_width_in_leds=3,
start_position=180,
forward=False,
primary_color=ColorRGBW(1, 0, 0, 0),
secondary_color=ColorRGBW(0, 1, 0, 0),
)
assert effect.as_bytes().hex() == (
"00003443" "00003443" "00003444" "00004040" "00003443" # five floats
"00" # forward = false
"ff000000" # primary
"00ff0000" # secondary
)
def test_random_two_color_interpolation() -> None:
effect = EffectRandomTwoColorInterpolationConfig(
cycle_durations_ms=1000,
start_with_existing=True,
num_segments=3,
hue1_random=False,
hue2_random=True,
color1=ColorHSV(180.0, 1.0, 0.5),
color2=ColorHSV(0.0, 0.0, 0.0),
)
assert effect.as_bytes().hex() == (
"e8030000" # cycle_durations_ms int32
"01" # start_with_existing
"03000000" # num_segments int32
"00" # hue1_random
"01" # hue2_random
"000034430000803f0000003f" # color1 hsv
"000000000000000000000000" # color2 hsv
)
def test_rgb_colors_are_converted_to_hsv_on_the_wire() -> None:
"""The firmware struct is HSV; reactions hand it RGBW figure colours."""
as_rgb = EffectRandomTwoColorInterpolationConfig(
color1=ColorRGBW(1, 0, 0, 0), color2=ColorRGBW(0, 1, 0, 0)
)
as_hsv = EffectRandomTwoColorInterpolationConfig(
color1=ColorHSV(0.0, 1.0, 1.0), color2=ColorHSV(120.0, 1.0, 1.0)
)
assert as_rgb.as_bytes() == as_hsv.as_bytes()
def test_swipe_and_change_is_the_two_payloads_concatenated() -> None:
effect = EffectSwipeAndChange()
assert effect.as_bytes() == effect.swipe.as_bytes() + effect.change.as_bytes()
@pytest.mark.parametrize(
("effect", "size"),
[
(EffectStaticConfig(ColorRGBW(0, 0, 0, 0)), 8),
(EffectStaticDetailedConfig(ColorRGBW(0, 0, 0, 0)), 18),
(EffectCircularConfig(), 12),
(EffectAlexaSwipeConfig(), 29),
(EffectRandomTwoColorInterpolationConfig(), 35),
(EffectReverseSwipe(), 12),
(EffectSwipeAndChange(), 64),
],
)
def test_payload_sizes(effect: LedEffect, size: int) -> None:
assert len(effect.as_bytes()) == size

View File

@@ -0,0 +1,333 @@
from __future__ import annotations
import logging
from collections.abc import AsyncIterator
import pytest
from musicmouse.bus import EventBus
from musicmouse.color import ColorRGBW
from musicmouse.devices.mouse import MusicMouseDevice
from musicmouse.devices.wire import encode_input_event
from musicmouse.effects import (
OFF,
EffectAlexaSwipeConfig,
EffectCircularConfig,
EffectStaticConfig,
)
from musicmouse.events import (
ActiveFigureChanged,
ButtonEvent,
ConnectionChanged,
Event,
LedEffectChanged,
RfidTokenRead,
TouchButtonPressed,
)
from musicmouse.hardware import Button, ButtonAction, LedZone, TouchButton
from musicmouse.simulator import FakeTransport
FUCHS = bytes.fromhex("04a1b2c3d4")
EULE = bytes.fromhex("04b2c3d4e5")
UNKNOWN = bytes.fromhex("0999999999")
TAG_MAP = {FUCHS: "fuchs", EULE: "eule"}
@pytest.fixture
async def bus() -> AsyncIterator[EventBus]:
async with EventBus() as running:
yield running
@pytest.fixture
def transport() -> FakeTransport:
return FakeTransport()
@pytest.fixture
def device(bus: EventBus, transport: FakeTransport) -> MusicMouseDevice:
mouse = MusicMouseDevice(bus, transport, TAG_MAP, port="/dev/fake")
transport.attach(mouse.feed)
return mouse
@pytest.fixture
def seen(bus: EventBus) -> list[Event]:
events: list[Event] = []
bus.subscribe_all(events.append)
return events
def only[T: Event](events: list[Event], event_type: type[T]) -> list[T]:
return [e for e in events if isinstance(e, event_type)]
# ----------------------------------------------------------------- incoming events
async def test_button_press_reaches_the_bus(
bus: EventBus, device: MusicMouseDevice, seen: list[Event]
) -> None:
device.feed(
encode_input_event(ButtonEvent(button=Button.RIGHT, action=ButtonAction.PRESSED))
)
await bus.drain()
assert only(seen, ButtonEvent) == [
ButtonEvent(button=Button.RIGHT, action=ButtonAction.PRESSED, source="device")
]
async def test_touch_press_reaches_the_bus(
bus: EventBus, device: MusicMouseDevice, seen: list[Event]
) -> None:
device.feed(encode_input_event(TouchButtonPressed(button=TouchButton.LEFT_EAR)))
await bus.drain()
assert only(seen, TouchButtonPressed)[0].button == TouchButton.LEFT_EAR
async def test_known_tag_resolves_to_a_figure(
bus: EventBus, device: MusicMouseDevice, seen: list[Event]
) -> None:
device.feed(encode_input_event(RfidTokenRead(tag_id=FUCHS)))
await bus.drain()
assert only(seen, RfidTokenRead)[0].figure == "fuchs"
assert only(seen, ActiveFigureChanged) == [
ActiveFigureChanged(figure="fuchs", previous=None, source="device")
]
assert device.active_figure == "fuchs"
async def test_all_zero_tag_means_the_figure_was_removed(
bus: EventBus, device: MusicMouseDevice, seen: list[Event]
) -> None:
device.feed(encode_input_event(RfidTokenRead(tag_id=FUCHS)))
await bus.drain()
seen.clear()
device.feed(encode_input_event(RfidTokenRead(tag_id=bytes(5))))
await bus.drain()
assert only(seen, ActiveFigureChanged) == [
ActiveFigureChanged(figure=None, previous="fuchs", source="device")
]
assert device.active_figure is None
async def test_swapping_figures_reports_the_previous_one(
bus: EventBus, device: MusicMouseDevice, seen: list[Event]
) -> None:
device.feed(encode_input_event(RfidTokenRead(tag_id=FUCHS)))
await bus.drain()
seen.clear()
device.feed(encode_input_event(RfidTokenRead(tag_id=EULE)))
await bus.drain()
assert only(seen, ActiveFigureChanged) == [
ActiveFigureChanged(figure="eule", previous="fuchs", source="device")
]
async def test_rereading_the_same_tag_is_not_a_change(
bus: EventBus, device: MusicMouseDevice, seen: list[Event]
) -> None:
for _ in range(3):
device.feed(encode_input_event(RfidTokenRead(tag_id=FUCHS)))
await bus.drain()
assert len(only(seen, RfidTokenRead)) == 3
assert len(only(seen, ActiveFigureChanged)) == 1
async def test_unknown_tag_is_reported_but_does_not_change_the_active_figure(
bus: EventBus, device: MusicMouseDevice, seen: list[Event], caplog: pytest.LogCaptureFixture
) -> None:
device.feed(encode_input_event(RfidTokenRead(tag_id=FUCHS)))
await bus.drain()
seen.clear()
with caplog.at_level(logging.WARNING):
device.feed(encode_input_event(RfidTokenRead(tag_id=UNKNOWN)))
await bus.drain()
read = only(seen, RfidTokenRead)[0]
assert read.known is False
assert read.figure is None
assert only(seen, ActiveFigureChanged) == []
assert device.active_figure == "fuchs"
assert "Unknown RFID tag 0999999999" in caplog.text
async def test_firmware_log_lines_are_logged_not_published(
bus: EventBus, device: MusicMouseDevice, seen: list[Event], caplog: pytest.LogCaptureFixture
) -> None:
with caplog.at_level(logging.INFO):
device.feed(b"RFID reader ready\n")
await bus.drain()
assert seen == []
assert "[firmware] RFID reader ready" in caplog.text
async def test_a_bad_frame_is_dropped_and_the_next_one_still_arrives(
bus: EventBus, device: MusicMouseDevice, seen: list[Event], caplog: pytest.LogCaptureFixture
) -> None:
import struct
from musicmouse.devices.wire import MAGIC_FW_TO_HOST
bad = struct.pack("<IBH", MAGIC_FW_TO_HOST, 99, 1) + b"\x00"
good = encode_input_event(TouchButtonPressed(button=TouchButton.RIGHT_EAR))
with caplog.at_level(logging.WARNING):
device.feed(bad + good)
await bus.drain()
assert only(seen, TouchButtonPressed)[0].button == TouchButton.RIGHT_EAR
assert "Discarding bad frame" in caplog.text
# ------------------------------------------------------------------------ actions
async def test_setting_an_effect_writes_it_and_announces_it(
bus: EventBus, device: MusicMouseDevice, transport: FakeTransport, seen: list[Event]
) -> None:
effect = EffectStaticConfig(ColorRGBW(1, 0, 0, 0))
device.set_effect(LedZone.RING, effect, origin="mqtt")
await bus.drain()
assert transport.effect(LedZone.RING) == effect
assert only(seen, LedEffectChanged) == [
LedEffectChanged(zone=LedZone.RING, effect=effect, origin="mqtt", source="device")
]
assert device.effect(LedZone.RING) == effect
async def test_last_write_wins_per_zone(
bus: EventBus, device: MusicMouseDevice, transport: FakeTransport
) -> None:
"""A figure animation and an MQTT command fight over the shelf; the later one wins."""
from_mqtt = EffectStaticConfig(ColorRGBW(0, 0, 1, 0))
from_figure = EffectCircularConfig(color=ColorRGBW(1, 0, 0, 0))
device.set_effect(LedZone.SHELF, from_mqtt, origin="mqtt")
device.set_effect(LedZone.SHELF, from_figure, origin="device")
await bus.drain()
assert transport.effect(LedZone.SHELF) == from_figure
assert device.effect(LedZone.SHELF) == from_figure
async def test_zones_are_independent(
bus: EventBus, device: MusicMouseDevice, transport: FakeTransport
) -> None:
ring = EffectStaticConfig(ColorRGBW(1, 0, 0, 0))
shelf = EffectStaticConfig(ColorRGBW(0, 1, 0, 0))
device.set_effect(LedZone.RING, ring)
device.set_effect(LedZone.SHELF, shelf)
await bus.drain()
assert transport.effect(LedZone.RING) == ring
assert transport.effect(LedZone.SHELF) == shelf
assert transport.effect(LedZone.MOUSE) is None
async def test_an_effect_a_zone_does_not_support_is_reported_not_sent(
bus: EventBus,
device: MusicMouseDevice,
transport: FakeTransport,
caplog: pytest.LogCaptureFixture,
) -> None:
with caplog.at_level(logging.ERROR):
device.set_effect(LedZone.MOUSE, EffectAlexaSwipeConfig())
await bus.drain()
assert transport.effect(LedZone.MOUSE) is None
assert device.effect(LedZone.MOUSE) is None
assert "cannot be sent to the mouse LEDs" in caplog.text
async def test_button_brightness_sets_both_backlights(
device: MusicMouseDevice, transport: FakeTransport
) -> None:
device.set_button_brightness(0.25)
assert transport.brightness(Button.LEFT) == pytest.approx(0.25)
assert transport.brightness(Button.RIGHT) == pytest.approx(0.25)
assert device.button_led_brightness == pytest.approx(0.25)
@pytest.mark.parametrize(("given", "expected"), [(-1.0, 0.0), (5.0, 1.0)])
async def test_button_brightness_is_clamped(
device: MusicMouseDevice, transport: FakeTransport, given: float, expected: float
) -> None:
device.set_button_brightness(given)
assert transport.brightness() == pytest.approx(expected)
async def test_all_leds_off(
bus: EventBus, device: MusicMouseDevice, transport: FakeTransport
) -> None:
device.set_button_brightness(1.0)
device.all_leds_off()
await bus.drain()
for zone in LedZone:
assert transport.effect(zone) == OFF()
assert transport.brightness() == pytest.approx(0.0)
# --------------------------------------------------------------------- reconnect
async def test_writes_while_disconnected_are_dropped(
bus: EventBus, device: MusicMouseDevice, transport: FakeTransport
) -> None:
transport.disconnect()
device.set_effect(LedZone.RING, EffectStaticConfig(ColorRGBW(1, 0, 0, 0)))
await bus.drain()
assert transport.effect(LedZone.RING) is None
assert transport.dropped_bytes > 0
async def test_reconnect_restores_the_memorized_led_state(
bus: EventBus, device: MusicMouseDevice, transport: FakeTransport
) -> None:
"""The point of memorizing state: a pulled USB cable should be invisible."""
ring = EffectStaticConfig(ColorRGBW(1, 0, 0, 0))
shelf = EffectCircularConfig(color=ColorRGBW(0, 0, 1, 0))
device.set_effect(LedZone.RING, ring)
device.set_effect(LedZone.SHELF, shelf)
device.set_button_brightness(0.5)
await bus.drain()
transport.disconnect()
device.on_disconnected("port closed")
await bus.drain()
transport.clear()
transport.reconnect()
device.on_connected()
await bus.drain()
assert transport.effect(LedZone.RING) == ring
assert transport.effect(LedZone.SHELF) == shelf
assert transport.brightness() == pytest.approx(0.5)
async def test_connection_changes_are_announced(
bus: EventBus, device: MusicMouseDevice, seen: list[Event]
) -> None:
device.on_connected()
device.on_disconnected("cable pulled")
await bus.drain()
assert only(seen, ConnectionChanged) == [
ConnectionChanged(target="firmware", connected=True, source="device"),
ConnectionChanged(target="firmware", connected=False, source="device"),
]

View File

@@ -0,0 +1,535 @@
from __future__ import annotations
import json
import logging
from collections.abc import AsyncIterator
from dataclasses import dataclass, field
import pytest
from musicmouse.bus import EventBus
from musicmouse.color import ColorRGBW
from musicmouse.config import MqttConfig
from musicmouse.devices.mouse import MusicMouseDevice
from musicmouse.effects import (
EffectCircularConfig,
EffectRandomTwoColorInterpolationConfig,
EffectStaticDetailedConfig,
EffectSwipeAndChange,
)
from musicmouse.events import (
ButtonEvent,
Event,
NextTrackRequested,
PauseRequested,
RfidTokenRead,
SetVolumeRequested,
TouchButtonPressed,
)
from musicmouse.hardware import Button, ButtonAction, LedZone, TouchButton
from musicmouse.services.mqtt.entity import Entity
from musicmouse.services.mqtt.lights import (
BLACK,
LightEntity,
effect_names,
parse_positional_effect,
)
from musicmouse.services.mqtt.player import TransportButton, VolumeNumber
from musicmouse.services.mqtt.service import build_entities
from musicmouse.services.mqtt.triggers import ButtonTrigger, TagScanner, TouchTrigger
from musicmouse.simulator.fake_player import FakePlayer
from musicmouse.simulator.fake_transport import FakeTransport
TAG_MAP = {bytes.fromhex("04a1b2c3d4"): "fuchs"}
@dataclass
class RecordingPublisher:
"""Stands in for the broker connection."""
messages: list[tuple[str, str, bool]] = field(default_factory=list)
async def publish(self, topic: str, payload: str, *, retain: bool = False) -> None:
self.messages.append((topic, payload, retain))
def payloads_on(self, topic: str) -> list[str]:
return [payload for sent_topic, payload, _ in self.messages if sent_topic == topic]
def last_json(self, topic: str) -> dict:
return json.loads(self.payloads_on(topic)[-1])
def clear(self) -> None:
self.messages.clear()
@pytest.fixture
async def bus() -> AsyncIterator[EventBus]:
async with EventBus() as running:
yield running
@pytest.fixture
def mqtt_config() -> MqttConfig:
return MqttConfig(server="broker.local")
@pytest.fixture
def transport() -> FakeTransport:
return FakeTransport()
@pytest.fixture
def mouse(bus: EventBus, transport: FakeTransport) -> MusicMouseDevice:
device = MusicMouseDevice(bus, transport, TAG_MAP, port="simulated")
transport.attach(device.feed)
return device
@pytest.fixture
def player(bus: EventBus) -> FakePlayer:
return FakePlayer(bus, initial_volume=40)
@pytest.fixture
def publisher() -> RecordingPublisher:
return RecordingPublisher()
def attach(entity: Entity, publisher: RecordingPublisher) -> Entity:
entity.attach(publisher)
return entity
@pytest.fixture
def shelf(
bus: EventBus, mqtt_config: MqttConfig, mouse: MusicMouseDevice, publisher: RecordingPublisher
) -> LightEntity:
entity = LightEntity(bus, mqtt_config, mouse, LedZone.SHELF, "Shelf")
entity.attach(publisher)
return entity
def command(**fields: object) -> str:
return json.dumps(fields)
# ------------------------------------------------------------------ effect names
def test_the_old_effect_names_still_decode_the_same_way() -> None:
"""Values taken from the hand-written elif chain in the old mqtt_json.py."""
assert parse_positional_effect("side_0.2") == (0.9, 0.1, 1)
assert parse_positional_effect("side_0.5") == (0.75, 0.25, 1)
assert parse_positional_effect("top_0.2") == (0.4, 0.6, 1)
assert parse_positional_effect("top_0.5") == (0.25, 0.75, 1)
assert parse_positional_effect("side_0.2_inc4") == (0.9, 0.1, 4)
assert parse_positional_effect("side_0.2_inc8") == (0.9, 0.1, 8)
assert parse_positional_effect("top_0.5_inc4") == (0.25, 0.75, 4)
@pytest.mark.parametrize("name", ["static", "circular", "sideways_0.2", "side", "top_", "side_x"])
def test_non_positional_names_are_not_parsed(name: str) -> None:
assert parse_positional_effect(name) is None
def test_every_old_effect_name_is_still_offered() -> None:
previously_offered = {
"static", "circular", "wipeup", "twocolor", "twocolorrandom",
"side_0.2", "side_0.5", "side_0.2_inc4", "side_0.2_inc8", "side_0.5_inc4",
"top_0.2", "top_0.5", "top_0.2_inc4", "top_0.5_inc4",
} # fmt: skip
assert previously_offered <= set(effect_names())
def test_every_offered_effect_name_can_be_built(
shelf: LightEntity, mouse: MusicMouseDevice
) -> None:
for name in effect_names():
shelf._state.update({"state": "ON", "effect": name})
assert shelf._build_effect() is not None
# ------------------------------------------------------------------- light commands
async def test_turning_the_light_on_sets_a_static_effect(
bus: EventBus, shelf: LightEntity, mouse: MusicMouseDevice
) -> None:
await shelf.handle(
shelf.command_topic,
command(state="ON", color={"r": 255, "g": 0, "b": 0, "w": 0}, brightness=255),
)
await bus.drain()
effect = mouse.effect(LedZone.SHELF)
assert isinstance(effect, EffectStaticDetailedConfig)
assert effect.color == ColorRGBW(1.0, 0.0, 0.0, 0.0)
async def test_brightness_scales_the_colour(
bus: EventBus, shelf: LightEntity, mouse: MusicMouseDevice
) -> None:
await shelf.handle(
shelf.command_topic,
command(state="ON", color={"r": 255, "g": 0, "b": 0, "w": 0}, brightness=128),
)
await bus.drain()
effect = mouse.effect(LedZone.SHELF)
assert isinstance(effect, EffectStaticDetailedConfig)
assert effect.color.r == pytest.approx(128 / 255, abs=0.01)
async def test_turning_the_light_off_sends_black(
bus: EventBus, shelf: LightEntity, mouse: MusicMouseDevice
) -> None:
await shelf.handle(shelf.command_topic, command(state="OFF"))
await bus.drain()
effect = mouse.effect(LedZone.SHELF)
assert isinstance(effect, EffectStaticDetailedConfig)
assert effect.color == BLACK
async def test_a_positional_effect_becomes_a_detailed_static(
bus: EventBus, shelf: LightEntity, mouse: MusicMouseDevice
) -> None:
await shelf.handle(shelf.command_topic, command(state="ON", effect="top_0.5_inc4"))
await bus.drain()
effect = mouse.effect(LedZone.SHELF)
assert isinstance(effect, EffectStaticDetailedConfig)
assert (effect.begin, effect.end, effect.increment) == (0.25, 0.75, 4)
@pytest.mark.parametrize(
("name", "expected"),
[
("circular", EffectCircularConfig),
("wipeup", EffectSwipeAndChange),
("twocolor", EffectRandomTwoColorInterpolationConfig),
("twocolorrandom", EffectRandomTwoColorInterpolationConfig),
],
)
async def test_named_effects(
bus: EventBus, shelf: LightEntity, mouse: MusicMouseDevice, name: str, expected: type
) -> None:
await shelf.handle(shelf.command_topic, command(state="ON", effect=name))
await bus.drain()
assert isinstance(mouse.effect(LedZone.SHELF), expected)
async def test_twocolorrandom_randomises_both_hues(
bus: EventBus, shelf: LightEntity, mouse: MusicMouseDevice
) -> None:
await shelf.handle(shelf.command_topic, command(state="ON", effect="twocolorrandom"))
await bus.drain()
effect = mouse.effect(LedZone.SHELF)
assert isinstance(effect, EffectRandomTwoColorInterpolationConfig)
assert effect.hue1_random and effect.hue2_random
async def test_an_unknown_effect_turns_the_light_off_with_a_warning(
bus: EventBus, shelf: LightEntity, mouse: MusicMouseDevice, caplog: pytest.LogCaptureFixture
) -> None:
with caplog.at_level(logging.WARNING):
await shelf.handle(shelf.command_topic, command(state="ON", effect="disco"))
await bus.drain()
effect = mouse.effect(LedZone.SHELF)
assert isinstance(effect, EffectStaticDetailedConfig)
assert effect.color == BLACK
assert "Unknown effect 'disco'" in caplog.text
async def test_malformed_json_is_ignored(
bus: EventBus, shelf: LightEntity, mouse: MusicMouseDevice, caplog: pytest.LogCaptureFixture
) -> None:
with caplog.at_level(logging.WARNING):
await shelf.handle(shelf.command_topic, "not json at all")
await bus.drain()
assert mouse.effect(LedZone.SHELF) is None
assert "non-JSON command" in caplog.text
async def test_the_previous_colour_is_remembered_for_two_colour_effects(
bus: EventBus, shelf: LightEntity, mouse: MusicMouseDevice
) -> None:
await shelf.handle(
shelf.command_topic,
command(state="ON", color={"r": 255, "g": 0, "b": 0, "w": 0}, brightness=255),
)
await shelf.handle(
shelf.command_topic,
command(
state="ON",
color={"r": 0, "g": 0, "b": 255, "w": 0},
brightness=255,
effect="twocolor",
),
)
await bus.drain()
effect = mouse.effect(LedZone.SHELF)
assert isinstance(effect, EffectRandomTwoColorInterpolationConfig)
assert effect.color1 == ColorRGBW(0, 0, 1, 0)
assert effect.color2 == ColorRGBW(1, 0, 0, 0)
# ---------------------------------------------------------------- state reporting
async def test_state_is_published_after_a_command(
bus: EventBus, shelf: LightEntity, publisher: RecordingPublisher
) -> None:
await shelf.handle(shelf.command_topic, command(state="ON", effect="static"))
await bus.drain()
assert publisher.last_json(shelf.state_topic)["state"] == "ON"
async def test_a_local_effect_updates_what_home_assistant_sees(
bus: EventBus, shelf: LightEntity, mouse: MusicMouseDevice, publisher: RecordingPublisher
) -> None:
"""The whole point of publishing from LedEffectChanged rather than the command echo."""
await shelf.handle(shelf.command_topic, command(state="OFF"))
await bus.drain()
assert publisher.last_json(shelf.state_topic)["state"] == "OFF"
mouse.set_effect(
LedZone.SHELF, EffectCircularConfig(color=ColorRGBW(0, 1, 0, 0)), origin="device"
)
await bus.drain()
reported = publisher.last_json(shelf.state_topic)
assert reported["state"] == "ON"
assert reported["color"] == {"r": 0, "g": 255, "b": 0, "w": 0}
async def test_a_local_off_effect_is_reported_as_off(
bus: EventBus, shelf: LightEntity, mouse: MusicMouseDevice, publisher: RecordingPublisher
) -> None:
from musicmouse.effects import OFF
await shelf.handle(shelf.command_topic, command(state="ON", effect="static"))
await bus.drain()
mouse.set_effect(LedZone.SHELF, OFF(), origin="device")
await bus.drain()
assert publisher.last_json(shelf.state_topic)["state"] == "OFF"
async def test_another_zone_does_not_affect_this_entity(
bus: EventBus, shelf: LightEntity, mouse: MusicMouseDevice, publisher: RecordingPublisher
) -> None:
publisher.clear()
mouse.set_effect(LedZone.RING, EffectCircularConfig(), origin="device")
await bus.drain()
assert publisher.payloads_on(shelf.state_topic) == []
async def test_discovery_declares_a_json_rgbw_light_with_effects(shelf: LightEntity) -> None:
payload = shelf.discovery_payload()
assert payload["schema"] == "json"
assert payload["supported_color_modes"] == ["rgbw"]
assert payload["command_topic"] == shelf.command_topic
assert "top_0.5_inc4" in payload["effect_list"]
assert payload["device"]["identifiers"] == ["musicmouse"]
async def test_announce_publishes_retained_discovery_then_state(
shelf: LightEntity, publisher: RecordingPublisher
) -> None:
await shelf.announce()
topics = [topic for topic, _, _ in publisher.messages]
assert topics == [shelf.discovery_topic, shelf.state_topic]
assert publisher.messages[0][2] is True # discovery is retained
async def test_nothing_is_published_while_offline(shelf: LightEntity) -> None:
shelf.attach(None)
await shelf.announce() # must not raise
# ----------------------------------------------------------------------- player
async def test_volume_command_emits_an_intent(
bus: EventBus, mqtt_config: MqttConfig, player: FakePlayer
) -> None:
seen: list[Event] = []
bus.subscribe(SetVolumeRequested, seen.append)
entity = VolumeNumber(bus, mqtt_config, player)
await entity.handle(entity.command_topic, "37")
await bus.drain()
assert seen == [SetVolumeRequested(volume=37, source="mqtt")]
async def test_a_non_numeric_volume_is_ignored(
bus: EventBus, mqtt_config: MqttConfig, player: FakePlayer, caplog: pytest.LogCaptureFixture
) -> None:
seen: list[Event] = []
bus.subscribe(SetVolumeRequested, seen.append)
entity = VolumeNumber(bus, mqtt_config, player)
with caplog.at_level(logging.WARNING):
await entity.handle(entity.command_topic, "loud")
await bus.drain()
assert seen == []
assert "non-numeric volume" in caplog.text
async def test_volume_state_follows_the_player(
bus: EventBus, mqtt_config: MqttConfig, player: FakePlayer, publisher: RecordingPublisher
) -> None:
entity = VolumeNumber(bus, mqtt_config, player)
entity.attach(publisher)
player.set_volume(22)
await bus.drain()
assert publisher.payloads_on(entity.state_topic)[-1] == "22"
@pytest.mark.parametrize(
("action", "expected"),
[("next", NextTrackRequested), ("pause", PauseRequested)],
)
async def test_transport_buttons_emit_intents(
bus: EventBus, mqtt_config: MqttConfig, action: str, expected: type[Event]
) -> None:
seen: list[Event] = []
bus.subscribe(expected, seen.append)
entity = TransportButton(bus, mqtt_config, action, f"Music Mouse {action}")
await entity.handle(entity.command_topic, "PRESS")
await bus.drain()
assert len(seen) == 1
assert seen[0].source == "mqtt"
async def test_the_player_sensor_reports_the_current_track(
bus: EventBus, mqtt_config: MqttConfig, player: FakePlayer, publisher: RecordingPublisher
) -> None:
from pathlib import Path
from musicmouse.media import Playlist, Track
from musicmouse.services.mqtt.player import PlayerSensor
entity = PlayerSensor(bus, mqtt_config, player)
entity.attach(publisher)
player.set_playlist(Playlist(name="fuchs", tracks=(Track(Path("/m/01 - Song.mp3")),)))
player.play_from_start()
await bus.drain()
assert publisher.payloads_on(entity.state_topic)[-1] == "playing"
attributes = publisher.last_json(f"{entity.base_topic}/attributes")
assert attributes["playlist"] == "fuchs"
assert attributes["title"] == "01 - Song"
assert attributes["volume"] == 40
# --------------------------------------------------------------------- triggers
async def test_a_button_trigger_fires_only_for_its_own_event(
bus: EventBus, mqtt_config: MqttConfig, publisher: RecordingPublisher
) -> None:
trigger = ButtonTrigger(bus, mqtt_config, Button.ROTARY, ButtonAction.PRESSED)
trigger.attach(publisher)
bus.emit(ButtonEvent(button=Button.LEFT, action=ButtonAction.PRESSED, source="device"))
bus.emit(ButtonEvent(button=Button.ROTARY, action=ButtonAction.RELEASED, source="device"))
bus.emit(ButtonEvent(button=Button.ROTARY, action=ButtonAction.PRESSED, source="device"))
await bus.drain()
assert publisher.payloads_on(trigger.trigger_topic) == ["pressed"]
async def test_button_trigger_discovery(bus: EventBus, mqtt_config: MqttConfig) -> None:
trigger = ButtonTrigger(bus, mqtt_config, Button.ROTARY, ButtonAction.LONG_PRESSED)
payload = trigger.discovery_payload()
assert trigger.discovery_topic == (
"homeassistant/device_automation/musicmouse/rotary_long_pressed/config"
)
assert payload["automation_type"] == "trigger"
assert payload["type"] == "button_long_press"
assert payload["subtype"] == "rotary"
async def test_a_touch_trigger_fires(
bus: EventBus, mqtt_config: MqttConfig, publisher: RecordingPublisher
) -> None:
trigger = TouchTrigger(bus, mqtt_config, TouchButton.LEFT_EAR, pressed=True)
trigger.attach(publisher)
bus.emit(TouchButtonPressed(button=TouchButton.RIGHT_EAR, source="device"))
bus.emit(TouchButtonPressed(button=TouchButton.LEFT_EAR, source="device"))
await bus.drain()
assert publisher.payloads_on(trigger.trigger_topic) == ["left_ear"]
async def test_the_tag_scanner_publishes_every_read(
bus: EventBus, mqtt_config: MqttConfig, mouse: MusicMouseDevice, publisher: RecordingPublisher
) -> None:
scanner = TagScanner(bus, mqtt_config)
scanner.attach(publisher)
bus.emit(
RfidTokenRead(tag_id=bytes.fromhex("04a1b2c3d4"), figure="fuchs", source="device")
)
await bus.drain()
assert json.loads(publisher.payloads_on(scanner.scan_topic)[-1]) == {
"tag_id": "04a1b2c3d4",
"figure": "fuchs",
"known": True,
}
async def test_tag_scanner_discovery(bus: EventBus, mqtt_config: MqttConfig) -> None:
scanner = TagScanner(bus, mqtt_config)
assert scanner.discovery_topic == "homeassistant/tag/musicmouse/config"
assert scanner.discovery_payload()["value_template"] == "{{ value_json.tag_id }}"
# ---------------------------------------------------------------------- wiring
def test_every_entity_has_a_unique_discovery_topic(
bus: EventBus, mqtt_config: MqttConfig, mouse: MusicMouseDevice, player: FakePlayer
) -> None:
entities = build_entities(bus, mqtt_config, mouse, player)
topics = [entity.discovery_topic for entity in entities]
assert len(topics) == len(set(topics))
assert len(entities) > 20
def test_command_topics_do_not_collide(
bus: EventBus, mqtt_config: MqttConfig, mouse: MusicMouseDevice, player: FakePlayer
) -> None:
entities = build_entities(bus, mqtt_config, mouse, player)
topics = [topic for entity in entities for topic in entity.command_topics()]
assert len(topics) == len(set(topics))
def test_all_three_led_zones_are_exposed(
bus: EventBus, mqtt_config: MqttConfig, mouse: MusicMouseDevice, player: FakePlayer
) -> None:
entities = build_entities(bus, mqtt_config, mouse, player)
lights = [e for e in entities if isinstance(e, LightEntity)]
assert {light.zone for light in lights} == set(LedZone)

View File

@@ -0,0 +1,297 @@
"""Tests for the shared player behaviour, exercised through FakePlayer.
VlcPlayer adds only the libVLC bindings on top of PlayerBase; it needs a real audio
device and is covered by the on-device checklist, not here.
"""
from __future__ import annotations
from collections.abc import AsyncIterator
from pathlib import Path
import pytest
from musicmouse.bus import EventBus
from musicmouse.clock import FakeClock
from musicmouse.devices.player import Player, VlcPlayer
from musicmouse.events import (
Event,
PlaybackChanged,
PlaylistFinished,
TrackChanged,
VolumeChanged,
)
from musicmouse.media import Playlist, Track
from musicmouse.simulator.fake_player import FakePlayer
def playlist(name: str = "fuchs", count: int = 3) -> Playlist:
tracks = tuple(Track(Path(f"/music/{name}/{i}.mp3")) for i in range(count))
return Playlist(name=name, tracks=tracks)
@pytest.fixture
async def bus() -> AsyncIterator[EventBus]:
async with EventBus() as running:
yield running
@pytest.fixture
def clock(bus: EventBus) -> FakeClock:
return FakeClock(idle=bus.drain)
@pytest.fixture
def player(bus: EventBus, clock: FakeClock) -> FakePlayer:
return FakePlayer(bus, clock=clock, track_duration=10.0, initial_volume=50)
@pytest.fixture
def seen(bus: EventBus) -> list[Event]:
events: list[Event] = []
bus.subscribe_all(events.append)
return events
def only[T: Event](events: list[Event], event_type: type[T]) -> list[T]:
return [e for e in events if isinstance(e, event_type)]
# ------------------------------------------------------------------------ volume
async def test_volume_starts_at_the_configured_value(player: FakePlayer) -> None:
assert player.volume == 50
async def test_setting_volume_announces_it(
bus: EventBus, player: FakePlayer, seen: list[Event]
) -> None:
player.set_volume(30, source="mqtt")
await bus.drain()
assert player.volume == 30
assert only(seen, VolumeChanged) == [VolumeChanged(volume=30, source="mqtt")]
async def test_setting_the_same_volume_is_not_announced(
bus: EventBus, player: FakePlayer, seen: list[Event]
) -> None:
player.set_volume(50)
await bus.drain()
assert only(seen, VolumeChanged) == []
async def test_change_volume_is_relative(bus: EventBus, player: FakePlayer) -> None:
player.change_volume(-20)
await bus.drain()
assert player.volume == 30
async def test_volume_is_clamped_to_the_configured_range(bus: EventBus) -> None:
limited = FakePlayer(bus, min_volume=20, max_volume=60, initial_volume=40)
limited.set_volume(100)
assert limited.volume == 60
limited.set_volume(0)
assert limited.volume == 20
async def test_min_volume_of_zero_is_honoured(bus: EventBus) -> None:
"""Regression: `if self.volume_min and ...` treated a configured 0 as unset."""
limited = FakePlayer(bus, min_volume=0, max_volume=100, initial_volume=10)
limited.set_volume(-5)
assert limited.volume == 0
# --------------------------------------------------------------------- playback
async def test_play_from_start_starts_the_first_track(
bus: EventBus, player: FakePlayer, seen: list[Event]
) -> None:
player.set_playlist(playlist())
player.play_from_start()
await bus.drain()
assert player.is_playing
assert player.track_index == 0
assert player.current_track is not None
assert player.current_track.title == "0"
assert only(seen, PlaybackChanged)[-1].playing is True
async def test_playing_an_empty_playlist_does_nothing(
bus: EventBus, player: FakePlayer, caplog: pytest.LogCaptureFixture
) -> None:
player.set_playlist(Playlist(name="leer", tracks=()))
player.play_from_start()
await bus.drain()
assert not player.is_playing
assert "playlist is empty" in caplog.text
async def test_pause_and_resume(bus: EventBus, player: FakePlayer) -> None:
player.set_playlist(playlist())
player.play_from_start()
player.pause()
await bus.drain()
assert not player.is_playing
player.play()
await bus.drain()
assert player.is_playing
async def test_tracks_advance_as_time_passes(
bus: EventBus, player: FakePlayer, clock: FakeClock, seen: list[Event]
) -> None:
player.set_playlist(playlist(count=3))
player.play_from_start()
await bus.drain()
await clock.advance(10.0)
assert player.track_index == 1
await clock.advance(10.0)
assert player.track_index == 2
assert [e.index for e in only(seen, TrackChanged)] == [1, 2]
async def test_playlist_end_stops_playback_and_is_announced(
bus: EventBus, player: FakePlayer, clock: FakeClock, seen: list[Event]
) -> None:
player.set_playlist(playlist(count=2))
player.play_from_start()
await clock.advance(25.0)
assert only(seen, PlaylistFinished) == [PlaylistFinished(source="player")]
assert not player.is_playing
async def test_a_paused_player_does_not_advance(
bus: EventBus, player: FakePlayer, clock: FakeClock
) -> None:
player.set_playlist(playlist())
player.play_from_start()
await bus.drain()
player.pause()
await clock.advance(100.0)
assert player.track_index == 0
assert not player.is_playing
async def test_resuming_continues_the_remainder_of_the_track(
bus: EventBus, player: FakePlayer, clock: FakeClock
) -> None:
player.set_playlist(playlist())
player.play_from_start()
await clock.advance(7.0)
player.pause()
await clock.advance(100.0)
player.play()
await clock.advance(2.0)
assert player.track_index == 0 # 3s of the track were still left
await clock.advance(2.0)
assert player.track_index == 1
async def test_next_and_previous(bus: EventBus, player: FakePlayer) -> None:
player.set_playlist(playlist(count=3))
player.play_from_start()
await bus.drain()
player.next_track()
player.next_track()
await bus.drain()
assert player.track_index == 2
player.previous_track()
await bus.drain()
assert player.track_index == 1
async def test_previous_on_the_first_track_stays_there(bus: EventBus, player: FakePlayer) -> None:
player.set_playlist(playlist())
player.play_from_start()
player.previous_track()
await bus.drain()
assert player.track_index == 0
async def test_next_past_the_last_track_ends_the_playlist(
bus: EventBus, player: FakePlayer, seen: list[Event]
) -> None:
player.set_playlist(playlist(count=2))
player.play_from_start()
player.next_track()
player.next_track()
await bus.drain()
assert only(seen, PlaylistFinished) == [PlaylistFinished(source="player")]
assert not player.is_playing
async def test_skipping_restarts_the_track_timer(
bus: EventBus, player: FakePlayer, clock: FakeClock
) -> None:
player.set_playlist(playlist(count=3))
player.play_from_start()
await clock.advance(9.0)
player.next_track()
await bus.drain()
await clock.advance(9.0)
assert player.track_index == 1 # a fresh 10s, not the 1s left over
await clock.advance(2.0)
assert player.track_index == 2
async def test_stop_resets_playback(bus: EventBus, player: FakePlayer, clock: FakeClock) -> None:
player.set_playlist(playlist())
player.play_from_start()
player.stop()
await bus.drain()
assert not player.is_playing
await clock.advance(100.0)
assert player.track_index == 0
async def test_setting_a_new_playlist_resets_the_index(bus: EventBus, player: FakePlayer) -> None:
player.set_playlist(playlist(count=3))
player.play_from_start()
player.next_track()
await bus.drain()
player.set_playlist(playlist(name="eule", count=2))
assert player.track_index == 0
assert player.playlist is not None
assert player.playlist.name == "eule"
async def test_current_track_is_none_without_a_playlist(player: FakePlayer) -> None:
assert player.current_track is None
assert player.playlist is None
def test_fake_player_satisfies_the_player_protocol(player: FakePlayer) -> None:
check: Player = player
assert check.volume == player.volume
def _vlc_player_satisfies_the_player_protocol(real: VlcPlayer) -> Player:
"""Checked by mypy, not at runtime: VlcPlayer needs libVLC to instantiate."""
return real

View File

@@ -0,0 +1,369 @@
"""The behaviour of the mouse, driven end to end through the simulator.
Everything between the tag being read and the LED bytes being written is production
code here - only the serial link and VLC are substituted.
"""
from __future__ import annotations
from collections.abc import AsyncIterator
from pathlib import Path
import pytest
from musicmouse.config import load_config
from musicmouse.effects import (
EffectRandomTwoColorInterpolationConfig,
EffectReverseSwipe,
EffectStaticConfig,
EffectSwipeAndChange,
)
from musicmouse.events import Event, LedEffectChanged, VolumeChanged
from musicmouse.hardware import MOUSE_LED_RANGES, LedZone, TouchButton
from musicmouse.simulator.driver import SimulatorDriver
from musicmouse.simulator.harness import Simulation, build_simulation
from tests.conftest import VALID_CONFIG, write_config
TRACK_SECONDS = 10.0
@pytest.fixture
async def sim(config_dir: Path) -> AsyncIterator[Simulation]:
config = load_config(write_config(config_dir, VALID_CONFIG))
simulation = await build_simulation(config, track_duration=TRACK_SECONDS)
try:
yield simulation
finally:
await simulation.aclose()
@pytest.fixture
def mouse(sim: Simulation) -> SimulatorDriver:
return sim.driver
@pytest.fixture
def seen(sim: Simulation) -> list[Event]:
events: list[Event] = []
sim.bus.subscribe_all(events.append)
return events
def only[T: Event](events: list[Event], event_type: type[T]) -> list[T]:
return [e for e in events if isinstance(e, event_type)]
# ------------------------------------------------------------------ figure on/off
async def test_placing_a_figure_starts_its_playlist(mouse: SimulatorDriver) -> None:
await mouse.place("fuchs")
mouse.check("figure", "fuchs")
mouse.check("playing", "true")
mouse.check("playlist", "fuchs")
mouse.check("track", "0")
async def test_placing_a_figure_lights_the_leds(
mouse: SimulatorDriver, sim: Simulation
) -> None:
await mouse.place("fuchs")
for zone in LedZone:
assert isinstance(sim.app.mouse.effect(zone), EffectSwipeAndChange)
assert sim.app.mouse.button_led_brightness == pytest.approx(0.5)
async def test_the_figures_colours_are_used(mouse: SimulatorDriver, sim: Simulation) -> None:
await mouse.place("fuchs")
effect = sim.app.mouse.effect(LedZone.RING)
assert isinstance(effect, EffectSwipeAndChange)
assert effect.swipe.primary_color == sim.app.colors("fuchs").primary
assert effect.swipe.secondary_color == sim.app.colors("fuchs").secondary
async def test_the_mouse_swipe_is_offset_from_the_ring(
mouse: SimulatorDriver, sim: Simulation
) -> None:
await mouse.place("fuchs")
ring = sim.app.mouse.effect(LedZone.RING)
body = sim.app.mouse.effect(LedZone.MOUSE)
assert isinstance(ring, EffectSwipeAndChange)
assert isinstance(body, EffectSwipeAndChange)
assert body.swipe.start_position != ring.swipe.start_position
async def test_removing_a_figure_pauses_and_runs_the_off_animation(
mouse: SimulatorDriver, sim: Simulation
) -> None:
await mouse.place("fuchs")
await mouse.remove()
mouse.check("figure", "none")
mouse.check("playing", "false")
assert isinstance(sim.app.mouse.effect(LedZone.RING), EffectReverseSwipe)
assert sim.app.mouse.button_led_brightness == pytest.approx(0.0)
async def test_putting_the_same_figure_back_resumes_where_it_left_off(
mouse: SimulatorDriver,
) -> None:
await mouse.place("fuchs")
await mouse.wait(TRACK_SECONDS + 1)
mouse.check("track", "1")
await mouse.remove()
await mouse.place("fuchs")
mouse.check("playing", "true")
mouse.check("track", "1")
async def test_a_different_figure_starts_from_the_top(mouse: SimulatorDriver) -> None:
await mouse.place("fuchs")
await mouse.wait(TRACK_SECONDS + 1)
await mouse.remove()
await mouse.place("eule")
mouse.check("playlist", "eule")
mouse.check("track", "0")
async def test_swapping_figures_without_removing_first(mouse: SimulatorDriver) -> None:
await mouse.place("fuchs")
await mouse.place("eule")
mouse.check("figure", "eule")
mouse.check("playlist", "eule")
mouse.check("playing", "true")
async def test_replacing_a_figure_after_its_playlist_finished_starts_over(
mouse: SimulatorDriver,
) -> None:
await mouse.place("fuchs")
await mouse.wait(TRACK_SECONDS * 5) # fuchs has 3 tracks
mouse.check("playing", "false")
await mouse.remove()
await mouse.place("fuchs")
mouse.check("track", "0")
mouse.check("playing", "true")
async def test_an_unknown_tag_changes_nothing(mouse: SimulatorDriver) -> None:
await mouse.place("fuchs")
await mouse.tag(bytes.fromhex("0999999999"))
mouse.check("figure", "fuchs")
mouse.check("playing", "true")
# ------------------------------------------------------------------- playlist end
async def test_the_playlist_ending_runs_the_off_animation(
mouse: SimulatorDriver, sim: Simulation
) -> None:
await mouse.place("eule") # two tracks
await mouse.wait(TRACK_SECONDS * 2 + 1)
mouse.check("playing", "false")
assert isinstance(sim.app.mouse.effect(LedZone.RING), EffectReverseSwipe)
# ----------------------------------------------------------------------- buttons
async def test_the_right_button_skips_forward(mouse: SimulatorDriver) -> None:
await mouse.place("fuchs")
await mouse.press("right")
mouse.check("track", "1")
async def test_the_left_button_skips_back(mouse: SimulatorDriver) -> None:
await mouse.place("fuchs")
await mouse.press("right")
await mouse.press("left")
mouse.check("track", "0")
async def test_buttons_do_nothing_while_paused(mouse: SimulatorDriver) -> None:
await mouse.place("fuchs")
await mouse.remove()
await mouse.press("right")
mouse.check("track", "0")
async def test_only_the_press_acts_not_the_release(mouse: SimulatorDriver) -> None:
await mouse.place("fuchs")
await mouse.press("right", "released")
await mouse.press("right", "clicked")
mouse.check("track", "0")
async def test_the_rotary_press_does_not_touch_playback(mouse: SimulatorDriver) -> None:
"""It is published to Home Assistant instead; the backend has no opinion."""
await mouse.place("fuchs")
await mouse.press("rotary")
mouse.check("playing", "true")
mouse.check("track", "0")
# ------------------------------------------------------------------------ volume
async def test_turning_the_encoder_up_raises_the_volume(
mouse: SimulatorDriver, seen: list[Event]
) -> None:
await mouse.turn(1)
mouse.check("volume", "45") # 40 initial + 5 increment
assert only(seen, VolumeChanged)[-1].volume == 45
async def test_turning_the_encoder_down_lowers_the_volume(mouse: SimulatorDriver) -> None:
await mouse.turn(-1)
mouse.check("volume", "35")
async def test_several_clicks_in_one_event_scale_the_step(mouse: SimulatorDriver) -> None:
await mouse.turn(3)
mouse.check("volume", "55")
async def test_volume_stops_at_the_configured_maximum(mouse: SimulatorDriver) -> None:
for _ in range(20):
await mouse.turn(1)
mouse.check("volume", "60")
async def test_volume_stops_at_the_configured_minimum(mouse: SimulatorDriver) -> None:
for _ in range(20):
await mouse.turn(-1)
mouse.check("volume", "0")
async def test_setting_the_volume_directly(mouse: SimulatorDriver) -> None:
await mouse.set_volume(25)
mouse.check("volume", "25")
# ------------------------------------------------------------------ touch buttons
@pytest.mark.parametrize("button", list(TouchButton))
async def test_touching_lights_that_body_part_in_the_accent_colour(
mouse: SimulatorDriver, sim: Simulation, button: TouchButton
) -> None:
await mouse.place("fuchs")
await mouse.touch(button.slug)
effect = sim.app.mouse.effect(LedZone.MOUSE)
assert isinstance(effect, EffectStaticConfig)
assert effect.color == sim.app.colors("fuchs").accent
assert (effect.begin, effect.end) == MOUSE_LED_RANGES[button]
async def test_releasing_restores_the_body_effect(
mouse: SimulatorDriver, sim: Simulation
) -> None:
await mouse.place("fuchs")
await mouse.touch("left_ear")
await mouse.release("left_ear")
assert isinstance(sim.app.mouse.effect(LedZone.MOUSE), EffectRandomTwoColorInterpolationConfig)
async def test_releasing_clears_the_area_before_restoring(
mouse: SimulatorDriver, sim: Simulation
) -> None:
await mouse.place("fuchs")
await mouse.touch("left_ear")
sim.transport.clear()
await mouse.release("left_ear")
written = sim.transport.effects_for(LedZone.MOUSE)
assert isinstance(written[0], EffectStaticConfig)
assert written[0].color == sim.app.colors("fuchs").primary
assert isinstance(written[1], EffectRandomTwoColorInterpolationConfig)
async def test_touching_with_no_figure_does_nothing(
mouse: SimulatorDriver, sim: Simulation
) -> None:
await mouse.touch("left_ear")
assert sim.app.mouse.effect(LedZone.MOUSE) is None
async def test_touching_while_paused_does_nothing(
mouse: SimulatorDriver, sim: Simulation
) -> None:
await mouse.place("fuchs")
await mouse.remove()
sim.transport.clear()
await mouse.touch("left_ear")
assert sim.transport.effects_for(LedZone.MOUSE) == []
# ------------------------------------------------------- light arbitration & reconnect
async def test_an_mqtt_command_wins_until_the_next_figure_animation(
mouse: SimulatorDriver, sim: Simulation
) -> None:
"""Last write wins, whichever side it came from."""
from musicmouse.color import ColorRGBW
from_mqtt = EffectStaticConfig(ColorRGBW(0, 0, 1, 0))
sim.app.mouse.set_effect(LedZone.SHELF, from_mqtt, origin="mqtt")
await mouse.settle()
assert sim.app.mouse.effect(LedZone.SHELF) == from_mqtt
await mouse.place("fuchs")
assert isinstance(sim.app.mouse.effect(LedZone.SHELF), EffectSwipeAndChange)
sim.app.mouse.set_effect(LedZone.SHELF, from_mqtt, origin="mqtt")
await mouse.settle()
assert sim.app.mouse.effect(LedZone.SHELF) == from_mqtt
async def test_every_led_write_is_announced_with_its_origin(
mouse: SimulatorDriver, seen: list[Event]
) -> None:
await mouse.place("fuchs")
origins = {e.origin for e in only(seen, LedEffectChanged)}
assert origins == {"device"}
assert {e.zone for e in only(seen, LedEffectChanged)} == set(LedZone)
async def test_reconnecting_restores_the_leds(
mouse: SimulatorDriver, sim: Simulation
) -> None:
await mouse.place("fuchs")
before = sim.app.mouse.effect(LedZone.RING)
assert before is not None
await mouse.disconnect()
sim.transport.clear()
await mouse.reconnect()
restored = sim.transport.effect(LedZone.RING)
assert restored is not None
# Compared as bytes: two-colour effects travel as HSV, so the decoded object holds
# equivalent-but-not-equal colours.
assert restored.as_bytes() == before.as_bytes()
assert sim.transport.brightness() == pytest.approx(0.5)

View File

@@ -0,0 +1,104 @@
"""Run every file in ``scenarios/`` as a test.
Same driver, same verbs, same code path as the interactive simulator - only the clock
differs, so a scenario that takes half a minute by hand runs in microseconds here.
"""
from __future__ import annotations
from collections.abc import AsyncIterator
from pathlib import Path
import pytest
from musicmouse.config import load_config
from musicmouse.simulator.driver import ExpectationError, ScriptError
from musicmouse.simulator.harness import Simulation, build_simulation
from musicmouse.simulator.script import run_script_file
from tests.conftest import VALID_CONFIG, write_config
SCENARIO_DIR = Path(__file__).resolve().parents[1] / "scenarios"
SCENARIOS = sorted(SCENARIO_DIR.glob("*.txt"))
@pytest.fixture
async def sim(config_dir: Path) -> AsyncIterator[Simulation]:
config = load_config(write_config(config_dir, VALID_CONFIG))
simulation = await build_simulation(config)
try:
yield simulation
finally:
await simulation.aclose()
def test_scenario_files_exist() -> None:
assert SCENARIOS, f"no scenario files found in {SCENARIO_DIR}"
@pytest.mark.parametrize("scenario", SCENARIOS, ids=lambda p: p.stem)
async def test_scenario(sim: Simulation, scenario: Path) -> None:
await run_script_file(sim, scenario)
# --------------------------------------------------------------- the script parser
async def test_comments_and_blank_lines_are_ignored(sim: Simulation) -> None:
await sim.driver.run_script("# just a comment\n\n \nplace fuchs # trailing\n")
sim.driver.check("figure", "fuchs")
async def test_a_failing_expectation_names_the_line(sim: Simulation) -> None:
with pytest.raises(ExpectationError) as excinfo:
await sim.driver.run_script("place fuchs\nexpect track 7\n")
message = str(excinfo.value)
assert "line 2" in message
assert "expected track to be '7', but it is '0'" in message
async def test_an_unknown_verb_is_reported_with_its_line(sim: Simulation) -> None:
with pytest.raises(ScriptError, match="line 1"):
await sim.driver.run_script("frobnicate the widget\n")
async def test_an_unknown_figure_lists_the_configured_ones(sim: Simulation) -> None:
with pytest.raises(ScriptError, match="configured: eule, fuchs"):
await sim.driver.run_script("place giraffe\n")
async def test_an_unknown_property_lists_the_valid_ones(sim: Simulation) -> None:
with pytest.raises(ScriptError, match="unknown property"):
await sim.driver.run_script("expect loudness 5\n")
async def test_an_unknown_touch_button_lists_the_valid_ones(sim: Simulation) -> None:
with pytest.raises(ScriptError, match="left_foot, right_foot"):
await sim.driver.run_script("touch nose\n")
@pytest.mark.parametrize(
("text", "seconds"),
[("2", 2.0), ("2s", 2.0), ("500ms", 0.5), ("1m", 60.0), ("0.5s", 0.5)],
)
async def test_duration_formats(text: str, seconds: float) -> None:
from musicmouse.simulator.driver import Duration
assert Duration.parse(text).seconds == pytest.approx(seconds)
async def test_a_bad_duration_is_reported() -> None:
from musicmouse.simulator.driver import Duration
with pytest.raises(ScriptError, match="is not a duration"):
Duration.parse("soon")
async def test_status_and_leds_produce_output(sim: Simulation) -> None:
await sim.driver.place("fuchs")
status = await sim.driver.execute("status")
leds = await sim.driver.execute("leds")
assert status is not None and "fuchs" in status and "playing" in status
assert leds is not None and "ring" in leds and "shelf" in leds

View File

@@ -0,0 +1,299 @@
from __future__ import annotations
import re
import struct
from pathlib import Path
import pytest
from musicmouse.color import ColorRGBW
from musicmouse.devices.wire import (
MAGIC_FW_TO_HOST,
FirmwareLog,
FrameDecoder,
MessageFwToHost,
MessageHostToFw,
ProtocolError,
UnsupportedEffectError,
encode_button_brightness,
encode_effect,
encode_input_event,
)
from musicmouse.effects import (
EffectAlexaSwipeConfig,
EffectStaticConfig,
EffectStaticDetailedConfig,
)
from musicmouse.events import (
ButtonEvent,
InputEvent,
RfidTokenRead,
RotaryTurned,
TouchButtonPressed,
TouchButtonReleased,
)
from musicmouse.hardware import Button, ButtonAction, LedZone, RotaryDirection, TouchButton
MESSAGES_H = Path(__file__).resolve().parents[2] / "esp-firmware" / "src" / "Messages.h"
def frame(msg_type: int, payload: bytes) -> bytes:
return struct.pack("<IBH", MAGIC_FW_TO_HOST, msg_type, len(payload)) + payload
# ------------------------------------------------------------ firmware contract
def _parse_cpp_enum(source: str, name: str) -> dict[str, int]:
body = re.search(rf"enum class {name}\s*:\s*uint8_t\s*\{{(.*?)\}}", source, re.DOTALL)
assert body is not None, f"{name} not found in Messages.h"
return {
match["name"]: int(match["value"])
for match in re.finditer(r"(?P<name>[A-Z_0-9]+)\s*=\s*(?P<value>\d+)", body.group(1))
}
@pytest.mark.skipif(not MESSAGES_H.exists(), reason="firmware sources not available")
@pytest.mark.parametrize("enum", [MessageFwToHost, MessageHostToFw])
def test_message_ids_match_the_firmware(enum: type[MessageFwToHost | MessageHostToFw]) -> None:
"""The contract is hand-duplicated in two languages; this is what catches drift.
A missing member here is exactly the bug the old host_driver.py shipped with:
its MessageFwToHost enum never had BUTTON_EVENT = 4.
"""
source = MESSAGES_H.read_text(encoding="utf-8", errors="replace")
assert _parse_cpp_enum(source, enum.__name__) == {m.name: m.value for m in enum}
@pytest.mark.skipif(not MESSAGES_H.exists(), reason="firmware sources not available")
def test_magic_tokens_match_the_firmware() -> None:
source = MESSAGES_H.read_text(encoding="utf-8", errors="replace")
found = dict(re.findall(r"MAGIC_TOKEN_(\w+)\s*=\s*(0x[0-9a-fA-F]+)", source))
assert int(found["FW_TO_HOST"], 16) == MAGIC_FW_TO_HOST
# ---------------------------------------------------------------------- encoding
def test_effect_frame_has_magic_type_and_length() -> None:
effect = EffectStaticConfig(ColorRGBW(1, 0, 0, 0))
encoded = encode_effect(LedZone.RING, effect)
magic, msg_type, size = struct.unpack("<IBH", encoded[:7])
assert magic == 0x1D6379E3
assert msg_type == MessageHostToFw.LED_WHEEL_EFFECT_STATIC
assert size == len(effect.as_bytes())
assert encoded[7:] == effect.as_bytes()
@pytest.mark.parametrize(
("zone", "expected"),
[
(LedZone.RING, MessageHostToFw.LED_WHEEL_EFFECT_STATIC),
(LedZone.MOUSE, MessageHostToFw.MOUSE_LED_EFFECT_STATIC),
(LedZone.SHELF, MessageHostToFw.SHELF_LED_EFFECT_STATIC),
],
)
def test_the_same_effect_gets_a_different_id_per_zone(
zone: LedZone, expected: MessageHostToFw
) -> None:
encoded = encode_effect(zone, EffectStaticConfig(ColorRGBW(0, 0, 0, 0)))
assert encoded[4] == expected
def test_alexa_swipe_is_only_supported_on_the_ring() -> None:
encode_effect(LedZone.RING, EffectAlexaSwipeConfig())
with pytest.raises(UnsupportedEffectError, match="mouse LEDs"):
encode_effect(LedZone.MOUSE, EffectAlexaSwipeConfig())
def test_static_detailed_is_only_supported_on_the_shelf() -> None:
encode_effect(LedZone.SHELF, EffectStaticDetailedConfig(ColorRGBW(0, 0, 0, 0)))
with pytest.raises(UnsupportedEffectError, match="supported there"):
encode_effect(LedZone.RING, EffectStaticDetailedConfig(ColorRGBW(0, 0, 0, 0)))
@pytest.mark.parametrize(
("button", "message"),
[
(Button.LEFT, MessageHostToFw.PREV_BUTTON_LED),
(Button.RIGHT, MessageHostToFw.NEXT_BUTTON_LED),
],
)
def test_button_brightness(button: Button, message: MessageHostToFw) -> None:
encoded = encode_button_brightness(button, 0.5)
assert encoded[4] == message
assert struct.unpack("<f", encoded[7:]) == (0.5,)
def test_button_brightness_range_is_checked() -> None:
with pytest.raises(ValueError, match=r"within 0\.\.1"):
encode_button_brightness(Button.LEFT, 1.5)
def test_rotary_button_has_no_backlight() -> None:
with pytest.raises(ValueError, match="no backlight"):
encode_button_brightness(Button.ROTARY, 0.5)
# ---------------------------------------------------------------------- decoding
def collect(decoder: FrameDecoder, data: bytes) -> list[object]:
"""Push ``data`` and drain everything the decoder can produce."""
decoder.push(data)
items: list[object] = []
while (item := decoder.take()) is not None:
items.append(item)
return items
def decode_one(data: bytes) -> object:
items = collect(FrameDecoder(), data)
assert len(items) == 1, f"expected exactly one item, got {items}"
return items[0]
def test_decode_rfid() -> None:
event = decode_one(frame(MessageFwToHost.RFID_TOKEN_READ, bytes.fromhex("04a1b2c3d4")))
assert event == RfidTokenRead(tag_id=bytes.fromhex("04a1b2c3d4"), source="device")
def test_decode_rotary() -> None:
payload = struct.pack("<iiB", -17, 2, RotaryDirection.UP)
event = decode_one(frame(MessageFwToHost.ROTARY_ENCODER, payload))
assert event == RotaryTurned(
position=-17, increment=2, direction=RotaryDirection.UP, source="device"
)
def test_decode_touch_press_and_release() -> None:
pressed = decode_one(frame(MessageFwToHost.TOUCH_BUTTON_PRESS, bytes([TouchButton.LEFT_EAR])))
released = decode_one(
frame(MessageFwToHost.TOUCH_BUTTON_RELEASE, bytes([TouchButton.RIGHT_FOOT]))
)
assert pressed == TouchButtonPressed(button=TouchButton.LEFT_EAR, source="device")
assert released == TouchButtonReleased(button=TouchButton.RIGHT_FOOT, source="device")
def test_decode_button_event() -> None:
payload = struct.pack("<BB", Button.ROTARY, ButtonAction.DOUBLE_CLICKED)
event = decode_one(frame(MessageFwToHost.BUTTON_EVENT, payload))
assert event == ButtonEvent(
button=Button.ROTARY, action=ButtonAction.DOUBLE_CLICKED, source="device"
)
def test_two_frames_in_one_chunk_are_both_decoded() -> None:
"""Regression: the old parser handled at most one frame per read."""
data = frame(MessageFwToHost.TOUCH_BUTTON_PRESS, b"\x00") + frame(
MessageFwToHost.TOUCH_BUTTON_RELEASE, b"\x00"
)
items = collect(FrameDecoder(), data)
assert [type(item) for item in items] == [TouchButtonPressed, TouchButtonReleased]
def test_a_frame_split_across_chunks_is_reassembled() -> None:
data = frame(MessageFwToHost.RFID_TOKEN_READ, bytes.fromhex("04a1b2c3d4"))
decoder = FrameDecoder()
for index in range(len(data) - 1):
assert collect(decoder, data[index : index + 1]) == []
items = collect(decoder, data[-1:])
assert items == [RfidTokenRead(tag_id=bytes.fromhex("04a1b2c3d4"), source="device")]
def test_log_text_is_yielded_separately() -> None:
assert decode_one(b"RFID reader ready\n") == FirmwareLog("RFID reader ready")
def test_log_text_interleaved_with_frames() -> None:
data = (
b"booting\n"
+ frame(MessageFwToHost.TOUCH_BUTTON_PRESS, b"\x02")
+ b"touched\r\n"
+ frame(MessageFwToHost.TOUCH_BUTTON_RELEASE, b"\x02")
)
items = collect(FrameDecoder(), data)
assert items == [
FirmwareLog("booting"),
TouchButtonPressed(button=TouchButton.LEFT_EAR, source="device"),
FirmwareLog("touched"),
TouchButtonReleased(button=TouchButton.LEFT_EAR, source="device"),
]
def test_unterminated_log_text_resyncs_on_the_next_frame() -> None:
data = b"half a line" + frame(MessageFwToHost.TOUCH_BUTTON_PRESS, b"\x01")
items = collect(FrameDecoder(), data)
assert items == [
FirmwareLog("half a line"),
TouchButtonPressed(button=TouchButton.RIGHT_FOOT, source="device"),
]
def test_partial_log_text_is_held_until_more_arrives() -> None:
decoder = FrameDecoder()
assert collect(decoder, b"partial") == []
assert collect(decoder, b" line\n") == [FirmwareLog("partial line")]
def test_unknown_message_type_raises_but_leaves_the_decoder_usable() -> None:
decoder = FrameDecoder()
data = frame(99, b"\x00") + frame(MessageFwToHost.TOUCH_BUTTON_PRESS, b"\x01")
decoder.push(data)
with pytest.raises(ProtocolError, match="unknown message type 99"):
decoder.take()
assert collect(decoder, b"") == [
TouchButtonPressed(button=TouchButton.RIGHT_FOOT, source="device")
]
def test_out_of_range_enum_value_raises() -> None:
decoder = FrameDecoder()
decoder.push(frame(MessageFwToHost.TOUCH_BUTTON_PRESS, b"\x09"))
with pytest.raises(ProtocolError, match="bad value"):
decoder.take()
def test_wrong_length_rfid_payload_raises() -> None:
decoder = FrameDecoder()
decoder.push(frame(MessageFwToHost.RFID_TOKEN_READ, b"\x01\x02"))
with pytest.raises(ProtocolError, match="must be 5 bytes"):
decoder.take()
def test_garbage_does_not_grow_the_buffer_without_bound() -> None:
decoder = FrameDecoder()
for _ in range(100):
collect(decoder, b"\x00" * 1000)
assert decoder.buffered < 8192 + 1000
# --------------------------------------------------------------------- round trip
@pytest.mark.parametrize(
"event",
[
RfidTokenRead(tag_id=bytes.fromhex("04a1b2c3d4"), source="device"),
RotaryTurned(position=5, increment=-1, direction=RotaryDirection.DOWN, source="device"),
TouchButtonPressed(button=TouchButton.RIGHT_EAR, source="device"),
TouchButtonReleased(button=TouchButton.LEFT_FOOT, source="device"),
ButtonEvent(button=Button.LEFT, action=ButtonAction.LONG_PRESSED, source="device"),
],
)
def test_encode_decode_round_trip(event: InputEvent) -> None:
"""The simulator injects events through this path, so it exercises the real codec."""
assert decode_one(encode_input_event(event)) == event
def test_encode_input_event_rejects_non_firmware_events() -> None:
from musicmouse.events import PlaylistFinished
with pytest.raises(ValueError, match="not a firmware message"):
encode_input_event(PlaylistFinished())