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(" 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[A-Z_0-9]+)\s*=\s*(?P\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(" 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(" 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(" 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(" 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())