Requiring 3.13 meant the device needed an interpreter the distribution does not
have, which is what dragged uv in, and uv then had to be matched to the Pi's
32-bit userland by hand and to build Pillow from source because no armv7 wheel
exists for a 3.13 ABI. Dropping to 3.11 removes all of that: apt provides the
interpreter and piwheels has prebuilt armhf wheels for the native dependencies.
The 3.13-only syntax was shallow - PEP 695 throughout, which converts back
mechanically:
type X = Y -> X: TypeAlias = Y
type Handler[E: Event] -> E = TypeVar("E", bound=Event) plus a plain alias,
which is generic anyway because it carries a TypeVar
def f[T: Bound](...) -> a module-level TypeVar
Also drop the one @override (3.12, and static-only), and stop the lirc test
harness calling Server.close_clients(), which is 3.13: the scripted handler now
releases its connection when asked, which is what that call was there to force.
Verified on 3.11.14 - 485 passed, mypy strict clean - and still 496 passed on
the 3.14 dev venv, which additionally has the analysis extra.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
338 lines
10 KiB
Python
338 lines
10 KiB
Python
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("<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"),
|
|
]
|