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(" 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"), ]