Full rearchitecture using Claude
- event bus systen - all components are independent - preparation for web frontend
This commit is contained in:
5
.gitignore
vendored
5
.gitignore
vendored
@@ -1,7 +1,12 @@
|
|||||||
generated_3d
|
generated_3d
|
||||||
venv
|
venv
|
||||||
|
.venv
|
||||||
build
|
build
|
||||||
|
*.egg-info
|
||||||
*.FCStd1
|
*.FCStd1
|
||||||
*.blend1
|
*.blend1
|
||||||
__pycache__
|
__pycache__
|
||||||
.ipynb_checkpoints
|
.ipynb_checkpoints
|
||||||
|
.pytest_cache
|
||||||
|
.mypy_cache
|
||||||
|
.ruff_cache
|
||||||
|
|||||||
@@ -1,42 +1,82 @@
|
|||||||
# MusicMouse — repo overview
|
# MusicMouse — repo overview
|
||||||
|
|
||||||
Orientation doc for AI agents (or humans) working on this repo for the first time. Written from what's actually in the repo — no roadmap speculation beyond `esp-firmware/todo.md`.
|
Orientation doc for AI agents (or humans) working on this repo for the first time.
|
||||||
|
|
||||||
## What this is
|
## What this is
|
||||||
|
|
||||||
MusicMouse is a DIY, Toniebox-style physical music player for kids, shaped like a mouse and living on a shelf. Small 3D-printed animal figurines (fox, owl, dog, elephant, squirrel, crocodile, rabbit, snowman, puppy — see `hardware/3dprints/figures/`) each carry an RFID tag. Placing a figurine on the mouse triggers an RFID read, which starts that figure's music playlist. The mouse also has a rotary encoder + touch buttons (ears/feet) for volume/skip control, addressable RGBW LED rings with animated effects, and MQTT/Home Assistant integration so a "shelf light" shows up as a smart-home device.
|
MusicMouse is a DIY, Toniebox-style physical music player for kids, shaped like a mouse
|
||||||
|
and living on a shelf. Small 3D-printed animal figurines (fox, owl, dog, elephant,
|
||||||
|
squirrel, crocodile, rabbit, snowman, puppy — see `hardware/3dprints/figures/`) each
|
||||||
|
carry an RFID tag. Placing a figurine on the mouse starts that figure's playlist. The
|
||||||
|
mouse also has a rotary encoder and capacitive touch areas (ears/feet) for
|
||||||
|
volume/skip control, addressable RGBW LED rings with animated effects, and MQTT/Home
|
||||||
|
Assistant integration.
|
||||||
|
|
||||||
## Repo layout
|
## Repo layout
|
||||||
|
|
||||||
| Path | What it is |
|
| Path | What it is |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `python-backend/` | Python host application — the main runtime. Reads RFID/button/encoder events from the ESP32 over serial, drives playback via VLC, sends LED effect commands, bridges to MQTT/Home Assistant. Start here for backend work. |
|
| `python-backend/` | Python host application — the main runtime. Has its own `README.md` with the architecture; start there for backend work. |
|
||||||
| `esp-firmware/` | ESP32 firmware (C++, Arduino framework via PlatformIO). Reads the RFID reader and buttons, drives the LED strips, talks to `python-backend` over serial. |
|
| `esp-firmware/` | ESP32 firmware (C++, Arduino framework via PlatformIO). Reads the RFID reader and buttons, drives the LED strips, talks to `python-backend` over serial. |
|
||||||
| `hardware/` | 3D-print models for the figurines and enclosure (FreeCAD/Blender/OBJ/STL), a Fritzing electronics sketch, datasheets, and `pinout.md` (RFID reader + button-board wiring). |
|
| `hardware/` | 3D-print models for the figurines and enclosure (FreeCAD/Blender/OBJ/STL), a Fritzing electronics sketch, datasheets, and `pinout.md` (RFID reader + button-board wiring). |
|
||||||
| `claude-design/` | An unrelated exploratory web-UI mockup ("Dolphin Beats Music Player" / rebrand concept). Not integrated with the rest of the repo — no build system ties it in. Don't assume it reflects current product direction. |
|
| `claude-design/` | An unrelated exploratory web-UI mockup ("Dolphin Beats"). Not integrated with anything — no build system ties it in. Don't assume it reflects current product direction. |
|
||||||
| `.vscode/` | Editor settings (C++ header associations). |
|
|
||||||
|
|
||||||
There is no top-level README elsewhere in the repo; this file plus `esp-firmware/todo.md` and `hardware/pinout.md` are the only prose docs.
|
|
||||||
|
|
||||||
## How the pieces talk to each other
|
## How the pieces talk to each other
|
||||||
|
|
||||||
- **ESP32 firmware ↔ `python-backend`**: a length-prefixed binary protocol over serial (`pyserial-asyncio`), implemented in `python-backend/host_driver.py`. Messages are framed with magic tokens (`MAGIC_TOKEN_HOST_TO_FW`/`MAGIC_TOKEN_FW_TO_HOST`) and a `struct`-packed header. The message-ID maps and struct formats in `host_driver.py` must stay byte-for-byte in sync with the firmware's `esp-firmware/src/Messages.h` — there's no shared schema or test verifying this cross-language contract, so a firmware protocol change can silently desync the Python side.
|
- **ESP32 firmware ↔ `python-backend`**: a length-prefixed binary protocol over serial.
|
||||||
- **`python-backend` ↔ MQTT/Home Assistant**: `python-backend/mqtt_json.py` exposes the shelf LED strip as a Home-Assistant-discoverable JSON-schema MQTT light (`ShelveLightMqtt`), and `main.py` also calls Home Assistant services directly (e.g. toggling room lights) via `hass-client`.
|
Frames are `uint32 magic | uint8 type | uint16 size | payload`, little-endian, with
|
||||||
|
firmware log text interleaved on the same link. The Python side lives in
|
||||||
|
`python-backend/musicmouse/devices/wire.py`, the firmware side in
|
||||||
|
`esp-firmware/src/Messages.h`.
|
||||||
|
|
||||||
|
The contract is hand-duplicated in two languages. `tests/test_wire.py` parses
|
||||||
|
`Messages.h` and fails if the message ids drift, and `tests/test_effects.py` pins the
|
||||||
|
exact bytes of every effect payload — so a firmware change that breaks the host now
|
||||||
|
breaks a test instead of just the LEDs.
|
||||||
|
|
||||||
|
- **`python-backend` ↔ Home Assistant**: MQTT only. The backend publishes three
|
||||||
|
discoverable lights, a player sensor, a volume number, transport buttons, device
|
||||||
|
triggers for every button and touch area, and a tag scanner for the RFID reader. It
|
||||||
|
does *not* call Home Assistant services directly any more; behaviour like "the left
|
||||||
|
ear turns the room light pink" is an HA automation. See `python-backend/README.md`
|
||||||
|
for the trigger topics and the old colour mapping.
|
||||||
|
|
||||||
## Running it
|
## Running it
|
||||||
|
|
||||||
```
|
```sh
|
||||||
python python-backend/main.py <config_dir>
|
python -m musicmouse --config /media/musicmouse/config.yml
|
||||||
```
|
```
|
||||||
|
|
||||||
`main.py` expects `<config_dir>/config.yml` (schema documented in the new `python-backend/config.yml.example` — no real config was previously checked in or documented). In production this is deployed as a systemd service reading music from `/media/musicmouse/`; see `esp-firmware/musicmouse.service` for the unit template — **note its `ExecStart` path (`.../espmusicmouse/host_driver/main.py`) is stale**, referencing the pre-reorg directory layout from before the `bd8925a "Cleaned up repository"` commit moved things to `python-backend/`. Update that path before relying on the service file.
|
Or, with no hardware and no audio:
|
||||||
|
|
||||||
## Config schema (see `python-backend/config.yml.example`)
|
```sh
|
||||||
|
python -m musicmouse --config ./config.yml --simulate
|
||||||
|
```
|
||||||
|
|
||||||
- `general.{alsa_device, serial_port, hass_url, hass_token, mqtt.{server,user,password}, min_volume, max_volume, volume_increment, button_leds_brightness}`
|
The simulator runs the whole app against a fake serial link and a fake player, either
|
||||||
- `figures.<name>.{id, colors, media_files}` — `id` is a hex RFID tag id, `colors` is a list of 4 colors (`primary, secondary, background, accent`, each `"#rrggbb"` or `"wNN"`), `media_files` is optional (auto-globbed from the config dir by figure name if omitted).
|
interactively or from a scenario file. `python-backend/musicmouse.service` is the
|
||||||
|
systemd unit for the device.
|
||||||
|
|
||||||
## Known gaps / notes for agents
|
## Config
|
||||||
|
|
||||||
- **No automated tests, no lint/formatter config, no CI** anywhere in the repo (neither `python-backend/` nor `esp-firmware/`, aside from a PlatformIO `native` build env for firmware unit testing).
|
`python-backend/config.yml.example` documents the schema. In short:
|
||||||
- `python-backend/audio_analysis.py` and the three chord-recognition notebooks (`C5S2_ChordRec_Templates.ipynb`, `C5S3_ChordRec_HMM.ipynb`, `C5S3_HiddenMarkovModel.ipynb`) are university-course exploratory material (chroma/chord-recognition DSP), **not imported by `main.py`** and not part of the running app. They reference stale personal absolute paths.
|
|
||||||
|
- `general.{figure_folder, serial_port, baudrate, reconnect_interval, alsa_device,
|
||||||
|
min_volume, max_volume, initial_volume, volume_increment, button_leds_brightness,
|
||||||
|
audio_extensions, mqtt.*}`
|
||||||
|
- `figures.<name>.{id, colors}` — `id` is a 5-byte hex RFID tag, unique per figure;
|
||||||
|
`colors` is exactly four (`primary, secondary, bg, accent`), each `"#rrggbb"` or
|
||||||
|
`"wNN"`.
|
||||||
|
|
||||||
|
Each figure plays `<figure_folder>/<figure name>`, alphabetically by filename. Config is
|
||||||
|
validated with pydantic: unknown keys are errors, and every problem is reported at once.
|
||||||
|
|
||||||
|
## Notes for agents
|
||||||
|
|
||||||
|
- The backend has `pytest`, `ruff` and `mypy --strict` configured in
|
||||||
|
`python-backend/pyproject.toml`, and no CI. Run all three before proposing changes.
|
||||||
|
- Prefer adding a scenario in `python-backend/scenarios/` over a hand-rolled test when
|
||||||
|
the behaviour is end-to-end — those files are executed by the test suite.
|
||||||
|
- `python-backend/notebooks/` is university course material on chord recognition, not
|
||||||
|
part of the app.
|
||||||
|
- The firmware has no automated tests beyond a PlatformIO `native` env for LED effects.
|
||||||
|
|||||||
@@ -1,13 +0,0 @@
|
|||||||
# Put this into /etc/systemd/system/musicmouse.service
|
|
||||||
[Unit]
|
|
||||||
Description=Music Mouse RFID Music Player
|
|
||||||
After=multi-user.target
|
|
||||||
|
|
||||||
|
|
||||||
[Service]
|
|
||||||
Type=simple
|
|
||||||
Restart=always
|
|
||||||
ExecStart=/opt/musicmouse/venv/bin/python /opt/musicmouse/espmusicmouse/host_driver/main.py /media/musicmouse/
|
|
||||||
|
|
||||||
[Install]
|
|
||||||
WantedBy=multi-user.target
|
|
||||||
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
|
# python -m musicmouse --config /media/musicmouse/config.yml
|
||||||
# was previously documented. Copy this file to config.yml in the directory
|
|
||||||
# passed as the CLI argument to main.py, e.g.:
|
|
||||||
#
|
#
|
||||||
# python main.py /media/musicmouse/
|
# 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.
|
||||||
# main.py reads "<config_dir>/config.yml". Real credentials (hass_token,
|
# Keep the real config (with credentials) off the repo - on the device only.
|
||||||
# mqtt.password) should never be committed - keep the real config.yml
|
|
||||||
# outside the repo (e.g. only on the deployed device).
|
|
||||||
|
|
||||||
general:
|
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"
|
alsa_device: "softvol_effects"
|
||||||
|
|
||||||
# Serial port the ESP32 firmware is connected on.
|
# Volume, 0..100. min/max clamp everything, including the rotary encoder.
|
||||||
serial_port: "/dev/ttyUSB0"
|
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()).
|
# Backlight of the prev/next buttons while a figure is playing, 0..1.
|
||||||
hass_url: "http://homeassistant.local:8123"
|
button_leds_brightness: 0.5
|
||||||
hass_token: "REPLACE_WITH_LONG_LIVED_ACCESS_TOKEN"
|
|
||||||
|
|
||||||
# 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:
|
mqtt:
|
||||||
server: "homeassistant.local"
|
server: "homeassistant.local"
|
||||||
|
port: 1883
|
||||||
user: "musicmouse"
|
user: "musicmouse"
|
||||||
password: "REPLACE_WITH_MQTT_PASSWORD"
|
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).
|
# One entry per figurine. The key is the figure name and the subfolder name.
|
||||||
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).
|
|
||||||
figures:
|
figures:
|
||||||
fuchs:
|
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"
|
id: "04a1b2c3d4"
|
||||||
# Exactly 4 colors: [primary, secondary, background, accent].
|
# Exactly four colours: primary, secondary, background, accent.
|
||||||
# Accepted formats: "#rrggbb" (RGB hex) or "wNN" (white channel hex, e.g. "wff").
|
# Either "#rrggbb" (RGB) or "wNN" (white channel only, hex).
|
||||||
colors: ["#ff6600", "#ffcc00", "#331100", "#ffffff"]
|
colors: ["#ff6600", "#ffcc00", "#331100", "wff"]
|
||||||
# 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: []
|
|
||||||
|
|
||||||
eule:
|
eule:
|
||||||
id: "04b2c3d4e5"
|
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