Files
musicmouse/python-backend/tests/test_mouse.py
Martin Bauer d465a74cde Figures: several albums per figure, picked on the LED ring
A figure folder that holds album folders instead of audio can play any of them,
up to six, in folder order (depth-first, alphabetical). Each is its own album,
titled "Fuchs - <folder>" and of the figure's kind. Putting the figure down plays
the album it played last, or the first, straight away; another figure placed in
between forgets the position in that album, not the album itself.

Meanwhile the ring offers all of them: one section per album from a fixed palette,
edges shifting towards the neighbours' hue, the selected one bright and wobbling.
The knob moves the selection one section per detent and wraps; pressing plays the
selected album, and left alone for 8 s the picker fades into the figure's colours
and the music carries on. While open, the device hands the knob to the picker
(`capture_knob`): turns do not change the volume and the press is not published to
Home Assistant. A tick per step and a confirm "pling" go with it.

The firmware only draws: one new ring effect, LED_WHEEL_EFFECT_SEGMENT_SELECT (23),
re-sent on every detent with the section to glide from. The look is tunable through
a temporary `general.figure_selection` config section until the right values
become constants.

Every UI sound - the screen's blubb, the parents' timer, the picker - now follows
the music volume with a floor, times a new parent-mode "Effekt-Lautstärke"
(`general.effects_volume`).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 00:29:18 +02:00

405 lines
13 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,
EffectSegmentSelect,
EffectStaticConfig,
)
from musicmouse.events import (
ActiveFigureChanged,
ButtonEvent,
ConnectionChanged,
Event,
LedEffectChanged,
RfidTokenRead,
RotaryTurned,
SelectorPressed,
SelectorTurned,
TouchButtonPressed,
)
from musicmouse.hardware import Button, ButtonAction, LedZone, RotaryDirection, 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")
]
def _turn(increment: int, direction: RotaryDirection) -> bytes:
return encode_input_event(RotaryTurned(position=0, increment=increment, direction=direction))
def _knob(action: ButtonAction) -> bytes:
return encode_input_event(ButtonEvent(button=Button.ROTARY, action=action))
async def test_a_captured_knob_turns_the_selector_not_the_volume(
bus: EventBus, device: MusicMouseDevice, seen: list[Event]
) -> None:
device.capture_knob(True)
device.feed(_turn(2, RotaryDirection.UP))
device.feed(_turn(1, RotaryDirection.DOWN))
# The first turn after boot reports no increment, but it was still a detent.
device.feed(_turn(0, RotaryDirection.UP))
await bus.drain()
assert [e.steps for e in only(seen, SelectorTurned)] == [2, -1, 1]
assert not only(seen, RotaryTurned)
async def test_a_captured_knob_press_is_the_selectors_alone(
bus: EventBus, device: MusicMouseDevice, seen: list[Event]
) -> None:
device.capture_knob(True)
for action in (ButtonAction.PRESSED, ButtonAction.RELEASED):
device.feed(_knob(action))
# The other buttons are not captured.
device.feed(encode_input_event(ButtonEvent(button=Button.LEFT, action=ButtonAction.PRESSED)))
await bus.drain()
assert len(only(seen, SelectorPressed)) == 1
assert [e.button for e in only(seen, ButtonEvent)] == [Button.LEFT]
async def test_a_released_knob_is_a_volume_knob_again(
bus: EventBus, device: MusicMouseDevice, seen: list[Event]
) -> None:
device.capture_knob(True)
device.capture_knob(False)
device.feed(_turn(1, RotaryDirection.UP))
device.feed(_knob(ButtonAction.PRESSED))
await bus.drain()
assert len(only(seen, RotaryTurned)) == 1
assert len(only(seen, ButtonEvent)) == 1
assert not only(seen, SelectorTurned) and not only(seen, SelectorPressed)
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_only_the_ring_can_be_a_selector(
bus: EventBus, device: MusicMouseDevice, transport: FakeTransport
) -> None:
effect = EffectSegmentSelect(colors=(ColorRGBW(1, 0, 0, 0), ColorRGBW(0, 1, 0, 0)))
device.set_effect(LedZone.RING, effect)
device.set_effect(LedZone.SHELF, effect)
await bus.drain()
sent = transport.effect(LedZone.RING)
assert sent is not None and sent.as_bytes() == effect.as_bytes()
assert transport.effect(LedZone.SHELF) is None
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"),
]