diff --git a/.gitignore b/.gitignore index 0e9d4f0..3400ee5 100644 --- a/.gitignore +++ b/.gitignore @@ -1,7 +1,12 @@ generated_3d venv +.venv build +*.egg-info *.FCStd1 *.blend1 __pycache__ .ipynb_checkpoints +.pytest_cache +.mypy_cache +.ruff_cache diff --git a/docs/REPO_OVERVIEW.md b/docs/REPO_OVERVIEW.md index 0fa5168..3d3e25f 100644 --- a/docs/REPO_OVERVIEW.md +++ b/docs/REPO_OVERVIEW.md @@ -1,42 +1,82 @@ # MusicMouse — repo overview -Orientation doc for AI agents (or humans) working on this repo for the first time. Written from what's actually in the repo — no roadmap speculation beyond `esp-firmware/todo.md`. +Orientation doc for AI agents (or humans) working on this repo for the first time. ## What this is -MusicMouse is a DIY, Toniebox-style physical music player for kids, shaped like a mouse and living on a shelf. Small 3D-printed animal figurines (fox, owl, dog, elephant, squirrel, crocodile, rabbit, snowman, puppy — see `hardware/3dprints/figures/`) each carry an RFID tag. Placing a figurine on the mouse triggers an RFID read, which starts that figure's music playlist. The mouse also has a rotary encoder + touch buttons (ears/feet) for volume/skip control, addressable RGBW LED rings with animated effects, and MQTT/Home Assistant integration so a "shelf light" shows up as a smart-home device. +MusicMouse is a DIY, Toniebox-style physical music player for kids, shaped like a mouse +and living on a shelf. Small 3D-printed animal figurines (fox, owl, dog, elephant, +squirrel, crocodile, rabbit, snowman, puppy — see `hardware/3dprints/figures/`) each +carry an RFID tag. Placing a figurine on the mouse starts that figure's playlist. The +mouse also has a rotary encoder and capacitive touch areas (ears/feet) for +volume/skip control, addressable RGBW LED rings with animated effects, and MQTT/Home +Assistant integration. ## Repo layout | Path | What it is | |---|---| -| `python-backend/` | Python host application — the main runtime. Reads RFID/button/encoder events from the ESP32 over serial, drives playback via VLC, sends LED effect commands, bridges to MQTT/Home Assistant. Start here for backend work. | +| `python-backend/` | Python host application — the main runtime. Has its own `README.md` with the architecture; start there for backend work. | | `esp-firmware/` | ESP32 firmware (C++, Arduino framework via PlatformIO). Reads the RFID reader and buttons, drives the LED strips, talks to `python-backend` over serial. | | `hardware/` | 3D-print models for the figurines and enclosure (FreeCAD/Blender/OBJ/STL), a Fritzing electronics sketch, datasheets, and `pinout.md` (RFID reader + button-board wiring). | -| `claude-design/` | An unrelated exploratory web-UI mockup ("Dolphin Beats Music Player" / rebrand concept). Not integrated with the rest of the repo — no build system ties it in. Don't assume it reflects current product direction. | -| `.vscode/` | Editor settings (C++ header associations). | - -There is no top-level README elsewhere in the repo; this file plus `esp-firmware/todo.md` and `hardware/pinout.md` are the only prose docs. +| `claude-design/` | An unrelated exploratory web-UI mockup ("Dolphin Beats"). Not integrated with anything — no build system ties it in. Don't assume it reflects current product direction. | ## How the pieces talk to each other -- **ESP32 firmware ↔ `python-backend`**: a length-prefixed binary protocol over serial (`pyserial-asyncio`), implemented in `python-backend/host_driver.py`. Messages are framed with magic tokens (`MAGIC_TOKEN_HOST_TO_FW`/`MAGIC_TOKEN_FW_TO_HOST`) and a `struct`-packed header. The message-ID maps and struct formats in `host_driver.py` must stay byte-for-byte in sync with the firmware's `esp-firmware/src/Messages.h` — there's no shared schema or test verifying this cross-language contract, so a firmware protocol change can silently desync the Python side. -- **`python-backend` ↔ MQTT/Home Assistant**: `python-backend/mqtt_json.py` exposes the shelf LED strip as a Home-Assistant-discoverable JSON-schema MQTT light (`ShelveLightMqtt`), and `main.py` also calls Home Assistant services directly (e.g. toggling room lights) via `hass-client`. +- **ESP32 firmware ↔ `python-backend`**: a length-prefixed binary protocol over serial. + Frames are `uint32 magic | uint8 type | uint16 size | payload`, little-endian, with + firmware log text interleaved on the same link. The Python side lives in + `python-backend/musicmouse/devices/wire.py`, the firmware side in + `esp-firmware/src/Messages.h`. + + The contract is hand-duplicated in two languages. `tests/test_wire.py` parses + `Messages.h` and fails if the message ids drift, and `tests/test_effects.py` pins the + exact bytes of every effect payload — so a firmware change that breaks the host now + breaks a test instead of just the LEDs. + +- **`python-backend` ↔ Home Assistant**: MQTT only. The backend publishes three + discoverable lights, a player sensor, a volume number, transport buttons, device + triggers for every button and touch area, and a tag scanner for the RFID reader. It + does *not* call Home Assistant services directly any more; behaviour like "the left + ear turns the room light pink" is an HA automation. See `python-backend/README.md` + for the trigger topics and the old colour mapping. ## Running it -``` -python python-backend/main.py +```sh +python -m musicmouse --config /media/musicmouse/config.yml ``` -`main.py` expects `/config.yml` (schema documented in the new `python-backend/config.yml.example` — no real config was previously checked in or documented). In production this is deployed as a systemd service reading music from `/media/musicmouse/`; see `esp-firmware/musicmouse.service` for the unit template — **note its `ExecStart` path (`.../espmusicmouse/host_driver/main.py`) is stale**, referencing the pre-reorg directory layout from before the `bd8925a "Cleaned up repository"` commit moved things to `python-backend/`. Update that path before relying on the service file. +Or, with no hardware and no audio: -## Config schema (see `python-backend/config.yml.example`) +```sh +python -m musicmouse --config ./config.yml --simulate +``` -- `general.{alsa_device, serial_port, hass_url, hass_token, mqtt.{server,user,password}, min_volume, max_volume, volume_increment, button_leds_brightness}` -- `figures..{id, colors, media_files}` — `id` is a hex RFID tag id, `colors` is a list of 4 colors (`primary, secondary, background, accent`, each `"#rrggbb"` or `"wNN"`), `media_files` is optional (auto-globbed from the config dir by figure name if omitted). +The simulator runs the whole app against a fake serial link and a fake player, either +interactively or from a scenario file. `python-backend/musicmouse.service` is the +systemd unit for the device. -## Known gaps / notes for agents +## Config -- **No automated tests, no lint/formatter config, no CI** anywhere in the repo (neither `python-backend/` nor `esp-firmware/`, aside from a PlatformIO `native` build env for firmware unit testing). -- `python-backend/audio_analysis.py` and the three chord-recognition notebooks (`C5S2_ChordRec_Templates.ipynb`, `C5S3_ChordRec_HMM.ipynb`, `C5S3_HiddenMarkovModel.ipynb`) are university-course exploratory material (chroma/chord-recognition DSP), **not imported by `main.py`** and not part of the running app. They reference stale personal absolute paths. +`python-backend/config.yml.example` documents the schema. In short: + +- `general.{figure_folder, serial_port, baudrate, reconnect_interval, alsa_device, + min_volume, max_volume, initial_volume, volume_increment, button_leds_brightness, + audio_extensions, mqtt.*}` +- `figures..{id, colors}` — `id` is a 5-byte hex RFID tag, unique per figure; + `colors` is exactly four (`primary, secondary, bg, accent`), each `"#rrggbb"` or + `"wNN"`. + +Each figure plays `/
`, alphabetically by filename. Config is +validated with pydantic: unknown keys are errors, and every problem is reported at once. + +## Notes for agents + +- The backend has `pytest`, `ruff` and `mypy --strict` configured in + `python-backend/pyproject.toml`, and no CI. Run all three before proposing changes. +- Prefer adding a scenario in `python-backend/scenarios/` over a hand-rolled test when + the behaviour is end-to-end — those files are executed by the test suite. +- `python-backend/notebooks/` is university course material on chord recognition, not + part of the app. +- The firmware has no automated tests beyond a PlatformIO `native` env for LED effects. diff --git a/esp-firmware/musicmouse.service b/esp-firmware/musicmouse.service deleted file mode 100644 index f3721de..0000000 --- a/esp-firmware/musicmouse.service +++ /dev/null @@ -1,13 +0,0 @@ -# Put this into /etc/systemd/system/musicmouse.service -[Unit] -Description=Music Mouse RFID Music Player -After=multi-user.target - - -[Service] -Type=simple -Restart=always -ExecStart=/opt/musicmouse/venv/bin/python /opt/musicmouse/espmusicmouse/host_driver/main.py /media/musicmouse/ - -[Install] -WantedBy=multi-user.target \ No newline at end of file diff --git a/python-backend/README.md b/python-backend/README.md new file mode 100644 index 0000000..fa2e0a3 --- /dev/null +++ b/python-backend/README.md @@ -0,0 +1,120 @@ +# MusicMouse backend + +The host application: it reads RFID tags, buttons and touch areas from the ESP32 +firmware over serial, plays music through VLC, drives three LED strips, and exposes +everything to Home Assistant over MQTT. + +``` + ESP32 ⇄ MusicMouseDevice ─┐ ┌─► MqttService (state out, intents in) + VLC ⇄ VlcPlayer ────────┼──► EventBus ──────────►┤ + broker ⇄ MqttService ──────┘ ▲ └─► (a web service would go here) + │ + reactions/*.py ── call actions on ──► device / player +``` + +Three objects own the outside world, one bus carries everything, and the *reactions* +are the only place that decides what should happen. Adding a new way to control the +mouse means adding a service that emits the same intents - no device or reaction +changes. + +## Running it + +```sh +pip install -e '.[dev]' +python -m musicmouse --config /media/musicmouse/config.yml +``` + +See `config.yml.example` for the schema and `musicmouse.service` for the systemd unit. +Config problems are reported all at once with the path to each one; unknown keys are +errors, not silent no-ops. + +### Without hardware + +The simulator runs the entire app - real bus, real device, real reactions, real MQTT +if configured - against a fake serial link and a fake player. + +```sh +python -m musicmouse --config ./config.yml --simulate +``` + +``` +musicmouse> place fuchs + in RfidTokenRead(04a1b2c3d4, figure='fuchs') + rfid fuchs + led ring: SwipeAndChange(AlexaSwipe(#(1.0, 0.4, 0.0, 0) -> ...)) + play playing +musicmouse> press right + play track 1: 01 - Song 1 +``` + +`help` lists the verbs. The same verbs go in a scenario file: + +```sh +python -m musicmouse --config ./config.yml --simulate --script scenarios/smoke.txt +``` + +Scenario files run on a virtual clock under pytest, so `wait 1s` costs microseconds and +every file in `scenarios/` is part of the test suite. A session reproduced by hand at +the prompt becomes a regression test by pasting it into a `.txt` file. + +## Layout + +| Path | What it is | +|---|---| +| `musicmouse/bus.py` | One FIFO queue on one loop. Thread-safe `emit()` - which is how libVLC's callback thread stops reaching the serial transport. | +| `musicmouse/events.py` | The vocabulary: **input** (something happened), **intent** (something was requested), **state** (something changed). | +| `musicmouse/devices/` | `mouse.py` (firmware), `player.py` (VLC), `wire.py` (pure codec), `serial_link.py` (transport + reconnect). | +| `musicmouse/reactions/` | The policy. `@on(SomeEvent)` functions that get the app and act. | +| `musicmouse/services/mqtt/` | Home Assistant entities: three lights, a player sensor, a volume number, transport buttons, device triggers, a tag scanner. | +| `musicmouse/simulator/` | Fake transport and player, the driver vocabulary, the REPL and the script runner. | +| `musicmouse/config.py` | Pydantic schema, validation, and human-readable error formatting. | + +## Checks + +```sh +pytest # unit + scenario tests +ruff check . +mypy # --strict, configured in pyproject.toml +``` + +`tests/test_wire.py` parses `../esp-firmware/src/Messages.h` and fails if the Python +message ids drift from the firmware's - the contract is hand-duplicated in two +languages, and it had already drifted once (`BUTTON_EVENT` was missing on the Python +side). `tests/test_effects.py` pins the exact bytes of every effect payload. + +## LED arbitration + +`MusicMouseDevice` is the single writer to each zone, and the most recent effect wins - +whether it came from a figure animation or from Home Assistant. There is no priority +scheme. Every write emits `LedEffectChanged`, and the MQTT light entities publish their +state from that rather than echoing their own commands, so HA keeps showing the strip's +real state when a figure animation overrides a colour it set. + +## Home Assistant + +The backend no longer calls Home Assistant directly (`hass-client` is gone). It +publishes what happened; the automations live in HA. + +MQTT device triggers are published for every button (`pressed`, `double_clicked`, +`long_pressed`), every touch area (touched/released), and the RFID reader appears as a +tag scanner. Topics are under `musicmouse/trigger/…` and `musicmouse/tag`. + +### Recreating the old room-light behaviour + +Two behaviours used to be hard-coded in `main.py` and now need automations: + +**Rotary press toggled the room light.** Trigger on the `rotary_pressed` device +trigger, action `light.toggle` on `light.kinderzimmer_fluter`. + +**Touching a body part set a colour** on `light.kinderzimmer_fluter` and +`light.music_mouse_regal_licht`: + +| Touch area | Old service data | +|---|---| +| `right_foot` | `rgb_color: [235, 255, 67]` | +| `left_foot` | `color_temp: 469` | +| `right_ear` | `rgb_color: [101, 49, 255]` | +| `left_ear` | `rgb_color: [255, 74, 254]` | + +Trigger on the corresponding `*_touched` device trigger and call `light.turn_on` with +that data. diff --git a/python-backend/config.yml.example b/python-backend/config.yml.example index 856679e..d03ed0d 100644 --- a/python-backend/config.yml.example +++ b/python-backend/config.yml.example @@ -1,50 +1,59 @@ -# Example config.yml for the MusicMouse python-backend. +# Example config for the MusicMouse backend. # -# Reverse-engineered from main.py (load_config/Controller) since no schema -# was previously documented. Copy this file to config.yml in the directory -# passed as the CLI argument to main.py, e.g.: +# python -m musicmouse --config /media/musicmouse/config.yml # -# python main.py /media/musicmouse/ -# -# main.py reads "/config.yml". Real credentials (hass_token, -# mqtt.password) should never be committed - keep the real config.yml -# outside the repo (e.g. only on the deployed device). +# Unknown keys are rejected rather than ignored, and every problem in the file is +# reported at once, so a typo fails at startup with the path to the offending line. +# Keep the real config (with credentials) off the repo - on the device only. general: - # ALSA output device passed to python-vlc, e.g. "hw:0,0"; omit/null for VLC's default. + # Root folder holding one subfolder per figure. Relative paths resolve against + # this file's directory. Each figure plays /
, + # in alphabetical order by filename. + figure_folder: music + + # Serial port the ESP32 firmware is on. A dropped link is retried, not fatal. + serial_port: "/dev/ttyUSB0" + baudrate: 115200 + reconnect_interval: 5.0 + + # ALSA output device passed to VLC, e.g. "hw:0,0". Omit for VLC's default. alsa_device: "softvol_effects" - # Serial port the ESP32 firmware is connected on. - serial_port: "/dev/ttyUSB0" + # Volume, 0..100. min/max clamp everything, including the rotary encoder. + min_volume: 0 + max_volume: 60 + initial_volume: 40 + volume_increment: 5 # per rotary-encoder click - # Home Assistant connection used for light/service calls (hass_service()). - hass_url: "http://homeassistant.local:8123" - hass_token: "REPLACE_WITH_LONG_LIVED_ACCESS_TOKEN" + # Backlight of the prev/next buttons while a figure is playing, 0..1. + button_leds_brightness: 0.5 - # MQTT broker used for the Home-Assistant-discoverable "shelf light". + # Which files count as music. Anything else in a figure folder is ignored. + audio_extensions: [".mp3", ".ogg", ".oga", ".opus", ".flac", ".wav", ".m4a", ".aac"] + + # Home Assistant integration. Omit the whole section to run without MQTT. + # The backend exposes three lights, a player sensor, a volume slider, transport + # buttons, device triggers for every button/touch area, and a tag scanner. mqtt: server: "homeassistant.local" + port: 1883 user: "musicmouse" password: "REPLACE_WITH_MQTT_PASSWORD" + base_topic: "musicmouse" + discovery_prefix: "homeassistant" + device_id: "musicmouse" + device_name: "Music Mouse" + reconnect_interval: 10.0 - # Optional playback/UI tuning (all have defaults if omitted). - min_volume: 0 - max_volume: 32 - volume_increment: 5 # per rotary-encoder tick - button_leds_brightness: 0.5 # 0..1, brightness of the prev/next button backlight - -# One entry per figurine. The key is an arbitrary figure name (also used as -# the subdirectory name under the config dir when media_files is omitted). +# One entry per figurine. The key is the figure name and the subfolder name. figures: fuchs: - # RFID tag id as a hex string (matched against bytes read from the reader). + # RFID tag id, 5 bytes as hex. Must be unique across figures. id: "04a1b2c3d4" - # Exactly 4 colors: [primary, secondary, background, accent]. - # Accepted formats: "#rrggbb" (RGB hex) or "wNN" (white channel hex, e.g. "wff"). - colors: ["#ff6600", "#ffcc00", "#331100", "#ffffff"] - # Optional explicit list of media file paths for this figure's playlist. - # If omitted, main.py globs os.path.join(config_dir, ""). - media_files: [] + # Exactly four colours: primary, secondary, background, accent. + # Either "#rrggbb" (RGB) or "wNN" (white channel only, hex). + colors: ["#ff6600", "#ffcc00", "#331100", "wff"] eule: id: "04b2c3d4e5" diff --git a/python-backend/hass_test.py b/python-backend/hass_test.py deleted file mode 100644 index 13cbf88..0000000 --- a/python-backend/hass_test.py +++ /dev/null @@ -1,48 +0,0 @@ -"""Some simple tests/examples for the Home Assistant client.""" - -import asyncio -import logging -import sys - -from hass_client import HomeAssistantClient - -LOGGER = logging.getLogger() - -if __name__ == "__main__": - - logformat = logging.Formatter( - "%(asctime)-15s %(levelname)-5s %(name)s.%(module)s -- %(message)s") - consolehandler = logging.StreamHandler() - consolehandler.setFormatter(logformat) - LOGGER.addHandler(consolehandler) - LOGGER.setLevel(logging.DEBUG) - - if len(sys.argv) < 3: - LOGGER.error("usage: test.py ") - sys.exit() - - url = sys.argv[1] - token = sys.argv[2] - loop = asyncio.new_event_loop() - asyncio.set_event_loop(loop) - hass = HomeAssistantClient(url, token) - - async def hass_event(event, event_details): - """Handle hass event callback.""" - LOGGER.info("received event %s --> %s\n", event, event_details) - - hass.register_event_callback(hass_event) - - async def run(): - """Run tests.""" - await hass.async_connect() - await asyncio.sleep(10) - await hass.async_close() - loop.stop() - - try: - loop.create_task(run()) - loop.run_forever() - except KeyboardInterrupt: - loop.stop() - loop.close() diff --git a/python-backend/host_driver.py b/python-backend/host_driver.py deleted file mode 100644 index f0c77eb..0000000 --- a/python-backend/host_driver.py +++ /dev/null @@ -1,200 +0,0 @@ -import asyncio -from enum import Enum -import struct - -from led_cmds import (EffectStaticConfig, EffectStaticDetailedConfig, EffectAlexaSwipeConfig, - EffectCircularConfig, EffectRandomTwoColorInterpolationConfig, - EffectSwipeAndChange, EffectReverseSwipe) - -MAGIC_TOKEN_HOST_TO_FW = 0x1d6379e3 -MAGIC_TOKEN_FW_TO_HOST = 0x10c65631 - - -class MessageFwToHost(Enum): - RFID_TOKEN_READ = 0 - ROTARY_ENCODER = 1 - TOUCH_BUTTON_PRESS = 2 - TOUCH_BUTTON_RELEASE = 3 - - -class TouchButton(Enum): - LEFT_FOOT = 0 - RIGHT_FOOT = 1 - LEFT_EAR = 2 - RIGHT_EAR = 3 - - -led_ring_effect_to_message_id = { - EffectStaticConfig: 0, - EffectAlexaSwipeConfig: 1, - EffectCircularConfig: 2, - EffectRandomTwoColorInterpolationConfig: 3, - EffectSwipeAndChange: 4, - EffectReverseSwipe: 5, -} - -mouse_led_effect_to_message_id = { - EffectStaticConfig: 6, - EffectCircularConfig: 7, - EffectRandomTwoColorInterpolationConfig: 8, - EffectSwipeAndChange: 9, - EffectReverseSwipe: 10, -} - -shelve_led_effect_to_message_id = { - EffectStaticConfig: 15, - EffectCircularConfig: 16, - EffectRandomTwoColorInterpolationConfig: 17, - EffectSwipeAndChange: 18, - EffectReverseSwipe: 19, - EffectStaticDetailedConfig: 20, -} - -mouse_leds_index_ranges = { - TouchButton.RIGHT_FOOT: (0, 6), - TouchButton.LEFT_FOOT: (6, 6 + 6), - TouchButton.LEFT_EAR: (6 + 6, 6 + 6 + 16), - TouchButton.RIGHT_EAR: (6 + 6 + 16, 6 + 6 + 16 + 17), -} - -PREV_BUTTON_LED_MSG = 21 -NEXT_BUTTON_LED_MSG = 22 - - -class RfidTokenRead: - def __init__(self, id: bytes): - self.id = id - - def __repr__(self): - return "RFID Token (" + " ".join(f"{v:02x}" for v in self.id) + ")" - - -class RotaryEncoderEvent: - def __init__(self, msg_content: bytes): - self.position, self.increment, self.direction = struct.unpack(" str: - return "Pressed " + repr(self.touch_button) - - -class TouchButtonRelease: - def __init__(self, msg_content: bytes): - val = int(msg_content[0]) - self.touch_button = TouchButton(val) - - def __repr__(self) -> str: - return "Released " + repr(self.touch_button) - - -class ButtonEvent: - button_name = {1: 'left', 2: 'right', 3: 'rotary'} - event_name = { - 0: 'pressed', - 1: 'released', - 2: 'clicked', - 3: 'double_clicked', - 4: 'long_pressed', - 5: 'repeat_pressed', - 6: 'long_released' - } - - def __init__(self, msg_content: bytes): - button_nr, event_nr = struct.unpack(" str: - return f"Button {self.button} {self.event}" - - -incomingMsgMap = { - 0: RfidTokenRead, - 1: RotaryEncoderEvent, - 2: TouchButtonPress, - 3: TouchButtonRelease, - 4: ButtonEvent, -} - - -class MusicMouseProtocol(asyncio.Protocol): - def __init__(self): - super() - self._msg_callback = None - - def register_message_callback(self, cb): - self._msg_callback = cb - - def connection_made(self, transport): - self.transport = transport - self.in_buff = bytes() - - def __led_effect(self, effect_cfg, msg_dict): - msg_content = effect_cfg.as_bytes() - header = struct.pack("= HEADER_SIZE: - token, msg_type, msg_size = struct.unpack("= HEADER_SIZE + msg_size: - self._on_msg_receive(msg_type, self.in_buff[HEADER_SIZE:HEADER_SIZE + msg_size]) - self.in_buff = self.in_buff[HEADER_SIZE + msg_size:] - else: - idx = self.in_buff.find("\n".encode()) - if idx >= 0: - text_msg = self.in_buff[:idx] - print("LOG:", text_msg.decode()) - self.in_buff = self.in_buff[idx + 1:] - - def _on_msg_receive(self, msg_type, msg_payload): - parsed_msg = incomingMsgMap[msg_type](msg_payload) - if self._msg_callback is not None: - self._msg_callback(self, parsed_msg) diff --git a/python-backend/led_cmds.py b/python-backend/led_cmds.py deleted file mode 100644 index 47c3b79..0000000 --- a/python-backend/led_cmds.py +++ /dev/null @@ -1,161 +0,0 @@ -from dataclasses import dataclass, field -import struct -import colorsys - - -@dataclass -class ColorRGBW: - r: float - g: float - b: float - w: float - - def __repr__(self): - return f"#({self.r}, {self.g}, {self.b}, {self.w})" - - def as_bytes(self) -> bytes: - assert self.is_valid(), "Trying to send invalid " + repr(self) - return struct.pack(" bytes: - return struct.pack(" bytes: - return self.color.as_bytes() + struct.pack(" bytes: - return self.color.as_bytes() + struct.pack(" bytes: - return struct.pack( - " bytes: - c1 = ColorHSV.fromRGB(self.color1) if isinstance(self.color1, ColorRGBW) else self.color1 - c2 = ColorHSV.fromRGB(self.color2) if isinstance(self.color2, ColorRGBW) else self.color2 - return struct.pack(" bytes: - return struct.pack(" bytes: - return self.swipe.as_bytes() + self.change.as_bytes() - - def __repr__(self) -> str: - return f"Swipe and Change: \n {str(self.swipe)}\n {str(self.change)}" - - -@dataclass -class EffectReverseSwipe: - swipeSpeed: float = 2 * 360 - bellCurveWidthInLeds: float = 3 - startPosition: float = 180 - - def as_bytes(self) -> bytes: - return struct.pack(" str: - return f"Reverse swipe, speed {self.swipeSpeed}, width in leds {self.bellCurveWidthInLeds}, start position {self.startPosition}" diff --git a/python-backend/main.py b/python-backend/main.py deleted file mode 100755 index f47e5b9..0000000 --- a/python-backend/main.py +++ /dev/null @@ -1,276 +0,0 @@ -#!/usr/bin/env python - -import asyncio -import sys -import serial_asyncio -from led_cmds import (ColorRGBW, EffectCircularConfig, EffectStaticConfig, - EffectRandomTwoColorInterpolationConfig, EffectAlexaSwipeConfig, - EffectSwipeAndChange, EffectReverseSwipe) -from host_driver import MusicMouseProtocol, RfidTokenRead, RotaryEncoderEvent, ButtonEvent, TouchButton, TouchButtonPress, TouchButtonRelease, mouse_leds_index_ranges -from player import AudioPlayer -from glob import glob -from copy import deepcopy -import os -from hass_client import HomeAssistantClient -from ruamel.yaml import YAML -import warnings -from typing import Optional, NamedTuple -from mqtt_json import start_mqtt - -yaml = YAML(typ='safe') - -OFF_COLOR = ColorRGBW(0, 0, 0, 0) - - -class FigureColors(NamedTuple): - primary: ColorRGBW - secondary: ColorRGBW - bg: ColorRGBW - accent: ColorRGBW - - -def parse_color(color_str: str): - if isinstance(color_str, ColorRGBW): - return color_str - elif color_str.startswith("#"): - color_str = color_str.lstrip('#') - t = tuple(int(color_str[i:i + 2], 16) / 255 for i in (0, 2, 4)) - return ColorRGBW(*t, 0) - elif color_str.startswith("w"): - color_str = color_str.lstrip("w") - return ColorRGBW(0, 0, 0, int(color_str, 16) / 255) - else: - raise ValueError(f"Unrecognized color format: {color_str!r}") - - -def load_config(config_path): - # Schema documented in config.yml.example. - with open(os.path.join(config_path, "config.yml")) as cfg_file: - cfg = yaml.load(cfg_file) - for figure_name, figure_cfg in cfg["figures"].items(): - figure_cfg["colors"] = FigureColors(*(parse_color(c) for c in figure_cfg["colors"])) - if 'media_files' not in figure_cfg: - figure_cfg['media_files'] = sorted(glob(os.path.join(config_path, figure_name))) - return cfg - - -def hass_service(hass, domain, service, **kwargs): - asyncio.create_task(hass.call_service(domain, service, kwargs)) - - -class MusicMouseState: - def __init__(self, protocol: MusicMouseProtocol): - self.active_figure: Optional[ - str] = None # None if no figure is placed on the reader, or the name of the figure - self.last_partially_played_figure: Optional[ - str] = None # figure whose playlist wasn't played completely and was removed - - self.current_mouse_led_effect = None - self.current_led_ring_effect = None - self.protocol: MusicMouseProtocol = protocol - self.button_led_brightness = None - - def mouse_led_effect(self, effect_cfg): - self.current_mouse_led_effect = effect_cfg - self.protocol.mouse_led_effect(effect_cfg) - - def led_ring_effect(self, effect_cfg): - self.current_led_ring_effect = effect_cfg - self.protocol.led_ring_effect(effect_cfg) - self.protocol.shelve_led_effect(effect_cfg) - - def button_leds(self, brightness): - assert 0 <= brightness <= 1 - self.protocol.button_background_led_prev(brightness) - self.protocol.button_background_led_next(brightness) - self.button_led_brightness = brightness - - def reset(self): - self.mouse_led_effect(EffectStaticConfig(OFF_COLOR)) - self.led_ring_effect(EffectStaticConfig(OFF_COLOR)) - - -class Controller: - def __init__(self, protocol, hass, cfg): - self.cfg = cfg - self.audio_player = AudioPlayer(cfg["general"]["alsa_device"]) - self.audio_player.set_volume(50) - self.mmstate = MusicMouseState(protocol) - self.protocol = protocol - self.hass = hass - - vol_min = self.cfg["general"].get("min_volume", None) - vol_max = self.cfg["general"].get("max_volume", None) - self.audio_player.set_volume_limits(vol_min, vol_max) - protocol.register_message_callback(self.on_firmware_msg) - - self.audio_player.on_playlist_end_callback = self._on_playlist_end - self.playlists = { - fig: self.audio_player.create_playlist(fig_cfg['media_files']) - for fig, fig_cfg in cfg['figures'].items() - } - self._rfid_to_figure_name = { - bytes.fromhex(figure_cfg["id"]): figure_name - for figure_name, figure_cfg in cfg["figures"].items() - } - - self.protocol.shelve_led_effect(EffectStaticConfig(ColorRGBW(0, 0, 0.1, 0))) - shelf_eff = EffectCircularConfig() - shelf_eff.color = ColorRGBW(0, 0, 0.4, 0) - shelf_eff = EffectStaticConfig(ColorRGBW(0, 0, 0, 0)) - self.protocol.shelve_led_effect(shelf_eff) - - def _on_playlist_end(self): - if not self.audio_player.is_playing(): - self.mmstate.last_partially_played_figure = None - self._run_off_animation() - else: - print("Playlist end was called, even if player remains playing?!") - - def handle_rfid_event(self, tagid): - if tagid == bytes.fromhex("0000000000"): - if self.audio_player.is_playing(): - print("Got 000 rfid -> playing off animation") - self._run_off_animation() - self.audio_player.pause() - self.mmstate.last_partially_played_figure = self.mmstate.active_figure - else: - self.mmstate.last_partially_played_figure = None - - self.mmstate.active_figure = None - elif tagid in self._rfid_to_figure_name: - newly_placed_figure = self._rfid_to_figure_name[tagid] - colors = self.cfg["figures"][newly_placed_figure]["colors"] - self._start_animation(colors.primary, colors.secondary) - self.mmstate.button_leds(self.cfg["general"].get("button_leds_brightness", 0.5)) - - if newly_placed_figure in self.cfg['figures']: - if self.mmstate.last_partially_played_figure == newly_placed_figure: - print("Continuing playlist") - self.audio_player.play() - else: - print("Restarting playlist") - self.audio_player.set_playlist( - self.audio_player.create_playlist(self.cfg['figures'][newly_placed_figure]['media_files'])) - self.audio_player.play_from_start() - - self.mmstate.active_figure = newly_placed_figure - else: - warnings.warn(f"Unknown figure/tag with id {tagid}") - - def on_firmware_msg(self, _, message): - print("FW msg:", message) - if isinstance(message, RfidTokenRead): - self.handle_rfid_event(message.id) - elif isinstance(message, RotaryEncoderEvent): - volume_increment = self.cfg["general"].get("volume_increment", 2) * abs(message.increment) - if message.direction == 2: - self.audio_player.change_volume(volume_increment) - elif message.direction == 1: - self.audio_player.change_volume(-volume_increment) - elif isinstance(message, ButtonEvent): - btn = message.button - if btn == "left" and message.event == "pressed" and self.audio_player.is_playing(): - self.audio_player.previous() - elif btn == "right" and message.event == "pressed" and self.audio_player.is_playing(): - self.audio_player.next() - elif message.button == "rotary" and message.event == "pressed": - hass_service(self.hass, "light", "toggle", entity_id="light.kinderzimmer_fluter") - elif isinstance(message, TouchButtonPress): - figure = self.mmstate.active_figure - if figure and self.audio_player.is_playing(): - figure_colors = self.cfg["figures"][figure]["colors"] - self.protocol.mouse_led_effect( - EffectStaticConfig(figure_colors.accent, *mouse_leds_index_ranges[message.touch_button])) - - colors = { - TouchButton.RIGHT_FOOT: { - 'rgb_color': [235, 255, 67] - }, - TouchButton.LEFT_FOOT: { - 'color_temp': 469 - }, - TouchButton.RIGHT_EAR: { - 'rgb_color': [101, 49, 255] - }, - TouchButton.LEFT_EAR: { - 'rgb_color': [255, 74, 254] - }, - } - hass_service( - self.hass, - "light", - "turn_on", - entity_id=["light.kinderzimmer_fluter", "light.music_mouse_regal_licht"], - **colors[message.touch_button]) - - elif isinstance(message, TouchButtonRelease): - figure = self.mmstate.active_figure - eff_change = EffectRandomTwoColorInterpolationConfig() - eff_static = EffectStaticConfig(ColorRGBW(0, 0, 0, 0), - *mouse_leds_index_ranges[message.touch_button]) - if figure and self.audio_player.is_playing(): - colors = self.cfg["figures"][figure]["colors"] - eff_static.color = colors.primary - self.protocol.mouse_led_effect(eff_static) - - if figure and self.audio_player.is_playing(): - colors = self.cfg["figures"][figure]["colors"] - eff_change.color1 = colors.primary - eff_change.color2 = colors.secondary - eff_change.start_with_existing = True - self.protocol.mouse_led_effect(eff_change) - - def _start_animation(self, primary_color, secondary_color): - ring_eff = EffectSwipeAndChange() - ring_eff.swipe.primary_color = primary_color - ring_eff.swipe.secondary_color = secondary_color - ring_eff.swipe.swipe_speed = 180 - ring_eff.change.color1 = primary_color - ring_eff.change.color2 = secondary_color - self.mmstate.led_ring_effect(ring_eff) - - mouse_eff = deepcopy(ring_eff) - mouse_eff.swipe.start_position = 6 / 45 * 360 - mouse_eff.swipe.bell_curve_width_in_leds = 16 - mouse_eff.swipe.swipe_speed = 180 - self.mmstate.mouse_led_effect(mouse_eff) - - def _run_off_animation(self): - print("Running off animation") - ring_eff = EffectReverseSwipe() - self.mmstate.led_ring_effect(ring_eff) - - mouse_eff = EffectReverseSwipe() - mouse_eff.startPosition = 6 / 45 * 360 - self.mmstate.mouse_led_effect(mouse_eff) - - self.mmstate.button_leds(0) - - -def main(config_path): - cfg = load_config(config_path) - - loop = asyncio.new_event_loop() - asyncio.set_event_loop(loop) - hass = HomeAssistantClient(cfg["general"]["hass_url"], cfg["general"]["hass_token"], loop=loop) - - coro = serial_asyncio.create_serial_connection(loop, - MusicMouseProtocol, - cfg["general"]["serial_port"], - baudrate=115200) - transport, protocol = loop.run_until_complete(coro) - controller = Controller(protocol, hass, cfg) - mqtt_cfg = cfg["general"]["mqtt"] - loop.create_task(start_mqtt(protocol, mqtt_cfg["server"], mqtt_cfg["user"], mqtt_cfg["password"] )) - loop.create_task(hass.connect()) - return controller, loop - - -if __name__ == "__main__": - if len(sys.argv) == 2: - controller, loop = main(config_path=sys.argv[1]) - loop.run_forever() - loop.close() - else: - print("Error: run with config file path as first argument") diff --git a/python-backend/mqtt_json.py b/python-backend/mqtt_json.py deleted file mode 100644 index 4e1d43a..0000000 --- a/python-backend/mqtt_json.py +++ /dev/null @@ -1,180 +0,0 @@ -from led_cmds import ColorRGBW, EffectStaticConfig, EffectStaticDetailedConfig, EffectCircularConfig, EffectRandomTwoColorInterpolationConfig, EffectAlexaSwipeConfig, EffectSwipeAndChange -import asyncio -import aiomqtt -import json - - -class ShelveLightMqtt: - def __init__(self, protocol, client: aiomqtt.Client): - self._protocol = protocol - self._mqtt_client = client - - self._state = { - "state": "OFF", - "color": { - "r": 255, - "g": 255, - "b": 255, - "w": 0, - }, - "color_mode": "rgbw", - "brightness": 30, - "effect": "static", - } - self._last_color = ColorRGBW(0.5, 0.5, 0.5, 0) - - self._discovery_spec = self._create_discovery_msg_light() - - async def init(self): - """Init method, because constructor can't be async""" - self._protocol.shelve_led_effect(EffectStaticConfig(ColorRGBW(0, 0, 0, 0))) - await self._send_autodiscovery_msg() - await self._notify_mqtt_state({"state": "OFF"}) - - async def handle_light_message(self, msg): - if msg.topic.value == self._discovery_spec['command_topic']: - payload = msg.payload.decode() - new_state = json.loads(payload) - print("IN ", new_state) - await self._update_state(new_state) - await self._notify_mqtt_state(new_state) - - async def _update_state(self, new_state): - """Merges current state with new state, updates device""" - - # memorize last color - this is used for effects that need 2 colors - if 'color' in new_state: - brightness = new_state.get('brightness', self._state['brightness']) - new_color = self._color_from_json(new_state['color'], brightness) - current_color = self._color_from_json(self._state['color']) - if new_color != current_color: - self._last_color = current_color - print("last color", self._last_color) - - self._state.update(new_state) - self._update_device() - - @staticmethod - def _color_from_json(json_color, brightness=255): - args = ((json_color[e] / 255) * (brightness / 255) for e in ('r', 'g', 'b', 'w')) - return ColorRGBW(*args) - - def _update_device(self): - s = self._state - current_color = self._color_from_json(s['color'], brightness=s["brightness"]) - transition = s.get("transition", 0.3) * 1000 - print(f"Effect {s['effect']} Transition {transition}") - - if s['state'] == "OFF": - if transition > 0: - eff = EffectStaticDetailedConfig(ColorRGBW(0,0,0,0), transition_time_in_ms=transition) - else: - eff = EffectStaticConfig(ColorRGBW(0, 0, 0, 0)) - elif s['effect'] == 'static': - if transition > 0: - eff = EffectStaticDetailedConfig(current_color, transition_time_in_ms=transition) - else: - eff = EffectStaticConfig(current_color) - elif s['effect'] == 'circular': - eff = EffectCircularConfig(speed=180, width=90, color=current_color) - elif s['effect'] == 'wipeup': - eff = EffectSwipeAndChange() - eff.swipe.secondary_color = current_color - eff.swipe.primary_color = self._last_color - eff.swipe.bell_curve_width_in_leds = 10 - eff.swipe.transition_width = 30 - eff.swipe.start_position = 0 - eff.swipe.swipe_speed = 260 - eff.change.color1 = current_color - eff.change.color2 = self._last_color - elif s['effect'] == "twocolor": - eff = EffectRandomTwoColorInterpolationConfig() - eff.color1 = current_color - eff.color2 = self._last_color - eff.start_with_existing = True - elif s['effect'] == "twocolorrandom": - eff = EffectRandomTwoColorInterpolationConfig() - eff.color1 = current_color - eff.color2 = self._last_color - eff.hue1_random = True - eff.hue2_random = True - eff.start_with_existing = True - elif s['effect'] == "side_0.2": - eff = EffectStaticDetailedConfig(current_color, begin=0.9, end=0.1, increment=1, transition_time_in_ms=transition) - elif s['effect'] == "side_0.2_inc4": - eff = EffectStaticDetailedConfig(current_color, begin=0.9, end=0.1, increment=4, transition_time_in_ms=transition) - elif s['effect'] == "side_0.2_inc8": - eff = EffectStaticDetailedConfig(current_color, begin=0.9, end=0.1, increment=8, transition_time_in_ms=transition) - elif s['effect'] == "side_0.5": - eff = EffectStaticDetailedConfig(current_color, begin=0.75, end=0.25, increment=1, transition_time_in_ms=transition) - elif s['effect'] == "side_0.5_inc4": - eff = EffectStaticDetailedConfig(current_color, begin=0.75, end=0.25, increment=4, transition_time_in_ms=transition) - elif s['effect'] == "top_0.2": - eff = EffectStaticDetailedConfig(current_color, begin=0.4, end=0.6, increment=1, transition_time_in_ms=transition) - elif s['effect'] == "top_0.2_inc4": - eff = EffectStaticDetailedConfig(current_color, begin=0.4, end=0.6, increment=4, transition_time_in_ms=transition) - elif s['effect'] == "top_0.5": - eff = EffectStaticDetailedConfig(current_color, begin=0.25, end=0.75, increment=1, transition_time_in_ms=transition) - elif s['effect'] == "top_0.5_inc4": - eff = EffectStaticDetailedConfig(current_color, begin=0.25, end=0.75, increment=4, transition_time_in_ms=transition) - else: - print(f"Unknown effect {s['effect']}") - eff = EffectStaticConfig(ColorRGBW(0, 0, 0, 0)) - self._protocol.shelve_led_effect(eff) - - @staticmethod - def _create_discovery_msg_light(base_name="musicmouse_json", - display_name="Music Mouse Regal Licht"): - id = "shelve" - return { - 'platform': 'mqtt', - 'schema': 'json', - 'name': display_name, - 'unique_id': f'{base_name}_{id}', - 'command_topic': f'{base_name}/lights_{id}/command', - 'state_topic': f'{base_name}/lights_{id}/state', - 'color_mode': True, - 'brightness': True, - #'device': { - # 'manufacturer': 'bauer.tech', - # 'model': "SK6812 LED strip", - #}, - 'effect': True, - 'effect_list': ['static', 'circular', 'wipeup', 'twocolor', 'twocolorrandom', - "side_0.2", "side_0.5", "side_0.2_inc4", "side_0.2_inc8", "side_0.5_inc4", - "top_0.2", "top_0.5", "top_0.2_inc4", "top_0.5_inc4"], - 'supported_color_modes': ['rgbw'], - } - - async def _send_autodiscovery_msg(self): - topic = f"homeassistant/light/{self._discovery_spec['unique_id']}/config" - await self._mqtt_client.publish(topic, json.dumps(self._discovery_spec).encode(), retain=True) - - async def _notify_mqtt_state(self, state): - state_payload = json.dumps(self._state) - await self._mqtt_client.publish(self._discovery_spec['state_topic'], state_payload.encode()) - - -async def start_mqtt(music_mouse_protocol, server, username, password): - reconnect_interval = 10 # [seconds] - while True: - try: - async with aiomqtt.Client(hostname=server, username=username, password=password) as client: - shelve_light = ShelveLightMqtt(music_mouse_protocol, client) - await shelve_light.init() - await client.subscribe("musicmouse_json/#") - async for message in client.messages: - await shelve_light.handle_light_message(message) - except aiomqtt.MqttError as error: - print(f'Error "{error}". Reconnecting in {reconnect_interval} seconds') - finally: - await asyncio.sleep(reconnect_interval) - - -if __name__ == "__main__": - class DummyProtocol: - def shelve_led_effect(self, effect): - print("EFF ", repr(effect)) - - password = "" - asyncio.run(start_mqtt(DummyProtocol(), "homeassistant", "musicmouse", password)) diff --git a/python-backend/musicmouse.service b/python-backend/musicmouse.service new file mode 100644 index 0000000..7aead01 --- /dev/null +++ b/python-backend/musicmouse.service @@ -0,0 +1,24 @@ +# Put this into /etc/systemd/system/musicmouse.service +# +# Assumes the repo is checked out at /opt/musicmouse with a venv at /opt/musicmouse/.venv: +# /opt/musicmouse/.venv/bin/pip install -e /opt/musicmouse/python-backend +# +# A dropped serial link is now handled in-process (SerialLink reconnects), so +# Restart=always is only for genuine crashes. + +[Unit] +Description=Music Mouse RFID Music Player +After=multi-user.target sound.target network-online.target +Wants=network-online.target + +[Service] +Type=simple +Restart=always +RestartSec=5 +WorkingDirectory=/opt/musicmouse/python-backend +ExecStart=/opt/musicmouse/.venv/bin/python -m musicmouse --config /media/musicmouse/config.yml +StandardOutput=journal +StandardError=journal + +[Install] +WantedBy=multi-user.target diff --git a/python-backend/musicmouse/__init__.py b/python-backend/musicmouse/__init__.py new file mode 100644 index 0000000..5a2b2a2 --- /dev/null +++ b/python-backend/musicmouse/__init__.py @@ -0,0 +1,5 @@ +"""MusicMouse backend: an RFID music player for kids.""" + +__all__ = ["__version__"] + +__version__ = "2.0.0" diff --git a/python-backend/musicmouse/__main__.py b/python-backend/musicmouse/__main__.py new file mode 100644 index 0000000..95d6292 --- /dev/null +++ b/python-backend/musicmouse/__main__.py @@ -0,0 +1,217 @@ +"""Entry point and composition root. + + python -m musicmouse --config /media/musicmouse/config.yml + python -m musicmouse --config ./config.yml --simulate + python -m musicmouse --config ./config.yml --simulate --script scenarios/smoke.txt + +This is the only module that knows which concrete implementations are in play; the +difference between "real mouse" and "simulated mouse" is which transport and which +player get built here. +""" + +from __future__ import annotations + +import argparse +import asyncio +import contextlib +import logging +import sys +from collections.abc import Coroutine +from pathlib import Path +from typing import Any + +from musicmouse import __version__ +from musicmouse.app import App +from musicmouse.bus import EventBus +from musicmouse.clock import RealClock +from musicmouse.config import Config, ConfigError, build_playlists, load_config +from musicmouse.devices.mouse import MusicMouseDevice +from musicmouse.devices.player import Player, VlcPlayer +from musicmouse.devices.serial_link import SerialLink +from musicmouse.reactions import register_all +from musicmouse.services.base import Service +from musicmouse.services.mqtt import MqttService, build_entities + +_log = logging.getLogger("musicmouse") + + +def parse_args(argv: list[str] | None = None) -> argparse.Namespace: + parser = argparse.ArgumentParser( + prog="musicmouse", description="Host backend for the MusicMouse RFID music player." + ) + parser.add_argument( + "-c", "--config", type=Path, required=True, help="path to config.yml" + ) + parser.add_argument( + "-s", + "--simulate", + action="store_true", + help="run against fake hardware and a fake player (no serial port, no audio)", + ) + parser.add_argument( + "--script", + type=Path, + help="with --simulate: run a scenario file instead of the interactive prompt", + ) + parser.add_argument( + "--log-level", + default="INFO", + choices=["DEBUG", "INFO", "WARNING", "ERROR"], + help="default: INFO", + ) + parser.add_argument("--version", action="version", version=f"%(prog)s {__version__}") + + args = parser.parse_args(argv) + if args.script and not args.simulate: + parser.error("--script only makes sense together with --simulate") + return args + + +def setup_logging(level: str) -> None: + logging.basicConfig( + level=getattr(logging, level), + format="%(asctime)s %(levelname)-7s %(name)-28s %(message)s", + datefmt="%H:%M:%S", + ) + + +def main(argv: list[str] | None = None) -> int: + args = parse_args(argv) + setup_logging(args.log_level) + + try: + config = load_config(args.config, check_paths=True) + except ConfigError as exc: + print(f"error: {exc}", file=sys.stderr) + return 2 + + runner = run_simulated(config, args.script) if args.simulate else run_real(config) + try: + asyncio.run(runner) + except KeyboardInterrupt: + _log.info("Interrupted") + return 0 + + +# ------------------------------------------------------------------------- real + + +async def run_real(config: Config) -> None: + bus = EventBus() + await bus.start() + clock = RealClock() + general = config.general + + link = SerialLink( + general.serial_port, + general.baudrate, + reconnect_interval=general.reconnect_interval, + clock=clock, + ) + mouse = MusicMouseDevice(bus, link, config.tag_map, port=general.serial_port) + link.attach( + mouse.feed, on_connect=mouse.on_connected, on_disconnect=mouse.on_disconnected + ) + + player: Player = VlcPlayer( + bus, + alsa_device=general.alsa_device, + clock=clock, + **VlcPlayer.volume_kwargs(general), + ) + + app = _build_app(config, bus, mouse, player, clock=clock) + services = _build_services(app, mouse, player, clock=clock) + + _log.info( + "MusicMouse %s starting: %d figures, serial %s, mqtt %s", + __version__, + len(config.figures), + general.serial_port, + general.mqtt.server if general.mqtt else "disabled", + ) + try: + await _run_forever( + [link.run(), player.run(), *(service.run() for service in services)] + ) + finally: + player.close() + await bus.stop() + + +# -------------------------------------------------------------------- simulated + + +async def run_simulated(config: Config, script: Path | None) -> None: + # Imported here so the production path never touches the simulator. + from musicmouse.simulator.harness import build_simulation + from musicmouse.simulator.repl import run_repl + from musicmouse.simulator.script import run_script_file + + # A script runs on virtual time, so `wait 1s` is instant. The prompt runs on the + # real clock, so playback ticks along while you watch it. + sim = await build_simulation( + config, clock=RealClock() if script is None else None, track_duration=5.0 + ) + + services = _build_services(sim.app, sim.app.mouse, sim.player, clock=RealClock()) + tasks = [asyncio.create_task(service.run(), name=service.name) for service in services] + try: + if script is not None: + await run_script_file(sim, script) + else: + await run_repl(sim) + finally: + for task in tasks: + task.cancel() + await sim.aclose() + + +# ---------------------------------------------------------------------- wiring + + +def _build_app( + config: Config, + bus: EventBus, + mouse: MusicMouseDevice, + player: Player, + *, + clock: RealClock, +) -> App: + app = App( + config=config, + bus=bus, + mouse=mouse, + player=player, + playlists=build_playlists(config), + clock=clock, + ) + register_all(bus, app) + return app + + +def _build_services( + app: App, mouse: MusicMouseDevice, player: Player, *, clock: RealClock +) -> list[Service]: + """Every front-end. A web service would be one more line here.""" + if app.config.general.mqtt is None: + _log.info("No mqtt section in the config: Home Assistant integration is off") + return [] + mqtt_config = app.config.general.mqtt + entities = build_entities(app.bus, mqtt_config, mouse, player) + return [MqttService(app.bus, mqtt_config, entities, clock=clock)] + + +async def _run_forever(coroutines: list[Coroutine[Any, Any, None]]) -> None: + tasks = [asyncio.create_task(coro) for coro in coroutines] + try: + await asyncio.gather(*tasks) + finally: + for task in tasks: + task.cancel() + with contextlib.suppress(asyncio.CancelledError): + await asyncio.gather(*tasks, return_exceptions=True) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/python-backend/musicmouse/app.py b/python-backend/musicmouse/app.py new file mode 100644 index 0000000..431f599 --- /dev/null +++ b/python-backend/musicmouse/app.py @@ -0,0 +1,46 @@ +"""What the reactions get handed: the three objects, the bus, and a little shared state.""" + +from __future__ import annotations + +import logging +from dataclasses import dataclass, field + +from musicmouse.bus import EventBus +from musicmouse.clock import Clock, RealClock +from musicmouse.config import Config, FigureColors +from musicmouse.devices.mouse import MusicMouseDevice +from musicmouse.devices.player import Player +from musicmouse.media import Playlist + +_log = logging.getLogger(__name__) + +__all__ = ["App", "AppState"] + + +@dataclass +class AppState: + """State that belongs to no single device but is shared between reactions.""" + + #: Figure that was taken off the reader mid-playlist, so putting it back resumes + #: instead of starting over. Cleared once its playlist runs out. + last_partially_played_figure: str | None = None + + +@dataclass +class App: + config: Config + bus: EventBus + mouse: MusicMouseDevice + player: Player + playlists: dict[str, Playlist] + clock: Clock = field(default_factory=RealClock) + state: AppState = field(default_factory=AppState) + + def colors(self, figure: str) -> FigureColors: + return self.config.figures[figure].colors + + def playlist(self, figure: str) -> Playlist | None: + playlist = self.playlists.get(figure) + if playlist is None: + _log.warning("No playlist for figure %r", figure) + return playlist diff --git a/python-backend/musicmouse/bus.py b/python-backend/musicmouse/bus.py new file mode 100644 index 0000000..6d8b9d9 --- /dev/null +++ b/python-backend/musicmouse/bus.py @@ -0,0 +1,153 @@ +"""The event bus. + +Everything in the process is serialised through one FIFO queue on one loop, which is +what makes "last event wins" a well-defined rule for LED zone arbitration and what +makes scenario tests deterministic. + +Handlers may be sync or async; async handlers are awaited, so one event is fully +handled before the next is dispatched. A handler that raises is logged and does not +stop the others or the bus. +""" + +from __future__ import annotations + +import asyncio +import contextlib +import inspect +import logging +from collections.abc import Callable, Coroutine +from typing import Any + +from musicmouse.events import Event + +_log = logging.getLogger(__name__) + +__all__ = ["EventBus", "Handler", "Unsubscribe"] + +type Handler[E: Event] = Callable[[E], Coroutine[Any, Any, None] | None] +type Unsubscribe = Callable[[], None] + + +class EventBus: + def __init__(self) -> None: + self._handlers: dict[type[Event], list[Handler[Any]]] = {} + self._wildcard: list[Handler[Any]] = [] + self._resolved: dict[type[Event], tuple[Handler[Any], ...]] = {} + self._queue: asyncio.Queue[Event] = asyncio.Queue() + self._loop: asyncio.AbstractEventLoop | None = None + self._dispatcher: asyncio.Task[None] | None = None + + # ------------------------------------------------------------------ lifecycle + + async def start(self) -> None: + if self._dispatcher is not None: + return + self._loop = asyncio.get_running_loop() + self._dispatcher = asyncio.create_task(self._run(), name="event-bus") + + async def stop(self) -> None: + if self._dispatcher is None: + return + self._dispatcher.cancel() + with contextlib.suppress(asyncio.CancelledError): + await self._dispatcher + self._dispatcher = None + self._loop = None + + async def __aenter__(self) -> EventBus: + await self.start() + return self + + async def __aexit__(self, *exc_info: object) -> None: + await self.stop() + + # --------------------------------------------------------------- subscription + + def subscribe[E: Event](self, event_type: type[E], handler: Handler[E]) -> Unsubscribe: + """Register ``handler`` for ``event_type`` and any subclass of it.""" + self._handlers.setdefault(event_type, []).append(handler) + self._resolved.clear() + + def unsubscribe() -> None: + handlers = self._handlers.get(event_type) + if handlers and handler in handlers: + handlers.remove(handler) + self._resolved.clear() + + return unsubscribe + + def subscribe_all(self, handler: Handler[Event]) -> Unsubscribe: + """Register ``handler`` for every event. Useful for logging and broadcasting.""" + self._wildcard.append(handler) + + def unsubscribe() -> None: + if handler in self._wildcard: + self._wildcard.remove(handler) + + return unsubscribe + + # ---------------------------------------------------------------- publication + + def emit(self, event: Event) -> None: + """Queue ``event`` for dispatch. Safe to call from any thread. + + libVLC fires its callbacks on its own thread; this is where that crossing is + made safe instead of reaching the serial transport off-loop. + """ + loop = self._loop + if loop is None: + raise RuntimeError("EventBus.emit() before start()") + try: + running = asyncio.get_running_loop() + except RuntimeError: + running = None + if running is loop: + self._queue.put_nowait(event) + else: + loop.call_soon_threadsafe(self._queue.put_nowait, event) + + async def drain(self) -> None: + """Wait until every queued event, and everything they emitted, is handled.""" + await self._queue.join() + + async def emit_and_wait(self, event: Event) -> None: + self.emit(event) + await self.drain() + + # -------------------------------------------------------------------- internals + + async def _run(self) -> None: + while True: + event = await self._queue.get() + try: + await self._dispatch(event) + finally: + self._queue.task_done() + + async def _dispatch(self, event: Event) -> None: + for handler in self._handlers_for(type(event)): + try: + result = handler(event) + if inspect.isawaitable(result): + await result + except asyncio.CancelledError: + raise + except Exception: + _log.exception("Handler %s failed on %r", _name(handler), event) + + def _handlers_for(self, event_type: type[Event]) -> tuple[Handler[Any], ...]: + cached = self._resolved.get(event_type) + if cached is None: + matched: list[Handler[Any]] = [] + for klass in event_type.__mro__: + if klass is object: + continue + matched.extend(self._handlers.get(klass, ())) + cached = tuple(matched) + self._resolved[event_type] = cached + # Wildcards are not cached: they are appended last and change rarely. + return cached + tuple(self._wildcard) + + +def _name(handler: Handler[Any]) -> str: + return getattr(handler, "__qualname__", repr(handler)) diff --git a/python-backend/musicmouse/clock.py b/python-backend/musicmouse/clock.py new file mode 100644 index 0000000..626ff17 --- /dev/null +++ b/python-backend/musicmouse/clock.py @@ -0,0 +1,94 @@ +"""Time, behind a protocol. + +Anything that waits takes a :class:`Clock` instead of calling :func:`asyncio.sleep` +directly. Under :class:`RealClock` a scenario runs in real time; under +:class:`FakeClock` the identical scenario runs in microseconds, which is what makes +``wait 1s`` affordable inside the test suite. +""" + +from __future__ import annotations + +import asyncio +import heapq +import itertools +import time +from collections.abc import Awaitable, Callable +from typing import Protocol + +__all__ = ["Clock", "FakeClock", "RealClock"] + + +class Clock(Protocol): + def now(self) -> float: + """Monotonic seconds. Only differences are meaningful.""" + ... + + async def sleep(self, seconds: float) -> None: + """Suspend the calling task for ``seconds``.""" + ... + + async def advance(self, seconds: float) -> None: + """Let ``seconds`` pass, from the driver's point of view.""" + ... + + +class RealClock: + def now(self) -> float: + return time.monotonic() + + async def sleep(self, seconds: float) -> None: + await asyncio.sleep(seconds) + + async def advance(self, seconds: float) -> None: + await asyncio.sleep(seconds) + + +class FakeClock: + """Virtual time. + + ``sleep()`` parks the caller until ``advance()`` moves time past its deadline. + ``idle`` is awaited after each wake-up so that whatever the woken task emitted has + been fully handled before virtual time moves on - pass ``EventBus.drain``. + """ + + def __init__(self, start: float = 0.0, idle: Callable[[], Awaitable[None]] | None = None): + self._now = start + self._idle = idle + self._counter = itertools.count() + self._sleepers: list[tuple[float, int, asyncio.Future[None]]] = [] + + def now(self) -> float: + return self._now + + async def sleep(self, seconds: float) -> None: + if seconds <= 0: + await self._settle() + return + future: asyncio.Future[None] = asyncio.get_running_loop().create_future() + heapq.heappush(self._sleepers, (self._now + seconds, next(self._counter), future)) + await future + + async def advance(self, seconds: float) -> None: + # Settle first: a task created but not yet started has not registered its + # sleep, and would otherwise have its deadline computed from the new time. + await self._settle() + target = self._now + max(0.0, seconds) + while self._sleepers and self._sleepers[0][0] <= target: + deadline, _, future = heapq.heappop(self._sleepers) + self._now = max(self._now, deadline) + if not future.done(): + future.set_result(None) + await self._settle() + self._now = target + await self._settle() + + @property + def pending_timers(self) -> int: + return len(self._sleepers) + + async def _settle(self) -> None: + # Give woken tasks a chance to run, then let their events be handled. + await asyncio.sleep(0) + if self._idle is not None: + await self._idle() + await asyncio.sleep(0) diff --git a/python-backend/musicmouse/color.py b/python-backend/musicmouse/color.py new file mode 100644 index 0000000..24cfb0c --- /dev/null +++ b/python-backend/musicmouse/color.py @@ -0,0 +1,128 @@ +"""Colour types shared by the LED wire format and the config schema. + +Kept separate from :mod:`musicmouse.devices.effects` so that :mod:`musicmouse.config` +can validate colours without importing anything device-related. +""" + +from __future__ import annotations + +import colorsys +import struct +from dataclasses import dataclass + +__all__ = ["ColorHSV", "ColorRGBW", "parse_color"] + + +@dataclass(frozen=True, slots=True) +class ColorRGBW: + """An RGBW colour with all channels normalised to ``0.0 .. 1.0``.""" + + r: float + g: float + b: float + w: float + + def __repr__(self) -> str: + return f"#({self.r}, {self.g}, {self.b}, {self.w})" + + @property + def is_valid(self) -> bool: + return all(0 <= v <= 1 for v in (self.r, self.g, self.b, self.w)) + + def as_bytes(self) -> bytes: + if not self.is_valid: + raise ValueError(f"Channel values must be within 0..1, got {self!r}") + return struct.pack( + " ColorRGBW: + r, g, b, w = struct.unpack(" ColorRGBW: + if not 0 <= scale <= 1: + raise ValueError(f"Scale must be within 0..1, got {scale}") + return ColorRGBW(self.r * scale, self.g * scale, self.b * scale, self.w * scale) + + def without_white_channel(self) -> ColorRGBW: + """Fold the white channel into RGB, for strips driven without a W channel.""" + r, g, b = (min(1.0, c + self.w) for c in (self.r, self.g, self.b)) + return ColorRGBW(r, g, b, 0) + + +@dataclass(frozen=True, slots=True) +class ColorHSV: + """Hue in degrees (``0..360``), saturation ``0..1``, value ``0..2``.""" + + h: float + s: float + v: float + + def __repr__(self) -> str: + return f"ColorHSV({self.h}, {self.s}, {self.v})" + + @staticmethod + def from_rgb(rgb: ColorRGBW) -> ColorHSV: + h, s, v = colorsys.rgb_to_hsv(rgb.r, rgb.g, rgb.b) + return ColorHSV(h * 360, s, v) + + @property + def is_valid(self) -> bool: + return 0 <= self.h <= 360 and 0 <= self.s <= 1 and 0 <= self.v <= 2 + + def as_bytes(self) -> bytes: + if not self.is_valid: + raise ValueError(f"Out-of-range HSV colour {self!r}") + return struct.pack(" ColorHSV: + return cls(*struct.unpack(" ColorRGBW: + """Parse ``"#rrggbb"`` (RGB) or ``"wNN"`` (white channel only) into a colour. + + Raises: + ValueError: with a message naming the accepted formats. + """ + if isinstance(value, ColorRGBW): + return value + if not isinstance(value, str): + raise ValueError(f"expected a colour string, got {type(value).__name__}") + + text = value.strip() + if text.startswith("#"): + digits = text[1:] + if len(digits) != 6: + raise ValueError( + f"unrecognized color format {value!r} " + f"(expected '#rrggbb' with 6 hex digits, got {len(digits)})" + ) + try: + r, g, b = (int(digits[i : i + 2], 16) / 255 for i in (0, 2, 4)) + except ValueError: + raise ValueError( + f"unrecognized color format {value!r} (expected '#rrggbb' with hex digits)" + ) from None + return ColorRGBW(r, g, b, 0) + + if text.startswith("w"): + digits = text[1:] + try: + white = int(digits, 16) + except ValueError: + raise ValueError( + f"unrecognized color format {value!r} (expected 'wNN' with hex digits)" + ) from None + if not 0 <= white <= 255: + raise ValueError(f"white value in {value!r} must be within 00..ff") + return ColorRGBW(0, 0, 0, white / 255) + + raise ValueError(f"unrecognized color format {value!r} (expected '#rrggbb' or 'wNN')") diff --git a/python-backend/musicmouse/config.py b/python-backend/musicmouse/config.py new file mode 100644 index 0000000..2412698 --- /dev/null +++ b/python-backend/musicmouse/config.py @@ -0,0 +1,256 @@ +"""Config schema and loading. + +Validation is strict on purpose: unknown keys are rejected (a typo'd setting that is +silently ignored is worse than a startup failure), and every problem in the file is +reported at once rather than one per run. +""" + +from __future__ import annotations + +import logging +from pathlib import Path +from typing import Annotated, Any, Self + +from pydantic import ( + BaseModel, + ConfigDict, + Field, + PlainValidator, + ValidationError, + ValidationInfo, + field_validator, + model_validator, +) +from ruamel.yaml import YAML +from ruamel.yaml.error import YAMLError + +from musicmouse.color import ColorRGBW, parse_color +from musicmouse.hardware import NO_FIGURE_TAG, RFID_TAG_LENGTH +from musicmouse.media import Playlist, build_playlist + +_log = logging.getLogger(__name__) + +__all__ = [ + "Config", + "ConfigError", + "FigureColors", + "FigureConfig", + "GeneralConfig", + "MqttConfig", + "build_playlists", + "format_validation_error", + "load_config", +] + +DEFAULT_AUDIO_EXTENSIONS = (".mp3", ".ogg", ".oga", ".opus", ".flac", ".wav", ".m4a", ".aac") + + +class ConfigError(Exception): + """Raised with an already human-readable, multi-line message.""" + + +def _parse_tag_id(value: Any) -> bytes: + if isinstance(value, bytes): + raw = value + else: + if not isinstance(value, str): + raise ValueError(f"expected a hex string, got {type(value).__name__}") + text = value.strip().replace(":", "").replace(" ", "") + try: + raw = bytes.fromhex(text) + except ValueError: + raise ValueError(f"{value!r} is not a valid hex string") from None + if len(raw) != RFID_TAG_LENGTH: + raise ValueError( + f"expected {RFID_TAG_LENGTH} bytes ({RFID_TAG_LENGTH * 2} hex digits), " + f"got {len(raw)} ({raw.hex()!r})" + ) + if raw == NO_FIGURE_TAG: + raise ValueError("the all-zero tag id is reserved for 'no figure on the reader'") + return raw + + +Color = Annotated[ColorRGBW, PlainValidator(parse_color)] +TagId = Annotated[bytes, PlainValidator(_parse_tag_id)] + +_COLOR_ROLES = ("primary", "secondary", "bg", "accent") + + +class _Strict(BaseModel): + model_config = ConfigDict(extra="forbid", arbitrary_types_allowed=True) + + +class FigureColors(_Strict): + """The four colours of a figure, given in config as a list of colour strings.""" + + primary: Color + secondary: Color + bg: Color + accent: Color + + @model_validator(mode="before") + @classmethod + def _accept_sequence(cls, data: Any) -> Any: + if isinstance(data, (list, tuple)): + if len(data) != len(_COLOR_ROLES): + raise ValueError( + f"expected exactly {len(_COLOR_ROLES)} colors " + f"({', '.join(_COLOR_ROLES)}), got {len(data)}" + ) + return dict(zip(_COLOR_ROLES, data, strict=True)) + return data + + +class MqttConfig(_Strict): + server: str + port: int = Field(default=1883, ge=1, le=65535) + user: str | None = None + password: str | None = None + base_topic: str = "musicmouse" + discovery_prefix: str = "homeassistant" + device_id: str = "musicmouse" + device_name: str = "Music Mouse" + reconnect_interval: float = Field(default=10.0, gt=0) + + +class GeneralConfig(_Strict): + #: Root folder holding one subfolder per figure. + figure_folder: Path + + serial_port: str = "/dev/ttyUSB0" + baudrate: int = Field(default=115200, gt=0) + reconnect_interval: float = Field(default=5.0, gt=0) + + #: ALSA output device passed to VLC, e.g. "hw:0,0"; null for VLC's default. + alsa_device: str | None = None + + mqtt: MqttConfig | None = None + + min_volume: int = Field(default=0, ge=0, le=200) + max_volume: int = Field(default=100, ge=0, le=200) + initial_volume: int = Field(default=50, ge=0, le=200) + volume_increment: int = Field(default=5, ge=1, le=100) + button_leds_brightness: float = Field(default=0.5, ge=0, le=1) + + audio_extensions: tuple[str, ...] = DEFAULT_AUDIO_EXTENSIONS + + @model_validator(mode="after") + def _check_volumes(self) -> Self: + if self.min_volume > self.max_volume: + raise ValueError( + f"min_volume ({self.min_volume}) must not exceed max_volume ({self.max_volume})" + ) + if not self.min_volume <= self.initial_volume <= self.max_volume: + raise ValueError( + f"initial_volume ({self.initial_volume}) must lie between " + f"min_volume ({self.min_volume}) and max_volume ({self.max_volume})" + ) + return self + + @field_validator("figure_folder") + @classmethod + def _resolve_figure_folder(cls, folder: Path, info: ValidationInfo) -> Path: + context = info.context or {} + base = context.get("config_dir") + if base is not None and not folder.is_absolute(): + folder = (Path(base) / folder).resolve() + if context.get("check_paths", True) and not folder.is_dir(): + raise ValueError(f"no such directory: {folder}") + return folder + + +class FigureConfig(_Strict): + #: RFID tag id as hex, e.g. "04a1b2c3d4". + id: TagId + colors: FigureColors + + +class Config(_Strict): + general: GeneralConfig + figures: dict[str, FigureConfig] = Field(min_length=1) + + @model_validator(mode="after") + def _check_unique_tag_ids(self) -> Self: + seen: dict[bytes, str] = {} + for name, figure in self.figures.items(): + if (other := seen.get(figure.id)) is not None: + raise ValueError( + f"figures {other!r} and {name!r} both use tag id {figure.id.hex()}" + ) + seen[figure.id] = name + return self + + @property + def tag_map(self) -> dict[bytes, str]: + """Tag id -> figure name, as handed to the device.""" + return {figure.id: name for name, figure in self.figures.items()} + + def folder_for(self, figure: str) -> Path: + return self.general.figure_folder / figure + + +def build_playlists(config: Config) -> dict[str, Playlist]: + """One playlist per figure, from ``/``, alphabetically.""" + return { + name: build_playlist(name, config.folder_for(name), config.general.audio_extensions) + for name in config.figures + } + + +def format_validation_error(error: ValidationError) -> str: + """Render a pydantic error as one short ``path: message`` line per problem.""" + lines: list[str] = [] + for entry in error.errors(): + location = ".".join( + f"[{part}]" if isinstance(part, int) else str(part) for part in entry["loc"] + ).replace(".[", "[") + message = entry["msg"] + for prefix in ("Value error, ", "Assertion failed, "): + message = message.removeprefix(prefix) + if entry["type"] == "extra_forbidden": + message = "unknown option (check the spelling against config.yml.example)" + lines.append(f" {location or ''}: {message}") + plural = "s" if len(lines) != 1 else "" + return f"{len(lines)} problem{plural} in the config file:\n" + "\n".join(lines) + + +def load_config(path: Path, *, check_paths: bool = True) -> Config: + """Load and validate a config file. + + Raises: + ConfigError: with a message that can be printed straight to the terminal. + """ + path = Path(path) + if path.is_dir(): + raise ConfigError( + f"{path} is a directory. Pass the config file itself, e.g. {path / 'config.yml'}" + ) + try: + text = path.read_text(encoding="utf-8") + except OSError as exc: + raise ConfigError(f"Cannot read config file {path}: {exc.strerror}") from exc + + try: + data = YAML(typ="safe").load(text) + except YAMLError as exc: + raise ConfigError(f"{path} is not valid YAML:\n {exc}") from exc + + if data is None: + raise ConfigError(f"{path} is empty") + if not isinstance(data, dict): + raise ConfigError( + f"{path} must contain a mapping at the top level, got {type(data).__name__}" + ) + + context = {"config_dir": path.parent, "check_paths": check_paths} + try: + config = Config.model_validate(data, context=context) + except ValidationError as exc: + raise ConfigError(f"{path}\n{format_validation_error(exc)}") from exc + + if check_paths: + for name in config.figures: + folder = config.folder_for(name) + if not folder.is_dir(): + _log.warning("Figure %r has no media folder at %s", name, folder) + return config diff --git a/python-backend/musicmouse/devices/__init__.py b/python-backend/musicmouse/devices/__init__.py new file mode 100644 index 0000000..708d49f --- /dev/null +++ b/python-backend/musicmouse/devices/__init__.py @@ -0,0 +1 @@ +"""Objects that own a piece of hardware: the mouse itself and the audio player.""" diff --git a/python-backend/musicmouse/devices/mouse.py b/python-backend/musicmouse/devices/mouse.py new file mode 100644 index 0000000..7e6d2f4 --- /dev/null +++ b/python-backend/musicmouse/devices/mouse.py @@ -0,0 +1,177 @@ +"""The mouse itself: the object that talks to the firmware. + +It owns the physical state - which figure is on the reader, how bright the button +backlights are, which effect each LED zone is showing - and it is the single writer to +all three LED zones. "Last write wins" is therefore a well-defined rule: whoever sets a +zone last, whether a figure animation or an MQTT command, is what the strip shows. + +Every write emits :class:`~musicmouse.events.LedEffectChanged`, so front-ends can +publish the strip's real state instead of echoing their own commands back. +""" + +from __future__ import annotations + +import logging +from dataclasses import replace + +from musicmouse.bus import EventBus +from musicmouse.devices.transport import Transport +from musicmouse.devices.wire import ( + FirmwareLog, + FrameDecoder, + ProtocolError, + UnsupportedEffectError, + encode_button_brightness, + encode_effect, +) +from musicmouse.effects import OFF, LedEffect +from musicmouse.events import ( + ActiveFigureChanged, + ConnectionChanged, + DeviceConnected, + DeviceDisconnected, + EventSource, + InputEvent, + LedEffectChanged, + RfidTokenRead, +) +from musicmouse.hardware import NO_FIGURE_TAG, Button, LedZone + +_log = logging.getLogger(__name__) + +__all__ = ["MusicMouseDevice"] + +_BACKLIT_BUTTONS = (Button.LEFT, Button.RIGHT) + + +class MusicMouseDevice: + def __init__( + self, + bus: EventBus, + transport: Transport, + tag_map: dict[bytes, str], + *, + port: str = "", + ) -> None: + self._bus = bus + self._transport = transport + self._tag_map = dict(tag_map) + self._decoder = FrameDecoder() + self.port = port + + self._active_figure: str | None = None + self._button_brightness: float = 0.0 + self._effects: dict[LedZone, LedEffect] = {} + + # -------------------------------------------------------------------- state + + @property + def active_figure(self) -> str | None: + """The figure currently on the reader, or ``None`` if there is none.""" + return self._active_figure + + @property + def button_led_brightness(self) -> float: + return self._button_brightness + + @property + def connected(self) -> bool: + return self._transport.connected + + def effect(self, zone: LedZone) -> LedEffect | None: + return self._effects.get(zone) + + # ------------------------------------------------------------------ actions + + def set_effect( + self, zone: LedZone, effect: LedEffect, *, origin: EventSource = "system" + ) -> None: + """Show ``effect`` on ``zone``. The most recent call wins.""" + try: + frame = encode_effect(zone, effect) + except UnsupportedEffectError as exc: + _log.error("%s", exc) + return + + self._effects[zone] = effect + self._transport.write(frame) + self._bus.emit( + LedEffectChanged(zone=zone, effect=effect, origin=origin, source="device") + ) + + def set_button_brightness(self, brightness: float, *, origin: EventSource = "system") -> None: + """Set both prev/next button backlights (``0..1``).""" + brightness = min(1.0, max(0.0, brightness)) + self._button_brightness = brightness + for button in _BACKLIT_BUTTONS: + self._transport.write(encode_button_brightness(button, brightness)) + _log.debug("Button backlights -> %.2f (%s)", brightness, origin) + + def all_leds_off(self, *, origin: EventSource = "system") -> None: + for zone in LedZone: + self.set_effect(zone, OFF(), origin=origin) + self.set_button_brightness(0.0, origin=origin) + + # ------------------------------------------------------------- link callbacks + + def on_connected(self) -> None: + """Re-apply memorized state, so a reconnect is invisible from the outside.""" + self._bus.emit(DeviceConnected(port=self.port, source="device")) + self._bus.emit(ConnectionChanged(target="firmware", connected=True, source="device")) + + for zone, effect in self._effects.items(): + self._transport.write(encode_effect(zone, effect)) + for button in _BACKLIT_BUTTONS: + self._transport.write(encode_button_brightness(button, self._button_brightness)) + if self._effects: + _log.info("Restored %d LED zone(s) after reconnect", len(self._effects)) + + def on_disconnected(self, reason: str = "") -> None: + self._bus.emit(DeviceDisconnected(port=self.port, reason=reason or None, source="device")) + self._bus.emit(ConnectionChanged(target="firmware", connected=False, source="device")) + + def feed(self, data: bytes) -> None: + """Hand bytes from the link to the decoder and publish what comes out.""" + self._decoder.push(data) + while True: + try: + item = self._decoder.take() + except ProtocolError as exc: + _log.warning("Discarding bad frame from firmware: %s", exc) + continue + if item is None: + return + + if isinstance(item, FirmwareLog): + if item.text: + _log.info("[firmware] %s", item.text) + else: + self._publish(item) + + # ---------------------------------------------------------------- internals + + def _publish(self, event: InputEvent) -> None: + if isinstance(event, RfidTokenRead): + self._publish_tag_read(event) + else: + self._bus.emit(event) + + def _publish_tag_read(self, event: RfidTokenRead) -> None: + if event.tag_id == NO_FIGURE_TAG: + figure, known = None, True + elif (name := self._tag_map.get(event.tag_id)) is not None: + figure, known = name, True + else: + figure, known = None, False + _log.warning("Unknown RFID tag %s - not configured as a figure", event.tag_id.hex()) + + self._bus.emit(replace(event, figure=figure, known=known)) + if not known: + # Leave the active figure alone: an unreadable tag is not a removal. + return + + previous, self._active_figure = self._active_figure, figure + if previous != figure: + self._bus.emit( + ActiveFigureChanged(figure=figure, previous=previous, source="device") + ) diff --git a/python-backend/musicmouse/devices/player.py b/python-backend/musicmouse/devices/player.py new file mode 100644 index 0000000..97226eb --- /dev/null +++ b/python-backend/musicmouse/devices/player.py @@ -0,0 +1,284 @@ +"""Audio playback, behind a protocol. + +:class:`VlcPlayer` is the only real implementation; the simulator supplies another. +libVLC fires its callbacks on its own thread, so every one of them goes through +``bus.emit()``, which hops back onto the event loop. The old code called straight into +the serial transport from that thread. +""" + +from __future__ import annotations + +import logging +from typing import TYPE_CHECKING, Any, Protocol + +from musicmouse.bus import EventBus +from musicmouse.clock import Clock, RealClock +from musicmouse.events import ( + EventSource, + PlaybackChanged, + PlaylistFinished, + TrackChanged, + VolumeChanged, +) +from musicmouse.media import Playlist, Track + +if TYPE_CHECKING: + from musicmouse.config import GeneralConfig + +_log = logging.getLogger(__name__) + +__all__ = ["Player", "PlayerBase", "VlcPlayer"] + + +class Player(Protocol): + """What reactions and front-ends are allowed to do with the audio player.""" + + @property + def is_playing(self) -> bool: ... + @property + def volume(self) -> int: ... + @property + def playlist(self) -> Playlist | None: ... + @property + def track_index(self) -> int: ... + @property + def current_track(self) -> Track | None: ... + + def set_playlist(self, playlist: Playlist) -> None: ... + def play(self) -> None: ... + def play_from_start(self) -> None: ... + def pause(self) -> None: ... + def stop(self) -> None: ... + def next_track(self) -> None: ... + def previous_track(self) -> None: ... + def set_volume(self, volume: int, *, source: EventSource = "system") -> None: ... + def change_volume(self, delta: int, *, source: EventSource = "system") -> None: ... + + async def run(self) -> None: + """Long-running task, if the implementation needs one.""" + ... + + def close(self) -> None: ... + + +class PlayerBase: + """Volume clamping, playlist bookkeeping and state events, shared by the + real and the simulated player.""" + + def __init__( + self, + bus: EventBus, + *, + min_volume: int = 0, + max_volume: int = 100, + initial_volume: int = 50, + ) -> None: + self._bus = bus + self._min_volume = min_volume + self._max_volume = max_volume + self._volume = self._clamp(initial_volume) + self._playlist: Playlist | None = None + self._index = 0 + self._playing = False + + @classmethod + def volume_kwargs(cls, config: GeneralConfig) -> dict[str, int]: + return { + "min_volume": config.min_volume, + "max_volume": config.max_volume, + "initial_volume": config.initial_volume, + } + + # -------------------------------------------------------------------- state + + @property + def is_playing(self) -> bool: + return self._playing + + @property + def volume(self) -> int: + return self._volume + + @property + def playlist(self) -> Playlist | None: + return self._playlist + + @property + def track_index(self) -> int: + return self._index + + @property + def current_track(self) -> Track | None: + if self._playlist is None or not 0 <= self._index < len(self._playlist): + return None + return self._playlist[self._index] + + # ---------------------------------------------------------------- internals + + def _clamp(self, volume: int) -> int: + # `if self._min_volume and ...` in the old code silently ignored min_volume: 0, + # which is what config.yml.example shipped with. + return max(self._min_volume, min(self._max_volume, volume)) + + def _set_playing(self, playing: bool, *, figure: str | None = None) -> None: + if playing == self._playing: + return + self._playing = playing + self._bus.emit( + PlaybackChanged( + playing=playing, figure=figure, playlist=self._playlist, source="player" + ) + ) + + def _set_index(self, index: int) -> None: + if index == self._index: + return + self._index = index + self._bus.emit(TrackChanged(index=index, track=self.current_track, source="player")) + + def _announce_volume(self, source: EventSource = "player") -> None: + self._bus.emit(VolumeChanged(volume=self._volume, source=source)) + + def _announce_playlist_finished(self) -> None: + self._bus.emit(PlaylistFinished(source="player")) + + async def run(self) -> None: # pragma: no cover - overridden where needed + return + + def close(self) -> None: # pragma: no cover - overridden where needed + return + + +class VlcPlayer(PlayerBase): + def __init__( + self, + bus: EventBus, + *, + alsa_device: str | None = None, + min_volume: int = 0, + max_volume: int = 100, + initial_volume: int = 50, + poll_interval: float = 1.0, + clock: Clock | None = None, + ) -> None: + super().__init__( + bus, min_volume=min_volume, max_volume=max_volume, initial_volume=initial_volume + ) + # Imported here rather than at module scope: python-vlc loads libvlc eagerly, + # and the simulator must run on machines without it. + import vlc + + self._vlc = vlc + self._poll_interval = poll_interval + self._clock = clock or RealClock() + + args = ["-A", "alsa", "--alsa-audio-device", alsa_device] if alsa_device else [] + self._instance = vlc.Instance(*args) + self._list_player = self._instance.media_list_player_new() + self._media_player = self._list_player.get_media_player() + self._mrl_to_index: dict[str, int] = {} + + self._attach_events() + self._media_player.audio_set_volume(self._volume) + + # ------------------------------------------------------------------ actions + + def set_playlist(self, playlist: Playlist) -> None: + media_list = self._vlc.MediaList() + self._mrl_to_index.clear() + for index, track in enumerate(playlist.tracks): + media = self._instance.media_new(str(track.path)) + media_list.add_media(media) + self._mrl_to_index[media.get_mrl()] = index + + self._list_player.set_media_list(media_list) + self._list_player.set_playback_mode(self._vlc.PlaybackMode.default) + self._playlist = playlist + self._index = 0 + _log.info("Playlist %r loaded (%d tracks)", playlist.name, len(playlist)) + + def play(self) -> None: + self._list_player.play() + + def play_from_start(self) -> None: + if self._playlist is None or not self._playlist: + _log.warning("Nothing to play: the playlist is empty") + return + self._list_player.play_item_at_index(0) + + def pause(self) -> None: + self._media_player.set_pause(1) + + def stop(self) -> None: + self._list_player.stop() + + def next_track(self) -> None: + self._list_player.next() + + def previous_track(self) -> None: + self._list_player.previous() + + def set_volume(self, volume: int, *, source: EventSource = "system") -> None: + clamped = self._clamp(volume) + if clamped == self._volume: + return + self._volume = clamped + self._media_player.audio_set_volume(clamped) + self._announce_volume(source) + + def change_volume(self, delta: int, *, source: EventSource = "system") -> None: + self.set_volume(self._volume + delta, source=source) + + async def run(self) -> None: + """Poll for state libVLC does not reliably report by event.""" + while True: + await self._clock.sleep(self._poll_interval) + try: + self._poll() + except Exception: # pragma: no cover - defensive around a C library + _log.exception("VLC poll failed") + + def close(self) -> None: + self._list_player.stop() + + # ---------------------------------------------------------------- internals + + def _poll(self) -> None: + volume = self._media_player.audio_get_volume() + if volume >= 0 and volume != self._volume: + self._volume = volume + self._announce_volume() + self._set_playing(bool(self._list_player.is_playing())) + + def _attach_events(self) -> None: + vlc = self._vlc + player_events = self._media_player.event_manager() + player_events.event_attach(vlc.EventType.MediaPlayerPlaying, self._on_playing) + player_events.event_attach(vlc.EventType.MediaPlayerPaused, self._on_stopped) + player_events.event_attach(vlc.EventType.MediaPlayerStopped, self._on_stopped) + + list_events = self._list_player.event_manager() + list_events.event_attach(vlc.EventType.MediaListPlayerPlayed, self._on_playlist_end) + list_events.event_attach(vlc.EventType.MediaListPlayerNextItemSet, self._on_next_item) + + # These four run on a libVLC thread. bus.emit() is the thread hop; nothing else + # here may touch the loop. + + def _on_playing(self, _event: Any) -> None: + self._set_playing(True) + + def _on_stopped(self, _event: Any) -> None: + self._set_playing(False) + + def _on_playlist_end(self, _event: Any) -> None: + self._set_playing(False) + self._announce_playlist_finished() + + def _on_next_item(self, event: Any) -> None: + try: + mrl = event.u.media.get_mrl() + except AttributeError: # pragma: no cover - depends on the libVLC build + return + index = self._mrl_to_index.get(mrl) + if index is not None: + self._set_index(index) diff --git a/python-backend/musicmouse/devices/serial_link.py b/python-backend/musicmouse/devices/serial_link.py new file mode 100644 index 0000000..2794a08 --- /dev/null +++ b/python-backend/musicmouse/devices/serial_link.py @@ -0,0 +1,122 @@ +"""Serial transport with reconnect. + +The old ``host_driver.py`` stopped the event loop when the USB cable was pulled and +relied on systemd to restart the whole process. Here a dropped link is just a +reconnect loop, and the device re-applies its LED state when it comes back. +""" + +from __future__ import annotations + +import asyncio +import logging +from collections.abc import Callable + +import serial_asyncio + +from musicmouse.clock import Clock, RealClock + +_log = logging.getLogger(__name__) + +__all__ = ["SerialLink"] + +_READ_CHUNK = 1024 + + +class SerialLink: + """Owns the serial port and keeps trying to hold it open.""" + + def __init__( + self, + port: str, + baudrate: int = 115200, + *, + reconnect_interval: float = 5.0, + clock: Clock | None = None, + ) -> None: + self.port = port + self.baudrate = baudrate + self._reconnect_interval = reconnect_interval + self._clock = clock or RealClock() + self._writer: asyncio.StreamWriter | None = None + + self._on_data: Callable[[bytes], None] = lambda _data: None + self._on_connect: Callable[[], None] | None = None + self._on_disconnect: Callable[[str], None] | None = None + + def attach( + self, + on_data: Callable[[bytes], None], + *, + on_connect: Callable[[], None] | None = None, + on_disconnect: Callable[[str], None] | None = None, + ) -> None: + """Wire up the device. Separate from ``__init__`` because the device needs the + link as its transport, so one of the two has to be built first.""" + self._on_data = on_data + self._on_connect = on_connect + self._on_disconnect = on_disconnect + + @property + def connected(self) -> bool: + return self._writer is not None + + def write(self, data: bytes) -> None: + writer = self._writer + if writer is None: + _log.debug("Dropping %d bytes: %s is not connected", len(data), self.port) + return + try: + writer.write(data) + except OSError as exc: # pragma: no cover - needs a real port dying mid-write + _log.warning("Write to %s failed: %s", self.port, exc) + + async def run(self) -> None: + """Connect, read until the link drops, wait, repeat. Runs until cancelled.""" + while True: + reader = await self._connect() + if reader is None: + await self._clock.sleep(self._reconnect_interval) + continue + + reason = await self._pump(reader) + + self._writer = None + _log.warning("Lost connection to %s: %s", self.port, reason) + if self._on_disconnect is not None: + self._on_disconnect(reason) + await self._clock.sleep(self._reconnect_interval) + + async def _connect(self) -> asyncio.StreamReader | None: + reader: asyncio.StreamReader + writer: asyncio.StreamWriter + try: + reader, writer = await serial_asyncio.open_serial_connection( + url=self.port, baudrate=self.baudrate + ) + except (OSError, ValueError) as exc: + _log.warning( + "Cannot open %s (%s); retrying in %gs", + self.port, + exc, + self._reconnect_interval, + ) + return None + + self._writer = writer + _log.info("Connected to firmware on %s at %d baud", self.port, self.baudrate) + if self._on_connect is not None: + self._on_connect() + return reader + + async def _pump(self, reader: asyncio.StreamReader) -> str: + try: + while True: + data = await reader.read(_READ_CHUNK) + if not data: + return "port closed" + self._on_data(data) + except asyncio.CancelledError: + self._writer = None + raise + except (OSError, asyncio.IncompleteReadError) as exc: + return str(exc) diff --git a/python-backend/musicmouse/devices/transport.py b/python-backend/musicmouse/devices/transport.py new file mode 100644 index 0000000..2936b30 --- /dev/null +++ b/python-backend/musicmouse/devices/transport.py @@ -0,0 +1,21 @@ +"""The seam between :class:`~musicmouse.devices.mouse.MusicMouseDevice` and the wire. + +Kept free of any serial import so the simulator can substitute a transport without +pulling in ``pyserial-asyncio``. +""" + +from __future__ import annotations + +from typing import Protocol + +__all__ = ["Transport"] + + +class Transport(Protocol): + def write(self, data: bytes) -> None: + """Send bytes to the firmware. Dropping them while disconnected is fine: + the device re-applies its memorized state once the link is back.""" + ... + + @property + def connected(self) -> bool: ... diff --git a/python-backend/musicmouse/devices/wire.py b/python-backend/musicmouse/devices/wire.py new file mode 100644 index 0000000..7de6570 --- /dev/null +++ b/python-backend/musicmouse/devices/wire.py @@ -0,0 +1,424 @@ +"""The serial wire protocol, as a pure codec. + +No I/O and no asyncio here, so the exact byte layout the firmware depends on can be +pinned by ``tests/test_wire.py``. Everything must stay in sync with +``esp-firmware/src/Messages.h``; that file is the authority. + +Frames in both directions are ``uint32 magic | uint8 type | uint16 payload_size`` +followed by the payload, little-endian. The firmware also writes plain +``Serial.println`` log text on the same link, so the decoder resynchronises on +newlines and on the magic token. +""" + +from __future__ import annotations + +import struct +from dataclasses import dataclass, replace +from enum import IntEnum + +from musicmouse.effects import ( + EffectAlexaSwipeConfig, + EffectCircularConfig, + EffectRandomTwoColorInterpolationConfig, + EffectReverseSwipe, + EffectStaticConfig, + EffectStaticDetailedConfig, + EffectSwipeAndChange, + LedEffect, +) +from musicmouse.events import ( + ButtonEvent, + InputEvent, + RfidTokenRead, + RotaryTurned, + TouchButtonPressed, + TouchButtonReleased, +) +from musicmouse.hardware import ( + RFID_TAG_LENGTH, + Button, + ButtonAction, + LedZone, + RotaryDirection, + TouchButton, +) + +__all__ = [ + "MAGIC_FW_TO_HOST", + "MAGIC_HOST_TO_FW", + "Decoded", + "FirmwareLog", + "FrameDecoder", + "HostCommand", + "HostFrameDecoder", + "MessageFwToHost", + "MessageHostToFw", + "ProtocolError", + "SetButtonBrightness", + "SetEffect", + "UnsupportedEffectError", + "encode_button_brightness", + "encode_effect", + "encode_input_event", +] + +MAGIC_HOST_TO_FW = 0x1D6379E3 +MAGIC_FW_TO_HOST = 0x10C65631 + +_HEADER = struct.Struct(" bytes: + return _HEADER.pack(MAGIC_HOST_TO_FW, message, len(payload)) + payload + + +def encode_effect(zone: LedZone, effect: LedEffect) -> bytes: + """Encode ``effect`` as a frame for ``zone``. + + Raises: + UnsupportedEffectError: if the firmware has no message for this combination. + """ + try: + message = _EFFECT_IDS[zone][type(effect)] + except KeyError: + supported = ", ".join(sorted(cls.__name__ for cls in _EFFECT_IDS[zone])) + raise UnsupportedEffectError( + f"{type(effect).__name__} cannot be sent to the {zone} LEDs " + f"(supported there: {supported})" + ) from None + return _frame(message, effect.as_bytes()) + + +def encode_button_brightness(button: Button, brightness: float) -> bytes: + """Encode the backlight brightness (``0..1``) of the prev/next button.""" + if not 0 <= brightness <= 1: + raise ValueError(f"brightness must be within 0..1, got {brightness}") + try: + message = _BUTTON_LED_IDS[button] + except KeyError: + raise ValueError(f"{button.slug} has no backlight") from None + return _frame(message, struct.pack(" bytes: + """Encode an event as the firmware would send it. + + The inverse of the decoder, used by the simulator so that simulated hardware + exercises the real codec rather than bypassing it. + """ + match event: + case RfidTokenRead(tag_id=tag_id): + if len(tag_id) != RFID_TAG_LENGTH: + raise ValueError(f"tag id must be {RFID_TAG_LENGTH} bytes, got {len(tag_id)}") + message, payload = MessageFwToHost.RFID_TOKEN_READ, tag_id + case RotaryTurned(position=position, increment=increment, direction=direction): + message = MessageFwToHost.ROTARY_ENCODER + payload = struct.pack(" InputEvent: + """Turn one frame payload into an event. + + Raises: + ProtocolError: on an unknown message type or a malformed payload. + """ + try: + message = MessageFwToHost(msg_type) + except ValueError: + raise ProtocolError(f"unknown message type {msg_type}") from None + + try: + match message: + case MessageFwToHost.RFID_TOKEN_READ: + if len(payload) != RFID_TAG_LENGTH: + raise ProtocolError( + f"RFID payload must be {RFID_TAG_LENGTH} bytes, got {len(payload)}" + ) + return RfidTokenRead(tag_id=bytes(payload), source="device") + case MessageFwToHost.ROTARY_ENCODER: + position, increment, direction = struct.unpack(" TouchButton: + if len(payload) != 1: + raise ProtocolError(f"touch payload must be 1 byte, got {len(payload)}") + return TouchButton(payload[0]) + + +class FrameDecoder: + """Incremental decoder for the byte stream coming from the firmware. + + Handles partial frames, several frames in one chunk, and interleaved log text. + """ + + def __init__(self) -> None: + self._buffer = bytearray() + + def push(self, data: bytes) -> None: + """Append freshly read bytes. Call :meth:`take` until it returns ``None``.""" + self._buffer += data + + @property + def buffered(self) -> int: + return len(self._buffer) + + def take(self) -> Decoded | None: + """Return the next complete item, or ``None`` if more bytes are needed. + + Raises: + ProtocolError: on a malformed frame. The offending frame has already been + consumed, so the caller can log it and call ``take()`` again - which is + why this is not a generator: an exception would close one for good. + """ + buffer = self._buffer + if not buffer: + return None + + if buffer[:4] == _MAGIC_FW_BYTES: + if len(buffer) < _HEADER_SIZE: + return None + _, msg_type, size = _HEADER.unpack_from(buffer) + end = _HEADER_SIZE + size + if len(buffer) < end: + return None + payload = bytes(buffer[_HEADER_SIZE:end]) + del buffer[:end] + return decode_message(msg_type, payload) + + if len(buffer) < 4 and _MAGIC_FW_BYTES.startswith(buffer): + return None # could still become a frame header + + # Not a frame at offset 0, so it is firmware log text. + if (newline := buffer.find(b"\n")) >= 0: + line = bytes(buffer[:newline]) + del buffer[: newline + 1] + return FirmwareLog(line.decode("utf-8", errors="replace").rstrip("\r")) + + # No newline yet: skip ahead to the next frame if one has already started. + if (start := buffer.find(_MAGIC_FW_BYTES)) > 0: + skipped = bytes(buffer[:start]) + del buffer[:start] + return FirmwareLog(skipped.decode("utf-8", errors="replace").rstrip("\r")) + + if len(buffer) > _MAX_BUFFER: + # Nothing recognisable and no end in sight - keep only what could still be + # the beginning of a magic token straddling the next chunk. + del buffer[: -len(_MAGIC_FW_BYTES) + 1] + return None + + +def with_figure(event: RfidTokenRead, figure: str | None, *, known: bool) -> RfidTokenRead: + """Attach a resolved figure name to a decoded tag read.""" + return replace(event, figure=figure, known=known) + + +# ------------------------------------------------- host -> firmware, read back +# +# Nothing in the running app needs this direction decoded - the firmware does that. +# The simulator uses it to report what the real device would have been told, which +# also means simulated hardware exercises the encoders rather than bypassing them. + + +@dataclass(frozen=True, slots=True) +class SetEffect: + zone: LedZone + effect: LedEffect + + def __repr__(self) -> str: + return f"{self.zone} <- {self.effect}" + + +@dataclass(frozen=True, slots=True) +class SetButtonBrightness: + button: Button + brightness: float + + def __repr__(self) -> str: + return f"{self.button.slug} backlight <- {self.brightness:.2f}" + + +type HostCommand = SetEffect | SetButtonBrightness + +_ID_TO_EFFECT: dict[int, tuple[LedZone, type[LedEffect]]] = { + message: (zone, effect_cls) + for zone, effects in _EFFECT_IDS.items() + for effect_cls, message in effects.items() +} +_ID_TO_BUTTON: dict[int, Button] = { + message: button for button, message in _BUTTON_LED_IDS.items() +} +_MAGIC_HOST_BYTES = struct.pack(" HostCommand: + if (target := _ID_TO_EFFECT.get(msg_type)) is not None: + zone, effect_cls = target + try: + return SetEffect(zone=zone, effect=effect_cls.from_bytes(payload)) + except (ValueError, struct.error) as exc: + raise ProtocolError(f"malformed {effect_cls.__name__} payload: {exc}") from exc + if (button := _ID_TO_BUTTON.get(msg_type)) is not None: + try: + (brightness,) = struct.unpack(" firmware direction.""" + + def __init__(self) -> None: + self._buffer = bytearray() + + def push(self, data: bytes) -> None: + self._buffer += data + + def take(self) -> HostCommand | None: + """Next command, or ``None`` if more bytes are needed. See :meth:`FrameDecoder.take`.""" + if len(self._buffer) < _HEADER_SIZE: + return None + magic, msg_type, size = _HEADER.unpack_from(self._buffer) + if magic != MAGIC_HOST_TO_FW: + self._buffer.clear() + raise ProtocolError(f"expected host-to-firmware magic, got {magic:#010x}") + end = _HEADER_SIZE + size + if len(self._buffer) < end: + return None + payload = bytes(self._buffer[_HEADER_SIZE:end]) + del self._buffer[:end] + return decode_host_command(msg_type, payload) diff --git a/python-backend/musicmouse/effects.py b/python-backend/musicmouse/effects.py new file mode 100644 index 0000000..a7a35e7 --- /dev/null +++ b/python-backend/musicmouse/effects.py @@ -0,0 +1,279 @@ +"""LED effect configurations and their firmware wire encoding. + +Each dataclass mirrors a ``struct`` in ``esp-firmware/lib/ledtl/effects/`` and its +``as_bytes()`` is the byte-for-byte payload the firmware expects. ``tests/test_effects.py`` +pins those layouts. + +``from_bytes()`` is the inverse. Nothing in the running app decodes effects - the +firmware does that - but the simulator uses it to show what the real device would have +been told, which also keeps the encoders honest. + +Formerly ``led_cmds.py``. ``EffectReverseSwipe``'s fields were camelCase there, copied +from the C++ side; they are snake_case here like every other effect. +""" + +from __future__ import annotations + +import struct +from dataclasses import dataclass, field +from typing import ClassVar, Protocol, Self + +from musicmouse.color import ColorHSV, ColorRGBW + +__all__ = [ + "OFF", + "EffectAlexaSwipeConfig", + "EffectCircularConfig", + "EffectRandomTwoColorInterpolationConfig", + "EffectReverseSwipe", + "EffectStaticConfig", + "EffectStaticDetailedConfig", + "EffectSwipeAndChange", + "LedEffect", +] + +_RGBW_SIZE = 4 +_HSV_SIZE = 12 + +_STATIC = struct.Struct(" bytes: ... + + @classmethod + def from_bytes(cls, data: bytes) -> Self: ... + + +def _check_size(data: bytes, expected: int, name: str) -> None: + if len(data) != expected: + raise ValueError(f"{name} payload must be {expected} bytes, got {len(data)}") + + +@dataclass +class EffectStaticConfig: + color: ColorRGBW + begin: int = 0 + end: int = 0 + + SIZE: ClassVar[int] = _RGBW_SIZE + _STATIC.size + + def as_bytes(self) -> bytes: + return self.color.as_bytes() + _STATIC.pack(self.begin, self.end) + + @classmethod + def from_bytes(cls, data: bytes) -> EffectStaticConfig: + _check_size(data, cls.SIZE, cls.__name__) + begin, end = _STATIC.unpack(data[_RGBW_SIZE:]) + return cls(ColorRGBW.from_bytes(data[:_RGBW_SIZE]), begin, end) + + def __repr__(self) -> str: + return f"Static({self.color}, begin={self.begin}, end={self.end})" + + +@dataclass +class EffectStaticDetailedConfig: + color: ColorRGBW + increment: int = 1 + begin: float = 0.0 + end: float = 1.0 + transition_time_in_ms: float = 500 + + SIZE: ClassVar[int] = _RGBW_SIZE + _STATIC_DETAILED.size + + def as_bytes(self) -> bytes: + return self.color.as_bytes() + _STATIC_DETAILED.pack( + self.increment, self.begin, self.end, self.transition_time_in_ms + ) + + @classmethod + def from_bytes(cls, data: bytes) -> EffectStaticDetailedConfig: + _check_size(data, cls.SIZE, cls.__name__) + increment, begin, end, transition = _STATIC_DETAILED.unpack(data[_RGBW_SIZE:]) + return cls(ColorRGBW.from_bytes(data[:_RGBW_SIZE]), increment, begin, end, transition) + + def __repr__(self) -> str: + return ( + f"StaticDetailed({self.color}, begin={self.begin}, end={self.end}, " + f"increment={self.increment}, transition={self.transition_time_in_ms}ms)" + ) + + +@dataclass +class EffectAlexaSwipeConfig: + primary_color_width: float = 20 # degrees + transition_width: float = 30 # degrees + swipe_speed: float = 2 * 360 # degrees per second + bell_curve_width_in_leds: float = 3 + start_position: float = 180 # degrees + forward: bool = True + primary_color: ColorRGBW = field(default_factory=lambda: ColorRGBW(0, 0, 1, 0)) + secondary_color: ColorRGBW = field(default_factory=lambda: ColorRGBW(0, 200 / 255, 1, 0)) + + SIZE: ClassVar[int] = _ALEXA_SWIPE.size + 2 * _RGBW_SIZE + + def as_bytes(self) -> bytes: + return ( + _ALEXA_SWIPE.pack( + self.primary_color_width, + self.transition_width, + self.swipe_speed, + self.bell_curve_width_in_leds, + self.start_position, + self.forward, + ) + + self.primary_color.as_bytes() + + self.secondary_color.as_bytes() + ) + + @classmethod + def from_bytes(cls, data: bytes) -> EffectAlexaSwipeConfig: + _check_size(data, cls.SIZE, cls.__name__) + primary_width, transition, speed, bell_width, start, forward = _ALEXA_SWIPE.unpack_from( + data + ) + colors = data[_ALEXA_SWIPE.size :] + return cls( + primary_color_width=primary_width, + transition_width=transition, + swipe_speed=speed, + bell_curve_width_in_leds=bell_width, + start_position=start, + forward=forward, + primary_color=ColorRGBW.from_bytes(colors[:_RGBW_SIZE]), + secondary_color=ColorRGBW.from_bytes(colors[_RGBW_SIZE:]), + ) + + def __repr__(self) -> str: + return f"AlexaSwipe({self.primary_color} -> {self.secondary_color})" + + +@dataclass +class EffectRandomTwoColorInterpolationConfig: + cycle_durations_ms: int = 6000 + start_with_existing: bool = True + num_segments: int = 3 + hue1_random: bool = False + hue2_random: bool = False + color1: ColorHSV | ColorRGBW = field(default_factory=lambda: ColorHSV(240, 1, 1)) + color2: ColorHSV | ColorRGBW = field(default_factory=lambda: ColorHSV(192, 1, 1)) + + SIZE: ClassVar[int] = _TWO_COLOR.size + 2 * _HSV_SIZE + + def as_bytes(self) -> bytes: + c1 = ColorHSV.from_rgb(self.color1) if isinstance(self.color1, ColorRGBW) else self.color1 + c2 = ColorHSV.from_rgb(self.color2) if isinstance(self.color2, ColorRGBW) else self.color2 + return ( + _TWO_COLOR.pack( + self.cycle_durations_ms, + self.start_with_existing, + self.num_segments, + self.hue1_random, + self.hue2_random, + ) + + c1.as_bytes() + + c2.as_bytes() + ) + + @classmethod + def from_bytes(cls, data: bytes) -> EffectRandomTwoColorInterpolationConfig: + _check_size(data, cls.SIZE, cls.__name__) + cycle, start_with_existing, segments, hue1, hue2 = _TWO_COLOR.unpack_from(data) + colors = data[_TWO_COLOR.size :] + return cls( + cycle_durations_ms=cycle, + start_with_existing=start_with_existing, + num_segments=segments, + hue1_random=hue1, + hue2_random=hue2, + color1=ColorHSV.from_bytes(colors[:_HSV_SIZE]), + color2=ColorHSV.from_bytes(colors[_HSV_SIZE:]), + ) + + def __repr__(self) -> str: + return f"TwoColor({self.color1}, {self.color2}, segments={self.num_segments})" + + +@dataclass +class EffectCircularConfig: + speed: float = 360 # degrees per second + width: float = 180 # degrees + color: ColorRGBW = field(default_factory=lambda: ColorRGBW(0, 0, 1, 0)) + + SIZE: ClassVar[int] = _CIRCULAR.size + _RGBW_SIZE + + def as_bytes(self) -> bytes: + return _CIRCULAR.pack(self.speed, self.width) + self.color.as_bytes() + + @classmethod + def from_bytes(cls, data: bytes) -> EffectCircularConfig: + _check_size(data, cls.SIZE, cls.__name__) + speed, width = _CIRCULAR.unpack_from(data) + return cls(speed, width, ColorRGBW.from_bytes(data[_CIRCULAR.size :])) + + def __repr__(self) -> str: + return f"Circular({self.color}, speed={self.speed}, width={self.width})" + + +@dataclass +class EffectSwipeAndChange: + swipe: EffectAlexaSwipeConfig = field(default_factory=EffectAlexaSwipeConfig) + change: EffectRandomTwoColorInterpolationConfig = field( + default_factory=EffectRandomTwoColorInterpolationConfig + ) + + SIZE: ClassVar[int] = EffectAlexaSwipeConfig.SIZE + EffectRandomTwoColorInterpolationConfig.SIZE + + def as_bytes(self) -> bytes: + return self.swipe.as_bytes() + self.change.as_bytes() + + @classmethod + def from_bytes(cls, data: bytes) -> EffectSwipeAndChange: + _check_size(data, cls.SIZE, cls.__name__) + split = EffectAlexaSwipeConfig.SIZE + return cls( + EffectAlexaSwipeConfig.from_bytes(data[:split]), + EffectRandomTwoColorInterpolationConfig.from_bytes(data[split:]), + ) + + def __repr__(self) -> str: + return f"SwipeAndChange({self.swipe}, {self.change})" + + +@dataclass +class EffectReverseSwipe: + swipe_speed: float = 2 * 360 + bell_curve_width_in_leds: float = 3 + start_position: float = 180 + + SIZE: ClassVar[int] = _REVERSE_SWIPE.size + + def as_bytes(self) -> bytes: + return _REVERSE_SWIPE.pack( + self.swipe_speed, self.bell_curve_width_in_leds, self.start_position + ) + + @classmethod + def from_bytes(cls, data: bytes) -> EffectReverseSwipe: + _check_size(data, cls.SIZE, cls.__name__) + return cls(*_REVERSE_SWIPE.unpack(data)) + + def __repr__(self) -> str: + return ( + f"ReverseSwipe(speed={self.swipe_speed}, " + f"width={self.bell_curve_width_in_leds}, start={self.start_position})" + ) + + +def OFF() -> EffectStaticConfig: # noqa: N802 - reads as a constant at call sites + """A fresh "all LEDs off" effect.""" + return EffectStaticConfig(ColorRGBW(0, 0, 0, 0)) diff --git a/python-backend/musicmouse/events.py b/python-backend/musicmouse/events.py new file mode 100644 index 0000000..c6583f8 --- /dev/null +++ b/python-backend/musicmouse/events.py @@ -0,0 +1,247 @@ +"""The event vocabulary. + +Three flavours, distinguished by base class: + +* :class:`InputEvent` - something happened (hardware, player). +* :class:`IntentEvent` - something was requested (button, MQTT, web, simulator). +* :class:`StateEvent` - something changed. + +The intent layer is what lets several front-ends drive the same behaviour: a button +press, an MQTT command and a future web request all emit ``NextTrackRequested`` and a +single reaction acts on it. +""" + +from __future__ import annotations + +import time +from dataclasses import dataclass, field +from typing import Literal + +from musicmouse.effects import LedEffect +from musicmouse.hardware import Button, ButtonAction, LedZone, RotaryDirection, TouchButton +from musicmouse.media import Playlist, Track + +__all__ = [ + "ActiveFigureChanged", + "ButtonEvent", + "ConnectionChanged", + "DeviceConnected", + "DeviceDisconnected", + "Event", + "EventSource", + "InputEvent", + "IntentEvent", + "LedEffectChanged", + "LedEffectRequested", + "NextTrackRequested", + "PauseRequested", + "PlayFigureRequested", + "PlayRequested", + "PlaybackChanged", + "PlaylistFinished", + "PrevTrackRequested", + "RfidTokenRead", + "RotaryTurned", + "SetVolumeRequested", + "StateEvent", + "TouchButtonPressed", + "TouchButtonReleased", + "TrackChanged", + "VolumeChangeRequested", + "VolumeChanged", +] + +type EventSource = Literal["device", "player", "mqtt", "web", "simulator", "system"] + + +@dataclass(frozen=True, slots=True, kw_only=True) +class Event: + """Base for every event. Keyword-only so subclasses can add required fields.""" + + source: EventSource = "system" + timestamp: float = field(default_factory=time.monotonic, compare=False) + + +@dataclass(frozen=True, slots=True, kw_only=True) +class InputEvent(Event): + """Something happened out in the world.""" + + +@dataclass(frozen=True, slots=True, kw_only=True) +class IntentEvent(Event): + """Something was requested. May come from any front-end.""" + + +@dataclass(frozen=True, slots=True, kw_only=True) +class StateEvent(Event): + """Something changed. Front-ends mirror these outwards.""" + + +# --------------------------------------------------------------------------- input + + +@dataclass(frozen=True, slots=True, kw_only=True) +class RfidTokenRead(InputEvent): + """A tag was read. ``figure`` is ``None`` for the all-zero "removed" tag and for + tags that match no configured figure (``known`` tells the two apart).""" + + tag_id: bytes + figure: str | None = None + known: bool = True + + def __repr__(self) -> str: + tag = self.tag_id.hex() + return f"RfidTokenRead({tag}, figure={self.figure!r})" + + +@dataclass(frozen=True, slots=True, kw_only=True) +class ButtonEvent(InputEvent): + button: Button + action: ButtonAction + + def __repr__(self) -> str: + return f"ButtonEvent({self.button.slug}, {self.action.slug})" + + +@dataclass(frozen=True, slots=True, kw_only=True) +class TouchButtonPressed(InputEvent): + button: TouchButton + + def __repr__(self) -> str: + return f"TouchButtonPressed({self.button.slug})" + + +@dataclass(frozen=True, slots=True, kw_only=True) +class TouchButtonReleased(InputEvent): + button: TouchButton + + def __repr__(self) -> str: + return f"TouchButtonReleased({self.button.slug})" + + +@dataclass(frozen=True, slots=True, kw_only=True) +class RotaryTurned(InputEvent): + position: int + increment: int + direction: RotaryDirection + + def __repr__(self) -> str: + return f"RotaryTurned(pos={self.position}, incr={self.increment}, {self.direction.name})" + + +@dataclass(frozen=True, slots=True, kw_only=True) +class PlaylistFinished(InputEvent): + """The player reached the end of the playlist.""" + + figure: str | None = None + + +@dataclass(frozen=True, slots=True, kw_only=True) +class DeviceConnected(InputEvent): + port: str + + +@dataclass(frozen=True, slots=True, kw_only=True) +class DeviceDisconnected(InputEvent): + port: str + reason: str | None = None + + +# -------------------------------------------------------------------------- intents + + +@dataclass(frozen=True, slots=True, kw_only=True) +class PlayRequested(IntentEvent): + pass + + +@dataclass(frozen=True, slots=True, kw_only=True) +class PauseRequested(IntentEvent): + pass + + +@dataclass(frozen=True, slots=True, kw_only=True) +class NextTrackRequested(IntentEvent): + pass + + +@dataclass(frozen=True, slots=True, kw_only=True) +class PrevTrackRequested(IntentEvent): + pass + + +@dataclass(frozen=True, slots=True, kw_only=True) +class PlayFigureRequested(IntentEvent): + """Start a figure's playlist. ``restart=False`` resumes where it left off.""" + + figure: str + restart: bool = True + + +@dataclass(frozen=True, slots=True, kw_only=True) +class VolumeChangeRequested(IntentEvent): + delta: int + + +@dataclass(frozen=True, slots=True, kw_only=True) +class SetVolumeRequested(IntentEvent): + volume: int + + +@dataclass(frozen=True, slots=True, kw_only=True) +class LedEffectRequested(IntentEvent): + zone: LedZone + effect: LedEffect + + def __repr__(self) -> str: + return f"LedEffectRequested({self.zone}, {self.effect}, from={self.source})" + + +# ---------------------------------------------------------------------------- state + + +@dataclass(frozen=True, slots=True, kw_only=True) +class PlaybackChanged(StateEvent): + playing: bool + figure: str | None = None + playlist: Playlist | None = None + + +@dataclass(frozen=True, slots=True, kw_only=True) +class TrackChanged(StateEvent): + index: int + track: Track | None + + +@dataclass(frozen=True, slots=True, kw_only=True) +class VolumeChanged(StateEvent): + volume: int + + +@dataclass(frozen=True, slots=True, kw_only=True) +class ActiveFigureChanged(StateEvent): + figure: str | None + previous: str | None = None + + +@dataclass(frozen=True, slots=True, kw_only=True) +class LedEffectChanged(StateEvent): + """Emitted on *every* write to an LED zone, whatever caused it. + + Front-ends publish zone state from this rather than echoing their own commands, + so Home Assistant keeps showing the strip's real state when a figure animation + overrides an MQTT-set colour. + """ + + zone: LedZone + effect: LedEffect + origin: EventSource + + def __repr__(self) -> str: + return f"LedEffectChanged({self.zone}, {self.effect}, origin={self.origin})" + + +@dataclass(frozen=True, slots=True, kw_only=True) +class ConnectionChanged(StateEvent): + target: Literal["firmware", "mqtt"] + connected: bool diff --git a/python-backend/musicmouse/hardware.py b/python-backend/musicmouse/hardware.py new file mode 100644 index 0000000..15354c9 --- /dev/null +++ b/python-backend/musicmouse/hardware.py @@ -0,0 +1,90 @@ +"""Hardware vocabulary: the enums and geometry the firmware and the host agree on. + +The integer values of :class:`Button`, :class:`ButtonAction`, :class:`TouchButton` and +:class:`RotaryDirection` are wire values and must match ``esp-firmware/src/Messages.h``. +""" + +from __future__ import annotations + +from enum import IntEnum, StrEnum + +__all__ = [ + "MOUSE_LED_RANGES", + "NO_FIGURE_TAG", + "RFID_TAG_LENGTH", + "Button", + "ButtonAction", + "LedZone", + "RotaryDirection", + "TouchButton", +] + +#: Length of an RFID tag id in bytes (``uint8_t tagId[5]`` in ``Messages.h``). +RFID_TAG_LENGTH = 5 + +#: The all-zero tag the firmware reports when nothing is on the reader. +NO_FIGURE_TAG = bytes(RFID_TAG_LENGTH) + + +class Button(IntEnum): + """The three physical push buttons.""" + + LEFT = 1 + RIGHT = 2 + ROTARY = 3 + + @property + def slug(self) -> str: + return self.name.lower() + + +class ButtonAction(IntEnum): + """AceButton event types, as reported by the firmware.""" + + PRESSED = 0 + RELEASED = 1 + CLICKED = 2 + DOUBLE_CLICKED = 3 + LONG_PRESSED = 4 + REPEAT_PRESSED = 5 + LONG_RELEASED = 6 + + @property + def slug(self) -> str: + return self.name.lower() + + +class TouchButton(IntEnum): + """The four capacitive touch areas on the mouse body.""" + + LEFT_FOOT = 0 + RIGHT_FOOT = 1 + LEFT_EAR = 2 + RIGHT_EAR = 3 + + @property + def slug(self) -> str: + return self.name.lower() + + +class RotaryDirection(IntEnum): + NONE = 0 + DOWN = 1 + UP = 2 + + +class LedZone(StrEnum): + """The three independently addressable LED strips.""" + + RING = "ring" + MOUSE = "mouse" + SHELF = "shelf" + + +#: LED index span (begin, end) lit up when a given touch area is touched. +MOUSE_LED_RANGES: dict[TouchButton, tuple[int, int]] = { + TouchButton.RIGHT_FOOT: (0, 6), + TouchButton.LEFT_FOOT: (6, 12), + TouchButton.LEFT_EAR: (12, 28), + TouchButton.RIGHT_EAR: (28, 45), +} diff --git a/python-backend/musicmouse/media.py b/python-backend/musicmouse/media.py new file mode 100644 index 0000000..8a96b2a --- /dev/null +++ b/python-backend/musicmouse/media.py @@ -0,0 +1,65 @@ +"""Playlist model. + +Deliberately a real type rather than a bare ``list[str]``: it is what a future +browse API would serve, and it keeps track metadata in one place. +""" + +from __future__ import annotations + +import logging +from dataclasses import dataclass +from pathlib import Path + +_log = logging.getLogger(__name__) + +__all__ = ["Playlist", "Track", "build_playlist"] + + +@dataclass(frozen=True, slots=True) +class Track: + path: Path + + @property + def title(self) -> str: + return self.path.stem + + def __repr__(self) -> str: + return f"Track({self.title!r})" + + +@dataclass(frozen=True, slots=True) +class Playlist: + name: str + tracks: tuple[Track, ...] + + def __len__(self) -> int: + return len(self.tracks) + + def __bool__(self) -> bool: + return bool(self.tracks) + + def __getitem__(self, index: int) -> Track: + return self.tracks[index] + + def __repr__(self) -> str: + return f"Playlist({self.name!r}, {len(self.tracks)} tracks)" + + +def build_playlist(name: str, folder: Path, extensions: tuple[str, ...]) -> Playlist: + """Collect ``folder``'s audio files into a playlist, ordered alphabetically. + + A missing or empty folder yields an empty playlist and a warning rather than an + error: an unfinished playlist should not stop the mouse from booting. + """ + if not folder.is_dir(): + _log.warning("Figure %r: no media folder at %s", name, folder) + return Playlist(name=name, tracks=()) + + suffixes = {ext.lower() for ext in extensions} + paths = sorted( + (p for p in folder.iterdir() if p.is_file() and p.suffix.lower() in suffixes), + key=lambda p: p.name, + ) + if not paths: + _log.warning("Figure %r: no audio files in %s", name, folder) + return Playlist(name=name, tracks=tuple(Track(p) for p in paths)) diff --git a/python-backend/musicmouse/reactions/__init__.py b/python-backend/musicmouse/reactions/__init__.py new file mode 100644 index 0000000..949a11c --- /dev/null +++ b/python-backend/musicmouse/reactions/__init__.py @@ -0,0 +1,17 @@ +"""Reactions: the policy layer. + +Every behaviour the mouse has lives here as a small function registered against an +event. Nothing else in the codebase decides what should happen - the devices only +report and obey, and the services only translate. + +Importing this package is what makes the reactions exist; ``register_all`` binds them +to a bus and an :class:`~musicmouse.app.App`. + +Publishing button presses to Home Assistant has no reaction of its own: the MQTT +service subscribes to those events directly. +""" + +from musicmouse.reactions import lighting, playback # noqa: F401 (import = register) +from musicmouse.reactions.registry import Reaction, on, register_all, registered + +__all__ = ["Reaction", "on", "register_all", "registered"] diff --git a/python-backend/musicmouse/reactions/lighting.py b/python-backend/musicmouse/reactions/lighting.py new file mode 100644 index 0000000..a117abd --- /dev/null +++ b/python-backend/musicmouse/reactions/lighting.py @@ -0,0 +1,136 @@ +"""What the LEDs do. + +These write straight to the device rather than going through intents: unlike +play/pause, nothing else in the system asks for "the figure's start animation". + +Figure animations drive the shelf strip as well as the ring, which means they compete +with Home Assistant for it. That is intentional - the device is the single writer and +the most recent effect wins, whichever side it came from. +""" + +from __future__ import annotations + +import logging +from copy import deepcopy + +from musicmouse.app import App +from musicmouse.color import ColorRGBW +from musicmouse.config import FigureColors +from musicmouse.effects import ( + EffectRandomTwoColorInterpolationConfig, + EffectReverseSwipe, + EffectStaticConfig, + EffectSwipeAndChange, +) +from musicmouse.events import ( + ActiveFigureChanged, + PlaylistFinished, + TouchButtonPressed, + TouchButtonReleased, +) +from musicmouse.hardware import MOUSE_LED_RANGES, LedZone, TouchButton +from musicmouse.reactions.registry import on + +_log = logging.getLogger(__name__) + +OFF_COLOR = ColorRGBW(0, 0, 0, 0) + +#: The mouse strip starts 6 LEDs into its 45, so its swipe is offset to match the ring. +MOUSE_SWIPE_START_DEGREES = 6 / 45 * 360 +MOUSE_BELL_CURVE_WIDTH = 16 +SWIPE_SPEED = 180 + + +@on(ActiveFigureChanged) +def figure_placed_or_removed(event: ActiveFigureChanged, app: App) -> None: + if event.figure is None: + off_animation(app) + else: + start_animation(app, app.colors(event.figure)) + app.mouse.set_button_brightness( + app.config.general.button_leds_brightness, origin="device" + ) + + +@on(PlaylistFinished) +def playlist_finished(_event: PlaylistFinished, app: App) -> None: + off_animation(app) + + +@on(TouchButtonPressed) +def touch_pressed(event: TouchButtonPressed, app: App) -> None: + colors = _active_colors(app) + if colors is None: + return + app.mouse.set_effect( + LedZone.MOUSE, _range_effect(event.button, colors.accent), origin="device" + ) + + +@on(TouchButtonReleased) +def touch_released(event: TouchButtonReleased, app: App) -> None: + colors = _active_colors(app) + # Clear the touched area first, then restore the whole-body effect over it. + app.mouse.set_effect( + LedZone.MOUSE, + _range_effect(event.button, colors.primary if colors else OFF_COLOR), + origin="device", + ) + if colors is None: + return + + app.mouse.set_effect( + LedZone.MOUSE, + EffectRandomTwoColorInterpolationConfig( + color1=colors.primary, color2=colors.secondary, start_with_existing=True + ), + origin="device", + ) + + +# ------------------------------------------------------------------- animations + + +def start_animation(app: App, colors: FigureColors) -> None: + ring = EffectSwipeAndChange() + ring.swipe.primary_color = colors.primary + ring.swipe.secondary_color = colors.secondary + ring.swipe.swipe_speed = SWIPE_SPEED + ring.change.color1 = colors.primary + ring.change.color2 = colors.secondary + + app.mouse.set_effect(LedZone.RING, ring, origin="device") + app.mouse.set_effect(LedZone.SHELF, deepcopy(ring), origin="device") + + mouse = deepcopy(ring) + mouse.swipe.start_position = MOUSE_SWIPE_START_DEGREES + mouse.swipe.bell_curve_width_in_leds = MOUSE_BELL_CURVE_WIDTH + app.mouse.set_effect(LedZone.MOUSE, mouse, origin="device") + + +def off_animation(app: App) -> None: + _log.info("Running off animation") + app.mouse.set_effect(LedZone.RING, EffectReverseSwipe(), origin="device") + app.mouse.set_effect(LedZone.SHELF, EffectReverseSwipe(), origin="device") + app.mouse.set_effect( + LedZone.MOUSE, + EffectReverseSwipe(start_position=MOUSE_SWIPE_START_DEGREES), + origin="device", + ) + app.mouse.set_button_brightness(0.0, origin="device") + + +# --------------------------------------------------------------------- helpers + + +def _active_colors(app: App) -> FigureColors | None: + """The current figure's colours, or ``None`` if nothing is playing.""" + figure = app.mouse.active_figure + if figure is None or not app.player.is_playing: + return None + return app.colors(figure) + + +def _range_effect(button: TouchButton, color: ColorRGBW) -> EffectStaticConfig: + begin, end = MOUSE_LED_RANGES[button] + return EffectStaticConfig(color, begin, end) diff --git a/python-backend/musicmouse/reactions/playback.py b/python-backend/musicmouse/reactions/playback.py new file mode 100644 index 0000000..114849a --- /dev/null +++ b/python-backend/musicmouse/reactions/playback.py @@ -0,0 +1,137 @@ +"""What the mouse plays, and when. + +Physical inputs are turned into *intents*, and the intents are what actually drive the +player. That indirection is the point: an MQTT command or a future web request emits +the same intent and lands in the same handler, so there is one place per behaviour. +""" + +from __future__ import annotations + +import logging + +from musicmouse.app import App +from musicmouse.events import ( + ActiveFigureChanged, + ButtonEvent, + NextTrackRequested, + PauseRequested, + PlayFigureRequested, + PlaylistFinished, + PlayRequested, + PrevTrackRequested, + RotaryTurned, + SetVolumeRequested, + VolumeChangeRequested, +) +from musicmouse.hardware import Button, ButtonAction, RotaryDirection +from musicmouse.reactions.registry import on + +_log = logging.getLogger(__name__) + + +# ------------------------------------------------------------------ figure on/off + + +@on(ActiveFigureChanged) +def figure_placed_or_removed(event: ActiveFigureChanged, app: App) -> None: + if event.figure is None: + _figure_removed(event.previous, app) + else: + app.bus.emit( + PlayFigureRequested( + figure=event.figure, + restart=app.state.last_partially_played_figure != event.figure, + source="device", + ) + ) + + +def _figure_removed(previous: str | None, app: App) -> None: + if app.player.is_playing: + app.player.pause() + # Remember where we were, so putting the same figure back resumes. + app.state.last_partially_played_figure = previous + _log.info("Figure %r removed mid-playlist", previous) + else: + app.state.last_partially_played_figure = None + + +@on(PlayFigureRequested) +def play_figure(event: PlayFigureRequested, app: App) -> None: + playlist = app.playlist(event.figure) + if playlist is None: + return + + if not event.restart and app.player.playlist is playlist: + _log.info("Resuming %r", event.figure) + app.player.play() + return + + _log.info("Starting %r from the beginning", event.figure) + app.player.set_playlist(playlist) + app.player.play_from_start() + + +@on(PlaylistFinished) +def playlist_finished(_event: PlaylistFinished, app: App) -> None: + # Nothing was left half-played, so the next placement starts from the top. + app.state.last_partially_played_figure = None + + +# ---------------------------------------------------------------- physical inputs + + +@on(ButtonEvent) +def button_pressed(event: ButtonEvent, app: App) -> None: + if event.action is not ButtonAction.PRESSED: + return + if event.button is Button.LEFT and app.player.is_playing: + app.bus.emit(PrevTrackRequested(source="device")) + elif event.button is Button.RIGHT and app.player.is_playing: + app.bus.emit(NextTrackRequested(source="device")) + # The rotary press is published to Home Assistant by the MQTT service; what it + # controls is an automation over there, not something this backend decides. + + +@on(RotaryTurned) +def rotary_turned(event: RotaryTurned, app: App) -> None: + step = app.config.general.volume_increment * abs(event.increment) + if event.direction is RotaryDirection.UP: + app.bus.emit(VolumeChangeRequested(delta=step, source="device")) + elif event.direction is RotaryDirection.DOWN: + app.bus.emit(VolumeChangeRequested(delta=-step, source="device")) + + +# ----------------------------------------------------------------------- intents + + +@on(NextTrackRequested) +def next_track(event: NextTrackRequested, app: App) -> None: + _log.debug("Next track (%s)", event.source) + app.player.next_track() + + +@on(PrevTrackRequested) +def previous_track(event: PrevTrackRequested, app: App) -> None: + _log.debug("Previous track (%s)", event.source) + app.player.previous_track() + + +@on(PlayRequested) +def play(_event: PlayRequested, app: App) -> None: + app.player.play() + + +@on(PauseRequested) +def pause(_event: PauseRequested, app: App) -> None: + app.player.pause() + + +@on(VolumeChangeRequested) +def change_volume(event: VolumeChangeRequested, app: App) -> None: + app.player.change_volume(event.delta, source=event.source) + + +@on(SetVolumeRequested) +def set_volume(event: SetVolumeRequested, app: App) -> None: + app.player.set_volume(event.volume, source=event.source) diff --git a/python-backend/musicmouse/reactions/registry.py b/python-backend/musicmouse/reactions/registry.py new file mode 100644 index 0000000..b38d1e7 --- /dev/null +++ b/python-backend/musicmouse/reactions/registry.py @@ -0,0 +1,55 @@ +"""The ``@on`` decorator and the binding step. + +Kept in its own module so the reaction modules can import ``on`` without importing the +package that imports them. +""" + +from __future__ import annotations + +import logging +from collections.abc import Callable, Coroutine +from typing import TYPE_CHECKING, Any + +from musicmouse.bus import EventBus +from musicmouse.events import Event + +if TYPE_CHECKING: + from musicmouse.app import App + +_log = logging.getLogger(__name__) + +__all__ = ["Reaction", "on", "register_all", "registered"] + +type Reaction[E: Event] = Callable[[E, "App"], Coroutine[Any, Any, None] | None] + +_REGISTRY: list[tuple[type[Event], Reaction[Any]]] = [] + + +def on[E: Event](event_type: type[E]) -> Callable[[Reaction[E]], Reaction[E]]: + """Register a reaction for ``event_type`` (and any subclass of it).""" + + def decorator(reaction: Reaction[E]) -> Reaction[E]: + _REGISTRY.append((event_type, reaction)) + return reaction + + return decorator + + +def registered() -> list[tuple[type[Event], Reaction[Any]]]: + return list(_REGISTRY) + + +def register_all(bus: EventBus, app: App) -> None: + """Subscribe every declared reaction, with ``app`` bound as its second argument.""" + for event_type, reaction in _REGISTRY: + bus.subscribe(event_type, _bind(reaction, app)) + _log.debug("Registered %d reactions", len(_REGISTRY)) + + +def _bind[E: Event](reaction: Reaction[E], app: App) -> Callable[[E], Any]: + def handler(event: E) -> Any: + return reaction(event, app) + + # Keep the reaction's name, so a failing handler is identifiable in the log. + handler.__qualname__ = getattr(reaction, "__qualname__", repr(reaction)) + return handler diff --git a/python-backend/musicmouse/services/__init__.py b/python-backend/musicmouse/services/__init__.py new file mode 100644 index 0000000..d00f59d --- /dev/null +++ b/python-backend/musicmouse/services/__init__.py @@ -0,0 +1,9 @@ +"""Front-ends: things that mirror state outwards and turn requests into intents. + +``MqttService`` is the reference implementation. A web service would be another file +here plus one line in the app - devices and reactions would not change. +""" + +from musicmouse.services.base import Service + +__all__ = ["Service"] diff --git a/python-backend/musicmouse/services/base.py b/python-backend/musicmouse/services/base.py new file mode 100644 index 0000000..0a1e540 --- /dev/null +++ b/python-backend/musicmouse/services/base.py @@ -0,0 +1,26 @@ +"""What a front-end has to look like. + +A service gets the bus, subscribes to state events to push outward, and emits intents +inward. Nothing else in the app knows which services exist. +""" + +from __future__ import annotations + +from typing import Protocol, runtime_checkable + +__all__ = ["Publisher", "Service"] + + +@runtime_checkable +class Service(Protocol): + name: str + + async def run(self) -> None: + """Long-running task. Cancelled on shutdown; may reconnect internally.""" + ... + + +class Publisher(Protocol): + """How an entity sends something out, without knowing about the connection.""" + + async def publish(self, topic: str, payload: str, *, retain: bool = False) -> None: ... diff --git a/python-backend/musicmouse/services/mqtt/__init__.py b/python-backend/musicmouse/services/mqtt/__init__.py new file mode 100644 index 0000000..07a7221 --- /dev/null +++ b/python-backend/musicmouse/services/mqtt/__init__.py @@ -0,0 +1,6 @@ +"""Home Assistant integration over MQTT.""" + +from musicmouse.services.mqtt.entity import Entity +from musicmouse.services.mqtt.service import MqttService, build_entities + +__all__ = ["Entity", "MqttService", "build_entities"] diff --git a/python-backend/musicmouse/services/mqtt/entity.py b/python-backend/musicmouse/services/mqtt/entity.py new file mode 100644 index 0000000..93929ce --- /dev/null +++ b/python-backend/musicmouse/services/mqtt/entity.py @@ -0,0 +1,107 @@ +"""Shared plumbing for Home-Assistant-discoverable MQTT entities. + +Adding an entity should be about thirty lines: a discovery payload, a state payload, +and whatever bus subscriptions keep it current. +""" + +from __future__ import annotations + +import json +import logging +from abc import ABC, abstractmethod +from typing import Any, ClassVar + +from musicmouse.bus import EventBus +from musicmouse.config import MqttConfig +from musicmouse.services.base import Publisher + +_log = logging.getLogger(__name__) + +__all__ = ["Entity"] + + +class Entity(ABC): + #: Home Assistant MQTT component, e.g. "light", "sensor", "device_automation". + component: ClassVar[str] + + def __init__(self, bus: EventBus, config: MqttConfig, object_id: str, name: str) -> None: + self.bus = bus + self.config = config + self.object_id = object_id + self.name = name + self._publisher: Publisher | None = None + self.subscribe() + + # -------------------------------------------------------------------- topics + + @property + def unique_id(self) -> str: + return f"{self.config.device_id}_{self.object_id}" + + @property + def base_topic(self) -> str: + return f"{self.config.base_topic}/{self.object_id}" + + @property + def state_topic(self) -> str: + return f"{self.base_topic}/state" + + @property + def command_topic(self) -> str: + return f"{self.base_topic}/set" + + @property + def discovery_topic(self) -> str: + return f"{self.config.discovery_prefix}/{self.component}/{self.unique_id}/config" + + def command_topics(self) -> tuple[str, ...]: + """Topics the service should route to :meth:`handle`.""" + return () + + # ------------------------------------------------------------------ contract + + @abstractmethod + def discovery_payload(self) -> dict[str, Any]: + """The retained config Home Assistant reads to create this entity.""" + + def subscribe(self) -> None: + """Register bus handlers. Called once, at construction.""" + + async def handle(self, topic: str, payload: str) -> None: + """React to a command on one of :meth:`command_topics`.""" + + async def publish_state(self) -> None: + """Push current state out. Called on connect and whenever state changes.""" + + # ------------------------------------------------------------------- runtime + + def attach(self, publisher: Publisher | None) -> None: + self._publisher = publisher + + @property + def online(self) -> bool: + return self._publisher is not None + + async def publish(self, topic: str, payload: Any, *, retain: bool = False) -> None: + """Send ``payload`` (JSON-encoded unless it is already a string). + + A no-op while the broker is unreachable: state is republished on reconnect. + """ + if self._publisher is None: + return + text = payload if isinstance(payload, str) else json.dumps(payload) + await self._publisher.publish(topic, text, retain=retain) + + async def announce(self) -> None: + """Publish discovery, then current state.""" + await self.publish(self.discovery_topic, self.discovery_payload(), retain=True) + await self.publish_state() + + def device_block(self) -> dict[str, Any]: + """Ties every entity to one device in Home Assistant's UI.""" + return { + "identifiers": [self.config.device_id], + "name": self.config.device_name, + "manufacturer": "bauer.tech", + "model": "MusicMouse", + } diff --git a/python-backend/musicmouse/services/mqtt/lights.py b/python-backend/musicmouse/services/mqtt/lights.py new file mode 100644 index 0000000..b071658 --- /dev/null +++ b/python-backend/musicmouse/services/mqtt/lights.py @@ -0,0 +1,250 @@ +"""Each LED zone as a Home-Assistant-discoverable JSON light. + +Two things changed from the old ``ShelveLightMqtt``: + +* The ``side_*``/``top_*`` effect names are parsed rather than enumerated, so adding a + width or an increment is a data change (see :data:`WIDTHS`, :data:`INCREMENTS`). +* State is published from :class:`~musicmouse.events.LedEffectChanged` - the device's + report of what it actually did - instead of echoing back the command. When a figure + animation overrides an MQTT-set colour, Home Assistant now follows along. +""" + +from __future__ import annotations + +import json +import logging +import re +from typing import Any + +from musicmouse.bus import EventBus +from musicmouse.color import ColorRGBW +from musicmouse.config import MqttConfig +from musicmouse.devices.mouse import MusicMouseDevice +from musicmouse.effects import ( + EffectCircularConfig, + EffectRandomTwoColorInterpolationConfig, + EffectStaticConfig, + EffectStaticDetailedConfig, + EffectSwipeAndChange, + LedEffect, +) +from musicmouse.events import LedEffectChanged +from musicmouse.hardware import LedZone +from musicmouse.services.mqtt.entity import Entity + +_log = logging.getLogger(__name__) + +__all__ = ["LightEntity", "effect_names", "parse_positional_effect"] + +BLACK = ColorRGBW(0, 0, 0, 0) + +#: Effects that are not simply "light this fraction of the strip". +BASE_EFFECTS = ("static", "circular", "wipeup", "twocolor", "twocolorrandom") + +#: Fraction of the strip lit by a positional effect. +WIDTHS = (0.2, 0.5) +#: Light every n-th LED. 1 is solid. +INCREMENTS = (1, 4, 8) + +_POSITIONAL = re.compile(r"^(?Pside|top)_(?P\d+(?:\.\d+)?)(?:_inc(?P\d+))?$") + +DEFAULT_TRANSITION_S = 0.3 + + +def effect_names() -> list[str]: + """Every effect name this entity accepts, for the discovery ``effect_list``.""" + positional = [ + f"{position}_{width:g}" + ("" if increment == 1 else f"_inc{increment}") + for position in ("side", "top") + for width in WIDTHS + for increment in INCREMENTS + ] + return [*BASE_EFFECTS, *positional] + + +def parse_positional_effect(name: str) -> tuple[float, float, int] | None: + """``"side_0.2_inc4"`` -> ``(begin, end, increment)``, or ``None`` if not one. + + ``side`` lights a band around the far end of the strip and wraps; ``top`` lights a + band centred on the middle. + """ + match = _POSITIONAL.match(name) + if match is None: + return None + width = float(match["width"]) + increment = int(match["inc"] or 1) + if match["position"] == "side": + return 1.0 - width / 2, width / 2, increment + return 0.5 - width / 2, 0.5 + width / 2, increment + + +class LightEntity(Entity): + component = "light" + + def __init__( + self, + bus: EventBus, + config: MqttConfig, + mouse: MusicMouseDevice, + zone: LedZone, + name: str, + ) -> None: + self.zone = zone + self.mouse = mouse + self._state: dict[str, Any] = { + "state": "OFF", + "color": {"r": 255, "g": 255, "b": 255, "w": 0}, + "color_mode": "rgbw", + "brightness": 30, + "effect": "static", + } + self._last_color = ColorRGBW(0.5, 0.5, 0.5, 0) + super().__init__(bus, config, object_id=f"light_{zone}", name=name) + + # ---------------------------------------------------------------- discovery + + def discovery_payload(self) -> dict[str, Any]: + return { + "schema": "json", + "name": self.name, + "unique_id": self.unique_id, + "command_topic": self.command_topic, + "state_topic": self.state_topic, + "brightness": True, + "color_mode": True, + "supported_color_modes": ["rgbw"], + "effect": True, + "effect_list": effect_names(), + "device": self.device_block(), + } + + def command_topics(self) -> tuple[str, ...]: + return (self.command_topic,) + + def subscribe(self) -> None: + self.bus.subscribe(LedEffectChanged, self._on_led_changed) + + # ----------------------------------------------------------------- commands + + async def handle(self, topic: str, payload: str) -> None: + try: + command = json.loads(payload) + except json.JSONDecodeError: + _log.warning("Ignoring non-JSON command on %s: %r", topic, payload[:120]) + return + if not isinstance(command, dict): + _log.warning("Ignoring command on %s: expected an object, got %r", topic, command) + return + + self._remember_previous_color(command) + self._state.update(command) + self.mouse.set_effect(self.zone, self._build_effect(), origin="mqtt") + # No publish here: LedEffectChanged will report what the device actually did. + + def _remember_previous_color(self, command: dict[str, Any]) -> None: + """Two-colour effects interpolate from the colour that was set before.""" + if "color" not in command: + return + brightness = command.get("brightness", self._state["brightness"]) + new_color = _color_from_json(command["color"], brightness) + current = _color_from_json(self._state["color"], self._state["brightness"]) + if new_color != current: + self._last_color = current + + def _build_effect(self) -> LedEffect: + state = self._state + color = _color_from_json(state["color"], state["brightness"]) + transition_ms = float(state.get("transition", DEFAULT_TRANSITION_S)) * 1000 + effect = str(state.get("effect", "static")) + + if state["state"] == "OFF": + return _static(BLACK, transition_ms) + + if (positional := parse_positional_effect(effect)) is not None: + begin, end, increment = positional + return EffectStaticDetailedConfig( + color, + increment=increment, + begin=begin, + end=end, + transition_time_in_ms=transition_ms, + ) + + match effect: + case "static": + return _static(color, transition_ms) + case "circular": + return EffectCircularConfig(speed=180, width=90, color=color) + case "wipeup": + swipe_and_change = EffectSwipeAndChange() + swipe_and_change.swipe.primary_color = self._last_color + swipe_and_change.swipe.secondary_color = color + swipe_and_change.swipe.bell_curve_width_in_leds = 10 + swipe_and_change.swipe.transition_width = 30 + swipe_and_change.swipe.start_position = 0 + swipe_and_change.swipe.swipe_speed = 260 + swipe_and_change.change.color1 = color + swipe_and_change.change.color2 = self._last_color + return swipe_and_change + case "twocolor" | "twocolorrandom": + random_hues = effect == "twocolorrandom" + return EffectRandomTwoColorInterpolationConfig( + color1=color, + color2=self._last_color, + hue1_random=random_hues, + hue2_random=random_hues, + start_with_existing=True, + ) + case _: + _log.warning("Unknown effect %r on %s, turning it off", effect, self.zone) + return _static(BLACK, transition_ms) + + # -------------------------------------------------------------------- state + + async def _on_led_changed(self, event: LedEffectChanged) -> None: + if event.zone is not self.zone: + return + if event.origin != "mqtt": + self._reconcile(event.effect) + await self.publish_state() + + def _reconcile(self, effect: LedEffect) -> None: + """Fold an effect this entity did not ask for into the reported state. + + The mapping is lossy - the firmware has richer effects than the HA light + schema - so only on/off and a colour are taken. The effect *name* is left + alone, since reporting one outside ``effect_list`` would confuse HA. + """ + color = getattr(effect, "color", None) + if isinstance(effect, EffectStaticConfig | EffectStaticDetailedConfig) and color == BLACK: + self._state["state"] = "OFF" + return + + self._state["state"] = "ON" + if isinstance(color, ColorRGBW): + self._state["color"] = _color_to_json(color) + self._state["brightness"] = 255 + + async def publish_state(self) -> None: + await self.publish(self.state_topic, self._state) + + +def _static(color: ColorRGBW, transition_ms: float) -> LedEffect: + if transition_ms > 0: + return EffectStaticDetailedConfig(color, transition_time_in_ms=transition_ms) + return EffectStaticConfig(color) + + +def _color_from_json(color: dict[str, int], brightness: int = 255) -> ColorRGBW: + scale = brightness / 255 + r, g, b, w = ((color.get(channel, 0) / 255) * scale for channel in "rgbw") + return ColorRGBW(r, g, b, w) + + +def _color_to_json(color: ColorRGBW) -> dict[str, int]: + return { + "r": round(color.r * 255), + "g": round(color.g * 255), + "b": round(color.b * 255), + "w": round(color.w * 255), + } diff --git a/python-backend/musicmouse/services/mqtt/player.py b/python-backend/musicmouse/services/mqtt/player.py new file mode 100644 index 0000000..20b1501 --- /dev/null +++ b/python-backend/musicmouse/services/mqtt/player.py @@ -0,0 +1,168 @@ +"""The audio player, exposed to Home Assistant. + +Home Assistant has no MQTT ``media_player`` platform, so the player is published as +the pieces that do exist: a sensor for what is going on, a number for the volume, and +buttons for the transport. Commands come back in as intents, which is the same path +the physical buttons take. +""" + +from __future__ import annotations + +import logging +from typing import Any, ClassVar + +from musicmouse.bus import EventBus +from musicmouse.config import MqttConfig +from musicmouse.devices.player import Player +from musicmouse.events import ( + ActiveFigureChanged, + Event, + IntentEvent, + NextTrackRequested, + PauseRequested, + PlaybackChanged, + PlayRequested, + PrevTrackRequested, + SetVolumeRequested, + TrackChanged, + VolumeChanged, +) +from musicmouse.services.mqtt.entity import Entity + +_log = logging.getLogger(__name__) + +__all__ = ["PlayerSensor", "TransportButton", "VolumeNumber", "player_entities"] + + +class PlayerSensor(Entity): + component = "sensor" + + def __init__(self, bus: EventBus, config: MqttConfig, player: Player) -> None: + self.player = player + self._figure: str | None = None + super().__init__(bus, config, object_id="player", name="Music Mouse Player") + + def discovery_payload(self) -> dict[str, Any]: + return { + "name": self.name, + "unique_id": self.unique_id, + "state_topic": self.state_topic, + "json_attributes_topic": f"{self.base_topic}/attributes", + "icon": "mdi:music-circle", + "device": self.device_block(), + } + + def subscribe(self) -> None: + for event_type in (PlaybackChanged, TrackChanged, VolumeChanged, ActiveFigureChanged): + self.bus.subscribe(event_type, self._on_change) + + async def _on_change(self, event: Event) -> None: + if isinstance(event, ActiveFigureChanged): + self._figure = event.figure + await self.publish_state() + + async def publish_state(self) -> None: + track = self.player.current_track + playlist = self.player.playlist + await self.publish(self.state_topic, "playing" if self.player.is_playing else "paused") + await self.publish( + f"{self.base_topic}/attributes", + { + "figure": self._figure, + "playlist": playlist.name if playlist else None, + "track_index": self.player.track_index, + "track_count": len(playlist) if playlist else 0, + "title": track.title if track else None, + "volume": self.player.volume, + }, + ) + + +class VolumeNumber(Entity): + component = "number" + + def __init__(self, bus: EventBus, config: MqttConfig, player: Player) -> None: + self.player = player + self._min = 0 + self._max = 100 + super().__init__(bus, config, object_id="volume", name="Music Mouse Volume") + + def discovery_payload(self) -> dict[str, Any]: + return { + "name": self.name, + "unique_id": self.unique_id, + "command_topic": self.command_topic, + "state_topic": self.state_topic, + "min": self._min, + "max": self._max, + "step": 1, + "mode": "slider", + "icon": "mdi:volume-high", + "device": self.device_block(), + } + + def command_topics(self) -> tuple[str, ...]: + return (self.command_topic,) + + def subscribe(self) -> None: + self.bus.subscribe(VolumeChanged, self._on_volume) + + async def _on_volume(self, _event: VolumeChanged) -> None: + await self.publish_state() + + async def handle(self, topic: str, payload: str) -> None: + try: + volume = int(float(payload)) + except ValueError: + _log.warning("Ignoring non-numeric volume on %s: %r", topic, payload[:40]) + return + self.bus.emit(SetVolumeRequested(volume=volume, source="mqtt")) + + async def publish_state(self) -> None: + await self.publish(self.state_topic, str(self.player.volume)) + + +class TransportButton(Entity): + component = "button" + + #: Button object id -> the intent pressing it emits. + INTENTS: ClassVar[dict[str, type[IntentEvent]]] = { + "next": NextTrackRequested, + "previous": PrevTrackRequested, + "play": PlayRequested, + "pause": PauseRequested, + } + + def __init__(self, bus: EventBus, config: MqttConfig, action: str, name: str) -> None: + self.action = action + super().__init__(bus, config, object_id=f"button_{action}", name=name) + + def discovery_payload(self) -> dict[str, Any]: + return { + "name": self.name, + "unique_id": self.unique_id, + "command_topic": self.command_topic, + "device": self.device_block(), + } + + def command_topics(self) -> tuple[str, ...]: + return (self.command_topic,) + + async def handle(self, topic: str, payload: str) -> None: + # HA publishes "PRESS"; the payload carries no information beyond "it happened". + _log.debug("Transport button %s pressed via %s (%r)", self.action, topic, payload[:20]) + self.bus.emit(self.INTENTS[self.action](source="mqtt")) + + +def player_entities(bus: EventBus, config: MqttConfig, player: Player) -> list[Entity]: + names = { + "next": "Music Mouse Next", + "previous": "Music Mouse Previous", + "play": "Music Mouse Play", + "pause": "Music Mouse Pause", + } + return [ + PlayerSensor(bus, config, player), + VolumeNumber(bus, config, player), + *(TransportButton(bus, config, action, name) for action, name in names.items()), + ] diff --git a/python-backend/musicmouse/services/mqtt/service.py b/python-backend/musicmouse/services/mqtt/service.py new file mode 100644 index 0000000..5dcb081 --- /dev/null +++ b/python-backend/musicmouse/services/mqtt/service.py @@ -0,0 +1,134 @@ +"""The MQTT connection: reconnect, discovery, and routing commands to entities. + +Unlike the old ``start_mqtt``, entities outlive a dropped connection - they keep their +state and simply republish it - and a clean restart is not delayed by a reconnect +sleep it never needed. +""" + +from __future__ import annotations + +import logging +from collections.abc import Iterable + +import aiomqtt + +from musicmouse.bus import EventBus +from musicmouse.clock import Clock, RealClock +from musicmouse.config import MqttConfig +from musicmouse.devices.mouse import MusicMouseDevice +from musicmouse.devices.player import Player +from musicmouse.events import ConnectionChanged +from musicmouse.hardware import LedZone +from musicmouse.services.mqtt.entity import Entity +from musicmouse.services.mqtt.lights import LightEntity +from musicmouse.services.mqtt.player import player_entities +from musicmouse.services.mqtt.triggers import trigger_entities + +_log = logging.getLogger(__name__) + +__all__ = ["MqttService", "build_entities"] + +ZONE_NAMES = { + LedZone.SHELF: "Music Mouse Regal Licht", + LedZone.RING: "Music Mouse Ring", + LedZone.MOUSE: "Music Mouse Body", +} + + +def build_entities( + bus: EventBus, config: MqttConfig, mouse: MusicMouseDevice, player: Player +) -> list[Entity]: + """Everything this backend exposes to Home Assistant.""" + return [ + *(LightEntity(bus, config, mouse, zone, ZONE_NAMES[zone]) for zone in LedZone), + *player_entities(bus, config, player), + *trigger_entities(bus, config), + ] + + +class MqttService: + name = "mqtt" + + def __init__( + self, + bus: EventBus, + config: MqttConfig, + entities: Iterable[Entity], + *, + clock: Clock | None = None, + ) -> None: + self.bus = bus + self.config = config + self.entities = list(entities) + self._clock = clock or RealClock() + self._client: aiomqtt.Client | None = None + self._routes: dict[str, Entity] = { + topic: entity for entity in self.entities for topic in entity.command_topics() + } + + # ------------------------------------------------------------------ Publisher + + async def publish(self, topic: str, payload: str, *, retain: bool = False) -> None: + client = self._client + if client is None: + return + try: + await client.publish(topic, payload.encode(), retain=retain) + except aiomqtt.MqttError as exc: + _log.debug("Publish to %s failed: %s", topic, exc) + + # -------------------------------------------------------------------- runtime + + async def run(self) -> None: + while True: + try: + await self._session() + except aiomqtt.MqttError as exc: + _log.warning( + "MQTT connection to %s lost (%s); retrying in %gs", + self.config.server, + exc, + self.config.reconnect_interval, + ) + finally: + self._detach() + await self._clock.sleep(self.config.reconnect_interval) + + async def _session(self) -> None: + async with aiomqtt.Client( + hostname=self.config.server, + port=self.config.port, + username=self.config.user, + password=self.config.password, + ) as client: + self._client = client + _log.info("Connected to MQTT broker %s:%d", self.config.server, self.config.port) + self.bus.emit(ConnectionChanged(target="mqtt", connected=True, source="mqtt")) + + for entity in self.entities: + entity.attach(self) + await entity.announce() + + await client.subscribe(f"{self.config.base_topic}/#") + async for message in client.messages: + await self._route(message) + + def _detach(self) -> None: + if self._client is None: + return + self._client = None + for entity in self.entities: + entity.attach(None) + self.bus.emit(ConnectionChanged(target="mqtt", connected=False, source="mqtt")) + + async def _route(self, message: aiomqtt.Message) -> None: + topic = message.topic.value + entity = self._routes.get(topic) + if entity is None: + return # our own state topics come back on the wildcard subscription + payload = message.payload + text = payload.decode(errors="replace") if isinstance(payload, bytes) else str(payload) + try: + await entity.handle(topic, text) + except Exception: + _log.exception("Entity %s failed on %s", entity.unique_id, topic) diff --git a/python-backend/musicmouse/services/mqtt/triggers.py b/python-backend/musicmouse/services/mqtt/triggers.py new file mode 100644 index 0000000..4d36b7a --- /dev/null +++ b/python-backend/musicmouse/services/mqtt/triggers.py @@ -0,0 +1,157 @@ +"""Button, touch and RFID events, published for Home Assistant to automate on. + +This is what replaces the old direct ``hass-client`` calls. The backend no longer +knows that pressing the rotary encoder toggles ``light.kinderzimmer_fluter`` or that +the left ear means pink - it reports what happened, and the automation lives in Home +Assistant where it can be changed without a deploy. +""" + +from __future__ import annotations + +from typing import Any, ClassVar + +from musicmouse.bus import EventBus +from musicmouse.config import MqttConfig +from musicmouse.events import ButtonEvent, RfidTokenRead, TouchButtonPressed, TouchButtonReleased +from musicmouse.hardware import Button, ButtonAction, TouchButton +from musicmouse.services.mqtt.entity import Entity + +__all__ = ["ButtonTrigger", "TagScanner", "TouchTrigger", "trigger_entities"] + +#: Which button actions are worth automating on, and the HA trigger type for each. +BUTTON_ACTIONS: dict[ButtonAction, str] = { + ButtonAction.PRESSED: "button_short_press", + ButtonAction.DOUBLE_CLICKED: "button_double_press", + ButtonAction.LONG_PRESSED: "button_long_press", +} + + +class _Trigger(Entity): + component = "device_automation" + + @property + def discovery_topic(self) -> str: + # Device triggers are addressed by node id + object id, not by unique id. + return ( + f"{self.config.discovery_prefix}/device_automation/" + f"{self.config.device_id}/{self.object_id}/config" + ) + + @property + def trigger_topic(self) -> str: + return f"{self.config.base_topic}/trigger/{self.object_id}" + + def _payload(self, trigger_type: str, subtype: str) -> dict[str, Any]: + return { + "automation_type": "trigger", + "topic": self.trigger_topic, + "type": trigger_type, + "subtype": subtype, + "device": self.device_block(), + } + + +class ButtonTrigger(_Trigger): + def __init__( + self, bus: EventBus, config: MqttConfig, button: Button, action: ButtonAction + ) -> None: + self.button = button + self.action = action + super().__init__( + bus, + config, + object_id=f"{button.slug}_{action.slug}", + name=f"{button.slug} {action.slug}", + ) + + def discovery_payload(self) -> dict[str, Any]: + return self._payload(BUTTON_ACTIONS[self.action], self.button.slug) + + def subscribe(self) -> None: + self.bus.subscribe(ButtonEvent, self._on_button) + + async def _on_button(self, event: ButtonEvent) -> None: + if event.button is self.button and event.action is self.action: + await self.publish(self.trigger_topic, self.action.slug) + + +class TouchTrigger(_Trigger): + TYPES: ClassVar[dict[bool, str]] = { + True: "button_short_press", + False: "button_short_release", + } + + def __init__( + self, bus: EventBus, config: MqttConfig, button: TouchButton, *, pressed: bool + ) -> None: + self.button = button + self.pressed = pressed + suffix = "touched" if pressed else "released" + super().__init__( + bus, + config, + object_id=f"{button.slug}_{suffix}", + name=f"{button.slug} {suffix}", + ) + + def discovery_payload(self) -> dict[str, Any]: + return self._payload(self.TYPES[self.pressed], self.button.slug) + + def subscribe(self) -> None: + if self.pressed: + self.bus.subscribe(TouchButtonPressed, self._on_touch) + else: + self.bus.subscribe(TouchButtonReleased, self._on_touch) + + async def _on_touch(self, event: TouchButtonPressed | TouchButtonReleased) -> None: + if event.button is self.button: + await self.publish(self.trigger_topic, self.button.slug) + + +class TagScanner(Entity): + """The RFID reader, as an HA tag scanner - the natural fit for "a tag was read".""" + + component = "tag" + + def __init__(self, bus: EventBus, config: MqttConfig) -> None: + super().__init__(bus, config, object_id="tag", name="Music Mouse Reader") + + @property + def discovery_topic(self) -> str: + return f"{self.config.discovery_prefix}/tag/{self.config.device_id}/config" + + @property + def scan_topic(self) -> str: + return f"{self.config.base_topic}/tag" + + def discovery_payload(self) -> dict[str, Any]: + return { + "topic": self.scan_topic, + "value_template": "{{ value_json.tag_id }}", + "device": self.device_block(), + } + + def subscribe(self) -> None: + self.bus.subscribe(RfidTokenRead, self._on_tag) + + async def _on_tag(self, event: RfidTokenRead) -> None: + await self.publish( + self.scan_topic, + {"tag_id": event.tag_id.hex(), "figure": event.figure, "known": event.known}, + ) + + +def trigger_entities(bus: EventBus, config: MqttConfig) -> list[Entity]: + return [ + *( + ButtonTrigger(bus, config, button, action) + for button in Button + for action in BUTTON_ACTIONS + ), + *( + TouchTrigger(bus, config, button, pressed=pressed) + for button in TouchButton + for pressed in (True, False) + ), + TagScanner(bus, config), + ] diff --git a/python-backend/musicmouse/simulator/__init__.py b/python-backend/musicmouse/simulator/__init__.py new file mode 100644 index 0000000..c52c6b5 --- /dev/null +++ b/python-backend/musicmouse/simulator/__init__.py @@ -0,0 +1,5 @@ +"""Stand-ins for the hardware, so the whole app can run with no mouse and no audio.""" + +from musicmouse.simulator.fake_transport import FakeTransport + +__all__ = ["FakeTransport"] diff --git a/python-backend/musicmouse/simulator/driver.py b/python-backend/musicmouse/simulator/driver.py new file mode 100644 index 0000000..53d0ef9 --- /dev/null +++ b/python-backend/musicmouse/simulator/driver.py @@ -0,0 +1,314 @@ +"""One vocabulary for driving a simulated mouse, shared by three front-ends. + +The same verbs are typed at the interactive prompt, listed in a scenario file, and +called from pytest - so a bug reproduced by hand becomes a regression test by pasting +the session into a ``.txt`` file. + + place fuchs + wait 1s + press right + expect track 1 + +Under :class:`~musicmouse.clock.FakeClock` ``wait 1s`` costs microseconds, so scenarios +are cheap enough to run on every commit. +""" + +from __future__ import annotations + +import logging +from dataclasses import dataclass + +from musicmouse.app import App +from musicmouse.clock import Clock, FakeClock +from musicmouse.events import ( + ButtonEvent, + NextTrackRequested, + PauseRequested, + PlayRequested, + PrevTrackRequested, + RfidTokenRead, + RotaryTurned, + SetVolumeRequested, + TouchButtonPressed, + TouchButtonReleased, +) +from musicmouse.hardware import ( + NO_FIGURE_TAG, + Button, + ButtonAction, + LedZone, + RotaryDirection, + TouchButton, +) +from musicmouse.simulator.fake_transport import FakeTransport + +_log = logging.getLogger(__name__) + +__all__ = ["ExpectationError", "ScriptError", "SimulatorDriver"] + + +class ScriptError(Exception): + """A scenario line could not be understood.""" + + +class ExpectationError(AssertionError): + """An ``expect`` line did not hold.""" + + +@dataclass(frozen=True, slots=True) +class Duration: + seconds: float + + @classmethod + def parse(cls, text: str) -> Duration: + raw = text.strip().lower() + scale = 1.0 + for suffix, factor in (("ms", 0.001), ("s", 1.0), ("m", 60.0)): + if raw.endswith(suffix): + raw = raw.removesuffix(suffix) + scale = factor + break + try: + return cls(float(raw) * scale) + except ValueError: + raise ScriptError(f"{text!r} is not a duration (try '1s', '500ms', '2')") from None + + +class SimulatorDriver: + def __init__(self, app: App, transport: FakeTransport, clock: Clock) -> None: + self.app = app + self.transport = transport + self.clock = clock + + # ------------------------------------------------------------------- inputs + + async def place(self, figure: str) -> None: + """Put a figure on the reader.""" + try: + tag = self.app.config.figures[figure].id + except KeyError: + known = ", ".join(sorted(self.app.config.figures)) + raise ScriptError(f"unknown figure {figure!r} (configured: {known})") from None + await self.tag(tag) + + async def tag(self, tag_id: bytes) -> None: + self.transport.inject(RfidTokenRead(tag_id=tag_id, source="simulator")) + await self.settle() + + async def remove(self) -> None: + """Take whatever is on the reader off it.""" + await self.tag(NO_FIGURE_TAG) + + async def press(self, button: str, action: str = "pressed") -> None: + self.transport.inject( + ButtonEvent( + button=_enum_by_name(Button, button, "button"), + action=_enum_by_name(ButtonAction, action, "button action"), + source="simulator", + ) + ) + await self.settle() + + async def touch(self, button: str) -> None: + self.transport.inject( + TouchButtonPressed( + button=_enum_by_name(TouchButton, button, "touch button"), source="simulator" + ) + ) + await self.settle() + + async def release(self, button: str) -> None: + self.transport.inject( + TouchButtonReleased( + button=_enum_by_name(TouchButton, button, "touch button"), source="simulator" + ) + ) + await self.settle() + + async def turn(self, steps: int) -> None: + """Turn the rotary encoder; negative steps turn it down.""" + self.transport.inject( + RotaryTurned( + position=0, + increment=abs(steps), + direction=RotaryDirection.UP if steps >= 0 else RotaryDirection.DOWN, + source="simulator", + ) + ) + await self.settle() + + async def disconnect(self) -> None: + self.transport.disconnect() + self.app.mouse.on_disconnected("simulated disconnect") + await self.settle() + + async def reconnect(self) -> None: + self.transport.reconnect() + self.app.mouse.on_connected() + await self.settle() + + # ------------------------------------------------------------------ intents + + async def emit_play(self) -> None: + self.app.bus.emit(PlayRequested(source="simulator")) + await self.settle() + + async def emit_pause(self) -> None: + self.app.bus.emit(PauseRequested(source="simulator")) + await self.settle() + + async def emit_next(self) -> None: + self.app.bus.emit(NextTrackRequested(source="simulator")) + await self.settle() + + async def emit_prev(self) -> None: + self.app.bus.emit(PrevTrackRequested(source="simulator")) + await self.settle() + + async def set_volume(self, volume: int) -> None: + self.app.bus.emit(SetVolumeRequested(volume=volume, source="simulator")) + await self.settle() + + # -------------------------------------------------------------------- time + + async def wait(self, seconds: float) -> None: + await self.clock.advance(seconds) + await self.settle() + + async def settle(self) -> None: + """Let every queued event, and everything it triggers, be handled.""" + await self.app.bus.drain() + + # ------------------------------------------------------------------ queries + + def status(self) -> str: + player = self.app.player + track = player.current_track + return ( + f"figure={self.app.mouse.active_figure or '-'} " + f"{'playing' if player.is_playing else 'paused'} " + f"track={player.track_index}{f' ({track.title})' if track else ''} " + f"volume={player.volume} " + f"buttons={self.app.mouse.button_led_brightness:.2f}" + ) + + def leds(self) -> str: + return "\n".join( + f" {zone:>5}: {self.app.mouse.effect(zone) or '-'}" for zone in LedZone + ) + + def check(self, key: str, value: str) -> None: + """Assert one property. Raises :class:`ExpectationError` if it does not hold.""" + actual = self._lookup(key) + expected = value.strip() + if actual != expected: + raise ExpectationError(f"expected {key} to be {expected!r}, but it is {actual!r}") + + def _lookup(self, key: str) -> str: + player = self.app.player + match key: + case "playing": + return "true" if player.is_playing else "false" + case "figure": + return self.app.mouse.active_figure or "none" + case "playlist": + return player.playlist.name if player.playlist else "none" + case "track": + return str(player.track_index) + case "title": + track = player.current_track + return track.title if track else "none" + case "volume": + return str(player.volume) + case "brightness": + return f"{self.app.mouse.button_led_brightness:.2f}" + case "ring" | "mouse" | "shelf": + effect = self.app.mouse.effect(LedZone(key)) + return type(effect).__name__ if effect is not None else "none" + case _: + raise ScriptError( + f"unknown property {key!r} (try: playing, figure, playlist, track, " + f"title, volume, brightness, ring, mouse, shelf)" + ) + + # ------------------------------------------------------------------ scripts + + async def execute(self, line: str) -> str | None: + """Run one scenario line. Returns text to show, if any.""" + stripped = line.split("#", 1)[0].strip() + if not stripped: + return None + verb, *args = stripped.split() + return await self._dispatch(verb.lower(), args) + + async def run_script(self, text: str) -> None: + for number, line in enumerate(text.splitlines(), start=1): + try: + if (output := await self.execute(line)) is not None: + print(output) + except (ScriptError, ExpectationError) as exc: + raise type(exc)(f"line {number}: {exc}\n {line.strip()}") from None + + async def _dispatch(self, verb: str, args: list[str]) -> str | None: + match verb, args: + case ("place" | "rfid", [figure]): + await self.place(figure) + case ("remove", []): + await self.remove() + case ("press", [button]): + await self.press(button) + case ("press", [button, action]): + await self.press(button, action) + case ("touch", [button]): + await self.touch(button) + case ("release", [button]): + await self.release(button) + case ("turn", [steps]): + await self.turn(_int(steps)) + case ("next", []): + await self.emit_next() + case ("prev" | "previous", []): + await self.emit_prev() + case ("play", []): + await self.emit_play() + case ("pause", []): + await self.emit_pause() + case ("volume", [level]): + await self.set_volume(_int(level)) + case ("disconnect", []): + await self.disconnect() + case ("reconnect", []): + await self.reconnect() + case ("wait", [duration]): + await self.wait(Duration.parse(duration).seconds) + case ("expect", [key, *rest]) if rest: + self.check(key, " ".join(rest)) + case ("status", []): + return self.status() + case ("leds", []): + return self.leds() + case _: + raise ScriptError(f"don't know how to {' '.join([verb, *args])!r}") + return None + + +def _int(text: str) -> int: + try: + return int(text) + except ValueError: + raise ScriptError(f"{text!r} is not a whole number") from None + + +def _enum_by_name[T: (Button, ButtonAction, TouchButton)]( + enum: type[T], name: str, what: str +) -> T: + try: + return enum[name.upper()] + except KeyError: + options = ", ".join(member.name.lower() for member in enum) + raise ScriptError(f"unknown {what} {name!r} (try: {options})") from None + + +def fake_clock_for(app: App) -> FakeClock: + """A clock whose ``advance`` also drains the bus, for deterministic scenarios.""" + return FakeClock(idle=app.bus.drain) diff --git a/python-backend/musicmouse/simulator/fake_player.py b/python-backend/musicmouse/simulator/fake_player.py new file mode 100644 index 0000000..5bda7ae --- /dev/null +++ b/python-backend/musicmouse/simulator/fake_player.py @@ -0,0 +1,164 @@ +"""A player that behaves like :class:`~musicmouse.devices.player.VlcPlayer` without VLC. + +Tracks advance on the injected :class:`~musicmouse.clock.Clock`, so under ``FakeClock`` +a three-minute playlist plays out in microseconds and under ``RealClock`` you can watch +it tick along in the interactive simulator. +""" + +from __future__ import annotations + +import asyncio +import contextlib +import logging + +from musicmouse.bus import EventBus +from musicmouse.clock import Clock, RealClock +from musicmouse.devices.player import PlayerBase +from musicmouse.events import EventSource +from musicmouse.media import Playlist + +_log = logging.getLogger(__name__) + +__all__ = ["FakePlayer"] + +DEFAULT_TRACK_DURATION = 5.0 + + +class FakePlayer(PlayerBase): + def __init__( + self, + bus: EventBus, + *, + min_volume: int = 0, + max_volume: int = 100, + initial_volume: int = 50, + track_duration: float = DEFAULT_TRACK_DURATION, + clock: Clock | None = None, + ) -> None: + super().__init__( + bus, min_volume=min_volume, max_volume=max_volume, initial_volume=initial_volume + ) + self._clock = clock or RealClock() + self.track_duration = track_duration + self._remaining = track_duration + self._started_at: float | None = None + self._timer: asyncio.Task[None] | None = None + self.closed = False + + # ------------------------------------------------------------------ actions + + def set_playlist(self, playlist: Playlist) -> None: + self._cancel_timer() + self._playlist = playlist + self._index = 0 + self._remaining = self.track_duration + _log.info("Playlist %r loaded (%d tracks)", playlist.name, len(playlist)) + + def play(self) -> None: + if self._playlist is None or not self._playlist: + _log.warning("Nothing to play: the playlist is empty") + return + if self._playing: + return + self._set_playing(True) + self._start_timer() + + def play_from_start(self) -> None: + if self._playlist is None or not self._playlist: + _log.warning("Nothing to play: the playlist is empty") + return + self._cancel_timer() + self._set_index(0) + self._remaining = self.track_duration + self._set_playing(True) + self._start_timer() + + def pause(self) -> None: + if not self._playing: + return + self._freeze() + self._set_playing(False) + + def stop(self) -> None: + self._cancel_timer() + self._remaining = self.track_duration + self._set_playing(False) + + def next_track(self) -> None: + self._skip(1) + + def previous_track(self) -> None: + self._skip(-1) + + def set_volume(self, volume: int, *, source: EventSource = "system") -> None: + clamped = self._clamp(volume) + if clamped == self._volume: + return + self._volume = clamped + self._announce_volume(source) + + def change_volume(self, delta: int, *, source: EventSource = "system") -> None: + self.set_volume(self._volume + delta, source=source) + + async def run(self) -> None: + return + + def close(self) -> None: + self._cancel_timer() + self.closed = True + + # ---------------------------------------------------------------- internals + + def _skip(self, offset: int) -> None: + if self._playlist is None or not self._playlist: + return + target = self._index + offset + if target < 0: + target = 0 + if target >= len(self._playlist): + self._finish() + return + + self._cancel_timer() + self._set_index(target) + self._remaining = self.track_duration + if self._playing: + self._start_timer() + + def _finish(self) -> None: + self._cancel_timer() + self._remaining = self.track_duration + self._set_playing(False) + self._announce_playlist_finished() + + def _start_timer(self) -> None: + self._cancel_timer() + self._started_at = self._clock.now() + self._timer = asyncio.create_task(self._await_track_end(), name="fake-player-track") + + def _cancel_timer(self) -> None: + if self._timer is not None: + self._timer.cancel() + self._timer = None + self._started_at = None + + def _freeze(self) -> None: + if self._started_at is not None: + self._remaining = max(0.0, self._remaining - (self._clock.now() - self._started_at)) + self._cancel_timer() + + async def _await_track_end(self) -> None: + with contextlib.suppress(asyncio.CancelledError): + await self._clock.sleep(self._remaining) + self._timer = None + self._started_at = None + self._on_track_end() + + def _on_track_end(self) -> None: + assert self._playlist is not None + if self._index + 1 < len(self._playlist): + self._set_index(self._index + 1) + self._remaining = self.track_duration + self._start_timer() + else: + self._finish() diff --git a/python-backend/musicmouse/simulator/fake_transport.py b/python-backend/musicmouse/simulator/fake_transport.py new file mode 100644 index 0000000..31ff709 --- /dev/null +++ b/python-backend/musicmouse/simulator/fake_transport.py @@ -0,0 +1,105 @@ +"""A :class:`~musicmouse.devices.transport.Transport` that decodes what it is told. + +Everything written goes through the real encoder and comes back through the real +decoder, so simulated hardware exercises the wire codec instead of bypassing it - and +what you see reported is what the firmware would actually have been asked to do. +""" + +from __future__ import annotations + +import logging +from collections.abc import Callable + +from musicmouse.devices.wire import ( + HostCommand, + HostFrameDecoder, + ProtocolError, + SetButtonBrightness, + SetEffect, + encode_input_event, +) +from musicmouse.effects import LedEffect +from musicmouse.events import InputEvent +from musicmouse.hardware import Button, LedZone + +_log = logging.getLogger(__name__) + +__all__ = ["FakeTransport"] + + +class FakeTransport: + def __init__(self, *, on_command: Callable[[HostCommand], None] | None = None) -> None: + self._decoder = HostFrameDecoder() + self._connected = True + self._feed: Callable[[bytes], None] | None = None + self.on_command = on_command + + self.commands: list[HostCommand] = [] + self.dropped_bytes = 0 + + # ------------------------------------------------------------------ transport + + @property + def connected(self) -> bool: + return self._connected + + def write(self, data: bytes) -> None: + if not self._connected: + self.dropped_bytes += len(data) + return + self._decoder.push(data) + while True: + try: + command = self._decoder.take() + except ProtocolError: + _log.exception("Simulated firmware could not parse a frame") + continue + if command is None: + return + self.commands.append(command) + _log.debug("FW <- %r", command) + if self.on_command is not None: + self.on_command(command) + + # -------------------------------------------------------------- link control + + def attach(self, feed: Callable[[bytes], None]) -> None: + """Register the device's ``feed`` so injected events reach it.""" + self._feed = feed + + def inject(self, event: InputEvent) -> None: + """Deliver ``event`` as if the firmware had sent it.""" + if self._feed is None: + raise RuntimeError("FakeTransport.inject() before attach()") + if not self._connected: + _log.debug("Dropping injected %r: link is down", event) + return + self._feed(encode_input_event(event)) + + def disconnect(self) -> None: + self._connected = False + + def reconnect(self) -> None: + self._connected = True + + # ------------------------------------------------------------------ queries + + def effect(self, zone: LedZone) -> LedEffect | None: + """The most recent effect sent to ``zone`` - last write wins.""" + for command in reversed(self.commands): + if isinstance(command, SetEffect) and command.zone == zone: + return command.effect + return None + + def brightness(self, button: Button = Button.LEFT) -> float | None: + for command in reversed(self.commands): + if isinstance(command, SetButtonBrightness) and command.button == button: + return command.brightness + return None + + def effects_for(self, zone: LedZone) -> list[LedEffect]: + return [c.effect for c in self.commands if isinstance(c, SetEffect) and c.zone == zone] + + def clear(self) -> None: + self.commands.clear() + self.dropped_bytes = 0 diff --git a/python-backend/musicmouse/simulator/harness.py b/python-backend/musicmouse/simulator/harness.py new file mode 100644 index 0000000..34058bd --- /dev/null +++ b/python-backend/musicmouse/simulator/harness.py @@ -0,0 +1,81 @@ +"""Assemble the whole app against fake hardware. + +This is the real bus, the real device, the real reactions - only the serial link and +VLC are substituted. That is what makes a scenario meaningful: everything between the +tag being read and the LED bytes being written is production code. +""" + +from __future__ import annotations + +from dataclasses import dataclass + +from musicmouse.app import App +from musicmouse.bus import EventBus +from musicmouse.clock import Clock, FakeClock +from musicmouse.config import Config, build_playlists +from musicmouse.devices.mouse import MusicMouseDevice +from musicmouse.reactions import register_all +from musicmouse.simulator.driver import SimulatorDriver +from musicmouse.simulator.fake_player import DEFAULT_TRACK_DURATION, FakePlayer +from musicmouse.simulator.fake_transport import FakeTransport + +__all__ = ["Simulation", "build_simulation"] + + +@dataclass +class Simulation: + app: App + driver: SimulatorDriver + transport: FakeTransport + player: FakePlayer + clock: Clock + bus: EventBus + + async def aclose(self) -> None: + self.player.close() + await self.bus.stop() + + +async def build_simulation( + config: Config, + *, + clock: Clock | None = None, + track_duration: float = DEFAULT_TRACK_DURATION, +) -> Simulation: + bus = EventBus() + await bus.start() + + if clock is None: + clock = FakeClock(idle=bus.drain) + + transport = FakeTransport() + mouse = MusicMouseDevice(bus, transport, config.tag_map, port="simulated") + transport.attach(mouse.feed) + + player = FakePlayer( + bus, + clock=clock, + track_duration=track_duration, + **FakePlayer.volume_kwargs(config.general), + ) + + app = App( + config=config, + bus=bus, + mouse=mouse, + player=player, + playlists=build_playlists(config), + clock=clock, + ) + register_all(bus, app) + mouse.on_connected() + await bus.drain() + + return Simulation( + app=app, + driver=SimulatorDriver(app, transport, clock), + transport=transport, + player=player, + clock=clock, + bus=bus, + ) diff --git a/python-backend/musicmouse/simulator/repl.py b/python-backend/musicmouse/simulator/repl.py new file mode 100644 index 0000000..a4929e3 --- /dev/null +++ b/python-backend/musicmouse/simulator/repl.py @@ -0,0 +1,96 @@ +"""The interactive simulator prompt. + +Runs the whole app - bus, device, reactions, MQTT if configured - against fake +hardware, and lets you poke it by hand while watching the events go past. +""" + +from __future__ import annotations + +import asyncio +import contextlib + +from musicmouse.events import ( + ActiveFigureChanged, + Event, + InputEvent, + LedEffectChanged, + PlaybackChanged, + StateEvent, + TrackChanged, + VolumeChanged, +) +from musicmouse.simulator.driver import ExpectationError, ScriptError +from musicmouse.simulator.harness import Simulation + +__all__ = ["run_repl"] + +PROMPT = "musicmouse> " + +HELP = """\ + place
put a figure on the reader remove + press [action] turn <+n|-n> + touch release + play pause next prev volume <0-100> + disconnect / reconnect simulate the USB cable + wait <1s|500ms|2> let time pass + status one-line summary leds + expect assert (playing, figure, playlist, track, title, + volume, brightness, ring, mouse, shelf) + help this text quit +""" + + +def _describe(event: Event) -> str | None: + """A compact one-liner, or None for events too noisy to show.""" + match event: + case LedEffectChanged(zone=zone, effect=effect): + return f" led {zone:>5}: {effect}" + case PlaybackChanged(playing=playing, figure=figure): + state = "playing" if playing else "paused" + return f" play {state}{f' [{figure}]' if figure else ''}" + case TrackChanged(index=index, track=track): + return f" play track {index}" + (f": {track.title}" if track else "") + case VolumeChanged(volume=volume): + return f" vol {volume}" + case ActiveFigureChanged(figure=figure): + return f" rfid {figure or '(removed)'}" + case InputEvent(): + return f" in {event}" + case StateEvent(): + return f" state {event}" + case _: + return None + + +async def run_repl(sim: Simulation) -> None: + print("MusicMouse simulator - no hardware, no audio. 'help' for commands.\n") + sim.bus.subscribe_all(_print_event) + print(sim.driver.status()) + + while True: + try: + line = await asyncio.to_thread(input, PROMPT) + except (EOFError, KeyboardInterrupt): + print() + return + + command = line.strip().lower() + if command in {"quit", "exit", "q"}: + return + if command in {"help", "?"}: + print(HELP, end="") + continue + + try: + if (output := await sim.driver.execute(line)) is not None: + print(output) + except (ScriptError, ExpectationError) as exc: + print(f"! {exc}") + except Exception as exc: # the prompt must survive anything + print(f"! {type(exc).__name__}: {exc}") + + +def _print_event(event: Event) -> None: + if (text := _describe(event)) is not None: + with contextlib.suppress(OSError): + print(text) diff --git a/python-backend/musicmouse/simulator/script.py b/python-backend/musicmouse/simulator/script.py new file mode 100644 index 0000000..2245e7a --- /dev/null +++ b/python-backend/musicmouse/simulator/script.py @@ -0,0 +1,24 @@ +"""Run a scenario file against a simulated mouse.""" + +from __future__ import annotations + +import logging +from pathlib import Path + +from musicmouse.simulator.harness import Simulation + +_log = logging.getLogger(__name__) + +__all__ = ["run_script_file"] + + +async def run_script_file(sim: Simulation, path: Path) -> None: + """Execute every line of ``path``. + + Raises: + ScriptError: on a line that cannot be understood. + ExpectationError: on an ``expect`` that does not hold. + """ + _log.info("Running scenario %s", path) + await sim.driver.run_script(path.read_text(encoding="utf-8")) + _log.info("Scenario %s passed", path.name) diff --git a/python-backend/C5S2_ChordRec_Templates.ipynb b/python-backend/notebooks/C5S2_ChordRec_Templates.ipynb similarity index 100% rename from python-backend/C5S2_ChordRec_Templates.ipynb rename to python-backend/notebooks/C5S2_ChordRec_Templates.ipynb diff --git a/python-backend/C5S3_ChordRec_HMM.ipynb b/python-backend/notebooks/C5S3_ChordRec_HMM.ipynb similarity index 100% rename from python-backend/C5S3_ChordRec_HMM.ipynb rename to python-backend/notebooks/C5S3_ChordRec_HMM.ipynb diff --git a/python-backend/C5S3_HiddenMarkovModel.ipynb b/python-backend/notebooks/C5S3_HiddenMarkovModel.ipynb similarity index 100% rename from python-backend/C5S3_HiddenMarkovModel.ipynb rename to python-backend/notebooks/C5S3_HiddenMarkovModel.ipynb diff --git a/python-backend/notebooks/README.md b/python-backend/notebooks/README.md new file mode 100644 index 0000000..8cd66f3 --- /dev/null +++ b/python-backend/notebooks/README.md @@ -0,0 +1,14 @@ +# Notebooks + +Exploratory material, **not part of the running backend** and not imported by it. + +`audio_analysis.py` and the three `C5S*` notebooks are chroma/chord-recognition course +work (they still reference stale absolute paths). `effect_debug.ipynb` is scratch work +for tinkering with LED effects. + +They need `librosa`, `numba` and `numpy`, which are deliberately not in the backend's +dependencies: + +```sh +pip install librosa numba numpy jupyter +``` diff --git a/python-backend/audio_analysis.py b/python-backend/notebooks/audio_analysis.py similarity index 100% rename from python-backend/audio_analysis.py rename to python-backend/notebooks/audio_analysis.py diff --git a/python-backend/effect_debug.ipynb b/python-backend/notebooks/effect_debug.ipynb similarity index 100% rename from python-backend/effect_debug.ipynb rename to python-backend/notebooks/effect_debug.ipynb diff --git a/python-backend/player.py b/python-backend/player.py deleted file mode 100644 index c0df42d..0000000 --- a/python-backend/player.py +++ /dev/null @@ -1,79 +0,0 @@ -import vlc - - -class AudioPlayer: - def __init__(self, alsa_device=None): - params = ["-A", "alsa", "--alsa-audio-device", alsa_device] if alsa_device else [] - self.instance = vlc.Instance(*params) - self.media_list_player = self.instance.media_list_player_new() - self.media_player = self.media_list_player.get_media_player() - - evm = self.media_player.event_manager() - evm.event_attach(vlc.EventType.MediaPlayerStopped, self._callback) - - evm2 = self.media_list_player.event_manager() - evm2.event_attach(vlc.EventType.MediaListPlayerPlayed, self._callback) - evm2.event_attach(vlc.EventType.MediaListPlayerStopped, self._callback) - - self.on_playlist_end_callback = None - - self.volume_min = None - self.volume_max = None - - def create_playlist(self, files): - result = vlc.MediaList() - for e in files: - result.add_media(self.instance.media_new(e)) - - evm = result.event_manager() - evm.event_attach(vlc.EventType.MediaListEndReached, - lambda e: print("Ml CB", str(e.type))) - evm.event_attach(vlc.EventType.MediaListItemAdded, - lambda e: print("Ml ia CB", str(e.type))) - - return result - - def set_playlist(self, media_list): - self.media_list_player.set_media_list(media_list) - print("Setting media list of length ", media_list.count()) - self.media_list_player.set_playback_mode(vlc.PlaybackMode.default) - - def next(self): - return self.media_list_player.next() - - def previous(self): - return self.media_list_player.previous() - - def play(self): - self.media_list_player.play() - - def play_from_start(self): - self.media_list_player.play_item_at_index(0) - - def is_playing(self): - return self.media_list_player.is_playing() - - def pause(self): - self.media_list_player.pause() - - def _callback(self, event, *args, **kwargs): - print(f"Got vlc event type {event.type}") - if event.type == vlc.EventType.MediaPlayerStopped: - if self.on_playlist_end_callback: - print("Calling playlist end cb") - self.on_playlist_end_callback() - - def set_volume(self, volume): - if self.volume_min and volume < self.volume_min: - volume = self.volume_min - if self.volume_max and volume > self.volume_max: - volume = self.volume_max - self.media_player.audio_set_volume(volume) - - def set_volume_limits(self, vmin, vmax): - self.volume_min = vmin - self.volume_max = vmax - - def change_volume(self, amount=1): - vol = self.media_player.audio_get_volume() + amount - self.set_volume(vol) diff --git a/python-backend/pyproject.toml b/python-backend/pyproject.toml new file mode 100644 index 0000000..a9679a3 --- /dev/null +++ b/python-backend/pyproject.toml @@ -0,0 +1,55 @@ +[project] +name = "musicmouse" +version = "2.0.0" +description = "Host backend for the MusicMouse RFID music player" +requires-python = ">=3.13" +dependencies = [ + "aiomqtt>=2.0", + "pydantic>=2.7", + "pyserial-asyncio>=0.6", + "python-vlc>=3.0", + "ruamel.yaml>=0.18", +] + +[project.optional-dependencies] +dev = ["mypy>=1.10", "pytest-asyncio>=0.23", "pytest>=8.0", "ruff>=0.5"] + +[project.scripts] +musicmouse = "musicmouse.__main__:main" + +[build-system] +requires = ["setuptools>=68"] +build-backend = "setuptools.build_meta" + +[tool.setuptools.packages.find] +include = ["musicmouse*"] + +[tool.pytest.ini_options] +testpaths = ["tests"] +asyncio_mode = "auto" +asyncio_default_fixture_loop_scope = "function" +filterwarnings = ["error"] + +[tool.ruff] +line-length = 100 +target-version = "py313" +# Course material and scratch work, not part of the backend. See notebooks/README.md. +extend-exclude = ["notebooks"] + +[tool.ruff.lint] +select = ["ARG", "B", "C4", "E", "F", "I", "N", "PTH", "RUF", "SIM", "UP", "W"] + +[tool.ruff.lint.per-file-ignores] +"tests/*" = ["ARG001"] +# Entity is an ABC with optional hooks: empty bodies and unused args are the point. +"musicmouse/services/mqtt/entity.py" = ["ARG002", "B027"] + +[tool.mypy] +python_version = "3.13" +strict = true +files = ["musicmouse"] +warn_unreachable = true + +[[tool.mypy.overrides]] +module = ["vlc", "serial_asyncio", "ruamel.*"] +ignore_missing_imports = true diff --git a/python-backend/requirements.txt b/python-backend/requirements.txt deleted file mode 100644 index 2b2cccd..0000000 --- a/python-backend/requirements.txt +++ /dev/null @@ -1,5 +0,0 @@ -pyserial-asyncio==0.6 -python-vlc==3.0.20123 -hass-client==0.1.2 -ruamel.yaml==0.18.6 -aiomqtt==2.0.0 \ No newline at end of file diff --git a/python-backend/scenarios/playlist_end.txt b/python-backend/scenarios/playlist_end.txt new file mode 100644 index 0000000..6035870 --- /dev/null +++ b/python-backend/scenarios/playlist_end.txt @@ -0,0 +1,19 @@ +# Reaching the end of a playlist stops playback and runs the off animation. +# eule has two tracks of 5s each. + +place eule +expect playing true + +wait 11s +expect playing false +expect ring EffectReverseSwipe +expect brightness 0.00 + +# the figure is still on the reader, but nothing is playing +expect figure eule + +# taking it off and putting it back starts from the top again +remove +place eule +expect playing true +expect track 0 diff --git a/python-backend/scenarios/smoke.txt b/python-backend/scenarios/smoke.txt new file mode 100644 index 0000000..4c5010c --- /dev/null +++ b/python-backend/scenarios/smoke.txt @@ -0,0 +1,51 @@ +# A figure is placed, plays, is taken off mid-playlist and put back. +# Run it against fake hardware with: +# python -m musicmouse --config --simulate --script scenarios/smoke.txt +# The test suite runs the same file on a fake clock. + +expect playing false +expect figure none + +place fuchs +expect figure fuchs +expect playing true +expect track 0 +expect ring EffectSwipeAndChange +expect brightness 0.50 + +# each simulated track is 5s by default +wait 6s +expect track 1 + +press right +expect track 2 + +press left +expect track 1 + +remove +expect figure none +expect playing false +expect ring EffectReverseSwipe +expect brightness 0.00 + +# putting the same figure back resumes rather than restarting +place fuchs +expect playing true +expect track 1 + +# a different figure always starts from the top +place eule +expect playlist eule +expect track 0 + +turn 2 +expect volume 50 + +turn -4 +expect volume 30 + +touch left_ear +expect mouse EffectStaticConfig +release left_ear +expect mouse EffectRandomTwoColorInterpolationConfig diff --git a/python-backend/music/config.yaml b/python-backend/tests/__init__.py similarity index 100% rename from python-backend/music/config.yaml rename to python-backend/tests/__init__.py diff --git a/python-backend/tests/conftest.py b/python-backend/tests/conftest.py new file mode 100644 index 0000000..f294df0 --- /dev/null +++ b/python-backend/tests/conftest.py @@ -0,0 +1,39 @@ +from __future__ import annotations + +from pathlib import Path +from typing import Any + +import pytest +from ruamel.yaml import YAML + +VALID_CONFIG: dict[str, Any] = { + "general": { + "figure_folder": "music", + "serial_port": "/dev/ttyUSB0", + "min_volume": 0, + "max_volume": 60, + "initial_volume": 40, + }, + "figures": { + "fuchs": {"id": "04a1b2c3d4", "colors": ["#ff6600", "#ffcc00", "#331100", "wff"]}, + "eule": {"id": "04b2c3d4e5", "colors": ["#3355ff", "#66aaff", "#001133", "#ffffff"]}, + }, +} + + +@pytest.fixture +def config_dir(tmp_path: Path) -> Path: + """A directory with a valid ``music/`` tree and two figures' worth of tracks.""" + for figure, tracks in (("fuchs", 3), ("eule", 2)): + folder = tmp_path / "music" / figure + folder.mkdir(parents=True) + for index in range(tracks): + (folder / f"{index:02d} - track.mp3").write_bytes(b"") + return tmp_path + + +def write_config(directory: Path, data: dict[str, Any], name: str = "config.yml") -> Path: + path = directory / name + with path.open("w", encoding="utf-8") as handle: + YAML(typ="safe").dump(data, handle) + return path diff --git a/python-backend/tests/test_bus.py b/python-backend/tests/test_bus.py new file mode 100644 index 0000000..49c8403 --- /dev/null +++ b/python-backend/tests/test_bus.py @@ -0,0 +1,178 @@ +from __future__ import annotations + +import asyncio +import logging +from collections.abc import AsyncIterator + +import pytest + +from musicmouse.bus import EventBus +from musicmouse.events import ( + ButtonEvent, + Event, + InputEvent, + NextTrackRequested, + RfidTokenRead, + VolumeChanged, +) +from musicmouse.hardware import Button, ButtonAction + + +@pytest.fixture +async def bus() -> AsyncIterator[EventBus]: + async with EventBus() as running_bus: + yield running_bus + + +def _rfid(tag: str = "04a1b2c3d4") -> RfidTokenRead: + return RfidTokenRead(tag_id=bytes.fromhex(tag), figure="fuchs", source="device") + + +async def test_handler_receives_its_event(bus: EventBus) -> None: + seen: list[Event] = [] + bus.subscribe(RfidTokenRead, seen.append) + + event = _rfid() + await bus.emit_and_wait(event) + + assert seen == [event] + + +async def test_unrelated_handlers_are_not_called(bus: EventBus) -> None: + seen: list[Event] = [] + bus.subscribe(NextTrackRequested, seen.append) + + await bus.emit_and_wait(_rfid()) + + assert seen == [] + + +async def test_subscribing_to_a_base_class_catches_subclasses(bus: EventBus) -> None: + seen: list[Event] = [] + bus.subscribe(InputEvent, seen.append) + + await bus.emit_and_wait(_rfid()) + await bus.emit_and_wait(VolumeChanged(volume=30)) + + assert [type(e) for e in seen] == [RfidTokenRead] + + +async def test_subscribe_all_sees_everything(bus: EventBus) -> None: + seen: list[Event] = [] + bus.subscribe_all(seen.append) + + await bus.emit_and_wait(_rfid()) + await bus.emit_and_wait(VolumeChanged(volume=30)) + + assert [type(e) for e in seen] == [RfidTokenRead, VolumeChanged] + + +async def test_async_handlers_are_awaited(bus: EventBus) -> None: + seen: list[str] = [] + + async def slow(_: Event) -> None: + await asyncio.sleep(0.01) + seen.append("slow") + + bus.subscribe(RfidTokenRead, slow) + bus.subscribe(RfidTokenRead, lambda _: seen.append("fast")) + + await bus.emit_and_wait(_rfid()) + + assert seen == ["slow", "fast"] + + +async def test_events_are_dispatched_in_emission_order(bus: EventBus) -> None: + """Ordering is what makes 'last event wins' meaningful for LED arbitration.""" + seen: list[int] = [] + + async def record(event: VolumeChanged) -> None: + await asyncio.sleep(0) + seen.append(event.volume) + + bus.subscribe(VolumeChanged, record) + + for volume in range(5): + bus.emit(VolumeChanged(volume=volume)) + await bus.drain() + + assert seen == [0, 1, 2, 3, 4] + + +async def test_events_emitted_from_a_handler_are_handled_before_drain_returns( + bus: EventBus, +) -> None: + seen: list[str] = [] + + def on_button(_: ButtonEvent) -> None: + seen.append("button") + bus.emit(NextTrackRequested(source="device")) + + bus.subscribe(ButtonEvent, on_button) + bus.subscribe(NextTrackRequested, lambda _: seen.append("next")) + + await bus.emit_and_wait( + ButtonEvent(button=Button.RIGHT, action=ButtonAction.PRESSED, source="device") + ) + + assert seen == ["button", "next"] + + +async def test_a_raising_handler_does_not_stop_the_others( + bus: EventBus, caplog: pytest.LogCaptureFixture +) -> None: + seen: list[str] = [] + + def boom(_: Event) -> None: + raise RuntimeError("handler is broken") + + bus.subscribe(RfidTokenRead, boom) + bus.subscribe(RfidTokenRead, lambda _: seen.append("survivor")) + + with caplog.at_level(logging.ERROR): + await bus.emit_and_wait(_rfid()) + await bus.emit_and_wait(_rfid()) + + assert seen == ["survivor", "survivor"] + assert "handler is broken" in caplog.text + + +async def test_unsubscribe(bus: EventBus) -> None: + seen: list[Event] = [] + unsubscribe = bus.subscribe(RfidTokenRead, seen.append) + + await bus.emit_and_wait(_rfid()) + unsubscribe() + await bus.emit_and_wait(_rfid()) + + assert len(seen) == 1 + + +async def test_emit_is_safe_from_another_thread(bus: EventBus) -> None: + """libVLC fires its callbacks off-loop; this is the crossing that must be safe.""" + seen: list[VolumeChanged] = [] + done = asyncio.Event() + + def record(event: VolumeChanged) -> None: + seen.append(event) + done.set() + + bus.subscribe(VolumeChanged, record) + + await asyncio.to_thread(bus.emit, VolumeChanged(volume=42, source="player")) + async with asyncio.timeout(2): + await done.wait() + + assert [e.volume for e in seen] == [42] + + +async def test_emit_before_start_is_an_error() -> None: + with pytest.raises(RuntimeError, match="before start"): + EventBus().emit(VolumeChanged(volume=1)) + + +async def test_stop_is_idempotent() -> None: + stopped = EventBus() + await stopped.start() + await stopped.stop() + await stopped.stop() diff --git a/python-backend/tests/test_clock.py b/python-backend/tests/test_clock.py new file mode 100644 index 0000000..1e189f9 --- /dev/null +++ b/python-backend/tests/test_clock.py @@ -0,0 +1,108 @@ +from __future__ import annotations + +import asyncio + +from musicmouse.clock import FakeClock, RealClock + + +async def test_fake_clock_starts_at_zero_and_advances() -> None: + clock = FakeClock() + assert clock.now() == 0.0 + + await clock.advance(2.5) + + assert clock.now() == 2.5 + + +async def test_sleep_blocks_until_time_is_advanced_past_the_deadline() -> None: + clock = FakeClock() + woken = asyncio.Event() + + async def sleeper() -> None: + await clock.sleep(10) + woken.set() + + task = asyncio.create_task(sleeper()) + await clock.advance(9) + assert not woken.is_set() + + await clock.advance(2) + assert woken.is_set() + await task + + +async def test_sleepers_wake_in_deadline_order_regardless_of_start_order() -> None: + clock = FakeClock() + woken: list[str] = [] + + async def sleeper(name: str, delay: float) -> None: + await clock.sleep(delay) + woken.append(name) + + tasks = [ + asyncio.create_task(sleeper("late", 30)), + asyncio.create_task(sleeper("early", 10)), + asyncio.create_task(sleeper("middle", 20)), + ] + await clock.advance(60) + + assert woken == ["early", "middle", "late"] + await asyncio.gather(*tasks) + + +async def test_time_at_wake_up_is_the_deadline_not_the_target() -> None: + clock = FakeClock() + observed: list[float] = [] + + async def sleeper() -> None: + await clock.sleep(5) + observed.append(clock.now()) + + task = asyncio.create_task(sleeper()) + await clock.advance(100) + + assert observed == [5.0] + assert clock.now() == 100.0 + await task + + +async def test_idle_hook_runs_after_each_wake_up() -> None: + calls: list[int] = [] + + async def idle() -> None: + calls.append(1) + + clock = FakeClock(idle=idle) + + async def sleeper() -> None: + await clock.sleep(1) + + task = asyncio.create_task(sleeper()) + await clock.advance(2) + + assert calls # the bus got a chance to drain between virtual ticks + await task + + +async def test_pending_timers_is_visible() -> None: + clock = FakeClock() + task = asyncio.create_task(clock.sleep(5)) + await asyncio.sleep(0) + + assert clock.pending_timers == 1 + await clock.advance(5) + assert clock.pending_timers == 0 + await task + + +async def test_zero_sleep_just_yields() -> None: + clock = FakeClock() + await clock.sleep(0) + assert clock.now() == 0.0 + + +async def test_real_clock_measures_real_elapsed_time() -> None: + clock = RealClock() + before = clock.now() + await clock.advance(0.01) + assert clock.now() - before >= 0.005 diff --git a/python-backend/tests/test_config.py b/python-backend/tests/test_config.py new file mode 100644 index 0000000..a82293c --- /dev/null +++ b/python-backend/tests/test_config.py @@ -0,0 +1,202 @@ +from __future__ import annotations + +import copy +from pathlib import Path +from typing import Any + +import pytest + +from musicmouse.color import ColorRGBW, parse_color +from musicmouse.config import ConfigError, build_playlists, load_config +from tests.conftest import VALID_CONFIG, write_config + + +def _config(**general: Any) -> dict[str, Any]: + data = copy.deepcopy(VALID_CONFIG) + data["general"].update(general) + return data + + +def test_loads_valid_config(config_dir: Path) -> None: + config = load_config(write_config(config_dir, VALID_CONFIG)) + + assert set(config.figures) == {"fuchs", "eule"} + assert config.figures["fuchs"].id == bytes.fromhex("04a1b2c3d4") + assert config.figures["fuchs"].colors.primary == ColorRGBW(1.0, 0.4, 0.0, 0) + assert config.figures["fuchs"].colors.accent == ColorRGBW(0, 0, 0, 1.0) + assert config.general.max_volume == 60 + + +def test_figure_folder_resolves_relative_to_the_config_file(config_dir: Path) -> None: + config = load_config(write_config(config_dir, VALID_CONFIG)) + assert config.general.figure_folder == (config_dir / "music").resolve() + + +def test_tag_map_and_playlists(config_dir: Path) -> None: + config = load_config(write_config(config_dir, VALID_CONFIG)) + + assert config.tag_map == { + bytes.fromhex("04a1b2c3d4"): "fuchs", + bytes.fromhex("04b2c3d4e5"): "eule", + } + playlists = build_playlists(config) + assert [t.path.name for t in playlists["fuchs"].tracks] == [ + "00 - track.mp3", + "01 - track.mp3", + "02 - track.mp3", + ] + assert len(playlists["eule"]) == 2 + + +def test_playlist_is_alphabetical_regardless_of_creation_order(config_dir: Path) -> None: + folder = config_dir / "music" / "fuchs" + for name in ("zz last.mp3", "aa first.mp3"): + (folder / name).write_bytes(b"") + + playlists = build_playlists(load_config(write_config(config_dir, VALID_CONFIG))) + names = [t.path.name for t in playlists["fuchs"].tracks] + assert names == sorted(names) + assert names[0] == "00 - track.mp3" + + +def test_non_audio_files_are_ignored(config_dir: Path) -> None: + (config_dir / "music" / "eule" / "cover.jpg").write_bytes(b"") + (config_dir / "music" / "eule" / "notes.txt").write_bytes(b"") + + playlists = build_playlists(load_config(write_config(config_dir, VALID_CONFIG))) + assert len(playlists["eule"]) == 2 + + +def test_missing_figure_folder_warns_but_does_not_fail( + config_dir: Path, caplog: pytest.LogCaptureFixture +) -> None: + data = copy.deepcopy(VALID_CONFIG) + data["figures"]["neu"] = {"id": "0400000001", "colors": ["#111111"] * 4} + + config = load_config(write_config(config_dir, data)) + playlists = build_playlists(config) + + assert len(playlists["neu"]) == 0 + assert "no media folder" in caplog.text + + +# --------------------------------------------------------------------- error paths + + +def _error(directory: Path, data: dict[str, Any]) -> str: + with pytest.raises(ConfigError) as excinfo: + load_config(write_config(directory, data)) + return str(excinfo.value) + + +def test_unknown_option_is_rejected_with_its_path(config_dir: Path) -> None: + message = _error(config_dir, _config(buton_leds_brightness=0.5)) + assert "general.buton_leds_brightness" in message + assert "unknown option" in message + + +def test_bad_color_names_the_figure_and_the_position(config_dir: Path) -> None: + data = copy.deepcopy(VALID_CONFIG) + data["figures"]["fuchs"]["colors"] = ["#ff6600", "not-a-color", "#331100", "wff"] + + message = _error(config_dir, data) + assert "figures.fuchs.colors.secondary" in message + assert "'#rrggbb' or 'wNN'" in message + + +def test_wrong_number_of_colors(config_dir: Path) -> None: + data = copy.deepcopy(VALID_CONFIG) + data["figures"]["eule"]["colors"] = ["#ffffff", "#000000"] + + message = _error(config_dir, data) + assert "figures.eule.colors" in message + assert "exactly 4 colors" in message + + +def test_duplicate_tag_ids_are_rejected(config_dir: Path) -> None: + data = copy.deepcopy(VALID_CONFIG) + data["figures"]["eule"]["id"] = data["figures"]["fuchs"]["id"] + + message = _error(config_dir, data) + assert "both use tag id 04a1b2c3d4" in message + + +def test_tag_id_length_is_checked(config_dir: Path) -> None: + data = copy.deepcopy(VALID_CONFIG) + data["figures"]["fuchs"]["id"] = "04a1b2" + + message = _error(config_dir, data) + assert "figures.fuchs.id" in message + assert "expected 5 bytes" in message + + +def test_all_zero_tag_id_is_reserved(config_dir: Path) -> None: + data = copy.deepcopy(VALID_CONFIG) + data["figures"]["fuchs"]["id"] = "0000000000" + + assert "reserved" in _error(config_dir, data) + + +def test_volume_range_is_checked(config_dir: Path) -> None: + message = _error(config_dir, _config(min_volume=50, max_volume=20, initial_volume=30)) + assert "must not exceed max_volume" in message + + +def test_initial_volume_must_lie_in_range(config_dir: Path) -> None: + message = _error(config_dir, _config(min_volume=10, max_volume=20, initial_volume=90)) + assert "must lie between" in message + + +def test_missing_figure_folder_is_an_error(config_dir: Path) -> None: + message = _error(config_dir, _config(figure_folder="does-not-exist")) + assert "general.figure_folder" in message + assert "no such directory" in message + + +def test_every_problem_is_reported_at_once(config_dir: Path) -> None: + data = _config(volume_increment=0) + data["figures"]["fuchs"]["colors"] = ["#ff6600", "nope", "#331100", "wff"] + + message = _error(config_dir, data) + assert "2 problems" in message + assert "general.volume_increment" in message + assert "figures.fuchs.colors.secondary" in message + + +def test_directory_instead_of_file_says_so(config_dir: Path) -> None: + with pytest.raises(ConfigError, match="Pass the config file itself"): + load_config(config_dir) + + +def test_missing_file(tmp_path: Path) -> None: + with pytest.raises(ConfigError, match="Cannot read config file"): + load_config(tmp_path / "nope.yml") + + +def test_malformed_yaml(tmp_path: Path) -> None: + path = tmp_path / "config.yml" + path.write_text("general: [unclosed\n", encoding="utf-8") + with pytest.raises(ConfigError, match="not valid YAML"): + load_config(path) + + +# --------------------------------------------------------------------------- colors + + +@pytest.mark.parametrize( + ("text", "expected"), + [ + ("#000000", ColorRGBW(0, 0, 0, 0)), + ("#ffffff", ColorRGBW(1, 1, 1, 0)), + ("w00", ColorRGBW(0, 0, 0, 0)), + ("wff", ColorRGBW(0, 0, 0, 1)), + ], +) +def test_parse_color(text: str, expected: ColorRGBW) -> None: + assert parse_color(text) == expected + + +@pytest.mark.parametrize("text", ["#fff", "#gggggg", "orange", "", "#ff66000"]) +def test_parse_color_rejects(text: str) -> None: + with pytest.raises(ValueError, match="unrecognized color format"): + parse_color(text) diff --git a/python-backend/tests/test_effects.py b/python-backend/tests/test_effects.py new file mode 100644 index 0000000..163aa61 --- /dev/null +++ b/python-backend/tests/test_effects.py @@ -0,0 +1,141 @@ +"""Golden-byte tests for the LED effect payloads. + +These pin the exact layout the firmware reads back into its C++ structs. If one of +them fails, either the firmware struct changed or an effect field was reordered - both +would otherwise show up only as garbled LEDs on the real device. +""" + +from __future__ import annotations + +import pytest + +from musicmouse.color import ColorHSV, ColorRGBW +from musicmouse.effects import ( + EffectAlexaSwipeConfig, + EffectCircularConfig, + EffectRandomTwoColorInterpolationConfig, + EffectReverseSwipe, + EffectStaticConfig, + EffectStaticDetailedConfig, + EffectSwipeAndChange, + LedEffect, +) + + +def test_color_rgbw_is_four_bytes() -> None: + assert ColorRGBW(1.0, 0.5, 0.0, 0.25).as_bytes().hex() == "ff7f003f" + + +def test_color_rgbw_rejects_out_of_range_channels() -> None: + with pytest.raises(ValueError, match=r"within 0\.\.1"): + ColorRGBW(1.5, 0, 0, 0).as_bytes() + + +def test_color_hsv_is_three_floats() -> None: + # h=180.0, s=1.0, v=0.5 + assert ColorHSV(180.0, 1.0, 0.5).as_bytes().hex() == "00003443" "0000803f" "0000003f" + + +def test_color_hsv_from_rgb() -> None: + assert ColorHSV.from_rgb(ColorRGBW(1, 0, 0, 0)) == ColorHSV(0.0, 1.0, 1.0) + assert ColorHSV.from_rgb(ColorRGBW(0, 1, 0, 0)) == ColorHSV(120.0, 1.0, 1.0) + + +def test_static() -> None: + effect = EffectStaticConfig(ColorRGBW(1.0, 0.5, 0.0, 0.25), begin=3, end=45) + assert effect.as_bytes().hex() == "ff7f003f" "0300" "2d00" + + +def test_static_detailed() -> None: + effect = EffectStaticDetailedConfig( + ColorRGBW(0, 0, 0, 1.0), increment=4, begin=0.25, end=0.75, transition_time_in_ms=500 + ) + assert effect.as_bytes().hex() == ( + "000000ff" # color + "0400" # increment (uint16) + "0000803e" # begin 0.25f + "0000403f" # end 0.75f + "0000fa43" # transition 500.0f + ) + + +def test_circular() -> None: + effect = EffectCircularConfig(speed=360, width=180, color=ColorRGBW(0, 0, 1, 0)) + assert effect.as_bytes().hex() == "0000b443" "00003443" "0000ff00" + + +def test_reverse_swipe() -> None: + effect = EffectReverseSwipe(swipe_speed=720, bell_curve_width_in_leds=3, start_position=180) + assert effect.as_bytes().hex() == "00003444" "00004040" "00003443" + + +def test_alexa_swipe() -> None: + effect = EffectAlexaSwipeConfig( + primary_color_width=180, + transition_width=180, + swipe_speed=720, + bell_curve_width_in_leds=3, + start_position=180, + forward=False, + primary_color=ColorRGBW(1, 0, 0, 0), + secondary_color=ColorRGBW(0, 1, 0, 0), + ) + assert effect.as_bytes().hex() == ( + "00003443" "00003443" "00003444" "00004040" "00003443" # five floats + "00" # forward = false + "ff000000" # primary + "00ff0000" # secondary + ) + + +def test_random_two_color_interpolation() -> None: + effect = EffectRandomTwoColorInterpolationConfig( + cycle_durations_ms=1000, + start_with_existing=True, + num_segments=3, + hue1_random=False, + hue2_random=True, + color1=ColorHSV(180.0, 1.0, 0.5), + color2=ColorHSV(0.0, 0.0, 0.0), + ) + assert effect.as_bytes().hex() == ( + "e8030000" # cycle_durations_ms int32 + "01" # start_with_existing + "03000000" # num_segments int32 + "00" # hue1_random + "01" # hue2_random + "000034430000803f0000003f" # color1 hsv + "000000000000000000000000" # color2 hsv + ) + + +def test_rgb_colors_are_converted_to_hsv_on_the_wire() -> None: + """The firmware struct is HSV; reactions hand it RGBW figure colours.""" + as_rgb = EffectRandomTwoColorInterpolationConfig( + color1=ColorRGBW(1, 0, 0, 0), color2=ColorRGBW(0, 1, 0, 0) + ) + as_hsv = EffectRandomTwoColorInterpolationConfig( + color1=ColorHSV(0.0, 1.0, 1.0), color2=ColorHSV(120.0, 1.0, 1.0) + ) + assert as_rgb.as_bytes() == as_hsv.as_bytes() + + +def test_swipe_and_change_is_the_two_payloads_concatenated() -> None: + effect = EffectSwipeAndChange() + assert effect.as_bytes() == effect.swipe.as_bytes() + effect.change.as_bytes() + + +@pytest.mark.parametrize( + ("effect", "size"), + [ + (EffectStaticConfig(ColorRGBW(0, 0, 0, 0)), 8), + (EffectStaticDetailedConfig(ColorRGBW(0, 0, 0, 0)), 18), + (EffectCircularConfig(), 12), + (EffectAlexaSwipeConfig(), 29), + (EffectRandomTwoColorInterpolationConfig(), 35), + (EffectReverseSwipe(), 12), + (EffectSwipeAndChange(), 64), + ], +) +def test_payload_sizes(effect: LedEffect, size: int) -> None: + assert len(effect.as_bytes()) == size diff --git a/python-backend/tests/test_mouse.py b/python-backend/tests/test_mouse.py new file mode 100644 index 0000000..16a0ecf --- /dev/null +++ b/python-backend/tests/test_mouse.py @@ -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(" 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"), + ] diff --git a/python-backend/tests/test_mqtt.py b/python-backend/tests/test_mqtt.py new file mode 100644 index 0000000..88023bf --- /dev/null +++ b/python-backend/tests/test_mqtt.py @@ -0,0 +1,535 @@ +from __future__ import annotations + +import json +import logging +from collections.abc import AsyncIterator +from dataclasses import dataclass, field + +import pytest + +from musicmouse.bus import EventBus +from musicmouse.color import ColorRGBW +from musicmouse.config import MqttConfig +from musicmouse.devices.mouse import MusicMouseDevice +from musicmouse.effects import ( + EffectCircularConfig, + EffectRandomTwoColorInterpolationConfig, + EffectStaticDetailedConfig, + EffectSwipeAndChange, +) +from musicmouse.events import ( + ButtonEvent, + Event, + NextTrackRequested, + PauseRequested, + RfidTokenRead, + SetVolumeRequested, + TouchButtonPressed, +) +from musicmouse.hardware import Button, ButtonAction, LedZone, TouchButton +from musicmouse.services.mqtt.entity import Entity +from musicmouse.services.mqtt.lights import ( + BLACK, + LightEntity, + effect_names, + parse_positional_effect, +) +from musicmouse.services.mqtt.player import TransportButton, VolumeNumber +from musicmouse.services.mqtt.service import build_entities +from musicmouse.services.mqtt.triggers import ButtonTrigger, TagScanner, TouchTrigger +from musicmouse.simulator.fake_player import FakePlayer +from musicmouse.simulator.fake_transport import FakeTransport + +TAG_MAP = {bytes.fromhex("04a1b2c3d4"): "fuchs"} + + +@dataclass +class RecordingPublisher: + """Stands in for the broker connection.""" + + messages: list[tuple[str, str, bool]] = field(default_factory=list) + + async def publish(self, topic: str, payload: str, *, retain: bool = False) -> None: + self.messages.append((topic, payload, retain)) + + def payloads_on(self, topic: str) -> list[str]: + return [payload for sent_topic, payload, _ in self.messages if sent_topic == topic] + + def last_json(self, topic: str) -> dict: + return json.loads(self.payloads_on(topic)[-1]) + + def clear(self) -> None: + self.messages.clear() + + +@pytest.fixture +async def bus() -> AsyncIterator[EventBus]: + async with EventBus() as running: + yield running + + +@pytest.fixture +def mqtt_config() -> MqttConfig: + return MqttConfig(server="broker.local") + + +@pytest.fixture +def transport() -> FakeTransport: + return FakeTransport() + + +@pytest.fixture +def mouse(bus: EventBus, transport: FakeTransport) -> MusicMouseDevice: + device = MusicMouseDevice(bus, transport, TAG_MAP, port="simulated") + transport.attach(device.feed) + return device + + +@pytest.fixture +def player(bus: EventBus) -> FakePlayer: + return FakePlayer(bus, initial_volume=40) + + +@pytest.fixture +def publisher() -> RecordingPublisher: + return RecordingPublisher() + + +def attach(entity: Entity, publisher: RecordingPublisher) -> Entity: + entity.attach(publisher) + return entity + + +@pytest.fixture +def shelf( + bus: EventBus, mqtt_config: MqttConfig, mouse: MusicMouseDevice, publisher: RecordingPublisher +) -> LightEntity: + entity = LightEntity(bus, mqtt_config, mouse, LedZone.SHELF, "Shelf") + entity.attach(publisher) + return entity + + +def command(**fields: object) -> str: + return json.dumps(fields) + + +# ------------------------------------------------------------------ effect names + + +def test_the_old_effect_names_still_decode_the_same_way() -> None: + """Values taken from the hand-written elif chain in the old mqtt_json.py.""" + assert parse_positional_effect("side_0.2") == (0.9, 0.1, 1) + assert parse_positional_effect("side_0.5") == (0.75, 0.25, 1) + assert parse_positional_effect("top_0.2") == (0.4, 0.6, 1) + assert parse_positional_effect("top_0.5") == (0.25, 0.75, 1) + assert parse_positional_effect("side_0.2_inc4") == (0.9, 0.1, 4) + assert parse_positional_effect("side_0.2_inc8") == (0.9, 0.1, 8) + assert parse_positional_effect("top_0.5_inc4") == (0.25, 0.75, 4) + + +@pytest.mark.parametrize("name", ["static", "circular", "sideways_0.2", "side", "top_", "side_x"]) +def test_non_positional_names_are_not_parsed(name: str) -> None: + assert parse_positional_effect(name) is None + + +def test_every_old_effect_name_is_still_offered() -> None: + previously_offered = { + "static", "circular", "wipeup", "twocolor", "twocolorrandom", + "side_0.2", "side_0.5", "side_0.2_inc4", "side_0.2_inc8", "side_0.5_inc4", + "top_0.2", "top_0.5", "top_0.2_inc4", "top_0.5_inc4", + } # fmt: skip + assert previously_offered <= set(effect_names()) + + +def test_every_offered_effect_name_can_be_built( + shelf: LightEntity, mouse: MusicMouseDevice +) -> None: + for name in effect_names(): + shelf._state.update({"state": "ON", "effect": name}) + assert shelf._build_effect() is not None + + +# ------------------------------------------------------------------- light commands + + +async def test_turning_the_light_on_sets_a_static_effect( + bus: EventBus, shelf: LightEntity, mouse: MusicMouseDevice +) -> None: + await shelf.handle( + shelf.command_topic, + command(state="ON", color={"r": 255, "g": 0, "b": 0, "w": 0}, brightness=255), + ) + await bus.drain() + + effect = mouse.effect(LedZone.SHELF) + assert isinstance(effect, EffectStaticDetailedConfig) + assert effect.color == ColorRGBW(1.0, 0.0, 0.0, 0.0) + + +async def test_brightness_scales_the_colour( + bus: EventBus, shelf: LightEntity, mouse: MusicMouseDevice +) -> None: + await shelf.handle( + shelf.command_topic, + command(state="ON", color={"r": 255, "g": 0, "b": 0, "w": 0}, brightness=128), + ) + await bus.drain() + + effect = mouse.effect(LedZone.SHELF) + assert isinstance(effect, EffectStaticDetailedConfig) + assert effect.color.r == pytest.approx(128 / 255, abs=0.01) + + +async def test_turning_the_light_off_sends_black( + bus: EventBus, shelf: LightEntity, mouse: MusicMouseDevice +) -> None: + await shelf.handle(shelf.command_topic, command(state="OFF")) + await bus.drain() + + effect = mouse.effect(LedZone.SHELF) + assert isinstance(effect, EffectStaticDetailedConfig) + assert effect.color == BLACK + + +async def test_a_positional_effect_becomes_a_detailed_static( + bus: EventBus, shelf: LightEntity, mouse: MusicMouseDevice +) -> None: + await shelf.handle(shelf.command_topic, command(state="ON", effect="top_0.5_inc4")) + await bus.drain() + + effect = mouse.effect(LedZone.SHELF) + assert isinstance(effect, EffectStaticDetailedConfig) + assert (effect.begin, effect.end, effect.increment) == (0.25, 0.75, 4) + + +@pytest.mark.parametrize( + ("name", "expected"), + [ + ("circular", EffectCircularConfig), + ("wipeup", EffectSwipeAndChange), + ("twocolor", EffectRandomTwoColorInterpolationConfig), + ("twocolorrandom", EffectRandomTwoColorInterpolationConfig), + ], +) +async def test_named_effects( + bus: EventBus, shelf: LightEntity, mouse: MusicMouseDevice, name: str, expected: type +) -> None: + await shelf.handle(shelf.command_topic, command(state="ON", effect=name)) + await bus.drain() + assert isinstance(mouse.effect(LedZone.SHELF), expected) + + +async def test_twocolorrandom_randomises_both_hues( + bus: EventBus, shelf: LightEntity, mouse: MusicMouseDevice +) -> None: + await shelf.handle(shelf.command_topic, command(state="ON", effect="twocolorrandom")) + await bus.drain() + + effect = mouse.effect(LedZone.SHELF) + assert isinstance(effect, EffectRandomTwoColorInterpolationConfig) + assert effect.hue1_random and effect.hue2_random + + +async def test_an_unknown_effect_turns_the_light_off_with_a_warning( + bus: EventBus, shelf: LightEntity, mouse: MusicMouseDevice, caplog: pytest.LogCaptureFixture +) -> None: + with caplog.at_level(logging.WARNING): + await shelf.handle(shelf.command_topic, command(state="ON", effect="disco")) + await bus.drain() + + effect = mouse.effect(LedZone.SHELF) + assert isinstance(effect, EffectStaticDetailedConfig) + assert effect.color == BLACK + assert "Unknown effect 'disco'" in caplog.text + + +async def test_malformed_json_is_ignored( + bus: EventBus, shelf: LightEntity, mouse: MusicMouseDevice, caplog: pytest.LogCaptureFixture +) -> None: + with caplog.at_level(logging.WARNING): + await shelf.handle(shelf.command_topic, "not json at all") + await bus.drain() + + assert mouse.effect(LedZone.SHELF) is None + assert "non-JSON command" in caplog.text + + +async def test_the_previous_colour_is_remembered_for_two_colour_effects( + bus: EventBus, shelf: LightEntity, mouse: MusicMouseDevice +) -> None: + await shelf.handle( + shelf.command_topic, + command(state="ON", color={"r": 255, "g": 0, "b": 0, "w": 0}, brightness=255), + ) + await shelf.handle( + shelf.command_topic, + command( + state="ON", + color={"r": 0, "g": 0, "b": 255, "w": 0}, + brightness=255, + effect="twocolor", + ), + ) + await bus.drain() + + effect = mouse.effect(LedZone.SHELF) + assert isinstance(effect, EffectRandomTwoColorInterpolationConfig) + assert effect.color1 == ColorRGBW(0, 0, 1, 0) + assert effect.color2 == ColorRGBW(1, 0, 0, 0) + + +# ---------------------------------------------------------------- state reporting + + +async def test_state_is_published_after_a_command( + bus: EventBus, shelf: LightEntity, publisher: RecordingPublisher +) -> None: + await shelf.handle(shelf.command_topic, command(state="ON", effect="static")) + await bus.drain() + + assert publisher.last_json(shelf.state_topic)["state"] == "ON" + + +async def test_a_local_effect_updates_what_home_assistant_sees( + bus: EventBus, shelf: LightEntity, mouse: MusicMouseDevice, publisher: RecordingPublisher +) -> None: + """The whole point of publishing from LedEffectChanged rather than the command echo.""" + await shelf.handle(shelf.command_topic, command(state="OFF")) + await bus.drain() + assert publisher.last_json(shelf.state_topic)["state"] == "OFF" + + mouse.set_effect( + LedZone.SHELF, EffectCircularConfig(color=ColorRGBW(0, 1, 0, 0)), origin="device" + ) + await bus.drain() + + reported = publisher.last_json(shelf.state_topic) + assert reported["state"] == "ON" + assert reported["color"] == {"r": 0, "g": 255, "b": 0, "w": 0} + + +async def test_a_local_off_effect_is_reported_as_off( + bus: EventBus, shelf: LightEntity, mouse: MusicMouseDevice, publisher: RecordingPublisher +) -> None: + from musicmouse.effects import OFF + + await shelf.handle(shelf.command_topic, command(state="ON", effect="static")) + await bus.drain() + + mouse.set_effect(LedZone.SHELF, OFF(), origin="device") + await bus.drain() + + assert publisher.last_json(shelf.state_topic)["state"] == "OFF" + + +async def test_another_zone_does_not_affect_this_entity( + bus: EventBus, shelf: LightEntity, mouse: MusicMouseDevice, publisher: RecordingPublisher +) -> None: + publisher.clear() + mouse.set_effect(LedZone.RING, EffectCircularConfig(), origin="device") + await bus.drain() + + assert publisher.payloads_on(shelf.state_topic) == [] + + +async def test_discovery_declares_a_json_rgbw_light_with_effects(shelf: LightEntity) -> None: + payload = shelf.discovery_payload() + + assert payload["schema"] == "json" + assert payload["supported_color_modes"] == ["rgbw"] + assert payload["command_topic"] == shelf.command_topic + assert "top_0.5_inc4" in payload["effect_list"] + assert payload["device"]["identifiers"] == ["musicmouse"] + + +async def test_announce_publishes_retained_discovery_then_state( + shelf: LightEntity, publisher: RecordingPublisher +) -> None: + await shelf.announce() + + topics = [topic for topic, _, _ in publisher.messages] + assert topics == [shelf.discovery_topic, shelf.state_topic] + assert publisher.messages[0][2] is True # discovery is retained + + +async def test_nothing_is_published_while_offline(shelf: LightEntity) -> None: + shelf.attach(None) + await shelf.announce() # must not raise + + +# ----------------------------------------------------------------------- player + + +async def test_volume_command_emits_an_intent( + bus: EventBus, mqtt_config: MqttConfig, player: FakePlayer +) -> None: + seen: list[Event] = [] + bus.subscribe(SetVolumeRequested, seen.append) + entity = VolumeNumber(bus, mqtt_config, player) + + await entity.handle(entity.command_topic, "37") + await bus.drain() + + assert seen == [SetVolumeRequested(volume=37, source="mqtt")] + + +async def test_a_non_numeric_volume_is_ignored( + bus: EventBus, mqtt_config: MqttConfig, player: FakePlayer, caplog: pytest.LogCaptureFixture +) -> None: + seen: list[Event] = [] + bus.subscribe(SetVolumeRequested, seen.append) + entity = VolumeNumber(bus, mqtt_config, player) + + with caplog.at_level(logging.WARNING): + await entity.handle(entity.command_topic, "loud") + await bus.drain() + + assert seen == [] + assert "non-numeric volume" in caplog.text + + +async def test_volume_state_follows_the_player( + bus: EventBus, mqtt_config: MqttConfig, player: FakePlayer, publisher: RecordingPublisher +) -> None: + entity = VolumeNumber(bus, mqtt_config, player) + entity.attach(publisher) + + player.set_volume(22) + await bus.drain() + + assert publisher.payloads_on(entity.state_topic)[-1] == "22" + + +@pytest.mark.parametrize( + ("action", "expected"), + [("next", NextTrackRequested), ("pause", PauseRequested)], +) +async def test_transport_buttons_emit_intents( + bus: EventBus, mqtt_config: MqttConfig, action: str, expected: type[Event] +) -> None: + seen: list[Event] = [] + bus.subscribe(expected, seen.append) + entity = TransportButton(bus, mqtt_config, action, f"Music Mouse {action}") + + await entity.handle(entity.command_topic, "PRESS") + await bus.drain() + + assert len(seen) == 1 + assert seen[0].source == "mqtt" + + +async def test_the_player_sensor_reports_the_current_track( + bus: EventBus, mqtt_config: MqttConfig, player: FakePlayer, publisher: RecordingPublisher +) -> None: + from pathlib import Path + + from musicmouse.media import Playlist, Track + from musicmouse.services.mqtt.player import PlayerSensor + + entity = PlayerSensor(bus, mqtt_config, player) + entity.attach(publisher) + player.set_playlist(Playlist(name="fuchs", tracks=(Track(Path("/m/01 - Song.mp3")),))) + player.play_from_start() + await bus.drain() + + assert publisher.payloads_on(entity.state_topic)[-1] == "playing" + attributes = publisher.last_json(f"{entity.base_topic}/attributes") + assert attributes["playlist"] == "fuchs" + assert attributes["title"] == "01 - Song" + assert attributes["volume"] == 40 + + +# --------------------------------------------------------------------- triggers + + +async def test_a_button_trigger_fires_only_for_its_own_event( + bus: EventBus, mqtt_config: MqttConfig, publisher: RecordingPublisher +) -> None: + trigger = ButtonTrigger(bus, mqtt_config, Button.ROTARY, ButtonAction.PRESSED) + trigger.attach(publisher) + + bus.emit(ButtonEvent(button=Button.LEFT, action=ButtonAction.PRESSED, source="device")) + bus.emit(ButtonEvent(button=Button.ROTARY, action=ButtonAction.RELEASED, source="device")) + bus.emit(ButtonEvent(button=Button.ROTARY, action=ButtonAction.PRESSED, source="device")) + await bus.drain() + + assert publisher.payloads_on(trigger.trigger_topic) == ["pressed"] + + +async def test_button_trigger_discovery(bus: EventBus, mqtt_config: MqttConfig) -> None: + trigger = ButtonTrigger(bus, mqtt_config, Button.ROTARY, ButtonAction.LONG_PRESSED) + payload = trigger.discovery_payload() + + assert trigger.discovery_topic == ( + "homeassistant/device_automation/musicmouse/rotary_long_pressed/config" + ) + assert payload["automation_type"] == "trigger" + assert payload["type"] == "button_long_press" + assert payload["subtype"] == "rotary" + + +async def test_a_touch_trigger_fires( + bus: EventBus, mqtt_config: MqttConfig, publisher: RecordingPublisher +) -> None: + trigger = TouchTrigger(bus, mqtt_config, TouchButton.LEFT_EAR, pressed=True) + trigger.attach(publisher) + + bus.emit(TouchButtonPressed(button=TouchButton.RIGHT_EAR, source="device")) + bus.emit(TouchButtonPressed(button=TouchButton.LEFT_EAR, source="device")) + await bus.drain() + + assert publisher.payloads_on(trigger.trigger_topic) == ["left_ear"] + + +async def test_the_tag_scanner_publishes_every_read( + bus: EventBus, mqtt_config: MqttConfig, mouse: MusicMouseDevice, publisher: RecordingPublisher +) -> None: + scanner = TagScanner(bus, mqtt_config) + scanner.attach(publisher) + + bus.emit( + RfidTokenRead(tag_id=bytes.fromhex("04a1b2c3d4"), figure="fuchs", source="device") + ) + await bus.drain() + + assert json.loads(publisher.payloads_on(scanner.scan_topic)[-1]) == { + "tag_id": "04a1b2c3d4", + "figure": "fuchs", + "known": True, + } + + +async def test_tag_scanner_discovery(bus: EventBus, mqtt_config: MqttConfig) -> None: + scanner = TagScanner(bus, mqtt_config) + assert scanner.discovery_topic == "homeassistant/tag/musicmouse/config" + assert scanner.discovery_payload()["value_template"] == "{{ value_json.tag_id }}" + + +# ---------------------------------------------------------------------- wiring + + +def test_every_entity_has_a_unique_discovery_topic( + bus: EventBus, mqtt_config: MqttConfig, mouse: MusicMouseDevice, player: FakePlayer +) -> None: + entities = build_entities(bus, mqtt_config, mouse, player) + topics = [entity.discovery_topic for entity in entities] + + assert len(topics) == len(set(topics)) + assert len(entities) > 20 + + +def test_command_topics_do_not_collide( + bus: EventBus, mqtt_config: MqttConfig, mouse: MusicMouseDevice, player: FakePlayer +) -> None: + entities = build_entities(bus, mqtt_config, mouse, player) + topics = [topic for entity in entities for topic in entity.command_topics()] + + assert len(topics) == len(set(topics)) + + +def test_all_three_led_zones_are_exposed( + bus: EventBus, mqtt_config: MqttConfig, mouse: MusicMouseDevice, player: FakePlayer +) -> None: + entities = build_entities(bus, mqtt_config, mouse, player) + lights = [e for e in entities if isinstance(e, LightEntity)] + assert {light.zone for light in lights} == set(LedZone) diff --git a/python-backend/tests/test_player.py b/python-backend/tests/test_player.py new file mode 100644 index 0000000..589bd78 --- /dev/null +++ b/python-backend/tests/test_player.py @@ -0,0 +1,297 @@ +"""Tests for the shared player behaviour, exercised through FakePlayer. + +VlcPlayer adds only the libVLC bindings on top of PlayerBase; it needs a real audio +device and is covered by the on-device checklist, not here. +""" + +from __future__ import annotations + +from collections.abc import AsyncIterator +from pathlib import Path + +import pytest + +from musicmouse.bus import EventBus +from musicmouse.clock import FakeClock +from musicmouse.devices.player import Player, VlcPlayer +from musicmouse.events import ( + Event, + PlaybackChanged, + PlaylistFinished, + TrackChanged, + VolumeChanged, +) +from musicmouse.media import Playlist, Track +from musicmouse.simulator.fake_player import FakePlayer + + +def playlist(name: str = "fuchs", count: int = 3) -> Playlist: + tracks = tuple(Track(Path(f"/music/{name}/{i}.mp3")) for i in range(count)) + return Playlist(name=name, tracks=tracks) + + +@pytest.fixture +async def bus() -> AsyncIterator[EventBus]: + async with EventBus() as running: + yield running + + +@pytest.fixture +def clock(bus: EventBus) -> FakeClock: + return FakeClock(idle=bus.drain) + + +@pytest.fixture +def player(bus: EventBus, clock: FakeClock) -> FakePlayer: + return FakePlayer(bus, clock=clock, track_duration=10.0, initial_volume=50) + + +@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)] + + +# ------------------------------------------------------------------------ volume + + +async def test_volume_starts_at_the_configured_value(player: FakePlayer) -> None: + assert player.volume == 50 + + +async def test_setting_volume_announces_it( + bus: EventBus, player: FakePlayer, seen: list[Event] +) -> None: + player.set_volume(30, source="mqtt") + await bus.drain() + + assert player.volume == 30 + assert only(seen, VolumeChanged) == [VolumeChanged(volume=30, source="mqtt")] + + +async def test_setting_the_same_volume_is_not_announced( + bus: EventBus, player: FakePlayer, seen: list[Event] +) -> None: + player.set_volume(50) + await bus.drain() + assert only(seen, VolumeChanged) == [] + + +async def test_change_volume_is_relative(bus: EventBus, player: FakePlayer) -> None: + player.change_volume(-20) + await bus.drain() + assert player.volume == 30 + + +async def test_volume_is_clamped_to_the_configured_range(bus: EventBus) -> None: + limited = FakePlayer(bus, min_volume=20, max_volume=60, initial_volume=40) + + limited.set_volume(100) + assert limited.volume == 60 + limited.set_volume(0) + assert limited.volume == 20 + + +async def test_min_volume_of_zero_is_honoured(bus: EventBus) -> None: + """Regression: `if self.volume_min and ...` treated a configured 0 as unset.""" + limited = FakePlayer(bus, min_volume=0, max_volume=100, initial_volume=10) + + limited.set_volume(-5) + + assert limited.volume == 0 + + +# --------------------------------------------------------------------- playback + + +async def test_play_from_start_starts_the_first_track( + bus: EventBus, player: FakePlayer, seen: list[Event] +) -> None: + player.set_playlist(playlist()) + player.play_from_start() + await bus.drain() + + assert player.is_playing + assert player.track_index == 0 + assert player.current_track is not None + assert player.current_track.title == "0" + assert only(seen, PlaybackChanged)[-1].playing is True + + +async def test_playing_an_empty_playlist_does_nothing( + bus: EventBus, player: FakePlayer, caplog: pytest.LogCaptureFixture +) -> None: + player.set_playlist(Playlist(name="leer", tracks=())) + player.play_from_start() + await bus.drain() + + assert not player.is_playing + assert "playlist is empty" in caplog.text + + +async def test_pause_and_resume(bus: EventBus, player: FakePlayer) -> None: + player.set_playlist(playlist()) + player.play_from_start() + player.pause() + await bus.drain() + assert not player.is_playing + + player.play() + await bus.drain() + assert player.is_playing + + +async def test_tracks_advance_as_time_passes( + bus: EventBus, player: FakePlayer, clock: FakeClock, seen: list[Event] +) -> None: + player.set_playlist(playlist(count=3)) + player.play_from_start() + await bus.drain() + + await clock.advance(10.0) + assert player.track_index == 1 + + await clock.advance(10.0) + assert player.track_index == 2 + + assert [e.index for e in only(seen, TrackChanged)] == [1, 2] + + +async def test_playlist_end_stops_playback_and_is_announced( + bus: EventBus, player: FakePlayer, clock: FakeClock, seen: list[Event] +) -> None: + player.set_playlist(playlist(count=2)) + player.play_from_start() + + await clock.advance(25.0) + + assert only(seen, PlaylistFinished) == [PlaylistFinished(source="player")] + assert not player.is_playing + + +async def test_a_paused_player_does_not_advance( + bus: EventBus, player: FakePlayer, clock: FakeClock +) -> None: + player.set_playlist(playlist()) + player.play_from_start() + await bus.drain() + player.pause() + + await clock.advance(100.0) + + assert player.track_index == 0 + assert not player.is_playing + + +async def test_resuming_continues_the_remainder_of_the_track( + bus: EventBus, player: FakePlayer, clock: FakeClock +) -> None: + player.set_playlist(playlist()) + player.play_from_start() + await clock.advance(7.0) + player.pause() + await clock.advance(100.0) + player.play() + + await clock.advance(2.0) + assert player.track_index == 0 # 3s of the track were still left + + await clock.advance(2.0) + assert player.track_index == 1 + + +async def test_next_and_previous(bus: EventBus, player: FakePlayer) -> None: + player.set_playlist(playlist(count=3)) + player.play_from_start() + await bus.drain() + + player.next_track() + player.next_track() + await bus.drain() + assert player.track_index == 2 + + player.previous_track() + await bus.drain() + assert player.track_index == 1 + + +async def test_previous_on_the_first_track_stays_there(bus: EventBus, player: FakePlayer) -> None: + player.set_playlist(playlist()) + player.play_from_start() + player.previous_track() + await bus.drain() + + assert player.track_index == 0 + + +async def test_next_past_the_last_track_ends_the_playlist( + bus: EventBus, player: FakePlayer, seen: list[Event] +) -> None: + player.set_playlist(playlist(count=2)) + player.play_from_start() + player.next_track() + player.next_track() + await bus.drain() + + assert only(seen, PlaylistFinished) == [PlaylistFinished(source="player")] + assert not player.is_playing + + +async def test_skipping_restarts_the_track_timer( + bus: EventBus, player: FakePlayer, clock: FakeClock +) -> None: + player.set_playlist(playlist(count=3)) + player.play_from_start() + await clock.advance(9.0) + player.next_track() + await bus.drain() + + await clock.advance(9.0) + assert player.track_index == 1 # a fresh 10s, not the 1s left over + + await clock.advance(2.0) + assert player.track_index == 2 + + +async def test_stop_resets_playback(bus: EventBus, player: FakePlayer, clock: FakeClock) -> None: + player.set_playlist(playlist()) + player.play_from_start() + player.stop() + await bus.drain() + + assert not player.is_playing + await clock.advance(100.0) + assert player.track_index == 0 + + +async def test_setting_a_new_playlist_resets_the_index(bus: EventBus, player: FakePlayer) -> None: + player.set_playlist(playlist(count=3)) + player.play_from_start() + player.next_track() + await bus.drain() + + player.set_playlist(playlist(name="eule", count=2)) + + assert player.track_index == 0 + assert player.playlist is not None + assert player.playlist.name == "eule" + + +async def test_current_track_is_none_without_a_playlist(player: FakePlayer) -> None: + assert player.current_track is None + assert player.playlist is None + + +def test_fake_player_satisfies_the_player_protocol(player: FakePlayer) -> None: + check: Player = player + assert check.volume == player.volume + + +def _vlc_player_satisfies_the_player_protocol(real: VlcPlayer) -> Player: + """Checked by mypy, not at runtime: VlcPlayer needs libVLC to instantiate.""" + return real diff --git a/python-backend/tests/test_reactions.py b/python-backend/tests/test_reactions.py new file mode 100644 index 0000000..ed5c2a2 --- /dev/null +++ b/python-backend/tests/test_reactions.py @@ -0,0 +1,369 @@ +"""The behaviour of the mouse, driven end to end through the simulator. + +Everything between the tag being read and the LED bytes being written is production +code here - only the serial link and VLC are substituted. +""" + +from __future__ import annotations + +from collections.abc import AsyncIterator +from pathlib import Path + +import pytest + +from musicmouse.config import load_config +from musicmouse.effects import ( + EffectRandomTwoColorInterpolationConfig, + EffectReverseSwipe, + EffectStaticConfig, + EffectSwipeAndChange, +) +from musicmouse.events import Event, LedEffectChanged, VolumeChanged +from musicmouse.hardware import MOUSE_LED_RANGES, LedZone, TouchButton +from musicmouse.simulator.driver import SimulatorDriver +from musicmouse.simulator.harness import Simulation, build_simulation +from tests.conftest import VALID_CONFIG, write_config + +TRACK_SECONDS = 10.0 + + +@pytest.fixture +async def sim(config_dir: Path) -> AsyncIterator[Simulation]: + config = load_config(write_config(config_dir, VALID_CONFIG)) + simulation = await build_simulation(config, track_duration=TRACK_SECONDS) + try: + yield simulation + finally: + await simulation.aclose() + + +@pytest.fixture +def mouse(sim: Simulation) -> SimulatorDriver: + return sim.driver + + +@pytest.fixture +def seen(sim: Simulation) -> list[Event]: + events: list[Event] = [] + sim.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)] + + +# ------------------------------------------------------------------ figure on/off + + +async def test_placing_a_figure_starts_its_playlist(mouse: SimulatorDriver) -> None: + await mouse.place("fuchs") + + mouse.check("figure", "fuchs") + mouse.check("playing", "true") + mouse.check("playlist", "fuchs") + mouse.check("track", "0") + + +async def test_placing_a_figure_lights_the_leds( + mouse: SimulatorDriver, sim: Simulation +) -> None: + await mouse.place("fuchs") + + for zone in LedZone: + assert isinstance(sim.app.mouse.effect(zone), EffectSwipeAndChange) + assert sim.app.mouse.button_led_brightness == pytest.approx(0.5) + + +async def test_the_figures_colours_are_used(mouse: SimulatorDriver, sim: Simulation) -> None: + await mouse.place("fuchs") + + effect = sim.app.mouse.effect(LedZone.RING) + assert isinstance(effect, EffectSwipeAndChange) + assert effect.swipe.primary_color == sim.app.colors("fuchs").primary + assert effect.swipe.secondary_color == sim.app.colors("fuchs").secondary + + +async def test_the_mouse_swipe_is_offset_from_the_ring( + mouse: SimulatorDriver, sim: Simulation +) -> None: + await mouse.place("fuchs") + + ring = sim.app.mouse.effect(LedZone.RING) + body = sim.app.mouse.effect(LedZone.MOUSE) + assert isinstance(ring, EffectSwipeAndChange) + assert isinstance(body, EffectSwipeAndChange) + assert body.swipe.start_position != ring.swipe.start_position + + +async def test_removing_a_figure_pauses_and_runs_the_off_animation( + mouse: SimulatorDriver, sim: Simulation +) -> None: + await mouse.place("fuchs") + await mouse.remove() + + mouse.check("figure", "none") + mouse.check("playing", "false") + assert isinstance(sim.app.mouse.effect(LedZone.RING), EffectReverseSwipe) + assert sim.app.mouse.button_led_brightness == pytest.approx(0.0) + + +async def test_putting_the_same_figure_back_resumes_where_it_left_off( + mouse: SimulatorDriver, +) -> None: + await mouse.place("fuchs") + await mouse.wait(TRACK_SECONDS + 1) + mouse.check("track", "1") + + await mouse.remove() + await mouse.place("fuchs") + + mouse.check("playing", "true") + mouse.check("track", "1") + + +async def test_a_different_figure_starts_from_the_top(mouse: SimulatorDriver) -> None: + await mouse.place("fuchs") + await mouse.wait(TRACK_SECONDS + 1) + await mouse.remove() + + await mouse.place("eule") + + mouse.check("playlist", "eule") + mouse.check("track", "0") + + +async def test_swapping_figures_without_removing_first(mouse: SimulatorDriver) -> None: + await mouse.place("fuchs") + await mouse.place("eule") + + mouse.check("figure", "eule") + mouse.check("playlist", "eule") + mouse.check("playing", "true") + + +async def test_replacing_a_figure_after_its_playlist_finished_starts_over( + mouse: SimulatorDriver, +) -> None: + await mouse.place("fuchs") + await mouse.wait(TRACK_SECONDS * 5) # fuchs has 3 tracks + mouse.check("playing", "false") + + await mouse.remove() + await mouse.place("fuchs") + + mouse.check("track", "0") + mouse.check("playing", "true") + + +async def test_an_unknown_tag_changes_nothing(mouse: SimulatorDriver) -> None: + await mouse.place("fuchs") + await mouse.tag(bytes.fromhex("0999999999")) + + mouse.check("figure", "fuchs") + mouse.check("playing", "true") + + +# ------------------------------------------------------------------- playlist end + + +async def test_the_playlist_ending_runs_the_off_animation( + mouse: SimulatorDriver, sim: Simulation +) -> None: + await mouse.place("eule") # two tracks + await mouse.wait(TRACK_SECONDS * 2 + 1) + + mouse.check("playing", "false") + assert isinstance(sim.app.mouse.effect(LedZone.RING), EffectReverseSwipe) + + +# ----------------------------------------------------------------------- buttons + + +async def test_the_right_button_skips_forward(mouse: SimulatorDriver) -> None: + await mouse.place("fuchs") + await mouse.press("right") + + mouse.check("track", "1") + + +async def test_the_left_button_skips_back(mouse: SimulatorDriver) -> None: + await mouse.place("fuchs") + await mouse.press("right") + await mouse.press("left") + + mouse.check("track", "0") + + +async def test_buttons_do_nothing_while_paused(mouse: SimulatorDriver) -> None: + await mouse.place("fuchs") + await mouse.remove() + await mouse.press("right") + + mouse.check("track", "0") + + +async def test_only_the_press_acts_not_the_release(mouse: SimulatorDriver) -> None: + await mouse.place("fuchs") + await mouse.press("right", "released") + await mouse.press("right", "clicked") + + mouse.check("track", "0") + + +async def test_the_rotary_press_does_not_touch_playback(mouse: SimulatorDriver) -> None: + """It is published to Home Assistant instead; the backend has no opinion.""" + await mouse.place("fuchs") + await mouse.press("rotary") + + mouse.check("playing", "true") + mouse.check("track", "0") + + +# ------------------------------------------------------------------------ volume + + +async def test_turning_the_encoder_up_raises_the_volume( + mouse: SimulatorDriver, seen: list[Event] +) -> None: + await mouse.turn(1) + + mouse.check("volume", "45") # 40 initial + 5 increment + assert only(seen, VolumeChanged)[-1].volume == 45 + + +async def test_turning_the_encoder_down_lowers_the_volume(mouse: SimulatorDriver) -> None: + await mouse.turn(-1) + mouse.check("volume", "35") + + +async def test_several_clicks_in_one_event_scale_the_step(mouse: SimulatorDriver) -> None: + await mouse.turn(3) + mouse.check("volume", "55") + + +async def test_volume_stops_at_the_configured_maximum(mouse: SimulatorDriver) -> None: + for _ in range(20): + await mouse.turn(1) + mouse.check("volume", "60") + + +async def test_volume_stops_at_the_configured_minimum(mouse: SimulatorDriver) -> None: + for _ in range(20): + await mouse.turn(-1) + mouse.check("volume", "0") + + +async def test_setting_the_volume_directly(mouse: SimulatorDriver) -> None: + await mouse.set_volume(25) + mouse.check("volume", "25") + + +# ------------------------------------------------------------------ touch buttons + + +@pytest.mark.parametrize("button", list(TouchButton)) +async def test_touching_lights_that_body_part_in_the_accent_colour( + mouse: SimulatorDriver, sim: Simulation, button: TouchButton +) -> None: + await mouse.place("fuchs") + await mouse.touch(button.slug) + + effect = sim.app.mouse.effect(LedZone.MOUSE) + assert isinstance(effect, EffectStaticConfig) + assert effect.color == sim.app.colors("fuchs").accent + assert (effect.begin, effect.end) == MOUSE_LED_RANGES[button] + + +async def test_releasing_restores_the_body_effect( + mouse: SimulatorDriver, sim: Simulation +) -> None: + await mouse.place("fuchs") + await mouse.touch("left_ear") + await mouse.release("left_ear") + + assert isinstance(sim.app.mouse.effect(LedZone.MOUSE), EffectRandomTwoColorInterpolationConfig) + + +async def test_releasing_clears_the_area_before_restoring( + mouse: SimulatorDriver, sim: Simulation +) -> None: + await mouse.place("fuchs") + await mouse.touch("left_ear") + sim.transport.clear() + await mouse.release("left_ear") + + written = sim.transport.effects_for(LedZone.MOUSE) + assert isinstance(written[0], EffectStaticConfig) + assert written[0].color == sim.app.colors("fuchs").primary + assert isinstance(written[1], EffectRandomTwoColorInterpolationConfig) + + +async def test_touching_with_no_figure_does_nothing( + mouse: SimulatorDriver, sim: Simulation +) -> None: + await mouse.touch("left_ear") + assert sim.app.mouse.effect(LedZone.MOUSE) is None + + +async def test_touching_while_paused_does_nothing( + mouse: SimulatorDriver, sim: Simulation +) -> None: + await mouse.place("fuchs") + await mouse.remove() + sim.transport.clear() + + await mouse.touch("left_ear") + + assert sim.transport.effects_for(LedZone.MOUSE) == [] + + +# ------------------------------------------------------- light arbitration & reconnect + + +async def test_an_mqtt_command_wins_until_the_next_figure_animation( + mouse: SimulatorDriver, sim: Simulation +) -> None: + """Last write wins, whichever side it came from.""" + from musicmouse.color import ColorRGBW + + from_mqtt = EffectStaticConfig(ColorRGBW(0, 0, 1, 0)) + sim.app.mouse.set_effect(LedZone.SHELF, from_mqtt, origin="mqtt") + await mouse.settle() + assert sim.app.mouse.effect(LedZone.SHELF) == from_mqtt + + await mouse.place("fuchs") + assert isinstance(sim.app.mouse.effect(LedZone.SHELF), EffectSwipeAndChange) + + sim.app.mouse.set_effect(LedZone.SHELF, from_mqtt, origin="mqtt") + await mouse.settle() + assert sim.app.mouse.effect(LedZone.SHELF) == from_mqtt + + +async def test_every_led_write_is_announced_with_its_origin( + mouse: SimulatorDriver, seen: list[Event] +) -> None: + await mouse.place("fuchs") + + origins = {e.origin for e in only(seen, LedEffectChanged)} + assert origins == {"device"} + assert {e.zone for e in only(seen, LedEffectChanged)} == set(LedZone) + + +async def test_reconnecting_restores_the_leds( + mouse: SimulatorDriver, sim: Simulation +) -> None: + await mouse.place("fuchs") + before = sim.app.mouse.effect(LedZone.RING) + assert before is not None + + await mouse.disconnect() + sim.transport.clear() + await mouse.reconnect() + + restored = sim.transport.effect(LedZone.RING) + assert restored is not None + # Compared as bytes: two-colour effects travel as HSV, so the decoded object holds + # equivalent-but-not-equal colours. + assert restored.as_bytes() == before.as_bytes() + assert sim.transport.brightness() == pytest.approx(0.5) diff --git a/python-backend/tests/test_scenarios.py b/python-backend/tests/test_scenarios.py new file mode 100644 index 0000000..68d86cb --- /dev/null +++ b/python-backend/tests/test_scenarios.py @@ -0,0 +1,104 @@ +"""Run every file in ``scenarios/`` as a test. + +Same driver, same verbs, same code path as the interactive simulator - only the clock +differs, so a scenario that takes half a minute by hand runs in microseconds here. +""" + +from __future__ import annotations + +from collections.abc import AsyncIterator +from pathlib import Path + +import pytest + +from musicmouse.config import load_config +from musicmouse.simulator.driver import ExpectationError, ScriptError +from musicmouse.simulator.harness import Simulation, build_simulation +from musicmouse.simulator.script import run_script_file +from tests.conftest import VALID_CONFIG, write_config + +SCENARIO_DIR = Path(__file__).resolve().parents[1] / "scenarios" +SCENARIOS = sorted(SCENARIO_DIR.glob("*.txt")) + + +@pytest.fixture +async def sim(config_dir: Path) -> AsyncIterator[Simulation]: + config = load_config(write_config(config_dir, VALID_CONFIG)) + simulation = await build_simulation(config) + try: + yield simulation + finally: + await simulation.aclose() + + +def test_scenario_files_exist() -> None: + assert SCENARIOS, f"no scenario files found in {SCENARIO_DIR}" + + +@pytest.mark.parametrize("scenario", SCENARIOS, ids=lambda p: p.stem) +async def test_scenario(sim: Simulation, scenario: Path) -> None: + await run_script_file(sim, scenario) + + +# --------------------------------------------------------------- the script parser + + +async def test_comments_and_blank_lines_are_ignored(sim: Simulation) -> None: + await sim.driver.run_script("# just a comment\n\n \nplace fuchs # trailing\n") + sim.driver.check("figure", "fuchs") + + +async def test_a_failing_expectation_names_the_line(sim: Simulation) -> None: + with pytest.raises(ExpectationError) as excinfo: + await sim.driver.run_script("place fuchs\nexpect track 7\n") + + message = str(excinfo.value) + assert "line 2" in message + assert "expected track to be '7', but it is '0'" in message + + +async def test_an_unknown_verb_is_reported_with_its_line(sim: Simulation) -> None: + with pytest.raises(ScriptError, match="line 1"): + await sim.driver.run_script("frobnicate the widget\n") + + +async def test_an_unknown_figure_lists_the_configured_ones(sim: Simulation) -> None: + with pytest.raises(ScriptError, match="configured: eule, fuchs"): + await sim.driver.run_script("place giraffe\n") + + +async def test_an_unknown_property_lists_the_valid_ones(sim: Simulation) -> None: + with pytest.raises(ScriptError, match="unknown property"): + await sim.driver.run_script("expect loudness 5\n") + + +async def test_an_unknown_touch_button_lists_the_valid_ones(sim: Simulation) -> None: + with pytest.raises(ScriptError, match="left_foot, right_foot"): + await sim.driver.run_script("touch nose\n") + + +@pytest.mark.parametrize( + ("text", "seconds"), + [("2", 2.0), ("2s", 2.0), ("500ms", 0.5), ("1m", 60.0), ("0.5s", 0.5)], +) +async def test_duration_formats(text: str, seconds: float) -> None: + from musicmouse.simulator.driver import Duration + + assert Duration.parse(text).seconds == pytest.approx(seconds) + + +async def test_a_bad_duration_is_reported() -> None: + from musicmouse.simulator.driver import Duration + + with pytest.raises(ScriptError, match="is not a duration"): + Duration.parse("soon") + + +async def test_status_and_leds_produce_output(sim: Simulation) -> None: + await sim.driver.place("fuchs") + + status = await sim.driver.execute("status") + leds = await sim.driver.execute("leds") + + assert status is not None and "fuchs" in status and "playing" in status + assert leds is not None and "ring" in leds and "shelf" in leds diff --git a/python-backend/tests/test_wire.py b/python-backend/tests/test_wire.py new file mode 100644 index 0000000..a5c9de7 --- /dev/null +++ b/python-backend/tests/test_wire.py @@ -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(" 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())