Full rearchitecture using Claude
- event bus systen - all components are independent - preparation for web frontend
This commit is contained in:
120
python-backend/README.md
Normal file
120
python-backend/README.md
Normal 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.
|
||||
@@ -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"
|
||||
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
@@ -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}"
|
||||
@@ -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")
|
||||
@@ -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))
|
||||
24
python-backend/musicmouse.service
Normal file
24
python-backend/musicmouse.service
Normal 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
|
||||
5
python-backend/musicmouse/__init__.py
Normal file
5
python-backend/musicmouse/__init__.py
Normal file
@@ -0,0 +1,5 @@
|
||||
"""MusicMouse backend: an RFID music player for kids."""
|
||||
|
||||
__all__ = ["__version__"]
|
||||
|
||||
__version__ = "2.0.0"
|
||||
217
python-backend/musicmouse/__main__.py
Normal file
217
python-backend/musicmouse/__main__.py
Normal 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())
|
||||
46
python-backend/musicmouse/app.py
Normal file
46
python-backend/musicmouse/app.py
Normal 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
|
||||
153
python-backend/musicmouse/bus.py
Normal file
153
python-backend/musicmouse/bus.py
Normal 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))
|
||||
94
python-backend/musicmouse/clock.py
Normal file
94
python-backend/musicmouse/clock.py
Normal 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)
|
||||
128
python-backend/musicmouse/color.py
Normal file
128
python-backend/musicmouse/color.py
Normal 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')")
|
||||
256
python-backend/musicmouse/config.py
Normal file
256
python-backend/musicmouse/config.py
Normal 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
|
||||
1
python-backend/musicmouse/devices/__init__.py
Normal file
1
python-backend/musicmouse/devices/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Objects that own a piece of hardware: the mouse itself and the audio player."""
|
||||
177
python-backend/musicmouse/devices/mouse.py
Normal file
177
python-backend/musicmouse/devices/mouse.py
Normal 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")
|
||||
)
|
||||
284
python-backend/musicmouse/devices/player.py
Normal file
284
python-backend/musicmouse/devices/player.py
Normal 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)
|
||||
122
python-backend/musicmouse/devices/serial_link.py
Normal file
122
python-backend/musicmouse/devices/serial_link.py
Normal 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)
|
||||
21
python-backend/musicmouse/devices/transport.py
Normal file
21
python-backend/musicmouse/devices/transport.py
Normal 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: ...
|
||||
424
python-backend/musicmouse/devices/wire.py
Normal file
424
python-backend/musicmouse/devices/wire.py
Normal 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)
|
||||
279
python-backend/musicmouse/effects.py
Normal file
279
python-backend/musicmouse/effects.py
Normal 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))
|
||||
247
python-backend/musicmouse/events.py
Normal file
247
python-backend/musicmouse/events.py
Normal 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
|
||||
90
python-backend/musicmouse/hardware.py
Normal file
90
python-backend/musicmouse/hardware.py
Normal 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),
|
||||
}
|
||||
65
python-backend/musicmouse/media.py
Normal file
65
python-backend/musicmouse/media.py
Normal 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))
|
||||
17
python-backend/musicmouse/reactions/__init__.py
Normal file
17
python-backend/musicmouse/reactions/__init__.py
Normal 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"]
|
||||
136
python-backend/musicmouse/reactions/lighting.py
Normal file
136
python-backend/musicmouse/reactions/lighting.py
Normal 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)
|
||||
137
python-backend/musicmouse/reactions/playback.py
Normal file
137
python-backend/musicmouse/reactions/playback.py
Normal 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)
|
||||
55
python-backend/musicmouse/reactions/registry.py
Normal file
55
python-backend/musicmouse/reactions/registry.py
Normal 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
|
||||
9
python-backend/musicmouse/services/__init__.py
Normal file
9
python-backend/musicmouse/services/__init__.py
Normal 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"]
|
||||
26
python-backend/musicmouse/services/base.py
Normal file
26
python-backend/musicmouse/services/base.py
Normal 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: ...
|
||||
6
python-backend/musicmouse/services/mqtt/__init__.py
Normal file
6
python-backend/musicmouse/services/mqtt/__init__.py
Normal 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"]
|
||||
107
python-backend/musicmouse/services/mqtt/entity.py
Normal file
107
python-backend/musicmouse/services/mqtt/entity.py
Normal 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",
|
||||
}
|
||||
250
python-backend/musicmouse/services/mqtt/lights.py
Normal file
250
python-backend/musicmouse/services/mqtt/lights.py
Normal 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),
|
||||
}
|
||||
168
python-backend/musicmouse/services/mqtt/player.py
Normal file
168
python-backend/musicmouse/services/mqtt/player.py
Normal 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()),
|
||||
]
|
||||
134
python-backend/musicmouse/services/mqtt/service.py
Normal file
134
python-backend/musicmouse/services/mqtt/service.py
Normal 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)
|
||||
157
python-backend/musicmouse/services/mqtt/triggers.py
Normal file
157
python-backend/musicmouse/services/mqtt/triggers.py
Normal 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),
|
||||
]
|
||||
5
python-backend/musicmouse/simulator/__init__.py
Normal file
5
python-backend/musicmouse/simulator/__init__.py
Normal 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"]
|
||||
314
python-backend/musicmouse/simulator/driver.py
Normal file
314
python-backend/musicmouse/simulator/driver.py
Normal 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)
|
||||
164
python-backend/musicmouse/simulator/fake_player.py
Normal file
164
python-backend/musicmouse/simulator/fake_player.py
Normal 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()
|
||||
105
python-backend/musicmouse/simulator/fake_transport.py
Normal file
105
python-backend/musicmouse/simulator/fake_transport.py
Normal 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
|
||||
81
python-backend/musicmouse/simulator/harness.py
Normal file
81
python-backend/musicmouse/simulator/harness.py
Normal 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,
|
||||
)
|
||||
96
python-backend/musicmouse/simulator/repl.py
Normal file
96
python-backend/musicmouse/simulator/repl.py
Normal 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)
|
||||
24
python-backend/musicmouse/simulator/script.py
Normal file
24
python-backend/musicmouse/simulator/script.py
Normal 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)
|
||||
14
python-backend/notebooks/README.md
Normal file
14
python-backend/notebooks/README.md
Normal 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
|
||||
```
|
||||
@@ -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)
|
||||
55
python-backend/pyproject.toml
Normal file
55
python-backend/pyproject.toml
Normal 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
|
||||
@@ -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
|
||||
19
python-backend/scenarios/playlist_end.txt
Normal file
19
python-backend/scenarios/playlist_end.txt
Normal 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
|
||||
51
python-backend/scenarios/smoke.txt
Normal file
51
python-backend/scenarios/smoke.txt
Normal 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
|
||||
39
python-backend/tests/conftest.py
Normal file
39
python-backend/tests/conftest.py
Normal 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
|
||||
178
python-backend/tests/test_bus.py
Normal file
178
python-backend/tests/test_bus.py
Normal 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()
|
||||
108
python-backend/tests/test_clock.py
Normal file
108
python-backend/tests/test_clock.py
Normal 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
|
||||
202
python-backend/tests/test_config.py
Normal file
202
python-backend/tests/test_config.py
Normal 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)
|
||||
141
python-backend/tests/test_effects.py
Normal file
141
python-backend/tests/test_effects.py
Normal 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
|
||||
333
python-backend/tests/test_mouse.py
Normal file
333
python-backend/tests/test_mouse.py
Normal 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"),
|
||||
]
|
||||
535
python-backend/tests/test_mqtt.py
Normal file
535
python-backend/tests/test_mqtt.py
Normal 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)
|
||||
297
python-backend/tests/test_player.py
Normal file
297
python-backend/tests/test_player.py
Normal 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
|
||||
369
python-backend/tests/test_reactions.py
Normal file
369
python-backend/tests/test_reactions.py
Normal 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)
|
||||
104
python-backend/tests/test_scenarios.py
Normal file
104
python-backend/tests/test_scenarios.py
Normal 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
|
||||
299
python-backend/tests/test_wire.py
Normal file
299
python-backend/tests/test_wire.py
Normal 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())
|
||||
Reference in New Issue
Block a user