Full rearchitecture using Claude

- event bus systen
- all components are independent
- preparation for web frontend
This commit is contained in:
2026-08-26 13:22:28 +02:00
parent 55bcc9448c
commit d44c24ec97
70 changed files with 7591 additions and 1011 deletions

120
python-backend/README.md Normal file
View File

@@ -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.

View File

@@ -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_dir>/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 <figure_folder>/<figure name>,
# 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, "<figure_name>").
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"

View File

@@ -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 <url> <token>")
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()

View File

@@ -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("<iiB", msg_content)
def __repr__(self):
return f"Rotary event: pos {self.position}, incr {self.increment}, dir {self.direction}"
class TouchButtonPress:
def __init__(self, msg_content: bytes):
val = int(msg_content[0])
self.touch_button = TouchButton(val)
def __repr__(self) -> 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("<BB", msg_content)
self.button = self.button_name[button_nr]
self.event = self.event_name[event_nr]
def __repr__(self) -> 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("<IBH", MAGIC_TOKEN_HOST_TO_FW, msg_dict[type(effect_cfg)],
len(msg_content))
self.transport.write(header + msg_content)
def led_ring_effect(self, effect_cfg):
self.__led_effect(effect_cfg, led_ring_effect_to_message_id)
def mouse_led_effect(self, effect_cfg):
self.__led_effect(effect_cfg, mouse_led_effect_to_message_id)
def shelve_led_effect(self, effect_cfg):
self.__led_effect(effect_cfg, shelve_led_effect_to_message_id)
def button_background_led_prev(self, val):
msg_content = struct.pack("<f", val)
header = struct.pack("<IBH", MAGIC_TOKEN_HOST_TO_FW, PREV_BUTTON_LED_MSG, len(msg_content))
self.transport.write(header + msg_content)
def button_background_led_next(self, val):
msg_content = struct.pack("<f", val)
header = struct.pack("<IBH", MAGIC_TOKEN_HOST_TO_FW, NEXT_BUTTON_LED_MSG, len(msg_content))
self.transport.write(header + msg_content)
def data_received(self, data):
self.in_buff += data
self._parse_message()
def connection_lost(self, exc):
print('port closed')
self.transport.loop.stop()
def pause_writing(self):
print('pause writing')
print(self.transport.get_write_buffer_size())
def resume_writing(self):
print(self.transport.get_write_buffer_size())
print('resume writing')
def _parse_message(self):
HEADER_SIZE = 4 + 1 + 2
if len(self.in_buff) == 0:
return
if len(self.in_buff) >= HEADER_SIZE:
token, msg_type, msg_size = struct.unpack("<IBH", self.in_buff[:HEADER_SIZE])
if token == MAGIC_TOKEN_FW_TO_HOST and len(self.in_buff) >= 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)

View File

@@ -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("<BBBB", int(self.r * 255), int(self.g * 255), int(self.b * 255),
int(self.w * 255))
def is_valid(self):
vals = (self.r, self.g, self.b, self.w)
return all(0 <= v <= 1 for v in vals)
def __mul__(self, other:float):
assert 0<= other <= 1
return ColorRGBW(self.r * other, self.g * other, self.b * other, self.w * other)
def without_white_channel(self, scale=1):
args = (min(1, e + self.w) for e in (self.r, self.g, self.b) )
return ColorRGBW(*args, 0)
@dataclass
class ColorHSV:
h: float
s: float
v: float
@staticmethod
def fromRGB(rgb):
conv = colorsys.rgb_to_hsv(rgb.r, rgb.g, rgb.b)
return ColorHSV(conv[0] * 360, conv[1], conv[2])
def __repr__(self):
return f"ColorHSV({self.h}, {self.s}, {self.v})"
def as_bytes(self) -> bytes:
return struct.pack("<fff", self.h, self.s, self.v)
def is_valid(self):
if not 0 <= self.h <= 360:
return False
if not 0 <= self.s <= 1:
return False
if not 0 <= self.v <= 2:
return False
return True
@dataclass
class EffectStaticConfig:
color: ColorRGBW
begin: int = 0
end: int = 0
def __repr__(self):
return f"EffectStaticConfig {str(self.color)}, beg: {self.begin}, end {self.end}"
def as_bytes(self) -> bytes:
return self.color.as_bytes() + struct.pack("<HH", self.begin, self.end)
@dataclass
class EffectStaticDetailedConfig:
color: ColorRGBW
increment: int = 1
begin: float = 0.0
end: float = 1.0
transition_time_in_ms : float = 500
def __repr__(self):
return f"EffectStaticDetailedConfig {str(self.color)}, beg: {self.begin}, end {self.end}, incr {self.increment}, transition in ms {self.transition_time_in_ms}"
def as_bytes(self) -> bytes:
return self.color.as_bytes() + struct.pack("<Hfff", self.increment, self.begin, self.end, self.transition_time_in_ms)
@dataclass
class EffectAlexaSwipeConfig:
primary_color_width: float = 20 # in degrees
transition_width: float = 30 # in degrees
swipe_speed: float = 2 * 360 # in degrees per second
bell_curve_width_in_leds: float = 3
start_position: float = 180 # in 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))
def as_bytes(self) -> bytes:
return struct.pack(
"<fffff?", 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()
def __repr__(self):
return f"EffectAlexaSwipe primary {str(self.primary_color)}, {str(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 = field(default_factory=lambda: ColorHSV(240, 1, 1))
color2: ColorHSV = field(default_factory=lambda: ColorHSV(192, 1, 1))
def as_bytes(self) -> 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("<i?i??", self.cycle_durations_ms, self.start_with_existing,
self.num_segments, self.hue1_random,
self.hue2_random) + c1.as_bytes() + c2.as_bytes()
def __repr__(self):
return f"RandTwoColor {str(self.color1)}, {str(self.color2)}, segments {self.num_segments}"
@dataclass
class EffectCircularConfig:
speed: float = 360 # in degrees per second
width: float = 180 # in degrees
color: ColorRGBW = field(default_factory=lambda: ColorRGBW(0, 0, 1, 0))
def as_bytes(self) -> bytes:
return struct.pack("<ff", self.speed, self.width) + self.color.as_bytes()
@dataclass
class EffectSwipeAndChange:
swipe: EffectAlexaSwipeConfig = field(default_factory=lambda: EffectAlexaSwipeConfig())
change: EffectRandomTwoColorInterpolationConfig = field(default_factory=lambda: EffectRandomTwoColorInterpolationConfig())
def as_bytes(self) -> 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("<fff", self.swipeSpeed, self.bellCurveWidthInLeds, self.startPosition)
def __repr__(self) -> str:
return f"Reverse swipe, speed {self.swipeSpeed}, width in leds {self.bellCurveWidthInLeds}, start position {self.startPosition}"

View File

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

View File

@@ -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))

View File

@@ -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

View File

@@ -0,0 +1,5 @@
"""MusicMouse backend: an RFID music player for kids."""
__all__ = ["__version__"]
__version__ = "2.0.0"

View File

@@ -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())

View File

@@ -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

View File

@@ -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))

View File

@@ -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)

View File

@@ -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(
"<BBBB",
int(self.r * 255),
int(self.g * 255),
int(self.b * 255),
int(self.w * 255),
)
@classmethod
def from_bytes(cls, data: bytes) -> ColorRGBW:
r, g, b, w = struct.unpack("<BBBB", data)
return cls(r / 255, g / 255, b / 255, w / 255)
def __mul__(self, scale: float) -> 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("<fff", self.h, self.s, self.v)
@classmethod
def from_bytes(cls, data: bytes) -> ColorHSV:
return cls(*struct.unpack("<fff", data))
def parse_color(value: str | ColorRGBW) -> 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')")

View File

@@ -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 ``<figure_folder>/<figure_name>``, 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 '<root>'}: {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

View File

@@ -0,0 +1 @@
"""Objects that own a piece of hardware: the mouse itself and the audio player."""

View File

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

View File

@@ -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)

View File

@@ -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)

View File

@@ -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: ...

View File

@@ -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("<IBH")
_HEADER_SIZE = _HEADER.size # 7
_MAGIC_FW_BYTES = struct.pack("<I", MAGIC_FW_TO_HOST)
#: Give up on resynchronising rather than buffering forever on a wedged link.
_MAX_BUFFER = 8192
class ProtocolError(Exception):
"""A frame arrived that could not be interpreted."""
class UnsupportedEffectError(Exception):
"""The firmware has no message for this effect on this LED zone."""
class MessageFwToHost(IntEnum):
RFID_TOKEN_READ = 0
ROTARY_ENCODER = 1
TOUCH_BUTTON_PRESS = 2
TOUCH_BUTTON_RELEASE = 3
BUTTON_EVENT = 4
class MessageHostToFw(IntEnum):
LED_WHEEL_EFFECT_STATIC = 0
LED_WHEEL_EFFECT_ALEXA_SWIPE = 1
LED_WHEEL_EFFECT_CIRCULAR = 2
LED_WHEEL_EFFECT_RANDOM_TWO_COLOR_INTERPOLATION = 3
LED_WHEEL_EFFECT_SWIPE_AND_CHANGE = 4
LED_WHEEL_EFFECT_REVERSE_SWIPE = 5
MOUSE_LED_EFFECT_STATIC = 6
MOUSE_LED_EFFECT_CIRCULAR = 7
MOUSE_LED_EFFECT_RANDOM_TWO_COLOR_INTERPOLATION = 8
MOUSE_LED_EFFECT_SWIPE_AND_CHANGE = 9
MOUSE_LED_EFFECT_REVERSE_SWIPE = 10
SHELF_LED_EFFECT_STATIC = 15
SHELF_LED_EFFECT_CIRCULAR = 16
SHELF_LED_EFFECT_RANDOM_TWO_COLOR_INTERPOLATION = 17
SHELF_LED_EFFECT_SWIPE_AND_CHANGE = 18
SHELF_LED_EFFECT_REVERSE_SWIPE = 19
SHELF_LED_EFFECT_STATIC_DETAILED = 20
PREV_BUTTON_LED = 21
NEXT_BUTTON_LED = 22
#: Which message id carries which effect, per zone. Note the asymmetry: only the ring
#: accepts AlexaSwipe on its own, and only the shelf accepts StaticDetailed.
_EFFECT_IDS: dict[LedZone, dict[type[LedEffect], MessageHostToFw]] = {
LedZone.RING: {
EffectStaticConfig: MessageHostToFw.LED_WHEEL_EFFECT_STATIC,
EffectAlexaSwipeConfig: MessageHostToFw.LED_WHEEL_EFFECT_ALEXA_SWIPE,
EffectCircularConfig: MessageHostToFw.LED_WHEEL_EFFECT_CIRCULAR,
EffectRandomTwoColorInterpolationConfig: (
MessageHostToFw.LED_WHEEL_EFFECT_RANDOM_TWO_COLOR_INTERPOLATION
),
EffectSwipeAndChange: MessageHostToFw.LED_WHEEL_EFFECT_SWIPE_AND_CHANGE,
EffectReverseSwipe: MessageHostToFw.LED_WHEEL_EFFECT_REVERSE_SWIPE,
},
LedZone.MOUSE: {
EffectStaticConfig: MessageHostToFw.MOUSE_LED_EFFECT_STATIC,
EffectCircularConfig: MessageHostToFw.MOUSE_LED_EFFECT_CIRCULAR,
EffectRandomTwoColorInterpolationConfig: (
MessageHostToFw.MOUSE_LED_EFFECT_RANDOM_TWO_COLOR_INTERPOLATION
),
EffectSwipeAndChange: MessageHostToFw.MOUSE_LED_EFFECT_SWIPE_AND_CHANGE,
EffectReverseSwipe: MessageHostToFw.MOUSE_LED_EFFECT_REVERSE_SWIPE,
},
LedZone.SHELF: {
EffectStaticConfig: MessageHostToFw.SHELF_LED_EFFECT_STATIC,
EffectCircularConfig: MessageHostToFw.SHELF_LED_EFFECT_CIRCULAR,
EffectRandomTwoColorInterpolationConfig: (
MessageHostToFw.SHELF_LED_EFFECT_RANDOM_TWO_COLOR_INTERPOLATION
),
EffectSwipeAndChange: MessageHostToFw.SHELF_LED_EFFECT_SWIPE_AND_CHANGE,
EffectReverseSwipe: MessageHostToFw.SHELF_LED_EFFECT_REVERSE_SWIPE,
EffectStaticDetailedConfig: MessageHostToFw.SHELF_LED_EFFECT_STATIC_DETAILED,
},
}
_BUTTON_LED_IDS: dict[Button, MessageHostToFw] = {
Button.LEFT: MessageHostToFw.PREV_BUTTON_LED,
Button.RIGHT: MessageHostToFw.NEXT_BUTTON_LED,
}
@dataclass(frozen=True, slots=True)
class FirmwareLog:
"""A ``Serial.println`` line from the firmware, interleaved with the frames."""
text: str
type Decoded = InputEvent | FirmwareLog
# ------------------------------------------------------------------------- encoding
def _frame(message: MessageHostToFw, payload: bytes) -> 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("<f", brightness))
def encode_input_event(event: InputEvent) -> 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("<iiB", position, increment, direction)
case TouchButtonPressed(button=button):
message, payload = MessageFwToHost.TOUCH_BUTTON_PRESS, struct.pack("<B", button)
case TouchButtonReleased(button=button):
message, payload = MessageFwToHost.TOUCH_BUTTON_RELEASE, struct.pack("<B", button)
case ButtonEvent(button=push_button, action=action):
message = MessageFwToHost.BUTTON_EVENT
payload = struct.pack("<BB", push_button, action)
case _:
raise ValueError(f"{type(event).__name__} is not a firmware message")
return struct.pack("<IBH", MAGIC_FW_TO_HOST, message, len(payload)) + payload
# ------------------------------------------------------------------------- decoding
def decode_message(msg_type: int, payload: bytes) -> 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("<iiB", payload)
return RotaryTurned(
position=position,
increment=increment,
direction=RotaryDirection(direction),
source="device",
)
case MessageFwToHost.TOUCH_BUTTON_PRESS:
return TouchButtonPressed(button=_touch_button(payload), source="device")
case MessageFwToHost.TOUCH_BUTTON_RELEASE:
return TouchButtonReleased(button=_touch_button(payload), source="device")
case MessageFwToHost.BUTTON_EVENT:
button_nr, event_nr = struct.unpack("<BB", payload)
return ButtonEvent(
button=Button(button_nr), action=ButtonAction(event_nr), source="device"
)
except struct.error as exc:
raise ProtocolError(f"malformed {message.name} payload {payload.hex()}: {exc}") from exc
except ValueError as exc:
raise ProtocolError(f"bad value in {message.name} payload {payload.hex()}: {exc}") from exc
raise AssertionError(f"unhandled message {message}") # pragma: no cover
def _touch_button(payload: bytes) -> 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("<I", MAGIC_HOST_TO_FW)
def decode_host_command(msg_type: int, payload: bytes) -> 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("<f", payload)
except struct.error as exc:
raise ProtocolError(f"malformed button brightness payload: {exc}") from exc
return SetButtonBrightness(button=button, brightness=brightness)
raise ProtocolError(f"unknown host-to-firmware message type {msg_type}")
class HostFrameDecoder:
"""Incremental decoder for the host -> 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)

View File

@@ -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("<HH")
_STATIC_DETAILED = struct.Struct("<Hfff")
_ALEXA_SWIPE = struct.Struct("<fffff?")
_TWO_COLOR = struct.Struct("<i?i??")
_CIRCULAR = struct.Struct("<ff")
_REVERSE_SWIPE = struct.Struct("<fff")
class LedEffect(Protocol):
"""Anything that can be sent to an LED zone, and read back off the wire."""
SIZE: ClassVar[int]
def as_bytes(self) -> 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))

View File

@@ -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

View File

@@ -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),
}

View File

@@ -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))

View File

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

View File

@@ -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)

View File

@@ -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)

View File

@@ -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

View File

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

View File

@@ -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: ...

View File

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

View File

@@ -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",
}

View File

@@ -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"^(?P<position>side|top)_(?P<width>\d+(?:\.\d+)?)(?:_inc(?P<inc>\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),
}

View File

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

View File

@@ -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)

View File

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

View File

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

View File

@@ -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)

View File

@@ -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()

View File

@@ -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

View File

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

View File

@@ -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 <figure> put a figure on the reader remove
press <left|right|rotary> [action] turn <+n|-n>
touch <left_ear|right_ear|left_foot|right_foot> release <same>
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 <key> <value> 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)

View File

@@ -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)

View File

@@ -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
```

View File

@@ -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)

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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 <config.yml> --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

View File

@@ -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

View File

@@ -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()

View File

@@ -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

View File

@@ -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)

View File

@@ -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

View File

@@ -0,0 +1,333 @@
from __future__ import annotations
import logging
from collections.abc import AsyncIterator
import pytest
from musicmouse.bus import EventBus
from musicmouse.color import ColorRGBW
from musicmouse.devices.mouse import MusicMouseDevice
from musicmouse.devices.wire import encode_input_event
from musicmouse.effects import (
OFF,
EffectAlexaSwipeConfig,
EffectCircularConfig,
EffectStaticConfig,
)
from musicmouse.events import (
ActiveFigureChanged,
ButtonEvent,
ConnectionChanged,
Event,
LedEffectChanged,
RfidTokenRead,
TouchButtonPressed,
)
from musicmouse.hardware import Button, ButtonAction, LedZone, TouchButton
from musicmouse.simulator import FakeTransport
FUCHS = bytes.fromhex("04a1b2c3d4")
EULE = bytes.fromhex("04b2c3d4e5")
UNKNOWN = bytes.fromhex("0999999999")
TAG_MAP = {FUCHS: "fuchs", EULE: "eule"}
@pytest.fixture
async def bus() -> AsyncIterator[EventBus]:
async with EventBus() as running:
yield running
@pytest.fixture
def transport() -> FakeTransport:
return FakeTransport()
@pytest.fixture
def device(bus: EventBus, transport: FakeTransport) -> MusicMouseDevice:
mouse = MusicMouseDevice(bus, transport, TAG_MAP, port="/dev/fake")
transport.attach(mouse.feed)
return mouse
@pytest.fixture
def seen(bus: EventBus) -> list[Event]:
events: list[Event] = []
bus.subscribe_all(events.append)
return events
def only[T: Event](events: list[Event], event_type: type[T]) -> list[T]:
return [e for e in events if isinstance(e, event_type)]
# ----------------------------------------------------------------- incoming events
async def test_button_press_reaches_the_bus(
bus: EventBus, device: MusicMouseDevice, seen: list[Event]
) -> None:
device.feed(
encode_input_event(ButtonEvent(button=Button.RIGHT, action=ButtonAction.PRESSED))
)
await bus.drain()
assert only(seen, ButtonEvent) == [
ButtonEvent(button=Button.RIGHT, action=ButtonAction.PRESSED, source="device")
]
async def test_touch_press_reaches_the_bus(
bus: EventBus, device: MusicMouseDevice, seen: list[Event]
) -> None:
device.feed(encode_input_event(TouchButtonPressed(button=TouchButton.LEFT_EAR)))
await bus.drain()
assert only(seen, TouchButtonPressed)[0].button == TouchButton.LEFT_EAR
async def test_known_tag_resolves_to_a_figure(
bus: EventBus, device: MusicMouseDevice, seen: list[Event]
) -> None:
device.feed(encode_input_event(RfidTokenRead(tag_id=FUCHS)))
await bus.drain()
assert only(seen, RfidTokenRead)[0].figure == "fuchs"
assert only(seen, ActiveFigureChanged) == [
ActiveFigureChanged(figure="fuchs", previous=None, source="device")
]
assert device.active_figure == "fuchs"
async def test_all_zero_tag_means_the_figure_was_removed(
bus: EventBus, device: MusicMouseDevice, seen: list[Event]
) -> None:
device.feed(encode_input_event(RfidTokenRead(tag_id=FUCHS)))
await bus.drain()
seen.clear()
device.feed(encode_input_event(RfidTokenRead(tag_id=bytes(5))))
await bus.drain()
assert only(seen, ActiveFigureChanged) == [
ActiveFigureChanged(figure=None, previous="fuchs", source="device")
]
assert device.active_figure is None
async def test_swapping_figures_reports_the_previous_one(
bus: EventBus, device: MusicMouseDevice, seen: list[Event]
) -> None:
device.feed(encode_input_event(RfidTokenRead(tag_id=FUCHS)))
await bus.drain()
seen.clear()
device.feed(encode_input_event(RfidTokenRead(tag_id=EULE)))
await bus.drain()
assert only(seen, ActiveFigureChanged) == [
ActiveFigureChanged(figure="eule", previous="fuchs", source="device")
]
async def test_rereading_the_same_tag_is_not_a_change(
bus: EventBus, device: MusicMouseDevice, seen: list[Event]
) -> None:
for _ in range(3):
device.feed(encode_input_event(RfidTokenRead(tag_id=FUCHS)))
await bus.drain()
assert len(only(seen, RfidTokenRead)) == 3
assert len(only(seen, ActiveFigureChanged)) == 1
async def test_unknown_tag_is_reported_but_does_not_change_the_active_figure(
bus: EventBus, device: MusicMouseDevice, seen: list[Event], caplog: pytest.LogCaptureFixture
) -> None:
device.feed(encode_input_event(RfidTokenRead(tag_id=FUCHS)))
await bus.drain()
seen.clear()
with caplog.at_level(logging.WARNING):
device.feed(encode_input_event(RfidTokenRead(tag_id=UNKNOWN)))
await bus.drain()
read = only(seen, RfidTokenRead)[0]
assert read.known is False
assert read.figure is None
assert only(seen, ActiveFigureChanged) == []
assert device.active_figure == "fuchs"
assert "Unknown RFID tag 0999999999" in caplog.text
async def test_firmware_log_lines_are_logged_not_published(
bus: EventBus, device: MusicMouseDevice, seen: list[Event], caplog: pytest.LogCaptureFixture
) -> None:
with caplog.at_level(logging.INFO):
device.feed(b"RFID reader ready\n")
await bus.drain()
assert seen == []
assert "[firmware] RFID reader ready" in caplog.text
async def test_a_bad_frame_is_dropped_and_the_next_one_still_arrives(
bus: EventBus, device: MusicMouseDevice, seen: list[Event], caplog: pytest.LogCaptureFixture
) -> None:
import struct
from musicmouse.devices.wire import MAGIC_FW_TO_HOST
bad = struct.pack("<IBH", MAGIC_FW_TO_HOST, 99, 1) + b"\x00"
good = encode_input_event(TouchButtonPressed(button=TouchButton.RIGHT_EAR))
with caplog.at_level(logging.WARNING):
device.feed(bad + good)
await bus.drain()
assert only(seen, TouchButtonPressed)[0].button == TouchButton.RIGHT_EAR
assert "Discarding bad frame" in caplog.text
# ------------------------------------------------------------------------ actions
async def test_setting_an_effect_writes_it_and_announces_it(
bus: EventBus, device: MusicMouseDevice, transport: FakeTransport, seen: list[Event]
) -> None:
effect = EffectStaticConfig(ColorRGBW(1, 0, 0, 0))
device.set_effect(LedZone.RING, effect, origin="mqtt")
await bus.drain()
assert transport.effect(LedZone.RING) == effect
assert only(seen, LedEffectChanged) == [
LedEffectChanged(zone=LedZone.RING, effect=effect, origin="mqtt", source="device")
]
assert device.effect(LedZone.RING) == effect
async def test_last_write_wins_per_zone(
bus: EventBus, device: MusicMouseDevice, transport: FakeTransport
) -> None:
"""A figure animation and an MQTT command fight over the shelf; the later one wins."""
from_mqtt = EffectStaticConfig(ColorRGBW(0, 0, 1, 0))
from_figure = EffectCircularConfig(color=ColorRGBW(1, 0, 0, 0))
device.set_effect(LedZone.SHELF, from_mqtt, origin="mqtt")
device.set_effect(LedZone.SHELF, from_figure, origin="device")
await bus.drain()
assert transport.effect(LedZone.SHELF) == from_figure
assert device.effect(LedZone.SHELF) == from_figure
async def test_zones_are_independent(
bus: EventBus, device: MusicMouseDevice, transport: FakeTransport
) -> None:
ring = EffectStaticConfig(ColorRGBW(1, 0, 0, 0))
shelf = EffectStaticConfig(ColorRGBW(0, 1, 0, 0))
device.set_effect(LedZone.RING, ring)
device.set_effect(LedZone.SHELF, shelf)
await bus.drain()
assert transport.effect(LedZone.RING) == ring
assert transport.effect(LedZone.SHELF) == shelf
assert transport.effect(LedZone.MOUSE) is None
async def test_an_effect_a_zone_does_not_support_is_reported_not_sent(
bus: EventBus,
device: MusicMouseDevice,
transport: FakeTransport,
caplog: pytest.LogCaptureFixture,
) -> None:
with caplog.at_level(logging.ERROR):
device.set_effect(LedZone.MOUSE, EffectAlexaSwipeConfig())
await bus.drain()
assert transport.effect(LedZone.MOUSE) is None
assert device.effect(LedZone.MOUSE) is None
assert "cannot be sent to the mouse LEDs" in caplog.text
async def test_button_brightness_sets_both_backlights(
device: MusicMouseDevice, transport: FakeTransport
) -> None:
device.set_button_brightness(0.25)
assert transport.brightness(Button.LEFT) == pytest.approx(0.25)
assert transport.brightness(Button.RIGHT) == pytest.approx(0.25)
assert device.button_led_brightness == pytest.approx(0.25)
@pytest.mark.parametrize(("given", "expected"), [(-1.0, 0.0), (5.0, 1.0)])
async def test_button_brightness_is_clamped(
device: MusicMouseDevice, transport: FakeTransport, given: float, expected: float
) -> None:
device.set_button_brightness(given)
assert transport.brightness() == pytest.approx(expected)
async def test_all_leds_off(
bus: EventBus, device: MusicMouseDevice, transport: FakeTransport
) -> None:
device.set_button_brightness(1.0)
device.all_leds_off()
await bus.drain()
for zone in LedZone:
assert transport.effect(zone) == OFF()
assert transport.brightness() == pytest.approx(0.0)
# --------------------------------------------------------------------- reconnect
async def test_writes_while_disconnected_are_dropped(
bus: EventBus, device: MusicMouseDevice, transport: FakeTransport
) -> None:
transport.disconnect()
device.set_effect(LedZone.RING, EffectStaticConfig(ColorRGBW(1, 0, 0, 0)))
await bus.drain()
assert transport.effect(LedZone.RING) is None
assert transport.dropped_bytes > 0
async def test_reconnect_restores_the_memorized_led_state(
bus: EventBus, device: MusicMouseDevice, transport: FakeTransport
) -> None:
"""The point of memorizing state: a pulled USB cable should be invisible."""
ring = EffectStaticConfig(ColorRGBW(1, 0, 0, 0))
shelf = EffectCircularConfig(color=ColorRGBW(0, 0, 1, 0))
device.set_effect(LedZone.RING, ring)
device.set_effect(LedZone.SHELF, shelf)
device.set_button_brightness(0.5)
await bus.drain()
transport.disconnect()
device.on_disconnected("port closed")
await bus.drain()
transport.clear()
transport.reconnect()
device.on_connected()
await bus.drain()
assert transport.effect(LedZone.RING) == ring
assert transport.effect(LedZone.SHELF) == shelf
assert transport.brightness() == pytest.approx(0.5)
async def test_connection_changes_are_announced(
bus: EventBus, device: MusicMouseDevice, seen: list[Event]
) -> None:
device.on_connected()
device.on_disconnected("cable pulled")
await bus.drain()
assert only(seen, ConnectionChanged) == [
ConnectionChanged(target="firmware", connected=True, source="device"),
ConnectionChanged(target="firmware", connected=False, source="device"),
]

View File

@@ -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)

View File

@@ -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

View File

@@ -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)

View File

@@ -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

View File

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