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,299 @@
from __future__ import annotations
import re
import struct
from pathlib import Path
import pytest
from musicmouse.color import ColorRGBW
from musicmouse.devices.wire import (
MAGIC_FW_TO_HOST,
FirmwareLog,
FrameDecoder,
MessageFwToHost,
MessageHostToFw,
ProtocolError,
UnsupportedEffectError,
encode_button_brightness,
encode_effect,
encode_input_event,
)
from musicmouse.effects import (
EffectAlexaSwipeConfig,
EffectStaticConfig,
EffectStaticDetailedConfig,
)
from musicmouse.events import (
ButtonEvent,
InputEvent,
RfidTokenRead,
RotaryTurned,
TouchButtonPressed,
TouchButtonReleased,
)
from musicmouse.hardware import Button, ButtonAction, LedZone, RotaryDirection, TouchButton
MESSAGES_H = Path(__file__).resolve().parents[2] / "esp-firmware" / "src" / "Messages.h"
def frame(msg_type: int, payload: bytes) -> bytes:
return struct.pack("<IBH", MAGIC_FW_TO_HOST, msg_type, len(payload)) + payload
# ------------------------------------------------------------ firmware contract
def _parse_cpp_enum(source: str, name: str) -> dict[str, int]:
body = re.search(rf"enum class {name}\s*:\s*uint8_t\s*\{{(.*?)\}}", source, re.DOTALL)
assert body is not None, f"{name} not found in Messages.h"
return {
match["name"]: int(match["value"])
for match in re.finditer(r"(?P<name>[A-Z_0-9]+)\s*=\s*(?P<value>\d+)", body.group(1))
}
@pytest.mark.skipif(not MESSAGES_H.exists(), reason="firmware sources not available")
@pytest.mark.parametrize("enum", [MessageFwToHost, MessageHostToFw])
def test_message_ids_match_the_firmware(enum: type[MessageFwToHost | MessageHostToFw]) -> None:
"""The contract is hand-duplicated in two languages; this is what catches drift.
A missing member here is exactly the bug the old host_driver.py shipped with:
its MessageFwToHost enum never had BUTTON_EVENT = 4.
"""
source = MESSAGES_H.read_text(encoding="utf-8", errors="replace")
assert _parse_cpp_enum(source, enum.__name__) == {m.name: m.value for m in enum}
@pytest.mark.skipif(not MESSAGES_H.exists(), reason="firmware sources not available")
def test_magic_tokens_match_the_firmware() -> None:
source = MESSAGES_H.read_text(encoding="utf-8", errors="replace")
found = dict(re.findall(r"MAGIC_TOKEN_(\w+)\s*=\s*(0x[0-9a-fA-F]+)", source))
assert int(found["FW_TO_HOST"], 16) == MAGIC_FW_TO_HOST
# ---------------------------------------------------------------------- encoding
def test_effect_frame_has_magic_type_and_length() -> None:
effect = EffectStaticConfig(ColorRGBW(1, 0, 0, 0))
encoded = encode_effect(LedZone.RING, effect)
magic, msg_type, size = struct.unpack("<IBH", encoded[:7])
assert magic == 0x1D6379E3
assert msg_type == MessageHostToFw.LED_WHEEL_EFFECT_STATIC
assert size == len(effect.as_bytes())
assert encoded[7:] == effect.as_bytes()
@pytest.mark.parametrize(
("zone", "expected"),
[
(LedZone.RING, MessageHostToFw.LED_WHEEL_EFFECT_STATIC),
(LedZone.MOUSE, MessageHostToFw.MOUSE_LED_EFFECT_STATIC),
(LedZone.SHELF, MessageHostToFw.SHELF_LED_EFFECT_STATIC),
],
)
def test_the_same_effect_gets_a_different_id_per_zone(
zone: LedZone, expected: MessageHostToFw
) -> None:
encoded = encode_effect(zone, EffectStaticConfig(ColorRGBW(0, 0, 0, 0)))
assert encoded[4] == expected
def test_alexa_swipe_is_only_supported_on_the_ring() -> None:
encode_effect(LedZone.RING, EffectAlexaSwipeConfig())
with pytest.raises(UnsupportedEffectError, match="mouse LEDs"):
encode_effect(LedZone.MOUSE, EffectAlexaSwipeConfig())
def test_static_detailed_is_only_supported_on_the_shelf() -> None:
encode_effect(LedZone.SHELF, EffectStaticDetailedConfig(ColorRGBW(0, 0, 0, 0)))
with pytest.raises(UnsupportedEffectError, match="supported there"):
encode_effect(LedZone.RING, EffectStaticDetailedConfig(ColorRGBW(0, 0, 0, 0)))
@pytest.mark.parametrize(
("button", "message"),
[
(Button.LEFT, MessageHostToFw.PREV_BUTTON_LED),
(Button.RIGHT, MessageHostToFw.NEXT_BUTTON_LED),
],
)
def test_button_brightness(button: Button, message: MessageHostToFw) -> None:
encoded = encode_button_brightness(button, 0.5)
assert encoded[4] == message
assert struct.unpack("<f", encoded[7:]) == (0.5,)
def test_button_brightness_range_is_checked() -> None:
with pytest.raises(ValueError, match=r"within 0\.\.1"):
encode_button_brightness(Button.LEFT, 1.5)
def test_rotary_button_has_no_backlight() -> None:
with pytest.raises(ValueError, match="no backlight"):
encode_button_brightness(Button.ROTARY, 0.5)
# ---------------------------------------------------------------------- decoding
def collect(decoder: FrameDecoder, data: bytes) -> list[object]:
"""Push ``data`` and drain everything the decoder can produce."""
decoder.push(data)
items: list[object] = []
while (item := decoder.take()) is not None:
items.append(item)
return items
def decode_one(data: bytes) -> object:
items = collect(FrameDecoder(), data)
assert len(items) == 1, f"expected exactly one item, got {items}"
return items[0]
def test_decode_rfid() -> None:
event = decode_one(frame(MessageFwToHost.RFID_TOKEN_READ, bytes.fromhex("04a1b2c3d4")))
assert event == RfidTokenRead(tag_id=bytes.fromhex("04a1b2c3d4"), source="device")
def test_decode_rotary() -> None:
payload = struct.pack("<iiB", -17, 2, RotaryDirection.UP)
event = decode_one(frame(MessageFwToHost.ROTARY_ENCODER, payload))
assert event == RotaryTurned(
position=-17, increment=2, direction=RotaryDirection.UP, source="device"
)
def test_decode_touch_press_and_release() -> None:
pressed = decode_one(frame(MessageFwToHost.TOUCH_BUTTON_PRESS, bytes([TouchButton.LEFT_EAR])))
released = decode_one(
frame(MessageFwToHost.TOUCH_BUTTON_RELEASE, bytes([TouchButton.RIGHT_FOOT]))
)
assert pressed == TouchButtonPressed(button=TouchButton.LEFT_EAR, source="device")
assert released == TouchButtonReleased(button=TouchButton.RIGHT_FOOT, source="device")
def test_decode_button_event() -> None:
payload = struct.pack("<BB", Button.ROTARY, ButtonAction.DOUBLE_CLICKED)
event = decode_one(frame(MessageFwToHost.BUTTON_EVENT, payload))
assert event == ButtonEvent(
button=Button.ROTARY, action=ButtonAction.DOUBLE_CLICKED, source="device"
)
def test_two_frames_in_one_chunk_are_both_decoded() -> None:
"""Regression: the old parser handled at most one frame per read."""
data = frame(MessageFwToHost.TOUCH_BUTTON_PRESS, b"\x00") + frame(
MessageFwToHost.TOUCH_BUTTON_RELEASE, b"\x00"
)
items = collect(FrameDecoder(), data)
assert [type(item) for item in items] == [TouchButtonPressed, TouchButtonReleased]
def test_a_frame_split_across_chunks_is_reassembled() -> None:
data = frame(MessageFwToHost.RFID_TOKEN_READ, bytes.fromhex("04a1b2c3d4"))
decoder = FrameDecoder()
for index in range(len(data) - 1):
assert collect(decoder, data[index : index + 1]) == []
items = collect(decoder, data[-1:])
assert items == [RfidTokenRead(tag_id=bytes.fromhex("04a1b2c3d4"), source="device")]
def test_log_text_is_yielded_separately() -> None:
assert decode_one(b"RFID reader ready\n") == FirmwareLog("RFID reader ready")
def test_log_text_interleaved_with_frames() -> None:
data = (
b"booting\n"
+ frame(MessageFwToHost.TOUCH_BUTTON_PRESS, b"\x02")
+ b"touched\r\n"
+ frame(MessageFwToHost.TOUCH_BUTTON_RELEASE, b"\x02")
)
items = collect(FrameDecoder(), data)
assert items == [
FirmwareLog("booting"),
TouchButtonPressed(button=TouchButton.LEFT_EAR, source="device"),
FirmwareLog("touched"),
TouchButtonReleased(button=TouchButton.LEFT_EAR, source="device"),
]
def test_unterminated_log_text_resyncs_on_the_next_frame() -> None:
data = b"half a line" + frame(MessageFwToHost.TOUCH_BUTTON_PRESS, b"\x01")
items = collect(FrameDecoder(), data)
assert items == [
FirmwareLog("half a line"),
TouchButtonPressed(button=TouchButton.RIGHT_FOOT, source="device"),
]
def test_partial_log_text_is_held_until_more_arrives() -> None:
decoder = FrameDecoder()
assert collect(decoder, b"partial") == []
assert collect(decoder, b" line\n") == [FirmwareLog("partial line")]
def test_unknown_message_type_raises_but_leaves_the_decoder_usable() -> None:
decoder = FrameDecoder()
data = frame(99, b"\x00") + frame(MessageFwToHost.TOUCH_BUTTON_PRESS, b"\x01")
decoder.push(data)
with pytest.raises(ProtocolError, match="unknown message type 99"):
decoder.take()
assert collect(decoder, b"") == [
TouchButtonPressed(button=TouchButton.RIGHT_FOOT, source="device")
]
def test_out_of_range_enum_value_raises() -> None:
decoder = FrameDecoder()
decoder.push(frame(MessageFwToHost.TOUCH_BUTTON_PRESS, b"\x09"))
with pytest.raises(ProtocolError, match="bad value"):
decoder.take()
def test_wrong_length_rfid_payload_raises() -> None:
decoder = FrameDecoder()
decoder.push(frame(MessageFwToHost.RFID_TOKEN_READ, b"\x01\x02"))
with pytest.raises(ProtocolError, match="must be 5 bytes"):
decoder.take()
def test_garbage_does_not_grow_the_buffer_without_bound() -> None:
decoder = FrameDecoder()
for _ in range(100):
collect(decoder, b"\x00" * 1000)
assert decoder.buffered < 8192 + 1000
# --------------------------------------------------------------------- round trip
@pytest.mark.parametrize(
"event",
[
RfidTokenRead(tag_id=bytes.fromhex("04a1b2c3d4"), source="device"),
RotaryTurned(position=5, increment=-1, direction=RotaryDirection.DOWN, source="device"),
TouchButtonPressed(button=TouchButton.RIGHT_EAR, source="device"),
TouchButtonReleased(button=TouchButton.LEFT_FOOT, source="device"),
ButtonEvent(button=Button.LEFT, action=ButtonAction.LONG_PRESSED, source="device"),
],
)
def test_encode_decode_round_trip(event: InputEvent) -> None:
"""The simulator injects events through this path, so it exercises the real codec."""
assert decode_one(encode_input_event(event)) == event
def test_encode_input_event_rejects_non_firmware_events() -> None:
from musicmouse.events import PlaylistFinished
with pytest.raises(ValueError, match="not a firmware message"):
encode_input_event(PlaylistFinished())