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

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