from __future__ import annotations import logging from collections.abc import AsyncIterator from typing import TypeVar 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 T = TypeVar("T", bound=Event) def only(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(" 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"), ]